vernier 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,15 @@
3
3
 
4
4
  #include "ruby.h"
5
5
 
6
+ // HACK: This isn't public, but the objspace ext uses it
7
+ extern "C" size_t rb_obj_memsize_of(VALUE);
8
+
9
+ #define sym(name) ID2SYM(rb_intern_const(name))
10
+
6
11
  extern VALUE rb_mVernier;
7
12
 
8
13
  void Init_memory();
14
+ void Init_stack_table();
15
+ void Init_heap_tracker();
9
16
 
10
17
  #endif /* VERNIER_H */
@@ -5,6 +5,110 @@ require_relative "thread_names"
5
5
 
6
6
  module Vernier
7
7
  class Collector
8
+ class CustomCollector < Collector
9
+ def initialize(mode, options)
10
+ @stack_table = StackTable.new
11
+
12
+ @samples = []
13
+ @timestamps = []
14
+
15
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
16
+ super
17
+ end
18
+
19
+ def sample
20
+ @samples << @stack_table.current_stack
21
+ @timestamps << Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
22
+ end
23
+
24
+ def start
25
+ end
26
+
27
+ def finish
28
+ result = Result.new
29
+ result.instance_variable_set(:@threads, {
30
+ 0 => {
31
+ tid: 0,
32
+ name: "custom",
33
+ started_at: @started_at,
34
+ samples: @samples,
35
+ weights: [1] * @samples.size,
36
+ timestamps: @timestamps,
37
+ sample_categories: [0] * @samples.size,
38
+ }
39
+ })
40
+ result.instance_variable_set(:@meta, {
41
+ started_at: @started_at
42
+ })
43
+ result
44
+ end
45
+ end
46
+
47
+ class RetainedCollector < Collector
48
+ def initialize(mode, options)
49
+ @stack_table = StackTable.new
50
+ @heap_tracker = HeapTracker.new(@stack_table)
51
+
52
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
53
+ super
54
+ end
55
+
56
+ def start
57
+ @heap_tracker.collect
58
+ end
59
+
60
+ def drain
61
+ @heap_tracker.drain
62
+ end
63
+
64
+ def finish
65
+ @heap_tracker.drain
66
+
67
+ GC.start
68
+
69
+ @stack_table.finalize
70
+
71
+ GC.start
72
+
73
+ @heap_tracker.lock
74
+ tracker_data = @heap_tracker.data
75
+
76
+ samples = tracker_data.fetch(:samples)
77
+ weights = tracker_data.fetch(:weights)
78
+
79
+ result = Result.new
80
+ result.instance_variable_set(:@threads, {
81
+ 0 => {
82
+ tid: 0,
83
+ name: "retained memory",
84
+ started_at: @started_at,
85
+ samples: samples,
86
+ weights: weights,
87
+ sample_categories: [0] * samples.size,
88
+ }
89
+ })
90
+ result.instance_variable_set(:@meta, {
91
+ started_at: @started_at
92
+ })
93
+ result
94
+ end
95
+ end
96
+
97
+ def self.new(mode, options = {})
98
+ return super unless Collector.equal?(self)
99
+
100
+ case mode
101
+ when :wall
102
+ TimeCollector.new(mode, options)
103
+ when :custom
104
+ CustomCollector.new(mode, options)
105
+ when :retained
106
+ RetainedCollector.new(mode, options)
107
+ else
108
+ raise ArgumentError, "invalid mode: #{mode.inspect}"
109
+ end
110
+ end
111
+
8
112
  def initialize(mode, options = {})
9
113
  @gc = options.fetch(:gc, true) && (mode == :retained)
10
114
  GC.start if @gc
@@ -30,14 +134,20 @@ module Vernier
30
134
  @user_metadata = options[:metadata] || {}
31
135
  end
32
136
 
137
+ attr_reader :stack_table
138
+
33
139
  private def add_hook(hook)
34
- case hook.to_sym
140
+ case hook.to_s.to_sym
35
141
  when :rails, :activesupport
36
142
  @hooks << Vernier::Hooks::ActiveSupport.new(self)
37
143
  when :memory_usage
38
144
  @hooks << Vernier::Hooks::MemoryUsage.new(self)
39
145
  else
40
- warn "unknown hook: #{hook.inspect}"
146
+ if hook.respond_to?(:new)
147
+ @hooks << hook.new(self)
148
+ else
149
+ warn "unknown hook: #{hook.inspect}"
150
+ end
41
151
  end
42
152
  end
43
153
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vernier
4
+ # Plan: The heap tracker can be in a few states:
5
+ # * Idle
6
+ # * Collecting
7
+ # * Watching for new objects
8
+ # * Watching for freed objects
9
+ # * Draining
10
+ # * Ignoring new objects
11
+ # * Watching for freed objects
12
+ # * Locked
13
+ # * Ignoring new objects
14
+ # * Ignoring freed objects
15
+ # * Marking all existing objects (not yet implemented)
16
+ # * N.B. This prevents any objects which the tracker has seen from being GC'd
17
+ class HeapTracker
18
+ attr_reader :stack_table
19
+
20
+ def self.new(stack_table = StackTable.new)
21
+ _new(stack_table)
22
+ end
23
+
24
+ def inspect
25
+ "#<#{self.class} allocated_objects=#{allocated_objects} freed_objects=#{freed_objects} stack_table=#{stack_table.inspect}>"
26
+ end
27
+
28
+ def self.track(&block)
29
+ tracker = new
30
+ tracker.track(&block)
31
+ tracker
32
+ end
33
+
34
+ def track
35
+ collect
36
+ yield self
37
+ ensure
38
+ lock
39
+ end
40
+
41
+ def stack(obj)
42
+ idx = stack_idx(obj)
43
+ return nil unless idx
44
+ stack_table.stack(idx)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vernier
4
+ class MemoryLeakDetector
5
+ def self.start_thread(...)
6
+ detector = new(...)
7
+ detector.start_thread
8
+ detector
9
+ end
10
+
11
+ def initialize(idle_time: 0, collect_time:, drain_time: 0, **collector_options)
12
+ @idle_time = idle_time
13
+ @collect_time = collect_time
14
+ @drain_time = drain_time
15
+ @collector_options = collector_options
16
+ @thread = nil
17
+ end
18
+
19
+ def start_thread
20
+ @thread = Thread.new do
21
+ sleep @idle_time
22
+
23
+ collector = Collector.new(:retained, @collector_options)
24
+ collector.start
25
+
26
+ sleep @collect_time
27
+
28
+ collector.drain
29
+
30
+ sleep @drain_time
31
+
32
+ collector.stop
33
+ end
34
+ end
35
+
36
+ def result
37
+ @thread&.value
38
+ end
39
+ end
40
+ end
@@ -8,35 +8,59 @@ require_relative "filename_filter"
8
8
  module Vernier
9
9
  module Output
10
10
  # https://profiler.firefox.com/
11
- # https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.js
11
+ # https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.ts
12
12
  class Firefox
13
13
  class Categorizer
14
+ RAILS_COMPONENTS = %w[ activesupport activemodel activerecord actionview
15
+ actionpack activejob actionmailer actioncable
16
+ activestorage actionmailbox actiontext railties ]
17
+
18
+ AVAILABLE_COLORS = %w[ transparent purple green orange yellow lightblue
19
+ blue brown magenta red lightred darkgrey grey ]
20
+
21
+ ORDERED_CATEGORIES = %w[ Kernel Rails gem Ruby ] # This is in the order of preference
22
+
14
23
  attr_reader :categories
24
+
15
25
  def initialize
16
26
  @categories = []
17
27
  @categories_by_name = {}
18
28
 
19
- add_category(name: "Ruby", color: "grey") do |c|
20
- rails_components = %w[ activesupport activemodel activerecord
21
- actionview actionpack activejob actionmailer actioncable
22
- activestorage actionmailbox actiontext railties ]
29
+ add_category(name: "Kernel", color: "magenta") do |c|
23
30
  c.add_subcategory(
24
- name: "Rails",
25
- matcher: gem_path(*rails_components)
31
+ name: "Kernel",
32
+ matcher: starts_with("<internal")
26
33
  )
34
+ end
35
+
36
+ add_category(name: "gem", color: "lightblue") do |c|
27
37
  c.add_subcategory(
28
38
  name: "gem",
29
39
  matcher: starts_with(*Gem.path)
30
40
  )
41
+ end
42
+
43
+ add_category(name: "Rails", color: "red") do |c|
44
+ RAILS_COMPONENTS.each do |subcategory|
45
+ c.add_subcategory(
46
+ name: subcategory,
47
+ matcher: gem_path(subcategory)
48
+ )
49
+ end
50
+ end
51
+
52
+ add_category(name: "Ruby", color: "purple") do |c|
31
53
  c.add_subcategory(
32
54
  name: "stdlib",
33
55
  matcher: starts_with(RbConfig::CONFIG["rubylibdir"])
34
56
  )
35
57
  end
58
+
36
59
  add_category(name: "Idle", color: "transparent")
37
60
  add_category(name: "Stalled", color: "transparent")
38
61
 
39
62
  add_category(name: "GC", color: "red")
63
+
40
64
  add_category(name: "cfunc", color: "yellow", matcher: "<cfunc>")
41
65
 
42
66
  add_category(name: "Thread", color: "grey")
@@ -69,7 +93,10 @@ module Vernier
69
93
 
70
94
  class Category
71
95
  attr_reader :idx, :name, :color, :matcher, :subcategories
96
+
72
97
  def initialize(idx, name:, color:, matcher: nil)
98
+ raise ArgumentError, "invalid color: #{color}" if color && AVAILABLE_COLORS.none?(color)
99
+
73
100
  @idx = idx
74
101
  @name = name
75
102
  @color = color
@@ -315,19 +342,13 @@ module Vernier
315
342
  func_implementations[func_idx]
316
343
  end
317
344
 
318
- cfunc_category = @categorizer.get_category("cfunc")
319
- ruby_category = @categorizer.get_category("Ruby")
320
345
  func_categories, func_subcategories = [], []
321
346
  filenames.each do |filename|
322
- if filename == "<cfunc>"
323
- func_categories << cfunc_category
324
- func_subcategories << 0
325
- else
326
- func_categories << ruby_category
327
- subcategory = ruby_category.subcategories.detect {|c| c.matches?(filename) }&.idx || 0
328
- func_subcategories << subcategory
329
- end
347
+ category, subcategory = categorize_filename(filename)
348
+ func_categories << category
349
+ func_subcategories << subcategory
330
350
  end
351
+
331
352
  @frame_categories = @stack_table_hash[:frame_table].fetch(:func).map do |func_idx|
332
353
  func_categories[func_idx]
333
354
  end
@@ -336,6 +357,32 @@ module Vernier
336
357
  end
337
358
  end
338
359
 
360
+ def categorize_filename(filename)
361
+ return cfunc_category_and_subcategory if filename == "<cfunc>"
362
+
363
+ category, subcategory = find_category_and_subcategory(filename, Categorizer::ORDERED_CATEGORIES)
364
+ return category, subcategory if subcategory
365
+
366
+ ruby_category_and_subcategory
367
+ end
368
+
369
+ def cfunc_category_and_subcategory
370
+ [@categorizer.get_category("cfunc"), 0]
371
+ end
372
+
373
+ def ruby_category_and_subcategory
374
+ [@categorizer.get_category("Ruby"), 0]
375
+ end
376
+
377
+ def find_category_and_subcategory(filename, categories)
378
+ categories.each do |category_name|
379
+ category = @categorizer.get_category(category_name)
380
+ subcategory = category.subcategories.detect {|c| c.matches?(filename) }&.idx
381
+ return category, subcategory if subcategory
382
+ end
383
+ [nil, nil]
384
+ end
385
+
339
386
  def filter_filenames(filenames)
340
387
  filter = FilenameFilter.new
341
388
  filenames.map do |filename|
@@ -3,14 +3,16 @@
3
3
  module Vernier
4
4
  module Output
5
5
  class Top
6
- def initialize(profile)
6
+ def initialize(profile, row_limit)
7
7
  @profile = profile
8
+ @row_limit = row_limit
8
9
  end
9
10
 
10
11
  class Table
11
- def initialize(header)
12
+ def initialize(header, row_limit)
12
13
  @header = header
13
14
  @rows = []
15
+ @row_limit = row_limit
14
16
  yield self
15
17
  end
16
18
 
@@ -24,7 +26,7 @@ module Vernier
24
26
  row_separator,
25
27
  format_row(@header),
26
28
  row_separator
27
- ] + @rows.map do |row|
29
+ ] + @rows.first(@row_limit).map do |row|
28
30
  format_row(row)
29
31
  end + [row_separator]
30
32
  ).join("\n")
@@ -70,7 +72,7 @@ module Vernier
70
72
  top_by_self[name] += weight
71
73
  end
72
74
 
73
- Table.new %w[Samples % name] do |t|
75
+ Table.new %w[Samples % name], @row_limit do |t|
74
76
  top_by_self.sort_by(&:last).reverse.each do |frame, samples|
75
77
  pct = 100.0 * samples / total
76
78
  t << [samples.to_s, pct.round(1).to_s, frame]
@@ -1,26 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Vernier
2
4
  class Result
3
5
  attr_accessor :stack_table
4
6
  alias _stack_table stack_table
5
7
 
6
- attr_reader :gc_markers
7
-
8
- attr_accessor :hooks
8
+ attr_accessor :hooks, :pid, :end_time
9
9
 
10
- attr_accessor :pid, :end_time
11
- attr_accessor :threads
12
- attr_accessor :meta
13
- attr_accessor :mode
10
+ attr_reader :meta, :threads, :gc_markers
14
11
 
15
12
  def main_thread
16
13
  threads.values.detect {|x| x[:is_main] }
17
14
  end
18
15
 
19
- # TODO: remove these
20
- def weights; threads.values.flat_map { _1[:weights] }; end
21
- def samples; threads.values.flat_map { _1[:samples] }; end
22
- def sample_categories; threads.values.flat_map { _1[:sample_categories] }; end
23
-
24
16
  # Realtime in nanoseconds since the unix epoch
25
17
  def started_at
26
18
  started_at_mono_ns = meta[:started_at]
@@ -41,10 +33,17 @@ module Vernier
41
33
  def write(out:, format: "firefox")
42
34
  case format
43
35
  when "cpuprofile"
44
- File.binwrite(out, to_cpuprofile)
45
- when nil, "firefox"
46
- gzip = out.end_with?(".gz")
47
- File.binwrite(out, to_firefox(gzip:))
36
+ if out.respond_to?(:write)
37
+ out.write(to_cpuprofile)
38
+ else
39
+ File.binwrite(out, to_cpuprofile)
40
+ end
41
+ when "firefox", nil
42
+ if out.respond_to?(:write)
43
+ out.write(to_firefox)
44
+ else
45
+ File.binwrite(out, to_firefox(gzip: out.end_with?(".gz")))
46
+ end
48
47
  else
49
48
  raise ArgumentError, "unknown format: #{format}"
50
49
  end
@@ -55,15 +54,15 @@ module Vernier
55
54
  end
56
55
 
57
56
  def inspect
58
- "#<#{self.class} #{elapsed_seconds} seconds, #{threads.count} threads, #{samples.count} samples, #{samples.uniq.size} unique>"
57
+ "#<#{self.class} #{elapsed_seconds rescue "?"} seconds, #{threads.count} threads, #{total_samples} samples, #{total_unique_samples} unique>"
59
58
  end
60
59
 
61
60
  def each_sample
62
61
  return enum_for(__method__) unless block_given?
63
- samples.size.times do |sample_idx|
64
- weight = weights[sample_idx]
65
- stack_idx = samples[sample_idx]
66
- yield stack(stack_idx), weight
62
+ threads.values.each do |thread|
63
+ thread[:samples].zip(thread[:weights]) do |stack_idx, weight|
64
+ yield stack(stack_idx), weight
65
+ end
67
66
  end
68
67
  end
69
68
 
@@ -71,8 +70,24 @@ module Vernier
71
70
  stack_table.stack(idx)
72
71
  end
73
72
 
73
+ def total_weights
74
+ threads.values.sum { _1[:weights].sum }
75
+ end
76
+
74
77
  def total_bytes
75
- weights.sum
78
+ unless meta[:mode] == :retained
79
+ raise NotImplementedError, "total_bytes is only implemented for retained mode"
80
+ end
81
+
82
+ total_weights
83
+ end
84
+
85
+ def total_samples
86
+ threads.values.sum { _1[:samples].count }
87
+ end
88
+
89
+ def total_unique_samples
90
+ threads.values.flat_map { _1[:samples] }.uniq.count
76
91
  end
77
92
  end
78
93
  end
@@ -23,15 +23,21 @@ module Vernier
23
23
  end
24
24
 
25
25
  def backtrace(stack_idx)
26
- full_stack(stack_idx).map do |stack_idx|
26
+ last_filename = nil
27
+ last_lineno = nil
28
+ full_stack(stack_idx).reverse.map do |stack_idx|
27
29
  frame_idx = stack_frame_idx(stack_idx)
28
30
  func_idx = frame_func_idx(frame_idx)
29
31
  line = frame_line_no(frame_idx)
30
- name = func_name(func_idx);
31
- filename = func_filename(func_idx);
32
+ line = last_lineno if line == 0
33
+ last_lineno = line
34
+ name = func_name(func_idx)
35
+ filename = func_path(func_idx)
36
+ filename = last_filename if filename.empty?
37
+ last_filename = filename
32
38
 
33
39
  "#{filename}:#{line}:in '#{name}'"
34
- end
40
+ end.reverse
35
41
  end
36
42
 
37
43
  def full_stack(stack_idx)
@@ -83,9 +89,14 @@ module Vernier
83
89
  def line
84
90
  stack_table.frame_line_no(idx)
85
91
  end
92
+ alias lineno line
86
93
 
87
94
  def to_s
88
- "#{func}:#{line}"
95
+ if (line = self.line) == 0
96
+ func.to_s
97
+ else
98
+ "#{func}:#{line}"
99
+ end
89
100
  end
90
101
  end
91
102
 
@@ -102,14 +113,16 @@ module Vernier
102
113
  end
103
114
  alias each_frame each
104
115
 
105
- def [](n)
106
- raise RangeError if n < 0
116
+ def [](offset)
117
+ raise RangeError if offset < 0
107
118
  stack_idx = idx
108
- while n > 0
119
+ while stack_idx && offset > 0
109
120
  stack_idx = stack_table.stack_parent_idx(stack_idx)
110
- n -= 1
121
+ offset -= 1
122
+ end
123
+ if stack_idx && offset == 0
124
+ Frame.new(stack_table, stack_table.stack_frame_idx(stack_idx))
111
125
  end
112
- Frame.new(stack_table, stack_table.stack_frame_idx(stack_idx))
113
126
  end
114
127
 
115
128
  def leaf_frame_idx
@@ -134,6 +147,7 @@ module Vernier
134
147
  end
135
148
 
136
149
  def stack(idx)
150
+ raise ArgumentError, "invalid index" unless idx
137
151
  Stack.new(self, idx)
138
152
  end
139
153
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "1.8.0"
4
+ VERSION = "1.9.0"
5
5
  end
data/lib/vernier.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative "vernier/version"
4
4
  require_relative "vernier/collector"
5
5
  require_relative "vernier/stack_table"
6
+ require_relative "vernier/heap_tracker"
7
+ require_relative "vernier/memory_leak_detector"
6
8
  require_relative "vernier/parsed_profile"
7
9
  require_relative "vernier/result"
8
10
  require_relative "vernier/hooks"
@@ -63,10 +65,4 @@ module Vernier
63
65
  def self.trace_retained(**profile_options, &block)
64
66
  profile(**profile_options.merge(mode: :retained), &block)
65
67
  end
66
-
67
- class Collector
68
- def self.new(mode, options = {})
69
- _new(mode, options)
70
- end
71
- end
72
68
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vernier
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Hawthorn
@@ -70,6 +70,7 @@ files:
70
70
  - bin/console
71
71
  - bin/setup
72
72
  - bin/vernier
73
+ - examples/custom_hook.rb
73
74
  - examples/fiber_stalls.rb
74
75
  - examples/gvl_sleep.rb
75
76
  - examples/measure_overhead.rb
@@ -79,20 +80,25 @@ files:
79
80
  - examples/threaded_http_requests.rb
80
81
  - exe/vernier
81
82
  - ext/vernier/extconf.rb
83
+ - ext/vernier/heap_tracker.cc
82
84
  - ext/vernier/memory.cc
83
85
  - ext/vernier/periodic_thread.hh
84
86
  - ext/vernier/ruby_type_names.h
85
87
  - ext/vernier/signal_safe_semaphore.hh
88
+ - ext/vernier/stack_table.cc
89
+ - ext/vernier/stack_table.hh
86
90
  - ext/vernier/timestamp.hh
87
91
  - ext/vernier/vernier.cc
88
92
  - ext/vernier/vernier.hh
89
93
  - lib/vernier.rb
90
94
  - lib/vernier/autorun.rb
91
95
  - lib/vernier/collector.rb
96
+ - lib/vernier/heap_tracker.rb
92
97
  - lib/vernier/hooks.rb
93
98
  - lib/vernier/hooks/active_support.rb
94
99
  - lib/vernier/hooks/memory_usage.rb
95
100
  - lib/vernier/marker.rb
101
+ - lib/vernier/memory_leak_detector.rb
96
102
  - lib/vernier/middleware.rb
97
103
  - lib/vernier/output/cpuprofile.rb
98
104
  - lib/vernier/output/file_listing.rb
@@ -127,7 +133,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
127
133
  - !ruby/object:Gem::Version
128
134
  version: '0'
129
135
  requirements: []
130
- rubygems_version: 3.6.7
136
+ rubygems_version: 3.6.9
131
137
  specification_version: 4
132
138
  summary: A next generation CRuby profiler
133
139
  test_files: []