pi-mono/packages/chord/docs/benchmarks.md
Mario Zechner e9c0ff4d84 feat(chord): Chord v2 is the package's root
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
2026-11-02 22:36:55 +00:00

80 KiB

Chord v2 benchmarks

Light benchmarks of hot paths, run by hand (not in CI), recorded per slice for later slices to compare against (owner, 2026-10-22). Each section names its script, the machine and what the numbers include.

Script: test/bench/links.ts, run from packages/chord with node --conditions=source --experimental-strip-types --expose-gc test/bench/links.ts (about 15 s). Node 24.4.1, Intel Core i9-13900, one process: server and client host on one memory link (queueMicrotask), every case once per codec, side by side.

Payloads, modelled on p2: entries { id, text, author, at } in one document of a local store, exposed by a remote view; "1,000 items" are entries with 80-character texts (the notes list), "500 KB" are 500 entries of 960 characters (a conversation document). Commits are server-side method calls (one transaction each) on the store.

What the numbers include:

  • first hold to live: the first client.get, which also activates the view on the server; one sample, so it moves by a few hundred µs between runs (JIT and collector state).
  • hold to live (mean of 100): further client.gets of the live view, each released: the hold, the snapshot, the bound message encoded, decoded and checked, the replicas built (two link hops).
  • wire bytes: one whole message as its codec writes it (memory links add nothing).
  • encode / decode: that message through the codec alone (decode without the schema check), averaged over 10,000 runs (1,000 for snapshots and the 1 MiB call), the fastest of five such loops (one loop of a microsecond-sized message is at the mercy of the collector and the JIT).
  • heap per encode + decode: heap bytes allocated per message while encoding and decoding it that many times, measured with GCProfiler (the growth plus what the collections freed); ArrayBuffer backing stores (the message bytes themselves) are outside the heap.
  • array buffer kept per encoded message: the process.memoryUsage().arrayBuffers growth while 50 encoded copies of the message are kept, per message: the backing store a queued or held message keeps. Freed buffers are not reported reliably inside a loop, so allocation of temporary buffers is not measured. V8 keeps very small typed arrays on the heap, so the smallest binary messages show 0 B here.
  • commit to frame: from the server's method call to the client's watch listener, averaged over 1,000 commits: the call and its transaction, the encodes, the hop, decode, check, apply, and the watch's delivery.
  • heap / array buffers retained per hold: the heap and arrayBuffers growth of holding the 1,000-item view, after full collections, divided by the holds; both ends (the provider's member encoders and subscriptions, the consumer's member decoders and replicas, each its own decoded copy of the snapshot), median of seven trials.
Case binary text
notes list of 1,000 items: first hold to live (activation, one sample) 0.958 ms 1.041 ms
notes list of 1,000 items: hold to live (two hops, mean of 100) 0.666 ms 0.686 ms
notes list of 1,000 items: snapshot: wire bytes 133.7 KiB 133.7 KiB
notes list of 1,000 items: snapshot: encode / decode 291.1 µs / 266.4 µs 312.3 µs / 302.8 µs
notes list of 1,000 items: snapshot: heap per encode + decode 441.5 KiB 441.8 KiB
notes list of 1,000 items: snapshot: array buffer kept per encoded message 137.7 KiB 133.7 KiB
500 KB document: first hold to live (activation, one sample) 1.461 ms 1.555 ms
500 KB document: hold to live (two hops, mean of 100) 1.138 ms 1.309 ms
500 KB document: snapshot: wire bytes 496.5 KiB 496.5 KiB
500 KB document: snapshot: encode / decode 615.4 µs / 390.8 µs 687.5 µs / 528.3 µs
500 KB document: snapshot: heap per encode + decode 1511.2 KiB 1511.2 KiB
500 KB document: snapshot: array buffer kept per encoded message 500.5 KiB 496.5 KiB
one appended item: commit to frame 0.037 ms 0.031 ms
one appended item: messages per commit, encodes per hold 1, 1 1, 1
one appended item: state message: wire bytes 165 B 175 B
one appended item: state message: encode / decode 1.4 µs / 1.4 µs 0.9 µs / 1.1 µs
one appended item: state message: heap per encode + decode 2607 B 3034 B
one appended item: state message: array buffer kept per encoded message 165 B 175 B
one changed field: commit to frame 0.036 ms 0.031 ms
one changed field: messages per commit, encodes per hold 1, 1 1, 1
one changed field: state message: wire bytes 27 B 38 B
one changed field: state message: encode / decode 0.6 µs / 1.0 µs 0.5 µs / 0.7 µs
one changed field: state message: heap per encode + decode 2005 B 2527 B
one changed field: state message: array buffer kept per encoded message 0 B 38 B
one removed item: commit to frame 0.031 ms 0.029 ms
one removed item: messages per commit, encodes per hold 1, 1 1, 1
one removed item: state message: wire bytes 23 B 34 B
one removed item: state message: encode / decode 0.6 µs / 1.0 µs 0.5 µs / 0.7 µs
one removed item: state message: heap per encode + decode 1704 B 2575 B
one removed item: state message: array buffer kept per encoded message 0 B 34 B
streamed reply, 2,000 appends: time per append 0.031 ms 0.027 ms
streamed reply: bytes per append, total 23 B, 45.0 KiB 34 B, 66.5 KiB
streamed token: wire bytes 23 B 34 B
streamed token: encode / decode 0.5 µs / 1.0 µs 0.5 µs / 0.6 µs
streamed token: heap per encode + decode 1696 B 2527 B
streamed token: array buffer kept per encoded message 0 B 34 B
1 hold(s) of 1,000 items: heap / array buffers retained per hold (both ends) 154.1 KiB / 47 B 159.7 KiB / 21 B
1 hold(s): messages per commit 1 1
10 hold(s) of 1,000 items: heap / array buffers retained per hold (both ends) 175.2 KiB / 41 B 175.2 KiB / 44 B
10 hold(s): messages per commit 1 1
call, small argument: round trip 0.016 ms 0.015 ms
call, small argument: call message: wire bytes 16 B 23 B
call, small argument: call message: encode / decode 0.6 µs / 0.4 µs 0.5 µs / 0.6 µs
call, small argument: call message: heap per encode + decode 1580 B 1603 B
call, small argument: call message: array buffer kept per encoded message 0 B 23 B
call, small argument: result message: wire bytes 9 B 14 B
call, small argument: result message: encode / decode 0.4 µs / 0.3 µs 0.5 µs / 0.5 µs
call, small argument: result message: heap per encode + decode 1219 B 1509 B
call, small argument: result message: array buffer kept per encoded message 0 B 14 B
call, 1 MiB bytes argument: round trip 0.192 ms 0.159 ms
call, 1 MiB bytes argument: call message: wire bytes 1024.0 KiB 1024.0 KiB
call, 1 MiB bytes argument: call message: encode / decode 113.7 µs / 1.4 µs 123.5 µs / 1.5 µs
call, 1 MiB bytes argument: call message: heap per encode + decode 3042 B 3383 B
call, 1 MiB bytes argument: call message: array buffer kept per encoded message 1028.0 KiB 1024.0 KiB

Observations:

  • One message per commit and one encode per hold in every case (W1).
  • The binary codec makes small messages 30-45 % smaller (a streamed token 23 B against 34 B; a changed field 27 B against 38 B) and allocates less heap per message; both are about 1 µs per small message each way. Binary decodes small messages a little slower than text: it makes one call per field (varints, strings, one JSON.parse per value) where text makes one JSON.parse of the whole tuple; the difference is under 1 µs per message and not worth optimizing. Snapshots are JSON in both and about the same size; binary decodes them faster, text encodes them a little faster (binary encodes the JSON text, then copies it into the message once).
  • A binary message keeps a right-sized backing store (a 500 KB snapshot keeps about 500 KiB, not the 1.5 MB a buffer reserved for the worst-case UTF-8 length kept before the review).
  • Kept backing stores of small binary messages (owner, 2026-10-22): a message is written into a 256-byte starting buffer, whose exact size is not known before the message is written, so in-place encoding does not right-size it. finish copies to an exact-size buffer when the spare room is at least 64 bytes and more than an eighth of the message. The appended-item state message (165 B) now keeps 165 B instead of 256 B, for about 0.2 µs more per encode (1.1 against 1.3-1.4 µs); messages up to about 100 B were copied before already (they show 0 B: V8 keeps such small arrays on the heap); large ones end within their 4 KiB of room and are not copied (the 1 MiB call keeps 1028 KiB).
  • Large snapshots (owner's profiling request, 2026-10-22): the earlier "hold to live" gap (2.67 ms binary, 1.67 ms text) was one sample each; averaged over 200 holds both codecs took about 1.2 ms. The remaining real gap was the encode (886 against 718 µs): binary encoded the JSON text into its own buffer, then copied it into the message. Long strings (from 4,096 UTF-16 units) are now encoded straight into the message buffer behind a fixed five-byte length (a padded varint), and short ones (up to 42 units, a one-byte length) too: binary now encodes a 500 KB snapshot in 650 µs against text's 690 µs and holds it in 1.14 ms against 1.30 ms.
  • A 1 MiB bytes() argument travels as 1 MiB plus a few bytes in both codecs (not base64's 1.33 MiB); its decode is a view (under 2 µs), its encode the one copy into the message.
  • Encoding is not the bottleneck at these sizes; no CBOR.
  • Each hold's replica is its own decoded copy (about 160-175 KiB for 1,000 entries); two references from one host are two holds by design.

Slice 3b: surviving drops

Script and machine as for slice 3a (test/bench/links.ts now runs the 3b cases after the 3a ones, about 37 s).

New cases, both on the 1,000-item notes list view (133.7 KiB snapshot):

  • reconnect, N reference(s): N references held on the client (N holds of one provider object); the server's end of the link closes, the client sees the drop, then a new link is accepted and connected. Time to all live: from connect to every reference's ref.state being live again (the re-holds, N snapshots encoded, decoded and adopted as one frame [["r", value]] per member); the mean of 20 reconnects for one reference, 5 for 100. Bytes: what the new link carried both ways until then (the handshake included).
  • 1,000 calls parked while down: a small call (ping) made 1,000 times right after connect, before the re-hold is bound, so every call parks; time from connect to the last result (the re-hold, then 1,000 calls sent in order and answered), mean of 10; the order is checked at the provider and in the results.
Case binary text
reconnect, 1 reference of 1,000 items: time to all live (mean of 20) 0.870 ms 0.791 ms
reconnect, 1 reference of 1,000 items: bytes down / up 133.7 KiB / 63 B 133.8 KiB / 110 B
reconnect, 100 references of 1,000 items: time to all live (mean of 5) 63.1 ms 66.4 ms
reconnect, 100 references of 1,000 items: bytes down / up 13370.5 KiB / 4718 B 13371.1 KiB / 6736 B
1,000 calls parked while down, flushed on reconnect: time to the last result 14.9 ms 13.8 ms
1,000 parked calls: run in the order they were made yes yes

Regression rows (the live paths of 3a, same run):

Case binary text
notes list of 1,000 items: hold to live (two hops, mean of 100) 0.750 ms 0.682 ms
500 KB document: hold to live (two hops, mean of 100) 1.134 ms 1.308 ms
one appended item: commit to frame 0.037 ms 0.031 ms
one changed field: commit to frame 0.036 ms 0.029 ms
streamed reply, 2,000 appends: time per append 0.031 ms 0.027 ms
1 hold of 1,000 items: heap / array buffers retained per hold (both ends) 154.6 KiB / 47 B 160.1 KiB / 21 B
10 holds of 1,000 items: heap / array buffers retained per hold (both ends) 175.1 KiB / 41 B 175.4 KiB / 44 B
call, small argument: round trip 0.016 ms 0.014 ms
call, 1 MiB bytes argument: round trip 0.192 ms 0.191 ms
messages per commit, encodes per hold (every commit case) 1, 1 1, 1

Observations:

  • The live paths are unchanged within noise (3a: 0.67 / 0.69 ms per hold, 0.037 / 0.031 ms per appended item, 154.1 / 159.7 KiB per hold); message sizes and encodes per commit are identical. A live reference's call checks its state once and makes no queue; ref.state is not made until read.
  • A reconnect costs what the holds cost the first time: about 0.63 ms and one 133.7 KiB snapshot per reference of this size (100 references: 63 ms, 13 MiB). The consumer sends only the holds (47 B each binary).
  • 1,000 parked calls take about 14 ms to flush and answer, a little less than 1,000 sequential round trips (16 ms): they leave in one turn once the re-hold is bound. Each parked call keeps its arguments and one timer until sent.

Script: test/bench/relay.ts, run from packages/chord with node --conditions=source --experimental-strip-types --expose-gc --no-flush-bytecode test/bench/relay.ts (about 5 s; the forked workers are the same file run with the argument worker). Machine as for slice 3a. Binary codec throughout. The topology is the one of slice 4a's example: a tab (a client host), a server with an address and a route for Session, and per session a worker host whose Store (a local document) backs a remote View placed on the session; the tab holds the view through the server, which relays (no part parsed).

What the numbers include:

  • call to frame: the tab calls stream (one commit appending " tok" on the worker) and waits for both the result and its watch frame; mean of 1,000 after 100 warm-up calls. Through a relay that is four hops for the call and four for the frame and result (tab → server → worker → server → tab); the baseline is a tab directly on the worker. Slice 3a's commit to frame started at the provider, so these are not comparable with it.
  • bytes per commit: bytes the server sent to the tab, and received from the worker, per commit (the state message and the call's result; acks and the call go the other way). From the worker the messages carry the tab link's origin (two bytes each).
  • first hold, resolve included: a tab.get of a new session: the route resolves (an in-process worker host is created, connects, loads its facet with 1,000 entries and activates the view), the relayed hold, the 133.7 KiB snapshot through two links. The IPC row's first hold includes forking a Node process with strip-types (one sample).
  • the throttled tab: the tab's end of its link delivers at 64 KiB/s (whole messages, in order; idle time banks no budget) in virtual time, 10 ms steps, so 60 s stalls run in well under a second. Per step: one token committed on the worker (100 tokens/s), the throttle's delivery, then every host's turn. Phases: the first snapshot of the 500 KB view (8 s of virtual time at 64 KiB/s), 2 s of streaming, the pause (a stall: the tab receives nothing, so it acknowledges nothing; or the control: the same streaming without a stall), then 10 s more of streaming and until the tab's text equals the worker's. Caught up after the stall: virtual time from the end of the pause to the first step where the tab's view equals the worker's document; bytes until then: what the throttle delivered meanwhile. Heap growth during the stall: the process's heap after full collections at the end of the pause minus at its start (all hosts share one process; compare with the control row, which is the same streaming without a stall). Commit at the worker: the mean time of store.stream (the transaction, its flush: an encode when the window has room, a push into the pending queues when it is full), outside the pause and during it. Longest message handling at the provider: the longest single received message handled after the pause at the worker, the provider of the tab's state: its end of the tab link directly, of the route link relayed (the ack that sends the folded state).
  • calls: tab B's sequential pings through the relay, alone and while tab A has issued 10,000 pings at once on the same session (two holds, two origins at the worker, each with its N = 256 running calls; the relay forwards at once).
Topology (1,000 streamed tokens into the 1,000-item notes list view) call to frame bytes per commit: to the tab / from the worker
memory tab, in-process worker (memory links) 0.053 ms 27 B / 29 B
memory tab, forked worker (IPC) 0.097 ms 27 B / 29 B
first hold to live, the fork included (one sample) 240 ms -
loopback WebSocket tab (ws serving, Node's WebSocket), forked worker (IPC) 0.148 ms 27 B / 29 B
no relay: memory tab on the worker (baseline) 0.034 ms 27 B / -
Hold to live, the 1,000-item notes list view (133.7 KiB snapshot) time
first hold, resolve of an in-process worker included (mean of 20) 1.375 ms
hold of a resolved session through the relay (four hops, mean of 100) 0.763 ms
no relay: hold on the worker (two hops, mean of 100, baseline) 0.703 ms
Throttled tab (64 KiB/s), 100 tokens/s into the 500 KB view caught up after the pause bytes to the tab until then heap growth during the pause commit at the worker, outside / during the pause longest message handling at the worker after the pause
direct, 1 s stall 0.02 s 485 B 28.0 KiB 0.018 ms / 0.030 ms 0.169 ms
direct, 10 s stall 0.08 s 4147 B 134.2 KiB 0.029 ms / 0.023 ms 0.053 ms
direct, 60 s stall 0.38 s 23.7 KiB 392.2 KiB 0.028 ms / 0.017 ms 0.138 ms
direct, 60 s without a stall (control) 0.01 s 23 B 482.7 KiB 0.025 ms / 0.017 ms 0.005 ms
relayed, 1 s stall 0.02 s 485 B 7.5 KiB 0.025 ms / 0.025 ms 0.057 ms
relayed, 10 s stall 0.08 s 4147 B -25.3 KiB 0.030 ms / 0.015 ms 0.059 ms
relayed, 60 s stall 0.38 s 23.7 KiB 513.5 KiB 0.026 ms / 0.015 ms 0.146 ms
relayed, 60 s without a stall (control) 0.01 s 23 B 257.1 KiB 0.023 ms / 0.021 ms 0.003 ms
Calls through the relay (in-process worker, memory links) p50 p95 max calls
tab B alone 0.012 ms 0.018 ms 0.600 ms 1,000
tab B while tab A has 10,000 calls in flight or queued 4.851 ms 10.701 ms 10.708 ms 27
tab A: 10,000 calls at once, until the last result 657 ms - - 10,000

Observations:

  • A relay hop costs about 0.019 ms per commit-to-frame on memory links (0.053 against 0.034 ms for call, commit, frame and result) and adds no bytes to the tab: the server forwards the worker's message with the origin taken off (ids unchanged, parts copied once); the origin costs two bytes per message on the route link. IPC to a forked worker adds about 0.04 ms, a loopback WebSocket to the tab another 0.05 ms.
  • Hold to live through the relay is within 9% of a direct hold (0.763 against 0.703 ms): the relay never parses the 133.7 KiB snapshot. A first hold that resolves an in-process worker (create, connect, load, activate) takes 1.4 ms; forking a strip-types Node process takes about 240 ms, which is the app's to hide (start workers ahead).
  • Directly, the provider's pending queue folds a stall of any length into one append (24 KiB of text after 60 s, sent in 0.38 s at 64 KiB/s); committing while the window is full costs no more than committing with room (about 0.02 ms), and the folded message is encoded in under 0.2 ms. The stall adds no heap beyond the control's, which is the streamed text itself on the worker and the tab.
  • Through the relay a stall now costs what it costs directly (decision "Relay flow control per origin"): the worker keeps the tab origin's window and folds its commits into its pending queues, the relay forwards and acknowledges per message. Before this rework the relay ran the tab's window itself and recovered with reset: every stall, even 1 s, cost three full snapshots (about 1.5 MiB) and about 24 s to catch up under sustained streaming.
  • Tab B's calls are their own origin at the worker, with their own N, but B's call still waits behind the up to 256 calls of A running there (one worker, one event loop): about 4.9 ms median, 11 ms p95, against 0.01 ms alone. A's 10,000 calls take 0.07 ms each (its window of 256 refills one reply round trip through the relay at a time).
  • A first run of this script found about 350 B retained per call or commit: AbortSignal.any on Node 24.4.1 keeps cleanup entries per call while its sources live (nodejs/node#62367, fixed in 24.16.0). Calls and gets now link their signals only until they settle (withLinkedAbortSignal); the numbers above are after that fix (heap during a 60 s stall: 2.3 MiB before, 0.4 MiB after).

Slice 4b: authority and housekeeping

Script: test/bench/compression.ts, run from packages/chord with node --conditions=source --experimental-strip-types --expose-gc test/bench/compression.ts (about 5 s). Node 24.4.1, Intel Core i9-13900, one process.

Payloads: the 500 KB document of slice 3a (500 entries { id, text, author, at } of 960 characters), but with prose-like text (words from a small vocabulary in a fixed pseudo-random order, some with numbers: it compresses about 3.6x at level 1, as conversation JSON does; the "x".repeat texts of the earlier benchmarks would compress about 100x and say nothing), and a 1 MiB string as a call's result (a file export). Compression is option (b) as built: nodeCompression (zlib's raw deflate at level 1) at the origin; the owner inflates with nodeCompression (synchronous, a Node client) or DecompressionStream (asynchronous, a browser).

  • deflate at the origin / inflate: one part through the adapter alone, mean of 20, the fastest of three loops.
  • end to end: a server and a client host on a memory link (binary codec); hold to live is a further get of the live view (snapshot encoded, deflated, sent, inflated, parsed, replicas built), call to result a call returning the 1 MiB string; bytes received is the one message as the client's link delivered it.
Payload JSON deflated (level 1) deflate at the origin inflate, sync (nodeCompression) inflate, async (DecompressionStream)
500 KB snapshot 494.9 KiB 137.4 KiB 3.308 ms 0.915 ms 1.400 ms
1 MiB result 1024.0 KiB 286.1 KiB 6.856 ms 1.757 ms 3.113 ms
End to end over a memory link (binary codec) snapshot: bytes received hold to live (mean of 20) 1 MiB result: bytes received call to result (mean of 20)
uncompressed 494.9 KiB 1.197 ms 1024.0 KiB 1.939 ms
deflated, inflated synchronously (Node client) 137.5 KiB 5.632 ms 286.1 KiB 10.868 ms
deflated, inflated asynchronously (browser client) 137.5 KiB 6.356 ms 286.1 KiB 12.198 ms

Rerun after the review fixes (inflate only after admission by the part's owner, a tagged adapter result, byte counts for the threshold): the same within noise (the asynchronous inflate of the 500 KB part varies between 1.4 and 2.0 ms from run to run).

Observations:

  • Level 1 cuts prose-like JSON to 28% (494.9 to 137.4 KiB, 1 MiB to 286 KiB) at about 6.7 ms per MiB deflated at the origin and 1.8 ms per MiB inflated synchronously; DecompressionStream takes about twice as long as zlib's synchronous inflate (3.1 against 1.8 ms per MiB) and adds its own turns, which the in-order holding of the link's later messages absorbs.
  • On a memory link (no transport time) compression only costs: hold to live 1.2 → 5.6 ms, the 1 MiB call 2.0 → 10.9 ms. It pays where bytes take time: at 10 MB/s a 500 KB snapshot takes about 50 ms uncompressed and 14 ms compressed (plus 4.3 ms of deflate and inflate); at 1 MB/s (a phone) 500 ms against 140 ms. On IPC to a worker (gigabytes per second) it does not pay for itself on that hop: it pays on the tab's WebSocket, where the relay forwards the worker's deflated part as it is (a test counts no inflate at the relay).
  • Parts at or below 16 KiB (UTF-8 bytes) are never compressed (a state change, most call arguments), nor is a part that deflating does not make smaller (sent as the bytes encoded for the attempt, never encoded twice), nor hold params, nor call arguments sent up a parent link: the cost per message of the slices 3a-4a benchmarks is unchanged. The binary codec's part prefix (length doubled, plus one when deflated) costs a byte more for parts between 64 and 127 bytes.

Slice 6a: the store

test/bench/store.ts (run by hand: node --conditions=source --experimental-strip-types --expose-gc test/bench/store.ts from packages/chord): Node 24.4.1, chord/sqlite/node on a fresh SQLite file per burst for both sides (the same pragmas and file system), 7 bursts of 1,000 commits after a warm-up burst; per commit, the median and p95 of single commits and the burst's mean.

The gate (decision "The commit-step benchmark"): the store without outbox no slower than Durable's SqliteStorage.

Case Durable Chord store Chord store + outbox
(a) one document delta per commit (step level) 23.7 / 31.4 / 32.9 µs 13.4 / 18.7 / 20.9 µs 16.6 / 29.2 / 26.7 µs
(b) one table put (Durable: one entry append) 19.3 / 27.1 / 29.5 µs 13.2 / 23.6 / 20.6 µs 17.5 / 28.1 / 26.3 µs
(c) whole transactions back to back, no watch (Durable Session, Chord host) 35.0 / 61.1 / 44.4 µs 18.1 / 27.2 / 25.7 µs 20.3 / 38.8 / 30.7 µs
(c) the same with a watched document 34.1 / 55.4 / 42.4 µs 20.6 / 26.3 / 27.1 µs 20.2 / 39.0 / 29.9 µs
(d) one in-memory document per transaction (no store) 2.5 / 2.9 / 2.7 µs
(d) the same, watched 2.6 / 4.4 / 2.7 µs

Every gate case passes. The outbox adds 3-5 µs per commit (one INSERT of the commit's writes as JSON). The heap after GC at the end of the whole script is about 3 MiB above its start (the modules and statement caches loaded on the way).

In-memory transactions against HEAD (before slice 6a), after the second fix round (Transaction.draft returns its draft's promise as it is: an in-memory change settles in 5 microtask turns, HEAD 6, a test guards it): one change per transaction through a host, 15 bursts of 1,000 per process with a timer around each change, HEAD's source extracted beside this checkout, the same script on both, alternating processes pinned to one CPU (taskset -c 4), three runs; median / p95 / burst mean:

Case HEAD now
unwatched document 2.52-2.55 / 4.96-5.02 / 5.41-5.44 µs 2.56-2.60 / 5.02-5.06 / 5.69-5.71 µs
watched document 2.96-3.07 / 6.44-6.54 / 6.81-6.90 µs 2.96-3.01 / 6.35-6.42 / 6.88-6.95 µs
watched derive through a reference 3.14-3.29 / 6.77-6.93 / 8.38-8.50 µs 3.06-3.08 / 6.49-6.53 / 7.52-7.54 µs

What the differences are (the second review's measurements, Opus):

  • The change path itself is at HEAD's level or below. Without the per-operation timer, a tight loop of 200,000 changes costs 3.67-3.69 µs against HEAD's 3.70-3.78 µs (and the loop of the timed harness 4.85-5.06 µs against 5.15-5.24 µs). Allocation per change is lower (7,703 B against 8,035 B, measured with a semi-space of 128 MB so no scavenge lands in a burst), and so are the collections (40 hosts of 2,000 changes: 34 scavenges against 38, 2 mark-compacts on both).
  • The median difference of the timed harness (+1-2% here, up to +3-7% elsewhere) comes from the per-operation timer: it stays with zero collections in the bursts and disappears without the timer, so it is harness-sensitive (code layout, inline caches), not work in the change path.
  • The burst means differ by where a mark-compact lands: the worst burst per process coincides with a mark-compact over a larger old space (67 MB against 45 MB before the collection, about 18 MB after on both; a pause of 5.2-7.1 ms against 3.0-3.7 ms), which is heap-growth timing, not extra garbage. Host setup is about 3% above HEAD (21.9-23.2 µs against 21.1-21.9 µs per host, create, load and get; close equal), which the burst means include too.

Calls and references (taskset -c 4, five alternating pairs of processes; one host, 21 bursts of 3,000 operations after 3 warm-up bursts, a timer around each; reads: 200,000 per burst, burst means). Every call admits its handler to the drain and its cleanup (call count, ctx.get references) in one map entry with one reaction: 19 promises per call against HEAD's 20, a test guards it. A reference's state members are its object's member sources (one per object and member, re-bound when a new attempt goes live): a watch through a reference gets the member's frame passed on as it is (one call more per frame), a read goes through one getter more.

Case HEAD now
local call: median / p95 / burst median / burst p95 1.49-1.51 / 1.93-1.99 / 1.64-1.67 / 2.51-2.75 µs 1.47-1.48 / 1.93-1.96 / 1.59-1.61 / 2.40-2.47 µs
change under a watch through a reference, derive member: median / p95; frames per second 2.80-2.92 / 4.64-4.80 µs; 268,000-282,000 2.76-2.87 / 4.75-4.84 µs; 278,000-288,000
the same, document member 2.68-2.79 / 4.58-4.79 µs; 285,000-306,000 2.67-2.76 / 4.57-4.79 µs; 285,000-306,000
ref.member.value, derive member 9.24-10.82 ns 8.53-8.57 ns
ref.member.value, document member 10.02-10.10 ns 10.30-10.32 ns

Calls are 1-3% faster than HEAD (bursts' p95 10% lower), watches through a reference at HEAD's level (p95 +1-2% for a derive member, more frames per second), a derive member's read faster; a document member's read costs 0.25 ns more (the member source's getter in front of the slot's).

A local document member re-exposed by a remote object and held over a memory link (one change and its state message per operation; Astra's third review harness, taskset -c 4, alternating pairs, 41 bursts of 10,000 after 3 warm-up bursts): a member source passes its subscribers (holds, member sources re-exposing it) on to the member it follows, so their frames take no extra hop. Burst median 7.80-8.38 µs against HEAD's 7.82-8.35 µs, burst p95 8.84-9.44 against 9.52-10.06 µs, median per operation 6.76-7.34 against 6.82-7.29 µs (before the pass-through, one run: 8.51 / 13.05 µs against 7.99 / 10.36 µs). Host setup (+2-4%) and a document member's read through a reference (+0.25 ns) are accepted as they are.

After Delta's tracker stopped allocating a WeakRef per change (pi a92283056, merged as 95ec93a08), against HEAD 95ec93a08 (which has that change too) extracted at a path as long as this checkout's (44 characters). Sol's tight harness without a per-operation timer (taskset -c 4, five alternating pairs of processes, 31 bursts of 10,000 after 3 warm-up bursts; medians of the burst medians, ranges in parentheses):

Case HEAD now
unwatched change 1.96 µs 1.96 µs (0.0%)
change under a watch of the document 2.24 µs 2.19 µs (-2.1%)
change under a watch through a reference 2.23 µs 2.21 µs (-0.8%)
change under a watch of a derive through a reference 2.33 µs 2.26 µs (-2.9%)
local call 1.36 µs 1.33 µs (-1.9%)
a document member forwarded into a remote object, held over a memory link 6.60 µs (6.54-6.63) 6.52 µs (6.48-6.59) (-1.2%)
the same, yielding a macrotask every 1,000 operations 6.67 µs (6.57-7.02) 6.54 µs (6.42-6.79) (-1.9%)

Collections over one forwarded-link run (--trace-gc-nvp, 340,000 operations, two processes each; scavenges and promotion were the same in both):

Run Promoted Scavenges Mark-compacts GC pauses
HEAD 6 MB 158 2 154 ms
now 7 MB 148 2 147-148 ms
HEAD, yielding every 1,000 6 MB 186 2-3 149-162 ms
now, yielding every 1,000 7 MB 178 2-3 145-149 ms

History: the fifth fix round's measurement, before the Delta change (failure calls once per episode, watches on failed sources, keyed pins still loading). Sol's tight harness without a per-operation timer (taskset -c 4, five alternating pairs of processes, 31 bursts of 10,000 after 3 warm-up bursts; burst medians, HEAD's source at /tmp/chord-head4, this checkout at its own path):

Case HEAD now
unwatched change 2.91 µs 2.95 µs (+1.5%)
change under a watch of the document 3.14 µs 3.10 µs (-1.4%)
change under a watch through a reference 3.10 µs 3.08 µs (-0.4%)
change under a watch of a derive through a reference 3.21 µs 3.26 µs (+1.6%)
local call 1.37 µs 1.35 µs (-2.0%)
a document member forwarded into a remote object, held over a memory link 7.80 µs 8.32 µs (+6.6%)
the same, --no-allocation-site-pretenuring 8.12 µs 8.04 µs (-1.0%)
the same, yielding a macrotask every 1,000 operations 7.17 µs 8.61 µs (+20%)
the same, yielding, --no-allocation-site-pretenuring 7.21 µs 7.52 µs (+4.2%)

Allocation per operation, with a fixed semi-space of 256 MB so no scavenge lands in the measured 20,000 operations, is below HEAD's in every case: 6,942 B against 7,317 B unwatched, 8,383-8,393 B against 8,764 B watched, 8,935-8,939 B against 9,338 B for a derive, 8,405-8,413 B against 8,764 B through a reference, 2,885 B against 3,089 B over the link. What stays after the link drains and a GC is equal (18.9 against 19.0 B per operation).

The forwarded-link gap is not work or retention in 6a's code. It is a V8 decision that depends on heap layout:

  • With huge semi-spaces (no scavenges) the two take the same time (10.01 against 10.00 µs). Without pretenuring (the flag above) they are equal too.
  • An identical copy of this checkout's source runs at HEAD's level (7.1 µs, 7 MB promoted over the run) when its path is at most 40 characters long (/tmp/wexp/packages/chord/src). From 44 characters on it flips: 46 MB promoted, 4 mark-compacts instead of 2, about 9 µs. The repository's own path is 44 characters. HEAD's source does not flip at any tested path length (28 to 62 characters).
  • --trace-deopt shows the flip: V8 marks the allocation site of the delta tracker's per-change overlay context (createContext, inlined into beginChange and Transaction.#begin) as tenured. From then on every change's context is allocated in old space and dies there.
  • The contexts survive scavenges because each change registers its context as a WeakRef target (TrackerImpl.#register). A WeakRef keeps its target alive until the current job ends, and scavenges do not clear it. So a site that allocates one context per change shows a high survival rate.
  • Heap snapshots taken during and after the run are equal between the two trees, and no 6a object outlives its change.
  • pi a92283056 ("Delta's tracker holds its one open change without a WeakRef") removed the cause: the numbers above it show the forwarded link at HEAD's level with 6-7 MB promoted and 2 mark-compacts on both.

A retirement under a diamond of derives (test/bench/derives.ts: 40 derives in 13 layers of two derives joined by a third, 2^13 paths from top to bottom, the bottom watched; one step retires and recreates the document in one transaction): 9.8 µs median, 17.7 µs p95 per step with one visit per derive per batch; 349 µs without the visited set (one visit per path).

Binary state messages (test/bench/links.ts, against HEAD, alternating, pinned): an entry without retirements costs no extra byte (165 / 27 / 23 B for one appended item, one changed field, one removed item, as at HEAD); encode and decode are at HEAD's level (one changed field, 200,000 runs, best of 9: encode 1,167-1,203 ns against 1,172-1,275 ns, decode 692 against 685-705 ns); heap per encode and decode 1,592 / 1,631 / 1,363 B against 1,684 / 1,726 / 1,355 B; commit to frame within HEAD's range (0.112-0.166 ms against 0.111-0.164 ms). After the second fix round (taskset -c 4, two runs each): commit to frame 0.086-0.087 / 0.097-0.099 / 0.065 ms against 0.084-0.089 / 0.098-0.099 / 0.064 ms, a streamed append 0.053 ms against 0.051-0.053 ms, the same bytes and heap.

Other costs (one run each):

Cost Result
100 commits submitted at once: time waiting in the queue against the step itself median wait 956 µs, step 11.3 µs
a query page of 100 rows in an index's order (10,000 rows, cursors) 113 µs per page
one updateWhere over 10,000 rows one step of 10.6 ms (every owner's commits wait behind it)
put with 10-character text cells / 10,000-character cells (2,000 rows, one commit) 2.4 / 58.4 µs per row
a rows migration of 10,000 rows (100 chunks; taskset -c 4) 69-71 ms in all, 7.0 µs per row; chunk commits: first 0.34 ms, last 0.21 ms, median 0.22 ms

Observations:

  • The commit step is cheaper than Durable's per commit: Chord keeps no generic entry log beside the documents engine and its tables, and serializes writes before the queue (rule 8).
  • Between chunks a migration yields one macrotask without the 1 ms clamp of setTimeout(0): setImmediate where the runtime has it, else a MessageChannel message (Workers, browsers). The same 10,000-row migration takes 191 ms with setTimeout(0) (99 clamped yields) and 100 ms with messages in Node (0.27 ms per yield there, against 13 µs for setImmediate).
  • Late migration chunks cost what early ones do (paging forward by the last key keeps each chunk's read bounded); the spec's estimate of about 15 µs per migrated row holds with room.
  • A bulk write's step is the one place a single commit holds every owner's commits for milliseconds; the guide says so.

Slice 6b: Durable on the store, persisted jobs (2026-10-28)

Node 24.4.1, one CPU (taskset -c 6), SQLite files in tmpdir(). HEAD is b2fcfee08, extracted at a path of the same length (/home/badlogic/pi-6h); runs alternate HEAD and the build.

Durable's storage benchmark (packages/durable/test/storage.bench.ts, TIMING_SCALE), StoreStorage (outbox off) against SqliteStorage in the same process, mean ms per operation over three runs (p75 where the mean is one outlier):

Case SqliteStorage StoreStorage
exact entry lookup 0.0030 0.0030 0%
entry page scan (100) 0.0942 0.0940 0%
filtered task scan (50) 0.0206 (p75 0.0200) 0.0220 (p75 0.0207) +7% mean, +3% p75
exact document address among many 0.0037 0.0037 0%
document replay tail (0) 0.0089 (p75 0.0084) 0.0092 (p75 0.0085) +3% mean, +1% p75
document replay tail (16 / 128 / 1024) 0.0300 / 0.1726 / 1.224 0.0270 / 0.1522 / 1.135 -10% / -12% / -7%
ancient / recent historical read 0.1681 / 0.1611 0.1541 / 0.1513 -8% / -6%
fork-depth history scan (100) 0.1177 (p75 0.1156) 0.1208 (p75 0.1159) +3% mean, 0% p75
fork-depth head lookup 0.0361 0.0332 -8%
commit one entry (20 samples) 0.0439 (p75 0.0416) 0.0601 (p75 0.0373) +37% mean, -10% p75
commit 100 entries 0.743 0.620 -17%
commit mixed entry/task/submission/document 0.195 0.187 -4%
reopen and first exact read 0.294 0.443 +51%

Warm micro-benchmarks behind the same cases (each read 20,000 times; commits 10,000 after 2,000 warm-up): page scan 91.2 vs 90.6 µs, task scan 19.9 vs 20.1 µs, fork-depth scan 114.1 vs 114.9 µs, head lookup 35.3 vs 32.9 µs, document address 3.57 vs 3.68 µs, replay tail (0) 8.41 vs 8.44 µs; one entry per commit 29.6 vs 27.8 µs mean, 18.4 vs 17.1 µs median. What it took, beyond the first build's compiled reads, point lookups without a bound LIMIT, version columns selected only past version 1 and the counting read guard:

  • Reads select only the record. A scan selected an entry's commit_seq too (only entry(id) needs it): 7% of a page scan.
  • No index for head markers on every entry. entries.by_head (conversation_id, has_head, id) cost about 5.5 µs per commit (a raw insert: 39.7 vs 45.2 µs); SqliteStorage has a partial index (WHERE head IS NOT NULL), which the table vocabulary does not have. The head markers are an entry_heads table instead, written only with an entry that has one: a commit without one writes no second index, and a head lookup reads the newest head's id, then the entry.
  • A document record is one JSON text. meta keeps the whole Durable record, its creation seq included; a read selects meta (and retired_at only at a point).
  • A store already at the newest schema opens with one read (schema version, encoding and metadata row), without the migrations' write transaction: 84 → 71 µs on a fresh connection.

Commit one entry: the mean is one sample (Maglev compiling the commit path on the first fixtures: 130 and 386 µs at fixtures 25 and 30 of the first store round of a 60-fixture replay, none in the second round, no GC or deopt traced); the median and p75 are lower than SqliteStorage's. Reopen: a fresh connection on a copied file, measured in parts (openNodeSqliteDatabase is the same for both): SqliteStorage.open 42-60 µs; openStore 63-71 µs (the schema read, the catalog with its JSON parse) plus StoreStorage.open 75 µs (declaring six tables: their SQL rendered, claims; the catalog check of the table preparation; max(record_ids.id) for the allocator, as §2.1 asks; compiling the point reads), first read +3 µs: about 0.1-0.15 ms once per open, cold code, not on any hot path.

Statements per commit (cost guard in packages/durable/test/store-storage.test.ts): an entry append and a document delta take 4 each (SqliteStorage: 5 and about 7).

Memory (packages/durable/test/storage-memory.ts): heap after seeding 0.16 → 0.30 MiB (1k), 0.17 → 0.32 MiB (10k): a fixed cost of the store (catalog, compiled statements, claims, the store's code), the same at both scales; heap after reads 0.13 → 0.16-0.18 MiB. Disk 0.61 → 0.58 MiB (1k), 4.34 → 4.38 MiB (10k).

Chord's commit gate (test/bench/store.ts), HEAD → build, two alternating runs each, medians:

Case HEAD build
(a) one document delta per commit 13.6-13.7 µs 13.8-14.0 µs (+2%)
(b) one table put per commit 12.8-13.3 µs 13.0-13.2 µs
(c) whole transactions (no watch / watched) 17.2-17.3 / 17.1-17.2 µs 17.4-17.7 / 17.2-17.7 µs (+2%)
(d) one in-memory document (median / burst) 2.1 / 2.9-3.0 µs 2.1 / 4.9 µs
heap after GC 2940 KiB 2996 KiB

The revisions index is a partial index on bases ((document_id, seq) WHERE kind = 'base'): a delta writes no index entry (the first build's (document_id, kind, seq) cost (a) 15%). (d)'s burst is not the in-memory path: run alone (only section (d), three alternating runs) HEAD gives 4.9-5.1 µs and the build 4.9-5.0 µs per commit, and 40 bursts of 1,000 in a fresh process 2.17 vs 2.18-2.38 µs; in the full gate, HEAD's in-memory path runs warm from the persisted sections before it, the build's does not share as much of their code (StoredChange's variants).

Durable's conversation view benchmark (test/conversation-view-bench.ts, PI_DURABLE_BENCH_STORAGE), two runs each, SqliteStorage → StoreStorage: append one entry per commit 56.1-57.2 → 56.2-57.0 µs with one watch, 61.0-62.1 → 62.7-63.1 µs with four (+2%); bursts of 50 884-888 → 891 µs; pi.live only 62.7-63.0 → 65.1-66.6 µs (+4-6%); a long-lived watch over 20,000 appends 0.83-0.85 → 0.84-0.85 s CPU; heaps 21.5 → 21.7 MiB.

Fix round 1 (2026-10-28, after the build reviews): StoreStorage's reads take one promise besides the store's guard (no #call wrapper; close waits for its commits, a read changes nothing), their request errors in a tagged result. Warm micro-benchmark (20,000 runs, two runs each, SqliteStorage → StoreStorage): filtered task scan 19.8 → 19.8-20.1 µs, entry(conversation, id) over the fork chain 23.1 → 21.9-22.3 µs. storage.bench.ts (two runs): task scan mean 0.0207 → 0.0217-0.0218 ms (p75 0.0200-0.0203 → 0.0204-0.0206), fork-depth scan 0.1177-0.1188 → 0.1210-0.1242 (p75 0.1154-0.1167 → 0.1160-0.1193), the rest as above. conversation-view-bench "resume 20 back", three runs: CPU 4.23-4.47 → 4.50-5.14 ms for a 3 ms scenario (one sample each; the head lookup and scans under it are at parity or faster in the micro-benchmark). The persisted-job fixes (one invocation per call of run, held state folded in a PendingOps): start 55.5 → 55.2 µs, immediate state change 21.3 → 21.2 µs, deferred 3.2 → 3.5 µs (the fold), in memory unchanged.

Persisted jobs (test/bench/jobs.ts, 7 bursts of 500, medians): a start (resolved after its commit) 8.4 µs in memory, 55.5 µs persisted (two commits: the record and state created, then the reservation); a state change inside run 2.7 µs in memory, 21.3 µs persisted (one delta commit, as the gate's (c)), 3.2 µs deferred (stored with the outcome).

Slice 8: registries and host.root (2026-10-28)

Node 24.4.1, one CPU (taskset -c 6), runs alternating. HEAD is 391e83fb1, extracted at a path of the same length (/home/badlogic/pi-h8); the build was measured in the worktree and, where the worktree itself skewed a result (below), in an extract of the same sources at /home/badlogic/pi-x8.

Paths under every object (test/bench/calls.ts, new: runs on HEAD as it is), median per operation over nine bursts, heap allocated per operation, three alternating runs each, HEAD → build:

Case HEAD build
local method call (its ctx) 2.80-2.81 µs, 6,894 B 2.81-2.83 µs, 6,894 B
commit through a watched derive (batch.compute) 10.34-10.43 µs, 15,312 B 10.46-10.51 µs, 15,310 B
activation churn: get, live, release (an object with params) 57.5-59.3 µs, 43,696-43,717 B 58.1-58.9 µs, 43,782-43,787 B
retained per live object (10,000 kept) 6,586 B 6,602 B

The attempt keeps its registry reads, contributions, wake signals and writables in one record made by the first such declaration, and iterates nothing when there is none: the first build allocated 1,020 B more per activation (six arrays per attempt, an array iterator per for … of over an empty list, a copy from splice) and retained 72 B more per live object; now +70 B allocated and +16 B retained (one field on the attempt, one on the object).

Existing benchmarks, HEAD → build: the derive diamond (derives.ts, three runs) 9.4-9.8 → 9.5-9.9 µs median; its pooled p95 16.5-17.1 → 18.4-20.8 µs comes from the run in which V8 tiers the commit path up (per run, after it: HEAD 10.7-11.3 µs, build 10.1-11.3 µs; the first measured runs 15.0-25.6 µs on either side, in another run each), the same with HEAD's batch.ts and derive.ts in the build. The store gate (store.ts, three runs): every median within 0.4 µs (Chord store 13.0-13.9 → 13.0-14.2 µs, Chord host 17.3-18.2 → 17.3-17.8 µs, in-memory document 2.1 → 2.2 µs), heap 3,498 → 3,144 KiB. Jobs (jobs.ts): start 8.4 → 8.5 µs in memory, 55.4 → 56.2 µs persisted; state change 2.7 → 2.7, 21.3 → 21.8, 3.4 → 3.5 µs. Links (links.ts, three runs): wire sizes, encodes, decodes and heap per message unchanged; its round trips are one cold burst each and vary as much between HEAD runs (commit to frame, text codec, 0.055-0.076 ms). A warmed remote call (2,000 calls per burst, nine bursts, a memory link): binary 7.16-7.24 → 7.17-7.29 µs, text 7.66-7.72 → 7.70-7.83 µs in the extracts; the worktree itself runs the same sources at 7.8-7.9 µs (binary), an effect of the tree, not the code.

Registries and writables (test/bench/registries.ts, new), median over nine bursts: writable.set delivered to one watch 0.81-0.91 µs (2.9 KB allocated: the batch and the watch's call); a set of the same value 0.008 µs, 0 B (one comparison: its batch work is a method of its own, so set allocates no closure context); one of 10 contributions changed while the registry is watched 0.74-0.77 µs, of 100 1.52-1.59 µs (the table built again over every entry, one compose per set: the counted chord.registry.composes is 1.00 per set).

Fix round 1 (2026-10-28), HEAD extract → build extract (/home/badlogic/pi-x8), CPU 6. calls.ts, two runs: local method call 2.80 → 2.80-2.81 µs, 6,894 B both; commit through a watched derive 10.51-10.65 → 10.72 µs; activation churn 57.9-59.0 → 59.3-60.0 µs, 43,714 → 43,769-43,787 B. The derive diamond (five runs): median 9.4-9.8 → 9.5 µs, pooled p95 16.4-16.7 → 19.0-19.2 µs. Per run, both have one slow run while V8 tiers the commit path up (HEAD its second, median/p95 15.5/22.6-22.9 µs; the build its first, 16.4/26.7-27.2 µs) and equal runs after it (HEAD 8.8-9.6/9.6-11.2 µs, build 8.9-9.8/9.7-10.6 µs). batch.compute is not the cause: HEAD's batch.ts and derive.ts in the build give the same p95, and swapping host files moves it without a pattern (code layout, not work on the path). registries.ts: writable.set 0.87 µs, the same value 0.011-0.016 µs and 0 B (the seal check now comes first: a set of an ended attempt's writable throws whatever its value); one of 10 contributions set 0.71-0.78 µs, of 100 1.43-1.61 µs (one run in six 3.4 µs on either variant tried: JIT).

Slice 7a: the dependency model (2026-10-29)

Node 24.4.1, one CPU (taskset -c 6), runs alternating. HEAD is babe30a7e, extracted at a path of the same length (/home/badlogic/pi-h7); the build in the worktree (/home/badlogic/pi-7a).

Paths under every object (test/bench/calls.ts), median per operation over nine bursts, heap allocated per operation, three alternating runs each, HEAD → build:

Case HEAD build
local method call (its ctx) 2.81-2.82 µs, 6,894 B 2.80-2.82 µs, 6,894 B
commit through a watched derive 10.43-10.70 µs, 15,310 B 10.38-10.53 µs, 15,310 B
activation churn: get, live, release (an object with params and one dependency) 58.5-59.9 µs, 43,716-43,719 B 53.7-55.4 µs, 41,540-41,559 B
retained per live object with one dependency (10,000 kept, an ad-hoc probe) 7,398-7,406 B 6,931-6,936 B

An activation whose dependencies are live at the check does not park: one check of its bindings, then its own steps a microtask later (as HEAD's wait did). The first build parked every attempt with a dependency (a waiter list, a parked_long record, a microtask per wake even outside parking) and cost 62.0-63.6 µs and 580 B more per activation; now a wake outside parking is a flag, the first-outcome promise is made only for whoever waits (the start barrier), and an object keeps no Set of waits and no settlement promise per attempt (R18's), hence the lower numbers.

Links (links.ts, three runs each): wire sizes, encodes, decodes, heap per message and retained heap per hold (176.0 → 175.4-176.0 KiB) unchanged. Hold to live over two hops (mean of 100), binary: 1,000-item list 0.781-0.817 → 0.807-0.811 ms, 500 KB document 1.250-1.278 → 1.289-1.310 ms (+1-3%: the hold counted at its arrival, the index check of the publication); text 1.545-1.580 → 1.521-1.573 and 3.299-3.379 → 3.317-3.393 ms. Commits to frames, streamed appends, call round trips and reconnects within the runs' spread.

Existing benchmarks, HEAD → build: the derive diamond (derives.ts, three runs) 9.4-10.1 → 9.5-9.6 µs median, p95 19.3-19.9 → 19.3-19.4 µs. Registries (registries.ts, three runs): writable.set 0.82-0.97 → 0.77-0.87 µs; one of 10 contributions set 0.72-2.71 → 0.70-1.06 µs; of 100 1.50-3.46 → 1.44-3.40 µs (one slow run per side: JIT); allocation within 20 B. A registry's last good composition keeps the list of contributions it was built from: the list is replaced (not spliced) when a contributor comes or goes, so a composition costs no copy.

Fix round 1 (2026-10-29: revocation over paths, retry operations on the wire, the provider's own host-address check for same-host remote requests, mapped values' provenance), calls.ts, three alternating runs, HEAD → build: local call 2.81 → 2.79-2.82 µs (6,894 B both), watched derive commit 10.45-10.70 → 10.35-10.47 µs, activation churn 58.6-59.8 → 53.9-54.2 µs (43,716-43,720 → 41,559 B); retained per live object with one dependency 7,399-7,405 → 6,930-6,938 B. The address check runs only for a remote type whose consumer and provider hostAddress differ; a retry's operation id is made per ref.retry(), and an object keeps its handled ids only once it was retried.

Slice 7b: reload in the kernel (2026-10-29)

Node 24.4.1, one CPU (taskset -c 6), runs alternating. HEAD is 0c3675e64, extracted at a path of the same length (/home/badlogic/pi-hb); the build in the worktree (/home/badlogic/pi-7b).

Paths under every object (test/bench/calls.ts), median per operation over nine bursts, heap allocated per operation, alternating runs (the last two after the fixes below), HEAD → build:

Case HEAD build
local method call (its ctx) 2.80-2.83 µs, 6,894 B 2.67-2.70 µs, 6,757 B
commit through a watched derive 10.38-10.69 µs, 15,309 B 10.22-10.39 µs, 15,312 B
activation churn: get, live, release (an object with params) 53.6-55.3 µs, 41,539-41,560 B 54.2-55.6 µs, 41,606-41,630 B
retained per live object with params and one dependency (10,000 kept, an ad-hoc probe, median of 5) 8,696-8,700 B 8,761 B

The first build cost 60.4-62.7 µs and 2,480 B more per activation: every attempt made an AbortController for its routines and two maps (routine calls, job invocations) whether it had any. Now they are made when routines start or a run is admitted, and the first dependency check's answer is a shared constant unless someone asks for it; what a reload did to an attempt is one record made by the first such step. What remains: a few fields on the object (its params key) and the attempt (its routines, its reload record, its first check), +46-70 B allocated and +61-65 B retained (0.7%). A local call allocates 137 B less: the method path is split (#invoke) so the current version's call builds no mapping closure.

Links (links.ts, two runs each): wire sizes, encodes, decodes and heap per message unchanged. Hold to live over two hops (mean of 100), binary: 1,000-item list 0.780-0.794 → 0.797-0.821 ms (+1-4%), 500 KB document 1.246-1.275 → 1.270-1.275 ms; commits to frames and streamed appends within the runs' spread. Retained per hold of 10: 175.8 → 176.2-176.5 KiB (the hold keeps its request, to be placed again after a reload); the single-hold row 176.0 → 180.2 KiB is one fixed amount (~4 KiB, code of the new paths), divided by ten in the other row.

Existing benchmarks, HEAD → build: the derive diamond (derives.ts, two runs) 9.5-9.6 → 9.6 µs median, p95 16.4-19.3 → 14.1-14.3 µs. Registries (registries.ts, two runs): writable.set 0.94-0.96 → 0.77-0.86 µs; one of 10 contributions set 0.77-1.05 → 0.69-0.72 µs; of 100 1.57-1.58 → 1.54-3.30 µs (one slow run: JIT, as in 7a). Jobs (jobs.ts, two runs): start 8.6-8.7 → 8.5 µs in memory, 55.3-56.1 → 55.3-55.6 µs persisted; state changes within 0.1 µs. The store (store.ts): every median within 0.3 µs. The relay (relay.ts, three runs): tab B alone 0.014 ms both; 10,000 calls at once 530-723 → 491-785 ms (the spread of either side).

Reload (test/bench/reload.ts, new): 20 plugins, 1,000 live objects (with params, a document, a derive and a method each), one plugin reloaded 20 times; medians after two warm-up reloads, two runs:

Case reload resolves all live again heap after 20 reloads, above after the first
swap, 50 of the 1,000 objects in the reloaded plugin 1.58-1.59 ms 1.64-1.65 ms 332-338 KiB
swap, all 1,000 in it 35.5-36.3 ms 36.6-37.2 ms 191-192 KiB
restart (a native each), 50 of 1,000 1.20-1.54 ms 1.90-2.05 ms 105-106 KiB
restart, all 1,000 20.0-20.2 ms 35.8-40.8 ms 57-58 KiB

A swap costs about 36 µs per object (its staging record, the new attempt's activation, the hand-over of its old one), a restart about 20 µs until the reload resolves (staging and the start of each fate; the new attempts activate after the drains). The heap after reloads levels off (swap, 50 objects: 332 KiB after 20, 437 KiB after 80, 550 KiB after 300 reloads): compiled code and feedback of the new facets, not objects (the GC tests collect old attempts, removed objects, providers and facets).

Fix round 1, CPU 6, alternating against HEAD. calls.ts, three runs: local call 2.80-2.82 → 2.66-2.68 µs (6,894 → 6,757 B); watched derive commit 10.42-10.66 → 10.31-10.47 µs; activation churn 53.7-55.8 → 54.3-55.9 µs, 41,540-41,560 → 41,566-41,584 B (an ?? [] per end, found here, is gone: the round's first measurement allocated 41,698 B); retained per live object with one dependency 8,699-8,700 → 8,768 B. Links (a review measured binary commit-to-frame +19% and small calls +6% on another CPU): links.ts, two runs, binary commit to frame 0.085/0.097-0.099/0.063-0.065 → 0.087/0.097/0.064 ms (append/change/removal), small call 0.038-0.039 → 0.035 ms. A warmed ad-hoc probe (binary memory link, 2,000 calls and 1,000 commits per burst) measures no regression: call round trip 9.8-10.2 → 9.9-10.1 µs, commit to frame 60.6-65.1 → 61.0-61.6 µs. Reload benchmark after the round: swap 50 / 1,000 objects 1.50 / 35.9 ms, restart 1.67 / 21.4 ms until the reload resolves (2.12 / 38.1 ms until all are live); heap after 20 reloads 339 / 193 / 104 / 68 KiB.

Fix round 2, alternating against HEAD. Stock links.ts on CPU 22, six rounds each before and after the fix (medians, binary / text): before, small call 0.062 → 0.068 / 0.058 → 0.064 ms (+10%), commit to frame within 1% (append 0.156 → 0.156, changed field 0.165 → 0.162, removal 0.120 → 0.119 ms binary). Cut-down copies located it: calls alone, or after the snapshot, commit and stream sections, or after the hold-memory trials alone, measured equal; only the full sequence showed the gap, and a CPU profile of its calls section put the difference in the remote call path (Hold.call with the attempt's call inlined, about 6.7 µs per call). The cause was Attempt.call delegating the invocation to a helper method shared with the older-version path; one body for every version, and no async (a local call allocated about 250 B more as an async function), fixed it. After: small call 0.063 → 0.065 / 0.058 → 0.058 ms, append 0.156 → 0.157, changed field 0.164 → 0.163, removal 0.119 → 0.120 ms, hold to live (1,000 items) 1.037 → 1.023 ms. calls.ts on CPU 6: local call 2.89-2.92 → 2.77-2.85 µs (6,894 → 6,660 B), watched derive commit 10.44-10.58 → 10.65-10.81 µs (15,309 → 14,969 B), activation churn 54.8-55.1 → 54.6-54.7 µs (41,560 → 41,592 B); retained per live object with one dependency 8,700 → 8,777 B. Reload benchmark: swap 50 / 1,000 objects 1.84 / 33.7 ms, restart 1.31 / 22.3 ms until the reload resolves (2.18 / 41.0 ms all live); heap after 20 reloads 323 / 200 / 104 / 70 KiB.

Fix round 3: the commit-to-frame gap is V8's young generation, not frame work. Astra (CPU 22, five pairs) measured binary append commit-to-frame 132 → 161 µs, changed field 148 → 168, removal 112 → 121 on the stock links.ts.

  • Path length. The HEAD copy /home/badlogic/pi-hb measured append 0.155 ms; Astra's /tmp/s7b-hd-astra, with identical sources, 0.129 ms. A copy of the same tree at a path of Astra's length (17 characters) measured 0.130 ms, at a longer one 0.155 ms: on the same code the result depends on the checkout path alone. Compare copies at paths of equal length.
  • Stock numbers, equal paths (/tmp/s7b-hd-astra vs a fresh build copy /tmp/s7b-xx-astra, CPU 22, three alternating runs, means): append 0.128-0.129 → 0.155-0.156 ms binary, 0.106-0.107 → 0.116-0.117 text; changed field 0.144-0.145 → 0.161-0.162 binary, 0.097-0.098 → 0.107-0.110 text; small call 0.061-0.063 → 0.065-0.066 binary, 0.058-0.059 → 0.058-0.059 text.
  • Same work. Heap allocated per commit is equal (191.9 vs 191.4 KB including the codec rows); so are the telemetry records per commit (5.012, the same names: two validations, two link checks, one encode).
  • The difference is scavenges. In the timed append loop HEAD ran 5 scavenges costing 3.6 ms in total, the build 3 costing 21 ms (changed field: 2 for 2.7 ms vs 1 for 10.9 ms). At the loop's start the young generation was 33.5 MB in HEAD and 67 MB in the build (134 MB by the changed-field loop): V8 grows it from the bytes surviving its collections, and the build crossed that threshold a few scavenges earlier. Over the whole run the GC work is equal (scavenge time 1,620 vs 1,605 ms, survived bytes 471 vs 480 MB, 285 vs 284 scavenges); where the expensive scavenges fall relative to the timed loops differs. Tracing the GC (--trace-gc-nvp) moves HEAD into the build's pattern by itself.
  • With the young generation pinned the two are equal: means 0.139/0.139 ms (append), 0.145/0.144 (changed field), 0.116/0.115 (removal), 0.065/0.064 (small call), HEAD/build; per-commit medians and p90s too (three runs, warmed script): append 0.085-0.086 / 0.085-0.086 ms median, 0.101 / 0.101-0.103 p90; changed field 0.073 / 0.072-0.073, 0.083-0.084 / 0.083; removal 0.060-0.061 / 0.060-0.061, 0.073 / 0.072-0.073 (binary; text alike, within 1 µs).
  • The extra live heap is code. Build minus HEAD after a full collection: +249 KB right after loading the modules (old space +229 KB, before any object), +290 KB after a snapshot section, +296 KB after 1,000 appends, +281 KB after 1,000 edits, +345 KB after 70 hold trials (old space +259 KB, the rest bytecode and code as more paths compile). It does not grow with commits or holds beyond the retained-per-hold and per-object figures above.

So links.ts changed: each commit section runs 1,000 untimed commits first (WARM_UP_COMMITS; the removal section starts with 1,000 more notes for them), and reports commit-to-frame as mean, median and p90 of the 1,000 timed commits. The warm-up stays because the timed loop then runs nearer the young generation's steady size: the warmed medians match the pinned ones (append 0.082 vs 0.085 ms, changed field 0.072 vs 0.073, removal 0.060 vs 0.060), and HEAD and build agree in five of six sections, against two of six without it (run-to-run spread is about 1 µs either way). The mean still carries the scavenges that fall in the loop. After the change, equal paths, CPU 22, three alternating runs, HEAD → build (mean; median; p90, ms):

Section binary text
append 0.106-0.108 → 0.142-0.144; 0.081-0.083 → 0.090-0.091; 0.093-0.096 → 0.125-0.127 0.095-0.119 → 0.121-0.125; 0.078-0.080 → 0.080-0.082; 0.090 → 0.094-0.095
changed field 0.084-0.087 → 0.098-0.099; 0.071-0.075 → 0.072-0.073; 0.081-0.084 → 0.081-0.082 0.086-0.090 → 0.099-0.101; 0.067-0.071 → 0.070-0.071; 0.079-0.082 → 0.082-0.084
removal 0.103-0.104 → 0.107-0.109; 0.060-0.061 → 0.060-0.061; 0.071-0.073 → 0.072-0.073 0.100-0.102 → 0.077-0.103; 0.058-0.060 → 0.059; 0.067-0.069 → 0.067-0.070
small call 0.052-0.058 → 0.053-0.054 0.054-0.065 → 0.059-0.069

The binary append differs unpinned (median +11%, p90 +31%) and is equal pinned: the young generation's size in that section, not per-append work. To compare commit-to-frame across two copies, run both at paths of equal length, pinned to one CPU, alternating, with the young generation pinned:

taskset -c 22 node --min-semi-space-size=16 --max-semi-space-size=16 --conditions=source --expose-gc test/bench/links.ts

Fix round 3 hot paths, /tmp/s7b-hd-astra → a build copy at a path of equal length, CPU 22, three alternating runs (calls.ts): local call 3.69-3.71 → 3.56-3.63 µs (6,894 → 6,660 B), watched derive commit 17.39-17.50 → 17.41-17.53 µs (15,310 → 14,967 B), activation churn 104.2-104.8 → 107.6-108.3 µs (+3%; 41,526 → 41,426 B); retained per live object with one dependency 8,700 → 8,585 B (one table per version replaced the current version's separate member map). Reload benchmark, same CPU: swap 50 / 1,000 objects 2.88 / 51.9 ms, restart 2.49 / 35.7 ms until the reload resolves (3.22 / 65.7 ms all live); heap after 20 reloads 345 / 188 / 119 / 85 KiB.

Sol's independent runs (CPU 24, five pairs of the stock links.ts) show the same commit-to-frame gap (binary append 135 → 162 µs, changed field 145 → 168, text 109 → 119 and 100 → 108): the young-generation effect above. Sol's isolated round trips (sol-isolated-links.ts, nine bursts each, no telemetry) measured a binary call at 44.83 → 47.20 µs (+5.3%) on the round-2 code. On this round's code, /tmp/s7b-hd-astra → a build copy at a path of equal length, CPU 24, five alternating pairs, medians: binary call 47.23 → 45.74 µs (-3.2%; HEAD's samples are bimodal, 44.3 or 46.9-47.9), binary commit 92.68 → 93.28 (+0.6%), text call 39.02 → 39.76 (+1.9%), text commit 82.86 → 85.28 (+2.9%); with the young generation pinned: binary call 45.45 → 43.85 (-3.5%), binary commit 92.73 → 93.06, text call 39.97 → 39.65, text commit 83.65 → 85.46 (+2.2%).

Rework: references are the only indirection (decision of the same name). /tmp/s7b-hd-astra (HEAD) → a build copy at a path of equal length, CPU 22, three alternating runs. calls.ts: local call 3.72-4.03 → 3.62-3.77 µs (6,894 → 6,660 B); watched derive commit 17.46-17.92 → 17.53-18.40 µs (15,310 → 14,900 B); activation churn 104.7-107.9 → 114.7-118.3 µs (+9.6%, accepted by the owner, 2026-10-29; 41,507-41,526 → 41,473-41,501 B). The churn's object derives over its dependency's member: that derive now attaches through its own reference's member (one ReferenceSource made, bound and ended per cycle) where HEAD shared the provider's one member source; the allocation is HEAD's, the time is the new node's (a copy of round 6, before the rework, measured 55.2 µs against HEAD's 53.5-54.6 µs and the rework's 58.4 µs on CPU 6). Retained per live object with one dependency (an ad-hoc probe, 2,000 objects, median of five): 6,924 → 6,738 B; a first cut made a source for every member at the binding and allocated four sets per source (8,627 B): sources are made on first use or when a reference that had values detaches, and a source's watch, subscriber and pass-through sets are made by their first user. links.ts (warmed, mean / median / p90 in ms), binary: append 0.107-0.113 / 0.082-0.083 / 0.095-0.097 → 0.141-0.142 / 0.089-0.090 / 0.123-0.124 (the young-generation effect above), changed field 0.084-0.086 / 0.071-0.072 / 0.081-0.082 → 0.098-0.099 / 0.072-0.073 / 0.081-0.082, removal 0.102-0.108 / 0.060 / 0.071-0.073 → 0.107-0.109 / 0.060-0.061 / 0.072; with the young generation pinned, append 0.140-0.143 / 0.086 / 0.100-0.102 → 0.143-0.147 / 0.085-0.087 / 0.101-0.103, changed field 0.091-0.092 / 0.072-0.073 / 0.082-0.084 → 0.091-0.092 / 0.073-0.074 / 0.082-0.084, removal 0.108-0.111 / 0.061-0.062 / 0.072-0.075 → 0.108-0.110 / 0.060-0.062 / 0.072-0.076; text alike within 3 µs. Small call 0.058-0.060 → 0.053-0.056 ms. Retained per hold (both ends): 175.4-176.0 → 176.4-177.0 KiB with one hold, 175.8 → 176.4 KiB with ten.

The 1 MiB call row (final review): one burst of 50 calls read 0.389 → 1.088 ms (binary, young generation pinned) in one review run. Not per-call work: a probe with the row's setup and a collected heap before each of 20 bursts of 50 (CPU 22, three alternating pairs) measured, per call without the collector's pauses, 0.27-0.31 → 0.30-0.31 ms binary and 0.31-0.36 → 0.33-0.36 ms text, heap 23,025 → 22,707 B binary and 23,964 → 23,650 B text. A burst's time is about 0.3 ms per call plus 0.2-0.27 ms per call when a full collection falls in it (the arguments fill memory outside the heap); one burst of 50 sees one or none, in either tree. links.ts now collects the heap before each of 20 bursts and reports the median and p90; three alternating pairs of the new row, stock, binary median / p90 0.386-0.455 / 0.409-0.473 → 0.279-0.357 / 0.306-0.394, text 0.344-0.395 / 0.510-0.566 → 0.363-0.500 / 0.522-0.621; pinned, binary 0.689-0.910 / 0.738-1.050 → 0.695-0.882 / 0.829-0.992, text 0.415-0.896 / 0.958-1.049 → 0.534-0.864 / 1.042-1.175. The row still moves by run with the collector's phase; compare it across several runs, not one. The small call, measured apart (ten bursts of 1,000 after 3,000 warm-up calls, three alternating pairs): stock, binary 29.5-30.6 → 27.6-27.9 µs and text 25.1-25.9 → 27.5-28.3 µs; with the young generation pinned, binary 29.7-30.7 → 30.3-30.7 µs and text 23.6-23.7 → 23.9-24.1 µs. The stock text gap is the young generation's sizing, as for commit to frame above (pinned, the two match); the snapshot before the final review's fixes measures the same.

Simplification audit (2026-10-30)

HEAD 4b535d57e → the audit build (A1, A2, A5, A9-A12), copies at paths of equal length, CPU 6, alternating. calls.ts, three runs: local call 2.70-2.73 → 2.69-2.73 µs (6,660 B both), watched derive commit 10.41-10.75 → 10.42-10.74 µs (14,899 B both), activation churn 59.0-60.9 → 59.1-61.4 µs (41,473-41,482 → 41,478 B). Retained per live object with one dependency (10,000 kept) 6,745-6,754 → 6,745-6,752 B; a first cut of the declaration-ordered dependency list (A11) kept it on live attempts, +225 B, and is now let go after the dependency check. links.ts with the young generation pinned: commit to frame, hold to live and retained per hold equal within run spread (176.4 → 176.2-176.6 KiB per hold).

Text-codec calls in isolation (Sol's probe: a remote object over one memory link, 5,000 warm-up calls, ten bursts of 1,000 small calls, then 200 warm-up and 30 bursts of 10 calls with a 1 MiB argument; binary first, then text; the heap collected before each burst and the collections in it recorded: none in any burst), seven alternating pairs, median of the pairs' medians:

Case stock young generation pinned
binary small 26.53 → 26.37 µs 25.72 → 25.90 µs
text small 19.31 → 20.95 µs (+8.5%) 19.11 → 19.93 µs (+4.3%)
binary 1 MiB 985 → 993 µs 539 → 412 µs (bimodal: 187-607 µs per run)
text 1 MiB 958 → 963 µs 273 → 284 µs (221-352 µs per run)

Heap per call is the same (text small 15,171 → 15,160-15,202 B). The text small gap is not per-call work: the codec alone (encode, decode and the message check of a call and a result message, 20,000 per burst) measures the same in both trees (text call 1.55 µs and 52 B, result 1.52 µs and 171 B), deoptimizations and inlining decisions are the same, and the gap follows the shape of the text decoder's fromFields, not what runs: the build with HEAD's text.ts matches HEAD (19.0-19.2 µs), the one change in it that matters is the never-executed T (retry) case, and writing that case with an object spread again (...(f.length >= 2 ? { hops } : …)) restores HEAD's 19.1 µs while other edits of the same size do not. With text measured first the gap is 22.4-22.5 → 23.0-23.3 µs (+2-3%). V8 code generation, recorded as noise.

Slice 7c: plugins (2026-10-30)

The plugins component (test/bench/plugins.ts, new): 20 plugins, one worker facet each (a store object over a document, a derive, a method, a helper module and a copied contract; 80.9 KiB of scripts in all), Node 24.4.1. Medians of seven runs; three runs pinned to CPU 8, one unpinned:

Measurement CPU 8 unpinned (32 CPUs)
cold build of the 20 worker facets (one esbuild build each, all at once) 240.1-240.7 ms 26.4 ms
rebuild of one facet after an edit 13.1-13.3 ms 11.0 ms
evaluation of one facet (vm.Script, its source map registered) 0.16-0.27 ms 0.09 ms
reload of one plugin on a host (evaluation and host.reload, a pure swap) 0.44-0.45 ms 0.44 ms
heap after 100 reloads of one plugin, against after a warm-up +230-235 KiB +229 KiB

The heap does not grow per reload: 2,000, 4,000 and 8,000 reloads leave +897, +1,186 and +1,432 KiB (sublinear); the same evaluations through plain vm.Script with V8's compilation cache off leave +420 KiB at 8,000 against +465 KiB through evaluateFacet (V8's own per-script bookkeeping), and the kernel's reloads alone wander between +200 and +600 KiB at any count. Old facets and their closures are collected (the GC test: 200 reloads, every facet but the last two collected, the heap within 2 MiB of the warm-up).

Fix round 1 (inline source maps without sourcesContent: positions only), CPU 8: the 20 scripts take 53.4 KiB (80.9 KiB before); cold build 241.1 ms, rebuild 13.0 ms, evaluation 0.14 ms per facet, a reload 0.39 ms, heap after 100 reloads +238 KiB.

Kernel hot paths (per plugin at load and reload changes only staging), HEAD 18a155e2f → build at paths of equal length, CPU 6, three alternating runs. calls.ts: local call 2.69-2.71 → 2.71-2.75 µs (6,660 B both), watched derive commit 10.34-10.36 → 10.43-10.49 µs (14,899 → 14,901 B), activation churn 58.93-59.58 → 59.44-59.73 µs (41,452-41,470 → 41,470 B). links.ts binary commit to frame (median / p90): append 0.035-0.036 / 0.058-0.059 → 0.034-0.036 / 0.058-0.060 ms, changed field 0.027-0.028 / 0.031-0.033 → 0.027-0.028 / 0.031-0.033 ms, removal 0.022 / 0.026-0.028 → 0.022 / 0.026-0.027 ms; with the young generation pinned alike within 2 µs; text alike; small call 0.026-0.030 → 0.028-0.033 ms (pinned 0.030-0.035 → 0.030-0.032 ms); retained per hold equal (one binary hold 176.2 → 170.1 KiB). reload.ts: swap of 50 / 1,000 objects 1.44-1.73 / 30.83-32.71 → 1.52-1.53 / 30.55-33.25 ms, restart 1.01-1.05 / 15.54-16.87 → 0.95-0.99 / 15.90-16.02 ms until the reload resolves.

Slice 10: HTTP and workers (2026-10-31)

test/bench/http.ts and test/bench/workers.ts, Node 24.4.1, CPU 6 (the forked workers inherit the pin).

Measurement Result
keep-alive round trip, a JSON body: plain Node handler / a route (same process as the client) build: 42.0-42.5 / 46.4-47.0 µs: +4.2-4.9 µs (9.9-11.7%); fix round 1: 42.5-42.9 / 62.8-64.4 µs: +20.3-21.5 µs
the same after 30,000 warm-up requests (fix round 1) 30.9-31.3 / 43.4-44.0 µs: +12.1-12.9 µs; with an authenticator reading request.headers: +15.9 µs
the same, server and client in two processes (CPU 6 and 8; build) 36.4-37.0 / 41.7-42.4 µs: +5 µs
100 MiB file: pipeline of a read stream / fileResponse build (64 KiB reads): 602-618 / 487-565 MiB/s; fix round 1 (256 KiB, pulled): 477-751 / 560-611 MiB/s
a worker's cold start with one shipped script: fork to started / to ready (median of 7) 531-532 / 538-539 ms
the server's heap per idle worker (20 workers, one reference each) 49.5 KiB

A request's overhead was 16 µs at first: host.root cost about 7 µs of it per request (a linked abort signal, a Promise.race and an awaitWithContext per scope). Root scopes now take the caller's signal with one listener and settle one promise (root.ts; behaviour and tests unchanged): 4.2-4.9 µs, about the bar of 10% of a round trip. The rest is the request's scope (its abort controller, CallContext), admission and the response's header merge. The cold start is mostly Node's startup stripping the types of the source tree it imports; the start timer (60 s) is far from it.

Fix round 1 (the fetch core, the security baseline, Chord's listener as a native object). A route's overhead in the standard run grew from +4.5 µs to +20-21 µs (+41 µs before the Node fast path: the listener reads Node's parsed headers and makes a Web Request only when something reads request.headers or the body or a relay verifies; whole bodies are written as strings, in one write with the head). Warmed, +12-13 µs; a CPU profile of the warmed route path (per request, test/bench/http.ts's route): the listener's request function 13.6 µs inclusive against the plain handler's 8.9 µs, of which writing the answer 8.3 µs (the plain handler's writeHead and end: 9.1 µs); serve 4.2 µs (the root scope 2.1, the URL parse 0.9, default headers 0.6, admission and routing 1.1, the answer 0.7); the root scope's seal in a later microtask 0.9 µs. The baseline's extra headers cost 0.4 µs (a plain handler sending them). The rest, about 5 µs, sits in no function of the component: Node's tick and microtask machinery (33 µs inclusive against 18 µs for the plain handler). Opus's two trims are in (lookup decodes only segments with %; one abort controller per request: the Node side's own is gone). Kernel rows, HEAD bc9a20f84 → fix round 1, CPU 6, alternating: binary 1 MiB call median 0.182-0.220 → 0.196 ms; reconnect of 1 / 100 references 1.02-1.10 / 79.1-80.3 → 1.04-1.05 / 81.3-81.5 ms; watched writable.set 0.836-0.868 → 0.861-0.863 µs; watched derive commit 10.42-10.58 → 10.36-10.60 µs; activation churn 58.9-60.5 → 59.1-60.8 µs.

Kernel hot paths (routes without a timer, RouteHandle, Path events, root scopes), HEAD bc9a20f84 → build at paths of equal length, CPU 6, alternating runs. calls.ts: local call 2.70-2.83 → 2.70-2.83 µs (6,660 B both), watched derive commit 10.36-10.60 → 10.34-10.79 µs, activation churn 59.43-61.30 → 59.48-60.72 µs. links.ts: commit to frame and encode/decode alike; reconnect of 1 / 100 references 0.94-1.15 / 80.8-84.0 → 0.94-1.15 / 80.6-86.5 ms; 1,000 parked calls 24.4-25.2 → 24.3-24.6 ms. reload.ts: swap of 50 / 1,000 objects 1.44-1.55 / 30.78-31.42 → 1.32-1.56 / 31.20-31.83 ms, restart 0.96-0.98 / 15.75-16.34 → 0.95-1.10 / 15.91-15.94 ms until the reload resolves. registries.ts alike (one of 100 contributions set 1.47-2.45 → 1.48-3.41 µs, run to run).

Slice 12: the plugin model (2026-11-01)

The build's checks (test/bench/plugins.ts, the 7c set: 20 plugins, one worker facet each, a copied contract by relative path), Node 24.4.1, CPU 8, medians of seven runs, three alternating runs. HEAD ebce9debd (its bench importing the contract by plugin name, which HEAD resolves) → this build: cold build of the 20 facets 257.6-257.8 → 257.2-258.8 ms, rebuild of one facet 13.2-13.5 → 13.3-13.9 ms. With the app's API set and the kinds named (every import checked by version and kind, every resolved file by its kind segment; the plugins importing @bench/app/v1): 257.4-258.8 ms cold, 12.9-14.3 ms rebuild, against 258.2-259.7 ms without. The checks cost nothing measurable; no hot path changed (the kernel gains two accessors read only by testPlugin).

Fix round 1: the bench itself now builds with the app's API and the kinds (its plugins import @bench/app/v1; Opus L5), and every package file's real path is checked against the plugin's node_modules. CPU 8, three runs: cold build 261.1-270.2 ms, rebuild 13.6-14.4 ms (the first two runs as before, the third with a slower cold build on a busy machine).