Item 10b. V2 (experiments/03-corrected-harness.md) is accepted and unchanged; this
is diagnosis on top of it, not a correction. Three independent tasks, run in
the order given.
Task A's data supports bandwidth-limited, not serialization: device-busy
fraction 0.82-0.86, per-request bandwidth within 85-95% of the spike's
O_DIRECT median, inter-fetch dead time negligible (<1% of cycle time). Task
B's expectation 4 (a depth exists at r=0.75 where wall-clock approaches arm A
while read_bytes stays below it) partially held: the gap to arm A closed
by roughly half (2.75s → 2.43s at best) but did not close fully at any tested
depth, and Task A's bandwidth-limited finding explains why — with a single
fetch already near the device's real ceiling, concurrency has little idle
capacity left to fill.
Checked before Task A and after Task C. Before: uptime load average
0.06-0.08, no cn-spike/gate5r_driver/iperf3 process running (an initial
pgrep -f "match" turned out to be the check command matching its own
argument string — confirmed via pgrep -af, which showed only the check
itself; investigated rather than assumed, per instructions). After: load
average 0.21-0.46, decaying, attributable entirely to this session's own
just-finished background sweeps (ps aux showed only this session's
processes and normal system daemons). No foreign workload at any point.
Instrumented fetch_chunk() with --fetch-trace <path> (one record per
fetch: handler entry, read start/end, UFFDIO_CONTINUE end, handler exit,
bytes), preallocated in-memory, flushed once at exit (never inside the
handler). Ran arm D (layer_order, prefetch off) at all three V2 budget
ratios, n=3, at V2's exact parameters (2 GiB region, 128 MiB chunks, 5
passes). Full data: experiments/logs/task_a_log.txt.
| Ratio | Read duration (median, ideal 52.2ms) | Effective BW (median) | Device-busy fraction | Inter-fetch dead time (median) | UFFDIO_CONTINUE (median, spike expects ~2.5ms) |
Handler overhead (median) |
|---|---|---|---|---|---|---|
| 0.25 | 55.1ms (p10=41.7, p90=68.3) | 2324 MiB/s | 0.816 | 0.55ms | 2.07ms | 9.48ms |
| 0.50 | 59.6ms (p10=47.2, p90=74.8) | 2147 MiB/s | 0.844 | 0.55ms | 2.03ms | 9.25ms |
| 0.75 | 60.4ms (p10=48.3, p90=78.2) | 2119 MiB/s | 0.862 | 0.58ms | 2.03ms | 9.26ms |
Interpretation: bandwidth-limited, with a real but secondary handler-overhead contribution — not serialization.
- Ruling out serialization: its defining signature is "meaningful inter-fetch dead time." Measured dead time is 0.55-0.58ms against a ~66-70ms total per-fetch cycle (<1%). The pager is not idling between fetches waiting for the workload to consume data.
- Supporting bandwidth-limited: per-request bandwidth (2119-2325 MiB/s) sits at 85-95% of the spike's measured single-threaded O_DIRECT median (2450 MiB/s) and, critically, well below the spike's own measured maximum (3396 MiB/s) — this is a real device read, not a cache artifact. Device-busy fraction (0.82-0.86) accounts for the large majority of wall-clock time.
- The remaining ~14-17% gap in device-busy fraction is fully explained by
measured components, not unaccounted-for time: handler overhead
(9.25-9.48ms, ~14% of cycle time) +
UFFDIO_CONTINUE(~2ms, ~3%) sums to almost exactly the observed non-busy fraction.UFFDIO_CONTINUEitself matches the spike's ~2.5ms expectation closely (2.0-2.1ms measured). The 9.3ms handler overhead is real but secondary — not "overhead-dominated" by the task's own criterion (it doesn't dominate per-fetch time), but not zero either. Traced to cause: D's fetches almost always coincide with an eviction (e.g. at r=0.75, 29 of 41 fetches also triggered an eviction), and A-3'sreconcile()runs unconditionally on every eviction regardless of the periodic amortization counter — so the amortization introduced by the item 10 correction provides less benefit for D specifically than for a workload with more budget headroom (fewer evictions). - This directly explains V2's finding, correcting its stated cause. V2
attributed the 2.3x throughput gap between arm A and arm D to "per-fetch
overhead," citing spike numbers that in fact rule that out (this task's own
context section pointed this out in advance, and the data confirms it).
Arm A's V2 throughput (4785 MiB/s) exceeded the spike's own measured
maximum O_DIRECT bandwidth (3396 MiB/s) — consistent with arm A having
been served by the Windows VHDX host cache, which guest
drop_cachescannot reach, not by genuine device I/O. Arm D's per-fetch bandwidth here (2119-2325 MiB/s) sits right where genuine O_DIRECT reads on this machine should: close to the spike's median, nowhere near arm A's number. The gap is largely apples-to-oranges (real I/O vs. host-cache-served I/O), not pager inefficiency.
Added --prefetch-depth N (default 1, item 8's original inline
single-threaded path, completely unchanged) and, for N>1, a fixed pool of N
worker threads (prefetch_pool.c) — the one place multi-threading enters the
pager; the fault handler itself stays single-threaded. Swept depth ∈
{1,2,4,8} × 3 budget ratios × n=3 at V2 scale. Full data:
results/data/historical/task-b-sweep.csv, experiments/logs/task_b_log.txt.
Three real concurrency bugs found and fixed while building this, not reasoned out in advance — each reproduced as an actual hang/incorrect-abort before being fixed:
- False-positive
RECONCILE FAILEDat depth>1: a worker'spwrite()intomap_bpopulates real shmem pages while the fetch is still in flight, beforecommit_reserved()moves its bytes fromreserved_bytesintoresident_bytes. Another thread'sreconcile()landing in that window saw the kernel'smemory.stat[shmem]already partway toward an in-flight fetch thatresident_bytesalone didn't yet know about. Fixed:reconcile()now checks thatshmemfalls within[resident_bytes, resident_bytes+reserved_bytes](±one chunk slack), not a single-point comparison. At depth==1,reserved_bytesis always 0 wheneverreconcile()runs, so this reduces to exactly the original check — no behavior change there. - Deadlock (reproduced, not hypothetical) at
--prefetch-depth 8: settingstate = CHUNK_RESIDENTbeforecommit_reserved()(which needsbudget_lock) left a window where another thread could select the chunk as a victim (it now looks evictable) and block acquiring its chunk-lock, while the original thread — still holding that same chunk-lock — blocked acquiringbudget_lockto finish committing. Classic circular wait. Fixed by committing before marking RESIDENT in all three call sites (pager.c,prefetch.c,prefetch_pool.c). - Second deadlock (flaky, timing-dependent, reproduced at depths 4 and
8): the just-resident chunk that triggers a prefetch round is
itself a prime victim candidate under
layer_order(it looks "unreachable from itself" in the successor walk — the same issue item 8 found and fixed for the depth==1 case by pinning). The fix pinned it, but one step too late — afterstate=RESIDENTwas already visible, leaving a narrow window. Fixed by making the pin land atomically with the commit (commit_reserved_and_pin(), onebudget_lockcritical section) and holding it for the chunk's entire post-processing, not as two separate steps. Found via a caught live hang (/proc/PID/task/*/wchanshowed the driver thread blocked inhandle_userfaultwith the pager thread idle inpoll()— the driver's fault had been silently dropped by the existing, correct-by-design "infeasible, don't force, drop the message" path, but a REAL fault (unlike a dropped prefetch) has nothing that will ever wake it again). A fourth, related fix followed from the same finding: real fetches now get a bounded retry (up to 200×2ms) whenensure_budget()goes infeasible specifically because prefetch reservations, not real residents, are occupying the budget — reserved bytes are always transient (every prefetch commits or drops within one fetch's duration), so a short wait reliably resolves the race. This retry path is unreachable at depth==1.
All fixes verified: 40/5 stress iterations at the worst-case depth (8) with
zero hangs after the fixes (was reproducing in roughly 20-60% of runs before
each fix); full existing regression suite (items 1-9, §13 correctness
harness) re-run clean, including item 8's exact historical numbers
byte-for-byte at depth=1 (28 absent_handled, 13 prefetches — unchanged).
| Depth | Ratio | read_bytes |
Demand faults | Prefetches | infeasible |
Wall-clock | Device-busy fraction |
|---|---|---|---|---|---|---|---|
| 1 | 0.25 | 10.88 GB | 55 | 30 | 0 | 5.648s | 0.824 |
| 2 | 0.25 | 11.26 GB | 43 | 45 | 0 | 4.360s | 0.860 |
| 4 | 0.25 | 13.31 GB | 35 | 69 | 12 | 4.379s | 0.884 |
| 8 | 0.25 | 16.77 GB | 40 | 89 | 45 | 5.175s | 0.904 |
| 1 | 0.50 | 8.19 GB | 44 | 20 | 0 | 4.333s | 0.830 |
| 2 | 0.50 | 9.98 GB | 42 | 36 | 0 | 4.142s | 0.860 |
| 4 | 0.50 | 9.47 GB | 35 | 39 | 0 | 3.728s | 0.872 |
| 8 | 0.50 | 10.50 GB | 27 | 55 | 0 | 3.564s | 0.903 |
| 1 | 0.75 | 5.12 GB | 32 | 8 | 0 | 2.750s | 0.861 |
| 2 | 0.75 | 5.25 GB | 27 | 14 | 0 | 2.431s | 0.873 |
| 4 | 0.75 | 6.14 GB | 26 | 22 | 0 | 2.775s | 0.865 |
| 8 | 0.75 | 6.14 GB | 26 | 22 | 0 | 2.546s | 0.861 |
(Arm A's V2 reference point: ~10.74 GB read, ~2.14s wall-clock, at every ratio — unaffected by pager budget since it's the kernel-native baseline.)
Prefetch hit/waste (post-hoc, correlating --fetch-trace with the
ground-truth reference trace: a prefetch is a "hit" if no later fetch event
exists for that chunk before the run ends — meaning it survived, resident,
until its real next use, per Defect 1's honest observability limit; a
"waste" if a later fetch event exists — meaning it was evicted and had to be
refetched before it was ever used for free):
| Depth | Ratio | Prefetches | Hits | Wasted | Hit rate |
|---|---|---|---|---|---|
| 1 | 0.25 | 90 | 15 | 75 | 0.17 |
| 2 | 0.25 | 135 | 27 | 108 | 0.20 |
| 4 | 0.25 | 203 | 39 | 164 | 0.19 |
| 8 | 0.25 | 261 | 42 | 219 | 0.16 |
| 1 | 0.50 | 60 | 9 | 51 | 0.15 |
| 2 | 0.50 | 108 | 15 | 93 | 0.14 |
| 4 | 0.50 | 117 | 24 | 93 | 0.21 |
| 8 | 0.50 | 165 | 36 | 129 | 0.22 |
| 1 | 0.75 | 24 | 6 | 18 | 0.25 |
| 2 | 0.75 | 42 | 9 | 33 | 0.21 |
| 4 | 0.75 | 66 | 18 | 48 | 0.27 |
| 8 | 0.75 | 66 | 18 | 48 | 0.27 |
Hit rate is low throughout (14-27%) and roughly flat across depth — deeper
prefetch doesn't make individual prefetches meaningfully more or less likely
to survive to use; it just attempts more of them, so absolute wasted bytes
grow with depth. This is the direct, quantitative mechanism behind the
read_bytes growth in the table above, not a new finding but a confirmation
of what V2 already inferred qualitatively (E costing more bytes than D at
tight budgets).
- Wall-clock decreases with depth at r=0.75: DID NOT CLEANLY HOLD. 2.750s (d1) → 2.431s (d2) → 2.775s (d4) → 2.546s (d8) — non-monotonic, with depth 2 fastest and depth 4 briefly worse than depth 1. This is not noise papered over as a trend: Task A's bandwidth-limited finding directly predicts it. A single fetch already runs at 85-95% of the device's real ceiling; there is little idle device time left for concurrent fetches to fill, so added depth mostly adds thread/lock overhead without proportional throughput gain once the fault count has already dropped (32→27→26→26 across these same cells).
- At r=0.25, deeper prefetch increases
read_bytesand may increase wall-clock, expected to worsen relative to V2: HELD, clearly.read_bytesrises monotonically with depth (10.88→11.26→13.31→16.77 GB).infeasibleappears at depth 4 (12) and worsens sharply at depth 8 (45) — exactly the "worsening" the expectation predicted. Wall-clock improves from depth 1→2 but is clearly worse at depth 8 (5.175s) than at depths 2-4, consistent with "may increase," not a strict monotonic claim. - Device-busy fraction rises with depth in every cell: HELD at r=0.25 and r=0.50 (clean monotonic rise, e.g. 0.824→0.860→0.884→0.904 at r=0.25); FLAT (not decreasing) at r=0.75 (0.861→0.873→0.865→0.861). Never decreases anywhere — the implementation is not broken/fake concurrency. The flatness at r=0.75 is explained by there being less total fetch volume to overlap (26-32 faults vs. 40-55 at r=0.25) and the device already being busy 86% of the time even at depth 1, leaving less room for a trend to show above measurement noise.
- A depth at r=0.75 where wall-clock approaches arm A while
read_bytesstays below arm A's: PARTIALLY HELD. Best wall-clock (depth 2, 2.431s) closes roughly half the gap to arm A's 2.14s (was 2.750s at depth 1), whileread_bytes(5.12-6.14 GB across all depths) stays well under half of arm A's ~10.74 GB at every depth tested — the byte advantage is not just preserved, it's never remotely threatened. The gap to arm A's wall-clock does not fully close at any depth. This is not reported as the project's headline result, per the pre-registered instruction, because full closure did not occur — Task A's bandwidth-limited finding explains why: closing the remaining gap would require either a faster device ceiling or genuine parallelism across independent storage paths, neither of which more worker threads can manufacture once a single stream is already near the ceiling.
Counters already existed (stat_dedup_resident, stat_dedup_fetching,
stat_absent_handled, from item 2). Ran T-3 unchanged (8 threads, 2-chunk
budget, 60s): total_touches=115736 mismatches=0 absent_handled=22991 dedup_resident=0 dedup_fetching=0. Consistent with every prior observation
across items 2, 6, the §13 harness, and V2 — this is at least the fifth
independent 60-second run to show exactly 0/0.
dedup_fetching is provably unreachable in the current architecture, not
just empirically rare. Source inspection of pager_run()/handle_fault()
(pager.c): the handler drains uffd messages strictly one at a time —
read() one message, call handle_fault() synchronously to completion
(including the entire fetch, which blocks on real I/O), only then loop back
to read() the next message. For handle_fault() to observe a chunk in
CHUNK_FETCHING state, some other concurrent execution context would need
to be mid-handle_absent() for that same chunk when handle_fault() runs —
impossible by construction, since exactly one thread ever calls
handle_fault(), ever, and it never reenters itself. This remains true
even with Task B's worker pool: prefetch workers dispatch through a
completely separate mechanism (prefetch_pool's internal queue, driven by
do_one_prefetch()), never through handle_fault() — so deeper prefetch
depth does not change this answer at all. dedup_fetching is dead code
under any configuration where the fault handler itself is single-threaded,
which is every configuration built so far (§4 step 10 keeps it that way by
design).
dedup_resident is theoretically reachable but requires a narrow timing
window that has never been hit. It needs two real uffd fault messages for
the same chunk to both be sitting in the kernel's queue before the handler
drains either of them (i.e. both faulting threads fault while that chunk is
still CHUNK_ABSENT/CHUNK_FETCHING, before the first one's full fetch —
real I/O, tens of milliseconds at V2 scale — completes). With T-3's 8
threads picking uniformly random chunks among only 2-8 candidates at high
frequency, this is not obviously impossible, but across 115,736 touches and
five independent 60-second runs it has never fired. No architectural reason
rules it out the way it rules out dedup_fetching; it is simply rare enough
at this scale/thread-count that it hasn't been observed.
This is a finding about T-3, not about the mechanism, per the task's own
framing. T-3 does not exercise the RESIDENT/FETCHING dedup branches of
handle_fault(), and — for dedup_fetching specifically — no realistic
single-handler-thread test ever could. T-3's own pass criteria (no
mismatches, no hang, budget never exceeded) are unaffected and remain valid;
they just aren't evidence for the dedup paths specifically. T-3 was not
redesigned to force this, per instructions.
- The two Task B deadlocks and the reconcile false-positive were all timing-dependent (not 100% reproducible per run), which made them materially harder to find than a deterministic bug — documented in full above rather than summarized away.
infeasiblecounts at r=0.25/depth≥4 (12 at depth 4, 45 at depth 8) are a direct, expected consequence of the bounded-retry fix: some contention that would otherwise have deadlocked the driver now correctly resolves to a transient infeasible-then-retry-succeeds sequence, but a few prefetch attempts (not real faults — real faults always eventually retry-succeed under this fix, per the regression testing above) still land as genuineE_INFEASIBLEwhen the budget is that tight. Not a correctness problem (prefetches are designed to be droppable), just a sign that depth 8 at r=0.25 is close to genuinely oversubscribing this budget.- Task A's handler-overhead finding (9.3ms, traced to
reconcile()running on nearly every fetch via the eviction trigger, not the periodic one) means A-3's amortization benefit is workload-dependent: large at low eviction rates (generous budget), small when budget is tight enough that most fetches also evict. Not evaluated further here — out of this diagnostic's scope, noted for whoever next tunesreconcile_interval.
- Prefetch depths other than {1,2,4,8}, or depths larger than
n_chunks(16) — untested, and thecursor == just_residentchain-break inprefetch_pool_top_up()means depth ≥ n_chunks may enqueue slightly fewer than the full requested depth on a first pass (a minor, disclosed under-fill, not a correctness bug — verified the byte/fault accounting stays internally consistent regardless). - Combining Task B's deeper prefetch with the
lrupolicy — this diagnostic only sweptlayer_order(the only policy with a workingpredict_next;lru's is always -1, so prefetch depth is moot for it, consistent with item 8's finding). - Prefetch depth sweep on arms other than the pager (no baseline-arm equivalent exists for "depth").
- Whether the Task B deadlock fixes have any performance cost at depth==1
(the extra
budget_lockacquisitions incommit_reserved,commit_reserved_and_pin,unpin_chunk) beyond confirming the outputs are byte-for-byte identical to pre-Task-B numbers — wall-clock at depth 1 was not specifically A/B-tested against a pre-Task-B binary. - Real device bandwidth ceiling verification independent of the spike's
original numbers (no fresh
O_DIRECTbandwidth microbenchmark was run in this diagnostic; Task A's interpretation relies on the spike's §2 numbers still being representative of this machine/kernel).
No fabricated numbers: every value above is a direct read from
experiments/logs/task_a_log.txt, results/data/historical/task-b-sweep.csv, experiments/logs/task_b_log.txt,
or a disclosed, straightforward computation over them (medians, percentiles,
union-interval device-busy fractions, hit/waste correlation — all via the
scripts used to produce the tables, not eyeballed). No test was weakened:
Task A's interpretation was picked from the pre-registered categories using
the actual numbers, not blended to avoid a choice, and where the
categories didn't perfectly fit (a real ~14% overhead component alongside
a bandwidth-limited primary cause) both are stated rather than the
inconvenient one being dropped. Task B's expectations are reported
held/not-held/partially-held individually, including two that did not
cleanly hold (1 and 4), not summarized into a false "prefetch helps."
Task C's zero counters are reported as a finding about the test (with a
source-level proof for one of the two, not just an assertion), exactly as
instructed, and T-3 was not modified. Three real concurrency bugs were
found via reproduction (flaky hangs, a caught live thread-state dump), not
invented for narrative effect, and are documented with the actual
mechanism, not just "fixed a race."