acpmux: one serialization per streamed entry; agent host 27 -> 13 us/token (perf100)

Closed
LA
7d ago
+425-96

What

Per-token copies removed on the claude-stdio path (what Lawrence's chats use):

  • Agent host: each entry is encoded once, from a borrowed view, and kept as its frame bytes. The live send and a resume send the same bytes. Before, each entry was serialized once to weigh it, deep-copied to send it, and serialized again by the writer.
  • Message::from_value moves the fields out instead of copying params. New Message::into_value; the host builds records with it. The raw Claude line moves into its record. It is now translated first, and the raw record still comes first.
  • Message::to_line serializes from the borrowed fields. Before, to_value copied every param for each client line.
  • The preview grows in place instead of being copied on every chunk.

Numbers (rbx, perf100 bench: 10 sessions x 4,000 deltas, one attached client; base 26e79e1a8483; two runs each)

MetricBeforeAfterRatio
Agent host CPU per token (release)27–28 µs13–14 µs2.0x
Daemon CPU per token (release)89.5–93.5 µs75.5–85.7 µs1.1–1.2x
Agent host CPU per token (debug cargo test)33–35 µs23 µs1.5x
Agent host RSS after a 20,000-delta turn (debug)64–89 MB36–40 MB~2x
Log bytes / write syscalls per token674 B / 2.35same1x

Most of the daemon's per-token cost is still in the hub: two records per token, the log line, the event ring's record copy, and the fan-out. That is the next slice; it needs a profiler on rbx (perf is blocked for the rbx user).

Test

tests/claude_stream_budget.rs: a recorded-shape turn of 20,000 deltas from a stand-in Claude Code (tests/fake_claude_stream.py), run through the CLI. It checks that the reply arrives whole. It also checks that the agent host uses at most 70 µs and the daemon at most 330 µs of CPU per delta, about 3x the measured debug values. It is a budget guard: on base the debug numbers (host 33–35 µs, daemon 129–136 µs) also pass.

Review

A correctness review subagent checked this change:

  • The encoded bytes are identical to write_frame(&HostFrame::Entry), and the controller decodes them.
  • from_value gives the same result for every input.
  • The raw record still comes before the translated records and the stdin taps.
  • The writer still does write_all and flush per frame.

Nits that remain open:

  • An entry over the frame limit now becomes a stderr note. If it was a response with an id, that request is not answered. Before, the controller was dropped on every resume. Input lines are capped below the limit, so this is hard to reach.
  • The buffer weight now counts the frame overhead, so the stderr drop threshold triggers slightly earlier.

Changelog

none

Observed / Not run

  • Observed on rbx: the bench, the budget test on head and on base, and cargo clippy -p acpmux --all-targets -D warnings, where the only errors are the base reds cmp_owned (#22906) and unnecessary_sort_by in cmux-worktree-care. The full suite runs in the landing gate.
  • Not run: macOS. The acp-fork lane's Codex bench was not rerun; the Codex bridge shares from_value and into_value.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Removes per-token copies on the claude-stdio path. The agent host encodes each streamed entry once and keeps its frame bytes, sending the same bytes live and on resume; Message::from_value and the new Message::into_value move fields instead of copying params; Message::to_line serializes borrowed fields; and the streaming preview grows in place. Agent host CPU per token drops from 27–28 to 13–14 µs (release, perf100 bench) and RSS after a 20,000-delta turn from 64–89 to 36–40 MB; daemon CPU per token drops from ~90 to ~78 µs.

Budget test

  • tests/claude_stream_budget.rs runs a recorded-shape 20,000-delta turn through a stand-in Claude Code (tests/fake_claude_stream.py) and asserts the reply arrives whole and that host/daemon CPU per delta stay under 3x the measured debug values.
  • An entry over the frame limit now becomes a stderr note instead of dropping the controller on resume, and the stderr drop threshold triggers slightly earlier because the buffer weight counts frame overhead.