Deterministic emulator timing, stage profile v2, and experimental overlap model - #98
Merged
Merged
Conversation
Deterministic event ordering (monotonic timer sequence ids; address-ordered HBM gather / ramulator transfers), a cycle-attributed serde stage profile (schema v2) with a routed-MoE classification-coverage guard that warns / fails loudly when the compiler's ASM comment vocabulary drifts, and an opt-in prefetch/ compute overlap model that accumulates in the picosecond domain (rounding to cycles once) and treats write-out ops as dependency-free. A repeat gate and per-run dump cwd round out the Python runner. Co-authored-by: Michael C Li <mcl123@ee-beholder0.ee.ic.ac.uk> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
qichao-arlo-wang
marked this pull request as ready for review
July 26, 2026 17:19
qichao-arlo-wang
added a commit
that referenced
this pull request
Jul 27, 2026
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) <noreply@anthropic.com>
qichao-arlo-wang
added a commit
that referenced
this pull request
Jul 31, 2026
[Group B — MoE timing-replay harnesses · 4 of 4] The emulator work these harnesses were written against landed on `main` independently as #98, and was hardened further in #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) <noreply@anthropic.com>
qichao-arlo-wang
added a commit
that referenced
this pull request
Aug 3, 2026
[Group B — MoE timing-replay harnesses · 4 of 4] The emulator work these harnesses were written against landed on `main` independently as #98, and was hardened further in #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) <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.
Emulator-core half of #97, split out so it can land on its own. #97 keeps the
moe_timing/Python harnesses and will be rebased onto this once it merges.Self-contained: 13 files, no reference to
moe_timing/, no dependency on thePython layers in #97.
What this does
1. Deterministic timing
Timing measurement is only meaningful if the same artifact produces the same
cycle count twice. Two sources of non-determinism are fixed:
lib/runtime/src/executor.rs— timers scheduled for the same instant weretie-broken by comparing their heap addresses (
core::ptr::eq/(self as *const Self).cmp(...)). Allocation layout varies between runs, so same-instantevents could fire in different orders. Replaced with a monotonic
sequence_idissued byExecutorInner, making the tie-break allocation-independentcreation order. New test:
test_same_instant_events_fire_in_schedule_order.lib/memory/src/chunked.rs—gather()raced all chunk reads in aFuturesUnorderedpool. Completion order genuinely doesn't matter (each resultcarries its own
dst_offset), but issue order reaches ramulator's FR-FCFSscheduler and changes row-hit/miss behaviour, and therefore timing. Switched to
join_allover an ordered iterator: reads are issued in input order, thencomplete concurrently. New test:
test_gather_issues_reads_in_input_orderasserts the recorded address sequence.
lib/ramulator/src/model.rsgets thesame treatment.
run_emulator_repeat_gate()(emulator_runner.py) is the opt-in check: run thesame artifact N times, require identical
sim_latency_cycles, fail otherwise.2. Stage profile schema v1 → v2
stage_profile.rspreviously recorded only instructions / seconds / HBM bytes.Added:
wall_cyclesper opcode, andresource_proxy_cyclesbucketed by opcode family(
matrix/vector/scalar/dma/other). The buckets are disjoint —every opcode lands in exactly one — so a total is their plain sum.
total_simulation_cycles,total_profiled_cycles,total_stage_wall_cycles,total_unprofiled_cycles,cycle_accounting_status.logical_bytes_*(explicitlynull) split fromphysical_hbm_bytes_*(real64B HBM deltas), each with a status string, so a reader can't mistake one for
the other.
compiler's generated ASM comments — an implicit cross-repo contract. If the
compiler's comment vocabulary drifts, opcodes silently fall into
Otherandthe profile is quietly wrong. The profiler now emits
classification.unclassified_fractionand warns past a threshold, andgpt_oss_moe_expert_test.py(already in CI) asserts< 0.35. Measured 12.2%,so there is ample margin. Drift now turns CI red instead of corrupting data.
3. Experimental prefetch/compute overlap model (off by default)
do_opsis still strictly serial.timing_overlay.rsis a post-hoc estimatorlayered on top of that serial execution, gated behind
--experimental-overlap-prefetch-compute. It tracks pending prefetches withtheir SRAM write ranges; each compute op first retires prefetches whose write
range overlaps its read range (a real dependency), then hides the remaining
independent prefetch time behind its own duration.
Two correctness details worth calling out:
summary(). Accumulating per-opdiv_ceilcycles systematically over-hides —two 500 ps prefetches would each round up to a full cycle. Covered by
sub_cycle_prefetches_hide_in_picosecond_domain_not_per_op.M_*_WOwrite-out ops carry only a destination (rd + imm), no inputoperands, so they emit an empty read set. Treating their output region as a
read would spuriously retire prefetches as false dependencies.
This changes reported cycles only — never functional execution or HBM traffic.
Known limitations (deliberate, documented in-code)
queue depth, or bank contention; and
H_STORE_V/C_BREAKconservativelyflush all pending prefetches. Real concurrent prefetch execution in the
transactional sim is follow-up work.
timing_access_for_opcodehand-mirrors each opcode's address computation(verified against the dispatch arms today). There is no shared source of truth
between the two, and no test that they agree.
StageProfiler::duration_to_cyclesrounds per-op, sototal_stage_wall_cyclesand
total_simulation_cyclesagree only while every op duration is a wholemultiple of
PERIOD. That holds today (hbm2_preset, tCK = 1 ns =PERIOD);a different DRAM preset or core clock would break the identity.
cycle_accounting_statusreports which case you are in.coverage guard detects drift; it does not remove the coupling. Replacing it
with a structured stage map emitted by the compiler needs a PLENA_Compiler PR.
Behaviour change affecting existing tests
run_emulator()now runscargo build --releaseon every call instead ofonly when the binary is missing. This prevents false failures where newly
generated ASM hits a stale release binary with old opcode decode logic. The
build is a fast no-op when current, but it applies to every existing caller
(
aten/compare/,models/gpt_oss/,routed_moe/, …), so local test runs pickup an extra cargo check.
Verification
cargo build --release— clean, no warningscargo test --workspace --release— 142 passed, 0 failedcargo fmt --all -- --check— cleanuv run ruff format --check ./ruff check .— cleanjust test-routed-moe-expert— PASSED, 100% match rate, classificationcoverage 12.2% unclassified
stage_profile.jsonverified by hand:schema_version: 2, resourcebuckets sum exactly to
total_profiled_cycles(4516),cycle_accounting_status: profiled_cycles_match_total🤖 Generated with Claude Code