Skip to content

Commit 04521dd

Browse files
kmbandyclaude
andcommitted
docs(wp): 2026-07-07 evening — ensure_batch 2x regression + cross-drain root cause + demux fix plan
Detailed continuation section at top: what was built (ensure_batch, commit e5089c2), the measured 2x decode regression (0.023 vs 0.04, coherent), the unified root cause (shared io_uring ring cross-drain cannibalizing the prefetch pipeline — explains both the decode regression and the depth-8/16 load hang), the fix (completion demux), validation gates, and the exact rebuild/run recipe on the ~/llama-wp worktree. Header updated for the merged branch + worktree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZRfPpL8XFzk1hep9MMg9P
1 parent e5089c2 commit 04521dd

1 file changed

Lines changed: 120 additions & 4 deletions

File tree

docs/dev/weight-paging-batch-eval-continuation.md

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,125 @@
11
# Weight-paging decode-speed work — continuation (morning pickup)
22

3-
**Branch:** `feat/wp-vnext` (edit/build-check on **mad-lab-2026** at `/home/kmbandy/GitHub/llama.cpp`).
4-
Pushed to `origin`; **mad-lab-main** is on `feat/dsws-phaseb-conversion` with the wp-vnext commits
5-
cherry-picked. All GPU validation is on mad-lab-main's **R9700 (ROCm0, gfx1201, 32 GB)**; the 6900XT
6-
is ROCm1. Remote shell is **fish** → always `ssh mad-lab-main bash -s <<'EOF'`.
3+
**Branch (CURRENT):** `feat/router-multigpu` = `feat/wp-vnext` (37 commits) **merged with** the router
4+
branch, + the `ensure_batch` change. Edit/build-check on **mad-lab-2026** at
5+
`/home/kmbandy/GitHub/llama.cpp`. Pushed to `origin`. **mad-lab-main** GPU box (R9700 / ROCm0 / gfx1201
6+
/ 32 GB; 6900XT = ROCm1) has this branch checked out in a **git worktree at `~/llama-wp`** (its main
7+
`~/GitHub/llama.cpp` checkout is on `feat/dsws-phaseb-conversion` with uncommitted MAD-305 DSWS work —
8+
**do NOT touch/checkout that**; always use the `~/llama-wp` worktree for weight-paging builds/runs).
9+
Remote shell is **fish** → always `ssh mad-lab-main bash -s <<'EOF'`.
10+
11+
---
12+
---
13+
14+
# ★★★ 2026-07-07 EVENING — READ THIS FIRST (supersedes the "NEXT FRONT" below) ★★★
15+
## `ensure_batch` (Colibri QD=N page-in) is BUILT + CORRECT but a **2× DECODE REGRESSION**. Root cause found. Fix = **completion demux**. Do that next.
16+
17+
### What happened this session (chronological, so we don't rehash)
18+
1. **Inspiration:** the GLM-5.2 CPU engine **Colibri** (`github.com/JustVugg/colibri`, file `c/glm.c`)
19+
does exactly our thesis on CPU — streams routed experts from NVMe. Its decode loop (`glm.c:1016`)
20+
is the pattern we want: **batch the misses, issue ALL their reads concurrently (`omp parallel`,
21+
each a coalesced single read), then wait** — QD1→QDN. User: apply that to our weight paging.
22+
2. **Merged** `feat/wp-vnext` into `feat/router-multigpu` (0 conflicts; pre-existing WIP
23+
`examples/pagedattn-*`, `src/llama-graph.cpp`, `examples/CMakeLists.txt` stashed/popped — **do NOT
24+
touch those**). Merge commit on the branch; then implemented `ensure_batch`.
25+
3. **Implemented `ensure_batch`** — commit **`e5089c241`** (pushed). Three edits:
26+
- `wp-pager.h/.cpp`: new `WeightPager::ensure_batch(pages, out_ptrs, out_pinned)` — reserve **and
27+
pin** every cold-miss slot up front (alloc_slot skips pinned, so no sibling read can evict it),
28+
then on the P2P/direct-to-device path submit all misses in **one `io_uring` batch (QD=N)** into
29+
the VRAM slots, wait for all, harvest; on read failure, sync-fallback into the **same pinned
30+
slot**. Returns `out_pinned` for the caller to unpin next callback.
31+
- `wp-pager.cpp`: `page_in_sync_(page, reuse_slot=-1)` refactor — reads into a caller-owned pinned
32+
slot without releasing it on error (the 5 error paths guard `if (owns_slot) release_slot`).
33+
- `wp-eval-cb.cpp` (~line 598): the MoE active-expert page-in is gated by **`WP_ENSURE_BATCH=1`**
34+
(default OFF; the existing prefetch+ensure path stays in the `else` for A/B + rollback). New
35+
branch calls `ensure_batch`, records the returned pins into `s_pinned_pages_prev_op`/`s_range_pins`.
36+
4. **Built clean** on mad-lab-main worktree `~/llama-wp/build-hip` (gfx1201, **AITER OFF**, ROCWMMA-FA
37+
ON). `ensure_batch` compiles with zero errors (only pre-existing `nodiscard hipError_t` warnings).
38+
5. **Measured on R9700, DeepSeek V4 Flash paged, P2P, `WP_ENSURE_BATCH=1`, ring depth 4:**
39+
**decode = 0.023 t/s (vs 0.04 baseline) — a 2× REGRESSION.** Output **fully coherent**
40+
(`"...the founding of the city of Rome in 753 BC..."`) → correctness OK, pin-lifecycle sound,
41+
**no expert corruption**. So the problem is PERF, not correctness.
42+
- (0.04 baseline IS the valid apples-to-apples number: `WP_ENSURE_BATCH=0` on THIS merged branch ==
43+
the old wp-vnext path. Do not re-excuse it as "different branch.")
44+
6. **Tried depth 8/16 first (WP_IOURING_DEPTH=16 WP_PREFETCH_DEPTH=16) — LOAD HANGS.** Confirmed via
45+
`/proc/<load-thread>/wchan = io_cqring_wait` (waiting on an io_uring completion that never arrives).
46+
Fell back to the known-good depth-4 ring for the run above.
47+
48+
### ROOT CAUSE — one bug explains BOTH the regression and the depth-8/16 hang: **shared io_uring ring CROSS-DRAIN**
49+
The sync path (`page_in_sync_`, sentinel `req_id=(uint64)-1`) / `ensure_batch`'s wait loop AND the
50+
`PrefetchScheduler` all drain the **same** io_uring ring. The io layer's `wait_any()`
51+
(`wp-file-io.cpp` IoUringAsyncFileIO, ~line 255-306) **unconditionally `io_uring_cqe_seen()`s** every
52+
CQE it returns. Both the sync/ensure_batch waiters and `PrefetchScheduler::tick()` **DROP any completion
53+
whose `req_id` isn't theirs** — but the CQE is already consumed. `PrefetchScheduler::process_io_`
54+
(`wp-prefetch.cpp:302`) frees a queue slot only on **its own** completions (via `req_to_slot_`); a
55+
completion cannibalized by the sync/ensure path **never frees that scheduler slot**`free_slots_`
56+
leaks empty → **speculative cross-layer prefetch stalls out**.
57+
- **DECODE (WP_ENSURE_BATCH=1):** `ensure_batch`'s `while(reaped<n) wait_any(-1)` eats the prefetch
58+
scheduler's completions → prefetch pipeline dies → we lose the **compute↔I/O overlap** the old path
59+
relied on → every MoE op becomes a cold, un-overlapped read → **2× slower** (net LOSS; "more
60+
concurrency" but no overlap, and capped at depth 4 anyway).
61+
- **LOAD at depth 16:** heavier prefetch traffic → `tick()` (`wait_any(0)` drain) eats
62+
`page_in_sync_`'s completion → `page_in_sync_` blocks forever in `io_cqring_wait`. At depth 4 the
63+
collision is rare enough to mostly work.
64+
65+
### THE FIX (do this next — it is the real unlock, NOT "just avoid it")
66+
**Completion demux in the io layer** (`wp-file-io.cpp`, IoUringAsyncFileIO): drain the ring **once**
67+
into a shared `std::unordered_map<uint64_t req_id, IoResult>` (`io_uring_cqe_seen` exactly once per
68+
CQE). Every waiter — `page_in_sync_`, `ensure_batch`, `PrefetchScheduler` — first checks that map for
69+
ITS `req_id`(s); only if absent does it pull a fresh CQE (moving any non-matching completions into the
70+
map, never discarding). No completion is ever lost to another consumer. This single change is expected
71+
to fix ALL THREE: (a) the 2× decode regression (prefetch pipeline survives `ensure_batch`), (b) the
72+
depth-8/16 load hang (`page_in_sync_` never loses its CQE), (c) `ensure_batch` finally gets **true
73+
QD=8** (raise `WP_IOURING_DEPTH`/`WP_PREFETCH_DEPTH` to ≥8 once the demux lands).
74+
- **Watch:** the load/decode paths are single-threaded per op (`PrefetchScheduler` +
75+
`page_in_sync_`/`ensure_batch` run on the same inference thread during a forward pass), so the map
76+
needs no lock — **verify** that assumption before relying on it.
77+
- Files: `wp-file-io.cpp` (the demux + a `wait_for(req_id)` that consults it), `wp-prefetch.cpp`
78+
(`tick()`/`process_io_` pull from the map), `wp-pager.cpp` (`page_in_sync_`, `ensure_batch` waits).
79+
80+
### VALIDATION GATES after the demux (this is the proof the cross-drain was the cause)
81+
1. **Decode ≥ 0.04** with `WP_ENSURE_BATCH=1` at depth 4 (regression gone: prefetch pipeline restored).
82+
2. **Depth-8 load does NOT hang** (`WP_IOURING_DEPTH=8 WP_PREFETCH_DEPTH=8`).
83+
3. **Decode at QD=8** climbs toward the **~0.4 t/s** ceiling (the ~10× headroom the diagnosis predicts).
84+
4. Output stays coherent; `routing_ptrs_discarded=0`; 0 faults.
85+
86+
### EXACT REBUILD + RUN RECIPE (mad-lab-main, `~/llama-wp` worktree)
87+
```
88+
# BUILD (fresh worktree build; ~25 min at -j2, RAM-capped for the 15 GB host; CPU-only, safe):
89+
ssh mad-lab-main bash -s <<'E'
90+
cd ~/llama-wp
91+
ROCM_PATH=/opt/rocm LD_LIBRARY_PATH=/opt/rocm/lib HIP_PATH=/opt/rocm cmake -S . -B build-hip \
92+
-DGGML_HIP=ON -DAMDGPU_TARGETS=gfx1201 -DCMAKE_BUILD_TYPE=Release -DGGML_HIP_ROCWMMA_FATTN=ON \
93+
-DGGML_HIP_AITER=OFF -DCMAKE_C_COMPILER=/opt/rocm/llvm/bin/clang -DCMAKE_CXX_COMPILER=/opt/rocm/llvm/bin/clang++
94+
cmake --build build-hip -j2 --target llama-perplexity llama-server
95+
E
96+
# RUN a decode measurement (server on :8081, R9700):
97+
ssh mad-lab-main bash -s <<'E'
98+
systemd-run --user --unit=dsv4-decode --collect -p MemoryMax=9000M --working-directory=/home/kmbandy/llama-wp \
99+
--setenv=WP_SIZE_CLASS_SLOTS=1 --setenv=WP_BATCH_EVAL_CB=1 --setenv=WP_PAGED_BATCH=1 \
100+
--setenv=WP_DENSE_PREFETCH_N=8 --setenv=WP_ENSURE_BATCH=1 \
101+
--setenv=LLAMA_WP_TRANSPORT=p2p --setenv=LLAMA_WP_TRANSPORT_FORCE=1 \
102+
bash -c './build-hip/bin/llama-server -m /home/kmbandy/Downloads/DeepSeek-V4-Flash-UD-Q8_K_XL-00001-of-00005.gguf \
103+
--no-mmap --weight-paging --weight-paging-slots 384 --weight-paging-prefetch \
104+
-ngl 99 --device ROCm0 --host 127.0.0.1 --port 8081 --ctx-size 4096 --parallel 1 > /tmp/dsv4-decode.log 2>&1'
105+
# wait for /health==200 (paged load ~3-5 min), then:
106+
curl -s http://127.0.0.1:8081/completion -H 'Content-Type: application/json' \
107+
-d '{"prompt":"The history of the Roman Empire begins with","n_predict":16,"temperature":0,"cache_prompt":false}' \
108+
| python3 -c "import sys,json;t=json.load(sys.stdin)['timings'];print('t/s',t['predicted_per_second'])"
109+
E
110+
```
111+
- **DO NOT** set `WP_IOURING_DEPTH`/`WP_PREFETCH_DEPTH` > 4 until the demux lands (hangs the load).
112+
- To get clean pager stats (`prefetch_hit_rate`, `sync_fallbacks`, `io_effective_gb_s`) the server MUST
113+
shut down GRACEFULLY — `systemctl stop` hard-kills before `log_stats_summary()` runs. Send SIGTERM
114+
and let it drain, or add a signal handler, if you need those numbers.
115+
- R9700 shares the box with the murmur **captain (:8090, ROCm0)** and the **6900XT LFM2.5 (:8092,
116+
ROCm1)**. When no murmur is active the captain holds ~0 VRAM so DeepSeek fits the 32 GB fine; if a
117+
murmur is running, the captain will contend for ROCm0 — check `rocm-smi --showmeminfo vram` first.
118+
119+
### Related (separate KG handoff notes written this session)
120+
- **KG `3b83ca79`** — this ensure_batch result + cross-drain root cause + demux fix (full detail).
121+
- **KG `5e2b8089`** — the recurrent-state mover HIP-only stub bug + 6900xt LFM2.5 concurrent GPU fault
122+
(a DIFFERENT subsystem; another session is picking up murmur; not on the critical path here).
7123

8124
---
9125
---

0 commit comments

Comments
 (0)