Skip to content

Deterministic emulator timing, stage profile v2, and experimental overlap model - #98

Merged
qichao-arlo-wang merged 1 commit into
mainfrom
feat/deterministic-emulator-timing
Jul 26, 2026
Merged

Deterministic emulator timing, stage profile v2, and experimental overlap model#98
qichao-arlo-wang merged 1 commit into
mainfrom
feat/deterministic-emulator-timing

Conversation

@qichao-arlo-wang

Copy link
Copy Markdown
Collaborator

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 the
Python 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 were
    tie-broken by comparing their heap addresses (core::ptr::eq / (self as *const Self).cmp(...)). Allocation layout varies between runs, so same-instant
    events could fire in different orders. Replaced with a monotonic
    sequence_id issued by ExecutorInner, making the tie-break allocation-independent
    creation order. New test: test_same_instant_events_fire_in_schedule_order.

  • lib/memory/src/chunked.rsgather() raced all chunk reads in a
    FuturesUnordered pool. Completion order genuinely doesn't matter (each result
    carries its own dst_offset), but issue order reaches ramulator's FR-FCFS
    scheduler and changes row-hit/miss behaviour, and therefore timing. Switched to
    join_all over an ordered iterator: reads are issued in input order, then
    complete concurrently. New test: test_gather_issues_reads_in_input_order
    asserts the recorded address sequence. lib/ramulator/src/model.rs gets the
    same treatment.

run_emulator_repeat_gate() (emulator_runner.py) is the opt-in check: run the
same artifact N times, require identical sim_latency_cycles, fail otherwise.

2. Stage profile schema v1 → v2

stage_profile.rs previously recorded only instructions / seconds / HBM bytes.
Added:

  • wall_cycles per opcode, and resource_proxy_cycles bucketed 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.
  • Cycle-accounting self-check fields: total_simulation_cycles,
    total_profiled_cycles, total_stage_wall_cycles, total_unprofiled_cycles,
    cycle_accounting_status.
  • logical_bytes_* (explicitly null) split from physical_hbm_bytes_* (real
    64B HBM deltas), each with a status string, so a reader can't mistake one for
    the other.
  • Classification coverage guard. Stage labels come from grepping the
    compiler's generated ASM comments — an implicit cross-repo contract. If the
    compiler's comment vocabulary drifts, opcodes silently fall into Other and
    the profile is quietly wrong. The profiler now emits
    classification.unclassified_fraction and warns past a threshold, and
    gpt_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_ops is still strictly serial. timing_overlay.rs is a post-hoc estimator
layered on top of that serial execution, gated behind
--experimental-overlap-prefetch-compute. It tracks pending prefetches with
their 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:

  • Time is accumulated in picoseconds and converted to cycles exactly once in
    summary(). Accumulating per-op div_ceil cycles 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_*_WO write-out ops carry only a destination (rd + imm), no input
    operands, 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)

  • The overlay is an estimate, not a measurement. It does not model DMA bandwidth,
    queue depth, or bank contention; and H_STORE_V / C_BREAK conservatively
    flush all pending prefetches. Real concurrent prefetch execution in the
    transactional sim is follow-up work.
  • timing_access_for_opcode hand-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_cycles rounds per-op, so total_stage_wall_cycles
    and total_simulation_cycles agree only while every op duration is a whole
    multiple of PERIOD. That holds today (hbm2_preset, tCK = 1 ns = PERIOD);
    a different DRAM preset or core clock would break the identity.
    cycle_accounting_status reports which case you are in.
  • Stage classification remains string-matching on compiler ASM comments. The
    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 runs cargo build --release on every call instead of
only 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 pick
up an extra cargo check.

Verification

  • cargo build --release — clean, no warnings
  • cargo test --workspace --release — 142 passed, 0 failed
  • cargo fmt --all -- --check — clean
  • uv run ruff format --check . / ruff check . — clean
  • just test-routed-moe-expert — PASSED, 100% match rate, classification
    coverage 12.2% unclassified
  • Emitted stage_profile.json verified by hand: schema_version: 2, resource
    buckets sum exactly to total_profiled_cycles (4516), cycle_accounting_status: profiled_cycles_match_total

🤖 Generated with Claude Code

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
qichao-arlo-wang marked this pull request as ready for review July 26, 2026 17:19
@qichao-arlo-wang
qichao-arlo-wang merged commit 2539533 into main Jul 26, 2026
4 checks passed
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>
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