Skip to content

e11: the restore path does not serialize — 90% of a restore is a fixed 20ms poll, and the c>=16 knee is downstream #303

Closed

Description

@pdettori

Measured on srv-r16b14s16 (bare metal, SH_SUBSTRATE=metal, 2026-09-21) with an
instrumented worker, while following up issue #259's result. The premise that the restore
path serializes is false
, and the knee is somewhere else.

The reasoning being corrected

#259 and #260 both start from this inference (quoting #259's runbook):

Restores are not serialized by the pool mutex — replenishOne calls p.mu.Unlock()
before p.warm(...) — so they already can overlap. Yet 62 Exec/s against E10's 23.06 ms
restore implies only about 1.4-way effective restore concurrency. Something in the
restore path serializes and it is not pool.mu.

The arithmetic is right; the premise it rests on is not. E10's 23 ms restore is mostly a
sleep
, and a sleep consumes no resource, so there is no concurrency to account for and
nothing in the restore path to find.

Phase breakdown, 3249 restores at c=8

Per-phase timing added around firecrackerLauncher.Restore (diagnostic only):

phase mean share
prep — jail mkdir + 5 os.Link + os.Chown 220 us 0.8%
wsimg — ensureWorkspaceImage check 44 us 0.2%
jailer — exec.Command + cmd.Start() 359 us 1.3%
sockwait — waitForUnixSocket 24,956 us 90.1%
loadsnap — PUT /snapshot/load 2,125 us 7.7%
total 27,706 us

Real work is 2.7 ms. The rest is sleeping. Filed separately as the actionable fix: #304.

sockwait is perfectly quantized, which is what identifies the cause beyond doubt:

sockwait restores
~20-22 ms 2,535 (78%)
~40-42 ms 714 (22%)
< 20 ms 0

Restore is FLAT in concurrency — so the knee is downstream

Same instrument at c=16:

phase (mean us) c=8 c=16 change
sockwait 24,956 25,984 +4.1%
loadsnap 2,125 2,128 +0.1%
jailer 359 406 +13% (of 0.4ms)
prep 220 221 —
total restore 27,706 28,787 +3.9%

Meanwhile per-Exec-per-pool latency roughly doubles over the same range
(c/throughput: 8/67.76 = 118 ms -> 16/62.99 = 254 ms). With restore flat at ~28 ms, that
puts ~90 ms -> ~226 ms of growth in resume + run + destroy, not in restore.

So the c>=16 knee (issue #291) and whatever limits aggregate per-process throughput are
downstream of Restore. Instrumenting Resume / the guest Run / Destroy the same way is
the obvious next step.

What was ruled out, with evidence

Recorded so nobody re-runs these:

candidate verdict evidence
Go syscall.ForkLock serializing jailer spawns no Linux builds forkpipe2.go, which refcounts concurrent forks rather than serializing them
fork page-table copy inflated by the 256 MiB mlocked memfile no microbenchmark: 10,988 spawns/s pinned vs 10,364 unpinned
jail-directory inode (i_rwsem) contention no 13,961 ops/s for the full 12-op jail lifecycle in one parent dir; 20,770 sharded. ~250x faster than needed
single-threaded Node relay no 0.04-0.09 cores mid-rung (delta-based /proc/<pid>/stat)
one gRPC/HTTP2 connection worker<->relay no the container arm reaches 75-256 Exec/s over the same path
cgroup_mutex, /dev/kvm, snapshot page cache, /srv device no all shared between the two workers in #259's treatment, which still got 2x
a worker-side Go mutex no under-load SIGQUIT dump (15 live VMMs): no goroutine in warm/Restore/acquire, no semacquire anywhere. Block profile top sites are selectgo, http.roundTrip, sync.Pool.pin

Method notes

Instrumentation lived on a local diag/259-serializer-probe branch (never pushed): per-phase
timing in Restore, plus net/http/pprof with SetBlockProfileRate/SetMutexProfileFraction
behind SH_DIAG_PPROF. Overhead was negligible — 61.00 Exec/s instrumented vs 58.26
uninstrumented at the same coldAcquireRate ~0.998.

microvm-worker has no pprof endpoint. Adding one behind an env var would have saved
most of a day here; worth considering on its own merits.

Refs #259, #260, #291

Activity

  1. pdettori commented on Sep 21, 2026

    @pdettori
    MemberAuthor

    Correction to this issue's own conclusion. The measurements above stand; the inference about
    where the knee lives was wrong, and #305 has the answer.

    What stays correct and is worth keeping:

    • The restore path does not serialize. Confirmed further: with the worker wired to
      ExecPhased, Acquire is 158 us at c=16 and >99% of acquires are warm, so the restore
      is not on the Exec hot path at all. The "at c>=16 every Exec pays a restore" reading was a
      coldAcquireRate artifact (#306).
    • 90.1% of a restore is waitForUnixSocket's fixed 20 ms sleep, perfectly quantized (vmpool: waitForUnixSocket's fixed 20ms retry is 90% of every restore (TDD) #304).
    • Restore is flat in concurrency: 27,706 us at c=8 vs 28,787 us at c=16.
    • The ruled-out table (ForkLock, mlock/fork cost, jail-directory inode contention, the Node relay,
      the single gRPC connection, every host-shared resource, worker-side mutexes).

    What was wrong: this issue concluded that because per-Exec-per-pool latency doubled from c=8
    to c=16 while restore stayed flat, "~90 ms -> ~226 ms of growth" must be in resume + run + destroy. It is not. Measuring those phases directly shows them identical at c=8 and c=16:

    phase c=8 c=16
    Acquire 961 us 158 us
    Resume 23,472 us 23,496 us
    Run 4,012 us 4,027 us
    Destroy 33,752 us 33,694 us
    total 62,197 us 61,375 us

    Nothing in vmpool degrades with concurrency. The apparent latency growth was queueing outside
    the pool
    : session.DefaultConcurrency = 4 caps a worker at 4 concurrent Execs, so at c=16 the
    other 12 wait. Raising WORKER_MAX_CONCURRENT to 16 took one worker from 62.99 to 172.88
    Exec/s
    — see #305.

    So the reasoning error here was mine and of the same kind this issue was written to correct:
    deriving a location from a latency subtraction instead of measuring the terms. The subtraction
    was sound, but it assumed the missing time was inside the pool, and it was in a queue in front of
    it.

    Destroy at 55% of an Exec is tracked on its own merits in #307 — as a flat cost, not as the
    knee.

    Refs #305, rossoctl/moca-experiments#8, #307

  2. pdettori commented on Sep 22, 2026

    @pdettori
    MemberAuthor

    Ready to close once #327 merges — with one correction to this issue's own conclusion

    Both actionable children now exist, which was the condition for closing this:

    The correction: this issue's title says the c >= 16 knee is "downstream" of restore. It is not
    downstream of anything — it is session.DefaultConcurrency = 4, a fixed pool of 4 goroutines
    draining the Exec queue in each worker, which e11-density.sh never overrode. With slots raised to
    match c, the same host reaches 577.55 Exec/s at 64 slots against ~62 here. The two headline
    findings — the restore path does not serialize and 90% of a restore is a fixed 20 ms poll — both
    hold exactly as written.

    The seven ruled-out candidates are preserved in docs/notes/e11-restore-path-dead-ends.md
    (PR #327), with their evidence, so they are not re-derived.

    One thing that note records deliberately, because it is instructive rather than embarrassing: the
    under-load SIGQUIT dump used here to rule out a worker-side mutex also showed exactly 4 goroutines
    in session.(*Session).Serve.func5
    . That was the answer to the whole investigation, present in
    the evidence, and it was dismissed as a sampling artifact. The dump ruled out a mutex correctly —
    what it was also saying is that nothing was waiting because only four things were ever running.

    Leaving this open until #327 merges, so the table has a home before the thread closes.

  3. added 2 commits that reference this issue on Sep 22, 2026
  4. pdettori commented on Sep 22, 2026

    @pdettori
    MemberAuthor

    Follow-up filed as #328: with fine-grained polling in place (#315), sockwait still measures 38.07 ms at 64 slots and 869.43 ms at 256 — so this issue's "90% of a restore is a fixed 20 ms poll" described the instrument, and the true wait is larger than the quantum it was measured in, not smaller.

    Both findings here survive: the restore path does not serialize, and the poll was misreporting. What changes is that the residual after fixing the poll is real work rather than sleep, and it is now the largest term in a restore.

  5. pdettori commented on Sep 22, 2026

    @pdettori
    MemberAuthor

    Closing — the ruled-out table is now durable on main in docs/notes/e11-restore-path-dead-ends.md (PR #327, merged), which was the condition for closing this.

    Both of this issue's headline findings stand as written: the restore path does not serialize, and the poll was misreporting. What changed is everything downstream of that:

    So this issue's instinct that the time was not real work was half right: the 20 ms quantum was an instrument artifact, but what it was hiding is our own per-VM setup cost rather than sleep.

    The seven ruled-out candidates are preserved with their evidence, including the SIGQUIT dump that correctly ruled out a worker-side mutex and, in the same output, showed exactly 4 goroutines draining the Exec queue — the answer to the whole investigation, present in the evidence and dismissed as a sampling artifact.

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