MoE: timing-replay harnesses, shared experts, parameterized V_TOPK routing - #97
Open
qichao-arlo-wang wants to merge 8 commits into
Open
MoE: timing-replay harnesses, shared experts, parameterized V_TOPK routing#97qichao-arlo-wang wants to merge 8 commits into
qichao-arlo-wang wants to merge 8 commits into
Conversation
This was referenced Jul 23, 2026
qichao-arlo-wang
force-pushed
the
feat/moe-timing
branch
2 times, most recently
from
July 23, 2026 22:15
0854354 to
13423dc
Compare
…iming/replay) A dependency-free route-trace schema and validator (hardened to append errors rather than crash on malformed input), a sample-trace generator, a replay runner, timing validation gates (structural, not magic-number), and result export. Outputs default to a git-ignored in-repo location; PLENA_OUT_ROOT overrides the base. Co-authored-by: Michael C Li <[email protected]> Co-Authored-By: Claude Opus 4.8 <[email protected]>
Generate true Qwen3 router traces from tokenized inputs and local weights, convert them to the replay schema, and replay through the emulator. The replay gate is an honest zero-input shape smoke (numerical correctness is validated by the routed-MoE op tests, not here); the resumable batch runner skips only genuinely-passed traces; the compiler submodule resolves on a standard checkout. Co-authored-by: Michael C Li <[email protected]> Co-Authored-By: Claude Opus 4.8 <[email protected]>
Stratified largest-remainder subset selection, a serial-vs-parallel determinism gate that no longer false-negatives when stage profiling is off, and checkpointed parallel replay that is resilient to individual unreadable traces. Co-authored-by: Michael C Li <[email protected]> Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rebasing onto main drops this branch's copy of the emulator commit (a superseded duplicate of 2539533) and picks up schema v3 from #99, which changed what the profile emits. Three of the fields this harness read no longer exist, and one of its sums is now explicitly forbidden. `total_stage_wall_cycles` was removed as tautological -- `record` adds each opcode's time to exactly one stage bucket *and* to the profiled total, so it was always identical to `total_profiled_cycles`. `summarize_run` now reports the profiled figure, in both picoseconds and cycles. `cycle_fraction` became `time_fraction`: once both derived from picoseconds they were the same ratio, so only the exact one survives. `ramulator_proxy` was removed back in #98 -- it was incremented with the identical value as `dma`, so it carried no information. Both were read through `.get()`, so they had been silently producing `None` columns rather than raising. `_sum_stages` added per-stage `wall_cycles` across five routing stages. Under v3 each stage rounds up to a whole period independently, so `n` stages over-report by up to `n-1` cycles; this is exactly the cross-level cycle arithmetic PROFILE_CAVEAT now forbids. It sums `wall_picos` and rounds once, falling back to the old field for pre-v3 profiles rather than silently reporting zero. `timing_validation_gates`'s stage-accounting check chained an equality through the removed field, which would have made it permanently false. It now asserts `cycle_accounting_status == "profiled_time_matches_total"`, which v3 computes in picoseconds and is therefore exact and independent of the clock period. Also passed `dump_cwd` to `run_emulator_repeat_gate` in the Qwen3 replay. #99 added that parameter precisely for this caller; without it the repeat runs fall back to the shared emulator directory, so concurrent campaign workers race on vram_dump.bin / fpsram_dump.bin and copy each other's dumps into their own build directories. The main run already isolated itself. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
qichao-arlo-wang
force-pushed
the
feat/moe-timing
branch
from
July 27, 2026 20:47
13423dc to
fea3dc5
Compare
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
`cargo fmt --all -- --check` is a CI gate (rust-lint) and I did not run it before pushing. Formatting only -- 87 unit tests unchanged and still pass. 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
Two related pieces of MoE work:
moe_timing/{replay,qwen,campaign}) — capture realMoE routing, replay it through the emulator, measure cycles and HBM bytes.
things it turned out to require: routing at arbitrary expert shapes, and an
explicit stage-attribution contract to replace substring-matching the
compiler's ASM comments.
Requires AICrossSim/PLENA_Compiler#69;
the submodule pin bump is included here. Neither is useful without the other.
Part 1 — Timing-replay harnesses
replay/— dependency-free route-trace schema and validator, sample-tracegenerator, replay runner, timing validation gates, result export.
qwen/— Qwen3 router-trace generation from tokenized inputs and localweights, conversion to the validated schema, replay, resumable batch runner,
pilot export.
campaign/— stratified largest-remainder subset selection,serial-vs-parallel determinism gate, checkpointed parallel replay.
Rebased onto the merged emulator work
This branch originally carried a fourth commit, "emulator: deterministic timing,
stage profiling, and overlap model". That work landed on
mainindependently as2539533(#98), was hardened further infc57756(#99), and the copy here was asuperseded duplicate — it has been dropped.
fea3dc5migrates the harnesses to the resulting stage-profile schema v3.Three fields these files read no longer exist, and one of their sums is now
explicitly forbidden:
total_stage_wall_cyclestotal_profiled_cycles).summarize_runreports the profiled figure, in picoseconds and cycles.cycle_fractiontime_fraction— once both derived from picoseconds they were the same ratio, so only the exact one survives.resource_proxy_cycles.ramulator_proxydma, so it carried no information.Σ per-stage wall_cyclesin_sum_stageswall_picosand rounds once. Under v3 each stage rounds up independently, sonstages over-report by up ton−1cycles.All three removed fields were read through
.get(), so before this commit theyhad been silently producing
Nonecolumns rather than raising.Also passes
dump_cwdtorun_emulator_repeat_gatein the Qwen3 replay. #99added that parameter for this exact caller; without it the repeat runs fall back
to the shared emulator directory, so concurrent campaign workers race on
vram_dump.bin/fpsram_dump.bin.Part 2 — Shared experts
A shared expert is an FFN every token passes through, summed into the routed
output unweighted:
moe_inter × n_sharedn_shared_expertsis not an emitter parameter: DeepSeek stores its shared expertspre-concatenated and instantiates a single MLP of width
moe_intermediate_size × n_shared_experts. Because SwiGLU is elementwise alongthe 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.
C_SET_TOPK_REG— routing at any expert shapeV_TOPK'srmaskwas a two-entry table (32/top-4, 128/top-8) covering GPT-OSS andQwen3-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. New sticky control register holds
(num_experts << 8) | top_k;rmask=15escapes to it,
0and1keep their exact previous meaning so existing programsemit byte-identical ASM.
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", pointing at thewrong thing.
classify_timing_access'sV_TOPKarm derives the logit read extent from theexpert count; its pre-existing
_ => 128fallback would have under-reported everymodel wider than 128 experts, crediting the unmodelled rows to the overlay as free
hiding capacity.
Stage attribution:
@stage=markers replace substring sniffingclassify_commentguessed stages by substring-matching the compiler's generatedASM prose. 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, so theprofile can answer what fraction of MoE time is architecturally dense versus
routing-dependent. Schema version 4, with two new reported fields:
classification.stage_attribution— which mode ran. Everyvocabulary_*fieldonly means anything in legacy 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.
Route-trace schema v2
Adds
shared_experts/shared_intermediate_size/shared_gatetomodel. Av1 trace is a valid v2 trace with no shared branch, so both are accepted.
shared_intermediate_sizeis the fused width. The validator rejectshalf-specified combinations: a
shared_expertswith no width would replay asrouted-only while still labelled shared, and the timing split would attribute
nothing while looking healthy.
summarize_rungains a shared-vs-routed picosecond split.gather/scatter_combine/accumulator_initare excluded from both terms — combineplumbing shared by the branches, and billing it to "routed" would overstate its
share.
Tests
just test-shared-moejust test-shared-moe-gatedjust test-shared-moe-deepseek-fusedn_shared=2fused + routed-accumulator combinejust test-router-policy-allShared-expert comparisons are bit-exact at
atol=rtol=0. MXFP8-representableinputs make weight 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.
Router policies assert which encoding they took, not just that routing was
correct — otherwise a shape could quietly fall back to a fixed
rmask, producecorrect indices anyway, and leave the escape path untested while appearing
covered. DeepSeek-V3 spans four MLEN-wide logit blocks and selects expert 255.
Negative-tested
@stage=markers not emitted{'expert_projection': 568}in an unrouted programThe last 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_weight, and re-marking afterwards is too late for a sticky marker.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 in the pinned submodule. 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.
Scope
These harnesses measure the timing of MoE layers in isolation. End-to-end
model timing belongs in
analytic_models/as a composition layer. Numericalcorrectness of the MoE ops is validated by the routed-MoE op tests and the new
shared-expert tests, not by the timing harnesses: the replay drives the emulator
with dummy zero expert weights, so its gate is a shape/no-crash smoke,
deliberately named
zero_input_smoke_gate. Overlap-adjusted cycle counts areopt-in and are an estimate, not a bound in either direction.
Known limitations
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_MRouted cost is strictly linear in
tokens × top_kwith zero batching benefit; theshared branch 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" —
the right call for bring-up, but 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. Documented
on
_branch_splitand inprogram_moe_shared.py's module docstring rather thanleft for whoever reads the numbers to discover. Expert-major batching is follow-up
work; note it gains nothing at batch=1 decode and is a prefill / large-batch
concern.
Carried over from the original review, none introduced by this branch:
moe_timinghas no CI wiring and no tests of its own. ~4.4k lines of Pythonwith no
justfiletarget and no assertions.ruffcovers formatting and lint;nothing covers execution. (The shared-expert and routing tests added here are
in CI.)
campaign/run_subset.py's_result_okreads a stale gate key(
result.get("functional_gate", True), defaulting toTrue). Since the resultsfile is written before the gate assertion fires,
--skip-existingtreats apreviously failed trace as complete and never retries it.
qwen/utils.pyhard-codes a machine layout:PLENA_ROOT = REPO_ROOT.parents[1],with no environment override (unlike
PLENA_OUT_ROOT).replay/timing_validation_gates.pycannot fail.main()computes the gates,writes the summary, prints them, and returns 0 unconditionally.
(
Window 1 P1/P2/P3,p3_rev_*.json).Validation
gpt_oss_topk_testwhich now exercises the compiler's deprecated-alias path
python -m compileall,ruff format --check,ruff check— cleanmain'semulator_runner.pysignaturesWhen to reconsider adding an ISA for shared expert
Shared-expert support here is a software-only decomposition: no new opcode, no
new register. The only ISA addition in this PR is
C_SET_TOPK_REG(0x38), whichbelongs to the routed branch. The shared expert lowers to existing
linear_projection+moe_expert_activation_v0, andfused_shared_intermediatefolds
n_shared > 1into one wide MLP that is exactly equal to the sum of theindividual shared experts — SwiGLU is elementwise along the intermediate axis and
the down projection sums over it, so this is not an approximation.
This is deliberate, and it matches practice: no mainstream accelerator gives the
shared expert its own datapath, because it is the dense path the hardware already
covers. Hardware assist goes to routing/gather-scatter — which is exactly where
V_TOPK/C_SET_TOPK_REGsit.It is also worth being explicit that an ISA would not have solved this PR's actual
pain points. Of the four (marker hijack, the gate's cost shape, pair-major
utilization, profile-contract fragility), a
shared_expert_v0macro-op addressesnone: the first and last are compiler↔emulator attribution contracts, and the
third belongs to the routed lowering. A macro-op would arguably make the first
worse, by hiding the misattribution under one opcode.
So "no ISA" is a decision with an expiry condition, not inertia. Revisit if any of:
shared_expert_gateexceeds ~10% of MoE cycles atrealistic prefill (
rows >= 512), or the gate's emit-time-unrolled token loop(
program_moe_shared.py,for token_idx in range(rows)) overruns theinstruction-memory budget. Note the fix then is a general
V_GEMV/vector-reduce-with-activation, not a shared-expert instruction: attention
scores, router logits and norm statistics all want the same shape. The sigmoid
itself is not a candidate — it is already 4 scalar-FP ops and correctly kept off
the vector path.
The response is still not an ISA: it is moving attribution from ASM comments
to hardware events (tag the stage at dispatch), which needs no new opcode.
instruction set cannot express exactly — e.g. per-token dynamic shared width.
None exists today; the variation across DeepSeek / Qwen2 / GLM / Llama-4 is
entirely in width, gating and count, all of which lower exactly as-is.
The bar used above: an ISA addition should (1) express something SW composition
cannot, (2) pay off across models rather than one checkpoint shape, (3) reduce
verification surface, and (4) encode something semantically settled.
C_SET_TOPK_REGmeets all four — one control register retires a whole class ofper-shape ISA revisions. A
shared_expert_v0macro-op meets none, and would haveto encode
n_shared, activation policy, gate presence and bias presence inhardware — the same trap
C_SET_TOPK_REGexists to avoid.Staying SW-only carries no forward-compat cost: nothing shared-specific enters the
ISA, so adding hardware later cannot break existing programs.
Boundary with Compiler PR #69
routed path processes one
(token, expert)pair per BLEN-row slot and re-fetchesexpert weights per pair; fixing it means re-lowering routed to token-major /
expert-batched. This PR must not touch it.
result until that lands. Shared is batched and routed is per-pair, so the ratio
reflects the routed lowering's overhead at least as much as the architecture.
This caveat is now emitted in the profile itself as
classification.attribution_notes.shared_vs_routed.not refactor: split accelerator op dispatch #69. It is a shared-path codegen issue (static instruction count grows with
batch), unrelated to routed placement.
Review hardening (stacked)
Defensive follow-ups from architectural review live on
feat/moe-timing-review-hardening(stacked on this branch, not yet part of it):
MOE_STAGESout of the pinned submoduleinstead of comparing against a hand-copied list that could never fail in the
direction it existed to protect;
classification.attribution_notes.shared_vs_routedcarries the caveat above intothe profile JSON, where consumers actually read it;
stagebecomes a required argument on the three stage-polymorphic emitters(PLENA_Compiler
feat/moe-stage-required-arg), so the marker-hijack bug classfails at emit time instead of silently misattributing.
🤖 Generated with Claude Code