rperf 0.9.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7182c353301aa38afde2d65928219c46cee3e777b49842bf60331dd40e7b3ab2
4
- data.tar.gz: ebb30b807d9b86a7ff48090bc5990d6bdcc0c9951f80b21758df2413bdbd39f2
3
+ metadata.gz: db5be965ca0511ea90e7482309f7dca4dc545a1c83a73f7b3fc08fc4029f9d34
4
+ data.tar.gz: 4ae2c1fe82a6d754a1ef0b4759ce2b1a2a22370788ce0197b1d269ba0a1e98e4
5
5
  SHA512:
6
- metadata.gz: d70dde1af1a4c3c9cec02e20981038c465facea4d3da32c32ce26e16cfc402de01f594c0b41386e3095f53603631f930fb2bd133d23b8a5757662d40f117d2a5
7
- data.tar.gz: b7868f0237f84a47bda6286c99b7056f47738fb235ede0d21ac8f950feb9b891b1677dff38f25294393766cbe4f742b2eeffe73a43dded92562926db8d5bd392
6
+ metadata.gz: d99ff20c55b9d359b0dfced5f13cdfaa5f8d4fe1debb5375f2a2de2c7d2ed8a219d2bd4b89c05dbb6ee7fe257b5cf51fa74ad8a85b017db608d6f30d9a5eadf1
7
+ data.tar.gz: fefb4b168fc733da575e758933083fd169ee6513ac38624f7d89ba8e66b202b4166bf2cb6d8f6e513fe85fb5fec9e31c48231d036ba19439a4e24e09b077460d
data/README.md CHANGED
@@ -75,9 +75,22 @@ rperf report --top profile.json.gz # print top functions to terminal
75
75
 
76
76
  # Compare two profiles (requires Go)
77
77
  rperf diff before.json.gz after.json.gz # open diff in browser
78
+
79
+ # Track performance across commits (time-travel viewer)
80
+ rperf record --snapshot-dir ./profiles ruby app.rb # → profiles/rperf-<sha7>-<ts>.json.gz
81
+ rperf report ./profiles/ # sidebar: per-commit list, diff, sparkline
82
+
83
+ # Flat tables for AI analysis (no Go required)
84
+ rperf diff --format table base.json.gz head.json.gz | claude -p "analyze the regression"
85
+
86
+ # Drive rperf without wrapping the command (CI/tooling): source the env, then
87
+ # run your command unchanged — `bundle exec …` stays bundler-managed, plain
88
+ # `ruby …` stays plain. (Run `rperf help` for details.)
89
+ eval "$(rperf record --snapshot-dir ./profiles --print-env)"
90
+ export RPERF_ROOT_PROCESS=$$ && exec ruby app.rb
78
91
  ```
79
92
 
80
- On `rperf report`, you can see the profile result like this page: [rprof viewer](https://ko1.github.io/rperf/examples/cpu_intensive_profile.html)
93
+ On `rperf report`, you can see the profile result like this page: [rperf viewer](https://ko1.github.io/rperf/examples/cpu_intensive_profile.html)
81
94
 
82
95
  ### Ruby API
83
96
 
@@ -119,10 +132,12 @@ Thread.new { loop { sleep 3600; Rperf::Viewer.instance&.take_snapshot! } }
119
132
  Profile without code changes (e.g., Rails):
120
133
 
121
134
  ```bash
122
- RPERF_ENABLED=1 RPERF_MODE=wall ruby app.rb # → rperf.json.gz
123
- rperf report # open in viewer
135
+ RPERF_ENABLED=1 RPERF_MODE=wall RUBYOPT=-rrperf ruby app.rb # → rperf.json.gz
136
+ rperf report # open in viewer
124
137
  ```
125
138
 
139
+ `RPERF_ENABLED` takes effect when rperf is loaded — if rperf is already in your Gemfile (e.g., Rails), `RUBYOPT=-rrperf` is unnecessary.
140
+
126
141
  Run `rperf help` for full documentation, or see the [online manual](https://ko1.github.io/rperf/docs/manual/).
127
142
 
128
143
  ## Subcommands
@@ -134,8 +149,8 @@ Inspired by Linux `perf` — familiar subcommand interface for profiling workflo
134
149
  | `rperf record` | Profile a command and save to file (default: `.json.gz`) |
135
150
  | `rperf stat` | Profile a command and print summary to stderr |
136
151
  | `rperf exec` | Profile a command and print full report to stderr |
137
- | `rperf report` | Open viewer for `.json.gz`; wraps `go tool pprof` for `.pb.gz` (requires Go) |
138
- | `rperf diff` | Compare two profiles (requires Go) |
152
+ | `rperf report` | Open viewer for `.json.gz` (or a directory for time-travel mode); wraps `go tool pprof` for `.pb.gz` (requires Go) |
153
+ | `rperf diff` | Compare two profiles (requires Go, except `--format table`) |
139
154
  | `rperf help` | Show full reference documentation |
140
155
 
141
156
  ## How It Works
@@ -188,7 +203,7 @@ rperf hooks GVL and GC events to attribute non-CPU time. These are recorded as l
188
203
  - **Accurate despite safepoints** — Safepoint sampling is *safer* (no async-signal-safety issues), but normally *inaccurate*. rperf compensates with real time-delta weights, so profiles faithfully reflect where time is actually spent.
189
204
  - **See the whole picture** (wall mode) — GVL contention, off-GVL I/O, GC marking/sweeping — all attributed to the call stacks responsible, via sample labels.
190
205
  - **Built-in viewer** — Flamegraph, Top, Tags tabs with interactive tag filtering. No external tools needed to analyze profiles.
191
- - **Low overhead** — Signal-based timer on Linux (no extra thread). ~1–5 us per sample.
206
+ - **Low overhead** — Signal-based timer on Linux (signals delivered to a dedicated worker thread — Ruby threads are never interrupted). ~1–5 us per sample.
192
207
  - **Zero code changes** — Profile any Ruby program via CLI or environment variables. Drop-in for Rails, too.
193
208
  - **`perf`-like CLI** — `record`, `stat`, `report`, `diff` — if you know Linux perf, you already know rperf.
194
209
  - **Multi-process** — automatically profiles forked/spawned Ruby child processes (e.g., Unicorn/Puma workers). Use `--no-inherit` to disable.
data/docs/help.md CHANGED
@@ -26,10 +26,37 @@ POSIX systems (Linux, macOS). Requires Ruby >= 3.4.0.
26
26
  (same as --format=text --output=/dev/stdout)
27
27
  --signal VALUE Timer signal (Linux only): signal number, or 'false'
28
28
  for nanosleep thread (default: auto)
29
+ --snapshot-dir DIR Save as rperf-<sha7>-<timestamp>.json.gz in DIR
30
+ (rperf-nogit-<timestamp>-<pid>.json.gz outside git)
31
+ --label KEY=VALUE Add a label to profile metadata (repeatable)
29
32
  --no-inherit Do not profile forked/spawned child processes
30
33
  --no-aggregate Disable C-level sample aggregation (raw per-sample data)
34
+ --print-env Print the env rperf would set (export KEY=VALUE lines)
35
+ and exit WITHOUT running a command — see below
31
36
  -v, --verbose Print sampling statistics to stderr
32
37
 
38
+ #### --print-env: drive rperf without wrapping the command
39
+
40
+ Instead of `rperf record [options] -- command`, `--print-env` prints the
41
+ environment a profiled process auto-starts from and exits without running
42
+ anything. A wrapper (e.g. a CI script) sources it, makes the process that runs
43
+ the command the session root, and runs the command unchanged — so a
44
+ `bundle exec …` command stays bundler-managed and a plain `ruby …` stays plain;
45
+ rperf is never inserted into the command line.
46
+
47
+ eval "$(rperf record --snapshot-dir ./profiles --print-env)"
48
+ export RPERF_ROOT_PROCESS=$$ # the pid that will exec the command is the root
49
+ exec ruby app.rb # runs verbatim; rperf auto-starts via RUBYOPT
50
+
51
+ `RPERF_ROOT_PROCESS` is not emitted (the caller sets it to the pid that execs
52
+ the command); `RPERF_META_LABELS` is not emitted (set it yourself if you want
53
+ per-run labels). Everything else (`RUBYOPT=-rrperf`, output path, mode,
54
+ frequency, fork session dir, …) is included.
55
+
56
+ JSON output embeds `meta` (git commit, host, Ruby/rperf versions, labels)
57
+ and `summary` (time, GC, allocation, top methods) — see "Profile metadata"
58
+ under OUTPUT FORMATS.
59
+
33
60
  ### stat: Run command and print performance summary to stderr.
34
61
 
35
62
  Uses wall mode by default. No file output by default.
@@ -37,6 +64,7 @@ Uses wall mode by default. No file output by default.
37
64
  -o, --output PATH Also save profile to file (default: none)
38
65
  -f, --frequency HZ Sampling frequency in Hz (default: 1000)
39
66
  -m, --mode MODE cpu or wall (default: wall)
67
+ --label KEY=VALUE Add a label to profile metadata (repeatable)
40
68
  --report Include flat/cumulative profile tables in output
41
69
  --signal VALUE Timer signal (Linux only): signal number, or 'false'
42
70
  for nanosleep thread (default: auto)
@@ -61,6 +89,7 @@ Like `stat --report`. Uses wall mode by default. No file output by default.
61
89
  -o, --output PATH Also save profile to file (default: none)
62
90
  -f, --frequency HZ Sampling frequency in Hz (default: 1000)
63
91
  -m, --mode MODE cpu or wall (default: wall)
92
+ --label KEY=VALUE Add a label to profile metadata (repeatable)
64
93
  --signal VALUE Timer signal (Linux only): signal number, or 'false'
65
94
  for nanosleep thread (default: auto)
66
95
  --no-inherit Do not profile forked/spawned child processes
@@ -74,11 +103,44 @@ and flat/cumulative top-50 function tables.
74
103
 
75
104
  --top Print top functions by flat time
76
105
  --text Print text report
106
+ --format FORMAT Flat table for AI/machine consumption:
107
+ 'table' (TSV) or 'table-json' (JSON array)
77
108
  --html Output static HTML viewer to stdout
109
+ --port PORT Port for the web UI (default: auto).
110
+ Useful for SSH port forwarding
111
+ --host HOST Bind address for the web UI (default: localhost).
112
+ 0.0.0.0 allows external access — the viewer has
113
+ NO authentication; prefer SSH port forwarding
114
+
115
+ The browser is auto-opened only when a GUI is available (DISPLAY /
116
+ WAYLAND_DISPLAY on Linux) and the bind address is local; otherwise the URL
117
+ is printed for manual opening (no terminal-browser fallback).
78
118
 
79
119
  Default (no flag): opens interactive web UI in browser.
80
120
  Default file: rperf.json.gz
81
121
 
122
+ #### Time-travel mode (directory input)
123
+
124
+ rperf report ./profiles/
125
+
126
+ Passing a directory lists all `*.json(.gz)` profiles in a sidebar — one row
127
+ per snapshot with commit SHA (a `*` marks a dirty working tree), commit
128
+ subject, date, and alloc/GC badges versus the previous snapshot (⚠️ when
129
+ allocation changed more than ±15%). Rows are grouped by git branch
130
+ (main/master expanded by default). Only meta/summary heads are read for the
131
+ listing; profile bodies are lazy-loaded on selection, so directories with
132
+ 100+ snapshots open instantly. Works well with `rperf record --snapshot-dir`.
133
+
134
+ - Click a row to view that snapshot; j / k move to the newer / older one.
135
+ - ⇄ on a row diffs the current snapshot against it: the flamegraph is
136
+ recolored by share change (red = increased, blue = decreased, neutral
137
+ below ±0.4pt; direction is base (older) → current).
138
+ - Shift+click a frame to pin that method: a sparkline of its share across
139
+ all snapshots appears at the top of the sidebar (points are filled in as
140
+ bodies load; click a point to jump). The pinned frame is highlighted and
141
+ others are dimmed.
142
+ - Files without meta (saved by older rperf) appear as unknown snapshots.
143
+
82
144
  `--html` generates an HTML file with profile data embedded inline.
83
145
  No server is needed — open it directly in a browser. d3 and
84
146
  d3-flamegraph are loaded from CDN, so an internet connection is
@@ -87,15 +149,48 @@ sites (e.g., GitHub Pages).
87
149
 
88
150
  rperf report --html profile.json.gz > report.html
89
151
 
90
- ### diff: Compare two profiles (target - base). Requires Go.
152
+ ### diff: Compare two profiles (target - base). Requires Go (except --format table).
91
153
 
92
154
  Accepts `.json.gz` (auto-converted to pprof) or `.pb.gz` files.
93
155
 
94
156
  --top Print top functions by diff
95
157
  --text Print text diff report
158
+ --format FORMAT Flat diff table for AI/machine consumption:
159
+ 'table' (TSV) or 'table-json' (JSON array).
160
+ Computed in Ruby — no Go required.
161
+ .json.gz / .json files only.
162
+ --port PORT Port for the web UI (default: auto)
163
+ --host HOST Bind address for the web UI (default: localhost)
96
164
 
97
165
  Default (no flag): opens diff in browser.
98
166
 
167
+ ### Table output for AI analysis (--format table / table-json)
168
+
169
+ Aggregation, diffing, and cutoff happen on the rperf side; the output is a
170
+ flat table that an LLM can analyze directly — no tree walking required.
171
+
172
+ `rperf report --format table FILE` columns (self_pct descending, top 50
173
+ plus an `(other)` aggregate row):
174
+
175
+ method self_pct total_pct self_ms
176
+
177
+ `rperf diff --format table BASE HEAD` columns (|delta_pt| descending,
178
+ top 50; delta_pt = self_pct_head - self_pct_base in percentage points):
179
+
180
+ method self_pct_base self_pct_head delta_pt
181
+
182
+ Per-method allocation data does not exist in sampling profiles, so
183
+ allocation counts appear only in the summary (whole-profile delta).
184
+
185
+ The last TSV line is `# summary` with tab-separated key=value pairs
186
+ (total_ms / allocated_objects / GC counts; base/head/delta for diff).
187
+ With `table-json`, the output is a JSON array of row objects whose last
188
+ element is `{"summary": {...}}`.
189
+
190
+ Feed the result to an LLM:
191
+
192
+ rperf diff --format table base.json.gz head.json.gz | claude -p "回帰の原因を分析して"
193
+
99
194
  ### Multi-process profiling
100
195
 
101
196
  By default, rperf profiles forked and spawned Ruby child processes.
@@ -146,6 +241,8 @@ Limitations:
146
241
  rperf record -o profile.collapsed ruby app.rb
147
242
  rperf record -o profile.txt ruby app.rb
148
243
  rperf record -p ruby app.rb
244
+ rperf record --snapshot-dir ./profiles ruby app.rb
245
+ rperf record --label ci=github-actions --label pr=123 ruby app.rb
149
246
  rperf stat ruby app.rb
150
247
  rperf stat --report ruby app.rb
151
248
  rperf stat -o profile.pb.gz ruby app.rb
@@ -155,8 +252,11 @@ Limitations:
155
252
  rperf exec -m cpu ruby app.rb
156
253
  rperf report
157
254
  rperf report --top profile.pb.gz
255
+ rperf report --format table profile.json.gz
256
+ rperf report ./profiles/
158
257
  rperf diff before.pb.gz after.pb.gz
159
258
  rperf diff --top before.pb.gz after.pb.gz
259
+ rperf diff --format table before.json.gz after.json.gz
160
260
 
161
261
  ## RUBY API
162
262
 
@@ -206,19 +306,24 @@ nil if profiler was not running; otherwise a Hash:
206
306
  detected_thread_count: 4, # threads seen during profiling
207
307
  start_time_ns: 17740..., # CLOCK_REALTIME epoch nanos
208
308
  duration_ns: 10000000, # profiling duration in nanos
209
- aggregated_samples: [ # when aggregate: true (default)
309
+ aggregated_samples: [ # always present
210
310
  [frames, weight, seq, label_set_id], # frames: [[path, label], ...] deepest-first
211
311
  ... # weight: Integer (nanoseconds, merged per unique stack)
212
312
  ], # seq: Integer (thread sequence, 1-based)
213
313
  # label_set_id: Integer (0 = no labels)
214
314
  label_sets: [{}, {request: "abc"}, ...], # label set table (index = label_set_id)
215
- # --- OR ---
216
- raw_samples: [ # when aggregate: false
217
- [frames, weight, seq, label_set_id], # one entry per timer sample (not merged)
218
- ...
219
- ] }
315
+ # additionally, when aggregate: false:
316
+ raw_samples: [ # one entry per timer sample (not merged)
317
+ [frames, weight, seq, label_set_id, vm_state],
318
+ ... # vm_state: Integer (raw VM state; NOT
319
+ ] } # converted to %GVL/%GC labels — only
320
+ # aggregated_samples gets that conversion)
220
321
  ```
221
322
 
323
+ With `aggregate: false`, BOTH keys are present: `aggregated_samples` is built
324
+ in Ruby from the raw samples (so encoders always work), and `raw_samples`
325
+ preserves the unmerged per-sample data.
326
+
222
327
  ### Rperf.snapshot(clear: false)
223
328
 
224
329
  Returns a snapshot of the current profiling data without stopping.
@@ -325,6 +430,10 @@ running). Raises `RuntimeError` if not started, `ArgumentError` without block.
325
430
 
326
431
  Returns the current thread's labels as a Hash. Empty hash if none set.
327
432
 
433
+ ### Rperf.running?
434
+
435
+ Returns true while a profiling session is active (between start and stop).
436
+
328
437
  ### Rperf.load(path)
329
438
 
330
439
  Loads a `.json.gz` or `.json` profile file (saved by `rperf record` or `Rperf.save`)
@@ -356,6 +465,8 @@ use Rperf::RackMiddleware
356
465
 
357
466
  The middleware uses `Rperf.profile` to activate timer and set labels.
358
467
  Start profiling separately. Option: `label_key:` (default: `:endpoint`).
468
+ When the profiler is not running, the middleware is a no-op (passes the
469
+ request straight through).
359
470
 
360
471
  ### Rperf::ActiveJobMiddleware
361
472
 
@@ -399,7 +510,19 @@ use Rperf::Viewer, max_snapshots: 12 # keep fewer snapshots (default: 24)
399
510
  ```
400
511
 
401
512
  Take snapshots via `Rperf::Viewer.instance.take_snapshot!` or
402
- `Rperf::Viewer.instance.add_snapshot(data)`.
513
+ `Rperf::Viewer.instance.add_snapshot(data)`. Snapshots carry the same
514
+ meta/summary as saved profiles, so when more than one snapshot exists the
515
+ UI shows the time-travel sidebar (list, diff, pin/sparkline, j/k) — see
516
+ "Time-travel mode" under the report subcommand.
517
+ `add_snapshot_dir(dir)` loads a directory of saved profiles (lazy-loaded;
518
+ `max_snapshots` does not apply to directory entries).
519
+
520
+ The UI fetches data from `<path>/snapshots` (list) and
521
+ `<path>/snapshots/<id>` (body). The URLs are replaceable at runtime:
522
+ define `window.RPERF_DATA_SOURCE` (with `listUrl()` / `snapshotUrl(id)`,
523
+ and optionally an async `onAuthError(url)` hook that returns a fresh URL
524
+ when a fetch hits HTTP 403, e.g. an expired signed URL) before the viewer
525
+ script runs to read snapshots from another source.
403
526
 
404
527
  #### Typical setup with RackMiddleware and periodic snapshots
405
528
 
@@ -445,7 +568,8 @@ end
445
568
 
446
569
  #### UI tabs
447
570
 
448
- - **Flamegraph** — Interactive flamegraph (d3-flame-graph). Click to zoom.
571
+ - **Flamegraph** — Interactive flamegraph (d3-flame-graph). Click to zoom;
572
+ Shift+click to pin a method (sparkline across snapshots in the sidebar).
449
573
  - **Top** — Flat/cumulative weight table. Click column headers to sort.
450
574
  - **Tags** — Label key/value breakdown with weight bars. Click a row to
451
575
  set tagfocus and switch to Flamegraph.
@@ -485,6 +609,71 @@ Extension convention: `.json.gz` (gzip-compressed, default) or `.json` (plain te
485
609
  View with: `rperf report` (opens rperf viewer in browser, no Go required).
486
610
  Load programmatically: `data = Rperf.load("rperf.json.gz")`
487
611
 
612
+ #### Profile metadata (meta / summary)
613
+
614
+ JSON profiles embed two extra top-level keys, written FIRST in the file so
615
+ tools can list profiles by decompressing only the head (`Rperf.read_meta`):
616
+
617
+ ```json
618
+ {
619
+ "meta": {
620
+ "format_version": 1,
621
+ "created_at": "2026-06-12T10:00:00Z",
622
+ "ruby_version": "3.5.0",
623
+ "rperf_version": "0.10.0",
624
+ "mode": "cpu",
625
+ "hostname": "...",
626
+ "git": {
627
+ "sha": "88e1a40...",
628
+ "branch": "main",
629
+ "subject": "Add nested includes support",
630
+ "committed_at": "2026-06-09T...",
631
+ "dirty": false
632
+ },
633
+ "labels": { "ci": "github-actions", "pr": "123" }
634
+ },
635
+ "summary": {
636
+ "total_ms": 2001.8,
637
+ "cpu_ms": 2023.3,
638
+ "gc_count_minor": 2,
639
+ "gc_count_major": 2,
640
+ "gc_ms": 3.0,
641
+ "allocated_objects": 48741,
642
+ "freed_objects": 27034,
643
+ "maxrss_mb": 16,
644
+ "samples": 1999,
645
+ "top_methods": [
646
+ { "name": "Object#fibonacci", "self_pct": 99.9, "total_pct": 99.9 }
647
+ ]
648
+ }
649
+ }
650
+ ```
651
+
652
+ Notes:
653
+
654
+ - `git` is omitted outside a git repository (never an error). In GitHub
655
+ Actions, `GITHUB_SHA` / `GITHUB_REF_NAME` take priority over git commands.
656
+ The CLI collects git info before launching the profiled command, so a
657
+ `chdir` in the app cannot point git at the wrong repository.
658
+ - `git.dirty` is true when the working tree has uncommitted changes.
659
+ - GC counts and allocation counts are deltas over the profiled period
660
+ (`GC.stat` baselines are captured at `Rperf.start`). `maxrss_mb` is the
661
+ process-lifetime peak (no period delta is possible; on macOS the value
662
+ comes from `ps -o rss=` and is the current RSS, not the peak).
663
+ - `summary.top_methods` lists up to 50 methods by self time.
664
+ - In multi-process profiling, GC/memory stats come from the root process
665
+ only (same policy as `rperf stat`); time and sample stats are aggregated.
666
+ - Files saved by older rperf versions (no `meta`) remain loadable; viewers
667
+ treat them as unknown snapshots.
668
+ - pprof / collapsed / text exports do not contain meta.
669
+
670
+ ### Rperf.read_meta(path)
671
+
672
+ Reads only `meta` / `summary` from a `.json(.gz)` profile without parsing
673
+ the sample body (fast even for large files). Returns
674
+ `{ meta: Hash|nil, summary: Hash|nil }`, or nil for pre-meta files and
675
+ unreadable files.
676
+
488
677
  ### pprof
489
678
 
490
679
  Gzip-compressed protobuf. Standard pprof format.
@@ -654,7 +843,7 @@ Or convert to text with pprof CLI:
654
843
 
655
844
  go tool pprof -text profile.pb.gz
656
845
  go tool pprof -top profile.pb.gz
657
- go tool pprof -flame profile.pb.gz
846
+ go tool pprof -http=:8080 profile.pb.gz # web UI (includes flame graph view)
658
847
 
659
848
  ## ENVIRONMENT VARIABLES
660
849