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