vernier 1.9.0 → 1.10.1
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 +4 -4
- data/.ruby-version +1 -1
- data/ext/vernier/heap_tracker.cc +5 -4
- data/ext/vernier/vernier.cc +2 -0
- data/lib/vernier/autorun.rb +4 -1
- data/lib/vernier/marker.rb +1 -1
- data/lib/vernier/middleware.rb +6 -1
- data/lib/vernier/output/firefox.rb +63 -25
- data/lib/vernier/output/markdown.rb +416 -0
- data/lib/vernier/parsed_profile.rb +2 -2
- data/lib/vernier/result.rb +10 -0
- data/lib/vernier/version.rb +1 -1
- data/lib/vernier.rb +15 -14
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1af998d7d2517b6c73e0ef7ec8a3e0762c9c0ca23dd859d3c268842f064612f
|
|
4
|
+
data.tar.gz: 558ad40b4ca261719e241f22806112e9ff18b2ba0e1a65d5c24067deee0803e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58a0c75db2f1ffb90017f887c4e6813653ef908efc4601cbe6fe105aa1493c5344028355d1ec68658e59c34d36b885fcd59340de553bfffc1a72fcadd7668398
|
|
7
|
+
data.tar.gz: a80cd4ab4c0da94d3799cbedfe93de02788dc48bb7626565eff62cfc7606aa75c93892f5dab17d6b076abf6ea361cbf9231940cfb40384c8116490aff5dee79e
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4.0
|
data/ext/vernier/heap_tracker.cc
CHANGED
|
@@ -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(
|
|
68
|
-
assert(objects_allocated == object_list.size());
|
|
66
|
+
assert(frame_list.size() == object_list.size());
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
void rebuild() {
|
|
@@ -140,6 +138,9 @@ class HeapTracker {
|
|
|
140
138
|
if (RTEST(tp_newobj)) {
|
|
141
139
|
rb_tracepoint_disable(tp_newobj);
|
|
142
140
|
tp_newobj = Qnil;
|
|
141
|
+
if (tombstones * 10 > object_list.size()) {
|
|
142
|
+
rebuild();
|
|
143
|
+
}
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
data/ext/vernier/vernier.cc
CHANGED
data/lib/vernier/autorun.rb
CHANGED
|
@@ -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 =
|
|
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
|
data/lib/vernier/marker.rb
CHANGED
data/lib/vernier/middleware.rb
CHANGED
|
@@ -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:
|
|
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)
|
|
@@ -267,6 +267,11 @@ module Vernier
|
|
|
267
267
|
end
|
|
268
268
|
|
|
269
269
|
class Thread
|
|
270
|
+
SAMPLE_CATEGORY_NAMES = {
|
|
271
|
+
1 => "Idle",
|
|
272
|
+
2 => "Stalled"
|
|
273
|
+
}.freeze
|
|
274
|
+
|
|
270
275
|
attr_reader :profile, :is_start
|
|
271
276
|
|
|
272
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)
|
|
@@ -355,6 +360,31 @@ module Vernier
|
|
|
355
360
|
@frame_subcategories = @stack_table_hash[:frame_table].fetch(:func).map do |func_idx|
|
|
356
361
|
func_subcategories[func_idx]
|
|
357
362
|
end
|
|
363
|
+
|
|
364
|
+
@sample_category_idx = SAMPLE_CATEGORY_NAMES.transform_values do |name|
|
|
365
|
+
@categorizer.get_category(name).idx
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
@samples.zip(@sample_categories).each do |sample, raw_category|
|
|
369
|
+
next if raw_category == 0
|
|
370
|
+
|
|
371
|
+
@categorized_stacks[[sample, raw_category]]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
base_stack_frames = @stack_table_hash[:stack_table].fetch(:frame)
|
|
375
|
+
base_frame_count = @stack_table_hash[:frame_table].fetch(:func).size
|
|
376
|
+
@extra_frames = []
|
|
377
|
+
@categorized_frame_map = {}
|
|
378
|
+
|
|
379
|
+
@categorized_stacks.each_key do |(stack, raw_category)|
|
|
380
|
+
original_frame_idx = base_stack_frames[stack]
|
|
381
|
+
key = [original_frame_idx, raw_category]
|
|
382
|
+
next if @categorized_frame_map.key?(key)
|
|
383
|
+
|
|
384
|
+
new_frame_idx = base_frame_count + @extra_frames.size
|
|
385
|
+
@categorized_frame_map[key] = new_frame_idx
|
|
386
|
+
@extra_frames << [original_frame_idx, @sample_category_idx[raw_category]]
|
|
387
|
+
end
|
|
358
388
|
end
|
|
359
389
|
|
|
360
390
|
def categorize_filename(filename)
|
|
@@ -377,7 +407,7 @@ module Vernier
|
|
|
377
407
|
def find_category_and_subcategory(filename, categories)
|
|
378
408
|
categories.each do |category_name|
|
|
379
409
|
category = @categorizer.get_category(category_name)
|
|
380
|
-
subcategory = category.subcategories.detect {|c| c.matches?(filename) }&.idx
|
|
410
|
+
subcategory = category.subcategories.detect { |c| c.matches?(filename) }&.idx
|
|
381
411
|
return category, subcategory if subcategory
|
|
382
412
|
end
|
|
383
413
|
[nil, nil]
|
|
@@ -430,7 +460,7 @@ module Vernier
|
|
|
430
460
|
categories = []
|
|
431
461
|
data = []
|
|
432
462
|
|
|
433
|
-
@markers.
|
|
463
|
+
@markers.each do |(_, name, start, finish, phase, datum)|
|
|
434
464
|
string_indexes << @strings[name]
|
|
435
465
|
start_times << (start / 1_000_000.0)
|
|
436
466
|
|
|
@@ -463,12 +493,14 @@ module Vernier
|
|
|
463
493
|
end
|
|
464
494
|
|
|
465
495
|
def allocations_table
|
|
466
|
-
return nil
|
|
496
|
+
return nil unless @allocations
|
|
497
|
+
|
|
467
498
|
samples, weights, timestamps = @allocations.values_at(:samples, :weights, :timestamps)
|
|
468
|
-
return nil if samples.
|
|
499
|
+
return nil if samples.empty?
|
|
500
|
+
|
|
469
501
|
size = samples.size
|
|
470
502
|
timestamps = timestamps.map { _1 / 1_000_000.0 }
|
|
471
|
-
|
|
503
|
+
{
|
|
472
504
|
"time": timestamps,
|
|
473
505
|
"className": ["Object"]*size,
|
|
474
506
|
"typeName": ["JSObject"]*size,
|
|
@@ -478,7 +510,6 @@ module Vernier
|
|
|
478
510
|
"stack": samples,
|
|
479
511
|
"length": size
|
|
480
512
|
}
|
|
481
|
-
ret
|
|
482
513
|
end
|
|
483
514
|
|
|
484
515
|
def samples_table
|
|
@@ -518,21 +549,22 @@ module Vernier
|
|
|
518
549
|
end
|
|
519
550
|
|
|
520
551
|
def stack_table
|
|
521
|
-
|
|
552
|
+
base_frames = @stack_table_hash[:stack_table].fetch(:frame)
|
|
553
|
+
frames = base_frames.dup
|
|
522
554
|
prefixes = @stack_table_hash[:stack_table].fetch(:parent).dup
|
|
523
|
-
categories
|
|
524
|
-
subcategories
|
|
555
|
+
categories = frames.map { |idx| @frame_categories[idx].idx }
|
|
556
|
+
subcategories = frames.map { |idx| @frame_subcategories[idx] }
|
|
525
557
|
|
|
526
|
-
@categorized_stacks.each_key do |(stack,
|
|
527
|
-
|
|
558
|
+
@categorized_stacks.each_key do |(stack, raw_category)|
|
|
559
|
+
original_frame_idx = base_frames[stack]
|
|
560
|
+
frames << @categorized_frame_map[[original_frame_idx, raw_category]]
|
|
528
561
|
prefixes << prefixes[stack]
|
|
529
|
-
categories <<
|
|
562
|
+
categories << @sample_category_idx[raw_category]
|
|
530
563
|
subcategories << 0
|
|
531
564
|
end
|
|
532
565
|
|
|
533
|
-
size
|
|
534
|
-
|
|
535
|
-
raise unless prefixes.size == size
|
|
566
|
+
raise unless prefixes.size == frames.size
|
|
567
|
+
|
|
536
568
|
{
|
|
537
569
|
frame: frames,
|
|
538
570
|
category: categories,
|
|
@@ -543,18 +575,27 @@ module Vernier
|
|
|
543
575
|
end
|
|
544
576
|
|
|
545
577
|
def frame_table
|
|
546
|
-
funcs = @stack_table_hash[:frame_table].fetch(:func)
|
|
547
|
-
lines = @stack_table_hash[:frame_table].fetch(:line)
|
|
578
|
+
funcs = @stack_table_hash[:frame_table].fetch(:func).dup
|
|
579
|
+
lines = @stack_table_hash[:frame_table].fetch(:line).dup
|
|
548
580
|
raise unless lines.size == funcs.size
|
|
549
581
|
|
|
582
|
+
categories = @frame_categories.map(&:idx)
|
|
583
|
+
subcategories = @frame_subcategories.dup
|
|
584
|
+
implementations = @frame_implementations.dup
|
|
585
|
+
|
|
586
|
+
@extra_frames.each do |(frame_idx, category_idx)|
|
|
587
|
+
funcs << funcs[frame_idx]
|
|
588
|
+
lines << lines[frame_idx]
|
|
589
|
+
categories << category_idx
|
|
590
|
+
subcategories << 0
|
|
591
|
+
implementations << implementations[frame_idx]
|
|
592
|
+
end
|
|
593
|
+
|
|
550
594
|
size = funcs.size
|
|
551
595
|
none = [nil] * size
|
|
552
596
|
default = [0] * size
|
|
553
597
|
unidentified = [-1] * size
|
|
554
598
|
|
|
555
|
-
categories = @frame_categories.map(&:idx)
|
|
556
|
-
subcategories = @frame_subcategories
|
|
557
|
-
|
|
558
599
|
{
|
|
559
600
|
address: unidentified,
|
|
560
601
|
inlineDepth: default,
|
|
@@ -563,7 +604,7 @@ module Vernier
|
|
|
563
604
|
func: funcs,
|
|
564
605
|
nativeSymbol: none,
|
|
565
606
|
innerWindowID: none,
|
|
566
|
-
implementation:
|
|
607
|
+
implementation: implementations,
|
|
567
608
|
line: lines,
|
|
568
609
|
column: none,
|
|
569
610
|
length: size
|
|
@@ -605,11 +646,8 @@ module Vernier
|
|
|
605
646
|
else
|
|
606
647
|
string.scrub
|
|
607
648
|
end
|
|
608
|
-
elsif string.encoding == Encoding::BINARY
|
|
609
|
-
# TODO: We might want to guess UTF-8 and escape the binary more explicitly
|
|
610
|
-
string.dup.force_encoding("UTF-8").scrub
|
|
611
649
|
else
|
|
612
|
-
# TODO:
|
|
650
|
+
# TODO: We might want to guess UTF-8 and escape the binary more explicitly
|
|
613
651
|
string.dup.force_encoding("UTF-8").scrub
|
|
614
652
|
end
|
|
615
653
|
end
|
|
@@ -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("&", "&")
|
|
410
|
+
.gsub("<", "<")
|
|
411
|
+
.gsub(">", ">")
|
|
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
|
-
|
|
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) = @
|
|
52
|
+
def func_first_lineno(idx) = @func_first_linenos[idx]
|
|
53
53
|
|
|
54
54
|
include StackTableHelpers
|
|
55
55
|
end
|
data/lib/vernier/result.rb
CHANGED
|
@@ -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)
|
data/lib/vernier/version.rb
CHANGED
data/lib/vernier.rb
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
4
|
+
version: 1.10.1
|
|
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:
|
|
137
|
+
rubygems_version: 4.0.6
|
|
137
138
|
specification_version: 4
|
|
138
139
|
summary: A next generation CRuby profiler
|
|
139
140
|
test_files: []
|