stackprof 0.2.15 → 0.2.21
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/.github/workflows/ci.yml +43 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +12 -1
- data/README.md +57 -51
- data/Rakefile +21 -25
- data/bin/stackprof +1 -1
- data/ext/stackprof/extconf.rb +6 -0
- data/ext/stackprof/stackprof.c +210 -71
- data/lib/stackprof/report.rb +65 -26
- data/lib/stackprof/truffleruby.rb +37 -0
- data/lib/stackprof.rb +10 -2
- data/stackprof.gemspec +8 -1
- data/test/fixtures/profile.dump +1 -0
- data/test/fixtures/profile.json +1 -0
- data/test/test_report.rb +24 -0
- data/test/test_stackprof.rb +75 -12
- data/test/test_truffleruby.rb +18 -0
- data/vendor/FlameGraph/flamegraph.pl +751 -85
- metadata +16 -10
- data/.travis.yml +0 -21
- data/Dockerfile +0 -21
- data/Gemfile.lock +0 -27
data/lib/stackprof/report.rb
CHANGED
@@ -1,8 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'pp'
|
2
|
-
require 'digest/
|
4
|
+
require 'digest/sha2'
|
5
|
+
require 'json'
|
3
6
|
|
4
7
|
module StackProf
|
5
8
|
class Report
|
9
|
+
MARSHAL_SIGNATURE = "\x04\x08"
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def from_file(file)
|
13
|
+
if (content = IO.binread(file)).start_with?(MARSHAL_SIGNATURE)
|
14
|
+
new(Marshal.load(content))
|
15
|
+
else
|
16
|
+
from_json(JSON.parse(content))
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def from_json(json)
|
21
|
+
new(parse_json(json))
|
22
|
+
end
|
23
|
+
|
24
|
+
def parse_json(json)
|
25
|
+
json.keys.each do |key|
|
26
|
+
value = json.delete(key)
|
27
|
+
from_json(value) if value.is_a?(Hash)
|
28
|
+
|
29
|
+
new_key = case key
|
30
|
+
when /\A[0-9]*\z/
|
31
|
+
key.to_i
|
32
|
+
else
|
33
|
+
key.to_sym
|
34
|
+
end
|
35
|
+
|
36
|
+
json[new_key] = value
|
37
|
+
end
|
38
|
+
json
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
6
42
|
def initialize(data)
|
7
43
|
@data = data
|
8
44
|
end
|
@@ -16,7 +52,7 @@ module StackProf
|
|
16
52
|
def normalized_frames
|
17
53
|
id2hash = {}
|
18
54
|
@data[:frames].each do |frame, info|
|
19
|
-
id2hash[frame.to_s] = info[:hash] = Digest::
|
55
|
+
id2hash[frame.to_s] = info[:hash] = Digest::SHA256.hexdigest("#{info[:name]}#{info[:file]}#{info[:line]}")
|
20
56
|
end
|
21
57
|
@data[:frames].inject(Hash.new) do |hash, (frame, info)|
|
22
58
|
info = hash[id2hash[frame.to_s]] = info.dup
|
@@ -38,7 +74,7 @@ module StackProf
|
|
38
74
|
end
|
39
75
|
|
40
76
|
def max_samples
|
41
|
-
@data[:max_samples] ||= frames.max_by{ |
|
77
|
+
@data[:max_samples] ||= @data[:frames].values.max_by{ |frame| frame[:samples] }[:samples]
|
42
78
|
end
|
43
79
|
|
44
80
|
def files
|
@@ -96,15 +132,7 @@ module StackProf
|
|
96
132
|
def print_flamegraph(f, skip_common, alphabetical=false)
|
97
133
|
raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
|
98
134
|
|
99
|
-
stacks =
|
100
|
-
max_x = 0
|
101
|
-
max_y = 0
|
102
|
-
while len = raw.shift
|
103
|
-
max_y = len if len > max_y
|
104
|
-
stack = raw.slice!(0, len+1)
|
105
|
-
stacks << stack
|
106
|
-
max_x += stack.last
|
107
|
-
end
|
135
|
+
stacks, max_x, max_y = flamegraph_stacks(raw)
|
108
136
|
|
109
137
|
stacks.sort! if alphabetical
|
110
138
|
|
@@ -156,8 +184,26 @@ module StackProf
|
|
156
184
|
f.puts '])'
|
157
185
|
end
|
158
186
|
|
187
|
+
def flamegraph_stacks(raw)
|
188
|
+
stacks = []
|
189
|
+
max_x = 0
|
190
|
+
max_y = 0
|
191
|
+
idx = 0
|
192
|
+
|
193
|
+
while len = raw[idx]
|
194
|
+
idx += 1
|
195
|
+
max_y = len if len > max_y
|
196
|
+
stack = raw.slice(idx, len+1)
|
197
|
+
idx += len+1
|
198
|
+
stacks << stack
|
199
|
+
max_x += stack.last
|
200
|
+
end
|
201
|
+
|
202
|
+
return stacks, max_x, max_y
|
203
|
+
end
|
204
|
+
|
159
205
|
def flamegraph_row(f, x, y, weight, addr)
|
160
|
-
frame = frames[addr]
|
206
|
+
frame = @data[:frames][addr]
|
161
207
|
f.print ',' if @rows_started
|
162
208
|
@rows_started = true
|
163
209
|
f.puts %{{"x":#{x},"y":#{y},"width":#{weight},"frame_id":#{addr},"frame":#{frame[:name].dump},"file":#{frame[:file].dump}}}
|
@@ -178,8 +224,8 @@ module StackProf
|
|
178
224
|
weight += stack.last
|
179
225
|
end
|
180
226
|
else
|
181
|
-
frame = frames[val]
|
182
|
-
child_name = "#{ frame[:name] } : #{ frame[:file] }"
|
227
|
+
frame = @data[:frames][val]
|
228
|
+
child_name = "#{ frame[:name] } : #{ frame[:file] } : #{ frame[:line] }"
|
183
229
|
child_data = convert_to_d3_flame_graph_format(child_name, child_stacks, depth + 1)
|
184
230
|
weight += child_data["value"]
|
185
231
|
children << child_data
|
@@ -196,15 +242,7 @@ module StackProf
|
|
196
242
|
def print_d3_flamegraph(f=STDOUT, skip_common=true)
|
197
243
|
raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
|
198
244
|
|
199
|
-
stacks =
|
200
|
-
max_x = 0
|
201
|
-
max_y = 0
|
202
|
-
while len = raw.shift
|
203
|
-
max_y = len if len > max_y
|
204
|
-
stack = raw.slice!(0, len+1)
|
205
|
-
stacks << stack
|
206
|
-
max_x += stack.last
|
207
|
-
end
|
245
|
+
stacks, * = flamegraph_stacks(raw)
|
208
246
|
|
209
247
|
# d3-flame-grpah supports only alphabetical flamegraph
|
210
248
|
stacks.sort!
|
@@ -410,7 +448,7 @@ module StackProf
|
|
410
448
|
call, total = info.values_at(:samples, :total_samples)
|
411
449
|
break if total < node_minimum || (limit && index >= limit)
|
412
450
|
|
413
|
-
sample = ''
|
451
|
+
sample = ''.dup
|
414
452
|
sample << "#{call} (%2.1f%%)\\rof " % (call*100.0/overall_samples) if call < total
|
415
453
|
sample << "#{total} (%2.1f%%)\\r" % (total*100.0/overall_samples)
|
416
454
|
fontsize = (1.0 * call / max_samples) * 28 + 10
|
@@ -654,7 +692,8 @@ module StackProf
|
|
654
692
|
end
|
655
693
|
end
|
656
694
|
end
|
695
|
+
rescue SystemCallError
|
696
|
+
f.puts " SOURCE UNAVAILABLE"
|
657
697
|
end
|
658
|
-
|
659
698
|
end
|
660
699
|
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module StackProf
|
2
|
+
# Define the same methods as stackprof.c
|
3
|
+
class << self
|
4
|
+
def running?
|
5
|
+
false
|
6
|
+
end
|
7
|
+
|
8
|
+
def run(*args)
|
9
|
+
unimplemented
|
10
|
+
end
|
11
|
+
|
12
|
+
def start(*args)
|
13
|
+
unimplemented
|
14
|
+
end
|
15
|
+
|
16
|
+
def stop
|
17
|
+
unimplemented
|
18
|
+
end
|
19
|
+
|
20
|
+
def results(*args)
|
21
|
+
unimplemented
|
22
|
+
end
|
23
|
+
|
24
|
+
def sample
|
25
|
+
unimplemented
|
26
|
+
end
|
27
|
+
|
28
|
+
def use_postponed_job!
|
29
|
+
# noop
|
30
|
+
end
|
31
|
+
|
32
|
+
private def unimplemented
|
33
|
+
raise "Use --cpusampler=flamegraph or --cpusampler instead of StackProf on TruffleRuby.\n" \
|
34
|
+
"See https://www.graalvm.org/tools/profiling/ and `ruby --help:cpusampler` for more details."
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/stackprof.rb
CHANGED
@@ -1,7 +1,15 @@
|
|
1
|
-
|
1
|
+
if RUBY_ENGINE == 'truffleruby'
|
2
|
+
require "stackprof/truffleruby"
|
3
|
+
else
|
4
|
+
require "stackprof/stackprof"
|
5
|
+
end
|
6
|
+
|
7
|
+
if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
|
8
|
+
StackProf.use_postponed_job!
|
9
|
+
end
|
2
10
|
|
3
11
|
module StackProf
|
4
|
-
VERSION = '0.2.
|
12
|
+
VERSION = '0.2.21'
|
5
13
|
end
|
6
14
|
|
7
15
|
StackProf.autoload :Report, "stackprof/report.rb"
|
data/stackprof.gemspec
CHANGED
@@ -1,11 +1,18 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'stackprof'
|
3
|
-
s.version = '0.2.
|
3
|
+
s.version = '0.2.21'
|
4
4
|
s.homepage = 'http://github.com/tmm1/stackprof'
|
5
5
|
|
6
6
|
s.authors = 'Aman Gupta'
|
7
7
|
s.email = 'aman@tmm1.net'
|
8
8
|
|
9
|
+
s.metadata = {
|
10
|
+
'bug_tracker_uri' => 'https://github.com/tmm1/stackprof/issues',
|
11
|
+
'changelog_uri' => "https://github.com/tmm1/stackprof/blob/v#{s.version}/CHANGELOG.md",
|
12
|
+
'documentation_uri' => "https://www.rubydoc.info/gems/stackprof/#{s.version}",
|
13
|
+
'source_code_uri' => "https://github.com/tmm1/stackprof/tree/v#{s.version}"
|
14
|
+
}
|
15
|
+
|
9
16
|
s.files = `git ls-files`.split("\n")
|
10
17
|
s.extensions = 'ext/stackprof/extconf.rb'
|
11
18
|
|
@@ -0,0 +1 @@
|
|
1
|
+
{: modeI"cpu:ET
|
@@ -0,0 +1 @@
|
|
1
|
+
{ "mode": "cpu" }
|
data/test/test_report.rb
CHANGED
@@ -32,3 +32,27 @@ class ReportDumpTest < MiniTest::Test
|
|
32
32
|
assert_equal expected, Marshal.load(marshal_data)
|
33
33
|
end
|
34
34
|
end
|
35
|
+
|
36
|
+
class ReportReadTest < MiniTest::Test
|
37
|
+
require 'pathname'
|
38
|
+
|
39
|
+
def test_from_file_read_json
|
40
|
+
file = fixture("profile.json")
|
41
|
+
report = StackProf::Report.from_file(file)
|
42
|
+
|
43
|
+
assert_equal({ mode: "cpu" }, report.data)
|
44
|
+
end
|
45
|
+
|
46
|
+
def test_from_file_read_marshal
|
47
|
+
file = fixture("profile.dump")
|
48
|
+
report = StackProf::Report.from_file(file)
|
49
|
+
|
50
|
+
assert_equal({ mode: "cpu" }, report.data)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def fixture(name)
|
56
|
+
Pathname.new(__dir__).join("fixtures", name)
|
57
|
+
end
|
58
|
+
end
|
data/test/test_stackprof.rb
CHANGED
@@ -5,6 +5,10 @@ require 'tempfile'
|
|
5
5
|
require 'pathname'
|
6
6
|
|
7
7
|
class StackProfTest < MiniTest::Test
|
8
|
+
def setup
|
9
|
+
Object.new # warm some caches to avoid flakiness
|
10
|
+
end
|
11
|
+
|
8
12
|
def test_info
|
9
13
|
profile = StackProf.run{}
|
10
14
|
assert_equal 1.2, profile[:version]
|
@@ -39,16 +43,30 @@ class StackProfTest < MiniTest::Test
|
|
39
43
|
end
|
40
44
|
assert_equal :object, profile[:mode]
|
41
45
|
assert_equal 1, profile[:interval]
|
42
|
-
|
46
|
+
if RUBY_VERSION >= '3'
|
47
|
+
assert_equal 4, profile[:samples]
|
48
|
+
else
|
49
|
+
assert_equal 2, profile[:samples]
|
50
|
+
end
|
43
51
|
|
44
52
|
frame = profile[:frames].values.first
|
45
53
|
assert_includes frame[:name], "StackProfTest#test_object_allocation"
|
46
54
|
assert_equal 2, frame[:samples]
|
47
55
|
assert_includes [profile_base_line - 2, profile_base_line], frame[:line]
|
48
|
-
|
49
|
-
|
56
|
+
if RUBY_VERSION >= '3'
|
57
|
+
assert_equal [2, 1], frame[:lines][profile_base_line+1]
|
58
|
+
assert_equal [2, 1], frame[:lines][profile_base_line+2]
|
59
|
+
else
|
60
|
+
assert_equal [1, 1], frame[:lines][profile_base_line+1]
|
61
|
+
assert_equal [1, 1], frame[:lines][profile_base_line+2]
|
62
|
+
end
|
50
63
|
frame = profile[:frames].values[1] if RUBY_VERSION < '2.3'
|
51
|
-
|
64
|
+
|
65
|
+
if RUBY_VERSION >= '3'
|
66
|
+
assert_equal [4, 0], frame[:lines][profile_base_line]
|
67
|
+
else
|
68
|
+
assert_equal [2, 0], frame[:lines][profile_base_line]
|
69
|
+
end
|
52
70
|
end
|
53
71
|
|
54
72
|
def test_object_allocation_interval
|
@@ -64,8 +82,14 @@ class StackProfTest < MiniTest::Test
|
|
64
82
|
end
|
65
83
|
|
66
84
|
assert_operator profile[:samples], :>=, 1
|
67
|
-
|
68
|
-
|
85
|
+
if RUBY_VERSION >= '3'
|
86
|
+
assert profile[:frames].values.take(2).map { |f|
|
87
|
+
f[:name].include? "StackProfTest#math"
|
88
|
+
}.any?
|
89
|
+
else
|
90
|
+
frame = profile[:frames].values.first
|
91
|
+
assert_includes frame[:name], "StackProfTest#math"
|
92
|
+
end
|
69
93
|
end
|
70
94
|
|
71
95
|
def test_walltime
|
@@ -74,7 +98,11 @@ class StackProfTest < MiniTest::Test
|
|
74
98
|
end
|
75
99
|
|
76
100
|
frame = profile[:frames].values.first
|
77
|
-
|
101
|
+
if RUBY_VERSION >= '3'
|
102
|
+
assert_equal "IO.select", frame[:name]
|
103
|
+
else
|
104
|
+
assert_equal "StackProfTest#idle", frame[:name]
|
105
|
+
end
|
78
106
|
assert_in_delta 200, frame[:samples], 25
|
79
107
|
end
|
80
108
|
|
@@ -89,24 +117,51 @@ class StackProfTest < MiniTest::Test
|
|
89
117
|
assert_equal :custom, profile[:mode]
|
90
118
|
assert_equal 10, profile[:samples]
|
91
119
|
|
92
|
-
|
120
|
+
offset = RUBY_VERSION >= '3' ? 1 : 0
|
121
|
+
frame = profile[:frames].values[offset]
|
93
122
|
assert_includes frame[:name], "StackProfTest#test_custom"
|
94
123
|
assert_includes [profile_base_line-2, profile_base_line+1], frame[:line]
|
95
|
-
|
124
|
+
|
125
|
+
if RUBY_VERSION >= '3'
|
126
|
+
assert_equal [10, 0], frame[:lines][profile_base_line+2]
|
127
|
+
else
|
128
|
+
assert_equal [10, 10], frame[:lines][profile_base_line+2]
|
129
|
+
end
|
96
130
|
end
|
97
131
|
|
98
132
|
def test_raw
|
133
|
+
before_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
|
134
|
+
|
99
135
|
profile = StackProf.run(mode: :custom, raw: true) do
|
100
136
|
10.times do
|
101
137
|
StackProf.sample
|
138
|
+
sleep 0.0001
|
102
139
|
end
|
103
140
|
end
|
104
141
|
|
142
|
+
after_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
|
143
|
+
|
105
144
|
raw = profile[:raw]
|
106
145
|
assert_equal 10, raw[-1]
|
107
146
|
assert_equal raw[0] + 2, raw.size
|
108
|
-
|
147
|
+
|
148
|
+
offset = RUBY_VERSION >= '3' ? -3 : -2
|
149
|
+
assert_includes profile[:frames][raw[offset]][:name], 'StackProfTest#test_raw'
|
150
|
+
|
151
|
+
assert_equal 10, profile[:raw_sample_timestamps].size
|
152
|
+
profile[:raw_sample_timestamps].each_cons(2) do |t1, t2|
|
153
|
+
assert_operator t1, :>, before_monotonic
|
154
|
+
assert_operator t2, :>=, t1
|
155
|
+
assert_operator t2, :<, after_monotonic
|
156
|
+
end
|
157
|
+
|
109
158
|
assert_equal 10, profile[:raw_timestamp_deltas].size
|
159
|
+
total_duration = after_monotonic - before_monotonic
|
160
|
+
assert_operator profile[:raw_timestamp_deltas].inject(&:+), :<, total_duration
|
161
|
+
|
162
|
+
profile[:raw_timestamp_deltas].each do |delta|
|
163
|
+
assert_operator delta, :>, 0
|
164
|
+
end
|
110
165
|
end
|
111
166
|
|
112
167
|
def test_metadata
|
@@ -178,7 +233,6 @@ class StackProfTest < MiniTest::Test
|
|
178
233
|
end
|
179
234
|
end
|
180
235
|
|
181
|
-
raw = profile[:raw]
|
182
236
|
gc_frame = profile[:frames].values.find{ |f| f[:name] == "(garbage collection)" }
|
183
237
|
marking_frame = profile[:frames].values.find{ |f| f[:name] == "(marking)" }
|
184
238
|
sweeping_frame = profile[:frames].values.find{ |f| f[:name] == "(sweeping)" }
|
@@ -232,6 +286,15 @@ class StackProfTest < MiniTest::Test
|
|
232
286
|
refute_empty profile[:frames]
|
233
287
|
end
|
234
288
|
|
289
|
+
def test_min_max_interval
|
290
|
+
[-1, 0, 1_000_000, 1_000_001].each do |invalid_interval|
|
291
|
+
err = assert_raises(ArgumentError, "invalid interval #{invalid_interval}") do
|
292
|
+
StackProf.run(interval: invalid_interval, debug: true) {}
|
293
|
+
end
|
294
|
+
assert_match(/microseconds/, err.message)
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
235
298
|
def math
|
236
299
|
250_000.times do
|
237
300
|
2 ** 10
|
@@ -245,4 +308,4 @@ class StackProfTest < MiniTest::Test
|
|
245
308
|
r.close
|
246
309
|
w.close
|
247
310
|
end
|
248
|
-
end
|
311
|
+
end unless RUBY_ENGINE == 'truffleruby'
|
@@ -0,0 +1,18 @@
|
|
1
|
+
$:.unshift File.expand_path('../../lib', __FILE__)
|
2
|
+
require 'stackprof'
|
3
|
+
require 'minitest/autorun'
|
4
|
+
|
5
|
+
if RUBY_ENGINE == 'truffleruby'
|
6
|
+
class StackProfTruffleRubyTest < MiniTest::Test
|
7
|
+
def test_error
|
8
|
+
error = assert_raises RuntimeError do
|
9
|
+
StackProf.run(mode: :cpu) do
|
10
|
+
unreacheable
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
assert_match(/TruffleRuby/, error.message)
|
15
|
+
assert_match(/--cpusampler/, error.message)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|