Skip to content

feat(moe): R3 rollout routing replay — slot-indexed expert-routing pool (scaffold)#737

Draft
qywu wants to merge 2 commits into
mainfrom
qywu/r3-routing-replay
Draft

feat(moe): R3 rollout routing replay — slot-indexed expert-routing pool (scaffold)#737
qywu wants to merge 2 commits into
mainfrom
qywu/r3-routing-replay

Conversation

@qywu

@qywu qywu commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What & why

R3 (arXiv:2510.11370) stabilizes RL training of MoE models: the training forward replays the exact expert selection the rollout made, instead of letting the inference and training routers pick experts independently. Independent routers + tiny numerical drift flip a token's top-k choice → train/inference KL blows up. R3 records per-token, per-layer top-k expert ids at rollout and replays them in training (KL cut ~50%; integrates with slime/veRL).

Design — store routing in the KV memory pool

The distinctive choice vs. vllm-ascend's transient RoutedExpertsCapturer: persist routing in a pool indexed by the KV slot, alongside KV.

On a prefix-cache hit the shared prefix tokens are dropped from the forward entirely — their MoE layers never run (out_cache_loc covers only new tokens). A transient capturer therefore returns nothing for the prefix; you'd have to force a re-forward. A slot-indexed pool makes routing follow the KV: a prefix hit that reuses slots also reuses the routing captured when those slots were first written — zero recompute, guaranteed consistent with the reused KV. Cost ≈ num_moe_layers * top_k * 4 B/token (~1-5% of KV).

This builds on infra the repo already has (moe/distribution_recorder.py already captures per-token per-layer topk_ids for offline analysis + tracks the current layer); R3 adds slot-indexed persistence and a return path.

What this PR lands (tested engine core)

runtime/cache/routed_experts_pool.py:

  • RoutedExpertsPool[size+1, num_moe_layers, top_k] int32, slot-indexed on dim 0 (row 0 reserved, mirrors the KV pools). store_layer() scatters by slot like set_kv_buffer; gather() is the prefix-hit retrieval.
  • RoutedExpertsCapturer — per-forward begin_forward/capture/commit; no-op when inactive; skips + counts TP/EP-misaligned rows rather than corrupt the pool.
  • process-global accessor (mirrors the distribution recorder).
  • GenerateReqInput.return_routed_experts opt-in flag (propagated via __getitem__).

18 CPU unit tests: store/gather, reserved slot, sizing, the prefix-hit retrieval property, capture/commit, inactive no-op, TP-misalignment skip, detach-on-capture, flag propagation.

Remaining wiring (needs a running model) — documented follow-ups

In docs/guides/routing-replay-r3.md: allocate the pool via a ServerArgs flag; capture hook at topk.py::select_experts (beside the existing on_select_experts); thread out_cache_loc into the MoE block; TP/EP all-gather alignment; BYPASSED fused-routing capture; prefix-hit retrieval + return path across the gRPC/gateway boundary (proto + servicer + Rust gateway, same boundary as the return_token_ids follow-up).

Docs

Adds docs/guides/routing-replay-r3.md + sidebar entry.

Local note: this venv can't import the full kernel stack on main (unrelated triton_kernels/flashinfer version drift). The pool/capturer tests are kernel-free and run directly (18 passed); the one lazy-imported flag test also passes.

🤖 Draft opened for design review.

qywu added 2 commits July 20, 2026 00:44
…ol (scaffold)

R3 (arXiv:2510.11370) stabilizes MoE RL by having the training forward replay
the exact expert selection the rollout made, instead of letting the inference
and training routers pick experts independently (numerical drift flips top-k
choices and inflates train/inference KL). Integrates with slime/veRL.

Core design: persist per-token, per-MoE-layer top-k expert ids in a pool
indexed by the KV slot, alongside the KV cache. Unlike a transient per-forward
capturer (vllm-ascend), slot-indexed storage makes routing follow the KV: on a
prefix-cache hit the shared prefix tokens are dropped from the forward, but
their routing is recovered from the reused slots with zero recompute and
guaranteed consistency with the reused KV.

Lands the tested engine core:

* RoutedExpertsPool — [size+1, num_moe_layers, top_k] int32 buffer, slot-indexed
  on dim 0 (row 0 reserved, mirroring the KV pools); store_layer() scatters by
  slot like set_kv_buffer, gather() is the prefix-hit retrieval.
* RoutedExpertsCapturer — per-forward begin/capture/commit controller; no-op
  when inactive; skips (and counts) TP/EP-misaligned rows rather than corrupt
  the pool.
* Process-global accessor (mirrors moe/distribution_recorder.py).
* GenerateReqInput.return_routed_experts opt-in flag (propagated via __getitem__).

18 CPU unit tests cover store/gather, reserved slot, sizing, the prefix-hit
retrieval property, capture/commit, inactive no-op, TP-misalignment skip, and
flag propagation.

Model-forward + serving wiring (allocate pool via ServerArgs, capture hook at
topk.py, out_cache_loc into the MoE block, TP/EP alignment, BYPASSED fused
routing, prefix-hit retrieval + return path across the gRPC/gateway boundary)
are documented as follow-ups in docs/guides/routing-replay-r3.md — they need a
running model. Draft for design review.

Signed-off-by: Qingyang Wu <willqywu@gmail.com>
Wires the R3 capture path end-to-end behind --enable-routing-replay
(off by default; every hook is a no-op when unset, so default behavior is
byte-identical):

* server_args: --enable-routing-replay flag.
* event_loop: after the KV pool is created, build_routed_experts_capturer()
  sizes a RoutedExpertsPool to max_total_num_tokens (layers/top_k from the model
  config; None for dense models) and installs the global capturer.
* model_runner.forward: begin_forward(out_cache_loc) before model.forward and
  commit() after (finally), keying capture to this forward's exact KV slots.
* topk.select_experts: capture_in_order(topk_ids) next to the existing
  on_select_experts hook. Layer index is assigned by per-forward MoE invocation
  order (layers fire in order), avoiding edits to every model file. Only the
  standard (non-BYPASSED) path materializes topk_ids in Python, so fused-routing
  models are naturally skipped.

capture_in_order skips under torch.cuda.is_current_stream_capturing() so it can
never corrupt a CUDA graph — only eager forwards are captured this increment.

Adds capturer layer-counter + builder tests (25 CPU tests total). Verified all
edited modules import cleanly (no circular imports). Remaining follow-ups —
CUDA-graph decode capture, TP/EP row alignment (safe-skipped today), and the
prefix-hit retrieval + return path — are documented in
docs/guides/routing-replay-r3.md. Not yet GPU-validated end to end.

Signed-off-by: Qingyang Wu <willqywu@gmail.com>
@qywu

qywu commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Update: forward wiring landed (commit 239ac75), guarded behind --enable-routing-replay (off by default → every hook is a no-op, default behavior byte-identical).

  • server_args: --enable-routing-replay
  • event_loop: build + install the pool right after the KV pool (sized to max_total_num_tokens; None for dense models)
  • model_runner.forward: begin_forward(out_cache_loc) / commit() around model.forward
  • topk.select_experts: capture_in_order(topk_ids) beside the existing on_select_experts; layer index by per-forward invocation order (no per-model edits); BYPASSED fused routing naturally skipped
  • capture_in_order skips under is_current_stream_capturing() so it can never corrupt a CUDA graph

Now 25 CPU tests; all edited modules verified to import cleanly (no circular imports).

Not yet GPU-validated end to end (this venv can't run a model). Remaining, documented in the guide: CUDA-graph decode capture, TP/EP row alignment (safe-skipped today; TP=1 works), and the prefix-hit retrieval + return path across the gRPC/gateway boundary.

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