align / code /docs /P3_ENGINE.md
viai957's picture
Add files using upload-large-folder tool
d909bd8 verified
|
Raw History Blame Contribute Delete
42.7 kB

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 PYTHONHASHSEEDs). 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.