Skip to content

Refactor: isolate A2A3 run stream sets per pipeline slot - #1464

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
Crane-Liu:codex/worker-async-pr5-run-streams
Jul 26, 2026
Merged

Refactor: isolate A2A3 run stream sets per pipeline slot#1464
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
Crane-Liu:codex/worker-async-pr5-run-streams

Conversation

@Crane-Liu

@Crane-Liu Crane-Liu commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Depends on #1463 (merged).

Summary

  • give the A2/A3 device runner an AICPU/AICore stream set per pipeline slot and submit every run on the selected set
  • keep the persistent pair for bootstrap and control work — the Init and RegisterCallable payloads and the block-dim query
  • size the slot array from PTO_PIPELINE_MAX_DEPTH; K=1 selects slot 0 only
  • create a slot's set on first use and reuse it; destroy_run_stream_sets() in finalize() is the sole release point
  • split sync_run_streams() so the wait can name a pair; A5 keeps the no-argument form and the persistent pair

Why a set per slot, not per run

The pipeline contract classifies both streams as EXEC_HANDLE, which asks for
one instance per in-flight run — that is one set per slot, i.e.
pipeline_depth sets, not one set per run() invocation. A stream set carries
no per-run content, so rebuilding it every run buys nothing.

It also costs a great deal. rtStreamCreate/rtStreamDestroy are ~300 us each
on this silicon, and both sit on the synchronous host path around
KernelLaunch. Measured with vector_example at 40 rounds, three alternating
repetitions of each binary under a single device lock (only
libhost_runtime.so swapped between repetitions), host time per round as the
p50 of the per-repetition p50 with warmup rounds dropped:

host p50 vs main
main 10316.8 us
per-run create/destroy 12607.6 us +2290.8 us (+22.2%)
this change 10481.1 us +164.3 us (+1.6%)

main's own three repetitions span 2.7%, so the remaining +1.6% is inside the
run-to-run spread. Device time is unchanged (~250 us) in all three.

Reusing the set also removes the teardown questions the per-run shape raised: a
stream destroy can no longer fail an otherwise successful run, and there is no
window in which a run's streams are destroyed while a launch error left an
AICore kernel spinning on them.

Teardown

run_stream_sets_ is this subclass's own RTS-owning member, so
destroy_run_stream_sets() runs in finalize() while RTS is live and before
the device reset — the window finalize_common() uses for the bootstrap pair,
per the teardown invariant that file documents (#1197). It is idempotent, so
the destructor's finalize() call is a safe backstop. As there, no pre-destroy
sync: rtStreamDestroy is the supported teardown for a stream an op-timeout
left in the error state.

Observability and the reuse test

Reuse is the invariant this PR turns on, and nothing outside DeviceRunner
could see it: streams are opaque handles, the run stream set created line sits
below the default host log threshold, and a leak needs ~990 worker lifetimes to
exhaust a device's 1981 streams. So the runner now reports how many sets it has
built, through the same C API → ChipWorker → nanobind path the
aicpu_dlopen_count / host_dlopen_count counters already use for exactly this
kind of assertion. It is diagnostic only and gates nothing; simulation and A5
report 0.

tests/st/a2a3/host_build_graph/run_stream_reuse runs one worker four times and
requires exactly one set. Reinstating the per-run create/destroy makes it report
four, so the regression this PR fixes can no longer land green.

Scope

K=1 throughout. TASK_ACCEPTED, asynchronous flight, Gate A/B, K=2 and arena
banking are later changes. run() still calls launch_run and reap_run
back-to-back.

Validation

Rebased onto merged main; the four superseded ancestor commits are dropped,
so this is a single commit of 13 files, +307/-15.

  • changed-file pre-commit hooks: passed
  • C++ unit tests: 61/61 passed
  • Python unit tests: 824 passed, 2 skipped
  • a2a3sim, full examples tests/st: 108 passed, 0 failed
  • a2a3 onboard, the exact CI command (examples tests/st minus the two SDMA
    demos, 4 devices under task-submit): 115 passed, 1 skipped, 0 failed
  • the new reuse test was mutation-checked: with the per-run create/destroy put
    back it fails with expected 1 run stream set for 4 runs, got 4
  • a2a3 onboard SDMA step (prefetch_async_demo,
    sdma_async_completion_demo, 2 devices): 2 passed
  • reuse across runs is exercised by the multi-run prepared_callable cases in
    the onboard suite; the code is onboard-only, so st-onboard-a2a3 is its only
    CI coverage

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d8941c50-1695-4a90-87bf-19a0754f2f31

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

A2A3 DeviceRunner now maintains reusable per-slot stream sets, synchronizes both device streams, and reports creation counts through runtime, C++, and Python APIs. A new repeated-run test verifies one stream set serves multiple pipeline runs.

Changes

Run stream-set reuse

Layer / File(s) Summary
Slot-aware stream lifecycle
src/a2a3/platform/onboard/host/device_runner.*, src/common/platform/onboard/host/device_runner_base.*
A2A3 runs lazily create slot-specific AICPU/AICore streams, launch and reap through the selected slot, synchronize both streams, and destroy all sets during finalization.
Runtime creation-count API
src/common/worker/pto_runtime_c_api.h, src/common/platform/*/host/c_api_shared.cpp, src/common/worker/chip_worker.*
The creation counter is added to the runtime contract, implemented for onboard and simulation hosts, dynamically loaded by ChipWorker, and reset across initialization and teardown paths.
Python exposure and reuse validation
python/bindings/task_interface.cpp, python/simpler/task_interface.py, python/simpler/worker.py, tests/st/a2a3/host_build_graph/run_stream_reuse/*
Read-only Python properties expose the count, and an A2A3 test repeats the workload four times while asserting the count is one.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonTest
  participant Worker
  participant ChipWorker
  participant DeviceRunner
  participant AICPU
  participant AICore
  PythonTest->>Worker: run repeated workload
  Worker->>ChipWorker: read run_stream_set_create_count
  ChipWorker->>DeviceRunner: query creation count
  DeviceRunner->>AICPU: launch on slot stream
  DeviceRunner->>AICore: launch on slot stream
  DeviceRunner-->>ChipWorker: return creation count
  ChipWorker-->>Worker: expose read-only property
  Worker-->>PythonTest: assert count equals 1
Loading

Possibly related PRs

Poem

A rabbit hops through streams so bright,
One set serves each repeated flight.
AICPU thumps, AICore sings,
Python counts the reused things.
“One!” I cheer, and wiggle my ears.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: isolating A2A3 run stream sets per pipeline slot.
Description check ✅ Passed The description is directly about the same stream-set reuse refactor and its validation.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Crane-Liu
Crane-Liu force-pushed the codex/worker-async-pr5-run-streams branch from aabef49 to 4ef67d6 Compare July 24, 2026 23:59
@ChaoWao
ChaoWao force-pushed the codex/worker-async-pr5-run-streams branch from 4ef67d6 to e56d50b Compare July 26, 2026 09:31
@ChaoWao ChaoWao changed the title Refactor: isolate per-run A2A3 stream sets (5/7) Refactor: isolate A2A3 run stream sets per pipeline slot Jul 26, 2026
@ChaoWao

ChaoWao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Revision: stream sets are per slot, not per run

Pushed e56d50bb, rebased onto merged main (the four superseded ancestor commits drop out, leaving 3 files / +109/-14). Review found one blocking issue and three that fall out of it.

Blocking: per-run create/destroy cost +22% host time

create_run_stream_set in run() and destroy_run_stream_set in reap_run are both synchronous host calls on the critical path, and rtStreamCreate/rtStreamDestroy measure ~300 us each on this silicon.

Measured on a2a3, vector_example at 40 rounds, three alternating repetitions of each binary inside one device lock with only libhost_runtime.so swapped, host p50 per round (p50 of per-repetition p50, warmup dropped):

host p50 vs main
main 10316.8 us
previous revision 12607.6 us +2290.8 us (+22.2%)
this revision 10481.1 us +164.3 us (+1.6%)

main's own repetitions span 2.7%, so +1.6% is inside the noise. Device time is ~250 us in all three. An intermediate control build — the previous revision with only the create made idempotent and the destroys removed — also landed at +0.7%, which pins the whole regression on create/destroy rather than on anything else in the diff.

The fix is to keep the slot shape and drop the per-run lifetime: a set is created on first use and reused. That is what EXEC_HANDLE in #1463 already asks for — one instance per in-flight run, i.e. per slot, not per run() invocation. A set carries no per-run content, so there was never a reason to rebuild it.

Falls out of the same change

  • run_stream_sets_ had no release point. It is an RTS-owning member released neither by finalize_common() nor by a destructor, against the invariant documented at device_runner_base.cpp:979 ([Code Health] Teardown ordering: release RTS resources before aclFinalize (load_aicpu_op_ stopgap is ad-hoc) #1197). Now destroy_run_stream_sets() runs in finalize() while RTS is live, before the device reset, and is idempotent so ~DeviceRunner() { finalize(); } is a safe backstop.
  • A teardown failure could fail a successful run. reap_run returned the destroy rc and run() returned on it before print_handshake_results(), so a benign rtStreamDestroy failure aborted a run that had already computed and reconciled. reap_run returns 0 again.
  • Duplicate cleanup in reap_run. The RAII guard plus the explicit destroy plus the dismiss are all gone with the per-run lifetime.

There was also a narrower hazard: on the launch_aicpu_kernel failure path the AICore kernel is already launched and spinning, and the guard destroyed its stream right after a bounded drain that may itself have timed out. Reuse removes that window.

Other

  • kRunStreamSetCount is now PTO_PIPELINE_MAX_DEPTH instead of a literal 2. src/common/worker is already on this target's include path.
  • The DEVICE_SCRATCH-adjacent comment on the base's persistent pair was reworded — they are per-slot sets, not per-run.

Validation

61/61 C++, 824 passed / 2 skipped Python, a2a3sim full suite 54 passed, and the exact onboard CI command on 4 devices: 113 passed, 1 skipped, 0 failed, plus the SDMA step 2 passed. Reuse across runs is exercised by the multi-run prepared_callable cases.

Coverage note: all three files are onboard-only, so st-onboard-a2a3 is the only CI job that can reach them — sim cannot.

@ChaoWao
ChaoWao force-pushed the codex/worker-async-pr5-run-streams branch from e56d50b to 47c70da Compare July 26, 2026 11:12
@ChaoWao

ChaoWao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Revision: a regression barrier for the reuse invariant, and a question about slot 1

Pushed 47c70da7.

The reuse invariant now has a test

I said in the last revision that the create-once invariant had no observable and therefore no test. That was true but not a good place to stop — the +22% regression this PR fixes would have landed green again. Streams are opaque handles, the run stream set %u created line sits below the default host log threshold, and a leak needs ~990 worker lifetimes to exhaust a device's 1981 streams (measured), so none of the passive routes work.

So the runner reports how many sets it has built, through the same C API → ChipWorker → nanobind path that aicpu_dlopen_count / host_dlopen_count already use — whose binding docstring says verbatim "Tests assert this to verify register_callable + repeated run do not redundantly dlopen." Same purpose, same shape. Diagnostic only, gates nothing; sim and A5 report 0.

tests/st/a2a3/host_build_graph/run_stream_reuse runs one worker four times and requires exactly one set. Mutation-checked by reinstating the per-run create/destroy:

E  AssertionError: expected 1 run stream set for 4 runs, got 4 — the set is being rebuilt per run

Slot 1 is unreachable through the whole series

Worth raising before this merges. I checked the codex/worker-async-pr6-run-resources and codex/worker-async-pr7-ack-flight branches: both still have constexpr unsigned kPipelineSlot = 0. So through #1467 — the last PR in the announced split — ensure_run_stream_set(1) is never called, slot 1's pair is never created, launched on, or synced, and the four-run-stream configuration never exists.

That is consistent with what #1467 actually enables: TASK_ACCEPTED lets a later submit() build while the prior run executes on the device, and K=1 device ownership is held until TASK_DONE. The overlap is host-prepare(N+1) ∥ device-execute(N), not stream ∥ stream. Two runs' kernels are never in flight together, so one pair would still suffice.

The second slot only starts paying when a run's KernelLaunch may be issued before the prior run is reaped — a stream is FIFO and a sync waits on everything enqueued, so at that point run N's reap must not be able to block on run N+1's kernels. No PR in the series does that.

So kRunStreamSetCount = PTO_PIPELINE_MAX_DEPTH ships an array whose second entry no test can reach and no announced change will select. I have left it as-is because you argued for fixing the ownership shape once, and it is 32 bytes — but the alternative, a single pair here and the array in the PR that first selects slot 1, would carry no untestable structure. Your call.

One thing the slot split does not buy, in case it is load-bearing anywhere downstream: separate streams give ordering and independent completion, not AICore exclusivity. Two runs on two slots would still contend for the same cores; serializing that is Gate B's job, not the stream layer's.

Validation

61/61 C++, 824 passed / 2 skipped Python, a2a3sim full suite 108 passed / 0 failed, and the exact onboard CI command on 4 devices: 115 passed, 1 skipped, 0 failed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/st/a2a3/host_build_graph/run_stream_reuse/test_run_stream_reuse.py (1)

39-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate intentional mutable class configuration.

Ruff flags both mutable class attributes with RUF012. Mark them as ClassVar (or copy them per test if the framework mutates nested values) to make the sharing semantics explicit.

Suggested fix
 import pytest
 import torch
+from typing import ClassVar
 from simpler.task_interface import ArgDirection as D
@@
-    CALLABLE = {
+    CALLABLE: ClassVar[dict] = {
@@
-    CASES = [
+    CASES: ClassVar[list] = [

Also applies to: 67-74

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/st/a2a3/host_build_graph/run_stream_reuse/test_run_stream_reuse.py`
around lines 39 - 65, Annotate the mutable class-level configuration
dictionaries containing CALLABLE and the corresponding configuration at the
other flagged location with ClassVar, preserving their shared test configuration
semantics. Import ClassVar from typing if needed, and use the annotation
consistently for both attributes.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/simpler/worker.py`:
- Around line 5960-5965: Update the docstring for run_stream_set_create_count to
state that it reports the number of run stream sets created across pipeline
slots, rather than promising a value of 1; retain the existing 0 behavior for
non-L2 workers and platforms using the persistent bootstrap stream pair.

---

Nitpick comments:
In `@tests/st/a2a3/host_build_graph/run_stream_reuse/test_run_stream_reuse.py`:
- Around line 39-65: Annotate the mutable class-level configuration dictionaries
containing CALLABLE and the corresponding configuration at the other flagged
location with ClassVar, preserving their shared test configuration semantics.
Import ClassVar from typing if needed, and use the annotation consistently for
both attributes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e3f22b4f-7a3c-485f-ab81-90655d9d74ea

📥 Commits

Reviewing files that changed from the base of the PR and between b28bd58 and 47c70da.

📒 Files selected for processing (13)
  • python/bindings/task_interface.cpp
  • python/simpler/task_interface.py
  • python/simpler/worker.py
  • src/a2a3/platform/onboard/host/device_runner.cpp
  • src/a2a3/platform/onboard/host/device_runner.h
  • src/common/platform/onboard/host/c_api_shared.cpp
  • src/common/platform/onboard/host/device_runner_base.cpp
  • src/common/platform/onboard/host/device_runner_base.h
  • src/common/platform/sim/host/c_api_shared.cpp
  • src/common/worker/chip_worker.cpp
  • src/common/worker/chip_worker.h
  • src/common/worker/pto_runtime_c_api.h
  • tests/st/a2a3/host_build_graph/run_stream_reuse/test_run_stream_reuse.py

Comment thread python/simpler/worker.py
@ChaoWao
ChaoWao force-pushed the codex/worker-async-pr5-run-streams branch from 47c70da to b70f386 Compare July 26, 2026 11:50
Give the A2/A3 device runner its own AICPU/AICore stream set per pipeline
slot and submit every run on the selected set, so two in-flight runs would
never share a stream. The persistent pair created by
ensure_device_initialized() stays, now reserved for bootstrap and control
work: the Init and RegisterCallable payloads and the block-dim query.

A stream set carries no per-run content, so a slot's set is created on
first use and reused for every later run on that slot. The pipeline
contract classifies both streams as EXEC_HANDLE, which asks for one
instance per in-flight run — that is one set per slot, not one per run
invocation — so kRunStreamSetCount is PTO_PIPELINE_MAX_DEPTH and only slot
0 is selected while the contract is K=1.

destroy_run_stream_sets() in finalize() is the sole release point, running
while RTS is live and before the device reset, which is the window
finalize_common() uses for the bootstrap pair. It is idempotent, so the
destructor's finalize() call is a safe backstop. As there, no pre-destroy
sync: rtStreamDestroy is the supported teardown for a stream an op-timeout
left in the error state.

sync_run_streams() is split so the wait can name a stream pair;
DeviceRunnerBase keeps the no-argument form for A5, whose runs still use
the persistent pair.

Report the number of sets a runner has built through the C API and
ChipWorker, alongside the dlopen counters that already serve this purpose,
so the reuse invariant is assertable. A new onboard scene test runs one
worker four times and requires exactly one set; rebuilding per run makes
it report four.

Rebuilding the set per run costs two rtStreamCreate plus two
rtStreamDestroy, ~300 us each, on the synchronous host path around
KernelLaunch. Measured on a2a3 silicon with vector_example at 40 rounds,
three alternating repetitions of each binary under one device lock, host
time per round (p50 of per-repetition p50, warmup rounds dropped): main
10316.8 us, per-run rebuild 12607.6 us (+22.2%), reuse 10481.1 us (+1.6%,
inside main's own 2.7% repetition spread). Device time is unchanged.
@ChaoWao
ChaoWao merged commit 0ded81c into hw-native-sys:main Jul 26, 2026
16 checks passed
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Jul 27, 2026
…tree

An editable install pins the compiled extension at install time
(`editable.rebuild = false`) while `python/simpler/*.py` is read live. Switching
branches or rebasing therefore moves the Python out from under a fixed binary,
nothing rebuilds, and a changed struct layout makes attributes read as 0 with no
error anywhere.

That is not hypothetical. A worktree installed before hw-native-sys#1309 (which dropped
`block_dim` from `CallConfig`) and then branched onto a main containing it read
`aicpu_thread_num` as 0, and the whole onboard L2 suite failed with
`launch_aicpu_num (0) must be in range [1, 4]` — a plausible-looking runtime
rejection that reads as a product bug. It cost a full bisect, and an A/B against
main "reproduced" it because both arms shared the same stale binary. The same
skew hit again a few hours later as `AttributeError:
run_stream_set_create_count` after a rebase across hw-native-sys#1464.

`_task_interface` now records the commit it was built from, and
`simpler.task_interface` compares it against the working tree at import, raising
with the one command that fixes it. Loud beats silent here: the alternative is
not an error, it is wrong values. A warning would also have been the wrong
choice — pytest relegates import-time warnings to its end-of-run summary, and in
the failure above the same warning would have appeared in *both* arms of the A/B
and been dismissed as noise.

A *missing* stamp raises too, rather than being treated as "cannot tell". The
attribute is absent only on an extension compiled before it existed, which in a
checkout new enough to run the check is by definition a different revision —
and is the state of every already-installed worktree the day this lands.

Keyed on git HEAD, matching `RuntimeBuilder._build_cache_stamp` — mtimes do not
survive a branch switch, which is why that stamp is git-based too. Deliberately
the whole HEAD rather than a subset: excluding paths means maintaining a second
"what cannot affect the build" list beside the one CI already keeps, and the
guard exists precisely so correctness does not rest on such judgments. It costs
little — 120 of the last 200 commits touch the ABI surface anyway, so most of
the reinstalls it forces were owed regardless and merely became visible; the
rest cost one ~20 s reinstall.

Inert outside a source tree: a wheel has no `.git` to compare against, and a
build made without git carries an empty stamp.

The rebuild table gained the trigger it was missing and no longer contradicts
itself. It was framed as "what did you change", so it had no row for the case
where you changed nothing and the tree moved — the one that bites, and the one
where verifying that `import simpler` resolves into your worktree gives false
confidence. Its "no rebuild needed" rows now say what is actually true: nothing
to recompile, but the guard keys on HEAD, so a commit still needs a reinstall
before the next import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Jul 27, 2026
…tree

An editable install pins the compiled extension at install time
(`editable.rebuild = false`) while `python/simpler/*.py` is read live. Switching
branches or rebasing therefore moves the Python out from under a fixed binary,
nothing rebuilds, and a changed struct layout makes attributes read as 0 with no
error anywhere.

That is not hypothetical. A worktree installed before hw-native-sys#1309 (which dropped
`block_dim` from `CallConfig`) and then branched onto a main containing it read
`aicpu_thread_num` as 0, and the whole onboard L2 suite failed with
`launch_aicpu_num (0) must be in range [1, 4]` — a plausible-looking runtime
rejection that reads as a product bug. It cost a full bisect, and an A/B against
main "reproduced" it because both arms shared the same stale binary. The same
skew hit again a few hours later as `AttributeError:
run_stream_set_create_count` after a rebase across hw-native-sys#1464.

`_task_interface` now records the commit it was built from, and
`simpler.task_interface` compares it against the working tree at import, raising
with the one command that fixes it. Loud beats silent here: the alternative is
not an error, it is wrong values. A warning would also have been the wrong
choice — pytest relegates import-time warnings to its end-of-run summary, and in
the failure above the same warning would have appeared in *both* arms of the A/B
and been dismissed as noise.

A *missing* stamp raises too, rather than being treated as "cannot tell". The
attribute is absent only on an extension compiled before it existed, which in a
checkout new enough to run the check is by definition a different revision —
and is the state of every already-installed worktree the day this lands.

Keyed on git HEAD, matching `RuntimeBuilder._build_cache_stamp` — mtimes do not
survive a branch switch, which is why that stamp is git-based too. Deliberately
the whole HEAD rather than a subset: excluding paths means maintaining a second
"what cannot affect the build" list beside the one CI already keeps, and the
guard exists precisely so correctness does not rest on such judgments. It costs
little — 120 of the last 200 commits touch the ABI surface anyway, so most of
the reinstalls it forces were owed regardless and merely became visible; the
rest cost one ~20 s reinstall.

Inert outside a source tree: a wheel has no `.git` to compare against, and a
build made without git carries an empty stamp.

The rebuild table gained the trigger it was missing and no longer contradicts
itself. It was framed as "what did you change", so it had no row for the case
where you changed nothing and the tree moved — the one that bites, and the one
where verifying that `import simpler` resolves into your worktree gives false
confidence. Its "no rebuild needed" rows now say what is actually true: nothing
to recompile, but the guard keys on HEAD, so a commit still needs a reinstall
before the next import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChaoWao added a commit that referenced this pull request Jul 27, 2026
…tree (#1523)

An editable install pins the compiled extension at install time
(`editable.rebuild = false`) while `python/simpler/*.py` is read live. Switching
branches or rebasing therefore moves the Python out from under a fixed binary,
nothing rebuilds, and a changed struct layout makes attributes read as 0 with no
error anywhere.

That is not hypothetical. A worktree installed before #1309 (which dropped
`block_dim` from `CallConfig`) and then branched onto a main containing it read
`aicpu_thread_num` as 0, and the whole onboard L2 suite failed with
`launch_aicpu_num (0) must be in range [1, 4]` — a plausible-looking runtime
rejection that reads as a product bug. It cost a full bisect, and an A/B against
main "reproduced" it because both arms shared the same stale binary. The same
skew hit again a few hours later as `AttributeError:
run_stream_set_create_count` after a rebase across #1464.

`_task_interface` now records the commit it was built from, and
`simpler.task_interface` compares it against the working tree at import, raising
with the one command that fixes it. Loud beats silent here: the alternative is
not an error, it is wrong values. A warning would also have been the wrong
choice — pytest relegates import-time warnings to its end-of-run summary, and in
the failure above the same warning would have appeared in *both* arms of the A/B
and been dismissed as noise.

A *missing* stamp raises too, rather than being treated as "cannot tell". The
attribute is absent only on an extension compiled before it existed, which in a
checkout new enough to run the check is by definition a different revision —
and is the state of every already-installed worktree the day this lands.

Keyed on git HEAD, matching `RuntimeBuilder._build_cache_stamp` — mtimes do not
survive a branch switch, which is why that stamp is git-based too. Deliberately
the whole HEAD rather than a subset: excluding paths means maintaining a second
"what cannot affect the build" list beside the one CI already keeps, and the
guard exists precisely so correctness does not rest on such judgments. It costs
little — 120 of the last 200 commits touch the ABI surface anyway, so most of
the reinstalls it forces were owed regardless and merely became visible; the
rest cost one ~20 s reinstall.

Inert outside a source tree: a wheel has no `.git` to compare against, and a
build made without git carries an empty stamp.

The rebuild table gained the trigger it was missing and no longer contradicts
itself. It was framed as "what did you change", so it had no row for the case
where you changed nothing and the tree moved — the one that bites, and the one
where verifying that `import simpler` resolves into your worktree gives false
confidence. Its "no rebuild needed" rows now say what is actually true: nothing
to recompile, but the guard keys on HEAD, so a commit still needs a reinstall
before the next import.
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.

2 participants