Tracing cost
Preview — Ranvier is pre-release; its APIs and these figures may change.
Tracing is a compile-time switch. RanvierTrace=true compiles the per-graph event log and the Trace queries into the library; it is the default in Debug and off in Release. This page states what the traced build costs against the untraced build of the same source, and the budget that later instrumentation changes are held to.
Baseline figures come from f48a3a7, recorded on 2026-10-24 on an AMD Ryzen 9 9900X under Windows 11 with .NET 10.0.13, Node.js 26.7.0 (fickl 0.5.3, fable-library-js 2.9.0) and Chromium 156. The raw results, the methodology and the machine are in docs/.ai/benchmarks/tracing/f48a3a7. The checkpoint and incremental-read follow-ups below cite their separate b786373 measurements. These are historical runs; upgrading the compiler or runtime requires a new comparison.
Reproducing
dotnet fsi build.fsx -- perf trace <fresh-directory>
The command builds the counter bench, its Fable harness and the Playground's keyed-row bundle twice, untraced and traced. It runs the counter scenarios, the trace growth probe on .NET and Node.js, the keyed-row harness in Chromium and the BenchmarkDotNet Trace category, and writes report.md beside the raw results. A full run takes about 75 minutes. --short is a smoke run and is not comparable with the baseline.
Cost model
Events per update
The log records a fixed number of events per write, set by the graph's shape.
| Workload | Events per update |
|---|---|
memo: a signal read by one memo and one effect | 14.0 |
fanout: a signal read by 64 memos | 392.1 |
Both runtimes record the same events. The count is deterministic: two runs agree exactly.
Retained memory
The log holds every event until a checkpoint drops it. Its retained cost is the traced build's heap slope against update count minus the untraced slope, measured after a full collection.
| Runtime | Workload | Log bytes per update | Log bytes per event |
|---|---|---|---|
| .NET | memo | 879 | 62.8 |
| .NET | fanout | 18,186 | 46.4 |
| Node.js | memo | 1,393 | 99.5 |
| Node.js | fanout | 37,899 | 96.7 |
Without a checkpoint, history grows linearly: 100,000 memo updates hold 1.4 million events, about 88 MB on .NET. Checkpointed 100 updates before each measurement, the same graph retains 1,400 events and 156 KB on .NET (890 KB on Node.js) at 100,000 updates, with a retained slope under 1 B per update (b786373). In Chromium, a mounted keyed list retains about 600 to 700 more bytes per row traced: 1,111 against 1,816 bytes per row at 1,000 rows, 976 against 1,584 at 10,000.
Time and allocation per write
BenchmarkDotNet, the same method under an untraced and a traced job. Steady-state methods write to a prebuilt graph; the untraced writes allocate nothing.
| Steady state | Untraced | Traced | Ratio | Traced allocation |
|---|---|---|---|---|
signal | 18.4 ns | 1,635 ns | 89.0 | 1,672 B |
memo | 35.7 ns | 1,333 ns | 37.3 | 3,472 B |
chain (4 memos) | 87.5 ns | 6,151 ns | 70.3 | 10,791 B |
fanout (64 memos) | 833 ns | 47,614 ns | 57.2 | 107,646 B |
cutoff (an equal result) | 26.5 ns | 360 ns | 13.6 | 2,576 B |
batch (10 writes) | 43.0 ns | 2,912 ns | 67.7 | 6,464 B |
| Construction | Untraced | Traced | Ratio | Allocation ratio |
|---|---|---|---|---|
signal | 145 ns | 18,318 ns | 126.5 | 23.4 |
memo | 234 ns | 27,086 ns | 117.3 | 28.6 |
chain | 478 ns | 54,663 ns | 115.9 | 34.5 |
fanout | 4,884 ns | 589,194 ns | 121.1 | 38.8 |
dispose (1,000 effect rows) | 49.6 µs | 9,386 µs | 189.2 | 53.5 |
The traced figures include the amortised growth of the log's backing array, so they depend on how much history the graph already holds. The traced memo write is bimodal (median 771 ns, mean 1,333 ns).
In Chromium, the keyed-list operations of the Playground's framework harness run 1.2 to 2.3 times slower traced, apart from operations dominated by writes to existing rows: update10th1k runs 10 times slower and armsPayload1k 7 to 8 times. The arm operations run 2.5 to 3.1 times slower.
Export and incremental reads
Every query except Trace.eventsSince copies or walks the whole retained log, so each costs time proportional to the events held. Per 1,000 events held, from the BenchmarkDotNet run at 100,000 memo updates (1.4 million events) on .NET, and from the growth probe at the same checkpoint on Node.js:
| Operation | .NET | Node.js |
|---|---|---|
Trace.events | 4.6 µs | 0.7 µs |
Trace.why | 4.2 µs | — |
Trace.snapshot | 53 µs | 83 µs |
Trace.origin | 54 µs | — |
Trace.dumpText | 126 µs | 548 µs |
| Tail read | 4.0 µs | 0.8 µs |
The tail read copies the log with Trace.events and slices the events after the previous read. It reads 1,400 new events at every checkpoint, yet its cost follows the length of the whole log. Trace.eventsSince reads the same events from a seq cursor and costs what it returns: 1.4 to 1.9 µs and 54.7 KB at 1,000 to 100,000 updates on .NET, 2 µs or less on Node.js (b786373). The signal maps read with it.
Trace.history is quadratic in the run count: 11.5 ms at 1,000 updates, 104 ms at 3,000 and 4.2 s at 10,000.
Budget
An instrumentation change is measured with perf trace on the same machine as the baseline it is compared with, and stays inside these limits. A change that exceeds one records the new figure, the reason, and a new baseline directory under docs/.ai/benchmarks/tracing/.
| Measure | Limit | Headroom from |
|---|---|---|
| Untraced build | Identical bytes/op and library counters to the untraced baseline in every counter scenario (counters.ps1 -NoPmc), and the existing perf gate passing. | The untraced library is the shipped Release build; it carries no trace cost. |
| Events per update | At most 14.0 (memo) and 392.1 (fanout). | Two runs agree exactly. |
| Log bytes per event | At most 66 B (memo) and 49 B (fanout) on .NET; 105 B and 102 B on Node.js. | Two runs agree exactly; 5 % covers runtime and allocator updates. |
| Traced .NET bytes/op in GC-free counter scenarios | At most 2 % above the traced baseline. | Two runs differ by at most 0.6 %. |
| Traced time ratio, BenchmarkDotNet | At most the measured ratio plus twice its RatioSD: signal 93, chain 73, fanout 70, cutoff 15.4, batch 77; construction signal 155, memo 152, chain 152, fanout 147, dispose 210. | BenchmarkDotNet's ratio spread. The memo write is bimodal and is reported, not gated. |
| Export and tail read per 1,000 events | At most 1.5 times the table above. | Export timings of two runs differ by up to 40 %. |
| Chromium retained heap per row | At most 5 % above 1,816 B (1,000 rows) and 1,584 B (10,000 rows). | Replicates differ by under 2 %. |
| Chromium traced-to-untraced ratio | At most 1.25 times the measured ratio for operations whose per-process medians differ by under 15 %. | Per-process medians of most operations differ by under 15 %; operations with more spread are reported, not gated. |
The input journal (GraphOptions.Journal) is compiled into the untraced build. With no journal, a signal write, an async-source settle, a flush and an untracked section each add one field load and a null check. Against its fork point, every untraced counter scenario keeps its bytes/op and library counters, except server-render, which builds a graph per request and gains 8 B/op for the graph's journal field. A paired, interleaved timing of a signal write and a four-memo chain shows no difference beyond an A/A run of the same harness. The figures are in docs/.ai/benchmarks/journal/.
A Node.js counter row that ran a collection inside a measured region (GC-free = no in the report) understates its allocation and is not gated; most traced Node.js rows are in this state. The growth probe's bytes per event is the Node.js figure.