# 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.