From e52b52ad4450f85a5a3b66f2e6ab4dc009206ba5 Mon Sep 17 00:00:00 2001 From: sunkaixuan2018 Date: Fri, 28 Aug 2026 17:14:41 +0800 Subject: [PATCH] Add: record scalar_count in the ChipCallable header A ChipCallable records its tensor signature but not how many scalar arguments its orchestration expects, so an argument list cannot be checked against the artifact. Store the count in the header and thread it through the factory and the binding, changing no byte of the existing layout: - scalar_count_ occupies four bytes of the padding between config_name_len_ and the 16-byte-aligned storage_, so every historical field offset, offsetof(storage_), and sizeof stay unchanged, and a legacy zero-initialized blob reads as 0 (legacy artifact and no-scalar orchestration are indistinguishable by design). - make_callable takes the count after sig_count and rejects values outside [0, CHIP_MAX_SCALAR_ARGS], naming the value and the limit. - ChipCallable.build gains a trailing scalar_count keyword defaulting to 0 and the binding exposes a read-only scalar_count property, so every existing caller keeps working unchanged. - static_asserts pin every field offset, sizeof, and POD-ness of both ChipCallable and CoreCallable. CoreCallable carries no scalar_count: its leaf header packs exactly to storage_ with no padding, so the field cannot be added there without moving the wire layout, and the chip-level artifact is where callers look the count up. - Tests cover factory round-trip, bounds rejection, legacy blobs reading 0, and that two builds differing only in scalar_count differ only in that field's four bytes. --- docs/user/reference/python-api.md | 5 +- python/bindings/task_interface.cpp | 21 +++- src/common/task_interface/callable.h | 62 ++++++++++- tests/ut/cpp/CMakeLists.txt | 1 + .../cpp/types/test_callable_scalar_count.cpp | 100 ++++++++++++++++++ .../test_chip_callable_upload_immutable.cpp | 2 +- .../cpp/types/test_chip_max_tensor_args.cpp | 4 +- tests/ut/py/test_task_interface.py | 42 ++++++++ 8 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 tests/ut/cpp/types/test_callable_scalar_count.cpp diff --git a/docs/user/reference/python-api.md b/docs/user/reference/python-api.md index b162eda7ad..a9d796a2e5 100644 --- a/docs/user/reference/python-api.md +++ b/docs/user/reference/python-api.md @@ -106,12 +106,15 @@ ChipCallable.build( func_name="my_orchestration", # the exported orchestration symbol binary=orch_bytes, children=[(func_id, core_callable), ...], + scalar_count=0, # scalar arguments the orchestration expects ) ``` `ArgDirection` is `SCALAR`, `IN`, `OUT`, or `INOUT`. The signature list is positional and defines the task-arg order. `func_id` must match the id the -orchestration submits. `ChipCallable` exposes `binary_size`. +orchestration submits. `ChipCallable` exposes `binary_size` and +`scalar_count`; a `scalar_count` of 0 means either an artifact built before +the count was recorded or an orchestration that takes no scalars. For L3+ graph construction, `TaskArgs.add_dep(*handles)` adds `WAIT | RETAIN` edges: each consumer waits for its producers and keeps their task-owned diff --git a/python/bindings/task_interface.cpp b/python/bindings/task_interface.cpp index 0da92d4996..ff4b38d4c6 100644 --- a/python/bindings/task_interface.cpp +++ b/python/bindings/task_interface.cpp @@ -2465,7 +2465,8 @@ NB_MODULE(_task_interface, m) { .def_static( "build", [](std::vector signature, std::string func_name, nb::bytes binary, - std::vector> children, std::string config_name) -> PyChipCallable { + std::vector> children, std::string config_name, + int32_t scalar_count) -> PyChipCallable { auto bin_ptr = reinterpret_cast(binary.c_str()); auto bin_size = static_cast(binary.size()); auto child_count = static_cast(children.size()); @@ -2478,14 +2479,15 @@ NB_MODULE(_task_interface, m) { } auto buf = make_callable( - signature.data(), static_cast(signature.size()), func_name.c_str(), bin_ptr, bin_size, - func_ids.data(), child_bufs.data(), child_count, config_name.c_str() + signature.data(), static_cast(signature.size()), scalar_count, func_name.c_str(), bin_ptr, + bin_size, func_ids.data(), child_bufs.data(), child_count, config_name.c_str() ); return PyChipCallable{std::move(buf)}; }, nb::arg("signature"), nb::arg("func_name"), nb::arg("binary"), nb::arg("children"), - nb::arg("config_name") = "", - "Build a ChipCallable from signature, func_name, binary, and list of (func_id, CoreCallable) children." + nb::arg("config_name") = "", nb::arg("scalar_count") = 0, + "Build a ChipCallable from signature, func_name, binary, and list of (func_id, CoreCallable) children. " + "scalar_count records how many scalar arguments the orchestration expects (0 when it takes none)." ) .def_static( @@ -2553,6 +2555,15 @@ NB_MODULE(_task_interface, m) { "The optional orchestration config function name." ) + .def_prop_ro( + "scalar_count", + [](const PyChipCallable &self) -> int32_t { + return self.get().scalar_count(); + }, + "Number of scalar arguments the orchestration expects. 0 also for " + "legacy artifacts built before the count was recorded." + ) + .def_prop_ro( "child_count", [](const PyChipCallable &self) -> int32_t { diff --git a/src/common/task_interface/callable.h b/src/common/task_interface/callable.h index a27803df37..cdd581441b 100644 --- a/src/common/task_interface/callable.h +++ b/src/common/task_interface/callable.h @@ -40,6 +40,7 @@ #include #include #include +#include #include #include "arg_direction.h" @@ -108,6 +109,10 @@ struct Callable { int32_t child_count_; char config_name_[CALLABLE_FUNC_NAME_MAX]; uint32_t config_name_len_; + // Occupies four bytes of the historical padding between config_name_len_ + // and the 16-byte-aligned storage_, so every other field keeps its wire + // offset and a legacy zero-initialized blob reads as scalar_count == 0. + int32_t scalar_count_; // Children live in storage_ at CALLABLE_ALIGN-aligned offsets, but the // all-uint32 header above can leave offsetof(storage_) at 4-mod-8, which // would place an 8-byte-aligned Child (CoreCallable has a uint64) on a @@ -129,6 +134,9 @@ struct Callable { uint32_t func_name_len() const { return func_name_len_; } const char *config_name() const { return config_name_; } uint32_t config_name_len() const { return config_name_len_; } + // 0 means either an artifact built before this field existed or an + // orchestration that takes no scalars; the two are indistinguishable. + int32_t scalar_count() const { return scalar_count_; } const Child &child(int32_t i) const { if (i < 0 || i >= child_count_) throw std::out_of_range("Callable: child index out of range"); @@ -149,9 +157,9 @@ struct Callable { template friend std::vector make_callable( - const ArgDirection *sig, int32_t sig_count, const char *func_name, const void *binary, uint32_t binary_size, - const int32_t *child_func_ids, const std::vector *child_buffers, int32_t child_count, - const char *config_name + const ArgDirection *sig, int32_t sig_count, int32_t scalar_count, const char *func_name, const void *binary, + uint32_t binary_size, const int32_t *child_func_ids, const std::vector *child_buffers, + int32_t child_count, const char *config_name ); }; @@ -177,6 +185,43 @@ static_assert( "ChipCallable.storage_ must be CALLABLE_CHILD_ALIGN-aligned for SIMT kernel binaries" ); +// Callable bytes are shipped through L3/L4 IPC and the on-disk kernel cache, +// so the header layout is wire ABI. scalar_count_ must consume tail padding +// rather than move any historical field. The constants below encode +// CHIP_MAX_TENSOR_ARGS = 256, CORE_MAX_TENSOR_ARGS = 32, +// CALLABLE_FUNC_NAME_MAX = 64, and MaxChildren = 1024; a deliberate capacity +// change updates them in the same commit. +static_assert( + std::is_trivially_copyable_v && std::is_standard_layout_v, + "ChipCallable wire ABI: must stay memcpy-able POD" +); +static_assert( + std::is_trivially_copyable_v && std::is_standard_layout_v, + "CoreCallable wire ABI: must stay memcpy-able POD" +); +static_assert(offsetof(ChipCallable, signature_) == 0, "ChipCallable wire ABI: signature offset changed"); +static_assert(offsetof(ChipCallable, sig_count_) == 1024, "ChipCallable wire ABI: sig_count offset changed"); +static_assert(offsetof(ChipCallable, binary_size_) == 1028, "ChipCallable wire ABI: binary_size offset changed"); +static_assert(offsetof(ChipCallable, func_name_) == 1032, "ChipCallable wire ABI: func_name offset changed"); +static_assert(offsetof(ChipCallable, func_name_len_) == 1096, "ChipCallable wire ABI: func_name_len offset changed"); +static_assert(offsetof(ChipCallable, child_func_ids_) == 1100, "ChipCallable wire ABI: child_func_ids offset changed"); +static_assert(offsetof(ChipCallable, child_offsets_) == 5196, "ChipCallable wire ABI: child_offsets offset changed"); +static_assert(offsetof(ChipCallable, child_count_) == 9292, "ChipCallable wire ABI: child_count offset changed"); +static_assert(offsetof(ChipCallable, config_name_) == 9296, "ChipCallable wire ABI: config_name offset changed"); +static_assert( + offsetof(ChipCallable, config_name_len_) == 9360, "ChipCallable wire ABI: config_name_len offset changed" +); +static_assert(offsetof(ChipCallable, scalar_count_) == 9364, "ChipCallable wire ABI: scalar_count offset changed"); +static_assert(offsetof(ChipCallable, storage_) == 9376, "ChipCallable wire ABI: storage offset changed"); +static_assert(sizeof(ChipCallable) == 9376, "ChipCallable wire ABI: header size changed"); +static_assert(offsetof(CoreCallable, signature_) == 0, "CoreCallable wire ABI: signature offset changed"); +static_assert(offsetof(CoreCallable, sig_count_) == 128, "CoreCallable wire ABI: sig_count offset changed"); +static_assert(offsetof(CoreCallable, binary_size_) == 132, "CoreCallable wire ABI: binary_size offset changed"); +static_assert(offsetof(CoreCallable, resolved_addr_) == 136, "CoreCallable wire ABI: resolved_addr offset changed"); +static_assert(offsetof(CoreCallable, storage_) == 144, "CoreCallable wire ABI: storage offset changed"); +static_assert(sizeof(CoreCallable) == 144, "CoreCallable wire ABI: header size changed"); +static_assert(CoreCallable::binary_data_offset() == 192, "CoreCallable wire ABI: binary data offset changed"); + // ============================================================================ // Factory: make_callable for static leaf // ============================================================================ @@ -216,8 +261,8 @@ make_callable(const ArgDirection *sig, int32_t sig_count, const void *binary, ui template std::vector make_callable( - const ArgDirection *sig, int32_t sig_count, const char *func_name, const void *binary, uint32_t binary_size, - const int32_t *child_func_ids, const std::vector *child_buffers, int32_t child_count, + const ArgDirection *sig, int32_t sig_count, int32_t scalar_count, const char *func_name, const void *binary, + uint32_t binary_size, const int32_t *child_func_ids, const std::vector *child_buffers, int32_t child_count, // No default arg here: the friend declaration above has none, so a default // on this definition is a "redeclaration may not have default arguments" // error once ChipCallable is instantiated (the static_assert below does @@ -231,6 +276,12 @@ std::vector make_callable( std::to_string(MaxSig) ); } + if (scalar_count < 0 || scalar_count > CHIP_MAX_SCALAR_ARGS) { + throw std::invalid_argument( + "make_callable: requested scalar count " + std::to_string(scalar_count) + + " is outside the supported range [0, " + std::to_string(CHIP_MAX_SCALAR_ARGS) + "]" + ); + } if (child_count > MaxChildren) throw std::invalid_argument("make_callable: child_count exceeds MaxChildren"); using T = Callable; @@ -250,6 +301,7 @@ std::vector make_callable( for (int32_t i = 0; i < sig_count; ++i) obj->signature_[i] = sig[i]; obj->sig_count_ = sig_count; + obj->scalar_count_ = scalar_count; obj->binary_size_ = binary_size; // Store func_name (null-terminated, truncated to CALLABLE_FUNC_NAME_MAX-1) diff --git a/tests/ut/cpp/CMakeLists.txt b/tests/ut/cpp/CMakeLists.txt index 920297f8cc..d753bd5cbd 100644 --- a/tests/ut/cpp/CMakeLists.txt +++ b/tests/ut/cpp/CMakeLists.txt @@ -482,6 +482,7 @@ set_tests_properties(test_sim_run_completion PROPERTIES LABELS "no_hardware") add_task_interface_test(test_buffer types/test_buffer.cpp) add_task_interface_test(test_child_memory types/test_child_memory.cpp) add_task_interface_test(test_chip_max_tensor_args types/test_chip_max_tensor_args.cpp) +add_task_interface_test(test_callable_scalar_count types/test_callable_scalar_count.cpp) add_task_interface_test(test_call_config types/test_call_config.cpp) # Contract test for upload_chip_callable_buffer caller-buffer immutability. diff --git a/tests/ut/cpp/types/test_callable_scalar_count.cpp b/tests/ut/cpp/types/test_callable_scalar_count.cpp new file mode 100644 index 0000000000..3a831afaab --- /dev/null +++ b/tests/ut/cpp/types/test_callable_scalar_count.cpp @@ -0,0 +1,100 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +// ChipCallable::scalar_count_ contract: the factory validates and records the +// count, the field occupies former header padding only, and a blob written by +// a producer that predates the field (whose padding make_callable +// zero-initialized) reads back as scalar_count == 0. + +#include +#include +#include +#include +#include + +#include + +#include "callable.h" + +namespace { + +std::vector build_chip_callable(int32_t scalar_count) { + ArgDirection sig[2] = {ArgDirection::IN, ArgDirection::OUT}; + + ArgDirection core_sig[1] = {ArgDirection::IN}; + const uint8_t kernel[] = {0x01, 0x02, 0x03, 0x04}; + auto core = make_callable(core_sig, 1, kernel, sizeof(kernel)); + + const uint8_t fake_orch_so[] = {0x7f, 'E', 'L', 'F'}; + int32_t child_ids[1] = {3}; + std::vector children[1] = {std::move(core)}; + + return make_callable( + sig, 2, scalar_count, "orch_fn", fake_orch_so, sizeof(fake_orch_so), child_ids, children, 1, "cfg_name" + ); +} + +} // namespace + +TEST(CallableScalarCount, RoundTripsThroughFactory) { + for (int32_t count : {0, 1, 7, CHIP_MAX_SCALAR_ARGS}) { + auto buf = build_chip_callable(count); + const auto *callable = reinterpret_cast(buf.data()); + EXPECT_EQ(callable->scalar_count(), count); + } +} + +TEST(CallableScalarCount, RejectsOutOfRangeWithValueAndLimit) { + for (int32_t count : {-1, CHIP_MAX_SCALAR_ARGS + 1}) { + try { + (void)build_chip_callable(count); + FAIL() << "expected scalar count validation to fail for " << count; + } catch (const std::invalid_argument &error) { + const std::string message = error.what(); + EXPECT_NE(message.find(std::to_string(count)), std::string::npos) << message; + EXPECT_NE(message.find(std::to_string(CHIP_MAX_SCALAR_ARGS)), std::string::npos) << message; + } + } +} + +// A legacy blob is byte-identical to a current one built with the same inputs +// except that the four bytes now holding scalar_count_ were zero-initialized +// padding. Zeroing them reproduces such a blob exactly. +TEST(CallableScalarCount, LegacyBlobReadsZero) { + auto buf = build_chip_callable(9); + std::memset(buf.data() + offsetof(ChipCallable, scalar_count_), 0, sizeof(int32_t)); + + const auto *callable = reinterpret_cast(buf.data()); + EXPECT_EQ(callable->scalar_count(), 0); + EXPECT_EQ(callable->sig_count(), 2); + EXPECT_EQ(std::string(callable->func_name(), callable->func_name_len()), "orch_fn"); + EXPECT_EQ(std::string(callable->config_name(), callable->config_name_len()), "cfg_name"); + ASSERT_EQ(callable->child_count(), 1); + EXPECT_EQ(callable->child_func_id(0), 3); + EXPECT_EQ(callable->child(0).binary_size(), 4u); +} + +// Two builds differing only in scalar_count must differ only in that field's +// four bytes — every other header byte, the orchestration binary, and the +// child payload keep their positions and values. +TEST(CallableScalarCount, OnlyTheFieldBytesVary) { + auto zero = build_chip_callable(0); + auto nine = build_chip_callable(9); + ASSERT_EQ(zero.size(), nine.size()); + + constexpr size_t field_begin = offsetof(ChipCallable, scalar_count_); + constexpr size_t field_end = field_begin + sizeof(int32_t); + for (size_t i = 0; i < zero.size(); ++i) { + if (i >= field_begin && i < field_end) continue; + ASSERT_EQ(zero[i], nine[i]) << "byte " << i << " must not depend on scalar_count"; + } + EXPECT_NE(std::memcmp(zero.data() + field_begin, nine.data() + field_begin, sizeof(int32_t)), 0); +} diff --git a/tests/ut/cpp/types/test_chip_callable_upload_immutable.cpp b/tests/ut/cpp/types/test_chip_callable_upload_immutable.cpp index 7bd6b63bde..f67e9a658d 100644 --- a/tests/ut/cpp/types/test_chip_callable_upload_immutable.cpp +++ b/tests/ut/cpp/types/test_chip_callable_upload_immutable.cpp @@ -60,7 +60,7 @@ std::vector build_test_chip_callable() { std::vector children[2] = {std::move(core0), std::move(core1)}; return make_callable( - nullptr, 0, "orch_fn", fake_orch_so, sizeof(fake_orch_so), child_ids, children, 2, "cfg_name" + nullptr, 0, 0, "orch_fn", fake_orch_so, sizeof(fake_orch_so), child_ids, children, 2, "cfg_name" ); } diff --git a/tests/ut/cpp/types/test_chip_max_tensor_args.cpp b/tests/ut/cpp/types/test_chip_max_tensor_args.cpp index ce31d46e5d..f38b36f1fb 100644 --- a/tests/ut/cpp/types/test_chip_max_tensor_args.cpp +++ b/tests/ut/cpp/types/test_chip_max_tensor_args.cpp @@ -50,7 +50,7 @@ TEST(ChipMaxTensorArgs, ChipStorageHoldsCapacity) { TEST(ChipMaxTensorArgs, ChipCallableAcceptsCapacity) { std::vector signature(256, ArgDirection::IN); auto buffer = make_callable( - signature.data(), static_cast(signature.size()), "composed", nullptr, 0, nullptr, nullptr, 0, "" + signature.data(), static_cast(signature.size()), 0, "composed", nullptr, 0, nullptr, nullptr, 0, "" ); const auto &callable = *reinterpret_cast(buffer.data()); @@ -63,7 +63,7 @@ TEST(ChipMaxTensorArgs, ChipCallableOverflowReportsRequestedAndSupportedCounts) try { (void)make_callable( - signature.data(), requested, "overflow", nullptr, 0, nullptr, nullptr, 0, "" + signature.data(), requested, 0, "overflow", nullptr, 0, nullptr, nullptr, 0, "" ); FAIL() << "expected signature capacity validation to fail"; } catch (const std::invalid_argument &error) { diff --git a/tests/ut/py/test_task_interface.py b/tests/ut/py/test_task_interface.py index c6d3ce822c..7fbddb7167 100644 --- a/tests/ut/py/test_task_interface.py +++ b/tests/ut/py/test_task_interface.py @@ -1249,6 +1249,48 @@ def test_repr(self): assert "sig_count=2" in r assert "child_count=1" in r + def test_scalar_count_defaults_to_zero(self): + chip = ChipCallable.build( + signature=[ArgDirection.IN], + func_name="test_func", + binary=b"\x00", + children=[], + ) + assert chip.scalar_count == 0 + + def test_scalar_count_keyword_round_trip(self): + chip = ChipCallable.build( + signature=[ArgDirection.IN, ArgDirection.OUT], + func_name="test_func", + binary=b"\x00", + children=[], + scalar_count=5, + ) + assert chip.scalar_count == 5 + + def test_scalar_count_survives_from_bytes(self): + chip = ChipCallable.build( + signature=[ArgDirection.IN], + func_name="test_func", + binary=b"\x00", + children=[], + scalar_count=7, + ) + raw = ctypes.string_at(int(chip.buffer_ptr()), int(chip.buffer_size())) + clone = ChipCallable.from_bytes(raw) + assert clone.scalar_count == 7 + + def test_scalar_count_out_of_range_reports_value_and_limit(self): + for bad in (-1, 129): + with pytest.raises(ValueError, match=rf"{bad}.*128"): + ChipCallable.build( + signature=[], + func_name="test_func", + binary=b"\x00", + children=[], + scalar_count=bad, + ) + # ============================================================================ # ChipTensor.child_memory