Skip to content

Fix: kernel-mode capacity refusals no longer free what they protect (⑤K3) - #2193

Closed
sunkaixuan2018 wants to merge 2 commits into
hw-native-sys:mainfrom
sunkaixuan2018:skx/kernel-capacity-freeze-k3
Closed

sunkaixuan2018 wants to merge 2 commits into
hw-native-sys:mainfrom
sunkaixuan2018:skx/kernel-capacity-freeze-k3

Conversation

@sunkaixuan2018

@sunkaixuan2018 sunkaixuan2018 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #2064 (①K1) @ 86dd62b4912866a6e96d63891b4c2838a2635b5a

This branch is built directly on that commit, which is not yet merged. The
main base above is a GitHub constraint — a branch on a fork cannot be a PR
base — so the diff shown against main includes all of K1. Exactly one
commit is mine:

  • b8e739d9 — kernel-mode capacity refusals no longer free what they protect

Incremental diff (this PR only):
sunkaixuan2018/simpler-PTO@86dd62b...skx/kernel-capacity-freeze-k3

Draft until #2064 merges, then rebased and marked ready.

What this is

A kernel-mode context's device buffers must keep their addresses for the life of
the context, because an ACLGraph replays the addresses that were captured. Every
runtime-time growth is release + reserve or free + malloc, which re-bases; the
failure mode is silently wrong data on replay, not a crash. That makes this a
correctness change, not a sizing one.

K1 landed the first guard on that invariant, in setup_static_arena. Its
predicate is right and is unchanged here.
What this PR fixes is that its
refusal, and the refusal on the second growth point, were both destructive.

Two refusals that destroyed what they protected

1. setup_static_arena — the guard freed the bases it had just declined to
move.
The refusal returned PTO_RUNTIME_ERR_INTERNAL, the caller collapsed that
to ok = false, and the !ok block released all three regions unconditionally.
DeviceArena::release() frees the backing buffer, so a fired guard dropped
exactly the base addresses it names in its own error message. K1's comment above
the guard said so; nothing acted on it.

2. RetainedTempBump::begin — the free came before anything could refuse. The
grow path is device_free(old) then device_malloc(bigger). A refusal placed
anywhere downstream of that — including at the platform's device_malloc —
arrives after the address is already gone, and the existing failure path then
clears the slot to {nullptr, 0}. The guard has to sit ahead of the free.

The invariant both fixes share, and the one every later growth-point guard will
need: a refusal must have no side effects.

What changed

host/static_arena_bank.h (new) the bank's commit rule, shared by the onboard and simulation runners instead of hand-duplicated in both
the rule's failure handling an allocation failure still rolls the whole bank back; a capacity refusal leaves every region committed and every cached size intact
both setup_static_arena bodies ~55 lines each become a request literal plus a call; each keeps its own prebuilt-arena cache invalidation, now keyed on bases_changed
RetainedTempBump::begin (a2a3 + a5) refuses a kernel-mode re-base ahead of the device_free, leaving the slot as the previous run left it
HostApiOps::is_kernel_mode (new) how the context's identity reaches runtime code; a table that does not supply it reports program mode
six contract sites the docs that described the behavior these guards make conditional, listed below

is_kernel_mode is a query of the existing ExecutionModeLatch, not a new
switch — no environment variable and no macro, per
env-macro-gating.md §1. Its producer
(c_api_shared.cpp) and its consumer (runtime_maker.cpp) are compiled into the
same libhost_runtime.so, so appending to the ops table creates no ABI skew.

The retained-buffer guard refuses a re-base, not an allocation

This is the part worth a reviewer's attention, because I got it wrong first.

The guard fires on required > size && addr != nullptr. The addr != nullptr
half matters: an empty slot holds no address a captured graph could reference, so
a context's first allocation moves nothing and is taken normally. Without that
clause the guard refuses the first bind too, and since the grow path is the only
writer of the slot, the slot would stay {nullptr, 0} forever — a kernel context
could never reach its frozen size at all.

My first version omitted it, on the premise that a kernel run never stages host
tensors anyway. That premise is not enforced anywhere in the tree, and I could
not support it on a re-read:

  • validate_kernel_launch_args checks null-ness and the callable-id range. It
    never inspects tensors.
  • ChipTensor::address_space defaults to HOST, so the default goes the
    opposite way.
  • SimplerKernelInvocationHeader::host_copy_tensor_count exists precisely to
    carry host-memory args in kernel mode, documented as "zero until the host-only
    copy contract lands". Host tensors there are a planned contract, not an excluded
    one.

So the narrow guard is the correct one: it still refuses every real re-base, and
it refuses nothing else.

Doc updates in the same commit

The behavior these guards make conditional was described in six places, all of
which now read false for a kernel context and move with the code:
setup_static_arena and the retained-temp-buffer entries in host_api.h,
DeviceRunnerBase::setup_static_arena's rollback promise (and its @return,
which named -1 for a function that returns PTO_RUNTIME_ERR_INTERNAL), the
kernel-mode capacity paragraph in runtime_c_api.h, both runners' latch comments,
docs/task-flow.md, and RUNTIME_LOGIC.md in both architecture trees.

The extraction is a flagged deviation from "program path byte-identical"

Behavior in program mode is unchanged — kernel_mode is false there, so the new
branch is unreachable and growth, release and rollback all take the paths they
took before. But the code moved, so this is not literally untouched, and it is
deliberate for two reasons. The rule was two hand-maintained copies that
codestyle.md §10 warns about, and onboard/sim
symmetry is now structural rather than a review obligation. And it is what makes
the fix testable without a device: DeviceRunnerBase needs CANN, the rule needs
nothing.

The allocate_tensor design question, answered

The handover asked whether kernel mode should refuse allocate_tensor
unconditionally, or only outside a prepare window borrowed from ④K2. The
unconditional form holds
: #2176's prepare-once allocator reaches mem_alloc_
directly through its own context ops, never through HostApi::device_malloc, so
refusing the runtime-facing surface would not block a legitimate kernel-time
allocation. This PR does not install that refusal, because the growth point it is
in scope for needs its guard higher up anyway — but the question is settled and the
next guard does not have to re-derive it.

Out of scope, and one question for a reviewer

Growth points #1 acquire_graph_definition_block and #2 HBG host-tensor staging
are not closed here.
Both are HBG-only and both live in files #2173 rewrites;
#2173 already carries a disposition for the definition block. @TaoZQY — do you want
to keep #1 (and #2) on your side, or should a follow-up here close both against the
is_kernel_mode query this PR adds? Splitting one growth point across two PRs is
the outcome worth avoiding. Note that acquire_graph_definition_block is
grow-by-replacement of a device block, so it is the same silent-replay hazard,
not a lesser one.

DFX is outside the frozen capacity, but it is not address-stable. This is the
question the handover asked, so here is the full answer rather than half of it. The
DFX device buffers — the device-wall buffer and the collectors' workspaces — are
separate mem_alloc_ allocations, not regions of an arena bank, so nothing this PR
freezes covers them and it correctly writes no DFX teardown logic. The device-wall
buffer is allocated lazily once and never grown, so it does not re-base. The
collector pools do
: prepare_execution calls finalize_collectors() whenever
collector_shape_is_stale(...), so a run whose core or AICPU-thread counts differ
from the previous one tears their device memory down and rebuilds it at a new
address. A kernel context's shape is context-static, so that should not fire — but
nothing enforces it, and if the DFX buffers are ever reachable from a captured
graph this is a growth point in its own right. Flagging it rather than guarding it
here: it belongs with whoever owns DFX under the execution claim (#2163).

The sim-side guard stays. Simulation never latches KERNEL, so that branch is
never true, but removing it would break the onboard/sim symmetry the stub-parity
argument rests on.

Verification

All on myserver (aarch64, CANN 9.0.0). No device is needed for any of it.

Lane Result
cpput, no hardware 144/144 passed — base 86dd62b4 is 143/143, built and run the same way
new test_static_arena_bank 11/11, no_hardware label
test_trb_runtime_temp_buffer 16/16, was 10 — and identically for test_a5_trb_runtime_temp_buffer
editable install all 8 libhost_runtime.so built (a2a3 + a5 × onboard + sim × hbg + trb); no warning names a changed file
tests/ut/py/test_host_runtime_abi.py 4 passed, 4 skipped — the exported symbol set is unchanged
pre-commit all hooks pass, clang-tidy included, in CI

The five acceptance criteria are covered twice, once per slot kind:

arena bank retained temp buffer
base and capacity constant across N calls RepeatedCommitsMoveNoBaseAndCallNoAllocator KernelModeHoldsOneAddressAcrossRepeatedRuns
zero underlying alloc/free same, via DeviceArena::alloc_count() same, via the fake's malloc/free counters
exact capacity accepted ExactAndSmallerRequestsAreServedInPlace same test, a run that exactly fills the buffer
over capacity refused with INTERNAL OneByteOverCapacityIsRefusedAndChangesNothing KernelModeRefusesToGrowTheRetainedBuffer
refusal does not break the held plan same test's second half KernelModeRefusalLeavesTheRetainedBufferUsable

KernelModeAllocatesOnceThenRefusesToGrow is the production shape: the latch is
write-once, so a real context is in kernel mode from its very first bind. It
asserts one allocation, then a refusal, then the sized run still binding from the
same address.

The other exit is pinned too. AllocationFailureRollsTheWholeBankBack and
AllocationFailureOnTheFirstCommitStillRollsBack drive a failing backing
allocator through DeviceArena's injectable alloc/free, so the rollback path is
reachable with no device: an allocation failure releases every region and zeroes
every remembered size, in kernel mode exactly as in program mode, because a setup
that never completed published no address to protect.

Negative controls

A test that passes against the fix is not evidence it would have caught the
defect, so each defect was re-introduced and the suites re-run.

  • Routing the capacity refusal back through the rollback (dropping the
    capacity_refused branch): 3 kernel-mode arena tests fail, and both
    program-mode tests still pass.
  • Removing the retained-buffer guard: 2 tests fail, identically on a2a3 and
    a5
    ; the rest pass.
  • Dropping the addr != nullptr clause: exactly
    KernelModeAllocatesOnceThenRefusesToGrow fails
    , on both architectures — the
    first-bind regression has a precise barrier.
  • Removing the rollback entirely: exactly the two allocation-failure tests
    fail
    ; the nine capacity tests still pass, so the two failure classes are
    pinned independently of each other.

How the findings above were found

The commit was put through a self-review before this update: eight independent
reviewers over separate dimensions of the diff, then three adversarial refutation
lenses per finding. It raised 22 findings and the skeptic panel refuted all 22 —
a verdict I do not report as a clean bill of health, because the refutation lens
set included reachability, and since nothing latches KERNEL yet, every
kernel-mode finding is trivially unreachable today. The panel's value was in
surfacing candidates, not in adjudicating them.

Four of those candidates were textually verifiable and are fixed here regardless
of the vote: the first-allocation refusal, the is_kernel_mode comment claiming
table-wide address stability that acquire_graph_definition_block contradicts,
the stale contracts listed earlier, and the two test defects above — the
"smaller" run that packed to exactly the retained capacity, and the missing
rollback coverage.

🤖 Generated with Claude Code

Kernel mode is simpler's second execution identity: instead of owning
the device, a context borrows the caller's already-current device and
stream to enqueue one bounded asynchronous operator per launch, so a
PyPTO program is capturable by ACLGraph as an ordinary node. This
change freezes the public surface that identity hangs off and gives the
context a write-once identity the guards can key on. It creates no
resources. The program path gains one call — simpler_init latches
PROGRAM — and no behavior: latching a fresh context always succeeds, is
idempotent, and nothing on that path reads the latch.

- runtime_c_api.h declares the lifecycle entries
  simpler_kernel_mode_{supported,init,prepare_callable,launch} and adds
  the host-band code PTO_RUNTIME_ERR_INVALID_STATE. The existing
  finalize_device stays the fifth lifecycle entry, and a kernel context
  now reaches it. Kernel-mode capacity is
  a mode invariant rather than a gated state: config is context-static,
  so each pooled arena region is committed at most once, and
  setup_static_arena reports a grow or release request on a committed
  region under kernel mode as an internal invariant break; capacity
  intent travels in CallConfig.runtime_env like everywhere else.
- Execution identity is a write-once property of the context rather
  than a state that evolves. ExecutionModeLatch (platform/include/host/
  execution_mode_latch.h) replaces the four-state claim: the first init
  entry to run latches the mode, and it never changes — not on finalize,
  not on error. simpler_init latches PROGRAM before touching any
  process or runner state, so the program/kernel mutual exclusion is
  enforced on every program init instead of resting on a separate
  declaration call. There is no unlatch, which the latch documents as a
  consequence: a handle from a failed kernel init can never be recycled
  into a program context. SimplerExecutionMode now has one definition
  (task_interface/execution_mode.h) that both the wire header and the
  latch consume, so the host-side identity and the value that travels to
  the AICPU can no longer disagree.
- device_id_ records which device a context is on, not a claim on it —
  ownership is what the latch carries. attach_current_thread splits
  accordingly: bind_current_thread does the per-thread rtSetDevice and
  nothing else; attach_current_thread is the program-mode adopt
  (bind plus the one-shot op-execute watchdog and identity write) and
  refuses on a kernel latch; adopt_borrowed_device records the device a
  kernel context runs on without binding the thread and without
  configure_aicore_op_timeout, whose aclrtSetOpExecuteTimeOutV2 would
  rewrite the watchdog for every other user of a borrowed card. It does
  resolve the timeout config, because the stream and scheduler timeouts
  derived from it are read on both identities. DeviceRunner::finalize()
  is the one caller that runs under both identities and skips the bind
  on a kernel latch, so the kernel close path reaches its no-reset
  branch instead of being turned away by a device bind it never needed.
- ensure_acl_ready(), force_reset_device(), and finalize()'s rt-layer
  device reset refuse on a kernel-mode context (a2a3 + a5): the ACL
  lifecycle belongs to the caller, and every call site of the five ACL
  lifecycle APIs falls into three enumerable classes (below the
  ensure_acl_ready guard, inside force_reset_device behind its own
  guard, or gated on acl_ready_ which only the guarded path sets), with
  finalize's rt-layer reset intercepted by its own kernel-mode branch —
  so poison recovery can never reset the device out from under the
  host process.
- kernel_invocation_header.h pins the envelope every kernel launch
  ships to the AICPU (mode / callable / generation / payload length /
  int32_t arg counts). Both sides of the wire come from one
  build_runtimes.py build, so the struct carries no version or size
  negotiation and the POD/standard-layout guards are its only
  compile-time checks. generation is the occupancy counter of the
  residency slot callable_id resolves to - a property of the slot, not
  of the callable in it, so a generation carried by the callable could
  not detect slot reuse - with zero reserved for "not recorded".
  ChipCallable's sig_count includes the scalar entries and its
  scalar_count reads 0 both for a scalar-free orchestration and for an
  artifact built before the field existed, so a consumer derives the
  effective scalar count - the field when nonzero, otherwise the
  signature's SCALAR entries, the split count_callable_tensor_args
  already computes - and compares tensor_count against sig_count minus
  it. Subtracting the field directly would count an unrecorded
  callable's scalars as tensors.
- Kernel-entry argument validation is shared by all eight host-runtime
  components through kernel_entry_validation.h (one copy of the
  null/range/image-size/alignment checks; a binary pointer and its size
  must be present or absent together, and a callable image must be
  aligned for ChipCallable so its CALLABLE_CHILD_ALIGN-relative storage_
  lands aligned too), so a stub and a real implementation accept and
  reject exactly the same arguments.
- KernelExecutionState and ExecutionModeClaimState carry the kernel
  context phase machine (New/Collecting/ReadyEnqueued/Poisoned/
  Closing/Closed with sticky, retriable Closing and separate
  runtime-error and teardown-error slots) and the two restricted
  operation vocabularies; synchronize, allocation, capture queries,
  and model attachment stay unrepresentable in those tables, and a
  launch implementation is obligated to route through them. Every
  kernel-mode guard reads the identity through ExecutionModeLatch::
  is_kernel() rather than comparing an enumerator at the call site, so
  the test lives in one place instead of eight.
- ChipWorker dlsyms the four new symbols from every runtime, so a
  component missing one fails at load, and clears them alongside the
  other resolved pointers on all three teardown paths so none is left
  dangling into the library DlHandleGuard dlcloses.
  test_host_runtime_abi.py
  asserts the export across all eight components, and table-driven UTs
  cover the phase machine (including failed-rollback landing in
  Closing with the create error reported and the cleanup error
  latched), the shared argument validation, and the wire layout.
- ChipCallable additionally records scalar_count as a cached
  derivation of the signature's SCALAR entries: make_callable rejects
  a nonzero count that disagrees with the signature, while 0 also
  means "not recorded" (legacy blobs read 0). The field occupies four
  bytes of historical header tail padding, so every historical offset,
  sizeof, and the kernel-cache ABI token are unchanged;
  ChipCallable.build gains a trailing scalar_count=0 keyword and a
  read-only property.

Two facts a reader should not have to re-derive. The latch refusal returns
PTO_RUNTIME_ERR_INVALID_STATE (-1003) rather than PTO_RUNTIME_ERR_INTERNAL
(-1000) on purpose: conftest.py scrapes "simpler_init failed with code <N>"
and treats -1000 as a poisoned card, so an identity conflict must not look
like one. And kernel_execution_state.cpp stays compiled into all four host
runtimes even though grepping KernelExecutionState now finds only its own
header and .cpp — it is the persistent-state change's foundation, not an
orphaned translation unit.

Every kernel-mode branch this adds is provably dead in this commit: no
production site latches KERNEL (`git grep 'latch(SIMPLER_MODE_KERNEL)' src`
is empty) because both simpler_kernel_mode_init stubs return before any latch
call, so is_kernel() is false on every context and the program path takes the
same branch it took before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

@sunkaixuan2018 sunkaixuan2018 changed the title Fix: kernel-mode capacity refusals no longer free what they protect (5K3) Fix: kernel-mode capacity refusals no longer free what they protect (⑤K3) Sep 11, 2026
@sunkaixuan2018
sunkaixuan2018 force-pushed the skx/kernel-capacity-freeze-k3 branch from eb69687 to 4297a35 Compare September 11, 2026 01:52
A kernel-mode context's device buffers are sized once and keep their
addresses, because a captured graph replays the addresses of the run it
captured; a re-base there produces silently wrong data rather than a
failure. Two refusals that enforce this were themselves destructive.

setup_static_arena's guard returned an error the caller collapsed into a
rollback that released all three regions, so a fired guard dropped the base
addresses it had just declined to move. The bank's commit rule now lives in
host/static_arena_bank.h, shared by the onboard and simulation runners
rather than duplicated in both, and it separates the two failures: an
allocation failure still rolls the whole bank back, while a capacity refusal
leaves every region committed and every cached size intact.

RetainedTempBump::begin freed the retained buffer before asking for a larger
one, so a refusal could only land after the address was already gone. It now
refuses ahead of the free. The refusal is scoped to a slot that already
holds a buffer: an empty slot has no address for a captured graph to hold,
so a context's first allocation is not a re-base and is taken normally,
which is what lets a kernel context reach its frozen size at all. Nothing in
the tree restricts a kernel context's tensors to device memory, so that
first allocation is reachable.

The context's identity reaches the runtime through a new HostApiOps entry,
is_kernel_mode. It is a query of the existing latch, not a new gate, and a
table that does not supply it reports program mode.

Program mode keeps growth, release and rollback unchanged. Both arena call
sites keep their prebuilt-arena cache invalidation, now keyed on whether the
commit moved a base.

The contracts that described the behavior these guards make conditional move
with them: the setup_static_arena and retained-temp-buffer entries in
host_api.h, DeviceRunnerBase::setup_static_arena's rollback promise and its
return code, the kernel-mode capacity paragraph in runtime_c_api.h, both
runners' latch comments, task-flow.md, and RUNTIME_LOGIC.md in both
architecture trees.

tests/ut/cpp/common/test_static_arena_bank.cpp covers the bank rule with no
device: base and capacity constant across repeated calls, zero allocator
calls, exact capacity accepted, one byte over refused with
PTO_RUNTIME_ERR_INTERNAL, and the held layout still served afterwards. The
TRB suite gains the same coverage on the retained temporary buffer for both
architectures, including a context that is in kernel mode from its first
bind, which is the only shape the write-once latch permits. Both suites pin
the other exit too: an allocation failure rolls the whole bank back, in
kernel mode as in program mode, since a setup that never completed published
no address to protect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ChaoWao

ChaoWao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

上板实测,正好落在这个 PR 的前提上

#2064 已合(1d1ddc81),这条是把我在 K1 上提过的东西里只与本 PR 相关的部分挪过来,外加三条新的硬件实测。

环境:a2a3 onboard、CANN 9.0.0、torch_npu 2.7.1。代码与全部结果在 docs-kernel-mode/probes/(每条附复跑命令与观测边界)。


1. 你的核心前提"silently wrong data on replay, not a crash"——CANN 侧实测证实,而且比想象的更彻底

我用 raw aclmdlRICapture* 直驱、每个操作一次独立 capture、用 aclmdlRICaptureGetInfo 区分"当场拒绝/被捕获/静默污染"。结果里与本 PR 直接相关的一行:

操作(捕获期间) op rc capture status CaptureEnd 出图
aclrtMalloc 0 ACTIVE 0 是
aclrtFree 0 ACTIVE 0 是

GLOBAL 与 RELAXED 两种捕获模式一致。 也就是说:一次 re-base 发生在被捕获的 launch 里,CANN 不报错、不把 capture 标脏、照常出图。

没有任何运行期兜底。这个 PR 的 guard 就是唯一挡在"re-base"和"replay 读错地址"之间的东西。 我认为这句可以直接写进 PR 描述——它把这条从"防御性编程"抬成"唯一防线"。

2. 对照:CANN 确实会拦的那些,恰好不含分配

同一批探针里被当场拒绝的操作,说明 CANN 的捕获校验并非形同虚设:

操作 错误码
wait 一个在捕获开始前 record 的 event 107024
fork 到侧流但不 join 回来(CaptureEnd 时) 107025
aclrtSynchronizeStream(捕获流)/SynchronizeDevice/StreamQuery(捕获流) 107027
aclrtSynchronizeEvent / aclrtQueryEventStatus 107028
aclrtMemcpy / aclrtMemset(同步) 107030

所以"地址与容量"正是那一类零执法的不变式,而同步、事件误用、悬空 fork 都有码可报。这个对照我觉得比单说"没人管"更有说服力:不是 CANN 疏漏了整块,是分配这一类它根本不视为捕获相关操作。

3. "an ACLGraph replays the addresses that were captured"——两侧都测过了

正面:两张图共享同一对 device slot、各写自己的 pattern,交替 replay A B A B A A B 7/7,背靠背不同步 replay(AB/BA/AA/BB)4/4,每次 slot 恰好翻成该图的 pattern。replay 确实照着捕获时的地址写。

反面(更贴近你说的失败形态):把工作挂在一条没有进入本次捕获的流上——不进图、不报错、replay 静默地什么都不做,只有数值不对。零 ACL 错误。

这两条合起来就是你 PR 里那句话的实测版本:错的地址不会让你崩,只会让你算错。


4. 一个问题:第一次分配允许发生在 capture 之内吗?

你把 addr != nullptr 这半说清楚了,我同意窄 guard 是对的。顺着问一句:

按 §1,捕获期间 aclrtMalloc 是放行的,而且分配不是流上的操作、不进图。所以如果某个 kernel context 的第一次 bind 恰好发生在一次被捕获的 launch 里,这次分配会真实发生、地址被后续录进图、且因为再也不 re-base 而保持稳定——结果是对的,但"capture 期做了一次真实分配"这件事本身值得明说是允许还是应当被 prepare 期兜住。

如果设计上第一次分配必须落在 prepare(capture 之外),那它就不只是"guard 放行",而是⑫ST 该有一条负例:capture 内出现首次分配 → 必须被检出。我没有主张改代码,只是这条今天没有任何东西挡。

5. rebase 之后请重锚

本 PR 基于 86dd62b4。此后除 K1 合入外,#2163 / #2200 / #2201 / #2204 四个 DFX PR 大改了 device_runner_base.cpp 与两个 arch 的 device_runner.cpp,我量过的位移:

b28c4c4d 现在
start_shared_collectors_for_run 定义 1888 1930(且改收 (const DfxRunConfig&, uint32_t pipeline_slot))
teardown_shared_collectors_after_run 1924 1975
ensure_device_wall_buffer 1654 1696
arm_device_wall_buffer 1814 1722
DFX 位图进 kernel_args device_runner.cpp:292-302 :1147-1150

allow_prepared_successor 里的 && !config->diagnostics_any() 也已删除(c_api_shared.cpp:800)。文件动得比较厉害,rebase 后值得整体过一遍而不只看冲突标记。


不属于本 PR 的:我在 K1 上提的"潜伏缺陷修在哪"那条规则,对应的是 ⑩a / #2185(它才是让 kernel context 真正可构造的那个),不在这里。本 PR 是已合入 guard 的正确性修复,位置我认为是对的。

边界:以上全部限 a2a3,a5 一个没跑——按非目标 #12 不得由 a2a3 推断 a5。

YunjiQin added a commit that referenced this pull request Sep 15, 2026
…gnment, 8192 ids (#2241)

* CI: bump pinned pto-isa to 03e45c4b (#2219)

* Refactor: unify scalar reads across host_build_graph and TaskArgsTpl (#2197)

host_build_graph's Arg::scalar(i) returned InheritableScalar
unconditionally and exposed the raw uint64_t only through a deprecated
implicit conversion, kept for callers that had not yet moved to
args.scalar(i).to<T>(). TaskArgsTpl::scalar(i) (TMR and every other Arg
built directly on it) returned S itself with no template parameter at
all. The two spellings disagreed on what a caller writes for a static
read even though both slots are the same uint64_t.

scalar(i) is now a template on both sides, its parameter spelled
ScalarT in both -- TaskArgsTpl's T is its tensor type. host_build_graph
defaults it to InheritableScalar, so a bare scalar(i) still forwards
with its origin; TaskArgsTpl defaults it to S (uint64_t), so a bare
scalar(i) there is unchanged. Either side accepts an explicit type for
a static value read (args.scalar<T>(i)), and TaskArgsTpl bounds that
read by sizeof(S) -- the slot the value has to fit in -- rather than by
a hardcoded 8. task_args.h includes data_type.h for the from_u64 that
read applies, rather than reaching it through tensor.h.

The deprecated operator uint64_t() is removed, which is stronger than
the deprecation it replaces: with no conversion left to suppress, a
value read is a compile error rather than a warning. That closes the
blind spot the deprecation had, where a read instantiated inside a
system header -- EXPECT_EQ(args.scalar(i), v) -- was silently exempt.
InheritableScalar::to<T>() stays: it is the only way to read a handle
that was passed on as a function argument, where Arg::scalar<T>(i) is
unavailable because the Arg is not in hand.

Every call site that triggered the deprecation warning (98 across 20
orchestration files) moves to the explicit-T spelling. All of them were
already value reads, so no call site changes meaning; which of them
ought to forward instead is a question about each example's semantics
and is tracked separately.

The tensormap_and_ringbuffer orchestrations that read a slot as some
type other than the slot's own move with them, so both runtimes spell
that read the same way and the non-S branch of TaskArgsTpl::scalar has
in-tree instantiations -- a template body is only checked when it is
instantiated, and until now every scalar<T> call site was
host_build_graph, whose Arg hides the base's scalar with its own. A
bare uint64_t read is left alone: that is what scalar(i) already
answers, so naming the type would add nothing.

Python's add_scalar took a pre-encoded uint64_t, pushing
scalar_to_uint64(value) onto every caller. It now takes the value
directly -- int, float, bool, or a ctypes scalar -- and encodes it
natively (encode_scalar in the bindings, exposed to Python as
scalar_to_uint64 for callers that still want the raw bits).
scene_test.py's three add_scalar call sites drop their
scalar_to_uint64 wrapping accordingly.

That encoder matches C++ to_u64() bit for bit, which the previous
Python implementation did not: it read a ctypes scalar through its
`.value`, which ctypes has already sign-extended for a signed type, so
c_int8(-1) produced 0xFFFF'FFFF'FFFF'FFFF where to_u64(int8_t{-1}) is
0xFF. A ctypes scalar is now read through the buffer protocol at its
own width and zero-extended, which is what to_u64's union does.

Reading raw bytes makes the scalar's byte order load-bearing, and its
buffer format is where that order is stated. ctypes admits
byte-order-qualified variants -- c_uint32.__ctype_be__ carries the same
_type_ as c_uint32 and differs only in the format prefix -- whose bytes
for the value 1 are 00 00 00 01, which a raw copy would store as
0x01000000. A reversed-order scalar has no native C++ counterpart to
agree with, so the format decides admission: host byte order plus one
of the integer widths, f, d or ?. The pointer and character types (P,
z, Z, c, u) and long double are refused with it; c_void_p and c_char_p
would otherwise encode a host pointer into a device-bound slot. A
subclass inherits its base's format and so is admitted with the base,
which dispatching on the type's __name__ would not do.

A native Python float still narrows to IEEE-754 single precision, and a
finite value out of that range now raises where a narrowing conversion
would produce an infinity -- struct.pack("<f", 1e100) raised too, and
storing that infinity would silently be a different number. inf and NaN
pass through as themselves. This is the one encoding that cannot align
with its C++ counterpart, because a Python float carries no width where
to_u64(1.5) is a double; ctypes.c_double is the spelling for full
precision.

Two Python C API returns that signal failure are checked rather than
used: PyObject_IsInstance answers -1, which is truthy, and
PyNumber_Index answers nullptr with the caller's own exception pending.
The common Python-int case is tested first, so the hot path costs one
PyLong_CheckExact and no attribute lookup.

test_task_interface.py pins every encoding across scalar_to_uint64,
TaskArgs.add_scalar and ChipStorageTaskArgs.add_scalar, including the
zero-extension widths, a c_double subclass, both byte-order qualifiers,
the single-precision range boundary against struct.pack's own verdict,
out-of-range integers, and an __index__ that raises.

* Fix: check args-dump payload truth at every level, on both arches (#2214)

The args-dump scene test validated structure only — entry counts, arg indices,
offsets, sizes, the manifest schema — and a dump whose payload is correctly
sized and entirely zero satisfies every one of those. #1560 is exactly that
failure: a5sim `--dump-args 2` wrote correctly sized, all-zero tensor payloads
and the test reported PASSED.

The a2a3 test did carry one payload-truth assertion, but only under
`if level == 3`, so levels 1 and 2 had none. The a5 test had that block
deleted, its docstring recording that "the A5 payload values remain untrusted
until #1560 is fixed".

The assertion now runs at every level that writes a payload, in both tests. It
anchors on task 0's `a + b` over the 2.0 / 3.0 inputs, whose bytes are known
before the run, rather than on "not all zero": the case also passes
`torch.zeros` as an argument, so an all-zero payload is a legitimate value
somewhere in this dump and a blanket non-zero rule would be wrong.

a5 also regains the level-3 payload-selection check. The two files now differ
only in `KERNELS_BASE` and their platform lists.

The underlying bug is gone. Running #1560's own per-record check — payload
sliced by each entry's `bin_offset` / `bin_size` — over a fresh a5sim level-2
run gives 13 tensor records, 0 of them all-zero, matching the a2a3sim control
exactly.

Verified at levels 1, 2 and 3 on a2a3sim, a5sim and a2a3 onboard: 9 runs, all
green. The negative control expects 6.0 instead of 5.0 and fails all six sim
combinations, including levels 1 and 2 — which is the point, since those two
had no payload assertion to fail before.

Note that CI exercises level 1 only: `_st-sim-a2a3.yml`, `_st-sim-a5.yml` and
`run-onboard-dfx-smokes` all pass a bare `--dump-args`, and `conftest.py`
declares that flag `nargs="?", const=1`. So levels 2 and 3 still have no
coverage there, including the level-3 mode `core_swimlane.py` consumes.

* Refactor: name the per-run H2D copy-in instead of overloading "staged" (#2213)

"stage" carries four unrelated meanings in this repo and is defined
nowhere. Two of them meet inside one file: host_build_graph's
runtime_maker.cpp reports `staged=%d` for caller tensors it copied to the
device, and a hundred lines away holds a `staging` block that is the host
scratch the Graph Definitions are assembled in. The scheduler adds a third
(`staged_core_mask`, cores held ready before release) and `drain_stage` a
fourth (a step in a sequence). A reader cannot tell which is meant without
following the code, and error text shown to users inherits the ambiguity.

This renames one sense: the per-run copy of a caller tensor into device
memory. The other senses keep the word -- a staging buffer, a pipeline
stage, a staged IPC frame and the args-dump capture stage are all ordinary
uses of it, and the sense renamed here had the weakest claim, since the
device buffer it produces is the live one the kernel reads rather than
somewhere bytes pass through.

The replacement reuses names the repo already has rather than coining any.
A tensor on this path is `child_memory=False`, i.e. `AddressSpace::HOST`,
so it is a host-memory tensor; the operation is an H2D copy-in, and `h2d`
is already how the sibling bind phase spells it (`BindArenaH2d`,
`arena_h2d`). So `bind.args` now reports `h2d=%d bytes=%llu`, and
tensormap_and_ringbuffer's `stage_device_args` becomes
`copy_in_device_args`.

Both runtimes carry this sense, so both move, and each arch sibling moves
with its pair. Nothing parses the attribute string programmatically --
`h2d=` replaces `staged=` in logs and in docs/dfx/hbg-bind-phases.md only.

The remaining occurrences were classified by reading them rather than by
pattern: a regex over the obvious spellings missed tmr's "Failed to stage
tensor" diagnostic, SCALAR_DATA_ACCESS.md's "used to stage", and the
prose in task-flow.md and buffer-abi.md. `docs/investigations/` is left as
written, since those are dated records of past measurements.

docs/testing.md also said the default copies every tensor in on every
round, which overstates it: pure OUT buffers skip the H2D.

No behaviour change.

* Update: take the PR-head refresh that does not re-open an adjudication

The heads this integration was audited against were frozen on 2026-09-11.
Since then #2064 merged to main and six of the thirteen remaining kernel-mode
PRs moved. Four of those moves are adoptable as they stand; the rest reverse
decisions this line already made. D15 in INTEGRATION-LOG.md records the
survey and says what each deferred item needs before it can be taken.

Adopted:

- From #2177, build_kernel_pipeline_contract_impl rejects an invalid
  CallConfig with INVALID_ARGUMENT and keeps INTERNAL for a null output or a
  contract it generated but cannot validate. This extends the K1 numbering
  D14 adopted for the entry points to the hook behind them.
- From #2176, KernelContextOps drops event_flag; create_event takes
  (context, event) and the onboard implementation owns ACL_EVENT_SYNC, so the
  platform constant stays with the platform.
- From #2176, a PersistentKernelArgs rollback whose own release fails now has
  coverage: a host-only test for the block the owner retains, and an rtFree
  fault hook behind the new persistent_free_close lifecycle scenario.
- From #2185, the Python entry test terminates a hung child instead of
  leaving it for the CI job timeout, and destroying the borrowed stream after
  a refused kernel_init is an assertion rather than a swallowed exception.

persistent_free_close asserts that the retry re-attempts and completes rather
than that it makes exactly one more rtFree call. A kernel context here
releases several device blocks and the injected failure stops the first pass
partway, so the retry covers the failed block plus everything the first pass
never reached: eight calls across the two passes where the first made three.
The count-exact form the source PR uses holds only for its own allocation set.

#2176, #2177 and #2185 also drop kernel_execution_state.cpp from a platform
source list, which does not apply here: kernel_resource_requirements.h calls
bind_resources_for_launch from the H chain, so the simulation host runtimes
need the translation unit too.

Validation is recorded in docs/kernel-integration-validation.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update: rebase the kernel-mode line onto the target call-flow design

The target call-flow design settles three of the four adjudications D15 left
open, and names this line as still carrying the old shape. D16 in
INTEGRATION-LOG.md records the decisions; they are one package rather than
three independent choices.

Registration mints the id. simpler_kernel_mode_prepare_callable takes
(ctx, callable, size, int32_t *out) and writes a context-local id on success,
-1 on every failure. It takes no caller_stream: registration enqueues on the
context's own AICPU stream, which every later launch also enqueues on, so
stream FIFO carries the ordering. Only launch takes a stream, per call.

Registration is pure. There is no deduplication and no lookup, so the same
image registered twice takes two ids, two uploads and two charges, and arena
capacity is spent per registration rather than per distinct image.

No callable generation survives. SimplerCallableHandle,
PTO_RUNTIME_ERR_CALLABLE_STALE, KernelCallableDeviceResidency::generation and
SimplerKernelInvocationHeader::generation are gone; the wire header is 32 bytes
and the device descriptor 24. Three properties replace the guard: an id is
minted once and never reused within a context, close invalidates every id the
context minted, and a closed worker accepts no launch.
PTO_RUNTIME_ERR_CAPACITY_EXCEEDED takes the vacated BASE - 8.

The launch sequence is chained rather than sibling. Events are Start,
AicoreStart, AicoreDone, AicpuDone, SerialTail: the caller records Start, AICPU
waits it, clears the handshake and records AicoreStart, AICore waits that and
records AicoreDone, AICPU launches with HostArgs, joins AicoreDone and records
AicpuDone, and the caller joins that and records SerialTail. Caller and AICore
share no event, so ACLGraph capture propagates in two hops and no wait crosses
a capture boundary. AicoreStart must precede the AICPU launch, since the AICPU
orchestrator spins on AICore's handshake report. Compensation now cancels only
between the AICore and AICPU launches and drives the chain back through AICPU,
which is the caller's only path to a tail.

#2190's block allocator is not taken: its cache is host-only, while this line's
AICPU entry resolves a descriptor at arena_ + callable_id * sizeof(descriptor).
The single arena and its descriptor prefix stay.

Validation is recorded in docs/kernel-integration-validation.md. The chained
sequence is what the a2a3 capture probe drives, and it completes 100 captured
replays with verified buffers; the public prepare/launch entries are still not
exercised inside a captured graph by any test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update: carry the callable image span in the launch packet

#2190 restored its device entry without a residency descriptor: the packet
carries chip_callable_address and chip_callable_bytes, and the consumer
receives the ChipCallable reference. This line follows that shape. D17 in
INTEGRATION-LOG.md records why.

The descriptor bought two things and only one of them was real. Its stated
purpose was revocation — the entry re-read the slot on every invocation
including graph replay, so the host had one small location it could
invalidate. Nothing ever wrote a descriptor after commit: ids are never
evicted, kernel-mode unregister_callable returns INVALID_STATE, and clear()
only drops host metadata. It would not have covered the case that matters
either, since after close the freed arena leaves the descriptor address
dangling exactly as the image address does. What it did buy was keeping the
extent out of the per-call packet, so a packet could name which descriptor but
not widen the window the device parses — an argument that assumes an untrusted
packet, when the packet is built by the host binder from cache.resolve().

SimplerKernelDispatchArgs replaces residency_address with
chip_callable_address and chip_callable_bytes. This line keeps the four
binding fields #2190 has no source for — binding_address, context_generation,
sm_bytes, arena_bytes — because its TMR consumer reads them, so the prefix is
88 bytes against #2190's 56. consume_kernel_invocation takes
(args, const ChipCallable &, callable_bytes, payload, payload_bytes), passing
the whole prefix for the same reason.

The entry validates the span — non-null, alignof(ChipCallable), at least
sizeof(ChipCallable), no wraparound — and performs no device read; cache
visibility for the image moves to the consumer, where the image is parsed.
KernelCallableDeviceResidency and its header are deleted, the code arena loses
its descriptor prefix, and KernelDispatchStatus retires NotResident and Stale
rather than reusing 2 and 3.

docs/zh-cn/kernel-mode-integration-test.md also had a stale launch diagram: it
still showed the sibling topology the previous commit replaced with the chained
one. Its device-side section and interface-adjudication table follow the packet
change.

Validation is recorded in docs/kernel-integration-validation.md. Every scene
and unit count matches the pre-change baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update: raise MAX_REGISTERED_CALLABLE_IDS to 8192

This was the one part of #2190's contract this line had not taken. Both lines
already register purely — no deduplication, no lookup, no reuse before close —
so every prepare spends an id permanently and 64 was the binding limit for a
caller covering all of a model's specializations.

The capacity unit test stops naming 64: it fills MAX_REGISTERED_CALLABLE_IDS
residents and static_asserts that one arena still holds them at that image
size, so the case keeps measuring the count limit rather than silently
becoming a byte-limit test.

The AICPU cost is real and measured, not incidental. The TMR AICPU executor
holds orch_so_table_[MAX_REGISTERED_CALLABLE_IDS] of ~296-byte entries, so
libaicpu_kernel.so's .bss grows from 450 KiB to 2.73 MiB. The device loads and
runs it: the kernel C API and capture suites pass unchanged on a2a3. The entry
is 86% char path[256], which the kernel path never uses since it registers by
dev_orch_so_addr, so moving path out of the resident table would return about
2 MiB. That is left alone here because it is program-mode registration code.

docs/zh-cn/kernel-callable-residency.md records which of the two admission
limits binds first, since that now depends on image size: sizeof(ChipCallable)
is 9376 bytes, so 8192 empty images occupy 73.5 MiB and the count binds, while
1 MiB images exhaust the 512 MiB budget at 512 registrations and the bytes
bind.

Validation is in docs/kernel-integration-validation.md, including the triage of
four cases that failed once on a device that dropped its host channel
(507901, hdc disconnect) and pass 9/9 on re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update: align the callable cache and image validation with #2190

Two things come over together. Device memory for callable images is taken in
2 MiB blocks against a 2 GiB budget instead of one 512 MiB arena committed on
the first registration, and the structural image validation moves into
`validate_kernel_callable_image` so the entry and the cache stop carrying two
copies of it.

The block allocator was blocked until now by the residency descriptor, which
indexed a fixed prefix of the single arena by callable_id. That descriptor is
gone, so nothing requires the images to be contiguous from one base. The first
registration now takes max(charged, 2 MiB); a registration larger than a block
gets one sized exactly to it while earlier blocks keep their usable tails;
block slack is charged against the budget through allocated_bytes() while
resident_bytes() still counts only charged image bytes; and a published
device_address never moves as blocks are added. With the id cap at 8192 the
crossover between the two admission limits lands at exactly 2 GiB / 8192 =
256 KiB per image.

The weak stub for consume_kernel_invocation is now linked into both dispatch
test targets, so test_kernel_dispatch's own strong consumer proves the override
resolves rather than colliding. That is the link error a runtime-specific
consumer would hit if the fallback were not weak.

Two deliberate divergences from #2190 remain.

validate_kernel_callable_image keeps rejecting a child func_id that is out of
range or repeated within one image. #2190's extraction lost those two checks,
and without them such an image reaches the device consumer, which rejects it at
launch and poisons the context rather than failing the registration cleanly.

The simulation prepare_callable calls the image validator too, so both entries
agree that structural checks precede the lifecycle refusal. Without it an image
whose size clears the header floor but whose variable tail does not add up
reports INVALID_STATE instead of INVALID_ARGUMENT, which is what
test_kernel_entries_reject_a_context_with_no_kernel_claim caught.

kernel_arena_change_is_forbidden is a third difference but not one against
#2190. It is mainline code from the merged K1 (#2064), so every branch off main
carries it. This line deleted it in D14 item 4 and routes the same rule through
K3's commit_static_arena_bank, which applies it to onboard and simulation from
one place. That consolidation removes a guard main uses today in favour of a
mechanism that exists only in an unmerged PR — static_arena_bank.h is on this
line and on #2193 and nowhere else — so it is recorded in the validation doc as
a risk rather than a settled question.

Validation is in docs/kernel-integration-validation.md. Every scene and unit
count matches the pre-change baseline with no re-runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix: bound the packet length against the mapped prefix, not SIZE_MAX

`simpler_aicpu_kernel_exec` treats `packet_bytes` as the length of the
region starting at `arg`, and derives the payload span from it. The upper
bound compared it against `std::numeric_limits<size_t>::max()`, which a
`uint64_t` field can never exceed on a 64-bit AICPU, so the check was dead
and `arg + packet_bytes` could wrap.

Bound it against the space actually left above `arg` instead, so the
payload span the consumer receives is guaranteed not to wrap the address
space. This is the shape #2190 carries at `9c085aa0`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update: bound a child func_id by the runtime function table

`validate_kernel_callable_image` rejected a child `func_id` outside the
extent of `ChipCallable::child_func_ids_`. That array's capacity is the
number of children an image may carry, not the size of the table the id
indexes; the two agree at 1024 only by coincidence, so raising the child
capacity would have silently widened the accepted id range past what the
device function table holds.

Name the real bound, `KERNEL_MAX_FUNC_ID`, next to the registration-id cap
it is independent of, and tie it to `RUNTIME_MAX_FUNC_ID` with a
`static_assert` in both onboard and simulation `device_runner_base.cpp`, so
the two cannot drift apart without a compile error. This is the shape #2190
carries at `08f7f921`, adopted along with the test it added; the accepted
set is unchanged today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: record the newest-head review and its regression

The frozen-heads table now shows #2190 at `08f7f9218728`, and the validation
doc carries the sweep run against the binaries this line actually ships: 166
and 168 C++ unit tests, 2428 Python unit tests, 2 C++ hardware tests, 21
scheduled hardware unit cases plus the ten that marker run drops, 13 kernel
C API cases, the capture replay, and all four scene platforms. Every count
matches the pre-change baseline.

D18 records why the child `func_id` bound moved from the extent of
`ChipCallable::child_func_ids_` to `KERNEL_MAX_FUNC_ID`, and the divergence
note in the earlier section is corrected: #2190 restored those checks, so only
the simulation `prepare_callable` ordering still differs.

Two harness details are written down because each cost a false red: the C++
unit build takes no `CMAKE_BUILD_TYPE`, since `Release` defines `NDEBUG` and
silences the `debug_assert` three tests assert on; and the C++ hardware tests
need a `--resource-spec-file` built from `TASK_DEVICE`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix: drop the duplicated kernel_device_resources entry in the a2a3 sim list

`HOST_RUNTIME_SOURCES` in the a2a3 simulation host list named
`common/platform/shared/host/kernel_device_resources.cpp` on two consecutive
lines. The a5 simulation list and both onboard lists name it once, and CMake
dedups a target's source list, so this changes no build output — it is a
copy/paste slip from the integration commit, removed so the four lists read
the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: chenshengxin2026 <hw_chenshengxin@163.com>
Co-authored-by: poursoul <49787929+poursoul@users.noreply.github.com>
Co-authored-by: Chao Wang <26245345+ChaoWao@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChaoWao

ChaoWao commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

关闭:交付路线已替代(superseded)

根据维护者确认,关闭这份旧 kernel-mode PR。

**原因:**main 的 setup_static_arena 已在任何 commit_region 前整体预检,拒绝直接返回,避免进入会释放旧分配的 rollback。旧 PR 另一部分修改的 RetainedTempBump 也已迁至 src/common/utils/retained_temp_bump.h,不继续推进原累计补丁。

**仍需保留/迁移:**这不是“全部目标已修复”:当前 RetainedTempBump::begin 仍有 free+malloc 增长路径。后续 kernel/graph 接入必须单独落实有效引用下的地址保有与非破坏性容量拒绝,并迁移相应测试;不得因关闭本 PR 将此缺口标为完成。

当前确定路线是 Pipeline A → kernel eager → capture/replay:两种资源模式接共同执行与退休层,每次 eager 重新准备并提交一次。集成分支中的采用不等于已合入 main,也不代表本 PR 的全部目标已交付。

本次只关闭旧 PR,保留分支、提交、作者贡献、测试及实验记录,供后续按能力迁移。

本次核查 head:b8e739d9d8143ca6f35bf2177241976f900e2ab7;main 基线:2d3661517。

@ChaoWao ChaoWao closed this Sep 19, 2026
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