File size: 42,666 Bytes
d909bd8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 | # P3: native paged-KV continuous-batching sampler
Status: implemented, CPU-verified, opt-in. GPU validation is scripted but NOT run (the P2 GPU chain owns the GPUs).
Files: `align/rollout/paged.py`, `align/rollout/engine.py`, lazy exports in `align/rollout/__init__.py`,
`tests/test_paged_engine.py` (see the Review section: 27 `test_mutant_*` functions, each with an unmutated control that passes),
`tests/test_paged_engine_bench.py` (plus the measurement CLI). Runtime deps unchanged (torch + stdlib; `tests/test_dependency_firewall.py` green).
## Why
`align.inference.engine.Engine` (what `NativeRolloutBackend` drives) gives each slot a contiguous `max_seq_len` strip of KV
for the engine's life. Prover rollouts have a heavy-tailed length distribution (most samples end at a few thousand tokens,
some run to the 16k/40k cap), so concurrency is `budget / cap` whatever is actually in flight. Paging hands out
`block_size`-token blocks on demand; waste is bounded by one partial block per sequence, and a sequence that outgrows its
share can be preempted and recomputed instead of the whole run failing.
## Design
**`paged.py`**
* `BlockAllocator`: LIFO free list + refcounts. `free` on refcount 0 raises (a double free would put one block on the list
twice and give it to two sequences). `check_consistent()` proves `free + referenced == total`, no duplicates.
* `PagedKVCache`: per-layer `[num_blocks, block_size, Hkv, D]` K and V; block tables; **copy-free append** (`write` scatters into
`(block, offset)` in place). `ensure_capacity` is all-or-nothing. `slots()` refuses a write into a block with refcount > 1
(a shared block is read-only for everyone, owner included). `gather()` returns contiguous `[1, Hkv, L, D]` (same bytes,
same layout whatever else is in the batch). `block_table_tensor()` is the int32 `[B, max_blocks]` argument a kernel takes.
* `TorchPagedAttention(mode)`: pure torch, the attention seam is the `PagedAttention` protocol (write new K/V, return
`[T, H, D]`). `'row'` = one SDPA per query row over exactly its own keys; `'segment'` = one SDPA per prefill chunk with a
bool mask + all decode rows padded into one batched SDPA.
* `TritonPagedAttention`: **interface only** (raises). The docstring pins the kernel contract (q, k/v cache blocks, block
tables, cu_seqlens_q, ctx_lens, slot mapping) and the invariance requirement (fixed KV split per context length, no atomics).
* `kv_bytes_per_token`, `plan_capacity`: the memory accounting below.
**`engine.py`**
* Model-agnostic WITHOUT editing any model file: `_ModelView` drives the model's own submodules layer by layer
(`attn_norm`, `attn.{q,k,v,o}_proj`, `q_norm`/`k_norm`, `mlp_norm`, `mlp`, `norm`, `lm_head`, `rope`) and replaces only
attention. Qwen3 per-head QK-norm vs OLMoE full-width QK-norm (+ `clip_qkv`) is told apart by the norm weight width; a
non-canonical kv-head map or an unknown module layout is refused at build. Mistral, Qwen3 and OLMoE (MoE mlp returning a
tuple) all run.
* Scheduler: every sequence is `tokens` + `num_computed`; a step schedules `n_new = min(remaining, chunk_size, budget)` per
sequence. Prefill, chunked prefill, decode and post-preemption recompute are one path; a sequence samples when its scheduled
tokens reach the end of `tokens`. Decodes are scheduled first; prefill chunks and decode tokens share a flat ragged step.
* Admit/evict: FIFO admission if the whole current token list fits; on block exhaustion the youngest unscheduled running
sequence is preempted by recompute (blocks freed, `num_computed = 0`, tokens and sampler state kept). No host swap exists on
this box, so recompute is the only eviction.
* Per-sequence `SamplingParams`: `max_tokens`, `temperature`, `top_p`, `top_k` (applied before top-p), `seed`,
`stop_token_ids`, text `stop` (needs a tokenizer), plus engine `eos_token_id` and `max_seq_len`. One `torch.Generator` per
sequence, advanced only by its own draws. Unseeded requests hash the request id (not admission order). The returned logprob is
`log_softmax(logits/T)[tok]` untruncated, the same basis as `native.LOGPROB_BASIS`, so the rho contract is unchanged.
* Prefix sharing (opt-in): a waiting sequence adopts the leading FULL blocks of a running sequence (GRPO's G samples prefill
the prompt once), defers while the leader is still computing them, and always recomputes its last prompt token.
* `numerics`: `'invariant'` (default; every row-wise module call is issued per row, attention per query row; bit-reproducible,
slow) or `'fast'` (rows batched, decode attention padded; equal to invariant to rounding).
Opt-in: `from align.rollout import PagedEngine, PagedEngineConfig, SamplingParams, PagedResult` resolve lazily; importing
`align.rollout` does not import `engine`/`paged` (tested in a subprocess). `native.py`, `client.py`, `align/rl/*` are untouched.
It is not yet wired as a `RolloutBackend` (see open items).
## Evidence (all measured, CPU, torch 2.11, 4-8 threads)
Run: `CUDA_VISIBLE_DEVICES= .venv/bin/python -m pytest tests/test_paged_engine.py tests/test_paged_engine_bench.py -m "not gpu and not oracle and not slow"`
-> 149 passed, 1 skipped (the dense-only reference check for OLMoE) after the review pass (69 before it). Before: the modules do not exist (ImportError); the
oracles below also fail on every mutant.
1. **Greedy equals independent references.** Mistral, Qwen3 (QK-norm) and OLMoE tiny models in fp64, three prompts (3, 9, 17
tokens), 10 new tokens, both numerics, chunk 5: tokens identical to a naive full-recompute loop. For Mistral/Qwen3 the
reference is a transformer written in the test from raw weights in fp64 (logprobs agree to < 1e-5; the models' RoPE
rotates in fp32 by design, so < 1e-15 is not attainable against a true-fp64 reference). OLMoE is checked against the
model's own full forward. A separate test pins the reference to the model forward (< 1e-5). Also identical (tokens, logprobs
< 1e-4) to `align.inference.engine.Engine` and to `NativeRolloutBackend` at temperature 0 (fp32).
2. **Batch invariance.** fp64 Qwen3, `invariant`: a sequence alone vs among 1/3/7 others of random lengths 2-30 admitted at
random steps 0-6, random chunk (3/5/64), budget (8/16/64) and `max_num_seqs` (2/4/16), 4 seeds x 3 compositions: tokens,
logprobs and every step's full logit vector are **bitwise equal** (`torch.equal`). Same under chunk size 1/2/64, with
prefix sharing on (>= 8 prompt tokens actually shared), and with a 17-block pool that forces preemption by recompute.
`fast` vs `invariant`: dense models, logits within 1e-9 (fp64) and identical greedy tokens. OLMoE is NOT that tight:
its router/MoE path has fp32 islands, so even an fp64 model drifts ~1.2e-7 between `fast` and `invariant` (tokens
identical in 400 randomized configs, tolerance 1e-5); a near-tie in expert routing is therefore a place `fast` can flip.
3. **Seeded sampling** (temperature, top-p, top-k, combined) is bit-identical alone vs 2/5/8 companions, with different chunking
and a 24-block pool; different seeds give different streams. `row_distribution` equals an independent fp64 reference
(top-k then minimal-nucleus top-p) to 1e-6 with identical support size.
4. **Allocator never leaks.** Property run of 10,000 randomized events (admit with optional prefix share / grow / finish) on
`PagedKVCache`, books checked after EVERY event against refcounts recomputed from the live block tables (>100 OOM events and
>100 shared admits occurred); plus an engine fuzz: 120 requests, 14-block pool, prefix sharing on, preemptions > 0, consistent
after every step, pool fully free at the end.
5. **Mutants killed** (monkeypatched; the oracle must fail): gather drops last key; slot mapping ignores block offset; K heads
flipped (GQA map); prefill causal mask off by one (fast); RoPE positions restart each step; allocator free leaks; free
double-appends; incref removed; double-free guard removed; preemption forgets generated tokens; preemption forgets to free
blocks; preemption picks the oldest victim (more recompute); one shared generator; row batching in fp32 (breaks bitwise
invariance); top-p keeps extra token; top-k off by one; logprob from truncated distribution; prefix sharing of a partial
block; prefix sharing without the last-token recompute. Each has an unmutated control that passes.
### CPU throughput scaling (generated tokens/s, best of 3, 16-token prompts, 24 new tokens, 3.41M-param Qwen3-shaped fp32, 8 threads, shared busy host)
| N seqs | fast tok/s | speedup | invariant tok/s | speedup |
|---|---|---|---|---|
| 1 | 210 | 1.00x | 174 | 1.00x |
| 2 | 412 | 1.96x | 207 | 1.19x |
| 4 | 581 | 2.77x | 237 | 1.36x |
| 8 | 867 | 4.13x | 272 | 1.56x |
| 16 | 1033 | 4.92x | 280 | 1.61x |
| 32 | 1154 | 5.49x | n/a | n/a |
Batching scales sub-linearly (Python-level per-sequence attention and per-row sampling dominate a tiny model); `invariant`
buys bit-reproducibility at 4-5x lower throughput at N=16. CPU numbers say nothing about GPU absolute speed.
### bf16 expectation (24 trials, 7 companions, random-init 4L/256H model, so near-flat logits)
* `invariant`: 0/24 greedy and 0/24 sampled sequences differ alone vs batched; max |dlogit| = 0 (bitwise).
* `fast`: 11/24 (45.8%) greedy sequences differ (first-divergence-limited max |dlogit| 0.047, mean-of-max 0.035, i.e. a few bf16
ulps of the logit scale); 0/24 sampled differ.
This model is a pessimistic tie-heavy case for greedy (2048 near-equal logits); a trained model's peaked distributions will
flip less, but that is not measured here. The 0/24 sampled figure is also an artefact of flat distributions (one uniform draw
lands in the same bin), not evidence of safety. **Rule: use `invariant` whenever the logprobs feed the rho correction or a
replayable run; `fast` is for throughput evals.**
## Memory accounting (Qwen3-8B / Goedel-Prover-V2-8B, bf16 KV)
* KV bytes per token = 36 layers x 2 x 8 kv-heads x 128 x 2 B = **147,456 B (144 KiB)** (`kv_bytes_per_token`; test pins it).
Mistral-7B: 131,072 B.
* **The vLLM probe's 455,888 tokens is a TP=4 number.** `.tmp/p2/P2-B/smoke.log`: "Available KV cache memory: 15.65 GiB" per
rank, KV heads sharded 4 ways -> 36,864 B/token/rank -> 15.65 GiB / 36,864 = 455,8xx (test reproduces it within 0.05%), and
"Maximum concurrency for 40,960 tokens: 11.13x" (our plan gives 11.12x). The P2 baseline manifest records 456,528 tokens at
util 0.9 and 876 tok/s end-to-end on 4 GPUs (9.6M tokens, 114 of 1952 length-capped).
* **One A10G, native engine, no TP** (device 22.06 GiB as vLLM sees it, util 0.9 -> 19.85 GiB; weights 15.26 GiB; 1.0 GiB
activations + 0.4 GiB context assumed): KV budget 3.19 GiB = **23,248 tokens**. At a 16k cap: **1** concurrent sequence;
at a 40k cap: **0** (a single 40,960-token sequence cannot fit). At util 0.95: 31,280 tokens, still 1 / 0. At a 4k cap: 5.
A dense per-slot strip allocates the same bytes, so paging does not conjure memory here; it makes the 23k tokens usable by
many short sequences instead of one reserved strip, and removes the failure when one long sample appears.
* Planner for sharded layouts (`tp_degree` divides KV heads): TP=2 gives 164,912 tokens (10 x 16k, 4 x 40k); TP=4 gives 440,944
(26 x 16k, 10 x 40k) with 0.5 GiB activations.
* **Consequence (decision input):** the native engine as written is single-device. For the 8B prover it cannot replace the TP=4
vLLM pool (876 tok/s end-to-end) - it can serve Goedel-8B at ~4k caps on one GPU, or serve models <= ~4B at long caps. Matching
vLLM needs TP (shard KV heads + projection columns) or an fp8 KV cache; neither is built here (open items).
## GPU validation plan (run only after the P2 chain, PID 733530, has exited; GPUs 0-3 show 22 GB each now)
Preconditions (`/apps/CLAUDE.md`): `free -g; uptime; awk '$3=="nvme2n1"{print $12}' /proc/diskstats; ps -eo stat | grep -c '^D'; nvidia-smi`
all clear and `pgrep -f chain_after_baseline` empty. Both steps below take the heavy-job lock (`--wait-lock`), so they serialise.
```
cd /apps/development/rlhf/align
export HF_HOME=/apps/development/rlhf/hf_cache HF_HUB_DISABLE_XET=1 PYTHONPATH=$PWD TMPDIR=$PWD/.tmp/g2/p3 \
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
M=/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a
mkdir -p .tmp/g2/p3/gpu
# 1) vLLM, ONE GPU, same memory the native engine gets, 16 problems x 4 samples, 4k cap (also writes the prompt_ids reused in step 2)
CUDA_VISIBLE_DEVICES=0 .venv-rollout/bin/python extras/vllm_rollout/lean_gen.py \
--problems .tmp/p2/problems/minif2f.jsonl --limit-problems 16 --out .tmp/g2/p3/gpu/vllm_tp1.jsonl \
--n 4 --cap-tokens 4096 --round 0 --tp 1 --max-model-len 8192 --gpu-mem-util 0.90 --max-num-seqs 64 --seed 0 --wait-lock --label p3-vllm-tp1
# 2) native paged engine, same GPU, same prompt ids, same T=1.0 / top_p=0.95 / 4096 cap; fills free memory minus 3 GiB with KV (see Review: the padded-decode transient is unmeasured)
CUDA_VISIBLE_DEVICES=0 .venv/bin/python tests/test_paged_engine_bench.py --gpu --model-dir $M \
--prompts-jsonl .tmp/g2/p3/gpu/vllm_tp1.jsonl --n-prompts 16 --g 4 --max-tokens 4096 \
--max-num-seqs 64 --budget 2048 --chunk 512 --numerics fast --wait-lock --out .tmp/g2/p3/gpu/native_fast.json
# 3) cost of bit-invariance (slow: fewer prompts)
CUDA_VISIBLE_DEVICES=0 .venv/bin/python tests/test_paged_engine_bench.py --gpu --model-dir $M \
--prompts-jsonl .tmp/g2/p3/gpu/vllm_tp1.jsonl --n-prompts 2 --g 4 --max-tokens 1024 \
--numerics invariant --wait-lock --out .tmp/g2/p3/gpu/native_invariant.json
```
Compare `tokens_per_s_end_to_end`, `len_p50`/`len_max`/`finish_reasons` and `peak_cuda_mib` in the native JSON with
`tokens_per_s_end_to_end`, `finish_length`, `gpu_mem_used_peak_mib` and `kv_cache.tokens` in
`.tmp/g2/p3/gpu/vllm_tp1.jsonl.manifest.json`. Sampling streams differ by construction (per-sequence generators vs vLLM's),
so compare throughput and length distributions, not tokens. Greedy fidelity on GPU (the check that matters for the rho
contract): run both on 8 prompts at temperature 0 and compare first-divergence positions; expect bf16 near-tie flips, report
the median first-divergence index, not "identical". Acceptance to proceed with TP work: native tokens/s >= 0.5x vLLM TP=1 at
this shape (CUDA-graph-free, per-sequence gather attention; if it is far below, the Triton kernel is the next step, not TP).
## Open items
1. **Not a `RolloutBackend` yet.** A `PagedRolloutBackend` (same `submit/collect`, `prompt_token_ids`/logprob extras, G-sample
expansion with `prefix_sharing=True`) is a small adapter on `run_compare`'s request building; deferred because `client.py`/
`native.py` are outside this item's list.
2. **Single-device only** (no TP, no fp8 KV): see the consequence above.
3. **Pure-torch attention is the bottleneck on GPU** (a Python loop over segments, K/V gathered per sequence per layer per step;
per-row `float()` syncs in sampling). The Triton kernel interface is written, the kernel is not; it must keep the per-row
reduction independent of batch composition or `invariant` becomes `fast`.
4. **Multi-turn masks / tool-turn continuation** (ROADMAP P3) are not implemented: the engine supports `add_request` of an
extended token list (prefix sharing makes re-prefilling the transcript cheap) but has no turn-boundary loss mask output.
5. **Swap-out preemption and prefix caching of FINISHED sequences** (hash-keyed block reuse across requests) are not built; sharing
is only between concurrently running sequences.
6. **`invariant` is ~4-5x slower than `fast` on CPU** and has not been timed on GPU; a batch-invariant GEMM (fixed tile/split-K
per row) would remove the per-row calls.
7. `OlmoeAttention`/MoE paths are exercised on tiny fp64 models only; expert-parallel modules (`moe_ep`) are not supported.
8. Greedy fidelity of `fast` mode in bf16 on a real checkpoint is unmeasured (plan above).
## Review (adversarial pass, CPU only)
Found by reading the engine against its own claims, then probing; every item below has a failing-before / passing-after test and
at least one mutant that the new oracle kills.
* **Pool guard off by one** (`add_request`): the last sampled token is never fed back, so a request ending with N tokens holds
N-1 in the cache. The guard sized the pool on N and refused a request that fits exactly (prompt 3 + 6 new, block 4: 8 cached
tokens = 2 blocks, 2-block pool rejected as "needs 3"). Now sized on N-1; swept over prompt 1-9 x max_tokens {1,2,3,4,5,8,9}
at block 4 with a pool of exactly the needed blocks (peak blocks == needed, pool fully free after).
* **Out-of-vocabulary prompt id wedged the engine**: the IndexError fired inside `step()`, the poisoned request stayed resident, and
every later `step()` re-raised (on CUDA it is a device-side assert). Refused at `add_request`.
* **Greedy NaN logits were silent**: `argmax` of a NaN row returns the NaN's index and `log_softmax` is NaN, so a bf16 overflow became
a plausible token with a NaN logprob in the rollout (sampled rows already raised inside `multinomial`). `step()` now raises
`FloatingPointError` before any sequence advances (retryable; -inf stays legal). The check also synchronises, so `forward_seconds`
now includes GPU time instead of kernel-launch time only (steps with no sampled row still do not sync).
* **`kv_dtype` crashed for any dtype other than the model's** (SDPA dtype mismatch on the first step). K/V are now dequantised on
read. bf16 KV under an fp32 model: logits within 6e-3, identical greedy tokens, half the bytes. float8_e4m3fn runs but is UNSCALED
and visibly lossy (tiny-model logits off by 1.3, tokens differ), so "fp8 KV" in the decision above needs per-tensor scales, not
just a dtype switch.
* **Bench seeds were not reproducible**: `hash((pid, j))` is salted per interpreter; two launches of the same GPU command drew
different sampled streams. Now a sha256-derived seed (tested across two `PYTHONHASHSEED`s). The bench also passed only
config.json's single eos id; vLLM also stops on generation_config's list (Goedel: 151645 and 151643), so the native engine could
run past `<|endoftext|>` and skew the length comparison. `stop_ids_from_generation_config` now supplies the set.
* **Mutation gaps closed**: `stop_token_ids` was untested (the older stop test's `or` passed with the feature broken); the
"truncated logprob" mutant never ran engine code (now swaps the engine's `sample_row` and checks logprob == log_softmax(logits/T)
through the whole engine); a batched `lm_head` stayed bit-identical at hidden=128 on CPU so that mutant survived (now a per-site
mutant on a 512-wide model for mlp, qkv, o_proj and lm_head, each asserted to be reached and to change bits).
Not fixed, measured or reasoned:
* **Recompute preemption bill**: with no admission watermark a new request takes the free blocks, the next step's residents need
them, and the youngest is preempted again. Measured on 20-40 block pools (block 4), 1 long decoder + 6 arrivals of 24 prompt / 40 new tokens:
7-10 preemptions, 667-818 forward tokens against 445 minimal (1.5-1.8x). Correctness is unaffected (fuzzed bitwise); a block
watermark on admission is the lever.
* **Unmeasured GPU transient**: `fast` pads decode K/V to the longest sequence: 64 seqs x 8 kv-heads x 4800 x 128 x 2 B x (K and V) =
1.17 GiB per layer call, and if the SDPA backend chosen for a masked GQA call is `math`, K/V are expanded to 32 heads (4.7 GiB).
`plan_capacity`'s 1.0 GiB activation allowance does not cover either; hence `--reserve-gib 3.0` above. Read `peak_cuda_mib`.
* `PagedEngineConfig.max_seq_len` may exceed the model's `max_position_embeddings` without any warning (RoPE just extrapolates).
## Tensor parallelism (inference, opt-in)
Status: implemented, CPU-verified on gloo at TP=2 and TP=4, opt-in. **GPU validation is scripted and NOT run** (the P2 chain owns the GPUs).
Files (all new; no existing file was edited, so every default is byte-identical): `align/model/tp.py` (the sharded model),
`align/rollout/tp_engine.py` (`TPPagedEngine`), `tests/test_tp.py`, `tests/test_tp_load.py`, `tests/test_tp_engine.py`,
`tests/test_tp_bench.py` (capacity/latency model + the GPU runner), `tests/test_tp_runner.py` (the runner end to end on CPU/gloo).
Not exported from `align.rollout`'s lazy table (that file is not part of this item): `from align.rollout.tp_engine import TPPagedEngine`.
Runtime deps unchanged (torch + stdlib, safetensors lazily; `tests/test_dependency_firewall.py` green).
### Why (the bottleneck, in numbers)
One A10G holds the 8B prover's weights (15.26 GiB) and has ~3.2-3.6 GiB left: **one 16k sequence, zero 40k sequences**. TP=4 shrinks both
terms of the KV budget: weights /4 (3.81 GiB per rank, computed from the config's parameter count; vLLM measured 3.83 GiB loaded) and KV
bytes per token /4 (36,864 B vs 147,456 B), so the pool grows ~17x in tokens, not 4x.
### Design
Megatron layout; the only collectives are on the residual stream: **2 all-reduces per layer + 1 for the embedding + 1 all-gather for the logits**.
| piece | sharding | comm |
|---|---|---|
| `embed_tokens` -> `VocabParallelEmbedding` | rows (ceil(V/tp) each, tail padded with zeros); masked lookup | all-reduce(sum); exact in any order (x + 0) |
| `q/k/v_proj` | plain `nn.Linear`, output = this rank's heads (q: H/tp, kv: Hkv/tp) | none |
| attention | the **unchanged** model code on the local head counts; Qwen3's per-head q/k-norm weight (width head_dim) is replicated | none |
| `o_proj` -> `RowParallelLinear` | input features = this rank's heads | all-reduce(sum) |
| `gate/up_proj` / `down_proj` | intermediate/tp columns / rows (`RowParallelLinear`) | all-reduce(sum) after `down_proj` |
| `lm_head` -> `VocabParallelLMHead` | rows, tail padded | all-gather of logits, cut back to V |
* **No second forward.** The TP model IS `Qwen3ForCausalLM` / `NmoMistralForCausalLM` built with a LOCAL config (heads, kv heads,
intermediate divided by tp) and five module types swapped. The dense forward, `_head_norm`/`_swiglu` seams, RoPE, RMSNorm and the GQA
map are the original code, and `PagedEngine` drives the shard through its own submodules unchanged. Consequence: **the paged KV cache is
sharded by kv head for free** (`_ModelView` reads the local `n_kv_heads`; Qwen3-8B at tp=4: 8 -> 2) and per-rank KV bytes are exactly 1/tp (tested).
* **GQA.** Blocked map `q_head // group` stays local and canonical iff tp divides `n_kv_heads`. tp > n_kv_heads would REPLICATE kv heads
(vLLM's choice); that layout is refused (`TPError` naming every violated divisibility: kv heads, heads, intermediate) rather than guessed.
Qwen3-8B supports tp in {1, 2, 4, 8}.
* **QK-norm.** Per head, local, replicated weight. OLMoE's full-width norm cannot be sharded this way; it is not buildable here (no rule -> `TPError`).
* **Logits: all-gather, not a parallel softmax.** Sampling needs the full row (top-k, top-p, the multinomial draw, the untruncated log-softmax
the rho correction is defined on) and every rank must hold identical logits for the replicated sampler below. Cost: (tp-1)/tp x V x 2 B =
223 KiB per SAMPLED row at tp=4 bf16. `vocab_parallel_logsumexp / _token_logprob / _argmax` are the two-scalars-per-row alternative for greedy
decoding and teacher-forced scoring (verified against the full softmax, ties to the lowest index like `torch.argmax`); the engine does not route through them.
* **Vocab padding.** A vocabulary that does not divide tp is padded per rank and cut after the gather (a padding column's logit 0.0 could otherwise
win the argmax of an all-negative row); a tp larger than the vocabulary leaves a rank with zero real rows (tested at V=5, tp=4).
* **Scheduler/sampler: replicated, rank 0 authoritative for INPUTS and verifier of the replication (chosen over "rank 0 broadcasts decisions").**
Every rank runs the identical deterministic scheduler; `generate` takes the request stream from rank 0 and broadcasts it once.
Why not broadcast decisions: a ragged object (segments, block tables, sampled-row list) pickled and host-synced every step, to ship what every rank
can recompute from state it holds. Why not sample on rank 0 only: a token broadcast between every forward and the next, plus the full row on rank 0 anyway.
Redundant sampling is free because the logits are bit-identical on every rank by construction (an all-gather hands every rank the same bytes) and
the per-sequence generators are seeded identically (`_stable_seed` is sha256, not `hash()`). The one real risk of replication is SILENT DIVERGENCE
(one differing token or schedule -> mismatched collectives -> an NCCL hang or garbage), closed by two 8-byte all-gathers per step:
`_schedule` agrees a fingerprint of the step (ids, `num_computed`, `n_new`, the fed token ids) BEFORE any model collective, and `has_unfinished`
agrees the loop predicate plus a hash of the previous step's events. Symmetric (all-gather), so a divergence raises `TPDesyncError` on ALL ranks.
`verify=False` drops them. Measured cost on gloo (3-layer toy, loaded host): +0.7 ms/step at tp=2 (9.9 vs 9.2 ms, +8%), +2.9 ms/step at tp=4
(18.5 vs 15.6 ms, +18%); at 36 layers the two checks are 2 of 76 collectives per step. **GPU cost unmeasured**: the runner has `--no-verify` for the A/B.
* **Weight loading, bounded host RAM.** `load_tp_model(snapshot, tp, dtype, device)` reads per tensor ONLY this rank's slice via
`safe_open().get_slice(name)[a:b]`, makes it compact and owned (`_own`: safetensors hands back a view whose storage spans the WHOLE source tensor),
moves it, casts on the target. Total and loud: the checkpoint's tensors must EQUAL the architecture's (extra/missing/wrong-shape/absent shard raise),
names come from the single-device loaders' own tables. `tp_from_model` shards an in-memory model (copies, never views) for tests and weight refresh.
* **Inference only**: `TPGroup.all_reduce` raises on a tensor that requires grad. `reduce_dtype=torch.float32` accumulates the all-reduce in fp32 (2x volume).
### Evidence (all measured, CPU/gloo, torch 2.11; run: `CUDA_VISIBLE_DEVICES= .venv/bin/python -m pytest tests/test_tp.py tests/test_tp_load.py tests/test_tp_engine.py tests/test_tp_bench.py tests/test_tp_runner.py -m "not gpu and not oracle and not slow"`)
Before: `align.model.tp` and `align.rollout.tp_engine` do not exist (ImportError); a vocab-sharded model also fails `PagedEngine.add_request`
(the engine reads the vocabulary from `embed.weight.shape[0]`, i.e. V/tp) - one of the mutants below. Tests in the 5 files: 134 (131 default + 1 `slow` RSS measurement + 2 `oracle` HuggingFace-parity cases).
1. **fp64 logits equal the unsharded model.** Tiny Qwen3 (per-head QK-norm) and Mistral, heads 8 : kv 4, odd vocab 99 (padded shards), padded
attention mask. Max |dlogit| over prefill: TP=2 5.6e-16 (qwen3) / 4.4e-16 (mistral), TP=4 6.7e-16 / 8.9e-16, at logit scale ~2.8; threshold 1e-12.
2. **Engine = TP=1 engine.** `TPPagedEngine` (requests given on rank 0 only) vs `PagedEngine` on the unsharded model, 5 variants (invariant, fast, 16-block
pool with 5 preemptions, prefix sharing with 36 shared tokens, chunk 3/budget 8), greedy + seeded T/top-k/top-p + unseeded streams + a stop-token request:
tokens, finish reasons, step counts, preemption counts, shared-prefix counts identical; logprob diff 0; every sampled logits row (fp64, captured at
`sample_row`) within 8.9e-16 at TP=2 and TP=4 for both families. Decode logits vs a DENSE full-recompute forward of the unsharded model: 1.1e-15.
3. **Batch invariance survives TP** (`invariant`): a seeded sequence alone vs among 5 companions admitted at other steps, 3 chunk/pool configs
(default, chunk 3 + budget 16, 40-block pool with `max_num_seqs=3`): tokens, logprobs and every step's logits **bitwise equal**, TP=2 and TP=4.
4. **KV is sharded**: per-rank KV bytes = unsharded / tp exactly (393,216 -> 196,608 at tp=2, 98,304 at tp=4); `kv_bytes_per_token(36, 8, 128, bf16, tp_degree=4)` = 36,864 B.
5. **Vocab-parallel ops** (embedding, lm_head gather, logsumexp, token logprob, argmax with ties) equal full-row fp64 references to 1e-12 at V = 99, 100 and 5.
6. **Loading**: shards loaded from single-file and 3-shard index snapshots, Qwen3 and Mistral, tp 2 and 4, are bit-identical to `tp_from_model`; a
fp32->bf16 cast per slice equals casting the full model. Bytes returned by safetensors per rank (recorded at the safetensors layer, independent of tp.py)
equal the rank's parameter bytes exactly; whole-tensor reads happen only for the replicated norms. **Peak anonymous RSS** materialising bf16 weights from a
490 MiB fp32 checkpoint (`-m slow`): unsharded loader **236 MiB**, one tp=4 rank **66 MiB** (file-backed mmap peak 178 / 183 MiB, reclaimable).
7. **Refusals**: non-divisible kv heads / heads / intermediate (all violations named), tp > kv heads, unknown parameter, extra / missing / wrong-shape tensor,
index naming an absent shard, unsupported `model_type`, out-of-vocabulary id (`IndexError`, where a naive sharded embedding yields zeros).
8. **HuggingFace parity on CPU** (`oracle`, `.venv-rollout`, transformers 5.19): the written tiny Qwen3 snapshot, 6 prompts x 40 greedy tokens fp32, no eos:
TP=2 and TP=4 tokens **identical to transformers' in 6/6** prompts. This is the CPU twin of the Goedel-8B GPU parity run below.
9. **bf16 drift** (tiny Qwen3, TP=4, vs unsharded): max |dlogit| 0.039 on logit scale 2.88 (1.4%, one bf16 ulp at that scale), identical with and without
`reduce_dtype=float32` - on this toy the all-reduce rounding is not the dominant term, which says nothing about 36 layers of a real model (GPU run).
10. **Defaults untouched**: no existing file edited; `test_paged_engine.py`, `test_paged_engine_bench.py`, `test_qwen3_parity.py`, `test_model_parity.py`,
`test_public_api.py`, `test_dependency_firewall.py`: 224 passed, 1 skipped (the existing OLMoE dense-only skip).
Full CPU suite once at the end (`-m "not gpu and not oracle and not slow"`, shared host, other items' edits in the tree): 2711 passed, 14 skipped,
1 failed - `test_moe_ep.py::test_ep_layer_forward_backward_equal_single_process_bitwise[4-4]` died with `EADDRINUSE` (a free-port race with a concurrent job on this
shared host) and passes alone (3 passed).
**Mutants** (monkeypatched INSIDE the spawned ranks; each oracle must fail, each has an unmutated control that passes):
| feature | mutant | how it dies |
|---|---|---|
| layers | no row-parallel all-reduce | logits wrong by 3.9 |
| layers | k_proj slice taken from the mirrored rank | wrong by 2.6 (GQA reads the wrong heads) |
| layers | up_proj slice from the mirrored rank | wrong by 3.4 |
| vocab | embedding: every id "mine" (clamped lookup summed tp times) | wrong by 3.7 |
| vocab | logits gathered in reversed rank order | wrong by 4.5 |
| vocab | padding columns not cut | gathered width != V (RuntimeError) |
| vocab | logsumexp with the LOCAL max | lse off by 3.5 |
| vocab | embedding range check removed | OOV id embeds silently (caught) |
| loader | `get_tensor` + narrow instead of `get_slice` | bytes read > 2x the shard's |
| loader | no copy: view of the full tensor (source AND `_own`) | storage oracle fails (either guard alone is still safe) |
| loader | `_own` removed on the safetensors path | CPU params pin the file mapping (caught) |
| loader | tensor-set check removed | extra tensor loads silently |
| engine | constant schedule fingerprint | schedule mismatch is not a `TPDesyncError` (gloo abort / hang-until-timeout) |
| engine | consensus all-gather removed | a wrong token on rank 1 goes through: both ranks return normally |
| engine | `_stable_seed` = pid | `TPDesyncError` (unseeded streams differ per rank) |
| engine | full-vocab shim removed | `add_request` refuses valid ids >= V/tp |
### Compute: Goedel-Prover-V2-8B on 4 x A10G
Run `.venv/bin/python tests/test_tp_bench.py --plan`. **Computed** from measured inputs (config parameter count; the vLLM probe's memory split in
`.tmp/p2/P2-B/smoke.log`; NCCL bandwidth in docs/MEASUREMENTS.md) except the rows marked ASSUMED.
* Per-rank weights, bf16: tp=1 15.26 GiB, tp=2 7.63, **tp=4 3.81 GiB** (2.048 B params; norms replicated, +0.3 M).
* The planner reproduces vLLM's TP=4 pool: weights 3.83 + activation 0.36 + non-torch 0.02 (CUDA graphs are charged after the pool) at util 0.9 of 22.06 GiB ->
**456,112 tokens vs vLLM's 455,888 (+0.05%)**, 11.14x at 40,960 (vLLM 11.13x).
* Native engine, bf16 KV, block 16; activation 0.5 GiB (padded-decode transient/tp + logits) and 0.5 GiB NCCL/context are ASSUMED, no CUDA graphs:
| tp | util | weights GiB | KV GiB | KV tokens | vs vLLM 455,888 | seqs @16k | seqs @40k |
|---|---|---|---|---|---|---|---|
| 1 | 0.90 | 15.26 | 3.60 | 26,192 | 0.06x | 1 | 0 |
| 2 | 0.90 | 7.63 | 11.23 | 163,472 | 0.36x | 9 | 3 |
| 4 | 0.90 | 3.81 | 15.04 | 438,048 | 0.96x | 26 | 10 |
| 4 | 0.95 | 3.81 | 16.14 | 470,176 | 1.03x | 28 | 11 |
(vLLM TP=4: 27 x 16k, 11 x 40k. The tp=1 row uses the same assumptions as the others; the earlier single-device section used 1.0 + 0.4 GiB, hence 23,248 tokens there.)
* **Link volume per rank, tp=4, bf16**: 73 all-reduces per step (2 x 36 layers + embedding), each `rows x 4096 x 2 B` and each moving 2(w-1)/w of that;
864 KiB of layer all-reduce + 12 KiB embedding per row; 223 KiB of logits gather per SAMPLED row.
* **Decode latency model** (lower bound: serial comm, no kernel-launch or Python overhead). Link: measured 4.0 GB/s all-gather algbw -> 3.0 GB/s bus bandwidth
(docs/MEASUREMENTS.md: NCCL over host SHM, 4 ranks, "it is the shared-memory transport's bandwidth, not latency"); HBM 600 GB/s (datasheet, ASSUMED);
**small-message collective latency: NO measurement exists on this box** - rows show 20 / 50 / 100 us (ASSUMED). `--mode allreduce` below measures it.
| batch | ctx | latency | weights ms | KV ms | all-reduce ms | gather ms | step ms | tok/s (decode-only) |
|---|---|---|---|---|---|---|---|---|
| 1 | 4096 | 20 / 50 / 100 us | 6.83 | 0.25 | 1.76 / 3.95 / 7.60 | 0.10-0.18 | 8.9 / 11.2 / 14.9 | 112 / 90 / 67 |
| 16 | 4096 | 20 / 50 / 100 us | 6.83 | 4.03 | 6.2 / 8.4 / 12.1 | 1.3 | 18.3 / 20.6 / 24.3 | 873 / 778 / 660 |
| 64 | 4096 | 20 / 50 / 100 us | 6.83 | 16.1 | 20.6 / 22.8 / 26.4 | 4.9 | 48.4 / 50.6 / 54.3 | 1322 / 1264 / 1178 |
| 64 | 8192 | 20 / 50 / 100 us | 6.83 | 32.2 | 20.6 / 22.8 / 26.4 | 4.9 | 64.5 / 66.7 / 70.4 | 992 / 959 / 909 |
Reading: batch 1 is latency-bound (73 collectives); batch 64 is bandwidth-bound on the 3 GB/s link (all-reduce 21-26 ms of a 48-54 ms step, ~40%) and then KV reads.
The decode-only ceiling of ~1,200-1,300 tok/s at batch 64 is NOT comparable to vLLM's measured 876 tok/s end-to-end (that includes prefill, the length tail and
Lean-length samples); the pure-torch engine will land well below its own ceiling (Python attention loop, no CUDA graphs).
* **`numerics='invariant'` under TP is a latency problem**: it issues the o_proj/down_proj/mlp calls per ROW, so a 64-row step makes 64 x 73 = 4,672 small
all-reduces (>= 93 ms at even 20 us each). Use `fast` for throughput and `invariant` only where bit-reproducible logprobs are needed (docs above).
### GPU validation plan (run only after the P2 chain, PID 733530, has exited; every step takes the heavy-job lock, so they serialise)
Preconditions (`/apps/CLAUDE.md`): `free -g; uptime; awk '$3=="nvme2n1"{print $12}' /proc/diskstats; ps -eo stat | grep -c '^D'; nvidia-smi` clear,
`pgrep -f chain_after_baseline` empty. Each job below is rank-0-locked (`--wait-lock` queues behind another holder).
```
cd /apps/development/rlhf/align
export HF_HOME=/apps/development/rlhf/hf_cache HF_HUB_DISABLE_XET=1 PYTHONPATH=$PWD TMPDIR=$PWD/.tmp/g3/tp \
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
M=/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a
G=.tmp/g3/tp/gpu; mkdir -p $G
# 0) the missing measurement: NCCL all-reduce latency (8 KiB = one decode row ... 64 MiB) and the logits gather, 4 ranks
torchrun --standalone --nproc_per_node=4 tests/test_tp_bench.py --mode allreduce --wait-lock --out $G/allreduce.json
# 1) vLLM TP=4 baseline = the throughput comparator AND the source of prompt_ids: 32 miniF2F problems x n=4, cap 16384
.venv-rollout/bin/python extras/vllm_rollout/lean_gen.py --problems .tmp/p2/problems/minif2f.jsonl --limit-problems 32 \
--out $G/vllm_tp4_cap16k.jsonl --n 4 --cap-tokens 16384 --round 0 --tp 4 --max-model-len 40960 --gpu-mem-util 0.90 \
--max-num-seqs 128 --seed 0 --wait-lock --label tp-vllm-cap16k
# 2) HuggingFace greedy reference, ONE GPU (fits: 16.4 GB), 8 prompts x 64 tokens, with the top1-top2 logit margin per step
CUDA_VISIBLE_DEVICES=0 .venv-rollout/bin/python tests/test_tp_bench.py --mode hf_ref --model-dir $M \
--prompts-jsonl $G/vllm_tp4_cap16k.jsonl --n-prompts 8 --new-tokens 64 --dtype bfloat16 --wait-lock --out $G/hf_ref.json
# 3) TP=4 parity vs HF, 64 greedy tokens (invariant numerics = the RL-safe mode), no eos stop (the reference never stops)
torchrun --standalone --nproc_per_node=4 tests/test_tp_bench.py --mode parity --model-dir $M --hf-ref $G/hf_ref.json \
--dtype bfloat16 --numerics invariant --no-eos --tie-margin 0.1 --max-num-seqs 8 --budget 512 --chunk 256 --wait-lock --out $G/parity.json
# 4) throughput: same prompt ids / T=1.0 / top_p=0.95 / cap 16384 / generation_config stop ids; KV pool = min free over ranks - 3 GiB
torchrun --standalone --nproc_per_node=4 tests/test_tp_bench.py --mode throughput --model-dir $M \
--prompts-jsonl $G/vllm_tp4_cap16k.jsonl --n-prompts 32 --g 4 --cap-tokens 16384 --max-seq-len 40960 \
--max-num-seqs 64 --budget 2048 --chunk 512 --numerics fast --reserve-gib 3.0 --wait-lock --out $G/native_tp4_fast.json
# and the consensus A/B: ... same command with --no-verify --out $G/native_tp4_fast_noverify.json
# 5) cost of bit-invariance under TP (slow: few prompts)
torchrun --standalone --nproc_per_node=4 tests/test_tp_bench.py --mode throughput --model-dir $M \
--prompts-jsonl $G/vllm_tp4_cap16k.jsonl --n-prompts 2 --g 4 --cap-tokens 1024 --numerics invariant --wait-lock --out $G/native_tp4_invariant.json
```
Acceptance. **Parity**: `n_bad == 0` (exit code 0): every divergence from HF's greedy stream sits where HF's own top1-top2 gap is below 0.1 logits (a bf16
near-tie), and report the median `first_divergence` (not "identical"). **Throughput**: compare `tokens_per_s_end_to_end`, `len_p50/len_max`, `finish_reasons`,
`kv_tokens` and `peak_cuda_mib_max_over_ranks` with the vLLM manifest (`$G/vllm_tp4_cap16k.jsonl.manifest.json`: `tokens_per_s_end_to_end`, `finish_length`,
`kv_cache.tokens`); the bar carried over from the single-device section is native >= 0.5x vLLM at this shape - if it is far below, the next step is the Triton
paged-attention kernel (and CUDA graphs), not more TP. Sampled streams differ by construction, so compare distributions, not tokens. **Replace the ASSUMED
latency column above with `allreduce.json`** (`all_reduce[0].us` is the 8 KiB decode-row latency) before quoting any decode estimate.
Things only a GPU run can show: NCCL `all_gather_into_tensor` / `broadcast_object_list(group_src=)` / the int64 consensus all-gather on CUDA tensors (gloo
passes; NCCL was not run), whether NCCL's all-reduce is bitwise identical across ranks at bf16 (the design needs only the gathered LOGITS identical, which an
all-gather guarantees, but identical residuals make `invariant` cleaner), the real step time vs the model above, and the `reduce_dtype=float32` effect over 36 layers.
### Open items
1. **Not a `RolloutBackend`**; `TPPagedEngine` is not in `align.rollout`'s lazy exports (that module is outside this item). Same adapter work as the single-device engine.
2. **GPU/NCCL path unrun** (see above). The runner's every mode is exercised on CPU/gloo (`tests/test_tp_runner.py`), so a typo should not survive to the GPU.
3. **`invariant` + TP issues per-row collectives** (4,672 all-reduces for a 64-row step). Batching the all-reduce would keep bit-invariance only if NCCL's
per-element reduction order is independent of message size/algorithm (it picks the algorithm by size); pin `NCCL_ALGO`/`NCCL_PROTO` and test, or keep `invariant` for small RL batches.
4. **Greedy fast path unrouted**: `vocab_parallel_argmax/_token_logprob` would cut the logits gather (223 KiB -> 16 B per row) for greedy requests; the engine
still gathers the full row. Worth it only if the measured gather time (model: ~5 ms at 64 rows) shows up.
5. **tp > n_kv_heads** (kv-head replication) and **MoE/OLMoE TP** are not built. 8B at tp=8 would work (1 kv head per rank) but this box has 4 GPUs.
6. **Per-rank KV is bf16**; fp8 KV (with per-tensor scales, see the single-device section) would double the pool again.
7. **The 2 consensus all-gathers cost 8-18% of a toy gloo step**; GPU cost to be measured with `--no-verify`.
8. Ranks must be fed identical requests in identical order; `generate` enforces it (rank 0's list is broadcast), direct `add_request` callers must do it themselves
(a miss is caught at the next schedule check, not silently absorbed).
### Review findings from building and testing this (all fixed, each pinned by a test)
* gloo's `all_gather_into_tensor` wants a flat rank-major output buffer, not `[world, ...]` (first parity run died on a size mismatch).
* safetensors slices are views whose storage spans the whole source tensor; kept as parameters on the CPU path they pin the full file mapping (found by the
storage oracle, fixed by `_own`); a dim-0 `narrow` of an in-memory tensor has the same defect, so `StateDictSource` copies.
* `PagedEngine.add_request` validates ids against `embed.weight.shape[0]`, which is V/tp for a sharded embedding: `TPPagedEngine` presents a full-vocabulary shim.
* `PagedEngine(return_logits=True)` stores float32 rows, which caps any logit parity check at ~6e-8; the fp64 oracles capture the row at `sample_row` instead.
* A test fixture's `model.to(torch.bfloat16)` is in place and silently turned a shared fp32 model into bf16 for every later test (caught by a byte-count bound).
* Mutants that desynchronise gloo can abort a rank in C++ (`Is there a distributed collective mismatch in your code?`) or hang to the timeout: the harness treats that as a kill for mutants only, never for controls.
|