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/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/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" 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