stackprof 0.2.15 → 0.2.27

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.
@@ -0,0 +1,19 @@
1
+ require "stackprof"
2
+
3
+ options = {}
4
+ options[:mode] = ENV["STACKPROF_MODE"].to_sym if ENV.key?("STACKPROF_MODE")
5
+ options[:interval] = Integer(ENV["STACKPROF_INTERVAL"]) if ENV.key?("STACKPROF_INTERVAL")
6
+ options[:raw] = true if ENV["STACKPROF_RAW"]
7
+ options[:ignore_gc] = true if ENV["STACKPROF_IGNORE_GC"]
8
+
9
+ at_exit do
10
+ StackProf.stop
11
+ output_path = ENV.fetch("STACKPROF_OUT") do
12
+ require "tempfile"
13
+ Tempfile.create(["stackprof", ".dump"]).path
14
+ end
15
+ StackProf.results(output_path)
16
+ $stderr.puts("StackProf results dumped at: #{output_path}")
17
+ end
18
+
19
+ StackProf.start(**options)
@@ -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,24 @@
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
+ if RUBY_VERSION < "3.3"
9
+ # On 3.3 we don't need postponed jobs:
10
+ # https://github.com/ruby/ruby/commit/a1dc1a3de9683daf5a543d6f618e17aabfcb8708
11
+ StackProf.use_postponed_job!
12
+ end
13
+ elsif RUBY_VERSION == "3.2.0"
14
+ # 3.2.0 crash is the signal is received at the wrong time.
15
+ # Fixed in https://github.com/ruby/ruby/pull/7116
16
+ # The fix is backported in 3.2.1: https://bugs.ruby-lang.org/issues/19336
17
+ StackProf.use_postponed_job!
18
+ end
2
19
 
3
20
  module StackProf
4
- VERSION = '0.2.15'
21
+ VERSION = '0.2.27'
5
22
  end
6
23
 
7
24
  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.27'
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
 
@@ -22,6 +29,5 @@ Gem::Specification.new do |s|
22
29
  s.license = 'MIT'
23
30
 
24
31
  s.add_development_dependency 'rake-compiler', '~> 0.9'
25
- s.add_development_dependency 'mocha', '~> 0.14'
26
32
  s.add_development_dependency 'minitest', '~> 5.0'
27
33
  end
@@ -0,0 +1 @@
1
+ {: modeI"cpu:ET
@@ -0,0 +1 @@
1
+ { "mode": "cpu" }
@@ -2,9 +2,9 @@ $:.unshift File.expand_path('../../lib', __FILE__)
2
2
  require 'stackprof'
3
3
  require 'stackprof/middleware'
4
4
  require 'minitest/autorun'
5
- require 'mocha/setup'
5
+ require 'tmpdir'
6
6
 
7
- class StackProf::MiddlewareTest < MiniTest::Test
7
+ class StackProf::MiddlewareTest < Minitest::Test
8
8
 
9
9
  def test_path_default
10
10
  StackProf::Middleware.new(Object.new)
@@ -19,23 +19,36 @@ class StackProf::MiddlewareTest < MiniTest::Test
19
19
  end
20
20
 
21
21
  def test_save_default
22
- StackProf::Middleware.new(Object.new)
23
-
24
- StackProf.stubs(:results).returns({ mode: 'foo' })
25
- FileUtils.expects(:mkdir_p).with('tmp/')
26
- File.expects(:open).with(regexp_matches(/^tmp\/stackprof-foo/), 'wb')
27
-
28
- StackProf::Middleware.save
22
+ middleware = StackProf::Middleware.new(->(env) { 100.times { Object.new } },
23
+ save_every: 1,
24
+ enabled: true)
25
+ Dir.mktmpdir do |dir|
26
+ Dir.chdir(dir) { middleware.call({}) }
27
+ dir = File.join(dir, "tmp")
28
+ assert File.directory? dir
29
+ profile = Dir.entries(dir).reject { |x| File.directory?(x) }.first
30
+ assert profile
31
+ assert_equal "stackprof", profile.split("-")[0]
32
+ assert_equal "cpu", profile.split("-")[1]
33
+ assert_equal Process.pid.to_s, profile.split("-")[2]
34
+ end
29
35
  end
30
36
 
31
37
  def test_save_custom
32
- StackProf::Middleware.new(Object.new, { path: 'foo/' })
33
-
34
- StackProf.stubs(:results).returns({ mode: 'foo' })
35
- FileUtils.expects(:mkdir_p).with('foo/')
36
- File.expects(:open).with(regexp_matches(/^foo\/stackprof-foo/), 'wb')
37
-
38
- StackProf::Middleware.save
38
+ middleware = StackProf::Middleware.new(->(env) { 100.times { Object.new } },
39
+ path: "foo/",
40
+ save_every: 1,
41
+ enabled: true)
42
+ Dir.mktmpdir do |dir|
43
+ Dir.chdir(dir) { middleware.call({}) }
44
+ dir = File.join(dir, "foo")
45
+ assert File.directory? dir
46
+ profile = Dir.entries(dir).reject { |x| File.directory?(x) }.first
47
+ assert profile
48
+ assert_equal "stackprof", profile.split("-")[0]
49
+ assert_equal "cpu", profile.split("-")[1]
50
+ assert_equal Process.pid.to_s, profile.split("-")[2]
51
+ end
39
52
  end
40
53
 
41
54
  def test_enabled_should_use_a_proc_if_passed
@@ -70,4 +83,4 @@ class StackProf::MiddlewareTest < MiniTest::Test
70
83
  StackProf::Middleware.new(Object.new, metadata: metadata)
71
84
  assert_equal metadata, StackProf::Middleware.metadata
72
85
  end
73
- end
86
+ end unless RUBY_ENGINE == 'truffleruby'
data/test/test_report.rb CHANGED
@@ -2,7 +2,7 @@ $:.unshift File.expand_path('../../lib', __FILE__)
2
2
  require 'stackprof'
3
3
  require 'minitest/autorun'
4
4
 
5
- class ReportDumpTest < MiniTest::Test
5
+ class ReportDumpTest < Minitest::Test
6
6
  require 'stringio'
7
7
 
8
8
  def test_dump_to_stdout
@@ -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
@@ -4,7 +4,11 @@ require 'minitest/autorun'
4
4
  require 'tempfile'
5
5
  require 'pathname'
6
6
 
7
- class StackProfTest < MiniTest::Test
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,18 +82,31 @@ 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
96
+ GC.disable
72
97
  profile = StackProf.run(mode: :wall) do
73
98
  idle
74
99
  end
75
100
 
76
101
  frame = profile[:frames].values.first
77
- assert_equal "StackProfTest#idle", frame[:name]
102
+ if RUBY_VERSION >= '3'
103
+ assert_equal "IO.select", frame[:name]
104
+ else
105
+ assert_equal "StackProfTest#idle", frame[:name]
106
+ end
78
107
  assert_in_delta 200, frame[:samples], 25
108
+ ensure
109
+ GC.enable
79
110
  end
80
111
 
81
112
  def test_custom
@@ -89,24 +120,54 @@ class StackProfTest < MiniTest::Test
89
120
  assert_equal :custom, profile[:mode]
90
121
  assert_equal 10, profile[:samples]
91
122
 
92
- frame = profile[:frames].values.first
123
+ offset = RUBY_VERSION >= '3' ? 1 : 0
124
+ frame = profile[:frames].values[offset]
93
125
  assert_includes frame[:name], "StackProfTest#test_custom"
94
126
  assert_includes [profile_base_line-2, profile_base_line+1], frame[:line]
95
- assert_equal [10, 10], frame[:lines][profile_base_line+2]
127
+
128
+ if RUBY_VERSION >= '3'
129
+ assert_equal [10, 0], frame[:lines][profile_base_line+2]
130
+ else
131
+ assert_equal [10, 10], frame[:lines][profile_base_line+2]
132
+ end
96
133
  end
97
134
 
98
135
  def test_raw
136
+ before_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
137
+
99
138
  profile = StackProf.run(mode: :custom, raw: true) do
100
139
  10.times do
101
140
  StackProf.sample
141
+ sleep 0.0001
102
142
  end
103
143
  end
104
144
 
145
+ after_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
146
+
105
147
  raw = profile[:raw]
148
+ raw_lines = profile[:raw_lines]
106
149
  assert_equal 10, raw[-1]
107
150
  assert_equal raw[0] + 2, raw.size
108
- assert_includes profile[:frames][raw[-2]][:name], 'StackProfTest#test_raw'
151
+ assert_equal 10, raw_lines[-1] # seen 10 times
152
+
153
+ offset = RUBY_VERSION >= '3' ? -3 : -2
154
+ assert_equal 140, raw_lines[offset] # sample caller is on 140
155
+ assert_includes profile[:frames][raw[offset]][:name], 'StackProfTest#test_raw'
156
+
157
+ assert_equal 10, profile[:raw_sample_timestamps].size
158
+ profile[:raw_sample_timestamps].each_cons(2) do |t1, t2|
159
+ assert_operator t1, :>, before_monotonic
160
+ assert_operator t2, :>=, t1
161
+ assert_operator t2, :<, after_monotonic
162
+ end
163
+
109
164
  assert_equal 10, profile[:raw_timestamp_deltas].size
165
+ total_duration = after_monotonic - before_monotonic
166
+ assert_operator profile[:raw_timestamp_deltas].inject(&:+), :<, total_duration
167
+
168
+ profile[:raw_timestamp_deltas].each do |delta|
169
+ assert_operator delta, :>, 0
170
+ end
110
171
  end
111
172
 
112
173
  def test_metadata
@@ -178,7 +239,6 @@ class StackProfTest < MiniTest::Test
178
239
  end
179
240
  end
180
241
 
181
- raw = profile[:raw]
182
242
  gc_frame = profile[:frames].values.find{ |f| f[:name] == "(garbage collection)" }
183
243
  marking_frame = profile[:frames].values.find{ |f| f[:name] == "(marking)" }
184
244
  sweeping_frame = profile[:frames].values.find{ |f| f[:name] == "(sweeping)" }
@@ -187,8 +247,12 @@ class StackProfTest < MiniTest::Test
187
247
  assert marking_frame
188
248
  assert sweeping_frame
189
249
 
190
- assert_equal gc_frame[:total_samples], profile[:gc_samples]
191
- assert_equal profile[:gc_samples], [gc_frame, marking_frame, sweeping_frame].map{|x| x[:samples] }.inject(:+)
250
+ # We can't guarantee a certain number of GCs to run, so just assert
251
+ # that it's within some kind of delta
252
+ assert_in_delta gc_frame[:total_samples], profile[:gc_samples], 2
253
+
254
+ # Lazy marking / sweeping can cause this math to not add up, so also use a delta
255
+ assert_in_delta profile[:gc_samples], [gc_frame, marking_frame, sweeping_frame].map{|x| x[:samples] }.inject(:+), 2
192
256
 
193
257
  assert_operator profile[:gc_samples], :>, 0
194
258
  assert_operator profile[:missed_samples], :<=, 25
@@ -232,6 +296,15 @@ class StackProfTest < MiniTest::Test
232
296
  refute_empty profile[:frames]
233
297
  end
234
298
 
299
+ def test_min_max_interval
300
+ [-1, 0, 1_000_000, 1_000_001].each do |invalid_interval|
301
+ err = assert_raises(ArgumentError, "invalid interval #{invalid_interval}") do
302
+ StackProf.run(interval: invalid_interval, debug: true) {}
303
+ end
304
+ assert_match(/microseconds/, err.message)
305
+ end
306
+ end
307
+
235
308
  def math
236
309
  250_000.times do
237
310
  2 ** 10
@@ -245,4 +318,4 @@ class StackProfTest < MiniTest::Test
245
318
  r.close
246
319
  w.close
247
320
  end
248
- end
321
+ 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