Skip to content

MoE: required stage argument, a lint that runs, and two attribution fixes - #70

Closed
qichao-arlo-wang wants to merge 11 commits into
feat/shared-expert-moefrom
feat/moe-stage-required-arg
Closed

MoE: required stage argument, a lint that runs, and two attribution fixes#70
qichao-arlo-wang wants to merge 11 commits into
feat/shared-expert-moefrom
feat/moe-stage-required-arg

Conversation

@qichao-arlo-wang

Copy link
Copy Markdown
Collaborator

What this PR does

Stacked review hardening for the MoE stage-attribution contract. Makes stage a
required argument on the stage-polymorphic emitters, adds a lint that enforces it
(and the routes around it), and closes two attribution gaps found while doing so.

Stacked on #69
base branch is feat/shared-expert-moe, not main. Merge #69 first.
The emulator half is
PLENA_Simulator#101,
stacked on PLENA_Simulator#97; the submodule pin couples them, so this branch
must land before that one.

1. stage becomes required — and stays required

209e299 drops the default from moe_materialize_route_weights_for_active_rows_v0,
moe_true_zero_vram_rows_v0 and moe_expert_activation_v0. Markers are sticky, so
an emitter called from inside a marked region inherits the enclosing marker: a
default for that parameter is a silent wrong answer rather than a missing one.
This is not hypothetical — the shared-expert sigmoid gate inherited
expert_route_weight and misattributed 999 instructions while every total still
added up and every test stayed green.

A defaulted stage is only one of several ways to supply one without the caller
naming it. 7b9d46e also rejects:

  • lambdas — same arguments node, none of the coverage;
  • functools.partial / partialmethod bindings that pre-bind stage;
  • decorators handed stage=, e.g. @with_stage(stage="gather").

A decorator that injects a stage without naming it in its own call cannot be
detected without resolving what the decorator does. That limit is stated at the
helper, with a TODO saying the answer is an explicit allowlist rather than deeper
analysis.

2. The lint now runs

74ec261 adds a moe-stage-guard job. Before it, ci.yml ran the generator tests
and a codegen smoke and nothing under aten/tests at all — so the guard could
not have failed a pull request, which is indistinguishable from not having written
it. The job needs neither torch nor a checkpoint (the lint parses source with
ast), so it is pytest + pyyaml and a couple of seconds.

Naming one file in a workflow step recreates the same hole one file over, so
test_every_test_file_here_is_wired_into_ci asserts every aten/tests/test_*.py
is either named in ci.yml or listed in _UNWIRED_TESTS. The five files on that
list all import torch; pinning them is what stops a new unwired file hiding among
them, and the list is checked in both directions.

3. Attribution fixes

  • residual_setup joins MOE_STAGES (7565629). MoE input preparation — the
    residual buffer zero, the residual copy, the input RMSNorm and its norm-weight
    multiply — was being labelled accumulator_init, which is the combine
    accumulator that expert outputs are scattered into. Different thing, and the cost
    scales with rows × hidden rather than with the accumulator.
  • The two qwen3_router_logits_* emitters gain the router_topk marker. They
    had none, and both lower through general-purpose projection helpers that have
    none either, so the entire router GEMM was billed to whatever stage preceded the
    call. moe_router_logits_bf16_v0 already marked itself; these now match. This is
    part of the same fix rather than a separate one: relabelling the residual zero
    without it would have moved the Qwen router GEMM into residual_setup — a new
    wrong attribution instead of the old one.

Markers are comments (IsaBuilder().comment(...)), so no instruction count, cycle
total or numerical result changes — only which stage the profile bills.

4. Caveat de-duplication

SHARED_VS_ROUTED_NOTE becomes a module constant in program_moe_shared.py
(916f990). The warning that a shared-vs-routed ratio measures this compiler's
lowering rather than MoE hardware previously existed as prose here and, in
different words, as a hardcoded string in the emulator that ships it in the profile
JSON — with nothing checking they agreed. The wording of a caveat is the whole of
its content. The emulator keeps its own copy (it writes that JSON with no Python
available) and a test on that side asserts byte equality.

a387f98 also qualifies fused_shared_intermediate's "exactly equal" as holding
in exact real arithmetic: as emitted, FP associativity and MXFP8 block alignment
(e4m3, one e8m0 scale per block of 8) both make it non-bit-identical. Small, and it
does not make the fusion wrong — but a bit-exactness test written against the old
wording would have failed for entirely correct reasons.

Validation

pytest aten/tests/test_moe_stage_attribution.py — 7/7, on pytest + pyyaml with no
torch installed. Each guard was also verified to fail correctly: an injected
defaulted-stage lambda, a partial binding and a partialmethod binding are each
reported with file and line; a stage="acumulator_init" typo on a MoE callee is
reported while qkt_multiply(stage="decode") is not; an unwired test file and a
stale _UNWIRED_TESTS entry each fail by name.

🤖 Generated with Claude Code

qichao-arlo-wang and others added 10 commits July 29, 2026 01:32
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…emitters

Marker attribution is sticky: a helper called from inside a marked region
inherits the enclosing marker, so an emitter reused across stages must be told
which one it serves. When that parameter has a default, forgetting it is not a
missing answer but a silently wrong one -- instruction counts, cycle totals and
numerics are all unchanged, so nothing downstream fails.

That is not hypothetical. The shared-expert sigmoid gate reused
`moe_materialize_route_weights_for_active_rows_v0`, inherited its
`expert_route_weight` default, and misattributed 999 instructions with the whole
suite green. Adding the `stage` parameter fixed that call site; it left the
mechanism that produced it in place.

Drop the default from all three stage-polymorphic emitters:

  moe_materialize_route_weights_for_active_rows_v0  (was expert_route_weight)
  moe_true_zero_vram_rows_v0                        (was accumulator_init)
  moe_expert_activation_v0                          (was expert_activation)

`moe_true_zero_vram_rows_v0` is the one that mattered most: it serves the most
stages and had the most callers, so its default silently absorbed every caller
that never thought about attribution.

The scope rule is "takes a `stage` parameter", not "is reused across >= 2
stages", because the two coincide by construction -- an emitter serving exactly
one stage hardcodes its marker and never takes the parameter.

One internal caller (moe_dynamic_expert_pair_v0 -> moe_expert_activation_v0) was
relying on the default and now names `expert_activation` explicitly. All 7
compiler call sites pass `stage`; behaviour is unchanged.

test_moe_stage_attribution.py locks the rule in: no defaulted `stage`
parameters, every literal `stage=` argument is a declared MOE_STAGES member, and
a self-check proving the AST walk can still fail -- without which a broken
scanner would pass over zero findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lint landed in 209e299 with no workflow invoking it. `ci.yml` ran the
generator parser tests and a codegen smoke, and nothing under `aten/tests`
at all -- so the guard against a defaulted `stage` parameter could not have
failed a pull request. A test nobody runs is indistinguishable from a test
nobody wrote.

Add a `moe-stage-guard` job. It needs neither torch nor a checkpoint,
because the guard reads sources with `ast` rather than importing them;
pyyaml is the one dependency, and only because collecting anything under
`aten/tests` imports `aten/__init__.py`, which pulls the op registry in.
That keeps the job at pytest + pyyaml and a couple of seconds, so it does
not sit behind `needs: syntax-check`.

Also add `test_every_test_file_here_is_wired_into_ci`, because naming a
single file in a workflow step reintroduces the same hole one directory
over: the next test file added here would again run nowhere. It asserts
every `aten/tests/test_*.py` is either named in `ci.yml` or listed in
`_UNWIRED_TESTS`. The five files on that list all import torch and some want
a real checkpoint, so wiring them is separate work -- pinning them is what
stops a new unwired file from hiding among them. The list is checked in both
directions, so an entry that gets wired up or deleted has to leave it.

Verified: the guard passes at 4/4 on pytest + pyyaml with no torch present;
dropping an unwired file into the directory fails it by name; and a stale
`_UNWIRED_TESTS` entry fails it too.

Addresses Agent 2 CRIT#1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_guard_would_catch_a_defaulted_stage_parameter` exists to prove the
lint can fail. It did that by reimplementing the argument walk inline, which
proves only that the copy agrees with itself -- and the copy had already
drifted: it selected functions with `isinstance(node, ast.FunctionDef)`,
while `ast.AsyncFunctionDef` is a sibling class rather than a subclass. So
`async def` emitters were outside the self-check entirely. The real lint
iterates `_functions`, which handles both, so the two had silently diverged
in exactly the direction this test is supposed to rule out.

Extract `_defaulted_stage_params(func)` -- the kwonly/positional/posonly
default alignment, in one place -- and call it from both the lint and the
self-check. The self-check now writes its fixture to `tmp_path` and iterates
`_functions`, so it exercises the same read-parse-select path as the lint
instead of a parallel one.

The fixture grows an `async def` offender and an `async def` clean case, so
the drift above would now be caught. Assert on sorted names rather than walk
order, which is an implementation detail of `ast.walk`.

Verified: 4/4 pass; the shared helper flags defaulted `stage` for async
kwonly, async positional and sync posonly parameters, and leaves a
non-defaulted `stage` and a stage-less function clean.

Addresses Agent 2 CRIT#2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_stage_arguments_are_declared_moe_stages` matched every `stage=`
keyword argument in the tree and checked the value against MOE_STAGES.
`stage` is not a reserved word: the attention path calls
`qkt_multiply(stage="decode")`, where it selects prefill vs decode. Within
`aten/plena` today every `stage=` callee happens to be a `moe_*` emitter, so
the lint is green by luck -- widen the scan by one directory and it reports
correct attention code as passing an unknown MoE stage. A lint that fires on
correct code gets suppressed, and then it protects nothing.

Match on the callee instead: `_is_moe_callee` accepts the `moe_` and
`gpt_oss_` prefixes (`moe_stage_marker` falls under `moe_`), and
`_moe_stage_arguments` yields `(callee, keyword)` pairs so the scan is one
helper rather than a nested walk inlined into the test. `_callee_name`
resolves `f(...)` and `obj.f(...)` alike.

Add `test_non_moe_stage_arguments_are_not_flagged` over a fixture holding
`qkt_multiply(stage="decode")`, `attention_softmax(stage="attn_input")` and
`builder.flash_attention(stage="prefill")` next to one real MoE call,
asserting only the MoE call is matched.

Offender lines now name the callee and point at the keyword rather than the
call, which for these multi-line emitter calls is a different line.

Verified: 5/5 pass; a deliberate `stage="acumulator_init"` typo on
`moe_true_zero_vram_rows_v0` is still reported, and `qkt_multiply` is not.

Addresses Agent 2 SF S2. Landed before the simulator-side scan because that
scan reaches `flashattn_qkt_test.py`, where the false positive is real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two attribution holes on the same stretch of the decoder MoE sublayer.

**No stage describes MoE input preparation.** Between the last attention
marker and the router, a program zeroes the residual buffer, copies the
post-attention hidden state into it, and runs the MoE input RMSNorm with its
norm-weight multiply. Testbenches were labelling the residual zero
`accumulator_init`, which is a different thing: `accumulator_init` is the
combine accumulator that expert outputs are scattered into, while these rows
are a residual copy added back *after* the experts run. The cost scales with
rows x hidden, and none of it is accumulator initialisation.

None of the other twelve stages fits either, so add `residual_setup` rather
than keep folding it into a stage it contradicts.

**The two Qwen router emitters emit no marker at all.**
`moe_router_logits_bf16_v0` marks itself `router_topk`;
`qwen3_router_logits_matrix_bf16_rowpacked_v0` and
`qwen3_router_logits_packed_skinny_bf16_rowpacked_v0` do not, and both lower
through `linear_projection_bf16*` / the packed-skinny helper, which are
general-purpose and carry no marker either. Markers being sticky, the entire
router GEMM was billed to whatever stage preceded the call.

That makes this part of the same fix rather than a separate one: relabelling
the residual zero without marking these would move the Qwen router GEMM from
`accumulator_init` into `residual_setup` -- a new wrong attribution instead
of the old one. Both paths now mark themselves like the vector-dot path.

Markers are comments (`IsaBuilder().comment(...)`), so no instruction count,
cycle total or numerical result changes; only which stage the profile bills
the region to.

The emulator side of `residual_setup` -- the `StageKind` variant and the
testbench call sites that use it -- lands with the pin bump in
PLENA_Simulator, since `stage_marker_names_match_the_compiler_vocabulary`
requires the two vocabularies to move together.

Verified: the module parses; the stage extractor recovers 14 stages
including `residual_setup`; all four router emitters now emit `router_topk`;
the attribution guard passes 5/5.

Addresses Agent 3 SF S3 (the misattribution) and a marker gap found while
fixing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…efault

`test_stage_parameters_have_no_default` walks `def` and `async def`
signatures. Making `stage` required is only enforced to the extent the lint
covers every way of supplying one, and three constructs are outside it:

- a lambda, which carries the same `arguments` node with defaults and none
  of the coverage;
- `functools.partial(emit, stage=...)` and `partialmethod`, which pre-bind
  the argument at the binding site so every call through the result omits it;
- a decorator handed `stage=`, which can inject it into the wrapped call.

Each leaves call sites that never name a stage, which is precisely the
condition the required parameter exists to prevent -- reached by a route the
lint was not looking at. None is hypothetical enough to ignore: `partial` is
the ordinary way to specialise an emitter, and `_DEPRECATED_METHOD_ALIASES`
already rebinds emitters onto the mixin.

`_stage_defaulting_lambdas` reuses `_defaulted_stage_params` unchanged, since
`ast.Lambda` and `ast.FunctionDef` share the `arguments` node.

The decorator check catches the declared shape, `@with_stage(stage="gather")`.
A decorator that injects a stage without naming it in its own call -- from a
closure, a registry, an attribute -- cannot be detected without resolving
what the decorator does, which is past what a source lint can do. That limit
is stated at the helper with a TODO saying the answer would be an explicit
allowlist of stage-injecting decorators, not deeper analysis.

`test_guard_would_catch_an_indirectly_supplied_stage` covers all three
against a fixture, with clean counterparts for each.

Verified by injecting each construct into `program_routed_moe.py` and
running the lint: a defaulted-stage lambda, a `partial` binding and a
`partialmethod` binding are each reported with file and line; a lambda whose
`stage` has no default, and a `partial` binding something other than
`stage`, are not. 7/7 pass with the file restored.

Addresses Agent 2's NIT on AST bypasses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…two places

The warning that a shared-vs-routed cycle ratio measures this compiler's
lowering rather than MoE hardware exists twice: as a paragraph in this
module's docstring, and as `SHARED_VS_ROUTED_NOTE` in the emulator's
`stage_profile.rs`, which puts it in the profile JSON. The two say the same
thing in different words, with nothing checking that they keep doing so.

That is two failure modes at once. A caveat living only in compiler source
is a caveat nobody applies, because the JSON is what consumers read -- which
is why the emulator restated it. A caveat restated by hand across two
repositories is one that quietly stops matching, and the wording of a caveat
is the whole of its content.

Declare `SHARED_VS_ROUTED_NOTE` here as the canonical text. The docstring
keeps the module-specific detail -- the per-pair gather and the
"intentionally wasteful" quote -- and defers the consequence to the constant
instead of restating it.

The emulator keeps its own `const` rather than reading this at runtime: the
profile is written by the emulator with no Python available, and shelling out
to produce a JSON field would be worse than a checked copy. A test on that
side parses this constant and asserts the two are byte-identical, so the copy
cannot drift silently. It lands with the pin bump.

Verified: the module parses; the two strings are byte-identical at 432
characters; the attribution guard passes 7/7.

Addresses Agent 1 NIT#6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`fused_shared_intermediate` said the fused MLP "is exactly equal to the sum
of the individual shared experts -- no approximation". The algebra is right:
SwiGLU is elementwise along the intermediate axis and the down projection
sums over it, so concatenating the shared experts and running one MLP is an
identity. But "exactly equal" reads as a claim about the emitted program,
and the emitted program is not bit-identical, for two reasons that have
nothing to do with the algebra:

- The down projection reduces over the whole concatenated axis in one pass,
  where the unfused form does `n_shared_experts` reductions and adds them.
  Floating-point addition is not associative, so the roundings differ before
  quantisation enters into it.
- Expert weights are stored MXFP8 -- e4m3 elements with one e8m0 scale per
  block of 8 along that axis (`load_config.rs:717`). When
  `moe_intermediate_size` is not a multiple of that block, the fused tensor's
  blocks straddle expert boundaries, so a block's scale is derived from values
  belonging to two different experts and the quantised weights themselves
  differ, not merely their sum.

Both are small and neither makes the fusion wrong. The reason to state them
is that a bit-exactness test written against the old wording would fail for
entirely correct reasons, and whoever wrote it would go looking for a bug
that is not there.

Addresses Agent 3 N1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_stage_arguments_are_declared_moe_stages` justifies itself with "a typo on
a rarely-exercised branch would reach the emulator, land in
`unresolved_stage_markers`, and leave that region inheriting the previous
marker". It could not have caught one. `_moe_stage_arguments` inspected
`node.keywords` only, and all 19 `moe_stage_marker(...)` call sites under
`aten/plena` pass the stage **positionally** — so the one construct that
actually emits a marker was entirely outside the lint. It was scanning three
`stage=` keyword arguments and none of the markers.

`moe_stage_marker` validates its argument at runtime, but only on paths a test
executes, which is exactly the gap this lint is supposed to cover.

Match the first positional argument for callees in `_POSITIONAL_STAGE_CALLEES`
as well as `stage=` keywords. Coverage across `aten/plena` goes from 3 stage
names to 25. `_moe_stage_arguments` now yields the `ast.Constant` rather than
the keyword node, since the two spellings have no common wrapper.

`test_non_moe_stage_arguments_are_not_flagged` grows a positional marker in its
fixture and a standalone assertion that a positional typo is visible, so the
blind spot cannot come back silently. It compares sorted results: `ast.walk` is
breadth-first and its order is not a property worth pinning.

Also adds `workflow_dispatch` to `ci.yml`. `pull_request` is filtered to
`branches: [main]`, so a stacked PR — which targets its parent branch — fires
no jobs at all, and the guard this file added in 74ec261 could not run on the
PR that adds it. Manual dispatch is the smallest fix that makes a stacked PR
verifiable before it retargets main.

Verified: 7/7 pass. Injecting `moe_stage_marker("expert_bais", ...)` into
`program_routed_moe.py` is now reported as `program_routed_moe.py:921`; before
this commit it was not reported at all.

Addresses review findings on lint coverage and CI triggering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qichao-arlo-wang
qichao-arlo-wang force-pushed the feat/shared-expert-moe branch from 311561e to 60239b0 Compare July 31, 2026 00:56
@qichao-arlo-wang

Copy link
Copy Markdown
Collaborator Author

Closing: everything on this branch is now in #69, which targets main directly.

The two branches have been merged into one history and reorganized by intent rather than by review round, so this PR's diff no longer means anything — its base (feat/shared-expert-moe) was rewritten.

What changed in the process, beyond the merge:

  • feat(moe): shared-expert emitters and explicit stage markers is split in two along its file boundary, which turned out to be its intent boundary: the marker vocabulary and the moe_* API migration in one commit, the shared-expert emitters in the next.
  • The five commits that make stage required and give it a lint that works are now one contiguous group, and read as a sequence of "here is another way the lint was not doing its job" — including that for several commits it was scanning three keyword arguments and none of the 19 real moe_stage_marker calls.
  • residual_setup is grouped as an attribution correction, after the enforcement work that made it findable, and it pins together with its emulator half.
  • The workflow_dispatch trigger is split out of the lint commit and grouped with the rest of the CI wiring.

The emulator half, PLENA_Simulator#101, is closed into PLENA_Simulator#97 the same way.

Nothing is lost: the pre-reorganization history is preserved at backup/moe-stage-20260731-0149, and this branch (feat/moe-stage-required-arg) is left in place untouched.

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