Skip to content

perf(trainer): DeepSeek-V4.1 FP8 experts, engram all-to-all EP, pipeline parallelism with one-sided NCCL transport - #3885

Draft
samsja wants to merge 48 commits into
feat/deepseek-v41from
feat/deepseek-v41-perf
Draft

samsja wants to merge 48 commits into
feat/deepseek-v41from
feat/deepseek-v41-perf

Conversation

@samsja

@samsja samsja commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Performance work for DeepSeek-V4.1 Flash, stacked on #3879 (base feat/deepseek-v41).

Changes

  • DeepEP works under activation checkpointing: the communication buffer is now built when the dispatcher is created. Built lazily inside the first checkpointed forward, it shifted the recompute's cached ops.

  • Single-pass DeepEP local layout: one Triton gather into the padded expert-grouped layout and one fp32 score-weighted gather-sum back, with gathers in backward too (models/kernels/moe_permute.py). This replaces about 7 full copies of the routed activations per layer.

  • DeepEP in the 12-node example: 9.3 s -> 5.6 s per step.

  • Hopper kernels from prime-kernels, each used only when available:

    • V4.1's indexer uses dsa_indexer_topk, and its mHC uses mhc_projection.
    • model.moe.compute.backend = "prime_kernels" runs moe_experts.
  • FP8 routed experts (model.moe.compute = {type = "deepgemm_fp8", backend = "prime_kernels"}): DeepGEMM blockwise FP8 grouped GEMMs (forward, dgrad, wgrad). The quantization and clamped SwiGLU are fused into the passes around them, via prime-kernels' moe_experts(fp8=True) (feat: Hopper DeepSeek-V4.1 training kernels (indexer top-k, MoE experts bf16/FP8, mHC, sparse attention bwd) prime-kernels#15). At V4.1's EP=96 shape this is ~1.4x faster than bf16. The existing DeepGEMM path is no faster than bf16, because its standalone quantization passes eat the gain.

  • fp8.toml overlay for V4.1: FP8 dense linears plus FP8 experts. The 512-wide KV projections and the indexer's query projection stay in bf16; measured in FP8, they come out at 0.81x and 0.99x of bf16 speed.

  • Cheaper FP8 casts for dense linears: each cast takes one reciprocal per group with an FMA correction instead of an exact divide per element, and is exhaustively bit-identical. The transposed cast pads the token dimension itself, so there are no F.pad copies. Scales are written in DeepGEMM's layout, which removes its scale-transpose kernels. About 5% less GPU time per FP8 linear.

  • Sparse attention backward: uses prime-kernels' Hopper dsa_sparse_attn_bwd when it is available (~1.2x cuDNN), and falls back to cuDNN otherwise. The gain is within noise end to end, because the saved time lands in DeepEP waits.

  • optim.apply_in_backward (SignSGD, SFT): applies the update to each parameter as soon as its gradient is reduced, then frees the gradient. It is exact, because sign updates ignore the loss scaling. Peak memory drops from 117.6 to 96.9 GiB at the same step time. Spending that memory on 24k tokens per GPU fits but is slower per token (242k vs 330k tokens/s).

  • Copy-free grouped o_a_proj (V4 and V4.1): one GEMM per group on strided slices, instead of a batched matmul over a transposed view that copied the output in forward and the gradient in backward. Bit-identical, and 1.23x faster fwd+bwd.

  • Fix: FP8 / MXFP8 linear conversion now swaps only plain nn.Linear. Subclasses with their own forward (V4's block-diagonal o_a_proj) were silently replaced and broke DeepGEMM downstream.

  • Engram: the tables are plain nn.Embeddings, row-sharded by a new AllToAllEmbeddingParallel style next to EmbeddingParallel. Deduplicated ids go to their owners and only those rows come back. RowwiseParallel would instead reduce-scatter ~19 GB of partial rows per table per rank at 96 GPUs. The custom table class is gone from the model.

  • DeepEP as one custom op (prime_rl::deepep_moe):

    • Forward and backward are pipelined over token chunks, with communication outside autograd.
    • Activation checkpointing saves the op's outputs, so recompute neither resends tokens nor reruns experts.
    • Parity with the torch dispatcher is ~3e-3 relative on outputs, input and weight gradients, with and without chunking.
    • It does not speed up a 12-node step (see below), but chunking no longer costs anything.
  • Optional FP8 token dispatch ([model.moe.dispatch] fp8 = true, DeepEP with deepgemm_fp8 experts): tokens are quantized per 1x128 block with power-of-two scales before the forward dispatch, then dequantized exactly on arrival to the bf16 the experts quantize again. Forward dispatch traffic and the received tokens kept for backward halve; gradients stay bf16. In a microbenchmark at the trainer's shapes (64 ranks) a dispatch takes 7.5 ms instead of 14.2 ms. End to end: 4.8 s -> 4.7 s per step and 4 GiB less peak memory, with the loss curve unchanged within noise.

  • In-place RoPE for V4.1's query and for the gradient of the attention output: only the 64 rotary channels of each 512-wide head are read and written, instead of copying the whole head (6.3x faster per call at 16k tokens, bit-identical). Within noise end to end: like the faster attention backward, the saved compute turns into DeepEP waits.

  • Fix: DeepEP's local experts run in row slices when routing sends a rank more token-expert pairs than prime-kernels' FP8 path addresses (32-bit offsets, ~419k rows at V4.1's width). Before, that rank raised and every other rank hung in DeepEP until its GPU timeout.

  • Pipeline parallelism (model.pp, pp_schedule, pp_stages_per_rank, pp_layers_per_stage): stage build in model.py, trainer/pipeline.py (pooled receive buffers, an async 1F1B schedule, a no-grad shape probe for static shapes), prune_to_pipeline_stage / pipeline_stage_forward for V4.1, PP-aware data ranks and grad clipping across the PP group. Losses match pp = 1 exactly.

  • Kept expert activations (model.moe.dispatch.keep_expert_activations): the routed experts' activations are kept for backward instead of recomputed (−5.8% step on a 5-layer EP64 proxy, +3.2 GB per layer).

  • FP8 expert weights quantized once per optimizer step, and FP8 weight gradients accumulated straight into FSDP's fp32 buffer (prime-kernels moe_experts_fp8_quantize_weights / moe_experts_fp8_backward_accumulate): +8.7% and +9.1% on a PP2×4 proxy.

  • dispatch.weight_grads_after_combine: each chunk's gradient is sent back before its weight-gradient GEMMs run, so those GEMMs hide the send. Bitwise-identical gradients; −2.5 ms per layer at EP48.

  • moe.reduce_local_expert_grads_once: when each expert FSDP group is a single rank, its per-micro-batch FSDP work (cast, reduce copies) runs once per step. −4.2 ms per layer at EP48; same up to fp32 summation order; ~0.65 GiB more peak memory per layer.

  • dispatch.fp8_grad (off by default, changes numerics): FP8 backward dispatch, halving its bytes. −2.5 ms per layer at EP48.

  • Spread balanced routing: debug.force_balanced_routing now strides experts by 97, so a token's 6 experts land on ~4–5 nodes like the real router. The old round-robin sent every token to one rank, which hid nearly all inter-node traffic.

  • prime_rl::dsv4_linear_rope: V4.1's query projection and its RoPE as one op that rotates the projection output in place and keeps no unrotated copy for backward. Bitwise identical; −0.46 ms and −1 GiB per layer. It is in the default selective-AC save list; runs passing an explicit --model.ac.targets must add it. RoPE, mHC and q-norm ops are also saveable by selective AC.

  • mHC stream-gradient sum folded into prime-kernels' projection backward when the installed build has hyper_connection_streams (bitwise identical, −0.47 ms per layer); otherwise the old path.

  • Fused Triton cross-entropy gradient in the chunked LM head: 131.7 -> 96.1 ms per 16k tokens, exact.

  • dispatch.type = "mega_moe" (off by default): prime-mega-moe's fused BF16 Hopper Mega MoE as a backend (distributed/mega_moe.py), one custom op with gradients for x, top-k weights, routed and shared expert weights. dispatch.num_sms caps its SMs: at the full count it deadlocks against FSDP's NCCL kernels. On one node it is slower than DeepEP + FP8 experts (97–105 vs 80.3 ms per layer), because the backward recomputes the forward and it is BF16.

  • debug.random_init draws per-parameter seeded N(0, 0.02). Before, it materialized zeros and kernels ran 1.5–3.5x too fast. Setup order is now AC -> FSDP -> compile.

Pipeline-parallel push (PP16 x EP8 plan of record)

  • MFU is 6ND only: perf.py drops the attention term; FLOPs per token = 6 x active matmul params (LoRA keeps its 4/2/6 split). PerfCounter no longer takes seq_len.
  • Async pipeline executor (AsyncPipelineSchedule): runs a precomputed op order and never waits on a stage transfer before it is needed. Schedules Async1F1B (with pp_warmup_step) and DualPipeV. pp_layers_per_stage takes multiples of 0.5, so a stage boundary can cut a V4.1 layer between its attention and MoE blocks. Sent outputs that no backward reads are freed early. Grad clipping and the loss broadcast handle several stages per rank and V schedules. Schedule-order unit tests are added.
  • Stage transports (pp_transport): nccl (default), copy_engine, and put. put uses one-sided ncclPutSignal / ncclWaitSignal on zero-CTA 2-rank communicators, so a transfer in flight holds no SMs and no longer slows the persistent kernels. It needs NCCL >= 2.30, and nvidia-nccl-cu13 is now overridden to 2.32.3. pp_transport_ctas and pp_gemm_sms pin SM budgets for nccl.
  • PP activation offload (pp_activation_offload, default off): keeps in-flight micro-batches' saved activations in pinned host memory between forward and backward. target = "fp8" instead keeps them on the GPU as FP8 with 1x128 scales. That changes numerics, is unverified, and measured slower than fp8t.
  • 1F1B-W (model.pp_defer_expert_weight_grads, default off): FP8 Mega MoE weight-gradient GEMMs run after the stage posts its input-gradient send. Same GEMMs, order and accumulators. On 6 nodes, steps 1–3 are bitwise and later steps are within FP8 noise. +1–3 GB.
  • Master offload (model.pp_offload_masters, default off; functionally validated; memory/step cost unvalidated, needs a run with expert DP > 1; keep default off): the sharded fp32 masters stay in pinned host memory between a step's first and last forward. The reduced-grad pool is released after the optimizer step. Frees 1.13 GB per MoE block at D=12; same values.
  • PP activation offload v2 (functionally validated; memory/step cost unvalidated, needs a run with expert DP > 1; keep default off):
    • Reloads allocate from the compute stream's pool.
    • every (default 1) offloads one micro-batch in k.
    • paced (default off) throttles warmup forwards to the swap-outs.
  • Validation status: 1F1B-W was validated on 6 nodes (bitwise for steps 1–3, then within FP8 noise). The wgrad-operand pool change is bitwise. The per-half AC modes were unit-checked. The PutEdge cleanup was parity-checked on 6 nodes (identical losses, per-stage times within 1 ms).
  • fp8_transposed_on_demand also accepts a list of pipeline stages. model.ac.layer_modes accepts [attention, MoE] pairs for layers that a stage boundary cuts. PutEdge checks for NCCL >= 2.30 at runtime. The FP8 Mega MoE wgrad operand is allocated from the compute stream's pool (bitwise).
  • FP8 Mega MoE (moe.dispatch.fp8, default off): prime-mega-moe's SM90 FP8 kernels for the routed experts. The forward keeps FP8 inputs and the bf16 gate/up output, so the backward does not recompute the forward. Weight gradients go through DeepGEMM K-grouped FP8 GEMMs into FSDP's fp32 accumulators. Sub-flags, each default off:
    • capacity_factor
    • wgrad_tile_scales (changes numerics)
    • free_bf16_expert_weights (same numerics; −3.4 GB per layer at EP8)
    • fp8_transposed_on_demand (bitwise; −1.7 GB per layer, ~0.95 ms per MoE backward)
  • Dense FP8 wgrad into fp32 (model.quantization.accumulate_wgrad_fp32, default off; 250-step study: within run-to-run noise): each dense FP8 linear's weight-gradient GEMM writes straight into FSDP's fp32 accumulator. This removes the bf16 rounding per micro-batch. −0.52 ms/layer on a 6-layer EP8 stage bench. Before a reducing post-backward, the group's other gradients are upcast to fp32, so FSDP's reduce-scatter sees one dtype.
  • Fused FP8 shared expert (always on, bitwise): V4/V4.1 FP8 shared experts run their gate and up projections as one FP8 GEMM, with a Triton clamped-SwiGLU kernel that also emits the down projection's FP8 input and a backward kernel that writes the dgrad/wgrad FP8 operands directly. The routed and shared outputs are added inside the mHC write-back kernel.
    • Evidence: on 1 GPU, outputs and all gradients are torch.equal under every AC mode. On 8 GPUs, the stage forward and the shared-expert fp32 grads are equal.
    • −0.66 ms/layer. Not yet run inside a PP job.
  • Fused expert wgrad across micro-batches (moe.dispatch.fused_wgrad_micro_batches, FP8 Mega MoE; default 1 = today's behaviour; recommended 2 on H200): n > 1 holds a layer's wgrad operands, then adds n micro-batches with one K-grouped GEMM launch per weight.
    • The int can instead be a per-stage list.
    • Bitwise equal to one launch per micro-batch.
    • +1.17 GB per MoE layer per held micro-batch, which is a net loss at 80 GB.
    • Track B validated it in PP on 6 nodes: losses equal within FP8 noise, −2.0% cycle at n = 2.
    • Required prime-mega-moe commit: 5827a5b (branch feat/sm90-fp8-mega-moe-v2, also on kern2/moe), which includes b6460ed and 125a11a. _C must be rebuilt for b6460ed.
      • b6460ed prefetches the fp32 accumulator tile into L2 and uses 128x144 dW2 tiles: bitwise, −0.55 ms/layer at n ≥ 2.
      • 5827a5b orders the FP8 Mega MoE forward and backward tasks expert-major whenever no expert exceeds 16 M blocks (checked per launch), so each expert's weights are read about once. It is bitwise and saves −0.53 ms/layer on top of b6460ed. It applies to the default path too, not just n ≥ 2. prime-mega-moe is not a declared dependency (it is supplied as prime_mega_moe out of band), so that commit is not pinned in pyproject.toml.
  • Sparse-attention slot list built once per index source (always on, bitwise): V4.1's index-source layer stores the window + top-k int32 slot list in the shared attention state, and consumer layers reuse it instead of rebuilding it in every forward and recompute. ~−0.11 ms/layer of GPU time, within noise in an untraced stage A/B. Pipeline boundaries now carry the int32 slot list instead of the int64 picks.
  • Experimental: single-stream residual for V4.1 (residual_type = "mhc" | "gated" | "layerscale" on the V4.1 config; default "mhc", bitwise unchanged; quality ablation pending): gated is mHC's post gate at hc_mult = 1, run by a fused Triton op (prime_rl::dsv41_gated_residual, a default selective-AC save target). Not checkpoint-compatible with DeepSeek's mHC gates.
    • −5.4% per layer.
    • Stage transfers drop from 335 to 84 MB.
    • 250-step smoke test: within noise of mHC. layerscale was worse.
  • One-pass FP8 dual cast for FP8-linear gradients (always on, bitwise; falls back to Triton): with prime-kernels' fp8_dual_cast, grad_output's per-token and transposed casts take one pass, and the q_b gradient's inverse RoPE is fused into it. −0.17 ms/layer (cast kernels 0.93 → 0.77 ms) once prime-kernels has fp8_dual_cast (prime-kernels 2ef8b4c, PR update qwen #15).
  • Activation-checkpointing levels: model.ac.mode adds full_moe (also recomputes the FP8 Mega MoE), projections, attention and matmul. model.ac.layer_modes sets a mode per layer, indexed by full-model layer so it works across pipeline stages. The default (full) is unchanged.
  • Engram:
    • Fix: the gate multiplies q_weight * k_weight in fp32, as DeepSeek's reference does. This changes numerics toward the reference.
    • The gate's wkv projection is no longer checkpointed by default. model.ac.recompute_engram (default off) restores the recompute on full / full_moe layers. This keeps 51 KB/token more per engram layer than before.
    • Lookups are routed ahead of the forward.
    • Later micro-batches add table rows straight into the gradient (fp32 summation order differs from autograd's).
    • model.engram_offload (default off, prototype) keeps the table shards and their optimizer state in host memory. Checkpointing offloaded tables is not implemented.
  • Optimizer:
    • One-pass Triton SignSGD update, bitwise with the torch ops.
    • optim.embedding_update for Muon (default off): V4.1's parameter split, with the Sinkhorn-balanced momentum update for embeddings, LM head and engram tables (MuonWithSinkhorn).
  • DeepSeek Muon recipe (optim.recipe = "deepseek", default "dion" unchanged; requires embedding_update): Muon as in the V4/V4.1 reports. It uses Nesterov momentum, 10 hybrid Newton-Schulz iterations in bf16, update RMS 0.18 and AdamW eps 1e-20. Each expert, query head, o_a group, engram wkv K/V and mHC mix is orthogonalized as its own matrix. The engram gate q/k weights are treated as norm weights with weight decay.
    • Distribution: each expert is orthogonalized on its expert-DP shard owner. Dense matrices go to owner ranks via a bf16 all-to-all, overlapped with the local work.
    • Evidence:
      • 11/11 GPU tests, including Sinkhorn.
      • The 8-GPU sharded step is bitwise equal to a single GPU.
      • A 250-step proxy is within run-to-run noise.
    • Cost:
      • Optimizer step ~150 ms per 43 s step.
      • +4 B/param of momentum.
      • Triton Newton-Schulz is off by default, because it kept gradients alive.
  • Kernels:
    • The mHC post backward is one pass.
    • dsv41_sparse_attn_rope fuses V4.1's sparse attention with the in-place inverse RoPE of its output, keeping only the rotary channels the backward needs.

Required kernel commits

Known numerics

  • Default block compile on AC-none layers fuses the shared-expert SwiGLU without bf16 rounding. That is more accurate against fp64, but it differs from eager.

Pipeline results (8-node emulation of PP16 stages 0–7, 8k tokens/GPU/micro-batch, M = 320, 1F1B)

MFU is 6·N·D (16.13B active, 989 TFLOP/s). Days are for 20T tokens. All rows are measured on the emulation and extrapolated with the measured-cycle method.

setup MFU days 1,536 GPUs days 1,024 GPUs peak mem/GPU
H200, pp_transport = nccl 28.9% 51.0 76.4 115.8 GB
H200, pp_transport = put 34.0% 43.4 65.1 117.5 GB
80 GB cap, put 31.6% 46.7 70.0 79.5 GB
80 GB cap, put + fp8_transposed_on_demand 31.6% (full PP16: +1.2 pt over the 30.4% projected baseline) 46.7 70.0 78.7 GB

With put, the pipeline cycle drops by 24.3 ms (14.9%) on H200. The step-10 loss and grad norm match nccl.

Results with spread routing (16k tokens/GPU, random init, selective AC, FP8, FP8 dispatch)

Per-layer cost is the marginal between two layer counts of reduced proxies; the 40-layer projection adds it to the measured step. MFU is 6·N·D with 16.13B active params at 989 TFLOP/s.

layout per layer 40-layer step MFU days for 20T on 1k H200
EP64, 8 nodes ~104 ms ~4.3 s ~37% ~62
EP48, 6 nodes, + weight_grads_after_combine, reduce_local_expert_grads_once, fp8_grad 96.7 ms — 39.4% ~57.6
EP8, 1 node, 64-expert proxy (no InfiniBand: the ceiling with ideal inter-node communication) 80.3 ms 3.39 s 47.3% ~48
  • EP64 is InfiniBand-bound: ~150 GB per GPU per step against ~143 GB at the target step time, and the forward DeepEP kernels already run back to back at ~46 GB/s.
  • PP4×EP8 on 8 nodes: 23–28% MFU, from the bubble, torch 1F1B running adjacent stages in lockstep, and stage imbalance.

The 12-node table below predates spread routing and the random-init fix, so it understates communication.

Earlier results (12x8 H200, 16k tokens/GPU, real weights, fake data, round-robin routing)

config step tok/s
#3879 (torch EP, overlap_chunks = 2) 9.3 s 170k
+ DeepEP, single-pass layout, prime-kernels kernels (bf16) 5.65 s 278k
+ FP8 experts 5.1 s 308k
+ FP8 experts + FP8 dense (fp8.toml) 4.9 s 321k
+ cheaper FP8 casts, copy-free grouped linear, bf16 KV/indexer projections 4.8 s 327k
+ FP8 token dispatch (dispatch.fp8 = true) 4.7 s 334k

These results use fake.toml, which also forces round-robin expert routing (force_balanced_routing). With the real router the 12-node config does not fit yet: on real math data (INTELLECT-3-SFT-10K) a rank runs out of memory in the first step. On 2 nodes with the first 4 layers, real data gives a max expert load ~20x the mean (Max Vio 18-20) and 1.1-2.0 s steps against 0.72 s with forced balancing.

  • FP8 numerics: FP8 shifts the loss curve on this fake-data run. For example, at step 5 the FP8 run is at 0.256 against 0.183 for bf16. That's why it is an overlay, not the default.
  • Where the time goes (trace of the bf16 config, rank 0): compute stream busy 4.47 s of 5.72 s.
    • Dense GEMMs: 1.71 s. Microbenchmarks put every large V4.1 projection at 600–720 TFLOP/s.
    • Expert GEMMs: 1.45 s.
    • Sparse attention: 0.65 s.
    • Glue: ~1 s.
    • Waits on DeepEP: ~1.2 s idle.
  • Why chunking does not hide DeepEP:
    • With 2 chunks, idle drops to 0.85 s only if the persistent expert GEMMs leave DeepEP its SMs, and that costs as much compute as it saves.
    • Each chunk adds a cross-rank cached_notify barrier.
    • Expert load is balanced across ranks (expert GEMM time 1,433–1,465 ms), so it is not skew.
  • Ruled out:
    • Disabling activation checkpointing: ~8 GiB per layer at 16k.
    • Fusing the small projections: ~20 ms per step.
    • Tuning DeepEP's send chunk sizes: the best of 144 configs is within 2% of the built-in ones.
    • EP 8 with torch all-to-all instead of EP across all GPUs: 2.5x slower on a 10-layer proxy, because FSDP then moves each GPU's 48 experts per layer every step (~13.6 GB per layer per GPU).
    • EP 8 with DeepEP's NVLink-only kernels: same result (2.8 s vs 1.1 s for EP 32 on the 10-layer proxy). Token traffic inside a node is cheap (~2.4 ms per dispatch at V4.1's shapes); the expert FSDP traffic is not.
    • Selective activation checkpointing at 40 layers: OOM with the default targets; saving only the sparse-attention outputs fits at 132.5 GiB but is slower (4.9-5.0 s).

Tried and parked: two-batch overlap

Branch exp/deepseek-v41-tbo (not part of this PR).

How it works:

  • Each packed row splits at its middle document boundary into two micro-batches that move through every layer together.
  • DeepEP is split into launch / experts / combine-wait ops whose backward roles mirror the forward, so the backward transfers overlap too.

Results:

  • It is correct: losses match.
  • Two 8k documents per row, bf16: 5.66 s -> 5.48 s, and only once the expert GEMMs are capped to leave DeepEP its 20 SMs. A cuBLAS SM carveout on top makes it slower.
  • FP8: 4.95 s -> 5.03 s, even with DeepGEMM capped.

Why so little: in the trace, DeepEP's internode kernels start a median of 12–16 ms after launch, because they need all their SMs co-resident and wait behind persistent or cooperative GEMMs.

Testing

  • 12-node runs above.
  • tests/unit/test_configs.py (203 passed).
  • prime-kernels tests (52 passed).
  • DeepEP vs torch dispatch parity on 2 GPUs under activation checkpointing.
  • Op-level parity for dsv4_linear_rope (bf16 and FP8; eager, compiled, SAC), the mHC stream-gradient path, weight_grads_after_combine and the Mega MoE backend; prime-kernels mhc_projection tests (16 passed).
  • Run on this push (CPU node, no GPU), on the pushed tree:
    • ruff check and ruff format --check on every changed file: clean.
    • tests/unit/test_configs.py, tests/unit/train/test_perf.py, tests/unit/train/test_pipeline_orders.py: all passed (test_configs, test_pipeline_orders, test_checkpointing and test_deepseek_v4 rerun after the latest commits; test_deepseek_v4's 8 GPU-only tests fail here with no NVIDIA driver, the same 8 as on the base).
    • tests/unit/train/models/test_checkpointing.py: 2 failures, with the same 2 on the base commit. prime_rl::fp8_indexer is not registered without a GPU.
    • tests/unit/train/test_sinkhorn.py and test_muon.py: GPU-only, so they fail here (no NVIDIA driver). Track optim ran them on H200: 11/11 pass.

🤖 Generated with Claude Code

samsja and others added 10 commits October 5, 2026 04:30
…vation checkpointing

- Build DeepEP's communication buffer when the dispatcher is created. Built lazily inside the
  first checkpointed forward, it shifted the recompute's cached ops ("Autograd not support
  dtype: Byte").
- Replace the local permute chain around DeepEP (index_select, vstack + gather, index_put, fp32
  score passes, scatter_add) with one Triton gather into the padded expert-grouped layout and one
  fp32 gather-sum back, weighted by the routing scores. Backward is also gathers: deterministic,
  no atomics.
- Size DeepEP's RDMA queue pairs from num_sms so num_sms > 24 works internode.
- 12-node V4.1 16k example uses DeepEP: 9.3 s -> 5.6 s per step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en available

- V4.1 indexer: `dsa_indexer_topk` (fused fp8 scoring + top-k + candidate blocks) replaces the
  chunked indexer when built for the GPU and shape.
- V4.1 mHC: `mhc_projection` fuses the projection, gates and stream collapse.
- `model.moe.compute.backend = "prime_kernels"`: `moe_experts` grouped GEMMs with the clamped
  SwiGLU fused in.

Each falls back to prime-rl's implementation when prime-kernels does not provide it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oAllEmbeddingParallel

V4.1's engram module now holds a plain nn.Embedding. The trainer row-shards it over the FSDP
mesh with a new AllToAllEmbeddingParallel style next to EmbeddingParallel: deduplicated ids go to
their owning ranks and only those rows come back, instead of RowwiseParallel's all-gather of every
rank's ids and reduce-scatter of partial rows for all of them (~19 GB per table per rank at 96
GPUs). The tables stay out of FSDP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Subclasses with their own forward (DeepSeek-V4's block-diagonal grouped o_a_proj) were replaced by
plain low-precision linears, computing the wrong product and failing in DeepGEMM downstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`model.moe.compute = {type = "deepgemm_fp8", backend = "prime_kernels"}` runs DeepGEMM's blockwise
FP8 grouped GEMMs (forward, dgrad, wgrad) with the quantization and clamped SwiGLU fused into the
passes around them (~1.4x faster than bf16 at V4.1's EP=96 shape). V4.1's `fp8.toml` overlay
enables it with FP8 dense linears: 12-node 16k step 5.65 s -> 4.9 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`prime_rl::deepep_moe` dispatches every token chunk up front and runs each chunk's experts while
later chunks are in flight; its backward does the same with the gradients, recomputing each chunk's
experts from the saved received tokens. Activation checkpointing saves the op's outputs, so
recompute neither resends tokens nor reruns the experts. `token_chunk_size` no longer slows a step
down (old chunking: 9.0 s -> 11.3 s), but on 12 nodes it does not speed it up either: the overlapped
DeepEP kernels wait for SMs held by the GEMMs and for DeepEP's per-chunk barriers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One GEMM per group reads and writes strided slices instead of a batched matmul over a transposed,
group-major view, which copied the output in forward and the gradient in backward. Bit-identical;
1.23x faster fwd+bwd at V4.1's shape (8 x 4096 -> 1024 over 16k tokens).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per-group reciprocal with an FMA correction instead of an exact divide per element (bit-identical,
checked exhaustively), a transposed cast that zero-pads the token dimension itself (no F.pad copies),
and scales written in DeepGEMM's layout (no scale-transpose kernels). ~5% less GPU time per FP8
linear fwd+bwd; outputs and gradients unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… under fp8.toml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r kernel when available

~1.2x cuDNN's DSA backward at V4.1 shapes (prime-kernels dsa_sparse_attn_bwd); falls back to cuDNN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samsja and others added 18 commits October 5, 2026 22:11
Updates each parameter as soon as its gradient is final and frees the gradient, so no step holds
every gradient at once. Exact for one micro-batch per step: sign updates ignore the positive loss
scaling and clipping applied after backward; the gradient norm is accumulated for logging.
DeepSeek-V4.1 on 12 nodes at 16k: peak memory 117.6 -> 96.9 GiB at the same step time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`[model.moe.dispatch] fp8 = true` (DeepEP with deepgemm_fp8 experts) quantizes tokens per
1 x 128 block with power-of-two scales before the forward dispatch and dequantizes them on
arrival, exactly, to the bf16 the experts quantize again. Forward dispatch traffic and the
received tokens kept for backward halve; gradients stay bf16.

V4.1 Flash, 12 nodes, 16k, fp8.toml: 4.8 s -> 4.7 s per step, peak memory 117.6 -> 113.8 GiB,
loss curve unchanged within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…adient

The query's rotation and both rotations in backward overwrite their input instead of copying
every 512-wide head; only the 64 rotary channels are read and written (6.3x faster per call
at 16k tokens). Bit-identical to the copying op, eager and compiled, with and without
activation checkpointing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…verloads a rank

prime-kernels' FP8 expert path addresses a padded copy of the received rows with 32-bit
offsets, so a rank receiving more than ~419k token-expert pairs at V4.1's width raised
"too many rows", and every other rank then hung in DeepEP until its GPU timeout. Skewed
routing reaches that easily (440k-855k rows on 12 nodes with the real router).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…E backend

Pipeline parallelism:
- `model.pp`, `pp_schedule`, `pp_stages_per_rank`, `pp_layers_per_stage`; stage build in
  `model.py`, `trainer/pipeline.py` (pooled receive buffers, async 1F1B, shape probe),
  `prune_to_pipeline_stage` / `pipeline_stage_forward` for V4.1, PP-aware data ranks and grad clip.

Expert path:
- `model.moe.dispatch.keep_expert_activations`: keep routed-expert activations for backward.
- FP8 expert weights quantized once per optimizer step; FP8 weight gradients accumulate into
  FSDP's fp32 buffer (prime-kernels `moe_experts`).
- `dispatch.weight_grads_after_combine`: send each chunk's gradient back before its weight-gradient
  GEMMs so they hide the send (bitwise-identical gradients).
- `moe.reduce_local_expert_grads_once`: single-rank expert FSDP groups reduce once per step instead
  of per micro-batch.
- `dispatch.fp8_grad` (off by default, changes numerics): FP8 backward dispatch.
- Spread balanced routing for `debug.force_balanced_routing`, so each token reaches ~4-5 nodes.
- `dispatch.type = "mega_moe"`: prime-mega-moe's fused BF16 Hopper MoE as a backend (off by default).

Non-MoE:
- `prime_rl::dsv4_linear_rope`: query projection and RoPE as one op (rotates in place, keeps no
  unrotated copy); out-of-place RoPE ops that selective AC can save.
- mHC stream-gradient sum folded into prime-kernels' projection backward when available.
- Fused Triton cross-entropy gradient in the chunked LM head.
- Per-parameter seeded `debug.random_init`; setup order AC -> FSDP -> compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the attention score/value term from the FLOPs per token, so MFU is 6 x active matmul
params x tokens (LoRA keeps its 4/2/6 split). PerfCounter and get_perf_counter no longer take
seq_len.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sign_sgd_update_ applies weight decay and the sign step in one kernel pass over param and grad
instead of four torch ops, matching them bit for bit; non-contiguous or CPU tensors keep the torch
path. Used by SignSGD and SignSGDInBackward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate for Muon

optim.embedding_update (default None, unchanged routing) applies the V4.1 tech report's split
(§2.5): embeddings, LM head and engram tables get the Sinkhorn-balanced momentum update
("sinkhorn") or AdamW without weight decay ("adamw"); norms AdamW with decay; biases and scales
AdamW without. MuonWithSinkhorn adds the "sinkhorn" algorithm to dion's Muon with fused Triton
row/column steps that work on row-sharded tables. Adds a GPU test against a dense reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ention

- dsv4_mhc: the h_post/bda backward computes g_x, g_orig, g_hp and g_hr in one pass over go.
- dsv4_rope: the in-place RoPE kernel can save the unrotated rotary channels.
- dsv41_sparse_attn_rope: sparse attention plus in-place inverse RoPE of its output, keeping only
  the rotary channels the backward needs unrotated (1/8 of the output for V4.1) instead of a
  rotated copy. V4.1 attention uses it with the FlashMLA kernel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moe.dispatch.fp8 (default off) runs the routed experts on prime-mega-moe's SM90 FP8 Mega MoE:
the forward keeps each routed row's FP8 input and bf16 gate/up output, the backward computes data
gradients from them and adds DeepGEMM K-grouped FP8 weight gradients into FSDP's fp32
accumulators. Flags (FP8 only, default off): capacity_factor, wgrad_tile_scales (128x128 tile
scales for the wgrad operands, changes numerics), free_bf16_expert_weights (frees the unsharded
bf16 expert weights once quantized, same numerics). prime_rl::mega_moe_fp8 is a mandatory AC save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model.ac.mode gains full_moe (full that also recomputes the FP8 Mega MoE), projections, attention
and matmul, between full and selective. model.ac.layer_modes sets one mode (or none) per decoder
layer, indexed by full-model layer so pipeline stages can retain less on early stages. Default
(mode=full, layer_modes=None) is unchanged. Random init strips the checkpoint wrapper prefix so
parameters draw the same values whether or not their layer is checkpointed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d lookups

- The engram gate multiplies q_weight * k_weight in fp32, as DeepSeek's reference (it rounded to
  bf16 first). Changes numerics toward the reference.
- The gate no longer checkpoints wkv: the compiled gate keeps kv and recomputes its fp32 math.
  model.ac.recompute_engram (default off) recomputes the projection too for layers in full/full_moe.
- Engram lookups are routed ahead of the forward (prefetch), moving the split-size host sync
  before the first layer.
- Later micro-batches add their table rows straight into the step's gradient instead of a dense
  per-micro-batch table gradient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… PP activation offload

- Async1F1B moves to a generic executor (AsyncPipelineSchedule) that runs any precomputed op order
  and never waits on a stage transfer before it is needed; it also runs the new DualPipeV schedule
  (two stages per rank in a V). pp_warmup_step tunes 1F1B warmup per neighbour pair.
- pp_transport: nccl send/recv (default) or copy_engine (symmetric-window all-gather on copy
  engines, NCCL >= 2.32); pp_transport_ctas and pp_gemm_sms pin SM budgets.
- pp_layers_per_stage accepts multiples of 0.5 to cut a V4.1 layer between attention and MoE.
- pp_activation_offload (default off) offloads kept activations to pinned host memory between a
  micro-batch's forward and backward.
- Sent stage outputs no backward reads are freed early; grad clipping and loss broadcast handle
  several stages per rank and V schedules. Adds schedule-order unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model.engram_offload (default off) keeps each rank's engram table shard and its optimizer state in
pinned host memory: lookups gather rows over PCIe on a side stream ahead of the forward, and the
AdamW/SignSGD step streams the shard through the GPU with the same update kernel, overlapped with
the next forward. Checkpointing offloaded tables is not implemented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge transfers

PutEdge carries each edge direction over its own 2-rank zero-CTA communicator: the sender packs a
micro-batch into its registered symmetric window and ncclPutSignal's it into the receiver's window;
the receiver waits on the signal, copies out and returns a credit with ncclSignal. A CPU proxy
drives the NIC, so transfers hold no SMs and stop slowing persistent kernels (Mega MoE, DeepGEMM,
sparse attention). Unlike copy_engine it needs no global transfer order and sends one way only.
Costs one micro-batch buffer per edge end; needs NCCL >= 2.30 at runtime. Default stays nccl.

8-node PP16-stage emulation (stages 0-7): H200 1F1B 28.9% -> 34.0% MFU, 80 GB 27.6% -> 31.6%;
step-10 loss and grad norm match nccl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moe.dispatch.fp8_transposed_on_demand (FP8 only, default off) keeps only the forward FP8 expert
weights for the step and rebuilds the transposed copy each backward with a Triton 128 x 128 byte
transpose (scales via torch), freeing it after. Frees 1 byte per local expert parameter (1.7 GB
per V4.1 MoE block at EP8) for ~0.95 ms per MoE block backward on H200. Bitwise equal to the
cached transposed copy. 8-node 80 GB emulation (put, 1F1B): 31.6% MFU at 78.7 GB vs 30.4%
projected baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model.pp_activation_offload.target = "fp8" (default "host") keeps a stage's kept activations on
the GPU as FP8 with one fp32 scale per 128 values instead of copying them to host memory, and
dequantizes them into their original storage before the backward (Triton kernels). Only
contiguous bf16 tensors with a last dimension divisible by 128 are compressed. Changes numerics
(backward and recompute read dequantized values) and is unverified beyond an unchanged loss and a
+0.2% grad norm on a PP2 rig; the 8-node 80 GB emulation measured 30.0% MFU, below fp8t alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-sided ncclPutSignal / ncclWaitSignal used by `model.pp_transport = "put"` need
NCCL >= 2.30; torch 2.13 pins nvidia-nccl-cu13 2.29.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@samsja samsja changed the title perf(trainer): DeepSeek-V4.1 FP8 experts, engram all-to-all embedding parallel, pipelined DeepEP perf(trainer): DeepSeek-V4.1 FP8 experts, engram all-to-all EP, pipeline parallelism with one-sided NCCL transport Oct 10, 2026
samsja and others added 13 commits October 10, 2026 08:58
…real transfer end

pp_transport=put now fails with a clear error when the loaded NCCL is older than 2.30 instead of
hanging or crashing. Also documents the ctypes arguments, checks _views against the actual end of
the packed tensors, and adds type hints. Same code path otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moe.dispatch.fp8_transposed_on_demand also accepts a list of pipeline stages, so the transposed
FP8 weights are rebuilt on demand only where memory binds. Default False; bitwise either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…input-grad send

model.pp_defer_expert_weight_grads (Async1F1B / DualPipeV, default off): a backward leaves the
FP8 Mega MoE experts' weight-gradient GEMMs until after the stage posts its input-gradient send,
so the previous stage gets its gradient one weight-gradient pass earlier. Not deferred on a step's
last micro-batch (it starts the reduction) or the blocking first step. Same GEMMs, same order, same
fp32 accumulators; operands (~1 GB per MoE block at 8k tokens) live until they run. 6-node: steps
1-3 bitwise, later steps within FP8 run-to-run noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model.pp_offload_masters (Async1F1B / DualPipeV, default off): after each step's first forward
(FSDP's only read of the shards in the pipeline) the sharded fp32 parameters go to pinned host
memory on a side stream, and come back after the last forward; empty_cache() after the optimizer
step releases the reduced-gradient pool cached on FSDP's reduce-scatter stream. Frees 8 bytes per
parameter / FSDP group size (1.13 GB per MoE block at D=12). Same values; GPU validation pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stream

x_t (~250 MB per MoE block per kept micro-batch) is allocated on the compute stream and filled by
column_quant on the side stream, so its memory returns to the pool activations draw from instead
of the side stream's. Bitwise (same kernel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cuts

model.ac.layer_modes entries may be [attention-half mode, MoE-half mode] for a layer cut by a
stage boundary; each stage's half takes its own mode. A pair on an uncut layer is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…every, paced

- Reloads allocate from the compute stream's pool; the copy stream's own pool kept freed memory
  out of reach of activations (same values).
- every (default 1): offload only every k-th micro-batch, to stay within host-link bandwidth.
- paced (default off): before an offloaded forward, the GPU waits for the previous swap-out, so
  warmup forwards cannot outrun the copies.
GPU validation pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…DP's fp32 buffer

model.quantization.accumulate_wgrad_fp32 (FP8Config, default off): each Float8BlockwiseLinear's
DeepGEMM weight-gradient GEMM writes into FSDP's fp32 unsharded accumulator, as the FP8 experts
already do, skipping the fp32 zero fill, the bf16 cast and FSDP's bf16->fp32 add per micro-batch.
Registered in setup_fsdp; no effect without FSDP. Changes numerics (no per-micro-batch bf16
rounding of dense weight gradients; per-op rel L2 vs fp64 3.465e-2 vs 3.470e-2 today).
Stage bench (EP8, 8k, 6 layers): -0.52 ms/layer, ~0.25 GB/layer less transient memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ckpoint

model.compile.inside_checkpoint (default off): apply_compile compiles the block inside its
CheckpointWrapper, the path it already takes for blocks holding a nested FSDP unit. With AC on, the
expert dispatch (torch.compiler.disable) is a graph break inside the checkpoint's higher-order op,
which sends the whole block to eager, so today's block compile does nothing; inside an eager
checkpoint the dense code around the break compiles. Works with every AC mode, per-layer modes and
cut pipeline stages. Changes numerics slightly (Inductor rounding). Stage bench (EP8, 8k, 6
layers): -0.73 ms/layer, -0.2 GiB peak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… accumulate_wgrad_fp32

With model.quantization.accumulate_wgrad_fp32 on, dense FP8 linears hold their gradient in FSDP's
fp32 accumulator while the group's other parameters hold bf16 gradients, so a reduce on the same
micro-batch (non-PP, or the last micro-batch) failed with "FSDP reduce-scatter expects uniform
gradient dtype". When reduce_grads is set, post_backward now first converts every parameter of
such a group to an fp32 accumulated gradient (FSDP's to_accumulated_grad_if_needed). Deferred
reduces were already converted by FSDP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e mHC write-back

For V4/V4.1 shared experts whose projections are all Float8BlockwiseLinear (always on, bitwise):
- One gate|up FP8 GEMM (prime_rl::fp8_gate_up_mm): x cast once, both weights block-cast into one
  stacked operand.
- A Triton clamped-SwiGLU kernel that also emits h's per-token FP8 cast, consumed by the down
  projection through new optional x_fp8 / x_sf arguments of prime_rl::fp8_blockwise_mm.
- A backward kernel writing d[gate|up] straight into the FP8 operands the dgrad/wgrad GEMMs read;
  x's transposed cast runs once. accumulate_wgrad_fp32 works as before.
- MoE.forward(split_shared_output=True) returns routed and shared outputs separately and the V4.1
  layer adds them inside fused_post_bda (HAS_X2), rounded to bf16 exactly as the add was.
fp8_gate_up_mm is saved wherever fp8_blockwise_mm is (FUSED_OPERATIONS); the SwiGLU is recomputed.

Outputs, activation and weight gradients torch.equal to before on 1 GPU under AC none, selective,
projections, full and full_moe; 8-GPU stage forward and shared-expert fp32 grads equal. Stage
bench (6 layers, 8k, selective): -0.66 ms/layer. Not yet run inside a PP job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s into one GEMM launch

model.moe.dispatch.fused_wgrad_micro_batches (FP8 Mega MoE, 1-4, default 1 = today's behaviour):
n > 1 holds a layer's expert wgrad operands (~1.17 GB per MoE layer per held micro-batch at 8k)
until the n-th micro-batch's backward, which adds all held sets with one K-grouped GEMM launch per
weight (prime-mega-moe k_grouped_fp8_gemm_nt_contiguous_multi, 125a11a). Bitwise equal to one
launch per micro-batch in order (torch.equal on the fp32 accumulators). The step's last
micro-batch never holds: the pipeline and the non-PP reduce-once path mark it with
set_expert_wgrad_final_micro_batch; flush_pending_expert_wgrads raises if a held gradient would
outlive its accumulator. With n = 1 the kernel is called without the new arguments, so older
prime-mega-moe builds keep working. At most 2 with wgrad_tile_scales.

Stage bench (EP8, 8k, 6 layers): n = 2 -0.77 ms/layer (+7 GiB peak), n = 4 -1.51 ms/layer.
Validated in PP on 6 nodes by Track B (losses equal within FP8 noise, -2.0% cycle at n = 2).
Net loss at 80 GB; recommended 2 on H200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model.moe.dispatch.fused_wgrad_micro_batches accepts an int (every stage, unchanged) or a list
with one entry per pipeline stage (pp * pp_stages_per_rank, stage order as pp_layers_per_stage),
so memory-bound plans can fuse expert wgrads only on stages with headroom. configure_moe_runtime
picks the stage's entry. The list length is validated, and every entry must be <= 2 with
wgrad_tile_scales. Default unchanged (1); bitwise as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samsja and others added 2 commits October 10, 2026 20:55
… source

The index-source layer builds the (1, t, 1, 640) int32 window + top-k slot list as before and
stores it in SharedAttnState.top_k_indices in place of the raw picks; consumer layers pass it to
SparseAttnInputs.build(indices=...), which then only lays out kv_buf. Removes five small eager
kernels (~75 us) per consumer layer in forward and again in recompute (~-0.11 ms/layer averaged
over layers 20-39). Bitwise: the kernels receive the same int32 values. Pipeline stages now send
the int32 slot list instead of the int64 picks (~35% fewer bytes per boundary for that tensor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion checkpoint"

This reverts commit e3b8e41. model.compile.inside_checkpoint changes gradients well beyond
rounding under selective-style activation checkpointing, where its gain comes from (step-1 grad
norm +1.5% against baselines that agree to 1e-4, loss +0.06-0.08); the root op is not pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samsja added a commit that referenced this pull request Oct 10, 2026
Replaces the three switches `model.fsdp_cpu_offload`,
`model.optim_cpu_offload` and `model.full_offload` with one field:

```toml
[model]
offload = "optimizer"   # default; also "full", "none"
```

This applies to both the RL trainer and SFT. The `fsdp` switch is not
migrated: the mode is removed outright (rationale below). The
`[model.full_offload] numa_bind` knob is dropped: no recipe, deployment,
template or doc sets it. Full offload now always pins each rank's CPUs
to its GPU's NUMA node. That was already the default. Ranks whose GPU
reports no NUMA node still skip binding with a warning. On hosts without
GPU sysfs topology (`/sys/bus/pci/devices/<bus>/numa_node`), binding is
no longer optional: full offload fails there instead of being switched
off.

Why: the three switches could only be used one at a time, enforced by
`cpu_offload_mutual_exclusion`. Because `optim_cpu_offload` defaulted to
`true`, enabling full offload also meant writing `optim_cpu_offload =
false`, otherwise validation failed (see the old
`configs/advanced/deepseek-v4-flash/sft.toml` comment). With one value
the conflicting combinations cannot be expressed, so that validator is
deleted. The default behaviour does not change: optimizer-state offload
stays on.

Why drop `fsdp`: the mode was the stock torch FSDP2 `CPUOffloadPolicy`
with no in-tree config, example, or test user; Muon rejected it and it
broke NCCL weight broadcast. It was kept only for long-context runs
whose fp32 masters/moments no longer fit on GPU (the reason it was
restored in #3052); the `full` mode added in #3234 keeps FP32 masters,
moments, and gradients in host RAM, covering that memory pressure
instead. Gone with it: the `CPUOffloadPolicy` selection in `setup_fsdp`,
CPU-side model init in `setup_model`/`load_dcp_from_hf`,
`_move_buffers_to_cuda`, and the fsdp leg of the Gloo special case.

Changes:
- `ModelConfig.offload: Literal["none", "optimizer", "full"] =
"optimizer"` replaces the three fields.
`OptimizerInBackwardOffloadConfig` (and with it `numa_bind`) and its
bool-normalizing `BeforeValidator` are gone.
- Validators in `TrainerConfig` and `SFTConfig`:
- full offload accepts only AdamW or SignSGD and disables clipping, now
reading `offload == "full"`;
- the `Muon optimizer does not support FSDP CPU offload` validators are
deleted with the mode.
- Runtime:
- `setup_optimizer(..., offload=...)` replaces `cpu_offload` +
`full_offload_config`, and its "both enabled" check is gone.
- `FullCPUOffloadOptimizer` drops its unused `offload_config` argument.
- `setup_full_cpu_optimizer_offload()` takes no arguments and always
binds.
- `model.py`: FSDP always uses the default `OffloadPolicy()`; CPU-side
model init in `setup_model`/`load_dcp_from_hf` and
`_move_buffers_to_cuda` are gone.
- Tomls: `glm-4.5-air/swe-budget.toml` and
`ci/integration/reverse-text-sft/full-offload-resume.toml` → `offload =
"full"`; `glm-5.3/sft/h200/16k-12-node.toml` → `offload = "none"`;
`glm-5.3/sft/h200/base.toml` → `offload = "optimizer"`. Also updated
`docs/scaling.md`, the `deepseek-v4-flash/sft.toml` comment, and
`skills/training/start-run/SKILL.md`, which documented the removed
fields.

Breaking: the old keys now fail with `extra_forbidden`, and `offload =
"fsdp"` fails the `Literal`. There is no shim.

Interaction with #3849 (one trainer-common config base): #3849 moves the
full-offload and Muon/FSDP validators from `TrainerConfig`/`SFTConfig`
onto `BaseTrainerConfig`. This PR edits the full-offload validator's
condition and deletes the Muon/FSDP one, so the conflict is textual
only: keep #3849's single copy of the full-offload validators with
`self.model.offload == "full"` and drop its Muon/FSDP copy. Whichever
lands second takes a small rebase.

Other open PRs that set or read the old keys need a rebase:
- tomls: #3883, #3879, #3766, #3765, #3729, #3629, #3627, #3616, #3544
- code: #3885 (`optim_cpu_offload`), #3739 (`full_offload` validators),
#3724, #3719, #3699, #3610, #3487 (`fsdp_cpu_offload` / `enable_gloo`)

Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py
tests/unit/train/test_cpu_adam.py tests/unit/train/test_state_offload.py
-m "not gpu"`: 336 passed, 1 skipped (Qwen3-VL snapshot not cached on
this host), 4 deselected (GPU; this host has no GPU). The existing
offload config tests are updated to the new field. `test_load_configs`
covers the 4 edited tomls.
- `cli(TrainerConfig|SFTConfig, ...)`: `--model.offload full|none` and
the default (`optimizer`) resolve as expected; `optim_cpu_offload` and
`offload = "fsdp"` are rejected.
- `import prime_rl.trainer.rl.train, prime_rl.trainer.sft.train` works.
- `ruff check` and `ruff format --check` on the changed Python files:
clean.
- Not run: GPU or multi-node training with `offload = "full"`.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Breaking TOML/API surface touches distributed training memory paths
(FSDP offload, optimizer-in-backward, Gloo); behavior should match prior
modes when mapped correctly, but mis-migrated configs will fail at parse
time.
> 
> **Overview**
> **Breaking config change:** `model.fsdp_cpu_offload`,
`model.optim_cpu_offload`, and `model.full_offload` are replaced by a
single **`model.offload`** setting (`"optimizer"` default, or `"full"`,
`"fsdp"`, `"none"`). RL and SFT share the same field; example TOMLs and
scaling docs are updated accordingly.
> 
> The mutual-exclusion validator between the old flags is removed
because only one mode can be expressed. **`[model.full_offload]` /
`numa_bind` is dropped** — `"full"` always runs NUMA binding and CPU
optimizer thread setup via parameterless
`setup_full_cpu_optimizer_offload()`. Runtime paths (`setup_optimizer`,
FSDP CPU offload in `model.py`, Gloo when offload is `"full"` or
`"fsdp"`) key off the new enum; `FullCPUOffloadOptimizer` no longer
takes an offload config object.
> 
> Default training behavior is unchanged: optimizer-state CPU offload
remains the default (`"optimizer"`). Old TOML keys are not aliased and
should fail validation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
35da73a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: sami jaghouar <sami.jaghouar@gmail.com>
samsja and others added 5 commits October 10, 2026 22:47
…r DeepSeek-V4.1

residual_type on the V4.1 text config: "mhc" (default, today's code path, bitwise unchanged),
"gated" or "layerscale", the latter two requiring hc_mult = 1. "gated" is mHC's post gate at one
stream, x + g * (f(x) [+ f2(x)]) with g = 2 sigmoid(scale * rstd(x) (x . w) + base) per token,
initialized to g = 1; "layerscale" is x + lambda * f(x) per channel. Both keep the
gates_and_collapse / update_states interface, so attention, MoE, engram and pipeline stage I/O
are unchanged. A fused Triton forward/backward (prime_rl::dsv41_gated_residual) does the gate in
one pass per token; it is a default selective-AC save target. Experimental architecture option:
not checkpoint-compatible with DeepSeek's mHC residual gates; quality ablation pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b6460ed (on top of 125a11a) adds the L2 prefetch of the accumulator tile and 128x144 dW2 tiles to the
multi-set K-grouped wgrad: bitwise identical, -0.55 ms/layer at fused_wgrad_micro_batches >= 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rime-kernels has it

When prime-kernels provides fp8_dual_cast (a CuTe kernel writing both the 1x128 per-token and the
token-padded 128x1 transposed e4m3 casts in one read), Float8BlockwiseLinear's backward casts
grad_output with it, and dsv4_linear_rope's FP8 backward fuses the query gradient's inverse RoPE
into the same pass, feeding fp8_blockwise_mm_backward through new optional precomputed-cast
arguments. Bitwise identical to the Triton casts; falls back to them without the op, on UE8M0
devices or for block sizes other than 128. Stage trace: -0.17 ms/layer (cast kernels 0.93 ->
0.77 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…batches

5827a5b (on top of b6460ed) orders the FP8 Mega MoE forward and backward tasks expert-major
whenever no expert exceeds 16 M blocks, so each expert's weights are read about once: bitwise
identical, -0.53 ms/layer, on the default path too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
optim.recipe = "deepseek" (Muon; default "dion", unchanged; requires optim.embedding_update)
follows the V4 report (§2.4, Algorithm 1) and V4.1 §2.5: Nesterov momentum, 10 hybrid
Newton-Schulz iterations in bf16 with fp32 accumulation, updates rescaled to RMS
optim.update_rms (0.18), AdamW epsilon 1e-20, and one Newton-Schulz per logically independent
matrix: each routed expert (gate and up split), each attention and indexer query head, each o_a
group, the engram wkv keys and value, and the mHC pre/post/comb mixes. Engram gate q/k weights
are treated as norm weights (AdamW with weight decay) in the embedding_update split.

muon_step orthogonalizes parameters whose shard holds whole matrices (the experts, on their
expert-DP shard owner) in place, and sends every other matrix to one owner rank chosen by FLOP
balance with one bf16 all_to_all per FSDP group, overlapped with the local expert work,
Sinkhorn and AdamW. Triton Newton-Schulz stays off (it kept gradients alive).

GPU tests (test_muon, test_sinkhorn): 11/11 on H200; 8-GPU sharded step bitwise vs single GPU
(one case 3e-4 from bf16 batching); 250-step 2-node proxy within run-to-run noise. Cost on a
3-layer stage: optimizer step 18 -> 150 ms, +4 B/param momentum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant