rperf 0.8.0 → 0.10.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 +26 -15
- data/docs/help.md +284 -18
- data/exe/rperf +278 -55
- data/ext/rperf/rperf.c +220 -81
- data/lib/rperf/active_job.rb +1 -0
- data/lib/rperf/meta.rb +343 -0
- data/lib/rperf/rack.rb +7 -2
- data/lib/rperf/table.rb +156 -0
- data/lib/rperf/version.rb +1 -1
- data/lib/rperf/viewer/viewer.html +1148 -0
- data/lib/rperf/viewer.rb +158 -661
- data/lib/rperf.rb +682 -89
- metadata +8 -4
data/lib/rperf/active_job.rb
CHANGED
data/lib/rperf/meta.rb
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# Profile metadata support: git/host info collection, summary statistics,
|
|
2
|
+
# snapshot file naming, and a meta/summary prefix reader that lists profiles
|
|
3
|
+
# without loading the sample body.
|
|
4
|
+
#
|
|
5
|
+
# JSON profiles written by rperf >= 0.10 place "meta" and "summary" as the
|
|
6
|
+
# first two top-level keys, so Meta.read can decompress only the head of the
|
|
7
|
+
# file and stop as soon as both are extracted.
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
require "time"
|
|
11
|
+
require "zlib"
|
|
12
|
+
|
|
13
|
+
module Rperf
|
|
14
|
+
module Meta
|
|
15
|
+
FORMAT_VERSION = 1
|
|
16
|
+
TOP_METHODS_LIMIT = 50
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Collect git information for the profiled working directory.
|
|
21
|
+
# GitHub Actions environment variables take priority over git commands
|
|
22
|
+
# (CI checkouts may be detached or shallow). Returns a Hash with
|
|
23
|
+
# sha/branch/subject/committed_at/dirty, or nil when not in a git
|
|
24
|
+
# repository or git is unavailable.
|
|
25
|
+
def collect_git(dir = Dir.pwd)
|
|
26
|
+
gh_sha = ENV["GITHUB_SHA"]
|
|
27
|
+
# Validate the sha shape: the value is passed to git as a positional
|
|
28
|
+
# argument, and a crafted value starting with "-" would be parsed as
|
|
29
|
+
# a git option
|
|
30
|
+
if gh_sha && gh_sha.match?(/\A\h{7,64}\z/)
|
|
31
|
+
git = { sha: gh_sha, dirty: false }
|
|
32
|
+
branch = ENV["GITHUB_HEAD_REF"]
|
|
33
|
+
branch = ENV["GITHUB_REF_NAME"] if branch.nil? || branch.empty?
|
|
34
|
+
git[:branch] = branch if branch && !branch.empty?
|
|
35
|
+
# Enrich from the local checkout when possible (may fail on shallow clones)
|
|
36
|
+
subject = git_capture(dir, "log", "-1", "--format=%s", gh_sha)
|
|
37
|
+
committed_at = git_capture(dir, "log", "-1", "--format=%cI", gh_sha)
|
|
38
|
+
git[:subject] = subject if subject && !subject.empty?
|
|
39
|
+
git[:committed_at] = committed_at if committed_at && !committed_at.empty?
|
|
40
|
+
return git
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sha = git_capture(dir, "rev-parse", "HEAD")
|
|
44
|
+
return nil if sha.nil? || sha.empty?
|
|
45
|
+
|
|
46
|
+
git = { sha: sha }
|
|
47
|
+
branch = git_capture(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
48
|
+
git[:branch] = branch if branch && !branch.empty? && branch != "HEAD"
|
|
49
|
+
subject = git_capture(dir, "log", "-1", "--format=%s")
|
|
50
|
+
git[:subject] = subject if subject && !subject.empty?
|
|
51
|
+
committed_at = git_capture(dir, "log", "-1", "--format=%cI")
|
|
52
|
+
git[:committed_at] = committed_at if committed_at && !committed_at.empty?
|
|
53
|
+
status = git_capture(dir, "status", "--porcelain")
|
|
54
|
+
git[:dirty] = !status.empty? if status
|
|
55
|
+
git
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Run a git command, returning stripped stdout or nil on failure
|
|
59
|
+
# (no git binary, not a repository, etc.).
|
|
60
|
+
def git_capture(dir, *args)
|
|
61
|
+
out = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read)
|
|
62
|
+
$?.success? ? out.strip : nil
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# File name used by `rperf record --snapshot-dir`.
|
|
68
|
+
# In a git repository: rperf-<sha7>-<timestamp>.json.gz
|
|
69
|
+
# Outside: rperf-nogit-<timestamp>-<pid>.json.gz
|
|
70
|
+
def snapshot_filename(git, time: Time.now.utc, pid: Process.pid)
|
|
71
|
+
ts = time.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
72
|
+
if git && git[:sha]
|
|
73
|
+
"rperf-#{git[:sha][0, 7]}-#{ts}.json.gz"
|
|
74
|
+
else
|
|
75
|
+
"rperf-nogit-#{ts}-#{pid}.json.gz"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build the meta hash for a profile about to be written.
|
|
80
|
+
# Git info comes from RPERF_META_GIT (set by the CLI, which collects it
|
|
81
|
+
# before exec so a chdir in the profiled app cannot point at the wrong
|
|
82
|
+
# repository); when unset (direct API usage) it is collected here.
|
|
83
|
+
# RPERF_META_GIT="null" means "already checked, not a repository".
|
|
84
|
+
def build_meta(data)
|
|
85
|
+
meta = {
|
|
86
|
+
format_version: FORMAT_VERSION,
|
|
87
|
+
created_at: Time.now.utc.iso8601,
|
|
88
|
+
ruby_version: RUBY_VERSION,
|
|
89
|
+
rperf_version: Rperf::VERSION,
|
|
90
|
+
mode: (data[:mode] || :cpu).to_s,
|
|
91
|
+
}
|
|
92
|
+
hostname = safe_hostname
|
|
93
|
+
meta[:hostname] = hostname if hostname
|
|
94
|
+
git = git_from_env_or_collect
|
|
95
|
+
meta[:git] = git if git
|
|
96
|
+
labels = labels_from_env
|
|
97
|
+
meta[:labels] = labels if labels && !labels.empty?
|
|
98
|
+
meta
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def git_from_env_or_collect
|
|
102
|
+
if ENV.key?("RPERF_META_GIT")
|
|
103
|
+
v = ENV["RPERF_META_GIT"].to_s
|
|
104
|
+
return nil if v.empty? || v == "null"
|
|
105
|
+
begin
|
|
106
|
+
JSON.parse(v, symbolize_names: true)
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
# Memoized (array wraps a legitimate nil): periodic viewer snapshots
|
|
112
|
+
# must not spawn git subprocesses on every take_snapshot!
|
|
113
|
+
@collect_git_memo ||= [collect_git]
|
|
114
|
+
@collect_git_memo[0]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def labels_from_env
|
|
119
|
+
v = ENV["RPERF_META_LABELS"]
|
|
120
|
+
return nil unless v
|
|
121
|
+
begin
|
|
122
|
+
labels = JSON.parse(v)
|
|
123
|
+
labels.is_a?(Hash) ? labels : nil
|
|
124
|
+
rescue JSON::ParserError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def safe_hostname
|
|
130
|
+
require "socket"
|
|
131
|
+
Socket.gethostname
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Build the summary hash from profile data (as returned by Rperf.stop).
|
|
137
|
+
# Fields whose source data is missing are omitted.
|
|
138
|
+
def build_summary(data)
|
|
139
|
+
s = {}
|
|
140
|
+
s[:total_ms] = (data[:duration_ns] / 1e6).round(1) if data[:duration_ns]
|
|
141
|
+
if data[:user_ns] || data[:sys_ns]
|
|
142
|
+
s[:cpu_ms] = (((data[:user_ns] || 0) + (data[:sys_ns] || 0)) / 1e6).round(1)
|
|
143
|
+
end
|
|
144
|
+
if (gc = data[:gc_stats])
|
|
145
|
+
s[:gc_count_minor] = gc[:minor_count] if gc[:minor_count]
|
|
146
|
+
s[:gc_count_major] = gc[:major_count] if gc[:major_count]
|
|
147
|
+
s[:gc_ms] = gc[:time_ms].to_f.round(1) if gc[:time_ms]
|
|
148
|
+
s[:allocated_objects] = gc[:allocated_objects] if gc[:allocated_objects]
|
|
149
|
+
s[:freed_objects] = gc[:freed_objects] if gc[:freed_objects]
|
|
150
|
+
end
|
|
151
|
+
s[:maxrss_mb] = data[:maxrss_mb] if data[:maxrss_mb]
|
|
152
|
+
s[:samples] = data[:sampling_count] if data[:sampling_count]
|
|
153
|
+
s[:top_methods] = top_methods(data)
|
|
154
|
+
s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Top methods by self time, merged by method name (shares the by-name
|
|
158
|
+
# fold with Table so report/summary numbers can never diverge).
|
|
159
|
+
def top_methods(data, limit: TOP_METHODS_LIMIT)
|
|
160
|
+
samples = data[:aggregated_samples]
|
|
161
|
+
return [] if !samples || samples.empty?
|
|
162
|
+
|
|
163
|
+
flat_by_name, cum_by_name, total = Table.flat_cum_by_name(data)
|
|
164
|
+
return [] if total <= 0
|
|
165
|
+
|
|
166
|
+
flat_by_name.sort_by { |_, w| -w }.first(limit).map do |name, w|
|
|
167
|
+
{
|
|
168
|
+
name: name,
|
|
169
|
+
self_pct: (w * 100.0 / total).round(1),
|
|
170
|
+
total_pct: (cum_by_name[name] * 100.0 / total).round(1),
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --- meta/summary prefix reader ---
|
|
176
|
+
|
|
177
|
+
READ_CHUNK = 64 * 1024
|
|
178
|
+
READ_LIMIT = 8 * 1024 * 1024
|
|
179
|
+
|
|
180
|
+
# Read meta/summary from a .json(.gz) profile without loading the body.
|
|
181
|
+
# Returns { meta: Hash|nil, summary: Hash|nil } or nil for files without
|
|
182
|
+
# leading meta/summary keys (old format) and unreadable/corrupt files.
|
|
183
|
+
def read(path)
|
|
184
|
+
File.open(path, "rb") do |f|
|
|
185
|
+
magic = f.read(2)
|
|
186
|
+
f.rewind
|
|
187
|
+
io = (magic == "\x1f\x8b".b) ? Zlib::GzipReader.new(f) : f
|
|
188
|
+
begin
|
|
189
|
+
buf = "".b
|
|
190
|
+
loop do
|
|
191
|
+
chunk = io.read(READ_CHUNK)
|
|
192
|
+
buf << chunk if chunk
|
|
193
|
+
result = scan_prefix(buf)
|
|
194
|
+
return result unless result == :incomplete
|
|
195
|
+
return nil if chunk.nil? || buf.bytesize > READ_LIMIT
|
|
196
|
+
end
|
|
197
|
+
ensure
|
|
198
|
+
# Free the inflate zstream now — directory listings open many files
|
|
199
|
+
# and the buffers would otherwise linger until GC
|
|
200
|
+
io.close if io.is_a?(Zlib::GzipReader)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
rescue Zlib::Error, SystemCallError, JSON::ParserError
|
|
204
|
+
# Zlib::Error covers GzipFile::Error (truncated) and also DataError /
|
|
205
|
+
# BufError (valid gzip header, corrupt deflate body) — one corrupt
|
|
206
|
+
# snapshot must not break listing an entire directory
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Byte codes used by the scanner. Byte-wise scanning is safe in UTF-8:
|
|
211
|
+
# continuation bytes are >= 0x80 and never collide with ASCII syntax.
|
|
212
|
+
DQUOTE = 0x22
|
|
213
|
+
BSLASH = 0x5c
|
|
214
|
+
LBRACE = 0x7b
|
|
215
|
+
RBRACE = 0x7d
|
|
216
|
+
LBRACKET = 0x5b
|
|
217
|
+
RBRACKET = 0x5d
|
|
218
|
+
COMMA = 0x2c
|
|
219
|
+
COLON = 0x3a
|
|
220
|
+
|
|
221
|
+
# Scan the head of a JSON object for top-level "meta" / "summary" keys.
|
|
222
|
+
# rperf writes them first, so scanning stops at the first other key —
|
|
223
|
+
# large sample arrays are never traversed.
|
|
224
|
+
# Returns { meta:, summary: }, nil (old format / malformed),
|
|
225
|
+
# or :incomplete (need more input).
|
|
226
|
+
def scan_prefix(buf)
|
|
227
|
+
n = buf.bytesize
|
|
228
|
+
i = skip_ws(buf, 0, n)
|
|
229
|
+
return :incomplete if i >= n
|
|
230
|
+
return nil unless buf.getbyte(i) == LBRACE
|
|
231
|
+
i += 1
|
|
232
|
+
found = {}
|
|
233
|
+
|
|
234
|
+
loop do
|
|
235
|
+
i = skip_ws(buf, i, n)
|
|
236
|
+
return :incomplete if i >= n
|
|
237
|
+
return finalize_scan(found) if buf.getbyte(i) == RBRACE
|
|
238
|
+
return nil unless buf.getbyte(i) == DQUOTE
|
|
239
|
+
|
|
240
|
+
key_start = i
|
|
241
|
+
i = scan_string(buf, i, n)
|
|
242
|
+
return :incomplete unless i
|
|
243
|
+
key = buf.byteslice(key_start + 1, i - key_start - 2)
|
|
244
|
+
|
|
245
|
+
# First key that is not meta/summary ends the scan (old format or body)
|
|
246
|
+
return finalize_scan(found) unless key == "meta" || key == "summary"
|
|
247
|
+
|
|
248
|
+
i = skip_ws(buf, i, n)
|
|
249
|
+
return :incomplete if i >= n
|
|
250
|
+
return nil unless buf.getbyte(i) == COLON
|
|
251
|
+
i += 1
|
|
252
|
+
i = skip_ws(buf, i, n)
|
|
253
|
+
return :incomplete if i >= n
|
|
254
|
+
|
|
255
|
+
vstart = i
|
|
256
|
+
i = scan_value(buf, i, n)
|
|
257
|
+
return :incomplete unless i
|
|
258
|
+
fragment = buf.byteslice(vstart, i - vstart).force_encoding(Encoding::UTF_8)
|
|
259
|
+
found[key.to_sym] = JSON.parse(fragment, symbolize_names: true)
|
|
260
|
+
return finalize_scan(found) if found.key?(:meta) && found.key?(:summary)
|
|
261
|
+
|
|
262
|
+
i = skip_ws(buf, i, n)
|
|
263
|
+
return :incomplete if i >= n
|
|
264
|
+
case buf.getbyte(i)
|
|
265
|
+
when COMMA then i += 1
|
|
266
|
+
when RBRACE then return finalize_scan(found)
|
|
267
|
+
else return nil
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
rescue JSON::ParserError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def finalize_scan(found)
|
|
275
|
+
found.empty? ? nil : { meta: found[:meta], summary: found[:summary] }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def skip_ws(buf, i, n)
|
|
279
|
+
while i < n
|
|
280
|
+
b = buf.getbyte(i)
|
|
281
|
+
break unless b == 0x20 || b == 0x09 || b == 0x0a || b == 0x0d
|
|
282
|
+
i += 1
|
|
283
|
+
end
|
|
284
|
+
i
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Scan a JSON string starting at the opening quote.
|
|
288
|
+
# Returns the index just past the closing quote, or nil if truncated.
|
|
289
|
+
def scan_string(buf, i, n)
|
|
290
|
+
j = i + 1
|
|
291
|
+
while j < n
|
|
292
|
+
b = buf.getbyte(j)
|
|
293
|
+
if b == BSLASH
|
|
294
|
+
j += 2
|
|
295
|
+
elsif b == DQUOTE
|
|
296
|
+
return j + 1
|
|
297
|
+
else
|
|
298
|
+
j += 1
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Scan a JSON value (string, container, or scalar) starting at i.
|
|
305
|
+
# Returns the index just past the value, or nil if truncated.
|
|
306
|
+
def scan_value(buf, i, n)
|
|
307
|
+
case buf.getbyte(i)
|
|
308
|
+
when DQUOTE
|
|
309
|
+
scan_string(buf, i, n)
|
|
310
|
+
when LBRACE, LBRACKET
|
|
311
|
+
depth = 0
|
|
312
|
+
j = i
|
|
313
|
+
while j < n
|
|
314
|
+
b = buf.getbyte(j)
|
|
315
|
+
if b == DQUOTE
|
|
316
|
+
j = scan_string(buf, j, n)
|
|
317
|
+
return nil unless j
|
|
318
|
+
elsif b == LBRACE || b == LBRACKET
|
|
319
|
+
depth += 1
|
|
320
|
+
j += 1
|
|
321
|
+
elsif b == RBRACE || b == RBRACKET
|
|
322
|
+
depth -= 1
|
|
323
|
+
j += 1
|
|
324
|
+
return j if depth == 0
|
|
325
|
+
else
|
|
326
|
+
j += 1
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
nil
|
|
330
|
+
else
|
|
331
|
+
# scalar: number, true, false, null
|
|
332
|
+
j = i
|
|
333
|
+
while j < n
|
|
334
|
+
b = buf.getbyte(j)
|
|
335
|
+
break if b == COMMA || b == RBRACE || b == RBRACKET ||
|
|
336
|
+
b == 0x20 || b == 0x09 || b == 0x0a || b == 0x0d
|
|
337
|
+
j += 1
|
|
338
|
+
end
|
|
339
|
+
j < n ? j : nil
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
data/lib/rperf/rack.rb
CHANGED
|
@@ -16,10 +16,15 @@ class Rperf::RackMiddleware
|
|
|
16
16
|
@label_proc = label
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
UUID_RE = %r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}i
|
|
20
|
-
NUMERIC_RE = %r{/\d+}
|
|
19
|
+
UUID_RE = %r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|\z)}i
|
|
20
|
+
NUMERIC_RE = %r{/\d+(?=/|\z)}
|
|
21
21
|
|
|
22
22
|
def call(env)
|
|
23
|
+
# No-op when the profiler is not running (Rperf.profile would raise):
|
|
24
|
+
# the app may boot without Rperf.start, stop mid-run, or run in a forked
|
|
25
|
+
# worker where the atfork handler silently stopped profiling.
|
|
26
|
+
return @app.call(env) unless Rperf.running?
|
|
27
|
+
|
|
23
28
|
endpoint = if @label_proc == :raw
|
|
24
29
|
"#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
|
|
25
30
|
elsif @label_proc
|
data/lib/rperf/table.rb
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Flat table output for AI / machine consumption (`--format table` /
|
|
2
|
+
# `--format table-json`). Aggregation, diffing, and cutoff all happen here so
|
|
3
|
+
# consumers (LLMs, scripts) get a flat result table instead of a sample tree.
|
|
4
|
+
#
|
|
5
|
+
# TSV: header row, data rows, then a trailing "# summary" line of
|
|
6
|
+
# tab-separated key=value pairs.
|
|
7
|
+
# JSON: an array of row objects; the last element is { "summary": { ... } }.
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
|
|
11
|
+
module Rperf
|
|
12
|
+
module Table
|
|
13
|
+
ROWS_LIMIT = 50
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# --- single-profile report ---
|
|
18
|
+
|
|
19
|
+
# Rows: method, self_pct, total_pct, self_ms — self_pct descending,
|
|
20
|
+
# top 50 plus an "(other)" row aggregating the rest.
|
|
21
|
+
def report_rows(data, limit: ROWS_LIMIT)
|
|
22
|
+
flat, cum, total = flat_cum_by_name(data)
|
|
23
|
+
entries = flat.sort_by { |name, w| [-w, name] }
|
|
24
|
+
rows = entries.first(limit).map do |name, w|
|
|
25
|
+
{
|
|
26
|
+
method: name,
|
|
27
|
+
self_pct: pct(w, total),
|
|
28
|
+
total_pct: pct(cum[name], total),
|
|
29
|
+
self_ms: ms(w),
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
if entries.size > limit
|
|
33
|
+
# Aggregate the raw weights, not the already-rounded row values:
|
|
34
|
+
# per-row rounding errors are systematic and would make the (other)
|
|
35
|
+
# row inconsistent with itself (pct vs ms) and with the true total
|
|
36
|
+
rest_weight = entries.drop(limit).sum { |_, w| w }
|
|
37
|
+
rows << {
|
|
38
|
+
method: "(other)",
|
|
39
|
+
self_pct: pct(rest_weight, total),
|
|
40
|
+
total_pct: nil, # overlapping cumulative values cannot be summed
|
|
41
|
+
self_ms: ms(rest_weight),
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
rows
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def report_summary(data)
|
|
48
|
+
s = data[:summary] || Meta.build_summary(data)
|
|
49
|
+
out = {}
|
|
50
|
+
out[:total_ms] = s[:total_ms] if s[:total_ms]
|
|
51
|
+
out[:cpu_ms] = s[:cpu_ms] if s[:cpu_ms]
|
|
52
|
+
out[:allocated_objects] = s[:allocated_objects] if s[:allocated_objects]
|
|
53
|
+
out[:gc_count_minor] = s[:gc_count_minor] if s[:gc_count_minor]
|
|
54
|
+
out[:gc_count_major] = s[:gc_count_major] if s[:gc_count_major]
|
|
55
|
+
out
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def report_tsv(data)
|
|
59
|
+
rows = report_rows(data)
|
|
60
|
+
out = String.new
|
|
61
|
+
out << "method\tself_pct\ttotal_pct\tself_ms\n"
|
|
62
|
+
rows.each do |r|
|
|
63
|
+
out << [tsv_cell(r[:method]), r[:self_pct], r[:total_pct], r[:self_ms]].map { |v| v.nil? ? "" : v.to_s }.join("\t") << "\n"
|
|
64
|
+
end
|
|
65
|
+
out << summary_line(report_summary(data))
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def report_json(data)
|
|
70
|
+
JSON.generate(report_rows(data) + [{ summary: report_summary(data) }])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- diff (head - base) ---
|
|
74
|
+
|
|
75
|
+
# Rows: method, self_pct_base, self_pct_head, delta_pt — |delta_pt|
|
|
76
|
+
# descending, top 50. Per-method allocation data does not exist in
|
|
77
|
+
# rperf profiles, so allocation appears only in the summary.
|
|
78
|
+
def diff_rows(base, head, limit: ROWS_LIMIT)
|
|
79
|
+
base_flat, _, base_total = flat_cum_by_name(base)
|
|
80
|
+
head_flat, _, head_total = flat_cum_by_name(head)
|
|
81
|
+
names = (base_flat.keys | head_flat.keys)
|
|
82
|
+
rows = names.map do |name|
|
|
83
|
+
b = pct(base_flat[name] || 0, base_total)
|
|
84
|
+
h = pct(head_flat[name] || 0, head_total)
|
|
85
|
+
{
|
|
86
|
+
method: name,
|
|
87
|
+
self_pct_base: b,
|
|
88
|
+
self_pct_head: h,
|
|
89
|
+
delta_pt: (h - b).round(2),
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
rows.sort_by! { |r| [-r[:delta_pt].abs, r[:method]] }
|
|
93
|
+
rows.first(limit)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def diff_summary(base, head)
|
|
97
|
+
b = report_summary(base)
|
|
98
|
+
h = report_summary(head)
|
|
99
|
+
out = {}
|
|
100
|
+
%i[total_ms allocated_objects gc_count_minor gc_count_major].each do |key|
|
|
101
|
+
out[:"#{key}_base"] = b[key] if b[key]
|
|
102
|
+
out[:"#{key}_head"] = h[key] if h[key]
|
|
103
|
+
out[:"#{key}_delta"] = (h[key] - b[key]).round(1) if b[key] && h[key]
|
|
104
|
+
end
|
|
105
|
+
out
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def diff_tsv(base, head)
|
|
109
|
+
rows = diff_rows(base, head)
|
|
110
|
+
out = String.new
|
|
111
|
+
out << "method\tself_pct_base\tself_pct_head\tdelta_pt\n"
|
|
112
|
+
rows.each do |r|
|
|
113
|
+
out << [tsv_cell(r[:method]), r[:self_pct_base], r[:self_pct_head], r[:delta_pt]].join("\t") << "\n"
|
|
114
|
+
end
|
|
115
|
+
out << summary_line(diff_summary(base, head))
|
|
116
|
+
out
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def diff_json(base, head)
|
|
120
|
+
JSON.generate(diff_rows(base, head) + [{ summary: diff_summary(base, head) }])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- helpers ---
|
|
124
|
+
|
|
125
|
+
# Flat / cumulative weights merged by method name (compute_flat_cum keys
|
|
126
|
+
# are [label, path]; a method split across paths counts once).
|
|
127
|
+
def flat_cum_by_name(data)
|
|
128
|
+
result = Rperf.send(:compute_flat_cum, data[:aggregated_samples] || [])
|
|
129
|
+
flat = Hash.new(0)
|
|
130
|
+
result[:flat].each { |(label, _path), w| flat[label] += w }
|
|
131
|
+
cum = Hash.new(0)
|
|
132
|
+
result[:cum].each { |(label, _path), w| cum[label] += w }
|
|
133
|
+
[flat, cum, result[:total_weight]]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def pct(weight, total)
|
|
137
|
+
total > 0 ? (weight * 100.0 / total).round(2) : 0.0
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def ms(ns)
|
|
141
|
+
(ns / 1e6).round(1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def summary_line(summary)
|
|
145
|
+
(["# summary"] + summary.map { |k, v| "#{k}=#{v}" }).join("\t") + "\n"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# TSV has no escaping convention — replace separator characters so a
|
|
149
|
+
# pathological method name (define_method allows any string) cannot
|
|
150
|
+
# shift columns or split a row.
|
|
151
|
+
def tsv_cell(value)
|
|
152
|
+
s = value.to_s
|
|
153
|
+
s.match?(/[\t\n\r]/) ? s.gsub(/[\t\n\r]/, " ") : s
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/rperf/version.rb
CHANGED