MoE: shared experts, parameterized V_TOPK routing, explicit stage attribution - #100
Merged
Merged
Conversation
Companion to the compiler-side ISA change. `topk_softmax` was already generic in (expert_count, top_k) -- only the rmask decode table was not -- so this is a decode and plumbing change, not a semantics change. `topk_policy` is Option<u32> rather than a u32 defaulting to 0 so V_TOPK can trap naming the missing C_SET_TOPK_REG. A 0 default would unpack to top_k=0 and abort inside topk_softmax with "topk must be positive", which points at the wrong thing. classify_timing_access now takes the policy as a second accessor closure. Its V_TOPK arm derives the logit read extent from the expert count, and the pre-existing `_ => 128` fallback would have under-reported every model wider than 128 experts -- DeepSeek-V3 and Kimi K2 are 256 -- crediting the unmodelled rows to the overlay as free hiding capacity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emulator side of the shared-expert work, plus the test that verifies it. Stage attribution ----------------- `classify_comment` guessed stages by substring-matching the compiler's generated ASM prose. That is unfixable for shared experts: there was no way to express a stage the substring table did not already contain, and the compiler's policy_name parameter would have broken the GPT-OSS literals the table keys on. `; @stage=<name>` markers now win. A program carrying any marker is classified *only* by markers -- mixing is not a conservative middle ground but wrong, because a marked region's body still contains ordinary comments from general-purpose helpers ("sub projection", "subblock [") that a live substring rule would immediately steal back. Legacy classification stays for unmarked programs, including the ASM already checked in under moe_timing. Three new stages: shared_expert_projection / _activation / _gate. The split exists so the profile can answer what fraction of MoE time is architecturally dense (shared, every token) versus routing-dependent. `classification.stage_attribution` reports which mode ran, and `unresolved_stage_markers` reports names the compiler emitted that no StageKind matches -- the compiler validates against its own MOE_STAGES, so anything there means the two vocabularies drifted, and those regions silently inherit the previous stage. Schema version 4. Test ---- moe_shared_expert_test.py covers DeepSeek (ungated), DeepSeek n_shared=2 fused, and Qwen2-MoE (gated), all bit-exact at atol=rtol=0. Exactness comes from MXFP8-representable inputs making weight quantization the identity, leaving only BF16 rounding, which _shared_moe_reference.py mirrors step by step -- silu(g)*u in one rounding does not equal the hardware's six. The gate weight is scaled by a power of two so the sigmoid stays strictly interior. At full scale the logits reach +-16 and the gate saturates to exactly 1.0 for most rows, which would let a completely unapplied gate still pass; a guard now fails the test if any scalar saturates. Guard 3 on the stage distribution caught a real bug during bring-up: the shared gate billed 999 instructions to expert_route_weight, because the broadcast emitter it reuses hardcoded that stage and re-marking afterwards is too late for a sticky marker. Fixed compiler-side. Also resolves --build-dir, since the emulator runs with its own working directory and the justfile recipes pass relative paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Route trace schema v2 --------------------- Adds the optional shared-expert block to `model`: shared_experts, shared_intermediate_size, shared_gate. A v1 trace is a valid v2 trace with no shared branch, so both are accepted rather than forcing every existing trace to be regenerated. shared_intermediate_size is the *fused* width, i.e. already multiplied by n_shared -- DeepSeek replays a single MLP, not n of them. The validator rejects the half-specified combinations: a shared_experts with no width would replay as routed-only while still being labelled shared, and the timing split would attribute nothing while looking healthy. summarize_run gains a shared-vs-routed picosecond split, summed exactly and rounded once. gather/scatter/accumulator_init are excluded from both terms: they are combine plumbing shared by the branches, and billing them to "routed" would overstate its share. Returns null rather than 0 on a pre-v4 profile, so "no shared branch" stays distinguishable from "this profile cannot express the question". Routing-policy tests -------------------- V_TOPK's two hardwired rmask policies cover GPT-OSS and Qwen3-30B-A3B and nothing else. moe_router_policy_test drives all six production shapes -- Llama-4 Scout 16/top-1, Qwen2-MoE 60/top-4, DeepSeek-V2-Lite 64/top-6, DeepSeek-V3 256/top-8 -- through the compiler, assembler and emulator. DeepSeek-V3 spans four MLEN-wide logit blocks and selects expert 255, which was unencodable before C_SET_TOPK_REG existed. Each case asserts which *encoding* it took, not just that routing was correct. Without that a shape could quietly fall back to a fixed rmask, produce correct indices anyway, and leave the escape path untested while appearing covered. Negative-tested, each guard against the defect it claims to catch: packing shift 8 -> 7 router policy test, wrong indices @stage= markers not emitted attribution fell back to legacy substrings shared gate multiply dropped numeric, 0.00% match rate, MSE 1.4e6 shared branch marked as routed {'expert_projection': 568} in an unrouted program Wired into CI as `just test-moe-shared-all`. Synthetic throughout -- no checkpoint, no HF libraries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Emulator and testbench side of shared-expert MoE support. Companion to
AICrossSim/PLENA_Compiler#69,
which this PR bumps the submodule pin to. Neither is useful without the other.
1.
C_SET_TOPK_REG— routing at any expert shapeV_TOPK'srmaskwas a two-entry table (32/top-4, 128/top-8) covering GPT-OSSand Qwen3-30B-A3B and nothing else. Llama-4 Scout is 16/top-1, Qwen2-MoE 60/top-4,
DeepSeek-V2-Lite 64/top-6, DeepSeek-V3 and Kimi K2 256/top-8 — each would have
cost an ISA revision.
topk_softmaxwas already generic in both counts; only the decode table wasnot. So this is a decode and plumbing change, not a semantics change.
topk_policyisOption<u32>rather than au32defaulting to 0 soV_TOPKcantrap naming the missing
C_SET_TOPK_REG. A 0 default would unpack totop_k=0and abort inside
topk_softmaxwith "topk must be positive", which points at thewrong thing.
classify_timing_accessnow takes the policy as a second accessor closure. ItsV_TOPKarm derives the logit read extent from the expert count, and thepre-existing
_ => 128fallback would have under-reported every model wider than128 experts — crediting the unmodelled rows to the overlay as free hiding
capacity.
2. Stage attribution:
@stage=markers replace substring sniffingclassify_commentguessed stages by substring-matching the compiler's generatedASM prose. That is unfixable for shared experts: there was no way to express a
stage the substring table did not already contain, and the compiler's
policy_nameparameter would have broken the GPT-OSS literals the table keys on.; @stage=<name>markers now win. A program carrying any marker is classifiedonly by markers — mixing is not a conservative middle ground but wrong, because
a marked region's body still contains ordinary comments from general-purpose
helpers (
"sub projection","subblock [") that a live substring rule wouldimmediately steal back. Legacy classification stays for unmarked programs,
including the ASM already checked in under
moe_timing.Three new stages:
shared_expert_projection/_activation/_gate. The splitexists so the profile can answer what fraction of MoE time is architecturally
dense (shared, every token) versus routing-dependent.
Two new reported fields, schema version 4:
classification.stage_attribution—"explicit_stage_markers"or"legacy_comment_substrings". Everyvocabulary_*field only means anything inthe latter mode; under markers they describe rules that never ran.
classification.unresolved_stage_markers— names the compiler emitted that noStageKindmatches. The compiler validates against its ownMOE_STAGESbeforeemitting, so anything here means the vocabularies drifted, and those regions
silently inherit the previous stage.
3. Tests
moe_shared_expert_test.py— bit-exact, three architecturesjust test-shared-moejust test-shared-moe-gatedjust test-shared-moe-deepseek-fusedn_shared=2fused + routed-accumulator combineAll at
atol=rtol=0. Exactness comes from MXFP8-representable inputs makingweight quantization the identity, leaving only BF16 rounding — which
_shared_moe_reference.pymirrors step by step.silu(g)*uin one rounding doesnot equal the hardware's six.
The gate weight is scaled by a power of two so the sigmoid stays strictly
interior. At full scale the logits reach ±16 and the gate saturates to exactly 1.0
for most rows, which would let a completely unapplied gate still pass; a guard
fails the test if any scalar saturates.
moe_router_policy_test.py— every production routing shapeSix policies through compiler → assembler → emulator, including DeepSeek-V3's 256
experts spanning four MLEN-wide logit blocks and selecting expert 255 (unencodable
before
C_SET_TOPK_REG).Each case asserts which encoding it took, not just that routing was correct.
Without that a shape could quietly fall back to a fixed
rmask, produce correctindices anyway, and leave the escape path untested while appearing covered.
Negative-tested
Each guard against the defect it claims to catch:
@stage=markers not emitted{'expert_projection': 568}in an unrouted programThe last one is not hypothetical — the guard caught it live during bring-up at 999
misattributed instructions, because the broadcast emitter the gate reuses hardcoded
expert_route_weightand re-marking afterwards is too late for a sticky marker.4. Route-trace schema v2
Adds the optional shared-expert block to
model:shared_experts,shared_intermediate_size,shared_gate. A v1 trace is a valid v2 trace with noshared branch, so both are accepted rather than forcing every existing trace to be
regenerated.
shared_intermediate_sizeis the fused width, already multiplied byn_shared— DeepSeek replays a single MLP, not n of them. The validator rejectsthe half-specified combinations: a
shared_expertswith no width would replay asrouted-only while still being labelled shared, and the timing split would
attribute nothing while looking healthy.
summarize_rungains a shared-vs-routed picosecond split, summed exactly androunded once.
gather/scatter_combine/accumulator_initare excluded fromboth terms: they are combine plumbing shared by the branches, and billing them to
"routed" would overstate its share.
5. Side effect: three previously-unrunnable files now resolve
gpt_oss_moe_gather_scatter_test.py,models/gpt_oss/attention_semantics_test.pyand
moe_timing/qwen/qwen3_trace_replay.pycalled eight compiler methods that didnot exist. AST-verified: all now resolve.
They still cannot run — they need the gpt-oss-20b checkpoint plus
huggingface_hub/safetensors, which is the pre-existing reason the justfileexcludes them from CI. That half is unchanged.
CI
New step
just test-moe-shared-all— three shared-expert variants plus sixrouting policies. Synthetic throughout: no checkpoint, no HF libraries.
Validation
gpt_oss_topk_testwhich now exercises the compiler's deprecated-alias path
ruff format/ruff checkcleanKnown 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 by emitting ASM and
counting matrix ops (MLEN=64, hidden=intermediate=64, top_k=2):
M_MMH_PREFETCH_MThe routed cost is strictly linear in
tokens × top_kwith zero batching benefit;the shared branch added here costs the same for 1 token or 64. The compiler calls
the pair-major lowering "intentionally wasteful but keeps the first L2 correctness
path exact", which was the right call for bring-up but is now the dominant term in
anything measuring prefill.
So a shared-vs-routed cycle ratio measured today reflects the routed lowering's
per-pair overhead at least as much as it reflects the architecture. This is
documented on
_branch_splitand inprogram_moe_shared.py's module docstringrather than left for whoever reads the numbers to discover.
Expert-major batching is follow-up work. Note it gains nothing at batch=1 decode
(one token,
top_kgroups of one row each) — it is a prefill / large-batch-decodeconcern.
🤖 Generated with Claude Code