Repository navigation
perf: remaining performance ledger after the 2026-09 SOCKS5 batch #172
Description
Successor to #30, which closes with this issue open. Every #30 item was re-verified against main on 2026-09-14 (ledger in the closing comment there): the 2026-07 hotspot work, the refill admission re-eval, the digest and generation-store rework, the XFS/direct-IO storm, the staging fsync recipe, the VM-name suffix widening and the SOCKS5 opt-in are done or superseded; wire v2 and the bulk-frame buffer live in #34. What remains is below, each with the trigger that reopens it. Numbers of record live in docs/benchmarks.md and docs/performance.md; the C1M campaign measurements stay in #30's thread.
Open — unowned lever
- Cocoon clone-serving / CLI-per-op. Every engine operation is one fork+exec of the cocoon binary; measured 155–180 clones/s through the CLI against 257–274/s direct. The cocoon supervisor issue closed without a daemon or library mode. Largest remaining fill-rate lever; needs a cocoon-side decision.
Deferred — reopen triggers
| item | where | trigger |
|---|---|---|
| claimStore whole-map marshal + rename per commit | sandboxd/pool/claims.go |
>120 live claims per node |
| idleOnce/archiveOnce O(n) scans under the manager mutex | sandboxd/pool/hibernate.go, archive.go |
same |
| checkpoint TTL sweep re-lists the full store per tick | sandboxd/pool/checkpoint.go |
checkpoint count grows 10x |
| resyncEgress serial lock pass at restart | sandboxd/pool/reconcile.go |
many egress claims + a restart-time SLA |
| probeReady engine-list fallback poll | sandboxd/pool/refill.go |
the fallback branch shows up in practice |
| recLock global serialization on template records | sandboxd/pool/template.go |
recLock reaching the claim path |
| ownership probing per distinct cold id (5 s positive cache + singleflight) | sandboxd/store/peer/probe.go |
sustained distinct-id branch rate |
Micro items — unchanged since 2026-07-26, all below the noise floor today
silkd: 64 KB inbound vsock buffer (folds into #34), tagged double-pass decode in proto.rs, regex recompiled per call in find.rs, 256 KB ring / 300 s detached linger with no aggregate cap in proc.rs/exec.rs. sandboxd: two random draws per claim id, eager key hash in server.go, quota precheck and warm-pop as separate lock takes, refill catch-up rescan under the mutex, 1 s gossip push/pull, intercept leaf-cache full clear. SDKs: fresh connection per call and in-memory archives (Python), client rebuilt per op (openai adapter), double marshal (mcp), unbounded append in sdk/go/files.go.
Rejected with data — do not re-litigate without new evidence
Pre-dialed spare connection; fs_push chunk 256K→1M; nft netlink connection merge; silkd conn scanner-buffer pooling; encodeTagged rework; silkd cold-start slimming; twice-registered cancel hook.
Activity
CMGS commentedon Sep 14, 2026
Two rows from the 2026-09-14 Rust ownership audit (all of silkd and boot/init read in full), kept out of the review PR #173 as their own change:
silkd/src/proc.rsTable::registercallsbroadcast::channel(OUTPUT_FANOUT)(256) for every exec and every pty open, and tokio allocates the whole slot buffer at construction: measuredChunk32 B,Slot48 B,Mutex<Slot>56 B on Linux → about 14 KB of heap per registered process, for a live fan-out most execs never attach to. Present since silkd's first commit (ddeddc7, 2026-07-05), where the ring backedattach/logson detached processes and the concern was reaping and pid reuse, not per-exec memory. CPU is under one percent of a fork+exec; the real cost is retention: a detached exec keeps its entry forREAP_DELAY(300 s), so a thousand detached execs pin about 14 MB of empty rings on top of the log ring. Fix shape: create the sender lazily on the firstattach_stream(OnceLock<Sender>;emitalready checksreceiver_count() > 0), or cut the capacity. Trigger: detached-exec-heavy workloads, or any memory budget per guest.silkd/src/git.rsbuilds theformat!("git {verb}")label beforesubprocess_resultdecides whether it needs it (only on failure), and every session command allocates two strings for its sentinel (session.rs/sysutil::rand_token). Noise next to a fork and a shell round trip; recorded so nobody re-finds them.pool: bind a warm clone's egress doors at refill, not at claim
pool: bind a warm clone's egress doors at refill, not at claim (#177)
CMGS commentedon Sep 14, 2026
Two rows closed on 2026-09-14:
Table::registerno longer allocates the 256-slot channel;attach_streambuilds it on the first attacher under the ring lock. cocoon-test1, same sandboxd, guests differing only in silkd, 500 timed execs per run, A B B A × 3: exec p50 726 → 699 µs, p90 780 → 754 (n=3000 each, every round lower); silkd VmRSS after 1000 detached spawns 5040 → 20768 kB before, 4920 → 5636 kB after. The 256 KB log ring and the 300 s detached linger in the micro list are a different item and stay.Also landed from the same batch, not perf rows: #176 (idle sweep vs live connections, #146) and #174 (spawn error kinds, #145).
CMGS commentedon Sep 14, 2026
Rows from the whole-repo review round (PR #179), measured or estimated by the readers, none changed in that PR:
fastBulkdecodes each data frame into a fresh buffer and copies it once more into the caller's slice; append-decode into the caller's buffer would drop one copy per 256 KiB chunk on every file read.json.Marshalplus an unbufferedwrite(2)per event, shared by the claim path (recordUsageinfinalizeBatch) and every guarded-egress decision; ~2–4 µs marshal plus 1–3 µs syscall per claim, and at 10k egress requests/s the claim path queues behind the data plane on one mutex. A size- and time-bounded buffer or a single writer goroutine.m.muacross three full scans ofm.claimed(reapOnce, idleOnce, archiveOnce) and hashes a PoolKey per claim in two of them; ≈185 µs of global-lock hold per 5 s at 1000 live claims. One scan collecting all three victim sets.maps.Clone+ marshal + whole-file write underwriteMu); ≈1.7 ms at 1000 live claims. Per-claim records or an append-only op log.reached()probe takessyscall.ForkLockon every guarded-egress claim; a claim that overlaps a refill'scocoonexec waits for that fork window.Accept4(SOCK_CLOEXEC)(Linux-only file) drops the lock; needs its own A/B.CMGS commentedon Sep 14, 2026
One more row from the same round (PR #179), unchanged there:
armEgressrunsnetfilter.Lock(tap)on the claim path: two netlink connections and two flush round trips per guarded-egress claim (the ~36 µs measured in review: batch-end round on the SOCKS5 opt-in, ports and lane-file state #173). The tap is already known at refill (startVMrecords it), so the lock could move next to the door pre-bind of pool: bind a warm clone's egress doors at refill, not at claim #177 and leave the claim path with the map write; a trimmed warm VM would unlock indestroy. Constrained by the hibernate rule (cocoon resumes before the lock can be re-applied), so it needs its own A/B and a look at what a warm VM may reach before its claim.CMGS commentedon Sep 17, 2026
Ledger addition: #213 — a redirected claim counts as an arrival on the entry node as well as on the node that serves it, so a burst against one node raises the warm target across the mesh. It rode on the back of #181: until the shrink lands, every inflated pool keeps its high-water mark, so the cluster strands N times the single-node residue. #211 bounds that in time; what gets counted is still open, and the reopen trigger is a mesh whose total warm VMs exceed its peak concurrent claims.
CMGS commentedon Sep 22, 2026
Re-verified every row of this ledger against
mainat24d9993(2026-09-22). All twelve are still open — nothing here has been fixed since the ledger was written, and no named file or symbol has moved. The only two structural changes touching these rows,netfilter.LockedTaps()prefetch inresyncEgressand theseq/writtencoalescing inclaimStore, both landed ind19b677on 2026-09-03, before this ledger, so neither counts against it.Two corrections to the wording, so nobody optimizes against a shape the code does not have:
The reap tick does not hold
m.muacross the three scans.reapOnce,idleOnceandarchiveOnceare three separate calls in the tick (sandboxd/pool/pool.go:497-503), and each takes and releasesm.muon its own (sandboxd/pool/claim.go:400-428,sandboxd/pool/hibernate.go:181-192,sandboxd/pool/archive.go:46-61). The cost stands — three O(n) passes overm.claimedper tick, two of them hashing a PoolKey per claim — but there is no single critical section spanning all three.fastBulkis not where the extra copy is. It allocates one buffer per data frame and returnsout[:n](protocol/wire/frame.go:738-744); there is no second copy inside it. The second copy is at the consumers —sandboxd/engine/silkd.go:109(data = append(data, resp.Data...)) andsdk/go/utils.go:172-175. The row's cost claim holds; the fix belongs on the consumer side, or in an append-decode API that lets the caller supply the buffer.Current file:line for each row, for whoever picks one up:
sandboxd/pool/claims.go:130-142sandboxd/pool/hibernate.go:181-192,sandboxd/pool/archive.go:46-61sandboxd/pool/checkpoint.go:339→checkpoint.go:91sandboxd/pool/reconcile.go:170-199sandboxd/pool/refill.go:429-439,:461sandboxd/pool/reclock.go:7-15,sandboxd/pool/template.go:256-269sandboxd/store/peer/probe.go:62-73sandboxd/pool/journal.go:49-64sandboxd/pool/claim.go:352→sandboxd/pool/egress.go:139→sandboxd/netfilter/nft_linux.go:19-53,77-85sandboxd/engine/engine.go:342-353#213 was re-checked too and is unchanged:
p.noteArrival(start)still runs before the warm check atsandboxd/pool/claim.go:46.CMGS commentedon Oct 17, 2026
Closed since 09-22:
claims.jsoncommit encodes only changed claims (pool: a claims.json commit encodes only the changed claims #305);sandboxd/engine/silkd.go:118still appends;Still open: one cocoon CLI process per engine op; the archive scan; the checkpoint TTL full list; the serial
resyncEgresspass at restart; theprobeReadyfallback poll;recLockserialization on template records; the peer ownership probe; the journal mutex with an unbuffered write; egress usage written per request; theForkLockinoutbound/doors.go:119; the nft lock taken on the claim path; #213.CMGS commentedon Oct 26, 2026
Addition, off the claim path: the archive checkpoint pins.
DeleteCheckpoint, once per delete marker in the retry sweep,dropStaleRow) is now a lookup. It went from 162 µs, 218 KB and 18 allocs to 25 ns and 0 allocs. It no longer copies the whole set under the manager mutex.Measured on a mac M2 Max with
-cpu 1, both arms interleaved in one run. The cost on the claim path is one extra map lookup per journal set/del, about 25 ns, with no allocation.