From c7d5b94f07aa26a5e522fcd0700996dfea33e926 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 13:58:50 -0700 Subject: [PATCH 1/2] Add vernier to the development bundle Singed is about to support profiling with vernier as an optional dependency, so its specs and Sorbet need the gem. The RBI is generated with `bin/tapioca gems`. --- Gemfile | 1 + Gemfile.lock | 2 + sorbet/rbi/gems/vernier@1.11.0.rbi | 1072 ++++++++++++++++++++++++++++ sorbet/tapioca/require.rb | 1 + 4 files changed, 1076 insertions(+) create mode 100644 sorbet/rbi/gems/vernier@1.11.0.rbi diff --git a/Gemfile b/Gemfile index 1ed9480..f3715a8 100644 --- a/Gemfile +++ b/Gemfile @@ -18,3 +18,4 @@ gem "rubyzip" gem "sidekiq" gem "sorbet", require: false gem "tapioca", require: false +gem "vernier" diff --git a/Gemfile.lock b/Gemfile.lock index 605cce1..0d8b29f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -243,6 +243,7 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + vernier (1.11.0) zeitwerk (2.8.3) PLATFORMS @@ -266,6 +267,7 @@ DEPENDENCIES singed! sorbet tapioca + vernier BUNDLED WITH 4.0.15 diff --git a/sorbet/rbi/gems/vernier@1.11.0.rbi b/sorbet/rbi/gems/vernier@1.11.0.rbi new file mode 100644 index 0000000..de566dc --- /dev/null +++ b/sorbet/rbi/gems/vernier@1.11.0.rbi @@ -0,0 +1,1072 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `vernier` gem. +# Please instead update this file by running `bin/tapioca gem vernier`. + + +# pkg:gem/vernier#lib/vernier/version.rb:3 +module Vernier + class << self + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def memory_rss; end + + # pkg:gem/vernier#lib/vernier.rb:24 + def profile(mode: T.unsafe(nil), **collector_options); end + + # pkg:gem/vernier#lib/vernier.rb:40 + def run(mode: T.unsafe(nil), **collector_options); end + + # pkg:gem/vernier#lib/vernier.rb:45 + def start_profile(mode: T.unsafe(nil), **collector_options); end + + # pkg:gem/vernier#lib/vernier.rb:57 + def stop_profile; end + + # pkg:gem/vernier#lib/vernier.rb:39 + def trace(mode: T.unsafe(nil), **collector_options); end + + # pkg:gem/vernier#lib/vernier.rb:66 + def trace_retained(**profile_options, &block); end + end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +class Vernier::Collector + # pkg:gem/vernier#lib/vernier/collector.rb:114 + def initialize(mode, options = T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/collector.rb:166 + def add_marker(name:, start:, finish:, thread: T.unsafe(nil), phase: T.unsafe(nil), data: T.unsafe(nil)); end + + # Get the current time. + # + # This method returns the current time from Process.clock_gettime in + # integer nanoseconds. It's the same time used by Vernier internals and + # can be used to generate timestamps for custom markers. + # + # pkg:gem/vernier#lib/vernier/collector.rb:162 + def current_time; end + + # Record an interval with a category and name. Yields to a block and + # records the amount of time spent in the block as an interval marker. + # + # pkg:gem/vernier#lib/vernier/collector.rb:178 + def record_interval(category, name = T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/collector.rb:139 + def stack_table; end + + # pkg:gem/vernier#lib/vernier/collector.rb:191 + def stop; end + + private + + # pkg:gem/vernier#lib/vernier/collector.rb:141 + def add_hook(hook); end + + class << self + # pkg:gem/vernier#lib/vernier/collector.rb:99 + def new(mode, options = T.unsafe(nil)); end + end +end + +# pkg:gem/vernier#lib/vernier/collector.rb:8 +class Vernier::Collector::CustomCollector < ::Vernier::Collector + # pkg:gem/vernier#lib/vernier/collector.rb:9 + def initialize(mode, options); end + + # pkg:gem/vernier#lib/vernier/collector.rb:27 + def finish; end + + # pkg:gem/vernier#lib/vernier/collector.rb:19 + def sample; end + + # pkg:gem/vernier#lib/vernier/collector.rb:24 + def start; end +end + +# pkg:gem/vernier#lib/vernier/collector.rb:48 +class Vernier::Collector::RetainedCollector < ::Vernier::Collector + # pkg:gem/vernier#lib/vernier/collector.rb:49 + def initialize(mode, options); end + + # pkg:gem/vernier#lib/vernier/collector.rb:61 + def drain; end + + # pkg:gem/vernier#lib/vernier/collector.rb:65 + def finish; end + + # pkg:gem/vernier#lib/vernier/collector.rb:57 + def start; end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +class Vernier::Collector::TimeCollector < ::Vernier::Collector + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def start; end + + private + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def finish; end + + class << self + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def new(_arg0, _arg1); end + end +end + +# pkg:gem/vernier#lib/vernier.rb:20 +class Vernier::Error < ::StandardError; end + +# Plan: The heap tracker can be in a few states: +# * Idle +# * Collecting +# * Watching for new objects +# * Watching for freed objects +# * Draining +# * Ignoring new objects +# * Watching for freed objects +# * Locked +# * Ignoring new objects +# * Ignoring freed objects +# * Marking all existing objects (not yet implemented) +# * N.B. This prevents any objects which the tracker has seen from being GC'd +# +# pkg:gem/vernier#lib/vernier/marker.rb:3 +class Vernier::HeapTracker + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def allocated_objects; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def collect; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def data; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def drain; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def freed_objects; end + + # pkg:gem/vernier#lib/vernier/heap_tracker.rb:24 + def inspect; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def lock; end + + # pkg:gem/vernier#lib/vernier/heap_tracker.rb:41 + def stack(obj); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def stack_idx(_arg0); end + + # pkg:gem/vernier#lib/vernier/heap_tracker.rb:18 + def stack_table; end + + # pkg:gem/vernier#lib/vernier/heap_tracker.rb:34 + def track; end + + class << self + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def _new(_arg0); end + + # pkg:gem/vernier#lib/vernier/heap_tracker.rb:20 + def new(stack_table = T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/heap_tracker.rb:28 + def track(&block); end + end +end + +# pkg:gem/vernier#lib/vernier/hooks.rb:4 +module Vernier::Hooks; end + +# pkg:gem/vernier#lib/vernier/hooks/active_support.rb:5 +class Vernier::Hooks::ActiveSupport + # pkg:gem/vernier#lib/vernier/hooks/active_support.rb:155 + def initialize(collector); end + + # pkg:gem/vernier#lib/vernier/hooks/active_support.rb:182 + def disable; end + + # pkg:gem/vernier#lib/vernier/hooks/active_support.rb:159 + def enable; end + + # pkg:gem/vernier#lib/vernier/hooks/active_support.rb:187 + def firefox_marker_schema; end +end + +# pkg:gem/vernier#lib/vernier/hooks/active_support.rb:6 +Vernier::Hooks::ActiveSupport::FIREFOX_MARKER_SCHEMA = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/hooks/active_support.rb:148 +Vernier::Hooks::ActiveSupport::SERIALIZED_KEYS = T.let(T.unsafe(nil), Hash) + +# pkg:gem/vernier#lib/vernier/hooks/memory_usage.rb:5 +class Vernier::Hooks::MemoryUsage + # pkg:gem/vernier#lib/vernier/hooks/memory_usage.rb:6 + def initialize(collector); end + + # pkg:gem/vernier#lib/vernier/hooks/memory_usage.rb:15 + def disable; end + + # pkg:gem/vernier#lib/vernier/hooks/memory_usage.rb:11 + def enable; end + + # pkg:gem/vernier#lib/vernier/hooks/memory_usage.rb:19 + def firefox_counters; end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +module Vernier::Marker + class << self + # Return an array of marker names. The index of the string maps to the + # value of the corresponding constant + # + # pkg:gem/vernier#lib/vernier/marker.rb:36 + def name_table; end + end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:13 +Vernier::Marker::MARKER_STRINGS = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/marker.rb:7 +Vernier::Marker::MARKER_SYMBOLS = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +module Vernier::Marker::Phase; end + +Vernier::Marker::Phase::INSTANT = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Phase::INTERVAL = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Phase::INTERVAL_END = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Phase::INTERVAL_START = T.let(T.unsafe(nil), Integer) + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +module Vernier::Marker::Type; end + +Vernier::Marker::Type::FIBER_SWITCH = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GC_END_MARK = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GC_END_SWEEP = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GC_ENTER = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GC_EXIT = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GC_PAUSE = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GC_START = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GVL_THREAD_EXITED = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::GVL_THREAD_STARTED = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::THREAD_RUNNING = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::THREAD_STALLED = T.let(T.unsafe(nil), Integer) +Vernier::Marker::Type::THREAD_SUSPENDED = T.let(T.unsafe(nil), Integer) + +# pkg:gem/vernier#lib/vernier/memory_leak_detector.rb:4 +class Vernier::MemoryLeakDetector + # pkg:gem/vernier#lib/vernier/memory_leak_detector.rb:11 + def initialize(collect_time:, idle_time: T.unsafe(nil), drain_time: T.unsafe(nil), **collector_options); end + + # pkg:gem/vernier#lib/vernier/memory_leak_detector.rb:36 + def result; end + + # pkg:gem/vernier#lib/vernier/memory_leak_detector.rb:19 + def start_thread; end + + class << self + # pkg:gem/vernier#lib/vernier/memory_leak_detector.rb:5 + def start_thread(*, **, &); end + end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +class Vernier::MemoryTracker + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def record; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def results; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def start; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def stop; end +end + +# pkg:gem/vernier#lib/vernier/middleware.rb:2 +class Vernier::Middleware + # pkg:gem/vernier#lib/vernier/middleware.rb:8 + def initialize(app, permit: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/middleware.rb:13 + def call(env); end +end + +# pkg:gem/vernier#lib/vernier/middleware.rb:3 +Vernier::Middleware::HOOKS = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/output/filename_filter.rb:4 +module Vernier::Output; end + +# pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:7 +class Vernier::Output::Cpuprofile + include ::Vernier::OutputHelpers + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:10 + def initialize(profile); end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:14 + def output; end + + private + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:57 + def build_nodes; end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:134 + def calculate_time_deltas(timestamps); end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:89 + def create_node_for_stack(stack_idx, nodes, stack_table); end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:26 + def data; end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:47 + def empty_profile; end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:22 + def ns_to_us(timestamp); end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:20 + def profile; end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:74 + def root_node; end + + # pkg:gem/vernier#lib/vernier/output/cpuprofile.rb:130 + def stack_to_node_id(stack_idx); end +end + +# pkg:gem/vernier#lib/vernier/output/file_listing.rb:9 +class Vernier::Output::FileListing + include ::Vernier::OutputHelpers + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:19 + def initialize(profile); end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:134 + def format_file(output, filename, all_samples, total:); end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:163 + def format_file_html(output, filename, relevant_files); end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:151 + def html_output(output, relevant_files); end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:107 + def output(template: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:23 + def samples_by_file; end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:130 + def total; end + + private + + # Returns [self_weight, total_weight] arrays indexed by frame, from a + # hash of per-stack weights. + # + # The stack table is a prefix tree whose parent is always interned + # before its children (parent idx < child idx), so total weights are + # accumulated bottom-up in a single reverse pass. + # + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:73 + def frame_weights(stack_table, stack_weights); end +end + +# pkg:gem/vernier#lib/vernier/output/file_listing.rb:12 +class Vernier::Output::FileListing::SamplesByLocation + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:14 + def initialize; end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:13 + def self; end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:13 + def self=(_arg0); end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:13 + def total; end + + # pkg:gem/vernier#lib/vernier/output/file_listing.rb:13 + def total=(_arg0); end +end + +# pkg:gem/vernier#lib/vernier/output/filename_filter.rb:5 +class Vernier::Output::FilenameFilter + # pkg:gem/vernier#lib/vernier/output/filename_filter.rb:6 + def initialize; end + + # pkg:gem/vernier#lib/vernier/output/filename_filter.rb:15 + def call(filename); end + + # pkg:gem/vernier#lib/vernier/output/filename_filter.rb:13 + def gem_match_regex; end + + # pkg:gem/vernier#lib/vernier/output/filename_filter.rb:13 + def gem_regex; end + + # pkg:gem/vernier#lib/vernier/output/filename_filter.rb:13 + def pwd; end + + # pkg:gem/vernier#lib/vernier/output/filename_filter.rb:13 + def rubylibdir; end +end + +# https://profiler.firefox.com/ +# https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.ts +# +# pkg:gem/vernier#lib/vernier/output/firefox.rb:13 +class Vernier::Output::Firefox + # pkg:gem/vernier#lib/vernier/output/firefox.rb:118 + def initialize(profile); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:123 + def output(gzip: T.unsafe(nil)); end + + private + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:195 + def counter_data; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:136 + def data; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:203 + def marker_schema; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:134 + def profile; end +end + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:14 +class Vernier::Output::Firefox::Categorizer + # pkg:gem/vernier#lib/vernier/output/firefox.rb:26 + def initialize; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:70 + def add_category(name:, **kw); end + + # This is in the order of preference + # + # pkg:gem/vernier#lib/vernier/output/firefox.rb:24 + def categories; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:91 + def categorize(path); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:87 + def gem_path(*names); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:79 + def get_category(name); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:83 + def starts_with(*paths); end +end + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:19 +Vernier::Output::Firefox::Categorizer::AVAILABLE_COLORS = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:95 +class Vernier::Output::Firefox::Categorizer::Category + # pkg:gem/vernier#lib/vernier/output/firefox.rb:98 + def initialize(idx, name:, color:, matcher: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:108 + def add_subcategory(**args); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:96 + def color; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:96 + def idx; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:96 + def matcher; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:112 + def matches?(path); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:96 + def name; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:96 + def subcategories; end +end + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:22 +Vernier::Output::Firefox::Categorizer::ORDERED_CATEGORIES = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:15 +Vernier::Output::Firefox::Categorizer::RAILS_COMPONENTS = T.let(T.unsafe(nil), Array) + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:270 +class Vernier::Output::Firefox::Thread + include ::Vernier::OutputHelpers + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:280 + def initialize(ruby_thread_id, profile, categorizer, name:, tid:, samples:, weights:, markers:, started_at:, timestamps: T.unsafe(nil), sample_categories: T.unsafe(nil), stopped_at: T.unsafe(nil), allocations: T.unsafe(nil), is_main: T.unsafe(nil), is_start: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:498 + def allocations_table; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:393 + def categorize_filename(filename); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:402 + def cfunc_category_and_subcategory; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:426 + def data; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:419 + def filter_filenames(filenames); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:410 + def find_category_and_subcategory(filename, categories); end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:580 + def frame_table; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:617 + def func_table; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:278 + def is_start; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:458 + def markers_table; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:278 + def profile; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:406 + def ruby_category_and_subcategory; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:518 + def samples_table; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:554 + def stack_table; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:642 + def string_table; end + + private + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:650 + def gc_category; end + + # pkg:gem/vernier#lib/vernier/output/firefox.rb:654 + def thread_category; end +end + +# pkg:gem/vernier#lib/vernier/output/firefox.rb:273 +Vernier::Output::Firefox::Thread::SAMPLE_CATEGORY_NAMES = T.let(T.unsafe(nil), Hash) + +# pkg:gem/vernier#lib/vernier/output/markdown.rb:7 +class Vernier::Output::Markdown + # pkg:gem/vernier#lib/vernier/output/markdown.rb:11 + def initialize(profile, top_n: T.unsafe(nil), lines_per_file: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:17 + def output; end + + private + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:185 + def build_files; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:79 + def build_hotspots; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:33 + def build_summary; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:156 + def build_threads; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:29 + def build_title; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:406 + def escape_markdown(text); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:339 + def filter_filename(filename); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:392 + def format_code_span(text); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:353 + def format_location(file, line); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:293 + def get_stack_table(thread); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:309 + def get_thread_main(thread); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:301 + def get_thread_name(thread); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:317 + def get_thread_tid(thread); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:277 + def get_threads; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:348 + def live_profile?; end + + # Helper methods + # + # pkg:gem/vernier#lib/vernier/output/markdown.rb:273 + def main_thread; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:359 + def read_source_line(filename, line_no); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:372 + def resolve_filename(filename); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:334 + def thread_count; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:134 + def top_functions_table(title, funcs, total, sort_key); end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:325 + def total_samples; end + + # pkg:gem/vernier#lib/vernier/output/markdown.rb:383 + def truncate_code(code, max_length: T.unsafe(nil)); end +end + +# pkg:gem/vernier#lib/vernier/output/markdown.rb:9 +Vernier::Output::Markdown::DEFAULT_LINES_PER_FILE = T.let(T.unsafe(nil), Integer) + +# pkg:gem/vernier#lib/vernier/output/markdown.rb:8 +Vernier::Output::Markdown::DEFAULT_TOP_N = T.let(T.unsafe(nil), Integer) + +# pkg:gem/vernier#lib/vernier/output/top.rb:7 +class Vernier::Output::Top + include ::Vernier::OutputHelpers + + # pkg:gem/vernier#lib/vernier/output/top.rb:10 + def initialize(profile, row_limit); end + + # pkg:gem/vernier#lib/vernier/output/top.rb:55 + def output; end +end + +# pkg:gem/vernier#lib/vernier/output/top.rb:15 +class Vernier::Output::Top::Table + # pkg:gem/vernier#lib/vernier/output/top.rb:16 + def initialize(header, row_limit); end + + # pkg:gem/vernier#lib/vernier/output/top.rb:23 + def <<(row); end + + # pkg:gem/vernier#lib/vernier/output/top.rb:50 + def format_row(row); end + + # pkg:gem/vernier#lib/vernier/output/top.rb:46 + def row_separator; end + + # pkg:gem/vernier#lib/vernier/output/top.rb:27 + def to_s; end + + # pkg:gem/vernier#lib/vernier/output/top.rb:39 + def widths; end +end + +# pkg:gem/vernier#lib/vernier/output_helpers.rb:4 +module Vernier::OutputHelpers + # Collapses parallel samples/weights arrays into a hash of total weight + # per stack index. Retained-mode profiles have one sample per object and + # many samples share the same stack, so collapsing first keeps later + # aggregation proportional to the number of unique stacks. + # + # pkg:gem/vernier#lib/vernier/output_helpers.rb:9 + def collapse_stack_weights(samples, weights); end + + # Returns a string safe to embed in JSON output: valid UTF-8 with any + # invalid bytes replaced. Method names and paths can carry arbitrary + # encodings (e.g. BINARY or Shift_JIS source), which would otherwise + # raise JSON::GeneratorError. + # + # pkg:gem/vernier#lib/vernier/output_helpers.rb:21 + def sanitize_string(string); end +end + +# pkg:gem/vernier#lib/vernier/parsed_profile.rb:7 +class Vernier::ParsedProfile + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:87 + def initialize(data); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:86 + def data; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:98 + def main_thread; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:91 + def threads; end + + class << self + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:8 + def read_file(filename); end + end +end + +# pkg:gem/vernier#lib/vernier/parsed_profile.rb:24 +class Vernier::ParsedProfile::StackTable + include ::Vernier::StackTableHelpers + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:25 + def initialize(thread_data); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:39 + def frame_count; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:45 + def frame_func_idx(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:46 + def frame_line_no(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:40 + def func_count; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:51 + def func_filename(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:49 + def func_filename_idx(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:52 + def func_first_lineno(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:50 + def func_name(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:48 + def func_name_idx(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:38 + def stack_count; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:43 + def stack_frame_idx(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:42 + def stack_parent_idx(idx); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:36 + def strings; end +end + +# pkg:gem/vernier#lib/vernier/parsed_profile.rb:57 +class Vernier::ParsedProfile::Thread + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:60 + def initialize(data); end + + # Emulate hash + # + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:81 + def [](name); end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:58 + def data; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:68 + def main_thread?; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:72 + def samples; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:64 + def stack_table; end + + # pkg:gem/vernier#lib/vernier/parsed_profile.rb:76 + def weights; end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +class Vernier::Result + # pkg:gem/vernier#lib/vernier/result.rb:6 + def _stack_table; end + + # pkg:gem/vernier#lib/vernier/result.rb:70 + def each_sample; end + + # pkg:gem/vernier#lib/vernier/result.rb:62 + def elapsed_seconds; end + + # pkg:gem/vernier#lib/vernier/result.rb:8 + def end_time; end + + # pkg:gem/vernier#lib/vernier/result.rb:8 + def end_time=(_arg0); end + + # pkg:gem/vernier#lib/vernier/result.rb:10 + def gc_markers; end + + # pkg:gem/vernier#lib/vernier/result.rb:8 + def hooks; end + + # pkg:gem/vernier#lib/vernier/result.rb:8 + def hooks=(_arg0); end + + # pkg:gem/vernier#lib/vernier/result.rb:66 + def inspect; end + + # pkg:gem/vernier#lib/vernier/result.rb:12 + def main_thread; end + + # pkg:gem/vernier#lib/vernier/result.rb:10 + def meta; end + + # pkg:gem/vernier#lib/vernier/result.rb:8 + def pid; end + + # pkg:gem/vernier#lib/vernier/result.rb:8 + def pid=(_arg0); end + + # pkg:gem/vernier#lib/vernier/result.rb:79 + def stack(idx); end + + # pkg:gem/vernier#lib/vernier/result.rb:5 + def stack_table; end + + # pkg:gem/vernier#lib/vernier/result.rb:5 + def stack_table=(_arg0); end + + # Realtime in nanoseconds since the unix epoch + # + # pkg:gem/vernier#lib/vernier/result.rb:17 + def started_at; end + + # pkg:gem/vernier#lib/vernier/result.rb:10 + def threads; end + + # pkg:gem/vernier#lib/vernier/result.rb:29 + def to_cpuprofile; end + + # pkg:gem/vernier#lib/vernier/result.rb:24 + def to_firefox(gzip: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/result.rb:27 + def to_gecko(gzip: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/result.rb:33 + def to_markdown(top_n: T.unsafe(nil), lines_per_file: T.unsafe(nil)); end + + # pkg:gem/vernier#lib/vernier/result.rb:87 + def total_bytes; end + + # pkg:gem/vernier#lib/vernier/result.rb:95 + def total_samples; end + + # pkg:gem/vernier#lib/vernier/result.rb:99 + def total_unique_samples; end + + # pkg:gem/vernier#lib/vernier/result.rb:83 + def total_weights; end + + # pkg:gem/vernier#lib/vernier/result.rb:37 + def write(out:, format: T.unsafe(nil)); end +end + +# pkg:gem/vernier#lib/vernier/marker.rb:3 +class Vernier::StackTable + include ::Vernier::StackTableHelpers + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def convert(_arg0, _arg1); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def current_stack(*_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def finalize; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def frame_count; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def frame_func_idx(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def frame_line_no(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def func_absolute_path(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def func_count; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def func_filename(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def func_first_lineno(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def func_name(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def func_path(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def stack_count; end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def stack_frame_idx(_arg0); end + + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def stack_parent_idx(_arg0); end + + class << self + # pkg:gem/vernier#lib/vernier/marker.rb:3 + def new; end + end +end + +# pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:2 +module Vernier::StackTableHelpers + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:25 + def backtrace(stack_idx); end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:43 + def full_stack(stack_idx); end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:3 + def inspect; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:149 + def stack(idx); end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:7 + def to_h; end +end + +# pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:52 +class Vernier::StackTableHelpers::BaseType + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:54 + def initialize(stack_table, idx); end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:53 + def idx; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:59 + def inspect; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:53 + def stack_table; end +end + +# pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:79 +class Vernier::StackTableHelpers::Frame < ::Vernier::StackTableHelpers::BaseType + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:81 + def filename; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:84 + def func; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:80 + def label; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:89 + def line; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:92 + def lineno; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:82 + def name; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:94 + def to_s; end +end + +# pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:64 +class Vernier::StackTableHelpers::Func < ::Vernier::StackTableHelpers::BaseType + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:70 + def filename; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:65 + def label; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:68 + def name; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:74 + def to_s; end +end + +# pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:103 +class Vernier::StackTableHelpers::Stack < ::Vernier::StackTableHelpers::BaseType + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:116 + def [](offset); end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:104 + def each; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:114 + def each_frame; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:136 + def frames; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:132 + def leaf_frame; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:128 + def leaf_frame_idx; end + + # pkg:gem/vernier#lib/vernier/stack_table_helpers.rb:140 + def to_s; end +end + +# Collects names of all seen threads +# +# pkg:gem/vernier#lib/vernier/thread_names.rb:3 +class Vernier::ThreadNames + # pkg:gem/vernier#lib/vernier/thread_names.rb:4 + def initialize; end + + # pkg:gem/vernier#lib/vernier/thread_names.rb:12 + def [](object_id); end + + # pkg:gem/vernier#lib/vernier/thread_names.rb:16 + def finish; end + + private + + # pkg:gem/vernier#lib/vernier/thread_names.rb:23 + def collect_running; end + + # pkg:gem/vernier#lib/vernier/thread_names.rb:29 + def collect_thread(th); end + + # pkg:gem/vernier#lib/vernier/thread_names.rb:33 + def pretty_name(thread); end +end + +# pkg:gem/vernier#lib/vernier/version.rb:4 +Vernier::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/tapioca/require.rb b/sorbet/tapioca/require.rb index dd275e6..7519520 100644 --- a/sorbet/tapioca/require.rb +++ b/sorbet/tapioca/require.rb @@ -14,4 +14,5 @@ require "stackprof" require "tempfile" require "tmpdir" +require "vernier" require "zeitwerk" From dd2d2dc779fb4370d1a7ca4efc4c929e2a89eead Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 13:58:50 -0700 Subject: [PATCH 2/2] Support profiling with vernier Closes #26. Vernier samples each thread separately, so a flamegraph from a multi-threaded app isn't a mix of what all its threads were doing. Pass `profiler: :vernier` to `flamegraph`, `Singed.start` or a controller's `flamegraph`, or set `Singed.profiler = :vernier` to make it the default for every integration. stackprof stays the default. Vernier isn't a dependency of the gem: it's a native extension that only some apps want, so Singed requires it when asked to use it and explains what's missing if it can't. Singed::VernierReport converts Vernier's result to speedscope's file format, with a profile per thread, so flamegraphs still open in the bundled speedscope. Samples from threads that were waiting get an `(idle)` or `(waiting for GVL)` frame on top, rather than looking like time spent in the code that started waiting. --- README.md | 37 +++++++- lib/singed.rb | 19 +++- lib/singed/controller_ext.rb | 6 +- lib/singed/flamegraph.rb | 81 ++++++++++++++--- lib/singed/kernel_ext.rb | 5 +- lib/singed/vernier_report.rb | 122 ++++++++++++++++++++++++++ spec/singed/controller_ext_spec.rb | 4 +- spec/singed/flamegraph_spec.rb | 87 ++++++++++++++++++ spec/singed/kernel_ext_spec.rb | 6 +- spec/singed/vernier_report_spec.rb | 136 +++++++++++++++++++++++++++++ spec/singed_spec.rb | 27 ++++++ 11 files changed, 506 insertions(+), 24 deletions(-) create mode 100644 lib/singed/vernier_report.rb create mode 100644 spec/singed/flamegraph_spec.rb create mode 100644 spec/singed/vernier_report_spec.rb diff --git a/README.md b/README.md index 029e2f7..28832c6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Singed -Singed makes it easy to get a flamegraph anywhere in your code base. It wraps profiling your code with [stackprof](https://github.com/tmm1/stackprof) or [rbspy](https://github.com/rbspy/rbspy), and then launching [speedscope](https://github.com/jlfwong/speedscope) to view it. +Singed makes it easy to get a flamegraph anywhere in your code base. It wraps profiling your code with [stackprof](https://github.com/tmm1/stackprof), [vernier](https://github.com/jhawthorn/vernier) or [rbspy](https://github.com/rbspy/rbspy), and then launching [speedscope](https://github.com/jlfwong/speedscope) to view it. ## Installation @@ -66,6 +66,40 @@ flamegraph.open Note that `Singed.start` can't be run multiple times in parallel, instantiate multiple `Singed::Flamegraph` objects instead and call `start` on them. +### Vernier + +Singed profiles with stackprof by default. [Vernier](https://github.com/jhawthorn/vernier) profiles each thread separately instead, so a flamegraph from a multi-threaded app like Puma or Sidekiq isn't a mix of what all its threads were doing. Singed doesn't depend on vernier, so add it (1.5 or newer) to your Gemfile: + +```ruby +gem "vernier" +``` + +Then ask for it when capturing a flamegraph. `Singed.start` and controllers' `flamegraph` take `profiler:` too: + +```ruby +flamegraph(profiler: :vernier) { + # your code here +} +``` + +Or make it the default, which the RSpec, controller, Rack and Sidekiq integrations below then use as well: + +```ruby +Singed.profiler = :vernier +``` + +That loads vernier straight away, so a missing or outdated gem fails at boot. If vernier is only in some of your Gemfile's groups, set this only in the environments that load them, e.g. in `config/environments/development.rb`. + +speedscope then gets a profile per thread, and opens on the thread that ran your code. Pick another thread from its title bar, or step through them with `n` and `p`. Vernier keeps sampling threads that are waiting, so their stacks end in `(idle)` while sleeping or waiting on I/O or a lock, and in `(waiting for GVL)` while another thread holds the GVL. + +Vernier doesn't sample a thread while it's running garbage collection, so `ignore_gc` makes no difference with it. The `singed` command line always uses rbspy. + +The flamegraph's `profile` is Vernier's own result, which you can also save for [vernier.prof](https://vernier.prof) to show GVL and GC activity alongside the flamegraph: + +```ruby +Singed.stop.profile.write(out: "tmp/profile.vernier.json.gz") +``` + ### RSpec If you are using RSpec, you can use the `flamegraph` metadata to capture it for you. @@ -173,3 +207,4 @@ The `open` command is expected to be available. - using [rbspy](https://rbspy.github.io/) directly - using [stackprof](https://github.com/tmm1/stackprof) (a dependency of singed) directly +- using [vernier](https://github.com/jhawthorn/vernier) directly diff --git a/lib/singed.rb b/lib/singed.rb index e3fa6a5..f4ab2a2 100644 --- a/lib/singed.rb +++ b/lib/singed.rb @@ -33,6 +33,18 @@ def enabled? @enabled = true end + # Which profiler records flamegraphs that aren't given one: :stackprof or :vernier. + #: (Symbol) -> void + def profiler=(profiler) + Flamegraph.load_profiler(profiler) + @profiler = profiler #: Symbol? + end + + #: () -> Symbol + def profiler + @profiler || :stackprof + end + # Not ActiveSupport::BacktraceCleaner: apps' Tapioca evaluates these sigs even when ActiveSupport isn't loaded. #: (untyped) -> void def backtrace_cleaner=(backtrace_cleaner) @@ -60,12 +72,12 @@ def filter_line(line) line end - #: (?String?, ?ignore_gc: bool, ?interval: Integer) -> Flamegraph? - def start(label = nil, ignore_gc: false, interval: 1000) + #: (?String?, ?ignore_gc: bool, ?interval: Integer, ?profiler: Symbol?) -> Flamegraph? + def start(label = nil, ignore_gc: false, interval: 1000, profiler: nil) return unless enabled? return if profiling? - @current_flamegraph = Flamegraph.new(label:, ignore_gc:, interval:) + @current_flamegraph = Flamegraph.new(label:, ignore_gc:, interval:, profiler:) @current_flamegraph.tap(&:start) end @@ -90,6 +102,7 @@ def profiling? autoload :Report, "singed/report" autoload :RackMiddleware, "singed/rack_middleware" autoload :Speedscope, "singed/speedscope" + autoload :VernierReport, "singed/vernier_report" end require "singed/kernel_ext" diff --git a/lib/singed/controller_ext.rb b/lib/singed/controller_ext.rb index b5a7c9e..816d385 100644 --- a/lib/singed/controller_ext.rb +++ b/lib/singed/controller_ext.rb @@ -11,10 +11,10 @@ module ControllerExt # @requires_ancestor: AbstractController::Callbacks::ClassMethods module ClassMethods # Define an around_action to generate flamegraph for a controller action. - #: (Symbol | String | Array[Symbol | String], ?ignore_gc: bool, ?interval: Integer) -> void - def flamegraph(target_action, ignore_gc: false, interval: 1000) + #: (Symbol | String | Array[Symbol | String], ?ignore_gc: bool, ?interval: Integer, ?profiler: Symbol?) -> void + def flamegraph(target_action, ignore_gc: false, interval: 1000, profiler: nil) around_action(only: target_action) do |controller, action| - controller.flamegraph(ignore_gc:, interval:, &action) + controller.flamegraph(ignore_gc:, interval:, profiler:, &action) end end end diff --git a/lib/singed/flamegraph.rb b/lib/singed/flamegraph.rb index de370c4..b17d812 100644 --- a/lib/singed/flamegraph.rb +++ b/lib/singed/flamegraph.rb @@ -3,15 +3,24 @@ module Singed class Flamegraph - # The StackProf.results hash; its values vary by key. - #: Hash[Symbol, untyped]? + PROFILERS = [:stackprof, :vernier].freeze + # The first with Vernier::Result#stack_table. + MINIMUM_VERNIER_VERSION = "1.5" + + # The StackProf.results hash, whose values vary by key, or a Vernier::Result when profiling with Vernier. + # Not typed as Vernier::Result: apps' Tapioca evaluates this sig even when Vernier isn't loaded. + #: untyped attr_accessor :profile #: Pathname attr_accessor :filename - #: (?label: String?, ?ignore_gc: bool, ?interval: Integer, ?filename: Pathname?) -> void - def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil) + # nil when wrapping an existing file. + #: Symbol? + attr_reader :profiler + + #: (?label: String?, ?ignore_gc: bool, ?interval: Integer, ?profiler: Symbol?, ?filename: Pathname?) -> void + def initialize(label: nil, ignore_gc: false, interval: 1000, profiler: nil, filename: nil) # it's been created elsewhere, ie rbspy if filename if ignore_gc @@ -22,9 +31,17 @@ def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil) raise ArgumentError, "label not supported when given an existing file" end + if profiler + raise ArgumentError, "profiler not supported when given an existing file" + end + @filename = filename #: Pathname else + profiler ||= Singed.profiler + self.class.load_profiler(profiler) + # Nilable because they stay unset when wrapping an existing file, and #start still reads them. + @profiler = profiler #: Symbol? @ignore_gc = ignore_gc #: bool? @interval = interval #: Integer? @time = Time.now #: Time @@ -46,17 +63,28 @@ def start return false if filename.exist? # file existing means its been captured already return false if started? - StackProf.start(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) + if vernier? + # A collector per flamegraph, rather than Vernier.start_profile, which raises if a profile is already running. + # There's no ignore_gc to pass: Vernier doesn't sample a thread while it's running GC. + @collector = Vernier::Collector.new(:wall, interval: @interval) #: untyped + @collector.start + else + StackProf.start(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) + end @started = true end - #: () -> Hash[Symbol, untyped]? + #: () -> untyped def stop return nil unless started? @started = false #: bool? - StackProf.stop - @profile = StackProf.results + if vernier? + @profile = @collector.stop + else + StackProf.stop + @profile = StackProf.results + end end #: () -> bool @@ -70,8 +98,12 @@ def save raise ArgumentError, "File #{filename} already exists" end - report = Singed::Report.new(@profile) - report.filter! + if vernier? + report = Singed::VernierReport.new(@profile) + else + report = Singed::Report.new(@profile) + report.filter! + end filename.dirname.mkpath filename.open("w") { |f| report.print_json(f) } end @@ -99,5 +131,34 @@ def self.generate_filename(label: nil, time: Time.now) file = file.relative_path_from(pwd) if file.absolute? && file.to_s.start_with?(pwd.to_s) file end + + # Raises unless Singed supports the profiler. Requires vernier, which Singed doesn't depend on, when it's the one asked for. + #: (Symbol) -> void + def self.load_profiler(profiler) + unless PROFILERS.include?(profiler) + raise ArgumentError, "Unsupported profiler #{profiler.inspect}, expected one of #{PROFILERS.inspect}" + end + return unless profiler == :vernier + + begin + require "vernier" + rescue LoadError => e + # Other paths mean vernier is installed but broken, e.g. its native extension didn't load. + raise unless e.path == "vernier" + + raise LoadError, "Profiling with vernier needs the vernier gem in your bundle (#{e.message})" + end + + if Gem::Version.new(Vernier::VERSION) < Gem::Version.new(MINIMUM_VERNIER_VERSION) + raise LoadError, "Profiling with vernier needs vernier #{MINIMUM_VERNIER_VERSION} or newer, not #{Vernier::VERSION}" + end + end + + private + + #: () -> bool + def vernier? + @profiler == :vernier + end end end diff --git a/lib/singed/kernel_ext.rb b/lib/singed/kernel_ext.rb index 7bca402..ae1c619 100644 --- a/lib/singed/kernel_ext.rb +++ b/lib/singed/kernel_ext.rb @@ -7,10 +7,11 @@ module Kernel #| ?open: bool, #| ?ignore_gc: bool, #| ?interval: Integer, + #| ?profiler: Symbol?, #| ?io: IO | StringIO #| ) { () -> Result } -> Result - def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, io: $stdout, &block) - fg = Singed::Flamegraph.new(label:, ignore_gc:, interval:) + def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, profiler: nil, io: $stdout, &block) # rubocop:disable Metrics/ParameterLists -- all optional keywords + fg = Singed::Flamegraph.new(label:, ignore_gc:, interval:, profiler:) result = fg.record(&block) fg.save diff --git a/lib/singed/vernier_report.rb b/lib/singed/vernier_report.rb new file mode 100644 index 0000000..46fee39 --- /dev/null +++ b/lib/singed/vernier_report.rb @@ -0,0 +1,122 @@ +# typed: strict +# frozen_string_literal: true + +module Singed + # Converts a Vernier::Result to speedscope's file format, with a profile for each thread: + # https://github.com/jlfwong/speedscope/blob/v1.24.0/src/lib/file-format-spec.ts + class VernierReport + # Vernier keeps sampling threads that are waiting, and categorizes those samples. Topping their + # stacks with one of these frames keeps waiting from reading as time spent running Ruby code. + CATEGORY_FRAMES = { + 1 => { name: "(idle)" }, # sleeping, or waiting on I/O or a lock + 2 => { name: "(waiting for GVL)" }, # ready to run, but another thread holds the GVL + }.freeze #: Hash[Integer, Hash[Symbol, String]] + + # Not Vernier::Result: apps' Tapioca evaluates these sigs even when Vernier isn't loaded. + #: (untyped) -> void + def initialize(result) + @result = result + @frames = [] #: Array[Hash[Symbol, untyped]] + @func_frame_indexes = {} #: Hash[Integer, Integer] + @category_frame_indexes = {} #: Hash[Integer, Integer] + @stacks = {} #: Hash[[Integer, Integer], Array[Integer]] + end + + #: (IO | StringIO) -> void + def print_json(io) + io.write(JSON.generate(to_h)) + end + + #: () -> Hash[Symbol, untyped] + def to_h + interval = @result.meta.fetch(:interval) + # Threads that never ran while profiling have no samples, so would only add empty profiles. + threads = @result.threads.values.select { |thread| thread[:is_start] || thread[:samples].any? } + profiles = threads.map { |thread| profile(thread, interval) } + + { + "$schema": "https://www.speedscope.app/file-format-schema.json", + shared: { frames: @frames }, + profiles:, + # The thread that started profiling is the one that ran the profiled code. + activeProfileIndex: threads.index { |thread| thread[:is_start] }, + } + end + + private + + #: (Hash[Symbol, untyped], Integer) -> Hash[Symbol, untyped] + def profile(thread, interval) + samples = thread[:samples].zip(thread[:sample_categories]).map do |stack_idx, category| + stack(stack_idx, category) + end + # Vernier merges consecutive samples of the same stack into one, counting them in its weight. + weights = thread[:weights].map { |weight| weight * interval } + + { + type: "sampled", + name: utf8(thread[:name]), + unit: "microseconds", + startValue: 0, + endValue: weights.sum, + samples:, + weights:, + } + end + + # Vernier links each stack to its parent, but speedscope lists a stack's frames from the root. + #: (Integer, Integer) -> Array[Integer] + def stack(stack_idx, category) + @stacks[[stack_idx, category]] ||= begin + frames = [] #: Array[Integer] + idx = stack_idx #: Integer? + while idx + frames << func_frame_index(stack_table.frame_func_idx(stack_table.stack_frame_idx(idx))) + idx = stack_table.stack_parent_idx(idx) + end + frames.reverse! + frames << category_frame_index(category) if CATEGORY_FRAMES.key?(category) + frames + end + end + + # One frame per method rather than per line, so each method is a single box in the flamegraph. + #: (Integer) -> Integer + def func_frame_index(func_idx) + @func_frame_indexes[func_idx] ||= begin + frame = { + name: utf8(stack_table.func_name(func_idx)), + file: Singed.filter_line(utf8(stack_table.func_filename(func_idx))), + } #: Hash[Symbol, untyped] + line = stack_table.func_first_lineno(func_idx) + frame[:line] = line if line.positive? # C functions have no line + add_frame(frame) + end + end + + #: (Integer) -> Integer + def category_frame_index(category) + @category_frame_indexes[category] ||= add_frame(CATEGORY_FRAMES.fetch(category)) + end + + #: (Hash[Symbol, untyped]) -> Integer + def add_frame(frame) + @frames << frame + @frames.size - 1 + end + + # JSON needs valid UTF-8. Vernier guesses its stack table's strings are UTF-8, so they may need scrubbing: + # https://github.com/jhawthorn/vernier/blob/v1.11.0/ext/vernier/stack_table.cc#L179-L191 + # It names threads that have no name after Thread#inspect, which is binary. + #: (String) -> String + def utf8(string) + string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY + string.scrub + end + + #: () -> untyped + def stack_table + @result.stack_table + end + end +end diff --git a/spec/singed/controller_ext_spec.rb b/spec/singed/controller_ext_spec.rb index f8ffe4f..c5a32b8 100644 --- a/spec/singed/controller_ext_spec.rb +++ b/spec/singed/controller_ext_spec.rb @@ -24,7 +24,7 @@ def self.around_actions end it "wraps the target action in a flamegraph" do - controller_class.flamegraph(:show, ignore_gc: true, interval: 500) + controller_class.flamegraph(:show, ignore_gc: true, interval: 500, profiler: :vernier) expect(controller_class.around_actions.size).to eq(1) options, callback = controller_class.around_actions.first @@ -36,7 +36,7 @@ def self.around_actions callback.call(controller, -> { action_ran = true }) - expect(controller).to have_received(:flamegraph).with(ignore_gc: true, interval: 500) + expect(controller).to have_received(:flamegraph).with(ignore_gc: true, interval: 500, profiler: :vernier) expect(action_ran).to be(true) end end diff --git a/spec/singed/flamegraph_spec.rb b/spec/singed/flamegraph_spec.rb new file mode 100644 index 0000000..3946211 --- /dev/null +++ b/spec/singed/flamegraph_spec.rb @@ -0,0 +1,87 @@ +# typed: false +# frozen_string_literal: true + +RSpec.describe Singed::Flamegraph do + around do |example| + example.run + ensure + Singed.instance_variable_set(:@profiler, nil) + end + + def spin(seconds) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds + nil while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + end + + def load_error(message, path:) + LoadError.new(message).tap { |error| allow(error).to receive(:path).and_return(path) } + end + + describe "#profiler" do + it "is Singed.profiler by default" do + Singed.profiler = :vernier + + expect(described_class.new.profiler).to eq(:vernier) + end + + it "is the profiler it's given" do + Singed.profiler = :vernier + + expect(described_class.new(profiler: :stackprof).profiler).to eq(:stackprof) + end + + it "rejects profilers Singed doesn't support" do + expect { described_class.new(profiler: :rbspy) }.to raise_error(ArgumentError, "Unsupported profiler :rbspy, expected one of [:stackprof, :vernier]") + end + + it "isn't supported when given an existing file" do + expect { described_class.new(filename: Pathname("profile.json"), profiler: :vernier) }.to raise_error(ArgumentError, "profiler not supported when given an existing file") + end + + it "explains that vernier needs installing when it can't be loaded" do + allow(described_class).to receive(:require).with("vernier").and_raise(load_error("cannot load such file -- vernier", path: "vernier")) + + expect { described_class.new(profiler: :vernier) }.to raise_error(LoadError, "Profiling with vernier needs the vernier gem in your bundle (cannot load such file -- vernier)") + end + + it "doesn't blame the bundle when vernier is installed but fails to load" do + error = load_error("incompatible library version - vernier.bundle", path: "/gems/vernier-1.11.0/lib/vernier/vernier.bundle") + allow(described_class).to receive(:require).with("vernier").and_raise(error) + + expect { described_class.new(profiler: :vernier) }.to raise_error(error) + end + + it "needs vernier 1.5 or newer" do + stub_const("Vernier::VERSION", "1.4.0") + + expect { described_class.new(profiler: :vernier) }.to raise_error(LoadError, "Profiling with vernier needs vernier 1.5 or newer, not 1.4.0") + end + end + + context "with vernier" do + subject(:flamegraph) { described_class.new(label: "vernier", profiler: :vernier) } + + it "records a Vernier::Result" do + flamegraph.record { spin(0.01) } + + expect(flamegraph.profile).to be_a(Vernier::Result) + end + + it "saves a speedscope profile for each thread" do + flamegraph.record do + Thread.new do + Thread.current.name = "singed-spec" + spin(0.05) + end.join + end + flamegraph.save + + json = JSON.parse(flamegraph.filename.read) + expect(json).to include("$schema" => "https://www.speedscope.app/file-format-schema.json") + expect(json["profiles"].map { |profile| profile["name"] }).to include("singed-spec") + # It opens on the thread that recorded, which waited in Thread#join while the other one ran. + active_frames = json["profiles"][json["activeProfileIndex"]]["samples"].flatten.map { |idx| json["shared"]["frames"][idx]["name"] } + expect(active_frames).to include("Thread#join") + end + end +end diff --git a/spec/singed/kernel_ext_spec.rb b/spec/singed/kernel_ext_spec.rb index d3285c1..5f82870 100644 --- a/spec/singed/kernel_ext_spec.rb +++ b/spec/singed/kernel_ext_spec.rb @@ -22,15 +22,15 @@ Object.new.flamegraph(io:) do end - expect(Singed::Flamegraph).to have_received(:new).with(label: nil, ignore_gc: false, interval: 1000) + expect(Singed::Flamegraph).to have_received(:new).with(label: nil, ignore_gc: false, interval: 1000, profiler: nil) end it "works with explicit arguments" do # NOTE: use Object.new to get the actual flamegraph kernel extension, instead of the rspec-specific flamegraph - Object.new.flamegraph("yellowjackets", ignore_gc: true, interval: 2000, io:) do + Object.new.flamegraph("yellowjackets", ignore_gc: true, interval: 2000, profiler: :vernier, io:) do end - expect(Singed::Flamegraph).to have_received(:new).with(label: "yellowjackets", ignore_gc: true, interval: 2000) + expect(Singed::Flamegraph).to have_received(:new).with(label: "yellowjackets", ignore_gc: true, interval: 2000, profiler: :vernier) end context "with default options" do diff --git a/spec/singed/vernier_report_spec.rb b/spec/singed/vernier_report_spec.rb new file mode 100644 index 0000000..86554b6 --- /dev/null +++ b/spec/singed/vernier_report_spec.rb @@ -0,0 +1,136 @@ +# typed: false +# frozen_string_literal: true + +require "vernier" + +RSpec.describe Singed::VernierReport do + subject(:report) { described_class.new(result).to_h } + + let(:result) { instance_double(Vernier::Result, meta: { interval: 500 }, threads:, stack_table:) } + + # Vernier's tables, keyed by index: each stack is a frame on top of its parent stack, + # each frame is a line in a function. + let(:funcs) do + [ + ["
", "/app/script.rb", 1], + ["Worker#run", "/app/worker.rb", 3], + ["Kernel#sleep", "", 0], + ] + end + let(:frame_funcs) { [0, 1, 1, 2] } # frames 1 and 2 are different lines of Worker#run + let(:stacks) do + [ + [nil, 0], #
+ [0, 1], #
> Worker#run + [0, 2], #
> Worker#run + [2, 3], #
> Worker#run > Kernel#sleep + ] + end + let(:stack_table) do + instance_double(Vernier::StackTable).tap do |stack_table| + allow(stack_table).to receive(:stack_parent_idx) { |idx| stacks.fetch(idx)[0] } + allow(stack_table).to receive(:stack_frame_idx) { |idx| stacks.fetch(idx)[1] } + allow(stack_table).to receive(:frame_func_idx) { |idx| frame_funcs.fetch(idx) } + allow(stack_table).to receive(:func_name) { |idx| funcs.fetch(idx)[0] } + allow(stack_table).to receive(:func_filename) { |idx| funcs.fetch(idx)[1] } + allow(stack_table).to receive(:func_first_lineno) { |idx| funcs.fetch(idx)[2] } + end + end + let(:threads) do + { + 8 => { name: "worker", is_start: false, samples: [1], weights: [4], sample_categories: [2] }, + 16 => { name: "never ran", is_start: false, samples: [], weights: [], sample_categories: [] }, + 24 => { name: "main", is_start: true, samples: [1, 2, 3], weights: [2, 1, 3], sample_categories: [0, 0, 1] }, + } + end + + def frame_names(profile) + profile[:samples].map { |stack| stack.map { |idx| report[:shared][:frames][idx][:name] } } + end + + it "is a speedscope file" do + expect(report).to include("$schema": "https://www.speedscope.app/file-format-schema.json") + end + + it "has a profile for each thread that recorded samples" do + expect(report[:profiles].map { |profile| profile[:name] }).to eq(["worker", "main"]) + end + + it "opens on the thread that started profiling" do + expect(report[:activeProfileIndex]).to eq(1) + end + + it "keeps the thread that started profiling, even without samples" do + threads[24].merge!(samples: [], weights: [], sample_categories: []) + + expect(report[:profiles].map { |profile| profile[:name] }).to eq(["worker", "main"]) + end + + it "lists each sample's frames from the root" do + expect(frame_names(report[:profiles][1])).to eq( + [ + ["
", "Worker#run"], + ["
", "Worker#run"], + ["
", "Worker#run", "Kernel#sleep", "(idle)"], + ] + ) + end + + it "tops the stacks of threads waiting for the GVL" do + expect(frame_names(report[:profiles][0])).to eq([["
", "Worker#run", "(waiting for GVL)"]]) + end + + it "has one frame per function" do + expect(report[:shared][:frames]).to contain_exactly( + { name: "
", file: "/app/script.rb", line: 1 }, + { name: "Worker#run", file: "/app/worker.rb", line: 3 }, + { name: "Kernel#sleep", file: "" }, + { name: "(idle)" }, + { name: "(waiting for GVL)" } + ) + end + + it "shares each category's frame between stacks" do + threads[24].merge!(samples: [1, 3], weights: [1, 1], sample_categories: [1, 1]) + + expect(report[:shared][:frames].count { |frame| frame[:name] == "(idle)" }).to eq(1) + end + + it "filters file names the way it filters backtraces" do + allow(Singed).to receive(:filter_line) { |line| line.delete_prefix("/app/") } + + expect(report[:shared][:frames].filter_map { |frame| frame[:file] }).to contain_exactly("script.rb", "worker.rb", "") + end + + it "scrubs names that aren't valid UTF-8" do + funcs[1][0] = "Worker#r\xFFn" + + expect(report[:shared][:frames]).to include({ name: "Worker#r�n", file: "/app/worker.rb", line: 3 }) + end + + it "scrubs thread names that aren't valid UTF-8" do + threads[8][:name] = "work\xFFer" + + expect(report[:profiles][0][:name]).to eq("work�er") + end + + it "reads binary thread names as UTF-8" do + # Vernier names threads that have no name after Thread#inspect, which is binary. + threads[8][:name] = "wörker".b + + expect(report[:profiles][0][:name]).to eq("wörker") + end + + it "weighs samples in microseconds, as Vernier's sampling interval times the samples each one merges" do + expect(report[:profiles][1]).to include(unit: "microseconds", weights: [1000, 500, 1500], startValue: 0, endValue: 3000) + end + + describe "#print_json" do + it "writes the report as JSON" do + io = StringIO.new + described_class.new(result).print_json(io) + + expect(JSON.parse(io.string, symbolize_names: true)).to eq(report) + end + end +end diff --git a/spec/singed_spec.rb b/spec/singed_spec.rb index b3920c9..843afa3 100644 --- a/spec/singed_spec.rb +++ b/spec/singed_spec.rb @@ -8,6 +8,24 @@ ensure Singed.enabled = original_enabled Singed.instance_variable_set(:@current_flamegraph, nil) + Singed.instance_variable_set(:@profiler, nil) + end + + describe ".profiler" do + it "is stackprof by default" do + expect(Singed.profiler).to eq(:stackprof) + end + + it "can be vernier" do + Singed.profiler = :vernier + + expect(Singed.profiler).to eq(:vernier) + end + + it "rejects profilers Singed doesn't support" do + expect { Singed.profiler = :rbspy }.to raise_error(ArgumentError, /Unsupported profiler :rbspy/) + expect(Singed.profiler).to eq(:stackprof) + end end describe ".start" do @@ -65,5 +83,14 @@ expect(Pathname(flamegraph.filename)).to exist end + + it "profiles with vernier when started with it" do + Singed.start(profiler: :vernier) + 100.times { 2**10 } + flamegraph = Singed.stop + + expect(flamegraph.profile).to be_a(Vernier::Result) + expect(JSON.parse(flamegraph.filename.read)).to include("$schema" => "https://www.speedscope.app/file-format-schema.json") + end end end