Skip to content

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

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
sunkaixuan2018:skx/kernel-PR1
Sep 13, 2026
Merged

Add: kernel-mode C ABI skeleton and wire headers (K1)#2064
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
sunkaixuan2018:skx/kernel-PR1

Conversation

@sunkaixuan2018

@sunkaixuan2018 sunkaixuan2018 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Add the shared kernel-mode C ABI and contracts for contexts that borrow the caller's device and stream. The program entry latches PROGRAM before changing runner or process state. ExecutionModeLatch is write-once and idempotent for the same mode; failures and finalization never roll it back or permit a switch to the other mode.

All current backends report kernel mode unsupported. This PR defines and tests the shared contracts; it does not enable kernel launches or allocate kernel execution resources in production.

Scope. This gate carries the surface the downstream PRs must agree on, plus the guards that make program mode safe today. The persistent execution resources — hidden stream pair, event set, and phase machine — are not here: they have no consumer in this change, ship in no library, and the K2 branch already stacked on this one revises the event set from four entries to five with an ordering constraint this change has no way to see. They belong to the change that implements them.

Contracts and implementation

  • Four C entries: simpler_kernel_mode_supported, simpler_kernel_mode_init, simpler_kernel_mode_prepare_callable, and simpler_kernel_mode_launch. Kernel close reuses finalize_device. Every built component exports all four, and ChipWorker resolves them unconditionally like the rest of the uniform ABI — shipping not-supported stubs is already this repo's answer for an unsupported capability, so the probe answers the capability question at call time rather than deciding which symbols get resolved.
  • simpler_kernel_mode_supported runs before any init, and create_device_context() takes no device, so an implementation answers from the runtime build alone; ctx is accepted for signature symmetry and must not be dereferenced for a device.
  • The invocation envelope is exactly 40 bytes, with assertions for every field offset and explicit zero-only host_copy_tensor_count and reserved_ fields. Runtime-specific payloads follow it. Both wire endpoints are built together, without version negotiation.
  • Kernel-entry structural argument errors return INVALID_ARGUMENT (-1004), explicit mode/lifecycle guards return INVALID_STATE (-1003), and an unavailable capability returns UNSUPPORTED (-1001). INTERNAL (-1000) remains reserved for internal invariants, including forbidden changes to committed kernel arena capacity.
  • ChipCallable::scalar_count() and the Python read-only property derive the count from the signature through count_scalar_args. No scalar-count field, sentinel, or extra factory argument is added. The original C++/Python construction interfaces, callable sizes, and field offsets remain intact and are protected by layout assertions.
  • Onboard thread attachment is split into bind_current_thread (thread binding), attach_current_thread (program ownership and watchdog setup), and adopt_borrowed_device (record a borrowed device). Mode guards read the single latch. ensure_acl_ready latches PROGRAM before its first ACL call, because that entry is reachable without simpler_init and taking ACL ownership is itself what makes a context a program context. The sim arena has no kernel-only branch.
Contract Enforced here Required by production integration
Context identity Program init latches PROGRAM; latch unit tests cover exclusivity and persistence Kernel init latches KERNEL and rejects program reuse
Kernel entries Shared structural validation and uniform unsupported stubs Supported init, prepare, launch, and close
Invocation envelope Fixed layout and byte-level tests Populate residency generation; validate identity, generation, counts, capacity, and zero-only fields on AICPU
Persistent resources Not in this PR Owned by the change that implements them, together with enqueue/close serialization and graph-resource lifetime
Borrowed device and capacity Onboard guard sites, ownership separation, and a capacity refusal decided across every region before any is touched Real-context guard rejection tests

A kernel-mode capacity refusal is decided over all three arena regions before the commit sequence begins, so it returns without reaching the rollback that would release the committed bases a captured graph still names. Program-mode rollback semantics are unchanged. No production entry latches KERNEL in this PR, so the onboard kernel branches remain unreachable until integration enables support. context_generation is checked for nonzero by the stub but is not persisted by it.

Testing

Verified on Linux against a worktree-local venv built with build.targets=build_package_sim:

  • CTest: 144/144 passed (hardware tests excluded), including the new test_execution_mode_latch.
  • Python unit suite: 2254 passed, 7 skipped, 0 failed (-m "not requires_hardware"), including the reworked ChipWorker dlsym cases — an unsupported runtime still exports the whole family, and any missing entry fails init whatever the probe would answer.
  • clang-format, ruff check, ruff format, and markdownlint-cli2 clean on every changed file.

CI has been re-triggered by the latest push; see the checks below for the authoritative result.

@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: f8bca506-9b18-4c9b-8b30-4cb2abd28687

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 change: adding the kernel-mode C ABI foundation and wire headers. It is concise and specific.
Description check ✅ Passed The description directly addresses the kernel-mode C ABI, wire contracts, runtime stubs, state handling, compatibility, and tests included in the changeset.
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 6 times, most recently from 1ecb27c to dc1268c Compare September 9, 2026 07:44
@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.

@poursoul poursoul 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 C ABI skeleton (#2064)

merge-base405b5bbdhead86dd62b4 | CI 全绿(含 onboard a2a3/a5、双 arch UT、packaging)

先说结论:方向和分层是对的,身份 write-once、guard 只读一处、stub 与真实现共享校验、受限词汇表让禁止操作不可表达——这几个决定都比"加个 bool 判断"稳得多。但作为一个**唯一产出就是"一份别人可依赖的接口说明"**的 PR,有四条建议在合入前处理。


0. 变更规模

Bucket 文件 churn
Core 19 1165 38 1203
Test/Ex 9 836 3 839
Docs 2 9 2 11
Build 4 4 0 4
TOTAL 34 2014 43 2057

Core 1203 行超过 1000 行阈值。不过构成特殊:其中约 480 行是新头文件里的说明性注释,实际可执行逻辑不到 400 行——真正的审阅负担不在代码量,而在下面第 1 条。


1. 机制理解(供核对,若有误读请指正)

  1. 身份ExecutionModeLatchsrc/common/platform/include/host/execution_mode_latch.h)挂在两个 runner base 上,write-once。simpler_init 在触碰任何 runner / process 状态之前先 latch PROGRAM,所以互斥在每次 program init 上都成立,不依赖谁记得调一个单独的 claim 步骤。无 unlatch、无 rollback。
  2. C ABI:4 个新入口(simpler_kernel_mode_supported/init/prepare_callable/launch),第五个生命周期入口复用已有的 finalize_device;新增 host band 错误码 PTO_RUNTIME_ERR_INVALID_STATE = -1003
  3. wire 信封SimplerKernelInvocationHeader(40 字节)+ 后随 runtime-specific payload;payload 格式归各 runtime,两侧同一次 build_runtimes.py 产出,故不带版本协商。
  4. 共享参数校验kernel_entry_validation.h 三个 inline 函数编进全部 8 个 host-runtime 组件,保证 stub 与真实现接受/拒绝完全相同的参数。
  5. 状态机KernelExecutionStateNew → Collecting ⇄ ReadyEnqueued,部分入队失败 → Poisoned(只接受 close),close → sticky 可重试 ClosingClosed;两个独立错误槽(首个 poison 原因 vs 首个真实 teardown 失败)。
  6. 受限词汇表KernelContextOps(5 op) / KernelLaunchOps(6 op),synchronize、分配、stream 创建、capture 查询、model attach 在表里无法表达。
  7. 设备绑定拆分attach_current_threadbind_current_thread(纯 rtSetDevice)+ attach_current_thread(program:bind + 写 op-execute 看门狗 + 记 device_id_)+ adopt_borrowed_device(kernel:只记 device_id_)。
  8. guard 落点(8 处,当前全部不可达):ensure_acl_readyforce_reset_devicefinalize() 的 rt 层 reset、attach_current_threadsetup_static_arenacommit_region(onboard + sim),a2a3 / a5 各一份。

2. 做对的地方

  • a2a3 / a5 双树同步落地,无遗漏,符合 codestyle 规则 10「同一 commit 完成所有 sibling」。
  • scalar_count_ 的落位很稳妥:塞进 config_name_len_storage_ 之间的历史尾部 padding,sizeof(ChipCallable) 保持 9376,所有字段偏移不变,legacy blob 读作 0;并且顺手给 ChipCallable / CoreCallable 补了 20 条 offset/size ABI 断言,这是净收益。
  • validate_kernel_prepare_callable_argsalignof(ChipCallable) 而非 CALLABLE_CHILD_ALIGN——因为 storage_ 声明为 alignas(CALLABLE_CHILD_ALIGN) char storage_[],两者等价,检查是对的。
  • Python 侧 scalar_count 是尾部带默认值的 kwarg,不破坏任何 Python 调用方。
  • commit_regionis_kernel() 提到 lambda 外只取一次,没在循环里反复加锁。
  • 新增的 KernelExecutionState / ExecutionModeLatch 都没带项目名前缀 ✅(对比下面 Should-fix 5)。
  • #pragma once ✅|enum class ✅|无新增 PTO2

3. Must fix / 必须讨论

3.1 PR 描述描述的是一个已被推翻的版本

这是本 PR 最实质的问题,因为它唯一的价值就是给后续并行开发一份可依赖的说明——说明本身错了,冻结就失去意义。

  • body 里的 ExecutionModeClaimStateaccepts_kernel_calls()claim_kernel()ClaimedExecutionMode树里一个都不存在
  • body 写 claim 是 "abortable on init failure",而 execution_mode_latch.h 明确写 "There is no unlatch and no rollback"——直接矛盾
  • body 的 "Known debts" 里「ClaimedExecutionModeSimplerExecutionMode 是两套零值相反的表示且无 converter」这条已不成立(现在只有 SimplerExecutionMode 一套)。
  • body 的 "Deliberately left to the follow-up PR" 一节用三段论证说明拆 attach_current_thread(thread bind / 设备看门狗 / 身份三合一)不在本 PR 处理,但代码确实拆成了 bind_current_thread / attach_current_thread / adopt_borrowed_device
  • 测试数 body 写 23,实际 24。

旁证:把 #2171 携带的 K1 快照放进来对比,这个机制至少改过三版——

来源 机制
本 PR body 描述 ExecutionModeClaimState / claim_kernel() / abortable
#2171 携带的 K1 快照 kernel_ctx_control / SimplerKernelCtxControl wire struct / CONFIGURE→FREEZE
本 PR 当前 head ExecutionModeLatch / latch() / 明确无 rollback

body 停在第一版,#2171 停在第二版,代码已在第三版。请按当前代码重写 body,特别是 "What is frozen vs. what is enforced" 那张表和 follow-up 一节。

3.2 全新的 wire struct 没有 layout 断言

src/common/task_interface/kernel_invocation_header.h 里只有 is_trivially_copyable / is_standard_layout 一条断言,没有 sizeof == 40,没有任何 offsetof——而同一个 PR 给已存在多年的 ChipCallable 补了 20 条。test_kernel_invocation_header.cpp 只做同进程 memcpy roundtrip,那个测试对布局变化永远不会失败

同时:36 字节有效字段 + 4 字节尾部 padding,既没有显式 reserved 字段,也没有零值校验——一个没写 {} 的实例会把未初始化字节 memcpy 到 AICPU。host_copy_tensor_count(注释写 "consumers reject nonzero meanwhile")本质上就是个没有 validator 的 reserved 字段。

建议:补 sizeof + 全字段 offsetof 断言;把尾部 padding 显式化为 uint32_t reserved_ 并在未来的 AICPU dispatch 入口校验为零。

3.3 mode guard 的返回码没有约定

同一类「模式不对」分了三种码:

位置 返回
ensure_acl_ready / force_reset_device / attach_current_thread PTO_RUNTIME_ERR_UNSUPPORTED
adopt_borrowed_device PTO_RUNTIME_ERR_INVALID_STATE
commit_region PTO_RUNTIME_ERR_INTERNAL

本 PR 的卖点正是冻结接口,返回码约定却没冻结。

另外 kernel_entry_validation.h 把纯粹的调用方参数错误(空指针、callable_id 越界、callable_size 不足、binary/size 不成对)一律报成 PTO_RUNTIME_ERR_INTERNAL——而 INTERNAL 在这个 band 的定义是「内部不变式被破坏,诊断看上一行日志」。这里缺的是一个 PTO_RUNTIME_ERR_INVALID_ARGUMENT


4. Should fix

4.1 scalar_count_ 这个字段的存在价值可疑

它自己的文档规定:消费者必须先推导有效值——字段非零就用字段,为零就去数 signature 的 SCALAR 项。既然 fallback 永远可用而且廉价(signature 就在同一个 header 里,最多 256 项),这个缓存字段从来不是 load-bearing 的:正确答案永远能只靠 signature 得到。代价却是一条 wire ABI 面、make_callable 一个必需的第三位置形参(仓外 C++ 调用方直接编译不过)、6 个 UT,以及一条要求每个消费者各自实现的推导规则。

建议二选一:用 -1 表示"未记录"让字段无歧义(0 就真的是 0 个 scalar);或者不加字段,提供一个 count_scalar_args(sig, n) helper。

附带一处误导交叉引用:callable.h 注释说 "the split count_callable_tensor_args already computes",但那个函数签名是 count_callable_tensor_args(const CoreCallable &),对 ChipCallable 的 signature 并不适用。

4.2 死代码进了全部 8 个 .so

  • kernel_execution_state.cpp 被加进 4 个 CMakeLists(覆盖 8 个 variant),但 KernelExecutionState / KernelContextOps / KernelLaunchOps 零生产调用点,只有 UT 引用。
  • adopt_borrowed_device 零调用点bind_current_thread 唯一调用者是 attach_current_thread,所以这次拆分目前收益为零。
  • sim 侧的 arena kernel guard 是永久死代码,不是"暂时未接线":sim 自己声明 kernel mode 永不支持("it has no real streams for a caller to lend"),这条分支按构造永不可能触发。

4.3 4 个 dlsym 无条件 eager 加载,让旧 .so 硬失败

ChipWorker::init 里 4 个符号无条件 load_symbol,缺符号即抛异常。后果:任何旧的预编译 host_runtime.so 会让 ChipWorker::init 直接抛,即使用户完全不碰 kernel mode——而本 PR 里所有 backend 的 supported() 都返回 0,且 ChipWorker 拿到的 4 个函数指针没有任何方法调用它们。为一个当前谁都用不到的能力,把整个 worker 初始化变成硬失败点,代价与收益不成比例。

现成的能力协商机制 simpler_kernel_mode_supported() 已经在这里了,合理分层应是:

符号 定位 理由
simpler_kernel_mode_supported 必需 能力探测入口,必须永远可调
init / prepare_callable / launch 应当可选 仅在 supported() 非 0 后才需要,lazy 解析即可

否则等于说「能力探测函数与被探测的能力本身绑在同一必需性等级」,探测就失去了意义。真正钉住导出的是 test_host_runtime_abi.py,那部分保留即可。

4.4 头文件注释里的状态叙述会立刻腐化

.claude/rules/comments.md 要求注释是"present-tense fact about the code as it currently stands",而这几处是「目前还没实现 / 还没人用」的状态叙述:

  • kernel_invocation_header.hNothing mints this value and no device-side comparand exists / no consumer implements it
  • runtime_c_api.hso today only program contexts exist and those guards never fire
  • device_runner_base.cppThe refusal is not self-contained ... so a fired guard drops the very base addresses it names

这些句子在下一个 PR 落地的那一刻就变成谎话,而且没人会想起来去改。"frozen vs enforced" 那张表属于 commit message / docs/,不属于头文件。

4.5 Simpler 前缀漏进 C++ 类内私有别名

src/common/worker/chip_worker.h

using SimplerKernelSupportedFn = decltype(&simpler_kernel_mode_supported);
using SimplerKernelInitFn = decltype(&simpler_kernel_mode_init);
using SimplerKernelPrepareCallableFn = decltype(&simpler_kernel_mode_prepare_callable);
using SimplerKernelLaunchFn = decltype(&simpler_kernel_mode_launch);

先界定该带前缀的部分:extern "C" 导出符号(simpler_kernel_mode_*)和跨 ABI 的 wire struct / enum(SimplerKernelInvocationHeaderSIMPLER_MODE_*带前缀是正确的,codestyle 规则 9 对此有明确豁免。

但这 4 个是 ChipWorker私有类型别名,作用域已被 ChipWorker:: 限死,永不出现在符号表、不可能冲突,Simpler 这 7 个字符信息量为零。该文件里确实已有 6 个 Simpler*Fn(所以新增的是在跟随既有约定),但同一个 private: 块里也活着另一套——8 个 Comm*FnCommBarrierFn 等),对应的同样是 extern "C" 导出符号(comm_barrier 等),却都没带前缀。即:文件里并存两套约定,新代码选了较差的那一套

按规则 9「replacing the disambiguation it provided with clear names or a namespace」,应为 KernelSupportedFn / KernelInitFn / KernelPrepareCallableFn / KernelLaunchFn。纯 C++ 类内改名,不碰任何 ABIdecltype(&simpler_kernel_mode_*) 右边保持不变;只改新增的 4 个,既有 6 个不动。

佐证:本 PR 自己新增的 KernelExecutionStateExecutionModeLatch 都没带前缀,做对了。


5. Consider

  1. ExecutionModeLatch 的形状:用 std::mutex 保护一个 bool + enum,而且通过 execution_mode_latch() 返回非 const 引用latch() 完全公开——write-once 不变式只靠纪律维持。更稳的形状:runner 上提供 latch_program() / latch_kernel(),读者拿 const 引用;存储换 std::atomic 免锁,顺带避免 mutex 成员让 runner 不可移动。
  2. hidden_stream() / event() 的锁是假安全:锁内取裸指针、锁外使用,并发 close() 销毁即 UAF。这正是 launch 实现要建在上面的接口,现在定型比以后改便宜。
  3. KernelLaunchOps 的函数指针带 noexceptKernelContextOps 不带,非对称且无说明;enum class KernelStreamKind : size_t 底层类型偏重,uint8_t 足够。
  4. finalize() 里出现只含注释的空 else if 分支;写成 else if (!execution_mode_latch().is_kernel()) 更直白。
  5. 路径 / 命名execution_mode.h 放在 src/common/task_interface/,但它是 src/common/worker/runtime_c_api.h 那套 C ABI 的枚举;kernel_entry_validation.h 的三个函数与 ExecutionModeLatch 都在全局命名空间且被编进 8 个组件。另外 ChipCallableMaxSig 常量叫 CHIP_MAX_TENSOR_ARGS(256) 却同时装 tensor 和 scalar——本 PR 引入 scalar_count 后这个旧名字更容易误导(scalar 另有上限 CHIP_MAX_SCALAR_ARGS = 128)。
  6. 性能:本 PR 无热路径影响,所有 guard 都落在 init / finalize / arena commit,不在 dispatch 路径上。唯一成本是 8 个 .so 各多一个未使用的 TU。
  7. 新错误码沿用遗留 PTO_ 前缀(PTO_RUNTIME_ERR_INVALID_STATE)与 codestyle 规则 10 有张力,但同一 enum band 内保持一致的优先级更高,跟随 PTO_RUNTIME_ERR_BASE 是合理的,不要求本 PR 处理。

6. 其他检查

  • pto-isa pin ℹ️ advisory:当前 pin 在 5a4f74cbf627d4aac2e0ce10d5e0d8b118343265,本 PR 无任何 pto-isa include 变化(无路径变更、无新增 include),pto_isa.pin 未改动——确认该 pin 仍然够用即可。若需 bump,记得用 SIMPLER_PTO_ISA_BUILD_COMMIT 重建 onboard a2a3 的 host_runtime.so
  • stub 覆盖:四个 platform variant 的 CMakeLists 均已加入 kernel_execution_state.cpp
  • docs/investigations/:无与 kernel mode / borrowed device / ACLGraph 相关的已否决提案,本 PR 不与任何既往结论冲突。

7. 结论

needs discussion。合入前建议至少处理:

优先级 理由
🔴 高 3.1 body 与代码不一致 本 PR 的产出就是这份说明,说明错了冻结就没有意义
🔴 高 3.2 wire header 缺 layout 断言 + 尾部 padding 全新 wire struct 的布局无任何保护,UT 检不出来
🔴 高 3.3 guard 返回码 / 参数错误码 冻结接口的 PR,返回码约定没冻结
🟡 中 4.1 scalar_count_ 保留 / 改 -1 / 换 helper 本 PR 唯一真正改了 wire ABI 和一个必需形参的部分,需作者答复
🟡 中 4.3 eager dlsym 影响面覆盖所有持旧 .so 的用户,当前收益为零
🟡 中 4.5 类内别名前缀 零 ABI 风险,只改新增的 4 个,现在改成本最低
🟢 低 4.2 / 4.4 / §5 死代码与注释腐化,可随后续 milestone 收敛

@sunkaixuan2018 sunkaixuan2018 changed the title Add: kernel-mode C ABI skeleton, state machine, and wire headers (K1) Add: kernel-mode C ABI skeleton, state machine, and wire headers Sep 11, 2026
@sunkaixuan2018

sunkaixuan2018 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

@poursoul 已按这份总评修正,并重写了 PR 描述:

  • 3.1:描述和 commit message 统一为当前的 ExecutionModeLatch,明确 write-once、无 rollback,以及已完成的 bind / attach / adopt 拆分。删除旧 claim/context-control 机制、失效的债务说明和过期测试数。
  • 3.2:补 sizeof == 40 和全部字段偏移断言;尾部四字节改为显式 reserved_,并增加固定字节布局检查。头文件规定两个保留字段必须为零;AICPU 入口的拒绝校验仍属于实际消费者,已在 PR 表格列出。
  • 3.3:kernel 入口的结构参数错误用 INVALID_ARGUMENT (-1004);模式或生命周期冲突用 INVALID_STATE (-1003);缺能力用 UNSUPPORTED (-1001)。arena 的 grow/release 是容量不变量破坏,仍用 INTERNAL,描述里明确区分。
  • 4.1:选择去掉缓存字段,用 count_scalar_args 从 signature 推导。恢复原 C++ factory 和 Python build 参数,保留派生的 scalar_count 属性以及历史布局断言。零值不再有两种含义;缓存 padding 不参与计数,并补损坏 signature 长度的拒绝测试。
  • 4.2:状态机实现从四个 host CMake 源列表移除,仅在 UT 中编译;移除 sim arena 永不可达的 kernel guard。保留 bind / attach / adopt 的身份边界,因为借用 device_id 与设备所有权必须分开;它不被描述为已接通的 kernel 能力。
  • 4.3 / 4.5:supported 保持必需,创建 context 后探测,只有返回非零才解析另外三个入口;失败销毁 context,成功初始化后才发布成员指针。四个新增私有别名改为 Kernel*Fn。新增 7 个真实 .so 用例覆盖能力/缺符号组合、回滚和重试。缺 supported 的旧库仍会失败,与本次建议的 required probe 约定一致。
  • 4.4 / §5.2–5.4:清掉指出的实现状态注释;移除会让人误以为句柄生命周期受锁保护的裸 getter;两张 ops 表统一 noexcept,stream/event enum 使用 uint8_t,finalize 改为直接的否定条件。close 的合同明确要求调用方先结束 graph 引用和已入队的资源使用。

§5.1 / §5.5 暂保留:latch 自身在锁内拒绝换模式,公开引用不会绕过 write-once;本次没有热路径成本证据要求替换 mutex,atomic 本身也不会恢复隐式可移动性。文件移动、全局 namespace 和既有 MaxSig 命名不在本次调整中,避免给其他并行 PR 增加机械迁移。K2 接线仍需封住借用设备与已有 ACL 状态的混用,并完成真实 context 的 guard 拒绝测试;当前没有生产 KERNEL latch。

验证:旧的已安装运行库先复现了缺生命周期符号的失败;重建后 CTest 144/144,通过全部 Python UT(2265 passed / 15 skipped),包含上述 7 个真实动态库用例。完整 pre-commit 通过(对齐 CI 的 clang-tidy 18,并使用串行执行避免缓存修复争用)。最终提交仅相对该验证树纠正了状态图注释,提交 hooks 也通过;GitHub CI 尚未全绿,详情见下。

CI 补充:最近检查时 17 项检查通过、1 项失败、1 项仍在运行(另 1 项部署跳过)。失败的是 A5 onboard / PMU TestPmu::test_run:首个错误为 pmu_collector.cpp:491 无法合并 pmu.csv.shard1.tmp,该分支删除最终 CSV,随后测试因缺少 pmu.csv 失败。合并失败也使 collected 计数没有更新,因此后续 silent_loss=5 不能单独证明设备丢记录。

该 PMU 文件及相关 quiesce / buffer pool / 测试代码与本次 base 相同;静态检查未发现本次修改改变该路径,但分片为何合并失败尚未确定,不能据此认定为 flaky。最有价值的补充证据是失败目录保留分片的大小、内容及读写状态。尝试重跑被 GitHub 拒绝(HTTP 403:需要仓库管理员权限),需维护者协助检查失败产物并重跑。A2/A3 onboard 已通过,DeepSeek A2/A3 onboard 检查仍在运行。

@nalinaly nalinaly left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

仅针对 kernel arena 拒绝路径补充一条行内意见:拒绝新请求不能释放 captured graph 可能仍引用的已提交资源。

"setup_static_arena: kernel mode forbids %s a committed region (cached %zu, requested %zu)",
requested_size == 0 ? "releasing" : "growing", cached_size, requested_size
);
return PTO_RUNTIME_ERR_INTERNAL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

建议修复:拒绝 kernel arena 变更时保留此前已提交的 region。

这里的 PTO_RUNTIME_ERR_INTERNAL 会被外层转换成 ok = false,继而进入 if (!ok),对 gm_heapgm_smruntime_pool 全部调用 release() 并清零 cached size。DeviceArena::release() 会实际调用底层 free,因此“禁止增长/释放,保护 captured graph 的旧基址”最终反而释放了这些基址。即使把请求视为内部不变量违例,也不能在尚未结束 graph 引用和在途设备使用时销毁旧资源。

建议 kernel 模式先对三个 region 做完整、无副作用的预检查:任一已提交 region 的增长或非零改零请求被拒时直接返回,不进入统一回滚,保持旧基址、cached size 和 committed 状态不变。如果后续允许补建 region,分配失败只回收本次新建资源;不要顺带改变 program 模式原有的全回滚语义。

建议补拒绝路径测试:分别让第一、第二、第三个已提交 region 触发增长/释放拒绝,确认返回错误但旧资源未释放、元数据未清空,底层 free 次数不增加。

当前 kernel init 仍是 stub,所以这是能力启用后的潜伏缺陷,不是已复现的设备故障。建议在本 PR 修复;若明确留给后续接线 PR,也应作为启用 kernel 能力前的硬条件。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已按建议修复。

改法:kernel 模式的容量判定整体提到提交序列之前,对 gm_heap / gm_sm / runtime_pool 三个 region 做一遍只读预扫,任一违例直接 return PTO_RUNTIME_ERR_INTERNAL,不进入 commit_region,因而也到不了 if (!ok) 的统一回滚。program 模式的全回滚语义一行未动。

判定抽成 kernel_entry_validation.h 里的 kernel_arena_change_is_forbidden(committed, cached, requested),生产代码与测试共用同一份定义,免得两边各写一遍再漂移。语义:未提交一律放行;已提交时,增长(requested > cached)与非零改零的释放都拒绝,等大与缩小放行——后两者既不换 base 也不改 committed size。

测试tests/ut/cpp/common/test_kernel_entry_validation.cpp 新增 4 个 KernelArenaGuard.*。其中 RefusalScanOverCommittedRegionsReleasesNothing 用计数 allocator 背书的三个真实 DeviceArena:三区提交后由第三个 region 触发增长拒绝——此时前两个已提交的 peer 正是回滚会误伤的对象——断言底层 free 次数不增、三个 base()is_committed() 全部不变。

有一条没做到,说清楚。 您建议"分别让第一、第二、第三个已提交 region 触发拒绝",我只覆盖了第三个。它是这条性质的最坏情形(前面有两个已提交 peer 待保护),但确实不是逐位置覆盖。

更要紧的是这些用例钉的是谓词加 arena 不变量,而不是真正执行一次 setup_static_arena:K1 里没有任何途径能把 context latch 成 KERNEL——simpler_kernel_mode_init 在 latch 之前就返回 UNSUPPORTED,而按您在另一条里的意见,这个 stub 也不该提前认领。所以"回滚没有被进入"目前是靠结构保证的:refusal 的 return 位于 commit_region 这个 lambda 被定义之前,中间没有任何可变异的语句。真正跑通这条路径要等 K2 接线,我把它记成了启用 kernel 能力前的硬条件,不是可选项。

head 31b03d92d,CI 19 pass / 1 skipping。

@nalinaly nalinaly left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

补充一条 ACL 所有权准入意见:以不支持同进程 PROGRAM/KERNEL 混用为前提,建议先封住 ensure_acl_ready 的未认领 context 旁路,并保留现有独立通信初始化入口。

}
// A kernel-mode context borrows the caller's device and ACL context;
// ACL initialization requires program-mode ownership.
if (execution_mode_latch().is_kernel()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

建议在 ACL 副作用之前认领 PROGRAM,封住未认领 context 的旁路。

按 workspace 等公共资源不支持同进程 PROGRAM/KERNEL 混用的前提,这里的第一层修复应完善模式准入,不需要引入共享 ACL 生命周期方案。目前 is_kernel() 只拒绝已经认领 KERNEL 的 context;未认领 context 仍可通过 ensure_acl_ready_ctx 调用 aclInit/aclrtSetDevice 并设置 acl_ready_=true,却没有认领 PROGRAM。后续 kernel init 接线后,若只做 KERNEL latch 与 borrowed-device adopt,就可能产生 KERNEL && acl_ready_;healthy finalize 会优先进入 ACL 分支,调用 aclrtResetDevice/aclFinalize,后面的 kernel no-reset 判断保护不到这一组合。当前 K1 kernel init 仍返回 UNSUPPORTED,因此这是启用前需要关闭的潜伏路径,不是已实测的当前 kernel 执行故障。

建议在基础参数校验之后、任何 ACL 调用之前,将此 guard 替换为:

const int mode_rc = execution_mode_latch().latch(SIMPLER_MODE_PROGRAM);
if (mode_rc != 0) return mode_rc;

这样未认领 → PROGRAM,已是 PROGRAM → 同模式认领成功,已是 KERNEL → INVALID_STATE 且没有 ACL 副作用。ACL 初始化失败也不撤销身份,与现有 write-once/no-rollback 合同一致。模式选择的竞争由已有 latch mutex 处理,无须仅为模式选择新增覆盖 ACL 调用的大锁;这不代表同模式 init/close 的生命周期并发已经安全。

不能简单改成“必须已经是 PROGRAM”:现有 tests/ut/cpp/hardware/test_comm_lifecycle.cpp:154 起直接使用 create_device_context → ensure_acl_ready_ctx,并不先调用 simpler_init。应保留该独立通信入口,并将合同明确为“首次初始化所有权的入口决定模式”,其中包含 ensure_acl_ready_ctx。A5 的同名函数需同步修改。后续真实 kernel init 应在资源副作用之前认领 KERNEL,但不要让当前 UNSUPPORTED stub 提前认领。

回归用例建议覆盖:helper-first 认领 PROGRAM 后拒绝同 context 的 KERNEL;KERNEL-first 调用 helper 被拒且 ACL 调用次数为零;PROGRAM 重复调用保持同一身份;注入 ACL 初始化失败后身份仍是 PROGRAM;原独立通信初始化用例继续通过。K1 可用 fake ACL/runner 验证入口副作用边界,不能只测 latch 类或把 UNSUPPORTED 当作互斥验证。finalize 还可防御性拒绝 KERNEL && acl_ready_,在任何破坏性清理前报错并保留状态,不能静默清标志或继续 reset/finalize。

范围说明:此修改只封住同一个 context 的旁路。不同 context 的同进程两模式混用若要主动拒绝,仍需统一的进程级准入,不能把各 runtime 动态库各自的 static 当作进程级保护;这是禁止混用的另一层,不是要求支持两模式共存。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已按您给的形状修复,a2a3 与 a5 同 commit 落地。

device_id 基础校验之后、任何 ACL 调用之前:

const int mode_rc = execution_mode_latch().latch(SIMPLER_MODE_PROGRAM);
if (mode_rc != 0) { LOG_ERROR(...); return mode_rc; }

未认领→PROGRAM,已是 PROGRAM→同模式返回 0,已是 KERNEL→INVALID_STATE 且身后没有任何 ACL 副作用。ACL 初始化失败不撤销身份,与 write-once / no-rollback 一致。

您指出的那条链现在不可达了,这点我做了正向核对而不是想当然:acl_ready_ = true 在两个 arch 上各只有一个写入点,就是 ensure_acl_ready 自己(a2a3:187 / a5:146)。它现在先 latch PROGRAM,所以 KERNEL && acl_ready_ 无法成立,healthy finalize 的 ACL 分支也就不会在借来的卡上跑 aclrtResetDevice / aclFinalize

独立通信入口保住了test_comm_lifecycle.cpp:154create_device_context → ensure_acl_ready_ctx(不经 simpler_init)落在 fresh context 上,latch PROGRAM 成功,行为不变。合同按您的说法写进了头注释与 PR 描述——"首次取得初始化所有权的入口决定模式",其中包含 ensure_acl_ready_ctx。UNSUPPORTED 的 kernel stub 没有提前认领。

两件没做的,明说:

  1. 您建议的入口级回归用例——KERNEL-first 调 helper 被拒且 ACL 调用次数为零、注入 ACL init 失败后身份仍是 PROGRAM——K1 里构造不出来,因为没有任何途径能 latch KERNEL(理由同上)。这正对应您那句"不能只测 latch 类或把 UNSUPPORTED 当作互斥验证",我认同,所以没有拿 latch 类测试来充数,也没有把 UNSUPPORTED 包装成互斥证据。这块随 K2 接线补,已记为启用前硬条件。

  2. finalize 里对 KERNEL && acl_ready_ 的防御性拒绝没有加。理由是上面那条可达性核对:该组合现在无法产生。加一个恒假分支,我更倾向于等 K2 真正有 KERNEL latch、teardown 策略也定下来时一起做,否则它现在既无法被测试覆盖,也容易在 K2 改动时被当成已有保护而误信。如果您认为即便不可达也应当先立着,我可以补——这条听您的。

head 31b03d92d,CI 19 pass / 1 skipping。

@poursoul poursoul 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(第二轮)

新 head 51dcf380,rebase 至当前 main c540572d。CI 19 pass / 1 skipping(deploy)。

先说结论:approve。上一轮四条高优先级全部解决,而且几处的解决方式比我建议的更彻底——scalar_count 不是补 sentinel 而是证明字段不必要后整个删除,泄漏的 accessor 不是加保护而是直接移除,sim 侧那条永久死的 guard 直接删掉。剩下两条都是一两行的收尾。


1. 上一轮意见的处理情况

上轮条目 状态 怎么改的
🔴 3.1 body 与代码不一致 body 完全重写,ExecutionModeClaimState / abortable / 过期的 "Deliberately left to the follow-up PR" 段全部消失,现在与代码一致
🔴 3.2 wire header 缺 layout 断言 sizeof == 40 + 8 条 offsetof 全补齐;尾部 padding 显式化为 uint32_t reserved_;头注释写明"生产者必须完整零初始化"与"消费者必须拒绝非零 host_copy_tensor_countreserved_"
🔴 3.3 返回码不统一 新增 PTO_RUNTIME_ERR_INVALID_ARGUMENT (-1004);结构性参数错误统一用它,四处 mode guard 统一 INVALID_STATEcommit_region 保留 INTERNAL 并在注释里说明这是容量不变式而非调用错误。四档语义在 body 和头文件里都写清楚了
🟡 4.1 scalar_count_ 字段冗余 字段整个删除,改为 count_scalar_args(sig, n) 派生 + scalar_count() 访问器。make_callable 签名回退(仓外 C++ 调用方不再被破坏),两个既有 UT 文件也不必改动,ChipCallable wire ABI 一个字节没动,20 条 offset/size 断言作为纯加固保留
🟡 4.2 死代码进 8 个 .so kernel_execution_state.cpp 从 4 个平台 CMakeLists 移除,只编进 UT;sim 侧那条按构造永不可能触发的 arena guard 删除
🟡 4.3 eager dlsym 只有 probe 必需,其余三个在 supported() != 0 后才解析;新增 tests/ut/py/test_chip_worker.py 四个用例(unsupported 无符号、supported 全符号、缺必需符号可重试、program init 失败后可重试)。test_host_runtime_abi.py 仍钉住全部四个导出 ✅
🟢 4.4 状态叙述式注释 today only program contexts exist and those guards never fireNothing mints this value and no device-side comparand existsThe refusal is not self-contained … 等全部删除或改写为 present-tense。全量 grep 无残留
🟢 4.5 Simpler*Fn 前缀 改为 KernelSupportedFn / KernelInitFn / KernelPrepareCallableFn / KernelLaunchFn
Consider 2 锁的假安全 直接删除 hidden_stream() / event() 两个会泄漏裸指针的 accessor,TOCTOU 面消失
Consider 3 noexcept 非对称 + size_t 底层类型 KernelContextOps 也加 noexcept;两个 enum 改 uint8_t
Consider 4 空 else if 分支 改为 } else if (!execution_mode_latch().is_kernel())
Consider 1 latch 形状(mutex / 非 const 引用暴露 latch() ⬜ 未动 属 Consider,可接受

另外确认几处回归风险为零:

  • ChipWorker::init 里新增的三个 load_symbol throw 点落在已有的 try 内,其 catch 开头就是 destroy_device_context_fn_(device_ctx_),回滚完整。
  • kernel_supported_fn(device_ctx_) 调用前 device_ctx_ 已创建并做过 null 检查。
  • KernelExecutionState 状态机实现除 INVALID_ARGUMENT 拆分外未变,语义无回归。

2. 本轮新发现

2.1 应该修:改码时漏改文档

src/common/platform/onboard/host/device_runner_base.h:246 仍写:

Refuses with PTO_RUNTIME_ERR_UNSUPPORTED on a context latched to kernel mode

device_runner_base.cppattach_current_thread 现在返回 PTO_RUNTIME_ERR_INVALID_STATE。修 3.3 时漏掉了这一处,属 doc-consistency.md §1。

2.2 应该修:supported() 的调用时机没写进契约

runtime_c_api.h:590 只有一行:

/** Return nonzero when this runtime/context can execute kernel-mode launches. */
int simpler_kernel_mode_supported(DeviceContextHandle ctx);

ChipWorker::init 已经把它固定在一个很具体的位置:create_device_context() 之后、simpler_init() 之前。也就是说这个入口必须在一个尚未初始化的 context 上给出答案。

今天 stub 忽略 ctx 直接返回 0,所以无所谓;但这已经成了每个真实后端必须遵守的事实契约——一个想按设备给出不同判断的实现,得知道自己是在未初始化的 context 上被问的。既然本 PR 的产出就是冻结接口,这条应该写进 doc block("may be called on a created but not yet initialized context; must not require init state"之类)。

2.3 遗留(可接受,请确认是有意的)

  • bind_current_thread / adopt_borrowed_device 仍是零生产调用点。这次拆分已被 body 明确列为本 PR 内容(不再声称留给 follow-up),所以不存在前后矛盾,只是这两个函数要等 K2 才有第一个调用者。
  • body 末尾 "CI is not yet green: A5 onboard PMU test failed…" 已过时(现在全绿),建议删掉这段。
  • KernelExecutionState 的状态机图从 Collecting ⇄ ReadyEnqueued 改成 (更准确),但 mark_ready_enqueued() 实现未变,仍接受 ReadyEnqueued → ReadyEnqueued 自环,图里没体现。极小。

2.4 Consider

  1. scalar_count() 从 O(1) 字段读变成 O(sig_count) 遍历(最多 256),且会 throw std::invalid_argumentcallable.h 被 AICPU TU 包含(aicpu_executor.cppaicpu_legacy_executor.cppscheduler_dispatch.cpp),目前安全——它是模板成员函数,没有 device 侧调用者就不会实例化,而 count_scalar_args 本身无 throw 无 STL。但如果将来 AICPU dispatch 路径要消费它,两点都要注意:既不能抛异常,也不该每次 launch 重算。
  2. count_scalar_args 是全局命名空间的 inline 自由函数,名字相当通用,而 callable.h 被约 20 个 TU 直接或间接包含。放进 namespace 或改个更具体的名字更稳。

3. 结论

approve。2.1 和 2.2 建议在合入前顺手修掉,都是一两行;2.3 / 2.4 可留待 K2。

@sunkaixuan2018

Copy link
Copy Markdown
Contributor Author

@poursoul 谢谢 approve。2.1 / 2.2 已修;顺着您 2.3 里那条过期 body 文字,又清掉了两处同类的。

  • 2.1 device_runner_base.hattach_current_thread 的文档 UNSUPPORTEDINVALID_STATE。顺手按 doc-consistency.md §1 全量扫了一遍:四处 mode guard 现在一致返回 INVALID_STATE,arena 按约定保留 INTERNAL,文档侧只有您指出的这一处漏网。
  • 2.2 simpler_kernel_mode_supported 补写调用时机契约——可在 create_device_context() 已返回、但尚未 init 的 context 上调用;实现必须从 runtime 构建与 context 指向的设备作答,不得依赖 init 状态;并点明它是每个 runtime 都必须导出的唯一 kernel 入口。
  • body 删掉 "CI is not yet green" 那段。另两处是 @nalinaly 本轮两条意见落地后变假的:表格里 "side-effect-free capacity refusal" 从"生产集成需要"移到本 PR 已做,以及 "The arena failure path still releases pooled regions…" 整段重写。

2.4.1 我给一个不同的定级。 您把 scalar_count() 的 O(n) + throw 标为 Consider / 可留待 K2,我在跟踪表里把它记成了 K2 硬前提而非 nice-to-have。理由正是您自己给的那半句:一旦 AICPU dispatch 要消费它,"不能抛异常"和"不该每次 launch 重算"两条都得先成立——而这属于发现得越晚越贵的那类。本 PR 不改,但不想让它以"可选优化"的身份留在列表里。

2.4.2 count_scalar_args 改名 / 进 namespace 本轮没做。 callable.h 被约 20 个 TU 直接或间接包含,approve 之后再动它要重开一轮全量 CI,收益不抵风险。已记入待办,随下一个有理由碰这个头文件的改动一起走。

2.3 三条确认都是有意的:bind_current_thread / adopt_borrowed_device 要等 K2 才有首个调用者;状态机图的自环差异确认极小,暂不改图。

本轮还合入了 @nalinaly 的两条行内意见——kernel arena 拒绝路径的副作用、ensure_acl_ready 的未认领旁路,改法与两处覆盖缺口写在对应 thread 里。简短说:两条的入口级回归用例在 K1 都构造不出来,因为没有任何途径能 latch KERNEL,我没有拿 latch 类测试或 UNSUPPORTED 返回来充数,而是把它们记成了启用 kernel 能力前的硬条件。

head 31b03d92d,CI 19 pass / 1 skipping(deploy)。

@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.

Item 4 ruling, one correction of mine, and confirmations

Read at 31b03d92d. CI 19 pass / 1 skipping. poursoul's approve and nalinaly's two
inline items are in; I'm not re-covering ground they closed.


First: my item 3 was wrong on the mechanism, and your trace is right

I said wiring the claim would arm the guards. It would not have, and your reason is the
one that matters: force_reset_device opens on device_id_ < 0 and finalize() opened on
device_id_ == -1, and device_id_ had exactly two writers — attach_current_thread
(which must rtSetDevice first) and ensure_acl_ready (which guard ① refuses). A kernel
context could reach neither, so those two guards were unreachable through a mechanism
entirely independent of the missing claim. A PROGRAM latch can never make is_kernel()
true, so nothing about wiring it would have changed that.

I reasoned from "the state is never written" to "writing it is what's missing" without
tracing what else stood between a kernel context and each guard. Re-pointing device_id_
at "which device" rather than "owns the device" is what actually arms ② and ③, and I would
not have gotten there from where I was looking.


Item 4 — my ruling: do not split the handle type. Fix the refusal depth instead.

Why not the split

I checked the thing I should have checked before proposing it:

typedef void *RuntimeHandle;
typedef void *DeviceContextHandle;

Two typedef void * names are the same type to a C compiler. A
SimplerKernelContextHandle alongside DeviceContextHandle would buy zero compile-time
separation — passing one where the other is expected diagnoses nothing. My last-round
framing ("it would be a type error instead of the next guard's customer") was simply false
on this ABI, and I asserted it without reading that line. Withdrawn.

Getting real separation means opaque struct pointers (struct SimplerKernelContext *),
which changes the shape of the whole C ABI and drags RuntimeHandle with it. That is a
large, purely-structural change to buy a property the runtime check already provides. Not
worth it, and definitely not worth it while K2 is being built against this surface.

What the real problem is

Not the handle's type — the depth at which the refusal happens.

You made simpler_init latch as its first act, explicitly above the first side effect
rather than merely above the attach. That is the right shape. But the other program-only
entries inherit their refusal from attach_current_thread, which is not their first act.
simpler_prepare_run (a2a3 c_api_shared.cpp:800-826) does all of this first:

state = new (runtime) OnboardNativeRunContext(runner, *config, ...);   // placement new
...
if (!runner->try_reserve_native_run(state, ...)) { ... }               // takes the admission slot
state->runner_reserved = true;
STRACE_CONTEXT(state->trace_inv, state->trace_hid, 1);                 // emits a trace
int rc = runner->attach_current_thread(runner->device_id());           // ← refusal lands here
if (rc != 0) return cleanup_failed_prepare(state, rc, true);

So a kernel-context prepare_run constructs a run context, takes and releases the
native-run admission slot
, and emits a trace before being refused. cleanup_failed_prepare
does roll it back, so this is not a leak and not a live bug — the entry is correct today.
My objection is that it is correct by coincidence of ordering plus a complete rollback
path
, not by construction, and that property is invisible: nothing marks
try_reserve_native_run as "must not run under a kernel latch", so the next person to add
a step above the attach has no signal. Compare simpler_register_callable, where the only
things above the attach are two read-only checks — that one is clean, but by accident of
how it happens to be written, not by rule.

That is also why "eleven entries are refused because attach_current_thread refuses" is
weaker than it sounds: it is true of the return code, not of the side effects.

What I'd do instead

Give the program-only entries the same shape you gave simpler_init — refusal as the first
act:

// one line at the top of each program-only entry
if (int rc = reject_on_kernel_latch(runner, "simpler_prepare_run"); rc != 0) return rc;

Three properties this buys that the current shape doesn't:

  1. Uniform depth — refusal precedes every side effect at every entry, not just where
    the attach happens to sit early.
  2. Uniform code — all of them report INVALID_STATE, rather than inheriting whatever
    the first internal callee returns.
  3. Testable as one invariant — a table-driven test can assert "every program-only entry
    on a kernel-latched context returns INVALID_STATE and leaves runner state unchanged",
    which also catches the next entry someone adds and forgets. That is the piece the
    current design cannot express at all.

This is a per-entry line plus one test, no ABI change, and it composes with everything you
have already landed. I'd treat (3) as the actual deliverable — the guard list has now been
re-derived three times in this review (mine, yours, poursoul's), which is a sign it wants a
test rather than another round of enumeration.

Timing: this doesn't block K2 — the entries do refuse today, and the codes are the only
thing K2 would observe. If you'd rather land it as its own change, that's fine by me; it
needs an owner more than it needs to be in this PR. What I'd avoid is leaving it implicit,
because the cost grows with every entry added above an attach.


On the two holes you found yourself

ensure_acl_ready latching PROGRAM is the right call of the two you offered, and I
agree with the reasoning now in the comment. The alternative — refusing a kernel init when
acl_ready_ is already true — closes one direction and states the invariant as a special
case; latching makes the latch the single source of truth for both orderings, which is the
property the rest of the design rests on. That it also makes the refusal side-effect-free
(latch before the first ACL call) is the part I'd have missed.

Finding the else if unreachability yourself — a kernel finalize running aclrtResetDevice

  • aclFinalize on the borrowed card whenever acl_ready_ was true — is the most valuable
    single item in this round, from any of the three of us. It was live in the code I reviewed
    and I read right past it.

Confirmations

  • scalar_count: deleting the field outright is better than either option I gave you.
    The sentinel ambiguity I raised disappears rather than getting documented, make_callable's
    signature stops breaking out-of-repo C++ callers, and the derived value is correct for
    legacy blobs for free. poursoul's read ("the cached field was never load-bearing") was
    the right one.
  • reserved_: worth noting this doesn't conflict with my A2. I asked you to drop
    reserved fields kept for version evolution; poursoul asked you to make the tail
    padding explicit so an incompletely-initialized instance can't memcpy uninitialized bytes
    to the AICPU. Different reasons, and his is right — I had written the implicit tail
    padding off as harmless, which was the shallower read.
  • I verified the layout asserts, the INVALID_ARGUMENT split, and that
    kernel_execution_state.cpp is out of all four platform CMakeLists.

Nothing here blocks merge from my side. Item 4 as I've framed it is a follow-up with an
owner, not a gate on this PR.

@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.

Full review at 31b03d92d: API design, clean code, architecture

A pass over the three dimensions rather than a continuation of the item-by-item thread.
Merge-base c540572d. Where something was already covered in the earlier rounds I reference
it rather than restate it.

Summary up front. The documentation and invariant-statement quality here is above this
repo's average — execution_mode_latch.h spends 40 lines of comment on 30 lines of code and
earns them, covering why write-once, what no-rollback costs, and why the re-init permission
is asymmetric. Of the three dimensions, architecture is strongest, clean code is second, and
API design is the weakest — which matters disproportionately, because the API is this
PR's only deliverable. One item (1.1) I'd want resolved before merge; everything else is a
card.


1. API design

1.1 simpler_kernel_mode_supported(ctx) contradicts its own contract — worth fixing before freeze

The contract (runtime_c_api.h:591-597) says:

Callable on a context that create_device_context() has returned but that no init has
touched … an implementation must answer from the runtime build and the device the
context names
, never from init state.

But create_device_context(void) takes no parameters (runtime_c_api.h:235) and
device_id_ starts at -1. An un-inited context names no device. The contract requires
implementations to use information that the specified call timing guarantees is absent.

Both stubs confirm it — neither even names the parameter:

int simpler_kernel_mode_supported(DeviceContextHandle) { return 0; }

So ctx is an argument no conforming implementation can legitimately use: the only
information available is which .so is loaded, and that is fixed at link time. This isn't a
typo — it only became visible once poursoul's 2.2 made you write the call timing down, which
is exactly what writing contracts down is for.

Three ways out; I'd take the first:

  • int simpler_kernel_mode_supported(void) — honest about the answer being a property of
    the runtime build. ChipWorker's call site barely changes.
  • Keep the parameter and specify that implementations must ignore it — then it is a
    placeholder for future use, which is the thing this repo's convention says not to add.
  • Move the probe after init so ctx carries a device — but that breaks the layering
    ChipWorker now has (probe first, then decide whether to resolve the rest).

A self-contradicting parameter on a frozen surface is much more expensive than no parameter.

1.2 finalize_device carries two contracts

Kernel mode is "four new entries plus the existing finalize_device", so that entry now
forks on the latch: program resets the device and aclFinalizes, kernel releases only
context-owned resources.

It is documented, but this is the other face of Item 4 — and subtler than the eleven
program-only entries. Those are "calling on the wrong context should be refused"; this one is
legal on both and does different things. I'm not arguing for a separate
simpler_kernel_mode_finalize (ABI surface +1, and the teardown logic genuinely overlaps) —
only that the doc should state the dual semantics as deliberate, rather than leaving readers
to infer it from the guards.

1.3 context_generation is caller-minted without a reason, and collides by name

simpler_kernel_mode_init takes uint64_t context_generation, contracted as a "nonzero
host-process-unique identity minted by the caller". The contract doesn't say why the
caller mints it. If it only needs process uniqueness, an atomic counter inside simpler
supplies it without pushing the obligation outward — and simpler can't validate what it
receives, so a caller that repeats a value goes undetected.

The name collision is the bigger risk: SimplerKernelInvocationHeader::generation is
documented as the residency slot's occupancy generation — "a property of the slot, not of
the callable in it". Two different generation concepts inside one API family. When K2 fills
in header.generation, these are the likeliest pair to cross. Suggest renaming the init
parameter context_id or context_token.


2. Clean code

2.1 ChipWorker's four function-pointer members are written and never read

kernel_supported_fn_, kernel_init_fn_, kernel_prepare_callable_fn_,
kernel_launch_fn_: grepping src/, every occurrence outside their declarations is either
an assignment or a null-out. Zero reads. They cost 4 using aliases + 4 members + 4
assignments + 4 sites × 4 lines of null-out, and buy nothing — they're private with no
accessor, so K2 can't reach them from outside either.

The thing that does work is the local kernel_supported_fn in init(), which gates the
lazy resolution. The members are pure future-proofing, and the K2 PR will add them naturally
when it has a caller.

This is the same reasoning you already accepted for moving kernel_execution_state.cpp out
of the four platform CMakeLists — just in a different place.

2.2 Function-pointer rollback is hand-repeated at four sites — and it already bit you once

init()'s three rollback paths plus finalize() each enumerate every function pointer by
hand. You reported the consequence yourself last round: "ChipWorker was leaving the four
new function pointers dangling into the .so that DlHandleGuard dlcloses, on all three
teardown paths."
That bug's cause is this structure — adding a symbol means remembering
four places.

Collecting the pointers into a struct HostRuntimeFns { … }; turns all four sites into
fns_ = {}; and removes the bug class. Pre-existing, not yours to fix here — but worth a
card, since this PR is at least the second change to trip over it.

2.3 Half of ExecutionModeLatch's public surface is test-only, and one piece is a trap

Production uses latch() and is_kernel(). is_latched() and latched_mode() have no
callers outside the UT
(test_kernel_execution_state.cpp:104/112/113/136).

And latched_mode() has an easy-to-misuse shape: before any latch it returns
SIMPLER_MODE_PROGRAM (the member default), with only a comment — "meaningful only once
is_latched()" — holding the line. A caller who forgets the guard reads "unlatched" as
"program", which is precisely the ambiguity write-once was introduced to remove.

Since production has no caller: delete them, or fold into
std::optional<SimplerExecutionMode> mode() const so "unlatched" can't be misread at the
type level.

2.4 create_thread doesn't use the seam you just extracted

std::thread DeviceRunnerBase::create_thread(std::function<void()> fn) {
    int dev_id = device_id_;
    return std::thread([dev_id, fn = std::move(fn)]() {
        rtSetDevice(dev_id);        // bare call, return value discarded
        fn();
    });
}

bind_current_thread is defined 12 lines below and does the same job with a device_id < 0
check, a consistency check, and logging. Here rtSetDevice is called bare and its result
dropped; if device_id_ is still -1, rtSetDevice(-1) fails silently.

You added a comment explaining why this particular rtSetDevice is legitimate for a borrowed
context, so you clearly considered the site — but the new abstraction sits next door unused.
The lambda genuinely can't propagate an error outward, but it could still call
bind_current_thread and log the failure. Pre-existing; Consider.


3. Architecture

3.1 The allowlist/denylist mismatch is still there, and this round sharpened it

Same point as my second round, but the contrast is now starker:

Technique Status
KernelContextOps / KernelLaunchOps Allowlist — forbidden ops absent from the type Zero production consumers; kernel_execution_state.cpp is now out of all four platform CMakeLists, UT-only
8 is_kernel() guards Denylist — refuse point by point Compiled into all 8 .sos; the defence actually in force

Moving the state machine out of the shipped artifacts is right on its own terms — dead code
shouldn't ship. But it also means the half that provides structural guarantees is no longer
compiled into the product at all
, while the half resting on enumeration is the entire real
defence.

The C ABI doc now encodes this as a contract: launch's doc says routing every runtime call
through KernelLaunchOps is "the implementation's obligation". Stating an invariant as an
implementer's duty rather than a structural constraint is the one place this design falls
back on discipline — everywhere else you structuralized it (write-once latch, shared
validation, the attach split). Worth being the thing K2's wiring is watched hardest on.

3.2 Refusal depth (Item 4 — detailed in my previous comment)

For the record: simpler_init latches as its first act, while simpler_prepare_run is
refused by attach_current_thread only after placement-new, try_reserve_native_run taking
the admission slot, and STRACE_CONTEXT. Fully rolled back, so not a live bug — correct by
ordering-plus-rollback rather than by construction. Suggested fix remains a uniform
first-act refusal per entry plus one table-driven test.

3.3 Dependency direction: an internal platform class pulls in the C ABI header for one constant

execution_mode_latch.h (platform/include/host/) includes runtime_c_api.h (worker/)
solely for PTO_RUNTIME_ERR_INVALID_STATE, which is why latch() returns int rather than
bool. An internal state class thereby absorbs the C ABI's error vocabulary into its own
interface.

This follows existing convention (KernelExecutionState does the same), so per
discipline.md §2 I am not asking you to change it here. Noting it because as this class
of internal type multiplies, the platform → worker back-edge calcifies; the eventual fix is
internal types returning their own result type, translated at the c_api_shared.cpp boundary.

3.4 Three judgements worth calling out as right

  • Re-pointing device_id_ from "owns the device" to "which device this is on" is what
    makes guards ② and ③ reachable at all, and you swept every read across both arches and both
    backends before changing it. That was the substantive move this round.
  • ensure_acl_ready latching PROGRAM rather than special-casing makes the latch the
    single truth for both orderings, instead of encoding the invariant as "refuse a kernel init
    when acl_ready_ is set".
  • The bind / attach / adopt split separates thread binding, device-global watchdog
    configuration, and identity recording, so the kernel path can take only what it's entitled
    to. This is the seam K2 needs.

Priority

Level Item Why
Before merge 1.1 supported(ctx) contract contradiction Frozen surface; free to fix now, costs callers after K2 wires it
Worth doing here 2.1 delete the four write-only members Same reasoning you accepted for the CMakeLists removal
Worth doing here 2.3 drop or optional-ize latched_mode() Trap shape, no production caller
Card 1.3 rename context_generation Likeliest pair to cross when K2 fills the header
Card 3.2 uniform entry-level refusal + table test Ownership already under discussion
Note only 1.2 / 2.2 / 2.4 / 3.1 / 3.3 Partly pre-existing, partly K2 watch-points

1.1 is the only one I'd hold merge for.

@ChaoWao

ChaoWao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Review:四个探针的实测结果,以及一个必须在 K1 冻结前定掉的口子

基线:head 31b03d92 / main b28c4c4d。实测环境 a2a3 onboard、CANN 9.0.0、torch_npu 2.7.1、task-submit --device auto
探针代码与完整结果:docs-kernel-mode/probes/RESULTS.md 含每条的复跑命令与观测边界)。

先交底一句:我核过 upstream/mainsimpler_kernel_mode / ExecutionModeLatch / SIMPLER_MODE_KERNEL 全是 0 命中,kernel 模式一行代码都还没合。所以下面每一条都是趁提案还能改提的,不是事后追认。


1. 🔴 FREEZE 移除之后,E.4 的 DFX 分界没有载体了

v3 采纳了我上一轮的 A1,把 context-control 整个拿掉。容量那一半有接盘的——改成在 arena 分配器内按 mode 无条件强制,#2193 顺着这个口径修拒绝路径。DFX 那一半没有。

实测:freeze#2064#2193 的 diff 里都是 0 命中seal 的几个命中是 kDefaultBa**seAl**ign 的假阳性)。

E.4 的实质是一条时间线

freeze 之前(=kernel eager)五个 DFX flag 照读照用、workspace 可分配;FREEZE 事务内拆 collector+释放 workspace+关 device_phase_capture+取容量快照;freeze 之后一律不读。

没有 freeze,这条时间线塌成一个点,kernel 模式的 DFX 只能是恒开或恒关,没有中间态。这不是措辞问题,需要一个裁定。连带两处现在是悬空的:计划里 D1 的验收写的是"freeze 后只读导出",⑤K3 的验收用例叫"DFX×freeze"。

为什么这条非要在 K1 里解决:K1 是冻接口的 PR。如果裁定结果是"kernel 模式需要一个 DFX 状态迁移点",那它就是一个 ABI 入口;接口冻了再加,代价差一个量级。如果裁定是"恒关"或"恒开",K1 不用加东西,但头文件里要写死这条契约,否则 K2/K3 会各按各的理解实现。

我没有立场替 DFX 定恒开还是恒关——只是这个选择现在没人做,而 K1 一合就贵了。


2. 🟠 launchcaller_stream 需要一条写进头文件的契约:每次现取,禁止缓存

四入口里 simpler_kernel_mode_launch(ctx, callable_id, args, caller_stream) 把流做成逐调用入参,这个形状是对的。但**"传进来的必须是本次调用当下的 current stream"这条,头文件没写,而违反它有两个独立的、都不报错的**失败模式:

证据一(capture)torch_npu.npu.graph 进入时会切到它自己的捕获流,outercapture 不是同一条。拿捕获外那条流做 record/wait 的结果是——入队不报错、capture 不报错、replay 静默地什么都不做,只有数值不对。(probe_b2_two_side_streams_capture.py arm2,已复现。)

证据二(任务队列)TASK_QUEUE_ENABLE=1 时 torch_npu 把 op 投给 acl_thread 后台发射(实测该配置下多出 acl_thread/release_thread 两个线程,每 op 主机发射成本 13.4→8.4 µs)。此时从调用线程直发的裸 ACL 调用不保证排在先前 torch op 之后:

臂(队列深 4000,每臂 40 次 × 4 轮) 违例
每次现取 current_stream() 0 / ~160
⑨binder 的 fork-join 形状,现取 0 / ~160
缓存 stream 指针 2 次
缓存 stream + data_ptr 2 次(读到 3997、3907,即 4000 次自增里有几次还没提交)

probe_f2_task_queue_ordering.py。注意 0/160 不是证明,机理也无文档依据——裸调用耗时 0.03–0.08 ms、取 stream ~0.1 ms,都远小于排空队列要的 ~30 ms,所以并不是"取 stream 会 flush",更像是那点开销恰好够后台线程追上。不能拿它当契约。

建议:在 runtime_c_api.h 的 launch 注释里写明「caller_stream 必须是本次调用当下的 current stream;实现方不得缓存,调用方也不得跨调用复用」。这是 K1 该做的事——它是唯一能让下游四个 PR 看到同一条规则的地方。

顺带一条给 ⑪K8 的(不在本 PR 范围,但结论是限制性的):adapter 不应在 op body 里直接调 launch,应走 torch 自己的入队路径。上表是 K8"定序假设"的直接反证。


3. 🟡 "launch 期零分配"在运行期毫无执法

Probe C 用 raw aclmdlRICapture* 直驱、每个操作一个独立 capture、用 CaptureGetInfo 区分"当场拒绝/被捕获/静默污染"。结果里与 K1 契约相关的几条:

操作 op rc CaptureEnd 判定
aclrtMalloc / aclrtFree 0 0 不拒绝,图照常生成(GLOBAL 与 RELAXED 都一样)
aclrtSynchronizeStream(捕获流)/SynchronizeDeviceStreamQuery(捕获流) 107027 0 拒绝
aclrtMemcpy / aclrtMemset(同步) 107030 0 拒绝
aclrtSynchronizeEvent / QueryEventStatus 107028 0 拒绝
aclrtSynchronizeStream(别的流) 0 0 放行(捕获期返回 0,但 replay 时不会发生)
wait 一个在捕获开始前 record 的 event 107024 0 拒绝
侧流工作、完全不挂 event 0 0 不进图、不报错,eager 跑掉一次
fork 到侧流但不 join 回来 0 107025 End 抓住,不给图

三条结论:

  • 零分配不变式靠 CANN 是抓不到的,只能靠 ⑫ST 的禁止-API shim。K1 的头注释若要声明这条契约,应当同时写明它无运行期执法
  • 107024 正是"等一个捕获外 record 的 event"。§0 第 6 行三分支里两个存活分支都明写"不 enqueue 旧 tail wait"——这条设计现在有硬证据了。而 kernel 模式的 event 是 context 常驻的,跨捕获边界等待是结构性会撞上的形状。
  • 危险的只有"漏挂 event"一种;"漏 join"反而安全(CaptureEnd 返 107025)。⑨binder 的失败闭包要防的是前者。

4. 附:两个被计划点名为排期闸的探针,已经跑完且是绿的

计划里 ⓿probe 至今无 owner,并被标为"全表唯一不缺技术前置、只缺人"、最迟 W-B 末必须出结论。其中两个我已经做掉:

Probe B′(计划语:"最要紧""不通则整段返工,930 前窗口极窄"):E.1 的双流形状——AICPU 专用流 + AICore hidden 流,两条都只经 event 与调用方的流相连——能同时进同一次捕获,3/3 replay,零 ACL 错误。Probe C 里用 raw capture 又独立验了一遍("两条侧流都 join 回来"那行)。目标二的单点风险以正面结果关闭。

Probe D(H7 分桶切换的前提):多张图共享同一批 context 常驻 event/stream——交替 replay A B A B A A B 7/7、背靠背不同步 replay AB/BA/AA/BB 4/4、replay 之后在同一批 event 上跑 eager,全过。H7 的前提成立。

两条都限 a2a3;按非目标 #12,不得由 a2a3 推断 a5。a5 全部未跑。

剩下的 Probe E(handshake 最小失效区)和 Probe A2rtFuncHandle 能否当 aclrtFuncHandle)我这边做不了——它们问的是 TMR 运行时自身的行为,得先构建 simpler,不是 ACL 层面能回答的。需要的话我可以在本机构建后补上。


小结:第 1 条是我唯一想在合入前看到结论的(因为它可能是个 ABI 入口);第 2 条建议落成头文件里的一句话;第 3、4 条是给下游 PR 和 probe 卡的材料,不挡本 PR。

@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.

Scope: KernelExecutionState belongs in ②K2, and there is now hard evidence

A scope test worth stating explicitly, because it settles several open threads at once:

Something belongs in K1 iff the four downstream PRs would each invent their own version
and disagree without it.

By that test most of this PR is core — the four entries, the shared validation helper, the 8
stubs, the 40-byte envelope, ExecutionModeLatch + the mode enum + the new error codes, the
dlsym surface, the ACL-lifecycle invariants, and the bind/attach/adopt split are all things a
downstream author has to agree with everyone else about.

KernelExecutionState and the two restricted vocabularies are not, and I can now show it
rather than argue it.

1. It is in no library

kernel_execution_state.cpp lives under src/, but grepping every CMakeLists and .cmake
in the tree, its only reference is:

tests/ut/cpp/CMakeLists.txt:524
    ${CMAKE_SOURCE_DIR}/../../../src/common/platform/shared/host/kernel_execution_state.cpp

— an add_executable, not a library target. The file is in no .so. Its 24 UTs exercise
code that does not ship.

2. It is K2's subject, and K2 has already rewritten it

#2176 is "Add: persistent kernel-context execution resources (K2)", owner @YunjiQin, and its
base is main — so it carries its own copy rather than stacking on this branch. I diffed the
two:

src/common/platform/include/host/kernel_execution_state.h | 40 ++++++++++-----
1 file changed, 32 insertions(+), 8 deletions(-)

This is not drift, it is a redesign of the exact thing K1 freezes:

K1 (this PR):  KernelEventKind { PrepareTail, Start, AicoreDone, SerialTail }          // 4
K2 (#2176):    KernelEventKind { Start, AicoreStart, AicoreDone, AicpuDone, SerialTail } // 5

PrepareTail is gone; AicoreStart and AicpuDone are new; KernelStreamKind's semantics
are corrected ("only Aicore is hidden in the capture sense; Aicpu is a dedicated private
stream"); create_event gains a creation-flag contract. And K2 carries a constraint K1 had
no way to know:

AicoreStart is recorded on the aicpu stream before the AICPU launch: the AICPU
orchestrator spins on AICore's handshake report, so an AicoreStart recorded after the
launch could only fire once the AICPU task completed, which is a deadlock.

So the resource set K1 pins is not an under-determined contract that downstream needs handed
to it — it is a guess, and the implementation has already found it wrong. K1 cannot
validate it, because K1 has no path that latches KERNEL. Freezing it costs #2176 a conflict
resolution on code that has zero consumers here.

This also resolves the allowlist/denylist mismatch I raised two rounds ago in the better
direction. I had concluded "watch it during K2 wiring"; the right answer is that the
allowlist half simply isn't K1's, and moving it out leaves K1 with a coherent story — it
carries contract text and the guards that make program mode safe today, nothing that only
becomes reachable later.

Suggested action: drop kernel_execution_state.{h,cpp}, KernelContextOps,
KernelLaunchOps and test_kernel_execution_state.cpp from this PR; #2176 already has the
version informed by the real launch sequence. ExecutionModeLatch stays — it is consumed
here, by simpler_init and eight guards.


A scope rule worth writing into the PR description

The trajectory of this PR — five entries + ctx_control POD → four entries with
ctx_control removed → nalinaly's two items landed same-day — shows it growing by absorbing
review items. nalinaly explicitly offered the choice on both:

当前 kernel init 仍是 stub,所以这是能力启用后的潜伏缺陷,不是已复现的设备故障。建议在本
PR 修复;若明确留给后续接线 PR,也应作为启用 kernel 能力前的硬条件。

The choice was offered and never made; the default won. And your own reply gives the reason
it should have gone the other way: those tests pin the predicate plus the arena invariant
rather than actually running setup_static_arena, because K1 has no way to latch a context
KERNEL
.

Proposed rule, worth stating in the description so it holds for the rest of the series:

K1 carries contract text, plus the guards required to make program mode safe today (the
ACL-lifecycle ones). Any latent defect that only becomes reachable once kernel mode is
enabled is registered as a hard precondition on ②K2 / ⑩a — not speculatively fixed in K1,
because K1 cannot construct a KERNEL context and therefore cannot verify the fix.

Applied to what has already landed: the ensure_acl_ready PROGRAM latch stays — it
governs what an unclaimed context does today, which is program-mode behaviour. The arena
refusal pre-scan would have been a precondition by this rule, but it is landed, small, and
carries a real-allocator counting test; I am not arguing to back it out, only that the rule
should stop the next one.

Self-check against the same rule

My own item 3.2 (uniform first-act refusal on the program-only entries, plus a table-driven
test) fails it. A test asserting "every program-only entry on a kernel-latched context
returns INVALID_STATE and leaves runner state unchanged" cannot be written in K1 — there
is no way to produce a kernel-latched context. I said it "doesn't block K2 and could land as
its own change", which was the right call for the wrong reason. Correctly filed: a hard
precondition on the PR that makes kernel init real, not a follow-up card of its own.

My items 1.1 (supported(ctx) contradicting its contract), 2.1 (the four write-only
ChipWorker members) and 2.3 (latched_mode()) all pass the rule — they are contract text
and deletions, verifiable today.


One coordination note: K1 vs ⑤K3 on the capacity invariant

#2193 is "kernel-mode capacity refusals no longer free what they protect (⑤K3)", base main.
Its own material is static_arena_bank.h + both arches' runtime_maker.cpp + the sim side —
a different layer from the device_runner_base.cpp this PR touches, so they do not collide.
But both describe the same invariant, and after K1 merges #2193 must rebase. Worth one line
somewhere: K1 owns the predicate and the onboard refusal ordering; K3 owns the arena bank
and the two runtimes.
Nobody has written that down yet.


Net: I'd hold merge for 1.1 (the supported(ctx) contradiction) and the
KernelExecutionState removal. Everything else in my previous comment stays a card.

@ChaoWao

ChaoWao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

更正:撤回上一条的 §1(FREEZE/DFX 载体)

上一条评论我把 §1 标成 🔴 并说"必须在 K1 冻结前定掉"。那条撤回,K1 在这件事上不需要做任何事。

我错在哪

我问的是"kernel 模式的 DFX 是恒开还是恒关"。这是个假二分——我漏了第三种,而且它才是对的:

DFX 做成常驻服务,device 侧使能由每次调用的 CallConfig 算成 launch 参数,capture 负责把它连同 tiling 一起固化。

  • eager:每次读 CallConfig → host 算出 device 侧控制 → 随 launch 参数下发。
  • capture:capture 记录的就是 launch 参数,DFX 的 device 侧控制在里面,于是被烧进图、每次 replay 照常产生 device 记录。
  • host 侧产物(dep_gen json、host phase records)在 replay 时根本不重算——天然惰性,不需要任何代码去关

代码上这已经成立:五个通道的使能在 a2a3/.../device_runner.cpp:292-302 变成 kernel_args.args.enable_profiling_flag;第六通道 device_phase_capture 也走 kernel_args.args.device_wall_data_basedevice_runner_base.cpp:1654),且它的开关是进程级 static const booldevice_phase_capture.cpp:20)。两者都是 launch 参数,不是 host 侧隐式状态。

所以 E.4 不是"缺载体",是前提写反了

E.4 说「FREEZE 事务内拆 collector + 释放 DFX workspace」——它假设 capture 之前必须把 DFX 拆掉

常驻模型的要求正相反:collector 和它的 device 侧缓冲必须活着、地址稳定,capture 才能烧地址、replay 才能复用。capture 前拆掉才是会坏的那个做法。

于是结论是减法

  • 不需要 FREEZE 事务
  • 不需要新的 C ABI 入口(没有要通知的转换点)
  • ①K1 不涉及

我原来那条如果被采纳,会往一个冻接口的 PR 里塞一个本来就不该存在的生命周期入口。请忽略它。

真正待做的在别处

今天 kernel 模式下 DFX 其实根本不转——不是会出错,是不启动也不落盘:start_shared_collectors_for_run() 挂在 launch_rundevice_runner.cpp:624),导出挂在 teardown_shared_collectors_after_run()(:715/:723),两头都是 kernel 模式不执行的路径。

修法是把括号从 run 作用域挪到 context 作用域(start() 放 kernel init、quiesce()+导出放 close()),落点在 ②K2/⑩a,不在 K1。已另开 issue 说明完整模型与代码依据。

上一条的其余部分不变

§2(caller_stream 每次现取、禁止缓存,两个独立证据)、§3(禁止-API 表与"零分配无运行期执法")、§4(Probe B′/D 已绿)都按原样成立。其中只有 §2 是对本 PR 的请求,且只是头注释里的一句话。


完整模型、代码依据与落点见 #2202

@ChaoWao

ChaoWao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

关于范围:一条建议移出,一条规则建议写明

poursoul 已经 approve,我这条不是要挡合入。但 PR 在几轮 review 里从"五入口 + context-control POD"长成了 28 文件 / +2157,其中一部分是靠吸收 review 意见长的,我想把范围这件事摆到台面上说一次,因为它影响的不只是这个 PR。

尺子

计划卡对 K1 的定义是:"一切的公共闸——K4/H2/K2 挂接段都按它的头文件草案开发",且**"只声明、不创建任何资源"**。据此只需要一把尺子:

属于 K1 的,当且仅当:下游四个 PR 若看不到它,就会各写各的、写完对不上。

按这把尺子,绝大部分内容是过的——四入口、K9 头、ExecutionModeLatch、错误码、dlsym 面、共享校验 helper、ACL 生命周期不变式、线程附着三分、容量谓词。下面只说两处。

1. 建议把 KernelExecutionState 移到 ②K2

三条理由,第一条是硬的:

① 它不在任何库里。 整个 PR 只修改了一个 CMakeLists —— tests/ut/cpp/CMakeLists.txt,而这也是 kernel_execution_state.cpp 唯一被引用的地方(add_executable(test_kernel_execution_state ... ${CMAKE_SOURCE_DIR}/../../../src/common/platform/shared/host/kernel_execution_state.cpp))。

我特意排除了 GLOB 的可能:四个 platform CMakeLists 里的 file(GLOB_RECURSE ...) 只作用于 CUSTOM_SOURCE_DIRS,不覆盖 shared/host;同目录另外 9 个 .cppargs_dump_collectorchip_swimlane_collectorhost_phase_records …)每个都被 4–5 个 CMakeLists 显式列名

所以这是一份位于 src/ 下、但不进入任何出货产物的源文件,它的 UT 测的是产品里不存在的代码。PR 描述里"Its implementation is compiled into its unit test, pending production integration"说的就是这个状态,我只是想指出它的代价。

② 它就是 ②K2 的题目。 #2176 的标题是 "persistent kernel-context execution resources (K2)",owner 是覃云集,而他正按 K1 的头文件草案开发。现在"隐藏流对 + event 集 + 相位机"这件事有两份定义在飞。

③ 过不了那把尺子。 本 PR 里没有任何消费者;下游看不到它也不会写歪,因为 K2 本来就要写它。

KernelContextOps / KernelLaunchOps 两张受限词表同理,随它一起走。

如果不想动,退一步的做法是在 PR 描述里写明"本 PR 的 KernelExecutionState 是形态草案,最终版本以 ②K2 为准",让覃云集知道以谁为准。但我更倾向直接移过去——冻接口的 PR 里不该有不出货的实现。

2. 建议写明一条规则:潜伏缺陷修在哪

nalinaly 的两条行内意见都自己声明了性质

"当前 kernel init 仍是 stub,所以这是能力启用后的潜伏缺陷,不是已复现的设备故障。建议在本 PR 修复;若明确留给后续接线 PR,也应作为启用 kernel 能力前的硬条件。"

也就是说"修在这里"还是"记成启用前硬条件",提意见的人已经把选择权交出来了,而没有人做这个选择——默认走了"修在这里",两条当天都落地了。

而反对的理由是作者自己给出的:

"这些用例钉的是谓词加 arena 不变量,而不是真正执行一次 setup_static_arena:K1 里没有任何途径能把 context latch 成 KERNEL。"

这条很关键。 K1 里造不出 KERNEL context,所以一切 kernel-only 的修复在这里都是不可达、且无法在真实路径上验证的。往一个冻接口的 PR 里放不可达且就地测不了的代码,是最差的一种交换——它既增加了审阅面,又没有换来信心。

建议在 PR 描述里定一条并沿用下去:

K1 承载契约文字,以及让 program 模式今天就安全所必需的守卫。凡"kernel 启用后才可达"的潜伏缺陷,登记为 ②K2/⑩a 启用前的硬前置,不在 K1 里投机修。

按这条,nalinaly 的两条里:ACL latch 那条该留(它管的是未认领 context 走 ensure_acl_ready 时的身份,是 program 模式今天的行为);arena 拒绝预扫本该登记为前置——但它已经落地、面小、且带了真 allocator 计数的测试,我不主张现在退回去,只主张把规则写下来挡住后面的。我自己上一条 review 里的 §1 就是个反例,已经撤回了。

3. 顺带:K1 与 ⑤K3 在容量不变式上的分工要写一句

计划卡把"容量不变式(原 FREEZE 的替代)"列在 K1,而 #2193 的标题是 "kernel-mode capacity refusals no longer free what they protect (⑤K3)"。文件集比对下来两者其实不冲突——K1 改 device_runner_base.cpp 的拒绝序,#2193 自己的料是 static_arena_bank.h + 两个 arch 的 runtime_maker.cpp + sim 侧——但没有一处文字说明这个分工。建议在本 PR 描述里加一句"K1 管谓词与 onboard 拒绝序,K3 管 arena bank 与两条 runtime",免得 rebase 时两边各改一半。


要动手的只有第 1 条,第 2、3 条是描述里的两段话。

@ChaoWao ChaoWao changed the title Add: kernel-mode C ABI skeleton, state machine, and wire headers Add: kernel-mode C ABI skeleton and wire headers (K1) Sep 13, 2026
ChaoWao
ChaoWao previously approved these changes Sep 13, 2026
Kernel mode borrows the caller's device and stream for a bounded
asynchronous operator. Define its C ABI, invocation envelope, and
lifecycle contracts without enabling kernel execution in any backend.

- Add the four simpler_kernel_mode entries, validating unsupported
  stubs, and distinct invalid-argument and invalid-state errors. Every
  host_runtime.so exports all four, so a consumer resolves them
  unconditionally like the rest of the uniform ABI; shipping stubs is
  already this repo's answer for an unsupported capability. The probe
  answers the capability question at call time and does not decide
  which symbols get resolved.
- Latch the execution identity once. Program init latches PROGRAM
  before side effects; errors and finalization cannot change the mode.
  Separate thread binding, program attachment, and borrowed-device
  adoption, and guard the onboard device lifecycle by that identity.
- Latch PROGRAM in ensure_acl_ready before its first ACL call. The
  entry is reachable without simpler_init, so taking ACL ownership is
  itself what makes a context a program context; leaving it unlatched
  would let a later kernel latch coexist with acl_ready_, whose
  finalize resets a device the context does not own.
- Decide a kernel-mode arena capacity refusal across every region
  before touching any of them. The commit sequence rolls all regions
  back on failure, so a refusal raised from inside it would release
  the committed bases a captured graph still names.
- Pin the 40-byte invocation header and every field offset. Make its
  trailing reserved bytes explicit and require both reserved fields
  to be zero. Payload validation belongs to the AICPU consumer.
- Derive scalar counts from the callable signature. Preserve the
  existing factory signatures and serialized layout, with fixed
  callable offsets and tests that ignore historical padding bytes.
- Scope simpler_kernel_mode_supported to what a caller can answer: the
  probe runs before any init, and create_device_context() takes no
  device, so an implementation answers from the runtime build alone.
- Cover wire bytes, scalar counts, error contracts, the write-once
  identity latch, arena refusal over real committed regions, and
  dlsym failure recovery. Keep uniform-export checks for all eight
  components.

Kernel init remains unsupported, so no production call latches KERNEL.

The persistent execution resources -- hidden stream pair, event set and
phase machine -- belong to the change that implements them rather than
to this gate. They have no consumer here and ship in no library, and
the branch already stacked on this one revises the event set from four
entries to five, carrying an ordering constraint this change has no way
to see. Production integration must also supply generation checks, safe
enqueue/close serialization, and graph-resource lifetime enforcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChaoWao
ChaoWao merged commit 1d1ddc8 into hw-native-sys:main Sep 13, 2026
20 checks passed
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.

4 participants