MoE: shared experts, arbitrary V_TOPK routing, and a checked stage-attribution contract - #97
Merged
Merged
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
qichao-arlo-wang
force-pushed
the
feat/moe-timing
branch
from
July 27, 2026 20:47
13423dc to
fea3dc5
Compare
qichao-arlo-wang
force-pushed
the
feat/moe-timing
branch
from
July 31, 2026 01:06
6e80ed8 to
6926cb7
Compare
qichao-arlo-wang
force-pushed
the
feat/moe-timing
branch
3 times, most recently
from
August 3, 2026 13:18
45588e0 to
ca11ba5
Compare
Companion to the compiler's 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", pointing at the wrong thing. classify_timing_access's V_TOPK arm derives the logit read extent from the expert count; its 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. The 0x38 decode arm sits with the other control ops so the block stays strictly ascending; match arms are disjoint constants, so that is behaviour-neutral.
Capture real MoE routing, replay it through the emulator, measure cycles and HBM bytes. - replay/ -- dependency-free route-trace schema and validator, sample-trace generator, replay runner, timing validation gates, result export. - qwen/ -- Qwen3 router-trace generation from tokenized inputs and local weights, 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. These were written against emulator work that landed on main independently and was hardened afterwards, which changed what the profile emits. They are migrated to stage-profile schema v3 here: `total_stage_wall_cycles` was removed as tautological, `cycle_fraction` became `time_fraction`, and `resource_proxy_cycles.ramulator_proxy` was removed. Summing per-stage `wall_cycles` is now forbidden -- each stage rounds up independently, so n stages over-report by up to n-1 cycles; `_sum_stages` sums `wall_picos` and rounds once. These harnesses measure MoE layers in isolation. 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`; numerical correctness is validated by the routed-MoE op tests, not here.
…o boundary `classify_comment` guessed stages by substring-matching the compiler's generated ASM prose. Unfixable for shared experts: there was no way to express a stage the substring table did not already contain. `; @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. Three new stages (shared_expert_projection / _activation / _gate) plus residual_setup, and schema version 4 with two new reported fields: `classification.stage_attribution` (which mode ran) and `classification.unresolved_stage_markers` (names the compiler emitted that no StageKind matches, i.e. the vocabularies drifted). The profile's cross-repo claims are checked, not mirrored. The stage vocabulary used to be compared against a hand-copied array commented "Mirrors MOE_STAGES in ..."; a copy only fails when somebody remembers to update it, which is the same failure mode as having no guard, and the compiler is the side that moves. It is now parsed out of the pinned submodule with Python's own `ast` -- not a hand-rolled scanner, which fits today's declaration and is silently wrong on four shapes, returning a *truncated* set rather than failing. Ten adversarial shapes and six unprovable ones are pinned. The shared-vs-routed caveat is emitted as `classification.attribution_notes.shared_vs_routed` and a test asserts it is byte-identical to the compiler's canonical constant. The contract crosses the repo boundary: the compiler lints its own emitters and stops at PLENA_Compiler, so a wrong stage name in a testbench here was exactly as invisible as one in the compiler used to be. Making `stage` required turned six testbench call sites into hard errors, every one of which had been inheriting `moe_true_zero_vram_rows_v0`'s `accumulator_init` default. That they surfaced at all is luck -- a wrong but declared stage name would have run fine and changed no instruction count, cycle total or numerical result. test_testbench_stage_attribution.py closes that, importing the matcher from the compiler's guard by file path rather than copying it. Attribution corrections: `residual_setup` for the pre-MoE hidden state added back after the experts run; the HBM store slot moves to `scatter_combine`; and `router_topk` joins the routed branch -- it does not scale with top_k, which is why it was excluded, but it scales with num_experts and has no counterpart at all in a dense or shared-only layer. test_branch_split.py asserts every StageKind is classified exactly once across shared, routed and plumbing, read out of stage_profile.rs rather than mirrored. Route-trace schema v2 adds shared_experts / shared_intermediate_size / shared_gate; a v1 trace is a valid v2 trace with no shared branch. The validator rejects half-specified combinations. Shared-expert comparisons are bit-exact at atol=rtol=0, and the router-policy tests assert *which encoding* was taken so a shape cannot quietly fall back to a fixed rmask and leave the C_SET_TOPK_REG escape untested while appearing covered.
qichao-arlo-wang
force-pushed
the
feat/moe-timing
branch
from
August 3, 2026 15:22
ca11ba5 to
9c92e88
Compare
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
Three related pieces of MoE work, plus the cross-repo contract that turned out to
be load-bearing for all of them:
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.
vocabulary and its caveat text rather than mirroring them, and the compiler's
stage lint is pointed at the testbenches on this side of the repo boundary.
Requires AICrossSim/PLENA_Compiler#69;
the submodule pin climbs through that branch here, in six steps.
How to read the commits
The history is grouped by intent, not by the order the work happened in. Each
commit's body opens with its group and position, and every commit that moves the
submodule pin says which step of the ladder it is and why it has to move there.
C_SET_TOPK_REG, execute thermask=15escapereplay/qwen/campaign/, and the v3 schema migration@stage=markers win over substring sniffing; schema v4MOE_STAGESread withast; the caveat held byte-identicalresidual_setup; the router joins the routed branchThe submodule pin ladder
Six steps, each in the same commit as the code that needs it, each strictly
forward along #69's history. This is the whole of the coupling between the two PRs:
StageKindgains the threeshared_expert_*stages, and the guards hold the two vocabularies equal in both directions — so they cannot land separatelySHARED_VS_ROUTED_NOTEstagebecomes required_moe_stage_argumentsto yield theast.Constant, and this repo imports that helper rather than copying itresidual_setupstageGroup A — executing
C_SET_TOPK_REGCompanion to #69's ISA change.
topk_softmaxwas already generic in(expert_count, top_k)— only the rmask decode table was not — so this is adecode 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", 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 — DeepSeek-V3 and Kimi K2 are 256 — crediting the
unmodelled rows to the overlay as free hiding capacity.
The second commit moves the
0x38 => C_SET_TOPK_REGdecode arm out from between0x2Eand0x2F, making the control-op block strictly ascending. Match arms aredisjoint constants, so this is behaviour-neutral.
Group B — 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.
The emulator work these were written against landed on
mainindependently as#98 and was hardened in #99, which changed what the profile emits. The fourth
commit migrates 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 that commit theyhad been silently producing
Nonecolumns rather than raising.Group C —
@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. (router_topkisexcluded from both terms here, and that turns out to be wrong — Group F fixes it
and adds the test that would have caught it.)
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.#69's Group D exists to retire that whole bug class rather than the one instance.
Group D — the profile's cross-repo claims are checked, not mirrored
The stage vocabulary is read, not copied.
stage_marker_names_match_the_compiler_vocabularyused to compare
StageKindagainstCOMPILER_MOE_STAGES, a 13-entry array instage_profile.rscommented "Mirrors MOE_STAGES in …". A copy only fails whensomeone remembers to update it — the same failure mode as no guard at all, since
the compiler is the side that moves.
It now parses
MOE_STAGESout of the pinned submodule withpython3 -c "import ast; ...", emitting sorted JSON thatserde_jsondecodes. Not with ahand-rolled scanner: a scanner that takes the first textual hit, matches a
brace-balanced region and pulls double-quoted runs out of it fits today's
declaration and is silently wrong on four shapes — returning a truncated set
rather than failing:
}frozenset({..}) | frozenset({..}){..} | {..}MOE_STAGESThe silent ones are what matter: the guard compares
StageKindagainst whatevercomes back, so a truncated vocabulary lets it pass while real drift goes
unreported — the exact vacuous green it exists to prevent.
astbinds thedeclaration at module scope rather than finding it by text, so docstring mentions
and same-named locals are excluded by construction, and quote style, comments,
unions,
frozenset(...), list arguments and implicit concatenation all fall outwithout special cases. Ten adversarial shapes and six unprovable ones are pinned.
The caveat consumers actually read is now emitted, and checked.
classification.attribution_notes.shared_vs_routedis a new consumer-visiblefield carrying the routed-lowering caveat to where numbers are actually read,
rather than leaving it in compiler docstrings. The emulator keeps its own
const— it writes that JSON with no Python available — but a test parses #69's canonical
SHARED_VS_ROUTED_NOTEand asserts byte equality.STRING_CONSTANT_EXTRACTORgeneralises the
astapproach to any module-level string and is pinned to failrather than invent a value for an absent constant, a value-less annotation, a
non-string, a computed value, or a function-local of the same name.
schema_versionstays at4: v4 is introduced by Group C on this same unmergedbranch, so this extends a schema that has never shipped. The field is purely
additive, but a consumer cannot detect its presence from the version alone.
A missing submodule checkout or absent
python3skips with a reason rather thanpanicking — but only outside CI, keyed on
$CI. CI checks outsubmodules: recursiveand runs insidenix develop, so a missing prerequisite there is abroken pipeline, and skipping would restore the vacuous green these guards exist
to prevent.
Group E — the contract crosses the repo boundary
The compiler lints its own emitters and stops at
PLENA_Compiler. The testbencheshere call the same emitters from the other side of it, so a wrong stage name in a
testbench was exactly as invisible as one in the compiler used to be.
The first commit is the evidence: making
stagerequired turned six testbenchcall sites into hard errors, every one of which had been inheriting
moe_true_zero_vram_rows_v0'saccumulator_initdefault. None had ever statedan attribution; they simply inherited one. That they surfaced at all is luck — a
signature change stopped them running. A stage name that was wrong but spelled
like a real one would have run fine and changed no instruction count, cycle total
or numerical result.
test_testbench_stage_attribution.pycloses that. The matcher is imported fromthe compiler's guard by file path, not copied — a second copy of "which callees
take a MoE stage argument" is the same mirroring failure this whole branch is
about. Loading by path also sidesteps
aten/__init__.py, so the job needs pytestand nothing else: no nix, no yaml, no torch.
test_the_known_call_sites_are_actually_scannedpins the sites by path and count,because both lints pass by finding nothing and a glob that stopped matching would
look identical to being correct. Keyed by path relative to the testbench root
rather than by basename: two files with the same name in different directories
would otherwise collapse into one key and silently drop a site from the count this
exists to pin.
Group F — attribution corrections found by review
residual_setup.DecoderMoeResidualholds the pre-MoE hidden state addedback after the experts run — not the combine accumulator, which is zeroed
separately further down and was already correct. Markers being sticky,
accumulator_initwas also absorbing the residual copy, the input RMSNorm andits norm-weight multiply.
ResidualSetupalso joins the pair-label reset, forthe same reason
RouterTopkandAccumulatorInitare in it: it runs before anyrouting, so it must not inherit the previous
(token, expert)pair's label.Latent in a single-layer program; real the moment a second MoE layer is emitted.
it belongs to
scatter_combinerather thanaccumulator_init.router_topkjoins the routed branch. It does not scale withtop_k—which is why it was excluded — but it scales with
num_expertsand has nocounterpart at all in a dense or shared-only layer. Excluding it understated
routing by the whole router GEMM plus top-k selection. Input and combine plumbing
stays in neither term, unchanged.
test_branch_split.pyasserts everyStageKindthe emulator can bill to isclassified exactly once across shared, routed and plumbing — read out of
stage_profile.rsrather than mirrored, so a stage added on the Rust side cannotquietly fall out of the split and read as free.
CI
stage-attribution-guardruns the testbench guard and the branch-split unit tests.PLENA_Compilerjoins the workflow's path filters: a compiler pin bump is preciselywhat moves
MOE_STAGES, and it was not triggering this workflow at all.test_every_guard_file_is_wired_into_cirequires every torch-freetest_*.pyundertestbench/to be named in the job, so the next guard added here cannot run nowhere.Unlike #69, the CI wiring here is not collected into one commit at the end. Each
job is added alongside the guard it runs, because
test_every_guard_file_is_wired_into_ciasserts the workflow's file list is complete — deferring the wiring would make the
intervening commits red.
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
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. That is what
classification.attribution_notes.shared_vs_routednow says, in the JSON, heldbyte-identical to the compiler's canonical wording. Expert-major batching is
follow-up work on #69; 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 ~4.5k lines of Python with thin CI coverage.ruffcoversformatting and lint. Partly closed here:
replay/utils.py's branch split now hasunit tests in CI, and 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.
replay/utils.py:13hard-codes a machine layout:PLENA_ROOT = REPO_ROOT.parents[1],with no environment override (unlike
PLENA_OUT_ROOT).qwen/utils.py:13imports the name and is a consumer, not the definition — both being line 13 is a
coincidence worth not repeating.
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
cargo testcannot linkramulatoroutsidenix develop, which is not availableon the machine this was written on. So, explicitly:
cargo fmt --all -- --checkandcargo check --testspass at all 7 Rust-touching commits; the pytest guards pass at everycommit where they exist (4 → 10 tests), run with
CI=1so none of them can takea prerequisite-missing skip.
StageKind↔MOE_STAGESlockstep was checked directly at every commit,since
cargo testcannot run locally: the two sets match in both directions fromthe moment the marker mechanism lands, 13 names through Group E and 14 from the
residual_setupcommit onward, stepping exactly where the pin does.exact source text of the extractors: the adversarial and negative canaries pass,
the real compiler source yields 14 stages, the two caveat strings match verbatim
at 432 characters, and a reworded caveat compares unequal.
index()is dense and unique over0..=14(15 variants,Otherlast).call site, an unclassified stage, an unwired guard file and a missing submodule
under
$CIeach fail by name.pre-existing routed-MoE CI tests still pass, 87 emulator unit tests, and
python -m compileall/ruff format --check/ruff checkclean.The full
cargo test --workspace --releaseand thejustintegration suites neednix developand are covered by CI on this PR, which — unlike the stacked branchthis work previously lived on — now triggers automatically, since the base is
main.When to reconsider adding an ISA for shared expert
Shared-expert support is a software-only decomposition: no new opcode, no new
register. The only ISA addition across both PRs 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 equal to the sum of the individual sharedexperts in exact real arithmetic (see #69 on FP associativity and MXFP8 block
alignment, neither of which affects the decomposition's validity).
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.An ISA would also not have solved this work'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 addresses none: the first and lastare 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 loopoverruns the instruction-memory budget. 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.
Note on branch naming
The branch is still called
feat/moe-timing, which now under-describes it — thetiming harnesses are one of four groups. Renaming would close this PR, so the name
stays and this paragraph exists instead.
🤖 Generated with Claude Code