Fix: kernel-mode capacity refusals no longer free what they protect (⑤K3) - #2193
sunkaixuan2018 wants to merge 2 commits into
Conversation
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>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
eb69687 to
4297a35
Compare
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>
4297a35 to
b8e739d
Compare
上板实测,正好落在这个 PR 的前提上#2064 已合( 环境:a2a3 onboard、CANN 9.0.0、torch_npu 2.7.1。代码与全部结果在 1. 你的核心前提"silently wrong data on replay, not a crash"——CANN 侧实测证实,而且比想象的更彻底我用 raw
GLOBAL 与 RELAXED 两种捕获模式一致。 也就是说:一次 re-base 发生在被捕获的 launch 里,CANN 不报错、不把 capture 标脏、照常出图。 没有任何运行期兜底。这个 PR 的 guard 就是唯一挡在"re-base"和"replay 读错地址"之间的东西。 我认为这句可以直接写进 PR 描述——它把这条从"防御性编程"抬成"唯一防线"。 2. 对照:CANN 确实会拦的那些,恰好不含分配同一批探针里被当场拒绝的操作,说明 CANN 的捕获校验并非形同虚设:
所以"地址与容量"正是那一类零执法的不变式,而同步、事件误用、悬空 fork 都有码可报。这个对照我觉得比单说"没人管"更有说服力:不是 CANN 疏漏了整块,是分配这一类它根本不视为捕获相关操作。 3. "an ACLGraph replays the addresses that were captured"——两侧都测过了正面:两张图共享同一对 device slot、各写自己的 pattern,交替 replay 反面(更贴近你说的失败形态):把工作挂在一条没有进入本次捕获的流上——不进图、不报错、replay 静默地什么都不做,只有数值不对。零 ACL 错误。 这两条合起来就是你 PR 里那句话的实测版本:错的地址不会让你崩,只会让你算错。 4. 一个问题:第一次分配允许发生在 capture 之内吗?你把 按 §1,捕获期间 如果设计上第一次分配必须落在 prepare(capture 之外),那它就不只是"guard 放行",而是⑫ST 该有一条负例:capture 内出现首次分配 → 必须被检出。我没有主张改代码,只是这条今天没有任何东西挡。 5. rebase 之后请重锚本 PR 基于
不属于本 PR 的:我在 K1 上提的"潜伏缺陷修在哪"那条规则,对应的是 ⑩a / #2185(它才是让 kernel context 真正可构造的那个),不在这里。本 PR 是已合入 guard 的正确性修复,位置我认为是对的。 边界:以上全部限 a2a3,a5 一个没跑——按非目标 #12 不得由 a2a3 推断 a5。 |
…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>
关闭:交付路线已替代(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: |
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 + reserveorfree + malloc, which re-bases; thefailure 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. Itspredicate 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 tomove. The refusal returned
PTO_RUNTIME_ERR_INTERNAL, the caller collapsed thatto
ok = false, and the!okblock released all three regions unconditionally.DeviceArena::release()frees the backing buffer, so a fired guard droppedexactly 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. Thegrow path is
device_free(old)thendevice_malloc(bigger). A refusal placedanywhere 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)setup_static_arenabodiesbases_changedRetainedTempBump::begin(a2a3 + a5)device_free, leaving the slot as the previous run left itHostApiOps::is_kernel_mode(new)is_kernel_modeis a query of the existingExecutionModeLatch, not a newswitch — 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 thesame
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. Theaddr != nullptrhalf 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 contextcould 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_argschecks null-ness and the callable-id range. Itnever inspects tensors.
ChipTensor::address_spacedefaults toHOST, so the default goes theopposite way.
SimplerKernelInvocationHeader::host_copy_tensor_countexists precisely tocarry 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_arenaand the retained-temp-buffer entries inhost_api.h,DeviceRunnerBase::setup_static_arena's rollback promise (and its@return,which named
-1for a function that returnsPTO_RUNTIME_ERR_INTERNAL), thekernel-mode capacity paragraph in
runtime_c_api.h, both runners' latch comments,docs/task-flow.md, andRUNTIME_LOGIC.mdin both architecture trees.The extraction is a flagged deviation from "program path byte-identical"
Behavior in program mode is unchanged —
kernel_modeis false there, so the newbranch 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/simsymmetry is now structural rather than a review obligation. And it is what makes
the fix testable without a device:
DeviceRunnerBaseneeds CANN, the rule needsnothing.
The
allocate_tensordesign question, answeredThe handover asked whether kernel mode should refuse
allocate_tensorunconditionally, 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, sorefusing 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_blockand #2 HBG host-tensor stagingare 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_modequery this PR adds? Splitting one growth point across two PRs isthe outcome worth avoiding. Note that
acquire_graph_definition_blockisgrow-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 PRfreezes 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_executioncallsfinalize_collectors()whenevercollector_shape_is_stale(...), so a run whose core or AICPU-thread counts differfrom 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.
86dd62b4is 143/143, built and run the same waytest_static_arena_bankno_hardwarelabeltest_trb_runtime_temp_buffertest_a5_trb_runtime_temp_bufferlibhost_runtime.sobuilt (a2a3 + a5 × onboard + sim × hbg + trb); no warning names a changed filetests/ut/py/test_host_runtime_abi.pyThe five acceptance criteria are covered twice, once per slot kind:
RepeatedCommitsMoveNoBaseAndCallNoAllocatorKernelModeHoldsOneAddressAcrossRepeatedRunsDeviceArena::alloc_count()ExactAndSmallerRequestsAreServedInPlaceINTERNALOneByteOverCapacityIsRefusedAndChangesNothingKernelModeRefusesToGrowTheRetainedBufferKernelModeRefusalLeavesTheRetainedBufferUsableKernelModeAllocatesOnceThenRefusesToGrowis the production shape: the latch iswrite-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.
AllocationFailureRollsTheWholeBankBackandAllocationFailureOnTheFirstCommitStillRollsBackdrive a failing backingallocator through
DeviceArena's injectable alloc/free, so the rollback path isreachable 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.
capacity_refusedbranch): 3 kernel-mode arena tests fail, and bothprogram-mode tests still pass.
a5; the rest pass.
addr != nullptrclause: exactlyKernelModeAllocatesOnceThenRefusesToGrowfails, on both architectures — thefirst-bind regression has a precise barrier.
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_modecomment claimingtable-wide address stability that
acquire_graph_definition_blockcontradicts,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