vernier 1.10.1 → 1.12.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.
@@ -1,8 +1,12 @@
1
1
  require "json"
2
2
 
3
+ require_relative "../output_helpers"
4
+
3
5
  module Vernier
4
6
  module Output
5
7
  class Cpuprofile
8
+ include OutputHelpers
9
+
6
10
  def initialize(profile)
7
11
  @profile = profile
8
12
  end
@@ -98,7 +102,9 @@ module Vernier
98
102
  line = stack_table.frame_line_no(frame_idx) - 1
99
103
 
100
104
  func_name = stack_table.func_name(func_idx)
105
+ func_name = sanitize_string(func_name) if func_name
101
106
  filename = stack_table.func_filename(func_idx)
107
+ filename = sanitize_string(filename) if filename
102
108
 
103
109
  node_id = nodes.length
104
110
  node = {
@@ -1,23 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "filename_filter"
4
+ require_relative "../output_helpers"
4
5
  require "cgi/escape"
5
6
 
6
7
  module Vernier
7
8
  module Output
8
9
  class FileListing
10
+ include OutputHelpers
11
+
9
12
  class SamplesByLocation
10
13
  attr_accessor :self, :total
11
14
  def initialize
12
15
  @self = @total = 0
13
16
  end
14
-
15
- def +(other)
16
- ret = SamplesByLocation.new
17
- ret.self = @self + other.self
18
- ret.total = @total + other.total
19
- ret
20
- end
21
17
  end
22
18
 
23
19
  def initialize(profile)
@@ -25,6 +21,8 @@ module Vernier
25
21
  end
26
22
 
27
23
  def samples_by_file
24
+ return @samples_by_file if defined?(@samples_by_file)
25
+
28
26
  thread = @profile.main_thread
29
27
  if Hash === thread
30
28
  # live profile
@@ -38,22 +36,9 @@ module Vernier
38
36
  weights = thread[:weights]
39
37
  samples = thread[:samples]
40
38
 
41
- self_samples_by_frame = Hash.new do |h, k|
42
- h[k] = SamplesByLocation.new
43
- end
44
-
45
- samples.zip(weights).each do |stack_idx, weight|
46
- # self time
47
- top_frame_index = stack_table.stack_frame_idx(stack_idx)
48
- self_samples_by_frame[top_frame_index].self += weight
39
+ stack_weights = collapse_stack_weights(samples, weights)
49
40
 
50
- # total time
51
- while stack_idx
52
- frame_idx = stack_table.stack_frame_idx(stack_idx)
53
- self_samples_by_frame[frame_idx].total += weight
54
- stack_idx = stack_table.stack_parent_idx(stack_idx)
55
- end
56
- end
41
+ self_by_frame, total_by_frame = frame_weights(stack_table, stack_weights)
57
42
 
58
43
  samples_by_file = Hash.new do |h, k|
59
44
  h[k] = Hash.new do |h2, k2|
@@ -61,19 +46,64 @@ module Vernier
61
46
  end
62
47
  end
63
48
 
64
- self_samples_by_frame.each do |frame, samples|
49
+ self_by_frame.each_with_index do |self_weight, frame|
50
+ total_weight = total_by_frame[frame]
51
+ next if self_weight == 0 && total_weight == 0
52
+
65
53
  line = stack_table.frame_line_no(frame)
66
54
  func_index = stack_table.frame_func_idx(frame)
67
55
  filename = stack_table.func_filename(func_index)
68
56
 
69
- samples_by_file[filename][line] += samples
57
+ location = samples_by_file[filename][line]
58
+ location.self += self_weight
59
+ location.total += total_weight
70
60
  end
71
61
 
72
- samples_by_file.transform_keys! do |filename|
62
+ @samples_by_file = samples_by_file.transform_keys! do |filename|
73
63
  filename_filter.call(filename)
74
64
  end
75
65
  end
76
66
 
67
+ # Returns [self_weight, total_weight] arrays indexed by frame, from a
68
+ # hash of per-stack weights.
69
+ #
70
+ # The stack table is a prefix tree whose parent is always interned
71
+ # before its children (parent idx < child idx), so total weights are
72
+ # accumulated bottom-up in a single reverse pass.
73
+ private def frame_weights(stack_table, stack_weights)
74
+ # Scatter the sparse weights into an array indexed by stack.
75
+ stack_totals = Array.new(stack_table.stack_count, 0)
76
+ stack_weights.each do |stack_idx, weight|
77
+ stack_totals[stack_idx] = weight
78
+ end
79
+
80
+ # Accumulate each stack's total into its parent, children first,
81
+ # so a single descending pass completes every subtree's total.
82
+ (stack_totals.length - 1).downto(0) do |stack_idx|
83
+ parent_idx = stack_table.stack_parent_idx(stack_idx)
84
+ next unless parent_idx
85
+
86
+ if parent_idx >= stack_idx
87
+ raise "Invalid profile: stack table is not parent-before-child ordered"
88
+ end
89
+
90
+ stack_totals[parent_idx] += stack_totals[stack_idx]
91
+ end
92
+
93
+ self_by_frame = Array.new(stack_table.frame_count, 0)
94
+ total_by_frame = Array.new(stack_table.frame_count, 0)
95
+ stack_weights.each do |stack_idx, weight|
96
+ self_by_frame[stack_table.stack_frame_idx(stack_idx)] += weight
97
+ end
98
+ stack_totals.each_with_index do |total, stack_idx|
99
+ next if total == 0
100
+
101
+ total_by_frame[stack_table.stack_frame_idx(stack_idx)] += total
102
+ end
103
+
104
+ [self_by_frame, total_by_frame]
105
+ end
106
+
77
107
  def output(template: nil)
78
108
  output = +""
79
109
 
@@ -98,8 +128,7 @@ module Vernier
98
128
  end
99
129
 
100
130
  def total
101
- thread = @profile.main_thread
102
- thread[:weights].sum
131
+ @total ||= @profile.main_thread[:weights].sum
103
132
  end
104
133
 
105
134
  def format_file(output, filename, all_samples, total:)
@@ -4,6 +4,7 @@ require "json"
4
4
  require "rbconfig"
5
5
 
6
6
  require_relative "filename_filter"
7
+ require_relative "../output_helpers"
7
8
 
8
9
  module Vernier
9
10
  module Output
@@ -120,7 +121,7 @@ module Vernier
120
121
  end
121
122
 
122
123
  def output(gzip: false)
123
- result = ::JSON.generate(data)
124
+ result = ::JSON.generate(data(profile.meta[:hide_anonymous_threads]))
124
125
  if gzip
125
126
  require "zlib"
126
127
  result = Zlib.gzip(result)
@@ -132,7 +133,7 @@ module Vernier
132
133
 
133
134
  attr_reader :profile
134
135
 
135
- def data
136
+ def data(hide_anonymous_threads)
136
137
  #markers_by_thread = profile.markers.group_by { |marker| marker[0] }
137
138
 
138
139
  threads = profile.threads.map do |ruby_thread_id, thread_info|
@@ -146,6 +147,12 @@ module Vernier
146
147
  )
147
148
  end
148
149
 
150
+ initially_visible_threads = if hide_anonymous_threads
151
+ threads.each_index.reject { |i| threads[i].anonymous? && !threads[i].is_main && !threads[i].is_start }.to_a
152
+ else
153
+ threads.each_index.to_a
154
+ end
155
+
149
156
  {
150
157
  meta: {
151
158
  interval: 1, # FIXME: memory vs wall
@@ -171,7 +178,7 @@ module Vernier
171
178
  }
172
179
  end,
173
180
  sourceCodeIsNotOnSearchfox: true,
174
- initialVisibleThreads: threads.each_index.to_a,
181
+ initialVisibleThreads: initially_visible_threads,
175
182
  initialSelectedThreads: Array(threads.find_index(&:is_start)),
176
183
  vernierUserMetadata: profile.meta[:user_metadata],
177
184
  extra: [
@@ -267,20 +274,24 @@ module Vernier
267
274
  end
268
275
 
269
276
  class Thread
277
+ include OutputHelpers
278
+
270
279
  SAMPLE_CATEGORY_NAMES = {
271
280
  1 => "Idle",
272
281
  2 => "Stalled"
273
282
  }.freeze
274
283
 
275
- attr_reader :profile, :is_start
284
+ attr_reader :profile, :is_start, :anonymous, :is_main
285
+ alias :anonymous? :anonymous
276
286
 
277
- def initialize(ruby_thread_id, profile, categorizer, name:, tid:, samples:, weights:, timestamps: nil, sample_categories: nil, markers:, started_at:, stopped_at: nil, allocations: nil, is_main: nil, is_start: nil)
287
+ def initialize(ruby_thread_id, profile, categorizer, name:, tid:, samples:, weights:, timestamps: nil, sample_categories: nil, markers:, started_at:, anonymous:, stopped_at: nil, allocations: nil, is_main: nil, is_start: nil)
278
288
  @ruby_thread_id = ruby_thread_id
279
289
  @profile = profile
280
290
  @categorizer = categorizer
281
291
  @tid = tid
282
292
  @name = name
283
293
  @is_main = is_main
294
+ @anonymous = anonymous
284
295
  if is_main.nil?
285
296
  @is_main = @ruby_thread_id == ::Thread.main.object_id
286
297
  end
@@ -438,7 +449,6 @@ module Vernier
438
449
  funcTable: func_table,
439
450
  nativeSymbols: {},
440
451
  samples: samples_table,
441
- jsAllocations: allocations_table,
442
452
  stackTable: stack_table,
443
453
  resourceTable: {
444
454
  length: 0,
@@ -449,7 +459,10 @@ module Vernier
449
459
  },
450
460
  markers: markers_table,
451
461
  stringArray: string_table
452
- }.compact
462
+ }.tap do |thread|
463
+ allocations = allocations_table
464
+ thread[:jsAllocations] = allocations if allocations
465
+ end
453
466
  end
454
467
 
455
468
  def markers_table
@@ -638,18 +651,7 @@ module Vernier
638
651
 
639
652
  def string_table
640
653
  @strings.keys.map do |string|
641
- if string.ascii_only?
642
- string
643
- elsif string.encoding == Encoding::UTF_8
644
- if string.valid_encoding?
645
- string
646
- else
647
- string.scrub
648
- end
649
- else
650
- # TODO: We might want to guess UTF-8 and escape the binary more explicitly
651
- string.dup.force_encoding("UTF-8").scrub
652
- end
654
+ sanitize_string(string)
653
655
  end
654
656
  end
655
657
 
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../output_helpers"
4
+
3
5
  module Vernier
4
6
  module Output
5
7
  class Top
8
+ include OutputHelpers
9
+
6
10
  def initialize(profile, row_limit)
7
11
  @profile = profile
8
12
  @row_limit = row_limit
@@ -57,10 +61,10 @@ module Vernier
57
61
  @profile._stack_table
58
62
  end
59
63
 
60
- stack_weights = Hash.new(0)
61
- thread[:samples].zip(thread[:weights]) do |stack_idx, weight|
62
- stack_weights[stack_idx] += weight
63
- end
64
+ samples = thread[:samples]
65
+ weights = thread[:weights]
66
+
67
+ stack_weights = collapse_stack_weights(samples, weights)
64
68
 
65
69
  total = stack_weights.values.sum
66
70
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vernier
4
+ module OutputHelpers
5
+ # Collapses parallel samples/weights arrays into a hash of total weight
6
+ # per stack index. Retained-mode profiles have one sample per object and
7
+ # many samples share the same stack, so collapsing first keeps later
8
+ # aggregation proportional to the number of unique stacks.
9
+ def collapse_stack_weights(samples, weights)
10
+ stack_weights = Hash.new(0)
11
+ samples.each_with_index do |stack_idx, idx|
12
+ stack_weights[stack_idx] += weights[idx]
13
+ end
14
+ stack_weights
15
+ end
16
+
17
+ # Returns a string safe to embed in JSON output: valid UTF-8 with any
18
+ # invalid bytes replaced. Method names and paths can carry arbitrary
19
+ # encodings (e.g. BINARY or Shift_JIS source), which would otherwise
20
+ # raise JSON::GeneratorError.
21
+ def sanitize_string(string)
22
+ if string.ascii_only?
23
+ string
24
+ elsif string.encoding == Encoding::UTF_8
25
+ string.valid_encoding? ? string : string.scrub
26
+ else
27
+ # We don't know the real encoding; interpret the bytes as UTF-8
28
+ # and replace anything invalid.
29
+ string.dup.force_encoding(Encoding::UTF_8).scrub
30
+ end
31
+ end
32
+ end
33
+ end
@@ -3,6 +3,7 @@ module Vernier
3
3
  class ThreadNames
4
4
  def initialize
5
5
  @names = {}
6
+ @anonymous = {}
6
7
  @tp = TracePoint.new(:thread_end) do |e|
7
8
  collect_thread(e.self)
8
9
  end
@@ -13,6 +14,10 @@ module Vernier
13
14
  @names[object_id] || "thread obj_id:#{object_id}"
14
15
  end
15
16
 
17
+ def anonymous?(object_id)
18
+ @anonymous[object_id]
19
+ end
20
+
16
21
  def finish
17
22
  collect_running
18
23
  @tp.disable
@@ -27,6 +32,7 @@ module Vernier
27
32
  end
28
33
 
29
34
  def collect_thread(th)
35
+ @anonymous[th.object_id] = !th.name
30
36
  @names[th.object_id] = pretty_name(th)
31
37
  end
32
38
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "1.10.1"
4
+ VERSION = "1.12.0"
5
5
  end
data/vernier.gemspec CHANGED
@@ -9,12 +9,12 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["john@hawthorn.email"]
10
10
 
11
11
  spec.summary = "A next generation CRuby profiler"
12
- spec.description = "Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more."
12
+ spec.description = "Next-generation Ruby 3.3+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more."
13
13
  spec.homepage = "https://github.com/jhawthorn/vernier"
14
14
  spec.license = "MIT"
15
15
 
16
16
  unless ENV["IGNORE_REQUIRED_RUBY_VERSION"]
17
- spec.required_ruby_version = ">= 3.2.1"
17
+ spec.required_ruby_version = ">= 3.3.0"
18
18
  end
19
19
 
20
20
  spec.metadata["homepage_uri"] = spec.homepage
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.10.1
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Hawthorn
@@ -51,7 +51,7 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
- description: Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads,
54
+ description: Next-generation Ruby 3.3+ sampling profiler. Tracks multiple threads,
55
55
  GVL activity, GC pauses, idle time, and more.
56
56
  email:
57
57
  - john@hawthorn.email
@@ -106,6 +106,7 @@ files:
106
106
  - lib/vernier/output/firefox.rb
107
107
  - lib/vernier/output/markdown.rb
108
108
  - lib/vernier/output/top.rb
109
+ - lib/vernier/output_helpers.rb
109
110
  - lib/vernier/parsed_profile.rb
110
111
  - lib/vernier/result.rb
111
112
  - lib/vernier/stack_table.rb
@@ -127,14 +128,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
127
128
  requirements:
128
129
  - - ">="
129
130
  - !ruby/object:Gem::Version
130
- version: 3.2.1
131
+ version: 3.3.0
131
132
  required_rubygems_version: !ruby/object:Gem::Requirement
132
133
  requirements:
133
134
  - - ">="
134
135
  - !ruby/object:Gem::Version
135
136
  version: '0'
136
137
  requirements: []
137
- rubygems_version: 4.0.6
138
+ rubygems_version: 4.0.20
138
139
  specification_version: 4
139
140
  summary: A next generation CRuby profiler
140
141
  test_files: []