perf(acpmux): index background process identities for 3,000-entry replay (SW-11)
SW-11: index the background process registry by stable ID and tool-call ID
Tracks the SW-11 follow-up on the daemon's process-history cost and its HQ result.
Root cause
ProcessTable keeps every running/completed process in an append-order Vec<Process>. On every newly observed background task, fold searches that entire vector for its toolCallId; unused_id scans it again to avoid a reused task name. During daemon adoption read_processes replays every background task and upsert linearly searches again for each persisted record. An independent 3,000-process history therefore creates millions of comparisons. Stop/tail and late output-file updates perform additional linear scans.
Change
- Add per-session, append-slot indices:
by_id: HashMap<String,usize>andby_tool: HashMap<String,usize>. Original vector remains the authoritative source for row ordering and process state; indices point into it only. - Route all appends (including process replay) through
ProcessTable::insert; use first-match tool lookup infoldand recorded id lookup inupsert,tail_process,kill_processand the late-output-file path. - When a logged
process_updatecorrects the tool-call ID of an existing process, repair the earliest-key mapping. That exceptional correction may scan; ordinary insert, lookup, replacement, and ID-collision detection now use average O(1) hash lookups. - Rebuild both indices as part of the existing fresh
ProcessTableon session log read/adoption, so no additional persistent state, disk contract, or migration. Preserve oldid-2,id-3collision naming; the suffix loop is still proportional to the number of collisions for the same base ID.
Regression
New processes_index_tests.rs covers:
- Two records sharing a tool call: lookups continue to point to the first matching row;
- A persisted correction that moves that first record to another tool-call ID, with mapping repaired to the next eligible row;
- Reused process IDs and suffix allocation;
- 3,000 replayed process records, correct slot counts and unchanged stable IDs after a later repeated update.
Existingtests/hub_integration/processes.rsandcodex_processes.rsmust cover stop, output-tail and re-adoption behavior end-to-end.
Performance
Same harness at base and head: an ignored #[test] (source below) that replays N background-process records through ProcessTable::upsert, replays each again (a second logged process_update), then allocates an id per row with unused_id, as fold does. Best of 5, cargo test --release -p acpmux --lib processes_replay_bench -- --ignored --nocapture through cmux-ci test (32 vCPU Linux). Base is 350dd620b91f (this PR's merge base) with only the bench module added to processes.rs.
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| replay 3,000 processes (ms, best of 5) | 18.789 | 1.628 | -91% (11.5x) | base run / head run |
| replay 10,000 processes (ms, best of 5) | 320.506 | 5.887 | -98% (54x) | same runs |
Memory: two HashMap<String, usize> per session, one entry each per process (a cloned id and tool-call id string plus a slot), so roughly 100-150 bytes per process; about 0.4 MB for 3,000 processes. Not measured separately.
Bench source (not committed)
//! Same-harness timing of a long background-process replay: `cargo test
//! --release -p acpmux --lib processes_replay_bench -- --ignored --nocapture`.
//! Uses only `upsert` and `unused_id`, so it runs unchanged on the pre-index base.
use super::*;
fn record(i: usize) -> Process {
serde_json::from_value(json!({
"id": format!("session/task-{i}"), "sessionId": "session", "toolCallId": format!("tool-{i}"),
"taskId": "task", "harness": "claude", "command": "true", "cwd": "/tmp",
"startedAt": 1, "state": "running", "origin": "background"
}))
.unwrap()
}
#[test]
#[ignore = "timing harness; run with --ignored --nocapture"]
fn processes_replay_bench() {
for n in [3_000usize, 10_000] {
let records: Vec<Process> = (0..n).map(record).collect();
let mut best = std::time::Duration::MAX;
for _ in 0..5 {
let mut table = ProcessTable::default();
let start = std::time::Instant::now();
for p in records.iter().cloned() {
table.upsert(p);
}
// Each replayed record logged again (a second process_update), then an id
// allocation per row, as `fold` does for a newly seen task.
for p in records.iter().cloned() {
table.upsert(p);
}
for i in 0..n {
std::hint::black_box(table.unused_id(format!("session/task-{i}")));
}
best = best.min(start.elapsed());
assert_eq!(table.processes.len(), n);
}
println!("BENCH processes_replay n={n} best_of_5_ms={:.3}", best.as_secs_f64() * 1e3);
}
}
Boundaries / validation
- Branched from
feat-cmux-next@ebda72d304f33baa4e3346bba2b2b99bbb1c53b6. Two changed files (one Rust module, one new unit-test module), no API, UI, network or format changes. - Shares the
hub/processes.rsownership path with our earlier #29238 mutex release patch, but edits separate sections. Merge/rebase in order and rerun the test suite against the combined head before landing; do not independently land both without confirming that shared file. - Required checks (run in this PR's CI):
cd cmux-tui && cargo test -p acpmux --lib processes_index_tests,cargo test -p acpmux --test hub_integration -- process,cargo fmt --check, Clippy and managed NEXT gates.
Capture
No capture: daemon-only lookup implementation with unchanged process-list serialization and CLI/UI responses.
Changelog: Background-process history uses stable in-memory lookup indices so large replay histories avoid repeated full-vector searches.