Skip to content

perf: remaining performance ledger after the 2026-09 SOCKS5 batch #172

Open

Description

@CMGS

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

  1. CMGS commented on Sep 14, 2026

    @CMGS
    ContributorAuthor

    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:

    • Eager broadcast ring per process — silkd/src/proc.rs Table::register calls broadcast::channel(OUTPUT_FANOUT) (256) for every exec and every pty open, and tokio allocates the whole slot buffer at construction: measured Chunk 32 B, Slot 48 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 backed attach/logs on 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 for REAP_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 first attach_stream (OnceLock<Sender>; emit already checks receiver_count() > 0), or cut the capacity. Trigger: detached-exec-heavy workloads, or any memory budget per guest.
    • Per-git-verb strings — silkd/src/git.rs builds the format!("git {verb}") label before subprocess_result decides 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.
  2. CMGS commented on Sep 14, 2026

    @CMGS
    ContributorAuthor

    Two rows closed on 2026-09-14:

    • Eager broadcast ring per process (the Rust ownership-audit row above) — silkd: build a process's fan-out ring on its first attacher #175. Table::register no longer allocates the 256-slot channel; attach_stream builds 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.
    • SOCKS5 second listener, +36 µs per warm claim — pool: bind a warm clone's egress doors at refill, not at claim #177 (row removed from the table). Refill binds a warm clone's doors and the claim serves the pre-bound pair after a one-accept probe. cocoon-test2 warm claims, A B B A × 3, n=288 per arm: no policy 243 vs 237 µs p50 (equal), HTTP door 307 → 263, both doors 351 → 274. The remaining 26–37 µs over the no-policy floor is the proxy build and the two serve goroutines.

    Also landed from the same batch, not perf rows: #176 (idle sweep vs live connections, #146) and #174 (spawn error kinds, #145).

  3. CMGS commented on Sep 14, 2026

    @CMGS
    ContributorAuthor

    Rows from the whole-repo review round (PR #179), measured or estimated by the readers, none changed in that PR:

    • wire fastBulk decodes 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.
    • usage and audit journals append under one mutex with json.Marshal plus an unbuffered write(2) per event, shared by the claim path (recordUsage in finalizeBatch) 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.
    • reap tick holds m.mu across three full scans of m.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.
    • claims.json commit is O(live claims) per claim/release/hibernate/wake (maps.Clone + marshal + whole-file write under writeMu); ≈1.7 ms at 1000 live claims. Per-claim records or an append-only op log.
    • egress usage writes one billing line per proxied request; a guest at 1000 req/s rotates the 64 MiB journal in ~9 minutes and evicts other sandboxes' records. Aggregate per sandbox, emit at release.
    • reached() probe takes syscall.ForkLock on every guarded-egress claim; a claim that overlaps a refill's cocoon exec waits for that fork window. Accept4(SOCK_CLOEXEC) (Linux-only file) drops the lock; needs its own A/B.
  4. CMGS commented on Sep 14, 2026

    @CMGS
    ContributorAuthor

    One more row from the same round (PR #179), unchanged there:

  5. CMGS commented on Sep 17, 2026

    @CMGS
    ContributorAuthor

    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.

  6. CMGS commented on Sep 22, 2026

    @CMGS
    ContributorAuthor

    Re-verified every row of this ledger against main at 24d9993 (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 in resyncEgress and the seq/written coalescing in claimStore, both landed in d19b677 on 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.mu across the three scans. reapOnce, idleOnce and archiveOnce are three separate calls in the tick (sandboxd/pool/pool.go:497-503), and each takes and releases m.mu on 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 over m.claimed per tick, two of them hashing a PoolKey per claim — but there is no single critical section spanning all three.

    fastBulk is not where the extra copy is. It allocates one buffer per data frame and returns out[: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...)) and sdk/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:

    row site
    claimStore whole-map marshal + rename sandboxd/pool/claims.go:130-142
    idleOnce / archiveOnce scans sandboxd/pool/hibernate.go:181-192, sandboxd/pool/archive.go:46-61
    checkpoint TTL sweep full list sandboxd/pool/checkpoint.go:339 → checkpoint.go:91
    resyncEgress serial pass sandboxd/pool/reconcile.go:170-199
    probeReady fallback poll sandboxd/pool/refill.go:429-439, :461
    recLock global serialization sandboxd/pool/reclock.go:7-15, sandboxd/pool/template.go:256-269
    peer ownership probe sandboxd/store/peer/probe.go:62-73
    journal mutex + unbuffered write sandboxd/pool/journal.go:49-64
    armEgress nft lock on the claim path sandboxd/pool/claim.go:352 → sandboxd/pool/egress.go:139 → sandboxd/netfilter/nft_linux.go:19-53,77-85
    cocoon CLI per op sandboxd/engine/engine.go:342-353

    #213 was re-checked too and is unchanged: p.noteArrival(start) still runs before the warm check at sandboxd/pool/claim.go:46.

  7. CMGS commented on Oct 17, 2026

    @CMGS
    ContributorAuthor

    Closed since 09-22:

    Still open: one cocoon CLI process per engine op; the archive scan; the checkpoint TTL full list; the serial resyncEgress pass at restart; the probeReady fallback poll; recLock serialization on template records; the peer ownership probe; the journal mutex with an unbuffered write; egress usage written per request; the ForkLock in outbound/doors.go:119; the nft lock taken on the claim path; #213.

  8. CMGS commented on Oct 26, 2026

    @CMGS
    ContributorAuthor

    Addition, off the claim path: the archive checkpoint pins.

    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.

  9. CMGS commented on Nov 2, 2026

    @CMGS
    ContributorAuthor

    Backlog from the 2026-11-02 hardware round: VM density limit in kvm_irqfd, plus one row to downgrade.

    Density limit. On a 384-core bare-metal node, kernel 6.12.100, none lane, 64 concurrent clones up to 1000 VMs: every span is flat except CH vm.restore, which climbs with the VM count, and the fill rate falls with it.

    cumulative VMs rate per s vm.restore p50 ms
    270 135 51
    401 66 842
    501 50 1113
    730 34 1706
    905 28 2336

    Kernel stacks of the CH threads during the storm: 625 of 648 D-state samples are in irq_bypass_register_consumer <- kvm_irqfd. In v6.12 that function takes a global mutex and walks every consumer on the host (virt/lib/irqbypass.c), so each irqfd costs O(irqfds on the host). v6.15 and v6.16 still use the lists, v6.18 uses an xarray, v6.17 not checked.

    Measured per VM with kprobes, 1 vCPU, no NIC, 21 kvm_irqfd calls per VM (kvm_irqfd sum of vm.restore): 1 clone 5.5 of 13.1 ms, 16 clones 125.6 of 146 ms, 64 clones 921.9 of 1035 ms. Upstream 8394b32faecd makes the lookup O(1); the global mutex stays, and no kernel with it has been run here.

    Options, none tried:

    • The registration only runs when kvm_arch_has_irq_bypass() is true, which is enable_apicv && irq_remapping_cap(IRQ_POSTING_CAP). Turning off posted interrupts in the IOMMU at boot (amd_iommu_intr=legacy, intremap=nopost on Intel; check the kernel docs, these names are from memory) should skip it and only affects devices assigned through VFIO. Needs a reboot.
    • kvm_amd.avic=0 also skips it but gives up AVIC. Needs a module reload with no VMs running.
    • A host kernel >= 6.18.

    Blocked on a host that can be rebooted. Whether this is the only limit is unknown.

    Open question. A 2026-07-29 run reached 1000 warm at 201/s on a 384-core node with restore_mode: mmap, no_direct_io, a tmpfs run dir, cocoon v0.5.8 and kernel 6.12.90. This round reached 50/s at n=1000. no_direct_io alone did not change it (48/s). The rest of that recipe was not tried. This round also ran a CH build from 2026-08-12, older than the fork's dev and without 0295e241e, while the July run used the fork's dev of that day. So this may be the recipe, the CH build or a regression, and the table above should be re-measured on current dev first.

    Downgrade: cocoon clone-serving / CLI-per-op. With GOMAXPROCS capped (#413), an in-process clone on cocoon v0.7.1 buys less than this ledger assumed. One clone: 32 ms through the CLI vs 17 ms in-process (51 ms before #413). Ready rate at 8 workers: 166-173/s vs 180-182/s (121-123/s before #413). At 64 workers all arms tie at about 200/s, limited by the row above. It is no longer the largest fill-rate lever.

  10. CMGS commented on Nov 3, 2026

    @CMGS
    ContributorAuthor

    Update from the audit behind #420-#432. Numbers are from a 384-core bare-metal node, cocoon v0.7.5, Cloud Hypervisor v54, restore_mode mmap, with both arms interleaved (the audited tree against main before it).

    Density, the next limits.

    • Claims hold up: warm claim p50 is flat between the arms at every count, and 1.08-1.14 ms against 1.11-1.13 ms with 3000 held. /v1/info stays at 0.25-0.30 ms.
    • kernel.pty.max: each VM holds a console pty, and the default of 4096 is host-wide. A node hit it at 4057 VMs: vm.restore answers 500 with No space left on device. docs/deploy.md states this (review: the audit's final tidy: layout, docs, shared test helpers #432). It is a sysctl, not yet tested above 4096.
    • Clone tier at density: claims that miss warm slow down with the count on both arms. Ramp p90 is 1.0-2.8 s up to 1000 VMs and 5.3-6.1 s up to 3000. vm clone averaged 4.29 s at the top, with the last 50 at 7.33 s, while vm inspect stayed at 18 ms. This is probably the kvm_irqfd row above, but no kernel stacks were taken in this run, so that is not confirmed.

    Closed since the 2026-10-17 list.

    Not rechecked in this round: the archive scan, the checkpoint TTL full list, resyncEgress, the probeReady fallback poll, the peer ownership probe, the journal mutex and egress usage per request.

    Decided, not planned: a delta log for claims.json. A commit took 0.45-0.57 ms at 3000 claims in this run, on a store that encodes only changed claims since #305. That is fast enough.

    Deferred, with triggers.

    item trigger
    Parallel ScanFile hashing hashing is what bounds a publish
    lseek skip in the hole scan disks fragmented enough to show in a scan
    silkd frame scanner rescans vsock measured; the loopback result is noise

    Rejected with data.

    • Pooling the preview relay's readers: the transport closes after Content-Length with no further Read, so a reader returned from Read is never reused (12000 of 12000 requests allocated a new one). Returning it on Close races an in-flight Read and needs an atomic per Read on the relay path. pool, store: dead warm VMs, shared staging, cache reclaim, volume unmount budget #427 grows the reader lazily instead.
    • A checksum header on S3 uploads to GCS: GCS does not verify it, and no gain was measured.
  11. CMGS commented on Nov 4, 2026

    @CMGS
    ContributorAuthor

    Path update after #438, no row changed. probeReady is now in sandboxd/pool/provision.go, golden builds in golden.go, reapOnce in reap.go, and the record locks are the recordLocks type in reclock.go. The latest warm and clone A/B is on #419.

  12. CMGS commented on Nov 5, 2026

    @CMGS
    ContributorAuthor

    Update after #459-#462, from the eight perf leads #457 listed as unmeasured. Measured on 2026-11-05: x86 test host, 2 pinned cores, ABBA order, 4 rounds, spread under 6%, except where marked mac or modeled.

    Done:

    lead change measured
    VM-event claim lookup scanned every claim under m.mu #459, VM-name index 10 / 67 / 340 µs per event at 1k / 5k / 20k claims → ~60 ns at any size (mac, real code, before/after)
    shared s3 store probed every peer on a missing checkpoint #460, no prober on s3 N-1 HEADs per miss and a wait of up to 1 s with a peer down → none
    s3 Metas read every record's labels #461, only the template store reads them 1 LIST + 2N GETs → 1 LIST + N GETs for checkpoint listings and reconcile
    silkd compiled find/replace patterns and ran replace_all on a runtime worker #462, both moved to the blocking pool worst runtime stall under a \w{1,200} find 47-70 ms → 2.4-2.8 ms (mac)

    Open, each with its trigger: #463 (a live move hashes every chunk on both ends; decided by the boundary-scale move test) and #464 (an archived wake waits for the store delete; reopen on an archived-wake latency target).

    Still open in #458: the silkd feed_client decode buffer, the usage and audit append on every egress decision under contention, claims racing at the last free slot, and the promoted-template meta.json GET on S3.

    Benchmark sources are kept with the round's evidence, not in the repository.

  13. CMGS commented on Nov 8, 2026

    @CMGS
    ContributorAuthor

    Status against main 46fd28c (2026-11-08).

    #458 closed on 2026-11-05:

    Closed from the original table:

    Still open:

    The body's table and micro list are out of date; the list above is the current set. The micro list was not re-measured item by item.

  14. CMGS commented on Nov 10, 2026

    @CMGS
    ContributorAuthor

    One row narrowed since 11-08: #536 (v0.1.28) moved archive and replica images to ar_ ids, and Checkpoints lists only the ck_ prefix (an S3 ListObjectsV2 prefix, a name filter on dir). The checkpoint TTL sweep and GET /v1/checkpoints no longer list or read wake images. With 3000 archived claims per node on 10 nodes, that removes ~30k meta GETs per call. The sweep still reads every user checkpoint's meta per tick. Every other row of the 11-08 list is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions