Download code/docs/P3_ENGINE.md from viai957/align: direct link, hf CLI and curl.
- Browser
- Download file 42.7 kB
-
https://huggingface.co/viai957/align/resolve/main/code/docs/P3_ENGINE.md
- Command line
-
hf download hf://viai957/align/code/docs/P3_ENGINE.md
-
curl -L -o P3_ENGINE.md https://huggingface.co/viai957/align/resolve/main/code/docs/P3_ENGINE.md
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.freeon refcount 0 raises (a double free would put one block on the list twice and give it to two sequences).check_consistent()provesfree + referenced == total, no duplicates.PagedKVCache: per-layer[num_blocks, block_size, Hkv, D]K and V; block tables; copy-free append (writescatters into(block, offset)in place).ensure_capacityis 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 thePagedAttentionprotocol (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:
_ModelViewdrives 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 schedulesn_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 oftokens. 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, textstop(needs a tokenizer), plus engineeos_token_idandmax_seq_len. Onetorch.Generatorper sequence, advanced only by its own draws. Unseeded requests hash the request id (not admission order). The returned logprob islog_softmax(logits/T)[tok]untruncated, the same basis asnative.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.
- 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.Engineand toNativeRolloutBackendat temperature 0 (fp32). - 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) andmax_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.fastvsinvariant: 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 betweenfastandinvariant(tokens identical in 400 randomized configs, tolerance 1e-5); a near-tie in expert routing is therefore a placefastcan flip. - 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_distributionequals an independent fp64 reference (top-k then minimal-nucleus top-p) to 1e-6 with identical support size. - 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 and100 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.
- 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: useinvariantwhenever the logprobs feed the rho correction or a replayable run;fastis 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_degreedivides 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
- Not a
RolloutBackendyet. APagedRolloutBackend(samesubmit/collect,prompt_token_ids/logprob extras, G-sample expansion withprefix_sharing=True) is a small adapter onrun_compare's request building; deferred becauseclient.py/native.pyare outside this item's list. - Single-device only (no TP, no fp8 KV): see the consequence above.
- 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 orinvariantbecomesfast. - Multi-turn masks / tool-turn continuation (ROADMAP P3) are not implemented: the engine supports
add_requestof an extended token list (prefix sharing makes re-prefilling the transcript cheap) but has no turn-boundary loss mask output. - 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.
invariantis ~4-5x slower thanfaston CPU and has not been timed on GPU; a batch-invariant GEMM (fixed tile/split-K per row) would remove the per-row calls.OlmoeAttention/MoE paths are exercised on tiny fp64 models only; expert-parallel modules (moe_ep) are not supported.- Greedy fidelity of
fastmode 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 laterstep()re-raised (on CUDA it is a device-side assert). Refused atadd_request. - Greedy NaN logits were silent:
argmaxof a NaN row returns the NaN's index andlog_softmaxis NaN, so a bf16 overflow became a plausible token with a NaN logprob in the rollout (sampled rows already raised insidemultinomial).step()now raisesFloatingPointErrorbefore any sequence advances (retryable; -inf stays legal). The check also synchronises, soforward_secondsnow includes GPU time instead of kernel-launch time only (steps with no sampled row still do not sync). kv_dtypecrashed 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 twoPYTHONHASHSEEDs). 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_confignow supplies the set. - Mutation gaps closed:
stop_token_idswas untested (the older stop test'sorpassed with the feature broken); the "truncated logprob" mutant never ran engine code (now swaps the engine'ssample_rowand checks logprob == log_softmax(logits/T) through the whole engine); a batchedlm_headstayed 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:
fastpads 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 ismath, 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.0above. Readpeak_cuda_mib. PagedEngineConfig.max_seq_lenmay exceed the model'smax_position_embeddingswithout 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/NmoMistralForCausalLMbuilt with a LOCAL config (heads, kv heads, intermediate divided by tp) and five module types swapped. The dense forward,_head_norm/_swigluseams, RoPE, RMSNorm and the GQA map are the original code, andPagedEnginedrives the shard through its own submodules unchanged. Consequence: the paged KV cache is sharded by kv head for free (_ModelViewreads the localn_kv_heads; Qwen3-8B at tp=4: 8 -> 2) and per-rank KV bytes are exactly 1/tp (tested). - GQA. Blocked map
q_head // groupstays local and canonical iff tp dividesn_kv_heads. tp > n_kv_heads would REPLICATE kv heads (vLLM's choice); that layout is refused (TPErrornaming 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 / _argmaxare the two-scalars-per-row alternative for greedy decoding and teacher-forced scoring (verified against the full softmax, ties to the lowest index liketorch.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;
generatetakes 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_seedis sha256, nothash()). 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:_scheduleagrees a fingerprint of the step (ids,num_computed,n_new, the fed token ids) BEFORE any model collective, andhas_unfinishedagrees the loop predicate plus a hash of the previous step's events. Symmetric (all-gather), so a divergence raisesTPDesyncErroron ALL ranks.verify=Falsedrops 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-verifyfor the A/B. - Weight loading, bounded host RAM.
load_tp_model(snapshot, tp, dtype, device)reads per tensor ONLY this rank's slice viasafe_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_modelshards an in-memory model (copies, never views) for tests and weight refresh. - Inference only:
TPGroup.all_reduceraises on a tensor that requires grad.reduce_dtype=torch.float32accumulates 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).
- 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.
- Engine = TP=1 engine.
TPPagedEngine(requests given on rank 0 only) vsPagedEngineon 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 atsample_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. - 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 withmax_num_seqs=3): tokens, logprobs and every step's logits bitwise equal, TP=2 and TP=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. - 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.
- 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). - 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). - 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. - 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). - 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 withEADDRINUSE(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 Band 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 allreducebelow 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). Usefastfor throughput andinvariantonly 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
- Not a
RolloutBackend;TPPagedEngineis not inalign.rollout's lazy exports (that module is outside this item). Same adapter work as the single-device engine. - 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. 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); pinNCCL_ALGO/NCCL_PROTOand test, or keepinvariantfor small RL batches.- Greedy fast path unrouted:
vocab_parallel_argmax/_token_logprobwould 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. - 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.
- Per-rank KV is bf16; fp8 KV (with per-tensor scales, see the single-device section) would double the pool again.
- The 2 consensus all-gathers cost 8-18% of a toy gloo step; GPU cost to be measured with
--no-verify. - Ranks must be fed identical requests in identical order;
generateenforces it (rank 0's list is broadcast), directadd_requestcallers 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_tensorwants 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-0narrowof an in-memory tensor has the same defect, soStateDictSourcecopies. PagedEngine.add_requestvalidates ids againstembed.weight.shape[0], which is V/tp for a sharded embedding:TPPagedEnginepresents 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 atsample_rowinstead.- 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.