vernier 1.9.0 → 1.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3aa6d3665db78499efc979f38af3d3a4f633e105ccd3a6a17b9e11ad19556cf
4
- data.tar.gz: eb752d610602a89417c894a360ea46f7727ce9a9bd11a13a2f608d35d07d4680
3
+ metadata.gz: a504e7ef885ed393a864128d15ca792af4e6ab60f30cdf90afbc36aa5d577b34
4
+ data.tar.gz: 87ab42b6cc3ea1effae71f3b95bdc4d90c117abf1aadc3d425bb30b4573dafa9
5
5
  SHA512:
6
- metadata.gz: 3a96e0d3fa78b72e3a54c05eb871af65d405c32c44e0c0d8bc5efbeffaad59bb8490ab5c599e1a435d755093927c623e813965cda31a9bbeda43e3c2c41945cc
7
- data.tar.gz: 32355e62e58124b426899dc4c167139521081d8be2bcf454aa3b0fb4916fb92334464a5685cccf5b712ee2d3d23a328a804b96b12a79e85e590f30d31d28fc66
6
+ metadata.gz: f52d1d392009084c1511b7b2cc24c51fdb5da983d0fe1dd60286ed9fe0564229ec409513cd179f2af3ceb96f9a085e2f9f48b556ac5ae6b597aba7c13bd56cf8
7
+ data.tar.gz: 7b8255043801305c221a967a25974ff1e5896393adf8c820784dd980a2eeecfa49f63509b7eeddf1c0f81a7279c29ea87ffa88622de306d0d5b430c23a40c109
@@ -47,8 +47,6 @@ class HeapTracker {
47
47
  }
48
48
 
49
49
  void record_newobj(VALUE obj) {
50
- objects_allocated++;
51
-
52
50
  RawSample sample;
53
51
  sample.sample();
54
52
  if (sample.empty()) {
@@ -59,13 +57,13 @@ class HeapTracker {
59
57
  }
60
58
  int stack_index = stack_table->stack_index(sample);
61
59
 
60
+ objects_allocated++;
62
61
  int idx = object_list.size();
63
62
  object_list.push_back(obj);
64
63
  frame_list.push_back(stack_index);
65
64
  object_index.emplace(obj, idx);
66
65
 
67
- assert(objects_allocated == frame_list.size());
68
- assert(objects_allocated == object_list.size());
66
+ assert(frame_list.size() == object_list.size());
69
67
  }
70
68
 
71
69
  void rebuild() {
@@ -29,7 +29,9 @@
29
29
  #undef assert
30
30
  #define assert RUBY_ASSERT_ALWAYS
31
31
 
32
+ #ifndef PTR2NUM
32
33
  # define PTR2NUM(x) (rb_int2inum((intptr_t)(void *)(x)))
34
+ #endif
33
35
 
34
36
  // Internal TracePoint events we'll monitor during profiling
35
37
  #define RUBY_INTERNAL_EVENTS \
@@ -48,8 +48,11 @@ module Vernier
48
48
  end
49
49
  prefix = "profile-"
50
50
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
51
- suffix = if options[:format] == "cpuprofile"
51
+ suffix = case options[:format]
52
+ when "cpuprofile"
52
53
  ".vernier.cpuprofile"
54
+ when "markdown", "md"
55
+ ".vernier.md"
53
56
  else
54
57
  ".vernier.json.gz"
55
58
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "vernier" # Make sure constants are loaded
3
+ require "vernier/vernier"
4
4
 
5
5
  module Vernier
6
6
  module Marker
@@ -1,5 +1,10 @@
1
1
  module Vernier
2
2
  class Middleware
3
+ HOOKS = [
4
+ (:rails if defined?(Rails))
5
+ ].compact.freeze
6
+ private_constant :HOOKS
7
+
3
8
  def initialize(app, permit: ->(_env) { true })
4
9
  @app = app
5
10
  @permit = permit
@@ -15,7 +20,7 @@ module Vernier
15
20
  interval = request.GET.fetch("vernier_interval", 200).to_i
16
21
  allocation_interval = request.GET.fetch("vernier_allocation_interval", 200).to_i
17
22
 
18
- result = Vernier.trace(interval:, allocation_interval:, hooks: [:rails]) do
23
+ result = Vernier.trace(interval:, allocation_interval:, hooks: HOOKS) do
19
24
  @app.call(env)
20
25
  end
21
26
  body = result.to_firefox(gzip: true)
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filename_filter"
4
+
5
+ module Vernier
6
+ module Output
7
+ class Markdown
8
+ DEFAULT_TOP_N = 20
9
+ DEFAULT_LINES_PER_FILE = 5
10
+
11
+ def initialize(profile, top_n: DEFAULT_TOP_N, lines_per_file: DEFAULT_LINES_PER_FILE)
12
+ @profile = profile
13
+ @top_n = top_n
14
+ @lines_per_file = lines_per_file
15
+ end
16
+
17
+ def output
18
+ out = +""
19
+ out << build_title
20
+ out << build_summary
21
+ out << build_hotspots
22
+ out << build_threads
23
+ out << build_files
24
+ out
25
+ end
26
+
27
+ private
28
+
29
+ def build_title
30
+ "# Vernier Profile\n\n"
31
+ end
32
+
33
+ def build_summary
34
+ out = +"## Summary\n\n"
35
+
36
+ mode = @profile.meta[:mode] rescue nil
37
+ weight_unit = mode == :retained ? "bytes" : "samples"
38
+
39
+ out << "| Metric | Value |\n"
40
+ out << "|--------|-------|\n"
41
+ out << "| Mode | #{mode || 'unknown'} |\n"
42
+
43
+ if @profile.respond_to?(:elapsed_seconds)
44
+ begin
45
+ out << "| Duration | #{format("%.2f", @profile.elapsed_seconds)} seconds |\n"
46
+ rescue
47
+ # elapsed_seconds may fail if end_time not set
48
+ end
49
+ end
50
+
51
+ out << "| Total Samples | #{total_samples} |\n"
52
+ out << "| Total Unique Samples | #{@profile.total_unique_samples rescue 'N/A'} |\n"
53
+ out << "| Threads | #{thread_count} |\n"
54
+ out << "| Weight Unit | #{weight_unit} |\n"
55
+
56
+ if @profile.respond_to?(:pid) && @profile.pid
57
+ out << "| PID | #{@profile.pid} |\n"
58
+ end
59
+
60
+ out << "\n"
61
+
62
+ # User metadata if available
63
+ if @profile.respond_to?(:meta) && @profile.meta[:user_metadata]
64
+ metadata = @profile.meta[:user_metadata]
65
+ unless metadata.empty?
66
+ out << "### User Metadata\n\n"
67
+ out << "| Key | Value |\n"
68
+ out << "|-----|-------|\n"
69
+ metadata.each do |k, v|
70
+ out << "| #{escape_markdown(k.to_s)} | #{escape_markdown(v.to_s)} |\n"
71
+ end
72
+ out << "\n"
73
+ end
74
+ end
75
+
76
+ out
77
+ end
78
+
79
+ def build_hotspots
80
+ out = +"## Top Hotspots\n\n"
81
+
82
+ thread = main_thread
83
+ return out << "_No samples collected._\n\n" unless thread
84
+
85
+ stack_table = get_stack_table(thread)
86
+ samples = thread[:samples] || []
87
+ weights = thread[:weights] || []
88
+
89
+ return out << "_No samples collected._\n\n" if samples.empty?
90
+
91
+ # Compute self weights per function
92
+ total = weights.sum
93
+ return out << "_No samples collected._\n\n" if total == 0
94
+
95
+ top_by_func = Hash.new { |h, k| h[k] = { self: 0, total: 0, file: nil, line: nil } }
96
+
97
+ samples.zip(weights).each do |stack_idx, weight|
98
+ # Self time: top frame only
99
+ frame_idx = stack_table.stack_frame_idx(stack_idx)
100
+ func_idx = stack_table.frame_func_idx(frame_idx)
101
+ name = stack_table.func_name(func_idx)
102
+ filename = stack_table.func_filename(func_idx)
103
+ first_lineno = stack_table.func_first_lineno(func_idx)
104
+
105
+ top_by_func[name][:self] += weight
106
+ top_by_func[name][:file] ||= filename
107
+ top_by_func[name][:line] ||= first_lineno
108
+
109
+ # Total time: walk up the stack
110
+ seen = {}
111
+ current_stack_idx = stack_idx
112
+ while current_stack_idx
113
+ frame_idx = stack_table.stack_frame_idx(current_stack_idx)
114
+ func_idx = stack_table.frame_func_idx(frame_idx)
115
+ func_name = stack_table.func_name(func_idx)
116
+
117
+ unless seen[func_name]
118
+ seen[func_name] = true
119
+ top_by_func[func_name][:total] += weight
120
+ top_by_func[func_name][:file] ||= stack_table.func_filename(func_idx)
121
+ top_by_func[func_name][:line] ||= stack_table.func_first_lineno(func_idx)
122
+ end
123
+
124
+ current_stack_idx = stack_table.stack_parent_idx(current_stack_idx)
125
+ end
126
+ end
127
+
128
+ out << top_functions_table("By Self Time", top_by_func, total, :self)
129
+ out << top_functions_table("By Total Time", top_by_func, total, :total)
130
+
131
+ out
132
+ end
133
+
134
+ def top_functions_table(title, funcs, total, sort_key)
135
+ out = +"### #{title}\n\n"
136
+ sorted = funcs.sort_by { |_, v| -v[sort_key] }.first(@top_n)
137
+
138
+ primary = sort_key == :self ? "Self" : "Total"
139
+ secondary = sort_key == :self ? "Total" : "Self"
140
+ out << "| Rank | #{primary} % | #{secondary} % | Function | Location |\n"
141
+ out << "|------|--------|---------|----------|----------|\n"
142
+
143
+ sorted.each_with_index do |(name, data), idx|
144
+ self_pct = 100.0 * data[:self] / total
145
+ total_pct = 100.0 * data[:total] / total
146
+ primary_pct = sort_key == :self ? self_pct : total_pct
147
+ secondary_pct = sort_key == :self ? total_pct : self_pct
148
+ location = format_location(data[:file], data[:line])
149
+ out << "| #{idx + 1} | #{format("%.1f", primary_pct)}% | #{format("%.1f", secondary_pct)}% | #{format_code_span(name)} | #{escape_markdown(location)} |\n"
150
+ end
151
+
152
+ out << "\n"
153
+ out
154
+ end
155
+
156
+ def build_threads
157
+ out = +"## Threads\n\n"
158
+
159
+ threads_data = get_threads
160
+ return out << "_No thread information available._\n\n" if threads_data.empty?
161
+
162
+ threads_data.each do |thread_id, thread|
163
+ name = get_thread_name(thread) || "Thread #{thread_id}"
164
+ is_main = get_thread_main(thread) ? "yes" : "no"
165
+ tid = get_thread_tid(thread) || thread_id
166
+
167
+ samples = thread[:samples] || []
168
+ weights = thread[:weights] || []
169
+ sample_count = samples.size
170
+ weight_sum = weights.sum
171
+
172
+ out << "### #{escape_markdown(name)}\n\n"
173
+ out << "| Property | Value |\n"
174
+ out << "|----------|-------|\n"
175
+ out << "| TID | #{tid} |\n"
176
+ out << "| Main Thread | #{is_main} |\n"
177
+ out << "| Samples | #{sample_count} |\n"
178
+ out << "| Total Weight | #{weight_sum} |\n"
179
+ out << "\n"
180
+ end
181
+
182
+ out
183
+ end
184
+
185
+ def build_files
186
+ out = +"## Hot Files\n\n"
187
+
188
+ thread = main_thread
189
+ return out << "_No file information available._\n\n" unless thread
190
+
191
+ stack_table = get_stack_table(thread)
192
+ samples = thread[:samples] || []
193
+ weights = thread[:weights] || []
194
+
195
+ return out << "_No samples collected._\n\n" if samples.empty?
196
+
197
+ total = weights.sum
198
+ return out << "_No samples collected._\n\n" if total == 0
199
+
200
+ # Build samples by file and line (similar to FileListing)
201
+ samples_by_frame = Hash.new { |h, k| h[k] = { self: 0, total: 0 } }
202
+
203
+ samples.zip(weights).each do |stack_idx, weight|
204
+ # Self time: top frame only
205
+ top_frame_idx = stack_table.stack_frame_idx(stack_idx)
206
+ samples_by_frame[top_frame_idx][:self] += weight
207
+
208
+ # Total time: walk up the stack
209
+ current_stack_idx = stack_idx
210
+ while current_stack_idx
211
+ frame_idx = stack_table.stack_frame_idx(current_stack_idx)
212
+ samples_by_frame[frame_idx][:total] += weight
213
+ current_stack_idx = stack_table.stack_parent_idx(current_stack_idx)
214
+ end
215
+ end
216
+
217
+ # Group by file
218
+ samples_by_file = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = { self: 0, total: 0 } } }
219
+
220
+ samples_by_frame.each do |frame_idx, data|
221
+ func_idx = stack_table.frame_func_idx(frame_idx)
222
+ filename = stack_table.func_filename(func_idx)
223
+ line = stack_table.frame_line_no(frame_idx)
224
+
225
+ filename = filter_filename(filename)
226
+ samples_by_file[filename][line][:self] += data[:self]
227
+ samples_by_file[filename][line][:total] += data[:total]
228
+ end
229
+
230
+ # Filter to relevant files (>1% self time, exclude gem/rubylib/<)
231
+ relevant_files = samples_by_file.select do |filename, lines|
232
+ next false if filename.start_with?("gem:")
233
+ next false if filename.start_with?("rubylib:")
234
+ next false if filename.start_with?("<")
235
+
236
+ lines.values.map { |d| d[:self] }.max > total * 0.01
237
+ end
238
+
239
+ if relevant_files.empty?
240
+ return out << "_No significant file hotspots found._\n\n"
241
+ end
242
+
243
+ # Sort files by total weight
244
+ sorted_files = relevant_files.sort_by { |_, lines| -lines.values.map { |d| d[:self] }.sum }
245
+
246
+ sorted_files.each do |filename, lines|
247
+ out << "### #{escape_markdown(filename)}\n\n"
248
+
249
+ # Get top lines by self weight
250
+ sorted_lines = lines.sort_by { |_, d| -d[:self] }.first(@lines_per_file)
251
+
252
+ out << "| Line | Self % | Total % | Code |\n"
253
+ out << "|------|--------|---------|------|\n"
254
+
255
+ sorted_lines.each do |line_no, data|
256
+ self_pct = 100.0 * data[:self] / total
257
+ total_pct = 100.0 * data[:total] / total
258
+
259
+ source_line = read_source_line(filename, line_no)
260
+ code = source_line ? truncate_code(source_line) : "_source unavailable_"
261
+
262
+ out << "| #{line_no} | #{format("%.1f", self_pct)}% | #{format("%.1f", total_pct)}% | #{format_code_span(code)} |\n"
263
+ end
264
+
265
+ out << "\n"
266
+ end
267
+
268
+ out
269
+ end
270
+
271
+ # Helper methods
272
+
273
+ def main_thread
274
+ @profile.main_thread
275
+ end
276
+
277
+ def get_threads
278
+ if @profile.respond_to?(:threads)
279
+ threads = @profile.threads
280
+ if threads.is_a?(Hash)
281
+ threads
282
+ elsif threads.is_a?(Array)
283
+ # ParsedProfile returns array
284
+ threads.each_with_index.to_h { |t, i| [i, t] }
285
+ else
286
+ {}
287
+ end
288
+ else
289
+ {}
290
+ end
291
+ end
292
+
293
+ def get_stack_table(thread)
294
+ if thread.respond_to?(:stack_table)
295
+ thread.stack_table
296
+ else
297
+ @profile._stack_table
298
+ end
299
+ end
300
+
301
+ def get_thread_name(thread)
302
+ if thread.is_a?(Hash)
303
+ thread[:name]
304
+ elsif thread.respond_to?(:data)
305
+ thread.data["name"]
306
+ end
307
+ end
308
+
309
+ def get_thread_main(thread)
310
+ if thread.is_a?(Hash)
311
+ thread[:is_main]
312
+ elsif thread.respond_to?(:main_thread?)
313
+ thread.main_thread?
314
+ end
315
+ end
316
+
317
+ def get_thread_tid(thread)
318
+ if thread.is_a?(Hash)
319
+ thread[:tid]
320
+ elsif thread.respond_to?(:data)
321
+ thread.data["tid"]
322
+ end
323
+ end
324
+
325
+ def total_samples
326
+ if @profile.respond_to?(:total_samples)
327
+ @profile.total_samples
328
+ else
329
+ main = main_thread
330
+ main ? (main[:samples]&.size || 0) : 0
331
+ end
332
+ end
333
+
334
+ def thread_count
335
+ threads = get_threads
336
+ threads.is_a?(Hash) ? threads.size : threads.length
337
+ end
338
+
339
+ def filter_filename(filename)
340
+ @filename_filter ||= if live_profile?
341
+ FilenameFilter.new
342
+ else
343
+ ->(x) { x }
344
+ end
345
+ @filename_filter.call(filename)
346
+ end
347
+
348
+ def live_profile?
349
+ thread = main_thread
350
+ thread.is_a?(Hash)
351
+ end
352
+
353
+ def format_location(file, line)
354
+ return "unknown" unless file
355
+ filtered = filter_filename(file)
356
+ line ? "#{filtered}:#{line}" : filtered
357
+ end
358
+
359
+ def read_source_line(filename, line_no)
360
+ return nil unless line_no && line_no > 0
361
+
362
+ # For filtered filenames, try to resolve back to real path
363
+ real_path = resolve_filename(filename)
364
+ return nil unless real_path && File.exist?(real_path)
365
+
366
+ lines = File.readlines(real_path)
367
+ lines[line_no - 1]&.chomp
368
+ rescue
369
+ nil
370
+ end
371
+
372
+ def resolve_filename(filename)
373
+ return nil if filename.start_with?("gem:", "rubylib:", "<")
374
+ return filename if File.exist?(filename)
375
+
376
+ # Try expanding relative to cwd
377
+ expanded = File.expand_path(filename)
378
+ return expanded if File.exist?(expanded)
379
+
380
+ nil
381
+ end
382
+
383
+ def truncate_code(code, max_length: 60)
384
+ code = code.strip
385
+ if code.length > max_length
386
+ code[0, max_length - 3] + "..."
387
+ else
388
+ code
389
+ end
390
+ end
391
+
392
+ def format_code_span(text)
393
+ content = text.to_s.gsub("|", "\\|")
394
+
395
+ # Find longest run of consecutive backticks in content
396
+ max_run = content.scan(/`+/).map(&:length).max || 0
397
+ delimiter = "`" * (max_run + 1)
398
+
399
+ if delimiter.length > 1
400
+ "#{delimiter} #{content} #{delimiter}"
401
+ else
402
+ "#{delimiter}#{content}#{delimiter}"
403
+ end
404
+ end
405
+
406
+ def escape_markdown(text)
407
+ return "" unless text
408
+ text.to_s
409
+ .gsub("&", "&amp;")
410
+ .gsub("<", "&lt;")
411
+ .gsub(">", "&gt;")
412
+ .gsub(/([|`*_\[\]])/, '\\\\\1')
413
+ end
414
+ end
415
+ end
416
+ end
@@ -29,7 +29,7 @@ module Vernier
29
29
  @frame_lines = thread_data["frameTable"]["line"]
30
30
  @func_names = thread_data["funcTable"]["name"]
31
31
  @func_filenames = thread_data["funcTable"]["fileName"]
32
- #@func_first_linenos = thread_data["funcTable"]["first"]
32
+ @func_first_linenos = thread_data["funcTable"]["lineNumber"]
33
33
  @strings = thread_data["stringArray"]
34
34
  end
35
35
 
@@ -49,7 +49,7 @@ module Vernier
49
49
  def func_filename_idx(idx) = @func_filenames[idx]
50
50
  def func_name(idx) = @strings[func_name_idx(idx)]
51
51
  def func_filename(idx) = @strings[func_filename_idx(idx)]
52
- def func_first_lineno(idx) = @func_first_lineno[idx]
52
+ def func_first_lineno(idx) = @func_first_linenos[idx]
53
53
 
54
54
  include StackTableHelpers
55
55
  end
@@ -30,6 +30,10 @@ module Vernier
30
30
  Output::Cpuprofile.new(self).output
31
31
  end
32
32
 
33
+ def to_markdown(top_n: 20, lines_per_file: 5)
34
+ Output::Markdown.new(self, top_n: top_n, lines_per_file: lines_per_file).output
35
+ end
36
+
33
37
  def write(out:, format: "firefox")
34
38
  case format
35
39
  when "cpuprofile"
@@ -38,6 +42,12 @@ module Vernier
38
42
  else
39
43
  File.binwrite(out, to_cpuprofile)
40
44
  end
45
+ when "markdown", "md"
46
+ if out.respond_to?(:write)
47
+ out.write(to_markdown)
48
+ else
49
+ File.binwrite(out, to_markdown)
50
+ end
41
51
  when "firefox", nil
42
52
  if out.respond_to?(:write)
43
53
  out.write(to_firefox)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "1.9.0"
4
+ VERSION = "1.10.0"
5
5
  end
data/lib/vernier.rb CHANGED
@@ -1,19 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "vernier/version"
4
- require_relative "vernier/collector"
5
- require_relative "vernier/stack_table"
6
- require_relative "vernier/heap_tracker"
7
- require_relative "vernier/memory_leak_detector"
8
- require_relative "vernier/parsed_profile"
9
- require_relative "vernier/result"
10
- require_relative "vernier/hooks"
11
- require_relative "vernier/vernier"
12
- require_relative "vernier/output/firefox"
13
- require_relative "vernier/output/cpuprofile"
14
- require_relative "vernier/output/top"
15
- require_relative "vernier/output/file_listing"
16
- require_relative "vernier/output/filename_filter"
3
+ require "vernier/version"
4
+ require "vernier/collector"
5
+ require "vernier/stack_table"
6
+ require "vernier/heap_tracker"
7
+ require "vernier/memory_leak_detector"
8
+ require "vernier/parsed_profile"
9
+ require "vernier/result"
10
+ require "vernier/hooks"
11
+ require "vernier/output/firefox"
12
+ require "vernier/output/cpuprofile"
13
+ require "vernier/output/top"
14
+ require "vernier/output/file_listing"
15
+ require "vernier/output/filename_filter"
16
+ require "vernier/output/markdown"
17
+ require "vernier/vernier"
17
18
 
18
19
  module Vernier
19
20
  class Error < StandardError; 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.9.0
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Hawthorn
@@ -104,6 +104,7 @@ files:
104
104
  - lib/vernier/output/file_listing.rb
105
105
  - lib/vernier/output/filename_filter.rb
106
106
  - lib/vernier/output/firefox.rb
107
+ - lib/vernier/output/markdown.rb
107
108
  - lib/vernier/output/top.rb
108
109
  - lib/vernier/parsed_profile.rb
109
110
  - lib/vernier/result.rb
@@ -133,7 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
133
134
  - !ruby/object:Gem::Version
134
135
  version: '0'
135
136
  requirements: []
136
- rubygems_version: 3.6.9
137
+ rubygems_version: 4.0.3
137
138
  specification_version: 4
138
139
  summary: A next generation CRuby profiler
139
140
  test_files: []