MoE: shared experts, arbitrary V_TOPK expert shapes, explicit stage markers - #69
Open
qichao-arlo-wang wants to merge 2 commits into
Open
MoE: shared experts, arbitrary V_TOPK expert shapes, explicit stage markers#69qichao-arlo-wang wants to merge 2 commits into
qichao-arlo-wang wants to merge 2 commits into
Conversation
V_TOPK's rmask was a two-entry table: 0 = 32 experts/top-4 (GPT-OSS), 1 = 128/top-8 (Qwen3-30B-A3B). Every other production MoE shape falls outside it -- Qwen2-MoE 60/top-4, DeepSeek-V2-Lite 64/top-6, DeepSeek-V3 and Kimi K2 256/top-8, Llama-4 Scout 16/top-1 -- so each new architecture would have cost an ISA revision plus a matching emulator change. Add C_SET_TOPK_REG (6'h38), a sticky control register in the same family as C_SET_SCALE_REG / C_SET_STRIDE_REG / C_SET_V_MASK_REG, holding (num_experts << 8) | top_k. rmask=15 escapes to it; 0 and 1 keep their exact previous meaning. The 8-bit shift keeps the packed value inside a single 22-bit S_ADDI_INT immediate for every shape up to 16383 experts, so no S_LUI_INT pair is needed. It bounds top_k at 255, which no published MoE approaches. The register is a rd-only form, so parse_asm_file already handles it and only the encoder set needed the entry. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds the shared-expert branch of MoE -- an FFN every token passes through,
summed into the routed output unweighted:
y = shared(x) + sum_k route_weight_k * routed_expert_k(x)
DeepSeek-V2/V3, Kimi K2, Llama-4, GLM-4.5 and Qwen3-Next all have one;
GPT-OSS, Qwen3-MoE and Mixtral do not, which is why the substrate had no
notion of it.
moe_shared_expert_v0 dense FFN over all rows, static weights
moe_shared_gate_v0 Qwen2-MoE's sigmoid(x @ w_gate)
moe_combine_shared_and_routed_v0 the unweighted add
fused_shared_intermediate() DeepSeek n_shared -> fused MLP width
n_shared_experts is deliberately not an emitter parameter: DeepSeek stores
its shared experts pre-concatenated and instantiates a single MLP of width
moe_intermediate_size * n_shared_experts. That is a checkpoint-loading
concern, and SwiGLU being elementwise along the intermediate axis makes the
fused form exactly equal to the sum -- not an approximation.
The gate reuses moe_materialize_route_weights_for_active_rows_v0: one FP
scalar per token broadcast across hidden is structurally identical to a
V_TOPK route weight, only the scalar's origin differs. The sigmoid itself
runs in the scalar FP unit, since there is one value per token and the
vector form would compute MLEN copies of it.
Explicit @stage= markers
------------------------
Stage attribution was an undeclared cross-repo contract: the emulator
substring-matched prose like "GPT-OSS gather token rows" out of the emitted
comments. Rewording silently reclassified instructions, and there was no way
at all to introduce a stage the substring table did not already know -- which
is exactly what made shared experts unmeasurable.
Emitters now emit `; @stage=<name>`, validated against MOE_STAGES at ASM-gen
time so a typo fails immediately rather than collapsing a region into
`other`. Markers are authoritative and sticky, so `stage` became a parameter
wherever one emitter serves several phases (moe_true_zero_vram_rows_v0 zeroes
accumulators, gather padding and route tiles; the row-weight broadcast serves
both route weights and the shared gate).
moe_* API migration
-------------------
Renames the gpt_oss_*_v0 methods the simulator testbench had already been
written against -- three of its files called eight methods that did not
exist in this repo at all -- and adds the policy_name parameter they pass.
moe_router_select_v0 is new: it generalizes the top-k emitter to arbitrary
(num_experts, top_k) via C_SET_TOPK_REG, keeping the two hardwired rmask
policies where they apply so existing programs emit byte-identical ASM.
Every old name survives as an alias, so nothing breaks mid-flight.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Adds the shared-expert branch of MoE, generalizes
V_TOPKto arbitrary expertshapes, and replaces the undeclared ASM-comment contract that stage profiling
depended on.
Companion to AICrossSim/PLENA_Simulator#100, which bumps the submodule pin.
Neither is useful without the other; land this first.
1.
C_SET_TOPK_REG— routing at any expert shapeV_TOPK'srmaskwas a two-entry table:0= 32 experts/top-4,1= 128/top-8.That covers GPT-OSS and Qwen3-30B-A3B and nothing else — every other production
MoE falls outside it:
Each would have cost an ISA revision plus a matching emulator change. New opcode
C_SET_TOPK_REG(6'h38) is a sticky control register in the same family asC_SET_SCALE_REG/C_SET_STRIDE_REG/C_SET_V_MASK_REG, holding(num_experts << 8) | top_k.rmask=15escapes to it;0and1keep theirexact previous meaning, so existing programs emit byte-identical ASM.
The 8-bit shift is chosen so every shape up to 16383 experts packs into a single
22-bit
S_ADDI_INTimmediate — noS_LUI_INTpair. It boundstop_kat 255,which no published MoE approaches.
C_SET_TOPK_REGis anrd-only form, soparse_asm_filealready handles it andonly the encoder set needed an entry.
2. Shared experts
A shared expert is an FFN every token passes through, summed into the routed
output unweighted:
moe_inter × n_sharedNew emitters:
moe_shared_expert_v0— dense FFN over all rows with static weightsmoe_shared_gate_v0— Qwen2-MoE'ssigmoid(x @ w_gate)moe_combine_shared_and_routed_v0— the unweighted addfused_shared_intermediate()— DeepSeekn_shared→ fused MLP widthn_shared_expertsis deliberately not an emitter parameter. DeepSeek storesits shared experts pre-concatenated and instantiates a single
DeepseekV2MLPof width
moe_intermediate_size × n_shared_experts. That is a checkpoint-loadingconcern, not an emission one — and because SwiGLU is elementwise along the
intermediate axis while the down projection sums over it, the fused MLP is
exactly equal to the sum of the individual shared experts, not an
approximation.
The gate reuses
moe_materialize_route_weights_for_active_rows_v0: one FP scalarper token broadcast across hidden is structurally identical to a
V_TOPKrouteweight, only the scalar's origin differs. The sigmoid itself runs in the scalar FP
unit (
S_SUB_FP/S_EXP_FP/S_ADD_FP/S_RECI_FP) — there is one value pertoken, and the vector form would compute MLEN copies of it.
3. Explicit
@stage=markersStage attribution was an undeclared cross-repo contract: the emulator
substring-matched prose like
"GPT-OSS gather token rows"out of emittedcomments. Rewording silently reclassified instructions, and there was no way at
all to introduce a stage the substring table did not already know — which is
exactly what made shared experts unmeasurable.
Emitters now emit
; @stage=<name>, validated againstMOE_STAGESat ASM-gentime so a typo fails immediately instead of collapsing a region into
other.Markers are authoritative and sticky, so
stagebecame a parameter whereverone emitter serves several phases:
moe_true_zero_vram_rows_v0zeroes combine accumulators, gather padding androute-weight tiles — the emulator previously guessed between them from the
preceding comment's stage, a stateful rule that could not express "this clear
belongs to the shared branch" at all.
moe_materialize_route_weights_for_active_rows_v0serves both route weights andthe shared gate. Without the parameter every gate instruction bills to
expert_route_weight, making a program with no routing appear to spend timecomputing route weights. This was caught by the new test's stage-distribution
guard during bring-up, at 999 misattributed instructions.
4.
moe_*API migrationThe simulator testbench had already been written against a generalized
moe_*API — three of its files call eight methods that did not exist in this repo at
all, plus a
policy_nameparameter no method accepted. This completes thatmigration.
moe_router_select_v0is new: it generalizes the top-k emitter to arbitrary(num_experts, top_k)viaC_SET_TOPK_REG, preferring the fixedrmasktablewhere it applies.
Every old name survives as an alias (
_DEPRECATED_METHOD_ALIASES), soin-flight callers and the GPT-OSS bring-up tests that predate the rename keep
working unchanged.
Validation
Verified from the simulator side (see the companion PR):
atol=rtol=0four MLEN-wide logit blocks, selecting expert 255
gpt_oss_topk_testwhich now exercises the alias pathKnown limitation, not introduced here
The routed path is pair-major: one
(token, expert)pair at a time in aBLEN-row slot, re-fetching expert weights per pair. Measured, its matrix-op count
is strictly linear in
tokens × top_kand matrix-engine row utilisation is fixedat 1/64 regardless of token count. The shared branch added here is properly
batched (3
M_MMfor 1 token or 64), so a shared-vs-routed cycle ratio measuredtoday reflects the routed lowering's per-pair overhead at least as much as it
reflects the architecture. This is documented in
program_moe_shared.py's moduledocstring. Expert-major batching is follow-up work.
Boundary with Simulator PR #97
PLENA_Simulator #97 pins
this branch and carries the emulator half (stage attribution, timing-replay
harnesses). The split, so neither side waits on the other:
above) is this PR's follow-up, not #97's. Fixing it means re-lowering the
routed path to token-major / expert-batched, which is entirely a compiler
concern. #97 explicitly does not touch it.
result yet. #97 now emits this caveat in the profile JSON itself as
classification.attribution_notes.shared_vs_routed, so a consumer reading onlythe profile still sees it — previously it lived solely in
program_moe_shared.py's module docstring, i.e. nowhere a numbers-consumer looks.(
moe_shared_gate_v0,for token_idx in range(rows)) is a shared-path codegenfollow-up tracked on the #97 side, not part of the pair-major re-lowering. Static
instruction count there grows with batch size; it is unrelated to routed placement.
MOE_STAGESinprogram_routed_moe.pyis a cross-repo contract. #97'semulator-side guard now parses this set directly out of the pinned submodule, so
adding or renaming a stage here without a matching
StageKindfails that test.Previously it compared against a hand-copied list, which could not fail in the
direction that actually moves.
Stacked follow-up
feat/moe-stage-required-arg(stacked on this branch) makesstagea requiredargument on the three stage-polymorphic emitters —
moe_materialize_route_weights_for_active_rows_v0,moe_true_zero_vram_rows_v0and
moe_expert_activation_v0. The shared-gate misattribution fixed in this PRcame from inheriting a
stagedefault; removing the defaults retires the bug classrather than the single instance. Note this is a breaking signature change — the
matching simulator call-site updates ride along with the pin bump in #97.
🤖 Generated with Claude Code