rperf 0.7.0 → 0.9.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 +4 -4
- data/LICENSE +21 -0
- data/README.md +75 -49
- data/docs/help.md +255 -36
- data/docs/logo.svg +25 -0
- data/exe/rperf +154 -30
- data/ext/rperf/rperf.c +235 -121
- data/lib/rperf/active_job.rb +1 -0
- data/lib/rperf/rack.rb +25 -3
- data/lib/rperf/version.rb +1 -1
- data/lib/rperf/viewer.rb +847 -0
- data/lib/rperf.rb +663 -92
- metadata +7 -4
data/exe/rperf
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
require "optparse"
|
|
3
3
|
require "socket"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
|
|
5
6
|
def find_available_port
|
|
6
7
|
server = TCPServer.new("localhost", 0)
|
|
@@ -23,6 +24,12 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
23
24
|
mode = :text
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
if min_files == 1
|
|
28
|
+
opts.on("--html", "Output static HTML viewer to stdout") do
|
|
29
|
+
mode = :html
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
opts.on("-h", "--help", "Show this help") do
|
|
27
34
|
puts opts
|
|
28
35
|
exit
|
|
@@ -31,7 +38,7 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
31
38
|
|
|
32
39
|
begin
|
|
33
40
|
parser.order!(ARGV)
|
|
34
|
-
rescue OptionParser::InvalidOption => e
|
|
41
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
|
|
35
42
|
$stderr.puts e.message
|
|
36
43
|
$stderr.puts parser
|
|
37
44
|
exit 1
|
|
@@ -44,7 +51,7 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
44
51
|
end
|
|
45
52
|
|
|
46
53
|
files = ARGV.shift(min_files > 1 ? [ARGV.size, min_files].min : 1)
|
|
47
|
-
files = ["rperf.
|
|
54
|
+
files = ["rperf.json.gz"] if files.empty? && min_files == 1
|
|
48
55
|
|
|
49
56
|
if min_files > 1 && files.size < min_files
|
|
50
57
|
$stderr.puts "Two profile files required."
|
|
@@ -59,22 +66,19 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
59
66
|
end
|
|
60
67
|
end
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
$stderr.puts "'go' command not found. Install Go to use 'rperf #{name}'."
|
|
64
|
-
$stderr.puts " https://go.dev/dl/"
|
|
65
|
-
exit 1
|
|
66
|
-
end
|
|
67
|
-
|
|
69
|
+
# Go check is deferred — only needed for pprof files, not marshal/json
|
|
68
70
|
yield mode, files
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
def self.help_text
|
|
74
|
+
@help_text ||= File.read(File.expand_path("../docs/help.md", __dir__))
|
|
75
|
+
end
|
|
72
76
|
|
|
73
77
|
USAGE = "Usage: rperf record [options] command [args...]\n" \
|
|
74
78
|
" rperf stat [options] command [args...]\n" \
|
|
75
79
|
" rperf exec [options] command [args...]\n" \
|
|
76
80
|
" rperf report [options] [file]\n" \
|
|
77
|
-
" rperf diff [options] base
|
|
81
|
+
" rperf diff [options] base target\n" \
|
|
78
82
|
" rperf help\n"
|
|
79
83
|
|
|
80
84
|
# Handle top-level flags before subcommand parsing
|
|
@@ -94,31 +98,127 @@ subcommand = ARGV.shift
|
|
|
94
98
|
|
|
95
99
|
case subcommand
|
|
96
100
|
when "help"
|
|
97
|
-
puts
|
|
101
|
+
puts help_text
|
|
98
102
|
exit
|
|
99
103
|
when "report"
|
|
100
104
|
run_pprof_subcommand("report",
|
|
101
105
|
"Usage: rperf report [options] [file]\n" \
|
|
102
|
-
" Opens
|
|
103
|
-
" Default file: rperf.
|
|
106
|
+
" Opens profile in browser (default) or prints summary.\n" \
|
|
107
|
+
" Default file: rperf.json.gz\n" \
|
|
108
|
+
" For .json.gz: opens rperf viewer (no Go required).\n" \
|
|
109
|
+
" For .pb.gz: opens go tool pprof (requires Go).",
|
|
104
110
|
min_files: 1) do |mode, files|
|
|
105
111
|
report_file = files[0]
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
if report_file =~ /\.json(\.gz)?\z/
|
|
113
|
+
# Use rperf viewer
|
|
114
|
+
require_relative "../lib/rperf"
|
|
115
|
+
require_relative "../lib/rperf/viewer"
|
|
116
|
+
|
|
117
|
+
data = Rperf.load(report_file)
|
|
118
|
+
|
|
119
|
+
case mode
|
|
120
|
+
when :top
|
|
121
|
+
$stdout.puts Rperf::Text.encode(data, top_n: 20, header: false)
|
|
122
|
+
exit
|
|
123
|
+
when :text
|
|
124
|
+
$stdout.puts Rperf::Text.encode(data)
|
|
125
|
+
exit
|
|
126
|
+
when :html
|
|
127
|
+
$stdout.puts Rperf::Viewer.render_static_html(data)
|
|
128
|
+
exit
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
port = find_available_port
|
|
132
|
+
|
|
133
|
+
app = Rperf::Viewer.new(
|
|
134
|
+
proc { |_| [404, { "content-type" => "text/plain" }, ["Not Found"]] },
|
|
135
|
+
path: ""
|
|
136
|
+
)
|
|
137
|
+
app.add_snapshot(data)
|
|
138
|
+
|
|
139
|
+
url = "http://localhost:#{port}/"
|
|
140
|
+
$stderr.puts "Serving rperf viewer at #{url}"
|
|
141
|
+
$stderr.puts "Press Ctrl-C to stop."
|
|
142
|
+
|
|
143
|
+
# Open browser after server is ready
|
|
144
|
+
Thread.new do
|
|
145
|
+
20.times do
|
|
146
|
+
TCPSocket.new("localhost", port).close
|
|
147
|
+
break
|
|
148
|
+
rescue Errno::ECONNREFUSED
|
|
149
|
+
sleep 0.1
|
|
150
|
+
end
|
|
151
|
+
case RUBY_PLATFORM
|
|
152
|
+
when /darwin/ then system("open", url)
|
|
153
|
+
when /linux/ then system("xdg-open", url, err: File::NULL) || system("sensible-browser", url, err: File::NULL)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Find an available Rack handler
|
|
158
|
+
begin
|
|
159
|
+
require "rackup/handler"
|
|
160
|
+
rescue LoadError
|
|
161
|
+
$stderr.puts "Error: 'rackup' gem is required for 'rperf report'. Install it with:"
|
|
162
|
+
$stderr.puts " gem install rackup"
|
|
163
|
+
exit 1
|
|
164
|
+
end
|
|
165
|
+
handler = Rackup::Handler.default
|
|
166
|
+
handler.run(app, Port: port, Host: "localhost", Silent: true)
|
|
167
|
+
exit
|
|
168
|
+
else
|
|
169
|
+
unless system("go", "version", out: File::NULL, err: File::NULL)
|
|
170
|
+
$stderr.puts "'go' command not found. Install Go to use pprof files, or use .json.gz format."
|
|
171
|
+
$stderr.puts " https://go.dev/dl/"
|
|
172
|
+
exit 1
|
|
173
|
+
end
|
|
174
|
+
case mode
|
|
175
|
+
when :top then exec("go", "tool", "pprof", "-top", report_file)
|
|
176
|
+
when :text then exec("go", "tool", "pprof", "-text", report_file)
|
|
177
|
+
else exec("go", "tool", "pprof", "-http=localhost:#{find_available_port}", report_file)
|
|
178
|
+
end
|
|
110
179
|
end
|
|
111
180
|
end
|
|
112
181
|
when "diff"
|
|
113
182
|
run_pprof_subcommand("diff",
|
|
114
|
-
"Usage: rperf diff [options] base
|
|
115
|
-
" Compare two
|
|
183
|
+
"Usage: rperf diff [options] base target\n" \
|
|
184
|
+
" Compare two profiles (shows target - base).\n" \
|
|
185
|
+
" Accepts .json.gz (auto-converted) or .pb.gz files.\n" \
|
|
186
|
+
" Requires Go (https://go.dev/dl/).",
|
|
116
187
|
min_files: 2) do |mode, files|
|
|
188
|
+
unless system("go", "version", out: File::NULL, err: File::NULL)
|
|
189
|
+
$stderr.puts "'go' command not found. Install Go to use 'rperf diff'."
|
|
190
|
+
$stderr.puts " https://go.dev/dl/"
|
|
191
|
+
exit 1
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
tmpfiles = []
|
|
195
|
+
files.each_with_index do |f, i|
|
|
196
|
+
if f =~ /\.json(\.gz)?\z/
|
|
197
|
+
require "tempfile"
|
|
198
|
+
require_relative "../lib/rperf"
|
|
199
|
+
data = Rperf.load(f)
|
|
200
|
+
tmp = Tempfile.new(["rperf_diff_#{i}", ".pb.gz"])
|
|
201
|
+
tmp.binmode
|
|
202
|
+
tmp.write(Rperf.__send__(:gzip, Rperf::PProf.encode(data)))
|
|
203
|
+
tmp.close
|
|
204
|
+
tmpfiles << tmp
|
|
205
|
+
files[i] = tmp.path
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
117
209
|
base_file, target_file = files
|
|
118
|
-
case mode
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
210
|
+
pprof_args = case mode
|
|
211
|
+
when :top then ["-top", "-diff_base=#{base_file}", target_file]
|
|
212
|
+
when :text then ["-text", "-diff_base=#{base_file}", target_file]
|
|
213
|
+
else ["-http=localhost:#{find_available_port}", "-diff_base=#{base_file}", target_file]
|
|
214
|
+
end
|
|
215
|
+
if tmpfiles.empty?
|
|
216
|
+
exec("go", "tool", "pprof", *pprof_args)
|
|
217
|
+
else
|
|
218
|
+
pid = spawn("go", "tool", "pprof", *pprof_args)
|
|
219
|
+
_, status = Process.wait2(pid)
|
|
220
|
+
tmpfiles.each(&:unlink)
|
|
221
|
+
exit(status.exitstatus || 1)
|
|
122
222
|
end
|
|
123
223
|
end
|
|
124
224
|
when "record", "stat", "exec"
|
|
@@ -129,7 +229,7 @@ else
|
|
|
129
229
|
exit 1
|
|
130
230
|
end
|
|
131
231
|
|
|
132
|
-
output = (subcommand == "record") ? "rperf.
|
|
232
|
+
output = (subcommand == "record") ? "rperf.json.gz" : nil
|
|
133
233
|
frequency = 1000
|
|
134
234
|
mode = (subcommand == "record") ? "cpu" : "wall"
|
|
135
235
|
format = nil
|
|
@@ -137,6 +237,7 @@ signal = nil
|
|
|
137
237
|
verbose = false
|
|
138
238
|
aggregate = true
|
|
139
239
|
stat_report = (subcommand == "exec")
|
|
240
|
+
inherit = true
|
|
140
241
|
|
|
141
242
|
parser = OptionParser.new do |opts|
|
|
142
243
|
opts.banner = case subcommand
|
|
@@ -145,7 +246,7 @@ parser = OptionParser.new do |opts|
|
|
|
145
246
|
when "exec" then "Usage: rperf exec [options] command [args...]"
|
|
146
247
|
end
|
|
147
248
|
|
|
148
|
-
opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.
|
|
249
|
+
opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.json.gz)' : ' (default: none)'}") do |v|
|
|
149
250
|
output = v
|
|
150
251
|
end
|
|
151
252
|
|
|
@@ -159,8 +260,8 @@ parser = OptionParser.new do |opts|
|
|
|
159
260
|
end
|
|
160
261
|
|
|
161
262
|
if subcommand == "record"
|
|
162
|
-
opts.on("--format FORMAT", %w[pprof collapsed text],
|
|
163
|
-
"Output format: pprof, collapsed, or text (default: auto from extension)") do |v|
|
|
263
|
+
opts.on("--format FORMAT", %w[pprof collapsed text json],
|
|
264
|
+
"Output format: json, pprof, collapsed, or text (default: auto from extension)") do |v|
|
|
164
265
|
format = v
|
|
165
266
|
end
|
|
166
267
|
|
|
@@ -171,7 +272,14 @@ parser = OptionParser.new do |opts|
|
|
|
171
272
|
end
|
|
172
273
|
|
|
173
274
|
opts.on("--signal VALUE", "Timer signal (Linux only): signal number, or 'false' for nanosleep thread") do |v|
|
|
174
|
-
|
|
275
|
+
if v == "false"
|
|
276
|
+
signal = "false"
|
|
277
|
+
elsif v =~ /\A\d+\z/
|
|
278
|
+
signal = v
|
|
279
|
+
else
|
|
280
|
+
$stderr.puts "Error: --signal must be a signal number or 'false', got: #{v.inspect}"
|
|
281
|
+
exit 1
|
|
282
|
+
end
|
|
175
283
|
end
|
|
176
284
|
|
|
177
285
|
opts.on("--no-aggregate", "Disable sample aggregation (keep raw samples)") do
|
|
@@ -184,6 +292,10 @@ parser = OptionParser.new do |opts|
|
|
|
184
292
|
end
|
|
185
293
|
end
|
|
186
294
|
|
|
295
|
+
opts.on("--no-inherit", "Do not profile forked/spawned child processes (default: inherit)") do
|
|
296
|
+
inherit = false
|
|
297
|
+
end
|
|
298
|
+
|
|
187
299
|
opts.on("-v", "--verbose", "Print sampling statistics to stderr") do
|
|
188
300
|
verbose = true
|
|
189
301
|
end
|
|
@@ -198,7 +310,7 @@ end
|
|
|
198
310
|
|
|
199
311
|
begin
|
|
200
312
|
parser.order!(ARGV)
|
|
201
|
-
rescue OptionParser::InvalidOption => e
|
|
313
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
|
|
202
314
|
$stderr.puts e.message
|
|
203
315
|
$stderr.puts parser
|
|
204
316
|
exit 1
|
|
@@ -233,7 +345,9 @@ if signal && signal != "false"
|
|
|
233
345
|
end
|
|
234
346
|
end
|
|
235
347
|
|
|
236
|
-
# Add lib dir to RUBYLIB so -rrperf can find the
|
|
348
|
+
# Add lib dir to RUBYLIB so -rrperf can find the correct version.
|
|
349
|
+
# RUBYLIB handles spaces in paths safely (PATH_SEPARATOR delimited).
|
|
350
|
+
# RUBYOPT -r<path> does not support spaces, so we use RUBYLIB + -rrperf.
|
|
237
351
|
lib_dir = File.expand_path("../lib", __dir__)
|
|
238
352
|
ENV["RUBYLIB"] = [lib_dir, ENV["RUBYLIB"]].compact.join(File::PATH_SEPARATOR)
|
|
239
353
|
ENV["RUBYOPT"] = "-rrperf #{ENV['RUBYOPT']}".strip
|
|
@@ -252,4 +366,14 @@ if subcommand == "stat" || subcommand == "exec"
|
|
|
252
366
|
ENV["RPERF_STAT_REPORT"] = "1" if stat_report
|
|
253
367
|
end
|
|
254
368
|
|
|
369
|
+
# Multi-process (fork) support: create a session directory for aggregation
|
|
370
|
+
if inherit
|
|
371
|
+
require_relative "../lib/rperf"
|
|
372
|
+
session_dir = Rperf.send(:_create_session_dir, clean_stale: true)
|
|
373
|
+
if session_dir
|
|
374
|
+
ENV["RPERF_ROOT_PROCESS"] = Process.pid.to_s
|
|
375
|
+
ENV["RPERF_SESSION_DIR"] = session_dir
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
255
379
|
exec(*ARGV)
|