stackprof 0.2.15 → 0.2.21

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,8 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pp'
2
- require 'digest/md5'
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::MD5.hexdigest("#{info[:name]}#{info[:file]}#{info[:line]}")
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{ |addr, frame| frame[:samples] }.last[:samples]
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
- require "stackprof/stackprof"
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.15'
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.15'
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
@@ -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
- assert_equal 2, profile[:samples]
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
- assert_equal [1, 1], frame[:lines][profile_base_line+1]
49
- assert_equal [1, 1], frame[:lines][profile_base_line+2]
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
- assert_equal [2, 0], frame[:lines][profile_base_line]
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
- frame = profile[:frames].values.first
68
- assert_includes frame[:name], "StackProfTest#math"
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
- assert_equal "StackProfTest#idle", frame[:name]
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
- frame = profile[:frames].values.first
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
- assert_equal [10, 10], frame[:lines][profile_base_line+2]
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
- assert_includes profile[:frames][raw[-2]][:name], 'StackProfTest#test_raw'
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