Skip to content

Add: kernel-mode C ABI skeleton, state machine, and wire headers (K1) - #2064

Open
sunkaixuan2018 wants to merge 1 commit into
hw-native-sys:mainfrom
sunkaixuan2018:skx/kernel-PR1
Open

Add: kernel-mode C ABI skeleton, state machine, and wire headers (K1)#2064
sunkaixuan2018 wants to merge 1 commit into
hw-native-sys:mainfrom
sunkaixuan2018:skx/kernel-PR1

Conversation

@sunkaixuan2018

@sunkaixuan2018 sunkaixuan2018 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Motivation

simpler today has exactly one execution identity: program mode, which takes exclusive ownership of the device. Kernel mode is the second identity — a context that borrows the caller's already-current device and caller-owned stream to enqueue one bounded asynchronous operator per launch: no device reset, no internal stream/device synchronize on the prepare/launch/close paths, zero allocation at launch, and no capture/model-state queries, so a launch is capturable by ACLGraph as an ordinary node.

This PR is K1, the single public gate the rest of the kernel-mode pipeline hangs off. It freezes the surface — entry points, the invocation wire envelope, the context state machine, and the restricted operation vocabularies — so the runtime-specific work (invocation snapshots, HBG launch blobs, persistent state) can be developed against it in parallel.

It creates no resources and no context can reach kernel mode, so every kernel-mode guard it adds is inert. The headers say so in as many words rather than describing the end state — see What is frozen vs. what is enforced below.

Surface frozen by this PR

Four lifecycle entries (src/common/worker/runtime_c_api.h), family simpler_kernel_mode_*:

  • simpler_kernel_mode_supported / simpler_kernel_mode_init / simpler_kernel_mode_prepare_callable / simpler_kernel_mode_launch; the fifth lifecycle entry is the existing finalize_device.
  • The execution mode has a single source: ExecutionModeClaimState on the platform runner (mutually exclusive, idempotent, abortable on init failure). Every kernel-mode guard reads it through accepts_kernel_calls() rather than comparing the enumerator at the call site, so the choice of == Kernel over != Program lives in one place instead of eight — a distinction an unclaimed context makes load-bearing, since != Program would refuse the ACL lifecycle product-wide.
  • Kernel-mode capacity is a mode invariant, not a gated state: config is context-static, so each pooled arena region is committed at most once and never grown or released afterwards. setup_static_arena's commit_region (onboard and sim) reports a grow or release request on a committed region under kernel mode as an internal invariant break (PTO_RUNTIME_ERR_INTERNAL); capacity intent travels in CallConfig.runtime_env like everywhere else.
  • ACL-lifecycle guards: ensure_acl_ready(), force_reset_device(), and finalize()'s rt-layer device reset refuse on a kernel-mode context (a2a3 + a5), so poison recovery can never reset the device out from under the host process.
  • One new host-band status code: PTO_RUNTIME_ERR_INVALID_STATE (-1003, out-of-order lifecycle call).

Unified invocation envelope (src/common/task_interface/kernel_invocation_header.h): SimplerKernelInvocationHeader, the 40-byte header every kernel-mode launch ships to the AICPU — mode / callable_id / generation / payload length / int32_t arg counts — plus the SimplerExecutionMode enum. Both sides of this wire come from the same build_runtimes.py build, so the struct carries no version or size negotiation; the POD/standard-layout guards remain.

generation is the occupancy counter of the residency slot callable_id resolves to — a property of the slot, not of the callable in it, since callable_id is a reusable index into a 64-entry table and a generation carried by the callable could not detect slot reuse. Zero is reserved for "not recorded".

ChipCallable::sig_count includes the scalar entries, and 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 first — 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; tests/ut/cpp/types/test_callable_scalar_count.cpp pins that difference.

Shared entry validation (src/common/platform/include/host/kernel_entry_validation.h): one copy of the null/range/image-size/alignment checks for init/prepare/launch, compiled into all eight host-runtime components — 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.

State machine and restricted vocabularies (src/common/platform/{include,shared}/host/kernel_execution_state.*):

  • KernelExecutionState: New → Collecting ⇄ ReadyEnqueued, partial-enqueue failure → Poisoned (only close accepted), close → sticky retriable ClosingClosed. Two separate error slots (first poison cause vs. first real teardown failure) so a controlled error can never mask a teardown failure.
  • Two operation vocabularies as function-pointer tables: context lifecycle (5 ops) and launch (6 ops). Synchronize, allocation, stream/event creation, capture queries, and model attachment are unrepresentable in them.

dlsym + stubs: the four symbols join ChipWorker's mandatory dlsym table, so all 8 host-runtime components must export them; a component missing one fails at load. ChipWorker also clears the four resolved pointers alongside the others on all three teardown paths, so none is left dangling into the library DlHandleGuard dlcloses. In this PR every component is a conservative skeleton: supported returns 0, init reports UNSUPPORTED after the shared structural validation, prepare/launch report INVALID_STATE.

Also included: scalar_count in the ChipCallable header

The first part of this commit (originally this PR's sole content) makes the compiled artifact record how many scalar arguments its orchestration expects. The field is a cached derivation of the signature: make_callable rejects a nonzero count that disagrees with the signature's SCALAR entry count, while 0 also means "not recorded". int32_t scalar_count_ occupies four bytes of historical tail padding, so every field offset, sizeof(ChipCallable) (9376), the kernel-cache ABI token, and legacy blobs are unchanged; ChipCallable.build gains a trailing scalar_count=0 keyword plus a read-only property.

What is frozen vs. what is enforced

Round 2 of review made the point that a frozen surface must be honest about which of its guarantees are already in force. Nothing in this PR can put a context into kernel mode, so:

Stated in the surface Status today
Four entries exported by all 8 components In force — mandatory dlsym + test_host_runtime_abi.py
Shared structural validation, stub/real parity In force — one copy, compiled into all 8
scalar_count derivation and its wire comparanda In force as a contract; pinned by UT
Mode is claimed by whichever init runs first Not in force — no implementation claims, so every context reads Unclaimed
The 8 kernel-mode guards Not in force — all unreachable
finalize_device releases context-owned resources in kernel mode Not in force — onboard finalize() returns early while device_id_ is unset, and only the device-owning program path sets it, so its kernel branch is unreachable by a second, independent mechanism
The restricted vocabularies constrain launch Not in force — no production consumer; routing through them is the launch implementation's obligation

Each of those rows is stated in the header itself, not only here.

Program-path impact

The program path's behavior is unchanged, with one caveat worth stating plainly: the four dlsym lookups are unconditional and throwing, so a libhost_runtime.so built before this commit now makes ChipWorker::init throw for pure program-mode use as well. This is a rebuild-forcing change, not a silent one — load_symbol's error names the symbol and says to rebuild the module.

The enumerable-coverage claim for the ACL lifecycle holds within the five ACL lifecycle APIs it names. Note that ensure_acl_ready is not on the ordinary program run path — its only production caller is ChipWorker::create_comm_stream_checked, i.e. the collectives path — so the guards cover the comm path and finalize, not simpler_init's own device attach.

Deliberately left to the follow-up PR

Review round 2's architectural read — identity modelled as a mutable state machine instead of a construction-time property, attach_current_thread fusing thread bind / device-global timeout / identity into one act, and the denylist perimeter missing aclrtSetOpExecuteTimeOutV2 — is accepted and not addressed here. The reason is that those three are one problem, not three:

  • device_id_ today means both "which device this context is on" and "this context owns that device", because only the program init path writes it.
  • Two of the three ACL guards sit behind device_id_ >= 0, so they are unreachable by a mechanism entirely separate from the missing claim — wiring claim_kernel() alone would not make them fire.
  • Splitting attach_current_thread without settling device_id_'s meaning only cuts half the seam.

Doing them together changes core program-path functions across 15 call sites and two runner trees. That is a behavioral change with its own review surface, and mixing it into a PR whose value is a frozen interface would put two unrelated questions in front of the same reviewer. It lands as its own PR on top of this one.

Known debts (accepted, tracked)

  • The kernel-mode guards are exercised by no test that goes through a real .so, because nothing here can put a context into kernel mode. The PR that flips the capability must add the rejection tests for all four guard sites, plus an ABI-level case that a kernel claim arms them.
  • commit_region's kernel-mode refusal is not side-effect-free: the caller collapses it to ok = false and then releases all three arena regions unconditionally, and DeviceArena::release() frees the backing buffer. The comment says so; making the refusal self-contained is a design change that belongs with the arena work.
  • ClaimedExecutionMode (platform layer) and SimplerExecutionMode (wire) are two representations of one concept with opposite zero values and no converter.
  • context_generation is validated nonzero and then dropped — no field holds it — and nothing mints the wire header's generation.
  • make_callable gained a required third positional parameter, so any out-of-repo C++ caller of the old signature no longer compiles. In-repo callers are the nanobind bindings and tests, all updated.

Tests

  • tests/ut/py/test_host_runtime_abi.py: the four symbols asserted exported on all 8 components.
  • tests/ut/cpp/common/test_kernel_execution_state.cpp (23 cases): full phase × entry table, init/close balance with fake ops, partial-init rollback (clean and failed-rollback-to-Closing), poison first-cause latching, sticky Closing retry, separate teardown-error slot, claim mutual exclusion/abort/closed.
  • tests/ut/cpp/common/test_kernel_entry_validation.cpp (5 cases): every structural rejection for init/prepare/launch, including both directions of the binary-span consistency check and a misaligned callable image.
  • tests/ut/cpp/types/test_kernel_invocation_header.cpp (3 cases): pinned mode values, memcpy round-trip, zero-blob semantics.
  • tests/ut/cpp/types/test_callable_scalar_count.cpp (6 cases): factory round-trip, range rejection, signature-disagreement rejection, legacy blob reads 0, only-the-field-bytes-vary, and the naive-formula miscount that the derivation rule exists to prevent.
  • Full tests/ut/cpp and full tests/ut/py green on the validation host; this PR's CI runs the full matrix.

Commits

Single squashed commit on top of main.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 3b459d59-d054-4557-b207-5895d230e90d

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: da9f2421-2fce-45e7-9d02-9dae2a92a350

📥 Commits

Reviewing files that changed from the base of the PR and between d79c88c and a77968c.

📒 Files selected for processing (29)
  • docs/dynamic-linking.md
  • docs/user/reference/python-api.md
  • python/bindings/task_interface.cpp
  • src/a2a3/platform/onboard/host/CMakeLists.txt
  • src/a2a3/platform/sim/host/CMakeLists.txt
  • src/a5/platform/onboard/host/CMakeLists.txt
  • src/a5/platform/sim/host/CMakeLists.txt
  • src/common/platform/include/host/kernel_ctx_control.h
  • src/common/platform/include/host/kernel_execution_state.h
  • src/common/platform/onboard/host/c_api_shared.cpp
  • src/common/platform/onboard/host/device_runner_base.h
  • src/common/platform/shared/host/kernel_ctx_control.cpp
  • src/common/platform/shared/host/kernel_execution_state.cpp
  • src/common/platform/sim/host/c_api_shared.cpp
  • src/common/platform/sim/host/device_runner_base.h
  • src/common/task_interface/callable.h
  • src/common/task_interface/kernel_invocation_header.h
  • src/common/worker/chip_worker.cpp
  • src/common/worker/chip_worker.h
  • src/common/worker/runtime_c_api.h
  • tests/ut/cpp/CMakeLists.txt
  • tests/ut/cpp/common/test_kernel_ctx_control.cpp
  • tests/ut/cpp/common/test_kernel_execution_state.cpp
  • tests/ut/cpp/types/test_callable_scalar_count.cpp
  • tests/ut/cpp/types/test_chip_callable_upload_immutable.cpp
  • tests/ut/cpp/types/test_chip_max_tensor_args.cpp
  • tests/ut/cpp/types/test_kernel_invocation_header.cpp
  • tests/ut/py/test_host_runtime_abi.py
  • tests/ut/py/test_task_interface.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds kernel-mode runtime contracts, lifecycle state management, validating host-runtime stubs, dynamic symbol loading, and tests. It also adds scalar-count metadata to callable artifacts and exposes it through C++ and Python APIs.

Changes

Kernel runtime lifecycle

Layer / File(s) Summary
Kernel lifecycle contracts
src/common/platform/include/host/*, src/common/worker/runtime_c_api.h, src/common/task_interface/kernel_invocation_header.h
Adds kernel context-control contracts, execution-mode claims, lifecycle phases, operation tables, runtime error codes, and fixed-layout invocation data.
Kernel execution state machine
src/common/platform/shared/host/kernel_execution_state.cpp
Implements initialization, resource ownership, dispatch readiness, poisoning, close, cleanup retries, and error latching.
Runtime backends and loading
src/common/platform/*/host/*, src/a2a3/*, src/a5/*, src/common/worker/*, docs/dynamic-linking.md
Adds validating kernel-mode stubs to host runtimes, compiles shared sources, and loads the required symbols in ChipWorker.
Kernel validation
tests/ut/cpp/common/*, tests/ut/py/test_host_runtime_abi.py
Adds tests for control validation, lifecycle transitions, cleanup behavior, error handling, and required runtime exports.

Callable scalar metadata

Layer / File(s) Summary
Callable scalar-count contract
src/common/task_interface/callable.h, src/common/task_interface/kernel_invocation_header.h
Stores validated scalar counts in callable data and adds fixed-layout invocation-header definitions and assertions.
Callable API exposure
python/bindings/task_interface.cpp, docs/user/reference/python-api.md
Adds the scalar_count build argument and read-only property, with documentation for default and legacy values.
Callable metadata tests
tests/ut/cpp/types/*, tests/ut/py/test_task_interface.py, tests/ut/cpp/CMakeLists.txt
Tests bounds, serialization, legacy blobs, byte placement, Python round trips, and invocation-header layout.

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

Merge Risk: ⚪ Minimal · up to a7796

The kernel-mode foundation preserves existing program-mode behavior, validates unsupported runtime paths, and maintains callable ABI compatibility. No merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 126 functions across 22 files. (7 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main changes: the kernel-mode C ABI foundation, state machine, and wire headers. It is concise and specific.
Description check ✅ Passed The description is directly related to the changeset. It explains the kernel-mode ABI, state machines, wire formats, stubs, scalar_count support, compatibility impact, tests, and known limitations.
Full details: Docstring Coverage

Explanation

Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 126 functions across 22 files. (7 skipped: 7 unsupported.)


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

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.

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

@sunkaixuan2018 sunkaixuan2018 changed the title Add: record scalar_count in the ChipCallable header Add: kernel-mode C ABI skeleton, state machine, and wire headers (K1) Sep 7, 2026
@sunkaixuan2018
sunkaixuan2018 marked this pull request as ready for review September 8, 2026 01:07
@sunkaixuan2018
sunkaixuan2018 force-pushed the skx/kernel-PR1 branch 2 times, most recently from 8f30a55 to 21fb15c Compare September 8, 2026 02:20

@ChaoWao ChaoWao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: K1 kernel-mode ABI gate

Reviewed at a67dfc4 against merge-base d79c88c. CI is 20/20 green including both
self-hosted pools and both OSes, and the PR body's own numbers check out — I verified
the test counts (20 / 20 / 3), the scalar_count_ padding arithmetic (config_name_len_
ends at 9364, round_up(9368, 16) is still 9376, so sizeof(ChipCallable) is unchanged),
and the kernel-cache ABI-token invariance (_chip_callable_abi_token() in
scene_test_cache.py:52 SHA-256s a callable built with the default scalar_count=0, so
the bytes are identical). Nothing below is a "this is broken" finding. They are design
comments, and A1/A2 are the two I would want settled before the surface is frozen, since
freezing is the whole point of the PR.

Naming checks out against .claude/rules/codestyle.md: the simpler_kernel_mode_* prefix
matches the simpler_init / simpler_run family, kernel is used in its established
repo sense (an entity submitted to a stream through the rtKernelLaunch family —
launch_aicpu_kernelrtsLaunchCpuKernel, launch_aicore_kernel
rtKernelLaunchWithHandleV2), the new PTO_RUNTIME_ERR_* enumerators correctly reuse the
existing enum's prefix, no new PTO2 spelling, #pragma once, enum class, wire-POD
guards, and the gm_heap_bytes / gm_sm_bytes / runtime_arena_bytes field names line up
with setup_static_arena's parameters. Leaving the new classes out of a namespace matches
the local style of that directory (MemoryAllocator, RunStreamPair).

The state machine in kernel_execution_state.{h,cpp} is the part I liked most: the
sticky-retriable Closing with per-handle nulling so a retry redoes only the remainder, the
two separate error slots so a controlled poison cannot mask a teardown failure, and a
destructor that makes no runtime calls at all because an ACLGraph may still reference the
handles. That reads like it was designed from the failure cases backwards.


A1 — simpler_kernel_mode_ctx_control may not need to exist at all

This is a level above the earlier review round on FREEZE's ordering (which the body records
as resolved by requiring a kernel-mode CONFIGURE). The question here is whether the
capacity-freeze mechanism is needed, not whether its preconditions are ordered correctly.

The PR already guarantees what FREEZE protects. From simpler_kernel_mode_init's own
doc comment:

config is context-static; launches never mutate it.

Arena capacity is derived entirely from config.runtime_envresolve_arena_sizing()
(runtime_maker.cpp:461) → ArenaStaticSizes{total_heap, sm_size} + layout.offsets.arena_size
setup_static_arena(...). So a context-static config means a constant
requested_size, which means commit_region()'s

if (arena.is_committed() && requested_size <= cached_size) return 0;

is always the branch taken, and the grow / release branches are unreachable by
construction. FREEZE guards against something the declared contract already rules out.

And a gate is weaker here than code. FREEZE puts the guarantee on the caller
remembering to call it; forget it and the launch path silently reverts to "usually doesn't
allocate, but might" — which surfaces inside an ACLGraph capture as a mysterious failure on
whichever launch first changes sizing. It also models the violation as caller misuse,
which is why it needs a new outward-facing code. If instead kernel mode never allocates
after init, a violation is an internal invariant break and the existing
PTO_RUNTIME_ERR_INTERNAL covers it — no new ABI surface at all. This is the case
.claude/rules/env-macro-gating.md §1 asks us to prefer: "do the thing unconditionally
when it is always correct."

CONFIGURE doesn't survive the same question. Its payload is mode plus capacity
intent, and both already have a single source:

  • mode — whether the caller invoked simpler_init or simpler_kernel_mode_init already
    decides it, which is exactly what ExecutionModeClaimState exists to record. Right now
    the same fact is stored twice with two different enums and no synchronisation between
    them: KernelCtxControlState::tuple_.mode (SimplerExecutionMode) and
    ExecutionModeClaimState::mode_ (ClaimedExecutionMode).

  • capacity intent — gm_heap_bytes / gm_sm_bytes / runtime_arena_bytes map one-to-one
    onto setup_static_arena(uint32_t, size_t gm_heap_size, size_t gm_sm_size, size_t runtime_arena_size),
    which is already fed from CallConfig.runtime_env. And simpler_kernel_mode_init
    already takes a const CallConfig *.

    Worth flagging that this is the exact link where PTO2_RING_* was retired: codestyle.md
    §10 records that removal as the model to copy, because CallConfig.runtime_env "already
    carried the same sizing per task and was strictly more expressive"
    (warn_on_retired_ring_env() in each runtime_maker.cpp is what that left behind). A
    third channel for the same sizing, frozen into a public ABI, is harder to retire than the
    env var was. What can runtime_env not express that these three fields can? If the
    answer is "kernel mode wants final byte counts rather than per-ring parameters", adding
    that path to RuntimeEnv seems preferable to a parallel ABI entry.

Suggested landing: enforce it in commit_region() by reading the existing single
source, ExecutionModeClaimState::mode() == Kernel, rather than a new frozen_ bit.
Reaching the grow or release branch under kernel mode is then a bug, reported as
PTO_RUNTIME_ERR_INTERNAL. No new state, no new entry point, no new error code.

What that removes: the simpler_kernel_mode_ctx_control entry plus both stubs (dlsym
surface 5 → 4) · SimplerKernelCtxControl + SimplerKernelCtxAction +
SimplerExecutionMode + 9 static_asserts · KernelCtxControlState (88 + 81 lines) ·
Environment / Capabilities and the device_bound() / has_committed_arena_region()
accessors added to both runner bases · PTO_RUNTIME_ERR_CAPACITY_EXCEEDED ·
test_kernel_ctx_control.cpp (225 lines). Core churn drops by roughly 40%.

Two side notes that fall out of this:

  • PTO_RUNTIME_ERR_CAPACITY_EXCEEDED currently has zero uses — only the definition at
    runtime_c_api.h:114. It is also one letter away from the existing
    SIMPLER_ERROR_FANIN_CAPACITY_EXCEEDED (device band, code 4) while meaning something
    completely different ("capacity is frozen, don't touch it" vs "the fanin pool is
    genuinely full").
  • It also dissolves a contradiction in the current preconditions. FREEZE requires
    env.init_done, which both c_api_shared.cpp map to device_bound() = device_id_ >= 0,
    and device_id_ has exactly one writer — the program-mode path at
    device_runner_base.cpp:475-479, right after rtSetDevice, commented "simpler_init
    performs the only lifetime write". Since ExecutionModeClaimState makes the two modes
    mutually exclusive, a kernel-mode context can never satisfy init_done as mapped. Today
    that is masked because caps.kernel_mode is false everywhere;
    FreezeSucceedsOnceThenFailsClosed passes only by hand-supplying kInitWithCapacity with
    kFull, a combination no real component can produce. If A1 is not taken, this needs
    resolving on its own — a frozen precondition whose only possible satisfier is the
    mutually-exclusive mode can't stay frozen.

A2 — drop the version machinery from SimplerKernelInvocationHeader

This header goes host → AICPU, and both ends are produced by build_runtimes.py in the
same pip install, landing in the same build/lib/{arch}/{variant}/{runtime}/. They cannot
be built separately, so abi_version can never actually disagree.

Suggest removing: SIMPLER_KERNEL_INVOCATION_ABI_VERSION + the abi_version field + its
consumer-side check · header_bytes (in one build the answer is sizeof) · reserved0 and
reserved[2], whose only purpose is "add a field later without moving the layout", i.e.
version evolution spelled differently — in one self-consistent tree you add the field and
change both sides · the 18 offset static_asserts and the 12 lines of test that re-assert
them. 64 → 40 bytes.

Keep is_trivially_copyable_v && is_standard_layout_v: that guard is
codestyle.md §8's requirement and it catches a real mistake (a pointer or std::string
sneaking into a wire struct), which is a different concern from versioning.

Same question applies to SimplerKernelCtxControl if it survives A1 — though struct_size
should go regardless. In one build sizeof is constant so the check is vacuous; across
builds abi_version already covers it. Two fields guarding one invariant, and because the
match is exact, a v2 struct is hard-rejected rather than negotiated — so it doesn't even
provide the evolution it appears to.

Explicit exception: the scalar_count_ compatibility work is the one place in this PR
with genuine cross-build skew, because ChipCallable has an on-disk kernel cache. That
reasoning and its four tests are correct as they stand — please don't remove them. The
distinction is whether the byte stream is written to disk, sent to another machine, or
compiled against by another repo.


B1 — scalar_count duplicates state already derivable from the signature

ChipCallable::signature_[0..sig_count_) already carries the scalars, stated independently
in two places: prepare_callable_common.h:62 ("Scalars are also present
(ArgDirection::SCALAR) and follow the tensor entries") and runtime_maker.cpp:562
("scalars follow the tensor entries"). The repo also already has the derive precedent —
count_callable_tensor_args() (args_dump_aicpu.cpp:282) computes the split by filtering
SCALAR out of sig_count() rather than storing it.

Three concrete consequences:

  1. Two different caps for overlapping facts. make_callable validates
    scalar_count ∈ [0, CHIP_MAX_SCALAR_ARGS=128] while signature_ holds up to
    CHIP_MAX_TENSOR_ARGS=256 entries.
  2. No consistency check, and the new test pins the inconsistency as accepted behaviour:
    test_task_interface.py:1258 builds signature=[IN, OUT], scalar_count=5 and asserts it
    round-trips — two args, zero SCALAR entries, declaring five scalars.
  3. An ambiguous consumer contract. kernel_invocation_header.h says the counts are
    checked "against the callable's declared signature (ChipCallable sig_count /
    scalar_count)", but sig_count includes scalars, so the correct comparandum for
    tensor_count is sig_count - scalar_count. As written this is a trap for whoever
    implements the AICPU-side check.

Either have make_callable verify the field equals the trailing SCALAR run and document
it as a cached derivation, or drop the field for a derived accessor (no wire change needed).
Either way please make the header comment say sig_count - scalar_count explicitly.

B2 — argument validation for the other entries is duplicated, untested

init / prepare_callable / launch validate inline in both
onboard/host/c_api_shared.cpp:1218-1262 and sim/host/c_api_shared.cpp:1020-1064. I
diffed the added sections: 55 of 58 lines are byte-identical, the only differences being
the comment block, one static_cast<DeviceRunnerBase*> vs <SimDeviceRunnerBase*>, and one
log string. The callable_id range check, the callable_size < sizeof(ChipCallable) check,
and the three (ptr == NULL && size != 0) triples each exist twice with no test on either
copy.

The PR justifies the shared placement of KernelCtxControlState on the grounds that "stub
parity is a correctness requirement, not a convenience" — I agree with that, which is why
it's worth noting the argument currently holds for 1 of 5 entries (and 0 of 4 if A1 lands).
A small shared validation helper would put all of them behind the same reasoning.

Minor, same area: the null/size checks are one-directional —
(binary == NULL && size != 0) is rejected but (binary != NULL && size == 0) passes
silently.

B3 — initialize()'s cleanup-failure branch is untested

kernel_execution_state.cpp:87-97 has the most distinctive semantics in the file: when a
create fails and the rollback cleanup also fails, the context lands in Closing rather
than back in New, latches the cleanup error in unexpected_teardown_error_, keeps ops_
for a retry, and returns the create error rather than the cleanup one. None of that is
covered — PartialInitFailureRollsBackCleanly only exercises the clean-rollback path.

Also uncovered: the get_current_device failure return. And
FakeContextOps::create_stream_rc_after is defined but never set by any case (only
create_event_rc_after is used). Setting destroy_failures_remaining alongside
create_event_rc_after, plus one case on the stream knob, would close it.


Smaller points

  • KernelLaunchOps is declared and consumed by nothing. The "forbidden operations are
    unrepresentable" guarantee only binds a future launch implementation if that
    implementation is required to route through the table. Nothing enforces that today — K2
    could call aclrtSynchronizeStream directly and no test would notice. Worth stating the
    routing requirement in the header as an obligation on the consumer.
  • KernelContextPhase::Initializing is unobservable. phase_ only holds it inside
    initialize()'s critical section and it is always overwritten before the lock is
    released, so close()'s case Initializing is unreachable. Harmless as defensive code,
    but the header's phase-machine diagram also omits Initializing while the enum lists it —
    worth making the two agree.
  • The resource set is hard-wired. initialize() unconditionally creates all
    KernelStreamKind::Count streams and all KernelEventKind::Count events. If hbg and tmr
    end up needing different event sets, this class changes rather than its caller. If the
    four events are genuinely runtime-independent, one sentence saying so would settle it.
  • ExecutionModeClaimState::mark_closed() returns an int that is always 0 — either
    void or give it a failure case.
  • simpler_kernel_mode_init's first 9 parameters are byte-identical to simpler_init's
    (the tails differ: 3 sdma parameters vs 1 context_generation). I am not suggesting
    merging the entries — the borrowed-device init semantics genuinely differ. But two
    8-parameter binary-loading lists will drift together; a shared
    struct SimplerExecutorBinaries { ... } would prevent that.
  • Reverse improvement worth taking: simpler_kernel_mode_prepare_callable takes
    callable_size, while the existing simpler_register_callable(ctx, callable_id, const void *callable)
    takes only a pointer and therefore cannot validate the image at all. The new entry is
    right; consider backporting the parameter.
  • kernel_execution_state.cpp (206 lines) is compiled into all four host runtimes with
    zero production callers
    , referenced only by its UT. Negligible in size and clearly
    intentional for a K1 skeleton, but worth tracking if K2 slips.

ℹ️ pto_isa.pin is a8040450238f162985d8b596fbebeb54bfba2bf5 and this PR changes no
pto-isa header references (verified: zero +/- pto includes in the diff), so no pin bump
is implied. Advisory only.


Net of A1 + A2 this PR gets smaller — one fewer public ABI entry, one fewer wire struct,
one fewer state machine, one fewer error code — while the core kernel-mode guarantee gets
harder, because it stops depending on caller discipline. That seems like the right trade for
a PR whose entire value is that the surface it freezes will not move.

@sunkaixuan2018

Copy link
Copy Markdown
Contributor Author

@ChaoWao All review items are addressed; the branch is re-squashed to one commit, rebased onto current main (the conflict is gone), and the full remote round is green (ut_cpp 139/139, kernel-mode UTs 23+5+3+5, full pyut 2214 passed / 0 failed). Item by item:

A1 — taken in full. simpler_kernel_mode_ctx_control, SimplerKernelCtxControl/SimplerKernelCtxAction, KernelCtxControlState, the Environment/Capabilities mapping and both runner accessors, PTO_RUNTIME_ERR_CAPACITY_EXCEEDED, and the 225-line test are all removed (dlsym surface 5 → 4). The mode's single source is now exactly the one you named: ExecutionModeClaimState, wired into both runner bases; the three ACL guard sites key on mode() == Kernel, and the capacity guarantee landed in commit_region() itself (onboard + sim) — a grow or release request on a committed region under kernel mode reports PTO_RUNTIME_ERR_INTERNAL as an invariant break, not a caller error. Your side note about the init_done precondition being unsatisfiable dissolves with the mechanism.

A2 — taken. abi_version, header_bytes, reserved0, reserved[2], the offset asserts and their test lines are gone; the header is 40 bytes, the POD/standard-layout guards stay, and SimplerExecutionMode moved into kernel_invocation_header.h (its one wire consumer). The scalar_count on-disk-cache reasoning and its tests are untouched, per your exception.

B1 — taken as "cached derivation + verify". make_callable now rejects a nonzero scalar_count that disagrees with the signature's SCALAR entry count; 0 keeps meaning "not recorded" so every existing caller (and legacy blob) is unaffected. The [IN, OUT] + scalar_count=5 test now pins the rejection instead of the inconsistency, and the sig_count - scalar_count comparandum is stated explicitly in the invocation header, the field comment, and the Python API doc.

B2 — taken. The entry validation is one copy in host/kernel_entry_validation.h, compiled into all eight components, with its own UT — including both directions of the null/size check (a binary pointer and its size must be present or absent together).

B3 — taken. New cases cover the failed-rollback path (create fails, cleanup also fails → Closing, create error reported, cleanup error latched in the teardown slot, explicit close retry succeeds), the get_current_device failure return, and the previously unused stream-create knob.

Smaller points: KernelLaunchOps now states the routing obligation in its header; the phase-machine doc says Initializing is unobservable outside initialize()'s critical section and that the stream/event set is the shared protocol vocabulary created unconditionally; mark_closed() is void. Two suggestions are deliberately not in this PR because they modify existing program-path ABI, which this PR's hard rule forbids: the shared SimplerExecutorBinaries struct (changes simpler_init's parameter list) and backporting callable_size to simpler_register_callable — both are good and belong to their own changes.

The one remaining debt is stated in the PR body: the four guard sites cannot be exercised through a real .so until something can claim kernel mode, so the persistent-state PR that flips the capability owes the rejection tests for all of them.

@ChaoWao ChaoWao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at 9b83280: verification, then an architectural read

Thanks — A1/A2/B1/B2/B3 all landed, and A1 landed more thoroughly than I asked for
(the three ACL-lifecycle guards were yours, not mine).

What I verified rather than took on trust

Item Verified
A1 ctx_control / CtxControl / PTO_RUNTIME_ERR_CAPACITY_EXCEEDED have zero residue repo-wide (the one grep hit is the unrelated pre-existing SubmitDispatchResult::CAPACITY_EXCEEDED); dlsym surface 5 → 4
A2 fields reordered by alignment, sizeof is 40; version/header_bytes/reserved/offset asserts gone, POD guards kept; the "both sides come from one build" criterion is now stated in the header
B1 validation body is correct — with sig == nullptr && sig_count == 0 the loop doesn't run, so no null deref
B2 one copy, (binary == nullptr) == (size == 0) fixes the one-directional check, 5 dedicated UTs
B3 all three cases present, including the previously idle stream knob
CI 19 pass + 1 skipping (deploy)

The commit_region() guard is right on the boundaries: kernel_mode short-circuits first
so the program path is untouched, arena.is_committed() lets the first commit through
(otherwise kernel mode could never establish capacity), and the ternary maps exactly onto
the grow/release branches.

I also checked your exhaustiveness claim on ensure_acl_ready class by class, since that
kind of claim needs a positive control: AclInitGuard's only instantiation is below the
guard in force_reset_device (a2a3 877 > 869, a5 833 > 826); acl_ready_'s only
write-to-true is below the guard in ensure_acl_ready; the fatal path reaches reset only
through attempt_fatal_resetforce_reset_device, and its if (acl_ready_) block has no
else reset. Within the four APIs it names, the claim holds.


The architectural read

Below is the part I owe you that I didn't give last round. One judgement, then the
structure behind it.

This PR uses two opposite techniques for one constraint — "kernel mode must not touch the
caller's device state" — and applies them to the wrong halves.

Technique Where Strength Status
Typed allowlist — forbidden ops don't exist in the type KernelContextOps / KernelLaunchOps Structural: a future author cannot write the call Zero consumers, not wired
Scattered denylistif (mode()==Kernel) refuse 8 sites across 4 files Exhaustive: true today, by discipline tomorrow This is the one actually in force

The technique that survives contact with future edits is the one that isn't connected yet.
The three problems below are consequences of that inversion, not separate defects.

1. The denylist's perimeter is drawn around the wrong set, and the main path is outside it

ensure_acl_ready is not on the program path at all. It's exposed as its own C entry
ensure_acl_ready_ctx, and its only caller is ChipWorker::create_comm_stream_checked
(chip_worker.cpp:977) — the comm path. The ordinary program path is:

simpler_init → attach_current_thread(device_id)
                 ├─ rtSetDevice(device_id)             ← no guard
                 ├─ configure_aicore_op_timeout()      ← no guard
                 │    └─ aclrtSetOpExecuteTimeOutV2()
                 └─ device_id_ = device_id

aclrtSetOpExecuteTimeOutV2 is device-global configuration. It isn't one of the four APIs
the claim enumerates, so the perimeter misses it — and it is precisely the
borrower-pollutes-host case: kernel mode borrows torch_npu's device and silently changes
the op-execute timeout for every torch_npu operator on that device. Worse than a stray
rtDeviceReset, because nothing fails; the host's behaviour just quietly changes.

This isn't an oversight so much as the denylist's defining property: you have to already
know what to forbid in order to forbid it.
An allowlist inverts that — a call absent from
the table cannot be reached, no enumeration required.

2. attach_current_thread fuses three concerns, and K2 has no seam

rtSetDevice(device_id);                    // (1) bind thread to device
if (device_id_ == -1) {
    configure_aicore_op_timeout();         // (2) mutate device-global config
    device_id_ = device_id;                // (3) record identity
}

Kernel mode needs (3), probably wants (1), and must never have (2) — and there is no seam to
separate them. K2's simpler_kernel_mode_init will have to either reuse this (polluting the
host's timeout) or write a second path that only does (1)+(3), at which point device_id_'s
"simpler_init performs the only lifetime write" comment stops being true.

This is also the root of the init_done contradiction from the last round. A1 removed
FREEZE, but the underlying coupling — device_id_'s write being welded into a program-only
method — is untouched and will resurface in K2 unchanged.

3. Identity is a construction-time property modelled as a runtime state machine — and nothing writes it

ExecutionModeClaimState has Unclaimed → Program|Kernel → Closed. But a context's identity
is fixed at its first init and never changes: that's a constructor parameter, not a state
machine. The costs of modelling it as one have all come due:

  • Unclaimed must exist, and since every guard reads == Kernel, Unclaimed silently
    means "program".
  • Neither init entry claims. simpler_init goes straight to attach_current_thread
    with no claim; kernel init is a stub. claim_program / claim_kernel have zero call
    sites in src/
    — only 8 reads of mode().
  • Therefore mode() is permanently Unclaimed, all 8 new guards are permanently
    unreachable
    , and claim_* / abort_kernel_initialization / mark_closed are dead code
    outside their UT.
  • The program/kernel mutual exclusion the class exists to provide is currently enforced by
    nothing.

If identity were fixed when the context is created, Unclaimed wouldn't exist and "the
defence silently does nothing because someone forgot to claim" would be structurally
impossible. As it stands this is the same anti-pattern FREEZE was removed for — a guarantee
resting on someone remembering a step — relocated rather than eliminated.

4. One handle carries two contracts, indistinguishable at the C ABI

simpler_run(ctx, ...) on a kernel-mode context is type-legal. It doesn't break today only
because kernel mode can't be established; after K2 the thing stopping it will be yet another
runtime guard.

Meanwhile the core methods are growing identity branches: finalize() already has three
paths (acl_ready_ / kernel / else), setup_static_arena() has two capacity semantics,
ensure_acl_ready() has a permanently-refusing path. Persistent state, launch blobs and
invocation snapshots are all still to come, and each will add its own branch. The seam
belongs at the handle: if the two identities produced distinct types (or the context carried
an immutable mode), "program entry on a kernel context" would be a type error instead of the
next guard's customer.

5. The concept has no owner

ClaimedExecutionMode (state machine) lives in platform/include/host/;
SimplerExecutionMode (wire value) lives in task_interface/. Two representations of one
concept in two architectural layers, with no conversion function and no consistency
guarantee — K2 will have to invent the mapping when it writes the state machine's Kernel
into the header's mode. Execution identity is neither a platform detail nor a
task_interface detail.

Suggested direction

Not all of it belongs in this PR — but it's worth settling before the surface is frozen:

  1. Move identity to context creation, demoting ExecutionModeClaimState from a mutable
    state machine to an immutable field. Unclaimed disappears; guards stop depending on who
    remembered to claim.
  2. Split attach_current_thread into composable steps so kernel init can take
    "record device_id_" and "bind thread" without "mutate global timeout". K2 needs this seam;
    it is cheaper to cut now.
  3. Move the device lifecycle onto a capability table, isomorphic with
    KernelContextOps / KernelLaunchOps. The PR already argues that technique is right for
    launch; it is equally right for the ACL lifecycle, which is the half actually executing
    today.
  4. If 2 and 3 are too large for this PR, it should at minimum wire claim_program() into
    simpler_init.
    That changes no program behaviour (first call succeeds, idempotent) but
    gives the state machine's program half full CI coverage and makes the mutual exclusion
    real. Without it the 8 guards are declarations until K2. Worth deciding what finalize()
    does — a mark_closed() there would reject an init → finalize → init reuse.

Two concrete items independent of the above

scalar_count == 0 is ambiguous in a way the new formula doesn't survive. The field
comment keeps "0 means either an artifact built before this field existed or an orchestration
that takes no scalars; the two are indistinguishable", while the invocation header now states
unconditionally that a consumer checks tensor_count against sig_count - scalar_count. A
legacy callable whose signature holds 5 SCALAR entries with scalar_count = 0 makes that
formula yield tensor_count = sig_count, counting the scalars as tensors. B1 fixed the
formula but not its interaction with the sentinel.

Two options; I'd prefer the second. Either document the fallback (scalar_count == 0
derive by counting SCALAR entries, i.e. what count_callable_tensor_args() already does),
or have make_callable also reject scalar_count == 0 when the signature does contain
SCALAR, so 0 unambiguously means "no scalars". I checked examples/, tests/st/ and
simpler_setup/: no production ChipCallable signature contains ArgDirection.SCALAR
today
, so the stricter version breaks no existing caller.

Fatal teardown under kernel mode retries three times. attempt_fatal_reset(force_reset_device, kFatalResetAttempts=3) will hit the new guard three times, emitting three
"force_reset_device: refused" errors plus a "did not confirm clean" — which reads like a
failed reset when it is in fact a by-design refusal. Returning UNSUPPORTED is semantically
right (kernel mode genuinely must not reset the caller's card), but the branch belongs
before attempt_fatal_reset. Unreachable today; K2's problem.


None of this is a "it's broken" finding — CI is green and the program path is genuinely
untouched. The architectural point is narrower: A1 removed a guarantee that rested on the
caller remembering to call FREEZE, and replaced it with guarantees that rest on developers
remembering to add guards and on init remembering to claim.
The abstraction level dropped;
the pattern didn't change. Item 4 above is the cheapest step that turns the current
declaration into something CI actually exercises.

@sunkaixuan2018
sunkaixuan2018 force-pushed the skx/kernel-PR1 branch 5 times, most recently from 9ee718b to 1ecb27c Compare September 9, 2026 07:38
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>
@sunkaixuan2018

sunkaixuan2018 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@ChaoWao This replaces an earlier comment of mine on the same subject — if you have two notifications from this PR within the hour, this is the one to read.

My first reply argued the identity work belonged in a follow-up PR. That was wrong, and it is now in this one at dc1268cd (single commit, CI green: 19 pass, deploy skipping). The reason is concrete rather than a change of taste: 覃云集 is developing K2 against this surface right now, and his 挂接段 is exactly the part that lands on this foundation, so every item I was deferring is one he would have built on and then redone. The criterion that actually sorts your five items is "does K2 get blocked, or built on sand?" — not "how large is the review surface". It moves four of them in and leaves item 4 out, and it is a better sort than mine was.

Why the larger diff is still safe to read as zero program-path change

git grep 'latch(SIMPLER_MODE_KERNEL)' src returns nothing. Both simpler_kernel_mode_init stubs return UNSUPPORTED before reaching any latch call, so is_kernel() is false on every context that can exist in this commit, and every guard, state machine and kernel entry added here is provably dead. The program path takes the branch it took at 405b5bbd. CI green is corroboration of that, not the proof — worth saying plainly, because an earlier push in this series (1ecb27ca) did not compile at all: rc out of scope in both arch siblings, caught by ut-a2a3. That is fixed in the head being reviewed.

The one added call on the program path is simpler_init latching PROGRAM on a fresh context, which always succeeds, is idempotent, and is read by nothing on that path.

Your items

Items 2, 3 and 5 — identity is a write-once property, not a state machine. ExecutionModeLatch replaces the four-state ExecutionModeClaimState; Unclaimed and the claim-state Closed are gone. One mutator, one transition, unlatched→latched. simpler_init latches PROGRAM as its first act in both backends — above the CANN dlog level write on onboard, above set_dma_workspace_request on sim, i.e. above the first side effect rather than merely above the attach — so a program init on a kernel context is refused before it mutates process or runner state, and the mutual exclusion is enforced on every init instead of resting on a step someone has to remember.

There is no unlatch and no rollback. That is an init-side decision with a consequence worth writing down rather than leaving to be derived: deleting abort_kernel_initialization means a handle from a failed kernel init can never be recycled into a program context. That is the intended answer — a half-constructed borrowed context must not become a device-owning one — and the latch header now says so. Re-latching the held mode is idempotent, so program re-init after finalize stays legal; kernel re-init is refused by KernelExecutionState's phase machine regardless of the latch. The permission is deliberately asymmetric.

SimplerExecutionMode now has a single definition in task_interface/execution_mode.h, included by both the wire header and the platform latch (item 5). The two-representation problem could not have survived K2 writing header.mode from the host-side value.

Items 1 and 2 — the attach split. bind_current_thread is the bare per-thread rtSetDevice with its preconditions. attach_current_thread keeps its name, signature and exact program behaviour — bind, then on first call the timeout resolve, the one-shot watchdog write and the identity record — and refuses outright on a kernel latch. adopt_borrowed_device records which device a kernel context runs on and calls neither rtSetDevice nor configure_aicore_op_timeout. That last omission is your item 1: aclrtSetOpExecuteTimeOutV2 is a card-scoped STARS setting that takes no device or process argument, and the single call to it in the tree now sits below the kernel refusal, so it is unreachable from a borrowed path by construction. adopt_borrowed_device is the item-2 seam, and it is wholly unexercised — zero callers, precondition is_kernel(), so any call at HEAD returns INVALID_STATE.

Of the seventeen attach_current_thread call sites, fifteen are untouched. The two I changed are both in DeviceRunner::finalize() (a2a3 :1100, a5 :985), now under if (!execution_mode_latch().is_kernel()) — finalize is the one caller that legitimately runs under both identities. Everything else that calls it is program-only by definition and is refused, which also replaces the accidental protection those entries had been getting from device_id_ == -1.

device_id_ changed meaning, and this is the only pre-existing invariant this PR touches: it now means which device this context is on, not this context owns the device. Ownership is what the latch carries. I swept every read and publish of the field across both arches and both backends before changing it, because a read that was really asking about ownership would become a latent bug the moment K2 wires kernel init — there are none; every one asks "which device" or "is one recorded yet". Its three declaration comments moved with it.

Item 3 was right about the goal and wrong about the mechanism — and I would not have found that without tracing it

You said wiring the claim would make the guards fire. It would not have, and neither would a latch on its own. Two of the three ACL guards sit behind device_id_: force_reset_device opens if (device_id_ < 0) return INTERNAL, and finalize() opened if (device_id_ == -1) return 0. device_id_ had exactly two writers — attach_current_thread, which must rtSetDevice first, and ensure_acl_ready, which guard ① refuses — so a kernel context could reach neither, and the documented fifth lifecycle entry released nothing. Those two guards were unreachable by a mechanism entirely separate from the missing claim, and a PROGRAM claim can never make is_kernel() true, so nothing about wiring it would have armed them.

Making device_id_ mean "which device" fixes that. Two consequences worth naming rather than leaving to be discovered: guards ② and ③ become reachable for the first time, and a kernel-mode force_reset_device now returns UNSUPPORTED where it previously returned INTERNAL from the check above it. On a5 that refusal is still not side-effect-free — clear_aicpu_topology_cache() runs before both checks, where a2a3 changes nothing before refusing — and I left that alone rather than reorder a fatal path in the same diff.

One defect neither of us has reported, found while checking the above

The kernel no-reset branch in finalize() is an else if under if (acl_ready_), so it is unreachable whenever acl_ready_ is true — a2a3 :1139 / :1151, a5 :1008 / :1020. On such a context a kernel finalize takes the acl_ready_ arm and runs aclrtResetDevice + aclFinalize on the caller's borrowed card: precisely what the branch below it exists to prevent.

It is reachable because ensure_acl_ready is the hole in the mutual exclusion. It does three device-owning things — aclInit, aclrtSetDevice, acl_ready_ = true — plus the device_id_ write, and it latches nothing. It is exported as its own C ABI entry (ensure_acl_ready_ctx, dlsym'd at chip_worker.cpp:240) and driven by comm_init, so it is reachable without simpler_init. Its own kernel guard refuses once a context is latched KERNEL, but nothing stops the reverse order: collectives bring ACL up on an unlatched context, and a later kernel init latches KERNEL over the top.

Today this is inert along with everything else — nothing latches KERNEL — so it is a latent defect that arms the moment K2 makes kernel init real. I would rather hand it to you named than have you find it. The cheaper of the two fixes looks like refusing a kernel init on a context whose acl_ready_ is already true, since that touches no program-path behaviour; making ensure_acl_ready latch PROGRAM is more symmetric but changes what the collectives path permits afterwards. Your call, and it belongs with whoever closes the hole rather than bolted on here.

Also in, from the previous round

Every guard reads is_kernel() rather than comparing the enumerator — twelve reads across both arches and both backends. validate_kernel_prepare_callable_args checks the callable image's alignment. ChipWorker was leaving the four new function pointers dangling into the .so that DlHandleGuard dlcloses, on all three teardown paths; they are nulled on each.

On scalar_count == 0: I took the other option you listed. Rather than have make_callable reject a zero count when the signature holds SCALAR entries, the contract now has consumers derive the effective count — the field when nonzero, otherwise the signature's SCALAR entries, which is what count_callable_tensor_args already does. Your survey covered this repo; prepare_callable_common.h:62 and runtime_maker.cpp:562 both document scalars trailing tensors in orchestration signatures, and I could not rule out a pypto-side producer, so tightening looked like it could break a downstream build for a property that deriving gets right for free. A test pins the miscount the naive formula produces. If you would still rather tighten it, say so and I will verify the pypto side first.

Not in this PR — four items, and none of them is "the next PR"

Item 4, one handle carrying two contracts — yours to rule. This wants distinct types or an entry-level split at the C ABI, which is a surface change, and it does not block K2. Today the eleven program-only entries (simpler_register_callable, prepare_run, launch_run, poll_run, wait_run, finalize_run, device_memory_info_ctx, plus the sim counterparts) are refused because attach_current_thread refuses, which surfaces as UNSUPPORTED. Whether that is the right shape, or whether they should be rejected at the entry with a clearer code, is the concrete form of your item 4 — and because it is an ABI surface decision, it gets more expensive the longer K2 builds on the single-handle shape. I am not landing a guess at it here; I would rather have your view.

The fatal-teardown policy — now a card with no owner. This stopped being a branch move once device_id_'s new meaning made guard ② reachable. After the refusal: reset_confirmed is permanently false so device_unusable_ never clears, aclFinalize is skipped, a2a3 first burns its 10 s SDMA handoff wait for a reset that will not happen — and abandon_common_after_device_failure() still routes the arenas through DeviceArena::abandon_after_device_failure(), which forgets the buffer without freeing it on the documented premise that a reset invalidated the addresses. On a borrowed card that was never reset, that premise is gone. Your own §2 in v9 supplies the replacement, and it is the stronger argument: the slot lease is host-only and replay never returns to the host, so it cannot express captured-node ownership, which makes 原则6's "宁可 pin" the only option rather than the conservative one. So the action stays and the justification changes — but what finalize() should report, whether device_unusable_ should clear or be read as 原则7's terminal-and-pinned state, and whether the SDMA wait should be skipped under a kernel latch, all still need deciding. None of the thirteen cards owns it: ①K1 declares and creates nothing, ④K2 covers close and destroy, ⑨binder is the launch path. I have added a card for it to the pipeline chart with those three questions and a recommendation each; it has no owner assigned.

commit_region's refusal is not side-effect-free. It returns INTERNAL, the caller collapses that to ok = false, and the unconditional rollback releases all three arena regions — DeviceArena::release() frees the backing buffer, so a fired guard drops the very base addresses it was added to protect. You read that guard as correct on the boundaries and it is; the surrounding error handling is what undoes it. The comment records this now. I had it down as a design pick between "skip the release" and "let the rollback run", but by the 原则6 argument above it is not a pick: a failed setup should converge to pinned-and-recorded, not freed. That makes it the same decision as the fatal path, which is why it belongs with the arena work rather than bolted on here.

ensure_acl_ready's unlatched device_id_ write — the root of the finalize defect above. Zero impact today, because unlatched reads the same as the old Unclaimed, but the mutual exclusion is not yet total and I would rather say so than have it surface under K2.

Merge request

The request stands on a better basis than last time: the guards are no longer declarations, the seam K2 needs exists and is labelled unexercised, the parts that remain inert are shown to be inert by grep rather than asserted, and the three things I am leaving out are named with an owner each instead of pooled into a vague follow-up. Item 4 is the one I need from you before it can move.

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