Skip to content

Commit a5b6a0f

Browse files
committed
Add runtime stats
This introduces a runtime-stats subsystem that coordinates per-process vCPU exit accounting, shim counter snapshots, and syscall histograms under a single env-var gate (ELFUSE_RUNTIME_STATS). Modes: summary (stderr table), json (one JSON object on exit), jsonl (one JSON object per interval or signal). ELFUSE_STATS_SIGNAL=USR1 delivers a snapshot on demand; final dumps suppress subsequent signal dumps via rt_final_done. Fix a recorder-guard race in syscall_hist_record: the old code entered the hist_active_recorders guard inside record() itself, leaving a window between syscall_hist_now_ns() and record() where the dump could flip mode to OFF, observe guard==0, and read the table before the in-flight recorder landed its updates. Replace the enter/record split with syscall_hist_enter() which does a cheap disabled-mode probe then increments the guard and rechecks mode before returning the start timestamp. record() takes (nr, start_ns, end_ns) and owns the guard release on every exit path including clock-failure. Fix unserialized concurrent dumps: runtime_stats_dump() now holds rt_dump_mutex for the duration of each dump so JSON/human output from a SIGUSR1 handler cannot interleave with the process-exit dump. Fix impossible JSON ratios in proc_dump_vcpu_exit_stats_json: load vcpu_exit_total last to maximize the denominator under concurrent increments, then clamp the null-exit numerator to total so null_exit_share stays in [0, 100]. Fix duplicate "reserved" JSON keys in shim_globals_counters_dump_json: unnamed counter slots now emit "reserved12", "reserved13", etc. Fix silent counter wrap in hist_snapshot_rows and cost-bucket sums: use saturating add (sat_add_u64) for total_count, total_ns, and all inter-bucket sums including process_lifecycle_ns. Add proc_reset_vcpu_exit_stats, syscall_hist_reset, and runtime_stats_reset_baseline so fork children start with clean counters rather than inheriting the parent's recording window. Cover the ELFUSE_RUNTIME_STATS output paths that lacked tests. tests/test-runtime-stats.sh checks json single-object output under a SIGUSR1 spray, jsonl per-line objects with a signal-triggered snapshot, and the summary tables. The make check wiring already references it. Add scripts/runtime-stats-convert.py to turn jsonl output into folded, speedscope, perfetto, and csv views for flamegraphs and timelines.
1 parent c15b647 commit a5b6a0f

16 files changed

Lines changed: 1078 additions & 112 deletions

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ SRCS := \
6868
debug/gdbstub-reg.c \
6969
debug/gdbstub-rsp.c \
7070
debug/log.c \
71+
debug/runtime-stats.c \
7172
debug/syscall-hist.c
7273

7374
SRCS := $(addprefix src/,$(SRCS))

‎docs/usage.md‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,96 @@ and memory access, and per-thread inspection. Implementation details, including
195195
the snapshot protocol used to keep Hypervisor.framework register access on the
196196
owning thread, are documented in [internals.md](internals.md).
197197

198+
## Performance Analysis
199+
200+
`elfuse` can account for where a guest spends its time without an external
201+
profiler. Set `ELFUSE_RUNTIME_STATS` to turn on a stats coordinator that
202+
aggregates three sources at exit: shim fast-path counters, vCPU exit reasons,
203+
and a per-syscall histogram (count, total, average, and max latency). All
204+
output goes to stderr, so guest stdout stays clean for pipelines.
205+
206+
| `ELFUSE_RUNTIME_STATS` | Output |
207+
|------------------------|--------|
208+
| `summary` (the default for any value other than `json`, `jsonl`, or `0`) | Human-readable tables on exit |
209+
| `json` | Exactly one JSON object on exit |
210+
| `jsonl` | One JSON object per line: a final object plus one per on-demand snapshot |
211+
212+
Quick eyeball of a run:
213+
214+
```sh
215+
ELFUSE_RUNTIME_STATS=summary build/elfuse ./guest-program 2>stats.txt
216+
```
217+
218+
The summary prints a `vcpu-exit-stats` block (`exits_total`, `exits_vtimer`,
219+
`exits_no_signal_cancel`, and `null_exit_share`) and a `syscall histogram`
220+
sorted by total time, with a trailing line reporting what fraction of wall time
221+
was spent inside syscalls. A high null-exit share or a syscall dominating total
222+
time is the first thing to chase.
223+
224+
### On-Demand Snapshots
225+
226+
For a long-running guest, request a snapshot without stopping it by setting
227+
`ELFUSE_STATS_SIGNAL=USR1` and sending `SIGUSR1` to the `elfuse` process:
228+
229+
```sh
230+
ELFUSE_RUNTIME_STATS=jsonl ELFUSE_STATS_SIGNAL=USR1 \
231+
build/elfuse ./guest-program 2>stats.jsonl &
232+
sleep 0.1 # let the process install the SIGUSR1 handler
233+
kill -USR1 $! # emit a snapshot; repeat as the workload progresses
234+
```
235+
236+
Each snapshot is one JSON line tagged `"reason":"signal","final":false`. The
237+
final dump has `"final":true`; its `reason` is `"exit"` for the top-level
238+
process, `"fork-child-exit"` (or `"fork-child-error"`) for fork children, so
239+
filter on `"final"` rather than the reason string. In `json` (not `jsonl`)
240+
mode, snapshots are suppressed so the output stays a single valid JSON document.
241+
242+
The `sleep 0.1` matters only for a script that sends the signal immediately: the
243+
handler is installed early in startup, but `SIGUSR1` delivered before then
244+
terminates the process by default.
245+
246+
### Timeline And Flamegraphs
247+
248+
`jsonl` mode plus `scripts/runtime-stats-convert.py` turns a run into viewer
249+
formats. The `folded`, `speedscope`, and `perfetto` exports diff consecutive
250+
snapshots per pid, so each interval shows the syscalls that ran during it; `csv`
251+
emits the raw cumulative rows, one row per snapshot record:
252+
253+
```sh
254+
# Flamegraph input (Brendan Gregg's flamegraph.pl):
255+
scripts/runtime-stats-convert.py folded stats.jsonl | flamegraph.pl >stats.svg
256+
257+
# Load in https://www.speedscope.app:
258+
scripts/runtime-stats-convert.py speedscope stats.jsonl >stats.speedscope.json
259+
260+
# Perfetto UI (https://ui.perfetto.dev):
261+
scripts/runtime-stats-convert.py perfetto stats.jsonl >stats.perfetto.json
262+
263+
# Cumulative per-snapshot rows for a spreadsheet:
264+
scripts/runtime-stats-convert.py csv stats.jsonl >stats.csv
265+
```
266+
267+
The JSON schema (`elfuse-runtime-stats/1`) also carries a `phase` object of
268+
coarse buckets summed from syscall-family totals: `clone_ns` (clone + clone3),
269+
`wait_ns` (wait4 + waitid), `host_vfs_ns` (openat + openat2 only), `futex_ns`,
270+
`mem_ns` (mmap + mprotect + madvise), and `process_lifecycle_ns` (clone + wait).
271+
They are a quick read on where fork-heavy or lock-heavy guests spend time, not
272+
independent phase timers; the raw histogram is authoritative.
273+
274+
### Startup Histogram
275+
276+
To profile just the dynamic-linker bring-up storm, use
277+
`ELFUSE_STARTUP_TRACE=syscalls`, which freezes the histogram at the first
278+
`execve` instead of recording the whole run. `ELFUSE_STARTUP_TRACE=syscalls-steady`
279+
keeps recording past `execve` for steady-state workloads. Turning on
280+
`ELFUSE_RUNTIME_STATS` implies steady-state recording.
281+
282+
Counters reset per process, so fork children start from a clean window rather
283+
than inheriting the parent's totals. Enabling stats adds a `clock_gettime` pair
284+
plus atomic counter updates around each syscall; the disabled path is a
285+
`pthread_once` guard and one atomic load, so leaving stats off costs
286+
effectively nothing.
287+
198288
## Guest Compatibility Model
199289

200290
`elfuse` is designed for Linux user-space workloads, not for booting a Linux

‎mk/tests.mk‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Test targets
22

33
.PHONY: test-hello test-all check check-syscall-coverage test-gdbstub test-coreutils test-busybox \
4+
test-runtime-stats \
45
test-static-bins \
56
test-dynamic test-dynamic-coreutils test-glibc-dynamic \
67
test-glibc-coreutils test-perf \
@@ -73,6 +74,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \
7374
@$(MAKE) --no-print-directory test-rosetta-cli
7475
@printf "\n$(BLUE)━━━ hot-syscall guardrail ━━━$(RESET)\n"
7576
@$(MAKE) --no-print-directory test-bench-guardrail
77+
@printf "\n$(BLUE)━━━ runtime-stats output validation ━━━$(RESET)\n"
78+
@$(MAKE) --no-print-directory test-runtime-stats
7679

7780
## Hot-syscall performance guardrail: ensure getpid, libc clock_gettime,
7881
## and 1-byte /dev/urandom reads stay under their TODO ns/op ceilings.
@@ -430,6 +433,14 @@ test-busybox: $(ELFUSE_BIN) $(BUSYBOX_DEPS)
430433
fi
431434
@bash tests/test-busybox.sh $(ELFUSE_BIN) $(BUSYBOX_BIN)
432435

436+
## Validate ELFUSE_RUNTIME_STATS output shapes (json/jsonl/summary + signal)
437+
test-runtime-stats: $(ELFUSE_BIN) $(BUSYBOX_DEPS)
438+
@if [ ! -x "$(BUSYBOX_BIN)" ]; then \
439+
printf "$(RED)✗ Busybox not found.$(RESET) Set BUSYBOX_BIN=/path/to/busybox.\n"; \
440+
exit 1; \
441+
fi
442+
@bash tests/test-runtime-stats.sh $(ELFUSE_BIN) $(BUSYBOX_BIN)
443+
433444
## Run the low-stack argv rewrite regression on busybox startup
434445
test-proctitle-low-stack: $(ELFUSE_BIN) $(BUSYBOX_DEPS)
435446
@if [ ! -x "$(BUSYBOX_BIN)" ]; then \

‎scripts/runtime-stats-convert.py‎

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env python3
2+
"""Convert ELFUSE_RUNTIME_STATS=jsonl output to simple viewer formats."""
3+
4+
import argparse
5+
import csv
6+
import json
7+
import sys
8+
import tempfile
9+
from contextlib import redirect_stdout
10+
from io import StringIO
11+
12+
13+
def load_records(path):
14+
records = []
15+
with open(path, "r", encoding="utf-8") as f:
16+
for line in f:
17+
line = line.strip()
18+
if not line or not line.startswith("{"):
19+
continue
20+
rec = json.loads(line)
21+
if rec.get("schema") == "elfuse-runtime-stats/1":
22+
records.append(rec)
23+
return records
24+
25+
26+
def ms(ns):
27+
return float(ns or 0) / 1_000_000.0
28+
29+
30+
def syscall_rows(rec):
31+
return rec.get("syscalls", {}).get("rows", [])
32+
33+
34+
def syscall_delta_rows(records):
35+
prev_by_pid = {}
36+
for rec in records:
37+
pid = rec.get("pid", 1)
38+
prev = prev_by_pid.setdefault(pid, {})
39+
rows = []
40+
for row in syscall_rows(rec):
41+
key = row.get("nr", row.get("name"))
42+
total = row.get("total_ns", 0)
43+
last = prev.get(key, 0)
44+
out = dict(row)
45+
out["total_ns"] = total - last if total >= last else total
46+
rows.append(out)
47+
prev[key] = total
48+
yield rec, rows
49+
if rec.get("final"):
50+
prev_by_pid.pop(pid, None)
51+
52+
53+
def write_csv(records):
54+
fields = [
55+
"workload",
56+
"wall_ms",
57+
"guest_run_ms",
58+
"syscall_ms",
59+
"clone_ms",
60+
"wait_ms",
61+
"openat_ms",
62+
"null_exit_share",
63+
]
64+
w = csv.DictWriter(sys.stdout, fieldnames=fields)
65+
w.writeheader()
66+
for rec in records:
67+
by_name = {r.get("name"): r for r in syscall_rows(rec)}
68+
argv = rec.get("argv") or []
69+
row = {
70+
"workload": " ".join(argv[1:]) if len(argv) > 1 else " ".join(argv),
71+
"wall_ms": ms(rec.get("wall_ns")),
72+
"guest_run_ms": ms(rec.get("guest_run_ns")),
73+
"syscall_ms": ms(rec.get("syscalls", {}).get("total_ns")),
74+
"clone_ms": ms((by_name.get("SYS_clone") or {}).get("total_ns")),
75+
"wait_ms": ms(
76+
sum(
77+
(by_name.get(n) or {}).get("total_ns", 0)
78+
for n in ("SYS_wait4", "SYS_waitid")
79+
)
80+
),
81+
"openat_ms": ms((by_name.get("SYS_openat") or {}).get("total_ns")),
82+
"null_exit_share": rec.get("vmexit", {}).get("null_exit_share", 0),
83+
}
84+
w.writerow(row)
85+
86+
87+
def write_folded(records):
88+
totals = {}
89+
for _, rows in syscall_delta_rows(records):
90+
for row in rows:
91+
name = row.get("name") or f"SYS_{row.get('nr')}"
92+
totals[f"elfuse;syscall;{name}"] = totals.get(
93+
f"elfuse;syscall;{name}", 0
94+
) + row.get("total_ns", 0)
95+
for stack, total in sorted(totals.items()):
96+
print(stack, total)
97+
98+
99+
def write_speedscope(records):
100+
frames = []
101+
frame_index = {}
102+
events = []
103+
at = 0
104+
for _, rows in syscall_delta_rows(records):
105+
for row in rows:
106+
name = row.get("name") or f"SYS_{row.get('nr')}"
107+
if name not in frame_index:
108+
frame_index[name] = len(frames)
109+
frames.append({"name": name})
110+
dur = row.get("total_ns", 0) / 1000.0
111+
events.append({"type": "O", "at": at, "frame": frame_index[name]})
112+
events.append({"type": "C", "at": at + dur, "frame": frame_index[name]})
113+
at += dur
114+
json.dump(
115+
{
116+
"$schema": "https://www.speedscope.app/file-format-schema.json",
117+
"shared": {"frames": frames},
118+
"profiles": [
119+
{
120+
"type": "evented",
121+
"name": "elfuse runtime stats",
122+
"unit": "microseconds",
123+
"startValue": 0,
124+
"endValue": at,
125+
"events": events,
126+
}
127+
],
128+
},
129+
sys.stdout,
130+
)
131+
print()
132+
133+
134+
def write_perfetto(records):
135+
trace = []
136+
pid = 1
137+
for rec, rows in syscall_delta_rows(records):
138+
tid = rec.get("pid", 1)
139+
ts = rec.get("time_ns", 0) / 1000.0
140+
for row in rows:
141+
trace.append(
142+
{
143+
"name": row.get("name") or f"SYS_{row.get('nr')}",
144+
"ph": "X",
145+
"ts": ts,
146+
"dur": row.get("total_ns", 0) / 1000.0,
147+
"pid": pid,
148+
"tid": tid,
149+
}
150+
)
151+
ts += row.get("total_ns", 0) / 1000.0
152+
json.dump({"traceEvents": trace}, sys.stdout)
153+
print()
154+
155+
156+
def main(argv):
157+
ap = argparse.ArgumentParser()
158+
ap.add_argument("--selftest", action="store_true")
159+
ap.add_argument("format", choices=["csv", "folded", "speedscope", "perfetto"])
160+
ap.add_argument("input", nargs="?")
161+
args = ap.parse_args(argv)
162+
if args.selftest:
163+
selftest(args.format)
164+
return
165+
if not args.input:
166+
ap.error("input is required")
167+
records = load_records(args.input)
168+
if args.format == "csv":
169+
write_csv(records)
170+
elif args.format == "folded":
171+
write_folded(records)
172+
elif args.format == "speedscope":
173+
write_speedscope(records)
174+
else:
175+
write_perfetto(records)
176+
177+
178+
def selftest(fmt):
179+
sample1 = {
180+
"schema": "elfuse-runtime-stats/1",
181+
"pid": 1,
182+
"time_ns": 1_000,
183+
"final": False,
184+
"argv": ["elfuse", "guest"],
185+
"wall_ns": 10_000_000,
186+
"guest_run_ns": 0,
187+
"vmexit": {"null_exit_share": 0.0},
188+
"syscalls": {
189+
"total_ns": 1_000_000,
190+
"rows": [
191+
{"nr": 220, "name": "SYS_clone", "total_ns": 1_000_000},
192+
],
193+
},
194+
}
195+
sample2 = json.loads(json.dumps(sample1))
196+
sample2["time_ns"] = 2_000
197+
sample2["final"] = True
198+
sample2["wall_ns"] = 20_000_000
199+
sample2["syscalls"]["total_ns"] = 3_000_000
200+
sample2["syscalls"]["rows"].append(
201+
{"nr": 260, "name": "SYS_wait4", "total_ns": 2_000_000}
202+
)
203+
with tempfile.NamedTemporaryFile("w+", encoding="utf-8") as f:
204+
f.write(json.dumps(sample1) + "\n")
205+
f.write(json.dumps(sample2) + "\n")
206+
f.flush()
207+
records = load_records(f.name)
208+
out = StringIO()
209+
with redirect_stdout(out):
210+
if fmt == "csv":
211+
write_csv(records)
212+
assert "clone_ms" in out.getvalue()
213+
elif fmt == "folded":
214+
write_folded(records)
215+
assert "elfuse;syscall;SYS_clone 1000000" in out.getvalue()
216+
elif fmt == "speedscope":
217+
write_speedscope(records)
218+
profile = json.loads(out.getvalue())["profiles"][0]
219+
assert profile["endValue"] == 3000.0
220+
else:
221+
write_perfetto(records)
222+
trace = json.loads(out.getvalue())["traceEvents"]
223+
assert sum(e["dur"] for e in trace) == 3000.0
224+
225+
226+
if __name__ == "__main__":
227+
main(sys.argv[1:])

0 commit comments

Comments
 (0)