From 1e919aac3f66072dbcdc08e5ae6c92c1667ea767 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Wed, 5 Aug 2026 02:02:39 +0000 Subject: [PATCH 01/60] feat(hipdnn): add persistent graph UUIDs --- projects/hipdnn/backend/src/CMakeLists.txt | 3 + projects/hipdnn/backend/src/PlatformUtils.hpp | 3 + .../backend/src/PlatformUtils.linux.cpp | 31 ++++ .../backend/src/PlatformUtils.windows.cpp | 17 ++ .../src/descriptors/GraphDescriptor.cpp | 63 +++++-- .../src/descriptors/GraphDescriptor.hpp | 4 + .../hipdnn/backend/tests/TestGraphLogger.cpp | 18 +- .../tests/descriptors/TestGraphDescriptor.cpp | 158 ++++++++++++++++++ .../data_objects/graph_generated.h | 70 +++++++- .../hipdnn_flatbuffers_sdk/utilities/Uuid.hpp | 100 +++++++++++ .../utilities/json/Graph.hpp | 19 ++- .../hipdnn/flatbuffers_sdk/schemas/graph.fbs | 7 + .../tests/utilities/TestJson.cpp | 88 ++++++++++ 13 files changed, 555 insertions(+), 26 deletions(-) create mode 100644 projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp diff --git a/projects/hipdnn/backend/src/CMakeLists.txt b/projects/hipdnn/backend/src/CMakeLists.txt index a7b0c0c798eb..f9fbe2dabffe 100644 --- a/projects/hipdnn/backend/src/CMakeLists.txt +++ b/projects/hipdnn/backend/src/CMakeLists.txt @@ -86,6 +86,9 @@ if(NOT TARGET nlohmann_json::nlohmann_json) endif() hipdnn_add_dependency_includes(hipdnn_backend_private nlohmann_json::nlohmann_json) target_link_libraries(hipdnn_backend_private PRIVATE ${CMAKE_DL_LIBS}) +if(WIN32) + target_link_libraries(hipdnn_backend_private PRIVATE bcrypt) +endif() target_compile_definitions(hipdnn_backend_private PRIVATE HIPDNN_BACKEND_COMPILATION) # Enable spdlog support via shared cmake function diff --git a/projects/hipdnn/backend/src/PlatformUtils.hpp b/projects/hipdnn/backend/src/PlatformUtils.hpp index 77ea47e4044a..fac853066236 100644 --- a/projects/hipdnn/backend/src/PlatformUtils.hpp +++ b/projects/hipdnn/backend/src/PlatformUtils.hpp @@ -3,6 +3,8 @@ #pragma once +#include +#include #include #include #include @@ -24,5 +26,6 @@ PluginLibHandle openLibrary(const std::filesystem::path& libraryPath); void closeLibrary(PluginLibHandle handle); void* getSymbol(PluginLibHandle handle, const char* symbolName); std::string getSystemInfo(); +std::array generateUuidV4(); } diff --git a/projects/hipdnn/backend/src/PlatformUtils.linux.cpp b/projects/hipdnn/backend/src/PlatformUtils.linux.cpp index d2ed2df605dc..c1bcb7dfaa83 100644 --- a/projects/hipdnn/backend/src/PlatformUtils.linux.cpp +++ b/projects/hipdnn/backend/src/PlatformUtils.linux.cpp @@ -6,8 +6,10 @@ #if defined(__linux__) #include "HipdnnException.hpp" +#include #include #include +#include #include namespace hipdnn_backend::platform_utilities @@ -79,6 +81,35 @@ std::string getSystemInfo() buffer.machine); } +std::array generateUuidV4() +{ + std::array bytes{}; + size_t filled = 0; + while(filled < bytes.size()) + { + const auto result = getrandom(bytes.data() + filled, bytes.size() - filled, 0); + if(result < 0) + { + if(errno == EINTR) + { + continue; + } + throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, + "Failed to generate graph UUID using getrandom."); + } + if(result == 0) + { + throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, + "Failed to generate graph UUID: getrandom returned no data."); + } + filled += static_cast(result); + } + + bytes[6] = static_cast((bytes[6] & 0x0fU) | 0x40U); + bytes[8] = static_cast((bytes[8] & 0x3fU) | 0x80U); + return bytes; +} + } #endif // defined(__linux__) diff --git a/projects/hipdnn/backend/src/PlatformUtils.windows.cpp b/projects/hipdnn/backend/src/PlatformUtils.windows.cpp index 6ee0a8eb2ff5..bcd7986607c9 100644 --- a/projects/hipdnn/backend/src/PlatformUtils.windows.cpp +++ b/projects/hipdnn/backend/src/PlatformUtils.windows.cpp @@ -6,6 +6,7 @@ #ifdef _WIN32 #include "HipdnnException.hpp" +#include #include #include @@ -142,6 +143,22 @@ std::string getSystemInfo() architecture); } +std::array generateUuidV4() +{ + std::array bytes{}; + const auto result = BCryptGenRandom( + nullptr, bytes.data(), static_cast(bytes.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if(result < 0) + { + throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, + "Failed to generate graph UUID using BCryptGenRandom."); + } + + bytes[6] = static_cast((bytes[6] & 0x0fU) | 0x40U); + bytes[8] = static_cast((bytes[8] & 0x3fU) | 0x80U); + return bytes; +} + } #endif // _WIN32 diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp index 2dd9b3b2bb70..118b4335cb9d 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp @@ -9,8 +9,10 @@ #include "HipdnnBackendDescriptorType.h" #include "HipdnnException.hpp" #include "NodeFactory.hpp" +#include "PlatformUtils.hpp" #include +#include #include #include #include @@ -33,8 +35,25 @@ void GraphDescriptor::finalize() HIPDNN_STATUS_BAD_PARAM, "GraphDescriptor::finalize: no operations set"); - invalidateCache(); - buildSerializedGraph(); + const bool needsId = !_graphId.has_value(); + if(needsId) + { + _graphId = platform_utilities::generateUuidV4(); + } + + try + { + invalidateCache(); + buildSerializedGraph(); + } + catch(...) + { + if(needsId) + { + _graphId.reset(); + } + throw; + } HipdnnBackendDescriptorImpl::finalize(); @@ -58,6 +77,11 @@ std::unique_ptr graph->preferred_engine_id = _preferredEngineId; graph->is_override_shape_enabled = _isOverrideShapeEnabled; graph->name = _name; + if(_graphId.has_value()) + { + graph->id = std::make_unique( + hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*_graphId)); + } std::unordered_map> seenTensors; @@ -375,6 +399,7 @@ void GraphDescriptor::setAttribute(hipdnnBackendAttributeName_t attributeName, if(invalidate) { invalidateCache(); + _graphId.reset(); } } @@ -386,9 +411,6 @@ void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t gr THROW_IF_TRUE(graphByteSize == 0, HIPDNN_STATUS_BAD_PARAM, "GraphDescriptor::deserializeGraph: graphByteSize is 0"); - - invalidateCache(); - // Parse FlatBuffer and eagerly unpack into _operations std::unique_ptr graph; flatbuffer_utilities::convertSerializedGraphToGraph(serializedGraph, graphByteSize, graph); @@ -401,17 +423,16 @@ void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t gr HIPDNN_STATUS_NOT_SUPPORTED, "Serialized graph requires a newer engine plugin API version than this build supports."); - // Extract graph-level attributes - _computeDataType = graph->compute_data_type; - _intermediateDataType = graph->intermediate_data_type; - _ioDataType = graph->io_data_type; - _preferredEngineId = graph->preferred_engine_id; - _isOverrideShapeEnabled = graph->is_override_shape_enabled; - _isRuntimePassByValueEnabled = requiredVersion >= hipdnn_data_sdk::utilities::Version{ - hipdnn_plugin_sdk::K_PASS_BY_VALUE_MIN_API_VERSION}; - _name = graph->name; + std::optional> graphId; + if(graph->id) + { + const auto id = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id); + THROW_IF_FALSE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(id), + HIPDNN_STATUS_BAD_PARAM, + "Serialized graph id must be an RFC 4122 version 4 UUID."); + graphId = id; + } - // Populate _operations from the deserialized graph nodes auto tensorMap = NodeFactory::buildTensorMap(graph->tensors); std::vector> unpacked; unpacked.reserve(graph->nodes.size()); @@ -419,6 +440,18 @@ void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t gr { unpacked.push_back(NodeFactory::createOperationFromNode(*nodeT, tensorMap)); } + + // Commit only after every validation and conversion succeeds. + invalidateCache(); + _computeDataType = graph->compute_data_type; + _intermediateDataType = graph->intermediate_data_type; + _ioDataType = graph->io_data_type; + _preferredEngineId = graph->preferred_engine_id; + _isOverrideShapeEnabled = graph->is_override_shape_enabled; + _isRuntimePassByValueEnabled = requiredVersion >= hipdnn_data_sdk::utilities::Version{ + hipdnn_plugin_sdk::K_PASS_BY_VALUE_MIN_API_VERSION}; + _name = graph->name; + _graphId = graphId; _operations = std::move(unpacked); } diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp index 84ea5dbeea03..ba9fc75ee195 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp @@ -5,6 +5,7 @@ #include "BackendDescriptor.hpp" #include "IGraphOperation.hpp" +#include #include #include #include @@ -37,6 +38,9 @@ class GraphDescriptor : public HipdnnBackendDescriptorImpl // Cleared by invalidateCache() when operations are mutated. flatbuffers::DetachedBuffer _graphSerializedBuffer; + // Stable identity of immutable graph contents. Absent for legacy and unfinalized graphs. + std::optional> _graphId; + // Source of truth for the graph's operations. Populated via setOperations() (C-API flow) // or eagerly from deserializeGraph() (FlatBuffer flow). // Stored as IBackendDescriptor so getOperations() can pack them without cross-casting. diff --git a/projects/hipdnn/backend/tests/TestGraphLogger.cpp b/projects/hipdnn/backend/tests/TestGraphLogger.cpp index 9046cd111ba8..5562ddba3771 100644 --- a/projects/hipdnn/backend/tests/TestGraphLogger.cpp +++ b/projects/hipdnn/backend/tests/TestGraphLogger.cpp @@ -133,17 +133,29 @@ TEST_F(TestGraphLogger, GraphLoggedWhenEnabled) EXPECT_TRUE(j.contains("name")); } -TEST_F(TestGraphLogger, DuplicateGraphNotLoggedTwice) +TEST_F(TestGraphLogger, IndependentlyFinalizedGraphsAreLoggedSeparately) { hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); hipdnn_backend::logging::loggerShutdown(); - // Finalize the same graph twice + // Equivalent graph contents represent distinct finalized graph identities. auto descriptor1 = createAndFinalizeGraph(); auto descriptor2 = createAndFinalizeGraph(); auto jsonFiles = getJsonFilesInDir(_tempDir); - EXPECT_EQ(jsonFiles.size(), 1u); + EXPECT_EQ(jsonFiles.size(), 2u); +} + +TEST_F(TestGraphLogger, ReLoggingSameFinalizedGraphIsDeduplicated) +{ + hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); + hipdnn_backend::logging::loggerShutdown(); + + auto descriptor = createAndFinalizeGraph(); + const auto serialized = descriptor.getSerializedGraph(); + logging::GraphLogger::logGraph(static_cast(serialized.ptr), serialized.size); + + EXPECT_EQ(getJsonFilesInDir(_tempDir).size(), 1u); } TEST_F(TestGraphLogger, DifferentGraphsLoggedSeparately) diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index 7814871eb145..2292eb39d548 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,23 @@ class TestGraphDescriptor : public ::testing::Test return test_utilities::createValidGraph(); } + static std::unique_ptr + unpack(const GraphDescriptor& descriptor) + { + const auto serialized = descriptor.getSerializedGraph(); + return hipdnn_flatbuffers_sdk::data_objects::UnPackGraph( + static_cast(serialized.ptr)); + } + + static void setHandle(GraphDescriptor& descriptor) + { + auto handle = reinterpret_cast(0x12345678); + descriptor.setAttribute(HIPDNN_ATTR_OPERATIONGRAPH_HANDLE, + HIPDNN_TYPE_HANDLE, + 1, + static_cast(&handle)); + } + static void verifyGraph(const hipdnn_flatbuffers_sdk::data_objects::GraphT& graph) { EXPECT_EQ(graph.name, "test"); @@ -47,6 +65,11 @@ class TestGraphDescriptor : public ::testing::Test EXPECT_EQ(graph1.intermediate_data_type, graph2.intermediate_data_type); EXPECT_EQ(graph1.io_data_type, graph2.io_data_type); EXPECT_EQ(graph1.preferred_engine_id, graph2.preferred_engine_id); + EXPECT_EQ(graph1.id == nullptr, graph2.id == nullptr); + if(graph1.id && graph2.id) + { + EXPECT_EQ(*graph1.id, *graph2.id); + } ASSERT_EQ(graph1.tensors.size(), graph2.tensors.size()); for(size_t i = 0; i < graph1.tensors.size(); ++i) @@ -843,6 +866,141 @@ TEST_F(TestGraphDescriptor, BinaryRoundTripViaApi) verifyGraphsEquivalent(*graph1, *graph2); } +TEST_F(TestGraphDescriptor, FinalizingLegacyGraphGeneratesValidStableUuid) +{ + auto builder = createValidGraph(); + auto serializedGraph = builder.Release(); + GraphDescriptor descriptor; + descriptor.deserializeGraph(serializedGraph.data(), serializedGraph.size()); + descriptor.buildSerializedGraph(); + ASSERT_EQ(unpack(descriptor)->id, nullptr); + + setHandle(descriptor); + descriptor.finalize(); + const auto first = unpack(descriptor); + ASSERT_NE(first->id, nullptr); + const auto firstId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*first->id); + EXPECT_TRUE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(firstId)); + + const auto second = unpack(descriptor); + ASSERT_NE(second->id, nullptr); + EXPECT_EQ(firstId, hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*second->id)); +} + +TEST_F(TestGraphDescriptor, EquivalentGraphsReceiveDifferentUuids) +{ + auto firstBuilder = createValidGraph(); + auto firstSerialized = firstBuilder.Release(); + GraphDescriptor first; + first.deserializeGraph(firstSerialized.data(), firstSerialized.size()); + setHandle(first); + first.finalize(); + + auto secondBuilder = createValidGraph(); + auto secondSerialized = secondBuilder.Release(); + GraphDescriptor second; + second.deserializeGraph(secondSerialized.data(), secondSerialized.size()); + setHandle(second); + second.finalize(); + + const auto firstGraph = unpack(first); + const auto secondGraph = unpack(second); + ASSERT_NE(firstGraph->id, nullptr); + ASSERT_NE(secondGraph->id, nullptr); + EXPECT_NE(*firstGraph->id, *secondGraph->id); +} + +TEST_F(TestGraphDescriptor, ExistingUuidSurvivesDeserializeHandleAndFinalize) +{ + auto legacyBuilder = createValidGraph(); + auto legacySerialized = legacyBuilder.Release(); + GraphDescriptor original; + original.deserializeGraph(legacySerialized.data(), legacySerialized.size()); + setHandle(original); + original.finalize(); + const auto originalGraph = unpack(original); + ASSERT_NE(originalGraph->id, nullptr); + const auto expectedId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*originalGraph->id); + + const auto originalData = original.getSerializedGraph(); + GraphDescriptor revived; + revived.deserializeGraph(static_cast(originalData.ptr), originalData.size); + setHandle(revived); + revived.finalize(); + const auto revivedGraph = unpack(revived); + ASSERT_NE(revivedGraph->id, nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*revivedGraph->id), expectedId); +} + +TEST_F(TestGraphDescriptor, ContentMutationReplacesInheritedUuid) +{ + auto legacyBuilder = createValidGraph(); + auto legacySerialized = legacyBuilder.Release(); + GraphDescriptor original; + original.deserializeGraph(legacySerialized.data(), legacySerialized.size()); + setHandle(original); + original.finalize(); + const auto originalGraph = unpack(original); + ASSERT_NE(originalGraph->id, nullptr); + const auto originalId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*originalGraph->id); + + const auto originalData = original.getSerializedGraph(); + GraphDescriptor mutated; + mutated.deserializeGraph(static_cast(originalData.ptr), originalData.size); + const std::array name{'m', 'u', 't', 'a', 't', 'e', 'd', '\0'}; + mutated.setAttribute(HIPDNN_ATTR_OPERATIONGRAPH_NAME_EXT, + HIPDNN_TYPE_CHAR, + static_cast(name.size()), + name.data()); + setHandle(mutated); + mutated.finalize(); + const auto mutatedGraph = unpack(mutated); + ASSERT_NE(mutatedGraph->id, nullptr); + EXPECT_NE(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*mutatedGraph->id), originalId); +} + +TEST_F(TestGraphDescriptor, RejectsSerializedUuidWithInvalidVersionOrVariant) +{ + auto builder = createValidGraph(); + auto serialized = builder.Release(); + auto graph = hipdnn_flatbuffers_sdk::data_objects::UnPackGraph(serialized.data()); + std::array invalidBytes{}; + invalidBytes[6] = 0x30; + invalidBytes[8] = 0x40; + graph->id = std::make_unique( + flatbuffers::span(invalidBytes)); + + flatbuffers::FlatBufferBuilder invalidBuilder; + invalidBuilder.Finish( + hipdnn_flatbuffers_sdk::data_objects::Graph::Pack(invalidBuilder, graph.get())); + auto invalid = invalidBuilder.Release(); + + GraphDescriptor descriptor; + ASSERT_THROW_HIPDNN_STATUS(descriptor.deserializeGraph(invalid.data(), invalid.size()), + HIPDNN_STATUS_BAD_PARAM); +} + +TEST_F(TestGraphDescriptor, JsonRoundTripPreservesUuid) +{ + auto legacyBuilder = createValidGraph(); + auto legacySerialized = legacyBuilder.Release(); + GraphDescriptor original; + original.deserializeGraph(legacySerialized.data(), legacySerialized.size()); + setHandle(original); + original.finalize(); + const auto originalGraph = unpack(original); + ASSERT_NE(originalGraph->id, nullptr); + const auto expectedId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*originalGraph->id); + + const auto json = original.getSerializedJsonGraph(); + GraphDescriptor revived; + GraphDescriptor::createFromJsonGraph(revived, json.c_str(), json.size()); + revived.buildSerializedGraph(); + const auto revivedGraph = unpack(revived); + ASSERT_NE(revivedGraph->id, nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*revivedGraph->id), expectedId); +} + // ============================================================================ // HIPDNN_ATTR_OPERATIONGRAPH_IS_OVERRIDE_SHAPE_ENABLED_EXT (RFC 0008) // ============================================================================ diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/data_objects/graph_generated.h b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/data_objects/graph_generated.h index 047da260e364..a219532ae323 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/data_objects/graph_generated.h +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/data_objects/graph_generated.h @@ -45,6 +45,8 @@ struct NodeT; struct EngineApiVersion; +struct Uuid; + struct Graph; struct GraphBuilder; struct GraphT; @@ -53,6 +55,8 @@ bool operator==(const NodeT &lhs, const NodeT &rhs); bool operator!=(const NodeT &lhs, const NodeT &rhs); bool operator==(const EngineApiVersion &lhs, const EngineApiVersion &rhs); bool operator!=(const EngineApiVersion &lhs, const EngineApiVersion &rhs); +bool operator==(const Uuid &lhs, const Uuid &rhs); +bool operator!=(const Uuid &lhs, const Uuid &rhs); bool operator==(const GraphT &lhs, const GraphT &rhs); bool operator!=(const GraphT &lhs, const GraphT &rhs); @@ -653,6 +657,36 @@ inline bool operator!=(const EngineApiVersion &lhs, const EngineApiVersion &rhs) } +FLATBUFFERS_MANUALLY_ALIGNED_STRUCT(1) Uuid FLATBUFFERS_FINAL_CLASS { + private: + uint8_t bytes_[16]; + + public: + Uuid() + : bytes_() { + } + Uuid(::flatbuffers::span _bytes) { + ::flatbuffers::CastToArray(bytes_).CopyFromSpan(_bytes); + } + const ::flatbuffers::Array *bytes() const { + return &::flatbuffers::CastToArray(bytes_); + } + ::flatbuffers::Array *mutable_bytes() { + return &::flatbuffers::CastToArray(bytes_); + } +}; +FLATBUFFERS_STRUCT_END(Uuid, 16); + +inline bool operator==(const Uuid &lhs, const Uuid &rhs) { + return + (*lhs.bytes() == *rhs.bytes()); +} + +inline bool operator!=(const Uuid &lhs, const Uuid &rhs) { + return !(lhs == rhs); +} + + struct NodeT : public ::flatbuffers::NativeTable { typedef Node TableType; std::string name{}; @@ -915,6 +949,7 @@ struct GraphT : public ::flatbuffers::NativeTable { ::flatbuffers::Optional preferred_engine_id = ::flatbuffers::nullopt; bool is_override_shape_enabled = false; std::unique_ptr min_required_engine_api_version{}; + std::unique_ptr id{}; GraphT() = default; GraphT(const GraphT &o); GraphT(GraphT&&) FLATBUFFERS_NOEXCEPT = default; @@ -933,7 +968,8 @@ struct Graph FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { VT_NODES = 14, VT_PREFERRED_ENGINE_ID = 16, VT_IS_OVERRIDE_SHAPE_ENABLED = 18, - VT_MIN_REQUIRED_ENGINE_API_VERSION = 20 + VT_MIN_REQUIRED_ENGINE_API_VERSION = 20, + VT_ID = 22 }; const ::flatbuffers::String *name() const { return GetPointer(VT_NAME); @@ -989,6 +1025,12 @@ struct Graph FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion *mutable_min_required_engine_api_version() { return GetStruct(VT_MIN_REQUIRED_ENGINE_API_VERSION); } + const hipdnn_flatbuffers_sdk::data_objects::Uuid *id() const { + return GetStruct(VT_ID); + } + hipdnn_flatbuffers_sdk::data_objects::Uuid *mutable_id() { + return GetStruct(VT_ID); + } bool Verify(::flatbuffers::Verifier &verifier) const { return VerifyTableStart(verifier) && VerifyOffset(verifier, VT_NAME) && @@ -1005,6 +1047,7 @@ struct Graph FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { VerifyField(verifier, VT_PREFERRED_ENGINE_ID, 8) && VerifyField(verifier, VT_IS_OVERRIDE_SHAPE_ENABLED, 1) && VerifyField(verifier, VT_MIN_REQUIRED_ENGINE_API_VERSION, 4) && + VerifyField(verifier, VT_ID, 1) && verifier.EndTable(); } GraphT *UnPack(const ::flatbuffers::resolver_function_t *_resolver = nullptr) const; @@ -1043,6 +1086,9 @@ struct GraphBuilder { void add_min_required_engine_api_version(const hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion *min_required_engine_api_version) { fbb_.AddStruct(Graph::VT_MIN_REQUIRED_ENGINE_API_VERSION, min_required_engine_api_version); } + void add_id(const hipdnn_flatbuffers_sdk::data_objects::Uuid *id) { + fbb_.AddStruct(Graph::VT_ID, id); + } explicit GraphBuilder(::flatbuffers::FlatBufferBuilder &_fbb) : fbb_(_fbb) { start_ = fbb_.StartTable(); @@ -1064,9 +1110,11 @@ inline ::flatbuffers::Offset CreateGraph( ::flatbuffers::Offset<::flatbuffers::Vector<::flatbuffers::Offset>> nodes = 0, ::flatbuffers::Optional preferred_engine_id = ::flatbuffers::nullopt, bool is_override_shape_enabled = false, - const hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion *min_required_engine_api_version = nullptr) { + const hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion *min_required_engine_api_version = nullptr, + const hipdnn_flatbuffers_sdk::data_objects::Uuid *id = nullptr) { GraphBuilder builder_(_fbb); if(preferred_engine_id) { builder_.add_preferred_engine_id(*preferred_engine_id); } + builder_.add_id(id); builder_.add_min_required_engine_api_version(min_required_engine_api_version); builder_.add_nodes(nodes); builder_.add_tensors(tensors); @@ -1088,7 +1136,8 @@ inline ::flatbuffers::Offset CreateGraphDirect( const std::vector<::flatbuffers::Offset> *nodes = nullptr, ::flatbuffers::Optional preferred_engine_id = ::flatbuffers::nullopt, bool is_override_shape_enabled = false, - const hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion *min_required_engine_api_version = nullptr) { + const hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion *min_required_engine_api_version = nullptr, + const hipdnn_flatbuffers_sdk::data_objects::Uuid *id = nullptr) { auto name__ = name ? _fbb.CreateString(name) : 0; auto tensors__ = tensors ? _fbb.CreateVector<::flatbuffers::Offset>(*tensors) : 0; auto nodes__ = nodes ? _fbb.CreateVector<::flatbuffers::Offset>(*nodes) : 0; @@ -1102,7 +1151,8 @@ inline ::flatbuffers::Offset CreateGraphDirect( nodes__, preferred_engine_id, is_override_shape_enabled, - min_required_engine_api_version); + min_required_engine_api_version, + id); } ::flatbuffers::Offset CreateGraph(::flatbuffers::FlatBufferBuilder &_fbb, const GraphT *_o, const ::flatbuffers::rehasher_function_t *_rehasher = nullptr); @@ -1166,7 +1216,8 @@ inline bool operator==(const GraphT &lhs, const GraphT &rhs) { (lhs.nodes.size() == rhs.nodes.size() && std::equal(lhs.nodes.cbegin(), lhs.nodes.cend(), rhs.nodes.cbegin(), [](std::unique_ptr const &a, std::unique_ptr const &b) { return (a == b) || (a && b && *a == *b); })) && (lhs.preferred_engine_id == rhs.preferred_engine_id) && (lhs.is_override_shape_enabled == rhs.is_override_shape_enabled) && - ((lhs.min_required_engine_api_version == rhs.min_required_engine_api_version) || (lhs.min_required_engine_api_version && rhs.min_required_engine_api_version && *lhs.min_required_engine_api_version == *rhs.min_required_engine_api_version)); + ((lhs.min_required_engine_api_version == rhs.min_required_engine_api_version) || (lhs.min_required_engine_api_version && rhs.min_required_engine_api_version && *lhs.min_required_engine_api_version == *rhs.min_required_engine_api_version)) && + ((lhs.id == rhs.id) || (lhs.id && rhs.id && *lhs.id == *rhs.id)); } inline bool operator!=(const GraphT &lhs, const GraphT &rhs) { @@ -1181,7 +1232,8 @@ inline GraphT::GraphT(const GraphT &o) io_data_type(o.io_data_type), preferred_engine_id(o.preferred_engine_id), is_override_shape_enabled(o.is_override_shape_enabled), - min_required_engine_api_version((o.min_required_engine_api_version) ? new hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion(*o.min_required_engine_api_version) : nullptr) { + min_required_engine_api_version((o.min_required_engine_api_version) ? new hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion(*o.min_required_engine_api_version) : nullptr), + id((o.id) ? new hipdnn_flatbuffers_sdk::data_objects::Uuid(*o.id) : nullptr) { tensors.reserve(o.tensors.size()); for (const auto &tensors_ : o.tensors) { tensors.emplace_back((tensors_) ? new hipdnn_flatbuffers_sdk::data_objects::TensorAttributesT(*tensors_) : nullptr); } nodes.reserve(o.nodes.size()); @@ -1198,6 +1250,7 @@ inline GraphT &GraphT::operator=(GraphT o) FLATBUFFERS_NOEXCEPT { std::swap(preferred_engine_id, o.preferred_engine_id); std::swap(is_override_shape_enabled, o.is_override_shape_enabled); std::swap(min_required_engine_api_version, o.min_required_engine_api_version); + std::swap(id, o.id); return *this; } @@ -1219,6 +1272,7 @@ inline void Graph::UnPackTo(GraphT *_o, const ::flatbuffers::resolver_function_t { auto _e = preferred_engine_id(); _o->preferred_engine_id = _e; } { auto _e = is_override_shape_enabled(); _o->is_override_shape_enabled = _e; } { auto _e = min_required_engine_api_version(); if (_e) _o->min_required_engine_api_version = std::unique_ptr(new hipdnn_flatbuffers_sdk::data_objects::EngineApiVersion(*_e)); } + { auto _e = id(); if (_e) _o->id = std::unique_ptr(new hipdnn_flatbuffers_sdk::data_objects::Uuid(*_e)); } } inline ::flatbuffers::Offset Graph::Pack(::flatbuffers::FlatBufferBuilder &_fbb, const GraphT* _o, const ::flatbuffers::rehasher_function_t *_rehasher) { @@ -1238,6 +1292,7 @@ inline ::flatbuffers::Offset CreateGraph(::flatbuffers::FlatBufferBuilder auto _preferred_engine_id = _o->preferred_engine_id; auto _is_override_shape_enabled = _o->is_override_shape_enabled; auto _min_required_engine_api_version = _o->min_required_engine_api_version ? _o->min_required_engine_api_version.get() : nullptr; + auto _id = _o->id ? _o->id.get() : nullptr; return hipdnn_flatbuffers_sdk::data_objects::CreateGraph( _fbb, _name, @@ -1248,7 +1303,8 @@ inline ::flatbuffers::Offset CreateGraph(::flatbuffers::FlatBufferBuilder _nodes, _preferred_engine_id, _is_override_shape_enabled, - _min_required_engine_api_version); + _min_required_engine_api_version, + _id); } inline bool VerifyNodeAttributes(::flatbuffers::Verifier &verifier, const void *obj, NodeAttributes type) { diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp new file mode 100644 index 000000000000..1cbccd279e20 --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp @@ -0,0 +1,100 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace hipdnn_flatbuffers_sdk::utilities +{ + +using UuidBytes = std::array; + +inline UuidBytes toUuidBytes(const data_objects::Uuid& uuid) +{ + UuidBytes bytes{}; + for(size_t i = 0; i < bytes.size(); ++i) + { + bytes[i] = uuid.bytes()->Get(static_cast(i)); + } + return bytes; +} + +inline data_objects::Uuid toFlatbufferUuid(const UuidBytes& bytes) +{ + return {flatbuffers::span(bytes)}; +} + +inline bool isUuidV4(const UuidBytes& bytes) +{ + return (bytes[6] & 0xf0U) == 0x40U && (bytes[8] & 0xc0U) == 0x80U; +} + +inline std::string formatUuid(const UuidBytes& bytes) +{ + static constexpr std::string_view HEX = "0123456789abcdef"; + std::string result(36, '-'); + size_t output = 0; + for(const auto byte : bytes) + { + if(output == 8 || output == 13 || output == 18 || output == 23) + { + ++output; + } + result[output++] = HEX[byte >> 4U]; + result[output++] = HEX[byte & 0x0fU]; + } + return result; +} + +inline uint8_t parseHexDigit(char value) +{ + if(value >= '0' && value <= '9') + { + return static_cast(value - '0'); + } + if(value >= 'a' && value <= 'f') + { + return static_cast(value - 'a' + 10); + } + if(value >= 'A' && value <= 'F') + { + return static_cast(value - 'A' + 10); + } + throw std::invalid_argument("UUID contains a non-hexadecimal character"); +} + +inline UuidBytes parseUuid(std::string_view text) +{ + if(text.size() != 36 || text[8] != '-' || text[13] != '-' || text[18] != '-' || text[23] != '-') + { + throw std::invalid_argument("UUID must use xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx format"); + } + + UuidBytes bytes{}; + size_t input = 0; + for(auto& byte : bytes) + { + if(input == 8 || input == 13 || input == 18 || input == 23) + { + ++input; + } + const auto high = parseHexDigit(text[input++]); + const auto low = parseHexDigit(text[input++]); + byte = static_cast((high << 4U) | low); + } + + if(!isUuidV4(bytes)) + { + throw std::invalid_argument("UUID must be RFC 4122 version 4 with the RFC variant"); + } + return bytes; +} + +} // namespace hipdnn_flatbuffers_sdk::utilities diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp index 03f37ebac03c..611f3765d979 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp @@ -5,6 +5,7 @@ #ifndef HIPDNN_FLATBUFFERS_SDK_SKIP_JSON_LIB #include +#include #include #include #include @@ -151,6 +152,10 @@ inline void to_json(nlohmann::json& graphJson, const data_objects::Graph& graph) { graphJson["preferred_engine_id"] = graph.preferred_engine_id().value(); } + if(graph.id() != nullptr) + { + graphJson["id"] = utilities::formatUuid(utilities::toUuidBytes(*graph.id())); + } } } @@ -236,6 +241,16 @@ inline auto to(flatbuffers::FlatBufferBuilder& builder, preferredEngineId = entry["preferred_engine_id"].get(); } const bool isOverrideShapeEnabled = entry.value("is_override_shape_enabled", false); + std::unique_ptr id; + if(entry.contains("id")) + { + if(!entry["id"].is_string()) + { + throw std::runtime_error("Graph id must be a canonical UUID string"); + } + id = std::make_unique( + utilities::toFlatbufferUuid(utilities::parseUuid(entry["id"].get()))); + } auto nodes = toVector(builder, entry.at("nodes")); auto tensors = toVector(builder, entry.at("tensors")); @@ -247,7 +262,9 @@ inline auto to(flatbuffers::FlatBufferBuilder& builder, &tensors, &nodes, preferredEngineId, - isOverrideShapeEnabled); + isOverrideShapeEnabled, + nullptr, + id.get()); } } diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs index 3c4d29c12e7a..2429c4d54362 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs @@ -67,6 +67,11 @@ struct EngineApiVersion { patch: uint; } +// Opaque RFC 4122 UUID stored in canonical network byte order. +struct Uuid { + bytes: [ubyte:16]; +} + // Hipdnn expects that the serialized graph is sorted in topological order, has no cycles, // and is fully connected (no orphan nodes). Additionally, all tensors in the graph must have unique uids. table Graph { @@ -84,6 +89,8 @@ table Graph { // graphs) -- callers must treat a missing field as the baseline "1.0.0" floor, // exactly like the field's absence used to mean min_reader_version == 0. min_required_engine_api_version: EngineApiVersion; + // Stable identity for this finalized graph. Absent on legacy or unfinalized graphs. + id: Uuid; } root_type Graph; diff --git a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp index b509eabfabe6..8851ddb5a404 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp @@ -6,15 +6,19 @@ #include #include +#include #include #include #include #include +#include #include #include #include #include #include +#include +#include using namespace hipdnn_flatbuffers_sdk::data_objects; @@ -59,6 +63,90 @@ void toJsonAndBackTestSuite(const hipdnn_flatbuffers_sdk::data_objects::Graph* g } // namespace +TEST(TestUuid, FormatsAndParsesCanonicalText) +{ + const auto bytes + = hipdnn_flatbuffers_sdk::utilities::parseUuid("01234567-89AB-4DEF-8123-456789ABCDEF"); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::formatUuid(bytes), + "01234567-89ab-4def-8123-456789abcdef"); + EXPECT_TRUE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(bytes)); + + const auto flatbufferUuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(bytes); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(flatbufferUuid), bytes); +} + +TEST(TestUuid, RejectsMalformedOrNonV4Text) +{ + const std::vector invalidIds{ + "", + "01234567-89ab-4def-8123-456789abcde", + "01234567_89ab-4def-8123-456789abcdef", + "g1234567-89ab-4def-8123-456789abcdef", + "01234567-89ab-3def-8123-456789abcdef", + "01234567-89ab-4def-4123-456789abcdef", + "00000000-0000-0000-0000-000000000000", + }; + + for(const auto& id : invalidIds) + { + EXPECT_THROW((void)hipdnn_flatbuffers_sdk::utilities::parseUuid(id), std::invalid_argument) + << id; + } +} + +TEST(TestJson, GraphUuidRoundTripsAsCanonicalString) +{ + auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); + auto graph = hipdnn_flatbuffers_sdk::data_objects::GetGraph(builder.GetBufferPointer()); + const auto id + = hipdnn_flatbuffers_sdk::utilities::parseUuid("01234567-89AB-4DEF-8123-456789ABCDEF"); + const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(id); + + auto graphT = std::unique_ptr(graph->UnPack()); + graphT->id = std::make_unique(uuid); + flatbuffers::FlatBufferBuilder identifiedBuilder; + identifiedBuilder.Finish(Graph::Pack(identifiedBuilder, graphT.get())); + const auto* identifiedGraph = GetGraph(identifiedBuilder.GetBufferPointer()); + + const nlohmann::json graphJson = *identifiedGraph; + ASSERT_TRUE(graphJson.contains("id")); + EXPECT_EQ(graphJson.at("id"), "01234567-89ab-4def-8123-456789abcdef"); + + flatbuffers::FlatBufferBuilder roundTripBuilder; + roundTripBuilder.Finish(hipdnn_flatbuffers_sdk::json::to(roundTripBuilder, graphJson)); + const auto* roundTripped = GetGraph(roundTripBuilder.GetBufferPointer()); + ASSERT_NE(roundTripped->id(), nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*roundTripped->id()), id); +} + +TEST(TestJson, LegacyGraphOmitsUuidAndStillRoundTrips) +{ + auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); + const auto* graph = GetGraph(builder.GetBufferPointer()); + const nlohmann::json graphJson = *graph; + EXPECT_FALSE(graphJson.contains("id")); + + flatbuffers::FlatBufferBuilder roundTripBuilder; + roundTripBuilder.Finish(hipdnn_flatbuffers_sdk::json::to(roundTripBuilder, graphJson)); + EXPECT_EQ(GetGraph(roundTripBuilder.GetBufferPointer())->id(), nullptr); +} + +TEST(TestJson, RejectsInvalidGraphUuid) +{ + auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); + const auto* graph = GetGraph(builder.GetBufferPointer()); + auto graphJson = nlohmann::json(*graph); + + for(const auto& id : + nlohmann::json::array({42, "not-a-uuid", "01234567-89ab-3def-8123-456789abcdef"})) + { + graphJson["id"] = id; + flatbuffers::FlatBufferBuilder invalidBuilder; + EXPECT_THROW((void)hipdnn_flatbuffers_sdk::json::to(invalidBuilder, graphJson), + std::exception); + } +} + TEST(TestJson, GraphToJsonAndBack) { auto nodeAttributeValues = EnumValuesNodeAttributes(); From d501d1436f64a19e5cfdbd83385a8b2890db4d18 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Wed, 5 Aug 2026 05:10:13 +0000 Subject: [PATCH 02/60] refactor(hipdnn): use CRT for Windows UUID RNG --- projects/hipdnn/backend/src/CMakeLists.txt | 3 --- .../backend/src/PlatformUtils.windows.cpp | 20 +++++++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/projects/hipdnn/backend/src/CMakeLists.txt b/projects/hipdnn/backend/src/CMakeLists.txt index f9fbe2dabffe..a7b0c0c798eb 100644 --- a/projects/hipdnn/backend/src/CMakeLists.txt +++ b/projects/hipdnn/backend/src/CMakeLists.txt @@ -86,9 +86,6 @@ if(NOT TARGET nlohmann_json::nlohmann_json) endif() hipdnn_add_dependency_includes(hipdnn_backend_private nlohmann_json::nlohmann_json) target_link_libraries(hipdnn_backend_private PRIVATE ${CMAKE_DL_LIBS}) -if(WIN32) - target_link_libraries(hipdnn_backend_private PRIVATE bcrypt) -endif() target_compile_definitions(hipdnn_backend_private PRIVATE HIPDNN_BACKEND_COMPILATION) # Enable spdlog support via shared cmake function diff --git a/projects/hipdnn/backend/src/PlatformUtils.windows.cpp b/projects/hipdnn/backend/src/PlatformUtils.windows.cpp index bcd7986607c9..92f297e01000 100644 --- a/projects/hipdnn/backend/src/PlatformUtils.windows.cpp +++ b/projects/hipdnn/backend/src/PlatformUtils.windows.cpp @@ -1,12 +1,17 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT +#ifdef _WIN32 +#define _CRT_RAND_S +#endif + #include "PlatformUtils.hpp" #ifdef _WIN32 #include "HipdnnException.hpp" -#include +#include +#include #include #include @@ -146,12 +151,15 @@ std::string getSystemInfo() std::array generateUuidV4() { std::array bytes{}; - const auto result = BCryptGenRandom( - nullptr, bytes.data(), static_cast(bytes.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG); - if(result < 0) + for(size_t offset = 0; offset < bytes.size(); offset += sizeof(unsigned int)) { - throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, - "Failed to generate graph UUID using BCryptGenRandom."); + unsigned int randomValue; + if(rand_s(&randomValue) != 0) + { + throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, + "Failed to generate graph UUID using rand_s."); + } + std::memcpy(bytes.data() + offset, &randomValue, sizeof(randomValue)); } bytes[6] = static_cast((bytes[6] & 0x0fU) | 0x40U); From d8ee796ddc0747a1eb9a4964cba971663a670467 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Wed, 5 Aug 2026 16:31:56 +0000 Subject: [PATCH 03/60] docs(hipdnn): update UUID standard wording --- projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp | 2 +- .../include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp | 2 +- projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp index 118b4335cb9d..e19340779c4a 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp @@ -429,7 +429,7 @@ void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t gr const auto id = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id); THROW_IF_FALSE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(id), HIPDNN_STATUS_BAD_PARAM, - "Serialized graph id must be an RFC 4122 version 4 UUID."); + "Serialized graph id must be a version 4 UUID with the standard variant."); graphId = id; } diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp index 1cbccd279e20..80879431431d 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp @@ -92,7 +92,7 @@ inline UuidBytes parseUuid(std::string_view text) if(!isUuidV4(bytes)) { - throw std::invalid_argument("UUID must be RFC 4122 version 4 with the RFC variant"); + throw std::invalid_argument("UUID must be version 4 with the standard variant"); } return bytes; } diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs index 2429c4d54362..68bff7ad6947 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs @@ -67,7 +67,8 @@ struct EngineApiVersion { patch: uint; } -// Opaque RFC 4122 UUID stored in canonical network byte order. +// UUID version 4 with the RFC variant, as defined by RFC 9562: +// https://www.rfc-editor.org/rfc/rfc9562.html#section-5.4 struct Uuid { bytes: [ubyte:16]; } From df810d25be72551564d8b49fec2ecc4dc528e151 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Wed, 5 Aug 2026 17:47:02 +0000 Subject: [PATCH 04/60] fix(hipdnn): treat graph IDs as opaque values --- .../src/descriptors/GraphDescriptor.cpp | 6 +---- .../tests/descriptors/TestGraphDescriptor.cpp | 24 ++++++++++--------- .../hipdnn_flatbuffers_sdk/utilities/Uuid.hpp | 5 ---- .../hipdnn/flatbuffers_sdk/schemas/graph.fbs | 3 +-- .../tests/utilities/TestJson.cpp | 20 +++++++++++----- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp index e19340779c4a..9180b496d60f 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp @@ -426,11 +426,7 @@ void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t gr std::optional> graphId; if(graph->id) { - const auto id = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id); - THROW_IF_FALSE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(id), - HIPDNN_STATUS_BAD_PARAM, - "Serialized graph id must be a version 4 UUID with the standard variant."); - graphId = id; + graphId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id); } auto tensorMap = NodeFactory::buildTensorMap(graph->tensors); diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index 2292eb39d548..2c3a1e3ceb55 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -959,25 +959,27 @@ TEST_F(TestGraphDescriptor, ContentMutationReplacesInheritedUuid) EXPECT_NE(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*mutatedGraph->id), originalId); } -TEST_F(TestGraphDescriptor, RejectsSerializedUuidWithInvalidVersionOrVariant) +TEST_F(TestGraphDescriptor, PreservesOpaqueSerializedGraphId) { auto builder = createValidGraph(); auto serialized = builder.Release(); auto graph = hipdnn_flatbuffers_sdk::data_objects::UnPackGraph(serialized.data()); - std::array invalidBytes{}; - invalidBytes[6] = 0x30; - invalidBytes[8] = 0x40; + std::array opaqueId{}; + opaqueId[6] = 0x30; + opaqueId[8] = 0x40; graph->id = std::make_unique( - flatbuffers::span(invalidBytes)); + flatbuffers::span(opaqueId)); - flatbuffers::FlatBufferBuilder invalidBuilder; - invalidBuilder.Finish( - hipdnn_flatbuffers_sdk::data_objects::Graph::Pack(invalidBuilder, graph.get())); - auto invalid = invalidBuilder.Release(); + flatbuffers::FlatBufferBuilder identifiedBuilder; + identifiedBuilder.Finish( + hipdnn_flatbuffers_sdk::data_objects::Graph::Pack(identifiedBuilder, graph.get())); + auto identified = identifiedBuilder.Release(); GraphDescriptor descriptor; - ASSERT_THROW_HIPDNN_STATUS(descriptor.deserializeGraph(invalid.data(), invalid.size()), - HIPDNN_STATUS_BAD_PARAM); + descriptor.deserializeGraph(identified.data(), identified.size()); + descriptor.buildSerializedGraph(); + ASSERT_NE(unpack(descriptor)->id, nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*unpack(descriptor)->id), opaqueId); } TEST_F(TestGraphDescriptor, JsonRoundTripPreservesUuid) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp index 80879431431d..1fbf75fc0afd 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp @@ -89,11 +89,6 @@ inline UuidBytes parseUuid(std::string_view text) const auto low = parseHexDigit(text[input++]); byte = static_cast((high << 4U) | low); } - - if(!isUuidV4(bytes)) - { - throw std::invalid_argument("UUID must be version 4 with the standard variant"); - } return bytes; } diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs index 68bff7ad6947..2ac48e911996 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs @@ -67,8 +67,7 @@ struct EngineApiVersion { patch: uint; } -// UUID version 4 with the RFC variant, as defined by RFC 9562: -// https://www.rfc-editor.org/rfc/rfc9562.html#section-5.4 +// Opaque 128-bit graph identity. struct Uuid { bytes: [ubyte:16]; } diff --git a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp index 8851ddb5a404..cd34245568c0 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp @@ -75,16 +75,13 @@ TEST(TestUuid, FormatsAndParsesCanonicalText) EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(flatbufferUuid), bytes); } -TEST(TestUuid, RejectsMalformedOrNonV4Text) +TEST(TestUuid, RejectsMalformedText) { const std::vector invalidIds{ "", "01234567-89ab-4def-8123-456789abcde", "01234567_89ab-4def-8123-456789abcdef", "g1234567-89ab-4def-8123-456789abcdef", - "01234567-89ab-3def-8123-456789abcdef", - "01234567-89ab-4def-4123-456789abcdef", - "00000000-0000-0000-0000-000000000000", }; for(const auto& id : invalidIds) @@ -94,6 +91,18 @@ TEST(TestUuid, RejectsMalformedOrNonV4Text) } } +TEST(TestUuid, ParsesOpaque128BitValues) +{ + for(const auto& id : {"01234567-89ab-3def-8123-456789abcdef", + "01234567-89ab-4def-4123-456789abcdef", + "00000000-0000-0000-0000-000000000000"}) + { + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::formatUuid( + hipdnn_flatbuffers_sdk::utilities::parseUuid(id)), + id); + } +} + TEST(TestJson, GraphUuidRoundTripsAsCanonicalString) { auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); @@ -137,8 +146,7 @@ TEST(TestJson, RejectsInvalidGraphUuid) const auto* graph = GetGraph(builder.GetBufferPointer()); auto graphJson = nlohmann::json(*graph); - for(const auto& id : - nlohmann::json::array({42, "not-a-uuid", "01234567-89ab-3def-8123-456789abcdef"})) + for(const auto& id : nlohmann::json::array({42, "not-a-uuid"})) { graphJson["id"] = id; flatbuffers::FlatBufferBuilder invalidBuilder; From 5a99a2c8a41608c197ee23945fec8a7e7ad52cb4 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Wed, 5 Aug 2026 17:58:02 +0000 Subject: [PATCH 05/60] docs(hipdnn): clarify graph ID contract --- projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp | 3 ++- projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp index ba9fc75ee195..82eaef6c920c 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp @@ -38,7 +38,8 @@ class GraphDescriptor : public HipdnnBackendDescriptorImpl // Cleared by invalidateCache() when operations are mutated. flatbuffers::DetachedBuffer _graphSerializedBuffer; - // Stable identity of immutable graph contents. Absent for legacy and unfinalized graphs. + // Stable identity of one graph object, not its contents. Newly generated values use UUID v4; + // deserialized values are preserved as opaque 128-bit IDs. std::optional> _graphId; // Source of truth for the graph's operations. Populated via setOperations() (C-API flow) diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs index 2ac48e911996..7c80d80fa526 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs @@ -67,7 +67,8 @@ struct EngineApiVersion { patch: uint; } -// Opaque 128-bit graph identity. +// Opaque 128-bit graph identity. hipDNN generates UUID v4 values, but readers preserve +// any 128-bit value without enforcing a UUID version or variant. struct Uuid { bytes: [ubyte:16]; } From a4c9c4079f7cb336880f4952301a67792c940840 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Wed, 5 Aug 2026 20:39:10 +0000 Subject: [PATCH 06/60] fix(hipdnn): keep finalized graph identity immutable --- .../src/descriptors/GraphDescriptor.cpp | 3 +++ .../tests/descriptors/TestGraphDescriptor.cpp | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp index c6c89203adc3..d0eaf7f6a624 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp @@ -409,6 +409,9 @@ void GraphDescriptor::setAttribute(hipdnnBackendAttributeName_t attributeName, void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t graphByteSize) { + THROW_IF_TRUE(isFinalized(), + HIPDNN_STATUS_NOT_INITIALIZED, + "GraphDescriptor::deserializeGraph() failed: Already finalized."); THROW_IF_NULL(serializedGraph, HIPDNN_STATUS_BAD_PARAM_NULL_POINTER, "GraphDescriptor::deserializeGraph: serializedGraph is null"); diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index 4e2e60a76aaa..5b95f3ce6c2f 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -708,6 +708,27 @@ TEST_F(TestGraphDescriptor, DeserializeInvalidatesSerializedBuffer) EXPECT_EQ(std::string(nameBuffer.data()), "graphB"); } +TEST_F(TestGraphDescriptor, DeserializeFailsAfterFinalizeAndPreservesIdentity) +{ + auto builder = createValidGraph(); + auto serialized = builder.Release(); + + GraphDescriptor descriptor; + descriptor.deserializeGraph(serialized.data(), serialized.size()); + setHandle(descriptor); + descriptor.finalize(); + const auto original = unpack(descriptor); + ASSERT_NE(original->id, nullptr); + const auto originalId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*original->id); + + ASSERT_THROW_HIPDNN_STATUS(descriptor.deserializeGraph(serialized.data(), serialized.size()), + HIPDNN_STATUS_NOT_INITIALIZED); + EXPECT_TRUE(descriptor.isFinalized()); + const auto preserved = unpack(descriptor); + ASSERT_NE(preserved->id, nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*preserved->id), originalId); +} + // ============================================================================ // Binary/JSON serialization error-path tests // ============================================================================ From 9cc9a89a96c9447502a2951c3b710ec280597c03 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Thu, 6 Aug 2026 00:11:38 +0000 Subject: [PATCH 07/60] fix(hipdnn): preserve graph dump content deduplication --- .../src/descriptors/GraphDescriptor.hpp | 5 ++- .../backend/src/logging/GraphLogger.cpp | 19 +++++---- .../hipdnn/backend/tests/TestGraphLogger.cpp | 7 ++-- .../tests/descriptors/TestGraphDescriptor.cpp | 40 ------------------- .../hipdnn_flatbuffers_sdk/utilities/Uuid.hpp | 32 +++++++-------- .../utilities/json/Graph.hpp | 7 ++-- .../hipdnn/flatbuffers_sdk/schemas/graph.fbs | 10 +++-- 7 files changed, 45 insertions(+), 75 deletions(-) diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp index 1f71585fc0fc..07f5f2343838 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.hpp @@ -38,8 +38,9 @@ class GraphDescriptor : public HipdnnBackendDescriptorImpl // Cleared by invalidateCache() when operations are mutated. flatbuffers::DetachedBuffer _graphSerializedBuffer; - // Stable identity of one graph object, not its contents. Newly generated values use UUID v4; - // deserialized values are preserved as opaque 128-bit IDs. + // Stable identity of a logical finalized graph across serialization round trips. + // Deserialized copies preserve it, so it is not a unique live-object token; use it only to + // key state derived from immutable graph content. Newly generated values use UUID v4. std::optional> _graphId; // Source of truth for the graph's operations. Populated via setOperations() (C-API flow) diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.cpp b/projects/hipdnn/backend/src/logging/GraphLogger.cpp index 9fd4640809ae..b420fc90d056 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.cpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.cpp @@ -88,9 +88,18 @@ std::filesystem::path GraphLogger::getOutputDirectory() return cachedPath; } -void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) +void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t) { - auto hash = hipdnn_data_sdk::utilities::fnv1aHash(serializedGraph, size); + auto* graph + = flatbuffers::GetRoot(serializedGraph); + const nlohmann::json graphJson = *graph; + auto graphContent = graphJson; + + // Graph IDs distinguish logical graph lifetimes for runtime caches, but graph dumps are + // content-addressed. Exclude the ID from the hash so rebuilding an equivalent graph does not + // create another log file; retain it in graphJson for diagnostics. + graphContent.erase("id"); + const auto hash = hipdnn_data_sdk::utilities::fnv1aHash(graphContent.dump()); std::ostringstream oss; oss << "graph_" << std::hex << std::setfill('0') << std::setw(16) << hash << ".json"; @@ -108,14 +117,10 @@ void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) return; } - auto* graph - = flatbuffers::GetRoot(serializedGraph); - const nlohmann::json j = *graph; - std::ofstream file(fullPath); if(file.is_open()) { - file << j.dump(2); + file << graphJson.dump(2); file.close(); HIPDNN_BACKEND_LOG_INFO("Graph logged to {}", fullPath.string()); } diff --git a/projects/hipdnn/backend/tests/TestGraphLogger.cpp b/projects/hipdnn/backend/tests/TestGraphLogger.cpp index 5562ddba3771..cc0e9758dbea 100644 --- a/projects/hipdnn/backend/tests/TestGraphLogger.cpp +++ b/projects/hipdnn/backend/tests/TestGraphLogger.cpp @@ -131,19 +131,20 @@ TEST_F(TestGraphLogger, GraphLoggedWhenEnabled) EXPECT_TRUE(j.contains("nodes")); EXPECT_TRUE(j.contains("tensors")); EXPECT_TRUE(j.contains("name")); + EXPECT_TRUE(j.contains("id")); } -TEST_F(TestGraphLogger, IndependentlyFinalizedGraphsAreLoggedSeparately) +TEST_F(TestGraphLogger, EquivalentGraphsAreNotLoggedTwice) { hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); hipdnn_backend::logging::loggerShutdown(); - // Equivalent graph contents represent distinct finalized graph identities. + // IDs differ, but graph dumps are deduplicated by graph content. auto descriptor1 = createAndFinalizeGraph(); auto descriptor2 = createAndFinalizeGraph(); auto jsonFiles = getJsonFilesInDir(_tempDir); - EXPECT_EQ(jsonFiles.size(), 2u); + EXPECT_EQ(jsonFiles.size(), 1u); } TEST_F(TestGraphLogger, ReLoggingSameFinalizedGraphIsDeduplicated) diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index 5b95f3ce6c2f..9f36d2495a9b 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -56,46 +56,6 @@ class TestGraphDescriptor : public ::testing::Test EXPECT_EQ(graph.tensors.size(), 3); EXPECT_EQ(graph.nodes.size(), 1); } - - static void verifyGraphsEquivalent(const hipdnn_flatbuffers_sdk::data_objects::GraphT& graph1, - const hipdnn_flatbuffers_sdk::data_objects::GraphT& graph2) - { - EXPECT_EQ(graph1.name, graph2.name); - EXPECT_EQ(graph1.compute_data_type, graph2.compute_data_type); - EXPECT_EQ(graph1.intermediate_data_type, graph2.intermediate_data_type); - EXPECT_EQ(graph1.io_data_type, graph2.io_data_type); - EXPECT_EQ(graph1.preferred_engine_id, graph2.preferred_engine_id); - EXPECT_EQ(graph1.id == nullptr, graph2.id == nullptr); - EXPECT_EQ(graph1.min_required_engine_api_version == nullptr, - graph2.min_required_engine_api_version == nullptr); - if(graph1.min_required_engine_api_version && graph2.min_required_engine_api_version) - { - EXPECT_EQ(*graph1.min_required_engine_api_version, - *graph2.min_required_engine_api_version); - } - if(graph1.id && graph2.id) - { - EXPECT_EQ(*graph1.id, *graph2.id); - } - - ASSERT_EQ(graph1.tensors.size(), graph2.tensors.size()); - for(size_t i = 0; i < graph1.tensors.size(); ++i) - { - SCOPED_TRACE("tensor[" + std::to_string(i) + "]"); - ASSERT_NE(graph1.tensors[i], nullptr); - ASSERT_NE(graph2.tensors[i], nullptr); - EXPECT_EQ(*graph1.tensors[i], *graph2.tensors[i]); - } - - ASSERT_EQ(graph1.nodes.size(), graph2.nodes.size()); - for(size_t i = 0; i < graph1.nodes.size(); ++i) - { - SCOPED_TRACE("node[" + std::to_string(i) + "]"); - ASSERT_NE(graph1.nodes[i], nullptr); - ASSERT_NE(graph2.nodes[i], nullptr); - EXPECT_EQ(*graph1.nodes[i], *graph2.nodes[i]); - } - } }; TEST_F(TestGraphDescriptor, SerializeDeserializeGraph) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp index 1fbf75fc0afd..cd8644b1e38b 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/Uuid.hpp @@ -53,29 +53,27 @@ inline std::string formatUuid(const UuidBytes& bytes) return result; } -inline uint8_t parseHexDigit(char value) -{ - if(value >= '0' && value <= '9') - { - return static_cast(value - '0'); - } - if(value >= 'a' && value <= 'f') - { - return static_cast(value - 'a' + 10); - } - if(value >= 'A' && value <= 'F') - { - return static_cast(value - 'A' + 10); - } - throw std::invalid_argument("UUID contains a non-hexadecimal character"); -} - inline UuidBytes parseUuid(std::string_view text) { if(text.size() != 36 || text[8] != '-' || text[13] != '-' || text[18] != '-' || text[23] != '-') { throw std::invalid_argument("UUID must use xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx format"); } + const auto parseHexDigit = [](char value) -> uint8_t { + if(value >= '0' && value <= '9') + { + return static_cast(value - '0'); + } + if(value >= 'a' && value <= 'f') + { + return static_cast(value - 'a' + 10); + } + if(value >= 'A' && value <= 'F') + { + return static_cast(value - 'A' + 10); + } + throw std::invalid_argument("UUID contains a non-hexadecimal character"); + }; UuidBytes bytes{}; size_t input = 0; diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp index db9b2ea51b52..ee9ee493b408 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/utilities/json/Graph.hpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace hipdnn_flatbuffers_sdk::data_objects { @@ -259,14 +260,14 @@ inline auto to(flatbuffers::FlatBufferBuilder& builder, preferredEngineId = entry["preferred_engine_id"].get(); } const bool isOverrideShapeEnabled = entry.value("is_override_shape_enabled", false); - std::unique_ptr id; + std::optional id; if(entry.contains("id")) { if(!entry["id"].is_string()) { throw std::runtime_error("Graph id must be a canonical UUID string"); } - id = std::make_unique( + id.emplace( utilities::toFlatbufferUuid(utilities::parseUuid(entry["id"].get()))); } @@ -294,7 +295,7 @@ inline auto to(flatbuffers::FlatBufferBuilder& builder, preferredEngineId, isOverrideShapeEnabled, minRequiredEngineApiVersion, - id.get()); + id ? &*id : nullptr); } } diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs index 4888f9786a55..f84e03cbd7ca 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/graph.fbs @@ -71,8 +71,7 @@ struct EngineApiVersion { patch: uint; } -// Opaque 128-bit graph identity. hipDNN generates UUID v4 values, but readers preserve -// any 128-bit value without enforcing a UUID version or variant. +// Opaque 128-bit value used for graph identities. struct Uuid { bytes: [ubyte:16]; } @@ -94,7 +93,12 @@ table Graph { // graphs) -- callers must treat a missing field as the baseline "1.0.0" floor, // exactly like the field's absence used to mean min_reader_version == 0. min_required_engine_api_version: EngineApiVersion; - // Stable identity for this finalized graph. Absent on legacy or unfinalized graphs. + // Stable logical graph identity across serialization round trips. hipDNN mints UUID v4 + // values at finalization; readers preserve any 128-bit value without enforcing a UUID + // version or variant. It is neither a content hash nor a unique live-object token: + // distinct IDs may have identical content, while one ID must not represent different + // immutable graph content. Absent on legacy and locally constructed, unfinalized graphs; + // an ID inherited through deserialization may be present before local finalization. id: Uuid; } From b3d438c52f3ec2366a2f4510a6fb2a176eb6cea7 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Thu, 6 Aug 2026 14:07:29 +0000 Subject: [PATCH 08/60] fix(hipdnn): mark graph log size unused --- projects/hipdnn/backend/src/logging/GraphLogger.cpp | 2 +- projects/hipdnn/backend/src/logging/GraphLogger.hpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.cpp b/projects/hipdnn/backend/src/logging/GraphLogger.cpp index b420fc90d056..e12635b0f3e1 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.cpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.cpp @@ -88,7 +88,7 @@ std::filesystem::path GraphLogger::getOutputDirectory() return cachedPath; } -void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t) +void GraphLogger::logGraph(const uint8_t* serializedGraph, [[maybe_unused]] size_t size) { auto* graph = flatbuffers::GetRoot(serializedGraph); diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.hpp b/projects/hipdnn/backend/src/logging/GraphLogger.hpp index 1a125b2a8d50..2f2a50b4c137 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.hpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.hpp @@ -13,8 +13,8 @@ namespace hipdnn_backend::logging class GraphLogger { public: - /// Logs the graph as JSON. Deduplicates by content hash — if the same - /// flatbuffer binary has already been logged, this is a no-op. + /// Logs the graph as JSON. Deduplicates by graph content while ignoring the graph ID, so + /// independently finalized equivalent graphs share one dump. /// @param serializedGraph pointer to flatbuffer binary /// @param size size of the flatbuffer binary static void logGraph(const uint8_t* serializedGraph, size_t size); From 783ceb667841e6f49ad33490fd9584ea07e852d1 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Mon, 10 Aug 2026 20:38:29 +0000 Subject: [PATCH 09/60] refactor(hipdnn): name graph dumps by graph ID --- .../backend/src/logging/GraphLogger.cpp | 44 +++++++++++++------ .../backend/src/logging/GraphLogger.hpp | 5 ++- .../hipdnn/backend/tests/TestGraphLogger.cpp | 16 +++---- projects/hipdnn/docs/Environment.md | 2 +- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.cpp b/projects/hipdnn/backend/src/logging/GraphLogger.cpp index e12635b0f3e1..94afa9c0d1b8 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.cpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -88,35 +89,50 @@ std::filesystem::path GraphLogger::getOutputDirectory() return cachedPath; } -void GraphLogger::logGraph(const uint8_t* serializedGraph, [[maybe_unused]] size_t size) +void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) { - auto* graph + const auto* graph = flatbuffers::GetRoot(serializedGraph); - const nlohmann::json graphJson = *graph; - auto graphContent = graphJson; - - // Graph IDs distinguish logical graph lifetimes for runtime caches, but graph dumps are - // content-addressed. Exclude the ID from the hash so rebuilding an equivalent graph does not - // create another log file; retain it in graphJson for diagnostics. - graphContent.erase("id"); - const auto hash = hipdnn_data_sdk::utilities::fnv1aHash(graphContent.dump()); - std::ostringstream oss; - oss << "graph_" << std::hex << std::setfill('0') << std::setw(16) << hash << ".json"; - auto fullPath = getOutputDirectory() / oss.str(); + // Dumps are named by the graph's own ID. Re-finalizing a descriptor, or replaying a + // serialized graph, keeps that ID and so reuses one file; a graph rebuilt from scratch is a + // distinct graph object and earns its own. Naming by a content hash would also fold those + // rebuilds together, but it can collide, and a collision silently discards one graph under a + // name already claimed by another. + std::string filename; + if(graph->id() != nullptr) + { + filename = "graph_" + + hipdnn_flatbuffers_sdk::utilities::formatUuid( + hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id())) + + ".json"; + } + else + { + // Unreachable via finalize(), which always assigns an ID before serializing. Kept so a + // pre-finalize buffer cannot land every graph on one filename. + std::ostringstream oss; + oss << "graph_" << std::hex << std::setfill('0') << std::setw(16) + << hipdnn_data_sdk::utilities::fnv1aHash(serializedGraph, size) << ".json"; + filename = oss.str(); + } - if(fullPath.empty()) + const auto outputDirectory = getOutputDirectory(); + if(outputDirectory.empty()) { HIPDNN_BACKEND_LOG_WARN("Graph logging is enabled but no valid output directory is set"); return; } + const auto fullPath = outputDirectory / filename; if(std::filesystem::exists(fullPath)) { HIPDNN_BACKEND_LOG_INFO("Skipping duplicate graph logged to {}", fullPath.string()); return; } + const nlohmann::json graphJson = *graph; + std::ofstream file(fullPath); if(file.is_open()) { diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.hpp b/projects/hipdnn/backend/src/logging/GraphLogger.hpp index 2f2a50b4c137..846cf28b479f 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.hpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.hpp @@ -13,8 +13,9 @@ namespace hipdnn_backend::logging class GraphLogger { public: - /// Logs the graph as JSON. Deduplicates by graph content while ignoring the graph ID, so - /// independently finalized equivalent graphs share one dump. + /// Logs the graph as JSON, named by the graph's ID. Logging a graph whose ID already has a + /// file in the output directory is a no-op, so re-finalized and replayed graphs are written + /// once; a graph rebuilt from scratch has a new ID and is written separately. /// @param serializedGraph pointer to flatbuffer binary /// @param size size of the flatbuffer binary static void logGraph(const uint8_t* serializedGraph, size_t size); diff --git a/projects/hipdnn/backend/tests/TestGraphLogger.cpp b/projects/hipdnn/backend/tests/TestGraphLogger.cpp index cc0e9758dbea..1cb3ec73968e 100644 --- a/projects/hipdnn/backend/tests/TestGraphLogger.cpp +++ b/projects/hipdnn/backend/tests/TestGraphLogger.cpp @@ -118,11 +118,6 @@ TEST_F(TestGraphLogger, GraphLoggedWhenEnabled) auto jsonFiles = getJsonFilesInDir(_tempDir); ASSERT_EQ(jsonFiles.size(), 1u); - // Verify filename format: graph_<16 hex chars>.json - auto filename = jsonFiles[0].filename().string(); - EXPECT_EQ(filename.rfind("graph_", 0), 0u); - EXPECT_EQ(filename.rfind(".json"), filename.size() - 5); - // Verify the file contains valid JSON with expected graph fields std::ifstream file(jsonFiles[0]); ASSERT_TRUE(file.is_open()); @@ -131,20 +126,23 @@ TEST_F(TestGraphLogger, GraphLoggedWhenEnabled) EXPECT_TRUE(j.contains("nodes")); EXPECT_TRUE(j.contains("tensors")); EXPECT_TRUE(j.contains("name")); - EXPECT_TRUE(j.contains("id")); + ASSERT_TRUE(j.contains("id")); + + // The dump is named after the graph it holds. + EXPECT_EQ(jsonFiles[0].filename().string(), "graph_" + j["id"].get() + ".json"); } -TEST_F(TestGraphLogger, EquivalentGraphsAreNotLoggedTwice) +TEST_F(TestGraphLogger, EquivalentGraphsAreLoggedSeparately) { hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); hipdnn_backend::logging::loggerShutdown(); - // IDs differ, but graph dumps are deduplicated by graph content. + // Two independently built graphs are two graph objects with two IDs, so each is dumped. auto descriptor1 = createAndFinalizeGraph(); auto descriptor2 = createAndFinalizeGraph(); auto jsonFiles = getJsonFilesInDir(_tempDir); - EXPECT_EQ(jsonFiles.size(), 1u); + EXPECT_EQ(jsonFiles.size(), 2u); } TEST_F(TestGraphLogger, ReLoggingSameFinalizedGraphIsDeduplicated) diff --git a/projects/hipdnn/docs/Environment.md b/projects/hipdnn/docs/Environment.md index 6b426692e271..22659abdb63d 100644 --- a/projects/hipdnn/docs/Environment.md +++ b/projects/hipdnn/docs/Environment.md @@ -162,7 +162,7 @@ Controls graph structure logging. When set to a non-empty directory path, graphs | (unset) | Graph logging disabled (default) | | `` | Write graph structures as JSON files to the given directory | -Graph JSON files are written to the directory specified by `HIPDNN_LOG_GRAPH_DIR`. If the directory does not exist, it is created automatically. Relative paths are resolved against the current working directory. Files are named `graph_.json` where `` is derived from the graph content, ensuring identical graphs are not duplicated. +Graph JSON files are written to the directory specified by `HIPDNN_LOG_GRAPH_DIR`. If the directory does not exist, it is created automatically. Relative paths are resolved against the current working directory. Files are named `graph_.json` after the graph's own ID, so finalizing a graph again, or replaying a serialized one, reuses a single file. A graph rebuilt from scratch is a new graph with a new ID and is written separately; point the variable at a per-process directory if many processes share one output location. This variable is independent of `HIPDNN_LOG_LEVEL` and `HIPDNN_LOG_FILE`. From 07265454459fbf2810955e64f3e595188f27ff1d Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Mon, 10 Aug 2026 21:30:05 +0000 Subject: [PATCH 10/60] fix(hipdnn): require graph identity for graph dumps --- .../backend/src/PlatformUtils.linux.cpp | 23 ++------ .../backend/src/PlatformUtils.windows.cpp | 2 + .../src/descriptors/GraphDescriptor.cpp | 21 +++++++ .../backend/src/logging/GraphLogger.cpp | 58 ++++++++++--------- .../backend/src/logging/GraphLogger.hpp | 7 ++- .../hipdnn/backend/tests/TestGraphLogger.cpp | 37 ++++++++++++ .../tests/descriptors/TestGraphDescriptor.cpp | 5 +- .../tests/utilities/TestJson.cpp | 17 +++--- 8 files changed, 113 insertions(+), 57 deletions(-) diff --git a/projects/hipdnn/backend/src/PlatformUtils.linux.cpp b/projects/hipdnn/backend/src/PlatformUtils.linux.cpp index c1bcb7dfaa83..21869e401a09 100644 --- a/projects/hipdnn/backend/src/PlatformUtils.linux.cpp +++ b/projects/hipdnn/backend/src/PlatformUtils.linux.cpp @@ -6,7 +6,6 @@ #if defined(__linux__) #include "HipdnnException.hpp" -#include #include #include #include @@ -83,26 +82,12 @@ std::string getSystemInfo() std::array generateUuidV4() { + // getentropy() fills the whole buffer or fails, for any request up to 256 bytes. std::array bytes{}; - size_t filled = 0; - while(filled < bytes.size()) + if(getentropy(bytes.data(), bytes.size()) != 0) { - const auto result = getrandom(bytes.data() + filled, bytes.size() - filled, 0); - if(result < 0) - { - if(errno == EINTR) - { - continue; - } - throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, - "Failed to generate graph UUID using getrandom."); - } - if(result == 0) - { - throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, - "Failed to generate graph UUID: getrandom returned no data."); - } - filled += static_cast(result); + throw HipdnnException(HIPDNN_STATUS_INTERNAL_ERROR, + "Failed to generate graph UUID using getentropy."); } bytes[6] = static_cast((bytes[6] & 0x0fU) | 0x40U); diff --git a/projects/hipdnn/backend/src/PlatformUtils.windows.cpp b/projects/hipdnn/backend/src/PlatformUtils.windows.cpp index b89f8f7be11f..d6d88b41adbc 100644 --- a/projects/hipdnn/backend/src/PlatformUtils.windows.cpp +++ b/projects/hipdnn/backend/src/PlatformUtils.windows.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: MIT #ifdef _WIN32 +// Must precede every include: only declares rand_s when this is already defined, and +// any header that reaches it first silently leaves rand_s undeclared. #define _CRT_RAND_S #endif diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp index d0eaf7f6a624..b104063294fb 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -48,6 +49,8 @@ void GraphDescriptor::finalize() } catch(...) { + // Uncovered by tests: reaching here needs buildSerializedGraph() to fail on operations + // that already validated, which no input can force through the public API. if(needsId) { _graphId.reset(); @@ -57,6 +60,10 @@ void GraphDescriptor::finalize() HipdnnBackendDescriptorImpl::finalize(); + HIPDNN_BACKEND_LOG_INFO("Finalized graph \"{}\" with id {}", + _name, + hipdnn_flatbuffers_sdk::utilities::formatUuid(*_graphId)); + if(logging::GraphLogger::isEnabled()) { auto serialized = getSerializedGraph(); @@ -402,6 +409,14 @@ void GraphDescriptor::setAttribute(hipdnnBackendAttributeName_t attributeName, // Non-mutating attributes (e.g., HANDLE) set invalidate = false. if(invalidate) { + if(_graphId.has_value()) + { + HIPDNN_BACKEND_LOG_DEBUG( + "Discarding id {} for graph \"{}\": its contents changed, so finalizing again " + "generates a new id", + hipdnn_flatbuffers_sdk::utilities::formatUuid(*_graphId), + _name); + } invalidateCache(); _graphId.reset(); } @@ -457,6 +472,12 @@ void GraphDescriptor::deserializeGraph(const uint8_t* serializedGraph, size_t gr graph->tensors, [](const auto& tensor) { return tensor.get(); }); _name = graph->name; _graphId = graphId; + if(_graphId.has_value()) + { + HIPDNN_BACKEND_LOG_DEBUG("Deserialized graph \"{}\" with inherited id {}", + _name, + hipdnn_flatbuffers_sdk::utilities::formatUuid(*_graphId)); + } _operations = std::move(unpacked); } diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.cpp b/projects/hipdnn/backend/src/logging/GraphLogger.cpp index 94afa9c0d1b8..68b4d3fbfcb6 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.cpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.cpp @@ -12,10 +12,8 @@ #include #include #include -#include #include #include -#include namespace hipdnn_backend::logging { @@ -91,32 +89,33 @@ std::filesystem::path GraphLogger::getOutputDirectory() void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) { + flatbuffers::Verifier verifier(serializedGraph, size); + if(!hipdnn_flatbuffers_sdk::data_objects::VerifyGraphBuffer(verifier)) + { + HIPDNN_BACKEND_LOG_WARN("Not logging a graph whose buffer failed verification"); + return; + } + const auto* graph = flatbuffers::GetRoot(serializedGraph); - // Dumps are named by the graph's own ID. Re-finalizing a descriptor, or replaying a - // serialized graph, keeps that ID and so reuses one file; a graph rebuilt from scratch is a - // distinct graph object and earns its own. Naming by a content hash would also fold those - // rebuilds together, but it can collide, and a collision silently discards one graph under a - // name already claimed by another. - std::string filename; - if(graph->id() != nullptr) + // Only a finalized graph carries an ID, and a graph without one is still mutable, so its + // contents are not yet a stable thing to name. Dumps are named by that ID: re-finalizing a + // descriptor, or replaying a serialized graph, keeps it and so reuses one file, while a graph + // rebuilt from scratch is a distinct graph object and earns its own. Naming by a content hash + // would also fold rebuilds together, but it can collide, and a collision silently discards one + // graph under a name already claimed by another. + if(graph->id() == nullptr) { - filename = "graph_" - + hipdnn_flatbuffers_sdk::utilities::formatUuid( - hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id())) - + ".json"; - } - else - { - // Unreachable via finalize(), which always assigns an ID before serializing. Kept so a - // pre-finalize buffer cannot land every graph on one filename. - std::ostringstream oss; - oss << "graph_" << std::hex << std::setfill('0') << std::setw(16) - << hipdnn_data_sdk::utilities::fnv1aHash(serializedGraph, size) << ".json"; - filename = oss.str(); + HIPDNN_BACKEND_LOG_WARN("Not logging a graph without an identity; only a finalized graph " + "has one"); + return; } + const auto graphId = hipdnn_flatbuffers_sdk::utilities::formatUuid( + hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*graph->id())); + const auto graphName = graph->name() != nullptr ? graph->name()->str() : std::string(); + const auto outputDirectory = getOutputDirectory(); if(outputDirectory.empty()) { @@ -124,10 +123,13 @@ void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) return; } - const auto fullPath = outputDirectory / filename; + const auto fullPath = outputDirectory / ("graph_" + graphId + ".json"); if(std::filesystem::exists(fullPath)) { - HIPDNN_BACKEND_LOG_INFO("Skipping duplicate graph logged to {}", fullPath.string()); + HIPDNN_BACKEND_LOG_INFO("Skipping graph \"{}\" with id {}, already logged to {}", + graphName, + graphId, + fullPath.string()); return; } @@ -138,11 +140,15 @@ void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) { file << graphJson.dump(2); file.close(); - HIPDNN_BACKEND_LOG_INFO("Graph logged to {}", fullPath.string()); + HIPDNN_BACKEND_LOG_INFO( + "Writing graph \"{}\" with id {} to {}", graphName, graphId, fullPath.string()); } else { - HIPDNN_BACKEND_LOG_WARN("Failed to open graph log file: {}", fullPath.string()); + HIPDNN_BACKEND_LOG_WARN("Failed to open graph log file {} for graph \"{}\" with id {}", + fullPath.string(), + graphName, + graphId); } } diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.hpp b/projects/hipdnn/backend/src/logging/GraphLogger.hpp index 846cf28b479f..b8a1908f2dd9 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.hpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.hpp @@ -13,9 +13,10 @@ namespace hipdnn_backend::logging class GraphLogger { public: - /// Logs the graph as JSON, named by the graph's ID. Logging a graph whose ID already has a - /// file in the output directory is a no-op, so re-finalized and replayed graphs are written - /// once; a graph rebuilt from scratch has a new ID and is written separately. + /// Logs the graph as JSON, named by the graph's ID. Only a finalized graph carries an ID; + /// an unidentified or malformed buffer is not logged. Logging a graph whose ID already has + /// a file in the output directory is a no-op, so re-finalized and replayed graphs are + /// written once; a graph rebuilt from scratch has a new ID and is written separately. /// @param serializedGraph pointer to flatbuffer binary /// @param size size of the flatbuffer binary static void logGraph(const uint8_t* serializedGraph, size_t size); diff --git a/projects/hipdnn/backend/tests/TestGraphLogger.cpp b/projects/hipdnn/backend/tests/TestGraphLogger.cpp index 1cb3ec73968e..5c4e91284c30 100644 --- a/projects/hipdnn/backend/tests/TestGraphLogger.cpp +++ b/projects/hipdnn/backend/tests/TestGraphLogger.cpp @@ -79,6 +79,19 @@ class TestGraphLogger : public ::testing::Test return descriptor; } + // Same graph, stopping short of finalize, so it never receives an identity. + static GraphDescriptor buildUnfinalizedGraph(test_utilities::ConvOpBundle& bundle) + { + GraphDescriptor descriptor; + const std::array ops = {bundle.convOp.get()}; + descriptor.setAttribute(HIPDNN_ATTR_OPERATIONGRAPH_OPS, + HIPDNN_TYPE_BACKEND_DESCRIPTOR, + 1, + static_cast(ops.data())); + descriptor.buildSerializedGraph(); + return descriptor; + } + static std::vector getJsonFilesInDir(const std::filesystem::path& dir) { std::vector jsonFiles; @@ -157,6 +170,30 @@ TEST_F(TestGraphLogger, ReLoggingSameFinalizedGraphIsDeduplicated) EXPECT_EQ(getJsonFilesInDir(_tempDir).size(), 1u); } +TEST_F(TestGraphLogger, UnfinalizedGraphIsNotLogged) +{ + hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); + hipdnn_backend::logging::loggerShutdown(); + + auto bundle = test_utilities::createDefaultConvOp(HIPDNN_DATA_FLOAT); + const auto descriptor = buildUnfinalizedGraph(bundle); + const auto serialized = descriptor.getSerializedGraph(); + logging::GraphLogger::logGraph(static_cast(serialized.ptr), serialized.size); + + EXPECT_TRUE(getJsonFilesInDir(_tempDir).empty()); +} + +TEST_F(TestGraphLogger, MalformedGraphIsNotLogged) +{ + hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); + hipdnn_backend::logging::loggerShutdown(); + + const std::array garbage{}; + logging::GraphLogger::logGraph(garbage.data(), garbage.size()); + + EXPECT_TRUE(getJsonFilesInDir(_tempDir).empty()); +} + TEST_F(TestGraphLogger, DifferentGraphsLoggedSeparately) { hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index 9f36d2495a9b..ce85bf3688ac 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -1007,8 +1007,9 @@ TEST_F(TestGraphDescriptor, PreservesOpaqueSerializedGraphId) GraphDescriptor descriptor; descriptor.deserializeGraph(identified.data(), identified.size()); descriptor.buildSerializedGraph(); - ASSERT_NE(unpack(descriptor)->id, nullptr); - EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*unpack(descriptor)->id), opaqueId); + const auto roundTripped = unpack(descriptor); + ASSERT_NE(roundTripped->id, nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*roundTripped->id), opaqueId); } TEST_F(TestGraphDescriptor, JsonRoundTripPreservesUuid) diff --git a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp index f1e6c4935dfc..41be4a40160a 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp @@ -127,13 +127,16 @@ TEST(TestJson, RejectsInvalidGraphUuid) const auto* graph = GetGraph(builder.GetBufferPointer()); auto graphJson = nlohmann::json(*graph); - for(const auto& id : nlohmann::json::array({42, "not-a-uuid"})) - { - graphJson["id"] = id; - flatbuffers::FlatBufferBuilder invalidBuilder; - EXPECT_THROW((void)hipdnn_flatbuffers_sdk::json::to(invalidBuilder, graphJson), - std::exception); - } + // A non-string id fails the type check; a malformed string fails parsing. + graphJson["id"] = 42; + flatbuffers::FlatBufferBuilder nonStringBuilder; + EXPECT_THROW((void)hipdnn_flatbuffers_sdk::json::to(nonStringBuilder, graphJson), + std::runtime_error); + + graphJson["id"] = "not-a-uuid"; + flatbuffers::FlatBufferBuilder malformedBuilder; + EXPECT_THROW((void)hipdnn_flatbuffers_sdk::json::to(malformedBuilder, graphJson), + std::invalid_argument); } TEST(TestJson, GraphToJsonAndBack) From 378ec3dae92a04b1ab105723e32c5a99c98351d4 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Mon, 10 Aug 2026 22:40:01 +0000 Subject: [PATCH 11/60] test(hipdnn): assert JSON export carries graph id --- .../hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index ce85bf3688ac..1e8aa33b87c4 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -1025,6 +1025,9 @@ TEST_F(TestGraphDescriptor, JsonRoundTripPreservesUuid) const auto expectedId = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*originalGraph->id); const auto json = original.getSerializedJsonGraph(); + EXPECT_EQ(nlohmann::json::parse(json).at("id"), + hipdnn_flatbuffers_sdk::utilities::formatUuid(expectedId)); + GraphDescriptor revived; GraphDescriptor::createFromJsonGraph(revived, json.c_str(), json.size()); revived.buildSerializedGraph(); From 7dd1a502d1d5c19398ce8ffdccf95bfd1c0e8a05 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Mon, 10 Aug 2026 22:56:36 +0000 Subject: [PATCH 12/60] test(hipdnn): assert graph dumps require an id --- .../hipdnn/backend/tests/TestGraphLogger.cpp | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/projects/hipdnn/backend/tests/TestGraphLogger.cpp b/projects/hipdnn/backend/tests/TestGraphLogger.cpp index 5c4e91284c30..598e67767d01 100644 --- a/projects/hipdnn/backend/tests/TestGraphLogger.cpp +++ b/projects/hipdnn/backend/tests/TestGraphLogger.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -79,19 +80,6 @@ class TestGraphLogger : public ::testing::Test return descriptor; } - // Same graph, stopping short of finalize, so it never receives an identity. - static GraphDescriptor buildUnfinalizedGraph(test_utilities::ConvOpBundle& bundle) - { - GraphDescriptor descriptor; - const std::array ops = {bundle.convOp.get()}; - descriptor.setAttribute(HIPDNN_ATTR_OPERATIONGRAPH_OPS, - HIPDNN_TYPE_BACKEND_DESCRIPTOR, - 1, - static_cast(ops.data())); - descriptor.buildSerializedGraph(); - return descriptor; - } - static std::vector getJsonFilesInDir(const std::filesystem::path& dir) { std::vector jsonFiles; @@ -170,15 +158,17 @@ TEST_F(TestGraphLogger, ReLoggingSameFinalizedGraphIsDeduplicated) EXPECT_EQ(getJsonFilesInDir(_tempDir).size(), 1u); } -TEST_F(TestGraphLogger, UnfinalizedGraphIsNotLogged) +TEST_F(TestGraphLogger, GraphWithoutIdIsNotLogged) { hipdnn_data_sdk::utilities::setEnv("HIPDNN_LOG_GRAPH_DIR", _tempDirStr.c_str()); hipdnn_backend::logging::loggerShutdown(); - auto bundle = test_utilities::createDefaultConvOp(HIPDNN_DATA_FLOAT); - const auto descriptor = buildUnfinalizedGraph(bundle); - const auto serialized = descriptor.getSerializedGraph(); - logging::GraphLogger::logGraph(static_cast(serialized.ptr), serialized.size); + // The guard is the missing id, not the route that produced it: a legacy graph carrying no id + // reaches the logger the same way a graph serialized before finalizing does. + auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); + ASSERT_EQ(hipdnn_flatbuffers_sdk::data_objects::GetGraph(builder.GetBufferPointer())->id(), + nullptr); + logging::GraphLogger::logGraph(builder.GetBufferPointer(), builder.GetSize()); EXPECT_TRUE(getJsonFilesInDir(_tempDir).empty()); } From d112f8bc63ed969e4470503333abafd69c776841 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Mon, 10 Aug 2026 23:07:09 +0000 Subject: [PATCH 13/60] feat(hipdnn): report graph id in descriptor text --- .../backend/src/descriptors/GraphDescriptor.cpp | 3 +++ .../hipdnn/backend/src/logging/GraphLogger.cpp | 9 +++------ .../tests/descriptors/TestGraphDescriptor.cpp | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp index b104063294fb..035e2b34a7f2 100644 --- a/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp +++ b/projects/hipdnn/backend/src/descriptors/GraphDescriptor.cpp @@ -631,6 +631,9 @@ std::string GraphDescriptor::toString() const std::string str = "GraphDescriptor: {handle="; str += _handle != nullptr ? fmt::format("{:p}", static_cast(_handle)) : "null"; str += ", name=" + (_name.empty() ? std::string("(empty)") : _name); + str += ", id=" + + (_graphId.has_value() ? hipdnn_flatbuffers_sdk::utilities::formatUuid(*_graphId) + : std::string("(none)")); str += ", serializedGraphSize=" + std::to_string(_graphSerializedBuffer.size()); str += '}'; return str; diff --git a/projects/hipdnn/backend/src/logging/GraphLogger.cpp b/projects/hipdnn/backend/src/logging/GraphLogger.cpp index 68b4d3fbfcb6..208091ca7bad 100644 --- a/projects/hipdnn/backend/src/logging/GraphLogger.cpp +++ b/projects/hipdnn/backend/src/logging/GraphLogger.cpp @@ -99,12 +99,9 @@ void GraphLogger::logGraph(const uint8_t* serializedGraph, size_t size) const auto* graph = flatbuffers::GetRoot(serializedGraph); - // Only a finalized graph carries an ID, and a graph without one is still mutable, so its - // contents are not yet a stable thing to name. Dumps are named by that ID: re-finalizing a - // descriptor, or replaying a serialized graph, keeps it and so reuses one file, while a graph - // rebuilt from scratch is a distinct graph object and earns its own. Naming by a content hash - // would also fold rebuilds together, but it can collide, and a collision silently discards one - // graph under a name already claimed by another. + // Dumps are named by the graph's ID, so re-finalizing a descriptor or replaying a serialized + // graph reuses one file, while a graph rebuilt from scratch is a distinct graph object and + // gets its own. if(graph->id() == nullptr) { HIPDNN_BACKEND_LOG_WARN("Not logging a graph without an identity; only a finalized graph " diff --git a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp index 1e8aa33b87c4..ea8a0516acd8 100644 --- a/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp +++ b/projects/hipdnn/backend/tests/descriptors/TestGraphDescriptor.cpp @@ -1036,6 +1036,22 @@ TEST_F(TestGraphDescriptor, JsonRoundTripPreservesUuid) EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*revivedGraph->id), expectedId); } +TEST_F(TestGraphDescriptor, ToStringReportsGraphId) +{ + auto builder = createValidGraph(); + auto serialized = builder.Release(); + + GraphDescriptor descriptor; + descriptor.deserializeGraph(serialized.data(), serialized.size()); + EXPECT_NE(descriptor.toString().find("id=(none)"), std::string::npos); + + setHandle(descriptor); + descriptor.finalize(); + const auto id = hipdnn_flatbuffers_sdk::utilities::formatUuid( + hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*unpack(descriptor)->id)); + EXPECT_NE(descriptor.toString().find("id=" + id), std::string::npos); +} + // ============================================================================ // HIPDNN_ATTR_OPERATIONGRAPH_IS_OVERRIDE_SHAPE_ENABLED_EXT (RFC 0008) // ============================================================================ From fe743e98570baf31a3341ee98a1ade4c8079eb56 Mon Sep 17 00:00:00 2001 From: Samuel Reeder Date: Tue, 11 Aug 2026 02:34:28 +0000 Subject: [PATCH 14/60] test(hipdnn): split Uuid.hpp tests into their own file --- .../TestGraphAndPlanSerializationApi.cpp | 80 +++++++++ .../backend/tests/TestPlatformUtils.cpp | 30 ++++ .../flatbuffers_sdk/tests/CMakeLists.txt | 1 + .../tests/utilities/TestJson.cpp | 40 ----- .../tests/utilities/TestUuid.cpp | 168 ++++++++++++++++++ 5 files changed, 279 insertions(+), 40 deletions(-) create mode 100644 projects/hipdnn/flatbuffers_sdk/tests/utilities/TestUuid.cpp diff --git a/projects/hipdnn/backend/tests/TestGraphAndPlanSerializationApi.cpp b/projects/hipdnn/backend/tests/TestGraphAndPlanSerializationApi.cpp index d0e92ae46c22..7f23448e4803 100644 --- a/projects/hipdnn/backend/tests/TestGraphAndPlanSerializationApi.cpp +++ b/projects/hipdnn/backend/tests/TestGraphAndPlanSerializationApi.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -134,6 +135,32 @@ class TestGraphAndPlanSerializationApi : public ::testing::Test return createValidGraph().Release(); } + // Same shape as makeBareGraphBlob(), but with an explicit graph id - the + // container format (HDGP) is a distinct wire format from the bare graph + // blob, so id survival has to be proven through it separately. + static flatbuffers::DetachedBuffer + makeBareGraphBlobWithId(const hipdnn_flatbuffers_sdk::utilities::UuidBytes& idBytes) + { + using namespace hipdnn_flatbuffers_sdk::data_objects; + flatbuffers::FlatBufferBuilder builder; + const std::vector<::flatbuffers::Offset> tensors; + const std::vector<::flatbuffers::Offset> nodes; + const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(idBytes); + auto graphOffset = CreateGraphDirect(builder, + "test", + DataType::FLOAT, + DataType::HALF, + DataType::BFLOAT16, + &tensors, + &nodes, + flatbuffers::nullopt, + false, + nullptr, + &uuid); + builder.Finish(graphOffset); + return builder.Release(); + } + // A bare SerializedExecutionPlan FlatBuffer, i.e. NOT wrapped in an HDGP // container. flatbuffers::DetachedBuffer makeBarePlanBlob(uint32_t version = 1, @@ -331,6 +358,36 @@ TEST_F(TestGraphAndPlanSerializationApi, ComboSerializeProducesContainerWithGrap } } +TEST_F(TestGraphAndPlanSerializationApi, ComboSerializePreservesGraphId) +{ + const auto id + = hipdnn_flatbuffers_sdk::utilities::parseUuid("01234567-89ab-4def-8123-456789abcdef"); + auto graphBlob = makeBareGraphBlobWithId(id); + hipdnnBackendDescriptor_t rawGraph = nullptr; + ASSERT_EQ( + hipdnnBackendCreateAndDeserializeGraph_ext(&rawGraph, graphBlob.data(), graphBlob.size()), + HIPDNN_STATUS_SUCCESS); + const ScopedBackendDescriptor graphDesc(rawGraph); + + makeExecutionPlanFinalized(); + expectPlanSerialization(); + + size_t size = 0; + ASSERT_EQ(hipdnnBackendGetSerializedBinaryGraphAndPlan_ext( + graphDesc.get(), _planWrapper.get(), 0, &size, nullptr), + HIPDNN_STATUS_SUCCESS); + std::vector blob(size); + ASSERT_EQ(hipdnnBackendGetSerializedBinaryGraphAndPlan_ext( + graphDesc.get(), _planWrapper.get(), size, &size, blob.data()), + HIPDNN_STATUS_SUCCESS); + + const auto graphView + = hipdnn_flatbuffers_sdk::flatbuffer_utilities::extractGraphBlob(blob.data(), blob.size()); + const auto* embeddedGraph = fbs::GetGraph(static_cast(graphView.data)); + ASSERT_NE(embeddedGraph->id(), nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*embeddedGraph->id()), id); +} + TEST_F(TestGraphAndPlanSerializationApi, ComboSerializeGraphOnlyWhenPlanDescIsNull) { auto graphBlob = makeBareGraphBlob(); @@ -567,6 +624,29 @@ TEST_F(TestGraphAndPlanSerializationApi, DeserializeGraphFromContainerExtractsGr verifyDeserializedGraphMatchesSource(desc.get()); } +TEST_F(TestGraphAndPlanSerializationApi, DeserializeGraphFromContainerPreservesId) +{ + const auto id + = hipdnn_flatbuffers_sdk::utilities::parseUuid("01234567-89ab-4def-8123-456789abcdef"); + auto graphBlob = makeBareGraphBlobWithId(id); + auto planBlob = makeBarePlanBlob(); + auto container = makeContainer({graphBlob.data(), graphBlob.data() + graphBlob.size()}, + {planBlob.data(), planBlob.data() + planBlob.size()}); + + hipdnnBackendDescriptor_t rawDesc = nullptr; + ASSERT_EQ( + hipdnnBackendCreateAndDeserializeGraph_ext(&rawDesc, container.data(), container.size()), + HIPDNN_STATUS_SUCCESS); + const ScopedBackendDescriptor desc(rawDesc); + + auto graphDesc = desc->asDescriptor(); + graphDesc->buildSerializedGraph(); + auto roundTripped = graphDesc->getSerializedGraph(); + const auto* reconstructed = fbs::GetGraph(static_cast(roundTripped.ptr)); + ASSERT_NE(reconstructed->id(), nullptr); + EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*reconstructed->id()), id); +} + TEST_F(TestGraphAndPlanSerializationApi, DeserializeGraphFromLegacyBareBlobUnchanged) { auto graphBlob = makeBareGraphBlob(); diff --git a/projects/hipdnn/backend/tests/TestPlatformUtils.cpp b/projects/hipdnn/backend/tests/TestPlatformUtils.cpp index 786068373b2d..9d3debd944d0 100644 --- a/projects/hipdnn/backend/tests/TestPlatformUtils.cpp +++ b/projects/hipdnn/backend/tests/TestPlatformUtils.cpp @@ -4,10 +4,12 @@ #include #include #include +#include #include "HipdnnException.hpp" #include "PlatformUtils.hpp" #include "TestPluginConstants.hpp" +#include TEST(TestPlatformUtils, GetSystemInfoReturnsNonEmpty) { @@ -107,3 +109,31 @@ TEST(TestPlatformUtils, OpenLibraryThrowsHipdnnExceptionForMissingLibrary) hipdnn_data_sdk::utilities::getLibraryName("hipdnn_missing_test_library")), hipdnn_backend::HipdnnException); } + +TEST(TestPlatformUtils, GenerateUuidV4ProducesValidVersion4Uuid) +{ + const auto uuid = hipdnn_backend::platform_utilities::generateUuidV4(); + EXPECT_TRUE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(uuid)); +} + +TEST(TestPlatformUtils, GenerateUuidV4ProducesDistinctValues) +{ + const auto first = hipdnn_backend::platform_utilities::generateUuidV4(); + const auto second = hipdnn_backend::platform_utilities::generateUuidV4(); + EXPECT_NE(first, second); +} + +TEST(TestPlatformUtils, GenerateUuidV4ProducesManyDistinctValues) +{ + // A broken RNG (fixed seed, zeroed buffer, stale entropy pool) would collapse + // this set well below 1000; a healthy one should never collide at this scale. + constexpr int UUID_COUNT = 1000; + std::unordered_set seen; + for(int i = 0; i < UUID_COUNT; ++i) + { + const auto uuid = hipdnn_backend::platform_utilities::generateUuidV4(); + EXPECT_TRUE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(uuid)); + seen.insert(hipdnn_flatbuffers_sdk::utilities::formatUuid(uuid)); + } + EXPECT_EQ(seen.size(), static_cast(UUID_COUNT)); +} diff --git a/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt b/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt index c3e5a55d102b..2e5ed1b16ee8 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt +++ b/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable( utilities/TestFlatbufferUtils.cpp utilities/TestJson.cpp utilities/TestPointwiseValidation.cpp + utilities/TestUuid.cpp ) target_compile_options(hipdnn_flatbuffers_sdk_tests PRIVATE ${HIPDNN_WARNING_COMPILE_OPTIONS} ${HIPDNN_NO_RTTI_OPTIONS}) diff --git a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp index 41be4a40160a..68340cb4b4ff 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestJson.cpp @@ -44,46 +44,6 @@ void toJsonAndBackTestSuite(const hipdnn_flatbuffers_sdk::data_objects::Graph* g } // namespace -TEST(TestUuid, FormatsAndParsesCanonicalText) -{ - const auto bytes - = hipdnn_flatbuffers_sdk::utilities::parseUuid("01234567-89AB-4DEF-8123-456789ABCDEF"); - EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::formatUuid(bytes), - "01234567-89ab-4def-8123-456789abcdef"); - EXPECT_TRUE(hipdnn_flatbuffers_sdk::utilities::isUuidV4(bytes)); - - const auto flatbufferUuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(bytes); - EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::toUuidBytes(flatbufferUuid), bytes); -} - -TEST(TestUuid, RejectsMalformedText) -{ - const std::vector invalidIds{ - "", - "01234567-89ab-4def-8123-456789abcde", - "01234567_89ab-4def-8123-456789abcdef", - "g1234567-89ab-4def-8123-456789abcdef", - }; - - for(const auto& id : invalidIds) - { - EXPECT_THROW((void)hipdnn_flatbuffers_sdk::utilities::parseUuid(id), std::invalid_argument) - << id; - } -} - -TEST(TestUuid, ParsesOpaque128BitValues) -{ - for(const auto& id : {"01234567-89ab-3def-8123-456789abcdef", - "01234567-89ab-4def-4123-456789abcdef", - "00000000-0000-0000-0000-000000000000"}) - { - EXPECT_EQ(hipdnn_flatbuffers_sdk::utilities::formatUuid( - hipdnn_flatbuffers_sdk::utilities::parseUuid(id)), - id); - } -} - TEST(TestJson, GraphUuidRoundTripsAsCanonicalString) { auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); diff --git a/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestUuid.cpp b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestUuid.cpp new file mode 100644 index 000000000000..e4e3ce0c4e69 --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/tests/utilities/TestUuid.cpp @@ -0,0 +1,168 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include + +#include +#include +#include +#include + +using hipdnn_flatbuffers_sdk::utilities::formatUuid; +using hipdnn_flatbuffers_sdk::utilities::isUuidV4; +using hipdnn_flatbuffers_sdk::utilities::parseUuid; +using hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid; +using hipdnn_flatbuffers_sdk::utilities::toUuidBytes; +using hipdnn_flatbuffers_sdk::utilities::UuidBytes; + +namespace +{ +constexpr UuidBytes ALL_ZERO_BYTES{}; +constexpr UuidBytes ALL_ONES_BYTES{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +} // namespace + +TEST(TestUuid, FormatsAndParsesCanonicalText) +{ + const auto bytes = parseUuid("01234567-89AB-4DEF-8123-456789ABCDEF"); + EXPECT_EQ(formatUuid(bytes), "01234567-89ab-4def-8123-456789abcdef"); + EXPECT_TRUE(isUuidV4(bytes)); + + const auto flatbufferUuid = toFlatbufferUuid(bytes); + EXPECT_EQ(toUuidBytes(flatbufferUuid), bytes); +} + +TEST(TestUuid, FormatUuidLowercasesHexDigits) +{ + const UuidBytes bytes{0xAB, + 0xCD, + 0xEF, + 0x01, + 0x23, + 0x45, + 0x67, + 0x89, + 0xAB, + 0xCD, + 0xEF, + 0x01, + 0x23, + 0x45, + 0x67, + 0x89}; + EXPECT_EQ(formatUuid(bytes), "abcdef01-2345-6789-abcd-ef0123456789"); +} + +TEST(TestUuid, FormatUuidPlacesDashesAtCanonicalPositions) +{ + const std::string text = formatUuid(ALL_ZERO_BYTES); + ASSERT_EQ(text.size(), 36U); + EXPECT_EQ(text[8], '-'); + EXPECT_EQ(text[13], '-'); + EXPECT_EQ(text[18], '-'); + EXPECT_EQ(text[23], '-'); + EXPECT_EQ(text, "00000000-0000-0000-0000-000000000000"); +} + +TEST(TestUuid, FormatUuidHandlesAllOnesBoundary) +{ + EXPECT_EQ(formatUuid(ALL_ONES_BYTES), "ffffffff-ffff-ffff-ffff-ffffffffffff"); +} + +TEST(TestUuid, ParseUuidIsCaseInsensitiveAndRoundTrips) +{ + EXPECT_EQ(parseUuid("ABCDEF01-2345-6789-ABCD-EF0123456789"), + parseUuid("abcdef01-2345-6789-abcd-ef0123456789")); +} + +TEST(TestUuid, ParseUuidRejectsWrongLength) +{ + EXPECT_THROW((void)parseUuid("01234567-89ab-4def-8123-456789abcde"), std::invalid_argument); + EXPECT_THROW((void)parseUuid("01234567-89ab-4def-8123-456789abcdef0"), std::invalid_argument); + EXPECT_THROW((void)parseUuid(""), std::invalid_argument); +} + +TEST(TestUuid, ParseUuidRejectsMisplacedDashes) +{ + // Length 36, but the dash sits one column right of its canonical slot each time, + // shifting a hex digit into it. + EXPECT_THROW((void)parseUuid("01234567_89ab-4def-8123-456789abcdef"), std::invalid_argument); + EXPECT_THROW((void)parseUuid("01234567-89ab_4def-8123-456789abcdef"), std::invalid_argument); + EXPECT_THROW((void)parseUuid("01234567-89ab-4def_8123-456789abcdef"), std::invalid_argument); + EXPECT_THROW((void)parseUuid("01234567-89ab-4def-8123_456789abcdef"), std::invalid_argument); +} + +TEST(TestUuid, ParseUuidRejectsNonHexCharacters) +{ + EXPECT_THROW((void)parseUuid("g1234567-89ab-4def-8123-456789abcdef"), std::invalid_argument); + EXPECT_THROW((void)parseUuid("01234567-89ab-4def-8123-456789abcdeZ"), std::invalid_argument); + EXPECT_THROW((void)parseUuid("0123456 -89ab-4def-8123-456789abcdef"), std::invalid_argument); +} + +TEST(TestUuid, ParseUuidAcceptsAllHexDigitRanges) +{ + // Exercises every branch of parseHexDigit: '0'-'9', 'a'-'f', 'A'-'F'. + const auto bytes = parseUuid("01234567-89ab-cdEF-ABcd-ef0123456789"); + EXPECT_EQ(formatUuid(bytes), "01234567-89ab-cdef-abcd-ef0123456789"); +} + +TEST(TestUuid, ParsesOpaque128BitValuesRegardlessOfVersionOrVariant) +{ + for(const auto& id : {"01234567-89ab-3def-8123-456789abcdef", + "01234567-89ab-4def-4123-456789abcdef", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff"}) + { + EXPECT_EQ(formatUuid(parseUuid(id)), id); + } +} + +TEST(TestUuid, IsUuidV4ChecksVersionNibbleIndependentlyOfVariant) +{ + // Version nibble (byte 6 high bits) must be 0100; variant (byte 8 high bits) checked + // separately, so wrong version with a valid-looking variant still fails. + EXPECT_TRUE(isUuidV4(parseUuid("01234567-89ab-4000-8123-456789abcdef"))); + EXPECT_FALSE(isUuidV4(parseUuid("01234567-89ab-3000-8123-456789abcdef"))); + EXPECT_FALSE(isUuidV4(parseUuid("01234567-89ab-5000-8123-456789abcdef"))); +} + +TEST(TestUuid, IsUuidV4ChecksVariantNibbleIndependentlyOfVersion) +{ + // Variant nibble (byte 8 high bits) must be 10xx (0x80-0xbf); version held at v4. + EXPECT_TRUE(isUuidV4(parseUuid("01234567-89ab-4000-8123-456789abcdef"))); + EXPECT_TRUE(isUuidV4(parseUuid("01234567-89ab-4000-bfff-456789abcdef"))); + EXPECT_FALSE(isUuidV4(parseUuid("01234567-89ab-4000-0123-456789abcdef"))); + EXPECT_FALSE(isUuidV4(parseUuid("01234567-89ab-4000-c123-456789abcdef"))); +} + +TEST(TestUuid, IsUuidV4RejectsNilAndMaxUuids) +{ + EXPECT_FALSE(isUuidV4(ALL_ZERO_BYTES)); + EXPECT_FALSE(isUuidV4(ALL_ONES_BYTES)); +} + +TEST(TestUuid, ToFlatbufferUuidRoundTripsThroughEqualityOperator) +{ + const auto bytes = parseUuid("01234567-89ab-4def-8123-456789abcdef"); + const auto lhs = toFlatbufferUuid(bytes); + const auto rhs = toFlatbufferUuid(bytes); + EXPECT_EQ(lhs, rhs); + EXPECT_EQ(toUuidBytes(lhs), bytes); +} + +TEST(TestUuid, ToFlatbufferUuidPreservesByteOrder) +{ + UuidBytes bytes{}; + for(size_t i = 0; i < bytes.size(); ++i) + { + bytes[i] = static_cast(i); + } + EXPECT_EQ(toUuidBytes(toFlatbufferUuid(bytes)), bytes); +} + +TEST(TestUuid, DistinctUuidsCompareUnequal) +{ + const auto a = toFlatbufferUuid(parseUuid("01234567-89ab-4def-8123-456789abcdef")); + const auto b = toFlatbufferUuid(parseUuid("00000000-0000-0000-0000-000000000000")); + EXPECT_NE(a, b); +} From c581886bfd7c4ed532f1f8bd3bb376f144fa3705 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 15:24:17 -0400 Subject: [PATCH 15/60] feat(hipdnn): add generic kernel ingestor skeleton (ALMIOPEN-2402) Adds the structural skeleton of the RFC 0017 kernel ingestor, behind the default-OFF HIPDNN_ENABLE_KERNEL_INGESTOR flag, proven end to end for one hardcoded case: a single-node pointwise ADD over 1-element tensors. Every descriptor kind exists as a real, cross-referenced-by-id type (UED/KMD/UMD/UDD/UHD/UKD/KDP), and KernelIngestorStateManager answers the three questions hipDNN's four host calls reduce to. Nothing is loaded from a file: each UMD, UHD, and UDD names a native symbol resolved from a registry, which is the escape hatch RFC 0017 already defines for steps its declarative form cannot express. Replacing a native symbol with real descriptor data is a change to one descriptor field, not to the ingestor -- which is the property this arrangement exists to demonstrate. The generic machinery is header-only in plugin_sdk so other providers can adopt a piece without adopting the whole ingestor. It is excluded from the SDK install tree while the flag is off, so a default build's installed artifacts are unchanged. Dispatch performs a real hiprtc compile and launch rather than a recorded no-op: the parts most likely to be wrong when real kernels arrive are the compile path and uid-to-pointer argument resolution, and only a real launch exercises them. The provider's engine table gains one static row. Engine registration stays compile-time; runtime loading of engine descriptors (ALMIOPEN-2401) replaces that row with one built per installed UED file. Testing: - hipdnn_plugin_sdk_tests: 26 new tests covering the catalog cache, the registry's fail-closed lookup, ranking and its tie-break order, and the state manager's matching, pruning, caching, and validation. - hip_kernel_provider_tests: 31 new tests covering the descriptor set, both matcher shapes, workspace sizing, and a real GPU launch (gfx90a). - hip_kernel_provider_integration_tests: 9 new tests driving the plugin's C ABI through dlopen -- applicability, knob reporting, workspace, plan build, execute -- asserting c == a + b on device. - Flag off: 428 unit + 5 integration pass and the ingestor tests are absent, confirming the gate compiles the feature out entirely. JIRA ID : ALMIOPEN-2402 --- .../hip-kernel-provider/CMakeLists.txt | 5 + .../hip-kernel-provider/src/CMakeLists.txt | 8 + .../src/core/Container.cpp | 15 + .../src/ingestor_poc/CMakeLists.txt | 21 + .../src/ingestor_poc/NativeMatchers.cpp | 189 +++++ .../src/ingestor_poc/NativeMatchers.hpp | 69 ++ .../src/ingestor_poc/NativeSymbolNames.hpp | 58 ++ .../PointwiseAddDispatchHandler.cpp | 160 +++++ .../PointwiseAddDispatchHandler.hpp | 80 +++ .../src/ingestor_poc/PointwiseAddEngine.cpp | 132 ++++ .../src/ingestor_poc/PointwiseAddEngine.hpp | 35 + .../src/ingestor_poc/PointwiseAddPack.cpp | 128 ++++ .../src/ingestor_poc/PointwiseAddPack.hpp | 76 ++ .../src/ingestor_poc/kernels/PointwiseAdd.cpp | 22 + .../src/integration_tests/CMakeLists.txt | 12 + .../IntegrationGpuKernelIngestor.cpp | 595 +++++++++++++++ .../src/tests/CMakeLists.txt | 21 + .../src/tests/core/TestContainer.cpp | 4 + .../src/tests/ingestor_poc/CMakeLists.txt | 8 + .../tests/ingestor_poc/PointwiseAddGraphs.hpp | 155 ++++ .../tests/ingestor_poc/TestNativeMatchers.cpp | 229 ++++++ .../TestPointwiseAddDispatchHandler.cpp | 253 +++++++ .../ingestor_poc/TestPointwiseAddPack.cpp | 162 +++++ projects/hipdnn/CMakeLists.txt | 12 + projects/hipdnn/plugin_sdk/CMakeLists.txt | 13 + .../hipdnn_plugin_sdk/ingestor/Catalog.hpp | 34 + .../ingestor/Descriptors.hpp | 179 +++++ .../ingestor/GenericEngine.hpp | 132 ++++ .../ingestor/GenericPlan.hpp | 97 +++ .../ingestor/GenericPlanBuilder.hpp | 203 ++++++ .../ingestor/IKernelDispatchHandler.hpp | 108 +++ .../ingestor/IKernelHeuristic.hpp | 113 +++ .../ingestor/KernelDefinition.hpp | 100 +++ .../ingestor/KernelIngestorStateManager.hpp | 391 ++++++++++ .../hipdnn_plugin_sdk/ingestor/LruCache.hpp | 120 ++++ .../ingestor/MatchContext.hpp | 92 +++ .../ingestor/NativeRegistry.hpp | 148 ++++ .../hipdnn/plugin_sdk/tests/CMakeLists.txt | 1 + .../plugin_sdk/tests/TestIngestorSkeleton.cpp | 676 ++++++++++++++++++ 39 files changed, 4856 insertions(+) create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt create mode 100644 dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp create mode 100644 projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp diff --git a/dnn-providers/hip-kernel-provider/CMakeLists.txt b/dnn-providers/hip-kernel-provider/CMakeLists.txt index 3ef9034dd82b..ed9197cf3fa1 100644 --- a/dnn-providers/hip-kernel-provider/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/CMakeLists.txt @@ -50,6 +50,11 @@ option(HIPKERNELPROVIDER_ENABLE_ROCKE "Build rocKE engine and smoke tests" OFF) option(ENABLE_ASM_SDPA_ENGINE "Build the ASM SDPA engine" ON) option(ENABLE_HIP_MLOPS_ENGINE "Build the HIP kernel engine" ON) +# Gates the generic kernel ingestor POC (RFC 0017), set by TheRock's +# THEROCK_FLAG_HIPDNN_ENABLE_KERNEL_INGESTOR. The ingestor engine compiles its kernel +# through the HIP kernel engine's compiler, so it is only built alongside that engine. +option(HIPDNN_ENABLE_KERNEL_INGESTOR "Build the generic kernel ingestor (RFC 0017)" OFF) + option(BUILD_ADDRESS_SANITIZER "Build with Address Sanitizer enabled" OFF) option(ENABLE_CLANG_FORMAT "Enable Clang-Format checks" ON) if(WIN32) diff --git a/dnn-providers/hip-kernel-provider/src/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/CMakeLists.txt index 4eaab18e33ae..40e064ce8146 100644 --- a/dnn-providers/hip-kernel-provider/src/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/CMakeLists.txt @@ -14,6 +14,10 @@ add_library( add_subdirectory(engines) +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + add_subdirectory(ingestor_poc) +endif() + target_include_directories(hip_kernel_provider_impl PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_LIST_DIR}/../include @@ -46,6 +50,10 @@ if(ENABLE_HIP_MLOPS_ENGINE) target_compile_definitions(hip_kernel_provider_private PUBLIC HIPDNN_ENGINE_HIP_MLOPS=1) endif() +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + target_compile_definitions(hip_kernel_provider_private PUBLIC HIPDNN_ENABLE_KERNEL_INGESTOR=1) +endif() + # hip_kernel_provider - SHARED library (the actual plugin) add_library(hip_kernel_provider SHARED $) target_link_libraries(hip_kernel_provider PRIVATE hipdnn_data_sdk hipdnn_flatbuffers_sdk hipdnn_plugin_sdk hiprtc::hiprtc hip::host) diff --git a/dnn-providers/hip-kernel-provider/src/core/Container.cpp b/dnn-providers/hip-kernel-provider/src/core/Container.cpp index 50ae143c18cc..c9fcd419c7f6 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Container.cpp +++ b/dnn-providers/hip-kernel-provider/src/core/Container.cpp @@ -20,6 +20,11 @@ #include "engines/asm_sdpa_engine/plans/SdpaFwdPlanBuilder.hpp" #endif +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +#include "ingestor_poc/PointwiseAddEngine.hpp" +#include "ingestor_poc/PointwiseAddPack.hpp" +#endif + #include #include #include @@ -64,6 +69,16 @@ const std::vector& Container::getEngineDefinitions( engine->addPlanBuilder(std::make_unique()); return engine; }}, +#endif +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + // The kernel-ingestor POC's engine (RFC 0017). Registered statically here + // because engines are still a compile-time table; runtime loading of engine + // descriptors replaces this entry with one built per installed UED file. + {ingestor_poc::pointwiseAddEngineId(), + [](const device::IDevicePropertyProvider& devicePropertyProvider) + -> std::unique_ptr> { + return ingestor_poc::makePointwiseAddEngine(devicePropertyProvider); + }}, #endif }; diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt new file mode 100644 index 000000000000..bf7efb6955ee --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt @@ -0,0 +1,21 @@ +# Copyright © Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +# Embed this pack's kernel source so it can be compiled at plan-build time. +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/KernelEmbedding.cmake") + +add_kernels_for_embedding( + TARGET hip_kernel_provider_impl FILES ${CMAKE_CURRENT_SOURCE_DIR}/kernels/PointwiseAdd.cpp +) + +target_sources(hip_kernel_provider_impl PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/NativeMatchers.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/PointwiseAddDispatchHandler.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/PointwiseAddEngine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/PointwiseAddPack.cpp +) + +# The sources above are entirely inside `#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR`, so the +# definition must reach the object library that compiles them, not only the static +# library the tests link. This mirrors how each engine sets its own gate. +target_compile_definitions(hip_kernel_provider_impl PUBLIC HIPDNN_ENABLE_KERNEL_INGESTOR=1) diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp new file mode 100644 index 000000000000..1ba02dbd79c5 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp @@ -0,0 +1,189 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include "ingestor_poc/NativeMatchers.hpp" + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include +#include +#include +#include + +#include "ingestor_poc/NativeSymbolNames.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +using namespace hipdnn_plugin_sdk::ingestor; +namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; + +namespace +{ + +const data_objects::TensorAttributes* findTensor(const MatchContext& context, int64_t uid) +{ + const auto& tensors = context.graph.getTensorMap(); + auto it = tensors.find(uid); + return it == tensors.end() ? nullptr : it->second; +} + +/// The tensor ranks this pack accepts. Not a property of the kernel, which indexes one +/// element and is indifferent to rank, but of the dispatch path: the provider's compile +/// options derive layout from the tensor and reject anything outside this range. +/// +/// Matching has to respect that. Accepting a graph commits the engine to producing a +/// launchable kernel for it, so a matcher that admits a rank dispatch cannot serve turns +/// a free decline into a failed plan build the caller pays for. +constexpr uint32_t MIN_SUPPORTED_RANK = 4; +constexpr uint32_t MAX_SUPPORTED_RANK = 5; + +/// True when the tensor is a supported rank holding exactly one element -- the whole of +/// this POC's supported problem space. +bool isSingleElement(const data_objects::TensorAttributes& tensor) +{ + const auto* dims = tensor.dims(); + if(dims == nullptr || dims->size() < MIN_SUPPORTED_RANK || dims->size() > MAX_SUPPORTED_RANK) + { + return false; + } + + int64_t elements = 1; + for(const auto dim : *dims) + { + elements *= dim; + } + return elements == 1; +} + +/// The graph's element type, taken from the first input. The matcher below requires +/// every operand to agree, so any of them would answer the same. +std::optional graphDataType(const MatchContext& context) +{ + if(context.graph.nodeCount() != 1) + { + return std::nullopt; + } + + const auto& node = context.graph.getNodeWrapper(0); + if(node.attributesType() != data_objects::NodeAttributes::PointwiseAttributes) + { + return std::nullopt; + } + + const auto& attributes = node.attributesAs(); + const auto* input = findTensor(context, attributes.in_0_tensor_uid()); + if(input == nullptr) + { + return std::nullopt; + } + return input->data_type(); +} + +std::string dataTypeName(data_objects::DataType dataType) +{ + return data_objects::EnumNameDataType(dataType); +} + +} // namespace + +bool pointwiseAddGraphMatches(const MatchContext& context) +{ + // Exactly one node: a prebuilt kernel serves one complete graph, so anything larger + // is a different problem even if it contains this one. + if(context.graph.nodeCount() != 1) + { + return false; + } + + const auto& node = context.graph.getNodeWrapper(0); + if(node.attributesType() != data_objects::NodeAttributes::PointwiseAttributes) + { + return false; + } + + const auto& attributes = node.attributesAs(); + if(attributes.operation() != data_objects::PointwiseMode::ADD) + { + return false; + } + + // Binary add: a second operand is required, a third would be a different operation. + if(!attributes.in_1_tensor_uid().has_value() || attributes.in_2_tensor_uid().has_value()) + { + return false; + } + + const auto* inputA = findTensor(context, attributes.in_0_tensor_uid()); + const auto* inputB = findTensor(context, attributes.in_1_tensor_uid().value()); + const auto* output = findTensor(context, attributes.out_0_tensor_uid()); + if(inputA == nullptr || inputB == nullptr || output == nullptr) + { + return false; + } + + // One element each: this POC's kernel indexes element 0 and nothing else. + if(!isSingleElement(*inputA) || !isSingleElement(*inputB) || !isSingleElement(*output)) + { + return false; + } + + // Uniform dtype across operands. Mixed-precision add is a different kernel, and + // accepting it here would hand one binary operands it cannot read. + return inputA->data_type() == inputB->data_type() && inputA->data_type() == output->data_type(); +} + +bool pointwiseAddKernelMatches(const MatchContext& context, const KernelDefinition& kernel) +{ + const auto dataType = graphDataType(context); + if(!dataType.has_value()) + { + return false; + } + + // Pins the kernel's baked element type against the graph's. Without this, the + // graph-level gate would accept an f32 graph and selection could hand it to the + // f16 kernel, which returns wrong numbers rather than failing. + return kernel.getStringMetadata(std::string(DTYPE_FIELD)) == dataTypeName(*dataType); +} + +double pointwiseAddScore(const KernelDefinition& kernel, const MatchContext& /*context*/) +{ + // A stand-in for a trained model: prefer the larger block size. It gives ranking a + // defined, inspectable winner without pretending to be a performance judgement — + // for a one-element add, no block size is actually better. + return static_cast(kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD))); +} + +PointwiseAddBinding pointwiseAddBinding(const MatchContext& context) +{ + if(!pointwiseAddGraphMatches(context)) + { + throw hipdnn_plugin_sdk::HipdnnPluginException( + HIPDNN_PLUGIN_STATUS_BAD_PARAM, "graph is not a single-node 1-element pointwise add"); + } + + const auto& attributes + = context.graph.getNodeWrapper(0).attributesAs(); + + return {attributes.in_0_tensor_uid(), + attributes.in_1_tensor_uid().value(), + attributes.out_0_tensor_uid()}; +} + +void registerPointwiseAddMatchers() +{ + GraphMatcherRegistry::registerSymbol(std::string(GRAPH_MATCHER_SYMBOL), + &pointwiseAddGraphMatches); + KernelMatcherRegistry::registerSymbol(std::string(KERNEL_MATCHER_SYMBOL), + &pointwiseAddKernelMatches); + ScoreRegistry::registerSymbol(std::string(SCORE_SYMBOL), &pointwiseAddScore); +} + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp new file mode 100644 index 000000000000..9e379d30805c --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp @@ -0,0 +1,69 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include + +namespace hip_kernel_provider::ingestor_poc +{ + +/** + * @brief Graph-scoped applicability: is this a single-node pointwise ADD over + * 1-element tensors? + * + * Reads only graph facts, so the ingestor evaluates it once per (graph, device) and one + * failure disqualifies every kernel in the pack without any per-kernel work. + */ +bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context); + +/** + * @brief Kernel-scoped applicability: does this kernel's dtype match the graph's? + * + * Reads kernel metadata, so it is evaluated once per candidate and disqualifies that + * candidate alone. + * + * This check is what a prebuilt-kernel system cannot omit: the graph-level gate above + * accepts any dtype the pack serves, so without pinning the kernel's baked dtype against + * the graph's, an f32 graph could be handed to an f16 binary — which does not fail + * outright, it silently returns wrong numbers. + */ +bool pointwiseAddKernelMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context, + const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel); + +/// @brief Ranks kernels for a pointwise add. +double pointwiseAddScore(const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel, + const hipdnn_plugin_sdk::ingestor::MatchContext& context); + +/// @brief The tensor uids a matched pointwise-add graph binds, in argument order. +struct PointwiseAddBinding +{ + int64_t inputA = 0; + int64_t inputB = 0; + int64_t output = 0; +}; + +/** + * @brief Re-reads the operand bindings a match established. + * + * The matcher proves the graph is a pointwise add and, in doing so, identifies which + * tensor is which. Dispatch needs those same uids to bind its arguments, so it asks for + * them here rather than re-deriving them from the graph with a second, possibly + * divergent, notion of what the graph shape is. + * + * @throws HipdnnPluginException if the graph is not one this matcher accepts. + */ +PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::MatchContext& context); + +/// @brief Registers this pack's matchers and scorer under their symbol names. +/// +/// Called once, from the pack's translation unit, rather than at static-init time: an +/// engine that is never constructed should not have mutated process-wide registries. +void registerPointwiseAddMatchers(); + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp new file mode 100644 index 000000000000..cc6797fc2f45 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp @@ -0,0 +1,58 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +namespace hip_kernel_provider::ingestor_poc +{ + +/** + * @file NativeSymbolNames.hpp + * @brief The symbol names this pack's descriptors resolve through. + * + * A descriptor names a native implementation by symbol, and the provider registers that + * implementation under the same name. These constants are that contract, written once so + * the descriptor side and the implementation side cannot drift apart. + * + * Each of these is a place a declarative descriptor field goes once the follow-up RFCs + * land: the matcher symbols become criteria expressions, the score symbol becomes a + * model artifact and its feature signature, and the dispatch symbol becomes grid, block, + * workspace, and argument-signature formulas. + */ + +/// Graph-scoped applicability: a single-node pointwise ADD over 1-element tensors. +inline constexpr std::string_view GRAPH_MATCHER_SYMBOL = "pointwise_add_poc.graph_match"; + +/// Kernel-scoped applicability: the kernel's dtype must be the graph's dtype. +inline constexpr std::string_view KERNEL_MATCHER_SYMBOL = "pointwise_add_poc.kernel_match"; + +/// Kernel-selection score. +inline constexpr std::string_view SCORE_SYMBOL = "pointwise_add_poc.score"; + +/// Workspace sizing and launch. +inline constexpr std::string_view DISPATCH_SYMBOL = "pointwise_add_poc.dispatch"; + +/** + * @brief The engine name, hashed into hipDNN's engine-id space. + * + * Scoped with a prefix, as RFC 0017 requires of a descriptor-backed engine: names must + * be globally unique because they hash into a shared id space, and a scope is what makes + * that tractable without a central allocation authority. + * + * Deliberately absent from EngineNames.hpp's registry. A descriptor-backed engine is + * defined by data, so it registers its name when it is constructed rather than at build + * time — which is the behavior this pack exists to demonstrate. + */ +inline constexpr std::string_view ENGINE_NAME = "kernel_ingestor_poc:PointwiseAdd"; + +/// The KMD fields this engine's kernels vary along. +inline constexpr std::string_view BLOCK_SIZE_FIELD = "block_size"; +inline constexpr std::string_view DTYPE_FIELD = "dtype"; + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp new file mode 100644 index 000000000000..ff1099df73cd --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp @@ -0,0 +1,160 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include "ingestor_poc/PointwiseAddDispatchHandler.hpp" + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include +#include +#include + +#include "compilation/KernelCompileOptions.hpp" +#include "ingestor_poc/NativeMatchers.hpp" +#include "ingestor_poc/NativeSymbolNames.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +using namespace hipdnn_plugin_sdk::ingestor; +namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; + +namespace +{ + +/// Scratch reported by the larger-block kernel. Arbitrary: its only job is to make the +/// engine's max-across-survivors answer observably non-zero. +constexpr size_t LARGE_BLOCK_WORKSPACE_BYTES = 1024; +constexpr int64_t LARGE_BLOCK_SIZE = 256; + +/// The compiled kernel plus the operand uids it launches with: everything read from the +/// graph, resolved once, owning nothing that points back into it. +class PreparedPointwiseAdd : public PreparedDispatch +{ +public: + PreparedPointwiseAdd(std::unique_ptr program, + std::unique_ptr kernel, + PointwiseAddBinding binding) + : _program(std::move(program)) + , _kernel(std::move(kernel)) + , _binding(binding) + { + } + + const compilation::IRunnableKernel& kernel() const + { + return *_kernel; + } + + const PointwiseAddBinding& binding() const + { + return _binding; + } + +private: + // The runnable kernel is a view into its program's module, so the program must + // outlive it; both are held here for the plan's lifetime. + std::unique_ptr _program; + std::unique_ptr _kernel; + PointwiseAddBinding _binding; +}; + +/// The C++ type the kernel is compiled for, from the kernel's dtype metadata. +std::string elementTypeFor(const KernelDefinition& kernel) +{ + const auto& dtype = kernel.getStringMetadata(std::string(DTYPE_FIELD)); + if(dtype == "FLOAT") + { + return "float"; + } + if(dtype == "HALF") + { + return "_Float16"; + } + + // Unreachable via matching, which admits only dtypes this pack declares. Reported + // rather than defaulted: a silent fallback would compile the wrong kernel. + throw hipdnn_plugin_sdk::HipdnnPluginException( + HIPDNN_PLUGIN_STATUS_BAD_PARAM, + "kernel '" + kernel.kernelId + "' declares unsupported dtype '" + dtype + "'"); +} + +const data_objects::TensorAttributes& firstInput(const MatchContext& context, + const PointwiseAddBinding& binding) +{ + const auto& tensors = context.graph.getTensorMap(); + auto it = tensors.find(binding.inputA); + if(it == tensors.end() || it->second == nullptr) + { + throw hipdnn_plugin_sdk::HipdnnPluginException( + HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, + "matched pointwise add has no tensor for uid " + std::to_string(binding.inputA)); + } + return *it->second; +} + +} // namespace + +size_t PointwiseAddDispatchHandler::workspaceBytes(const KernelDefinition& kernel) const +{ + return kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD)) == LARGE_BLOCK_SIZE + ? LARGE_BLOCK_WORKSPACE_BYTES + : 0; +} + +std::unique_ptr + PointwiseAddDispatchHandler::prepare(const MatchContext& context, + const KernelDefinition& kernel) const +{ + // Re-reads the operand uids the matcher bound, rather than re-deriving them here + // with a second notion of what this graph looks like. + const auto binding = pointwiseAddBinding(context); + + const auto blockSize + = static_cast(kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD))); + + compilation::KernelCompileOptions options(&firstInput(context, binding), _deviceProperties); + options.add("HIP_PLUGIN_POINTWISE_ADD_TYPE", elementTypeFor(kernel)); + options.add("HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE", blockSize); + + auto program = _kernelCompiler.compile(kernel.sourceFile, options); + auto runnableKernel = program->getKernel(kernel.entryPoint); + + // One element, so one workgroup; the block size comes from kernel metadata, which is + // how a per-kernel launch quantity reaches a dispatch descriptor shared by the pack. + runnableKernel->setBlockSize(blockSize, 1, 1); + runnableKernel->setGridSize(1, 1, 1); + + return std::make_unique( + std::move(program), std::move(runnableKernel), binding); +} + +void PointwiseAddDispatchHandler::launch(const Handle& handle, + const PreparedDispatch& prepared, + const hipdnnPluginDeviceBuffer_t* deviceBuffers, + uint32_t numDeviceBuffers, + void* /*workspace*/) const +{ + const auto& preparedAdd = dynamic_cast(prepared); + const auto& binding = preparedAdd.binding(); + + const auto inputA + = hipdnn_plugin_sdk::findDeviceBuffer(binding.inputA, deviceBuffers, numDeviceBuffers); + const auto inputB + = hipdnn_plugin_sdk::findDeviceBuffer(binding.inputB, deviceBuffers, numDeviceBuffers); + const auto output + = hipdnn_plugin_sdk::findDeviceBuffer(binding.output, deviceBuffers, numDeviceBuffers); + + preparedAdd.kernel().launch(handle.getStream(), inputA.ptr, inputB.ptr, output.ptr); +} + +void registerPointwiseAddDispatch(const PointwiseAddDispatchHandler& handler) +{ + DispatchRegistry::registerSymbol(std::string(DISPATCH_SYMBOL), &handler); +} + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp new file mode 100644 index 000000000000..9a34bdba4b7d --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp @@ -0,0 +1,80 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include +#include + +#include "compilation/IKernelCompiler.hpp" +#include "core/Handle.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +/** + * @brief The native dispatch behind this POC's UDD: sizes and launches a pointwise add. + * + * Holds the provider machinery a launch needs — a kernel compiler and the device + * properties it compiles against — which is exactly why this interface exists: the SDK + * never names either of them. + * + * The work splits the way RFC 0017 §8.5 describes. Everything derived from the graph and + * the chosen kernel (which uids the arguments bind to, the launch geometry, the compiled + * kernel itself) resolves once at plan build; execute afterwards only resolves device + * pointers by uid and launches. A plan may execute concurrently from several threads, so + * nothing here mutates after preparation. + */ +class PointwiseAddDispatchHandler + : public hipdnn_plugin_sdk::ingestor::IKernelDispatchHandler +{ +public: + /// @param kernelCompiler Owned by the engine, which outlives this handler. + /// @param deviceProperties Copied: the provider hands these out by value. + PointwiseAddDispatchHandler(const compilation::IKernelCompiler& kernelCompiler, + hipDeviceProp_t deviceProperties) + : _kernelCompiler(kernelCompiler) + , _deviceProperties(deviceProperties) + { + } + + /** + * @brief Scratch this kernel requires. + * + * A one-element add needs none. The 256-block kernel nonetheless reports a non-zero + * requirement so the engine's "maximum across surviving kernels" is observably a + * maximum rather than a constant zero — the workspace path is otherwise + * indistinguishable from not being wired up at all. + */ + size_t + workspaceBytes(const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel) const override; + + std::unique_ptr + prepare(const hipdnn_plugin_sdk::ingestor::MatchContext& context, + const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel) const override; + + void launch(const Handle& handle, + const hipdnn_plugin_sdk::ingestor::PreparedDispatch& prepared, + const hipdnnPluginDeviceBuffer_t* deviceBuffers, + uint32_t numDeviceBuffers, + void* workspace) const override; + +private: + const compilation::IKernelCompiler& _kernelCompiler; + hipDeviceProp_t _deviceProperties; +}; + +/// @brief Registers @p handler under this pack's dispatch symbol. +/// +/// The handler is registered by pointer and must outlive every plan built from it. +void registerPointwiseAddDispatch(const PointwiseAddDispatchHandler& handler); + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp new file mode 100644 index 000000000000..21ae7ba08496 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp @@ -0,0 +1,132 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include "ingestor_poc/PointwiseAddEngine.hpp" + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include + +#include +#include + +#include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" +#include "ingestor_poc/NativeMatchers.hpp" +#include "ingestor_poc/PointwiseAddDispatchHandler.hpp" +#include "ingestor_poc/PointwiseAddPack.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +namespace +{ + +int currentDeviceId() +{ + int deviceId = 0; + if(hipGetDevice(&deviceId) != hipSuccess) + { + // Catalogs are cached per device, so failing to resolve one is not fatal: + // device 0 keys the cache consistently and matching is unaffected. + return 0; + } + return deviceId; +} + +/** + * @brief Registers this pack's native implementations, then builds its state manager. + * + * Order matters: assembling the state manager resolves the heuristic's score symbol, so + * the implementations must already be registered. + * + * Registration deliberately does not run at static-init time. An engine that is never + * constructed should not have mutated process-wide registries, and a duplicate-symbol + * throw from a global constructor would escape during dlopen() and terminate the + * process; from here it surfaces as a failed plugin creation the host can report. + */ +std::shared_ptr> + registerThenBuildStateManager(const PointwiseAddDispatchHandler& dispatchHandler) +{ + static std::once_flag s_registered; + std::call_once(s_registered, [&dispatchHandler]() { + registerPointwiseAddMatchers(); + registerPointwiseAddDispatch(dispatchHandler); + }); + + return makePointwiseAddStateManager(); +} + +/** + * @brief A generic engine plus the provider-owned machinery its kernels reach through + * the native registry. + * + * The registry holds the dispatch handler by pointer, so something must own that handler + * for as long as any plan built from it can execute. That is this type: the compiler and + * handler are declared before the engine, so they are constructed first and destroyed + * last, and the engine's plans never outlive the engine. + */ +class PointwiseAddEngine : public hipdnn_plugin_sdk::IEngine +{ +public: + explicit PointwiseAddEngine(const device::IDevicePropertyProvider& devicePropertyProvider) + : _dispatchHandler(_kernelCompiler, devicePropertyProvider.getDeviceProperties()) + , _engine(registerThenBuildStateManager(_dispatchHandler), + devicePropertyProvider.getDeviceProperties(), + currentDeviceId()) + { + } + + int64_t id() const override + { + return _engine.id(); + } + + bool isApplicable( + Handle& handle, + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph) const override + { + return _engine.isApplicable(handle, opGraph); + } + + void getDetails(Handle& handle, + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph, + hipdnnPluginConstData_t& detailsOut) const override + { + _engine.getDetails(handle, opGraph, detailsOut); + } + + size_t getMaxWorkspaceSize(const Handle& handle, + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph, + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig& + engineConfig) const override + { + return _engine.getMaxWorkspaceSize(handle, opGraph, engineConfig); + } + + void initializeExecutionContext( + const Handle& handle, + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph, + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig& engineConfig, + Context& executionContext) const override + { + _engine.initializeExecutionContext(handle, opGraph, engineConfig, executionContext); + } + +private: + HipMlopsKernelCompiler _kernelCompiler; + PointwiseAddDispatchHandler _dispatchHandler; + hipdnn_plugin_sdk::ingestor::GenericEngine _engine; +}; + +} // namespace + +std::unique_ptr> + makePointwiseAddEngine(const device::IDevicePropertyProvider& devicePropertyProvider) +{ + return std::make_unique(devicePropertyProvider); +} + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp new file mode 100644 index 000000000000..6a097d541fdb --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp @@ -0,0 +1,35 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +#include "core/Context.hpp" +#include "core/Handle.hpp" +#include "core/Settings.hpp" +#include "device/IDevicePropertyProvider.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +/** + * @brief Builds the descriptor-backed pointwise-add engine. + * + * Registers this pack's native implementations, assembles its descriptor set, and + * returns a generic engine driven entirely by that data. The engine owns the kernel + * compiler and dispatch handler its kernels need, so both outlive every plan it builds. + * + * This function is what a UED loader replaces: given descriptor files, the same generic + * engine is constructed from parsed data instead of from a hardcoded pack. + */ +std::unique_ptr> + makePointwiseAddEngine(const device::IDevicePropertyProvider& devicePropertyProvider); + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp new file mode 100644 index 000000000000..a9e49ff23921 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp @@ -0,0 +1,128 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include "ingestor_poc/PointwiseAddPack.hpp" + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include +#include +#include + +#include "ingestor_poc/NativeSymbolNames.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +using namespace hipdnn_plugin_sdk::ingestor; + +namespace +{ + +// Descriptor ids. Real descriptors carry GUIDs so any author can mint one locally +// without colliding with another's; readable names serve the same role for a pack that +// is built in memory and never shared. +constexpr const char* ENGINE_ID = "poc.ued.pointwise_add"; +constexpr const char* SCHEMA_ID = "poc.kmd.pointwise_add"; +constexpr const char* HEURISTIC_ID = "poc.uhd.pointwise_add"; +constexpr const char* GRAPH_MATCHER_ID = "poc.umd.pointwise_add.graph"; +constexpr const char* KERNEL_MATCHER_ID = "poc.umd.pointwise_add.kernel"; +constexpr const char* DISPATCH_ID = "poc.udd.pointwise_add"; +constexpr const char* PACK_ID = "poc.kdp.pointwise_add"; + +KernelDescriptor + makeKernel(const std::string& id, int64_t blockSize, const std::string& dtype, int64_t priority) +{ + KernelDescriptor kernel; + kernel.id = id; + kernel.name = "pointwise add (" + dtype + ", block " + std::to_string(blockSize) + ")"; + kernel.sourceFile = "PointwiseAdd.cpp"; + kernel.entryPoint = "PointwiseAdd"; + kernel.metadata + = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; + kernel.priority = priority; + return kernel; +} + +} // namespace + +PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() +{ + PointwiseAddDescriptorSet set; + + set.schema.id = SCHEMA_ID; + set.schema.name = "pointwise add variant fields"; + set.schema.fields = {{std::string(BLOCK_SIZE_FIELD), int64_t{64}}, + {std::string(DTYPE_FIELD), std::string{"FLOAT"}}}; + + set.heuristic.id = HEURISTIC_ID; + set.heuristic.name = "pointwise add selector"; + set.heuristic.scoreSymbol = SCORE_SYMBOL; + + set.engine.id = ENGINE_ID; + set.engine.name = ENGINE_NAME; + set.engine.heuristicId = HEURISTIC_ID; + set.engine.metadataSchemaId = SCHEMA_ID; + // block_size is exposed to the caller; dtype is not, because it is pinned by the + // graph rather than chosen. Exposing it would offer a choice nothing can serve. + set.engine.knobs = {std::string(BLOCK_SIZE_FIELD)}; + + set.matchers = { + {GRAPH_MATCHER_ID, + "single-node pointwise add over 1-element tensors", + MatchScope::GRAPH, + std::string(GRAPH_MATCHER_SYMBOL)}, + {KERNEL_MATCHER_ID, + "kernel dtype matches the graph's dtype", + MatchScope::KERNEL, + std::string(KERNEL_MATCHER_SYMBOL)}, + }; + + set.dispatches = { + {DISPATCH_ID, "pointwise add dispatch", std::string(DISPATCH_SYMBOL)}, + }; + + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "kernel_ingestor_poc:pointwise_add"; + pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + // Three kernels, each earning its place: the two FLOAT entries differ only in block + // size, so ranking has an order to produce and the block_size knob has a real value + // set; the HALF entry is what the kernel-scoped matcher prunes on a FLOAT graph. + pack.kernels = {makeKernel("poc.ukd.add_f32_block64", 64, "FLOAT", 0), + makeKernel("poc.ukd.add_f32_block256", 256, "FLOAT", 0), + makeKernel("poc.ukd.add_f16_block64", 64, "HALF", 0)}; + set.packs = {std::move(pack)}; + + return set; +} + +int64_t pointwiseAddEngineId() +{ + // Registers the engine name against its id on first call, so diagnostics can print + // a name instead of a hex value and a colliding descriptor is named. See the header + // for why this is a function-local static rather than a namespace-scope object. + static const hipdnn_data_sdk::utilities::EngineRegistrar s_registrar{ENGINE_NAME}; + return hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); +} + +std::shared_ptr> makePointwiseAddStateManager() +{ + auto set = buildPointwiseAddDescriptorSet(); + + return std::make_shared>( + std::move(set.engine), + std::move(set.schema), + std::move(set.matchers), + std::move(set.dispatches), + std::move(set.packs), + std::make_shared(ScoreRegistry::resolve(std::string(SCORE_SYMBOL)))); +} + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp new file mode 100644 index 000000000000..c61d2267acb1 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp @@ -0,0 +1,76 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include +#include + +#include "core/Handle.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +/** + * @file PointwiseAddPack.hpp + * @brief The descriptor set the kernel-ingestor POC ships, built in memory. + * + * Stands in for what a loader will produce from installed files: one engine (UED) with + * its metadata schema (KMD) and heuristic (UHD), two matchers (UMDs), one dispatch + * descriptor (UDD), and one pack (KDP) binding them over three kernels (UKDs). + * + * Nothing here is loaded, parsed, or validated against a schema — that is ALMIOPEN-2401 + * and the packaging follow-ups. What this demonstrates is that once descriptors exist in + * memory, everything downstream of them works: matching, pruning, ranking, knob + * reporting, workspace sizing, and launch are all driven by this data. + * + * The three kernels are chosen so each of those steps has something real to do: + * two differ only in block size, so ranking has an order to produce and the knob has a + * value set of two; the third targets a different dtype, so the kernel-scoped matcher + * has something to prune. + */ + +/// A pack plus every descriptor it references by id, ready to construct a state manager. +struct PointwiseAddDescriptorSet +{ + hipdnn_plugin_sdk::ingestor::EngineDescriptor engine; + hipdnn_plugin_sdk::ingestor::MetadataSchema schema; + std::vector matchers; + std::vector dispatches; + std::vector packs; + hipdnn_plugin_sdk::ingestor::HeuristicDescriptor heuristic; +}; + +/// @brief Builds this POC's descriptor set. +PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); + +/** + * @brief This engine's hipDNN engine id, registering its name on first call. + * + * A descriptor-backed engine's id is its UED name hashed into hipDNN's engine-id space, + * the same derivation a hand-written engine's registered name goes through. Because the + * engine is defined by data rather than by a compile-time registration macro, the name + * is registered at run time. + * + * Registration is deliberately reached through a function-local static rather than a + * namespace-scope object. The registrar throws on a name or hash collision, and a throw + * from a namespace-scope initializer would escape a global constructor during dlopen() + * and terminate the process. Reached from here — called by the engine table, which runs + * inside the plugin's create entry point — the same collision surfaces as a failed + * plugin creation the host can report. + */ +int64_t pointwiseAddEngineId(); + +/// @brief Builds the state manager backing this POC's engine. +std::shared_ptr> + makePointwiseAddStateManager(); + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp new file mode 100644 index 000000000000..00d9f9f5fa85 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp @@ -0,0 +1,22 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +// The kernel behind the kernel-ingestor POC's descriptor pack. Deliberately the +// narrowest useful operation: elementwise add over a single-element tensor. +// +// HIP_PLUGIN_POINTWISE_ADD_TYPE is the element type, supplied per compilation from the +// kernel descriptor's dtype metadata. HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE is the block +// size the descriptor selected; the kernel does not read it, because one element needs +// one thread. It exists so the ingestor's ranking, knob reporting, and per-kernel +// dispatch have a metadata field that genuinely varies between kernels and genuinely +// reaches the compiler, rather than a field that only tests observe. + +extern "C" __global__ void PointwiseAdd(const HIP_PLUGIN_POINTWISE_ADD_TYPE* a, + const HIP_PLUGIN_POINTWISE_ADD_TYPE* b, + HIP_PLUGIN_POINTWISE_ADD_TYPE* c) +{ + if(blockIdx.x == 0 && threadIdx.x == 0) + { + c[0] = a[0] + b[0]; + } +} diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt index 39bb59890104..923896cd71e2 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt @@ -70,6 +70,18 @@ if(ENABLE_HIP_MLOPS_ENGINE) hip_mlops_engine/IntegrationGpuPassByValue.cpp ) endif() + +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + target_sources(hip_kernel_provider_integration_tests PRIVATE + ingestor_poc/IntegrationGpuKernelIngestor.cpp + ) + # This target links only the SDKs, not hip_kernel_provider_private, so it does not + # inherit the provider's gate and needs the definition set directly. + target_compile_definitions(hip_kernel_provider_integration_tests + PRIVATE HIPDNN_ENABLE_KERNEL_INGESTOR=1 + ) +endif() + clang_tidy_check(hip_kernel_provider_integration_tests) # ENVIRONMENT is forwarded to add_integration_test_target() rather than set diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp b/dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp new file mode 100644 index 000000000000..3bee3a282119 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp @@ -0,0 +1,595 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @file IntegrationGpuKernelIngestor.cpp + * @brief The kernel ingestor, end to end across the plugin ABI. + * + * Loads the real built plugin and drives every call hipDNN makes, in the order it makes + * them: applicability, engine details, workspace, execution context, execute. Nothing + * here links the provider's internals — the only surface used is the C ABI a host would + * use — so this is what proves the descriptor set, the matchers, the heuristic, the + * dispatch handler, and the engine registration actually compose. + * + * The unit tests cover each of those pieces in isolation. What they cannot show is that + * the pieces agree with one another through the ABI, which is the whole claim of a + * data-driven ingestor. + */ +namespace hip_kernel_provider::ingestor_poc::integration +{ + +namespace +{ + +namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; + +constexpr int64_t INPUT_A_UID = 1; +constexpr int64_t INPUT_B_UID = 2; +constexpr int64_t OUTPUT_UID = 3; +constexpr const char* ENGINE_NAME = "kernel_ingestor_poc:PointwiseAdd"; +constexpr const char* BLOCK_SIZE_KNOB = "block_size"; + +/// Workspace the pack's larger-block kernel declares, which the engine reports as the +/// maximum across its surviving kernels. +constexpr size_t EXPECTED_WORKSPACE_BYTES = 1024; + +/// A serialized single-node pointwise-ADD graph over 1-element 4-D FLOAT tensors: the +/// one shape this POC's pack accepts. +flatbuffers::DetachedBuffer buildPointwiseAddGraph(bool withIdentity = true) +{ + flatbuffers::FlatBufferBuilder builder; + const std::vector dims = {1, 1, 1, 1}; + const std::vector strides = {1, 1, 1, 1}; + + std::vector> tensors; + for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID}) + { + tensors.push_back(data_objects::CreateTensorAttributesDirect( + builder, uid, nullptr, data_objects::DataType::FLOAT, &strides, &dims, false)); + } + + data_objects::PointwiseAttributesBuilder attributesBuilder(builder); + attributesBuilder.add_operation(data_objects::PointwiseMode::ADD); + attributesBuilder.add_in_0_tensor_uid(INPUT_A_UID); + attributesBuilder.add_in_1_tensor_uid(INPUT_B_UID); + attributesBuilder.add_out_0_tensor_uid(OUTPUT_UID); + auto attributes = attributesBuilder.Finish(); + + std::vector> nodes; + nodes.push_back( + data_objects::CreateNodeDirect(builder, + "pointwise_add", + data_objects::DataType::FLOAT, + data_objects::NodeAttributes::PointwiseAttributes, + attributes.Union())); + + auto name = builder.CreateString("ingestor_poc_pointwise_add"); + auto tensorsVector = builder.CreateVector(tensors); + auto nodesVector = builder.CreateVector(nodes); + + // A finalized graph carries an identity, which is what the provider keys its + // catalog cache on. A graph without one still matches, just uncached. + hipdnn_flatbuffers_sdk::utilities::UuidBytes idBytes{}; + idBytes.fill(0x42); + const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(idBytes); + + data_objects::GraphBuilder graphBuilder(builder); + graphBuilder.add_name(name); + graphBuilder.add_tensors(tensorsVector); + graphBuilder.add_nodes(nodesVector); + if(withIdentity) + { + graphBuilder.add_id(&uuid); + } + builder.Finish(graphBuilder.Finish()); + + return builder.Release(); +} + +/// A graph this pack must decline: two nodes, so no single prebuilt kernel serves it. +flatbuffers::DetachedBuffer buildUnsupportedGraph() +{ + flatbuffers::FlatBufferBuilder builder; + const std::vector dims = {1, 1, 1, 1}; + const std::vector strides = {1, 1, 1, 1}; + constexpr int64_t INTERMEDIATE_UID = 4; + + std::vector> tensors; + for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID, INTERMEDIATE_UID}) + { + tensors.push_back(data_objects::CreateTensorAttributesDirect(builder, + uid, + nullptr, + data_objects::DataType::FLOAT, + &strides, + &dims, + uid == INTERMEDIATE_UID)); + } + + std::vector> nodes; + for(const auto& uids : + std::vector>{{INPUT_A_UID, INPUT_B_UID, INTERMEDIATE_UID}, + {INTERMEDIATE_UID, INPUT_B_UID, OUTPUT_UID}}) + { + data_objects::PointwiseAttributesBuilder attributesBuilder(builder); + attributesBuilder.add_operation(data_objects::PointwiseMode::ADD); + attributesBuilder.add_in_0_tensor_uid(uids[0]); + attributesBuilder.add_in_1_tensor_uid(uids[1]); + attributesBuilder.add_out_0_tensor_uid(uids[2]); + auto attributes = attributesBuilder.Finish(); + + nodes.push_back( + data_objects::CreateNodeDirect(builder, + "pointwise_add", + data_objects::DataType::FLOAT, + data_objects::NodeAttributes::PointwiseAttributes, + attributes.Union())); + } + + builder.Finish(data_objects::CreateGraphDirect(builder, + "two_node_pointwise", + data_objects::DataType::FLOAT, + data_objects::DataType::FLOAT, + data_objects::DataType::FLOAT, + &tensors, + &nodes)); + + return builder.Release(); +} + +/// An engine config naming this engine, with no knob settings. +flatbuffers::DetachedBuffer buildEngineConfig(int64_t engineId) +{ + flatbuffers::FlatBufferBuilder builder; + data_objects::EngineConfigBuilder configBuilder(builder); + configBuilder.add_engine_id(engineId); + builder.Finish(configBuilder.Finish()); + return builder.Release(); +} + +hipdnnPluginConstData_t asConstData(const flatbuffers::DetachedBuffer& buffer) +{ + return {buffer.data(), buffer.size()}; +} + +/// Device buffers for one 1-element add, freed on scope exit. +class AddBuffers +{ +public: + AddBuffers(float a, float b) + { + EXPECT_EQ(hipMalloc(&_a, sizeof(float)), hipSuccess); + EXPECT_EQ(hipMalloc(&_b, sizeof(float)), hipSuccess); + EXPECT_EQ(hipMalloc(&_c, sizeof(float)), hipSuccess); + EXPECT_EQ(hipMemcpy(_a, &a, sizeof(float), hipMemcpyHostToDevice), hipSuccess); + EXPECT_EQ(hipMemcpy(_b, &b, sizeof(float), hipMemcpyHostToDevice), hipSuccess); + } + + ~AddBuffers() + { + static_cast(hipFree(_a)); + static_cast(hipFree(_b)); + static_cast(hipFree(_c)); + } + + AddBuffers(const AddBuffers&) = delete; + AddBuffers& operator=(const AddBuffers&) = delete; + + std::array descriptors() const + { + return {hipdnnPluginDeviceBuffer_t{INPUT_A_UID, _a}, + hipdnnPluginDeviceBuffer_t{INPUT_B_UID, _b}, + hipdnnPluginDeviceBuffer_t{OUTPUT_UID, _c}}; + } + + float readResult() const + { + float result = 0.0f; + EXPECT_EQ(hipMemcpy(&result, _c, sizeof(float), hipMemcpyDeviceToHost), hipSuccess); + return result; + } + +private: + void* _a = nullptr; + void* _b = nullptr; + void* _c = nullptr; +}; + +/** + * @brief The plugin's exported C API, resolved from the built shared object. + * + * The provider exports these symbols for hipDNN to load at run time; nothing links + * against them. Resolving them the same way is what makes this test cross the real ABI + * boundary rather than reaching into the provider's internals. + */ +class PluginApi +{ +public: + PluginApi() + { + // PLUGIN_PATH names the CMake target, so the platform's library prefix and + // extension have to be applied to reach the file on disk. + const std::filesystem::path pluginTarget(PLUGIN_PATH); + const auto pluginFile = hipdnn_data_sdk::utilities::LIB_PREFIX + + pluginTarget.filename().string() + + hipdnn_data_sdk::utilities::SHARED_LIB_EXT; + const auto pluginPath = std::filesystem::weakly_canonical( + hipdnn_data_sdk::utilities::getCurrentExecutableDirectory() / pluginTarget.parent_path() + / pluginFile); + _library = hipdnn_data_sdk::utilities::openLibrary(pluginPath); + + create = resolve("hipdnnEnginePluginCreate"); + destroy = resolve("hipdnnEnginePluginDestroy"); + setStream = resolve("hipdnnEnginePluginSetStream"); + getAllEngineIds = resolve( + "hipdnnEnginePluginGetAllEngineIds"); + getApplicableEngineIds = resolve( + "hipdnnEnginePluginGetApplicableEngineIds"); + getEngineDetails = resolve( + "hipdnnEnginePluginGetEngineDetails"); + destroyEngineDetails = resolve( + "hipdnnEnginePluginDestroyEngineDetails"); + getWorkspaceSize = resolve( + "hipdnnEnginePluginGetWorkspaceSize"); + createExecutionContext = resolve( + "hipdnnEnginePluginCreateExecutionContext"); + destroyExecutionContext = resolve( + "hipdnnEnginePluginDestroyExecutionContext"); + getWorkspaceSizeFromExecutionContext + = resolve( + "hipdnnEnginePluginGetWorkspaceSizeFromExecutionContext"); + executeOpGraph = resolve( + "hipdnnEnginePluginExecuteOpGraph"); + } + + decltype(&hipdnnEnginePluginCreate) create = nullptr; + decltype(&hipdnnEnginePluginDestroy) destroy = nullptr; + decltype(&hipdnnEnginePluginSetStream) setStream = nullptr; + decltype(&hipdnnEnginePluginGetAllEngineIds) getAllEngineIds = nullptr; + decltype(&hipdnnEnginePluginGetApplicableEngineIds) getApplicableEngineIds = nullptr; + decltype(&hipdnnEnginePluginGetEngineDetails) getEngineDetails = nullptr; + decltype(&hipdnnEnginePluginDestroyEngineDetails) destroyEngineDetails = nullptr; + decltype(&hipdnnEnginePluginGetWorkspaceSize) getWorkspaceSize = nullptr; + decltype(&hipdnnEnginePluginCreateExecutionContext) createExecutionContext = nullptr; + decltype(&hipdnnEnginePluginDestroyExecutionContext) destroyExecutionContext = nullptr; + decltype(&hipdnnEnginePluginGetWorkspaceSizeFromExecutionContext) + getWorkspaceSizeFromExecutionContext + = nullptr; + decltype(&hipdnnEnginePluginExecuteOpGraph) executeOpGraph = nullptr; + +private: + template + T resolve(const char* name) const + { + auto* symbol = hipdnn_data_sdk::utilities::getSymbol(_library, name); + if(symbol == nullptr) + { + throw std::runtime_error("plugin does not export " + std::string(name)); + } + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return reinterpret_cast(symbol); + } + + hipdnn_data_sdk::utilities::SharedLibraryHandle _library = nullptr; +}; + +/// One process-wide load, since a plugin is loaded once and shared by every handle. +const PluginApi& pluginApi() +{ + static const PluginApi s_api; + return s_api; +} + +class IntegrationGpuKernelIngestor : public ::testing::Test +{ +protected: + void SetUp() override + { + SKIP_IF_NO_DEVICES(); + + ASSERT_EQ(pluginApi().create(&_handle), HIPDNN_PLUGIN_STATUS_SUCCESS); + ASSERT_EQ(hipStreamCreate(&_stream), hipSuccess); + ASSERT_EQ(pluginApi().setStream(_handle, _stream), HIPDNN_PLUGIN_STATUS_SUCCESS); + + _engineId = hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); + } + + void TearDown() override + { + if(_handle != nullptr) + { + EXPECT_EQ(pluginApi().destroy(_handle), HIPDNN_PLUGIN_STATUS_SUCCESS); + } + if(_stream != nullptr) + { + EXPECT_EQ(hipStreamDestroy(_stream), hipSuccess); + } + } + + std::vector applicableEngines(const hipdnnPluginConstData_t& graph) const + { + uint32_t count = 0; + EXPECT_EQ(pluginApi().getApplicableEngineIds(_handle, &graph, nullptr, 0, &count), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + std::vector engines(count); + if(count > 0) + { + EXPECT_EQ( + pluginApi().getApplicableEngineIds(_handle, &graph, engines.data(), count, &count), + HIPDNN_PLUGIN_STATUS_SUCCESS); + } + return engines; + } + + bool isApplicable(const hipdnnPluginConstData_t& graph) const + { + const auto engines = applicableEngines(graph); + return std::find(engines.begin(), engines.end(), _engineId) != engines.end(); + } + + hipdnnEnginePluginHandle_t _handle = nullptr; + hipStream_t _stream = nullptr; + int64_t _engineId = 0; +}; + +// --------------------------------------------------------------------------- +// Applicability +// --------------------------------------------------------------------------- + +TEST_F(IntegrationGpuKernelIngestor, ExposesTheDescriptorBackedEngineToTheHost) +{ + // The engine's id is its descriptor name hashed into hipDNN's id space, registered + // when the engine was constructed rather than by a compile-time macro. + uint32_t count = 0; + ASSERT_EQ(pluginApi().getAllEngineIds(nullptr, 0, &count), HIPDNN_PLUGIN_STATUS_SUCCESS); + + std::vector engines(count); + ASSERT_EQ(pluginApi().getAllEngineIds(engines.data(), count, &count), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + EXPECT_NE(std::find(engines.begin(), engines.end(), _engineId), engines.end()); +} + +TEST_F(IntegrationGpuKernelIngestor, AcceptsTheGraphItsDescriptorsDescribe) +{ + const auto graph = buildPointwiseAddGraph(); + + EXPECT_TRUE(isApplicable(asConstData(graph))); +} + +TEST_F(IntegrationGpuKernelIngestor, DeclinesAGraphNoKernelInTheCatalogServes) +{ + // Declining is free: an empty catalog answers false and hipDNN moves on. Getting + // this wrong is what turns a cheap decline into a failed plan build. + const auto graph = buildUnsupportedGraph(); + + EXPECT_FALSE(isApplicable(asConstData(graph))); +} + +TEST_F(IntegrationGpuKernelIngestor, AcceptsAGraphCarryingNoIdentity) +{ + // Without a graph id the catalog cannot be cached, but matching is unaffected: the + // answer must be the same, only recomputed. + const auto graph = buildPointwiseAddGraph(/*withIdentity=*/false); + + EXPECT_TRUE(isApplicable(asConstData(graph))); +} + +// --------------------------------------------------------------------------- +// Engine details: knobs from the catalog +// --------------------------------------------------------------------------- + +TEST_F(IntegrationGpuKernelIngestor, ReportsAKnobWhoseValuesComeFromTheCatalog) +{ + const auto graph = buildPointwiseAddGraph(); + const auto graphData = asConstData(graph); + + hipdnnPluginConstData_t details{}; + ASSERT_EQ(pluginApi().getEngineDetails(_handle, _engineId, &graphData, &details), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + const auto* engineDetails = flatbuffers::GetRoot(details.ptr); + ASSERT_NE(engineDetails, nullptr); + ASSERT_NE(engineDetails->knobs(), nullptr); + ASSERT_EQ(engineDetails->knobs()->size(), 1U); + + const auto* knob = engineDetails->knobs()->Get(0); + ASSERT_NE(knob->knob_id(), nullptr); + EXPECT_EQ(knob->knob_id()->str(), BLOCK_SIZE_KNOB); + + // The pack ships three kernels, but the HALF one is pruned for this FLOAT graph, so + // the knob offers exactly the two block sizes the surviving kernels implement -- + // never the schema's theoretical range. + const auto* constraint = knob->constraint_as_IntConstraint(); + ASSERT_NE(constraint, nullptr); + ASSERT_NE(constraint->valid_values(), nullptr); + std::vector values(constraint->valid_values()->begin(), + constraint->valid_values()->end()); + std::sort(values.begin(), values.end()); + EXPECT_EQ(values, (std::vector{64, 256})); + + // The default is whatever the heuristic ranked first, so leaving the knob alone + // reproduces the out-of-the-box selection. + const auto* defaultValue = knob->default_value_as_IntValue(); + ASSERT_NE(defaultValue, nullptr); + EXPECT_EQ(defaultValue->value(), 256); + + EXPECT_EQ(pluginApi().destroyEngineDetails(_handle, &details), HIPDNN_PLUGIN_STATUS_SUCCESS); +} + +// --------------------------------------------------------------------------- +// Workspace +// --------------------------------------------------------------------------- + +TEST_F(IntegrationGpuKernelIngestor, ReportsTheMaximumWorkspaceAcrossSurvivingKernels) +{ + const auto graph = buildPointwiseAddGraph(); + const auto config = buildEngineConfig(_engineId); + const auto graphData = asConstData(graph); + const auto configData = asConstData(config); + + size_t workspaceSize = 0; + ASSERT_EQ(pluginApi().getWorkspaceSize(_handle, &configData, &graphData, &workspaceSize), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + // One surviving kernel declares 0 bytes and the other 1024, so this answer proves + // the query aggregates across the catalog rather than reporting one kernel's value. + EXPECT_EQ(workspaceSize, EXPECTED_WORKSPACE_BYTES); +} + +// --------------------------------------------------------------------------- +// Plan build and execute +// --------------------------------------------------------------------------- + +TEST_F(IntegrationGpuKernelIngestor, ExecutesTheSelectedKernelOnDevice) +{ + const auto graph = buildPointwiseAddGraph(); + const auto config = buildEngineConfig(_engineId); + const auto graphData = asConstData(graph); + const auto configData = asConstData(config); + + hipdnnEnginePluginExecutionContext_t context = nullptr; + ASSERT_EQ(pluginApi().createExecutionContext(_handle, &configData, &graphData, &context), + HIPDNN_PLUGIN_STATUS_SUCCESS); + ASSERT_NE(context, nullptr); + + size_t workspaceSize = 0; + ASSERT_EQ(pluginApi().getWorkspaceSizeFromExecutionContext(_handle, context, &workspaceSize), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + void* workspace = nullptr; + if(workspaceSize > 0) + { + ASSERT_EQ(hipMalloc(&workspace, workspaceSize), hipSuccess); + } + + const AddBuffers buffers(3.0f, 4.0f); + const auto descriptors = buffers.descriptors(); + + ASSERT_EQ(pluginApi().executeOpGraph( + _handle, context, workspace, descriptors.data(), descriptors.size()), + HIPDNN_PLUGIN_STATUS_SUCCESS); + ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); + + // The whole chain, end to end: the matchers admitted this graph, the heuristic + // ranked the catalog, the dispatch descriptor's handler compiled and launched the + // winner, and its arguments were resolved by tensor uid. + EXPECT_FLOAT_EQ(buffers.readResult(), 7.0f); + + EXPECT_EQ(pluginApi().destroyExecutionContext(_handle, context), HIPDNN_PLUGIN_STATUS_SUCCESS); + if(workspace != nullptr) + { + EXPECT_EQ(hipFree(workspace), hipSuccess); + } +} + +TEST_F(IntegrationGpuKernelIngestor, ReusesOnePlanAcrossExecutions) +{ + const auto graph = buildPointwiseAddGraph(); + const auto config = buildEngineConfig(_engineId); + const auto graphData = asConstData(graph); + const auto configData = asConstData(config); + + hipdnnEnginePluginExecutionContext_t context = nullptr; + ASSERT_EQ(pluginApi().createExecutionContext(_handle, &configData, &graphData, &context), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + size_t workspaceSize = 0; + ASSERT_EQ(pluginApi().getWorkspaceSizeFromExecutionContext(_handle, context, &workspaceSize), + HIPDNN_PLUGIN_STATUS_SUCCESS); + void* workspace = nullptr; + if(workspaceSize > 0) + { + ASSERT_EQ(hipMalloc(&workspace, workspaceSize), hipSuccess); + } + + // A plan is built once and executed many times with different buffers; every + // decision was made at build, so execution must not depend on the previous one. + for(const auto& values : std::vector>{ + {1.0f, 2.0f, 3.0f}, {-5.0f, 2.5f, -2.5f}, {0.125f, 0.375f, 0.5f}}) + { + const AddBuffers buffers(values[0], values[1]); + const auto descriptors = buffers.descriptors(); + + ASSERT_EQ(pluginApi().executeOpGraph( + _handle, context, workspace, descriptors.data(), descriptors.size()), + HIPDNN_PLUGIN_STATUS_SUCCESS); + ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); + + EXPECT_FLOAT_EQ(buffers.readResult(), values[2]); + } + + EXPECT_EQ(pluginApi().destroyExecutionContext(_handle, context), HIPDNN_PLUGIN_STATUS_SUCCESS); + if(workspace != nullptr) + { + EXPECT_EQ(hipFree(workspace), hipSuccess); + } +} + +TEST_F(IntegrationGpuKernelIngestor, ExecutesAGraphThatCannotBeCached) +{ + // Same result through the uncached path: a graph with no identity is rematched on + // every query, which costs time and never correctness. + const auto graph = buildPointwiseAddGraph(/*withIdentity=*/false); + const auto config = buildEngineConfig(_engineId); + const auto graphData = asConstData(graph); + const auto configData = asConstData(config); + + hipdnnEnginePluginExecutionContext_t context = nullptr; + ASSERT_EQ(pluginApi().createExecutionContext(_handle, &configData, &graphData, &context), + HIPDNN_PLUGIN_STATUS_SUCCESS); + + size_t workspaceSize = 0; + ASSERT_EQ(pluginApi().getWorkspaceSizeFromExecutionContext(_handle, context, &workspaceSize), + HIPDNN_PLUGIN_STATUS_SUCCESS); + void* workspace = nullptr; + if(workspaceSize > 0) + { + ASSERT_EQ(hipMalloc(&workspace, workspaceSize), hipSuccess); + } + + const AddBuffers buffers(2.5f, 6.5f); + const auto descriptors = buffers.descriptors(); + + ASSERT_EQ(pluginApi().executeOpGraph( + _handle, context, workspace, descriptors.data(), descriptors.size()), + HIPDNN_PLUGIN_STATUS_SUCCESS); + ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); + + EXPECT_FLOAT_EQ(buffers.readResult(), 9.0f); + + EXPECT_EQ(pluginApi().destroyExecutionContext(_handle, context), HIPDNN_PLUGIN_STATUS_SUCCESS); + if(workspace != nullptr) + { + EXPECT_EQ(hipFree(workspace), hipSuccess); + } +} + +} // namespace + +} // namespace hip_kernel_provider::ingestor_poc::integration + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt index 8015e97ef56a..13ed37e36273 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt @@ -30,6 +30,23 @@ add_executable( # Add the dummy kernel for running the tests include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/KernelEmbedding.cmake") add_kernels_for_embedding(TARGET hip_kernel_provider_tests FILES ${CMAKE_CURRENT_SOURCE_DIR}/kernels/vector_add.cpp) + +# The ingestor POC's dispatch handler compiles this kernel at plan-build time, and +# kernel sources are embedded per target, so the test binary needs its own copy. +# It is embedded from a configure-time copy rather than from the original path: a source +# file registered for embedding in two directories lands in both directories' +# CMAKE_CONFIGURE_DEPENDS, which Ninja rejects as two rules generating one file. +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/../ingestor_poc/kernels/PointwiseAdd.cpp + ${CMAKE_CURRENT_BINARY_DIR}/kernels/PointwiseAdd.cpp + COPYONLY + ) + add_kernels_for_embedding( + TARGET hip_kernel_provider_tests + FILES ${CMAKE_CURRENT_BINARY_DIR}/kernels/PointwiseAdd.cpp + ) +endif() embed_kernel_sources( TARGET hip_kernel_provider_tests OUTPUT_SRCS_CPP ${CMAKE_CURRENT_BINARY_DIR}/engines/kernel_sources.cpp @@ -69,6 +86,10 @@ if(ENABLE_HIP_MLOPS_ENGINE) add_subdirectory(engines/hip_mlops_engine) endif() +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + add_subdirectory(ingestor_poc) +endif() + clang_tidy_check(hip_kernel_provider_tests) add_unit_test_target(hip_kernel_provider_tests ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp index 7b76c00738c9..f5391d13d583 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp @@ -27,6 +27,10 @@ constexpr uint32_t EXPECTED_ENGINES = 0 + 1 #endif +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + + 1 +#endif + // Add more blocks like this as more engines are implemented ; diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt new file mode 100644 index 000000000000..962f929d2a0c --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright © Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +target_sources(hip_kernel_provider_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/TestPointwiseAddPack.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/TestNativeMatchers.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/TestPointwiseAddDispatchHandler.cpp +) diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp new file mode 100644 index 000000000000..8729b9f381a1 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp @@ -0,0 +1,155 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include +#include +#include +#include + +namespace hip_kernel_provider::ingestor_poc::testing +{ + +/// Tensor uids the builders below use, in argument order. +constexpr int64_t INPUT_A_UID = 1; +constexpr int64_t INPUT_B_UID = 2; +constexpr int64_t OUTPUT_UID = 3; + +/** + * @brief Builds a single-node pointwise graph, for driving the POC's matchers. + * + * Parameterized on everything the matchers gate, so a test can vary exactly one thing + * and assert the effect: the operation, the element type, the tensor shape, and whether + * the graph carries an identity. + */ +inline flatbuffers::FlatBufferBuilder + buildPointwiseGraph(hipdnn_flatbuffers_sdk::data_objects::PointwiseMode operation + = hipdnn_flatbuffers_sdk::data_objects::PointwiseMode::ADD, + hipdnn_flatbuffers_sdk::data_objects::DataType dataType + = hipdnn_flatbuffers_sdk::data_objects::DataType::FLOAT, + const std::vector& dims = {1, 1, 1, 1}, + std::optional graphId + = std::nullopt, + bool binary = true) +{ + namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; + + flatbuffers::FlatBufferBuilder builder; + const std::vector strides(dims.size(), 1); + + std::vector> tensors; + for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID}) + { + tensors.push_back(data_objects::CreateTensorAttributesDirect( + builder, uid, nullptr, dataType, &strides, &dims, false)); + } + + data_objects::PointwiseAttributesBuilder attributesBuilder(builder); + attributesBuilder.add_operation(operation); + attributesBuilder.add_in_0_tensor_uid(INPUT_A_UID); + if(binary) + { + attributesBuilder.add_in_1_tensor_uid(INPUT_B_UID); + } + attributesBuilder.add_out_0_tensor_uid(OUTPUT_UID); + auto attributes = attributesBuilder.Finish(); + + std::vector> nodes; + nodes.push_back( + data_objects::CreateNodeDirect(builder, + "pointwise", + dataType, + data_objects::NodeAttributes::PointwiseAttributes, + attributes.Union())); + + auto name = builder.CreateString("pointwise_add_test"); + auto tensorsVector = builder.CreateVector(tensors); + auto nodesVector = builder.CreateVector(nodes); + + data_objects::GraphBuilder graphBuilder(builder); + graphBuilder.add_name(name); + graphBuilder.add_tensors(tensorsVector); + graphBuilder.add_nodes(nodesVector); + + // Held for the duration of the GraphBuilder: add_id stores a pointer to it. + data_objects::Uuid uuid{}; + if(graphId.has_value()) + { + uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*graphId); + graphBuilder.add_id(&uuid); + } + builder.Finish(graphBuilder.Finish()); + + return builder; +} + +/// @brief A graph with two pointwise nodes, which no prebuilt single-op kernel serves. +inline flatbuffers::FlatBufferBuilder buildTwoNodePointwiseGraph() +{ + namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; + + flatbuffers::FlatBufferBuilder builder; + const std::vector dims = {1, 1, 1, 1}; + const std::vector strides = {1, 1, 1, 1}; + constexpr int64_t INTERMEDIATE_UID = 4; + + std::vector> tensors; + for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID, INTERMEDIATE_UID}) + { + tensors.push_back(data_objects::CreateTensorAttributesDirect(builder, + uid, + nullptr, + data_objects::DataType::FLOAT, + &strides, + &dims, + uid == INTERMEDIATE_UID)); + } + + std::vector> nodes; + for(const auto& [in0, in1, out] : {std::tuple{INPUT_A_UID, INPUT_B_UID, INTERMEDIATE_UID}, + std::tuple{INTERMEDIATE_UID, INPUT_B_UID, OUTPUT_UID}}) + { + data_objects::PointwiseAttributesBuilder attributesBuilder(builder); + attributesBuilder.add_operation(data_objects::PointwiseMode::ADD); + attributesBuilder.add_in_0_tensor_uid(in0); + attributesBuilder.add_in_1_tensor_uid(in1); + attributesBuilder.add_out_0_tensor_uid(out); + auto attributes = attributesBuilder.Finish(); + + nodes.push_back( + data_objects::CreateNodeDirect(builder, + "pointwise", + data_objects::DataType::FLOAT, + data_objects::NodeAttributes::PointwiseAttributes, + attributes.Union())); + } + + builder.Finish(data_objects::CreateGraphDirect(builder, + "two_node_pointwise", + data_objects::DataType::FLOAT, + data_objects::DataType::FLOAT, + data_objects::DataType::FLOAT, + &tensors, + &nodes)); + + return builder; +} + +/// @brief A distinct graph identity, so cache-keyed tests do not collide. +inline hipdnn_flatbuffers_sdk::utilities::UuidBytes makeGraphId(uint8_t seed) +{ + hipdnn_flatbuffers_sdk::utilities::UuidBytes id{}; + id.fill(seed); + return id; +} + +} // namespace hip_kernel_provider::ingestor_poc::testing + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp new file mode 100644 index 000000000000..d8842bf9b522 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp @@ -0,0 +1,229 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +#include +#include +#include +#include +#include + +#include "ingestor_poc/NativeMatchers.hpp" +#include "ingestor_poc/NativeSymbolNames.hpp" +#include "tests/ingestor_poc/PointwiseAddGraphs.hpp" + +/** + * @file TestNativeMatchers.cpp + * @brief The POC's two matcher shapes: what each accepts, and what each refuses. + * + * The refusals matter more than the acceptances here. An under-specified decline accepts + * a graph the kernel cannot serve, which is a wrong answer rather than a missed + * optimization — and for a prebuilt kernel it is a silent one. + */ +namespace +{ + +using namespace hip_kernel_provider::ingestor_poc; +using namespace hip_kernel_provider::ingestor_poc::testing; +using hipdnn_plugin_sdk::ingestor::KernelDefinition; +using hipdnn_plugin_sdk::ingestor::MatchContext; +namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; + +hipDeviceProp_t testDeviceProperties() +{ + hipDeviceProp_t properties{}; + properties.warpSize = 64; + return properties; +} + +KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) +{ + KernelDefinition kernel; + kernel.kernelId = "test.kernel"; + kernel.packId = "test.pack"; + kernel.dispatchId = "test.dispatch"; + kernel.sourceFile = "PointwiseAdd.cpp"; + kernel.entryPoint = "PointwiseAdd"; + kernel.metadata + = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; + return kernel; +} + +/// Wraps a built graph buffer so a test reads it the way an engine does. +class GraphFixture +{ +public: + explicit GraphFixture(flatbuffers::FlatBufferBuilder builder) + : _builder(std::move(builder)) + , _graph(_builder.GetBufferPointer(), _builder.GetSize()) + , _properties(testDeviceProperties()) + { + } + + MatchContext context() const + { + return MatchContext{_graph, 0, _properties}; + } + +private: + flatbuffers::FlatBufferBuilder _builder; + hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper _graph; + hipDeviceProp_t _properties; +}; + +// --------------------------------------------------------------------------- +// Graph-scoped matcher +// --------------------------------------------------------------------------- + +TEST(TestPointwiseAddGraphMatcher, AcceptsASingleElementFloatAdd) +{ + const GraphFixture fixture(buildPointwiseGraph()); + + EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, AcceptsAHalfPrecisionAdd) +{ + // The graph-level gate is dtype-agnostic within the pack's declared set; pinning the + // kernel's baked dtype is the kernel-scoped matcher's job. + const GraphFixture fixture( + buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::HALF)); + + EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, RefusesAnotherPointwiseOperation) +{ + const GraphFixture fixture(buildPointwiseGraph(data_objects::PointwiseMode::MUL)); + + EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, RefusesMultiElementTensors) +{ + // The kernel writes element 0 and nothing else, so anything larger would silently + // leave most of the output untouched. + const GraphFixture fixture(buildPointwiseGraph( + data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 2, 2})); + + EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, RefusesARankTheDispatchPathCannotServe) +{ + // A 1-element 1-D tensor suits the kernel, which indexes element 0, but the + // provider's compile options derive layout from the tensor and reject anything + // below rank 4. Accepting it would trade a free decline at applicability for a + // failed plan build, which the caller pays for. + const GraphFixture fixture( + buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1})); + + EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) +{ + const GraphFixture fixture(buildPointwiseGraph( + data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 1, 1, 1})); + + EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, RefusesAUnaryPointwise) +{ + const GraphFixture fixture(buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/false)); + + EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); +} + +TEST(TestPointwiseAddGraphMatcher, RefusesAMultiNodeGraph) +{ + // A prebuilt kernel serves one complete graph, so a larger graph is a different + // problem even though it contains this one. + const GraphFixture fixture(buildTwoNodePointwiseGraph()); + + EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); +} + +// --------------------------------------------------------------------------- +// Kernel-scoped matcher +// --------------------------------------------------------------------------- + +TEST(TestPointwiseAddKernelMatcher, AcceptsAKernelWhoseDtypeMatchesTheGraph) +{ + const GraphFixture fixture(buildPointwiseGraph()); + + EXPECT_TRUE(pointwiseAddKernelMatches(fixture.context(), makeKernel(64, "FLOAT"))); +} + +TEST(TestPointwiseAddKernelMatcher, RefusesAKernelBakedForAnotherDtype) +{ + // The failure this prevents is silent: an f16 binary handed f32 operands does not + // fail, it returns wrong numbers. + const GraphFixture fixture(buildPointwiseGraph()); + + EXPECT_FALSE(pointwiseAddKernelMatches(fixture.context(), makeKernel(64, "HALF"))); +} + +TEST(TestPointwiseAddKernelMatcher, AcceptsAHalfKernelForAHalfGraph) +{ + const GraphFixture fixture( + buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::HALF)); + + EXPECT_TRUE(pointwiseAddKernelMatches(fixture.context(), makeKernel(64, "HALF"))); +} + +TEST(TestPointwiseAddKernelMatcher, IgnoresBlockSizeWhichTheGraphDoesNotConstrain) +{ + // block_size is a ranking and launch axis, not an applicability one: every block + // size serves every graph this pack accepts. + const GraphFixture fixture(buildPointwiseGraph()); + + EXPECT_TRUE(pointwiseAddKernelMatches(fixture.context(), makeKernel(64, "FLOAT"))); + EXPECT_TRUE(pointwiseAddKernelMatches(fixture.context(), makeKernel(256, "FLOAT"))); +} + +// --------------------------------------------------------------------------- +// Score and binding +// --------------------------------------------------------------------------- + +TEST(TestPointwiseAddScore, PrefersTheLargerBlockSize) +{ + const GraphFixture fixture(buildPointwiseGraph()); + + EXPECT_GT(pointwiseAddScore(makeKernel(256, "FLOAT"), fixture.context()), + pointwiseAddScore(makeKernel(64, "FLOAT"), fixture.context())); +} + +TEST(TestPointwiseAddBinding, ReportsTheOperandUidsInArgumentOrder) +{ + // Dispatch binds its arguments from these, rather than re-deriving the graph shape + // with a second notion of what a pointwise add looks like. + const GraphFixture fixture(buildPointwiseGraph()); + + const auto binding = pointwiseAddBinding(fixture.context()); + + EXPECT_EQ(binding.inputA, INPUT_A_UID); + EXPECT_EQ(binding.inputB, INPUT_B_UID); + EXPECT_EQ(binding.output, OUTPUT_UID); +} + +TEST(TestPointwiseAddBinding, RefusesAGraphTheMatcherWouldReject) +{ + const GraphFixture fixture(buildTwoNodePointwiseGraph()); + + EXPECT_THROW(pointwiseAddBinding(fixture.context()), hipdnn_plugin_sdk::HipdnnPluginException); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp new file mode 100644 index 000000000000..d40f7957f011 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp @@ -0,0 +1,253 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/Handle.hpp" +#include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" +#include "ingestor_poc/NativeSymbolNames.hpp" +#include "ingestor_poc/PointwiseAddDispatchHandler.hpp" +#include "tests/ingestor_poc/PointwiseAddGraphs.hpp" + +/** + * @file TestPointwiseAddDispatchHandler.cpp + * @brief The POC's dispatch: workspace sizing, and a real compile-and-launch. + * + * The launch tests run on device deliberately. A recorded no-op would exercise neither + * the runtime compile nor the uid-to-pointer resolution, which are the two parts most + * likely to be wrong when real kernels arrive. + */ +namespace +{ + +using namespace hip_kernel_provider; +using namespace hip_kernel_provider::ingestor_poc; +using namespace hip_kernel_provider::ingestor_poc::testing; +using hipdnn_plugin_sdk::ingestor::KernelDefinition; +using hipdnn_plugin_sdk::ingestor::MatchContext; + +KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) +{ + KernelDefinition kernel; + kernel.kernelId = "test.kernel"; + kernel.packId = "test.pack"; + kernel.dispatchId = "test.dispatch"; + kernel.sourceFile = "PointwiseAdd.cpp"; + kernel.entryPoint = "PointwiseAdd"; + kernel.metadata + = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; + return kernel; +} + +hipDeviceProp_t currentDeviceProperties() +{ + hipDeviceProp_t properties{}; + int deviceId = 0; + if(hipGetDevice(&deviceId) == hipSuccess) + { + static_cast(hipGetDeviceProperties(&properties, deviceId)); + } + return properties; +} + +class GraphFixture +{ +public: + explicit GraphFixture(flatbuffers::FlatBufferBuilder builder) + : _builder(std::move(builder)) + , _graph(_builder.GetBufferPointer(), _builder.GetSize()) + , _properties(currentDeviceProperties()) + { + } + + MatchContext context() const + { + return MatchContext{_graph, 0, _properties}; + } + + const hipDeviceProp_t& deviceProperties() const + { + return _properties; + } + +private: + flatbuffers::FlatBufferBuilder _builder; + hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper _graph; + hipDeviceProp_t _properties; +}; + +/// Device buffers for one 1-element add, freed on scope exit. +class AddBuffers +{ +public: + AddBuffers(float a, float b) + { + EXPECT_EQ(hipSuccess, hipMalloc(&_a, sizeof(float))); + EXPECT_EQ(hipSuccess, hipMalloc(&_b, sizeof(float))); + EXPECT_EQ(hipSuccess, hipMalloc(&_c, sizeof(float))); + EXPECT_EQ(hipSuccess, hipMemcpy(_a, &a, sizeof(float), hipMemcpyHostToDevice)); + EXPECT_EQ(hipSuccess, hipMemcpy(_b, &b, sizeof(float), hipMemcpyHostToDevice)); + } + + ~AddBuffers() + { + static_cast(hipFree(_a)); + static_cast(hipFree(_b)); + static_cast(hipFree(_c)); + } + + AddBuffers(const AddBuffers&) = delete; + AddBuffers& operator=(const AddBuffers&) = delete; + + std::array descriptors() const + { + return {hipdnnPluginDeviceBuffer_t{INPUT_A_UID, _a}, + hipdnnPluginDeviceBuffer_t{INPUT_B_UID, _b}, + hipdnnPluginDeviceBuffer_t{OUTPUT_UID, _c}}; + } + + float readResult() const + { + float result = 0.0f; + EXPECT_EQ(hipSuccess, hipMemcpy(&result, _c, sizeof(float), hipMemcpyDeviceToHost)); + return result; + } + +private: + void* _a = nullptr; + void* _b = nullptr; + void* _c = nullptr; +}; + +// --------------------------------------------------------------------------- +// Workspace +// --------------------------------------------------------------------------- + +TEST(TestPointwiseAddDispatch, ReportsWorkspaceFromKernelMetadata) +{ + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler, hipDeviceProp_t{}); + + // The two surviving kernels report different requirements, so the engine's + // "maximum across survivors" has something to actually maximize over. + EXPECT_EQ(handler.workspaceBytes(makeKernel(64, "FLOAT")), 0U); + EXPECT_EQ(handler.workspaceBytes(makeKernel(256, "FLOAT")), 1024U); +} + +TEST(TestPointwiseAddDispatch, WorkspaceDoesNotDependOnTheGraph) +{ + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler, hipDeviceProp_t{}); + + // Workspace is asked before a kernel is chosen and before any plan exists, so it + // must be answerable from the kernel alone. + EXPECT_EQ(handler.workspaceBytes(makeKernel(256, "FLOAT")), + handler.workspaceBytes(makeKernel(256, "HALF"))); +} + +// --------------------------------------------------------------------------- +// Prepare and launch +// --------------------------------------------------------------------------- + +TEST(TestPointwiseAddDispatch, LaunchesARealAddOnDevice) +{ + SKIP_IF_NO_DEVICES(); + + const GraphFixture fixture(buildPointwiseGraph()); + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + + const auto prepared = handler.prepare(fixture.context(), makeKernel(64, "FLOAT")); + ASSERT_NE(prepared, nullptr); + + const AddBuffers buffers(3.0f, 4.0f); + const auto descriptors = buffers.descriptors(); + const Handle handle; + + handler.launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); + ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + + EXPECT_FLOAT_EQ(buffers.readResult(), 7.0f); +} + +TEST(TestPointwiseAddDispatch, LaunchesTheSameResultForEitherBlockSize) +{ + SKIP_IF_NO_DEVICES(); + + const GraphFixture fixture(buildPointwiseGraph()); + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + + // block_size reaches the compiler and the launch geometry, so both kernels are + // genuinely different builds; a one-element add must still agree. + for(const int64_t blockSize : {64, 256}) + { + const auto prepared = handler.prepare(fixture.context(), makeKernel(blockSize, "FLOAT")); + const AddBuffers buffers(1.5f, 2.25f); + const auto descriptors = buffers.descriptors(); + const Handle handle; + + handler.launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); + ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + + EXPECT_FLOAT_EQ(buffers.readResult(), 3.75f) << "block size " << blockSize; + } +} + +TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) +{ + SKIP_IF_NO_DEVICES(); + + const GraphFixture fixture(buildPointwiseGraph()); + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + + // A plan is built once and may execute many times with different buffers, so + // preparation must hold nothing tied to one execution. + const auto prepared = handler.prepare(fixture.context(), makeKernel(64, "FLOAT")); + const Handle handle; + + for(const auto& [a, b, expected] : std::vector>{ + {1.0f, 2.0f, 3.0f}, {10.0f, -4.0f, 6.0f}, {0.5f, 0.25f, 0.75f}}) + { + const AddBuffers buffers(a, b); + const auto descriptors = buffers.descriptors(); + + handler.launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); + ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + + EXPECT_FLOAT_EQ(buffers.readResult(), expected); + } +} + +TEST(TestPointwiseAddDispatch, RefusesToPrepareAGraphTheMatcherRejects) +{ + SKIP_IF_NO_DEVICES(); + + const GraphFixture fixture(buildTwoNodePointwiseGraph()); + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + + // Preparation reads the operand binding the matcher established, so a graph that + // never matched cannot be prepared. + EXPECT_THROW(handler.prepare(fixture.context(), makeKernel(64, "FLOAT")), + hipdnn_plugin_sdk::HipdnnPluginException); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp new file mode 100644 index 000000000000..691a185229fe --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp @@ -0,0 +1,162 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include + +#include + +#include + +#include "ingestor_poc/NativeSymbolNames.hpp" +#include "ingestor_poc/PointwiseAddPack.hpp" + +/** + * @file TestPointwiseAddPack.cpp + * @brief The POC's descriptor data: its shape, its cross-references, and its engine id. + */ +namespace +{ + +using namespace hip_kernel_provider::ingestor_poc; + +TEST(TestPointwiseAddPack, ShipsThreeKernelsCoveringTwoBlockSizesAndTwoDataTypes) +{ + const auto set = buildPointwiseAddDescriptorSet(); + + ASSERT_EQ(set.packs.size(), 1U); + const auto& kernels = set.packs.front().kernels; + ASSERT_EQ(kernels.size(), 3U); + + // The two FLOAT kernels differ only in block size, so ranking has an order to + // produce and the block_size knob has a value set of two. The HALF kernel is what + // the kernel-scoped matcher prunes on a FLOAT graph. + const auto describes = [&kernels](int64_t blockSize, const std::string& dtype) { + return std::any_of(kernels.begin(), kernels.end(), [&](const auto& kernel) { + return std::get(kernel.metadata.at(std::string(BLOCK_SIZE_FIELD))) == blockSize + && std::get(kernel.metadata.at(std::string(DTYPE_FIELD))) == dtype; + }); + }; + + EXPECT_TRUE(describes(64, "FLOAT")); + EXPECT_TRUE(describes(256, "FLOAT")); + EXPECT_TRUE(describes(64, "HALF")); +} + +TEST(TestPointwiseAddPack, EveryKernelNamesTheEmbeddedSource) +{ + const auto set = buildPointwiseAddDescriptorSet(); + + for(const auto& kernel : set.packs.front().kernels) + { + EXPECT_EQ(kernel.sourceFile, "PointwiseAdd.cpp"); + EXPECT_EQ(kernel.entryPoint, "PointwiseAdd"); + } +} + +TEST(TestPointwiseAddPack, PackCrossReferencesResolveWithinTheDescriptorSet) +{ + const auto set = buildPointwiseAddDescriptorSet(); + const auto& pack = set.packs.front(); + + // A pack names its engine, matchers, and dispatch by id; every one must resolve or + // the descriptor set cannot be evaluated. + EXPECT_EQ(pack.engineId, set.engine.id); + + ASSERT_EQ(pack.matcherIds.size(), 2U); + for(const auto& matcherId : pack.matcherIds) + { + EXPECT_TRUE(std::any_of(set.matchers.begin(), set.matchers.end(), [&](const auto& matcher) { + return matcher.id == matcherId; + })); + } + + EXPECT_TRUE(std::any_of(set.dispatches.begin(), + set.dispatches.end(), + [&](const auto& dispatch) { return dispatch.id == pack.dispatchId; })); +} + +TEST(TestPointwiseAddPack, EngineNamesItsHeuristicAndMetadataSchema) +{ + const auto set = buildPointwiseAddDescriptorSet(); + + // An engine carries exactly one of each, because one selector ranks all of its + // kernels over one feature space. + EXPECT_EQ(set.engine.heuristicId, set.heuristic.id); + EXPECT_EQ(set.engine.metadataSchemaId, set.schema.id); +} + +TEST(TestPointwiseAddPack, ExposesBlockSizeAsAKnobAndDtypeAsInternal) +{ + const auto set = buildPointwiseAddDescriptorSet(); + + // dtype is pinned by the graph rather than chosen, so exposing it would offer a + // choice nothing can serve. + ASSERT_EQ(set.engine.knobs.size(), 1U); + EXPECT_EQ(set.engine.knobs.front(), std::string(BLOCK_SIZE_FIELD)); +} + +TEST(TestPointwiseAddPack, EveryKnobNamesADeclaredMetadataField) +{ + const auto set = buildPointwiseAddDescriptorSet(); + + // A knob naming a field the schema does not declare is a load error in the real + // system: it could never be filtered or defaulted. + for(const auto& knob : set.engine.knobs) + { + EXPECT_TRUE(std::any_of(set.schema.fields.begin(), + set.schema.fields.end(), + [&](const auto& field) { return field.name == knob; })); + } +} + +TEST(TestPointwiseAddPack, MatchersCoverBothScopes) +{ + const auto set = buildPointwiseAddDescriptorSet(); + + // Graph-scoped prunes the whole pack once per graph; kernel-scoped prunes per + // candidate. Losing either collapses the pruning order the ingestor relies on. + EXPECT_EQ(std::count_if(set.matchers.begin(), + set.matchers.end(), + [](const auto& matcher) { + return matcher.scope + == hipdnn_plugin_sdk::ingestor::MatchScope::GRAPH; + }), + 1); + EXPECT_EQ(std::count_if(set.matchers.begin(), + set.matchers.end(), + [](const auto& matcher) { + return matcher.scope + == hipdnn_plugin_sdk::ingestor::MatchScope::KERNEL; + }), + 1); +} + +TEST(TestPointwiseAddPack, EngineIdIsTheHashOfItsScopedName) +{ + // A descriptor-backed engine's id comes from its UED name, the same derivation a + // hand-written engine's registered name goes through. + EXPECT_EQ(pointwiseAddEngineId(), hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME)); +} + +TEST(TestPointwiseAddPack, EngineIdIsStableAcrossCalls) +{ + // The id keys the engine in hipDNN's registry and in this provider's engine table, + // so it must not vary between the two places that ask for it. + EXPECT_EQ(pointwiseAddEngineId(), pointwiseAddEngineId()); +} + +TEST(TestPointwiseAddPack, RegistersTheEngineNameForDiagnostics) +{ + // Registered at run time rather than by a compile-time macro, so a log line and a + // collision report can name the engine instead of printing a hex id. + const auto id = pointwiseAddEngineId(); + + EXPECT_EQ(hipdnn_data_sdk::utilities::getEngineNameFromId(id), ENGINE_NAME); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/CMakeLists.txt b/projects/hipdnn/CMakeLists.txt index 4f11bc09a361..be69823464f1 100644 --- a/projects/hipdnn/CMakeLists.txt +++ b/projects/hipdnn/CMakeLists.txt @@ -60,6 +60,18 @@ else() message(STATUS "hipDNN: SDPA support disabled") endif() +# Gates the generic kernel ingestor platform (RFC 0017: Universal Kernel Descriptors). +# Set by TheRock's THEROCK_FLAG_HIPDNN_ENABLE_KERNEL_INGESTOR, which scopes it to the +# hipDNN and hipkernelprovider subprojects. With it OFF the ingestor headers compile to +# nothing and are not installed, so default builds and installed artifacts are unchanged. +option(HIPDNN_ENABLE_KERNEL_INGESTOR "Enable the generic kernel ingestor (RFC 0017)" OFF) +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + message(STATUS "hipDNN: kernel ingestor enabled") + add_compile_definitions(HIPDNN_ENABLE_KERNEL_INGESTOR) +else() + message(STATUS "hipDNN: kernel ingestor disabled") +endif() + # Gates install of the cuDNN-compatibility shim headers and build/run of its # tests. The shim sources always exist in the tree; this option controls only # whether they are installed and tested. diff --git a/projects/hipdnn/plugin_sdk/CMakeLists.txt b/projects/hipdnn/plugin_sdk/CMakeLists.txt index aaae9b578912..4f223f0e6e7c 100644 --- a/projects/hipdnn/plugin_sdk/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/CMakeLists.txt @@ -43,6 +43,18 @@ export( install(TARGETS hipdnn_plugin_sdk EXPORT hipdnn_plugin_sdk_targets COMPONENT Development) # cmake-lint: disable=E1122 +# +# The ingestor/ headers are gated by HIPDNN_ENABLE_KERNEL_INGESTOR and compile to +# nothing without it, but this glob is unconditional -- so without an explicit +# exclusion they would ship in the SDK package even on a default build, changing the +# installed artifacts of a build that has the feature turned off. They are work in +# progress (RFC 0017) and not a supported surface, so they are installed only when the +# feature that uses them is enabled. +set(HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS "") +if(NOT HIPDNN_ENABLE_KERNEL_INGESTOR) + list(APPEND HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS PATTERN "ingestor/*" EXCLUDE) +endif() + install( DIRECTORY ${HIP_DNN_PLUGIN_SDK_INCLUDE_DIR}/ DESTINATION ${HIP_DNN_PLUGIN_SDK_INSTALL_INCLUDE_DIR} @@ -52,6 +64,7 @@ install( PATTERN "*.hpp" PATTERN "*.h" PATTERN "*.h.in" EXCLUDE + ${HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS} ) # Install generated version header diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp new file mode 100644 index 000000000000..8d13b86b9eef --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp @@ -0,0 +1,34 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief An engine's kernels that fit one graph on one device, plus their sort state. + * + * Built once per (graph, device) by running the matchers, then cached. An engine is + * applicable exactly when its catalog is non-empty. + * + * `isSorted` is what makes ranking lazy: applicability only needs to know whether any + * kernel survived, so the heuristic is not loaded or run until something actually asks + * for an order — a knob query or a plan build. Once sorted, `entries` is in + * heuristic-ranked order and stays that way for the life of the cache entry. + */ +struct Catalog +{ + std::vector entries; + bool isSorted = false; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp new file mode 100644 index 000000000000..930f6baa92c8 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -0,0 +1,179 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include + +/** + * @file Descriptors.hpp + * @brief The universal descriptor set from RFC 0017, as parsed in-memory data. + * + * Each descriptor kind maps 1:1 onto a concept hipDNN already has, expressed as data + * rather than hand-written C++: + * + * | Descriptor | Describes | + * |------------|--------------------------------------------------------------| + * | KMD | the variant fields every kernel in an engine carries | + * | UHD | how to rank the kernels that fit a graph | + * | UED | the engine: its identity, its one UHD, its one KMD, its knobs | + * | UMD | one applicability check over the graph and kernel metadata | + * | UDD | how to invoke a kernel: workspace and launch | + * | UKD | one launchable kernel: a source plus its KMD values | + * | KDP | binds a matcher set, one engine, and one UDD over N kernels | + * + * Descriptors reference each other by `id` only, never by pointer or index, so a pack + * authored independently can name a matcher or an engine it does not own. + * + * These types are the *parsed* form. Nothing here parses, loads, or validates a file: + * an ingestor built on this header is handed already-constructed descriptors. The + * loader that produces them from disk is ALMIOPEN-2401 and its follow-ups; the + * expression language that replaces the native symbol fields below is the UMD/UDD + * follow-up RFC. + */ +namespace hipdnn_plugin_sdk::ingestor +{ + +/// A descriptor cross-reference. Stable and globally unique in the real system (a GUID); +/// any unique string here, since nothing is loaded from a file yet. +using DescriptorId = std::string; + +/// A value a kernel supplies for a KMD-declared field. The KMD declares which type each +/// field holds; a UKD carrying the wrong alternative for a field is a load error the +/// validating loader reports (not modelled here, since nothing is loaded). +using MetadataValue = std::variant; + +/// A kernel's complete metadata tuple: every KMD field name mapped to this kernel's +/// value for it. RFC 0017 §4 makes this tuple the kernel's identity to the catalog, so +/// it must be unique across every kernel in an engine. Ordered, so two kernels with the +/// same fields compare and hash identically regardless of insertion order. +using MetadataValues = std::map; + +/// One field a kernel may vary along, as declared by an engine's KMD. +struct MetadataField +{ + std::string name; + /// The value a kernel that omits this field is taken to have supplied. + MetadataValue defaultValue; +}; + +/// KMD: the metadata schema, one per engine, shared by every kernel the engine owns. +/// +/// The field set is the engine's kernel key: the heuristic ranks on these values and +/// matchers read them as `$kernel.`, so any quantity that distinguishes two +/// kernels must appear here or the two collide. +struct MetadataSchema +{ + DescriptorId id; + std::string name; + std::vector fields; +}; + +/// UHD: the kernel-selection model for one engine. +/// +/// One level below hipDNN's engine-selection heuristic: this chooses *which kernel +/// within an engine* to run, given the kernels that fit the graph. +struct HeuristicDescriptor +{ + DescriptorId id; + std::string name; + /// Resolved through NativeRegistry to a per-kernel scorer. The data-driven form + /// (a model artifact plus its feature signature) is the UHD follow-up RFC. + std::string scoreSymbol; +}; + +/// UED: the engine itself, carrying no logic of its own. +/// +/// Names its one heuristic and one metadata schema by id, because a single selector +/// ranks all of the engine's kernels over one feature space. `name` hashes into +/// hipDNN's engine-id space (see EngineNames.hpp), so it must be globally unique and +/// should be scoped, e.g. "rocke:SDPA". +struct EngineDescriptor +{ + DescriptorId id; + std::string name; + DescriptorId heuristicId; + DescriptorId metadataSchemaId; + /// KMD field names this engine exposes for the caller to control. A name no KMD + /// field matches is a load error. + std::vector knobs; +}; + +/// Which inputs a matcher reads, which decides how often it runs and what its failure +/// prunes (RFC 0017 §5, "applicability is a cheap, shared-matcher pass"). +enum class MatchScope +{ + /// Reads only graph and device facts, so it runs once per (graph, device) and its + /// failure disqualifies every kernel in every pack that lists it. + GRAPH, + /// Also reads `$kernel.*`, so it is re-evaluated per candidate kernel and + /// disqualifies that kernel alone. + KERNEL, +}; + +/// UMD: one applicability check, shared by id across packs. +struct MatchDescriptor +{ + DescriptorId id; + std::string name; + MatchScope scope; + /// Resolved through NativeRegistry. The data-driven form (a structural node pattern + /// plus a declarative criteria expression) is the UMD follow-up RFC. + std::string matchSymbol; +}; + +/// UDD: how to invoke a kernel — the dispatch ABI, shared by every kernel in a pack. +/// +/// A kernel whose argument list or launch-formula shape differs belongs in another pack +/// with its own UDD (RFC 0017 §6). +struct DispatchDescriptor +{ + DescriptorId id; + std::string name; + /// Resolved through NativeRegistry to a dispatch handler supplying both the + /// workspace requirement and the launch. The data-driven form (symbolic grid, block, + /// shared memory, workspace, and argument signature) is the UDD follow-up RFC. + std::string dispatchSymbol; +}; + +/// UKD: one launchable kernel — a source plus concrete values for the fields its +/// engine's KMD declares. Its matchers, engine, and dispatch are all its pack's, and +/// its heuristic and metadata schema are that engine's, so it names none of them. +struct KernelDescriptor +{ + DescriptorId id; + std::string name; + /// The kernel source. Real sources are a tagged union over kpack, hsaco, hip, and + /// rocke (RFC 0017 §7); this skeleton carries only what the embedded-source path + /// needs, and a real source kind is the packaging follow-up. + std::string sourceFile; + std::string entryPoint; + /// This kernel's values for the KMD's fields. Omitted fields take the KMD default; + /// the completed tuple is this kernel's catalog key and must be unique engine-wide. + MetadataValues metadata; + /// Tie-break when the heuristic is not decisive. Higher wins. + int64_t priority = 0; +}; + +/// KDP: one pack binding a matcher set, one engine, and one dispatch descriptor over a +/// vector of child kernels. Everything except the child kernels is shared by id. +struct KernelDescriptorPack +{ + DescriptorId id; + std::string name; + /// A child kernel applies only when every one of these matchers passes. + std::vector matcherIds; + DescriptorId engineId; + DescriptorId dispatchId; + std::vector kernels; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp new file mode 100644 index 000000000000..7eb790577df3 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -0,0 +1,132 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief One hipDNN engine, defined entirely by a UED and the packs naming it. + * + * Satisfies hipDNN's existing IEngine contract using descriptor data. No new engine or + * plugin-ABI interface is introduced: from the host's side this is an ordinary engine, + * and everything data-driven about it lives behind that contract. + * + * The engine's id is its UED name hashed into hipDNN's engine-id space, the same + * derivation a hand-written engine's registered name goes through. Because a + * descriptor-backed engine is defined by data rather than by a compile-time + * registration, its name is registered when the engine is constructed rather than at + * build time — which is why an id collision surfaces here, as a failure to create the + * engine, rather than as a link-time or startup error. + * + * Holds exactly one plan builder. A catalog entry is a candidate, not a builder, so an + * engine with 150 kernels still has one. + */ +template +class GenericEngine : public IEngine +{ +public: + using IGraph = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph; + using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; + + /** + * @param stateManager The engine's descriptor state, already validated. + * @param deviceProperties Supplies `$device.*`. Held by value: providers hand these + * out by value, so a reference would bind to a temporary. + * @param deviceId The device this engine's catalogs are keyed on. + */ + GenericEngine(std::shared_ptr> stateManager, + const hipDeviceProp_t& deviceProperties, + DeviceId deviceId) + : _stateManager(std::move(stateManager)) + , _id(hipdnn_data_sdk::utilities::engineNameToId(_stateManager->engine().name)) + , _planBuilder(_stateManager, deviceProperties, deviceId) + { + } + + int64_t id() const override + { + return _id; + } + + bool isApplicable(THandle& handle, const IGraph& opGraph) const override + { + return _planBuilder.isApplicable(handle, opGraph); + } + + /** + * @brief Reports this engine's knobs for @p opGraph. + * + * The returned buffer is detached and handed to the caller's handle to own, matching + * how every other engine in this provider answers the query: the host reads the + * buffer through the returned pointer and releases it by handle later. + */ + void getDetails(THandle& handle, + const IGraph& opGraph, + hipdnnPluginConstData_t& detailsOut) const override + { + flatbuffers::FlatBufferBuilder builder; + + std::vector> knobOffsets; + for(const auto& knob : _planBuilder.getCustomKnobs(handle, opGraph)) + { + knobOffsets.push_back(hipdnn_flatbuffers_sdk::data_objects::Knob::Pack(builder, &knob)); + } + + auto knobs = builder.CreateVector(knobOffsets); + auto engineDetails + = hipdnn_flatbuffers_sdk::data_objects::CreateEngineDetails(builder, _id, knobs); + builder.Finish(engineDetails); + + auto detachedBuffer = std::make_unique(builder.Release()); + detailsOut.ptr = detachedBuffer->data(); + detailsOut.size = detachedBuffer->size(); + handle.storeEngineDetailsDetachedBuffer(detailsOut.ptr, std::move(detachedBuffer)); + } + + size_t getMaxWorkspaceSize(const THandle& handle, + const IGraph& opGraph, + const IEngineConfig& engineConfig) const override + { + TSettings executionSettings; + _planBuilder.initializeExecutionSettings(handle, opGraph, engineConfig, executionSettings); + return _planBuilder.getMaxWorkspaceSize(handle, opGraph, executionSettings); + } + + void initializeExecutionContext(const THandle& handle, + const IGraph& opGraph, + const IEngineConfig& engineConfig, + TContext& executionContext) const override + { + TSettings executionSettings; + _planBuilder.initializeExecutionSettings(handle, opGraph, engineConfig, executionSettings); + executionContext.setExecutionSettings(executionSettings); + _planBuilder.buildPlan(handle, opGraph, engineConfig, executionContext); + } + +private: + std::shared_ptr> _stateManager; + int64_t _id; + GenericPlanBuilder _planBuilder; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp new file mode 100644 index 000000000000..f369fc8200c4 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -0,0 +1,97 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief An executable plan for one descriptor-selected kernel. + * + * Holds the kernel the heuristic chose, the handler that launches it, and the launch + * detail that handler resolved from the graph at build time. Every decision — which + * kernels applied, which ranked first, how much scratch it needs, what its arguments are + * — was made before this object existed, so execute() resolves nothing and re-matches + * nothing. + * + * Immutable after construction, as IPlan requires: the same plan may execute + * concurrently from several threads with different device buffers. + * + * The engine has one plan builder producing this one plan type, not one per kernel: a + * catalog entry is a candidate, and building a plan is what turns the chosen candidate + * into something launchable. + */ +template +class GenericPlan : public IPlan +{ +public: + /** + * @param dispatcher The selected kernel and its resolved dispatch handler. + * @param context Bound graph and device state. Read during construction to + * prepare the launch; not retained, because a plan outlives the + * graph reference this carries. + */ + GenericPlan(KernelDispatcher dispatcher, const MatchContext& context) + : _dispatcher(std::move(dispatcher)) + , _workspaceBytes(_dispatcher.handler->workspaceBytes(_dispatcher.kernel)) + , _prepared(_dispatcher.handler->prepare(context, _dispatcher.kernel)) + { + if(_prepared == nullptr) + { + throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, + "dispatch handler for kernel '" + + _dispatcher.kernel.kernelId + "' prepared no launch"); + } + } + + size_t getWorkspaceSize(const THandle& /*handle*/) const override + { + return _workspaceBytes; + } + + void execute(const THandle& handle, + const hipdnnPluginDeviceBuffer_t* deviceBuffers, + uint32_t numDeviceBuffers, + void* workspace = nullptr) const override + { + if(_workspaceBytes > 0 && workspace == nullptr) + { + throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE, + "kernel '" + _dispatcher.kernel.kernelId + "' requires " + + std::to_string(_workspaceBytes) + + " workspace bytes but none was provided"); + } + + _dispatcher.handler->launch(handle, *_prepared, deviceBuffers, numDeviceBuffers, workspace); + } + + /// The kernel this plan launches. Exposed for the resolved-plan diagnostics RFC 0017 + /// §10 calls for, and for tests asserting which kernel selection actually chose. + const KernelDefinition& kernel() const + { + return _dispatcher.kernel; + } + +private: + KernelDispatcher _dispatcher; + size_t _workspaceBytes; + std::unique_ptr _prepared; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp new file mode 100644 index 000000000000..3fc090bb1e58 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -0,0 +1,203 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief The one plan builder a descriptor-backed engine has. + * + * Not one per kernel: a catalog entry is a candidate, and this builder's job is to + * produce a plan that can launch whichever candidate selection chose. An engine with 150 + * kernels still has one builder and pays only for the kernel a plan needs. + * + * Every method here is a read of state the state manager already computed and cached. + * Applicability builds the catalog; the knob query and the plan build read the ranked + * order; the workspace query reads the dispatch descriptors. Nothing rebuilds. + * + * @tparam TSettings The provider's settings type. Carried through unchanged: knob + * settings reach the catalog as a filter (future work, see initializeExecutionSettings), + * not as provider-specific state this builder interprets. + */ +template +class GenericPlanBuilder : public IPlanBuilder +{ +public: + using IGraph = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph; + using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; + + /// @param stateManager The engine's descriptor state. Shared rather than owned so the + /// engine and its builder read one catalog cache. + /// @param deviceProperties Supplies `$device.*`. Held by value: providers hand these + /// out by value, so a reference would bind to a temporary. + GenericPlanBuilder(std::shared_ptr> stateManager, + hipDeviceProp_t deviceProperties, + DeviceId deviceId) + : _stateManager(std::move(stateManager)) + , _deviceProperties(deviceProperties) + , _deviceId(deviceId) + { + } + + /// Applicable exactly when some kernel survived matching. Deliberately does not rank: + /// a membership test needs no order, so the heuristic is never run here. + bool isApplicable(const THandle& /*handle*/, const IGraph& opGraph) const override + { + return !_stateManager->unsortedDefinitions(_deviceId, contextFor(opGraph)).empty(); + } + + /** + * @brief The largest scratch any surviving kernel needs. + * + * The maximum suffices because the buffer is reused rather than partitioned: kernels + * launch one at a time on one stream, so a candidate's scratch is live only while it + * runs. A kernel needing less over-allocates, which is accepted. + */ + size_t getMaxWorkspaceSize(const THandle& /*handle*/, + const IGraph& opGraph, + const TSettings& /*executionSettings*/) const override + { + const auto context = contextFor(opGraph); + size_t maxBytes = 0; + for(const auto& kernel : _stateManager->sortedDefinitions(_deviceId, context)) + { + const auto dispatcher = _stateManager->getDispatchDetails(kernel); + maxBytes = std::max(maxBytes, dispatcher.handler->workspaceBytes(kernel)); + } + return maxBytes; + } + + /// Knob settings are read into provider settings here in the full design, where they + /// filter the catalog before ranking. This skeleton's engine exposes its knob for + /// reporting only, so there is nothing to carry across. + void initializeExecutionSettings(const THandle& /*handle*/, + const IGraph& /*opGraph*/, + const IEngineConfig& /*engineConfig*/, + TSettings& /*executionSettings*/) const override + { + } + + /** + * @brief Builds a plan for the top-ranked kernel. + * + * @throws HipdnnPluginException if the catalog is empty. Reaching here with nothing + * to build means applicability accepted a graph this engine cannot serve — + * RFC 0017 §8.6 makes that a bug, not a legal outcome, so it surfaces as a + * failed plan build rather than a silent decline. + */ + void buildPlan(const THandle& /*handle*/, + const IGraph& opGraph, + const IEngineConfig& /*engineConfig*/, + TContext& executionContext) const override + { + const auto context = contextFor(opGraph); + const auto ranked = _stateManager->sortedDefinitions(_deviceId, context); + if(ranked.empty()) + { + throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, + "engine '" + _stateManager->engine().name + + "' accepted this graph but has no applicable kernel"); + } + + executionContext.setPlan(std::make_unique>( + _stateManager->getDispatchDetails(ranked.front()), context)); + } + + /** + * @brief One knob per KMD field the engine exposes. + * + * A knob's legal values are what the catalog implements for this graph, not the + * field's theoretical range — offering a value no surviving kernel carries would + * produce a request nothing can serve. Its default is whatever the heuristic ranked + * first, so leaving every knob alone reproduces the out-of-the-box selection. + */ + std::vector + getCustomKnobs(const THandle& /*handle*/, const IGraph& opGraph) const override + { + using namespace hipdnn_flatbuffers_sdk::data_objects; + + const auto ranked = _stateManager->sortedDefinitions(_deviceId, contextFor(opGraph)); + std::vector knobs; + if(ranked.empty()) + { + return knobs; + } + + for(const auto& knobName : _stateManager->engine().knobs) + { + const auto values = KernelIngestorStateManager::knobValues(ranked, knobName); + + std::vector choices; + choices.reserve(values.size()); + for(const auto& value : values) + { + // Only integer-valued fields are expressible as knobs today, since + // hipDNN's integer knob constraint carries int64 choices. A + // string-valued KMD field is skipped rather than stringified into a + // number that would mean nothing to a caller. + if(const auto* intValue = std::get_if(&value)) + { + choices.push_back(*intValue); + } + } + if(choices.empty()) + { + continue; + } + + KnobT knob; + knob.knob_id = knobName; + knob.description = "Kernel metadata field '" + knobName + "' of engine '" + + _stateManager->engine().name + "'"; + + IntValueT defaultValue; + // knobValues() preserves ranked order, so the first entry is the top-ranked + // kernel's value: leaving the knob alone reproduces the out-of-the-box + // selection. + defaultValue.value = choices.front(); + knob.default_value.Set(defaultValue); + + IntConstraintT constraint; + constraint.min_value = *std::min_element(choices.begin(), choices.end()); + constraint.max_value = *std::max_element(choices.begin(), choices.end()); + constraint.step = 1; + constraint.valid_values = std::move(choices); + knob.constraint.Set(constraint); + + knobs.push_back(std::move(knob)); + } + + return knobs; + } + +private: + MatchContext contextFor(const IGraph& opGraph) const + { + return MatchContext{opGraph, _deviceId, _deviceProperties}; + } + + std::shared_ptr> _stateManager; + hipDeviceProp_t _deviceProperties; + DeviceId _deviceId; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp new file mode 100644 index 000000000000..1ae7fd86702b --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp @@ -0,0 +1,108 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief Everything a launch needs that was derived from the graph, resolved once. + * + * A provider derives from this to hold whatever its kernels need: argument uids, launch + * geometry, a loaded code object, a preallocated argument buffer. + * + * It exists because a plan outlives the call that built it. The bound token state a + * MatchContext carries is references into a graph hipDNN owns for the duration of one + * plugin call, so a plan cannot keep it. Instead the handler reads that state once, at + * plan build, and hands back owned values. + * + * This is also the shape RFC 0017 §8.5 describes: the plan build evaluates the dispatch + * descriptor's grid, block, shared-memory and argument formulas over the bound token + * state, and execute afterwards only resolves device pointers by uid and launches. + * Nothing is re-matched and nothing is re-derived per execution. + */ +class PreparedDispatch +{ +public: + virtual ~PreparedDispatch() = default; +}; + +/** + * @brief The native escape hatch for a UDD: how to size, prepare, and launch a kernel. + * + * This is the seam that keeps provider-specific machinery out of the SDK. A provider's + * implementation holds whatever it needs to run a kernel — a compiler, a module cache, a + * loaded code object — and the SDK never names any of it. Templating on THandle matches + * IPlanBuilder and IPlan, which are parameterized the same way for the same reason. + * + * RFC 0017 §6 makes workspace part of this interface deliberately: the workspace query + * arrives before any kernel is chosen and before a plan exists, so anything that can + * launch must also be able to size its scratch. A handler that can launch but cannot + * answer the workspace question is incomplete. + * + * The data-driven replacement evaluates the UDD's expressions instead of calling native + * code. That is the UDD follow-up RFC; this interface is what it plugs into. + */ +template +class IKernelDispatchHandler +{ +public: + virtual ~IKernelDispatchHandler() = default; + + /** + * @brief Global scratch this kernel requires, in bytes. + * + * Answered per kernel, before selection, so the engine can report the maximum across + * every kernel the caller's knobs leave in the catalog. Taking only the kernel keeps + * the answer independent of which other kernels happen to be present. + */ + // NOLINTNEXTLINE(portability-template-virtual-member-function) + virtual size_t workspaceBytes(const KernelDefinition& kernel) const = 0; + + /** + * @brief Resolves everything @p kernel's launch needs from the bound token state. + * + * Called once, at plan build, while @p context is still valid. The returned object is + * owned by the plan and must not reference anything in @p context. + */ + // NOLINTNEXTLINE(portability-template-virtual-member-function) + virtual std::unique_ptr prepare(const MatchContext& context, + const KernelDefinition& kernel) const + = 0; + + /** + * @brief Launches a prepared kernel against the caller's device buffers. + * + * @param handle Supplies the stream the launch is ordered on. + * @param prepared The object this handler returned from prepare(). + * @param deviceBuffers uid/pointer pairs; resolve each argument by uid through + * hipdnn_plugin_sdk::findDeviceBuffer. + * @param numDeviceBuffers Length of @p deviceBuffers. + * @param workspace The plan's scratch, or nullptr when workspaceBytes() was 0. + * + * @note May be called concurrently from several threads with different device + * buffers, so it must not mutate @p prepared or the handler. + */ + // NOLINTNEXTLINE(portability-template-virtual-member-function) + virtual void launch(const THandle& handle, + const PreparedDispatch& prepared, + const hipdnnPluginDeviceBuffer_t* deviceBuffers, + uint32_t numDeviceBuffers, + void* workspace) const + = 0; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp new file mode 100644 index 000000000000..52a5ef48d496 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp @@ -0,0 +1,113 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include + +#include +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief Chooses which kernel within an engine to run, given the kernels that fit. + * + * One level below hipDNN's engine-selection heuristic, which decides *which engine* + * handles a graph and is untouched by this system. + * + * Two levels of interface, deliberately: + * + * - `score()` ranks one kernel at a time and never sees the catalog. Because a kernel's + * score cannot depend on which other kernels are present, filtering and ranking + * commute: a knob-filtered subset ranks exactly as it did in the whole catalog, so the + * kernel a knob setting selects is the kernel the reported default named. That failure + * — a reported default a knob setting then contradicts — is silent and surfaces to a + * user rather than to the author who caused it, which is why the property is + * structural here rather than advisory. + * + * - `rank()` is virtual and receives the whole catalog. A future selector that must + * reason over the candidate set as a whole (spreading a choice across a batch, or + * ranking by a criterion only meaningful relatively) overrides it. + * + * **Overriding `rank()` forfeits the consistency guarantee above.** An override must + * state what a knob-filtered query then means for it; the default implementation is the + * shape every day-one descriptor-backed heuristic should use. + */ +class IKernelHeuristic +{ +public: + virtual ~IKernelHeuristic() = default; + + /// @brief Scores one kernel for one problem. Higher wins. + virtual double score(const KernelDefinition& kernel, const MatchContext& context) const = 0; + + /** + * @brief Orders @p catalog best-first. + * + * The default implementation scores each entry independently and sorts descending, + * breaking ties deterministically: first on the kernel's explicit `priority`, then on + * its descriptor id compared as bytes. That id order carries no meaning — it is + * chosen only for being stable across runs, load orders, and machines, so two + * processes given the same catalog always choose the same kernel. + */ + virtual std::vector rank(const Catalog& catalog, + const MatchContext& context) const + { + std::vector ranked = catalog.entries; + + std::stable_sort( + ranked.begin(), + ranked.end(), + [this, &context](const KernelDefinition& lhs, const KernelDefinition& rhs) { + const double lhsScore = score(lhs, context); + const double rhsScore = score(rhs, context); + if(lhsScore != rhsScore) + { + return lhsScore > rhsScore; + } + if(lhs.priority != rhs.priority) + { + return lhs.priority > rhs.priority; + } + return lhs.kernelId < rhs.kernelId; + }); + + return ranked; + } +}; + +/** + * @brief An IKernelHeuristic whose score() is a native function resolved by symbol. + * + * The UHD escape hatch: the descriptor names a symbol, this resolves it. The + * data-driven form loads a model artifact and assembles its feature vector from the + * bound token state instead — the UHD follow-up RFC — and slots in here as another + * IKernelHeuristic. + */ +class NativeKernelHeuristic : public IKernelHeuristic +{ +public: + explicit NativeKernelHeuristic(ScoreFn scoreFn) + : _scoreFn(scoreFn) + { + } + + double score(const KernelDefinition& kernel, const MatchContext& context) const override + { + return _scoreFn(kernel, context); + } + +private: + ScoreFn _scoreFn; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp new file mode 100644 index 000000000000..0c5d003db226 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp @@ -0,0 +1,100 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include + +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief One catalog entry: a kernel that passed every matcher for a graph. + * + * This is the POD that leaves KernelIngestorStateManager. It is copied out rather than + * handed out by reference so a caller holds a stable snapshot while the cache it came + * from is concurrently evicted or refilled. + * + * It carries the kernel's completed metadata tuple (its catalog key), enough source + * detail to load it, and the ids needed to find the dispatch descriptor that launches + * it — but no pointer back into the descriptor set. + */ +struct KernelDefinition +{ + DescriptorId kernelId; + /// The pack this kernel came from, which owns the matchers and UDD that apply to it. + DescriptorId packId; + /// The dispatch descriptor id, denormalized from the pack so a caller holding only a + /// KernelDefinition can reach its dispatch without walking the pack list again. + DescriptorId dispatchId; + std::string sourceFile; + std::string entryPoint; + /// Complete: every KMD field, with defaults filled in for fields the UKD omitted. + MetadataValues metadata; + int64_t priority = 0; + + /// @brief The value of a KMD field, or nullopt when this kernel has no such field. + /// + /// Returns nullopt rather than throwing so a matcher or scorer written against a + /// newer schema can ask about a field an older kernel predates. + std::optional tryGetMetadata(const std::string& field) const + { + auto it = metadata.find(field); + if(it == metadata.end()) + { + return std::nullopt; + } + return it->second; + } + + /// @brief The integer value of a KMD field. + /// @throws std::out_of_range if the field is absent, std::invalid_argument if it + /// holds a different alternative. Both are author errors a validating + /// loader would have caught, so they throw rather than returning a default + /// that would silently mis-rank or mis-launch a kernel. + int64_t getIntMetadata(const std::string& field) const + { + auto it = metadata.find(field); + if(it == metadata.end()) + { + throw std::out_of_range("kernel '" + kernelId + "' has no metadata field '" + field + + "'"); + } + const auto* value = std::get_if(&it->second); + if(value == nullptr) + { + throw std::invalid_argument("metadata field '" + field + "' of kernel '" + kernelId + + "' is not an integer"); + } + return *value; + } + + /// @brief The string value of a KMD field. Throws on the same terms as getIntMetadata. + const std::string& getStringMetadata(const std::string& field) const + { + auto it = metadata.find(field); + if(it == metadata.end()) + { + throw std::out_of_range("kernel '" + kernelId + "' has no metadata field '" + field + + "'"); + } + const auto* value = std::get_if(&it->second); + if(value == nullptr) + { + throw std::invalid_argument("metadata field '" + field + "' of kernel '" + kernelId + + "' is not a string"); + } + return *value; + } +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp new file mode 100644 index 000000000000..124ddd247f50 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -0,0 +1,391 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/// What a caller needs to size and launch one selected kernel: the kernel itself, plus +/// the dispatch handler its pack's UDD resolved to. Copied out like KernelDefinition, so +/// holding one does not pin the state manager's internals. +template +struct KernelDispatcher +{ + KernelDefinition kernel; + /// Non-owning. Points at a provider-owned handler that must outlive any plan built + /// from this dispatcher (see NativeRegistry's DispatchRegistry note). + const IKernelDispatchHandler* handler = nullptr; +}; + +/** + * @brief The engine's view of its own kernels: which apply to a graph, in what order, + * and how to launch one. + * + * Holds an engine's descriptor set (one UED, its KMD and UHD, and the packs naming it) + * and answers the three questions hipDNN's four host calls reduce to: + * + * | Host call | Answered by | + * |------------------------------|------------------------------------------------| + * | isApplicable | unsortedDefinitions() is non-empty | + * | getDetails (knobs) | sortedDefinitions() — value sets and defaults | + * | getMaxWorkspaceSize | getDispatchDetails() per survivor, then max | + * | initializeExecutionContext | sortedDefinitions().front(), getDispatchDetails() | + * + * **Work is done as late as possible and kept.** Applicability only needs to know + * whether any kernel survived, so it matches but does not rank. Ranking happens on the + * first call that needs an order, and is cached alongside the catalog it ordered. Both + * are keyed on (graph, device) — the problem — rather than on the handle, which is a + * caller-side object whose lifetime says nothing about whether the work is still valid. + * + * Thread-safe: the cache is internally synchronized, and matcher and scorer calls are + * made outside the lock, so one thread matching a graph never blocks another. + */ +template +class KernelIngestorStateManager +{ +public: + /// How many (graph, device) catalogs to retain. Entries hold ids and metadata values, + /// not kernels or graphs, so this is generous by design; eviction costs a rematch, + /// never a wrong answer. + static constexpr size_t DEFAULT_CATALOG_CACHE_CAPACITY = 256; + + /** + * @param engine The UED this manager serves. + * @param schema That engine's KMD. Supplies the defaults completing each kernel's + * metadata tuple. + * @param matchers Every UMD any of @p packs references, by id. + * @param dispatches Every UDD any of @p packs references, by id. + * @param packs The KDPs naming this engine. + * @param heuristic That engine's UHD, resolved to a scorer. + * + * @throws std::invalid_argument if a pack names a matcher or dispatch descriptor not + * supplied, or if two kernels share a metadata tuple. Both are load-time + * validation failures in the real system: a dangling cross-reference cannot + * be evaluated, and duplicate catalog keys leave selection with two + * indistinguishable candidates and no basis to prefer either. + */ + KernelIngestorStateManager(EngineDescriptor engine, + MetadataSchema schema, + std::vector matchers, + std::vector dispatches, + std::vector packs, + std::shared_ptr heuristic, + size_t catalogCacheCapacity = DEFAULT_CATALOG_CACHE_CAPACITY) + : _engine(std::move(engine)) + , _schema(std::move(schema)) + , _packs(std::move(packs)) + , _heuristic(std::move(heuristic)) + , _catalogCache(catalogCacheCapacity) + { + if(_heuristic == nullptr) + { + throw std::invalid_argument("kernel ingestor requires a heuristic"); + } + + for(auto& matcher : matchers) + { + _matchers.emplace(matcher.id, std::move(matcher)); + } + for(auto& dispatch : dispatches) + { + _dispatches.emplace(dispatch.id, std::move(dispatch)); + } + + validateAndIndexPacks(); + } + + const EngineDescriptor& engine() const + { + return _engine; + } + + const MetadataSchema& metadataSchema() const + { + return _schema; + } + + /** + * @brief Every kernel that applies to @p graph on @p deviceId, in no particular order. + * + * On a cache hit, returns the cached catalog. Otherwise runs the matchers in pruning + * order — each pack's graph-scoped matchers first, since one failure disqualifies the + * whole pack, then the kernel-scoped matchers over whatever survived — and caches the + * result. + * + * A graph carrying no identity cannot be cached (there is no key, and inventing one + * would alias unrelated graphs), so it is matched fresh every call. That costs time, + * never correctness. + */ + std::vector unsortedDefinitions(DeviceId deviceId, + const MatchContext& context) const + { + return catalogFor(deviceId, context).entries; + } + + /** + * @brief Every kernel that applies to @p graph on @p deviceId, best first. + * + * Returns the cached order if there is one; otherwise ranks the catalog and caches + * that order. If the catalog itself is missing, it is built first. + */ + std::vector sortedDefinitions(DeviceId deviceId, + const MatchContext& context) const + { + Catalog catalog = catalogFor(deviceId, context); + if(catalog.isSorted) + { + return catalog.entries; + } + + catalog.entries = _heuristic->rank(catalog, context); + catalog.isSorted = true; + + if(const auto key = cacheKey(deviceId, context); key.has_value()) + { + _catalogCache.put(*key, catalog); + } + + return catalog.entries; + } + + /** + * @brief Resolves how to size and launch @p kernel. + * @throws std::runtime_error if the kernel's pack is unknown or its UDD names a + * symbol the provider does not ship. Both fail closed: after applicability + * accepted a graph, hipDNN has already chosen this engine on that promise, so + * a missing dispatch is a hard error rather than a silent decline. + */ + KernelDispatcher getDispatchDetails(const KernelDefinition& kernel) const + { + auto it = _dispatches.find(kernel.dispatchId); + if(it == _dispatches.end()) + { + throw std::runtime_error("kernel '" + kernel.kernelId + + "' names unknown dispatch descriptor '" + kernel.dispatchId + + "'"); + } + + return {kernel, DispatchRegistry::resolve(it->second.dispatchSymbol)}; + } + + /// @brief The distinct values @p field takes across @p kernels, in ranked-first order. + /// + /// A knob offers what the catalog actually implements, never the KMD field's + /// theoretical range: offering a value no surviving kernel carries would produce a + /// request nothing can serve. + static std::vector knobValues(const std::vector& kernels, + const std::string& field) + { + std::vector values; + for(const auto& kernel : kernels) + { + const auto value = kernel.tryGetMetadata(field); + if(!value.has_value()) + { + continue; + } + if(std::find(values.begin(), values.end(), *value) == values.end()) + { + values.push_back(*value); + } + } + return values; + } + +private: + void validateAndIndexPacks() + { + std::vector seenKeys; + + for(const auto& pack : _packs) + { + for(const auto& matcherId : pack.matcherIds) + { + if(_matchers.find(matcherId) == _matchers.end()) + { + throw std::invalid_argument("pack '" + pack.id + "' names unknown matcher '" + + matcherId + "'"); + } + } + if(_dispatches.find(pack.dispatchId) == _dispatches.end()) + { + throw std::invalid_argument("pack '" + pack.id + + "' names unknown dispatch descriptor '" + + pack.dispatchId + "'"); + } + + for(const auto& kernel : pack.kernels) + { + auto key = completeMetadata(kernel); + if(std::find(seenKeys.begin(), seenKeys.end(), key) != seenKeys.end()) + { + throw std::invalid_argument( + "kernel '" + kernel.id + + "' duplicates the metadata tuple of another kernel in engine '" + + _engine.name + "'; the tuple is the catalog key and must be unique"); + } + seenKeys.push_back(std::move(key)); + } + } + } + + /// A kernel's declared values plus the KMD's defaults for every field it omitted. + /// This completed tuple, not the descriptor id, is what identifies the kernel to + /// matching and ranking. + MetadataValues completeMetadata(const KernelDescriptor& kernel) const + { + MetadataValues complete = kernel.metadata; + for(const auto& field : _schema.fields) + { + complete.emplace(field.name, field.defaultValue); + } + return complete; + } + + std::optional cacheKey(DeviceId deviceId, const MatchContext& context) const + { + const auto graphId = tryGetGraphId(context.graph); + if(!graphId.has_value()) + { + return std::nullopt; + } + return CatalogKey{*graphId, deviceId}; + } + + Catalog catalogFor(DeviceId deviceId, const MatchContext& context) const + { + const auto key = cacheKey(deviceId, context); + if(key.has_value()) + { + if(auto cached = _catalogCache.get(*key); cached.has_value()) + { + return *cached; + } + } + + Catalog catalog = buildCatalog(context); + + if(key.has_value()) + { + _catalogCache.put(*key, catalog); + } + + return catalog; + } + + /** + * @brief Runs every pack's matchers over @p context, cheapest and broadest first. + * + * Graph-scoped matchers read only graph and device facts, so each runs once for the + * whole pack and one failure disqualifies every kernel in it without any per-kernel + * work. Only then do the kernel-scoped matchers run, once per surviving kernel. + * + * That ordering is the point of splitting matchers by scope: it is what keeps + * applicability cheap for an engine whose packs do not apply, which is most engines + * for most graphs. + */ + Catalog buildCatalog(const MatchContext& context) const + { + Catalog catalog; + + for(const auto& pack : _packs) + { + if(!graphLevelMatchersPass(pack, context)) + { + continue; + } + + for(const auto& kernel : pack.kernels) + { + KernelDefinition definition{kernel.id, + pack.id, + pack.dispatchId, + kernel.sourceFile, + kernel.entryPoint, + completeMetadata(kernel), + kernel.priority}; + + if(kernelLevelMatchersPass(pack, context, definition)) + { + catalog.entries.push_back(std::move(definition)); + } + } + } + + return catalog; + } + + bool graphLevelMatchersPass(const KernelDescriptorPack& pack, const MatchContext& context) const + { + for(const auto& matcherId : pack.matcherIds) + { + const auto& matcher = _matchers.at(matcherId); + if(matcher.scope != MatchScope::GRAPH) + { + continue; + } + if(!GraphMatcherRegistry::resolve(matcher.matchSymbol)(context)) + { + return false; + } + } + return true; + } + + bool kernelLevelMatchersPass(const KernelDescriptorPack& pack, + const MatchContext& context, + const KernelDefinition& kernel) const + { + for(const auto& matcherId : pack.matcherIds) + { + const auto& matcher = _matchers.at(matcherId); + if(matcher.scope != MatchScope::KERNEL) + { + continue; + } + if(!KernelMatcherRegistry::resolve(matcher.matchSymbol)(context, kernel)) + { + return false; + } + } + return true; + } + + EngineDescriptor _engine; + MetadataSchema _schema; + std::unordered_map _matchers; + std::unordered_map _dispatches; + std::vector _packs; + std::shared_ptr _heuristic; + /// Mutable because the query methods are logically const: they answer questions about + /// the descriptor set without changing it, and caching is an optimization invisible + /// to the caller. The cache is internally synchronized. + mutable LruCache _catalogCache; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp new file mode 100644 index 000000000000..b3a0153327dd --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp @@ -0,0 +1,120 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief A bounded, thread-safe least-recently-used cache. + * + * Sized by entry count rather than bytes: the ingestor's entries hold descriptor ids and + * bound field values, not kernels or graphs, so their size is bounded by construction. + * + * Eviction costs a rematch, never a wrong answer, so the capacity is a memory/latency + * tradeoff rather than a correctness one. + * + * @tparam Key Must be hashable by @p Hash and equality-comparable. + * @tparam Value Copied in and out; callers hold snapshots, not references into the cache. + * @tparam Hash Defaults to std::hash. Composite keys need an explicit hash, since + * the standard library provides none for std::pair. + */ +template > +class LruCache +{ +public: + /// @throws std::invalid_argument if @p capacity is zero, which would make every + /// insertion immediately evict itself — always a caller bug, never intent. + explicit LruCache(size_t capacity) + : _capacity(capacity) + { + if(capacity == 0) + { + throw std::invalid_argument("LruCache capacity must be non-zero"); + } + } + + /// @brief Looks up @p key, marking it most-recently-used on a hit. + /// @return A copy of the cached value, or nullopt on a miss. + std::optional get(const Key& key) + { + const std::lock_guard lock(_mutex); + + auto it = _index.find(key); + if(it == _index.end()) + { + return std::nullopt; + } + + _order.splice(_order.begin(), _order, it->second); + return it->second->second; + } + + /// @brief Inserts or overwrites @p key, marking it most-recently-used and evicting + /// the least-recently-used entry if that pushes the cache over capacity. + void put(const Key& key, Value value) + { + const std::lock_guard lock(_mutex); + + auto it = _index.find(key); + if(it != _index.end()) + { + it->second->second = std::move(value); + _order.splice(_order.begin(), _order, it->second); + return; + } + + _order.emplace_front(key, std::move(value)); + _index[key] = _order.begin(); + + if(_index.size() > _capacity) + { + _index.erase(_order.back().first); + _order.pop_back(); + } + } + + /// @brief Drops every entry. The next query for any key rematches. + void clear() + { + const std::lock_guard lock(_mutex); + _order.clear(); + _index.clear(); + } + + size_t size() const + { + const std::lock_guard lock(_mutex); + return _index.size(); + } + + size_t capacity() const + { + return _capacity; + } + +private: + using Entry = std::pair; + + mutable std::mutex _mutex; + size_t _capacity; + /// Most-recently-used first. A list so splicing an entry to the front on a hit does + /// not invalidate the iterators held in _index. + std::list _order; + std::unordered_map::iterator, Hash> _index; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp new file mode 100644 index 000000000000..39f25ea6ad63 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -0,0 +1,92 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include + +#include +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/// The device a catalog was built for. A plain HIP device ordinal, as returned by +/// hipGetDevice; hipDNN has no richer device-identity type today. +using DeviceId = int; + +/// A finalized graph's stable identity, minted by hipDNN at finalization and preserved +/// across serialization round trips. +using GraphId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; + +/// The catalog cache key. Deliberately excludes the handle: a handle is a caller-side +/// object that can be swapped, rebound, or destroyed while a plan built through it is +/// still live, so keying on it would tie cached work to a lifetime unrelated to that +/// work's validity. Two handles on one device share an entry; one handle rebound to +/// another device does not. +using CatalogKey = std::pair; + +/// std::hash has no std::pair specialization, so the cache needs this explicitly. +struct CatalogKeyHash +{ + size_t operator()(const CatalogKey& key) const noexcept + { + // The graph id is already a well-distributed 128-bit value (a UUID v4), so + // folding its bytes and mixing the device ordinal in is sufficient here; this + // keys an in-process cache and is never persisted or exposed. + size_t hash = 1469598103934665603ULL; + for(const uint8_t byte : key.first) + { + hash ^= static_cast(byte); + hash *= 1099511628211ULL; + } + hash ^= static_cast(key.second) + 0x9e3779b9ULL + (hash << 6U) + (hash >> 2U); + return hash; + } +}; + +/** + * @brief The bound token state a matcher, scorer, or dispatch formula reads. + * + * RFC 0017 names five expression namespaces; this skeleton binds the two that do not + * require a matcher to have run first — `$graph.*` (and the tensor and node fields + * reached through it) and `$device.*`. The third, `$kernel.*`, arrives separately as a + * KernelDefinition, because a kernel-scoped check is evaluated once per candidate while + * this context is bound once per (graph, device). + * + * Holds references, not copies: it is built on the stack for the duration of one + * matching pass and never outlives the graph it names. + */ +struct MatchContext +{ + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& graph; + DeviceId deviceId; + const hipDeviceProp_t& deviceProperties; +}; + +/// @brief The graph's stable identity, or nullopt when it has none. +/// +/// Absent on legacy graphs and on locally constructed graphs that were never finalized. +/// Callers must treat that as "cannot cache", never as an error: matching a graph with +/// no id still produces the right answer, it just cannot be memoized (there is no key to +/// memoize it under, and inventing one would alias unrelated graphs). +inline std::optional + tryGetGraphId(const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& graph) +{ + const auto* id = graph.getGraph().id(); + if(id == nullptr) + { + return std::nullopt; + } + return hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*id); +} + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp new file mode 100644 index 000000000000..acacee8cfda7 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp @@ -0,0 +1,148 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include + +#include +#include +#include + +/** + * @file NativeRegistry.hpp + * @brief Symbol name to native function, the escape hatch descriptors resolve through. + * + * RFC 0017 gives every declarative step a named escape hatch for the case its built-ins + * cannot express: a criterion may invoke a custom predicate, and a UDD may name a custom + * plan. In both cases the descriptor carries only a *symbol name*, never inline code, + * and the provider resolves it from a registry that is part of its published contract. + * + * This skeleton uses that escape hatch as the only implementation: every UMD, UHD, and + * UDD names a symbol here rather than carrying an expression or a model. Replacing a + * native symbol with real declarative data is a change to one descriptor field, not to + * the ingestor — which is the property this arrangement exists to demonstrate. + * + * Lookup fails closed. A descriptor naming a symbol the provider does not ship throws + * rather than being silently skipped, so a missing registration surfaces as a clear + * error at the point of use instead of as an engine that mysteriously matches nothing. + */ +namespace hipdnn_plugin_sdk::ingestor +{ + +/// A graph-scoped applicability check: reads only graph and device facts, so it runs +/// once per (graph, device) and its failure prunes every kernel in the pack. +using GraphMatcherFn = bool (*)(const MatchContext&); + +/// A kernel-scoped applicability check: also reads the candidate's metadata, so it runs +/// once per surviving kernel and disqualifies that kernel alone. +using KernelMatcherFn = bool (*)(const MatchContext&, const KernelDefinition&); + +/// Scores one kernel for one problem. Higher is better. +/// +/// Deliberately never handed the catalog: see IKernelHeuristic for why that constraint +/// is what keeps a knob-filtered query consistent with the default it reported. +using ScoreFn = double (*)(const KernelDefinition&, const MatchContext&); + +/** + * @brief The provider's registry of native implementations, keyed by symbol name. + * + * One instance per registered type per process, reached through the static accessors + * below. Registration happens at static-init time from the provider's own translation + * units; lookup happens on the applicability, ranking, and dispatch paths. + * + * Guarded by a mutex because registration and lookup can race: a provider loaded on one + * thread while another is already matching is unusual but not forbidden. + * + * @tparam T The registered callable or interface pointer type. + */ +template +class NativeRegistry +{ +public: + /** + * @brief Registers @p implementation under @p symbol. + * @throws std::runtime_error if @p symbol is already registered. Two implementations + * behind one name means one of them is silently unreachable, and which one + * wins would depend on static-init order — so this is always an author bug + * and is reported rather than resolved. + */ + static void registerSymbol(const std::string& symbol, T implementation) + { + auto& self = instance(); + const std::lock_guard lock(self._mutex); + + auto [it, inserted] = self._symbols.emplace(symbol, implementation); + if(!inserted) + { + throw std::runtime_error("duplicate native ingestor symbol registration: " + symbol); + } + } + + /** + * @brief Resolves @p symbol. + * @throws std::runtime_error if no implementation is registered under it. + */ + static T resolve(const std::string& symbol) + { + auto& self = instance(); + const std::lock_guard lock(self._mutex); + + auto it = self._symbols.find(symbol); + if(it == self._symbols.end()) + { + throw std::runtime_error("unresolved native ingestor symbol: " + symbol); + } + return it->second; + } + + static bool isRegistered(const std::string& symbol) + { + auto& self = instance(); + const std::lock_guard lock(self._mutex); + return self._symbols.find(symbol) != self._symbols.end(); + } + + /// @brief Removes @p symbol if present. Exists so a test can install a counting or + /// failing implementation and restore the original afterwards; production code + /// registers once at static-init time and never unregisters. + static void unregisterSymbol(const std::string& symbol) + { + auto& self = instance(); + const std::lock_guard lock(self._mutex); + self._symbols.erase(symbol); + } + +private: + static NativeRegistry& instance() + { + // Function-local static: initialized on first use, so this is immune to the + // unspecified initialization order of the namespace-scope registrations that + // populate it. + static NativeRegistry s_instance; + return s_instance; + } + + std::mutex _mutex; + std::unordered_map _symbols; +}; + +using GraphMatcherRegistry = NativeRegistry; +using KernelMatcherRegistry = NativeRegistry; +using ScoreRegistry = NativeRegistry; + +/// Dispatch handlers are registered as non-owning pointers to provider-owned objects, +/// because a handler holds provider state (a compiler, a module cache) whose lifetime is +/// the provider's, not the registry's. The provider must keep the handler alive for as +/// long as any plan built from it can execute. +template +using DispatchRegistry = NativeRegistry*>; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt index 3c09159bc76d..62c104fd4742 100644 --- a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt @@ -26,6 +26,7 @@ add_executable(hipdnn_plugin_sdk_tests TestVersion.cpp TestPluginDeviceBuffers.cpp TestRuntimePassByValue.cpp + $<$:TestIngestorSkeleton.cpp> ) target_compile_options(hipdnn_plugin_sdk_tests PRIVATE ${HIPDNN_WARNING_COMPILE_OPTIONS} ${HIPDNN_NO_RTTI_OPTIONS}) diff --git a/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp b/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp new file mode 100644 index 000000000000..3c137ec88135 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp @@ -0,0 +1,676 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @file TestIngestorSkeleton.cpp + * @brief Unit tests for the descriptor-driven kernel ingestor's SDK-side machinery. + * + * Covers the pieces every descriptor-backed engine depends on, independent of any + * provider: the bounded catalog cache, the native symbol registry's fail-closed + * behavior, the heuristic's ranking and tie-break order, and the state manager's + * matching, pruning, caching, and validation. + * + * Provider-specific behavior (the pointwise-add matchers and the real GPU dispatch) + * is covered by the hip-kernel-provider tests. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; + +constexpr const char* BLOCK_SIZE = "block_size"; +constexpr const char* DTYPE = "dtype"; + +/// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the +/// rest of the interface throws: a test that starts depending on graph contents should +/// fail loudly rather than silently match against an empty graph. +class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph +{ +public: + /// @param graphId The identity to carry, or nullopt to model a legacy or + /// unfinalized graph that has none. + explicit TestGraph(std::optional graphId = std::nullopt) + { + flatbuffers::Offset graph; + if(graphId.has_value()) + { + const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*graphId); + auto name = _builder.CreateString("test_graph"); + hipdnn_flatbuffers_sdk::data_objects::GraphBuilder graphBuilder(_builder); + graphBuilder.add_name(name); + graphBuilder.add_id(&uuid); + graph = graphBuilder.Finish(); + } + else + { + graph = hipdnn_flatbuffers_sdk::data_objects::CreateGraphDirect(_builder, "test_graph"); + } + _builder.Finish(graph); + } + + const hipdnn_flatbuffers_sdk::data_objects::Graph& getGraph() const override + { + return *flatbuffers::GetRoot( + _builder.GetBufferPointer()); + } + + bool isValid() const override + { + return true; + } + + uint32_t nodeCount() const override + { + return 0; + } + + bool hasOnlySupportedAttributes( + std::set /*supported*/) const override + { + return true; + } + + const hipdnn_flatbuffers_sdk::data_objects::Node& getNode(uint32_t /*index*/) const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper& + getNodeWrapper(uint32_t /*index*/) const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const std::vector>& + nodeWrappers() const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const std::unordered_map& + getTensorMap() const override + { + return _tensors; + } + +private: + flatbuffers::FlatBufferBuilder _builder; + std::unordered_map + _tensors; +}; + +GraphId makeGraphId(uint8_t seed) +{ + GraphId id{}; + id.fill(seed); + return id; +} + +/// Counts how often each matcher shape ran, so a test can assert that a graph-scoped +/// matcher is evaluated once per (graph, device) while a kernel-scoped one is evaluated +/// once per surviving kernel. +struct MatcherCounters +{ + int graphCalls = 0; + int kernelCalls = 0; + + void reset() + { + graphCalls = 0; + kernelCalls = 0; + } +}; + +MatcherCounters& counters() +{ + static MatcherCounters s_counters; + return s_counters; +} + +bool acceptGraph(const MatchContext& /*context*/) +{ + ++counters().graphCalls; + return true; +} + +bool rejectGraph(const MatchContext& /*context*/) +{ + ++counters().graphCalls; + return false; +} + +/// Accepts only FLOAT kernels, so a pack carrying a HALF kernel is pruned down. +bool acceptFloatKernels(const MatchContext& /*context*/, const KernelDefinition& kernel) +{ + ++counters().kernelCalls; + return kernel.getStringMetadata(DTYPE) == "FLOAT"; +} + +/// Bigger block size scores higher, so ranking has a defined winner. +double scoreByBlockSize(const KernelDefinition& kernel, const MatchContext& /*context*/) +{ + return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); +} + +/// Every kernel scores the same, so ranking falls through to the tie-break. +double scoreConstant(const KernelDefinition& /*kernel*/, const MatchContext& /*context*/) +{ + return 1.0; +} + +MetadataSchema makeSchema() +{ + return {"kmd", "test schema", {{BLOCK_SIZE, int64_t{64}}, {DTYPE, std::string{"FLOAT"}}}}; +} + +EngineDescriptor makeEngine(std::vector knobs = {BLOCK_SIZE}) +{ + return {"ued", "test:engine", "uhd", "kmd", std::move(knobs)}; +} + +KernelDescriptor + makeKernel(std::string id, int64_t blockSize, std::string dtype, int64_t priority = 0) +{ + KernelDescriptor kernel; + kernel.id = std::move(id); + kernel.name = kernel.id; + kernel.sourceFile = "Test.cpp"; + kernel.entryPoint = "TestKernel"; + kernel.metadata = {{BLOCK_SIZE, blockSize}, {DTYPE, std::move(dtype)}}; + kernel.priority = priority; + return kernel; +} + +/// The pack shape the provider POC uses: two FLOAT kernels differing only in block size, +/// plus a HALF kernel the kernel-scoped matcher prunes. +KernelDescriptorPack makePack(std::vector matcherIds) +{ + KernelDescriptorPack pack; + pack.id = "kdp"; + pack.name = "test pack"; + pack.matcherIds = std::move(matcherIds); + pack.engineId = "ued"; + pack.dispatchId = "udd"; + pack.kernels = {makeKernel("kernel_64_float", 64, "FLOAT"), + makeKernel("kernel_256_float", 256, "FLOAT"), + makeKernel("kernel_64_half", 64, "HALF")}; + return pack; +} + +/// Registers the symbols a test's descriptors name, and removes them afterwards so +/// tests sharing the process-wide registry stay independent. +class ScopedSymbols +{ +public: + ScopedSymbols(std::string graphSymbol, + GraphMatcherFn graphFn, + std::string kernelSymbol, + KernelMatcherFn kernelFn) + : _graphSymbol(std::move(graphSymbol)) + , _kernelSymbol(std::move(kernelSymbol)) + { + GraphMatcherRegistry::registerSymbol(_graphSymbol, graphFn); + KernelMatcherRegistry::registerSymbol(_kernelSymbol, kernelFn); + counters().reset(); + } + + ~ScopedSymbols() + { + GraphMatcherRegistry::unregisterSymbol(_graphSymbol); + KernelMatcherRegistry::unregisterSymbol(_kernelSymbol); + } + + ScopedSymbols(const ScopedSymbols&) = delete; + ScopedSymbols& operator=(const ScopedSymbols&) = delete; + +private: + std::string _graphSymbol; + std::string _kernelSymbol; +}; + +using TestHandle = int; +using StateManager = KernelIngestorStateManager; + +std::unique_ptr makeStateManager(ScoreFn scoreFn = scoreByBlockSize, + size_t cacheCapacity + = StateManager::DEFAULT_CATALOG_CACHE_CAPACITY) +{ + std::vector matchers{ + {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}, + {"umd_kernel", "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; + std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; + + return std::make_unique( + makeEngine(), + makeSchema(), + std::move(matchers), + std::move(dispatches), + std::vector{makePack({"umd_graph", "umd_kernel"})}, + std::make_shared(scoreFn), + cacheCapacity); +} + +hipDeviceProp_t testDeviceProperties() +{ + hipDeviceProp_t properties{}; + properties.warpSize = 64; + return properties; +} + +// --------------------------------------------------------------------------- +// LruCache +// --------------------------------------------------------------------------- + +TEST(TestIngestorLruCache, ReturnsCachedValueWithinCapacity) +{ + LruCache cache(2); + cache.put(1, "one"); + + const auto found = cache.get(1); + + ASSERT_TRUE(found.has_value()); + EXPECT_EQ(*found, "one"); +} + +TEST(TestIngestorLruCache, ReportsMissForAbsentKey) +{ + LruCache cache(2); + + EXPECT_FALSE(cache.get(42).has_value()); +} + +TEST(TestIngestorLruCache, EvictsLeastRecentlyUsedPastCapacity) +{ + LruCache cache(2); + cache.put(1, "one"); + cache.put(2, "two"); + cache.put(3, "three"); + + EXPECT_FALSE(cache.get(1).has_value()); + EXPECT_TRUE(cache.get(2).has_value()); + EXPECT_TRUE(cache.get(3).has_value()); + EXPECT_EQ(cache.size(), 2U); +} + +TEST(TestIngestorLruCache, ReadingAnEntryProtectsItFromEviction) +{ + LruCache cache(2); + cache.put(1, "one"); + cache.put(2, "two"); + + // Touching 1 makes 2 the least recently used, so inserting 3 must evict 2, not 1. + ASSERT_TRUE(cache.get(1).has_value()); + cache.put(3, "three"); + + EXPECT_TRUE(cache.get(1).has_value()); + EXPECT_FALSE(cache.get(2).has_value()); +} + +TEST(TestIngestorLruCache, OverwritingAKeyDoesNotGrowTheCache) +{ + LruCache cache(2); + cache.put(1, "one"); + cache.put(1, "uno"); + + const auto found = cache.get(1); + + ASSERT_TRUE(found.has_value()); + EXPECT_EQ(*found, "uno"); + EXPECT_EQ(cache.size(), 1U); +} + +TEST(TestIngestorLruCache, RejectsZeroCapacity) +{ + // A zero-capacity cache would evict every entry as it was inserted, which is always + // a caller bug rather than a way to disable caching. + using IntCache = LruCache; + EXPECT_THROW(IntCache(0), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// NativeRegistry +// --------------------------------------------------------------------------- + +TEST(TestIngestorNativeRegistry, ResolvesARegisteredSymbol) +{ + GraphMatcherRegistry::registerSymbol("registry.resolves", acceptGraph); + + EXPECT_EQ(GraphMatcherRegistry::resolve("registry.resolves"), acceptGraph); + + GraphMatcherRegistry::unregisterSymbol("registry.resolves"); +} + +TEST(TestIngestorNativeRegistry, RejectsDuplicateRegistration) +{ + GraphMatcherRegistry::registerSymbol("registry.duplicate", acceptGraph); + + // Two implementations behind one name leaves one silently unreachable, and which one + // wins would depend on static-init order. + EXPECT_THROW(GraphMatcherRegistry::registerSymbol("registry.duplicate", rejectGraph), + std::runtime_error); + + GraphMatcherRegistry::unregisterSymbol("registry.duplicate"); +} + +TEST(TestIngestorNativeRegistry, FailsClosedOnUnknownSymbol) +{ + // A descriptor naming a symbol the provider does not ship must surface as an error, + // never as an engine that quietly matches nothing. + EXPECT_THROW(GraphMatcherRegistry::resolve("registry.never_registered"), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// IKernelHeuristic +// --------------------------------------------------------------------------- + +TEST(TestIngestorHeuristic, RanksHigherScoringKernelsFirst) +{ + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + Catalog catalog; + catalog.entries = {{"low", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 0}, + {"high", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{256}}}, 0}}; + + const NativeKernelHeuristic heuristic(scoreByBlockSize); + const auto ranked = heuristic.rank(catalog, context); + + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().kernelId, "high"); +} + +TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) +{ + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + Catalog catalog; + catalog.entries = {{"a", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 1}, + {"b", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 5}}; + + const NativeKernelHeuristic heuristic(scoreConstant); + const auto ranked = heuristic.rank(catalog, context); + + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().kernelId, "b"); +} + +TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) +{ + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + Catalog catalog; + // Deliberately inserted out of id order: the result must not depend on load order. + catalog.entries = {{"zulu", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 0}, + {"alpha", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 0}}; + + const NativeKernelHeuristic heuristic(scoreConstant); + const auto ranked = heuristic.rank(catalog, context); + + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().kernelId, "alpha"); +} + +// --------------------------------------------------------------------------- +// KernelIngestorStateManager +// --------------------------------------------------------------------------- + +TEST(TestIngestorStateManager, KernelLevelMatcherPrunesTheCatalog) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(1)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto definitions = manager->unsortedDefinitions(0, context); + + // Three kernels in the pack; the HALF one does not survive. + ASSERT_EQ(definitions.size(), 2U); + for(const auto& definition : definitions) + { + EXPECT_EQ(definition.getStringMetadata(DTYPE), "FLOAT"); + } +} + +TEST(TestIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) +{ + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(2)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + EXPECT_TRUE(manager->unsortedDefinitions(0, context).empty()); + // The whole point of the graph/kernel split: a pack that cannot serve the graph + // costs one matcher call, not one per kernel. + EXPECT_EQ(counters().graphCalls, 1); + EXPECT_EQ(counters().kernelCalls, 0); +} + +TEST(TestIngestorStateManager, MatchesOncePerGraphAndDevice) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(3)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + manager->unsortedDefinitions(0, context); + manager->unsortedDefinitions(0, context); + manager->unsortedDefinitions(0, context); + + EXPECT_EQ(counters().graphCalls, 1); + // One call per kernel in the pack, on the single uncached pass. + EXPECT_EQ(counters().kernelCalls, 3); +} + +TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(4)); + const auto properties = testDeviceProperties(); + + manager->unsortedDefinitions(0, MatchContext{graph, 0, properties}); + manager->unsortedDefinitions(1, MatchContext{graph, 1, properties}); + + // The same graph on a different device is a different problem: a kernel applicable + // on one device need not be applicable on another. + EXPECT_EQ(counters().graphCalls, 2); +} + +TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + // A legacy or unfinalized graph: there is no key to memoize under, and inventing one + // would alias unrelated graphs. + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto first = manager->unsortedDefinitions(0, context); + const auto second = manager->unsortedDefinitions(0, context); + + // Correct both times; only the caching is lost. + EXPECT_EQ(first.size(), 2U); + EXPECT_EQ(second.size(), 2U); + EXPECT_EQ(counters().graphCalls, 2); +} + +TEST(TestIngestorStateManager, RematchesAfterCacheEviction) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(scoreByBlockSize, 1); + const auto properties = testDeviceProperties(); + const TestGraph first(makeGraphId(5)); + const TestGraph second(makeGraphId(6)); + + manager->unsortedDefinitions(0, MatchContext{first, 0, properties}); + manager->unsortedDefinitions(0, MatchContext{second, 0, properties}); + manager->unsortedDefinitions(0, MatchContext{first, 0, properties}); + + // Capacity 1, so the second graph evicted the first and it had to rematch. Eviction + // costs work, never a wrong answer. + EXPECT_EQ(counters().graphCalls, 3); +} + +TEST(TestIngestorStateManager, SortedDefinitionsAreRankedBestFirst) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(7)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto sorted = manager->sortedDefinitions(0, context); + + ASSERT_EQ(sorted.size(), 2U); + EXPECT_EQ(sorted.front().getIntMetadata(BLOCK_SIZE), 256); +} + +TEST(TestIngestorStateManager, RankingReusesTheAlreadyMatchedCatalog) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(8)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + manager->unsortedDefinitions(0, context); + manager->sortedDefinitions(0, context); + manager->sortedDefinitions(0, context); + + // Ranking is a read of the cached catalog, never a rematch. + EXPECT_EQ(counters().graphCalls, 1); + EXPECT_EQ(counters().kernelCalls, 3); +} + +TEST(TestIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(9)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto values + = StateManager::knobValues(manager->sortedDefinitions(0, context), BLOCK_SIZE); + + // The pruned HALF kernel also carried block_size 64, but it contributes no value: + // a knob offers what the surviving catalog implements, not the schema's range. + ASSERT_EQ(values.size(), 2U); + EXPECT_EQ(std::get(values[0]), 256); + EXPECT_EQ(std::get(values[1]), 64); +} + +TEST(TestIngestorStateManager, CompletesKernelMetadataFromSchemaDefaults) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + + KernelDescriptorPack pack = makePack({"umd_graph", "umd_kernel"}); + // A kernel that declares no metadata at all takes every schema default. + KernelDescriptor sparse; + sparse.id = "kernel_defaults"; + sparse.name = sparse.id; + pack.kernels = {sparse}; + + std::vector matchers{ + {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}, + {"umd_kernel", "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; + std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; + + const StateManager manager(makeEngine(), + makeSchema(), + std::move(matchers), + std::move(dispatches), + {pack}, + std::make_shared(scoreByBlockSize)); + + const TestGraph graph(makeGraphId(10)); + const auto properties = testDeviceProperties(); + const auto definitions = manager.unsortedDefinitions(0, MatchContext{graph, 0, properties}); + + ASSERT_EQ(definitions.size(), 1U); + EXPECT_EQ(definitions.front().getIntMetadata(BLOCK_SIZE), 64); + EXPECT_EQ(definitions.front().getStringMetadata(DTYPE), "FLOAT"); +} + +TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownMatcher) +{ + std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; + + // A dangling cross-reference cannot be evaluated, so it is caught when the + // descriptor set is assembled rather than when a graph first arrives. + EXPECT_THROW(StateManager(makeEngine(), + makeSchema(), + {}, + std::move(dispatches), + {makePack({"umd_missing"})}, + std::make_shared(scoreByBlockSize)), + std::invalid_argument); +} + +TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownDispatchDescriptor) +{ + std::vector matchers{ + {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}}; + + EXPECT_THROW(StateManager(makeEngine(), + makeSchema(), + std::move(matchers), + {}, + {makePack({"umd_graph"})}, + std::make_shared(scoreByBlockSize)), + std::invalid_argument); +} + +TEST(TestIngestorStateManager, RejectsTwoKernelsSharingAMetadataTuple) +{ + std::vector matchers{ + {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}}; + std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; + + KernelDescriptorPack pack = makePack({"umd_graph"}); + // Same completed tuple as kernel_64_float: selection would have two indistinguishable + // candidates and no basis to prefer either. + pack.kernels.push_back(makeKernel("kernel_duplicate", 64, "FLOAT")); + + EXPECT_THROW(StateManager(makeEngine(), + makeSchema(), + std::move(matchers), + std::move(dispatches), + {pack}, + std::make_shared(scoreByBlockSize)), + std::invalid_argument); +} + +TEST(TestIngestorStateManager, RejectsAMissingHeuristic) +{ + EXPECT_THROW(StateManager(makeEngine(), makeSchema(), {}, {}, {}, nullptr), + std::invalid_argument); +} + +} // namespace From b8f33a95a70785e626d68e3e06dad1719d223427 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 17:30:07 -0400 Subject: [PATCH 16/60] fix(hipdnn): key ingestor catalogs on the calling handle's device (ALMIOPEN-2402) Two defects in the kernel ingestor skeleton, both found by asking what happens when several handles share one provider container. The catalog cache is keyed on (graph, device), but the device was captured once when the engine was built rather than read from the handle each call. One container -- and therefore one cache -- is shared by every handle in the process, so two handles on different devices would have shared a catalog entry belonging to whichever device happened to be current at construction. That is a wrong answer, not a missed cache hit. IDeviceResolver now resolves the device per call from the handle's stream, and the dispatch handler compiles against the properties the call carries instead of ones captured at construction. The dispatch handler was owned by the engine but registered in a process-wide registry. A container is destroyed when its last handle closes, so the registration outlived the handler it pointed at, and the next container resolved freed memory. The handler and its compiler are now process-lifetime, matching the registry that holds them. The second defect is worth describing precisely, because it hides well. Every test passed with it present: a freed handler carrying no per-instance state keeps answering through its stale vtable. It surfaced only as a segfault at process teardown, after the last test had already reported success. Verified by mutation: reintroducing engine ownership makes the integration binary exit 139 on every run, and the fix returns it to 0. Testing: - 8 new concurrency tests: the LRU under concurrent readers, writers and eviction; the state manager serving one graph, several devices, uncacheable graphs and constant eviction from 8 threads; concurrent registry resolution. - Clean under ThreadSanitizer: 8/8 pass, zero race reports. - A regression test that the registered dispatch still resolves and launches after a container is destroyed and rebuilt. It documents what it cannot prove: the use-after-free is caught by the integration binary's exit status, not by any assertion. - Full suites green: 125 plugin_sdk, 460 provider unit, 14 integration, all exiting 0. Flag off unchanged at 428 unit + 5 integration. JIRA ID : ALMIOPEN-2402 --- .../src/core/Container.cpp | 6 +- .../src/ingestor_poc/HandleDeviceResolver.hpp | 82 ++++++ .../PointwiseAddDispatchHandler.cpp | 3 +- .../PointwiseAddDispatchHandler.hpp | 10 +- .../src/ingestor_poc/PointwiseAddEngine.cpp | 57 ++--- .../src/ingestor_poc/PointwiseAddEngine.hpp | 4 +- .../TestPointwiseAddDispatchHandler.cpp | 55 +++- .../ingestor/GenericEngine.hpp | 14 +- .../ingestor/GenericPlanBuilder.hpp | 43 ++-- .../ingestor/IDeviceResolver.hpp | 56 +++++ .../hipdnn/plugin_sdk/tests/CMakeLists.txt | 1 + .../plugin_sdk/tests/IngestorTestGraphs.hpp | 232 +++++++++++++++++ .../tests/TestIngestorConcurrency.cpp | 236 ++++++++++++++++++ .../plugin_sdk/tests/TestIngestorSkeleton.cpp | 154 ++---------- 14 files changed, 752 insertions(+), 201 deletions(-) create mode 100644 dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp create mode 100644 projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp create mode 100644 projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp create mode 100644 projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp diff --git a/dnn-providers/hip-kernel-provider/src/core/Container.cpp b/dnn-providers/hip-kernel-provider/src/core/Container.cpp index c9fcd419c7f6..889f56e65145 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Container.cpp +++ b/dnn-providers/hip-kernel-provider/src/core/Container.cpp @@ -75,9 +75,11 @@ const std::vector& Container::getEngineDefinitions( // because engines are still a compile-time table; runtime loading of engine // descriptors replaces this entry with one built per installed UED file. {ingestor_poc::pointwiseAddEngineId(), - [](const device::IDevicePropertyProvider& devicePropertyProvider) + [](const device::IDevicePropertyProvider& /*devicePropertyProvider*/) -> std::unique_ptr> { - return ingestor_poc::makePointwiseAddEngine(devicePropertyProvider); + // Device facts are resolved per call from the handle rather than captured + // here, so this engine needs nothing from the construction-time provider. + return ingestor_poc::makePointwiseAddEngine(); }}, #endif }; diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp new file mode 100644 index 000000000000..d302281bbf4b --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp @@ -0,0 +1,82 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include + +#include +#include + +#include "core/Handle.hpp" + +namespace hip_kernel_provider::ingestor_poc +{ + +/** + * @brief Resolves a call's device from the stream its handle carries. + * + * A handle can be bound to any device and rebound between calls, so the device is read + * per call rather than captured once. Resolving it from the handle's stream — rather + * than from whichever device happens to be current on the calling thread — is what makes + * the answer correct when several threads drive different handles concurrently. + * + * Device properties are cached, because they are asked on the applicability path and + * never change for a given device. The cache hands out references that stay valid for + * this resolver's lifetime, so entries are never erased or rehashed away: node handles + * in std::unordered_map keep referenced values pinned across growth. + */ +class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver +{ +public: + hipdnn_plugin_sdk::ingestor::DeviceId deviceId(const Handle& handle) const override + { + int deviceId = 0; + + // A null stream means the default stream, which belongs to the current device. + if(handle.getStream() != nullptr) + { + if(hipStreamGetDevice(handle.getStream(), &deviceId) == hipSuccess) + { + return deviceId; + } + } + + if(hipGetDevice(&deviceId) != hipSuccess) + { + // Only reachable with no usable HIP context, where nothing can be matched + // or launched anyway. Device 0 keys the cache consistently so the failure + // surfaces at compile or launch, with a message about the real problem, + // rather than here as a cache-key error. + return 0; + } + return deviceId; + } + + const hipDeviceProp_t& + deviceProperties(hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const override + { + const std::lock_guard lock(_mutex); + + auto it = _properties.find(deviceId); + if(it != _properties.end()) + { + return it->second; + } + + hipDeviceProp_t properties{}; + static_cast(hipGetDeviceProperties(&properties, deviceId)); + return _properties.emplace(deviceId, properties).first->second; + } + +private: + mutable std::mutex _mutex; + mutable std::unordered_map _properties; +}; + +} // namespace hip_kernel_provider::ingestor_poc + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp index ff1099df73cd..25e00d616e25 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp @@ -115,7 +115,8 @@ std::unique_ptr const auto blockSize = static_cast(kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD))); - compilation::KernelCompileOptions options(&firstInput(context, binding), _deviceProperties); + compilation::KernelCompileOptions options(&firstInput(context, binding), + context.deviceProperties); options.add("HIP_PLUGIN_POINTWISE_ADD_TYPE", elementTypeFor(kernel)); options.add("HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE", blockSize); diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp index 9a34bdba4b7d..4501210aa521 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp @@ -36,11 +36,12 @@ class PointwiseAddDispatchHandler { public: /// @param kernelCompiler Owned by the engine, which outlives this handler. - /// @param deviceProperties Copied: the provider hands these out by value. - PointwiseAddDispatchHandler(const compilation::IKernelCompiler& kernelCompiler, - hipDeviceProp_t deviceProperties) + /// + /// Device properties are not held: they arrive per call on the MatchContext, so a + /// kernel is always compiled for the device the call is actually for rather than + /// for whichever device was current when this handler was built. + explicit PointwiseAddDispatchHandler(const compilation::IKernelCompiler& kernelCompiler) : _kernelCompiler(kernelCompiler) - , _deviceProperties(deviceProperties) { } @@ -67,7 +68,6 @@ class PointwiseAddDispatchHandler private: const compilation::IKernelCompiler& _kernelCompiler; - hipDeviceProp_t _deviceProperties; }; /// @brief Registers @p handler under this pack's dispatch symbol. diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp index 21ae7ba08496..050ee4b35f4d 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp @@ -12,6 +12,7 @@ #include #include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" +#include "ingestor_poc/HandleDeviceResolver.hpp" #include "ingestor_poc/NativeMatchers.hpp" #include "ingestor_poc/PointwiseAddDispatchHandler.hpp" #include "ingestor_poc/PointwiseAddPack.hpp" @@ -22,16 +23,23 @@ namespace hip_kernel_provider::ingestor_poc namespace { -int currentDeviceId() +/** + * @brief The dispatch handler this pack's UDD resolves to. + * + * Process-lifetime, because the registry that holds it is. The registry stores a + * non-owning pointer, and a provider's container is created and destroyed along with + * its handles, so a handler owned by an engine would be freed while the registration + * still pointed at it -- and the next container's engine would resolve that dangling + * pointer rather than its own handler. + * + * The compiler it holds is a static for the same reason, and matches the module cache + * behind it, which is already process-wide. + */ +const PointwiseAddDispatchHandler& dispatchHandler() { - int deviceId = 0; - if(hipGetDevice(&deviceId) != hipSuccess) - { - // Catalogs are cached per device, so failing to resolve one is not fatal: - // device 0 keys the cache consistently and matching is unaffected. - return 0; - } - return deviceId; + static const HipMlopsKernelCompiler s_kernelCompiler; + static const PointwiseAddDispatchHandler s_dispatchHandler(s_kernelCompiler); + return s_dispatchHandler; } /** @@ -46,34 +54,29 @@ int currentDeviceId() * process; from here it surfaces as a failed plugin creation the host can report. */ std::shared_ptr> - registerThenBuildStateManager(const PointwiseAddDispatchHandler& dispatchHandler) + registerThenBuildStateManager() { static std::once_flag s_registered; - std::call_once(s_registered, [&dispatchHandler]() { + std::call_once(s_registered, []() { registerPointwiseAddMatchers(); - registerPointwiseAddDispatch(dispatchHandler); + registerPointwiseAddDispatch(dispatchHandler()); }); return makePointwiseAddStateManager(); } /** - * @brief A generic engine plus the provider-owned machinery its kernels reach through - * the native registry. + * @brief A generic engine over this pack's descriptor set. * - * The registry holds the dispatch handler by pointer, so something must own that handler - * for as long as any plan built from it can execute. That is this type: the compiler and - * handler are declared before the engine, so they are constructed first and destroyed - * last, and the engine's plans never outlive the engine. + * Owns only what is scoped to one engine: the device resolver, and the state manager's + * catalog cache. The dispatch handler and its compiler are deliberately not members -- + * see dispatchHandler() for why their lifetime has to exceed this engine's. */ class PointwiseAddEngine : public hipdnn_plugin_sdk::IEngine { public: - explicit PointwiseAddEngine(const device::IDevicePropertyProvider& devicePropertyProvider) - : _dispatchHandler(_kernelCompiler, devicePropertyProvider.getDeviceProperties()) - , _engine(registerThenBuildStateManager(_dispatchHandler), - devicePropertyProvider.getDeviceProperties(), - currentDeviceId()) + PointwiseAddEngine() + : _engine(registerThenBuildStateManager(), _deviceResolver) { } @@ -114,17 +117,15 @@ class PointwiseAddEngine : public hipdnn_plugin_sdk::IEngine _engine; }; } // namespace -std::unique_ptr> - makePointwiseAddEngine(const device::IDevicePropertyProvider& devicePropertyProvider) +std::unique_ptr> makePointwiseAddEngine() { - return std::make_unique(devicePropertyProvider); + return std::make_unique(); } } // namespace hip_kernel_provider::ingestor_poc diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp index 6a097d541fdb..b406edef9431 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp +++ b/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp @@ -12,7 +12,6 @@ #include "core/Context.hpp" #include "core/Handle.hpp" #include "core/Settings.hpp" -#include "device/IDevicePropertyProvider.hpp" namespace hip_kernel_provider::ingestor_poc { @@ -27,8 +26,7 @@ namespace hip_kernel_provider::ingestor_poc * This function is what a UED loader replaces: given descriptor files, the same generic * engine is constructed from parsed data instead of from a hardcoded pack. */ -std::unique_ptr> - makePointwiseAddEngine(const device::IDevicePropertyProvider& devicePropertyProvider); +std::unique_ptr> makePointwiseAddEngine(); } // namespace hip_kernel_provider::ingestor_poc diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp index d40f7957f011..52a7a052a477 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp @@ -17,6 +17,9 @@ #include #include +#include + +#include "core/Container.hpp" #include "core/Handle.hpp" #include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" #include "ingestor_poc/NativeSymbolNames.hpp" @@ -140,7 +143,7 @@ class AddBuffers TEST(TestPointwiseAddDispatch, ReportsWorkspaceFromKernelMetadata) { const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler, hipDeviceProp_t{}); + const PointwiseAddDispatchHandler handler(compiler); // The two surviving kernels report different requirements, so the engine's // "maximum across survivors" has something to actually maximize over. @@ -151,7 +154,7 @@ TEST(TestPointwiseAddDispatch, ReportsWorkspaceFromKernelMetadata) TEST(TestPointwiseAddDispatch, WorkspaceDoesNotDependOnTheGraph) { const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler, hipDeviceProp_t{}); + const PointwiseAddDispatchHandler handler(compiler); // Workspace is asked before a kernel is chosen and before any plan exists, so it // must be answerable from the kernel alone. @@ -169,7 +172,7 @@ TEST(TestPointwiseAddDispatch, LaunchesARealAddOnDevice) const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + const PointwiseAddDispatchHandler handler(compiler); const auto prepared = handler.prepare(fixture.context(), makeKernel(64, "FLOAT")); ASSERT_NE(prepared, nullptr); @@ -190,7 +193,7 @@ TEST(TestPointwiseAddDispatch, LaunchesTheSameResultForEitherBlockSize) const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + const PointwiseAddDispatchHandler handler(compiler); // block_size reaches the compiler and the launch geometry, so both kernels are // genuinely different builds; a one-element add must still agree. @@ -214,7 +217,7 @@ TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + const PointwiseAddDispatchHandler handler(compiler); // A plan is built once and may execute many times with different buffers, so // preparation must hold nothing tied to one execution. @@ -240,7 +243,7 @@ TEST(TestPointwiseAddDispatch, RefusesToPrepareAGraphTheMatcherRejects) const GraphFixture fixture(buildTwoNodePointwiseGraph()); const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler, fixture.deviceProperties()); + const PointwiseAddDispatchHandler handler(compiler); // Preparation reads the operand binding the matcher established, so a graph that // never matched cannot be prepared. @@ -248,6 +251,46 @@ TEST(TestPointwiseAddDispatch, RefusesToPrepareAGraphTheMatcherRejects) hipdnn_plugin_sdk::HipdnnPluginException); } +TEST(TestPointwiseAddDispatch, DispatchStaysResolvableAcrossContainerLifetimes) +{ + SKIP_IF_NO_DEVICES(); + + // A container is destroyed when its last handle closes, and a process that opens + // handles again builds a new one. This pack's implementations are registered once in + // a process-wide registry, so anything registered there must outlive every container + // -- a handler owned by a container's engine would be freed while the registration + // still pointed at it. + // + // This asserts the invariant holds: after a container is destroyed and rebuilt, the + // registered dispatch still resolves and still runs. It does NOT by itself prove the + // absence of a use-after-free, because a freed handler carrying no per-instance state + // usually keeps answering. What catches that is the integration binary's exit status: + // owning the handler on the engine makes hip_kernel_provider_integration_tests abort + // at process teardown while every test still reports as passing. + { + const core::Container first; + } + + const core::Container second; + + const auto* handler = hipdnn_plugin_sdk::ingestor::DispatchRegistry::resolve( + std::string(DISPATCH_SYMBOL)); + ASSERT_NE(handler, nullptr); + + const GraphFixture fixture(buildPointwiseGraph()); + const auto prepared = handler->prepare(fixture.context(), makeKernel(64, "FLOAT")); + ASSERT_NE(prepared, nullptr); + + const AddBuffers buffers(2.0f, 5.0f); + const auto descriptors = buffers.descriptors(); + const Handle handle; + + handler->launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); + ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + + EXPECT_FLOAT_EQ(buffers.readResult(), 7.0f); +} + } // namespace #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index 7eb790577df3..be07b3d7d187 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -47,17 +48,16 @@ class GenericEngine : public IEngine using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; /** - * @param stateManager The engine's descriptor state, already validated. - * @param deviceProperties Supplies `$device.*`. Held by value: providers hand these - * out by value, so a reference would bind to a temporary. - * @param deviceId The device this engine's catalogs are keyed on. + * @param stateManager The engine's descriptor state, already validated. + * @param deviceResolver Answers which device each call is for, from the handle that + * carries it. Held by reference; owned by the provider, which must keep it + * alive for the engine's lifetime. */ GenericEngine(std::shared_ptr> stateManager, - const hipDeviceProp_t& deviceProperties, - DeviceId deviceId) + const IDeviceResolver& deviceResolver) : _stateManager(std::move(stateManager)) , _id(hipdnn_data_sdk::utilities::engineNameToId(_stateManager->engine().name)) - , _planBuilder(_stateManager, deviceProperties, deviceId) + , _planBuilder(_stateManager, deviceResolver) { } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 3fc090bb1e58..96fafcc60c10 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -45,22 +46,21 @@ class GenericPlanBuilder : public IPlanBuilder /// @param stateManager The engine's descriptor state. Shared rather than owned so the /// engine and its builder read one catalog cache. - /// @param deviceProperties Supplies `$device.*`. Held by value: providers hand these - /// out by value, so a reference would bind to a temporary. + /// @param deviceResolver Answers which device each call is for. Held by reference; + /// owned by the engine, which outlives its builder. GenericPlanBuilder(std::shared_ptr> stateManager, - hipDeviceProp_t deviceProperties, - DeviceId deviceId) + const IDeviceResolver& deviceResolver) : _stateManager(std::move(stateManager)) - , _deviceProperties(deviceProperties) - , _deviceId(deviceId) + , _deviceResolver(deviceResolver) { } /// Applicable exactly when some kernel survived matching. Deliberately does not rank: /// a membership test needs no order, so the heuristic is never run here. - bool isApplicable(const THandle& /*handle*/, const IGraph& opGraph) const override + bool isApplicable(const THandle& handle, const IGraph& opGraph) const override { - return !_stateManager->unsortedDefinitions(_deviceId, contextFor(opGraph)).empty(); + const auto deviceId = _deviceResolver.deviceId(handle); + return !_stateManager->unsortedDefinitions(deviceId, contextFor(deviceId, opGraph)).empty(); } /** @@ -70,13 +70,14 @@ class GenericPlanBuilder : public IPlanBuilder * launch one at a time on one stream, so a candidate's scratch is live only while it * runs. A kernel needing less over-allocates, which is accepted. */ - size_t getMaxWorkspaceSize(const THandle& /*handle*/, + size_t getMaxWorkspaceSize(const THandle& handle, const IGraph& opGraph, const TSettings& /*executionSettings*/) const override { - const auto context = contextFor(opGraph); + const auto deviceId = _deviceResolver.deviceId(handle); + const auto context = contextFor(deviceId, opGraph); size_t maxBytes = 0; - for(const auto& kernel : _stateManager->sortedDefinitions(_deviceId, context)) + for(const auto& kernel : _stateManager->sortedDefinitions(deviceId, context)) { const auto dispatcher = _stateManager->getDispatchDetails(kernel); maxBytes = std::max(maxBytes, dispatcher.handler->workspaceBytes(kernel)); @@ -102,13 +103,14 @@ class GenericPlanBuilder : public IPlanBuilder * RFC 0017 §8.6 makes that a bug, not a legal outcome, so it surfaces as a * failed plan build rather than a silent decline. */ - void buildPlan(const THandle& /*handle*/, + void buildPlan(const THandle& handle, const IGraph& opGraph, const IEngineConfig& /*engineConfig*/, TContext& executionContext) const override { - const auto context = contextFor(opGraph); - const auto ranked = _stateManager->sortedDefinitions(_deviceId, context); + const auto deviceId = _deviceResolver.deviceId(handle); + const auto context = contextFor(deviceId, opGraph); + const auto ranked = _stateManager->sortedDefinitions(deviceId, context); if(ranked.empty()) { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, @@ -129,11 +131,13 @@ class GenericPlanBuilder : public IPlanBuilder * first, so leaving every knob alone reproduces the out-of-the-box selection. */ std::vector - getCustomKnobs(const THandle& /*handle*/, const IGraph& opGraph) const override + getCustomKnobs(const THandle& handle, const IGraph& opGraph) const override { using namespace hipdnn_flatbuffers_sdk::data_objects; - const auto ranked = _stateManager->sortedDefinitions(_deviceId, contextFor(opGraph)); + const auto deviceId = _deviceResolver.deviceId(handle); + const auto ranked + = _stateManager->sortedDefinitions(deviceId, contextFor(deviceId, opGraph)); std::vector knobs; if(ranked.empty()) { @@ -188,14 +192,13 @@ class GenericPlanBuilder : public IPlanBuilder } private: - MatchContext contextFor(const IGraph& opGraph) const + MatchContext contextFor(DeviceId deviceId, const IGraph& opGraph) const { - return MatchContext{opGraph, _deviceId, _deviceProperties}; + return MatchContext{opGraph, deviceId, _deviceResolver.deviceProperties(deviceId)}; } std::shared_ptr> _stateManager; - hipDeviceProp_t _deviceProperties; - DeviceId _deviceId; + const IDeviceResolver& _deviceResolver; }; } // namespace hipdnn_plugin_sdk::ingestor diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp new file mode 100644 index 000000000000..f9090ac7eaef --- /dev/null +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp @@ -0,0 +1,56 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include + +namespace hipdnn_plugin_sdk::ingestor +{ + +/** + * @brief Answers which device a call is for, from the handle that carries it. + * + * An engine is shared by every handle in the process, and a handle can be bound to any + * device, so the device a query concerns is a property of the call rather than of the + * engine. Resolving it per call is what lets one cache serve many handles correctly: + * two handles on one device share a catalog entry, and one handle rebound to another + * device does not. + * + * Capturing the device once when the engine is built would be wrong in exactly the case + * this system is meant to handle. The catalog is keyed on (graph, device) because + * applicability genuinely differs per device; keying every entry to whichever device + * happened to be current at construction would let one device's catalog answer for + * another. That is a wrong answer, not merely a missed cache hit. + * + * How a handle names its device is provider-specific, which is why this is an interface + * rather than a call the SDK makes directly. + */ +template +class IDeviceResolver +{ +public: + virtual ~IDeviceResolver() = default; + + /// @brief The device @p handle currently targets. + // NOLINTNEXTLINE(portability-template-virtual-member-function) + virtual DeviceId deviceId(const THandle& handle) const = 0; + + /** + * @brief Properties of @p deviceId, for the `$device.*` expression namespace. + * + * Returns a reference that stays valid for the resolver's lifetime, because a + * MatchContext binds it rather than copying it. Implementations are expected to + * cache: this is asked on the applicability path, which runs once per engine per + * graph. + */ + // NOLINTNEXTLINE(portability-template-virtual-member-function) + virtual const hipDeviceProp_t& deviceProperties(DeviceId deviceId) const = 0; +}; + +} // namespace hipdnn_plugin_sdk::ingestor + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt index 62c104fd4742..88aaf4e64662 100644 --- a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt @@ -27,6 +27,7 @@ add_executable(hipdnn_plugin_sdk_tests TestPluginDeviceBuffers.cpp TestRuntimePassByValue.cpp $<$:TestIngestorSkeleton.cpp> + $<$:TestIngestorConcurrency.cpp> ) target_compile_options(hipdnn_plugin_sdk_tests PRIVATE ${HIPDNN_WARNING_COMPILE_OPTIONS} ${HIPDNN_NO_RTTI_OPTIONS}) diff --git a/projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp b/projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp new file mode 100644 index 000000000000..6be4d1f59216 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp @@ -0,0 +1,232 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @file IngestorTestGraphs.hpp + * @brief Shared fixtures for the ingestor's SDK-level tests. + * + * Models the pack shape a real engine ships: two kernels that differ only in a ranking + * axis, plus one the kernel-scoped matcher prunes. That is the smallest catalog where + * matching, pruning, and ranking each have something to do. + */ +namespace hipdnn_plugin_sdk::ingestor::testing +{ + +constexpr const char* BLOCK_SIZE = "block_size"; +constexpr const char* DTYPE = "dtype"; +constexpr const char* GRAPH_MATCH_SYMBOL = "ingestor_test.graph_match"; +constexpr const char* KERNEL_MATCH_SYMBOL = "ingestor_test.kernel_match"; +constexpr const char* SCORE_SYMBOL = "ingestor_test.score"; + +/// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the rest +/// of the interface throws: a test that starts depending on graph contents should fail +/// loudly rather than silently match against an empty graph. +class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph +{ +public: + /// @param graphId The identity to carry, or nullopt to model a legacy or unfinalized + /// graph that has none. + explicit TestGraph(std::optional graphId = std::nullopt) + { + flatbuffers::Offset graph; + if(graphId.has_value()) + { + const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*graphId); + auto name = _builder.CreateString("test_graph"); + hipdnn_flatbuffers_sdk::data_objects::GraphBuilder graphBuilder(_builder); + graphBuilder.add_name(name); + graphBuilder.add_id(&uuid); + graph = graphBuilder.Finish(); + } + else + { + graph = hipdnn_flatbuffers_sdk::data_objects::CreateGraphDirect(_builder, "test_graph"); + } + _builder.Finish(graph); + } + + const hipdnn_flatbuffers_sdk::data_objects::Graph& getGraph() const override + { + return *flatbuffers::GetRoot( + _builder.GetBufferPointer()); + } + + bool isValid() const override + { + return true; + } + + uint32_t nodeCount() const override + { + return 0; + } + + bool hasOnlySupportedAttributes( + std::set /*supported*/) const override + { + return true; + } + + const hipdnn_flatbuffers_sdk::data_objects::Node& getNode(uint32_t /*index*/) const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper& + getNodeWrapper(uint32_t /*index*/) const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const std::vector>& + nodeWrappers() const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const std::unordered_map& + getTensorMap() const override + { + return _tensors; + } + +private: + flatbuffers::FlatBufferBuilder _builder; + std::unordered_map + _tensors; +}; + +inline GraphId makeGraphId(uint8_t seed) +{ + GraphId id{}; + id.fill(seed); + return id; +} + +inline hipDeviceProp_t testDeviceProperties() +{ + hipDeviceProp_t properties{}; + properties.warpSize = 64; + return properties; +} + +inline bool acceptAnyGraph(const MatchContext& /*context*/) +{ + return true; +} + +/// Accepts only FLOAT kernels, so a pack carrying a HALF kernel is pruned down. +inline bool acceptFloatKernels(const MatchContext& /*context*/, const KernelDefinition& kernel) +{ + return kernel.getStringMetadata(DTYPE) == "FLOAT"; +} + +/// Bigger block size scores higher, so ranking has a defined winner. +inline double scoreByBlockSize(const KernelDefinition& kernel, const MatchContext& /*context*/) +{ + return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); +} + +/// Registers this fixture's symbols for a test's duration, so tests sharing the +/// process-wide registry stay independent of one another's registrations. +class ScopedTestSymbols +{ +public: + ScopedTestSymbols() + { + GraphMatcherRegistry::registerSymbol(GRAPH_MATCH_SYMBOL, &acceptAnyGraph); + KernelMatcherRegistry::registerSymbol(KERNEL_MATCH_SYMBOL, &acceptFloatKernels); + ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); + } + + ~ScopedTestSymbols() + { + GraphMatcherRegistry::unregisterSymbol(GRAPH_MATCH_SYMBOL); + KernelMatcherRegistry::unregisterSymbol(KERNEL_MATCH_SYMBOL); + ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); + } + + ScopedTestSymbols(const ScopedTestSymbols&) = delete; + ScopedTestSymbols& operator=(const ScopedTestSymbols&) = delete; +}; + +inline KernelDescriptor + makeTestKernel(const std::string& id, int64_t blockSize, const std::string& dtype) +{ + KernelDescriptor kernel; + kernel.id = id; + kernel.name = id; + kernel.sourceFile = "Test.cpp"; + kernel.entryPoint = "TestKernel"; + kernel.metadata = {{BLOCK_SIZE, blockSize}, {DTYPE, dtype}}; + return kernel; +} + +/// Two FLOAT kernels differing only in block size, plus a HALF kernel the kernel-scoped +/// matcher prunes: a catalog of two survivors with a defined ranking. +inline std::unique_ptr> + makeTestStateManager(size_t cacheCapacity + = KernelIngestorStateManager::DEFAULT_CATALOG_CACHE_CAPACITY) +{ + MetadataSchema schema; + schema.id = "kmd"; + schema.name = "test schema"; + schema.fields = {{BLOCK_SIZE, int64_t{64}}, {DTYPE, std::string{"FLOAT"}}}; + + EngineDescriptor engine; + engine.id = "ued"; + engine.name = "test:engine"; + engine.heuristicId = "uhd"; + engine.metadataSchemaId = "kmd"; + engine.knobs = {BLOCK_SIZE}; + + std::vector matchers{ + {"umd_graph", "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}, + {"umd_kernel", "kernel scoped", MatchScope::KERNEL, KERNEL_MATCH_SYMBOL}}; + std::vector dispatches{{"udd", "test dispatch", "ingestor_test.dispatch"}}; + + KernelDescriptorPack pack; + pack.id = "kdp"; + pack.name = "test pack"; + pack.matcherIds = {"umd_graph", "umd_kernel"}; + pack.engineId = "ued"; + pack.dispatchId = "udd"; + pack.kernels = {makeTestKernel("kernel_64_float", 64, "FLOAT"), + makeTestKernel("kernel_256_float", 256, "FLOAT"), + makeTestKernel("kernel_64_half", 64, "HALF")}; + + return std::make_unique>( + std::move(engine), + std::move(schema), + std::move(matchers), + std::move(dispatches), + std::vector{std::move(pack)}, + std::make_shared(&scoreByBlockSize), + cacheCapacity); +} + +} // namespace hipdnn_plugin_sdk::ingestor::testing + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp b/projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp new file mode 100644 index 000000000000..cb2c38bf7de1 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp @@ -0,0 +1,236 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "IngestorTestGraphs.hpp" + +/** + * @file TestIngestorConcurrency.cpp + * @brief The ingestor's shared state under concurrent use. + * + * One engine, and therefore one catalog cache, is shared by every handle in the process, + * and hipDNN may drive several handles from several threads at once. These tests pin the + * two properties that arrangement depends on: the shared state stays internally + * consistent under concurrent access, and the answers it gives do not depend on which + * thread asked or on what any other thread was doing. + * + * They are worth running under thread and address sanitizers, where a data race in the + * cache would be reported directly rather than inferred from a corrupted result. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +constexpr int THREAD_COUNT = 8; +constexpr int ITERATIONS_PER_THREAD = 200; + +/// Runs @p body on several threads at once, releasing them together so their work +/// genuinely overlaps rather than serializing by startup order. +template +void runConcurrently(int threadCount, Body body) +{ + std::atomic go{false}; + std::vector threads; + threads.reserve(static_cast(threadCount)); + + for(int i = 0; i < threadCount; ++i) + { + threads.emplace_back([&go, &body, i]() { + while(!go.load(std::memory_order_acquire)) + { + std::this_thread::yield(); + } + body(i); + }); + } + + go.store(true, std::memory_order_release); + for(auto& thread : threads) + { + thread.join(); + } +} + +// --------------------------------------------------------------------------- +// LruCache +// --------------------------------------------------------------------------- + +TEST(TestIngestorCacheConcurrency, SurvivesConcurrentReadsAndWrites) +{ + // Capacity far below the key range, so eviction runs constantly and readers are + // touching entries writers are simultaneously retiring. + LruCache cache(16); + + runConcurrently(THREAD_COUNT, [&cache](int thread) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + const int key = (thread * ITERATIONS_PER_THREAD + i) % 128; + cache.put(key, key * 2); + + // A hit must carry the value stored under that key; a miss is legitimate + // because another thread may have evicted it in between. + if(const auto found = cache.get(key); found.has_value()) + { + EXPECT_EQ(*found, key * 2); + } + } + }); + + EXPECT_LE(cache.size(), cache.capacity()); +} + +TEST(TestIngestorCacheConcurrency, NeverExceedsCapacityUnderContention) +{ + LruCache cache(4); + + runConcurrently(THREAD_COUNT, [&cache](int thread) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + cache.put(thread * ITERATIONS_PER_THREAD + i, i); + } + }); + + // The bound is what makes this cache safe to leave running in a long-lived process: + // concurrent insertion must not be able to grow it without limit. + EXPECT_LE(cache.size(), cache.capacity()); +} + +// --------------------------------------------------------------------------- +// KernelIngestorStateManager +// --------------------------------------------------------------------------- + +TEST(TestIngestorStateManagerConcurrency, ServesOneGraphFromManyThreadsConsistently) +{ + const ScopedTestSymbols symbols; + const auto manager = makeTestStateManager(); + const TestGraph graph(makeGraphId(0x21)); + const auto properties = testDeviceProperties(); + + // Every thread asks the same question of the same shared cache, the way several + // handles bound to one device would. + runConcurrently(THREAD_COUNT, [&](int) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + const MatchContext context{graph, 0, properties}; + const auto definitions = manager->unsortedDefinitions(0, context); + ASSERT_EQ(definitions.size(), 2U); + } + }); +} + +TEST(TestIngestorStateManagerConcurrency, RanksConsistentlyFromManyThreads) +{ + const ScopedTestSymbols symbols; + const auto manager = makeTestStateManager(); + const TestGraph graph(makeGraphId(0x22)); + const auto properties = testDeviceProperties(); + + // Ranking writes the sorted catalog back into the cache, so this is the path where + // concurrent readers and a writer meet. Every thread must still see the same winner. + runConcurrently(THREAD_COUNT, [&](int) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + const MatchContext context{graph, 0, properties}; + const auto ranked = manager->sortedDefinitions(0, context); + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().getIntMetadata(std::string(BLOCK_SIZE)), 256); + } + }); +} + +TEST(TestIngestorStateManagerConcurrency, KeepsPerDeviceCatalogsDistinct) +{ + const ScopedTestSymbols symbols; + const auto manager = makeTestStateManager(); + const TestGraph graph(makeGraphId(0x23)); + const auto properties = testDeviceProperties(); + + // One graph, several devices, all at once: the catalog is keyed on both, so a + // device's answer must never be served from another device's entry. + runConcurrently(THREAD_COUNT, [&](int thread) { + const DeviceId deviceId = thread % 4; + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + const MatchContext context{graph, deviceId, properties}; + const auto definitions = manager->unsortedDefinitions(deviceId, context); + ASSERT_EQ(definitions.size(), 2U); + } + }); +} + +TEST(TestIngestorStateManagerConcurrency, ServesUncacheableGraphsConcurrently) +{ + const ScopedTestSymbols symbols; + const auto manager = makeTestStateManager(); + // No identity, so every call rematches instead of reading the cache. That path runs + // the matchers concurrently rather than serializing behind a cached result. + const TestGraph graph; + const auto properties = testDeviceProperties(); + + runConcurrently(THREAD_COUNT, [&](int) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + const MatchContext context{graph, 0, properties}; + ASSERT_EQ(manager->unsortedDefinitions(0, context).size(), 2U); + } + }); +} + +TEST(TestIngestorStateManagerConcurrency, EvictsUnderConcurrentDistinctGraphs) +{ + const ScopedTestSymbols symbols; + // Capacity 2 against many distinct graphs, so entries are evicted while other + // threads are reading and writing them. + const auto manager = makeTestStateManager(2); + const auto properties = testDeviceProperties(); + + runConcurrently(THREAD_COUNT, [&](int thread) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + const TestGraph graph(makeGraphId(static_cast((thread * 7 + i) % 32))); + const MatchContext context{graph, 0, properties}; + + // Eviction costs a rematch and never a wrong answer, so the result is the + // same whether this call hit the cache or rebuilt the catalog. + ASSERT_EQ(manager->unsortedDefinitions(0, context).size(), 2U); + } + }); +} + +// --------------------------------------------------------------------------- +// NativeRegistry +// --------------------------------------------------------------------------- + +TEST(TestIngestorRegistryConcurrency, ResolvesFromManyThreads) +{ + const ScopedTestSymbols symbols; + + // Resolution happens on the applicability path, which several threads reach at once. + runConcurrently(THREAD_COUNT, [](int) { + for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) + { + EXPECT_NE(GraphMatcherRegistry::resolve(std::string(GRAPH_MATCH_SYMBOL)), nullptr); + EXPECT_NE(ScoreRegistry::resolve(std::string(SCORE_SYMBOL)), nullptr); + } + }); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp b/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp index 3c137ec88135..4466eae995af 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp @@ -17,6 +17,8 @@ #include #include +#include "IngestorTestGraphs.hpp" + /** * @file TestIngestorSkeleton.cpp * @brief Unit tests for the descriptor-driven kernel ingestor's SDK-side machinery. @@ -34,98 +36,10 @@ namespace using namespace hipdnn_plugin_sdk::ingestor; -constexpr const char* BLOCK_SIZE = "block_size"; -constexpr const char* DTYPE = "dtype"; - -/// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the -/// rest of the interface throws: a test that starts depending on graph contents should -/// fail loudly rather than silently match against an empty graph. -class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph -{ -public: - /// @param graphId The identity to carry, or nullopt to model a legacy or - /// unfinalized graph that has none. - explicit TestGraph(std::optional graphId = std::nullopt) - { - flatbuffers::Offset graph; - if(graphId.has_value()) - { - const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*graphId); - auto name = _builder.CreateString("test_graph"); - hipdnn_flatbuffers_sdk::data_objects::GraphBuilder graphBuilder(_builder); - graphBuilder.add_name(name); - graphBuilder.add_id(&uuid); - graph = graphBuilder.Finish(); - } - else - { - graph = hipdnn_flatbuffers_sdk::data_objects::CreateGraphDirect(_builder, "test_graph"); - } - _builder.Finish(graph); - } - - const hipdnn_flatbuffers_sdk::data_objects::Graph& getGraph() const override - { - return *flatbuffers::GetRoot( - _builder.GetBufferPointer()); - } - - bool isValid() const override - { - return true; - } - - uint32_t nodeCount() const override - { - return 0; - } - - bool hasOnlySupportedAttributes( - std::set /*supported*/) const override - { - return true; - } - - const hipdnn_flatbuffers_sdk::data_objects::Node& getNode(uint32_t /*index*/) const override - { - throw std::logic_error("TestGraph carries no nodes"); - } - - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper& - getNodeWrapper(uint32_t /*index*/) const override - { - throw std::logic_error("TestGraph carries no nodes"); - } - - const std::vector>& - nodeWrappers() const override - { - throw std::logic_error("TestGraph carries no nodes"); - } +using namespace hipdnn_plugin_sdk::ingestor::testing; - const std::unordered_map& - getTensorMap() const override - { - return _tensors; - } - -private: - flatbuffers::FlatBufferBuilder _builder; - std::unordered_map - _tensors; -}; - -GraphId makeGraphId(uint8_t seed) -{ - GraphId id{}; - id.fill(seed); - return id; -} - -/// Counts how often each matcher shape ran, so a test can assert that a graph-scoped -/// matcher is evaluated once per (graph, device) while a kernel-scoped one is evaluated -/// once per surviving kernel. +/// Counts matcher invocations, so a test can assert that a graph-scoped matcher runs +/// once per (graph, device) while a kernel-scoped one runs once per surviving kernel. struct MatcherCounters { int graphCalls = 0; @@ -156,17 +70,10 @@ bool rejectGraph(const MatchContext& /*context*/) return false; } -/// Accepts only FLOAT kernels, so a pack carrying a HALF kernel is pruned down. -bool acceptFloatKernels(const MatchContext& /*context*/, const KernelDefinition& kernel) +bool countingFloatKernels(const MatchContext& context, const KernelDefinition& kernel) { ++counters().kernelCalls; - return kernel.getStringMetadata(DTYPE) == "FLOAT"; -} - -/// Bigger block size scores higher, so ranking has a defined winner. -double scoreByBlockSize(const KernelDefinition& kernel, const MatchContext& /*context*/) -{ - return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); + return acceptFloatKernels(context, kernel); } /// Every kernel scores the same, so ranking falls through to the tie-break. @@ -185,21 +92,17 @@ EngineDescriptor makeEngine(std::vector knobs = {BLOCK_SIZE}) return {"ued", "test:engine", "uhd", "kmd", std::move(knobs)}; } -KernelDescriptor - makeKernel(std::string id, int64_t blockSize, std::string dtype, int64_t priority = 0) +KernelDescriptor makeKernel(const std::string& id, + int64_t blockSize, + const std::string& dtype, + int64_t priority = 0) { - KernelDescriptor kernel; - kernel.id = std::move(id); - kernel.name = kernel.id; - kernel.sourceFile = "Test.cpp"; - kernel.entryPoint = "TestKernel"; - kernel.metadata = {{BLOCK_SIZE, blockSize}, {DTYPE, std::move(dtype)}}; + auto kernel = makeTestKernel(id, blockSize, dtype); kernel.priority = priority; return kernel; } -/// The pack shape the provider POC uses: two FLOAT kernels differing only in block size, -/// plus a HALF kernel the kernel-scoped matcher prunes. +/// The pack shape a real engine ships, wired to the counting matchers above. KernelDescriptorPack makePack(std::vector matcherIds) { KernelDescriptorPack pack; @@ -214,8 +117,8 @@ KernelDescriptorPack makePack(std::vector matcherIds) return pack; } -/// Registers the symbols a test's descriptors name, and removes them afterwards so -/// tests sharing the process-wide registry stay independent. +/// Registers counting matchers under this file's own symbol names, so these tests +/// observe invocation counts without disturbing the shared fixture's registrations. class ScopedSymbols { public: @@ -267,13 +170,6 @@ std::unique_ptr makeStateManager(ScoreFn scoreFn = scoreByBlockSiz cacheCapacity); } -hipDeviceProp_t testDeviceProperties() -{ - hipDeviceProp_t properties{}; - properties.warpSize = 64; - return properties; -} - // --------------------------------------------------------------------------- // LruCache // --------------------------------------------------------------------------- @@ -438,7 +334,7 @@ TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) TEST(TestIngestorStateManager, KernelLevelMatcherPrunesTheCatalog) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(1)); const auto properties = testDeviceProperties(); @@ -456,7 +352,7 @@ TEST(TestIngestorStateManager, KernelLevelMatcherPrunesTheCatalog) TEST(TestIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) { - const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(2)); const auto properties = testDeviceProperties(); @@ -471,7 +367,7 @@ TEST(TestIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) TEST(TestIngestorStateManager, MatchesOncePerGraphAndDevice) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(3)); const auto properties = testDeviceProperties(); @@ -488,7 +384,7 @@ TEST(TestIngestorStateManager, MatchesOncePerGraphAndDevice) TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(4)); const auto properties = testDeviceProperties(); @@ -503,7 +399,7 @@ TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); // A legacy or unfinalized graph: there is no key to memoize under, and inventing one // would alias unrelated graphs. @@ -522,7 +418,7 @@ TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) TEST(TestIngestorStateManager, RematchesAfterCacheEviction) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(scoreByBlockSize, 1); const auto properties = testDeviceProperties(); const TestGraph first(makeGraphId(5)); @@ -539,7 +435,7 @@ TEST(TestIngestorStateManager, RematchesAfterCacheEviction) TEST(TestIngestorStateManager, SortedDefinitionsAreRankedBestFirst) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(7)); const auto properties = testDeviceProperties(); @@ -553,7 +449,7 @@ TEST(TestIngestorStateManager, SortedDefinitionsAreRankedBestFirst) TEST(TestIngestorStateManager, RankingReusesTheAlreadyMatchedCatalog) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(8)); const auto properties = testDeviceProperties(); @@ -570,7 +466,7 @@ TEST(TestIngestorStateManager, RankingReusesTheAlreadyMatchedCatalog) TEST(TestIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph graph(makeGraphId(9)); const auto properties = testDeviceProperties(); @@ -588,7 +484,7 @@ TEST(TestIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) TEST(TestIngestorStateManager, CompletesKernelMetadataFromSchemaDefaults) { - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", acceptFloatKernels); + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); KernelDescriptorPack pack = makePack({"umd_graph", "umd_kernel"}); // A kernel that declares no metadata at all takes every schema default. From 93ccd233ef88fba6d414fd70bf119b5ad19a01ba Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 19:32:27 -0400 Subject: [PATCH 17/60] refactor(hipdnn): name the kernel ingestor as the foundation it is (ALMIOPEN-2402) The first drop landed the ingestor under an `ingestor_poc/` directory with `poc`-prefixed descriptor ids and a `kernel_ingestor_poc:` engine name. That naming describes how the code was written rather than what it is: the engine shell here is the one every descriptor-backed kernel will run through, so the names should read that way from the start instead of being renamed later once they have dependents. Layout now follows the provider's existing convention, one directory per engine under `src/engines/`, and separates the two things that were conflated: engines/kernel_ingestor_engine/ the engine shell, permanent KernelIngestorEngine.{hpp,cpp} the IEngine over descriptor data HandleDeviceResolver.hpp per-call device resolution packs/ descriptor packs, replaceable PointwiseAdd{Pack,Matchers,DispatchHandler,Symbols} kernels/PointwiseAdd.cpp The shell is what ALMIOPEN-2401 hangs UED file loading off. The pack beneath it is the first content the shell carries, and is the part real descriptor data replaces, so a second pack is a new directory under `packs/` rather than a change to anything above it. Tests mirror the layout under `tests/engines/kernel_ingestor_engine/` and `integration_tests/kernel_ingestor_engine/`, matching the sibling engines. Descriptor ids move from `poc..` to `hipkernel..`, and the native escape-hatch symbols to `hipkernel.pointwise_add.*`, so both are scoped by the provider that owns them. The engine name is the change worth doing now rather than later. It hashes into hipDNN's shared engine-id space, and EngineNames.hpp records that a name is never renamed once it ships, because renaming changes the derived id. It becomes `hipkernel:PointwiseAdd`, dropping `poc` from a value that is meant to be permanent and appears in logs and diagnostics. No behavior changes: this is names, directories, and comments. Testing: - Reconfigured from a clean cache to pick up the new CMake layout. - 125 plugin_sdk, 460 provider unit, 14 integration tests pass, all exiting 0. - Flag off unchanged: 428 unit and 5 integration pass with the feature compiled out. - pre-commit passes. JIRA ID : ALMIOPEN-2402 --- .../hip-kernel-provider/CMakeLists.txt | 2 +- .../hip-kernel-provider/src/CMakeLists.txt | 4 --- .../src/core/Container.cpp | 10 +++---- .../src/engines/CMakeLists.txt | 4 +++ .../kernel_ingestor_engine}/CMakeLists.txt | 14 ++++----- .../HandleDeviceResolver.hpp | 4 +-- .../KernelIngestorEngine.cpp} | 14 ++++----- .../KernelIngestorEngine.hpp} | 4 +-- .../kernels/PointwiseAdd.cpp | 2 +- .../packs}/PointwiseAddDispatchHandler.cpp | 10 +++---- .../packs}/PointwiseAddDispatchHandler.hpp | 6 ++-- .../packs/PointwiseAddMatchers.cpp} | 12 ++++---- .../packs/PointwiseAddMatchers.hpp} | 4 +-- .../packs}/PointwiseAddPack.cpp | 30 +++++++++---------- .../packs}/PointwiseAddPack.hpp | 10 +++---- .../packs/PointwiseAddSymbols.hpp} | 14 ++++----- .../src/integration_tests/CMakeLists.txt | 2 +- .../IntegrationGpuKernelIngestor.cpp | 10 +++---- .../src/tests/CMakeLists.txt | 6 ++-- .../kernel_ingestor_engine/CMakeLists.txt | 8 +++++ .../packs/PointwiseAddTestGraphs.hpp} | 6 ++-- .../TestPointwiseAddDispatchHandler.cpp | 12 ++++---- .../packs/TestPointwiseAddMatchers.cpp} | 12 ++++---- .../packs}/TestPointwiseAddPack.cpp | 8 ++--- .../src/tests/ingestor_poc/CMakeLists.txt | 8 ----- .../ingestor/Descriptors.hpp | 2 +- .../ingestor/GenericPlanBuilder.hpp | 2 +- .../ingestor/MatchContext.hpp | 2 +- .../ingestor/NativeRegistry.hpp | 2 +- .../hipdnn/plugin_sdk/tests/CMakeLists.txt | 4 +-- ...phs.hpp => KernelIngestorTestFixtures.hpp} | 11 +++---- ...torSkeleton.cpp => TestKernelIngestor.cpp} | 4 +-- ....cpp => TestKernelIngestorConcurrency.cpp} | 4 +-- 33 files changed, 124 insertions(+), 123 deletions(-) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine}/CMakeLists.txt (52%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine}/HandleDeviceResolver.hpp (96%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc/PointwiseAddEngine.cpp => engines/kernel_ingestor_engine/KernelIngestorEngine.cpp} (90%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc/PointwiseAddEngine.hpp => engines/kernel_ingestor_engine/KernelIngestorEngine.hpp} (89%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine}/kernels/PointwiseAdd.cpp (92%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine/packs}/PointwiseAddDispatchHandler.cpp (94%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine/packs}/PointwiseAddDispatchHandler.hpp (93%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc/NativeMatchers.cpp => engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp} (94%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc/NativeMatchers.hpp => engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp} (95%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine/packs}/PointwiseAddPack.cpp (78%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc => engines/kernel_ingestor_engine/packs}/PointwiseAddPack.hpp (91%) rename dnn-providers/hip-kernel-provider/src/{ingestor_poc/NativeSymbolNames.hpp => engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp} (77%) rename dnn-providers/hip-kernel-provider/src/integration_tests/{ingestor_poc => kernel_ingestor_engine}/IntegrationGpuKernelIngestor.cpp (98%) create mode 100644 dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt rename dnn-providers/hip-kernel-provider/src/tests/{ingestor_poc/PointwiseAddGraphs.hpp => engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp} (96%) rename dnn-providers/hip-kernel-provider/src/tests/{ingestor_poc => engines/kernel_ingestor_engine/packs}/TestPointwiseAddDispatchHandler.cpp (95%) rename dnn-providers/hip-kernel-provider/src/tests/{ingestor_poc/TestNativeMatchers.cpp => engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp} (94%) rename dnn-providers/hip-kernel-provider/src/tests/{ingestor_poc => engines/kernel_ingestor_engine/packs}/TestPointwiseAddPack.cpp (95%) delete mode 100644 dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt rename projects/hipdnn/plugin_sdk/tests/{IngestorTestGraphs.hpp => KernelIngestorTestFixtures.hpp} (94%) rename projects/hipdnn/plugin_sdk/tests/{TestIngestorSkeleton.cpp => TestKernelIngestor.cpp} (99%) rename projects/hipdnn/plugin_sdk/tests/{TestIngestorConcurrency.cpp => TestKernelIngestorConcurrency.cpp} (99%) diff --git a/dnn-providers/hip-kernel-provider/CMakeLists.txt b/dnn-providers/hip-kernel-provider/CMakeLists.txt index ed9197cf3fa1..d9eb77784c4b 100644 --- a/dnn-providers/hip-kernel-provider/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/CMakeLists.txt @@ -50,7 +50,7 @@ option(HIPKERNELPROVIDER_ENABLE_ROCKE "Build rocKE engine and smoke tests" OFF) option(ENABLE_ASM_SDPA_ENGINE "Build the ASM SDPA engine" ON) option(ENABLE_HIP_MLOPS_ENGINE "Build the HIP kernel engine" ON) -# Gates the generic kernel ingestor POC (RFC 0017), set by TheRock's +# Gates the generic kernel ingestor (RFC 0017), set by TheRock's # THEROCK_FLAG_HIPDNN_ENABLE_KERNEL_INGESTOR. The ingestor engine compiles its kernel # through the HIP kernel engine's compiler, so it is only built alongside that engine. option(HIPDNN_ENABLE_KERNEL_INGESTOR "Build the generic kernel ingestor (RFC 0017)" OFF) diff --git a/dnn-providers/hip-kernel-provider/src/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/CMakeLists.txt index 40e064ce8146..87eebc706c0e 100644 --- a/dnn-providers/hip-kernel-provider/src/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/CMakeLists.txt @@ -14,10 +14,6 @@ add_library( add_subdirectory(engines) -if(HIPDNN_ENABLE_KERNEL_INGESTOR) - add_subdirectory(ingestor_poc) -endif() - target_include_directories(hip_kernel_provider_impl PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_LIST_DIR}/../include diff --git a/dnn-providers/hip-kernel-provider/src/core/Container.cpp b/dnn-providers/hip-kernel-provider/src/core/Container.cpp index 889f56e65145..90823b779dd0 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Container.cpp +++ b/dnn-providers/hip-kernel-provider/src/core/Container.cpp @@ -21,8 +21,8 @@ #endif #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR -#include "ingestor_poc/PointwiseAddEngine.hpp" -#include "ingestor_poc/PointwiseAddPack.hpp" +#include "engines/kernel_ingestor_engine/KernelIngestorEngine.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" #endif #include @@ -71,15 +71,15 @@ const std::vector& Container::getEngineDefinitions( }}, #endif #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR - // The kernel-ingestor POC's engine (RFC 0017). Registered statically here + // The kernel ingestor's engine (RFC 0017). Registered statically here // because engines are still a compile-time table; runtime loading of engine // descriptors replaces this entry with one built per installed UED file. - {ingestor_poc::pointwiseAddEngineId(), + {kernel_ingestor_engine::pointwiseAddEngineId(), [](const device::IDevicePropertyProvider& /*devicePropertyProvider*/) -> std::unique_ptr> { // Device facts are resolved per call from the handle rather than captured // here, so this engine needs nothing from the construction-time provider. - return ingestor_poc::makePointwiseAddEngine(); + return kernel_ingestor_engine::makePointwiseAddEngine(); }}, #endif }; diff --git a/dnn-providers/hip-kernel-provider/src/engines/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/engines/CMakeLists.txt index 959a2a9169f7..4bc2a01cd5fe 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/engines/CMakeLists.txt @@ -8,3 +8,7 @@ endif() if(ENABLE_HIP_MLOPS_ENGINE) add_subdirectory(hip_mlops_engine) endif() + +if(HIPDNN_ENABLE_KERNEL_INGESTOR) + add_subdirectory(kernel_ingestor_engine) +endif() diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt similarity index 52% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt index bf7efb6955ee..9eb2d3401672 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt @@ -1,21 +1,21 @@ # Copyright © Advanced Micro Devices, Inc., or its affiliates. # SPDX-License-Identifier: MIT -# Embed this pack's kernel source so it can be compiled at plan-build time. -include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/KernelEmbedding.cmake") +# Embed the kernel sources this engine's packs compile at plan-build time. +include("${CMAKE_CURRENT_SOURCE_DIR}/../../cmake/KernelEmbedding.cmake") add_kernels_for_embedding( TARGET hip_kernel_provider_impl FILES ${CMAKE_CURRENT_SOURCE_DIR}/kernels/PointwiseAdd.cpp ) target_sources(hip_kernel_provider_impl PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/NativeMatchers.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/PointwiseAddDispatchHandler.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/PointwiseAddEngine.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/PointwiseAddPack.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/KernelIngestorEngine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/packs/PointwiseAddDispatchHandler.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/packs/PointwiseAddMatchers.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/packs/PointwiseAddPack.cpp ) # The sources above are entirely inside `#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR`, so the # definition must reach the object library that compiles them, not only the static -# library the tests link. This mirrors how each engine sets its own gate. +# library the tests link. This mirrors how each other engine sets its own gate. target_compile_definitions(hip_kernel_provider_impl PUBLIC HIPDNN_ENABLE_KERNEL_INGESTOR=1) diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp similarity index 96% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp index d302281bbf4b..7992555257cc 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/HandleDeviceResolver.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp @@ -13,7 +13,7 @@ #include "core/Handle.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { /** @@ -77,6 +77,6 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver mutable std::unordered_map _properties; }; -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp similarity index 90% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index 050ee4b35f4d..c66c7a940af0 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -1,7 +1,7 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -#include "ingestor_poc/PointwiseAddEngine.hpp" +#include "engines/kernel_ingestor_engine/KernelIngestorEngine.hpp" #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR @@ -12,12 +12,12 @@ #include #include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" -#include "ingestor_poc/HandleDeviceResolver.hpp" -#include "ingestor_poc/NativeMatchers.hpp" -#include "ingestor_poc/PointwiseAddDispatchHandler.hpp" -#include "ingestor_poc/PointwiseAddPack.hpp" +#include "engines/kernel_ingestor_engine/HandleDeviceResolver.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { namespace @@ -128,6 +128,6 @@ std::unique_ptr> makePoint return std::make_unique(); } -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp similarity index 89% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp index b406edef9431..e573c1052354 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddEngine.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp @@ -13,7 +13,7 @@ #include "core/Handle.hpp" #include "core/Settings.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { /** @@ -28,6 +28,6 @@ namespace hip_kernel_provider::ingestor_poc */ std::unique_ptr> makePointwiseAddEngine(); -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp similarity index 92% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp index 00d9f9f5fa85..96236ef498ea 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/kernels/PointwiseAdd.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp @@ -1,7 +1,7 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -// The kernel behind the kernel-ingestor POC's descriptor pack. Deliberately the +// The kernel behind the pointwise-add descriptor pack. Deliberately the // narrowest useful operation: elementwise add over a single-element tensor. // // HIP_PLUGIN_POINTWISE_ADD_TYPE is the element type, supplied per compilation from the diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp similarity index 94% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp index 25e00d616e25..5e3a0808c871 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp @@ -1,7 +1,7 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -#include "ingestor_poc/PointwiseAddDispatchHandler.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp" #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR @@ -12,10 +12,10 @@ #include #include "compilation/KernelCompileOptions.hpp" -#include "ingestor_poc/NativeMatchers.hpp" -#include "ingestor_poc/NativeSymbolNames.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { using namespace hipdnn_plugin_sdk::ingestor; @@ -156,6 +156,6 @@ void registerPointwiseAddDispatch(const PointwiseAddDispatchHandler& handler) DispatchRegistry::registerSymbol(std::string(DISPATCH_SYMBOL), &handler); } -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp similarity index 93% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp index 4501210aa521..9719e71f24a6 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddDispatchHandler.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp @@ -15,11 +15,11 @@ #include "compilation/IKernelCompiler.hpp" #include "core/Handle.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { /** - * @brief The native dispatch behind this POC's UDD: sizes and launches a pointwise add. + * @brief The native dispatch behind this pack's UDD: sizes and launches a pointwise add. * * Holds the provider machinery a launch needs — a kernel compiler and the device * properties it compiles against — which is exactly why this interface exists: the SDK @@ -75,6 +75,6 @@ class PointwiseAddDispatchHandler /// The handler is registered by pointer and must outlive every plan built from it. void registerPointwiseAddDispatch(const PointwiseAddDispatchHandler& handler); -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp similarity index 94% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index 1ba02dbd79c5..fafda0a9e9a9 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -1,7 +1,7 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -#include "ingestor_poc/NativeMatchers.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR @@ -14,9 +14,9 @@ #include #include -#include "ingestor_poc/NativeSymbolNames.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { using namespace hipdnn_plugin_sdk::ingestor; @@ -43,7 +43,7 @@ constexpr uint32_t MIN_SUPPORTED_RANK = 4; constexpr uint32_t MAX_SUPPORTED_RANK = 5; /// True when the tensor is a supported rank holding exactly one element -- the whole of -/// this POC's supported problem space. +/// this pack's supported problem space. bool isSingleElement(const data_objects::TensorAttributes& tensor) { const auto* dims = tensor.dims(); @@ -126,7 +126,7 @@ bool pointwiseAddGraphMatches(const MatchContext& context) return false; } - // One element each: this POC's kernel indexes element 0 and nothing else. + // One element each: this pack's kernel indexes element 0 and nothing else. if(!isSingleElement(*inputA) || !isSingleElement(*inputB) || !isSingleElement(*output)) { return false; @@ -184,6 +184,6 @@ void registerPointwiseAddMatchers() ScoreRegistry::registerSymbol(std::string(SCORE_SYMBOL), &pointwiseAddScore); } -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp similarity index 95% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp index 9e379d30805c..17f342cfbd79 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeMatchers.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp @@ -8,7 +8,7 @@ #include #include -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { /** @@ -64,6 +64,6 @@ PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::Match /// engine that is never constructed should not have mutated process-wide registries. void registerPointwiseAddMatchers(); -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp similarity index 78% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp index a9e49ff23921..de97ba580f76 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp @@ -1,7 +1,7 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -#include "ingestor_poc/PointwiseAddPack.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR @@ -11,9 +11,9 @@ #include #include -#include "ingestor_poc/NativeSymbolNames.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { using namespace hipdnn_plugin_sdk::ingestor; @@ -24,13 +24,13 @@ namespace // Descriptor ids. Real descriptors carry GUIDs so any author can mint one locally // without colliding with another's; readable names serve the same role for a pack that // is built in memory and never shared. -constexpr const char* ENGINE_ID = "poc.ued.pointwise_add"; -constexpr const char* SCHEMA_ID = "poc.kmd.pointwise_add"; -constexpr const char* HEURISTIC_ID = "poc.uhd.pointwise_add"; -constexpr const char* GRAPH_MATCHER_ID = "poc.umd.pointwise_add.graph"; -constexpr const char* KERNEL_MATCHER_ID = "poc.umd.pointwise_add.kernel"; -constexpr const char* DISPATCH_ID = "poc.udd.pointwise_add"; -constexpr const char* PACK_ID = "poc.kdp.pointwise_add"; +constexpr const char* ENGINE_ID = "hipkernel.ued.pointwise_add"; +constexpr const char* SCHEMA_ID = "hipkernel.kmd.pointwise_add"; +constexpr const char* HEURISTIC_ID = "hipkernel.uhd.pointwise_add"; +constexpr const char* GRAPH_MATCHER_ID = "hipkernel.umd.pointwise_add.graph"; +constexpr const char* KERNEL_MATCHER_ID = "hipkernel.umd.pointwise_add.kernel"; +constexpr const char* DISPATCH_ID = "hipkernel.udd.pointwise_add"; +constexpr const char* PACK_ID = "hipkernel.kdp.pointwise_add"; KernelDescriptor makeKernel(const std::string& id, int64_t blockSize, const std::string& dtype, int64_t priority) @@ -86,16 +86,16 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() KernelDescriptorPack pack; pack.id = PACK_ID; - pack.name = "kernel_ingestor_poc:pointwise_add"; + pack.name = "hipkernel:pointwise_add"; pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; pack.engineId = ENGINE_ID; pack.dispatchId = DISPATCH_ID; // Three kernels, each earning its place: the two FLOAT entries differ only in block // size, so ranking has an order to produce and the block_size knob has a real value // set; the HALF entry is what the kernel-scoped matcher prunes on a FLOAT graph. - pack.kernels = {makeKernel("poc.ukd.add_f32_block64", 64, "FLOAT", 0), - makeKernel("poc.ukd.add_f32_block256", 256, "FLOAT", 0), - makeKernel("poc.ukd.add_f16_block64", 64, "HALF", 0)}; + pack.kernels = {makeKernel("hipkernel.ukd.pointwise_add.f32_block64", 64, "FLOAT", 0), + makeKernel("hipkernel.ukd.pointwise_add.f32_block256", 256, "FLOAT", 0), + makeKernel("hipkernel.ukd.pointwise_add.f16_block64", 64, "HALF", 0)}; set.packs = {std::move(pack)}; return set; @@ -123,6 +123,6 @@ std::shared_ptr> makePointwiseAddStateManager std::make_shared(ScoreRegistry::resolve(std::string(SCORE_SYMBOL)))); } -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp similarity index 91% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp index c61d2267acb1..e3b5581f67b2 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/PointwiseAddPack.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp @@ -14,12 +14,12 @@ #include "core/Handle.hpp" -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { /** * @file PointwiseAddPack.hpp - * @brief The descriptor set the kernel-ingestor POC ships, built in memory. + * @brief The pointwise-add descriptor set, built in memory. * * Stands in for what a loader will produce from installed files: one engine (UED) with * its metadata schema (KMD) and heuristic (UHD), two matchers (UMDs), one dispatch @@ -47,7 +47,7 @@ struct PointwiseAddDescriptorSet hipdnn_plugin_sdk::ingestor::HeuristicDescriptor heuristic; }; -/// @brief Builds this POC's descriptor set. +/// @brief Builds this pack's descriptor set. PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); /** @@ -67,10 +67,10 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); */ int64_t pointwiseAddEngineId(); -/// @brief Builds the state manager backing this POC's engine. +/// @brief Builds the state manager backing this pack. std::shared_ptr> makePointwiseAddStateManager(); -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp similarity index 77% rename from dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp rename to dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp index cc6797fc2f45..96a059d474d2 100644 --- a/dnn-providers/hip-kernel-provider/src/ingestor_poc/NativeSymbolNames.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp @@ -7,7 +7,7 @@ #include -namespace hip_kernel_provider::ingestor_poc +namespace hip_kernel_provider::kernel_ingestor_engine { /** @@ -25,16 +25,16 @@ namespace hip_kernel_provider::ingestor_poc */ /// Graph-scoped applicability: a single-node pointwise ADD over 1-element tensors. -inline constexpr std::string_view GRAPH_MATCHER_SYMBOL = "pointwise_add_poc.graph_match"; +inline constexpr std::string_view GRAPH_MATCHER_SYMBOL = "hipkernel.pointwise_add.graph_match"; /// Kernel-scoped applicability: the kernel's dtype must be the graph's dtype. -inline constexpr std::string_view KERNEL_MATCHER_SYMBOL = "pointwise_add_poc.kernel_match"; +inline constexpr std::string_view KERNEL_MATCHER_SYMBOL = "hipkernel.pointwise_add.kernel_match"; /// Kernel-selection score. -inline constexpr std::string_view SCORE_SYMBOL = "pointwise_add_poc.score"; +inline constexpr std::string_view SCORE_SYMBOL = "hipkernel.pointwise_add.score"; /// Workspace sizing and launch. -inline constexpr std::string_view DISPATCH_SYMBOL = "pointwise_add_poc.dispatch"; +inline constexpr std::string_view DISPATCH_SYMBOL = "hipkernel.pointwise_add.dispatch"; /** * @brief The engine name, hashed into hipDNN's engine-id space. @@ -47,12 +47,12 @@ inline constexpr std::string_view DISPATCH_SYMBOL = "pointwise_add_poc.dispatch" * defined by data, so it registers its name when it is constructed rather than at build * time — which is the behavior this pack exists to demonstrate. */ -inline constexpr std::string_view ENGINE_NAME = "kernel_ingestor_poc:PointwiseAdd"; +inline constexpr std::string_view ENGINE_NAME = "hipkernel:PointwiseAdd"; /// The KMD fields this engine's kernels vary along. inline constexpr std::string_view BLOCK_SIZE_FIELD = "block_size"; inline constexpr std::string_view DTYPE_FIELD = "dtype"; -} // namespace hip_kernel_provider::ingestor_poc +} // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt index 923896cd71e2..385b02c990ec 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt @@ -73,7 +73,7 @@ endif() if(HIPDNN_ENABLE_KERNEL_INGESTOR) target_sources(hip_kernel_provider_integration_tests PRIVATE - ingestor_poc/IntegrationGpuKernelIngestor.cpp + kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp ) # This target links only the SDKs, not hip_kernel_provider_private, so it does not # inherit the provider's gate and needs the definition set directly. diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp b/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp similarity index 98% rename from dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp rename to dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp index 3bee3a282119..3534d76e7fd8 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/ingestor_poc/IntegrationGpuKernelIngestor.cpp +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp @@ -37,7 +37,7 @@ * the pieces agree with one another through the ABI, which is the whole claim of a * data-driven ingestor. */ -namespace hip_kernel_provider::ingestor_poc::integration +namespace hip_kernel_provider::kernel_ingestor_engine::integration { namespace @@ -48,7 +48,7 @@ namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; constexpr int64_t INPUT_A_UID = 1; constexpr int64_t INPUT_B_UID = 2; constexpr int64_t OUTPUT_UID = 3; -constexpr const char* ENGINE_NAME = "kernel_ingestor_poc:PointwiseAdd"; +constexpr const char* ENGINE_NAME = "hipkernel:PointwiseAdd"; constexpr const char* BLOCK_SIZE_KNOB = "block_size"; /// Workspace the pack's larger-block kernel declares, which the engine reports as the @@ -56,7 +56,7 @@ constexpr const char* BLOCK_SIZE_KNOB = "block_size"; constexpr size_t EXPECTED_WORKSPACE_BYTES = 1024; /// A serialized single-node pointwise-ADD graph over 1-element 4-D FLOAT tensors: the -/// one shape this POC's pack accepts. +/// one shape this pack accepts. flatbuffers::DetachedBuffer buildPointwiseAddGraph(bool withIdentity = true) { flatbuffers::FlatBufferBuilder builder; @@ -85,7 +85,7 @@ flatbuffers::DetachedBuffer buildPointwiseAddGraph(bool withIdentity = true) data_objects::NodeAttributes::PointwiseAttributes, attributes.Union())); - auto name = builder.CreateString("ingestor_poc_pointwise_add"); + auto name = builder.CreateString("pointwise_add"); auto tensorsVector = builder.CreateVector(tensors); auto nodesVector = builder.CreateVector(nodes); @@ -590,6 +590,6 @@ TEST_F(IntegrationGpuKernelIngestor, ExecutesAGraphThatCannotBeCached) } // namespace -} // namespace hip_kernel_provider::ingestor_poc::integration +} // namespace hip_kernel_provider::kernel_ingestor_engine::integration #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt index 13ed37e36273..178762d38f5b 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt @@ -31,14 +31,14 @@ add_executable( include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/KernelEmbedding.cmake") add_kernels_for_embedding(TARGET hip_kernel_provider_tests FILES ${CMAKE_CURRENT_SOURCE_DIR}/kernels/vector_add.cpp) -# The ingestor POC's dispatch handler compiles this kernel at plan-build time, and +# The kernel ingestor's dispatch handler compiles this kernel at plan-build time, and # kernel sources are embedded per target, so the test binary needs its own copy. # It is embedded from a configure-time copy rather than from the original path: a source # file registered for embedding in two directories lands in both directories' # CMAKE_CONFIGURE_DEPENDS, which Ninja rejects as two rules generating one file. if(HIPDNN_ENABLE_KERNEL_INGESTOR) configure_file( - ${CMAKE_CURRENT_SOURCE_DIR}/../ingestor_poc/kernels/PointwiseAdd.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp ${CMAKE_CURRENT_BINARY_DIR}/kernels/PointwiseAdd.cpp COPYONLY ) @@ -87,7 +87,7 @@ if(ENABLE_HIP_MLOPS_ENGINE) endif() if(HIPDNN_ENABLE_KERNEL_INGESTOR) - add_subdirectory(ingestor_poc) + add_subdirectory(engines/kernel_ingestor_engine) endif() clang_tidy_check(hip_kernel_provider_tests) diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt new file mode 100644 index 000000000000..1ad85d83b188 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright © Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +target_sources(hip_kernel_provider_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/packs/TestPointwiseAddDispatchHandler.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/packs/TestPointwiseAddMatchers.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/packs/TestPointwiseAddPack.cpp +) diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp similarity index 96% rename from dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp rename to dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp index 8729b9f381a1..4d7d8c2de557 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/PointwiseAddGraphs.hpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp @@ -14,7 +14,7 @@ #include #include -namespace hip_kernel_provider::ingestor_poc::testing +namespace hip_kernel_provider::kernel_ingestor_engine::testing { /// Tensor uids the builders below use, in argument order. @@ -23,7 +23,7 @@ constexpr int64_t INPUT_B_UID = 2; constexpr int64_t OUTPUT_UID = 3; /** - * @brief Builds a single-node pointwise graph, for driving the POC's matchers. + * @brief Builds a single-node pointwise graph, for driving this pack's matchers. * * Parameterized on everything the matchers gate, so a test can vary exactly one thing * and assert the effect: the operation, the element type, the tensor shape, and whether @@ -150,6 +150,6 @@ inline hipdnn_flatbuffers_sdk::utilities::UuidBytes makeGraphId(uint8_t seed) return id; } -} // namespace hip_kernel_provider::ingestor_poc::testing +} // namespace hip_kernel_provider::kernel_ingestor_engine::testing #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp similarity index 95% rename from dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp rename to dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index 52a7a052a477..de31f570702f 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -22,13 +22,13 @@ #include "core/Container.hpp" #include "core/Handle.hpp" #include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" -#include "ingestor_poc/NativeSymbolNames.hpp" -#include "ingestor_poc/PointwiseAddDispatchHandler.hpp" -#include "tests/ingestor_poc/PointwiseAddGraphs.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" +#include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" /** * @file TestPointwiseAddDispatchHandler.cpp - * @brief The POC's dispatch: workspace sizing, and a real compile-and-launch. + * @brief The pack's dispatch: workspace sizing, and a real compile-and-launch. * * The launch tests run on device deliberately. A recorded no-op would exercise neither * the runtime compile nor the uid-to-pointer resolution, which are the two parts most @@ -38,8 +38,8 @@ namespace { using namespace hip_kernel_provider; -using namespace hip_kernel_provider::ingestor_poc; -using namespace hip_kernel_provider::ingestor_poc::testing; +using namespace hip_kernel_provider::kernel_ingestor_engine; +using namespace hip_kernel_provider::kernel_ingestor_engine::testing; using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp similarity index 94% rename from dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp rename to dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index d8842bf9b522..7a05c30de4f0 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestNativeMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -13,13 +13,13 @@ #include #include -#include "ingestor_poc/NativeMatchers.hpp" -#include "ingestor_poc/NativeSymbolNames.hpp" -#include "tests/ingestor_poc/PointwiseAddGraphs.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" +#include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" /** * @file TestNativeMatchers.cpp - * @brief The POC's two matcher shapes: what each accepts, and what each refuses. + * @brief The pack's two matcher shapes: what each accepts, and what each refuses. * * The refusals matter more than the acceptances here. An under-specified decline accepts * a graph the kernel cannot serve, which is a wrong answer rather than a missed @@ -28,8 +28,8 @@ namespace { -using namespace hip_kernel_provider::ingestor_poc; -using namespace hip_kernel_provider::ingestor_poc::testing; +using namespace hip_kernel_provider::kernel_ingestor_engine; +using namespace hip_kernel_provider::kernel_ingestor_engine::testing; using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp similarity index 95% rename from dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp rename to dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp index 691a185229fe..c1cdd47616b0 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/TestPointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp @@ -10,17 +10,17 @@ #include -#include "ingestor_poc/NativeSymbolNames.hpp" -#include "ingestor_poc/PointwiseAddPack.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" /** * @file TestPointwiseAddPack.cpp - * @brief The POC's descriptor data: its shape, its cross-references, and its engine id. + * @brief The pack's descriptor data: its shape, its cross-references, and its engine id. */ namespace { -using namespace hip_kernel_provider::ingestor_poc; +using namespace hip_kernel_provider::kernel_ingestor_engine; TEST(TestPointwiseAddPack, ShipsThreeKernelsCoveringTwoBlockSizesAndTwoDataTypes) { diff --git a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt deleted file mode 100644 index 962f929d2a0c..000000000000 --- a/dnn-providers/hip-kernel-provider/src/tests/ingestor_poc/CMakeLists.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright © Advanced Micro Devices, Inc., or its affiliates. -# SPDX-License-Identifier: MIT - -target_sources(hip_kernel_provider_tests PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/TestPointwiseAddPack.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/TestNativeMatchers.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/TestPointwiseAddDispatchHandler.cpp -) diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 930f6baa92c8..49ead116b1d1 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -150,7 +150,7 @@ struct KernelDescriptor DescriptorId id; std::string name; /// The kernel source. Real sources are a tagged union over kpack, hsaco, hip, and - /// rocke (RFC 0017 §7); this skeleton carries only what the embedded-source path + /// rocke (RFC 0017 §7); this carries only what the embedded-source path /// needs, and a real source kind is the packaging follow-up. std::string sourceFile; std::string entryPoint; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 96fafcc60c10..f6af113445aa 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -86,7 +86,7 @@ class GenericPlanBuilder : public IPlanBuilder } /// Knob settings are read into provider settings here in the full design, where they - /// filter the catalog before ranking. This skeleton's engine exposes its knob for + /// filter the catalog before ranking. The first engine exposes its knob for /// reporting only, so there is nothing to carry across. void initializeExecutionSettings(const THandle& /*handle*/, const IGraph& /*opGraph*/, diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index 39f25ea6ad63..1dff4b283f8f 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -54,7 +54,7 @@ struct CatalogKeyHash /** * @brief The bound token state a matcher, scorer, or dispatch formula reads. * - * RFC 0017 names five expression namespaces; this skeleton binds the two that do not + * RFC 0017 names five expression namespaces; this binds the two that do not * require a matcher to have run first — `$graph.*` (and the tensor and node fields * reached through it) and `$device.*`. The third, `$kernel.*`, arrives separately as a * KernelDefinition, because a kernel-scoped check is evaluated once per candidate while diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp index acacee8cfda7..0a11b669fec6 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp @@ -23,7 +23,7 @@ * plan. In both cases the descriptor carries only a *symbol name*, never inline code, * and the provider resolves it from a registry that is part of its published contract. * - * This skeleton uses that escape hatch as the only implementation: every UMD, UHD, and + * The first implementation uses that escape hatch throughout: every UMD, UHD, and * UDD names a symbol here rather than carrying an expression or a model. Replacing a * native symbol with real declarative data is a change to one descriptor field, not to * the ingestor — which is the property this arrangement exists to demonstrate. diff --git a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt index 88aaf4e64662..9dc5ee1ae373 100644 --- a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt @@ -26,8 +26,8 @@ add_executable(hipdnn_plugin_sdk_tests TestVersion.cpp TestPluginDeviceBuffers.cpp TestRuntimePassByValue.cpp - $<$:TestIngestorSkeleton.cpp> - $<$:TestIngestorConcurrency.cpp> + $<$:TestKernelIngestor.cpp> + $<$:TestKernelIngestorConcurrency.cpp> ) target_compile_options(hipdnn_plugin_sdk_tests PRIVATE ${HIPDNN_WARNING_COMPILE_OPTIONS} ${HIPDNN_NO_RTTI_OPTIONS}) diff --git a/projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp similarity index 94% rename from projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp rename to projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp index 6be4d1f59216..9bbbc8c593b8 100644 --- a/projects/hipdnn/plugin_sdk/tests/IngestorTestGraphs.hpp +++ b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp @@ -23,7 +23,7 @@ #include /** - * @file IngestorTestGraphs.hpp + * @file KernelIngestorTestFixtures.hpp * @brief Shared fixtures for the ingestor's SDK-level tests. * * Models the pack shape a real engine ships: two kernels that differ only in a ranking @@ -35,9 +35,9 @@ namespace hipdnn_plugin_sdk::ingestor::testing constexpr const char* BLOCK_SIZE = "block_size"; constexpr const char* DTYPE = "dtype"; -constexpr const char* GRAPH_MATCH_SYMBOL = "ingestor_test.graph_match"; -constexpr const char* KERNEL_MATCH_SYMBOL = "ingestor_test.kernel_match"; -constexpr const char* SCORE_SYMBOL = "ingestor_test.score"; +constexpr const char* GRAPH_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.graph_match"; +constexpr const char* KERNEL_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.kernel_match"; +constexpr const char* SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.score"; /// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the rest /// of the interface throws: a test that starts depending on graph contents should fail @@ -205,7 +205,8 @@ inline std::unique_ptr> std::vector matchers{ {"umd_graph", "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}, {"umd_kernel", "kernel scoped", MatchScope::KERNEL, KERNEL_MATCH_SYMBOL}}; - std::vector dispatches{{"udd", "test dispatch", "ingestor_test.dispatch"}}; + std::vector dispatches{ + {"udd", "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}; KernelDescriptorPack pack; pack.id = "kdp"; diff --git a/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp similarity index 99% rename from projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp rename to projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp index 4466eae995af..2e011c44aaed 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestIngestorSkeleton.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp @@ -17,10 +17,10 @@ #include #include -#include "IngestorTestGraphs.hpp" +#include "KernelIngestorTestFixtures.hpp" /** - * @file TestIngestorSkeleton.cpp + * @file TestKernelIngestor.cpp * @brief Unit tests for the descriptor-driven kernel ingestor's SDK-side machinery. * * Covers the pieces every descriptor-backed engine depends on, independent of any diff --git a/projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp similarity index 99% rename from projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp rename to projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp index cb2c38bf7de1..26bef2d32c4a 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestIngestorConcurrency.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp @@ -16,10 +16,10 @@ #include #include -#include "IngestorTestGraphs.hpp" +#include "KernelIngestorTestFixtures.hpp" /** - * @file TestIngestorConcurrency.cpp + * @file TestKernelIngestorConcurrency.cpp * @brief The ingestor's shared state under concurrent use. * * One engine, and therefore one catalog cache, is shared by every handle in the process, From d78892958bab814cf216d9e1ad787689a5b8bc9c Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 20:21:26 -0400 Subject: [PATCH 18/60] refactor(hipdnn): tighten the ingestor descriptor types from PR review (ALMIOPEN-2402) Review feedback on the descriptor definitions, applied. These are type changes, so they are cheap now and expensive once packs and a loader depend on them. DescriptorId becomes the 128-bit UUID this branch already added for graph identity, rather than a bare string. RFC 0017 picks GUIDs so any author can mint an id locally without a central allocator, and using one type for both id spaces means the loader parses descriptor ids with the same parseUuid/formatUuid it already uses for graphs. std::array has no std::hash, so the maps keyed on an id take an explicit DescriptorIdHash, and diagnostics format ids through toString() rather than concatenating raw bytes. MetadataValue spans bool, int64_t, double, and string instead of just int64_t and string. int64_t and double are the widest signed integer and floating-point forms, so a narrower authored value converts into one without loss, and bool is separate because a flag compares and prints differently from the integer 1. A KMD field now declares its type explicitly and its default is optional. The two go together: without a default there is nothing to infer a type from, and RFC 0017 marks UKD metadata entries optional individually, so a field with no default is one every kernel must state for itself. completeMetadata enforces both at assembly time, where the kernel's catalog key is formed: omitting a field that has no default is an error rather than a silent fallback to a key the author never wrote, and supplying the wrong type is caught there rather than surfacing later as a bad_variant_access inside a matcher or a scorer. GenericEngine now owns the EngineDescriptor. A UED is 1:1 with a hipDNN engine, so the engine's identity and knob list belong to the engine; the state manager holds only what selection reads, which is the KMD, the matchers and dispatch descriptors the packs reference, the packs, and the resolved heuristic. The plan builder takes the UED by reference for its knob list and its diagnostics. The kernel-source fields carry a comment recording where they land: shipped AOT kernels are always packed into a kpack and loaded dynamically, so the real form is a tagged union whose alternatives are the source kinds of RFC 0017 section 7, and the build-time packaging step lowers hsaco, hip, and rocke to that same kpack form before install. Testing: - Two new tests covering the semantics the optional default introduces: a kernel omitting a field that has no default is rejected, and a kernel supplying a field of the wrong type is rejected. Both assert at assembly time, where the catalog key is formed. - 127 plugin_sdk, 460 provider unit, 14 integration tests pass, all exiting 0. - Flag off unchanged: 428 unit and 5 integration pass. - pre-commit passes. JIRA ID : ALMIOPEN-2402 --- .../KernelIngestorEngine.cpp | 4 +- .../packs/PointwiseAddDispatchHandler.cpp | 2 +- .../packs/PointwiseAddPack.cpp | 51 +++-- .../packs/PointwiseAddPack.hpp | 4 + .../packs/TestPointwiseAddDispatchHandler.cpp | 7 +- .../packs/TestPointwiseAddMatchers.cpp | 7 +- .../ingestor/Descriptors.hpp | 95 ++++++++- .../ingestor/GenericEngine.hpp | 24 ++- .../ingestor/GenericPlan.hpp | 7 +- .../ingestor/GenericPlanBuilder.hpp | 22 +- .../ingestor/KernelDefinition.hpp | 16 +- .../ingestor/KernelIngestorStateManager.hpp | 75 ++++--- .../tests/KernelIngestorTestFixtures.hpp | 74 ++++--- .../plugin_sdk/tests/TestKernelIngestor.cpp | 190 +++++++++++------- 14 files changed, 395 insertions(+), 183 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index c66c7a940af0..a96f966bc0ed 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -76,7 +76,9 @@ class PointwiseAddEngine : public hipdnn_plugin_sdk::IEngine> makePointwiseAddStateManager auto set = buildPointwiseAddDescriptorSet(); return std::make_shared>( - std::move(set.engine), std::move(set.schema), std::move(set.matchers), std::move(set.dispatches), diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp index e3b5581f67b2..fd81e5300465 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp @@ -68,6 +68,10 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); int64_t pointwiseAddEngineId(); /// @brief Builds the state manager backing this pack. +/// +/// Carries only what selection needs: the KMD, the matchers and dispatch descriptors the +/// packs reference, the packs themselves, and the resolved heuristic. The UED is not +/// among them, because a UED is 1:1 with a hipDNN engine and is owned by the engine. std::shared_ptr> makePointwiseAddStateManager(); diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index de31f570702f..28936110ab5a 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -40,15 +40,16 @@ namespace using namespace hip_kernel_provider; using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; +using hipdnn_flatbuffers_sdk::utilities::parseUuid; using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) { KernelDefinition kernel; - kernel.kernelId = "test.kernel"; - kernel.packId = "test.pack"; - kernel.dispatchId = "test.dispatch"; + kernel.kernelId = parseUuid("00000000-0000-4000-8000-000000000001"); + kernel.packId = parseUuid("00000000-0000-4000-8000-000000000002"); + kernel.dispatchId = parseUuid("00000000-0000-4000-8000-000000000003"); kernel.sourceFile = "PointwiseAdd.cpp"; kernel.entryPoint = "PointwiseAdd"; kernel.metadata diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index 7a05c30de4f0..4ae3eb7e75bf 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -30,6 +30,7 @@ namespace using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; +using hipdnn_flatbuffers_sdk::utilities::parseUuid; using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; @@ -44,9 +45,9 @@ hipDeviceProp_t testDeviceProperties() KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) { KernelDefinition kernel; - kernel.kernelId = "test.kernel"; - kernel.packId = "test.pack"; - kernel.dispatchId = "test.dispatch"; + kernel.kernelId = parseUuid("00000000-0000-4000-8000-000000000001"); + kernel.packId = parseUuid("00000000-0000-4000-8000-000000000002"); + kernel.dispatchId = parseUuid("00000000-0000-4000-8000-000000000003"); kernel.sourceFile = "PointwiseAdd.cpp"; kernel.entryPoint = "PointwiseAdd"; kernel.metadata diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 49ead116b1d1..5e6799a059b6 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -7,10 +7,13 @@ #include #include +#include #include #include #include +#include + /** * @file Descriptors.hpp * @brief The universal descriptor set from RFC 0017, as parsed in-memory data. @@ -40,14 +43,65 @@ namespace hipdnn_plugin_sdk::ingestor { -/// A descriptor cross-reference. Stable and globally unique in the real system (a GUID); -/// any unique string here, since nothing is loaded from a file yet. -using DescriptorId = std::string; +/// A descriptor cross-reference: the stable, globally unique id every descriptor +/// carries, and the only way descriptors name one another. +/// +/// A 128-bit UUID rather than a string, matching the graph identity hipDNN mints at +/// finalization, so the two id spaces in this system are one type. RFC 0017 §4 chooses +/// GUIDs so any author can mint an id locally that never collides with another's, with +/// no central allocation authority; `parseUuid` and `formatUuid` in the same header +/// convert to and from the text form descriptor files carry. +using DescriptorId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; + +/// @brief A descriptor id in its canonical text form, for diagnostics. +inline std::string toString(const DescriptorId& id) +{ + return hipdnn_flatbuffers_sdk::utilities::formatUuid(id); +} + +/// Hash for keying maps on a descriptor id. std::array has no std::hash, and a +/// specialization for it cannot be added without opening namespace std over a standard +/// type, so this is passed explicitly to the containers that need it. +struct DescriptorIdHash +{ + size_t operator()(const DescriptorId& id) const noexcept + { + // The id is a UUID, already well distributed, so an FNV-1a fold over its bytes + // is sufficient for an in-process map and is never persisted or exposed. + size_t hash = 1469598103934665603ULL; + for(const uint8_t byte : id) + { + hash ^= static_cast(byte); + hash *= 1099511628211ULL; + } + return hash; + } +}; -/// A value a kernel supplies for a KMD-declared field. The KMD declares which type each -/// field holds; a UKD carrying the wrong alternative for a field is a load error the -/// validating loader reports (not modelled here, since nothing is loaded). -using MetadataValue = std::variant; +/// A value a kernel supplies for a KMD-declared field. +/// +/// Spans the primitive types a descriptor field can hold. `int64_t` and `double` are the +/// widest signed integer and floating-point forms, so a narrower authored value converts +/// into one without loss; `bool` is distinct because a flag compares and prints +/// differently from the integer 1. +using MetadataValue = std::variant; + +/// The type a KMD field holds. Kept as an explicit enum, and ordered to match +/// MetadataValue's alternatives, so a field can declare its type without also having to +/// supply a value of it. +enum class MetadataType +{ + BOOL, + INT, + FLOAT, + STRING, +}; + +/// @brief The type of a value, for checking one against its field's declaration. +inline MetadataType metadataTypeOf(const MetadataValue& value) +{ + return static_cast(value.index()); +} /// A kernel's complete metadata tuple: every KMD field name mapped to this kernel's /// value for it. RFC 0017 §4 makes this tuple the kernel's identity to the catalog, so @@ -59,8 +113,17 @@ using MetadataValues = std::map; struct MetadataField { std::string name; + /// What this field holds. Declared separately from the default because a field need + /// not have one, and a kernel supplying the wrong alternative is a load error the + /// validating loader reports against this. + MetadataType type = MetadataType::INT; /// The value a kernel that omits this field is taken to have supplied. - MetadataValue defaultValue; + /// + /// Optional, because a field can be mandatory: RFC 0017 §4 marks a UKD's metadata + /// entries `optional` individually, so a field with no default is one every kernel + /// in the engine must state for itself, and omitting it is a load error rather than + /// a silent fallback. + std::optional defaultValue; }; /// KMD: the metadata schema, one per engine, shared by every kernel the engine owns. @@ -149,9 +212,19 @@ struct KernelDescriptor { DescriptorId id; std::string name; - /// The kernel source. Real sources are a tagged union over kpack, hsaco, hip, and - /// rocke (RFC 0017 §7); this carries only what the embedded-source path - /// needs, and a real source kind is the packaging follow-up. + /// Where this kernel's code comes from. + /// + /// A named source file plus an entry point, which is what the embedded-source path + /// this engine currently compiles through needs. The real form is a tagged union + /// over the source kinds of RFC 0017 §7, one alternative per supported kind, so a + /// descriptor states which kind it carries rather than every kind sharing two + /// strings. + /// + /// Shipped AOT kernels converge on one of those kinds: they are always packed into + /// a kpack and loaded dynamically from it, so the field these two become is a kpack + /// library plus the symbol to resolve inside it. The other kinds (`hsaco`, `hip`, + /// `rocke`) describe how a kernel is *built*, and the build-time packaging step + /// lowers each of them to that same kpack form before install. std::string sourceFile; std::string entryPoint; /// This kernel's values for the KMD's fields. Omitted fields take the KMD default; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index be07b3d7d187..14cde71073ab 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -48,19 +48,32 @@ class GenericEngine : public IEngine using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; /** - * @param stateManager The engine's descriptor state, already validated. + * @param engine The UED this engine is. A UED is 1:1 with a hipDNN engine, so + * the engine owns it: its name is this engine's identity, and its knob list is + * what this engine exposes to a caller. + * @param stateManager The descriptor state this engine selects over, already + * validated. * @param deviceResolver Answers which device each call is for, from the handle that * carries it. Held by reference; owned by the provider, which must keep it * alive for the engine's lifetime. */ - GenericEngine(std::shared_ptr> stateManager, + GenericEngine(EngineDescriptor engine, + std::shared_ptr> stateManager, const IDeviceResolver& deviceResolver) - : _stateManager(std::move(stateManager)) - , _id(hipdnn_data_sdk::utilities::engineNameToId(_stateManager->engine().name)) - , _planBuilder(_stateManager, deviceResolver) + : _engine(std::move(engine)) + , _stateManager(std::move(stateManager)) + , _id(hipdnn_data_sdk::utilities::engineNameToId(_engine.name)) + , _planBuilder(_engine, _stateManager, deviceResolver) { } + /// The descriptor this engine was built from, for diagnostics and for a caller that + /// needs the engine's declared knobs or notes. + const EngineDescriptor& descriptor() const + { + return _engine; + } + int64_t id() const override { return _id; @@ -122,6 +135,7 @@ class GenericEngine : public IEngine } private: + EngineDescriptor _engine; std::shared_ptr> _stateManager; int64_t _id; GenericPlanBuilder _planBuilder; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp index f369fc8200c4..de85ff22e6a4 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -54,7 +54,8 @@ class GenericPlan : public IPlan { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, "dispatch handler for kernel '" - + _dispatcher.kernel.kernelId + "' prepared no launch"); + + toString(_dispatcher.kernel.kernelId) + + "' prepared no launch"); } } @@ -71,8 +72,8 @@ class GenericPlan : public IPlan if(_workspaceBytes > 0 && workspace == nullptr) { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE, - "kernel '" + _dispatcher.kernel.kernelId + "' requires " - + std::to_string(_workspaceBytes) + "kernel '" + toString(_dispatcher.kernel.kernelId) + + "' requires " + std::to_string(_workspaceBytes) + " workspace bytes but none was provided"); } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index f6af113445aa..b241aac35755 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -44,13 +44,18 @@ class GenericPlanBuilder : public IPlanBuilder using IGraph = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph; using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; - /// @param stateManager The engine's descriptor state. Shared rather than owned so the - /// engine and its builder read one catalog cache. + /// @param engine The UED whose knobs this builder reports and whose name its + /// diagnostics carry. Held by reference; owned by the engine, which outlives + /// its builder. + /// @param stateManager The descriptor state to select over. Shared rather than owned + /// so the engine and its builder read one catalog cache. /// @param deviceResolver Answers which device each call is for. Held by reference; /// owned by the engine, which outlives its builder. - GenericPlanBuilder(std::shared_ptr> stateManager, + GenericPlanBuilder(const EngineDescriptor& engine, + std::shared_ptr> stateManager, const IDeviceResolver& deviceResolver) - : _stateManager(std::move(stateManager)) + : _engine(engine) + , _stateManager(std::move(stateManager)) , _deviceResolver(deviceResolver) { } @@ -114,7 +119,7 @@ class GenericPlanBuilder : public IPlanBuilder if(ranked.empty()) { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, - "engine '" + _stateManager->engine().name + "engine '" + _engine.name + "' accepted this graph but has no applicable kernel"); } @@ -144,7 +149,7 @@ class GenericPlanBuilder : public IPlanBuilder return knobs; } - for(const auto& knobName : _stateManager->engine().knobs) + for(const auto& knobName : _engine.knobs) { const auto values = KernelIngestorStateManager::knobValues(ranked, knobName); @@ -168,8 +173,8 @@ class GenericPlanBuilder : public IPlanBuilder KnobT knob; knob.knob_id = knobName; - knob.description = "Kernel metadata field '" + knobName + "' of engine '" - + _stateManager->engine().name + "'"; + knob.description + = "Kernel metadata field '" + knobName + "' of engine '" + _engine.name + "'"; IntValueT defaultValue; // knobValues() preserves ranked order, so the first entry is the top-ranked @@ -197,6 +202,7 @@ class GenericPlanBuilder : public IPlanBuilder return MatchContext{opGraph, deviceId, _deviceResolver.deviceProperties(deviceId)}; } + const EngineDescriptor& _engine; std::shared_ptr> _stateManager; const IDeviceResolver& _deviceResolver; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp index 0c5d003db226..adde2db18f38 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp @@ -64,14 +64,14 @@ struct KernelDefinition auto it = metadata.find(field); if(it == metadata.end()) { - throw std::out_of_range("kernel '" + kernelId + "' has no metadata field '" + field - + "'"); + throw std::out_of_range("kernel '" + toString(kernelId) + "' has no metadata field '" + + field + "'"); } const auto* value = std::get_if(&it->second); if(value == nullptr) { - throw std::invalid_argument("metadata field '" + field + "' of kernel '" + kernelId - + "' is not an integer"); + throw std::invalid_argument("metadata field '" + field + "' of kernel '" + + toString(kernelId) + "' is not an integer"); } return *value; } @@ -82,14 +82,14 @@ struct KernelDefinition auto it = metadata.find(field); if(it == metadata.end()) { - throw std::out_of_range("kernel '" + kernelId + "' has no metadata field '" + field - + "'"); + throw std::out_of_range("kernel '" + toString(kernelId) + "' has no metadata field '" + + field + "'"); } const auto* value = std::get_if(&it->second); if(value == nullptr) { - throw std::invalid_argument("metadata field '" + field + "' of kernel '" + kernelId - + "' is not a string"); + throw std::invalid_argument("metadata field '" + field + "' of kernel '" + + toString(kernelId) + "' is not a string"); } return *value; } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index 124ddd247f50..c572044c030e 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -44,8 +44,8 @@ struct KernelDispatcher * @brief The engine's view of its own kernels: which apply to a graph, in what order, * and how to launch one. * - * Holds an engine's descriptor set (one UED, its KMD and UHD, and the packs naming it) - * and answers the three questions hipDNN's four host calls reduce to: + * Holds the descriptor state one engine selects over (its KMD and UHD, and the packs + * naming it) and answers the three questions hipDNN's four host calls reduce to: * * | Host call | Answered by | * |------------------------------|------------------------------------------------| @@ -73,9 +73,8 @@ class KernelIngestorStateManager static constexpr size_t DEFAULT_CATALOG_CACHE_CAPACITY = 256; /** - * @param engine The UED this manager serves. - * @param schema That engine's KMD. Supplies the defaults completing each kernel's - * metadata tuple. + * @param schema The engine's KMD. Supplies the defaults completing each kernel's + * metadata tuple, and the field types a value is checked against. * @param matchers Every UMD any of @p packs references, by id. * @param dispatches Every UDD any of @p packs references, by id. * @param packs The KDPs naming this engine. @@ -87,15 +86,13 @@ class KernelIngestorStateManager * be evaluated, and duplicate catalog keys leave selection with two * indistinguishable candidates and no basis to prefer either. */ - KernelIngestorStateManager(EngineDescriptor engine, - MetadataSchema schema, + KernelIngestorStateManager(MetadataSchema schema, std::vector matchers, std::vector dispatches, std::vector packs, std::shared_ptr heuristic, size_t catalogCacheCapacity = DEFAULT_CATALOG_CACHE_CAPACITY) - : _engine(std::move(engine)) - , _schema(std::move(schema)) + : _schema(std::move(schema)) , _packs(std::move(packs)) , _heuristic(std::move(heuristic)) , _catalogCache(catalogCacheCapacity) @@ -117,11 +114,6 @@ class KernelIngestorStateManager validateAndIndexPacks(); } - const EngineDescriptor& engine() const - { - return _engine; - } - const MetadataSchema& metadataSchema() const { return _schema; @@ -183,9 +175,9 @@ class KernelIngestorStateManager auto it = _dispatches.find(kernel.dispatchId); if(it == _dispatches.end()) { - throw std::runtime_error("kernel '" + kernel.kernelId - + "' names unknown dispatch descriptor '" + kernel.dispatchId - + "'"); + throw std::runtime_error("kernel '" + toString(kernel.kernelId) + + "' names unknown dispatch descriptor '" + + toString(kernel.dispatchId) + "'"); } return {kernel, DispatchRegistry::resolve(it->second.dispatchSymbol)}; @@ -226,15 +218,16 @@ class KernelIngestorStateManager { if(_matchers.find(matcherId) == _matchers.end()) { - throw std::invalid_argument("pack '" + pack.id + "' names unknown matcher '" - + matcherId + "'"); + throw std::invalid_argument("pack '" + toString(pack.id) + + "' names unknown matcher '" + toString(matcherId) + + "'"); } } if(_dispatches.find(pack.dispatchId) == _dispatches.end()) { - throw std::invalid_argument("pack '" + pack.id + throw std::invalid_argument("pack '" + toString(pack.id) + "' names unknown dispatch descriptor '" - + pack.dispatchId + "'"); + + toString(pack.dispatchId) + "'"); } for(const auto& kernel : pack.kernels) @@ -243,9 +236,9 @@ class KernelIngestorStateManager if(std::find(seenKeys.begin(), seenKeys.end(), key) != seenKeys.end()) { throw std::invalid_argument( - "kernel '" + kernel.id - + "' duplicates the metadata tuple of another kernel in engine '" - + _engine.name + "'; the tuple is the catalog key and must be unique"); + "kernel '" + toString(kernel.id) + + "' duplicates the metadata tuple of another kernel under schema '" + + _schema.name + "'; the tuple is the catalog key and must be unique"); } seenKeys.push_back(std::move(key)); } @@ -258,10 +251,37 @@ class KernelIngestorStateManager MetadataValues completeMetadata(const KernelDescriptor& kernel) const { MetadataValues complete = kernel.metadata; + for(const auto& field : _schema.fields) { - complete.emplace(field.name, field.defaultValue); + auto it = complete.find(field.name); + + if(it == complete.end()) + { + // A field with no default is one every kernel must state for itself, so + // an omission is an authoring error rather than a silent fallback: it + // would otherwise produce a catalog key the author never wrote. + if(!field.defaultValue.has_value()) + { + throw std::invalid_argument("kernel '" + toString(kernel.id) + + "' omits metadata field '" + field.name + + "', which declares no default"); + } + complete.emplace(field.name, *field.defaultValue); + continue; + } + + // The KMD declares each field's type, so a value of the wrong one is caught + // here rather than surfacing as a bad_variant_access from a matcher or a + // scorer, far from the descriptor that caused it. + if(metadataTypeOf(it->second) != field.type) + { + throw std::invalid_argument("kernel '" + toString(kernel.id) + + "' supplies metadata field '" + field.name + + "' with a value of the wrong type"); + } } + return complete; } @@ -374,10 +394,9 @@ class KernelIngestorStateManager return true; } - EngineDescriptor _engine; MetadataSchema _schema; - std::unordered_map _matchers; - std::unordered_map _dispatches; + std::unordered_map _matchers; + std::unordered_map _dispatches; std::vector _packs; std::shared_ptr _heuristic; /// Mutable because the query methods are logically const: they answer questions about diff --git a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp index 9bbbc8c593b8..7e4c82d9fb14 100644 --- a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp @@ -172,18 +172,50 @@ class ScopedTestSymbols ScopedTestSymbols& operator=(const ScopedTestSymbols&) = delete; }; -inline KernelDescriptor - makeTestKernel(const std::string& id, int64_t blockSize, const std::string& dtype) +/// A descriptor id from a short seed, so a fixture can name ids readably while the type +/// stays the real 128-bit one. +inline DescriptorId testId(uint8_t seed) +{ + DescriptorId id{}; + id.fill(seed); + return id; +} + +inline const DescriptorId ENGINE_ID = testId(0xE0); +inline const DescriptorId SCHEMA_ID = testId(0xE1); +inline const DescriptorId HEURISTIC_ID = testId(0xE2); +inline const DescriptorId GRAPH_MATCHER_ID = testId(0xE3); +inline const DescriptorId KERNEL_MATCHER_ID = testId(0xE4); +inline const DescriptorId DISPATCH_ID = testId(0xE5); +inline const DescriptorId PACK_ID = testId(0xE6); + +inline KernelDescriptor makeTestKernel(const DescriptorId& id, + const std::string& name, + int64_t blockSize, + const std::string& dtype) { KernelDescriptor kernel; kernel.id = id; - kernel.name = id; + kernel.name = name; kernel.sourceFile = "Test.cpp"; kernel.entryPoint = "TestKernel"; - kernel.metadata = {{BLOCK_SIZE, blockSize}, {DTYPE, dtype}}; + kernel.metadata = {{BLOCK_SIZE, MetadataValue{blockSize}}, {DTYPE, MetadataValue{dtype}}}; return kernel; } +/// The UED the fixture engine is built from. Separate from the state manager, which +/// holds only what selection reads. +inline EngineDescriptor makeTestEngine() +{ + EngineDescriptor engine; + engine.id = ENGINE_ID; + engine.name = "test:engine"; + engine.heuristicId = HEURISTIC_ID; + engine.metadataSchemaId = SCHEMA_ID; + engine.knobs = {BLOCK_SIZE}; + return engine; +} + /// Two FLOAT kernels differing only in block size, plus a HALF kernel the kernel-scoped /// matcher prunes: a catalog of two survivors with a defined ranking. inline std::unique_ptr> @@ -191,35 +223,29 @@ inline std::unique_ptr> = KernelIngestorStateManager::DEFAULT_CATALOG_CACHE_CAPACITY) { MetadataSchema schema; - schema.id = "kmd"; + schema.id = SCHEMA_ID; schema.name = "test schema"; - schema.fields = {{BLOCK_SIZE, int64_t{64}}, {DTYPE, std::string{"FLOAT"}}}; - - EngineDescriptor engine; - engine.id = "ued"; - engine.name = "test:engine"; - engine.heuristicId = "uhd"; - engine.metadataSchemaId = "kmd"; - engine.knobs = {BLOCK_SIZE}; + // block_size defaults; dtype is mandatory, so every kernel below states it. + schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}; std::vector matchers{ - {"umd_graph", "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}, - {"umd_kernel", "kernel scoped", MatchScope::KERNEL, KERNEL_MATCH_SYMBOL}}; + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, KERNEL_MATCH_SYMBOL}}; std::vector dispatches{ - {"udd", "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}; + {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}; KernelDescriptorPack pack; - pack.id = "kdp"; + pack.id = PACK_ID; pack.name = "test pack"; - pack.matcherIds = {"umd_graph", "umd_kernel"}; - pack.engineId = "ued"; - pack.dispatchId = "udd"; - pack.kernels = {makeTestKernel("kernel_64_float", 64, "FLOAT"), - makeTestKernel("kernel_256_float", 256, "FLOAT"), - makeTestKernel("kernel_64_half", 64, "HALF")}; + pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT"), + makeTestKernel(testId(0x65), "kernel_256_float", 256, "FLOAT"), + makeTestKernel(testId(0x66), "kernel_64_half", 64, "HALF")}; return std::make_unique>( - std::move(engine), std::move(schema), std::move(matchers), std::move(dispatches), diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp index 2e011c44aaed..3ad25086de1d 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp @@ -84,39 +84,61 @@ double scoreConstant(const KernelDefinition& /*kernel*/, const MatchContext& /*c MetadataSchema makeSchema() { - return {"kmd", "test schema", {{BLOCK_SIZE, int64_t{64}}, {DTYPE, std::string{"FLOAT"}}}}; + return {SCHEMA_ID, + "test schema", + {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}}; } -EngineDescriptor makeEngine(std::vector knobs = {BLOCK_SIZE}) -{ - return {"ued", "test:engine", "uhd", "kmd", std::move(knobs)}; -} - -KernelDescriptor makeKernel(const std::string& id, +KernelDescriptor makeKernel(const DescriptorId& id, + const std::string& name, int64_t blockSize, const std::string& dtype, int64_t priority = 0) { - auto kernel = makeTestKernel(id, blockSize, dtype); + auto kernel = makeTestKernel(id, name, blockSize, dtype); kernel.priority = priority; return kernel; } /// The pack shape a real engine ships, wired to the counting matchers above. -KernelDescriptorPack makePack(std::vector matcherIds) +KernelDescriptorPack makePack(const std::vector& matcherIds) { KernelDescriptorPack pack; - pack.id = "kdp"; + pack.id = PACK_ID; pack.name = "test pack"; - pack.matcherIds = std::move(matcherIds); - pack.engineId = "ued"; - pack.dispatchId = "udd"; - pack.kernels = {makeKernel("kernel_64_float", 64, "FLOAT"), - makeKernel("kernel_256_float", 256, "FLOAT"), - makeKernel("kernel_64_half", 64, "HALF")}; + pack.matcherIds = matcherIds; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeKernel(testId(0x64), "kernel_64_float", 64, "FLOAT"), + makeKernel(testId(0x65), "kernel_256_float", 256, "FLOAT"), + makeKernel(testId(0x66), "kernel_64_half", 64, "HALF")}; return pack; } +std::vector makeTestMatchers() +{ + return {{GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; +} + +std::vector makeTestDispatches() +{ + return {{DISPATCH_ID, "test dispatch", "test.dispatch"}}; +} + +/// A catalog entry, for the ranking tests that build one directly. +KernelDefinition makeDefinition(const DescriptorId& id, int64_t blockSize, int64_t priority = 0) +{ + return {id, + PACK_ID, + DISPATCH_ID, + "Test.cpp", + "TestKernel", + {{BLOCK_SIZE, MetadataValue{blockSize}}}, + priority}; +} + /// Registers counting matchers under this file's own symbol names, so these tests /// observe invocation counts without disturbing the shared fixture's registrations. class ScopedSymbols @@ -156,16 +178,15 @@ std::unique_ptr makeStateManager(ScoreFn scoreFn = scoreByBlockSiz = StateManager::DEFAULT_CATALOG_CACHE_CAPACITY) { std::vector matchers{ - {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}, - {"umd_kernel", "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; - std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; + std::vector dispatches{{DISPATCH_ID, "test dispatch", "test.dispatch"}}; return std::make_unique( - makeEngine(), makeSchema(), std::move(matchers), std::move(dispatches), - std::vector{makePack({"umd_graph", "umd_kernel"})}, + std::vector{makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID})}, std::make_shared(scoreFn), cacheCapacity); } @@ -283,14 +304,15 @@ TEST(TestIngestorHeuristic, RanksHigherScoringKernelsFirst) const MatchContext context{graph, 0, properties}; Catalog catalog; - catalog.entries = {{"low", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 0}, - {"high", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{256}}}, 0}}; + const auto lowId = testId(0x01); + const auto highId = testId(0x02); + catalog.entries = {makeDefinition(lowId, 64), makeDefinition(highId, 256)}; const NativeKernelHeuristic heuristic(scoreByBlockSize); const auto ranked = heuristic.rank(catalog, context); ASSERT_EQ(ranked.size(), 2U); - EXPECT_EQ(ranked.front().kernelId, "high"); + EXPECT_EQ(ranked.front().kernelId, highId); } TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) @@ -300,14 +322,15 @@ TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) const MatchContext context{graph, 0, properties}; Catalog catalog; - catalog.entries = {{"a", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 1}, - {"b", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 5}}; + const auto lowPriorityId = testId(0x01); + const auto highPriorityId = testId(0x02); + catalog.entries = {makeDefinition(lowPriorityId, 64, 1), makeDefinition(highPriorityId, 64, 5)}; const NativeKernelHeuristic heuristic(scoreConstant); const auto ranked = heuristic.rank(catalog, context); ASSERT_EQ(ranked.size(), 2U); - EXPECT_EQ(ranked.front().kernelId, "b"); + EXPECT_EQ(ranked.front().kernelId, highPriorityId); } TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) @@ -318,14 +341,16 @@ TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) Catalog catalog; // Deliberately inserted out of id order: the result must not depend on load order. - catalog.entries = {{"zulu", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 0}, - {"alpha", "kdp", "udd", "s", "e", {{BLOCK_SIZE, int64_t{64}}}, 0}}; + // Inserted highest-id first: the result must not depend on load order. + const auto lowerId = testId(0x01); + const auto higherId = testId(0x02); + catalog.entries = {makeDefinition(higherId, 64), makeDefinition(lowerId, 64)}; const NativeKernelHeuristic heuristic(scoreConstant); const auto ranked = heuristic.rank(catalog, context); ASSERT_EQ(ranked.size(), 2U); - EXPECT_EQ(ranked.front().kernelId, "alpha"); + EXPECT_EQ(ranked.front().kernelId, lowerId); } // --------------------------------------------------------------------------- @@ -482,26 +507,21 @@ TEST(TestIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) EXPECT_EQ(std::get(values[1]), 64); } -TEST(TestIngestorStateManager, CompletesKernelMetadataFromSchemaDefaults) +TEST(TestIngestorStateManager, CompletesAnOmittedFieldFromItsSchemaDefault) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - KernelDescriptorPack pack = makePack({"umd_graph", "umd_kernel"}); - // A kernel that declares no metadata at all takes every schema default. + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + // Omits block_size, which defaults, and states dtype, which does not. KernelDescriptor sparse; - sparse.id = "kernel_defaults"; - sparse.name = sparse.id; + sparse.id = testId(0x70); + sparse.name = "kernel_defaults"; + sparse.metadata = {{DTYPE, MetadataValue{std::string{"FLOAT"}}}}; pack.kernels = {sparse}; - std::vector matchers{ - {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}, - {"umd_kernel", "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; - std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; - - const StateManager manager(makeEngine(), - makeSchema(), - std::move(matchers), - std::move(dispatches), + const StateManager manager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), {pack}, std::make_shared(scoreByBlockSize)); @@ -514,50 +534,83 @@ TEST(TestIngestorStateManager, CompletesKernelMetadataFromSchemaDefaults) EXPECT_EQ(definitions.front().getStringMetadata(DTYPE), "FLOAT"); } -TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownMatcher) +TEST(TestIngestorStateManager, RejectsAKernelOmittingAFieldWithNoDefault) { - std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + // dtype declares no default, so every kernel must state it. Omitting it would + // otherwise produce a catalog key the author never wrote. + KernelDescriptor missingDtype; + missingDtype.id = testId(0x71); + missingDtype.name = "kernel_missing_dtype"; + missingDtype.metadata = {{BLOCK_SIZE, MetadataValue{int64_t{64}}}}; + pack.kernels = {missingDtype}; + + // Caught when the descriptor set is assembled, not when a graph first arrives: the + // completed tuple is the kernel's catalog key, so it has to exist before matching. + EXPECT_THROW(StateManager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {pack}, + std::make_shared(scoreByBlockSize)), + std::invalid_argument); +} + +TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldOfTheWrongType) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + // block_size is declared INT. A string here would otherwise surface far away, as a + // bad_variant_access inside a matcher or a scorer. + KernelDescriptor wrongType; + wrongType.id = testId(0x72); + wrongType.name = "kernel_wrong_type"; + wrongType.metadata = {{BLOCK_SIZE, MetadataValue{std::string{"64"}}}, + {DTYPE, MetadataValue{std::string{"FLOAT"}}}}; + pack.kernels = {wrongType}; + + EXPECT_THROW(StateManager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {pack}, + std::make_shared(scoreByBlockSize)), + std::invalid_argument); +} + +TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownMatcher) +{ // A dangling cross-reference cannot be evaluated, so it is caught when the // descriptor set is assembled rather than when a graph first arrives. - EXPECT_THROW(StateManager(makeEngine(), - makeSchema(), + EXPECT_THROW(StateManager(makeSchema(), {}, - std::move(dispatches), - {makePack({"umd_missing"})}, + makeTestDispatches(), + {makePack({testId(0xFF)})}, std::make_shared(scoreByBlockSize)), std::invalid_argument); } TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownDispatchDescriptor) { - std::vector matchers{ - {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}}; - - EXPECT_THROW(StateManager(makeEngine(), - makeSchema(), - std::move(matchers), + EXPECT_THROW(StateManager(makeSchema(), + makeTestMatchers(), {}, - {makePack({"umd_graph"})}, + {makePack({GRAPH_MATCHER_ID})}, std::make_shared(scoreByBlockSize)), std::invalid_argument); } TEST(TestIngestorStateManager, RejectsTwoKernelsSharingAMetadataTuple) { - std::vector matchers{ - {"umd_graph", "graph scoped", MatchScope::GRAPH, "test.graph"}}; - std::vector dispatches{{"udd", "test dispatch", "test.dispatch"}}; - - KernelDescriptorPack pack = makePack({"umd_graph"}); + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID}); // Same completed tuple as kernel_64_float: selection would have two indistinguishable // candidates and no basis to prefer either. - pack.kernels.push_back(makeKernel("kernel_duplicate", 64, "FLOAT")); + pack.kernels.push_back(makeKernel(testId(0x73), "kernel_duplicate", 64, "FLOAT")); - EXPECT_THROW(StateManager(makeEngine(), - makeSchema(), - std::move(matchers), - std::move(dispatches), + EXPECT_THROW(StateManager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), {pack}, std::make_shared(scoreByBlockSize)), std::invalid_argument); @@ -565,8 +618,7 @@ TEST(TestIngestorStateManager, RejectsTwoKernelsSharingAMetadataTuple) TEST(TestIngestorStateManager, RejectsAMissingHeuristic) { - EXPECT_THROW(StateManager(makeEngine(), makeSchema(), {}, {}, {}, nullptr), - std::invalid_argument); + EXPECT_THROW(StateManager(makeSchema(), {}, {}, {}, nullptr), std::invalid_argument); } } // namespace From 3c9098df43d924003220d8b49e0ee58051163f4e Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 21:15:43 -0400 Subject: [PATCH 19/60] fix(hipdnn): conform the kernel ingestor to RFC 0017's matching and caching rules (ALMIOPEN-2402) Findings from a section-by-section review of the ingestor against RFC 0017. Each of these is invisible with one pack and one engine, which is why they are worth fixing before packs and a loader depend on them. Matching, RFC 0017 section 5. A graph-scoped matcher was evaluated once per pack that listed it rather than once per (graph, device), and a failure pruned only the pack being iterated. Matchers are shared by id across packs precisely so the broadly shared checks prune the candidate set fast; re-running one per pack inverts that, making the most shared check the most expensive. Verdicts are now memoized by matcher id for the duration of a catalog build, so one evaluation disqualifies every pack listing it. Catalog key, RFC 0017 section 4. completeMetadata started from the kernel's own map, so a metadata field the KMD never declared survived into the catalog key. A misspelled field name failed twice over: the real field silently took its default, and the stray value joined the key where nothing downstream can read it. Two kernels differing only in such a value both entered the catalog with selection unable to tell them apart. The key is now built from the schema's field list and an undeclared field is rejected. Engine knobs, RFC 0017 section 4: "a name no field matches is a load error". That was neither checked nor checkable, because the state manager held the schema and never saw the UED. Now that GenericEngine owns the UED it holds both, and validates knobs against the schema at construction. Workspace, RFC 0017 section 6. workspaceBytes took only the kernel, but a workspace requirement is a formula over graph dimensions as much as kernel metadata, so the declarative evaluator this seam exists for could not have been written against it. It now takes the bound token state, which both call sites already held. getMaxWorkspaceSize also ranked the catalog to compute a maximum that does not depend on order, which under a real model heuristic would load and run one per candidate engine for nothing; it reads the unsorted catalog now. Heuristic, RFC 0017 section 9.2. rank() was virtual, which is exactly the whole-catalog scorer the RFC says the interface must make unwritable, with only a comment warning against overriding it. The RFC treats that consistency as structural rather than advisory and defers relative ranking to the heuristic follow-up, so rank() is no longer virtual; re-opening it later is additive. It also scored inside the sort comparator, running a model O(n log n) times for an n-kernel catalog; each kernel is now scored once before sorting. Accept-implies-launchable, RFC 0017 section 8.6. The graph matcher bounded tensor rank to what the compile path accepts but not stride order, and the same function throws on a 4D order that is neither NCHW nor NHWC. A one-element tensor viewing into a larger buffer carries such an order, so the matcher accepted a graph the plan build then failed on. The matcher now asks the classifier directly rather than restating its rules, since restating them is how the rank half came to be imported without the layout half. Also: the cache key is a named struct rather than a pair, so the engine id and inventory generation RFC 0017 sections 8.1 and 8.6 require can be added without changing how the key is spelled at its use sites, with a comment recording why each is absent today. unsortedDefinitions and sortedDefinitions no longer take a device alongside a context that already carries one, which removed an invariant five call sites were maintaining by convention. Testing: - Two tests for the matcher memoization, verified by mutation: both fail when the memo is removed and pass with it. - Tests for the undeclared-metadata-field and undeclared-knob rejections, and for the unsupported stride order, which reproduced the accept-then-fail before the fix. - 132 plugin_sdk, 461 provider unit, 14 integration tests pass, all exiting 0. - Flag off unchanged: 428 unit pass. - pre-commit passes. JIRA ID : ALMIOPEN-2402 --- .../packs/PointwiseAddDispatchHandler.cpp | 3 +- .../packs/PointwiseAddDispatchHandler.hpp | 3 +- .../packs/PointwiseAddMatchers.cpp | 34 ++- .../packs/PointwiseAddTestGraphs.hpp | 8 +- .../packs/TestPointwiseAddDispatchHandler.cpp | 20 +- .../packs/TestPointwiseAddMatchers.cpp | 18 ++ .../ingestor/Descriptors.hpp | 16 +- .../ingestor/GenericEngine.hpp | 22 ++ .../ingestor/GenericPlan.hpp | 2 +- .../ingestor/GenericPlanBuilder.hpp | 28 +- .../ingestor/IKernelDispatchHandler.hpp | 13 +- .../ingestor/IKernelHeuristic.hpp | 88 ++++--- .../ingestor/KernelIngestorStateManager.hpp | 97 +++++-- .../ingestor/MatchContext.hpp | 32 ++- .../tests/KernelIngestorTestFixtures.hpp | 20 ++ .../plugin_sdk/tests/TestKernelIngestor.cpp | 241 ++++++++++++++++-- .../tests/TestKernelIngestorConcurrency.cpp | 10 +- 17 files changed, 524 insertions(+), 131 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp index a9ca292476fa..7138132faeb6 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp @@ -97,7 +97,8 @@ const data_objects::TensorAttributes& firstInput(const MatchContext& context, } // namespace -size_t PointwiseAddDispatchHandler::workspaceBytes(const KernelDefinition& kernel) const +size_t PointwiseAddDispatchHandler::workspaceBytes(const MatchContext& /*context*/, + const KernelDefinition& kernel) const { return kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD)) == LARGE_BLOCK_SIZE ? LARGE_BLOCK_WORKSPACE_BYTES diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp index 9719e71f24a6..5ae64d2ce417 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp @@ -54,7 +54,8 @@ class PointwiseAddDispatchHandler * indistinguishable from not being wired up at all. */ size_t - workspaceBytes(const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel) const override; + workspaceBytes(const hipdnn_plugin_sdk::ingestor::MatchContext& context, + const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel) const override; std::unique_ptr prepare(const hipdnn_plugin_sdk::ingestor::MatchContext& context, diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index fafda0a9e9a9..ed561d9df10d 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -14,6 +14,7 @@ #include #include +#include "core/Utils.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" namespace hip_kernel_provider::kernel_ingestor_engine @@ -42,8 +43,30 @@ const data_objects::TensorAttributes* findTensor(const MatchContext& context, in constexpr uint32_t MIN_SUPPORTED_RANK = 4; constexpr uint32_t MAX_SUPPORTED_RANK = 5; -/// True when the tensor is a supported rank holding exactly one element -- the whole of -/// this pack's supported problem space. +/// True when the tensor's stride order is one the dispatch path can classify. +/// +/// The same constraint as the rank bound above, from the same function: compile options +/// derive a layout from the strides and throw on any order that is neither channel-first +/// nor channel-last. A one-element tensor can still carry an unclassifiable order when it +/// views into a larger buffer, so element count does not imply a usable layout. +bool hasSupportedLayout(const data_objects::TensorAttributes& tensor) +{ + try + { + static_cast(core::utils::isChannelLastLayout(&tensor)); + return true; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException&) + { + // Asking the classifier directly keeps this gate and the dispatch path on one + // definition of "supported layout"; restating its rules here would let the two + // drift, which is exactly how the rank half of this constraint got missed. + return false; + } +} + +/// True when the tensor is a supported rank and layout holding exactly one element -- +/// the whole of this pack's supported problem space. bool isSingleElement(const data_objects::TensorAttributes& tensor) { const auto* dims = tensor.dims(); @@ -57,7 +80,12 @@ bool isSingleElement(const data_objects::TensorAttributes& tensor) { elements *= dim; } - return elements == 1; + if(elements != 1) + { + return false; + } + + return hasSupportedLayout(tensor); } /// The graph's element type, taken from the first input. The matcher below requires diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp index 4d7d8c2de557..0016a599cfff 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp @@ -37,12 +37,16 @@ inline flatbuffers::FlatBufferBuilder const std::vector& dims = {1, 1, 1, 1}, std::optional graphId = std::nullopt, - bool binary = true) + bool binary = true, + const std::optional>& explicitStrides = std::nullopt) { namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; flatbuffers::FlatBufferBuilder builder; - const std::vector strides(dims.size(), 1); + // Packed strides by default; an explicit set lets a test describe a view into a + // larger buffer, whose stride order the layout classifier may not accept. + const std::vector strides + = explicitStrides.has_value() ? *explicitStrides : std::vector(dims.size(), 1); std::vector> tensors; for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID}) diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index 28936110ab5a..40e62e43ea78 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -143,24 +143,30 @@ class AddBuffers TEST(TestPointwiseAddDispatch, ReportsWorkspaceFromKernelMetadata) { + const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); // The two surviving kernels report different requirements, so the engine's // "maximum across survivors" has something to actually maximize over. - EXPECT_EQ(handler.workspaceBytes(makeKernel(64, "FLOAT")), 0U); - EXPECT_EQ(handler.workspaceBytes(makeKernel(256, "FLOAT")), 1024U); + EXPECT_EQ(handler.workspaceBytes(fixture.context(), makeKernel(64, "FLOAT")), 0U); + EXPECT_EQ(handler.workspaceBytes(fixture.context(), makeKernel(256, "FLOAT")), 1024U); } -TEST(TestPointwiseAddDispatch, WorkspaceDoesNotDependOnTheGraph) +TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) { + const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - // Workspace is asked before a kernel is chosen and before any plan exists, so it - // must be answerable from the kernel alone. - EXPECT_EQ(handler.workspaceBytes(makeKernel(256, "FLOAT")), - handler.workspaceBytes(makeKernel(256, "HALF"))); + // The query is answered per kernel, before selection and before any plan exists, so + // the answer must not depend on which other kernels are in the catalog. Asking twice + // for the same kernel, either side of a different one, gives the same number. + const auto first = handler.workspaceBytes(fixture.context(), makeKernel(256, "FLOAT")); + static_cast(handler.workspaceBytes(fixture.context(), makeKernel(64, "FLOAT"))); + const auto second = handler.workspaceBytes(fixture.context(), makeKernel(256, "FLOAT")); + + EXPECT_EQ(first, second); } // --------------------------------------------------------------------------- diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index 4ae3eb7e75bf..00482d557e09 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -135,6 +135,24 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); } +TEST(TestPointwiseAddGraphMatcher, RefusesAStrideOrderTheDispatchPathCannotClassify) +{ + // Same class as the rank refusal above, and the same root cause: the provider's + // compile options derive a layout from the tensor's strides and throw on any 4D + // order that is neither NCHW nor NHWC. A 1-element tensor viewing into a larger + // buffer can carry such an order, so without this the matcher accepts a graph the + // plan build then fails on, which RFC 0017 section 8.6 forbids. + const GraphFixture fixture( + buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::vector{8, 2, 4, 1})); + + EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); +} + TEST(TestPointwiseAddGraphMatcher, RefusesAUnaryPointwise) { const GraphFixture fixture(buildPointwiseGraph(data_objects::PointwiseMode::ADD, diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 5e6799a059b6..050d63267845 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -119,10 +119,11 @@ struct MetadataField MetadataType type = MetadataType::INT; /// The value a kernel that omits this field is taken to have supplied. /// - /// Optional, because a field can be mandatory: RFC 0017 §4 marks a UKD's metadata - /// entries `optional` individually, so a field with no default is one every kernel - /// in the engine must state for itself, and omitting it is a load error rather than - /// a silent fallback. + /// Optional, because a field can be mandatory. RFC 0017 §4's KMD example carries + /// `optional` and `default` as separate attributes on the field; those collapse here, + /// since a field that is mandatory has no default to fall back on. A kernel omitting + /// such a field is a load error rather than a silent fallback to a catalog key its + /// author never wrote. std::optional defaultValue; }; @@ -170,6 +171,13 @@ struct EngineDescriptor /// Which inputs a matcher reads, which decides how often it runs and what its failure /// prunes (RFC 0017 §5, "applicability is a cheap, shared-matcher pass"). +/// +/// Authored per matcher here, which is provisional. RFC 0017 §5 puts the split inside a +/// single matcher's criteria tree rather than across matchers: `conv.tile_fit` mixes +/// graph-bound dims and `$kernel.*` in one `and`, and the `$kernel.*` clauses are the +/// ones re-evaluated per candidate. Once the criteria language lands this becomes a +/// derived property, computed from whether a matcher's expression references `$kernel.*`, +/// and a matcher with mixed clauses is evaluated per clause rather than classified whole. enum class MatchScope { /// Reads only graph and device facts, so it runs once per (graph, device) and its diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index 14cde71073ab..9c8b5244136e 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -5,9 +5,12 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +#include #include #include #include +#include +#include #include #include @@ -56,6 +59,12 @@ class GenericEngine : public IEngine * @param deviceResolver Answers which device each call is for, from the handle that * carries it. Held by reference; owned by the provider, which must keep it * alive for the engine's lifetime. + * + * @throws std::invalid_argument if a knob names no field in the engine's metadata + * schema. RFC 0017 §4 makes that a load error: a knob is only a name, with + * the field supplying its type, its default and its legal values, so a knob + * matching no field can never be reported or honoured. Caught here because + * this is the one place holding both the UED and its KMD. */ GenericEngine(EngineDescriptor engine, std::shared_ptr> stateManager, @@ -65,6 +74,19 @@ class GenericEngine : public IEngine , _id(hipdnn_data_sdk::utilities::engineNameToId(_engine.name)) , _planBuilder(_engine, _stateManager, deviceResolver) { + const auto& fields = _stateManager->metadataSchema().fields; + for(const auto& knob : _engine.knobs) + { + const auto declared + = std::any_of(fields.begin(), fields.end(), [&knob](const MetadataField& field) { + return field.name == knob; + }); + if(!declared) + { + throw std::invalid_argument("engine '" + _engine.name + "' exposes knob '" + knob + + "', which its metadata schema does not declare"); + } + } } /// The descriptor this engine was built from, for diagnostics and for a caller that diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp index de85ff22e6a4..b0404da1be8f 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -47,7 +47,7 @@ class GenericPlan : public IPlan */ GenericPlan(KernelDispatcher dispatcher, const MatchContext& context) : _dispatcher(std::move(dispatcher)) - , _workspaceBytes(_dispatcher.handler->workspaceBytes(_dispatcher.kernel)) + , _workspaceBytes(_dispatcher.handler->workspaceBytes(context, _dispatcher.kernel)) , _prepared(_dispatcher.handler->prepare(context, _dispatcher.kernel)) { if(_prepared == nullptr) diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index b241aac35755..8aca4b34df9a 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -64,8 +64,7 @@ class GenericPlanBuilder : public IPlanBuilder /// a membership test needs no order, so the heuristic is never run here. bool isApplicable(const THandle& handle, const IGraph& opGraph) const override { - const auto deviceId = _deviceResolver.deviceId(handle); - return !_stateManager->unsortedDefinitions(deviceId, contextFor(deviceId, opGraph)).empty(); + return !_stateManager->unsortedDefinitions(contextFor(handle, opGraph)).empty(); } /** @@ -79,13 +78,16 @@ class GenericPlanBuilder : public IPlanBuilder const IGraph& opGraph, const TSettings& /*executionSettings*/) const override { - const auto deviceId = _deviceResolver.deviceId(handle); - const auto context = contextFor(deviceId, opGraph); + const auto context = contextFor(handle, opGraph); size_t maxBytes = 0; - for(const auto& kernel : _stateManager->sortedDefinitions(deviceId, context)) + // Unsorted deliberately: a maximum over the survivors is order-independent, and + // this call arrives for every candidate engine. Ranking here would load and run + // each engine's heuristic to compute a number that does not depend on the order, + // which is the cost the lazy-ranking split exists to avoid. + for(const auto& kernel : _stateManager->unsortedDefinitions(context)) { const auto dispatcher = _stateManager->getDispatchDetails(kernel); - maxBytes = std::max(maxBytes, dispatcher.handler->workspaceBytes(kernel)); + maxBytes = std::max(maxBytes, dispatcher.handler->workspaceBytes(context, kernel)); } return maxBytes; } @@ -113,9 +115,8 @@ class GenericPlanBuilder : public IPlanBuilder const IEngineConfig& /*engineConfig*/, TContext& executionContext) const override { - const auto deviceId = _deviceResolver.deviceId(handle); - const auto context = contextFor(deviceId, opGraph); - const auto ranked = _stateManager->sortedDefinitions(deviceId, context); + const auto context = contextFor(handle, opGraph); + const auto ranked = _stateManager->sortedDefinitions(context); if(ranked.empty()) { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, @@ -140,9 +141,7 @@ class GenericPlanBuilder : public IPlanBuilder { using namespace hipdnn_flatbuffers_sdk::data_objects; - const auto deviceId = _deviceResolver.deviceId(handle); - const auto ranked - = _stateManager->sortedDefinitions(deviceId, contextFor(deviceId, opGraph)); + const auto ranked = _stateManager->sortedDefinitions(contextFor(handle, opGraph)); std::vector knobs; if(ranked.empty()) { @@ -197,8 +196,11 @@ class GenericPlanBuilder : public IPlanBuilder } private: - MatchContext contextFor(DeviceId deviceId, const IGraph& opGraph) const + /// Binds the graph and the device this call is for. Resolving the device here, once + /// per call, is what keeps the cache key and the matchers reading the same one. + MatchContext contextFor(const THandle& handle, const IGraph& opGraph) const { + const auto deviceId = _deviceResolver.deviceId(handle); return MatchContext{opGraph, deviceId, _deviceResolver.deviceProperties(deviceId)}; } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp index 1ae7fd86702b..b7351319b308 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp @@ -64,11 +64,18 @@ class IKernelDispatchHandler * @brief Global scratch this kernel requires, in bytes. * * Answered per kernel, before selection, so the engine can report the maximum across - * every kernel the caller's knobs leave in the catalog. Taking only the kernel keeps - * the answer independent of which other kernels happen to be present. + * every kernel the caller's knobs leave in the catalog. The answer stays independent + * of which other kernels are present: the catalog is never passed in. + * + * Takes the bound token state as well as the kernel because a workspace requirement + * is a formula over graph dimensions as much as over kernel metadata. RFC 0017 §6's + * worked example is `batch * num_heads * seqlen_q * 4`, which a signature over the + * kernel alone cannot express, and the declarative evaluator that eventually replaces + * a native handler has to. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) - virtual size_t workspaceBytes(const KernelDefinition& kernel) const = 0; + virtual size_t workspaceBytes(const MatchContext& context, const KernelDefinition& kernel) const + = 0; /** * @brief Resolves everything @p kernel's launch needs from the bound token state. diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp index 52a5ef48d496..770399696b0a 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp @@ -22,23 +22,20 @@ namespace hipdnn_plugin_sdk::ingestor * One level below hipDNN's engine-selection heuristic, which decides *which engine* * handles a graph and is untouched by this system. * - * Two levels of interface, deliberately: + * An implementation supplies `score()` and nothing else. It ranks one kernel at a time + * and never sees the catalog, so a kernel's score cannot depend on which other kernels + * are present. That is what makes filtering and ranking commute: a knob-filtered subset + * ranks exactly as it did in the whole catalog, so the kernel a knob setting selects is + * the kernel the reported default named. The failure it prevents, a reported default + * that a knob setting then contradicts, is silent and surfaces to a user rather than to + * the author who caused it. * - * - `score()` ranks one kernel at a time and never sees the catalog. Because a kernel's - * score cannot depend on which other kernels are present, filtering and ranking - * commute: a knob-filtered subset ranks exactly as it did in the whole catalog, so the - * kernel a knob setting selects is the kernel the reported default named. That failure - * — a reported default a knob setting then contradicts — is silent and surfaces to a - * user rather than to the author who caused it, which is why the property is - * structural here rather than advisory. - * - * - `rank()` is virtual and receives the whole catalog. A future selector that must - * reason over the candidate set as a whole (spreading a choice across a batch, or - * ranking by a criterion only meaningful relatively) overrides it. - * - * **Overriding `rank()` forfeits the consistency guarantee above.** An override must - * state what a knob-filtered query then means for it; the default implementation is the - * shape every day-one descriptor-backed heuristic should use. + * RFC 0017 §9.2 makes that structural rather than advisory: the scorer interface "takes + * one kernel at a time and is never handed the catalog, so a scorer that ranks relative + * to its peers cannot be written against it". `rank()` is therefore non-virtual. A + * selector that must reason over the candidate set as a whole is admitted by the + * heuristic follow-up RFC, which owns deciding what a knob-filtered query means once + * ranking is no longer per kernel; re-opening this for override then is additive. */ class IKernelHeuristic { @@ -51,34 +48,45 @@ class IKernelHeuristic /** * @brief Orders @p catalog best-first. * - * The default implementation scores each entry independently and sorts descending, - * breaking ties deterministically: first on the kernel's explicit `priority`, then on - * its descriptor id compared as bytes. That id order carries no meaning — it is - * chosen only for being stable across runs, load orders, and machines, so two - * processes given the same catalog always choose the same kernel. + * Scores each entry independently and sorts descending, breaking ties + * deterministically: first on the kernel's explicit `priority`, then on its + * descriptor id compared as bytes. That id order carries no meaning — it is chosen + * only for being stable across runs, load orders, and machines, so two processes + * given the same catalog always choose the same kernel. + * + * Each kernel is scored exactly once, before sorting, rather than inside the + * comparator. A scorer is a model evaluation in the data-driven form, so scoring + * from the comparator would run inference O(n log n) times for an n-kernel catalog + * instead of n, and would also let a scorer that is not perfectly deterministic + * produce an inconsistent ordering. */ - virtual std::vector rank(const Catalog& catalog, - const MatchContext& context) const + std::vector rank(const Catalog& catalog, const MatchContext& context) const { - std::vector ranked = catalog.entries; + std::vector> scored; + scored.reserve(catalog.entries.size()); + for(const auto& entry : catalog.entries) + { + scored.emplace_back(score(entry, context), &entry); + } - std::stable_sort( - ranked.begin(), - ranked.end(), - [this, &context](const KernelDefinition& lhs, const KernelDefinition& rhs) { - const double lhsScore = score(lhs, context); - const double rhsScore = score(rhs, context); - if(lhsScore != rhsScore) - { - return lhsScore > rhsScore; - } - if(lhs.priority != rhs.priority) - { - return lhs.priority > rhs.priority; - } - return lhs.kernelId < rhs.kernelId; - }); + std::stable_sort(scored.begin(), scored.end(), [](const auto& lhs, const auto& rhs) { + if(lhs.first != rhs.first) + { + return lhs.first > rhs.first; + } + if(lhs.second->priority != rhs.second->priority) + { + return lhs.second->priority > rhs.second->priority; + } + return lhs.second->kernelId < rhs.second->kernelId; + }); + std::vector ranked; + ranked.reserve(scored.size()); + for(const auto& [_, entry] : scored) + { + ranked.push_back(*entry); + } return ranked; } }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index c572044c030e..16151932f489 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -120,7 +120,8 @@ class KernelIngestorStateManager } /** - * @brief Every kernel that applies to @p graph on @p deviceId, in no particular order. + * @brief Every kernel that applies to the graph and device @p context names, in no + * particular order. * * On a cache hit, returns the cached catalog. Otherwise runs the matchers in pruning * order — each pack's graph-scoped matchers first, since one failure disqualifies the @@ -131,22 +132,21 @@ class KernelIngestorStateManager * would alias unrelated graphs), so it is matched fresh every call. That costs time, * never correctness. */ - std::vector unsortedDefinitions(DeviceId deviceId, - const MatchContext& context) const + std::vector unsortedDefinitions(const MatchContext& context) const { - return catalogFor(deviceId, context).entries; + return catalogFor(context).entries; } /** - * @brief Every kernel that applies to @p graph on @p deviceId, best first. + * @brief Every kernel that applies to the graph and device @p context names, best + * first. * * Returns the cached order if there is one; otherwise ranks the catalog and caches * that order. If the catalog itself is missing, it is built first. */ - std::vector sortedDefinitions(DeviceId deviceId, - const MatchContext& context) const + std::vector sortedDefinitions(const MatchContext& context) const { - Catalog catalog = catalogFor(deviceId, context); + Catalog catalog = catalogFor(context); if(catalog.isSorted) { return catalog.entries; @@ -155,7 +155,7 @@ class KernelIngestorStateManager catalog.entries = _heuristic->rank(catalog, context); catalog.isSorted = true; - if(const auto key = cacheKey(deviceId, context); key.has_value()) + if(const auto key = cacheKey(context); key.has_value()) { _catalogCache.put(*key, catalog); } @@ -245,18 +245,23 @@ class KernelIngestorStateManager } } - /// A kernel's declared values plus the KMD's defaults for every field it omitted. - /// This completed tuple, not the descriptor id, is what identifies the kernel to - /// matching and ranking. + /// A kernel's declared values for the fields its engine's KMD declares, with the + /// KMD's defaults filled in for the ones it omitted. This completed tuple, not the + /// descriptor id, is what identifies the kernel to matching and ranking. + /// + /// Built from the schema's field list rather than from the kernel's own map, because + /// the KMD fields are the only per-kernel input selection has: a value the schema + /// never declared is unreadable to a matcher or a scorer, so admitting one into the + /// key would let two kernels that selection cannot tell apart both enter the catalog. MetadataValues completeMetadata(const KernelDescriptor& kernel) const { - MetadataValues complete = kernel.metadata; + MetadataValues complete; for(const auto& field : _schema.fields) { - auto it = complete.find(field.name); + auto it = kernel.metadata.find(field.name); - if(it == complete.end()) + if(it == kernel.metadata.end()) { // A field with no default is one every kernel must state for itself, so // an omission is an authoring error rather than a silent fallback: it @@ -280,24 +285,42 @@ class KernelIngestorStateManager + "' supplies metadata field '" + field.name + "' with a value of the wrong type"); } + complete.emplace(field.name, it->second); + } + + // A value the schema does not declare cannot be read by anything downstream, so + // it is almost always a misspelled field name. Left in place it would be doubly + // silent: the real field takes its default, and the stray value joins the key. + for(const auto& [name, value] : kernel.metadata) + { + if(complete.find(name) == complete.end()) + { + throw std::invalid_argument("kernel '" + toString(kernel.id) + + "' supplies metadata field '" + name + + "', which its engine's metadata schema does " + "not declare"); + } } return complete; } - std::optional cacheKey(DeviceId deviceId, const MatchContext& context) const + /// The device comes from the context rather than a separate argument: taking it + /// twice would let a caller cache one device's catalog under another device's key, + /// which is a wrong answer rather than a missed hit. + std::optional cacheKey(const MatchContext& context) const { const auto graphId = tryGetGraphId(context.graph); if(!graphId.has_value()) { return std::nullopt; } - return CatalogKey{*graphId, deviceId}; + return CatalogKey{*graphId, context.deviceId}; } - Catalog catalogFor(DeviceId deviceId, const MatchContext& context) const + Catalog catalogFor(const MatchContext& context) const { - const auto key = cacheKey(deviceId, context); + const auto key = cacheKey(context); if(key.has_value()) { if(auto cached = _catalogCache.get(*key); cached.has_value()) @@ -316,24 +339,32 @@ class KernelIngestorStateManager return catalog; } + /// A graph-scoped matcher's verdict for one (graph, device), keyed by matcher id. + /// + /// Matchers are shared by id across packs, so the same check would otherwise be + /// re-run once per pack that lists it. + using GraphMatcherMemo = std::unordered_map; + /** * @brief Runs every pack's matchers over @p context, cheapest and broadest first. * - * Graph-scoped matchers read only graph and device facts, so each runs once for the - * whole pack and one failure disqualifies every kernel in it without any per-kernel - * work. Only then do the kernel-scoped matchers run, once per surviving kernel. + * Graph-scoped matchers read only graph and device facts, so each is evaluated once + * per (graph, device) no matter how many packs list it, and one failure disqualifies + * every kernel in every pack that lists it without any per-kernel work. Only then do + * the kernel-scoped matchers run, once per surviving kernel. * - * That ordering is the point of splitting matchers by scope: it is what keeps - * applicability cheap for an engine whose packs do not apply, which is most engines - * for most graphs. + * That ordering, and that sharing, are the point of splitting matchers by scope: the + * broadly shared checks are what prune the candidate set fast, so an engine whose + * packs do not apply pays for each distinct check once rather than once per pack. */ Catalog buildCatalog(const MatchContext& context) const { Catalog catalog; + GraphMatcherMemo graphVerdicts; for(const auto& pack : _packs) { - if(!graphLevelMatchersPass(pack, context)) + if(!graphLevelMatchersPass(pack, context, graphVerdicts)) { continue; } @@ -358,7 +389,9 @@ class KernelIngestorStateManager return catalog; } - bool graphLevelMatchersPass(const KernelDescriptorPack& pack, const MatchContext& context) const + bool graphLevelMatchersPass(const KernelDescriptorPack& pack, + const MatchContext& context, + GraphMatcherMemo& graphVerdicts) const { for(const auto& matcherId : pack.matcherIds) { @@ -367,7 +400,15 @@ class KernelIngestorStateManager { continue; } - if(!GraphMatcherRegistry::resolve(matcher.matchSymbol)(context)) + + auto memo = graphVerdicts.find(matcherId); + if(memo == graphVerdicts.end()) + { + const bool passed = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context); + memo = graphVerdicts.emplace(matcherId, passed).first; + } + + if(!memo->second) { return false; } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index 1dff4b283f8f..d310b8942183 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -30,9 +30,33 @@ using GraphId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; /// still live, so keying on it would tie cached work to a lifetime unrelated to that /// work's validity. Two handles on one device share an entry; one handle rebound to /// another device does not. -using CatalogKey = std::pair; +/// +/// RFC 0017 §8.1 keys this on (engine id, graph id, device id) and §8.6 folds in a +/// descriptor-inventory generation. Neither is present, and both omissions are scoped +/// rather than free: +/// +/// - The engine id is implicit because one state manager serves one engine and owns this +/// cache, so no other engine's catalog can reach it. That holds only while a state +/// manager is never shared across engines. +/// - The generation retires cached verdicts when a discovery scan changes the pack +/// inventory. Nothing is loaded from a file yet, so the inventory is fixed at compile +/// time and the generation would be a constant. +/// +/// Both become real key components once engines are built from UED files and packs can +/// be dropped in. This is a struct rather than a std::pair so adding them then does not +/// change how the key is spelled at its use sites. +struct CatalogKey +{ + GraphId graphId; + DeviceId deviceId; + + bool operator==(const CatalogKey& other) const noexcept + { + return graphId == other.graphId && deviceId == other.deviceId; + } +}; -/// std::hash has no std::pair specialization, so the cache needs this explicitly. +/// A user-defined key has no std::hash, so the cache is given this explicitly. struct CatalogKeyHash { size_t operator()(const CatalogKey& key) const noexcept @@ -41,12 +65,12 @@ struct CatalogKeyHash // folding its bytes and mixing the device ordinal in is sufficient here; this // keys an in-process cache and is never persisted or exposed. size_t hash = 1469598103934665603ULL; - for(const uint8_t byte : key.first) + for(const uint8_t byte : key.graphId) { hash ^= static_cast(byte); hash *= 1099511628211ULL; } - hash ^= static_cast(key.second) + 0x9e3779b9ULL + (hash << 6U) + (hash >> 2U); + hash ^= static_cast(key.deviceId) + 0x9e3779b9ULL + (hash << 6U) + (hash >> 2U); return hash; } }; diff --git a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp index 7e4c82d9fb14..60f0c4c52009 100644 --- a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -172,6 +173,25 @@ class ScopedTestSymbols ScopedTestSymbols& operator=(const ScopedTestSymbols&) = delete; }; +/// A device resolver for tests that construct an engine. Reports one fixed device, since +/// nothing here exercises multi-device behavior. +class TestDeviceResolver : public IDeviceResolver +{ +public: + DeviceId deviceId(const int& /*handle*/) const override + { + return 0; + } + + const hipDeviceProp_t& deviceProperties(DeviceId /*deviceId*/) const override + { + return _properties; + } + +private: + hipDeviceProp_t _properties = testDeviceProperties(); +}; + /// A descriptor id from a short seed, so a fixture can name ids readably while the type /// stays the real 128-bit one. inline DescriptorId testId(uint8_t seed) diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp index 3ad25086de1d..8d2a1d5f6bde 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -365,7 +366,7 @@ TEST(TestIngestorStateManager, KernelLevelMatcherPrunesTheCatalog) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - const auto definitions = manager->unsortedDefinitions(0, context); + const auto definitions = manager->unsortedDefinitions(context); // Three kernels in the pack; the HALF one does not survive. ASSERT_EQ(definitions.size(), 2U); @@ -383,7 +384,7 @@ TEST(TestIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - EXPECT_TRUE(manager->unsortedDefinitions(0, context).empty()); + EXPECT_TRUE(manager->unsortedDefinitions(context).empty()); // The whole point of the graph/kernel split: a pack that cannot serve the graph // costs one matcher call, not one per kernel. EXPECT_EQ(counters().graphCalls, 1); @@ -398,15 +399,70 @@ TEST(TestIngestorStateManager, MatchesOncePerGraphAndDevice) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - manager->unsortedDefinitions(0, context); - manager->unsortedDefinitions(0, context); - manager->unsortedDefinitions(0, context); + manager->unsortedDefinitions(context); + manager->unsortedDefinitions(context); + manager->unsortedDefinitions(context); EXPECT_EQ(counters().graphCalls, 1); // One call per kernel in the pack, on the single uncached pass. EXPECT_EQ(counters().kernelCalls, 3); } +TEST(TestIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNotOncePerPack) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + + // Matchers are shared by id across packs, so the broadly shared checks are what prune + // the candidate set fast. Re-running one per pack that lists it would make the most + // shared check the most expensive, inverting the property the graph/kernel split + // exists to provide. + auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + second.id = testId(0x80); + // A distinct metadata tuple: the uniqueness rule is engine-wide, not per pack, so + // the second pack cannot repeat a block size the first already uses. + second.kernels = {makeKernel(testId(0x81), "second_pack_kernel", 512, "FLOAT")}; + + const StateManager manager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {first, second}, + std::make_shared(scoreByBlockSize)); + + const TestGraph graph(makeGraphId(20)); + const auto properties = testDeviceProperties(); + const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); + + // Two packs, one shared graph-scoped matcher: evaluated once. + EXPECT_EQ(counters().graphCalls, 1); + // Two FLOAT survivors from the first pack plus one from the second. + EXPECT_EQ(definitions.size(), 3U); +} + +TEST(TestIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackListingIt) +{ + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + + auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + second.id = testId(0x82); + second.kernels = {makeKernel(testId(0x83), "second_pack_kernel", 512, "FLOAT")}; + + const StateManager manager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {first, second}, + std::make_shared(scoreByBlockSize)); + + const TestGraph graph(makeGraphId(21)); + const auto properties = testDeviceProperties(); + + EXPECT_TRUE(manager.unsortedDefinitions(MatchContext{graph, 0, properties}).empty()); + // One failing evaluation disqualifies both packs, and no per-kernel work runs at all. + EXPECT_EQ(counters().graphCalls, 1); + EXPECT_EQ(counters().kernelCalls, 0); +} + TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); @@ -414,8 +470,8 @@ TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) const TestGraph graph(makeGraphId(4)); const auto properties = testDeviceProperties(); - manager->unsortedDefinitions(0, MatchContext{graph, 0, properties}); - manager->unsortedDefinitions(1, MatchContext{graph, 1, properties}); + manager->unsortedDefinitions(MatchContext{graph, 0, properties}); + manager->unsortedDefinitions(MatchContext{graph, 1, properties}); // The same graph on a different device is a different problem: a kernel applicable // on one device need not be applicable on another. @@ -432,8 +488,8 @@ TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - const auto first = manager->unsortedDefinitions(0, context); - const auto second = manager->unsortedDefinitions(0, context); + const auto first = manager->unsortedDefinitions(context); + const auto second = manager->unsortedDefinitions(context); // Correct both times; only the caching is lost. EXPECT_EQ(first.size(), 2U); @@ -449,9 +505,9 @@ TEST(TestIngestorStateManager, RematchesAfterCacheEviction) const TestGraph first(makeGraphId(5)); const TestGraph second(makeGraphId(6)); - manager->unsortedDefinitions(0, MatchContext{first, 0, properties}); - manager->unsortedDefinitions(0, MatchContext{second, 0, properties}); - manager->unsortedDefinitions(0, MatchContext{first, 0, properties}); + manager->unsortedDefinitions(MatchContext{first, 0, properties}); + manager->unsortedDefinitions(MatchContext{second, 0, properties}); + manager->unsortedDefinitions(MatchContext{first, 0, properties}); // Capacity 1, so the second graph evicted the first and it had to rematch. Eviction // costs work, never a wrong answer. @@ -466,7 +522,7 @@ TEST(TestIngestorStateManager, SortedDefinitionsAreRankedBestFirst) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - const auto sorted = manager->sortedDefinitions(0, context); + const auto sorted = manager->sortedDefinitions(context); ASSERT_EQ(sorted.size(), 2U); EXPECT_EQ(sorted.front().getIntMetadata(BLOCK_SIZE), 256); @@ -480,9 +536,9 @@ TEST(TestIngestorStateManager, RankingReusesTheAlreadyMatchedCatalog) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - manager->unsortedDefinitions(0, context); - manager->sortedDefinitions(0, context); - manager->sortedDefinitions(0, context); + manager->unsortedDefinitions(context); + manager->sortedDefinitions(context); + manager->sortedDefinitions(context); // Ranking is a read of the cached catalog, never a rematch. EXPECT_EQ(counters().graphCalls, 1); @@ -497,8 +553,7 @@ TEST(TestIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; - const auto values - = StateManager::knobValues(manager->sortedDefinitions(0, context), BLOCK_SIZE); + const auto values = StateManager::knobValues(manager->sortedDefinitions(context), BLOCK_SIZE); // The pruned HALF kernel also carried block_size 64, but it contributes no value: // a knob offers what the surviving catalog implements, not the schema's range. @@ -527,7 +582,7 @@ TEST(TestIngestorStateManager, CompletesAnOmittedFieldFromItsSchemaDefault) const TestGraph graph(makeGraphId(10)); const auto properties = testDeviceProperties(); - const auto definitions = manager.unsortedDefinitions(0, MatchContext{graph, 0, properties}); + const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); ASSERT_EQ(definitions.size(), 1U); EXPECT_EQ(definitions.front().getIntMetadata(BLOCK_SIZE), 64); @@ -557,6 +612,31 @@ TEST(TestIngestorStateManager, RejectsAKernelOmittingAFieldWithNoDefault) std::invalid_argument); } +TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldTheSchemaDoesNotDeclare) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + // A misspelled field name is the common case, and left in place it fails twice over: + // the real field silently takes its default, and the stray value joins the catalog + // key, where nothing downstream can read it. Two kernels differing only in such a + // value would both enter the catalog with selection unable to tell them apart. + KernelDescriptor undeclared; + undeclared.id = testId(0x74); + undeclared.name = "kernel_undeclared_field"; + undeclared.metadata = {{BLOCK_SIZE, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataValue{std::string{"FLOAT"}}}, + {"blocksize", MetadataValue{int64_t{128}}}}; + pack.kernels = {undeclared}; + + EXPECT_THROW(StateManager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {pack}, + std::make_shared(scoreByBlockSize)), + std::invalid_argument); +} + TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldOfTheWrongType) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); @@ -621,4 +701,127 @@ TEST(TestIngestorStateManager, RejectsAMissingHeuristic) EXPECT_THROW(StateManager(makeSchema(), {}, {}, {}, nullptr), std::invalid_argument); } +// --------------------------------------------------------------------------- +// GenericEngine: engine-level descriptor validation +// --------------------------------------------------------------------------- + +/// Minimal stand-ins for the provider types GenericEngine is parameterized on. Only the +/// members the engine actually touches are present, so a change that starts depending on +/// more of a real handle or context fails here rather than compiling silently. +struct StubHandle +{ + void storeEngineDetailsDetachedBuffer(const void* /*ptr*/, + std::unique_ptr buffer) + { + _buffers.push_back(std::move(buffer)); + } + +private: + std::vector> _buffers; +}; + +struct StubSettings +{ +}; + +struct StubContext +{ + void setExecutionSettings(const StubSettings& /*settings*/) {} + + void setPlan(std::unique_ptr> plan) + { + _plan = std::move(plan); + } + + bool hasPlan() const + { + return _plan != nullptr; + } + +private: + std::unique_ptr> _plan; +}; + +/// A device resolver over StubHandle, for the engine-level tests. +class StubDeviceResolver : public IDeviceResolver +{ +public: + DeviceId deviceId(const StubHandle& /*handle*/) const override + { + return 0; + } + + const hipDeviceProp_t& deviceProperties(DeviceId /*deviceId*/) const override + { + return _properties; + } + +private: + hipDeviceProp_t _properties = testDeviceProperties(); +}; + +using StubEngine = GenericEngine; + +/// The engine-level tests need a state manager over StubHandle, which the shared fixture +/// builds over int, so this mirrors it for the stub handle type. +std::unique_ptr> makeStubStateManager() +{ + MetadataSchema schema; + schema.id = SCHEMA_ID; + schema.name = "test schema"; + schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}; + + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "test pack"; + pack.matcherIds = {GRAPH_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT")}; + + return std::make_unique>( + std::move(schema), + std::vector{ + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}}, + std::vector{ + {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}, + std::vector{std::move(pack)}, + std::make_shared(&scoreByBlockSize)); +} + +EngineDescriptor makeEngineWithKnobs(std::vector knobs) +{ + EngineDescriptor engine; + engine.id = ENGINE_ID; + engine.name = "test:engine"; + engine.heuristicId = HEURISTIC_ID; + engine.metadataSchemaId = SCHEMA_ID; + engine.knobs = std::move(knobs); + return engine; +} + +TEST(TestIngestorGenericEngine, AcceptsAKnobNamingADeclaredMetadataField) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + + EXPECT_NO_THROW( + (StubEngine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver))); +} + +TEST(TestIngestorGenericEngine, RejectsAKnobNamingNoMetadataField) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + + // A knob is only a name: the field it points at supplies the type, the default and + // the legal values, so a knob matching no field can never be reported or honoured. + // Left unchecked it is silently dropped, which reads to a caller exactly like a knob + // the engine chose not to expose. + EXPECT_THROW( + (StubEngine(makeEngineWithKnobs({"no_such_field"}), makeStubStateManager(), resolver)), + std::invalid_argument); +} + } // namespace diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp index 26bef2d32c4a..fa497b736dba 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp @@ -128,7 +128,7 @@ TEST(TestIngestorStateManagerConcurrency, ServesOneGraphFromManyThreadsConsisten for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { const MatchContext context{graph, 0, properties}; - const auto definitions = manager->unsortedDefinitions(0, context); + const auto definitions = manager->unsortedDefinitions(context); ASSERT_EQ(definitions.size(), 2U); } }); @@ -147,7 +147,7 @@ TEST(TestIngestorStateManagerConcurrency, RanksConsistentlyFromManyThreads) for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { const MatchContext context{graph, 0, properties}; - const auto ranked = manager->sortedDefinitions(0, context); + const auto ranked = manager->sortedDefinitions(context); ASSERT_EQ(ranked.size(), 2U); EXPECT_EQ(ranked.front().getIntMetadata(std::string(BLOCK_SIZE)), 256); } @@ -168,7 +168,7 @@ TEST(TestIngestorStateManagerConcurrency, KeepsPerDeviceCatalogsDistinct) for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { const MatchContext context{graph, deviceId, properties}; - const auto definitions = manager->unsortedDefinitions(deviceId, context); + const auto definitions = manager->unsortedDefinitions(context); ASSERT_EQ(definitions.size(), 2U); } }); @@ -187,7 +187,7 @@ TEST(TestIngestorStateManagerConcurrency, ServesUncacheableGraphsConcurrently) for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { const MatchContext context{graph, 0, properties}; - ASSERT_EQ(manager->unsortedDefinitions(0, context).size(), 2U); + ASSERT_EQ(manager->unsortedDefinitions(context).size(), 2U); } }); } @@ -208,7 +208,7 @@ TEST(TestIngestorStateManagerConcurrency, EvictsUnderConcurrentDistinctGraphs) // Eviction costs a rematch and never a wrong answer, so the result is the // same whether this call hit the cache or rebuilt the catalog. - ASSERT_EQ(manager->unsortedDefinitions(0, context).size(), 2U); + ASSERT_EQ(manager->unsortedDefinitions(context).size(), 2U); } }); } From f1a3d6da0132390e7f857bd9dbc32158120637ff Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 21:42:08 -0400 Subject: [PATCH 20/60] feat(hipdnn): defer heuristic loading and report the UED's behavior notes (ALMIOPEN-2402) Follow-ups from the RFC 0017 review discussion. The heuristic descriptor can be built eagerly; what it selects with cannot. NativeKernelHeuristic took an already-resolved function pointer, so the symbol was resolved when the state manager was assembled, which happens at container construction before any graph is seen. It now holds the symbol name and resolves on the first score(), matching RFC 0017 section 8.1 ("the heuristic is named but not loaded; nothing ranks yet") and section 3 ("a heuristic model is not read until something needs the catalog ranked"). Placing the deferral inside the adapter rather than one level up is deliberate: only the adapter knows what loading costs it, and a LightGBM adapter would read its artifact in the same place. The object stays constructible before any graph is seen, which is what lets the engine own it outright rather than threading a factory through the state manager. The UED gains behaviorNotes and numericalNotes, and getDetails reports the former. EngineDetails already transports behavior notes and every hand-written engine populates them, so passing nothing was reporting a descriptor-backed engine as having none, which is a claim rather than an omission. The pointwise pack declares RUNTIME_COMPILATION, which is true of it: its dispatch handler compiles through hiprtc at plan build. A pack whose kernels ship prebuilt in a kpack would not declare it, so the note belongs to the pack rather than to the ingestor. numericalNotes is carried but not reported, because EngineDetails has no field for it; recorded here so a descriptor can state them and the gap sits in the schema rather than in this type. Both notes are int32 rather than a typed enum, matching the transport, which is int32 so a newer descriptor can carry a value an older backend does not know without truncating it. Testing: - A test that constructing a heuristic against an unregistered symbol succeeds and only scoring fails, pinning the deferral. - 133 plugin_sdk, 461 provider unit, 14 integration tests pass, all exiting 0. - pre-commit passes. JIRA ID : ALMIOPEN-2402 --- .../packs/PointwiseAddPack.cpp | 7 +- .../ingestor/Descriptors.hpp | 17 ++++ .../ingestor/GenericEngine.hpp | 8 +- .../ingestor/IKernelHeuristic.hpp | 36 +++++++-- .../tests/KernelIngestorTestFixtures.hpp | 2 +- .../plugin_sdk/tests/TestKernelIngestor.cpp | 77 +++++++++++++++---- 6 files changed, 120 insertions(+), 27 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp index 8bc862b27d4d..4a2c3730049a 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -78,6 +79,10 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() // block_size is exposed to the caller; dtype is not, because it is pinned by the // graph rather than chosen. Exposing it would offer a choice nothing can serve. set.engine.knobs = {std::string(BLOCK_SIZE_FIELD)}; + // True of this engine today: the dispatch handler compiles its kernel through hiprtc + // when the plan is built. A pack whose kernels ship prebuilt in a kpack would not + // declare this, so it is a property of the pack rather than of the ingestor. + set.engine.behaviorNotes = {HIPDNN_BEHAVIOR_NOTE_RUNTIME_COMPILATION}; set.matchers = { {GRAPH_MATCHER_ID, @@ -133,7 +138,7 @@ std::shared_ptr> makePointwiseAddStateManager std::move(set.matchers), std::move(set.dispatches), std::move(set.packs), - std::make_shared(ScoreRegistry::resolve(std::string(SCORE_SYMBOL)))); + std::make_shared(std::string(SCORE_SYMBOL))); } } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 050d63267845..d7ac39e8ffa2 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -167,6 +167,23 @@ struct EngineDescriptor /// KMD field names this engine exposes for the caller to control. A name no KMD /// field matches is a load error. std::vector knobs; + /// Advisory execution behavior this engine's kernels exhibit, as + /// `hipdnnBackendBehaviorNote_t` values (BehaviorNote.h). Reported to the caller + /// through EngineDetails like any other engine's, so a descriptor-backed engine is + /// not silently noteless: the transport already carries these and every hand-written + /// engine populates them. + /// + /// Held as int32 rather than a typed enum because the transport is deliberately + /// int32, so a newer descriptor can carry a note value an older backend does not + /// know without truncating it. + std::vector behaviorNotes; + /// Numerical behavior this engine's kernels exhibit, from the same vocabulary RFC + /// 0017 §4 gives the UED (`tensor_core`, `reduced_precision_reduction`, and so on). + /// + /// Carried here but not yet reported: EngineDetails has no numerical-notes field, so + /// unlike behaviorNotes there is nothing to transport these through. Recorded so a + /// descriptor can state them and the gap is in the schema rather than in this type. + std::vector numericalNotes; }; /// Which inputs a matcher reads, which decides how often it runs and what its failure diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index 9c8b5244136e..1228d10352eb 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -126,8 +126,12 @@ class GenericEngine : public IEngine } auto knobs = builder.CreateVector(knobOffsets); - auto engineDetails - = hipdnn_flatbuffers_sdk::data_objects::CreateEngineDetails(builder, _id, knobs); + // The UED's notes reach the caller the same way every other engine's do. Passing + // nothing here would report a descriptor-backed engine as having no behavior + // notes at all, which is a claim rather than an omission. + auto behaviorNotes = builder.CreateVector(_engine.behaviorNotes); + auto engineDetails = hipdnn_flatbuffers_sdk::data_objects::CreateEngineDetails( + builder, _id, knobs, behaviorNotes); builder.Finish(engineDetails); auto detachedBuffer = std::make_unique(builder.Release()); diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp index 770399696b0a..4948b858c598 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp @@ -6,6 +6,9 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR #include +#include +#include +#include #include #include @@ -94,26 +97,45 @@ class IKernelHeuristic /** * @brief An IKernelHeuristic whose score() is a native function resolved by symbol. * - * The UHD escape hatch: the descriptor names a symbol, this resolves it. The - * data-driven form loads a model artifact and assembles its feature vector from the - * bound token state instead — the UHD follow-up RFC — and slots in here as another - * IKernelHeuristic. + * The UHD escape hatch: the descriptor names a symbol, this resolves it. The data-driven + * form loads a model artifact and assembles its feature vector from the bound token state + * instead (the UHD follow-up RFC), and slots in here as another IKernelHeuristic. + * + * **Constructing a heuristic must stay cheap; whatever it selects with loads on first + * use.** RFC 0017 §8.1 admits the heuristic at applicability only as a name: "The + * heuristic is named but **not** loaded; nothing ranks yet", and §3 generalizes it, + * "a heuristic model is not read until something needs the catalog ranked". An engine + * whose matchers reject a graph must never pay for its selector. + * + * That contract lives here rather than one level up, because only the adapter knows what + * "loading" costs it. This one holds a symbol name and resolves it on first score(); a + * LightGBM adapter would hold an artifact path and read the model there. Either way the + * object is constructible before any graph is seen, which is what lets the engine own it + * outright instead of threading a factory through the state manager. */ class NativeKernelHeuristic : public IKernelHeuristic { public: - explicit NativeKernelHeuristic(ScoreFn scoreFn) - : _scoreFn(scoreFn) + /// @param scoreSymbol Resolved from the registry on first use, not here, so + /// constructing this costs nothing an engine that never ranks would pay. + explicit NativeKernelHeuristic(std::string scoreSymbol) + : _scoreSymbol(std::move(scoreSymbol)) { } double score(const KernelDefinition& kernel, const MatchContext& context) const override { + std::call_once(_resolved, [this]() { _scoreFn = ScoreRegistry::resolve(_scoreSymbol); }); return _scoreFn(kernel, context); } private: - ScoreFn _scoreFn; + std::string _scoreSymbol; + /// Resolved once, on the first call that needs an order. Mutable because score() is + /// logically const: resolving is an implementation detail of answering, and a plan + /// may rank from several threads at once. + mutable std::once_flag _resolved; + mutable ScoreFn _scoreFn = nullptr; }; } // namespace hipdnn_plugin_sdk::ingestor diff --git a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp index 60f0c4c52009..a93652ee5966 100644 --- a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp @@ -270,7 +270,7 @@ inline std::unique_ptr> std::move(matchers), std::move(dispatches), std::vector{std::move(pack)}, - std::make_shared(&scoreByBlockSize), + std::make_shared(SCORE_SYMBOL), cacheCapacity); } diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp index 8d2a1d5f6bde..aae1f872401b 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp @@ -78,11 +78,32 @@ bool countingFloatKernels(const MatchContext& context, const KernelDefinition& k } /// Every kernel scores the same, so ranking falls through to the tie-break. +constexpr const char* CONSTANT_SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.constant_score"; + double scoreConstant(const KernelDefinition& /*kernel*/, const MatchContext& /*context*/) { return 1.0; } +/// Registers the constant scorer for a test's duration. The heuristic resolves its +/// symbol on first use, so the registration has to outlive the ranking call. +class ScopedConstantScore +{ +public: + ScopedConstantScore() + { + ScoreRegistry::registerSymbol(CONSTANT_SCORE_SYMBOL, &scoreConstant); + } + + ~ScopedConstantScore() + { + ScoreRegistry::unregisterSymbol(CONSTANT_SCORE_SYMBOL); + } + + ScopedConstantScore(const ScopedConstantScore&) = delete; + ScopedConstantScore& operator=(const ScopedConstantScore&) = delete; +}; + MetadataSchema makeSchema() { return {SCHEMA_ID, @@ -154,6 +175,9 @@ class ScopedSymbols { GraphMatcherRegistry::registerSymbol(_graphSymbol, graphFn); KernelMatcherRegistry::registerSymbol(_kernelSymbol, kernelFn); + // The heuristic resolves its symbol on the first call that needs an order, not + // at construction, so ranking tests need it registered for their duration too. + ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); counters().reset(); } @@ -161,6 +185,7 @@ class ScopedSymbols { GraphMatcherRegistry::unregisterSymbol(_graphSymbol); KernelMatcherRegistry::unregisterSymbol(_kernelSymbol); + ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); } ScopedSymbols(const ScopedSymbols&) = delete; @@ -174,7 +199,7 @@ class ScopedSymbols using TestHandle = int; using StateManager = KernelIngestorStateManager; -std::unique_ptr makeStateManager(ScoreFn scoreFn = scoreByBlockSize, +std::unique_ptr makeStateManager(const std::string& scoreSymbol = SCORE_SYMBOL, size_t cacheCapacity = StateManager::DEFAULT_CATALOG_CACHE_CAPACITY) { @@ -188,7 +213,7 @@ std::unique_ptr makeStateManager(ScoreFn scoreFn = scoreByBlockSiz std::move(matchers), std::move(dispatches), std::vector{makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID})}, - std::make_shared(scoreFn), + std::make_shared(scoreSymbol), cacheCapacity); } @@ -298,8 +323,26 @@ TEST(TestIngestorNativeRegistry, FailsClosedOnUnknownSymbol) // IKernelHeuristic // --------------------------------------------------------------------------- +TEST(TestIngestorHeuristic, ResolvesItsSymbolOnFirstUseRatherThanAtConstruction) +{ + // RFC 0017 section 8.1 admits the heuristic at applicability only as a name, and + // section 3 generalizes it: a heuristic model is not read until something needs the + // catalog ranked. An engine whose matchers reject a graph must never pay for its + // selector, so constructing one against a symbol that is not registered yet has to + // be legal, and only scoring may fail. + const NativeKernelHeuristic heuristic("hipdnn.kernel_ingestor.test.not_yet_registered"); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const auto kernel = makeDefinition(testId(0x01), 64); + + EXPECT_THROW(heuristic.score(kernel, context), std::runtime_error); +} + TEST(TestIngestorHeuristic, RanksHigherScoringKernelsFirst) { + const ScopedTestSymbols symbols; const TestGraph graph; const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; @@ -309,7 +352,7 @@ TEST(TestIngestorHeuristic, RanksHigherScoringKernelsFirst) const auto highId = testId(0x02); catalog.entries = {makeDefinition(lowId, 64), makeDefinition(highId, 256)}; - const NativeKernelHeuristic heuristic(scoreByBlockSize); + const NativeKernelHeuristic heuristic(SCORE_SYMBOL); const auto ranked = heuristic.rank(catalog, context); ASSERT_EQ(ranked.size(), 2U); @@ -318,6 +361,7 @@ TEST(TestIngestorHeuristic, RanksHigherScoringKernelsFirst) TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) { + const ScopedConstantScore constantScore; const TestGraph graph; const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; @@ -327,7 +371,7 @@ TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) const auto highPriorityId = testId(0x02); catalog.entries = {makeDefinition(lowPriorityId, 64, 1), makeDefinition(highPriorityId, 64, 5)}; - const NativeKernelHeuristic heuristic(scoreConstant); + const NativeKernelHeuristic heuristic(CONSTANT_SCORE_SYMBOL); const auto ranked = heuristic.rank(catalog, context); ASSERT_EQ(ranked.size(), 2U); @@ -336,6 +380,7 @@ TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) { + const ScopedConstantScore constantScore; const TestGraph graph; const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; @@ -347,7 +392,7 @@ TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) const auto higherId = testId(0x02); catalog.entries = {makeDefinition(higherId, 64), makeDefinition(lowerId, 64)}; - const NativeKernelHeuristic heuristic(scoreConstant); + const NativeKernelHeuristic heuristic(CONSTANT_SCORE_SYMBOL); const auto ranked = heuristic.rank(catalog, context); ASSERT_EQ(ranked.size(), 2U); @@ -427,7 +472,7 @@ TEST(TestIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNotOncePe makeTestMatchers(), makeTestDispatches(), {first, second}, - std::make_shared(scoreByBlockSize)); + std::make_shared(SCORE_SYMBOL)); const TestGraph graph(makeGraphId(20)); const auto properties = testDeviceProperties(); @@ -452,7 +497,7 @@ TEST(TestIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackListingI makeTestMatchers(), makeTestDispatches(), {first, second}, - std::make_shared(scoreByBlockSize)); + std::make_shared(SCORE_SYMBOL)); const TestGraph graph(makeGraphId(21)); const auto properties = testDeviceProperties(); @@ -500,7 +545,7 @@ TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) TEST(TestIngestorStateManager, RematchesAfterCacheEviction) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(scoreByBlockSize, 1); + const auto manager = makeStateManager(SCORE_SYMBOL, 1); const auto properties = testDeviceProperties(); const TestGraph first(makeGraphId(5)); const TestGraph second(makeGraphId(6)); @@ -578,7 +623,7 @@ TEST(TestIngestorStateManager, CompletesAnOmittedFieldFromItsSchemaDefault) makeTestMatchers(), makeTestDispatches(), {pack}, - std::make_shared(scoreByBlockSize)); + std::make_shared(SCORE_SYMBOL)); const TestGraph graph(makeGraphId(10)); const auto properties = testDeviceProperties(); @@ -608,7 +653,7 @@ TEST(TestIngestorStateManager, RejectsAKernelOmittingAFieldWithNoDefault) makeTestMatchers(), makeTestDispatches(), {pack}, - std::make_shared(scoreByBlockSize)), + std::make_shared(SCORE_SYMBOL)), std::invalid_argument); } @@ -633,7 +678,7 @@ TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldTheSchemaDoesNotDecl makeTestMatchers(), makeTestDispatches(), {pack}, - std::make_shared(scoreByBlockSize)), + std::make_shared(SCORE_SYMBOL)), std::invalid_argument); } @@ -655,7 +700,7 @@ TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldOfTheWrongType) makeTestMatchers(), makeTestDispatches(), {pack}, - std::make_shared(scoreByBlockSize)), + std::make_shared(SCORE_SYMBOL)), std::invalid_argument); } @@ -667,7 +712,7 @@ TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownMatcher) {}, makeTestDispatches(), {makePack({testId(0xFF)})}, - std::make_shared(scoreByBlockSize)), + std::make_shared(SCORE_SYMBOL)), std::invalid_argument); } @@ -677,7 +722,7 @@ TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownDispatchDescriptor) makeTestMatchers(), {}, {makePack({GRAPH_MATCHER_ID})}, - std::make_shared(scoreByBlockSize)), + std::make_shared(SCORE_SYMBOL)), std::invalid_argument); } @@ -692,7 +737,7 @@ TEST(TestIngestorStateManager, RejectsTwoKernelsSharingAMetadataTuple) makeTestMatchers(), makeTestDispatches(), {pack}, - std::make_shared(scoreByBlockSize)), + std::make_shared(SCORE_SYMBOL)), std::invalid_argument); } @@ -787,7 +832,7 @@ std::unique_ptr> makeStubStateManager() std::vector{ {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}, std::vector{std::move(pack)}, - std::make_shared(&scoreByBlockSize)); + std::make_shared(SCORE_SYMBOL)); } EngineDescriptor makeEngineWithKnobs(std::vector knobs) From b81a756624499e2864e297651fb1f7de777b40a2 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 10 Aug 2026 22:06:02 -0400 Subject: [PATCH 21/60] feat(hipdnn): cache the bound token state and scope the catalog to one engine (ALMIOPEN-2402) Two structural points from the RFC 0017 review discussion. Matching now carries its bindings forward. RFC 0017 section 8.5 gives matching double duty -- it decides a kernel applies and binds the fields the launch will use -- and section 8.1 keeps that state alongside the catalog so nothing is re-matched afterwards. The skeleton was not exempt from this in practice: the pointwise pack's prepare() called pointwiseAddBinding(), which re-ran the entire graph matcher on every plan build, behind a comment claiming it "re-reads the operand uids the matcher bound". A graph-scoped matcher now writes what it resolved into a BoundTokens map, the catalog carries it and caches it with the entries, and the dispatch handler reads it. The re-matching function is gone. Only graph-scoped matchers bind. Kernel-scoped ones run per candidate, so their findings would be per-kernel state with nowhere in the catalog to live; the tokens a launch needs describe the problem, not the kernel chosen for it. unsortedCatalog() and sortedCatalog() return the entries and the bindings together, replacing the pair of calls a plan build would otherwise make. That matters for a graph carrying no identity: it cannot be cached, so two accessors would mean two full matching passes on exactly the path with no cache to absorb the repeat. GenericEngine now owns its state manager (unique_ptr, not shared). The applicability cache is keyed on (graph, device) with no engine component, which is sound only because the cache cannot be reached from another engine. A shared_ptr permitted two engines built from different UEDs to be handed the same manager, and the first to populate the cache would answer for the second with no diagnostic. Ownership makes the engine component constant within any one cache, which is what section 8.1's key is for, at no per-entry cost. The plan builder holds a reference, since the engine owns it and outlives it. Testing: - The bound state reaches dispatch intact, and reading it after matching does not re-run the matcher. Mutation-verified: the second fails when the cache is made to always miss. - A rejected graph binds nothing, and dispatch refuses bindings that did not come from its matcher. - 135 plugin_sdk (2 new), 461 provider unit, 14 integration tests pass, all exiting 0. Flag off unchanged: 428 pass. - pre-commit passes. JIRA ID : ALMIOPEN-2402 --- .../KernelIngestorEngine.cpp | 2 +- .../packs/PointwiseAddDispatchHandler.cpp | 12 ++-- .../packs/PointwiseAddDispatchHandler.hpp | 2 + .../packs/PointwiseAddMatchers.cpp | 44 +++++++++----- .../packs/PointwiseAddMatchers.hpp | 5 +- .../packs/PointwiseAddPack.cpp | 4 +- .../packs/PointwiseAddPack.hpp | 2 +- .../packs/PointwiseAddSymbols.hpp | 15 +++++ .../packs/TestPointwiseAddDispatchHandler.cpp | 55 +++++++++++++----- .../packs/TestPointwiseAddMatchers.cpp | 46 ++++++++++----- .../hipdnn_plugin_sdk/ingestor/Catalog.hpp | 11 ++++ .../ingestor/GenericEngine.hpp | 9 ++- .../ingestor/GenericPlan.hpp | 12 +++- .../ingestor/GenericPlanBuilder.hpp | 29 ++++++---- .../ingestor/IKernelDispatchHandler.hpp | 13 ++++- .../ingestor/KernelIngestorStateManager.hpp | 57 +++++++++++++++++-- .../ingestor/MatchContext.hpp | 36 +++++++++--- .../ingestor/NativeRegistry.hpp | 12 +++- .../tests/KernelIngestorTestFixtures.hpp | 2 +- .../plugin_sdk/tests/TestKernelIngestor.cpp | 51 ++++++++++++++++- 20 files changed, 329 insertions(+), 90 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index a96f966bc0ed..e58c7e2f6995 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -53,7 +53,7 @@ const PointwiseAddDispatchHandler& dispatchHandler() * throw from a global constructor would escape during dlopen() and terminate the * process; from here it surfaces as a failed plugin creation the host can report. */ -std::shared_ptr> +std::unique_ptr> registerThenBuildStateManager() { static std::once_flag s_registered; diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp index 7138132faeb6..d5c8b8f0ed2c 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp @@ -98,6 +98,7 @@ const data_objects::TensorAttributes& firstInput(const MatchContext& context, } // namespace size_t PointwiseAddDispatchHandler::workspaceBytes(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, const KernelDefinition& kernel) const { return kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD)) == LARGE_BLOCK_SIZE @@ -105,13 +106,12 @@ size_t PointwiseAddDispatchHandler::workspaceBytes(const MatchContext& /*context : 0; } -std::unique_ptr - PointwiseAddDispatchHandler::prepare(const MatchContext& context, - const KernelDefinition& kernel) const +std::unique_ptr PointwiseAddDispatchHandler::prepare( + const MatchContext& context, const BoundTokens& bound, const KernelDefinition& kernel) const { - // Re-reads the operand uids the matcher bound, rather than re-deriving them here - // with a second notion of what this graph looks like. - const auto binding = pointwiseAddBinding(context); + // Reads the operand uids the matcher bound, rather than re-deriving them here with a + // second notion of what this graph looks like. + const auto binding = pointwiseAddBinding(bound); const auto blockSize = static_cast(kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD))); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp index 5ae64d2ce417..1318d4ab16b8 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp @@ -55,10 +55,12 @@ class PointwiseAddDispatchHandler */ size_t workspaceBytes(const hipdnn_plugin_sdk::ingestor::MatchContext& context, + const hipdnn_plugin_sdk::ingestor::BoundTokens& bound, const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel) const override; std::unique_ptr prepare(const hipdnn_plugin_sdk::ingestor::MatchContext& context, + const hipdnn_plugin_sdk::ingestor::BoundTokens& bound, const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel) const override; void launch(const Handle& handle, diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index ed561d9df10d..ca920cac2ac6 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -119,7 +119,7 @@ std::string dataTypeName(data_objects::DataType dataType) } // namespace -bool pointwiseAddGraphMatches(const MatchContext& context) +bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) { // Exactly one node: a prebuilt kernel serves one complete graph, so anything larger // is a different problem even if it contains this one. @@ -162,7 +162,18 @@ bool pointwiseAddGraphMatches(const MatchContext& context) // Uniform dtype across operands. Mixed-precision add is a different kernel, and // accepting it here would hand one binary operands it cannot read. - return inputA->data_type() == inputB->data_type() && inputA->data_type() == output->data_type(); + if(inputA->data_type() != inputB->data_type() || inputA->data_type() != output->data_type()) + { + return false; + } + + // Bind what the launch needs, now that the walk that found it has succeeded. The + // dispatch handler reads these back instead of re-walking the graph, so there is one + // notion of which tensor is which operand rather than two that can drift apart. + bound[std::string(INPUT_A_TOKEN)] = attributes.in_0_tensor_uid(); + bound[std::string(INPUT_B_TOKEN)] = attributes.in_1_tensor_uid().value(); + bound[std::string(OUTPUT_TOKEN)] = attributes.out_0_tensor_uid(); + return true; } bool pointwiseAddKernelMatches(const MatchContext& context, const KernelDefinition& kernel) @@ -187,20 +198,23 @@ double pointwiseAddScore(const KernelDefinition& kernel, const MatchContext& /*c return static_cast(kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD))); } -PointwiseAddBinding pointwiseAddBinding(const MatchContext& context) +PointwiseAddBinding pointwiseAddBinding(const BoundTokens& bound) { - if(!pointwiseAddGraphMatches(context)) - { - throw hipdnn_plugin_sdk::HipdnnPluginException( - HIPDNN_PLUGIN_STATUS_BAD_PARAM, "graph is not a single-node 1-element pointwise add"); - } - - const auto& attributes - = context.graph.getNodeWrapper(0).attributesAs(); - - return {attributes.in_0_tensor_uid(), - attributes.in_1_tensor_uid().value(), - attributes.out_0_tensor_uid()}; + // Every token was written by the graph matcher that admitted this graph, so a missing + // one means the catalog was built by a matcher other than ours -- a wiring error, not + // a graph this pack should decline. + const auto read = [&bound](std::string_view token) { + const auto it = bound.find(std::string(token)); + if(it == bound.end()) + { + throw hipdnn_plugin_sdk::HipdnnPluginException( + HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, + "pointwise add dispatch is missing bound token '" + std::string(token) + "'"); + } + return it->second; + }; + + return {read(INPUT_A_TOKEN), read(INPUT_B_TOKEN), read(OUTPUT_TOKEN)}; } void registerPointwiseAddMatchers() diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp index 17f342cfbd79..222663b1dfc3 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp @@ -18,7 +18,8 @@ namespace hip_kernel_provider::kernel_ingestor_engine * Reads only graph facts, so the ingestor evaluates it once per (graph, device) and one * failure disqualifies every kernel in the pack without any per-kernel work. */ -bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context); +bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context, + hipdnn_plugin_sdk::ingestor::BoundTokens& bound); /** * @brief Kernel-scoped applicability: does this kernel's dtype match the graph's? @@ -56,7 +57,7 @@ struct PointwiseAddBinding * * @throws HipdnnPluginException if the graph is not one this matcher accepts. */ -PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::MatchContext& context); +PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::BoundTokens& bound); /// @brief Registers this pack's matchers and scorer under their symbol names. /// diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp index 4a2c3730049a..10b838603b62 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp @@ -129,11 +129,11 @@ int64_t pointwiseAddEngineId() return hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); } -std::shared_ptr> makePointwiseAddStateManager() +std::unique_ptr> makePointwiseAddStateManager() { auto set = buildPointwiseAddDescriptorSet(); - return std::make_shared>( + return std::make_unique>( std::move(set.schema), std::move(set.matchers), std::move(set.dispatches), diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp index fd81e5300465..9416cfefb879 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp @@ -72,7 +72,7 @@ int64_t pointwiseAddEngineId(); /// Carries only what selection needs: the KMD, the matchers and dispatch descriptors the /// packs reference, the packs themselves, and the resolved heuristic. The UED is not /// among them, because a UED is 1:1 with a hipDNN engine and is owned by the engine. -std::shared_ptr> +std::unique_ptr> makePointwiseAddStateManager(); } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp index 96a059d474d2..afad1691f1b8 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp @@ -53,6 +53,21 @@ inline constexpr std::string_view ENGINE_NAME = "hipkernel:PointwiseAdd"; inline constexpr std::string_view BLOCK_SIZE_FIELD = "block_size"; inline constexpr std::string_view DTYPE_FIELD = "dtype"; +/** + * @brief The tokens this pack's graph matcher binds for its dispatch handler to read. + * + * Written once here for the same reason the symbol names are: the matcher writes these + * and the dispatch handler reads them, and a typo on either side would otherwise be a + * runtime lookup failure rather than a compile error. + * + * These are the native stand-in for the `$`-prefixed names a descriptor's dispatch + * formulas would reference (RFC 0017 §5). Once the expression language lands, a UMD + * declares these names in its patterns and a UDD reads them by the same names. + */ +inline constexpr std::string_view INPUT_A_TOKEN = "pointwise_add.input_a.uid"; +inline constexpr std::string_view INPUT_B_TOKEN = "pointwise_add.input_b.uid"; +inline constexpr std::string_view OUTPUT_TOKEN = "pointwise_add.output.uid"; + } // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index 40e62e43ea78..098fcf572226 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -23,6 +23,7 @@ #include "core/Handle.hpp" #include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" #include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" @@ -41,9 +42,23 @@ using namespace hip_kernel_provider; using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; using hipdnn_flatbuffers_sdk::utilities::parseUuid; +using hipdnn_plugin_sdk::ingestor::BoundTokens; using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; +/// The bindings a real plan build would hand the handler, produced the way the state +/// manager produces them: by running the graph matcher. Building them by hand instead +/// would let these tests pass against a matcher that binds the wrong uids. +BoundTokens bindingsFor(const MatchContext& context) +{ + BoundTokens bound; + if(!pointwiseAddGraphMatches(context, bound)) + { + throw std::logic_error("test graph does not match the pack it is dispatched against"); + } + return bound; +} + KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) { KernelDefinition kernel; @@ -149,8 +164,12 @@ TEST(TestPointwiseAddDispatch, ReportsWorkspaceFromKernelMetadata) // The two surviving kernels report different requirements, so the engine's // "maximum across survivors" has something to actually maximize over. - EXPECT_EQ(handler.workspaceBytes(fixture.context(), makeKernel(64, "FLOAT")), 0U); - EXPECT_EQ(handler.workspaceBytes(fixture.context(), makeKernel(256, "FLOAT")), 1024U); + EXPECT_EQ(handler.workspaceBytes( + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")), + 0U); + EXPECT_EQ(handler.workspaceBytes( + fixture.context(), bindingsFor(fixture.context()), makeKernel(256, "FLOAT")), + 1024U); } TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) @@ -162,9 +181,12 @@ TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) // The query is answered per kernel, before selection and before any plan exists, so // the answer must not depend on which other kernels are in the catalog. Asking twice // for the same kernel, either side of a different one, gives the same number. - const auto first = handler.workspaceBytes(fixture.context(), makeKernel(256, "FLOAT")); - static_cast(handler.workspaceBytes(fixture.context(), makeKernel(64, "FLOAT"))); - const auto second = handler.workspaceBytes(fixture.context(), makeKernel(256, "FLOAT")); + const auto first = handler.workspaceBytes( + fixture.context(), bindingsFor(fixture.context()), makeKernel(256, "FLOAT")); + static_cast(handler.workspaceBytes( + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT"))); + const auto second = handler.workspaceBytes( + fixture.context(), bindingsFor(fixture.context()), makeKernel(256, "FLOAT")); EXPECT_EQ(first, second); } @@ -181,7 +203,8 @@ TEST(TestPointwiseAddDispatch, LaunchesARealAddOnDevice) const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - const auto prepared = handler.prepare(fixture.context(), makeKernel(64, "FLOAT")); + const auto prepared = handler.prepare( + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")); ASSERT_NE(prepared, nullptr); const AddBuffers buffers(3.0f, 4.0f); @@ -206,7 +229,8 @@ TEST(TestPointwiseAddDispatch, LaunchesTheSameResultForEitherBlockSize) // genuinely different builds; a one-element add must still agree. for(const int64_t blockSize : {64, 256}) { - const auto prepared = handler.prepare(fixture.context(), makeKernel(blockSize, "FLOAT")); + const auto prepared = handler.prepare( + fixture.context(), bindingsFor(fixture.context()), makeKernel(blockSize, "FLOAT")); const AddBuffers buffers(1.5f, 2.25f); const auto descriptors = buffers.descriptors(); const Handle handle; @@ -228,7 +252,8 @@ TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) // A plan is built once and may execute many times with different buffers, so // preparation must hold nothing tied to one execution. - const auto prepared = handler.prepare(fixture.context(), makeKernel(64, "FLOAT")); + const auto prepared = handler.prepare( + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")); const Handle handle; for(const auto& [a, b, expected] : std::vector>{ @@ -244,17 +269,18 @@ TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) } } -TEST(TestPointwiseAddDispatch, RefusesToPrepareAGraphTheMatcherRejects) +TEST(TestPointwiseAddDispatch, RefusesToPrepareWithoutTheMatcherSBindings) { SKIP_IF_NO_DEVICES(); - const GraphFixture fixture(buildTwoNodePointwiseGraph()); + const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - // Preparation reads the operand binding the matcher established, so a graph that - // never matched cannot be prepared. - EXPECT_THROW(handler.prepare(fixture.context(), makeKernel(64, "FLOAT")), + // Preparation reads the operand uids the matcher bound rather than re-deriving them, + // so bindings that never came from this pack's matcher are a wiring error. It must + // fail loudly instead of guessing which tensor is which operand. + EXPECT_THROW(handler.prepare(fixture.context(), BoundTokens{}, makeKernel(64, "FLOAT")), hipdnn_plugin_sdk::HipdnnPluginException); } @@ -285,7 +311,8 @@ TEST(TestPointwiseAddDispatch, DispatchStaysResolvableAcrossContainerLifetimes) ASSERT_NE(handler, nullptr); const GraphFixture fixture(buildPointwiseGraph()); - const auto prepared = handler->prepare(fixture.context(), makeKernel(64, "FLOAT")); + const auto prepared = handler->prepare( + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")); ASSERT_NE(prepared, nullptr); const AddBuffers buffers(2.0f, 5.0f); diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index 00482d557e09..caf366bcbbb7 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -31,10 +31,19 @@ namespace using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; using hipdnn_flatbuffers_sdk::utilities::parseUuid; +using hipdnn_plugin_sdk::ingestor::BoundTokens; using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; +/// Runs the graph matcher where only its verdict is under test, discarding what it +/// bound. What it binds on success is asserted separately, below. +bool matches(const MatchContext& context) +{ + BoundTokens bound; + return pointwiseAddGraphMatches(context, bound); +} + hipDeviceProp_t testDeviceProperties() { hipDeviceProp_t properties{}; @@ -85,7 +94,7 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsASingleElementFloatAdd) { const GraphFixture fixture(buildPointwiseGraph()); - EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_TRUE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, AcceptsAHalfPrecisionAdd) @@ -95,14 +104,14 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsAHalfPrecisionAdd) const GraphFixture fixture( buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::HALF)); - EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_TRUE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, RefusesAnotherPointwiseOperation) { const GraphFixture fixture(buildPointwiseGraph(data_objects::PointwiseMode::MUL)); - EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_FALSE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, RefusesMultiElementTensors) @@ -112,7 +121,7 @@ TEST(TestPointwiseAddGraphMatcher, RefusesMultiElementTensors) const GraphFixture fixture(buildPointwiseGraph( data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 2, 2})); - EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_FALSE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, RefusesARankTheDispatchPathCannotServe) @@ -124,7 +133,7 @@ TEST(TestPointwiseAddGraphMatcher, RefusesARankTheDispatchPathCannotServe) const GraphFixture fixture( buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1})); - EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_FALSE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) @@ -132,7 +141,7 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) const GraphFixture fixture(buildPointwiseGraph( data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 1, 1, 1})); - EXPECT_TRUE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_TRUE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, RefusesAStrideOrderTheDispatchPathCannotClassify) @@ -150,7 +159,7 @@ TEST(TestPointwiseAddGraphMatcher, RefusesAStrideOrderTheDispatchPathCannotClass /*binary=*/true, /*explicitStrides=*/std::vector{8, 2, 4, 1})); - EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_FALSE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, RefusesAUnaryPointwise) @@ -161,7 +170,7 @@ TEST(TestPointwiseAddGraphMatcher, RefusesAUnaryPointwise) std::nullopt, /*binary=*/false)); - EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_FALSE(matches(fixture.context())); } TEST(TestPointwiseAddGraphMatcher, RefusesAMultiNodeGraph) @@ -170,7 +179,7 @@ TEST(TestPointwiseAddGraphMatcher, RefusesAMultiNodeGraph) // problem even though it contains this one. const GraphFixture fixture(buildTwoNodePointwiseGraph()); - EXPECT_FALSE(pointwiseAddGraphMatches(fixture.context())); + EXPECT_FALSE(matches(fixture.context())); } // --------------------------------------------------------------------------- @@ -223,24 +232,33 @@ TEST(TestPointwiseAddScore, PrefersTheLargerBlockSize) pointwiseAddScore(makeKernel(64, "FLOAT"), fixture.context())); } -TEST(TestPointwiseAddBinding, ReportsTheOperandUidsInArgumentOrder) +TEST(TestPointwiseAddBinding, TheMatcherBindsTheOperandUidsItResolved) { - // Dispatch binds its arguments from these, rather than re-deriving the graph shape + // Matching does double duty: deciding the kernel applies also binds the fields the + // launch will use, so dispatch reads these rather than re-deriving the graph shape // with a second notion of what a pointwise add looks like. const GraphFixture fixture(buildPointwiseGraph()); - const auto binding = pointwiseAddBinding(fixture.context()); + BoundTokens bound; + ASSERT_TRUE(pointwiseAddGraphMatches(fixture.context(), bound)); + const auto binding = pointwiseAddBinding(bound); EXPECT_EQ(binding.inputA, INPUT_A_UID); EXPECT_EQ(binding.inputB, INPUT_B_UID); EXPECT_EQ(binding.output, OUTPUT_UID); } -TEST(TestPointwiseAddBinding, RefusesAGraphTheMatcherWouldReject) +TEST(TestPointwiseAddBinding, ARejectedGraphBindsNothingToDispatchFrom) { + // A matcher that declines must not leave partial bindings behind: its pack is pruned, + // so anything it wrote would be state no surviving kernel could correctly read. const GraphFixture fixture(buildTwoNodePointwiseGraph()); - EXPECT_THROW(pointwiseAddBinding(fixture.context()), hipdnn_plugin_sdk::HipdnnPluginException); + BoundTokens bound; + ASSERT_FALSE(pointwiseAddGraphMatches(fixture.context(), bound)); + + EXPECT_TRUE(bound.empty()); + EXPECT_THROW(pointwiseAddBinding(bound), hipdnn_plugin_sdk::HipdnnPluginException); } } // namespace diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp index 8d13b86b9eef..82ea758f3c2e 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp @@ -8,6 +8,7 @@ #include #include +#include namespace hipdnn_plugin_sdk::ingestor { @@ -27,6 +28,16 @@ struct Catalog { std::vector entries; bool isSorted = false; + /// What this engine's graph-scoped matchers resolved about the graph, merged across + /// the packs that survived. Cached with the entries because RFC 0017 §8.1 keeps the + /// bound token state alongside the catalog, so a plan build reads these rather than + /// re-running a matcher to recover them. + /// + /// Merged rather than kept per pack: a token name means the same thing to every pack + /// in an engine, since they all read one graph through one metadata schema. Two packs + /// binding one name to different values would be an authoring error the loader should + /// reject, not a state this needs to represent. + BoundTokens bound; }; } // namespace hipdnn_plugin_sdk::ingestor diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index 1228d10352eb..41ca015fc254 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -67,12 +67,12 @@ class GenericEngine : public IEngine * this is the one place holding both the UED and its KMD. */ GenericEngine(EngineDescriptor engine, - std::shared_ptr> stateManager, + std::unique_ptr> stateManager, const IDeviceResolver& deviceResolver) : _engine(std::move(engine)) , _stateManager(std::move(stateManager)) , _id(hipdnn_data_sdk::utilities::engineNameToId(_engine.name)) - , _planBuilder(_engine, _stateManager, deviceResolver) + , _planBuilder(_engine, *_stateManager, deviceResolver) { const auto& fields = _stateManager->metadataSchema().fields; for(const auto& knob : _engine.knobs) @@ -162,7 +162,10 @@ class GenericEngine : public IEngine private: EngineDescriptor _engine; - std::shared_ptr> _stateManager; + /// Owned outright: see the constructor for why this must not be shared. Held by + /// pointer rather than by value so the plan builder can bind a reference to it in the + /// member initializer list, which needs a stable address. + std::unique_ptr> _stateManager; int64_t _id; GenericPlanBuilder _planBuilder; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp index b0404da1be8f..df7e6d290ce4 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -44,11 +44,17 @@ class GenericPlan : public IPlan * @param context Bound graph and device state. Read during construction to * prepare the launch; not retained, because a plan outlives the * graph reference this carries. + * @param bound What matching resolved about the graph, from the catalog that + * selected this kernel. Passed in rather than recovered here: the + * plan is built from a matched catalog, so re-running a matcher to + * rediscover these would repeat work already cached. */ - GenericPlan(KernelDispatcher dispatcher, const MatchContext& context) + GenericPlan(KernelDispatcher dispatcher, + const MatchContext& context, + const BoundTokens& bound) : _dispatcher(std::move(dispatcher)) - , _workspaceBytes(_dispatcher.handler->workspaceBytes(context, _dispatcher.kernel)) - , _prepared(_dispatcher.handler->prepare(context, _dispatcher.kernel)) + , _workspaceBytes(_dispatcher.handler->workspaceBytes(context, bound, _dispatcher.kernel)) + , _prepared(_dispatcher.handler->prepare(context, bound, _dispatcher.kernel)) { if(_prepared == nullptr) { diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 8aca4b34df9a..524486d1ee7c 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -52,10 +52,10 @@ class GenericPlanBuilder : public IPlanBuilder /// @param deviceResolver Answers which device each call is for. Held by reference; /// owned by the engine, which outlives its builder. GenericPlanBuilder(const EngineDescriptor& engine, - std::shared_ptr> stateManager, + const KernelIngestorStateManager& stateManager, const IDeviceResolver& deviceResolver) : _engine(engine) - , _stateManager(std::move(stateManager)) + , _stateManager(stateManager) , _deviceResolver(deviceResolver) { } @@ -64,7 +64,7 @@ class GenericPlanBuilder : public IPlanBuilder /// a membership test needs no order, so the heuristic is never run here. bool isApplicable(const THandle& handle, const IGraph& opGraph) const override { - return !_stateManager->unsortedDefinitions(contextFor(handle, opGraph)).empty(); + return !_stateManager.unsortedDefinitions(contextFor(handle, opGraph)).empty(); } /** @@ -79,15 +79,18 @@ class GenericPlanBuilder : public IPlanBuilder const TSettings& /*executionSettings*/) const override { const auto context = contextFor(handle, opGraph); + // Unsorted, but still one lookup for the entries and the bound state together. + const auto catalog = _stateManager.unsortedCatalog(context); size_t maxBytes = 0; // Unsorted deliberately: a maximum over the survivors is order-independent, and // this call arrives for every candidate engine. Ranking here would load and run // each engine's heuristic to compute a number that does not depend on the order, // which is the cost the lazy-ranking split exists to avoid. - for(const auto& kernel : _stateManager->unsortedDefinitions(context)) + for(const auto& kernel : catalog.entries) { - const auto dispatcher = _stateManager->getDispatchDetails(kernel); - maxBytes = std::max(maxBytes, dispatcher.handler->workspaceBytes(context, kernel)); + const auto dispatcher = _stateManager.getDispatchDetails(kernel); + maxBytes = std::max(maxBytes, + dispatcher.handler->workspaceBytes(context, catalog.bound, kernel)); } return maxBytes; } @@ -116,7 +119,10 @@ class GenericPlanBuilder : public IPlanBuilder TContext& executionContext) const override { const auto context = contextFor(handle, opGraph); - const auto ranked = _stateManager->sortedDefinitions(context); + // One lookup for both the order and the bound state: asking separately would + // match twice for a graph that carries no identity and so cannot be cached. + const auto catalog = _stateManager.sortedCatalog(context); + const auto& ranked = catalog.entries; if(ranked.empty()) { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, @@ -125,7 +131,7 @@ class GenericPlanBuilder : public IPlanBuilder } executionContext.setPlan(std::make_unique>( - _stateManager->getDispatchDetails(ranked.front()), context)); + _stateManager.getDispatchDetails(ranked.front()), context, catalog.bound)); } /** @@ -141,7 +147,7 @@ class GenericPlanBuilder : public IPlanBuilder { using namespace hipdnn_flatbuffers_sdk::data_objects; - const auto ranked = _stateManager->sortedDefinitions(contextFor(handle, opGraph)); + const auto ranked = _stateManager.sortedDefinitions(contextFor(handle, opGraph)); std::vector knobs; if(ranked.empty()) { @@ -205,7 +211,10 @@ class GenericPlanBuilder : public IPlanBuilder } const EngineDescriptor& _engine; - std::shared_ptr> _stateManager; + /// Held by reference; owned by the engine, which owns this builder and so outlives + /// it. Sharing ownership here would reintroduce exactly the cross-engine aliasing the + /// engine's ownership rules out. + const KernelIngestorStateManager& _stateManager; const IDeviceResolver& _deviceResolver; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp index b7351319b308..d5084ba1a91a 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp @@ -72,9 +72,15 @@ class IKernelDispatchHandler * worked example is `batch * num_heads * seqlen_q * 4`, which a signature over the * kernel alone cannot express, and the declarative evaluator that eventually replaces * a native handler has to. + * + * @param bound What matching already resolved about this graph. Read rather than + * re-derived: RFC 0017 §8.5 gives matching the job of binding the fields the + * launch uses, and §8.1 requires that nothing be re-matched afterwards. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) - virtual size_t workspaceBytes(const MatchContext& context, const KernelDefinition& kernel) const + virtual size_t workspaceBytes(const MatchContext& context, + const BoundTokens& bound, + const KernelDefinition& kernel) const = 0; /** @@ -82,9 +88,14 @@ class IKernelDispatchHandler * * Called once, at plan build, while @p context is still valid. The returned object is * owned by the plan and must not reference anything in @p context. + * + * @param bound What matching resolved about this graph, for the same reason + * workspaceBytes() takes it: the values a launch needs were already found once + * and re-finding them here would be a second, divergeable notion of the graph. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual std::unique_ptr prepare(const MatchContext& context, + const BoundTokens& bound, const KernelDefinition& kernel) const = 0; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index 16151932f489..6387dcf7dee7 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -137,6 +137,27 @@ class KernelIngestorStateManager return catalogFor(context).entries; } + /** + * @brief The unranked catalog and the state matching bound, from one lookup. + * + * RFC 0017 §8.1 keeps the bound token state alongside the catalog so that "nothing + * is re-matched" after applicability: a dispatch handler sizing a workspace reads the + * values the matcher already resolved rather than walking the graph again with a + * second notion of what it looks like. + * + * Returned together with the entries rather than through a separate accessor because + * a caller needing both would otherwise match twice for a graph carrying no identity, + * which is exactly the case with no cache to absorb the repeat. + * + * `bound` is empty when no graph-scoped matcher bound anything, which is legal: a + * pack whose launch geometry is fully determined by kernel metadata has nothing to + * bind. + */ + Catalog unsortedCatalog(const MatchContext& context) const + { + return catalogFor(context); + } + /** * @brief Every kernel that applies to the graph and device @p context names, best * first. @@ -145,11 +166,24 @@ class KernelIngestorStateManager * that order. If the catalog itself is missing, it is built first. */ std::vector sortedDefinitions(const MatchContext& context) const + { + return sortedCatalog(context).entries; + } + + /** + * @brief The ranked catalog and the state matching bound, from one lookup. + * + * A plan build needs both, and asking for them separately means two calls into the + * cache -- or, for a graph carrying no identity and so no cache entry, two full + * matching passes. Returning them together makes "match once" hold for the + * uncacheable case as well, which is the case that can least afford the second pass. + */ + Catalog sortedCatalog(const MatchContext& context) const { Catalog catalog = catalogFor(context); if(catalog.isSorted) { - return catalog.entries; + return catalog; } catalog.entries = _heuristic->rank(catalog, context); @@ -160,7 +194,7 @@ class KernelIngestorStateManager _catalogCache.put(*key, catalog); } - return catalog.entries; + return catalog; } /** @@ -342,7 +376,9 @@ class KernelIngestorStateManager /// A graph-scoped matcher's verdict for one (graph, device), keyed by matcher id. /// /// Matchers are shared by id across packs, so the same check would otherwise be - /// re-run once per pack that lists it. + /// re-run once per pack that lists it. The memo covers what the matcher *bound* as + /// well as whether it passed, since re-running it to recover its bindings would + /// defeat the memo entirely. using GraphMatcherMemo = std::unordered_map; /** @@ -364,7 +400,7 @@ class KernelIngestorStateManager for(const auto& pack : _packs) { - if(!graphLevelMatchersPass(pack, context, graphVerdicts)) + if(!graphLevelMatchersPass(pack, context, graphVerdicts, catalog.bound)) { continue; } @@ -389,9 +425,14 @@ class KernelIngestorStateManager return catalog; } + /// @param bound Accumulates what the matchers resolved, for the catalog to keep. + /// Written straight into the catalog's map: a matcher that passes contributes + /// its bindings, and one that fails prunes its pack, so nothing it wrote can + /// be read by a kernel that survives. bool graphLevelMatchersPass(const KernelDescriptorPack& pack, const MatchContext& context, - GraphMatcherMemo& graphVerdicts) const + GraphMatcherMemo& graphVerdicts, + BoundTokens& bound) const { for(const auto& matcherId : pack.matcherIds) { @@ -404,7 +445,11 @@ class KernelIngestorStateManager auto memo = graphVerdicts.find(matcherId); if(memo == graphVerdicts.end()) { - const bool passed = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context); + // Binds on the first evaluation only. A later pack listing this same + // matcher reuses both the verdict and the bindings it already wrote, + // which is the whole point of memoizing across packs. + const bool passed + = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context, bound); memo = graphVerdicts.emplace(matcherId, passed).first; } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index d310b8942183..4c31cdb7b512 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include @@ -32,19 +34,22 @@ using GraphId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; /// another device does not. /// /// RFC 0017 §8.1 keys this on (engine id, graph id, device id) and §8.6 folds in a -/// descriptor-inventory generation. Neither is present, and both omissions are scoped -/// rather than free: +/// descriptor-inventory generation. Neither appears here, for different reasons: /// -/// - The engine id is implicit because one state manager serves one engine and owns this -/// cache, so no other engine's catalog can reach it. That holds only while a state -/// manager is never shared across engines. +/// - The engine id is constant within any one cache, because GenericEngine owns its +/// state manager outright (std::unique_ptr, not shared) and the state manager owns +/// this cache. One engine, one UED, one catalog: an entry cannot be reached from an +/// engine other than the one that wrote it, so storing the id would only repeat a +/// value every entry already agrees on. This is enforced by the ownership, not by +/// convention -- a second engine cannot be handed this manager. /// - The generation retires cached verdicts when a discovery scan changes the pack /// inventory. Nothing is loaded from a file yet, so the inventory is fixed at compile /// time and the generation would be a constant. /// -/// Both become real key components once engines are built from UED files and packs can -/// be dropped in. This is a struct rather than a std::pair so adding them then does not -/// change how the key is spelled at its use sites. +/// The generation becomes a real key component once packs can be dropped in; the engine +/// id stays out for as long as the ownership above holds. This is a struct rather than a +/// std::pair so adding a member then does not change how the key is spelled at its use +/// sites. struct CatalogKey { GraphId graphId; @@ -75,6 +80,21 @@ struct CatalogKeyHash } }; +/** + * @brief What matching resolved about one graph, carried forward to dispatch. + * + * RFC 0017 §8.5: "Matching does double duty: it decides the kernel applies, and it binds + * the fields the launch will use", and §8.1 keeps that state alongside the catalog so + * "nothing is re-matched" once a graph has been matched. A dispatch formula reading + * `$q.uid` is reading a value the matcher already resolved, not re-deriving it from the + * graph with a second notion of what the graph looks like. + * + * Keyed by the token name a descriptor would write. Values are int64 because every token + * a matcher binds today is a tensor uid or a dimension; a wider value type is the + * expression-language follow-up's to choose, and widening it is additive. + */ +using BoundTokens = std::unordered_map; + /** * @brief The bound token state a matcher, scorer, or dispatch formula reads. * diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp index 0a11b669fec6..b992688c527b 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp @@ -37,7 +37,17 @@ namespace hipdnn_plugin_sdk::ingestor /// A graph-scoped applicability check: reads only graph and device facts, so it runs /// once per (graph, device) and its failure prunes every kernel in the pack. -using GraphMatcherFn = bool (*)(const MatchContext&); +/// +/// On success it also writes what it resolved about the graph into @p bound, which the +/// catalog keeps and dispatch reads back. RFC 0017 §8.5 gives matching this double duty +/// deliberately: the matcher already walked the graph to decide the kernel applies, so +/// the launch reuses those values rather than re-deriving them. A matcher that binds +/// nothing simply leaves @p bound alone. +/// +/// Only graph-scoped matchers bind. Kernel-scoped ones run once per candidate, so what +/// they resolved would be per-kernel state with nowhere in the catalog to live; the +/// tokens a launch needs describe the problem, not the kernel chosen for it. +using GraphMatcherFn = bool (*)(const MatchContext&, BoundTokens& bound); /// A kernel-scoped applicability check: also reads the candidate's metadata, so it runs /// once per surviving kernel and disqualifies that kernel alone. diff --git a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp index a93652ee5966..9bc1a77cbc65 100644 --- a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp @@ -133,7 +133,7 @@ inline hipDeviceProp_t testDeviceProperties() return properties; } -inline bool acceptAnyGraph(const MatchContext& /*context*/) +inline bool acceptAnyGraph(const MatchContext& /*context*/, BoundTokens& /*bound*/) { return true; } diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp index aae1f872401b..846f2d285d5d 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp @@ -59,13 +59,19 @@ MatcherCounters& counters() return s_counters; } -bool acceptGraph(const MatchContext& /*context*/) +/// An arbitrary value acceptGraph binds, so a test can prove it reached dispatch intact. +constexpr int64_t BOUND_TOKEN_VALUE = 4242; + +bool acceptGraph(const MatchContext& /*context*/, BoundTokens& bound) { ++counters().graphCalls; + // Stands in for the tensor uids and dimensions a real matcher resolves: the point + // under test is that whatever matching bound reaches dispatch without a re-match. + bound["test.bound_token"] = BOUND_TOKEN_VALUE; return true; } -bool rejectGraph(const MatchContext& /*context*/) +bool rejectGraph(const MatchContext& /*context*/, BoundTokens& /*bound*/) { ++counters().graphCalls; return false; @@ -542,6 +548,47 @@ TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) EXPECT_EQ(counters().graphCalls, 2); } +TEST(TestIngestorStateManager, CarriesWhatMatchingBoundThroughToDispatch) +{ + // RFC 0017 section 8.5: matching does double duty, deciding a kernel applies and + // binding the fields the launch will use. A dispatch handler must read those values + // rather than re-deriving them from the graph with a second notion of its shape. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto properties = testDeviceProperties(); + const TestGraph graph; + const MatchContext context{graph, 0, properties}; + + const auto bound = manager->unsortedCatalog(context).bound; + + ASSERT_EQ(bound.count("test.bound_token"), 1U); + EXPECT_EQ(bound.at("test.bound_token"), BOUND_TOKEN_VALUE); +} + +TEST(TestIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematch) +{ + // Section 8.1 keeps the bound token state alongside the catalog precisely so that + // "nothing is re-matched" once a graph has been matched. Recovering these values by + // re-running the matcher would be correct and quietly quadratic. + // + // The graph carries an identity, so there is a cache entry to serve the second and + // third reads. A graph without one is matched fresh every call by design; that is why + // the entries and the bound state come back from a single call rather than two. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto properties = testDeviceProperties(); + const TestGraph graph(makeGraphId(11)); + const MatchContext context{graph, 0, properties}; + + static_cast(manager->unsortedCatalog(context)); + const auto afterMatching = counters().graphCalls; + + static_cast(manager->unsortedCatalog(context).bound); + static_cast(manager->sortedCatalog(context).bound); + + EXPECT_EQ(counters().graphCalls, afterMatching); +} + TEST(TestIngestorStateManager, RematchesAfterCacheEviction) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); From 57c3755c334f48da7aa9946327520edae4bf0abd Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 10:19:21 -0400 Subject: [PATCH 22/60] ci(hipdnn): enable the kernel ingestor in the superbuild CI (ALMIOPEN-2402) Build and test the ingestor on both superbuild jobs, mirroring how HIPKERNELPROVIDER_ENABLE_ROCKE is enabled on the same configure lines. The flag is declared by TheRock (ROCm/TheRock#7194) and defaults OFF, so without this the engine is compiled out and gets no CI signal at all. Both runners have a GPU, so this exercises the hipRTC compile and launch, not just the build. --- .github/workflows/hipdnn-superbuild-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index 80349f4bafb3..9265ce2daedf 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -120,6 +120,7 @@ jobs: -DROCM_LIBS_ENABLE_ROOT_CTEST=ON \ -DENABLE_CLANG_FORMAT=OFF \ -DHIPKERNELPROVIDER_ENABLE_ROCKE=ON \ + -DHIPDNN_ENABLE_KERNEL_INGESTOR=ON \ -DENABLE_CLANG_TIDY=OFF - name: Build @@ -280,6 +281,7 @@ jobs: -DENABLE_CLANG_FORMAT=OFF \ -DENABLE_CLANG_TIDY=ON \ -DHIPKERNELPROVIDER_ENABLE_ROCKE=ON \ + -DHIPDNN_ENABLE_KERNEL_INGESTOR=ON \ -DCMAKE_CXX_CLANG_TIDY=/usr/bin/clang-tidy-20 - name: Build From 180cb28660af92706e28bb00c6ba55ca379e968b Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 14:59:05 -0400 Subject: [PATCH 23/60] refactor(hipdnn): make a new ingestor op four functions plus data (ALMIOPEN-2402) Six changes to the kernel ingestor skeleton, all op-independent: none adds anything a pack author would write again for a second op. Delete PointwiseAddEngine. GenericEngine already is the IEngine, so the 50-line forwarding subclass existed only for member-init order. makePointwiseAddEngine() is now four statements. HandleDeviceResolver and the dispatch handler become process-lifetime, which is what lets the wrapper go and answers the review note about DispatchRegistry holding non-owning pointers -- one lifetime question, not two. Symbol registration moves to a single registerNativeIngestorSymbols() at container construction: a UED loaded from a file names hipkernel.pointwise_add.graph_match, and nothing on that path runs the pack's own registration, so today it resolves only because the TU that registers the symbol is the TU that hardcodes the descriptors. That was the blocker for ALMIOPEN-2401. Unify the value type. A criteria interpreter evaluating divisible($q.head_size, $kernel.tile_m) puts a graph fact and a kernel fact on either side of one operator, so MetadataValue gains vector and the matcher, dispatch, and catalog signatures agree on one type. Make kernel_source a tagged union rather than two bare strings: the kind is the adapter dispatch point, and kpack-symbol, hsaco-file, and rocke-builder are structurally different payloads. Only EMBEDDED_SOURCE has an adapter. Give the UHD a data path. It was the only descriptor constructed and then stepped around; kind plus payload and a makeKernelHeuristic() factory keyed on it means "add file loading and it works" is now true for all seven kinds. Scope bound state per pack. graphLevelMatchersPass wrote into the shared catalog bindings as it walked and could then prune its pack, leaving a pruned pack's bindings readable by a surviving pack's kernel. Invisible with one pack, live the moment two packs share a matcher, which is the normal case. Each pack now binds into its own view, merged only on success; cross-pack matcher memoization is preserved. Honor knob settings. getCustomKnobs advertised block_size with a real value set and buildPlan ignored it, so block_size=64 did not select the 64 kernel. The catalog is filtered before ranking in both buildPlan and getMaxWorkspaceSize. A setting no kernel satisfies is a loud INVALID_VALUE naming the engine, every requested knob, and how many kernels matched the graph before filtering -- the expected outcome of setting knobs by hand, kept distinct from the pre-existing INTERNAL_ERROR for an empty catalog. Also records two decisions in the headers: descriptor parsing is eager at load (a conscious amendment of RFC 0017 sections 3 and 8, whose expensive parts -- kernel sources and heuristic models -- stay lazy), and which way multi-launch UDDs go when they land. Rewrite the integration test on the frontend API. It drove the plugin C ABI through dlopen and hand-built flatbuffer graphs; it now inherits IntegrationGraphVerificationHarness like every sibling engine test, pins the engine with set_preferred_engine_id_ext, and checks numerics against the CPU reference executor instead of a hardcoded sum. 595 lines to 386. One direct-ABI test survives for getAllEngineIds, the only assertion with no frontend equivalent. The harness's handle members move private to protected so a subclass can stage Graph calls without reimplementing plugin loading. Verified flag-on: 139 SDK, 462 provider (1 pre-existing skip), 13 integration, every binary exiting 0. Flag-off: 91 SDK, 5 integration, no ingestor test present in either binary. --- .../src/core/Container.cpp | 11 + .../hip-kernel-provider/src/core/Settings.hpp | 14 + .../HandleDeviceResolver.hpp | 13 + .../KernelIngestorEngine.cpp | 114 +-- .../KernelIngestorEngine.hpp | 31 +- .../packs/PointwiseAddDispatchHandler.cpp | 6 +- .../packs/PointwiseAddPack.cpp | 24 +- .../packs/PointwiseAddPack.hpp | 15 +- .../IntegrationGraphVerificationHarness.hpp | 10 +- .../IntegrationGpuKernelIngestor.cpp | 705 +++++++----------- .../packs/TestPointwiseAddDispatchHandler.cpp | 4 +- .../packs/TestPointwiseAddMatchers.cpp | 4 +- .../packs/TestPointwiseAddPack.cpp | 6 +- .../hipdnn_plugin_sdk/ingestor/Catalog.hpp | 7 + .../ingestor/Descriptors.hpp | 107 ++- .../ingestor/GenericPlanBuilder.hpp | 207 ++++- .../ingestor/IKernelDispatchHandler.hpp | 12 + .../ingestor/IKernelHeuristic.hpp | 30 + .../ingestor/KernelDefinition.hpp | 25 +- .../ingestor/KernelIngestorStateManager.hpp | 91 ++- .../tests/KernelIngestorTestFixtures.hpp | 4 +- .../plugin_sdk/tests/TestKernelIngestor.cpp | 275 ++++++- 22 files changed, 1082 insertions(+), 633 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/core/Container.cpp b/dnn-providers/hip-kernel-provider/src/core/Container.cpp index 90823b779dd0..12e453a6ca26 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Container.cpp +++ b/dnn-providers/hip-kernel-provider/src/core/Container.cpp @@ -114,6 +114,17 @@ Container::Container() { HIPDNN_PLUGIN_LOG_INFO("Creating Container"); +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + // Every ingestor pack's native matchers, scorers, and dispatch handlers must be + // registered before any descriptor-backed engine below can resolve the symbols its + // UMDs, UHDs, and UDDs name -- including a UED loaded from a file that shares this + // pack's symbols with no compile-time link to this translation unit. Safe to call + // on every Container construction: it registers exactly once for the process, no + // matter how many containers SharedContainerManager builds and tears down over the + // plugin's lifetime. + kernel_ingestor_engine::registerNativeIngestorSymbols(); +#endif + _engineManager = std::make_unique>(); diff --git a/dnn-providers/hip-kernel-provider/src/core/Settings.hpp b/dnn-providers/hip-kernel-provider/src/core/Settings.hpp index 4561db434349..67000e6e2589 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Settings.hpp +++ b/dnn-providers/hip-kernel-provider/src/core/Settings.hpp @@ -7,6 +7,10 @@ #include +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +#include +#endif + /** * @brief HIP kernel provider plugin-specific execution settings. * @@ -18,4 +22,14 @@ struct Settings /// Accumulator precision for backward SDPA dQ gradient. /// nullopt means no user preference (default: A32). std::optional accumulatorType; + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + /// The kernel ingestor's per-call knob filter (RFC 0017 §4's catalog -> filter -> + /// rank ordering). See GenericPlanBuilder.hpp's KnobFilter doc for why this lives + /// here rather than being threaded through some other channel: + /// getMaxWorkspaceSize() receives only this Settings object, not an IEngineConfig, + /// so a knob setting reaches it only via a field initializeExecutionSettings() + /// populated first. + hipdnn_plugin_sdk::ingestor::KnobFilter ingestorKnobFilter; +#endif }; diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp index 7992555257cc..7e7acfc11c90 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp @@ -28,6 +28,19 @@ namespace hip_kernel_provider::kernel_ingestor_engine * never change for a given device. The cache hands out references that stay valid for * this resolver's lifetime, so entries are never erased or rehashed away: node handles * in std::unordered_map keep referenced values pinned across growth. + * + * Instantiated once, at process lifetime (see KernelIngestorEngine.cpp's deviceResolver()), + * rather than once per engine or once per Container. This class carries no engine state + * -- only device properties, which are a fact about the machine, not about which engine + * or container is asking -- so nothing about it needs to be reconstructed when a + * container is destroyed and rebuilt (SharedContainerManager's weak_ptr does exactly + * that across handle churn). A per-engine instance would duplicate the same cache once + * per engine and lose its warm entries every time a container cycled, for no isolation + * benefit: two engines resolving the same physical device must agree on that device's + * properties by construction, so a shared cache is the correct scope, not merely a + * convenient one. That is also why the cache the class holds is never invalidated + * ("references stay valid for this resolver's lifetime" above is a promise this + * lifetime choice keeps easy to honor). */ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver { diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index e58c7e2f6995..b7ff1b879fe0 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -7,9 +7,9 @@ #include #include +#include #include -#include #include "engines/hip_mlops_engine/HipMlopsKernelCompiler.hpp" #include "engines/kernel_ingestor_engine/HandleDeviceResolver.hpp" @@ -27,10 +27,13 @@ namespace * @brief The dispatch handler this pack's UDD resolves to. * * Process-lifetime, because the registry that holds it is. The registry stores a - * non-owning pointer, and a provider's container is created and destroyed along with - * its handles, so a handler owned by an engine would be freed while the registration - * still pointed at it -- and the next container's engine would resolve that dangling - * pointer rather than its own handler. + * non-owning pointer, and a provider's container is created and destroyed along with its + * handles (see SharedContainerManager), so a handler owned by an engine or a container + * would be freed while the registration still pointed at it -- and the next container's + * engine would resolve that dangling pointer rather than its own handler. A function- + * local static outlives every container the process ever builds, which is the only + * lifetime that satisfies DispatchRegistry's non-owning-pointer contract without the + * registry taking ownership itself; NativeRegistry's own doc explains why it does not. * * The compiler it holds is a static for the same reason, and matches the module cache * behind it, which is already process-wide. @@ -43,91 +46,46 @@ const PointwiseAddDispatchHandler& dispatchHandler() } /** - * @brief Registers this pack's native implementations, then builds its state manager. + * @brief The device resolver every descriptor-backed engine in this provider shares. * - * Order matters: assembling the state manager resolves the heuristic's score symbol, so - * the implementations must already be registered. - * - * Registration deliberately does not run at static-init time. An engine that is never - * constructed should not have mutated process-wide registries, and a duplicate-symbol - * throw from a global constructor would escape during dlopen() and terminate the - * process; from here it surfaces as a failed plugin creation the host can report. + * Container/process-lifetime, not per-engine: it is a device-property cache with no + * engine-specific state (see HandleDeviceResolver's own doc for why a device's + * properties are safe to share across every handle and every engine that asks about + * that device), so giving each engine its own instance would only duplicate the cache + * for no isolation benefit. A function-local static matches how Container and + * EnginePluginImpl already share state that outlives any one engine or container + * instance -- dispatchHandler() above is the same pattern for the same reason. */ -std::unique_ptr> - registerThenBuildStateManager() +const HandleDeviceResolver& deviceResolver() +{ + static const HandleDeviceResolver s_deviceResolver; + return s_deviceResolver; +} + +} // namespace + +void registerNativeIngestorSymbols() { + // Idempotent across repeated Container construction (see SharedContainerManager): + // the process-wide NativeRegistry this populates must be populated exactly once, and + // a second registerSymbol() call under the same name would throw on the duplicate. static std::once_flag s_registered; std::call_once(s_registered, []() { registerPointwiseAddMatchers(); registerPointwiseAddDispatch(dispatchHandler()); }); - - return makePointwiseAddStateManager(); } -/** - * @brief A generic engine over this pack's descriptor set. - * - * Owns only what is scoped to one engine: the device resolver, and the state manager's - * catalog cache. The dispatch handler and its compiler are deliberately not members -- - * see dispatchHandler() for why their lifetime has to exceed this engine's. - */ -class PointwiseAddEngine : public hipdnn_plugin_sdk::IEngine -{ -public: - PointwiseAddEngine() - : _engine(buildPointwiseAddDescriptorSet().engine, - registerThenBuildStateManager(), - _deviceResolver) - { - } - - int64_t id() const override - { - return _engine.id(); - } - - bool isApplicable( - Handle& handle, - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph) const override - { - return _engine.isApplicable(handle, opGraph); - } - - void getDetails(Handle& handle, - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph, - hipdnnPluginConstData_t& detailsOut) const override - { - _engine.getDetails(handle, opGraph, detailsOut); - } - - size_t getMaxWorkspaceSize(const Handle& handle, - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph, - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig& - engineConfig) const override - { - return _engine.getMaxWorkspaceSize(handle, opGraph, engineConfig); - } - - void initializeExecutionContext( - const Handle& handle, - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& opGraph, - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig& engineConfig, - Context& executionContext) const override - { - _engine.initializeExecutionContext(handle, opGraph, engineConfig, executionContext); - } - -private: - HandleDeviceResolver _deviceResolver; - hipdnn_plugin_sdk::ingestor::GenericEngine _engine; -}; - -} // namespace - std::unique_ptr> makePointwiseAddEngine() { - return std::make_unique(); + auto set = buildPointwiseAddDescriptorSet(); + // Moves the UED out of `set` in its own statement, fully sequenced before the move + // of the (now engine-less) remainder below -- not two moves racing inside one call's + // argument list. makePointwiseAddStateManager() never reads set.engine, so its + // moved-from state here is inert. + auto engine = std::move(set.engine); + return std::make_unique>( + std::move(engine), makePointwiseAddStateManager(std::move(set)), deviceResolver()); } } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp index e573c1052354..6fa2de47a5bd 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp @@ -16,12 +16,37 @@ namespace hip_kernel_provider::kernel_ingestor_engine { +/** + * @brief Registers every native matcher, scorer, and dispatch handler this provider's + * ingestor packs resolve through, exactly once for the life of the process. + * + * Called eagerly from Container's constructor -- which runs inside the plugin's create + * entry point -- rather than lazily as a side effect of building today's one hardcoded + * engine. That laziness is the ALMIOPEN-2401 blocker this closes: a UED loaded from a + * descriptor file names a symbol like "hipkernel.pointwise_add.graph_match" with no + * compile-time link to the C++ translation unit that implements it, so nothing after a + * file-loaded UED can rely on registration happening as a byproduct of constructing the + * one engine that used to hardcode it. + * + * Idempotent across repeated calls: Container can be destroyed and rebuilt many times + * over a process's life (see SharedContainerManager's weak_ptr), but the process-wide + * NativeRegistry this populates must be populated only once or the second call throws + * on the duplicate. + */ +void registerNativeIngestorSymbols(); + /** * @brief Builds the descriptor-backed pointwise-add engine. * - * Registers this pack's native implementations, assembles its descriptor set, and - * returns a generic engine driven entirely by that data. The engine owns the kernel - * compiler and dispatch handler its kernels need, so both outlive every plan it builds. + * Assembles this pack's descriptor set once and hands its engine descriptor and state + * manager straight to GenericEngine, which already satisfies IEngine end to end -- there + * is no forwarding wrapper left to own. The device resolver and dispatch handler this + * engine's kernels need are process-lifetime statics (see KernelIngestorEngine.cpp), not + * members, so nothing about their lifetime constrains how this object is constructed. + * + * Registration is not this function's job: registerNativeIngestorSymbols() must already + * have run, so that a file-loaded UED sharing this pack's symbols resolves the same way + * a hardcoded one does. * * This function is what a UED loader replaces: given descriptor files, the same generic * engine is constructed from parsed data instead of from a hardcoded pack. diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp index d5c8b8f0ed2c..f1bf77bc2dcb 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp @@ -121,8 +121,10 @@ std::unique_ptr PointwiseAddDispatchHandler::prepare( options.add("HIP_PLUGIN_POINTWISE_ADD_TYPE", elementTypeFor(kernel)); options.add("HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE", blockSize); - auto program = _kernelCompiler.compile(kernel.sourceFile, options); - auto runnableKernel = program->getKernel(kernel.entryPoint); + // The only KernelSourceKind this dispatch handler knows how to load; see + // KernelSource's doc for the kinds a future adapter will add. + auto program = _kernelCompiler.compile(kernel.source.sourceFile, options); + auto runnableKernel = program->getKernel(kernel.source.entryPoint); // One element, so one workgroup; the block size comes from kernel metadata, which is // how a per-kernel launch quantity reaches a dispatch descriptor shared by the pack. diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp index 10b838603b62..a9a183b1850a 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp @@ -47,8 +47,10 @@ KernelDescriptor makeKernel(const DescriptorId& id, KernelDescriptor kernel; kernel.id = id; kernel.name = "pointwise_add." + variant; - kernel.sourceFile = "PointwiseAdd.cpp"; - kernel.entryPoint = "PointwiseAdd"; + // The only KernelSourceKind this POC implements; see KernelSource's doc for what + // the other kinds converge on once a loader ships them. + kernel.source.sourceFile = "PointwiseAdd.cpp"; + kernel.source.entryPoint = "PointwiseAdd"; kernel.metadata = {{std::string(BLOCK_SIZE_FIELD), MetadataValue{blockSize}}, {std::string(DTYPE_FIELD), MetadataValue{dtype}}}; kernel.priority = priority; @@ -70,7 +72,7 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() set.heuristic.id = HEURISTIC_ID; set.heuristic.name = "pointwise add selector"; - set.heuristic.scoreSymbol = SCORE_SYMBOL; + set.heuristic.payload = SCORE_SYMBOL; set.engine.id = ENGINE_ID; set.engine.name = ENGINE_NAME; @@ -129,16 +131,14 @@ int64_t pointwiseAddEngineId() return hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); } -std::unique_ptr> makePointwiseAddStateManager() +std::unique_ptr> + makePointwiseAddStateManager(PointwiseAddDescriptorSet set) { - auto set = buildPointwiseAddDescriptorSet(); - - return std::make_unique>( - std::move(set.schema), - std::move(set.matchers), - std::move(set.dispatches), - std::move(set.packs), - std::make_shared(std::string(SCORE_SYMBOL))); + return std::make_unique>(std::move(set.schema), + std::move(set.matchers), + std::move(set.dispatches), + std::move(set.packs), + makeKernelHeuristic(set.heuristic)); } } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp index 9416cfefb879..f356b5669652 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp @@ -67,13 +67,20 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); */ int64_t pointwiseAddEngineId(); -/// @brief Builds the state manager backing this pack. +/// @brief Builds the state manager backing this pack, from an already-built descriptor +/// set. +/// +/// Takes @p set by value rather than building its own, so a caller assembling both the +/// engine and its state manager calls buildPointwiseAddDescriptorSet() exactly once: +/// the two used to each build a separate copy, which is wasted work with no correctness +/// benefit since both copies describe the same pack. /// /// Carries only what selection needs: the KMD, the matchers and dispatch descriptors the -/// packs reference, the packs themselves, and the resolved heuristic. The UED is not -/// among them, because a UED is 1:1 with a hipDNN engine and is owned by the engine. +/// packs reference, the packs themselves, and the resolved heuristic. @p set's UED is +/// ignored here, because a UED is 1:1 with a hipDNN engine and is owned by the engine, +/// not by this state manager. std::unique_ptr> - makePointwiseAddStateManager(); + makePointwiseAddStateManager(PointwiseAddDescriptorSet set); } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp b/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp index 638b34ce9477..2bd6efec18b3 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp @@ -164,6 +164,13 @@ class IntegrationGraphVerificationHarness : public ::testing::TestWithParam _tensorIdToNameMap; std::unordered_map> _tensorIdToValidatorMap; diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp b/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp index 3534d76e7fd8..c02b7f03448c 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp @@ -4,8 +4,9 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR #include -#include +#include #include +#include #include #include @@ -14,28 +15,43 @@ #include #include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include #include +#include #include -#include +#include +#include + +#include "../IntegrationGraphVerificationHarness.hpp" + +using namespace hipdnn_frontend; +using namespace hipdnn_frontend::graph; +using namespace hipdnn_data_sdk::utilities; +using namespace hipdnn_test_sdk::utilities; +using namespace hip_kernel_provider::test_utilities; /** * @file IntegrationGpuKernelIngestor.cpp - * @brief The kernel ingestor, end to end across the plugin ABI. + * @brief The kernel ingestor pack, end to end through the hipDNN frontend API. * - * Loads the real built plugin and drives every call hipDNN makes, in the order it makes - * them: applicability, engine details, workspace, execution context, execute. Nothing - * here links the provider's internals — the only surface used is the C ABI a host would - * use — so this is what proves the descriptor set, the matchers, the heuristic, the - * dispatch handler, and the engine registration actually compose. + * Every sibling engine test drives `hipdnn_frontend::graph::Graph` through the harness; + * this one used to hand-roll the plugin C ABI instead (dlopen, flatbuffer graph + * builders, raw device buffers). That duplicated machinery the frontend already owns + * and tested a door hipDNN itself never walks through in production. This file drives + * the same door every other engine's E2E test does -- Graph::pointwise(), + * get_ranked_engine_ids(), get_knobs_for_engine(), get_workspace_size(), execute() -- + * so it proves the descriptor set, the matchers, the heuristic, and the dispatch + * handler compose the way a real caller reaches them. * - * The unit tests cover each of those pieces in isolation. What they cannot show is that - * the pieces agree with one another through the ABI, which is the whole claim of a - * data-driven ingestor. + * One exception: `hipdnnEnginePluginGetAllEngineIds` enumerates every engine a plugin + * exports, independent of any graph. The frontend has no equivalent -- it only ever + * ranks engines applicable to a graph that was actually built -- so that one assertion + * still crosses the raw ABI via a minimal dlopen helper. */ namespace hip_kernel_provider::kernel_ingestor_engine::integration { @@ -43,359 +59,222 @@ namespace hip_kernel_provider::kernel_ingestor_engine::integration namespace { -namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; - -constexpr int64_t INPUT_A_UID = 1; -constexpr int64_t INPUT_B_UID = 2; -constexpr int64_t OUTPUT_UID = 3; constexpr const char* ENGINE_NAME = "hipkernel:PointwiseAdd"; constexpr const char* BLOCK_SIZE_KNOB = "block_size"; /// Workspace the pack's larger-block kernel declares, which the engine reports as the /// maximum across its surviving kernels. -constexpr size_t EXPECTED_WORKSPACE_BYTES = 1024; +constexpr int64_t EXPECTED_WORKSPACE_BYTES = 1024; -/// A serialized single-node pointwise-ADD graph over 1-element 4-D FLOAT tensors: the -/// one shape this pack accepts. -flatbuffers::DetachedBuffer buildPointwiseAddGraph(bool withIdentity = true) +std::shared_ptr makeScalarTensor(int64_t uid, const std::string& name) { - flatbuffers::FlatBufferBuilder builder; - const std::vector dims = {1, 1, 1, 1}; - const std::vector strides = {1, 1, 1, 1}; - - std::vector> tensors; - for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID}) - { - tensors.push_back(data_objects::CreateTensorAttributesDirect( - builder, uid, nullptr, data_objects::DataType::FLOAT, &strides, &dims, false)); - } - - data_objects::PointwiseAttributesBuilder attributesBuilder(builder); - attributesBuilder.add_operation(data_objects::PointwiseMode::ADD); - attributesBuilder.add_in_0_tensor_uid(INPUT_A_UID); - attributesBuilder.add_in_1_tensor_uid(INPUT_B_UID); - attributesBuilder.add_out_0_tensor_uid(OUTPUT_UID); - auto attributes = attributesBuilder.Finish(); - - std::vector> nodes; - nodes.push_back( - data_objects::CreateNodeDirect(builder, - "pointwise_add", - data_objects::DataType::FLOAT, - data_objects::NodeAttributes::PointwiseAttributes, - attributes.Union())); - - auto name = builder.CreateString("pointwise_add"); - auto tensorsVector = builder.CreateVector(tensors); - auto nodesVector = builder.CreateVector(nodes); - - // A finalized graph carries an identity, which is what the provider keys its - // catalog cache on. A graph without one still matches, just uncached. - hipdnn_flatbuffers_sdk::utilities::UuidBytes idBytes{}; - idBytes.fill(0x42); - const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(idBytes); - - data_objects::GraphBuilder graphBuilder(builder); - graphBuilder.add_name(name); - graphBuilder.add_tensors(tensorsVector); - graphBuilder.add_nodes(nodesVector); - if(withIdentity) - { - graphBuilder.add_id(&uuid); - } - builder.Finish(graphBuilder.Finish()); - - return builder.Release(); + auto tensor = std::make_shared(); + tensor->set_uid(uid) + .set_name(name) + .set_dim({1, 1, 1, 1}) + .set_stride({1, 1, 1, 1}) + .set_data_type(DataType::FLOAT); + return tensor; } -/// A graph this pack must decline: two nodes, so no single prebuilt kernel serves it. -flatbuffers::DetachedBuffer buildUnsupportedGraph() +/// A single pointwise-ADD node over 1-element FLOAT tensors: the one shape this pack +/// accepts. Each call returns a fresh graph so tests never share build/plan state. +std::shared_ptr buildPointwiseAddGraph() { - flatbuffers::FlatBufferBuilder builder; - const std::vector dims = {1, 1, 1, 1}; - const std::vector strides = {1, 1, 1, 1}; - constexpr int64_t INTERMEDIATE_UID = 4; + auto graph = std::make_shared(); + graph->set_name("pointwise_add") + .set_io_data_type(DataType::FLOAT) + .set_intermediate_data_type(DataType::FLOAT) + .set_compute_data_type(DataType::FLOAT); - std::vector> tensors; - for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID, INTERMEDIATE_UID}) - { - tensors.push_back(data_objects::CreateTensorAttributesDirect(builder, - uid, - nullptr, - data_objects::DataType::FLOAT, - &strides, - &dims, - uid == INTERMEDIATE_UID)); - } + auto a = makeScalarTensor(1, "A"); + auto b = makeScalarTensor(2, "B"); - std::vector> nodes; - for(const auto& uids : - std::vector>{{INPUT_A_UID, INPUT_B_UID, INTERMEDIATE_UID}, - {INTERMEDIATE_UID, INPUT_B_UID, OUTPUT_UID}}) - { - data_objects::PointwiseAttributesBuilder attributesBuilder(builder); - attributesBuilder.add_operation(data_objects::PointwiseMode::ADD); - attributesBuilder.add_in_0_tensor_uid(uids[0]); - attributesBuilder.add_in_1_tensor_uid(uids[1]); - attributesBuilder.add_out_0_tensor_uid(uids[2]); - auto attributes = attributesBuilder.Finish(); - - nodes.push_back( - data_objects::CreateNodeDirect(builder, - "pointwise_add", - data_objects::DataType::FLOAT, - data_objects::NodeAttributes::PointwiseAttributes, - attributes.Union())); - } - - builder.Finish(data_objects::CreateGraphDirect(builder, - "two_node_pointwise", - data_objects::DataType::FLOAT, - data_objects::DataType::FLOAT, - data_objects::DataType::FLOAT, - &tensors, - &nodes)); + PointwiseAttributes attrs; + attrs.set_name("pointwise_add").set_mode(PointwiseMode::ADD); + auto c = graph->pointwise(a, b, attrs); + c->set_uid(3).set_name("C").set_output(true).set_data_type(DataType::FLOAT); - return builder.Release(); -} - -/// An engine config naming this engine, with no knob settings. -flatbuffers::DetachedBuffer buildEngineConfig(int64_t engineId) -{ - flatbuffers::FlatBufferBuilder builder; - data_objects::EngineConfigBuilder configBuilder(builder); - configBuilder.add_engine_id(engineId); - builder.Finish(configBuilder.Finish()); - return builder.Release(); + return graph; } -hipdnnPluginConstData_t asConstData(const flatbuffers::DetachedBuffer& buffer) +/// A graph this pack must decline: two nodes, so no single prebuilt kernel serves it. +std::shared_ptr buildUnsupportedGraph() { - return {buffer.data(), buffer.size()}; + auto graph = std::make_shared(); + graph->set_name("two_node_pointwise") + .set_io_data_type(DataType::FLOAT) + .set_intermediate_data_type(DataType::FLOAT) + .set_compute_data_type(DataType::FLOAT); + + auto a = makeScalarTensor(1, "A"); + auto b = makeScalarTensor(2, "B"); + + PointwiseAttributes attrs1; + attrs1.set_name("add_1").set_mode(PointwiseMode::ADD); + auto intermediate = graph->pointwise(a, b, attrs1); + intermediate->set_uid(4).set_name("Intermediate").set_data_type(DataType::FLOAT); + + PointwiseAttributes attrs2; + attrs2.set_name("add_2").set_mode(PointwiseMode::ADD); + auto c = graph->pointwise(intermediate, b, attrs2); + c->set_uid(3).set_name("C").set_output(true).set_data_type(DataType::FLOAT); + + return graph; } -/// Device buffers for one 1-element add, freed on scope exit. -class AddBuffers +/// One case per execute() shape: a single call, and several reusing the same built +/// plan. Both must produce numerically correct results; only the second proves reuse. +struct ExecuteCase { -public: - AddBuffers(float a, float b) - { - EXPECT_EQ(hipMalloc(&_a, sizeof(float)), hipSuccess); - EXPECT_EQ(hipMalloc(&_b, sizeof(float)), hipSuccess); - EXPECT_EQ(hipMalloc(&_c, sizeof(float)), hipSuccess); - EXPECT_EQ(hipMemcpy(_a, &a, sizeof(float), hipMemcpyHostToDevice), hipSuccess); - EXPECT_EQ(hipMemcpy(_b, &b, sizeof(float), hipMemcpyHostToDevice), hipSuccess); - } - - ~AddBuffers() - { - static_cast(hipFree(_a)); - static_cast(hipFree(_b)); - static_cast(hipFree(_c)); - } - - AddBuffers(const AddBuffers&) = delete; - AddBuffers& operator=(const AddBuffers&) = delete; - - std::array descriptors() const - { - return {hipdnnPluginDeviceBuffer_t{INPUT_A_UID, _a}, - hipdnnPluginDeviceBuffer_t{INPUT_B_UID, _b}, - hipdnnPluginDeviceBuffer_t{OUTPUT_UID, _c}}; - } - - float readResult() const - { - float result = 0.0f; - EXPECT_EQ(hipMemcpy(&result, _c, sizeof(float), hipMemcpyDeviceToHost), hipSuccess); - return result; - } - -private: - void* _a = nullptr; - void* _b = nullptr; - void* _c = nullptr; + std::string name; + int iterations; }; -/** - * @brief The plugin's exported C API, resolved from the built shared object. - * - * The provider exports these symbols for hipDNN to load at run time; nothing links - * against them. Resolving them the same way is what makes this test cross the real ABI - * boundary rather than reaching into the provider's internals. - */ -class PluginApi +} // namespace + +class IntegrationGpuKernelIngestor + : public hip_kernel_provider::test_utilities::IntegrationGraphVerificationHarness { -public: - PluginApi() +protected: + static int64_t engineId() { - // PLUGIN_PATH names the CMake target, so the platform's library prefix and - // extension have to be applied to reach the file on disk. - const std::filesystem::path pluginTarget(PLUGIN_PATH); - const auto pluginFile = hipdnn_data_sdk::utilities::LIB_PREFIX - + pluginTarget.filename().string() - + hipdnn_data_sdk::utilities::SHARED_LIB_EXT; - const auto pluginPath = std::filesystem::weakly_canonical( - hipdnn_data_sdk::utilities::getCurrentExecutableDirectory() / pluginTarget.parent_path() - / pluginFile); - _library = hipdnn_data_sdk::utilities::openLibrary(pluginPath); - - create = resolve("hipdnnEnginePluginCreate"); - destroy = resolve("hipdnnEnginePluginDestroy"); - setStream = resolve("hipdnnEnginePluginSetStream"); - getAllEngineIds = resolve( - "hipdnnEnginePluginGetAllEngineIds"); - getApplicableEngineIds = resolve( - "hipdnnEnginePluginGetApplicableEngineIds"); - getEngineDetails = resolve( - "hipdnnEnginePluginGetEngineDetails"); - destroyEngineDetails = resolve( - "hipdnnEnginePluginDestroyEngineDetails"); - getWorkspaceSize = resolve( - "hipdnnEnginePluginGetWorkspaceSize"); - createExecutionContext = resolve( - "hipdnnEnginePluginCreateExecutionContext"); - destroyExecutionContext = resolve( - "hipdnnEnginePluginDestroyExecutionContext"); - getWorkspaceSizeFromExecutionContext - = resolve( - "hipdnnEnginePluginGetWorkspaceSizeFromExecutionContext"); - executeOpGraph = resolve( - "hipdnnEnginePluginExecuteOpGraph"); + return hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); } - decltype(&hipdnnEnginePluginCreate) create = nullptr; - decltype(&hipdnnEnginePluginDestroy) destroy = nullptr; - decltype(&hipdnnEnginePluginSetStream) setStream = nullptr; - decltype(&hipdnnEnginePluginGetAllEngineIds) getAllEngineIds = nullptr; - decltype(&hipdnnEnginePluginGetApplicableEngineIds) getApplicableEngineIds = nullptr; - decltype(&hipdnnEnginePluginGetEngineDetails) getEngineDetails = nullptr; - decltype(&hipdnnEnginePluginDestroyEngineDetails) destroyEngineDetails = nullptr; - decltype(&hipdnnEnginePluginGetWorkspaceSize) getWorkspaceSize = nullptr; - decltype(&hipdnnEnginePluginCreateExecutionContext) createExecutionContext = nullptr; - decltype(&hipdnnEnginePluginDestroyExecutionContext) destroyExecutionContext = nullptr; - decltype(&hipdnnEnginePluginGetWorkspaceSizeFromExecutionContext) - getWorkspaceSizeFromExecutionContext - = nullptr; - decltype(&hipdnnEnginePluginExecuteOpGraph) executeOpGraph = nullptr; - -private: - template - T resolve(const char* name) const + /// Builds `graph`, pins this pack via set_preferred_engine_id_ext() before plan + /// creation (the precedent at IntegrationGraphEngineFiltering.cpp:159), and + /// compiles a plan with default knobs. Returns once the plan is ready to execute. + void buildAndCompile(Graph& graph) { - auto* symbol = hipdnn_data_sdk::utilities::getSymbol(_library, name); - if(symbol == nullptr) - { - throw std::runtime_error("plugin does not export " + std::string(name)); - } - // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) - return reinterpret_cast(symbol); - } - - hipdnn_data_sdk::utilities::SharedLibraryHandle _library = nullptr; -}; + graph.set_preferred_engine_id_ext(engineId()); -/// One process-wide load, since a plugin is loaded once and shared by every handle. -const PluginApi& pluginApi() -{ - static const PluginApi s_api; - return s_api; -} + auto result = graph.build_operation_graph(_handle); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; -class IntegrationGpuKernelIngestor : public ::testing::Test -{ -protected: - void SetUp() override - { - SKIP_IF_NO_DEVICES(); + result = graph.create_execution_plans(); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; - ASSERT_EQ(pluginApi().create(&_handle), HIPDNN_PLUGIN_STATUS_SUCCESS); - ASSERT_EQ(hipStreamCreate(&_stream), hipSuccess); - ASSERT_EQ(pluginApi().setStream(_handle, _stream), HIPDNN_PLUGIN_STATUS_SUCCESS); + result = graph.check_support(); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; - _engineId = hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); + result = graph.build_plans(); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; } - void TearDown() override + /// Builds fresh CPU/GPU tensor bundles for `graph`, executes it once on GPU with + /// `workspace`, and verifies the result against CpuReferenceGraphExecutor. `seed` + /// varies the input values so repeated calls never compare against stale buffers. + void executeAndVerify(Graph& graph, void* workspace, unsigned int seed) { - if(_handle != nullptr) + GraphTensorBundle gpuBundle; + GraphTensorBundle cpuBundle; + graph.visit([&](const INode& node) { + for(const auto& tensorAttr : node.getNodeOutputTensorAttributes()) + { + gpuBundle.addTensor(*tensorAttr, createTensorFromAttribute(*tensorAttr)); + cpuBundle.addTensor(*tensorAttr, createTensorFromAttribute(*tensorAttr)); + } + for(const auto& tensorAttr : node.getNodeInputTensorAttributes()) + { + if(gpuBundle.tensors.find(tensorAttr->get_uid()) == gpuBundle.tensors.end()) + { + gpuBundle.addTensor(*tensorAttr, createTensorFromAttribute(*tensorAttr)); + cpuBundle.addTensor(*tensorAttr, createTensorFromAttribute(*tensorAttr)); + } + } + }); + for(auto& [uid, tensor] : gpuBundle.tensors) { - EXPECT_EQ(pluginApi().destroy(_handle), HIPDNN_PLUGIN_STATUS_SUCCESS); + gpuBundle.randomizeTensor(uid, -4.0f, 4.0f, seed); + cpuBundle.randomizeTensor(uid, -4.0f, 4.0f, seed); } - if(_stream != nullptr) - { - EXPECT_EQ(hipStreamDestroy(_stream), hipSuccess); - } - } - - std::vector applicableEngines(const hipdnnPluginConstData_t& graph) const - { - uint32_t count = 0; - EXPECT_EQ(pluginApi().getApplicableEngineIds(_handle, &graph, nullptr, 0, &count), - HIPDNN_PLUGIN_STATUS_SUCCESS); - std::vector engines(count); - if(count > 0) - { - EXPECT_EQ( - pluginApi().getApplicableEngineIds(_handle, &graph, engines.data(), count, &count), - HIPDNN_PLUGIN_STATUS_SUCCESS); - } - return engines; - } + auto deviceVariantPack = gpuBundle.toDeviceVariantPack(); + auto result = graph.execute(_handle, deviceVariantPack, workspace); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; + ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); - bool isApplicable(const hipdnnPluginConstData_t& graph) const - { - const auto engines = applicableEngines(graph); - return std::find(engines.begin(), engines.end(), _engineId) != engines.end(); + auto [serializedGraph, serErr] = graph.to_binary(); + ASSERT_TRUE(serErr.is_good()) << serErr.get_message(); + CpuReferenceGraphExecutor().execute( + serializedGraph.data(), serializedGraph.size(), cpuBundle.toHostVariantPack()); + + auto& gpuOut = gpuBundle.getTensor(3); + auto& cpuOut = cpuBundle.getTensor(3); + gpuOut.markDeviceModified(); + // The whole chain, end to end: the matchers admitted this graph, the heuristic + // ranked the catalog, the dispatch descriptor's handler compiled and launched + // the winner, and its arguments were resolved by tensor uid. + EXPECT_TRUE(CpuFpReferenceValidation().allClose(cpuOut, gpuOut)); } - - hipdnnEnginePluginHandle_t _handle = nullptr; - hipStream_t _stream = nullptr; - int64_t _engineId = 0; }; // --------------------------------------------------------------------------- -// Applicability +// Direct ABI: load-time self-registration // --------------------------------------------------------------------------- -TEST_F(IntegrationGpuKernelIngestor, ExposesTheDescriptorBackedEngineToTheHost) +// The only assertion with no frontend equivalent: the frontend never lists "every +// engine this plugin exports", only engines ranked for a graph that was built. Proving +// the pack self-registers at load time, independent of any graph, still needs the raw +// C ABI -- kept minimal rather than reusing the deleted PluginApi wrapper. +TEST(IntegrationGpuKernelIngestorDirectAbi, SelfRegistersAllEngineIds) { - // The engine's id is its descriptor name hashed into hipDNN's id space, registered - // when the engine was constructed rather than by a compile-time macro. - uint32_t count = 0; - ASSERT_EQ(pluginApi().getAllEngineIds(nullptr, 0, &count), HIPDNN_PLUGIN_STATUS_SUCCESS); + const std::filesystem::path pluginTarget(PLUGIN_PATH); + const auto pluginFile = hipdnn_data_sdk::utilities::LIB_PREFIX + + pluginTarget.filename().string() + + hipdnn_data_sdk::utilities::SHARED_LIB_EXT; + const auto pluginPath = std::filesystem::weakly_canonical( + hipdnn_data_sdk::utilities::getCurrentExecutableDirectory() / pluginTarget.parent_path() + / pluginFile); + auto* library = hipdnn_data_sdk::utilities::openLibrary(pluginPath); + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + auto* getAllEngineIds = reinterpret_cast( + hipdnn_data_sdk::utilities::getSymbol(library, "hipdnnEnginePluginGetAllEngineIds")); + ASSERT_NE(getAllEngineIds, nullptr); + uint32_t count = 0; + ASSERT_EQ(getAllEngineIds(nullptr, 0, &count), HIPDNN_PLUGIN_STATUS_SUCCESS); std::vector engines(count); - ASSERT_EQ(pluginApi().getAllEngineIds(engines.data(), count, &count), - HIPDNN_PLUGIN_STATUS_SUCCESS); + ASSERT_EQ(getAllEngineIds(engines.data(), count, &count), HIPDNN_PLUGIN_STATUS_SUCCESS); - EXPECT_NE(std::find(engines.begin(), engines.end(), _engineId), engines.end()); + EXPECT_NE(std::find(engines.begin(), engines.end(), engineNameToId(ENGINE_NAME)), + engines.end()); } +// --------------------------------------------------------------------------- +// Applicability +// --------------------------------------------------------------------------- + TEST_F(IntegrationGpuKernelIngestor, AcceptsTheGraphItsDescriptorsDescribe) { - const auto graph = buildPointwiseAddGraph(); + auto graph = buildPointwiseAddGraph(); - EXPECT_TRUE(isApplicable(asConstData(graph))); -} + auto result = graph->build_operation_graph(_handle); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; -TEST_F(IntegrationGpuKernelIngestor, DeclinesAGraphNoKernelInTheCatalogServes) -{ - // Declining is free: an empty catalog answers false and hipDNN moves on. Getting - // this wrong is what turns a cheap decline into a failed plan build. - const auto graph = buildUnsupportedGraph(); + std::vector rankedEngineIds; + result = graph->get_ranked_engine_ids(rankedEngineIds); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; - EXPECT_FALSE(isApplicable(asConstData(graph))); + EXPECT_NE(std::find(rankedEngineIds.begin(), rankedEngineIds.end(), engineId()), + rankedEngineIds.end()); } -TEST_F(IntegrationGpuKernelIngestor, AcceptsAGraphCarryingNoIdentity) +TEST_F(IntegrationGpuKernelIngestor, DeclinesATwoNodeGraph) { - // Without a graph id the catalog cannot be cached, but matching is unaffected: the - // answer must be the same, only recomputed. - const auto graph = buildPointwiseAddGraph(/*withIdentity=*/false); + // Declining is free: no engine config comes back and hipDNN moves on. Getting this + // wrong is what turns a cheap decline into a failed plan build. + auto graph = buildUnsupportedGraph(); - EXPECT_TRUE(isApplicable(asConstData(graph))); + auto result = graph->build_operation_graph(_handle); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; + + std::vector rankedEngineIds; + result = graph->get_ranked_engine_ids(rankedEngineIds); + EXPECT_EQ(result.code, ErrorCode::GRAPH_NOT_SUPPORTED); + EXPECT_TRUE(rankedEngineIds.empty()); } // --------------------------------------------------------------------------- @@ -404,40 +283,30 @@ TEST_F(IntegrationGpuKernelIngestor, AcceptsAGraphCarryingNoIdentity) TEST_F(IntegrationGpuKernelIngestor, ReportsAKnobWhoseValuesComeFromTheCatalog) { - const auto graph = buildPointwiseAddGraph(); - const auto graphData = asConstData(graph); - - hipdnnPluginConstData_t details{}; - ASSERT_EQ(pluginApi().getEngineDetails(_handle, _engineId, &graphData, &details), - HIPDNN_PLUGIN_STATUS_SUCCESS); + auto graph = buildPointwiseAddGraph(); - const auto* engineDetails = flatbuffers::GetRoot(details.ptr); - ASSERT_NE(engineDetails, nullptr); - ASSERT_NE(engineDetails->knobs(), nullptr); - ASSERT_EQ(engineDetails->knobs()->size(), 1U); + auto result = graph->build_operation_graph(_handle); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; - const auto* knob = engineDetails->knobs()->Get(0); - ASSERT_NE(knob->knob_id(), nullptr); - EXPECT_EQ(knob->knob_id()->str(), BLOCK_SIZE_KNOB); + std::vector knobs; + result = graph->get_knobs_for_engine(engineId(), knobs); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; + ASSERT_EQ(knobs.size(), 1U); + EXPECT_EQ(knobs[0].knobId(), BLOCK_SIZE_KNOB); // The pack ships three kernels, but the HALF one is pruned for this FLOAT graph, so // the knob offers exactly the two block sizes the surviving kernels implement -- // never the schema's theoretical range. - const auto* constraint = knob->constraint_as_IntConstraint(); + const auto* constraint = dynamic_cast(knobs[0].constraint()); ASSERT_NE(constraint, nullptr); - ASSERT_NE(constraint->valid_values(), nullptr); - std::vector values(constraint->valid_values()->begin(), - constraint->valid_values()->end()); - std::sort(values.begin(), values.end()); - EXPECT_EQ(values, (std::vector{64, 256})); + const auto& validValues = constraint->getValidValues(); + EXPECT_EQ(validValues, (std::unordered_set{64, 256})); // The default is whatever the heuristic ranked first, so leaving the knob alone // reproduces the out-of-the-box selection. - const auto* defaultValue = knob->default_value_as_IntValue(); + const auto* defaultValue = std::get_if(&knobs[0].defaultValue()); ASSERT_NE(defaultValue, nullptr); - EXPECT_EQ(defaultValue->value(), 256); - - EXPECT_EQ(pluginApi().destroyEngineDetails(_handle, &details), HIPDNN_PLUGIN_STATUS_SUCCESS); + EXPECT_EQ(*defaultValue, 256); } // --------------------------------------------------------------------------- @@ -446,17 +315,14 @@ TEST_F(IntegrationGpuKernelIngestor, ReportsAKnobWhoseValuesComeFromTheCatalog) TEST_F(IntegrationGpuKernelIngestor, ReportsTheMaximumWorkspaceAcrossSurvivingKernels) { - const auto graph = buildPointwiseAddGraph(); - const auto config = buildEngineConfig(_engineId); - const auto graphData = asConstData(graph); - const auto configData = asConstData(config); - - size_t workspaceSize = 0; - ASSERT_EQ(pluginApi().getWorkspaceSize(_handle, &configData, &graphData, &workspaceSize), - HIPDNN_PLUGIN_STATUS_SUCCESS); + auto graph = buildPointwiseAddGraph(); + buildAndCompile(*graph); // One surviving kernel declares 0 bytes and the other 1024, so this answer proves // the query aggregates across the catalog rather than reporting one kernel's value. + int64_t workspaceSize = 0; + auto result = graph->get_workspace_size(workspaceSize); + ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; EXPECT_EQ(workspaceSize, EXPECTED_WORKSPACE_BYTES); } @@ -464,131 +330,58 @@ TEST_F(IntegrationGpuKernelIngestor, ReportsTheMaximumWorkspaceAcrossSurvivingKe // Plan build and execute // --------------------------------------------------------------------------- -TEST_F(IntegrationGpuKernelIngestor, ExecutesTheSelectedKernelOnDevice) +// Collapses ExecutesTheSelectedKernelOnDevice and ReusesOnePlanAcrossExecutions from +// the original file: one case executes once, the other executes the same compiled +// plan repeatedly with fresh buffers each time. Every decision the plan makes happens +// at build, so execution must never depend on the previous call. +TEST_P(IntegrationGpuKernelIngestor, ExecutesTheSelectedKernelOnDevice) { - const auto graph = buildPointwiseAddGraph(); - const auto config = buildEngineConfig(_engineId); - const auto graphData = asConstData(graph); - const auto configData = asConstData(config); - - hipdnnEnginePluginExecutionContext_t context = nullptr; - ASSERT_EQ(pluginApi().createExecutionContext(_handle, &configData, &graphData, &context), - HIPDNN_PLUGIN_STATUS_SUCCESS); - ASSERT_NE(context, nullptr); - - size_t workspaceSize = 0; - ASSERT_EQ(pluginApi().getWorkspaceSizeFromExecutionContext(_handle, context, &workspaceSize), - HIPDNN_PLUGIN_STATUS_SUCCESS); - - void* workspace = nullptr; - if(workspaceSize > 0) - { - ASSERT_EQ(hipMalloc(&workspace, workspaceSize), hipSuccess); - } + const auto& testCase = GetParam(); - const AddBuffers buffers(3.0f, 4.0f); - const auto descriptors = buffers.descriptors(); + auto graph = buildPointwiseAddGraph(); + buildAndCompile(*graph); - ASSERT_EQ(pluginApi().executeOpGraph( - _handle, context, workspace, descriptors.data(), descriptors.size()), - HIPDNN_PLUGIN_STATUS_SUCCESS); - ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); + int64_t workspaceSize = 0; + ASSERT_EQ(graph->get_workspace_size(workspaceSize).code, ErrorCode::OK); + const hipdnn_data_sdk::utilities::Workspace workspace(static_cast(workspaceSize)); - // The whole chain, end to end: the matchers admitted this graph, the heuristic - // ranked the catalog, the dispatch descriptor's handler compiled and launched the - // winner, and its arguments were resolved by tensor uid. - EXPECT_FLOAT_EQ(buffers.readResult(), 7.0f); - - EXPECT_EQ(pluginApi().destroyExecutionContext(_handle, context), HIPDNN_PLUGIN_STATUS_SUCCESS); - if(workspace != nullptr) - { - EXPECT_EQ(hipFree(workspace), hipSuccess); - } -} - -TEST_F(IntegrationGpuKernelIngestor, ReusesOnePlanAcrossExecutions) -{ - const auto graph = buildPointwiseAddGraph(); - const auto config = buildEngineConfig(_engineId); - const auto graphData = asConstData(graph); - const auto configData = asConstData(config); - - hipdnnEnginePluginExecutionContext_t context = nullptr; - ASSERT_EQ(pluginApi().createExecutionContext(_handle, &configData, &graphData, &context), - HIPDNN_PLUGIN_STATUS_SUCCESS); - - size_t workspaceSize = 0; - ASSERT_EQ(pluginApi().getWorkspaceSizeFromExecutionContext(_handle, context, &workspaceSize), - HIPDNN_PLUGIN_STATUS_SUCCESS); - void* workspace = nullptr; - if(workspaceSize > 0) - { - ASSERT_EQ(hipMalloc(&workspace, workspaceSize), hipSuccess); - } - - // A plan is built once and executed many times with different buffers; every - // decision was made at build, so execution must not depend on the previous one. - for(const auto& values : std::vector>{ - {1.0f, 2.0f, 3.0f}, {-5.0f, 2.5f, -2.5f}, {0.125f, 0.375f, 0.5f}}) - { - const AddBuffers buffers(values[0], values[1]); - const auto descriptors = buffers.descriptors(); - - ASSERT_EQ(pluginApi().executeOpGraph( - _handle, context, workspace, descriptors.data(), descriptors.size()), - HIPDNN_PLUGIN_STATUS_SUCCESS); - ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); - - EXPECT_FLOAT_EQ(buffers.readResult(), values[2]); - } - - EXPECT_EQ(pluginApi().destroyExecutionContext(_handle, context), HIPDNN_PLUGIN_STATUS_SUCCESS); - if(workspace != nullptr) + for(int iteration = 0; iteration < testCase.iterations; ++iteration) { - EXPECT_EQ(hipFree(workspace), hipSuccess); + executeAndVerify(*graph, workspace.get(), static_cast(iteration)); } } -TEST_F(IntegrationGpuKernelIngestor, ExecutesAGraphThatCannotBeCached) +// hipDNN never exposes a caller-settable graph identity through the frontend: +// GraphDescriptor::finalize() always synthesizes a fresh UUID, so every graph built +// through Graph::pointwise() is a guaranteed catalog miss relative to every other one. +// This is the frontend-reachable form of the original file's uncacheable-graph +// coverage: two independently built graphs, each compiled and executed in the same +// test body, both must still match the reference executor -- proving the recompute +// path is correct rather than accidentally relying on state left by a prior graph. +TEST_F(IntegrationGpuKernelIngestor, ExecutesTwoIndependentlyBuiltGraphsCorrectly) { - // Same result through the uncached path: a graph with no identity is rematched on - // every query, which costs time and never correctness. - const auto graph = buildPointwiseAddGraph(/*withIdentity=*/false); - const auto config = buildEngineConfig(_engineId); - const auto graphData = asConstData(graph); - const auto configData = asConstData(config); - - hipdnnEnginePluginExecutionContext_t context = nullptr; - ASSERT_EQ(pluginApi().createExecutionContext(_handle, &configData, &graphData, &context), - HIPDNN_PLUGIN_STATUS_SUCCESS); - - size_t workspaceSize = 0; - ASSERT_EQ(pluginApi().getWorkspaceSizeFromExecutionContext(_handle, context, &workspaceSize), - HIPDNN_PLUGIN_STATUS_SUCCESS); - void* workspace = nullptr; - if(workspaceSize > 0) - { - ASSERT_EQ(hipMalloc(&workspace, workspaceSize), hipSuccess); - } - - const AddBuffers buffers(2.5f, 6.5f); - const auto descriptors = buffers.descriptors(); - - ASSERT_EQ(pluginApi().executeOpGraph( - _handle, context, workspace, descriptors.data(), descriptors.size()), - HIPDNN_PLUGIN_STATUS_SUCCESS); - ASSERT_EQ(hipStreamSynchronize(_stream), hipSuccess); - - EXPECT_FLOAT_EQ(buffers.readResult(), 9.0f); - - EXPECT_EQ(pluginApi().destroyExecutionContext(_handle, context), HIPDNN_PLUGIN_STATUS_SUCCESS); - if(workspace != nullptr) - { - EXPECT_EQ(hipFree(workspace), hipSuccess); - } + auto graphA = buildPointwiseAddGraph(); + buildAndCompile(*graphA); + int64_t workspaceSizeA = 0; + ASSERT_EQ(graphA->get_workspace_size(workspaceSizeA).code, ErrorCode::OK); + const hipdnn_data_sdk::utilities::Workspace workspaceA(static_cast(workspaceSizeA)); + executeAndVerify(*graphA, workspaceA.get(), 0); + + auto graphB = buildPointwiseAddGraph(); + buildAndCompile(*graphB); + int64_t workspaceSizeB = 0; + ASSERT_EQ(graphB->get_workspace_size(workspaceSizeB).code, ErrorCode::OK); + const hipdnn_data_sdk::utilities::Workspace workspaceB(static_cast(workspaceSizeB)); + executeAndVerify(*graphB, workspaceB.get(), 1); } -} // namespace +INSTANTIATE_TEST_SUITE_P(, + IntegrationGpuKernelIngestor, + ::testing::Values(ExecuteCase{"SingleExecute", 1}, + ExecuteCase{"RepeatedExecute", 3}), + [](const ::testing::TestParamInfo& info) { + return info.param.name; + }); } // namespace hip_kernel_provider::kernel_ingestor_engine::integration diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index 098fcf572226..c20e72e21372 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -65,8 +65,8 @@ KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) kernel.kernelId = parseUuid("00000000-0000-4000-8000-000000000001"); kernel.packId = parseUuid("00000000-0000-4000-8000-000000000002"); kernel.dispatchId = parseUuid("00000000-0000-4000-8000-000000000003"); - kernel.sourceFile = "PointwiseAdd.cpp"; - kernel.entryPoint = "PointwiseAdd"; + kernel.source.sourceFile = "PointwiseAdd.cpp"; + kernel.source.entryPoint = "PointwiseAdd"; kernel.metadata = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; return kernel; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index caf366bcbbb7..8b7406a76719 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -57,8 +57,8 @@ KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) kernel.kernelId = parseUuid("00000000-0000-4000-8000-000000000001"); kernel.packId = parseUuid("00000000-0000-4000-8000-000000000002"); kernel.dispatchId = parseUuid("00000000-0000-4000-8000-000000000003"); - kernel.sourceFile = "PointwiseAdd.cpp"; - kernel.entryPoint = "PointwiseAdd"; + kernel.source.sourceFile = "PointwiseAdd.cpp"; + kernel.source.entryPoint = "PointwiseAdd"; kernel.metadata = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; return kernel; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp index c1cdd47616b0..593a4e37a831 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp @@ -51,8 +51,10 @@ TEST(TestPointwiseAddPack, EveryKernelNamesTheEmbeddedSource) for(const auto& kernel : set.packs.front().kernels) { - EXPECT_EQ(kernel.sourceFile, "PointwiseAdd.cpp"); - EXPECT_EQ(kernel.entryPoint, "PointwiseAdd"); + EXPECT_EQ(kernel.source.kind, + hipdnn_plugin_sdk::ingestor::KernelSourceKind::EMBEDDED_SOURCE); + EXPECT_EQ(kernel.source.sourceFile, "PointwiseAdd.cpp"); + EXPECT_EQ(kernel.source.entryPoint, "PointwiseAdd"); } } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp index 82ea758f3c2e..7a23036b969a 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp @@ -33,6 +33,13 @@ struct Catalog /// bound token state alongside the catalog, so a plan build reads these rather than /// re-running a matcher to recover them. /// + /// "The packs that survived" is enforced by KernelIngestorStateManager::buildCatalog(), + /// which accumulates each pack's bindings in a pack-scoped view and merges it into + /// this map only once that pack's own graph-scoped matchers all pass; a pruned + /// pack's view is discarded unmerged. A shared matcher's bindings still reach every + /// surviving pack that lists it, because the memo each pack merges from is keyed by + /// matcher id, not by pack. + /// /// Merged rather than kept per pack: a token name means the same thing to every pack /// in an engine, since they all read one graph through one metadata schema. Two packs /// binding one name to different values would be an authoring error the loader should diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index d7ac39e8ffa2..7109761096e3 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -78,23 +78,30 @@ struct DescriptorIdHash } }; -/// A value a kernel supplies for a KMD-declared field. +/// A value a kernel supplies for a KMD-declared field, and also the value type +/// `$graph.*` binds (see MatchContext.hpp's BoundTokens): RFC 0017's criteria language +/// puts one on each side of a single operator (`divisible($q.head_size, +/// $kernel.tile_m)`), so an interpreter needs one value type spanning both namespaces +/// or its first act is a refactor. Splitting them was cheap only as long as nothing +/// compared across the split. /// -/// Spans the primitive types a descriptor field can hold. `int64_t` and `double` are the -/// widest signed integer and floating-point forms, so a narrower authored value converts -/// into one without loss; `bool` is distinct because a flag compares and prints -/// differently from the integer 1. -using MetadataValue = std::variant; +/// Spans the primitive types a descriptor field or a bound graph fact can hold. +/// `int64_t` and `double` are the widest signed integer and floating-point forms, so a +/// narrower authored value converts into one without loss; `bool` is distinct because a +/// flag compares and prints differently from the integer 1. `std::vector` is +/// for list-valued facts like `stride_order` that no scalar alternative can hold. +using MetadataValue = std::variant>; -/// The type a KMD field holds. Kept as an explicit enum, and ordered to match -/// MetadataValue's alternatives, so a field can declare its type without also having to -/// supply a value of it. +/// The type a KMD field holds, or a bound graph fact's type. Kept as an explicit enum, +/// and ordered to match MetadataValue's alternatives, so a field can declare its type +/// without also having to supply a value of it. enum class MetadataType { BOOL, INT, FLOAT, STRING, + INT_LIST, }; /// @brief The type of a value, for checking one against its field's declaration. @@ -139,6 +146,22 @@ struct MetadataSchema std::vector fields; }; +/// Which adapter builds an engine's IKernelHeuristic from a UHD's `payload`. The kind +/// is the adapter dispatch point (RFC 0017 §9.1), the same role KernelSourceKind plays +/// for a UKD below: a UHD is reachable as data now instead of being the one descriptor +/// that is constructed and then stepped around in favor of a hand-built native +/// heuristic. +enum class HeuristicKind +{ + /// `payload` is a NativeRegistry score symbol, resolved into a NativeKernelHeuristic + /// (see IKernelHeuristic.hpp's makeKernelHeuristic()). The only kind with an adapter + /// today. + NATIVE, + /// A trained model artifact plus its feature signature (the UHD follow-up RFC). No + /// adapter yet. + MODEL, +}; + /// UHD: the kernel-selection model for one engine. /// /// One level below hipDNN's engine-selection heuristic: this chooses *which kernel @@ -147,9 +170,11 @@ struct HeuristicDescriptor { DescriptorId id; std::string name; - /// Resolved through NativeRegistry to a per-kernel scorer. The data-driven form - /// (a model artifact plus its feature signature) is the UHD follow-up RFC. - std::string scoreSymbol; + HeuristicKind kind = HeuristicKind::NATIVE; + /// `kind`'s payload: a NativeRegistry score symbol (kind == NATIVE), or, for a kind + /// with no adapter yet, that kind's analogous single identifier (a model artifact + /// path). + std::string payload; }; /// UED: the engine itself, carrying no logic of its own. @@ -230,6 +255,46 @@ struct DispatchDescriptor std::string dispatchSymbol; }; +/// Which adapter loads and prepares one kernel's code, given its source's payload. The +/// adapter dispatch point RFC 0017 §9.1 describes: a kpack-symbol lookup, an hsaco file +/// load, and a rocke-builder invocation are structurally different operations, not +/// three interpretations shoehorned into the same two strings. +enum class KernelSourceKind +{ + /// A named source file plus an entry point, compiled at plan-build time. What the + /// embedded-source path this engine's dispatch handler currently compiles through + /// needs, and the only kind this POC implements. + EMBEDDED_SOURCE, + /// A prebuilt kpack library plus the symbol to resolve inside it. Every shipped AOT + /// kernel converges on this kind at install time. No adapter yet. + KPACK_SYMBOL, + /// A standalone `.hsaco` code-object file. No adapter yet. + HSACO_FILE, + /// A rocke builder name plus its build values. No adapter yet. + ROCKE_BUILDER, +}; + +/// UKD's source: where a kernel's code comes from, as a tagged union over RFC 0017 +/// §7's source kinds rather than two bare strings. Shipped as two strings, the first +/// loader to need a second kind would have had to invent its own discriminator and then +/// migrate every existing descriptor to use it; the discriminator exists from the start +/// instead. +/// +/// Only `EMBEDDED_SOURCE` is implemented; the others exist as enum values with a +/// comment because a descriptor states which kind it carries independently of whether +/// this provider can load that kind yet. +struct KernelSource +{ + KernelSourceKind kind = KernelSourceKind::EMBEDDED_SOURCE; + /// `EMBEDDED_SOURCE`: the source file name. Unused, and left empty, by every other + /// kind until each grows its own payload shape (a kpack library plus symbol, an + /// hsaco path, a builder name plus build values). + std::string sourceFile; + /// `EMBEDDED_SOURCE`: the entry point within `sourceFile`. Unused by every other + /// kind, for the same reason as `sourceFile` above. + std::string entryPoint; +}; + /// UKD: one launchable kernel — a source plus concrete values for the fields its /// engine's KMD declares. Its matchers, engine, and dispatch are all its pack's, and /// its heuristic and metadata schema are that engine's, so it names none of them. @@ -237,21 +302,9 @@ struct KernelDescriptor { DescriptorId id; std::string name; - /// Where this kernel's code comes from. - /// - /// A named source file plus an entry point, which is what the embedded-source path - /// this engine currently compiles through needs. The real form is a tagged union - /// over the source kinds of RFC 0017 §7, one alternative per supported kind, so a - /// descriptor states which kind it carries rather than every kind sharing two - /// strings. - /// - /// Shipped AOT kernels converge on one of those kinds: they are always packed into - /// a kpack and loaded dynamically from it, so the field these two become is a kpack - /// library plus the symbol to resolve inside it. The other kinds (`hsaco`, `hip`, - /// `rocke`) describe how a kernel is *built*, and the build-time packaging step - /// lowers each of them to that same kpack form before install. - std::string sourceFile; - std::string entryPoint; + /// Where this kernel's code comes from and how to load it. See KernelSource for why + /// this is a tagged union rather than two bare strings. + KernelSource source; /// This kernel's values for the KMD's fields. Omitted fields take the KMD default; /// the completed tuple is this kernel's catalog key and must be unique engine-wide. MetadataValues metadata; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 524486d1ee7c..73d6c8feb606 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,18 @@ namespace hipdnn_plugin_sdk::ingestor { +/// A caller's requested value for each knob it explicitly set, keyed by knob (= KMD +/// field) name. Only integer-valued fields are expressible as knobs today (see +/// GenericPlanBuilder::getCustomKnobs), so this is the same restriction knobValues() +/// already has, not a new one. +/// +/// A `TSettings` used with GenericPlanBuilder must carry one of these named +/// `ingestorKnobFilter`, populated by initializeExecutionSettings() and read back by +/// getMaxWorkspaceSize(): that method's IPlanBuilder signature carries no +/// IEngineConfig, so TSettings is the only channel by which a knob setting can reach +/// it. buildPlan() gets IEngineConfig directly and reads the filter from there instead. +using KnobFilter = std::map; + /** * @brief The one plan builder a descriptor-backed engine has. * @@ -33,9 +46,14 @@ namespace hipdnn_plugin_sdk::ingestor * Applicability builds the catalog; the knob query and the plan build read the ranked * order; the workspace query reads the dispatch descriptors. Nothing rebuilds. * - * @tparam TSettings The provider's settings type. Carried through unchanged: knob - * settings reach the catalog as a filter (future work, see initializeExecutionSettings), - * not as provider-specific state this builder interprets. + * @tparam TSettings The provider's settings type. Must carry a `KnobFilter + * ingestorKnobFilter` member (see KnobFilter's doc for why): knob settings + * reach the catalog as a filter applied before ranking, RFC 0017 §4's + * catalog -> filter -> rank order. Filtering after GenericEngine's already- + * ranked read and taking the filtered list's front is equivalent to filtering + * first: IKernelHeuristic's score() takes one kernel at a time and is never + * handed the catalog, so filtering and ranking commute (see its doc), and a + * stable filter over an already-ranked list preserves that ranking. */ template class GenericPlanBuilder : public IPlanBuilder @@ -76,17 +94,30 @@ class GenericPlanBuilder : public IPlanBuilder */ size_t getMaxWorkspaceSize(const THandle& handle, const IGraph& opGraph, - const TSettings& /*executionSettings*/) const override + const TSettings& executionSettings) const override { const auto context = contextFor(handle, opGraph); // Unsorted, but still one lookup for the entries and the bound state together. const auto catalog = _stateManager.unsortedCatalog(context); + // Filtered before the max is taken, same contract as buildPlan: the caller's + // knob settings are a request for a subset of the catalog, not a value the + // survivors are merely ranked by. + const auto filtered + = applyKnobFilter(catalog.entries, executionSettings.ingestorKnobFilter); + if(!catalog.entries.empty() && filtered.empty()) + { + // Same failure buildPlan reports for the same reason: a workspace query and + // a plan build must not disagree about whether a knob combination is legal. + throwUnsatisfiableKnobFilter(executionSettings.ingestorKnobFilter, + catalog.entries.size()); + } + size_t maxBytes = 0; // Unsorted deliberately: a maximum over the survivors is order-independent, and // this call arrives for every candidate engine. Ranking here would load and run // each engine's heuristic to compute a number that does not depend on the order, // which is the cost the lazy-ranking split exists to avoid. - for(const auto& kernel : catalog.entries) + for(const auto& kernel : filtered) { const auto dispatcher = _stateManager.getDispatchDetails(kernel); maxBytes = std::max(maxBytes, @@ -95,43 +126,68 @@ class GenericPlanBuilder : public IPlanBuilder return maxBytes; } - /// Knob settings are read into provider settings here in the full design, where they - /// filter the catalog before ranking. The first engine exposes its knob for - /// reporting only, so there is nothing to carry across. + /** + * @brief Reads the caller's knob settings from @p engineConfig into TSettings. + * + * Populates `executionSettings.ingestorKnobFilter` so getMaxWorkspaceSize() -- which + * receives only TSettings, not an IEngineConfig -- can apply the same filter + * buildPlan() reads directly from @p engineConfig. This is the one channel by which + * a knob setting reaches that call. + */ void initializeExecutionSettings(const THandle& /*handle*/, const IGraph& /*opGraph*/, - const IEngineConfig& /*engineConfig*/, - TSettings& /*executionSettings*/) const override + const IEngineConfig& engineConfig, + TSettings& executionSettings) const override { + executionSettings.ingestorKnobFilter = readKnobFilter(engineConfig); } /** - * @brief Builds a plan for the top-ranked kernel. + * @brief Builds a plan for the top-ranked kernel satisfying every knob the caller + * set. * - * @throws HipdnnPluginException if the catalog is empty. Reaching here with nothing - * to build means applicability accepted a graph this engine cannot serve — - * RFC 0017 §8.6 makes that a bug, not a legal outcome, so it surfaces as a - * failed plan build rather than a silent decline. + * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR) if the catalog + * is empty before knob filtering. Reaching here with nothing to build means + * applicability accepted a graph this engine cannot serve at all — RFC 0017 + * §8.6 makes that a bug, not a legal outcome, so it surfaces as a failed + * plan build rather than a silent decline. Distinct from the case below: + * this is a matcher/applicability disagreement, not a bad caller request. + * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE) if the catalog + * is non-empty but knob filtering empties it. See + * throwUnsatisfiableKnobFilter() for why this is the caller's fault rather + * than the engine's: a graph this engine can serve exists, but no kernel + * implements the specific combination of values the caller asked for. */ void buildPlan(const THandle& handle, const IGraph& opGraph, - const IEngineConfig& /*engineConfig*/, + const IEngineConfig& engineConfig, TContext& executionContext) const override { const auto context = contextFor(handle, opGraph); // One lookup for both the order and the bound state: asking separately would // match twice for a graph that carries no identity and so cannot be cached. const auto catalog = _stateManager.sortedCatalog(context); - const auto& ranked = catalog.entries; - if(ranked.empty()) + if(catalog.entries.empty()) { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, "engine '" + _engine.name + "' accepted this graph but has no applicable kernel"); } + // Filtered after ranking rather than before: filtering and ranking commute (see + // this class's own tparam doc), so filtering the already-ranked list and taking + // its front is the same kernel a filter-then-rank pipeline would choose, without + // a second heuristic pass. RFC 0017 §4's catalog -> filter -> rank ordering is + // preserved in effect, not in the order these two calls execute. + const auto filter = readKnobFilter(engineConfig); + const auto filtered = applyKnobFilter(catalog.entries, filter); + if(filtered.empty()) + { + throwUnsatisfiableKnobFilter(filter, catalog.entries.size()); + } + executionContext.setPlan(std::make_unique>( - _stateManager.getDispatchDetails(ranked.front()), context, catalog.bound)); + _stateManager.getDispatchDetails(filtered.front()), context, catalog.bound)); } /** @@ -210,6 +266,119 @@ class GenericPlanBuilder : public IPlanBuilder return MatchContext{opGraph, deviceId, _deviceResolver.deviceProperties(deviceId)}; } + /** + * @brief Reads @p engineConfig's settings for this engine's own knobs into a + * KnobFilter. + * + * Only engine-exposed knobs are read: `_engine.knobs` names the KMD fields this + * engine advertises through getCustomKnobs(), and a filter is built only from those + * -- a setting for a knob this engine never exposed cannot mean anything to it. + */ + KnobFilter readKnobFilter(const IEngineConfig& engineConfig) const + { + using namespace hipdnn_flatbuffers_sdk::data_objects; + + KnobFilter filter; + if(!engineConfig.isValid()) + { + return filter; + } + + for(const auto& knobName : _engine.knobs) + { + if(!engineConfig.hasKnobSetting(knobName)) + { + continue; + } + + const auto& setting = engineConfig.getKnobSettingByName(knobName); + if(setting.valueType() != KnobValue::IntValue) + { + // Only integer-valued fields are expressible as knobs today (see + // getCustomKnobs()), so a setting of another type names a knob this + // engine never advertised as anything but an integer -- a caller error, + // reported the same way an unsatisfiable value is below. + throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE, + "engine '" + _engine.name + "' knob '" + knobName + + "' must be set to an integer value"); + } + filter[knobName] = setting.valueAs().value(); + } + return filter; + } + + /** + * @brief The catalog entries matching every SET knob in @p filter. + * + * An entry with no value for a filtered field never matches: knobValues() already + * treats "no value for this field" as "does not offer a choice here" (see its doc), + * and a filter is a request for a specific choice, so a kernel with nothing to say + * about that field cannot be what the caller asked for. + */ + std::vector applyKnobFilter(const std::vector& catalog, + const KnobFilter& filter) const + { + if(filter.empty()) + { + return catalog; + } + + std::vector filtered; + filtered.reserve(catalog.size()); + for(const auto& kernel : catalog) + { + const bool matchesEverySetKnob + = std::all_of(filter.begin(), filter.end(), [&kernel](const auto& setting) { + const auto value = kernel.tryGetMetadata(setting.first); + const auto* intValue + = value.has_value() ? std::get_if(&*value) : nullptr; + return intValue != nullptr && *intValue == setting.second; + }); + if(matchesEverySetKnob) + { + filtered.push_back(kernel); + } + } + return filtered; + } + + /** + * @brief Reports a knob combination no kernel in the catalog implements. + * + * HIPDNN_PLUGIN_STATUS_INVALID_VALUE, not HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR: the + * caller supplied a value combination nothing implements, which is a bad input, not + * an internal bug -- unlike buildPlan()'s empty-catalog-before-filtering case, which + * really is a matcher/applicability disagreement. The two must stay distinguishable + * by status and by message, since a caller diagnosing which case they hit has only + * this exception to read. + * + * Names every SET knob and its requested value, not just the first mismatch: a + * caller who set three knobs needs to see all three to know which to back off. Also + * states how many kernels survived matching before knob filtering, which + * distinguishes "the graph matched nothing" (irrelevant here; this path is only + * reached when that count is nonzero) from "the graph matched, but your knobs + * excluded everything" (always the case here). + */ + [[noreturn]] void throwUnsatisfiableKnobFilter(const KnobFilter& filter, + size_t survivorsBeforeFilter) const + { + std::string settingsText; + for(const auto& [knobName, value] : filter) + { + if(!settingsText.empty()) + { + settingsText += ", "; + } + settingsText += knobName + "=" + std::to_string(value); + } + + throw HipdnnPluginException( + HIPDNN_PLUGIN_STATUS_INVALID_VALUE, + "engine '" + _engine.name + "' has no kernel satisfying the requested knob setting(s) " + + settingsText + " (" + std::to_string(survivorsBeforeFilter) + + " kernel(s) matched the graph before knob filtering)"); + } + const EngineDescriptor& _engine; /// Held by reference; owned by the engine, which owns this builder and so outlives /// it. Sharing ownership here would reintroduce exactly the cross-engine aliasing the diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp index d5084ba1a91a..0081a6ab2d05 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp @@ -111,6 +111,18 @@ class IKernelDispatchHandler * * @note May be called concurrently from several threads with different device * buffers, so it must not mutate @p prepared or the handler. + * + * Deliberately one call, one kernel: multi-launch UDDs (a UDD whose dispatch is + * several kernels rather than one) are out of scope for this interface and are + * correctly deferred, per RFC 0017's own follow-up. When that lands, the direction + * this interface takes is a handler invoked once per launch that internally issues + * N kernel launches on @p handle's stream -- not the SDK calling launch() N times -- + * because grid/block/argument formulas for launch i in a multi-kernel dispatch can + * depend on what launch i-1 produced (a two-pass reduction's second kernel sizes + * itself from the first's output), which only the handler that owns the whole + * dispatch can resolve. Recording the direction now, rather than only the + * deferral, is what keeps this a decision instead of an open question the next + * implementer has to re-litigate. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual void launch(const THandle& handle, diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp index 4948b858c598..6ae47c46ebb4 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp @@ -6,12 +6,15 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -138,6 +141,33 @@ class NativeKernelHeuristic : public IKernelHeuristic mutable ScoreFn _scoreFn = nullptr; }; +/** + * @brief Builds the IKernelHeuristic a UHD names, keyed on its kind. + * + * The same shape DispatchRegistry's symbol-to-handler resolution has, applied to the + * one descriptor RFC 0017 constructs and then steps around: before this, a UHD's + * `scoreSymbol` had no path from the descriptor to a live heuristic except a provider + * hand-building a NativeKernelHeuristic itself. With this factory, "add file loading and + * it works" is true for a UHD exactly as it already is for a UMD or a UDD. + * + * @throws std::invalid_argument if @p descriptor names a kind with no adapter yet + * (HeuristicKind::MODEL). Fails at descriptor-assembly time rather than at the + * first rank(), for the same reason KernelIngestorStateManager's other + * cross-reference checks run eagerly: a kind nothing can load is a load error, + * not a runtime surprise on the first graph that needs ranking. + */ +inline std::shared_ptr makeKernelHeuristic(const HeuristicDescriptor& descriptor) +{ + switch(descriptor.kind) + { + case HeuristicKind::NATIVE: + return std::make_shared(descriptor.payload); + default: + throw std::invalid_argument("heuristic '" + toString(descriptor.id) + + "' names a kind with no adapter yet"); + } +} + } // namespace hipdnn_plugin_sdk::ingestor #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp index adde2db18f38..8cd27dd0f01f 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include @@ -34,8 +35,9 @@ struct KernelDefinition /// The dispatch descriptor id, denormalized from the pack so a caller holding only a /// KernelDefinition can reach its dispatch without walking the pack list again. DescriptorId dispatchId; - std::string sourceFile; - std::string entryPoint; + /// Where this kernel's code comes from and how to load it. See KernelSource for why + /// this is a tagged union rather than two bare strings. + KernelSource source; /// Complete: every KMD field, with defaults filled in for fields the UKD omitted. MetadataValues metadata; int64_t priority = 0; @@ -93,6 +95,25 @@ struct KernelDefinition } return *value; } + + /// @brief The int-list value of a KMD field (e.g. `stride_order`). Throws on the + /// same terms as getIntMetadata. + const std::vector& getIntListMetadata(const std::string& field) const + { + auto it = metadata.find(field); + if(it == metadata.end()) + { + throw std::out_of_range("kernel '" + toString(kernelId) + "' has no metadata field '" + + field + "'"); + } + const auto* value = std::get_if>(&it->second); + if(value == nullptr) + { + throw std::invalid_argument("metadata field '" + field + "' of kernel '" + + toString(kernelId) + "' is not an int list"); + } + return *value; + } }; } // namespace hipdnn_plugin_sdk::ingestor diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index 6387dcf7dee7..239fdd6d7223 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -85,6 +85,21 @@ class KernelIngestorStateManager * validation failures in the real system: a dangling cross-reference cannot * be evaluated, and duplicate catalog keys leave selection with two * indistinguishable candidates and no basis to prefer either. + * + * **This constructor eagerly walks and validates every pack and kernel, via + * validateAndIndexPacks(), at plugin load -- a conscious amendment of RFC 0017 §3 + * and §8's "nothing is parsed until a graph needs it".** What stays lazy is the + * expensive part: a kernel source is not compiled and a heuristic model is not read + * until a graph actually needs that kernel or that ranking (see + * NativeKernelHeuristic's doc for the latter). Descriptor *parsing* -- walking the + * KMD/UMD/UDD/UKD structures this constructor is handed and checking their + * cross-references and metadata tuples -- is cheap relative to those, and doing it + * once at load time is what lets every later match, rank, and dispatch assume the + * descriptor set is internally consistent rather than re-checking it per graph. The + * cost this trades away is startup latency proportional to descriptor count, which + * for an in-process pack like this one is negligible; a loader reading many packs + * from disk is where that cost becomes visible, and if it ever needs to be paid + * lazily instead, this is the constructor that amendment would have to change. */ KernelIngestorStateManager(MetadataSchema schema, std::vector matchers, @@ -373,13 +388,24 @@ class KernelIngestorStateManager return catalog; } - /// A graph-scoped matcher's verdict for one (graph, device), keyed by matcher id. + /// One graph-scoped matcher's verdict for one (graph, device), keyed by matcher id. /// /// Matchers are shared by id across packs, so the same check would otherwise be /// re-run once per pack that lists it. The memo covers what the matcher *bound* as /// well as whether it passed, since re-running it to recover its bindings would /// defeat the memo entirely. - using GraphMatcherMemo = std::unordered_map; + /// + /// `bound` holds only what THIS matcher wrote, isolated from every other matcher's + /// writes. That isolation is what makes it safe to merge into a per-pack scoped + /// view one matcher at a time: a pack merges in exactly the matchers it lists, never + /// a sibling pack's unrelated contribution to some other matcher's memo entry. + struct GraphMatcherVerdict + { + bool passed = false; + BoundTokens bound; + }; + using GraphMatcherMemo + = std::unordered_map; /** * @brief Runs every pack's matchers over @p context, cheapest and broadest first. @@ -392,6 +418,16 @@ class KernelIngestorStateManager * That ordering, and that sharing, are the point of splitting matchers by scope: the * broadly shared checks are what prune the candidate set fast, so an engine whose * packs do not apply pays for each distinct check once rather than once per pack. + * + * Each pack's graph-scoped bindings are accumulated in a scope-local view and merged + * into the catalog's shared bound state only when the whole pack survives (see + * graphLevelMatchersPass()). Bindings a pruned pack's matchers wrote along the way + * are discarded with that pack's local view rather than leaking into the state a + * surviving pack's kernel later reads: with one pack this was invisible, because a + * pruned pack's catalog is empty and nothing reads its bound state, but two packs + * sharing an engine is the normal case (matchers are shared by id), and the moment a + * second pack survives, a bare shared `bound` map would hand its kernels tokens that + * describe a graph shape only the pruned pack's own matcher resolved. */ Catalog buildCatalog(const MatchContext& context) const { @@ -400,18 +436,26 @@ class KernelIngestorStateManager for(const auto& pack : _packs) { - if(!graphLevelMatchersPass(pack, context, graphVerdicts, catalog.bound)) + // Scoped to this pack. Merged into catalog.bound below only if every one of + // this pack's graph-scoped matchers passes; discarded with this pack's + // failure otherwise. See buildCatalog()'s doc for why that scoping is the + // fix rather than an optimization. + BoundTokens packBound; + if(!graphLevelMatchersPass(pack, context, graphVerdicts, packBound)) { continue; } + // Merged rather than kept per pack: Catalog::bound's own doc explains why a + // token name means the same thing to every pack in an engine. + catalog.bound.insert(packBound.begin(), packBound.end()); + for(const auto& kernel : pack.kernels) { KernelDefinition definition{kernel.id, pack.id, pack.dispatchId, - kernel.sourceFile, - kernel.entryPoint, + kernel.source, completeMetadata(kernel), kernel.priority}; @@ -425,14 +469,18 @@ class KernelIngestorStateManager return catalog; } - /// @param bound Accumulates what the matchers resolved, for the catalog to keep. - /// Written straight into the catalog's map: a matcher that passes contributes - /// its bindings, and one that fails prunes its pack, so nothing it wrote can - /// be read by a kernel that survives. + /** + * @param packBound Accumulates what THIS pack's own graph-scoped matchers resolved, + * isolated from every other pack's contribution (see GraphMatcherVerdict). + * The caller merges it into the catalog's shared bound state only when this + * function returns true; a pack this function prunes leaves @p packBound to + * be discarded unmerged, which is what stops its matchers' bindings from + * reaching a kernel in a pack that survives. + */ bool graphLevelMatchersPass(const KernelDescriptorPack& pack, const MatchContext& context, GraphMatcherMemo& graphVerdicts, - BoundTokens& bound) const + BoundTokens& packBound) const { for(const auto& matcherId : pack.matcherIds) { @@ -445,18 +493,27 @@ class KernelIngestorStateManager auto memo = graphVerdicts.find(matcherId); if(memo == graphVerdicts.end()) { - // Binds on the first evaluation only. A later pack listing this same - // matcher reuses both the verdict and the bindings it already wrote, - // which is the whole point of memoizing across packs. - const bool passed - = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context, bound); - memo = graphVerdicts.emplace(matcherId, passed).first; + // Evaluated on the first pack that lists this matcher only. A later pack + // listing the same matcher reuses both the verdict and the bindings it + // already wrote from this memo entry, which is the whole point of + // memoizing across packs -- the native function itself runs once no + // matter how many packs share the matcher. + GraphMatcherVerdict verdict; + verdict.passed + = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context, verdict.bound); + memo = graphVerdicts.emplace(matcherId, std::move(verdict)).first; } - if(!memo->second) + if(!memo->second.passed) { return false; } + + // Merges only THIS matcher's own bindings into the pack's scoped view. A + // pack that does not list this matcher never merges them; a pack that + // shares it with another pack that failed elsewhere still merges exactly + // what this matcher wrote, reused from the memo rather than recomputed. + packBound.insert(memo->second.bound.begin(), memo->second.bound.end()); } return true; } diff --git a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp index 9bc1a77cbc65..3f6f95cb4c6b 100644 --- a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp @@ -217,8 +217,8 @@ inline KernelDescriptor makeTestKernel(const DescriptorId& id, KernelDescriptor kernel; kernel.id = id; kernel.name = name; - kernel.sourceFile = "Test.cpp"; - kernel.entryPoint = "TestKernel"; + kernel.source.sourceFile = "Test.cpp"; + kernel.source.entryPoint = "TestKernel"; kernel.metadata = {{BLOCK_SIZE, MetadataValue{blockSize}}, {DTYPE, MetadataValue{dtype}}}; return kernel; } diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp index 846f2d285d5d..9d81336f24a9 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp @@ -8,11 +8,17 @@ #include +#include +#include +#include #include #include +#include #include #include #include +#include +#include #include #include #include @@ -161,8 +167,7 @@ KernelDefinition makeDefinition(const DescriptorId& id, int64_t blockSize, int64 return {id, PACK_ID, DISPATCH_ID, - "Test.cpp", - "TestKernel", + KernelSource{KernelSourceKind::EMBEDDED_SOURCE, "Test.cpp", "TestKernel"}, {{BLOCK_SIZE, MetadataValue{blockSize}}}, priority}; } @@ -514,6 +519,79 @@ TEST(TestIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackListingI EXPECT_EQ(counters().kernelCalls, 0); } +TEST(TestIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingPackKernels) +{ + // Two packs sharing an engine, the normal case since matchers are shared by id. + // The pruned pack's own graph-scoped matcher passes and binds a token before a + // second matcher in the SAME pack fails and prunes it; the surviving pack lists + // neither of those matchers. If the pruned pack's binding reaches catalog.bound + // anyway, a kernel in the surviving pack -- which never asked for that token -- can + // read it. That is the correctness bug item 5 fixes: the header's claim that + // "nothing a failing matcher wrote can be read by a kernel that survives" held only + // by accident with one pack, and breaks the moment a second pack shares the engine. + constexpr const char* PASS_NO_BIND_SYMBOL = "test.pruned_bound_isolation.pass_no_bind"; + constexpr const char* LEAK_THEN_PRUNE_SYMBOL = "test.pruned_bound_isolation.leak"; + constexpr const char* ALWAYS_FAILS_SYMBOL = "test.pruned_bound_isolation.fail"; + + // Reused rather than reinvented: acceptAnyGraph (fixtures) passes and binds nothing; + // acceptGraph (this file) passes and binds "test.bound_token"; rejectGraph (this + // file) always fails. Exactly the three behaviors this test needs. + GraphMatcherRegistry::registerSymbol(PASS_NO_BIND_SYMBOL, &acceptAnyGraph); + GraphMatcherRegistry::registerSymbol(LEAK_THEN_PRUNE_SYMBOL, &acceptGraph); + GraphMatcherRegistry::registerSymbol(ALWAYS_FAILS_SYMBOL, &rejectGraph); + counters().reset(); + + const auto passNoBindMatcherId = testId(0xA4); + const auto leakMatcherId = testId(0xA5); + const auto failMatcherId = testId(0xA6); + + KernelDescriptorPack survivingPack; + survivingPack.id = testId(0xA0); + survivingPack.name = "surviving pack"; + survivingPack.matcherIds = {passNoBindMatcherId}; + survivingPack.engineId = ENGINE_ID; + survivingPack.dispatchId = DISPATCH_ID; + survivingPack.kernels = {makeKernel(testId(0xA1), "surviving_kernel", 64, "FLOAT")}; + + KernelDescriptorPack prunedPack; + prunedPack.id = testId(0xA2); + prunedPack.name = "pruned pack"; + // Order matters: the leaking matcher must run (and bind) before the failing one + // prunes the pack, exactly as graphLevelMatchersPass evaluates them in listed order. + prunedPack.matcherIds = {leakMatcherId, failMatcherId}; + prunedPack.engineId = ENGINE_ID; + prunedPack.dispatchId = DISPATCH_ID; + prunedPack.kernels = {makeKernel(testId(0xA3), "pruned_kernel", 128, "FLOAT")}; + + const StateManager manager( + makeSchema(), + {{passNoBindMatcherId, "passes, binds nothing", MatchScope::GRAPH, PASS_NO_BIND_SYMBOL}, + {leakMatcherId, "passes, binds a token", MatchScope::GRAPH, LEAK_THEN_PRUNE_SYMBOL}, + {failMatcherId, "always fails", MatchScope::GRAPH, ALWAYS_FAILS_SYMBOL}}, + makeTestDispatches(), + {survivingPack, prunedPack}, + std::make_shared(SCORE_SYMBOL)); + + const TestGraph graph(makeGraphId(50)); + const auto properties = testDeviceProperties(); + const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); + + // The surviving pack's kernel is the only entry: the pruned pack's kernel never + // enters the catalog, which was already correct before this fix. + ASSERT_EQ(catalog.entries.size(), 1U); + EXPECT_EQ(catalog.entries.front().packId, survivingPack.id); + + // What this test actually regresses: the token only the pruned pack's own matcher + // bound must not survive into the catalog the surviving pack's kernel reads from, + // even though that matcher itself returned true before its pack's second matcher + // failed. + EXPECT_EQ(catalog.bound.count("test.bound_token"), 0U); + + GraphMatcherRegistry::unregisterSymbol(PASS_NO_BIND_SYMBOL); + GraphMatcherRegistry::unregisterSymbol(LEAK_THEN_PRUNE_SYMBOL); + GraphMatcherRegistry::unregisterSymbol(ALWAYS_FAILS_SYMBOL); +} + TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); @@ -814,6 +892,7 @@ struct StubHandle struct StubSettings { + KnobFilter ingestorKnobFilter; }; struct StubContext @@ -916,4 +995,196 @@ TEST(TestIngestorGenericEngine, RejectsAKnobNamingNoMetadataField) std::invalid_argument); } +// --------------------------------------------------------------------------- +// GenericPlanBuilder: knob filtering (item 6) +// --------------------------------------------------------------------------- + +/// A no-op dispatch handler, sufficient to let buildPlan() construct a GenericPlan: +/// these tests assert which kernel selection chose, not how it launches. +class NoopDispatchHandler : public IKernelDispatchHandler +{ +public: + size_t workspaceBytes(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, + const KernelDefinition& /*kernel*/) const override + { + return 0; + } + + std::unique_ptr prepare(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, + const KernelDefinition& /*kernel*/) const override + { + return std::make_unique(); + } + + void launch(const TestHandle& /*handle*/, + const PreparedDispatch& /*prepared*/, + const hipdnnPluginDeviceBuffer_t* /*deviceBuffers*/, + uint32_t /*numDeviceBuffers*/, + void* /*workspace*/) const override + { + } +}; + +/// Registers the no-op dispatch handler under this test suite's dispatch symbol for a +/// test's duration. +class ScopedTestDispatch +{ +public: + ScopedTestDispatch() + { + DispatchRegistry::registerSymbol("test.dispatch", &_handler); + } + + ~ScopedTestDispatch() + { + DispatchRegistry::unregisterSymbol("test.dispatch"); + } + + ScopedTestDispatch(const ScopedTestDispatch&) = delete; + ScopedTestDispatch& operator=(const ScopedTestDispatch&) = delete; + +private: + NoopDispatchHandler _handler; +}; + +struct KnobFilterSettings +{ + KnobFilter ingestorKnobFilter; +}; + +struct KnobFilterContext +{ + void setExecutionSettings(const KnobFilterSettings& /*settings*/) {} + + void setPlan(std::unique_ptr> plan) + { + _plan = std::move(plan); + } + + /// Safe without RTTI: GenericPlanBuilder only ever hands setPlan() + /// a GenericPlan, so the concrete type is known at every call site here. + const GenericPlan& plan() const + { + return static_cast&>(*_plan); + } + +private: + std::unique_ptr> _plan; +}; + +using TestPlanBuilder = GenericPlanBuilder; + +/// An IEngineConfig setting `knobName` to `value`, built the same way +/// TestEngineConfigWrapper.cpp builds one: a real flatbuffer, not a mock, so these tests +/// exercise the same parsing path a real caller's setAttribute() eventually produces. +hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeIntKnobEngineConfig( + flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, int64_t value) +{ + using namespace hipdnn_flatbuffers_sdk::data_objects; + + std::vector> knobSettings; + knobSettings.push_back(CreateKnobSettingDirect( + builder, knobName.c_str(), KnobValue::IntValue, CreateIntValue(builder, value).Union())); + auto knobsVector = builder.CreateVector(knobSettings); + builder.Finish(CreateEngineConfig(builder, ENGINE_ID.front(), knobsVector)); + + return hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper( + builder.GetBufferPointer(), builder.GetSize()); +} + +TEST(TestIngestorPlanBuilderKnobFilter, HonorsAnExplicitKnobSettingOverTheHeuristicDefault) +{ + // The heuristic (scoreByBlockSize) ranks 256 first, so this is the assertion that + // proves the knob is honored rather than merely advertised: leaving it alone would + // select 256, but setting it must select 64 instead. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const ScopedTestDispatch dispatch; + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 64); + + const TestGraph graph(makeGraphId(30)); + KnobFilterSettings settings; + builder.initializeExecutionSettings(0, graph, engineConfig, settings); + + KnobFilterContext context; + builder.buildPlan(0, graph, engineConfig, context); + + EXPECT_EQ(context.plan().kernel().getIntMetadata(BLOCK_SIZE), 64); +} + +TEST(TestIngestorPlanBuilderKnobFilter, UnsatisfiableKnobValueThrowsInvalidValueNamingItAndTheValue) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const ScopedTestDispatch dispatch; + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + // No surviving kernel carries block_size 999: the catalog matches (two FLOAT + // kernels), but knob filtering excludes both. + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 999); + + const TestGraph graph(makeGraphId(31)); + KnobFilterContext context; + + try + { + builder.buildPlan(0, graph, engineConfig, context); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); + EXPECT_NE(ex.getMessage().find(BLOCK_SIZE), std::string::npos); + EXPECT_NE(ex.getMessage().find("999"), std::string::npos); + } +} + +TEST(TestIngestorPlanBuilderKnobFilter, + EmptyCatalogBeforeFilteringStillThrowsInternalErrorWithItsOriginalMessage) +{ + // The pre-existing, unrelated failure: applicability accepted a graph this engine + // cannot serve at all (matching itself found nothing), independent of any knob. + // Item 6 must not collapse this into the same status or message as the + // knob-filtering case above -- a caller needs to tell "your knobs excluded + // everything" apart from "this engine cannot serve this graph regardless of knobs". + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + const ScopedTestDispatch dispatch; + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + // No knob set: an empty engine config is as close to "no request" as this helper + // allows, and the point under test is the no-kernels-at-all path, not filtering. + fbb.Finish(hipdnn_flatbuffers_sdk::data_objects::CreateEngineConfig(fbb, ENGINE_ID.front())); + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper engineConfig( + fbb.GetBufferPointer(), fbb.GetSize()); + + const TestGraph graph(makeGraphId(32)); + KnobFilterContext context; + + try + { + builder.buildPlan(0, graph, engineConfig, context); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR); + EXPECT_EQ(ex.getMessage(), + "engine '" + engine.name + "' accepted this graph but has no applicable kernel"); + } +} + } // namespace From a31904ebaf09bf195a251f08206405ba4f59534f Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 16:53:11 -0400 Subject: [PATCH 24/60] test(hipdnn): one test file per ingestor source, happy and unhappy (ALMIOPEN-2402) Coverage stopped at the seam where the pieces compose, and one 1190-line file tested eight production files at once -- which is why the classes with no coverage at all were easy to miss: there was no empty TestGenericPlan.cpp to notice. Split the SDK suite into one file per production header under plugin_sdk/tests/ingestor/, with gmock doubles for the two interfaces (IngestorMocks.hpp) so the composition path is provable on CPU. Every assertion from the old file is migrated, not rewritten. GenericPlan, GenericPlanBuilder, Descriptors, MatchContext, and KernelDefinition had no direct tests and now do; GenericEngine had two constructor tests and now covers all four IEngine methods. 48 ingestor tests to 106. Provider side gains the two files that shipped with none: an engine test (registration idempotency, the four IEngine methods through the Container/EngineManager path, and an unsupported graph declining) and a resolver test, whose cache invariants matter more now that it is a process-lifetime static -- including the "references stay valid across map growth" claim its own doc makes, verified by holding a reference across 64 inserts. Close the three matcher refusals that had no test. All three were unreachable from the shared graph builder, which forced one dtype across every tensor and had no third-operand or dangling-uid path, so the builder is extended first. Cross-operand dtype mismatch is the valuable one: it is the guard against handing one binary operands it cannot read, in a file whose own comment says the refusals matter more than the acceptances. TEST_P wherever a shape repeats, per the convention the rest of hipDNN already follows: the seven state-manager validation throws, the metadata accessor throw matrix, LRU eviction order, and nine graph-matcher refusals that were nine near-identical function bodies. The concurrency suite is left alone -- its five bodies assert five different invariants, and merging them would blur which one failed on a red run. Also ungates a dispatch test that threw before any HIP call and so had no business requiring a device, and adds a MockKernelCompiler test asserting the exact compile options reach the compiler, reaching elementTypeFor's throw branch on CPU. One branch is deliberately untested and marked as such: HandleDeviceResolver falling back to device 0 when hipGetDevice itself fails needs a broken HIP runtime to reach, and faking one would be worse than the gap. Verified flag-on: 197 SDK, 482 provider (1 pre-existing arch skip), 13 integration, every binary exiting 0. Flag-off: 91 SDK, 429 provider, no ingestor test present in either. --- .../src/tests/core/TestContainer.cpp | 27 + .../kernel_ingestor_engine/CMakeLists.txt | 2 + .../TestHandleDeviceResolver.cpp | 192 +++ .../TestKernelIngestorEngine.cpp | 179 +++ .../packs/PointwiseAddTestGraphs.hpp | 150 ++- .../packs/TestPointwiseAddDispatchHandler.cpp | 204 +-- .../packs/TestPointwiseAddMatchers.cpp | 226 ++-- .../hipdnn/plugin_sdk/tests/CMakeLists.txt | 11 +- .../tests/KernelIngestorTestFixtures.hpp | 279 ---- .../plugin_sdk/tests/TestKernelIngestor.cpp | 1190 ----------------- .../tests/TestKernelIngestorConcurrency.cpp | 2 +- .../tests/ingestor/IngestorMocks.hpp | 71 + .../ingestor/KernelIngestorTestFixtures.hpp | 667 +++++++++ .../tests/ingestor/TestDescriptors.cpp | 158 +++ .../tests/ingestor/TestGenericEngine.cpp | 192 +++ .../tests/ingestor/TestGenericPlan.cpp | 158 +++ .../tests/ingestor/TestGenericPlanBuilder.cpp | 444 ++++++ .../tests/ingestor/TestKernelDefinition.cpp | 177 +++ .../tests/ingestor/TestKernelHeuristic.cpp | 147 ++ .../TestKernelIngestorStateManager.cpp | 557 ++++++++ .../tests/ingestor/TestLruCache.cpp | 159 +++ .../tests/ingestor/TestMatchContext.cpp | 114 ++ .../tests/ingestor/TestNativeRegistry.cpp | 70 + 23 files changed, 3698 insertions(+), 1678 deletions(-) create mode 100644 dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp delete mode 100644 projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp delete mode 100644 projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp create mode 100644 projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp index f5391d13d583..6120988725aa 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp @@ -1,6 +1,7 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT +#include #include #include @@ -14,6 +15,11 @@ #include #include +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +#include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" +#include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" +#endif + using namespace hip_kernel_provider; using namespace hip_kernel_provider::core; @@ -110,6 +116,27 @@ TEST(TestContainer, GetApplicableEngineIdsSdpaGraph) #endif } +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +TEST(TestContainer, GetApplicableEngineIdsPointwiseAddGraph) +{ + using namespace hip_kernel_provider::kernel_ingestor_engine; + using namespace hip_kernel_provider::kernel_ingestor_engine::testing; + + Handle handle; + Container container; + auto& engineManager = container.getEngineManager(); + + const auto graph = buildPointwiseGraph(); + const auto graphWrapper = hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper( + graph.GetBufferPointer(), graph.GetSize()); + + auto applicableEngines = engineManager.getApplicableEngineIds(handle, graphWrapper); + + EXPECT_NE(std::find(applicableEngines.begin(), applicableEngines.end(), pointwiseAddEngineId()), + applicableEngines.end()); +} +#endif + TEST(TestContainer, GetAllEngineIds) { Container container; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt index 1ad85d83b188..f96a93370092 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/CMakeLists.txt @@ -2,6 +2,8 @@ # SPDX-License-Identifier: MIT target_sources(hip_kernel_provider_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/TestHandleDeviceResolver.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/TestKernelIngestorEngine.cpp ${CMAKE_CURRENT_SOURCE_DIR}/packs/TestPointwiseAddDispatchHandler.cpp ${CMAKE_CURRENT_SOURCE_DIR}/packs/TestPointwiseAddMatchers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/packs/TestPointwiseAddPack.cpp diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp new file mode 100644 index 000000000000..b4afdb8c5f00 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp @@ -0,0 +1,192 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include + +#include +#include + +#include "core/Handle.hpp" +#include "engines/kernel_ingestor_engine/HandleDeviceResolver.hpp" + +/** + * @file TestHandleDeviceResolver.cpp + * @brief HandleDeviceResolver's device-id resolution and its device-properties cache. + * + * Now a process-lifetime static (see KernelIngestorEngine.cpp's deviceResolver()) rather + * than a per-engine member, which makes its cache semantics load-bearing rather than + * incidental: the same instance answers for every engine and every container this + * process ever builds, so a stale entry or an invalidated reference is a + * process-lifetime bug, not one bounded by an engine's or a container's lifetime. + * + * deviceId() has four paths per its own doc: a null stream falls through to the current + * device, a non-null stream resolves via hipStreamGetDevice, a stream hipStreamGetDevice + * cannot resolve falls through the same way a null one does, and a process with no + * usable HIP context at all falls back to device 0. The first three are driven below; + * the fourth requires a HIP runtime with no usable context, which is not something a + * test can put this process into without faking the HIP runtime itself, so it is a + * defensive branch with no test hook here -- documented, not exercised. + */ +namespace +{ + +using hip_kernel_provider::kernel_ingestor_engine::HandleDeviceResolver; + +// --------------------------------------------------------------------------- +// deviceId() +// --------------------------------------------------------------------------- + +TEST(TestHandleDeviceResolver, ResolvesTheCurrentDeviceForANullStream) +{ + SKIP_IF_NO_DEVICES(); + + // A null stream means the default stream, which belongs to the current device. + const HandleDeviceResolver resolver; + Handle handle; + handle.setStream(nullptr); + + int currentDevice = -1; + ASSERT_EQ(hipGetDevice(¤tDevice), hipSuccess); + + EXPECT_EQ(resolver.deviceId(handle), currentDevice); +} + +TEST(TestHandleDeviceResolver, ResolvesTheStreamsOwnDeviceWhenItDiffersFromCurrent) +{ + SKIP_IF_NO_DEVICES(); + + // A real, non-null stream resolves through hipStreamGetDevice rather than through + // whichever device happens to be current on the calling thread -- the property that + // makes the answer correct when several threads drive different handles. + hipStream_t stream = nullptr; + ASSERT_EQ(hipStreamCreate(&stream), hipSuccess); + + int streamDevice = -1; + ASSERT_EQ(hipStreamGetDevice(stream, &streamDevice), hipSuccess); + + const HandleDeviceResolver resolver; + Handle handle; + handle.setStream(stream); + + EXPECT_EQ(resolver.deviceId(handle), streamDevice); + + static_cast(hipStreamDestroy(stream)); +} + +TEST(TestHandleDeviceResolver, FallsThroughToTheCurrentDeviceWhenTheStreamCannotBeResolved) +{ + SKIP_IF_NO_DEVICES(); + + // A stream hipStreamGetDevice cannot resolve (here, one already destroyed) must not + // propagate that failure: it falls through to the current device exactly as a null + // stream does. Destroying the stream also leaves a genuine HIP error pending, which + // this test clears so it does not fail an unrelated test via HipErrorHandler. + hipStream_t stream = nullptr; + ASSERT_EQ(hipStreamCreate(&stream), hipSuccess); + ASSERT_EQ(hipStreamDestroy(stream), hipSuccess); + + int currentDevice = -1; + ASSERT_EQ(hipGetDevice(¤tDevice), hipSuccess); + + const HandleDeviceResolver resolver; + Handle handle; + handle.setStream(stream); + + EXPECT_EQ(resolver.deviceId(handle), currentDevice); + + static_cast(hipGetLastError()); + static_cast(hipExtGetLastError()); +} + +// --------------------------------------------------------------------------- +// deviceProperties(): cache hit vs miss, and the growth-safety invariant +// --------------------------------------------------------------------------- + +TEST(TestHandleDeviceResolver, CachesDevicePropertiesAcrossCalls) +{ + SKIP_IF_NO_DEVICES(); + + const HandleDeviceResolver resolver; + + // A cache miss followed by a hit for the same device must answer identically -- + // same address, since deviceProperties() promises a reference stable for the + // resolver's lifetime, and a fresh lookup that reallocated would break that promise + // silently rather than answering wrong. + const auto& first = resolver.deviceProperties(0); + const auto& second = resolver.deviceProperties(0); + + EXPECT_EQ(&first, &second); + EXPECT_EQ(first.warpSize, second.warpSize); +} + +TEST(TestHandleDeviceResolver, ReferencesStayValidAcrossCacheGrowth) +{ + // This is the invariant HandleDeviceResolver's own doc claims: "the cache hands out + // references that stay valid for this resolver's lifetime... entries are never + // erased or rehashed away: node handles in std::unordered_map keep referenced values + // pinned across growth." Verified directly here rather than trusted from the + // container choice, since a future change to a different container (e.g. a + // node-invalidating flat map) would silently break every caller holding a reference + // across a later insert. + // + // Invalid device ids are used deliberately: hipGetDeviceProperties() fails for them + // and deviceProperties() still caches and returns the (zeroed) result, which is + // enough to grow the map without needing that many real devices. CPU-only: no + // successful HIP call is required, only that insertion happens and does not move + // prior entries. + const HandleDeviceResolver resolver; + + const auto& firstInserted = resolver.deviceProperties(1000); + + // Enough insertions to force at least one rehash of a typical unordered_map's + // default bucket count. + for(int deviceId = 1001; deviceId < 1064; ++deviceId) + { + static_cast(resolver.deviceProperties(deviceId)); + } + + const auto& sameEntryAfterGrowth = resolver.deviceProperties(1000); + EXPECT_EQ(&firstInserted, &sameEntryAfterGrowth); + + static_cast(hipGetLastError()); + static_cast(hipExtGetLastError()); +} + +TEST(TestHandleDeviceResolver, ConcurrentDevicePropertyLookupsAreSafe) +{ + // The cache is mutex-guarded because registration and lookup can race (see the + // class's own doc); this drives that path directly rather than trusting the lock is + // exercised by incidental test-suite parallelism. Every thread asks about the same + // small set of device ids so a real race has many chances to corrupt the map if the + // lock were missing. + const HandleDeviceResolver resolver; + + std::vector threads; + threads.reserve(8); + for(int t = 0; t < 8; ++t) + { + threads.emplace_back([&resolver, t]() { + for(int i = 0; i < 200; ++i) + { + static_cast(resolver.deviceProperties((t % 4) + 2000)); + } + }); + } + for(auto& thread : threads) + { + thread.join(); + } + + static_cast(hipGetLastError()); + static_cast(hipExtGetLastError()); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp new file mode 100644 index 000000000000..6f9d2da9457b --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp @@ -0,0 +1,179 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +#include +#include +#include +#include + +#include "core/Container.hpp" +#include "core/Handle.hpp" +#include "engines/kernel_ingestor_engine/KernelIngestorEngine.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" +#include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" + +/** + * @file TestKernelIngestorEngine.cpp + * @brief What now exists in place of the deleted PointwiseAddEngine forwarding wrapper: + * registerNativeIngestorSymbols() and makePointwiseAddEngine(). + * + * There is no engine class left to test directly -- GenericEngine already satisfies IEngine end to end, and it is the SDK's own type, covered + * by the SDK's own suite. What is this provider's to test is that the factory wires it + * up correctly: the resulting engine answers every IEngine method for a graph this + * pack's matchers accept, and declines one they do not. Reached through Container and + * EngineManager rather than constructed piecemeal, since makePointwiseAddEngine() itself + * takes no seams a test could inject through -- the device resolver and dispatch + * handler it wires up are process-lifetime statics (see KernelIngestorEngine.cpp). + */ +namespace +{ + +using namespace hip_kernel_provider; +using namespace hip_kernel_provider::kernel_ingestor_engine; +using namespace hip_kernel_provider::kernel_ingestor_engine::testing; +using hip_kernel_provider::core::Container; +using hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper; +using hipdnn_test_sdk::utilities::MockEngineConfig; + +/// Wraps a built graph buffer as the frontend hands one to an engine. +GraphWrapper wrap(const flatbuffers::FlatBufferBuilder& builder) +{ + return GraphWrapper(builder.GetBufferPointer(), builder.GetSize()); +} + +/// Stubs a config naming this pack's engine and setting no knobs -- the shape +/// getMaxWorkspaceSize() and initializeExecutionContext() both key their engine lookup +/// on, per EngineManager::getEngine(engineConfig.engineId()). +void stubAsThisEnginesConfig(MockEngineConfig& config) +{ + EXPECT_CALL(config, isValid()).WillRepeatedly(::testing::Return(false)); + EXPECT_CALL(config, engineId()).WillRepeatedly(::testing::Return(pointwiseAddEngineId())); +} + +// --------------------------------------------------------------------------- +// registerNativeIngestorSymbols(): idempotent across repeated calls +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorEngine, RegisterNativeIngestorSymbolsIsIdempotentAcrossRepeatedCalls) +{ + // once_flag-guarded (see its own doc): Container's constructor calls this on every + // Container built over the process's life (SharedContainerManager's weak_ptr means + // that can be many times), so a second call must be a silent no-op rather than a + // throw on the duplicate registration NativeRegistry::registerSymbol() would + // otherwise report. + EXPECT_NO_THROW(registerNativeIngestorSymbols()); + EXPECT_NO_THROW(registerNativeIngestorSymbols()); + EXPECT_NO_THROW(registerNativeIngestorSymbols()); +} + +// --------------------------------------------------------------------------- +// makePointwiseAddEngine(): a working GenericEngine, reached through Container +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorEngine, MakePointwiseAddEngineIsReachableWithTheDescriptorEngineId) +{ + Container container; + auto& engineManager = container.getEngineManager(); + + // The engine table keys this entry on pointwiseAddEngineId(), which registers the + // engine's name on first call; getAllEngineIds() proves the factory above actually + // installed an engine under that id rather than merely compiling. + const auto allEngineIds = engineManager.getAllEngineIds(); + EXPECT_NE(std::find(allEngineIds.begin(), allEngineIds.end(), pointwiseAddEngineId()), + allEngineIds.end()); +} + +TEST(TestKernelIngestorEngine, IsApplicableAcceptsAGraphThisPacksMatchersAccept) +{ + Container container; + auto& engineManager = container.getEngineManager(); + Handle handle; + + const auto graph = buildPointwiseGraph(); + const auto applicable = engineManager.getApplicableEngineIds(handle, wrap(graph)); + + EXPECT_NE(std::find(applicable.begin(), applicable.end(), pointwiseAddEngineId()), + applicable.end()); +} + +TEST(TestKernelIngestorEngine, GetEngineDetailsReportsTheBlockSizeKnob) +{ + Container container; + auto& engineManager = container.getEngineManager(); + Handle handle; + + const auto graph = buildPointwiseGraph(); + hipdnnPluginConstData_t details{}; + engineManager.getEngineDetails(handle, wrap(graph), pointwiseAddEngineId(), details); + + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineDetailsWrapper wrapper(details.ptr, + details.size); + ASSERT_EQ(wrapper.knobCount(), 1U); + EXPECT_EQ(wrapper.getKnobByName("block_size").knobId(), "block_size"); +} + +TEST(TestKernelIngestorEngine, GetMaxWorkspaceSizeReportsTheLargerBlocksRequirement) +{ + Container container; + auto& engineManager = container.getEngineManager(); + const Handle handle; + + const auto graph = buildPointwiseGraph(); + MockEngineConfig engineConfig; + stubAsThisEnginesConfig(engineConfig); + + // The catalog's two surviving FLOAT kernels report 0 and 1024 bytes; the engine's + // answer is a maximum across whichever kernels the graph admits, not a fixed value, + // so this also proves the workspace query reaches the real catalog rather than a + // stub. + const auto workspaceSize = engineManager.getMaxWorkspaceSize(handle, wrap(graph), engineConfig); + EXPECT_EQ(workspaceSize, 1024U); +} + +TEST(TestKernelIngestorEngine, InitializeExecutionContextBuildsAPlanForTheTopRankedKernel) +{ + Container container; + auto& engineManager = container.getEngineManager(); + const Handle handle; + + const auto graph = buildPointwiseGraph(); + MockEngineConfig engineConfig; + stubAsThisEnginesConfig(engineConfig); + + Context context; + EXPECT_NO_THROW( + engineManager.initializeExecutionContext(handle, wrap(graph), engineConfig, context)); + EXPECT_TRUE(context.hasValidPlan()); +} + +// --------------------------------------------------------------------------- +// Unhappy path: a graph none of this pack's kernels serve +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorEngine, DeclinesAGraphThisPacksMatchersRefuse) +{ + Container container; + auto& engineManager = container.getEngineManager(); + Handle handle; + + // A multiplication, not this pack's add: refused at the graph-scoped matcher, the + // same refusal TestPointwiseAddGraphMatcherRefusal covers directly, exercised here + // through the full engine rather than the bare matcher function. + const auto graph + = buildPointwiseGraph(hipdnn_flatbuffers_sdk::data_objects::PointwiseMode::MUL); + const auto applicable = engineManager.getApplicableEngineIds(handle, wrap(graph)); + + EXPECT_EQ(std::find(applicable.begin(), applicable.end(), pointwiseAddEngineId()), + applicable.end()); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp index 0016a599cfff..b06ca303cb43 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp @@ -7,12 +7,18 @@ #include #include +#include #include +#include #include #include #include #include +#include +#include + +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" namespace hip_kernel_provider::kernel_ingestor_engine::testing { @@ -21,24 +27,70 @@ namespace hip_kernel_provider::kernel_ingestor_engine::testing constexpr int64_t INPUT_A_UID = 1; constexpr int64_t INPUT_B_UID = 2; constexpr int64_t OUTPUT_UID = 3; +/// The third operand buildPointwiseGraph() adds when `includeThirdOperand` is set: a +/// real tensor, distinct from the dangling-uid case below, which never appears here. +constexpr int64_t INPUT_C_UID = 4; +/// Names in_1_tensor_uid when `danglingInputBUid` is not overridden -- distinct from +/// every uid this file inserts into a graph's tensor list, so a test opting into the +/// dangling case gets a uid guaranteed absent from getTensorMap(). +constexpr int64_t DEFAULT_DANGLING_UID = 999; + +/// A fixed, warp-64 device: enough for the CPU-only matcher tests, which never compile +/// or launch anything and so never need the actual current device's properties. +inline hipDeviceProp_t testDeviceProperties() +{ + hipDeviceProp_t properties{}; + properties.warpSize = 64; + return properties; +} + +/// The real current device's properties, queried once. Left zeroed if no device is +/// current, which is fine for a test that immediately SKIP_IF_NO_DEVICES()s. +inline hipDeviceProp_t currentDeviceProperties() +{ + hipDeviceProp_t properties{}; + int deviceId = 0; + if(hipGetDevice(&deviceId) == hipSuccess) + { + static_cast(hipGetDeviceProperties(&properties, deviceId)); + } + return properties; +} /** * @brief Builds a single-node pointwise graph, for driving this pack's matchers. * * Parameterized on everything the matchers gate, so a test can vary exactly one thing - * and assert the effect: the operation, the element type, the tensor shape, and whether - * the graph carries an identity. + * and assert the effect: the operation, the element type (uniformly and per-operand), + * the tensor shape, whether the graph carries an identity, arity (binary, ternary, or + * unary), and whether an operand's uid resolves to a real tensor. + * + * @param inputBDataType Overrides input B's dtype away from @p dataType, which stays + * input A's and the output's. Exists solely to build the cross-operand dtype + * mismatch the graph matcher must refuse: this pack's kernel reads one dtype for + * every operand, so a binary op that disagrees is unreadable, not merely + * unoptimized. + * @param includeThirdOperand Adds a real third tensor (`INPUT_C_UID`) and sets + * `in_2_tensor_uid`, producing the ternary shape the graph matcher must refuse: + * this pack's kernel is binary add, and a third operand is a different operation + * entirely. + * @param danglingInputBUid When set, `in_1_tensor_uid` names this value instead of + * `INPUT_B_UID`, and no tensor is inserted for it -- the graph carries a node + * whose operand uid resolves to nothing in getTensorMap(). Exercises the matcher + * refusing a graph it cannot even read, as opposed to one it reads and declines. */ -inline flatbuffers::FlatBufferBuilder - buildPointwiseGraph(hipdnn_flatbuffers_sdk::data_objects::PointwiseMode operation - = hipdnn_flatbuffers_sdk::data_objects::PointwiseMode::ADD, - hipdnn_flatbuffers_sdk::data_objects::DataType dataType - = hipdnn_flatbuffers_sdk::data_objects::DataType::FLOAT, - const std::vector& dims = {1, 1, 1, 1}, - std::optional graphId - = std::nullopt, - bool binary = true, - const std::optional>& explicitStrides = std::nullopt) +inline flatbuffers::FlatBufferBuilder buildPointwiseGraph( + hipdnn_flatbuffers_sdk::data_objects::PointwiseMode operation + = hipdnn_flatbuffers_sdk::data_objects::PointwiseMode::ADD, + hipdnn_flatbuffers_sdk::data_objects::DataType dataType + = hipdnn_flatbuffers_sdk::data_objects::DataType::FLOAT, + const std::vector& dims = {1, 1, 1, 1}, + std::optional graphId = std::nullopt, + bool binary = true, + const std::optional>& explicitStrides = std::nullopt, + std::optional inputBDataType = std::nullopt, + bool includeThirdOperand = false, + std::optional danglingInputBUid = std::nullopt) { namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; @@ -47,12 +99,19 @@ inline flatbuffers::FlatBufferBuilder // larger buffer, whose stride order the layout classifier may not accept. const std::vector strides = explicitStrides.has_value() ? *explicitStrides : std::vector(dims.size(), 1); + const auto resolvedInputBDataType = inputBDataType.value_or(dataType); std::vector> tensors; - for(const auto uid : {INPUT_A_UID, INPUT_B_UID, OUTPUT_UID}) + tensors.push_back(data_objects::CreateTensorAttributesDirect( + builder, INPUT_A_UID, nullptr, dataType, &strides, &dims, false)); + tensors.push_back(data_objects::CreateTensorAttributesDirect( + builder, INPUT_B_UID, nullptr, resolvedInputBDataType, &strides, &dims, false)); + tensors.push_back(data_objects::CreateTensorAttributesDirect( + builder, OUTPUT_UID, nullptr, dataType, &strides, &dims, false)); + if(includeThirdOperand) { tensors.push_back(data_objects::CreateTensorAttributesDirect( - builder, uid, nullptr, dataType, &strides, &dims, false)); + builder, INPUT_C_UID, nullptr, dataType, &strides, &dims, false)); } data_objects::PointwiseAttributesBuilder attributesBuilder(builder); @@ -60,7 +119,11 @@ inline flatbuffers::FlatBufferBuilder attributesBuilder.add_in_0_tensor_uid(INPUT_A_UID); if(binary) { - attributesBuilder.add_in_1_tensor_uid(INPUT_B_UID); + attributesBuilder.add_in_1_tensor_uid(danglingInputBUid.value_or(INPUT_B_UID)); + } + if(includeThirdOperand) + { + attributesBuilder.add_in_2_tensor_uid(INPUT_C_UID); } attributesBuilder.add_out_0_tensor_uid(OUTPUT_UID); auto attributes = attributesBuilder.Finish(); @@ -154,6 +217,63 @@ inline hipdnn_flatbuffers_sdk::utilities::UuidBytes makeGraphId(uint8_t seed) return id; } +/** + * @brief Wraps a built graph buffer so a test reads it the way an engine does. + * + * Parameterized on the device-properties source rather than hardcoding one, because the + * pack's two consumers need different ones: the matcher tests never compile or launch + * anything, so a fixed testDeviceProperties() is enough and keeps them CPU-only; the + * dispatch-handler tests actually compile through hiprtc, so they must pass + * currentDeviceProperties() to build for the device they will launch on. Was + * copy-pasted near-verbatim into TestPointwiseAddMatchers.cpp and + * TestPointwiseAddDispatchHandler.cpp with only that one difference before this move. + */ +class GraphFixture +{ +public: + explicit GraphFixture(flatbuffers::FlatBufferBuilder builder, + hipDeviceProp_t properties = testDeviceProperties()) + : _builder(std::move(builder)) + , _graph(_builder.GetBufferPointer(), _builder.GetSize()) + , _properties(properties) + { + } + + hipdnn_plugin_sdk::ingestor::MatchContext context() const + { + return hipdnn_plugin_sdk::ingestor::MatchContext{_graph, 0, _properties}; + } + + const hipDeviceProp_t& deviceProperties() const + { + return _properties; + } + +private: + flatbuffers::FlatBufferBuilder _builder; + hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper _graph; + hipDeviceProp_t _properties; +}; + +/// @brief A KernelDefinition for this pack's kernel, for tests that never need a real +/// descriptor set. Also copy-pasted near-verbatim in both test files before this move. +inline hipdnn_plugin_sdk::ingestor::KernelDefinition makeKernel(int64_t blockSize, + const std::string& dtype) +{ + hipdnn_plugin_sdk::ingestor::KernelDefinition kernel; + kernel.kernelId + = hipdnn_flatbuffers_sdk::utilities::parseUuid("00000000-0000-4000-8000-000000000001"); + kernel.packId + = hipdnn_flatbuffers_sdk::utilities::parseUuid("00000000-0000-4000-8000-000000000002"); + kernel.dispatchId + = hipdnn_flatbuffers_sdk::utilities::parseUuid("00000000-0000-4000-8000-000000000003"); + kernel.source.sourceFile = "PointwiseAdd.cpp"; + kernel.source.entryPoint = "PointwiseAdd"; + kernel.metadata + = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; + return kernel; +} + } // namespace hip_kernel_provider::kernel_ingestor_engine::testing #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index c20e72e21372..c47c77f7ff9e 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -3,7 +3,9 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +#include #include +#include #include #include @@ -15,9 +17,8 @@ #include #include #include -#include - #include +#include #include "core/Container.hpp" #include "core/Handle.hpp" @@ -25,15 +26,22 @@ #include "engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" +#include "mocks/MockCompiledProgram.hpp" +#include "mocks/MockKernelCompiler.hpp" +#include "mocks/MockRunnableKernel.hpp" #include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" /** * @file TestPointwiseAddDispatchHandler.cpp - * @brief The pack's dispatch: workspace sizing, and a real compile-and-launch. + * @brief The pack's dispatch: workspace sizing, prepare's compile options, and a real + * compile-and-launch. * * The launch tests run on device deliberately. A recorded no-op would exercise neither * the runtime compile nor the uid-to-pointer resolution, which are the two parts most - * likely to be wrong when real kernels arrive. + * likely to be wrong when real kernels arrive. Everything that fails before either of + * those -- an unbound dispatch, an unsupported dtype, the exact options handed to the + * compiler -- is asserted CPU-only, so a decline is cheap to verify and does not need a + * device to run in CI. */ namespace { @@ -41,9 +49,7 @@ namespace using namespace hip_kernel_provider; using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; -using hipdnn_flatbuffers_sdk::utilities::parseUuid; using hipdnn_plugin_sdk::ingestor::BoundTokens; -using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; /// The bindings a real plan build would hand the handler, produced the way the state @@ -59,56 +65,6 @@ BoundTokens bindingsFor(const MatchContext& context) return bound; } -KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) -{ - KernelDefinition kernel; - kernel.kernelId = parseUuid("00000000-0000-4000-8000-000000000001"); - kernel.packId = parseUuid("00000000-0000-4000-8000-000000000002"); - kernel.dispatchId = parseUuid("00000000-0000-4000-8000-000000000003"); - kernel.source.sourceFile = "PointwiseAdd.cpp"; - kernel.source.entryPoint = "PointwiseAdd"; - kernel.metadata - = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; - return kernel; -} - -hipDeviceProp_t currentDeviceProperties() -{ - hipDeviceProp_t properties{}; - int deviceId = 0; - if(hipGetDevice(&deviceId) == hipSuccess) - { - static_cast(hipGetDeviceProperties(&properties, deviceId)); - } - return properties; -} - -class GraphFixture -{ -public: - explicit GraphFixture(flatbuffers::FlatBufferBuilder builder) - : _builder(std::move(builder)) - , _graph(_builder.GetBufferPointer(), _builder.GetSize()) - , _properties(currentDeviceProperties()) - { - } - - MatchContext context() const - { - return MatchContext{_graph, 0, _properties}; - } - - const hipDeviceProp_t& deviceProperties() const - { - return _properties; - } - -private: - flatbuffers::FlatBufferBuilder _builder; - hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper _graph; - hipDeviceProp_t _properties; -}; - /// Device buffers for one 1-element add, freed on scope exit. class AddBuffers { @@ -154,27 +110,44 @@ class AddBuffers // --------------------------------------------------------------------------- // Workspace +// +// Collapses two block-size cases -- the 256-block kernel's non-zero requirement is what +// makes the engine's "maximum across survivors" answer observably a maximum rather than +// a constant zero -- into one TEST_P over the (block size, expected bytes) pairs. // --------------------------------------------------------------------------- -TEST(TestPointwiseAddDispatch, ReportsWorkspaceFromKernelMetadata) +struct WorkspaceCase { - const GraphFixture fixture(buildPointwiseGraph()); + int64_t blockSize; + size_t expectedBytes; +}; + +class TestPointwiseAddDispatchWorkspace : public ::testing::TestWithParam +{ +}; + +TEST_P(TestPointwiseAddDispatchWorkspace, ReportsWorkspaceFromKernelMetadata) +{ + const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - // The two surviving kernels report different requirements, so the engine's - // "maximum across survivors" has something to actually maximize over. - EXPECT_EQ(handler.workspaceBytes( - fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")), - 0U); - EXPECT_EQ(handler.workspaceBytes( - fixture.context(), bindingsFor(fixture.context()), makeKernel(256, "FLOAT")), - 1024U); + EXPECT_EQ(handler.workspaceBytes(fixture.context(), + bindingsFor(fixture.context()), + makeKernel(GetParam().blockSize, "FLOAT")), + GetParam().expectedBytes); } +INSTANTIATE_TEST_SUITE_P(, + TestPointwiseAddDispatchWorkspace, + ::testing::Values(WorkspaceCase{64, 0U}, WorkspaceCase{256, 1024U}), + [](const ::testing::TestParamInfo& info) { + return "BlockSize" + std::to_string(info.param.blockSize); + }); + TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) { - const GraphFixture fixture(buildPointwiseGraph()); + const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); @@ -192,14 +165,86 @@ TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) } // --------------------------------------------------------------------------- -// Prepare and launch +// Prepare: compile options and unhappy paths -- all CPU-only, since everything here +// either never reaches the compiler or replaces it with a mock. +// --------------------------------------------------------------------------- + +TEST(TestPointwiseAddDispatch, PreparePassesTheKernelsTypeAndBlockSizeToTheCompiler) +{ + // Mirrors TestRMSnormBwdPlan.cpp's captured-options pattern: a mock compiler in + // place of a real one proves exactly what prepare() sends it, without depending on + // hiprtc or a device. + const GraphFixture fixture(buildPointwiseGraph()); + const MockKernelCompiler compiler; + std::vector capturedOptions; + + EXPECT_CALL(compiler, compile("PointwiseAdd.cpp", ::testing::_)) + .WillOnce([&](const std::string&, const std::vector& options) { + capturedOptions = options; + + auto kernel = std::make_unique(); + EXPECT_CALL(*kernel, setBlockSize(::testing::_, ::testing::_, ::testing::_)).Times(1); + EXPECT_CALL(*kernel, setGridSize(::testing::_, ::testing::_, ::testing::_)).Times(1); + + auto program = std::make_unique(); + EXPECT_CALL(*program, getKernel("PointwiseAdd")) + .WillOnce(::testing::Return(::testing::ByMove(std::move(kernel)))); + return program; + }); + + const PointwiseAddDispatchHandler handler(compiler); + const auto prepared = handler.prepare( + fixture.context(), bindingsFor(fixture.context()), makeKernel(256, "FLOAT")); + ASSERT_NE(prepared, nullptr); + + const auto hasOption = [&capturedOptions](const std::string& option) { + return std::find(capturedOptions.begin(), capturedOptions.end(), option) + != capturedOptions.end(); + }; + EXPECT_TRUE(hasOption("-DHIP_PLUGIN_POINTWISE_ADD_TYPE=float")); + EXPECT_TRUE(hasOption("-DHIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE=256")); +} + +TEST(TestPointwiseAddDispatch, PrepareRejectsAKernelDeclaringAnUnsupportedDtype) +{ + // elementTypeFor's dtype switch is unreachable via matching, which admits only the + // dtypes this pack declares (FLOAT, HALF); reached directly here to prove the + // fallback reports rather than silently compiling the wrong kernel. Never reaches + // the compiler -- elementTypeFor throws before prepare() calls it -- so this needs + // no device and no mock beyond a real, unused compiler. + const GraphFixture fixture(buildPointwiseGraph()); + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler); + + EXPECT_THROW(handler.prepare( + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "BFLOAT16")), + hipdnn_plugin_sdk::HipdnnPluginException); +} + +TEST(TestPointwiseAddDispatch, RefusesToPrepareWithoutTheMatcherSBindings) +{ + // Not gated: pointwiseAddBinding() throws on a missing token before prepare() reads + // context.deviceProperties or does anything HIP-touching, so this needs no device. + const GraphFixture fixture(buildPointwiseGraph()); + const HipMlopsKernelCompiler compiler; + const PointwiseAddDispatchHandler handler(compiler); + + // Preparation reads the operand uids the matcher bound rather than re-deriving them, + // so bindings that never came from this pack's matcher are a wiring error. It must + // fail loudly instead of guessing which tensor is which operand. + EXPECT_THROW(handler.prepare(fixture.context(), BoundTokens{}, makeKernel(64, "FLOAT")), + hipdnn_plugin_sdk::HipdnnPluginException); +} + +// --------------------------------------------------------------------------- +// Prepare and launch on device // --------------------------------------------------------------------------- TEST(TestPointwiseAddDispatch, LaunchesARealAddOnDevice) { SKIP_IF_NO_DEVICES(); - const GraphFixture fixture(buildPointwiseGraph()); + const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); @@ -221,7 +266,7 @@ TEST(TestPointwiseAddDispatch, LaunchesTheSameResultForEitherBlockSize) { SKIP_IF_NO_DEVICES(); - const GraphFixture fixture(buildPointwiseGraph()); + const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); @@ -246,7 +291,7 @@ TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) { SKIP_IF_NO_DEVICES(); - const GraphFixture fixture(buildPointwiseGraph()); + const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); @@ -269,21 +314,6 @@ TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) } } -TEST(TestPointwiseAddDispatch, RefusesToPrepareWithoutTheMatcherSBindings) -{ - SKIP_IF_NO_DEVICES(); - - const GraphFixture fixture(buildPointwiseGraph()); - const HipMlopsKernelCompiler compiler; - const PointwiseAddDispatchHandler handler(compiler); - - // Preparation reads the operand uids the matcher bound rather than re-deriving them, - // so bindings that never came from this pack's matcher are a wiring error. It must - // fail loudly instead of guessing which tensor is which operand. - EXPECT_THROW(handler.prepare(fixture.context(), BoundTokens{}, makeKernel(64, "FLOAT")), - hipdnn_plugin_sdk::HipdnnPluginException); -} - TEST(TestPointwiseAddDispatch, DispatchStaysResolvableAcrossContainerLifetimes) { SKIP_IF_NO_DEVICES(); @@ -310,7 +340,7 @@ TEST(TestPointwiseAddDispatch, DispatchStaysResolvableAcrossContainerLifetimes) std::string(DISPATCH_SYMBOL)); ASSERT_NE(handler, nullptr); - const GraphFixture fixture(buildPointwiseGraph()); + const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); const auto prepared = handler->prepare( fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")); ASSERT_NE(prepared, nullptr); diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index 8b7406a76719..31682aac5d8b 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -3,11 +3,12 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR +#include #include +#include #include -#include #include #include #include @@ -18,7 +19,7 @@ #include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" /** - * @file TestNativeMatchers.cpp + * @file TestPointwiseAddMatchers.cpp * @brief The pack's two matcher shapes: what each accepts, and what each refuses. * * The refusals matter more than the acceptances here. An under-specified decline accepts @@ -30,9 +31,7 @@ namespace using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; -using hipdnn_flatbuffers_sdk::utilities::parseUuid; using hipdnn_plugin_sdk::ingestor::BoundTokens; -using hipdnn_plugin_sdk::ingestor::KernelDefinition; using hipdnn_plugin_sdk::ingestor::MatchContext; namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; @@ -44,50 +43,8 @@ bool matches(const MatchContext& context) return pointwiseAddGraphMatches(context, bound); } -hipDeviceProp_t testDeviceProperties() -{ - hipDeviceProp_t properties{}; - properties.warpSize = 64; - return properties; -} - -KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) -{ - KernelDefinition kernel; - kernel.kernelId = parseUuid("00000000-0000-4000-8000-000000000001"); - kernel.packId = parseUuid("00000000-0000-4000-8000-000000000002"); - kernel.dispatchId = parseUuid("00000000-0000-4000-8000-000000000003"); - kernel.source.sourceFile = "PointwiseAdd.cpp"; - kernel.source.entryPoint = "PointwiseAdd"; - kernel.metadata - = {{std::string(BLOCK_SIZE_FIELD), blockSize}, {std::string(DTYPE_FIELD), dtype}}; - return kernel; -} - -/// Wraps a built graph buffer so a test reads it the way an engine does. -class GraphFixture -{ -public: - explicit GraphFixture(flatbuffers::FlatBufferBuilder builder) - : _builder(std::move(builder)) - , _graph(_builder.GetBufferPointer(), _builder.GetSize()) - , _properties(testDeviceProperties()) - { - } - - MatchContext context() const - { - return MatchContext{_graph, 0, _properties}; - } - -private: - flatbuffers::FlatBufferBuilder _builder; - hipdnn_flatbuffers_sdk::flatbuffer_utilities::GraphWrapper _graph; - hipDeviceProp_t _properties; -}; - // --------------------------------------------------------------------------- -// Graph-scoped matcher +// Graph-scoped matcher: acceptances // --------------------------------------------------------------------------- TEST(TestPointwiseAddGraphMatcher, AcceptsASingleElementFloatAdd) @@ -107,35 +64,6 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsAHalfPrecisionAdd) EXPECT_TRUE(matches(fixture.context())); } -TEST(TestPointwiseAddGraphMatcher, RefusesAnotherPointwiseOperation) -{ - const GraphFixture fixture(buildPointwiseGraph(data_objects::PointwiseMode::MUL)); - - EXPECT_FALSE(matches(fixture.context())); -} - -TEST(TestPointwiseAddGraphMatcher, RefusesMultiElementTensors) -{ - // The kernel writes element 0 and nothing else, so anything larger would silently - // leave most of the output untouched. - const GraphFixture fixture(buildPointwiseGraph( - data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 2, 2})); - - EXPECT_FALSE(matches(fixture.context())); -} - -TEST(TestPointwiseAddGraphMatcher, RefusesARankTheDispatchPathCannotServe) -{ - // A 1-element 1-D tensor suits the kernel, which indexes element 0, but the - // provider's compile options derive layout from the tensor and reject anything - // below rank 4. Accepting it would trade a free decline at applicability for a - // failed plan build, which the caller pays for. - const GraphFixture fixture( - buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1})); - - EXPECT_FALSE(matches(fixture.context())); -} - TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) { const GraphFixture fixture(buildPointwiseGraph( @@ -144,44 +72,132 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) EXPECT_TRUE(matches(fixture.context())); } -TEST(TestPointwiseAddGraphMatcher, RefusesAStrideOrderTheDispatchPathCannotClassify) -{ - // Same class as the rank refusal above, and the same root cause: the provider's - // compile options derive a layout from the tensor's strides and throw on any 4D - // order that is neither NCHW nor NHWC. A 1-element tensor viewing into a larger - // buffer can carry such an order, so without this the matcher accepts a graph the - // plan build then fails on, which RFC 0017 section 8.6 forbids. - const GraphFixture fixture( - buildPointwiseGraph(data_objects::PointwiseMode::ADD, - data_objects::DataType::FLOAT, - {1, 1, 1, 1}, - std::nullopt, - /*binary=*/true, - /*explicitStrides=*/std::vector{8, 2, 4, 1})); - - EXPECT_FALSE(matches(fixture.context())); -} +// --------------------------------------------------------------------------- +// Graph-scoped matcher: refusals +// +// Collapsed into one TEST_P: each case differs only in which graph it hands the +// matcher, and every one asserts the identical verdict. Nine cases in total -- the six +// original shape refusals, plus the three this pack's matcher previously left +// unreachable from the builder (cross-operand dtype mismatch, a third operand, and a +// dangling tensor uid). +// --------------------------------------------------------------------------- -TEST(TestPointwiseAddGraphMatcher, RefusesAUnaryPointwise) +/// One graph the matcher must refuse, plus a readable name for a failing run. The +/// builder is a captureless-lambda-convertible function pointer rather than a +/// std::function, because flatbuffers::FlatBufferBuilder is move-only and every case +/// here needs only a zero-argument factory. +struct GraphMatcherRefusalCase { - const GraphFixture fixture(buildPointwiseGraph(data_objects::PointwiseMode::ADD, - data_objects::DataType::FLOAT, - {1, 1, 1, 1}, - std::nullopt, - /*binary=*/false)); + std::string name; + flatbuffers::FlatBufferBuilder (*buildGraph)(); +}; - EXPECT_FALSE(matches(fixture.context())); -} +class TestPointwiseAddGraphMatcherRefusal : public ::testing::TestWithParam +{ +}; -TEST(TestPointwiseAddGraphMatcher, RefusesAMultiNodeGraph) +TEST_P(TestPointwiseAddGraphMatcherRefusal, Refuses) { - // A prebuilt kernel serves one complete graph, so a larger graph is a different - // problem even though it contains this one. - const GraphFixture fixture(buildTwoNodePointwiseGraph()); + const GraphFixture fixture(GetParam().buildGraph()); EXPECT_FALSE(matches(fixture.context())); } +INSTANTIATE_TEST_SUITE_P( + , + TestPointwiseAddGraphMatcherRefusal, + ::testing::ValuesIn(std::vector{ + {"AnotherPointwiseOperation", + []() { return buildPointwiseGraph(data_objects::PointwiseMode::MUL); }}, + {"MultiElementTensors", + // The kernel writes element 0 and nothing else, so anything larger would + // silently leave most of the output untouched. + []() { + return buildPointwiseGraph( + data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 2, 2}); + }}, + {"ARankTheDispatchPathCannotServe", + // A 1-element 1-D tensor suits the kernel, which indexes element 0, but the + // provider's compile options derive layout from the tensor and reject anything + // below rank 4. Accepting it would trade a free decline at applicability for a + // failed plan build, which the caller pays for. + []() { + return buildPointwiseGraph( + data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1}); + }}, + {"AStrideOrderTheDispatchPathCannotClassify", + // Same class as the rank refusal above, and the same root cause: the + // provider's compile options derive a layout from the tensor's strides and + // throw on any 4D order that is neither NCHW nor NHWC. A 1-element tensor + // viewing into a larger buffer can carry such an order, so without this the + // matcher accepts a graph the plan build then fails on, which RFC 0017 section + // 8.6 forbids. + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::vector{8, 2, 4, 1}); + }}, + {"AUnaryPointwise", + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/false); + }}, + {"AMultiNodeGraph", + // A prebuilt kernel serves one complete graph, so a larger graph is a + // different problem even though it contains this one. + []() { return buildTwoNodePointwiseGraph(); }}, + {"CrossOperandDtypeMismatch", + // PointwiseAddMatchers.cpp:165-168. The highest-value refusal here: this pack's + // kernel reads one dtype for every operand, so a binary add whose inputs + // disagree is not merely unoptimized, it is unreadable. Unreachable from the + // pre-extension builder, which forced every tensor to share one dtype. + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::nullopt, + /*inputBDataType=*/data_objects::DataType::HALF); + }}, + {"AThirdOperand", + // PointwiseAddMatchers.cpp:144, the in_2_tensor_uid().has_value() half. A third + // operand is a different operation from this pack's binary add, not a larger + // instance of the same one. + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::nullopt, + /*inputBDataType=*/std::nullopt, + /*includeThirdOperand=*/true); + }}, + {"ADanglingTensorUid", + // PointwiseAddMatchers.cpp:152-155. in_1_tensor_uid names a uid absent from + // the graph's tensor map -- a node the matcher cannot even read, as distinct + // from one it reads and declines. + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::nullopt, + /*inputBDataType=*/std::nullopt, + /*includeThirdOperand=*/false, + /*danglingInputBUid=*/DEFAULT_DANGLING_UID); + }}, + }), + [](const ::testing::TestParamInfo& info) { return info.param.name; }); + // --------------------------------------------------------------------------- // Kernel-scoped matcher // --------------------------------------------------------------------------- diff --git a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt index 9dc5ee1ae373..499e439a2c18 100644 --- a/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/tests/CMakeLists.txt @@ -26,7 +26,16 @@ add_executable(hipdnn_plugin_sdk_tests TestVersion.cpp TestPluginDeviceBuffers.cpp TestRuntimePassByValue.cpp - $<$:TestKernelIngestor.cpp> + $<$:ingestor/TestLruCache.cpp> + $<$:ingestor/TestNativeRegistry.cpp> + $<$:ingestor/TestKernelHeuristic.cpp> + $<$:ingestor/TestMatchContext.cpp> + $<$:ingestor/TestDescriptors.cpp> + $<$:ingestor/TestKernelDefinition.cpp> + $<$:ingestor/TestKernelIngestorStateManager.cpp> + $<$:ingestor/TestGenericPlan.cpp> + $<$:ingestor/TestGenericEngine.cpp> + $<$:ingestor/TestGenericPlanBuilder.cpp> $<$:TestKernelIngestorConcurrency.cpp> ) diff --git a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp deleted file mode 100644 index 3f6f95cb4c6b..000000000000 --- a/projects/hipdnn/plugin_sdk/tests/KernelIngestorTestFixtures.hpp +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright © Advanced Micro Devices, Inc., or its affiliates. -// SPDX-License-Identifier: MIT - -#pragma once - -#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/** - * @file KernelIngestorTestFixtures.hpp - * @brief Shared fixtures for the ingestor's SDK-level tests. - * - * Models the pack shape a real engine ships: two kernels that differ only in a ranking - * axis, plus one the kernel-scoped matcher prunes. That is the smallest catalog where - * matching, pruning, and ranking each have something to do. - */ -namespace hipdnn_plugin_sdk::ingestor::testing -{ - -constexpr const char* BLOCK_SIZE = "block_size"; -constexpr const char* DTYPE = "dtype"; -constexpr const char* GRAPH_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.graph_match"; -constexpr const char* KERNEL_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.kernel_match"; -constexpr const char* SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.score"; - -/// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the rest -/// of the interface throws: a test that starts depending on graph contents should fail -/// loudly rather than silently match against an empty graph. -class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph -{ -public: - /// @param graphId The identity to carry, or nullopt to model a legacy or unfinalized - /// graph that has none. - explicit TestGraph(std::optional graphId = std::nullopt) - { - flatbuffers::Offset graph; - if(graphId.has_value()) - { - const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*graphId); - auto name = _builder.CreateString("test_graph"); - hipdnn_flatbuffers_sdk::data_objects::GraphBuilder graphBuilder(_builder); - graphBuilder.add_name(name); - graphBuilder.add_id(&uuid); - graph = graphBuilder.Finish(); - } - else - { - graph = hipdnn_flatbuffers_sdk::data_objects::CreateGraphDirect(_builder, "test_graph"); - } - _builder.Finish(graph); - } - - const hipdnn_flatbuffers_sdk::data_objects::Graph& getGraph() const override - { - return *flatbuffers::GetRoot( - _builder.GetBufferPointer()); - } - - bool isValid() const override - { - return true; - } - - uint32_t nodeCount() const override - { - return 0; - } - - bool hasOnlySupportedAttributes( - std::set /*supported*/) const override - { - return true; - } - - const hipdnn_flatbuffers_sdk::data_objects::Node& getNode(uint32_t /*index*/) const override - { - throw std::logic_error("TestGraph carries no nodes"); - } - - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper& - getNodeWrapper(uint32_t /*index*/) const override - { - throw std::logic_error("TestGraph carries no nodes"); - } - - const std::vector>& - nodeWrappers() const override - { - throw std::logic_error("TestGraph carries no nodes"); - } - - const std::unordered_map& - getTensorMap() const override - { - return _tensors; - } - -private: - flatbuffers::FlatBufferBuilder _builder; - std::unordered_map - _tensors; -}; - -inline GraphId makeGraphId(uint8_t seed) -{ - GraphId id{}; - id.fill(seed); - return id; -} - -inline hipDeviceProp_t testDeviceProperties() -{ - hipDeviceProp_t properties{}; - properties.warpSize = 64; - return properties; -} - -inline bool acceptAnyGraph(const MatchContext& /*context*/, BoundTokens& /*bound*/) -{ - return true; -} - -/// Accepts only FLOAT kernels, so a pack carrying a HALF kernel is pruned down. -inline bool acceptFloatKernels(const MatchContext& /*context*/, const KernelDefinition& kernel) -{ - return kernel.getStringMetadata(DTYPE) == "FLOAT"; -} - -/// Bigger block size scores higher, so ranking has a defined winner. -inline double scoreByBlockSize(const KernelDefinition& kernel, const MatchContext& /*context*/) -{ - return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); -} - -/// Registers this fixture's symbols for a test's duration, so tests sharing the -/// process-wide registry stay independent of one another's registrations. -class ScopedTestSymbols -{ -public: - ScopedTestSymbols() - { - GraphMatcherRegistry::registerSymbol(GRAPH_MATCH_SYMBOL, &acceptAnyGraph); - KernelMatcherRegistry::registerSymbol(KERNEL_MATCH_SYMBOL, &acceptFloatKernels); - ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); - } - - ~ScopedTestSymbols() - { - GraphMatcherRegistry::unregisterSymbol(GRAPH_MATCH_SYMBOL); - KernelMatcherRegistry::unregisterSymbol(KERNEL_MATCH_SYMBOL); - ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); - } - - ScopedTestSymbols(const ScopedTestSymbols&) = delete; - ScopedTestSymbols& operator=(const ScopedTestSymbols&) = delete; -}; - -/// A device resolver for tests that construct an engine. Reports one fixed device, since -/// nothing here exercises multi-device behavior. -class TestDeviceResolver : public IDeviceResolver -{ -public: - DeviceId deviceId(const int& /*handle*/) const override - { - return 0; - } - - const hipDeviceProp_t& deviceProperties(DeviceId /*deviceId*/) const override - { - return _properties; - } - -private: - hipDeviceProp_t _properties = testDeviceProperties(); -}; - -/// A descriptor id from a short seed, so a fixture can name ids readably while the type -/// stays the real 128-bit one. -inline DescriptorId testId(uint8_t seed) -{ - DescriptorId id{}; - id.fill(seed); - return id; -} - -inline const DescriptorId ENGINE_ID = testId(0xE0); -inline const DescriptorId SCHEMA_ID = testId(0xE1); -inline const DescriptorId HEURISTIC_ID = testId(0xE2); -inline const DescriptorId GRAPH_MATCHER_ID = testId(0xE3); -inline const DescriptorId KERNEL_MATCHER_ID = testId(0xE4); -inline const DescriptorId DISPATCH_ID = testId(0xE5); -inline const DescriptorId PACK_ID = testId(0xE6); - -inline KernelDescriptor makeTestKernel(const DescriptorId& id, - const std::string& name, - int64_t blockSize, - const std::string& dtype) -{ - KernelDescriptor kernel; - kernel.id = id; - kernel.name = name; - kernel.source.sourceFile = "Test.cpp"; - kernel.source.entryPoint = "TestKernel"; - kernel.metadata = {{BLOCK_SIZE, MetadataValue{blockSize}}, {DTYPE, MetadataValue{dtype}}}; - return kernel; -} - -/// The UED the fixture engine is built from. Separate from the state manager, which -/// holds only what selection reads. -inline EngineDescriptor makeTestEngine() -{ - EngineDescriptor engine; - engine.id = ENGINE_ID; - engine.name = "test:engine"; - engine.heuristicId = HEURISTIC_ID; - engine.metadataSchemaId = SCHEMA_ID; - engine.knobs = {BLOCK_SIZE}; - return engine; -} - -/// Two FLOAT kernels differing only in block size, plus a HALF kernel the kernel-scoped -/// matcher prunes: a catalog of two survivors with a defined ranking. -inline std::unique_ptr> - makeTestStateManager(size_t cacheCapacity - = KernelIngestorStateManager::DEFAULT_CATALOG_CACHE_CAPACITY) -{ - MetadataSchema schema; - schema.id = SCHEMA_ID; - schema.name = "test schema"; - // block_size defaults; dtype is mandatory, so every kernel below states it. - schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, - {DTYPE, MetadataType::STRING, std::nullopt}}; - - std::vector matchers{ - {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}, - {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, KERNEL_MATCH_SYMBOL}}; - std::vector dispatches{ - {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}; - - KernelDescriptorPack pack; - pack.id = PACK_ID; - pack.name = "test pack"; - pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; - pack.engineId = ENGINE_ID; - pack.dispatchId = DISPATCH_ID; - pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT"), - makeTestKernel(testId(0x65), "kernel_256_float", 256, "FLOAT"), - makeTestKernel(testId(0x66), "kernel_64_half", 64, "HALF")}; - - return std::make_unique>( - std::move(schema), - std::move(matchers), - std::move(dispatches), - std::vector{std::move(pack)}, - std::make_shared(SCORE_SYMBOL), - cacheCapacity); -} - -} // namespace hipdnn_plugin_sdk::ingestor::testing - -#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp deleted file mode 100644 index 9d81336f24a9..000000000000 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestor.cpp +++ /dev/null @@ -1,1190 +0,0 @@ -// Copyright © Advanced Micro Devices, Inc., or its affiliates. -// SPDX-License-Identifier: MIT - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "KernelIngestorTestFixtures.hpp" - -/** - * @file TestKernelIngestor.cpp - * @brief Unit tests for the descriptor-driven kernel ingestor's SDK-side machinery. - * - * Covers the pieces every descriptor-backed engine depends on, independent of any - * provider: the bounded catalog cache, the native symbol registry's fail-closed - * behavior, the heuristic's ranking and tie-break order, and the state manager's - * matching, pruning, caching, and validation. - * - * Provider-specific behavior (the pointwise-add matchers and the real GPU dispatch) - * is covered by the hip-kernel-provider tests. - */ -namespace -{ - -using namespace hipdnn_plugin_sdk::ingestor; - -using namespace hipdnn_plugin_sdk::ingestor::testing; - -/// Counts matcher invocations, so a test can assert that a graph-scoped matcher runs -/// once per (graph, device) while a kernel-scoped one runs once per surviving kernel. -struct MatcherCounters -{ - int graphCalls = 0; - int kernelCalls = 0; - - void reset() - { - graphCalls = 0; - kernelCalls = 0; - } -}; - -MatcherCounters& counters() -{ - static MatcherCounters s_counters; - return s_counters; -} - -/// An arbitrary value acceptGraph binds, so a test can prove it reached dispatch intact. -constexpr int64_t BOUND_TOKEN_VALUE = 4242; - -bool acceptGraph(const MatchContext& /*context*/, BoundTokens& bound) -{ - ++counters().graphCalls; - // Stands in for the tensor uids and dimensions a real matcher resolves: the point - // under test is that whatever matching bound reaches dispatch without a re-match. - bound["test.bound_token"] = BOUND_TOKEN_VALUE; - return true; -} - -bool rejectGraph(const MatchContext& /*context*/, BoundTokens& /*bound*/) -{ - ++counters().graphCalls; - return false; -} - -bool countingFloatKernels(const MatchContext& context, const KernelDefinition& kernel) -{ - ++counters().kernelCalls; - return acceptFloatKernels(context, kernel); -} - -/// Every kernel scores the same, so ranking falls through to the tie-break. -constexpr const char* CONSTANT_SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.constant_score"; - -double scoreConstant(const KernelDefinition& /*kernel*/, const MatchContext& /*context*/) -{ - return 1.0; -} - -/// Registers the constant scorer for a test's duration. The heuristic resolves its -/// symbol on first use, so the registration has to outlive the ranking call. -class ScopedConstantScore -{ -public: - ScopedConstantScore() - { - ScoreRegistry::registerSymbol(CONSTANT_SCORE_SYMBOL, &scoreConstant); - } - - ~ScopedConstantScore() - { - ScoreRegistry::unregisterSymbol(CONSTANT_SCORE_SYMBOL); - } - - ScopedConstantScore(const ScopedConstantScore&) = delete; - ScopedConstantScore& operator=(const ScopedConstantScore&) = delete; -}; - -MetadataSchema makeSchema() -{ - return {SCHEMA_ID, - "test schema", - {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, - {DTYPE, MetadataType::STRING, std::nullopt}}}; -} - -KernelDescriptor makeKernel(const DescriptorId& id, - const std::string& name, - int64_t blockSize, - const std::string& dtype, - int64_t priority = 0) -{ - auto kernel = makeTestKernel(id, name, blockSize, dtype); - kernel.priority = priority; - return kernel; -} - -/// The pack shape a real engine ships, wired to the counting matchers above. -KernelDescriptorPack makePack(const std::vector& matcherIds) -{ - KernelDescriptorPack pack; - pack.id = PACK_ID; - pack.name = "test pack"; - pack.matcherIds = matcherIds; - pack.engineId = ENGINE_ID; - pack.dispatchId = DISPATCH_ID; - pack.kernels = {makeKernel(testId(0x64), "kernel_64_float", 64, "FLOAT"), - makeKernel(testId(0x65), "kernel_256_float", 256, "FLOAT"), - makeKernel(testId(0x66), "kernel_64_half", 64, "HALF")}; - return pack; -} - -std::vector makeTestMatchers() -{ - return {{GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, - {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; -} - -std::vector makeTestDispatches() -{ - return {{DISPATCH_ID, "test dispatch", "test.dispatch"}}; -} - -/// A catalog entry, for the ranking tests that build one directly. -KernelDefinition makeDefinition(const DescriptorId& id, int64_t blockSize, int64_t priority = 0) -{ - return {id, - PACK_ID, - DISPATCH_ID, - KernelSource{KernelSourceKind::EMBEDDED_SOURCE, "Test.cpp", "TestKernel"}, - {{BLOCK_SIZE, MetadataValue{blockSize}}}, - priority}; -} - -/// Registers counting matchers under this file's own symbol names, so these tests -/// observe invocation counts without disturbing the shared fixture's registrations. -class ScopedSymbols -{ -public: - ScopedSymbols(std::string graphSymbol, - GraphMatcherFn graphFn, - std::string kernelSymbol, - KernelMatcherFn kernelFn) - : _graphSymbol(std::move(graphSymbol)) - , _kernelSymbol(std::move(kernelSymbol)) - { - GraphMatcherRegistry::registerSymbol(_graphSymbol, graphFn); - KernelMatcherRegistry::registerSymbol(_kernelSymbol, kernelFn); - // The heuristic resolves its symbol on the first call that needs an order, not - // at construction, so ranking tests need it registered for their duration too. - ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); - counters().reset(); - } - - ~ScopedSymbols() - { - GraphMatcherRegistry::unregisterSymbol(_graphSymbol); - KernelMatcherRegistry::unregisterSymbol(_kernelSymbol); - ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); - } - - ScopedSymbols(const ScopedSymbols&) = delete; - ScopedSymbols& operator=(const ScopedSymbols&) = delete; - -private: - std::string _graphSymbol; - std::string _kernelSymbol; -}; - -using TestHandle = int; -using StateManager = KernelIngestorStateManager; - -std::unique_ptr makeStateManager(const std::string& scoreSymbol = SCORE_SYMBOL, - size_t cacheCapacity - = StateManager::DEFAULT_CATALOG_CACHE_CAPACITY) -{ - std::vector matchers{ - {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, - {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; - std::vector dispatches{{DISPATCH_ID, "test dispatch", "test.dispatch"}}; - - return std::make_unique( - makeSchema(), - std::move(matchers), - std::move(dispatches), - std::vector{makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID})}, - std::make_shared(scoreSymbol), - cacheCapacity); -} - -// --------------------------------------------------------------------------- -// LruCache -// --------------------------------------------------------------------------- - -TEST(TestIngestorLruCache, ReturnsCachedValueWithinCapacity) -{ - LruCache cache(2); - cache.put(1, "one"); - - const auto found = cache.get(1); - - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, "one"); -} - -TEST(TestIngestorLruCache, ReportsMissForAbsentKey) -{ - LruCache cache(2); - - EXPECT_FALSE(cache.get(42).has_value()); -} - -TEST(TestIngestorLruCache, EvictsLeastRecentlyUsedPastCapacity) -{ - LruCache cache(2); - cache.put(1, "one"); - cache.put(2, "two"); - cache.put(3, "three"); - - EXPECT_FALSE(cache.get(1).has_value()); - EXPECT_TRUE(cache.get(2).has_value()); - EXPECT_TRUE(cache.get(3).has_value()); - EXPECT_EQ(cache.size(), 2U); -} - -TEST(TestIngestorLruCache, ReadingAnEntryProtectsItFromEviction) -{ - LruCache cache(2); - cache.put(1, "one"); - cache.put(2, "two"); - - // Touching 1 makes 2 the least recently used, so inserting 3 must evict 2, not 1. - ASSERT_TRUE(cache.get(1).has_value()); - cache.put(3, "three"); - - EXPECT_TRUE(cache.get(1).has_value()); - EXPECT_FALSE(cache.get(2).has_value()); -} - -TEST(TestIngestorLruCache, OverwritingAKeyDoesNotGrowTheCache) -{ - LruCache cache(2); - cache.put(1, "one"); - cache.put(1, "uno"); - - const auto found = cache.get(1); - - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, "uno"); - EXPECT_EQ(cache.size(), 1U); -} - -TEST(TestIngestorLruCache, RejectsZeroCapacity) -{ - // A zero-capacity cache would evict every entry as it was inserted, which is always - // a caller bug rather than a way to disable caching. - using IntCache = LruCache; - EXPECT_THROW(IntCache(0), std::invalid_argument); -} - -// --------------------------------------------------------------------------- -// NativeRegistry -// --------------------------------------------------------------------------- - -TEST(TestIngestorNativeRegistry, ResolvesARegisteredSymbol) -{ - GraphMatcherRegistry::registerSymbol("registry.resolves", acceptGraph); - - EXPECT_EQ(GraphMatcherRegistry::resolve("registry.resolves"), acceptGraph); - - GraphMatcherRegistry::unregisterSymbol("registry.resolves"); -} - -TEST(TestIngestorNativeRegistry, RejectsDuplicateRegistration) -{ - GraphMatcherRegistry::registerSymbol("registry.duplicate", acceptGraph); - - // Two implementations behind one name leaves one silently unreachable, and which one - // wins would depend on static-init order. - EXPECT_THROW(GraphMatcherRegistry::registerSymbol("registry.duplicate", rejectGraph), - std::runtime_error); - - GraphMatcherRegistry::unregisterSymbol("registry.duplicate"); -} - -TEST(TestIngestorNativeRegistry, FailsClosedOnUnknownSymbol) -{ - // A descriptor naming a symbol the provider does not ship must surface as an error, - // never as an engine that quietly matches nothing. - EXPECT_THROW(GraphMatcherRegistry::resolve("registry.never_registered"), std::runtime_error); -} - -// --------------------------------------------------------------------------- -// IKernelHeuristic -// --------------------------------------------------------------------------- - -TEST(TestIngestorHeuristic, ResolvesItsSymbolOnFirstUseRatherThanAtConstruction) -{ - // RFC 0017 section 8.1 admits the heuristic at applicability only as a name, and - // section 3 generalizes it: a heuristic model is not read until something needs the - // catalog ranked. An engine whose matchers reject a graph must never pay for its - // selector, so constructing one against a symbol that is not registered yet has to - // be legal, and only scoring may fail. - const NativeKernelHeuristic heuristic("hipdnn.kernel_ingestor.test.not_yet_registered"); - - const TestGraph graph; - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - const auto kernel = makeDefinition(testId(0x01), 64); - - EXPECT_THROW(heuristic.score(kernel, context), std::runtime_error); -} - -TEST(TestIngestorHeuristic, RanksHigherScoringKernelsFirst) -{ - const ScopedTestSymbols symbols; - const TestGraph graph; - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - Catalog catalog; - const auto lowId = testId(0x01); - const auto highId = testId(0x02); - catalog.entries = {makeDefinition(lowId, 64), makeDefinition(highId, 256)}; - - const NativeKernelHeuristic heuristic(SCORE_SYMBOL); - const auto ranked = heuristic.rank(catalog, context); - - ASSERT_EQ(ranked.size(), 2U); - EXPECT_EQ(ranked.front().kernelId, highId); -} - -TEST(TestIngestorHeuristic, BreaksScoreTiesOnPriority) -{ - const ScopedConstantScore constantScore; - const TestGraph graph; - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - Catalog catalog; - const auto lowPriorityId = testId(0x01); - const auto highPriorityId = testId(0x02); - catalog.entries = {makeDefinition(lowPriorityId, 64, 1), makeDefinition(highPriorityId, 64, 5)}; - - const NativeKernelHeuristic heuristic(CONSTANT_SCORE_SYMBOL); - const auto ranked = heuristic.rank(catalog, context); - - ASSERT_EQ(ranked.size(), 2U); - EXPECT_EQ(ranked.front().kernelId, highPriorityId); -} - -TEST(TestIngestorHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) -{ - const ScopedConstantScore constantScore; - const TestGraph graph; - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - Catalog catalog; - // Deliberately inserted out of id order: the result must not depend on load order. - // Inserted highest-id first: the result must not depend on load order. - const auto lowerId = testId(0x01); - const auto higherId = testId(0x02); - catalog.entries = {makeDefinition(higherId, 64), makeDefinition(lowerId, 64)}; - - const NativeKernelHeuristic heuristic(CONSTANT_SCORE_SYMBOL); - const auto ranked = heuristic.rank(catalog, context); - - ASSERT_EQ(ranked.size(), 2U); - EXPECT_EQ(ranked.front().kernelId, lowerId); -} - -// --------------------------------------------------------------------------- -// KernelIngestorStateManager -// --------------------------------------------------------------------------- - -TEST(TestIngestorStateManager, KernelLevelMatcherPrunesTheCatalog) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(1)); - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - const auto definitions = manager->unsortedDefinitions(context); - - // Three kernels in the pack; the HALF one does not survive. - ASSERT_EQ(definitions.size(), 2U); - for(const auto& definition : definitions) - { - EXPECT_EQ(definition.getStringMetadata(DTYPE), "FLOAT"); - } -} - -TEST(TestIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) -{ - const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(2)); - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - EXPECT_TRUE(manager->unsortedDefinitions(context).empty()); - // The whole point of the graph/kernel split: a pack that cannot serve the graph - // costs one matcher call, not one per kernel. - EXPECT_EQ(counters().graphCalls, 1); - EXPECT_EQ(counters().kernelCalls, 0); -} - -TEST(TestIngestorStateManager, MatchesOncePerGraphAndDevice) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(3)); - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - manager->unsortedDefinitions(context); - manager->unsortedDefinitions(context); - manager->unsortedDefinitions(context); - - EXPECT_EQ(counters().graphCalls, 1); - // One call per kernel in the pack, on the single uncached pass. - EXPECT_EQ(counters().kernelCalls, 3); -} - -TEST(TestIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNotOncePerPack) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - - // Matchers are shared by id across packs, so the broadly shared checks are what prune - // the candidate set fast. Re-running one per pack that lists it would make the most - // shared check the most expensive, inverting the property the graph/kernel split - // exists to provide. - auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - second.id = testId(0x80); - // A distinct metadata tuple: the uniqueness rule is engine-wide, not per pack, so - // the second pack cannot repeat a block size the first already uses. - second.kernels = {makeKernel(testId(0x81), "second_pack_kernel", 512, "FLOAT")}; - - const StateManager manager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {first, second}, - std::make_shared(SCORE_SYMBOL)); - - const TestGraph graph(makeGraphId(20)); - const auto properties = testDeviceProperties(); - const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); - - // Two packs, one shared graph-scoped matcher: evaluated once. - EXPECT_EQ(counters().graphCalls, 1); - // Two FLOAT survivors from the first pack plus one from the second. - EXPECT_EQ(definitions.size(), 3U); -} - -TEST(TestIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackListingIt) -{ - const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); - - auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - second.id = testId(0x82); - second.kernels = {makeKernel(testId(0x83), "second_pack_kernel", 512, "FLOAT")}; - - const StateManager manager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {first, second}, - std::make_shared(SCORE_SYMBOL)); - - const TestGraph graph(makeGraphId(21)); - const auto properties = testDeviceProperties(); - - EXPECT_TRUE(manager.unsortedDefinitions(MatchContext{graph, 0, properties}).empty()); - // One failing evaluation disqualifies both packs, and no per-kernel work runs at all. - EXPECT_EQ(counters().graphCalls, 1); - EXPECT_EQ(counters().kernelCalls, 0); -} - -TEST(TestIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingPackKernels) -{ - // Two packs sharing an engine, the normal case since matchers are shared by id. - // The pruned pack's own graph-scoped matcher passes and binds a token before a - // second matcher in the SAME pack fails and prunes it; the surviving pack lists - // neither of those matchers. If the pruned pack's binding reaches catalog.bound - // anyway, a kernel in the surviving pack -- which never asked for that token -- can - // read it. That is the correctness bug item 5 fixes: the header's claim that - // "nothing a failing matcher wrote can be read by a kernel that survives" held only - // by accident with one pack, and breaks the moment a second pack shares the engine. - constexpr const char* PASS_NO_BIND_SYMBOL = "test.pruned_bound_isolation.pass_no_bind"; - constexpr const char* LEAK_THEN_PRUNE_SYMBOL = "test.pruned_bound_isolation.leak"; - constexpr const char* ALWAYS_FAILS_SYMBOL = "test.pruned_bound_isolation.fail"; - - // Reused rather than reinvented: acceptAnyGraph (fixtures) passes and binds nothing; - // acceptGraph (this file) passes and binds "test.bound_token"; rejectGraph (this - // file) always fails. Exactly the three behaviors this test needs. - GraphMatcherRegistry::registerSymbol(PASS_NO_BIND_SYMBOL, &acceptAnyGraph); - GraphMatcherRegistry::registerSymbol(LEAK_THEN_PRUNE_SYMBOL, &acceptGraph); - GraphMatcherRegistry::registerSymbol(ALWAYS_FAILS_SYMBOL, &rejectGraph); - counters().reset(); - - const auto passNoBindMatcherId = testId(0xA4); - const auto leakMatcherId = testId(0xA5); - const auto failMatcherId = testId(0xA6); - - KernelDescriptorPack survivingPack; - survivingPack.id = testId(0xA0); - survivingPack.name = "surviving pack"; - survivingPack.matcherIds = {passNoBindMatcherId}; - survivingPack.engineId = ENGINE_ID; - survivingPack.dispatchId = DISPATCH_ID; - survivingPack.kernels = {makeKernel(testId(0xA1), "surviving_kernel", 64, "FLOAT")}; - - KernelDescriptorPack prunedPack; - prunedPack.id = testId(0xA2); - prunedPack.name = "pruned pack"; - // Order matters: the leaking matcher must run (and bind) before the failing one - // prunes the pack, exactly as graphLevelMatchersPass evaluates them in listed order. - prunedPack.matcherIds = {leakMatcherId, failMatcherId}; - prunedPack.engineId = ENGINE_ID; - prunedPack.dispatchId = DISPATCH_ID; - prunedPack.kernels = {makeKernel(testId(0xA3), "pruned_kernel", 128, "FLOAT")}; - - const StateManager manager( - makeSchema(), - {{passNoBindMatcherId, "passes, binds nothing", MatchScope::GRAPH, PASS_NO_BIND_SYMBOL}, - {leakMatcherId, "passes, binds a token", MatchScope::GRAPH, LEAK_THEN_PRUNE_SYMBOL}, - {failMatcherId, "always fails", MatchScope::GRAPH, ALWAYS_FAILS_SYMBOL}}, - makeTestDispatches(), - {survivingPack, prunedPack}, - std::make_shared(SCORE_SYMBOL)); - - const TestGraph graph(makeGraphId(50)); - const auto properties = testDeviceProperties(); - const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); - - // The surviving pack's kernel is the only entry: the pruned pack's kernel never - // enters the catalog, which was already correct before this fix. - ASSERT_EQ(catalog.entries.size(), 1U); - EXPECT_EQ(catalog.entries.front().packId, survivingPack.id); - - // What this test actually regresses: the token only the pruned pack's own matcher - // bound must not survive into the catalog the surviving pack's kernel reads from, - // even though that matcher itself returned true before its pack's second matcher - // failed. - EXPECT_EQ(catalog.bound.count("test.bound_token"), 0U); - - GraphMatcherRegistry::unregisterSymbol(PASS_NO_BIND_SYMBOL); - GraphMatcherRegistry::unregisterSymbol(LEAK_THEN_PRUNE_SYMBOL); - GraphMatcherRegistry::unregisterSymbol(ALWAYS_FAILS_SYMBOL); -} - -TEST(TestIngestorStateManager, MatchesSeparatelyPerDevice) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(4)); - const auto properties = testDeviceProperties(); - - manager->unsortedDefinitions(MatchContext{graph, 0, properties}); - manager->unsortedDefinitions(MatchContext{graph, 1, properties}); - - // The same graph on a different device is a different problem: a kernel applicable - // on one device need not be applicable on another. - EXPECT_EQ(counters().graphCalls, 2); -} - -TEST(TestIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - // A legacy or unfinalized graph: there is no key to memoize under, and inventing one - // would alias unrelated graphs. - const TestGraph graph; - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - const auto first = manager->unsortedDefinitions(context); - const auto second = manager->unsortedDefinitions(context); - - // Correct both times; only the caching is lost. - EXPECT_EQ(first.size(), 2U); - EXPECT_EQ(second.size(), 2U); - EXPECT_EQ(counters().graphCalls, 2); -} - -TEST(TestIngestorStateManager, CarriesWhatMatchingBoundThroughToDispatch) -{ - // RFC 0017 section 8.5: matching does double duty, deciding a kernel applies and - // binding the fields the launch will use. A dispatch handler must read those values - // rather than re-deriving them from the graph with a second notion of its shape. - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const auto properties = testDeviceProperties(); - const TestGraph graph; - const MatchContext context{graph, 0, properties}; - - const auto bound = manager->unsortedCatalog(context).bound; - - ASSERT_EQ(bound.count("test.bound_token"), 1U); - EXPECT_EQ(bound.at("test.bound_token"), BOUND_TOKEN_VALUE); -} - -TEST(TestIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematch) -{ - // Section 8.1 keeps the bound token state alongside the catalog precisely so that - // "nothing is re-matched" once a graph has been matched. Recovering these values by - // re-running the matcher would be correct and quietly quadratic. - // - // The graph carries an identity, so there is a cache entry to serve the second and - // third reads. A graph without one is matched fresh every call by design; that is why - // the entries and the bound state come back from a single call rather than two. - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const auto properties = testDeviceProperties(); - const TestGraph graph(makeGraphId(11)); - const MatchContext context{graph, 0, properties}; - - static_cast(manager->unsortedCatalog(context)); - const auto afterMatching = counters().graphCalls; - - static_cast(manager->unsortedCatalog(context).bound); - static_cast(manager->sortedCatalog(context).bound); - - EXPECT_EQ(counters().graphCalls, afterMatching); -} - -TEST(TestIngestorStateManager, RematchesAfterCacheEviction) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(SCORE_SYMBOL, 1); - const auto properties = testDeviceProperties(); - const TestGraph first(makeGraphId(5)); - const TestGraph second(makeGraphId(6)); - - manager->unsortedDefinitions(MatchContext{first, 0, properties}); - manager->unsortedDefinitions(MatchContext{second, 0, properties}); - manager->unsortedDefinitions(MatchContext{first, 0, properties}); - - // Capacity 1, so the second graph evicted the first and it had to rematch. Eviction - // costs work, never a wrong answer. - EXPECT_EQ(counters().graphCalls, 3); -} - -TEST(TestIngestorStateManager, SortedDefinitionsAreRankedBestFirst) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(7)); - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - const auto sorted = manager->sortedDefinitions(context); - - ASSERT_EQ(sorted.size(), 2U); - EXPECT_EQ(sorted.front().getIntMetadata(BLOCK_SIZE), 256); -} - -TEST(TestIngestorStateManager, RankingReusesTheAlreadyMatchedCatalog) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(8)); - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - manager->unsortedDefinitions(context); - manager->sortedDefinitions(context); - manager->sortedDefinitions(context); - - // Ranking is a read of the cached catalog, never a rematch. - EXPECT_EQ(counters().graphCalls, 1); - EXPECT_EQ(counters().kernelCalls, 3); -} - -TEST(TestIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const auto manager = makeStateManager(); - const TestGraph graph(makeGraphId(9)); - const auto properties = testDeviceProperties(); - const MatchContext context{graph, 0, properties}; - - const auto values = StateManager::knobValues(manager->sortedDefinitions(context), BLOCK_SIZE); - - // The pruned HALF kernel also carried block_size 64, but it contributes no value: - // a knob offers what the surviving catalog implements, not the schema's range. - ASSERT_EQ(values.size(), 2U); - EXPECT_EQ(std::get(values[0]), 256); - EXPECT_EQ(std::get(values[1]), 64); -} - -TEST(TestIngestorStateManager, CompletesAnOmittedFieldFromItsSchemaDefault) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - - KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - // Omits block_size, which defaults, and states dtype, which does not. - KernelDescriptor sparse; - sparse.id = testId(0x70); - sparse.name = "kernel_defaults"; - sparse.metadata = {{DTYPE, MetadataValue{std::string{"FLOAT"}}}}; - pack.kernels = {sparse}; - - const StateManager manager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {pack}, - std::make_shared(SCORE_SYMBOL)); - - const TestGraph graph(makeGraphId(10)); - const auto properties = testDeviceProperties(); - const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); - - ASSERT_EQ(definitions.size(), 1U); - EXPECT_EQ(definitions.front().getIntMetadata(BLOCK_SIZE), 64); - EXPECT_EQ(definitions.front().getStringMetadata(DTYPE), "FLOAT"); -} - -TEST(TestIngestorStateManager, RejectsAKernelOmittingAFieldWithNoDefault) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - - KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - // dtype declares no default, so every kernel must state it. Omitting it would - // otherwise produce a catalog key the author never wrote. - KernelDescriptor missingDtype; - missingDtype.id = testId(0x71); - missingDtype.name = "kernel_missing_dtype"; - missingDtype.metadata = {{BLOCK_SIZE, MetadataValue{int64_t{64}}}}; - pack.kernels = {missingDtype}; - - // Caught when the descriptor set is assembled, not when a graph first arrives: the - // completed tuple is the kernel's catalog key, so it has to exist before matching. - EXPECT_THROW(StateManager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {pack}, - std::make_shared(SCORE_SYMBOL)), - std::invalid_argument); -} - -TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldTheSchemaDoesNotDeclare) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - - KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - // A misspelled field name is the common case, and left in place it fails twice over: - // the real field silently takes its default, and the stray value joins the catalog - // key, where nothing downstream can read it. Two kernels differing only in such a - // value would both enter the catalog with selection unable to tell them apart. - KernelDescriptor undeclared; - undeclared.id = testId(0x74); - undeclared.name = "kernel_undeclared_field"; - undeclared.metadata = {{BLOCK_SIZE, MetadataValue{int64_t{64}}}, - {DTYPE, MetadataValue{std::string{"FLOAT"}}}, - {"blocksize", MetadataValue{int64_t{128}}}}; - pack.kernels = {undeclared}; - - EXPECT_THROW(StateManager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {pack}, - std::make_shared(SCORE_SYMBOL)), - std::invalid_argument); -} - -TEST(TestIngestorStateManager, RejectsAKernelSupplyingAFieldOfTheWrongType) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - - KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - // block_size is declared INT. A string here would otherwise surface far away, as a - // bad_variant_access inside a matcher or a scorer. - KernelDescriptor wrongType; - wrongType.id = testId(0x72); - wrongType.name = "kernel_wrong_type"; - wrongType.metadata = {{BLOCK_SIZE, MetadataValue{std::string{"64"}}}, - {DTYPE, MetadataValue{std::string{"FLOAT"}}}}; - pack.kernels = {wrongType}; - - EXPECT_THROW(StateManager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {pack}, - std::make_shared(SCORE_SYMBOL)), - std::invalid_argument); -} - -TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownMatcher) -{ - // A dangling cross-reference cannot be evaluated, so it is caught when the - // descriptor set is assembled rather than when a graph first arrives. - EXPECT_THROW(StateManager(makeSchema(), - {}, - makeTestDispatches(), - {makePack({testId(0xFF)})}, - std::make_shared(SCORE_SYMBOL)), - std::invalid_argument); -} - -TEST(TestIngestorStateManager, RejectsAPackNamingAnUnknownDispatchDescriptor) -{ - EXPECT_THROW(StateManager(makeSchema(), - makeTestMatchers(), - {}, - {makePack({GRAPH_MATCHER_ID})}, - std::make_shared(SCORE_SYMBOL)), - std::invalid_argument); -} - -TEST(TestIngestorStateManager, RejectsTwoKernelsSharingAMetadataTuple) -{ - KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID}); - // Same completed tuple as kernel_64_float: selection would have two indistinguishable - // candidates and no basis to prefer either. - pack.kernels.push_back(makeKernel(testId(0x73), "kernel_duplicate", 64, "FLOAT")); - - EXPECT_THROW(StateManager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {pack}, - std::make_shared(SCORE_SYMBOL)), - std::invalid_argument); -} - -TEST(TestIngestorStateManager, RejectsAMissingHeuristic) -{ - EXPECT_THROW(StateManager(makeSchema(), {}, {}, {}, nullptr), std::invalid_argument); -} - -// --------------------------------------------------------------------------- -// GenericEngine: engine-level descriptor validation -// --------------------------------------------------------------------------- - -/// Minimal stand-ins for the provider types GenericEngine is parameterized on. Only the -/// members the engine actually touches are present, so a change that starts depending on -/// more of a real handle or context fails here rather than compiling silently. -struct StubHandle -{ - void storeEngineDetailsDetachedBuffer(const void* /*ptr*/, - std::unique_ptr buffer) - { - _buffers.push_back(std::move(buffer)); - } - -private: - std::vector> _buffers; -}; - -struct StubSettings -{ - KnobFilter ingestorKnobFilter; -}; - -struct StubContext -{ - void setExecutionSettings(const StubSettings& /*settings*/) {} - - void setPlan(std::unique_ptr> plan) - { - _plan = std::move(plan); - } - - bool hasPlan() const - { - return _plan != nullptr; - } - -private: - std::unique_ptr> _plan; -}; - -/// A device resolver over StubHandle, for the engine-level tests. -class StubDeviceResolver : public IDeviceResolver -{ -public: - DeviceId deviceId(const StubHandle& /*handle*/) const override - { - return 0; - } - - const hipDeviceProp_t& deviceProperties(DeviceId /*deviceId*/) const override - { - return _properties; - } - -private: - hipDeviceProp_t _properties = testDeviceProperties(); -}; - -using StubEngine = GenericEngine; - -/// The engine-level tests need a state manager over StubHandle, which the shared fixture -/// builds over int, so this mirrors it for the stub handle type. -std::unique_ptr> makeStubStateManager() -{ - MetadataSchema schema; - schema.id = SCHEMA_ID; - schema.name = "test schema"; - schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, - {DTYPE, MetadataType::STRING, std::nullopt}}; - - KernelDescriptorPack pack; - pack.id = PACK_ID; - pack.name = "test pack"; - pack.matcherIds = {GRAPH_MATCHER_ID}; - pack.engineId = ENGINE_ID; - pack.dispatchId = DISPATCH_ID; - pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT")}; - - return std::make_unique>( - std::move(schema), - std::vector{ - {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}}, - std::vector{ - {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}, - std::vector{std::move(pack)}, - std::make_shared(SCORE_SYMBOL)); -} - -EngineDescriptor makeEngineWithKnobs(std::vector knobs) -{ - EngineDescriptor engine; - engine.id = ENGINE_ID; - engine.name = "test:engine"; - engine.heuristicId = HEURISTIC_ID; - engine.metadataSchemaId = SCHEMA_ID; - engine.knobs = std::move(knobs); - return engine; -} - -TEST(TestIngestorGenericEngine, AcceptsAKnobNamingADeclaredMetadataField) -{ - const ScopedTestSymbols symbols; - const StubDeviceResolver resolver; - - EXPECT_NO_THROW( - (StubEngine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver))); -} - -TEST(TestIngestorGenericEngine, RejectsAKnobNamingNoMetadataField) -{ - const ScopedTestSymbols symbols; - const StubDeviceResolver resolver; - - // A knob is only a name: the field it points at supplies the type, the default and - // the legal values, so a knob matching no field can never be reported or honoured. - // Left unchecked it is silently dropped, which reads to a caller exactly like a knob - // the engine chose not to expose. - EXPECT_THROW( - (StubEngine(makeEngineWithKnobs({"no_such_field"}), makeStubStateManager(), resolver)), - std::invalid_argument); -} - -// --------------------------------------------------------------------------- -// GenericPlanBuilder: knob filtering (item 6) -// --------------------------------------------------------------------------- - -/// A no-op dispatch handler, sufficient to let buildPlan() construct a GenericPlan: -/// these tests assert which kernel selection chose, not how it launches. -class NoopDispatchHandler : public IKernelDispatchHandler -{ -public: - size_t workspaceBytes(const MatchContext& /*context*/, - const BoundTokens& /*bound*/, - const KernelDefinition& /*kernel*/) const override - { - return 0; - } - - std::unique_ptr prepare(const MatchContext& /*context*/, - const BoundTokens& /*bound*/, - const KernelDefinition& /*kernel*/) const override - { - return std::make_unique(); - } - - void launch(const TestHandle& /*handle*/, - const PreparedDispatch& /*prepared*/, - const hipdnnPluginDeviceBuffer_t* /*deviceBuffers*/, - uint32_t /*numDeviceBuffers*/, - void* /*workspace*/) const override - { - } -}; - -/// Registers the no-op dispatch handler under this test suite's dispatch symbol for a -/// test's duration. -class ScopedTestDispatch -{ -public: - ScopedTestDispatch() - { - DispatchRegistry::registerSymbol("test.dispatch", &_handler); - } - - ~ScopedTestDispatch() - { - DispatchRegistry::unregisterSymbol("test.dispatch"); - } - - ScopedTestDispatch(const ScopedTestDispatch&) = delete; - ScopedTestDispatch& operator=(const ScopedTestDispatch&) = delete; - -private: - NoopDispatchHandler _handler; -}; - -struct KnobFilterSettings -{ - KnobFilter ingestorKnobFilter; -}; - -struct KnobFilterContext -{ - void setExecutionSettings(const KnobFilterSettings& /*settings*/) {} - - void setPlan(std::unique_ptr> plan) - { - _plan = std::move(plan); - } - - /// Safe without RTTI: GenericPlanBuilder only ever hands setPlan() - /// a GenericPlan, so the concrete type is known at every call site here. - const GenericPlan& plan() const - { - return static_cast&>(*_plan); - } - -private: - std::unique_ptr> _plan; -}; - -using TestPlanBuilder = GenericPlanBuilder; - -/// An IEngineConfig setting `knobName` to `value`, built the same way -/// TestEngineConfigWrapper.cpp builds one: a real flatbuffer, not a mock, so these tests -/// exercise the same parsing path a real caller's setAttribute() eventually produces. -hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeIntKnobEngineConfig( - flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, int64_t value) -{ - using namespace hipdnn_flatbuffers_sdk::data_objects; - - std::vector> knobSettings; - knobSettings.push_back(CreateKnobSettingDirect( - builder, knobName.c_str(), KnobValue::IntValue, CreateIntValue(builder, value).Union())); - auto knobsVector = builder.CreateVector(knobSettings); - builder.Finish(CreateEngineConfig(builder, ENGINE_ID.front(), knobsVector)); - - return hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper( - builder.GetBufferPointer(), builder.GetSize()); -} - -TEST(TestIngestorPlanBuilderKnobFilter, HonorsAnExplicitKnobSettingOverTheHeuristicDefault) -{ - // The heuristic (scoreByBlockSize) ranks 256 first, so this is the assertion that - // proves the knob is honored rather than merely advertised: leaving it alone would - // select 256, but setting it must select 64 instead. - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const ScopedTestDispatch dispatch; - const auto manager = makeStateManager(); - const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); - const TestDeviceResolver resolver; - const TestPlanBuilder builder(engine, *manager, resolver); - - flatbuffers::FlatBufferBuilder fbb; - const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 64); - - const TestGraph graph(makeGraphId(30)); - KnobFilterSettings settings; - builder.initializeExecutionSettings(0, graph, engineConfig, settings); - - KnobFilterContext context; - builder.buildPlan(0, graph, engineConfig, context); - - EXPECT_EQ(context.plan().kernel().getIntMetadata(BLOCK_SIZE), 64); -} - -TEST(TestIngestorPlanBuilderKnobFilter, UnsatisfiableKnobValueThrowsInvalidValueNamingItAndTheValue) -{ - const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - const ScopedTestDispatch dispatch; - const auto manager = makeStateManager(); - const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); - const TestDeviceResolver resolver; - const TestPlanBuilder builder(engine, *manager, resolver); - - // No surviving kernel carries block_size 999: the catalog matches (two FLOAT - // kernels), but knob filtering excludes both. - flatbuffers::FlatBufferBuilder fbb; - const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 999); - - const TestGraph graph(makeGraphId(31)); - KnobFilterContext context; - - try - { - builder.buildPlan(0, graph, engineConfig, context); - FAIL() << "expected HipdnnPluginException"; - } - catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) - { - EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); - EXPECT_NE(ex.getMessage().find(BLOCK_SIZE), std::string::npos); - EXPECT_NE(ex.getMessage().find("999"), std::string::npos); - } -} - -TEST(TestIngestorPlanBuilderKnobFilter, - EmptyCatalogBeforeFilteringStillThrowsInternalErrorWithItsOriginalMessage) -{ - // The pre-existing, unrelated failure: applicability accepted a graph this engine - // cannot serve at all (matching itself found nothing), independent of any knob. - // Item 6 must not collapse this into the same status or message as the - // knob-filtering case above -- a caller needs to tell "your knobs excluded - // everything" apart from "this engine cannot serve this graph regardless of knobs". - const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); - const ScopedTestDispatch dispatch; - const auto manager = makeStateManager(); - const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); - const TestDeviceResolver resolver; - const TestPlanBuilder builder(engine, *manager, resolver); - - flatbuffers::FlatBufferBuilder fbb; - // No knob set: an empty engine config is as close to "no request" as this helper - // allows, and the point under test is the no-kernels-at-all path, not filtering. - fbb.Finish(hipdnn_flatbuffers_sdk::data_objects::CreateEngineConfig(fbb, ENGINE_ID.front())); - const hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper engineConfig( - fbb.GetBufferPointer(), fbb.GetSize()); - - const TestGraph graph(makeGraphId(32)); - KnobFilterContext context; - - try - { - builder.buildPlan(0, graph, engineConfig, context); - FAIL() << "expected HipdnnPluginException"; - } - catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) - { - EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR); - EXPECT_EQ(ex.getMessage(), - "engine '" + engine.name + "' accepted this graph but has no applicable kernel"); - } -} - -} // namespace diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp index fa497b736dba..bf2220720f80 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp @@ -16,7 +16,7 @@ #include #include -#include "KernelIngestorTestFixtures.hpp" +#include "ingestor/KernelIngestorTestFixtures.hpp" /** * @file TestKernelIngestorConcurrency.cpp diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp new file mode 100644 index 000000000000..5d3c7c2f7e66 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp @@ -0,0 +1,71 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +#include +#include +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file IngestorMocks.hpp + * @brief gmock doubles for the ingestor's two pure interfaces. + * + * IKernelDispatchHandler and IDeviceResolver have no test of their own (see the Phase 2 + * plan): they are asserted only through the generic code that consumes them -- + * GenericPlan/GenericPlanBuilder for the dispatch handler, GenericPlanBuilder::contextFor + * for the device resolver. These mocks are what those tests set expectations against. + */ +namespace hipdnn_plugin_sdk::ingestor::testing +{ + +/// Mocks the native dispatch escape hatch, so GenericPlan/GenericPlanBuilder tests can +/// assert exactly what a plan asked of it (workspace queried before prepare, buffers +/// forwarded to launch unchanged) without a real kernel launch. +class MockKernelDispatchHandler : public IKernelDispatchHandler +{ +public: + MOCK_METHOD(size_t, + workspaceBytes, + (const MatchContext& context, + const BoundTokens& bound, + const KernelDefinition& kernel), + (const, override)); + MOCK_METHOD(std::unique_ptr, + prepare, + (const MatchContext& context, + const BoundTokens& bound, + const KernelDefinition& kernel), + (const, override)); + MOCK_METHOD(void, + launch, + (const StubHandle& handle, + const PreparedDispatch& prepared, + const hipdnnPluginDeviceBuffer_t* deviceBuffers, + uint32_t numDeviceBuffers, + void* workspace), + (const, override)); +}; + +/// Mocks device resolution so GenericPlanBuilder::contextFor() can be shown folding +/// per-handle device facts into the MatchContext it builds: two handles that resolve to +/// different devices must reach the matchers with different DeviceId/deviceProperties, +/// proving the resolution happens per call rather than once at construction. +class MockDeviceResolver : public IDeviceResolver +{ +public: + MOCK_METHOD(DeviceId, deviceId, (const StubHandle& handle), (const, override)); + MOCK_METHOD(const hipDeviceProp_t&, deviceProperties, (DeviceId deviceId), (const, override)); +}; + +} // namespace hipdnn_plugin_sdk::ingestor::testing + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp new file mode 100644 index 000000000000..de77ef512a8b --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp @@ -0,0 +1,667 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @file KernelIngestorTestFixtures.hpp + * @brief Shared fixtures for the ingestor's SDK-level tests, split across + * `plugin_sdk/tests/ingestor/`. + * + * Everything here is `inline`: this header is included by several translation units in + * the same test binary, so a plain free function or namespace-scope object would violate + * the one-definition rule the moment two of those files linked together. + * + * Two catalog shapes recur across the split files and are built here once: + * - The `int`-handle shape (`TestHandle`/`StateManager`/`makeStateManager()`): a pack of + * two FLOAT kernels differing in block size plus one HALF kernel a kernel-scoped + * matcher prunes, wired to counting matchers so a test can assert how often each + * scope ran. + * - The `int`-handle shape from `makeTestStateManager()`: the same catalog, wired to + * fixed (non-counting) matchers, for tests that only need a stable answer. + * - The `StubHandle` shape (`StubSettings`/`StubContext`/`makeStubStateManager()`): + * minimal stand-ins for the provider types `GenericEngine`/`GenericPlanBuilder` are + * parameterized on, for tests that construct those templates directly. + */ +namespace hipdnn_plugin_sdk::ingestor::testing +{ + +constexpr const char* BLOCK_SIZE = "block_size"; +constexpr const char* DTYPE = "dtype"; +constexpr const char* GRAPH_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.graph_match"; +constexpr const char* KERNEL_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.kernel_match"; +constexpr const char* SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.score"; + +/// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the rest +/// of the interface throws: a test that starts depending on graph contents should fail +/// loudly rather than silently match against an empty graph. +class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph +{ +public: + /// @param graphId The identity to carry, or nullopt to model a legacy or unfinalized + /// graph that has none. + explicit TestGraph(std::optional graphId = std::nullopt) + { + flatbuffers::Offset graph; + if(graphId.has_value()) + { + const auto uuid = hipdnn_flatbuffers_sdk::utilities::toFlatbufferUuid(*graphId); + auto name = _builder.CreateString("test_graph"); + hipdnn_flatbuffers_sdk::data_objects::GraphBuilder graphBuilder(_builder); + graphBuilder.add_name(name); + graphBuilder.add_id(&uuid); + graph = graphBuilder.Finish(); + } + else + { + graph = hipdnn_flatbuffers_sdk::data_objects::CreateGraphDirect(_builder, "test_graph"); + } + _builder.Finish(graph); + } + + const hipdnn_flatbuffers_sdk::data_objects::Graph& getGraph() const override + { + return *flatbuffers::GetRoot( + _builder.GetBufferPointer()); + } + + bool isValid() const override + { + return true; + } + + uint32_t nodeCount() const override + { + return 0; + } + + bool hasOnlySupportedAttributes( + std::set /*supported*/) const override + { + return true; + } + + const hipdnn_flatbuffers_sdk::data_objects::Node& getNode(uint32_t /*index*/) const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper& + getNodeWrapper(uint32_t /*index*/) const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const std::vector>& + nodeWrappers() const override + { + throw std::logic_error("TestGraph carries no nodes"); + } + + const std::unordered_map& + getTensorMap() const override + { + return _tensors; + } + +private: + flatbuffers::FlatBufferBuilder _builder; + std::unordered_map + _tensors; +}; + +inline GraphId makeGraphId(uint8_t seed) +{ + GraphId id{}; + id.fill(seed); + return id; +} + +inline hipDeviceProp_t testDeviceProperties() +{ + hipDeviceProp_t properties{}; + properties.warpSize = 64; + return properties; +} + +inline bool acceptAnyGraph(const MatchContext& /*context*/, BoundTokens& /*bound*/) +{ + return true; +} + +/// Accepts only FLOAT kernels, so a pack carrying a HALF kernel is pruned down. +inline bool acceptFloatKernels(const MatchContext& /*context*/, const KernelDefinition& kernel) +{ + return kernel.getStringMetadata(DTYPE) == "FLOAT"; +} + +/// Bigger block size scores higher, so ranking has a defined winner. +inline double scoreByBlockSize(const KernelDefinition& kernel, const MatchContext& /*context*/) +{ + return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); +} + +/// Registers this fixture's symbols for a test's duration, so tests sharing the +/// process-wide registry stay independent of one another's registrations. +class ScopedTestSymbols +{ +public: + ScopedTestSymbols() + { + GraphMatcherRegistry::registerSymbol(GRAPH_MATCH_SYMBOL, &acceptAnyGraph); + KernelMatcherRegistry::registerSymbol(KERNEL_MATCH_SYMBOL, &acceptFloatKernels); + ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); + } + + ~ScopedTestSymbols() + { + GraphMatcherRegistry::unregisterSymbol(GRAPH_MATCH_SYMBOL); + KernelMatcherRegistry::unregisterSymbol(KERNEL_MATCH_SYMBOL); + ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); + } + + ScopedTestSymbols(const ScopedTestSymbols&) = delete; + ScopedTestSymbols& operator=(const ScopedTestSymbols&) = delete; +}; + +/// A device resolver for tests that construct an engine. Reports one fixed device, since +/// nothing here exercises multi-device behavior. +class TestDeviceResolver : public IDeviceResolver +{ +public: + DeviceId deviceId(const int& /*handle*/) const override + { + return 0; + } + + const hipDeviceProp_t& deviceProperties(DeviceId /*deviceId*/) const override + { + return _properties; + } + +private: + hipDeviceProp_t _properties = testDeviceProperties(); +}; + +/// A descriptor id from a short seed, so a fixture can name ids readably while the type +/// stays the real 128-bit one. +inline DescriptorId testId(uint8_t seed) +{ + DescriptorId id{}; + id.fill(seed); + return id; +} + +inline const DescriptorId ENGINE_ID = testId(0xE0); +inline const DescriptorId SCHEMA_ID = testId(0xE1); +inline const DescriptorId HEURISTIC_ID = testId(0xE2); +inline const DescriptorId GRAPH_MATCHER_ID = testId(0xE3); +inline const DescriptorId KERNEL_MATCHER_ID = testId(0xE4); +inline const DescriptorId DISPATCH_ID = testId(0xE5); +inline const DescriptorId PACK_ID = testId(0xE6); + +inline KernelDescriptor makeTestKernel(const DescriptorId& id, + const std::string& name, + int64_t blockSize, + const std::string& dtype) +{ + KernelDescriptor kernel; + kernel.id = id; + kernel.name = name; + kernel.source.sourceFile = "Test.cpp"; + kernel.source.entryPoint = "TestKernel"; + kernel.metadata = {{BLOCK_SIZE, MetadataValue{blockSize}}, {DTYPE, MetadataValue{dtype}}}; + return kernel; +} + +/// The UED the fixture engine is built from. Separate from the state manager, which +/// holds only what selection reads. +inline EngineDescriptor makeTestEngine() +{ + EngineDescriptor engine; + engine.id = ENGINE_ID; + engine.name = "test:engine"; + engine.heuristicId = HEURISTIC_ID; + engine.metadataSchemaId = SCHEMA_ID; + engine.knobs = {BLOCK_SIZE}; + return engine; +} + +/// Two FLOAT kernels differing only in block size, plus a HALF kernel the kernel-scoped +/// matcher prunes: a catalog of two survivors with a defined ranking. +inline std::unique_ptr> + makeTestStateManager(size_t cacheCapacity + = KernelIngestorStateManager::DEFAULT_CATALOG_CACHE_CAPACITY) +{ + MetadataSchema schema; + schema.id = SCHEMA_ID; + schema.name = "test schema"; + // block_size defaults; dtype is mandatory, so every kernel below states it. + schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}; + + std::vector matchers{ + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, KERNEL_MATCH_SYMBOL}}; + std::vector dispatches{ + {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}; + + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "test pack"; + pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT"), + makeTestKernel(testId(0x65), "kernel_256_float", 256, "FLOAT"), + makeTestKernel(testId(0x66), "kernel_64_half", 64, "HALF")}; + + return std::make_unique>( + std::move(schema), + std::move(matchers), + std::move(dispatches), + std::vector{std::move(pack)}, + std::make_shared(SCORE_SYMBOL), + cacheCapacity); +} + +// --------------------------------------------------------------------------- +// Counting matchers: the `TestHandle = int` catalog wired to observe how often each +// matcher scope ran. Shared by TestKernelIngestorStateManager.cpp and +// TestGenericPlanBuilder.cpp. +// --------------------------------------------------------------------------- + +/// Counts matcher invocations, so a test can assert that a graph-scoped matcher runs +/// once per (graph, device) while a kernel-scoped one runs once per surviving kernel. +struct MatcherCounters +{ + int graphCalls = 0; + int kernelCalls = 0; + + void reset() + { + graphCalls = 0; + kernelCalls = 0; + } +}; + +inline MatcherCounters& counters() +{ + static MatcherCounters s_counters; + return s_counters; +} + +/// An arbitrary value acceptGraph binds, so a test can prove it reached dispatch intact. +constexpr int64_t BOUND_TOKEN_VALUE = 4242; + +inline bool acceptGraph(const MatchContext& /*context*/, BoundTokens& bound) +{ + ++counters().graphCalls; + // Stands in for the tensor uids and dimensions a real matcher resolves: the point + // under test is that whatever matching bound reaches dispatch without a re-match. + bound["test.bound_token"] = BOUND_TOKEN_VALUE; + return true; +} + +inline bool rejectGraph(const MatchContext& /*context*/, BoundTokens& /*bound*/) +{ + ++counters().graphCalls; + return false; +} + +inline bool countingFloatKernels(const MatchContext& context, const KernelDefinition& kernel) +{ + ++counters().kernelCalls; + return acceptFloatKernels(context, kernel); +} + +/// Every kernel scores the same, so ranking falls through to the tie-break. +constexpr const char* CONSTANT_SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.constant_score"; + +inline double scoreConstant(const KernelDefinition& /*kernel*/, const MatchContext& /*context*/) +{ + return 1.0; +} + +/// Registers the constant scorer for a test's duration. The heuristic resolves its +/// symbol on first use, so the registration has to outlive the ranking call. +class ScopedConstantScore +{ +public: + ScopedConstantScore() + { + ScoreRegistry::registerSymbol(CONSTANT_SCORE_SYMBOL, &scoreConstant); + } + + ~ScopedConstantScore() + { + ScoreRegistry::unregisterSymbol(CONSTANT_SCORE_SYMBOL); + } + + ScopedConstantScore(const ScopedConstantScore&) = delete; + ScopedConstantScore& operator=(const ScopedConstantScore&) = delete; +}; + +inline MetadataSchema makeSchema() +{ + return {SCHEMA_ID, + "test schema", + {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}}; +} + +inline KernelDescriptor makeKernel(const DescriptorId& id, + const std::string& name, + int64_t blockSize, + const std::string& dtype, + int64_t priority = 0) +{ + auto kernel = makeTestKernel(id, name, blockSize, dtype); + kernel.priority = priority; + return kernel; +} + +/// The pack shape a real engine ships, wired to the counting matchers above. +inline KernelDescriptorPack makePack(const std::vector& matcherIds) +{ + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "test pack"; + pack.matcherIds = matcherIds; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeKernel(testId(0x64), "kernel_64_float", 64, "FLOAT"), + makeKernel(testId(0x65), "kernel_256_float", 256, "FLOAT"), + makeKernel(testId(0x66), "kernel_64_half", 64, "HALF")}; + return pack; +} + +inline std::vector makeTestMatchers() +{ + return {{GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; +} + +inline std::vector makeTestDispatches() +{ + return {{DISPATCH_ID, "test dispatch", "test.dispatch"}}; +} + +/// A catalog entry, for the ranking and plan tests that build one directly. +inline KernelDefinition + makeDefinition(const DescriptorId& id, int64_t blockSize, int64_t priority = 0) +{ + return {id, + PACK_ID, + DISPATCH_ID, + KernelSource{KernelSourceKind::EMBEDDED_SOURCE, "Test.cpp", "TestKernel"}, + {{BLOCK_SIZE, MetadataValue{blockSize}}}, + priority}; +} + +/// Registers counting matchers under this file's own symbol names, so these tests +/// observe invocation counts without disturbing the shared fixture's registrations. +class ScopedSymbols +{ +public: + ScopedSymbols(std::string graphSymbol, + GraphMatcherFn graphFn, + std::string kernelSymbol, + KernelMatcherFn kernelFn) + : _graphSymbol(std::move(graphSymbol)) + , _kernelSymbol(std::move(kernelSymbol)) + { + GraphMatcherRegistry::registerSymbol(_graphSymbol, graphFn); + KernelMatcherRegistry::registerSymbol(_kernelSymbol, kernelFn); + // The heuristic resolves its symbol on the first call that needs an order, not + // at construction, so ranking tests need it registered for their duration too. + ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); + counters().reset(); + } + + ~ScopedSymbols() + { + GraphMatcherRegistry::unregisterSymbol(_graphSymbol); + KernelMatcherRegistry::unregisterSymbol(_kernelSymbol); + ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); + } + + ScopedSymbols(const ScopedSymbols&) = delete; + ScopedSymbols& operator=(const ScopedSymbols&) = delete; + +private: + std::string _graphSymbol; + std::string _kernelSymbol; +}; + +using TestHandle = int; +using StateManager = KernelIngestorStateManager; + +inline std::unique_ptr + makeStateManager(const std::string& scoreSymbol = SCORE_SYMBOL, + size_t cacheCapacity = StateManager::DEFAULT_CATALOG_CACHE_CAPACITY) +{ + std::vector matchers{ + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}; + std::vector dispatches{{DISPATCH_ID, "test dispatch", "test.dispatch"}}; + + return std::make_unique( + makeSchema(), + std::move(matchers), + std::move(dispatches), + std::vector{makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID})}, + std::make_shared(scoreSymbol), + cacheCapacity); +} + +// --------------------------------------------------------------------------- +// A no-op dispatch handler, generic over the handle type: sufficient wherever a test +// needs buildPlan() or initializeExecutionContext() to succeed without asserting on the +// launch itself. Shared by TestGenericEngine.cpp and TestGenericPlanBuilder.cpp. +// --------------------------------------------------------------------------- + +template +class NoopDispatchHandler : public IKernelDispatchHandler +{ +public: + size_t workspaceBytes(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, + const KernelDefinition& /*kernel*/) const override + { + return 0; + } + + std::unique_ptr prepare(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, + const KernelDefinition& /*kernel*/) const override + { + return std::make_unique(); + } + + void launch(const THandle& /*handle*/, + const PreparedDispatch& /*prepared*/, + const hipdnnPluginDeviceBuffer_t* /*deviceBuffers*/, + uint32_t /*numDeviceBuffers*/, + void* /*workspace*/) const override + { + } +}; + +/// Registers @p handler under @p symbol in DispatchRegistry for a test's +/// duration. +template +class ScopedDispatchRegistration +{ +public: + ScopedDispatchRegistration(std::string symbol, const IKernelDispatchHandler& handler) + : _symbol(std::move(symbol)) + { + DispatchRegistry::registerSymbol(_symbol, &handler); + } + + ~ScopedDispatchRegistration() + { + DispatchRegistry::unregisterSymbol(_symbol); + } + + ScopedDispatchRegistration(const ScopedDispatchRegistration&) = delete; + ScopedDispatchRegistration& operator=(const ScopedDispatchRegistration&) = delete; + +private: + std::string _symbol; +}; + +// --------------------------------------------------------------------------- +// StubHandle: minimal stand-ins for the provider types GenericEngine/GenericPlanBuilder +// are parameterized on. Only the members those templates actually touch are present, so +// a change that starts depending on more of a real handle or context fails here rather +// than compiling silently. +// --------------------------------------------------------------------------- + +struct StubHandle +{ + void storeEngineDetailsDetachedBuffer(const void* /*ptr*/, + std::unique_ptr buffer) + { + _buffers.push_back(std::move(buffer)); + } + +private: + std::vector> _buffers; +}; + +/// Every TSettings GenericPlanBuilder is instantiated over must carry this field (see +/// KnobFilter's doc): initializeExecutionSettings() populates it, getMaxWorkspaceSize() +/// reads it back. +struct StubSettings +{ + KnobFilter ingestorKnobFilter; +}; + +struct StubContext +{ + void setExecutionSettings(const StubSettings& /*settings*/) {} + + void setPlan(std::unique_ptr> plan) + { + _plan = std::move(plan); + } + + bool hasPlan() const + { + return _plan != nullptr; + } + +private: + std::unique_ptr> _plan; +}; + +/// A device resolver over StubHandle, for the engine-level tests. Reports one fixed +/// device; tests needing per-handle device resolution use IngestorMocks.hpp's +/// MockDeviceResolver instead. +class StubDeviceResolver : public IDeviceResolver +{ +public: + DeviceId deviceId(const StubHandle& /*handle*/) const override + { + return 0; + } + + const hipDeviceProp_t& deviceProperties(DeviceId /*deviceId*/) const override + { + return _properties; + } + +private: + hipDeviceProp_t _properties = testDeviceProperties(); +}; + +/// A state manager over StubHandle: one FLOAT kernel behind one graph-scoped matcher, +/// for the GenericEngine-level tests that only need construction to succeed. +inline std::unique_ptr> makeStubStateManager() +{ + MetadataSchema schema; + schema.id = SCHEMA_ID; + schema.name = "test schema"; + schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}; + + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "test pack"; + pack.matcherIds = {GRAPH_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT")}; + + return std::make_unique>( + std::move(schema), + std::vector{ + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, GRAPH_MATCH_SYMBOL}}, + std::vector{ + {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}, + std::vector{std::move(pack)}, + std::make_shared(SCORE_SYMBOL)); +} + +inline EngineDescriptor makeEngineWithKnobs(std::vector knobs) +{ + EngineDescriptor engine; + engine.id = ENGINE_ID; + engine.name = "test:engine"; + engine.heuristicId = HEURISTIC_ID; + engine.metadataSchemaId = SCHEMA_ID; + engine.knobs = std::move(knobs); + return engine; +} + +/// An IEngineConfig setting `knobName` to `value`, built the same way +/// TestEngineConfigWrapper.cpp builds one: a real flatbuffer, not a mock, so tests using +/// this exercise the same parsing path a real caller's setAttribute() eventually +/// produces. +inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeIntKnobEngineConfig( + flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, int64_t value) +{ + using namespace hipdnn_flatbuffers_sdk::data_objects; + + std::vector> knobSettings; + knobSettings.push_back(CreateKnobSettingDirect( + builder, knobName.c_str(), KnobValue::IntValue, CreateIntValue(builder, value).Union())); + auto knobsVector = builder.CreateVector(knobSettings); + builder.Finish(CreateEngineConfig(builder, ENGINE_ID.front(), knobsVector)); + + return hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper( + builder.GetBufferPointer(), builder.GetSize()); +} + +} // namespace hipdnn_plugin_sdk::ingestor::testing + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp new file mode 100644 index 000000000000..2d55b0c6aa24 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp @@ -0,0 +1,158 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include + +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestDescriptors.cpp + * @brief Unit tests for Descriptors.hpp: id formatting and hashing, the unified + * MetadataValue/MetadataType pairing (including the vector alternative + * item 2 added), and the KernelSource tagged union item 3 introduced. + * + * makeKernelHeuristic() is declared in IKernelHeuristic.hpp, not here, so its tests live + * in TestKernelHeuristic.cpp; this file covers HeuristicDescriptor purely as data. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +// --------------------------------------------------------------------------- +// toString(DescriptorId) / DescriptorIdHash +// --------------------------------------------------------------------------- + +TEST(TestIngestorDescriptors, ToStringFormatsAsCanonicalUuidText) +{ + const auto id = testId(0xAB); + + const auto text = toString(id); + + // Canonical UUID text form: 32 hex digits plus 4 hyphens, 36 characters. + EXPECT_EQ(text.size(), 36U); + EXPECT_EQ(text.find("ab"), 0U); +} + +TEST(TestIngestorDescriptors, DescriptorIdHashIsConsistentForEqualIds) +{ + const DescriptorIdHash hash; + const auto first = testId(0x11); + const auto second = testId(0x11); + + EXPECT_EQ(hash(first), hash(second)); +} + +TEST(TestIngestorDescriptors, DescriptorIdHashDistinguishesDifferentIds) +{ + const DescriptorIdHash hash; + + EXPECT_NE(hash(testId(0x11)), hash(testId(0x22))); +} + +// --------------------------------------------------------------------------- +// metadataTypeOf: MetadataType must track MetadataValue's variant order exactly, one +// alternative at a time, so a mismatch here means the enum and the variant have drifted. +// --------------------------------------------------------------------------- + +struct MetadataTypeCase +{ + std::string name; + MetadataValue value; + MetadataType expectedType; +}; + +class TestIngestorDescriptorsMetadataTypeOf : public ::testing::TestWithParam +{ +}; + +TEST_P(TestIngestorDescriptorsMetadataTypeOf, ReportsTheVariantsAlternativeType) +{ + EXPECT_EQ(metadataTypeOf(GetParam().value), GetParam().expectedType); +} + +INSTANTIATE_TEST_SUITE_P( + EveryAlternative, + TestIngestorDescriptorsMetadataTypeOf, + ::testing::Values( + MetadataTypeCase{"Bool", MetadataValue{true}, MetadataType::BOOL}, + MetadataTypeCase{"Int", MetadataValue{int64_t{42}}, MetadataType::INT}, + MetadataTypeCase{"Float", MetadataValue{1.5}, MetadataType::FLOAT}, + MetadataTypeCase{"String", MetadataValue{std::string{"FLOAT"}}, MetadataType::STRING}, + MetadataTypeCase{ + "IntList", MetadataValue{std::vector{1, 2, 3}}, MetadataType::INT_LIST}), + [](const ::testing::TestParamInfo& info) { return info.param.name; }); + +// --------------------------------------------------------------------------- +// KernelSourceKind / KernelSource: the tagged union item 3 introduced. +// --------------------------------------------------------------------------- + +TEST(TestIngestorDescriptors, KernelSourceDefaultsToEmbeddedSource) +{ + // The only kind this POC implements, and the kind every existing fixture kernel + // relies on defaulting to. + const KernelSource source{}; + + EXPECT_EQ(source.kind, KernelSourceKind::EMBEDDED_SOURCE); + EXPECT_TRUE(source.sourceFile.empty()); + EXPECT_TRUE(source.entryPoint.empty()); +} + +TEST(TestIngestorDescriptors, KernelSourceCarriesEmbeddedSourceFileAndEntryPoint) +{ + KernelSource source; + source.kind = KernelSourceKind::EMBEDDED_SOURCE; + source.sourceFile = "PointwiseAdd.cpp"; + source.entryPoint = "pointwise_add_kernel"; + + EXPECT_EQ(source.sourceFile, "PointwiseAdd.cpp"); + EXPECT_EQ(source.entryPoint, "pointwise_add_kernel"); +} + +/// Every kind besides EMBEDDED_SOURCE exists as a named value with no adapter yet: a +/// kernel can state one, even though nothing loads it. Distinct kind values must stay +/// distinct so a descriptor stating one is never confused with another. +TEST(TestIngestorDescriptors, EveryKernelSourceKindIsDistinct) +{ + EXPECT_NE(KernelSourceKind::EMBEDDED_SOURCE, KernelSourceKind::KPACK_SYMBOL); + EXPECT_NE(KernelSourceKind::EMBEDDED_SOURCE, KernelSourceKind::HSACO_FILE); + EXPECT_NE(KernelSourceKind::EMBEDDED_SOURCE, KernelSourceKind::ROCKE_BUILDER); + EXPECT_NE(KernelSourceKind::KPACK_SYMBOL, KernelSourceKind::HSACO_FILE); + EXPECT_NE(KernelSourceKind::KPACK_SYMBOL, KernelSourceKind::ROCKE_BUILDER); + EXPECT_NE(KernelSourceKind::HSACO_FILE, KernelSourceKind::ROCKE_BUILDER); +} + +// --------------------------------------------------------------------------- +// HeuristicDescriptor: HeuristicKind as data. The adapter dispatch on it is +// makeKernelHeuristic()'s behavior, tested in TestKernelHeuristic.cpp. +// --------------------------------------------------------------------------- + +TEST(TestIngestorDescriptors, HeuristicDescriptorDefaultsToNativeKind) +{ + const HeuristicDescriptor descriptor{}; + + EXPECT_EQ(descriptor.kind, HeuristicKind::NATIVE); +} + +TEST(TestIngestorDescriptors, HeuristicDescriptorCarriesItsPayloadIndependentOfKind) +{ + HeuristicDescriptor descriptor; + descriptor.kind = HeuristicKind::MODEL; + descriptor.payload = "model/artifact.bin"; + + EXPECT_EQ(descriptor.kind, HeuristicKind::MODEL); + EXPECT_EQ(descriptor.payload, "model/artifact.bin"); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp new file mode 100644 index 000000000000..d28122993d58 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp @@ -0,0 +1,192 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestGenericEngine.cpp + * @brief Unit tests for GenericEngine.hpp: engine-level knob validation at construction, + * and every IEngine override GenericEngine implements by delegating to its plan + * builder and state manager. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +using StubEngine = GenericEngine; + +// --------------------------------------------------------------------------- +// Construction: knob validation against the metadata schema. +// --------------------------------------------------------------------------- + +TEST(TestIngestorGenericEngine, AcceptsAKnobNamingADeclaredMetadataField) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + + EXPECT_NO_THROW( + (StubEngine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver))); +} + +TEST(TestIngestorGenericEngine, RejectsAKnobNamingNoMetadataField) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + + // A knob is only a name: the field it points at supplies the type, the default and + // the legal values, so a knob matching no field can never be reported or honoured. + // Left unchecked it is silently dropped, which reads to a caller exactly like a knob + // the engine chose not to expose. + EXPECT_THROW( + (StubEngine(makeEngineWithKnobs({"no_such_field"}), makeStubStateManager(), resolver)), + std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// IEngine overrides: each one delegates to the plan builder or the state manager, so +// these tests exercise that delegation end to end rather than re-testing the builder. +// --------------------------------------------------------------------------- + +TEST(TestIngestorGenericEngine, IdHashesTheUedNameIntoHipdnnsEngineIdSpace) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); + + // The id is derived, not stored verbatim, so this only pins that construction + // succeeds in producing a stable, non-zero id -- an engine with no id could never + // be looked up by the host. + EXPECT_NE(engine.id(), 0); +} + +TEST(TestIngestorGenericEngine, IsApplicableTrueWhenTheStateManagerHasASurvivingKernel) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); + + StubHandle handle; + const TestGraph graph(makeGraphId(0x60)); + + EXPECT_TRUE(engine.isApplicable(handle, graph)); +} + +TEST(TestIngestorGenericEngine, IsApplicableFalseWhenNoMatcherAccepts) +{ + // A distinct symbol so this test's registration does not collide with + // ScopedTestSymbols' GRAPH_MATCH_SYMBOL running concurrently in another test. + constexpr const char* REJECT_SYMBOL = "hipdnn.kernel_ingestor.test.generic_engine.reject"; + GraphMatcherRegistry::registerSymbol(REJECT_SYMBOL, &rejectGraph); + ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); + + MetadataSchema schema; + schema.id = SCHEMA_ID; + schema.name = "test schema"; + schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}; + + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "test pack"; + pack.matcherIds = {GRAPH_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT")}; + + auto stateManager = std::make_unique>( + std::move(schema), + std::vector{ + {GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, REJECT_SYMBOL}}, + std::vector{ + {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}, + std::vector{std::move(pack)}, + std::make_shared(SCORE_SYMBOL)); + + const StubDeviceResolver resolver; + const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), std::move(stateManager), resolver); + + StubHandle handle; + const TestGraph graph(makeGraphId(0x61)); + + EXPECT_FALSE(engine.isApplicable(handle, graph)); + + GraphMatcherRegistry::unregisterSymbol(REJECT_SYMBOL); + ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); +} + +TEST(TestIngestorGenericEngine, GetDetailsReportsTheEnginesKnobs) +{ + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); + + StubHandle handle; + const TestGraph graph(makeGraphId(0x62)); + hipdnnPluginConstData_t details{}; + + engine.getDetails(handle, graph, details); + + ASSERT_NE(details.ptr, nullptr); + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineDetailsWrapper wrapper(details.ptr, + details.size); + ASSERT_TRUE(wrapper.isValid()); + EXPECT_EQ(wrapper.engineId(), engine.id()); + ASSERT_EQ(wrapper.knobCount(), 1U); + EXPECT_EQ(wrapper.getKnobByName(BLOCK_SIZE).knobId(), BLOCK_SIZE); +} + +TEST(TestIngestorGenericEngine, GetMaxWorkspaceSizeDelegatesToThePlanBuilder) +{ + // The single-kernel stub state manager has no dispatch symbol registered, so the + // plan builder's own workspace query (through getDispatchDetails) throws -- proving + // the call actually reached the plan builder rather than returning a stub zero. + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); + + const StubHandle handle; + const TestGraph graph(makeGraphId(0x63)); + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper emptyConfig(nullptr, 0); + + EXPECT_THROW(engine.getMaxWorkspaceSize(handle, graph, emptyConfig), std::runtime_error); +} + +TEST(TestIngestorGenericEngine, InitializeExecutionContextDelegatesToThePlanBuilder) +{ + // Same reasoning as the workspace test: no dispatch handler is registered for the + // stub state manager's dispatch symbol, so buildPlan()'s own lookup throws. That + // failure mode is only reachable if initializeExecutionContext() actually calls + // through to the plan builder. + const ScopedTestSymbols symbols; + const StubDeviceResolver resolver; + const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); + + const StubHandle handle; + const TestGraph graph(makeGraphId(0x64)); + const hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper emptyConfig(nullptr, 0); + StubContext context; + + EXPECT_THROW(engine.initializeExecutionContext(handle, graph, emptyConfig, context), + std::runtime_error); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp new file mode 100644 index 000000000000..801ea880d218 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp @@ -0,0 +1,158 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "IngestorMocks.hpp" +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestGenericPlan.cpp + * @brief Unit tests for GenericPlan.hpp: the plan every descriptor-backed engine + * produces, holding one selected kernel and its resolved dispatch handler. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; +using ::testing::_; +using ::testing::Return; + +KernelDispatcher makeDispatcher(const MockKernelDispatchHandler& handler) +{ + return {makeDefinition(testId(0x01), 64), &handler}; +} + +TEST(TestIngestorGenericPlan, ConstructorThrowsInternalErrorWhenPrepareReturnsNull) +{ + // prepare() returning null means the handler resolved no launch for this kernel: an + // internal inconsistency between what applicability accepted and what dispatch can + // actually build, not a caller error. + const MockKernelDispatchHandler handler; + EXPECT_CALL(handler, workspaceBytes(_, _, _)).WillOnce(Return(0)); + EXPECT_CALL(handler, prepare(_, _, _)).WillOnce(Return(nullptr)); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const BoundTokens bound; + + try + { + const GenericPlan plan(makeDispatcher(handler), context, bound); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR); + } +} + +TEST(TestIngestorGenericPlan, GetWorkspaceSizeReportsWhatTheHandlerComputedAtConstruction) +{ + const MockKernelDispatchHandler handler; + EXPECT_CALL(handler, workspaceBytes(_, _, _)).WillOnce(Return(size_t{4096})); + EXPECT_CALL(handler, prepare(_, _, _)) + .WillOnce(Return(::testing::ByMove(std::make_unique()))); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const BoundTokens bound; + + const GenericPlan plan(makeDispatcher(handler), context, bound); + const StubHandle handle; + + EXPECT_EQ(plan.getWorkspaceSize(handle), 4096U); +} + +TEST(TestIngestorGenericPlan, ExecuteThrowsInvalidValueWhenWorkspaceRequiredButNoneProvided) +{ + const MockKernelDispatchHandler handler; + EXPECT_CALL(handler, workspaceBytes(_, _, _)).WillOnce(Return(size_t{1024})); + EXPECT_CALL(handler, prepare(_, _, _)) + .WillOnce(Return(::testing::ByMove(std::make_unique()))); + // Workspace is missing, so launch() must never be reached. + EXPECT_CALL(handler, launch(_, _, _, _, _)).Times(0); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const BoundTokens bound; + + const GenericPlan plan(makeDispatcher(handler), context, bound); + const StubHandle handle; + + try + { + plan.execute(handle, nullptr, 0, nullptr); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); + } +} + +TEST(TestIngestorGenericPlan, ExecuteForwardsDeviceBuffersAndWorkspaceToLaunchUnchanged) +{ + const MockKernelDispatchHandler handler; + EXPECT_CALL(handler, workspaceBytes(_, _, _)).WillOnce(Return(size_t{256})); + EXPECT_CALL(handler, prepare(_, _, _)) + .WillOnce(Return(::testing::ByMove(std::make_unique()))); + + const std::array buffers{ + {{/*uid=*/7, /*ptr=*/reinterpret_cast(0x1234)}}}; + int workspaceStorage = 0; + void* const workspace = &workspaceStorage; + + EXPECT_CALL(handler, launch(_, _, buffers.data(), 1U, workspace)).Times(1); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const BoundTokens bound; + + const GenericPlan plan(makeDispatcher(handler), context, bound); + const StubHandle handle; + + plan.execute(handle, buffers.data(), 1, workspace); +} + +TEST(TestIngestorGenericPlan, KernelReturnsTheDispatchersSelectedKernel) +{ + const MockKernelDispatchHandler handler; + EXPECT_CALL(handler, workspaceBytes(_, _, _)).WillOnce(Return(0)); + EXPECT_CALL(handler, prepare(_, _, _)) + .WillOnce(Return(::testing::ByMove(std::make_unique()))); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const BoundTokens bound; + const auto kernelId = testId(0x77); + + const GenericPlan plan({makeDefinition(kernelId, 64), &handler}, context, bound); + + EXPECT_EQ(plan.kernel().kernelId, kernelId); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp new file mode 100644 index 000000000000..9355fcec496a --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp @@ -0,0 +1,444 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "IngestorMocks.hpp" +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestGenericPlanBuilder.cpp + * @brief Unit tests for GenericPlanBuilder.hpp: applicability, the workspace query's + * max-across-survivors, buildPlan's two distinct empty-catalog failure modes, + * getCustomKnobs' int-only reporting, the knob-filtering contract item 6 added + * (a set knob actually selects a kernel, an unsatisfiable one throws), and + * contextFor()'s per-handle device resolution. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; +using ::testing::_; +using ::testing::Ref; +using ::testing::Return; +using ::testing::ReturnRef; + +// --------------------------------------------------------------------------- +// isApplicable / getMaxWorkspaceSize / buildPlan / getCustomKnobs, using the +// TestHandle = int fixture shape already wired for a two-survivor catalog. +// --------------------------------------------------------------------------- + +/// A dispatch handler whose workspace requirement equals the kernel's block_size, so a +/// getMaxWorkspaceSize test can assert on a real max rather than a constant every kernel +/// shares. +class WorkspaceEqualsBlockSizeHandler : public IKernelDispatchHandler +{ +public: + size_t workspaceBytes(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, + const KernelDefinition& kernel) const override + { + return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); + } + + std::unique_ptr prepare(const MatchContext& /*context*/, + const BoundTokens& /*bound*/, + const KernelDefinition& /*kernel*/) const override + { + return std::make_unique(); + } + + void launch(const TestHandle& /*handle*/, + const PreparedDispatch& /*prepared*/, + const hipdnnPluginDeviceBuffer_t* /*deviceBuffers*/, + uint32_t /*numDeviceBuffers*/, + void* /*workspace*/) const override + { + } +}; + +struct KnobFilterSettings +{ + KnobFilter ingestorKnobFilter; +}; + +struct KnobFilterContext +{ + void setExecutionSettings(const KnobFilterSettings& /*settings*/) {} + + void setPlan(std::unique_ptr> plan) + { + _plan = std::move(plan); + } + + /// Safe without RTTI: GenericPlanBuilder only ever hands setPlan() + /// a GenericPlan, so the concrete type is known at every call site here. + const GenericPlan& plan() const + { + return static_cast&>(*_plan); + } + +private: + std::unique_ptr> _plan; +}; + +using TestPlanBuilder = GenericPlanBuilder; + +hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper + makeEmptyEngineConfig(flatbuffers::FlatBufferBuilder& builder) +{ + builder.Finish( + hipdnn_flatbuffers_sdk::data_objects::CreateEngineConfig(builder, ENGINE_ID.front())); + return hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper( + builder.GetBufferPointer(), builder.GetSize()); +} + +TEST(TestIngestorGenericPlanBuilder, IsApplicableTrueWhenTheCatalogHasASurvivor) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + const TestGraph graph(makeGraphId(0x90)); + + EXPECT_TRUE(builder.isApplicable(0, graph)); +} + +TEST(TestIngestorGenericPlanBuilder, IsApplicableFalseWhenTheGraphMatcherRejects) +{ + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + const TestGraph graph(makeGraphId(0x91)); + + EXPECT_FALSE(builder.isApplicable(0, graph)); +} + +TEST(TestIngestorGenericPlanBuilder, GetMaxWorkspaceSizeTakesTheMaxAcrossSurvivors) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const WorkspaceEqualsBlockSizeHandler handler; + const ScopedDispatchRegistration dispatch("test.dispatch", handler); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + const TestGraph graph(makeGraphId(0x92)); + // Two FLOAT survivors, block_size 64 and 256: the max must be 256, not the first + // entry or an unweighted sum. + EXPECT_EQ(builder.getMaxWorkspaceSize(0, graph, KnobFilterSettings{}), 256U); +} + +TEST(TestIngestorGenericPlanBuilder, BuildPlanThrowsInternalErrorOnAnEmptyRankedCatalog) +{ + // The pre-existing, unrelated failure: applicability accepted a graph this engine + // cannot serve at all (matching itself found nothing), independent of any knob. + // Item 6 must not collapse this into the same status or message as the + // knob-filtering case below -- a caller needs to tell "your knobs excluded + // everything" apart from "this engine cannot serve this graph regardless of knobs". + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeEmptyEngineConfig(fbb); + const TestGraph graph(makeGraphId(0x93)); + KnobFilterContext context; + + try + { + builder.buildPlan(0, graph, engineConfig, context); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR); + EXPECT_EQ(ex.getMessage(), + "engine '" + engine.name + "' accepted this graph but has no applicable kernel"); + } +} + +TEST(TestIngestorGenericPlanBuilder, GetCustomKnobsReportsMinMaxStepAndRankedDefault) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + const TestGraph graph(makeGraphId(0x94)); + const auto knobs = builder.getCustomKnobs(0, graph); + + ASSERT_EQ(knobs.size(), 1U); + const auto& knob = knobs.front(); + EXPECT_EQ(knob.knob_id, BLOCK_SIZE); + ASSERT_TRUE(knob.constraint.AsIntConstraint() != nullptr); + const auto& constraint = *knob.constraint.AsIntConstraint(); + EXPECT_EQ(constraint.min_value, 64); + EXPECT_EQ(constraint.max_value, 256); + EXPECT_EQ(constraint.step, 1); + ASSERT_TRUE(knob.default_value.AsIntValue() != nullptr); + // The heuristic ranks 256 first, so leaving the knob alone must reproduce that pick. + EXPECT_EQ(knob.default_value.AsIntValue()->value, 256); +} + +TEST(TestIngestorGenericPlanBuilder, GetCustomKnobsSkipsFieldsWithNoIntegerValues) +{ + // dtype is a declared, engine-exposed knob but every value it takes is a string, so + // it can never be expressed as an integer knob and must be silently skipped rather + // than stringified into a number that would mean nothing to a caller. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE, DTYPE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + const TestGraph graph(makeGraphId(0x95)); + const auto knobs = builder.getCustomKnobs(0, graph); + + ASSERT_EQ(knobs.size(), 1U); + EXPECT_EQ(knobs.front().knob_id, BLOCK_SIZE); +} + +// --------------------------------------------------------------------------- +// Knob filtering (item 6): relocated unchanged from Phase 1's regression tests. +// --------------------------------------------------------------------------- + +TEST(TestIngestorGenericPlanBuilder, HonorsAnExplicitKnobSettingOverTheHeuristicDefault) +{ + // The heuristic (scoreByBlockSize) ranks 256 first, so this is the assertion that + // proves the knob is honored rather than merely advertised: leaving it alone would + // select 256, but setting it must select 64 instead. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const WorkspaceEqualsBlockSizeHandler handler; + const ScopedDispatchRegistration dispatch("test.dispatch", handler); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 64); + + const TestGraph graph(makeGraphId(30)); + KnobFilterSettings settings; + builder.initializeExecutionSettings(0, graph, engineConfig, settings); + + KnobFilterContext context; + builder.buildPlan(0, graph, engineConfig, context); + + EXPECT_EQ(context.plan().kernel().getIntMetadata(BLOCK_SIZE), 64); +} + +TEST(TestIngestorGenericPlanBuilder, UnsatisfiableKnobValueThrowsInvalidValueNamingItAndTheValue) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const WorkspaceEqualsBlockSizeHandler handler; + const ScopedDispatchRegistration dispatch("test.dispatch", handler); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + // No surviving kernel carries block_size 999: the catalog matches (two FLOAT + // kernels), but knob filtering excludes both. + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 999); + + const TestGraph graph(makeGraphId(31)); + KnobFilterContext context; + + try + { + builder.buildPlan(0, graph, engineConfig, context); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); + EXPECT_NE(ex.getMessage().find(BLOCK_SIZE), std::string::npos); + EXPECT_NE(ex.getMessage().find("999"), std::string::npos); + } +} + +TEST(TestIngestorGenericPlanBuilder, GetMaxWorkspaceSizeHonorsTheSameKnobFilterAsBuildPlan) +{ + // A workspace query and a plan build must not disagree about which kernels a knob + // combination admits: setting block_size=64 must restrict the max to the 64 + // kernel's requirement, not the unfiltered catalog's 256. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const WorkspaceEqualsBlockSizeHandler handler; + const ScopedDispatchRegistration dispatch("test.dispatch", handler); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 64); + const TestGraph graph(makeGraphId(0x96)); + + KnobFilterSettings settings; + builder.initializeExecutionSettings(0, graph, engineConfig, settings); + + EXPECT_EQ(builder.getMaxWorkspaceSize(0, graph, settings), 64U); +} + +TEST(TestIngestorGenericPlanBuilder, + GetMaxWorkspaceSizeThrowsInvalidValueWhenTheFilterIsUnsatisfiable) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const WorkspaceEqualsBlockSizeHandler handler; + const ScopedDispatchRegistration dispatch("test.dispatch", handler); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeIntKnobEngineConfig(fbb, BLOCK_SIZE, 999); + const TestGraph graph(makeGraphId(0x97)); + + KnobFilterSettings settings; + builder.initializeExecutionSettings(0, graph, engineConfig, settings); + + EXPECT_THROW(builder.getMaxWorkspaceSize(0, graph, settings), + hipdnn_plugin_sdk::HipdnnPluginException); +} + +TEST(TestIngestorGenericPlanBuilder, + EmptyCatalogBeforeFilteringStillThrowsInternalErrorWithItsOriginalMessage) +{ + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + const WorkspaceEqualsBlockSizeHandler handler; + const ScopedDispatchRegistration dispatch("test.dispatch", handler); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + // No knob set: an empty engine config is as close to "no request" as this helper + // allows, and the point under test is the no-kernels-at-all path, not filtering. + const auto engineConfig = makeEmptyEngineConfig(fbb); + + const TestGraph graph(makeGraphId(32)); + KnobFilterContext context; + + try + { + builder.buildPlan(0, graph, engineConfig, context); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR); + EXPECT_EQ(ex.getMessage(), + "engine '" + engine.name + "' accepted this graph but has no applicable kernel"); + } +} + +// --------------------------------------------------------------------------- +// contextFor(): a mocked device resolver proves per-handle device resolution folds +// into the MatchContext that reaches the matchers, using StubHandle/StubSettings/ +// StubContext so the mock's handle type matches IngestorMocks.hpp's. +// --------------------------------------------------------------------------- + +constexpr DeviceId DEVICE_FOR_HANDLE_A = 42; +constexpr DeviceId DEVICE_FOR_HANDLE_B = 7; +constexpr const char* DEVICE_GATED_MATCH_SYMBOL + = "hipdnn.kernel_ingestor.test.generic_plan_builder.device_gated"; + +bool acceptsOnlyDeviceA(const MatchContext& context, BoundTokens& /*bound*/) +{ + return context.deviceId == DEVICE_FOR_HANDLE_A; +} + +TEST(TestIngestorGenericPlanBuilder, ContextForFoldsPerHandleDeviceResolutionIntoTheMatchContext) +{ + GraphMatcherRegistry::registerSymbol(DEVICE_GATED_MATCH_SYMBOL, &acceptsOnlyDeviceA); + ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); + + MetadataSchema schema; + schema.id = SCHEMA_ID; + schema.name = "test schema"; + schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataType::STRING, std::nullopt}}; + + KernelDescriptorPack pack; + pack.id = PACK_ID; + pack.name = "test pack"; + pack.matcherIds = {GRAPH_MATCHER_ID}; + pack.engineId = ENGINE_ID; + pack.dispatchId = DISPATCH_ID; + pack.kernels = {makeTestKernel(testId(0x64), "kernel_64_float", 64, "FLOAT")}; + + const KernelIngestorStateManager manager( + std::move(schema), + std::vector{ + {GRAPH_MATCHER_ID, "device gated", MatchScope::GRAPH, DEVICE_GATED_MATCH_SYMBOL}}, + std::vector{ + {DISPATCH_ID, "test dispatch", "hipdnn.kernel_ingestor.test.dispatch"}}, + std::vector{std::move(pack)}, + std::make_shared(SCORE_SYMBOL)); + + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const MockDeviceResolver resolver; + const auto properties = testDeviceProperties(); + const StubHandle handleA; + const StubHandle handleB; + + EXPECT_CALL(resolver, deviceId(Ref(handleA))).WillRepeatedly(Return(DEVICE_FOR_HANDLE_A)); + EXPECT_CALL(resolver, deviceId(Ref(handleB))).WillRepeatedly(Return(DEVICE_FOR_HANDLE_B)); + EXPECT_CALL(resolver, deviceProperties(_)).WillRepeatedly(ReturnRef(properties)); + + const GenericPlanBuilder builder( + engine, manager, resolver); + const TestGraph graph(makeGraphId(0x98)); + + // Same builder, same graph, two handles: only the resolved device differs, and only + // handleA's device satisfies the matcher. isApplicable can differ only if + // contextFor() actually asked the resolver per handle rather than resolving once. + EXPECT_TRUE(builder.isApplicable(handleA, graph)); + EXPECT_FALSE(builder.isApplicable(handleB, graph)); + + GraphMatcherRegistry::unregisterSymbol(DEVICE_GATED_MATCH_SYMBOL); + ScoreRegistry::unregisterSymbol(SCORE_SYMBOL); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp new file mode 100644 index 000000000000..3931ea46adb1 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp @@ -0,0 +1,177 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestKernelDefinition.cpp + * @brief Unit tests for KernelDefinition.hpp: tryGetMetadata()'s optional contract, and + * the three typed getters (int, string, int-list) over both their happy path and + * their two throw branches (absent field, wrong alternative). + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +KernelDefinition makeKernelWithMetadata(MetadataValues metadata) +{ + return {testId(0x01), + PACK_ID, + DISPATCH_ID, + KernelSource{KernelSourceKind::EMBEDDED_SOURCE, "Test.cpp", "TestKernel"}, + std::move(metadata), + /*priority=*/0}; +} + +// --------------------------------------------------------------------------- +// tryGetMetadata +// --------------------------------------------------------------------------- + +TEST(TestIngestorKernelDefinition, TryGetMetadataReturnsTheValueWhenPresent) +{ + const auto kernel = makeKernelWithMetadata({{BLOCK_SIZE, MetadataValue{int64_t{64}}}}); + + const auto value = kernel.tryGetMetadata(BLOCK_SIZE); + + ASSERT_TRUE(value.has_value()); + EXPECT_EQ(std::get(*value), 64); +} + +TEST(TestIngestorKernelDefinition, TryGetMetadataReturnsNulloptWhenAbsent) +{ + // Nullopt rather than throwing, so a matcher or scorer written against a newer + // schema can ask about a field an older kernel predates. + const auto kernel = makeKernelWithMetadata({}); + + EXPECT_EQ(kernel.tryGetMetadata(BLOCK_SIZE), std::nullopt); +} + +// --------------------------------------------------------------------------- +// Typed accessor happy path: one per alternative getIntMetadata / getStringMetadata / +// getIntListMetadata reads. +// --------------------------------------------------------------------------- + +TEST(TestIngestorKernelDefinition, GetIntMetadataReturnsTheIntegerValue) +{ + const auto kernel = makeKernelWithMetadata({{BLOCK_SIZE, MetadataValue{int64_t{256}}}}); + + EXPECT_EQ(kernel.getIntMetadata(BLOCK_SIZE), 256); +} + +TEST(TestIngestorKernelDefinition, GetStringMetadataReturnsTheStringValue) +{ + const auto kernel = makeKernelWithMetadata({{DTYPE, MetadataValue{std::string{"FLOAT"}}}}); + + EXPECT_EQ(kernel.getStringMetadata(DTYPE), "FLOAT"); +} + +TEST(TestIngestorKernelDefinition, GetIntListMetadataReturnsTheListValue) +{ + constexpr const char* STRIDE_ORDER = "stride_order"; + const auto kernel + = makeKernelWithMetadata({{STRIDE_ORDER, MetadataValue{std::vector{3, 1, 2, 0}}}}); + + EXPECT_EQ(kernel.getIntListMetadata(STRIDE_ORDER), (std::vector{3, 1, 2, 0})); +} + +// --------------------------------------------------------------------------- +// Throw matrix: each of the three typed getters, crossed with "field absent" +// (out_of_range) and "field holds a different alternative" (invalid_argument). Both are +// author errors a validating loader would have caught, so they throw rather than +// returning a default that would silently mis-rank or mis-launch a kernel. +// --------------------------------------------------------------------------- + +struct KernelDefinitionThrowCase +{ + std::string name; + /// Builds the kernel this case exercises. A function rather than a stored + /// KernelDefinition so each case can supply metadata shaped for exactly the + /// alternative it is testing. + std::function makeKernel; + /// Invokes the getter under test against the kernel. A function so the matrix can + /// share one fixture shape across three differently-typed accessors. + std::function callGetter; + /// True when the getter must throw std::out_of_range (field absent); false when it + /// must throw std::invalid_argument (wrong alternative). RTTI is disabled in this + /// build, so the matrix distinguishes exception types via EXPECT_THROW's static + /// type rather than dynamic_cast. + bool expectsOutOfRange; +}; + +class TestIngestorKernelDefinitionThrowMatrix + : public ::testing::TestWithParam +{ +}; + +TEST_P(TestIngestorKernelDefinitionThrowMatrix, ThrowsOnTheExpectedFailureMode) +{ + const auto& testCase = GetParam(); + const auto kernel = testCase.makeKernel(); + + if(testCase.expectsOutOfRange) + { + EXPECT_THROW(testCase.callGetter(kernel), std::out_of_range); + } + else + { + EXPECT_THROW(testCase.callGetter(kernel), std::invalid_argument); + } +} + +INSTANTIATE_TEST_SUITE_P( + EveryAccessorTimesEveryFailureMode, + TestIngestorKernelDefinitionThrowMatrix, + ::testing::Values( + KernelDefinitionThrowCase{ + "GetIntMetadataAbsentFieldThrowsOutOfRange", + [] { return makeKernelWithMetadata({}); }, + [](const KernelDefinition& kernel) { kernel.getIntMetadata(BLOCK_SIZE); }, + /*expectsOutOfRange=*/true}, + KernelDefinitionThrowCase{ + "GetIntMetadataWrongAlternativeThrowsInvalidArgument", + [] { return makeKernelWithMetadata({{BLOCK_SIZE, MetadataValue{std::string{"64"}}}}); }, + [](const KernelDefinition& kernel) { kernel.getIntMetadata(BLOCK_SIZE); }, + /*expectsOutOfRange=*/false}, + KernelDefinitionThrowCase{ + "GetStringMetadataAbsentFieldThrowsOutOfRange", + [] { return makeKernelWithMetadata({}); }, + [](const KernelDefinition& kernel) { kernel.getStringMetadata(DTYPE); }, + /*expectsOutOfRange=*/true}, + KernelDefinitionThrowCase{ + "GetStringMetadataWrongAlternativeThrowsInvalidArgument", + [] { return makeKernelWithMetadata({{DTYPE, MetadataValue{int64_t{1}}}}); }, + [](const KernelDefinition& kernel) { kernel.getStringMetadata(DTYPE); }, + /*expectsOutOfRange=*/false}, + KernelDefinitionThrowCase{ + "GetIntListMetadataAbsentFieldThrowsOutOfRange", + [] { return makeKernelWithMetadata({}); }, + [](const KernelDefinition& kernel) { kernel.getIntListMetadata("stride_order"); }, + /*expectsOutOfRange=*/true}, + KernelDefinitionThrowCase{ + "GetIntListMetadataWrongAlternativeThrowsInvalidArgument", + [] { return makeKernelWithMetadata({{"stride_order", MetadataValue{int64_t{1}}}}); }, + [](const KernelDefinition& kernel) { kernel.getIntListMetadata("stride_order"); }, + /*expectsOutOfRange=*/false}), + [](const ::testing::TestParamInfo& info) { + return info.param.name; + }); + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp new file mode 100644 index 000000000000..f8251f16de36 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp @@ -0,0 +1,147 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestKernelHeuristic.cpp + * @brief Unit tests for IKernelHeuristic.hpp: NativeKernelHeuristic's lazy symbol + * resolution, the ranking/tie-break order every IKernelHeuristic gets for free + * from rank(), and the makeKernelHeuristic() factory. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +TEST(TestIngestorKernelHeuristic, ResolvesItsSymbolOnFirstUseRatherThanAtConstruction) +{ + // RFC 0017 section 8.1 admits the heuristic at applicability only as a name, and + // section 3 generalizes it: a heuristic model is not read until something needs the + // catalog ranked. An engine whose matchers reject a graph must never pay for its + // selector, so constructing one against a symbol that is not registered yet has to + // be legal, and only scoring may fail. + const NativeKernelHeuristic heuristic("hipdnn.kernel_ingestor.test.not_yet_registered"); + + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + const auto kernel = makeDefinition(testId(0x01), 64); + + EXPECT_THROW(heuristic.score(kernel, context), std::runtime_error); +} + +TEST(TestIngestorKernelHeuristic, RanksHigherScoringKernelsFirst) +{ + const ScopedTestSymbols symbols; + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + Catalog catalog; + const auto lowId = testId(0x01); + const auto highId = testId(0x02); + catalog.entries = {makeDefinition(lowId, 64), makeDefinition(highId, 256)}; + + const NativeKernelHeuristic heuristic(SCORE_SYMBOL); + const auto ranked = heuristic.rank(catalog, context); + + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().kernelId, highId); +} + +TEST(TestIngestorKernelHeuristic, BreaksScoreTiesOnPriority) +{ + const ScopedConstantScore constantScore; + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + Catalog catalog; + const auto lowPriorityId = testId(0x01); + const auto highPriorityId = testId(0x02); + catalog.entries = {makeDefinition(lowPriorityId, 64, 1), makeDefinition(highPriorityId, 64, 5)}; + + const NativeKernelHeuristic heuristic(CONSTANT_SCORE_SYMBOL); + const auto ranked = heuristic.rank(catalog, context); + + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().kernelId, highPriorityId); +} + +TEST(TestIngestorKernelHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcrossRuns) +{ + const ScopedConstantScore constantScore; + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + Catalog catalog; + // Inserted highest-id first: the result must not depend on load order. + const auto lowerId = testId(0x01); + const auto higherId = testId(0x02); + catalog.entries = {makeDefinition(higherId, 64), makeDefinition(lowerId, 64)}; + + const NativeKernelHeuristic heuristic(CONSTANT_SCORE_SYMBOL); + const auto ranked = heuristic.rank(catalog, context); + + ASSERT_EQ(ranked.size(), 2U); + EXPECT_EQ(ranked.front().kernelId, lowerId); +} + +// --------------------------------------------------------------------------- +// makeKernelHeuristic(): the UHD -> IKernelHeuristic adapter factory. +// --------------------------------------------------------------------------- + +TEST(TestIngestorKernelHeuristic, MakeKernelHeuristicBuildsANativeHeuristicForNativeKind) +{ + const ScopedTestSymbols symbols; + + HeuristicDescriptor descriptor; + descriptor.id = HEURISTIC_ID; + descriptor.name = "test heuristic"; + descriptor.kind = HeuristicKind::NATIVE; + descriptor.payload = SCORE_SYMBOL; + + const auto heuristic = makeKernelHeuristic(descriptor); + + ASSERT_NE(heuristic, nullptr); + // Proves the factory actually wired the payload symbol through: scoring against the + // registered scorer must succeed and return what it computes. + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + EXPECT_EQ(heuristic->score(makeDefinition(testId(0x01), 128), context), 128.0); +} + +TEST(TestIngestorKernelHeuristic, MakeKernelHeuristicThrowsForAKindWithNoAdapter) +{ + // HeuristicKind::MODEL has no adapter yet: RFC 0017's UHD follow-up owns it. Failing + // at descriptor-assembly time, not at the first rank(), matches the other + // cross-reference checks the state manager runs eagerly. + HeuristicDescriptor descriptor; + descriptor.id = HEURISTIC_ID; + descriptor.name = "model heuristic"; + descriptor.kind = HeuristicKind::MODEL; + descriptor.payload = "some/model/artifact.bin"; + + EXPECT_THROW(makeKernelHeuristic(descriptor), std::invalid_argument); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp new file mode 100644 index 000000000000..3873d055dc9e --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp @@ -0,0 +1,557 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestKernelIngestorStateManager.cpp + * @brief Unit tests for KernelIngestorStateManager.hpp: matching, pruning, caching, + * ranking-reuse, knob value discovery, metadata completion, and the eager + * construction-time validation the constructor's doc calls out. + * + * The per-pack bound-state isolation regression (item 5) lives here too: + * PrunedPackBindingsAreNotVisibleToSurvivingPackKernels, unchanged from its Phase 1 + * introduction. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +// --------------------------------------------------------------------------- +// Matching and pruning +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorStateManager, KernelLevelMatcherPrunesTheCatalog) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(1)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto definitions = manager->unsortedDefinitions(context); + + // Three kernels in the pack; the HALF one does not survive. + ASSERT_EQ(definitions.size(), 2U); + for(const auto& definition : definitions) + { + EXPECT_EQ(definition.getStringMetadata(DTYPE), "FLOAT"); + } +} + +TEST(TestKernelIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) +{ + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(2)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + EXPECT_TRUE(manager->unsortedDefinitions(context).empty()); + // The whole point of the graph/kernel split: a pack that cannot serve the graph + // costs one matcher call, not one per kernel. + EXPECT_EQ(counters().graphCalls, 1); + EXPECT_EQ(counters().kernelCalls, 0); +} + +TEST(TestKernelIngestorStateManager, MatchesOncePerGraphAndDevice) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(3)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + manager->unsortedDefinitions(context); + manager->unsortedDefinitions(context); + manager->unsortedDefinitions(context); + + EXPECT_EQ(counters().graphCalls, 1); + // One call per kernel in the pack, on the single uncached pass. + EXPECT_EQ(counters().kernelCalls, 3); +} + +TEST(TestKernelIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNotOncePerPack) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + + // Matchers are shared by id across packs, so the broadly shared checks are what prune + // the candidate set fast. Re-running one per pack that lists it would make the most + // shared check the most expensive, inverting the property the graph/kernel split + // exists to provide. + auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + second.id = testId(0x80); + // A distinct metadata tuple: the uniqueness rule is engine-wide, not per pack, so + // the second pack cannot repeat a block size the first already uses. + second.kernels = {makeKernel(testId(0x81), "second_pack_kernel", 512, "FLOAT")}; + + const StateManager manager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {first, second}, + std::make_shared(SCORE_SYMBOL)); + + const TestGraph graph(makeGraphId(20)); + const auto properties = testDeviceProperties(); + const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); + + // Two packs, one shared graph-scoped matcher: evaluated once. + EXPECT_EQ(counters().graphCalls, 1); + // Two FLOAT survivors from the first pack plus one from the second. + EXPECT_EQ(definitions.size(), 3U); +} + +TEST(TestKernelIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackListingIt) +{ + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + + auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + second.id = testId(0x82); + second.kernels = {makeKernel(testId(0x83), "second_pack_kernel", 512, "FLOAT")}; + + const StateManager manager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {first, second}, + std::make_shared(SCORE_SYMBOL)); + + const TestGraph graph(makeGraphId(21)); + const auto properties = testDeviceProperties(); + + EXPECT_TRUE(manager.unsortedDefinitions(MatchContext{graph, 0, properties}).empty()); + // One failing evaluation disqualifies both packs, and no per-kernel work runs at all. + EXPECT_EQ(counters().graphCalls, 1); + EXPECT_EQ(counters().kernelCalls, 0); +} + +TEST(TestKernelIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingPackKernels) +{ + // Two packs sharing an engine, the normal case since matchers are shared by id. + // The pruned pack's own graph-scoped matcher passes and binds a token before a + // second matcher in the SAME pack fails and prunes it; the surviving pack lists + // neither of those matchers. If the pruned pack's binding reaches catalog.bound + // anyway, a kernel in the surviving pack -- which never asked for that token -- can + // read it. That is the correctness bug item 5 fixes: the header's claim that + // "nothing a failing matcher wrote can be read by a kernel that survives" held only + // by accident with one pack, and breaks the moment a second pack shares the engine. + constexpr const char* PASS_NO_BIND_SYMBOL = "test.pruned_bound_isolation.pass_no_bind"; + constexpr const char* LEAK_THEN_PRUNE_SYMBOL = "test.pruned_bound_isolation.leak"; + constexpr const char* ALWAYS_FAILS_SYMBOL = "test.pruned_bound_isolation.fail"; + + // Reused rather than reinvented: acceptAnyGraph (fixtures) passes and binds nothing; + // acceptGraph (this file's fixtures) passes and binds "test.bound_token"; rejectGraph + // always fails. Exactly the three behaviors this test needs. + GraphMatcherRegistry::registerSymbol(PASS_NO_BIND_SYMBOL, &acceptAnyGraph); + GraphMatcherRegistry::registerSymbol(LEAK_THEN_PRUNE_SYMBOL, &acceptGraph); + GraphMatcherRegistry::registerSymbol(ALWAYS_FAILS_SYMBOL, &rejectGraph); + counters().reset(); + + const auto passNoBindMatcherId = testId(0xA4); + const auto leakMatcherId = testId(0xA5); + const auto failMatcherId = testId(0xA6); + + KernelDescriptorPack survivingPack; + survivingPack.id = testId(0xA0); + survivingPack.name = "surviving pack"; + survivingPack.matcherIds = {passNoBindMatcherId}; + survivingPack.engineId = ENGINE_ID; + survivingPack.dispatchId = DISPATCH_ID; + survivingPack.kernels = {makeKernel(testId(0xA1), "surviving_kernel", 64, "FLOAT")}; + + KernelDescriptorPack prunedPack; + prunedPack.id = testId(0xA2); + prunedPack.name = "pruned pack"; + // Order matters: the leaking matcher must run (and bind) before the failing one + // prunes the pack, exactly as graphLevelMatchersPass evaluates them in listed order. + prunedPack.matcherIds = {leakMatcherId, failMatcherId}; + prunedPack.engineId = ENGINE_ID; + prunedPack.dispatchId = DISPATCH_ID; + prunedPack.kernels = {makeKernel(testId(0xA3), "pruned_kernel", 128, "FLOAT")}; + + const StateManager manager( + makeSchema(), + {{passNoBindMatcherId, "passes, binds nothing", MatchScope::GRAPH, PASS_NO_BIND_SYMBOL}, + {leakMatcherId, "passes, binds a token", MatchScope::GRAPH, LEAK_THEN_PRUNE_SYMBOL}, + {failMatcherId, "always fails", MatchScope::GRAPH, ALWAYS_FAILS_SYMBOL}}, + makeTestDispatches(), + {survivingPack, prunedPack}, + std::make_shared(SCORE_SYMBOL)); + + const TestGraph graph(makeGraphId(50)); + const auto properties = testDeviceProperties(); + const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); + + // The surviving pack's kernel is the only entry: the pruned pack's kernel never + // enters the catalog, which was already correct before this fix. + ASSERT_EQ(catalog.entries.size(), 1U); + EXPECT_EQ(catalog.entries.front().packId, survivingPack.id); + + // What this test actually regresses: the token only the pruned pack's own matcher + // bound must not survive into the catalog the surviving pack's kernel reads from, + // even though that matcher itself returned true before its pack's second matcher + // failed. + EXPECT_EQ(catalog.bound.count("test.bound_token"), 0U); + + GraphMatcherRegistry::unregisterSymbol(PASS_NO_BIND_SYMBOL); + GraphMatcherRegistry::unregisterSymbol(LEAK_THEN_PRUNE_SYMBOL); + GraphMatcherRegistry::unregisterSymbol(ALWAYS_FAILS_SYMBOL); +} + +TEST(TestKernelIngestorStateManager, MatchesSeparatelyPerDevice) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(4)); + const auto properties = testDeviceProperties(); + + manager->unsortedDefinitions(MatchContext{graph, 0, properties}); + manager->unsortedDefinitions(MatchContext{graph, 1, properties}); + + // The same graph on a different device is a different problem: a kernel applicable + // on one device need not be applicable on another. + EXPECT_EQ(counters().graphCalls, 2); +} + +TEST(TestKernelIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + // A legacy or unfinalized graph: there is no key to memoize under, and inventing one + // would alias unrelated graphs. + const TestGraph graph; + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto first = manager->unsortedDefinitions(context); + const auto second = manager->unsortedDefinitions(context); + + // Correct both times; only the caching is lost. + EXPECT_EQ(first.size(), 2U); + EXPECT_EQ(second.size(), 2U); + EXPECT_EQ(counters().graphCalls, 2); +} + +// --------------------------------------------------------------------------- +// Bound state alongside the catalog +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorStateManager, CarriesWhatMatchingBoundThroughToDispatch) +{ + // RFC 0017 section 8.5: matching does double duty, deciding a kernel applies and + // binding the fields the launch will use. A dispatch handler must read those values + // rather than re-deriving them from the graph with a second notion of its shape. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto properties = testDeviceProperties(); + const TestGraph graph; + const MatchContext context{graph, 0, properties}; + + const auto bound = manager->unsortedCatalog(context).bound; + + ASSERT_EQ(bound.count("test.bound_token"), 1U); + EXPECT_EQ(bound.at("test.bound_token"), BOUND_TOKEN_VALUE); +} + +TEST(TestKernelIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematch) +{ + // Section 8.1 keeps the bound token state alongside the catalog precisely so that + // "nothing is re-matched" once a graph has been matched. Recovering these values by + // re-running the matcher would be correct and quietly quadratic. + // + // The graph carries an identity, so there is a cache entry to serve the second and + // third reads. A graph without one is matched fresh every call by design; that is why + // the entries and the bound state come back from a single call rather than two. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto properties = testDeviceProperties(); + const TestGraph graph(makeGraphId(11)); + const MatchContext context{graph, 0, properties}; + + static_cast(manager->unsortedCatalog(context)); + const auto afterMatching = counters().graphCalls; + + static_cast(manager->unsortedCatalog(context).bound); + static_cast(manager->sortedCatalog(context).bound); + + EXPECT_EQ(counters().graphCalls, afterMatching); +} + +// --------------------------------------------------------------------------- +// Caching +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorStateManager, RematchesAfterCacheEviction) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(SCORE_SYMBOL, 1); + const auto properties = testDeviceProperties(); + const TestGraph first(makeGraphId(5)); + const TestGraph second(makeGraphId(6)); + + manager->unsortedDefinitions(MatchContext{first, 0, properties}); + manager->unsortedDefinitions(MatchContext{second, 0, properties}); + manager->unsortedDefinitions(MatchContext{first, 0, properties}); + + // Capacity 1, so the second graph evicted the first and it had to rematch. Eviction + // costs work, never a wrong answer. + EXPECT_EQ(counters().graphCalls, 3); +} + +// --------------------------------------------------------------------------- +// Ranking reuse and knob value discovery +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorStateManager, SortedDefinitionsAreRankedBestFirst) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(7)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto sorted = manager->sortedDefinitions(context); + + ASSERT_EQ(sorted.size(), 2U); + EXPECT_EQ(sorted.front().getIntMetadata(BLOCK_SIZE), 256); +} + +TEST(TestKernelIngestorStateManager, RankingReusesTheAlreadyMatchedCatalog) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(8)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + manager->unsortedDefinitions(context); + manager->sortedDefinitions(context); + manager->sortedDefinitions(context); + + // Ranking is a read of the cached catalog, never a rematch. + EXPECT_EQ(counters().graphCalls, 1); + EXPECT_EQ(counters().kernelCalls, 3); +} + +TEST(TestKernelIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph graph(makeGraphId(9)); + const auto properties = testDeviceProperties(); + const MatchContext context{graph, 0, properties}; + + const auto values = StateManager::knobValues(manager->sortedDefinitions(context), BLOCK_SIZE); + + // The pruned HALF kernel also carried block_size 64, but it contributes no value: + // a knob offers what the surviving catalog implements, not the schema's range. + ASSERT_EQ(values.size(), 2U); + EXPECT_EQ(std::get(values[0]), 256); + EXPECT_EQ(std::get(values[1]), 64); +} + +// --------------------------------------------------------------------------- +// getDispatchDetails +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorStateManager, GetDispatchDetailsThrowsOnADanglingDispatchId) +{ + // After applicability accepted a graph, hipDNN has already chosen this engine on + // that promise, so a missing dispatch is a hard error rather than a silent decline. + // Constructed directly rather than through the pack/matcher path: the point here is + // a KernelDefinition whose dispatchId the state manager's own descriptor set never + // registered, which validateAndIndexPacks() cannot catch because this definition + // never went through a pack. + const auto manager = makeStateManager(); + const auto kernel = makeDefinition(testId(0x01), 64); + + EXPECT_THROW(manager->getDispatchDetails(kernel), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// completeMetadata (exercised through the constructor/matching path) +// --------------------------------------------------------------------------- + +TEST(TestKernelIngestorStateManager, CompletesAnOmittedFieldFromItsSchemaDefault) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + // Omits block_size, which defaults, and states dtype, which does not. + KernelDescriptor sparse; + sparse.id = testId(0x70); + sparse.name = "kernel_defaults"; + sparse.metadata = {{DTYPE, MetadataValue{std::string{"FLOAT"}}}}; + pack.kernels = {sparse}; + + const StateManager manager(makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + {pack}, + std::make_shared(SCORE_SYMBOL)); + + const TestGraph graph(makeGraphId(10)); + const auto properties = testDeviceProperties(); + const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); + + ASSERT_EQ(definitions.size(), 1U); + EXPECT_EQ(definitions.front().getIntMetadata(BLOCK_SIZE), 64); + EXPECT_EQ(definitions.front().getStringMetadata(DTYPE), "FLOAT"); +} + +// --------------------------------------------------------------------------- +// Construction-time validation: every eager check validateAndIndexPacks() and the +// constructor itself run, TEST_P'd over one shared shape (a pack, invalid in one way, +// must fail construction with std::invalid_argument). +// --------------------------------------------------------------------------- + +struct StateManagerConstructionThrowCase +{ + std::string name; + std::function()> construct; +}; + +class TestKernelIngestorStateManagerConstructionThrows + : public ::testing::TestWithParam +{ +}; + +TEST_P(TestKernelIngestorStateManagerConstructionThrows, RejectsAtConstruction) +{ + EXPECT_THROW(GetParam().construct(), std::invalid_argument); +} + +INSTANTIATE_TEST_SUITE_P( + EagerValidationChecks, + TestKernelIngestorStateManagerConstructionThrows, + ::testing::Values( + StateManagerConstructionThrowCase{ + "RejectsAKernelOmittingAFieldWithNoDefault", + [] { + // dtype declares no default, so every kernel must state it. Omitting it + // would otherwise produce a catalog key the author never wrote. + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + KernelDescriptor missingDtype; + missingDtype.id = testId(0x71); + missingDtype.name = "kernel_missing_dtype"; + missingDtype.metadata = {{BLOCK_SIZE, MetadataValue{int64_t{64}}}}; + pack.kernels = {missingDtype}; + return std::make_unique( + makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + std::vector{pack}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{ + "RejectsAKernelSupplyingAFieldTheSchemaDoesNotDeclare", + [] { + // A misspelled field name is the common case, and left in place it fails + // twice over: the real field silently takes its default, and the stray + // value joins the catalog key, where nothing downstream can read it. + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + KernelDescriptor undeclared; + undeclared.id = testId(0x74); + undeclared.name = "kernel_undeclared_field"; + undeclared.metadata = {{BLOCK_SIZE, MetadataValue{int64_t{64}}}, + {DTYPE, MetadataValue{std::string{"FLOAT"}}}, + {"blocksize", MetadataValue{int64_t{128}}}}; + pack.kernels = {undeclared}; + return std::make_unique( + makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + std::vector{pack}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{ + "RejectsAKernelSupplyingAFieldOfTheWrongType", + [] { + // block_size is declared INT. A string here would otherwise surface far + // away, as a bad_variant_access inside a matcher or a scorer. + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + KernelDescriptor wrongType; + wrongType.id = testId(0x72); + wrongType.name = "kernel_wrong_type"; + wrongType.metadata = {{BLOCK_SIZE, MetadataValue{std::string{"64"}}}, + {DTYPE, MetadataValue{std::string{"FLOAT"}}}}; + pack.kernels = {wrongType}; + return std::make_unique( + makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + std::vector{pack}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{ + "RejectsAPackNamingAnUnknownMatcher", + [] { + // A dangling cross-reference cannot be evaluated, so it is caught when + // the descriptor set is assembled rather than when a graph first arrives. + return std::make_unique( + makeSchema(), + std::vector{}, + makeTestDispatches(), + std::vector{makePack({testId(0xFF)})}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{ + "RejectsAPackNamingAnUnknownDispatchDescriptor", + [] { + return std::make_unique( + makeSchema(), + makeTestMatchers(), + std::vector{}, + std::vector{makePack({GRAPH_MATCHER_ID})}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{ + "RejectsTwoKernelsSharingAMetadataTuple", + [] { + // Same completed tuple as kernel_64_float: selection would have two + // indistinguishable candidates and no basis to prefer either. + KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID}); + pack.kernels.push_back(makeKernel(testId(0x73), "kernel_duplicate", 64, "FLOAT")); + return std::make_unique( + makeSchema(), + makeTestMatchers(), + makeTestDispatches(), + std::vector{pack}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{"RejectsAMissingHeuristic", + [] { + return std::make_unique( + makeSchema(), + std::vector{}, + std::vector{}, + std::vector{}, + nullptr); + }}), + [](const ::testing::TestParamInfo& info) { + return info.param.name; + }); + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp new file mode 100644 index 000000000000..34f128d3d0c0 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp @@ -0,0 +1,159 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include +#include +#include + +#include + +#include + +/** + * @file TestLruCache.cpp + * @brief Unit tests for the bounded, thread-safe LRU cache LruCache.hpp declares. + * + * Concurrent access is covered separately by TestKernelIngestorConcurrency.cpp; these + * tests are single-threaded and pin the eviction and recency contract itself. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; + +TEST(TestIngestorLruCache, ReturnsCachedValueWithinCapacity) +{ + LruCache cache(2); + cache.put(1, "one"); + + const auto found = cache.get(1); + + ASSERT_TRUE(found.has_value()); + EXPECT_EQ(*found, "one"); +} + +TEST(TestIngestorLruCache, ReportsMissForAbsentKey) +{ + LruCache cache(2); + + EXPECT_FALSE(cache.get(42).has_value()); +} + +TEST(TestIngestorLruCache, RejectsZeroCapacity) +{ + // A zero-capacity cache would evict every entry as it was inserted, which is always + // a caller bug rather than a way to disable caching. + using IntCache = LruCache; + EXPECT_THROW(IntCache(0), std::invalid_argument); +} + +TEST(TestIngestorLruCache, ClearDropsEveryEntry) +{ + LruCache cache(4); + cache.put(1, "one"); + cache.put(2, "two"); + + cache.clear(); + + EXPECT_EQ(cache.size(), 0U); + EXPECT_FALSE(cache.get(1).has_value()); + EXPECT_FALSE(cache.get(2).has_value()); +} + +TEST(TestIngestorLruCache, ClearOnAnAlreadyEmptyCacheIsANoop) +{ + LruCache cache(4); + + cache.clear(); + + EXPECT_EQ(cache.size(), 0U); + // The cache must still be usable afterwards, not left in some half-cleared state. + cache.put(1, "one"); + EXPECT_TRUE(cache.get(1).has_value()); +} + +// --------------------------------------------------------------------------- +// Eviction order: which entry a capacity-crossing insert evicts depends on recency, +// not on insertion order, and a get() counts as a touch just as a put() does. +// --------------------------------------------------------------------------- + +struct LruEvictionCase +{ + std::string name; + /// Populates the cache and touches whatever entries the case needs, leaving it one + /// insert away from a capacity-2 overflow. + std::function&)> setup; + int expectedEvicted; + int expectedSurvivor; +}; + +class TestIngestorLruCacheEvictionOrder : public ::testing::TestWithParam +{ +}; + +TEST_P(TestIngestorLruCacheEvictionOrder, EvictsByRecencyNotInsertionOrder) +{ + const auto& testCase = GetParam(); + LruCache cache(2); + testCase.setup(cache); + + // The insert that crosses capacity: key 3 must always survive, since it is always + // the most recently used entry regardless of which case set it up this way. + cache.put(3, "three"); + + EXPECT_FALSE(cache.get(testCase.expectedEvicted).has_value()); + EXPECT_TRUE(cache.get(testCase.expectedSurvivor).has_value()); + EXPECT_TRUE(cache.get(3).has_value()); + EXPECT_EQ(cache.size(), 2U); +} + +INSTANTIATE_TEST_SUITE_P(EvictionOrder, + TestIngestorLruCacheEvictionOrder, + ::testing::Values(LruEvictionCase{"PlainInsertionOrderEvictsOldest", + [](LruCache& cache) { + cache.put(1, "one"); + cache.put(2, "two"); + }, + /*expectedEvicted=*/1, + /*expectedSurvivor=*/2}, + LruEvictionCase{"ReadingAnEntryProtectsItFromEviction", + [](LruCache& cache) { + cache.put(1, "one"); + cache.put(2, "two"); + // Touching 1 makes 2 the least recently used. + ASSERT_TRUE( + cache.get(1).has_value()); + }, + /*expectedEvicted=*/2, + /*expectedSurvivor=*/1}, + LruEvictionCase{"OverwritingAKeyRefreshesItsRecency", + [](LruCache& cache) { + cache.put(1, "one"); + cache.put(2, "two"); + // Overwriting 1 marks it most-recently-used, same as a get(). + cache.put(1, "uno"); + }, + /*expectedEvicted=*/2, + /*expectedSurvivor=*/1}), + [](const ::testing::TestParamInfo& info) { + return info.param.name; + }); + +TEST(TestIngestorLruCache, OverwritingAKeyDoesNotGrowTheCache) +{ + LruCache cache(2); + cache.put(1, "one"); + cache.put(1, "uno"); + + const auto found = cache.get(1); + + ASSERT_TRUE(found.has_value()); + EXPECT_EQ(*found, "uno"); + EXPECT_EQ(cache.size(), 1U); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp new file mode 100644 index 000000000000..350da164b8a5 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp @@ -0,0 +1,114 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestMatchContext.cpp + * @brief Unit tests for MatchContext.hpp: the catalog cache key's equality and hash, and + * tryGetGraphId()'s optional-identity contract. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +// --------------------------------------------------------------------------- +// CatalogKey::operator== +// --------------------------------------------------------------------------- + +TEST(TestIngestorMatchContext, CatalogKeysWithEqualFieldsCompareEqual) +{ + const CatalogKey first{makeGraphId(1), 0}; + const CatalogKey second{makeGraphId(1), 0}; + + EXPECT_TRUE(first == second); +} + +struct CatalogKeyInequalityCase +{ + std::string name; + CatalogKey key; +}; + +class TestIngestorMatchContextCatalogKeyInequality + : public ::testing::TestWithParam +{ +}; + +TEST_P(TestIngestorMatchContextCatalogKeyInequality, KeysDifferingInOneFieldCompareUnequal) +{ + const CatalogKey reference{makeGraphId(1), 0}; + + EXPECT_FALSE(reference == GetParam().key); +} + +INSTANTIATE_TEST_SUITE_P( + OneFieldAtATime, + TestIngestorMatchContextCatalogKeyInequality, + ::testing::Values(CatalogKeyInequalityCase{"DifferentGraphId", CatalogKey{makeGraphId(2), 0}}, + CatalogKeyInequalityCase{"DifferentDeviceId", CatalogKey{makeGraphId(1), 1}}), + [](const ::testing::TestParamInfo& info) { return info.param.name; }); + +// --------------------------------------------------------------------------- +// CatalogKeyHash +// --------------------------------------------------------------------------- + +TEST(TestIngestorMatchContext, CatalogKeyHashIsConsistentForEqualKeys) +{ + const CatalogKey first{makeGraphId(3), 2}; + const CatalogKey second{makeGraphId(3), 2}; + const CatalogKeyHash hash; + + // A hash function must agree with operator== on which keys are "the same": unequal + // hashes for equal keys would break the LruCache's unordered_map lookup silently. + EXPECT_EQ(hash(first), hash(second)); +} + +TEST(TestIngestorMatchContext, CatalogKeyHashDistinguishesDifferentDeviceIds) +{ + const CatalogKeyHash hash; + const CatalogKey onDeviceZero{makeGraphId(4), 0}; + const CatalogKey onDeviceOne{makeGraphId(4), 1}; + + // Not a correctness requirement (collisions are legal), but this hash's own doc + // claims it folds the device ordinal in, and a hash that silently ignored deviceId + // would still "work" via bucket collision while quietly regressing lookup cost. + EXPECT_NE(hash(onDeviceZero), hash(onDeviceOne)); +} + +// --------------------------------------------------------------------------- +// tryGetGraphId +// --------------------------------------------------------------------------- + +TEST(TestIngestorMatchContext, TryGetGraphIdReturnsTheGraphsIdentity) +{ + const TestGraph graph(makeGraphId(0x42)); + + const auto id = tryGetGraphId(graph); + + ASSERT_TRUE(id.has_value()); + EXPECT_EQ(*id, makeGraphId(0x42)); +} + +TEST(TestIngestorMatchContext, TryGetGraphIdReturnsNulloptForAGraphWithNoIdentity) +{ + // A legacy or unfinalized graph: there is no key to memoize under, and callers must + // treat this as "cannot cache", not as an error. + const TestGraph graph; + + EXPECT_EQ(tryGetGraphId(graph), std::nullopt); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp new file mode 100644 index 000000000000..5c215d266ee2 --- /dev/null +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp @@ -0,0 +1,70 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR + +#include + +#include + +#include +#include + +#include "KernelIngestorTestFixtures.hpp" + +/** + * @file TestNativeRegistry.cpp + * @brief Unit tests for the symbol-name-to-native-callable registry. + * + * Covers registration, resolution, and the fail-closed contract a descriptor naming an + * unshipped symbol depends on -- a missing registration must surface as an error at the + * point of use, never as an engine that silently matches nothing. + */ +namespace +{ + +using namespace hipdnn_plugin_sdk::ingestor; +using namespace hipdnn_plugin_sdk::ingestor::testing; + +TEST(TestIngestorNativeRegistry, ResolvesARegisteredSymbol) +{ + GraphMatcherRegistry::registerSymbol("registry.resolves", acceptGraph); + + EXPECT_EQ(GraphMatcherRegistry::resolve("registry.resolves"), acceptGraph); + + GraphMatcherRegistry::unregisterSymbol("registry.resolves"); +} + +TEST(TestIngestorNativeRegistry, RejectsDuplicateRegistration) +{ + GraphMatcherRegistry::registerSymbol("registry.duplicate", acceptGraph); + + // Two implementations behind one name leaves one silently unreachable, and which one + // wins would depend on static-init order. + EXPECT_THROW(GraphMatcherRegistry::registerSymbol("registry.duplicate", rejectGraph), + std::runtime_error); + + GraphMatcherRegistry::unregisterSymbol("registry.duplicate"); +} + +TEST(TestIngestorNativeRegistry, FailsClosedOnUnknownSymbol) +{ + // A descriptor naming a symbol the provider does not ship must surface as an error, + // never as an engine that quietly matches nothing. + EXPECT_THROW(GraphMatcherRegistry::resolve("registry.never_registered"), std::runtime_error); +} + +TEST(TestIngestorNativeRegistry, IsRegisteredReflectsCurrentRegistrationState) +{ + EXPECT_FALSE(GraphMatcherRegistry::isRegistered("registry.is_registered_probe")); + + GraphMatcherRegistry::registerSymbol("registry.is_registered_probe", acceptGraph); + EXPECT_TRUE(GraphMatcherRegistry::isRegistered("registry.is_registered_probe")); + + GraphMatcherRegistry::unregisterSymbol("registry.is_registered_probe"); + EXPECT_FALSE(GraphMatcherRegistry::isRegistered("registry.is_registered_probe")); +} + +} // namespace + +#endif // HIPDNN_ENABLE_KERNEL_INGESTOR From 77f50fad0c2a55aceb7fde5f86cca1a294b7b886 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 17:27:42 -0400 Subject: [PATCH 25/60] test(hipdnn): drop an unused ingestor test double (ALMIOPEN-2402) NoopDispatchHandler had no reference in any test file. The two suites it was written for use the gmock MockKernelDispatchHandler instead. --- .../ingestor/KernelIngestorTestFixtures.hpp | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp index de77ef512a8b..7f4c047b9c2c 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp @@ -482,39 +482,6 @@ inline std::unique_ptr cacheCapacity); } -// --------------------------------------------------------------------------- -// A no-op dispatch handler, generic over the handle type: sufficient wherever a test -// needs buildPlan() or initializeExecutionContext() to succeed without asserting on the -// launch itself. Shared by TestGenericEngine.cpp and TestGenericPlanBuilder.cpp. -// --------------------------------------------------------------------------- - -template -class NoopDispatchHandler : public IKernelDispatchHandler -{ -public: - size_t workspaceBytes(const MatchContext& /*context*/, - const BoundTokens& /*bound*/, - const KernelDefinition& /*kernel*/) const override - { - return 0; - } - - std::unique_ptr prepare(const MatchContext& /*context*/, - const BoundTokens& /*bound*/, - const KernelDefinition& /*kernel*/) const override - { - return std::make_unique(); - } - - void launch(const THandle& /*handle*/, - const PreparedDispatch& /*prepared*/, - const hipdnnPluginDeviceBuffer_t* /*deviceBuffers*/, - uint32_t /*numDeviceBuffers*/, - void* /*workspace*/) const override - { - } -}; - /// Registers @p handler under @p symbol in DispatchRegistry for a test's /// duration. template From 2921936ba598894bae9e36bedf8e05c3264701c7 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 21:47:22 -0400 Subject: [PATCH 26/60] fix(hipdnn): close four defects the ingestor review proved (ALMIOPEN-2402) Each was proven by something failing, not by inspection. Guard the plan-building engine test with SKIP_IF_NO_DEVICES. It compiles through hiprtc, so on the device-less superbuild runners it failed where every sibling device-touching test skips -- the one red test in the 483-test provider suite, and the reason both superbuild CI jobs were failing. Three applicability tests and TestContainer's ingestor case get the same guard for the same reason: they assert an accept, and matching now declines outright when no device can be named. The test also asserts which kernel was selected rather than only that a plan exists, which is what its name claims. Seed each tensor distinctly in the integration harness. randomizeTensor builds a fresh mt19937 from the seed it is handed, so one seed for every uid made both operands byte-identical and allClose() was satisfied by a+a as readily as by a+b -- the E2E check could not witness that the handler resolved its arguments by tensor uid, which is the property it exists to prove. Confirmed by mutation: pointing inputB at binding.inputA now fails three integration tests, including ExecutesTwoIndependentlyBuiltGraphsCorrectly, all of which passed before. Exclude the ingestor directory from the flag-off install, not just its contents. Under FILES_MATCHING, PATTERN "ingestor/*" EXCLUDE still created an empty directory in the public SDK include tree, so a default build's installed artifacts were not in fact unchanged. Refuse a failed device-property query instead of caching it. The cache is process-lifetime and never invalidated, so one transient failure pinned a zeroed hipDeviceProp_t for every later caller asking about that device -- and those zeroed values do not fail loudly, they reach KernelCompileOptions as an empty --offload-arch. Before this resolver became process-lifetime a container cycle cleared such an entry; nothing does now, which is what made caching the failure a regression rather than a wart. A protected query seam keeps the reference-stability-across-growth invariant testable now that invalid ids no longer produce entries. An unresolvable device now answers NO_DEVICE rather than device 0. Zero is a valid ordinal other healthy calls resolve, so answering it keyed this call's catalog and property cache to a real device nobody asked about. It is not a throw either: deviceId() is reached from isApplicable(), and EngineManager::getApplicableEngineIds() walks every engine with no try/catch, so throwing would deny a healthy sibling engine its answer instead of merely declining for this one. The matchers decline on NO_DEVICE, so the call finds no applicable kernel -- the correct outcome with no device to launch on. Verified: device-less provider suite exits 0 (was 1) with 461 passed and the device-dependent cases skipped; with a device 482 passed and the one pre-existing arch skip; flag-off build clean; flag-off install contains no ingestor path. --- .../HandleDeviceResolver.hpp | 73 +++++++++++++++++-- .../packs/PointwiseAddMatchers.cpp | 11 +++ .../IntegrationGpuKernelIngestor.cpp | 12 ++- .../src/tests/core/TestContainer.cpp | 5 ++ .../TestHandleDeviceResolver.cpp | 73 +++++++++++++++---- .../TestKernelIngestorEngine.cpp | 35 ++++++++- projects/hipdnn/plugin_sdk/CMakeLists.txt | 6 +- .../ingestor/GenericPlan.hpp | 2 + .../ingestor/MatchContext.hpp | 7 ++ 9 files changed, 198 insertions(+), 26 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp index 7e7acfc11c90..1bfaa7ed44ec 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp @@ -6,9 +6,11 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR #include +#include #include #include +#include #include #include "core/Handle.hpp" @@ -29,6 +31,18 @@ namespace hip_kernel_provider::kernel_ingestor_engine * this resolver's lifetime, so entries are never erased or rehashed away: node handles * in std::unordered_map keep referenced values pinned across growth. * + * Only successful queries are cached. A failed hipGetDeviceProperties throws rather + * than caching the zeroed struct it left behind: this cache is process-lifetime and is + * never invalidated, so one transient failure would otherwise pin unusable properties + * for every later caller asking about that device -- and those zeroed values do not + * fail loudly, they reach KernelCompileOptions as an empty --offload-arch and surface + * as an hiprtc error naming neither the device nor the property query. Before this + * class became process-lifetime a container cycle cleared such an entry; now nothing + * does, which is exactly why the failure has to be refused rather than remembered. + * The provider's own convention for this call is the same (see + * CurrentDevicePropertyProvider), as is the module cache's refusal to cache a failed + * load. + * * Instantiated once, at process lifetime (see KernelIngestorEngine.cpp's deviceResolver()), * rather than once per engine or once per Container. This class carries no engine state * -- only device properties, which are a fact about the machine, not about which engine @@ -38,9 +52,7 @@ namespace hip_kernel_provider::kernel_ingestor_engine * per engine and lose its warm entries every time a container cycled, for no isolation * benefit: two engines resolving the same physical device must agree on that device's * properties by construction, so a shared cache is the correct scope, not merely a - * convenient one. That is also why the cache the class holds is never invalidated - * ("references stay valid for this resolver's lifetime" above is a promise this - * lifetime choice keeps easy to honor). + * convenient one. */ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver { @@ -61,10 +73,21 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver if(hipGetDevice(&deviceId) != hipSuccess) { // Only reachable with no usable HIP context, where nothing can be matched - // or launched anyway. Device 0 keys the cache consistently so the failure - // surfaces at compile or launch, with a message about the real problem, - // rather than here as a cache-key error. - return 0; + // or launched anyway. + // + // Not device 0, and not a throw. Not 0 because 0 is a valid ordinal other + // healthy calls resolve, so answering it would key this call's catalog and + // property cache to a real device nobody asked about -- and those entries + // outlive the container, so one failure would answer for every later + // caller. Not a throw because deviceId() is reached from isApplicable(), + // and EngineManager::getApplicableEngineIds() walks every engine with no + // try/catch: an exception here would deny a healthy sibling engine its + // answer rather than just declining for this one. + // + // NO_DEVICE is a key nothing matches and nothing caches against, so the + // engine simply finds no applicable kernel and declines, which is the + // correct outcome when there is no device to launch on. + return hipdnn_plugin_sdk::ingestor::NO_DEVICE; } return deviceId; } @@ -72,6 +95,18 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver const hipDeviceProp_t& deviceProperties(hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const override { + // NO_DEVICE is not a device to query: MatchContext binds properties eagerly, + // before any matcher runs, so this is reached whenever deviceId() could not + // name a device. Answering with an inert zeroed struct lets matching proceed to + // the matchers, which decline on NO_DEVICE -- throwing here instead would deny + // every sibling engine its applicability answer (EngineManager walks them with + // no try/catch). Nothing reads these values, because no kernel survives. + if(deviceId == hipdnn_plugin_sdk::ingestor::NO_DEVICE) + { + static const hipDeviceProp_t s_noDevice{}; + return s_noDevice; + } + const std::lock_guard lock(_mutex); auto it = _properties.find(deviceId); @@ -81,10 +116,32 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver } hipDeviceProp_t properties{}; - static_cast(hipGetDeviceProperties(&properties, deviceId)); + const auto status = queryDeviceProperties(&properties, deviceId); + if(status != hipSuccess) + { + // Not cached: see the class doc. A zeroed hipDeviceProp_t is not a usable + // answer, and this cache is never invalidated, so remembering one failure + // would answer wrongly for the life of the process. + throw hipdnn_plugin_sdk::HipdnnPluginException( + HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, + "hipGetDeviceProperties failed for device " + std::to_string(deviceId) + ": " + + hipGetErrorString(status)); + } return _properties.emplace(deviceId, properties).first->second; } +protected: + /// Seam for tests that need to grow the cache without that many real devices. The + /// production path is the HIP call; overriding it lets a test supply successful + /// answers for ids this machine does not have, which is the only way to exercise + /// the reference-stability-across-growth invariant now that failures are refused + /// rather than cached. + virtual hipError_t queryDeviceProperties(hipDeviceProp_t* properties, + hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const + { + return hipGetDeviceProperties(properties, deviceId); + } + private: mutable std::mutex _mutex; mutable std::unordered_map _properties; diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index ca920cac2ac6..b6677fe092fb 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -121,6 +121,17 @@ std::string dataTypeName(data_objects::DataType dataType) bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) { + // No device, no launch. The resolver reports NO_DEVICE when it cannot name the + // device this call is for, and every fact below that a kernel would be selected on + // -- including the device properties the compile is configured from -- is + // meaningless without one. Declining here is what keeps that failure a clean "this + // engine does not apply" rather than a property lookup for a device that does not + // exist. + if(context.deviceId == hipdnn_plugin_sdk::ingestor::NO_DEVICE) + { + return false; + } + // Exactly one node: a prebuilt kernel serves one complete graph, so anything larger // is a different problem even if it contains this one. if(context.graph.nodeCount() != 1) diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp b/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp index c02b7f03448c..639225cb173e 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/kernel_ingestor_engine/IntegrationGpuKernelIngestor.cpp @@ -187,8 +187,16 @@ class IntegrationGpuKernelIngestor }); for(auto& [uid, tensor] : gpuBundle.tensors) { - gpuBundle.randomizeTensor(uid, -4.0f, 4.0f, seed); - cpuBundle.randomizeTensor(uid, -4.0f, 4.0f, seed); + // Per-uid, not one seed for every tensor: randomizeTensor builds a fresh + // mt19937 from the seed it is handed, so a shared seed makes every operand + // byte-identical and allClose() below is then satisfied by a+a or b+b just + // as well as by a+b -- it would no longer witness that the handler resolved + // its arguments by tensor uid, which is the property this harness exists to + // check. Offsetting by the uid is what PointwiseTensorBundles.hpp does + // (seed, seed + 1) for the same reason. + const auto tensorSeed = seed + static_cast(uid); + gpuBundle.randomizeTensor(uid, -4.0f, 4.0f, tensorSeed); + cpuBundle.randomizeTensor(uid, -4.0f, 4.0f, tensorSeed); } auto deviceVariantPack = gpuBundle.toDeviceVariantPack(); diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp index 6120988725aa..b2be52611c9e 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp @@ -119,6 +119,11 @@ TEST(TestContainer, GetApplicableEngineIdsSdpaGraph) #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR TEST(TestContainer, GetApplicableEngineIdsPointwiseAddGraph) { + // Applicability is device-resolved: with no device the matchers decline, so an + // accept can only be asserted where there is one. Matches the SDPA applicability + // test above. + SKIP_IF_NO_DEVICES(); + using namespace hip_kernel_provider::kernel_ingestor_engine; using namespace hip_kernel_provider::kernel_ingestor_engine::testing; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp index b4afdb8c5f00..a5996835eea9 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp @@ -28,16 +28,50 @@ * deviceId() has four paths per its own doc: a null stream falls through to the current * device, a non-null stream resolves via hipStreamGetDevice, a stream hipStreamGetDevice * cannot resolve falls through the same way a null one does, and a process with no - * usable HIP context at all falls back to device 0. The first three are driven below; - * the fourth requires a HIP runtime with no usable context, which is not something a - * test can put this process into without faking the HIP runtime itself, so it is a - * defensive branch with no test hook here -- documented, not exercised. + * usable HIP context at all is refused rather than defaulted to device 0. The first + * three are driven below; the fourth requires a HIP runtime with no usable context, + * which is not something a test can put this process into without faking the HIP + * runtime itself, so it is a defensive branch with no test hook here -- documented, + * not exercised. + * + * deviceProperties() refuses to cache a failed query, so the growth test below supplies + * successful answers through the resolver's own query seam rather than leaning on + * invalid device ids, which no longer produce cache entries. */ namespace { using hip_kernel_provider::kernel_ingestor_engine::HandleDeviceResolver; +/// Answers every property query successfully, whatever the device id, so a test can +/// grow the cache past a rehash without needing that many physical devices. The +/// production class refuses to cache a failed query, so invalid ids no longer produce +/// entries at all -- this seam is how the growth invariant stays testable on one GPU. +class FakeQueryResolver : public HandleDeviceResolver +{ +public: + /// warpSize is set from the id so a caller can tell one faked entry from another. + hipError_t queryDeviceProperties(hipDeviceProp_t* properties, + hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const override + { + *properties = hipDeviceProp_t{}; + properties->warpSize = static_cast(deviceId); + return hipSuccess; + } +}; + +/// Fails every property query, to drive the refusal path. +class FailingQueryResolver : public HandleDeviceResolver +{ +public: + hipError_t + queryDeviceProperties(hipDeviceProp_t* /*properties*/, + hipdnn_plugin_sdk::ingestor::DeviceId /*deviceId*/) const override + { + return hipErrorInvalidDevice; + } +}; + // --------------------------------------------------------------------------- // deviceId() // --------------------------------------------------------------------------- @@ -135,12 +169,10 @@ TEST(TestHandleDeviceResolver, ReferencesStayValidAcrossCacheGrowth) // node-invalidating flat map) would silently break every caller holding a reference // across a later insert. // - // Invalid device ids are used deliberately: hipGetDeviceProperties() fails for them - // and deviceProperties() still caches and returns the (zeroed) result, which is - // enough to grow the map without needing that many real devices. CPU-only: no - // successful HIP call is required, only that insertion happens and does not move - // prior entries. - const HandleDeviceResolver resolver; + // Faked successful queries rather than invalid device ids: deviceProperties() no + // longer caches a failed query, so invalid ids would insert nothing and the map + // would never grow. CPU-only -- no real device is touched. + const FakeQueryResolver resolver; const auto& firstInserted = resolver.deviceProperties(1000); @@ -153,9 +185,24 @@ TEST(TestHandleDeviceResolver, ReferencesStayValidAcrossCacheGrowth) const auto& sameEntryAfterGrowth = resolver.deviceProperties(1000); EXPECT_EQ(&firstInserted, &sameEntryAfterGrowth); + // The entry still holds what it was inserted with, not a neighbour's value moved + // over it: an address that survived a rehash is necessary but not sufficient. + EXPECT_EQ(sameEntryAfterGrowth.warpSize, 1000); +} - static_cast(hipGetLastError()); - static_cast(hipExtGetLastError()); +TEST(TestHandleDeviceResolver, RefusesAndDoesNotCacheAFailedPropertyQuery) +{ + // A zeroed hipDeviceProp_t is not a usable answer, and this cache is never + // invalidated: caching one failure would answer wrongly for every later caller + // asking about that device for the life of the process. Before the resolver became + // process-lifetime a container cycle cleared such an entry; nothing does now. + const FailingQueryResolver resolver; + + EXPECT_THROW(static_cast(resolver.deviceProperties(7)), + hipdnn_plugin_sdk::HipdnnPluginException); + // Still refused on the second ask -- the failure was not remembered as an answer. + EXPECT_THROW(static_cast(resolver.deviceProperties(7)), + hipdnn_plugin_sdk::HipdnnPluginException); } TEST(TestHandleDeviceResolver, ConcurrentDevicePropertyLookupsAreSafe) @@ -165,7 +212,7 @@ TEST(TestHandleDeviceResolver, ConcurrentDevicePropertyLookupsAreSafe) // exercised by incidental test-suite parallelism. Every thread asks about the same // small set of device ids so a real race has many chances to corrupt the map if the // lock were missing. - const HandleDeviceResolver resolver; + const FakeQueryResolver resolver; std::vector threads; threads.reserve(8); diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp index 6f9d2da9457b..b2a78f1cb283 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp @@ -4,6 +4,7 @@ #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR #include +#include #include @@ -11,11 +12,16 @@ #include #include #include +#include + +#include #include "core/Container.hpp" +#include "core/Context.hpp" #include "core/Handle.hpp" #include "engines/kernel_ingestor_engine/KernelIngestorEngine.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" #include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" /** @@ -92,6 +98,11 @@ TEST(TestKernelIngestorEngine, MakePointwiseAddEngineIsReachableWithTheDescripto TEST(TestKernelIngestorEngine, IsApplicableAcceptsAGraphThisPacksMatchersAccept) { + // Applicability resolves the call's device and matches on its properties, and the + // matchers decline outright when no device can be named -- so an accept is only + // meaningful where there is a device to accept for. + SKIP_IF_NO_DEVICES(); + Container container; auto& engineManager = container.getEngineManager(); Handle handle; @@ -105,6 +116,10 @@ TEST(TestKernelIngestorEngine, IsApplicableAcceptsAGraphThisPacksMatchersAccept) TEST(TestKernelIngestorEngine, GetEngineDetailsReportsTheBlockSizeKnob) { + // The reported knob's value set comes from the catalog this graph matched, which is + // empty without a device (see the applicability test above). + SKIP_IF_NO_DEVICES(); + Container container; auto& engineManager = container.getEngineManager(); Handle handle; @@ -121,6 +136,9 @@ TEST(TestKernelIngestorEngine, GetEngineDetailsReportsTheBlockSizeKnob) TEST(TestKernelIngestorEngine, GetMaxWorkspaceSizeReportsTheLargerBlocksRequirement) { + // Sizes a workspace from the kernels the graph matched -- none, without a device. + SKIP_IF_NO_DEVICES(); + Container container; auto& engineManager = container.getEngineManager(); const Handle handle; @@ -139,6 +157,11 @@ TEST(TestKernelIngestorEngine, GetMaxWorkspaceSizeReportsTheLargerBlocksRequirem TEST(TestKernelIngestorEngine, InitializeExecutionContextBuildsAPlanForTheTopRankedKernel) { + // Builds a plan, which compiles the selected kernel through hiprtc -- so unlike the + // applicability and knob-reporting tests above, this one needs a device. Every + // device-touching test in this engine's suite guards the same way. + SKIP_IF_NO_DEVICES(); + Container container; auto& engineManager = container.getEngineManager(); const Handle handle; @@ -148,9 +171,17 @@ TEST(TestKernelIngestorEngine, InitializeExecutionContextBuildsAPlanForTheTopRan stubAsThisEnginesConfig(engineConfig); Context context; - EXPECT_NO_THROW( + ASSERT_NO_THROW( engineManager.initializeExecutionContext(handle, wrap(graph), engineConfig, context)); - EXPECT_TRUE(context.hasValidPlan()); + ASSERT_TRUE(context.hasValidPlan()); + + // Which kernel, not merely that there is one: pointwiseAddScore ranks on block size + // and the graph admits both FLOAT kernels, so the 256 entry is the defined winner. + // Asserting only hasValidPlan() would pass just as well if selection handed back the + // 64 kernel, which is the property this test is named for. + const auto& plan + = dynamic_cast&>(context.plan()); + EXPECT_EQ(plan.kernel().getIntMetadata(std::string(BLOCK_SIZE_FIELD)), 256); } // --------------------------------------------------------------------------- diff --git a/projects/hipdnn/plugin_sdk/CMakeLists.txt b/projects/hipdnn/plugin_sdk/CMakeLists.txt index 4f223f0e6e7c..27e8e193bbd9 100644 --- a/projects/hipdnn/plugin_sdk/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/CMakeLists.txt @@ -52,7 +52,11 @@ install(TARGETS hipdnn_plugin_sdk EXPORT hipdnn_plugin_sdk_targets COMPONENT Dev # feature that uses them is enabled. set(HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS "") if(NOT HIPDNN_ENABLE_KERNEL_INGESTOR) - list(APPEND HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS PATTERN "ingestor/*" EXCLUDE) + # PATTERN "ingestor" and not "ingestor/*": under FILES_MATCHING, excluding the + # contents still creates the (empty) directory in the install tree, which is itself + # a change to a default build's installed artifacts. Matching the directory keeps it + # out entirely. + list(APPEND HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS PATTERN "ingestor" EXCLUDE) endif() install( diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp index df7e6d290ce4..5958d2fba058 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -65,11 +65,13 @@ class GenericPlan : public IPlan } } + // NOLINTNEXTLINE(portability-template-virtual-member-function) size_t getWorkspaceSize(const THandle& /*handle*/) const override { return _workspaceBytes; } + // NOLINTNEXTLINE(portability-template-virtual-member-function) void execute(const THandle& handle, const hipdnnPluginDeviceBuffer_t* deviceBuffers, uint32_t numDeviceBuffers, diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index 4c31cdb7b512..06f6e356e073 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -23,6 +23,13 @@ namespace hipdnn_plugin_sdk::ingestor /// hipGetDevice; hipDNN has no richer device-identity type today. using DeviceId = int; +/// The device id meaning "this call has no resolvable device". Negative so it can never +/// alias a real ordinal: a resolver that cannot answer must not return 0, which is a +/// device other calls legitimately resolve and would then share a catalog and property +/// cache entry with. Matchers decline on it, so a call carrying no device finds no +/// applicable kernel rather than matching against another device's facts. +inline constexpr DeviceId NO_DEVICE = -1; + /// A finalized graph's stable identity, minted by hipDNN at finalization and preserved /// across serialization round trips. using GraphId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; From 39bfb2ab518027a1b846ab0b78a9277dad551fcf Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 21:53:07 -0400 Subject: [PATCH 27/60] feat(hipdnn): unify the bound-token value type and log why matching declined (ALMIOPEN-2402) Two changes on one surface, landed together because they touch the same signatures and splitting them would mean changing those signatures twice. Widen BoundTokens from int64 to MetadataValue. Descriptors.hpp already argued that one value type must span $kernel.* and $graph.* -- RFC 0017's criteria language puts a graph fact and a kernel fact on either side of one operator, so an interpreter over two types begins with a refactor -- and cited BoundTokens by name while leaving it narrow. The previous commit widened MetadataValue with vector for stride_order, which is itself a graph fact the graph side could not hold, so the two halves of that unification disagreed. Deferring this costs four interface changes later (GraphMatcherFn, IKernelDispatchHandler::workspaceBytes and prepare, Catalog::bound, GenericPlan's constructor) against one line now, and every one of those is a seam this skeleton exists to fix in place. tryGetBoundInt() names the int64 read, which is the common one: bound tokens are usually tensor uids and dimensions. It distinguishes "not bound" from "bound to another alternative" by returning nullopt for both rather than letting a type confusion read as a missing token -- the dispatch side reports either as the wiring error it is. Log the matching decisions. Nothing in the ingestor logged anything: 13 SDK headers and 8 provider sources with no diagnostic, against RFC 0017 section 10, which opens by asking that an operator see why a kernel was not selected. That is the most user-visible regression a data-driven engine introduces -- for a hand-written engine you read the switch, and here there was nothing to read. Now a pack declining at a graph-scoped matcher says so, each pack reports how many of its kernels survived kernel-scoped matching, the catalog reports its size, and a plan build names the kernel it selected and how many candidates it chose from. Cache hits and uncacheable graphs log at trace, so a caller seeing repeated matching for one graph can tell a missing identity from a caching bug. These use the SDK's existing HIPDNN_PLUGIN_LOG_* macros, which are level-gated and default to off, so the default path pays a predicate and nothing else. Also closes a coverage gap this exposed: ReadingBoundStateAfterMatchingDoesNotRematch discarded the bound state it read and asserted only the matcher call count, so it passed against a cache serving entries with Catalog::bound emptied -- which destroys the section 8.1 property the test is named for. It now asserts the cached reads still carry the token. Verified: 197 SDK and 482 provider tests pass (one pre-existing arch skip); flag-off builds clean; with HIPDNN_LOG_LEVEL=info a declining graph reports the pack that refused it and a zero-kernel catalog, an accepting graph reports 2 of 3 kernels admitted and names the selected kernel, and both are silent by default. --- .../packs/PointwiseAddMatchers.cpp | 16 +++++---- .../ingestor/GenericPlanBuilder.hpp | 11 ++++++ .../ingestor/KernelIngestorStateManager.hpp | 30 ++++++++++++++++ .../ingestor/MatchContext.hpp | 36 ++++++++++++++++--- .../TestKernelIngestorStateManager.cpp | 15 ++++++-- 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index b6677fe092fb..daf4e6d7b362 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -181,6 +181,8 @@ bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) // Bind what the launch needs, now that the walk that found it has succeeded. The // dispatch handler reads these back instead of re-walking the graph, so there is one // notion of which tensor is which operand rather than two that can drift apart. + // The uid type is already int64_t; what matters is that these land in the + // MetadataValue's integer alternative, which is the one the dispatch side reads. bound[std::string(INPUT_A_TOKEN)] = attributes.in_0_tensor_uid(); bound[std::string(INPUT_B_TOKEN)] = attributes.in_1_tensor_uid().value(); bound[std::string(OUTPUT_TOKEN)] = attributes.out_0_tensor_uid(); @@ -212,17 +214,19 @@ double pointwiseAddScore(const KernelDefinition& kernel, const MatchContext& /*c PointwiseAddBinding pointwiseAddBinding(const BoundTokens& bound) { // Every token was written by the graph matcher that admitted this graph, so a missing - // one means the catalog was built by a matcher other than ours -- a wiring error, not - // a graph this pack should decline. + // one -- or one holding something other than the uid this expects -- means the + // catalog was built by a matcher other than ours: a wiring error, not a graph this + // pack should decline. const auto read = [&bound](std::string_view token) { - const auto it = bound.find(std::string(token)); - if(it == bound.end()) + const auto value = hipdnn_plugin_sdk::ingestor::tryGetBoundInt(bound, token); + if(!value.has_value()) { throw hipdnn_plugin_sdk::HipdnnPluginException( HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, - "pointwise add dispatch is missing bound token '" + std::string(token) + "'"); + "pointwise add dispatch is missing bound token '" + std::string(token) + + "', or it does not hold a tensor uid"); } - return it->second; + return *value; }; return {read(INPUT_A_TOKEN), read(INPUT_B_TOKEN), read(OUTPUT_TOKEN)}; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 73d6c8feb606..70920c6caa43 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -186,6 +187,16 @@ class GenericPlanBuilder : public IPlanBuilder throwUnsatisfiableKnobFilter(filter, catalog.entries.size()); } + // The selection an operator would otherwise have to infer. RFC 0017 §10 asks + // that a resolved plan say which kernel it resolved to; with the catalog built + // from descriptors rather than a switch statement, this line is the only place + // the choice is observable without a debugger. + HIPDNN_PLUGIN_LOG_INFO("ingestor: engine '" << _engine.name << "' selected kernel " + << toString(filtered.front().kernelId) + << " from " << filtered.size() + << " candidate(s) (" << catalog.entries.size() + << " before knob filtering)"); + executionContext.setPlan(std::make_unique>( _stateManager.getDispatchDetails(filtered.front()), context, catalog.bound)); } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index 239fdd6d7223..82b91fe9c2e3 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -374,9 +375,19 @@ class KernelIngestorStateManager { if(auto cached = _catalogCache.get(*key); cached.has_value()) { + HIPDNN_PLUGIN_LOG_TRACE("ingestor: catalog cache hit for device " + << context.deviceId); return *cached; } } + else + { + // Worth saying once per call: a graph with no identity is re-matched every + // time, so an operator seeing repeated matching for what looks like the same + // graph is seeing this rather than a caching bug. + HIPDNN_PLUGIN_LOG_TRACE( + "ingestor: graph carries no identity, so its catalog cannot be cached"); + } Catalog catalog = buildCatalog(context); @@ -443,6 +454,13 @@ class KernelIngestorStateManager BoundTokens packBound; if(!graphLevelMatchersPass(pack, context, graphVerdicts, packBound)) { + // Logged, not silent: "no kernel matched my graph" is the question a + // data-driven engine is hardest to answer, because there is no + // hand-written switch to read. RFC 0017 §10 asks that an operator be + // able to see why a kernel was not selected, and a pack pruned at the + // graph level is the coarsest and most common reason. + HIPDNN_PLUGIN_LOG_INFO("ingestor: pack " << toString(pack.id) + << " declined at a graph-scoped matcher"); continue; } @@ -450,6 +468,7 @@ class KernelIngestorStateManager // token name means the same thing to every pack in an engine. catalog.bound.insert(packBound.begin(), packBound.end()); + size_t admitted = 0; for(const auto& kernel : pack.kernels) { KernelDefinition definition{kernel.id, @@ -462,10 +481,21 @@ class KernelIngestorStateManager if(kernelLevelMatchersPass(pack, context, definition)) { catalog.entries.push_back(std::move(definition)); + ++admitted; } } + + HIPDNN_PLUGIN_LOG_INFO("ingestor: pack " << toString(pack.id) << " admitted " + << admitted << " of " << pack.kernels.size() + << " kernel(s) after kernel-scoped matching"); } + // The summary a caller diagnosing an unexpected decline actually needs: an empty + // catalog here is what applicability reports as "does not apply", and without + // this line the only observable is the absence of an engine id. + HIPDNN_PLUGIN_LOG_INFO("ingestor: catalog for device " + << context.deviceId << " holds " << catalog.entries.size() + << " kernel(s) from " << _packs.size() << " pack(s)"); return catalog; } diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index 06f6e356e073..1c2aa3344bc0 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -10,11 +10,14 @@ #include #include #include +#include #include +#include #include #include #include +#include namespace hipdnn_plugin_sdk::ingestor { @@ -96,11 +99,36 @@ struct CatalogKeyHash * `$q.uid` is reading a value the matcher already resolved, not re-deriving it from the * graph with a second notion of what the graph looks like. * - * Keyed by the token name a descriptor would write. Values are int64 because every token - * a matcher binds today is a tensor uid or a dimension; a wider value type is the - * expression-language follow-up's to choose, and widening it is additive. + * Keyed by the token name a descriptor would write. Values are MetadataValue, the same + * type a KMD field holds: RFC 0017's criteria language puts a graph fact and a kernel + * fact on either side of one operator (`divisible($q.head_size, $kernel.tile_m)`), so + * an interpreter needs one value type spanning both namespaces or its first act is a + * refactor. That is also why the list alternative matters on this side and not only on + * the kernel side: `stride_order`, the worked example for INT_LIST, is a graph fact. */ -using BoundTokens = std::unordered_map; +using BoundTokens = std::unordered_map; + +/// Reads @p token from @p bound as an integer. +/// +/// Bound tokens are frequently tensor uids and dimensions, so the int64 read is the +/// common one and is worth naming rather than open-coding a std::get_if at every call +/// site. Returns nullopt when the token is absent OR holds a non-integer alternative: +/// a caller that asked for an integer cannot use a list, and conflating "not bound" +/// with "bound to something else" would let a type confusion read as a missing token. +inline std::optional tryGetBoundInt(const BoundTokens& bound, std::string_view token) +{ + const auto it = bound.find(std::string(token)); + if(it == bound.end()) + { + return std::nullopt; + } + const auto* value = std::get_if(&it->second); + if(value == nullptr) + { + return std::nullopt; + } + return *value; +} /** * @brief The bound token state a matcher, scorer, or dispatch formula reads. diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp index 3873d055dc9e..0ec9f75c7031 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp @@ -270,7 +270,10 @@ TEST(TestKernelIngestorStateManager, CarriesWhatMatchingBoundThroughToDispatch) const auto bound = manager->unsortedCatalog(context).bound; ASSERT_EQ(bound.count("test.bound_token"), 1U); - EXPECT_EQ(bound.at("test.bound_token"), BOUND_TOKEN_VALUE); + // Compared as the integer alternative rather than against the variant as a whole: + // a bare int64 on the right would not convert, and asserting through the accessor + // also pins that the matcher bound an integer and not some other alternative. + EXPECT_EQ(tryGetBoundInt(bound, "test.bound_token"), BOUND_TOKEN_VALUE); } TEST(TestKernelIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematch) @@ -291,10 +294,16 @@ TEST(TestKernelIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematc static_cast(manager->unsortedCatalog(context)); const auto afterMatching = counters().graphCalls; - static_cast(manager->unsortedCatalog(context).bound); - static_cast(manager->sortedCatalog(context).bound); + // The cached reads must still carry the bound state, not merely avoid re-matching. + // Asserting only graphCalls would pass against a cache that served an entry with + // Catalog::bound emptied -- which destroys the section 8.1 property this test is + // named for while leaving its counter untouched. + const auto secondRead = manager->unsortedCatalog(context).bound; + const auto thirdRead = manager->sortedCatalog(context).bound; EXPECT_EQ(counters().graphCalls, afterMatching); + EXPECT_EQ(tryGetBoundInt(secondRead, "test.bound_token"), BOUND_TOKEN_VALUE); + EXPECT_EQ(tryGetBoundInt(thirdRead, "test.bound_token"), BOUND_TOKEN_VALUE); } // --------------------------------------------------------------------------- From 4e0e30351f8a95ad3fae6b200a8b5c64129095fe Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 22:58:50 -0400 Subject: [PATCH 28/60] fix(hipdnn): fail closed on collisions and cover what the tests missed (ALMIOPEN-2402) Four review slices, each finding proven by a mutation that the new test catches and the old suite did not. Fail closed where the ingestor silently kept the first value. A duplicate matcher or dispatch descriptor id was dropped by emplace, so a pack could run a matcher other than the one it named while load-time validation still passed -- NativeRegistry already treats exactly this as an author bug. Two packs binding one token name to different values were merged by insert, which keeps the first and discards the second; Catalog's own doc called that an authoring error the loader should reject, and no loader checked it. Merging now compares and throws on a real conflict while leaving the same-value case alone, since packs share matchers by id and agreeing merges are the normal path. It is a merge-time check because validateAndIndexPacks sees descriptor ids, never what a matcher resolves. Reject a nil or non-v4 graph id as no identity. tryGetGraphId tested only that the pointer was non-null, so a present-but-nil uuid produced a valid cache key and every graph carrying one shared a single catalog entry -- the aliasing its own doc forbids. Verified this rejects nothing real: GraphDescriptor's tests assert synthesized ids are v4. The test fixture's makeGraphId had never produced a v4-shaped id, so it now sets the version and variant nibbles while staying injective over every seed, with separate helpers for the nil and non-v4 shapes the negative tests need. Make the two workspace paths agree. getMaxWorkspaceSize returned 0 for an empty catalog while buildPlan called the identical state an internal error. Both are reachable only after applicability accepted the graph, so it is one bug in two places; answering 0 sends a caller off to allocate nothing and discover the real failure later, detached from its cause. Both now throw through one helper. Refuse operands dispatch cannot serve. The matcher gated dims, strides, dtype and arity but accepted virtual and pass-by-value operands. A virtual tensor never reaches the variant pack, so the launch failed only after applicability, plan build, a compile and a workspace allocation; worse, a one-element rank-4 tensor is exactly a pass-by-value scalar's shape, whose slot carries a host pointer that this handler passed to hipModuleLaunchKernel. It asks the provider's existing predicate rather than restating the rule. Roll back a partial registration. call_once leaves its flag clear when the callable throws, so the next container retried -- but the symbols already installed stayed, and the retry then failed on one this pack itself registered, reporting a cause that was not the cause. It now unregisters what it installed, so the second attempt reports the same conflict as the first. Cover what nothing covered. The pack ships a HALF kernel that no test ever compiled or launched, so retyping _Float16 to float failed nothing; the launch test is now parameterized over FLOAT and HALF against a real device. The non-integer knob rejection, the unsatisfiable-filter survivor count, and the empty-catalog workspace path had no tests at all. Delete what nothing used: LruCache::clear, NativeRegistry::isRegistered, EngineDescriptor::numericalNotes, two dead test helpers, and two tests no source change could fail. GenericEngine is no longer movable, since its plan builder holds references into it. NativeRegistry now states the thread-safety obligation on the function-pointer surface every pack author writes, and that one registry per process means one per loaded image -- which holds only because the provider builds with hidden visibility. Verified: 202 SDK, 486 provider with a device, 464 device-less exiting 0, 13 integration. Flag off, 91 SDK and 428 provider with no ingestor test present, no ingestor header installed, and no ingestor object rebuilt when its sources are touched. --- .../KernelIngestorEngine.cpp | 28 ++- .../KernelIngestorEngine.hpp | 5 + .../packs/PointwiseAddMatchers.cpp | 47 ++++- .../packs/PointwiseAddMatchers.hpp | 6 + .../TestKernelIngestorEngine.cpp | 47 +++++ .../packs/PointwiseAddTestGraphs.hpp | 19 +- .../packs/TestPointwiseAddDispatchHandler.cpp | 77 ++++++-- .../packs/TestPointwiseAddMatchers.cpp | 30 +++ .../hipdnn_plugin_sdk/ingestor/Catalog.hpp | 5 +- .../ingestor/Descriptors.hpp | 7 - .../ingestor/GenericEngine.hpp | 7 + .../ingestor/GenericPlanBuilder.hpp | 41 ++-- .../ingestor/KernelIngestorStateManager.hpp | 41 +++- .../hipdnn_plugin_sdk/ingestor/LruCache.hpp | 8 - .../ingestor/MatchContext.hpp | 11 +- .../ingestor/NativeRegistry.hpp | 28 +-- .../tests/ingestor/IngestorMocks.hpp | 8 +- .../ingestor/KernelIngestorTestFixtures.hpp | 76 ++++++-- .../tests/ingestor/TestDescriptors.cpp | 29 +-- .../tests/ingestor/TestGenericEngine.cpp | 8 + .../tests/ingestor/TestGenericPlanBuilder.cpp | 86 ++++++++ .../TestKernelIngestorStateManager.cpp | 184 ++++++++++++++++-- .../tests/ingestor/TestLruCache.cpp | 25 --- .../tests/ingestor/TestMatchContext.cpp | 16 ++ .../tests/ingestor/TestNativeRegistry.cpp | 11 -- 25 files changed, 665 insertions(+), 185 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index b7ff1b879fe0..0b461494cfce 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -64,16 +64,30 @@ const HandleDeviceResolver& deviceResolver() } // namespace +/// The body registerNativeIngestorSymbols() runs at most once per process. Broken out +/// so a test can drive it directly and observe rollback on a forced conflict, which the +/// once_flag wrapper below would otherwise make unreachable a second time. +void registerNativeIngestorSymbolsOnce() +{ + registerPointwiseAddMatchers(); + try + { + registerPointwiseAddDispatch(dispatchHandler()); + } + catch(...) + { + unregisterPointwiseAddMatchers(); + throw; + } +} + void registerNativeIngestorSymbols() { - // Idempotent across repeated Container construction (see SharedContainerManager): - // the process-wide NativeRegistry this populates must be populated exactly once, and - // a second registerSymbol() call under the same name would throw on the duplicate. + // call_once leaves the flag unset on a throw, so a retry re-runs the body -- which + // must roll back what it installed, or the retry fails on ITS OWN partial state + // instead of the original conflict. static std::once_flag s_registered; - std::call_once(s_registered, []() { - registerPointwiseAddMatchers(); - registerPointwiseAddDispatch(dispatchHandler()); - }); + std::call_once(s_registered, registerNativeIngestorSymbolsOnce); } std::unique_ptr> makePointwiseAddEngine() diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp index 6fa2de47a5bd..8df04ff79e04 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp @@ -35,6 +35,11 @@ namespace hip_kernel_provider::kernel_ingestor_engine */ void registerNativeIngestorSymbols(); +/// @brief The once_flag-guarded body of registerNativeIngestorSymbols(), callable +/// directly so a test can force and observe a partial-failure/rollback cycle a second +/// time -- something the flag makes unreachable through the wrapper itself. +void registerNativeIngestorSymbolsOnce(); + /** * @brief Builds the descriptor-backed pointwise-add engine. * diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index daf4e6d7b362..767925a3ffcf 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -171,6 +172,22 @@ bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) return false; } + // A virtual tensor never materializes into a device buffer, so it is absent from + // the variant pack findDeviceBuffer resolves at launch. + if(inputA->virtual_() || inputB->virtual_() || output->virtual_()) + { + return false; + } + + // A 1-element rank-4 tensor is also the shape of a pass-by-value scalar, whose + // variant-pack slot holds a host pointer, not a device one. + if(hipdnn_flatbuffers_sdk::utilities::isPassByValueTensor(inputA) + || hipdnn_flatbuffers_sdk::utilities::isPassByValueTensor(inputB) + || hipdnn_flatbuffers_sdk::utilities::isPassByValueTensor(output)) + { + return false; + } + // Uniform dtype across operands. Mixed-precision add is a different kernel, and // accepting it here would hand one binary operands it cannot read. if(inputA->data_type() != inputB->data_type() || inputA->data_type() != output->data_type()) @@ -236,9 +253,33 @@ void registerPointwiseAddMatchers() { GraphMatcherRegistry::registerSymbol(std::string(GRAPH_MATCHER_SYMBOL), &pointwiseAddGraphMatches); - KernelMatcherRegistry::registerSymbol(std::string(KERNEL_MATCHER_SYMBOL), - &pointwiseAddKernelMatches); - ScoreRegistry::registerSymbol(std::string(SCORE_SYMBOL), &pointwiseAddScore); + try + { + KernelMatcherRegistry::registerSymbol(std::string(KERNEL_MATCHER_SYMBOL), + &pointwiseAddKernelMatches); + } + catch(...) + { + GraphMatcherRegistry::unregisterSymbol(std::string(GRAPH_MATCHER_SYMBOL)); + throw; + } + try + { + ScoreRegistry::registerSymbol(std::string(SCORE_SYMBOL), &pointwiseAddScore); + } + catch(...) + { + GraphMatcherRegistry::unregisterSymbol(std::string(GRAPH_MATCHER_SYMBOL)); + KernelMatcherRegistry::unregisterSymbol(std::string(KERNEL_MATCHER_SYMBOL)); + throw; + } +} + +void unregisterPointwiseAddMatchers() +{ + GraphMatcherRegistry::unregisterSymbol(std::string(GRAPH_MATCHER_SYMBOL)); + KernelMatcherRegistry::unregisterSymbol(std::string(KERNEL_MATCHER_SYMBOL)); + ScoreRegistry::unregisterSymbol(std::string(SCORE_SYMBOL)); } } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp index 222663b1dfc3..6cf1d6feff68 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp @@ -63,8 +63,14 @@ PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::Bound /// /// Called once, from the pack's translation unit, rather than at static-init time: an /// engine that is never constructed should not have mutated process-wide registries. +/// Atomic: if one symbol is already taken, whichever of the others this call already +/// installed is unregistered before it rethrows. void registerPointwiseAddMatchers(); +/// @brief Undoes registerPointwiseAddMatchers(), for a caller that must roll this pack +/// back after a later registration step of its own fails. +void unregisterPointwiseAddMatchers(); + } // namespace hip_kernel_provider::kernel_ingestor_engine #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp index b2a78f1cb283..c19519db63d0 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp @@ -15,11 +15,13 @@ #include #include +#include #include "core/Container.hpp" #include "core/Context.hpp" #include "core/Handle.hpp" #include "engines/kernel_ingestor_engine/KernelIngestorEngine.hpp" +#include "engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp" #include "engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp" #include "tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp" @@ -79,6 +81,51 @@ TEST(TestKernelIngestorEngine, RegisterNativeIngestorSymbolsIsIdempotentAcrossRe EXPECT_NO_THROW(registerNativeIngestorSymbols()); } +TEST(TestKernelIngestorEngine, RegistrationFailureReportsTheSameCauseOnRetryAfterRollback) +{ + using hipdnn_plugin_sdk::ingestor::DispatchRegistry; + + // Container's constructor already ran registration once for the process; + // unregister to drive controlled attempts, and always restore -- every other test + // resolves matching and dispatch through these symbols. + unregisterPointwiseAddMatchers(); + DispatchRegistry::unregisterSymbol(std::string(DISPATCH_SYMBOL)); + struct RestoreGuard + { + ~RestoreGuard() + { + registerNativeIngestorSymbolsOnce(); + } + } const restoreGuard; + + // Occupies the dispatch symbol, so registerNativeIngestorSymbolsOnce() succeeds on + // every matcher and fails only on dispatch -- the partial-failure shape the + // rollback exists for. + DispatchRegistry::registerSymbol(std::string(DISPATCH_SYMBOL), nullptr); + + const auto attempt = [] { + try + { + registerNativeIngestorSymbolsOnce(); + } + catch(const std::runtime_error& e) + { + return std::string(e.what()); + } + ADD_FAILURE() << "expected a duplicate-symbol throw"; + return std::string(); + }; + + const auto firstMessage = attempt(); + const auto secondMessage = attempt(); + + // Without rollback, the first attempt's matcher symbols stay registered and the + // second attempt fails on one of those instead -- a different, misleading cause. + EXPECT_EQ(firstMessage, secondMessage); + + DispatchRegistry::unregisterSymbol(std::string(DISPATCH_SYMBOL)); +} + // --------------------------------------------------------------------------- // makePointwiseAddEngine(): a working GenericEngine, reached through Container // --------------------------------------------------------------------------- diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp index b06ca303cb43..6b7d06c0e228 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp @@ -78,6 +78,9 @@ inline hipDeviceProp_t currentDeviceProperties() * `INPUT_B_UID`, and no tensor is inserted for it -- the graph carries a node * whose operand uid resolves to nothing in getTensorMap(). Exercises the matcher * refusing a graph it cannot even read, as opposed to one it reads and declines. + * @param inputAVirtual Marks input A virtual, which the graph matcher must refuse. + * @param inputAIsRuntimePassByValue Marks input A runtime-pass-by-value, which the + * graph matcher must refuse. */ inline flatbuffers::FlatBufferBuilder buildPointwiseGraph( hipdnn_flatbuffers_sdk::data_objects::PointwiseMode operation @@ -90,7 +93,9 @@ inline flatbuffers::FlatBufferBuilder buildPointwiseGraph( const std::optional>& explicitStrides = std::nullopt, std::optional inputBDataType = std::nullopt, bool includeThirdOperand = false, - std::optional danglingInputBUid = std::nullopt) + std::optional danglingInputBUid = std::nullopt, + bool inputAVirtual = false, + bool inputAIsRuntimePassByValue = false) { namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; @@ -102,8 +107,16 @@ inline flatbuffers::FlatBufferBuilder buildPointwiseGraph( const auto resolvedInputBDataType = inputBDataType.value_or(dataType); std::vector> tensors; - tensors.push_back(data_objects::CreateTensorAttributesDirect( - builder, INPUT_A_UID, nullptr, dataType, &strides, &dims, false)); + tensors.push_back(data_objects::CreateTensorAttributesDirect(builder, + INPUT_A_UID, + nullptr, + dataType, + &strides, + &dims, + inputAVirtual, + data_objects::TensorValue::NONE, + 0, + inputAIsRuntimePassByValue)); tensors.push_back(data_objects::CreateTensorAttributesDirect( builder, INPUT_B_UID, nullptr, resolvedInputBDataType, &strides, &dims, false)); tensors.push_back(data_objects::CreateTensorAttributesDirect( diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index c47c77f7ff9e..92f96580c66d 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -65,17 +66,20 @@ BoundTokens bindingsFor(const MatchContext& context) return bound; } -/// Device buffers for one 1-element add, freed on scope exit. +/// Device buffers for one 1-element add, freed on scope exit. Templated on the element +/// type so the same buffer plumbing serves every dtype this pack's dispatch handler +/// compiles for, rather than one copy per type that could quietly drift apart. +template class AddBuffers { public: - AddBuffers(float a, float b) + AddBuffers(T a, T b) { - EXPECT_EQ(hipSuccess, hipMalloc(&_a, sizeof(float))); - EXPECT_EQ(hipSuccess, hipMalloc(&_b, sizeof(float))); - EXPECT_EQ(hipSuccess, hipMalloc(&_c, sizeof(float))); - EXPECT_EQ(hipSuccess, hipMemcpy(_a, &a, sizeof(float), hipMemcpyHostToDevice)); - EXPECT_EQ(hipSuccess, hipMemcpy(_b, &b, sizeof(float), hipMemcpyHostToDevice)); + EXPECT_EQ(hipSuccess, hipMalloc(&_a, sizeof(T))); + EXPECT_EQ(hipSuccess, hipMalloc(&_b, sizeof(T))); + EXPECT_EQ(hipSuccess, hipMalloc(&_c, sizeof(T))); + EXPECT_EQ(hipSuccess, hipMemcpy(_a, &a, sizeof(T), hipMemcpyHostToDevice)); + EXPECT_EQ(hipSuccess, hipMemcpy(_b, &b, sizeof(T), hipMemcpyHostToDevice)); } ~AddBuffers() @@ -95,10 +99,10 @@ class AddBuffers hipdnnPluginDeviceBuffer_t{OUTPUT_UID, _c}}; } - float readResult() const + T readResult() const { - float result = 0.0f; - EXPECT_EQ(hipSuccess, hipMemcpy(&result, _c, sizeof(float), hipMemcpyDeviceToHost)); + T result{}; + EXPECT_EQ(hipSuccess, hipMemcpy(&result, _c, sizeof(T), hipMemcpyDeviceToHost)); return result; } @@ -240,28 +244,65 @@ TEST(TestPointwiseAddDispatch, RefusesToPrepareWithoutTheMatcherSBindings) // Prepare and launch on device // --------------------------------------------------------------------------- -TEST(TestPointwiseAddDispatch, LaunchesARealAddOnDevice) +// Runs over every dtype the pack ships a kernel for, so a broken HALF compile path +// fails a real launch rather than only the matcher tests, which never reach hiprtc. +struct RealLaunchCase +{ + std::string name; + hipdnn_flatbuffers_sdk::data_objects::DataType dataType; + std::string kernelDtype; +}; + +class TestPointwiseAddDispatchRealLaunch : public ::testing::TestWithParam +{ +}; + +TEST_P(TestPointwiseAddDispatchRealLaunch, LaunchesARealAddOnDevice) { SKIP_IF_NO_DEVICES(); - const GraphFixture fixture(buildPointwiseGraph(), currentDeviceProperties()); + const auto& param = GetParam(); + const GraphFixture fixture( + buildPointwiseGraph(hipdnn_flatbuffers_sdk::data_objects::PointwiseMode::ADD, + param.dataType), + currentDeviceProperties()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); const auto prepared = handler.prepare( - fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")); + fixture.context(), bindingsFor(fixture.context()), makeKernel(64, param.kernelDtype)); ASSERT_NE(prepared, nullptr); - const AddBuffers buffers(3.0f, 4.0f); - const auto descriptors = buffers.descriptors(); const Handle handle; + if(param.dataType == hipdnn_flatbuffers_sdk::data_objects::DataType::HALF) + { + const AddBuffers buffers(hipdnn_data_sdk::types::half(3.0f), + hipdnn_data_sdk::types::half(4.0f)); + const auto descriptors = buffers.descriptors(); - handler.launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); - ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + handler.launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); + ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + EXPECT_NEAR(static_cast(buffers.readResult()), 7.0f, 1e-2f); + } + else + { + const AddBuffers buffers(3.0f, 4.0f); + const auto descriptors = buffers.descriptors(); - EXPECT_FLOAT_EQ(buffers.readResult(), 7.0f); + handler.launch(handle, *prepared, descriptors.data(), descriptors.size(), nullptr); + ASSERT_EQ(hipSuccess, hipDeviceSynchronize()); + EXPECT_FLOAT_EQ(buffers.readResult(), 7.0f); + } } +INSTANTIATE_TEST_SUITE_P( + , + TestPointwiseAddDispatchRealLaunch, + ::testing::Values( + RealLaunchCase{"Float", hipdnn_flatbuffers_sdk::data_objects::DataType::FLOAT, "FLOAT"}, + RealLaunchCase{"Half", hipdnn_flatbuffers_sdk::data_objects::DataType::HALF, "HALF"}), + [](const ::testing::TestParamInfo& info) { return info.param.name; }); + TEST(TestPointwiseAddDispatch, LaunchesTheSameResultForEitherBlockSize) { SKIP_IF_NO_DEVICES(); diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index 31682aac5d8b..1cc39bbade06 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -195,6 +195,36 @@ INSTANTIATE_TEST_SUITE_P( /*includeThirdOperand=*/false, /*danglingInputBUid=*/DEFAULT_DANGLING_UID); }}, + {"AVirtualOperand", + // A virtual tensor never appears in the launch's variant pack. + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::nullopt, + /*inputBDataType=*/std::nullopt, + /*includeThirdOperand=*/false, + /*danglingInputBUid=*/std::nullopt, + /*inputAVirtual=*/true); + }}, + {"ARuntimePassByValueOperand", + // This pack's supported shape is indistinguishable from a pass-by-value + // scalar, whose variant-pack slot is a host pointer, not a device one. + []() { + return buildPointwiseGraph(data_objects::PointwiseMode::ADD, + data_objects::DataType::FLOAT, + {1, 1, 1, 1}, + std::nullopt, + /*binary=*/true, + /*explicitStrides=*/std::nullopt, + /*inputBDataType=*/std::nullopt, + /*includeThirdOperand=*/false, + /*danglingInputBUid=*/std::nullopt, + /*inputAVirtual=*/false, + /*inputAIsRuntimePassByValue=*/true); + }}, }), [](const ::testing::TestParamInfo& info) { return info.param.name; }); diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp index 7a23036b969a..540617613740 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp @@ -41,9 +41,8 @@ struct Catalog /// matcher id, not by pack. /// /// Merged rather than kept per pack: a token name means the same thing to every pack - /// in an engine, since they all read one graph through one metadata schema. Two packs - /// binding one name to different values would be an authoring error the loader should - /// reject, not a state this needs to represent. + /// in an engine. Two packs binding one name to DIFFERENT values is an authoring + /// error, rejected at merge time since only a runtime match reveals it. BoundTokens bound; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 7109761096e3..2fcafd558075 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -202,13 +202,6 @@ struct EngineDescriptor /// int32, so a newer descriptor can carry a note value an older backend does not /// know without truncating it. std::vector behaviorNotes; - /// Numerical behavior this engine's kernels exhibit, from the same vocabulary RFC - /// 0017 §4 gives the UED (`tensor_core`, `reduced_precision_reduction`, and so on). - /// - /// Carried here but not yet reported: EngineDetails has no numerical-notes field, so - /// unlike behaviorNotes there is nothing to transport these through. Recorded so a - /// descriptor can state them and the gap is in the schema rather than in this type. - std::vector numericalNotes; }; /// Which inputs a matcher reads, which decides how often it runs and what its failure diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index 41ca015fc254..e83509844fba 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -89,6 +89,13 @@ class GenericEngine : public IEngine } } + /// Not relocatable: _planBuilder holds references into _engine and *_stateManager, + /// so a move or copy would leave it referring into the source object. + GenericEngine(const GenericEngine&) = delete; + GenericEngine& operator=(const GenericEngine&) = delete; + GenericEngine(GenericEngine&&) = delete; + GenericEngine& operator=(GenericEngine&&) = delete; + /// The descriptor this engine was built from, for diagnostics and for a caller that /// needs the engine's declared knobs or notes. const EngineDescriptor& descriptor() const diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 70920c6caa43..08fd37afaad7 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -92,6 +92,11 @@ class GenericPlanBuilder : public IPlanBuilder * The maximum suffices because the buffer is reused rather than partitioned: kernels * launch one at a time on one stream, so a candidate's scratch is live only while it * runs. A kernel needing less over-allocates, which is accepted. + * + * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR) if the catalog + * is empty. Same failure as buildPlan(), for the same reason (see its doc). + * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE) if knob + * filtering empties a non-empty catalog. See throwUnsatisfiableKnobFilter(). */ size_t getMaxWorkspaceSize(const THandle& handle, const IGraph& opGraph, @@ -100,12 +105,17 @@ class GenericPlanBuilder : public IPlanBuilder const auto context = contextFor(handle, opGraph); // Unsorted, but still one lookup for the entries and the bound state together. const auto catalog = _stateManager.unsortedCatalog(context); + if(catalog.entries.empty()) + { + throwNoApplicableKernel(); + } + // Filtered before the max is taken, same contract as buildPlan: the caller's // knob settings are a request for a subset of the catalog, not a value the // survivors are merely ranked by. const auto filtered = applyKnobFilter(catalog.entries, executionSettings.ingestorKnobFilter); - if(!catalog.entries.empty() && filtered.empty()) + if(filtered.empty()) { // Same failure buildPlan reports for the same reason: a workspace query and // a plan build must not disagree about whether a knob combination is legal. @@ -148,16 +158,10 @@ class GenericPlanBuilder : public IPlanBuilder * set. * * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR) if the catalog - * is empty before knob filtering. Reaching here with nothing to build means - * applicability accepted a graph this engine cannot serve at all — RFC 0017 - * §8.6 makes that a bug, not a legal outcome, so it surfaces as a failed - * plan build rather than a silent decline. Distinct from the case below: - * this is a matcher/applicability disagreement, not a bad caller request. - * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE) if the catalog - * is non-empty but knob filtering empties it. See - * throwUnsatisfiableKnobFilter() for why this is the caller's fault rather - * than the engine's: a graph this engine can serve exists, but no kernel - * implements the specific combination of values the caller asked for. + * is empty before knob filtering: a matcher/applicability disagreement, not + * a bad caller request. Same failure getMaxWorkspaceSize() reports. + * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE) if knob + * filtering empties a non-empty catalog. See throwUnsatisfiableKnobFilter(). */ void buildPlan(const THandle& handle, const IGraph& opGraph, @@ -170,9 +174,7 @@ class GenericPlanBuilder : public IPlanBuilder const auto catalog = _stateManager.sortedCatalog(context); if(catalog.entries.empty()) { - throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, - "engine '" + _engine.name - + "' accepted this graph but has no applicable kernel"); + throwNoApplicableKernel(); } // Filtered after ranking rather than before: filtering and ranking commute (see @@ -353,6 +355,17 @@ class GenericPlanBuilder : public IPlanBuilder return filtered; } + /** + * @brief Reports an empty catalog: shared by getMaxWorkspaceSize() and buildPlan(), + * since both run only after isApplicable() already accepted this graph. + */ + [[noreturn]] void throwNoApplicableKernel() const + { + throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, + "engine '" + _engine.name + + "' accepted this graph but has no applicable kernel"); + } + /** * @brief Reports a knob combination no kernel in the catalog implements. * diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index 82b91fe9c2e3..ccfba8e72d05 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -120,11 +120,23 @@ class KernelIngestorStateManager for(auto& matcher : matchers) { - _matchers.emplace(matcher.id, std::move(matcher)); + const auto id = matcher.id; + if(const auto [it, inserted] = _matchers.emplace(id, std::move(matcher)); !inserted) + { + // Same defect NativeRegistry.hpp rejects for a duplicate symbol: a pack + // naming this id would silently run whichever matcher loaded first. + throw std::invalid_argument("duplicate match descriptor id '" + toString(id) + + "' collides with '" + it->second.name + "'"); + } } for(auto& dispatch : dispatches) { - _dispatches.emplace(dispatch.id, std::move(dispatch)); + const auto id = dispatch.id; + if(const auto [it, inserted] = _dispatches.emplace(id, std::move(dispatch)); !inserted) + { + throw std::invalid_argument("duplicate dispatch descriptor id '" + toString(id) + + "' collides with '" + it->second.name + "'"); + } } validateAndIndexPacks(); @@ -464,9 +476,9 @@ class KernelIngestorStateManager continue; } - // Merged rather than kept per pack: Catalog::bound's own doc explains why a - // token name means the same thing to every pack in an engine. - catalog.bound.insert(packBound.begin(), packBound.end()); + // Merged rather than kept per pack: a token name means the same thing to + // every pack in an engine (Catalog::bound's doc). + mergeBound(catalog.bound, packBound, pack.id); size_t admitted = 0; for(const auto& kernel : pack.kernels) @@ -499,6 +511,25 @@ class KernelIngestorStateManager return catalog; } + /// Folds @p packBound into @p bound. Two packs agreeing on a token's value are just + /// sharing a matcher by id; two packs writing DIFFERENT values under one name is an + /// authoring error, only detectable here since it depends on a runtime match. + static void + mergeBound(BoundTokens& bound, const BoundTokens& packBound, const DescriptorId& packId) + { + for(const auto& [token, value] : packBound) + { + const auto [it, inserted] = bound.emplace(token, value); + if(!inserted && !(it->second == value)) + { + throw std::runtime_error( + "pack '" + toString(packId) + "' binds token '" + token + + "' to a value that disagrees with another pack's binding of the " + "same token; one token name must mean one thing across an engine"); + } + } + } + /** * @param packBound Accumulates what THIS pack's own graph-scoped matchers resolved, * isolated from every other pack's contribution (see GraphMatcherVerdict). diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp index b3a0153327dd..ea0d960bde84 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp @@ -85,14 +85,6 @@ class LruCache } } - /// @brief Drops every entry. The next query for any key rematches. - void clear() - { - const std::lock_guard lock(_mutex); - _order.clear(); - _index.clear(); - } - size_t size() const { const std::lock_guard lock(_mutex); diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index 1c2aa3344bc0..1d267d44576b 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -155,6 +155,10 @@ struct MatchContext /// Callers must treat that as "cannot cache", never as an error: matching a graph with /// no id still produces the right answer, it just cannot be memoized (there is no key to /// memoize it under, and inventing one would alias unrelated graphs). +/// +/// A present field is not enough: a nil or non-v4 id (real ids are always v4) is not +/// guaranteed unique, so it reads as "no identity" too. Cache-key uniqueness therefore +/// depends on isUuidV4()'s exact predicate; loosening it reopens this aliasing. inline std::optional tryGetGraphId(const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& graph) { @@ -163,7 +167,12 @@ inline std::optional { return std::nullopt; } - return hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*id); + const auto bytes = hipdnn_flatbuffers_sdk::utilities::toUuidBytes(*id); + if(!hipdnn_flatbuffers_sdk::utilities::isUuidV4(bytes)) + { + return std::nullopt; + } + return bytes; } } // namespace hipdnn_plugin_sdk::ingestor diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp index b992688c527b..44e270572c73 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp @@ -47,28 +47,39 @@ namespace hipdnn_plugin_sdk::ingestor /// Only graph-scoped matchers bind. Kernel-scoped ones run once per candidate, so what /// they resolved would be per-kernel state with nowhere in the catalog to live; the /// tokens a launch needs describe the problem, not the kernel chosen for it. +/// +/// Must be thread-safe: the registry may call it concurrently, same contract as +/// `IKernelDispatchHandler::launch`. using GraphMatcherFn = bool (*)(const MatchContext&, BoundTokens& bound); /// A kernel-scoped applicability check: also reads the candidate's metadata, so it runs -/// once per surviving kernel and disqualifies that kernel alone. +/// once per surviving kernel and disqualifies that kernel alone. Must be thread-safe. using KernelMatcherFn = bool (*)(const MatchContext&, const KernelDefinition&); /// Scores one kernel for one problem. Higher is better. /// /// Deliberately never handed the catalog: see IKernelHeuristic for why that constraint -/// is what keeps a knob-filtered query consistent with the default it reported. +/// is what keeps a knob-filtered query consistent with the default it reported. Must be +/// thread-safe. using ScoreFn = double (*)(const KernelDefinition&, const MatchContext&); /** * @brief The provider's registry of native implementations, keyed by symbol name. * - * One instance per registered type per process, reached through the static accessors - * below. Registration happens at static-init time from the provider's own translation - * units; lookup happens on the applicability, ranking, and dispatch paths. + * One instance per registered type per loaded image (not per process: two plugin + * `.so`s each get their own, which is why lookup never crosses providers). Registration + * happens at static-init time from the provider's own translation units; lookup happens + * on the applicability, ranking, and dispatch paths. * * Guarded by a mutex because registration and lookup can race: a provider loaded on one * thread while another is already matching is unusual but not forbidden. * + * Per-image isolation depends on the provider building with `CXX_VISIBILITY_PRESET + * hidden` and `--exclude-libs=ALL` (`src/CMakeLists.txt`): if a provider ever exported + * this template's symbols, two loaded copies of the same provider would share one + * registry and the second `registerSymbol()` would throw out of + * `hipdnnEnginePluginCreate`. + * * @tparam T The registered callable or interface pointer type. */ template @@ -111,13 +122,6 @@ class NativeRegistry return it->second; } - static bool isRegistered(const std::string& symbol) - { - auto& self = instance(); - const std::lock_guard lock(self._mutex); - return self._symbols.find(symbol) != self._symbols.end(); - } - /// @brief Removes @p symbol if present. Exists so a test can install a counting or /// failing implementation and restore the original afterwards; production code /// registers once at static-init time and never unregisters. diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp index 5d3c7c2f7e66..4a3c0bc83669 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp @@ -19,10 +19,10 @@ * @file IngestorMocks.hpp * @brief gmock doubles for the ingestor's two pure interfaces. * - * IKernelDispatchHandler and IDeviceResolver have no test of their own (see the Phase 2 - * plan): they are asserted only through the generic code that consumes them -- - * GenericPlan/GenericPlanBuilder for the dispatch handler, GenericPlanBuilder::contextFor - * for the device resolver. These mocks are what those tests set expectations against. + * IKernelDispatchHandler and IDeviceResolver have no test of their own: they are + * asserted only through the generic code that consumes them -- GenericPlan/ + * GenericPlanBuilder for the dispatch handler, GenericPlanBuilder::contextFor for the + * device resolver. These mocks are what those tests set expectations against. */ namespace hipdnn_plugin_sdk::ingestor::testing { diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp index 7f4c047b9c2c..e96cf470a69a 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp @@ -137,13 +137,33 @@ class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph _tensors; }; +/// A test graph id distinct per seed, shaped as a valid v4 UUID (tryGetGraphId() now +/// rejects non-v4 ids as "no identity"). inline GraphId makeGraphId(uint8_t seed) { GraphId id{}; id.fill(seed); + id[6] = static_cast((id[6] & 0x0fU) | 0x40U); + id[8] = static_cast((id[8] & 0x3fU) | 0x80U); return id; } +/// A distinct-per-seed id shaped to fail the v4 check, for tests needing a non-nil +/// "no identity" id. +inline GraphId makeNonV4GraphId(uint8_t seed) +{ + GraphId id{}; + id.fill(seed); + id[6] = static_cast(id[6] & 0x0fU); + return id; +} + +/// The nil UUID: all-zero bytes, the id a default-constructed field reads as. +inline GraphId makeNilGraphId() +{ + return GraphId{}; +} + inline hipDeviceProp_t testDeviceProperties() { hipDeviceProp_t properties{}; @@ -241,19 +261,6 @@ inline KernelDescriptor makeTestKernel(const DescriptorId& id, return kernel; } -/// The UED the fixture engine is built from. Separate from the state manager, which -/// holds only what selection reads. -inline EngineDescriptor makeTestEngine() -{ - EngineDescriptor engine; - engine.id = ENGINE_ID; - engine.name = "test:engine"; - engine.heuristicId = HEURISTIC_ID; - engine.metadataSchemaId = SCHEMA_ID; - engine.knobs = {BLOCK_SIZE}; - return engine; -} - /// Two FLOAT kernels differing only in block size, plus a HALF kernel the kernel-scoped /// matcher prunes: a catalog of two survivors with a defined ranking. inline std::unique_ptr> @@ -542,11 +549,6 @@ struct StubContext _plan = std::move(plan); } - bool hasPlan() const - { - return _plan != nullptr; - } - private: std::unique_ptr> _plan; }; @@ -629,6 +631,44 @@ inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeInt builder.GetBufferPointer(), builder.GetSize()); } +/// A FloatValue knob setting, so a test can reach readKnobFilter()'s type-rejection +/// branch -- every other builder here only produces IntValue. +inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeFloatKnobEngineConfig( + flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, double value) +{ + using namespace hipdnn_flatbuffers_sdk::data_objects; + + std::vector> knobSettings; + knobSettings.push_back(CreateKnobSettingDirect(builder, + knobName.c_str(), + KnobValue::FloatValue, + CreateFloatValue(builder, value).Union())); + auto knobsVector = builder.CreateVector(knobSettings); + builder.Finish(CreateEngineConfig(builder, ENGINE_ID.front(), knobsVector)); + + return hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper( + builder.GetBufferPointer(), builder.GetSize()); +} + +/// StringValue counterpart of makeFloatKnobEngineConfig(). +inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeStringKnobEngineConfig( + flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, const std::string& value) +{ + using namespace hipdnn_flatbuffers_sdk::data_objects; + + std::vector> knobSettings; + knobSettings.push_back( + CreateKnobSettingDirect(builder, + knobName.c_str(), + KnobValue::StringValue, + CreateStringValueDirect(builder, value.c_str()).Union())); + auto knobsVector = builder.CreateVector(knobSettings); + builder.Finish(CreateEngineConfig(builder, ENGINE_ID.front(), knobsVector)); + + return hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper( + builder.GetBufferPointer(), builder.GetSize()); +} + } // namespace hipdnn_plugin_sdk::ingestor::testing #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp index 2d55b0c6aa24..b3a8a264e1a2 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp @@ -16,8 +16,8 @@ /** * @file TestDescriptors.cpp * @brief Unit tests for Descriptors.hpp: id formatting and hashing, the unified - * MetadataValue/MetadataType pairing (including the vector alternative - * item 2 added), and the KernelSource tagged union item 3 introduced. + * MetadataValue/MetadataType pairing over vector, and the KernelSource + * tagged union. * * makeKernelHeuristic() is declared in IKernelHeuristic.hpp, not here, so its tests live * in TestKernelHeuristic.cpp; this file covers HeuristicDescriptor purely as data. @@ -93,7 +93,7 @@ INSTANTIATE_TEST_SUITE_P( [](const ::testing::TestParamInfo& info) { return info.param.name; }); // --------------------------------------------------------------------------- -// KernelSourceKind / KernelSource: the tagged union item 3 introduced. +// KernelSourceKind / KernelSource: the tagged union over RFC 0017 §7's source kinds. // --------------------------------------------------------------------------- TEST(TestIngestorDescriptors, KernelSourceDefaultsToEmbeddedSource) @@ -118,19 +118,6 @@ TEST(TestIngestorDescriptors, KernelSourceCarriesEmbeddedSourceFileAndEntryPoint EXPECT_EQ(source.entryPoint, "pointwise_add_kernel"); } -/// Every kind besides EMBEDDED_SOURCE exists as a named value with no adapter yet: a -/// kernel can state one, even though nothing loads it. Distinct kind values must stay -/// distinct so a descriptor stating one is never confused with another. -TEST(TestIngestorDescriptors, EveryKernelSourceKindIsDistinct) -{ - EXPECT_NE(KernelSourceKind::EMBEDDED_SOURCE, KernelSourceKind::KPACK_SYMBOL); - EXPECT_NE(KernelSourceKind::EMBEDDED_SOURCE, KernelSourceKind::HSACO_FILE); - EXPECT_NE(KernelSourceKind::EMBEDDED_SOURCE, KernelSourceKind::ROCKE_BUILDER); - EXPECT_NE(KernelSourceKind::KPACK_SYMBOL, KernelSourceKind::HSACO_FILE); - EXPECT_NE(KernelSourceKind::KPACK_SYMBOL, KernelSourceKind::ROCKE_BUILDER); - EXPECT_NE(KernelSourceKind::HSACO_FILE, KernelSourceKind::ROCKE_BUILDER); -} - // --------------------------------------------------------------------------- // HeuristicDescriptor: HeuristicKind as data. The adapter dispatch on it is // makeKernelHeuristic()'s behavior, tested in TestKernelHeuristic.cpp. @@ -143,16 +130,6 @@ TEST(TestIngestorDescriptors, HeuristicDescriptorDefaultsToNativeKind) EXPECT_EQ(descriptor.kind, HeuristicKind::NATIVE); } -TEST(TestIngestorDescriptors, HeuristicDescriptorCarriesItsPayloadIndependentOfKind) -{ - HeuristicDescriptor descriptor; - descriptor.kind = HeuristicKind::MODEL; - descriptor.payload = "model/artifact.bin"; - - EXPECT_EQ(descriptor.kind, HeuristicKind::MODEL); - EXPECT_EQ(descriptor.payload, "model/artifact.bin"); -} - } // namespace #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp index d28122993d58..993384a87f8e 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -32,6 +33,13 @@ using namespace hipdnn_plugin_sdk::ingestor::testing; using StubEngine = GenericEngine; +// GenericEngine holds a plan builder bound by reference to its own members (see the +// class doc); relocating it would dangle those references. +static_assert(!std::is_move_constructible_v); +static_assert(!std::is_move_assignable_v); +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); + // --------------------------------------------------------------------------- // Construction: knob validation against the metadata schema. // --------------------------------------------------------------------------- diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp index 9355fcec496a..43ac388103c7 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp @@ -191,6 +191,30 @@ TEST(TestIngestorGenericPlanBuilder, BuildPlanThrowsInternalErrorOnAnEmptyRanked } } +TEST(TestIngestorGenericPlanBuilder, GetMaxWorkspaceSizeThrowsInternalErrorOnAnEmptyCatalog) +{ + // Must agree with buildPlan()'s empty-catalog case above, not silently return 0. + const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + const TestGraph graph(makeGraphId(0x9A)); + + try + { + builder.getMaxWorkspaceSize(0, graph, KnobFilterSettings{}); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR); + EXPECT_EQ(ex.getMessage(), + "engine '" + engine.name + "' accepted this graph but has no applicable kernel"); + } +} + TEST(TestIngestorGenericPlanBuilder, GetCustomKnobsReportsMinMaxStepAndRankedDefault) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); @@ -291,6 +315,10 @@ TEST(TestIngestorGenericPlanBuilder, UnsatisfiableKnobValueThrowsInvalidValueNam EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); EXPECT_NE(ex.getMessage().find(BLOCK_SIZE), std::string::npos); EXPECT_NE(ex.getMessage().find("999"), std::string::npos); + // Survivor count is the caller's only way to tell "graph matched nothing" + // apart from "graph matched, knobs excluded everything". + EXPECT_NE(ex.getMessage().find("2 kernel(s) matched the graph before knob filtering"), + std::string::npos); } } @@ -371,6 +399,64 @@ TEST(TestIngestorGenericPlanBuilder, } } +// --------------------------------------------------------------------------- +// readKnobFilter(): rejecting a non-integer setting for an engine-exposed knob. +// --------------------------------------------------------------------------- + +TEST(TestIngestorGenericPlanBuilder, InitializeExecutionSettingsRejectsAFloatValuedKnobSetting) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeFloatKnobEngineConfig(fbb, BLOCK_SIZE, 64.0); + const TestGraph graph(makeGraphId(0x9B)); + KnobFilterSettings settings; + + try + { + builder.initializeExecutionSettings(0, graph, engineConfig, settings); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); + EXPECT_EQ(ex.getMessage(), + "engine '" + engine.name + "' knob '" + BLOCK_SIZE + + "' must be set to an integer value"); + } +} + +TEST(TestIngestorGenericPlanBuilder, InitializeExecutionSettingsRejectsAStringValuedKnobSetting) +{ + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); + const TestDeviceResolver resolver; + const TestPlanBuilder builder(engine, *manager, resolver); + + flatbuffers::FlatBufferBuilder fbb; + const auto engineConfig = makeStringKnobEngineConfig(fbb, BLOCK_SIZE, "fast"); + const TestGraph graph(makeGraphId(0x9C)); + KnobFilterSettings settings; + + try + { + builder.initializeExecutionSettings(0, graph, engineConfig, settings); + FAIL() << "expected HipdnnPluginException"; + } + catch(const hipdnn_plugin_sdk::HipdnnPluginException& ex) + { + EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); + EXPECT_EQ(ex.getMessage(), + "engine '" + engine.name + "' knob '" + BLOCK_SIZE + + "' must be set to an integer value"); + } +} + // --------------------------------------------------------------------------- // contextFor(): a mocked device resolver proves per-handle device resolution folds // into the MatchContext that reaches the matchers, using StubHandle/StubSettings/ diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp index 0ec9f75c7031..9635ed3706d7 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp @@ -25,10 +25,6 @@ * @brief Unit tests for KernelIngestorStateManager.hpp: matching, pruning, caching, * ranking-reuse, knob value discovery, metadata completion, and the eager * construction-time validation the constructor's doc calls out. - * - * The per-pack bound-state isolation regression (item 5) lives here too: - * PrunedPackBindingsAreNotVisibleToSurvivingPackKernels, unchanged from its Phase 1 - * introduction. */ namespace { @@ -36,6 +32,20 @@ namespace using namespace hipdnn_plugin_sdk::ingestor; using namespace hipdnn_plugin_sdk::ingestor::testing; +/// Binds "test.bound_token" to a value that disagrees with acceptGraph's. +inline bool bindConflictingTokenValue(const MatchContext& /*context*/, BoundTokens& bound) +{ + bound["test.bound_token"] = BOUND_TOKEN_VALUE + 1; + return true; +} + +/// Binds "test.bound_token" to acceptGraph's value, via a different matcher id. +inline bool bindAgreeingTokenValue(const MatchContext& /*context*/, BoundTokens& bound) +{ + bound["test.bound_token"] = BOUND_TOKEN_VALUE; + return true; +} + // --------------------------------------------------------------------------- // Matching and pruning // --------------------------------------------------------------------------- @@ -94,31 +104,52 @@ TEST(TestKernelIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNot { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - // Matchers are shared by id across packs, so the broadly shared checks are what prune - // the candidate set fast. Re-running one per pack that lists it would make the most - // shared check the most expensive, inverting the property the graph/kernel split - // exists to provide. - auto first = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); - auto second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); + // Matchers are shared by id across packs, so a shared graph-scoped check runs once, + // not once per pack. + // + // The first pack also fails a second, unshared matcher, so only the second pack + // survives. That isolates BINDING REUSE from memoization: if both packs survived, + // the first pack's own merge could put the token in catalog.bound, hiding a + // mutation that only merges a memo entry's bindings on the cache-MISS pack and + // skips it on every cache-HIT pack -- exactly the second pack's read here. + constexpr const char* UNSHARED_FAIL_SYMBOL = "test.cross_pack_binding_reuse.fail"; + GraphMatcherRegistry::registerSymbol(UNSHARED_FAIL_SYMBOL, rejectGraph); + const auto unsharedFailMatcherId = testId(0x84); + + const KernelDescriptorPack first = makePack({GRAPH_MATCHER_ID, unsharedFailMatcherId}); + KernelDescriptorPack second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); second.id = testId(0x80); // A distinct metadata tuple: the uniqueness rule is engine-wide, not per pack, so // the second pack cannot repeat a block size the first already uses. second.kernels = {makeKernel(testId(0x81), "second_pack_kernel", 512, "FLOAT")}; - const StateManager manager(makeSchema(), - makeTestMatchers(), - makeTestDispatches(), - {first, second}, - std::make_shared(SCORE_SYMBOL)); + const StateManager manager( + makeSchema(), + {{GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {unsharedFailMatcherId, "always fails", MatchScope::GRAPH, UNSHARED_FAIL_SYMBOL}, + {KERNEL_MATCHER_ID, "kernel scoped", MatchScope::KERNEL, "test.kernel"}}, + makeTestDispatches(), + {first, second}, + std::make_shared(SCORE_SYMBOL)); const TestGraph graph(makeGraphId(20)); const auto properties = testDeviceProperties(); - const auto definitions = manager.unsortedDefinitions(MatchContext{graph, 0, properties}); + const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); - // Two packs, one shared graph-scoped matcher: evaluated once. - EXPECT_EQ(counters().graphCalls, 1); - // Two FLOAT survivors from the first pack plus one from the second. - EXPECT_EQ(definitions.size(), 3U); + // Three native calls would mean the shared matcher ran twice; it must not. One call + // for GRAPH_MATCHER_ID (shared, evaluated once for both packs) plus one for the + // first pack's own unshared, failing matcher is the whole budget. + EXPECT_EQ(counters().graphCalls, 2); + // Only the second pack's kernel: the first pack's graph-scoped matchers did not all + // pass, so none of its kernels were ever considered. + ASSERT_EQ(catalog.entries.size(), 1U); + EXPECT_EQ(catalog.entries.front().packId, second.id); + // The point of this test: the shared matcher's binding reaches catalog.bound through + // the second pack's read of the memo, not through the (pruned) first pack that + // actually ran the matcher. + EXPECT_EQ(catalog.bound.count("test.bound_token"), 1U); + + GraphMatcherRegistry::unregisterSymbol(UNSHARED_FAIL_SYMBOL); } TEST(TestKernelIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackListingIt) @@ -218,6 +249,70 @@ TEST(TestKernelIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingP GraphMatcherRegistry::unregisterSymbol(ALWAYS_FAILS_SYMBOL); } +TEST(TestKernelIngestorStateManager, TwoPacksBindingOneTokenToDifferentValuesThrows) +{ + // Two packs writing different values under one token name is an authoring error + // (Catalog::bound's doc); a silent first-wins merge would hide it. + constexpr const char* CONFLICTING_SYMBOL = "test.bound_conflict.conflicting"; + GraphMatcherRegistry::registerSymbol(CONFLICTING_SYMBOL, &bindConflictingTokenValue); + + const auto conflictingMatcherId = testId(0xB0); + const KernelDescriptorPack first = makePack({GRAPH_MATCHER_ID}); + KernelDescriptorPack second = makePack({conflictingMatcherId}); + second.id = testId(0xB1); + second.kernels = {makeKernel(testId(0xB2), "second_pack_kernel", 512, "FLOAT")}; + + const StateManager manager(makeSchema(), + {{GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {conflictingMatcherId, + "binds a conflicting value", + MatchScope::GRAPH, + CONFLICTING_SYMBOL}}, + makeTestDispatches(), + {first, second}, + std::make_shared(SCORE_SYMBOL)); + + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const TestGraph graph(makeGraphId(51)); + const auto properties = testDeviceProperties(); + + EXPECT_THROW(manager.unsortedCatalog(MatchContext{graph, 0, properties}), std::runtime_error); + + GraphMatcherRegistry::unregisterSymbol(CONFLICTING_SYMBOL); +} + +TEST(TestKernelIngestorStateManager, TwoPacksBindingOneTokenToTheSameValueMergeCleanly) +{ + // Packs agreeing on a token's value (the normal shared-matcher case) must still + // merge cleanly. + constexpr const char* AGREEING_SYMBOL = "test.bound_conflict.agreeing"; + GraphMatcherRegistry::registerSymbol(AGREEING_SYMBOL, &bindAgreeingTokenValue); + + const auto agreeingMatcherId = testId(0xB3); + const KernelDescriptorPack first = makePack({GRAPH_MATCHER_ID}); + KernelDescriptorPack second = makePack({agreeingMatcherId}); + second.id = testId(0xB4); + second.kernels = {makeKernel(testId(0xB5), "second_pack_kernel", 512, "FLOAT")}; + + const StateManager manager( + makeSchema(), + {{GRAPH_MATCHER_ID, "graph scoped", MatchScope::GRAPH, "test.graph"}, + {agreeingMatcherId, "binds the same value", MatchScope::GRAPH, AGREEING_SYMBOL}}, + makeTestDispatches(), + {first, second}, + std::make_shared(SCORE_SYMBOL)); + + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const TestGraph graph(makeGraphId(52)); + const auto properties = testDeviceProperties(); + + const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); + + EXPECT_EQ(tryGetBoundInt(catalog.bound, "test.bound_token"), BOUND_TOKEN_VALUE); + // Agreement never prunes anything: all 4 kernels across both packs survive. + EXPECT_EQ(catalog.entries.size(), 4U); +} + TEST(TestKernelIngestorStateManager, MatchesSeparatelyPerDevice) { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); @@ -252,6 +347,25 @@ TEST(TestKernelIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity EXPECT_EQ(counters().graphCalls, 2); } +TEST(TestKernelIngestorStateManager, DistinctGraphsCarryingANilUuidDoNotShareACatalogEntry) +{ + // Two unrelated graphs sharing the nil id must not share a cache entry: a regression + // would run the matcher once, not twice, and serve the second graph the first's catalog. + const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); + const auto manager = makeStateManager(); + const TestGraph first(makeNilGraphId()); + const TestGraph second(makeNilGraphId()); + const auto properties = testDeviceProperties(); + + const auto firstDefinitions = manager->unsortedDefinitions(MatchContext{first, 0, properties}); + const auto secondDefinitions + = manager->unsortedDefinitions(MatchContext{second, 0, properties}); + + EXPECT_EQ(counters().graphCalls, 2); + EXPECT_EQ(firstDefinitions.size(), 2U); + EXPECT_EQ(secondDefinitions.size(), 2U); +} + // --------------------------------------------------------------------------- // Bound state alongside the catalog // --------------------------------------------------------------------------- @@ -548,6 +662,36 @@ INSTANTIATE_TEST_SUITE_P( std::vector{pack}, std::make_shared(SCORE_SYMBOL)); }}, + StateManagerConstructionThrowCase{ + "RejectsADuplicateMatchDescriptorId", + [] { + // Two distinct matchers under one id: silent first-wins would let a pack + // run whichever matcher loaded first. + std::vector matchers = makeTestMatchers(); + matchers.push_back({GRAPH_MATCHER_ID, + "a different matcher entirely", + MatchScope::KERNEL, + "test.kernel"}); + return std::make_unique( + makeSchema(), + matchers, + makeTestDispatches(), + std::vector{makePack({GRAPH_MATCHER_ID})}, + std::make_shared(SCORE_SYMBOL)); + }}, + StateManagerConstructionThrowCase{ + "RejectsADuplicateDispatchDescriptorId", + [] { + std::vector dispatches = makeTestDispatches(); + dispatches.push_back( + {DISPATCH_ID, "a different dispatch entirely", "test.dispatch.other"}); + return std::make_unique( + makeSchema(), + makeTestMatchers(), + dispatches, + std::vector{makePack({GRAPH_MATCHER_ID})}, + std::make_shared(SCORE_SYMBOL)); + }}, StateManagerConstructionThrowCase{"RejectsAMissingHeuristic", [] { return std::make_unique( diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp index 34f128d3d0c0..98b14276e0d8 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp @@ -49,31 +49,6 @@ TEST(TestIngestorLruCache, RejectsZeroCapacity) EXPECT_THROW(IntCache(0), std::invalid_argument); } -TEST(TestIngestorLruCache, ClearDropsEveryEntry) -{ - LruCache cache(4); - cache.put(1, "one"); - cache.put(2, "two"); - - cache.clear(); - - EXPECT_EQ(cache.size(), 0U); - EXPECT_FALSE(cache.get(1).has_value()); - EXPECT_FALSE(cache.get(2).has_value()); -} - -TEST(TestIngestorLruCache, ClearOnAnAlreadyEmptyCacheIsANoop) -{ - LruCache cache(4); - - cache.clear(); - - EXPECT_EQ(cache.size(), 0U); - // The cache must still be usable afterwards, not left in some half-cleared state. - cache.put(1, "one"); - EXPECT_TRUE(cache.get(1).has_value()); -} - // --------------------------------------------------------------------------- // Eviction order: which entry a capacity-crossing insert evicts depends on recency, // not on insertion order, and a get() counts as a touch just as a put() does. diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp index 350da164b8a5..6d58392ccb7e 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp @@ -109,6 +109,22 @@ TEST(TestIngestorMatchContext, TryGetGraphIdReturnsNulloptForAGraphWithNoIdentit EXPECT_EQ(tryGetGraphId(graph), std::nullopt); } +TEST(TestIngestorMatchContext, TryGetGraphIdReturnsNulloptForANilId) +{ + // A present-but-nil id must not read as a valid, cacheable key. + const TestGraph graph(makeNilGraphId()); + + EXPECT_EQ(tryGetGraphId(graph), std::nullopt); +} + +TEST(TestIngestorMatchContext, TryGetGraphIdReturnsNulloptForANonV4Id) +{ + // Any non-v4 id is as unable to promise per-graph uniqueness as nil. + const TestGraph graph(makeNonV4GraphId(0x55)); + + EXPECT_EQ(tryGetGraphId(graph), std::nullopt); +} + } // namespace #endif // HIPDNN_ENABLE_KERNEL_INGESTOR diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp index 5c215d266ee2..3b3cd6e11374 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp @@ -54,17 +54,6 @@ TEST(TestIngestorNativeRegistry, FailsClosedOnUnknownSymbol) EXPECT_THROW(GraphMatcherRegistry::resolve("registry.never_registered"), std::runtime_error); } -TEST(TestIngestorNativeRegistry, IsRegisteredReflectsCurrentRegistrationState) -{ - EXPECT_FALSE(GraphMatcherRegistry::isRegistered("registry.is_registered_probe")); - - GraphMatcherRegistry::registerSymbol("registry.is_registered_probe", acceptGraph); - EXPECT_TRUE(GraphMatcherRegistry::isRegistered("registry.is_registered_probe")); - - GraphMatcherRegistry::unregisterSymbol("registry.is_registered_probe"); - EXPECT_FALSE(GraphMatcherRegistry::isRegistered("registry.is_registered_probe")); -} - } // namespace #endif // HIPDNN_ENABLE_KERNEL_INGESTOR From 06323477bd90caa25fd3c9a2b664b0be7a370ffc Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 11 Aug 2026 23:26:22 -0400 Subject: [PATCH 29/60] docs(hipdnn): cut the argument out of the ingestor's comments (ALMIOPEN-2402) Comments only. No code changed, verified by diff: every added and removed line is a comment. The ingestor's production files were 39% comment, with one unbroken 36-line block, while the .cpp files sat at a healthy 10-20%. The excess was almost entirely in headers, and almost entirely one thing: prose defending decisions to a reviewer rather than describing the code to a reader. A header explaining at length why it is not the other design is answering a question the next author is not asking. Kept what a reader needs and an out-of-tree provider author cannot get elsewhere: a description on every public type, the contracts an implementer must honour (thread-safety, lifetime, what a prepared dispatch must not retain, what a TSettings must supply), non-obvious invariants, and a single RFC section reference where a design follows one. Cut rejected-alternative tours, history of how the code got this way, the same point restated in consecutive sentences, quoted RFC prose where a section number does the job, and narration of what the next line plainly says. Now 31%, longest run 23 lines, 412 comment lines lighter. Doc comments on public SDK types were the last thing touched and none was left bare. Verified unchanged: 202 SDK, 486 provider with a device, 464 device-less exiting 0, 13 integration; flag off, 91 SDK and 428 provider with no ingestor test present and no ingestor path installed. --- .../HandleDeviceResolver.hpp | 71 ++----- .../KernelIngestorEngine.cpp | 38 ++-- .../KernelIngestorEngine.hpp | 37 ++-- .../kernels/PointwiseAdd.cpp | 15 +- .../packs/PointwiseAddDispatchHandler.cpp | 6 +- .../packs/PointwiseAddDispatchHandler.hpp | 21 +-- .../packs/PointwiseAddMatchers.cpp | 25 +-- .../packs/PointwiseAddMatchers.hpp | 22 +-- .../packs/PointwiseAddPack.cpp | 31 ++-- .../packs/PointwiseAddPack.hpp | 47 ++--- .../packs/PointwiseAddSymbols.hpp | 32 +--- .../hipdnn_plugin_sdk/ingestor/Catalog.hpp | 26 +-- .../ingestor/Descriptors.hpp | 113 ++++-------- .../ingestor/GenericEngine.hpp | 49 ++--- .../ingestor/GenericPlan.hpp | 18 +- .../ingestor/GenericPlanBuilder.hpp | 158 ++++++---------- .../ingestor/IDeviceResolver.hpp | 18 +- .../ingestor/IKernelDispatchHandler.hpp | 67 ++----- .../ingestor/IKernelHeuristic.hpp | 66 ++----- .../ingestor/KernelDefinition.hpp | 11 +- .../ingestor/KernelIngestorStateManager.hpp | 174 ++++++------------ .../hipdnn_plugin_sdk/ingestor/LruCache.hpp | 13 +- .../ingestor/MatchContext.hpp | 83 +++------ .../ingestor/NativeRegistry.hpp | 70 +++---- 24 files changed, 407 insertions(+), 804 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp index 1bfaa7ed44ec..c879d906e7db 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp @@ -21,38 +21,18 @@ namespace hip_kernel_provider::kernel_ingestor_engine /** * @brief Resolves a call's device from the stream its handle carries. * - * A handle can be bound to any device and rebound between calls, so the device is read - * per call rather than captured once. Resolving it from the handle's stream — rather - * than from whichever device happens to be current on the calling thread — is what makes - * the answer correct when several threads drive different handles concurrently. + * A handle can be rebound between calls, so the device is read per call. Reading it from + * the handle's stream rather than the calling thread's current device is what keeps the + * answer right when several threads drive different handles. * - * Device properties are cached, because they are asked on the applicability path and - * never change for a given device. The cache hands out references that stay valid for - * this resolver's lifetime, so entries are never erased or rehashed away: node handles - * in std::unordered_map keep referenced values pinned across growth. + * Device properties are cached and the cache is never invalidated, so the references it + * hands out stay valid for this resolver's lifetime. Only successful queries are cached: + * a zeroed hipDeviceProp_t is not an answer, and this resolver is process-lifetime, so + * caching one failure would answer wrongly for every later caller. * - * Only successful queries are cached. A failed hipGetDeviceProperties throws rather - * than caching the zeroed struct it left behind: this cache is process-lifetime and is - * never invalidated, so one transient failure would otherwise pin unusable properties - * for every later caller asking about that device -- and those zeroed values do not - * fail loudly, they reach KernelCompileOptions as an empty --offload-arch and surface - * as an hiprtc error naming neither the device nor the property query. Before this - * class became process-lifetime a container cycle cleared such an entry; now nothing - * does, which is exactly why the failure has to be refused rather than remembered. - * The provider's own convention for this call is the same (see - * CurrentDevicePropertyProvider), as is the module cache's refusal to cache a failed - * load. - * - * Instantiated once, at process lifetime (see KernelIngestorEngine.cpp's deviceResolver()), - * rather than once per engine or once per Container. This class carries no engine state - * -- only device properties, which are a fact about the machine, not about which engine - * or container is asking -- so nothing about it needs to be reconstructed when a - * container is destroyed and rebuilt (SharedContainerManager's weak_ptr does exactly - * that across handle churn). A per-engine instance would duplicate the same cache once - * per engine and lose its warm entries every time a container cycled, for no isolation - * benefit: two engines resolving the same physical device must agree on that device's - * properties by construction, so a shared cache is the correct scope, not merely a - * convenient one. + * One instance per process (see KernelIngestorEngine.cpp's deviceResolver()). It holds no + * engine state, and two engines asking about one device must agree, so a shared cache is + * the correct scope rather than a convenient one. */ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver { @@ -72,21 +52,10 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver if(hipGetDevice(&deviceId) != hipSuccess) { - // Only reachable with no usable HIP context, where nothing can be matched - // or launched anyway. - // - // Not device 0, and not a throw. Not 0 because 0 is a valid ordinal other - // healthy calls resolve, so answering it would key this call's catalog and - // property cache to a real device nobody asked about -- and those entries - // outlive the container, so one failure would answer for every later - // caller. Not a throw because deviceId() is reached from isApplicable(), - // and EngineManager::getApplicableEngineIds() walks every engine with no - // try/catch: an exception here would deny a healthy sibling engine its - // answer rather than just declining for this one. - // - // NO_DEVICE is a key nothing matches and nothing caches against, so the - // engine simply finds no applicable kernel and declines, which is the - // correct outcome when there is no device to launch on. + // Neither 0 nor a throw: 0 is a real ordinal this call never asked about, + // and deviceId() runs under isApplicable(), which EngineManager walks with + // no try/catch -- throwing would deny sibling engines their answer. Matchers + // decline on NO_DEVICE, so the engine simply does not apply. return hipdnn_plugin_sdk::ingestor::NO_DEVICE; } return deviceId; @@ -95,12 +64,8 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver const hipDeviceProp_t& deviceProperties(hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const override { - // NO_DEVICE is not a device to query: MatchContext binds properties eagerly, - // before any matcher runs, so this is reached whenever deviceId() could not - // name a device. Answering with an inert zeroed struct lets matching proceed to - // the matchers, which decline on NO_DEVICE -- throwing here instead would deny - // every sibling engine its applicability answer (EngineManager walks them with - // no try/catch). Nothing reads these values, because no kernel survives. + // Reached whenever deviceId() could not name a device, because MatchContext binds + // properties before any matcher runs. Inert values, since no kernel survives. if(deviceId == hipdnn_plugin_sdk::ingestor::NO_DEVICE) { static const hipDeviceProp_t s_noDevice{}; @@ -119,9 +84,7 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver const auto status = queryDeviceProperties(&properties, deviceId); if(status != hipSuccess) { - // Not cached: see the class doc. A zeroed hipDeviceProp_t is not a usable - // answer, and this cache is never invalidated, so remembering one failure - // would answer wrongly for the life of the process. + // Never cached; see the class doc. throw hipdnn_plugin_sdk::HipdnnPluginException( HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, "hipGetDeviceProperties failed for device " + std::to_string(deviceId) + ": " diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index 0b461494cfce..241bcfb2b28b 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -26,14 +26,11 @@ namespace /** * @brief The dispatch handler this pack's UDD resolves to. * - * Process-lifetime, because the registry that holds it is. The registry stores a - * non-owning pointer, and a provider's container is created and destroyed along with its - * handles (see SharedContainerManager), so a handler owned by an engine or a container - * would be freed while the registration still pointed at it -- and the next container's - * engine would resolve that dangling pointer rather than its own handler. A function- - * local static outlives every container the process ever builds, which is the only - * lifetime that satisfies DispatchRegistry's non-owning-pointer contract without the - * registry taking ownership itself; NativeRegistry's own doc explains why it does not. + * Process-lifetime, because DispatchRegistry stores a non-owning pointer to it while a + * provider's Container -- and any engine or handler it owns -- is created and destroyed + * per handle (see SharedContainerManager). A function-local static is the only lifetime + * that satisfies that non-owning-pointer contract; see NativeRegistry's doc for why the + * registry itself does not take ownership. * * The compiler it holds is a static for the same reason, and matches the module cache * behind it, which is already process-wide. @@ -49,12 +46,8 @@ const PointwiseAddDispatchHandler& dispatchHandler() * @brief The device resolver every descriptor-backed engine in this provider shares. * * Container/process-lifetime, not per-engine: it is a device-property cache with no - * engine-specific state (see HandleDeviceResolver's own doc for why a device's - * properties are safe to share across every handle and every engine that asks about - * that device), so giving each engine its own instance would only duplicate the cache - * for no isolation benefit. A function-local static matches how Container and - * EnginePluginImpl already share state that outlives any one engine or container - * instance -- dispatchHandler() above is the same pattern for the same reason. + * engine-specific state (see HandleDeviceResolver's doc), so a per-engine instance + * would only duplicate the cache with no isolation benefit. */ const HandleDeviceResolver& deviceResolver() { @@ -64,9 +57,8 @@ const HandleDeviceResolver& deviceResolver() } // namespace -/// The body registerNativeIngestorSymbols() runs at most once per process. Broken out -/// so a test can drive it directly and observe rollback on a forced conflict, which the -/// once_flag wrapper below would otherwise make unreachable a second time. +/// The body registerNativeIngestorSymbols() runs at most once per process; broken out +/// so a test can drive it directly and observe rollback on a forced conflict. void registerNativeIngestorSymbolsOnce() { registerPointwiseAddMatchers(); @@ -83,9 +75,8 @@ void registerNativeIngestorSymbolsOnce() void registerNativeIngestorSymbols() { - // call_once leaves the flag unset on a throw, so a retry re-runs the body -- which - // must roll back what it installed, or the retry fails on ITS OWN partial state - // instead of the original conflict. + // call_once leaves the flag unset on a throw, so a retry re-runs the body, which + // must roll back what it installed or the retry fails on its own partial state. static std::once_flag s_registered; std::call_once(s_registered, registerNativeIngestorSymbolsOnce); } @@ -93,10 +84,9 @@ void registerNativeIngestorSymbols() std::unique_ptr> makePointwiseAddEngine() { auto set = buildPointwiseAddDescriptorSet(); - // Moves the UED out of `set` in its own statement, fully sequenced before the move - // of the (now engine-less) remainder below -- not two moves racing inside one call's - // argument list. makePointwiseAddStateManager() never reads set.engine, so its - // moved-from state here is inert. + // Moved out of `set` in its own statement, fully sequenced before the move of the + // (now engine-less) remainder below. makePointwiseAddStateManager() never reads + // set.engine, so its moved-from state here is inert. auto engine = std::move(set.engine); return std::make_unique>( std::move(engine), makePointwiseAddStateManager(std::move(set)), deviceResolver()); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp index 8df04ff79e04..ebac2966417d 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp @@ -20,41 +20,36 @@ namespace hip_kernel_provider::kernel_ingestor_engine * @brief Registers every native matcher, scorer, and dispatch handler this provider's * ingestor packs resolve through, exactly once for the life of the process. * - * Called eagerly from Container's constructor -- which runs inside the plugin's create - * entry point -- rather than lazily as a side effect of building today's one hardcoded - * engine. That laziness is the ALMIOPEN-2401 blocker this closes: a UED loaded from a + * Called eagerly from Container's constructor, inside the plugin's create entry point, + * rather than lazily as a side effect of constructing an engine: a UED loaded from a * descriptor file names a symbol like "hipkernel.pointwise_add.graph_match" with no - * compile-time link to the C++ translation unit that implements it, so nothing after a - * file-loaded UED can rely on registration happening as a byproduct of constructing the - * one engine that used to hardcode it. + * compile-time link to the translation unit that implements it, so nothing can rely on + * registration happening as a byproduct of building one hardcoded engine. * - * Idempotent across repeated calls: Container can be destroyed and rebuilt many times - * over a process's life (see SharedContainerManager's weak_ptr), but the process-wide - * NativeRegistry this populates must be populated only once or the second call throws - * on the duplicate. + * Idempotent across repeated calls: Container is destroyed and rebuilt many times over + * a process's life (see SharedContainerManager's weak_ptr), but the process-wide + * NativeRegistry this populates must be populated only once. */ void registerNativeIngestorSymbols(); /// @brief The once_flag-guarded body of registerNativeIngestorSymbols(), callable /// directly so a test can force and observe a partial-failure/rollback cycle a second -/// time -- something the flag makes unreachable through the wrapper itself. +/// time -- unreachable through the wrapper itself once the flag is set. void registerNativeIngestorSymbolsOnce(); /** * @brief Builds the descriptor-backed pointwise-add engine. * - * Assembles this pack's descriptor set once and hands its engine descriptor and state - * manager straight to GenericEngine, which already satisfies IEngine end to end -- there - * is no forwarding wrapper left to own. The device resolver and dispatch handler this - * engine's kernels need are process-lifetime statics (see KernelIngestorEngine.cpp), not - * members, so nothing about their lifetime constrains how this object is constructed. + * Assembles this pack's descriptor set and hands its engine descriptor and state + * manager to GenericEngine, which already satisfies IEngine end to end. The device + * resolver and dispatch handler this engine's kernels need are process-lifetime + * statics (see KernelIngestorEngine.cpp), not members. * - * Registration is not this function's job: registerNativeIngestorSymbols() must already - * have run, so that a file-loaded UED sharing this pack's symbols resolves the same way - * a hardcoded one does. + * Requires registerNativeIngestorSymbols() to already have run, so a file-loaded UED + * sharing this pack's symbols resolves the same way a hardcoded one does. * - * This function is what a UED loader replaces: given descriptor files, the same generic - * engine is constructed from parsed data instead of from a hardcoded pack. + * This function is what a UED loader replaces: given descriptor files, the same + * generic engine is constructed from parsed data instead of a hardcoded pack. */ std::unique_ptr> makePointwiseAddEngine(); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp index 96236ef498ea..b322509f8dad 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp @@ -1,15 +1,14 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -// The kernel behind the pointwise-add descriptor pack. Deliberately the -// narrowest useful operation: elementwise add over a single-element tensor. +// The kernel behind the pointwise-add descriptor pack: elementwise add over a +// single-element tensor. // -// HIP_PLUGIN_POINTWISE_ADD_TYPE is the element type, supplied per compilation from the -// kernel descriptor's dtype metadata. HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE is the block -// size the descriptor selected; the kernel does not read it, because one element needs -// one thread. It exists so the ingestor's ranking, knob reporting, and per-kernel -// dispatch have a metadata field that genuinely varies between kernels and genuinely -// reaches the compiler, rather than a field that only tests observe. +// HIP_PLUGIN_POINTWISE_ADD_TYPE is the element type, from the kernel descriptor's +// dtype metadata. HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE is the descriptor's block size; +// the kernel itself ignores it (one element needs one thread), but it must still +// reach the compiler so ranking, knob reporting, and per-kernel dispatch have a +// metadata field that genuinely varies and isn't just observed by tests. extern "C" __global__ void PointwiseAdd(const HIP_PLUGIN_POINTWISE_ADD_TYPE* a, const HIP_PLUGIN_POINTWISE_ADD_TYPE* b, diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp index f1bf77bc2dcb..0fa7a4df8ab4 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp @@ -74,8 +74,7 @@ std::string elementTypeFor(const KernelDefinition& kernel) return "_Float16"; } - // Unreachable via matching, which admits only dtypes this pack declares. Reported - // rather than defaulted: a silent fallback would compile the wrong kernel. + // Unreachable via matching, which admits only dtypes this pack declares. throw hipdnn_plugin_sdk::HipdnnPluginException( HIPDNN_PLUGIN_STATUS_BAD_PARAM, "kernel '" + toString(kernel.kernelId) + "' declares unsupported dtype '" + dtype + "'"); @@ -121,8 +120,7 @@ std::unique_ptr PointwiseAddDispatchHandler::prepare( options.add("HIP_PLUGIN_POINTWISE_ADD_TYPE", elementTypeFor(kernel)); options.add("HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE", blockSize); - // The only KernelSourceKind this dispatch handler knows how to load; see - // KernelSource's doc for the kinds a future adapter will add. + // The only KernelSourceKind this dispatch handler knows how to load. auto program = _kernelCompiler.compile(kernel.source.sourceFile, options); auto runnableKernel = program->getKernel(kernel.source.entryPoint); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp index 1318d4ab16b8..1cb42f64f310 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp @@ -21,15 +21,13 @@ namespace hip_kernel_provider::kernel_ingestor_engine /** * @brief The native dispatch behind this pack's UDD: sizes and launches a pointwise add. * - * Holds the provider machinery a launch needs — a kernel compiler and the device - * properties it compiles against — which is exactly why this interface exists: the SDK - * never names either of them. + * Holds the provider machinery a launch needs -- a kernel compiler and the device + * properties it compiles against -- which the SDK never names. * - * The work splits the way RFC 0017 §8.5 describes. Everything derived from the graph and - * the chosen kernel (which uids the arguments bind to, the launch geometry, the compiled - * kernel itself) resolves once at plan build; execute afterwards only resolves device - * pointers by uid and launches. A plan may execute concurrently from several threads, so - * nothing here mutates after preparation. + * Splits per RFC 0017 §8.5: everything derived from the graph and chosen kernel + * resolves once at plan build; execute only resolves device pointers by uid and + * launches. A plan may execute concurrently from several threads, so nothing here + * mutates after preparation. */ class PointwiseAddDispatchHandler : public hipdnn_plugin_sdk::ingestor::IKernelDispatchHandler @@ -48,10 +46,9 @@ class PointwiseAddDispatchHandler /** * @brief Scratch this kernel requires. * - * A one-element add needs none. The 256-block kernel nonetheless reports a non-zero - * requirement so the engine's "maximum across surviving kernels" is observably a - * maximum rather than a constant zero — the workspace path is otherwise - * indistinguishable from not being wired up at all. + * A one-element add needs none. The 256-block kernel reports a non-zero + * requirement so the engine's max-across-survivors is observably a maximum + * rather than a constant zero. */ size_t workspaceBytes(const hipdnn_plugin_sdk::ingestor::MatchContext& context, diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index 767925a3ffcf..6a93a7fe0739 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -34,22 +34,18 @@ const data_objects::TensorAttributes* findTensor(const MatchContext& context, in return it == tensors.end() ? nullptr : it->second; } -/// The tensor ranks this pack accepts. Not a property of the kernel, which indexes one -/// element and is indifferent to rank, but of the dispatch path: the provider's compile -/// options derive layout from the tensor and reject anything outside this range. -/// -/// Matching has to respect that. Accepting a graph commits the engine to producing a -/// launchable kernel for it, so a matcher that admits a rank dispatch cannot serve turns -/// a free decline into a failed plan build the caller pays for. +/// The tensor ranks this pack accepts. Not a kernel property (it indexes one element +/// regardless of rank) but a dispatch-path one: compile options derive layout from the +/// tensor and reject anything outside this range, so a matcher that admits a rank +/// dispatch cannot serve turns a free decline into a failed plan build. constexpr uint32_t MIN_SUPPORTED_RANK = 4; constexpr uint32_t MAX_SUPPORTED_RANK = 5; /// True when the tensor's stride order is one the dispatch path can classify. /// -/// The same constraint as the rank bound above, from the same function: compile options -/// derive a layout from the strides and throw on any order that is neither channel-first -/// nor channel-last. A one-element tensor can still carry an unclassifiable order when it -/// views into a larger buffer, so element count does not imply a usable layout. +/// Compile options derive a layout from the strides and throw on any order that is +/// neither channel-first nor channel-last; a one-element tensor viewing into a larger +/// buffer can still carry an unclassifiable order. bool hasSupportedLayout(const data_objects::TensorAttributes& tensor) { try @@ -60,8 +56,7 @@ bool hasSupportedLayout(const data_objects::TensorAttributes& tensor) catch(const hipdnn_plugin_sdk::HipdnnPluginException&) { // Asking the classifier directly keeps this gate and the dispatch path on one - // definition of "supported layout"; restating its rules here would let the two - // drift, which is exactly how the rank half of this constraint got missed. + // definition of "supported layout" rather than two that can drift. return false; } } @@ -89,8 +84,8 @@ bool isSingleElement(const data_objects::TensorAttributes& tensor) return hasSupportedLayout(tensor); } -/// The graph's element type, taken from the first input. The matcher below requires -/// every operand to agree, so any of them would answer the same. +/// The graph's element type, from the first input; the matcher below requires every +/// operand to agree, so any of them would answer the same. std::optional graphDataType(const MatchContext& context) { if(context.graph.nodeCount() != 1) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp index 6cf1d6feff68..58278005be06 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp @@ -15,8 +15,8 @@ namespace hip_kernel_provider::kernel_ingestor_engine * @brief Graph-scoped applicability: is this a single-node pointwise ADD over * 1-element tensors? * - * Reads only graph facts, so the ingestor evaluates it once per (graph, device) and one - * failure disqualifies every kernel in the pack without any per-kernel work. + * Reads only graph facts, so it is evaluated once per (graph, device); a failure + * disqualifies every kernel in the pack without per-kernel work. */ bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context, hipdnn_plugin_sdk::ingestor::BoundTokens& bound); @@ -25,12 +25,9 @@ bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& c * @brief Kernel-scoped applicability: does this kernel's dtype match the graph's? * * Reads kernel metadata, so it is evaluated once per candidate and disqualifies that - * candidate alone. - * - * This check is what a prebuilt-kernel system cannot omit: the graph-level gate above - * accepts any dtype the pack serves, so without pinning the kernel's baked dtype against - * the graph's, an f32 graph could be handed to an f16 binary — which does not fail - * outright, it silently returns wrong numbers. + * candidate alone. Without this check, the graph-level gate above would accept any + * dtype the pack serves and an f32 graph could reach an f16 binary, which returns + * wrong numbers rather than failing. */ bool pointwiseAddKernelMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context, const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel); @@ -50,10 +47,8 @@ struct PointwiseAddBinding /** * @brief Re-reads the operand bindings a match established. * - * The matcher proves the graph is a pointwise add and, in doing so, identifies which - * tensor is which. Dispatch needs those same uids to bind its arguments, so it asks for - * them here rather than re-deriving them from the graph with a second, possibly - * divergent, notion of what the graph shape is. + * The matcher proves the graph is a pointwise add and identifies which tensor is + * which; dispatch reads those same uids here rather than re-deriving them. * * @throws HipdnnPluginException if the graph is not one this matcher accepts. */ @@ -61,8 +56,7 @@ PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::Bound /// @brief Registers this pack's matchers and scorer under their symbol names. /// -/// Called once, from the pack's translation unit, rather than at static-init time: an -/// engine that is never constructed should not have mutated process-wide registries. +/// Called once, from the pack's translation unit, rather than at static-init time. /// Atomic: if one symbol is already taken, whichever of the others this call already /// installed is unregistered before it rethrows. void registerPointwiseAddMatchers(); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp index a9a183b1850a..7c1b74829e64 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp @@ -23,13 +23,10 @@ using hipdnn_flatbuffers_sdk::utilities::parseUuid; namespace { -// Descriptor ids. Real descriptors carry GUIDs so any author can mint one locally -// without colliding with another's; readable names serve the same role for a pack that -// is built in memory and never shared. // Descriptor ids are stable GUIDs, minted once for this pack and never regenerated: -// they are how other descriptors name these, so changing one silently breaks a -// cross-reference. Authored descriptors will carry the same values as text and reach -// this form through parseUuid(). +// other descriptors name these by id, so changing one silently breaks a cross-reference. +// Authored descriptors will carry the same values as text and reach this form through +// parseUuid(). const DescriptorId ENGINE_ID = parseUuid("8f1a6c30-7d24-4a0e-9b51-2c6d84f0a911"); const DescriptorId SCHEMA_ID = parseUuid("b27e4d15-3f89-4c62-8a07-51de93b4c7a2"); const DescriptorId HEURISTIC_ID = parseUuid("4c9b0e72-16a5-4d38-b6f4-8e20a7c15d63"); @@ -47,8 +44,7 @@ KernelDescriptor makeKernel(const DescriptorId& id, KernelDescriptor kernel; kernel.id = id; kernel.name = "pointwise_add." + variant; - // The only KernelSourceKind this POC implements; see KernelSource's doc for what - // the other kinds converge on once a loader ships them. + // The only KernelSourceKind this POC implements; see KernelSource's doc for the others. kernel.source.sourceFile = "PointwiseAdd.cpp"; kernel.source.entryPoint = "PointwiseAdd"; kernel.metadata = {{std::string(BLOCK_SIZE_FIELD), MetadataValue{blockSize}}, @@ -65,8 +61,8 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() set.schema.id = SCHEMA_ID; set.schema.name = "pointwise add variant fields"; - // block_size defaults, so a kernel may omit it. dtype does not: every kernel bakes - // one, and a kernel that failed to state it would silently inherit another's. + // block_size defaults, so a kernel may omit it; dtype does not, since a kernel that + // omits it would silently inherit another's. set.schema.fields = {{std::string(BLOCK_SIZE_FIELD), MetadataType::INT, int64_t{64}}, {std::string(DTYPE_FIELD), MetadataType::STRING, std::nullopt}}; @@ -78,12 +74,11 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() set.engine.name = ENGINE_NAME; set.engine.heuristicId = HEURISTIC_ID; set.engine.metadataSchemaId = SCHEMA_ID; - // block_size is exposed to the caller; dtype is not, because it is pinned by the - // graph rather than chosen. Exposing it would offer a choice nothing can serve. + // block_size is exposed to the caller; dtype is not, since it is pinned by the + // graph rather than chosen. set.engine.knobs = {std::string(BLOCK_SIZE_FIELD)}; - // True of this engine today: the dispatch handler compiles its kernel through hiprtc - // when the plan is built. A pack whose kernels ship prebuilt in a kpack would not - // declare this, so it is a property of the pack rather than of the ingestor. + // True of this engine today: its dispatch handler compiles through hiprtc at plan + // build. A pack shipping prebuilt kernels would not declare this. set.engine.behaviorNotes = {HIPDNN_BEHAVIOR_NOTE_RUNTIME_COMPILATION}; set.matchers = { @@ -107,9 +102,9 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; pack.engineId = ENGINE_ID; pack.dispatchId = DISPATCH_ID; - // Three kernels, each earning its place: the two FLOAT entries differ only in block - // size, so ranking has an order to produce and the block_size knob has a real value - // set; the HALF entry is what the kernel-scoped matcher prunes on a FLOAT graph. + // Three kernels: the two FLOAT entries differ only in block size, giving ranking an + // order and the block_size knob a real value set; the HALF entry is what the + // kernel-scoped matcher prunes on a FLOAT graph. pack.kernels = {makeKernel( parseUuid("2f8c17d6-4a90-4b53-9e18-c05a63b782d4"), "f32_block64", 64, "FLOAT", 0), diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp index f356b5669652..77c54f5f022c 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp @@ -25,15 +25,9 @@ namespace hip_kernel_provider::kernel_ingestor_engine * its metadata schema (KMD) and heuristic (UHD), two matchers (UMDs), one dispatch * descriptor (UDD), and one pack (KDP) binding them over three kernels (UKDs). * - * Nothing here is loaded, parsed, or validated against a schema — that is ALMIOPEN-2401 - * and the packaging follow-ups. What this demonstrates is that once descriptors exist in - * memory, everything downstream of them works: matching, pruning, ranking, knob - * reporting, workspace sizing, and launch are all driven by this data. - * - * The three kernels are chosen so each of those steps has something real to do: - * two differ only in block size, so ranking has an order to produce and the knob has a - * value set of two; the third targets a different dtype, so the kernel-scoped matcher - * has something to prune. + * Three kernels: two differ only in block size, giving ranking an order to produce and + * the knob a value set of two; the third targets a different dtype, giving the + * kernel-scoped matcher something to prune. */ /// A pack plus every descriptor it references by id, ready to construct a state manager. @@ -53,32 +47,23 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); /** * @brief This engine's hipDNN engine id, registering its name on first call. * - * A descriptor-backed engine's id is its UED name hashed into hipDNN's engine-id space, - * the same derivation a hand-written engine's registered name goes through. Because the - * engine is defined by data rather than by a compile-time registration macro, the name - * is registered at run time. + * A descriptor-backed engine's id is its UED name hashed into hipDNN's engine-id + * space, registered at run time since the engine is defined by data. * - * Registration is deliberately reached through a function-local static rather than a - * namespace-scope object. The registrar throws on a name or hash collision, and a throw - * from a namespace-scope initializer would escape a global constructor during dlopen() - * and terminate the process. Reached from here — called by the engine table, which runs - * inside the plugin's create entry point — the same collision surfaces as a failed - * plugin creation the host can report. + * Reached through a function-local static, not a namespace-scope object: the + * registrar throws on a collision, and a throw from a namespace-scope initializer + * would escape a global constructor during dlopen() and terminate the process. */ int64_t pointwiseAddEngineId(); -/// @brief Builds the state manager backing this pack, from an already-built descriptor -/// set. -/// -/// Takes @p set by value rather than building its own, so a caller assembling both the -/// engine and its state manager calls buildPointwiseAddDescriptorSet() exactly once: -/// the two used to each build a separate copy, which is wasted work with no correctness -/// benefit since both copies describe the same pack. -/// -/// Carries only what selection needs: the KMD, the matchers and dispatch descriptors the -/// packs reference, the packs themselves, and the resolved heuristic. @p set's UED is -/// ignored here, because a UED is 1:1 with a hipDNN engine and is owned by the engine, -/// not by this state manager. +/** + * @brief Builds the state manager backing this pack, from an already-built + * descriptor set. + * + * Takes @p set by value so a caller building both the engine and its state manager + * calls buildPointwiseAddDescriptorSet() exactly once. @p set's UED is ignored: a + * UED is 1:1 with a hipDNN engine and owned by the engine, not this state manager. + */ std::unique_ptr> makePointwiseAddStateManager(PointwiseAddDescriptorSet set); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp index afad1691f1b8..9b200899e1fb 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp @@ -14,14 +14,9 @@ namespace hip_kernel_provider::kernel_ingestor_engine * @file NativeSymbolNames.hpp * @brief The symbol names this pack's descriptors resolve through. * - * A descriptor names a native implementation by symbol, and the provider registers that - * implementation under the same name. These constants are that contract, written once so - * the descriptor side and the implementation side cannot drift apart. - * - * Each of these is a place a declarative descriptor field goes once the follow-up RFCs - * land: the matcher symbols become criteria expressions, the score symbol becomes a - * model artifact and its feature signature, and the dispatch symbol becomes grid, block, - * workspace, and argument-signature formulas. + * A descriptor names a native implementation by symbol; the provider registers the + * implementation under the same name. Each constant here is a placeholder for a + * declarative descriptor field once the follow-up RFCs land. */ /// Graph-scoped applicability: a single-node pointwise ADD over 1-element tensors. @@ -39,13 +34,9 @@ inline constexpr std::string_view DISPATCH_SYMBOL = "hipkernel.pointwise_add.dis /** * @brief The engine name, hashed into hipDNN's engine-id space. * - * Scoped with a prefix, as RFC 0017 requires of a descriptor-backed engine: names must - * be globally unique because they hash into a shared id space, and a scope is what makes - * that tractable without a central allocation authority. - * - * Deliberately absent from EngineNames.hpp's registry. A descriptor-backed engine is - * defined by data, so it registers its name when it is constructed rather than at build - * time — which is the behavior this pack exists to demonstrate. + * Scoped with a prefix per RFC 0017's global-uniqueness requirement. Absent from + * EngineNames.hpp's registry: a descriptor-backed engine registers its name at + * construction, not at build time. */ inline constexpr std::string_view ENGINE_NAME = "hipkernel:PointwiseAdd"; @@ -54,15 +45,10 @@ inline constexpr std::string_view BLOCK_SIZE_FIELD = "block_size"; inline constexpr std::string_view DTYPE_FIELD = "dtype"; /** - * @brief The tokens this pack's graph matcher binds for its dispatch handler to read. - * - * Written once here for the same reason the symbol names are: the matcher writes these - * and the dispatch handler reads them, and a typo on either side would otherwise be a - * runtime lookup failure rather than a compile error. + * @brief Tokens the graph matcher binds for the dispatch handler to read. * - * These are the native stand-in for the `$`-prefixed names a descriptor's dispatch - * formulas would reference (RFC 0017 §5). Once the expression language lands, a UMD - * declares these names in its patterns and a UDD reads them by the same names. + * The native stand-in for the `$`-prefixed names a descriptor's dispatch formulas + * would reference (RFC 0017 §5). */ inline constexpr std::string_view INPUT_A_TOKEN = "pointwise_add.input_a.uid"; inline constexpr std::string_view INPUT_B_TOKEN = "pointwise_add.input_b.uid"; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp index 540617613740..d1be3e9f6f25 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp @@ -19,30 +19,22 @@ namespace hipdnn_plugin_sdk::ingestor * Built once per (graph, device) by running the matchers, then cached. An engine is * applicable exactly when its catalog is non-empty. * - * `isSorted` is what makes ranking lazy: applicability only needs to know whether any - * kernel survived, so the heuristic is not loaded or run until something actually asks - * for an order — a knob query or a plan build. Once sorted, `entries` is in - * heuristic-ranked order and stays that way for the life of the cache entry. + * `isSorted` makes ranking lazy: applicability only needs to know whether any kernel + * survived, so the heuristic does not run until something asks for an order. Once + * sorted, `entries` stays in heuristic-ranked order for the life of the cache entry. */ struct Catalog { std::vector entries; bool isSorted = false; /// What this engine's graph-scoped matchers resolved about the graph, merged across - /// the packs that survived. Cached with the entries because RFC 0017 §8.1 keeps the - /// bound token state alongside the catalog, so a plan build reads these rather than - /// re-running a matcher to recover them. + /// the packs that survived (RFC 0017 §8.1 keeps bound state alongside the catalog). /// - /// "The packs that survived" is enforced by KernelIngestorStateManager::buildCatalog(), - /// which accumulates each pack's bindings in a pack-scoped view and merges it into - /// this map only once that pack's own graph-scoped matchers all pass; a pruned - /// pack's view is discarded unmerged. A shared matcher's bindings still reach every - /// surviving pack that lists it, because the memo each pack merges from is keyed by - /// matcher id, not by pack. - /// - /// Merged rather than kept per pack: a token name means the same thing to every pack - /// in an engine. Two packs binding one name to DIFFERENT values is an authoring - /// error, rejected at merge time since only a runtime match reveals it. + /// Merged only once a pack's own graph-scoped matchers all pass; a pruned pack's + /// bindings are discarded unmerged (KernelIngestorStateManager::buildCatalog()). + /// Merged rather than kept per pack because a token name means the same thing to + /// every pack in an engine — two packs binding one name to different values is an + /// authoring error, rejected at merge time. BoundTokens bound; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 2fcafd558075..7dffe4dbe098 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -18,8 +18,7 @@ * @file Descriptors.hpp * @brief The universal descriptor set from RFC 0017, as parsed in-memory data. * - * Each descriptor kind maps 1:1 onto a concept hipDNN already has, expressed as data - * rather than hand-written C++: + * Each descriptor kind maps 1:1 onto a concept hipDNN already has: * * | Descriptor | Describes | * |------------|--------------------------------------------------------------| @@ -34,11 +33,9 @@ * Descriptors reference each other by `id` only, never by pointer or index, so a pack * authored independently can name a matcher or an engine it does not own. * - * These types are the *parsed* form. Nothing here parses, loads, or validates a file: - * an ingestor built on this header is handed already-constructed descriptors. The - * loader that produces them from disk is ALMIOPEN-2401 and its follow-ups; the - * expression language that replaces the native symbol fields below is the UMD/UDD - * follow-up RFC. + * These are the *parsed* form; nothing here parses, loads, or validates a file. The + * loader that produces them from disk is ALMIOPEN-2401; the expression language that + * replaces the native symbol fields below is the UMD/UDD follow-up RFC. */ namespace hipdnn_plugin_sdk::ingestor { @@ -46,11 +43,10 @@ namespace hipdnn_plugin_sdk::ingestor /// A descriptor cross-reference: the stable, globally unique id every descriptor /// carries, and the only way descriptors name one another. /// -/// A 128-bit UUID rather than a string, matching the graph identity hipDNN mints at -/// finalization, so the two id spaces in this system are one type. RFC 0017 §4 chooses -/// GUIDs so any author can mint an id locally that never collides with another's, with -/// no central allocation authority; `parseUuid` and `formatUuid` in the same header -/// convert to and from the text form descriptor files carry. +/// A 128-bit UUID, matching the graph identity hipDNN mints at finalization, so the +/// two id spaces in this system are one type (RFC 0017 §4). `parseUuid` and +/// `formatUuid` in the same header convert to and from the text form descriptor files +/// carry. using DescriptorId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; /// @brief A descriptor id in its canonical text form, for diagnostics. @@ -59,9 +55,8 @@ inline std::string toString(const DescriptorId& id) return hipdnn_flatbuffers_sdk::utilities::formatUuid(id); } -/// Hash for keying maps on a descriptor id. std::array has no std::hash, and a -/// specialization for it cannot be added without opening namespace std over a standard -/// type, so this is passed explicitly to the containers that need it. +/// Hash for keying maps on a descriptor id. std::array has no std::hash, so this is +/// passed explicitly to the containers that need it. struct DescriptorIdHash { size_t operator()(const DescriptorId& id) const noexcept @@ -79,22 +74,18 @@ struct DescriptorIdHash }; /// A value a kernel supplies for a KMD-declared field, and also the value type -/// `$graph.*` binds (see MatchContext.hpp's BoundTokens): RFC 0017's criteria language -/// puts one on each side of a single operator (`divisible($q.head_size, -/// $kernel.tile_m)`), so an interpreter needs one value type spanning both namespaces -/// or its first act is a refactor. Splitting them was cheap only as long as nothing -/// compared across the split. +/// `$graph.*` binds (see MatchContext.hpp's BoundTokens): both sides of a criteria +/// comparison need one shared value type. /// -/// Spans the primitive types a descriptor field or a bound graph fact can hold. /// `int64_t` and `double` are the widest signed integer and floating-point forms, so a /// narrower authored value converts into one without loss; `bool` is distinct because a /// flag compares and prints differently from the integer 1. `std::vector` is -/// for list-valued facts like `stride_order` that no scalar alternative can hold. +/// for list-valued facts like `stride_order`. using MetadataValue = std::variant>; -/// The type a KMD field holds, or a bound graph fact's type. Kept as an explicit enum, -/// and ordered to match MetadataValue's alternatives, so a field can declare its type -/// without also having to supply a value of it. +/// The type a KMD field holds, or a bound graph fact's type. Ordered to match +/// MetadataValue's alternatives, so a field can declare its type without also having to +/// supply a value of it. enum class MetadataType { BOOL, @@ -111,7 +102,7 @@ inline MetadataType metadataTypeOf(const MetadataValue& value) } /// A kernel's complete metadata tuple: every KMD field name mapped to this kernel's -/// value for it. RFC 0017 §4 makes this tuple the kernel's identity to the catalog, so +/// value for it. This tuple is the kernel's identity to the catalog (RFC 0017 §4), so /// it must be unique across every kernel in an engine. Ordered, so two kernels with the /// same fields compare and hash identically regardless of insertion order. using MetadataValues = std::map; @@ -120,17 +111,11 @@ using MetadataValues = std::map; struct MetadataField { std::string name; - /// What this field holds. Declared separately from the default because a field need - /// not have one, and a kernel supplying the wrong alternative is a load error the - /// validating loader reports against this. + /// What this field holds. MetadataType type = MetadataType::INT; - /// The value a kernel that omits this field is taken to have supplied. - /// - /// Optional, because a field can be mandatory. RFC 0017 §4's KMD example carries - /// `optional` and `default` as separate attributes on the field; those collapse here, - /// since a field that is mandatory has no default to fall back on. A kernel omitting - /// such a field is a load error rather than a silent fallback to a catalog key its - /// author never wrote. + /// The value a kernel that omits this field is taken to have supplied. Optional, + /// because a field can be mandatory; a kernel omitting a mandatory field is a load + /// error rather than a silent fallback. std::optional defaultValue; }; @@ -146,11 +131,8 @@ struct MetadataSchema std::vector fields; }; -/// Which adapter builds an engine's IKernelHeuristic from a UHD's `payload`. The kind -/// is the adapter dispatch point (RFC 0017 §9.1), the same role KernelSourceKind plays -/// for a UKD below: a UHD is reachable as data now instead of being the one descriptor -/// that is constructed and then stepped around in favor of a hand-built native -/// heuristic. +/// Which adapter builds an engine's IKernelHeuristic from a UHD's `payload` (RFC 0017 +/// §9.1's adapter dispatch point). enum class HeuristicKind { /// `payload` is a NativeRegistry score symbol, resolved into a NativeKernelHeuristic @@ -193,26 +175,21 @@ struct EngineDescriptor /// field matches is a load error. std::vector knobs; /// Advisory execution behavior this engine's kernels exhibit, as - /// `hipdnnBackendBehaviorNote_t` values (BehaviorNote.h). Reported to the caller - /// through EngineDetails like any other engine's, so a descriptor-backed engine is - /// not silently noteless: the transport already carries these and every hand-written - /// engine populates them. + /// `hipdnnBackendBehaviorNote_t` values (BehaviorNote.h), reported through + /// EngineDetails like any other engine's. /// - /// Held as int32 rather than a typed enum because the transport is deliberately - /// int32, so a newer descriptor can carry a note value an older backend does not - /// know without truncating it. + /// Held as int32 rather than a typed enum because the transport is int32, so a + /// newer descriptor can carry a note value an older backend does not know without + /// truncating it. std::vector behaviorNotes; }; /// Which inputs a matcher reads, which decides how often it runs and what its failure -/// prunes (RFC 0017 §5, "applicability is a cheap, shared-matcher pass"). +/// prunes (RFC 0017 §5). /// -/// Authored per matcher here, which is provisional. RFC 0017 §5 puts the split inside a -/// single matcher's criteria tree rather than across matchers: `conv.tile_fit` mixes -/// graph-bound dims and `$kernel.*` in one `and`, and the `$kernel.*` clauses are the -/// ones re-evaluated per candidate. Once the criteria language lands this becomes a -/// derived property, computed from whether a matcher's expression references `$kernel.*`, -/// and a matcher with mixed clauses is evaluated per clause rather than classified whole. +/// Authored per matcher here, which is provisional: once the criteria language lands +/// this becomes a derived property, computed from whether a matcher's expression +/// references `$kernel.*`. enum class MatchScope { /// Reads only graph and device facts, so it runs once per (graph, device) and its @@ -248,18 +225,14 @@ struct DispatchDescriptor std::string dispatchSymbol; }; -/// Which adapter loads and prepares one kernel's code, given its source's payload. The -/// adapter dispatch point RFC 0017 §9.1 describes: a kpack-symbol lookup, an hsaco file -/// load, and a rocke-builder invocation are structurally different operations, not -/// three interpretations shoehorned into the same two strings. +/// Which adapter loads and prepares one kernel's code, given its source's payload (RFC +/// 0017 §9.1's adapter dispatch point). enum class KernelSourceKind { - /// A named source file plus an entry point, compiled at plan-build time. What the - /// embedded-source path this engine's dispatch handler currently compiles through - /// needs, and the only kind this POC implements. + /// A named source file plus an entry point, compiled at plan-build time. The only + /// kind this POC implements. EMBEDDED_SOURCE, - /// A prebuilt kpack library plus the symbol to resolve inside it. Every shipped AOT - /// kernel converges on this kind at install time. No adapter yet. + /// A prebuilt kpack library plus the symbol to resolve inside it. No adapter yet. KPACK_SYMBOL, /// A standalone `.hsaco` code-object file. No adapter yet. HSACO_FILE, @@ -268,20 +241,14 @@ enum class KernelSourceKind }; /// UKD's source: where a kernel's code comes from, as a tagged union over RFC 0017 -/// §7's source kinds rather than two bare strings. Shipped as two strings, the first -/// loader to need a second kind would have had to invent its own discriminator and then -/// migrate every existing descriptor to use it; the discriminator exists from the start -/// instead. -/// -/// Only `EMBEDDED_SOURCE` is implemented; the others exist as enum values with a -/// comment because a descriptor states which kind it carries independently of whether +/// §7's source kinds. Only `EMBEDDED_SOURCE` is implemented; the others exist as enum +/// values, since a descriptor states which kind it carries independently of whether /// this provider can load that kind yet. struct KernelSource { KernelSourceKind kind = KernelSourceKind::EMBEDDED_SOURCE; /// `EMBEDDED_SOURCE`: the source file name. Unused, and left empty, by every other - /// kind until each grows its own payload shape (a kpack library plus symbol, an - /// hsaco path, a builder name plus build values). + /// kind until each grows its own payload shape. std::string sourceFile; /// `EMBEDDED_SOURCE`: the entry point within `sourceFile`. Unused by every other /// kind, for the same reason as `sourceFile` above. diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index e83509844fba..3cb4a1caa854 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -29,18 +29,14 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief One hipDNN engine, defined entirely by a UED and the packs naming it. * - * Satisfies hipDNN's existing IEngine contract using descriptor data. No new engine or - * plugin-ABI interface is introduced: from the host's side this is an ordinary engine, - * and everything data-driven about it lives behind that contract. + * Satisfies hipDNN's existing IEngine contract using descriptor data; from the host's + * side this is an ordinary engine. * - * The engine's id is its UED name hashed into hipDNN's engine-id space, the same - * derivation a hand-written engine's registered name goes through. Because a - * descriptor-backed engine is defined by data rather than by a compile-time - * registration, its name is registered when the engine is constructed rather than at - * build time — which is why an id collision surfaces here, as a failure to create the - * engine, rather than as a link-time or startup error. + * The engine's id is its UED name hashed into hipDNN's engine-id space, registered when + * the engine is constructed rather than at build time, so an id collision surfaces here + * as a failure to create the engine. * - * Holds exactly one plan builder. A catalog entry is a candidate, not a builder, so an + * Holds exactly one plan builder: a catalog entry is a candidate, not a builder, so an * engine with 150 kernels still has one. */ template @@ -51,20 +47,16 @@ class GenericEngine : public IEngine using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; /** - * @param engine The UED this engine is. A UED is 1:1 with a hipDNN engine, so - * the engine owns it: its name is this engine's identity, and its knob list is - * what this engine exposes to a caller. + * @param engine The UED this engine is; 1:1 with a hipDNN engine, so its + * name is this engine's identity and its knob list is what it exposes. * @param stateManager The descriptor state this engine selects over, already * validated. - * @param deviceResolver Answers which device each call is for, from the handle that - * carries it. Held by reference; owned by the provider, which must keep it - * alive for the engine's lifetime. + * @param deviceResolver Answers which device each call is for. Held by reference; + * owned by the provider, which must keep it alive for the engine's lifetime. * * @throws std::invalid_argument if a knob names no field in the engine's metadata - * schema. RFC 0017 §4 makes that a load error: a knob is only a name, with - * the field supplying its type, its default and its legal values, so a knob - * matching no field can never be reported or honoured. Caught here because - * this is the one place holding both the UED and its KMD. + * schema (RFC 0017 §4): a knob is only a name, with the field supplying its + * type, default and legal values. */ GenericEngine(EngineDescriptor engine, std::unique_ptr> stateManager, @@ -89,15 +81,13 @@ class GenericEngine : public IEngine } } - /// Not relocatable: _planBuilder holds references into _engine and *_stateManager, - /// so a move or copy would leave it referring into the source object. + /// Not relocatable: _planBuilder holds references into _engine and *_stateManager. GenericEngine(const GenericEngine&) = delete; GenericEngine& operator=(const GenericEngine&) = delete; GenericEngine(GenericEngine&&) = delete; GenericEngine& operator=(GenericEngine&&) = delete; - /// The descriptor this engine was built from, for diagnostics and for a caller that - /// needs the engine's declared knobs or notes. + /// The descriptor this engine was built from, for diagnostics. const EngineDescriptor& descriptor() const { return _engine; @@ -117,8 +107,7 @@ class GenericEngine : public IEngine * @brief Reports this engine's knobs for @p opGraph. * * The returned buffer is detached and handed to the caller's handle to own, matching - * how every other engine in this provider answers the query: the host reads the - * buffer through the returned pointer and releases it by handle later. + * how every other engine in this provider answers the query. */ void getDetails(THandle& handle, const IGraph& opGraph, @@ -133,9 +122,7 @@ class GenericEngine : public IEngine } auto knobs = builder.CreateVector(knobOffsets); - // The UED's notes reach the caller the same way every other engine's do. Passing - // nothing here would report a descriptor-backed engine as having no behavior - // notes at all, which is a claim rather than an omission. + // The UED's notes reach the caller the same way every other engine's do. auto behaviorNotes = builder.CreateVector(_engine.behaviorNotes); auto engineDetails = hipdnn_flatbuffers_sdk::data_objects::CreateEngineDetails( builder, _id, knobs, behaviorNotes); @@ -170,8 +157,8 @@ class GenericEngine : public IEngine private: EngineDescriptor _engine; /// Owned outright: see the constructor for why this must not be shared. Held by - /// pointer rather than by value so the plan builder can bind a reference to it in the - /// member initializer list, which needs a stable address. + /// pointer so the plan builder can bind a reference to it in the member + /// initializer list, which needs a stable address. std::unique_ptr> _stateManager; int64_t _id; GenericPlanBuilder _planBuilder; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp index 5958d2fba058..b754cf5f3e84 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -23,17 +23,11 @@ namespace hipdnn_plugin_sdk::ingestor * @brief An executable plan for one descriptor-selected kernel. * * Holds the kernel the heuristic chose, the handler that launches it, and the launch - * detail that handler resolved from the graph at build time. Every decision — which - * kernels applied, which ranked first, how much scratch it needs, what its arguments are - * — was made before this object existed, so execute() resolves nothing and re-matches - * nothing. + * detail that handler resolved from the graph at build time: execute() resolves and + * re-matches nothing. * * Immutable after construction, as IPlan requires: the same plan may execute * concurrently from several threads with different device buffers. - * - * The engine has one plan builder producing this one plan type, not one per kernel: a - * catalog entry is a candidate, and building a plan is what turns the chosen candidate - * into something launchable. */ template class GenericPlan : public IPlan @@ -45,9 +39,7 @@ class GenericPlan : public IPlan * prepare the launch; not retained, because a plan outlives the * graph reference this carries. * @param bound What matching resolved about the graph, from the catalog that - * selected this kernel. Passed in rather than recovered here: the - * plan is built from a matched catalog, so re-running a matcher to - * rediscover these would repeat work already cached. + * selected this kernel. */ GenericPlan(KernelDispatcher dispatcher, const MatchContext& context, @@ -88,8 +80,8 @@ class GenericPlan : public IPlan _dispatcher.handler->launch(handle, *_prepared, deviceBuffers, numDeviceBuffers, workspace); } - /// The kernel this plan launches. Exposed for the resolved-plan diagnostics RFC 0017 - /// §10 calls for, and for tests asserting which kernel selection actually chose. + /// The kernel this plan launches. Exposed for the resolved-plan diagnostics + /// (RFC 0017 §10) and for tests asserting which kernel selection chose. const KernelDefinition& kernel() const { return _dispatcher.kernel; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 08fd37afaad7..93684ff22a0c 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -25,36 +25,25 @@ namespace hipdnn_plugin_sdk::ingestor { /// A caller's requested value for each knob it explicitly set, keyed by knob (= KMD -/// field) name. Only integer-valued fields are expressible as knobs today (see -/// GenericPlanBuilder::getCustomKnobs), so this is the same restriction knobValues() -/// already has, not a new one. +/// field) name. Only integer-valued fields are expressible as knobs today. /// /// A `TSettings` used with GenericPlanBuilder must carry one of these named /// `ingestorKnobFilter`, populated by initializeExecutionSettings() and read back by -/// getMaxWorkspaceSize(): that method's IPlanBuilder signature carries no -/// IEngineConfig, so TSettings is the only channel by which a knob setting can reach -/// it. buildPlan() gets IEngineConfig directly and reads the filter from there instead. +/// getMaxWorkspaceSize() and buildPlan(). using KnobFilter = std::map; /** * @brief The one plan builder a descriptor-backed engine has. * - * Not one per kernel: a catalog entry is a candidate, and this builder's job is to - * produce a plan that can launch whichever candidate selection chose. An engine with 150 - * kernels still has one builder and pays only for the kernel a plan needs. + * One builder regardless of catalog size: a catalog entry is a candidate, and this + * builds a plan for whichever candidate selection chose. * - * Every method here is a read of state the state manager already computed and cached. - * Applicability builds the catalog; the knob query and the plan build read the ranked - * order; the workspace query reads the dispatch descriptors. Nothing rebuilds. + * Every method here reads state the state manager already computed and cached; + * nothing rebuilds. * - * @tparam TSettings The provider's settings type. Must carry a `KnobFilter - * ingestorKnobFilter` member (see KnobFilter's doc for why): knob settings - * reach the catalog as a filter applied before ranking, RFC 0017 §4's - * catalog -> filter -> rank order. Filtering after GenericEngine's already- - * ranked read and taking the filtered list's front is equivalent to filtering - * first: IKernelHeuristic's score() takes one kernel at a time and is never - * handed the catalog, so filtering and ranking commute (see its doc), and a - * stable filter over an already-ranked list preserves that ranking. + * @tparam TSettings Must carry a `KnobFilter ingestorKnobFilter` member, populated by + * initializeExecutionSettings() so getMaxWorkspaceSize() can apply the same + * filter buildPlan() reads directly from IEngineConfig. */ template class GenericPlanBuilder : public IPlanBuilder @@ -64,10 +53,10 @@ class GenericPlanBuilder : public IPlanBuilder using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; /// @param engine The UED whose knobs this builder reports and whose name its - /// diagnostics carry. Held by reference; owned by the engine, which outlives - /// its builder. - /// @param stateManager The descriptor state to select over. Shared rather than owned - /// so the engine and its builder read one catalog cache. + /// diagnostics carry. Held by reference; owned by the engine, which + /// outlives its builder. + /// @param stateManager The descriptor state to select over. Shared, not owned, so + /// the engine and its builder read one catalog cache. /// @param deviceResolver Answers which device each call is for. Held by reference; /// owned by the engine, which outlives its builder. GenericPlanBuilder(const EngineDescriptor& engine, @@ -79,8 +68,8 @@ class GenericPlanBuilder : public IPlanBuilder { } - /// Applicable exactly when some kernel survived matching. Deliberately does not rank: - /// a membership test needs no order, so the heuristic is never run here. + /// Applicable exactly when some kernel survived matching. Deliberately does not + /// rank: a membership test needs no order. bool isApplicable(const THandle& handle, const IGraph& opGraph) const override { return !_stateManager.unsortedDefinitions(contextFor(handle, opGraph)).empty(); @@ -89,45 +78,40 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief The largest scratch any surviving kernel needs. * - * The maximum suffices because the buffer is reused rather than partitioned: kernels - * launch one at a time on one stream, so a candidate's scratch is live only while it - * runs. A kernel needing less over-allocates, which is accepted. + * The buffer is reused rather than partitioned: kernels launch one at a time on + * one stream, so a candidate's scratch is live only while it runs. * * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR) if the catalog - * is empty. Same failure as buildPlan(), for the same reason (see its doc). + * is empty (see throwNoApplicableKernel()). * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE) if knob - * filtering empties a non-empty catalog. See throwUnsatisfiableKnobFilter(). + * filtering empties a non-empty catalog (see throwUnsatisfiableKnobFilter()). */ size_t getMaxWorkspaceSize(const THandle& handle, const IGraph& opGraph, const TSettings& executionSettings) const override { const auto context = contextFor(handle, opGraph); - // Unsorted, but still one lookup for the entries and the bound state together. + // Unsorted: one lookup for the entries and the bound state together. const auto catalog = _stateManager.unsortedCatalog(context); if(catalog.entries.empty()) { throwNoApplicableKernel(); } - // Filtered before the max is taken, same contract as buildPlan: the caller's - // knob settings are a request for a subset of the catalog, not a value the - // survivors are merely ranked by. + // Filtered before the max is taken: knob settings request a catalog subset, + // same contract as buildPlan(). const auto filtered = applyKnobFilter(catalog.entries, executionSettings.ingestorKnobFilter); if(filtered.empty()) { - // Same failure buildPlan reports for the same reason: a workspace query and - // a plan build must not disagree about whether a knob combination is legal. + // Same failure buildPlan() reports, for the same reason. throwUnsatisfiableKnobFilter(executionSettings.ingestorKnobFilter, catalog.entries.size()); } size_t maxBytes = 0; - // Unsorted deliberately: a maximum over the survivors is order-independent, and - // this call arrives for every candidate engine. Ranking here would load and run - // each engine's heuristic to compute a number that does not depend on the order, - // which is the cost the lazy-ranking split exists to avoid. + // Unsorted deliberately: the maximum is order-independent, and ranking here + // would load every candidate engine's heuristic for a number that doesn't need it. for(const auto& kernel : filtered) { const auto dispatcher = _stateManager.getDispatchDetails(kernel); @@ -140,10 +124,9 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief Reads the caller's knob settings from @p engineConfig into TSettings. * - * Populates `executionSettings.ingestorKnobFilter` so getMaxWorkspaceSize() -- which - * receives only TSettings, not an IEngineConfig -- can apply the same filter - * buildPlan() reads directly from @p engineConfig. This is the one channel by which - * a knob setting reaches that call. + * Populates `executionSettings.ingestorKnobFilter` so getMaxWorkspaceSize() -- + * which receives only TSettings, not an IEngineConfig -- reads the same filter + * buildPlan() reads directly from @p engineConfig. */ void initializeExecutionSettings(const THandle& /*handle*/, const IGraph& /*opGraph*/, @@ -158,10 +141,9 @@ class GenericPlanBuilder : public IPlanBuilder * set. * * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR) if the catalog - * is empty before knob filtering: a matcher/applicability disagreement, not - * a bad caller request. Same failure getMaxWorkspaceSize() reports. + * is empty before knob filtering (see throwNoApplicableKernel()). * @throws HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE) if knob - * filtering empties a non-empty catalog. See throwUnsatisfiableKnobFilter(). + * filtering empties a non-empty catalog (see throwUnsatisfiableKnobFilter()). */ void buildPlan(const THandle& handle, const IGraph& opGraph, @@ -169,19 +151,16 @@ class GenericPlanBuilder : public IPlanBuilder TContext& executionContext) const override { const auto context = contextFor(handle, opGraph); - // One lookup for both the order and the bound state: asking separately would - // match twice for a graph that carries no identity and so cannot be cached. + // One lookup for both the order and the bound state. const auto catalog = _stateManager.sortedCatalog(context); if(catalog.entries.empty()) { throwNoApplicableKernel(); } - // Filtered after ranking rather than before: filtering and ranking commute (see - // this class's own tparam doc), so filtering the already-ranked list and taking - // its front is the same kernel a filter-then-rank pipeline would choose, without - // a second heuristic pass. RFC 0017 §4's catalog -> filter -> rank ordering is - // preserved in effect, not in the order these two calls execute. + // Filtered after ranking: filtering and ranking commute (see this class's + // tparam doc), yielding the same kernel filter-then-rank would without a + // second heuristic pass. const auto filter = readKnobFilter(engineConfig); const auto filtered = applyKnobFilter(catalog.entries, filter); if(filtered.empty()) @@ -189,10 +168,8 @@ class GenericPlanBuilder : public IPlanBuilder throwUnsatisfiableKnobFilter(filter, catalog.entries.size()); } - // The selection an operator would otherwise have to infer. RFC 0017 §10 asks - // that a resolved plan say which kernel it resolved to; with the catalog built - // from descriptors rather than a switch statement, this line is the only place - // the choice is observable without a debugger. + // Logs the selected kernel (RFC 0017 §10); the only place the choice is + // observable without a debugger. HIPDNN_PLUGIN_LOG_INFO("ingestor: engine '" << _engine.name << "' selected kernel " << toString(filtered.front().kernelId) << " from " << filtered.size() @@ -206,10 +183,9 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief One knob per KMD field the engine exposes. * - * A knob's legal values are what the catalog implements for this graph, not the - * field's theoretical range — offering a value no surviving kernel carries would - * produce a request nothing can serve. Its default is whatever the heuristic ranked - * first, so leaving every knob alone reproduces the out-of-the-box selection. + * A knob's legal values are what the catalog implements for this graph. Its + * default is whatever the heuristic ranked first, so leaving every knob alone + * reproduces the out-of-the-box selection. */ std::vector getCustomKnobs(const THandle& handle, const IGraph& opGraph) const override @@ -231,10 +207,8 @@ class GenericPlanBuilder : public IPlanBuilder choices.reserve(values.size()); for(const auto& value : values) { - // Only integer-valued fields are expressible as knobs today, since - // hipDNN's integer knob constraint carries int64 choices. A - // string-valued KMD field is skipped rather than stringified into a - // number that would mean nothing to a caller. + // Only integer-valued fields are expressible as knobs today; a + // string-valued field is skipped rather than stringified. if(const auto* intValue = std::get_if(&value)) { choices.push_back(*intValue); @@ -251,9 +225,8 @@ class GenericPlanBuilder : public IPlanBuilder = "Kernel metadata field '" + knobName + "' of engine '" + _engine.name + "'"; IntValueT defaultValue; - // knobValues() preserves ranked order, so the first entry is the top-ranked - // kernel's value: leaving the knob alone reproduces the out-of-the-box - // selection. + // First entry is the top-ranked kernel's value, so leaving the knob alone + // reproduces the out-of-the-box selection. defaultValue.value = choices.front(); knob.default_value.Set(defaultValue); @@ -279,14 +252,9 @@ class GenericPlanBuilder : public IPlanBuilder return MatchContext{opGraph, deviceId, _deviceResolver.deviceProperties(deviceId)}; } - /** - * @brief Reads @p engineConfig's settings for this engine's own knobs into a - * KnobFilter. - * - * Only engine-exposed knobs are read: `_engine.knobs` names the KMD fields this - * engine advertises through getCustomKnobs(), and a filter is built only from those - * -- a setting for a knob this engine never exposed cannot mean anything to it. - */ + /// Reads @p engineConfig's settings for this engine's own knobs into a KnobFilter. + /// Only engine-exposed knobs are read: a setting for a knob this engine never + /// advertised cannot mean anything to it. KnobFilter readKnobFilter(const IEngineConfig& engineConfig) const { using namespace hipdnn_flatbuffers_sdk::data_objects; @@ -308,9 +276,7 @@ class GenericPlanBuilder : public IPlanBuilder if(setting.valueType() != KnobValue::IntValue) { // Only integer-valued fields are expressible as knobs today (see - // getCustomKnobs()), so a setting of another type names a knob this - // engine never advertised as anything but an integer -- a caller error, - // reported the same way an unsatisfiable value is below. + // getCustomKnobs()); a setting of another type is a caller error. throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE, "engine '" + _engine.name + "' knob '" + knobName + "' must be set to an integer value"); @@ -320,14 +286,8 @@ class GenericPlanBuilder : public IPlanBuilder return filter; } - /** - * @brief The catalog entries matching every SET knob in @p filter. - * - * An entry with no value for a filtered field never matches: knobValues() already - * treats "no value for this field" as "does not offer a choice here" (see its doc), - * and a filter is a request for a specific choice, so a kernel with nothing to say - * about that field cannot be what the caller asked for. - */ + /// The catalog entries matching every SET knob in @p filter. An entry with no + /// value for a filtered field never matches: no value means no choice offered. std::vector applyKnobFilter(const std::vector& catalog, const KnobFilter& filter) const { @@ -369,19 +329,11 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief Reports a knob combination no kernel in the catalog implements. * - * HIPDNN_PLUGIN_STATUS_INVALID_VALUE, not HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR: the - * caller supplied a value combination nothing implements, which is a bad input, not - * an internal bug -- unlike buildPlan()'s empty-catalog-before-filtering case, which - * really is a matcher/applicability disagreement. The two must stay distinguishable - * by status and by message, since a caller diagnosing which case they hit has only - * this exception to read. + * HIPDNN_PLUGIN_STATUS_INVALID_VALUE, not INTERNAL_ERROR: a bad caller input, not + * a matcher/applicability disagreement like buildPlan()'s empty-catalog case. * - * Names every SET knob and its requested value, not just the first mismatch: a - * caller who set three knobs needs to see all three to know which to back off. Also - * states how many kernels survived matching before knob filtering, which - * distinguishes "the graph matched nothing" (irrelevant here; this path is only - * reached when that count is nonzero) from "the graph matched, but your knobs - * excluded everything" (always the case here). + * Names every set knob and value, and how many kernels survived matching before + * knob filtering. */ [[noreturn]] void throwUnsatisfiableKnobFilter(const KnobFilter& filter, size_t survivorsBeforeFilter) const @@ -404,9 +356,7 @@ class GenericPlanBuilder : public IPlanBuilder } const EngineDescriptor& _engine; - /// Held by reference; owned by the engine, which owns this builder and so outlives - /// it. Sharing ownership here would reintroduce exactly the cross-engine aliasing the - /// engine's ownership rules out. + /// Held by reference; owned by the engine, which outlives this builder. const KernelIngestorStateManager& _stateManager; const IDeviceResolver& _deviceResolver; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp index f9090ac7eaef..4efb379d2ec2 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp @@ -14,20 +14,12 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief Answers which device a call is for, from the handle that carries it. * - * An engine is shared by every handle in the process, and a handle can be bound to any - * device, so the device a query concerns is a property of the call rather than of the - * engine. Resolving it per call is what lets one cache serve many handles correctly: - * two handles on one device share a catalog entry, and one handle rebound to another - * device does not. + * An engine is shared by every handle in the process, and a handle can be rebound to a + * different device between calls, so the device a query concerns is resolved per call + * rather than captured once at engine construction. * - * Capturing the device once when the engine is built would be wrong in exactly the case - * this system is meant to handle. The catalog is keyed on (graph, device) because - * applicability genuinely differs per device; keying every entry to whichever device - * happened to be current at construction would let one device's catalog answer for - * another. That is a wrong answer, not merely a missed cache hit. - * - * How a handle names its device is provider-specific, which is why this is an interface - * rather than a call the SDK makes directly. + * How a handle names its device is provider-specific, which is why this is an + * interface rather than a call the SDK makes directly. */ template class IDeviceResolver diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp index 0081a6ab2d05..94d9a48a532b 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp @@ -19,18 +19,12 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief Everything a launch needs that was derived from the graph, resolved once. * - * A provider derives from this to hold whatever its kernels need: argument uids, launch + * A provider derives from this to hold what its kernels need: argument uids, launch * geometry, a loaded code object, a preallocated argument buffer. * - * It exists because a plan outlives the call that built it. The bound token state a - * MatchContext carries is references into a graph hipDNN owns for the duration of one - * plugin call, so a plan cannot keep it. Instead the handler reads that state once, at - * plan build, and hands back owned values. - * - * This is also the shape RFC 0017 §8.5 describes: the plan build evaluates the dispatch - * descriptor's grid, block, shared-memory and argument formulas over the bound token - * state, and execute afterwards only resolves device pointers by uid and launches. - * Nothing is re-matched and nothing is re-derived per execution. + * A plan outlives the call that built it, and a MatchContext holds references into a + * graph hipDNN owns for one plugin call only, so a plan cannot keep that state. The + * handler reads it once at plan build and returns owned values (RFC 0017 §8.5). */ class PreparedDispatch { @@ -41,18 +35,12 @@ class PreparedDispatch /** * @brief The native escape hatch for a UDD: how to size, prepare, and launch a kernel. * - * This is the seam that keeps provider-specific machinery out of the SDK. A provider's - * implementation holds whatever it needs to run a kernel — a compiler, a module cache, a - * loaded code object — and the SDK never names any of it. Templating on THandle matches - * IPlanBuilder and IPlan, which are parameterized the same way for the same reason. - * - * RFC 0017 §6 makes workspace part of this interface deliberately: the workspace query - * arrives before any kernel is chosen and before a plan exists, so anything that can - * launch must also be able to size its scratch. A handler that can launch but cannot - * answer the workspace question is incomplete. + * Keeps provider machinery out of the SDK -- an implementation holds its own compiler, + * module cache and code objects, none of which the SDK names. Templated on THandle to + * match IPlanBuilder and IPlan. * - * The data-driven replacement evaluates the UDD's expressions instead of calling native - * code. That is the UDD follow-up RFC; this interface is what it plugs into. + * Workspace belongs here (RFC 0017 §6) because the workspace query arrives before any + * kernel is chosen: anything that can launch must also be able to size its scratch. */ template class IKernelDispatchHandler @@ -64,18 +52,12 @@ class IKernelDispatchHandler * @brief Global scratch this kernel requires, in bytes. * * Answered per kernel, before selection, so the engine can report the maximum across - * every kernel the caller's knobs leave in the catalog. The answer stays independent - * of which other kernels are present: the catalog is never passed in. + * the kernels the caller's knobs leave in the catalog. The catalog is never passed + * in, so the answer cannot depend on which other kernels are present. * - * Takes the bound token state as well as the kernel because a workspace requirement - * is a formula over graph dimensions as much as over kernel metadata. RFC 0017 §6's - * worked example is `batch * num_heads * seqlen_q * 4`, which a signature over the - * kernel alone cannot express, and the declarative evaluator that eventually replaces - * a native handler has to. - * - * @param bound What matching already resolved about this graph. Read rather than - * re-derived: RFC 0017 §8.5 gives matching the job of binding the fields the - * launch uses, and §8.1 requires that nothing be re-matched afterwards. + * @param bound What matching already resolved about this graph. A workspace size is + * a formula over graph dimensions as much as kernel metadata, so this is read + * rather than re-derived (RFC 0017 §8.1: nothing is re-matched). */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual size_t workspaceBytes(const MatchContext& context, @@ -87,11 +69,7 @@ class IKernelDispatchHandler * @brief Resolves everything @p kernel's launch needs from the bound token state. * * Called once, at plan build, while @p context is still valid. The returned object is - * owned by the plan and must not reference anything in @p context. - * - * @param bound What matching resolved about this graph, for the same reason - * workspaceBytes() takes it: the values a launch needs were already found once - * and re-finding them here would be a second, divergeable notion of the graph. + * owned by the plan and MUST NOT reference anything in @p context or @p bound. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual std::unique_ptr prepare(const MatchContext& context, @@ -112,17 +90,10 @@ class IKernelDispatchHandler * @note May be called concurrently from several threads with different device * buffers, so it must not mutate @p prepared or the handler. * - * Deliberately one call, one kernel: multi-launch UDDs (a UDD whose dispatch is - * several kernels rather than one) are out of scope for this interface and are - * correctly deferred, per RFC 0017's own follow-up. When that lands, the direction - * this interface takes is a handler invoked once per launch that internally issues - * N kernel launches on @p handle's stream -- not the SDK calling launch() N times -- - * because grid/block/argument formulas for launch i in a multi-kernel dispatch can - * depend on what launch i-1 produced (a two-pass reduction's second kernel sizes - * itself from the first's output), which only the handler that owns the whole - * dispatch can resolve. Recording the direction now, rather than only the - * deferral, is what keeps this a decision instead of an open question the next - * implementer has to re-litigate. + * One call, one kernel. When multi-launch UDDs land, the handler should issue the N + * launches internally rather than the SDK calling launch() N times: launch i's + * geometry can depend on what launch i-1 produced, and only the handler owning the + * whole dispatch can resolve that. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual void launch(const THandle& handle, diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp index 6ae47c46ebb4..7ab7e3093b1d 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp @@ -28,20 +28,12 @@ namespace hipdnn_plugin_sdk::ingestor * One level below hipDNN's engine-selection heuristic, which decides *which engine* * handles a graph and is untouched by this system. * - * An implementation supplies `score()` and nothing else. It ranks one kernel at a time - * and never sees the catalog, so a kernel's score cannot depend on which other kernels - * are present. That is what makes filtering and ranking commute: a knob-filtered subset - * ranks exactly as it did in the whole catalog, so the kernel a knob setting selects is - * the kernel the reported default named. The failure it prevents, a reported default - * that a knob setting then contradicts, is silent and surfaces to a user rather than to - * the author who caused it. - * - * RFC 0017 §9.2 makes that structural rather than advisory: the scorer interface "takes - * one kernel at a time and is never handed the catalog, so a scorer that ranks relative - * to its peers cannot be written against it". `rank()` is therefore non-virtual. A - * selector that must reason over the candidate set as a whole is admitted by the - * heuristic follow-up RFC, which owns deciding what a knob-filtered query means once - * ranking is no longer per kernel; re-opening this for override then is additive. + * An implementation supplies `score()` and nothing else: it ranks one kernel at a + * time and never sees the catalog, so a kernel's score cannot depend on which other + * kernels are present. That is what makes filtering and ranking commute (RFC 0017 + * §9.2), so a knob-filtered subset ranks exactly as it did in the whole catalog. + * `rank()` is therefore non-virtual; a selector that must reason over the candidate + * set as a whole is the heuristic follow-up RFC's job. */ class IKernelHeuristic { @@ -54,17 +46,13 @@ class IKernelHeuristic /** * @brief Orders @p catalog best-first. * - * Scores each entry independently and sorts descending, breaking ties - * deterministically: first on the kernel's explicit `priority`, then on its - * descriptor id compared as bytes. That id order carries no meaning — it is chosen - * only for being stable across runs, load orders, and machines, so two processes - * given the same catalog always choose the same kernel. + * Scores each entry independently and sorts descending, breaking ties first on + * the kernel's explicit `priority`, then on its descriptor id compared as bytes + * (stable across runs, load orders, and machines). * * Each kernel is scored exactly once, before sorting, rather than inside the - * comparator. A scorer is a model evaluation in the data-driven form, so scoring - * from the comparator would run inference O(n log n) times for an n-kernel catalog - * instead of n, and would also let a scorer that is not perfectly deterministic - * produce an inconsistent ordering. + * comparator: scoring from the comparator would run inference O(n log n) times + * instead of n for an n-kernel catalog. */ std::vector rank(const Catalog& catalog, const MatchContext& context) const { @@ -100,21 +88,14 @@ class IKernelHeuristic /** * @brief An IKernelHeuristic whose score() is a native function resolved by symbol. * - * The UHD escape hatch: the descriptor names a symbol, this resolves it. The data-driven - * form loads a model artifact and assembles its feature vector from the bound token state - * instead (the UHD follow-up RFC), and slots in here as another IKernelHeuristic. + * The UHD escape hatch: the descriptor names a symbol, this resolves it. The + * data-driven form loads a model artifact instead (the UHD follow-up RFC), and slots + * in here as another IKernelHeuristic. * * **Constructing a heuristic must stay cheap; whatever it selects with loads on first - * use.** RFC 0017 §8.1 admits the heuristic at applicability only as a name: "The - * heuristic is named but **not** loaded; nothing ranks yet", and §3 generalizes it, - * "a heuristic model is not read until something needs the catalog ranked". An engine - * whose matchers reject a graph must never pay for its selector. - * - * That contract lives here rather than one level up, because only the adapter knows what - * "loading" costs it. This one holds a symbol name and resolves it on first score(); a - * LightGBM adapter would hold an artifact path and read the model there. Either way the - * object is constructible before any graph is seen, which is what lets the engine own it - * outright instead of threading a factory through the state manager. + * use** (RFC 0017 §8.1, §3): an engine whose matchers reject a graph must never pay + * for its selector. This one holds a symbol name and resolves it on first score(); a + * LightGBM adapter would hold an artifact path and read the model there. */ class NativeKernelHeuristic : public IKernelHeuristic { @@ -144,17 +125,10 @@ class NativeKernelHeuristic : public IKernelHeuristic /** * @brief Builds the IKernelHeuristic a UHD names, keyed on its kind. * - * The same shape DispatchRegistry's symbol-to-handler resolution has, applied to the - * one descriptor RFC 0017 constructs and then steps around: before this, a UHD's - * `scoreSymbol` had no path from the descriptor to a live heuristic except a provider - * hand-building a NativeKernelHeuristic itself. With this factory, "add file loading and - * it works" is true for a UHD exactly as it already is for a UMD or a UDD. - * * @throws std::invalid_argument if @p descriptor names a kind with no adapter yet - * (HeuristicKind::MODEL). Fails at descriptor-assembly time rather than at the - * first rank(), for the same reason KernelIngestorStateManager's other - * cross-reference checks run eagerly: a kind nothing can load is a load error, - * not a runtime surprise on the first graph that needs ranking. + * (HeuristicKind::MODEL). Fails at descriptor-assembly time, matching + * KernelIngestorStateManager's other cross-reference checks: a kind nothing + * can load is a load error, not a runtime surprise at the first rank(). */ inline std::shared_ptr makeKernelHeuristic(const HeuristicDescriptor& descriptor) { diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp index 8cd27dd0f01f..f7a2728fae24 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp @@ -19,13 +19,13 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief One catalog entry: a kernel that passed every matcher for a graph. * - * This is the POD that leaves KernelIngestorStateManager. It is copied out rather than + * This is the POD that leaves KernelIngestorStateManager. Copied out rather than * handed out by reference so a caller holds a stable snapshot while the cache it came * from is concurrently evicted or refilled. * - * It carries the kernel's completed metadata tuple (its catalog key), enough source - * detail to load it, and the ids needed to find the dispatch descriptor that launches - * it — but no pointer back into the descriptor set. + * Carries the kernel's completed metadata tuple (its catalog key), enough source + * detail to load it, and the ids needed to find its dispatch descriptor — but no + * pointer back into the descriptor set. */ struct KernelDefinition { @@ -59,8 +59,7 @@ struct KernelDefinition /// @brief The integer value of a KMD field. /// @throws std::out_of_range if the field is absent, std::invalid_argument if it /// holds a different alternative. Both are author errors a validating - /// loader would have caught, so they throw rather than returning a default - /// that would silently mis-rank or mis-launch a kernel. + /// loader would have caught. int64_t getIntMetadata(const std::string& field) const { auto it = metadata.find(field); diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index ccfba8e72d05..c2bf860c5ea2 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -29,9 +29,9 @@ namespace hipdnn_plugin_sdk::ingestor { -/// What a caller needs to size and launch one selected kernel: the kernel itself, plus -/// the dispatch handler its pack's UDD resolved to. Copied out like KernelDefinition, so -/// holding one does not pin the state manager's internals. +/// What a caller needs to size and launch one selected kernel: the kernel plus the +/// dispatch handler its pack's UDD resolved to. Copied out, so holding one does not +/// pin the state manager's internals. template struct KernelDispatcher { @@ -46,7 +46,7 @@ struct KernelDispatcher * and how to launch one. * * Holds the descriptor state one engine selects over (its KMD and UHD, and the packs - * naming it) and answers the three questions hipDNN's four host calls reduce to: + * naming it) and answers hipDNN's four host calls: * * | Host call | Answered by | * |------------------------------|------------------------------------------------| @@ -55,11 +55,9 @@ struct KernelDispatcher * | getMaxWorkspaceSize | getDispatchDetails() per survivor, then max | * | initializeExecutionContext | sortedDefinitions().front(), getDispatchDetails() | * - * **Work is done as late as possible and kept.** Applicability only needs to know - * whether any kernel survived, so it matches but does not rank. Ranking happens on the - * first call that needs an order, and is cached alongside the catalog it ordered. Both - * are keyed on (graph, device) — the problem — rather than on the handle, which is a - * caller-side object whose lifetime says nothing about whether the work is still valid. + * Work is done as late as possible and kept: applicability only matches, ranking + * happens on first need and is cached alongside the catalog, both keyed on + * (graph, device) rather than the handle. * * Thread-safe: the cache is internally synchronized, and matcher and scorer calls are * made outside the lock, so one thread matching a graph never blocks another. @@ -82,25 +80,14 @@ class KernelIngestorStateManager * @param heuristic That engine's UHD, resolved to a scorer. * * @throws std::invalid_argument if a pack names a matcher or dispatch descriptor not - * supplied, or if two kernels share a metadata tuple. Both are load-time - * validation failures in the real system: a dangling cross-reference cannot - * be evaluated, and duplicate catalog keys leave selection with two - * indistinguishable candidates and no basis to prefer either. + * supplied, or if two kernels share a metadata tuple: a dangling + * cross-reference cannot be evaluated, and duplicate catalog keys leave + * selection with two indistinguishable candidates. * - * **This constructor eagerly walks and validates every pack and kernel, via - * validateAndIndexPacks(), at plugin load -- a conscious amendment of RFC 0017 §3 - * and §8's "nothing is parsed until a graph needs it".** What stays lazy is the - * expensive part: a kernel source is not compiled and a heuristic model is not read - * until a graph actually needs that kernel or that ranking (see - * NativeKernelHeuristic's doc for the latter). Descriptor *parsing* -- walking the - * KMD/UMD/UDD/UKD structures this constructor is handed and checking their - * cross-references and metadata tuples -- is cheap relative to those, and doing it - * once at load time is what lets every later match, rank, and dispatch assume the - * descriptor set is internally consistent rather than re-checking it per graph. The - * cost this trades away is startup latency proportional to descriptor count, which - * for an in-process pack like this one is negligible; a loader reading many packs - * from disk is where that cost becomes visible, and if it ever needs to be paid - * lazily instead, this is the constructor that amendment would have to change. + * Eagerly walks and validates every pack and kernel at plugin load, via + * validateAndIndexPacks() -- an amendment of RFC 0017 §3/§8's "nothing is parsed + * until a graph needs it". What stays lazy is the expensive part: a kernel source + * is not compiled and a heuristic model is not read until a graph needs it. */ KernelIngestorStateManager(MetadataSchema schema, std::vector matchers, @@ -152,13 +139,11 @@ class KernelIngestorStateManager * particular order. * * On a cache hit, returns the cached catalog. Otherwise runs the matchers in pruning - * order — each pack's graph-scoped matchers first, since one failure disqualifies the - * whole pack, then the kernel-scoped matchers over whatever survived — and caches the - * result. + * order — each pack's graph-scoped matchers first, then the kernel-scoped matchers + * over whatever survived — and caches the result. * - * A graph carrying no identity cannot be cached (there is no key, and inventing one - * would alias unrelated graphs), so it is matched fresh every call. That costs time, - * never correctness. + * A graph carrying no identity cannot be cached, so it is matched fresh every call. + * That costs time, never correctness. */ std::vector unsortedDefinitions(const MatchContext& context) const { @@ -168,14 +153,10 @@ class KernelIngestorStateManager /** * @brief The unranked catalog and the state matching bound, from one lookup. * - * RFC 0017 §8.1 keeps the bound token state alongside the catalog so that "nothing - * is re-matched" after applicability: a dispatch handler sizing a workspace reads the - * values the matcher already resolved rather than walking the graph again with a - * second notion of what it looks like. - * - * Returned together with the entries rather than through a separate accessor because - * a caller needing both would otherwise match twice for a graph carrying no identity, - * which is exactly the case with no cache to absorb the repeat. + * RFC 0017 §8.1 keeps the bound token state alongside the catalog so a dispatch + * handler sizing a workspace reads values the matcher already resolved rather than + * walking the graph again. Returned together with the entries so a caller needing + * both does not match twice for a graph with no identity to cache under. * * `bound` is empty when no graph-scoped matcher bound anything, which is legal: a * pack whose launch geometry is fully determined by kernel metadata has nothing to @@ -201,10 +182,8 @@ class KernelIngestorStateManager /** * @brief The ranked catalog and the state matching bound, from one lookup. * - * A plan build needs both, and asking for them separately means two calls into the - * cache -- or, for a graph carrying no identity and so no cache entry, two full - * matching passes. Returning them together makes "match once" hold for the - * uncacheable case as well, which is the case that can least afford the second pass. + * A plan build needs both; returning them together avoids matching twice for a + * graph with no identity to cache under. */ Catalog sortedCatalog(const MatchContext& context) const { @@ -309,12 +288,10 @@ class KernelIngestorStateManager /// A kernel's declared values for the fields its engine's KMD declares, with the /// KMD's defaults filled in for the ones it omitted. This completed tuple, not the - /// descriptor id, is what identifies the kernel to matching and ranking. - /// - /// Built from the schema's field list rather than from the kernel's own map, because - /// the KMD fields are the only per-kernel input selection has: a value the schema - /// never declared is unreadable to a matcher or a scorer, so admitting one into the - /// key would let two kernels that selection cannot tell apart both enter the catalog. + /// descriptor id, is what identifies the kernel to matching and ranking: a value the + /// schema never declared is unreadable to a matcher or a scorer, so admitting one + /// into the key would let two kernels that selection cannot tell apart both enter + /// the catalog. MetadataValues completeMetadata(const KernelDescriptor& kernel) const { MetadataValues complete; @@ -394,9 +371,7 @@ class KernelIngestorStateManager } else { - // Worth saying once per call: a graph with no identity is re-matched every - // time, so an operator seeing repeated matching for what looks like the same - // graph is seeing this rather than a caching bug. + // A graph with no identity is re-matched every call rather than cached. HIPDNN_PLUGIN_LOG_TRACE( "ingestor: graph carries no identity, so its catalog cannot be cached"); } @@ -412,16 +387,9 @@ class KernelIngestorStateManager } /// One graph-scoped matcher's verdict for one (graph, device), keyed by matcher id. - /// - /// Matchers are shared by id across packs, so the same check would otherwise be - /// re-run once per pack that lists it. The memo covers what the matcher *bound* as - /// well as whether it passed, since re-running it to recover its bindings would - /// defeat the memo entirely. - /// - /// `bound` holds only what THIS matcher wrote, isolated from every other matcher's - /// writes. That isolation is what makes it safe to merge into a per-pack scoped - /// view one matcher at a time: a pack merges in exactly the matchers it lists, never - /// a sibling pack's unrelated contribution to some other matcher's memo entry. + /// Matchers are shared by id across packs, so this memoizes both the verdict and + /// what it bound, isolated per matcher so a pack merges in exactly the matchers it + /// lists and never a sibling pack's contribution to another matcher's entry. struct GraphMatcherVerdict { bool passed = false; @@ -433,24 +401,15 @@ class KernelIngestorStateManager /** * @brief Runs every pack's matchers over @p context, cheapest and broadest first. * - * Graph-scoped matchers read only graph and device facts, so each is evaluated once - * per (graph, device) no matter how many packs list it, and one failure disqualifies - * every kernel in every pack that lists it without any per-kernel work. Only then do - * the kernel-scoped matchers run, once per surviving kernel. - * - * That ordering, and that sharing, are the point of splitting matchers by scope: the - * broadly shared checks are what prune the candidate set fast, so an engine whose - * packs do not apply pays for each distinct check once rather than once per pack. + * Graph-scoped matchers read only graph and device facts, so each runs once per + * (graph, device) no matter how many packs list it; one failure disqualifies the + * whole pack before any kernel-scoped work. Kernel-scoped matchers then run once per + * surviving kernel. * - * Each pack's graph-scoped bindings are accumulated in a scope-local view and merged - * into the catalog's shared bound state only when the whole pack survives (see - * graphLevelMatchersPass()). Bindings a pruned pack's matchers wrote along the way - * are discarded with that pack's local view rather than leaking into the state a - * surviving pack's kernel later reads: with one pack this was invisible, because a - * pruned pack's catalog is empty and nothing reads its bound state, but two packs - * sharing an engine is the normal case (matchers are shared by id), and the moment a - * second pack survives, a bare shared `bound` map would hand its kernels tokens that - * describe a graph shape only the pruned pack's own matcher resolved. + * Each pack's graph-scoped bindings accumulate in a scope-local view and merge into + * the catalog's shared bound state only when the whole pack survives + * (graphLevelMatchersPass()); a pruned pack's bindings are discarded with its local + * view rather than leaking into a surviving pack's kernel. */ Catalog buildCatalog(const MatchContext& context) const { @@ -459,25 +418,20 @@ class KernelIngestorStateManager for(const auto& pack : _packs) { - // Scoped to this pack. Merged into catalog.bound below only if every one of - // this pack's graph-scoped matchers passes; discarded with this pack's - // failure otherwise. See buildCatalog()'s doc for why that scoping is the - // fix rather than an optimization. + // Scoped to this pack; merged into catalog.bound below only if the pack + // survives (see buildCatalog()'s doc). BoundTokens packBound; if(!graphLevelMatchersPass(pack, context, graphVerdicts, packBound)) { - // Logged, not silent: "no kernel matched my graph" is the question a - // data-driven engine is hardest to answer, because there is no - // hand-written switch to read. RFC 0017 §10 asks that an operator be - // able to see why a kernel was not selected, and a pack pruned at the - // graph level is the coarsest and most common reason. + // RFC 0017 §10: an operator must be able to see why a kernel was not + // selected. A pack pruned at the graph level is the coarsest, most + // common reason. HIPDNN_PLUGIN_LOG_INFO("ingestor: pack " << toString(pack.id) << " declined at a graph-scoped matcher"); continue; } - // Merged rather than kept per pack: a token name means the same thing to - // every pack in an engine (Catalog::bound's doc). + // Merged rather than kept per pack (Catalog::bound's doc). mergeBound(catalog.bound, packBound, pack.id); size_t admitted = 0; @@ -502,18 +456,16 @@ class KernelIngestorStateManager << " kernel(s) after kernel-scoped matching"); } - // The summary a caller diagnosing an unexpected decline actually needs: an empty - // catalog here is what applicability reports as "does not apply", and without - // this line the only observable is the absence of an engine id. + // Without this, an empty catalog's only observable is a missing engine id. HIPDNN_PLUGIN_LOG_INFO("ingestor: catalog for device " << context.deviceId << " holds " << catalog.entries.size() << " kernel(s) from " << _packs.size() << " pack(s)"); return catalog; } - /// Folds @p packBound into @p bound. Two packs agreeing on a token's value are just - /// sharing a matcher by id; two packs writing DIFFERENT values under one name is an - /// authoring error, only detectable here since it depends on a runtime match. + /// Folds @p packBound into @p bound. Two packs writing DIFFERENT values under one + /// token name is an authoring error, detectable only here since it needs a runtime + /// match. static void mergeBound(BoundTokens& bound, const BoundTokens& packBound, const DescriptorId& packId) { @@ -531,12 +483,9 @@ class KernelIngestorStateManager } /** - * @param packBound Accumulates what THIS pack's own graph-scoped matchers resolved, - * isolated from every other pack's contribution (see GraphMatcherVerdict). - * The caller merges it into the catalog's shared bound state only when this - * function returns true; a pack this function prunes leaves @p packBound to - * be discarded unmerged, which is what stops its matchers' bindings from - * reaching a kernel in a pack that survives. + * @param packBound Accumulates THIS pack's own graph-scoped matcher bindings. The + * caller merges it into the shared bound state only when this returns true; + * a pack this function prunes leaves it to be discarded unmerged. */ bool graphLevelMatchersPass(const KernelDescriptorPack& pack, const MatchContext& context, @@ -554,11 +503,8 @@ class KernelIngestorStateManager auto memo = graphVerdicts.find(matcherId); if(memo == graphVerdicts.end()) { - // Evaluated on the first pack that lists this matcher only. A later pack - // listing the same matcher reuses both the verdict and the bindings it - // already wrote from this memo entry, which is the whole point of - // memoizing across packs -- the native function itself runs once no - // matter how many packs share the matcher. + // Evaluated once, on the first pack that lists this matcher; later + // packs reuse the verdict and bindings from the memo. GraphMatcherVerdict verdict; verdict.passed = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context, verdict.bound); @@ -570,10 +516,7 @@ class KernelIngestorStateManager return false; } - // Merges only THIS matcher's own bindings into the pack's scoped view. A - // pack that does not list this matcher never merges them; a pack that - // shares it with another pack that failed elsewhere still merges exactly - // what this matcher wrote, reused from the memo rather than recomputed. + // Merges only THIS matcher's bindings into the pack's scoped view. packBound.insert(memo->second.bound.begin(), memo->second.bound.end()); } return true; @@ -603,9 +546,8 @@ class KernelIngestorStateManager std::unordered_map _dispatches; std::vector _packs; std::shared_ptr _heuristic; - /// Mutable because the query methods are logically const: they answer questions about - /// the descriptor set without changing it, and caching is an optimization invisible - /// to the caller. The cache is internally synchronized. + /// Mutable because the query methods are logically const; the cache is internally + /// synchronized. mutable LruCache _catalogCache; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp index ea0d960bde84..d7663f3ce8f4 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp @@ -19,11 +19,12 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief A bounded, thread-safe least-recently-used cache. * - * Sized by entry count rather than bytes: the ingestor's entries hold descriptor ids and - * bound field values, not kernels or graphs, so their size is bounded by construction. + * Sized by entry count rather than bytes: the ingestor's entries hold descriptor ids + * and bound field values, not kernels or graphs, so their size is bounded by + * construction. * - * Eviction costs a rematch, never a wrong answer, so the capacity is a memory/latency - * tradeoff rather than a correctness one. + * Eviction costs a rematch, never a wrong answer, so the capacity is a + * memory/latency tradeoff rather than a correctness one. * * @tparam Key Must be hashable by @p Hash and equality-comparable. * @tparam Value Copied in and out; callers hold snapshots, not references into the cache. @@ -101,8 +102,8 @@ class LruCache mutable std::mutex _mutex; size_t _capacity; - /// Most-recently-used first. A list so splicing an entry to the front on a hit does - /// not invalidate the iterators held in _index. + /// Most-recently-used first. A list so splicing an entry to the front on a hit + /// does not invalidate the iterators held in _index. std::list _order; std::unordered_map::iterator, Hash> _index; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index 1d267d44576b..c7a1e7364452 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -37,29 +37,13 @@ inline constexpr DeviceId NO_DEVICE = -1; /// across serialization round trips. using GraphId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; -/// The catalog cache key. Deliberately excludes the handle: a handle is a caller-side -/// object that can be swapped, rebound, or destroyed while a plan built through it is -/// still live, so keying on it would tie cached work to a lifetime unrelated to that -/// work's validity. Two handles on one device share an entry; one handle rebound to -/// another device does not. +/// The catalog cache key. Excludes the handle: a handle can be swapped, rebound, or +/// destroyed while a plan built through it is still live, so keying on it would tie +/// cached work to a lifetime unrelated to that work's validity. /// -/// RFC 0017 §8.1 keys this on (engine id, graph id, device id) and §8.6 folds in a -/// descriptor-inventory generation. Neither appears here, for different reasons: -/// -/// - The engine id is constant within any one cache, because GenericEngine owns its -/// state manager outright (std::unique_ptr, not shared) and the state manager owns -/// this cache. One engine, one UED, one catalog: an entry cannot be reached from an -/// engine other than the one that wrote it, so storing the id would only repeat a -/// value every entry already agrees on. This is enforced by the ownership, not by -/// convention -- a second engine cannot be handed this manager. -/// - The generation retires cached verdicts when a discovery scan changes the pack -/// inventory. Nothing is loaded from a file yet, so the inventory is fixed at compile -/// time and the generation would be a constant. -/// -/// The generation becomes a real key component once packs can be dropped in; the engine -/// id stays out for as long as the ownership above holds. This is a struct rather than a -/// std::pair so adding a member then does not change how the key is spelled at its use -/// sites. +/// RFC 0017 §8.1 also keys this on engine id and folds in a descriptor-inventory +/// generation (§8.6); both are constant today (one engine owns one manager, and the +/// pack set is fixed at compile time) so they are omitted until that changes. struct CatalogKey { GraphId graphId; @@ -76,9 +60,8 @@ struct CatalogKeyHash { size_t operator()(const CatalogKey& key) const noexcept { - // The graph id is already a well-distributed 128-bit value (a UUID v4), so - // folding its bytes and mixing the device ordinal in is sufficient here; this - // keys an in-process cache and is never persisted or exposed. + // The graph id is already a well-distributed UUID v4; folding its bytes and + // mixing in the device ordinal is enough for an in-process cache. size_t hash = 1469598103934665603ULL; for(const uint8_t byte : key.graphId) { @@ -93,28 +76,15 @@ struct CatalogKeyHash /** * @brief What matching resolved about one graph, carried forward to dispatch. * - * RFC 0017 §8.5: "Matching does double duty: it decides the kernel applies, and it binds - * the fields the launch will use", and §8.1 keeps that state alongside the catalog so - * "nothing is re-matched" once a graph has been matched. A dispatch formula reading - * `$q.uid` is reading a value the matcher already resolved, not re-deriving it from the - * graph with a second notion of what the graph looks like. - * - * Keyed by the token name a descriptor would write. Values are MetadataValue, the same - * type a KMD field holds: RFC 0017's criteria language puts a graph fact and a kernel - * fact on either side of one operator (`divisible($q.head_size, $kernel.tile_m)`), so - * an interpreter needs one value type spanning both namespaces or its first act is a - * refactor. That is also why the list alternative matters on this side and not only on - * the kernel side: `stride_order`, the worked example for INT_LIST, is a graph fact. + * Keyed by the token name a descriptor would write; values are MetadataValue, the same + * type a KMD field holds, so a criteria expression can compare a graph fact and a kernel + * fact with one operator (RFC 0017 §8.5, §8.1). */ using BoundTokens = std::unordered_map; -/// Reads @p token from @p bound as an integer. -/// -/// Bound tokens are frequently tensor uids and dimensions, so the int64 read is the -/// common one and is worth naming rather than open-coding a std::get_if at every call -/// site. Returns nullopt when the token is absent OR holds a non-integer alternative: -/// a caller that asked for an integer cannot use a list, and conflating "not bound" -/// with "bound to something else" would let a type confusion read as a missing token. +/// Reads @p token from @p bound as an integer. Returns nullopt when absent or bound to +/// a non-integer value: conflating "not bound" with "bound to something else" would let +/// a type confusion read as a missing token. inline std::optional tryGetBoundInt(const BoundTokens& bound, std::string_view token) { const auto it = bound.find(std::string(token)); @@ -131,16 +101,12 @@ inline std::optional tryGetBoundInt(const BoundTokens& bound, std::stri } /** - * @brief The bound token state a matcher, scorer, or dispatch formula reads. - * - * RFC 0017 names five expression namespaces; this binds the two that do not - * require a matcher to have run first — `$graph.*` (and the tensor and node fields - * reached through it) and `$device.*`. The third, `$kernel.*`, arrives separately as a - * KernelDefinition, because a kernel-scoped check is evaluated once per candidate while - * this context is bound once per (graph, device). + * @brief The bound token state a matcher, scorer, or dispatch formula reads: `$graph.*` + * and `$device.*`. `$kernel.*` arrives separately as a KernelDefinition, evaluated + * per candidate rather than once per (graph, device). * - * Holds references, not copies: it is built on the stack for the duration of one - * matching pass and never outlives the graph it names. + * Holds references, not copies: built on the stack for one matching pass, and must not + * outlive the graph it names. */ struct MatchContext { @@ -151,14 +117,11 @@ struct MatchContext /// @brief The graph's stable identity, or nullopt when it has none. /// -/// Absent on legacy graphs and on locally constructed graphs that were never finalized. -/// Callers must treat that as "cannot cache", never as an error: matching a graph with -/// no id still produces the right answer, it just cannot be memoized (there is no key to -/// memoize it under, and inventing one would alias unrelated graphs). +/// Absent on legacy or never-finalized graphs. Callers must treat that as "cannot +/// cache", not an error: matching still produces the right answer, just unmemoized. /// -/// A present field is not enough: a nil or non-v4 id (real ids are always v4) is not -/// guaranteed unique, so it reads as "no identity" too. Cache-key uniqueness therefore -/// depends on isUuidV4()'s exact predicate; loosening it reopens this aliasing. +/// A present but non-v4 id also reads as "no identity" — uniqueness is not otherwise +/// guaranteed, so cache-key correctness depends on isUuidV4()'s exact predicate. inline std::optional tryGetGraphId(const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& graph) { diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp index 44e270572c73..d342c1da7f8e 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp @@ -18,19 +18,9 @@ * @file NativeRegistry.hpp * @brief Symbol name to native function, the escape hatch descriptors resolve through. * - * RFC 0017 gives every declarative step a named escape hatch for the case its built-ins - * cannot express: a criterion may invoke a custom predicate, and a UDD may name a custom - * plan. In both cases the descriptor carries only a *symbol name*, never inline code, - * and the provider resolves it from a registry that is part of its published contract. - * - * The first implementation uses that escape hatch throughout: every UMD, UHD, and - * UDD names a symbol here rather than carrying an expression or a model. Replacing a - * native symbol with real declarative data is a change to one descriptor field, not to - * the ingestor — which is the property this arrangement exists to demonstrate. - * - * Lookup fails closed. A descriptor naming a symbol the provider does not ship throws - * rather than being silently skipped, so a missing registration surfaces as a clear - * error at the point of use instead of as an engine that mysteriously matches nothing. + * A descriptor names a custom predicate or plan by symbol rather than carrying inline + * code (RFC 0017); the provider resolves it here. Lookup fails closed: a symbol the + * provider does not ship throws rather than being silently skipped. */ namespace hipdnn_plugin_sdk::ingestor { @@ -38,15 +28,10 @@ namespace hipdnn_plugin_sdk::ingestor /// A graph-scoped applicability check: reads only graph and device facts, so it runs /// once per (graph, device) and its failure prunes every kernel in the pack. /// -/// On success it also writes what it resolved about the graph into @p bound, which the -/// catalog keeps and dispatch reads back. RFC 0017 §8.5 gives matching this double duty -/// deliberately: the matcher already walked the graph to decide the kernel applies, so -/// the launch reuses those values rather than re-deriving them. A matcher that binds -/// nothing simply leaves @p bound alone. -/// -/// Only graph-scoped matchers bind. Kernel-scoped ones run once per candidate, so what -/// they resolved would be per-kernel state with nowhere in the catalog to live; the -/// tokens a launch needs describe the problem, not the kernel chosen for it. +/// On success, writes what it resolved into @p bound (RFC 0017 §8.5), which the catalog +/// keeps and dispatch reads back instead of re-deriving it. A matcher that binds +/// nothing leaves @p bound alone. Kernel-scoped matchers never bind: their result is +/// per-kernel, with nowhere in the catalog to live. /// /// Must be thread-safe: the registry may call it concurrently, same contract as /// `IKernelDispatchHandler::launch`. @@ -66,19 +51,13 @@ using ScoreFn = double (*)(const KernelDefinition&, const MatchContext&); /** * @brief The provider's registry of native implementations, keyed by symbol name. * - * One instance per registered type per loaded image (not per process: two plugin - * `.so`s each get their own, which is why lookup never crosses providers). Registration - * happens at static-init time from the provider's own translation units; lookup happens - * on the applicability, ranking, and dispatch paths. + * One instance per registered type per loaded image, not per process: isolation + * between two loaded copies depends on the provider building with + * `CXX_VISIBILITY_PRESET hidden` and `--exclude-libs=ALL` (`src/CMakeLists.txt`) — an + * exported symbol would let two copies share one registry and the second + * `registerSymbol()` throw. * - * Guarded by a mutex because registration and lookup can race: a provider loaded on one - * thread while another is already matching is unusual but not forbidden. - * - * Per-image isolation depends on the provider building with `CXX_VISIBILITY_PRESET - * hidden` and `--exclude-libs=ALL` (`src/CMakeLists.txt`): if a provider ever exported - * this template's symbols, two loaded copies of the same provider would share one - * registry and the second `registerSymbol()` would throw out of - * `hipdnnEnginePluginCreate`. + * Guarded by a mutex: registration and lookup can race across threads. * * @tparam T The registered callable or interface pointer type. */ @@ -87,12 +66,11 @@ class NativeRegistry { public: /** - * @brief Registers @p implementation under @p symbol. - * @throws std::runtime_error if @p symbol is already registered. Two implementations - * behind one name means one of them is silently unreachable, and which one - * wins would depend on static-init order — so this is always an author bug - * and is reported rather than resolved. - */ + * @brief Registers @p implementation under @p symbol. + * @throws std::runtime_error if @p symbol is already registered — always an author + * bug, since which implementation would silently win depends on static-init + * order. + */ static void registerSymbol(const std::string& symbol, T implementation) { auto& self = instance(); @@ -135,9 +113,8 @@ class NativeRegistry private: static NativeRegistry& instance() { - // Function-local static: initialized on first use, so this is immune to the - // unspecified initialization order of the namespace-scope registrations that - // populate it. + // Function-local static: initialized on first use, immune to the unspecified + // init order of the namespace-scope registrations that populate it. static NativeRegistry s_instance; return s_instance; } @@ -150,10 +127,9 @@ using GraphMatcherRegistry = NativeRegistry; using KernelMatcherRegistry = NativeRegistry; using ScoreRegistry = NativeRegistry; -/// Dispatch handlers are registered as non-owning pointers to provider-owned objects, -/// because a handler holds provider state (a compiler, a module cache) whose lifetime is -/// the provider's, not the registry's. The provider must keep the handler alive for as -/// long as any plan built from it can execute. +/// Registered as non-owning pointers: a handler holds provider state (a compiler, +/// a module cache) whose lifetime is the provider's. The provider must keep the +/// handler alive for as long as any plan built from it can execute. template using DispatchRegistry = NativeRegistry*>; From 4576b091303fc20441f5212abbd3da2212433893 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Wed, 12 Aug 2026 08:45:28 -0400 Subject: [PATCH 30/60] docs(hipdnn): minimize comments across the ingestor skeleton (ALMIOPEN-2402) Trim the PR's comments to concise, factual statements. Drop historical framing ("used to", "this replaces", "before this move"), multi-paragraph design justifications, cross-file line-number citations used as evidence, and comments that only restate the code or the test name. Keep file-purpose blocks, forward-looking scope notes (the ALMIOPEN-2401 loader, the UMD/UDD and heuristic follow-up RFCs), RFC 0017 references, ownership and lifetime contracts, thread-safety requirements, and the descriptor/host-call tables. Also correct two comments that disagreed with the code: - PointwiseAddSymbols.hpp named a nonexistent file in its @file tag. - PointwiseAddDispatchHandler.hpp claimed the handler holds device properties; it holds only the kernel compiler. Comment lines: 2351 -> 1722. No functional change. --- .../hip-kernel-provider/CMakeLists.txt | 5 +- .../src/core/Container.cpp | 17 +- .../hip-kernel-provider/src/core/Settings.hpp | 7 +- .../kernel_ingestor_engine/CMakeLists.txt | 5 +- .../HandleDeviceResolver.hpp | 33 ++-- .../KernelIngestorEngine.cpp | 24 +-- .../KernelIngestorEngine.hpp | 26 +-- .../kernels/PointwiseAdd.cpp | 11 +- .../packs/PointwiseAddDispatchHandler.cpp | 6 +- .../packs/PointwiseAddDispatchHandler.hpp | 11 +- .../packs/PointwiseAddMatchers.cpp | 57 ++----- .../packs/PointwiseAddMatchers.hpp | 21 +-- .../packs/PointwiseAddPack.cpp | 29 ++-- .../packs/PointwiseAddPack.hpp | 20 +-- .../packs/PointwiseAddSymbols.hpp | 14 +- .../IntegrationGraphVerificationHarness.hpp | 5 +- .../IntegrationGpuKernelIngestor.cpp | 82 ++++------ .../src/tests/CMakeLists.txt | 8 +- .../src/tests/core/TestContainer.cpp | 4 +- .../TestHandleDeviceResolver.cpp | 78 +++------ .../TestKernelIngestorEngine.cpp | 75 ++++----- .../packs/PointwiseAddTestGraphs.hpp | 57 +++---- .../packs/TestPointwiseAddDispatchHandler.cpp | 72 +++------ .../packs/TestPointwiseAddMatchers.cpp | 70 +++------ .../packs/TestPointwiseAddPack.cpp | 28 ++-- projects/hipdnn/CMakeLists.txt | 6 +- projects/hipdnn/plugin_sdk/CMakeLists.txt | 14 +- .../hipdnn_plugin_sdk/ingestor/Catalog.hpp | 16 +- .../ingestor/Descriptors.hpp | 145 ++++++----------- .../ingestor/GenericEngine.hpp | 26 ++- .../ingestor/GenericPlan.hpp | 20 +-- .../ingestor/GenericPlanBuilder.hpp | 72 +++------ .../ingestor/IDeviceResolver.hpp | 14 +- .../ingestor/IKernelDispatchHandler.hpp | 45 ++---- .../ingestor/IKernelHeuristic.hpp | 46 ++---- .../ingestor/KernelDefinition.hpp | 32 ++-- .../ingestor/KernelIngestorStateManager.hpp | 148 +++++++----------- .../hipdnn_plugin_sdk/ingestor/LruCache.hpp | 21 +-- .../ingestor/MatchContext.hpp | 55 ++----- .../ingestor/NativeRegistry.hpp | 57 +++---- .../tests/TestKernelIngestorConcurrency.cpp | 47 ++---- .../tests/ingestor/IngestorMocks.hpp | 17 +- .../ingestor/KernelIngestorTestFixtures.hpp | 126 +++++++-------- .../tests/ingestor/TestDescriptors.cpp | 19 +-- .../tests/ingestor/TestGenericEngine.cpp | 36 ++--- .../tests/ingestor/TestGenericPlan.cpp | 5 +- .../tests/ingestor/TestGenericPlanBuilder.cpp | 61 +++----- .../tests/ingestor/TestKernelDefinition.cpp | 34 ++-- .../tests/ingestor/TestKernelHeuristic.cpp | 24 +-- .../TestKernelIngestorStateManager.cpp | 148 +++++++----------- .../tests/ingestor/TestLruCache.cpp | 24 +-- .../tests/ingestor/TestMatchContext.cpp | 11 +- .../tests/ingestor/TestNativeRegistry.cpp | 13 +- 53 files changed, 709 insertions(+), 1338 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/CMakeLists.txt b/dnn-providers/hip-kernel-provider/CMakeLists.txt index d9eb77784c4b..861d4c7a68f3 100644 --- a/dnn-providers/hip-kernel-provider/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/CMakeLists.txt @@ -50,9 +50,8 @@ option(HIPKERNELPROVIDER_ENABLE_ROCKE "Build rocKE engine and smoke tests" OFF) option(ENABLE_ASM_SDPA_ENGINE "Build the ASM SDPA engine" ON) option(ENABLE_HIP_MLOPS_ENGINE "Build the HIP kernel engine" ON) -# Gates the generic kernel ingestor (RFC 0017), set by TheRock's -# THEROCK_FLAG_HIPDNN_ENABLE_KERNEL_INGESTOR. The ingestor engine compiles its kernel -# through the HIP kernel engine's compiler, so it is only built alongside that engine. +# Gates the generic kernel ingestor (RFC 0017). The ingestor engine compiles its kernel +# through the HIP kernel engine's compiler, so it only builds alongside that engine. option(HIPDNN_ENABLE_KERNEL_INGESTOR "Build the generic kernel ingestor (RFC 0017)" OFF) option(BUILD_ADDRESS_SANITIZER "Build with Address Sanitizer enabled" OFF) diff --git a/dnn-providers/hip-kernel-provider/src/core/Container.cpp b/dnn-providers/hip-kernel-provider/src/core/Container.cpp index 12e453a6ca26..d420da0ab748 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Container.cpp +++ b/dnn-providers/hip-kernel-provider/src/core/Container.cpp @@ -71,14 +71,14 @@ const std::vector& Container::getEngineDefinitions( }}, #endif #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR - // The kernel ingestor's engine (RFC 0017). Registered statically here - // because engines are still a compile-time table; runtime loading of engine - // descriptors replaces this entry with one built per installed UED file. + // The kernel ingestor's engine (RFC 0017). Registered statically here; runtime + // loading of engine descriptors will replace this entry with one built per + // installed UED file. {kernel_ingestor_engine::pointwiseAddEngineId(), [](const device::IDevicePropertyProvider& /*devicePropertyProvider*/) -> std::unique_ptr> { - // Device facts are resolved per call from the handle rather than captured - // here, so this engine needs nothing from the construction-time provider. + // Device facts are resolved per call from the handle, not from the + // construction-time provider. return kernel_ingestor_engine::makePointwiseAddEngine(); }}, #endif @@ -117,11 +117,8 @@ Container::Container() #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR // Every ingestor pack's native matchers, scorers, and dispatch handlers must be // registered before any descriptor-backed engine below can resolve the symbols its - // UMDs, UHDs, and UDDs name -- including a UED loaded from a file that shares this - // pack's symbols with no compile-time link to this translation unit. Safe to call - // on every Container construction: it registers exactly once for the process, no - // matter how many containers SharedContainerManager builds and tears down over the - // plugin's lifetime. + // UMDs, UHDs, and UDDs name. Safe to call on every Container construction: it + // registers exactly once per process (see SharedContainerManager). kernel_ingestor_engine::registerNativeIngestorSymbols(); #endif diff --git a/dnn-providers/hip-kernel-provider/src/core/Settings.hpp b/dnn-providers/hip-kernel-provider/src/core/Settings.hpp index 67000e6e2589..87dd5c13f980 100644 --- a/dnn-providers/hip-kernel-provider/src/core/Settings.hpp +++ b/dnn-providers/hip-kernel-provider/src/core/Settings.hpp @@ -25,11 +25,8 @@ struct Settings #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR /// The kernel ingestor's per-call knob filter (RFC 0017 §4's catalog -> filter -> - /// rank ordering). See GenericPlanBuilder.hpp's KnobFilter doc for why this lives - /// here rather than being threaded through some other channel: - /// getMaxWorkspaceSize() receives only this Settings object, not an IEngineConfig, - /// so a knob setting reaches it only via a field initializeExecutionSettings() - /// populated first. + /// rank ordering). Populated by initializeExecutionSettings(); getMaxWorkspaceSize() + /// reads it from here since it receives only this Settings object. hipdnn_plugin_sdk::ingestor::KnobFilter ingestorKnobFilter; #endif }; diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt index 9eb2d3401672..2dfb0423e0c3 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/CMakeLists.txt @@ -15,7 +15,6 @@ target_sources(hip_kernel_provider_impl PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/packs/PointwiseAddPack.cpp ) -# The sources above are entirely inside `#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR`, so the -# definition must reach the object library that compiles them, not only the static -# library the tests link. This mirrors how each other engine sets its own gate. +# Sources above are entirely inside `#ifdef HIPDNN_ENABLE_KERNEL_INGESTOR`; the +# definition must reach this object library, not only the static library tests link. target_compile_definitions(hip_kernel_provider_impl PUBLIC HIPDNN_ENABLE_KERNEL_INGESTOR=1) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp index c879d906e7db..169540098655 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/HandleDeviceResolver.hpp @@ -21,18 +21,13 @@ namespace hip_kernel_provider::kernel_ingestor_engine /** * @brief Resolves a call's device from the stream its handle carries. * - * A handle can be rebound between calls, so the device is read per call. Reading it from - * the handle's stream rather than the calling thread's current device is what keeps the - * answer right when several threads drive different handles. + * A handle can be rebound between calls, so the device is read per call from the + * handle's stream rather than the calling thread's current device. * - * Device properties are cached and the cache is never invalidated, so the references it - * hands out stay valid for this resolver's lifetime. Only successful queries are cached: - * a zeroed hipDeviceProp_t is not an answer, and this resolver is process-lifetime, so - * caching one failure would answer wrongly for every later caller. + * Device properties are cached for this resolver's lifetime; the cache is never + * invalidated. Only successful queries are cached. * - * One instance per process (see KernelIngestorEngine.cpp's deviceResolver()). It holds no - * engine state, and two engines asking about one device must agree, so a shared cache is - * the correct scope rather than a convenient one. + * One instance per process (see KernelIngestorEngine.cpp's deviceResolver()). */ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver { @@ -52,10 +47,8 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver if(hipGetDevice(&deviceId) != hipSuccess) { - // Neither 0 nor a throw: 0 is a real ordinal this call never asked about, - // and deviceId() runs under isApplicable(), which EngineManager walks with - // no try/catch -- throwing would deny sibling engines their answer. Matchers - // decline on NO_DEVICE, so the engine simply does not apply. + // Neither 0 nor a throw: deviceId() runs under isApplicable(), which + // EngineManager walks with no try/catch. Matchers decline on NO_DEVICE. return hipdnn_plugin_sdk::ingestor::NO_DEVICE; } return deviceId; @@ -64,8 +57,8 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver const hipDeviceProp_t& deviceProperties(hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const override { - // Reached whenever deviceId() could not name a device, because MatchContext binds - // properties before any matcher runs. Inert values, since no kernel survives. + // Inert values for a call with no resolvable device; MatchContext binds + // properties before any matcher runs, so the caller receives this regardless. if(deviceId == hipdnn_plugin_sdk::ingestor::NO_DEVICE) { static const hipDeviceProp_t s_noDevice{}; @@ -94,11 +87,9 @@ class HandleDeviceResolver : public hipdnn_plugin_sdk::ingestor::IDeviceResolver } protected: - /// Seam for tests that need to grow the cache without that many real devices. The - /// production path is the HIP call; overriding it lets a test supply successful - /// answers for ids this machine does not have, which is the only way to exercise - /// the reference-stability-across-growth invariant now that failures are refused - /// rather than cached. + /// Seam for tests that need to grow the cache without that many real devices; + /// overriding it lets a test supply successful answers for ids this machine + /// does not have. virtual hipError_t queryDeviceProperties(hipDeviceProp_t* properties, hipdnn_plugin_sdk::ingestor::DeviceId deviceId) const { diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp index 241bcfb2b28b..e128b97e28f2 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.cpp @@ -26,14 +26,9 @@ namespace /** * @brief The dispatch handler this pack's UDD resolves to. * - * Process-lifetime, because DispatchRegistry stores a non-owning pointer to it while a - * provider's Container -- and any engine or handler it owns -- is created and destroyed - * per handle (see SharedContainerManager). A function-local static is the only lifetime - * that satisfies that non-owning-pointer contract; see NativeRegistry's doc for why the - * registry itself does not take ownership. - * - * The compiler it holds is a static for the same reason, and matches the module cache - * behind it, which is already process-wide. + * Process-lifetime: DispatchRegistry stores a non-owning pointer to it, while a + * provider's Container is created and destroyed per handle (see + * SharedContainerManager). The compiler it holds is a static for the same reason. */ const PointwiseAddDispatchHandler& dispatchHandler() { @@ -46,8 +41,7 @@ const PointwiseAddDispatchHandler& dispatchHandler() * @brief The device resolver every descriptor-backed engine in this provider shares. * * Container/process-lifetime, not per-engine: it is a device-property cache with no - * engine-specific state (see HandleDeviceResolver's doc), so a per-engine instance - * would only duplicate the cache with no isolation benefit. + * engine-specific state (see HandleDeviceResolver's doc). */ const HandleDeviceResolver& deviceResolver() { @@ -57,8 +51,8 @@ const HandleDeviceResolver& deviceResolver() } // namespace -/// The body registerNativeIngestorSymbols() runs at most once per process; broken out -/// so a test can drive it directly and observe rollback on a forced conflict. +/// Runs the body of registerNativeIngestorSymbols() at most once per process; broken +/// out so a test can drive it directly and observe rollback on a forced conflict. void registerNativeIngestorSymbolsOnce() { registerPointwiseAddMatchers(); @@ -75,8 +69,7 @@ void registerNativeIngestorSymbolsOnce() void registerNativeIngestorSymbols() { - // call_once leaves the flag unset on a throw, so a retry re-runs the body, which - // must roll back what it installed or the retry fails on its own partial state. + // call_once leaves the flag unset on a throw, so a retry re-runs the body. static std::once_flag s_registered; std::call_once(s_registered, registerNativeIngestorSymbolsOnce); } @@ -85,8 +78,7 @@ std::unique_ptr> makePoint { auto set = buildPointwiseAddDescriptorSet(); // Moved out of `set` in its own statement, fully sequenced before the move of the - // (now engine-less) remainder below. makePointwiseAddStateManager() never reads - // set.engine, so its moved-from state here is inert. + // remainder below. auto engine = std::move(set.engine); return std::make_unique>( std::move(engine), makePointwiseAddStateManager(std::move(set)), deviceResolver()); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp index ebac2966417d..58ca2a189c8c 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/KernelIngestorEngine.hpp @@ -20,36 +20,22 @@ namespace hip_kernel_provider::kernel_ingestor_engine * @brief Registers every native matcher, scorer, and dispatch handler this provider's * ingestor packs resolve through, exactly once for the life of the process. * - * Called eagerly from Container's constructor, inside the plugin's create entry point, - * rather than lazily as a side effect of constructing an engine: a UED loaded from a - * descriptor file names a symbol like "hipkernel.pointwise_add.graph_match" with no - * compile-time link to the translation unit that implements it, so nothing can rely on - * registration happening as a byproduct of building one hardcoded engine. - * - * Idempotent across repeated calls: Container is destroyed and rebuilt many times over - * a process's life (see SharedContainerManager's weak_ptr), but the process-wide - * NativeRegistry this populates must be populated only once. + * Called from Container's constructor, so a UED loaded from a descriptor file with no + * compile-time link to the symbol's translation unit still finds it. Idempotent. */ void registerNativeIngestorSymbols(); -/// @brief The once_flag-guarded body of registerNativeIngestorSymbols(), callable -/// directly so a test can force and observe a partial-failure/rollback cycle a second -/// time -- unreachable through the wrapper itself once the flag is set. +/// The once_flag-guarded body of registerNativeIngestorSymbols(), callable directly so +/// a test can force and observe a partial-failure/rollback cycle. void registerNativeIngestorSymbolsOnce(); /** * @brief Builds the descriptor-backed pointwise-add engine. * - * Assembles this pack's descriptor set and hands its engine descriptor and state - * manager to GenericEngine, which already satisfies IEngine end to end. The device - * resolver and dispatch handler this engine's kernels need are process-lifetime + * The device resolver and dispatch handler its kernels need are process-lifetime * statics (see KernelIngestorEngine.cpp), not members. * - * Requires registerNativeIngestorSymbols() to already have run, so a file-loaded UED - * sharing this pack's symbols resolves the same way a hardcoded one does. - * - * This function is what a UED loader replaces: given descriptor files, the same - * generic engine is constructed from parsed data instead of a hardcoded pack. + * Requires registerNativeIngestorSymbols() to have run first. */ std::unique_ptr> makePointwiseAddEngine(); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp index b322509f8dad..51d0e45d87be 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp @@ -1,14 +1,13 @@ // Copyright © Advanced Micro Devices, Inc., or its affiliates. // SPDX-License-Identifier: MIT -// The kernel behind the pointwise-add descriptor pack: elementwise add over a +// Skeleton kernel behind the pointwise-add descriptor pack: elementwise add over a // single-element tensor. // -// HIP_PLUGIN_POINTWISE_ADD_TYPE is the element type, from the kernel descriptor's -// dtype metadata. HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE is the descriptor's block size; -// the kernel itself ignores it (one element needs one thread), but it must still -// reach the compiler so ranking, knob reporting, and per-kernel dispatch have a -// metadata field that genuinely varies and isn't just observed by tests. +// HIP_PLUGIN_POINTWISE_ADD_TYPE is the element type from the kernel descriptor's dtype +// metadata. HIP_PLUGIN_POINTWISE_ADD_BLOCK_SIZE is the descriptor's block size; unused +// by the kernel itself (one element needs one thread), but must reach the compiler for +// ranking and knob reporting. extern "C" __global__ void PointwiseAdd(const HIP_PLUGIN_POINTWISE_ADD_TYPE* a, const HIP_PLUGIN_POINTWISE_ADD_TYPE* b, diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp index 0fa7a4df8ab4..225229a84507 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.cpp @@ -108,8 +108,7 @@ size_t PointwiseAddDispatchHandler::workspaceBytes(const MatchContext& /*context std::unique_ptr PointwiseAddDispatchHandler::prepare( const MatchContext& context, const BoundTokens& bound, const KernelDefinition& kernel) const { - // Reads the operand uids the matcher bound, rather than re-deriving them here with a - // second notion of what this graph looks like. + // Reads the operand uids the matcher bound rather than re-deriving them. const auto binding = pointwiseAddBinding(bound); const auto blockSize @@ -124,8 +123,7 @@ std::unique_ptr PointwiseAddDispatchHandler::prepare( auto program = _kernelCompiler.compile(kernel.source.sourceFile, options); auto runnableKernel = program->getKernel(kernel.source.entryPoint); - // One element, so one workgroup; the block size comes from kernel metadata, which is - // how a per-kernel launch quantity reaches a dispatch descriptor shared by the pack. + // One element, so one workgroup; block size comes from kernel metadata. runnableKernel->setBlockSize(blockSize, 1, 1); runnableKernel->setGridSize(1, 1, 1); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp index 1cb42f64f310..5ee95aad905b 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddDispatchHandler.hpp @@ -21,8 +21,7 @@ namespace hip_kernel_provider::kernel_ingestor_engine /** * @brief The native dispatch behind this pack's UDD: sizes and launches a pointwise add. * - * Holds the provider machinery a launch needs -- a kernel compiler and the device - * properties it compiles against -- which the SDK never names. + * Holds the kernel compiler a launch needs. * * Splits per RFC 0017 §8.5: everything derived from the graph and chosen kernel * resolves once at plan build; execute only resolves device pointers by uid and @@ -35,9 +34,8 @@ class PointwiseAddDispatchHandler public: /// @param kernelCompiler Owned by the engine, which outlives this handler. /// - /// Device properties are not held: they arrive per call on the MatchContext, so a - /// kernel is always compiled for the device the call is actually for rather than - /// for whichever device was current when this handler was built. + /// Device properties are not held; they arrive per call on the MatchContext, so a + /// kernel is compiled for the device the call is actually for. explicit PointwiseAddDispatchHandler(const compilation::IKernelCompiler& kernelCompiler) : _kernelCompiler(kernelCompiler) { @@ -47,8 +45,7 @@ class PointwiseAddDispatchHandler * @brief Scratch this kernel requires. * * A one-element add needs none. The 256-block kernel reports a non-zero - * requirement so the engine's max-across-survivors is observably a maximum - * rather than a constant zero. + * requirement so the engine's max-across-survivors is observably a maximum. */ size_t workspaceBytes(const hipdnn_plugin_sdk::ingestor::MatchContext& context, diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp index 6a93a7fe0739..c56c4713009a 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.cpp @@ -34,18 +34,13 @@ const data_objects::TensorAttributes* findTensor(const MatchContext& context, in return it == tensors.end() ? nullptr : it->second; } -/// The tensor ranks this pack accepts. Not a kernel property (it indexes one element -/// regardless of rank) but a dispatch-path one: compile options derive layout from the -/// tensor and reject anything outside this range, so a matcher that admits a rank -/// dispatch cannot serve turns a free decline into a failed plan build. +/// The tensor ranks this pack accepts; compile options derive layout from the tensor +/// and reject anything outside this range. constexpr uint32_t MIN_SUPPORTED_RANK = 4; constexpr uint32_t MAX_SUPPORTED_RANK = 5; -/// True when the tensor's stride order is one the dispatch path can classify. -/// -/// Compile options derive a layout from the strides and throw on any order that is -/// neither channel-first nor channel-last; a one-element tensor viewing into a larger -/// buffer can still carry an unclassifiable order. +/// True when the tensor's stride order is channel-first or channel-last, the only +/// orders the dispatch path's compile options can classify. bool hasSupportedLayout(const data_objects::TensorAttributes& tensor) { try @@ -55,14 +50,11 @@ bool hasSupportedLayout(const data_objects::TensorAttributes& tensor) } catch(const hipdnn_plugin_sdk::HipdnnPluginException&) { - // Asking the classifier directly keeps this gate and the dispatch path on one - // definition of "supported layout" rather than two that can drift. return false; } } -/// True when the tensor is a supported rank and layout holding exactly one element -- -/// the whole of this pack's supported problem space. +/// True when the tensor is a supported rank and layout holding exactly one element. bool isSingleElement(const data_objects::TensorAttributes& tensor) { const auto* dims = tensor.dims(); @@ -117,19 +109,14 @@ std::string dataTypeName(data_objects::DataType dataType) bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) { - // No device, no launch. The resolver reports NO_DEVICE when it cannot name the - // device this call is for, and every fact below that a kernel would be selected on - // -- including the device properties the compile is configured from -- is - // meaningless without one. Declining here is what keeps that failure a clean "this - // engine does not apply" rather than a property lookup for a device that does not - // exist. + // No device, no launch: every fact this matcher would select on -- including the + // device properties the compile is configured from -- is meaningless without one. if(context.deviceId == hipdnn_plugin_sdk::ingestor::NO_DEVICE) { return false; } - // Exactly one node: a prebuilt kernel serves one complete graph, so anything larger - // is a different problem even if it contains this one. + // Exactly one node: this pack's kernel serves one complete graph. if(context.graph.nodeCount() != 1) { return false; @@ -167,8 +154,7 @@ bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) return false; } - // A virtual tensor never materializes into a device buffer, so it is absent from - // the variant pack findDeviceBuffer resolves at launch. + // A virtual tensor has no device buffer for findDeviceBuffer to resolve at launch. if(inputA->virtual_() || inputB->virtual_() || output->virtual_()) { return false; @@ -183,18 +169,14 @@ bool pointwiseAddGraphMatches(const MatchContext& context, BoundTokens& bound) return false; } - // Uniform dtype across operands. Mixed-precision add is a different kernel, and - // accepting it here would hand one binary operands it cannot read. + // Uniform dtype across operands; mixed precision is a different kernel. if(inputA->data_type() != inputB->data_type() || inputA->data_type() != output->data_type()) { return false; } - // Bind what the launch needs, now that the walk that found it has succeeded. The - // dispatch handler reads these back instead of re-walking the graph, so there is one - // notion of which tensor is which operand rather than two that can drift apart. - // The uid type is already int64_t; what matters is that these land in the - // MetadataValue's integer alternative, which is the one the dispatch side reads. + // Binds operand uids for the dispatch handler to read back rather than re-deriving + // them from the graph. bound[std::string(INPUT_A_TOKEN)] = attributes.in_0_tensor_uid(); bound[std::string(INPUT_B_TOKEN)] = attributes.in_1_tensor_uid().value(); bound[std::string(OUTPUT_TOKEN)] = attributes.out_0_tensor_uid(); @@ -209,26 +191,21 @@ bool pointwiseAddKernelMatches(const MatchContext& context, const KernelDefiniti return false; } - // Pins the kernel's baked element type against the graph's. Without this, the - // graph-level gate would accept an f32 graph and selection could hand it to the - // f16 kernel, which returns wrong numbers rather than failing. + // Pins the kernel's baked dtype against the graph's, so an f32 graph cannot reach + // an f16 kernel and get wrong numbers back. return kernel.getStringMetadata(std::string(DTYPE_FIELD)) == dataTypeName(*dataType); } double pointwiseAddScore(const KernelDefinition& kernel, const MatchContext& /*context*/) { - // A stand-in for a trained model: prefer the larger block size. It gives ranking a - // defined, inspectable winner without pretending to be a performance judgement — - // for a one-element add, no block size is actually better. + // A stand-in for a trained model: prefers the larger block size. return static_cast(kernel.getIntMetadata(std::string(BLOCK_SIZE_FIELD))); } PointwiseAddBinding pointwiseAddBinding(const BoundTokens& bound) { - // Every token was written by the graph matcher that admitted this graph, so a missing - // one -- or one holding something other than the uid this expects -- means the - // catalog was built by a matcher other than ours: a wiring error, not a graph this - // pack should decline. + // Every token was written by the graph matcher that admitted this graph; a missing + // one means the catalog was built by a matcher other than ours. const auto read = [&bound](std::string_view token) { const auto value = hipdnn_plugin_sdk::ingestor::tryGetBoundInt(bound, token); if(!value.has_value()) diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp index 58278005be06..26e03766edfc 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddMatchers.hpp @@ -15,8 +15,7 @@ namespace hip_kernel_provider::kernel_ingestor_engine * @brief Graph-scoped applicability: is this a single-node pointwise ADD over * 1-element tensors? * - * Reads only graph facts, so it is evaluated once per (graph, device); a failure - * disqualifies every kernel in the pack without per-kernel work. + * Evaluated once per (graph, device); a failure disqualifies every kernel in the pack. */ bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context, hipdnn_plugin_sdk::ingestor::BoundTokens& bound); @@ -24,10 +23,8 @@ bool pointwiseAddGraphMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& c /** * @brief Kernel-scoped applicability: does this kernel's dtype match the graph's? * - * Reads kernel metadata, so it is evaluated once per candidate and disqualifies that - * candidate alone. Without this check, the graph-level gate above would accept any - * dtype the pack serves and an f32 graph could reach an f16 binary, which returns - * wrong numbers rather than failing. + * Evaluated once per candidate kernel. Without it, an f32 graph could reach an f16 + * binary and return wrong numbers rather than failing. */ bool pointwiseAddKernelMatches(const hipdnn_plugin_sdk::ingestor::MatchContext& context, const hipdnn_plugin_sdk::ingestor::KernelDefinition& kernel); @@ -47,22 +44,18 @@ struct PointwiseAddBinding /** * @brief Re-reads the operand bindings a match established. * - * The matcher proves the graph is a pointwise add and identifies which tensor is - * which; dispatch reads those same uids here rather than re-deriving them. - * * @throws HipdnnPluginException if the graph is not one this matcher accepts. */ PointwiseAddBinding pointwiseAddBinding(const hipdnn_plugin_sdk::ingestor::BoundTokens& bound); /// @brief Registers this pack's matchers and scorer under their symbol names. /// -/// Called once, from the pack's translation unit, rather than at static-init time. -/// Atomic: if one symbol is already taken, whichever of the others this call already -/// installed is unregistered before it rethrows. +/// Called once, from the pack's translation unit. Atomic: if one symbol is already +/// taken, any symbols this call already installed are unregistered before it rethrows. void registerPointwiseAddMatchers(); -/// @brief Undoes registerPointwiseAddMatchers(), for a caller that must roll this pack -/// back after a later registration step of its own fails. +/// @brief Undoes registerPointwiseAddMatchers(), for a caller rolling this pack back +/// after a later registration step of its own fails. void unregisterPointwiseAddMatchers(); } // namespace hip_kernel_provider::kernel_ingestor_engine diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp index 7c1b74829e64..40061be8de0f 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.cpp @@ -23,10 +23,8 @@ using hipdnn_flatbuffers_sdk::utilities::parseUuid; namespace { -// Descriptor ids are stable GUIDs, minted once for this pack and never regenerated: -// other descriptors name these by id, so changing one silently breaks a cross-reference. -// Authored descriptors will carry the same values as text and reach this form through -// parseUuid(). +// Descriptor ids are stable GUIDs, minted once for this pack and never regenerated. +// Authored descriptors will carry the same values as text, parsed with parseUuid(). const DescriptorId ENGINE_ID = parseUuid("8f1a6c30-7d24-4a0e-9b51-2c6d84f0a911"); const DescriptorId SCHEMA_ID = parseUuid("b27e4d15-3f89-4c62-8a07-51de93b4c7a2"); const DescriptorId HEURISTIC_ID = parseUuid("4c9b0e72-16a5-4d38-b6f4-8e20a7c15d63"); @@ -44,7 +42,7 @@ KernelDescriptor makeKernel(const DescriptorId& id, KernelDescriptor kernel; kernel.id = id; kernel.name = "pointwise_add." + variant; - // The only KernelSourceKind this POC implements; see KernelSource's doc for the others. + // The only KernelSourceKind this pack implements; see KernelSource's doc for the others. kernel.source.sourceFile = "PointwiseAdd.cpp"; kernel.source.entryPoint = "PointwiseAdd"; kernel.metadata = {{std::string(BLOCK_SIZE_FIELD), MetadataValue{blockSize}}, @@ -61,8 +59,8 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() set.schema.id = SCHEMA_ID; set.schema.name = "pointwise add variant fields"; - // block_size defaults, so a kernel may omit it; dtype does not, since a kernel that - // omits it would silently inherit another's. + // block_size defaults; dtype does not, since an omitted one would silently inherit + // another kernel's. set.schema.fields = {{std::string(BLOCK_SIZE_FIELD), MetadataType::INT, int64_t{64}}, {std::string(DTYPE_FIELD), MetadataType::STRING, std::nullopt}}; @@ -74,11 +72,10 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() set.engine.name = ENGINE_NAME; set.engine.heuristicId = HEURISTIC_ID; set.engine.metadataSchemaId = SCHEMA_ID; - // block_size is exposed to the caller; dtype is not, since it is pinned by the - // graph rather than chosen. + // block_size is exposed to the caller; dtype is pinned by the graph, not chosen. set.engine.knobs = {std::string(BLOCK_SIZE_FIELD)}; - // True of this engine today: its dispatch handler compiles through hiprtc at plan - // build. A pack shipping prebuilt kernels would not declare this. + // This engine's dispatch handler compiles through hiprtc at plan build; a pack + // shipping prebuilt kernels would not declare this. set.engine.behaviorNotes = {HIPDNN_BEHAVIOR_NOTE_RUNTIME_COMPILATION}; set.matchers = { @@ -102,9 +99,8 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() pack.matcherIds = {GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}; pack.engineId = ENGINE_ID; pack.dispatchId = DISPATCH_ID; - // Three kernels: the two FLOAT entries differ only in block size, giving ranking an - // order and the block_size knob a real value set; the HALF entry is what the - // kernel-scoped matcher prunes on a FLOAT graph. + // The two FLOAT entries differ only in block size, giving ranking an order; the + // HALF entry is what the kernel-scoped matcher prunes on a FLOAT graph. pack.kernels = {makeKernel( parseUuid("2f8c17d6-4a90-4b53-9e18-c05a63b782d4"), "f32_block64", 64, "FLOAT", 0), @@ -119,9 +115,8 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet() int64_t pointwiseAddEngineId() { - // Registers the engine name against its id on first call, so diagnostics can print - // a name instead of a hex value and a colliding descriptor is named. See the header - // for why this is a function-local static rather than a namespace-scope object. + // Registers the engine name against its id on first call; see the header for why + // this is a function-local static. static const hipdnn_data_sdk::utilities::EngineRegistrar s_registrar{ENGINE_NAME}; return hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME); } diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp index 77c54f5f022c..e3563010e824 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddPack.hpp @@ -21,13 +21,9 @@ namespace hip_kernel_provider::kernel_ingestor_engine * @file PointwiseAddPack.hpp * @brief The pointwise-add descriptor set, built in memory. * - * Stands in for what a loader will produce from installed files: one engine (UED) with + * Stands in for what a loader will produce from installed files: one engine (UED), * its metadata schema (KMD) and heuristic (UHD), two matchers (UMDs), one dispatch * descriptor (UDD), and one pack (KDP) binding them over three kernels (UKDs). - * - * Three kernels: two differ only in block size, giving ranking an order to produce and - * the knob a value set of two; the third targets a different dtype, giving the - * kernel-scoped matcher something to prune. */ /// A pack plus every descriptor it references by id, ready to construct a state manager. @@ -47,12 +43,10 @@ PointwiseAddDescriptorSet buildPointwiseAddDescriptorSet(); /** * @brief This engine's hipDNN engine id, registering its name on first call. * - * A descriptor-backed engine's id is its UED name hashed into hipDNN's engine-id - * space, registered at run time since the engine is defined by data. - * - * Reached through a function-local static, not a namespace-scope object: the - * registrar throws on a collision, and a throw from a namespace-scope initializer - * would escape a global constructor during dlopen() and terminate the process. + * The id is the UED name hashed into hipDNN's engine-id space, registered at run time + * since the engine is defined by data. A function-local static, not a namespace-scope + * object: the registrar throws on a collision, which from a global constructor during + * dlopen() would terminate the process. */ int64_t pointwiseAddEngineId(); @@ -61,8 +55,8 @@ int64_t pointwiseAddEngineId(); * descriptor set. * * Takes @p set by value so a caller building both the engine and its state manager - * calls buildPointwiseAddDescriptorSet() exactly once. @p set's UED is ignored: a - * UED is 1:1 with a hipDNN engine and owned by the engine, not this state manager. + * calls buildPointwiseAddDescriptorSet() once. @p set's UED is ignored; the engine + * owns it. */ std::unique_ptr> makePointwiseAddStateManager(PointwiseAddDescriptorSet set); diff --git a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp index 9b200899e1fb..b5d97730bc81 100644 --- a/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp +++ b/dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/PointwiseAddSymbols.hpp @@ -11,12 +11,11 @@ namespace hip_kernel_provider::kernel_ingestor_engine { /** - * @file NativeSymbolNames.hpp + * @file PointwiseAddSymbols.hpp * @brief The symbol names this pack's descriptors resolve through. * - * A descriptor names a native implementation by symbol; the provider registers the - * implementation under the same name. Each constant here is a placeholder for a - * declarative descriptor field once the follow-up RFCs land. + * Each constant is a placeholder for a declarative descriptor field once the + * follow-up RFCs land. */ /// Graph-scoped applicability: a single-node pointwise ADD over 1-element tensors. @@ -34,9 +33,8 @@ inline constexpr std::string_view DISPATCH_SYMBOL = "hipkernel.pointwise_add.dis /** * @brief The engine name, hashed into hipDNN's engine-id space. * - * Scoped with a prefix per RFC 0017's global-uniqueness requirement. Absent from - * EngineNames.hpp's registry: a descriptor-backed engine registers its name at - * construction, not at build time. + * Prefixed per RFC 0017's global-uniqueness rule. Absent from EngineNames.hpp's + * registry: registered at construction, not at build time. */ inline constexpr std::string_view ENGINE_NAME = "hipkernel:PointwiseAdd"; @@ -47,7 +45,7 @@ inline constexpr std::string_view DTYPE_FIELD = "dtype"; /** * @brief Tokens the graph matcher binds for the dispatch handler to read. * - * The native stand-in for the `$`-prefixed names a descriptor's dispatch formulas + * Native stand-in for the `$`-prefixed names a descriptor's dispatch formulas * would reference (RFC 0017 §5). */ inline constexpr std::string_view INPUT_A_TOKEN = "pointwise_add.input_a.uid"; diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp b/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp index 2bd6efec18b3..5b8fff1cbabe 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/IntegrationGraphVerificationHarness.hpp @@ -164,9 +164,8 @@ class IntegrationGraphVerificationHarness : public ::testing::TestWithParam makeScalarTensor(int64_t uid, const std::string& name) @@ -78,7 +72,7 @@ std::shared_ptr makeScalarTensor(int64_t uid, const std::strin } /// A single pointwise-ADD node over 1-element FLOAT tensors: the one shape this pack -/// accepts. Each call returns a fresh graph so tests never share build/plan state. +/// accepts. Each call returns a fresh graph, never shared build/plan state. std::shared_ptr buildPointwiseAddGraph() { auto graph = std::make_shared(); @@ -123,8 +117,7 @@ std::shared_ptr buildUnsupportedGraph() return graph; } -/// One case per execute() shape: a single call, and several reusing the same built -/// plan. Both must produce numerically correct results; only the second proves reuse. +/// Execute shapes: a single call, and several reusing the same built plan. struct ExecuteCase { std::string name; @@ -144,8 +137,7 @@ class IntegrationGpuKernelIngestor } /// Builds `graph`, pins this pack via set_preferred_engine_id_ext() before plan - /// creation (the precedent at IntegrationGraphEngineFiltering.cpp:159), and - /// compiles a plan with default knobs. Returns once the plan is ready to execute. + /// creation, and compiles a plan with default knobs. void buildAndCompile(Graph& graph) { graph.set_preferred_engine_id_ext(engineId()); @@ -164,8 +156,8 @@ class IntegrationGpuKernelIngestor } /// Builds fresh CPU/GPU tensor bundles for `graph`, executes it once on GPU with - /// `workspace`, and verifies the result against CpuReferenceGraphExecutor. `seed` - /// varies the input values so repeated calls never compare against stale buffers. + /// `workspace`, and verifies against CpuReferenceGraphExecutor. `seed` varies the + /// input values so repeated calls never compare against stale buffers. void executeAndVerify(Graph& graph, void* workspace, unsigned int seed) { GraphTensorBundle gpuBundle; @@ -187,13 +179,9 @@ class IntegrationGpuKernelIngestor }); for(auto& [uid, tensor] : gpuBundle.tensors) { - // Per-uid, not one seed for every tensor: randomizeTensor builds a fresh - // mt19937 from the seed it is handed, so a shared seed makes every operand - // byte-identical and allClose() below is then satisfied by a+a or b+b just - // as well as by a+b -- it would no longer witness that the handler resolved - // its arguments by tensor uid, which is the property this harness exists to - // check. Offsetting by the uid is what PointwiseTensorBundles.hpp does - // (seed, seed + 1) for the same reason. + // Offset per uid: randomizeTensor seeds a fresh mt19937, so a shared seed + // makes every operand byte-identical and allClose() would pass on a+a as + // readily as a+b, no longer witnessing uid-based argument resolution. const auto tensorSeed = seed + static_cast(uid); gpuBundle.randomizeTensor(uid, -4.0f, 4.0f, tensorSeed); cpuBundle.randomizeTensor(uid, -4.0f, 4.0f, tensorSeed); @@ -212,9 +200,8 @@ class IntegrationGpuKernelIngestor auto& gpuOut = gpuBundle.getTensor(3); auto& cpuOut = cpuBundle.getTensor(3); gpuOut.markDeviceModified(); - // The whole chain, end to end: the matchers admitted this graph, the heuristic - // ranked the catalog, the dispatch descriptor's handler compiled and launched - // the winner, and its arguments were resolved by tensor uid. + // Proves the full chain: matcher admission, heuristic ranking, handler compile + // and launch, and argument resolution by tensor uid. EXPECT_TRUE(CpuFpReferenceValidation().allClose(cpuOut, gpuOut)); } }; @@ -223,10 +210,8 @@ class IntegrationGpuKernelIngestor // Direct ABI: load-time self-registration // --------------------------------------------------------------------------- -// The only assertion with no frontend equivalent: the frontend never lists "every -// engine this plugin exports", only engines ranked for a graph that was built. Proving -// the pack self-registers at load time, independent of any graph, still needs the raw -// C ABI -- kept minimal rather than reusing the deleted PluginApi wrapper. +// The frontend never lists "every engine this plugin exports", so proving load-time +// self-registration needs the raw C ABI. TEST(IntegrationGpuKernelIngestorDirectAbi, SelfRegistersAllEngineIds) { const std::filesystem::path pluginTarget(PLUGIN_PATH); @@ -272,8 +257,6 @@ TEST_F(IntegrationGpuKernelIngestor, AcceptsTheGraphItsDescriptorsDescribe) TEST_F(IntegrationGpuKernelIngestor, DeclinesATwoNodeGraph) { - // Declining is free: no engine config comes back and hipDNN moves on. Getting this - // wrong is what turns a cheap decline into a failed plan build. auto graph = buildUnsupportedGraph(); auto result = graph->build_operation_graph(_handle); @@ -302,16 +285,14 @@ TEST_F(IntegrationGpuKernelIngestor, ReportsAKnobWhoseValuesComeFromTheCatalog) ASSERT_EQ(knobs.size(), 1U); EXPECT_EQ(knobs[0].knobId(), BLOCK_SIZE_KNOB); - // The pack ships three kernels, but the HALF one is pruned for this FLOAT graph, so - // the knob offers exactly the two block sizes the surviving kernels implement -- - // never the schema's theoretical range. + // The pack ships three kernels; the HALF one is pruned for this FLOAT graph, so the + // knob offers exactly the two block sizes the surviving kernels implement. const auto* constraint = dynamic_cast(knobs[0].constraint()); ASSERT_NE(constraint, nullptr); const auto& validValues = constraint->getValidValues(); EXPECT_EQ(validValues, (std::unordered_set{64, 256})); - // The default is whatever the heuristic ranked first, so leaving the knob alone - // reproduces the out-of-the-box selection. + // The default is whatever the heuristic ranked first. const auto* defaultValue = std::get_if(&knobs[0].defaultValue()); ASSERT_NE(defaultValue, nullptr); EXPECT_EQ(*defaultValue, 256); @@ -326,8 +307,8 @@ TEST_F(IntegrationGpuKernelIngestor, ReportsTheMaximumWorkspaceAcrossSurvivingKe auto graph = buildPointwiseAddGraph(); buildAndCompile(*graph); - // One surviving kernel declares 0 bytes and the other 1024, so this answer proves - // the query aggregates across the catalog rather than reporting one kernel's value. + // One surviving kernel declares 0 bytes and the other 1024, so this proves the + // query aggregates across the catalog rather than reporting one kernel's value. int64_t workspaceSize = 0; auto result = graph->get_workspace_size(workspaceSize); ASSERT_EQ(result.code, ErrorCode::OK) << result.err_msg; @@ -338,10 +319,8 @@ TEST_F(IntegrationGpuKernelIngestor, ReportsTheMaximumWorkspaceAcrossSurvivingKe // Plan build and execute // --------------------------------------------------------------------------- -// Collapses ExecutesTheSelectedKernelOnDevice and ReusesOnePlanAcrossExecutions from -// the original file: one case executes once, the other executes the same compiled -// plan repeatedly with fresh buffers each time. Every decision the plan makes happens -// at build, so execution must never depend on the previous call. +// Every decision the plan makes happens at build, so execution must never depend on +// the previous call. TEST_P(IntegrationGpuKernelIngestor, ExecutesTheSelectedKernelOnDevice) { const auto& testCase = GetParam(); @@ -359,13 +338,10 @@ TEST_P(IntegrationGpuKernelIngestor, ExecutesTheSelectedKernelOnDevice) } } -// hipDNN never exposes a caller-settable graph identity through the frontend: // GraphDescriptor::finalize() always synthesizes a fresh UUID, so every graph built -// through Graph::pointwise() is a guaranteed catalog miss relative to every other one. -// This is the frontend-reachable form of the original file's uncacheable-graph -// coverage: two independently built graphs, each compiled and executed in the same -// test body, both must still match the reference executor -- proving the recompute -// path is correct rather than accidentally relying on state left by a prior graph. +// through the frontend is a guaranteed catalog miss relative to every other one. Two +// independently built graphs must both match the reference executor, proving the +// recompute path is correct rather than relying on state left by a prior graph. TEST_F(IntegrationGpuKernelIngestor, ExecutesTwoIndependentlyBuiltGraphsCorrectly) { auto graphA = buildPointwiseAddGraph(); diff --git a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt index 178762d38f5b..8e2a59a5d258 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt @@ -31,11 +31,9 @@ add_executable( include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/KernelEmbedding.cmake") add_kernels_for_embedding(TARGET hip_kernel_provider_tests FILES ${CMAKE_CURRENT_SOURCE_DIR}/kernels/vector_add.cpp) -# The kernel ingestor's dispatch handler compiles this kernel at plan-build time, and -# kernel sources are embedded per target, so the test binary needs its own copy. -# It is embedded from a configure-time copy rather than from the original path: a source -# file registered for embedding in two directories lands in both directories' -# CMAKE_CONFIGURE_DEPENDS, which Ninja rejects as two rules generating one file. +# Test binary needs its own copy (embedding is per-target). Copied via configure_file: +# registering the same source for embedding in two directories doubles its +# CMAKE_CONFIGURE_DEPENDS entry, which Ninja rejects as two rules for one file. if(HIPDNN_ENABLE_KERNEL_INGESTOR) configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/../engines/kernel_ingestor_engine/kernels/PointwiseAdd.cpp diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp index b2be52611c9e..15d4983e7f94 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestContainer.cpp @@ -119,9 +119,7 @@ TEST(TestContainer, GetApplicableEngineIdsSdpaGraph) #ifdef HIPDNN_ENABLE_KERNEL_INGESTOR TEST(TestContainer, GetApplicableEngineIdsPointwiseAddGraph) { - // Applicability is device-resolved: with no device the matchers decline, so an - // accept can only be asserted where there is one. Matches the SDPA applicability - // test above. + // Applicability is device-resolved: with no device, matchers decline. SKIP_IF_NO_DEVICES(); using namespace hip_kernel_provider::kernel_ingestor_engine; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp index a5996835eea9..46bf5568c7e7 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestHandleDeviceResolver.cpp @@ -19,34 +19,20 @@ * @file TestHandleDeviceResolver.cpp * @brief HandleDeviceResolver's device-id resolution and its device-properties cache. * - * Now a process-lifetime static (see KernelIngestorEngine.cpp's deviceResolver()) rather - * than a per-engine member, which makes its cache semantics load-bearing rather than - * incidental: the same instance answers for every engine and every container this - * process ever builds, so a stale entry or an invalidated reference is a - * process-lifetime bug, not one bounded by an engine's or a container's lifetime. + * The resolver is a process-lifetime static (see KernelIngestorEngine.cpp's + * deviceResolver()), so a stale or invalidated cache entry is a process-lifetime bug, + * not one scoped to a single engine or container. * - * deviceId() has four paths per its own doc: a null stream falls through to the current - * device, a non-null stream resolves via hipStreamGetDevice, a stream hipStreamGetDevice - * cannot resolve falls through the same way a null one does, and a process with no - * usable HIP context at all is refused rather than defaulted to device 0. The first - * three are driven below; the fourth requires a HIP runtime with no usable context, - * which is not something a test can put this process into without faking the HIP - * runtime itself, so it is a defensive branch with no test hook here -- documented, - * not exercised. - * - * deviceProperties() refuses to cache a failed query, so the growth test below supplies - * successful answers through the resolver's own query seam rather than leaning on - * invalid device ids, which no longer produce cache entries. + * deviceId() has four paths; the fourth (no usable HIP context) has no test hook here + * and is documented but not exercised. */ namespace { using hip_kernel_provider::kernel_ingestor_engine::HandleDeviceResolver; -/// Answers every property query successfully, whatever the device id, so a test can -/// grow the cache past a rehash without needing that many physical devices. The -/// production class refuses to cache a failed query, so invalid ids no longer produce -/// entries at all -- this seam is how the growth invariant stays testable on one GPU. +/// Answers every property query successfully, so a test can grow the cache past a +/// rehash without needing many physical devices. class FakeQueryResolver : public HandleDeviceResolver { public: @@ -95,9 +81,7 @@ TEST(TestHandleDeviceResolver, ResolvesTheStreamsOwnDeviceWhenItDiffersFromCurre { SKIP_IF_NO_DEVICES(); - // A real, non-null stream resolves through hipStreamGetDevice rather than through - // whichever device happens to be current on the calling thread -- the property that - // makes the answer correct when several threads drive different handles. + // Resolves via hipStreamGetDevice, not whichever device is current on this thread. hipStream_t stream = nullptr; ASSERT_EQ(hipStreamCreate(&stream), hipSuccess); @@ -117,10 +101,9 @@ TEST(TestHandleDeviceResolver, FallsThroughToTheCurrentDeviceWhenTheStreamCannot { SKIP_IF_NO_DEVICES(); - // A stream hipStreamGetDevice cannot resolve (here, one already destroyed) must not - // propagate that failure: it falls through to the current device exactly as a null - // stream does. Destroying the stream also leaves a genuine HIP error pending, which - // this test clears so it does not fail an unrelated test via HipErrorHandler. + // A stream hipStreamGetDevice cannot resolve (already destroyed) falls through like + // a null stream. Destroying it leaves a pending HIP error, cleared below so it does + // not fail an unrelated test. hipStream_t stream = nullptr; ASSERT_EQ(hipStreamCreate(&stream), hipSuccess); ASSERT_EQ(hipStreamDestroy(stream), hipSuccess); @@ -148,10 +131,8 @@ TEST(TestHandleDeviceResolver, CachesDevicePropertiesAcrossCalls) const HandleDeviceResolver resolver; - // A cache miss followed by a hit for the same device must answer identically -- - // same address, since deviceProperties() promises a reference stable for the - // resolver's lifetime, and a fresh lookup that reallocated would break that promise - // silently rather than answering wrong. + // A hit for the same device returns the same address: deviceProperties() promises a + // reference stable for the resolver's lifetime. const auto& first = resolver.deviceProperties(0); const auto& second = resolver.deviceProperties(0); @@ -161,23 +142,16 @@ TEST(TestHandleDeviceResolver, CachesDevicePropertiesAcrossCalls) TEST(TestHandleDeviceResolver, ReferencesStayValidAcrossCacheGrowth) { - // This is the invariant HandleDeviceResolver's own doc claims: "the cache hands out - // references that stay valid for this resolver's lifetime... entries are never - // erased or rehashed away: node handles in std::unordered_map keep referenced values - // pinned across growth." Verified directly here rather than trusted from the - // container choice, since a future change to a different container (e.g. a - // node-invalidating flat map) would silently break every caller holding a reference - // across a later insert. + // Verifies HandleDeviceResolver's stated invariant: references stay valid across + // cache growth (std::unordered_map keeps node handles stable across rehash). // - // Faked successful queries rather than invalid device ids: deviceProperties() no - // longer caches a failed query, so invalid ids would insert nothing and the map - // would never grow. CPU-only -- no real device is touched. + // Uses FakeQueryResolver rather than invalid device ids, since deviceProperties() + // does not cache failed queries. CPU-only. const FakeQueryResolver resolver; const auto& firstInserted = resolver.deviceProperties(1000); - // Enough insertions to force at least one rehash of a typical unordered_map's - // default bucket count. + // Enough insertions to force a rehash of the default bucket count. for(int deviceId = 1001; deviceId < 1064; ++deviceId) { static_cast(resolver.deviceProperties(deviceId)); @@ -185,17 +159,14 @@ TEST(TestHandleDeviceResolver, ReferencesStayValidAcrossCacheGrowth) const auto& sameEntryAfterGrowth = resolver.deviceProperties(1000); EXPECT_EQ(&firstInserted, &sameEntryAfterGrowth); - // The entry still holds what it was inserted with, not a neighbour's value moved - // over it: an address that survived a rehash is necessary but not sufficient. + // Value must match too -- a surviving address alone is not sufficient proof. EXPECT_EQ(sameEntryAfterGrowth.warpSize, 1000); } TEST(TestHandleDeviceResolver, RefusesAndDoesNotCacheAFailedPropertyQuery) { - // A zeroed hipDeviceProp_t is not a usable answer, and this cache is never - // invalidated: caching one failure would answer wrongly for every later caller - // asking about that device for the life of the process. Before the resolver became - // process-lifetime a container cycle cleared such an entry; nothing does now. + // A zeroed hipDeviceProp_t is not cached: this cache is never invalidated, so a + // false answer would persist for the process's life. const FailingQueryResolver resolver; EXPECT_THROW(static_cast(resolver.deviceProperties(7)), @@ -207,11 +178,8 @@ TEST(TestHandleDeviceResolver, RefusesAndDoesNotCacheAFailedPropertyQuery) TEST(TestHandleDeviceResolver, ConcurrentDevicePropertyLookupsAreSafe) { - // The cache is mutex-guarded because registration and lookup can race (see the - // class's own doc); this drives that path directly rather than trusting the lock is - // exercised by incidental test-suite parallelism. Every thread asks about the same - // small set of device ids so a real race has many chances to corrupt the map if the - // lock were missing. + // The cache is mutex-guarded (see class doc); many threads querying the same small + // id set maximize a real race's chance to corrupt the map. const FakeQueryResolver resolver; std::vector threads; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp index c19519db63d0..1a3a1b3ca8b4 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/TestKernelIngestorEngine.cpp @@ -28,17 +28,11 @@ /** * @file TestKernelIngestorEngine.cpp - * @brief What now exists in place of the deleted PointwiseAddEngine forwarding wrapper: - * registerNativeIngestorSymbols() and makePointwiseAddEngine(). - * - * There is no engine class left to test directly -- GenericEngine already satisfies IEngine end to end, and it is the SDK's own type, covered - * by the SDK's own suite. What is this provider's to test is that the factory wires it - * up correctly: the resulting engine answers every IEngine method for a graph this - * pack's matchers accept, and declines one they do not. Reached through Container and - * EngineManager rather than constructed piecemeal, since makePointwiseAddEngine() itself - * takes no seams a test could inject through -- the device resolver and dispatch - * handler it wires up are process-lifetime statics (see KernelIngestorEngine.cpp). + * @brief Tests registerNativeIngestorSymbols() and makePointwiseAddEngine(), which + * replace the deleted PointwiseAddEngine forwarding wrapper. GenericEngine + * itself is the SDK's type and covered by the SDK's suite; this file only + * verifies the factory wires it up correctly, reached through Container and + * EngineManager since makePointwiseAddEngine() takes no injectable seams. */ namespace { @@ -56,9 +50,8 @@ GraphWrapper wrap(const flatbuffers::FlatBufferBuilder& builder) return GraphWrapper(builder.GetBufferPointer(), builder.GetSize()); } -/// Stubs a config naming this pack's engine and setting no knobs -- the shape -/// getMaxWorkspaceSize() and initializeExecutionContext() both key their engine lookup -/// on, per EngineManager::getEngine(engineConfig.engineId()). +/// Stubs a config naming this pack's engine, keyed the way getMaxWorkspaceSize() and +/// initializeExecutionContext() both look it up: EngineManager::getEngine(engineId()). void stubAsThisEnginesConfig(MockEngineConfig& config) { EXPECT_CALL(config, isValid()).WillRepeatedly(::testing::Return(false)); @@ -71,11 +64,8 @@ void stubAsThisEnginesConfig(MockEngineConfig& config) TEST(TestKernelIngestorEngine, RegisterNativeIngestorSymbolsIsIdempotentAcrossRepeatedCalls) { - // once_flag-guarded (see its own doc): Container's constructor calls this on every - // Container built over the process's life (SharedContainerManager's weak_ptr means - // that can be many times), so a second call must be a silent no-op rather than a - // throw on the duplicate registration NativeRegistry::registerSymbol() would - // otherwise report. + // once_flag-guarded; Container's constructor calls this on every Container built + // (may be many, via SharedContainerManager's weak_ptr), so repeats must be a no-op. EXPECT_NO_THROW(registerNativeIngestorSymbols()); EXPECT_NO_THROW(registerNativeIngestorSymbols()); EXPECT_NO_THROW(registerNativeIngestorSymbols()); @@ -85,9 +75,8 @@ TEST(TestKernelIngestorEngine, RegistrationFailureReportsTheSameCauseOnRetryAfte { using hipdnn_plugin_sdk::ingestor::DispatchRegistry; - // Container's constructor already ran registration once for the process; - // unregister to drive controlled attempts, and always restore -- every other test - // resolves matching and dispatch through these symbols. + // Unregisters to drive controlled attempts; every other test relies on these + // symbols for matching and dispatch, so always restore. unregisterPointwiseAddMatchers(); DispatchRegistry::unregisterSymbol(std::string(DISPATCH_SYMBOL)); struct RestoreGuard @@ -98,9 +87,8 @@ TEST(TestKernelIngestorEngine, RegistrationFailureReportsTheSameCauseOnRetryAfte } } const restoreGuard; - // Occupies the dispatch symbol, so registerNativeIngestorSymbolsOnce() succeeds on - // every matcher and fails only on dispatch -- the partial-failure shape the - // rollback exists for. + // Occupies the dispatch symbol so registration succeeds on every matcher and fails + // only on dispatch -- the partial-failure shape rollback exists for. DispatchRegistry::registerSymbol(std::string(DISPATCH_SYMBOL), nullptr); const auto attempt = [] { @@ -120,7 +108,7 @@ TEST(TestKernelIngestorEngine, RegistrationFailureReportsTheSameCauseOnRetryAfte const auto secondMessage = attempt(); // Without rollback, the first attempt's matcher symbols stay registered and the - // second attempt fails on one of those instead -- a different, misleading cause. + // second attempt fails on a different, misleading cause. EXPECT_EQ(firstMessage, secondMessage); DispatchRegistry::unregisterSymbol(std::string(DISPATCH_SYMBOL)); @@ -135,9 +123,8 @@ TEST(TestKernelIngestorEngine, MakePointwiseAddEngineIsReachableWithTheDescripto Container container; auto& engineManager = container.getEngineManager(); - // The engine table keys this entry on pointwiseAddEngineId(), which registers the - // engine's name on first call; getAllEngineIds() proves the factory above actually - // installed an engine under that id rather than merely compiling. + // pointwiseAddEngineId() registers the engine's name on first call; + // getAllEngineIds() proves the factory installed it, not just compiled. const auto allEngineIds = engineManager.getAllEngineIds(); EXPECT_NE(std::find(allEngineIds.begin(), allEngineIds.end(), pointwiseAddEngineId()), allEngineIds.end()); @@ -145,9 +132,8 @@ TEST(TestKernelIngestorEngine, MakePointwiseAddEngineIsReachableWithTheDescripto TEST(TestKernelIngestorEngine, IsApplicableAcceptsAGraphThisPacksMatchersAccept) { - // Applicability resolves the call's device and matches on its properties, and the - // matchers decline outright when no device can be named -- so an accept is only - // meaningful where there is a device to accept for. + // Matchers decline outright with no device resolved, so an accept is only + // meaningful where there is one. SKIP_IF_NO_DEVICES(); Container container; @@ -163,8 +149,7 @@ TEST(TestKernelIngestorEngine, IsApplicableAcceptsAGraphThisPacksMatchersAccept) TEST(TestKernelIngestorEngine, GetEngineDetailsReportsTheBlockSizeKnob) { - // The reported knob's value set comes from the catalog this graph matched, which is - // empty without a device (see the applicability test above). + // The reported knob set comes from the matched catalog, empty without a device. SKIP_IF_NO_DEVICES(); Container container; @@ -194,19 +179,16 @@ TEST(TestKernelIngestorEngine, GetMaxWorkspaceSizeReportsTheLargerBlocksRequirem MockEngineConfig engineConfig; stubAsThisEnginesConfig(engineConfig); - // The catalog's two surviving FLOAT kernels report 0 and 1024 bytes; the engine's - // answer is a maximum across whichever kernels the graph admits, not a fixed value, - // so this also proves the workspace query reaches the real catalog rather than a - // stub. + // The catalog's two surviving FLOAT kernels report 0 and 1024 bytes; the answer is + // a max across admitted kernels, proving the query reaches the real catalog. const auto workspaceSize = engineManager.getMaxWorkspaceSize(handle, wrap(graph), engineConfig); EXPECT_EQ(workspaceSize, 1024U); } TEST(TestKernelIngestorEngine, InitializeExecutionContextBuildsAPlanForTheTopRankedKernel) { - // Builds a plan, which compiles the selected kernel through hiprtc -- so unlike the - // applicability and knob-reporting tests above, this one needs a device. Every - // device-touching test in this engine's suite guards the same way. + // Builds a plan by compiling the selected kernel through hiprtc, so unlike the + // tests above this needs a device. SKIP_IF_NO_DEVICES(); Container container; @@ -222,10 +204,8 @@ TEST(TestKernelIngestorEngine, InitializeExecutionContextBuildsAPlanForTheTopRan engineManager.initializeExecutionContext(handle, wrap(graph), engineConfig, context)); ASSERT_TRUE(context.hasValidPlan()); - // Which kernel, not merely that there is one: pointwiseAddScore ranks on block size - // and the graph admits both FLOAT kernels, so the 256 entry is the defined winner. - // Asserting only hasValidPlan() would pass just as well if selection handed back the - // 64 kernel, which is the property this test is named for. + // pointwiseAddScore ranks on block size and both FLOAT kernels are admitted, so 256 + // is the defined winner -- hasValidPlan() alone would not catch a wrong selection. const auto& plan = dynamic_cast&>(context.plan()); EXPECT_EQ(plan.kernel().getIntMetadata(std::string(BLOCK_SIZE_FIELD)), 256); @@ -241,9 +221,8 @@ TEST(TestKernelIngestorEngine, DeclinesAGraphThisPacksMatchersRefuse) auto& engineManager = container.getEngineManager(); Handle handle; - // A multiplication, not this pack's add: refused at the graph-scoped matcher, the - // same refusal TestPointwiseAddGraphMatcherRefusal covers directly, exercised here - // through the full engine rather than the bare matcher function. + // A multiplication is refused at the graph-scoped matcher (also covered directly by + // TestPointwiseAddGraphMatcherRefusal), exercised here through the full engine. const auto graph = buildPointwiseGraph(hipdnn_flatbuffers_sdk::data_objects::PointwiseMode::MUL); const auto applicable = engineManager.getApplicableEngineIds(handle, wrap(graph)); diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp index 6b7d06c0e228..5015afd6de62 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/PointwiseAddTestGraphs.hpp @@ -27,16 +27,13 @@ namespace hip_kernel_provider::kernel_ingestor_engine::testing constexpr int64_t INPUT_A_UID = 1; constexpr int64_t INPUT_B_UID = 2; constexpr int64_t OUTPUT_UID = 3; -/// The third operand buildPointwiseGraph() adds when `includeThirdOperand` is set: a -/// real tensor, distinct from the dangling-uid case below, which never appears here. +/// A real third operand, added when `includeThirdOperand` is set. constexpr int64_t INPUT_C_UID = 4; -/// Names in_1_tensor_uid when `danglingInputBUid` is not overridden -- distinct from -/// every uid this file inserts into a graph's tensor list, so a test opting into the -/// dangling case gets a uid guaranteed absent from getTensorMap(). +/// Uid named by `in_1_tensor_uid` when `danglingInputBUid` is not overridden; guaranteed +/// absent from every graph this file builds. constexpr int64_t DEFAULT_DANGLING_UID = 999; -/// A fixed, warp-64 device: enough for the CPU-only matcher tests, which never compile -/// or launch anything and so never need the actual current device's properties. +/// A fixed, warp-64 device, for CPU-only matcher tests that never compile or launch. inline hipDeviceProp_t testDeviceProperties() { hipDeviceProp_t properties{}; @@ -44,8 +41,7 @@ inline hipDeviceProp_t testDeviceProperties() return properties; } -/// The real current device's properties, queried once. Left zeroed if no device is -/// current, which is fine for a test that immediately SKIP_IF_NO_DEVICES()s. +/// The real current device's properties, queried once; zeroed if no device is current. inline hipDeviceProp_t currentDeviceProperties() { hipDeviceProp_t properties{}; @@ -58,29 +54,17 @@ inline hipDeviceProp_t currentDeviceProperties() } /** - * @brief Builds a single-node pointwise graph, for driving this pack's matchers. + * @brief Builds a single-node binary-pointwise-add graph, parameterized on everything + * this pack's matchers gate: operation, dtype (uniform and per-operand), shape, + * arity, and operand validity. * - * Parameterized on everything the matchers gate, so a test can vary exactly one thing - * and assert the effect: the operation, the element type (uniformly and per-operand), - * the tensor shape, whether the graph carries an identity, arity (binary, ternary, or - * unary), and whether an operand's uid resolves to a real tensor. - * - * @param inputBDataType Overrides input B's dtype away from @p dataType, which stays - * input A's and the output's. Exists solely to build the cross-operand dtype - * mismatch the graph matcher must refuse: this pack's kernel reads one dtype for - * every operand, so a binary op that disagrees is unreadable, not merely - * unoptimized. + * @param inputBDataType Overrides input B's dtype away from @p dataType. * @param includeThirdOperand Adds a real third tensor (`INPUT_C_UID`) and sets - * `in_2_tensor_uid`, producing the ternary shape the graph matcher must refuse: - * this pack's kernel is binary add, and a third operand is a different operation - * entirely. + * `in_2_tensor_uid`, producing a ternary op. * @param danglingInputBUid When set, `in_1_tensor_uid` names this value instead of - * `INPUT_B_UID`, and no tensor is inserted for it -- the graph carries a node - * whose operand uid resolves to nothing in getTensorMap(). Exercises the matcher - * refusing a graph it cannot even read, as opposed to one it reads and declines. - * @param inputAVirtual Marks input A virtual, which the graph matcher must refuse. - * @param inputAIsRuntimePassByValue Marks input A runtime-pass-by-value, which the - * graph matcher must refuse. + * `INPUT_B_UID`, and no tensor is inserted for it. + * @param inputAVirtual Marks input A virtual. + * @param inputAIsRuntimePassByValue Marks input A runtime-pass-by-value. */ inline flatbuffers::FlatBufferBuilder buildPointwiseGraph( hipdnn_flatbuffers_sdk::data_objects::PointwiseMode operation @@ -100,8 +84,7 @@ inline flatbuffers::FlatBufferBuilder buildPointwiseGraph( namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; flatbuffers::FlatBufferBuilder builder; - // Packed strides by default; an explicit set lets a test describe a view into a - // larger buffer, whose stride order the layout classifier may not accept. + // Packed strides by default; an explicit set describes a view into a larger buffer. const std::vector strides = explicitStrides.has_value() ? *explicitStrides : std::vector(dims.size(), 1); const auto resolvedInputBDataType = inputBDataType.value_or(dataType); @@ -233,13 +216,9 @@ inline hipdnn_flatbuffers_sdk::utilities::UuidBytes makeGraphId(uint8_t seed) /** * @brief Wraps a built graph buffer so a test reads it the way an engine does. * - * Parameterized on the device-properties source rather than hardcoding one, because the - * pack's two consumers need different ones: the matcher tests never compile or launch - * anything, so a fixed testDeviceProperties() is enough and keeps them CPU-only; the - * dispatch-handler tests actually compile through hiprtc, so they must pass - * currentDeviceProperties() to build for the device they will launch on. Was - * copy-pasted near-verbatim into TestPointwiseAddMatchers.cpp and - * TestPointwiseAddDispatchHandler.cpp with only that one difference before this move. + * Parameterized on the device-properties source: the matcher tests are CPU-only and use + * a fixed testDeviceProperties(), while the dispatch-handler tests compile through + * hiprtc and need currentDeviceProperties() for the device they launch on. */ class GraphFixture { @@ -269,7 +248,7 @@ class GraphFixture }; /// @brief A KernelDefinition for this pack's kernel, for tests that never need a real -/// descriptor set. Also copy-pasted near-verbatim in both test files before this move. +/// descriptor set. inline hipdnn_plugin_sdk::ingestor::KernelDefinition makeKernel(int64_t blockSize, const std::string& dtype) { diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp index 92f96580c66d..87e94775d752 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddDispatchHandler.cpp @@ -37,12 +37,8 @@ * @brief The pack's dispatch: workspace sizing, prepare's compile options, and a real * compile-and-launch. * - * The launch tests run on device deliberately. A recorded no-op would exercise neither - * the runtime compile nor the uid-to-pointer resolution, which are the two parts most - * likely to be wrong when real kernels arrive. Everything that fails before either of - * those -- an unbound dispatch, an unsupported dtype, the exact options handed to the - * compiler -- is asserted CPU-only, so a decline is cheap to verify and does not need a - * device to run in CI. + * The launch tests run on device to exercise the runtime compile and uid-to-pointer + * resolution. Everything that fails before those is asserted CPU-only. */ namespace { @@ -53,9 +49,8 @@ using namespace hip_kernel_provider::kernel_ingestor_engine::testing; using hipdnn_plugin_sdk::ingestor::BoundTokens; using hipdnn_plugin_sdk::ingestor::MatchContext; -/// The bindings a real plan build would hand the handler, produced the way the state -/// manager produces them: by running the graph matcher. Building them by hand instead -/// would let these tests pass against a matcher that binds the wrong uids. +/// The bindings a real plan build would hand the handler, produced by running the graph +/// matcher rather than built by hand. BoundTokens bindingsFor(const MatchContext& context) { BoundTokens bound; @@ -67,8 +62,7 @@ BoundTokens bindingsFor(const MatchContext& context) } /// Device buffers for one 1-element add, freed on scope exit. Templated on the element -/// type so the same buffer plumbing serves every dtype this pack's dispatch handler -/// compiles for, rather than one copy per type that could quietly drift apart. +/// type to serve every dtype this pack's dispatch handler compiles for. template class AddBuffers { @@ -114,10 +108,6 @@ class AddBuffers // --------------------------------------------------------------------------- // Workspace -// -// Collapses two block-size cases -- the 256-block kernel's non-zero requirement is what -// makes the engine's "maximum across survivors" answer observably a maximum rather than -// a constant zero -- into one TEST_P over the (block size, expected bytes) pairs. // --------------------------------------------------------------------------- struct WorkspaceCase @@ -156,8 +146,7 @@ TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) const PointwiseAddDispatchHandler handler(compiler); // The query is answered per kernel, before selection and before any plan exists, so - // the answer must not depend on which other kernels are in the catalog. Asking twice - // for the same kernel, either side of a different one, gives the same number. + // the answer must not depend on which other kernels are in the catalog. const auto first = handler.workspaceBytes( fixture.context(), bindingsFor(fixture.context()), makeKernel(256, "FLOAT")); static_cast(handler.workspaceBytes( @@ -169,15 +158,13 @@ TEST(TestPointwiseAddDispatch, ReportsWorkspaceWithoutSeeingTheRestOfTheCatalog) } // --------------------------------------------------------------------------- -// Prepare: compile options and unhappy paths -- all CPU-only, since everything here -// either never reaches the compiler or replaces it with a mock. +// Prepare: compile options and unhappy paths -- all CPU-only, since nothing here +// reaches a real compiler. // --------------------------------------------------------------------------- TEST(TestPointwiseAddDispatch, PreparePassesTheKernelsTypeAndBlockSizeToTheCompiler) { - // Mirrors TestRMSnormBwdPlan.cpp's captured-options pattern: a mock compiler in - // place of a real one proves exactly what prepare() sends it, without depending on - // hiprtc or a device. + // A mock compiler proves exactly what prepare() sends it, without hiprtc or a device. const GraphFixture fixture(buildPointwiseGraph()); const MockKernelCompiler compiler; std::vector capturedOptions; @@ -212,10 +199,8 @@ TEST(TestPointwiseAddDispatch, PreparePassesTheKernelsTypeAndBlockSizeToTheCompi TEST(TestPointwiseAddDispatch, PrepareRejectsAKernelDeclaringAnUnsupportedDtype) { // elementTypeFor's dtype switch is unreachable via matching, which admits only the - // dtypes this pack declares (FLOAT, HALF); reached directly here to prove the - // fallback reports rather than silently compiling the wrong kernel. Never reaches - // the compiler -- elementTypeFor throws before prepare() calls it -- so this needs - // no device and no mock beyond a real, unused compiler. + // dtypes this pack declares (FLOAT, HALF). Never reaches the compiler -- + // elementTypeFor throws before prepare() calls it. const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); @@ -227,15 +212,13 @@ TEST(TestPointwiseAddDispatch, PrepareRejectsAKernelDeclaringAnUnsupportedDtype) TEST(TestPointwiseAddDispatch, RefusesToPrepareWithoutTheMatcherSBindings) { - // Not gated: pointwiseAddBinding() throws on a missing token before prepare() reads - // context.deviceProperties or does anything HIP-touching, so this needs no device. + // pointwiseAddBinding() throws on a missing token before prepare() touches HIP. const GraphFixture fixture(buildPointwiseGraph()); const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - // Preparation reads the operand uids the matcher bound rather than re-deriving them, - // so bindings that never came from this pack's matcher are a wiring error. It must - // fail loudly instead of guessing which tensor is which operand. + // Preparation reads the operand uids the matcher bound rather than re-deriving them; + // bindings not produced by this pack's matcher are a wiring error. EXPECT_THROW(handler.prepare(fixture.context(), BoundTokens{}, makeKernel(64, "FLOAT")), hipdnn_plugin_sdk::HipdnnPluginException); } @@ -244,8 +227,7 @@ TEST(TestPointwiseAddDispatch, RefusesToPrepareWithoutTheMatcherSBindings) // Prepare and launch on device // --------------------------------------------------------------------------- -// Runs over every dtype the pack ships a kernel for, so a broken HALF compile path -// fails a real launch rather than only the matcher tests, which never reach hiprtc. +// Runs over every dtype the pack ships a kernel for. struct RealLaunchCase { std::string name; @@ -311,8 +293,8 @@ TEST(TestPointwiseAddDispatch, LaunchesTheSameResultForEitherBlockSize) const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - // block_size reaches the compiler and the launch geometry, so both kernels are - // genuinely different builds; a one-element add must still agree. + // block_size reaches the compiler and the launch geometry; a one-element add must + // still agree across both kernels. for(const int64_t blockSize : {64, 256}) { const auto prepared = handler.prepare( @@ -336,8 +318,8 @@ TEST(TestPointwiseAddDispatch, PreparedLaunchIsReusableAcrossExecutions) const HipMlopsKernelCompiler compiler; const PointwiseAddDispatchHandler handler(compiler); - // A plan is built once and may execute many times with different buffers, so - // preparation must hold nothing tied to one execution. + // A plan is built once and executes many times, so preparation must hold nothing + // tied to one execution. const auto prepared = handler.prepare( fixture.context(), bindingsFor(fixture.context()), makeKernel(64, "FLOAT")); const Handle handle; @@ -359,18 +341,10 @@ TEST(TestPointwiseAddDispatch, DispatchStaysResolvableAcrossContainerLifetimes) { SKIP_IF_NO_DEVICES(); - // A container is destroyed when its last handle closes, and a process that opens - // handles again builds a new one. This pack's implementations are registered once in - // a process-wide registry, so anything registered there must outlive every container - // -- a handler owned by a container's engine would be freed while the registration - // still pointed at it. - // - // This asserts the invariant holds: after a container is destroyed and rebuilt, the - // registered dispatch still resolves and still runs. It does NOT by itself prove the - // absence of a use-after-free, because a freed handler carrying no per-instance state - // usually keeps answering. What catches that is the integration binary's exit status: - // owning the handler on the engine makes hip_kernel_provider_integration_tests abort - // at process teardown while every test still reports as passing. + // Registered dispatch implementations live in a process-wide registry and must + // outlive every container. This asserts a rebuilt container still resolves and runs + // the registration; a use-after-free is instead caught by the integration binary's + // exit status at process teardown. { const core::Container first; } diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp index 1cc39bbade06..7d04ac66c8d2 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddMatchers.cpp @@ -22,9 +22,8 @@ * @file TestPointwiseAddMatchers.cpp * @brief The pack's two matcher shapes: what each accepts, and what each refuses. * - * The refusals matter more than the acceptances here. An under-specified decline accepts - * a graph the kernel cannot serve, which is a wrong answer rather than a missed - * optimization — and for a prebuilt kernel it is a silent one. + * An under-specified refusal accepts a graph the kernel cannot serve, which is silently + * wrong rather than merely a missed optimization. */ namespace { @@ -35,8 +34,7 @@ using hipdnn_plugin_sdk::ingestor::BoundTokens; using hipdnn_plugin_sdk::ingestor::MatchContext; namespace data_objects = hipdnn_flatbuffers_sdk::data_objects; -/// Runs the graph matcher where only its verdict is under test, discarding what it -/// bound. What it binds on success is asserted separately, below. +/// Runs the graph matcher, discarding what it binds; binding is asserted separately below. bool matches(const MatchContext& context) { BoundTokens bound; @@ -56,8 +54,8 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsASingleElementFloatAdd) TEST(TestPointwiseAddGraphMatcher, AcceptsAHalfPrecisionAdd) { - // The graph-level gate is dtype-agnostic within the pack's declared set; pinning the - // kernel's baked dtype is the kernel-scoped matcher's job. + // The graph-level gate is dtype-agnostic; pinning the kernel's baked dtype is the + // kernel-scoped matcher's job. const GraphFixture fixture( buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::HALF)); @@ -74,18 +72,10 @@ TEST(TestPointwiseAddGraphMatcher, AcceptsTheUpperSupportedRank) // --------------------------------------------------------------------------- // Graph-scoped matcher: refusals -// -// Collapsed into one TEST_P: each case differs only in which graph it hands the -// matcher, and every one asserts the identical verdict. Nine cases in total -- the six -// original shape refusals, plus the three this pack's matcher previously left -// unreachable from the builder (cross-operand dtype mismatch, a third operand, and a -// dangling tensor uid). // --------------------------------------------------------------------------- -/// One graph the matcher must refuse, plus a readable name for a failing run. The -/// builder is a captureless-lambda-convertible function pointer rather than a -/// std::function, because flatbuffers::FlatBufferBuilder is move-only and every case -/// here needs only a zero-argument factory. +/// One graph the matcher must refuse, plus a readable name for a failing run. The builder +/// is a plain function pointer, not std::function, since FlatBufferBuilder is move-only. struct GraphMatcherRefusalCase { std::string name; @@ -110,28 +100,20 @@ INSTANTIATE_TEST_SUITE_P( {"AnotherPointwiseOperation", []() { return buildPointwiseGraph(data_objects::PointwiseMode::MUL); }}, {"MultiElementTensors", - // The kernel writes element 0 and nothing else, so anything larger would - // silently leave most of the output untouched. + // The kernel writes only element 0; a larger tensor leaves the rest unwritten. []() { return buildPointwiseGraph( data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1, 1, 2, 2}); }}, {"ARankTheDispatchPathCannotServe", - // A 1-element 1-D tensor suits the kernel, which indexes element 0, but the - // provider's compile options derive layout from the tensor and reject anything - // below rank 4. Accepting it would trade a free decline at applicability for a - // failed plan build, which the caller pays for. + // The provider derives layout from tensor rank and rejects anything below rank 4. []() { return buildPointwiseGraph( data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, {1}); }}, {"AStrideOrderTheDispatchPathCannotClassify", - // Same class as the rank refusal above, and the same root cause: the - // provider's compile options derive a layout from the tensor's strides and - // throw on any 4D order that is neither NCHW nor NHWC. A 1-element tensor - // viewing into a larger buffer can carry such an order, so without this the - // matcher accepts a graph the plan build then fails on, which RFC 0017 section - // 8.6 forbids. + // The provider derives layout from tensor strides and only classifies NCHW or + // NHWC; other orders reach plan build and fail there (RFC 0017 §8.6). []() { return buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, @@ -149,14 +131,11 @@ INSTANTIATE_TEST_SUITE_P( /*binary=*/false); }}, {"AMultiNodeGraph", - // A prebuilt kernel serves one complete graph, so a larger graph is a - // different problem even though it contains this one. + // A prebuilt kernel serves one complete graph, not a graph containing it. []() { return buildTwoNodePointwiseGraph(); }}, {"CrossOperandDtypeMismatch", - // PointwiseAddMatchers.cpp:165-168. The highest-value refusal here: this pack's - // kernel reads one dtype for every operand, so a binary add whose inputs - // disagree is not merely unoptimized, it is unreadable. Unreachable from the - // pre-extension builder, which forced every tensor to share one dtype. + // This pack's kernel reads one dtype for every operand; mismatched inputs are + // unreadable, not merely unoptimized. []() { return buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, @@ -167,9 +146,8 @@ INSTANTIATE_TEST_SUITE_P( /*inputBDataType=*/data_objects::DataType::HALF); }}, {"AThirdOperand", - // PointwiseAddMatchers.cpp:144, the in_2_tensor_uid().has_value() half. A third - // operand is a different operation from this pack's binary add, not a larger - // instance of the same one. + // A third operand is a different operation from this pack's binary add, not a + // larger instance of the same one. []() { return buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, @@ -181,9 +159,8 @@ INSTANTIATE_TEST_SUITE_P( /*includeThirdOperand=*/true); }}, {"ADanglingTensorUid", - // PointwiseAddMatchers.cpp:152-155. in_1_tensor_uid names a uid absent from - // the graph's tensor map -- a node the matcher cannot even read, as distinct - // from one it reads and declines. + // The uid names a tensor absent from the graph's tensor map, distinct from one + // the matcher reads and declines. []() { return buildPointwiseGraph(data_objects::PointwiseMode::ADD, data_objects::DataType::FLOAT, @@ -241,8 +218,7 @@ TEST(TestPointwiseAddKernelMatcher, AcceptsAKernelWhoseDtypeMatchesTheGraph) TEST(TestPointwiseAddKernelMatcher, RefusesAKernelBakedForAnotherDtype) { - // The failure this prevents is silent: an f16 binary handed f32 operands does not - // fail, it returns wrong numbers. + // An f16 kernel handed f32 operands does not fail; it returns wrong numbers. const GraphFixture fixture(buildPointwiseGraph()); EXPECT_FALSE(pointwiseAddKernelMatches(fixture.context(), makeKernel(64, "HALF"))); @@ -258,8 +234,7 @@ TEST(TestPointwiseAddKernelMatcher, AcceptsAHalfKernelForAHalfGraph) TEST(TestPointwiseAddKernelMatcher, IgnoresBlockSizeWhichTheGraphDoesNotConstrain) { - // block_size is a ranking and launch axis, not an applicability one: every block - // size serves every graph this pack accepts. + // block_size ranks kernels but never gates applicability. const GraphFixture fixture(buildPointwiseGraph()); EXPECT_TRUE(pointwiseAddKernelMatches(fixture.context(), makeKernel(64, "FLOAT"))); @@ -280,9 +255,6 @@ TEST(TestPointwiseAddScore, PrefersTheLargerBlockSize) TEST(TestPointwiseAddBinding, TheMatcherBindsTheOperandUidsItResolved) { - // Matching does double duty: deciding the kernel applies also binds the fields the - // launch will use, so dispatch reads these rather than re-deriving the graph shape - // with a second notion of what a pointwise add looks like. const GraphFixture fixture(buildPointwiseGraph()); BoundTokens bound; @@ -296,8 +268,6 @@ TEST(TestPointwiseAddBinding, TheMatcherBindsTheOperandUidsItResolved) TEST(TestPointwiseAddBinding, ARejectedGraphBindsNothingToDispatchFrom) { - // A matcher that declines must not leave partial bindings behind: its pack is pruned, - // so anything it wrote would be state no surviving kernel could correctly read. const GraphFixture fixture(buildTwoNodePointwiseGraph()); BoundTokens bound; diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp index 593a4e37a831..8cc3d2085839 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestPointwiseAddPack.cpp @@ -30,9 +30,7 @@ TEST(TestPointwiseAddPack, ShipsThreeKernelsCoveringTwoBlockSizesAndTwoDataTypes const auto& kernels = set.packs.front().kernels; ASSERT_EQ(kernels.size(), 3U); - // The two FLOAT kernels differ only in block size, so ranking has an order to - // produce and the block_size knob has a value set of two. The HALF kernel is what - // the kernel-scoped matcher prunes on a FLOAT graph. + // The two FLOAT kernels differ only in block size; HALF is pruned on a FLOAT graph. const auto describes = [&kernels](int64_t blockSize, const std::string& dtype) { return std::any_of(kernels.begin(), kernels.end(), [&](const auto& kernel) { return std::get(kernel.metadata.at(std::string(BLOCK_SIZE_FIELD))) == blockSize @@ -63,8 +61,7 @@ TEST(TestPointwiseAddPack, PackCrossReferencesResolveWithinTheDescriptorSet) const auto set = buildPointwiseAddDescriptorSet(); const auto& pack = set.packs.front(); - // A pack names its engine, matchers, and dispatch by id; every one must resolve or - // the descriptor set cannot be evaluated. + // A pack names its engine, matchers, and dispatch by id; every one must resolve. EXPECT_EQ(pack.engineId, set.engine.id); ASSERT_EQ(pack.matcherIds.size(), 2U); @@ -84,8 +81,7 @@ TEST(TestPointwiseAddPack, EngineNamesItsHeuristicAndMetadataSchema) { const auto set = buildPointwiseAddDescriptorSet(); - // An engine carries exactly one of each, because one selector ranks all of its - // kernels over one feature space. + // An engine carries exactly one heuristic and one metadata schema. EXPECT_EQ(set.engine.heuristicId, set.heuristic.id); EXPECT_EQ(set.engine.metadataSchemaId, set.schema.id); } @@ -94,8 +90,7 @@ TEST(TestPointwiseAddPack, ExposesBlockSizeAsAKnobAndDtypeAsInternal) { const auto set = buildPointwiseAddDescriptorSet(); - // dtype is pinned by the graph rather than chosen, so exposing it would offer a - // choice nothing can serve. + // dtype is pinned by the graph rather than chosen. ASSERT_EQ(set.engine.knobs.size(), 1U); EXPECT_EQ(set.engine.knobs.front(), std::string(BLOCK_SIZE_FIELD)); } @@ -104,8 +99,7 @@ TEST(TestPointwiseAddPack, EveryKnobNamesADeclaredMetadataField) { const auto set = buildPointwiseAddDescriptorSet(); - // A knob naming a field the schema does not declare is a load error in the real - // system: it could never be filtered or defaulted. + // A knob naming a field the schema does not declare could never be filtered or defaulted. for(const auto& knob : set.engine.knobs) { EXPECT_TRUE(std::any_of(set.schema.fields.begin(), @@ -118,8 +112,7 @@ TEST(TestPointwiseAddPack, MatchersCoverBothScopes) { const auto set = buildPointwiseAddDescriptorSet(); - // Graph-scoped prunes the whole pack once per graph; kernel-scoped prunes per - // candidate. Losing either collapses the pruning order the ingestor relies on. + // Graph-scoped prunes the whole pack once per graph; kernel-scoped prunes per candidate. EXPECT_EQ(std::count_if(set.matchers.begin(), set.matchers.end(), [](const auto& matcher) { @@ -138,22 +131,19 @@ TEST(TestPointwiseAddPack, MatchersCoverBothScopes) TEST(TestPointwiseAddPack, EngineIdIsTheHashOfItsScopedName) { - // A descriptor-backed engine's id comes from its UED name, the same derivation a - // hand-written engine's registered name goes through. + // A descriptor-backed engine's id comes from its UED name. EXPECT_EQ(pointwiseAddEngineId(), hipdnn_data_sdk::utilities::engineNameToId(ENGINE_NAME)); } TEST(TestPointwiseAddPack, EngineIdIsStableAcrossCalls) { - // The id keys the engine in hipDNN's registry and in this provider's engine table, - // so it must not vary between the two places that ask for it. + // The id keys the engine in hipDNN's registry and this provider's engine table. EXPECT_EQ(pointwiseAddEngineId(), pointwiseAddEngineId()); } TEST(TestPointwiseAddPack, RegistersTheEngineNameForDiagnostics) { - // Registered at run time rather than by a compile-time macro, so a log line and a - // collision report can name the engine instead of printing a hex id. + // Registered at run time so a log line or collision report can name the engine. const auto id = pointwiseAddEngineId(); EXPECT_EQ(hipdnn_data_sdk::utilities::getEngineNameFromId(id), ENGINE_NAME); diff --git a/projects/hipdnn/CMakeLists.txt b/projects/hipdnn/CMakeLists.txt index be69823464f1..7c737a877420 100644 --- a/projects/hipdnn/CMakeLists.txt +++ b/projects/hipdnn/CMakeLists.txt @@ -60,10 +60,8 @@ else() message(STATUS "hipDNN: SDPA support disabled") endif() -# Gates the generic kernel ingestor platform (RFC 0017: Universal Kernel Descriptors). -# Set by TheRock's THEROCK_FLAG_HIPDNN_ENABLE_KERNEL_INGESTOR, which scopes it to the -# hipDNN and hipkernelprovider subprojects. With it OFF the ingestor headers compile to -# nothing and are not installed, so default builds and installed artifacts are unchanged. +# Gates the generic kernel ingestor (RFC 0017: Universal Kernel Descriptors), set by +# TheRock's THEROCK_FLAG_HIPDNN_ENABLE_KERNEL_INGESTOR. option(HIPDNN_ENABLE_KERNEL_INGESTOR "Enable the generic kernel ingestor (RFC 0017)" OFF) if(HIPDNN_ENABLE_KERNEL_INGESTOR) message(STATUS "hipDNN: kernel ingestor enabled") diff --git a/projects/hipdnn/plugin_sdk/CMakeLists.txt b/projects/hipdnn/plugin_sdk/CMakeLists.txt index 27e8e193bbd9..9592433ccf60 100644 --- a/projects/hipdnn/plugin_sdk/CMakeLists.txt +++ b/projects/hipdnn/plugin_sdk/CMakeLists.txt @@ -44,18 +44,12 @@ install(TARGETS hipdnn_plugin_sdk EXPORT hipdnn_plugin_sdk_targets COMPONENT Dev # cmake-lint: disable=E1122 # -# The ingestor/ headers are gated by HIPDNN_ENABLE_KERNEL_INGESTOR and compile to -# nothing without it, but this glob is unconditional -- so without an explicit -# exclusion they would ship in the SDK package even on a default build, changing the -# installed artifacts of a build that has the feature turned off. They are work in -# progress (RFC 0017) and not a supported surface, so they are installed only when the -# feature that uses them is enabled. +# ingestor/ headers are WIP (RFC 0017); this glob is unconditional, so exclude them +# explicitly unless the feature is enabled. set(HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS "") if(NOT HIPDNN_ENABLE_KERNEL_INGESTOR) - # PATTERN "ingestor" and not "ingestor/*": under FILES_MATCHING, excluding the - # contents still creates the (empty) directory in the install tree, which is itself - # a change to a default build's installed artifacts. Matching the directory keeps it - # out entirely. + # Matches the directory itself (not "ingestor/*"): under FILES_MATCHING, excluding + # only the contents still creates the empty directory in the install tree. list(APPEND HIP_DNN_PLUGIN_SDK_INSTALL_EXCLUDE_PATTERNS PATTERN "ingestor" EXCLUDE) endif() diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp index d1be3e9f6f25..2b0b2a3d9811 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Catalog.hpp @@ -19,22 +19,16 @@ namespace hipdnn_plugin_sdk::ingestor * Built once per (graph, device) by running the matchers, then cached. An engine is * applicable exactly when its catalog is non-empty. * - * `isSorted` makes ranking lazy: applicability only needs to know whether any kernel - * survived, so the heuristic does not run until something asks for an order. Once - * sorted, `entries` stays in heuristic-ranked order for the life of the cache entry. + * `isSorted` defers ranking until something asks for an order; once sorted, `entries` + * stays in heuristic-ranked order for the life of the cache entry. */ struct Catalog { std::vector entries; bool isSorted = false; - /// What this engine's graph-scoped matchers resolved about the graph, merged across - /// the packs that survived (RFC 0017 §8.1 keeps bound state alongside the catalog). - /// - /// Merged only once a pack's own graph-scoped matchers all pass; a pruned pack's - /// bindings are discarded unmerged (KernelIngestorStateManager::buildCatalog()). - /// Merged rather than kept per pack because a token name means the same thing to - /// every pack in an engine — two packs binding one name to different values is an - /// authoring error, rejected at merge time. + /// What this engine's graph-scoped matchers resolved, merged across surviving packs + /// (RFC 0017 §8.1); a pruned pack's bindings are discarded unmerged. One token name + /// binding to different values across packs is an authoring error, rejected at merge. BoundTokens bound; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp index 7dffe4dbe098..3d6d642db912 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/Descriptors.hpp @@ -40,13 +40,8 @@ namespace hipdnn_plugin_sdk::ingestor { -/// A descriptor cross-reference: the stable, globally unique id every descriptor -/// carries, and the only way descriptors name one another. -/// -/// A 128-bit UUID, matching the graph identity hipDNN mints at finalization, so the -/// two id spaces in this system are one type (RFC 0017 §4). `parseUuid` and -/// `formatUuid` in the same header convert to and from the text form descriptor files -/// carry. +/// Stable, globally unique id every descriptor carries; descriptors reference each +/// other only by this id. A 128-bit UUID, matching hipDNN's graph identity (RFC 0017 §4). using DescriptorId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; /// @brief A descriptor id in its canonical text form, for diagnostics. @@ -55,14 +50,12 @@ inline std::string toString(const DescriptorId& id) return hipdnn_flatbuffers_sdk::utilities::formatUuid(id); } -/// Hash for keying maps on a descriptor id. std::array has no std::hash, so this is -/// passed explicitly to the containers that need it. +/// Hash for keying maps on a descriptor id (std::array has no std::hash). struct DescriptorIdHash { size_t operator()(const DescriptorId& id) const noexcept { - // The id is a UUID, already well distributed, so an FNV-1a fold over its bytes - // is sufficient for an in-process map and is never persisted or exposed. + // FNV-1a fold over the UUID bytes; not persisted or exposed outside this process. size_t hash = 1469598103934665603ULL; for(const uint8_t byte : id) { @@ -73,19 +66,13 @@ struct DescriptorIdHash } }; -/// A value a kernel supplies for a KMD-declared field, and also the value type -/// `$graph.*` binds (see MatchContext.hpp's BoundTokens): both sides of a criteria -/// comparison need one shared value type. -/// -/// `int64_t` and `double` are the widest signed integer and floating-point forms, so a -/// narrower authored value converts into one without loss; `bool` is distinct because a -/// flag compares and prints differently from the integer 1. `std::vector` is -/// for list-valued facts like `stride_order`. +/// Value type for a KMD field or a bound `$graph.*` fact (MatchContext.hpp's +/// BoundTokens). `bool` is distinct from `int64_t` so a flag doesn't compare as 1; +/// `std::vector` covers list-valued facts like `stride_order`. using MetadataValue = std::variant>; -/// The type a KMD field holds, or a bound graph fact's type. Ordered to match -/// MetadataValue's alternatives, so a field can declare its type without also having to -/// supply a value of it. +/// The type a KMD field or bound graph fact holds. Ordered to match MetadataValue's +/// alternatives. enum class MetadataType { BOOL, @@ -101,29 +88,22 @@ inline MetadataType metadataTypeOf(const MetadataValue& value) return static_cast(value.index()); } -/// A kernel's complete metadata tuple: every KMD field name mapped to this kernel's -/// value for it. This tuple is the kernel's identity to the catalog (RFC 0017 §4), so -/// it must be unique across every kernel in an engine. Ordered, so two kernels with the -/// same fields compare and hash identically regardless of insertion order. +/// A kernel's complete metadata tuple: every KMD field mapped to this kernel's value. +/// Must be unique per kernel within an engine (RFC 0017 §4); ordered so equal tuples +/// compare equal regardless of insertion order. using MetadataValues = std::map; /// One field a kernel may vary along, as declared by an engine's KMD. struct MetadataField { std::string name; - /// What this field holds. MetadataType type = MetadataType::INT; - /// The value a kernel that omits this field is taken to have supplied. Optional, - /// because a field can be mandatory; a kernel omitting a mandatory field is a load - /// error rather than a silent fallback. + /// Value used when a kernel omits this field; nullopt means the field is mandatory. std::optional defaultValue; }; -/// KMD: the metadata schema, one per engine, shared by every kernel the engine owns. -/// -/// The field set is the engine's kernel key: the heuristic ranks on these values and -/// matchers read them as `$kernel.`, so any quantity that distinguishes two -/// kernels must appear here or the two collide. +/// KMD: the metadata schema, one per engine, shared by every kernel it owns. The +/// field set is the engine's kernel key; matchers read fields as `$kernel.`. struct MetadataSchema { DescriptorId id; @@ -132,71 +112,52 @@ struct MetadataSchema }; /// Which adapter builds an engine's IKernelHeuristic from a UHD's `payload` (RFC 0017 -/// §9.1's adapter dispatch point). +/// §9.1). enum class HeuristicKind { - /// `payload` is a NativeRegistry score symbol, resolved into a NativeKernelHeuristic - /// (see IKernelHeuristic.hpp's makeKernelHeuristic()). The only kind with an adapter - /// today. + /// `payload` is a NativeRegistry score symbol (IKernelHeuristic.hpp's + /// makeKernelHeuristic()). Only kind with an adapter today. NATIVE, - /// A trained model artifact plus its feature signature (the UHD follow-up RFC). No - /// adapter yet. + /// Trained model artifact plus feature signature (UHD follow-up RFC). No adapter yet. MODEL, }; -/// UHD: the kernel-selection model for one engine. -/// -/// One level below hipDNN's engine-selection heuristic: this chooses *which kernel -/// within an engine* to run, given the kernels that fit the graph. +/// UHD: the kernel-selection model for one engine — chooses which kernel within an +/// engine to run, given the kernels that fit the graph. struct HeuristicDescriptor { DescriptorId id; std::string name; HeuristicKind kind = HeuristicKind::NATIVE; - /// `kind`'s payload: a NativeRegistry score symbol (kind == NATIVE), or, for a kind - /// with no adapter yet, that kind's analogous single identifier (a model artifact - /// path). + /// `kind`'s payload: a NativeRegistry score symbol when kind == NATIVE. std::string payload; }; -/// UED: the engine itself, carrying no logic of its own. -/// -/// Names its one heuristic and one metadata schema by id, because a single selector -/// ranks all of the engine's kernels over one feature space. `name` hashes into -/// hipDNN's engine-id space (see EngineNames.hpp), so it must be globally unique and -/// should be scoped, e.g. "rocke:SDPA". +/// UED: the engine itself, carrying no logic of its own. `name` hashes into hipDNN's +/// engine-id space (EngineNames.hpp); must be globally unique, e.g. "rocke:SDPA". struct EngineDescriptor { DescriptorId id; std::string name; DescriptorId heuristicId; DescriptorId metadataSchemaId; - /// KMD field names this engine exposes for the caller to control. A name no KMD - /// field matches is a load error. + /// KMD field names this engine exposes to the caller. Unmatched name is a load error. std::vector knobs; - /// Advisory execution behavior this engine's kernels exhibit, as - /// `hipdnnBackendBehaviorNote_t` values (BehaviorNote.h), reported through - /// EngineDetails like any other engine's. - /// - /// Held as int32 rather than a typed enum because the transport is int32, so a - /// newer descriptor can carry a note value an older backend does not know without - /// truncating it. + /// `hipdnnBackendBehaviorNote_t` values (BehaviorNote.h), reported via EngineDetails. + /// Stored as int32 so an unrecognized newer note value isn't truncated. std::vector behaviorNotes; }; -/// Which inputs a matcher reads, which decides how often it runs and what its failure -/// prunes (RFC 0017 §5). -/// -/// Authored per matcher here, which is provisional: once the criteria language lands -/// this becomes a derived property, computed from whether a matcher's expression -/// references `$kernel.*`. +/// Which inputs a matcher reads; determines how often it runs and what its failure +/// prunes (RFC 0017 §5). Provisional until the criteria language lands, when this +/// becomes derived from whether the expression references `$kernel.*`. enum class MatchScope { - /// Reads only graph and device facts, so it runs once per (graph, device) and its - /// failure disqualifies every kernel in every pack that lists it. + /// Runs once per (graph, device); failure disqualifies every kernel in every pack + /// that lists it. GRAPH, - /// Also reads `$kernel.*`, so it is re-evaluated per candidate kernel and - /// disqualifies that kernel alone. + /// Also reads `$kernel.*`; re-evaluated per candidate kernel, disqualifying only + /// that kernel. KERNEL, }; @@ -206,22 +167,21 @@ struct MatchDescriptor DescriptorId id; std::string name; MatchScope scope; - /// Resolved through NativeRegistry. The data-driven form (a structural node pattern - /// plus a declarative criteria expression) is the UMD follow-up RFC. + /// Resolved through NativeRegistry; a data-driven form (structural node pattern plus + /// declarative criteria) is the UMD follow-up RFC. std::string matchSymbol; }; /// UDD: how to invoke a kernel — the dispatch ABI, shared by every kernel in a pack. -/// -/// A kernel whose argument list or launch-formula shape differs belongs in another pack -/// with its own UDD (RFC 0017 §6). +/// A kernel with a different argument list or launch-formula shape belongs in +/// another pack with its own UDD (RFC 0017 §6). struct DispatchDescriptor { DescriptorId id; std::string name; - /// Resolved through NativeRegistry to a dispatch handler supplying both the - /// workspace requirement and the launch. The data-driven form (symbolic grid, block, - /// shared memory, workspace, and argument signature) is the UDD follow-up RFC. + /// Resolved through NativeRegistry to a handler supplying workspace and launch. A + /// data-driven form (symbolic grid/block/shared-memory/workspace/args) is the UDD + /// follow-up RFC. std::string dispatchSymbol; }; @@ -240,33 +200,26 @@ enum class KernelSourceKind ROCKE_BUILDER, }; -/// UKD's source: where a kernel's code comes from, as a tagged union over RFC 0017 -/// §7's source kinds. Only `EMBEDDED_SOURCE` is implemented; the others exist as enum -/// values, since a descriptor states which kind it carries independently of whether -/// this provider can load that kind yet. +/// UKD's source: where a kernel's code comes from, a tagged union over RFC 0017 §7's +/// source kinds. Only `EMBEDDED_SOURCE` is implemented. struct KernelSource { KernelSourceKind kind = KernelSourceKind::EMBEDDED_SOURCE; - /// `EMBEDDED_SOURCE`: the source file name. Unused, and left empty, by every other - /// kind until each grows its own payload shape. + /// `EMBEDDED_SOURCE`: source file name. Unused by other kinds. std::string sourceFile; - /// `EMBEDDED_SOURCE`: the entry point within `sourceFile`. Unused by every other - /// kind, for the same reason as `sourceFile` above. + /// `EMBEDDED_SOURCE`: entry point within `sourceFile`. Unused by other kinds. std::string entryPoint; }; -/// UKD: one launchable kernel — a source plus concrete values for the fields its -/// engine's KMD declares. Its matchers, engine, and dispatch are all its pack's, and -/// its heuristic and metadata schema are that engine's, so it names none of them. +/// UKD: one launchable kernel — a source plus concrete values for its engine KMD's +/// fields. Matchers, engine, and dispatch are inherited from its pack. struct KernelDescriptor { DescriptorId id; std::string name; - /// Where this kernel's code comes from and how to load it. See KernelSource for why - /// this is a tagged union rather than two bare strings. KernelSource source; - /// This kernel's values for the KMD's fields. Omitted fields take the KMD default; - /// the completed tuple is this kernel's catalog key and must be unique engine-wide. + /// This kernel's values for the KMD's fields; omitted fields take the KMD default. + /// The completed tuple is this kernel's catalog key, unique engine-wide. MetadataValues metadata; /// Tie-break when the heuristic is not decisive. Higher wins. int64_t priority = 0; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp index 3cb4a1caa854..d7f37eeb525e 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericEngine.hpp @@ -29,15 +29,12 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief One hipDNN engine, defined entirely by a UED and the packs naming it. * - * Satisfies hipDNN's existing IEngine contract using descriptor data; from the host's - * side this is an ordinary engine. + * Satisfies hipDNN's IEngine contract using descriptor data. * - * The engine's id is its UED name hashed into hipDNN's engine-id space, registered when - * the engine is constructed rather than at build time, so an id collision surfaces here - * as a failure to create the engine. + * The engine's id is its UED name hashed into hipDNN's engine-id space, registered at + * construction, so an id collision surfaces as a failure to create the engine. * - * Holds exactly one plan builder: a catalog entry is a candidate, not a builder, so an - * engine with 150 kernels still has one. + * Holds exactly one plan builder: a catalog entry is a candidate, not a builder. */ template class GenericEngine : public IEngine @@ -47,16 +44,15 @@ class GenericEngine : public IEngine using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; /** - * @param engine The UED this engine is; 1:1 with a hipDNN engine, so its - * name is this engine's identity and its knob list is what it exposes. + * @param engine The UED this engine is; its name is this engine's identity + * and its knob list is what it exposes. * @param stateManager The descriptor state this engine selects over, already * validated. * @param deviceResolver Answers which device each call is for. Held by reference; * owned by the provider, which must keep it alive for the engine's lifetime. * * @throws std::invalid_argument if a knob names no field in the engine's metadata - * schema (RFC 0017 §4): a knob is only a name, with the field supplying its - * type, default and legal values. + * schema (RFC 0017 §4). */ GenericEngine(EngineDescriptor engine, std::unique_ptr> stateManager, @@ -106,8 +102,7 @@ class GenericEngine : public IEngine /** * @brief Reports this engine's knobs for @p opGraph. * - * The returned buffer is detached and handed to the caller's handle to own, matching - * how every other engine in this provider answers the query. + * The returned buffer is detached and handed to the caller's handle to own. */ void getDetails(THandle& handle, const IGraph& opGraph, @@ -156,9 +151,8 @@ class GenericEngine : public IEngine private: EngineDescriptor _engine; - /// Owned outright: see the constructor for why this must not be shared. Held by - /// pointer so the plan builder can bind a reference to it in the member - /// initializer list, which needs a stable address. + /// Owned outright: held by pointer so the plan builder can bind a reference to it + /// in the member initializer list, which needs a stable address. std::unique_ptr> _stateManager; int64_t _id; GenericPlanBuilder _planBuilder; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp index b754cf5f3e84..1bf376023885 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlan.hpp @@ -22,12 +22,11 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief An executable plan for one descriptor-selected kernel. * - * Holds the kernel the heuristic chose, the handler that launches it, and the launch - * detail that handler resolved from the graph at build time: execute() resolves and - * re-matches nothing. + * Holds the selected kernel, its dispatch handler, and launch details resolved from + * the graph at construction; execute() re-matches nothing. * - * Immutable after construction, as IPlan requires: the same plan may execute - * concurrently from several threads with different device buffers. + * Immutable after construction: the same plan may execute concurrently from multiple + * threads with different device buffers. */ template class GenericPlan : public IPlan @@ -35,11 +34,9 @@ class GenericPlan : public IPlan public: /** * @param dispatcher The selected kernel and its resolved dispatch handler. - * @param context Bound graph and device state. Read during construction to - * prepare the launch; not retained, because a plan outlives the - * graph reference this carries. - * @param bound What matching resolved about the graph, from the catalog that - * selected this kernel. + * @param context Bound graph and device state, read during construction only; + * not retained. + * @param bound What matching resolved about the graph. */ GenericPlan(KernelDispatcher dispatcher, const MatchContext& context, @@ -80,8 +77,7 @@ class GenericPlan : public IPlan _dispatcher.handler->launch(handle, *_prepared, deviceBuffers, numDeviceBuffers, workspace); } - /// The kernel this plan launches. Exposed for the resolved-plan diagnostics - /// (RFC 0017 §10) and for tests asserting which kernel selection chose. + /// The kernel this plan launches (used by resolved-plan diagnostics, RFC 0017 §10). const KernelDefinition& kernel() const { return _dispatcher.kernel; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp index 93684ff22a0c..d72963e6d36a 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/GenericPlanBuilder.hpp @@ -28,7 +28,7 @@ namespace hipdnn_plugin_sdk::ingestor /// field) name. Only integer-valued fields are expressible as knobs today. /// /// A `TSettings` used with GenericPlanBuilder must carry one of these named -/// `ingestorKnobFilter`, populated by initializeExecutionSettings() and read back by +/// `ingestorKnobFilter`, populated by initializeExecutionSettings() and read by /// getMaxWorkspaceSize() and buildPlan(). using KnobFilter = std::map; @@ -38,12 +38,9 @@ using KnobFilter = std::map; * One builder regardless of catalog size: a catalog entry is a candidate, and this * builds a plan for whichever candidate selection chose. * - * Every method here reads state the state manager already computed and cached; - * nothing rebuilds. - * * @tparam TSettings Must carry a `KnobFilter ingestorKnobFilter` member, populated by - * initializeExecutionSettings() so getMaxWorkspaceSize() can apply the same - * filter buildPlan() reads directly from IEngineConfig. + * initializeExecutionSettings() and read by getMaxWorkspaceSize() and + * buildPlan(). */ template class GenericPlanBuilder : public IPlanBuilder @@ -52,11 +49,9 @@ class GenericPlanBuilder : public IPlanBuilder using IGraph = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph; using IEngineConfig = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IEngineConfig; - /// @param engine The UED whose knobs this builder reports and whose name its - /// diagnostics carry. Held by reference; owned by the engine, which - /// outlives its builder. - /// @param stateManager The descriptor state to select over. Shared, not owned, so - /// the engine and its builder read one catalog cache. + /// @param engine The UED whose knobs this builder reports. Held by reference; + /// owned by the engine, which outlives its builder. + /// @param stateManager The descriptor state to select over. Shared, not owned. /// @param deviceResolver Answers which device each call is for. Held by reference; /// owned by the engine, which outlives its builder. GenericPlanBuilder(const EngineDescriptor& engine, @@ -68,8 +63,7 @@ class GenericPlanBuilder : public IPlanBuilder { } - /// Applicable exactly when some kernel survived matching. Deliberately does not - /// rank: a membership test needs no order. + /// Applicable exactly when some kernel survived matching; does not rank. bool isApplicable(const THandle& handle, const IGraph& opGraph) const override { return !_stateManager.unsortedDefinitions(contextFor(handle, opGraph)).empty(); @@ -91,27 +85,25 @@ class GenericPlanBuilder : public IPlanBuilder const TSettings& executionSettings) const override { const auto context = contextFor(handle, opGraph); - // Unsorted: one lookup for the entries and the bound state together. + // One lookup for the entries and the bound state together. const auto catalog = _stateManager.unsortedCatalog(context); if(catalog.entries.empty()) { throwNoApplicableKernel(); } - // Filtered before the max is taken: knob settings request a catalog subset, - // same contract as buildPlan(). + // Knob settings request a catalog subset, same contract as buildPlan(). const auto filtered = applyKnobFilter(catalog.entries, executionSettings.ingestorKnobFilter); if(filtered.empty()) { - // Same failure buildPlan() reports, for the same reason. + // Same failure buildPlan() reports. throwUnsatisfiableKnobFilter(executionSettings.ingestorKnobFilter, catalog.entries.size()); } size_t maxBytes = 0; - // Unsorted deliberately: the maximum is order-independent, and ranking here - // would load every candidate engine's heuristic for a number that doesn't need it. + // Unsorted deliberately: order-independent, so no ranking heuristic needed. for(const auto& kernel : filtered) { const auto dispatcher = _stateManager.getDispatchDetails(kernel); @@ -124,9 +116,8 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief Reads the caller's knob settings from @p engineConfig into TSettings. * - * Populates `executionSettings.ingestorKnobFilter` so getMaxWorkspaceSize() -- - * which receives only TSettings, not an IEngineConfig -- reads the same filter - * buildPlan() reads directly from @p engineConfig. + * Populates `executionSettings.ingestorKnobFilter`, read the same way by + * getMaxWorkspaceSize() and buildPlan(). */ void initializeExecutionSettings(const THandle& /*handle*/, const IGraph& /*opGraph*/, @@ -158,9 +149,7 @@ class GenericPlanBuilder : public IPlanBuilder throwNoApplicableKernel(); } - // Filtered after ranking: filtering and ranking commute (see this class's - // tparam doc), yielding the same kernel filter-then-rank would without a - // second heuristic pass. + // Filtering after ranking commutes with filter-then-rank (see tparam doc). const auto filter = readKnobFilter(engineConfig); const auto filtered = applyKnobFilter(catalog.entries, filter); if(filtered.empty()) @@ -168,8 +157,7 @@ class GenericPlanBuilder : public IPlanBuilder throwUnsatisfiableKnobFilter(filter, catalog.entries.size()); } - // Logs the selected kernel (RFC 0017 §10); the only place the choice is - // observable without a debugger. + // Logs the selected kernel (RFC 0017 §10) for debugging without a debugger. HIPDNN_PLUGIN_LOG_INFO("ingestor: engine '" << _engine.name << "' selected kernel " << toString(filtered.front().kernelId) << " from " << filtered.size() @@ -183,9 +171,8 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief One knob per KMD field the engine exposes. * - * A knob's legal values are what the catalog implements for this graph. Its - * default is whatever the heuristic ranked first, so leaving every knob alone - * reproduces the out-of-the-box selection. + * A knob's legal values are what the catalog implements for this graph; its + * default is the top-ranked kernel's value. */ std::vector getCustomKnobs(const THandle& handle, const IGraph& opGraph) const override @@ -207,8 +194,7 @@ class GenericPlanBuilder : public IPlanBuilder choices.reserve(values.size()); for(const auto& value : values) { - // Only integer-valued fields are expressible as knobs today; a - // string-valued field is skipped rather than stringified. + // Only integer-valued fields are expressible as knobs today. if(const auto* intValue = std::get_if(&value)) { choices.push_back(*intValue); @@ -225,8 +211,7 @@ class GenericPlanBuilder : public IPlanBuilder = "Kernel metadata field '" + knobName + "' of engine '" + _engine.name + "'"; IntValueT defaultValue; - // First entry is the top-ranked kernel's value, so leaving the knob alone - // reproduces the out-of-the-box selection. + // First entry is the top-ranked kernel's value. defaultValue.value = choices.front(); knob.default_value.Set(defaultValue); @@ -244,8 +229,7 @@ class GenericPlanBuilder : public IPlanBuilder } private: - /// Binds the graph and the device this call is for. Resolving the device here, once - /// per call, is what keeps the cache key and the matchers reading the same one. + /// Binds the graph and the device this call is for. MatchContext contextFor(const THandle& handle, const IGraph& opGraph) const { const auto deviceId = _deviceResolver.deviceId(handle); @@ -253,8 +237,6 @@ class GenericPlanBuilder : public IPlanBuilder } /// Reads @p engineConfig's settings for this engine's own knobs into a KnobFilter. - /// Only engine-exposed knobs are read: a setting for a knob this engine never - /// advertised cannot mean anything to it. KnobFilter readKnobFilter(const IEngineConfig& engineConfig) const { using namespace hipdnn_flatbuffers_sdk::data_objects; @@ -275,8 +257,7 @@ class GenericPlanBuilder : public IPlanBuilder const auto& setting = engineConfig.getKnobSettingByName(knobName); if(setting.valueType() != KnobValue::IntValue) { - // Only integer-valued fields are expressible as knobs today (see - // getCustomKnobs()); a setting of another type is a caller error. + // Only integer-valued fields are expressible as knobs today. throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INVALID_VALUE, "engine '" + _engine.name + "' knob '" + knobName + "' must be set to an integer value"); @@ -286,8 +267,7 @@ class GenericPlanBuilder : public IPlanBuilder return filter; } - /// The catalog entries matching every SET knob in @p filter. An entry with no - /// value for a filtered field never matches: no value means no choice offered. + /// The catalog entries matching every SET knob in @p filter. std::vector applyKnobFilter(const std::vector& catalog, const KnobFilter& filter) const { @@ -315,10 +295,7 @@ class GenericPlanBuilder : public IPlanBuilder return filtered; } - /** - * @brief Reports an empty catalog: shared by getMaxWorkspaceSize() and buildPlan(), - * since both run only after isApplicable() already accepted this graph. - */ + /// Reports an empty catalog; shared by getMaxWorkspaceSize() and buildPlan(). [[noreturn]] void throwNoApplicableKernel() const { throw HipdnnPluginException(HIPDNN_PLUGIN_STATUS_INTERNAL_ERROR, @@ -329,9 +306,6 @@ class GenericPlanBuilder : public IPlanBuilder /** * @brief Reports a knob combination no kernel in the catalog implements. * - * HIPDNN_PLUGIN_STATUS_INVALID_VALUE, not INTERNAL_ERROR: a bad caller input, not - * a matcher/applicability disagreement like buildPlan()'s empty-catalog case. - * * Names every set knob and value, and how many kernels survived matching before * knob filtering. */ diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp index 4efb379d2ec2..41595e23c29a 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IDeviceResolver.hpp @@ -14,12 +14,8 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief Answers which device a call is for, from the handle that carries it. * - * An engine is shared by every handle in the process, and a handle can be rebound to a - * different device between calls, so the device a query concerns is resolved per call - * rather than captured once at engine construction. - * - * How a handle names its device is provider-specific, which is why this is an - * interface rather than a call the SDK makes directly. + * Resolved per call, not at engine construction: an engine is shared across every + * handle in the process, and a handle can rebind to a different device between calls. */ template class IDeviceResolver @@ -34,10 +30,8 @@ class IDeviceResolver /** * @brief Properties of @p deviceId, for the `$device.*` expression namespace. * - * Returns a reference that stays valid for the resolver's lifetime, because a - * MatchContext binds it rather than copying it. Implementations are expected to - * cache: this is asked on the applicability path, which runs once per engine per - * graph. + * Returns a reference valid for the resolver's lifetime; a MatchContext binds it + * rather than copying. Implementations should cache. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual const hipDeviceProp_t& deviceProperties(DeviceId deviceId) const = 0; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp index 94d9a48a532b..69b52584c57c 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelDispatchHandler.hpp @@ -17,14 +17,11 @@ namespace hipdnn_plugin_sdk::ingestor { /** - * @brief Everything a launch needs that was derived from the graph, resolved once. + * @brief Launch state derived from the graph, resolved once at plan build. * - * A provider derives from this to hold what its kernels need: argument uids, launch - * geometry, a loaded code object, a preallocated argument buffer. - * - * A plan outlives the call that built it, and a MatchContext holds references into a - * graph hipDNN owns for one plugin call only, so a plan cannot keep that state. The - * handler reads it once at plan build and returns owned values (RFC 0017 §8.5). + * A provider derives from this to hold what its kernels need (argument uids, launch + * geometry, code object, argument buffer). Must not reference the MatchContext or + * BoundTokens it was built from (RFC 0017 §8.5). */ class PreparedDispatch { @@ -33,14 +30,10 @@ class PreparedDispatch }; /** - * @brief The native escape hatch for a UDD: how to size, prepare, and launch a kernel. - * - * Keeps provider machinery out of the SDK -- an implementation holds its own compiler, - * module cache and code objects, none of which the SDK names. Templated on THandle to - * match IPlanBuilder and IPlan. + * @brief Native escape hatch for a UDD: sizes, prepares, and launches a kernel. * - * Workspace belongs here (RFC 0017 §6) because the workspace query arrives before any - * kernel is chosen: anything that can launch must also be able to size its scratch. + * Templated on THandle to match IPlanBuilder and IPlan. Also answers workspace size + * (RFC 0017 §6), since that query arrives before a kernel is chosen. */ template class IKernelDispatchHandler @@ -51,13 +44,10 @@ class IKernelDispatchHandler /** * @brief Global scratch this kernel requires, in bytes. * - * Answered per kernel, before selection, so the engine can report the maximum across - * the kernels the caller's knobs leave in the catalog. The catalog is never passed - * in, so the answer cannot depend on which other kernels are present. + * Answered per kernel before selection; must not depend on which other kernels + * are present (the catalog is never passed in). * - * @param bound What matching already resolved about this graph. A workspace size is - * a formula over graph dimensions as much as kernel metadata, so this is read - * rather than re-derived (RFC 0017 §8.1: nothing is re-matched). + * @param bound Graph state matching already resolved (RFC 0017 §8.1: not re-derived). */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual size_t workspaceBytes(const MatchContext& context, @@ -68,8 +58,8 @@ class IKernelDispatchHandler /** * @brief Resolves everything @p kernel's launch needs from the bound token state. * - * Called once, at plan build, while @p context is still valid. The returned object is - * owned by the plan and MUST NOT reference anything in @p context or @p bound. + * Called once at plan build, while @p context is still valid. The returned object + * is owned by the plan and MUST NOT reference @p context or @p bound. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual std::unique_ptr prepare(const MatchContext& context, @@ -82,18 +72,13 @@ class IKernelDispatchHandler * * @param handle Supplies the stream the launch is ordered on. * @param prepared The object this handler returned from prepare(). - * @param deviceBuffers uid/pointer pairs; resolve each argument by uid through + * @param deviceBuffers uid/pointer pairs; resolve via * hipdnn_plugin_sdk::findDeviceBuffer. * @param numDeviceBuffers Length of @p deviceBuffers. * @param workspace The plan's scratch, or nullptr when workspaceBytes() was 0. * - * @note May be called concurrently from several threads with different device - * buffers, so it must not mutate @p prepared or the handler. - * - * One call, one kernel. When multi-launch UDDs land, the handler should issue the N - * launches internally rather than the SDK calling launch() N times: launch i's - * geometry can depend on what launch i-1 produced, and only the handler owning the - * whole dispatch can resolve that. + * @note May run concurrently across threads; must not mutate @p prepared or the + * handler. */ // NOLINTNEXTLINE(portability-template-virtual-member-function) virtual void launch(const THandle& handle, diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp index 7ab7e3093b1d..64f4f08245ec 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/IKernelHeuristic.hpp @@ -25,15 +25,11 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief Chooses which kernel within an engine to run, given the kernels that fit. * - * One level below hipDNN's engine-selection heuristic, which decides *which engine* - * handles a graph and is untouched by this system. + * One level below hipDNN's engine-selection heuristic, which picks the engine. * - * An implementation supplies `score()` and nothing else: it ranks one kernel at a - * time and never sees the catalog, so a kernel's score cannot depend on which other - * kernels are present. That is what makes filtering and ranking commute (RFC 0017 - * §9.2), so a knob-filtered subset ranks exactly as it did in the whole catalog. - * `rank()` is therefore non-virtual; a selector that must reason over the candidate - * set as a whole is the heuristic follow-up RFC's job. + * An implementation supplies only `score()`: it ranks one kernel at a time and never + * sees the catalog, so filtering and ranking commute (RFC 0017 §9.2). `rank()` is + * non-virtual; a selector over the whole candidate set is the heuristic follow-up RFC. */ class IKernelHeuristic { @@ -46,13 +42,9 @@ class IKernelHeuristic /** * @brief Orders @p catalog best-first. * - * Scores each entry independently and sorts descending, breaking ties first on - * the kernel's explicit `priority`, then on its descriptor id compared as bytes - * (stable across runs, load orders, and machines). - * - * Each kernel is scored exactly once, before sorting, rather than inside the - * comparator: scoring from the comparator would run inference O(n log n) times - * instead of n for an n-kernel catalog. + * Scores each entry once, then sorts descending, breaking ties on the kernel's + * `priority`, then its descriptor id as bytes (stable across runs, load orders, + * and machines). */ std::vector rank(const Catalog& catalog, const MatchContext& context) const { @@ -88,20 +80,14 @@ class IKernelHeuristic /** * @brief An IKernelHeuristic whose score() is a native function resolved by symbol. * - * The UHD escape hatch: the descriptor names a symbol, this resolves it. The - * data-driven form loads a model artifact instead (the UHD follow-up RFC), and slots - * in here as another IKernelHeuristic. - * - * **Constructing a heuristic must stay cheap; whatever it selects with loads on first - * use** (RFC 0017 §8.1, §3): an engine whose matchers reject a graph must never pay - * for its selector. This one holds a symbol name and resolves it on first score(); a - * LightGBM adapter would hold an artifact path and read the model there. + * The UHD escape hatch: the descriptor names a symbol, this resolves it on first + * score(). Construction must stay cheap (RFC 0017 §8.1, §3). A data-driven form (UHD + * follow-up RFC) would load a model artifact instead. */ class NativeKernelHeuristic : public IKernelHeuristic { public: - /// @param scoreSymbol Resolved from the registry on first use, not here, so - /// constructing this costs nothing an engine that never ranks would pay. + /// @param scoreSymbol Resolved from the registry on first use, not here. explicit NativeKernelHeuristic(std::string scoreSymbol) : _scoreSymbol(std::move(scoreSymbol)) { @@ -115,9 +101,8 @@ class NativeKernelHeuristic : public IKernelHeuristic private: std::string _scoreSymbol; - /// Resolved once, on the first call that needs an order. Mutable because score() is - /// logically const: resolving is an implementation detail of answering, and a plan - /// may rank from several threads at once. + /// Mutable: resolution is an implementation detail of a logically-const score(), + /// which may be called from several threads at once. mutable std::once_flag _resolved; mutable ScoreFn _scoreFn = nullptr; }; @@ -126,9 +111,8 @@ class NativeKernelHeuristic : public IKernelHeuristic * @brief Builds the IKernelHeuristic a UHD names, keyed on its kind. * * @throws std::invalid_argument if @p descriptor names a kind with no adapter yet - * (HeuristicKind::MODEL). Fails at descriptor-assembly time, matching - * KernelIngestorStateManager's other cross-reference checks: a kind nothing - * can load is a load error, not a runtime surprise at the first rank(). + * (HeuristicKind::MODEL). Fails at descriptor-assembly time, not at the + * first rank(). */ inline std::shared_ptr makeKernelHeuristic(const HeuristicDescriptor& descriptor) { diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp index f7a2728fae24..156c818f3a25 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelDefinition.hpp @@ -19,33 +19,27 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief One catalog entry: a kernel that passed every matcher for a graph. * - * This is the POD that leaves KernelIngestorStateManager. Copied out rather than - * handed out by reference so a caller holds a stable snapshot while the cache it came - * from is concurrently evicted or refilled. + * Copied out of KernelIngestorStateManager rather than referenced, so a caller holds a + * stable snapshot while the source cache is concurrently evicted or refilled. * * Carries the kernel's completed metadata tuple (its catalog key), enough source - * detail to load it, and the ids needed to find its dispatch descriptor — but no - * pointer back into the descriptor set. + * detail to load it, and the ids needed to find its dispatch descriptor. */ struct KernelDefinition { DescriptorId kernelId; - /// The pack this kernel came from, which owns the matchers and UDD that apply to it. + /// The pack this kernel came from; owns the matchers and UDD that apply to it. DescriptorId packId; - /// The dispatch descriptor id, denormalized from the pack so a caller holding only a - /// KernelDefinition can reach its dispatch without walking the pack list again. + /// Dispatch descriptor id, denormalized from the pack so a caller holding only a + /// KernelDefinition can reach it without walking the pack list. DescriptorId dispatchId; - /// Where this kernel's code comes from and how to load it. See KernelSource for why - /// this is a tagged union rather than two bare strings. + /// Where this kernel's code comes from and how to load it. KernelSource source; - /// Complete: every KMD field, with defaults filled in for fields the UKD omitted. + /// Every KMD field, with defaults filled in for fields the UKD omitted. MetadataValues metadata; int64_t priority = 0; - /// @brief The value of a KMD field, or nullopt when this kernel has no such field. - /// - /// Returns nullopt rather than throwing so a matcher or scorer written against a - /// newer schema can ask about a field an older kernel predates. + /// @brief The value of a KMD field, or nullopt if the kernel has no such field. std::optional tryGetMetadata(const std::string& field) const { auto it = metadata.find(field); @@ -57,9 +51,8 @@ struct KernelDefinition } /// @brief The integer value of a KMD field. - /// @throws std::out_of_range if the field is absent, std::invalid_argument if it - /// holds a different alternative. Both are author errors a validating - /// loader would have caught. + /// @throws std::out_of_range if absent, std::invalid_argument if a different + /// alternative is held. int64_t getIntMetadata(const std::string& field) const { auto it = metadata.find(field); @@ -95,8 +88,7 @@ struct KernelDefinition return *value; } - /// @brief The int-list value of a KMD field (e.g. `stride_order`). Throws on the - /// same terms as getIntMetadata. + /// @brief The int-list value of a KMD field (e.g. `stride_order`). const std::vector& getIntListMetadata(const std::string& field) const { auto it = metadata.find(field); diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp index c2bf860c5ea2..0f26c5c25f88 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/KernelIngestorStateManager.hpp @@ -29,15 +29,14 @@ namespace hipdnn_plugin_sdk::ingestor { -/// What a caller needs to size and launch one selected kernel: the kernel plus the -/// dispatch handler its pack's UDD resolved to. Copied out, so holding one does not -/// pin the state manager's internals. +/// What a caller needs to size and launch one selected kernel. Copied out, so +/// holding one does not pin the state manager's internals. template struct KernelDispatcher { KernelDefinition kernel; - /// Non-owning. Points at a provider-owned handler that must outlive any plan built - /// from this dispatcher (see NativeRegistry's DispatchRegistry note). + /// Non-owning; must outlive any plan built from this dispatcher (see + /// NativeRegistry's DispatchRegistry). const IKernelDispatchHandler* handler = nullptr; }; @@ -55,20 +54,18 @@ struct KernelDispatcher * | getMaxWorkspaceSize | getDispatchDetails() per survivor, then max | * | initializeExecutionContext | sortedDefinitions().front(), getDispatchDetails() | * - * Work is done as late as possible and kept: applicability only matches, ranking - * happens on first need and is cached alongside the catalog, both keyed on - * (graph, device) rather than the handle. + * Ranking is cached alongside the catalog, keyed on (graph, device) rather than the + * handle. * - * Thread-safe: the cache is internally synchronized, and matcher and scorer calls are - * made outside the lock, so one thread matching a graph never blocks another. + * Thread-safe: the cache is internally synchronized; matcher and scorer calls run + * outside the lock. */ template class KernelIngestorStateManager { public: - /// How many (graph, device) catalogs to retain. Entries hold ids and metadata values, - /// not kernels or graphs, so this is generous by design; eviction costs a rematch, - /// never a wrong answer. + /// How many (graph, device) catalogs to retain. Entries hold ids and metadata + /// values, not kernels or graphs; eviction costs a rematch, never a wrong answer. static constexpr size_t DEFAULT_CATALOG_CACHE_CAPACITY = 256; /** @@ -79,15 +76,13 @@ class KernelIngestorStateManager * @param packs The KDPs naming this engine. * @param heuristic That engine's UHD, resolved to a scorer. * - * @throws std::invalid_argument if a pack names a matcher or dispatch descriptor not - * supplied, or if two kernels share a metadata tuple: a dangling - * cross-reference cannot be evaluated, and duplicate catalog keys leave - * selection with two indistinguishable candidates. + * @throws std::invalid_argument if a pack names an unknown matcher or dispatch + * descriptor, or if two kernels share a metadata tuple (the catalog key + * must be unique). * - * Eagerly walks and validates every pack and kernel at plugin load, via - * validateAndIndexPacks() -- an amendment of RFC 0017 §3/§8's "nothing is parsed - * until a graph needs it". What stays lazy is the expensive part: a kernel source - * is not compiled and a heuristic model is not read until a graph needs it. + * Eagerly validates every pack and kernel at plugin load (validateAndIndexPacks()); + * kernel source compilation and heuristic model loading stay lazy until a graph + * needs them. */ KernelIngestorStateManager(MetadataSchema schema, std::vector matchers, @@ -110,8 +105,8 @@ class KernelIngestorStateManager const auto id = matcher.id; if(const auto [it, inserted] = _matchers.emplace(id, std::move(matcher)); !inserted) { - // Same defect NativeRegistry.hpp rejects for a duplicate symbol: a pack - // naming this id would silently run whichever matcher loaded first. + // A pack naming this id would otherwise silently run whichever + // matcher loaded first. throw std::invalid_argument("duplicate match descriptor id '" + toString(id) + "' collides with '" + it->second.name + "'"); } @@ -138,12 +133,8 @@ class KernelIngestorStateManager * @brief Every kernel that applies to the graph and device @p context names, in no * particular order. * - * On a cache hit, returns the cached catalog. Otherwise runs the matchers in pruning - * order — each pack's graph-scoped matchers first, then the kernel-scoped matchers - * over whatever survived — and caches the result. - * - * A graph carrying no identity cannot be cached, so it is matched fresh every call. - * That costs time, never correctness. + * Runs pack matchers in pruning order (graph-scoped, then kernel-scoped) and + * caches the result. A graph with no identity is matched fresh every call. */ std::vector unsortedDefinitions(const MatchContext& context) const { @@ -153,14 +144,9 @@ class KernelIngestorStateManager /** * @brief The unranked catalog and the state matching bound, from one lookup. * - * RFC 0017 §8.1 keeps the bound token state alongside the catalog so a dispatch - * handler sizing a workspace reads values the matcher already resolved rather than - * walking the graph again. Returned together with the entries so a caller needing - * both does not match twice for a graph with no identity to cache under. - * - * `bound` is empty when no graph-scoped matcher bound anything, which is legal: a - * pack whose launch geometry is fully determined by kernel metadata has nothing to - * bind. + * Returned together so a caller needing both does not match twice for a graph + * with no identity to cache under (RFC 0017 §8.1). `bound` is empty when no + * graph-scoped matcher bound anything. */ Catalog unsortedCatalog(const MatchContext& context) const { @@ -171,8 +157,8 @@ class KernelIngestorStateManager * @brief Every kernel that applies to the graph and device @p context names, best * first. * - * Returns the cached order if there is one; otherwise ranks the catalog and caches - * that order. If the catalog itself is missing, it is built first. + * Uses the cached order if present; otherwise ranks and caches it, building the + * catalog first if needed. */ std::vector sortedDefinitions(const MatchContext& context) const { @@ -182,8 +168,7 @@ class KernelIngestorStateManager /** * @brief The ranked catalog and the state matching bound, from one lookup. * - * A plan build needs both; returning them together avoids matching twice for a - * graph with no identity to cache under. + * Avoids matching twice for a graph with no identity to cache under. */ Catalog sortedCatalog(const MatchContext& context) const { @@ -206,10 +191,9 @@ class KernelIngestorStateManager /** * @brief Resolves how to size and launch @p kernel. - * @throws std::runtime_error if the kernel's pack is unknown or its UDD names a - * symbol the provider does not ship. Both fail closed: after applicability - * accepted a graph, hipDNN has already chosen this engine on that promise, so - * a missing dispatch is a hard error rather than a silent decline. + * @throws std::runtime_error if the kernel's dispatch descriptor is unknown or + * names a symbol the provider does not ship; fails hard rather than + * declining silently. */ KernelDispatcher getDispatchDetails(const KernelDefinition& kernel) const { @@ -226,9 +210,7 @@ class KernelIngestorStateManager /// @brief The distinct values @p field takes across @p kernels, in ranked-first order. /// - /// A knob offers what the catalog actually implements, never the KMD field's - /// theoretical range: offering a value no surviving kernel carries would produce a - /// request nothing can serve. + /// Reflects what the catalog implements, not the KMD field's theoretical range. static std::vector knobValues(const std::vector& kernels, const std::string& field) { @@ -286,12 +268,8 @@ class KernelIngestorStateManager } } - /// A kernel's declared values for the fields its engine's KMD declares, with the - /// KMD's defaults filled in for the ones it omitted. This completed tuple, not the - /// descriptor id, is what identifies the kernel to matching and ranking: a value the - /// schema never declared is unreadable to a matcher or a scorer, so admitting one - /// into the key would let two kernels that selection cannot tell apart both enter - /// the catalog. + /// A kernel's declared metadata values, with the KMD's defaults filled in for + /// omitted fields. This completed tuple, not the descriptor id, is the catalog key. MetadataValues completeMetadata(const KernelDescriptor& kernel) const { MetadataValues complete; @@ -302,9 +280,8 @@ class KernelIngestorStateManager if(it == kernel.metadata.end()) { - // A field with no default is one every kernel must state for itself, so - // an omission is an authoring error rather than a silent fallback: it - // would otherwise produce a catalog key the author never wrote. + // A field with no default must be set by every kernel; omission is + // an authoring error. if(!field.defaultValue.has_value()) { throw std::invalid_argument("kernel '" + toString(kernel.id) @@ -315,9 +292,8 @@ class KernelIngestorStateManager continue; } - // The KMD declares each field's type, so a value of the wrong one is caught - // here rather than surfacing as a bad_variant_access from a matcher or a - // scorer, far from the descriptor that caused it. + // Caught here rather than surfacing as a bad_variant_access far from the + // descriptor that caused it. if(metadataTypeOf(it->second) != field.type) { throw std::invalid_argument("kernel '" + toString(kernel.id) @@ -327,9 +303,8 @@ class KernelIngestorStateManager complete.emplace(field.name, it->second); } - // A value the schema does not declare cannot be read by anything downstream, so - // it is almost always a misspelled field name. Left in place it would be doubly - // silent: the real field takes its default, and the stray value joins the key. + // Rejects fields the schema doesn't declare (usually a misspelled name); + // otherwise silently ignored while joining the key. for(const auto& [name, value] : kernel.metadata) { if(complete.find(name) == complete.end()) @@ -344,9 +319,8 @@ class KernelIngestorStateManager return complete; } - /// The device comes from the context rather than a separate argument: taking it - /// twice would let a caller cache one device's catalog under another device's key, - /// which is a wrong answer rather than a missed hit. + /// Device comes from the context, not a separate argument, to avoid caching one + /// device's catalog under another device's key. std::optional cacheKey(const MatchContext& context) const { const auto graphId = tryGetGraphId(context.graph); @@ -387,9 +361,7 @@ class KernelIngestorStateManager } /// One graph-scoped matcher's verdict for one (graph, device), keyed by matcher id. - /// Matchers are shared by id across packs, so this memoizes both the verdict and - /// what it bound, isolated per matcher so a pack merges in exactly the matchers it - /// lists and never a sibling pack's contribution to another matcher's entry. + /// Memoizes per matcher so a pack merges only the matchers it lists. struct GraphMatcherVerdict { bool passed = false; @@ -401,15 +373,9 @@ class KernelIngestorStateManager /** * @brief Runs every pack's matchers over @p context, cheapest and broadest first. * - * Graph-scoped matchers read only graph and device facts, so each runs once per - * (graph, device) no matter how many packs list it; one failure disqualifies the - * whole pack before any kernel-scoped work. Kernel-scoped matchers then run once per - * surviving kernel. - * - * Each pack's graph-scoped bindings accumulate in a scope-local view and merge into - * the catalog's shared bound state only when the whole pack survives - * (graphLevelMatchersPass()); a pruned pack's bindings are discarded with its local - * view rather than leaking into a surviving pack's kernel. + * Graph-scoped matchers run once per (graph, device) regardless of how many packs + * list them; a failure disqualifies the pack before kernel-scoped matching runs. + * A pruned pack's graph-scoped bindings are discarded, never merged. */ Catalog buildCatalog(const MatchContext& context) const { @@ -418,20 +384,16 @@ class KernelIngestorStateManager for(const auto& pack : _packs) { - // Scoped to this pack; merged into catalog.bound below only if the pack - // survives (see buildCatalog()'s doc). + // Merged into catalog.bound below only if the pack survives. BoundTokens packBound; if(!graphLevelMatchersPass(pack, context, graphVerdicts, packBound)) { - // RFC 0017 §10: an operator must be able to see why a kernel was not - // selected. A pack pruned at the graph level is the coarsest, most - // common reason. + // RFC 0017 §10: surfaces the pack decline reason for operators. HIPDNN_PLUGIN_LOG_INFO("ingestor: pack " << toString(pack.id) << " declined at a graph-scoped matcher"); continue; } - // Merged rather than kept per pack (Catalog::bound's doc). mergeBound(catalog.bound, packBound, pack.id); size_t admitted = 0; @@ -456,16 +418,16 @@ class KernelIngestorStateManager << " kernel(s) after kernel-scoped matching"); } - // Without this, an empty catalog's only observable is a missing engine id. + // Logged unconditionally so an empty catalog is distinguishable from a + // missing engine id. HIPDNN_PLUGIN_LOG_INFO("ingestor: catalog for device " << context.deviceId << " holds " << catalog.entries.size() << " kernel(s) from " << _packs.size() << " pack(s)"); return catalog; } - /// Folds @p packBound into @p bound. Two packs writing DIFFERENT values under one - /// token name is an authoring error, detectable only here since it needs a runtime - /// match. + /// Folds @p packBound into @p bound. Throws if two packs bind the same token to + /// different values. static void mergeBound(BoundTokens& bound, const BoundTokens& packBound, const DescriptorId& packId) { @@ -483,9 +445,8 @@ class KernelIngestorStateManager } /** - * @param packBound Accumulates THIS pack's own graph-scoped matcher bindings. The - * caller merges it into the shared bound state only when this returns true; - * a pack this function prunes leaves it to be discarded unmerged. + * @param packBound Accumulates this pack's graph-scoped bindings; merged by the + * caller only if this returns true. */ bool graphLevelMatchersPass(const KernelDescriptorPack& pack, const MatchContext& context, @@ -503,8 +464,7 @@ class KernelIngestorStateManager auto memo = graphVerdicts.find(matcherId); if(memo == graphVerdicts.end()) { - // Evaluated once, on the first pack that lists this matcher; later - // packs reuse the verdict and bindings from the memo. + // Evaluated once per matcher; later packs reuse the memoized verdict. GraphMatcherVerdict verdict; verdict.passed = GraphMatcherRegistry::resolve(matcher.matchSymbol)(context, verdict.bound); @@ -516,7 +476,7 @@ class KernelIngestorStateManager return false; } - // Merges only THIS matcher's bindings into the pack's scoped view. + // Merges this matcher's bindings into the pack's scoped view. packBound.insert(memo->second.bound.begin(), memo->second.bound.end()); } return true; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp index d7663f3ce8f4..a5115c6aa950 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/LruCache.hpp @@ -19,24 +19,20 @@ namespace hipdnn_plugin_sdk::ingestor /** * @brief A bounded, thread-safe least-recently-used cache. * - * Sized by entry count rather than bytes: the ingestor's entries hold descriptor ids - * and bound field values, not kernels or graphs, so their size is bounded by - * construction. + * Sized by entry count rather than bytes: entries hold descriptor ids and bound field + * values, not kernels or graphs. * - * Eviction costs a rematch, never a wrong answer, so the capacity is a - * memory/latency tradeoff rather than a correctness one. + * Eviction costs a rematch, never a wrong answer. * * @tparam Key Must be hashable by @p Hash and equality-comparable. * @tparam Value Copied in and out; callers hold snapshots, not references into the cache. - * @tparam Hash Defaults to std::hash. Composite keys need an explicit hash, since - * the standard library provides none for std::pair. + * @tparam Hash Defaults to std::hash; composite keys need an explicit hash. */ template > class LruCache { public: - /// @throws std::invalid_argument if @p capacity is zero, which would make every - /// insertion immediately evict itself — always a caller bug, never intent. + /// @throws std::invalid_argument if @p capacity is zero. explicit LruCache(size_t capacity) : _capacity(capacity) { @@ -62,8 +58,8 @@ class LruCache return it->second->second; } - /// @brief Inserts or overwrites @p key, marking it most-recently-used and evicting - /// the least-recently-used entry if that pushes the cache over capacity. + /// @brief Inserts or overwrites @p key, marking it most-recently-used and evicting the + /// least-recently-used entry if that pushes the cache over capacity. void put(const Key& key, Value value) { const std::lock_guard lock(_mutex); @@ -102,8 +98,7 @@ class LruCache mutable std::mutex _mutex; size_t _capacity; - /// Most-recently-used first. A list so splicing an entry to the front on a hit - /// does not invalidate the iterators held in _index. + /// Most-recently-used first; a list keeps splice-to-front from invalidating _index iterators. std::list _order; std::unordered_map::iterator, Hash> _index; }; diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp index c7a1e7364452..66be4cc1eb0b 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/MatchContext.hpp @@ -22,28 +22,21 @@ namespace hipdnn_plugin_sdk::ingestor { -/// The device a catalog was built for. A plain HIP device ordinal, as returned by -/// hipGetDevice; hipDNN has no richer device-identity type today. +/// The device a catalog was built for: a plain HIP device ordinal (hipGetDevice). using DeviceId = int; -/// The device id meaning "this call has no resolvable device". Negative so it can never -/// alias a real ordinal: a resolver that cannot answer must not return 0, which is a -/// device other calls legitimately resolve and would then share a catalog and property -/// cache entry with. Matchers decline on it, so a call carrying no device finds no -/// applicable kernel rather than matching against another device's facts. +/// Device id meaning "no resolvable device". Negative so it never aliases a real ordinal; +/// matchers decline on it rather than matching against another device's facts. inline constexpr DeviceId NO_DEVICE = -1; -/// A finalized graph's stable identity, minted by hipDNN at finalization and preserved -/// across serialization round trips. +/// A finalized graph's stable identity, preserved across serialization round trips. using GraphId = hipdnn_flatbuffers_sdk::utilities::UuidBytes; -/// The catalog cache key. Excludes the handle: a handle can be swapped, rebound, or -/// destroyed while a plan built through it is still live, so keying on it would tie -/// cached work to a lifetime unrelated to that work's validity. +/// The catalog cache key. Excludes the handle: a handle's lifetime is unrelated to a plan's +/// validity, so keying on it would be wrong. /// -/// RFC 0017 §8.1 also keys this on engine id and folds in a descriptor-inventory -/// generation (§8.6); both are constant today (one engine owns one manager, and the -/// pack set is fixed at compile time) so they are omitted until that changes. +/// RFC 0017 §8.1 also keys this on engine id and a descriptor-inventory generation (§8.6); +/// both are constant today so they are omitted. struct CatalogKey { GraphId graphId; @@ -60,8 +53,7 @@ struct CatalogKeyHash { size_t operator()(const CatalogKey& key) const noexcept { - // The graph id is already a well-distributed UUID v4; folding its bytes and - // mixing in the device ordinal is enough for an in-process cache. + // UUID v4 bytes are already well-distributed; this folds them with the device ordinal. size_t hash = 1469598103934665603ULL; for(const uint8_t byte : key.graphId) { @@ -73,18 +65,11 @@ struct CatalogKeyHash } }; -/** - * @brief What matching resolved about one graph, carried forward to dispatch. - * - * Keyed by the token name a descriptor would write; values are MetadataValue, the same - * type a KMD field holds, so a criteria expression can compare a graph fact and a kernel - * fact with one operator (RFC 0017 §8.5, §8.1). - */ +/// Token name to MetadataValue map of what matching resolved for one graph. using BoundTokens = std::unordered_map; -/// Reads @p token from @p bound as an integer. Returns nullopt when absent or bound to -/// a non-integer value: conflating "not bound" with "bound to something else" would let -/// a type confusion read as a missing token. +/// Returns nullopt when absent or bound to a non-integer value, so type confusion never reads +/// as a missing token. inline std::optional tryGetBoundInt(const BoundTokens& bound, std::string_view token) { const auto it = bound.find(std::string(token)); @@ -101,11 +86,10 @@ inline std::optional tryGetBoundInt(const BoundTokens& bound, std::stri } /** - * @brief The bound token state a matcher, scorer, or dispatch formula reads: `$graph.*` - * and `$device.*`. `$kernel.*` arrives separately as a KernelDefinition, evaluated - * per candidate rather than once per (graph, device). + * @brief Bound token state a matcher, scorer, or dispatch formula reads (`$graph.*`, + * `$device.*`); `$kernel.*` arrives separately as a KernelDefinition per candidate. * - * Holds references, not copies: built on the stack for one matching pass, and must not + * Holds references, not copies: built on the stack for one matching pass and must not * outlive the graph it names. */ struct MatchContext @@ -115,13 +99,8 @@ struct MatchContext const hipDeviceProp_t& deviceProperties; }; -/// @brief The graph's stable identity, or nullopt when it has none. -/// -/// Absent on legacy or never-finalized graphs. Callers must treat that as "cannot -/// cache", not an error: matching still produces the right answer, just unmemoized. -/// -/// A present but non-v4 id also reads as "no identity" — uniqueness is not otherwise -/// guaranteed, so cache-key correctness depends on isUuidV4()'s exact predicate. +/// @brief The graph's stable identity, or nullopt when absent (legacy/unfinalized graphs) or +/// non-v4 (uniqueness is otherwise unguaranteed). Callers must treat both as "cannot cache". inline std::optional tryGetGraphId(const hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph& graph) { diff --git a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp index d342c1da7f8e..ea4a1f2acdbf 100644 --- a/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp +++ b/projects/hipdnn/plugin_sdk/include/hipdnn_plugin_sdk/ingestor/NativeRegistry.hpp @@ -19,45 +19,32 @@ * @brief Symbol name to native function, the escape hatch descriptors resolve through. * * A descriptor names a custom predicate or plan by symbol rather than carrying inline - * code (RFC 0017); the provider resolves it here. Lookup fails closed: a symbol the - * provider does not ship throws rather than being silently skipped. + * code (RFC 0017); the provider resolves it here. Lookup fails closed. */ namespace hipdnn_plugin_sdk::ingestor { -/// A graph-scoped applicability check: reads only graph and device facts, so it runs -/// once per (graph, device) and its failure prunes every kernel in the pack. -/// -/// On success, writes what it resolved into @p bound (RFC 0017 §8.5), which the catalog -/// keeps and dispatch reads back instead of re-deriving it. A matcher that binds -/// nothing leaves @p bound alone. Kernel-scoped matchers never bind: their result is -/// per-kernel, with nowhere in the catalog to live. -/// -/// Must be thread-safe: the registry may call it concurrently, same contract as -/// `IKernelDispatchHandler::launch`. +/// Graph-scoped applicability check: reads only graph/device facts, so it runs once per +/// (graph, device); failure prunes every kernel in the pack. On success, writes resolved +/// values into @p bound (RFC 0017 §8.5). Must be thread-safe. using GraphMatcherFn = bool (*)(const MatchContext&, BoundTokens& bound); -/// A kernel-scoped applicability check: also reads the candidate's metadata, so it runs +/// Kernel-scoped applicability check: also reads the candidate's metadata, so it runs /// once per surviving kernel and disqualifies that kernel alone. Must be thread-safe. using KernelMatcherFn = bool (*)(const MatchContext&, const KernelDefinition&); -/// Scores one kernel for one problem. Higher is better. -/// -/// Deliberately never handed the catalog: see IKernelHeuristic for why that constraint -/// is what keeps a knob-filtered query consistent with the default it reported. Must be -/// thread-safe. +/// Scores one kernel for one problem. Higher is better. Never handed the catalog (see +/// IKernelHeuristic). Must be thread-safe. using ScoreFn = double (*)(const KernelDefinition&, const MatchContext&); /** * @brief The provider's registry of native implementations, keyed by symbol name. * - * One instance per registered type per loaded image, not per process: isolation - * between two loaded copies depends on the provider building with - * `CXX_VISIBILITY_PRESET hidden` and `--exclude-libs=ALL` (`src/CMakeLists.txt`) — an - * exported symbol would let two copies share one registry and the second - * `registerSymbol()` throw. + * One instance per registered type per loaded image: requires `CXX_VISIBILITY_PRESET + * hidden` and `--exclude-libs=ALL` (`src/CMakeLists.txt`) so two loaded copies do not + * share a registry. * - * Guarded by a mutex: registration and lookup can race across threads. + * Thread-safe: registration and lookup are mutex-guarded. * * @tparam T The registered callable or interface pointer type. */ @@ -66,11 +53,10 @@ class NativeRegistry { public: /** - * @brief Registers @p implementation under @p symbol. - * @throws std::runtime_error if @p symbol is already registered — always an author - * bug, since which implementation would silently win depends on static-init - * order. - */ + * @brief Registers @p implementation under @p symbol. + * @throws std::runtime_error if @p symbol is already registered (which wins would + * depend on static-init order). + */ static void registerSymbol(const std::string& symbol, T implementation) { auto& self = instance(); @@ -100,9 +86,8 @@ class NativeRegistry return it->second; } - /// @brief Removes @p symbol if present. Exists so a test can install a counting or - /// failing implementation and restore the original afterwards; production code - /// registers once at static-init time and never unregisters. + /// @brief Removes @p symbol if present. Used by tests to swap in a temporary + /// implementation and restore the original; production code never unregisters. static void unregisterSymbol(const std::string& symbol) { auto& self = instance(); @@ -113,8 +98,7 @@ class NativeRegistry private: static NativeRegistry& instance() { - // Function-local static: initialized on first use, immune to the unspecified - // init order of the namespace-scope registrations that populate it. + // Function-local static: avoids depending on unspecified namespace-scope init order. static NativeRegistry s_instance; return s_instance; } @@ -127,9 +111,8 @@ using GraphMatcherRegistry = NativeRegistry; using KernelMatcherRegistry = NativeRegistry; using ScoreRegistry = NativeRegistry; -/// Registered as non-owning pointers: a handler holds provider state (a compiler, -/// a module cache) whose lifetime is the provider's. The provider must keep the -/// handler alive for as long as any plan built from it can execute. +/// Non-owning: the provider owns each handler and must keep it alive for as long as +/// any plan built from it can execute. template using DispatchRegistry = NativeRegistry*>; diff --git a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp index bf2220720f80..e9580281763f 100644 --- a/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp +++ b/projects/hipdnn/plugin_sdk/tests/TestKernelIngestorConcurrency.cpp @@ -20,16 +20,8 @@ /** * @file TestKernelIngestorConcurrency.cpp - * @brief The ingestor's shared state under concurrent use. - * - * One engine, and therefore one catalog cache, is shared by every handle in the process, - * and hipDNN may drive several handles from several threads at once. These tests pin the - * two properties that arrangement depends on: the shared state stays internally - * consistent under concurrent access, and the answers it gives do not depend on which - * thread asked or on what any other thread was doing. - * - * They are worth running under thread and address sanitizers, where a data race in the - * cache would be reported directly rather than inferred from a corrupted result. + * @brief Concurrency tests for the ingestor's shared per-process catalog cache: internal + * consistency and thread/order independence under concurrent access. */ namespace { @@ -40,8 +32,7 @@ using namespace hipdnn_plugin_sdk::ingestor::testing; constexpr int THREAD_COUNT = 8; constexpr int ITERATIONS_PER_THREAD = 200; -/// Runs @p body on several threads at once, releasing them together so their work -/// genuinely overlaps rather than serializing by startup order. +/// Runs @p body on threadCount threads, releasing them together so they truly overlap. template void runConcurrently(int threadCount, Body body) { @@ -73,8 +64,8 @@ void runConcurrently(int threadCount, Body body) TEST(TestIngestorCacheConcurrency, SurvivesConcurrentReadsAndWrites) { - // Capacity far below the key range, so eviction runs constantly and readers are - // touching entries writers are simultaneously retiring. + // Capacity far below the key range: eviction runs constantly, so readers race + // evicting writers on the same entries. LruCache cache(16); runConcurrently(THREAD_COUNT, [&cache](int thread) { @@ -83,8 +74,7 @@ TEST(TestIngestorCacheConcurrency, SurvivesConcurrentReadsAndWrites) const int key = (thread * ITERATIONS_PER_THREAD + i) % 128; cache.put(key, key * 2); - // A hit must carry the value stored under that key; a miss is legitimate - // because another thread may have evicted it in between. + // Hit must match; a miss is legal (raced with eviction). if(const auto found = cache.get(key); found.has_value()) { EXPECT_EQ(*found, key * 2); @@ -106,8 +96,7 @@ TEST(TestIngestorCacheConcurrency, NeverExceedsCapacityUnderContention) } }); - // The bound is what makes this cache safe to leave running in a long-lived process: - // concurrent insertion must not be able to grow it without limit. + // Concurrent insertion must not grow the cache past capacity. EXPECT_LE(cache.size(), cache.capacity()); } @@ -122,8 +111,7 @@ TEST(TestIngestorStateManagerConcurrency, ServesOneGraphFromManyThreadsConsisten const TestGraph graph(makeGraphId(0x21)); const auto properties = testDeviceProperties(); - // Every thread asks the same question of the same shared cache, the way several - // handles bound to one device would. + // Same graph and device from every thread: shared-cache read contention. runConcurrently(THREAD_COUNT, [&](int) { for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { @@ -141,8 +129,7 @@ TEST(TestIngestorStateManagerConcurrency, RanksConsistentlyFromManyThreads) const TestGraph graph(makeGraphId(0x22)); const auto properties = testDeviceProperties(); - // Ranking writes the sorted catalog back into the cache, so this is the path where - // concurrent readers and a writer meet. Every thread must still see the same winner. + // Ranking writes the sorted catalog back: readers and the writer race here. runConcurrently(THREAD_COUNT, [&](int) { for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { @@ -161,8 +148,8 @@ TEST(TestIngestorStateManagerConcurrency, KeepsPerDeviceCatalogsDistinct) const TestGraph graph(makeGraphId(0x23)); const auto properties = testDeviceProperties(); - // One graph, several devices, all at once: the catalog is keyed on both, so a - // device's answer must never be served from another device's entry. + // Same graph, distinct devices concurrently: catalog is keyed on both and must not + // cross-serve. runConcurrently(THREAD_COUNT, [&](int thread) { const DeviceId deviceId = thread % 4; for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) @@ -178,8 +165,8 @@ TEST(TestIngestorStateManagerConcurrency, ServesUncacheableGraphsConcurrently) { const ScopedTestSymbols symbols; const auto manager = makeTestStateManager(); - // No identity, so every call rematches instead of reading the cache. That path runs - // the matchers concurrently rather than serializing behind a cached result. + // No graph identity: every call rematches, exercising concurrent matcher execution + // instead of the cache. const TestGraph graph; const auto properties = testDeviceProperties(); @@ -195,8 +182,7 @@ TEST(TestIngestorStateManagerConcurrency, ServesUncacheableGraphsConcurrently) TEST(TestIngestorStateManagerConcurrency, EvictsUnderConcurrentDistinctGraphs) { const ScopedTestSymbols symbols; - // Capacity 2 against many distinct graphs, so entries are evicted while other - // threads are reading and writing them. + // Capacity 2 against many distinct graphs: constant eviction races readers/writers. const auto manager = makeTestStateManager(2); const auto properties = testDeviceProperties(); @@ -206,8 +192,7 @@ TEST(TestIngestorStateManagerConcurrency, EvictsUnderConcurrentDistinctGraphs) const TestGraph graph(makeGraphId(static_cast((thread * 7 + i) % 32))); const MatchContext context{graph, 0, properties}; - // Eviction costs a rematch and never a wrong answer, so the result is the - // same whether this call hit the cache or rebuilt the catalog. + // Eviction only costs a rematch, never a wrong answer. ASSERT_EQ(manager->unsortedDefinitions(context).size(), 2U); } }); @@ -221,7 +206,7 @@ TEST(TestIngestorRegistryConcurrency, ResolvesFromManyThreads) { const ScopedTestSymbols symbols; - // Resolution happens on the applicability path, which several threads reach at once. + // Exercises concurrent resolve() from the applicability path. runConcurrently(THREAD_COUNT, [](int) { for(int i = 0; i < ITERATIONS_PER_THREAD; ++i) { diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp index 4a3c0bc83669..5fc39d3e8182 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/IngestorMocks.hpp @@ -19,17 +19,15 @@ * @file IngestorMocks.hpp * @brief gmock doubles for the ingestor's two pure interfaces. * - * IKernelDispatchHandler and IDeviceResolver have no test of their own: they are - * asserted only through the generic code that consumes them -- GenericPlan/ - * GenericPlanBuilder for the dispatch handler, GenericPlanBuilder::contextFor for the - * device resolver. These mocks are what those tests set expectations against. + * IKernelDispatchHandler and IDeviceResolver are exercised only through the generic code + * that consumes them (GenericPlan/GenericPlanBuilder for the dispatch handler, + * GenericPlanBuilder::contextFor for the device resolver); these mocks back those tests. */ namespace hipdnn_plugin_sdk::ingestor::testing { /// Mocks the native dispatch escape hatch, so GenericPlan/GenericPlanBuilder tests can -/// assert exactly what a plan asked of it (workspace queried before prepare, buffers -/// forwarded to launch unchanged) without a real kernel launch. +/// assert what a plan asked of it without a real kernel launch. class MockKernelDispatchHandler : public IKernelDispatchHandler { public: @@ -55,10 +53,9 @@ class MockKernelDispatchHandler : public IKernelDispatchHandler (const, override)); }; -/// Mocks device resolution so GenericPlanBuilder::contextFor() can be shown folding -/// per-handle device facts into the MatchContext it builds: two handles that resolve to -/// different devices must reach the matchers with different DeviceId/deviceProperties, -/// proving the resolution happens per call rather than once at construction. +/// Mocks device resolution so GenericPlanBuilder::contextFor() can be shown resolving +/// per-handle device facts into the MatchContext it builds, per call rather than once at +/// construction. class MockDeviceResolver : public IDeviceResolver { public: diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp index e96cf470a69a..4690607d430e 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/KernelIngestorTestFixtures.hpp @@ -31,23 +31,20 @@ /** * @file KernelIngestorTestFixtures.hpp - * @brief Shared fixtures for the ingestor's SDK-level tests, split across - * `plugin_sdk/tests/ingestor/`. + * @brief Shared fixtures for the ingestor's SDK-level tests under `plugin_sdk/tests/ingestor/`. * * Everything here is `inline`: this header is included by several translation units in - * the same test binary, so a plain free function or namespace-scope object would violate - * the one-definition rule the moment two of those files linked together. + * the same test binary. * - * Two catalog shapes recur across the split files and are built here once: - * - The `int`-handle shape (`TestHandle`/`StateManager`/`makeStateManager()`): a pack of - * two FLOAT kernels differing in block size plus one HALF kernel a kernel-scoped - * matcher prunes, wired to counting matchers so a test can assert how often each - * scope ran. - * - The `int`-handle shape from `makeTestStateManager()`: the same catalog, wired to - * fixed (non-counting) matchers, for tests that only need a stable answer. - * - The `StubHandle` shape (`StubSettings`/`StubContext`/`makeStubStateManager()`): - * minimal stand-ins for the provider types `GenericEngine`/`GenericPlanBuilder` are - * parameterized on, for tests that construct those templates directly. + * Two catalog shapes recur: + * - `int`-handle (`TestHandle`/`StateManager`/`makeStateManager()`): two FLOAT kernels + * differing in block size plus one HALF kernel a kernel-scoped matcher prunes, wired + * to counting matchers. + * - `int`-handle via `makeTestStateManager()`: same catalog, wired to fixed + * (non-counting) matchers, for tests needing only a stable answer. + * - `StubHandle` (`StubSettings`/`StubContext`/`makeStubStateManager()`): minimal + * stand-ins for the provider types `GenericEngine`/`GenericPlanBuilder` are + * parameterized on. */ namespace hipdnn_plugin_sdk::ingestor::testing { @@ -58,14 +55,12 @@ constexpr const char* GRAPH_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.graph_ma constexpr const char* KERNEL_MATCH_SYMBOL = "hipdnn.kernel_ingestor.test.kernel_match"; constexpr const char* SCORE_SYMBOL = "hipdnn.kernel_ingestor.test.score"; -/// A minimal IGraph. The SDK-side machinery reads only the graph's identity, so the rest -/// of the interface throws: a test that starts depending on graph contents should fail -/// loudly rather than silently match against an empty graph. +/// Minimal IGraph exposing only identity; every other member throws so a test depending +/// on graph contents fails loudly. class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph { public: - /// @param graphId The identity to carry, or nullopt to model a legacy or unfinalized - /// graph that has none. + /// @param graphId Identity to carry, or nullopt for a legacy/unfinalized graph. explicit TestGraph(std::optional graphId = std::nullopt) { flatbuffers::Offset graph; @@ -137,8 +132,7 @@ class TestGraph : public hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph _tensors; }; -/// A test graph id distinct per seed, shaped as a valid v4 UUID (tryGetGraphId() now -/// rejects non-v4 ids as "no identity"). +/// Test graph id distinct per seed, shaped as a valid v4 UUID. inline GraphId makeGraphId(uint8_t seed) { GraphId id{}; @@ -148,7 +142,7 @@ inline GraphId makeGraphId(uint8_t seed) return id; } -/// A distinct-per-seed id shaped to fail the v4 check, for tests needing a non-nil +/// Distinct-per-seed id shaped to fail the v4 check, for tests needing a non-nil /// "no identity" id. inline GraphId makeNonV4GraphId(uint8_t seed) { @@ -158,7 +152,7 @@ inline GraphId makeNonV4GraphId(uint8_t seed) return id; } -/// The nil UUID: all-zero bytes, the id a default-constructed field reads as. +/// The nil UUID: all-zero bytes. inline GraphId makeNilGraphId() { return GraphId{}; @@ -176,20 +170,20 @@ inline bool acceptAnyGraph(const MatchContext& /*context*/, BoundTokens& /*bound return true; } -/// Accepts only FLOAT kernels, so a pack carrying a HALF kernel is pruned down. +/// Accepts only FLOAT kernels; a HALF kernel in the pack is pruned. inline bool acceptFloatKernels(const MatchContext& /*context*/, const KernelDefinition& kernel) { return kernel.getStringMetadata(DTYPE) == "FLOAT"; } -/// Bigger block size scores higher, so ranking has a defined winner. +/// Bigger block size scores higher, giving ranking a defined winner. inline double scoreByBlockSize(const KernelDefinition& kernel, const MatchContext& /*context*/) { return static_cast(kernel.getIntMetadata(BLOCK_SIZE)); } -/// Registers this fixture's symbols for a test's duration, so tests sharing the -/// process-wide registry stay independent of one another's registrations. +/// RAII: registers this fixture's symbols for the object's lifetime, so tests sharing +/// the process-wide registry stay independent. class ScopedTestSymbols { public: @@ -211,8 +205,7 @@ class ScopedTestSymbols ScopedTestSymbols& operator=(const ScopedTestSymbols&) = delete; }; -/// A device resolver for tests that construct an engine. Reports one fixed device, since -/// nothing here exercises multi-device behavior. +/// Device resolver reporting one fixed device; no multi-device behavior is exercised. class TestDeviceResolver : public IDeviceResolver { public: @@ -230,8 +223,7 @@ class TestDeviceResolver : public IDeviceResolver hipDeviceProp_t _properties = testDeviceProperties(); }; -/// A descriptor id from a short seed, so a fixture can name ids readably while the type -/// stays the real 128-bit one. +/// Descriptor id built from a short seed, keeping the real 128-bit type readable. inline DescriptorId testId(uint8_t seed) { DescriptorId id{}; @@ -261,8 +253,8 @@ inline KernelDescriptor makeTestKernel(const DescriptorId& id, return kernel; } -/// Two FLOAT kernels differing only in block size, plus a HALF kernel the kernel-scoped -/// matcher prunes: a catalog of two survivors with a defined ranking. +/// Two FLOAT kernels differing in block size, plus a HALF kernel the kernel-scoped +/// matcher prunes: two survivors with a defined ranking. inline std::unique_ptr> makeTestStateManager(size_t cacheCapacity = KernelIngestorStateManager::DEFAULT_CATALOG_CACHE_CAPACITY) @@ -270,7 +262,7 @@ inline std::unique_ptr> MetadataSchema schema; schema.id = SCHEMA_ID; schema.name = "test schema"; - // block_size defaults; dtype is mandatory, so every kernel below states it. + // block_size has a default; dtype is mandatory, so every kernel below sets it. schema.fields = {{BLOCK_SIZE, MetadataType::INT, MetadataValue{int64_t{64}}}, {DTYPE, MetadataType::STRING, std::nullopt}}; @@ -299,14 +291,11 @@ inline std::unique_ptr> cacheCapacity); } -// --------------------------------------------------------------------------- -// Counting matchers: the `TestHandle = int` catalog wired to observe how often each -// matcher scope ran. Shared by TestKernelIngestorStateManager.cpp and -// TestGenericPlanBuilder.cpp. -// --------------------------------------------------------------------------- +// Counting matchers: `TestHandle = int` catalog instrumented to count matcher-scope +// calls. Shared by TestKernelIngestorStateManager.cpp and TestGenericPlanBuilder.cpp. -/// Counts matcher invocations, so a test can assert that a graph-scoped matcher runs -/// once per (graph, device) while a kernel-scoped one runs once per surviving kernel. +/// Counts matcher calls: graph-scoped runs once per (graph, device); kernel-scoped runs +/// once per surviving kernel. struct MatcherCounters { int graphCalls = 0; @@ -325,14 +314,13 @@ inline MatcherCounters& counters() return s_counters; } -/// An arbitrary value acceptGraph binds, so a test can prove it reached dispatch intact. +/// Value acceptGraph binds, so a test can verify it reaches dispatch intact. constexpr int64_t BOUND_TOKEN_VALUE = 4242; inline bool acceptGraph(const MatchContext& /*context*/, BoundTokens& bound) { ++counters().graphCalls; - // Stands in for the tensor uids and dimensions a real matcher resolves: the point - // under test is that whatever matching bound reaches dispatch without a re-match. + // Stands in for the tensor uids/dimensions a real matcher would resolve. bound["test.bound_token"] = BOUND_TOKEN_VALUE; return true; } @@ -357,8 +345,8 @@ inline double scoreConstant(const KernelDefinition& /*kernel*/, const MatchConte return 1.0; } -/// Registers the constant scorer for a test's duration. The heuristic resolves its -/// symbol on first use, so the registration has to outlive the ranking call. +/// RAII: registers the constant scorer. Must outlive the ranking call since the +/// heuristic resolves its symbol lazily. class ScopedConstantScore { public: @@ -421,7 +409,7 @@ inline std::vector makeTestDispatches() return {{DISPATCH_ID, "test dispatch", "test.dispatch"}}; } -/// A catalog entry, for the ranking and plan tests that build one directly. +/// A catalog entry built directly by ranking and plan tests. inline KernelDefinition makeDefinition(const DescriptorId& id, int64_t blockSize, int64_t priority = 0) { @@ -433,8 +421,8 @@ inline KernelDefinition priority}; } -/// Registers counting matchers under this file's own symbol names, so these tests -/// observe invocation counts without disturbing the shared fixture's registrations. +/// RAII: registers matchers under caller-supplied symbol names without disturbing the +/// shared fixture's registrations. class ScopedSymbols { public: @@ -447,8 +435,8 @@ class ScopedSymbols { GraphMatcherRegistry::registerSymbol(_graphSymbol, graphFn); KernelMatcherRegistry::registerSymbol(_kernelSymbol, kernelFn); - // The heuristic resolves its symbol on the first call that needs an order, not - // at construction, so ranking tests need it registered for their duration too. + // Heuristic resolves its symbol lazily, not at construction; ranking tests need + // it registered for the duration too. ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); counters().reset(); } @@ -489,8 +477,8 @@ inline std::unique_ptr cacheCapacity); } -/// Registers @p handler under @p symbol in DispatchRegistry for a test's -/// duration. +/// Registers @p handler under @p symbol in DispatchRegistry for the object's +/// lifetime. template class ScopedDispatchRegistration { @@ -513,12 +501,9 @@ class ScopedDispatchRegistration std::string _symbol; }; -// --------------------------------------------------------------------------- -// StubHandle: minimal stand-ins for the provider types GenericEngine/GenericPlanBuilder -// are parameterized on. Only the members those templates actually touch are present, so -// a change that starts depending on more of a real handle or context fails here rather -// than compiling silently. -// --------------------------------------------------------------------------- +// StubHandle: minimal stand-ins for the types GenericEngine/GenericPlanBuilder are +// parameterized on. Only members those templates touch are present, so a template +// depending on more fails to compile here. struct StubHandle { @@ -532,9 +517,8 @@ struct StubHandle std::vector> _buffers; }; -/// Every TSettings GenericPlanBuilder is instantiated over must carry this field (see -/// KnobFilter's doc): initializeExecutionSettings() populates it, getMaxWorkspaceSize() -/// reads it back. +/// Every TSettings GenericPlanBuilder is instantiated over must carry this field; +/// initializeExecutionSettings() populates it, getMaxWorkspaceSize() reads it back. struct StubSettings { KnobFilter ingestorKnobFilter; @@ -553,9 +537,8 @@ struct StubContext std::unique_ptr> _plan; }; -/// A device resolver over StubHandle, for the engine-level tests. Reports one fixed -/// device; tests needing per-handle device resolution use IngestorMocks.hpp's -/// MockDeviceResolver instead. +/// Device resolver over StubHandle reporting one fixed device; use IngestorMocks.hpp's +/// MockDeviceResolver for per-handle resolution. class StubDeviceResolver : public IDeviceResolver { public: @@ -573,8 +556,7 @@ class StubDeviceResolver : public IDeviceResolver hipDeviceProp_t _properties = testDeviceProperties(); }; -/// A state manager over StubHandle: one FLOAT kernel behind one graph-scoped matcher, -/// for the GenericEngine-level tests that only need construction to succeed. +/// State manager over StubHandle: one FLOAT kernel behind one graph-scoped matcher. inline std::unique_ptr> makeStubStateManager() { MetadataSchema schema; @@ -612,10 +594,8 @@ inline EngineDescriptor makeEngineWithKnobs(std::vector knobs) return engine; } -/// An IEngineConfig setting `knobName` to `value`, built the same way -/// TestEngineConfigWrapper.cpp builds one: a real flatbuffer, not a mock, so tests using -/// this exercise the same parsing path a real caller's setAttribute() eventually -/// produces. +/// IEngineConfig setting `knobName` to an int `value`, built as a real flatbuffer (the +/// same parsing path a real caller's setAttribute() eventually produces). inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeIntKnobEngineConfig( flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, int64_t value) { @@ -631,8 +611,8 @@ inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeInt builder.GetBufferPointer(), builder.GetSize()); } -/// A FloatValue knob setting, so a test can reach readKnobFilter()'s type-rejection -/// branch -- every other builder here only produces IntValue. +/// FloatValue knob setting, to reach readKnobFilter()'s type-rejection branch (other +/// builders here produce IntValue). inline hipdnn_flatbuffers_sdk::flatbuffer_utilities::EngineConfigWrapper makeFloatKnobEngineConfig( flatbuffers::FlatBufferBuilder& builder, const std::string& knobName, double value) { diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp index b3a8a264e1a2..66a741caaa39 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestDescriptors.cpp @@ -15,12 +15,8 @@ /** * @file TestDescriptors.cpp - * @brief Unit tests for Descriptors.hpp: id formatting and hashing, the unified - * MetadataValue/MetadataType pairing over vector, and the KernelSource - * tagged union. - * - * makeKernelHeuristic() is declared in IKernelHeuristic.hpp, not here, so its tests live - * in TestKernelHeuristic.cpp; this file covers HeuristicDescriptor purely as data. + * @brief Tests for Descriptors.hpp: id formatting/hashing, the MetadataValue/MetadataType + * pairing, and the KernelSource tagged union. */ namespace { @@ -60,8 +56,7 @@ TEST(TestIngestorDescriptors, DescriptorIdHashDistinguishesDifferentIds) } // --------------------------------------------------------------------------- -// metadataTypeOf: MetadataType must track MetadataValue's variant order exactly, one -// alternative at a time, so a mismatch here means the enum and the variant have drifted. +// metadataTypeOf: MetadataType must track MetadataValue's variant order exactly. // --------------------------------------------------------------------------- struct MetadataTypeCase @@ -93,13 +88,12 @@ INSTANTIATE_TEST_SUITE_P( [](const ::testing::TestParamInfo& info) { return info.param.name; }); // --------------------------------------------------------------------------- -// KernelSourceKind / KernelSource: the tagged union over RFC 0017 §7's source kinds. +// KernelSourceKind / KernelSource: tagged union over RFC 0017 §7's source kinds. // --------------------------------------------------------------------------- TEST(TestIngestorDescriptors, KernelSourceDefaultsToEmbeddedSource) { - // The only kind this POC implements, and the kind every existing fixture kernel - // relies on defaulting to. + // Only kind this POC implements. const KernelSource source{}; EXPECT_EQ(source.kind, KernelSourceKind::EMBEDDED_SOURCE); @@ -119,8 +113,7 @@ TEST(TestIngestorDescriptors, KernelSourceCarriesEmbeddedSourceFileAndEntryPoint } // --------------------------------------------------------------------------- -// HeuristicDescriptor: HeuristicKind as data. The adapter dispatch on it is -// makeKernelHeuristic()'s behavior, tested in TestKernelHeuristic.cpp. +// HeuristicDescriptor: HeuristicKind as data; dispatch is tested in TestKernelHeuristic.cpp. // --------------------------------------------------------------------------- TEST(TestIngestorDescriptors, HeuristicDescriptorDefaultsToNativeKind) diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp index 993384a87f8e..5b6949cf7e20 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericEngine.cpp @@ -21,9 +21,8 @@ /** * @file TestGenericEngine.cpp - * @brief Unit tests for GenericEngine.hpp: engine-level knob validation at construction, - * and every IEngine override GenericEngine implements by delegating to its plan - * builder and state manager. + * @brief Unit tests for GenericEngine.hpp: knob validation at construction and + * IEngine overrides delegating to the plan builder and state manager. */ namespace { @@ -33,8 +32,8 @@ using namespace hipdnn_plugin_sdk::ingestor::testing; using StubEngine = GenericEngine; -// GenericEngine holds a plan builder bound by reference to its own members (see the -// class doc); relocating it would dangle those references. +// GenericEngine holds its plan builder by reference to its own members, so it is +// non-movable and non-copyable. static_assert(!std::is_move_constructible_v); static_assert(!std::is_move_assignable_v); static_assert(!std::is_copy_constructible_v); @@ -58,18 +57,14 @@ TEST(TestIngestorGenericEngine, RejectsAKnobNamingNoMetadataField) const ScopedTestSymbols symbols; const StubDeviceResolver resolver; - // A knob is only a name: the field it points at supplies the type, the default and - // the legal values, so a knob matching no field can never be reported or honoured. - // Left unchecked it is silently dropped, which reads to a caller exactly like a knob - // the engine chose not to expose. + // A knob naming no metadata field would otherwise be silently dropped. EXPECT_THROW( (StubEngine(makeEngineWithKnobs({"no_such_field"}), makeStubStateManager(), resolver)), std::invalid_argument); } // --------------------------------------------------------------------------- -// IEngine overrides: each one delegates to the plan builder or the state manager, so -// these tests exercise that delegation end to end rather than re-testing the builder. +// IEngine overrides: each delegates to the plan builder or the state manager. // --------------------------------------------------------------------------- TEST(TestIngestorGenericEngine, IdHashesTheUedNameIntoHipdnnsEngineIdSpace) @@ -78,9 +73,7 @@ TEST(TestIngestorGenericEngine, IdHashesTheUedNameIntoHipdnnsEngineIdSpace) const StubDeviceResolver resolver; const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); - // The id is derived, not stored verbatim, so this only pins that construction - // succeeds in producing a stable, non-zero id -- an engine with no id could never - // be looked up by the host. + // Pins that construction produces a stable, non-zero id. EXPECT_NE(engine.id(), 0); } @@ -98,8 +91,8 @@ TEST(TestIngestorGenericEngine, IsApplicableTrueWhenTheStateManagerHasASurviving TEST(TestIngestorGenericEngine, IsApplicableFalseWhenNoMatcherAccepts) { - // A distinct symbol so this test's registration does not collide with - // ScopedTestSymbols' GRAPH_MATCH_SYMBOL running concurrently in another test. + // A distinct symbol avoids colliding with ScopedTestSymbols' matcher running + // concurrently in another test. constexpr const char* REJECT_SYMBOL = "hipdnn.kernel_ingestor.test.generic_engine.reject"; GraphMatcherRegistry::registerSymbol(REJECT_SYMBOL, &rejectGraph); ScoreRegistry::registerSymbol(SCORE_SYMBOL, &scoreByBlockSize); @@ -162,9 +155,8 @@ TEST(TestIngestorGenericEngine, GetDetailsReportsTheEnginesKnobs) TEST(TestIngestorGenericEngine, GetMaxWorkspaceSizeDelegatesToThePlanBuilder) { - // The single-kernel stub state manager has no dispatch symbol registered, so the - // plan builder's own workspace query (through getDispatchDetails) throws -- proving - // the call actually reached the plan builder rather than returning a stub zero. + // No dispatch symbol is registered, so getDispatchDetails() throws -- proving + // the call reached the plan builder rather than returning a stub zero. const ScopedTestSymbols symbols; const StubDeviceResolver resolver; const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); @@ -178,10 +170,8 @@ TEST(TestIngestorGenericEngine, GetMaxWorkspaceSizeDelegatesToThePlanBuilder) TEST(TestIngestorGenericEngine, InitializeExecutionContextDelegatesToThePlanBuilder) { - // Same reasoning as the workspace test: no dispatch handler is registered for the - // stub state manager's dispatch symbol, so buildPlan()'s own lookup throws. That - // failure mode is only reachable if initializeExecutionContext() actually calls - // through to the plan builder. + // No dispatch handler is registered, so buildPlan()'s lookup throws -- proving + // this reached the plan builder. const ScopedTestSymbols symbols; const StubDeviceResolver resolver; const StubEngine engine(makeEngineWithKnobs({BLOCK_SIZE}), makeStubStateManager(), resolver); diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp index 801ea880d218..26a2dfe58d7f 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlan.cpp @@ -41,9 +41,8 @@ KernelDispatcher makeDispatcher(const MockKernelDispatchHandler& han TEST(TestIngestorGenericPlan, ConstructorThrowsInternalErrorWhenPrepareReturnsNull) { - // prepare() returning null means the handler resolved no launch for this kernel: an - // internal inconsistency between what applicability accepted and what dispatch can - // actually build, not a caller error. + // prepare() returning null means dispatch resolved no launch for a kernel that + // applicability already accepted -- an internal inconsistency, not a caller error. const MockKernelDispatchHandler handler; EXPECT_CALL(handler, workspaceBytes(_, _, _)).WillOnce(Return(0)); EXPECT_CALL(handler, prepare(_, _, _)).WillOnce(Return(nullptr)); diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp index 43ac388103c7..d6acf76699ba 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestGenericPlanBuilder.cpp @@ -32,11 +32,8 @@ /** * @file TestGenericPlanBuilder.cpp - * @brief Unit tests for GenericPlanBuilder.hpp: applicability, the workspace query's - * max-across-survivors, buildPlan's two distinct empty-catalog failure modes, - * getCustomKnobs' int-only reporting, the knob-filtering contract item 6 added - * (a set knob actually selects a kernel, an unsatisfiable one throws), and - * contextFor()'s per-handle device resolution. + * @brief Unit tests for GenericPlanBuilder.hpp: applicability, workspace sizing, plan + * building, knob filtering, and per-handle device resolution. */ namespace { @@ -49,13 +46,12 @@ using ::testing::Return; using ::testing::ReturnRef; // --------------------------------------------------------------------------- -// isApplicable / getMaxWorkspaceSize / buildPlan / getCustomKnobs, using the -// TestHandle = int fixture shape already wired for a two-survivor catalog. +// isApplicable / getMaxWorkspaceSize / buildPlan / getCustomKnobs, using a +// two-survivor catalog (TestHandle = int). // --------------------------------------------------------------------------- -/// A dispatch handler whose workspace requirement equals the kernel's block_size, so a -/// getMaxWorkspaceSize test can assert on a real max rather than a constant every kernel -/// shares. +/// Workspace requirement equals the kernel's block_size, so getMaxWorkspaceSize can assert +/// a real max instead of a shared constant. class WorkspaceEqualsBlockSizeHandler : public IKernelDispatchHandler { public: @@ -96,8 +92,7 @@ struct KnobFilterContext _plan = std::move(plan); } - /// Safe without RTTI: GenericPlanBuilder only ever hands setPlan() - /// a GenericPlan, so the concrete type is known at every call site here. + /// Safe without RTTI: setPlan() only ever receives a GenericPlan here. const GenericPlan& plan() const { return static_cast&>(*_plan); @@ -155,18 +150,14 @@ TEST(TestIngestorGenericPlanBuilder, GetMaxWorkspaceSizeTakesTheMaxAcrossSurvivo const TestPlanBuilder builder(engine, *manager, resolver); const TestGraph graph(makeGraphId(0x92)); - // Two FLOAT survivors, block_size 64 and 256: the max must be 256, not the first - // entry or an unweighted sum. + // Two survivors (block_size 64, 256): expects the max, not the first entry or a sum. EXPECT_EQ(builder.getMaxWorkspaceSize(0, graph, KnobFilterSettings{}), 256U); } TEST(TestIngestorGenericPlanBuilder, BuildPlanThrowsInternalErrorOnAnEmptyRankedCatalog) { - // The pre-existing, unrelated failure: applicability accepted a graph this engine - // cannot serve at all (matching itself found nothing), independent of any knob. - // Item 6 must not collapse this into the same status or message as the - // knob-filtering case below -- a caller needs to tell "your knobs excluded - // everything" apart from "this engine cannot serve this graph regardless of knobs". + // Applicability accepted a graph with no matching kernel at all (not a knob issue); + // this must carry a different status/message than the knob-filtering case below. const ScopedSymbols symbols("test.graph", rejectGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const auto engine = makeEngineWithKnobs({BLOCK_SIZE}); @@ -241,9 +232,8 @@ TEST(TestIngestorGenericPlanBuilder, GetCustomKnobsReportsMinMaxStepAndRankedDef TEST(TestIngestorGenericPlanBuilder, GetCustomKnobsSkipsFieldsWithNoIntegerValues) { - // dtype is a declared, engine-exposed knob but every value it takes is a string, so - // it can never be expressed as an integer knob and must be silently skipped rather - // than stringified into a number that would mean nothing to a caller. + // dtype takes only string values, so it can never become an integer knob and must be + // skipped. const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const auto engine = makeEngineWithKnobs({BLOCK_SIZE, DTYPE}); @@ -258,14 +248,12 @@ TEST(TestIngestorGenericPlanBuilder, GetCustomKnobsSkipsFieldsWithNoIntegerValue } // --------------------------------------------------------------------------- -// Knob filtering (item 6): relocated unchanged from Phase 1's regression tests. +// Knob filtering: setting a knob must actually restrict kernel selection. // --------------------------------------------------------------------------- TEST(TestIngestorGenericPlanBuilder, HonorsAnExplicitKnobSettingOverTheHeuristicDefault) { - // The heuristic (scoreByBlockSize) ranks 256 first, so this is the assertion that - // proves the knob is honored rather than merely advertised: leaving it alone would - // select 256, but setting it must select 64 instead. + // The heuristic would pick 256 by default; setting block_size=64 must override it. const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const WorkspaceEqualsBlockSizeHandler handler; const ScopedDispatchRegistration dispatch("test.dispatch", handler); @@ -315,8 +303,7 @@ TEST(TestIngestorGenericPlanBuilder, UnsatisfiableKnobValueThrowsInvalidValueNam EXPECT_EQ(ex.getStatus(), HIPDNN_PLUGIN_STATUS_INVALID_VALUE); EXPECT_NE(ex.getMessage().find(BLOCK_SIZE), std::string::npos); EXPECT_NE(ex.getMessage().find("999"), std::string::npos); - // Survivor count is the caller's only way to tell "graph matched nothing" - // apart from "graph matched, knobs excluded everything". + // Survivor count distinguishes "knobs excluded everything" from "graph matched nothing". EXPECT_NE(ex.getMessage().find("2 kernel(s) matched the graph before knob filtering"), std::string::npos); } @@ -324,9 +311,8 @@ TEST(TestIngestorGenericPlanBuilder, UnsatisfiableKnobValueThrowsInvalidValueNam TEST(TestIngestorGenericPlanBuilder, GetMaxWorkspaceSizeHonorsTheSameKnobFilterAsBuildPlan) { - // A workspace query and a plan build must not disagree about which kernels a knob - // combination admits: setting block_size=64 must restrict the max to the 64 - // kernel's requirement, not the unfiltered catalog's 256. + // getMaxWorkspaceSize and buildPlan must apply the same knob filter: block_size=64 + // caps the max at 64, not the unfiltered catalog's 256. const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const WorkspaceEqualsBlockSizeHandler handler; const ScopedDispatchRegistration dispatch("test.dispatch", handler); @@ -379,8 +365,7 @@ TEST(TestIngestorGenericPlanBuilder, const TestPlanBuilder builder(engine, *manager, resolver); flatbuffers::FlatBufferBuilder fbb; - // No knob set: an empty engine config is as close to "no request" as this helper - // allows, and the point under test is the no-kernels-at-all path, not filtering. + // An empty engine config exercises the no-kernels-at-all path, not knob filtering. const auto engineConfig = makeEmptyEngineConfig(fbb); const TestGraph graph(makeGraphId(32)); @@ -458,9 +443,8 @@ TEST(TestIngestorGenericPlanBuilder, InitializeExecutionSettingsRejectsAStringVa } // --------------------------------------------------------------------------- -// contextFor(): a mocked device resolver proves per-handle device resolution folds -// into the MatchContext that reaches the matchers, using StubHandle/StubSettings/ -// StubContext so the mock's handle type matches IngestorMocks.hpp's. +// contextFor(): a mocked device resolver proves per-handle device resolution folds into +// the MatchContext (uses StubHandle/StubSettings/StubContext to match IngestorMocks.hpp). // --------------------------------------------------------------------------- constexpr DeviceId DEVICE_FOR_HANDLE_A = 42; @@ -515,9 +499,8 @@ TEST(TestIngestorGenericPlanBuilder, ContextForFoldsPerHandleDeviceResolutionInt engine, manager, resolver); const TestGraph graph(makeGraphId(0x98)); - // Same builder, same graph, two handles: only the resolved device differs, and only - // handleA's device satisfies the matcher. isApplicable can differ only if - // contextFor() actually asked the resolver per handle rather than resolving once. + // Same builder and graph, two handles differing only in resolved device: isApplicable + // can differ only if contextFor() asks the resolver per handle. EXPECT_TRUE(builder.isApplicable(handleA, graph)); EXPECT_FALSE(builder.isApplicable(handleB, graph)); diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp index 3931ea46adb1..77b609287908 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelDefinition.cpp @@ -19,9 +19,8 @@ /** * @file TestKernelDefinition.cpp - * @brief Unit tests for KernelDefinition.hpp: tryGetMetadata()'s optional contract, and - * the three typed getters (int, string, int-list) over both their happy path and - * their two throw branches (absent field, wrong alternative). + * @brief Tests for KernelDefinition.hpp: tryGetMetadata()'s optional contract, and the + * typed getters' happy path and throw branches. */ namespace { @@ -55,17 +54,13 @@ TEST(TestIngestorKernelDefinition, TryGetMetadataReturnsTheValueWhenPresent) TEST(TestIngestorKernelDefinition, TryGetMetadataReturnsNulloptWhenAbsent) { - // Nullopt rather than throwing, so a matcher or scorer written against a newer - // schema can ask about a field an older kernel predates. + // Nullopt rather than throwing: lets callers query fields an older kernel predates. const auto kernel = makeKernelWithMetadata({}); EXPECT_EQ(kernel.tryGetMetadata(BLOCK_SIZE), std::nullopt); } -// --------------------------------------------------------------------------- -// Typed accessor happy path: one per alternative getIntMetadata / getStringMetadata / -// getIntListMetadata reads. -// --------------------------------------------------------------------------- +// Typed accessor happy path. TEST(TestIngestorKernelDefinition, GetIntMetadataReturnsTheIntegerValue) { @@ -90,27 +85,18 @@ TEST(TestIngestorKernelDefinition, GetIntListMetadataReturnsTheListValue) EXPECT_EQ(kernel.getIntListMetadata(STRIDE_ORDER), (std::vector{3, 1, 2, 0})); } -// --------------------------------------------------------------------------- -// Throw matrix: each of the three typed getters, crossed with "field absent" -// (out_of_range) and "field holds a different alternative" (invalid_argument). Both are -// author errors a validating loader would have caught, so they throw rather than -// returning a default that would silently mis-rank or mis-launch a kernel. -// --------------------------------------------------------------------------- +// Throw matrix: each typed getter x "field absent" (out_of_range) / "wrong alternative" +// (invalid_argument). struct KernelDefinitionThrowCase { std::string name; - /// Builds the kernel this case exercises. A function rather than a stored - /// KernelDefinition so each case can supply metadata shaped for exactly the - /// alternative it is testing. + /// Builds the kernel this case exercises. std::function makeKernel; - /// Invokes the getter under test against the kernel. A function so the matrix can - /// share one fixture shape across three differently-typed accessors. + /// Invokes the getter under test. std::function callGetter; - /// True when the getter must throw std::out_of_range (field absent); false when it - /// must throw std::invalid_argument (wrong alternative). RTTI is disabled in this - /// build, so the matrix distinguishes exception types via EXPECT_THROW's static - /// type rather than dynamic_cast. + /// True for std::out_of_range (field absent); false for std::invalid_argument (wrong + /// alternative). RTTI is disabled, so distinguished via EXPECT_THROW's static type. bool expectsOutOfRange; }; diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp index f8251f16de36..fb7b6a2d690f 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelHeuristic.cpp @@ -18,9 +18,8 @@ /** * @file TestKernelHeuristic.cpp - * @brief Unit tests for IKernelHeuristic.hpp: NativeKernelHeuristic's lazy symbol - * resolution, the ranking/tie-break order every IKernelHeuristic gets for free - * from rank(), and the makeKernelHeuristic() factory. + * @brief Tests for IKernelHeuristic.hpp: lazy symbol resolution, ranking/tie-break + * order, and the makeKernelHeuristic() factory. */ namespace { @@ -30,11 +29,8 @@ using namespace hipdnn_plugin_sdk::ingestor::testing; TEST(TestIngestorKernelHeuristic, ResolvesItsSymbolOnFirstUseRatherThanAtConstruction) { - // RFC 0017 section 8.1 admits the heuristic at applicability only as a name, and - // section 3 generalizes it: a heuristic model is not read until something needs the - // catalog ranked. An engine whose matchers reject a graph must never pay for its - // selector, so constructing one against a symbol that is not registered yet has to - // be legal, and only scoring may fail. + // RFC 0017 §8.1/§3: constructing against an unregistered symbol must be legal; + // only scoring may fail. An engine whose matchers reject a graph never pays for it. const NativeKernelHeuristic heuristic("hipdnn.kernel_ingestor.test.not_yet_registered"); const TestGraph graph; @@ -91,7 +87,7 @@ TEST(TestIngestorKernelHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcros const MatchContext context{graph, 0, properties}; Catalog catalog; - // Inserted highest-id first: the result must not depend on load order. + // Inserted highest-id first: result must not depend on load order. const auto lowerId = testId(0x01); const auto higherId = testId(0x02); catalog.entries = {makeDefinition(higherId, 64), makeDefinition(lowerId, 64)}; @@ -103,9 +99,7 @@ TEST(TestIngestorKernelHeuristic, BreaksRemainingTiesOnKernelIdForStabilityAcros EXPECT_EQ(ranked.front().kernelId, lowerId); } -// --------------------------------------------------------------------------- // makeKernelHeuristic(): the UHD -> IKernelHeuristic adapter factory. -// --------------------------------------------------------------------------- TEST(TestIngestorKernelHeuristic, MakeKernelHeuristicBuildsANativeHeuristicForNativeKind) { @@ -120,8 +114,7 @@ TEST(TestIngestorKernelHeuristic, MakeKernelHeuristicBuildsANativeHeuristicForNa const auto heuristic = makeKernelHeuristic(descriptor); ASSERT_NE(heuristic, nullptr); - // Proves the factory actually wired the payload symbol through: scoring against the - // registered scorer must succeed and return what it computes. + // Confirms the factory wired the payload symbol through. const TestGraph graph; const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; @@ -130,9 +123,8 @@ TEST(TestIngestorKernelHeuristic, MakeKernelHeuristicBuildsANativeHeuristicForNa TEST(TestIngestorKernelHeuristic, MakeKernelHeuristicThrowsForAKindWithNoAdapter) { - // HeuristicKind::MODEL has no adapter yet: RFC 0017's UHD follow-up owns it. Failing - // at descriptor-assembly time, not at the first rank(), matches the other - // cross-reference checks the state manager runs eagerly. + // HeuristicKind::MODEL has no adapter yet (RFC 0017's UHD follow-up); fails at + // descriptor-assembly time, not at first rank(). HeuristicDescriptor descriptor; descriptor.id = HEURISTIC_ID; descriptor.name = "model heuristic"; diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp index 9635ed3706d7..fbbc8975e5bf 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestKernelIngestorStateManager.cpp @@ -22,9 +22,8 @@ /** * @file TestKernelIngestorStateManager.cpp - * @brief Unit tests for KernelIngestorStateManager.hpp: matching, pruning, caching, - * ranking-reuse, knob value discovery, metadata completion, and the eager - * construction-time validation the constructor's doc calls out. + * @brief Unit tests for KernelIngestorStateManager: matching, pruning, caching, ranking, + * knob discovery, metadata completion, and construction-time validation. */ namespace { @@ -77,8 +76,7 @@ TEST(TestKernelIngestorStateManager, GraphLevelMatcherFailurePrunesTheWholePack) const MatchContext context{graph, 0, properties}; EXPECT_TRUE(manager->unsortedDefinitions(context).empty()); - // The whole point of the graph/kernel split: a pack that cannot serve the graph - // costs one matcher call, not one per kernel. + // A rejected graph costs one matcher call, not one per kernel. EXPECT_EQ(counters().graphCalls, 1); EXPECT_EQ(counters().kernelCalls, 0); } @@ -104,14 +102,9 @@ TEST(TestKernelIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNot { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); - // Matchers are shared by id across packs, so a shared graph-scoped check runs once, - // not once per pack. - // + // Matchers are shared by id, so the graph-scoped check runs once, not once per pack. // The first pack also fails a second, unshared matcher, so only the second pack - // survives. That isolates BINDING REUSE from memoization: if both packs survived, - // the first pack's own merge could put the token in catalog.bound, hiding a - // mutation that only merges a memo entry's bindings on the cache-MISS pack and - // skips it on every cache-HIT pack -- exactly the second pack's read here. + // survives, isolating binding reuse (read from the memo) from re-evaluation. constexpr const char* UNSHARED_FAIL_SYMBOL = "test.cross_pack_binding_reuse.fail"; GraphMatcherRegistry::registerSymbol(UNSHARED_FAIL_SYMBOL, rejectGraph); const auto unsharedFailMatcherId = testId(0x84); @@ -119,8 +112,7 @@ TEST(TestKernelIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNot const KernelDescriptorPack first = makePack({GRAPH_MATCHER_ID, unsharedFailMatcherId}); KernelDescriptorPack second = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); second.id = testId(0x80); - // A distinct metadata tuple: the uniqueness rule is engine-wide, not per pack, so - // the second pack cannot repeat a block size the first already uses. + // Distinct metadata tuple: uniqueness is engine-wide, not per pack. second.kernels = {makeKernel(testId(0x81), "second_pack_kernel", 512, "FLOAT")}; const StateManager manager( @@ -136,17 +128,14 @@ TEST(TestKernelIngestorStateManager, EvaluatesASharedGraphMatcherOncePerGraphNot const auto properties = testDeviceProperties(); const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); - // Three native calls would mean the shared matcher ran twice; it must not. One call - // for GRAPH_MATCHER_ID (shared, evaluated once for both packs) plus one for the - // first pack's own unshared, failing matcher is the whole budget. + // Two calls: the shared matcher once, plus the first pack's unshared failing matcher. EXPECT_EQ(counters().graphCalls, 2); - // Only the second pack's kernel: the first pack's graph-scoped matchers did not all - // pass, so none of its kernels were ever considered. + // Only the second pack's kernel survives; the first pack was pruned before its + // kernels ran. ASSERT_EQ(catalog.entries.size(), 1U); EXPECT_EQ(catalog.entries.front().packId, second.id); - // The point of this test: the shared matcher's binding reaches catalog.bound through - // the second pack's read of the memo, not through the (pruned) first pack that - // actually ran the matcher. + // The binding reaches catalog.bound via the second pack's memo read, not the + // pruned first pack that actually ran the matcher. EXPECT_EQ(catalog.bound.count("test.bound_token"), 1U); GraphMatcherRegistry::unregisterSymbol(UNSHARED_FAIL_SYMBOL); @@ -178,21 +167,15 @@ TEST(TestKernelIngestorStateManager, ASharedGraphMatcherFailurePrunesEveryPackLi TEST(TestKernelIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingPackKernels) { - // Two packs sharing an engine, the normal case since matchers are shared by id. - // The pruned pack's own graph-scoped matcher passes and binds a token before a - // second matcher in the SAME pack fails and prunes it; the surviving pack lists - // neither of those matchers. If the pruned pack's binding reaches catalog.bound - // anyway, a kernel in the surviving pack -- which never asked for that token -- can - // read it. That is the correctness bug item 5 fixes: the header's claim that - // "nothing a failing matcher wrote can be read by a kernel that survives" held only - // by accident with one pack, and breaks the moment a second pack shares the engine. + // Two packs share an engine. The pruned pack's own matcher binds a token before a + // second matcher in the same pack fails and prunes it; the surviving pack lists + // neither matcher, so it must not see that binding. constexpr const char* PASS_NO_BIND_SYMBOL = "test.pruned_bound_isolation.pass_no_bind"; constexpr const char* LEAK_THEN_PRUNE_SYMBOL = "test.pruned_bound_isolation.leak"; constexpr const char* ALWAYS_FAILS_SYMBOL = "test.pruned_bound_isolation.fail"; - // Reused rather than reinvented: acceptAnyGraph (fixtures) passes and binds nothing; - // acceptGraph (this file's fixtures) passes and binds "test.bound_token"; rejectGraph - // always fails. Exactly the three behaviors this test needs. + // acceptAnyGraph passes and binds nothing; acceptGraph passes and binds + // "test.bound_token"; rejectGraph always fails. GraphMatcherRegistry::registerSymbol(PASS_NO_BIND_SYMBOL, &acceptAnyGraph); GraphMatcherRegistry::registerSymbol(LEAK_THEN_PRUNE_SYMBOL, &acceptGraph); GraphMatcherRegistry::registerSymbol(ALWAYS_FAILS_SYMBOL, &rejectGraph); @@ -213,8 +196,8 @@ TEST(TestKernelIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingP KernelDescriptorPack prunedPack; prunedPack.id = testId(0xA2); prunedPack.name = "pruned pack"; - // Order matters: the leaking matcher must run (and bind) before the failing one - // prunes the pack, exactly as graphLevelMatchersPass evaluates them in listed order. + // Order matters: the leaking matcher must run before the failing one prunes the + // pack (matchers run in listed order). prunedPack.matcherIds = {leakMatcherId, failMatcherId}; prunedPack.engineId = ENGINE_ID; prunedPack.dispatchId = DISPATCH_ID; @@ -233,15 +216,12 @@ TEST(TestKernelIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingP const auto properties = testDeviceProperties(); const auto catalog = manager.unsortedCatalog(MatchContext{graph, 0, properties}); - // The surviving pack's kernel is the only entry: the pruned pack's kernel never - // enters the catalog, which was already correct before this fix. + // Only the surviving pack's kernel enters the catalog. ASSERT_EQ(catalog.entries.size(), 1U); EXPECT_EQ(catalog.entries.front().packId, survivingPack.id); - // What this test actually regresses: the token only the pruned pack's own matcher - // bound must not survive into the catalog the surviving pack's kernel reads from, - // even though that matcher itself returned true before its pack's second matcher - // failed. + // The pruned pack's bound token must not reach the catalog the surviving pack + // reads, even though its matcher returned true before the pack was pruned. EXPECT_EQ(catalog.bound.count("test.bound_token"), 0U); GraphMatcherRegistry::unregisterSymbol(PASS_NO_BIND_SYMBOL); @@ -251,8 +231,8 @@ TEST(TestKernelIngestorStateManager, PrunedPackBindingsAreNotVisibleToSurvivingP TEST(TestKernelIngestorStateManager, TwoPacksBindingOneTokenToDifferentValuesThrows) { - // Two packs writing different values under one token name is an authoring error - // (Catalog::bound's doc); a silent first-wins merge would hide it. + // Conflicting values under one token name is an authoring error; a silent merge + // would hide it. constexpr const char* CONFLICTING_SYMBOL = "test.bound_conflict.conflicting"; GraphMatcherRegistry::registerSymbol(CONFLICTING_SYMBOL, &bindConflictingTokenValue); @@ -283,8 +263,7 @@ TEST(TestKernelIngestorStateManager, TwoPacksBindingOneTokenToDifferentValuesThr TEST(TestKernelIngestorStateManager, TwoPacksBindingOneTokenToTheSameValueMergeCleanly) { - // Packs agreeing on a token's value (the normal shared-matcher case) must still - // merge cleanly. + // Packs agreeing on a token's value must still merge cleanly. constexpr const char* AGREEING_SYMBOL = "test.bound_conflict.agreeing"; GraphMatcherRegistry::registerSymbol(AGREEING_SYMBOL, &bindAgreeingTokenValue); @@ -323,8 +302,7 @@ TEST(TestKernelIngestorStateManager, MatchesSeparatelyPerDevice) manager->unsortedDefinitions(MatchContext{graph, 0, properties}); manager->unsortedDefinitions(MatchContext{graph, 1, properties}); - // The same graph on a different device is a different problem: a kernel applicable - // on one device need not be applicable on another. + // A kernel applicable on one device need not be applicable on another. EXPECT_EQ(counters().graphCalls, 2); } @@ -332,8 +310,8 @@ TEST(TestKernelIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity { const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); - // A legacy or unfinalized graph: there is no key to memoize under, and inventing one - // would alias unrelated graphs. + // A graph with no identity has no key to memoize under; inventing one would alias + // unrelated graphs. const TestGraph graph; const auto properties = testDeviceProperties(); const MatchContext context{graph, 0, properties}; @@ -349,8 +327,8 @@ TEST(TestKernelIngestorStateManager, RematchesEveryCallWhenTheGraphHasNoIdentity TEST(TestKernelIngestorStateManager, DistinctGraphsCarryingANilUuidDoNotShareACatalogEntry) { - // Two unrelated graphs sharing the nil id must not share a cache entry: a regression - // would run the matcher once, not twice, and serve the second graph the first's catalog. + // Two graphs sharing the nil id must not share a cache entry, or the second would + // get the first's catalog. const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const TestGraph first(makeNilGraphId()); @@ -372,9 +350,8 @@ TEST(TestKernelIngestorStateManager, DistinctGraphsCarryingANilUuidDoNotShareACa TEST(TestKernelIngestorStateManager, CarriesWhatMatchingBoundThroughToDispatch) { - // RFC 0017 section 8.5: matching does double duty, deciding a kernel applies and - // binding the fields the launch will use. A dispatch handler must read those values - // rather than re-deriving them from the graph with a second notion of its shape. + // RFC 0017 section 8.5: matching also binds the fields dispatch reads, instead of + // re-deriving them from the graph. const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const auto properties = testDeviceProperties(); @@ -384,21 +361,16 @@ TEST(TestKernelIngestorStateManager, CarriesWhatMatchingBoundThroughToDispatch) const auto bound = manager->unsortedCatalog(context).bound; ASSERT_EQ(bound.count("test.bound_token"), 1U); - // Compared as the integer alternative rather than against the variant as a whole: - // a bare int64 on the right would not convert, and asserting through the accessor - // also pins that the matcher bound an integer and not some other alternative. + // tryGetBoundInt also pins that the bound value is the integer alternative, not + // another. EXPECT_EQ(tryGetBoundInt(bound, "test.bound_token"), BOUND_TOKEN_VALUE); } TEST(TestKernelIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematch) { - // Section 8.1 keeps the bound token state alongside the catalog precisely so that - // "nothing is re-matched" once a graph has been matched. Recovering these values by - // re-running the matcher would be correct and quietly quadratic. - // - // The graph carries an identity, so there is a cache entry to serve the second and - // third reads. A graph without one is matched fresh every call by design; that is why - // the entries and the bound state come back from a single call rather than two. + // Section 8.1: bound state is cached with the catalog, so recovering it never + // re-matches. The graph carries an identity, so a cache entry serves the second + // and third reads. const ScopedSymbols symbols("test.graph", acceptGraph, "test.kernel", countingFloatKernels); const auto manager = makeStateManager(); const auto properties = testDeviceProperties(); @@ -408,10 +380,8 @@ TEST(TestKernelIngestorStateManager, ReadingBoundStateAfterMatchingDoesNotRematc static_cast(manager->unsortedCatalog(context)); const auto afterMatching = counters().graphCalls; - // The cached reads must still carry the bound state, not merely avoid re-matching. - // Asserting only graphCalls would pass against a cache that served an entry with - // Catalog::bound emptied -- which destroys the section 8.1 property this test is - // named for while leaving its counter untouched. + // Asserting only graphCalls would miss a cache that emptied Catalog::bound; the + // cached reads must carry it too. const auto secondRead = manager->unsortedCatalog(context).bound; const auto thirdRead = manager->sortedCatalog(context).bound; @@ -436,8 +406,8 @@ TEST(TestKernelIngestorStateManager, RematchesAfterCacheEviction) manager->unsortedDefinitions(MatchContext{second, 0, properties}); manager->unsortedDefinitions(MatchContext{first, 0, properties}); - // Capacity 1, so the second graph evicted the first and it had to rematch. Eviction - // costs work, never a wrong answer. + // Capacity 1 evicts the first graph, forcing a rematch; eviction costs work, not + // correctness. EXPECT_EQ(counters().graphCalls, 3); } @@ -486,8 +456,8 @@ TEST(TestKernelIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) const auto values = StateManager::knobValues(manager->sortedDefinitions(context), BLOCK_SIZE); - // The pruned HALF kernel also carried block_size 64, but it contributes no value: - // a knob offers what the surviving catalog implements, not the schema's range. + // The pruned HALF kernel also has block_size 64 but contributes no value; a knob + // reflects the surviving catalog, not the schema range. ASSERT_EQ(values.size(), 2U); EXPECT_EQ(std::get(values[0]), 256); EXPECT_EQ(std::get(values[1]), 64); @@ -499,12 +469,9 @@ TEST(TestKernelIngestorStateManager, KnobValuesComeFromTheCatalogInRankedOrder) TEST(TestKernelIngestorStateManager, GetDispatchDetailsThrowsOnADanglingDispatchId) { - // After applicability accepted a graph, hipDNN has already chosen this engine on - // that promise, so a missing dispatch is a hard error rather than a silent decline. - // Constructed directly rather than through the pack/matcher path: the point here is - // a KernelDefinition whose dispatchId the state manager's own descriptor set never - // registered, which validateAndIndexPacks() cannot catch because this definition - // never went through a pack. + // A missing dispatch after a graph was accepted is a hard error, not a silent + // decline. Built directly, bypassing pack validation, since validateAndIndexPacks() + // cannot see a definition that never went through a pack. const auto manager = makeStateManager(); const auto kernel = makeDefinition(testId(0x01), 64); @@ -543,9 +510,8 @@ TEST(TestKernelIngestorStateManager, CompletesAnOmittedFieldFromItsSchemaDefault } // --------------------------------------------------------------------------- -// Construction-time validation: every eager check validateAndIndexPacks() and the -// constructor itself run, TEST_P'd over one shared shape (a pack, invalid in one way, -// must fail construction with std::invalid_argument). +// Construction-time validation (TEST_P): each case must fail construction with +// std::invalid_argument. // --------------------------------------------------------------------------- struct StateManagerConstructionThrowCase @@ -571,8 +537,7 @@ INSTANTIATE_TEST_SUITE_P( StateManagerConstructionThrowCase{ "RejectsAKernelOmittingAFieldWithNoDefault", [] { - // dtype declares no default, so every kernel must state it. Omitting it - // would otherwise produce a catalog key the author never wrote. + // dtype has no schema default, so every kernel must state it explicitly. KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); KernelDescriptor missingDtype; missingDtype.id = testId(0x71); @@ -589,9 +554,8 @@ INSTANTIATE_TEST_SUITE_P( StateManagerConstructionThrowCase{ "RejectsAKernelSupplyingAFieldTheSchemaDoesNotDeclare", [] { - // A misspelled field name is the common case, and left in place it fails - // twice over: the real field silently takes its default, and the stray - // value joins the catalog key, where nothing downstream can read it. + // An undeclared field (e.g. a misspelling) silently takes its default + // while the stray value joins the catalog key unread. KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); KernelDescriptor undeclared; undeclared.id = testId(0x74); @@ -610,8 +574,8 @@ INSTANTIATE_TEST_SUITE_P( StateManagerConstructionThrowCase{ "RejectsAKernelSupplyingAFieldOfTheWrongType", [] { - // block_size is declared INT. A string here would otherwise surface far - // away, as a bad_variant_access inside a matcher or a scorer. + // A wrong-typed field would otherwise surface as a bad_variant_access far + // away, inside a matcher or scorer. KernelDescriptorPack pack = makePack({GRAPH_MATCHER_ID, KERNEL_MATCHER_ID}); KernelDescriptor wrongType; wrongType.id = testId(0x72); @@ -629,8 +593,8 @@ INSTANTIATE_TEST_SUITE_P( StateManagerConstructionThrowCase{ "RejectsAPackNamingAnUnknownMatcher", [] { - // A dangling cross-reference cannot be evaluated, so it is caught when - // the descriptor set is assembled rather than when a graph first arrives. + // A dangling matcher reference is caught at construction, not when a + // graph first arrives. return std::make_unique( makeSchema(), std::vector{}, @@ -665,8 +629,8 @@ INSTANTIATE_TEST_SUITE_P( StateManagerConstructionThrowCase{ "RejectsADuplicateMatchDescriptorId", [] { - // Two distinct matchers under one id: silent first-wins would let a pack - // run whichever matcher loaded first. + // Silent first-wins under a duplicate id would run whichever matcher + // loaded first. std::vector matchers = makeTestMatchers(); matchers.push_back({GRAPH_MATCHER_ID, "a different matcher entirely", diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp index 98b14276e0d8..5cecd909be1f 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestLruCache.cpp @@ -13,10 +13,8 @@ /** * @file TestLruCache.cpp - * @brief Unit tests for the bounded, thread-safe LRU cache LruCache.hpp declares. - * - * Concurrent access is covered separately by TestKernelIngestorConcurrency.cpp; these - * tests are single-threaded and pin the eviction and recency contract itself. + * @brief Tests for the bounded, thread-safe LRU cache LruCache.hpp declares. + * Concurrent access is covered separately by TestKernelIngestorConcurrency.cpp. */ namespace { @@ -43,22 +41,17 @@ TEST(TestIngestorLruCache, ReportsMissForAbsentKey) TEST(TestIngestorLruCache, RejectsZeroCapacity) { - // A zero-capacity cache would evict every entry as it was inserted, which is always - // a caller bug rather than a way to disable caching. + // Zero capacity would evict every entry on insert; always a caller bug. using IntCache = LruCache; EXPECT_THROW(IntCache(0), std::invalid_argument); } -// --------------------------------------------------------------------------- -// Eviction order: which entry a capacity-crossing insert evicts depends on recency, -// not on insertion order, and a get() counts as a touch just as a put() does. -// --------------------------------------------------------------------------- +// Eviction order: depends on recency, not insertion order; get() counts as a touch. struct LruEvictionCase { std::string name; - /// Populates the cache and touches whatever entries the case needs, leaving it one - /// insert away from a capacity-2 overflow. + /// Populates the cache, leaving it one insert away from a capacity-2 overflow. std::function&)> setup; int expectedEvicted; int expectedSurvivor; @@ -74,8 +67,7 @@ TEST_P(TestIngestorLruCacheEvictionOrder, EvictsByRecencyNotInsertionOrder) LruCache cache(2); testCase.setup(cache); - // The insert that crosses capacity: key 3 must always survive, since it is always - // the most recently used entry regardless of which case set it up this way. + // Key 3 is always most-recently-used and must survive the crossing insert. cache.put(3, "three"); EXPECT_FALSE(cache.get(testCase.expectedEvicted).has_value()); @@ -97,7 +89,7 @@ INSTANTIATE_TEST_SUITE_P(EvictionOrder, [](LruCache& cache) { cache.put(1, "one"); cache.put(2, "two"); - // Touching 1 makes 2 the least recently used. + // Touching 1 makes 2 LRU. ASSERT_TRUE( cache.get(1).has_value()); }, @@ -107,7 +99,7 @@ INSTANTIATE_TEST_SUITE_P(EvictionOrder, [](LruCache& cache) { cache.put(1, "one"); cache.put(2, "two"); - // Overwriting 1 marks it most-recently-used, same as a get(). + // Overwrite counts as a touch. cache.put(1, "uno"); }, /*expectedEvicted=*/2, diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp index 6d58392ccb7e..54462f1bcf5e 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestMatchContext.cpp @@ -69,8 +69,7 @@ TEST(TestIngestorMatchContext, CatalogKeyHashIsConsistentForEqualKeys) const CatalogKey second{makeGraphId(3), 2}; const CatalogKeyHash hash; - // A hash function must agree with operator== on which keys are "the same": unequal - // hashes for equal keys would break the LruCache's unordered_map lookup silently. + // Equal keys must hash equal, or LruCache's unordered_map lookup breaks silently. EXPECT_EQ(hash(first), hash(second)); } @@ -80,9 +79,8 @@ TEST(TestIngestorMatchContext, CatalogKeyHashDistinguishesDifferentDeviceIds) const CatalogKey onDeviceZero{makeGraphId(4), 0}; const CatalogKey onDeviceOne{makeGraphId(4), 1}; - // Not a correctness requirement (collisions are legal), but this hash's own doc - // claims it folds the device ordinal in, and a hash that silently ignored deviceId - // would still "work" via bucket collision while quietly regressing lookup cost. + // Collisions are legal, but this hash folds in deviceId; ignoring it would regress + // lookup cost while still "working" via collision. EXPECT_NE(hash(onDeviceZero), hash(onDeviceOne)); } @@ -102,8 +100,7 @@ TEST(TestIngestorMatchContext, TryGetGraphIdReturnsTheGraphsIdentity) TEST(TestIngestorMatchContext, TryGetGraphIdReturnsNulloptForAGraphWithNoIdentity) { - // A legacy or unfinalized graph: there is no key to memoize under, and callers must - // treat this as "cannot cache", not as an error. + // No key to memoize under; callers must treat this as "cannot cache", not an error. const TestGraph graph; EXPECT_EQ(tryGetGraphId(graph), std::nullopt); diff --git a/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp b/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp index 3b3cd6e11374..59a657b8d6c3 100644 --- a/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp +++ b/projects/hipdnn/plugin_sdk/tests/ingestor/TestNativeRegistry.cpp @@ -14,11 +14,8 @@ /** * @file TestNativeRegistry.cpp - * @brief Unit tests for the symbol-name-to-native-callable registry. - * - * Covers registration, resolution, and the fail-closed contract a descriptor naming an - * unshipped symbol depends on -- a missing registration must surface as an error at the - * point of use, never as an engine that silently matches nothing. + * @brief Tests for the symbol-name-to-native-callable registry: registration, + * resolution, and fail-closed behavior on unresolved symbols. */ namespace { @@ -39,8 +36,7 @@ TEST(TestIngestorNativeRegistry, RejectsDuplicateRegistration) { GraphMatcherRegistry::registerSymbol("registry.duplicate", acceptGraph); - // Two implementations behind one name leaves one silently unreachable, and which one - // wins would depend on static-init order. + // Two implementations behind one name: which wins depends on static-init order. EXPECT_THROW(GraphMatcherRegistry::registerSymbol("registry.duplicate", rejectGraph), std::runtime_error); @@ -49,8 +45,7 @@ TEST(TestIngestorNativeRegistry, RejectsDuplicateRegistration) TEST(TestIngestorNativeRegistry, FailsClosedOnUnknownSymbol) { - // A descriptor naming a symbol the provider does not ship must surface as an error, - // never as an engine that quietly matches nothing. + // Unshipped symbol must error, not silently match nothing. EXPECT_THROW(GraphMatcherRegistry::resolve("registry.never_registered"), std::runtime_error); } From 606c5c7f6b1c83d4473ecdae0ec05c61560849fc Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 23 Jul 2026 09:40:38 -0600 Subject: [PATCH 31/60] First pass of RFC (cherry picked from commit 8b79670ebd1e967e895b7bce52fa9225248943b8) (cherry picked from commit 71208bfc0dd7226e315c1fbe472b14624f2c12b3) --- .../hipdnn/docs/images/umd_binding_model.svg | 68 ++ .../docs/images/umd_matcher_pipeline.svg | 79 ++ .../hipdnn/docs/images/umd_symbol_binding.svg | 66 ++ .../rfcs/0018_UniversalMatchDescriptor.md | 866 ++++++++++++++++++ 4 files changed, 1079 insertions(+) create mode 100644 projects/hipdnn/docs/images/umd_binding_model.svg create mode 100644 projects/hipdnn/docs/images/umd_matcher_pipeline.svg create mode 100644 projects/hipdnn/docs/images/umd_symbol_binding.svg create mode 100644 projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md diff --git a/projects/hipdnn/docs/images/umd_binding_model.svg b/projects/hipdnn/docs/images/umd_binding_model.svg new file mode 100644 index 000000000000..723ab21b05ae --- /dev/null +++ b/projects/hipdnn/docs/images/umd_binding_model.svg @@ -0,0 +1,68 @@ + + + + + + + + + UID-centric graph + Op-schema registry + Auto-bound symbol table + + + + Node { + op = sdpa_fwd + attributes(union): + q_tensor_uid = 7 + k_tensor_uid = 8 + v_tensor_uid = 9 + o_tensor_uid = 12 + } // no inputs[]/outputs[] + tensors[uid]: + 7: dims[..] strides[..] + data_type, virtual_ + stream -> getDeviceString() + = "gfx942" (out-of-band) + + + resolve + + + + sdpa_fwd schema: + operands (required): + Q -> q_tensor_uid + K -> k_tensor_uid + V -> v_tensor_uid + results: + O -> o_tensor_uid + operands (optional): + attn_mask, page_table_* + attributes: + causal_mask, dropout_.. + generated from the + flatbuffer op schema + + + bind + + + + $q -> tensor(uid 7) + $k $v $o ... + $q0..$q3 (dims) + $q.strides[0..3] + $q.rank $q.dtype + stride_order($q) + $batch, $num_heads, + $seqlen_q, $head_size + (named shape dims) + $root.causal_mask + $root.dropout_probability + consumed by the UDD's + launch + workspace formulas + + The graph has no edges; the op-schema registry reconstructs them from shared UIDs and auto-binds the full symbol table. + diff --git a/projects/hipdnn/docs/images/umd_matcher_pipeline.svg b/projects/hipdnn/docs/images/umd_matcher_pipeline.svg new file mode 100644 index 000000000000..a02c8678718c --- /dev/null +++ b/projects/hipdnn/docs/images/umd_matcher_pipeline.svg @@ -0,0 +1,79 @@ + + + + + + + + + Compile once at load; run per graph at plan build + + + + Compile (once) + + + text UMD + (JSONC) + + + + + resolve roles + expand layouts + parse exprs, check + + + + + constraint IR + early-out + ordered + + + + + root-op + index + + + + Run (per graph) + + + live graph + root op + + + index lookup + + + candidate UMDs + for this opcode + + + eval ordered + + + accept + + bind symbols + + + + + plan + cache + + + + Per-plan bind cache + chosen UMD + bound symbol table + arbitration outcome + reused by isApplicable / + workspace / buildPlan + + + + Root-opcode indexing keeps match cost sublinear in descriptor count; the per-plan cache matches each graph once. + diff --git a/projects/hipdnn/docs/images/umd_symbol_binding.svg b/projects/hipdnn/docs/images/umd_symbol_binding.svg new file mode 100644 index 000000000000..d1bb77a78643 --- /dev/null +++ b/projects/hipdnn/docs/images/umd_symbol_binding.svg @@ -0,0 +1,66 @@ + + + + + + + + + Live graph + UMD constraints (declarative) + Declared / bound / used + + + + + Q + + K + + V + + sdpa_fwd + + O + + + + + + + match + + + + op: sdpa_fwd + operands {Q:$q, K:$k, V:$v} + results {O:$o} + $q.dtype one_of {BF16, FP8} + $k.dtype same_as $q + $q.shape [b, h, sq, $head_size] + $k.shape [b, kvh, sk, $head_size] + expr: $head_size == 128 + layout: [0, 1, 2, 3] + $attn_mask present: false + native: strides_fit_u32(...) + native: sdpa_mask_consistent + + + bind + + + + $q $k $v $o + $q0..$q3 (dims) + $q.strides[0..3] + $batch, $num_heads + $kv_heads ($k1==$v1) + $seqlen_q, $seqlen_k + $head_size + $root.causal_mask + $root.dropout_.. + used by the UDD's + grid / arg formulas + + The match auto-binds every tensor, dim, stride, and attribute; shared names unify dims ($kv_heads = $k1 = $v1). + diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md new file mode 100644 index 000000000000..02636856be95 --- /dev/null +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -0,0 +1,866 @@ +# RFC 0018: Universal Match Descriptor (UMD) and the Graph Matcher + +- Contributors: Brian Harrison + +> Follow-up to [RFC 0017 (Universal Kernel Descriptors)](0017_UniversalKernelDescriptor.md), +> the "UMD + graph matcher" row of its follow-up series ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)). +> This RFC designs the match format and the matcher; the sibling formats (UDD, UED, UHD, KDP) and +> subsystems (packaging, drop-in, adapters) are designed in their own follow-ups and are referenced, +> not redesigned, here. The RFC number is provisional and is reconciled against the concurrent +> follow-up series at PR-open time. + +## Table of Contents + +1. [Overview](#1-overview) +2. [What the UMD Replaces](#2-what-the-umd-replaces) +3. [The Matcher's Input: hipDNN's Graph Model](#3-the-matchers-input-hipdnns-graph-model) +4. [Structural Pattern](#4-structural-pattern) +5. [Symbol Binding and the Auto-Binding Formula](#5-symbol-binding-and-the-auto-binding-formula) +6. [Constraint Vocabulary](#6-constraint-vocabulary) +7. [The Shared Expression Language](#7-the-shared-expression-language) +8. [Layout and Stride-Order Constraints](#8-layout-and-stride-order-constraints) +9. [Native-Predicate Escape Hatch](#9-native-predicate-escape-hatch) +10. [Composite Constraints (Deferred, Forward-Compatible)](#10-composite-constraints-deferred-forward-compatible) +11. [The Matcher: Compilation, Indexing, and Caching](#11-the-matcher-compilation-indexing-and-caching) +12. [Static Matcher (Sketch)](#12-static-matcher-sketch) +13. [Arbitration](#13-arbitration) +14. [Serialization and Versioning](#14-serialization-and-versioning) +15. [Security and Hostile Input](#15-security-and-hostile-input) +16. [Observability and Diagnostics](#16-observability-and-diagnostics) +17. [Testing and Performance](#17-testing-and-performance) +18. [Migration](#18-migration) +19. [Worked Example: SDPA Forward](#19-worked-example-sdpa-forward) +20. [Risks](#20-risks) +21. [Open Questions](#21-open-questions) +22. [References and Prior Art](#22-references-and-prior-art) +23. [Glossary](#23-glossary) + +--- + +## 1. Overview + +A **UMD (Universal Match Descriptor)** is the declarative data that decides whether a kernel applies to +an incoming problem graph, and, in the same pass, binds the named variables that the kernel's dispatch +and workspace formulas reference. It replaces the graph half of a hand-coded +`IPlanBuilder::isApplicable` check ([RFC 0017 §2](0017_UniversalKernelDescriptor.md#2-the-descriptors)). +One UMD is authored once and referenced by ID from many UKDs, so a family of near-identical kernels +shares a handful of matches rather than carrying a bespoke C++ check each. + +A UMD has two parts, unchanged in intent from +[RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd): + +1. A **structural pattern**: named operation nodes and their named operand and result edges. Because + hipDNN op graphs are DAGs, the pattern is an explicit node-and-edge graph, not a nested expression. +2. A **constraint list** attached to those nodes and edges. Constraints implicitly AND together. + +This document turns that frame into a concrete format and a concrete matcher. It specifies the pattern +and constraint schema, the standard formula that auto-binds every tensor and attribute of a matched op, +a shared expression language (used here for boolean constraints and reused by the UDD for dispatch +formulas), the layout representation as a stride-order index array, the native-predicate escape hatch, +deterministic arbitration, and the compile-once matcher with its indexing and caching. The static +(compile-time) matcher is sketched as options, not fully designed, in this iteration. + +### 1.1 What This RFC Specifies Versus Defers + +| Capability | This RFC (day-one) | Deferred | +|---|---|---| +| Structural pattern: op nodes, named operand/result edges, single-op and bounded fused subgraphs | Yes ([§4](#4-structural-pattern)) | None | +| Auto-binding of every operand/result tensor, its dims and strides, and every op attribute | Yes ([§5](#5-symbol-binding-and-the-auto-binding-formula)) | None | +| Constraints: opcode, dtype (exact/one_of/relation), shape/rank, symbolic-dim unification, layout, packing, attribute, use-count, cross-tensor relation, optional operand, device/arch | Yes ([§6](#6-constraint-vocabulary)) | None | +| Shared expression language: arithmetic value core (UDD-shared) plus a boolean predicate layer (UMD) | Yes ([§7](#7-the-shared-expression-language)) | New operators as needed | +| Layout as a stride-order index array, with named aliases | Yes ([§8](#8-layout-and-stride-order-constraints)) | None | +| Native-predicate escape hatch, registry-resolved | Yes ([§9](#9-native-predicate-escape-hatch)) | None | +| Composite constraints: `(A AND B) OR C` as one constraint | Forward-compatible schema only ([§10](#10-composite-constraints-deferred-forward-compatible)) | Evaluation semantics | +| Compile-once matcher, root-opcode index, per-plan match cache | Yes ([§11](#11-the-matcher-compilation-indexing-and-caching)) | None | +| Static (compile-time / AOT-lowered) matcher | Options sketched ([§12](#12-static-matcher-sketch)) | Full design | +| General N-ary commutative matching, unbounded variable-length chains | None | JIT follow-up ([RFC 0017 §8.3](0017_UniversalKernelDescriptor.md#83-future-jit-and-normalized-providers)) | + +--- + +## 2. What the UMD Replaces + +Every check the UMD must express already exists in the provider as hand-written C++. Two families show +the full range: the `asm_sdpa_engine` builders inline their gates, and the `hip_mlops_engine` builders +delegate tensor-level gates to reusable `IValidator` primitives +(`dnn-providers/hip-kernel-provider/src/engines/hip_mlops_engine/plans/ApplicabilityChecks.cpp`). Each +hand-coded check kind maps to a UMD construct or, where it needs real C++, to a named native predicate. + +| Hand-written check (with a representative site) | UMD construct | +|---|---| +| Override-shape gate (`SdpaFwdPlanBuilder.cpp:177`) | implicit precondition; matcher declines override-shape graphs unless a UMD opts in ([§4](#4-structural-pattern)) | +| Device arch gate `gfx942`/`gfx950` (`SdpaFwdPlanBuilder.cpp:186`) | `device.arch` constraint ([§6](#6-constraint-vocabulary)) | +| Node-count and single-node gate (`SdpaFwdPlanBuilder.cpp:199`) | the pattern's node set is exact ([§4](#4-structural-pattern)) | +| Node attribute-type gate (`attributesType() == SdpaAttributes`) (`SdpaFwdPlanBuilder.cpp:200`) | node `op` opcode ([§4](#4-structural-pattern)) | +| Per-attribute value gates: dropout unset/zero, alibi false, padding false, `generate_stats` false (`SdpaFwdPlanBuilder.cpp:205-224`) | `attr` constraints ([§6](#6-constraint-vocabulary)) | +| Optional operand absent: `attn_mask`, `page_table_k/v` UIDs (`SdpaFwdPlanBuilder.cpp:209-215`) | optional-operand `absent` constraint ([§6](#6-constraint-vocabulary)) | +| Tensor rank == 4 (`SdpaFwdPlanBuilder.cpp:231-247`) | `rank` or an exact `shape` ([§6](#6-constraint-vocabulary)) | +| Cross-tensor dtype equality `q == k == v` (`SdpaFwdPlanBuilder.cpp:244`) | dtype relation ([§6](#6-constraint-vocabulary)) | +| Cross-tensor dim relation, head count `k.dims[1] == v.dims[1]` (`SdpaFwdPlanBuilder.cpp:251`) | symbolic-dim unification ([§5](#5-symbol-binding-and-the-auto-binding-formula)) | +| Packed-tensor and supported-layout gates (`ApplicabilityChecks.cpp:65,77`) | packing and layout constraints ([§8](#8-layout-and-stride-order-constraints)) | +| Consistent-layout across tensors (`ApplicabilityChecks.cpp:106`) | cross-tensor layout relation ([§8](#8-layout-and-stride-order-constraints)) | +| Head dim `one_of {64,128,192}` (`SdpaBwdPlanBuilder.cpp:597`) | `one_of` on a bound dim ([§7](#7-the-shared-expression-language)) | +| GQA divisibility `nhead_q % nhead_k == 0 && nhead_k != 0` (`SdpaBwdPlanBuilder.cpp:548`) | expression predicate, or a native predicate if fail-closed overflow matters ([§9](#9-native-predicate-escape-hatch)) | +| uint32 byte-stride-overflow guard (`SdpaFwdPlanBuilder.cpp:294`, `SdpaPlanUtils.hpp:159`) | **native predicate** ([§9](#9-native-predicate-escape-hatch)) | +| Kernel-name-key / CSV-registry table lookups (`SdpaFwdPlanBuilder.cpp:287`, three in `SdpaBwdPlanBuilder.cpp:660`) | **native predicate** (or removed once the KDP names the code object directly) ([§9](#9-native-predicate-escape-hatch)) | +| Mask classification `getMaskType` throwing on contradiction (`SdpaFwdPlanBuilder.cpp:276`) | attr constraints, with a native predicate for the contradiction check ([§9](#9-native-predicate-escape-hatch)) | +| NumPy-broadcast affine-shape compatibility (`BatchnormApplicabilityChecks.cpp:169`), normalized-dim reconciliation (`LayernormApplicabilityChecks.cpp:68`), inv_rms derived shape (`RMSnormApplicabilityChecks.cpp:106`) | **native predicate** (derived-quantity relations) ([§9](#9-native-predicate-escape-hatch)) | +| Fusion wiring: intermediate is virtual and privately used (`BatchnormPlanBuilder.cpp:184`) | `use_count` / exclusivity plus a virtual-tensor constraint ([§6](#6-constraint-vocabulary)) | + +The provider today has **no arbitration**: each engine loops its plan builders and takes the first that +returns true (`AsmSdpaEngine.cpp:32`, `HipMlopsEngine.cpp:36`), with a standing comment that this +"is wrong if we ever have more than 1 plan builder thats applicable" (`HipMlopsEngine.cpp:34`). +Correctness depends on builders being mutually exclusive by construction. The UMD makes overlap +explicit and resolves it deterministically ([§13](#13-arbitration)). + +--- + +## 3. The Matcher's Input: hipDNN's Graph Model + +The matcher reads an immutable graph through the existing `IGraph` interface +(`projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/flatbuffer_utilities/GraphWrapper.hpp`) +plus the HIP stream for device properties. Three properties of that model drive the design. + +**The graph is UID-centric, not edge-centric.** A `Node` carries only `{name, compute_data_type, +attributes_type, attributes}` (`graph_generated.h`, Node table). It has no input or output tensor +lists. A node's operands and results are UID fields inside its concrete attribute table, for example +`SdpaAttributes::q_tensor_uid()`, `k_tensor_uid()`, `o_tensor_uid()` (`sdpa_attributes_generated.h`). +Connectivity between nodes is implicit: two nodes are connected when a result UID of one appears as an +operand UID of the other. To resolve a node's edges, the matcher must know the op type, cast via +`attributesAs()`, and read the per-role UID fields. + +**Consequence: the matcher needs an op-schema registry.** For each op type, the registry declares which +attribute fields are operand UIDs, which are result UIDs, whether each is required or optional, and the +names of the op's scalar attributes. This registry is what lets a UMD name operands and results by +role (`Q`, `K`, `V`, `O`) and what powers the auto-binding formula of +[§5](#5-symbol-binding-and-the-auto-binding-formula). It is generated from the flatbuffer op schema, +so it stays in lockstep with the graph definitions rather than being hand-maintained. + +**Tensors expose dims, strides, dtype, and a virtual flag, but no layout enum and no rank field.** +`TensorAttributes` (`tensor_attributes_generated.h`) offers `dims()`, `strides()` (both nullable +vectors), `data_type()`, `uid()`, and `virtual_()`. Rank is `dims()->size()`. Layout is not stored; it +is derived from the stride order, which is why the UMD represents layout as a stride-order index array +([§8](#8-layout-and-stride-order-constraints)). Quantities like `head_size`, `batch`, and `num_heads` +are **not** attributes; they are specific tensor dims (for SDPA, `q.dims[3]`, `q.dims[0]`, +`q.dims[1]`). The UMD binds them as shape symbols, not as `attr` reads. (RFC 0017's illustrative +`attr: {head_size: ...}` is imprecise on this point; this RFC uses the shape-symbol form.) + +**Device and arch are out-of-band.** The graph carries no device identity. Arch comes from the stream +via `getDeviceString(handle.getStream())` (`HipDeviceUtils.hpp:48`), so the matcher receives the +`Handle` alongside the graph, and a `device` constraint resolves against it rather than against a graph +field. + +**Graph guarantees the matcher may rely on.** Per the `IGraph` contract the graph is topologically +sorted, acyclic, fully connected, and has unique tensor UIDs. The matcher builds its own +UID-to-producer and UID-to-consumers index once per graph to walk edges and to evaluate use-count +constraints, since no adjacency query is provided. + +![The matcher reads a UID-centric graph via an op-schema registry that reconstructs edges and auto-binds symbols](../images/umd_binding_model.svg) + +--- + +## 4. Structural Pattern + +The pattern is a set of op nodes and the named edges between them and the graph's tensors. Each node +declares its opcode and maps operand and result **roles** (from the op-schema registry) to pattern +variables (`$q`, `$conv_out`). + +```jsonc +{ + "schema": "hipdnn.umd/v1", + "id": 1180449020, + "name": "SDPA forward (d128, bf16) match", + "nodes": [ + {"kind": "op", "id": "$root", "op": "sdpa_fwd", + "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} + ], + "constraints": [ /* Section 6 */ ] +} +``` + +- **Node identity.** Each node has a pattern-local `id` (`$root`, `$conv`, `$add`) used to target + constraints and diagnostics. It is distinct from the descriptor's global `id`. +- **Opcode.** `op` names one opcode, or `{"one_of": [...]}` for a small fixed set, or `"any"` for a + wildcard node (used only inside a bounded fused pattern). +- **Roles.** Keys in `operands` and `results` are op-schema role names; values are pattern variables. + A role the schema marks optional may be omitted from the map, or bound and constrained `absent` + ([§6](#6-constraint-vocabulary)). Roles not named are ignored for matching but still auto-bound + ([§5](#5-symbol-binding-and-the-auto-binding-formula)). +- **Edges are implicit through shared variables.** Two nodes are connected when the same variable + appears as a result of one and an operand of another. In the fused example below, `$conv_out` is + `$conv`'s result and `$add`'s operand, which is the edge. +- **Node set is exact by default.** A single-node pattern matches only a single-node graph, replacing + the `nodeWrappers().size() != 1` gate. A multi-node pattern matches a connected subgraph of exactly + those op nodes; whether the subgraph must be the whole graph or may be embedded is governed by the + fusion legality constraints (`use_count`) below. +- **Override-shape graphs** are declined by default (mirroring today's gate); a UMD that supports them + sets `"allow_override_shape": true` at the top level. + +**Fusion is day-one.** A multi-node pattern binds the whole fused subgraph at once and hands it to one +UKD. Legality (that the fused intermediates have no consumer outside the pattern) is expressed with +`use_count` constraints: + +```jsonc +{ + "schema": "hipdnn.umd/v1", + "id": 6620551007, + "name": "Conv-Bias-ReLU (NHWC, f16) match", + "nodes": [ + {"kind": "op", "id": "$conv", "op": "convolution_fwd", + "operands": {"X": "$x", "W": "$w"}, "results": {"Y": "$conv_out"}}, + {"kind": "op", "id": "$add", "op": "pointwise_add", + "operands": {"A": "$conv_out", "B": "$bias"}, "results": {"Y": "$bias_out"}}, + {"kind": "op", "id": "$act", "op": "pointwise_relu", + "operands": {"A": "$bias_out"}, "results": {"Y": "$y"}} + ], + "constraints": [ + {"on": "$x", "dtype": {"one_of": ["FLOAT16"]}, "layout": [0, 3, 1, 2]}, + {"on": "$y", "dtype": {"one_of": ["FLOAT16"]}, "layout": [0, 3, 1, 2], + "shape": ["$batch", "$out_h", "$out_w", "$out_channels"]}, + {"on": "$bias", "shape": ["$out_channels"]}, + {"on": "$conv_out", "use_count": 1}, + {"on": "$bias_out", "use_count": 1} + ] +} +``` + +Out of scope for this iteration, as in [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd): +general N-ary commutative matching and unbounded variable-length chains. Bounded commutative pairs and +bounded optional slots cover the fusion cases a prebuilt kernel needs. + +--- + +## 5. Symbol Binding and the Auto-Binding Formula + +Matching does double duty: it decides applicability and it binds named variables. A symbol is +**declared** in the UMD, **bound** when the graph matches, and **used** by the UDD's dispatch and +workspace formulas ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). Every +symbol a formula references must be bound by the match, so a formula can only read values the match +actually produces. The UMD publishes its bound-symbol set; each UKD that pairs this UMD with a UDD is +checked at build and at drop-in load, and a UDD that references an unbound symbol is rejected then +rather than failing closed later on a live graph. + +**Auto-binding is the default, and follows a standard formula (AICK-1698).** When a pattern names an +operand or result variable, the matcher, using the op-schema registry, automatically binds: + +- The **tensor handle** itself: `$q` (a pointer/UID reference). +- Each **dim**, addressable positionally as `$q.dims[i]` and, as authoring sugar, as `$q` (so `$q0` + is `$q.dims[0]`, `$q3` is `$q.dims[3]`). +- Each **stride**, addressable as `$q.strides[i]`. +- Derived tensor facts: `$q.rank`, `$q.dtype`, `$q.stride_order` ([§8](#8-layout-and-stride-order-constraints)), + `$q.is_virtual`. + +When a pattern names a node, the matcher auto-binds every scalar attribute of that op, addressable as +`$root.` (for example `$root.causal_mask`, `$root.dropout_probability`). Optional attributes carry +a presence flag, `$root..present`. + +**A `$` marks a bound symbol.** Every reference to a bound symbol carries a leading `$`: tensor handles +(`$q`), their dims, strides, and derived facts (`$q0`, `$q.dims[2]`, `$q.rank`), named shape symbols +(`$head_size`), and the node handle and its attributes (`$root`, `$root.causal_mask`). Tokens without a +`$` are literals: numbers, enum values (`"BFLOAT16"`, `"gfx942"`), attribute-name keys, layout aliases, +opcodes, and keywords (`device`, `one_of`, `any`). The same token then reads identically wherever it +appears, in a `shape` list, an `expr` string, or a native-predicate argument. + +Authors therefore get a complete, uniform symbol table for free and never hand-declare each field. Two +optional forms add friendly names and cross-tensor unification on top: + +- **Named shape dims.** A `shape` list names a tensor's dims: `"shape": ["$batch", "$num_heads", + "$seqlen_q", "$head_size"]` binds those four symbols to `$q`'s dims. Reusing a name across tensors + **unifies** it: writing `$head_size` on both `$q` and `$k` requires `$q3 == $k3` and binds one symbol, + replacing the hand-coded `k.dims[1] == v.dims[1]` relation. A dim position may be left anonymous with + `"_"`. +- **Named attributes.** Rarely needed, since `$root.` already reads them, but a constraint may + alias one for reuse in formulas. + +Symbol names are unique within a UMD across tensors, nodes, and named dims; a match/dispatch pair is +validated so that unification is consistent (a name bound to two different concrete values fails the +match, not the load). + +![A live graph matched against a declarative pattern, auto-binding tensors, dims, strides, and attributes](../images/umd_symbol_binding.svg) + +--- + +## 6. Constraint Vocabulary + +Constraints attach to a target (`on`) and implicitly AND together. The target is a bound tensor +variable (`$q`), a pattern node id (`root`), or the special `device`. The vocabulary is fixed to what +the hand-written checks of [§2](#2-what-the-umd-replaces) express, so no check needs code that a +constraint cannot state (the residue goes to a native predicate, [§9](#9-native-predicate-escape-hatch)). + +| Constraint | Form | Lowers from | +|---|---|---| +| **Opcode** | node `op`: exact, `one_of`, `any` | node attribute-type gate | +| **Dtype (exact / set)** | `{"on": "$q", "dtype": {"one_of": ["BFLOAT16", "FP8_E4M3"]}}` | `validateDataTypeIsSupported`, `validateFixedDataType` | +| **Dtype (relation)** | `{"on": "$k", "dtype": {"same_as": "$q"}}` | `validateConsistentDataTypes`, `q == k == v` | +| **Rank** | `{"on": "$q", "rank": 4}` | `validateDimensionCount`, rank == 4 | +| **Shape (exact / symbolic)** | `{"on": "$q", "shape": [1, "$num_heads", "_", "$head_size"]}` (int = exact, name = bind/unify, `_` = any) | dim reads and cross-tensor dim relations | +| **Layout** | `{"on": "$q", "layout": [0, 1, 2, 3]}` (stride-order index array, [§8](#8-layout-and-stride-order-constraints)) | `validateSupportedLayout` | +| **Packing** | `{"on": "$q", "packed": true}` | `validatePackedTensors` | +| **Cross-tensor layout** | `{"consistent_layout": ["$x", "$y", "$scale"]}` | `validateConsistentLayouts` | +| **Attribute (value)** | `{"on": "$root", "attr": {"causal_mask": {"equals": false}, "dropout_probability": {"absent_or": {"equals": 0.0}}}}` | per-attr value gates | +| **Attribute (one_of / relation)** | `{"on": "$root", "attr": {"head_dim": {"one_of": [64, 128, 192]}}}` | `head_dim in {...}` | +| **Optional operand present/absent** | `{"on": "$attn_mask", "present": false}` | `attn_mask_tensor_uid()` absent gate | +| **Virtual tensor** | `{"on": "$conv_out", "virtual": true}` | fusion tensor-virtuality gates | +| **Use-count / exclusivity** | `{"on": "$conv_out", "use_count": 1}` or `{"on": "$bias_out", "no_external_consumer": true}` | fusion legality checks | +| **Cross-tensor / expression** | `{"expr": "$q1 == $k2"}`, `{"expr": "$q0 <= 128"}` ([§7](#7-the-shared-expression-language)) | arithmetic and comparison gates | +| **Device / arch** | `{"on": "device", "arch": {"one_of": ["gfx942", "gfx950"]}}` | `getDeviceString` arch gate | +| **Native predicate** | `{"kind": "native_predicate", "name": "hipdnn.strides_fit_u32", "args": ["$q", "$k", "$v", "$o"]}` ([§9](#9-native-predicate-escape-hatch)) | overflow guards, table lookups, derived-shape relations | + +The `device` constraint gates *applicability* at match time; the per-architecture `kpack` manifest +([RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)) gates *loadability* at +install and load. Both must agree for a kernel to run. + +--- + +## 7. The Shared Expression Language + +The UMD needs boolean predicates over bound values (`$q1 == $k2`, `$q0 <= 128`, `$q0 one_of {64,128,256}`, +`$nhead_q % $nhead_k == 0`). The UDD needs integer-valued formulas over the same bound values for grid, +block, shared memory, and workspace (`ceil_div($seqlen_q, 16)`, +[RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). These are the same language +at two layers, and this RFC defines it once so the two subsystems cannot drift. + +**Layering.** + +- **Value core (shared with the UDD).** Literals (int, float, string/enum, array), symbol references, + tensor and attribute accessors, and arithmetic. The value core is exactly what the UDD RFC consumes; + that RFC references this section rather than redefining it. +- **Predicate layer (UMD-only).** Comparison, set membership, and, in a later iteration, boolean + composition ([§10](#10-composite-constraints-deferred-forward-compatible)) over value-core + expressions, producing a boolean. A UMD constraint's `expr` is a predicate-layer expression. + +**Canonical AST.** The stored form is the nested object form already used in RFC 0017 §6, so the two +subsystems share one parser, one validator, and one interpreter: + +```jsonc +// value core +{"op": "ceil_div", "args": [{"sym": "$seqlen_q"}, 16]} +{"op": "mod", "args": [{"dim": "$q", "axis": 1}, {"dim": "$k", "axis": 1}]} + +// predicate layer (UMD) +{"op": "eq", "args": [{"dim": "$q", "axis": 1}, {"dim": "$k", "axis": 2}]} // $q1 == $k2 +{"op": "one_of", "args": [{"dim": "$q", "axis": 0}, [64, 128, 256]]} // $q0 in {64,128,256} +``` + +**Authoring sugar.** A UMD `expr` may be written as a light infix string that compiles to the AST, so +the AICK-1698 examples are literal: + +``` +$q0 == 64 +$q1 == $k2 +$w1 == $x1 +$q0 <= 128 +$q0 one_of {64, 128, 256} +stride_order($q) == [0, 1, 2, 3] +$nhead_q % $nhead_k == 0 +``` + +In a UMD these are `expr` constraints ([§6](#6-constraint-vocabulary)); the infix string and the AST +form are interchangeable and compile to the same tree: + +```jsonc +"constraints": [ + {"expr": "$q0 == 64"}, // dim equality + {"expr": "$q1 == $k2"}, // cross-tensor dim relation + {"expr": "$q0 <= 128"}, // range bound + {"expr": "$q0 one_of {64, 128, 256}"}, // set membership + {"expr": "$nhead_q % $nhead_k == 0"}, // divisibility + {"expr": "stride_order($q) == [0, 1, 2, 3]"}, // layout via stride order + + // the infix string is sugar; the equivalent AST form is accepted too + {"expr": {"op": "eq", "args": [{"dim": "$q", "axis": 1}, {"dim": "$k", "axis": 2}]}} // == "$q1 == $k2" +] +``` + +Grammar (EBNF, value core plus predicate layer): + +``` +predicate = comparison | membership +comparison = value ("==" | "!=" | "<" | "<=" | ">" | ">=") value +membership = value "one_of" "{" value ("," value)* "}" +value = term (("+" | "-" | "*" | "/" | "%") term)* +term = number | symbol | call | array | "(" value ")" +symbol = "$" ident ("." ident | "[" number "]")* // $q0, $q.dims[2], $root.causal_mask +call = ident "(" (value ("," value)*)? ")" // ceil_div($seqlen_q, 16), stride_order($q) +array = "[" (value ("," value)*)? "]" +``` + +**Operators.** Value core: `+ - * / %`, `ceil_div`, `min`, `max`, `abs`, `pow`, `log2`, `rsqrt`. +Predicate layer: `== != < <= > >=`, `one_of`. Booleans combine only by the implicit top-level AND in +this iteration; explicit `and`/`or`/`not` are reserved for composite constraints +([§10](#10-composite-constraints-deferred-forward-compatible)). + +**Evaluation is a safe, bounded interpreter.** It fails closed on an unknown symbol, an out-of-range +axis, a type error, or an invalid operation, and never executes arbitrary code. Integer arithmetic +uses checked-width integers and fails closed on overflow rather than wrapping +([§15](#15-security-and-hostile-input)). The interpreter is bounded in recursion depth and step count. +Because the language is dependency-free and hand-written (no third-party parser), it is small enough to +audit and to lower into the static matcher ([§12](#12-static-matcher-sketch)). + +--- + +## 8. Layout and Stride-Order Constraints + +hipDNN tensors store no layout enum; layout is implied by stride order +([§3](#3-the-matchers-input-hipdnns-graph-model)). The UMD therefore represents layout as an **array of +dimension indexes giving the stride order**, from the slowest-varying dimension to the +fastest-varying. This is the shape a matcher can check directly against `strides()` and matches how +`TensorDescriptor` already precomputes `strideOrder` (`ApplicabilityChecks.cpp:17`). + +```jsonc +{"on": "$q", "layout": [0, 1, 2, 3]} // fully packed, natural order (BHSD for a rank-4 SDPA tensor) +{"on": "$x", "layout": [0, 3, 1, 2]} // NHWC over an NCHW logical dim order +``` + +- The array is a permutation of `0..rank-1`. Entry `k` names the logical dimension that occupies stride + position `k`, so `[0,1,2,3]` is descending-stride packed and `[0,3,1,2]` places the channel dim last + (NHWC). The `axis` used everywhere else (dims, strides, `args_signature`) indexes the logical + dimension order, independent of this physical layout, consistent with RFC 0017 §6. +- **Named aliases** are provided for the common cases and expand to arrays at compile time: + `"nchw" -> [0,1,2,3]`, `"nhwc" -> [0,3,1,2]`, `"ncdhw"`, `"ndhwc"`, `"bhsd" -> [0,1,2,3]`, + `"contiguous"` (identity permutation for the tensor's rank). Aliases keep authoring readable while + the array remains the single canonical form. The set of aliases matches the layouts + `validateSupportedLayout` accepts today (`ApplicabilityChecks.cpp:77`). +- **Cross-tensor consistency** uses `consistent_layout` ([§6](#6-constraint-vocabulary)), lowering + `validateConsistentLayouts`; layout-agnostic tensors (rank-1 scalars, pass-by-value) are skipped as + they are today. +- **Packing** is a separate `packed` constraint, since a supported stride order does not imply the + tensor is gap-free; it lowers `validatePackedTensors`. +- The stride-order array is also available to expressions as `stride_order($q)`, so + `stride_order(q) == [0,1,2,3]` from AICK-1698 is expressible directly. + +--- + +## 9. Native-Predicate Escape Hatch + +Some checks cannot be stated declaratively: they need real C++. A UMD constraint may name a **native +predicate** resolved from a provider-internal registry. + +```jsonc +{"kind": "native_predicate", "name": "hipdnn.strides_fit_u32", "args": ["$q", "$k", "$v", "$o"], "negated": false} +``` + +The descriptor carries only a symbol name and a typed argument list drawn from bound variables, never +inline code. Predicates take explicit arguments, not the whole graph, so they stay auditable and +reusable. Both the build-time and drop-in paths resolve predicates from the same registry: a file that +names only shipped predicates loads identically either way, and a file naming a predicate the running +provider does not ship fails to resolve on the drop-in path. The registry a provider ships is therefore +part of its published contract. + +The grounded cases that need this hatch, from [§2](#2-what-the-umd-replaces): + +- **Integer-overflow guards.** `wouldFwdByteStridesFitUint32` / `byteStrideFitsU32` + (`SdpaFwdPlanBuilder.cpp:294`, `SdpaPlanUtils.hpp:159`): the kernarg struct stores byte strides as + `uint32`, so the check must be exact and fail closed. A predicate `hipdnn.strides_fit_u32`. +- **Derived-quantity relations.** NumPy-broadcast affine-shape compatibility + (`BatchnormApplicabilityChecks.cpp:169`), layernorm normalized-dim reconciliation + (`LayernormApplicabilityChecks.cpp:68`), and RMSnorm `inv_rms` derived shape + (`RMSnormApplicabilityChecks.cpp:106`) each compute a shape and compare it, beyond the constraint + vocabulary. +- **Contradiction checks.** `getMaskType` throws when mask attributes contradict + (`SdpaFwdPlanBuilder.cpp:276`); a predicate encodes "the mask attributes are self-consistent". +- **GQA divisibility.** `nhead_q % nhead_k == 0 && nhead_k != 0` (`SdpaBwdPlanBuilder.cpp:548`) is + expressible with `%` in [§7](#7-the-shared-expression-language), but a native predicate is an option + if the zero-guard and fail-closed semantics are better centralized. + +**Kernel-table lookups are a migration artifact, not a lasting predicate.** The +`getKernelNameKey` / CSV-registry lookups (`SdpaFwdPlanBuilder.cpp:287`, three in +`SdpaBwdPlanBuilder.cpp:660`) exist because today the builder resolves which prebuilt code object serves +a shape. Under the UKD model the KDP names the code object directly and the heuristic ranks candidates, +so these lookups mostly dissolve into ordinary constraints plus the Launch's kernel source. Where a +residual "is there a row for this exact combination" gate remains during coexistence, it is a native +predicate. + +Together with the UDD's custom-plan hatch +([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)), these form the graded +ladder: fully declarative constraints, then a named predicate for one step that needs C++, then a full +provider. + +--- + +## 10. Composite Constraints (Deferred, Forward-Compatible) + +All constraints AND together, which covers every hand-written check surveyed +([§2](#2-what-the-umd-replaces)). AICK-1698 anticipates a later need for a single constraint that is a +boolean combination, for example `(A AND B) OR C`. This iteration does not evaluate boolean composition +beyond the top-level AND, but reserves a forward-compatible shape so adding it is additive and does not +change existing descriptors: + +```jsonc +// reserved shape, not evaluated in v1 +{"any_of": [ // OR + {"all_of": [{"expr": "q0 == 64"}, {"expr": "q1 == k2"}]}, // (A AND B) + {"expr": "q0 == 128"} // OR C +]} +``` + +A v1 matcher rejects a UMD that uses `any_of` / `all_of` with a clear "composite constraints not +supported by this runtime version" error ([§14](#14-serialization-and-versioning)), rather than +silently ignoring it. The predicate-layer grammar ([§7](#7-the-shared-expression-language)) already +reserves `and` / `or` / `not` for the same reason. General N-ary commutative matching and unbounded +chains remain deferred to the JIT follow-up, as in RFC 0017 §5. + +--- + +## 11. The Matcher: Compilation, Indexing, and Caching + +A UMD is authored as text and **compiled once** into an in-memory matcher structure at provider load +(or, for the drop-in path, when the bundle is scanned). Compilation resolves op-schema roles, expands +layout aliases, parses expressions to AST, validates that every referenced symbol is bound, and orders +the constraints for early-out. The compiled form, not the text, is what runs against live graphs. + +**Root-opcode indexing.** The compiled matchers are indexed by the root node's opcode, so match cost +does not grow linearly with the number of descriptors: a graph whose root op is `sdpa_fwd` only +consults UMDs rooted at `sdpa_fwd`. This is the index RFC 0017 §14 calls for. Per-candidate cost +(constraint, predicate, and expression evaluation) is separate and is bounded by the ordering below, +not by the index. + +**Prioritized, early-out constraint ordering (AICK-1698).** Within a candidate, constraints run +cheapest-and-most-selective first so a non-match is rejected as early as possible. The compiler assigns +a static cost/selectivity order, roughly: + +1. Node count and opcode (already narrowed by the index, but confirmed). +2. Attribute equality and `one_of` (scalar reads). +3. Rank and dtype (single tensor-field reads). +4. Shape, symbolic-dim unification, and layout/stride-order (vector reads). +5. Cross-tensor relations and expressions. +6. Native predicates (opaque, potentially the most expensive), last. + +Authors do not order constraints; the compiler does, from the constraint kind. The relative order is a +matcher-internal detail, not part of the descriptor contract, so it can be tuned without a schema +change. + +**Per-plan caching (AICK-1698).** Matching runs at plan-build time. The result (the chosen UMD, the +bound symbol table, and the arbitration outcome) is cached on the compiled plan and reused for +workspace queries and execution, so the same graph is not re-matched across the +`isApplicable` / `getMaxWorkspaceSize` / `buildPlan` calls that re-run the loop today +(`AsmSdpaEngine.cpp:66,87`). The compiled matcher itself is built once and shared across plans; only +the per-graph binding result is per-plan. + +**Device gating short-circuits.** The `device.arch` constraint is evaluated once per graph, before +per-candidate work, since arch is constant for the stream. + +![Compile-once pipeline: text UMD to constraint IR to a root-opcode-indexed matcher, with a per-plan bind cache](../images/umd_matcher_pipeline.svg) + +--- + +## 12. Static Matcher (Sketch) + +AICK-1698 asks whether a UMD can be pre-compiled into a static matcher that further cuts the runtime +cost, while still supporting runtime (drop-in) matchers. This iteration does not commit to a design; it +records the options and the constraint they must satisfy. + +**The parity constraint.** However a static matcher is produced, it must be behaviorally identical to +the runtime matcher on the same UMD and graph. Build-time and drop-in descriptors run through one +generic engine ([RFC 0017 §3](0017_UniversalKernelDescriptor.md#3-how-it-works)), so a kernel that is +AOT-packed today and dropped in tomorrow must match the same graphs either way. Parity is testable as a +cross-path equivalence check ([§17](#17-testing-and-performance)). + +Options, from least to most build coupling: + +- **Interpreted compiled IR (baseline).** The [§11](#11-the-matcher-compilation-indexing-and-caching) + compiled form, interpreted. No codegen; identical on both paths by construction. This is the + fallback and the parity oracle. +- **Bytecode / flattened decision program.** Lower the constraint IR to a compact bytecode (a linear + program of typed ops over the bound symbol table) that a tiny VM executes. Serializable into the + KDP, so drop-in gets the same artifact AOT does. Faster than tree-walking, still data. +- **Generated C++ predicate, AOT only.** For build-time descriptors, emit a specialized C++ + applicability function per UMD and compile it into the provider (or a packed object). Closest to a + hand-written `isApplicable`, but unavailable to pure drop-in descriptors, which fall back to the + interpreted or bytecode path. Needs the generated function proven equivalent to the interpreter. +- **Shared decision tree across UMDs.** Combine many UMDs rooted at the same opcode into one decision + tree that tests shared constraints once (a discrimination net). An optimization layered on any of the + above; it changes throughput, not per-UMD semantics. + +Recommendation for a later iteration: make the interpreted compiled IR the contract and the parity +oracle, add the bytecode form as the shared AOT/drop-in fast path, and treat generated C++ and the +shared decision tree as opportunistic optimizations gated behind the parity test. The concrete choice +is deferred. + +--- + +## 13. Arbitration + +Today the first applicable plan builder wins, which is a documented latent bug when more than one +matches (`HipMlopsEngine.cpp:34`). The UMD makes overlap explicit and resolves it deterministically, +reusing the rule from RFC 0017 §5: + +1. When several UKDs match a graph, the **UHD** (kernel-selection heuristic) ranks them and the + top-scored kernel wins. +2. Ties break by explicit **`priority`** on the UKD. +3. Remaining ties break by the descriptor's stable **`id`**, and the conflict is logged to the warning + log so an unintended overlap is visible. + +Arbitration is a property of the generic engine over the set of matching UKDs; a UMD shared by several +UKDs contributes each of them as a candidate. This closes the mutual-exclusion-by-construction +requirement that the current engines depend on: overlap is allowed and resolved, not a correctness +hazard. + +--- + +## 14. Serialization and Versioning + +- **Authoring form.** Human-readable, diffable JSONC (the examples here), including the infix `expr` + sugar of [§7](#7-the-shared-expression-language). +- **Compiled form.** The compact binary the matcher runs ([§11](#11-the-matcher-compilation-indexing-and-caching)), + whose concrete bytes are defined with the KDP/packaging follow-up + ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)); this RFC defines the schema + those bytes encode. +- **Schema and version.** Every UMD carries `schema: "hipdnn.umd/v1"`, a stable opaque `id`, and a + mandatory `name` for diagnostics. A UMD whose schema version is newer than the runtime understands is + refused with a clear error, never silently reinterpreted, matching + [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats). +- **Additive evolution.** New constraint kinds, operators, layout aliases, and the composite forms of + [§10](#10-composite-constraints-deferred-forward-compatible) are additive within `v1` where they do + not change the meaning of an existing descriptor; anything that would reinterpret existing fields + bumps the version. +- **Identity.** UMD ids are unique within the UMD kind; a match id and an engine id may collide as + strings because references are typed by field (a UKD's `match` versus `engine`). A colliding drop-in + id is logged and ignored rather than taking down the provider ([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). + +--- + +## 15. Security and Hostile Input + +On the drop-in path the loader, the matcher, and the expression interpreter parse input that may be +untrusted or simply malformed, so they must be bounded and fail closed rather than crash +([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). + +- **Bounded parsing and matching.** Recursion depth, expression step count, node/constraint counts, and + descriptor size are capped; exceeding a cap quarantines the descriptor, it does not abort the + provider. +- **Checked arithmetic.** Shape, stride, and workspace arithmetic uses checked-width integers and fails + closed on overflow rather than under-allocating or wrapping. This is the same class of bug the + `strides_fit_u32` predicate guards ([§9](#9-native-predicate-escape-hatch)). +- **Fail-closed evaluation.** An unknown symbol, unresolved native predicate, out-of-range axis, or + type error declines the match; it never matches by default. +- **Quarantine, not cascade.** A bad descriptor is quarantined on load with a diagnostic; the rest load + ([RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)). +- **Fuzzing.** A seed corpus of UMDs and graphs plus a fuzzer over the loader, matcher, and interpreter + run under the existing ASAN build ([§17](#17-testing-and-performance)), backing the fail-closed + requirement. + +--- + +## 16. Observability and Diagnostics + +Because matching is data-driven, it is inspectable, and the tooling is a first-class deliverable +([RFC 0017 §9](0017_UniversalKernelDescriptor.md#9-observability-and-diagnostics)). For the UMD the +provider surfaces: + +- **A why-not trace.** For a graph and a candidate UMD, the first constraint that failed and why (the + concrete values compared), so an author can see exactly which gate declined. +- **A binding view.** For a successful match, the full bound symbol table (tensors, dims, strides, + attributes) as the UDD will see it. +- **An arbitration trace.** Which UKDs matched, how the UHD scored them, and where a tie fell to + `priority` or stable `id` ([§13](#13-arbitration)). +- **Load diagnostics.** Which UMDs compiled, which were quarantined and why, and unresolved native + predicates by name. + +These reuse the diagnostic surface RFC 0017 §9 defines rather than adding a UMD-specific one. + +--- + +## 17. Testing and Performance + +The UMD introduces no new testing strategy; it slots into hipDNN's existing tiers (`docs/Testing.md`, +`docs/testing/TestingStrategy.md`) as RFC 0017 §12.1 requires. A UMD-backed kernel runs through the +generic engine as an ordinary engine and produces the same graphs everything else consumes, so the +plugin-agnostic integration harness ([RFC 0006](0006_PluginAgnosticIntegrationTests.md)) validates it +against the CPU reference ([RFC 0001](0001_CpuGraphExecutorDesign.md)) with the golden-reference +tolerance chain ([RFC 0011](0011_GoldenReferenceValidation.md)). + +UMD-specific coverage: + +- **Match-equivalence against hand-written `isApplicable`.** For each converted engine, a test drives a + battery of graphs (accepting and rejecting) through both the hand-written builder and the UMD and + asserts identical accept/reject decisions and identical bound values. The SDPA-forward builder + ([§19](#19-worked-example-sdpa-forward)) is the first target. +- **Static/runtime parity.** The parity oracle of [§12](#12-static-matcher-sketch): the same UMD and + graph must decide identically on the interpreted and any lowered matcher. +- **Expression-language conformance.** A table-driven suite over the value core and predicate layer, + including the AICK-1698 examples and the fail-closed cases (overflow, unknown symbol, bad axis), + shared with the UDD RFC's expression tests since the language is shared. +- **Fuzzing.** The corpus and fuzzer of [§15](#15-security-and-hostile-input). +- **Match overhead.** Plan-time match cost is measured against the hand-written baseline as + benchmarking matures (`tools/dnn-benchmarking`, [RFC 0013](0013_Autotune.md)); the compiled matcher, + root-opcode index, and per-plan cache ([§11](#11-the-matcher-compilation-indexing-and-caching)) keep + it minimal, and the cost is paid once at plan build. + +--- + +## 18. Migration + +Migration follows RFC 0017 §12: no engine is converted until a UMD-backed kernel runs end to end, and a +hand-written engine and its descriptor-backed replacement coexist until the generic one reaches parity +on the graphs that engine covers, at which point the hand-written code is retired. + +The **SDPA-forward** `isApplicable` (`SdpaFwdPlanBuilder.cpp:167`) is the first conversion, because it +exercises nearly the whole vocabulary (opcode, attribute gates, optional-operand absence, rank, dtype +relations, symbolic-dim unification, and two native predicates) in one node. Its match-equivalence test +([§17](#17-testing-and-performance)) gates the cutover. The mlops builders follow, reusing the +`IValidator` primitives ([§2](#2-what-the-umd-replaces)) as the reference for their constraint +lowering. The kernel-table lookups dissolve into the KDP as described in +[§9](#9-native-predicate-escape-hatch). + +--- + +## 19. Worked Example: SDPA Forward + +The SDPA-forward check collapses into one UMD. Compared to the hand-written builder +(`SdpaFwdPlanBuilder.cpp:167-296`), each C++ gate becomes a field, and only the two genuinely +non-declarative gates (uint32 stride fit, mask self-consistency) remain as native predicates. Note +`$head_size` is bound from `$q`'s dim, not read as an attribute +([§3](#3-the-matchers-input-hipdnns-graph-model)). + +```jsonc +{ + "schema": "hipdnn.umd/v1", + "id": 1180449020, + "name": "SDPA forward (d128, bf16/fp8) match", + "nodes": [ + {"kind": "op", "id": "$root", "op": "sdpa_fwd", + "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} + ], + "constraints": [ + {"on": "device", "arch": {"one_of": ["gfx942", "gfx950"]}}, // getDeviceString gate + + // dtype: $q == $k == $v (relation), $o supported (set) + {"on": "$q", "dtype": {"one_of": ["BFLOAT16", "FP8_E4M3"]}}, + {"on": "$k", "dtype": {"same_as": "$q"}}, + {"on": "$v", "dtype": {"same_as": "$q"}}, + + // rank 4 and dim binding; head_size and head count unify across q/k/v + {"on": "$q", "shape": ["$batch", "$num_heads", "$seqlen_q", "$head_size"]}, + {"on": "$k", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]}, + {"on": "$v", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]}, // $k1 == $v1 via $kv_heads + {"on": "$o", "rank": 4}, + {"expr": "$head_size == 128"}, + + // attribute gates: no dropout, no alibi/padding mask, no stats output + {"on": "$root", "attr": { + "dropout_probability": {"absent_or": {"equals": 0.0}}, + "alibi_mask": {"equals": false}, + "padding_mask": {"equals": false}, + "generate_stats": {"absent_or": {"equals": false}} + }}, + + // optional operands that this kernel does not support must be absent + {"on": "$attn_mask", "present": false}, + {"on": "$page_table_k","present": false}, + {"on": "$page_table_v","present": false}, + + // escape hatches: the two checks that need real C++ + {"kind": "native_predicate", "name": "hipdnn.sdpa_mask_consistent", "args": ["$root"]}, + {"kind": "native_predicate", "name": "hipdnn.strides_fit_u32", "args": ["$q", "$k", "$v", "$o"]} + ] +} +``` + +Mapping to the hand-written code: + +| Hand-written (`SdpaFwdPlanBuilder.cpp`) | UMD field | +|---|---| +| `getDeviceString` gfx942/gfx950 (:186) | `device.arch` | +| `nodeWrappers().size() != 1` (:199) | single-node pattern | +| `attributesType() != SdpaAttributes` (:200) | node `op: sdpa_fwd` | +| dropout / alibi / padding / stats gates (:205-224) | `attr` constraints | +| `attn_mask` / `page_table_*` absent (:209-215) | optional-operand `present: false` | +| rank == 4 (:231-247) | `shape` (rank 4) | +| `q == k == v` dtype (:244) | `dtype.same_as` | +| `k.dims[1] == v.dims[1]` head count (:251) | `$kv_heads` shape unification | +| head dim == 128 | `expr: $head_size == 128` | +| `getMaskType` throw-on-contradiction (:276) | `hipdnn.sdpa_mask_consistent` predicate | +| `wouldFwdByteStridesFitUint32` (:294) | `hipdnn.strides_fit_u32` predicate | +| `getKernelNameKey` table lookup (:287) | dissolves into the KDP's Launch ([§9](#9-native-predicate-escape-hatch)) | + +The bound symbols (`$q..$o`, `$batch`, `$num_heads`, `$kv_heads`, `$seqlen_q`, `$seqlen_k`, `$head_size`, and +every auto-bound dim/stride) are exactly what the paired UDD's grid and argument formulas reference +([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). + +--- + +## 20. Risks + +- **Op-schema registry coupling.** Auto-binding depends on a registry generated from the flatbuffer op + schema ([§3](#3-the-matchers-input-hipdnns-graph-model)). If it drifts from the graph definitions, + bindings are wrong. Mitigation: generate it from the same schema, and fail closed on an unknown op or + role rather than binding a wrong field. +- **Expression language sharing.** The value core is shared with the UDD ([§7](#7-the-shared-expression-language)). + A change made for one subsystem can affect the other. Mitigation: one parser/validator/interpreter, + a shared conformance suite, and a clear layer split (UDD uses the value core only). +- **Predicate registry as contract.** Native predicates are part of the published provider contract + ([§9](#9-native-predicate-escape-hatch)); a drop-in naming an unshipped predicate fails to resolve. + Mitigation: version and document the shipped predicate set; fail closed with a clear diagnostic. +- **Match overhead.** Per-candidate constraint and predicate evaluation is unbounded by the root-opcode + index ([§11](#11-the-matcher-compilation-indexing-and-caching)). Mitigation: early-out ordering, + per-plan caching, and the overhead test of [§17](#17-testing-and-performance). +- **Static-matcher parity.** A lowered matcher that diverges from the interpreter is a silent + correctness bug ([§12](#12-static-matcher-sketch)). Mitigation: the interpreter is the oracle and the + parity test gates any lowering. + +--- + +## 21. Open Questions + +1. **Expression language home.** This RFC defines the shared value core; the UDD RFC references it. + Confirm this split with the UDD RFC rather than each defining its own, and decide where the shared + conformance suite lives. +2. **GQA divisibility.** Express `nhead_q % nhead_k == 0` with the `%` operator + ([§7](#7-the-shared-expression-language)) or centralize it as a native predicate for uniform + fail-closed zero-guarding ([§9](#9-native-predicate-escape-hatch))? +3. **Static-matcher form.** Which of the [§12](#12-static-matcher-sketch) options becomes the AOT fast + path, and does it also serve drop-in via a serialized bytecode? +4. **Feature-vector overlap.** The bound symbol table overlaps the feature vector a UHD consumes + ([RFC 0017 §15 Q4](0017_UniversalKernelDescriptor.md#15-open-questions)); should the UMD's bindings + be the canonical feature source for kernel selection? +5. **Composite timing.** When composite constraints ([§10](#10-composite-constraints-deferred-forward-compatible)) + are needed, do they land before or after the first engines are migrated? + +--- + +## 22. References and Prior Art + +The design borrows established ideas; none is a dependency. These informed the UMD specifically. + +| System | Idea borrowed | +|---|---| +| **MLIR PDL / PDLL** | Two-layer design: a declarative pattern compiled once to a fast matcher; constraints inline on the binding; a named native-predicate escape hatch; pattern priority for arbitration | +| **TVM Relax DFPattern** | Constraint vocabulary (op, dtype, symbolic shape, wildcard); dataflow use-def constraints; cross-tensor same-shape relations | +| **XLA pattern matcher** | Exact-vs-compatible equality; use-count versus user-count; layout as a distinct constraint; optional operands; capture-by-reference binding | +| **PyTorch Inductor / torch.library** | Node/edge pattern vocabulary; serialized precompiled patterns; duplicate-pattern detection | +| **LLVM ISel / discrimination nets** | Sharing common prefixes of many patterns rooted at one opcode into one decision structure ([§12](#12-static-matcher-sketch)) | +| **ONNX Runtime** | First-claim arbitration as the anti-pattern this RFC replaces with deterministic ranking; single-node versus fused-subgraph capability | + +--- + +## 23. Glossary + +- **UMD (Universal Match Descriptor):** the declarative pattern and constraints that decide whether a + kernel applies to a graph and bind the variables its dispatch and workspace formulas use. Reused + across kernels by ID. +- **Structural pattern:** the op nodes and the named operand/result edges of a UMD; edges are implicit + through shared pattern variables ([§4](#4-structural-pattern)). +- **Constraint:** a condition attached to a bound tensor, a node, or `device`; constraints implicitly + AND ([§6](#6-constraint-vocabulary)). +- **Symbol lifecycle:** a name is declared in the UMD, bound when the graph matches, and used by the UDD + ([§5](#5-symbol-binding-and-the-auto-binding-formula)). +- **Auto-binding formula:** the standard scheme that binds every operand/result tensor, its dims and + strides, and every op attribute of a matched node, without hand-declaration + ([§5](#5-symbol-binding-and-the-auto-binding-formula)). +- **Op-schema registry:** the generated table mapping each op type to its operand/result UID fields and + attributes, letting the matcher reconstruct edges and auto-bind + ([§3](#3-the-matchers-input-hipdnns-graph-model)). +- **Value core / predicate layer:** the two layers of the shared expression language; the value core + (arithmetic, shared with the UDD) yields numbers, the predicate layer (comparison, membership) yields + booleans ([§7](#7-the-shared-expression-language)). +- **Stride-order layout:** layout represented as an array of dimension indexes giving stride order, + since tensors carry no layout enum ([§8](#8-layout-and-stride-order-constraints)). +- **Native predicate:** the escape hatch; a registry-resolved check a UMD names for logic it cannot + state declaratively, carried as a symbol name and typed arguments, never inline code + ([§9](#9-native-predicate-escape-hatch)). +- **Composite constraint:** a single constraint that is a boolean combination of sub-constraints; + schema reserved, evaluation deferred ([§10](#10-composite-constraints-deferred-forward-compatible)). +- **Arbitration:** the deterministic resolution when several UKDs match: UHD score, then `priority`, + then stable `id` ([§13](#13-arbitration)). +- **Root-opcode index:** the index of compiled matchers by root opcode that keeps match cost sublinear + in descriptor count ([§11](#11-the-matcher-compilation-indexing-and-caching)). From dd6c3b0e73516e2b2ca7a8cdbe7231f1c04c596a Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 23 Jul 2026 12:37:47 -0600 Subject: [PATCH 32/60] More updates to RFC (cherry picked from commit 2c7626cf89fa7c8382a543c0f11beb05810c823e) (cherry picked from commit 702e6b6c040542569ebc1291ddbd7cf4d42a40ec) --- .../rfcs/0018_UniversalMatchDescriptor.md | 485 +++++++++--------- 1 file changed, 237 insertions(+), 248 deletions(-) diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 02636856be95..983dfef26068 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -20,7 +20,7 @@ 7. [The Shared Expression Language](#7-the-shared-expression-language) 8. [Layout and Stride-Order Constraints](#8-layout-and-stride-order-constraints) 9. [Native-Predicate Escape Hatch](#9-native-predicate-escape-hatch) -10. [Composite Constraints (Deferred, Forward-Compatible)](#10-composite-constraints-deferred-forward-compatible) +10. [Composite Constraints](#10-composite-constraints) 11. [The Matcher: Compilation, Indexing, and Caching](#11-the-matcher-compilation-indexing-and-caching) 12. [Static Matcher (Sketch)](#12-static-matcher-sketch) 13. [Arbitration](#13-arbitration) @@ -51,12 +51,13 @@ A UMD has two parts, unchanged in intent from 1. A **structural pattern**: named operation nodes and their named operand and result edges. Because hipDNN op graphs are DAGs, the pattern is an explicit node-and-edge graph, not a nested expression. -2. A **constraint list** attached to those nodes and edges. Constraints implicitly AND together. +2. A **constraint expression** over the pattern's bound variables: one JsonLogic boolean, typically an + `and` of the individual tests. This document turns that frame into a concrete format and a concrete matcher. It specifies the pattern and constraint schema, the standard formula that auto-binds every tensor and attribute of a matched op, -a shared expression language (used here for boolean constraints and reused by the UDD for dispatch -formulas), the layout representation as a stride-order index array, the native-predicate escape hatch, +JsonLogic as the shared expression language (boolean constraints here, dispatch formulas in the UDD), +the layout representation as a stride-order index array, the native-predicate escape hatch, deterministic arbitration, and the compile-once matcher with its indexing and caching. The static (compile-time) matcher is sketched as options, not fully designed, in this iteration. @@ -66,11 +67,11 @@ deterministic arbitration, and the compile-once matcher with its indexing and ca |---|---|---| | Structural pattern: op nodes, named operand/result edges, single-op and bounded fused subgraphs | Yes ([§4](#4-structural-pattern)) | None | | Auto-binding of every operand/result tensor, its dims and strides, and every op attribute | Yes ([§5](#5-symbol-binding-and-the-auto-binding-formula)) | None | -| Constraints: opcode, dtype (exact/one_of/relation), shape/rank, symbolic-dim unification, layout, packing, attribute, use-count, cross-tensor relation, optional operand, device/arch | Yes ([§6](#6-constraint-vocabulary)) | None | -| Shared expression language: arithmetic value core (UDD-shared) plus a boolean predicate layer (UMD) | Yes ([§7](#7-the-shared-expression-language)) | New operators as needed | +| Constraints as one JsonLogic expression: opcode, dtype (exact/set/relation), shape/rank, symbolic-dim unification, layout, packing, attribute, use-count, cross-tensor relation, optional operand, device/arch | Yes ([§6](#6-constraint-vocabulary)) | None | +| JsonLogic as the shared expression language (UMD boolean + UDD value), `$`-variable convention | Yes ([§7](#7-the-shared-expression-language)) | New operators as needed | | Layout as a stride-order index array, with named aliases | Yes ([§8](#8-layout-and-stride-order-constraints)) | None | | Native-predicate escape hatch, registry-resolved | Yes ([§9](#9-native-predicate-escape-hatch)) | None | -| Composite constraints: `(A AND B) OR C` as one constraint | Forward-compatible schema only ([§10](#10-composite-constraints-deferred-forward-compatible)) | Evaluation semantics | +| Composite constraints: `(A AND B) OR C` as one constraint, via JsonLogic `and`/`or`/`!`/`if` | Yes ([§10](#10-composite-constraints)) | None | | Compile-once matcher, root-opcode index, per-plan match cache | Yes ([§11](#11-the-matcher-compilation-indexing-and-caching)) | None | | Static (compile-time / AOT-lowered) matcher | Options sketched ([§12](#12-static-matcher-sketch)) | Full design | | General N-ary commutative matching, unbounded variable-length chains | None | JIT follow-up ([RFC 0017 §8.3](0017_UniversalKernelDescriptor.md#83-future-jit-and-normalized-providers)) | @@ -98,7 +99,7 @@ hand-coded check kind maps to a UMD construct or, where it needs real C++, to a | Cross-tensor dim relation, head count `k.dims[1] == v.dims[1]` (`SdpaFwdPlanBuilder.cpp:251`) | symbolic-dim unification ([§5](#5-symbol-binding-and-the-auto-binding-formula)) | | Packed-tensor and supported-layout gates (`ApplicabilityChecks.cpp:65,77`) | packing and layout constraints ([§8](#8-layout-and-stride-order-constraints)) | | Consistent-layout across tensors (`ApplicabilityChecks.cpp:106`) | cross-tensor layout relation ([§8](#8-layout-and-stride-order-constraints)) | -| Head dim `one_of {64,128,192}` (`SdpaBwdPlanBuilder.cpp:597`) | `one_of` on a bound dim ([§7](#7-the-shared-expression-language)) | +| Head dim `one_of {64,128,192}` (`SdpaBwdPlanBuilder.cpp:597`) | `{"in": ["$head_dim", [64, 128, 192]]}` ([§7](#7-the-shared-expression-language)) | | GQA divisibility `nhead_q % nhead_k == 0 && nhead_k != 0` (`SdpaBwdPlanBuilder.cpp:548`) | expression predicate, or a native predicate if fail-closed overflow matters ([§9](#9-native-predicate-escape-hatch)) | | uint32 byte-stride-overflow guard (`SdpaFwdPlanBuilder.cpp:294`, `SdpaPlanUtils.hpp:159`) | **native predicate** ([§9](#9-native-predicate-escape-hatch)) | | Kernel-name-key / CSV-registry table lookups (`SdpaFwdPlanBuilder.cpp:287`, three in `SdpaBwdPlanBuilder.cpp:660`) | **native predicate** (or removed once the KDP names the code object directly) ([§9](#9-native-predicate-escape-hatch)) | @@ -173,7 +174,7 @@ variables (`$q`, `$conv_out`). {"kind": "op", "id": "$root", "op": "sdpa_fwd", "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} ], - "constraints": [ /* Section 6 */ ] + "constraints": { /* Section 6: one JsonLogic boolean expression */ } } ``` @@ -212,14 +213,18 @@ UKD. Legality (that the fused intermediates have no consumer outside the pattern {"kind": "op", "id": "$act", "op": "pointwise_relu", "operands": {"A": "$bias_out"}, "results": {"Y": "$y"}} ], - "constraints": [ - {"on": "$x", "dtype": {"one_of": ["FLOAT16"]}, "layout": [0, 3, 1, 2]}, - {"on": "$y", "dtype": {"one_of": ["FLOAT16"]}, "layout": [0, 3, 1, 2], - "shape": ["$batch", "$out_h", "$out_w", "$out_channels"]}, - {"on": "$bias", "shape": ["$out_channels"]}, - {"on": "$conv_out", "use_count": 1}, - {"on": "$bias_out", "use_count": 1} - ] + "bindings": [ + {"on": "$y", "shape": ["$batch", "$out_h", "$out_w", "$out_channels"]}, + {"on": "$bias", "shape": ["$out_channels"]} // unifies $out_channels with $y's last dim + ], + "constraints": {"and": [ + {"==": ["$x.dtype", "FLOAT16"]}, + {"==": ["$x.stride_order", [0, 3, 1, 2]]}, + {"==": ["$y.dtype", "FLOAT16"]}, + {"==": ["$y.stride_order", [0, 3, 1, 2]]}, + {"==": ["$conv_out.use_count", 1]}, // intermediates private to the subgraph + {"==": ["$bias_out.use_count", 1]} + ]} } ``` @@ -247,29 +252,43 @@ operand or result variable, the matcher, using the op-schema registry, automatic is `$q.dims[0]`, `$q3` is `$q.dims[3]`). - Each **stride**, addressable as `$q.strides[i]`. - Derived tensor facts: `$q.rank`, `$q.dtype`, `$q.stride_order` ([§8](#8-layout-and-stride-order-constraints)), - `$q.is_virtual`. + `$q.is_virtual`, `$q.is_packed`, `$q.use_count` (consumers inside the pattern) and + `$q.external_use_count` (consumers outside it), and, for an optional operand role, `$q.present`. When a pattern names a node, the matcher auto-binds every scalar attribute of that op, addressable as `$root.` (for example `$root.causal_mask`, `$root.dropout_probability`). Optional attributes carry -a presence flag, `$root..present`. +a presence flag, `$root..present`. The GPU is bound as `$device` with `$device.arch` +([§3](#3-the-matchers-input-hipdnns-graph-model)), resolved from the stream, not the graph. **A `$` marks a bound symbol.** Every reference to a bound symbol carries a leading `$`: tensor handles (`$q`), their dims, strides, and derived facts (`$q0`, `$q.dims[2]`, `$q.rank`), named shape symbols -(`$head_size`), and the node handle and its attributes (`$root`, `$root.causal_mask`). Tokens without a -`$` are literals: numbers, enum values (`"BFLOAT16"`, `"gfx942"`), attribute-name keys, layout aliases, -opcodes, and keywords (`device`, `one_of`, `any`). The same token then reads identically wherever it -appears, in a `shape` list, an `expr` string, or a native-predicate argument. - -Authors therefore get a complete, uniform symbol table for free and never hand-declare each field. Two -optional forms add friendly names and cross-tensor unification on top: - -- **Named shape dims.** A `shape` list names a tensor's dims: `"shape": ["$batch", "$num_heads", - "$seqlen_q", "$head_size"]` binds those four symbols to `$q`'s dims. Reusing a name across tensors - **unifies** it: writing `$head_size` on both `$q` and `$k` requires `$q3 == $k3` and binds one symbol, - replacing the hand-coded `k.dims[1] == v.dims[1]` relation. A dim position may be left anonymous with - `"_"`. -- **Named attributes.** Rarely needed, since `$root.` already reads them, but a constraint may - alias one for reuse in formulas. +(`$head_size`), the node handle and its attributes (`$root`, `$root.causal_mask`), and the device +(`$device.arch`). Tokens without a `$` are literals: numbers, enum values (`"BFLOAT16"`, `"gfx942"`), +opcodes, and layout aliases. This is exactly the JsonLogic variable rule of +[§7](#7-the-shared-expression-language): a `$`-string is a variable, everything else is a literal. The +same token reads identically wherever it appears — a `bindings` `shape` list, a constraint expression, +or a native-predicate argument. + +Authors therefore get a complete, uniform symbol table for free and never hand-declare each field. A +separate top-level **`bindings`** list adds friendly names and cross-tensor unification on top; it holds +naming directives, not boolean tests, so it stays out of the `constraints` expression (which is pure +JsonLogic, [§6](#6-constraint-vocabulary)): + +```jsonc +"bindings": [ + {"on": "$q", "shape": ["$batch", "$num_heads", "$seqlen_q", "$head_size"]}, + {"on": "$k", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]} +] +``` + +- **Named shape dims.** A `shape` list names a tensor's dims and requires its rank to equal the list + length, so it doubles as a rank check. Reusing a name across tensors **unifies** it: writing + `$head_size` on both `$q` and `$k` binds one symbol and requires `$q.dims[3] == $k.dims[3]`, which the + matcher enforces as the implied JsonLogic equality — replacing the hand-coded `k.dims[1] == v.dims[1]` + relation. A dim position may be left anonymous with `"_"`. The same relation may instead be written + explicitly as a sub-expression of `constraints`; the `bindings` name is the sugar. +- **Named attributes.** Rarely needed, since `$root.` already reads them, but a `bindings` entry + may alias one for reuse in formulas. Symbol names are unique within a UMD across tensors, nodes, and named dims; a match/dispatch pair is validated so that unification is consistent (a name bound to two different concrete values fails the @@ -281,29 +300,35 @@ match, not the load). ## 6. Constraint Vocabulary -Constraints attach to a target (`on`) and implicitly AND together. The target is a bound tensor -variable (`$q`), a pattern node id (`root`), or the special `device`. The vocabulary is fixed to what -the hand-written checks of [§2](#2-what-the-umd-replaces) express, so no check needs code that a -constraint cannot state (the residue goes to a native predicate, [§9](#9-native-predicate-escape-hatch)). - -| Constraint | Form | Lowers from | +The `constraints` field is a **single JsonLogic boolean expression** evaluated over the bound symbol +table ([§5](#5-symbol-binding-and-the-auto-binding-formula), [§7](#7-the-shared-expression-language)). +It is normally an `and` of the individual tests, and reaches for `or` / `!` / `if` wherever a real +disjunction is needed ([§10](#10-composite-constraints)) — there is no list and no per-entry wrapper. +The table below is not a set of constraint *kinds* (there are none); it is the set of hand-written +checks ([§2](#2-what-the-umd-replaces)) and the JsonLogic sub-expression that expresses each, so no +check needs code a JsonLogic expression cannot state. The one residue is a **native predicate**, itself +a JsonLogic operation resolved from the provider registry ([§9](#9-native-predicate-escape-hatch)), so +it composes inside the same expression as any built-in operator. + +| Hand-written check | JsonLogic `expr` | Lowers from | |---|---|---| -| **Opcode** | node `op`: exact, `one_of`, `any` | node attribute-type gate | -| **Dtype (exact / set)** | `{"on": "$q", "dtype": {"one_of": ["BFLOAT16", "FP8_E4M3"]}}` | `validateDataTypeIsSupported`, `validateFixedDataType` | -| **Dtype (relation)** | `{"on": "$k", "dtype": {"same_as": "$q"}}` | `validateConsistentDataTypes`, `q == k == v` | -| **Rank** | `{"on": "$q", "rank": 4}` | `validateDimensionCount`, rank == 4 | -| **Shape (exact / symbolic)** | `{"on": "$q", "shape": [1, "$num_heads", "_", "$head_size"]}` (int = exact, name = bind/unify, `_` = any) | dim reads and cross-tensor dim relations | -| **Layout** | `{"on": "$q", "layout": [0, 1, 2, 3]}` (stride-order index array, [§8](#8-layout-and-stride-order-constraints)) | `validateSupportedLayout` | -| **Packing** | `{"on": "$q", "packed": true}` | `validatePackedTensors` | -| **Cross-tensor layout** | `{"consistent_layout": ["$x", "$y", "$scale"]}` | `validateConsistentLayouts` | -| **Attribute (value)** | `{"on": "$root", "attr": {"causal_mask": {"equals": false}, "dropout_probability": {"absent_or": {"equals": 0.0}}}}` | per-attr value gates | -| **Attribute (one_of / relation)** | `{"on": "$root", "attr": {"head_dim": {"one_of": [64, 128, 192]}}}` | `head_dim in {...}` | -| **Optional operand present/absent** | `{"on": "$attn_mask", "present": false}` | `attn_mask_tensor_uid()` absent gate | -| **Virtual tensor** | `{"on": "$conv_out", "virtual": true}` | fusion tensor-virtuality gates | -| **Use-count / exclusivity** | `{"on": "$conv_out", "use_count": 1}` or `{"on": "$bias_out", "no_external_consumer": true}` | fusion legality checks | -| **Cross-tensor / expression** | `{"expr": "$q1 == $k2"}`, `{"expr": "$q0 <= 128"}` ([§7](#7-the-shared-expression-language)) | arithmetic and comparison gates | -| **Device / arch** | `{"on": "device", "arch": {"one_of": ["gfx942", "gfx950"]}}` | `getDeviceString` arch gate | -| **Native predicate** | `{"kind": "native_predicate", "name": "hipdnn.strides_fit_u32", "args": ["$q", "$k", "$v", "$o"]}` ([§9](#9-native-predicate-escape-hatch)) | overflow guards, table lookups, derived-shape relations | +| **Opcode** | in the pattern node `op` (exact, `one_of`, `any`), not a constraint | node attribute-type gate | +| **Dtype (exact / set)** | `{"==": ["$q.dtype", "BFLOAT16"]}` / `{"in": ["$q.dtype", ["BFLOAT16", "FP8_E4M3"]]}` | `validateDataTypeIsSupported`, `validateFixedDataType` | +| **Dtype (relation)** | `{"==": ["$k.dtype", "$q.dtype"]}` | `validateConsistentDataTypes`, `q == k == v` | +| **Rank** | `{"==": ["$q.rank", 4]}` | `validateDimensionCount`, rank == 4 | +| **Shape (exact)** | `{"==": ["$q.dims[0]", 1]}` | dim reads | +| **Shape (symbolic / unify)** | name dims in `bindings` ([§5](#5-symbol-binding-and-the-auto-binding-formula)); relate with `{"==": ["$q.dims[1]", "$k.dims[1]"]}` | cross-tensor dim relations | +| **Layout** | `{"==": ["$q.stride_order", [0, 1, 2, 3]]}` ([§8](#8-layout-and-stride-order-constraints)) | `validateSupportedLayout` | +| **Packing** | `{"==": ["$q.is_packed", true]}` | `validatePackedTensors` | +| **Cross-tensor layout** | `{"==": ["$x.stride_order", "$y.stride_order"]}` (per pair) | `validateConsistentLayouts` | +| **Attribute (value)** | `{"==": ["$root.causal_mask", false]}`; absent-or `{"or": [{"!": "$root.dropout_probability.present"}, {"==": ["$root.dropout_probability", 0.0]}]}` | per-attr value gates | +| **Attribute (one_of)** | `{"in": ["$root.head_dim", [64, 128, 192]]}` | `head_dim in {...}` | +| **Optional operand present/absent** | `{"!": "$attn_mask.present"}` (absent) / `"$bias.present"` (present) | `attn_mask_tensor_uid()` absent gate | +| **Virtual tensor** | `{"==": ["$conv_out.is_virtual", true]}` | fusion tensor-virtuality gates | +| **Use-count / exclusivity** | `{"==": ["$conv_out.use_count", 1]}` / `{"==": ["$bias_out.external_use_count", 0]}` | fusion legality checks | +| **Cross-tensor / arithmetic** | `{"==": ["$q.dims[1]", "$k.dims[2]"]}`, `{"<=": ["$q.dims[0]", 128]}`, `{"==": [{"%": ["$nhead_q", "$nhead_k"]}, 0]}` | arithmetic and comparison gates | +| **Device / arch** | `{"in": ["$device.arch", ["gfx942", "gfx950"]]}` | `getDeviceString` arch gate | +| **Native predicate** | `{"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]}` ([§9](#9-native-predicate-escape-hatch)) | overflow guards, table lookups, derived-shape relations | The `device` constraint gates *applicability* at match time; the per-architecture `kpack` manifest ([RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)) gates *loadability* at @@ -313,88 +338,60 @@ install and load. Both must agree for a kernel to run. ## 7. The Shared Expression Language -The UMD needs boolean predicates over bound values (`$q1 == $k2`, `$q0 <= 128`, `$q0 one_of {64,128,256}`, -`$nhead_q % $nhead_k == 0`). The UDD needs integer-valued formulas over the same bound values for grid, -block, shared memory, and workspace (`ceil_div($seqlen_q, 16)`, -[RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). These are the same language -at two layers, and this RFC defines it once so the two subsystems cannot drift. - -**Layering.** - -- **Value core (shared with the UDD).** Literals (int, float, string/enum, array), symbol references, - tensor and attribute accessors, and arithmetic. The value core is exactly what the UDD RFC consumes; - that RFC references this section rather than redefining it. -- **Predicate layer (UMD-only).** Comparison, set membership, and, in a later iteration, boolean - composition ([§10](#10-composite-constraints-deferred-forward-compatible)) over value-core - expressions, producing a boolean. A UMD constraint's `expr` is a predicate-layer expression. - -**Canonical AST.** The stored form is the nested object form already used in RFC 0017 §6, so the two -subsystems share one parser, one validator, and one interpreter: - -```jsonc -// value core -{"op": "ceil_div", "args": [{"sym": "$seqlen_q"}, 16]} -{"op": "mod", "args": [{"dim": "$q", "axis": 1}, {"dim": "$k", "axis": 1}]} - -// predicate layer (UMD) -{"op": "eq", "args": [{"dim": "$q", "axis": 1}, {"dim": "$k", "axis": 2}]} // $q1 == $k2 -{"op": "one_of", "args": [{"dim": "$q", "axis": 0}, [64, 128, 256]]} // $q0 in {64,128,256} -``` - -**Authoring sugar.** A UMD `expr` may be written as a light infix string that compiles to the AST, so -the AICK-1698 examples are literal: - -``` -$q0 == 64 -$q1 == $k2 -$w1 == $x1 -$q0 <= 128 -$q0 one_of {64, 128, 256} -stride_order($q) == [0, 1, 2, 3] -$nhead_q % $nhead_k == 0 -``` - -In a UMD these are `expr` constraints ([§6](#6-constraint-vocabulary)); the infix string and the AST -form are interchangeable and compile to the same tree: +Every UMD constraint and every UDD dispatch formula is a **JsonLogic** expression. JsonLogic is a +small, well-specified JSON expression format — an operator object `{"op": [args...]}` whose arguments +are themselves expressions or literals — so descriptors stay pure data and one parser, validator, and +interpreter serve both subsystems. This RFC pins JsonLogic as the concrete form RFC 0017 §5/§6 left to +the follow-ups; the UDD follow-up adopts the same core. + +**The `$`-variable convention.** Stock JsonLogic reads a bound value with `{"var": "path"}`. This RFC +replaces that with a single rule: **any string that begins with `$` is a variable reference**, and its +remainder is a path into the bound symbol table ([§5](#5-symbol-binding-and-the-auto-binding-formula)) — +`"$q"`, `"$q.dims[3]"`, `"$q.rank"`, `"$q.dtype"`, `"$q.stride_order"`, `"$root.causal_mask"`, +`"$device.arch"`, `"$head_size"`. Every other JSON scalar is a literal: numbers (`128`), booleans +(`false`), and non-`$` strings (`"BFLOAT16"`, `"gfx942"`). There is no ambiguity, so no `{"var": ...}` +wrapper is used or accepted. The same token reads identically wherever it appears: inside a constraint +expression, a native-predicate argument, or a UDD formula. + +**Two uses, one language.** + +- **Constraints (UMD).** A boolean-valued expression. The comparison operators `==`, `!=`, `<`, `<=`, + `>`, `>=`, the membership operator `in`, and the boolean operators `and`, `or`, `!`, `!!`, `if` + cover the surveyed checks ([§2](#2-what-the-umd-replaces)). A constraint that does not evaluate to a + boolean is a compile error. +- **Dispatch formulas (UDD).** A value-valued expression for grid, block, shared memory, and workspace + ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). It uses the arithmetic + operators below and yields a number. + +**Operators.** Native JsonLogic: `== != < <= > >=`, `in`, `and or ! !! if`, `+ - * / % min max`. +Value-core extensions registered as JsonLogic operations for the arithmetic hipDNN needs: `ceil_div`, +`abs`, `pow`, `log2`, `rsqrt`. Adding an operator is additive +([§14](#14-serialization-and-versioning)). There are no constraint *kinds*: the whole `constraints` +field is one JsonLogic expression, and even the native-predicate escape hatch is a registered JsonLogic +operation ([§9](#9-native-predicate-escape-hatch)), not a distinct clause. ```jsonc -"constraints": [ - {"expr": "$q0 == 64"}, // dim equality - {"expr": "$q1 == $k2"}, // cross-tensor dim relation - {"expr": "$q0 <= 128"}, // range bound - {"expr": "$q0 one_of {64, 128, 256}"}, // set membership - {"expr": "$nhead_q % $nhead_k == 0"}, // divisibility - {"expr": "stride_order($q) == [0, 1, 2, 3]"}, // layout via stride order - - // the infix string is sugar; the equivalent AST form is accepted too - {"expr": {"op": "eq", "args": [{"dim": "$q", "axis": 1}, {"dim": "$k", "axis": 2}]}} // == "$q1 == $k2" -] -``` - -Grammar (EBNF, value core plus predicate layer): - -``` -predicate = comparison | membership -comparison = value ("==" | "!=" | "<" | "<=" | ">" | ">=") value -membership = value "one_of" "{" value ("," value)* "}" -value = term (("+" | "-" | "*" | "/" | "%") term)* -term = number | symbol | call | array | "(" value ")" -symbol = "$" ident ("." ident | "[" number "]")* // $q0, $q.dims[2], $root.causal_mask -call = ident "(" (value ("," value)*)? ")" // ceil_div($seqlen_q, 16), stride_order($q) -array = "[" (value ("," value)*)? "]" +// constraints (boolean): each line is a sub-expression of the single top-level constraints expression +{"==": ["$q.dims[0]", 64]} // dim equality +{"==": ["$q.dims[1]", "$k.dims[2]"]} // cross-tensor dim relation +{"<=": ["$q.dims[0]", 128]} // range bound +{"in": ["$q.dims[0]", [64, 128, 256]]} // set membership +{"==": [{"%": ["$nhead_q", "$nhead_k"]}, 0]} // divisibility +{"==": ["$q.stride_order", [0, 1, 2, 3]]} // layout via stride order +{"or": [{"!": "$attn_mask.present"}, + {"==": ["$attn_mask.dtype", "$q.dtype"]}]} // composition (§10) + +// dispatch formulas (value), reused by the UDD +{"ceil_div": ["$seqlen_q", 16]} +{"*": [{"rsqrt": ["$head_size"]}, 1.4426950408889634]} ``` -**Operators.** Value core: `+ - * / %`, `ceil_div`, `min`, `max`, `abs`, `pow`, `log2`, `rsqrt`. -Predicate layer: `== != < <= > >=`, `one_of`. Booleans combine only by the implicit top-level AND in -this iteration; explicit `and`/`or`/`not` are reserved for composite constraints -([§10](#10-composite-constraints-deferred-forward-compatible)). - **Evaluation is a safe, bounded interpreter.** It fails closed on an unknown symbol, an out-of-range -axis, a type error, or an invalid operation, and never executes arbitrary code. Integer arithmetic -uses checked-width integers and fails closed on overflow rather than wrapping -([§15](#15-security-and-hostile-input)). The interpreter is bounded in recursion depth and step count. -Because the language is dependency-free and hand-written (no third-party parser), it is small enough to -audit and to lower into the static matcher ([§12](#12-static-matcher-sketch)). +axis, a type error, a non-boolean constraint result, or an invalid operation, and never executes +arbitrary code. Integer arithmetic uses checked-width integers and fails closed on overflow rather than +wrapping ([§15](#15-security-and-hostile-input)). The interpreter is bounded in recursion depth and step +count. Because JsonLogic is tiny and the evaluator is hand-written (no third-party parser), it is small +enough to audit and to lower into the static matcher ([§12](#12-static-matcher-sketch)). --- @@ -407,40 +404,44 @@ fastest-varying. This is the shape a matcher can check directly against `strides `TensorDescriptor` already precomputes `strideOrder` (`ApplicabilityChecks.cpp:17`). ```jsonc -{"on": "$q", "layout": [0, 1, 2, 3]} // fully packed, natural order (BHSD for a rank-4 SDPA tensor) -{"on": "$x", "layout": [0, 3, 1, 2]} // NHWC over an NCHW logical dim order +{"==": ["$q.stride_order", [0, 1, 2, 3]]} // packed, natural order (BHSD, rank-4) +{"==": ["$x.stride_order", [0, 3, 1, 2]]} // NHWC over an NCHW logical dim order ``` - The array is a permutation of `0..rank-1`. Entry `k` names the logical dimension that occupies stride position `k`, so `[0,1,2,3]` is descending-stride packed and `[0,3,1,2]` places the channel dim last (NHWC). The `axis` used everywhere else (dims, strides, `args_signature`) indexes the logical dimension order, independent of this physical layout, consistent with RFC 0017 §6. -- **Named aliases** are provided for the common cases and expand to arrays at compile time: +- **Named aliases** are provided for the common cases and expand to the array literal at compile time, + so `{"==": ["$q.stride_order", "nhwc"]}` compiles to a comparison against `[0, 3, 1, 2]`: `"nchw" -> [0,1,2,3]`, `"nhwc" -> [0,3,1,2]`, `"ncdhw"`, `"ndhwc"`, `"bhsd" -> [0,1,2,3]`, - `"contiguous"` (identity permutation for the tensor's rank). Aliases keep authoring readable while - the array remains the single canonical form. The set of aliases matches the layouts - `validateSupportedLayout` accepts today (`ApplicabilityChecks.cpp:77`). -- **Cross-tensor consistency** uses `consistent_layout` ([§6](#6-constraint-vocabulary)), lowering - `validateConsistentLayouts`; layout-agnostic tensors (rank-1 scalars, pass-by-value) are skipped as - they are today. -- **Packing** is a separate `packed` constraint, since a supported stride order does not imply the - tensor is gap-free; it lowers `validatePackedTensors`. -- The stride-order array is also available to expressions as `stride_order($q)`, so - `stride_order(q) == [0,1,2,3]` from AICK-1698 is expressible directly. + `"contiguous"` (identity permutation for the tensor's rank). The array remains the single canonical + form. The alias set matches the layouts `validateSupportedLayout` accepts today + (`ApplicabilityChecks.cpp:77`). +- **Cross-tensor consistency** is a JsonLogic equality between stride orders, + `{"==": ["$x.stride_order", "$y.stride_order"]}` (one per pair, joined by the top-level `and`), lowering `validateConsistentLayouts`; + layout-agnostic tensors (rank-1 scalars, pass-by-value) are skipped as they are today. +- **Packing** is the separate derived fact `$q.is_packed` (`{"==": ["$q.is_packed", true]}`), since a + supported stride order does not imply the tensor is gap-free; it lowers `validatePackedTensors`. +- `$q.stride_order` is an ordinary bound value ([§5](#5-symbol-binding-and-the-auto-binding-formula)), + so the `stride_order(q) == [0,1,2,3]` gate from AICK-1698 is expressible directly. --- ## 9. Native-Predicate Escape Hatch -Some checks cannot be stated declaratively: they need real C++. A UMD constraint may name a **native -predicate** resolved from a provider-internal registry. +Some checks cannot be stated with the built-in operators: they need real C++. The UMD exposes them as a +**native predicate** — a JsonLogic operation resolved from a provider-internal registry and invoked by +its registered (namespaced) name as the operator key, so it nests inside the `constraints` expression +exactly like a built-in operator and negates with `!`: ```jsonc -{"kind": "native_predicate", "name": "hipdnn.strides_fit_u32", "args": ["$q", "$k", "$v", "$o"], "negated": false} +{"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} // a registered boolean operation +{"!": {"hipdnn.sdpa_mask_consistent": ["$root"]}} // negated inside the same tree ``` -The descriptor carries only a symbol name and a typed argument list drawn from bound variables, never -inline code. Predicates take explicit arguments, not the whole graph, so they stay auditable and +The descriptor carries only the operation name and a typed argument list drawn from bound variables, +never inline code. Predicates take explicit arguments, not the whole graph, so they stay auditable and reusable. Both the build-time and drop-in paths resolve predicates from the same registry: a file that names only shipped predicates loads identically either way, and a file naming a predicate the running provider does not ship fails to resolve on the drop-in path. The registry a provider ships is therefore @@ -477,26 +478,23 @@ provider. --- -## 10. Composite Constraints (Deferred, Forward-Compatible) +## 10. Composite Constraints -All constraints AND together, which covers every hand-written check surveyed -([§2](#2-what-the-umd-replaces)). AICK-1698 anticipates a later need for a single constraint that is a -boolean combination, for example `(A AND B) OR C`. This iteration does not evaluate boolean composition -beyond the top-level AND, but reserves a forward-compatible shape so adding it is additive and does not -change existing descriptors: +The `constraints` field is a single JsonLogic expression, and JsonLogic supplies boolean composition +natively, so `(A AND B) OR C` is stated directly in the one tree with no extra mechanism: ```jsonc -// reserved shape, not evaluated in v1 -{"any_of": [ // OR - {"all_of": [{"expr": "q0 == 64"}, {"expr": "q1 == k2"}]}, // (A AND B) - {"expr": "q0 == 128"} // OR C -]} +"constraints": + {"or": [ + {"and": [{"==": ["$q.dims[0]", 64]}, {"==": ["$q.dims[1]", "$k.dims[2]"]}]}, // (A AND B) + {"==": ["$q.dims[0]", 128]} // OR C + ]} ``` -A v1 matcher rejects a UMD that uses `any_of` / `all_of` with a clear "composite constraints not -supported by this runtime version" error ([§14](#14-serialization-and-versioning)), rather than -silently ignoring it. The predicate-layer grammar ([§7](#7-the-shared-expression-language)) already -reserves `and` / `or` / `not` for the same reason. General N-ary commutative matching and unbounded +`and`, `or`, `!`, and `if` compose the same comparison, membership, and native-predicate +([§9](#9-native-predicate-escape-hatch)) tests used everywhere else, so the deferral and reserved-shape +workaround of earlier drafts is gone: composition is day-one. A UMD whose tests all conjoin simply makes +the top-level expression an `and`, the common case. General N-ary commutative matching and unbounded chains remain deferred to the JIT follow-up, as in RFC 0017 §5. --- @@ -505,29 +503,21 @@ chains remain deferred to the JIT follow-up, as in RFC 0017 §5. A UMD is authored as text and **compiled once** into an in-memory matcher structure at provider load (or, for the drop-in path, when the bundle is scanned). Compilation resolves op-schema roles, expands -layout aliases, parses expressions to AST, validates that every referenced symbol is bound, and orders -the constraints for early-out. The compiled form, not the text, is what runs against live graphs. +layout aliases, parses the constraints expression to an AST, and validates that every referenced symbol +is bound. The compiled form, not the text, is what runs against live graphs. **Root-opcode indexing.** The compiled matchers are indexed by the root node's opcode, so match cost does not grow linearly with the number of descriptors: a graph whose root op is `sdpa_fwd` only -consults UMDs rooted at `sdpa_fwd`. This is the index RFC 0017 §14 calls for. Per-candidate cost -(constraint, predicate, and expression evaluation) is separate and is bounded by the ordering below, -not by the index. - -**Prioritized, early-out constraint ordering (AICK-1698).** Within a candidate, constraints run -cheapest-and-most-selective first so a non-match is rejected as early as possible. The compiler assigns -a static cost/selectivity order, roughly: +consults UMDs rooted at `sdpa_fwd`. This is the index RFC 0017 §14 calls for. Per-candidate cost (one +pass over the constraints expression) is separate from the index and bounded by short-circuit +evaluation. -1. Node count and opcode (already narrowed by the index, but confirmed). -2. Attribute equality and `one_of` (scalar reads). -3. Rank and dtype (single tensor-field reads). -4. Shape, symbolic-dim unification, and layout/stride-order (vector reads). -5. Cross-tensor relations and expressions. -6. Native predicates (opaque, potentially the most expensive), last. - -Authors do not order constraints; the compiler does, from the constraint kind. The relative order is a -matcher-internal detail, not part of the descriptor contract, so it can be tuned without a schema -change. +**Short-circuit evaluation.** The constraints expression evaluates with normal JsonLogic +short-circuiting: an `and` stops at its first false sub-expression, an `or` at its first true one, so a +non-match is rejected as early as the author's structure allows. The author orders the tree; the +compiler may hoist a cheap, highly selective sub-expression (a scalar attribute or dtype read) ahead of +an expensive one (a native predicate) as an internal optimization, but this never changes the result, +only when a decision is reached. **Per-plan caching (AICK-1698).** Matching runs at plan-build time. The result (the chosen UMD, the bound symbol table, and the arbitration outcome) is cached on the compiled plan and reused for @@ -536,7 +526,7 @@ workspace queries and execution, so the same graph is not re-matched across the (`AsmSdpaEngine.cpp:66,87`). The compiled matcher itself is built once and shared across plans; only the per-graph binding result is per-plan. -**Device gating short-circuits.** The `device.arch` constraint is evaluated once per graph, before +**Device gating short-circuits.** The `$device.arch` sub-expression is evaluated once per graph, before per-candidate work, since arch is constant for the stream. ![Compile-once pipeline: text UMD to constraint IR to a root-opcode-indexed matcher, with a per-plan bind cache](../images/umd_matcher_pipeline.svg) @@ -599,8 +589,8 @@ hazard. ## 14. Serialization and Versioning -- **Authoring form.** Human-readable, diffable JSONC (the examples here), including the infix `expr` - sugar of [§7](#7-the-shared-expression-language). +- **Authoring form.** Human-readable, diffable JSONC (the examples here): the JsonLogic constraint + expressions of [§7](#7-the-shared-expression-language) with the `$`-variable convention. - **Compiled form.** The compact binary the matcher runs ([§11](#11-the-matcher-compilation-indexing-and-caching)), whose concrete bytes are defined with the KDP/packaging follow-up ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)); this RFC defines the schema @@ -609,10 +599,9 @@ hazard. mandatory `name` for diagnostics. A UMD whose schema version is newer than the runtime understands is refused with a clear error, never silently reinterpreted, matching [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats). -- **Additive evolution.** New constraint kinds, operators, layout aliases, and the composite forms of - [§10](#10-composite-constraints-deferred-forward-compatible) are additive within `v1` where they do - not change the meaning of an existing descriptor; anything that would reinterpret existing fields - bumps the version. +- **Additive evolution.** New JsonLogic operators, native predicates, and layout aliases are additive + within `v1` where they do not change the meaning of an existing descriptor; anything that would + reinterpret existing fields bumps the version. - **Identity.** UMD ids are unique within the UMD kind; a match id and an engine id may collide as strings because references are typed by field (a UKD's `match` versus `engine`). A colliding drop-in id is logged and ignored rather than taking down the provider ([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). @@ -647,8 +636,8 @@ Because matching is data-driven, it is inspectable, and the tooling is a first-c ([RFC 0017 §9](0017_UniversalKernelDescriptor.md#9-observability-and-diagnostics)). For the UMD the provider surfaces: -- **A why-not trace.** For a graph and a candidate UMD, the first constraint that failed and why (the - concrete values compared), so an author can see exactly which gate declined. +- **A why-not trace.** For a graph and a candidate UMD, the sub-expression of `constraints` that + evaluated false and why (the concrete values compared), so an author can see exactly which test declined. - **A binding view.** For a successful match, the full bound symbol table (tensors, dims, strides, attributes) as the UDD will see it. - **An arbitration trace.** Which UKDs matched, how the UHD scored them, and where a tie fell to @@ -677,9 +666,9 @@ UMD-specific coverage: ([§19](#19-worked-example-sdpa-forward)) is the first target. - **Static/runtime parity.** The parity oracle of [§12](#12-static-matcher-sketch): the same UMD and graph must decide identically on the interpreted and any lowered matcher. -- **Expression-language conformance.** A table-driven suite over the value core and predicate layer, - including the AICK-1698 examples and the fail-closed cases (overflow, unknown symbol, bad axis), - shared with the UDD RFC's expression tests since the language is shared. +- **Expression-language conformance.** A table-driven suite over the JsonLogic operator set (boolean + and value forms), including the AICK-1698 examples and the fail-closed cases (overflow, unknown + symbol, bad axis), shared with the UDD RFC's expression tests since the language is shared. - **Fuzzing.** The corpus and fuzzer of [§15](#15-security-and-hostile-input). - **Match overhead.** Plan-time match cost is measured against the hand-written baseline as benchmarking matures (`tools/dnn-benchmarking`, [RFC 0013](0013_Autotune.md)); the compiled matcher, @@ -707,7 +696,7 @@ lowering. The kernel-table lookups dissolve into the KDP as described in ## 19. Worked Example: SDPA Forward The SDPA-forward check collapses into one UMD. Compared to the hand-written builder -(`SdpaFwdPlanBuilder.cpp:167-296`), each C++ gate becomes a field, and only the two genuinely +(`SdpaFwdPlanBuilder.cpp:167-296`), each C++ gate becomes a sub-expression, and only the two genuinely non-declarative gates (uint32 stride fit, mask self-consistency) remain as native predicates. Note `$head_size` is bound from `$q`'s dim, not read as an attribute ([§3](#3-the-matchers-input-hipdnns-graph-model)). @@ -721,38 +710,39 @@ non-declarative gates (uint32 stride fit, mask self-consistency) remain as nativ {"kind": "op", "id": "$root", "op": "sdpa_fwd", "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} ], - "constraints": [ - {"on": "device", "arch": {"one_of": ["gfx942", "gfx950"]}}, // getDeviceString gate - - // dtype: $q == $k == $v (relation), $o supported (set) - {"on": "$q", "dtype": {"one_of": ["BFLOAT16", "FP8_E4M3"]}}, - {"on": "$k", "dtype": {"same_as": "$q"}}, - {"on": "$v", "dtype": {"same_as": "$q"}}, - - // rank 4 and dim binding; head_size and head count unify across q/k/v + "bindings": [ + // rank 4 and dim naming; $head_size and $kv_heads unify across q/k/v (implied ==) {"on": "$q", "shape": ["$batch", "$num_heads", "$seqlen_q", "$head_size"]}, {"on": "$k", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]}, - {"on": "$v", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]}, // $k1 == $v1 via $kv_heads - {"on": "$o", "rank": 4}, - {"expr": "$head_size == 128"}, + {"on": "$v", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]} + ], + "constraints": {"and": [ + {"in": ["$device.arch", ["gfx942", "gfx950"]]}, // getDeviceString gate + + // dtype: $q == $k == $v (relation), $q in the supported set + {"in": ["$q.dtype", ["BFLOAT16", "FP8_E4M3"]]}, + {"==": ["$k.dtype", "$q.dtype"]}, + {"==": ["$v.dtype", "$q.dtype"]}, + + // rank 4 on the un-named output, and the head-dim gate + {"==": ["$o.rank", 4]}, + {"==": ["$head_size", 128]}, // attribute gates: no dropout, no alibi/padding mask, no stats output - {"on": "$root", "attr": { - "dropout_probability": {"absent_or": {"equals": 0.0}}, - "alibi_mask": {"equals": false}, - "padding_mask": {"equals": false}, - "generate_stats": {"absent_or": {"equals": false}} - }}, - - // optional operands that this kernel does not support must be absent - {"on": "$attn_mask", "present": false}, - {"on": "$page_table_k","present": false}, - {"on": "$page_table_v","present": false}, - - // escape hatches: the two checks that need real C++ - {"kind": "native_predicate", "name": "hipdnn.sdpa_mask_consistent", "args": ["$root"]}, - {"kind": "native_predicate", "name": "hipdnn.strides_fit_u32", "args": ["$q", "$k", "$v", "$o"]} - ] + {"or": [{"!": "$root.dropout_probability.present"}, {"==": ["$root.dropout_probability", 0.0]}]}, + {"==": ["$root.alibi_mask", false]}, + {"==": ["$root.padding_mask", false]}, + {"or": [{"!": "$root.generate_stats.present"}, {"==": ["$root.generate_stats", false]}]}, + + // optional operands this kernel does not support must be absent + {"!": "$attn_mask.present"}, + {"!": "$page_table_k.present"}, + {"!": "$page_table_v.present"}, + + // escape hatches: the two checks that need real C++, as registered JsonLogic operations + {"hipdnn.sdpa_mask_consistent": ["$root"]}, + {"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} + ]} } ``` @@ -766,9 +756,9 @@ Mapping to the hand-written code: | dropout / alibi / padding / stats gates (:205-224) | `attr` constraints | | `attn_mask` / `page_table_*` absent (:209-215) | optional-operand `present: false` | | rank == 4 (:231-247) | `shape` (rank 4) | -| `q == k == v` dtype (:244) | `dtype.same_as` | +| `q == k == v` dtype (:244) | `{"==": ["$k.dtype", "$q.dtype"]}` | | `k.dims[1] == v.dims[1]` head count (:251) | `$kv_heads` shape unification | -| head dim == 128 | `expr: $head_size == 128` | +| head dim == 128 | `{"==": ["$head_size", 128]}` | | `getMaskType` throw-on-contradiction (:276) | `hipdnn.sdpa_mask_consistent` predicate | | `wouldFwdByteStridesFitUint32` (:294) | `hipdnn.strides_fit_u32` predicate | | `getKernelNameKey` table lookup (:287) | dissolves into the KDP's Launch ([§9](#9-native-predicate-escape-hatch)) | @@ -785,15 +775,16 @@ every auto-bound dim/stride) are exactly what the paired UDD's grid and argument schema ([§3](#3-the-matchers-input-hipdnns-graph-model)). If it drifts from the graph definitions, bindings are wrong. Mitigation: generate it from the same schema, and fail closed on an unknown op or role rather than binding a wrong field. -- **Expression language sharing.** The value core is shared with the UDD ([§7](#7-the-shared-expression-language)). +- **Expression language sharing.** JsonLogic is shared with the UDD ([§7](#7-the-shared-expression-language)). A change made for one subsystem can affect the other. Mitigation: one parser/validator/interpreter, - a shared conformance suite, and a clear layer split (UDD uses the value core only). + a shared conformance suite, and a clear split (constraints are boolean JsonLogic, dispatch formulas + are value JsonLogic). - **Predicate registry as contract.** Native predicates are part of the published provider contract ([§9](#9-native-predicate-escape-hatch)); a drop-in naming an unshipped predicate fails to resolve. Mitigation: version and document the shipped predicate set; fail closed with a clear diagnostic. -- **Match overhead.** Per-candidate constraint and predicate evaluation is unbounded by the root-opcode - index ([§11](#11-the-matcher-compilation-indexing-and-caching)). Mitigation: early-out ordering, - per-plan caching, and the overhead test of [§17](#17-testing-and-performance). +- **Match overhead.** Per-candidate evaluation of the constraints expression is unbounded by the + root-opcode index ([§11](#11-the-matcher-compilation-indexing-and-caching)). Mitigation: short-circuit + evaluation, per-plan caching, and the overhead test of [§17](#17-testing-and-performance). - **Static-matcher parity.** A lowered matcher that diverges from the interpreter is a silent correctness bug ([§12](#12-static-matcher-sketch)). Mitigation: the interpreter is the oracle and the parity test gates any lowering. @@ -802,9 +793,9 @@ every auto-bound dim/stride) are exactly what the paired UDD's grid and argument ## 21. Open Questions -1. **Expression language home.** This RFC defines the shared value core; the UDD RFC references it. - Confirm this split with the UDD RFC rather than each defining its own, and decide where the shared - conformance suite lives. +1. **Expression language home.** This RFC pins JsonLogic as the shared expression language and defines + it here because the UMD needs it first; the UDD follow-up references this section. Confirm the split + rather than each subsystem defining its own, and decide where the shared conformance suite lives. 2. **GQA divisibility.** Express `nhead_q % nhead_k == 0` with the `%` operator ([§7](#7-the-shared-expression-language)) or centralize it as a native predicate for uniform fail-closed zero-guarding ([§9](#9-native-predicate-escape-hatch))? @@ -813,8 +804,6 @@ every auto-bound dim/stride) are exactly what the paired UDD's grid and argument 4. **Feature-vector overlap.** The bound symbol table overlaps the feature vector a UHD consumes ([RFC 0017 §15 Q4](0017_UniversalKernelDescriptor.md#15-open-questions)); should the UMD's bindings be the canonical feature source for kernel selection? -5. **Composite timing.** When composite constraints ([§10](#10-composite-constraints-deferred-forward-compatible)) - are needed, do they land before or after the first engines are migrated? --- @@ -840,8 +829,8 @@ The design borrows established ideas; none is a dependency. These informed the U across kernels by ID. - **Structural pattern:** the op nodes and the named operand/result edges of a UMD; edges are implicit through shared pattern variables ([§4](#4-structural-pattern)). -- **Constraint:** a condition attached to a bound tensor, a node, or `device`; constraints implicitly - AND ([§6](#6-constraint-vocabulary)). +- **Constraint expression:** the single JsonLogic boolean a UMD evaluates over its bound symbol table, + typically an `and` of the individual tests ([§6](#6-constraint-vocabulary)). - **Symbol lifecycle:** a name is declared in the UMD, bound when the graph matches, and used by the UDD ([§5](#5-symbol-binding-and-the-auto-binding-formula)). - **Auto-binding formula:** the standard scheme that binds every operand/result tensor, its dims and @@ -850,16 +839,16 @@ The design borrows established ideas; none is a dependency. These informed the U - **Op-schema registry:** the generated table mapping each op type to its operand/result UID fields and attributes, letting the matcher reconstruct edges and auto-bind ([§3](#3-the-matchers-input-hipdnns-graph-model)). -- **Value core / predicate layer:** the two layers of the shared expression language; the value core - (arithmetic, shared with the UDD) yields numbers, the predicate layer (comparison, membership) yields - booleans ([§7](#7-the-shared-expression-language)). +- **JsonLogic:** the shared expression language; boolean-valued expressions are UMD constraints and + value-valued expressions are UDD dispatch formulas, both over one `$`-variable symbol table with one + evaluator ([§7](#7-the-shared-expression-language)). - **Stride-order layout:** layout represented as an array of dimension indexes giving stride order, since tensors carry no layout enum ([§8](#8-layout-and-stride-order-constraints)). -- **Native predicate:** the escape hatch; a registry-resolved check a UMD names for logic it cannot - state declaratively, carried as a symbol name and typed arguments, never inline code - ([§9](#9-native-predicate-escape-hatch)). -- **Composite constraint:** a single constraint that is a boolean combination of sub-constraints; - schema reserved, evaluation deferred ([§10](#10-composite-constraints-deferred-forward-compatible)). +- **Native predicate:** the escape hatch; a registry-resolved JsonLogic operation a UMD invokes by name + for logic it cannot state with built-in operators, carried as an operation name and typed arguments, + never inline code ([§9](#9-native-predicate-escape-hatch)). +- **Composite constraint:** any boolean combination of tests within the one `constraints` expression, + written directly with JsonLogic `and` / `or` / `!` / `if` ([§10](#10-composite-constraints)). - **Arbitration:** the deterministic resolution when several UKDs match: UHD score, then `priority`, then stable `id` ([§13](#13-arbitration)). - **Root-opcode index:** the index of compiled matchers by root opcode that keeps match cost sublinear From 7f481880798bf64593c5b44a48958e2a942327e9 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 23 Jul 2026 12:37:02 -0600 Subject: [PATCH 33/60] First implementation of JsonLogic (cherry picked from commit f56acdcad4d3e3af8923be34fd68b925240596c2) (cherry picked from commit 7f3242a83975e6f1d14a12c49cff63e400f13da5) --- .../hip_kernel_provider_common/JsonLogic.hpp | 868 ++++++++++++++++++ .../hip_kernel_provider_common/JsonLogic.md | 117 +++ .../src/tests/CMakeLists.txt | 9 +- .../src/tests/core/TestJsonLogic.cpp | 270 ++++++ 4 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md create mode 100644 dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp new file mode 100644 index 000000000000..2872975d5873 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp @@ -0,0 +1,868 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +// JsonLogic.hpp - single-file JsonLogic expression compiler. +// +// All names below live in namespace hip_kernel_provider_common::jsonlogic; +// these examples assume `namespace jl = hip_kernel_provider_common::jsonlogic;`. +// +// A JsonLogic expression (an nlohmann::json value) is *compiled once* into a +// reusable jl::Expression, then evaluated many times against different +// data sources: +// +// struct MyData { // your data source +// jl::Value getData(const std::string& path) const; +// }; +// +// auto expr = jl::compile(rule); // parse + build tree once +// jl::Value r1 = expr(dataA); // evaluate, no re-parse +// jl::Value r2 = expr(dataB); // reuse for other data +// +// The runtime value type (jl::Value) is a small standalone variant that does +// NOT depend on nlohmann/json; nlohmann is used only to express the rule being +// compiled. The data source is any C++ type exposing +// Value getData(const std::string&) +// which resolves a variable path to a Value ("" means the whole document; a +// null return means "not found", triggering a var default if one is given). +// +// Inline variables (extension over stock JsonLogic) +// ------------------------------------------------- +// A string prefixed with a sigil (default '$') is a variable reference, so a +// variable can appear anywhere a literal can: +// {"+": ["$x", "$y"]} == {"+": [{"var": "x"}, {"var": "y"}]} +// "$a.b" nested path "$" whole document "$$x" literal "$x" +// Strings without the sigil remain literals, so stock rules keep working. +// +// Scope: core operator set (data access, logic, comparison, arithmetic), the +// membership operator `in`, the `value_or_default` fallback, and the value-core +// math extensions the hipDNN UMD needs (ceil_div, abs, pow, log2, rsqrt). +// Collection/string operators (map/reduce/filter/cat/substr/...) are not +// included. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace hip_kernel_provider_common::jsonlogic +{ +/// Thrown when a rule cannot be compiled (unknown operator, bad arity, ...). +class JsonLogicCompileError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +// =========================================================================== +// Value - standalone runtime value (json-like, no nlohmann dependency) +// =========================================================================== +class Value +{ +public: + using Array = std::vector; + + Value() + : v_(nullptr) + { + } + Value(std::nullptr_t) + : v_(nullptr) + { + } + Value(bool b) + : v_(b) + { + } + Value(int i) + : v_(static_cast(i)) + { + } + Value(std::int64_t i) + : v_(i) + { + } + Value(double d) + : v_(d) + { + } + Value(const char* s) + : v_(std::string(s)) + { + } + Value(std::string s) + : v_(std::move(s)) + { + } + Value(Array a) + : v_(std::move(a)) + { + } + + /// Build a numeric value, storing an integer when the double is exactly + /// integral and representable, so integer inputs yield integer output. + static Value number(double d) + { + if(std::isfinite(d)) + { + const double t = std::trunc(d); + if(t == d && d >= -9.007199254740992e15 && d <= 9.007199254740992e15) + return Value(static_cast(t)); + } + return Value(d); + } + + bool is_null() const + { + return std::holds_alternative(v_); + } + bool is_bool() const + { + return std::holds_alternative(v_); + } + bool is_int() const + { + return std::holds_alternative(v_); + } + bool is_double() const + { + return std::holds_alternative(v_); + } + bool is_number() const + { + return is_int() || is_double(); + } + bool is_string() const + { + return std::holds_alternative(v_); + } + bool is_array() const + { + return std::holds_alternative(v_); + } + + bool as_bool() const + { + return std::get(v_); + } + std::int64_t as_int() const + { + return std::get(v_); + } + double as_double() const + { + return std::get(v_); + } + const std::string& as_string() const + { + return std::get(v_); + } + const Array& as_array() const + { + return std::get(v_); + } + + /// JsonLogic truthiness: false, 0, "", null and the empty array are falsy. + bool truthy() const + { + switch(v_.index()) + { + case 1: + return std::get(v_); + case 2: + return std::get(v_) != 0; + case 3: + return std::get(v_) != 0.0; + case 4: + return !std::get(v_).empty(); + case 5: + return !std::get(v_).empty(); + default: + return false; // null + } + } + + /// JS Number() coercion. Non-numeric strings and multi-element arrays yield + /// NaN; empty string / empty array / null yield 0. + double to_number() const + { + switch(v_.index()) + { + case 1: + return std::get(v_) ? 1.0 : 0.0; + case 2: + return static_cast(std::get(v_)); + case 3: + return std::get(v_); + case 4: + return string_to_number(std::get(v_)); + case 5: + { + const auto& a = std::get(v_); + if(a.empty()) + return 0.0; + if(a.size() == 1) + return a.front().to_number(); + return std::nan(""); + } + default: + return 0.0; // null + } + } + + /// Structural equality (== / !=). Integers and doubles of equal value + /// compare equal; differing kinds do not. + bool operator==(const Value& o) const + { + if(is_number() && o.is_number()) + return to_number() == o.to_number(); + if(v_.index() != o.v_.index()) + return false; + switch(v_.index()) + { + case 0: + return true; // null == null + case 1: + return std::get(v_) == std::get(o.v_); + case 4: + return std::get(v_) == std::get(o.v_); + case 5: + return std::get(v_) == std::get(o.v_); + default: + return false; + } + } + bool operator!=(const Value& o) const + { + return !(*this == o); + } + + /// Three-way compare for ordering. Returns -1/0/1, or 2 (incomparable, NaN) + /// which makes every ordering test false. Two strings compare lexically. + static int compare(const Value& a, const Value& b) + { + if(a.is_string() && b.is_string()) + { + const auto& x = a.as_string(); + const auto& y = b.as_string(); + return x < y ? -1 : (x > y ? 1 : 0); + } + const double x = a.to_number(); + const double y = b.to_number(); + if(std::isnan(x) || std::isnan(y)) + return 2; + return x < y ? -1 : (x > y ? 1 : 0); + } + + /// Human-readable rendering, mainly for diagnostics and tests. + std::string dump() const + { + switch(v_.index()) + { + case 1: + return std::get(v_) ? "true" : "false"; + case 2: + return std::to_string(std::get(v_)); + case 3: + { + std::string s = std::to_string(std::get(v_)); + return s; + } + case 4: + return "\"" + std::get(v_) + "\""; + case 5: + { + std::string s = "["; + const auto& a = std::get(v_); + for(std::size_t i = 0; i < a.size(); ++i) + s += (i ? "," : "") + a[i].dump(); + return s + "]"; + } + default: + return "null"; + } + } + +private: + static double string_to_number(const std::string& s) + { + std::size_t b = 0, e = s.size(); + while(b < e && std::isspace(static_cast(s[b]))) + ++b; + while(e > b && std::isspace(static_cast(s[e - 1]))) + --e; + if(b == e) + return 0.0; // JS Number("") == 0 + const std::string t = s.substr(b, e - b); + const char* first = t.c_str(); + char* last = nullptr; + const double d = std::strtod(first, &last); + if(last != first + t.size()) + return std::nan(""); // trailing garbage -> NaN + return d; + } + + // null, bool, int64, double, string, array - indices used by the switches. + std::variant v_; +}; + +namespace detail +{ +// ---- data-source capability detection ------------------------------------ +template +struct has_getData : std::false_type +{ +}; +template +struct has_getData< + T, + std::void_t().getData(std::declval()))>> + : std::true_type +{ +}; + +/// Fetch a variable path from the data source. +template +Value fetch(const DataT& d, const std::string& path) +{ + static_assert(has_getData::value, + "Data source must provide Value getData(std::string)."); + return d.getData(path); +} + +// ---- compiled node tree --------------------------------------------------- +enum class Op +{ + Add, + Sub, + Mul, + Div, + Mod, + Min, + Max, + Lt, + Le, + Gt, + Ge, + Eq, + Neq, + Not, + NotNot, + If, + And, + Or, + In, + CeilDiv, + Abs, + Pow, + Log2, + Rsqrt, + ValueOrDefault +}; + +template +struct Node +{ + virtual ~Node() = default; + virtual Value eval(const DataT&) const = 0; +}; + +template +using NodePtr = std::unique_ptr>; + +template +struct LiteralNode final : Node +{ + Value value; + explicit LiteralNode(Value v) + : value(std::move(v)) + { + } + Value eval(const DataT&) const override + { + return value; + } +}; + +template +struct ArrayNode final : Node +{ + std::vector> items; + Value eval(const DataT& d) const override + { + Value::Array a; + a.reserve(items.size()); + for(const auto& it : items) + a.push_back(it->eval(d)); + return Value(std::move(a)); + } +}; + +template +struct VarNode final : Node +{ + bool has_static = false; + std::string path; // used when has_static + NodePtr path_expr; // used when !has_static (computed key) + NodePtr default_expr; // optional fallback when fetch returns null + + Value eval(const DataT& d) const override + { + if(has_static) + { + Value r = fetch(d, path); + if(r.is_null() && default_expr) + return default_expr->eval(d); + return r; + } + const Value p = path_expr->eval(d); + std::string key; + if(p.is_string()) + key = p.as_string(); + else if(p.is_int()) + key = std::to_string(p.as_int()); + else if(p.is_number()) + key = std::to_string(static_cast(p.to_number())); + Value r = fetch(d, key); + if(r.is_null() && default_expr) + return default_expr->eval(d); + return r; + } +}; + +template +struct OpNode final : Node +{ + Op op; + std::vector> args; + + Value eval(const DataT& d) const override + { + switch(op) + { + case Op::Add: + { + double a = 0.0; + for(const auto& c : args) + a += c->eval(d).to_number(); + return Value::number(a); + } + case Op::Mul: + { + double a = 1.0; + for(const auto& c : args) + a *= c->eval(d).to_number(); + return Value::number(a); + } + case Op::Sub: + { + if(args.size() == 1) + return Value::number(-args[0]->eval(d).to_number()); + return Value::number(args[0]->eval(d).to_number() - args[1]->eval(d).to_number()); + } + case Op::Div: + return Value::number(args[0]->eval(d).to_number() / args[1]->eval(d).to_number()); + case Op::Mod: + return Value::number( + std::fmod(args[0]->eval(d).to_number(), args[1]->eval(d).to_number())); + case Op::Min: + { + double best = std::nan(""); + for(const auto& c : args) + { + const double n = c->eval(d).to_number(); + if(std::isnan(best) || n < best) + best = n; + } + return Value::number(best); + } + case Op::Max: + { + double best = std::nan(""); + for(const auto& c : args) + { + const double n = c->eval(d).to_number(); + if(std::isnan(best) || n > best) + best = n; + } + return Value::number(best); + } + case Op::Lt: + { + const Value a = args[0]->eval(d); + const Value b = args[1]->eval(d); + if(args.size() >= 3) + return Value(Value::compare(a, b) == -1 + && Value::compare(b, args[2]->eval(d)) == -1); + return Value(Value::compare(a, b) == -1); + } + case Op::Le: + { + const Value a = args[0]->eval(d); + const Value b = args[1]->eval(d); + const auto le = [](const Value& x, const Value& y) { + const int c = Value::compare(x, y); + return c == -1 || c == 0; + }; + if(args.size() >= 3) + return Value(le(a, b) && le(b, args[2]->eval(d))); + return Value(le(a, b)); + } + case Op::Gt: + return Value(Value::compare(args[0]->eval(d), args[1]->eval(d)) == 1); + case Op::Ge: + { + const int c = Value::compare(args[0]->eval(d), args[1]->eval(d)); + return Value(c == 1 || c == 0); + } + case Op::Eq: + return Value(args[0]->eval(d) == args[1]->eval(d)); + case Op::Neq: + return Value(args[0]->eval(d) != args[1]->eval(d)); + case Op::Not: + return Value(!args[0]->eval(d).truthy()); + case Op::NotNot: + return Value(args[0]->eval(d).truthy()); + case Op::If: + { + std::size_t i = 0; + for(; i + 1 < args.size(); i += 2) + if(args[i]->eval(d).truthy()) + return args[i + 1]->eval(d); + return i < args.size() ? args[i]->eval(d) : Value(); + } + case Op::And: + { + Value cur(true); + for(const auto& c : args) + { + cur = c->eval(d); + if(!cur.truthy()) + return cur; // first falsy + } + return cur; // last value + } + case Op::Or: + { + Value cur; + for(const auto& c : args) + { + cur = c->eval(d); + if(cur.truthy()) + return cur; // first truthy + } + return cur; // last value + } + case Op::In: + { + const Value needle = args[0]->eval(d); + const Value hay = args[1]->eval(d); + if(hay.is_array()) + { + for(const auto& e : hay.as_array()) + if(e == needle) + return Value(true); + return Value(false); + } + if(hay.is_string()) + { + const std::string n = needle.is_string() ? needle.as_string() : needle.dump(); + return Value(hay.as_string().find(n) != std::string::npos); + } + return Value(false); + } + case Op::CeilDiv: + { + const double num = args[0]->eval(d).to_number(); + const double den = args[1]->eval(d).to_number(); + return Value::number(std::ceil(num / den)); + } + case Op::Abs: + return Value::number(std::fabs(args[0]->eval(d).to_number())); + case Op::Pow: + return Value::number( + std::pow(args[0]->eval(d).to_number(), args[1]->eval(d).to_number())); + case Op::Log2: + return Value::number(std::log2(args[0]->eval(d).to_number())); + case Op::Rsqrt: + return Value::number(1.0 / std::sqrt(args[0]->eval(d).to_number())); + case Op::ValueOrDefault: + { + // First arg is a variable reference; a null result means the path + // did not resolve in the data source, so fall back to the default. + Value v = args[0]->eval(d); + return v.is_null() ? args[1]->eval(d) : v; + } + } + return Value(); + } +}; + +inline const Op* lookup_op(const std::string& key) +{ + static const std::pair table[] = {{"+", Op::Add}, + {"-", Op::Sub}, + {"*", Op::Mul}, + {"/", Op::Div}, + {"%", Op::Mod}, + {"min", Op::Min}, + {"max", Op::Max}, + {"<", Op::Lt}, + {"<=", Op::Le}, + {">", Op::Gt}, + {">=", Op::Ge}, + {"==", Op::Eq}, + {"!=", Op::Neq}, + {"!", Op::Not}, + {"!!", Op::NotNot}, + {"if", Op::If}, + {"?:", Op::If}, + {"and", Op::And}, + {"or", Op::Or}, + {"in", Op::In}, + {"ceil_div", Op::CeilDiv}, + {"abs", Op::Abs}, + {"pow", Op::Pow}, + {"log2", Op::Log2}, + {"rsqrt", Op::Rsqrt}, + {"value_or_default", Op::ValueOrDefault}}; + for(const auto& e : table) + if(key == e.first) + return &e.second; + return nullptr; +} + +inline void check_arity(Op op, std::size_t n, const std::string& key) +{ + const auto require = [&](bool ok) { + if(!ok) + throw JsonLogicCompileError("operator '" + key + + "' got wrong argument count: " + std::to_string(n)); + }; + switch(op) + { + case Op::Add: + case Op::Mul: + break; // any arity + case Op::Sub: + require(n == 1 || n == 2); + break; + case Op::Div: + case Op::Mod: + case Op::Eq: + case Op::Neq: + case Op::Gt: + case Op::Ge: + require(n == 2); + break; + case Op::Lt: + case Op::Le: + require(n == 2 || n == 3); + break; + case Op::Min: + case Op::Max: + case Op::And: + case Op::Or: + require(n >= 1); + break; + case Op::Not: + case Op::NotNot: + require(n == 1); + break; + case Op::If: + require(n >= 2); + break; + case Op::In: + case Op::Pow: + case Op::CeilDiv: + case Op::ValueOrDefault: + require(n == 2); + break; + case Op::Abs: + case Op::Log2: + case Op::Rsqrt: + require(n == 1); + break; + } +} + +inline Value json_scalar_to_value(const nlohmann::json& j) +{ + if(j.is_boolean()) + return Value(j.get()); + if(j.is_number_integer() || j.is_number_unsigned()) + return Value(j.get()); + if(j.is_number_float()) + return Value(j.get()); + return Value(); // null +} + +template +NodePtr compile_node(const nlohmann::json& j, char sigil); + +template +NodePtr compile_var(const nlohmann::json& val, char sigil) +{ + auto n = std::make_unique>(); + if(val.is_string()) + { + n->has_static = true; + n->path = val.get(); + } + else if(val.is_number()) + { + n->has_static = true; + n->path = std::to_string(val.get()); + } + else if(val.is_array()) + { + const nlohmann::json& p = val.empty() ? nlohmann::json("") : val.at(0); + if(p.is_string()) + { + n->has_static = true; + n->path = p.get(); + } + else if(p.is_number()) + { + n->has_static = true; + n->path = std::to_string(p.get()); + } + else + { + n->path_expr = compile_node(p, sigil); + } + if(val.size() > 1) + n->default_expr = compile_node(val.at(1), sigil); + } + else if(val.is_object()) + { + n->path_expr = compile_node(val, sigil); + } + else + { + n->has_static = true; // null / other -> whole document + n->path.clear(); + } + return n; +} + +template +NodePtr compile_object(const nlohmann::json& j, char sigil) +{ + if(j.size() != 1) + throw JsonLogicCompileError("expression object must have exactly one operator key"); + const auto it = j.begin(); + const std::string& key = it.key(); + const nlohmann::json& val = it.value(); + if(key == "var") + return compile_var(val, sigil); + + const Op* op = lookup_op(key); + if(op == nullptr) + throw JsonLogicCompileError("unrecognized operation: " + key); + + auto node = std::make_unique>(); + node->op = *op; + if(val.is_array()) + { + node->args.reserve(val.size()); + for(const auto& e : val) + node->args.push_back(compile_node(e, sigil)); + } + else + { + node->args.push_back(compile_node(val, sigil)); + } + check_arity(*op, node->args.size(), key); + return node; +} + +template +NodePtr compile_node(const nlohmann::json& j, char sigil) +{ + if(j.is_object()) + return compile_object(j, sigil); + if(j.is_array()) + { + auto n = std::make_unique>(); + n->items.reserve(j.size()); + for(const auto& e : j) + n->items.push_back(compile_node(e, sigil)); + return n; + } + if(j.is_string()) + { + const std::string& s = j.get_ref(); + if(s.empty() || s[0] != sigil) + return std::make_unique>(Value(s)); + if(s.size() >= 2 && s[1] == sigil) + return std::make_unique>(Value(s.substr(1))); // "$$x" -> "$x" + auto n = std::make_unique>(); + n->has_static = true; + n->path = s.substr(1); + return n; + } + return std::make_unique>(json_scalar_to_value(j)); +} + +} // namespace detail + +// =========================================================================== +// Expression - a compiled, reusable JsonLogic expression +// =========================================================================== +template +class Expression +{ +public: + Expression() = default; + explicit Expression(detail::NodePtr root) + : root_(std::move(root)) + { + } + + /// Evaluate against a data source. Cheap: walks the pre-compiled tree. + Value operator()(const DataT& data) const + { + return root_ ? root_->eval(data) : Value(); + } + Value evaluate(const DataT& data) const + { + return (*this)(data); + } + + explicit operator bool() const + { + return static_cast(root_); + } + +private: + detail::NodePtr root_; +}; + +/// Compile a JsonLogic rule into a reusable Expression bound to data source +/// type DataT. Throws JsonLogicCompileError on malformed rules. +template +Expression compile(const nlohmann::json& rule, char var_sigil = '$') +{ + return Expression(detail::compile_node(rule, var_sigil)); +} + +/// Convenience one-shot: compile and evaluate in a single call. Prefer +/// compile() + reuse when evaluating the same rule repeatedly. +template +Value evaluate(const nlohmann::json& rule, const DataT& data, char var_sigil = '$') +{ + return compile(rule, var_sigil)(data); +} + +} // namespace hip_kernel_provider_common::jsonlogic diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md new file mode 100644 index 000000000000..37bdbeb0e8ef --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md @@ -0,0 +1,117 @@ +# JsonLogic + +Single-header [JsonLogic](https://jsonlogic.com/) **expression compiler** +(`JsonLogic.hpp`). A rule written as an `nlohmann::json` value is compiled once +into a reusable `Expression`, then evaluated many times against different +data sources. The runtime value type (`Value`) is a small standalone variant +that does **not** depend on nlohmann/json — nlohmann is used only to express the +rule being compiled. + +All names live in namespace `hip_kernel_provider_common::jsonlogic`; the examples +below assume `namespace jl = hip_kernel_provider_common::jsonlogic;`. + +```cpp +#include "hip_kernel_provider_common/JsonLogic.hpp" + +namespace jl = hip_kernel_provider_common::jsonlogic; + +struct MyData { // your data source + jl::Value getData(const std::string& path) const; +}; + +nlohmann::json rule = {{"+", {"$x", "$y"}}}; +auto expr = jl::compile(rule); // parse + build tree once +jl::Value a = expr(dataA); // evaluate - no re-parse +jl::Value b = expr(dataB); // reuse for other data +``` + +## Data source + +The evaluator is templated on your data type, which must expose: + +```cpp +jl::Value getData(const std::string& path) const; +``` + +The compiled expression passes the variable path (e.g. `"a.b.c"`) straight to +this accessor; your type owns path resolution. Conventions: + +- `""` is the whole-document request. +- returning `Value()` (null) means "not found" and triggers a `var` default if + the rule supplies one. + +## `Value` + +A json-like tagged value with no external dependency. Alternatives: null, bool, +`int64_t`, `double`, `std::string`, and `Array` (`std::vector`). Numeric +results are stored as integers when exactly integral (so `1 + 1` is `2`, not +`2.0`). Key members: `is_*()`, `as_*()`, `truthy()`, `to_number()`, `dump()`, +strict `operator==`, and the static `compare`. + +There is intentionally no object alternative — nested structure is reached +through the data source's path accessor, not carried in a `Value`. + +## Inline variables + +Stock JsonLogic references data with `{"var": "path"}` and treats every bare +string as a literal. This implementation additionally lets a variable appear +anywhere a literal can, by prefixing it with a sigil (`$` by default): + +| Inline | Equivalent | Meaning | +| ------------ | ------------------- | ----------------------- | +| `"$x"` | `{"var": "x"}` | top-level key | +| `"$a.b.c"` | `{"var": "a.b.c"}` | nested path | +| `"$arr.0"` | `{"var": "arr.0"}` | array index | +| `"$"` | `{"var": ""}` | whole document | +| `"$$text"` | `"$text"` | escaped string literal | + +Strings without the sigil stay literals, so stock rules keep working. Pass a +different sigil as the second argument to `compile` / `evaluate` if your keys +begin with `$`. + +## Supported operators + +Data access: `var` (dotted paths, array indices, `""` whole-document, +`[path, default]` fallback, and computed `[expr, default]` paths); +`value_or_default` (`{"value_or_default": ["$x", default]}`) returns the +variable's value when the path resolves and the default otherwise. It keys on +*existence* (an unresolved path reads as `null`), not truthiness, so a present +`0`, `""`, or `false` is returned rather than the default. + +Logic / control: `if` / `?:`, `and`, `or`, `!`, `!!` (short-circuit, lazy +branches). + +Comparison: `==`, `!=`, `<`, `<=`, `>`, `>=` (`<`/`<=` accept a third argument +for between-tests). `==`/`!=` are strict: no type coercion, so `1 == "1"` is +false (integers and doubles of equal value still compare equal). Ordering +(`<`/`<=`/`>`/`>=`) still uses numeric coercion. + +Arithmetic: `+`, `-` (binary and unary negation), `*`, `/`, `%`, `min`, `max`. + +Math extensions (value-core, for hipDNN dispatch/constraint formulas): `ceil_div` +(2-arg ceiling division), `abs`, `pow`, `log2`, `rsqrt` (`1/sqrt(x)`). + +Membership: `in`. `{"in": [needle, array]}` is true when `array` contains +`needle` (strict element equality); `{"in": [needle, string]}` is a substring +test. + +Value semantics follow JsonLogic/JS: `false`, `0`, `""`, `null` and the empty +array are falsy; `Number()`-style coercion drives arithmetic and ordering. + +Malformed rules (unknown operator, wrong argument count, a non-operator object) +raise `JsonLogicCompileError` at `compile` time, so evaluation stays on the fast +path. + +Not included: collection/string operators (`map`, `reduce`, `filter`, `all`, +`some`, `none`, `merge`, `cat`, `substr`, `missing`, `missing_some`). + +## Tests + +Unit tests live in `src/tests/core/TestJsonLogic.cpp` and build into the +`hip_kernel_provider_tests` GTest binary. Run them with: + +```bash +ctest -R hip_kernel_provider_tests +# or, filtered directly on the binary: +./hip_kernel_provider_tests --gtest_filter=JsonLogic.* +``` diff --git a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt index 8e2a59a5d258..41078d36c107 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt @@ -13,6 +13,12 @@ endif() if(NOT TARGET hipdnn_plugin_sdk) find_package(hipdnn_plugin_sdk CONFIG REQUIRED) endif() +# TestJsonLogic.cpp expresses rules as nlohmann::json. Available transitively via +# the hipdnn SDKs when HIPDNN_SKIP_JSON_LIB=OFF; link explicitly so the suite also +# builds standalone and with HIPDNN_SKIP_JSON_LIB=ON. +if(NOT TARGET nlohmann_json::nlohmann_json) + find_package(nlohmann_json CONFIG REQUIRED) +endif() add_executable( hip_kernel_provider_tests @@ -22,6 +28,7 @@ add_executable( core/TestContext.cpp core/TestUtils.cpp core/TestPluginPublic.cpp + core/TestJsonLogic.cpp compilation/TestProgramAndKernel.cpp compilation/TestKernelCompileOptions.cpp compilation/TestModuleCache.cpp @@ -64,7 +71,7 @@ target_compile_options(hip_kernel_provider_tests PRIVATE ${HIPDNN_WARNING_COMPIL target_link_libraries( hip_kernel_provider_tests PUBLIC GTest::gtest GTest::gmock hip::host - hip_kernel_provider_private hipdnn_test_sdk hipdnn_plugin_sdk Threads::Threads + hip_kernel_provider_private hipdnn_test_sdk hipdnn_plugin_sdk nlohmann_json::nlohmann_json Threads::Threads ) # make it so tests inside folders can find files without having to use a relative path. diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp new file mode 100644 index 000000000000..eb83b0e11353 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -0,0 +1,270 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include + +#include + +#include +#include +#include + +#include "hip_kernel_provider_common/JsonLogic.hpp" + +namespace jl = hip_kernel_provider_common::jsonlogic; + +using json = nlohmann::json; +using V = jl::Value; + +namespace hip_kernel_provider_common::jsonlogic +{ +// GTest value printer so EXPECT_EQ failures render a readable Value. +inline void PrintTo(const Value& v, std::ostream* os) +{ + *os << v.dump(); +} +} // namespace hip_kernel_provider_common::jsonlogic + +namespace +{ +// --------------------------------------------------------------------------- +// A sample data source: wraps an nlohmann::json document and resolves dotted +// paths to jl::Value. Demonstrates the getData(std::string) contract. +// --------------------------------------------------------------------------- +struct JsonData +{ + json doc; + + static V convert(const json& j) + { + if(j.is_boolean()) + return V(j.get()); + if(j.is_number_integer() || j.is_number_unsigned()) + return V(j.get()); + if(j.is_number_float()) + return V(j.get()); + if(j.is_string()) + return V(j.get()); + if(j.is_array()) + { + V::Array a; + a.reserve(j.size()); + for(const auto& e : j) + a.push_back(convert(e)); + return V(std::move(a)); + } + return V(); // null or object -> not representable, treated as null + } + + V getData(const std::string& path) const + { + if(path.empty()) + return convert(doc); + const json* cur = &doc; + std::size_t start = 0; + while(start <= path.size()) + { + const std::size_t dot = path.find('.', start); + const std::string seg + = path.substr(start, (dot == std::string::npos ? path.size() : dot) - start); + if(cur->is_object()) + { + const auto it = cur->find(seg); + if(it == cur->end()) + return V(); + cur = &*it; + } + else if(cur->is_array()) + { + char* end = nullptr; + const long idx = std::strtol(seg.c_str(), &end, 10); + if(*end != '\0' || idx < 0 || static_cast(idx) >= cur->size()) + return V(); + cur = &(*cur)[static_cast(idx)]; + } + else + { + return V(); + } + if(dot == std::string::npos) + break; + start = dot + 1; + } + return convert(*cur); + } +}; + +const JsonData D{json{{"x", 41}, + {"y", 8}, + {"name", "amd"}, + {"nested", {{"a", {{"b", 7}}}}}, + {"arr", {10, 20, 30}}, + {"flag", true}, + {"zero", 0}}}; + +// Compile a rule and evaluate it against the shared document D. +V eval(const json& rule) +{ + return jl::compile(rule)(D); +} +} // namespace + +TEST(JsonLogic, Literals) +{ + EXPECT_EQ(eval(42), V(42)); + EXPECT_EQ(eval("hello"), V("hello")); + EXPECT_EQ(eval(true), V(true)); + EXPECT_EQ(eval(json::array({1, 2, 3})), V(V::Array{V(1), V(2), V(3)})); +} + +TEST(JsonLogic, VarStockForm) +{ + EXPECT_EQ(eval(json({{"var", "x"}})), V(41)); + EXPECT_EQ(eval(json({{"var", "nested.a.b"}})), V(7)); + EXPECT_EQ(eval(json({{"var", "arr.1"}})), V(20)); + EXPECT_EQ(eval(json({{"var", "nope"}})), V()); + EXPECT_EQ(eval(json({{"var", json::array({"nope", 99})}})), V(99)); + EXPECT_EQ(eval(json({{"var", json::array({"x", 99})}})), V(41)); + EXPECT_EQ(eval(json({{"var", "arr"}})), V(V::Array{V(10), V(20), V(30)})); +} + +TEST(JsonLogic, InlineVariables) +{ + EXPECT_EQ(eval("$x"), V(41)); + EXPECT_EQ(eval("$nested.a.b"), V(7)); + EXPECT_EQ(eval("$arr.2"), V(30)); + EXPECT_EQ(eval("$nope"), V()); + EXPECT_EQ(eval("$$literal"), V("$literal")); + EXPECT_EQ(eval(json({{"+", json::array({"$x", "$y"})}})), V(49)); + EXPECT_EQ(eval(json({{"+", json::array({"$x", 1})}})), V(42)); + EXPECT_EQ(eval(json({{"==", json::array({"amd", "$name"})}})), V(true)); +} + +TEST(JsonLogic, Arithmetic) +{ + EXPECT_EQ(eval(json({{"+", json::array({1, 2, 3})}})), V(6)); + EXPECT_EQ(eval(json({{"+", json::array({"2", "3"})}})), V(5)); + EXPECT_EQ(eval(json({{"-", json::array({10, 3})}})), V(7)); + EXPECT_EQ(eval(json({{"-", json::array({5})}})), V(-5)); + EXPECT_EQ(eval(json({{"*", json::array({2, 3, 4})}})), V(24)); + EXPECT_EQ(eval(json({{"/", json::array({12, 4})}})), V(3)); + EXPECT_EQ(eval(json({{"%", json::array({10, 3})}})), V(1)); + EXPECT_EQ(eval(json({{"min", json::array({3, 1, 2})}})), V(1)); + EXPECT_EQ(eval(json({{"max", json::array({"$x", "$y", 100})}})), V(100)); + EXPECT_EQ(eval(json({{"+", json::array({{{"var", "x"}}, 1})}})), V(42)); +} + +TEST(JsonLogic, ComparisonIsStrict) +{ + EXPECT_EQ(eval(json({{"==", json::array({1, 1})}})), V(true)); + EXPECT_EQ(eval(json({{"==", json::array({1, 1.0})}})), V(true)); + EXPECT_EQ(eval(json({{"==", json::array({1, "1"})}})), V(false)); // no coercion + EXPECT_EQ(eval(json({{"==", json::array({"amd", "amd"})}})), V(true)); + EXPECT_EQ(eval(json({{"!=", json::array({1, 2})}})), V(true)); + EXPECT_EQ(eval(json({{"!=", json::array({1, "1"})}})), V(true)); // no coercion + EXPECT_EQ(eval(json({{">", json::array({"$x", "$y"})}})), V(true)); + EXPECT_EQ(eval(json({{">=", json::array({5, 5})}})), V(true)); + EXPECT_EQ(eval(json({{"<", json::array({1, 2})}})), V(true)); + EXPECT_EQ(eval(json({{"<=", json::array({2, 2})}})), V(true)); + EXPECT_EQ(eval(json({{"<", json::array({1, 2, 3})}})), V(true)); + EXPECT_EQ(eval(json({{"<", json::array({1, 5, 3})}})), V(false)); + EXPECT_EQ(eval(json({{"<=", json::array({1, 1, 3})}})), V(true)); + EXPECT_EQ(eval(json({{"<", json::array({"abc", "abd"})}})), V(true)); +} + +TEST(JsonLogic, TruthinessAndLogic) +{ + EXPECT_EQ(eval(json({{"!", 0}})), V(true)); + EXPECT_EQ(eval(json({{"!", 1}})), V(false)); + EXPECT_EQ(eval(json({{"!", json::array({json::array()})}})), V(true)); + EXPECT_EQ(eval(json({{"!!", "hello"}})), V(true)); + EXPECT_EQ(eval(json({{"and", json::array({true, 1, "x"})}})), V("x")); + EXPECT_EQ(eval(json({{"and", json::array({true, 0, "x"})}})), V(0)); + EXPECT_EQ(eval(json({{"or", json::array({0, "", "hit"})}})), V("hit")); + EXPECT_EQ(eval(json({{"or", json::array({0, ""})}})), V("")); +} + +TEST(JsonLogic, IfTernary) +{ + EXPECT_EQ(eval(json({{"if", json::array({true, "yes", "no"})}})), V("yes")); + EXPECT_EQ(eval(json({{"if", json::array({false, "yes", "no"})}})), V("no")); + EXPECT_EQ(eval(json({{"if", json::array({false, "a", true, "b", "c"})}})), V("b")); + EXPECT_EQ(eval(json({{"if", json::array({false, "a"})}})), V()); + EXPECT_EQ(eval(json({{"if", json::array({{{">", json::array({"$x", "$y"})}}, "$x", "$y"})}})), + V(41)); +} + +TEST(JsonLogic, Composed) +{ + EXPECT_EQ( + eval(json({{"if", + json::array({{{"<", json::array({{{"+", json::array({"$x", "$y"})}}, 50})}}, + "small", + "big"})}})), + V("small")); +} + +TEST(JsonLogic, Membership) +{ + EXPECT_EQ(eval(json({{"in", json::array({20, "$arr"})}})), V(true)); + EXPECT_EQ(eval(json({{"in", json::array({99, "$arr"})}})), V(false)); + EXPECT_EQ(eval(json({{"in", json::array({"$x", json::array({40, 41, 42})})}})), V(true)); + // strict element equality: "41" != 41 + EXPECT_EQ(eval(json({{"in", json::array({"41", json::array({40, 41, 42})})}})), V(false)); + EXPECT_EQ(eval(json({{"in", json::array({"m", "$name"})}})), V(true)); + EXPECT_EQ(eval(json({{"in", json::array({"z", "$name"})}})), V(false)); +} + +TEST(JsonLogic, MathExtensions) +{ + EXPECT_EQ(eval(json({{"ceil_div", json::array({100, 16})}})), V(7)); + EXPECT_EQ(eval(json({{"ceil_div", json::array({32, 16})}})), V(2)); + EXPECT_EQ(eval(json({{"abs", -5}})), V(5)); + EXPECT_EQ(eval(json({{"abs", 5}})), V(5)); + EXPECT_EQ(eval(json({{"pow", json::array({2, 10})}})), V(1024)); + EXPECT_EQ(eval(json({{"log2", 8}})), V(3)); + EXPECT_EQ(eval(json({{"rsqrt", 4}})), V(0.5)); +} + +TEST(JsonLogic, ValueOrDefault) +{ + EXPECT_EQ(eval(json({{"value_or_default", json::array({"$x", 99})}})), V(41)); + EXPECT_EQ(eval(json({{"value_or_default", json::array({"$nope", 99})}})), V(99)); + // keys on existence, not truthiness: present 0 is returned + EXPECT_EQ(eval(json({{"value_or_default", json::array({"$zero", 99})}})), V(0)); + EXPECT_EQ(eval(json({{"value_or_default", json::array({"$nested.a.b", -1})}})), V(7)); + // default is itself an expression, evaluated lazily + EXPECT_EQ(eval(json({{"value_or_default", json::array({"$nope", "$x"})}})), V(41)); + EXPECT_EQ(eval(json({{"value_or_default", json::array({"$nope", "fallback"})}})), + V("fallback")); +} + +TEST(JsonLogic, Umd0018ConstraintShapes) +{ + EXPECT_EQ(eval(json({{"in", json::array({"$name", json::array({"amd", "xilinx"})})}})), + V(true)); + EXPECT_EQ(eval(json({{"==", json::array({"$arr", json::array({10, 20, 30})})}})), V(true)); + // 41 % 8 == 1 + EXPECT_EQ(eval(json({{"==", json::array({{{"%", json::array({"$x", "$y"})}}, 1})}})), V(true)); + // ceil(41/16) == 3 + EXPECT_EQ(eval(json({{"ceil_div", json::array({"$x", 16})}})), V(3)); +} + +TEST(JsonLogic, CompileOnceReuseAcrossData) +{ + const auto expr = jl::compile(json({{"*", json::array({"$x", 2})}})); + const JsonData a{json{{"x", 3}}}; + const JsonData b{json{{"x", 10}}}; + EXPECT_EQ(expr(a), V(6)); + EXPECT_EQ(expr(b), V(20)); +} + +TEST(JsonLogic, MalformedRulesThrowAtCompileTime) +{ + EXPECT_THROW(jl::compile(json({{"nope", json::array({1, 2})}})), + jl::JsonLogicCompileError); + EXPECT_THROW(jl::compile(json({{"/", json::array({1, 2, 3})}})), + jl::JsonLogicCompileError); + EXPECT_THROW(jl::compile(json({{"abs", json::array({1, 2})}})), + jl::JsonLogicCompileError); +} From 998ff1a10973add84402ff6eaf6a777f7e3f3b92 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 23 Jul 2026 15:24:45 -0600 Subject: [PATCH 34/60] Refactor JsonLogic Value API and namespace naming Rename the namespace alias jl -> jlogic and the Value accessors to camelCase (is_*/as_*/to_number -> is*/as*/toNumber), rename the private member v_ -> _v, add an ostream operator<< for GoogleTest value printing, and update the JsonLogic.md docs and TestJsonLogic.cpp suite to the new names. (cherry picked from commit 8e99f07e5d2835313a07609c01fb693065d60c98) (cherry picked from commit 7b240517c4ba9296884ce8855caf05457734632a) --- .../hip_kernel_provider_common/JsonLogic.hpp | 911 +++++++++++------- .../hip_kernel_provider_common/JsonLogic.md | 18 +- .../src/tests/core/TestJsonLogic.cpp | 134 ++- 3 files changed, 689 insertions(+), 374 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp index 2872975d5873..2603bbbeb041 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp @@ -6,33 +6,34 @@ // JsonLogic.hpp - single-file JsonLogic expression compiler. // // All names below live in namespace hip_kernel_provider_common::jsonlogic; -// these examples assume `namespace jl = hip_kernel_provider_common::jsonlogic;`. +// these examples assume `namespace jlogic = hip_kernel_provider_common::jsonlogic;`. // // A JsonLogic expression (an nlohmann::json value) is *compiled once* into a -// reusable jl::Expression, then evaluated many times against different +// reusable jlogic::Expression, then evaluated many times against different // data sources: // // struct MyData { // your data source -// jl::Value getData(const std::string& path) const; +// jlogic::Value getData(const std::string& path) const; // }; // -// auto expr = jl::compile(rule); // parse + build tree once -// jl::Value r1 = expr(dataA); // evaluate, no re-parse -// jl::Value r2 = expr(dataB); // reuse for other data +// auto expr = jlogic::compile(rule); // parse + build tree once +// jlogic::Value r1 = expr(dataA); // evaluate, no re-parse +// jlogic::Value r2 = expr(dataB); // reuse for other data // -// The runtime value type (jl::Value) is a small standalone variant that does +// The runtime value type (jlogic::Value) is a small standalone variant that does // NOT depend on nlohmann/json; nlohmann is used only to express the rule being // compiled. The data source is any C++ type exposing // Value getData(const std::string&) -// which resolves a variable path to a Value ("" means the whole document; a -// null return means "not found", triggering a var default if one is given). +// which resolves a variable path to a Value; a null return means "not found", +// triggering a var default if one is given. Variable paths are always +// non-empty (whole-document references are not supported). // // Inline variables (extension over stock JsonLogic) // ------------------------------------------------- // A string prefixed with a sigil (default '$') is a variable reference, so a // variable can appear anywhere a literal can: // {"+": ["$x", "$y"]} == {"+": [{"var": "x"}, {"var": "y"}]} -// "$a.b" nested path "$" whole document "$$x" literal "$x" +// "$a.b" nested path "$$x" literal "$x" // Strings without the sigil remain literals, so stock rules keep working. // // Scope: core operator set (data access, logic, comparison, arithmetic), the @@ -43,11 +44,15 @@ #include +#include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -73,39 +78,39 @@ class Value using Array = std::vector; Value() - : v_(nullptr) + : _v(nullptr) { } Value(std::nullptr_t) - : v_(nullptr) + : _v(nullptr) { } Value(bool b) - : v_(b) + : _v(b) { } Value(int i) - : v_(static_cast(i)) + : _v(static_cast(i)) { } Value(std::int64_t i) - : v_(i) + : _v(i) { } Value(double d) - : v_(d) + : _v(d) { } Value(const char* s) - : v_(std::string(s)) + : _v(std::string(s)) { } Value(std::string s) - : v_(std::move(s)) + : _v(std::move(s)) { } Value(Array a) - : v_(std::move(a)) + : _v(std::move(a)) { } @@ -117,76 +122,78 @@ class Value { const double t = std::trunc(d); if(t == d && d >= -9.007199254740992e15 && d <= 9.007199254740992e15) - return Value(static_cast(t)); + { + return {static_cast(t)}; + } } - return Value(d); + return {d}; } - bool is_null() const + bool isNull() const { - return std::holds_alternative(v_); + return std::holds_alternative(_v); } - bool is_bool() const + bool isBool() const { - return std::holds_alternative(v_); + return std::holds_alternative(_v); } - bool is_int() const + bool isInt() const { - return std::holds_alternative(v_); + return std::holds_alternative(_v); } - bool is_double() const + bool isDouble() const { - return std::holds_alternative(v_); + return std::holds_alternative(_v); } - bool is_number() const + bool isNumber() const { - return is_int() || is_double(); + return isInt() || isDouble(); } - bool is_string() const + bool isString() const { - return std::holds_alternative(v_); + return std::holds_alternative(_v); } - bool is_array() const + bool isArray() const { - return std::holds_alternative(v_); + return std::holds_alternative(_v); } - bool as_bool() const + bool asBool() const { - return std::get(v_); + return std::get(_v); } - std::int64_t as_int() const + std::int64_t asInt() const { - return std::get(v_); + return std::get(_v); } - double as_double() const + double asDouble() const { - return std::get(v_); + return std::get(_v); } - const std::string& as_string() const + const std::string& asString() const { - return std::get(v_); + return std::get(_v); } - const Array& as_array() const + const Array& asArray() const { - return std::get(v_); + return std::get(_v); } /// JsonLogic truthiness: false, 0, "", null and the empty array are falsy. bool truthy() const { - switch(v_.index()) + switch(_v.index()) { case 1: - return std::get(v_); + return std::get(_v); case 2: - return std::get(v_) != 0; + return std::get(_v) != 0; case 3: - return std::get(v_) != 0.0; + return std::get(_v) != 0.0; case 4: - return !std::get(v_).empty(); + return !std::get(_v).empty(); case 5: - return !std::get(v_).empty(); + return !std::get(_v).empty(); default: return false; // null } @@ -194,25 +201,29 @@ class Value /// JS Number() coercion. Non-numeric strings and multi-element arrays yield /// NaN; empty string / empty array / null yield 0. - double to_number() const + double toNumber() const { - switch(v_.index()) + switch(_v.index()) { case 1: - return std::get(v_) ? 1.0 : 0.0; + return std::get(_v) ? 1.0 : 0.0; case 2: - return static_cast(std::get(v_)); + return static_cast(std::get(_v)); case 3: - return std::get(v_); + return std::get(_v); case 4: - return string_to_number(std::get(v_)); + return stringToNumber(std::get(_v)); case 5: { - const auto& a = std::get(v_); + const auto& a = std::get(_v); if(a.empty()) + { return 0.0; + } if(a.size() == 1) - return a.front().to_number(); + { + return a.front().toNumber(); + } return std::nan(""); } default: @@ -224,20 +235,24 @@ class Value /// compare equal; differing kinds do not. bool operator==(const Value& o) const { - if(is_number() && o.is_number()) - return to_number() == o.to_number(); - if(v_.index() != o.v_.index()) + if(isNumber() && o.isNumber()) + { + return toNumber() == o.toNumber(); + } + if(_v.index() != o._v.index()) + { return false; - switch(v_.index()) + } + switch(_v.index()) { case 0: return true; // null == null case 1: - return std::get(v_) == std::get(o.v_); + return std::get(_v) == std::get(o._v); case 4: - return std::get(v_) == std::get(o.v_); + return std::get(_v) == std::get(o._v); case 5: - return std::get(v_) == std::get(o.v_); + return std::get(_v) == std::get(o._v); default: return false; } @@ -251,41 +266,53 @@ class Value /// which makes every ordering test false. Two strings compare lexically. static int compare(const Value& a, const Value& b) { - if(a.is_string() && b.is_string()) + if(a.isString() && b.isString()) { - const auto& x = a.as_string(); - const auto& y = b.as_string(); - return x < y ? -1 : (x > y ? 1 : 0); + const auto& x = a.asString(); + const auto& y = b.asString(); + if(x < y) + { + return -1; + } + return x > y ? 1 : 0; } - const double x = a.to_number(); - const double y = b.to_number(); + const double x = a.toNumber(); + const double y = b.toNumber(); if(std::isnan(x) || std::isnan(y)) + { return 2; - return x < y ? -1 : (x > y ? 1 : 0); + } + if(x < y) + { + return -1; + } + return x > y ? 1 : 0; } /// Human-readable rendering, mainly for diagnostics and tests. std::string dump() const { - switch(v_.index()) + switch(_v.index()) { case 1: - return std::get(v_) ? "true" : "false"; + return std::get(_v) ? "true" : "false"; case 2: - return std::to_string(std::get(v_)); + return std::to_string(std::get(_v)); case 3: { - std::string s = std::to_string(std::get(v_)); + std::string s = std::to_string(std::get(_v)); return s; } case 4: - return "\"" + std::get(v_) + "\""; + return "\"" + std::get(_v) + "\""; case 5: { std::string s = "["; - const auto& a = std::get(v_); + const auto& a = std::get(_v); for(std::size_t i = 0; i < a.size(); ++i) - s += (i ? "," : "") + a[i].dump(); + { + s += ((i != 0u) ? "," : "") + a[i].dump(); + } return s + "]"; } default: @@ -293,220 +320,268 @@ class Value } } + /// Stream rendering (used by GoogleTest value printing and diagnostics). + friend std::ostream& operator<<(std::ostream& os, const Value& v) + { + return os << v.dump(); + } + private: - static double string_to_number(const std::string& s) + static double stringToNumber(const std::string& s) { - std::size_t b = 0, e = s.size(); - while(b < e && std::isspace(static_cast(s[b]))) + std::size_t b = 0; + std::size_t e = s.size(); + while(b < e && (std::isspace(static_cast(s[b])) != 0)) + { ++b; - while(e > b && std::isspace(static_cast(s[e - 1]))) + } + while(e > b && (std::isspace(static_cast(s[e - 1])) != 0)) + { --e; + } if(b == e) + { return 0.0; // JS Number("") == 0 + } const std::string t = s.substr(b, e - b); const char* first = t.c_str(); char* last = nullptr; const double d = std::strtod(first, &last); if(last != first + t.size()) + { return std::nan(""); // trailing garbage -> NaN + } return d; } // null, bool, int64, double, string, array - indices used by the switches. - std::variant v_; + std::variant _v; }; namespace detail { // ---- data-source capability detection ------------------------------------ template -struct has_getData : std::false_type +struct HasGetData : std::false_type { }; template -struct has_getData< +struct HasGetData< T, std::void_t().getData(std::declval()))>> : std::true_type { }; -/// Fetch a variable path from the data source. +// ---- type-erased data source --------------------------------------------- +// The compiled node tree evaluates against this abstract source rather than a +// concrete DataT, so the tree itself carries no template parameter (and thus +// no per-DataT virtual member instantiation). Expression wraps the +// caller's data object in a DataSourceAdapter at evaluation time. +struct IDataSource +{ + virtual ~IDataSource() = default; + virtual Value getData(const std::string& path) const = 0; +}; + template -Value fetch(const DataT& d, const std::string& path) +struct DataSourceAdapter final : IDataSource { - static_assert(has_getData::value, - "Data source must provide Value getData(std::string)."); - return d.getData(path); -} + static_assert(HasGetData::value, "Data source must provide Value getData(std::string)."); + const DataT& data; + explicit DataSourceAdapter(const DataT& d) + : data(d) + { + } + Value getData(const std::string& path) const override + { + return data.getData(path); + } +}; // ---- compiled node tree --------------------------------------------------- enum class Op { - Add, - Sub, - Mul, - Div, - Mod, - Min, - Max, - Lt, - Le, - Gt, - Ge, - Eq, - Neq, - Not, - NotNot, - If, - And, - Or, - In, - CeilDiv, - Abs, - Pow, - Log2, - Rsqrt, - ValueOrDefault + ADD, + SUB, + MUL, + DIV, + MOD, + MIN, + MAX, + LT, + LE, + GT, + GE, + EQ, + NEQ, + NOT, + NOT_NOT, + IF, + AND, + OR, + IN, + CEIL_DIV, + ABS, + POW, + LOG2, + RSQRT, + VALUE_OR_DEFAULT }; -template struct Node { virtual ~Node() = default; - virtual Value eval(const DataT&) const = 0; + virtual Value eval(const IDataSource& data) const = 0; + virtual const std::string* variable() const + { + return nullptr; + } + virtual void pushChildren(std::vector& /*unused*/) const {} }; -template -using NodePtr = std::unique_ptr>; +using NodePtr = std::unique_ptr; -template -struct LiteralNode final : Node +struct LiteralNode final : Node { Value value; explicit LiteralNode(Value v) : value(std::move(v)) { } - Value eval(const DataT&) const override + Value eval(const IDataSource& /*unused*/) const override { return value; } }; -template -struct ArrayNode final : Node +struct ArrayNode final : Node { - std::vector> items; - Value eval(const DataT& d) const override + std::vector items; + Value eval(const IDataSource& data) const override { Value::Array a; a.reserve(items.size()); for(const auto& it : items) - a.push_back(it->eval(d)); - return Value(std::move(a)); + { + a.push_back(it->eval(data)); + } + return {std::move(a)}; + } + void pushChildren(std::vector& stack) const override + { + for(auto it = items.rbegin(); it != items.rend(); ++it) + { + stack.push_back(it->get()); + } } }; -template -struct VarNode final : Node +struct VarNode final : Node { - bool has_static = false; - std::string path; // used when has_static - NodePtr path_expr; // used when !has_static (computed key) - NodePtr default_expr; // optional fallback when fetch returns null + std::string path; + NodePtr defaultExpr; // optional fallback when the lookup returns null - Value eval(const DataT& d) const override + Value eval(const IDataSource& data) const override { - if(has_static) + Value r = data.getData(path); + if(r.isNull() && defaultExpr) { - Value r = fetch(d, path); - if(r.is_null() && default_expr) - return default_expr->eval(d); - return r; + return defaultExpr->eval(data); } - const Value p = path_expr->eval(d); - std::string key; - if(p.is_string()) - key = p.as_string(); - else if(p.is_int()) - key = std::to_string(p.as_int()); - else if(p.is_number()) - key = std::to_string(static_cast(p.to_number())); - Value r = fetch(d, key); - if(r.is_null() && default_expr) - return default_expr->eval(d); return r; } + + const std::string* variable() const override + { + return &path; + } + void pushChildren(std::vector& stack) const override + { + if(defaultExpr) + { + stack.push_back(defaultExpr.get()); + } + } }; -template -struct OpNode final : Node +struct OpNode final : Node { Op op; - std::vector> args; + std::vector args; - Value eval(const DataT& d) const override + Value eval(const IDataSource& d) const override { switch(op) { - case Op::Add: + case Op::ADD: { double a = 0.0; for(const auto& c : args) - a += c->eval(d).to_number(); + { + a += c->eval(d).toNumber(); + } return Value::number(a); } - case Op::Mul: + case Op::MUL: { double a = 1.0; for(const auto& c : args) - a *= c->eval(d).to_number(); + { + a *= c->eval(d).toNumber(); + } return Value::number(a); } - case Op::Sub: + case Op::SUB: { if(args.size() == 1) - return Value::number(-args[0]->eval(d).to_number()); - return Value::number(args[0]->eval(d).to_number() - args[1]->eval(d).to_number()); + { + return Value::number(-args[0]->eval(d).toNumber()); + } + return Value::number(args[0]->eval(d).toNumber() - args[1]->eval(d).toNumber()); } - case Op::Div: - return Value::number(args[0]->eval(d).to_number() / args[1]->eval(d).to_number()); - case Op::Mod: + case Op::DIV: + return Value::number(args[0]->eval(d).toNumber() / args[1]->eval(d).toNumber()); + case Op::MOD: return Value::number( - std::fmod(args[0]->eval(d).to_number(), args[1]->eval(d).to_number())); - case Op::Min: + std::fmod(args[0]->eval(d).toNumber(), args[1]->eval(d).toNumber())); + case Op::MIN: { double best = std::nan(""); for(const auto& c : args) { - const double n = c->eval(d).to_number(); + const double n = c->eval(d).toNumber(); if(std::isnan(best) || n < best) + { best = n; + } } return Value::number(best); } - case Op::Max: + case Op::MAX: { double best = std::nan(""); for(const auto& c : args) { - const double n = c->eval(d).to_number(); + const double n = c->eval(d).toNumber(); if(std::isnan(best) || n > best) + { best = n; + } } return Value::number(best); } - case Op::Lt: + case Op::LT: { const Value a = args[0]->eval(d); const Value b = args[1]->eval(d); if(args.size() >= 3) - return Value(Value::compare(a, b) == -1 - && Value::compare(b, args[2]->eval(d)) == -1); - return Value(Value::compare(a, b) == -1); + { + return {Value::compare(a, b) == -1 && Value::compare(b, args[2]->eval(d)) == -1}; + } + return {Value::compare(a, b) == -1}; } - case Op::Le: + case Op::LE: { const Value a = args[0]->eval(d); const Value b = args[1]->eval(d); @@ -515,306 +590,456 @@ struct OpNode final : Node return c == -1 || c == 0; }; if(args.size() >= 3) - return Value(le(a, b) && le(b, args[2]->eval(d))); - return Value(le(a, b)); + { + return {le(a, b) && le(b, args[2]->eval(d))}; + } + return {le(a, b)}; } - case Op::Gt: - return Value(Value::compare(args[0]->eval(d), args[1]->eval(d)) == 1); - case Op::Ge: + case Op::GT: + return {Value::compare(args[0]->eval(d), args[1]->eval(d)) == 1}; + case Op::GE: { const int c = Value::compare(args[0]->eval(d), args[1]->eval(d)); - return Value(c == 1 || c == 0); - } - case Op::Eq: - return Value(args[0]->eval(d) == args[1]->eval(d)); - case Op::Neq: - return Value(args[0]->eval(d) != args[1]->eval(d)); - case Op::Not: - return Value(!args[0]->eval(d).truthy()); - case Op::NotNot: - return Value(args[0]->eval(d).truthy()); - case Op::If: + return {c == 1 || c == 0}; + } + case Op::EQ: + return {args[0]->eval(d) == args[1]->eval(d)}; + case Op::NEQ: + return {args[0]->eval(d) != args[1]->eval(d)}; + case Op::NOT: + return {!args[0]->eval(d).truthy()}; + case Op::NOT_NOT: + return {args[0]->eval(d).truthy()}; + case Op::IF: { std::size_t i = 0; for(; i + 1 < args.size(); i += 2) + { if(args[i]->eval(d).truthy()) + { return args[i + 1]->eval(d); + } + } return i < args.size() ? args[i]->eval(d) : Value(); } - case Op::And: + case Op::AND: { Value cur(true); for(const auto& c : args) { cur = c->eval(d); if(!cur.truthy()) + { return cur; // first falsy + } } return cur; // last value } - case Op::Or: + case Op::OR: { Value cur; for(const auto& c : args) { cur = c->eval(d); if(cur.truthy()) + { return cur; // first truthy + } } return cur; // last value } - case Op::In: + case Op::IN: { const Value needle = args[0]->eval(d); const Value hay = args[1]->eval(d); - if(hay.is_array()) + if(hay.isArray()) { - for(const auto& e : hay.as_array()) + for(const auto& e : hay.asArray()) + { if(e == needle) - return Value(true); - return Value(false); + { + return {true}; + } + } + return {false}; } - if(hay.is_string()) + if(hay.isString()) { - const std::string n = needle.is_string() ? needle.as_string() : needle.dump(); - return Value(hay.as_string().find(n) != std::string::npos); + const std::string n = needle.isString() ? needle.asString() : needle.dump(); + return {hay.asString().find(n) != std::string::npos}; } - return Value(false); + return {false}; } - case Op::CeilDiv: + case Op::CEIL_DIV: { - const double num = args[0]->eval(d).to_number(); - const double den = args[1]->eval(d).to_number(); + const double num = args[0]->eval(d).toNumber(); + const double den = args[1]->eval(d).toNumber(); return Value::number(std::ceil(num / den)); } - case Op::Abs: - return Value::number(std::fabs(args[0]->eval(d).to_number())); - case Op::Pow: + case Op::ABS: + return Value::number(std::fabs(args[0]->eval(d).toNumber())); + case Op::POW: return Value::number( - std::pow(args[0]->eval(d).to_number(), args[1]->eval(d).to_number())); - case Op::Log2: - return Value::number(std::log2(args[0]->eval(d).to_number())); - case Op::Rsqrt: - return Value::number(1.0 / std::sqrt(args[0]->eval(d).to_number())); - case Op::ValueOrDefault: + std::pow(args[0]->eval(d).toNumber(), args[1]->eval(d).toNumber())); + case Op::LOG2: + return Value::number(std::log2(args[0]->eval(d).toNumber())); + case Op::RSQRT: + return Value::number(1.0 / std::sqrt(args[0]->eval(d).toNumber())); + case Op::VALUE_OR_DEFAULT: { // First arg is a variable reference; a null result means the path // did not resolve in the data source, so fall back to the default. - Value v = args[0]->eval(d); - return v.is_null() ? args[1]->eval(d) : v; + Value const v = args[0]->eval(d); + return v.isNull() ? args[1]->eval(d) : v; + } + default: + break; } + return {}; + } + + void pushChildren(std::vector& stack) const override + { + for(auto it = args.rbegin(); it != args.rend(); ++it) + { + stack.push_back(it->get()); } - return Value(); } }; -inline const Op* lookup_op(const std::string& key) +inline const Op* lookupOp(const std::string& key) { - static const std::pair table[] = {{"+", Op::Add}, - {"-", Op::Sub}, - {"*", Op::Mul}, - {"/", Op::Div}, - {"%", Op::Mod}, - {"min", Op::Min}, - {"max", Op::Max}, - {"<", Op::Lt}, - {"<=", Op::Le}, - {">", Op::Gt}, - {">=", Op::Ge}, - {"==", Op::Eq}, - {"!=", Op::Neq}, - {"!", Op::Not}, - {"!!", Op::NotNot}, - {"if", Op::If}, - {"?:", Op::If}, - {"and", Op::And}, - {"or", Op::Or}, - {"in", Op::In}, - {"ceil_div", Op::CeilDiv}, - {"abs", Op::Abs}, - {"pow", Op::Pow}, - {"log2", Op::Log2}, - {"rsqrt", Op::Rsqrt}, - {"value_or_default", Op::ValueOrDefault}}; - for(const auto& e : table) + static const std::array, 26> s_table + = {{{"+", Op::ADD}, + {"-", Op::SUB}, + {"*", Op::MUL}, + {"/", Op::DIV}, + {"%", Op::MOD}, + {"min", Op::MIN}, + {"max", Op::MAX}, + {"<", Op::LT}, + {"<=", Op::LE}, + {">", Op::GT}, + {">=", Op::GE}, + {"==", Op::EQ}, + {"!=", Op::NEQ}, + {"!", Op::NOT}, + {"!!", Op::NOT_NOT}, + {"if", Op::IF}, + {"?:", Op::IF}, + {"and", Op::AND}, + {"or", Op::OR}, + {"in", Op::IN}, + {"ceil_div", Op::CEIL_DIV}, + {"abs", Op::ABS}, + {"pow", Op::POW}, + {"log2", Op::LOG2}, + {"rsqrt", Op::RSQRT}, + {"value_or_default", Op::VALUE_OR_DEFAULT}}}; + for(const auto& e : s_table) + { if(key == e.first) + { return &e.second; + } + } return nullptr; } -inline void check_arity(Op op, std::size_t n, const std::string& key) +inline void checkArity(Op op, std::size_t n, const std::string& key) { const auto require = [&](bool ok) { if(!ok) + { throw JsonLogicCompileError("operator '" + key + "' got wrong argument count: " + std::to_string(n)); + } }; switch(op) { - case Op::Add: - case Op::Mul: + case Op::ADD: + case Op::MUL: break; // any arity - case Op::Sub: + case Op::SUB: require(n == 1 || n == 2); break; - case Op::Div: - case Op::Mod: - case Op::Eq: - case Op::Neq: - case Op::Gt: - case Op::Ge: + case Op::DIV: + case Op::MOD: + case Op::EQ: + case Op::NEQ: + case Op::GT: + case Op::GE: require(n == 2); break; - case Op::Lt: - case Op::Le: + case Op::LT: + case Op::LE: require(n == 2 || n == 3); break; - case Op::Min: - case Op::Max: - case Op::And: - case Op::Or: + case Op::MIN: + case Op::MAX: + case Op::AND: + case Op::OR: require(n >= 1); break; - case Op::Not: - case Op::NotNot: + case Op::NOT: + case Op::NOT_NOT: require(n == 1); break; - case Op::If: + case Op::IF: require(n >= 2); break; - case Op::In: - case Op::Pow: - case Op::CeilDiv: - case Op::ValueOrDefault: + case Op::IN: + case Op::POW: + case Op::CEIL_DIV: + case Op::VALUE_OR_DEFAULT: require(n == 2); break; - case Op::Abs: - case Op::Log2: - case Op::Rsqrt: + case Op::ABS: + case Op::LOG2: + case Op::RSQRT: require(n == 1); break; + default: + break; } } -inline Value json_scalar_to_value(const nlohmann::json& j) +inline Value jsonScalarToValue(const nlohmann::json& j) { if(j.is_boolean()) - return Value(j.get()); + { + return {j.get()}; + } if(j.is_number_integer() || j.is_number_unsigned()) - return Value(j.get()); + { + return {j.get()}; + } if(j.is_number_float()) - return Value(j.get()); - return Value(); // null + { + return {j.get()}; + } + return {}; // null } -template -NodePtr compile_node(const nlohmann::json& j, char sigil); +inline NodePtr compileNode(const nlohmann::json& j, char sigil); -template -NodePtr compile_var(const nlohmann::json& val, char sigil) +inline NodePtr compileVar(const nlohmann::json& val, char sigil) { - auto n = std::make_unique>(); - if(val.is_string()) - { - n->has_static = true; - n->path = val.get(); - } - else if(val.is_number()) - { - n->has_static = true; - n->path = std::to_string(val.get()); - } - else if(val.is_array()) - { - const nlohmann::json& p = val.empty() ? nlohmann::json("") : val.at(0); + auto n = std::make_unique(); + const auto setStaticPath = [&](const nlohmann::json& p) { if(p.is_string()) { - n->has_static = true; - n->path = p.get(); + std::string s = p.get(); + if(s.empty()) + { + throw JsonLogicCompileError("whole-document variable reference is not supported"); + } + n->path = std::move(s); } else if(p.is_number()) { - n->has_static = true; n->path = std::to_string(p.get()); } + else if(p.is_null()) + { + throw JsonLogicCompileError("whole-document variable reference is not supported"); + } else { - n->path_expr = compile_node(p, sigil); + throw JsonLogicCompileError("computed variable keys are not supported"); } - if(val.size() > 1) - n->default_expr = compile_node(val.at(1), sigil); - } - else if(val.is_object()) + }; + if(val.is_array()) { - n->path_expr = compile_node(val, sigil); + if(val.empty()) + { + throw JsonLogicCompileError("whole-document variable reference is not supported"); + } + setStaticPath(val.at(0)); + if(val.size() > 1) + { + n->defaultExpr = compileNode(val.at(1), sigil); + } } else { - n->has_static = true; // null / other -> whole document - n->path.clear(); + setStaticPath(val); } return n; } -template -NodePtr compile_object(const nlohmann::json& j, char sigil) +inline NodePtr compileObject(const nlohmann::json& j, char sigil) { if(j.size() != 1) + { throw JsonLogicCompileError("expression object must have exactly one operator key"); + } const auto it = j.begin(); const std::string& key = it.key(); const nlohmann::json& val = it.value(); if(key == "var") - return compile_var(val, sigil); + { + return compileVar(val, sigil); + } - const Op* op = lookup_op(key); + const Op* op = lookupOp(key); if(op == nullptr) + { throw JsonLogicCompileError("unrecognized operation: " + key); + } - auto node = std::make_unique>(); + auto node = std::make_unique(); node->op = *op; if(val.is_array()) { node->args.reserve(val.size()); for(const auto& e : val) - node->args.push_back(compile_node(e, sigil)); + { + node->args.push_back(compileNode(e, sigil)); + } } else { - node->args.push_back(compile_node(val, sigil)); + node->args.push_back(compileNode(val, sigil)); } - check_arity(*op, node->args.size(), key); + checkArity(*op, node->args.size(), key); return node; } -template -NodePtr compile_node(const nlohmann::json& j, char sigil) +inline NodePtr compileNode(const nlohmann::json& j, char sigil) { if(j.is_object()) - return compile_object(j, sigil); + { + return compileObject(j, sigil); + } if(j.is_array()) { - auto n = std::make_unique>(); + auto n = std::make_unique(); n->items.reserve(j.size()); for(const auto& e : j) - n->items.push_back(compile_node(e, sigil)); + { + n->items.push_back(compileNode(e, sigil)); + } return n; } if(j.is_string()) { - const std::string& s = j.get_ref(); + const auto& s = j.get_ref(); if(s.empty() || s[0] != sigil) - return std::make_unique>(Value(s)); + { + return std::make_unique(Value(s)); + } if(s.size() >= 2 && s[1] == sigil) - return std::make_unique>(Value(s.substr(1))); // "$$x" -> "$x" - auto n = std::make_unique>(); - n->has_static = true; + { + return std::make_unique(Value(s.substr(1))); // "$$x" -> "$x" + } + if(s.size() == 1) + { + throw JsonLogicCompileError("whole-document variable reference is not supported"); + } + auto n = std::make_unique(); n->path = s.substr(1); return n; } - return std::make_unique>(json_scalar_to_value(j)); + return std::make_unique(jsonScalarToValue(j)); } +// ---- variable iteration --------------------------------------------------- +// Lazily yields, in pre-order, a reference to every variable path referenced +// by a compiled node tree. References point into the live VarNode::path, so no +// strings are copied; duplicates are yielded as they occur (build a std::set +// from the range if you need the unique, sorted set). +class VarIterator +{ +public: + using value_type = std::string; + using reference = const std::string&; + using pointer = const std::string*; + using difference_type = std::ptrdiff_t; + using iterator_category = std::input_iterator_tag; + + VarIterator() = default; // end + explicit VarIterator(const Node* root) + { + if(root != nullptr) + { + _stack.push_back(root); + } + advance(); + } + + reference operator*() const + { + return *_cur; + } + pointer operator->() const + { + return _cur; + } + + VarIterator& operator++() + { + advance(); + return *this; + } + VarIterator operator++(int) + { + VarIterator tmp = *this; + advance(); + return tmp; + } + + bool operator==(const VarIterator& o) const + { + return _cur == nullptr && o._cur == nullptr; + } + bool operator!=(const VarIterator& o) const + { + return !(*this == o); + } + +private: + void advance() + { + while(!_stack.empty()) + { + const Node* n = _stack.back(); + _stack.pop_back(); + n->pushChildren(_stack); + if(const std::string* p = n->variable()) + { + _cur = p; + return; + } + } + _cur = nullptr; + } + + std::vector _stack; + const std::string* _cur = nullptr; +}; + +class VarRange +{ +public: + explicit VarRange(const Node* root) + : _begin(root) + { + } + const VarIterator& begin() const + { + return _begin; + } + const VarIterator& end() const + { + return _end; + } + +private: + VarIterator _begin; + VarIterator _end; +}; + } // namespace detail // =========================================================================== @@ -825,15 +1050,20 @@ class Expression { public: Expression() = default; - explicit Expression(detail::NodePtr root) - : root_(std::move(root)) + explicit Expression(detail::NodePtr root) + : _root(std::move(root)) { } /// Evaluate against a data source. Cheap: walks the pre-compiled tree. Value operator()(const DataT& data) const { - return root_ ? root_->eval(data) : Value(); + if(!_root) + { + return {}; + } + const detail::DataSourceAdapter source(data); + return _root->eval(source); } Value evaluate(const DataT& data) const { @@ -842,27 +1072,36 @@ class Expression explicit operator bool() const { - return static_cast(root_); + return static_cast(_root); + } + + /// A lazy, pre-order range over every variable path referenced in the + /// expression. References point into the live tree, so the range must not + /// outlive this Expression. Duplicates are yielded as they occur; + /// construct a std::set from the range for the unique, sorted set. + detail::VarRange variables() const + { + return detail::VarRange(_root.get()); } private: - detail::NodePtr root_; + detail::NodePtr _root; }; /// Compile a JsonLogic rule into a reusable Expression bound to data source /// type DataT. Throws JsonLogicCompileError on malformed rules. template -Expression compile(const nlohmann::json& rule, char var_sigil = '$') +Expression compile(const nlohmann::json& rule, char varSigil = '$') { - return Expression(detail::compile_node(rule, var_sigil)); + return Expression(detail::compileNode(rule, varSigil)); } /// Convenience one-shot: compile and evaluate in a single call. Prefer /// compile() + reuse when evaluating the same rule repeatedly. template -Value evaluate(const nlohmann::json& rule, const DataT& data, char var_sigil = '$') +Value evaluate(const nlohmann::json& rule, const DataT& data, char varSigil = '$') { - return compile(rule, var_sigil)(data); + return compile(rule, varSigil)(data); } } // namespace hip_kernel_provider_common::jsonlogic diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md index 37bdbeb0e8ef..5d0a44cc81d7 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md @@ -8,21 +8,21 @@ that does **not** depend on nlohmann/json — nlohmann is used only to express t rule being compiled. All names live in namespace `hip_kernel_provider_common::jsonlogic`; the examples -below assume `namespace jl = hip_kernel_provider_common::jsonlogic;`. +below assume `namespace jlogic = hip_kernel_provider_common::jsonlogic;`. ```cpp #include "hip_kernel_provider_common/JsonLogic.hpp" -namespace jl = hip_kernel_provider_common::jsonlogic; +namespace jlogic = hip_kernel_provider_common::jsonlogic; struct MyData { // your data source - jl::Value getData(const std::string& path) const; + jlogic::Value getData(const std::string& path) const; }; nlohmann::json rule = {{"+", {"$x", "$y"}}}; -auto expr = jl::compile(rule); // parse + build tree once -jl::Value a = expr(dataA); // evaluate - no re-parse -jl::Value b = expr(dataB); // reuse for other data +auto expr = jlogic::compile(rule); // parse + build tree once +jlogic::Value a = expr(dataA); // evaluate - no re-parse +jlogic::Value b = expr(dataB); // reuse for other data ``` ## Data source @@ -30,7 +30,7 @@ jl::Value b = expr(dataB); // reuse for other data The evaluator is templated on your data type, which must expose: ```cpp -jl::Value getData(const std::string& path) const; +jlogic::Value getData(const std::string& path) const; ``` The compiled expression passes the variable path (e.g. `"a.b.c"`) straight to @@ -45,8 +45,8 @@ this accessor; your type owns path resolution. Conventions: A json-like tagged value with no external dependency. Alternatives: null, bool, `int64_t`, `double`, `std::string`, and `Array` (`std::vector`). Numeric results are stored as integers when exactly integral (so `1 + 1` is `2`, not -`2.0`). Key members: `is_*()`, `as_*()`, `truthy()`, `to_number()`, `dump()`, -strict `operator==`, and the static `compare`. +`2.0`). Key members: the `is*()` / `as*()` inspectors, `truthy()`, `toNumber()`, +`dump()`, strict `operator==`, and the static `compare`. There is intentionally no object alternative — nested structure is reached through the data source's path accessor, not carried in a `Value`. diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp index eb83b0e11353..11a920469e4f 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -5,31 +5,26 @@ #include +#include #include +#include #include +#include #include +#include #include "hip_kernel_provider_common/JsonLogic.hpp" -namespace jl = hip_kernel_provider_common::jsonlogic; +namespace jlogic = hip_kernel_provider_common::jsonlogic; using json = nlohmann::json; -using V = jl::Value; - -namespace hip_kernel_provider_common::jsonlogic -{ -// GTest value printer so EXPECT_EQ failures render a readable Value. -inline void PrintTo(const Value& v, std::ostream* os) -{ - *os << v.dump(); -} -} // namespace hip_kernel_provider_common::jsonlogic +using V = jlogic::Value; namespace { // --------------------------------------------------------------------------- // A sample data source: wraps an nlohmann::json document and resolves dotted -// paths to jl::Value. Demonstrates the getData(std::string) contract. +// paths to jlogic::Value. Demonstrates the getData(std::string) contract. // --------------------------------------------------------------------------- struct JsonData { @@ -38,28 +33,40 @@ struct JsonData static V convert(const json& j) { if(j.is_boolean()) - return V(j.get()); + { + return {j.get()}; + } if(j.is_number_integer() || j.is_number_unsigned()) - return V(j.get()); + { + return {j.get()}; + } if(j.is_number_float()) - return V(j.get()); + { + return {j.get()}; + } if(j.is_string()) - return V(j.get()); + { + return {j.get()}; + } if(j.is_array()) { V::Array a; a.reserve(j.size()); for(const auto& e : j) + { a.push_back(convert(e)); - return V(std::move(a)); + } + return {std::move(a)}; } - return V(); // null or object -> not representable, treated as null + return {}; // null or object -> not representable, treated as null } V getData(const std::string& path) const { if(path.empty()) + { return convert(doc); + } const json* cur = &doc; std::size_t start = 0; while(start <= path.size()) @@ -71,7 +78,9 @@ struct JsonData { const auto it = cur->find(seg); if(it == cur->end()) - return V(); + { + return {}; + } cur = &*it; } else if(cur->is_array()) @@ -79,15 +88,19 @@ struct JsonData char* end = nullptr; const long idx = std::strtol(seg.c_str(), &end, 10); if(*end != '\0' || idx < 0 || static_cast(idx) >= cur->size()) - return V(); + { + return {}; + } cur = &(*cur)[static_cast(idx)]; } else { - return V(); + return {}; } if(dot == std::string::npos) + { break; + } start = dot + 1; } return convert(*cur); @@ -105,7 +118,7 @@ const JsonData D{json{{"x", 41}, // Compile a rule and evaluate it against the shared document D. V eval(const json& rule) { - return jl::compile(rule)(D); + return jlogic::compile(rule)(D); } } // namespace @@ -252,7 +265,7 @@ TEST(JsonLogic, Umd0018ConstraintShapes) TEST(JsonLogic, CompileOnceReuseAcrossData) { - const auto expr = jl::compile(json({{"*", json::array({"$x", 2})}})); + const auto expr = jlogic::compile(json({{"*", json::array({"$x", 2})}})); const JsonData a{json{{"x", 3}}}; const JsonData b{json{{"x", 10}}}; EXPECT_EQ(expr(a), V(6)); @@ -261,10 +274,73 @@ TEST(JsonLogic, CompileOnceReuseAcrossData) TEST(JsonLogic, MalformedRulesThrowAtCompileTime) { - EXPECT_THROW(jl::compile(json({{"nope", json::array({1, 2})}})), - jl::JsonLogicCompileError); - EXPECT_THROW(jl::compile(json({{"/", json::array({1, 2, 3})}})), - jl::JsonLogicCompileError); - EXPECT_THROW(jl::compile(json({{"abs", json::array({1, 2})}})), - jl::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"nope", json::array({1, 2})}})), + jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"/", json::array({1, 2, 3})}})), + jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"abs", json::array({1, 2})}})), + jlogic::JsonLogicCompileError); +} + +TEST(JsonLogic, VariablesCollectsReferencedPaths) +{ + using S = std::set; + // Keep the Expression alive while draining the borrowed range into a set. + const auto vars = [](const json& rule) { + const auto expr = jlogic::compile(rule); + return S(expr.variables().begin(), expr.variables().end()); + }; + // inline vars across an op + EXPECT_EQ(vars(json({{"+", json::array({"$x", "$y"})}})), (S{"x", "y"})); + // stock var form keeps the dotted path verbatim + EXPECT_EQ(vars(json({{"var", "nested.a.b"}})), (S{"nested.a.b"})); + // a var default subtree contributes its own referenced vars + EXPECT_EQ(vars(json({{"var", json::array({"nope", "$y"})}})), (S{"nope", "y"})); + // nested composition reaches every leaf var + EXPECT_EQ( + vars(json({{"if", json::array({{{">", json::array({"$x", "$y"})}}, "$name", "$zero"})}})), + (S{"x", "y", "name", "zero"})); + // literal-only expressions reference nothing + EXPECT_EQ(vars(json({{"+", json::array({1, 2})}})), (S{})); + EXPECT_TRUE(vars(json(42)).empty()); +} + +TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) +{ + // range-for yields every occurrence, duplicates included + const auto expr = jlogic::compile(json({{"+", json::array({"$x", "$x", "$y"})}})); + std::vector seen; + for(const std::string& v : expr.variables()) + { + seen.push_back(v); + } + EXPECT_EQ(seen.size(), 3u); + EXPECT_EQ(std::count(seen.begin(), seen.end(), std::string("x")), 2); + EXPECT_EQ(std::count(seen.begin(), seen.end(), std::string("y")), 1); + + // empty range: begin() == end(), the body never runs + const auto lit = jlogic::compile(json(42)); + EXPECT_TRUE(lit.variables().begin() == lit.variables().end()); + + // composes with STL algorithms over the borrowed references + const auto r = expr.variables(); + EXPECT_EQ(std::distance(r.begin(), r.end()), 3); + EXPECT_TRUE(std::any_of(r.begin(), r.end(), [](const std::string& s) { return s == "y"; })); +} + +TEST(JsonLogic, WholeDocumentAndDynamicKeysRejected) +{ + // whole-document references: inline sigil, empty var, empty array, null + EXPECT_THROW(jlogic::compile(json("$")), jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"var", ""}})), jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"var", json::array()}})), + jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"var", nullptr}})), + jlogic::JsonLogicCompileError); + // computed / dynamic keys (object key, or computed first array element) + EXPECT_THROW(jlogic::compile(json({{"var", {{"+", json::array({1, 2})}}}})), + jlogic::JsonLogicCompileError); + EXPECT_THROW( + jlogic::compile(json({{"var", json::array({{{"+", json::array({1, 2})}}, 0})}})), + jlogic::JsonLogicCompileError); } From f010f160507451ea0b6250df029fd73e26d3ebf1 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 23 Jul 2026 16:15:15 -0600 Subject: [PATCH 35/60] Add nlohmann::json-backed JsonLogic data source with getData/setData Provide JsonDataSource, a sample JsonLogic data source backed by an nlohmann::json document. getData resolves dotted keys, [N] subscripts, dot-form indices, and an optional leading variable sigil. setData writes a Value back at a path, creating intermediate objects/arrays on demand and throwing on malformed paths. Add a dedicated JsonDataSource gtest suite, repoint TestJsonLogic to the shared sample, and document the accessor. (cherry picked from commit 701f086d7e6a3c9a1a1bd03b3bb9dbaa2338cee3) (cherry picked from commit bc1617734f1c48610c759301399e6d8b6609614c) --- .../JsonDataSource.hpp | 310 ++++++++++++++++++ .../hip_kernel_provider_common/JsonLogic.md | 34 +- .../src/tests/CMakeLists.txt | 1 + .../src/tests/core/TestJsonDataSource.cpp | 109 ++++++ .../src/tests/core/TestJsonLogic.cpp | 158 +++------ 5 files changed, 501 insertions(+), 111 deletions(-) create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonDataSource.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonDataSource.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonDataSource.hpp new file mode 100644 index 000000000000..7faab6798bab --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonDataSource.hpp @@ -0,0 +1,310 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +// JsonDataSource.hpp - a sample JsonLogic data source backed by nlohmann::json. +// +// All names below live in namespace hip_kernel_provider_common::jsonlogic; +// these examples assume `namespace jlogic = hip_kernel_provider_common::jsonlogic;`. +// +// JsonDataSource wraps an nlohmann::json document and satisfies the JsonLogic +// data-source contract (Value getData(const std::string&) const), so a compiled +// jlogic::Expression can evaluate directly against a JSON document: +// +// jlogic::JsonDataSource src(nlohmann::json{{"q", {{"dims", {8, 16}}}}}); +// auto expr = jlogic::compile(rule); +// jlogic::Value r = expr(src); +// +// It also offers the inverse, setData, which writes a jlogic::Value back into +// the document at a path, creating intermediate objects and arrays as needed: +// +// src.setData("$q.dims[0]", 2); // -> {"q":{"dims":[2, 16]}} +// +// Path syntax (shared by getData and setData): +// - dotted keys: a.b.c +// - [N] array subscripts: arr[0], rows[2].name, grid[0][1] +// - dot-form array indices: arr.1 (resolves as an index only against an +// existing array; use arr[1] to force array creation) +// - an optional leading variable sigil ('$' by default) is stripped, so both +// "q.dims[0]" and "$q.dims[0]" address the same location +// - the empty path (or a bare sigil) addresses the whole document +// +// getData follows the JsonLogic convention: an unresolved path reads as null +// (Value()). setData is a mutation and reports a malformed path or an +// incompatible index by throwing std::invalid_argument. +// +// This is a *sample* accessor: objects and null in the document convert to +// jlogic::Value null (Value has no object alternative), matching Value's +// scalar/array-only model. + +#include "hip_kernel_provider_common/JsonLogic.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace hip_kernel_provider_common::jsonlogic +{ +class JsonDataSource +{ +public: + JsonDataSource() = default; + explicit JsonDataSource(nlohmann::json doc, char sigil = '$') + : _doc(std::move(doc)) + , _sigil(sigil) + { + } + + /// The backing document, for inspection or bulk replacement. + const nlohmann::json& document() const + { + return _doc; + } + nlohmann::json& document() + { + return _doc; + } + + /// JsonLogic data-source contract: resolve a variable path to a Value. + /// An empty path returns the whole document; an unresolved path (missing + /// key, out-of-range or non-numeric index, subscript on a non-array) + /// returns null so a `var` default, if any, takes over. + Value getData(const std::string& path) const + { + std::vector segs; + if(!tokenize(path, _sigil, segs)) + { + return {}; // malformed subscript -> not found + } + const nlohmann::json* cur = &_doc; + for(const auto& seg : segs) + { + if(seg.subscript || cur->is_array()) + { + if(!cur->is_array()) + { + return {}; // subscript on a non-array + } + std::size_t idx = 0; + if(!parseIndex(seg.text, idx) || idx >= cur->size()) + { + return {}; + } + cur = &(*cur)[idx]; + } + else if(cur->is_object()) + { + const auto it = cur->find(seg.text); + if(it == cur->end()) + { + return {}; + } + cur = &*it; + } + else + { + return {}; + } + } + return toValue(*cur); + } + + /// Write a Value into the document at a path, creating intermediate + /// containers as needed: a `[N]` subscript (or a dot-form index against an + /// existing array) grows an array, filling gaps with null; any other key + /// creates or descends into an object. An empty path replaces the whole + /// document. Throws std::invalid_argument on a malformed path or a + /// non-numeric index applied to an array. + void setData(const std::string& path, const Value& value) + { + std::vector segs; + if(!tokenize(path, _sigil, segs)) + { + throw std::invalid_argument("JsonDataSource::setData: malformed path '" + path + "'"); + } + if(segs.empty()) + { + _doc = toJson(value); // whole-document assignment + return; + } + nlohmann::json* cur = &_doc; + for(std::size_t i = 0; i < segs.size(); ++i) + { + const Segment& seg = segs[i]; + nlohmann::json* target = nullptr; + if(seg.subscript) + { + std::size_t idx = 0; + if(!parseIndex(seg.text, idx)) + { + throw std::invalid_argument("JsonDataSource::setData: bad array index '" + + seg.text + "' in path '" + path + "'"); + } + if(!cur->is_array()) + { + *cur = nlohmann::json::array(); + } + target = &(*cur)[idx]; // grows the array, filling gaps with null + } + else if(cur->is_array()) + { + std::size_t idx = 0; + if(!parseIndex(seg.text, idx)) + { + throw std::invalid_argument("JsonDataSource::setData: non-numeric key '" + + seg.text + "' on array in path '" + path + "'"); + } + target = &(*cur)[idx]; + } + else + { + if(!cur->is_object()) + { + *cur = nlohmann::json::object(); + } + target = &(*cur)[seg.text]; + } + if(i + 1 == segs.size()) + { + *target = toJson(value); + } + else + { + cur = target; + } + } + } + +private: + struct Segment + { + bool subscript; // true for [N] forms, false for dotted/leading keys + std::string text; + }; + + /// Split a path into segments. Strips one optional leading sigil. Returns + /// false on a malformed subscript (missing ']'). + static bool tokenize(const std::string& raw, char sigil, std::vector& out) + { + std::size_t pos = 0; + if(!raw.empty() && raw[0] == sigil) + { + ++pos; // strip the variable sigil + } + while(pos < raw.size()) + { + if(raw[pos] == '[') + { + const std::size_t close = raw.find(']', pos + 1); + if(close == std::string::npos) + { + return false; + } + out.push_back({true, raw.substr(pos + 1, close - pos - 1)}); + pos = close + 1; + } + else + { + if(raw[pos] == '.') + { + ++pos; // skip the key separator + } + const std::size_t next = raw.find_first_of(".[", pos); + const std::size_t end = (next == std::string::npos) ? raw.size() : next; + out.push_back({false, raw.substr(pos, end - pos)}); + pos = end; + } + } + return true; + } + + /// Parse a non-negative decimal index. Rejects empty, negative, and + /// non-numeric (with trailing garbage) text. + static bool parseIndex(const std::string& s, std::size_t& idx) + { + if(s.empty()) + { + return false; + } + char* endp = nullptr; + const long val = std::strtol(s.c_str(), &endp, 10); + if(*endp != '\0' || val < 0) + { + return false; + } + idx = static_cast(val); + return true; + } + + static Value toValue(const nlohmann::json& j) + { + if(j.is_boolean()) + { + return {j.get()}; + } + if(j.is_number_integer() || j.is_number_unsigned()) + { + return {j.get()}; + } + if(j.is_number_float()) + { + return {j.get()}; + } + if(j.is_string()) + { + return {j.get()}; + } + if(j.is_array()) + { + Value::Array a; + a.reserve(j.size()); + for(const auto& e : j) + { + a.push_back(toValue(e)); + } + return {std::move(a)}; + } + return {}; // null or object -> not representable, treated as null + } + + static nlohmann::json toJson(const Value& v) + { + if(v.isBool()) + { + return v.asBool(); + } + if(v.isInt()) + { + return v.asInt(); + } + if(v.isDouble()) + { + return v.asDouble(); + } + if(v.isString()) + { + return v.asString(); + } + if(v.isArray()) + { + nlohmann::json a = nlohmann::json::array(); + for(const auto& e : v.asArray()) + { + a.push_back(toJson(e)); + } + return a; + } + return nullptr; // null + } + + nlohmann::json _doc; + char _sigil = '$'; +}; + +} // namespace hip_kernel_provider_common::jsonlogic diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md index 5d0a44cc81d7..52d5bb4c217e 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md @@ -40,6 +40,36 @@ this accessor; your type owns path resolution. Conventions: - returning `Value()` (null) means "not found" and triggers a `var` default if the rule supplies one. +### Sample: `JsonDataSource` + +`JsonDataSource.hpp` provides a ready-made data source backed by an +`nlohmann::json` document, so you can evaluate rules against JSON without writing +an accessor: + +```cpp +#include "hip_kernel_provider_common/JsonDataSource.hpp" + +jlogic::JsonDataSource src(nlohmann::json{{"q", {{"dims", {8, 16}}}}}); +auto expr = jlogic::compile(rule); +jlogic::Value r = expr(src); +``` + +It resolves dotted keys (`a.b.c`), `[N]` array subscripts (`arr[0]`, +`rows[2].name`, `grid[0][1]`), and dot-form indices (`arr.1`). A leading +variable sigil is stripped, so `"$q.dims[0]"` and `"q.dims[0]"` address the same +location. It also offers the inverse, `setData`, which writes a `Value` back into +the document and creates intermediate objects and arrays on demand: + +```cpp +src.setData("$q.dims[0]", 2); // -> {"q":{"dims":[2, 16]}} +``` + +A `[N]` subscript grows an array (filling gaps with null); any other key creates +or descends into an object; the empty path replaces the whole document. +`setData` throws `std::invalid_argument` on a malformed path or a non-numeric +index applied to an array. Objects and null in the document read back as `Value` +null, matching `Value`'s scalar/array-only model. + ## `Value` A json-like tagged value with no external dependency. Alternatives: null, bool, @@ -61,7 +91,7 @@ anywhere a literal can, by prefixing it with a sigil (`$` by default): | ------------ | ------------------- | ----------------------- | | `"$x"` | `{"var": "x"}` | top-level key | | `"$a.b.c"` | `{"var": "a.b.c"}` | nested path | -| `"$arr.0"` | `{"var": "arr.0"}` | array index | +| `"$arr[0]"` | `{"var": "arr[0]"}` | array index (subscript) | | `"$"` | `{"var": ""}` | whole document | | `"$$text"` | `"$text"` | escaped string literal | @@ -71,7 +101,7 @@ begin with `$`. ## Supported operators -Data access: `var` (dotted paths, array indices, `""` whole-document, +Data access: `var` (dotted paths, `[N]` array subscripts, `""` whole-document, `[path, default]` fallback, and computed `[expr, default]` paths); `value_or_default` (`{"value_or_default": ["$x", default]}`) returns the variable's value when the path resolves and the default otherwise. It keys on diff --git a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt index 41078d36c107..e9632de6c421 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable( core/TestUtils.cpp core/TestPluginPublic.cpp core/TestJsonLogic.cpp + core/TestJsonDataSource.cpp compilation/TestProgramAndKernel.cpp compilation/TestKernelCompileOptions.cpp compilation/TestModuleCache.cpp diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp new file mode 100644 index 000000000000..4e3d618c125d --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp @@ -0,0 +1,109 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#include + +#include + +#include + +#include "hip_kernel_provider_common/JsonDataSource.hpp" +#include "hip_kernel_provider_common/JsonLogic.hpp" + +namespace jlogic = hip_kernel_provider_common::jsonlogic; + +using json = nlohmann::json; +using V = jlogic::Value; + +// --------------------------------------------------------------------------- +// JsonDataSource: the sample nlohmann::json-backed data source (getData/setData). +// --------------------------------------------------------------------------- +TEST(JsonDataSource, GetResolvesPathsAndSubscripts) +{ + const jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}, + {"rows", {{{"name", "a0"}}, {{"name", "a1"}}}}, + {"grid", {{1, 2}, {3, 4}}}}}; + EXPECT_EQ(src.getData("q.dims"), V(V::Array{V(8), V(16)})); + EXPECT_EQ(src.getData("q.dims[1]"), V(16)); + EXPECT_EQ(src.getData("q.dims.0"), V(8)); // dot-form index against an array + EXPECT_EQ(src.getData("rows[1].name"), V("a1")); + EXPECT_EQ(src.getData("grid[0][1]"), V(2)); + EXPECT_EQ(src.getData(""), V()); // whole document is an object -> null + // unresolved paths read as null + EXPECT_EQ(src.getData("q.nope"), V()); + EXPECT_EQ(src.getData("q.dims[9]"), V()); + EXPECT_EQ(src.getData("q.dims[x]"), V()); + EXPECT_EQ(src.getData("q.dims["), V()); // malformed subscript +} + +TEST(JsonDataSource, GetStripsOptionalSigil) +{ + const jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; + EXPECT_EQ(src.getData("$q.dims[0]"), src.getData("q.dims[0]")); + EXPECT_EQ(src.getData("$q.dims[0]"), V(8)); +} + +TEST(JsonDataSource, SetScalarCreatesNestedObjects) +{ + jlogic::JsonDataSource src; + src.setData("q.dims", V(4)); + EXPECT_EQ(src.getData("q.dims"), V(4)); + EXPECT_EQ(src.document(), (json{{"q", {{"dims", 4}}}})); +} + +TEST(JsonDataSource, SetSubscriptCreatesAndExtendsArray) +{ + jlogic::JsonDataSource src; + // [N] forces array creation; gaps fill with null. + src.setData("q.dims[2]", V(7)); + EXPECT_EQ(src.document(), (json{{"q", {{"dims", {nullptr, nullptr, 7}}}}})); + EXPECT_EQ(src.getData("q.dims[2]"), V(7)); + EXPECT_EQ(src.getData("q.dims[0]"), V()); // gap reads as null +} + +TEST(JsonDataSource, SetOverwritesExistingArrayIndex) +{ + jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; + src.setData("$q.dims[0]", V(2)); // the motivating example + EXPECT_EQ(src.document(), (json{{"q", {{"dims", {2, 16}}}}})); + EXPECT_EQ(src.getData("q.dims[0]"), V(2)); +} + +TEST(JsonDataSource, SetWholeDocument) +{ + jlogic::JsonDataSource src{json{{"x", 1}}}; + src.setData("", V(V::Array{V(1), V(2)})); + EXPECT_EQ(src.document(), (json::array({1, 2}))); + EXPECT_EQ(src.getData("[1]"), V(2)); +} + +TEST(JsonDataSource, SetRoundTripsValueKinds) +{ + jlogic::JsonDataSource src; + src.setData("b", V(true)); + src.setData("s", V("amd")); + src.setData("d", V(1.5)); + src.setData("a", V(V::Array{V(1), V("two"), V(false)})); + EXPECT_EQ(src.getData("b"), V(true)); + EXPECT_EQ(src.getData("s"), V("amd")); + EXPECT_EQ(src.getData("d"), V(1.5)); + EXPECT_EQ(src.getData("a"), V(V::Array{V(1), V("two"), V(false)})); +} + +TEST(JsonDataSource, SetThenEvaluateReflectsChange) +{ + jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; + const auto expr = jlogic::compile( + json({{"*", json::array({"$q.dims[0]", "$q.dims[1]"})}})); + EXPECT_EQ(expr(src), V(128)); + src.setData("$q.dims[0]", V(2)); + EXPECT_EQ(expr(src), V(32)); +} + +TEST(JsonDataSource, SetRejectsMalformedPaths) +{ + jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; + EXPECT_THROW(src.setData("q.dims[0", V(1)), std::invalid_argument); // missing ']' + EXPECT_THROW(src.setData("q.dims[x]", V(1)), std::invalid_argument); // non-numeric index + EXPECT_THROW(src.setData("q.dims.nope", V(1)), std::invalid_argument); // string key on array +} diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp index 11a920469e4f..59c3f167cc93 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -13,6 +13,7 @@ #include #include +#include "hip_kernel_provider_common/JsonDataSource.hpp" #include "hip_kernel_provider_common/JsonLogic.hpp" namespace jlogic = hip_kernel_provider_common::jsonlogic; @@ -22,103 +23,20 @@ using V = jlogic::Value; namespace { -// --------------------------------------------------------------------------- -// A sample data source: wraps an nlohmann::json document and resolves dotted -// paths to jlogic::Value. Demonstrates the getData(std::string) contract. -// --------------------------------------------------------------------------- -struct JsonData -{ - json doc; - - static V convert(const json& j) - { - if(j.is_boolean()) - { - return {j.get()}; - } - if(j.is_number_integer() || j.is_number_unsigned()) - { - return {j.get()}; - } - if(j.is_number_float()) - { - return {j.get()}; - } - if(j.is_string()) - { - return {j.get()}; - } - if(j.is_array()) - { - V::Array a; - a.reserve(j.size()); - for(const auto& e : j) - { - a.push_back(convert(e)); - } - return {std::move(a)}; - } - return {}; // null or object -> not representable, treated as null - } - - V getData(const std::string& path) const - { - if(path.empty()) - { - return convert(doc); - } - const json* cur = &doc; - std::size_t start = 0; - while(start <= path.size()) - { - const std::size_t dot = path.find('.', start); - const std::string seg - = path.substr(start, (dot == std::string::npos ? path.size() : dot) - start); - if(cur->is_object()) - { - const auto it = cur->find(seg); - if(it == cur->end()) - { - return {}; - } - cur = &*it; - } - else if(cur->is_array()) - { - char* end = nullptr; - const long idx = std::strtol(seg.c_str(), &end, 10); - if(*end != '\0' || idx < 0 || static_cast(idx) >= cur->size()) - { - return {}; - } - cur = &(*cur)[static_cast(idx)]; - } - else - { - return {}; - } - if(dot == std::string::npos) - { - break; - } - start = dot + 1; - } - return convert(*cur); - } -}; - -const JsonData D{json{{"x", 41}, - {"y", 8}, - {"name", "amd"}, - {"nested", {{"a", {{"b", 7}}}}}, - {"arr", {10, 20, 30}}, - {"flag", true}, - {"zero", 0}}}; +const jlogic::JsonDataSource D{json{{"x", 41}, + {"y", 8}, + {"name", "amd"}, + {"nested", {{"a", {{"b", 7}}}}}, + {"arr", {10, 20, 30}}, + {"grid", {{1, 2}, {3, 4}}}, + {"rows", {{{"name", "a0"}}, {{"name", "a1"}}}}, + {"flag", true}, + {"zero", 0}}}; // Compile a rule and evaluate it against the shared document D. V eval(const json& rule) { - return jlogic::compile(rule)(D); + return jlogic::compile(rule)(D); } } // namespace @@ -153,6 +71,24 @@ TEST(JsonLogic, InlineVariables) EXPECT_EQ(eval(json({{"==", json::array({"amd", "$name"})}})), V(true)); } +TEST(JsonLogic, SubscriptIndex) +{ + // [N] subscript, stock and inline forms + EXPECT_EQ(eval(json({{"var", "arr[0]"}})), V(10)); + EXPECT_EQ(eval("$arr[2]"), V(30)); + // subscript and dotted keys mixed + EXPECT_EQ(eval("$rows[1].name"), V("a1")); + // chained subscripts into a nested array + EXPECT_EQ(eval("$grid[0][1]"), V(2)); + EXPECT_EQ(eval("$grid[1][0]"), V(3)); + // dot-form array index still resolves + EXPECT_EQ(eval("$arr.1"), V(20)); + // out-of-range, non-numeric, and subscript-on-non-array resolve to null + EXPECT_EQ(eval("$arr[9]"), V()); + EXPECT_EQ(eval("$arr[x]"), V()); + EXPECT_EQ(eval("$nested[0]"), V()); +} + TEST(JsonLogic, Arithmetic) { EXPECT_EQ(eval(json({{"+", json::array({1, 2, 3})}})), V(6)); @@ -265,20 +201,21 @@ TEST(JsonLogic, Umd0018ConstraintShapes) TEST(JsonLogic, CompileOnceReuseAcrossData) { - const auto expr = jlogic::compile(json({{"*", json::array({"$x", 2})}})); - const JsonData a{json{{"x", 3}}}; - const JsonData b{json{{"x", 10}}}; + const auto expr + = jlogic::compile(json({{"*", json::array({"$x", 2})}})); + const jlogic::JsonDataSource a{json{{"x", 3}}}; + const jlogic::JsonDataSource b{json{{"x", 10}}}; EXPECT_EQ(expr(a), V(6)); EXPECT_EQ(expr(b), V(20)); } TEST(JsonLogic, MalformedRulesThrowAtCompileTime) { - EXPECT_THROW(jlogic::compile(json({{"nope", json::array({1, 2})}})), + EXPECT_THROW(jlogic::compile(json({{"nope", json::array({1, 2})}})), jlogic::JsonLogicCompileError); - EXPECT_THROW(jlogic::compile(json({{"/", json::array({1, 2, 3})}})), + EXPECT_THROW(jlogic::compile(json({{"/", json::array({1, 2, 3})}})), jlogic::JsonLogicCompileError); - EXPECT_THROW(jlogic::compile(json({{"abs", json::array({1, 2})}})), + EXPECT_THROW(jlogic::compile(json({{"abs", json::array({1, 2})}})), jlogic::JsonLogicCompileError); } @@ -287,7 +224,7 @@ TEST(JsonLogic, VariablesCollectsReferencedPaths) using S = std::set; // Keep the Expression alive while draining the borrowed range into a set. const auto vars = [](const json& rule) { - const auto expr = jlogic::compile(rule); + const auto expr = jlogic::compile(rule); return S(expr.variables().begin(), expr.variables().end()); }; // inline vars across an op @@ -308,7 +245,8 @@ TEST(JsonLogic, VariablesCollectsReferencedPaths) TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) { // range-for yields every occurrence, duplicates included - const auto expr = jlogic::compile(json({{"+", json::array({"$x", "$x", "$y"})}})); + const auto expr + = jlogic::compile(json({{"+", json::array({"$x", "$x", "$y"})}})); std::vector seen; for(const std::string& v : expr.variables()) { @@ -319,7 +257,7 @@ TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) EXPECT_EQ(std::count(seen.begin(), seen.end(), std::string("y")), 1); // empty range: begin() == end(), the body never runs - const auto lit = jlogic::compile(json(42)); + const auto lit = jlogic::compile(json(42)); EXPECT_TRUE(lit.variables().begin() == lit.variables().end()); // composes with STL algorithms over the borrowed references @@ -331,16 +269,18 @@ TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) TEST(JsonLogic, WholeDocumentAndDynamicKeysRejected) { // whole-document references: inline sigil, empty var, empty array, null - EXPECT_THROW(jlogic::compile(json("$")), jlogic::JsonLogicCompileError); - EXPECT_THROW(jlogic::compile(json({{"var", ""}})), jlogic::JsonLogicCompileError); - EXPECT_THROW(jlogic::compile(json({{"var", json::array()}})), + EXPECT_THROW(jlogic::compile(json("$")), jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"var", ""}})), jlogic::JsonLogicCompileError); - EXPECT_THROW(jlogic::compile(json({{"var", nullptr}})), + EXPECT_THROW(jlogic::compile(json({{"var", json::array()}})), jlogic::JsonLogicCompileError); - // computed / dynamic keys (object key, or computed first array element) - EXPECT_THROW(jlogic::compile(json({{"var", {{"+", json::array({1, 2})}}}})), + EXPECT_THROW(jlogic::compile(json({{"var", nullptr}})), jlogic::JsonLogicCompileError); + // computed / dynamic keys (object key, or computed first array element) EXPECT_THROW( - jlogic::compile(json({{"var", json::array({{{"+", json::array({1, 2})}}, 0})}})), + jlogic::compile(json({{"var", {{"+", json::array({1, 2})}}}})), jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile( + json({{"var", json::array({{{"+", json::array({1, 2})}}, 0})}})), + jlogic::JsonLogicCompileError); } From 4b4813009a64e339bfd85165b28980806f8404c9 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 23 Jul 2026 18:08:15 -0600 Subject: [PATCH 36/60] Flesh out RFC 0018 schema and annotate SDPA UMD bindings RFC 0018: - Add normative Appendix A (descriptor, pattern, criteria, and expression schema with the operator reference and compile-time validation) and Appendix B (annotation-driven op-schema registry generation). - Rename the binding attribute umd_role -> umd_name; document that scalar attributes need no annotation and are still auto-bound by field name. - Make the UMD id a UUID; specify strictly in-order, no-reorder evaluation. Schemas: - Declare umd_operand / umd_result / umd_name once in data_types.fbs. - Annotate SdpaAttributes UID fields as operands/results with umd_name; unannotated fields remain scalar attributes. (cherry picked from commit e43533e5e3960f2bb1b02fb568e203f94ca48a70) (cherry picked from commit e35ed40f1b1f424b82d3c069e7cdba9c6ada284d) --- .../rfcs/0018_UniversalMatchDescriptor.md | 1124 +++++++++++------ .../flatbuffers_sdk/schemas/data_types.fbs | 9 + .../schemas/sdpa_attributes.fbs | 56 +- 3 files changed, 770 insertions(+), 419 deletions(-) diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 983dfef26068..d9619547fb90 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -12,28 +12,29 @@ ## Table of Contents 1. [Overview](#1-overview) -2. [What the UMD Replaces](#2-what-the-umd-replaces) -3. [The Matcher's Input: hipDNN's Graph Model](#3-the-matchers-input-hipdnns-graph-model) -4. [Structural Pattern](#4-structural-pattern) -5. [Symbol Binding and the Auto-Binding Formula](#5-symbol-binding-and-the-auto-binding-formula) -6. [Constraint Vocabulary](#6-constraint-vocabulary) -7. [The Shared Expression Language](#7-the-shared-expression-language) -8. [Layout and Stride-Order Constraints](#8-layout-and-stride-order-constraints) -9. [Native-Predicate Escape Hatch](#9-native-predicate-escape-hatch) -10. [Composite Constraints](#10-composite-constraints) -11. [The Matcher: Compilation, Indexing, and Caching](#11-the-matcher-compilation-indexing-and-caching) -12. [Static Matcher (Sketch)](#12-static-matcher-sketch) -13. [Arbitration](#13-arbitration) -14. [Serialization and Versioning](#14-serialization-and-versioning) -15. [Security and Hostile Input](#15-security-and-hostile-input) -16. [Observability and Diagnostics](#16-observability-and-diagnostics) -17. [Testing and Performance](#17-testing-and-performance) -18. [Migration](#18-migration) -19. [Worked Example: SDPA Forward](#19-worked-example-sdpa-forward) -20. [Risks](#20-risks) -21. [Open Questions](#21-open-questions) -22. [References and Prior Art](#22-references-and-prior-art) -23. [Glossary](#23-glossary) +2. [The Matcher's Input: hipDNN's Graph Model](#2-the-matchers-input-hipdnns-graph-model) +3. [Structural Pattern](#3-structural-pattern) +4. [Symbol Binding and the Auto-Binding Formula](#4-symbol-binding-and-the-auto-binding-formula) +5. [Constraint Vocabulary](#5-constraint-vocabulary) +6. [The Shared Expression Language](#6-the-shared-expression-language) +7. [Layout and Stride-Order Constraints](#7-layout-and-stride-order-constraints) +8. [Native-Predicate Escape Hatch](#8-native-predicate-escape-hatch) +9. [Composite Constraints](#9-composite-constraints) +10. [The Matcher: Compilation, Indexing, and Caching](#10-the-matcher-compilation-indexing-and-caching) +11. [Static Matcher (Sketch)](#11-static-matcher-sketch) +12. [Arbitration](#12-arbitration) +13. [Serialization and Versioning](#13-serialization-and-versioning) +14. [Security and Hostile Input](#14-security-and-hostile-input) +15. [Observability and Diagnostics](#15-observability-and-diagnostics) +16. [Testing and Performance](#16-testing-and-performance) +17. [Migration](#17-migration) +18. [Worked Example: SDPA Forward](#18-worked-example-sdpa-forward) +19. [Risks](#19-risks) +20. [Open Questions](#20-open-questions) +21. [References and Prior Art](#21-references-and-prior-art) +22. [Glossary](#22-glossary) +23. [Appendix A: Schema Reference](#appendix-a-schema-reference) +24. [Appendix B: Op-Schema Registry Generation](#appendix-b-op-schema-registry-generation) --- @@ -43,21 +44,23 @@ A **UMD (Universal Match Descriptor)** is the declarative data that decides whet an incoming problem graph, and, in the same pass, binds the named variables that the kernel's dispatch and workspace formulas reference. It replaces the graph half of a hand-coded `IPlanBuilder::isApplicable` check ([RFC 0017 §2](0017_UniversalKernelDescriptor.md#2-the-descriptors)). -One UMD is authored once and referenced by ID from many UKDs, so a family of near-identical kernels -shares a handful of matches rather than carrying a bespoke C++ check each. +One UMD is authored once and referenced by ID; a KDP lists a set of matcher IDs and a kernel applies +only when all pass ([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)), so a +family of near-identical kernels shares a handful of matchers rather than carrying a bespoke C++ check +each. A UMD has two parts, unchanged in intent from [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd): 1. A **structural pattern**: named operation nodes and their named operand and result edges. Because hipDNN op graphs are DAGs, the pattern is an explicit node-and-edge graph, not a nested expression. -2. A **constraint expression** over the pattern's bound variables: one JsonLogic boolean, typically an +2. A **criteria expression** over the pattern's bound variables: one JsonLogic boolean, typically an `and` of the individual tests. This document turns that frame into a concrete format and a concrete matcher. It specifies the pattern -and constraint schema, the standard formula that auto-binds every tensor and attribute of a matched op, -JsonLogic as the shared expression language (boolean constraints here, dispatch formulas in the UDD), -the layout representation as a stride-order index array, the native-predicate escape hatch, +and criteria schema, the standard formula that auto-binds every tensor and attribute of a matched op, +JsonLogic as the shared expression language (boolean criteria here, dispatch formulas in the UDD), +the layout representation as a stride-order index array, the custom-operation escape hatch, deterministic arbitration, and the compile-once matcher with its indexing and caching. The static (compile-time) matcher is sketched as options, not fully designed, in this iteration. @@ -65,57 +68,20 @@ deterministic arbitration, and the compile-once matcher with its indexing and ca | Capability | This RFC (day-one) | Deferred | |---|---|---| -| Structural pattern: op nodes, named operand/result edges, single-op and bounded fused subgraphs | Yes ([§4](#4-structural-pattern)) | None | -| Auto-binding of every operand/result tensor, its dims and strides, and every op attribute | Yes ([§5](#5-symbol-binding-and-the-auto-binding-formula)) | None | -| Constraints as one JsonLogic expression: opcode, dtype (exact/set/relation), shape/rank, symbolic-dim unification, layout, packing, attribute, use-count, cross-tensor relation, optional operand, device/arch | Yes ([§6](#6-constraint-vocabulary)) | None | -| JsonLogic as the shared expression language (UMD boolean + UDD value), `$`-variable convention | Yes ([§7](#7-the-shared-expression-language)) | New operators as needed | -| Layout as a stride-order index array, with named aliases | Yes ([§8](#8-layout-and-stride-order-constraints)) | None | -| Native-predicate escape hatch, registry-resolved | Yes ([§9](#9-native-predicate-escape-hatch)) | None | -| Composite constraints: `(A AND B) OR C` as one constraint, via JsonLogic `and`/`or`/`!`/`if` | Yes ([§10](#10-composite-constraints)) | None | -| Compile-once matcher, root-opcode index, per-plan match cache | Yes ([§11](#11-the-matcher-compilation-indexing-and-caching)) | None | -| Static (compile-time / AOT-lowered) matcher | Options sketched ([§12](#12-static-matcher-sketch)) | Full design | +| Structural pattern: op nodes, named operand/result edges, single-op and bounded fused subgraphs | Yes ([§3](#3-structural-pattern)) | None | +| Auto-binding of every operand/result tensor, its dims and strides, and every op attribute | Yes ([§4](#4-symbol-binding-and-the-auto-binding-formula)) | None | +| Criteria as one JsonLogic expression: opcode, dtype (exact/set/relation), shape/rank, divisibility, stride order, packed, attribute, graph-structure (`node_count`, `virtual`), cross-tensor relation, optional operand, device property | Yes ([§5](#5-constraint-vocabulary)) | None | +| JsonLogic as the shared expression language (UMD boolean + UDD value), `$`-variable convention | Yes ([§6](#6-the-shared-expression-language)) | New operators as needed | +| Layout as a stride-order index array, with named aliases | Yes ([§7](#7-layout-and-stride-order-constraints)) | None | +| Custom-operation (native-predicate) escape hatch, registry-resolved | Yes ([§8](#8-native-predicate-escape-hatch)) | None | +| Composite criteria: `(A AND B) OR C` as one criteria expression, via JsonLogic `and`/`or`/`!`/`if` | Yes ([§9](#9-composite-constraints)) | None | +| Compile-once matcher, root-opcode index, per-plan match cache | Yes ([§10](#10-the-matcher-compilation-indexing-and-caching)) | None | +| Static (compile-time / AOT-lowered) matcher | Options sketched ([§11](#11-static-matcher-sketch)) | Full design | | General N-ary commutative matching, unbounded variable-length chains | None | JIT follow-up ([RFC 0017 §8.3](0017_UniversalKernelDescriptor.md#83-future-jit-and-normalized-providers)) | --- -## 2. What the UMD Replaces - -Every check the UMD must express already exists in the provider as hand-written C++. Two families show -the full range: the `asm_sdpa_engine` builders inline their gates, and the `hip_mlops_engine` builders -delegate tensor-level gates to reusable `IValidator` primitives -(`dnn-providers/hip-kernel-provider/src/engines/hip_mlops_engine/plans/ApplicabilityChecks.cpp`). Each -hand-coded check kind maps to a UMD construct or, where it needs real C++, to a named native predicate. - -| Hand-written check (with a representative site) | UMD construct | -|---|---| -| Override-shape gate (`SdpaFwdPlanBuilder.cpp:177`) | implicit precondition; matcher declines override-shape graphs unless a UMD opts in ([§4](#4-structural-pattern)) | -| Device arch gate `gfx942`/`gfx950` (`SdpaFwdPlanBuilder.cpp:186`) | `device.arch` constraint ([§6](#6-constraint-vocabulary)) | -| Node-count and single-node gate (`SdpaFwdPlanBuilder.cpp:199`) | the pattern's node set is exact ([§4](#4-structural-pattern)) | -| Node attribute-type gate (`attributesType() == SdpaAttributes`) (`SdpaFwdPlanBuilder.cpp:200`) | node `op` opcode ([§4](#4-structural-pattern)) | -| Per-attribute value gates: dropout unset/zero, alibi false, padding false, `generate_stats` false (`SdpaFwdPlanBuilder.cpp:205-224`) | `attr` constraints ([§6](#6-constraint-vocabulary)) | -| Optional operand absent: `attn_mask`, `page_table_k/v` UIDs (`SdpaFwdPlanBuilder.cpp:209-215`) | optional-operand `absent` constraint ([§6](#6-constraint-vocabulary)) | -| Tensor rank == 4 (`SdpaFwdPlanBuilder.cpp:231-247`) | `rank` or an exact `shape` ([§6](#6-constraint-vocabulary)) | -| Cross-tensor dtype equality `q == k == v` (`SdpaFwdPlanBuilder.cpp:244`) | dtype relation ([§6](#6-constraint-vocabulary)) | -| Cross-tensor dim relation, head count `k.dims[1] == v.dims[1]` (`SdpaFwdPlanBuilder.cpp:251`) | symbolic-dim unification ([§5](#5-symbol-binding-and-the-auto-binding-formula)) | -| Packed-tensor and supported-layout gates (`ApplicabilityChecks.cpp:65,77`) | packing and layout constraints ([§8](#8-layout-and-stride-order-constraints)) | -| Consistent-layout across tensors (`ApplicabilityChecks.cpp:106`) | cross-tensor layout relation ([§8](#8-layout-and-stride-order-constraints)) | -| Head dim `one_of {64,128,192}` (`SdpaBwdPlanBuilder.cpp:597`) | `{"in": ["$head_dim", [64, 128, 192]]}` ([§7](#7-the-shared-expression-language)) | -| GQA divisibility `nhead_q % nhead_k == 0 && nhead_k != 0` (`SdpaBwdPlanBuilder.cpp:548`) | expression predicate, or a native predicate if fail-closed overflow matters ([§9](#9-native-predicate-escape-hatch)) | -| uint32 byte-stride-overflow guard (`SdpaFwdPlanBuilder.cpp:294`, `SdpaPlanUtils.hpp:159`) | **native predicate** ([§9](#9-native-predicate-escape-hatch)) | -| Kernel-name-key / CSV-registry table lookups (`SdpaFwdPlanBuilder.cpp:287`, three in `SdpaBwdPlanBuilder.cpp:660`) | **native predicate** (or removed once the KDP names the code object directly) ([§9](#9-native-predicate-escape-hatch)) | -| Mask classification `getMaskType` throwing on contradiction (`SdpaFwdPlanBuilder.cpp:276`) | attr constraints, with a native predicate for the contradiction check ([§9](#9-native-predicate-escape-hatch)) | -| NumPy-broadcast affine-shape compatibility (`BatchnormApplicabilityChecks.cpp:169`), normalized-dim reconciliation (`LayernormApplicabilityChecks.cpp:68`), inv_rms derived shape (`RMSnormApplicabilityChecks.cpp:106`) | **native predicate** (derived-quantity relations) ([§9](#9-native-predicate-escape-hatch)) | -| Fusion wiring: intermediate is virtual and privately used (`BatchnormPlanBuilder.cpp:184`) | `use_count` / exclusivity plus a virtual-tensor constraint ([§6](#6-constraint-vocabulary)) | - -The provider today has **no arbitration**: each engine loops its plan builders and takes the first that -returns true (`AsmSdpaEngine.cpp:32`, `HipMlopsEngine.cpp:36`), with a standing comment that this -"is wrong if we ever have more than 1 plan builder thats applicable" (`HipMlopsEngine.cpp:34`). -Correctness depends on builders being mutually exclusive by construction. The UMD makes overlap -explicit and resolves it deterministically ([§13](#13-arbitration)). - ---- - -## 3. The Matcher's Input: hipDNN's Graph Model +## 2. The Matcher's Input: hipDNN's Graph Model The matcher reads an immutable graph through the existing `IGraph` interface (`projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/flatbuffer_utilities/GraphWrapper.hpp`) @@ -133,33 +99,45 @@ operand UID of the other. To resolve a node's edges, the matcher must know the o attribute fields are operand UIDs, which are result UIDs, whether each is required or optional, and the names of the op's scalar attributes. This registry is what lets a UMD name operands and results by role (`Q`, `K`, `V`, `O`) and what powers the auto-binding formula of -[§5](#5-symbol-binding-and-the-auto-binding-formula). It is generated from the flatbuffer op schema, -so it stays in lockstep with the graph definitions rather than being hand-maintained. +[§4](#4-symbol-binding-and-the-auto-binding-formula). + +**The registry is generated from schema annotations, not name conventions.** Field-level FlatBuffers +attributes on each op's attribute table declare the binding contract next to the field it governs: +`umd_operand` / `umd_result` mark a UID field and `umd_name` names it, so SDPA's operand Q is +`q_tensor_uid: long (umd_operand, umd_name: "Q")` and its result O is `o_tensor_uid: long (umd_result, +umd_name: "O")`. Optionality is not re-annotated: a UID field's `= null` default already encodes it +(`attn_mask_tensor_uid: long = null`), and the `NodeAttributes` union already maps each opcode to its +table, so every unannotated field is a scalar attribute by elimination. A build step emits the binary +reflection schema (`graph.bfbs`, which transitively covers every attribute table; custom attributes +surface only through reflection, not the generated headers) and a generator reads each field's +attributes to emit the registry, so it stays in lockstep with the graph definitions rather than being +hand-maintained ([Appendix B](#appendix-b-op-schema-registry-generation) specifies the annotation contract, the field-classification rules, and the generation pipeline). Roles are never inferred from the `_tensor_uid` name suffix, which would misclassify +non-UID fields such as `PointwiseAttributes::axis_tensor_uid` (a plain axis index, not a tensor UID). **Tensors expose dims, strides, dtype, and a virtual flag, but no layout enum and no rank field.** `TensorAttributes` (`tensor_attributes_generated.h`) offers `dims()`, `strides()` (both nullable vectors), `data_type()`, `uid()`, and `virtual_()`. Rank is `dims()->size()`. Layout is not stored; it is derived from the stride order, which is why the UMD represents layout as a stride-order index array -([§8](#8-layout-and-stride-order-constraints)). Quantities like `head_size`, `batch`, and `num_heads` -are **not** attributes; they are specific tensor dims (for SDPA, `q.dims[3]`, `q.dims[0]`, -`q.dims[1]`). The UMD binds them as shape symbols, not as `attr` reads. (RFC 0017's illustrative -`attr: {head_size: ...}` is imprecise on this point; this RFC uses the shape-symbol form.) +([§7](#7-layout-and-stride-order-constraints)). Quantities like `head_size`, `batch`, and `num_heads` +are **not** attributes; they are specific tensor dims (for SDPA, `q.dims[3]`, `q.dims[0]`, `q.dims[1]`). +The UMD binds them as named shape dims (`$q.head_size`), not as attribute reads, matching RFC 0017's +`shape` short-hand ([§4](#4-symbol-binding-and-the-auto-binding-formula)). **Device and arch are out-of-band.** The graph carries no device identity. Arch comes from the stream -via `getDeviceString(handle.getStream())` (`HipDeviceUtils.hpp:48`), so the matcher receives the -`Handle` alongside the graph, and a `device` constraint resolves against it rather than against a graph -field. +via `getDeviceString(handle.getStream())` (`HipDeviceUtils.hpp:48`); for AOT it gates *pack selection*, +not a match criterion ([§5](#5-constraint-vocabulary)). Other device properties resolve against the +`Handle` the matcher receives alongside the graph, read as `$device.` rather than a graph field. **Graph guarantees the matcher may rely on.** Per the `IGraph` contract the graph is topologically sorted, acyclic, fully connected, and has unique tensor UIDs. The matcher builds its own -UID-to-producer and UID-to-consumers index once per graph to walk edges and to evaluate use-count -constraints, since no adjacency query is provided. +UID-to-producer and UID-to-consumers index once per graph to walk edges and reconstruct connectivity, +since no adjacency query is provided; fusion legality reads each intermediate's `virtual` flag. ![The matcher reads a UID-centric graph via an op-schema registry that reconstructs edges and auto-binds symbols](../images/umd_binding_model.svg) --- -## 4. Structural Pattern +## 3. Structural Pattern The pattern is a set of op nodes and the named edges between them and the graph's tensors. Each node declares its opcode and maps operand and result **roles** (from the op-schema registry) to pattern @@ -168,62 +146,62 @@ variables (`$q`, `$conv_out`). ```jsonc { "schema": "hipdnn.umd/v1", - "id": 1180449020, + "id": "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a", "name": "SDPA forward (d128, bf16) match", "nodes": [ - {"kind": "op", "id": "$root", "op": "sdpa_fwd", + {"kind": "op", "id": "sdpa_fwd", "op": "sdpa_fwd", "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} ], - "constraints": { /* Section 6: one JsonLogic boolean expression */ } + "criteria": { /* Section 6: one JsonLogic boolean expression */ } } ``` -- **Node identity.** Each node has a pattern-local `id` (`$root`, `$conv`, `$add`) used to target - constraints and diagnostics. It is distinct from the descriptor's global `id`. +- **Node identity.** Each node has a pattern-local `id` (`sdpa_fwd`, `conv`, `add`) used to read the + node's attributes (`$.`, [§4](#4-symbol-binding-and-the-auto-binding-formula)) and in + diagnostics. It is distinct from the descriptor's global `id`. - **Opcode.** `op` names one opcode, or `{"one_of": [...]}` for a small fixed set, or `"any"` for a wildcard node (used only inside a bounded fused pattern). - **Roles.** Keys in `operands` and `results` are op-schema role names; values are pattern variables. - A role the schema marks optional may be omitted from the map, or bound and constrained `absent` - ([§6](#6-constraint-vocabulary)). Roles not named are ignored for matching but still auto-bound - ([§5](#5-symbol-binding-and-the-auto-binding-formula)). + A role the schema marks optional is bound with a `?` suffix (`"attn_mask": "$attn_mask?"`), bound only + when the graph supplies it, and read with a default via `value_or_default` + ([§4](#4-symbol-binding-and-the-auto-binding-formula)). Roles not named are ignored for matching but + still auto-bound. - **Edges are implicit through shared variables.** Two nodes are connected when the same variable appears as a result of one and an operand of another. In the fused example below, `$conv_out` is - `$conv`'s result and `$add`'s operand, which is the edge. -- **Node set is exact by default.** A single-node pattern matches only a single-node graph, replacing - the `nodeWrappers().size() != 1` gate. A multi-node pattern matches a connected subgraph of exactly - those op nodes; whether the subgraph must be the whole graph or may be embedded is governed by the - fusion legality constraints (`use_count`) below. + `conv`'s result and `add`'s operand, which is the edge. +- **Subgraph matching by default; exact match is explicit.** The pattern matches a connected subgraph, + which may sit inside a larger graph. A prebuilt kernel that is the whole graph pins the exact op count + with `{"==": ["$graph.node_count", N]}` ([§5](#5-constraint-vocabulary)): a single-op kernel checks + `node_count == 1`, replacing the `nodeWrappers().size() != 1` gate; a fused three-op kernel checks + `node_count == 3`. Matching a pattern inside a larger graph without a fixed count is the looser JIT + mode ([RFC 0017 §8.3](0017_UniversalKernelDescriptor.md#83-future-jit-and-normalized-providers)). - **Override-shape graphs** are declined by default (mirroring today's gate); a UMD that supports them sets `"allow_override_shape": true` at the top level. **Fusion is day-one.** A multi-node pattern binds the whole fused subgraph at once and hands it to one -UKD. Legality (that the fused intermediates have no consumer outside the pattern) is expressed with -`use_count` constraints: +UKD. Legality — that the fused intermediates are absorbed into the kernel, not consumed outside it — is +expressed by requiring each intermediate `virtual` and pinning the op count with `$graph.node_count`: ```jsonc { "schema": "hipdnn.umd/v1", - "id": 6620551007, + "id": "2b7e1c44-9a0d-4f13-8c21-6e5d4a3b2c10", "name": "Conv-Bias-ReLU (NHWC, f16) match", "nodes": [ - {"kind": "op", "id": "$conv", "op": "convolution_fwd", - "operands": {"X": "$x", "W": "$w"}, "results": {"Y": "$conv_out"}}, - {"kind": "op", "id": "$add", "op": "pointwise_add", - "operands": {"A": "$conv_out", "B": "$bias"}, "results": {"Y": "$bias_out"}}, - {"kind": "op", "id": "$act", "op": "pointwise_relu", - "operands": {"A": "$bias_out"}, "results": {"Y": "$y"}} - ], - "bindings": [ - {"on": "$y", "shape": ["$batch", "$out_h", "$out_w", "$out_channels"]}, - {"on": "$bias", "shape": ["$out_channels"]} // unifies $out_channels with $y's last dim + {"kind": "op", "id": "conv", "op": "convolution_fwd", + "operands": {"X": "$x", "W": "$w"}, "results": {"Y": "$conv_out"}}, + {"kind": "op", "id": "bias", "op": "pointwise_add", + "operands": {"A": "$conv_out", "B": "$bias"}, "results": {"Y": "$bias_out"}}, + {"kind": "op", "id": "act", "op": "pointwise_relu", + "operands": {"A": "$bias_out"}, "results": {"Y": "$y"}} ], - "constraints": {"and": [ - {"==": ["$x.dtype", "FLOAT16"]}, - {"==": ["$x.stride_order", [0, 3, 1, 2]]}, - {"==": ["$y.dtype", "FLOAT16"]}, - {"==": ["$y.stride_order", [0, 3, 1, 2]]}, - {"==": ["$conv_out.use_count", 1]}, // intermediates private to the subgraph - {"==": ["$bias_out.use_count", 1]} + "criteria": {"and": [ + {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [0, 2, 3, 1]]}, "$x.packed", // NHWC + {"==": ["$y.dtype", "FLOAT16"]}, {"==": ["$y.stride_order", [0, 2, 3, 1]]}, "$y.packed", // NHWC + {"shape": ["$y", ["batch", "out_h", "out_w", "out_channels"]]}, + {"shape": ["$bias", ["out_channels"]]}, + {"==": ["$graph.node_count", 3]}, // exactly these three ops + "$conv_out.virtual", "$bias_out.virtual" // intermediates absorbed by the fused kernel ]} } ``` @@ -234,7 +212,7 @@ bounded optional slots cover the fusion cases a prebuilt kernel needs. --- -## 5. Symbol Binding and the Auto-Binding Formula +## 4. Symbol Binding and the Auto-Binding Formula Matching does double duty: it decides applicability and it binds named variables. A symbol is **declared** in the UMD, **bound** when the graph matches, and **used** by the UDD's dispatch and @@ -245,209 +223,203 @@ checked at build and at drop-in load, and a UDD that references an unbound symbo rather than failing closed later on a live graph. **Auto-binding is the default, and follows a standard formula (AICK-1698).** When a pattern names an -operand or result variable, the matcher, using the op-schema registry, automatically binds: - -- The **tensor handle** itself: `$q` (a pointer/UID reference). -- Each **dim**, addressable positionally as `$q.dims[i]` and, as authoring sugar, as `$q` (so `$q0` - is `$q.dims[0]`, `$q3` is `$q.dims[3]`). -- Each **stride**, addressable as `$q.strides[i]`. -- Derived tensor facts: `$q.rank`, `$q.dtype`, `$q.stride_order` ([§8](#8-layout-and-stride-order-constraints)), - `$q.is_virtual`, `$q.is_packed`, `$q.use_count` (consumers inside the pattern) and - `$q.external_use_count` (consumers outside it), and, for an optional operand role, `$q.present`. - -When a pattern names a node, the matcher auto-binds every scalar attribute of that op, addressable as -`$root.` (for example `$root.causal_mask`, `$root.dropout_probability`). Optional attributes carry -a presence flag, `$root..present`. The GPU is bound as `$device` with `$device.arch` -([§3](#3-the-matchers-input-hipdnns-graph-model)), resolved from the stream, not the graph. - -**A `$` marks a bound symbol.** Every reference to a bound symbol carries a leading `$`: tensor handles -(`$q`), their dims, strides, and derived facts (`$q0`, `$q.dims[2]`, `$q.rank`), named shape symbols -(`$head_size`), the node handle and its attributes (`$root`, `$root.causal_mask`), and the device -(`$device.arch`). Tokens without a `$` are literals: numbers, enum values (`"BFLOAT16"`, `"gfx942"`), -opcodes, and layout aliases. This is exactly the JsonLogic variable rule of -[§7](#7-the-shared-expression-language): a `$`-string is a variable, everything else is a literal. The -same token reads identically wherever it appears — a `bindings` `shape` list, a constraint expression, -or a native-predicate argument. - -Authors therefore get a complete, uniform symbol table for free and never hand-declare each field. A -separate top-level **`bindings`** list adds friendly names and cross-tensor unification on top; it holds -naming directives, not boolean tests, so it stays out of the `constraints` expression (which is pure -JsonLogic, [§6](#6-constraint-vocabulary)): - -```jsonc -"bindings": [ - {"on": "$q", "shape": ["$batch", "$num_heads", "$seqlen_q", "$head_size"]}, - {"on": "$k", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]} -] -``` - -- **Named shape dims.** A `shape` list names a tensor's dims and requires its rank to equal the list - length, so it doubles as a rank check. Reusing a name across tensors **unifies** it: writing - `$head_size` on both `$q` and `$k` binds one symbol and requires `$q.dims[3] == $k.dims[3]`, which the - matcher enforces as the implied JsonLogic equality — replacing the hand-coded `k.dims[1] == v.dims[1]` - relation. A dim position may be left anonymous with `"_"`. The same relation may instead be written - explicitly as a sub-expression of `constraints`; the `bindings` name is the sugar. -- **Named attributes.** Rarely needed, since `$root.` already reads them, but a `bindings` entry - may alias one for reuse in formulas. - -Symbol names are unique within a UMD across tensors, nodes, and named dims; a match/dispatch pair is -validated so that unification is consistent (a name bound to two different concrete values fails the -match, not the load). +operand or result variable, the matcher, using the op-schema registry, automatically binds it and its +fields, so authors get a complete symbol table for free and never hand-declare each field. Every field a +criteria or dispatch expression may reference falls in one of **five namespaces**, and the hipDNN schema +declares them so the interpreter fails closed on anything undeclared: + +- **Tensor** — a bound operand/result and its fields: `$q` is the whole tensor (the matched + `TensorAttributes`) and `$q.uid` its graph UID; each + dim positionally as `$q.dims[i]` and by name as `$q.` once a `shape` names it (`$q.seqlen_q`); + each stride as `$q.strides[i]`; and the derived facts `$q.rank`, `$q.dtype`, `$q.stride_order` + ([§7](#7-layout-and-stride-order-constraints)), `$q.packed`, and `$q.virtual` (an internal + intermediate between matched nodes, not a graph input or output). An optional operand also carries + `$q.present`, true only when the graph supplies it. +- **Graph** — structural facts of the matched graph, chiefly `$graph.node_count`, which pins an exact + match ([§3](#3-structural-pattern)). +- **Attributes** — a matched node's scalar attributes, named by the node's pattern `id`: an + `{"id": "sdpa_fwd"}` node exposes `$sdpa_fwd.dropout_probability`, a `{"id": "conv"}` node + `$conv.dilation`. An optional attribute carries `$sdpa_fwd..present`. +- **Kernel metadata** — `$kernel.`, the values a UKD supplies for the fields its KMD declares + (tile and vector constants, the dtype it targets, [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats)); + a matcher that reads them is evaluated per kernel ([§10](#10-the-matcher-compilation-indexing-and-caching)). +- **Device properties** — `$device.` such as `$device.lds_size` or `$device.warp_size`, for a + check like an LDS budget. Architecture is **not** here for AOT: it is a pack property gated at + selection ([§2](#2-the-matchers-input-hipdnns-graph-model)); a JIT pack may reference `$device.arch`. + +**A `$` marks a reference.** Every reference to a bound field carries a leading `$`: tensors and +their fields (`$q`, `$q.uid`, `$q.dims[2]`, `$q.rank`, `$q.seqlen_q`), a node's attributes (`$sdpa_fwd.head_size` +— the node id `sdpa_fwd` is bare, the reference carries the `$`), `$graph.node_count`, `$kernel.tile_m`, +and `$device.lds_size`. Tokens without a `$` are literals: numbers, enum values (`"BFLOAT16"`, +`"gfx942"`), opcodes, and layout aliases. This is the JsonLogic variable rule of +[§6](#6-the-shared-expression-language): a `$`-string is a variable, everything else a literal, so no +`var` wrapper is needed and the same token reads identically in a criteria expression or a +custom-operation argument. + +**Named dims via the `shape` short-hand.** `shape` is a criteria short-hand that names a tensor's dims +and pins its rank to the list length: `{"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}` +makes those dims readable as `$q.batch` … `$q.head_size` and requires `$q` to be rank 4. Names are +per-tensor, so a cross-tensor relation (same head dim across q/k) is an explicit criterion, +`{"==": ["$k.head_size", "$q.head_size"]}`, or a custom operation. A dim position may be left anonymous +with `"_"`. When rank varies (NCHW vs NCDHW), name the fixed dims and capture the variable run as one +vector, `["n", "c", "$spatial"]`, so one matcher accepts both ranks and still reaches those dims through +`all` or a product; the exact `shape` grammar is specified in [Appendix A.5](#a5-the-shape-short-hand). ![A live graph matched against a declarative pattern, auto-binding tensors, dims, strides, and attributes](../images/umd_symbol_binding.svg) --- -## 6. Constraint Vocabulary +## 5. Constraint Vocabulary -The `constraints` field is a **single JsonLogic boolean expression** evaluated over the bound symbol -table ([§5](#5-symbol-binding-and-the-auto-binding-formula), [§7](#7-the-shared-expression-language)). -It is normally an `and` of the individual tests, and reaches for `or` / `!` / `if` wherever a real -disjunction is needed ([§10](#10-composite-constraints)) — there is no list and no per-entry wrapper. -The table below is not a set of constraint *kinds* (there are none); it is the set of hand-written -checks ([§2](#2-what-the-umd-replaces)) and the JsonLogic sub-expression that expresses each, so no -check needs code a JsonLogic expression cannot state. The one residue is a **native predicate**, itself -a JsonLogic operation resolved from the provider registry ([§9](#9-native-predicate-escape-hatch)), so -it composes inside the same expression as any built-in operator. +The `criteria` field is a **single JsonLogic boolean expression** evaluated over the bound symbol table +([§4](#4-symbol-binding-and-the-auto-binding-formula), [§6](#6-the-shared-expression-language)). It is +normally an `and` of the individual tests, and reaches for `or` / `!` / `if` wherever a real +disjunction is needed ([§9](#9-composite-constraints)). The table below is not a set of criterion +*kinds* (there are none); it is the set of hand-written checks and the +JsonLogic sub-expression that expresses each, so no check needs code a JsonLogic expression cannot +state. The one residue is a **custom operation** (native predicate), itself a JsonLogic operation +resolved from the provider registry ([§8](#8-native-predicate-escape-hatch)), so it composes inside the +same expression as any built-in operator. -| Hand-written check | JsonLogic `expr` | Lowers from | +| Hand-written check | JsonLogic criterion | Lowers from | |---|---|---| -| **Opcode** | in the pattern node `op` (exact, `one_of`, `any`), not a constraint | node attribute-type gate | +| **Opcode** | in the pattern node `op` (exact, `one_of`, `any`), not a criterion | node attribute-type gate | | **Dtype (exact / set)** | `{"==": ["$q.dtype", "BFLOAT16"]}` / `{"in": ["$q.dtype", ["BFLOAT16", "FP8_E4M3"]]}` | `validateDataTypeIsSupported`, `validateFixedDataType` | | **Dtype (relation)** | `{"==": ["$k.dtype", "$q.dtype"]}` | `validateConsistentDataTypes`, `q == k == v` | -| **Rank** | `{"==": ["$q.rank", 4]}` | `validateDimensionCount`, rank == 4 | -| **Shape (exact)** | `{"==": ["$q.dims[0]", 1]}` | dim reads | -| **Shape (symbolic / unify)** | name dims in `bindings` ([§5](#5-symbol-binding-and-the-auto-binding-formula)); relate with `{"==": ["$q.dims[1]", "$k.dims[1]"]}` | cross-tensor dim relations | -| **Layout** | `{"==": ["$q.stride_order", [0, 1, 2, 3]]}` ([§8](#8-layout-and-stride-order-constraints)) | `validateSupportedLayout` | -| **Packing** | `{"==": ["$q.is_packed", true]}` | `validatePackedTensors` | +| **Rank** | `{"==": ["$q.rank", 4]}`, or a `shape` short-hand that names four dims | `validateDimensionCount`, rank == 4 | +| **Shape (bind / relate)** | `{"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}` binds `$q.`; relate with `{"==": ["$q.head_size", "$k.head_size"]}` | dim reads and cross-tensor dim relations | +| **Divisibility** | `{"divisible": [{"*": ["$y.n", "$y.ho", "$y.wo"]}, "$kernel.MPerBlock"]}` | tile-fit / GEMM-dim gates | +| **Layout** | `{"==": ["$q.stride_order", [0, 1, 2, 3]]}` ([§7](#7-layout-and-stride-order-constraints)) | `validateSupportedLayout` | +| **Packing** | `"$q.packed"` (a bound boolean) | `validatePackedTensors` | | **Cross-tensor layout** | `{"==": ["$x.stride_order", "$y.stride_order"]}` (per pair) | `validateConsistentLayouts` | -| **Attribute (value)** | `{"==": ["$root.causal_mask", false]}`; absent-or `{"or": [{"!": "$root.dropout_probability.present"}, {"==": ["$root.dropout_probability", 0.0]}]}` | per-attr value gates | -| **Attribute (one_of)** | `{"in": ["$root.head_dim", [64, 128, 192]]}` | `head_dim in {...}` | -| **Optional operand present/absent** | `{"!": "$attn_mask.present"}` (absent) / `"$bias.present"` (present) | `attn_mask_tensor_uid()` absent gate | -| **Virtual tensor** | `{"==": ["$conv_out.is_virtual", true]}` | fusion tensor-virtuality gates | -| **Use-count / exclusivity** | `{"==": ["$conv_out.use_count", 1]}` / `{"==": ["$bias_out.external_use_count", 0]}` | fusion legality checks | -| **Cross-tensor / arithmetic** | `{"==": ["$q.dims[1]", "$k.dims[2]"]}`, `{"<=": ["$q.dims[0]", 128]}`, `{"==": [{"%": ["$nhead_q", "$nhead_k"]}, 0]}` | arithmetic and comparison gates | -| **Device / arch** | `{"in": ["$device.arch", ["gfx942", "gfx950"]]}` | `getDeviceString` arch gate | -| **Native predicate** | `{"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]}` ([§9](#9-native-predicate-escape-hatch)) | overflow guards, table lookups, derived-shape relations | - -The `device` constraint gates *applicability* at match time; the per-architecture `kpack` manifest -([RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)) gates *loadability* at -install and load. Both must agree for a kernel to run. +| **Attribute (value)** | `{"==": ["$sdpa_fwd.causal_mask", false]}`; absent-or `{"or": [{"!": "$sdpa_fwd.dropout_probability.present"}, {"==": ["$sdpa_fwd.dropout_probability", 0.0]}]}` | per-attr value gates | +| **Attribute (one_of)** | `{"in": ["$sdpa_fwd.head_dim", [64, 128, 192]]}` | `head_dim in {...}` | +| **Optional operand present/absent** | operand `?` in the pattern; `{"!": "$attn_mask.present"}` (absent) / `"$bias.present"` (present) | `attn_mask_tensor_uid()` absent gate | +| **Graph structure (exact / fusion)** | `{"==": ["$graph.node_count", 3]}`, and each intermediate `"$conv_out.virtual"` | node-count gate, fusion legality | +| **Cross-tensor / arithmetic** | `{"==": ["$q.dims[1]", "$k.dims[2]"]}`, `{"<": ["$q.head_size", 129]}`, `{"==": [{"%": ["$q.num_heads", "$k.kv_heads"]}, 0]}` | arithmetic and comparison gates | +| **Device property** | `{"<=": ["$kernel.lds_per_block", "$device.lds_size"]}` (arch is a pack property, not a criterion) | LDS/occupancy budgets; `getDeviceString` arch → pack `arch` | +| **Custom operation** | `{"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]}` ([§8](#8-native-predicate-escape-hatch)) | overflow guards, table lookups, derived-shape relations | + +Architecture gates *applicability* at pack selection via the KDP `arch` property and the per-arch +`kpack` manifest ([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd), +[§9](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)), not as a match-time criterion for +AOT; other device properties (`$device.lds_size`, `$device.warp_size`) are read directly in criteria. --- -## 7. The Shared Expression Language +## 6. The Shared Expression Language -Every UMD constraint and every UDD dispatch formula is a **JsonLogic** expression. JsonLogic is a -small, well-specified JSON expression format — an operator object `{"op": [args...]}` whose arguments -are themselves expressions or literals — so descriptors stay pure data and one parser, validator, and -interpreter serve both subsystems. This RFC pins JsonLogic as the concrete form RFC 0017 §5/§6 left to -the follow-ups; the UDD follow-up adopts the same core. +Every UMD criterion and every UDD dispatch formula is a **JsonLogic** expression: a nested +`{"op": [args]}` tree whose arguments are themselves expressions or literals (the criteria-expression +form of [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)). Descriptors stay pure +data, and one parser, validator, and interpreter serve both subsystems. This RFC pins JsonLogic as the +concrete form RFC 0017 §5/§6 left to the follow-ups; the UDD follow-up adopts the same core. **The `$`-variable convention.** Stock JsonLogic reads a bound value with `{"var": "path"}`. This RFC -replaces that with a single rule: **any string that begins with `$` is a variable reference**, and its -remainder is a path into the bound symbol table ([§5](#5-symbol-binding-and-the-auto-binding-formula)) — -`"$q"`, `"$q.dims[3]"`, `"$q.rank"`, `"$q.dtype"`, `"$q.stride_order"`, `"$root.causal_mask"`, -`"$device.arch"`, `"$head_size"`. Every other JSON scalar is a literal: numbers (`128`), booleans -(`false`), and non-`$` strings (`"BFLOAT16"`, `"gfx942"`). There is no ambiguity, so no `{"var": ...}` -wrapper is used or accepted. The same token reads identically wherever it appears: inside a constraint -expression, a native-predicate argument, or a UDD formula. +replaces that with a single rule: **any string that begins with `$` is a variable reference** into the +bound symbol table ([§4](#4-symbol-binding-and-the-auto-binding-formula)) — `"$q"`, `"$q.dims[3]"`, +`"$q.seqlen_q"`, `"$q.dtype"`, `"$q.stride_order"`, `"$sdpa_fwd.head_size"`, `"$graph.node_count"`, +`"$kernel.tile_m"`, `"$device.lds_size"`. Every other JSON scalar is a literal: numbers (`128`), +booleans (`false`), and non-`$` strings (`"BFLOAT16"`, `"gfx942"`). There is no ambiguity, so no +`{"var": ...}` wrapper is used or accepted. A bare `$`-string is itself a valid boolean criterion when +it names a boolean field, e.g. `"$q.packed"`. **Two uses, one language.** -- **Constraints (UMD).** A boolean-valued expression. The comparison operators `==`, `!=`, `<`, `<=`, - `>`, `>=`, the membership operator `in`, and the boolean operators `and`, `or`, `!`, `!!`, `if` - cover the surveyed checks ([§2](#2-what-the-umd-replaces)). A constraint that does not evaluate to a - boolean is a compile error. +- **Criteria (UMD).** A boolean-valued expression that decides applicability; a criterion that does not + evaluate to a boolean is a compile error. - **Dispatch formulas (UDD).** A value-valued expression for grid, block, shared memory, and workspace - ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). It uses the arithmetic - operators below and yields a number. + ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)), yielding a number. -**Operators.** Native JsonLogic: `== != < <= > >=`, `in`, `and or ! !! if`, `+ - * / % min max`. -Value-core extensions registered as JsonLogic operations for the arithmetic hipDNN needs: `ceil_div`, -`abs`, `pow`, `log2`, `rsqrt`. Adding an operator is additive -([§14](#14-serialization-and-versioning)). There are no constraint *kinds*: the whole `constraints` -field is one JsonLogic expression, and even the native-predicate escape hatch is a registered JsonLogic -operation ([§9](#9-native-predicate-escape-hatch)), not a distinct clause. +**Operators.** Logical `and`, `or`, `!`; comparison `==`, `!=`, `<`, `<=`, `>`, `>=`; membership `in`; +the per-element `all`; arithmetic `+`, `-`, `*`, `/`, `%`; the pattern/short-hand ops `shape`, `rank`, +`divisible`, and `value_or_default` (reads a possibly-absent optional operand with a default); the +conditional `if`; and the value-core arithmetic the UDD needs (`ceil_div`, `min`, `max`, `abs`, `pow`, +`log2`, `rsqrt`). Anything the built-ins cannot express is a **custom operation** +([§8](#8-native-predicate-escape-hatch)). Adding an operator is additive +([§13](#13-serialization-and-versioning)); it never introduces a new criterion *kind*, since the whole +`criteria` field is one JsonLogic expression. ```jsonc -// constraints (boolean): each line is a sub-expression of the single top-level constraints expression -{"==": ["$q.dims[0]", 64]} // dim equality -{"==": ["$q.dims[1]", "$k.dims[2]"]} // cross-tensor dim relation -{"<=": ["$q.dims[0]", 128]} // range bound -{"in": ["$q.dims[0]", [64, 128, 256]]} // set membership -{"==": [{"%": ["$nhead_q", "$nhead_k"]}, 0]} // divisibility -{"==": ["$q.stride_order", [0, 1, 2, 3]]} // layout via stride order +// criteria (boolean): sub-expressions of the single top-level criteria expression +{"==": ["$q.head_size", 64]} // dim equality (named dim) +{"==": ["$q.dims[1]", "$k.dims[2]"]} // cross-tensor dim relation (positional) +{"<=": ["$q.head_size", 128]} // range bound +{"in": ["$q.head_size", [64, 128, 256]]} // set membership +{"divisible": ["$q.num_heads", "$k.kv_heads"]} // GQA divisibility +{"==": ["$q.stride_order", [0, 1, 2, 3]]}, "$q.packed" // layout + packed +{"==": ["$graph.node_count", 1]} // exact whole-graph match {"or": [{"!": "$attn_mask.present"}, {"==": ["$attn_mask.dtype", "$q.dtype"]}]} // composition (§10) // dispatch formulas (value), reused by the UDD -{"ceil_div": ["$seqlen_q", 16]} -{"*": [{"rsqrt": ["$head_size"]}, 1.4426950408889634]} +{"ceil_div": ["$q.seqlen_q", 16]} +{"*": [{"rsqrt": ["$q.head_size"]}, 1.4426950408889634]} ``` **Evaluation is a safe, bounded interpreter.** It fails closed on an unknown symbol, an out-of-range -axis, a type error, a non-boolean constraint result, or an invalid operation, and never executes +axis, a type error, a non-boolean criterion result, or an invalid operation, and never executes arbitrary code. Integer arithmetic uses checked-width integers and fails closed on overflow rather than -wrapping ([§15](#15-security-and-hostile-input)). The interpreter is bounded in recursion depth and step +wrapping ([§14](#14-security-and-hostile-input)). The interpreter is bounded in recursion depth and step count. Because JsonLogic is tiny and the evaluator is hand-written (no third-party parser), it is small -enough to audit and to lower into the static matcher ([§12](#12-static-matcher-sketch)). +enough to audit and to lower into the static matcher ([§11](#11-static-matcher-sketch)). --- -## 8. Layout and Stride-Order Constraints +## 7. Layout and Stride-Order Constraints hipDNN tensors store no layout enum; layout is implied by stride order -([§3](#3-the-matchers-input-hipdnns-graph-model)). The UMD therefore represents layout as an **array of +([§2](#2-the-matchers-input-hipdnns-graph-model)). The UMD therefore represents layout as an **array of dimension indexes giving the stride order**, from the slowest-varying dimension to the fastest-varying. This is the shape a matcher can check directly against `strides()` and matches how `TensorDescriptor` already precomputes `strideOrder` (`ApplicabilityChecks.cpp:17`). ```jsonc -{"==": ["$q.stride_order", [0, 1, 2, 3]]} // packed, natural order (BHSD, rank-4) -{"==": ["$x.stride_order", [0, 3, 1, 2]]} // NHWC over an NCHW logical dim order +{"==": ["$q.stride_order", [0, 1, 2, 3]]} // natural order (BHSD, rank-4) +{"==": ["$x.stride_order", [0, 2, 3, 1]]} // NHWC over an NCHW logical dim order ``` - The array is a permutation of `0..rank-1`. Entry `k` names the logical dimension that occupies stride - position `k`, so `[0,1,2,3]` is descending-stride packed and `[0,3,1,2]` places the channel dim last + position `k`, so `[0,1,2,3]` is descending-stride packed and `[0,2,3,1]` places the channel dim last (NHWC). The `axis` used everywhere else (dims, strides, `args_signature`) indexes the logical dimension order, independent of this physical layout, consistent with RFC 0017 §6. - **Named aliases** are provided for the common cases and expand to the array literal at compile time, - so `{"==": ["$q.stride_order", "nhwc"]}` compiles to a comparison against `[0, 3, 1, 2]`: - `"nchw" -> [0,1,2,3]`, `"nhwc" -> [0,3,1,2]`, `"ncdhw"`, `"ndhwc"`, `"bhsd" -> [0,1,2,3]`, + so `{"==": ["$x.stride_order", "nhwc"]}` compiles to a comparison against `[0, 2, 3, 1]`: + `"nchw" -> [0,1,2,3]`, `"nhwc" -> [0,2,3,1]`, `"ncdhw"`, `"ndhwc"`, `"bhsd" -> [0,1,2,3]`, `"contiguous"` (identity permutation for the tensor's rank). The array remains the single canonical form. The alias set matches the layouts `validateSupportedLayout` accepts today (`ApplicabilityChecks.cpp:77`). - **Cross-tensor consistency** is a JsonLogic equality between stride orders, - `{"==": ["$x.stride_order", "$y.stride_order"]}` (one per pair, joined by the top-level `and`), lowering `validateConsistentLayouts`; - layout-agnostic tensors (rank-1 scalars, pass-by-value) are skipped as they are today. -- **Packing** is the separate derived fact `$q.is_packed` (`{"==": ["$q.is_packed", true]}`), since a - supported stride order does not imply the tensor is gap-free; it lowers `validatePackedTensors`. -- `$q.stride_order` is an ordinary bound value ([§5](#5-symbol-binding-and-the-auto-binding-formula)), - so the `stride_order(q) == [0,1,2,3]` gate from AICK-1698 is expressible directly. + `{"==": ["$x.stride_order", "$y.stride_order"]}` (one per pair, joined by the top-level `and`), + lowering `validateConsistentLayouts`; layout-agnostic tensors (rank-1 scalars, pass-by-value) are + skipped as they are today. +- **Packing** is the separate bound boolean `$q.packed` (written `"$q.packed"`), since a supported + stride order does not imply the tensor is gap-free; it lowers `validatePackedTensors`. +- `$q.stride_order` is an ordinary bound value ([§4](#4-symbol-binding-and-the-auto-binding-formula)), + so a `stride_order == [0,1,2,3]` gate is expressible directly. --- -## 9. Native-Predicate Escape Hatch +## 8. Native-Predicate Escape Hatch Some checks cannot be stated with the built-in operators: they need real C++. The UMD exposes them as a -**native predicate** — a JsonLogic operation resolved from a provider-internal registry and invoked by -its registered (namespaced) name as the operator key, so it nests inside the `constraints` expression -exactly like a built-in operator and negates with `!`: +**custom operation** (a native predicate) — a JsonLogic operation resolved from a provider-internal +registry and invoked by its registered (namespaced) name as the operator key, so it nests inside the +`criteria` expression exactly like a built-in operator and negates with `!`: ```jsonc {"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} // a registered boolean operation -{"!": {"hipdnn.sdpa_mask_consistent": ["$root"]}} // negated inside the same tree +{"!": {"hipdnn.sdpa_mask_consistent": ["$sdpa_fwd"]}} // negated inside the same tree ``` The descriptor carries only the operation name and a typed argument list drawn from bound variables, -never inline code. Predicates take explicit arguments, not the whole graph, so they stay auditable and +never inline code. Custom operations take explicit arguments, not the whole graph, so they stay auditable and reusable. Both the build-time and drop-in paths resolve predicates from the same registry: a file that names only shipped predicates loads identically either way, and a file naming a predicate the running provider does not ship fails to resolve on the drop-in path. The registry a provider ships is therefore part of its published contract. -The grounded cases that need this hatch, from [§2](#2-what-the-umd-replaces): +The grounded cases that need this hatch: - **Integer-overflow guards.** `wouldFwdByteStridesFitUint32` / `byteStrideFitsU32` (`SdpaFwdPlanBuilder.cpp:294`, `SdpaPlanUtils.hpp:159`): the kernarg struct stores byte strides as @@ -460,7 +432,7 @@ The grounded cases that need this hatch, from [§2](#2-what-the-umd-replaces): - **Contradiction checks.** `getMaskType` throws when mask attributes contradict (`SdpaFwdPlanBuilder.cpp:276`); a predicate encodes "the mask attributes are self-consistent". - **GQA divisibility.** `nhead_q % nhead_k == 0 && nhead_k != 0` (`SdpaBwdPlanBuilder.cpp:548`) is - expressible with `%` in [§7](#7-the-shared-expression-language), but a native predicate is an option + expressible with `%` in [§6](#6-the-shared-expression-language), but a native predicate is an option if the zero-guard and fail-closed semantics are better centralized. **Kernel-table lookups are a migration artifact, not a lasting predicate.** The @@ -478,41 +450,49 @@ provider. --- -## 10. Composite Constraints +## 9. Composite Constraints -The `constraints` field is a single JsonLogic expression, and JsonLogic supplies boolean composition +The `criteria` field is a single JsonLogic expression, and JsonLogic supplies boolean composition natively, so `(A AND B) OR C` is stated directly in the one tree with no extra mechanism: ```jsonc -"constraints": +"criteria": {"or": [ - {"and": [{"==": ["$q.dims[0]", 64]}, {"==": ["$q.dims[1]", "$k.dims[2]"]}]}, // (A AND B) - {"==": ["$q.dims[0]", 128]} // OR C + {"and": [{"==": ["$q.head_size", 64]}, {"==": ["$q.num_heads", "$k.kv_heads"]}]}, // (A AND B) + {"==": ["$q.head_size", 128]} // OR C ]} ``` -`and`, `or`, `!`, and `if` compose the same comparison, membership, and native-predicate -([§9](#9-native-predicate-escape-hatch)) tests used everywhere else, so the deferral and reserved-shape +`and`, `or`, `!`, and `if` compose the same comparison, membership, and custom-operation +([§8](#8-native-predicate-escape-hatch)) tests used everywhere else, so the deferral and reserved-shape workaround of earlier drafts is gone: composition is day-one. A UMD whose tests all conjoin simply makes the top-level expression an `and`, the common case. General N-ary commutative matching and unbounded chains remain deferred to the JIT follow-up, as in RFC 0017 §5. --- -## 11. The Matcher: Compilation, Indexing, and Caching +## 10. The Matcher: Compilation, Indexing, and Caching A UMD is authored as text and **compiled once** into an in-memory matcher structure at provider load (or, for the drop-in path, when the bundle is scanned). Compilation resolves op-schema roles, expands -layout aliases, parses the constraints expression to an AST, and validates that every referenced symbol +layout aliases, parses the criteria expression to an AST, and validates that every referenced symbol is bound. The compiled form, not the text, is what runs against live graphs. **Root-opcode indexing.** The compiled matchers are indexed by the root node's opcode, so match cost does not grow linearly with the number of descriptors: a graph whose root op is `sdpa_fwd` only consults UMDs rooted at `sdpa_fwd`. This is the index RFC 0017 §14 calls for. Per-candidate cost (one -pass over the constraints expression) is separate from the index and bounded by short-circuit -evaluation. - -**Short-circuit evaluation.** The constraints expression evaluates with normal JsonLogic +pass over the criteria expression) is separate from the index and bounded by short-circuit evaluation. + +**Shared matchers, evaluated once.** A KDP lists a set of matcher IDs and a kernel applies only when +all pass ([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)), so matchers are the +unit of sharing and of evaluation. A matcher that reads only graph fields (Tensor / Graph / Attributes / +Device, [§4](#4-symbol-binding-and-the-auto-binding-formula)) runs **once per graph**; on failure it +prunes every pack that lists it, so the most-shared checks (dtype, layout, shape) evaluated first shrink +the candidate set fast. A matcher that also reads `$kernel.*` is the **same** matcher re-evaluated +**once per distinct metadata** (memoized), pruning per kernel rather than per pack. Results are cached +across queries. + +**Short-circuit evaluation.** The criteria expression evaluates with normal JsonLogic short-circuiting: an `and` stops at its first false sub-expression, an `or` at its first true one, so a non-match is rejected as early as the author's structure allows. The author orders the tree; the compiler may hoist a cheap, highly selective sub-expression (a scalar attribute or dtype read) ahead of @@ -526,14 +506,16 @@ workspace queries and execution, so the same graph is not re-matched across the (`AsmSdpaEngine.cpp:66,87`). The compiled matcher itself is built once and shared across plans; only the per-graph binding result is per-plan. -**Device gating short-circuits.** The `$device.arch` sub-expression is evaluated once per graph, before -per-candidate work, since arch is constant for the stream. +**Device properties are constant per stream.** A `$device.` sub-expression (for example +`$device.lds_size`) is evaluated once per graph, since device properties do not vary across a stream. +Architecture is not a match-time criterion at all for AOT: it is a pack property gated at selection +([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)). ![Compile-once pipeline: text UMD to constraint IR to a root-opcode-indexed matcher, with a per-plan bind cache](../images/umd_matcher_pipeline.svg) --- -## 12. Static Matcher (Sketch) +## 11. Static Matcher (Sketch) AICK-1698 asks whether a UMD can be pre-compiled into a static matcher that further cuts the runtime cost, while still supporting runtime (drop-in) matchers. This iteration does not commit to a design; it @@ -543,11 +525,11 @@ records the options and the constraint they must satisfy. the runtime matcher on the same UMD and graph. Build-time and drop-in descriptors run through one generic engine ([RFC 0017 §3](0017_UniversalKernelDescriptor.md#3-how-it-works)), so a kernel that is AOT-packed today and dropped in tomorrow must match the same graphs either way. Parity is testable as a -cross-path equivalence check ([§17](#17-testing-and-performance)). +cross-path equivalence check ([§16](#16-testing-and-performance)). Options, from least to most build coupling: -- **Interpreted compiled IR (baseline).** The [§11](#11-the-matcher-compilation-indexing-and-caching) +- **Interpreted compiled IR (baseline).** The [§10](#10-the-matcher-compilation-indexing-and-caching) compiled form, interpreted. No codegen; identical on both paths by construction. This is the fallback and the parity oracle. - **Bytecode / flattened decision program.** Lower the constraint IR to a compact bytecode (a linear @@ -568,7 +550,7 @@ is deferred. --- -## 13. Arbitration +## 12. Arbitration Today the first applicable plan builder wins, which is a documented latent bug when more than one matches (`HipMlopsEngine.cpp:34`). The UMD makes overlap explicit and resolves it deterministically, @@ -587,28 +569,30 @@ hazard. --- -## 14. Serialization and Versioning +## 13. Serialization and Versioning -- **Authoring form.** Human-readable, diffable JSONC (the examples here): the JsonLogic constraint - expressions of [§7](#7-the-shared-expression-language) with the `$`-variable convention. -- **Compiled form.** The compact binary the matcher runs ([§11](#11-the-matcher-compilation-indexing-and-caching)), +- **Authoring form.** Human-readable, diffable JSONC (the examples here): the JsonLogic criteria + expression of [§6](#6-the-shared-expression-language) with the `$`-variable convention. +- **Compiled form.** The compact binary the matcher runs ([§10](#10-the-matcher-compilation-indexing-and-caching)), whose concrete bytes are defined with the KDP/packaging follow-up - ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)); this RFC defines the schema - those bytes encode. -- **Schema and version.** Every UMD carries `schema: "hipdnn.umd/v1"`, a stable opaque `id`, and a + ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)); the schema those bytes + encode is specified in [Appendix A](#appendix-a-schema-reference). +- **Schema and version.** Every UMD carries `schema: "hipdnn.umd/v1"`, a stable `id` (a UUID), and a mandatory `name` for diagnostics. A UMD whose schema version is newer than the runtime understands is refused with a clear error, never silently reinterpreted, matching [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats). - **Additive evolution.** New JsonLogic operators, native predicates, and layout aliases are additive within `v1` where they do not change the meaning of an existing descriptor; anything that would reinterpret existing fields bumps the version. -- **Identity.** UMD ids are unique within the UMD kind; a match id and an engine id may collide as - strings because references are typed by field (a UKD's `match` versus `engine`). A colliding drop-in - id is logged and ignored rather than taking down the provider ([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). +- **Identity.** A UMD `id` is a **UUID**: globally unique with no central allocator, so descriptors + authored independently — including third-party drop-in files — do not collide by construction. + References are typed by field (a KDP's `matchers` versus `engine`), so a matcher id and an engine id + are never confused. A duplicate `id` seen on the drop-in path is logged and ignored rather than + taking down the provider ([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). --- -## 15. Security and Hostile Input +## 14. Security and Hostile Input On the drop-in path the loader, the matcher, and the expression interpreter parse input that may be untrusted or simply malformed, so they must be bounded and fail closed rather than crash @@ -619,29 +603,29 @@ untrusted or simply malformed, so they must be bounded and fail closed rather th provider. - **Checked arithmetic.** Shape, stride, and workspace arithmetic uses checked-width integers and fails closed on overflow rather than under-allocating or wrapping. This is the same class of bug the - `strides_fit_u32` predicate guards ([§9](#9-native-predicate-escape-hatch)). + `strides_fit_u32` predicate guards ([§8](#8-native-predicate-escape-hatch)). - **Fail-closed evaluation.** An unknown symbol, unresolved native predicate, out-of-range axis, or type error declines the match; it never matches by default. - **Quarantine, not cascade.** A bad descriptor is quarantined on load with a diagnostic; the rest load ([RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)). - **Fuzzing.** A seed corpus of UMDs and graphs plus a fuzzer over the loader, matcher, and interpreter - run under the existing ASAN build ([§17](#17-testing-and-performance)), backing the fail-closed + run under the existing ASAN build ([§16](#16-testing-and-performance)), backing the fail-closed requirement. --- -## 16. Observability and Diagnostics +## 15. Observability and Diagnostics Because matching is data-driven, it is inspectable, and the tooling is a first-class deliverable ([RFC 0017 §9](0017_UniversalKernelDescriptor.md#9-observability-and-diagnostics)). For the UMD the provider surfaces: -- **A why-not trace.** For a graph and a candidate UMD, the sub-expression of `constraints` that +- **A why-not trace.** For a graph and a candidate UMD, the sub-expression of `criteria` that evaluated false and why (the concrete values compared), so an author can see exactly which test declined. - **A binding view.** For a successful match, the full bound symbol table (tensors, dims, strides, attributes) as the UDD will see it. - **An arbitration trace.** Which UKDs matched, how the UHD scored them, and where a tie fell to - `priority` or stable `id` ([§13](#13-arbitration)). + `priority` or stable `id` ([§12](#12-arbitration)). - **Load diagnostics.** Which UMDs compiled, which were quarantined and why, and unresolved native predicates by name. @@ -649,7 +633,7 @@ These reuse the diagnostic surface RFC 0017 §9 defines rather than adding a UMD --- -## 17. Testing and Performance +## 16. Testing and Performance The UMD introduces no new testing strategy; it slots into hipDNN's existing tiers (`docs/Testing.md`, `docs/testing/TestingStrategy.md`) as RFC 0017 §12.1 requires. A UMD-backed kernel runs through the @@ -663,21 +647,21 @@ UMD-specific coverage: - **Match-equivalence against hand-written `isApplicable`.** For each converted engine, a test drives a battery of graphs (accepting and rejecting) through both the hand-written builder and the UMD and asserts identical accept/reject decisions and identical bound values. The SDPA-forward builder - ([§19](#19-worked-example-sdpa-forward)) is the first target. -- **Static/runtime parity.** The parity oracle of [§12](#12-static-matcher-sketch): the same UMD and + ([§18](#18-worked-example-sdpa-forward)) is the first target. +- **Static/runtime parity.** The parity oracle of [§11](#11-static-matcher-sketch): the same UMD and graph must decide identically on the interpreted and any lowered matcher. - **Expression-language conformance.** A table-driven suite over the JsonLogic operator set (boolean and value forms), including the AICK-1698 examples and the fail-closed cases (overflow, unknown symbol, bad axis), shared with the UDD RFC's expression tests since the language is shared. -- **Fuzzing.** The corpus and fuzzer of [§15](#15-security-and-hostile-input). +- **Fuzzing.** The corpus and fuzzer of [§14](#14-security-and-hostile-input). - **Match overhead.** Plan-time match cost is measured against the hand-written baseline as benchmarking matures (`tools/dnn-benchmarking`, [RFC 0013](0013_Autotune.md)); the compiled matcher, - root-opcode index, and per-plan cache ([§11](#11-the-matcher-compilation-indexing-and-caching)) keep + root-opcode index, and per-plan cache ([§10](#10-the-matcher-compilation-indexing-and-caching)) keep it minimal, and the cost is paid once at plan build. --- -## 18. Migration +## 17. Migration Migration follows RFC 0017 §12: no engine is converted until a UMD-backed kernel runs end to end, and a hand-written engine and its descriptor-backed replacement coexist until the generic one reaches parity @@ -685,64 +669,54 @@ on the graphs that engine covers, at which point the hand-written code is retire The **SDPA-forward** `isApplicable` (`SdpaFwdPlanBuilder.cpp:167`) is the first conversion, because it exercises nearly the whole vocabulary (opcode, attribute gates, optional-operand absence, rank, dtype -relations, symbolic-dim unification, and two native predicates) in one node. Its match-equivalence test -([§17](#17-testing-and-performance)) gates the cutover. The mlops builders follow, reusing the -`IValidator` primitives ([§2](#2-what-the-umd-replaces)) as the reference for their constraint +relations, cross-tensor dim relations, and two custom operations) in one node. Its match-equivalence +test ([§16](#16-testing-and-performance)) gates the cutover. The mlops builders follow, reusing the +`IValidator` primitives (`dnn-providers/hip-kernel-provider/src/engines/hip_mlops_engine/plans/ApplicabilityChecks.cpp`) as the reference for their criteria lowering. The kernel-table lookups dissolve into the KDP as described in -[§9](#9-native-predicate-escape-hatch). +[§8](#8-native-predicate-escape-hatch). --- -## 19. Worked Example: SDPA Forward +## 18. Worked Example: SDPA Forward The SDPA-forward check collapses into one UMD. Compared to the hand-written builder (`SdpaFwdPlanBuilder.cpp:167-296`), each C++ gate becomes a sub-expression, and only the two genuinely -non-declarative gates (uint32 stride fit, mask self-consistency) remain as native predicates. Note -`$head_size` is bound from `$q`'s dim, not read as an attribute -([§3](#3-the-matchers-input-hipdnns-graph-model)). +non-declarative gates (uint32 stride fit, mask self-consistency) remain as custom operations. Note +`$q.head_size` is bound from `$q`'s dim, not read as an attribute +([§2](#2-the-matchers-input-hipdnns-graph-model)). ```jsonc { "schema": "hipdnn.umd/v1", - "id": 1180449020, + "id": "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a", "name": "SDPA forward (d128, bf16/fp8) match", "nodes": [ - {"kind": "op", "id": "$root", "op": "sdpa_fwd", - "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} + {"kind": "op", "id": "sdpa_fwd", "op": "sdpa_fwd", + "operands": {"Q": "$q", "K": "$k", "V": "$v", + "attn_mask": "$attn_mask?", "page_table_k": "$page_table_k?", + "page_table_v": "$page_table_v?"}, // optional operands carry a ? suffix + "results": {"O": "$o"}} ], - "bindings": [ - // rank 4 and dim naming; $head_size and $kv_heads unify across q/k/v (implied ==) - {"on": "$q", "shape": ["$batch", "$num_heads", "$seqlen_q", "$head_size"]}, - {"on": "$k", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]}, - {"on": "$v", "shape": ["$batch", "$kv_heads", "$seqlen_k", "$head_size"]} - ], - "constraints": {"and": [ - {"in": ["$device.arch", ["gfx942", "gfx950"]]}, // getDeviceString gate - - // dtype: $q == $k == $v (relation), $q in the supported set - {"in": ["$q.dtype", ["BFLOAT16", "FP8_E4M3"]]}, - {"==": ["$k.dtype", "$q.dtype"]}, - {"==": ["$v.dtype", "$q.dtype"]}, - - // rank 4 on the un-named output, and the head-dim gate + "criteria": {"and": [ + {"==": ["$graph.node_count", 1]}, // exact: this kernel is the whole graph + {"in": ["$q.dtype", ["BFLOAT16", "FP8_E4M3"]]}, // supported dtype set + {"==": ["$k.dtype", "$q.dtype"]}, {"==": ["$v.dtype", "$q.dtype"]}, // q == k == v + {"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}, // rank 4, binds dims + {"shape": ["$k", ["batch", "kv_heads", "seqlen_k", "head_size"]]}, + {"shape": ["$v", ["batch", "kv_heads", "seqlen_k", "head_size"]]}, {"==": ["$o.rank", 4]}, - {"==": ["$head_size", 128]}, - - // attribute gates: no dropout, no alibi/padding mask, no stats output - {"or": [{"!": "$root.dropout_probability.present"}, {"==": ["$root.dropout_probability", 0.0]}]}, - {"==": ["$root.alibi_mask", false]}, - {"==": ["$root.padding_mask", false]}, - {"or": [{"!": "$root.generate_stats.present"}, {"==": ["$root.generate_stats", false]}]}, - - // optional operands this kernel does not support must be absent - {"!": "$attn_mask.present"}, - {"!": "$page_table_k.present"}, - {"!": "$page_table_v.present"}, - - // escape hatches: the two checks that need real C++, as registered JsonLogic operations - {"hipdnn.sdpa_mask_consistent": ["$root"]}, - {"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} + {"==": ["$q.head_size", 128]}, + {"==": ["$k.head_size", "$q.head_size"]}, // same head dim across q/k/v + {"or": [{"!": "$sdpa_fwd.dropout_probability.present"}, {"==": ["$sdpa_fwd.dropout_probability", 0.0]}]}, + {"==": ["$sdpa_fwd.alibi_mask", false]}, + {"==": ["$sdpa_fwd.padding_mask", false]}, + {"or": [{"!": "$sdpa_fwd.generate_stats.present"}, {"==": ["$sdpa_fwd.generate_stats", false]}]}, + {"!": "$attn_mask.present"}, // unsupported optional operands absent + {"!": "$page_table_k.present"}, {"!": "$page_table_v.present"}, + {"hipdnn.sdpa_mask_consistent": ["$sdpa_fwd"]}, // custom operation (needs C++) + {"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} // custom operation (needs C++) ]} + // arch is a pack property (KDP.arch), not a match criterion } ``` @@ -750,56 +724,58 @@ Mapping to the hand-written code: | Hand-written (`SdpaFwdPlanBuilder.cpp`) | UMD field | |---|---| -| `getDeviceString` gfx942/gfx950 (:186) | `device.arch` | -| `nodeWrappers().size() != 1` (:199) | single-node pattern | +| `getDeviceString` gfx942/gfx950 (:186) | pack `arch` property (KDP), gated at selection | +| `nodeWrappers().size() != 1` (:199) | `{"==": ["$graph.node_count", 1]}` | | `attributesType() != SdpaAttributes` (:200) | node `op: sdpa_fwd` | -| dropout / alibi / padding / stats gates (:205-224) | `attr` constraints | -| `attn_mask` / `page_table_*` absent (:209-215) | optional-operand `present: false` | -| rank == 4 (:231-247) | `shape` (rank 4) | +| dropout / alibi / padding / stats gates (:205-224) | `$sdpa_fwd.*` criteria | +| `attn_mask` / `page_table_*` absent (:209-215) | `?` operands + `{"!": "$attn_mask.present"}` | +| rank == 4 (:231-247) | `shape` short-hand (names four dims) | | `q == k == v` dtype (:244) | `{"==": ["$k.dtype", "$q.dtype"]}` | -| `k.dims[1] == v.dims[1]` head count (:251) | `$kv_heads` shape unification | -| head dim == 128 | `{"==": ["$head_size", 128]}` | -| `getMaskType` throw-on-contradiction (:276) | `hipdnn.sdpa_mask_consistent` predicate | -| `wouldFwdByteStridesFitUint32` (:294) | `hipdnn.strides_fit_u32` predicate | -| `getKernelNameKey` table lookup (:287) | dissolves into the KDP's Launch ([§9](#9-native-predicate-escape-hatch)) | +| `k.dims[1] == v.dims[1]` head count (:251) | shared `kv_heads` name in `$k`/`$v` `shape` | +| head dim == 128 | `{"==": ["$q.head_size", 128]}` | +| `getMaskType` throw-on-contradiction (:276) | `hipdnn.sdpa_mask_consistent` custom operation | +| `wouldFwdByteStridesFitUint32` (:294) | `hipdnn.strides_fit_u32` custom operation | +| `getKernelNameKey` table lookup (:287) | dissolves into the KDP's Launch ([§8](#8-native-predicate-escape-hatch)) | -The bound symbols (`$q..$o`, `$batch`, `$num_heads`, `$kv_heads`, `$seqlen_q`, `$seqlen_k`, `$head_size`, and -every auto-bound dim/stride) are exactly what the paired UDD's grid and argument formulas reference -([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). +The bound symbols (`$q..$o`, the named dims `$q.batch`, `$q.num_heads`, `$k.kv_heads`, `$q.seqlen_q`, +`$k.seqlen_k`, `$q.head_size`, and every auto-bound dim/stride) are exactly what the paired UDD's grid +and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)). --- -## 20. Risks +## 19. Risks - **Op-schema registry coupling.** Auto-binding depends on a registry generated from the flatbuffer op - schema ([§3](#3-the-matchers-input-hipdnns-graph-model)). If it drifts from the graph definitions, - bindings are wrong. Mitigation: generate it from the same schema, and fail closed on an unknown op or - role rather than binding a wrong field. -- **Expression language sharing.** JsonLogic is shared with the UDD ([§7](#7-the-shared-expression-language)). + schema ([§2](#2-the-matchers-input-hipdnns-graph-model)). If it drifts from the graph definitions, + bindings are wrong. Mitigation: generate it from the schema's own `umd_operand` / `umd_result` / + `umd_name` field annotations (never from field-name conventions), so a new or renamed operand carries + its binding contract in the same edit ([§2](#2-the-matchers-input-hipdnns-graph-model)), and fail + closed on an unknown op or role rather than binding a wrong field. +- **Expression language sharing.** JsonLogic is shared with the UDD ([§6](#6-the-shared-expression-language)). A change made for one subsystem can affect the other. Mitigation: one parser/validator/interpreter, - a shared conformance suite, and a clear split (constraints are boolean JsonLogic, dispatch formulas + a shared conformance suite, and a clear split (criteria are boolean JsonLogic, dispatch formulas are value JsonLogic). - **Predicate registry as contract.** Native predicates are part of the published provider contract - ([§9](#9-native-predicate-escape-hatch)); a drop-in naming an unshipped predicate fails to resolve. + ([§8](#8-native-predicate-escape-hatch)); a drop-in naming an unshipped predicate fails to resolve. Mitigation: version and document the shipped predicate set; fail closed with a clear diagnostic. -- **Match overhead.** Per-candidate evaluation of the constraints expression is unbounded by the - root-opcode index ([§11](#11-the-matcher-compilation-indexing-and-caching)). Mitigation: short-circuit - evaluation, per-plan caching, and the overhead test of [§17](#17-testing-and-performance). +- **Match overhead.** Per-candidate evaluation of the criteria expression is unbounded by the + root-opcode index ([§10](#10-the-matcher-compilation-indexing-and-caching)). Mitigation: short-circuit + evaluation, per-plan caching, and the overhead test of [§16](#16-testing-and-performance). - **Static-matcher parity.** A lowered matcher that diverges from the interpreter is a silent - correctness bug ([§12](#12-static-matcher-sketch)). Mitigation: the interpreter is the oracle and the + correctness bug ([§11](#11-static-matcher-sketch)). Mitigation: the interpreter is the oracle and the parity test gates any lowering. --- -## 21. Open Questions +## 20. Open Questions 1. **Expression language home.** This RFC pins JsonLogic as the shared expression language and defines it here because the UMD needs it first; the UDD follow-up references this section. Confirm the split rather than each subsystem defining its own, and decide where the shared conformance suite lives. 2. **GQA divisibility.** Express `nhead_q % nhead_k == 0` with the `%` operator - ([§7](#7-the-shared-expression-language)) or centralize it as a native predicate for uniform - fail-closed zero-guarding ([§9](#9-native-predicate-escape-hatch))? -3. **Static-matcher form.** Which of the [§12](#12-static-matcher-sketch) options becomes the AOT fast + ([§6](#6-the-shared-expression-language)) or centralize it as a native predicate for uniform + fail-closed zero-guarding ([§8](#8-native-predicate-escape-hatch))? +3. **Static-matcher form.** Which of the [§11](#11-static-matcher-sketch) options becomes the AOT fast path, and does it also serve drop-in via a serialized bytecode? 4. **Feature-vector overlap.** The bound symbol table overlaps the feature vector a UHD consumes ([RFC 0017 §15 Q4](0017_UniversalKernelDescriptor.md#15-open-questions)); should the UMD's bindings @@ -807,7 +783,7 @@ every auto-bound dim/stride) are exactly what the paired UDD's grid and argument --- -## 22. References and Prior Art +## 21. References and Prior Art The design borrows established ideas; none is a dependency. These informed the UMD specifically. @@ -815,41 +791,407 @@ The design borrows established ideas; none is a dependency. These informed the U |---|---| | **MLIR PDL / PDLL** | Two-layer design: a declarative pattern compiled once to a fast matcher; constraints inline on the binding; a named native-predicate escape hatch; pattern priority for arbitration | | **TVM Relax DFPattern** | Constraint vocabulary (op, dtype, symbolic shape, wildcard); dataflow use-def constraints; cross-tensor same-shape relations | -| **XLA pattern matcher** | Exact-vs-compatible equality; use-count versus user-count; layout as a distinct constraint; optional operands; capture-by-reference binding | +| **XLA pattern matcher** | Exact-vs-compatible equality; a tensor virtual/internal flag gating fusion; layout as a distinct constraint; optional operands; capture-by-reference binding | | **PyTorch Inductor / torch.library** | Node/edge pattern vocabulary; serialized precompiled patterns; duplicate-pattern detection | -| **LLVM ISel / discrimination nets** | Sharing common prefixes of many patterns rooted at one opcode into one decision structure ([§12](#12-static-matcher-sketch)) | +| **LLVM ISel / discrimination nets** | Sharing common prefixes of many patterns rooted at one opcode into one decision structure ([§11](#11-static-matcher-sketch)) | | **ONNX Runtime** | First-claim arbitration as the anti-pattern this RFC replaces with deterministic ranking; single-node versus fused-subgraph capability | --- -## 23. Glossary +## 22. Glossary -- **UMD (Universal Match Descriptor):** the declarative pattern and constraints that decide whether a - kernel applies to a graph and bind the variables its dispatch and workspace formulas use. Reused - across kernels by ID. +- **UMD (Universal Match Descriptor) / matcher:** the declarative pattern and criteria that decide + whether a kernel applies to a graph and bind the variables its dispatch and workspace formulas use. + A KDP lists a set of matcher IDs; a kernel applies only when all pass. Reused across packs by ID. - **Structural pattern:** the op nodes and the named operand/result edges of a UMD; edges are implicit - through shared pattern variables ([§4](#4-structural-pattern)). -- **Constraint expression:** the single JsonLogic boolean a UMD evaluates over its bound symbol table, - typically an `and` of the individual tests ([§6](#6-constraint-vocabulary)). + through shared pattern variables ([§3](#3-structural-pattern)). +- **Criteria expression:** the single JsonLogic `{"op": [args]}` boolean a UMD evaluates over its bound + symbol table, typically an `and` of the individual tests ([§5](#5-constraint-vocabulary)). - **Symbol lifecycle:** a name is declared in the UMD, bound when the graph matches, and used by the UDD - ([§5](#5-symbol-binding-and-the-auto-binding-formula)). + ([§4](#4-symbol-binding-and-the-auto-binding-formula)). - **Auto-binding formula:** the standard scheme that binds every operand/result tensor, its dims and strides, and every op attribute of a matched node, without hand-declaration - ([§5](#5-symbol-binding-and-the-auto-binding-formula)). + ([§4](#4-symbol-binding-and-the-auto-binding-formula)). - **Op-schema registry:** the generated table mapping each op type to its operand/result UID fields and attributes, letting the matcher reconstruct edges and auto-bind - ([§3](#3-the-matchers-input-hipdnns-graph-model)). -- **JsonLogic:** the shared expression language; boolean-valued expressions are UMD constraints and - value-valued expressions are UDD dispatch formulas, both over one `$`-variable symbol table with one - evaluator ([§7](#7-the-shared-expression-language)). + ([§2](#2-the-matchers-input-hipdnns-graph-model)). +- **JsonLogic:** the shared expression language; boolean-valued expressions are UMD criteria and + value-valued expressions are UDD dispatch formulas, both over one `$`-variable symbol table (the five + namespaces of [§4](#4-symbol-binding-and-the-auto-binding-formula)) with one evaluator + ([§6](#6-the-shared-expression-language)). - **Stride-order layout:** layout represented as an array of dimension indexes giving stride order, - since tensors carry no layout enum ([§8](#8-layout-and-stride-order-constraints)). -- **Native predicate:** the escape hatch; a registry-resolved JsonLogic operation a UMD invokes by name - for logic it cannot state with built-in operators, carried as an operation name and typed arguments, - never inline code ([§9](#9-native-predicate-escape-hatch)). -- **Composite constraint:** any boolean combination of tests within the one `constraints` expression, - written directly with JsonLogic `and` / `or` / `!` / `if` ([§10](#10-composite-constraints)). + since tensors carry no layout enum ([§7](#7-layout-and-stride-order-constraints)). +- **Custom operation (native predicate):** the escape hatch; a registry-resolved JsonLogic operation a + UMD invokes by its namespaced name for logic the built-in operators cannot state, carried as an + operation name and typed arguments, never inline code ([§8](#8-native-predicate-escape-hatch)). +- **Composite criteria:** any boolean combination of tests within the one `criteria` expression, + written directly with JsonLogic `and` / `or` / `!` / `if` ([§9](#9-composite-constraints)). - **Arbitration:** the deterministic resolution when several UKDs match: UHD score, then `priority`, - then stable `id` ([§13](#13-arbitration)). + then stable `id` ([§12](#12-arbitration)). - **Root-opcode index:** the index of compiled matchers by root opcode that keeps match cost sublinear - in descriptor count ([§11](#11-the-matcher-compilation-indexing-and-caching)). + in descriptor count ([§10](#10-the-matcher-compilation-indexing-and-caching)). + +--- + +## Appendix A: Schema Reference + +This appendix is the normative schema for `hipdnn.umd/v1`. Where the prose sections above describe a +construct by example, the grammar and tables here fix its exact form. A descriptor that violates a +**MUST** here is refused at compile ([§10](#10-the-matcher-compilation-indexing-and-caching)); it never +matches by default ([§14](#14-security-and-hostile-input)). Grammar is EBNF; quoted terminals are JSON +tokens. The value domain is `Int`, `Float`, `Bool`, `Dtype` (an enum name such as `"BFLOAT16"`), +`IntArray` (e.g. a stride order), and `Tensor` (an opaque bound operand/result); `Value` is any of +these. Criteria arithmetic is integer; `Float` arises only in UDD dispatch formulas +([§6](#6-the-shared-expression-language)). + +### A.1 Descriptor object + +| Field | Type | Required | Default | Rule | +|---|---|---|---|---| +| `schema` | string | yes | — | MUST equal `"hipdnn.umd/v1"`; a newer version is refused, never reinterpreted ([§13](#13-serialization-and-versioning)) | +| `id` | string (UUID) | yes | — | A UUID; stable, globally unique identity ([§13](#13-serialization-and-versioning)) | +| `name` | string | yes | — | Diagnostics only; not semantic | +| `allow_override_shape` | bool | no | `false` | When `false`, override-shape graphs are declined ([§3](#3-structural-pattern)) | +| `nodes` | array<Node> | yes | — | Non-empty; A.2 | +| `criteria` | Expr | yes | — | A single expression whose static type is `Bool` (A.6) | + +No other top-level keys are permitted; an unknown key is refused. + +### A.2 Node object and opcode selector + +```ebnf +node = "{" , '"kind"' , ":" , '"op"' , "," + , '"id"' , ":" , string , "," + , '"op"' , ":" , op-selector + , [ "," , '"operands"' , ":" , role-map ] + , [ "," , '"results"' , ":" , role-map ] , "}" ; +op-selector = opcode | one-of | '"any"' ; +one-of = "{" , '"one_of"' , ":" , "[" , opcode , { "," , opcode } , "]" , "}" ; +opcode = string ; (* MUST resolve in the op-schema registry *) +role-map = "{" , [ role-bind , { "," , role-bind } ] , "}" ; +role-bind = string , ":" , bind ; (* key is an op-schema role name *) +bind = '"$' , ident , [ "?" ] , '"' ; +ident = letter , { letter | digit | "_" } ; +``` + +- `kind` MUST be `"op"` (the only kind in `v1`). +- Node `id` values MUST be unique within `nodes` and disjoint from every pattern-variable name (A.4). +- Every `opcode` and every role key MUST resolve in the op-schema registry for that opcode; an unknown + opcode or role is refused ([§2](#2-the-matchers-input-hipdnns-graph-model)). +- `"any"` is legal only inside a fused pattern (a descriptor whose match pins `node_count > 1`); it + MUST NOT be the sole or root node. +- A `?` suffix marks an optional binding and is legal only for a role the registry marks optional. A + `?` on a required role, or an optional role bound without `?`, is refused. +- Roles not named in `operands`/`results` are ignored for matching but still auto-bound (A.4). + +### A.3 Roles and edges + +Role keys are drawn from the op-schema registry (`Q`, `K`, `V`, `O`, …). An edge is implicit: two nodes +are connected when the same pattern variable is a `results` value of one and an `operands` value of +another ([§3](#3-structural-pattern)). A pattern variable MUST appear as a `results` value at most once +(single producer). + +### A.4 Variable references and the five namespaces + +Any JSON string beginning with `$` is a variable reference; every other JSON scalar is a literal — no +`{"var": …}` wrapper is used or accepted ([§6](#6-the-shared-expression-language)). + +```ebnf +var-ref = "$" , ( tensor-ref | graph-ref | attr-ref | kernel-ref | device-ref | element-ref ) ; +tensor-ref = tvar , [ "." , tensor-field ] ; +tvar = ident ; (* a pattern variable bound to a Tensor *) +tensor-field = "uid" | "rank" | "dtype" | "stride_order" | "packed" | "virtual" | "present" + | "dims" , "[" , uint , "]" + | "strides" , "[" , uint , "]" + | dim-name ; (* a name a shape short-hand introduced (A.5) *) +graph-ref = "graph" , "." , "node_count" ; +attr-ref = node-id , "." , attr-name , [ "." , "present" ] ; +kernel-ref = "kernel" , "." , ident ; +device-ref = "device" , "." , ident ; +element-ref = "_" ; (* the current element inside an `all` predicate *) +uint = digit , { digit } ; +``` + +| Namespace | Root | Fields | Type | +|---|---|---|---| +| Tensor | a pattern variable (`$q`) | `uid`, `rank`, `dtype`, `stride_order`, `packed`, `virtual`, `present`, `dims[i]`, `strides[i]`, `` | `Tensor` / `Int` / `Dtype` / `IntArray` / `Bool` | +| Graph | `$graph` | `node_count` | `Int` | +| Attributes | a node `id` (`$sdpa_fwd`) | ``, `.present` | scalar / `Bool` | +| Kernel | `$kernel` | `` a UKD supplies ([RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats)) | scalar | +| Device | `$device` | `` (`lds_size`, `warp_size`, …) | scalar | + +Rules: +- `graph`, `kernel`, and `device` are **reserved** namespace roots: a `tvar` and a node `id` MUST NOT + use them. +- `present` is bound only for an optional operand/attribute; reading it on a required one is refused. +- A field access on an **absent** optional operand or attribute (e.g. `$attn_mask.dtype` when + `attn_mask` is absent) declines the whole `criteria` (fail closed, [§14](#14-security-and-hostile-input)); + guard such reads with `.present` and rely on short-circuit ordering + ([§10](#10-the-matcher-compilation-indexing-and-caching)). +- An out-of-range `dims[i]`/`strides[i]`, an unknown `dim-name`, or any unresolved reference declines + the match. + +### A.5 The `shape` short-hand + +```ebnf +shape-op = "{" , '"shape"' , ":" , "[" , tensor-var , "," , entry-list , "]" , "}" ; +tensor-var = '"$' , ident , '"' ; +entry-list = "[" , entry , { "," , entry } , "]" ; +entry = dim-name | anon | capture ; +dim-name = '"' , ident , '"' ; (* not "_", not "$"-prefixed, not a reserved tensor-field *) +anon = '"_"' ; (* positional only; reachable via dims[i], not by name *) +capture = '"$' , ident , '"' ; (* at most one; MUST be the last entry *) +``` + +`shape` is a boolean-valued short-hand that binds names and pins rank: + +- With no `capture`, the entry count pins rank exactly (`rank == count`); each `dim-name` binds + `$.` to its positional dim, and `"_"` leaves that position name-less. +- With a trailing `capture`, the fixed entries bind positionally from the front and `$` binds + the remaining dims `[k .. rank-1]` (where `k` is the capture's index) as an ordered `Vector`, so one + matcher accepts a family of ranks (`["n", "c", "$spatial"]` matches NCHW rank 4 and NCDHW rank 5). + Rank MUST be `>= count - 1`. +- A `dim-name` MUST NOT equal a reserved `tensor-field` and MUST be unique within the entry list. +- A captured `Vector` is read by `all` (A.7), by arithmetic reduction (`*`/`+`), or positionally as + `$[i]`. It is not a scalar. + +```jsonc +{"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]} // rank 4; binds four names +{"shape": ["$x", ["n", "c", "$spatial"]]} // rank >= 2; $spatial = dims[2..] +{"all": ["$spatial", {">": ["$_", 0]}]} // every captured dim positive +``` + +### A.6 Criteria expression grammar + +```ebnf +expr = literal | var-ref-str | operation ; +operation = "{" , op-key , ":" , operand , "}" ; (* exactly one key *) +operand = expr | arg-array ; (* unary sugar, or an argument list *) +arg-array = "[" , [ expr , { "," , expr } ] , "]" ; +op-key = '"' , ( builtin-op | custom-op ) , '"' ; +custom-op = ident , "." , ident , { "." , ident } ; +literal = number | boolean | non-dollar-string | json-array ; +``` + +- An object used as an expression MUST have exactly one key, the operator; multi-key objects are + refused. `non-dollar-string` is any JSON string not beginning with `$` and is a literal (enum name, + opcode, or layout alias). +- The top-level `criteria` MUST be an `expr` whose static type is `Bool`; a non-boolean result is a + compile error. A bare `var-ref-str` is a valid criterion only when it resolves to `Bool` (`"$q.packed"`). +- Evaluation is strictly in written order with short-circuit; the engine never reorders operands (A.7, + [§10](#10-the-matcher-compilation-indexing-and-caching)). + +### A.7 Operator reference + +All integer arithmetic uses checked-width integers and fails closed on overflow +([§14](#14-security-and-hostile-input)). `n-ary` means two or more arguments. + +| Operator | Arity | Argument types | Result | Notes | +|---|---|---|---|---| +| `and` | n-ary | `Bool…` | `Bool` | Short-circuits at the first `false` | +| `or` | n-ary | `Bool…` | `Bool` | Short-circuits at the first `true` | +| `!` | 1 | `Bool` | `Bool` | Unary sugar accepts a bare operand | +| `==`, `!=` | 2 | `Value, Value` (same type) | `Bool` | Compares `Int`, `Bool`, `Dtype`, and `IntArray` (stride order) | +| `<`, `<=`, `>`, `>=` | 2 | `Int, Int` | `Bool` | | +| `in` | 2 | `Value, Array` | `Bool` | Element type MUST match the array element type | +| `all` | 2 | `Vector, Bool` | `Bool` | Predicate evaluated per element; the current element is `$_` | +| `+`, `*` | n-ary | `Number…` | `Number` | | +| `-`, `/` | 2 | `Number, Number` | `Number` | `/` fails closed on a zero divisor | +| `%` | 2 | `Int, Int` | `Int` | Fails closed on a zero divisor | +| `min`, `max` | n-ary | `Number…` | `Number` | | +| `abs` | 1 | `Number` | `Number` | | +| `pow` | 2 | `Number, Number` | `Number` | | +| `ceil_div` | 2 | `Int, Int` | `Int` | Fails closed on a zero divisor | +| `log2`, `rsqrt` | 1 | `Number` | `Float` | Dispatch-formula value core; fails closed on a non-positive argument | +| `rank` | 1 | `Tensor` | `Int` | Equal to `$t.rank` | +| `divisible` | 2 | `Int, Int` | `Bool` | `true` iff divisor `!= 0` and dividend `% divisor == 0`; a zero divisor yields `false`, not an error, giving uniform fail-closed zero-guarding (resolves [Open Question 2](#20-open-questions)) | +| `value_or_default` | 2 | `Ref, Value` | `Value` | The referenced optional operand/attribute value when present, else the default literal | +| `if` | 3 or 2n+1 | `Bool, Value [, Bool, Value]… , Value` | `Value` | `if`/`elif`/`else` chain; branch results MUST share a type | +| `shape` | 2 | `Tensor, EntryList` | `Bool` | A.5 | +| `.` | per registry | per registry | `Bool` or `Value` | Custom operation, A.9 | + +Adding an operator is additive within `v1` where it does not change the meaning of an existing +descriptor ([§13](#13-serialization-and-versioning)). + +### A.8 `stride_order` values and layout aliases + +A `stride_order` comparison accepts either an integer array or an alias string; aliases expand to the +array at compile time, and the array is the single canonical form ([§7](#7-layout-and-stride-order-constraints)). +An array MUST be a permutation of `0 .. rank-1`, slowest-varying dimension first. + +| Alias | Array | | Alias | Array | +|---|---|---|---|---| +| `nchw` | `[0,1,2,3]` | | `ndhwc` | `[0,2,3,4,1]` | +| `nhwc` | `[0,2,3,1]` | | `bhsd` | `[0,1,2,3]` | +| `ncdhw` | `[0,1,2,3,4]` | | `contiguous` | identity permutation for the tensor's rank | + +### A.9 Custom operations + +A custom operation is a JsonLogic operation whose key is a namespaced name (`hipdnn.strides_fit_u32`) +resolved from the provider-internal registry ([§8](#8-native-predicate-escape-hatch)). It nests and +negates like any built-in. The descriptor carries only the name and an argument list of `expr`s (bound +variables or literals) whose types MUST match the predicate's registered signature; it never carries +inline code. A name the running provider does not ship fails to resolve and declines the match +([§8](#8-native-predicate-escape-hatch), [§14](#14-security-and-hostile-input)). + +### A.10 Compile-time validation (normative) + +A descriptor MUST pass every check below to compile; a failure refuses (and, on the drop-in path, +quarantines) the descriptor with a diagnostic ([§10](#10-the-matcher-compilation-indexing-and-caching), +[§14](#14-security-and-hostile-input)): + +1. `schema == "hipdnn.umd/v1"`, and `id` is a well-formed UUID. +2. Only the keys of A.1 at the top level; only the keys of A.2 on each node. +3. Every `opcode` and every role key resolves in the op-schema registry. +4. Every `?` suffix matches the registry's optionality for that role. +5. Node `id`s are unique and disjoint from every pattern-variable name; reserved roots + (`graph`, `kernel`, `device`) are unused as a `tvar` or node `id`. +6. Each pattern variable is a `results` value at most once (single producer, A.3). +7. At most one `shape` `capture` per tensor, in the last position; no `dim-name` collides with a + reserved `tensor-field`. +8. Every `$`-reference resolves to a bound symbol (the build and drop-in bound-symbol check of + [§4](#4-symbol-binding-and-the-auto-binding-formula)). +9. Every operation's arity and argument types satisfy A.7; every layout alias resolves. +10. `criteria` has static type `Bool`. + +--- + +## Appendix B: Op-Schema Registry Generation + +The op-schema registry ([§2](#2-the-matchers-input-hipdnns-graph-model)) is the table the matcher +consults to reconstruct a UID-centric graph's edges and to auto-bind symbols +([§4](#4-symbol-binding-and-the-auto-binding-formula), [Appendix A.4](#a4-variable-references-and-the-five-namespaces)). +It is **generated from FlatBuffers field annotations on the graph schema**, never hand-maintained and +never inferred from field-name conventions, so the binding contract for an operand lives in the same +`.fbs` edit that adds the operand and cannot silently drift from the graph definitions. + +### B.1 Attribute declarations + +Three custom attributes are declared once in the graph schema. FlatBuffers requires an `attribute` +declaration before an attribute may be used, and declared attributes are retained in the binary +reflection schema (`.bfbs`), which is what the generator reads. + +```fbs +attribute "umd_operand"; // flag: this field is an operand tensor UID +attribute "umd_result"; // flag: this field is a result tensor UID +attribute "umd_name"; // string: the name the UMD binds it by (e.g. "Q", "O") +``` + +No separate type attribute is needed: an operand or result always binds a `Tensor` — a tensor UID is +the only edge kind in a UID-centric graph ([§2](#2-the-matchers-input-hipdnns-graph-model)) — and every +scalar attribute is precisely an unannotated field (B.3). The `long` UID field type and the +`umd_operand`/`umd_result` flag together already fix what the binding is. + +### B.2 Annotated schema + +Each op's attribute table annotates its UID fields next to the field they govern. Optionality is **not** +re-annotated: a UID field's `= null` default (an optional field) already encodes it, so the generator +derives required-vs-optional from the field's presence semantics rather than a fourth attribute. + +```fbs +table SdpaAttributes { + q_tensor_uid:long (umd_operand, umd_name: "Q"); // required operand + k_tensor_uid:long (umd_operand, umd_name: "K"); + v_tensor_uid:long (umd_operand, umd_name: "V"); + o_tensor_uid:long (umd_result, umd_name: "O"); // required result + attn_mask_tensor_uid:long = null (umd_operand, umd_name: "attn_mask"); // optional operand + // ... other optional UID operands, likewise annotated ... + dropout_probability:float = null; // unannotated -> scalar attribute + alibi_mask:bool = false; // unannotated -> scalar attribute + causal_mask:bool = false; +} +``` + +### B.3 Field classification (normative) + +For every table reachable from the `NodeAttributes` union (which already maps each opcode to its +attribute table), the generator classifies each field by these rules; a violation **fails the build** +rather than emitting a wrong registry: + +| Field carries | Classified as | Requirements | +|---|---|---| +| `umd_operand` + `umd_name` | operand edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | +| `umd_result` + `umd_name` | result edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | +| neither `umd_operand` nor `umd_result` | scalar attribute, named by the field name | — | + +- **Optionality** is derived, not annotated: a field with a `= null` default (an optional UID or an + optional scalar) is optional; it supplies the `?`-binding of [Appendix A.2](#a2-node-object-and-opcode-selector) + and the `.present` field of [Appendix A.4](#a4-variable-references-and-the-five-namespaces). +- **Build errors (fail closed):** `umd_operand` and `umd_result` on the same field; `umd_name` without + either flag; `umd_operand`/`umd_result` on a non-integer field; a duplicate `umd_name` within one op; + an operand/result whose role name collides with a reserved token + ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)). +- **No name-suffix inference.** `PointwiseAttributes::axis_tensor_uid` is a plain axis index, not a + tensor UID; because it carries no `umd_operand`/`umd_result` it is classified a scalar attribute, + exactly as intended — nothing keys off the `_tensor_uid` suffix. +- **Scalar attributes need no annotation, and are still fully bound.** An annotation carries the two + facts that cannot be inferred for an *edge*: that a `long` field is a tensor UID rather than a plain + integer (`q_tensor_uid` and `left_bound` are the same type, distinguishable only by the flag), and a + role name distinct from the field name (`"Q"` vs `q_tensor_uid`). A scalar needs neither: it is a + non-edge by elimination, and its bind name *is* its field name, which reflection already reports. So + every unannotated field is auto-bound in the Attributes namespace as `$.` + ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)) with its reflected type and its + `= null`-derived optionality — `$sdpa_fwd.dropout_probability`, `$sdpa_fwd.alibi_mask`, + `$sdpa_fwd.left_bound` bind with no annotation (B.5). The one consequence is that a scalar's bind + name is coupled to its field name: renaming the field renames the symbol, whereas `umd_name` + insulates an operand from a field rename. If a scalar ever needs that insulation, the additive + extension is an optional `umd_name` on an unflagged field, used purely as a bind-name override; the + flag remains the operand/result discriminator. + +### B.4 Generation pipeline + +1. `flatc` compiles the graph schema and emits the binary reflection schema `graph.bfbs`, which + transitively covers every attribute table and carries the declared `umd_*` attributes (custom + attributes surface through reflection, not through the generated C++ headers). +2. A build-time generator loads `graph.bfbs` through the FlatBuffers reflection API, enumerates the + `NodeAttributes` union members (opcode → table), and applies the B.3 rules to each table's fields. +3. The generator emits the op-schema registry as C++ compiled into the provider: a table keyed by + opcode, each entry listing its operands and results (role name, optionality, and the typed accessor + for the UID field) and its scalar attributes (name, optionality, typed accessor). + +Reflection is used **only at build time**; the generated registry holds typed accessors, so the runtime +match path reads UID and attribute fields via `attributesAs()` +([§2](#2-the-matchers-input-hipdnns-graph-model)) with no per-match reflection cost. + +### B.5 Generated registry and how the matcher uses it + +The emitted entry for one opcode is, conceptually: + +```jsonc +// generated; illustrative shape, not a wire format +"sdpa_fwd": { + "attributes_type": "SdpaAttributes", + "operands": [ + {"role": "Q", "uid": "&SdpaAttributes::q_tensor_uid", "optional": false}, + {"role": "K", "uid": "&SdpaAttributes::k_tensor_uid", "optional": false}, + {"role": "V", "uid": "&SdpaAttributes::v_tensor_uid", "optional": false}, + {"role": "attn_mask", "uid": "&SdpaAttributes::attn_mask_tensor_uid", "optional": true} + ], + "results": [{"role": "O", "uid": "&SdpaAttributes::o_tensor_uid", "optional": false}], + "attributes": [ + {"name": "dropout_probability", "get": "&SdpaAttributes::dropout_probability", "optional": true}, + {"name": "alibi_mask", "get": "&SdpaAttributes::alibi_mask", "optional": false} + ] +} +``` + +At compile ([§10](#10-the-matcher-compilation-indexing-and-caching)) the matcher resolves each pattern +role against this entry. At match time, for a node of that opcode it reads each role's UID via the typed +accessor, resolves the UID against the per-graph UID→producer/consumer index +([§2](#2-the-matchers-input-hipdnns-graph-model)) to bind the tensor, and auto-binds the tensor's fields +and the node's scalar attributes into the five namespaces +([Appendix A.4](#a4-variable-references-and-the-five-namespaces)). An optional operand absent from the +graph binds `.present = false` and is read only through a guarded reference or `value_or_default`. + +### B.6 Lockstep and failure modes + +- **Lockstep.** Adding or renaming an operand is one `.fbs` edit that carries its `umd_operand` / + `umd_name` with it; the next build regenerates the registry, so a UMD naming that name resolves and a + UMD naming a role that no longer exists fails compile + ([Appendix A.10](#a10-compile-time-validation-normative)). +- **Unknown op or role at match compile.** The matcher fails closed: a pattern node whose opcode or + role is absent from the registry is refused, never bound to a guessed field + ([§19 Op-schema registry coupling](#19-risks)). +- **Generation is deterministic and diffable.** The generated registry is a build artifact; a schema + change that alters bindings shows up as a registry diff, which is the review surface for a binding + change. diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs index 344cc2d879d0..6c2cd378f040 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs @@ -24,3 +24,12 @@ enum DataType : byte { FP8_E4M3_FNUZ = 17, FP8_E5M2_FNUZ = 18, } + +// UMD binding annotations (RFC 0018 Appendix B). Declared once here, in the shared +// include, and applied to each op's attribute-table UID fields. `umd_operand` / +// `umd_result` mark a tensor-UID edge and `umd_name` gives the name a UMD binds it +// by; the operand/result flag already fixes that the binding is a tensor, so no +// separate type attribute is carried. Unannotated fields are scalar attributes. +attribute "umd_operand"; +attribute "umd_result"; +attribute "umd_name"; diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs index a3cc168c87b5..3c02943e0857 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs @@ -18,44 +18,44 @@ enum AttentionImplementation : byte { table SdpaAttributes { // Required input tensor UIDs - q_tensor_uid: long; - k_tensor_uid: long; - v_tensor_uid: long; + q_tensor_uid: long (umd_operand, umd_name: "Q"); + k_tensor_uid: long (umd_operand, umd_name: "K"); + v_tensor_uid: long (umd_operand, umd_name: "V"); // Required output tensor UID - o_tensor_uid: long; + o_tensor_uid: long (umd_result, umd_name: "O"); // Optional input tensor UIDs - attn_mask_tensor_uid: long = null; // Additive attention bias (Bias) - scale_tensor_uid: long = null; // Attention scale tensor (Attn_scale) - seq_len_q_tensor_uid: long = null; - seq_len_kv_tensor_uid: long = null; - seed_tensor_uid: long = null; // Dropout seed - offset_tensor_uid: long = null; // Dropout offset - dropout_mask_tensor_uid: long = null; - dropout_scale_tensor_uid: long = null; - page_table_k_tensor_uid: long = null; - page_table_v_tensor_uid: long = null; - block_mask_tensor_uid: long = null; - sink_token_tensor_uid: long = null; + attn_mask_tensor_uid: long = null (umd_operand, umd_name: "attn_mask"); // Additive attention bias (Bias) + scale_tensor_uid: long = null (umd_operand, umd_name: "scale"); // Attention scale tensor (Attn_scale) + seq_len_q_tensor_uid: long = null (umd_operand, umd_name: "seq_len_q"); + seq_len_kv_tensor_uid: long = null (umd_operand, umd_name: "seq_len_kv"); + seed_tensor_uid: long = null (umd_operand, umd_name: "seed"); // Dropout seed + offset_tensor_uid: long = null (umd_operand, umd_name: "offset"); // Dropout offset + dropout_mask_tensor_uid: long = null (umd_operand, umd_name: "dropout_mask"); + dropout_scale_tensor_uid: long = null (umd_operand, umd_name: "dropout_scale"); + page_table_k_tensor_uid: long = null (umd_operand, umd_name: "page_table_k"); + page_table_v_tensor_uid: long = null (umd_operand, umd_name: "page_table_v"); + block_mask_tensor_uid: long = null (umd_operand, umd_name: "block_mask"); + sink_token_tensor_uid: long = null (umd_operand, umd_name: "sink_token"); // FP8-specific input tensor UIDs - descale_q_tensor_uid: long = null; - descale_k_tensor_uid: long = null; - descale_v_tensor_uid: long = null; - descale_s_tensor_uid: long = null; - scale_s_tensor_uid: long = null; - scale_o_tensor_uid: long = null; + descale_q_tensor_uid: long = null (umd_operand, umd_name: "descale_q"); + descale_k_tensor_uid: long = null (umd_operand, umd_name: "descale_k"); + descale_v_tensor_uid: long = null (umd_operand, umd_name: "descale_v"); + descale_s_tensor_uid: long = null (umd_operand, umd_name: "descale_s"); + scale_s_tensor_uid: long = null (umd_operand, umd_name: "scale_s"); + scale_o_tensor_uid: long = null (umd_operand, umd_name: "scale_o"); // Optional output tensor UIDs - stats_tensor_uid: long = null; - max_tensor_uid: long = null; // Logit max (Max) - sum_exp_tensor_uid: long = null; // Score sum exp (Sum_exp) - rng_dump_tensor_uid: long = null; // RNG dump for dropout debugging + stats_tensor_uid: long = null (umd_result, umd_name: "stats"); + max_tensor_uid: long = null (umd_result, umd_name: "max"); // Logit max (Max) + sum_exp_tensor_uid: long = null (umd_result, umd_name: "sum_exp"); // Score sum exp (Sum_exp) + rng_dump_tensor_uid: long = null (umd_result, umd_name: "rng_dump"); // RNG dump for dropout debugging // FP8-specific output tensor UIDs - amax_s_tensor_uid: long = null; // Absolute max for attention scores - amax_o_tensor_uid: long = null; // Absolute max for output tensor + amax_s_tensor_uid: long = null (umd_result, umd_name: "amax_s"); // Absolute max for attention scores + amax_o_tensor_uid: long = null (umd_result, umd_name: "amax_o"); // Absolute max for output tensor // Boolean flags generate_stats: bool = null; From f5c9f80175f4a5fe6c673fe1b6f084b76774f92c Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 10:46:52 -0600 Subject: [PATCH 37/60] Added plan (cherry picked from commit 718d830339039e9aa655f9ed06f31d2d7839b5e2) (cherry picked from commit f1bd39ceb0b68bac44b7da428e7ce141acc5d6cf) --- 0018_UMD_PoC_Plan.md | 223 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 0018_UMD_PoC_Plan.md diff --git a/0018_UMD_PoC_Plan.md b/0018_UMD_PoC_Plan.md new file mode 100644 index 000000000000..f6b631874cbc --- /dev/null +++ b/0018_UMD_PoC_Plan.md @@ -0,0 +1,223 @@ +# RFC 0018 UMD — Proof-of-Concept Implementation Plan + +Scope: parse a Universal Match Descriptor JSON, build a `UniversalGraphMatcher` that +matches arbitrary graphs and returns a queryable bindings object, with automatic codegen +of the op-schema registry from the `umd_*` annotations. **SDPA forward only.** Driven by a +gtest suite (graphs built in code or from JSON→flatbuffer). Not plumbed as a provider engine. + +Proving out **the bindings** — the schema-generated, strongly-typed path→value layer that turns a +UMD variable reference (`$q.head_size`, `$sdpa_fwd.dropout_probability`, role→tensor) into a concrete +value read off the flatbuffer graph — is a first-class deliverable of this PoC, not a side effect of +matching. The phases below generate that layer at build time and prove it end-to-end. + +Source RFC: `projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md`. + +--- + +## What already exists (reuse, don't rebuild) + +- **JsonLogic** — full compile-once/evaluate-many implementation at + `dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp`. + Already implements the RFC `$`-sigil variable convention, `Value` variant + (bool/int/double/string/array), operators `==`/`!=`/`<`/`<=`/`>`/`>=`/`in`/`and`/`or`/`!`/ + arithmetic/`min`/`max`/`ceil_div`/`abs`/`pow`/`log2`/`rsqrt`/`value_or_default`/`if`, and + `compile(rule)` → `Expression` over any `getData(path)->Value` data source. + Tests already include a `Umd0018ConstraintShapes` case (`src/tests/core/TestJsonLogic.cpp`). + **Gap: no `shape` short-hand (lowered by the UMD compiler, Phase 1). The custom-operation + (native-predicate) hook is out of scope for this PoC — see Scope decisions.** +- **UMD annotations already in the schema** — `umd_operand` / `umd_result` / `umd_name` are + declared once (`flatbuffers_sdk/schemas/data_types.fbs:33-35`) and applied on every SDPA UID + field (`flatbuffers_sdk/schemas/sdpa_attributes.fbs`). But flatc runs `--cpp` only + (`cmake/flatc_flags.txt`); no `.bfbs` is emitted, so the annotations are invisible at + build/runtime. **Gap: no `.bfbs` emit, no reflection-driven registry generator.** +- **Graph model** — `IGraph` / `GraphWrapper` + (`flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/flatbuffer_utilities/GraphWrapper.hpp`), + `INodeWrapper::attributesAs()`, `getTensorMap()` (uid→`TensorAttributes*`). + JSON→flatbuffer graph via `hipdnn_flatbuffers_sdk::json::to(builder, json)` + (`utilities/json/Graph.hpp`). In-code SDPA graph builder `createValidSdpaFwdGraph(...)` in + `test_sdk/include/hipdnn_test_sdk/utilities/FlatbufferGraphTestUtils.hpp:2218`. +- **Test infra** — GoogleTest only. Provider suite `hip_kernel_provider_tests` already links + `nlohmann_json` and the SDPA `plan_utils` (`getMaskType`, `byteStrideFitsU32`). + `src/tests/engines/asm_sdpa_engine/TestSdpaFwdPlanBuilder.cpp` is the closest template. +- **Parity oracle** — `SdpaFwdPlanBuilder::isApplicable` + (`dnn-providers/hip-kernel-provider/src/engines/asm_sdpa_engine/plans/SdpaFwdPlanBuilder.cpp:167-296`): + the declarative gates the UMD must reproduce. Arch (gfx942/gfx950) and kernel-table lookup are + **out** of the UMD per RFC §5/§8 (pack property + KDP Launch). + +--- + +## Home / layout + +Provider-local, under `dnn-providers/hip-kernel-provider` (reuses JsonLogic + SDPA `plan_utils`). +Registry generation lives in `flatbuffers_sdk` (schema owner), emitting a jlogic-agnostic header +the provider consumes. Matcher is exercised directly by tests — not registered as an engine. + +--- + +## Phase 0 — Op-schema registry codegen (RFC Appendix B) + +Goal: generate, from the `umd_*` annotations, a C++ registry keyed by opcode listing operand/result +roles (name, optionality, UID reader) and scalar attributes (name, optionality, typed reader). + +1. **Emit `graph.bfbs`.** Add a dedicated flatc invocation (NOT via `flatc_flags.txt`, which is + `--cpp`-only) in `projects/hipdnn/cmake/FlatBuffersGenerate.cmake` — a sibling function + `hipdnn_generate_bfbs(graph.fbs)` running + `flatc -b --schema --bfbs-comments -I -o graph.fbs`. `graph.fbs` transitively + covers every attribute table + the `umd_*` declarations. Wire into `flatbuffers_sdk/CMakeLists.txt`. +2. **Generator tool.** Small standalone C++ executable `umd_registry_gen` linking flatbuffers + reflection (`flatbuffers/reflection.h`). Loads `graph.bfbs`, enumerates `NodeAttributes` union + members (opcode→table), applies B.3 classification per field: + - `umd_operand` + `umd_name` → operand role; `umd_result` + `umd_name` → result role + (type MUST be `long`, `umd_name` non-empty). + - neither flag → scalar attribute, bind-named by field name. + - optionality derived from `= null` default (not re-annotated). + - **Fail the build** on B.3 violations (both flags on one field, `umd_name` without a flag, + flag on a non-integer field, duplicate `umd_name` within an op, role name colliding with a + reserved token `graph`/`kernel`/`device`). +3. **Emit `op_schema_registry_generated.{h,cpp}`** into the `flatbuffers_sdk` generated include dir. + Neutral (jlogic-agnostic) shape so `flatbuffers_sdk` needn't depend on the provider: per opcode, + arrays of `{role, optional, UID-reader int64_t(const void*)}` and + `{attr-name, optional, AttrType, reader→neutral ScalarValue}`. Readers use the generated typed + accessors (`&SdpaAttributes::q_tensor_uid`, …) — **no runtime reflection** (RFC B.4). The + generator is general; SDPA is the only annotated op today, so the registry meaningfully contains + the `sdpa_fwd` entry (this is what proves the codegen). + - Each attr entry's `AttrType` maps to a jlogic `ValueKind` (Int/Float/Bool/Dtype), consumed by the + Phase 1 compiler for compile-time criteria type-checking (A.10 §9). This carries strong typing + from the schema through to expression validation and is the payoff that makes the codegen worth + more than a hand-written table. +4. Wire `umd_registry_gen` as an `add_custom_command`/target; make it a dependency of the provider. + +**Verify:** registry unit test — `sdpa_fwd` entry lists Q/K/V (required operands), O (required +result), `attn_mask`/`page_table_k`/`page_table_v` (optional operands), and scalar attrs +(`dropout_probability` optional, `alibi_mask`/`padding_mask`/`causal_mask` with correct optionality). +A B.3-violation fixture fails generation. +- **Accessor value round-trip (proves the bindings, not just the shape):** build an `SdpaAttributes` + with known UIDs and scalars; assert every generated UID reader and every scalar reader returns the + exact value. Registry *shape* (roles listed) is necessary but not sufficient — this proves the + generated accessors actually read the right field. +- **Optionality parity with the header:** the generator's `= null`-derived optionality matches the + generated header's `Optional` fields (an absent optional scalar reports not-present, a required + scalar always present). + +--- + +## Phase 1 — Bindings system + UMD compiler + matcher + +1. **`BindingContext`** (the queryable "bindings object" and the JsonLogic data source). Built per + graph after structural match; resolves all five namespaces via `getData`: + - **Tensor**: `$q` (→ uid int64), `$q.uid`, `$q.rank` (`dims->size()`), `$q.dtype` (enum name + string), `$q.dims[i]`, `$q.strides[i]`, `$q.` (via compiler-supplied name→index map), + `$q.stride_order` (IntArray, computed from strides as in `ApplicabilityChecks.cpp:17`), + `$q.packed` (bool, from dims+strides), `$q.virtual`, `$q.present` (optional operands). + Absent-optional field access → decline (fail closed, A.4). + - **Graph**: `$graph.node_count`. + - **Attributes**: `$.`, `$..present`, `$` (→ node index + for attribute reads). + - **Device**: `$device.` from `Handle` (`lds_size`, `warp_size`, …). + (Kernel namespace omitted — SDPA fwd criteria references none.) + - **Binding architecture (the two resolvers).** Edge and scalar-attr resolution use the Phase 0 + *generated* typed accessors — role→UID→tensor via `getTensorMap()`, and `$.` via the + typed reader. The Tensor-namespace path resolver (`dims[i]`/`strides[i]`/`rank`/`stride_order`/ + `packed`/`virtual`/`uid`) is hand-written **once** over the single `TensorAttributes` shape, since + there is exactly one tensor table. jlogic `Value` is the sole type-erasure boundary: strong typing + lives inside each accessor and is erased exactly once, at its return. +2. **UMD compiler.** Parse `{schema,id,name,allow_override_shape,nodes,criteria}`; validate the A.10 + subset (schema exact, single key per op-object, roles resolve in registry, `?` ↔ registry + optionality, node-id/tvar disjoint from reserved roots, single-producer per variable). + JSON→JSON lowering pass: + - Expand layout aliases (`"nhwc"`→`[0,2,3,1]`, …, A.8). + - Expand `shape` (A.5): record per-tensor `{name→index, rank, capture}`; rewrite + `{"shape":["$q",[...]]}` → `{"==":["$q.rank",N]}` (no capture) / `{">=":["$q.rank",N-1]}` + (capture). Named-dim reads then resolve through `BindingContext`. + - `jlogic::compile(loweredCriteria)`; collect referenced symbols + (`Expression::variables()`) → published bound-symbol set; validate each resolves against + registry + shape maps (RFC §4 build check). + - **Static criteria type-check (A.10 §9).** Using the generated `AttrType`/tensor-field kinds from + Phase 0, verify each operator's argument types at compile (`$q.dtype` is `Dtype`, `$q.head_size` + is `Int`, `$q.stride_order` is `IntArray`); a type mismatch is a compile error, not a runtime + decline. This is where the generated strong typing pays off in the compiler. +3. **`UniversalGraphMatcher`.** Root-opcode index (opcode→compiled UMDs). `match(handle, graph)`: + - structural: locate node(s) by opcode (SDPA: the single node), build per-graph + uid→producer/consumer index, bind roles (decline on missing required role/tensor), honor + `allow_override_shape` gate. + - construct `BindingContext`, evaluate criteria `Expression` → bool. + - return `MatchResult{ matched, bindings (BindingContext), umd_id }`. Bindings queryable + post-match (`bindings.get("$q.head_size")`). + +--- + +## Phase 2 — Test suite (the deliverable driver) + +New gtest source added via `target_sources` in +`dnn-providers/hip-kernel-provider/src/tests/engines/asm_sdpa_engine/CMakeLists.txt` (already links +nlohmann_json + plan_utils; add dep on the generated registry). + +- **Graph fixtures:** both paths — manual (`createValidSdpaFwdGraph(...)`, head_size=128, bf16) and + JSON (`json::to` from an inline JSON fixture) → `GraphWrapper`. +- **UMD fixture:** the §18 SDPA-forward descriptor JSON (head_size 128, bf16/fp8), **with the two + custom-operation gates (`strides_fit_u32`, `sdpa_mask_consistent`) removed** — the custom-op hook + is out of scope, so those gates are held constant in the graph battery, not expressed as criteria. +- **Match-equivalence (RFC §16 primary):** battery of accepting/rejecting graphs through both + `SdpaFwdPlanBuilder::isApplicable` and the matcher; assert identical accept/reject on the + declarative gates. Hold device=gfx942, a valid kernel key, and the two non-declarative gates + (uint32 stride fit, mask self-consistency) constant across the battery — arch/kernel-table are + outside the UMD per §5/§8, and the custom-op gates are out of scope; all four are held explicitly, + not silently skipped. +- **Binding proof (RFC §4/§15, primary deliverable):** the auto-binding layer, proven end-to-end: + - *Completeness (§4 "complete symbol table for free"):* after a successful match the published + symbol table contains every operand/result tensor and, for each, `uid`/`rank`/`dtype`/`dims[i]`/ + `strides[i]`/`stride_order`/`packed`/`virtual`, plus every scalar attr of the matched node. Assert + against the enumerated expected set, not a sampled few. + - *Binding view (§15):* dump the full bound table as an inspectable artifact and assert its contents. + - *Path resolution per form:* `$q`, `$q.uid`, `$q.rank`, `$q.dtype`, `$q.dims[i]` (positional), + `$q.head_size` (named), `$q.strides[i]`, `$q.stride_order`, `$q.packed`, `$q.virtual`, + `$attn_mask.present`==false, `$sdpa_fwd.dropout_probability` + `.present`, `$graph.node_count`, + `$device.lds_size` — each resolves to the expected typed value. + - *Generated-accessor correctness through a live graph:* role→UID→`TensorAttributes`→field returns + the graph's actual values, tying the Phase 0 accessors to the Phase 2 resolver on real data. + - *Bad-path fail-closed:* out-of-range `dims[i]`, unknown dim-name, absent-optional field read → + decline, never a wrong value. +- **Fail-closed:** unknown symbol, absent-optional field access, `node_count`≠1 → decline (never + match by default). +- **Compiler validation:** bad schema version, `?` on a required role, unknown role → compile error / + quarantine. + +--- + +## Scope decisions & deferrals + +- **Only SDPA forward.** Registry generator is general but only SDPA is annotated, so it is the only + exercised opcode. +- **`shape` lowered in the UMD compiler**, not JsonLogic — keeps the shared language pure; matches + RFC §10 "compile expands short-hands." +- **No JsonLogic core change.** The custom-operation (native-predicate) hook is skipped for this + PoC; the two SDPA gates that need it (uint32 stride fit, mask self-consistency) are held constant + in the test battery rather than expressed as criteria. `all` / `rank`-op / `divisible` are also + unused by SDPA fwd — deferred (add if broader §A.7 coverage is wanted). +- **Not plumbed as a provider engine** — matcher is exercised directly by tests. +- **Deferred (RFC-acknowledged):** custom-operation (native-predicate) hook and predicate registry + (§8), static/bytecode matcher (§11), fuzzing (§14), arbitration across multiple UKDs (§12), UDD + dispatch formulas, per-plan match cache, multi-node fusion patterns. +- **Two non-declarative gates out of the UMD.** `strides_fit_u32` and `sdpa_mask_consistent` + (RFC §8 custom operations) are excluded with the hook; match-equivalence covers the declarative + subset with those two gates held constant, exactly as arch/kernel-table are. + +## Primary risks + +- **Reflection availability/version** — flatbuffers 25.9.23 ships `reflection.h`; `graph.bfbs` must + compile cleanly with the pinned flatc. Verify early (Phase 0 gate). +- **Parity honesty** — the UMD deliberately omits arch, kernel-table, and (with the hook skipped) the + two custom-op gates; equivalence is over the declarative subset. Tests must fix those variables, + not fudge them. +- **`shape` name-binding ordering** — resolved by static compile-time lowering (name maps are + UMD-static, graph-independent), avoiding mutation during eval. + +## Open questions for iteration + +- Registry generator home: `flatbuffers_sdk` (schema owner, neutral output) vs. provider-local + (more contained for a PoC). Plan assumes `flatbuffers_sdk`. +- Include `all` / `divisible` / `rank`-op now for fuller A.7 coverage, or defer? +- Neutral `ScalarValue` type location for the generated registry (shared header vs. `flatbuffers_sdk`). +- Static criteria type-checking (A.10 §9) via the generated `AttrType` in the PoC, or defer to the + full matcher? Plan includes it as part of the binding proof, since it is the direct payoff of the + strongly-typed codegen. From 3445c145101c63fc43c7377a30ca7c8a8a79faaa Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 11:32:16 -0600 Subject: [PATCH 38/60] Add UMD op-schema registry codegen from FlatBuffers annotations RFC 0018 Phase 0: generate a strongly-typed op-schema registry from the schema's umd_* annotations, proving out the graph->value bindings. - Emit graph.bfbs (hipdnn_generate_bfbs) so the umd_* attributes reach a build-time generator via the reflection API; the --cpp headers drop them. - Add table-level umd_opcode (e.g. sdpa_fwd) as the opcode source of truth, alongside field-level umd_operand/umd_result/umd_name. - umd_registry_gen: reflection walk of NodeAttributes, B.3 classification, emits a header-only registry of typed operand/result UID readers and scalar attribute readers (enum->Dtype); fails closed on annotation violations. - Verify: 8 gtests (shape, optionality parity, accessor value round-trip, absent-optional, enum) plus a B.3-violation CTest. - Update RFC 0018 (umd_opcode, non-scalar-field skip, value kind) and the PoC plan to match the implementation. (cherry picked from commit e41d031be3e7aaea10d60ee74acd61afda786e7d) (cherry picked from commit bf263a0b0b894466b80fd9994df22938531e1bb6) --- .pre-commit-config.yaml | 4 +- 0018_UMD_PoC_Plan.md | 47 ++- .../hipdnn/cmake/FlatBuffersGenerate.cmake | 55 +++ .../rfcs/0018_UniversalMatchDescriptor.md | 89 ++-- .../hipdnn/flatbuffers_sdk/CMakeLists.txt | 31 ++ .../umd/OpSchemaRegistry.hpp | 110 +++++ .../umd/op_schema_registry_generated.hpp | 345 +++++++++++++++ .../flatbuffers_sdk/schemas/data_types.fbs | 12 +- .../schemas/sdpa_attributes.fbs | 2 +- .../flatbuffers_sdk/tests/CMakeLists.txt | 25 ++ .../tests/umd/TestOpSchemaRegistry.cpp | 226 ++++++++++ .../tests/umd/fixtures/bad_nonlong.fbs | 22 + .../umd_registry_gen/umd_registry_gen.cpp | 396 ++++++++++++++++++ 13 files changed, 1311 insertions(+), 53 deletions(-) create mode 100644 projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp create mode 100644 projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp create mode 100644 projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp create mode 100644 projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs create mode 100644 projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 416c8ddceb3a..180cd7293480 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,8 +44,10 @@ exclude: | \.github/workflows/.*\.lock\.yml$| # Binary files projects/hipdnn/.*\.bin$| - # Generated hipDNN FlatBuffers headers + # Generated hipDNN FlatBuffers headers (flatc output) projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/data_objects/.*_generated\.h| + # umd_registry_gen-derived op-schema registry (keep OpSchemaRegistry.hpp formatted) + projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/.*_generated\.hpp| # Reference data (auto-generated) dnn-providers/integration-tests/integration-test-bundles/.*| # Generated files (see rocrand/tools/ _generator.cpp files) diff --git a/0018_UMD_PoC_Plan.md b/0018_UMD_PoC_Plan.md index f6b631874cbc..3b5ed17d6c99 100644 --- a/0018_UMD_PoC_Plan.md +++ b/0018_UMD_PoC_Plan.md @@ -25,9 +25,10 @@ Source RFC: `projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md`. Tests already include a `Umd0018ConstraintShapes` case (`src/tests/core/TestJsonLogic.cpp`). **Gap: no `shape` short-hand (lowered by the UMD compiler, Phase 1). The custom-operation (native-predicate) hook is out of scope for this PoC — see Scope decisions.** -- **UMD annotations already in the schema** — `umd_operand` / `umd_result` / `umd_name` are - declared once (`flatbuffers_sdk/schemas/data_types.fbs:33-35`) and applied on every SDPA UID - field (`flatbuffers_sdk/schemas/sdpa_attributes.fbs`). But flatc runs `--cpp` only +- **UMD annotations already in the schema** — the table-level `umd_opcode` shorthand (e.g. + `SdpaAttributes (umd_opcode: "sdpa_fwd")`) and the field-level `umd_operand` / `umd_result` / + `umd_name` are declared once (`flatbuffers_sdk/schemas/data_types.fbs`) and applied on the SDPA + table + its UID fields (`flatbuffers_sdk/schemas/sdpa_attributes.fbs`). But flatc runs `--cpp` only (`cmake/flatc_flags.txt`); no `.bfbs` is emitted, so the annotations are invisible at build/runtime. **Gap: no `.bfbs` emit, no reflection-driven registry generator.** - **Graph model** — `IGraph` / `GraphWrapper` @@ -56,38 +57,54 @@ the provider consumes. Matcher is exercised directly by tests — not registered ## Phase 0 — Op-schema registry codegen (RFC Appendix B) -Goal: generate, from the `umd_*` annotations, a C++ registry keyed by opcode listing operand/result -roles (name, optionality, UID reader) and scalar attributes (name, optionality, typed reader). +Goal: generate, from the `umd_*` annotations, a C++ registry keyed by opcode — the table's +`umd_opcode` shorthand (e.g. `sdpa_fwd`), falling back to the `NodeAttributes` union member name when +absent. Each entry also carries the table name (diagnostics) and the integer `NodeAttributes` value +(O(1) lookup against `Node::attributes_type()`), and lists operand/result roles (name, optionality, +UID reader) and scalar attributes (name, optionality, typed reader). 1. **Emit `graph.bfbs`.** Add a dedicated flatc invocation (NOT via `flatc_flags.txt`, which is `--cpp`-only) in `projects/hipdnn/cmake/FlatBuffersGenerate.cmake` — a sibling function `hipdnn_generate_bfbs(graph.fbs)` running - `flatc -b --schema --bfbs-comments -I -o graph.fbs`. `graph.fbs` transitively - covers every attribute table + the `umd_*` declarations. Wire into `flatbuffers_sdk/CMakeLists.txt`. + `flatc -b --schema -I -o graph.fbs`; the custom `umd_*` attributes are retained in + the `.bfbs` with no extra flag. `graph.fbs` transitively covers every attribute table + the `umd_*` + declarations. Wire into `flatbuffers_sdk/CMakeLists.txt`. 2. **Generator tool.** Small standalone C++ executable `umd_registry_gen` linking flatbuffers reflection (`flatbuffers/reflection.h`). Loads `graph.bfbs`, enumerates `NodeAttributes` union - members (opcode→table), applies B.3 classification per field: + members (opcode→table); reads the table's `umd_opcode` shorthand and applies B.3 classification + per field: - `umd_operand` + `umd_name` → operand role; `umd_result` + `umd_name` → result role (type MUST be `long`, `umd_name` non-empty). - neither flag → scalar attribute, bind-named by field name. - optionality derived from `= null` default (not re-annotated). - **Fail the build** on B.3 violations (both flags on one field, `umd_name` without a flag, - flag on a non-integer field, duplicate `umd_name` within an op, role name colliding with a - reserved token `graph`/`kernel`/`device`). -3. **Emit `op_schema_registry_generated.{h,cpp}`** into the `flatbuffers_sdk` generated include dir. + flag on a non-integer field, duplicate `umd_name` within an op, a role name colliding with a + reserved token `graph`/`kernel`/`device`, or a duplicate `umd_opcode` across ops). +3. **Emit `op_schema_registry_generated.hpp`** (header-only: inline per-op tables + inline lookup + functions, so `flatbuffers_sdk` stays INTERFACE-only) into the committed `flatbuffers_sdk` include + dir (`include/hipdnn_flatbuffers_sdk/umd/`). The neutral types live in a hand-written + `umd/OpSchemaRegistry.hpp`. Neutral (jlogic-agnostic) shape so `flatbuffers_sdk` needn't depend on the provider: per opcode, arrays of `{role, optional, UID-reader int64_t(const void*)}` and `{attr-name, optional, AttrType, reader→neutral ScalarValue}`. Readers use the generated typed accessors (`&SdpaAttributes::q_tensor_uid`, …) — **no runtime reflection** (RFC B.4). The - generator is general; SDPA is the only annotated op today, so the registry meaningfully contains - the `sdpa_fwd` entry (this is what proves the codegen). + generator is general (it emits an entry for every `NodeAttributes` member); SDPA is the only + annotated op today, so the `SdpaAttributes` entry is the one carrying edges — that entry proves the + codegen. Each entry carries its `umd_opcode` shorthand (fallback: table name), the table name, and + the integer `NodeAttributes` value, so the matcher resolves it by `Node::attributes_type()` (O(1)), + and the UMD compiler resolves a node `op: "sdpa_fwd"` by the shorthand. + - Enum-typed scalar attributes surface as `AttrType::Dtype` carrying the enum-value name string + (via the generated `EnumName…`). Unannotated non-scalar fields (vectors/sub-tables) are not UMD + scalars and are skipped; SDPA's attribute table is all UID + scalar, so it is fully covered. - Each attr entry's `AttrType` maps to a jlogic `ValueKind` (Int/Float/Bool/Dtype), consumed by the Phase 1 compiler for compile-time criteria type-checking (A.10 §9). This carries strong typing from the schema through to expression validation and is the payoff that makes the codegen worth more than a hand-written table. -4. Wire `umd_registry_gen` as an `add_custom_command`/target; make it a dependency of the provider. +4. Wire `umd_registry_gen` as an `add_custom_command`/target; make it a dependency of + `hipdnn_flatbuffers_sdk` (the provider consumes the header transitively), gated on + `HIPDNN_GENERATE_SDK_HEADERS` (default ON) like the existing flatc header generation. -**Verify:** registry unit test — `sdpa_fwd` entry lists Q/K/V (required operands), O (required +**Verify:** registry unit test — `SdpaAttributes` entry lists Q/K/V (required operands), O (required result), `attn_mask`/`page_table_k`/`page_table_v` (optional operands), and scalar attrs (`dropout_probability` optional, `alibi_mask`/`padding_mask`/`causal_mask` with correct optionality). A B.3-violation fixture fails generation. diff --git a/projects/hipdnn/cmake/FlatBuffersGenerate.cmake b/projects/hipdnn/cmake/FlatBuffersGenerate.cmake index dc6f40d2f482..f461fe7044b6 100644 --- a/projects/hipdnn/cmake/FlatBuffersGenerate.cmake +++ b/projects/hipdnn/cmake/FlatBuffersGenerate.cmake @@ -148,3 +148,58 @@ function(hipdnn_generate_flatbuffer_headers) add_dependencies(${ARG_TARGET} ${_gen_target_name}) endfunction() + +# Generate a FlatBuffers binary reflection schema (.bfbs) from one .fbs schema. +# Unlike the C++ header path (flatc --cpp), the reflection schema retains custom +# field attributes (umd_operand / umd_result / umd_name), which the +# umd_registry_gen tool reads via the FlatBuffers reflection API to emit the UMD +# op-schema registry (RFC 0018 Appendix B). flatc writes .bfbs into +# the -o directory; OUTPUT MUST name that file. +# Usage: +# hipdnn_generate_bfbs( +# SCHEMA schemas/graph.fbs +# SCHEMAS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/schemas +# OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/umd/graph.bfbs +# ) +function(hipdnn_generate_bfbs) + set(_one_value_args SCHEMA SCHEMAS_DIR OUTPUT) + cmake_parse_arguments(ARG "" "${_one_value_args}" "" ${ARGN}) + + foreach(_required SCHEMA SCHEMAS_DIR OUTPUT) + if(NOT ARG_${_required}) + message(FATAL_ERROR "hipdnn_generate_bfbs: missing required argument ${_required}") + endif() + endforeach() + + if(IS_ABSOLUTE "${ARG_SCHEMA}") + set(_schema_path "${ARG_SCHEMA}") + else() + set(_schema_path "${CMAKE_CURRENT_SOURCE_DIR}/${ARG_SCHEMA}") + endif() + + get_filename_component(_out_dir "${ARG_OUTPUT}" DIRECTORY) + + _hipdnn_resolve_flatc_command(_flatc_command _flatc_dependency) + + # graph.fbs pulls in the other schemas via `include`; flatc reads them all, so + # the .bfbs must regenerate when ANY schema in the include dir changes (a + # DEPENDS on graph.fbs alone would miss edits to included tables/attributes). + # CONFIGURE_DEPENDS re-runs the glob when a schema is added or removed. + file(GLOB _schema_deps CONFIGURE_DEPENDS "${ARG_SCHEMAS_DIR}/*.fbs") + set(_depends "${_schema_path}" ${_schema_deps}) + if(_flatc_dependency) + list(PREPEND _depends ${_flatc_dependency}) + endif() + + add_custom_command( + OUTPUT "${ARG_OUTPUT}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_out_dir}" + COMMAND "${_flatc_command}" -b --schema + -o "${_out_dir}" + -I "${ARG_SCHEMAS_DIR}" + "${_schema_path}" + DEPENDS ${_depends} + COMMENT "Generating reflection schema ${ARG_OUTPUT}" + VERBATIM + ) +endfunction() diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index d9619547fb90..05ecb745249f 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -101,13 +101,18 @@ names of the op's scalar attributes. This registry is what lets a UMD name opera role (`Q`, `K`, `V`, `O`) and what powers the auto-binding formula of [§4](#4-symbol-binding-and-the-auto-binding-formula). -**The registry is generated from schema annotations, not name conventions.** Field-level FlatBuffers -attributes on each op's attribute table declare the binding contract next to the field it governs: -`umd_operand` / `umd_result` mark a UID field and `umd_name` names it, so SDPA's operand Q is -`q_tensor_uid: long (umd_operand, umd_name: "Q")` and its result O is `o_tensor_uid: long (umd_result, -umd_name: "O")`. Optionality is not re-annotated: a UID field's `= null` default already encodes it -(`attn_mask_tensor_uid: long = null`), and the `NodeAttributes` union already maps each opcode to its -table, so every unannotated field is a scalar attribute by elimination. A build step emits the binary +**The registry is generated from schema annotations, not name conventions.** A table-level FlatBuffers +attribute names the op, and field-level attributes on each op's attribute table declare the binding +contract next to the field they govern. `umd_opcode` on the attribute table gives the op's UMD-facing +opcode (`SdpaAttributes (umd_opcode: "sdpa_fwd")`); a UMD node's `op` names it, and the registry keys +on it (falling back to the table type name when the attribute is absent), so the schema is the single +source of truth for the opcode rather than an ad-hoc string. `umd_operand` / `umd_result` mark a UID +field and `umd_name` names it, so SDPA's operand Q is `q_tensor_uid: long (umd_operand, umd_name: "Q")` +and its result O is `o_tensor_uid: long (umd_result, umd_name: "O")`. Optionality is not re-annotated: +a UID field's `= null` default already encodes it (`attn_mask_tensor_uid: long = null`), and the +`NodeAttributes` union already maps each opcode to its table, so every unannotated *scalar* field is a +scalar attribute by elimination (unannotated non-scalar fields — vectors, sub-tables — are not +bindable scalars and are skipped). A build step emits the binary reflection schema (`graph.bfbs`, which transitively covers every attribute table; custom attributes surface only through reflection, not the generated headers) and a generator reads each field's attributes to emit the registry, so it stays in lockstep with the graph definitions rather than being @@ -159,8 +164,10 @@ variables (`$q`, `$conv_out`). - **Node identity.** Each node has a pattern-local `id` (`sdpa_fwd`, `conv`, `add`) used to read the node's attributes (`$.`, [§4](#4-symbol-binding-and-the-auto-binding-formula)) and in diagnostics. It is distinct from the descriptor's global `id`. -- **Opcode.** `op` names one opcode, or `{"one_of": [...]}` for a small fixed set, or `"any"` for a - wildcard node (used only inside a bounded fused pattern). +- **Opcode.** `op` names one opcode — the op's `umd_opcode` shorthand from the schema + ([§2](#2-the-matchers-input-hipdnns-graph-model), [Appendix B](#appendix-b-op-schema-registry-generation)), + e.g. `sdpa_fwd` — or `{"one_of": [...]}` for a small fixed set, or `"any"` for a wildcard node (used + only inside a bounded fused pattern). - **Roles.** Keys in `operands` and `results` are op-schema role names; values are pattern variables. A role the schema marks optional is bound with a `?` suffix (`"attn_mask": "$attn_mask?"`), bound only when the graph supplies it, and read with a default via `value_or_default` @@ -747,8 +754,9 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m - **Op-schema registry coupling.** Auto-binding depends on a registry generated from the flatbuffer op schema ([§2](#2-the-matchers-input-hipdnns-graph-model)). If it drifts from the graph definitions, - bindings are wrong. Mitigation: generate it from the schema's own `umd_operand` / `umd_result` / - `umd_name` field annotations (never from field-name conventions), so a new or renamed operand carries + bindings are wrong. Mitigation: generate it from the schema's own `umd_opcode` table attribute and + `umd_operand` / `umd_result` / `umd_name` field annotations (never from field-name conventions), so a + new or renamed operand carries its binding contract in the same edit ([§2](#2-the-matchers-input-hipdnns-graph-model)), and fail closed on an unknown op or role rather than binding a wrong field. - **Expression language sharing.** JsonLogic is shared with the UDD ([§6](#6-the-shared-expression-language)). @@ -1067,20 +1075,23 @@ never inferred from field-name conventions, so the binding contract for an opera ### B.1 Attribute declarations -Three custom attributes are declared once in the graph schema. FlatBuffers requires an `attribute` -declaration before an attribute may be used, and declared attributes are retained in the binary -reflection schema (`.bfbs`), which is what the generator reads. +Four custom attributes are declared once in the graph schema. FlatBuffers requires an `attribute` +declaration before an attribute may be used, and declared attributes — on a table or on a field — are +retained in the binary reflection schema (`.bfbs`), which is what the generator reads. ```fbs -attribute "umd_operand"; // flag: this field is an operand tensor UID -attribute "umd_result"; // flag: this field is a result tensor UID -attribute "umd_name"; // string: the name the UMD binds it by (e.g. "Q", "O") +attribute "umd_opcode"; // table: the op's UMD-facing opcode (e.g. "sdpa_fwd") +attribute "umd_operand"; // field flag: this field is an operand tensor UID +attribute "umd_result"; // field flag: this field is a result tensor UID +attribute "umd_name"; // field string: the name the UMD binds it by (e.g. "Q", "O") ``` -No separate type attribute is needed: an operand or result always binds a `Tensor` — a tensor UID is -the only edge kind in a UID-centric graph ([§2](#2-the-matchers-input-hipdnns-graph-model)) — and every -scalar attribute is precisely an unannotated field (B.3). The `long` UID field type and the -`umd_operand`/`umd_result` flag together already fix what the binding is. +`umd_opcode` is a **table-level** attribute (applied in parentheses after the table name); the other +three are field-level. No separate operand/result type attribute is needed: an operand or result always +binds a `Tensor` — a tensor UID is the only edge kind in a UID-centric graph +([§2](#2-the-matchers-input-hipdnns-graph-model)) — and every scalar attribute is precisely an +unannotated scalar field (B.3). The `long` UID field type and the `umd_operand`/`umd_result` flag +together already fix what the binding is. ### B.2 Annotated schema @@ -1089,7 +1100,7 @@ re-annotated: a UID field's `= null` default (an optional field) already encodes derives required-vs-optional from the field's presence semantics rather than a fourth attribute. ```fbs -table SdpaAttributes { +table SdpaAttributes (umd_opcode: "sdpa_fwd") { // table-level opcode shorthand q_tensor_uid:long (umd_operand, umd_name: "Q"); // required operand k_tensor_uid:long (umd_operand, umd_name: "K"); v_tensor_uid:long (umd_operand, umd_name: "V"); @@ -1105,14 +1116,16 @@ table SdpaAttributes { ### B.3 Field classification (normative) For every table reachable from the `NodeAttributes` union (which already maps each opcode to its -attribute table), the generator classifies each field by these rules; a violation **fails the build** -rather than emitting a wrong registry: +attribute table), the generator reads the table's `umd_opcode` (the entry's opcode key; when absent it +falls back to the table type name) and classifies each field by these rules; a violation **fails the +build** rather than emitting a wrong registry: | Field carries | Classified as | Requirements | |---|---|---| | `umd_operand` + `umd_name` | operand edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | | `umd_result` + `umd_name` | result edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | -| neither `umd_operand` nor `umd_result` | scalar attribute, named by the field name | — | +| neither flag, a **scalar** field | scalar attribute, named by the field name | — | +| neither flag, a **non-scalar** field (vector, sub-table, union, string) | skipped (not a UMD scalar) | — | - **Optionality** is derived, not annotated: a field with a `= null` default (an optional UID or an optional scalar) is optional; it supplies the `?`-binding of [Appendix A.2](#a2-node-object-and-opcode-selector) @@ -1120,7 +1133,13 @@ rather than emitting a wrong registry: - **Build errors (fail closed):** `umd_operand` and `umd_result` on the same field; `umd_name` without either flag; `umd_operand`/`umd_result` on a non-integer field; a duplicate `umd_name` within one op; an operand/result whose role name collides with a reserved token - ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)). + ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)); or a duplicate `umd_opcode` across + ops. +- **Scalar attribute value kind.** A scalar attribute carries its value kind for compile-time type + checking ([Appendix A.10](#a10-compile-time-validation-normative)): integer fields bind as `Int`, + float/double as `Float`, `bool` as `Bool`, and an **enum-typed** field as `Dtype`, carrying the + enum-value name string (e.g. `diagonal_alignment` → `"TOP_LEFT"`). This mirrors the tensor `dtype` + representation and lets a criterion compare an enum attribute against a literal enum name. - **No name-suffix inference.** `PointwiseAttributes::axis_tensor_uid` is a plain axis index, not a tensor UID; because it carries no `umd_operand`/`umd_result` it is classified a scalar attribute, exactly as intended — nothing keys off the `_tensor_uid` suffix. @@ -1144,10 +1163,16 @@ rather than emitting a wrong registry: transitively covers every attribute table and carries the declared `umd_*` attributes (custom attributes surface through reflection, not through the generated C++ headers). 2. A build-time generator loads `graph.bfbs` through the FlatBuffers reflection API, enumerates the - `NodeAttributes` union members (opcode → table), and applies the B.3 rules to each table's fields. -3. The generator emits the op-schema registry as C++ compiled into the provider: a table keyed by - opcode, each entry listing its operands and results (role name, optionality, and the typed accessor - for the UID field) and its scalar attributes (name, optionality, typed accessor). + `NodeAttributes` union members (opcode → table), reads each table's `umd_opcode`, and applies the + B.3 rules to each table's fields. Because `graph.fbs` pulls the per-op tables in via `include`, the + `.bfbs` (and thus the registry) must regenerate when **any** included schema changes, not only when + the top-level schema does. +3. The generator emits the op-schema registry as generated C++ the provider compiles (a header-only + registry emitted into the schema-owning SDK, so it needs no dependency on the provider): a table + keyed by the `umd_opcode` shorthand, each entry also carrying its attribute-table name and the + integer `NodeAttributes` value, and listing its operands and results (role name, optionality, and + the typed accessor for the UID field) and its scalar attributes (name, optionality, value kind, and + typed accessor). Reflection is used **only at build time**; the generated registry holds typed accessors, so the runtime match path reads UID and attribute fields via `attributesAs()` @@ -1159,8 +1184,8 @@ The emitted entry for one opcode is, conceptually: ```jsonc // generated; illustrative shape, not a wire format -"sdpa_fwd": { - "attributes_type": "SdpaAttributes", +"sdpa_fwd": { // key: the table's umd_opcode (fallback: table name) + "attributes_type": "SdpaAttributes", // NodeAttributes union member; matched via Node::attributes_type() "operands": [ {"role": "Q", "uid": "&SdpaAttributes::q_tensor_uid", "optional": false}, {"role": "K", "uid": "&SdpaAttributes::k_tensor_uid", "optional": false}, diff --git a/projects/hipdnn/flatbuffers_sdk/CMakeLists.txt b/projects/hipdnn/flatbuffers_sdk/CMakeLists.txt index 7f6fec723765..6f90169ed2eb 100644 --- a/projects/hipdnn/flatbuffers_sdk/CMakeLists.txt +++ b/projects/hipdnn/flatbuffers_sdk/CMakeLists.txt @@ -148,6 +148,37 @@ if(HIPDNN_GENERATE_SDK_HEADERS) GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/hipdnn_flatbuffers_sdk/data_objects" OUTPUT_NAMESPACE hipdnn_flatbuffers_sdk ) + + # --- RFC 0018 UMD op-schema registry generation (Appendix B) --- + # 1. Emit graph.bfbs (reflection schema; retains the umd_* attributes the + # C++ headers drop). 2. Build the host tool umd_registry_gen. 3. Run it to + # emit the strongly-typed op-schema registry header into the committed + # include tree, in lockstep with the schema. + set(_umd_bfbs "${CMAKE_CURRENT_BINARY_DIR}/umd/graph.bfbs") + hipdnn_generate_bfbs( + SCHEMA schemas/graph.fbs + SCHEMAS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/schemas" + OUTPUT "${_umd_bfbs}" + ) + + add_executable(umd_registry_gen tools/umd_registry_gen/umd_registry_gen.cpp) + target_compile_features(umd_registry_gen PRIVATE cxx_std_17) + target_link_libraries(umd_registry_gen PRIVATE flatbuffers::flatbuffers) + + set(_umd_registry_hpp + "${CMAKE_CURRENT_SOURCE_DIR}/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp") + add_custom_command( + OUTPUT "${_umd_registry_hpp}" + COMMAND umd_registry_gen "${_umd_bfbs}" "${_umd_registry_hpp}" + DEPENDS umd_registry_gen "${_umd_bfbs}" + COMMENT "Generating UMD op-schema registry (RFC 0018 Appendix B)" + VERBATIM + ) + add_custom_target(generate_umd_registry + DEPENDS "${_umd_registry_hpp}" + COMMENT "UMD op-schema registry generated" + ) + add_dependencies(hipdnn_flatbuffers_sdk generate_umd_registry) endif() # --- Export and install --- diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp new file mode 100644 index 000000000000..34d35f925844 --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp @@ -0,0 +1,110 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT +// +// Neutral (JsonLogic-agnostic) op-schema registry types for RFC 0018 (UMD). +// +// This header defines the data shapes the generated registry populates. It is +// hand-written and stable; the per-op tables and lookup functions live in the +// build-time-generated op_schema_registry_generated.hpp, emitted by the +// umd_registry_gen tool from the FlatBuffers `.bfbs` reflection schema +// (RFC 0018 Appendix B). Keeping these types free of any provider / JsonLogic +// dependency lets flatbuffers_sdk own the registry without depending on a +// consumer. +// +// The registry reconstructs a UID-centric graph's edges and auto-binds symbols: +// each op entry lists its operand and result roles (each with a typed UID +// reader) and its scalar attributes (each with a typed value reader). Readers +// use the generated FlatBuffers accessors directly -- no runtime reflection. + +#pragma once + +#include +#include +#include + +namespace hipdnn_flatbuffers_sdk::umd +{ + +// The value kind of a scalar attribute. Mirrors the JsonLogic value domain a +// criteria expression type-checks against (RFC 0018 Appendix A.7): integer, +// float, boolean, or an enum name (`Dtype`). Enum-typed attributes surface as +// `Dtype`, carrying the enum-value name string. +enum class AttrType : std::uint8_t +{ + Int, + Float, + Bool, + Dtype, +}; + +// A resolved scalar attribute value. `present` is false only for an optional +// attribute the graph omitted; a present value populates exactly the member +// selected by `type`. `dtype` points at a static enum-name string (no +// ownership) when `type == Dtype`. +struct ScalarValue +{ + AttrType type = AttrType::Int; + bool present = false; + std::int64_t i = 0; + double f = 0.0; + bool b = false; + const char* dtype = nullptr; +}; + +// Reads a role's tensor UID out of a concrete attribute table (passed as a +// `const void*` to keep this header type-erased). Returns false when the role +// is optional and absent from the graph; returns true and writes `out` +// otherwise. A required role always returns true. +using UidReader = bool (*)(const void* attributes, std::int64_t& out); + +// Reads a scalar attribute value out of a concrete attribute table. +using ScalarReader = ScalarValue (*)(const void* attributes); + +struct OperandBinding +{ + std::string_view role; + bool optional = false; + UidReader read = nullptr; +}; + +struct ResultBinding +{ + std::string_view role; + bool optional = false; + UidReader read = nullptr; +}; + +struct AttrBinding +{ + std::string_view name; + bool optional = false; + AttrType type = AttrType::Int; + ScalarReader read = nullptr; +}; + +// One op's schema: its `opcode` (the UMD-facing shorthand from the table's +// `umd_opcode` attribute, e.g. "sdpa_fwd", falling back to the table type name +// when the attribute is absent), the `tableName` (the NodeAttributes union member, +// e.g. "SdpaAttributes", for diagnostics), and the integer `attributesType` (the +// value of the NodeAttributes enum, for O(1) lookup against +// Node::attributes_type()), plus its operand, result, and scalar-attribute +// bindings. +struct OpSchemaEntry +{ + std::string_view opcode; + std::string_view tableName; + int attributesType = 0; + const OperandBinding* operands = nullptr; + std::size_t operandCount = 0; + const ResultBinding* results = nullptr; + std::size_t resultCount = 0; + const AttrBinding* attributes = nullptr; + std::size_t attributeCount = 0; +}; + +// Defined (inline) in the generated header. +// const OpSchemaEntry* opSchemaEntries(std::size_t& count); +// const OpSchemaEntry* lookupOpByName(std::string_view opcode); +// const OpSchemaEntry* lookupOpByType(int attributesType); + +} // namespace hipdnn_flatbuffers_sdk::umd diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp new file mode 100644 index 000000000000..4159b69f50fc --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -0,0 +1,345 @@ +// AUTOMATICALLY GENERATED by umd_registry_gen -- do not modify. +// Source: FlatBuffers reflection schema (graph.bfbs). See RFC 0018 Appendix B. +#pragma once + +#include +#include +#include + +#include +#include + +namespace hipdnn_flatbuffers_sdk::umd +{ +namespace generated +{ + +namespace op0 { +inline const AttrBinding attributes[] = { + {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"inv_variance_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->inv_variance_tensor_uid()); return s; }}, + {"mean_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->mean_tensor_uid()); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op0 + +namespace op1 { +inline const AttrBinding attributes[] = { + {"axis_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->axis_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"elu_alpha", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->elu_alpha(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"in_0_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->in_0_tensor_uid()); return s; }}, + {"in_1_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->in_1_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"in_2_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->in_2_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"operation", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePointwiseMode(static_cast(a)->operation()); return s; }}, + {"out_0_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->out_0_tensor_uid()); return s; }}, + {"relu_lower_clip", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->relu_lower_clip(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"relu_lower_clip_slope", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->relu_lower_clip_slope(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"relu_upper_clip", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->relu_upper_clip(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"softplus_beta", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->softplus_beta(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"swish_beta", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->swish_beta(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, +}; +} // namespace op1 + +namespace op2 { +inline const AttrBinding attributes[] = { + {"dbias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, + {"dscale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, + {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, +}; +} // namespace op2 + +namespace op3 { +inline const AttrBinding attributes[] = { + {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"momentum_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->momentum_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"next_running_mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->next_running_mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"next_running_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->next_running_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"prev_running_mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->prev_running_mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"prev_running_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->prev_running_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op3 + +namespace op4 { +inline const AttrBinding attributes[] = { + {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, + {"w_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->w_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op4 + +namespace op5 { +inline const AttrBinding attributes[] = { + {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, + {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"w_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->w_tensor_uid()); return s; }}, +}; +} // namespace op5 + +namespace op6 { +inline const AttrBinding attributes[] = { + {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, + {"dw_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dw_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, +}; +} // namespace op6 + +namespace op7 { +inline const AttrBinding attributes[] = { + {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"mean_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->mean_tensor_uid()); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"variance_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->variance_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op7 + +namespace op8 { +inline const AttrBinding attributes[] = { + {"a_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->a_tensor_uid()); return s; }}, + {"b_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->b_tensor_uid()); return s; }}, + {"c_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->c_tensor_uid()); return s; }}, +}; +} // namespace op8 + +namespace op9 { +inline const AttrBinding attributes[] = { + {"bias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->bias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"forward_phase", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameNormFwdPhase(static_cast(a)->forward_phase()); return s; }}, + {"inv_rms_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_rms_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op9 + +namespace op10 { +inline const AttrBinding attributes[] = { + {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"forward_phase", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameNormFwdPhase(static_cast(a)->forward_phase()); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"normalized_dim_count", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->normalized_dim_count()); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op10 + +namespace op11 { +inline const OperandBinding operands[] = { + {"attn_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->attn_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"block_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->block_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"descale_k", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->descale_k_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"descale_q", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->descale_q_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"descale_s", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->descale_s_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"descale_v", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->descale_v_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"dropout_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->dropout_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"dropout_scale", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->dropout_scale_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"K", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->k_tensor_uid(); return true; }}, + {"offset", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->offset_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"page_table_k", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->page_table_k_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"page_table_v", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->page_table_v_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"Q", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->q_tensor_uid(); return true; }}, + {"scale_o", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->scale_o_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"scale_s", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->scale_s_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"scale", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->scale_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"seed", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->seed_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"seq_len_kv", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->seq_len_kv_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"seq_len_q", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->seq_len_q_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"sink_token", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->sink_token_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"V", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->v_tensor_uid(); return true; }}, +}; +inline const ResultBinding results[] = { + {"amax_o", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->amax_o_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"amax_s", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->amax_s_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"max", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->max_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"O", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->o_tensor_uid(); return true; }}, + {"rng_dump", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->rng_dump_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"stats", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->stats_tensor_uid(); if(!v) return false; out = *v; return true; }}, + {"sum_exp", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->sum_exp_tensor_uid(); if(!v) return false; out = *v; return true; }}, +}; +inline const AttrBinding attributes[] = { + {"alibi_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, + {"attn_scale_value", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->attn_scale_value(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"causal_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask(); return s; }}, + {"causal_mask_bottom_right", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask_bottom_right(); return s; }}, + {"diagonal_alignment", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDiagonalAlignment(static_cast(a)->diagonal_alignment()); return s; }}, + {"dropout_probability", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->dropout_probability(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"generate_stats", true, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; auto v = static_cast(a)->generate_stats(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, + {"implementation", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameAttentionImplementation(static_cast(a)->implementation()); return s; }}, + {"left_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->left_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"max_seq_len_kv", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->max_seq_len_kv(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mma_core_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDataType(static_cast(a)->mma_core_mode()); return s; }}, + {"padding_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->padding_mask(); return s; }}, + {"right_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->right_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, +}; +} // namespace op11 + +namespace op12 { +inline const AttrBinding attributes[] = { + {"is_negative_scale", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->is_negative_scale(); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op12 + +namespace op13 { +inline const AttrBinding attributes[] = { + {"axis", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->axis(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"block_size", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->block_size()); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"transpose", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->transpose(); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op13 + +namespace op14 { +inline const AttrBinding attributes[] = { + {"alibi_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, + {"attn_mask_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->attn_mask_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"attn_scale_value", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->attn_scale_value(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"causal_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask(); return s; }}, + {"causal_mask_bottom_right", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask_bottom_right(); return s; }}, + {"dbias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"diagonal_alignment", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDiagonalAlignment(static_cast(a)->diagonal_alignment()); return s; }}, + {"dk_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dk_tensor_uid()); return s; }}, + {"do_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->do_tensor_uid()); return s; }}, + {"dq_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dq_tensor_uid()); return s; }}, + {"dropout_mask_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dropout_mask_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dropout_probability", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->dropout_probability(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"dropout_scale_inv_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dropout_scale_inv_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dropout_scale_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dropout_scale_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dv_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dv_tensor_uid()); return s; }}, + {"k_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->k_tensor_uid()); return s; }}, + {"left_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->left_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"o_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->o_tensor_uid()); return s; }}, + {"offset_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->offset_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"padding_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->padding_mask(); return s; }}, + {"q_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->q_tensor_uid()); return s; }}, + {"right_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->right_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->scale_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"seed_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->seed_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"seq_len_kv_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->seq_len_kv_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"seq_len_q_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->seq_len_q_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"stats_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->stats_tensor_uid()); return s; }}, + {"v_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->v_tensor_uid()); return s; }}, +}; +} // namespace op14 + +namespace op16 { +inline const AttrBinding attributes[] = { + {"dbias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dscale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, + {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"inv_rms_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->inv_rms_tensor_uid()); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, +}; +} // namespace op16 + +namespace op17 { +inline const AttrBinding attributes[] = { + {"in_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->in_tensor_uid()); return s; }}, + {"is_deterministic", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->is_deterministic(); return s; }}, + {"mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameReductionMode(static_cast(a)->mode()); return s; }}, + {"out_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->out_tensor_uid()); return s; }}, +}; +} // namespace op17 + +namespace op18 { +inline const AttrBinding attributes[] = { + {"generate_index", true, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; auto v = static_cast(a)->generate_index(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, + {"index_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->index_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"padding_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePaddingMode(static_cast(a)->padding_mode()); return s; }}, + {"resample_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameResampleMode(static_cast(a)->resample_mode()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, +}; +} // namespace op18 + +namespace op19 { +inline const AttrBinding attributes[] = { + {"dbias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, + {"dscale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, + {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->epsilon_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"normalized_dim_count", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->normalized_dim_count()); return s; }}, + {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, +}; +} // namespace op19 + +inline const OpSchemaEntry entries[] = { + {"BatchnormInferenceAttributes", "BatchnormInferenceAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributes), nullptr, 0u, nullptr, 0u, op0::attributes, 6u}, + {"PointwiseAttributes", "PointwiseAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::PointwiseAttributes), nullptr, 0u, nullptr, 0u, op1::attributes, 12u}, + {"BatchnormBackwardAttributes", "BatchnormBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormBackwardAttributes), nullptr, 0u, nullptr, 0u, op2::attributes, 8u}, + {"BatchnormAttributes", "BatchnormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormAttributes), nullptr, 0u, nullptr, 0u, op3::attributes, 12u}, + {"ConvolutionFwdAttributes", "ConvolutionFwdAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionFwdAttributes), nullptr, 0u, nullptr, 0u, op4::attributes, 4u}, + {"ConvolutionBwdAttributes", "ConvolutionBwdAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionBwdAttributes), nullptr, 0u, nullptr, 0u, op5::attributes, 4u}, + {"ConvolutionWrwAttributes", "ConvolutionWrwAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionWrwAttributes), nullptr, 0u, nullptr, 0u, op6::attributes, 4u}, + {"BatchnormInferenceAttributesVarianceExt", "BatchnormInferenceAttributesVarianceExt", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributesVarianceExt), nullptr, 0u, nullptr, 0u, op7::attributes, 7u}, + {"MatmulAttributes", "MatmulAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::MatmulAttributes), nullptr, 0u, nullptr, 0u, op8::attributes, 3u}, + {"RMSNormAttributes", "RMSNormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormAttributes), nullptr, 0u, nullptr, 0u, op9::attributes, 7u}, + {"LayernormAttributes", "LayernormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormAttributes), nullptr, 0u, nullptr, 0u, op10::attributes, 9u}, + {"sdpa_fwd", "SdpaAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaAttributes), op11::operands, 21u, op11::results, 7u, op11::attributes, 13u}, + {"BlockScaleDequantizeAttributes", "BlockScaleDequantizeAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleDequantizeAttributes), nullptr, 0u, nullptr, 0u, op12::attributes, 4u}, + {"BlockScaleQuantizeAttributes", "BlockScaleQuantizeAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleQuantizeAttributes), nullptr, 0u, nullptr, 0u, op13::attributes, 6u}, + {"SdpaBackwardAttributes", "SdpaBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaBackwardAttributes), nullptr, 0u, nullptr, 0u, op14::attributes, 28u}, + {"CustomOpAttributes", "CustomOpAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::CustomOpAttributes), nullptr, 0u, nullptr, 0u, nullptr, 0u}, + {"RMSNormBackwardAttributes", "RMSNormBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormBackwardAttributes), nullptr, 0u, nullptr, 0u, op16::attributes, 7u}, + {"ReductionAttributes", "ReductionAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ReductionAttributes), nullptr, 0u, nullptr, 0u, op17::attributes, 4u}, + {"ResampleFwdAttributes", "ResampleFwdAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ResampleFwdAttributes), nullptr, 0u, nullptr, 0u, op18::attributes, 6u}, + {"LayernormBackwardAttributes", "LayernormBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormBackwardAttributes), nullptr, 0u, nullptr, 0u, op19::attributes, 10u}, +}; + +} // namespace generated + +inline const OpSchemaEntry* opSchemaEntries(std::size_t& count) +{ + count = sizeof(generated::entries) / sizeof(generated::entries[0]); + return generated::entries; +} + +inline const OpSchemaEntry* lookupOpByName(std::string_view opcode) +{ + for(const auto& e : generated::entries) + if(e.opcode == opcode) + return &e; + return nullptr; +} + +inline const OpSchemaEntry* lookupOpByType(int attributesType) +{ + for(const auto& e : generated::entries) + if(e.attributesType == attributesType) + return &e; + return nullptr; +} + +} // namespace hipdnn_flatbuffers_sdk::umd diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs index 6c2cd378f040..caa097b733ed 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs @@ -26,10 +26,14 @@ enum DataType : byte { } // UMD binding annotations (RFC 0018 Appendix B). Declared once here, in the shared -// include, and applied to each op's attribute-table UID fields. `umd_operand` / -// `umd_result` mark a tensor-UID edge and `umd_name` gives the name a UMD binds it -// by; the operand/result flag already fixes that the binding is a tensor, so no -// separate type attribute is carried. Unannotated fields are scalar attributes. +// include, and applied to each op's attribute table. `umd_opcode` is a table-level +// attribute giving the op's UMD-facing shorthand (e.g. "sdpa_fwd"); a UMD file's +// node `op` names it, and the registry keys on it (falling back to the table type +// name when absent). `umd_operand` / `umd_result` mark a tensor-UID edge and +// `umd_name` gives the name a UMD binds it by; the operand/result flag already +// fixes that the binding is a tensor, so no separate type attribute is carried. +// Unannotated fields are scalar attributes. +attribute "umd_opcode"; attribute "umd_operand"; attribute "umd_result"; attribute "umd_name"; diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs index 3c02943e0857..ebb12ca6e9b2 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs @@ -16,7 +16,7 @@ enum AttentionImplementation : byte { UNIFIED = 2, } -table SdpaAttributes { +table SdpaAttributes (umd_opcode: "sdpa_fwd") { // Required input tensor UIDs q_tensor_uid: long (umd_operand, umd_name: "Q"); k_tensor_uid: long (umd_operand, umd_name: "K"); diff --git a/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt b/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt index 2e5ed1b16ee8..3f0af1241a78 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt +++ b/projects/hipdnn/flatbuffers_sdk/tests/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable( utilities/TestJson.cpp utilities/TestPointwiseValidation.cpp utilities/TestUuid.cpp + umd/TestOpSchemaRegistry.cpp ) target_compile_options(hipdnn_flatbuffers_sdk_tests PRIVATE ${HIPDNN_WARNING_COMPILE_OPTIONS} ${HIPDNN_NO_RTTI_OPTIONS}) @@ -75,3 +76,27 @@ if(ENABLE_CLANG_TIDY AND _fb_include_dirs) endif() add_hipdnn_test(hipdnn_flatbuffers_sdk_tests ${CMAKE_CURRENT_BINARY_DIR}) + +# RFC 0018 B.3: registry generation must fail closed on an annotation violation +# rather than emit a wrong binding. flatc accepts the fixture (it is valid +# FlatBuffers); umd_registry_gen must reject it (non-zero exit). Gated on the +# generator target, which exists only when HIPDNN_GENERATE_SDK_HEADERS is ON. +if(TARGET umd_registry_gen) + set(_umd_bad_bfbs "${CMAKE_CURRENT_BINARY_DIR}/umd/bad_nonlong.bfbs") + hipdnn_generate_bfbs( + SCHEMA "${CMAKE_CURRENT_SOURCE_DIR}/umd/fixtures/bad_nonlong.fbs" + SCHEMAS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/umd/fixtures" + OUTPUT "${_umd_bad_bfbs}" + ) + add_custom_target(generate_umd_bad_bfbs + DEPENDS "${_umd_bad_bfbs}" + COMMENT "UMD B.3 negative fixture schema generated" + ) + add_dependencies(hipdnn_flatbuffers_sdk_tests generate_umd_bad_bfbs umd_registry_gen) + add_test( + NAME umd_registry_gen_rejects_bad_schema + COMMAND $ "${_umd_bad_bfbs}" + "${CMAKE_CURRENT_BINARY_DIR}/umd/bad_nonlong_out.hpp" + ) + set_tests_properties(umd_registry_gen_rejects_bad_schema PROPERTIES WILL_FAIL TRUE) +endif() diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp new file mode 100644 index 000000000000..0a85587a40f0 --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -0,0 +1,226 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT +// +// RFC 0018 Phase 0: proves the generated op-schema registry. Two claims: +// 1. Shape + optionality parity -- the registry lists SDPA's operand/result +// roles and scalar attributes with the optionality the schema `= null` +// defaults imply (matching the generated header's Optional fields). +// 2. Accessor value round-trip -- the generated typed readers, driven off a +// live SdpaAttributes flatbuffer, return the exact stored values. Registry +// shape alone is necessary but not sufficient; this proves the accessors +// read the right field (the binding). + +#include +#include +#include + +#include + +#include + +#include + +namespace fb = flatbuffers; +namespace umd = hipdnn_flatbuffers_sdk::umd; +namespace data = hipdnn_flatbuffers_sdk::data_objects; + +namespace +{ + +const umd::OperandBinding* findOperand(const umd::OpSchemaEntry* e, std::string_view role) +{ + for(std::size_t i = 0; i < e->operandCount; ++i) + if(e->operands[i].role == role) + return &e->operands[i]; + return nullptr; +} + +const umd::ResultBinding* findResult(const umd::OpSchemaEntry* e, std::string_view role) +{ + for(std::size_t i = 0; i < e->resultCount; ++i) + if(e->results[i].role == role) + return &e->results[i]; + return nullptr; +} + +const umd::AttrBinding* findAttr(const umd::OpSchemaEntry* e, std::string_view name) +{ + for(std::size_t i = 0; i < e->attributeCount; ++i) + if(e->attributes[i].name == name) + return &e->attributes[i]; + return nullptr; +} + +const umd::OpSchemaEntry* sdpaEntry() +{ + return umd::lookupOpByName("sdpa_fwd"); +} + +// A minimal SDPA attribute table with known values; optionals left unset stay +// absent. Owned by the returned builder; read via GetRoot on its buffer. +fb::FlatBufferBuilder buildSdpa() +{ + fb::FlatBufferBuilder b; + data::SdpaAttributesBuilder sb(b); + sb.add_q_tensor_uid(101); + sb.add_k_tensor_uid(102); + sb.add_v_tensor_uid(103); + sb.add_o_tensor_uid(201); + sb.add_dropout_probability(0.25f); // optional present + sb.add_alibi_mask(true); // required bool + // attn_mask / page_table_* / generate_stats left unset -> absent + b.Finish(sb.Finish()); + return b; +} + +} // namespace + +TEST(UmdOpSchemaRegistry, SdpaEntryResolvesByNameAndType) +{ + const auto* byName = umd::lookupOpByName("sdpa_fwd"); // the umd_opcode shorthand + const auto* byType + = umd::lookupOpByType(static_cast(data::NodeAttributes::SdpaAttributes)); + ASSERT_NE(byName, nullptr); + ASSERT_NE(byType, nullptr); + EXPECT_EQ(byName, byType); + EXPECT_EQ(byName->opcode, "sdpa_fwd"); + EXPECT_EQ(byName->tableName, "SdpaAttributes"); + EXPECT_EQ(umd::lookupOpByName("SdpaAttributes"), nullptr); // table name is not the key + EXPECT_EQ(umd::lookupOpByName("NoSuchOp"), nullptr); +} + +TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + for(std::string_view role : {"Q", "K", "V"}) + { + const auto* op = findOperand(e, role); + ASSERT_NE(op, nullptr) << role; + EXPECT_FALSE(op->optional) << role; + } + const auto* o = findResult(e, "O"); + ASSERT_NE(o, nullptr); + EXPECT_FALSE(o->optional); +} + +TEST(UmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + for(std::string_view role : {"attn_mask", "page_table_k", "page_table_v", "scale", "seed"}) + { + const auto* op = findOperand(e, role); + ASSERT_NE(op, nullptr) << role; + EXPECT_TRUE(op->optional) << role; + } + // Optional results too. + for(std::string_view role : {"stats", "amax_s", "amax_o"}) + { + const auto* r = findResult(e, role); + ASSERT_NE(r, nullptr) << role; + EXPECT_TRUE(r->optional) << role; + } +} + +// Optionality parity with the schema `= null` defaults / generated Optional. +TEST(UmdOpSchemaRegistry, SdpaScalarAttributeOptionalityParity) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + + // `= null` scalars are optional. + for(std::string_view name : {"generate_stats", + "dropout_probability", + "attn_scale_value", + "left_bound", + "right_bound", + "max_seq_len_kv"}) + { + const auto* a = findAttr(e, name); + ASSERT_NE(a, nullptr) << name; + EXPECT_TRUE(a->optional) << name; + } + // Defaulted (non-null) scalars/enums are required. + for(std::string_view name : {"alibi_mask", + "padding_mask", + "causal_mask", + "causal_mask_bottom_right", + "diagonal_alignment"}) + { + const auto* a = findAttr(e, name); + ASSERT_NE(a, nullptr) << name; + EXPECT_FALSE(a->optional) << name; + } +} + +TEST(UmdOpSchemaRegistry, SdpaScalarAttributeTypes) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + EXPECT_EQ(findAttr(e, "alibi_mask")->type, umd::AttrType::Bool); + EXPECT_EQ(findAttr(e, "dropout_probability")->type, umd::AttrType::Float); + EXPECT_EQ(findAttr(e, "left_bound")->type, umd::AttrType::Int); + EXPECT_EQ(findAttr(e, "diagonal_alignment")->type, umd::AttrType::Dtype); +} + +TEST(UmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + auto b = buildSdpa(); + const auto* attrs = fb::GetRoot(b.GetBufferPointer()); + const void* a = attrs; + + std::int64_t uid = 0; + ASSERT_TRUE(findOperand(e, "Q")->read(a, uid)); + EXPECT_EQ(uid, 101); + ASSERT_TRUE(findOperand(e, "K")->read(a, uid)); + EXPECT_EQ(uid, 102); + ASSERT_TRUE(findOperand(e, "V")->read(a, uid)); + EXPECT_EQ(uid, 103); + ASSERT_TRUE(findResult(e, "O")->read(a, uid)); + EXPECT_EQ(uid, 201); +} + +TEST(UmdOpSchemaRegistry, AbsentOptionalOperandReadsFalse) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + auto b = buildSdpa(); + const auto* attrs = fb::GetRoot(b.GetBufferPointer()); + const void* a = attrs; + + std::int64_t uid = -1; + EXPECT_FALSE(findOperand(e, "attn_mask")->read(a, uid)); + EXPECT_FALSE(findOperand(e, "page_table_k")->read(a, uid)); +} + +TEST(UmdOpSchemaRegistry, ScalarReadersReflectPresenceAndValue) +{ + const auto* e = sdpaEntry(); + ASSERT_NE(e, nullptr); + auto b = buildSdpa(); + const auto* attrs = fb::GetRoot(b.GetBufferPointer()); + const void* a = attrs; + + const umd::ScalarValue alibi = findAttr(e, "alibi_mask")->read(a); + EXPECT_EQ(alibi.type, umd::AttrType::Bool); + EXPECT_TRUE(alibi.present); + EXPECT_TRUE(alibi.b); + + const umd::ScalarValue drop = findAttr(e, "dropout_probability")->read(a); + EXPECT_EQ(drop.type, umd::AttrType::Float); + EXPECT_TRUE(drop.present); + EXPECT_DOUBLE_EQ(drop.f, 0.25); + + const umd::ScalarValue stats = findAttr(e, "generate_stats")->read(a); + EXPECT_EQ(stats.type, umd::AttrType::Bool); + EXPECT_FALSE(stats.present); // optional, unset + + const umd::ScalarValue align = findAttr(e, "diagonal_alignment")->read(a); + EXPECT_EQ(align.type, umd::AttrType::Dtype); + EXPECT_TRUE(align.present); + ASSERT_NE(align.dtype, nullptr); + EXPECT_STREQ(align.dtype, "TOP_LEFT"); +} diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs b/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs new file mode 100644 index 000000000000..085acbd48673 --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs @@ -0,0 +1,22 @@ +// RFC 0018 B.3 negative fixture: a umd_operand on a non-`long` field must fail +// registry generation (fail closed), not emit a wrong binding. Consumed by the +// umd_registry_gen_rejects_bad_schema CTest, which expects a non-zero exit. +namespace hipdnn_flatbuffers_sdk.data_objects; + +attribute "umd_operand"; +attribute "umd_result"; +attribute "umd_name"; + +table BadAttributes { + x: int (umd_operand, umd_name: "X"); // violation: operand UID must be `long` +} + +union NodeAttributes { + BadAttributes +} + +table Node { + attributes: NodeAttributes; +} + +root_type Node; diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp new file mode 100644 index 000000000000..ec5e741a1bdc --- /dev/null +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -0,0 +1,396 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT +// +// umd_registry_gen -- build-time generator for the RFC 0018 op-schema registry. +// +// Reads the FlatBuffers binary reflection schema (graph.bfbs), enumerates the +// NodeAttributes union members (opcode -> attribute table), classifies each +// field per RFC 0018 Appendix B.3, and emits a header-only, strongly-typed +// registry (op_schema_registry_generated.hpp) whose readers call the generated +// FlatBuffers accessors directly -- no runtime reflection (RFC 0018 B.4). +// +// Field classification (B.3): +// * umd_operand + umd_name -> operand edge (field MUST be a `long` UID) +// * umd_result + umd_name -> result edge (field MUST be a `long` UID) +// * neither flag, scalar -> scalar attribute, bind-named by field name +// * neither flag, non-scalar-> skipped (a vector/table/union is not a UMD +// scalar; the RFC's attribute tables are UID + +// scalar, so this only affects ops outside the +// UMD scalar namespace) +// Optionality is derived from the `= null` default (reflection `optional()`). +// +// Build errors (fail closed, non-zero exit): both flags on one field; umd_name +// without a flag; a flag on a non-integer field; a duplicate umd_name within an +// op; a role name colliding with a reserved root (graph/kernel/device). + +#include +#include +#include +#include +#include +#include +#include + +#include "flatbuffers/reflection.h" +#include "flatbuffers/reflection_generated.h" + +namespace +{ + +[[noreturn]] void fail(const std::string& msg) +{ + std::fprintf(stderr, "umd_registry_gen: error: %s\n", msg.c_str()); + std::exit(1); +} + +std::string readFile(const std::string& path) +{ + std::ifstream f(path, std::ios::binary); + if(!f) + fail("cannot open input schema: " + path); + return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); +} + +// Short (unqualified) name: text after the last '.'. +std::string shortName(const std::string& qualified) +{ + auto pos = qualified.rfind('.'); + return pos == std::string::npos ? qualified : qualified.substr(pos + 1); +} + +const reflection::KeyValue* attr(const reflection::Field* f, const char* key) +{ + return f->attributes() ? f->attributes()->LookupByKey(key) : nullptr; +} + +// Table-level attribute value (e.g. `umd_opcode`), or empty when absent. +std::string objAttr(const reflection::Object* o, const char* key) +{ + if(!o->attributes()) + return {}; + const auto* kv = o->attributes()->LookupByKey(key); + if(!kv) + return {}; + return kv->value() ? kv->value()->str() : std::string(); +} + +bool isIntegerBase(reflection::BaseType t) +{ + return t == reflection::Byte || t == reflection::UByte || t == reflection::Short + || t == reflection::UShort || t == reflection::Int || t == reflection::UInt + || t == reflection::Long || t == reflection::ULong; +} + +bool isScalarBase(reflection::BaseType t) +{ + return t == reflection::Bool || t == reflection::Float || t == reflection::Double + || isIntegerBase(t); +} + +// C++ keywords flatc suffixes with '_' when emitting an accessor name. +const std::set& cppKeywords() +{ + static const std::set k + = {"alignas", "alignof", "and", "asm", "auto", "bool", "break", + "case", "catch", "char", "class", "const", "continue", "default", + "delete", "do", "double", "else", "enum", "explicit", "export", + "extern", "false", "float", "for", "friend", "goto", "if", + "inline", "int", "long", "mutable", "namespace", "new", "operator", + "private", "protected", "public", "register", "return", "short", "signed", + "sizeof", "static", "struct", "switch", "template", "this", "throw", + "true", "try", "typedef", "typename", "union", "unsigned", "using", + "virtual", "void", "volatile", "while", "and_eq", "bitand", "bitor", + "compl", "not", "not_eq", "or", "or_eq", "xor", "xor_eq"}; + return k; +} + +std::string accessorName(const std::string& fieldName) +{ + return cppKeywords().count(fieldName) ? fieldName + "_" : fieldName; +} + +bool isReservedRoot(const std::string& n) +{ + return n == "graph" || n == "kernel" || n == "device"; +} + +constexpr const char* kNs = "::hipdnn_flatbuffers_sdk::data_objects"; + +struct Emitted +{ + std::string operands; // OperandBinding initializers + std::string results; // ResultBinding initializers + std::string attributes; // AttrBinding initializers + std::size_t operandCount = 0; + std::size_t resultCount = 0; + std::size_t attributeCount = 0; +}; + +// Emit the reader lambda + binding line for one operand/result UID field. +std::string emitUidBinding(const std::string& table, + const std::string& role, + const std::string& acc, + bool optional) +{ + std::ostringstream o; + o << " {\"" << role << "\", " << (optional ? "true" : "false") << ", " + << "+[](const void* a, std::int64_t& out) -> bool { "; + if(optional) + o << "auto v = static_cast(a)->" << acc + << "(); if(!v) return false; out = *v; return true;"; + else + o << "out = static_cast(a)->" << acc + << "(); return true;"; + o << " }},\n"; + return o.str(); +} + +// Emit the reader lambda + binding line for one scalar attribute field. +std::string emitAttrBinding(const reflection::Schema* schema, + const std::string& table, + const reflection::Field* fld) +{ + const std::string name = fld->name()->str(); + const std::string acc = accessorName(name); + const auto base = fld->type()->base_type(); + const bool optional = fld->optional(); + const int enumIdx = fld->type()->index(); + const bool isEnum = enumIdx >= 0 && isIntegerBase(base); + + std::string attrType; + std::string body; + if(isEnum) + { + const std::string enumShort = shortName(schema->enums()->Get(enumIdx)->name()->str()); + attrType = "AttrType::Dtype"; + std::ostringstream b; + b << "ScalarValue s; s.type = AttrType::Dtype; "; + if(optional) + b << "auto v = static_cast(a)->" << acc + << "(); s.present = v.has_value(); if(v) s.dtype = " << kNs << "::EnumName" + << enumShort << "(*v);"; + else + b << "s.present = true; s.dtype = " << kNs << "::EnumName" << enumShort + << "(static_cast(a)->" << acc << "());"; + b << " return s;"; + body = b.str(); + } + else + { + std::string member; + if(base == reflection::Bool) + { + attrType = "AttrType::Bool"; + member = "b"; + } + else if(base == reflection::Float || base == reflection::Double) + { + attrType = "AttrType::Float"; + member = "f"; + } + else + { + attrType = "AttrType::Int"; + member = "i"; + } + std::string cast; + if(member == "i") + cast = "std::int64_t"; + else if(member == "f") + cast = "double"; + const std::string readExpr + = "static_cast(a)->" + acc + "()"; + std::ostringstream b; + b << "ScalarValue s; s.type = " << attrType << "; "; + if(optional) + b << "auto v = " << readExpr << "; s.present = v.has_value(); if(v) s." << member + << " = " << (cast.empty() ? "*v" : "static_cast<" + cast + ">(*v)") << ";"; + else + b << "s.present = true; s." << member << " = " + << (cast.empty() ? readExpr : "static_cast<" + cast + ">(" + readExpr + ")") << ";"; + b << " return s;"; + body = b.str(); + } + + std::ostringstream o; + o << " {\"" << name << "\", " << (optional ? "true" : "false") << ", " << attrType + << ", +[](const void* a) -> ScalarValue { " << body << " }},\n"; + return o.str(); +} + +Emitted classifyTable(const reflection::Schema* schema, const reflection::Object* obj) +{ + const std::string table = shortName(obj->name()->str()); + Emitted out; + std::set seenRoles; + + for(const auto* fld : *obj->fields()) + { + const std::string name = fld->name()->str(); + const auto* opFlag = attr(fld, "umd_operand"); + const auto* resFlag = attr(fld, "umd_result"); + const auto* nameAttr = attr(fld, "umd_name"); + const auto base = fld->type()->base_type(); + + if(opFlag && resFlag) + fail(table + "." + name + ": has both umd_operand and umd_result"); + + const bool isEdge = opFlag || resFlag; + if(isEdge) + { + if(!nameAttr) + fail(table + "." + name + ": umd_operand/umd_result requires umd_name"); + const std::string role = nameAttr->value() ? nameAttr->value()->str() : ""; + if(role.empty()) + fail(table + "." + name + ": umd_name must be non-empty"); + if(base != reflection::Long) + fail(table + "." + name + ": umd_operand/umd_result field must be `long` (a UID)"); + if(isReservedRoot(role)) + fail(table + "." + name + ": role name '" + role + + "' collides with a reserved root (graph/kernel/device)"); + if(!seenRoles.insert(role).second) + fail(table + ": duplicate umd_name '" + role + "'"); + + const std::string acc = accessorName(name); + const bool optl = fld->optional(); + if(opFlag) + { + out.operands += emitUidBinding(table, role, acc, optl); + ++out.operandCount; + } + else + { + out.results += emitUidBinding(table, role, acc, optl); + ++out.resultCount; + } + continue; + } + + if(nameAttr) + fail(table + "." + name + ": umd_name without umd_operand/umd_result"); + + // Unannotated: a scalar attribute (if a scalar base type). Non-scalar + // fields (vector/table/union/string) are not UMD scalars -- skip. + if(isScalarBase(base)) + { + out.attributes += emitAttrBinding(schema, table, fld); + ++out.attributeCount; + } + } + return out; +} + +} // namespace + +int main(int argc, char** argv) +{ + if(argc != 3) + fail("usage: umd_registry_gen "); + const std::string bfbsPath = argv[1]; + const std::string outPath = argv[2]; + + const std::string buf = readFile(bfbsPath); + const auto* schema = reflection::GetSchema(buf.data()); + if(!schema) + fail("input is not a valid reflection schema: " + bfbsPath); + + const reflection::Enum* nodeAttrs = nullptr; + for(const auto* e : *schema->enums()) + { + if(e->is_union() && shortName(e->name()->str()) == "NodeAttributes") + nodeAttrs = e; + } + if(!nodeAttrs) + fail("NodeAttributes union not found in schema"); + + std::ostringstream body; + std::ostringstream entries; + std::size_t opCount = 0; + std::set seenOpcodes; + + for(const auto* ev : *nodeAttrs->values()) + { + if(!ev->union_type()) + continue; + const int objIdx = ev->union_type()->index(); + if(objIdx < 0) + continue; // NONE + const reflection::Object* obj = schema->objects()->Get(objIdx); + const std::string table = shortName(obj->name()->str()); + const std::string member = ev->name()->str(); // union value name == table type name + + // Opcode key: the table's `umd_opcode` shorthand (RFC 0018), else the + // table type name. Must be unique across ops. + std::string opcode = objAttr(obj, "umd_opcode"); + if(opcode.empty()) + opcode = member; + if(!seenOpcodes.insert(opcode).second) + fail("duplicate umd_opcode '" + opcode + "'"); + + const Emitted em = classifyTable(schema, obj); + + const std::string tag = "op" + std::to_string(opCount); + std::string operandsRef = "nullptr"; + std::string resultsRef = "nullptr"; + std::string attrsRef = "nullptr"; + const bool anyTable = em.operandCount || em.resultCount || em.attributeCount; + if(anyTable) + body << "namespace " << tag << " {\n"; + if(em.operandCount) + { + body << "inline const OperandBinding operands[] = {\n" << em.operands << "};\n"; + operandsRef = tag + "::operands"; + } + if(em.resultCount) + { + body << "inline const ResultBinding results[] = {\n" << em.results << "};\n"; + resultsRef = tag + "::results"; + } + if(em.attributeCount) + { + body << "inline const AttrBinding attributes[] = {\n" << em.attributes << "};\n"; + attrsRef = tag + "::attributes"; + } + if(anyTable) + body << "} // namespace " << tag << "\n\n"; + + entries << " {\"" << opcode << "\", \"" << member << "\", static_cast(" << kNs + << "::NodeAttributes::" << member << "), " << operandsRef << ", " << em.operandCount + << "u, " << resultsRef << ", " << em.resultCount << "u, " << attrsRef << ", " + << em.attributeCount << "u},\n"; + ++opCount; + } + + std::ostringstream hdr; + hdr << "// AUTOMATICALLY GENERATED by umd_registry_gen -- do not modify.\n" + << "// Source: FlatBuffers reflection schema (graph.bfbs). See RFC 0018 Appendix B.\n" + << "#pragma once\n\n" + << "#include \n#include \n#include \n\n" + << "#include \n" + << "#include \n\n" + << "namespace hipdnn_flatbuffers_sdk::umd\n{\nnamespace generated\n{\n\n" + << body.str() << "inline const OpSchemaEntry entries[] = {\n" + << entries.str() << "};\n\n} // namespace generated\n\n" + << "inline const OpSchemaEntry* opSchemaEntries(std::size_t& count)\n{\n" + << " count = sizeof(generated::entries) / sizeof(generated::entries[0]);\n" + << " return generated::entries;\n}\n\n" + << "inline const OpSchemaEntry* lookupOpByName(std::string_view opcode)\n{\n" + << " for(const auto& e : generated::entries)\n if(e.opcode == opcode)\n" + << " return &e;\n return nullptr;\n}\n\n" + << "inline const OpSchemaEntry* lookupOpByType(int attributesType)\n{\n" + << " for(const auto& e : generated::entries)\n if(e.attributesType == " + "attributesType)\n" + << " return &e;\n return nullptr;\n}\n\n" + << "} // namespace hipdnn_flatbuffers_sdk::umd\n"; + + std::ofstream ofs(outPath, std::ios::binary); + if(!ofs) + fail("cannot open output for writing: " + outPath); + ofs << hdr.str(); + if(!ofs) + fail("failed writing output: " + outPath); + + std::fprintf( + stderr, "umd_registry_gen: emitted %zu op entries to %s\n", opCount, outPath.c_str()); + return 0; +} From 4e8bb7b43e73b5cfba77fd4f901734f5eff7007f Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 11:36:11 -0600 Subject: [PATCH 39/60] Lowercase SDPA q/k/v/o UMD role names Change umd_name for the SDPA Q/K/V/O tensor UIDs to lowercase q/k/v/o and regenerate the op-schema registry; update the registry tests to match. (cherry picked from commit e668eb16a4efb26ba308f854ce8de63161322ecc) (cherry picked from commit 40b508cbce467e8b5d1b633584f20fd6e4ac0132) --- .../umd/op_schema_registry_generated.hpp | 8 ++++---- .../flatbuffers_sdk/schemas/sdpa_attributes.fbs | 8 ++++---- .../tests/umd/TestOpSchemaRegistry.cpp | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index 4159b69f50fc..346968a7d451 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -155,11 +155,11 @@ inline const OperandBinding operands[] = { {"descale_v", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->descale_v_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"dropout_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->dropout_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"dropout_scale", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->dropout_scale_tensor_uid(); if(!v) return false; out = *v; return true; }}, - {"K", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->k_tensor_uid(); return true; }}, + {"k", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->k_tensor_uid(); return true; }}, {"offset", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->offset_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"page_table_k", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->page_table_k_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"page_table_v", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->page_table_v_tensor_uid(); if(!v) return false; out = *v; return true; }}, - {"Q", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->q_tensor_uid(); return true; }}, + {"q", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->q_tensor_uid(); return true; }}, {"scale_o", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->scale_o_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"scale_s", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->scale_s_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"scale", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->scale_tensor_uid(); if(!v) return false; out = *v; return true; }}, @@ -167,13 +167,13 @@ inline const OperandBinding operands[] = { {"seq_len_kv", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->seq_len_kv_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"seq_len_q", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->seq_len_q_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"sink_token", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->sink_token_tensor_uid(); if(!v) return false; out = *v; return true; }}, - {"V", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->v_tensor_uid(); return true; }}, + {"v", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->v_tensor_uid(); return true; }}, }; inline const ResultBinding results[] = { {"amax_o", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->amax_o_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"amax_s", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->amax_s_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"max", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->max_tensor_uid(); if(!v) return false; out = *v; return true; }}, - {"O", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->o_tensor_uid(); return true; }}, + {"o", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->o_tensor_uid(); return true; }}, {"rng_dump", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->rng_dump_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"stats", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->stats_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"sum_exp", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->sum_exp_tensor_uid(); if(!v) return false; out = *v; return true; }}, diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs index ebb12ca6e9b2..8657b901bba6 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs @@ -18,12 +18,12 @@ enum AttentionImplementation : byte { table SdpaAttributes (umd_opcode: "sdpa_fwd") { // Required input tensor UIDs - q_tensor_uid: long (umd_operand, umd_name: "Q"); - k_tensor_uid: long (umd_operand, umd_name: "K"); - v_tensor_uid: long (umd_operand, umd_name: "V"); + q_tensor_uid: long (umd_operand, umd_name: "q"); + k_tensor_uid: long (umd_operand, umd_name: "k"); + v_tensor_uid: long (umd_operand, umd_name: "v"); // Required output tensor UID - o_tensor_uid: long (umd_result, umd_name: "O"); + o_tensor_uid: long (umd_result, umd_name: "o"); // Optional input tensor UIDs attn_mask_tensor_uid: long = null (umd_operand, umd_name: "attn_mask"); // Additive attention bias (Bias) diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp index 0a85587a40f0..223eba642c3c 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -93,13 +93,13 @@ TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); - for(std::string_view role : {"Q", "K", "V"}) + for(std::string_view role : {"q", "k", "v"}) { const auto* op = findOperand(e, role); ASSERT_NE(op, nullptr) << role; EXPECT_FALSE(op->optional) << role; } - const auto* o = findResult(e, "O"); + const auto* o = findResult(e, "o"); ASSERT_NE(o, nullptr); EXPECT_FALSE(o->optional); } @@ -173,13 +173,13 @@ TEST(UmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) const void* a = attrs; std::int64_t uid = 0; - ASSERT_TRUE(findOperand(e, "Q")->read(a, uid)); + ASSERT_TRUE(findOperand(e, "q")->read(a, uid)); EXPECT_EQ(uid, 101); - ASSERT_TRUE(findOperand(e, "K")->read(a, uid)); + ASSERT_TRUE(findOperand(e, "k")->read(a, uid)); EXPECT_EQ(uid, 102); - ASSERT_TRUE(findOperand(e, "V")->read(a, uid)); + ASSERT_TRUE(findOperand(e, "v")->read(a, uid)); EXPECT_EQ(uid, 103); - ASSERT_TRUE(findResult(e, "O")->read(a, uid)); + ASSERT_TRUE(findResult(e, "o")->read(a, uid)); EXPECT_EQ(uid, 201); } From 87b478df793fa684601434ec90edd594b0b69903 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 11:47:58 -0600 Subject: [PATCH 40/60] Rename UMD tensor annotations and bindings to input/output tensor terminology Systematic terminology migration operand->input tensor, result->output tensor: - FlatBuffers attributes: umd_operand -> umd_input_tensor, umd_result -> umd_output_tensor (data_types.fbs, sdpa_attributes.fbs, B.3 fixture). - Neutral registry types: OperandBinding -> InputTensorBinding, ResultBinding -> OutputTensorBinding; OpSchemaEntry fields operands/operandCount/results/ resultCount -> inputTensors/inputTensorCount/outputTensors/outputTensorCount. - Generator: inputTensorFlag/outputTensorFlag, matching Emitted fields, emitted array names, and diagnostics; regenerated the registry header. - Tests: findInputTensor/findOutputTensor helpers and call sites. - RFC 0018, PoC plan, and cmake comment updated to the new terminology; RFC example role strings lowercased to match the schema. (cherry picked from commit 7a0af44a6902547df13b37f6dfd4a3ce3e1be4b2) (cherry picked from commit c8f945a19b631db0290e08025aae103615c9bf2e) --- 0018_UMD_PoC_Plan.md | 4 +- .../hipdnn/cmake/FlatBuffersGenerate.cmake | 2 +- .../rfcs/0018_UniversalMatchDescriptor.md | 39 +++++------ .../umd/OpSchemaRegistry.hpp | 16 ++--- .../umd/op_schema_registry_generated.hpp | 6 +- .../flatbuffers_sdk/schemas/data_types.fbs | 12 ++-- .../schemas/sdpa_attributes.fbs | 56 ++++++++-------- .../tests/umd/TestOpSchemaRegistry.cpp | 36 +++++----- .../tests/umd/fixtures/bad_nonlong.fbs | 12 ++-- .../umd_registry_gen/umd_registry_gen.cpp | 67 ++++++++++--------- 10 files changed, 127 insertions(+), 123 deletions(-) diff --git a/0018_UMD_PoC_Plan.md b/0018_UMD_PoC_Plan.md index 3b5ed17d6c99..272388e9cd39 100644 --- a/0018_UMD_PoC_Plan.md +++ b/0018_UMD_PoC_Plan.md @@ -26,7 +26,7 @@ Source RFC: `projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md`. **Gap: no `shape` short-hand (lowered by the UMD compiler, Phase 1). The custom-operation (native-predicate) hook is out of scope for this PoC — see Scope decisions.** - **UMD annotations already in the schema** — the table-level `umd_opcode` shorthand (e.g. - `SdpaAttributes (umd_opcode: "sdpa_fwd")`) and the field-level `umd_operand` / `umd_result` / + `SdpaAttributes (umd_opcode: "sdpa_fwd")`) and the field-level `umd_input_tensor` / `umd_output_tensor` / `umd_name` are declared once (`flatbuffers_sdk/schemas/data_types.fbs`) and applied on the SDPA table + its UID fields (`flatbuffers_sdk/schemas/sdpa_attributes.fbs`). But flatc runs `--cpp` only (`cmake/flatc_flags.txt`); no `.bfbs` is emitted, so the annotations are invisible at @@ -73,7 +73,7 @@ UID reader) and scalar attributes (name, optionality, typed reader). reflection (`flatbuffers/reflection.h`). Loads `graph.bfbs`, enumerates `NodeAttributes` union members (opcode→table); reads the table's `umd_opcode` shorthand and applies B.3 classification per field: - - `umd_operand` + `umd_name` → operand role; `umd_result` + `umd_name` → result role + - `umd_input_tensor` + `umd_name` → operand role; `umd_output_tensor` + `umd_name` → result role (type MUST be `long`, `umd_name` non-empty). - neither flag → scalar attribute, bind-named by field name. - optionality derived from `= null` default (not re-annotated). diff --git a/projects/hipdnn/cmake/FlatBuffersGenerate.cmake b/projects/hipdnn/cmake/FlatBuffersGenerate.cmake index f461fe7044b6..304341ee31b8 100644 --- a/projects/hipdnn/cmake/FlatBuffersGenerate.cmake +++ b/projects/hipdnn/cmake/FlatBuffersGenerate.cmake @@ -151,7 +151,7 @@ endfunction() # Generate a FlatBuffers binary reflection schema (.bfbs) from one .fbs schema. # Unlike the C++ header path (flatc --cpp), the reflection schema retains custom -# field attributes (umd_operand / umd_result / umd_name), which the +# field attributes (umd_input_tensor / umd_output_tensor / umd_name), which the # umd_registry_gen tool reads via the FlatBuffers reflection API to emit the UMD # op-schema registry (RFC 0018 Appendix B). flatc writes .bfbs into # the -o directory; OUTPUT MUST name that file. diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 05ecb745249f..a4a685d99b60 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -106,9 +106,10 @@ attribute names the op, and field-level attributes on each op's attribute table contract next to the field they govern. `umd_opcode` on the attribute table gives the op's UMD-facing opcode (`SdpaAttributes (umd_opcode: "sdpa_fwd")`); a UMD node's `op` names it, and the registry keys on it (falling back to the table type name when the attribute is absent), so the schema is the single -source of truth for the opcode rather than an ad-hoc string. `umd_operand` / `umd_result` mark a UID -field and `umd_name` names it, so SDPA's operand Q is `q_tensor_uid: long (umd_operand, umd_name: "Q")` -and its result O is `o_tensor_uid: long (umd_result, umd_name: "O")`. Optionality is not re-annotated: +source of truth for the opcode rather than an ad-hoc string. `umd_input_tensor` / `umd_output_tensor` +mark a UID field and `umd_name` names it, so SDPA's Q operand is +`q_tensor_uid: long (umd_input_tensor, umd_name: "q")` and its O result is +`o_tensor_uid: long (umd_output_tensor, umd_name: "o")`. Optionality is not re-annotated: a UID field's `= null` default already encodes it (`attn_mask_tensor_uid: long = null`), and the `NodeAttributes` union already maps each opcode to its table, so every unannotated *scalar* field is a scalar attribute by elimination (unannotated non-scalar fields — vectors, sub-tables — are not @@ -755,7 +756,7 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m - **Op-schema registry coupling.** Auto-binding depends on a registry generated from the flatbuffer op schema ([§2](#2-the-matchers-input-hipdnns-graph-model)). If it drifts from the graph definitions, bindings are wrong. Mitigation: generate it from the schema's own `umd_opcode` table attribute and - `umd_operand` / `umd_result` / `umd_name` field annotations (never from field-name conventions), so a + `umd_input_tensor` / `umd_output_tensor` / `umd_name` field annotations (never from field-name conventions), so a new or renamed operand carries its binding contract in the same edit ([§2](#2-the-matchers-input-hipdnns-graph-model)), and fail closed on an unknown op or role rather than binding a wrong field. @@ -1081,16 +1082,16 @@ retained in the binary reflection schema (`.bfbs`), which is what the generator ```fbs attribute "umd_opcode"; // table: the op's UMD-facing opcode (e.g. "sdpa_fwd") -attribute "umd_operand"; // field flag: this field is an operand tensor UID -attribute "umd_result"; // field flag: this field is a result tensor UID -attribute "umd_name"; // field string: the name the UMD binds it by (e.g. "Q", "O") +attribute "umd_input_tensor"; // field flag: this field is an input (operand) tensor UID +attribute "umd_output_tensor"; // field flag: this field is an output (result) tensor UID +attribute "umd_name"; // field string: the name the UMD binds it by (e.g. "q", "o") ``` `umd_opcode` is a **table-level** attribute (applied in parentheses after the table name); the other three are field-level. No separate operand/result type attribute is needed: an operand or result always binds a `Tensor` — a tensor UID is the only edge kind in a UID-centric graph ([§2](#2-the-matchers-input-hipdnns-graph-model)) — and every scalar attribute is precisely an -unannotated scalar field (B.3). The `long` UID field type and the `umd_operand`/`umd_result` flag +unannotated scalar field (B.3). The `long` UID field type and the `umd_input_tensor`/`umd_output_tensor` flag together already fix what the binding is. ### B.2 Annotated schema @@ -1101,11 +1102,11 @@ derives required-vs-optional from the field's presence semantics rather than a f ```fbs table SdpaAttributes (umd_opcode: "sdpa_fwd") { // table-level opcode shorthand - q_tensor_uid:long (umd_operand, umd_name: "Q"); // required operand - k_tensor_uid:long (umd_operand, umd_name: "K"); - v_tensor_uid:long (umd_operand, umd_name: "V"); - o_tensor_uid:long (umd_result, umd_name: "O"); // required result - attn_mask_tensor_uid:long = null (umd_operand, umd_name: "attn_mask"); // optional operand + q_tensor_uid:long (umd_input_tensor, umd_name: "q"); // required input + k_tensor_uid:long (umd_input_tensor, umd_name: "k"); + v_tensor_uid:long (umd_input_tensor, umd_name: "v"); + o_tensor_uid:long (umd_output_tensor, umd_name: "o"); // required output + attn_mask_tensor_uid:long = null (umd_input_tensor, umd_name: "attn_mask"); // optional input // ... other optional UID operands, likewise annotated ... dropout_probability:float = null; // unannotated -> scalar attribute alibi_mask:bool = false; // unannotated -> scalar attribute @@ -1122,16 +1123,16 @@ build** rather than emitting a wrong registry: | Field carries | Classified as | Requirements | |---|---|---| -| `umd_operand` + `umd_name` | operand edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | -| `umd_result` + `umd_name` | result edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | +| `umd_input_tensor` + `umd_name` | input (operand) edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | +| `umd_output_tensor` + `umd_name` | output (result) edge for that name | field type MUST be an integer UID (`long`); `umd_name` MUST be non-empty | | neither flag, a **scalar** field | scalar attribute, named by the field name | — | | neither flag, a **non-scalar** field (vector, sub-table, union, string) | skipped (not a UMD scalar) | — | - **Optionality** is derived, not annotated: a field with a `= null` default (an optional UID or an optional scalar) is optional; it supplies the `?`-binding of [Appendix A.2](#a2-node-object-and-opcode-selector) and the `.present` field of [Appendix A.4](#a4-variable-references-and-the-five-namespaces). -- **Build errors (fail closed):** `umd_operand` and `umd_result` on the same field; `umd_name` without - either flag; `umd_operand`/`umd_result` on a non-integer field; a duplicate `umd_name` within one op; +- **Build errors (fail closed):** `umd_input_tensor` and `umd_output_tensor` on the same field; `umd_name` without + either flag; `umd_input_tensor`/`umd_output_tensor` on a non-integer field; a duplicate `umd_name` within one op; an operand/result whose role name collides with a reserved token ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)); or a duplicate `umd_opcode` across ops. @@ -1141,7 +1142,7 @@ build** rather than emitting a wrong registry: enum-value name string (e.g. `diagonal_alignment` → `"TOP_LEFT"`). This mirrors the tensor `dtype` representation and lets a criterion compare an enum attribute against a literal enum name. - **No name-suffix inference.** `PointwiseAttributes::axis_tensor_uid` is a plain axis index, not a - tensor UID; because it carries no `umd_operand`/`umd_result` it is classified a scalar attribute, + tensor UID; because it carries no `umd_input_tensor`/`umd_output_tensor` it is classified a scalar attribute, exactly as intended — nothing keys off the `_tensor_uid` suffix. - **Scalar attributes need no annotation, and are still fully bound.** An annotation carries the two facts that cannot be inferred for an *edge*: that a `long` field is a tensor UID rather than a plain @@ -1210,7 +1211,7 @@ graph binds `.present = false` and is read only through a guarded reference or ` ### B.6 Lockstep and failure modes -- **Lockstep.** Adding or renaming an operand is one `.fbs` edit that carries its `umd_operand` / +- **Lockstep.** Adding or renaming an operand is one `.fbs` edit that carries its `umd_input_tensor` / `umd_name` with it; the next build regenerates the registry, so a UMD naming that name resolves and a UMD naming a role that no longer exists fails compile ([Appendix A.10](#a10-compile-time-validation-normative)). diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp index 34d35f925844..19a3e8d9000a 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp @@ -12,7 +12,7 @@ // consumer. // // The registry reconstructs a UID-centric graph's edges and auto-binds symbols: -// each op entry lists its operand and result roles (each with a typed UID +// each op entry lists its input-tensor and output-tensor roles (each with a typed // reader) and its scalar attributes (each with a typed value reader). Readers // use the generated FlatBuffers accessors directly -- no runtime reflection. @@ -60,14 +60,14 @@ using UidReader = bool (*)(const void* attributes, std::int64_t& out); // Reads a scalar attribute value out of a concrete attribute table. using ScalarReader = ScalarValue (*)(const void* attributes); -struct OperandBinding +struct InputTensorBinding { std::string_view role; bool optional = false; UidReader read = nullptr; }; -struct ResultBinding +struct OutputTensorBinding { std::string_view role; bool optional = false; @@ -87,17 +87,17 @@ struct AttrBinding // when the attribute is absent), the `tableName` (the NodeAttributes union member, // e.g. "SdpaAttributes", for diagnostics), and the integer `attributesType` (the // value of the NodeAttributes enum, for O(1) lookup against -// Node::attributes_type()), plus its operand, result, and scalar-attribute +// Node::attributes_type()), plus its input-tensor, output-tensor, and scalar // bindings. struct OpSchemaEntry { std::string_view opcode; std::string_view tableName; int attributesType = 0; - const OperandBinding* operands = nullptr; - std::size_t operandCount = 0; - const ResultBinding* results = nullptr; - std::size_t resultCount = 0; + const InputTensorBinding* inputTensors = nullptr; + std::size_t inputTensorCount = 0; + const OutputTensorBinding* outputTensors = nullptr; + std::size_t outputTensorCount = 0; const AttrBinding* attributes = nullptr; std::size_t attributeCount = 0; }; diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index 346968a7d451..f283eba7b46a 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -146,7 +146,7 @@ inline const AttrBinding attributes[] = { } // namespace op10 namespace op11 { -inline const OperandBinding operands[] = { +inline const InputTensorBinding inputTensors[] = { {"attn_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->attn_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"block_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->block_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"descale_k", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->descale_k_tensor_uid(); if(!v) return false; out = *v; return true; }}, @@ -169,7 +169,7 @@ inline const OperandBinding operands[] = { {"sink_token", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->sink_token_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"v", false, +[](const void* a, std::int64_t& out) -> bool { out = static_cast(a)->v_tensor_uid(); return true; }}, }; -inline const ResultBinding results[] = { +inline const OutputTensorBinding outputTensors[] = { {"amax_o", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->amax_o_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"amax_s", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->amax_s_tensor_uid(); if(!v) return false; out = *v; return true; }}, {"max", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->max_tensor_uid(); if(!v) return false; out = *v; return true; }}, @@ -307,7 +307,7 @@ inline const OpSchemaEntry entries[] = { {"MatmulAttributes", "MatmulAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::MatmulAttributes), nullptr, 0u, nullptr, 0u, op8::attributes, 3u}, {"RMSNormAttributes", "RMSNormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormAttributes), nullptr, 0u, nullptr, 0u, op9::attributes, 7u}, {"LayernormAttributes", "LayernormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormAttributes), nullptr, 0u, nullptr, 0u, op10::attributes, 9u}, - {"sdpa_fwd", "SdpaAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaAttributes), op11::operands, 21u, op11::results, 7u, op11::attributes, 13u}, + {"sdpa_fwd", "SdpaAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaAttributes), op11::inputTensors, 21u, op11::outputTensors, 7u, op11::attributes, 13u}, {"BlockScaleDequantizeAttributes", "BlockScaleDequantizeAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleDequantizeAttributes), nullptr, 0u, nullptr, 0u, op12::attributes, 4u}, {"BlockScaleQuantizeAttributes", "BlockScaleQuantizeAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleQuantizeAttributes), nullptr, 0u, nullptr, 0u, op13::attributes, 6u}, {"SdpaBackwardAttributes", "SdpaBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaBackwardAttributes), nullptr, 0u, nullptr, 0u, op14::attributes, 28u}, diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs index caa097b733ed..6c24d53118b7 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/data_types.fbs @@ -29,11 +29,11 @@ enum DataType : byte { // include, and applied to each op's attribute table. `umd_opcode` is a table-level // attribute giving the op's UMD-facing shorthand (e.g. "sdpa_fwd"); a UMD file's // node `op` names it, and the registry keys on it (falling back to the table type -// name when absent). `umd_operand` / `umd_result` mark a tensor-UID edge and -// `umd_name` gives the name a UMD binds it by; the operand/result flag already -// fixes that the binding is a tensor, so no separate type attribute is carried. -// Unannotated fields are scalar attributes. +// name when absent). `umd_input_tensor` / `umd_output_tensor` mark a tensor-UID +// edge and `umd_name` gives the name a UMD binds it by; the input/output flag +// already fixes that the binding is a tensor, so no separate type attribute is +// carried. Unannotated fields are scalar attributes. attribute "umd_opcode"; -attribute "umd_operand"; -attribute "umd_result"; +attribute "umd_input_tensor"; +attribute "umd_output_tensor"; attribute "umd_name"; diff --git a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs index 8657b901bba6..26a2538d9295 100644 --- a/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs +++ b/projects/hipdnn/flatbuffers_sdk/schemas/sdpa_attributes.fbs @@ -18,44 +18,44 @@ enum AttentionImplementation : byte { table SdpaAttributes (umd_opcode: "sdpa_fwd") { // Required input tensor UIDs - q_tensor_uid: long (umd_operand, umd_name: "q"); - k_tensor_uid: long (umd_operand, umd_name: "k"); - v_tensor_uid: long (umd_operand, umd_name: "v"); + q_tensor_uid: long (umd_input_tensor, umd_name: "q"); + k_tensor_uid: long (umd_input_tensor, umd_name: "k"); + v_tensor_uid: long (umd_input_tensor, umd_name: "v"); // Required output tensor UID - o_tensor_uid: long (umd_result, umd_name: "o"); + o_tensor_uid: long (umd_output_tensor, umd_name: "o"); // Optional input tensor UIDs - attn_mask_tensor_uid: long = null (umd_operand, umd_name: "attn_mask"); // Additive attention bias (Bias) - scale_tensor_uid: long = null (umd_operand, umd_name: "scale"); // Attention scale tensor (Attn_scale) - seq_len_q_tensor_uid: long = null (umd_operand, umd_name: "seq_len_q"); - seq_len_kv_tensor_uid: long = null (umd_operand, umd_name: "seq_len_kv"); - seed_tensor_uid: long = null (umd_operand, umd_name: "seed"); // Dropout seed - offset_tensor_uid: long = null (umd_operand, umd_name: "offset"); // Dropout offset - dropout_mask_tensor_uid: long = null (umd_operand, umd_name: "dropout_mask"); - dropout_scale_tensor_uid: long = null (umd_operand, umd_name: "dropout_scale"); - page_table_k_tensor_uid: long = null (umd_operand, umd_name: "page_table_k"); - page_table_v_tensor_uid: long = null (umd_operand, umd_name: "page_table_v"); - block_mask_tensor_uid: long = null (umd_operand, umd_name: "block_mask"); - sink_token_tensor_uid: long = null (umd_operand, umd_name: "sink_token"); + attn_mask_tensor_uid: long = null (umd_input_tensor, umd_name: "attn_mask"); // Additive attention bias (Bias) + scale_tensor_uid: long = null (umd_input_tensor, umd_name: "scale"); // Attention scale tensor (Attn_scale) + seq_len_q_tensor_uid: long = null (umd_input_tensor, umd_name: "seq_len_q"); + seq_len_kv_tensor_uid: long = null (umd_input_tensor, umd_name: "seq_len_kv"); + seed_tensor_uid: long = null (umd_input_tensor, umd_name: "seed"); // Dropout seed + offset_tensor_uid: long = null (umd_input_tensor, umd_name: "offset"); // Dropout offset + dropout_mask_tensor_uid: long = null (umd_input_tensor, umd_name: "dropout_mask"); + dropout_scale_tensor_uid: long = null (umd_input_tensor, umd_name: "dropout_scale"); + page_table_k_tensor_uid: long = null (umd_input_tensor, umd_name: "page_table_k"); + page_table_v_tensor_uid: long = null (umd_input_tensor, umd_name: "page_table_v"); + block_mask_tensor_uid: long = null (umd_input_tensor, umd_name: "block_mask"); + sink_token_tensor_uid: long = null (umd_input_tensor, umd_name: "sink_token"); // FP8-specific input tensor UIDs - descale_q_tensor_uid: long = null (umd_operand, umd_name: "descale_q"); - descale_k_tensor_uid: long = null (umd_operand, umd_name: "descale_k"); - descale_v_tensor_uid: long = null (umd_operand, umd_name: "descale_v"); - descale_s_tensor_uid: long = null (umd_operand, umd_name: "descale_s"); - scale_s_tensor_uid: long = null (umd_operand, umd_name: "scale_s"); - scale_o_tensor_uid: long = null (umd_operand, umd_name: "scale_o"); + descale_q_tensor_uid: long = null (umd_input_tensor, umd_name: "descale_q"); + descale_k_tensor_uid: long = null (umd_input_tensor, umd_name: "descale_k"); + descale_v_tensor_uid: long = null (umd_input_tensor, umd_name: "descale_v"); + descale_s_tensor_uid: long = null (umd_input_tensor, umd_name: "descale_s"); + scale_s_tensor_uid: long = null (umd_input_tensor, umd_name: "scale_s"); + scale_o_tensor_uid: long = null (umd_input_tensor, umd_name: "scale_o"); // Optional output tensor UIDs - stats_tensor_uid: long = null (umd_result, umd_name: "stats"); - max_tensor_uid: long = null (umd_result, umd_name: "max"); // Logit max (Max) - sum_exp_tensor_uid: long = null (umd_result, umd_name: "sum_exp"); // Score sum exp (Sum_exp) - rng_dump_tensor_uid: long = null (umd_result, umd_name: "rng_dump"); // RNG dump for dropout debugging + stats_tensor_uid: long = null (umd_output_tensor, umd_name: "stats"); + max_tensor_uid: long = null (umd_output_tensor, umd_name: "max"); // Logit max (Max) + sum_exp_tensor_uid: long = null (umd_output_tensor, umd_name: "sum_exp"); // Score sum exp (Sum_exp) + rng_dump_tensor_uid: long = null (umd_output_tensor, umd_name: "rng_dump"); // RNG dump for dropout debugging // FP8-specific output tensor UIDs - amax_s_tensor_uid: long = null (umd_result, umd_name: "amax_s"); // Absolute max for attention scores - amax_o_tensor_uid: long = null (umd_result, umd_name: "amax_o"); // Absolute max for output tensor + amax_s_tensor_uid: long = null (umd_output_tensor, umd_name: "amax_s"); // Absolute max for attention scores + amax_o_tensor_uid: long = null (umd_output_tensor, umd_name: "amax_o"); // Absolute max for output tensor // Boolean flags generate_stats: bool = null; diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp index 223eba642c3c..70d4d8028ca8 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -27,19 +27,19 @@ namespace data = hipdnn_flatbuffers_sdk::data_objects; namespace { -const umd::OperandBinding* findOperand(const umd::OpSchemaEntry* e, std::string_view role) +const umd::InputTensorBinding* findInputTensor(const umd::OpSchemaEntry* e, std::string_view role) { - for(std::size_t i = 0; i < e->operandCount; ++i) - if(e->operands[i].role == role) - return &e->operands[i]; + for(std::size_t i = 0; i < e->inputTensorCount; ++i) + if(e->inputTensors[i].role == role) + return &e->inputTensors[i]; return nullptr; } -const umd::ResultBinding* findResult(const umd::OpSchemaEntry* e, std::string_view role) +const umd::OutputTensorBinding* findOutputTensor(const umd::OpSchemaEntry* e, std::string_view role) { - for(std::size_t i = 0; i < e->resultCount; ++i) - if(e->results[i].role == role) - return &e->results[i]; + for(std::size_t i = 0; i < e->outputTensorCount; ++i) + if(e->outputTensors[i].role == role) + return &e->outputTensors[i]; return nullptr; } @@ -95,11 +95,11 @@ TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) ASSERT_NE(e, nullptr); for(std::string_view role : {"q", "k", "v"}) { - const auto* op = findOperand(e, role); + const auto* op = findInputTensor(e, role); ASSERT_NE(op, nullptr) << role; EXPECT_FALSE(op->optional) << role; } - const auto* o = findResult(e, "o"); + const auto* o = findOutputTensor(e, "o"); ASSERT_NE(o, nullptr); EXPECT_FALSE(o->optional); } @@ -110,14 +110,14 @@ TEST(UmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) ASSERT_NE(e, nullptr); for(std::string_view role : {"attn_mask", "page_table_k", "page_table_v", "scale", "seed"}) { - const auto* op = findOperand(e, role); + const auto* op = findInputTensor(e, role); ASSERT_NE(op, nullptr) << role; EXPECT_TRUE(op->optional) << role; } // Optional results too. for(std::string_view role : {"stats", "amax_s", "amax_o"}) { - const auto* r = findResult(e, role); + const auto* r = findOutputTensor(e, role); ASSERT_NE(r, nullptr) << role; EXPECT_TRUE(r->optional) << role; } @@ -173,13 +173,13 @@ TEST(UmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) const void* a = attrs; std::int64_t uid = 0; - ASSERT_TRUE(findOperand(e, "q")->read(a, uid)); + ASSERT_TRUE(findInputTensor(e, "q")->read(a, uid)); EXPECT_EQ(uid, 101); - ASSERT_TRUE(findOperand(e, "k")->read(a, uid)); + ASSERT_TRUE(findInputTensor(e, "k")->read(a, uid)); EXPECT_EQ(uid, 102); - ASSERT_TRUE(findOperand(e, "v")->read(a, uid)); + ASSERT_TRUE(findInputTensor(e, "v")->read(a, uid)); EXPECT_EQ(uid, 103); - ASSERT_TRUE(findResult(e, "o")->read(a, uid)); + ASSERT_TRUE(findOutputTensor(e, "o")->read(a, uid)); EXPECT_EQ(uid, 201); } @@ -192,8 +192,8 @@ TEST(UmdOpSchemaRegistry, AbsentOptionalOperandReadsFalse) const void* a = attrs; std::int64_t uid = -1; - EXPECT_FALSE(findOperand(e, "attn_mask")->read(a, uid)); - EXPECT_FALSE(findOperand(e, "page_table_k")->read(a, uid)); + EXPECT_FALSE(findInputTensor(e, "attn_mask")->read(a, uid)); + EXPECT_FALSE(findInputTensor(e, "page_table_k")->read(a, uid)); } TEST(UmdOpSchemaRegistry, ScalarReadersReflectPresenceAndValue) diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs b/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs index 085acbd48673..47c0a3f07b6a 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/fixtures/bad_nonlong.fbs @@ -1,14 +1,14 @@ -// RFC 0018 B.3 negative fixture: a umd_operand on a non-`long` field must fail -// registry generation (fail closed), not emit a wrong binding. Consumed by the -// umd_registry_gen_rejects_bad_schema CTest, which expects a non-zero exit. +// RFC 0018 B.3 negative fixture: a umd_input_tensor on a non-`long` field must +// fail registry generation (fail closed), not emit a wrong binding. Consumed by +// the umd_registry_gen_rejects_bad_schema CTest, which expects a non-zero exit. namespace hipdnn_flatbuffers_sdk.data_objects; -attribute "umd_operand"; -attribute "umd_result"; +attribute "umd_input_tensor"; +attribute "umd_output_tensor"; attribute "umd_name"; table BadAttributes { - x: int (umd_operand, umd_name: "X"); // violation: operand UID must be `long` + x: int (umd_input_tensor, umd_name: "X"); // violation: input tensor UID must be `long` } union NodeAttributes { diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp index ec5e741a1bdc..f558fb56caa6 100644 --- a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -10,8 +10,8 @@ // FlatBuffers accessors directly -- no runtime reflection (RFC 0018 B.4). // // Field classification (B.3): -// * umd_operand + umd_name -> operand edge (field MUST be a `long` UID) -// * umd_result + umd_name -> result edge (field MUST be a `long` UID) +// * umd_input_tensor + umd_name -> input-tensor edge (field MUST be a `long` UID) +// * umd_output_tensor + umd_name -> output-tensor edge (field MUST be a `long` UID) // * neither flag, scalar -> scalar attribute, bind-named by field name // * neither flag, non-scalar-> skipped (a vector/table/union is not a UMD // scalar; the RFC's attribute tables are UID + @@ -118,15 +118,15 @@ constexpr const char* kNs = "::hipdnn_flatbuffers_sdk::data_objects"; struct Emitted { - std::string operands; // OperandBinding initializers - std::string results; // ResultBinding initializers + std::string inputTensors; // InputTensorBinding initializers + std::string outputTensors; // OutputTensorBinding initializers std::string attributes; // AttrBinding initializers - std::size_t operandCount = 0; - std::size_t resultCount = 0; + std::size_t inputTensorCount = 0; + std::size_t outputTensorCount = 0; std::size_t attributeCount = 0; }; -// Emit the reader lambda + binding line for one operand/result UID field. +// Emit the reader lambda + binding line for one input/output tensor UID field. std::string emitUidBinding(const std::string& table, const std::string& role, const std::string& acc, @@ -227,24 +227,25 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object for(const auto* fld : *obj->fields()) { const std::string name = fld->name()->str(); - const auto* opFlag = attr(fld, "umd_operand"); - const auto* resFlag = attr(fld, "umd_result"); + const auto* inputTensorFlag = attr(fld, "umd_input_tensor"); + const auto* outputTensorFlag = attr(fld, "umd_output_tensor"); const auto* nameAttr = attr(fld, "umd_name"); const auto base = fld->type()->base_type(); - if(opFlag && resFlag) - fail(table + "." + name + ": has both umd_operand and umd_result"); + if(inputTensorFlag && outputTensorFlag) + fail(table + "." + name + ": has both umd_input_tensor and umd_output_tensor"); - const bool isEdge = opFlag || resFlag; + const bool isEdge = inputTensorFlag || outputTensorFlag; if(isEdge) { if(!nameAttr) - fail(table + "." + name + ": umd_operand/umd_result requires umd_name"); + fail(table + "." + name + ": umd_input_tensor/umd_output_tensor requires umd_name"); const std::string role = nameAttr->value() ? nameAttr->value()->str() : ""; if(role.empty()) fail(table + "." + name + ": umd_name must be non-empty"); if(base != reflection::Long) - fail(table + "." + name + ": umd_operand/umd_result field must be `long` (a UID)"); + fail(table + "." + name + + ": umd_input_tensor/umd_output_tensor field must be `long` (a UID)"); if(isReservedRoot(role)) fail(table + "." + name + ": role name '" + role + "' collides with a reserved root (graph/kernel/device)"); @@ -253,21 +254,21 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object const std::string acc = accessorName(name); const bool optl = fld->optional(); - if(opFlag) + if(inputTensorFlag) { - out.operands += emitUidBinding(table, role, acc, optl); - ++out.operandCount; + out.inputTensors += emitUidBinding(table, role, acc, optl); + ++out.inputTensorCount; } else { - out.results += emitUidBinding(table, role, acc, optl); - ++out.resultCount; + out.outputTensors += emitUidBinding(table, role, acc, optl); + ++out.outputTensorCount; } continue; } if(nameAttr) - fail(table + "." + name + ": umd_name without umd_operand/umd_result"); + fail(table + "." + name + ": umd_name without umd_input_tensor/umd_output_tensor"); // Unannotated: a scalar attribute (if a scalar base type). Non-scalar // fields (vector/table/union/string) are not UMD scalars -- skip. @@ -330,21 +331,23 @@ int main(int argc, char** argv) const Emitted em = classifyTable(schema, obj); const std::string tag = "op" + std::to_string(opCount); - std::string operandsRef = "nullptr"; - std::string resultsRef = "nullptr"; + std::string inputTensorsRef = "nullptr"; + std::string outputTensorsRef = "nullptr"; std::string attrsRef = "nullptr"; - const bool anyTable = em.operandCount || em.resultCount || em.attributeCount; + const bool anyTable = em.inputTensorCount || em.outputTensorCount || em.attributeCount; if(anyTable) body << "namespace " << tag << " {\n"; - if(em.operandCount) + if(em.inputTensorCount) { - body << "inline const OperandBinding operands[] = {\n" << em.operands << "};\n"; - operandsRef = tag + "::operands"; + body << "inline const InputTensorBinding inputTensors[] = {\n" + << em.inputTensors << "};\n"; + inputTensorsRef = tag + "::inputTensors"; } - if(em.resultCount) + if(em.outputTensorCount) { - body << "inline const ResultBinding results[] = {\n" << em.results << "};\n"; - resultsRef = tag + "::results"; + body << "inline const OutputTensorBinding outputTensors[] = {\n" + << em.outputTensors << "};\n"; + outputTensorsRef = tag + "::outputTensors"; } if(em.attributeCount) { @@ -355,9 +358,9 @@ int main(int argc, char** argv) body << "} // namespace " << tag << "\n\n"; entries << " {\"" << opcode << "\", \"" << member << "\", static_cast(" << kNs - << "::NodeAttributes::" << member << "), " << operandsRef << ", " << em.operandCount - << "u, " << resultsRef << ", " << em.resultCount << "u, " << attrsRef << ", " - << em.attributeCount << "u},\n"; + << "::NodeAttributes::" << member << "), " << inputTensorsRef << ", " + << em.inputTensorCount << "u, " << outputTensorsRef << ", " << em.outputTensorCount + << "u, " << attrsRef << ", " << em.attributeCount << "u},\n"; ++opCount; } From 96f460a86aeddb23bbd508f81bda2eb4abf071c1 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 11:54:16 -0600 Subject: [PATCH 41/60] Comment each generated op namespace with its source fbs table name umd_registry_gen now emits a // comment above every namespace opXX in the generated registry, so a reader can map a per-op block back to its FlatBuffers attribute table (e.g. // SdpaAttributes over namespace op11). (cherry picked from commit ad99eb9c55d6a09a81d3005b354cfcff93c27d3f) (cherry picked from commit 47de36a3ad4259cc6b46be42693466d5201915ed) --- .../umd/op_schema_registry_generated.hpp | 19 +++++++++++++++++++ .../umd_registry_gen/umd_registry_gen.cpp | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index f283eba7b46a..a4a606fbb6d0 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -14,6 +14,7 @@ namespace hipdnn_flatbuffers_sdk::umd namespace generated { +// BatchnormInferenceAttributes namespace op0 { inline const AttrBinding attributes[] = { {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, @@ -25,6 +26,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op0 +// PointwiseAttributes namespace op1 { inline const AttrBinding attributes[] = { {"axis_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->axis_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, @@ -42,6 +44,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op1 +// BatchnormBackwardAttributes namespace op2 { inline const AttrBinding attributes[] = { {"dbias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, @@ -55,6 +58,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op2 +// BatchnormAttributes namespace op3 { inline const AttrBinding attributes[] = { {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, @@ -72,6 +76,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op3 +// ConvolutionFwdAttributes namespace op4 { inline const AttrBinding attributes[] = { {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, @@ -81,6 +86,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op4 +// ConvolutionBwdAttributes namespace op5 { inline const AttrBinding attributes[] = { {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, @@ -90,6 +96,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op5 +// ConvolutionWrwAttributes namespace op6 { inline const AttrBinding attributes[] = { {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, @@ -99,6 +106,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op6 +// BatchnormInferenceAttributesVarianceExt namespace op7 { inline const AttrBinding attributes[] = { {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, @@ -111,6 +119,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op7 +// MatmulAttributes namespace op8 { inline const AttrBinding attributes[] = { {"a_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->a_tensor_uid()); return s; }}, @@ -119,6 +128,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op8 +// RMSNormAttributes namespace op9 { inline const AttrBinding attributes[] = { {"bias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->bias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, @@ -131,6 +141,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op9 +// LayernormAttributes namespace op10 { inline const AttrBinding attributes[] = { {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, @@ -145,6 +156,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op10 +// SdpaAttributes namespace op11 { inline const InputTensorBinding inputTensors[] = { {"attn_mask", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->attn_mask_tensor_uid(); if(!v) return false; out = *v; return true; }}, @@ -195,6 +207,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op11 +// BlockScaleDequantizeAttributes namespace op12 { inline const AttrBinding attributes[] = { {"is_negative_scale", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->is_negative_scale(); return s; }}, @@ -204,6 +217,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op12 +// BlockScaleQuantizeAttributes namespace op13 { inline const AttrBinding attributes[] = { {"axis", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->axis(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, @@ -215,6 +229,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op13 +// SdpaBackwardAttributes namespace op14 { inline const AttrBinding attributes[] = { {"alibi_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, @@ -248,6 +263,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op14 +// RMSNormBackwardAttributes namespace op16 { inline const AttrBinding attributes[] = { {"dbias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, @@ -260,6 +276,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op16 +// ReductionAttributes namespace op17 { inline const AttrBinding attributes[] = { {"in_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->in_tensor_uid()); return s; }}, @@ -269,6 +286,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op17 +// ResampleFwdAttributes namespace op18 { inline const AttrBinding attributes[] = { {"generate_index", true, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; auto v = static_cast(a)->generate_index(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, @@ -280,6 +298,7 @@ inline const AttrBinding attributes[] = { }; } // namespace op18 +// LayernormBackwardAttributes namespace op19 { inline const AttrBinding attributes[] = { {"dbias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp index f558fb56caa6..62a7b985b62d 100644 --- a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -336,7 +336,7 @@ int main(int argc, char** argv) std::string attrsRef = "nullptr"; const bool anyTable = em.inputTensorCount || em.outputTensorCount || em.attributeCount; if(anyTable) - body << "namespace " << tag << " {\n"; + body << "// " << table << "\nnamespace " << tag << " {\n"; if(em.inputTensorCount) { body << "inline const InputTensorBinding inputTensors[] = {\n" From f5b566eecd32dda7fa544c5c2f8d3baceee10b2c Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 12:03:19 -0600 Subject: [PATCH 42/60] Rename binding 'role' field to 'name' in op-schema registry The input/output tensor bindings looked up a tensor by a field called 'role'; rename it to 'name' for consistency with AttrBinding.name and the umd_name annotation it carries. Updates the neutral types, the generator (bindName local, seenNames, diagnostics) and regenerated header, and the tests. (cherry picked from commit d0aca816fd771978b2e584dd1d159494407a19dc) (cherry picked from commit 46c5ea565768a35c239e5edd254eb24c9bd36591) --- .../umd/OpSchemaRegistry.hpp | 12 +++---- .../tests/umd/TestOpSchemaRegistry.cpp | 36 +++++++++---------- .../umd_registry_gen/umd_registry_gen.cpp | 24 ++++++------- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp index 19a3e8d9000a..9e1d63a0ebf0 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp @@ -12,7 +12,7 @@ // consumer. // // The registry reconstructs a UID-centric graph's edges and auto-binds symbols: -// each op entry lists its input-tensor and output-tensor roles (each with a typed +// each op entry lists its input-tensor and output-tensor names (each with a typed // reader) and its scalar attributes (each with a typed value reader). Readers // use the generated FlatBuffers accessors directly -- no runtime reflection. @@ -51,10 +51,10 @@ struct ScalarValue const char* dtype = nullptr; }; -// Reads a role's tensor UID out of a concrete attribute table (passed as a -// `const void*` to keep this header type-erased). Returns false when the role +// Reads a bound tensor's UID out of a concrete attribute table (passed as a +// `const void*` to keep this header type-erased). Returns false when the tensor // is optional and absent from the graph; returns true and writes `out` -// otherwise. A required role always returns true. +// otherwise. A required tensor always returns true. using UidReader = bool (*)(const void* attributes, std::int64_t& out); // Reads a scalar attribute value out of a concrete attribute table. @@ -62,14 +62,14 @@ using ScalarReader = ScalarValue (*)(const void* attributes); struct InputTensorBinding { - std::string_view role; + std::string_view name; bool optional = false; UidReader read = nullptr; }; struct OutputTensorBinding { - std::string_view role; + std::string_view name; bool optional = false; UidReader read = nullptr; }; diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp index 70d4d8028ca8..1a6c7f08ff2f 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -2,8 +2,8 @@ // SPDX-License-Identifier: MIT // // RFC 0018 Phase 0: proves the generated op-schema registry. Two claims: -// 1. Shape + optionality parity -- the registry lists SDPA's operand/result -// roles and scalar attributes with the optionality the schema `= null` +// 1. Shape + optionality parity -- the registry lists SDPA's input/output tensor +// names and scalar attributes with the optionality the schema `= null` // defaults imply (matching the generated header's Optional fields). // 2. Accessor value round-trip -- the generated typed readers, driven off a // live SdpaAttributes flatbuffer, return the exact stored values. Registry @@ -27,18 +27,18 @@ namespace data = hipdnn_flatbuffers_sdk::data_objects; namespace { -const umd::InputTensorBinding* findInputTensor(const umd::OpSchemaEntry* e, std::string_view role) +const umd::InputTensorBinding* findInputTensor(const umd::OpSchemaEntry* e, std::string_view name) { for(std::size_t i = 0; i < e->inputTensorCount; ++i) - if(e->inputTensors[i].role == role) + if(e->inputTensors[i].name == name) return &e->inputTensors[i]; return nullptr; } -const umd::OutputTensorBinding* findOutputTensor(const umd::OpSchemaEntry* e, std::string_view role) +const umd::OutputTensorBinding* findOutputTensor(const umd::OpSchemaEntry* e, std::string_view name) { for(std::size_t i = 0; i < e->outputTensorCount; ++i) - if(e->outputTensors[i].role == role) + if(e->outputTensors[i].name == name) return &e->outputTensors[i]; return nullptr; } @@ -93,11 +93,11 @@ TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); - for(std::string_view role : {"q", "k", "v"}) + for(std::string_view name : {"q", "k", "v"}) { - const auto* op = findInputTensor(e, role); - ASSERT_NE(op, nullptr) << role; - EXPECT_FALSE(op->optional) << role; + const auto* op = findInputTensor(e, name); + ASSERT_NE(op, nullptr) << name; + EXPECT_FALSE(op->optional) << name; } const auto* o = findOutputTensor(e, "o"); ASSERT_NE(o, nullptr); @@ -108,18 +108,18 @@ TEST(UmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); - for(std::string_view role : {"attn_mask", "page_table_k", "page_table_v", "scale", "seed"}) + for(std::string_view name : {"attn_mask", "page_table_k", "page_table_v", "scale", "seed"}) { - const auto* op = findInputTensor(e, role); - ASSERT_NE(op, nullptr) << role; - EXPECT_TRUE(op->optional) << role; + const auto* op = findInputTensor(e, name); + ASSERT_NE(op, nullptr) << name; + EXPECT_TRUE(op->optional) << name; } // Optional results too. - for(std::string_view role : {"stats", "amax_s", "amax_o"}) + for(std::string_view name : {"stats", "amax_s", "amax_o"}) { - const auto* r = findOutputTensor(e, role); - ASSERT_NE(r, nullptr) << role; - EXPECT_TRUE(r->optional) << role; + const auto* r = findOutputTensor(e, name); + ASSERT_NE(r, nullptr) << name; + EXPECT_TRUE(r->optional) << name; } } diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp index 62a7b985b62d..745c650f6051 100644 --- a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -21,7 +21,7 @@ // // Build errors (fail closed, non-zero exit): both flags on one field; umd_name // without a flag; a flag on a non-integer field; a duplicate umd_name within an -// op; a role name colliding with a reserved root (graph/kernel/device). +// op; a binding name colliding with a reserved root (graph/kernel/device). #include #include @@ -128,12 +128,12 @@ struct Emitted // Emit the reader lambda + binding line for one input/output tensor UID field. std::string emitUidBinding(const std::string& table, - const std::string& role, + const std::string& name, const std::string& acc, bool optional) { std::ostringstream o; - o << " {\"" << role << "\", " << (optional ? "true" : "false") << ", " + o << " {\"" << name << "\", " << (optional ? "true" : "false") << ", " << "+[](const void* a, std::int64_t& out) -> bool { "; if(optional) o << "auto v = static_cast(a)->" << acc @@ -222,7 +222,7 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object { const std::string table = shortName(obj->name()->str()); Emitted out; - std::set seenRoles; + std::set seenNames; for(const auto* fld : *obj->fields()) { @@ -240,28 +240,28 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object { if(!nameAttr) fail(table + "." + name + ": umd_input_tensor/umd_output_tensor requires umd_name"); - const std::string role = nameAttr->value() ? nameAttr->value()->str() : ""; - if(role.empty()) + const std::string bindName = nameAttr->value() ? nameAttr->value()->str() : ""; + if(bindName.empty()) fail(table + "." + name + ": umd_name must be non-empty"); if(base != reflection::Long) fail(table + "." + name + ": umd_input_tensor/umd_output_tensor field must be `long` (a UID)"); - if(isReservedRoot(role)) - fail(table + "." + name + ": role name '" + role + if(isReservedRoot(bindName)) + fail(table + "." + name + ": umd_name '" + bindName + "' collides with a reserved root (graph/kernel/device)"); - if(!seenRoles.insert(role).second) - fail(table + ": duplicate umd_name '" + role + "'"); + if(!seenNames.insert(bindName).second) + fail(table + ": duplicate umd_name '" + bindName + "'"); const std::string acc = accessorName(name); const bool optl = fld->optional(); if(inputTensorFlag) { - out.inputTensors += emitUidBinding(table, role, acc, optl); + out.inputTensors += emitUidBinding(table, bindName, acc, optl); ++out.inputTensorCount; } else { - out.outputTensors += emitUidBinding(table, role, acc, optl); + out.outputTensors += emitUidBinding(table, bindName, acc, optl); ++out.outputTensorCount; } continue; From 22940fea552db8cbd6fedeeb4f0e646fabdf0ed8 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 12:09:10 -0600 Subject: [PATCH 43/60] Rename 'role' to 'name' in RFC 0018 and PoC plan Align the docs with the code rename: a tensor's lookup key is a 'name', not a 'role'. Rewords the concept throughout (op-schema names, name-map/name-bind grammar, A.3 'Names and edges', B.5 registry example keys) and lowercases the q/k/v/o example strings to match the schema. (cherry picked from commit 27fcafd11e1342f7007067f646ca7e015a994b42) (cherry picked from commit af207fe6edece3ed9409bbaac7560060d1f87017) --- 0018_UMD_PoC_Plan.md | 22 +++--- .../rfcs/0018_UniversalMatchDescriptor.md | 70 +++++++++---------- .../umd/OpSchemaRegistry.hpp | 2 +- .../umd/op_schema_registry_generated.hpp | 4 +- .../tests/umd/TestOpSchemaRegistry.cpp | 3 +- .../umd_registry_gen/umd_registry_gen.cpp | 6 +- 6 files changed, 54 insertions(+), 53 deletions(-) diff --git a/0018_UMD_PoC_Plan.md b/0018_UMD_PoC_Plan.md index 272388e9cd39..09ce4addc7ec 100644 --- a/0018_UMD_PoC_Plan.md +++ b/0018_UMD_PoC_Plan.md @@ -6,7 +6,7 @@ of the op-schema registry from the `umd_*` annotations. **SDPA forward only.** D gtest suite (graphs built in code or from JSON→flatbuffer). Not plumbed as a provider engine. Proving out **the bindings** — the schema-generated, strongly-typed path→value layer that turns a -UMD variable reference (`$q.head_size`, `$sdpa_fwd.dropout_probability`, role→tensor) into a concrete +UMD variable reference (`$q.head_size`, `$sdpa_fwd.dropout_probability`, name→tensor) into a concrete value read off the flatbuffer graph — is a first-class deliverable of this PoC, not a side effect of matching. The phases below generate that layer at build time and prove it end-to-end. @@ -60,7 +60,7 @@ the provider consumes. Matcher is exercised directly by tests — not registered Goal: generate, from the `umd_*` annotations, a C++ registry keyed by opcode — the table's `umd_opcode` shorthand (e.g. `sdpa_fwd`), falling back to the `NodeAttributes` union member name when absent. Each entry also carries the table name (diagnostics) and the integer `NodeAttributes` value -(O(1) lookup against `Node::attributes_type()`), and lists operand/result roles (name, optionality, +(O(1) lookup against `Node::attributes_type()`), and lists input/output tensors (name, optionality, UID reader) and scalar attributes (name, optionality, typed reader). 1. **Emit `graph.bfbs`.** Add a dedicated flatc invocation (NOT via `flatc_flags.txt`, which is @@ -73,19 +73,19 @@ UID reader) and scalar attributes (name, optionality, typed reader). reflection (`flatbuffers/reflection.h`). Loads `graph.bfbs`, enumerates `NodeAttributes` union members (opcode→table); reads the table's `umd_opcode` shorthand and applies B.3 classification per field: - - `umd_input_tensor` + `umd_name` → operand role; `umd_output_tensor` + `umd_name` → result role + - `umd_input_tensor` + `umd_name` → input tensor name; `umd_output_tensor` + `umd_name` → output tensor name (type MUST be `long`, `umd_name` non-empty). - neither flag → scalar attribute, bind-named by field name. - optionality derived from `= null` default (not re-annotated). - **Fail the build** on B.3 violations (both flags on one field, `umd_name` without a flag, - flag on a non-integer field, duplicate `umd_name` within an op, a role name colliding with a + flag on a non-integer field, duplicate `umd_name` within an op, a name colliding with a reserved token `graph`/`kernel`/`device`, or a duplicate `umd_opcode` across ops). 3. **Emit `op_schema_registry_generated.hpp`** (header-only: inline per-op tables + inline lookup functions, so `flatbuffers_sdk` stays INTERFACE-only) into the committed `flatbuffers_sdk` include dir (`include/hipdnn_flatbuffers_sdk/umd/`). The neutral types live in a hand-written `umd/OpSchemaRegistry.hpp`. Neutral (jlogic-agnostic) shape so `flatbuffers_sdk` needn't depend on the provider: per opcode, - arrays of `{role, optional, UID-reader int64_t(const void*)}` and + arrays of `{name, optional, UID-reader int64_t(const void*)}` and `{attr-name, optional, AttrType, reader→neutral ScalarValue}`. Readers use the generated typed accessors (`&SdpaAttributes::q_tensor_uid`, …) — **no runtime reflection** (RFC B.4). The generator is general (it emits an entry for every `NodeAttributes` member); SDPA is the only @@ -110,7 +110,7 @@ result), `attn_mask`/`page_table_k`/`page_table_v` (optional operands), and scal A B.3-violation fixture fails generation. - **Accessor value round-trip (proves the bindings, not just the shape):** build an `SdpaAttributes` with known UIDs and scalars; assert every generated UID reader and every scalar reader returns the - exact value. Registry *shape* (roles listed) is necessary but not sufficient — this proves the + exact value. Registry *shape* (names listed) is necessary but not sufficient — this proves the generated accessors actually read the right field. - **Optionality parity with the header:** the generator's `= null`-derived optionality matches the generated header's `Optional` fields (an absent optional scalar reports not-present, a required @@ -133,13 +133,13 @@ A B.3-violation fixture fails generation. - **Device**: `$device.` from `Handle` (`lds_size`, `warp_size`, …). (Kernel namespace omitted — SDPA fwd criteria references none.) - **Binding architecture (the two resolvers).** Edge and scalar-attr resolution use the Phase 0 - *generated* typed accessors — role→UID→tensor via `getTensorMap()`, and `$.` via the + *generated* typed accessors — name→UID→tensor via `getTensorMap()`, and `$.` via the typed reader. The Tensor-namespace path resolver (`dims[i]`/`strides[i]`/`rank`/`stride_order`/ `packed`/`virtual`/`uid`) is hand-written **once** over the single `TensorAttributes` shape, since there is exactly one tensor table. jlogic `Value` is the sole type-erasure boundary: strong typing lives inside each accessor and is erased exactly once, at its return. 2. **UMD compiler.** Parse `{schema,id,name,allow_override_shape,nodes,criteria}`; validate the A.10 - subset (schema exact, single key per op-object, roles resolve in registry, `?` ↔ registry + subset (schema exact, single key per op-object, names resolve in registry, `?` ↔ registry optionality, node-id/tvar disjoint from reserved roots, single-producer per variable). JSON→JSON lowering pass: - Expand layout aliases (`"nhwc"`→`[0,2,3,1]`, …, A.8). @@ -155,7 +155,7 @@ A B.3-violation fixture fails generation. decline. This is where the generated strong typing pays off in the compiler. 3. **`UniversalGraphMatcher`.** Root-opcode index (opcode→compiled UMDs). `match(handle, graph)`: - structural: locate node(s) by opcode (SDPA: the single node), build per-graph - uid→producer/consumer index, bind roles (decline on missing required role/tensor), honor + uid→producer/consumer index, bind names (decline on missing required tensor), honor `allow_override_shape` gate. - construct `BindingContext`, evaluate criteria `Expression` → bool. - return `MatchResult{ matched, bindings (BindingContext), umd_id }`. Bindings queryable @@ -190,13 +190,13 @@ nlohmann_json + plan_utils; add dep on the generated registry). `$q.head_size` (named), `$q.strides[i]`, `$q.stride_order`, `$q.packed`, `$q.virtual`, `$attn_mask.present`==false, `$sdpa_fwd.dropout_probability` + `.present`, `$graph.node_count`, `$device.lds_size` — each resolves to the expected typed value. - - *Generated-accessor correctness through a live graph:* role→UID→`TensorAttributes`→field returns + - *Generated-accessor correctness through a live graph:* name→UID→`TensorAttributes`→field returns the graph's actual values, tying the Phase 0 accessors to the Phase 2 resolver on real data. - *Bad-path fail-closed:* out-of-range `dims[i]`, unknown dim-name, absent-optional field read → decline, never a wrong value. - **Fail-closed:** unknown symbol, absent-optional field access, `node_count`≠1 → decline (never match by default). -- **Compiler validation:** bad schema version, `?` on a required role, unknown role → compile error / +- **Compiler validation:** bad schema version, `?` on a required name, unknown name → compile error / quarantine. --- diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index a4a685d99b60..da74f05cedce 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -93,12 +93,12 @@ lists. A node's operands and results are UID fields inside its concrete attribut `SdpaAttributes::q_tensor_uid()`, `k_tensor_uid()`, `o_tensor_uid()` (`sdpa_attributes_generated.h`). Connectivity between nodes is implicit: two nodes are connected when a result UID of one appears as an operand UID of the other. To resolve a node's edges, the matcher must know the op type, cast via -`attributesAs()`, and read the per-role UID fields. +`attributesAs()`, and read the named UID fields. **Consequence: the matcher needs an op-schema registry.** For each op type, the registry declares which attribute fields are operand UIDs, which are result UIDs, whether each is required or optional, and the -names of the op's scalar attributes. This registry is what lets a UMD name operands and results by -role (`Q`, `K`, `V`, `O`) and what powers the auto-binding formula of +names of the op's scalar attributes. This registry is what lets a UMD reference operands and results +by name (`q`, `k`, `v`, `o`) and what powers the auto-binding formula of [§4](#4-symbol-binding-and-the-auto-binding-formula). **The registry is generated from schema annotations, not name conventions.** A table-level FlatBuffers @@ -117,7 +117,7 @@ bindable scalars and are skipped). A build step emits the binary reflection schema (`graph.bfbs`, which transitively covers every attribute table; custom attributes surface only through reflection, not the generated headers) and a generator reads each field's attributes to emit the registry, so it stays in lockstep with the graph definitions rather than being -hand-maintained ([Appendix B](#appendix-b-op-schema-registry-generation) specifies the annotation contract, the field-classification rules, and the generation pipeline). Roles are never inferred from the `_tensor_uid` name suffix, which would misclassify +hand-maintained ([Appendix B](#appendix-b-op-schema-registry-generation) specifies the annotation contract, the field-classification rules, and the generation pipeline). Names are never inferred from the `_tensor_uid` name suffix, which would misclassify non-UID fields such as `PointwiseAttributes::axis_tensor_uid` (a plain axis index, not a tensor UID). **Tensors expose dims, strides, dtype, and a virtual flag, but no layout enum and no rank field.** @@ -146,7 +146,7 @@ since no adjacency query is provided; fusion legality reads each intermediate's ## 3. Structural Pattern The pattern is a set of op nodes and the named edges between them and the graph's tensors. Each node -declares its opcode and maps operand and result **roles** (from the op-schema registry) to pattern +declares its opcode and maps operand and result **names** (from the op-schema registry) to pattern variables (`$q`, `$conv_out`). ```jsonc @@ -169,10 +169,10 @@ variables (`$q`, `$conv_out`). ([§2](#2-the-matchers-input-hipdnns-graph-model), [Appendix B](#appendix-b-op-schema-registry-generation)), e.g. `sdpa_fwd` — or `{"one_of": [...]}` for a small fixed set, or `"any"` for a wildcard node (used only inside a bounded fused pattern). -- **Roles.** Keys in `operands` and `results` are op-schema role names; values are pattern variables. - A role the schema marks optional is bound with a `?` suffix (`"attn_mask": "$attn_mask?"`), bound only +- **Names.** Keys in `operands` and `results` are op-schema tensor names; values are pattern variables. + A name the schema marks optional is bound with a `?` suffix (`"attn_mask": "$attn_mask?"`), bound only when the graph supplies it, and read with a default via `value_or_default` - ([§4](#4-symbol-binding-and-the-auto-binding-formula)). Roles not named are ignored for matching but + ([§4](#4-symbol-binding-and-the-auto-binding-formula)). Names not listed are ignored for matching but still auto-bound. - **Edges are implicit through shared variables.** Two nodes are connected when the same variable appears as a result of one and an operand of another. In the fused example below, `$conv_out` is @@ -482,7 +482,7 @@ chains remain deferred to the JIT follow-up, as in RFC 0017 §5. ## 10. The Matcher: Compilation, Indexing, and Caching A UMD is authored as text and **compiled once** into an in-memory matcher structure at provider load -(or, for the drop-in path, when the bundle is scanned). Compilation resolves op-schema roles, expands +(or, for the drop-in path, when the bundle is scanned). Compilation resolves op-schema names, expands layout aliases, parses the criteria expression to an AST, and validates that every referenced symbol is bound. The compiled form, not the text, is what runs against live graphs. @@ -759,7 +759,7 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m `umd_input_tensor` / `umd_output_tensor` / `umd_name` field annotations (never from field-name conventions), so a new or renamed operand carries its binding contract in the same edit ([§2](#2-the-matchers-input-hipdnns-graph-model)), and fail - closed on an unknown op or role rather than binding a wrong field. + closed on an unknown op or name rather than binding a wrong field. - **Expression language sharing.** JsonLogic is shared with the UDD ([§6](#6-the-shared-expression-language)). A change made for one subsystem can affect the other. Mitigation: one parser/validator/interpreter, a shared conformance suite, and a clear split (criteria are boolean JsonLogic, dispatch formulas @@ -872,30 +872,30 @@ No other top-level keys are permitted; an unknown key is refused. node = "{" , '"kind"' , ":" , '"op"' , "," , '"id"' , ":" , string , "," , '"op"' , ":" , op-selector - , [ "," , '"operands"' , ":" , role-map ] - , [ "," , '"results"' , ":" , role-map ] , "}" ; + , [ "," , '"operands"' , ":" , name-map ] + , [ "," , '"results"' , ":" , name-map ] , "}" ; op-selector = opcode | one-of | '"any"' ; one-of = "{" , '"one_of"' , ":" , "[" , opcode , { "," , opcode } , "]" , "}" ; opcode = string ; (* MUST resolve in the op-schema registry *) -role-map = "{" , [ role-bind , { "," , role-bind } ] , "}" ; -role-bind = string , ":" , bind ; (* key is an op-schema role name *) +name-map = "{" , [ name-bind , { "," , name-bind } ] , "}" ; +name-bind = string , ":" , bind ; (* key is an op-schema tensor name *) bind = '"$' , ident , [ "?" ] , '"' ; ident = letter , { letter | digit | "_" } ; ``` - `kind` MUST be `"op"` (the only kind in `v1`). - Node `id` values MUST be unique within `nodes` and disjoint from every pattern-variable name (A.4). -- Every `opcode` and every role key MUST resolve in the op-schema registry for that opcode; an unknown - opcode or role is refused ([§2](#2-the-matchers-input-hipdnns-graph-model)). +- Every `opcode` and every name key MUST resolve in the op-schema registry for that opcode; an unknown + opcode or name is refused ([§2](#2-the-matchers-input-hipdnns-graph-model)). - `"any"` is legal only inside a fused pattern (a descriptor whose match pins `node_count > 1`); it MUST NOT be the sole or root node. -- A `?` suffix marks an optional binding and is legal only for a role the registry marks optional. A - `?` on a required role, or an optional role bound without `?`, is refused. -- Roles not named in `operands`/`results` are ignored for matching but still auto-bound (A.4). +- A `?` suffix marks an optional binding and is legal only for a name the registry marks optional. A + `?` on a required name, or an optional name bound without `?`, is refused. +- Names not listed in `operands`/`results` are ignored for matching but still auto-bound (A.4). -### A.3 Roles and edges +### A.3 Names and edges -Role keys are drawn from the op-schema registry (`Q`, `K`, `V`, `O`, …). An edge is implicit: two nodes +Name keys are drawn from the op-schema registry (`q`, `k`, `v`, `o`, …). An edge is implicit: two nodes are connected when the same pattern variable is a `results` value of one and an `operands` value of another ([§3](#3-structural-pattern)). A pattern variable MUST appear as a `results` value at most once (single producer). @@ -1051,8 +1051,8 @@ quarantines) the descriptor with a diagnostic ([§10](#10-the-matcher-compilatio 1. `schema == "hipdnn.umd/v1"`, and `id` is a well-formed UUID. 2. Only the keys of A.1 at the top level; only the keys of A.2 on each node. -3. Every `opcode` and every role key resolves in the op-schema registry. -4. Every `?` suffix matches the registry's optionality for that role. +3. Every `opcode` and every name key resolves in the op-schema registry. +4. Every `?` suffix matches the registry's optionality for that name. 5. Node `id`s are unique and disjoint from every pattern-variable name; reserved roots (`graph`, `kernel`, `device`) are unused as a `tvar` or node `id`. 6. Each pattern variable is a `results` value at most once (single producer, A.3). @@ -1133,7 +1133,7 @@ build** rather than emitting a wrong registry: and the `.present` field of [Appendix A.4](#a4-variable-references-and-the-five-namespaces). - **Build errors (fail closed):** `umd_input_tensor` and `umd_output_tensor` on the same field; `umd_name` without either flag; `umd_input_tensor`/`umd_output_tensor` on a non-integer field; a duplicate `umd_name` within one op; - an operand/result whose role name collides with a reserved token + an input/output tensor whose name collides with a reserved token ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)); or a duplicate `umd_opcode` across ops. - **Scalar attribute value kind.** A scalar attribute carries its value kind for compile-time type @@ -1147,7 +1147,7 @@ build** rather than emitting a wrong registry: - **Scalar attributes need no annotation, and are still fully bound.** An annotation carries the two facts that cannot be inferred for an *edge*: that a `long` field is a tensor UID rather than a plain integer (`q_tensor_uid` and `left_bound` are the same type, distinguishable only by the flag), and a - role name distinct from the field name (`"Q"` vs `q_tensor_uid`). A scalar needs neither: it is a + bind name distinct from the field name (`"q"` vs `q_tensor_uid`). A scalar needs neither: it is a non-edge by elimination, and its bind name *is* its field name, which reflection already reports. So every unannotated field is auto-bound in the Attributes namespace as `$.` ([Appendix A.4](#a4-variable-references-and-the-five-namespaces)) with its reflected type and its @@ -1171,7 +1171,7 @@ build** rather than emitting a wrong registry: 3. The generator emits the op-schema registry as generated C++ the provider compiles (a header-only registry emitted into the schema-owning SDK, so it needs no dependency on the provider): a table keyed by the `umd_opcode` shorthand, each entry also carrying its attribute-table name and the - integer `NodeAttributes` value, and listing its operands and results (role name, optionality, and + integer `NodeAttributes` value, and listing its input and output tensors (name, optionality, and the typed accessor for the UID field) and its scalar attributes (name, optionality, value kind, and typed accessor). @@ -1188,12 +1188,12 @@ The emitted entry for one opcode is, conceptually: "sdpa_fwd": { // key: the table's umd_opcode (fallback: table name) "attributes_type": "SdpaAttributes", // NodeAttributes union member; matched via Node::attributes_type() "operands": [ - {"role": "Q", "uid": "&SdpaAttributes::q_tensor_uid", "optional": false}, - {"role": "K", "uid": "&SdpaAttributes::k_tensor_uid", "optional": false}, - {"role": "V", "uid": "&SdpaAttributes::v_tensor_uid", "optional": false}, - {"role": "attn_mask", "uid": "&SdpaAttributes::attn_mask_tensor_uid", "optional": true} + {"name": "q", "uid": "&SdpaAttributes::q_tensor_uid", "optional": false}, + {"name": "k", "uid": "&SdpaAttributes::k_tensor_uid", "optional": false}, + {"name": "v", "uid": "&SdpaAttributes::v_tensor_uid", "optional": false}, + {"name": "attn_mask", "uid": "&SdpaAttributes::attn_mask_tensor_uid", "optional": true} ], - "results": [{"role": "O", "uid": "&SdpaAttributes::o_tensor_uid", "optional": false}], + "results": [{"name": "o", "uid": "&SdpaAttributes::o_tensor_uid", "optional": false}], "attributes": [ {"name": "dropout_probability", "get": "&SdpaAttributes::dropout_probability", "optional": true}, {"name": "alibi_mask", "get": "&SdpaAttributes::alibi_mask", "optional": false} @@ -1202,7 +1202,7 @@ The emitted entry for one opcode is, conceptually: ``` At compile ([§10](#10-the-matcher-compilation-indexing-and-caching)) the matcher resolves each pattern -role against this entry. At match time, for a node of that opcode it reads each role's UID via the typed +name against this entry. At match time, for a node of that opcode it reads each name's UID via the typed accessor, resolves the UID against the per-graph UID→producer/consumer index ([§2](#2-the-matchers-input-hipdnns-graph-model)) to bind the tensor, and auto-binds the tensor's fields and the node's scalar attributes into the five namespaces @@ -1213,10 +1213,10 @@ graph binds `.present = false` and is read only through a guarded reference or ` - **Lockstep.** Adding or renaming an operand is one `.fbs` edit that carries its `umd_input_tensor` / `umd_name` with it; the next build regenerates the registry, so a UMD naming that name resolves and a - UMD naming a role that no longer exists fails compile + UMD referencing a name that no longer exists fails compile ([Appendix A.10](#a10-compile-time-validation-normative)). -- **Unknown op or role at match compile.** The matcher fails closed: a pattern node whose opcode or - role is absent from the registry is refused, never bound to a guessed field +- **Unknown op or name at match compile.** The matcher fails closed: a pattern node whose opcode or + name is absent from the registry is refused, never bound to a guessed field ([§19 Op-schema registry coupling](#19-risks)). - **Generation is deterministic and diffable.** The generated registry is a build artifact; a schema change that alters bindings shows up as a registry diff, which is the review surface for a binding diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp index 9e1d63a0ebf0..8c15e66188cc 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp @@ -105,6 +105,6 @@ struct OpSchemaEntry // Defined (inline) in the generated header. // const OpSchemaEntry* opSchemaEntries(std::size_t& count); // const OpSchemaEntry* lookupOpByName(std::string_view opcode); -// const OpSchemaEntry* lookupOpByType(int attributesType); +// const OpSchemaEntry* lookupOpByType(data_objects::NodeAttributes attributesType); } // namespace hipdnn_flatbuffers_sdk::umd diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index a4a606fbb6d0..ca13c34c1d18 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -353,10 +353,10 @@ inline const OpSchemaEntry* lookupOpByName(std::string_view opcode) return nullptr; } -inline const OpSchemaEntry* lookupOpByType(int attributesType) +inline const OpSchemaEntry* lookupOpByType(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes attributesType) { for(const auto& e : generated::entries) - if(e.attributesType == attributesType) + if(e.attributesType == static_cast(attributesType)) return &e; return nullptr; } diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp index 1a6c7f08ff2f..439875c98f3e 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -78,8 +78,7 @@ fb::FlatBufferBuilder buildSdpa() TEST(UmdOpSchemaRegistry, SdpaEntryResolvesByNameAndType) { const auto* byName = umd::lookupOpByName("sdpa_fwd"); // the umd_opcode shorthand - const auto* byType - = umd::lookupOpByType(static_cast(data::NodeAttributes::SdpaAttributes)); + const auto* byType = umd::lookupOpByType(data::NodeAttributes::SdpaAttributes); ASSERT_NE(byName, nullptr); ASSERT_NE(byType, nullptr); EXPECT_EQ(byName, byType); diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp index 745c650f6051..aeeb20dba295 100644 --- a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -380,9 +380,11 @@ int main(int argc, char** argv) << "inline const OpSchemaEntry* lookupOpByName(std::string_view opcode)\n{\n" << " for(const auto& e : generated::entries)\n if(e.opcode == opcode)\n" << " return &e;\n return nullptr;\n}\n\n" - << "inline const OpSchemaEntry* lookupOpByType(int attributesType)\n{\n" + << "inline const OpSchemaEntry* " + "lookupOpByType(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes " + "attributesType)\n{\n" << " for(const auto& e : generated::entries)\n if(e.attributesType == " - "attributesType)\n" + "static_cast(attributesType))\n" << " return &e;\n return nullptr;\n}\n\n" << "} // namespace hipdnn_flatbuffers_sdk::umd\n"; From 97386b936923281e51d25b09fe872fea22d3f34d Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 12:19:34 -0600 Subject: [PATCH 44/60] Store NodeAttributes enum value in OpSchemaEntry instead of int OpSchemaEntry stored the union discriminant as int, forcing a hard static_cast at every generated entry and an inverse cast in lookupOpByType. Carry the real NodeAttributes enum value instead: add an opaque forward declaration (fixed uint8_t underlying type) of the enum to the neutral OpSchemaRegistry.hpp so the field can hold the enum without pulling the generated graph schema into that header. Both casts are gone; lookupOpByType takes and compares the enum directly. The generated registry TU sees both the forward declaration and the real definition, so any underlying-type mismatch is a compile error there. (cherry picked from commit 485d227d3c3c91d4e33f9a81bfe8749e89170cb0) (cherry picked from commit 6f4f5da13d6181e41fcbbe43751d7855e80c2d8f) --- .../umd/OpSchemaRegistry.hpp | 21 +++++++--- .../umd/op_schema_registry_generated.hpp | 42 +++++++++---------- .../umd_registry_gen/umd_registry_gen.cpp | 6 +-- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp index 8c15e66188cc..ad65dc710210 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp @@ -22,6 +22,18 @@ #include #include +// Opaque forward declaration of the FlatBuffers NodeAttributes union discriminant +// (fully defined in the generated graph_generated.h). Declaring it here with its +// fixed underlying type makes it a complete type, so OpSchemaEntry can carry the +// real enum value -- no int laundering -- while this neutral header stays free of +// the generated schema include. The generated registry TU sees both this +// declaration and the real definition, so any underlying-type mismatch is a +// compile error there. +namespace hipdnn_flatbuffers_sdk::data_objects +{ +enum class NodeAttributes : std::uint8_t; +} // namespace hipdnn_flatbuffers_sdk::data_objects + namespace hipdnn_flatbuffers_sdk::umd { @@ -85,15 +97,14 @@ struct AttrBinding // One op's schema: its `opcode` (the UMD-facing shorthand from the table's // `umd_opcode` attribute, e.g. "sdpa_fwd", falling back to the table type name // when the attribute is absent), the `tableName` (the NodeAttributes union member, -// e.g. "SdpaAttributes", for diagnostics), and the integer `attributesType` (the -// value of the NodeAttributes enum, for O(1) lookup against -// Node::attributes_type()), plus its input-tensor, output-tensor, and scalar -// bindings. +// e.g. "SdpaAttributes", for diagnostics), and the `attributesType` (the +// NodeAttributes enum value, for O(1) lookup against Node::attributes_type()), +// plus its input-tensor, output-tensor, and scalar bindings. struct OpSchemaEntry { std::string_view opcode; std::string_view tableName; - int attributesType = 0; + data_objects::NodeAttributes attributesType{}; const InputTensorBinding* inputTensors = nullptr; std::size_t inputTensorCount = 0; const OutputTensorBinding* outputTensors = nullptr; diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index ca13c34c1d18..b5d29cbfb3e5 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -315,26 +315,26 @@ inline const AttrBinding attributes[] = { } // namespace op19 inline const OpSchemaEntry entries[] = { - {"BatchnormInferenceAttributes", "BatchnormInferenceAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributes), nullptr, 0u, nullptr, 0u, op0::attributes, 6u}, - {"PointwiseAttributes", "PointwiseAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::PointwiseAttributes), nullptr, 0u, nullptr, 0u, op1::attributes, 12u}, - {"BatchnormBackwardAttributes", "BatchnormBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormBackwardAttributes), nullptr, 0u, nullptr, 0u, op2::attributes, 8u}, - {"BatchnormAttributes", "BatchnormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormAttributes), nullptr, 0u, nullptr, 0u, op3::attributes, 12u}, - {"ConvolutionFwdAttributes", "ConvolutionFwdAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionFwdAttributes), nullptr, 0u, nullptr, 0u, op4::attributes, 4u}, - {"ConvolutionBwdAttributes", "ConvolutionBwdAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionBwdAttributes), nullptr, 0u, nullptr, 0u, op5::attributes, 4u}, - {"ConvolutionWrwAttributes", "ConvolutionWrwAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionWrwAttributes), nullptr, 0u, nullptr, 0u, op6::attributes, 4u}, - {"BatchnormInferenceAttributesVarianceExt", "BatchnormInferenceAttributesVarianceExt", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributesVarianceExt), nullptr, 0u, nullptr, 0u, op7::attributes, 7u}, - {"MatmulAttributes", "MatmulAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::MatmulAttributes), nullptr, 0u, nullptr, 0u, op8::attributes, 3u}, - {"RMSNormAttributes", "RMSNormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormAttributes), nullptr, 0u, nullptr, 0u, op9::attributes, 7u}, - {"LayernormAttributes", "LayernormAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormAttributes), nullptr, 0u, nullptr, 0u, op10::attributes, 9u}, - {"sdpa_fwd", "SdpaAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaAttributes), op11::inputTensors, 21u, op11::outputTensors, 7u, op11::attributes, 13u}, - {"BlockScaleDequantizeAttributes", "BlockScaleDequantizeAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleDequantizeAttributes), nullptr, 0u, nullptr, 0u, op12::attributes, 4u}, - {"BlockScaleQuantizeAttributes", "BlockScaleQuantizeAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleQuantizeAttributes), nullptr, 0u, nullptr, 0u, op13::attributes, 6u}, - {"SdpaBackwardAttributes", "SdpaBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaBackwardAttributes), nullptr, 0u, nullptr, 0u, op14::attributes, 28u}, - {"CustomOpAttributes", "CustomOpAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::CustomOpAttributes), nullptr, 0u, nullptr, 0u, nullptr, 0u}, - {"RMSNormBackwardAttributes", "RMSNormBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormBackwardAttributes), nullptr, 0u, nullptr, 0u, op16::attributes, 7u}, - {"ReductionAttributes", "ReductionAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ReductionAttributes), nullptr, 0u, nullptr, 0u, op17::attributes, 4u}, - {"ResampleFwdAttributes", "ResampleFwdAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ResampleFwdAttributes), nullptr, 0u, nullptr, 0u, op18::attributes, 6u}, - {"LayernormBackwardAttributes", "LayernormBackwardAttributes", static_cast(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormBackwardAttributes), nullptr, 0u, nullptr, 0u, op19::attributes, 10u}, + {"BatchnormInferenceAttributes", "BatchnormInferenceAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributes, nullptr, 0u, nullptr, 0u, op0::attributes, 6u}, + {"PointwiseAttributes", "PointwiseAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::PointwiseAttributes, nullptr, 0u, nullptr, 0u, op1::attributes, 12u}, + {"BatchnormBackwardAttributes", "BatchnormBackwardAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormBackwardAttributes, nullptr, 0u, nullptr, 0u, op2::attributes, 8u}, + {"BatchnormAttributes", "BatchnormAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormAttributes, nullptr, 0u, nullptr, 0u, op3::attributes, 12u}, + {"ConvolutionFwdAttributes", "ConvolutionFwdAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionFwdAttributes, nullptr, 0u, nullptr, 0u, op4::attributes, 4u}, + {"ConvolutionBwdAttributes", "ConvolutionBwdAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionBwdAttributes, nullptr, 0u, nullptr, 0u, op5::attributes, 4u}, + {"ConvolutionWrwAttributes", "ConvolutionWrwAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ConvolutionWrwAttributes, nullptr, 0u, nullptr, 0u, op6::attributes, 4u}, + {"BatchnormInferenceAttributesVarianceExt", "BatchnormInferenceAttributesVarianceExt", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributesVarianceExt, nullptr, 0u, nullptr, 0u, op7::attributes, 7u}, + {"MatmulAttributes", "MatmulAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::MatmulAttributes, nullptr, 0u, nullptr, 0u, op8::attributes, 3u}, + {"RMSNormAttributes", "RMSNormAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormAttributes, nullptr, 0u, nullptr, 0u, op9::attributes, 7u}, + {"LayernormAttributes", "LayernormAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormAttributes, nullptr, 0u, nullptr, 0u, op10::attributes, 9u}, + {"sdpa_fwd", "SdpaAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaAttributes, op11::inputTensors, 21u, op11::outputTensors, 7u, op11::attributes, 13u}, + {"BlockScaleDequantizeAttributes", "BlockScaleDequantizeAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleDequantizeAttributes, nullptr, 0u, nullptr, 0u, op12::attributes, 4u}, + {"BlockScaleQuantizeAttributes", "BlockScaleQuantizeAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BlockScaleQuantizeAttributes, nullptr, 0u, nullptr, 0u, op13::attributes, 6u}, + {"SdpaBackwardAttributes", "SdpaBackwardAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::SdpaBackwardAttributes, nullptr, 0u, nullptr, 0u, op14::attributes, 28u}, + {"CustomOpAttributes", "CustomOpAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::CustomOpAttributes, nullptr, 0u, nullptr, 0u, nullptr, 0u}, + {"RMSNormBackwardAttributes", "RMSNormBackwardAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::RMSNormBackwardAttributes, nullptr, 0u, nullptr, 0u, op16::attributes, 7u}, + {"ReductionAttributes", "ReductionAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ReductionAttributes, nullptr, 0u, nullptr, 0u, op17::attributes, 4u}, + {"ResampleFwdAttributes", "ResampleFwdAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ResampleFwdAttributes, nullptr, 0u, nullptr, 0u, op18::attributes, 6u}, + {"LayernormBackwardAttributes", "LayernormBackwardAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormBackwardAttributes, nullptr, 0u, nullptr, 0u, op19::attributes, 10u}, }; } // namespace generated @@ -356,7 +356,7 @@ inline const OpSchemaEntry* lookupOpByName(std::string_view opcode) inline const OpSchemaEntry* lookupOpByType(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes attributesType) { for(const auto& e : generated::entries) - if(e.attributesType == static_cast(attributesType)) + if(e.attributesType == attributesType) return &e; return nullptr; } diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp index aeeb20dba295..159f9847f5a6 100644 --- a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -357,8 +357,8 @@ int main(int argc, char** argv) if(anyTable) body << "} // namespace " << tag << "\n\n"; - entries << " {\"" << opcode << "\", \"" << member << "\", static_cast(" << kNs - << "::NodeAttributes::" << member << "), " << inputTensorsRef << ", " + entries << " {\"" << opcode << "\", \"" << member << "\", " << kNs + << "::NodeAttributes::" << member << ", " << inputTensorsRef << ", " << em.inputTensorCount << "u, " << outputTensorsRef << ", " << em.outputTensorCount << "u, " << attrsRef << ", " << em.attributeCount << "u},\n"; ++opCount; @@ -384,7 +384,7 @@ int main(int argc, char** argv) "lookupOpByType(::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes " "attributesType)\n{\n" << " for(const auto& e : generated::entries)\n if(e.attributesType == " - "static_cast(attributesType))\n" + "attributesType)\n" << " return &e;\n return nullptr;\n}\n\n" << "} // namespace hipdnn_flatbuffers_sdk::umd\n"; From be392ceac1e060fdb9e95ffb1df8dfc8beb95e46 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 12:42:12 -0600 Subject: [PATCH 45/60] Skip generated umd registry header in clang-tidy and fix hand-written findings The umd op-schema registry header is machine-generated (umd_registry_gen) and lives under umd/, outside the data_objects path the clang-tidy ExcludeHeaderFilterRegex already skipped, so its emitter-artifact diagnostics (C-style arrays, non-conforming identifiers, brace-less statements) were being reported. Extend the exclusion to umd/*_generated. {h,hpp}, scoped to that directory rather than a blanket *_generated so a hand-written file elsewhere is still checked. Fix the remaining hand-written findings the audit surfaced: - umd_registry_gen.cpp: brace all statements; make pointer/size bool conversions explicit; return a braced init list; rename kNs -> NS and static local k -> s_keywords; replace chained-`+` fail() messages with a failField() helper that appends. - OpSchemaRegistry.hpp: rename AttrType constants Int/Float/Bool/Dtype to UPPER_CASE (INT/FLOAT/BOOL/DTYPE) and update the defaults and comments. - TestOpSchemaRegistry.cpp: brace single-statement if/for, make range-loop name const, and use the UPPER_CASE AttrType constants. - op_schema_registry_generated.hpp: regenerated from the updated generator. clang-tidy reports zero errors on both changed TUs; the flatbuffers_sdk tests build and UmdOpSchemaRegistry.* pass. (cherry picked from commit 616591fb4a7d02fbeb08ac434d3d3080fa45b49b) (cherry picked from commit 550915b8511ff6606bb5b5634a6c081db3aa6298) --- projects/hipdnn/.clang-tidy | 8 +- .../umd/OpSchemaRegistry.hpp | 18 +- .../umd/op_schema_registry_generated.hpp | 308 +++++++++--------- .../tests/umd/TestOpSchemaRegistry.cpp | 57 ++-- .../umd_registry_gen/umd_registry_gen.cpp | 170 +++++++--- 5 files changed, 331 insertions(+), 230 deletions(-) diff --git a/projects/hipdnn/.clang-tidy b/projects/hipdnn/.clang-tidy index 1208549203a4..e2269a310b80 100644 --- a/projects/hipdnn/.clang-tidy +++ b/projects/hipdnn/.clang-tidy @@ -259,7 +259,13 @@ HeaderFileExtensions: ['h','hpp'] # This regex is a inclusion filter for the files to be checked. Its a Posix regex and # does not seem to support negative lookahead. HeaderFilterRegex: '.' -ExcludeHeaderFilterRegex: '^(.*(data_objects|python3\.[0-9]+|nanobind|_deps).*)$' +# Skip machine-generated headers: the FlatBuffers reflection headers live under +# data_objects, and the umd registry emitter (umd_registry_gen) writes +# umd/*_generated.hpp (e.g. umd/op_schema_registry_generated.hpp). Their C-style +# arrays, non-conforming identifiers, and brace-less statements are artifacts of the +# emitter, not fixable in-tree. The umd match is scoped to that directory so a +# hand-written file elsewhere that happens to end in _generated is still checked. +ExcludeHeaderFilterRegex: '^(.*(data_objects|python3\.[0-9]+|nanobind|_deps).*|.*/umd/.*_generated\.(h|hpp))$' CheckOptions: # Disable type analysis for misc-use-internal-linkage to maintain consistent diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp index ad65dc710210..07f39742e48b 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/OpSchemaRegistry.hpp @@ -39,23 +39,23 @@ namespace hipdnn_flatbuffers_sdk::umd // The value kind of a scalar attribute. Mirrors the JsonLogic value domain a // criteria expression type-checks against (RFC 0018 Appendix A.7): integer, -// float, boolean, or an enum name (`Dtype`). Enum-typed attributes surface as -// `Dtype`, carrying the enum-value name string. +// float, boolean, or an enum name (`DTYPE`). Enum-typed attributes surface as +// `DTYPE`, carrying the enum-value name string. enum class AttrType : std::uint8_t { - Int, - Float, - Bool, - Dtype, + INT, + FLOAT, + BOOL, + DTYPE, }; // A resolved scalar attribute value. `present` is false only for an optional // attribute the graph omitted; a present value populates exactly the member // selected by `type`. `dtype` points at a static enum-name string (no -// ownership) when `type == Dtype`. +// ownership) when `type == DTYPE`. struct ScalarValue { - AttrType type = AttrType::Int; + AttrType type = AttrType::INT; bool present = false; std::int64_t i = 0; double f = 0.0; @@ -90,7 +90,7 @@ struct AttrBinding { std::string_view name; bool optional = false; - AttrType type = AttrType::Int; + AttrType type = AttrType::INT; ScalarReader read = nullptr; }; diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index b5d29cbfb3e5..77b2d80e8c0a 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -17,142 +17,142 @@ namespace generated // BatchnormInferenceAttributes namespace op0 { inline const AttrBinding attributes[] = { - {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, - {"inv_variance_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->inv_variance_tensor_uid()); return s; }}, - {"mean_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->mean_tensor_uid()); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"bias_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"inv_variance_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->inv_variance_tensor_uid()); return s; }}, + {"mean_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->mean_tensor_uid()); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op0 // PointwiseAttributes namespace op1 { inline const AttrBinding attributes[] = { - {"axis_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->axis_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"elu_alpha", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->elu_alpha(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"in_0_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->in_0_tensor_uid()); return s; }}, - {"in_1_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->in_1_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"in_2_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->in_2_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"operation", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePointwiseMode(static_cast(a)->operation()); return s; }}, - {"out_0_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->out_0_tensor_uid()); return s; }}, - {"relu_lower_clip", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->relu_lower_clip(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"relu_lower_clip_slope", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->relu_lower_clip_slope(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"relu_upper_clip", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->relu_upper_clip(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"softplus_beta", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->softplus_beta(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"swish_beta", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->swish_beta(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"axis_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->axis_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"elu_alpha", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->elu_alpha(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"in_0_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->in_0_tensor_uid()); return s; }}, + {"in_1_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->in_1_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"in_2_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->in_2_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"operation", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePointwiseMode(static_cast(a)->operation()); return s; }}, + {"out_0_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->out_0_tensor_uid()); return s; }}, + {"relu_lower_clip", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->relu_lower_clip(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"relu_lower_clip_slope", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->relu_lower_clip_slope(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"relu_upper_clip", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->relu_upper_clip(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"softplus_beta", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->softplus_beta(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"swish_beta", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->swish_beta(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, }; } // namespace op1 // BatchnormBackwardAttributes namespace op2 { inline const AttrBinding attributes[] = { - {"dbias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, - {"dscale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, - {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, - {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, - {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"dbias_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, + {"dscale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, + {"dx_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, }; } // namespace op2 // BatchnormAttributes namespace op3 { inline const AttrBinding attributes[] = { - {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, - {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, - {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"momentum_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->momentum_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"next_running_mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->next_running_mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"next_running_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->next_running_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"prev_running_mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->prev_running_mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"prev_running_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->prev_running_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"bias_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"momentum_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->momentum_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"next_running_mean_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->next_running_mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"next_running_variance_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->next_running_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"prev_running_mean_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->prev_running_mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"prev_running_variance_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->prev_running_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op3 // ConvolutionFwdAttributes namespace op4 { inline const AttrBinding attributes[] = { - {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, - {"w_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->w_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"conv_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, + {"w_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->w_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op4 // ConvolutionBwdAttributes namespace op5 { inline const AttrBinding attributes[] = { - {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, - {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, - {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, - {"w_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->w_tensor_uid()); return s; }}, + {"conv_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, + {"dx_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"w_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->w_tensor_uid()); return s; }}, }; } // namespace op5 // ConvolutionWrwAttributes namespace op6 { inline const AttrBinding attributes[] = { - {"conv_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, - {"dw_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dw_tensor_uid()); return s; }}, - {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"conv_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameConvMode(static_cast(a)->conv_mode()); return s; }}, + {"dw_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dw_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, }; } // namespace op6 // BatchnormInferenceAttributesVarianceExt namespace op7 { inline const AttrBinding attributes[] = { - {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, - {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, - {"mean_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->mean_tensor_uid()); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"variance_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->variance_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"bias_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"mean_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->mean_tensor_uid()); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"variance_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->variance_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op7 // MatmulAttributes namespace op8 { inline const AttrBinding attributes[] = { - {"a_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->a_tensor_uid()); return s; }}, - {"b_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->b_tensor_uid()); return s; }}, - {"c_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->c_tensor_uid()); return s; }}, + {"a_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->a_tensor_uid()); return s; }}, + {"b_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->b_tensor_uid()); return s; }}, + {"c_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->c_tensor_uid()); return s; }}, }; } // namespace op8 // RMSNormAttributes namespace op9 { inline const AttrBinding attributes[] = { - {"bias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->bias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, - {"forward_phase", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameNormFwdPhase(static_cast(a)->forward_phase()); return s; }}, - {"inv_rms_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_rms_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"bias_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->bias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"epsilon_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"forward_phase", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameNormFwdPhase(static_cast(a)->forward_phase()); return s; }}, + {"inv_rms_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->inv_rms_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op9 // LayernormAttributes namespace op10 { inline const AttrBinding attributes[] = { - {"bias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, - {"epsilon_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, - {"forward_phase", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameNormFwdPhase(static_cast(a)->forward_phase()); return s; }}, - {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"normalized_dim_count", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->normalized_dim_count()); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"bias_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->bias_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->epsilon_tensor_uid()); return s; }}, + {"forward_phase", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameNormFwdPhase(static_cast(a)->forward_phase()); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"normalized_dim_count", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->normalized_dim_count()); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op10 @@ -191,126 +191,126 @@ inline const OutputTensorBinding outputTensors[] = { {"sum_exp", true, +[](const void* a, std::int64_t& out) -> bool { auto v = static_cast(a)->sum_exp_tensor_uid(); if(!v) return false; out = *v; return true; }}, }; inline const AttrBinding attributes[] = { - {"alibi_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, - {"attn_scale_value", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->attn_scale_value(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"causal_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask(); return s; }}, - {"causal_mask_bottom_right", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask_bottom_right(); return s; }}, - {"diagonal_alignment", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDiagonalAlignment(static_cast(a)->diagonal_alignment()); return s; }}, - {"dropout_probability", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->dropout_probability(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"generate_stats", true, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; auto v = static_cast(a)->generate_stats(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, - {"implementation", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameAttentionImplementation(static_cast(a)->implementation()); return s; }}, - {"left_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->left_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"max_seq_len_kv", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->max_seq_len_kv(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"mma_core_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDataType(static_cast(a)->mma_core_mode()); return s; }}, - {"padding_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->padding_mask(); return s; }}, - {"right_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->right_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"alibi_mask", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, + {"attn_scale_value", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->attn_scale_value(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"causal_mask", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->causal_mask(); return s; }}, + {"causal_mask_bottom_right", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->causal_mask_bottom_right(); return s; }}, + {"diagonal_alignment", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDiagonalAlignment(static_cast(a)->diagonal_alignment()); return s; }}, + {"dropout_probability", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->dropout_probability(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"generate_stats", true, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; auto v = static_cast(a)->generate_stats(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, + {"implementation", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameAttentionImplementation(static_cast(a)->implementation()); return s; }}, + {"left_bound", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->left_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"max_seq_len_kv", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->max_seq_len_kv(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mma_core_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDataType(static_cast(a)->mma_core_mode()); return s; }}, + {"padding_mask", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->padding_mask(); return s; }}, + {"right_bound", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->right_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, }; } // namespace op11 // BlockScaleDequantizeAttributes namespace op12 { inline const AttrBinding attributes[] = { - {"is_negative_scale", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->is_negative_scale(); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"is_negative_scale", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->is_negative_scale(); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op12 // BlockScaleQuantizeAttributes namespace op13 { inline const AttrBinding attributes[] = { - {"axis", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->axis(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"block_size", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->block_size()); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"transpose", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->transpose(); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"axis", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->axis(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"block_size", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->block_size()); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"transpose", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->transpose(); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op13 // SdpaBackwardAttributes namespace op14 { inline const AttrBinding attributes[] = { - {"alibi_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, - {"attn_mask_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->attn_mask_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"attn_scale_value", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->attn_scale_value(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"causal_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask(); return s; }}, - {"causal_mask_bottom_right", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->causal_mask_bottom_right(); return s; }}, - {"dbias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"diagonal_alignment", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDiagonalAlignment(static_cast(a)->diagonal_alignment()); return s; }}, - {"dk_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dk_tensor_uid()); return s; }}, - {"do_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->do_tensor_uid()); return s; }}, - {"dq_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dq_tensor_uid()); return s; }}, - {"dropout_mask_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dropout_mask_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"dropout_probability", true, AttrType::Float, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Float; auto v = static_cast(a)->dropout_probability(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, - {"dropout_scale_inv_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dropout_scale_inv_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"dropout_scale_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dropout_scale_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"dv_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dv_tensor_uid()); return s; }}, - {"k_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->k_tensor_uid()); return s; }}, - {"left_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->left_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"o_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->o_tensor_uid()); return s; }}, - {"offset_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->offset_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"padding_mask", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->padding_mask(); return s; }}, - {"q_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->q_tensor_uid()); return s; }}, - {"right_bound", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->right_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"scale_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->scale_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"seed_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->seed_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"seq_len_kv_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->seq_len_kv_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"seq_len_q_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->seq_len_q_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"stats_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->stats_tensor_uid()); return s; }}, - {"v_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->v_tensor_uid()); return s; }}, + {"alibi_mask", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->alibi_mask(); return s; }}, + {"attn_mask_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->attn_mask_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"attn_scale_value", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->attn_scale_value(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"causal_mask", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->causal_mask(); return s; }}, + {"causal_mask_bottom_right", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->causal_mask_bottom_right(); return s; }}, + {"dbias_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"diagonal_alignment", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameDiagonalAlignment(static_cast(a)->diagonal_alignment()); return s; }}, + {"dk_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dk_tensor_uid()); return s; }}, + {"do_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->do_tensor_uid()); return s; }}, + {"dq_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dq_tensor_uid()); return s; }}, + {"dropout_mask_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->dropout_mask_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dropout_probability", true, AttrType::FLOAT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::FLOAT; auto v = static_cast(a)->dropout_probability(); s.present = v.has_value(); if(v) s.f = static_cast(*v); return s; }}, + {"dropout_scale_inv_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->dropout_scale_inv_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dropout_scale_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->dropout_scale_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dv_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dv_tensor_uid()); return s; }}, + {"k_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->k_tensor_uid()); return s; }}, + {"left_bound", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->left_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"o_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->o_tensor_uid()); return s; }}, + {"offset_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->offset_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"padding_mask", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->padding_mask(); return s; }}, + {"q_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->q_tensor_uid()); return s; }}, + {"right_bound", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->right_bound(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"scale_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->scale_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"seed_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->seed_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"seq_len_kv_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->seq_len_kv_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"seq_len_q_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->seq_len_q_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"stats_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->stats_tensor_uid()); return s; }}, + {"v_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->v_tensor_uid()); return s; }}, }; } // namespace op14 // RMSNormBackwardAttributes namespace op16 { inline const AttrBinding attributes[] = { - {"dbias_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"dscale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, - {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, - {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, - {"inv_rms_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->inv_rms_tensor_uid()); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"dbias_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->dbias_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"dscale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, + {"dx_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"inv_rms_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->inv_rms_tensor_uid()); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, }; } // namespace op16 // ReductionAttributes namespace op17 { inline const AttrBinding attributes[] = { - {"in_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->in_tensor_uid()); return s; }}, - {"is_deterministic", false, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; s.present = true; s.b = static_cast(a)->is_deterministic(); return s; }}, - {"mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameReductionMode(static_cast(a)->mode()); return s; }}, - {"out_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->out_tensor_uid()); return s; }}, + {"in_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->in_tensor_uid()); return s; }}, + {"is_deterministic", false, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; s.present = true; s.b = static_cast(a)->is_deterministic(); return s; }}, + {"mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameReductionMode(static_cast(a)->mode()); return s; }}, + {"out_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->out_tensor_uid()); return s; }}, }; } // namespace op17 // ResampleFwdAttributes namespace op18 { inline const AttrBinding attributes[] = { - {"generate_index", true, AttrType::Bool, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Bool; auto v = static_cast(a)->generate_index(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, - {"index_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->index_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"padding_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePaddingMode(static_cast(a)->padding_mode()); return s; }}, - {"resample_mode", false, AttrType::Dtype, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Dtype; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameResampleMode(static_cast(a)->resample_mode()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, - {"y_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, + {"generate_index", true, AttrType::BOOL, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::BOOL; auto v = static_cast(a)->generate_index(); s.present = v.has_value(); if(v) s.b = *v; return s; }}, + {"index_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->index_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"padding_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePaddingMode(static_cast(a)->padding_mode()); return s; }}, + {"resample_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameResampleMode(static_cast(a)->resample_mode()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"y_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->y_tensor_uid()); return s; }}, }; } // namespace op18 // LayernormBackwardAttributes namespace op19 { inline const AttrBinding attributes[] = { - {"dbias_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, - {"dscale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, - {"dx_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, - {"dy_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, - {"epsilon_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->epsilon_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"inv_variance_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"mean_tensor_uid", true, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, - {"normalized_dim_count", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->normalized_dim_count()); return s; }}, - {"scale_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, - {"x_tensor_uid", false, AttrType::Int, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::Int; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, + {"dbias_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dbias_tensor_uid()); return s; }}, + {"dscale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dscale_tensor_uid()); return s; }}, + {"dx_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"epsilon_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->epsilon_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"inv_variance_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->inv_variance_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"mean_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->mean_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"normalized_dim_count", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->normalized_dim_count()); return s; }}, + {"scale_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->scale_tensor_uid()); return s; }}, + {"x_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->x_tensor_uid()); return s; }}, }; } // namespace op19 diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp index 439875c98f3e..b5c7b1a49992 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -30,24 +30,36 @@ namespace const umd::InputTensorBinding* findInputTensor(const umd::OpSchemaEntry* e, std::string_view name) { for(std::size_t i = 0; i < e->inputTensorCount; ++i) + { if(e->inputTensors[i].name == name) + { return &e->inputTensors[i]; + } + } return nullptr; } const umd::OutputTensorBinding* findOutputTensor(const umd::OpSchemaEntry* e, std::string_view name) { for(std::size_t i = 0; i < e->outputTensorCount; ++i) + { if(e->outputTensors[i].name == name) + { return &e->outputTensors[i]; + } + } return nullptr; } const umd::AttrBinding* findAttr(const umd::OpSchemaEntry* e, std::string_view name) { for(std::size_t i = 0; i < e->attributeCount; ++i) + { if(e->attributes[i].name == name) + { return &e->attributes[i]; + } + } return nullptr; } @@ -92,7 +104,7 @@ TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); - for(std::string_view name : {"q", "k", "v"}) + for(const std::string_view name : {"q", "k", "v"}) { const auto* op = findInputTensor(e, name); ASSERT_NE(op, nullptr) << name; @@ -107,14 +119,15 @@ TEST(UmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); - for(std::string_view name : {"attn_mask", "page_table_k", "page_table_v", "scale", "seed"}) + for(const std::string_view name : + {"attn_mask", "page_table_k", "page_table_v", "scale", "seed"}) { const auto* op = findInputTensor(e, name); ASSERT_NE(op, nullptr) << name; EXPECT_TRUE(op->optional) << name; } // Optional results too. - for(std::string_view name : {"stats", "amax_s", "amax_o"}) + for(const std::string_view name : {"stats", "amax_s", "amax_o"}) { const auto* r = findOutputTensor(e, name); ASSERT_NE(r, nullptr) << name; @@ -129,23 +142,23 @@ TEST(UmdOpSchemaRegistry, SdpaScalarAttributeOptionalityParity) ASSERT_NE(e, nullptr); // `= null` scalars are optional. - for(std::string_view name : {"generate_stats", - "dropout_probability", - "attn_scale_value", - "left_bound", - "right_bound", - "max_seq_len_kv"}) + for(const std::string_view name : {"generate_stats", + "dropout_probability", + "attn_scale_value", + "left_bound", + "right_bound", + "max_seq_len_kv"}) { const auto* a = findAttr(e, name); ASSERT_NE(a, nullptr) << name; EXPECT_TRUE(a->optional) << name; } // Defaulted (non-null) scalars/enums are required. - for(std::string_view name : {"alibi_mask", - "padding_mask", - "causal_mask", - "causal_mask_bottom_right", - "diagonal_alignment"}) + for(const std::string_view name : {"alibi_mask", + "padding_mask", + "causal_mask", + "causal_mask_bottom_right", + "diagonal_alignment"}) { const auto* a = findAttr(e, name); ASSERT_NE(a, nullptr) << name; @@ -157,10 +170,10 @@ TEST(UmdOpSchemaRegistry, SdpaScalarAttributeTypes) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); - EXPECT_EQ(findAttr(e, "alibi_mask")->type, umd::AttrType::Bool); - EXPECT_EQ(findAttr(e, "dropout_probability")->type, umd::AttrType::Float); - EXPECT_EQ(findAttr(e, "left_bound")->type, umd::AttrType::Int); - EXPECT_EQ(findAttr(e, "diagonal_alignment")->type, umd::AttrType::Dtype); + EXPECT_EQ(findAttr(e, "alibi_mask")->type, umd::AttrType::BOOL); + EXPECT_EQ(findAttr(e, "dropout_probability")->type, umd::AttrType::FLOAT); + EXPECT_EQ(findAttr(e, "left_bound")->type, umd::AttrType::INT); + EXPECT_EQ(findAttr(e, "diagonal_alignment")->type, umd::AttrType::DTYPE); } TEST(UmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) @@ -204,21 +217,21 @@ TEST(UmdOpSchemaRegistry, ScalarReadersReflectPresenceAndValue) const void* a = attrs; const umd::ScalarValue alibi = findAttr(e, "alibi_mask")->read(a); - EXPECT_EQ(alibi.type, umd::AttrType::Bool); + EXPECT_EQ(alibi.type, umd::AttrType::BOOL); EXPECT_TRUE(alibi.present); EXPECT_TRUE(alibi.b); const umd::ScalarValue drop = findAttr(e, "dropout_probability")->read(a); - EXPECT_EQ(drop.type, umd::AttrType::Float); + EXPECT_EQ(drop.type, umd::AttrType::FLOAT); EXPECT_TRUE(drop.present); EXPECT_DOUBLE_EQ(drop.f, 0.25); const umd::ScalarValue stats = findAttr(e, "generate_stats")->read(a); - EXPECT_EQ(stats.type, umd::AttrType::Bool); + EXPECT_EQ(stats.type, umd::AttrType::BOOL); EXPECT_FALSE(stats.present); // optional, unset const umd::ScalarValue align = findAttr(e, "diagonal_alignment")->read(a); - EXPECT_EQ(align.type, umd::AttrType::Dtype); + EXPECT_EQ(align.type, umd::AttrType::DTYPE); EXPECT_TRUE(align.present); ASSERT_NE(align.dtype, nullptr); EXPECT_STREQ(align.dtype, "TOP_LEFT"); diff --git a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp index 159f9847f5a6..0f78b4e9d847 100644 --- a/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tools/umd_registry_gen/umd_registry_gen.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include "flatbuffers/reflection.h" @@ -43,12 +44,27 @@ namespace std::exit(1); } +// Reports a per-field schema error as `table.field: msg` and exits. Builds the +// message by appending so these cold error paths avoid chained-`+` temporaries. +[[noreturn]] void + failField(const std::string& table, const std::string& field, std::string_view msg) +{ + std::string s = table; + s += '.'; + s += field; + s += ": "; + s += msg; + fail(s); +} + std::string readFile(const std::string& path) { std::ifstream f(path, std::ios::binary); if(!f) + { fail("cannot open input schema: " + path); - return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + } + return {std::istreambuf_iterator(f), std::istreambuf_iterator()}; } // Short (unqualified) name: text after the last '.'. @@ -60,18 +76,22 @@ std::string shortName(const std::string& qualified) const reflection::KeyValue* attr(const reflection::Field* f, const char* key) { - return f->attributes() ? f->attributes()->LookupByKey(key) : nullptr; + return (f->attributes() != nullptr) ? f->attributes()->LookupByKey(key) : nullptr; } // Table-level attribute value (e.g. `umd_opcode`), or empty when absent. std::string objAttr(const reflection::Object* o, const char* key) { - if(!o->attributes()) + if(o->attributes() == nullptr) + { return {}; + } const auto* kv = o->attributes()->LookupByKey(key); - if(!kv) + if(kv == nullptr) + { return {}; - return kv->value() ? kv->value()->str() : std::string(); + } + return (kv->value() != nullptr) ? kv->value()->str() : std::string(); } bool isIntegerBase(reflection::BaseType t) @@ -90,7 +110,7 @@ bool isScalarBase(reflection::BaseType t) // C++ keywords flatc suffixes with '_' when emitting an accessor name. const std::set& cppKeywords() { - static const std::set k + static const std::set s_keywords = {"alignas", "alignof", "and", "asm", "auto", "bool", "break", "case", "catch", "char", "class", "const", "continue", "default", "delete", "do", "double", "else", "enum", "explicit", "export", @@ -101,12 +121,12 @@ const std::set& cppKeywords() "true", "try", "typedef", "typename", "union", "unsigned", "using", "virtual", "void", "volatile", "while", "and_eq", "bitand", "bitor", "compl", "not", "not_eq", "or", "or_eq", "xor", "xor_eq"}; - return k; + return s_keywords; } std::string accessorName(const std::string& fieldName) { - return cppKeywords().count(fieldName) ? fieldName + "_" : fieldName; + return (cppKeywords().count(fieldName) != 0u) ? fieldName + "_" : fieldName; } bool isReservedRoot(const std::string& n) @@ -114,7 +134,7 @@ bool isReservedRoot(const std::string& n) return n == "graph" || n == "kernel" || n == "device"; } -constexpr const char* kNs = "::hipdnn_flatbuffers_sdk::data_objects"; +constexpr const char* NS = "::hipdnn_flatbuffers_sdk::data_objects"; struct Emitted { @@ -136,11 +156,15 @@ std::string emitUidBinding(const std::string& table, o << " {\"" << name << "\", " << (optional ? "true" : "false") << ", " << "+[](const void* a, std::int64_t& out) -> bool { "; if(optional) - o << "auto v = static_cast(a)->" << acc + { + o << "auto v = static_cast(a)->" << acc << "(); if(!v) return false; out = *v; return true;"; + } else - o << "out = static_cast(a)->" << acc + { + o << "out = static_cast(a)->" << acc << "(); return true;"; + } o << " }},\n"; return o.str(); } @@ -162,16 +186,20 @@ std::string emitAttrBinding(const reflection::Schema* schema, if(isEnum) { const std::string enumShort = shortName(schema->enums()->Get(enumIdx)->name()->str()); - attrType = "AttrType::Dtype"; + attrType = "AttrType::DTYPE"; std::ostringstream b; - b << "ScalarValue s; s.type = AttrType::Dtype; "; + b << "ScalarValue s; s.type = AttrType::DTYPE; "; if(optional) - b << "auto v = static_cast(a)->" << acc - << "(); s.present = v.has_value(); if(v) s.dtype = " << kNs << "::EnumName" + { + b << "auto v = static_cast(a)->" << acc + << "(); s.present = v.has_value(); if(v) s.dtype = " << NS << "::EnumName" << enumShort << "(*v);"; + } else - b << "s.present = true; s.dtype = " << kNs << "::EnumName" << enumShort - << "(static_cast(a)->" << acc << "());"; + { + b << "s.present = true; s.dtype = " << NS << "::EnumName" << enumShort + << "(static_cast(a)->" << acc << "());"; + } b << " return s;"; body = b.str(); } @@ -180,34 +208,42 @@ std::string emitAttrBinding(const reflection::Schema* schema, std::string member; if(base == reflection::Bool) { - attrType = "AttrType::Bool"; + attrType = "AttrType::BOOL"; member = "b"; } else if(base == reflection::Float || base == reflection::Double) { - attrType = "AttrType::Float"; + attrType = "AttrType::FLOAT"; member = "f"; } else { - attrType = "AttrType::Int"; + attrType = "AttrType::INT"; member = "i"; } std::string cast; if(member == "i") + { cast = "std::int64_t"; + } else if(member == "f") + { cast = "double"; + } const std::string readExpr - = "static_cast(a)->" + acc + "()"; + = "static_cast(a)->" + acc + "()"; std::ostringstream b; b << "ScalarValue s; s.type = " << attrType << "; "; if(optional) + { b << "auto v = " << readExpr << "; s.present = v.has_value(); if(v) s." << member << " = " << (cast.empty() ? "*v" : "static_cast<" + cast + ">(*v)") << ";"; + } else + { b << "s.present = true; s." << member << " = " << (cast.empty() ? readExpr : "static_cast<" + cast + ">(" + readExpr + ")") << ";"; + } b << " return s;"; body = b.str(); } @@ -232,29 +268,48 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object const auto* nameAttr = attr(fld, "umd_name"); const auto base = fld->type()->base_type(); - if(inputTensorFlag && outputTensorFlag) - fail(table + "." + name + ": has both umd_input_tensor and umd_output_tensor"); + if((inputTensorFlag != nullptr) && (outputTensorFlag != nullptr)) + { + failField(table, name, "has both umd_input_tensor and umd_output_tensor"); + } - const bool isEdge = inputTensorFlag || outputTensorFlag; + const bool isEdge = (inputTensorFlag != nullptr) || (outputTensorFlag != nullptr); if(isEdge) { - if(!nameAttr) - fail(table + "." + name + ": umd_input_tensor/umd_output_tensor requires umd_name"); - const std::string bindName = nameAttr->value() ? nameAttr->value()->str() : ""; + if(nameAttr == nullptr) + { + failField(table, name, "umd_input_tensor/umd_output_tensor requires umd_name"); + } + const std::string bindName + = (nameAttr->value() != nullptr) ? nameAttr->value()->str() : ""; if(bindName.empty()) - fail(table + "." + name + ": umd_name must be non-empty"); + { + failField(table, name, "umd_name must be non-empty"); + } if(base != reflection::Long) - fail(table + "." + name - + ": umd_input_tensor/umd_output_tensor field must be `long` (a UID)"); + { + failField( + table, name, "umd_input_tensor/umd_output_tensor field must be `long` (a UID)"); + } if(isReservedRoot(bindName)) - fail(table + "." + name + ": umd_name '" + bindName - + "' collides with a reserved root (graph/kernel/device)"); + { + std::string msg = "umd_name '"; + msg += bindName; + msg += "' collides with a reserved root (graph/kernel/device)"; + failField(table, name, msg); + } if(!seenNames.insert(bindName).second) - fail(table + ": duplicate umd_name '" + bindName + "'"); + { + std::string msg = table; + msg += ": duplicate umd_name '"; + msg += bindName; + msg += '\''; + fail(msg); + } const std::string acc = accessorName(name); const bool optl = fld->optional(); - if(inputTensorFlag) + if(inputTensorFlag != nullptr) { out.inputTensors += emitUidBinding(table, bindName, acc, optl); ++out.inputTensorCount; @@ -267,8 +322,10 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object continue; } - if(nameAttr) - fail(table + "." + name + ": umd_name without umd_input_tensor/umd_output_tensor"); + if(nameAttr != nullptr) + { + failField(table, name, "umd_name without umd_input_tensor/umd_output_tensor"); + } // Unannotated: a scalar attribute (if a scalar base type). Non-scalar // fields (vector/table/union/string) are not UMD scalars -- skip. @@ -286,23 +343,31 @@ Emitted classifyTable(const reflection::Schema* schema, const reflection::Object int main(int argc, char** argv) { if(argc != 3) + { fail("usage: umd_registry_gen "); + } const std::string bfbsPath = argv[1]; const std::string outPath = argv[2]; const std::string buf = readFile(bfbsPath); const auto* schema = reflection::GetSchema(buf.data()); - if(!schema) + if(schema == nullptr) + { fail("input is not a valid reflection schema: " + bfbsPath); + } const reflection::Enum* nodeAttrs = nullptr; for(const auto* e : *schema->enums()) { if(e->is_union() && shortName(e->name()->str()) == "NodeAttributes") + { nodeAttrs = e; + } } - if(!nodeAttrs) + if(nodeAttrs == nullptr) + { fail("NodeAttributes union not found in schema"); + } std::ostringstream body; std::ostringstream entries; @@ -311,11 +376,15 @@ int main(int argc, char** argv) for(const auto* ev : *nodeAttrs->values()) { - if(!ev->union_type()) + if(ev->union_type() == nullptr) + { continue; + } const int objIdx = ev->union_type()->index(); if(objIdx < 0) + { continue; // NONE + } const reflection::Object* obj = schema->objects()->Get(objIdx); const std::string table = shortName(obj->name()->str()); const std::string member = ev->name()->str(); // union value name == table type name @@ -324,9 +393,13 @@ int main(int argc, char** argv) // table type name. Must be unique across ops. std::string opcode = objAttr(obj, "umd_opcode"); if(opcode.empty()) + { opcode = member; + } if(!seenOpcodes.insert(opcode).second) + { fail("duplicate umd_opcode '" + opcode + "'"); + } const Emitted em = classifyTable(schema, obj); @@ -334,30 +407,35 @@ int main(int argc, char** argv) std::string inputTensorsRef = "nullptr"; std::string outputTensorsRef = "nullptr"; std::string attrsRef = "nullptr"; - const bool anyTable = em.inputTensorCount || em.outputTensorCount || em.attributeCount; + const bool anyTable = (em.inputTensorCount != 0u) || (em.outputTensorCount != 0u) + || (em.attributeCount != 0u); if(anyTable) + { body << "// " << table << "\nnamespace " << tag << " {\n"; - if(em.inputTensorCount) + } + if(em.inputTensorCount != 0u) { body << "inline const InputTensorBinding inputTensors[] = {\n" << em.inputTensors << "};\n"; inputTensorsRef = tag + "::inputTensors"; } - if(em.outputTensorCount) + if(em.outputTensorCount != 0u) { body << "inline const OutputTensorBinding outputTensors[] = {\n" << em.outputTensors << "};\n"; outputTensorsRef = tag + "::outputTensors"; } - if(em.attributeCount) + if(em.attributeCount != 0u) { body << "inline const AttrBinding attributes[] = {\n" << em.attributes << "};\n"; attrsRef = tag + "::attributes"; } if(anyTable) + { body << "} // namespace " << tag << "\n\n"; + } - entries << " {\"" << opcode << "\", \"" << member << "\", " << kNs + entries << " {\"" << opcode << "\", \"" << member << "\", " << NS << "::NodeAttributes::" << member << ", " << inputTensorsRef << ", " << em.inputTensorCount << "u, " << outputTensorsRef << ", " << em.outputTensorCount << "u, " << attrsRef << ", " << em.attributeCount << "u},\n"; @@ -390,10 +468,14 @@ int main(int argc, char** argv) std::ofstream ofs(outPath, std::ios::binary); if(!ofs) + { fail("cannot open output for writing: " + outPath); + } ofs << hdr.str(); if(!ofs) + { fail("failed writing output: " + outPath); + } std::fprintf( stderr, "umd_registry_gen: emitted %zu op entries to %s\n", opCount, outPath.c_str()); From 71265b3807304a7a6f99b363a184e31c67b3467d Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 15:48:25 -0600 Subject: [PATCH 46/60] Implement RFC 0018 UMD Phase 1: bindings, compiler, matcher Add the provider-local Universal Match Descriptor runtime under dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd: - BindingContext: the queryable bindings object and JsonLogic data source, resolving the RFC's tensor/graph/attribute/device namespaces over a live flatbuffer graph via the Phase 0 generated registry readers plus a hand-written tensor-path resolver. Fails closed on any unresolved reference. - UmdCompiler: parses and validates a hipdnn.umd/v1 descriptor (Appendix A.10), lowers the shape short-hand and layout aliases, statically type-checks the criteria against the generated AttrType, and compiles it to a jlogic::Expression. Rejects malformed input with UmdCompileError. - UniversalGraphMatcher: owns exactly one compiled descriptor (1:1) and matches a graph via structural subgraph binding with edge consistency across shared pattern variables, then evaluates the criteria. Bounded backtracking. - UmdPathParse: shared variable-path parsing used by both resolvers. Also lowercase the operand/result name-map keys in the RFC 0018 examples to match Appendix A.3 and the generated registry. Exercised by TestUniversalGraphMatcher (35 cases): compiler validation, single/multi-node structural matching, edge consistency, per-node attributes, namespace resolution, and fail-closed behavior. (cherry picked from commit f00bcf9402465dd400988d55482d53df7082e061) (cherry picked from commit 705360589dcf51546afa95c0d2508438823e5d5c) --- .../umd/BindingContext.hpp | 341 +++++ .../umd/UmdCompiler.hpp | 1119 +++++++++++++++++ .../umd/UmdPathParse.hpp | 103 ++ .../umd/UniversalGraphMatcher.hpp | 288 +++++ .../src/tests/CMakeLists.txt | 1 + .../tests/core/TestUniversalGraphMatcher.cpp | 581 +++++++++ .../rfcs/0018_UniversalMatchDescriptor.md | 12 +- 7 files changed, 2439 insertions(+), 6 deletions(-) create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdPathParse.hpp create mode 100644 dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp create mode 100644 dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp new file mode 100644 index 000000000000..28b92203f04b --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp @@ -0,0 +1,341 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +// BindingContext.hpp - the queryable "bindings object" for RFC 0018 (UMD). +// +// All names below live in namespace hip_kernel_provider_common::umd; these +// examples assume `namespace umd = hip_kernel_provider_common::umd;`. +// +// After a UniversalGraphMatcher structurally matches a graph, it builds one +// BindingContext per match. The context is both: +// +// 1. the JsonLogic data source the criteria expression evaluates against +// (it satisfies the `Value getData(const std::string&) const` contract), and +// 2. the queryable bindings object the caller inspects post-match +// (`ctx.get("$q.head_size")`). +// +// It resolves the RFC's five `$`-namespaces over a live flatbuffer graph +// (RFC 0018 §4): +// - Tensor `$q`, `$q.uid`, `$q.rank`, `$q.dtype`, `$q.dims[i]`, +// `$q.strides[i]`, `$q.`, `$q.stride_order`, +// `$q.packed`, `$q.virtual`, `$q.present` +// - Graph `$graph.node_count` +// - Attributes `$.`, `$..present` +// - Device `$device.` +// (Kernel namespace omitted: SDPA-forward criteria references none.) +// +// The two resolvers (RFC 0018 §4, "binding architecture"): edge (name->UID-> +// tensor) and scalar-attribute reads use the Phase 0 *generated* typed +// accessors carried on the op-schema registry entry; the Tensor-namespace path +// resolver (dims/strides/rank/stride_order/packed/virtual/uid) is hand-written +// once over the single TensorAttributes shape. jlogic::Value is the sole +// type-erasure boundary. +// +// Fail-closed (RFC 0018 §4/§14): every unresolved reference -- an unknown root, +// an out-of-range `dims[i]`, an unknown dim-name, or a read of a field on an +// absent optional operand -- resolves to null, which makes the enclosing +// criterion false, declining the match rather than matching on a wrong value. + +#include "hip_kernel_provider_common/JsonLogic.hpp" +#include "hip_kernel_provider_common/umd/UmdPathParse.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace hip_kernel_provider_common::umd +{ + +namespace jlogic = hip_kernel_provider_common::jsonlogic; + +// A tensor pattern-variable bound to a concrete graph tensor. `tensor` is null +// only for an optional operand the graph omitted; `dimNames` points at the +// UMD-static name->index map the compiler's `shape` lowering produced (owned by +// the CompiledUmd, which outlives every BindingContext it spawns). +struct BoundTensor +{ + const hipdnn_flatbuffers_sdk::data_objects::TensorAttributes* tensor = nullptr; + bool optional = false; + const std::unordered_map* dimNames = nullptr; +}; + +// A pattern node bound to a concrete graph node's attributes, resolving the +// Attributes namespace `$.` for that node. +struct BoundNode +{ + const hipdnn_flatbuffers_sdk::umd::OpSchemaEntry* schema = nullptr; + const void* attributes = nullptr; +}; + +class BindingContext +{ +public: + using TensorAttributes = hipdnn_flatbuffers_sdk::data_objects::TensorAttributes; + using IGraph = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph; + using OpSchemaEntry = hipdnn_flatbuffers_sdk::umd::OpSchemaEntry; + + BindingContext() = default; + + BindingContext(const IGraph* graph, std::unordered_map device) + : _graph(graph) + , _device(std::move(device)) + { + } + + // Bind a pattern node id (`$sdpa_fwd`) to a graph node's attributes. A UMD + // may declare several nodes, each resolving its own Attributes namespace. + void bindNode(const std::string& nodeId, const OpSchemaEntry* schema, const void* attributes) + { + _nodes[nodeId] = BoundNode{schema, attributes}; + } + + // Bind a tensor pattern variable (`$q`) to a graph tensor. A null `tensor` + // records an absent optional operand (`$q.present` reads false; any other + // field read declines). + void bindTensor(const std::string& tvar, + const TensorAttributes* tensor, + bool optional, + const std::unordered_map* dimNames) + { + _tensors[tvar] = BoundTensor{tensor, optional, dimNames}; + } + + // JsonLogic data-source contract: resolve a sigil-stripped variable path + // (`q.head_size`, `graph.node_count`, `sdpa_fwd.dropout_probability.present`) + // to a Value. Unresolved -> null (fail closed). + jlogic::Value getData(const std::string& varPath) const + { + std::string root; + std::string rest; + path::splitRoot(varPath, root, rest); + + if(root == "graph") + { + return resolveGraph(rest); + } + if(root == "device") + { + return resolveDevice(rest); + } + const auto nit = _nodes.find(root); + if(nit != _nodes.end()) + { + return resolveAttr(nit->second, rest); + } + const auto it = _tensors.find(root); + if(it != _tensors.end()) + { + return resolveTensor(it->second, rest); + } + return {}; // unknown root -> decline + } + + // Query helper for post-match inspection: accepts a leading `$` sigil. + jlogic::Value get(const std::string& ref) const + { + if(!ref.empty() && ref.front() == '$') + { + return getData(ref.substr(1)); + } + return getData(ref); + } + + // The tensor pattern variables bound in this match, for symbol-table + // enumeration (RFC 0018 §15). `tensor` is null for an absent optional. + const std::unordered_map& boundTensors() const + { + return _tensors; + } + + // The pattern nodes bound in this match, keyed by node id. + const std::unordered_map& boundNodes() const + { + return _nodes; + } + +private: + static std::vector toVector(const ::flatbuffers::Vector* v) + { + if(v == nullptr) + { + return {}; + } + // NOLINTNEXTLINE(modernize-return-braced-init-list) - iterator-range ctor, not initializer_list + return std::vector(v->begin(), v->end()); + } + + jlogic::Value resolveGraph(const std::string& rest) const + { + if(rest == "node_count" && _graph != nullptr) + { + return {static_cast(_graph->nodeCount())}; + } + return {}; + } + + jlogic::Value resolveDevice(const std::string& rest) const + { + const auto it = _device.find(rest); + return it != _device.end() ? it->second : jlogic::Value(); + } + + static jlogic::Value resolveAttr(const BoundNode& node, const std::string& rest) + { + if(node.schema == nullptr || node.attributes == nullptr) + { + return {}; + } + if(rest.empty()) + { + return {}; // a bare node id is not a value reference (compiler-rejected) + } + + std::string attr = rest; + const bool wantPresent = path::stripPresentSuffix(attr); + + const hipdnn_flatbuffers_sdk::umd::AttrBinding* binding = nullptr; + for(std::size_t i = 0; i < node.schema->attributeCount; ++i) + { + if(node.schema->attributes[i].name == attr) + { + binding = &node.schema->attributes[i]; + break; + } + } + if(binding == nullptr || binding->read == nullptr) + { + return {}; + } + + const hipdnn_flatbuffers_sdk::umd::ScalarValue sv = binding->read(node.attributes); + if(wantPresent) + { + return {sv.present}; + } + if(!sv.present) + { + return {}; // absent optional attribute read -> decline + } + switch(sv.type) + { + case hipdnn_flatbuffers_sdk::umd::AttrType::INT: + return {sv.i}; + case hipdnn_flatbuffers_sdk::umd::AttrType::FLOAT: + return {sv.f}; + case hipdnn_flatbuffers_sdk::umd::AttrType::BOOL: + return {sv.b}; + case hipdnn_flatbuffers_sdk::umd::AttrType::DTYPE: + return {std::string(sv.dtype != nullptr ? sv.dtype : "")}; + default: + break; + } + return {}; + } + + static jlogic::Value resolveTensor(const BoundTensor& bt, const std::string& rest) + { + if(rest == "present") + { + if(!bt.optional) + { + return {}; // present on a required operand is refused (compiler-gated) + } + return {bt.tensor != nullptr}; + } + if(bt.tensor == nullptr) + { + return {}; // field read on an absent optional operand -> decline + } + const TensorAttributes* t = bt.tensor; + + if(rest.empty() || rest == "uid") + { + return {static_cast(t->uid())}; + } + if(rest == "rank") + { + return {static_cast(t->dims() != nullptr ? t->dims()->size() : 0)}; + } + if(rest == "dtype") + { + return {std::string( + hipdnn_flatbuffers_sdk::data_objects::EnumNameDataType(t->data_type()))}; + } + if(rest == "virtual") + { + return {t->virtual_()}; + } + if(rest == "packed") + { + return {hipdnn_data_sdk::utilities::isTensorPacked(toVector(t->dims()), + toVector(t->strides()))}; + } + if(rest == "stride_order") + { + const std::vector order + = hipdnn_data_sdk::utilities::extractStrideOrder(toVector(t->strides())); + jlogic::Value::Array arr; + arr.reserve(order.size()); + for(const std::int64_t v : order) + { + arr.emplace_back(v); + } + return {std::move(arr)}; + } + + std::size_t idx = 0; + if(path::parseSubscript(rest, "dims", idx)) + { + const auto* dims = t->dims(); + if(dims == nullptr || idx >= dims->size()) + { + return {}; + } + return { + static_cast(dims->Get(static_cast<::flatbuffers::uoffset_t>(idx)))}; + } + if(path::parseSubscript(rest, "strides", idx)) + { + const auto* strides = t->strides(); + if(strides == nullptr || idx >= strides->size()) + { + return {}; + } + return {static_cast( + strides->Get(static_cast<::flatbuffers::uoffset_t>(idx)))}; + } + + // Named dim introduced by a `shape` short-hand. + if(bt.dimNames != nullptr) + { + const auto dit = bt.dimNames->find(rest); + if(dit != bt.dimNames->end()) + { + const auto* dims = t->dims(); + if(dims == nullptr || dit->second >= dims->size()) + { + return {}; + } + return {static_cast( + dims->Get(static_cast<::flatbuffers::uoffset_t>(dit->second)))}; + } + } + return {}; // unknown tensor field -> decline + } + + const IGraph* _graph = nullptr; + std::unordered_map _device; + std::unordered_map _nodes; + std::unordered_map _tensors; +}; + +} // namespace hip_kernel_provider_common::umd diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp new file mode 100644 index 000000000000..8d429367761b --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -0,0 +1,1119 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +// UmdCompiler.hpp - compiles a Universal Match Descriptor (RFC 0018) into a +// reusable CompiledUmd. +// +// All names below live in namespace hip_kernel_provider_common::umd. +// +// The compiler turns a `hipdnn.umd/v1` descriptor (a nlohmann::json document) +// into a CompiledUmd the UniversalGraphMatcher runs against graphs. It: +// 1. validates the descriptor against Appendix A.10 (schema/id, node object, +// registry-resolved opcodes and names, `?`<->optionality, reserved roots, +// single producer); +// 2. lowers the JSON criteria to pure JsonLogic: expands the `shape` +// short-hand (A.5) to a rank check while recording each named dim on its +// tensor, and expands layout aliases (A.8) to stride-order arrays; +// 3. statically type-checks the lowered criteria against the Phase 0 +// generated registry (A.10 §9) -- the payoff of the strongly-typed +// codegen; a type mismatch is a compile error, not a runtime decline; +// 4. compiles the criteria to a jlogic::Expression and +// publishes the referenced bound-symbol set (RFC 0018 §4). +// +// A descriptor that violates any check throws UmdCompileError (RFC 0018 §10 / +// A.10: refuse at compile, never match by default). + +#include "hip_kernel_provider_common/JsonLogic.hpp" +#include "hip_kernel_provider_common/umd/BindingContext.hpp" +#include "hip_kernel_provider_common/umd/UmdPathParse.hpp" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace hip_kernel_provider_common::umd +{ + +namespace reg = hipdnn_flatbuffers_sdk::umd; + +// Thrown when a descriptor fails any Appendix A.10 compile-time check. +class UmdCompileError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +// One operand/result binding slot of a pattern node: the op-schema tensor name +// (`q`) and the pattern variable it binds (`q`), with the generated typed UID +// reader for that node's opcode. +struct EdgeSlot +{ + std::string schemaName; // op-schema registry tensor name, e.g. "q" / "attn_mask" + std::string tvar; // pattern variable, e.g. "q" + bool optional = false; + bool isOutput = false; // operand vs result + const reg::UidReader* reader = nullptr; // generated typed UID reader +}; + +// A pattern node: its id, opcode/op-schema, and the operand/result edges that +// bind pattern variables. A descriptor may declare several nodes; edges connect +// them when two nodes share a pattern variable (RFC 0018 §3 / A.3). +struct NodeSpec +{ + std::string id; + std::string opcode; + const reg::OpSchemaEntry* opSchema = nullptr; + std::vector edges; +}; + +// A pattern variable (`$q`) shared across the descriptor's nodes. `dimNames` is +// populated by the `shape` lowering and read by BindingContext. `optional` is +// true only when every binding slot referencing it is optional (its `.present` +// is then legal). +struct TensorVarSpec +{ + std::string tvar; + bool optional = false; + std::unordered_map dimNames; // shape short-hand (A.5) + std::size_t pinnedRank = 0; // 0 == not pinned by a shape op +}; + +// A compiled descriptor: heap-stable (owned via unique_ptr by the matcher) so +// BindingContext may reference its TensorVarSpec::dimNames maps for the match's +// lifetime. +struct CompiledUmd +{ + std::string id; + std::string name; + bool allowOverrideShape = false; + std::vector nodes; + std::vector tvars; + jlogic::Expression criteria; + std::set boundSymbols; + + const TensorVarSpec* findTvar(const std::string& tvar) const + { + for(const auto& t : tvars) + { + if(t.tvar == tvar) + { + return &t; + } + } + return nullptr; + } + + TensorVarSpec* findTvarMut(const std::string& tvar) + { + for(auto& t : tvars) + { + if(t.tvar == tvar) + { + return &t; + } + } + return nullptr; + } + + const NodeSpec* findNode(const std::string& nodeId) const + { + for(const auto& n : nodes) + { + if(n.id == nodeId) + { + return &n; + } + } + return nullptr; + } +}; + +class UmdCompiler +{ +public: + static CompiledUmd compile(const nlohmann::json& descriptor) + { + UmdCompiler c; + return c.run(descriptor); + } + +private: + // Static type domain for A.10 §9. Numeric unifies Int and Float; ARRAY + // carries its element kind for `in` / IntArray equality. + enum class Kind + { + INT, + FLOAT, + BOOL, + DTYPE, + ARRAY, + TENSOR, + JSON_NULL, + UNKNOWN + }; + struct TypeInfo + { + Kind kind = Kind::UNKNOWN; + Kind elem = Kind::UNKNOWN; // element kind when kind == ARRAY + }; + + static bool isNumeric(Kind k) + { + return k == Kind::INT || k == Kind::FLOAT; + } + + CompiledUmd run(const nlohmann::json& d) + { + CompiledUmd out; + validateTopLevel(d); + out.id = d.at("id").get(); + out.name = d.at("name").get(); + out.allowOverrideShape + = d.contains("allow_override_shape") ? d.at("allow_override_shape").get() : false; + + parseNodes(d.at("nodes"), out); + + nlohmann::json const lowered = lowerCriteria(d.at("criteria"), out); + const TypeInfo t = inferType(lowered, &out); + if(t.kind != Kind::BOOL) + { + throw UmdCompileError("criteria must have static type Bool (A.10 §10)"); + } + + out.criteria = jlogic::compile(lowered); + for(const std::string& sym : out.criteria.variables()) + { + // Validate each reference resolves (A.10 §8); varTypeOf throws if not. + varTypeOf(sym, out); + out.boundSymbols.insert(sym); + } + return out; + } + + // ---- A.1 / A.10 top level ------------------------------------------- + static void validateTopLevel(const nlohmann::json& d) + { + if(!d.is_object()) + { + throw UmdCompileError("descriptor must be a JSON object"); + } + static const std::set s_allowed + = {"schema", "id", "name", "allow_override_shape", "nodes", "criteria"}; + for(const auto& [key, value] : d.items()) + { + if(s_allowed.find(key) == s_allowed.end()) + { + throw UmdCompileError("unknown top-level key: " + key); + } + } + for(const char* required : {"schema", "id", "name", "nodes", "criteria"}) + { + if(!d.contains(required)) + { + throw UmdCompileError(std::string("missing required key: ") + required); + } + } + if(!d.at("schema").is_string() || d.at("schema").get() != "hipdnn.umd/v1") + { + throw UmdCompileError("schema must equal \"hipdnn.umd/v1\" (A.10 §1)"); + } + if(!d.at("id").is_string() || !isUuid(d.at("id").get())) + { + throw UmdCompileError("id must be a well-formed UUID (A.10 §1)"); + } + if(!d.at("name").is_string()) + { + throw UmdCompileError("name must be a string"); + } + if(d.contains("allow_override_shape") && !d.at("allow_override_shape").is_boolean()) + { + throw UmdCompileError("allow_override_shape must be a boolean"); + } + if(!d.at("nodes").is_array() || d.at("nodes").empty()) + { + throw UmdCompileError("nodes must be a non-empty array (A.1)"); + } + } + + static bool isUuid(const std::string& s) + { + if(s.size() != 36) + { + return false; + } + for(std::size_t i = 0; i < s.size(); ++i) + { + if(i == 8 || i == 13 || i == 18 || i == 23) + { + if(s[i] != '-') + { + return false; + } + } + else if(std::isxdigit(static_cast(s[i])) == 0) + { + return false; + } + } + return true; + } + + static bool isReservedRoot(const std::string& s) + { + return s == "graph" || s == "kernel" || s == "device"; + } + + // ---- A.2 / A.3 node object ------------------------------------------ + static void parseNodes(const nlohmann::json& nodes, CompiledUmd& out) + { + for(const auto& node : nodes) + { + parseNode(node, out); + } + + // A.10 §5: node ids unique. + for(std::size_t i = 0; i < out.nodes.size(); ++i) + { + for(std::size_t j = i + 1; j < out.nodes.size(); ++j) + { + if(out.nodes[i].id == out.nodes[j].id) + { + throw UmdCompileError("duplicate node id (A.10 §5): " + out.nodes[i].id); + } + } + } + + // A.10 §5: pattern variables disjoint from node ids and reserved roots. + for(const TensorVarSpec& t : out.tvars) + { + if(out.findNode(t.tvar) != nullptr) + { + throw UmdCompileError("pattern variable collides with a node id (A.10 §5): " + + t.tvar); + } + if(isReservedRoot(t.tvar)) + { + throw UmdCompileError("pattern variable uses a reserved root (A.10 §5): " + t.tvar); + } + } + } + + static void parseNode(const nlohmann::json& node, CompiledUmd& out) + { + if(!node.is_object()) + { + throw UmdCompileError("node must be a JSON object"); + } + static const std::set s_allowed = {"kind", "id", "op", "operands", "results"}; + for(const auto& [key, value] : node.items()) + { + if(s_allowed.find(key) == s_allowed.end()) + { + throw UmdCompileError("unknown node key: " + key); + } + } + if(!node.contains("kind") || node.at("kind").get() != "op") + { + throw UmdCompileError("node kind must be \"op\" (A.2)"); + } + if(!node.contains("id") || !node.at("id").is_string()) + { + throw UmdCompileError("node must have a string id (A.2)"); + } + NodeSpec spec; + spec.id = node.at("id").get(); + if(isReservedRoot(spec.id)) + { + throw UmdCompileError("node id must not be a reserved root (A.10 §5): " + spec.id); + } + if(!node.contains("op") || !node.at("op").is_string()) + { + // one_of / "any" op-selectors are deferred (opcode-string PoC scope). + throw UmdCompileError("node op must be an opcode string (PoC scope)"); + } + spec.opcode = node.at("op").get(); + spec.opSchema = reg::lookupOpByName(spec.opcode); + if(spec.opSchema == nullptr) + { + throw UmdCompileError("opcode does not resolve in the op-schema registry: " + + spec.opcode); + } + + if(node.contains("operands")) + { + parseNameMap(node.at("operands"), out, spec, /*isOutput=*/false); + } + if(node.contains("results")) + { + parseNameMap(node.at("results"), out, spec, /*isOutput=*/true); + } + out.nodes.push_back(std::move(spec)); + } + + static void + parseNameMap(const nlohmann::json& map, CompiledUmd& out, NodeSpec& spec, bool isOutput) + { + if(!map.is_object()) + { + throw UmdCompileError("operands/results must be an object (A.2)"); + } + for(const auto& [name, bindJson] : map.items()) + { + if(!bindJson.is_string()) + { + throw UmdCompileError("name binding must be a \"$var\" string: " + name); + } + std::string bind = bindJson.get(); + if(bind.empty() || bind.front() != '$') + { + throw UmdCompileError("name binding must start with '$': " + bind); + } + bool optional = false; + if(bind.back() == '?') + { + optional = true; + bind.pop_back(); + } + const std::string tvar = bind.substr(1); + if(tvar.empty()) + { + throw UmdCompileError("empty pattern variable in binding"); + } + + // A.10 §3/§4: name resolves in registry with matching optionality. + const reg::UidReader* reader = nullptr; + bool registryOptional = false; + if(!resolveTensorName(*spec.opSchema, name, isOutput, reader, registryOptional)) + { + throw UmdCompileError("name does not resolve in the op-schema registry for " + + spec.opcode + ": " + name); + } + if(registryOptional != optional) + { + throw UmdCompileError( + "`?` suffix does not match registry optionality for name (A.10 §4): " + name); + } + // A.3: single producer -- a variable is a `results` value at most + // once across all nodes. + if(isOutput) + { + for(const NodeSpec& n : out.nodes) + { + for(const EdgeSlot& e : n.edges) + { + if(e.isOutput && e.tvar == tvar) + { + throw UmdCompileError("pattern variable produced more than once (A.3): " + + tvar); + } + } + } + for(const EdgeSlot& e : spec.edges) + { + if(e.isOutput && e.tvar == tvar) + { + throw UmdCompileError("pattern variable produced more than once (A.3): " + + tvar); + } + } + } + + spec.edges.push_back(EdgeSlot{name, tvar, optional, isOutput, reader}); + registerTvar(out, tvar, optional); + } + } + + // Register (or update) a pattern variable. A variable is optional only when + // every binding slot referencing it is optional (RFC 0018 A.4: `.present` + // is legal only for an optional binding). + static void registerTvar(CompiledUmd& out, const std::string& tvar, bool optional) + { + TensorVarSpec* existing = out.findTvarMut(tvar); + if(existing == nullptr) + { + TensorVarSpec spec; + spec.tvar = tvar; + spec.optional = optional; + out.tvars.push_back(std::move(spec)); + return; + } + existing->optional = existing->optional && optional; + } + + static bool resolveTensorName(const reg::OpSchemaEntry& e, + const std::string& name, + bool isOutput, + const reg::UidReader*& reader, + bool& optional) + { + if(isOutput) + { + for(std::size_t i = 0; i < e.outputTensorCount; ++i) + { + if(e.outputTensors[i].name == name) + { + reader = &e.outputTensors[i].read; + optional = e.outputTensors[i].optional; + return true; + } + } + return false; + } + for(std::size_t i = 0; i < e.inputTensorCount; ++i) + { + if(e.inputTensors[i].name == name) + { + reader = &e.inputTensors[i].read; + optional = e.inputTensors[i].optional; + return true; + } + } + return false; + } + + // ---- A.5 / A.8 lowering --------------------------------------------- + nlohmann::json lowerCriteria(const nlohmann::json& node, CompiledUmd& out) + { + if(node.is_object()) + { + if(node.size() == 1 && node.begin().key() == "shape") + { + return lowerShape(node.begin().value(), out); + } + nlohmann::json result = nlohmann::json::object(); + for(const auto& [key, value] : node.items()) + { + nlohmann::json lowered = lowerCriteria(value, out); + if((key == "==" || key == "!=") && lowered.is_array() && lowered.size() == 2) + { + expandLayoutAlias(lowered, out); + } + result[key] = std::move(lowered); + } + return result; + } + if(node.is_array()) + { + nlohmann::json result = nlohmann::json::array(); + for(const auto& e : node) + { + result.push_back(lowerCriteria(e, out)); + } + return result; + } + return node; + } + + static nlohmann::json lowerShape(const nlohmann::json& args, CompiledUmd& out) + { + if(!args.is_array() || args.size() != 2 || !args.at(0).is_string() + || !args.at(1).is_array()) + { + throw UmdCompileError("shape short-hand must be [\"$tensor\", [entries]] (A.5)"); + } + const std::string ref = args.at(0).get(); + if(ref.empty() || ref.front() != '$') + { + throw UmdCompileError("shape target must be a tensor reference: " + ref); + } + const std::string tvar = ref.substr(1); + TensorVarSpec* spec = out.findTvarMut(tvar); + if(spec == nullptr) + { + throw UmdCompileError("shape target is not a bound tensor: " + ref); + } + + const nlohmann::json& entries = args.at(1); + bool hasCapture = false; + std::size_t idx = 0; + for(const auto& entry : entries) + { + if(!entry.is_string()) + { + throw UmdCompileError("shape entry must be a string (A.5)"); + } + const std::string name = entry.get(); + if(hasCapture) + { + throw UmdCompileError("shape capture must be the last entry (A.5/A.10 §7)"); + } + if(name == "_") + { + ++idx; // anonymous positional + continue; + } + if(name.front() == '$') + { + hasCapture = true; // trailing capture vector; rank pinned as >= count-1 + ++idx; + continue; + } + if(isReservedTensorField(name)) + { + throw UmdCompileError( + "shape dim-name collides with a reserved tensor-field (A.10 §7): " + name); + } + if(spec->dimNames.find(name) != spec->dimNames.end()) + { + throw UmdCompileError("duplicate shape dim-name (A.5): " + name); + } + spec->dimNames[name] = idx; + ++idx; + } + + const std::string rankRef = "$" + tvar + ".rank"; + if(hasCapture) + { + spec->pinnedRank = 0; // variable rank + return nlohmann::json{ + {">=", + nlohmann::json::array({rankRef, static_cast(entries.size() - 1)})}}; + } + spec->pinnedRank = entries.size(); + return nlohmann::json{ + {"==", nlohmann::json::array({rankRef, static_cast(entries.size())})}}; + } + + static bool isReservedTensorField(const std::string& name) + { + static const std::set s_reserved = {"uid", + "rank", + "dtype", + "stride_order", + "packed", + "virtual", + "present", + "dims", + "strides"}; + return s_reserved.find(name) != s_reserved.end(); + } + + static void expandLayoutAlias(nlohmann::json& args, CompiledUmd& out) + { + const int strideRefIdx = strideOrderRefIndex(args); + if(strideRefIdx < 0) + { + return; + } + const std::size_t aliasIdx = strideRefIdx == 0 ? 1 : 0; + if(!args.at(aliasIdx).is_string()) + { + return; + } + const std::string alias = args.at(aliasIdx).get(); + if(alias.empty() || alias.front() == '$') + { + return; // a tensor reference, not an alias + } + const std::string tvar = tvarOfStrideOrderRef( + args.at(static_cast(strideRefIdx)).get()); + std::size_t rank = 0; + const TensorVarSpec* spec = out.findTvar(tvar); + if(spec != nullptr) + { + rank = spec->pinnedRank; + } + args[aliasIdx] = aliasToArray(alias, rank); + } + + static int strideOrderRefIndex(const nlohmann::json& args) + { + for(int i = 0; i < 2; ++i) + { + if(args.at(static_cast(i)).is_string()) + { + const std::string s = args.at(static_cast(i)).get(); + if(!s.empty() && s.front() == '$' && endsWith(s, ".stride_order")) + { + return i; + } + } + } + return -1; + } + + static std::string tvarOfStrideOrderRef(const std::string& ref) + { + // "$q.stride_order" -> "q" + const std::size_t dot = ref.find('.'); + return ref.substr(1, dot - 1); + } + + // Expand a layout alias to a stride-order array. `$q.stride_order` is + // computed by extractStrideOrder (ApplicabilityChecks.cpp:17) as a per-dim + // priority (higher == slower-varying / larger stride), so a contiguous + // rank-4 tensor is [3,2,1,0] and NHWC is [3,0,2,1]. The alias arrays match + // that encoding -- the same TensorLayout::NCHW/NHWC arrays the real + // validateSupportedLayout oracle compares against -- so `stride_order == + // alias` holds against a live graph's strides. (RFC A.8's tables are + // written in the inverse, position->dim convention; the PoC pins to the + // codebase encoding per the plan.) + static nlohmann::json aliasToArray(const std::string& alias, std::size_t rank) + { + nlohmann::json arr; + if(alias == "nchw" || alias == "bhsd") + { + arr = nlohmann::json::array({3, 2, 1, 0}); + } + else if(alias == "nhwc") + { + arr = nlohmann::json::array({3, 0, 2, 1}); + } + else if(alias == "ncdhw") + { + arr = nlohmann::json::array({4, 3, 2, 1, 0}); + } + else if(alias == "ndhwc") + { + arr = nlohmann::json::array({4, 0, 3, 2, 1}); + } + else if(alias == "contiguous") + { + if(rank == 0) + { + throw UmdCompileError("layout alias \"contiguous\" requires a rank pinned by a " + "shape short-hand (A.8)"); + } + arr = nlohmann::json::array(); + for(std::size_t i = 0; i < rank; ++i) + { + arr.push_back(static_cast(rank - 1 - i)); + } + } + else + { + throw UmdCompileError("unknown layout alias (A.8): " + alias); + } + // A fixed-rank alias compared against a tensor whose shape short-hand + // pinned a different rank can never hold; refuse it at compile rather + // than silently always declining at match time (A.8). + if(rank != 0 && arr.size() != rank) + { + throw UmdCompileError( + "layout alias \"" + alias + "\" has rank " + std::to_string(arr.size()) + + " but the tensor is pinned to rank " + std::to_string(rank) + " (A.8)"); + } + return arr; + } + + static bool endsWith(const std::string& s, const std::string& suffix) + { + return s.size() >= suffix.size() + && s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + } + + // ---- A.10 §9 static type-check -------------------------------------- + TypeInfo inferType(const nlohmann::json& node, const CompiledUmd* out = nullptr) + { + // `out` threads the compiled context through recursion; it is set once + // by run() via a member and reused. Store it on first entry. + if(out != nullptr) + { + _ctx = out; + } + return inferTypeImpl(node); + } + + TypeInfo inferTypeImpl(const nlohmann::json& node) + { + if(node.is_object()) + { + if(node.size() != 1) + { + throw UmdCompileError("expression object must have exactly one operator key (A.6)"); + } + return inferOp(node.begin().key(), node.begin().value()); + } + if(node.is_array()) + { + TypeInfo info; + info.kind = Kind::ARRAY; + info.elem = Kind::UNKNOWN; + for(const auto& e : node) + { + const TypeInfo et = inferTypeImpl(e); + if(info.elem == Kind::UNKNOWN) + { + info.elem = et.kind; + } + else if(!sameDomain(info.elem, et.kind)) + { + throw UmdCompileError("array literal has mixed element types (A.7 `in`)"); + } + } + return info; + } + if(node.is_boolean()) + { + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(node.is_number_float()) + { + return {Kind::FLOAT, Kind::UNKNOWN}; + } + if(node.is_number()) + { + return {Kind::INT, Kind::UNKNOWN}; + } + if(node.is_string()) + { + const std::string s = node.get(); + if(!s.empty() && s.front() == '$') + { + // A.4: any string beginning with `$` is a variable reference; no + // escape form exists, so a malformed ref resolves (and throws) + // through varTypeOf rather than being reinterpreted as a literal. + return varTypeOf(s.substr(1), *_ctx); + } + return {Kind::DTYPE, Kind::UNKNOWN}; // enum name / opcode literal + } + return {Kind::JSON_NULL, Kind::UNKNOWN}; + } + + static bool sameDomain(Kind a, Kind b) + { + if(isNumeric(a) && isNumeric(b)) + { + return true; + } + return a == b; + } + + TypeInfo inferOp(const std::string& op, const nlohmann::json& operand) + { + std::vector args; + if(operand.is_array()) + { + for(const auto& a : operand) + { + args.push_back(a); + } + } + else + { + args.push_back(operand); // unary sugar + } + + const auto argType = [&](std::size_t i) { return inferTypeImpl(args.at(i)); }; + // Reject arity violations at compile with a UmdCompileError (A.7/A.10 §9) + // rather than letting a fixed-index argType() throw std::out_of_range. + // `max == 0` means unbounded (n-ary). + const auto requireArity = [&](std::size_t minArgs, std::size_t maxArgs) { + if(args.size() < minArgs || (maxArgs != 0 && args.size() > maxArgs)) + { + throw UmdCompileError("operator '" + op + "' has wrong arity (A.7)"); + } + }; + const auto requireBool = [&](const TypeInfo& t) { + if(t.kind != Kind::BOOL) + { + throw UmdCompileError("operator '" + op + "' requires Bool arguments (A.7)"); + } + }; + const auto requireNumeric = [&](const TypeInfo& t) { + if(!isNumeric(t.kind)) + { + throw UmdCompileError("operator '" + op + "' requires numeric arguments (A.7)"); + } + }; + + if(op == "and" || op == "or") + { + requireArity(2, 0); + for(std::size_t i = 0; i < args.size(); ++i) + { + requireBool(argType(i)); + } + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(op == "!" || op == "!!") + { + requireArity(1, 1); + requireBool(argType(0)); + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(op == "==" || op == "!=") + { + requireArity(2, 2); + const TypeInfo a = argType(0); + const TypeInfo b = argType(1); + if(!sameDomain(a.kind, b.kind)) + { + throw UmdCompileError("'" + op + "' compares values of different types (A.7)"); + } + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(op == "<" || op == "<=" || op == ">" || op == ">=") + { + requireArity(2, 2); + requireNumeric(argType(0)); + requireNumeric(argType(1)); + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(op == "in") + { + requireArity(2, 2); + const TypeInfo needle = argType(0); + const TypeInfo hay = argType(1); + if(hay.kind != Kind::ARRAY) + { + throw UmdCompileError("'in' requires an array second argument (A.7)"); + } + if(hay.elem != Kind::UNKNOWN && !sameDomain(needle.kind, hay.elem)) + { + throw UmdCompileError("'in' element type mismatch (A.7)"); + } + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(op == "+" || op == "*" || op == "min" || op == "max") + { + requireArity(2, 0); // n-ary + Kind k = Kind::INT; + for(std::size_t i = 0; i < args.size(); ++i) + { + const TypeInfo t = argType(i); + requireNumeric(t); + if(t.kind == Kind::FLOAT) + { + k = Kind::FLOAT; + } + } + return {k, Kind::UNKNOWN}; + } + if(op == "-") + { + requireArity(2, 2); + const TypeInfo a = argType(0); + const TypeInfo b = argType(1); + requireNumeric(a); + requireNumeric(b); + return {(a.kind == Kind::FLOAT || b.kind == Kind::FLOAT) ? Kind::FLOAT : Kind::INT, + Kind::UNKNOWN}; + } + if(op == "%" || op == "ceil_div") + { + requireArity(2, 2); + requireNumeric(argType(0)); + requireNumeric(argType(1)); + return {Kind::INT, Kind::UNKNOWN}; + } + if(op == "/" || op == "pow") + { + requireArity(2, 2); + requireNumeric(argType(0)); + requireNumeric(argType(1)); + return {Kind::FLOAT, Kind::UNKNOWN}; + } + if(op == "abs") + { + requireArity(1, 1); + const TypeInfo t = argType(0); + requireNumeric(t); + return {t.kind, Kind::UNKNOWN}; + } + if(op == "log2" || op == "rsqrt") + { + requireArity(1, 1); + requireNumeric(argType(0)); + return {Kind::FLOAT, Kind::UNKNOWN}; + } + if(op == "value_or_default") + { + requireArity(2, 2); + return argType(1); // value type follows the default literal + } + if(op == "if") + { + return inferIf(args); + } + if(op.find('.') != std::string::npos) + { + // Custom operations (native predicates) are out of scope for this + // PoC: no predicate registry ships, so the name fails to resolve + // (RFC 0018 §8 / A.9: fail closed). + throw UmdCompileError("custom operation not supported in PoC: " + op); + } + throw UmdCompileError("unrecognized operator: " + op); + } + + TypeInfo inferIf(const std::vector& args) + { + if(args.size() < 3 || args.size() % 2 == 0) + { + throw UmdCompileError("'if' requires 3 or 2n+1 arguments (A.7)"); + } + TypeInfo result; + bool haveResult = false; + for(std::size_t i = 0; i + 1 < args.size(); i += 2) + { + if(inferTypeImpl(args.at(i)).kind != Kind::BOOL) + { + throw UmdCompileError("'if' condition must be Bool (A.7)"); + } + const TypeInfo branch = inferTypeImpl(args.at(i + 1)); + result = unify(result, branch, haveResult); + haveResult = true; + } + if(args.size() % 2 == 1) + { + result = unify(result, inferTypeImpl(args.back()), haveResult); + } + return result; + } + + static TypeInfo unify(const TypeInfo& acc, const TypeInfo& next, bool haveAcc) + { + if(!haveAcc) + { + return next; + } + if(!sameDomain(acc.kind, next.kind)) + { + throw UmdCompileError("'if' branch results must share a type (A.7)"); + } + return acc; + } + + // Resolve the static type of a `$`-stripped variable path against the + // registry and shape maps; throws if the reference does not resolve + // (A.10 §8). + static TypeInfo varTypeOf(const std::string& varPath, const CompiledUmd& out) + { + std::string root; + std::string rest; + path::splitRoot(varPath, root, rest); + + if(root == "graph") + { + if(rest == "node_count") + { + return {Kind::INT, Kind::UNKNOWN}; + } + throw UmdCompileError("unknown graph field: " + rest); + } + if(root == "device") + { + if(rest.empty()) + { + throw UmdCompileError("device reference needs a field"); + } + return {Kind::INT, Kind::UNKNOWN}; // device scalars are numeric + } + const NodeSpec* node = out.findNode(root); + if(node != nullptr) + { + return attrType(rest, *node); + } + const TensorVarSpec* spec = out.findTvar(root); + if(spec != nullptr) + { + return tensorFieldType(rest, *spec); + } + throw UmdCompileError("unresolved reference (A.10 §8): $" + varPath); + } + static TypeInfo attrType(const std::string& rest, const NodeSpec& node) + { + if(rest.empty()) + { + // A bare node id is not a value reference (A.4 attr-ref needs a field). + throw UmdCompileError("node id $" + node.id + " is not a value reference (A.4)"); + } + std::string attr = rest; + const bool present = path::stripPresentSuffix(attr); + for(std::size_t i = 0; i < node.opSchema->attributeCount; ++i) + { + if(node.opSchema->attributes[i].name == attr) + { + if(present) + { + if(!node.opSchema->attributes[i].optional) + { + throw UmdCompileError(".present on a required attribute is refused (A.4): " + + attr); + } + return {Kind::BOOL, Kind::UNKNOWN}; + } + switch(node.opSchema->attributes[i].type) + { + case reg::AttrType::INT: + return {Kind::INT, Kind::UNKNOWN}; + case reg::AttrType::FLOAT: + return {Kind::FLOAT, Kind::UNKNOWN}; + case reg::AttrType::BOOL: + return {Kind::BOOL, Kind::UNKNOWN}; + case reg::AttrType::DTYPE: + return {Kind::DTYPE, Kind::UNKNOWN}; + default: + break; + } + } + } + throw UmdCompileError("unknown attribute for " + node.opcode + ": " + attr); + } + + static TypeInfo tensorFieldType(const std::string& rest, const TensorVarSpec& spec) + { + if(rest == "present") + { + if(!spec.optional) + { + throw UmdCompileError(".present on a required operand is refused (A.4): " + + spec.tvar); + } + return {Kind::BOOL, Kind::UNKNOWN}; + } + if(rest.empty() || rest == "uid") + { + return rest.empty() ? TypeInfo{Kind::TENSOR, Kind::UNKNOWN} + : TypeInfo{Kind::INT, Kind::UNKNOWN}; + } + if(rest == "rank") + { + return {Kind::INT, Kind::UNKNOWN}; + } + if(rest == "dtype") + { + return {Kind::DTYPE, Kind::UNKNOWN}; + } + if(rest == "stride_order") + { + return {Kind::ARRAY, Kind::INT}; + } + if(rest == "packed" || rest == "virtual") + { + return {Kind::BOOL, Kind::UNKNOWN}; + } + for(const std::string_view prefix : {std::string_view("dims"), std::string_view("strides")}) + { + if(path::isSubscriptOf(rest, prefix)) + { + std::size_t idx = 0; + if(!path::parseSubscript(rest, prefix, idx)) + { + throw UmdCompileError("malformed subscript $" + spec.tvar + "." + rest + + " (A.4 requires a non-negative integer index)"); + } + return {Kind::INT, Kind::UNKNOWN}; + } + } + if(spec.dimNames.find(rest) != spec.dimNames.end()) + { + return {Kind::INT, Kind::UNKNOWN}; + } + throw UmdCompileError("unknown tensor field $" + spec.tvar + "." + rest); + } + + const CompiledUmd* _ctx = nullptr; +}; + +} // namespace hip_kernel_provider_common::umd diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdPathParse.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdPathParse.hpp new file mode 100644 index 000000000000..d9a5439bc079 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdPathParse.hpp @@ -0,0 +1,103 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +// UmdPathParse.hpp - shared parsing of a sigil-stripped UMD variable path +// (RFC 0018 A.4), used by both the runtime resolver (BindingContext) and the +// compile-time resolver (UmdCompiler). The two MUST agree on how a path splits +// into namespace root and field, so the logic lives in one place rather than +// being duplicated where it could drift. + +#include +#include +#include + +namespace hip_kernel_provider_common::umd::path +{ + +// Split a path into its first segment (`root`) and the remainder (`rest`). A +// leading '[' subscript stays in `rest` so a bare-tensor path like `q` and a +// subscripted `q.dims[0]` both split on the first '.'/'['. +inline void splitRoot(const std::string& path, std::string& root, std::string& rest) +{ + const std::size_t p = path.find_first_of(".["); + if(p == std::string::npos) + { + root = path; + rest.clear(); + } + else if(path[p] == '.') + { + root = path.substr(0, p); + rest = path.substr(p + 1); + } + else + { + root = path.substr(0, p); + rest = path.substr(p); // keep '[' for the subscript + } +} + +// Parse `[N]` -> N where N is a non-negative decimal integer. Returns +// false when `rest` is not exactly that form (wrong prefix, missing/misplaced +// brackets, empty, non-numeric, negative, or trailing characters). +inline bool parseSubscript(const std::string& rest, std::string_view prefix, std::size_t& idx) +{ + const std::size_t plen = prefix.size(); + if(rest.size() < plen + 2 || rest.compare(0, plen, prefix) != 0 || rest[plen] != '[') + { + return false; + } + const std::size_t close = rest.find(']', plen + 1); + if(close == std::string::npos || close != rest.size() - 1) + { + return false; + } + const std::string num = rest.substr(plen + 1, close - plen - 1); + if(num.empty()) + { + return false; + } + std::size_t consumed = 0; + long value = 0; + try + { + value = std::stol(num, &consumed); + } + catch(const std::exception&) + { + return false; + } + if(consumed != num.size() || value < 0) + { + return false; + } + idx = static_cast(value); + return true; +} + +// If `s` ends with the reserved `.present` suffix, strip it and return true; +// otherwise leave `s` unchanged and return false. +inline bool stripPresentSuffix(std::string& s) +{ + static constexpr std::string_view K_PRESENT = ".present"; + if(s.size() > K_PRESENT.size() + && s.compare(s.size() - K_PRESENT.size(), K_PRESENT.size(), K_PRESENT) == 0) + { + s.resize(s.size() - K_PRESENT.size()); + return true; + } + return false; +} + +// True when `rest` begins with `[`, i.e. it is (well-formed or not) a +// subscript of that prefix. Used to distinguish a subscript attempt from an +// unrelated field before validating it with parseSubscript. +inline bool isSubscriptOf(const std::string& rest, std::string_view prefix) +{ + return rest.size() > prefix.size() && rest.compare(0, prefix.size(), prefix) == 0 + && rest[prefix.size()] == '['; +} + +} // namespace hip_kernel_provider_common::umd::path diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp new file mode 100644 index 000000000000..0728ec76dce8 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp @@ -0,0 +1,288 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT + +#pragma once + +// UniversalGraphMatcher.hpp - RFC 0018 (UMD) graph matcher. +// +// All names below live in namespace hip_kernel_provider_common::umd. +// +// A UniversalGraphMatcher owns exactly one CompiledUmd -- there is a 1:1 +// relationship between a matcher and its UMD JSON descriptor -- and matches a +// live flatbuffer graph against it. Matching does double duty (RFC 0018 §4): it +// decides applicability and binds the descriptor's named variables into a +// queryable BindingContext. +// +// match(): +// 1. structural -- locate a distinct graph node per pattern node (by opcode), +// honor the `allow_override_shape` gate, and bind each operand/result name +// to a graph tensor via the Phase 0 generated UID readers, enforcing that +// a shared pattern variable resolves to one tensor across nodes (implicit +// edges); decline on a missing required tensor or an edge conflict; +// 2. criteria -- construct the BindingContext and evaluate the compiled +// criteria expression; a non-true result declines; +// 3. return a MatchResult carrying the queryable bindings and the descriptor id. +// +// Fail closed: any structural failure or a false criterion yields no match; the +// matcher never matches a graph by default (RFC 0018 §14). + +#include "hip_kernel_provider_common/JsonLogic.hpp" +#include "hip_kernel_provider_common/umd/BindingContext.hpp" +#include "hip_kernel_provider_common/umd/UmdCompiler.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace hip_kernel_provider_common::umd +{ + +// The device-property scalars a criterion may read via `$device.` +// (RFC 0018 §4). Populated from the runtime device (hipDeviceProp_t +// sharedMemPerBlock / warpSize) and surfaced by name in the BindingContext. +struct DeviceProperties +{ + std::int64_t ldsSize = 0; + std::int64_t warpSize = 0; +}; + +struct MatchResult +{ + bool matched = false; + std::string umdId; + BindingContext bindings; // valid when matched; queryable post-match +}; + +class UniversalGraphMatcher +{ +public: + using IGraph = hipdnn_flatbuffers_sdk::flatbuffer_utilities::IGraph; + + // Compile a descriptor into this matcher. There is a 1:1 relationship + // between a matcher and its UMD; a matcher matches graphs against exactly + // one descriptor. Throws UmdCompileError on an invalid descriptor + // (RFC 0018 A.10). + explicit UniversalGraphMatcher(const nlohmann::json& descriptor) + : _umd(std::make_unique(UmdCompiler::compile(descriptor))) + { + } + + // Adopt an already-compiled descriptor. + explicit UniversalGraphMatcher(CompiledUmd umd) + : _umd(std::make_unique(std::move(umd))) + { + } + + // The id of the descriptor this matcher matches against. + const std::string& umdId() const + { + return _umd->id; + } + + const CompiledUmd& descriptor() const + { + return *_umd; + } + + // Match a graph against this matcher's single descriptor. + MatchResult match(const DeviceProperties& device, const IGraph& graph) const + { + // Fail closed on an invalid or nodeless graph. nodeCount() tolerates a + // null `nodes` field; nodeWrappers() would throw on it, so this guard + // also keeps match() exception-free for a graph with no nodes. + if(!graph.isValid() || graph.nodeCount() == 0) + { + return {}; + } + std::unordered_map deviceMap; + deviceMap.emplace("lds_size", jlogic::Value(device.ldsSize)); + deviceMap.emplace("warp_size", jlogic::Value(device.warpSize)); + + return tryMatch(*_umd, graph, graph.nodeWrappers(), deviceMap); + } + +private: + using INodeWrapper = hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper; + + MatchResult tryMatch(const CompiledUmd& umd, + const IGraph& graph, + const std::vector>& wrappers, + const std::unordered_map& deviceMap) const + { + // allow_override_shape gate (RFC 0018 §3): decline override-shape graphs + // unless the descriptor opts in. + if(graph.getGraph().is_override_shape_enabled() && !umd.allowOverrideShape) + { + return {}; + } + + // Structural: candidate graph nodes per pattern node (same opcode). + std::vector> candidates(umd.nodes.size()); + for(std::size_t i = 0; i < umd.nodes.size(); ++i) + { + for(const auto& w : wrappers) + { + if(w->attributesType() == umd.nodes[i].opSchema->attributesType + && w->attributes() != nullptr) + { + candidates[i].push_back(w.get()); + } + } + if(candidates[i].empty()) + { + return {}; + } + } + + // Assign each pattern node to a distinct graph node (injective), then + // enforce edges and evaluate criteria. Patterns are tiny, but a hostile + // graph could present many same-opcode candidates; a step budget caps + // the search so an adversarial input declines rather than stalls + // (RFC 0018 §14). Exhausting the budget fails closed. + std::vector assignment(umd.nodes.size(), nullptr); + std::vector used; + std::size_t steps = 0; + MatchResult result; + if(assignNode(umd, graph, deviceMap, candidates, 0, assignment, used, steps, result)) + { + return result; + } + return {}; + } + + // Upper bound on backtracking steps (candidate placements attempted). Real + // fusion patterns need a handful; the cap only fires on pathological input. + static constexpr std::size_t K_MAX_SEARCH_STEPS = 100000; + + bool assignNode(const CompiledUmd& umd, + const IGraph& graph, + const std::unordered_map& deviceMap, + const std::vector>& candidates, + std::size_t index, + std::vector& assignment, + std::vector& used, + std::size_t& steps, + MatchResult& result) const + { + if(index == umd.nodes.size()) + { + return finalize(umd, graph, deviceMap, assignment, result); + } + for(const INodeWrapper* cand : candidates[index]) + { + if(++steps > K_MAX_SEARCH_STEPS) + { + return false; // search budget exhausted -> fail closed (RFC 0018 §14) + } + if(std::find(used.begin(), used.end(), cand) != used.end()) + { + continue; // a graph node backs at most one pattern node + } + assignment[index] = cand; + used.push_back(cand); + if(assignNode( + umd, graph, deviceMap, candidates, index + 1, assignment, used, steps, result)) + { + return true; + } + used.pop_back(); + assignment[index] = nullptr; + } + return false; + } + + // Resolve every edge for a full node assignment, enforcing that a shared + // pattern variable resolves to one tensor across all nodes (implicit edges, + // RFC 0018 §3/A.3), then evaluate the criteria over the BindingContext. + static bool finalize(const CompiledUmd& umd, + const IGraph& graph, + const std::unordered_map& deviceMap, + const std::vector& assignment, + MatchResult& result) + { + struct VarBinding + { + bool present = false; + std::int64_t uid = 0; + }; + std::unordered_map binds; + + for(std::size_t i = 0; i < umd.nodes.size(); ++i) + { + const void* attrs = assignment[i]->attributes(); + for(const EdgeSlot& slot : umd.nodes[i].edges) + { + // NOLINTNEXTLINE(misc-const-correctness) - reader out-parameter + std::int64_t uid = 0; + const bool present = (*slot.reader)(attrs, uid); + const auto it = binds.find(slot.tvar); + if(!present) + { + if(!slot.optional) + { + return false; // required tensor absent -> decline + } + if(it == binds.end()) + { + binds.emplace(slot.tvar, VarBinding{false, 0}); + } + else if(it->second.present) + { + return false; // absent here but present on another edge + } + continue; + } + if(it == binds.end()) + { + binds.emplace(slot.tvar, VarBinding{true, uid}); + } + else if(!it->second.present || it->second.uid != uid) + { + return false; // edge conflict: same variable, different tensor + } + } + } + + BindingContext ctx(&graph, deviceMap); + for(std::size_t i = 0; i < umd.nodes.size(); ++i) + { + ctx.bindNode(umd.nodes[i].id, umd.nodes[i].opSchema, assignment[i]->attributes()); + } + + const auto& tensorMap = graph.getTensorMap(); + for(const TensorVarSpec& spec : umd.tvars) + { + const auto it = binds.find(spec.tvar); + if(it == binds.end() || !it->second.present) + { + ctx.bindTensor(spec.tvar, nullptr, spec.optional, &spec.dimNames); + continue; + } + const auto t = tensorMap.find(it->second.uid); + if(t == tensorMap.end()) + { + return false; // dangling UID -> decline (malformed graph) + } + ctx.bindTensor(spec.tvar, t->second, spec.optional, &spec.dimNames); + } + + if(!umd.criteria(ctx).truthy()) + { + return false; + } + result.matched = true; + result.umdId = umd.id; + result.bindings = std::move(ctx); + return true; + } + + std::unique_ptr _umd; +}; + +} // namespace hip_kernel_provider_common::umd diff --git a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt index e9632de6c421..c60e135c5288 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/tests/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable( core/TestPluginPublic.cpp core/TestJsonLogic.cpp core/TestJsonDataSource.cpp + core/TestUniversalGraphMatcher.cpp compilation/TestProgramAndKernel.cpp compilation/TestKernelCompileOptions.cpp compilation/TestModuleCache.cpp diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp new file mode 100644 index 000000000000..5cfd7622d415 --- /dev/null +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -0,0 +1,581 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// SPDX-License-Identifier: MIT +// +// RFC 0018 Phase 1: proves the bindings + UMD compiler + matcher end to end. +// - Compiler: A.10 validation (accept the SDPA descriptor; reject bad schema, +// mismatched `?`, unknown name, type errors, custom ops). +// - Lowering: `shape` -> rank check with named dims; layout alias -> array. +// - Matcher: structural match + criteria evaluation over a live graph, with +// fail-closed rejection on the declarative gates. +// - Bindings: the queryable BindingContext resolves every `$`-namespace form. +// +// The full match-equivalence battery against SdpaFwdPlanBuilder::isApplicable +// and the enumerated binding-completeness proof are the Phase 2 deliverable. + +#include + +#include + +#include +#include +#include + +#include +#include + +#include "hip_kernel_provider_common/umd/BindingContext.hpp" +#include "hip_kernel_provider_common/umd/UmdCompiler.hpp" +#include "hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp" + +namespace umd = hip_kernel_provider_common::umd; +namespace jlogic = hip_kernel_provider_common::jsonlogic; +namespace data = hipdnn_flatbuffers_sdk::data_objects; +namespace fbu = hipdnn_flatbuffers_sdk::flatbuffer_utilities; + +using json = nlohmann::json; + +namespace +{ + +// The RFC §18 SDPA-forward descriptor, with the two custom-operation gates +// (strides_fit_u32, sdpa_mask_consistent) removed -- the custom-op hook is out +// of scope for this PoC. Name keys are the lowercase op-schema registry names. +json sdpaDescriptor() +{ + return json::parse(R"JSON({ + "schema": "hipdnn.umd/v1", + "id": "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a", + "name": "SDPA forward (d128, bf16/fp8) match", + "nodes": [ + {"kind": "op", "id": "sdpa_fwd", "op": "sdpa_fwd", + "operands": {"q": "$q", "k": "$k", "v": "$v", + "attn_mask": "$attn_mask?", "page_table_k": "$page_table_k?", + "page_table_v": "$page_table_v?"}, + "results": {"o": "$o"}} + ], + "criteria": {"and": [ + {"==": ["$graph.node_count", 1]}, + {"in": ["$q.dtype", ["BFLOAT16", "FP8_E4M3"]]}, + {"==": ["$k.dtype", "$q.dtype"]}, {"==": ["$v.dtype", "$q.dtype"]}, + {"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}, + {"shape": ["$k", ["batch", "kv_heads", "seqlen_k", "head_size"]]}, + {"shape": ["$v", ["batch", "kv_heads", "seqlen_k", "head_size"]]}, + {"==": ["$o.rank", 4]}, + {"==": ["$q.head_size", 128]}, + {"==": ["$k.head_size", "$q.head_size"]}, + {"or": [{"!": "$sdpa_fwd.dropout_probability.present"}, {"==": ["$sdpa_fwd.dropout_probability", 0.0]}]}, + {"==": ["$sdpa_fwd.alibi_mask", false]}, + {"==": ["$sdpa_fwd.padding_mask", false]}, + {"or": [{"!": "$sdpa_fwd.generate_stats.present"}, {"==": ["$sdpa_fwd.generate_stats", false]}]}, + {"!": "$attn_mask.present"}, + {"!": "$page_table_k.present"}, {"!": "$page_table_v.present"} + ]} + })JSON"); +} + +std::vector contiguousStrides(const std::vector& dims) +{ + std::vector strides(dims.size(), 1); + for(std::size_t i = dims.size() - 1; i-- > 0;) + { + strides[i] = strides[i + 1] * dims[i + 1]; + } + return strides; +} + +// Build a single-node SDPA-forward graph with the given per-tensor dims and +// dtype (contiguous strides), returning the owning builder. +flatbuffers::FlatBufferBuilder buildSdpaGraph(const std::vector& dims, + data::DataType dtype, + bool withAttnMask = false, + bool overrideShape = false) +{ + const std::vector strides = contiguousStrides(dims); + return hipdnn_test_sdk::utilities::createValidSdpaFwdGraph(dims, + strides, + dims, + strides, + dims, + strides, + dims, + strides, + dtype, + withAttnMask, + /*withScale=*/false, + /*withStats=*/false, + /*alibiMask=*/false, + /*paddingMask=*/false, + /*causalMask=*/false, + overrideShape); +} + +umd::UniversalGraphMatcher makeSdpaMatcher() +{ + return umd::UniversalGraphMatcher(sdpaDescriptor()); +} + +constexpr umd::DeviceProperties K_DEVICE{/*ldsSize=*/65536, /*warpSize=*/64}; + +} // namespace + +// ---- Compiler validation (A.10) ------------------------------------------ + +TEST(UmdCompiler, AcceptsSdpaDescriptor) +{ + EXPECT_NO_THROW(umd::UmdCompiler::compile(sdpaDescriptor())); +} + +TEST(UmdCompiler, PublishesReferencedBoundSymbols) +{ + const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); + // The `shape` short-hands lower to `$q.rank` etc.; named dims are read + // directly. + EXPECT_TRUE(c.boundSymbols.count("q.head_size") == 1); + EXPECT_TRUE(c.boundSymbols.count("graph.node_count") == 1); + EXPECT_TRUE(c.boundSymbols.count("sdpa_fwd.dropout_probability") == 1); + EXPECT_TRUE(c.boundSymbols.count("attn_mask.present") == 1); +} + +TEST(UmdCompiler, RejectsWrongSchema) +{ + json d = sdpaDescriptor(); + d["schema"] = "hipdnn.umd/v2"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsMalformedUuid) +{ + json d = sdpaDescriptor(); + d["id"] = "not-a-uuid"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsUnknownTopLevelKey) +{ + json d = sdpaDescriptor(); + d["priority"] = 3; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsUnknownOperandName) +{ + json d = sdpaDescriptor(); + d["nodes"][0]["operands"]["not_a_name"] = "$bogus"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsQuestionOnRequiredName) +{ + json d = sdpaDescriptor(); + d["nodes"][0]["operands"]["q"] = "$q?"; // q is required + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsMissingQuestionOnOptionalName) +{ + json d = sdpaDescriptor(); + d["nodes"][0]["operands"]["attn_mask"] = "$attn_mask"; // optional, needs ? + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsReservedRootAsNodeId) +{ + json d = sdpaDescriptor(); + d["nodes"][0]["id"] = "device"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsTypeMismatchIntVsDtype) +{ + json d = sdpaDescriptor(); + // $q.head_size is Int; comparing it to a Dtype string is a compile error. + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.head_size", "BFLOAT16"]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsNonBooleanCriteria) +{ + json d = sdpaDescriptor(); + d["criteria"] = json::parse(R"({"+": ["$q.head_size", 1]})"); // Int, not Bool + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsCustomOperation) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"hipdnn.strides_fit_u32": ["$q"]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsPresentOnRequiredOperand) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"!": "$q.present"})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsWrongArity) +{ + // Binary/unary operators with the wrong argument count must refuse at + // compile with a UmdCompileError, not escape as std::out_of_range. + for(const char* bad : {R"({"==": ["$q.rank"]})", + R"({"==": ["$q.rank", 4, 4]})", + R"({"<": ["$q.rank"]})", + R"({"in": ["$q.dtype"]})", + R"({"and": [true]})", + R"({"if": [true, 1]})"}) + { + json d = sdpaDescriptor(); + d["criteria"] = json::parse(bad); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError) << bad; + } +} + +TEST(UmdCompiler, RejectsMalformedSubscript) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.dims[x]", 2]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsBareNodeIdAsValue) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$sdpa_fwd", 0]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsLayoutAliasRankMismatch) +{ + // $q is pinned rank 4 by the shape short-hand; a rank-5 alias can never + // match, so it is refused at compile rather than always declining. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.stride_order", "ncdhw"]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, AcceptsWellFormedArithmeticAndIf) +{ + // Guard the arity checks against over-rejecting valid n-ary / binary / if + // forms. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse( + R"({"==": [{"if": [{"<": ["$q.head_size", 64]}, {"-": ["$q.head_size", 1]}, 128]}, 128]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); +} + +// ---- Matcher: accept / reject -------------------------------------------- + +TEST(UniversalGraphMatcher, OwnsSingleDescriptor) +{ + // A matcher has a 1:1 relationship with its UMD: it exposes that one + // descriptor's id and compiled form. + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + EXPECT_EQ(m.umdId(), "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); + EXPECT_EQ(m.descriptor().id, "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); + EXPECT_EQ(m.descriptor().nodes.size(), 1u); +} + +TEST(UniversalGraphMatcher, MatchesValidSdpaForward) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + EXPECT_EQ(r.umdId, "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); +} + +TEST(UniversalGraphMatcher, DeclinesWrongHeadSize) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 64}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, DeclinesUnsupportedDtype) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::HALF); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, DeclinesWhenUnsupportedOptionalPresent) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16, /*withAttnMask=*/true); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, DeclinesOverrideShapeGraph) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, + data::DataType::BFLOAT16, + /*withAttnMask=*/false, + /*overrideShape=*/true); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, DeclinesGraphWithoutMatchingOpcode) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = hipdnn_test_sdk::utilities::createValidBlockScaleQuantizeGraph(); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, DeclinesNodelessGraphWithoutThrowing) +{ + // A valid but empty graph must fail closed, not throw (nodeWrappers() would + // throw on a null nodes vector). + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = hipdnn_test_sdk::utilities::createEmptyValidGraph(); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_NO_THROW({ EXPECT_FALSE(m.match(K_DEVICE, g).matched); }); +} + +// ---- Bindings: queryable BindingContext (RFC 0018 §4/§15) ---------------- + +TEST(BindingContext, ResolvesEveryNamespaceForm) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + const umd::BindingContext& b = r.bindings; + + // Tensor namespace. + EXPECT_EQ(b.get("$q.uid").asInt(), 1); + EXPECT_EQ(b.get("$q.rank").asInt(), 4); + EXPECT_EQ(b.get("$q.dtype").asString(), "BFLOAT16"); + EXPECT_EQ(b.get("$q.dims[0]").asInt(), 2); // positional + EXPECT_EQ(b.get("$q.head_size").asInt(), 128); // named dim + EXPECT_EQ(b.get("$q.strides[3]").asInt(), 1); + EXPECT_TRUE(b.get("$q.packed").asBool()); + EXPECT_FALSE(b.get("$q.virtual").asBool()); + + // stride_order uses extractStrideOrder's per-dim priority encoding + // (ApplicabilityChecks.cpp:17): contiguous rank-4 is [3,2,1,0]. + const jlogic::Value strideOrder = b.get("$q.stride_order"); + ASSERT_TRUE(strideOrder.isArray()); + EXPECT_EQ(strideOrder, + jlogic::Value(jlogic::Value::Array{jlogic::Value(std::int64_t{3}), + jlogic::Value(std::int64_t{2}), + jlogic::Value(std::int64_t{1}), + jlogic::Value(std::int64_t{0})})); + + // Attributes namespace. + EXPECT_FALSE(b.get("$sdpa_fwd.dropout_probability.present").asBool()); + EXPECT_FALSE(b.get("$sdpa_fwd.alibi_mask").asBool()); + + // Graph and device namespaces. + EXPECT_EQ(b.get("$graph.node_count").asInt(), 1); + EXPECT_EQ(b.get("$device.lds_size").asInt(), 65536); + EXPECT_EQ(b.get("$device.warp_size").asInt(), 64); +} + +TEST(BindingContext, FailsClosedOnBadPaths) +{ + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + const umd::BindingContext& b = r.bindings; + + EXPECT_TRUE(b.get("$q.dims[9]").isNull()); // out of range + EXPECT_TRUE(b.get("$q.unknown_dim").isNull()); // unknown dim-name + EXPECT_TRUE(b.get("$attn_mask.dtype").isNull()); // absent optional field read + EXPECT_TRUE(b.get("$device.unknown").isNull()); // unknown device field + EXPECT_TRUE(b.get("$nope.rank").isNull()); // unknown tensor var +} + +// ---- Lowering: layout alias (A.8) ---------------------------------------- + +TEST(UmdCompiler, LayoutAliasMatchesContiguousLayout) +{ + const umd::UniversalGraphMatcher m(json::parse(R"JSON({ + "schema": "hipdnn.umd/v1", + "id": "11111111-2222-3333-4444-555555555555", + "name": "layout alias smoke", + "nodes": [{"kind": "op", "id": "sdpa_fwd", "op": "sdpa_fwd", + "operands": {"q": "$q"}}], + "criteria": {"and": [ + {"==": ["$graph.node_count", 1]}, + {"shape": ["$q", ["b", "h", "s", "d"]]}, + {"==": ["$q.stride_order", "nchw"]} + ]} + })JSON")); + + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, g).matched); +} + +// ---- Multi-node patterns (RFC 0018 §3/A.3) ------------------------------- + +namespace +{ + +// Build a two-node SDPA graph. When `chained`, node1's output tensor (uid 4) is +// also node2's Q input, forming an edge; otherwise node2 reads an independent Q +// (uid 8), so no edge exists. `alibi2` sets node2's alibi_mask to exercise +// per-node attribute resolution. +flatbuffers::FlatBufferBuilder buildTwoNodeSdpaGraph(bool chained, bool alibi2 = false) +{ + flatbuffers::FlatBufferBuilder builder; + const std::vector dims{2, 8, 16, 128}; + const std::vector strides = contiguousStrides(dims); + const data::DataType dtype = data::DataType::BFLOAT16; + + const auto mkTensor = [&](std::int64_t uid, const char* name) { + return data::CreateTensorAttributesDirect(builder, uid, name, dtype, &strides, &dims); + }; + + std::vector<::flatbuffers::Offset> tensors; + tensors.push_back(mkTensor(1, "q1")); + tensors.push_back(mkTensor(2, "k1")); + tensors.push_back(mkTensor(3, "v1")); + tensors.push_back(mkTensor(4, "o1")); // node1 output + tensors.push_back(mkTensor(5, "k2")); + tensors.push_back(mkTensor(6, "v2")); + tensors.push_back(mkTensor(7, "o2")); // node2 output + const std::int64_t node2q = chained ? 4 : 8; + if(!chained) + { + tensors.push_back(mkTensor(8, "q2")); + } + + const auto mkSdpa + = [&](std::int64_t q, std::int64_t k, std::int64_t v, std::int64_t o, bool alibi) { + data::SdpaAttributesBuilder sb(builder); + sb.add_q_tensor_uid(q); + sb.add_k_tensor_uid(k); + sb.add_v_tensor_uid(v); + sb.add_o_tensor_uid(o); + sb.add_alibi_mask(alibi); + return sb.Finish(); + }; + const auto s1 = mkSdpa(1, 2, 3, 4, /*alibi=*/false); + const auto s2 = mkSdpa(node2q, 5, 6, 7, alibi2); + + std::vector<::flatbuffers::Offset> nodes; + nodes.push_back(data::CreateNodeDirect( + builder, "sdpa1", dtype, data::NodeAttributes::SdpaAttributes, s1.Union())); + nodes.push_back(data::CreateNodeDirect( + builder, "sdpa2", dtype, data::NodeAttributes::SdpaAttributes, s2.Union())); + + auto graph = data::CreateGraphDirect(builder, + "test", + data::DataType::FLOAT, + data::DataType::HALF, + data::DataType::BFLOAT16, + &tensors, + &nodes, + flatbuffers::nullopt, + /*is_override_shape_enabled=*/false); + builder.Finish(graph); + return builder; +} + +// A two-node fusion pattern: two SDPA nodes chained by the shared `$mid` edge, +// each bound to its own node id (`sdpa1`, `sdpa2`). +json twoNodeChainDescriptor() +{ + return json::parse(R"JSON({ + "schema": "hipdnn.umd/v1", + "id": "22222222-3333-4444-5555-666666666666", + "name": "two-node sdpa chain", + "nodes": [ + {"kind": "op", "id": "sdpa1", "op": "sdpa_fwd", + "operands": {"q": "$q", "k": "$k1", "v": "$v1"}, "results": {"o": "$mid"}}, + {"kind": "op", "id": "sdpa2", "op": "sdpa_fwd", + "operands": {"q": "$mid", "k": "$k2", "v": "$v2"}, "results": {"o": "$out"}} + ], + "criteria": {"and": [ + {"==": ["$graph.node_count", 2]}, + {"shape": ["$mid", ["b", "h", "s", "d"]]}, + {"==": ["$mid.d", 128]}, + {"==": ["$q.dtype", "$out.dtype"]}, + {"==": ["$sdpa1.alibi_mask", false]}, + {"==": ["$sdpa2.alibi_mask", false]} + ]} + })JSON"); +} + +} // namespace + +TEST(UmdCompiler, AcceptsMultiNodeDescriptor) +{ + const umd::CompiledUmd c = umd::UmdCompiler::compile(twoNodeChainDescriptor()); + EXPECT_EQ(c.nodes.size(), 2u); + ASSERT_NE(c.findNode("sdpa1"), nullptr); + ASSERT_NE(c.findNode("sdpa2"), nullptr); + // The `$mid` edge variable is shared by both nodes (result of one, operand + // of the other) but declared once. + ASSERT_NE(c.findTvar("mid"), nullptr); +} + +TEST(UmdCompiler, RejectsDuplicateNodeId) +{ + json d = twoNodeChainDescriptor(); + d["nodes"][1]["id"] = "sdpa1"; // collides with node 0 + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsVariableProducedTwice) +{ + json d = twoNodeChainDescriptor(); + d["nodes"][1]["results"]["o"] = "$mid"; // $mid produced by both nodes + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UniversalGraphMatcher, MatchesTwoNodeChainAndBindsEachNode) +{ + const umd::UniversalGraphMatcher m(twoNodeChainDescriptor()); + auto builder = buildTwoNodeSdpaGraph(/*chained=*/true); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + const umd::BindingContext& b = r.bindings; + // Each pattern variable resolved to its own graph tensor; the shared edge + // variable resolved to the tensor produced by node1 and consumed by node2. + EXPECT_EQ(b.get("$q.uid").asInt(), 1); + EXPECT_EQ(b.get("$mid.uid").asInt(), 4); + EXPECT_EQ(b.get("$out.uid").asInt(), 7); + EXPECT_EQ(b.get("$mid.d").asInt(), 128); // named dim on the edge tensor + // Each node id resolves its own attributes namespace. + EXPECT_FALSE(b.get("$sdpa1.alibi_mask").asBool()); + EXPECT_FALSE(b.get("$sdpa2.alibi_mask").asBool()); +} + +TEST(UniversalGraphMatcher, DeclinesWhenEdgeIsInconsistent) +{ + const umd::UniversalGraphMatcher m(twoNodeChainDescriptor()); + // node2 reads an independent Q, so no tensor satisfies the shared `$mid` + // edge (node1.o != node2.q) -> no consistent assignment -> decline. + auto builder = buildTwoNodeSdpaGraph(/*chained=*/false); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, PerNodeAttributesAreDistinct) +{ + const umd::UniversalGraphMatcher m(twoNodeChainDescriptor()); + // Chained graph, but node2's alibi_mask is set; the criterion + // {"==": ["$sdpa2.alibi_mask", false]} must read node2's own attribute and + // decline, proving per-node attribute resolution. + auto builder = buildTwoNodeSdpaGraph(/*chained=*/true, /*alibi2=*/true); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index da74f05cedce..3e2d92982e60 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -156,7 +156,7 @@ variables (`$q`, `$conv_out`). "name": "SDPA forward (d128, bf16) match", "nodes": [ {"kind": "op", "id": "sdpa_fwd", "op": "sdpa_fwd", - "operands": {"Q": "$q", "K": "$k", "V": "$v"}, "results": {"O": "$o"}} + "operands": {"q": "$q", "k": "$k", "v": "$v"}, "results": {"o": "$o"}} ], "criteria": { /* Section 6: one JsonLogic boolean expression */ } } @@ -197,11 +197,11 @@ expressed by requiring each intermediate `virtual` and pinning the op count with "name": "Conv-Bias-ReLU (NHWC, f16) match", "nodes": [ {"kind": "op", "id": "conv", "op": "convolution_fwd", - "operands": {"X": "$x", "W": "$w"}, "results": {"Y": "$conv_out"}}, + "operands": {"x": "$x", "w": "$w"}, "results": {"y": "$conv_out"}}, {"kind": "op", "id": "bias", "op": "pointwise_add", - "operands": {"A": "$conv_out", "B": "$bias"}, "results": {"Y": "$bias_out"}}, + "operands": {"a": "$conv_out", "b": "$bias"}, "results": {"y": "$bias_out"}}, {"kind": "op", "id": "act", "op": "pointwise_relu", - "operands": {"A": "$bias_out"}, "results": {"Y": "$y"}} + "operands": {"a": "$bias_out"}, "results": {"y": "$y"}} ], "criteria": {"and": [ {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [0, 2, 3, 1]]}, "$x.packed", // NHWC @@ -700,10 +700,10 @@ non-declarative gates (uint32 stride fit, mask self-consistency) remain as custo "name": "SDPA forward (d128, bf16/fp8) match", "nodes": [ {"kind": "op", "id": "sdpa_fwd", "op": "sdpa_fwd", - "operands": {"Q": "$q", "K": "$k", "V": "$v", + "operands": {"q": "$q", "k": "$k", "v": "$v", "attn_mask": "$attn_mask?", "page_table_k": "$page_table_k?", "page_table_v": "$page_table_v?"}, // optional operands carry a ? suffix - "results": {"O": "$o"}} + "results": {"o": "$o"}} ], "criteria": {"and": [ {"==": ["$graph.node_count", 1]}, // exact: this kernel is the whole graph From 486228c80e748ce7682303e951b9cda39032587d Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Fri, 24 Jul 2026 17:05:08 -0600 Subject: [PATCH 47/60] Add $kernel metadata namespace to UMD graph matcher Resolve `$kernel.` criteria references against caller-supplied, fully-resolved KMD metadata. Kernel fields are DYNAMIC scalars: not statically typed at UMD-compile time (their types come from the engine's KMD, validated at load), unified with any scalar domain at match time. - JsonLogic: add referencesVariableRoot() first-path-token helper. - UmdCompiler: DYNAMIC kind for $kernel fields; flag descriptors that reference kernel metadata; accept DYNAMIC in bool/numeric/if positions; restrict DYNAMIC unification to scalar domains (reject array/tensor). - BindingContext: bindKernelMetadata() backed by a JsonDataSource. - UniversalGraphMatcher: three-arg match() overload taking kernel metadata; two-arg overload throws for kernel-referencing descriptors; referencesKernelMetadata() routes between them. - Tests for helper, compiler type domain, and matcher routing/behavior. (cherry picked from commit 1607859e374c875d2f5d484390db99f79aaf2e3e) (cherry picked from commit 2cd003d42ecc26c9151a09c69b6775c4ea8013e1) --- .../hip_kernel_provider_common/JsonLogic.hpp | 20 +++ .../umd/BindingContext.hpp | 20 ++- .../umd/UmdCompiler.hpp | 47 +++++- .../umd/UniversalGraphMatcher.hpp | 71 +++++++-- .../src/tests/core/TestJsonLogic.cpp | 21 +++ .../tests/core/TestUniversalGraphMatcher.cpp | 135 ++++++++++++++++++ 6 files changed, 299 insertions(+), 15 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp index 2603bbbeb041..51450ad125b3 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp @@ -55,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -1104,4 +1105,23 @@ Value evaluate(const nlohmann::json& rule, const DataT& data, char varSigil = '$ return compile(rule, varSigil)(data); } +/// True when any variable referenced by `expr` has `root` as its first path +/// token (the segment before the first '.'/'[' separator). The paths yielded by +/// Expression::variables() are already sigil-stripped, so `root` is given +/// without the sigil (e.g. "kernel"). Short-circuits on the first match. +template +bool referencesVariableRoot(const Expression& expr, std::string_view root) +{ + for(const std::string& path : expr.variables()) + { + const std::size_t end = path.find_first_of(".["); + const std::string_view first(path.data(), end == std::string::npos ? path.size() : end); + if(first == root) + { + return true; + } + } + return false; +} + } // namespace hip_kernel_provider_common::jsonlogic diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp index 28b92203f04b..f07d2488e56c 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp @@ -23,8 +23,10 @@ // `$q.packed`, `$q.virtual`, `$q.present` // - Graph `$graph.node_count` // - Attributes `$.`, `$..present` +// - Kernel `$kernel.` (from caller-supplied, fully-resolved KMD +// metadata; an unbound document is a defensive fallback that +// reads every field as null) // - Device `$device.` -// (Kernel namespace omitted: SDPA-forward criteria references none.) // // The two resolvers (RFC 0018 §4, "binding architecture"): edge (name->UID-> // tensor) and scalar-attribute reads use the Phase 0 *generated* typed @@ -38,6 +40,7 @@ // absent optional operand -- resolves to null, which makes the enclosing // criterion false, declining the match rather than matching on a wrong value. +#include "hip_kernel_provider_common/JsonDataSource.hpp" #include "hip_kernel_provider_common/JsonLogic.hpp" #include "hip_kernel_provider_common/umd/UmdPathParse.hpp" @@ -109,6 +112,16 @@ class BindingContext _tensors[tvar] = BoundTensor{tensor, optional, dimNames}; } + // Bind the kernel metadata the criteria's `$kernel.` references + // resolve against (RFC 0018 §4). The document is a UKD's fully-resolved KMD + // values (optional fields already filled with schema defaults, so every + // field a referenced criterion needs is present). An unset or empty + // document is a defensive fallback in which every kernel field reads null. + void bindKernelMetadata(nlohmann::json metadata) + { + _kernel = jlogic::JsonDataSource(std::move(metadata)); + } + // JsonLogic data-source contract: resolve a sigil-stripped variable path // (`q.head_size`, `graph.node_count`, `sdpa_fwd.dropout_probability.present`) // to a Value. Unresolved -> null (fail closed). @@ -126,6 +139,10 @@ class BindingContext { return resolveDevice(rest); } + if(root == "kernel") + { + return _kernel.getData(rest); + } const auto nit = _nodes.find(root); if(nit != _nodes.end()) { @@ -336,6 +353,7 @@ class BindingContext std::unordered_map _device; std::unordered_map _nodes; std::unordered_map _tensors; + jlogic::JsonDataSource _kernel; // empty until bindKernelMetadata() }; } // namespace hip_kernel_provider_common::umd diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp index 8d429367761b..92e8c9b6e9ac 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -101,6 +101,11 @@ struct CompiledUmd jlogic::Expression criteria; std::set boundSymbols; + // True when the criteria expression references any `$kernel.`. Set + // once by UmdCompiler::run() and immutable thereafter; the matcher reads it + // to route between the metadata and non-metadata match overloads. + bool referencesKernelMetadata = false; + const TensorVarSpec* findTvar(const std::string& tvar) const { for(const auto& t : tvars) @@ -149,7 +154,13 @@ class UmdCompiler private: // Static type domain for A.10 §9. Numeric unifies Int and Float; ARRAY - // carries its element kind for `in` / IntArray equality. + // carries its element kind for `in` / IntArray equality. DYNAMIC is a + // runtime-resolved scalar wildcard: `$kernel.` values come from a + // UKD's KMD, unavailable at UMD-compile time (RFC 0018 §4/§10), so they are + // not statically type-checked here. A referenced kernel field is assumed + // validated against the engine's KMD schema at load (a typo'd field is + // caught there, not here); the metadata supplied to match() is fully + // resolved, so every referenced field is present at match time. enum class Kind { INT, @@ -159,6 +170,7 @@ class UmdCompiler ARRAY, TENSOR, JSON_NULL, + DYNAMIC, UNKNOWN }; struct TypeInfo @@ -185,7 +197,7 @@ class UmdCompiler nlohmann::json const lowered = lowerCriteria(d.at("criteria"), out); const TypeInfo t = inferType(lowered, &out); - if(t.kind != Kind::BOOL) + if(t.kind != Kind::BOOL && t.kind != Kind::DYNAMIC) { throw UmdCompileError("criteria must have static type Bool (A.10 §10)"); } @@ -197,6 +209,7 @@ class UmdCompiler varTypeOf(sym, out); out.boundSymbols.insert(sym); } + out.referencesKernelMetadata = jlogic::referencesVariableRoot(out.criteria, "kernel"); return out; } @@ -782,6 +795,17 @@ class UmdCompiler static bool sameDomain(Kind a, Kind b) { + // DYNAMIC (a runtime-resolved $kernel field) is a scalar wildcard: it + // unifies with any scalar domain, but not with ARRAY or TENSOR. Its + // concrete scalar type is unknown until match time. + if(a == Kind::DYNAMIC) + { + return b != Kind::ARRAY && b != Kind::TENSOR; + } + if(b == Kind::DYNAMIC) + { + return a != Kind::ARRAY && a != Kind::TENSOR; + } if(isNumeric(a) && isNumeric(b)) { return true; @@ -815,13 +839,13 @@ class UmdCompiler } }; const auto requireBool = [&](const TypeInfo& t) { - if(t.kind != Kind::BOOL) + if(t.kind != Kind::BOOL && t.kind != Kind::DYNAMIC) { throw UmdCompileError("operator '" + op + "' requires Bool arguments (A.7)"); } }; const auto requireNumeric = [&](const TypeInfo& t) { - if(!isNumeric(t.kind)) + if(!isNumeric(t.kind) && t.kind != Kind::DYNAMIC) { throw UmdCompileError("operator '" + op + "' requires numeric arguments (A.7)"); } @@ -956,7 +980,8 @@ class UmdCompiler bool haveResult = false; for(std::size_t i = 0; i + 1 < args.size(); i += 2) { - if(inferTypeImpl(args.at(i)).kind != Kind::BOOL) + const Kind cond = inferTypeImpl(args.at(i)).kind; + if(cond != Kind::BOOL && cond != Kind::DYNAMIC) { throw UmdCompileError("'if' condition must be Bool (A.7)"); } @@ -1009,6 +1034,18 @@ class UmdCompiler } return {Kind::INT, Kind::UNKNOWN}; // device scalars are numeric } + if(root == "kernel") + { + if(rest.empty()) + { + throw UmdCompileError("kernel reference needs a field"); + } + // Kernel field types come from a UKD's KMD, unavailable at + // UMD-compile time; resolve their domain at match time (DYNAMIC). + // Field existence is validated against the engine's KMD schema at + // load, so an undeclared field is rejected before match runs. + return {Kind::DYNAMIC, Kind::UNKNOWN}; + } const NodeSpec* node = out.findNode(root); if(node != nullptr) { diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp index 0728ec76dce8..204b8b909925 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp @@ -31,10 +31,12 @@ #include "hip_kernel_provider_common/umd/UmdCompiler.hpp" #include +#include #include #include #include +#include #include #include #include @@ -90,8 +92,46 @@ class UniversalGraphMatcher return *_umd; } + // True when this matcher's descriptor references `$kernel.`. Such a + // descriptor must be matched with the kernel-metadata overload of match(); + // the two-argument overload throws for it. + bool referencesKernelMetadata() const + { + return _umd->referencesKernelMetadata; + } + // Match a graph against this matcher's single descriptor. + // + // Throws std::logic_error if the descriptor references `$kernel.`: + // resolving those needs kernel metadata, so the caller must use the + // three-argument overload. Use referencesKernelMetadata() to route. MatchResult match(const DeviceProperties& device, const IGraph& graph) const + { + if(_umd->referencesKernelMetadata) + { + throw std::logic_error( + "UMD '" + _umd->id + + "' references $kernel metadata; call the kernel-metadata match overload"); + } + return matchImpl(device, graph, nullptr); + } + + // Match a graph against this matcher's single descriptor, resolving + // `$kernel.` references against the supplied kernel metadata (a UKD's + // KMD values). An unresolved kernel field reads null (fail closed). + MatchResult match(const DeviceProperties& device, + const IGraph& graph, + const nlohmann::json& kernelMetadata) const + { + return matchImpl(device, graph, &kernelMetadata); + } + +private: + using INodeWrapper = hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper; + + MatchResult matchImpl(const DeviceProperties& device, + const IGraph& graph, + const nlohmann::json* kernel) const { // Fail closed on an invalid or nodeless graph. nodeCount() tolerates a // null `nodes` field; nodeWrappers() would throw on it, so this guard @@ -104,16 +144,14 @@ class UniversalGraphMatcher deviceMap.emplace("lds_size", jlogic::Value(device.ldsSize)); deviceMap.emplace("warp_size", jlogic::Value(device.warpSize)); - return tryMatch(*_umd, graph, graph.nodeWrappers(), deviceMap); + return tryMatch(*_umd, graph, graph.nodeWrappers(), deviceMap, kernel); } -private: - using INodeWrapper = hipdnn_flatbuffers_sdk::flatbuffer_utilities::INodeWrapper; - MatchResult tryMatch(const CompiledUmd& umd, const IGraph& graph, const std::vector>& wrappers, - const std::unordered_map& deviceMap) const + const std::unordered_map& deviceMap, + const nlohmann::json* kernel) const { // allow_override_shape gate (RFC 0018 §3): decline override-shape graphs // unless the descriptor opts in. @@ -149,7 +187,8 @@ class UniversalGraphMatcher std::vector used; std::size_t steps = 0; MatchResult result; - if(assignNode(umd, graph, deviceMap, candidates, 0, assignment, used, steps, result)) + if(assignNode( + umd, graph, deviceMap, kernel, candidates, 0, assignment, used, steps, result)) { return result; } @@ -163,6 +202,7 @@ class UniversalGraphMatcher bool assignNode(const CompiledUmd& umd, const IGraph& graph, const std::unordered_map& deviceMap, + const nlohmann::json* kernel, const std::vector>& candidates, std::size_t index, std::vector& assignment, @@ -172,7 +212,7 @@ class UniversalGraphMatcher { if(index == umd.nodes.size()) { - return finalize(umd, graph, deviceMap, assignment, result); + return finalize(umd, graph, deviceMap, kernel, assignment, result); } for(const INodeWrapper* cand : candidates[index]) { @@ -186,8 +226,16 @@ class UniversalGraphMatcher } assignment[index] = cand; used.push_back(cand); - if(assignNode( - umd, graph, deviceMap, candidates, index + 1, assignment, used, steps, result)) + if(assignNode(umd, + graph, + deviceMap, + kernel, + candidates, + index + 1, + assignment, + used, + steps, + result)) { return true; } @@ -203,6 +251,7 @@ class UniversalGraphMatcher static bool finalize(const CompiledUmd& umd, const IGraph& graph, const std::unordered_map& deviceMap, + const nlohmann::json* kernel, const std::vector& assignment, MatchResult& result) { @@ -250,6 +299,10 @@ class UniversalGraphMatcher } BindingContext ctx(&graph, deviceMap); + if(kernel != nullptr) + { + ctx.bindKernelMetadata(*kernel); + } for(std::size_t i = 0; i < umd.nodes.size(); ++i) { ctx.bindNode(umd.nodes[i].id, umd.nodes[i].opSchema, assignment[i]->attributes()); diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp index 59c3f167cc93..39ba0a4659f4 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "hip_kernel_provider_common/JsonDataSource.hpp" @@ -242,6 +243,26 @@ TEST(JsonLogic, VariablesCollectsReferencedPaths) EXPECT_TRUE(vars(json(42)).empty()); } +TEST(JsonLogic, ReferencesVariableRootMatchesFirstToken) +{ + const auto refs = [](const json& rule, std::string_view root) { + const auto expr = jlogic::compile(rule); + return jlogic::referencesVariableRoot(expr, root); + }; + // matches on the first path token, before any '.' or '[' separator + EXPECT_TRUE(refs(json({{"<", json::array({"$kernel.tile_m", "$device.lds_size"})}}), "kernel")); + EXPECT_TRUE(refs(json({{"<", json::array({"$kernel.tile_m", "$device.lds_size"})}}), "device")); + EXPECT_TRUE(refs(json({{"==", json::array({"$kernel.vec[0]", 1})}}), "kernel")); + // a bare root (no field) still matches its own token + EXPECT_TRUE(refs(json("$kernel"), "kernel")); + // no variable has that root + EXPECT_FALSE(refs(json({{"==", json::array({"$q.head_size", 128})}}), "kernel")); + // a root is a whole-token match, not a prefix + EXPECT_FALSE(refs(json("$kernelish.x"), "kernel")); + // literal-only expression references nothing + EXPECT_FALSE(refs(json({{"+", json::array({1, 2})}}), "kernel")); +} + TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) { // range-for yields every occurrence, duplicates included diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index 5cfd7622d415..8ec17b99ae74 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -579,3 +579,138 @@ TEST(UniversalGraphMatcher, PerNodeAttributesAreDistinct) fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); EXPECT_FALSE(m.match(K_DEVICE, g).matched); } + +// ---- Kernel metadata ($kernel) ------------------------------------------- + +namespace +{ + +// The SDPA descriptor with one `$kernel.` criterion added. Compiles only +// because the compiler resolves `$kernel.*` as a runtime-DYNAMIC scalar. +json sdpaKernelDescriptor() +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$kernel.head_dim", "$q.head_size"]})")); + return d; +} + +} // namespace + +TEST(UmdCompiler, AcceptsKernelReference) +{ + // A criterion over a $kernel field compiles (DYNAMIC wildcard) and the + // descriptor is flagged as referencing kernel metadata. + umd::CompiledUmd c; + ASSERT_NO_THROW(c = umd::UmdCompiler::compile(sdpaKernelDescriptor())); + EXPECT_TRUE(c.referencesKernelMetadata); + EXPECT_TRUE(c.boundSymbols.count("kernel.head_dim") == 1); +} + +TEST(UmdCompiler, StockDescriptorDoesNotReferenceKernel) +{ + const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); + EXPECT_FALSE(c.referencesKernelMetadata); +} + +TEST(UmdCompiler, KernelFieldUnifiesWithAnyScalarDomain) +{ + // A $kernel field is DYNAMIC: it compiles against an int, a dtype string, + // and a bool alike -- none is a static type error. + for(const char* crit : {R"({"==": ["$kernel.head_dim", 128]})", + R"({"==": ["$kernel.target_dtype", "BFLOAT16"]})", + R"({"<": ["$kernel.tile_m", "$q.head_size"]})", + R"({"!": "$kernel.causal"})"}) + { + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(crit)); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)) << crit; + } +} + +TEST(UmdCompiler, KernelReferenceNeedsAField) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$kernel", 1]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, KernelFieldAsIfCondition) +{ + // A DYNAMIC $kernel field is accepted in an `if` condition, consistent with + // its acceptance in and/or/! boolean positions. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": [{"if": ["$kernel.causal", 1, 0]}, 0]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); +} + +TEST(UmdCompiler, KernelFieldDoesNotUnifyWithArray) +{ + // DYNAMIC is a scalar wildcard: comparing a $kernel field to an array + // literal is a static type error, not a runtime-deferred check. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$kernel.tiles", [1, 2, 3]]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, KernelIsReservedAsPatternVariable) +{ + json d = sdpaDescriptor(); + d["nodes"][0]["operands"]["q"] = "$kernel"; // bind a tensor var to the reserved root + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, KernelIsReservedAsNodeId) +{ + json d = sdpaDescriptor(); + d["nodes"][0]["id"] = "kernel"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UniversalGraphMatcher, ReportsKernelMetadataReference) +{ + const umd::UniversalGraphMatcher mk(sdpaKernelDescriptor()); + EXPECT_TRUE(mk.referencesKernelMetadata()); + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + EXPECT_FALSE(m.referencesKernelMetadata()); +} + +TEST(UniversalGraphMatcher, TwoArgMatchThrowsWhenDescriptorNeedsKernel) +{ + const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + // Wrong overload for a kernel-referencing UMD -> programming error. + EXPECT_THROW(m.match(K_DEVICE, g), std::logic_error); +} + +TEST(UniversalGraphMatcher, MatchesWithSuppliedKernelMetadata) +{ + const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + // head_dim matches $q.head_size (128) -> match; the value is queryable. + const umd::MatchResult r = m.match(K_DEVICE, g, json{{"head_dim", 128}}); + ASSERT_TRUE(r.matched); + EXPECT_EQ(r.bindings.get("$kernel.head_dim").asInt(), 128); +} + +TEST(UniversalGraphMatcher, DeclinesWhenKernelMetadataMismatches) +{ + const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + // head_dim != $q.head_size (128) -> criterion false -> decline. + EXPECT_FALSE(m.match(K_DEVICE, g, json{{"head_dim", 64}}).matched); +} + +TEST(UniversalGraphMatcher, EmptyKernelMetadataDeclinesComparison) +{ + // Defensive edge case: production metadata is fully resolved so every + // referenced field is present, but an unbound/empty document must not + // throw -- $kernel.head_dim reads null -> comparison false -> decline. + const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_NO_THROW({ EXPECT_FALSE(m.match(K_DEVICE, g, json::object()).matched); }); +} From 7fb91f3ed3c2060b9acf51489390ab474430541b Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 09:43:20 -0600 Subject: [PATCH 48/60] ci(hipdnn): guard UMD op-schema registry against schema drift The CMake build regenerates op_schema_registry_generated.hpp in-tree from graph.bfbs, but the flatc-hipdnn pre-commit hook only refreshes the --cpp data-object headers, so a committed schema change can leave the registry stale. Add a superbuild CI step that fails if the post-build tree is dirty at that header, and document the split of responsibility in the hook config and run_flatc.py. (cherry picked from commit 8b16f55be725756958832a7bd32a26b8baf619af) (cherry picked from commit 1d6c72f995f95e6cbecf05526288baf9eb3ce0e4) --- .github/workflows/hipdnn-superbuild-ci.yml | 14 ++++++++++++++ .pre-commit-config.yaml | 7 +++++++ projects/hipdnn/scripts/run_flatc.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index 9265ce2daedf..161ef5f6e320 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -287,6 +287,20 @@ jobs: - name: Build run: cmake --build build + - name: Verify UMD op-schema registry is in sync + # The build regenerates op_schema_registry_generated.hpp in-tree from the + # .fbs schemas (HIPDNN_GENERATE_SDK_HEADERS defaults ON). The pre-commit + # flatc-hipdnn hook only refreshes the --cpp data-object headers, not this + # registry, so a schema change committed via pre-commit can leave the + # committed registry stale. This step is the authoritative drift guard. + run: | + if ! git diff --exit-code -- \ + projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp; then + echo "::error::UMD op-schema registry is stale: a schema change was committed without regenerating op_schema_registry_generated.hpp." + echo "Rebuild hipDNN with -DHIPDNN_GENERATE_SDK_HEADERS=ON (the default) and commit the regenerated header." + exit 1 + fi + - name: Run tests run: ctest --test-dir build --output-on-failure diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 180cd7293480..876c45249d66 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,6 +95,13 @@ repos: - repo: local hooks: + # Regenerates only the --cpp data-object headers (data_objects/*_generated.h) + # from the edited .fbs schemas. The UMD op-schema registry + # (umd/op_schema_registry_generated.hpp) is NOT refreshed here: it is derived + # from a C++ tool (umd_registry_gen) that must be compiled, which the + # pre-commit environment cannot do. That header is regenerated by the CMake + # build (HIPDNN_GENERATE_SDK_HEADERS, default ON) and enforced in CI by the + # "Verify UMD op-schema registry is in sync" step in hipdnn-superbuild-ci.yml. - id: flatc-hipdnn name: flatc (hipDNN) entry: python projects/hipdnn/scripts/run_flatc.py diff --git a/projects/hipdnn/scripts/run_flatc.py b/projects/hipdnn/scripts/run_flatc.py index e65b51b412c6..2fd06544c8e1 100644 --- a/projects/hipdnn/scripts/run_flatc.py +++ b/projects/hipdnn/scripts/run_flatc.py @@ -1,6 +1,20 @@ # Copyright © Advanced Micro Devices, Inc., or its affiliates. # SPDX-License-Identifier: MIT +"""Regenerate hipDNN FlatBuffers C++ headers from edited .fbs schemas. + +Runs `flatc --cpp` for each changed schema, writing the data-object headers +(data_objects/*_generated.h) into the committed include tree. This is the +pre-commit entry point (hook `flatc-hipdnn`). + +Scope limit: this script does NOT regenerate the UMD op-schema registry +(umd/op_schema_registry_generated.hpp). That header is derived from a compiled +C++ tool (umd_registry_gen) via the FlatBuffers reflection schema (graph.bfbs), +which the pre-commit environment cannot build. It is regenerated by the CMake +build (HIPDNN_GENERATE_SDK_HEADERS, default ON) and enforced in CI by the +"Verify UMD op-schema registry is in sync" step in hipdnn-superbuild-ci.yml. +""" + import sys import subprocess import shutil From a65762df7f00b3eaaffab0f632c8ba7e69c4ecc8 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 09:45:18 -0600 Subject: [PATCH 49/60] docs(hipdnn): move UMD PoC plan under projects/hipdnn/docs/rfcs The plan was committed at the monorepo root, a tree shared with every other ROCm library. Move it next to the RFC it implements. Nothing links to it. (cherry picked from commit f96449113dc3d5821924fd8135980844cb6a84bd) (cherry picked from commit 3e9f7724d5845add8bc998fd6821c61b8e70937e) --- .../hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename 0018_UMD_PoC_Plan.md => projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md (100%) diff --git a/0018_UMD_PoC_Plan.md b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md similarity index 100% rename from 0018_UMD_PoC_Plan.md rename to projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md From dcf0fac26cc8613cf38d8dda0a665cf9c668089a Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 10:32:45 -0600 Subject: [PATCH 50/60] docs(rfc-0018): align UMD spec with RFC 0017 review updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0017 grew from 1318 to 2701 lines and renumbered every section from §8 onward, breaking 13 cross-references and tightening several rules the UMD spec restates. Cross-references: retarget all 13 broken anchors (old §8.3 -> §9.3, §9 -> §10, §10 -> §12, §12.x -> §14.x, §14 -> §16, §15 -> §17) and fix a pre-existing label/anchor mismatch that pointed "§9" at the packaging section. Contract changes: - A UMD carries `version` and `sdk_version`, gated against a runtime floor and compared numerically by (major, minor). - Add the `present` / `not_present` operators, which always evaluate rather than declining on an absent reference, and use the list form to decline unsupported optional operands in one call. - Widen `value_or_default` so the fallback may be any type-compatible expression, including a second field reference. - Add `$q.is_runtime_pass_by_value`, `$q.value_f32`, and `$graph.is_override_shape_enabled`, and describe precomputed fields as a distinct mechanism between the built-ins and the escape hatch. - State the exactness-to-kernel rule (every baked quantity is a KMD field pinned by a `$kernel.*` criterion) and the umbrella-matcher rule, both marked as pack-level loader checks rather than per-descriptor ones. - Defer matching order and caching to RFC 0017 §8: matching runs during applicability against a provider-owned cache keyed on engine, graph, and device, producing a catalog and bound token state. Cite the base-path invariant as a constraint on match semantics. - Narrow day-one fusion matching to a pattern that is the entire graph. - Memoize `$kernel.*` matchers on the fields they read, not on the whole metadata tuple. Layout aliases now document the `extractStrideOrder` encoding the codebase computes (`nchw` -> [3,2,1,0]) instead of the inverse position-to-dim convention, which no implementation used. Retarget observability at §10 and the tooling claim at the new §11, and record the runtime opt-out env vars and their shared-matcher risk. Refresh all three diagrams: JsonLogic syntax instead of the pre-JsonLogic key/value form, per-tensor dim names, `$sdpa_fwd.*` instead of `$root.*`, lowercase operand keys, and the applicability-time cache. (cherry picked from commit 7f061b22a21a8f2045de98ede11a4b13f6f677a3) (cherry picked from commit 4a47215ee32b8ae0547dd596080dc6c89e971d1c) --- .../hipdnn/docs/images/umd_binding_model.svg | 20 +- .../docs/images/umd_matcher_pipeline.svg | 18 +- .../hipdnn/docs/images/umd_symbol_binding.svg | 38 +-- .../hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md | 19 +- .../rfcs/0018_UniversalMatchDescriptor.md | 320 +++++++++++++----- 5 files changed, 283 insertions(+), 132 deletions(-) diff --git a/projects/hipdnn/docs/images/umd_binding_model.svg b/projects/hipdnn/docs/images/umd_binding_model.svg index 723ab21b05ae..d6a37213c6a5 100644 --- a/projects/hipdnn/docs/images/umd_binding_model.svg +++ b/projects/hipdnn/docs/images/umd_binding_model.svg @@ -33,11 +33,11 @@ sdpa_fwd schema: operands (required): - Q -> q_tensor_uid - K -> k_tensor_uid - V -> v_tensor_uid + q -> q_tensor_uid + k -> k_tensor_uid + v -> v_tensor_uid results: - O -> o_tensor_uid + o -> o_tensor_uid operands (optional): attn_mask, page_table_* attributes: @@ -52,15 +52,15 @@ $q -> tensor(uid 7) $k $v $o ... - $q0..$q3 (dims) + $q.dims[0..3] $q.strides[0..3] $q.rank $q.dtype - stride_order($q) - $batch, $num_heads, - $seqlen_q, $head_size + $q.stride_order $q.packed + $q.batch, $q.num_heads, + $q.seqlen_q, $q.head_size (named shape dims) - $root.causal_mask - $root.dropout_probability + $sdpa_fwd.causal_mask + $sdpa_fwd.dropout_probability consumed by the UDD's launch + workspace formulas diff --git a/projects/hipdnn/docs/images/umd_matcher_pipeline.svg b/projects/hipdnn/docs/images/umd_matcher_pipeline.svg index a02c8678718c..5a0f695b0680 100644 --- a/projects/hipdnn/docs/images/umd_matcher_pipeline.svg +++ b/projects/hipdnn/docs/images/umd_matcher_pipeline.svg @@ -19,7 +19,7 @@ - resolve roles + resolve names expand layouts parse exprs, check @@ -61,19 +61,19 @@ - plan + provider cache - Per-plan bind cache - chosen UMD - bound symbol table - arbitration outcome - reused by isApplicable / - workspace / buildPlan + Applicability-time cache + catalog (kernels that passed) + bound token state + key: engine, graph, device + written by isApplicable; read by + getDetails / workspace / buildPlan - Root-opcode indexing keeps match cost sublinear in descriptor count; the per-plan cache matches each graph once. + Root-opcode indexing keeps match cost sublinear in descriptor count; the provider-owned cache matches each graph once (RFC 0017 §8). diff --git a/projects/hipdnn/docs/images/umd_symbol_binding.svg b/projects/hipdnn/docs/images/umd_symbol_binding.svg index d1bb77a78643..6d36347fe248 100644 --- a/projects/hipdnn/docs/images/umd_symbol_binding.svg +++ b/projects/hipdnn/docs/images/umd_symbol_binding.svg @@ -33,17 +33,17 @@ op: sdpa_fwd - operands {Q:$q, K:$k, V:$v} - results {O:$o} - $q.dtype one_of {BF16, FP8} - $k.dtype same_as $q - $q.shape [b, h, sq, $head_size] - $k.shape [b, kvh, sk, $head_size] - expr: $head_size == 128 - layout: [0, 1, 2, 3] - $attn_mask present: false - native: strides_fit_u32(...) - native: sdpa_mask_consistent + operands {q:$q, k:$k, v:$v} + results {o:$o} + {"in": ["$q.dtype", ["BFLOAT16","FP8_E4M3"]]} + {"==": ["$k.dtype", "$q.dtype"]} + {"shape": ["$q", [b, h, sq, head_size]]} + {"shape": ["$k", [b, kvh, sk, head_size]]} + {"==": ["$q.head_size", 128]} + {"==": ["$q.stride_order", [3,2,1,0]]} + {"not_present": ["$attn_mask"]} + {"hipdnn.strides_fit_u32": [...]} + {"hipdnn.sdpa_mask_consistent": [...]} bind @@ -51,16 +51,16 @@ $q $k $v $o - $q0..$q3 (dims) + $q.dims[0..3] $q.strides[0..3] - $batch, $num_heads - $kv_heads ($k1==$v1) - $seqlen_q, $seqlen_k - $head_size - $root.causal_mask - $root.dropout_.. + $q.batch, $q.num_heads + $k.kv_heads, $v.kv_heads + $q.seqlen_q, $k.seqlen_k + $q.head_size, $k.head_size + $sdpa_fwd.causal_mask + $sdpa_fwd.dropout_.. used by the UDD's grid / arg formulas - The match auto-binds every tensor, dim, stride, and attribute; shared names unify dims ($kv_heads = $k1 = $v1). + The match auto-binds every tensor, dim, stride, and attribute; dim names are per-tensor, so a cross-tensor relation is an explicit criterion. diff --git a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md index 09ce4addc7ec..63c948ac3e87 100644 --- a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md +++ b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md @@ -207,14 +207,27 @@ nlohmann_json + plan_utils; add dep on the generated registry). exercised opcode. - **`shape` lowered in the UMD compiler**, not JsonLogic — keeps the shared language pure; matches RFC §10 "compile expands short-hands." -- **No JsonLogic core change.** The custom-operation (native-predicate) hook is skipped for this +- **JsonLogic core changes are limited to the presence predicates.** `present` / `not_present` are + added because RFC 0017 §5 lists them in the closed operator vocabulary and they are the only way to + write "absent, or present and constrained" — a bare field read on an absent operand declines rather + than answering. The custom-operation (native-predicate) hook is still skipped for this PoC; the two SDPA gates that need it (uint32 stride fit, mask self-consistency) are held constant - in the test battery rather than expressed as criteria. `all` / `rank`-op / `divisible` are also + in the test battery rather than expressed as criteria. `all` / `rank`-op / `divisible` are unused by SDPA fwd — deferred (add if broader §A.7 coverage is wanted). - **Not plumbed as a provider engine** — matcher is exercised directly by tests. - **Deferred (RFC-acknowledged):** custom-operation (native-predicate) hook and predicate registry (§8), static/bytecode matcher (§11), fuzzing (§14), arbitration across multiple UKDs (§12), UDD - dispatch formulas, per-plan match cache, multi-node fusion patterns. + dispatch formulas, multi-node fusion patterns, and the whole applicability-time catalog and cache + (RFC 0017 §8) — the PoC matcher answers one UMD against one graph and holds no catalog. +- **Deferred pack-level checks.** Two rules RFC 0018 §5 states are enforced by a KDP/KMD loader, not + by a single descriptor's compile: that every quantity a kernel bakes is a KMD field pinned by a + `$kernel.*` criterion, and that every pack carries an umbrella matcher checking the full graph + topology. Neither a KDP nor a KMD loader exists in this tree, so both are out of scope. The + compiler does its half: it publishes the `$kernel.*` fields a matcher reads, which is what such a + loader needs. +- **Version floors are structural only.** `version` and `sdk_version` parse and gate against a + compiled-in floor, but nothing yet publishes a real graph-schema version to compare against, so + the floor is a constant rather than a negotiated value. - **Two non-declarative gates out of the UMD.** `strides_fit_u32` and `sdpa_mask_consistent` (RFC §8 custom operations) are excluded with the hook; match-equivalence covers the declarative subset with those two gates held constant, exactly as arch/kernel-table are. diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 3e2d92982e60..734073f626b6 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -3,7 +3,7 @@ - Contributors: Brian Harrison > Follow-up to [RFC 0017 (Universal Kernel Descriptors)](0017_UniversalKernelDescriptor.md), -> the "UMD + graph matcher" row of its follow-up series ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)). +> the "UMD + graph matcher" row of its follow-up series ([RFC 0017 §14.2](0017_UniversalKernelDescriptor.md#142-follow-up-rfcs)). > This RFC designs the match format and the matcher; the sibling formats (UDD, UED, UHD, KDP) and > subsystems (packaging, drop-in, adapters) are designed in their own follow-ups and are referenced, > not redesigned, here. The RFC number is provisional and is reconciled against the concurrent @@ -68,16 +68,16 @@ deterministic arbitration, and the compile-once matcher with its indexing and ca | Capability | This RFC (day-one) | Deferred | |---|---|---| -| Structural pattern: op nodes, named operand/result edges, single-op and bounded fused subgraphs | Yes ([§3](#3-structural-pattern)) | None | +| Structural pattern: op nodes, named operand/result edges, single-op and bounded fused subgraphs that are the entire graph | Yes ([§3](#3-structural-pattern)) | Matching a fused pattern *inside* a larger graph: JIT follow-up | | Auto-binding of every operand/result tensor, its dims and strides, and every op attribute | Yes ([§4](#4-symbol-binding-and-the-auto-binding-formula)) | None | | Criteria as one JsonLogic expression: opcode, dtype (exact/set/relation), shape/rank, divisibility, stride order, packed, attribute, graph-structure (`node_count`, `virtual`), cross-tensor relation, optional operand, device property | Yes ([§5](#5-constraint-vocabulary)) | None | | JsonLogic as the shared expression language (UMD boolean + UDD value), `$`-variable convention | Yes ([§6](#6-the-shared-expression-language)) | New operators as needed | | Layout as a stride-order index array, with named aliases | Yes ([§7](#7-layout-and-stride-order-constraints)) | None | | Custom-operation (native-predicate) escape hatch, registry-resolved | Yes ([§8](#8-native-predicate-escape-hatch)) | None | | Composite criteria: `(A AND B) OR C` as one criteria expression, via JsonLogic `and`/`or`/`!`/`if` | Yes ([§9](#9-composite-constraints)) | None | -| Compile-once matcher, root-opcode index, per-plan match cache | Yes ([§10](#10-the-matcher-compilation-indexing-and-caching)) | None | +| Compile-once matcher, root-opcode index, applicability-time match cache | Yes ([§10](#10-the-matcher-compilation-indexing-and-caching)) | None | | Static (compile-time / AOT-lowered) matcher | Options sketched ([§11](#11-static-matcher-sketch)) | Full design | -| General N-ary commutative matching, unbounded variable-length chains | None | JIT follow-up ([RFC 0017 §8.3](0017_UniversalKernelDescriptor.md#83-future-jit-and-normalized-providers)) | +| General N-ary commutative matching, unbounded variable-length chains | None | JIT follow-up ([RFC 0017 §9.3](0017_UniversalKernelDescriptor.md#93-future-jit-and-normalized-providers)) | --- @@ -182,13 +182,17 @@ variables (`$q`, `$conv_out`). with `{"==": ["$graph.node_count", N]}` ([§5](#5-constraint-vocabulary)): a single-op kernel checks `node_count == 1`, replacing the `nodeWrappers().size() != 1` gate; a fused three-op kernel checks `node_count == 3`. Matching a pattern inside a larger graph without a fixed count is the looser JIT - mode ([RFC 0017 §8.3](0017_UniversalKernelDescriptor.md#83-future-jit-and-normalized-providers)). + mode ([RFC 0017 §9.3](0017_UniversalKernelDescriptor.md#93-future-jit-and-normalized-providers)). - **Override-shape graphs** are declined by default (mirroring today's gate); a UMD that supports them sets `"allow_override_shape": true` at the top level. -**Fusion is day-one.** A multi-node pattern binds the whole fused subgraph at once and hands it to one -UKD. Legality — that the fused intermediates are absorbed into the kernel, not consumed outside it — is -expressed by requiring each intermediate `virtual` and pinning the op count with `$graph.node_count`: +**Fusion matching is day-one, for a pattern that is the whole graph.** A multi-node pattern binds the +whole fused subgraph at once and hands it to one UKD. Legality — that the fused intermediates are +absorbed into the kernel, not consumed outside it — is expressed by requiring each intermediate +`virtual` and pinning the op count with `$graph.node_count`, which also confines a day-one fused +pattern to the entire graph. Matching a fused pattern *inside* a larger graph, with no fixed count, +is the JIT follow-up's looser mode. Fusion is distinct from composition — running one graph as +several kernels — which goes the opposite direction and is not in scope here: ```jsonc { @@ -204,8 +208,8 @@ expressed by requiring each intermediate `virtual` and pinning the op count with "operands": {"a": "$bias_out"}, "results": {"y": "$y"}} ], "criteria": {"and": [ - {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [0, 2, 3, 1]]}, "$x.packed", // NHWC - {"==": ["$y.dtype", "FLOAT16"]}, {"==": ["$y.stride_order", [0, 2, 3, 1]]}, "$y.packed", // NHWC + {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [3, 0, 2, 1]]}, "$x.packed", // NHWC + {"==": ["$y.dtype", "FLOAT16"]}, {"==": ["$y.stride_order", [3, 0, 2, 1]]}, "$y.packed", // NHWC {"shape": ["$y", ["batch", "out_h", "out_w", "out_channels"]]}, {"shape": ["$bias", ["out_channels"]]}, {"==": ["$graph.node_count", 3]}, // exactly these three ops @@ -240,21 +244,43 @@ declares them so the interpreter fails closed on anything undeclared: `TensorAttributes`) and `$q.uid` its graph UID; each dim positionally as `$q.dims[i]` and by name as `$q.` once a `shape` names it (`$q.seqlen_q`); each stride as `$q.strides[i]`; and the derived facts `$q.rank`, `$q.dtype`, `$q.stride_order` - ([§7](#7-layout-and-stride-order-constraints)), `$q.packed`, and `$q.virtual` (an internal - intermediate between matched nodes, not a graph input or output). An optional operand also carries - `$q.present`, true only when the graph supplies it. -- **Graph** — structural facts of the matched graph, chiefly `$graph.node_count`, which pins an exact - match ([§3](#3-structural-pattern)). + ([§7](#7-layout-and-stride-order-constraints)), `$q.packed`, `$q.virtual` (an internal + intermediate between matched nodes, not a graph input or output), + `$q.is_runtime_pass_by_value` (its value arrives per execution rather than being baked into the + graph, [RFC 0016](0016_RuntimePassByValueTensors.md)), and the precomputed scalar `$q.value_f32` + (below). An optional operand also carries `$q.present`, true only when the graph supplies it. +- **Graph** — structural facts and graph-level flags of the matched graph: `$graph.node_count`, which + pins an exact match ([§3](#3-structural-pattern)), and `$graph.is_override_shape_enabled`, the + graph's own opt-in to execute-time override shapes. That flag is the graph's state and is distinct + from the descriptor's `allow_override_shape`, which is this matcher's opt-in to accepting such a + graph at all. - **Attributes** — a matched node's scalar attributes, named by the node's pattern `id`: an `{"id": "sdpa_fwd"}` node exposes `$sdpa_fwd.dropout_probability`, a `{"id": "conv"}` node `$conv.dilation`. An optional attribute carries `$sdpa_fwd..present`. - **Kernel metadata** — `$kernel.`, the values a UKD supplies for the fields its KMD declares (tile and vector constants, the dtype it targets, [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats)); a matcher that reads them is evaluated per kernel ([§10](#10-the-matcher-compilation-indexing-and-caching)). + A `$kernel.*` field a matcher reads MUST exist in the engine's KMD; the loader checks this, so the + matcher publishes the set of `$kernel.*` fields it reads alongside its bound-symbol set. - **Device properties** — `$device.` such as `$device.lds_size` or `$device.warp_size`, for a - check like an LDS budget. Architecture is **not** here for AOT: it is a pack property gated at + check like an LDS budget. The device facts hipDNN carries today are narrower than this vocabulary + needs, so the device-property set is extended additively as the checks that need it land. + Architecture is **not** here for AOT: it is a pack property gated at selection ([§2](#2-the-matchers-input-hipdnns-graph-model)); a JIT pack may reference `$device.arch`. +**Precomputed fields.** Some tokens above are not stored on the graph: the schema layer derives them +once and publishes them as ordinary fields, so a matcher compares a value instead of re-deriving it. +`$q.packed` and `$q.stride_order` are the layout examples, standing in for `inferLayout`'s +contiguous-stride arithmetic. `$q.value_f32` is the other kind: a tensor's compile-time `value` is a +tagged union over eight differently-typed arms, and the expression language has no discriminator +syntax to unwrap one, so the schema layer coerces whichever arm is set to `f32` once and publishes it +as a single typed token — present only when the tensor carries a compile-time value at all, so a +criterion over it declines on a tensor that does not. A precomputed field is declared in the hipDNN +schema like any other field and versioned with it, so adding one is an additive schema change rather +than a per-pack extension point. Precomputed fields sit between the built-in operators and the +native-predicate escape hatch ([§8](#8-native-predicate-escape-hatch)): reach for one when a check +needs a derived fact, and for the hatch only when it needs real C++. + **A `$` marks a reference.** Every reference to a bound field carries a leading `$`: tensors and their fields (`$q`, `$q.uid`, `$q.dims[2]`, `$q.rank`, `$q.seqlen_q`), a node's attributes (`$sdpa_fwd.head_size` — the node id `sdpa_fwd` is bare, the reference carries the `$`), `$graph.node_count`, `$kernel.tile_m`, @@ -297,12 +323,12 @@ same expression as any built-in operator. | **Rank** | `{"==": ["$q.rank", 4]}`, or a `shape` short-hand that names four dims | `validateDimensionCount`, rank == 4 | | **Shape (bind / relate)** | `{"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}` binds `$q.`; relate with `{"==": ["$q.head_size", "$k.head_size"]}` | dim reads and cross-tensor dim relations | | **Divisibility** | `{"divisible": [{"*": ["$y.n", "$y.ho", "$y.wo"]}, "$kernel.MPerBlock"]}` | tile-fit / GEMM-dim gates | -| **Layout** | `{"==": ["$q.stride_order", [0, 1, 2, 3]]}` ([§7](#7-layout-and-stride-order-constraints)) | `validateSupportedLayout` | +| **Layout** | `{"==": ["$q.stride_order", [3, 2, 1, 0]]}` ([§7](#7-layout-and-stride-order-constraints)) | `validateSupportedLayout` | | **Packing** | `"$q.packed"` (a bound boolean) | `validatePackedTensors` | | **Cross-tensor layout** | `{"==": ["$x.stride_order", "$y.stride_order"]}` (per pair) | `validateConsistentLayouts` | | **Attribute (value)** | `{"==": ["$sdpa_fwd.causal_mask", false]}`; absent-or `{"or": [{"!": "$sdpa_fwd.dropout_probability.present"}, {"==": ["$sdpa_fwd.dropout_probability", 0.0]}]}` | per-attr value gates | | **Attribute (one_of)** | `{"in": ["$sdpa_fwd.head_dim", [64, 128, 192]]}` | `head_dim in {...}` | -| **Optional operand present/absent** | operand `?` in the pattern; `{"!": "$attn_mask.present"}` (absent) / `"$bias.present"` (present) | `attn_mask_tensor_uid()` absent gate | +| **Optional operand present/absent** | operand `?` in the pattern; `{"not_present": ["$attn_mask"]}` (absent) / `{"present": ["$bias"]}` (present); one call takes a list, so a pack declines every optional operand it cannot serve at once | `attn_mask_tensor_uid()` absent gate | | **Graph structure (exact / fusion)** | `{"==": ["$graph.node_count", 3]}`, and each intermediate `"$conv_out.virtual"` | node-count gate, fusion legality | | **Cross-tensor / arithmetic** | `{"==": ["$q.dims[1]", "$k.dims[2]"]}`, `{"<": ["$q.head_size", 129]}`, `{"==": [{"%": ["$q.num_heads", "$k.kv_heads"]}, 0]}` | arithmetic and comparison gates | | **Device property** | `{"<=": ["$kernel.lds_per_block", "$device.lds_size"]}` (arch is a pack property, not a criterion) | LDS/occupancy budgets; `getDeviceString` arch → pack `arch` | @@ -310,9 +336,37 @@ same expression as any built-in operator. Architecture gates *applicability* at pack selection via the KDP `arch` property and the per-arch `kpack` manifest ([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd), -[§9](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)), not as a match-time criterion for +[§12](0017_UniversalKernelDescriptor.md#12-packaging-and-delivery)), not as a match-time criterion for AOT; other device properties (`$device.lds_size`, `$device.warp_size`) are read directly in criteria. +**Exactness runs to the kernel, not just the graph.** `$graph.node_count` and the pattern's topology +pin the shape of the *graph*, but say nothing about whether a given candidate kernel can serve it. A +prebuilt kernel bakes quantities into its binary — a dtype, a head size, sometimes a sequence length — +and a graph that clears a pack's graph-level gates may still disagree with what one kernel baked. +**Every quantity a kernel bakes MUST therefore be a KMD field, and the pack's matcher MUST pin it +against the graph with a `$kernel.*` criterion.** Those are the clauses re-evaluated per candidate +([§10](#10-the-matcher-compilation-indexing-and-caching)), which is what turns one matcher plus a +kernel vector into a per-kernel applicability test. + +Getting this wrong fails silently rather than loudly: a matcher gating dtype only as +`{"in": ["$q.dtype", ["FLOAT16", "BFLOAT16"]]}` accepts an fp16 graph and may hand it to a bf16 +binary, which returns wrong numbers instead of an error. A field missing from the KMD also cannot be +pinned, so two kernels differing only in an unmodelled baked constant collide on the catalog key. The +check is mechanical, so the loader performs it: a UKD whose source declares a baked constant with no +corresponding KMD field is a load error. That check is a KDP/KMD-loader responsibility and is +specified by [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd); the UMD's part +is to publish the `$kernel.*` fields it reads ([§4](#4-symbol-binding-and-the-auto-binding-formula)) +so the loader can perform it. + +**Every KDP needs one umbrella matcher.** At least one matcher in a pack MUST check the complete graph +topology the pack accepts — the same `node_count` and shape-defining criteria, applied to the whole +graph rather than a fragment. Other matchers in the pack may constrain any subset of what that matcher +binds. Without the rule, several matchers could each verify disjoint pieces of a graph while nothing +ever confirms the overall topology, producing loose or incorrect matches. Authors may write one large +matcher or split the work across focused ones; the only requirement is that the full graph shape is +checked explicitly somewhere in the pack. This is a **pack-level** rule: a single UMD cannot enforce +it, so it is validated by the KDP loader, not by A.10's per-descriptor checks. + --- ## 6. The Shared Expression Language @@ -320,8 +374,9 @@ AOT; other device properties (`$device.lds_size`, `$device.warp_size`) are read Every UMD criterion and every UDD dispatch formula is a **JsonLogic** expression: a nested `{"op": [args]}` tree whose arguments are themselves expressions or literals (the criteria-expression form of [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)). Descriptors stay pure -data, and one parser, validator, and interpreter serve both subsystems. This RFC pins JsonLogic as the -concrete form RFC 0017 §5/§6 left to the follow-ups; the UDD follow-up adopts the same core. +data, and one parser, validator, and interpreter serve both subsystems. RFC 0017 §5 fixes the operator +vocabulary and the `$`-reference leaf convention; this RFC pins the concrete dialect, grammar, and +static typing those imply, and the UDD follow-up adopts the same core. **The `$`-variable convention.** Stock JsonLogic reads a bound value with `{"var": "path"}`. This RFC replaces that with a single rule: **any string that begins with `$` is a variable reference** into the @@ -340,14 +395,20 @@ it names a boolean field, e.g. `"$q.packed"`. ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)), yielding a number. **Operators.** Logical `and`, `or`, `!`; comparison `==`, `!=`, `<`, `<=`, `>`, `>=`; membership `in`; -the per-element `all`; arithmetic `+`, `-`, `*`, `/`, `%`; the pattern/short-hand ops `shape`, `rank`, -`divisible`, and `value_or_default` (reads a possibly-absent optional operand with a default); the -conditional `if`; and the value-core arithmetic the UDD needs (`ceil_div`, `min`, `max`, `abs`, `pow`, -`log2`, `rsqrt`). Anything the built-ins cannot express is a **custom operation** +the per-element `all`; arithmetic `+`, `-`, `*`, `/`, `%`; the presence predicates `present` and +`not_present`; the pattern/short-hand ops `shape`, `rank`, `divisible`, and `value_or_default` (reads +a possibly-absent optional operand with a fallback); the conditional `if`; and the value-core +arithmetic the UDD needs (`ceil_div`, `min`, `max`, `abs`, `pow`, `log2`, `rsqrt`). The full table +with arities and types is [A.7](#a7-operator-reference); naming an operator outside it fails load +validation. Anything the built-ins cannot express is a **custom operation** ([§8](#8-native-predicate-escape-hatch)). Adding an operator is additive ([§13](#13-serialization-and-versioning)); it never introduces a new criterion *kind*, since the whole `criteria` field is one JsonLogic expression. +**Operators nest, and either side of a comparison may be computed.** A leaf is a literal or a +`$`-prefixed reference, but either operand of a comparison can itself be an expression, so a check +like `{"divisible": [{"*": ["$y.n", "$y.ho", "$y.wo"]}, "$kernel.MPerBlock"]}` needs no temporary. + ```jsonc // criteria (boolean): sub-expressions of the single top-level criteria expression {"==": ["$q.head_size", 64]} // dim equality (named dim) @@ -355,9 +416,9 @@ conditional `if`; and the value-core arithmetic the UDD needs (`ceil_div`, `min` {"<=": ["$q.head_size", 128]} // range bound {"in": ["$q.head_size", [64, 128, 256]]} // set membership {"divisible": ["$q.num_heads", "$k.kv_heads"]} // GQA divisibility -{"==": ["$q.stride_order", [0, 1, 2, 3]]}, "$q.packed" // layout + packed +{"==": ["$q.stride_order", [3, 2, 1, 0]]}, "$q.packed" // layout + packed {"==": ["$graph.node_count", 1]} // exact whole-graph match -{"or": [{"!": "$attn_mask.present"}, +{"or": [{"not_present": ["$attn_mask"]}, {"==": ["$attn_mask.dtype", "$q.dtype"]}]} // composition (§10) // dispatch formulas (value), reused by the UDD @@ -378,25 +439,25 @@ enough to audit and to lower into the static matcher ([§11](#11-static-matcher- hipDNN tensors store no layout enum; layout is implied by stride order ([§2](#2-the-matchers-input-hipdnns-graph-model)). The UMD therefore represents layout as an **array of -dimension indexes giving the stride order**, from the slowest-varying dimension to the -fastest-varying. This is the shape a matcher can check directly against `strides()` and matches how -`TensorDescriptor` already precomputes `strideOrder` (`ApplicabilityChecks.cpp:17`). +per-dimension stride priorities**: entry `i` ranks logical dimension `i` by stride magnitude, with a +higher number meaning a slower-varying (larger-stride) dimension. This is the encoding +`extractStrideOrder` already computes (`ApplicabilityChecks.cpp:17`), so a matcher compares +`$q.stride_order` directly against the same arrays the `validateSupportedLayout` oracle uses today +rather than against a re-derived permutation. ```jsonc -{"==": ["$q.stride_order", [0, 1, 2, 3]]} // natural order (BHSD, rank-4) -{"==": ["$x.stride_order", [0, 2, 3, 1]]} // NHWC over an NCHW logical dim order +{"==": ["$q.stride_order", [3, 2, 1, 0]]} // descending-stride packed (BHSD, rank-4) +{"==": ["$x.stride_order", [3, 0, 2, 1]]} // NHWC over an NCHW logical dim order ``` -- The array is a permutation of `0..rank-1`. Entry `k` names the logical dimension that occupies stride - position `k`, so `[0,1,2,3]` is descending-stride packed and `[0,2,3,1]` places the channel dim last +- The array is a permutation of `0..rank-1`. Entry `i` is the stride rank of logical dimension `i`, + so `[3,2,1,0]` is descending-stride packed and `[3,0,2,1]` places the channel dim fastest-varying (NHWC). The `axis` used everywhere else (dims, strides, `args_signature`) indexes the logical dimension order, independent of this physical layout, consistent with RFC 0017 §6. - **Named aliases** are provided for the common cases and expand to the array literal at compile time, - so `{"==": ["$x.stride_order", "nhwc"]}` compiles to a comparison against `[0, 2, 3, 1]`: - `"nchw" -> [0,1,2,3]`, `"nhwc" -> [0,2,3,1]`, `"ncdhw"`, `"ndhwc"`, `"bhsd" -> [0,1,2,3]`, - `"contiguous"` (identity permutation for the tensor's rank). The array remains the single canonical - form. The alias set matches the layouts `validateSupportedLayout` accepts today - (`ApplicabilityChecks.cpp:77`). + so `{"==": ["$x.stride_order", "nhwc"]}` compiles to a comparison against `[3, 0, 2, 1]` + (A.8). The array remains the single canonical form. The alias set matches the layouts + `validateSupportedLayout` accepts today (`ApplicabilityChecks.cpp:77`). - **Cross-tensor consistency** is a JsonLogic equality between stride orders, `{"==": ["$x.stride_order", "$y.stride_order"]}` (one per pair, joined by the top-level `and`), lowering `validateConsistentLayouts`; layout-agnostic tensors (rank-1 scalars, pass-by-value) are @@ -404,7 +465,7 @@ fastest-varying. This is the shape a matcher can check directly against `strides - **Packing** is the separate bound boolean `$q.packed` (written `"$q.packed"`), since a supported stride order does not imply the tensor is gap-free; it lowers `validatePackedTensors`. - `$q.stride_order` is an ordinary bound value ([§4](#4-symbol-binding-and-the-auto-binding-formula)), - so a `stride_order == [0,1,2,3]` gate is expressible directly. + so a `stride_order == [3,2,1,0]` gate is expressible directly. --- @@ -481,14 +542,15 @@ chains remain deferred to the JIT follow-up, as in RFC 0017 §5. ## 10. The Matcher: Compilation, Indexing, and Caching -A UMD is authored as text and **compiled once** into an in-memory matcher structure at provider load -(or, for the drop-in path, when the bundle is scanned). Compilation resolves op-schema names, expands -layout aliases, parses the criteria expression to an AST, and validates that every referenced symbol -is bound. The compiled form, not the text, is what runs against live graphs. +A UMD is authored as text and **compiled once** into an in-memory matcher structure, on demand: nothing +is parsed until a graph needs it, and the parsed result is cached and reused +([RFC 0017 §3](0017_UniversalKernelDescriptor.md#3-how-it-works)). Compilation resolves op-schema +names, expands layout aliases, parses the criteria expression to an AST, and validates that every +referenced symbol is bound. The compiled form, not the text, is what runs against live graphs. **Root-opcode indexing.** The compiled matchers are indexed by the root node's opcode, so match cost does not grow linearly with the number of descriptors: a graph whose root op is `sdpa_fwd` only -consults UMDs rooted at `sdpa_fwd`. This is the index RFC 0017 §14 calls for. Per-candidate cost (one +consults UMDs rooted at `sdpa_fwd`. This is the index RFC 0017 §16 calls for. Per-candidate cost (one pass over the criteria expression) is separate from the index and bounded by short-circuit evaluation. **Shared matchers, evaluated once.** A KDP lists a set of matcher IDs and a kernel applies only when @@ -497,8 +559,12 @@ unit of sharing and of evaluation. A matcher that reads only graph fields (Tenso Device, [§4](#4-symbol-binding-and-the-auto-binding-formula)) runs **once per graph**; on failure it prunes every pack that lists it, so the most-shared checks (dtype, layout, shape) evaluated first shrink the candidate set fast. A matcher that also reads `$kernel.*` is the **same** matcher re-evaluated -**once per distinct metadata** (memoized), pruning per kernel rather than per pack. Results are cached -across queries. +**once per distinct value of the `$kernel.*` fields it reads**, memoized on those, pruning per kernel +rather than per pack. The projection is what makes this pay: a kernel's full metadata tuple is unique +by construction, so memoizing on the whole tuple would save nothing, while a matcher reading one field +collapses an engine's catalog to that field's handful of distinct values. The compiler already +computes which `$kernel.*` fields a matcher reads ([§4](#4-symbol-binding-and-the-auto-binding-formula)), +so the memoization key costs nothing extra. Results are cached across queries. **Short-circuit evaluation.** The criteria expression evaluates with normal JsonLogic short-circuiting: an `and` stops at its first false sub-expression, an `or` at its first true one, so a @@ -507,19 +573,30 @@ compiler may hoist a cheap, highly selective sub-expression (a scalar attribute an expensive one (a native predicate) as an internal optimization, but this never changes the result, only when a decision is reached. -**Per-plan caching (AICK-1698).** Matching runs at plan-build time. The result (the chosen UMD, the -bound symbol table, and the arbitration outcome) is cached on the compiled plan and reused for -workspace queries and execution, so the same graph is not re-matched across the -`isApplicable` / `getMaxWorkspaceSize` / `buildPlan` calls that re-run the loop today -(`AsmSdpaEngine.cpp:66,87`). The compiled matcher itself is built once and shared across plans; only -the per-graph binding result is per-plan. +**Matching runs during applicability, and the provider owns the cache.** The order and the cache are +specified by [RFC 0017 §8](0017_UniversalKernelDescriptor.md#8-end-to-end-flow), which this RFC +follows rather than restates: matching happens inside `IEngine::isApplicable`, and its two products — +the **catalog** (the kernels whose full matcher set passed) and the **bound token state** (every +`$`-prefixed value the matchers resolved) — are cached together on the provider's shared container, +keyed on the engine, graph, and device that describe the problem, plus the descriptor-inventory +generation. Later phases read that cache instead of re-matching, so the +`isApplicable` / `getMaxWorkspaceSize` / `buildPlan` sequence that re-runs the loop today +(`AsmSdpaEngine.cpp:66,87`) matches a graph once. The compiled matcher itself is built once and shared +across every graph; only the binding result is per-problem. + +**Accepting is a promise.** Because the catalog is settled during applicability, a non-empty catalog +commits the engine to producing a launchable kernel: a later failure surfaces as a failed plan build, +not a fallback to another engine. This is RFC 0017 §8.6's base-path invariant, and it is what makes +match semantics load-bearing — a matcher that accepts a graph its kernel cannot serve turns a decline +into a user-visible error rather than a retry. It is the reason every quantity a kernel bakes must be +pinned by a `$kernel.*` criterion ([§5](#5-constraint-vocabulary)). **Device properties are constant per stream.** A `$device.` sub-expression (for example `$device.lds_size`) is evaluated once per graph, since device properties do not vary across a stream. Architecture is not a match-time criterion at all for AOT: it is a pack property gated at selection ([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)). -![Compile-once pipeline: text UMD to constraint IR to a root-opcode-indexed matcher, with a per-plan bind cache](../images/umd_matcher_pipeline.svg) +![Compile-once pipeline: text UMD to constraint IR to a root-opcode-indexed matcher, with an applicability-time bind cache](../images/umd_matcher_pipeline.svg) --- @@ -564,11 +641,13 @@ Today the first applicable plan builder wins, which is a documented latent bug w matches (`HipMlopsEngine.cpp:34`). The UMD makes overlap explicit and resolves it deterministically, reusing the rule from RFC 0017 §5: -1. When several UKDs match a graph, the **UHD** (kernel-selection heuristic) ranks them and the +1. When several UKDs match a graph, the engine's **heuristic** (the UHD) ranks them and the top-scored kernel wins. 2. Ties break by explicit **`priority`** on the UKD. -3. Remaining ties break by the descriptor's stable **`id`**, and the conflict is logged to the warning - log so an unintended overlap is visible. +3. Remaining ties break by the descriptor's stable **`id`**, compared as raw bytes, and the conflict + is logged to the warning log so an unintended overlap is visible. That byte order carries no + meaning; it is chosen for being stable across runs, load orders, and machines, not because a lower + id is better. Arbitration is a property of the generic engine over the set of matching UKDs; a UMD shared by several UKDs contributes each of them as a candidate. This closes the mutual-exclusion-by-construction @@ -583,12 +662,20 @@ hazard. expression of [§6](#6-the-shared-expression-language) with the `$`-variable convention. - **Compiled form.** The compact binary the matcher runs ([§10](#10-the-matcher-compilation-indexing-and-caching)), whose concrete bytes are defined with the KDP/packaging follow-up - ([RFC 0017 §12.2](0017_UniversalKernelDescriptor.md#122-follow-up-rfcs)); the schema those bytes + ([RFC 0017 §14.2](0017_UniversalKernelDescriptor.md#142-follow-up-rfcs)); the schema those bytes encode is specified in [Appendix A](#appendix-a-schema-reference). - **Schema and version.** Every UMD carries `schema: "hipdnn.umd/v1"`, a stable `id` (a UUID), and a mandatory `name` for diagnostics. A UMD whose schema version is newer than the runtime understands is refused with a clear error, never silently reinterpreted, matching [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats). +- **A matcher carries two versions.** `version` is the matcher format itself. `sdk_version` is the + hipDNN graph schema the matcher was authored against, and a matcher is the only descriptor that + needs it: it is the only one that reads graph fields, so it is the only one a graph-schema change + can silently invalidate. The runtime holds a floor for each, and a matcher declaring either below + its floor is declined before it runs rather than matching against a schema it does not understand + ([RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats)). Both compare numerically + by `(major, minor)`; both default to `1.0` when omitted, which is what every descriptor authored + against this revision means. - **Additive evolution.** New JsonLogic operators, native predicates, and layout aliases are additive within `v1` where they do not change the meaning of an existing descriptor; anything that would reinterpret existing fields bumps the version. @@ -596,7 +683,7 @@ hazard. authored independently — including third-party drop-in files — do not collide by construction. References are typed by field (a KDP's `matchers` versus `engine`), so a matcher id and an engine id are never confused. A duplicate `id` seen on the drop-in path is logged and ignored rather than - taking down the provider ([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). + taking down the provider ([RFC 0017 §16](0017_UniversalKernelDescriptor.md#16-risks)). --- @@ -604,7 +691,7 @@ hazard. On the drop-in path the loader, the matcher, and the expression interpreter parse input that may be untrusted or simply malformed, so they must be bounded and fail closed rather than crash -([RFC 0017 §14](0017_UniversalKernelDescriptor.md#14-risks)). +([RFC 0017 §16](0017_UniversalKernelDescriptor.md#16-risks)). - **Bounded parsing and matching.** Recursion depth, expression step count, node/constraint counts, and descriptor size are capped; exceeding a cap quarantines the descriptor, it does not abort the @@ -615,7 +702,7 @@ untrusted or simply malformed, so they must be bounded and fail closed rather th - **Fail-closed evaluation.** An unknown symbol, unresolved native predicate, out-of-range axis, or type error declines the match; it never matches by default. - **Quarantine, not cascade.** A bad descriptor is quarantined on load with a diagnostic; the rest load - ([RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-packaging-and-delivery)). + ([RFC 0017 §12](0017_UniversalKernelDescriptor.md#12-packaging-and-delivery)). - **Fuzzing.** A seed corpus of UMDs and graphs plus a fuzzer over the loader, matcher, and interpreter run under the existing ASAN build ([§16](#16-testing-and-performance)), backing the fail-closed requirement. @@ -624,27 +711,39 @@ untrusted or simply malformed, so they must be bounded and fail closed rather th ## 15. Observability and Diagnostics -Because matching is data-driven, it is inspectable, and the tooling is a first-class deliverable -([RFC 0017 §9](0017_UniversalKernelDescriptor.md#9-observability-and-diagnostics)). For the UMD the -provider surfaces: +Because matching is data-driven, it is inspectable. For the UMD the provider surfaces: - **A why-not trace.** For a graph and a candidate UMD, the sub-expression of `criteria` that evaluated false and why (the concrete values compared), so an author can see exactly which test declined. - **A binding view.** For a successful match, the full bound symbol table (tensors, dims, strides, - attributes) as the UDD will see it. -- **An arbitration trace.** Which UKDs matched, how the UHD scored them, and where a tie fell to + attributes) as the UDD will see it — the bound token state of + [RFC 0017 §8](0017_UniversalKernelDescriptor.md#8-end-to-end-flow). +- **An arbitration trace.** Which UKDs matched, how the heuristic scored them, and where a tie fell to `priority` or stable `id` ([§12](#12-arbitration)). - **Load diagnostics.** Which UMDs compiled, which were quarantined and why, and unresolved native predicates by name. -These reuse the diagnostic surface RFC 0017 §9 defines rather than adding a UMD-specific one. +These reuse the diagnostic surface +[RFC 0017 §10](0017_UniversalKernelDescriptor.md#10-observability-and-diagnostics) defines rather than +adding a UMD-specific one. Authoring and validation tooling around the format is a separate, +first-class deliverable specified in +[RFC 0017 §11](0017_UniversalKernelDescriptor.md#11-tooling), where agentic authoring — agent-driven +skills that build and check descriptors from intent — is a committed first step. A UMD is a good fit +for it: the schema of [Appendix A](#appendix-a-schema-reference) and the compile-time checks of A.10 +are exactly what such a tool validates against. + +A matcher is also affected by the runtime opt-outs RFC 0017 §10 defines +(`HIPDNN_DISABLE_ENGINES`, `HIPDNN_DISABLE_KDPS`, `HIPDNN_DISABLE_UKDS`). Disabling a kernel carries a +risk specific to shared matchers: a matcher written around the kernel set it was meant to cover may no +longer be correct once one of those kernels is excluded, leaving the engine over-claiming +applicability for cases it no longer serves. The option is provided with that risk stated. --- ## 16. Testing and Performance The UMD introduces no new testing strategy; it slots into hipDNN's existing tiers (`docs/Testing.md`, -`docs/testing/TestingStrategy.md`) as RFC 0017 §12.1 requires. A UMD-backed kernel runs through the +`docs/testing/TestingStrategy.md`) as RFC 0017 §14.1 requires. A UMD-backed kernel runs through the generic engine as an ordinary engine and produces the same graphs everything else consumes, so the plugin-agnostic integration harness ([RFC 0006](0006_PluginAgnosticIntegrationTests.md)) validates it against the CPU reference ([RFC 0001](0001_CpuGraphExecutorDesign.md)) with the golden-reference @@ -664,14 +763,14 @@ UMD-specific coverage: - **Fuzzing.** The corpus and fuzzer of [§14](#14-security-and-hostile-input). - **Match overhead.** Plan-time match cost is measured against the hand-written baseline as benchmarking matures (`tools/dnn-benchmarking`, [RFC 0013](0013_Autotune.md)); the compiled matcher, - root-opcode index, and per-plan cache ([§10](#10-the-matcher-compilation-indexing-and-caching)) keep - it minimal, and the cost is paid once at plan build. + root-opcode index, and applicability-time cache ([§10](#10-the-matcher-compilation-indexing-and-caching)) + keep it minimal, and the cost is paid once per graph and device. --- ## 17. Migration -Migration follows RFC 0017 §12: no engine is converted until a UMD-backed kernel runs end to end, and a +Migration follows RFC 0017 §14: no engine is converted until a UMD-backed kernel runs end to end, and a hand-written engine and its descriptor-backed replacement coexist until the generic one reaches parity on the graphs that engine covers, at which point the hand-written code is retired. @@ -693,6 +792,13 @@ non-declarative gates (uint32 stride fit, mask self-consistency) remain as custo `$q.head_size` is bound from `$q`'s dim, not read as an attribute ([§2](#2-the-matchers-input-hipdnns-graph-model)). +This example is grounded on the asm-SDPA builder because that builder is this RFC's first migration +target ([§17](#17-migration)), so the mapping table below doubles as the cutover checklist. It is +deliberately a different example from +[RFC 0017 §13](0017_UniversalKernelDescriptor.md#13-worked-example-sdpa-as-a-ukd), which works the +`attention_dense` kernel family end to end across all six descriptor kinds; that one shows a UMD in +the context of a full UKD, this one shows one hand-written `isApplicable` becoming one UMD. + ```jsonc { "schema": "hipdnn.umd/v1", @@ -719,8 +825,9 @@ non-declarative gates (uint32 stride fit, mask self-consistency) remain as custo {"==": ["$sdpa_fwd.alibi_mask", false]}, {"==": ["$sdpa_fwd.padding_mask", false]}, {"or": [{"!": "$sdpa_fwd.generate_stats.present"}, {"==": ["$sdpa_fwd.generate_stats", false]}]}, - {"!": "$attn_mask.present"}, // unsupported optional operands absent - {"!": "$page_table_k.present"}, {"!": "$page_table_v.present"}, + // unsupported optional operands declined together; `not_present` always evaluates, + // unlike a field read on an absent operand + {"not_present": ["$attn_mask", "$page_table_k", "$page_table_v"]}, {"hipdnn.sdpa_mask_consistent": ["$sdpa_fwd"]}, // custom operation (needs C++) {"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} // custom operation (needs C++) ]} @@ -736,7 +843,7 @@ Mapping to the hand-written code: | `nodeWrappers().size() != 1` (:199) | `{"==": ["$graph.node_count", 1]}` | | `attributesType() != SdpaAttributes` (:200) | node `op: sdpa_fwd` | | dropout / alibi / padding / stats gates (:205-224) | `$sdpa_fwd.*` criteria | -| `attn_mask` / `page_table_*` absent (:209-215) | `?` operands + `{"!": "$attn_mask.present"}` | +| `attn_mask` / `page_table_*` absent (:209-215) | `?` operands + one `{"not_present": [...]}` over all three | | rank == 4 (:231-247) | `shape` short-hand (names four dims) | | `q == k == v` dtype (:244) | `{"==": ["$k.dtype", "$q.dtype"]}` | | `k.dims[1] == v.dims[1]` head count (:251) | shared `kv_heads` name in `$k`/`$v` `shape` | @@ -769,7 +876,7 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m Mitigation: version and document the shipped predicate set; fail closed with a clear diagnostic. - **Match overhead.** Per-candidate evaluation of the criteria expression is unbounded by the root-opcode index ([§10](#10-the-matcher-compilation-indexing-and-caching)). Mitigation: short-circuit - evaluation, per-plan caching, and the overhead test of [§16](#16-testing-and-performance). + evaluation, applicability-time caching, and the overhead test of [§16](#16-testing-and-performance). - **Static-matcher parity.** A lowered matcher that diverges from the interpreter is a silent correctness bug ([§11](#11-static-matcher-sketch)). Mitigation: the interpreter is the oracle and the parity test gates any lowering. @@ -787,8 +894,13 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m 3. **Static-matcher form.** Which of the [§11](#11-static-matcher-sketch) options becomes the AOT fast path, and does it also serve drop-in via a serialized bytecode? 4. **Feature-vector overlap.** The bound symbol table overlaps the feature vector a UHD consumes - ([RFC 0017 §15 Q4](0017_UniversalKernelDescriptor.md#15-open-questions)); should the UMD's bindings + ([RFC 0017 §17 Q4](0017_UniversalKernelDescriptor.md#17-open-questions)); should the UMD's bindings be the canonical feature source for kernel selection? +5. **Operators RFC 0017 omits.** `abs`, `pow`, `log2`, and `if` are in A.7 but not in the vocabulary + table of [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd), which declares + itself complete. They are needed for dispatch formulas and precedence chains. Add them to RFC + 0017's table, or scope them in this RFC as dispatch-formula-only and keep criteria to RFC 0017's + set? --- @@ -828,15 +940,19 @@ The design borrows established ideas; none is a dependency. These informed the U value-valued expressions are UDD dispatch formulas, both over one `$`-variable symbol table (the five namespaces of [§4](#4-symbol-binding-and-the-auto-binding-formula)) with one evaluator ([§6](#6-the-shared-expression-language)). -- **Stride-order layout:** layout represented as an array of dimension indexes giving stride order, - since tensors carry no layout enum ([§7](#7-layout-and-stride-order-constraints)). +- **Stride-order layout:** layout represented as an array of per-dimension stride ranks, higher meaning + slower-varying, since tensors carry no layout enum ([§7](#7-layout-and-stride-order-constraints)). - **Custom operation (native predicate):** the escape hatch; a registry-resolved JsonLogic operation a UMD invokes by its namespaced name for logic the built-in operators cannot state, carried as an operation name and typed arguments, never inline code ([§8](#8-native-predicate-escape-hatch)). - **Composite criteria:** any boolean combination of tests within the one `criteria` expression, written directly with JsonLogic `and` / `or` / `!` / `if` ([§9](#9-composite-constraints)). -- **Arbitration:** the deterministic resolution when several UKDs match: UHD score, then `priority`, - then stable `id` ([§12](#12-arbitration)). +- **Arbitration:** the deterministic resolution when several UKDs match: heuristic (UHD) score, then + `priority`, then stable `id` compared as raw bytes ([§12](#12-arbitration)). +- **Catalog / bound token state:** the two products of matching a graph — the kernels whose full + matcher set passed, and every `$`-prefixed value the matchers resolved. Both are cached by the + provider during applicability and read by every later phase + ([RFC 0017 §8](0017_UniversalKernelDescriptor.md#8-end-to-end-flow)). - **Root-opcode index:** the index of compiled matchers by root opcode that keeps match cost sublinear in descriptor count ([§10](#10-the-matcher-compilation-indexing-and-caching)). @@ -860,11 +976,14 @@ these. Criteria arithmetic is integer; `Float` arises only in UDD dispatch formu | `schema` | string | yes | — | MUST equal `"hipdnn.umd/v1"`; a newer version is refused, never reinterpreted ([§13](#13-serialization-and-versioning)) | | `id` | string (UUID) | yes | — | A UUID; stable, globally unique identity ([§13](#13-serialization-and-versioning)) | | `name` | string | yes | — | Diagnostics only; not semantic | +| `version` | string | no | `"1.0"` | Matcher format version, `.`, gated at load against the runtime's floor ([§13](#13-serialization-and-versioning)) | +| `sdk_version` | string | no | `"1.0"` | The hipDNN graph schema version this matcher was authored against, `.`. A matcher declaring a version below the runtime's floor is declined before it runs ([§13](#13-serialization-and-versioning)) | | `allow_override_shape` | bool | no | `false` | When `false`, override-shape graphs are declined ([§3](#3-structural-pattern)) | | `nodes` | array<Node> | yes | — | Non-empty; A.2 | | `criteria` | Expr | yes | — | A single expression whose static type is `Bool` (A.6) | -No other top-level keys are permitted; an unknown key is refused. +No other top-level keys are permitted; an unknown key is refused. Both version fields compare +numerically by `(major, minor)`, so `1.10` is above `1.9`. ### A.2 Node object and opcode selector @@ -910,10 +1029,11 @@ var-ref = "$" , ( tensor-ref | graph-ref | attr-ref | kernel-ref | device-r tensor-ref = tvar , [ "." , tensor-field ] ; tvar = ident ; (* a pattern variable bound to a Tensor *) tensor-field = "uid" | "rank" | "dtype" | "stride_order" | "packed" | "virtual" | "present" + | "is_runtime_pass_by_value" | "value_f32" | "dims" , "[" , uint , "]" | "strides" , "[" , uint , "]" | dim-name ; (* a name a shape short-hand introduced (A.5) *) -graph-ref = "graph" , "." , "node_count" ; +graph-ref = "graph" , "." , ( "node_count" | "is_override_shape_enabled" ) ; attr-ref = node-id , "." , attr-name , [ "." , "present" ] ; kernel-ref = "kernel" , "." , ident ; device-ref = "device" , "." , ident ; @@ -923,8 +1043,8 @@ uint = digit , { digit } ; | Namespace | Root | Fields | Type | |---|---|---|---| -| Tensor | a pattern variable (`$q`) | `uid`, `rank`, `dtype`, `stride_order`, `packed`, `virtual`, `present`, `dims[i]`, `strides[i]`, `` | `Tensor` / `Int` / `Dtype` / `IntArray` / `Bool` | -| Graph | `$graph` | `node_count` | `Int` | +| Tensor | a pattern variable (`$q`) | `uid`, `rank`, `dtype`, `stride_order`, `packed`, `virtual`, `present`, `is_runtime_pass_by_value`, `value_f32`, `dims[i]`, `strides[i]`, `` | `Tensor` / `Int` / `Dtype` / `IntArray` / `Bool` / `Float` | +| Graph | `$graph` | `node_count`, `is_override_shape_enabled` | `Int` / `Bool` | | Attributes | a node `id` (`$sdpa_fwd`) | ``, `.present` | scalar / `Bool` | | Kernel | `$kernel` | `` a UKD supplies ([RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats)) | scalar | | Device | `$device` | `` (`lds_size`, `warp_size`, …) | scalar | @@ -938,7 +1058,11 @@ Rules: guard such reads with `.present` and rely on short-circuit ordering ([§10](#10-the-matcher-compilation-indexing-and-caching)). - An out-of-range `dims[i]`/`strides[i]`, an unknown `dim-name`, or any unresolved reference declines - the match. + the match. `value_f32` resolves only when the tensor carries a compile-time value, so it declines + on one that does not. +- The `present` / `not_present` **operators** (A.7) are the exception to the fail-closed rule above: + they report whether a reference resolved and so always yield a boolean, on a required or an + optional reference alike. ### A.5 The `shape` short-hand @@ -1014,7 +1138,8 @@ All integer arithmetic uses checked-width integers and fails closed on overflow | `log2`, `rsqrt` | 1 | `Number` | `Float` | Dispatch-formula value core; fails closed on a non-positive argument | | `rank` | 1 | `Tensor` | `Int` | Equal to `$t.rank` | | `divisible` | 2 | `Int, Int` | `Bool` | `true` iff divisor `!= 0` and dividend `% divisor == 0`; a zero divisor yields `false`, not an error, giving uniform fail-closed zero-guarding (resolves [Open Question 2](#20-open-questions)) | -| `value_or_default` | 2 | `Ref, Value` | `Value` | The referenced optional operand/attribute value when present, else the default literal | +| `value_or_default` | 2 | `Ref, Value` | `Value` | The referenced optional operand/attribute value when present, else the fallback. The fallback is usually a literal, but it may be any expression of the same type, including a second field reference, so "this field, else that one" is one operator instead of a branch; both arms MUST be type-compatible against the schema's declared field types | +| `present`, `not_present` | n-ary | `Ref…` | `Bool` | Was this supplied? `present` is `true` iff every referenced operand/field is bound; `not_present` iff every one is absent. Unlike a field read, these **always evaluate** rather than declining on an absent reference, so one call declines a list of unsupported optional operands | | `if` | 3 or 2n+1 | `Bool, Value [, Bool, Value]… , Value` | `Value` | `if`/`elif`/`else` chain; branch results MUST share a type | | `shape` | 2 | `Tensor, EntryList` | `Bool` | A.5 | | `.` | per registry | per registry | `Bool` or `Value` | Custom operation, A.9 | @@ -1022,17 +1147,29 @@ All integer arithmetic uses checked-width integers and fails closed on overflow Adding an operator is additive within `v1` where it does not change the meaning of an existing descriptor ([§13](#13-serialization-and-versioning)). +`abs`, `pow`, `log2`, and `if` are not in the vocabulary table of +[RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd), which declares its list +complete. They are retained here because the UDD's dispatch formulas need them, and because +`if` is what encodes a precedence chain such as a mask classifier. They are additive under the rule +above; reconciling them into RFC 0017's table is tracked as +[Open Question 5](#20-open-questions). + ### A.8 `stride_order` values and layout aliases A `stride_order` comparison accepts either an integer array or an alias string; aliases expand to the array at compile time, and the array is the single canonical form ([§7](#7-layout-and-stride-order-constraints)). -An array MUST be a permutation of `0 .. rank-1`, slowest-varying dimension first. +An array MUST be a permutation of `0 .. rank-1` giving each logical dimension's stride rank, higher +meaning slower-varying. | Alias | Array | | Alias | Array | |---|---|---|---|---| -| `nchw` | `[0,1,2,3]` | | `ndhwc` | `[0,2,3,4,1]` | -| `nhwc` | `[0,2,3,1]` | | `bhsd` | `[0,1,2,3]` | -| `ncdhw` | `[0,1,2,3,4]` | | `contiguous` | identity permutation for the tensor's rank | +| `nchw` | `[3,2,1,0]` | | `ndhwc` | `[4,0,3,2,1]` | +| `nhwc` | `[3,0,2,1]` | | `bhsd` | `[3,2,1,0]` | +| `ncdhw` | `[4,3,2,1,0]` | | `contiguous` | descending stride ranks for the tensor's rank | + +`contiguous` needs a rank, so it is legal only on a tensor whose rank a `shape` short-hand has pinned +(A.5); a fixed-rank alias compared against a differently-pinned tensor is refused at compile rather +than declining silently at match time. ### A.9 Custom operations @@ -1049,7 +1186,8 @@ A descriptor MUST pass every check below to compile; a failure refuses (and, on quarantines) the descriptor with a diagnostic ([§10](#10-the-matcher-compilation-indexing-and-caching), [§14](#14-security-and-hostile-input)): -1. `schema == "hipdnn.umd/v1"`, and `id` is a well-formed UUID. +1. `schema == "hipdnn.umd/v1"`, `id` is a well-formed UUID, and each of `version` / `sdk_version`, + when present, is a well-formed `.` string at or above the runtime's floor. 2. Only the keys of A.1 at the top level; only the keys of A.2 on each node. 3. Every `opcode` and every name key resolves in the op-schema registry. 4. Every `?` suffix matches the registry's optionality for that name. From af0b51aeae8a80d29fbbbc535eb0d30834e63b01 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 11:12:30 -0600 Subject: [PATCH 51/60] feat(hip-kernel-provider): conform the UMD matcher to RFC 0017 RFC 0017 tightened the match contract on the base branch. Five changes were load-bearing; the sixth replaces a bool the new rules cannot use. The blocking one is the version keys. `validateTopLevel` rejects any unknown top-level key, so a spec-conformant descriptor carrying `sdk_version` failed to compile at all. Both `version` and `sdk_version` are now accepted, optional so existing descriptors keep compiling, and gated against a floor. Comparison is numeric on (major, minor), so "1.10" ranks above "1.9"; anything that does not parse is below every floor. `present` / `not_present` are new operators. Unlike every other operator they do not propagate the null an unresolved path reads: answering "was this supplied?" always yields a boolean, which is what lets a pack write "absent, or present and constrained" instead of silently accepting a graph it cannot serve. Both fold with `and` over their arguments, so one call declines a whole list of unsupported optional operands. They resolve the bare reference rather than the `.present` field path, so they work on required operands too; the field keeps its existing optional-only rule. `value_or_default` now type-checks both arms and accepts an expression or a second field reference as the fallback, not just a literal. Three namespace fields join the vocabulary: `$graph.is_override_shape_enabled` (the graph's own opt-in, distinct from the descriptor's `allow_override_shape`), `$q.is_runtime_pass_by_value`, and `$q.value_f32`, which coerces whichever arm of the tensor's eight-arm value union is set. A tensor carrying no compile-time value resolves to null so a criterion over it declines, rather than reading a zero that would satisfy a comparison. The Float8Value arm decodes its raw bits through the tensor's data_type; a pairing that names no 8-bit format also resolves to null. `referencesKernelMetadata` becomes the set of `$kernel.*` field names the criteria read. A matcher is memoized per distinct value of the fields it reads, not per kernel, and a loader must check each name exists in the engine's KMD -- neither is expressible with a bool. The set and the bound-symbol set are both exposed on the matcher so a KDP/KMD loader can consume them. Two existing tests now assert on the field set instead of the bool. (cherry picked from commit f1578d2910461f1510bfe25fafe8941f3e8a9b31) (cherry picked from commit a24c4260070e76164df0cb881868e06925113ecb) --- .../hip_kernel_provider_common/JsonLogic.hpp | 37 +- .../hip_kernel_provider_common/JsonLogic.md | 16 +- .../umd/BindingContext.hpp | 88 +++- .../umd/UmdCompiler.hpp | 199 ++++++- .../umd/UniversalGraphMatcher.hpp | 22 +- .../src/tests/core/TestJsonLogic.cpp | 28 + .../tests/core/TestUniversalGraphMatcher.cpp | 491 +++++++++++++++++- 7 files changed, 852 insertions(+), 29 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp index 51450ad125b3..dd5ae8ec2bec 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp @@ -37,8 +37,9 @@ // Strings without the sigil remain literals, so stock rules keep working. // // Scope: core operator set (data access, logic, comparison, arithmetic), the -// membership operator `in`, the `value_or_default` fallback, and the value-core -// math extensions the hipDNN UMD needs (ceil_div, abs, pow, log2, rsqrt). +// membership operator `in`, the `value_or_default` fallback, the `present` / +// `not_present` resolution predicates, and the value-core math extensions the +// hipDNN UMD needs (ceil_div, abs, pow, log2, rsqrt). // Collection/string operators (map/reduce/filter/cat/substr/...) are not // included. @@ -427,7 +428,9 @@ enum class Op POW, LOG2, RSQRT, - VALUE_OR_DEFAULT + VALUE_OR_DEFAULT, + PRESENT, + NOT_PRESENT }; struct Node @@ -693,6 +696,24 @@ struct OpNode final : Node Value const v = args[0]->eval(d); return v.isNull() ? args[1]->eval(d) : v; } + case Op::PRESENT: + case Op::NOT_PRESENT: + { + // Presence keys on *existence*, the same mechanism as + // VALUE_OR_DEFAULT above: an unresolved path reads null. Unlike + // every other operator these do not propagate that null -- asking + // "was this supplied?" always yields a real boolean. Both fold with + // `and` over their arguments, so one call decides a whole list. + const bool wantNull = (op == Op::NOT_PRESENT); + for(const auto& c : args) + { + if(c->eval(d).isNull() != wantNull) + { + return {false}; + } + } + return {true}; + } default: break; } @@ -710,7 +731,7 @@ struct OpNode final : Node inline const Op* lookupOp(const std::string& key) { - static const std::array, 26> s_table + static const std::array, 28> s_table = {{{"+", Op::ADD}, {"-", Op::SUB}, {"*", Op::MUL}, @@ -736,7 +757,9 @@ inline const Op* lookupOp(const std::string& key) {"pow", Op::POW}, {"log2", Op::LOG2}, {"rsqrt", Op::RSQRT}, - {"value_or_default", Op::VALUE_OR_DEFAULT}}}; + {"value_or_default", Op::VALUE_OR_DEFAULT}, + {"present", Op::PRESENT}, + {"not_present", Op::NOT_PRESENT}}}; for(const auto& e : s_table) { if(key == e.first) @@ -795,6 +818,10 @@ inline void checkArity(Op op, std::size_t n, const std::string& key) case Op::VALUE_OR_DEFAULT: require(n == 2); break; + case Op::PRESENT: + case Op::NOT_PRESENT: + require(n >= 1); + break; case Op::ABS: case Op::LOG2: case Op::RSQRT: diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md index 52d5bb4c217e..6105dc7f7e0b 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md @@ -103,10 +103,12 @@ begin with `$`. Data access: `var` (dotted paths, `[N]` array subscripts, `""` whole-document, `[path, default]` fallback, and computed `[expr, default]` paths); -`value_or_default` (`{"value_or_default": ["$x", default]}`) returns the -variable's value when the path resolves and the default otherwise. It keys on +`value_or_default` (`{"value_or_default": ["$x", fallback]}`) returns the +variable's value when the path resolves and the fallback otherwise. It keys on *existence* (an unresolved path reads as `null`), not truthiness, so a present -`0`, `""`, or `false` is returned rather than the default. +`0`, `""`, or `false` is returned rather than the fallback. The fallback is any +expression, evaluated lazily, so `["$a", "$b"]` reads "this field, else that +one". Logic / control: `if` / `?:`, `and`, `or`, `!`, `!!` (short-circuit, lazy branches). @@ -125,6 +127,14 @@ Membership: `in`. `{"in": [needle, array]}` is true when `array` contains `needle` (strict element equality); `{"in": [needle, string]}` is a substring test. +Presence: `present`, `not_present`. `{"present": ["$a", "$b"]}` is true when +every listed path resolves to a non-null value; `{"not_present": [...]}` is true +when every listed path resolves to null. Both are `and`-folds, so one call +decides a whole list. They key on the same *existence* mechanism as +`value_or_default`, and are the only operators that do not propagate a null: an +unresolved path yields `false`/`true` rather than `null`, because answering +"was this supplied?" is their whole job. + Value semantics follow JsonLogic/JS: `false`, `0`, `""`, `null` and the empty array are falsy; `Number()`-style coercion drives arithmetic and ordering. diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp index f07d2488e56c..788d664e360e 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp @@ -20,8 +20,9 @@ // (RFC 0018 §4): // - Tensor `$q`, `$q.uid`, `$q.rank`, `$q.dtype`, `$q.dims[i]`, // `$q.strides[i]`, `$q.`, `$q.stride_order`, -// `$q.packed`, `$q.virtual`, `$q.present` -// - Graph `$graph.node_count` +// `$q.packed`, `$q.virtual`, `$q.present`, +// `$q.is_runtime_pass_by_value`, `$q.value_f32` +// - Graph `$graph.node_count`, `$graph.is_override_shape_enabled` // - Attributes `$.`, `$..present` // - Kernel `$kernel.` (from caller-supplied, fully-resolved KMD // metadata; an unbound document is a defensive fallback that @@ -44,6 +45,10 @@ #include "hip_kernel_provider_common/JsonLogic.hpp" #include "hip_kernel_provider_common/umd/UmdPathParse.hpp" +#include +#include +#include +#include #include #include #include @@ -192,10 +197,21 @@ class BindingContext jlogic::Value resolveGraph(const std::string& rest) const { - if(rest == "node_count" && _graph != nullptr) + if(_graph == nullptr) + { + return {}; + } + if(rest == "node_count") { return {static_cast(_graph->nodeCount())}; } + if(rest == "is_override_shape_enabled") + { + // The graph's own state, read from the same accessor the matcher's + // override-shape gate uses. Distinct from the descriptor's + // `allow_override_shape` key, which is the matcher's opt-in. + return {_graph->getGraph().is_override_shape_enabled()}; + } return {}; } @@ -296,6 +312,14 @@ class BindingContext return {hipdnn_data_sdk::utilities::isTensorPacked(toVector(t->dims()), toVector(t->strides()))}; } + if(rest == "is_runtime_pass_by_value") + { + return {t->is_runtime_pass_by_value()}; + } + if(rest == "value_f32") + { + return resolveValueF32(*t); + } if(rest == "stride_order") { const std::vector order @@ -349,6 +373,64 @@ class BindingContext return {}; // unknown tensor field -> decline } + // Coerce whichever arm of the tensor's `value` union is set to f32 and + // publish it as the single `$q.value_f32` token (RFC 0017 §5). A tensor + // carrying no compile-time value resolves to null, so a criterion over it + // declines rather than reading a zero that would satisfy a comparison. + static jlogic::Value resolveValueF32(const TensorAttributes& t) + { + namespace data = hipdnn_flatbuffers_sdk::data_objects; + switch(t.value_type()) + { + case data::TensorValue::Float32Value: + return {static_cast(t.value_as_Float32Value()->value())}; + case data::TensorValue::Float16Value: + return {static_cast(t.value_as_Float16Value()->value())}; + case data::TensorValue::BFloat16Value: + return {static_cast(t.value_as_BFloat16Value()->value())}; + case data::TensorValue::Float64Value: + return {t.value_as_Float64Value()->value()}; + case data::TensorValue::Int32Value: + return {static_cast(t.value_as_Int32Value()->value())}; + case data::TensorValue::Int64Value: + return {static_cast(t.value_as_Int64Value()->value())}; + case data::TensorValue::BoolValue: + return {t.value_as_BoolValue()->value() ? 1.0 : 0.0}; + case data::TensorValue::Float8Value: + return fp8ValueToF32(t.value_as_Float8Value()->value(), t.data_type()); + default: + break; + } + return {}; // TensorValue::NONE -> no compile-time value -> decline + } + + // Float8Value stores raw bits; the tensor's data_type says which 8-bit + // format they encode. An arm/data_type pairing that names no 8-bit format + // resolves to null (fail closed) rather than reinterpreting the bits. + static jlogic::Value fp8ValueToF32(std::uint8_t bits, + hipdnn_flatbuffers_sdk::data_objects::DataType dtype) + { + namespace data = hipdnn_flatbuffers_sdk::data_objects; + namespace types = hipdnn_data_sdk::types; + switch(dtype) + { + case data::DataType::FP8_E4M3: + return {static_cast(static_cast(types::fp8_e4m3::from_bits(bits)))}; + case data::DataType::FP8_E5M2: + return {static_cast(static_cast(types::fp8_e5m2::from_bits(bits)))}; + case data::DataType::FP8_E4M3_FNUZ: + return {static_cast(static_cast(types::fp8_e4m3_fnuz::from_bits(bits)))}; + case data::DataType::FP8_E5M2_FNUZ: + return {static_cast(static_cast(types::fp8_e5m2_fnuz::from_bits(bits)))}; + case data::DataType::UINT8: + case data::DataType::INT8: + return {static_cast(bits)}; + default: + break; + } + return {}; + } + const IGraph* _graph = nullptr; std::unordered_map _device; std::unordered_map _nodes; diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp index 92e8c9b6e9ac..a424efdcd8bf 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -95,16 +95,24 @@ struct CompiledUmd { std::string id; std::string name; + // Matcher format version and the hipDNN graph schema version this matcher + // was authored against; empty when the descriptor declares neither + // (RFC 0017 §4). + std::string version; + std::string sdkVersion; bool allowOverrideShape = false; std::vector nodes; std::vector tvars; jlogic::Expression criteria; std::set boundSymbols; - // True when the criteria expression references any `$kernel.`. Set - // once by UmdCompiler::run() and immutable thereafter; the matcher reads it - // to route between the metadata and non-metadata match overloads. - bool referencesKernelMetadata = false; + // The `$kernel.` names the criteria expression reads, without the + // `kernel.` prefix (`$kernel.tile_m` contributes "tile_m"). Set once by + // UmdCompiler::run() and immutable thereafter; empty for a graph-only + // matcher, which is what routes between the metadata and non-metadata + // match overloads. A KDP/KMD loader checks each name exists in the engine's + // KMD and uses the set as the per-kernel memoization key (RFC 0017 §5). + std::set kernelFields; const TensorVarSpec* findTvar(const std::string& tvar) const { @@ -152,6 +160,29 @@ class UmdCompiler return c.run(descriptor); } + // The oldest matcher format version and hipDNN graph schema (SDK) version + // this compiler accepts. A descriptor declaring less is declined before it + // runs (RFC 0017 §4 / A.10 §1). + static constexpr const char* K_MIN_VERSION = "1.0"; + static constexpr const char* K_MIN_SDK_VERSION = "1.0"; + + // True when the "." `version` is at or above `floor`. + // Compared numerically on (major, minor), never lexicographically, so + // "1.10" ranks above "1.9". A version that does not parse is below every + // floor (fail closed). + static bool versionAtLeast(const std::string& version, const std::string& floor) + { + std::size_t major = 0; + std::size_t minor = 0; + std::size_t floorMajor = 0; + std::size_t floorMinor = 0; + if(!parseVersion(version, major, minor) || !parseVersion(floor, floorMajor, floorMinor)) + { + return false; + } + return major > floorMajor || (major == floorMajor && minor >= floorMinor); + } + private: // Static type domain for A.10 §9. Numeric unifies Int and Float; ARRAY // carries its element kind for `in` / IntArray equality. DYNAMIC is a @@ -184,12 +215,42 @@ class UmdCompiler return k == Kind::INT || k == Kind::FLOAT; } + // Kind rendered for a diagnostic (A.10 §9). + static std::string kindName(Kind k) + { + switch(k) + { + case Kind::INT: + return "Int"; + case Kind::FLOAT: + return "Float"; + case Kind::BOOL: + return "Bool"; + case Kind::DTYPE: + return "Dtype"; + case Kind::ARRAY: + return "Array"; + case Kind::TENSOR: + return "Tensor"; + case Kind::JSON_NULL: + return "Null"; + case Kind::DYNAMIC: + return "Dynamic"; + default: + break; + } + return "Unknown"; + } + CompiledUmd run(const nlohmann::json& d) { CompiledUmd out; validateTopLevel(d); out.id = d.at("id").get(); out.name = d.at("name").get(); + out.version = d.contains("version") ? d.at("version").get() : std::string(); + out.sdkVersion + = d.contains("sdk_version") ? d.at("sdk_version").get() : std::string(); out.allowOverrideShape = d.contains("allow_override_shape") ? d.at("allow_override_shape").get() : false; @@ -208,8 +269,14 @@ class UmdCompiler // Validate each reference resolves (A.10 §8); varTypeOf throws if not. varTypeOf(sym, out); out.boundSymbols.insert(sym); + std::string root; + std::string rest; + path::splitRoot(sym, root, rest); + if(root == "kernel") + { + out.kernelFields.insert(rest); + } } - out.referencesKernelMetadata = jlogic::referencesVariableRoot(out.criteria, "kernel"); return out; } @@ -220,8 +287,14 @@ class UmdCompiler { throw UmdCompileError("descriptor must be a JSON object"); } - static const std::set s_allowed - = {"schema", "id", "name", "allow_override_shape", "nodes", "criteria"}; + static const std::set s_allowed = {"schema", + "version", + "sdk_version", + "id", + "name", + "allow_override_shape", + "nodes", + "criteria"}; for(const auto& [key, value] : d.items()) { if(s_allowed.find(key) == s_allowed.end()) @@ -252,6 +325,8 @@ class UmdCompiler { throw UmdCompileError("allow_override_shape must be a boolean"); } + validateVersionKey(d, "version", K_MIN_VERSION); + validateVersionKey(d, "sdk_version", K_MIN_SDK_VERSION); if(!d.at("nodes").is_array() || d.at("nodes").empty()) { throw UmdCompileError("nodes must be a non-empty array (A.1)"); @@ -281,6 +356,66 @@ class UmdCompiler return true; } + // Both version keys are optional; when present each must be a + // "." string at or above its floor (A.10 §1). + static void validateVersionKey(const nlohmann::json& d, const char* key, const char* floor) + { + if(!d.contains(key)) + { + return; + } + if(!d.at(key).is_string()) + { + throw UmdCompileError(std::string(key) + + " must be a \".\" string (A.10 §1)"); + } + const std::string declared = d.at(key).get(); + std::size_t major = 0; + std::size_t minor = 0; + if(!parseVersion(declared, major, minor)) + { + throw UmdCompileError(std::string(key) + " \"" + declared + + "\" is not a \".\" string (A.10 §1)"); + } + if(!versionAtLeast(declared, floor)) + { + throw UmdCompileError(std::string(key) + " " + declared + + " is below the minimum supported " + floor + " (A.10 §1)"); + } + } + + static bool parseVersion(const std::string& s, std::size_t& major, std::size_t& minor) + { + const std::size_t dot = s.find('.'); + if(dot == std::string::npos || s.find('.', dot + 1) != std::string::npos) + { + return false; + } + return parseVersionComponent(s.substr(0, dot), major) + && parseVersionComponent(s.substr(dot + 1), minor); + } + + // A version component is a run of decimal digits: no sign, no whitespace, + // no exponent. The length cap keeps the accumulation below overflow. + static bool parseVersionComponent(const std::string& s, std::size_t& out) + { + if(s.empty() || s.size() > 9) + { + return false; + } + std::size_t value = 0; + for(const char c : s) + { + if(std::isdigit(static_cast(c)) == 0) + { + return false; + } + value = (value * 10) + static_cast(c - '0'); + } + out = value; + return true; + } + static bool isReservedRoot(const std::string& s) { return s == "graph" || s == "kernel" || s == "device"; @@ -607,7 +742,9 @@ class UmdCompiler "virtual", "present", "dims", - "strides"}; + "strides", + "is_runtime_pass_by_value", + "value_f32"}; return s_reserved.find(name) != s_reserved.end(); } @@ -953,8 +1090,39 @@ class UmdCompiler } if(op == "value_or_default") { + // A.7: the fallback is any expression of the same type, not just a + // literal, so both arms are checked and unified. DYNAMIC carries no + // concrete type, so the other arm supplies the result kind. requireArity(2, 2); - return argType(1); // value type follows the default literal + const TypeInfo a = argType(0); + const TypeInfo b = argType(1); + if(!sameDomain(a.kind, b.kind)) + { + throw UmdCompileError("'value_or_default' arms have different types (A.7): " + + kindName(a.kind) + " vs " + kindName(b.kind)); + } + return a.kind == Kind::DYNAMIC ? b : a; + } + if(op == "present" || op == "not_present") + { + // A.7: n-ary resolution predicates. Each argument is a variable + // reference resolved as itself -- never through the `.present` + // field form -- so a required operand is legal here, and the result + // is always a real Bool because the operator inspects resolution + // rather than a value. + requireArity(1, 0); + for(std::size_t i = 0; i < args.size(); ++i) + { + const nlohmann::json& a = args.at(i); + if(!a.is_string() || a.get_ref().size() < 2 + || a.get_ref().front() != '$') + { + throw UmdCompileError("operator '" + op + + "' requires variable-reference arguments (A.7)"); + } + argType(i); // the reference must resolve (A.10 §8) + } + return {Kind::BOOL, Kind::UNKNOWN}; } if(op == "if") { @@ -1024,6 +1192,10 @@ class UmdCompiler { return {Kind::INT, Kind::UNKNOWN}; } + if(rest == "is_override_shape_enabled") + { + return {Kind::BOOL, Kind::UNKNOWN}; + } throw UmdCompileError("unknown graph field: " + rest); } if(root == "device") @@ -1126,10 +1298,17 @@ class UmdCompiler { return {Kind::ARRAY, Kind::INT}; } - if(rest == "packed" || rest == "virtual") + if(rest == "packed" || rest == "virtual" || rest == "is_runtime_pass_by_value") { return {Kind::BOOL, Kind::UNKNOWN}; } + if(rest == "value_f32") + { + // The schema layer coerces whichever arm of the tensor's `value` + // union is set to f32 and publishes it as one typed token; it reads + // null when the tensor carries no compile-time value (RFC 0017 §5). + return {Kind::FLOAT, Kind::UNKNOWN}; + } for(const std::string_view prefix : {std::string_view("dims"), std::string_view("strides")}) { if(path::isSubscriptOf(rest, prefix)) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp index 204b8b909925..67d209453ec2 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -97,7 +98,24 @@ class UniversalGraphMatcher // the two-argument overload throws for it. bool referencesKernelMetadata() const { - return _umd->referencesKernelMetadata; + return !_umd->kernelFields.empty(); + } + + // The `$kernel.` names this matcher reads, without the `kernel.` + // prefix. A KDP/KMD loader checks each name exists in the engine's KMD and + // uses the set as the key that memoizes this matcher per distinct kernel + // field tuple, rather than per kernel (RFC 0017 §5). + const std::set& kernelFields() const + { + return _umd->kernelFields; + } + + // The variable paths the descriptor's criteria bind, so a pack's dispatch + // descriptor can be checked against the fields its matchers publish + // (RFC 0017 §6). + const std::set& boundSymbols() const + { + return _umd->boundSymbols; } // Match a graph against this matcher's single descriptor. @@ -107,7 +125,7 @@ class UniversalGraphMatcher // three-argument overload. Use referencesKernelMetadata() to route. MatchResult match(const DeviceProperties& device, const IGraph& graph) const { - if(_umd->referencesKernelMetadata) + if(referencesKernelMetadata()) { throw std::logic_error( "UMD '" + _umd->id diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp index 39ba0a4659f4..275a91d9d7ff 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -189,6 +189,34 @@ TEST(JsonLogic, ValueOrDefault) V("fallback")); } +TEST(JsonLogic, PresenceOperators) +{ + // A resolving path is present; an unresolved one is not. + EXPECT_EQ(eval(json({{"present", json::array({"$x"})}})), V(true)); + EXPECT_EQ(eval(json({{"present", json::array({"$nope"})}})), V(false)); + EXPECT_EQ(eval(json({{"not_present", json::array({"$nope"})}})), V(true)); + EXPECT_EQ(eval(json({{"not_present", json::array({"$x"})}})), V(false)); + // Presence keys on existence, not truthiness: a present 0 / false is present. + EXPECT_EQ(eval(json({{"present", json::array({"$zero"})}})), V(true)); + EXPECT_EQ(eval(json({{"not_present", json::array({"$zero"})}})), V(false)); + // Unlike every other operator, an unresolved path yields a real boolean + // rather than propagating null, so the criterion decides instead of declining. + EXPECT_TRUE(eval(json({{"present", json::array({"$nope"})}})).isBool()); + EXPECT_TRUE(eval(json({{"not_present", json::array({"$nope"})}})).isBool()); + // n-ary: both fold with `and` over every argument. + EXPECT_EQ(eval(json({{"present", json::array({"$x", "$y", "$name"})}})), V(true)); + EXPECT_EQ(eval(json({{"present", json::array({"$x", "$nope"})}})), V(false)); + EXPECT_EQ(eval(json({{"not_present", json::array({"$nope", "$missing"})}})), V(true)); + EXPECT_EQ(eval(json({{"not_present", json::array({"$nope", "$x"})}})), V(false)); + // unary sugar (a bare argument, not an array) + EXPECT_EQ(eval(json({{"present", "$x"}})), V(true)); + // at least one argument is required + EXPECT_THROW(jlogic::compile(json({{"present", json::array()}})), + jlogic::JsonLogicCompileError); + EXPECT_THROW(jlogic::compile(json({{"not_present", json::array()}})), + jlogic::JsonLogicCompileError); +} + TEST(JsonLogic, Umd0018ConstraintShapes) { EXPECT_EQ(eval(json({{"in", json::array({"$name", json::array({"amd", "xilinx"})})}})), diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index 8ec17b99ae74..396e586de4fd 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -84,11 +85,14 @@ std::vector contiguousStrides(const std::vector& dim } // Build a single-node SDPA-forward graph with the given per-tensor dims and -// dtype (contiguous strides), returning the owning builder. +// dtype (contiguous strides), returning the owning builder. `withScale` adds +// the optional `scale` operand as a pass-by-value tensor carrying a +// compile-time Float32 value of 1.0. flatbuffers::FlatBufferBuilder buildSdpaGraph(const std::vector& dims, data::DataType dtype, bool withAttnMask = false, - bool overrideShape = false) + bool overrideShape = false, + bool withScale = false) { const std::vector strides = contiguousStrides(dims); return hipdnn_test_sdk::utilities::createValidSdpaFwdGraph(dims, @@ -101,7 +105,7 @@ flatbuffers::FlatBufferBuilder buildSdpaGraph(const std::vector& d strides, dtype, withAttnMask, - /*withScale=*/false, + withScale, /*withStats=*/false, /*alibiMask=*/false, /*paddingMask=*/false, @@ -599,17 +603,29 @@ json sdpaKernelDescriptor() TEST(UmdCompiler, AcceptsKernelReference) { // A criterion over a $kernel field compiles (DYNAMIC wildcard) and the - // descriptor is flagged as referencing kernel metadata. + // field name it reads is recorded. umd::CompiledUmd c; ASSERT_NO_THROW(c = umd::UmdCompiler::compile(sdpaKernelDescriptor())); - EXPECT_TRUE(c.referencesKernelMetadata); + EXPECT_EQ(c.kernelFields, (std::set{"head_dim"})); EXPECT_TRUE(c.boundSymbols.count("kernel.head_dim") == 1); } TEST(UmdCompiler, StockDescriptorDoesNotReferenceKernel) { const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); - EXPECT_FALSE(c.referencesKernelMetadata); + EXPECT_TRUE(c.kernelFields.empty()); +} + +TEST(UmdCompiler, RecordsEveryDistinctKernelFieldRead) +{ + // The memoization key is the set of `$kernel.*` fields the matcher reads, + // without the `kernel.` prefix; a field read twice contributes once, and a + // subscripted path contributes its subscript form (RFC 0017 §5). + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$kernel.tile_m", 64]})")); + d["criteria"]["and"].push_back(json::parse(R"({"<": ["$kernel.tile_m", "$kernel.tile_n"]})")); + const umd::CompiledUmd c = umd::UmdCompiler::compile(d); + EXPECT_EQ(c.kernelFields, (std::set{"tile_m", "tile_n"})); } TEST(UmdCompiler, KernelFieldUnifiesWithAnyScalarDomain) @@ -674,6 +690,27 @@ TEST(UniversalGraphMatcher, ReportsKernelMetadataReference) EXPECT_FALSE(m.referencesKernelMetadata()); } +TEST(UniversalGraphMatcher, PublishesKernelFieldsItReads) +{ + // The set is the loader's KMD existence check and the per-kernel + // memoization key (RFC 0017 §5): exactly the names read, prefix stripped. + const umd::UniversalGraphMatcher mk(sdpaKernelDescriptor()); + EXPECT_EQ(mk.kernelFields(), (std::set{"head_dim"})); + // A graph-only matcher reads none, which is what makes the two-argument + // match() overload legal for it. + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + EXPECT_TRUE(m.kernelFields().empty()); +} + +TEST(UniversalGraphMatcher, PublishesBoundSymbols) +{ + // A pack's dispatch descriptor is checked against the fields its matchers + // bind, so the set must be reachable from the matcher (RFC 0017 §6). + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + EXPECT_EQ(m.boundSymbols(), m.descriptor().boundSymbols); + EXPECT_TRUE(m.boundSymbols().count("q.head_size") == 1); +} + TEST(UniversalGraphMatcher, TwoArgMatchThrowsWhenDescriptorNeedsKernel) { const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); @@ -714,3 +751,445 @@ TEST(UniversalGraphMatcher, EmptyKernelMetadataDeclinesComparison) fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); EXPECT_NO_THROW({ EXPECT_FALSE(m.match(K_DEVICE, g, json::object()).matched); }); } + +// ---- Descriptor versions (RFC 0017 §4 / A.10 §1) ------------------------- + +TEST(UmdCompiler, AcceptsAndPublishesWellFormedVersions) +{ + json d = sdpaDescriptor(); + d["version"] = "1.0"; + d["sdk_version"] = "1.2"; + umd::CompiledUmd c; + ASSERT_NO_THROW(c = umd::UmdCompiler::compile(d)); + EXPECT_EQ(c.version, "1.0"); + EXPECT_EQ(c.sdkVersion, "1.2"); +} + +TEST(UmdCompiler, VersionKeysAreOptional) +{ + // A descriptor omitting both still compiles; the fields read empty so a + // loader can tell "undeclared" from "declared 1.0". + const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); + EXPECT_TRUE(c.version.empty()); + EXPECT_TRUE(c.sdkVersion.empty()); +} + +TEST(UmdCompiler, RejectsMalformedVersion) +{ + for(const char* bad : {"1", "1.0.0", "1.", ".0", "+1.0", "-1.0", "1.x", "one.zero", ""}) + { + json d = sdpaDescriptor(); + d["version"] = bad; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError) << bad; + json e = sdpaDescriptor(); + e["sdk_version"] = bad; + EXPECT_THROW(umd::UmdCompiler::compile(e), umd::UmdCompileError) << bad; + } +} + +TEST(UmdCompiler, RejectsNonStringVersion) +{ + json d = sdpaDescriptor(); + d["version"] = 1.0; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsSdkVersionBelowFloor) +{ + // The graph schema the matcher was authored against predates what this + // compiler serves, so it is declined before it runs. + json d = sdpaDescriptor(); + d["sdk_version"] = "0.9"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, RejectsVersionBelowFloor) +{ + json d = sdpaDescriptor(); + d["version"] = "0.1"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UmdCompiler, VersionFloorIsNumericNotLexicographic) +{ + // "1.10" is above "1.9" numerically but below it as text; a lexicographic + // comparison would decline it. + EXPECT_TRUE(umd::UmdCompiler::versionAtLeast("1.10", "1.9")); + EXPECT_FALSE(umd::UmdCompiler::versionAtLeast("1.9", "1.10")); + EXPECT_FALSE(umd::UmdCompiler::versionAtLeast("2.0", "10.0")); + EXPECT_TRUE(umd::UmdCompiler::versionAtLeast("10.0", "2.0")); + // equal is at the floor, not below it + EXPECT_TRUE(umd::UmdCompiler::versionAtLeast("1.0", "1.0")); + // an unparseable version is below every floor (fail closed) + EXPECT_FALSE(umd::UmdCompiler::versionAtLeast("1", "1.0")); + + json d = sdpaDescriptor(); + d["sdk_version"] = "1.10"; + d["version"] = "1.10"; + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); +} + +// ---- present / not_present (RFC 0017 §5) --------------------------------- + +namespace +{ + +// The SDPA descriptor with its `.present` field gates on the three optional +// operands replaced by the `not_present` operator over the same list. +json sdpaNotPresentDescriptor() +{ + json d = sdpaDescriptor(); + json& crit = d["criteria"]["and"]; + // Drop the three trailing `{"!": "$x.present"}` field-form gates. + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.push_back( + json::parse(R"({"not_present": ["$attn_mask", "$page_table_k", "$page_table_v"]})")); + return d; +} + +} // namespace + +TEST(UmdCompiler, AcceptsPresenceOperators) +{ + EXPECT_NO_THROW(umd::UmdCompiler::compile(sdpaNotPresentDescriptor())); + + // `present`/`not_present` are the general form: unlike the `.present` + // FIELD, they apply to a required operand too. + for(const char* crit : {R"({"present": ["$q"]})", + R"({"not_present": ["$attn_mask"]})", + R"({"present": ["$q", "$k", "$v"]})", + R"({"or": [{"not_present": ["$attn_mask"]}, {"present": ["$q"]}]})"}) + { + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(crit)); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)) << crit; + } +} + +TEST(UmdCompiler, RejectsPresenceOperatorOnNonReference) +{ + for(const char* bad : {R"({"present": [4]})", + R"({"present": ["BFLOAT16"]})", + R"({"not_present": [{"+": ["$q.rank", 1]}]})", + R"({"present": ["$q", 4]})", + R"({"present": []})"}) + { + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(bad)); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError) << bad; + } +} + +TEST(UmdCompiler, RejectsPresenceOperatorOnUnresolvedReference) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"present": ["$not_a_tensor"]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(UniversalGraphMatcher, NotPresentOperatorMatchesWhenOptionalsAbsent) +{ + const umd::UniversalGraphMatcher m(sdpaNotPresentDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, NotPresentOperatorDeclinesWhenAnyOptionalBound) +{ + // The n-ary form is an `and`-fold: one supplied operand out of the list + // decides the whole call. + const umd::UniversalGraphMatcher m(sdpaNotPresentDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16, /*withAttnMask=*/true); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, PresenceOperatorsEvaluateRatherThanDecline) +{ + // The key distinction from a bare field read: a field read on an absent + // optional resolves null and declines, but `present`/`not_present` always + // produce a boolean, so a criterion built only from them still matches. + json d = sdpaDescriptor(); + json& crit = d["criteria"]["and"]; + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + // `$attn_mask` is absent; `present` on it is false, so `!` of it is true. + crit.push_back(json::parse(R"({"!": {"present": ["$attn_mask"]}})")); + // `$q` is a required operand and always bound. + crit.push_back(json::parse(R"({"present": ["$q", "$k", "$v", "$o"]})")); + + const umd::UniversalGraphMatcher m(d); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, PresentOperatorSeesABoundOptional) +{ + // Same descriptor, but the graph supplies `attn_mask`: `present` now reads + // true and the criterion declines, proving it tracks binding rather than + // returning a constant. + json d = sdpaDescriptor(); + json& crit = d["criteria"]["and"]; + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.push_back(json::parse(R"({"!": {"present": ["$attn_mask"]}})")); + + const umd::UniversalGraphMatcher m(d); + auto bound = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16, /*withAttnMask=*/true); + fbu::GraphWrapper const gb(bound.GetBufferPointer(), bound.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, gb).matched); + + auto absent = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const ga(absent.GetBufferPointer(), absent.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, ga).matched); +} + +// ---- value_or_default with an expression fallback (RFC 0017 §5) ---------- + +TEST(UmdCompiler, ValueOrDefaultAcceptsFieldReferenceFallback) +{ + // "this field, else that one": both arms are Int, so the pair unifies. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back( + json::parse(R"({"==": [{"value_or_default": ["$q.head_size", "$k.head_size"]}, 128]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); +} + +TEST(UmdCompiler, ValueOrDefaultAcceptsDynamicArm) +{ + // A `$kernel.*` arm is DYNAMIC, so the other arm supplies the result kind + // and the comparison against an Int still type-checks. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back( + json::parse(R"({"==": [{"value_or_default": ["$kernel.tile_m", 64]}, 128]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); + json e = sdpaDescriptor(); + e["criteria"]["and"].push_back( + json::parse(R"({"==": [{"value_or_default": ["$q.head_size", "$kernel.tile_m"]}, 128]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(e)); +} + +TEST(UmdCompiler, RejectsTypeIncompatibleValueOrDefaultArms) +{ + for(const char* bad : + {R"({"==": [{"value_or_default": ["$q.head_size", "BFLOAT16"]}, 128]})", + R"({"==": [{"value_or_default": ["$q.dtype", 4]}, "BFLOAT16"]})", + R"({"==": [{"value_or_default": ["$q.head_size", "$q.stride_order"]}, 128]})"}) + { + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(bad)); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError) << bad; + } +} + +TEST(UniversalGraphMatcher, ValueOrDefaultFallsBackToTheSecondField) +{ + // `$sdpa_fwd.dropout_probability` is an unset optional attribute, so the + // fallback expression is evaluated and its value is what the comparison + // sees. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse( + R"({"==": [{"value_or_default": ["$sdpa_fwd.dropout_probability", "$q.head_size"]}, 128]})")); + const umd::UniversalGraphMatcher m(d); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, g).matched); +} + +// ---- $graph.is_override_shape_enabled (RFC 0017 §5) ---------------------- + +TEST(UmdCompiler, AcceptsGraphOverrideShapeFlag) +{ + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"!": "$graph.is_override_shape_enabled"})")); + umd::CompiledUmd c; + ASSERT_NO_THROW(c = umd::UmdCompiler::compile(d)); + EXPECT_TRUE(c.boundSymbols.count("graph.is_override_shape_enabled") == 1); +} + +TEST(UmdCompiler, RejectsGraphOverrideShapeFlagAgainstNonBool) +{ + // The flag is Bool; comparing it to an int is a static type error. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back( + json::parse(R"({"==": ["$graph.is_override_shape_enabled", 1]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(BindingContext, ResolvesGraphOverrideShapeFlag) +{ + // The descriptor key `allow_override_shape` is the MATCHER's opt-in; the + // `$graph.is_override_shape_enabled` token is the GRAPH's state. A matcher + // that opts in still sees the graph's flag. + json d = sdpaDescriptor(); + d["allow_override_shape"] = true; + const umd::UniversalGraphMatcher m(d); + + auto plain = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const gp(plain.GetBufferPointer(), plain.GetSize()); + const umd::MatchResult rp = m.match(K_DEVICE, gp); + ASSERT_TRUE(rp.matched); + EXPECT_TRUE(rp.bindings.get("$graph.is_override_shape_enabled").isBool()); + EXPECT_FALSE(rp.bindings.get("$graph.is_override_shape_enabled").asBool()); + + auto overriden = buildSdpaGraph({2, 8, 16, 128}, + data::DataType::BFLOAT16, + /*withAttnMask=*/false, + /*overrideShape=*/true); + fbu::GraphWrapper const go(overriden.GetBufferPointer(), overriden.GetSize()); + const umd::MatchResult ro = m.match(K_DEVICE, go); + ASSERT_TRUE(ro.matched); + EXPECT_TRUE(ro.bindings.get("$graph.is_override_shape_enabled").asBool()); +} + +TEST(UniversalGraphMatcher, DeclinesOnGraphOverrideShapeFlagCriterion) +{ + // An opted-in matcher can still refuse an override-shape graph by reading + // the graph's own flag. + json d = sdpaDescriptor(); + d["allow_override_shape"] = true; + d["criteria"]["and"].push_back(json::parse(R"({"!": "$graph.is_override_shape_enabled"})")); + const umd::UniversalGraphMatcher m(d); + + auto overriden = buildSdpaGraph({2, 8, 16, 128}, + data::DataType::BFLOAT16, + /*withAttnMask=*/false, + /*overrideShape=*/true); + fbu::GraphWrapper const go(overriden.GetBufferPointer(), overriden.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, go).matched); + + auto plain = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const gp(plain.GetBufferPointer(), plain.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gp).matched); +} + +// ---- $q.is_runtime_pass_by_value / $q.value_f32 (RFC 0017 §5) ------------ + +namespace +{ + +// The SDPA descriptor with the optional `scale` operand bound, so the tensor +// carrying a compile-time value is reachable as `$scale`. +json sdpaScaleDescriptor() +{ + json d = sdpaDescriptor(); + d["nodes"][0]["operands"]["scale"] = "$scale?"; + return d; +} + +} // namespace + +TEST(UmdCompiler, AcceptsTensorValueFields) +{ + json d = sdpaScaleDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"!": "$scale.is_runtime_pass_by_value"})")); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$scale.value_f32", 1.0]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); +} + +TEST(UmdCompiler, TensorValueFieldsAreTyped) +{ + // is_runtime_pass_by_value is Bool and value_f32 is Float; using either in + // the wrong domain is a static type error. + json d = sdpaScaleDescriptor(); + d["criteria"]["and"].push_back( + json::parse(R"({"==": ["$scale.is_runtime_pass_by_value", 1]})")); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); + + json e = sdpaScaleDescriptor(); + e["criteria"]["and"].push_back(json::parse(R"({"==": ["$scale.value_f32", "BFLOAT16"]})")); + EXPECT_THROW(umd::UmdCompiler::compile(e), umd::UmdCompileError); + + json f = sdpaScaleDescriptor(); + f["criteria"]["and"].push_back(json::parse(R"({"<": ["$scale.value_f32", 2.0]})")); + EXPECT_NO_THROW(umd::UmdCompiler::compile(f)); +} + +TEST(UmdCompiler, RejectsShapeDimNameCollidingWithTensorValueFields) +{ + // A.10 §7: a `shape` short-hand may not name a dim after a reserved field. + for(const char* name : {"is_runtime_pass_by_value", "value_f32"}) + { + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back( + json{{"shape", json::array({"$o", json::array({"b", "h", "s", name})})}}); + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError) << name; + } +} + +TEST(BindingContext, ResolvesTensorValueFields) +{ + const umd::UniversalGraphMatcher m(sdpaScaleDescriptor()); + auto builder = buildSdpaGraph({2, 8, 16, 128}, + data::DataType::BFLOAT16, + /*withAttnMask=*/false, + /*overrideShape=*/false, + /*withScale=*/true); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + const umd::BindingContext& b = r.bindings; + + // The scale tensor carries a compile-time Float32 value of 1.0, baked into + // the graph rather than supplied per execution. + EXPECT_FALSE(b.get("$scale.is_runtime_pass_by_value").asBool()); + EXPECT_DOUBLE_EQ(b.get("$scale.value_f32").toNumber(), 1.0); + + // A regular data tensor carries neither. + EXPECT_FALSE(b.get("$q.is_runtime_pass_by_value").asBool()); + EXPECT_TRUE(b.get("$q.value_f32").isNull()); +} + +TEST(BindingContext, ValueF32DeclinesWithoutACompileTimeValue) +{ + // "present only when the tensor carries a compile-time value at all": the + // union is NONE, so the token reads null and a criterion over it declines + // rather than seeing a zero that would satisfy a comparison. + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + EXPECT_TRUE(r.bindings.get("$q.value_f32").isNull()); + EXPECT_FALSE(r.bindings.get("$q.value_f32").isNumber()); +} + +TEST(UniversalGraphMatcher, DeclinesOnValueF32OfAValuelessTensor) +{ + // The fail-closed consequence: a criterion reading value_f32 on a tensor + // with no compile-time value declines the whole match. + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.value_f32", 0.0]})")); + const umd::UniversalGraphMatcher m(d); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + +TEST(UniversalGraphMatcher, MatchesOnACompileTimeScaleValue) +{ + json d = sdpaScaleDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$scale.value_f32", 1.0]})")); + const umd::UniversalGraphMatcher m(d); + + auto withScale = buildSdpaGraph({2, 8, 16, 128}, + data::DataType::BFLOAT16, + /*withAttnMask=*/false, + /*overrideShape=*/false, + /*withScale=*/true); + fbu::GraphWrapper const gs(withScale.GetBufferPointer(), withScale.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gs).matched); + + // No scale operand at all -> the field read on an absent optional declines. + auto noScale = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const gn(noScale.GetBufferPointer(), noScale.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, gn).matched); +} From f82fbdbcfdfd825612377aab9d3d891dfb7fe7b5 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 13:31:12 -0600 Subject: [PATCH 52/60] fix(hip-kernel-provider): fail closed on absent operands; correct version gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three RFC 0017 contracts the matcher did not honor. The first is a fail-open bug in the applicability path. `present`/`not_present` were documented as special because they alone do not propagate the null an unresolved path reads. They were in fact the only operators that handled null at all; every other one coerced it, so a field read on an ABSENT optional operand read as 0 / false / not-equal: {"!": "$bias.packed"} => true {"!=": ["$bias.dtype", "BFLOAT16"]} => true {"<": ["$bias.rank", 5]} => true {"==": ["$bias.dtype", "$mask.dtype"]} => true (two absent "equal") Every one of those is a narrowing check that PASSED on an operand the pack cannot serve, so a matcher accepted graphs it had no kernel for. RFC 0017 §5 requires such a check to "neither pass nor fail, it simply does not run". Null now means unresolved and propagates through every operator except the two presence predicates and `value_or_default`, which answer "did this resolve?" and so always yield a real value. `and`/`or` are Kleene: a definite false still decides an `and` and a definite true an `or`, which is what makes RFC 0017's own "absent, or present and constrained" idiom work when the operand is missing. A null root is falsy, so an undecided criterion declines. `/`, `%`, and `ceil_div` decline on a zero divisor instead of yielding inf/NaN, and `log2`/`rsqrt` on a non-positive argument. Version gating was inverted. RFC 0017 §4 makes `version` a CEILING -- reject a differing major or a minor newer than the runtime's, and "a file stamped 1.0 loads on a 1.1 runtime" -- but both keys were gated against a floor, so a conformant older descriptor was refused by a newer runtime, exactly backwards. `sdk_version` is a floor the GRAPH sets, not a constant, and that half was missing entirely; hipDNN already computes it as `min_required_engine_api_version` from the optional features a graph uses, so the matcher reads that rather than deriving a second source of truth. An omitted key now reads "1.0" instead of empty, so the per-graph comparison always has a concrete version. `$q.value_f32` dropped FP8_E8M0 -- an 8-bit format its own comment claimed to cover -- to the fail-closed null, and decoded INT8 through UINT8's unsigned widening, so a stored -1 read as 255.0. Tests: the two that encoded the inverted floor now assert the ceiling and the older-minor case. New coverage for the fail-open forms, the Kleene short-circuits, the per-graph floor, and the seven value-union arms plus the fp8 decoder, none of which had any. (cherry picked from commit 0c6df1c40e30b9bc84893c4fe4865e43a0ef2507) (cherry picked from commit 7731a2830724ef4b1735648783cfe2b98e0d5f14) --- .../hip_kernel_provider_common/JsonLogic.hpp | 264 ++++++++--- .../hip_kernel_provider_common/JsonLogic.md | 37 +- .../umd/BindingContext.hpp | 8 +- .../umd/UmdCompiler.hpp | 121 +++-- .../umd/UniversalGraphMatcher.hpp | 29 ++ .../src/tests/core/TestJsonLogic.cpp | 64 +++ .../tests/core/TestUniversalGraphMatcher.cpp | 435 +++++++++++++++++- 7 files changed, 831 insertions(+), 127 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp index dd5ae8ec2bec..161d24ec7b86 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp @@ -42,6 +42,16 @@ // hipDNN UMD needs (ceil_div, abs, pow, log2, rsqrt). // Collection/string operators (map/reduce/filter/cat/substr/...) are not // included. +// +// Null is "unresolved", not a value (extension over stock JsonLogic) +// ----------------------------------------------------------------- +// Every operator except `present`, `not_present`, and `value_or_default` +// propagates a null argument instead of coercing it, and `and`/`or` are +// three-valued: a definite false still decides an `and`, a definite true a +// `or`. The consumer is the hipDNN UMD matcher, where an unresolved path is an +// absent optional operand; a coerced null would read as false/0/not-equal and +// make a narrowing criterion silently PASS on data the pack never saw. A null +// root is falsy, so an undecided expression declines the match. #include @@ -514,61 +524,123 @@ struct OpNode final : Node Op op; std::vector args; + // Null is "unresolved", not a value: an absent optional operand's field + // read must neither pass nor fail a criterion (RFC 0017 §5, RFC 0018 A.4). + // Every operator below therefore propagates a null operand instead of + // coercing it (null would otherwise read as 0 / false / not-equal, so a + // narrowing check on an absent operand would silently PASS). `present`, + // `not_present`, `value_or_default`, and the short-circuit halves of + // `and`/`or` are the deliberate exceptions: they answer "did this + // resolve?", so they always yield a real value. The root null declines the + // match, because Value::truthy() reads null as false. + static bool anyNull(const std::vector& v) + { + for(const Value& x : v) + { + if(x.isNull()) + { + return true; + } + } + return false; + } + + // Evaluate every argument once, so a null is detected before the operator + // coerces it and so each argument is evaluated exactly once. + std::vector evalArgs(const IDataSource& d) const + { + std::vector out; + out.reserve(args.size()); + for(const auto& c : args) + { + out.push_back(c->eval(d)); + } + return out; + } + Value eval(const IDataSource& d) const override { switch(op) { case Op::ADD: { + const std::vector v = evalArgs(d); + if(anyNull(v)) + { + return {}; + } double a = 0.0; - for(const auto& c : args) + for(const Value& x : v) { - a += c->eval(d).toNumber(); + a += x.toNumber(); } return Value::number(a); } case Op::MUL: { + const std::vector v = evalArgs(d); + if(anyNull(v)) + { + return {}; + } double a = 1.0; - for(const auto& c : args) + for(const Value& x : v) { - a *= c->eval(d).toNumber(); + a *= x.toNumber(); } return Value::number(a); } case Op::SUB: { - if(args.size() == 1) + const std::vector v = evalArgs(d); + if(anyNull(v)) { - return Value::number(-args[0]->eval(d).toNumber()); + return {}; } - return Value::number(args[0]->eval(d).toNumber() - args[1]->eval(d).toNumber()); + if(v.size() == 1) + { + return Value::number(-v[0].toNumber()); + } + return Value::number(v[0].toNumber() - v[1].toNumber()); } case Op::DIV: - return Value::number(args[0]->eval(d).toNumber() / args[1]->eval(d).toNumber()); case Op::MOD: - return Value::number( - std::fmod(args[0]->eval(d).toNumber(), args[1]->eval(d).toNumber())); - case Op::MIN: + case Op::CEIL_DIV: { - double best = std::nan(""); - for(const auto& c : args) + const std::vector v = evalArgs(d); + if(anyNull(v)) { - const double n = c->eval(d).toNumber(); - if(std::isnan(best) || n < best) - { - best = n; - } + return {}; } - return Value::number(best); + const double num = v[0].toNumber(); + const double den = v[1].toNumber(); + if(den == 0.0) + { + return {}; // zero divisor declines rather than yielding inf/NaN + } + if(op == Op::DIV) + { + return Value::number(num / den); + } + if(op == Op::MOD) + { + return Value::number(std::fmod(num, den)); + } + return Value::number(std::ceil(num / den)); } + case Op::MIN: case Op::MAX: { + const std::vector v = evalArgs(d); + if(anyNull(v)) + { + return {}; + } double best = std::nan(""); - for(const auto& c : args) + for(const Value& x : v) { - const double n = c->eval(d).toNumber(); - if(std::isnan(best) || n > best) + const double n = x.toNumber(); + if(std::isnan(best) || (op == Op::MIN ? n < best : n > best)) { best = n; } @@ -576,50 +648,69 @@ struct OpNode final : Node return Value::number(best); } case Op::LT: + case Op::LE: + case Op::GT: + case Op::GE: { - const Value a = args[0]->eval(d); - const Value b = args[1]->eval(d); - if(args.size() >= 3) + const std::vector v = evalArgs(d); + if(anyNull(v)) { - return {Value::compare(a, b) == -1 && Value::compare(b, args[2]->eval(d)) == -1}; + return {}; } - return {Value::compare(a, b) == -1}; - } - case Op::LE: - { - const Value a = args[0]->eval(d); - const Value b = args[1]->eval(d); - const auto le = [](const Value& x, const Value& y) { + const auto ordered = [this](const Value& x, const Value& y) { const int c = Value::compare(x, y); - return c == -1 || c == 0; + switch(op) + { + case Op::LT: + return c == -1; + case Op::LE: + return c == -1 || c == 0; + case Op::GT: + return c == 1; + default: + return c == 1 || c == 0; + } }; - if(args.size() >= 3) + // The 3-arg form is the between-chain: a < b < c. + if(v.size() >= 3) { - return {le(a, b) && le(b, args[2]->eval(d))}; + return {ordered(v[0], v[1]) && ordered(v[1], v[2])}; } - return {le(a, b)}; - } - case Op::GT: - return {Value::compare(args[0]->eval(d), args[1]->eval(d)) == 1}; - case Op::GE: - { - const int c = Value::compare(args[0]->eval(d), args[1]->eval(d)); - return {c == 1 || c == 0}; + return {ordered(v[0], v[1])}; } case Op::EQ: - return {args[0]->eval(d) == args[1]->eval(d)}; case Op::NEQ: - return {args[0]->eval(d) != args[1]->eval(d)}; + { + const std::vector v = evalArgs(d); + // Two unresolved references are not "equal"; the question is + // unanswerable, so it declines instead of accepting. + if(anyNull(v)) + { + return {}; + } + return {op == Op::EQ ? v[0] == v[1] : v[0] != v[1]}; + } case Op::NOT: - return {!args[0]->eval(d).truthy()}; case Op::NOT_NOT: - return {args[0]->eval(d).truthy()}; + { + const Value a = args[0]->eval(d); + if(a.isNull()) + { + return {}; // negating an unresolved reference stays unresolved + } + return {op == Op::NOT ? !a.truthy() : a.truthy()}; + } case Op::IF: { std::size_t i = 0; for(; i + 1 < args.size(); i += 2) { - if(args[i]->eval(d).truthy()) + const Value cond = args[i]->eval(d); + if(cond.isNull()) + { + return {}; // an unresolved condition picks no branch + } + if(cond.truthy()) { return args[i + 1]->eval(d); } @@ -628,34 +719,59 @@ struct OpNode final : Node } case Op::AND: { + // Kleene `and`: a definite false short-circuits even when another + // argument is unresolved, so `and`-ing an inapplicable check beside + // a failing one still declines. Otherwise a null makes the whole + // conjunction unresolved. Value cur(true); + bool sawNull = false; for(const auto& c : args) { cur = c->eval(d); + if(cur.isNull()) + { + sawNull = true; + continue; + } if(!cur.truthy()) { - return cur; // first falsy + return cur; // definite false } } - return cur; // last value + return sawNull ? Value() : cur; } case Op::OR: { + // Kleene `or`: a definite true short-circuits past an unresolved + // argument, which is what lets + // `{"or": [{"not_present": ["$bias"]}, {"==": ["$bias.dtype", ...]}]}` + // accept a bias-free graph even though the second arm cannot run. Value cur; + bool sawNull = false; for(const auto& c : args) { cur = c->eval(d); + if(cur.isNull()) + { + sawNull = true; + continue; + } if(cur.truthy()) { - return cur; // first truthy + return cur; // definite true } } - return cur; // last value + return sawNull ? Value() : cur; } case Op::IN: { - const Value needle = args[0]->eval(d); - const Value hay = args[1]->eval(d); + const std::vector v = evalArgs(d); + if(anyNull(v)) + { + return {}; + } + const Value& needle = v[0]; + const Value& hay = v[1]; if(hay.isArray()) { for(const auto& e : hay.asArray()) @@ -674,21 +790,35 @@ struct OpNode final : Node } return {false}; } - case Op::CEIL_DIV: - { - const double num = args[0]->eval(d).toNumber(); - const double den = args[1]->eval(d).toNumber(); - return Value::number(std::ceil(num / den)); - } case Op::ABS: - return Value::number(std::fabs(args[0]->eval(d).toNumber())); - case Op::POW: - return Value::number( - std::pow(args[0]->eval(d).toNumber(), args[1]->eval(d).toNumber())); case Op::LOG2: - return Value::number(std::log2(args[0]->eval(d).toNumber())); case Op::RSQRT: - return Value::number(1.0 / std::sqrt(args[0]->eval(d).toNumber())); + { + const Value a = args[0]->eval(d); + if(a.isNull()) + { + return {}; + } + const double n = a.toNumber(); + if(op == Op::ABS) + { + return Value::number(std::fabs(n)); + } + if(n <= 0.0) + { + return {}; // log2/rsqrt decline on a non-positive argument + } + return Value::number(op == Op::LOG2 ? std::log2(n) : 1.0 / std::sqrt(n)); + } + case Op::POW: + { + const std::vector v = evalArgs(d); + if(anyNull(v)) + { + return {}; + } + return Value::number(std::pow(v[0].toNumber(), v[1].toNumber())); + } case Op::VALUE_OR_DEFAULT: { // First arg is a variable reference; a null result means the path diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md index 6105dc7f7e0b..c740553e4f15 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md @@ -119,9 +119,12 @@ false (integers and doubles of equal value still compare equal). Ordering (`<`/`<=`/`>`/`>=`) still uses numeric coercion. Arithmetic: `+`, `-` (binary and unary negation), `*`, `/`, `%`, `min`, `max`. +`/`, `%`, and `ceil_div` decline (yield `null`) on a zero divisor rather than +producing `inf`/`NaN`. Math extensions (value-core, for hipDNN dispatch/constraint formulas): `ceil_div` -(2-arg ceiling division), `abs`, `pow`, `log2`, `rsqrt` (`1/sqrt(x)`). +(2-arg ceiling division), `abs`, `pow`, `log2`, `rsqrt` (`1/sqrt(x)`). `log2` and +`rsqrt` decline on a non-positive argument. Membership: `in`. `{"in": [needle, array]}` is true when `array` contains `needle` (strict element equality); `{"in": [needle, string]}` is a substring @@ -131,12 +134,32 @@ Presence: `present`, `not_present`. `{"present": ["$a", "$b"]}` is true when every listed path resolves to a non-null value; `{"not_present": [...]}` is true when every listed path resolves to null. Both are `and`-folds, so one call decides a whole list. They key on the same *existence* mechanism as -`value_or_default`, and are the only operators that do not propagate a null: an -unresolved path yields `false`/`true` rather than `null`, because answering -"was this supplied?" is their whole job. - -Value semantics follow JsonLogic/JS: `false`, `0`, `""`, `null` and the empty -array are falsy; `Number()`-style coercion drives arithmetic and ordering. +`value_or_default`, and, with `value_or_default`, are the only operators that do +not propagate a null: an unresolved path yields `false`/`true` rather than +`null`, because answering "was this supplied?" is their whole job. + +## Null is unknown, and it propagates + +`null` means *unresolved*, not a value. Every operator except `present`, +`not_present`, and `value_or_default` returns `null` when any argument is +`null`, rather than coercing it to `false`/`0`/not-equal. This matters because +the consumer is hipDNN's UMD matcher, where an unresolved path is an absent +optional operand: if `null` coerced, a narrowing check such as +`{"!=": ["$bias.dtype", "BFLOAT16"]}` would evaluate **true** on a graph with no +bias and the matcher would accept input it cannot serve. Two `null`s are not +equal to each other either — the question is unanswerable, so `==` and `!=` both +decline. + +`and` and `or` are three-valued (Kleene): a definite `false` still decides an +`and` and a definite `true` still decides an `or`, even beside a `null` +argument; otherwise the result is `null`. That is what lets +`{"or": [{"not_present": ["$bias"]}, {"and": [{"present": ["$bias"]}, ...]}]}` +accept an absent operand whose field reads cannot run. + +Once every argument resolves, value semantics follow JsonLogic/JS: `false`, `0`, +`""`, `null` and the empty array are falsy; `Number()`-style coercion drives +arithmetic and ordering. A `null` root is falsy, so an undecided expression +declines. Malformed rules (unknown operator, wrong argument count, a non-operator object) raise `JsonLogicCompileError` at `compile` time, so evaluation stays on the fast diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp index 788d664e360e..182798f1938f 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -422,9 +423,14 @@ class BindingContext return {static_cast(static_cast(types::fp8_e4m3_fnuz::from_bits(bits)))}; case data::DataType::FP8_E5M2_FNUZ: return {static_cast(static_cast(types::fp8_e5m2_fnuz::from_bits(bits)))}; + case data::DataType::FP8_E8M0: + return {static_cast(static_cast(types::fp8_e8m0::from_bits(bits)))}; case data::DataType::UINT8: - case data::DataType::INT8: return {static_cast(bits)}; + case data::DataType::INT8: + // Reinterpret the raw byte as signed: an INT8 -1 is stored as 0xFF + // and must read as -1.0, not 255.0. + return {static_cast(static_cast(bits))}; default: break; } diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp index a424efdcd8bf..738f1d776d85 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -160,27 +160,77 @@ class UmdCompiler return c.run(descriptor); } - // The oldest matcher format version and hipDNN graph schema (SDK) version - // this compiler accepts. A descriptor declaring less is declined before it - // runs (RFC 0017 §4 / A.10 §1). - static constexpr const char* K_MIN_VERSION = "1.0"; - static constexpr const char* K_MIN_SDK_VERSION = "1.0"; - - // True when the "." `version` is at or above `floor`. - // Compared numerically on (major, minor), never lexicographically, so - // "1.10" ranks above "1.9". A version that does not parse is below every - // floor (fail closed). - static bool versionAtLeast(const std::string& version, const std::string& floor) + // The matcher format version this compiler implements. Gating is a + // CEILING, not a floor (RFC 0017 §4): a descriptor whose major differs, or + // whose minor is newer than this runtime's, is refused, because it carries + // features this runtime cannot understand. An older minor within the same + // major always loads -- a file stamped "1.0" loads on a "1.1" runtime. + static constexpr const char* K_RUNTIME_VERSION = "1.0"; + + // The hipDNN graph schema (SDK) version this compiler understands. Unlike + // the format version, `sdk_version` is gated against a floor the GRAPH + // sets, not a constant: a graph reports the schema version its own + // contents require, and a matcher declaring less is declined before it runs + // (RFC 0017 §4). This constant is only the ceiling half -- the upper bound + // on what a matcher may claim to understand. The per-graph floor is applied + // by UniversalGraphMatcher via graphRequiredSdkVersion(). + static constexpr const char* K_RUNTIME_SDK_VERSION = "1.2"; + + // The version an omitted `version` / `sdk_version` key means: the lowest + // this format ever had, which is what a descriptor authored before either + // key existed implies (RFC 0018 A.1). + static constexpr const char* K_DEFAULT_VERSION = "1.0"; + + // Parsed ".". A version that does not parse is not ordered + // against anything; callers treat that as a refusal (fail closed). + struct SemVer { std::size_t major = 0; std::size_t minor = 0; - std::size_t floorMajor = 0; - std::size_t floorMinor = 0; - if(!parseVersion(version, major, minor) || !parseVersion(floor, floorMajor, floorMinor)) + + bool operator<(const SemVer& o) const + { + return major != o.major ? major < o.major : minor < o.minor; + } + bool operator<=(const SemVer& o) const + { + return !(o < *this); + } + }; + + // Parse "."; false when the string is not exactly two + // digit-only components. + static bool parseSemVer(const std::string& s, SemVer& out) + { + return parseVersion(s, out.major, out.minor); + } + + // True when `version` is at or above `floor`. Compared numerically on + // (major, minor), never lexicographically, so "1.10" ranks above "1.9". A + // version that does not parse is below every floor (fail closed). + static bool versionAtLeast(const std::string& version, const std::string& floor) + { + SemVer v; + SemVer f; + if(!parseSemVer(version, v) || !parseSemVer(floor, f)) + { + return false; + } + return f <= v; + } + + // True when a descriptor stamped `version` is loadable on a runtime + // implementing `runtime`: same major, and a minor no newer than the + // runtime's (RFC 0017 §4). An unparseable version loads nowhere. + static bool versionLoadableOn(const std::string& version, const std::string& runtime) + { + SemVer v; + SemVer r; + if(!parseSemVer(version, v) || !parseSemVer(runtime, r)) { return false; } - return major > floorMajor || (major == floorMajor && minor >= floorMinor); + return v.major == r.major && v.minor <= r.minor; } private: @@ -248,9 +298,14 @@ class UmdCompiler validateTopLevel(d); out.id = d.at("id").get(); out.name = d.at("name").get(); - out.version = d.contains("version") ? d.at("version").get() : std::string(); - out.sdkVersion - = d.contains("sdk_version") ? d.at("sdk_version").get() : std::string(); + // Both version keys are optional. An omitted key means "1.0", the + // version every descriptor authored against this revision implies + // (RFC 0018 A.1), so the published value is never empty and the + // per-graph SDK floor has a concrete version to compare against. + out.version = d.contains("version") ? d.at("version").get() + : std::string(K_DEFAULT_VERSION); + out.sdkVersion = d.contains("sdk_version") ? d.at("sdk_version").get() + : std::string(K_DEFAULT_VERSION); out.allowOverrideShape = d.contains("allow_override_shape") ? d.at("allow_override_shape").get() : false; @@ -325,8 +380,8 @@ class UmdCompiler { throw UmdCompileError("allow_override_shape must be a boolean"); } - validateVersionKey(d, "version", K_MIN_VERSION); - validateVersionKey(d, "sdk_version", K_MIN_SDK_VERSION); + validateVersionKey(d, "version", K_RUNTIME_VERSION); + validateVersionKey(d, "sdk_version", K_RUNTIME_SDK_VERSION); if(!d.at("nodes").is_array() || d.at("nodes").empty()) { throw UmdCompileError("nodes must be a non-empty array (A.1)"); @@ -357,8 +412,13 @@ class UmdCompiler } // Both version keys are optional; when present each must be a - // "." string at or above its floor (A.10 §1). - static void validateVersionKey(const nlohmann::json& d, const char* key, const char* floor) + // "." string this runtime can honor. The gate is a ceiling + // (RFC 0017 §4): same major, minor no newer than the runtime's. An older + // minor always loads, so a descriptor stays loadable on the oldest runtime + // that can serve it. The per-graph `sdk_version` FLOOR is a separate, + // match-time check (UniversalGraphMatcher), because only a graph can say + // which schema version its own contents require. + static void validateVersionKey(const nlohmann::json& d, const char* key, const char* runtime) { if(!d.contains(key)) { @@ -370,17 +430,17 @@ class UmdCompiler + " must be a \".\" string (A.10 §1)"); } const std::string declared = d.at(key).get(); - std::size_t major = 0; - std::size_t minor = 0; - if(!parseVersion(declared, major, minor)) + SemVer parsed; + if(!parseSemVer(declared, parsed)) { throw UmdCompileError(std::string(key) + " \"" + declared + "\" is not a \".\" string (A.10 §1)"); } - if(!versionAtLeast(declared, floor)) + if(!versionLoadableOn(declared, runtime)) { throw UmdCompileError(std::string(key) + " " + declared - + " is below the minimum supported " + floor + " (A.10 §1)"); + + " is not supported by this runtime, which implements " + runtime + + " (A.10 §1)"); } } @@ -800,14 +860,13 @@ class UmdCompiler } // Expand a layout alias to a stride-order array. `$q.stride_order` is - // computed by extractStrideOrder (ApplicabilityChecks.cpp:17) as a per-dim + // computed by extractStrideOrder (ShapeUtilities.hpp) as a per-dim // priority (higher == slower-varying / larger stride), so a contiguous // rank-4 tensor is [3,2,1,0] and NHWC is [3,0,2,1]. The alias arrays match // that encoding -- the same TensorLayout::NCHW/NHWC arrays the real // validateSupportedLayout oracle compares against -- so `stride_order == - // alias` holds against a live graph's strides. (RFC A.8's tables are - // written in the inverse, position->dim convention; the PoC pins to the - // codebase encoding per the plan.) + // alias` holds against a live graph's strides, and it is the encoding + // RFC 0018 A.8 tabulates. static nlohmann::json aliasToArray(const std::string& alias, std::size_t rank) { nlohmann::json arr; diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp index 67d209453ec2..d9e666f7953a 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp @@ -165,12 +165,41 @@ class UniversalGraphMatcher return tryMatch(*_umd, graph, graph.nodeWrappers(), deviceMap, kernel); } + // The hipDNN graph schema (SDK) version this graph's own contents require, + // as "." (RFC 0017 §4). hipDNN already computes exactly this + // floor from the optional features a graph uses and stamps it into the + // serialized graph as `min_required_engine_api_version` (RFC 0008 + // override shapes at 1.1, RFC 0016 runtime pass-by-value at 1.2), so the + // matcher reads that rather than inventing a second source of truth. An + // unstamped graph (a hand-built fixture, or one written before the field + // existed) reads as the 1.0 baseline, matching the plugin SDK's own + // null-tolerant accessor. + static std::string graphRequiredSdkVersion(const IGraph& graph) + { + const auto* required = graph.getGraph().min_required_engine_api_version(); + if(required == nullptr) + { + return std::string(UmdCompiler::K_DEFAULT_VERSION); + } + return std::to_string(required->major()) + "." + std::to_string(required->minor()); + } + MatchResult tryMatch(const CompiledUmd& umd, const IGraph& graph, const std::vector>& wrappers, const std::unordered_map& deviceMap, const nlohmann::json* kernel) const { + // Per-graph SDK floor (RFC 0017 §4). A graph reports the schema version + // its own contents require; a matcher declaring less was authored + // before a feature this graph uses existed, so it is skipped rather + // than asked -- it would otherwise match on the fields it knows and + // silently ignore one that changes what the graph means. + if(!UmdCompiler::versionAtLeast(umd.sdkVersion, graphRequiredSdkVersion(graph))) + { + return {}; + } + // allow_override_shape gate (RFC 0018 §3): decline override-shape graphs // unless the descriptor opts in. if(graph.getGraph().is_override_shape_enabled() && !umd.allowOverrideShape) diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp index 275a91d9d7ff..6f3202369115 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -217,6 +217,70 @@ TEST(JsonLogic, PresenceOperators) jlogic::JsonLogicCompileError); } +TEST(JsonLogic, NullPropagatesThroughEveryOtherOperator) +{ + // An unresolved reference is "unknown", not a value. Every operator except + // the presence pair and value_or_default must yield null rather than + // coerce, because a coerced null reads as false / 0 / not-equal and would + // make a narrowing check silently PASS on data it never saw. + for(const json& rule : {json({{"!", "$nope"}}), + json({{"!!", "$nope"}}), + json({{"!=", json::array({"$nope", 1})}}), + json({{"==", json::array({"$nope", 1})}}), + json({{"<", json::array({"$nope", 5})}}), + json({{"<=", json::array({"$nope", 5})}}), + json({{">", json::array({"$nope", 5})}}), + json({{">=", json::array({"$nope", 0})}}), + json({{"+", json::array({"$nope", 1})}}), + json({{"*", json::array({"$nope", 1})}}), + json({{"-", json::array({"$nope", 1})}}), + json({{"in", json::array({"$nope", json::array({1, 2})})}}), + json({{"ceil_div", json::array({"$nope", 4})}}), + json({{"abs", "$nope"}}), + json({{"min", json::array({"$nope", 1})}}), + json({{"max", json::array({"$nope", 1})}}), + json({{"if", json::array({"$nope", 1, 2})}})}) + { + EXPECT_TRUE(eval(rule).isNull()) << rule.dump(); + } + // Two unresolved references are not "equal": the question is unanswerable. + EXPECT_TRUE(eval(json({{"==", json::array({"$nope", "$missing"})}})).isNull()); + EXPECT_TRUE(eval(json({{"!=", json::array({"$nope", "$missing"})}})).isNull()); +} + +TEST(JsonLogic, KleeneAndOrShortCircuitPastUnknown) +{ + // A definite false decides an `and` even beside an unknown, and a definite + // true decides an `or`. This is what lets "absent, or present and + // constrained" accept an absent operand whose field checks cannot run. + EXPECT_EQ(eval(json({{"and", json::array({false, "$nope"})}})), V(false)); + EXPECT_EQ(eval(json({{"or", json::array({true, "$nope"})}})), V(true)); + // Without a decisive argument the result stays unknown rather than + // collapsing to true/false. + EXPECT_TRUE(eval(json({{"and", json::array({true, "$nope"})}})).isNull()); + EXPECT_TRUE(eval(json({{"or", json::array({false, "$nope"})}})).isNull()); + // A fully-resolved expression is unaffected. + EXPECT_EQ(eval(json({{"and", json::array({true, true})}})), V(true)); + EXPECT_EQ(eval(json({{"or", json::array({false, false})}})), V(false)); +} + +TEST(JsonLogic, DivisionAndDomainErrorsFailClosed) +{ + // A zero divisor declines instead of yielding inf/NaN, giving uniform + // fail-closed zero-guarding (RFC 0018 A.7). + EXPECT_TRUE(eval(json({{"/", json::array({"$x", 0})}})).isNull()); + EXPECT_TRUE(eval(json({{"%", json::array({"$x", 0})}})).isNull()); + EXPECT_TRUE(eval(json({{"ceil_div", json::array({"$x", 0})}})).isNull()); + // log2/rsqrt decline on a non-positive argument rather than returning + // -inf/NaN. + EXPECT_TRUE(eval(json({{"log2", 0}})).isNull()); + EXPECT_TRUE(eval(json({{"rsqrt", 0}})).isNull()); + EXPECT_TRUE(eval(json({{"rsqrt", -4}})).isNull()); + // The well-behaved cases still compute. + EXPECT_EQ(eval(json({{"/", json::array({8, 2})}})), V(4)); + EXPECT_EQ(eval(json({{"log2", 8}})), V(3)); +} + TEST(JsonLogic, Umd0018ConstraintShapes) { EXPECT_EQ(eval(json({{"in", json::array({"$name", json::array({"amd", "xilinx"})})}})), diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index 396e586de4fd..d7cf3264341b 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -367,7 +367,7 @@ TEST(BindingContext, ResolvesEveryNamespaceForm) EXPECT_FALSE(b.get("$q.virtual").asBool()); // stride_order uses extractStrideOrder's per-dim priority encoding - // (ApplicabilityChecks.cpp:17): contiguous rank-4 is [3,2,1,0]. + // (ShapeUtilities.hpp, RFC 0018 A.8): contiguous rank-4 is [3,2,1,0]. const jlogic::Value strideOrder = b.get("$q.stride_order"); ASSERT_TRUE(strideOrder.isArray()); EXPECT_EQ(strideOrder, @@ -765,13 +765,15 @@ TEST(UmdCompiler, AcceptsAndPublishesWellFormedVersions) EXPECT_EQ(c.sdkVersion, "1.2"); } -TEST(UmdCompiler, VersionKeysAreOptional) +TEST(UmdCompiler, VersionKeysAreOptionalAndDefaultToOneZero) { - // A descriptor omitting both still compiles; the fields read empty so a - // loader can tell "undeclared" from "declared 1.0". + // A descriptor omitting both still compiles, and both read "1.0": the + // version a descriptor authored before either key existed implies + // (RFC 0018 A.1). The published value is never empty, so the per-graph SDK + // floor always has a concrete version to compare against. const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); - EXPECT_TRUE(c.version.empty()); - EXPECT_TRUE(c.sdkVersion.empty()); + EXPECT_EQ(c.version, "1.0"); + EXPECT_EQ(c.sdkVersion, "1.0"); } TEST(UmdCompiler, RejectsMalformedVersion) @@ -794,26 +796,56 @@ TEST(UmdCompiler, RejectsNonStringVersion) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsSdkVersionBelowFloor) +TEST(UmdCompiler, RejectsVersionNewerThanTheRuntime) { - // The graph schema the matcher was authored against predates what this - // compiler serves, so it is declined before it runs. - json d = sdpaDescriptor(); - d["sdk_version"] = "0.9"; - EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); + // RFC 0017 §4: a minor newer than the runtime's carries features this + // runtime cannot understand, so the descriptor is refused, never silently + // reinterpreted. + json newerMinor = sdpaDescriptor(); + newerMinor["version"] = "1.9"; + EXPECT_THROW(umd::UmdCompiler::compile(newerMinor), umd::UmdCompileError); + + // A differing major is refused in either direction. + for(const char* major : {"0.9", "2.0"}) + { + json d = sdpaDescriptor(); + d["version"] = major; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError) << major; + } + + // Same for the SDK version's ceiling half. + json newerSdk = sdpaDescriptor(); + newerSdk["sdk_version"] = "1.99"; + EXPECT_THROW(umd::UmdCompiler::compile(newerSdk), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsVersionBelowFloor) +TEST(UmdCompiler, AcceptsAnOlderMinorWithinTheSameMajor) { - json d = sdpaDescriptor(); - d["version"] = "0.1"; - EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); + // RFC 0017 §4: "A file stamped 1.0 loads on a 1.1 runtime." An older minor + // is never refused, so a descriptor stays loadable on the oldest runtime + // that can serve it. This is the case the previous floor-only gate broke. + EXPECT_TRUE(umd::UmdCompiler::versionLoadableOn("1.0", "1.1")); + EXPECT_TRUE(umd::UmdCompiler::versionLoadableOn("1.1", "1.1")); + EXPECT_FALSE(umd::UmdCompiler::versionLoadableOn("1.2", "1.1")); + EXPECT_FALSE(umd::UmdCompiler::versionLoadableOn("2.0", "1.1")); + EXPECT_FALSE(umd::UmdCompiler::versionLoadableOn("0.9", "1.1")); + // an unparseable version loads nowhere (fail closed) + EXPECT_FALSE(umd::UmdCompiler::versionLoadableOn("1", "1.1")); + + // The SDK version's runtime is 1.2, so a matcher authored against the + // older 1.0 and 1.1 schemas still compiles. + for(const char* older : {"1.0", "1.1", "1.2"}) + { + json d = sdpaDescriptor(); + d["sdk_version"] = older; + EXPECT_NO_THROW(umd::UmdCompiler::compile(d)) << older; + } } -TEST(UmdCompiler, VersionFloorIsNumericNotLexicographic) +TEST(UmdCompiler, VersionComparisonIsNumericNotLexicographic) { // "1.10" is above "1.9" numerically but below it as text; a lexicographic - // comparison would decline it. + // comparison would order these backwards. EXPECT_TRUE(umd::UmdCompiler::versionAtLeast("1.10", "1.9")); EXPECT_FALSE(umd::UmdCompiler::versionAtLeast("1.9", "1.10")); EXPECT_FALSE(umd::UmdCompiler::versionAtLeast("2.0", "10.0")); @@ -822,11 +854,112 @@ TEST(UmdCompiler, VersionFloorIsNumericNotLexicographic) EXPECT_TRUE(umd::UmdCompiler::versionAtLeast("1.0", "1.0")); // an unparseable version is below every floor (fail closed) EXPECT_FALSE(umd::UmdCompiler::versionAtLeast("1", "1.0")); + // the ceiling comparison is numeric too: 1.10 is newer than a 1.9 runtime + EXPECT_FALSE(umd::UmdCompiler::versionLoadableOn("1.10", "1.9")); + EXPECT_TRUE(umd::UmdCompiler::versionLoadableOn("1.9", "1.10")); +} + +// ---- Per-graph sdk_version floor (RFC 0017 §4) --------------------------- + +namespace +{ + +// Rebuild an SDPA graph with `min_required_engine_api_version` stamped, the +// field hipDNN computes from the optional features a graph uses (RFC 0008 +// override shapes at 1.1, RFC 0016 runtime pass-by-value at 1.2). The test-SDK +// fixture leaves it unset, so the floor is set explicitly here. +flatbuffers::FlatBufferBuilder buildSdpaGraphRequiringSchema(std::uint32_t major, + std::uint32_t minor) +{ + flatbuffers::FlatBufferBuilder builder; + const std::vector dims{2, 8, 16, 128}; + const std::vector strides = contiguousStrides(dims); + + std::vector<::flatbuffers::Offset> tensors; + for(const auto& [uid, name] : + {std::pair{1, "q"}, {2, "k"}, {3, "v"}, {4, "o"}}) + { + tensors.push_back(data::CreateTensorAttributesDirect( + builder, uid, name, data::DataType::BFLOAT16, &strides, &dims)); + } + + data::SdpaAttributesBuilder sb(builder); + sb.add_q_tensor_uid(1); + sb.add_k_tensor_uid(2); + sb.add_v_tensor_uid(3); + sb.add_o_tensor_uid(4); + const auto sdpa = sb.Finish(); + + std::vector<::flatbuffers::Offset> nodes; + nodes.push_back(data::CreateNodeDirect(builder, + "sdpa_fwd", + data::DataType::BFLOAT16, + data::NodeAttributes::SdpaAttributes, + sdpa.Union())); + + const data::EngineApiVersion required(major, minor, 0); + auto graph = data::CreateGraphDirect(builder, + "test", + data::DataType::FLOAT, + data::DataType::HALF, + data::DataType::BFLOAT16, + &tensors, + &nodes, + flatbuffers::nullopt, + /*is_override_shape_enabled=*/false, + &required); + builder.Finish(graph); + return builder; +} +} // namespace + +TEST(UniversalGraphMatcher, DeclinesAMatcherBelowTheGraphsRequiredSchema) +{ + // RFC 0017 §4: a graph reports the schema version its own contents require, + // and a matcher declaring less is skipped instead of asked -- it would + // otherwise match on the fields it knows and silently ignore a field that + // changes what the graph means. The descriptor is otherwise a full match. json d = sdpaDescriptor(); - d["sdk_version"] = "1.10"; - d["version"] = "1.10"; - EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); + d["sdk_version"] = "1.0"; + const umd::UniversalGraphMatcher m(d); + + auto baseline = buildSdpaGraphRequiringSchema(1, 0); + fbu::GraphWrapper const gBaseline(baseline.GetBufferPointer(), baseline.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gBaseline).matched); + + // The graph sets an optional feature this matcher never accounted for. + auto newer = buildSdpaGraphRequiringSchema(1, 2); + fbu::GraphWrapper const gNewer(newer.GetBufferPointer(), newer.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, gNewer).matched); +} + +TEST(UniversalGraphMatcher, MatcherAtOrAboveTheGraphsRequiredSchemaRuns) +{ + // The same graph, matched by a descriptor that adopted the newer schema. + json d = sdpaDescriptor(); + d["sdk_version"] = "1.2"; + const umd::UniversalGraphMatcher m(d); + + auto newer = buildSdpaGraphRequiringSchema(1, 2); + fbu::GraphWrapper const gNewer(newer.GetBufferPointer(), newer.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gNewer).matched); + + // A matcher above the floor still serves a graph that requires less. + auto baseline = buildSdpaGraphRequiringSchema(1, 0); + fbu::GraphWrapper const gBaseline(baseline.GetBufferPointer(), baseline.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gBaseline).matched); +} + +TEST(UniversalGraphMatcher, AnUnstampedGraphReadsAsTheBaselineFloor) +{ + // A hand-built fixture or a graph written before the field existed carries + // no stamp; it reads as the 1.0 baseline rather than declining every + // matcher, mirroring the plugin SDK's null-tolerant accessor. + const umd::UniversalGraphMatcher m = makeSdpaMatcher(); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, g).matched); } // ---- present / not_present (RFC 0017 §5) --------------------------------- @@ -928,6 +1061,81 @@ TEST(UniversalGraphMatcher, PresenceOperatorsEvaluateRatherThanDecline) EXPECT_TRUE(m.match(K_DEVICE, g).matched); } +// ---- Absent optional operands neither pass nor fail (RFC 0017 §5) -------- + +namespace +{ + +// The SDPA descriptor with its three trailing `.present` field gates dropped +// and `extra` appended, so a single criterion can be exercised against a graph +// that supplies no optional operands. +json sdpaWithCriterion(const char* extra) +{ + json d = sdpaDescriptor(); + json& crit = d["criteria"]["and"]; + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.erase(crit.size() - 1); + crit.push_back(json::parse(extra)); + return d; +} + +} // namespace + +TEST(UniversalGraphMatcher, FieldReadOnAnAbsentOptionalNeverAccepts) +{ + // RFC 0017 §5: "A dtype or layout check on an absent $bias neither passes + // nor fails, it simply does not run." The hazard is the negative and + // ordering forms: if an unresolved read coerced to false/0 instead of + // propagating, each of these would evaluate TRUE and the pack would accept + // a graph carrying an operand its kernel cannot serve. + for(const char* crit : {R"({"!": "$attn_mask.packed"})", + R"({"!=": ["$attn_mask.dtype", "BFLOAT16"]})", + R"({"<": ["$attn_mask.rank", 5]})", + R"({">=": ["$attn_mask.rank", 0]})", + R"({"==": ["$attn_mask.dtype", "$page_table_k.dtype"]})", + R"({"==": [{"+": ["$attn_mask.rank", 1]}, 1]})"}) + { + const umd::UniversalGraphMatcher m(sdpaWithCriterion(crit)); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched) << crit; + } +} + +TEST(UniversalGraphMatcher, AbsentOrPresentAndConstrainedAcceptsBothShapes) +{ + // RFC 0017 §5: the pair a pack writes when it serves an optional operand + // only in a particular form. The `or` must see past the unresolved second + // arm when the operand is absent, and must still enforce it when present. + const char* crit = R"({"or": [ + {"not_present": ["$attn_mask"]}, + {"and": [{"present": ["$attn_mask"]}, + {"==": ["$attn_mask.dtype", "BFLOAT16"]}]} + ]})"; + const umd::UniversalGraphMatcher m(sdpaWithCriterion(crit)); + + auto absent = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const gAbsent(absent.GetBufferPointer(), absent.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gAbsent).matched); + + auto present = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16, /*withAttnMask=*/true); + fbu::GraphWrapper const gPresent(present.GetBufferPointer(), present.GetSize()); + EXPECT_TRUE(m.match(K_DEVICE, gPresent).matched); +} + +TEST(UniversalGraphMatcher, DefiniteFalseStillDeclinesBesideAnUnresolvedCheck) +{ + // An unresolved sibling must not rescue a criterion that definitely fails: + // `and` short-circuits on the definite false rather than going unresolved. + const char* crit = R"({"and": [{"==": ["$q.rank", 9]}, + {"==": ["$attn_mask.dtype", "BFLOAT16"]}]})"; + const umd::UniversalGraphMatcher m(sdpaWithCriterion(crit)); + auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + EXPECT_FALSE(m.match(K_DEVICE, g).matched); +} + TEST(UniversalGraphMatcher, PresentOperatorSeesABoundOptional) { // Same descriptor, but the graph supplies `attn_mask`: `present` now reads @@ -1193,3 +1401,188 @@ TEST(UniversalGraphMatcher, MatchesOnACompileTimeScaleValue) fbu::GraphWrapper const gn(noScale.GetBufferPointer(), noScale.GetSize()); EXPECT_FALSE(m.match(K_DEVICE, gn).matched); } + +// ---- value_f32 union coercion (RFC 0017 §5) ------------------------------ + +namespace +{ + +// A single-node SDPA graph whose optional `scale` operand carries `value`, the +// tagged union arm under test, with the tensor's own data_type. The test-SDK +// fixture only ever builds the Float32 arm, so the other seven (and the +// data_type-dispatched Float8 decode) are unreachable through it. +template +flatbuffers::FlatBufferBuilder buildSdpaGraphWithScaleValue(data::DataType scaleType, + AddValue&& addValue) +{ + flatbuffers::FlatBufferBuilder builder; + const std::vector dims{2, 8, 16, 128}; + const std::vector strides = contiguousStrides(dims); + const std::vector scalarDims{1}; + const std::vector scalarStrides{1}; + + std::vector<::flatbuffers::Offset> tensors; + for(const auto& [uid, name] : + {std::pair{1, "q"}, {2, "k"}, {3, "v"}, {4, "o"}}) + { + tensors.push_back(data::CreateTensorAttributesDirect( + builder, uid, name, data::DataType::BFLOAT16, &strides, &dims)); + } + + // addValue writes the union arm and returns its (type, offset) pair. + const auto [valueType, valueOffset] = addValue(builder); + const auto scaleName = builder.CreateString("scale"); + const auto scaleDims = builder.CreateVector(scalarDims); + const auto scaleStrides = builder.CreateVector(scalarStrides); + data::TensorAttributesBuilder tb(builder); + tb.add_uid(5); + tb.add_name(scaleName); + tb.add_data_type(scaleType); + tb.add_dims(scaleDims); + tb.add_strides(scaleStrides); + tb.add_value_type(valueType); + tb.add_value(valueOffset); + tensors.push_back(tb.Finish()); + + data::SdpaAttributesBuilder sb(builder); + sb.add_q_tensor_uid(1); + sb.add_k_tensor_uid(2); + sb.add_v_tensor_uid(3); + sb.add_o_tensor_uid(4); + sb.add_scale_tensor_uid(5); + const auto sdpa = sb.Finish(); + + std::vector<::flatbuffers::Offset> nodes; + nodes.push_back(data::CreateNodeDirect(builder, + "sdpa_fwd", + data::DataType::BFLOAT16, + data::NodeAttributes::SdpaAttributes, + sdpa.Union())); + + auto graph = data::CreateGraphDirect(builder, + "test", + data::DataType::FLOAT, + data::DataType::HALF, + data::DataType::BFLOAT16, + &tensors, + &nodes, + flatbuffers::nullopt, + /*is_override_shape_enabled=*/false); + builder.Finish(graph); + return builder; +} + +// Resolve `$scale.value_f32` for a graph carrying one union arm. +jlogic::Value scaleValueF32(flatbuffers::FlatBufferBuilder& builder) +{ + const umd::UniversalGraphMatcher m(sdpaScaleDescriptor()); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + const umd::MatchResult r = m.match(K_DEVICE, g); + EXPECT_TRUE(r.matched); + return r.bindings.get("$scale.value_f32"); +} + +} // namespace + +TEST(BindingContext, ValueF32CoercesEveryUnionArm) +{ + // RFC 0017 §5: the schema layer "coerces whichever arm is set to f32" and + // publishes one typed token. Every arm must produce a number, not null. + const auto f32 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::FLOAT, [](auto& b) { + return std::pair{ + data::TensorValue::Float32Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float32Value(2.5F)).o)}; + }))); + EXPECT_DOUBLE_EQ(f32.toNumber(), 2.5); + + const auto f64 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::DOUBLE, [](auto& b) { + return std::pair{ + data::TensorValue::Float64Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float64Value(0.25)).o)}; + }))); + EXPECT_DOUBLE_EQ(f64.toNumber(), 0.25); + + const auto i32 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::INT32, [](auto& b) { + return std::pair{data::TensorValue::Int32Value, + ::flatbuffers::Offset(b.CreateStruct(data::Int32Value(-7)).o)}; + }))); + EXPECT_DOUBLE_EQ(i32.toNumber(), -7.0); + + const auto i64 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::INT64, [](auto& b) { + return std::pair{data::TensorValue::Int64Value, + ::flatbuffers::Offset(b.CreateStruct(data::Int64Value(42)).o)}; + }))); + EXPECT_DOUBLE_EQ(i64.toNumber(), 42.0); + + const auto boolean = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::BOOLEAN, [](auto& b) { + return std::pair{data::TensorValue::BoolValue, + ::flatbuffers::Offset(b.CreateStruct(data::BoolValue(true)).o)}; + }))); + EXPECT_DOUBLE_EQ(boolean.toNumber(), 1.0); +} + +TEST(BindingContext, ValueF32DecodesFloat8ThroughTheTensorDataType) +{ + // Float8Value stores raw bits; the tensor's data_type says which 8-bit + // format they encode. The bits 0x38 are 1.0 in E4M3 (exponent bias 7) but + // 0.5 in E5M2 (bias 15), so decoding through the wrong format -- or not + // decoding at all -- is directly observable. + const auto e4m3 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::FP8_E4M3, [](auto& b) { + return std::pair{ + data::TensorValue::Float8Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float8Value(0x38)).o)}; + }))); + EXPECT_DOUBLE_EQ(e4m3.toNumber(), 1.0); + + const auto e5m2 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::FP8_E5M2, [](auto& b) { + return std::pair{ + data::TensorValue::Float8Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float8Value(0x38)).o)}; + }))); + EXPECT_DOUBLE_EQ(e5m2.toNumber(), 0.5); + + // E8M0 is an 8-bit format too: bits 127 encode the 2^0 scale. It must + // decode rather than fall through to the fail-closed null. + const auto e8m0 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::FP8_E8M0, [](auto& b) { + return std::pair{data::TensorValue::Float8Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float8Value(127)).o)}; + }))); + ASSERT_FALSE(e8m0.isNull()); + EXPECT_DOUBLE_EQ(e8m0.toNumber(), 1.0); + + // INT8 raw bits are signed: 0xFF is -1, not 255. + const auto i8 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::INT8, [](auto& b) { + return std::pair{ + data::TensorValue::Float8Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float8Value(0xFF)).o)}; + }))); + EXPECT_DOUBLE_EQ(i8.toNumber(), -1.0); + + // UINT8 keeps the unsigned reading of the same bits. + const auto u8 = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::UINT8, [](auto& b) { + return std::pair{ + data::TensorValue::Float8Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float8Value(0xFF)).o)}; + }))); + EXPECT_DOUBLE_EQ(u8.toNumber(), 255.0); + + // A pairing that names no 8-bit format resolves to null rather than + // reinterpreting the bits. + const auto mismatched = scaleValueF32(*std::make_unique( + buildSdpaGraphWithScaleValue(data::DataType::FLOAT, [](auto& b) { + return std::pair{ + data::TensorValue::Float8Value, + ::flatbuffers::Offset(b.CreateStruct(data::Float8Value(0x38)).o)}; + }))); + EXPECT_TRUE(mismatched.isNull()); +} From df12e7d42d53aba8e56decb91b88fb4e3d35a965 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 13:32:05 -0600 Subject: [PATCH 53/60] docs(rfc-0018): correct version gating, fail-closed rule, and layout claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior alignment pass restated several RFC 0017 contracts in the direction the implementation had them, not the direction RFC 0017 states them. RFC 0017 is the authority and is left untouched here. Version gating. §13, A.1, and A.10 §1 all required `version` to be "at or above the runtime's floor". RFC 0017 §4 makes it a CEILING: reject a differing major or a minor newer than the runtime's, otherwise load -- "a file stamped 1.0 loads on a 1.1 runtime". Under the floor wording that descriptor was refused, exactly inverting the rule. `sdk_version` is a floor, but one the GRAPH sets per match, not a runtime constant; both bounds now appear, and the graph-side floor is identified as hipDNN's existing `min_required_engine_api_version` rather than a new mechanism. Absent-optional semantics. A.4 said a field read on an absent optional "declines the whole criteria". That is the outcome, not the mechanism, and it left `and`/`or` unspecified -- under a plain decline, RFC 0017 §5's "absent, or present and constrained" idiom cannot work, since the second arm's field reads never run. Stated as three-valued: the read resolves to unknown, unknown propagates rather than coercing, and a definite result still short-circuits either connective. A.7 carries the same rule for the operator table. Layout. The fused Conv-Bias-ReLU example named `$y`'s dims in NHWC order while its `stride_order` of [3,0,2,1] implies an NCHW logical order -- `shape` names logical dims, layout is the stride order. Corrected, and the claim that the alias set "matches the layouts validateSupportedLayout accepts today" narrowed: that oracle covers the four conv layouts, while `bhsd` and `contiguous` are additions for attention. `extractStrideOrder` is cited at its implementation rather than a section comment. RFC 0017 §5's `stride_order` literals use the inverse, position-to-dimension convention, which no implementation uses and which RFC 0017 never states as intended. Raised as Open Question 6 for RFC 0017 to resolve, alongside the existing Open Question 5, rather than changed here: a matcher copied from those examples silently accepts the wrong physical layout. The PoC plan expanded "nhwc" to [0,2,3,1], contradicting the A.8 table it cites and the encoding `extractStrideOrder` computes, so implementing to it would have baked in the wrong permutation. (cherry picked from commit 8e5b3617e590870d561a571142c30a510ef9d514) (cherry picked from commit dd12b10ee24fb6130f214b465c8a690ba04659c5) --- .../hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md | 10 ++- .../rfcs/0018_UniversalMatchDescriptor.md | 78 +++++++++++++++---- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md index 63c948ac3e87..40ccf76534cf 100644 --- a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md +++ b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md @@ -124,10 +124,12 @@ A B.3-violation fixture fails generation. graph after structural match; resolves all five namespaces via `getData`: - **Tensor**: `$q` (→ uid int64), `$q.uid`, `$q.rank` (`dims->size()`), `$q.dtype` (enum name string), `$q.dims[i]`, `$q.strides[i]`, `$q.` (via compiler-supplied name→index map), - `$q.stride_order` (IntArray, computed from strides as in `ApplicabilityChecks.cpp:17`), + `$q.stride_order` (IntArray, computed from strides by `extractStrideOrder`, + `data_sdk/include/hipdnn_data_sdk/utilities/ShapeUtilities.hpp:146`), `$q.packed` (bool, from dims+strides), `$q.virtual`, `$q.present` (optional operands). - Absent-optional field access → decline (fail closed, A.4). - - **Graph**: `$graph.node_count`. + Absent-optional field access resolves to unknown, which propagates rather than coercing, so the + criterion containing it declines (fail closed, A.4). + - **Graph**: `$graph.node_count`, `$graph.is_override_shape_enabled`. - **Attributes**: `$.`, `$..present`, `$` (→ node index for attribute reads). - **Device**: `$device.` from `Handle` (`lds_size`, `warp_size`, …). @@ -142,7 +144,7 @@ A B.3-violation fixture fails generation. subset (schema exact, single key per op-object, names resolve in registry, `?` ↔ registry optionality, node-id/tvar disjoint from reserved roots, single-producer per variable). JSON→JSON lowering pass: - - Expand layout aliases (`"nhwc"`→`[0,2,3,1]`, …, A.8). + - Expand layout aliases (`"nhwc"`→`[3,0,2,1]`, `"nchw"`→`[3,2,1,0]`, …, A.8). - Expand `shape` (A.5): record per-tensor `{name→index, rank, capture}`; rewrite `{"shape":["$q",[...]]}` → `{"==":["$q.rank",N]}` (no capture) / `{">=":["$q.rank",N-1]}` (capture). Named-dim reads then resolve through `BindingContext`. diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 734073f626b6..92a171ed56ec 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -210,7 +210,7 @@ several kernels — which goes the opposite direction and is not in scope here: "criteria": {"and": [ {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [3, 0, 2, 1]]}, "$x.packed", // NHWC {"==": ["$y.dtype", "FLOAT16"]}, {"==": ["$y.stride_order", [3, 0, 2, 1]]}, "$y.packed", // NHWC - {"shape": ["$y", ["batch", "out_h", "out_w", "out_channels"]]}, + {"shape": ["$y", ["batch", "out_channels", "out_h", "out_w"]]}, // logical dim order; layout is the stride_order above {"shape": ["$bias", ["out_channels"]]}, {"==": ["$graph.node_count", 3]}, // exactly these three ops "$conv_out.virtual", "$bias_out.virtual" // intermediates absorbed by the fused kernel @@ -441,7 +441,9 @@ hipDNN tensors store no layout enum; layout is implied by stride order ([§2](#2-the-matchers-input-hipdnns-graph-model)). The UMD therefore represents layout as an **array of per-dimension stride priorities**: entry `i` ranks logical dimension `i` by stride magnitude, with a higher number meaning a slower-varying (larger-stride) dimension. This is the encoding -`extractStrideOrder` already computes (`ApplicabilityChecks.cpp:17`), so a matcher compares +`extractStrideOrder` already computes +(`data_sdk/include/hipdnn_data_sdk/utilities/ShapeUtilities.hpp:146`, called from +`ApplicabilityChecks.cpp:22`), so a matcher compares `$q.stride_order` directly against the same arrays the `validateSupportedLayout` oracle uses today rather than against a re-derived permutation. @@ -456,8 +458,10 @@ rather than against a re-derived permutation. dimension order, independent of this physical layout, consistent with RFC 0017 §6. - **Named aliases** are provided for the common cases and expand to the array literal at compile time, so `{"==": ["$x.stride_order", "nhwc"]}` compiles to a comparison against `[3, 0, 2, 1]` - (A.8). The array remains the single canonical form. The alias set matches the layouts - `validateSupportedLayout` accepts today (`ApplicabilityChecks.cpp:77`). + (A.8). The array remains the single canonical form. The four convolution aliases are exactly the + layouts `validateSupportedLayout` accepts today — NCHW/NHWC at rank 4, NCDHW/NDHWC at rank 5 + (`ApplicabilityChecks.cpp:76`); `bhsd` and `contiguous` are additions for the attention families, + which that oracle never covered. - **Cross-tensor consistency** is a JsonLogic equality between stride orders, `{"==": ["$x.stride_order", "$y.stride_order"]}` (one per pair, joined by the top-level `and`), lowering `validateConsistentLayouts`; layout-agnostic tensors (rank-1 scalars, pass-by-value) are @@ -668,14 +672,28 @@ hazard. mandatory `name` for diagnostics. A UMD whose schema version is newer than the runtime understands is refused with a clear error, never silently reinterpreted, matching [RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats). -- **A matcher carries two versions.** `version` is the matcher format itself. `sdk_version` is the - hipDNN graph schema the matcher was authored against, and a matcher is the only descriptor that - needs it: it is the only one that reads graph fields, so it is the only one a graph-schema change - can silently invalidate. The runtime holds a floor for each, and a matcher declaring either below - its floor is declined before it runs rather than matching against a schema it does not understand +- **A matcher carries two versions, gated in opposite directions.** `version` is the matcher format + itself, gated as a **ceiling**: a differing `major`, or a `minor` newer than the runtime's, is + refused, because the descriptor carries features that runtime cannot understand. An older minor + within the same major always loads — a file stamped `1.0` loads on a `1.1` runtime — so an author + stamps the lowest version their descriptor needs and it stays loadable on the oldest runtime that + can serve it. `sdk_version` is the hipDNN graph schema the matcher was authored against, and a + matcher is the only descriptor that needs it: it is the only one that reads graph fields, so it is + the only one a graph-schema change can silently invalidate. It is gated as a **floor the graph + sets**, not a constant: a graph reports the schema version its own contents require, and a matcher + declaring less is declined before it runs rather than matching on the fields it knows while + silently ignoring one that changes what the graph means. Both bounds hold at once — a + `sdk_version` newer than the runtime's own schema is still refused at compile ([RFC 0017 §4](0017_UniversalKernelDescriptor.md#4-descriptor-formats)). Both compare numerically by `(major, minor)`; both default to `1.0` when omitted, which is what every descriptor authored against this revision means. +- **The graph's floor is an existing mechanism, not a new one.** hipDNN already computes the minimum + engine-plugin API version a graph requires from the optional features it uses and stamps it into + the serialized graph (`min_required_engine_api_version`); override shapes + ([RFC 0008](0008_OverridableTensorShapesDesign.md)) raise it to `1.1` and runtime pass-by-value + tensors ([RFC 0016](0016_RuntimePassByValueTensors.md)) to `1.2`. The matcher reads that field + rather than deriving a second floor of its own. A graph carrying no stamp reads as the `1.0` + baseline. - **Additive evolution.** New JsonLogic operators, native predicates, and layout aliases are additive within `v1` where they do not change the meaning of an existing descriptor; anything that would reinterpret existing fields bumps the version. @@ -901,6 +919,16 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m itself complete. They are needed for dispatch formulas and precedence chains. Add them to RFC 0017's table, or scope them in this RFC as dispatch-formula-only and keep criteria to RFC 0017's set? +6. **Stride-order convention.** [§7](#7-layout-and-stride-order-constraints) and A.8 define + `stride_order` as a per-dimension stride **rank** (higher = slower-varying), which is what + `extractStrideOrder` computes and what the matcher implements and tests: contiguous rank-4 is + `[3,2,1,0]` and NHWC is `[3,0,2,1]`. The examples in + [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd) use the inverse, + position-to-dimension convention (`[0,1,2,3]` contiguous, `[0,2,3,1]` NHWC), which no + implementation uses; RFC 0017 never states which convention it intends. This RFC pins the + codebase encoding, so RFC 0017's `stride_order` literals need inverting to match. Confirm, and + correct them in RFC 0017 — a matcher copied from those examples silently accepts the wrong + physical layout. --- @@ -976,14 +1004,15 @@ these. Criteria arithmetic is integer; `Float` arises only in UDD dispatch formu | `schema` | string | yes | — | MUST equal `"hipdnn.umd/v1"`; a newer version is refused, never reinterpreted ([§13](#13-serialization-and-versioning)) | | `id` | string (UUID) | yes | — | A UUID; stable, globally unique identity ([§13](#13-serialization-and-versioning)) | | `name` | string | yes | — | Diagnostics only; not semantic | -| `version` | string | no | `"1.0"` | Matcher format version, `.`, gated at load against the runtime's floor ([§13](#13-serialization-and-versioning)) | -| `sdk_version` | string | no | `"1.0"` | The hipDNN graph schema version this matcher was authored against, `.`. A matcher declaring a version below the runtime's floor is declined before it runs ([§13](#13-serialization-and-versioning)) | +| `version` | string | no | `"1.0"` | Matcher format version, `.`, gated at load as a **ceiling**: a differing `major`, or a `minor` newer than the runtime's, is refused; an older minor always loads ([§13](#13-serialization-and-versioning)) | +| `sdk_version` | string | no | `"1.0"` | The hipDNN graph schema version this matcher was authored against, `.`. Refused at load when newer than the runtime's own schema, and declined at match time against the **floor the graph sets** — a matcher below what the graph requires is skipped instead of asked ([§13](#13-serialization-and-versioning)) | | `allow_override_shape` | bool | no | `false` | When `false`, override-shape graphs are declined ([§3](#3-structural-pattern)) | | `nodes` | array<Node> | yes | — | Non-empty; A.2 | | `criteria` | Expr | yes | — | A single expression whose static type is `Bool` (A.6) | No other top-level keys are permitted; an unknown key is refused. Both version fields compare -numerically by `(major, minor)`, so `1.10` is above `1.9`. +numerically by `(major, minor)`, so `1.10` is above `1.9`; a value that does not parse as exactly two +decimal components is refused. ### A.2 Node object and opcode selector @@ -1054,9 +1083,18 @@ Rules: use them. - `present` is bound only for an optional operand/attribute; reading it on a required one is refused. - A field access on an **absent** optional operand or attribute (e.g. `$attn_mask.dtype` when - `attn_mask` is absent) declines the whole `criteria` (fail closed, [§14](#14-security-and-hostile-input)); - guard such reads with `.present` and rely on short-circuit ordering - ([§10](#10-the-matcher-compilation-indexing-and-caching)). + `attn_mask` is absent) resolves to **unknown**, which is neither true nor false: the criterion + containing it can no longer be satisfied, so the match declines (fail closed, + [§14](#14-security-and-hostile-input)). Unknown is not coerced — it never reads as `false`, `0`, or + "not equal" — because a coerced unknown would make a narrowing check such as + `{"!=": ["$attn_mask.dtype", "BFLOAT16"]}` silently **pass** on an operand the pack never saw. This + is what RFC 0017 §5 means by a check on an absent operand that "neither passes nor fails, it simply + does not run". +- **`and` and `or` are three-valued.** A definite `false` still decides an `and` and a definite `true` + still decides an `or`, even beside an unknown argument; only an otherwise-undecided result stays + unknown. This is what lets the "absent, or present and constrained" pair of + [§5](#5-the-constraint-vocabulary) accept a graph without the operand, where the second arm's field + reads cannot run. - An out-of-range `dims[i]`/`strides[i]`, an unknown `dim-name`, or any unresolved reference declines the match. `value_f32` resolves only when the tensor carries a compile-time value, so it declines on one that does not. @@ -1144,6 +1182,12 @@ All integer arithmetic uses checked-width integers and fails closed on overflow | `shape` | 2 | `Tensor, EntryList` | `Bool` | A.5 | | `.` | per registry | per registry | `Bool` or `Value` | Custom operation, A.9 | +**Every operator except `present`, `not_present`, and `value_or_default` propagates an unknown** +(an unresolved reference) rather than coercing it, so an absent optional operand can never satisfy a +criterion (A.4). `and` and `or` are three-valued: a definite `false` still decides an `and` and a +definite `true` still decides an `or`, even beside an unknown argument. The three exceptions answer +"did this resolve?", so they always yield a real value. + Adding an operator is additive within `v1` where it does not change the meaning of an existing descriptor ([§13](#13-serialization-and-versioning)). @@ -1187,7 +1231,9 @@ quarantines) the descriptor with a diagnostic ([§10](#10-the-matcher-compilatio [§14](#14-security-and-hostile-input)): 1. `schema == "hipdnn.umd/v1"`, `id` is a well-formed UUID, and each of `version` / `sdk_version`, - when present, is a well-formed `.` string at or above the runtime's floor. + when present, is a well-formed `.` string the runtime can honor: same `major`, and a + `minor` no newer than the runtime's ([§13](#13-serialization-and-versioning)). The `sdk_version` + floor is a match-time check against the graph, not a compile-time one. 2. Only the keys of A.1 at the top level; only the keys of A.2 on each node. 3. Every `opcode` and every name key resolves in the op-schema registry. 4. Every `?` suffix matches the registry's optionality for that name. From b15b5f6222e46123be373d25df8bcd8815e3c109 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 13:44:22 -0600 Subject: [PATCH 54/60] refactor(umd): adopt RFC 0017's stride-order convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0018 and the matcher published `stride_order` as `extractStrideOrder` returns it: a per-dimension stride RANK, entry `d` giving dimension `d`'s rank with higher meaning slower-varying, so NHWC is [3,0,2,1]. RFC 0017 §5 writes the inverse: an ordered list of logical dimension indices, outermost first, so NHWC is [0,2,3,1]. The two forms are exact inverses and carry identical information -- the conversion round-trips over every permutation, and reproduces every RFC 0017 literal including §13.2's BSHD [0,2,1,3]. Neither document was wrong about layouts; they spelled the same layouts two ways. RFC 0017 is the authority, so RFC 0018 moves. Its form is also the better one to author in: [0,2,3,1] over an (n,c,h,w) dim order reads N,H,W,C left to right. The rank vector does not read as anything, and misreading one as the other is silent -- take NHWC's [3,0,2,1] as an outermost-first list and it names H,C,W,N, a layout that does not exist. Converting the spec alone would have been worse than leaving it: an author would write [0,2,3,1] while the matcher compared against [3,0,2,1], and the criterion would decline forever with nothing to show why. So BindingContext now inverts once, at the single point a descriptor-visible value is minted, and the A.8 alias table is expanded in the same form. Nothing in the graph model changes; extractStrideOrder and every existing caller are untouched. Cross-tensor equality (`$x.stride_order == $y.stride_order`) is convention-independent and reads the same either way. The new test pins the direction with an NHWC graph rather than a contiguous one: [0,1,2,3] and [3,2,1,0] are both palindromic under this inversion, so a contiguous tensor cannot tell a correct conversion from a missing one. Drops Open Question 6, which asked RFC 0017 to invert its literals. (cherry picked from commit 9ad934dc614610104b305edb47fe130e59ef5c90) (cherry picked from commit cecfc9e70aa63ba4641a2e484ffd08de72741568) --- .../umd/BindingContext.hpp | 23 ++++-- .../umd/UmdCompiler.hpp | 26 +++---- .../tests/core/TestUniversalGraphMatcher.cpp | 52 +++++++++++-- .../hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md | 6 +- .../rfcs/0018_UniversalMatchDescriptor.md | 74 +++++++++---------- 5 files changed, 117 insertions(+), 64 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp index 182798f1938f..ca28531e7960 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/BindingContext.hpp @@ -323,13 +323,26 @@ class BindingContext } if(rest == "stride_order") { - const std::vector order + // Published in the RFC 0017 §5 form: logical dimension indices + // ordered outermost (largest-stride) first, so `[0,2,3,1]` over an + // (n,c,h,w) dim order spells N,H,W,C. extractStrideOrder returns + // the inverse -- entry `d` is dimension `d`'s stride rank, higher + // meaning slower-varying -- so invert it here, once, at the one + // place a descriptor-visible value is minted. + const std::vector ranks = hipdnn_data_sdk::utilities::extractStrideOrder(toVector(t->strides())); - jlogic::Value::Array arr; - arr.reserve(order.size()); - for(const std::int64_t v : order) + jlogic::Value::Array arr(ranks.size(), jlogic::Value(std::int64_t{0})); + const auto rank = static_cast(ranks.size()); + for(std::size_t dim = 0; dim < ranks.size(); ++dim) { - arr.emplace_back(v); + // A dim of stride rank r sits at physical position rank-1-r. + const std::int64_t position = rank - 1 - ranks[dim]; + if(position < 0 || position >= rank) + { + return {}; // malformed rank vector -> decline (fail closed) + } + arr[static_cast(position)] + = jlogic::Value(static_cast(dim)); } return {std::move(arr)}; } diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp index 738f1d776d85..2df715e50c11 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -859,32 +859,32 @@ class UmdCompiler return ref.substr(1, dot - 1); } - // Expand a layout alias to a stride-order array. `$q.stride_order` is - // computed by extractStrideOrder (ShapeUtilities.hpp) as a per-dim - // priority (higher == slower-varying / larger stride), so a contiguous - // rank-4 tensor is [3,2,1,0] and NHWC is [3,0,2,1]. The alias arrays match - // that encoding -- the same TensorLayout::NCHW/NHWC arrays the real - // validateSupportedLayout oracle compares against -- so `stride_order == - // alias` holds against a live graph's strides, and it is the encoding - // RFC 0018 A.8 tabulates. + // Expand a layout alias to a stride-order array, in the RFC 0017 §5 form + // BindingContext publishes: logical dimension indices ordered outermost + // (largest-stride) first, so a contiguous rank-4 tensor is [0,1,2,3] and + // NHWC is [0,2,3,1] -- the array reads as the layout it names. This is the + // inverse of the stride-rank vector extractStrideOrder returns; + // BindingContext converts once when it binds `$q.stride_order`, so + // `stride_order == alias` holds against a live graph's strides. + // RFC 0018 A.8 tabulates these arrays. static nlohmann::json aliasToArray(const std::string& alias, std::size_t rank) { nlohmann::json arr; if(alias == "nchw" || alias == "bhsd") { - arr = nlohmann::json::array({3, 2, 1, 0}); + arr = nlohmann::json::array({0, 1, 2, 3}); } else if(alias == "nhwc") { - arr = nlohmann::json::array({3, 0, 2, 1}); + arr = nlohmann::json::array({0, 2, 3, 1}); } else if(alias == "ncdhw") { - arr = nlohmann::json::array({4, 3, 2, 1, 0}); + arr = nlohmann::json::array({0, 1, 2, 3, 4}); } else if(alias == "ndhwc") { - arr = nlohmann::json::array({4, 0, 3, 2, 1}); + arr = nlohmann::json::array({0, 2, 3, 4, 1}); } else if(alias == "contiguous") { @@ -896,7 +896,7 @@ class UmdCompiler arr = nlohmann::json::array(); for(std::size_t i = 0; i < rank; ++i) { - arr.push_back(static_cast(rank - 1 - i)); + arr.push_back(static_cast(i)); } } else diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index d7cf3264341b..131071e7c4fa 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -366,15 +366,17 @@ TEST(BindingContext, ResolvesEveryNamespaceForm) EXPECT_TRUE(b.get("$q.packed").asBool()); EXPECT_FALSE(b.get("$q.virtual").asBool()); - // stride_order uses extractStrideOrder's per-dim priority encoding - // (ShapeUtilities.hpp, RFC 0018 A.8): contiguous rank-4 is [3,2,1,0]. + // stride_order is published in the RFC 0017 §5 form: logical dim indices + // outermost-first, so a contiguous rank-4 tensor is [0,1,2,3]. That is the + // inverse of extractStrideOrder's stride-rank vector, which BindingContext + // converts once (RFC 0018 §7/A.8). const jlogic::Value strideOrder = b.get("$q.stride_order"); ASSERT_TRUE(strideOrder.isArray()); EXPECT_EQ(strideOrder, - jlogic::Value(jlogic::Value::Array{jlogic::Value(std::int64_t{3}), - jlogic::Value(std::int64_t{2}), + jlogic::Value(jlogic::Value::Array{jlogic::Value(std::int64_t{0}), jlogic::Value(std::int64_t{1}), - jlogic::Value(std::int64_t{0})})); + jlogic::Value(std::int64_t{2}), + jlogic::Value(std::int64_t{3})})); // Attributes namespace. EXPECT_FALSE(b.get("$sdpa_fwd.dropout_probability.present").asBool()); @@ -425,6 +427,46 @@ TEST(UmdCompiler, LayoutAliasMatchesContiguousLayout) EXPECT_TRUE(m.match(K_DEVICE, g).matched); } +TEST(BindingContext, StrideOrderIsPublishedOutermostFirst) +{ + // The conversion is observable, not cosmetic: an NHWC tensor must publish + // [0,2,3,1] (N,H,W,C read left to right), NOT extractStrideOrder's + // [3,0,2,1] stride-rank vector. Reading the rank vector as if it were this + // form would name the dims H,C,W,N -- a layout that does not exist. + const std::vector dims{2, 8, 16, 128}; + // NHWC over an (n,c,h,w) logical dim order: c varies fastest. + const std::vector nhwcStrides + = hipdnn_data_sdk::utilities::generateStrides(dims, {3, 0, 2, 1}); + + auto builder = hipdnn_test_sdk::utilities::createValidSdpaFwdGraph(dims, + nhwcStrides, + dims, + nhwcStrides, + dims, + nhwcStrides, + dims, + nhwcStrides, + data::DataType::BFLOAT16); + fbu::GraphWrapper const g(builder.GetBufferPointer(), builder.GetSize()); + + json d = sdpaDescriptor(); + d["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.stride_order", [0, 2, 3, 1]]})")); + const umd::UniversalGraphMatcher m(d); + + const umd::MatchResult r = m.match(K_DEVICE, g); + ASSERT_TRUE(r.matched); + EXPECT_EQ(r.bindings.get("$q.stride_order"), + jlogic::Value(jlogic::Value::Array{jlogic::Value(std::int64_t{0}), + jlogic::Value(std::int64_t{2}), + jlogic::Value(std::int64_t{3}), + jlogic::Value(std::int64_t{1})})); + + // The `nhwc` alias expands to the same array, so it matches the same graph. + json aliased = sdpaDescriptor(); + aliased["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.stride_order", "nhwc"]})")); + EXPECT_TRUE(umd::UniversalGraphMatcher(aliased).match(K_DEVICE, g).matched); +} + // ---- Multi-node patterns (RFC 0018 §3/A.3) ------------------------------- namespace diff --git a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md index 40ccf76534cf..77c87aada8b7 100644 --- a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md +++ b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md @@ -124,8 +124,8 @@ A B.3-violation fixture fails generation. graph after structural match; resolves all five namespaces via `getData`: - **Tensor**: `$q` (→ uid int64), `$q.uid`, `$q.rank` (`dims->size()`), `$q.dtype` (enum name string), `$q.dims[i]`, `$q.strides[i]`, `$q.` (via compiler-supplied name→index map), - `$q.stride_order` (IntArray, computed from strides by `extractStrideOrder`, - `data_sdk/include/hipdnn_data_sdk/utilities/ShapeUtilities.hpp:146`), + `$q.stride_order` (IntArray in the RFC 0017 §5 outermost-first form, inverted from + `extractStrideOrder`'s stride-rank vector, `ShapeUtilities.hpp:146`), `$q.packed` (bool, from dims+strides), `$q.virtual`, `$q.present` (optional operands). Absent-optional field access resolves to unknown, which propagates rather than coercing, so the criterion containing it declines (fail closed, A.4). @@ -144,7 +144,7 @@ A B.3-violation fixture fails generation. subset (schema exact, single key per op-object, names resolve in registry, `?` ↔ registry optionality, node-id/tvar disjoint from reserved roots, single-producer per variable). JSON→JSON lowering pass: - - Expand layout aliases (`"nhwc"`→`[3,0,2,1]`, `"nchw"`→`[3,2,1,0]`, …, A.8). + - Expand layout aliases (`"nhwc"`→`[0,2,3,1]`, `"nchw"`→`[0,1,2,3]`, …, A.8). - Expand `shape` (A.5): record per-tensor `{name→index, rank, capture}`; rewrite `{"shape":["$q",[...]]}` → `{"==":["$q.rank",N]}` (no capture) / `{">=":["$q.rank",N-1]}` (capture). Named-dim reads then resolve through `BindingContext`. diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 92a171ed56ec..50789fbcf8ed 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -208,8 +208,8 @@ several kernels — which goes the opposite direction and is not in scope here: "operands": {"a": "$bias_out"}, "results": {"y": "$y"}} ], "criteria": {"and": [ - {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [3, 0, 2, 1]]}, "$x.packed", // NHWC - {"==": ["$y.dtype", "FLOAT16"]}, {"==": ["$y.stride_order", [3, 0, 2, 1]]}, "$y.packed", // NHWC + {"==": ["$x.dtype", "FLOAT16"]}, {"==": ["$x.stride_order", [0, 2, 3, 1]]}, "$x.packed", // NHWC + {"==": ["$y.dtype", "FLOAT16"]}, {"==": ["$y.stride_order", [0, 2, 3, 1]]}, "$y.packed", // NHWC {"shape": ["$y", ["batch", "out_channels", "out_h", "out_w"]]}, // logical dim order; layout is the stride_order above {"shape": ["$bias", ["out_channels"]]}, {"==": ["$graph.node_count", 3]}, // exactly these three ops @@ -323,7 +323,7 @@ same expression as any built-in operator. | **Rank** | `{"==": ["$q.rank", 4]}`, or a `shape` short-hand that names four dims | `validateDimensionCount`, rank == 4 | | **Shape (bind / relate)** | `{"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}` binds `$q.`; relate with `{"==": ["$q.head_size", "$k.head_size"]}` | dim reads and cross-tensor dim relations | | **Divisibility** | `{"divisible": [{"*": ["$y.n", "$y.ho", "$y.wo"]}, "$kernel.MPerBlock"]}` | tile-fit / GEMM-dim gates | -| **Layout** | `{"==": ["$q.stride_order", [3, 2, 1, 0]]}` ([§7](#7-layout-and-stride-order-constraints)) | `validateSupportedLayout` | +| **Layout** | `{"==": ["$q.stride_order", [0, 1, 2, 3]]}` ([§7](#7-layout-and-stride-order-constraints)) | `validateSupportedLayout` | | **Packing** | `"$q.packed"` (a bound boolean) | `validatePackedTensors` | | **Cross-tensor layout** | `{"==": ["$x.stride_order", "$y.stride_order"]}` (per pair) | `validateConsistentLayouts` | | **Attribute (value)** | `{"==": ["$sdpa_fwd.causal_mask", false]}`; absent-or `{"or": [{"!": "$sdpa_fwd.dropout_probability.present"}, {"==": ["$sdpa_fwd.dropout_probability", 0.0]}]}` | per-attr value gates | @@ -416,7 +416,7 @@ like `{"divisible": [{"*": ["$y.n", "$y.ho", "$y.wo"]}, "$kernel.MPerBlock"]}` n {"<=": ["$q.head_size", 128]} // range bound {"in": ["$q.head_size", [64, 128, 256]]} // set membership {"divisible": ["$q.num_heads", "$k.kv_heads"]} // GQA divisibility -{"==": ["$q.stride_order", [3, 2, 1, 0]]}, "$q.packed" // layout + packed +{"==": ["$q.stride_order", [0, 1, 2, 3]]}, "$q.packed" // layout + packed {"==": ["$graph.node_count", 1]} // exact whole-graph match {"or": [{"not_present": ["$attn_mask"]}, {"==": ["$attn_mask.dtype", "$q.dtype"]}]} // composition (§10) @@ -438,26 +438,32 @@ enough to audit and to lower into the static matcher ([§11](#11-static-matcher- ## 7. Layout and Stride-Order Constraints hipDNN tensors store no layout enum; layout is implied by stride order -([§2](#2-the-matchers-input-hipdnns-graph-model)). The UMD therefore represents layout as an **array of -per-dimension stride priorities**: entry `i` ranks logical dimension `i` by stride magnitude, with a -higher number meaning a slower-varying (larger-stride) dimension. This is the encoding -`extractStrideOrder` already computes -(`data_sdk/include/hipdnn_data_sdk/utilities/ShapeUtilities.hpp:146`, called from -`ApplicabilityChecks.cpp:22`), so a matcher compares -`$q.stride_order` directly against the same arrays the `validateSupportedLayout` oracle uses today -rather than against a re-derived permutation. +([§2](#2-the-matchers-input-hipdnns-graph-model)). The UMD represents layout the way +[RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd) writes it: an **ordered list +of logical dimension indices, outermost (largest-stride) first**. Entry `i` names the logical +dimension stored at physical position `i`, so the array reads as the layout it describes: +`[0, 2, 3, 1]` over an `(n, c, h, w)` logical dim order spells N, H, W, C. ```jsonc -{"==": ["$q.stride_order", [3, 2, 1, 0]]} // descending-stride packed (BHSD, rank-4) -{"==": ["$x.stride_order", [3, 0, 2, 1]]} // NHWC over an NCHW logical dim order +{"==": ["$q.stride_order", [0, 1, 2, 3]]} // descending-stride packed (BHSD, rank-4) +{"==": ["$x.stride_order", [0, 2, 3, 1]]} // NHWC over an NCHW logical dim order ``` -- The array is a permutation of `0..rank-1`. Entry `i` is the stride rank of logical dimension `i`, - so `[3,2,1,0]` is descending-stride packed and `[3,0,2,1]` places the channel dim fastest-varying - (NHWC). The `axis` used everywhere else (dims, strides, `args_signature`) indexes the logical - dimension order, independent of this physical layout, consistent with RFC 0017 §6. +- The array is a permutation of `0..rank-1`. Entry `i` is the logical dimension at physical position + `i`, counting from the slowest-varying, so `[0,1,2,3]` is descending-stride packed and `[0,2,3,1]` + places the channel dim last, hence fastest-varying (NHWC). The `axis` used everywhere else (dims, + strides, `args_signature`) indexes the logical dimension order, independent of this physical + layout, consistent with RFC 0017 §6. +- **This is not the encoding `extractStrideOrder` returns.** That helper + (`data_sdk/include/hipdnn_data_sdk/utilities/ShapeUtilities.hpp:146`, called from + `ApplicabilityChecks.cpp:22`) produces the inverse: a per-dimension stride *rank*, entry `d` + giving the rank of logical dimension `d`, higher meaning slower-varying. The two forms are exact + inverses carrying identical information — `[0,2,3,1]` and `[3,0,2,1]` are the same NHWC layout — + so the binding layer converts once when it publishes `$q.stride_order`, and descriptors are + authored in the RFC 0017 form throughout. Nothing in the graph model changes; only the spelling a + matcher author writes. - **Named aliases** are provided for the common cases and expand to the array literal at compile time, - so `{"==": ["$x.stride_order", "nhwc"]}` compiles to a comparison against `[3, 0, 2, 1]` + so `{"==": ["$x.stride_order", "nhwc"]}` compiles to a comparison against `[0, 2, 3, 1]` (A.8). The array remains the single canonical form. The four convolution aliases are exactly the layouts `validateSupportedLayout` accepts today — NCHW/NHWC at rank 4, NCDHW/NDHWC at rank 5 (`ApplicabilityChecks.cpp:76`); `bhsd` and `contiguous` are additions for the attention families, @@ -465,11 +471,12 @@ rather than against a re-derived permutation. - **Cross-tensor consistency** is a JsonLogic equality between stride orders, `{"==": ["$x.stride_order", "$y.stride_order"]}` (one per pair, joined by the top-level `and`), lowering `validateConsistentLayouts`; layout-agnostic tensors (rank-1 scalars, pass-by-value) are - skipped as they are today. + skipped as they are today. Equality is convention-independent, so a cross-tensor check reads the + same under either form. - **Packing** is the separate bound boolean `$q.packed` (written `"$q.packed"`), since a supported stride order does not imply the tensor is gap-free; it lowers `validatePackedTensors`. - `$q.stride_order` is an ordinary bound value ([§4](#4-symbol-binding-and-the-auto-binding-formula)), - so a `stride_order == [3,2,1,0]` gate is expressible directly. + so a `stride_order == [0,1,2,3]` gate is expressible directly. --- @@ -919,16 +926,6 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m itself complete. They are needed for dispatch formulas and precedence chains. Add them to RFC 0017's table, or scope them in this RFC as dispatch-formula-only and keep criteria to RFC 0017's set? -6. **Stride-order convention.** [§7](#7-layout-and-stride-order-constraints) and A.8 define - `stride_order` as a per-dimension stride **rank** (higher = slower-varying), which is what - `extractStrideOrder` computes and what the matcher implements and tests: contiguous rank-4 is - `[3,2,1,0]` and NHWC is `[3,0,2,1]`. The examples in - [RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd) use the inverse, - position-to-dimension convention (`[0,1,2,3]` contiguous, `[0,2,3,1]` NHWC), which no - implementation uses; RFC 0017 never states which convention it intends. This RFC pins the - codebase encoding, so RFC 0017's `stride_order` literals need inverting to match. Confirm, and - correct them in RFC 0017 — a matcher copied from those examples silently accepts the wrong - physical layout. --- @@ -968,8 +965,8 @@ The design borrows established ideas; none is a dependency. These informed the U value-valued expressions are UDD dispatch formulas, both over one `$`-variable symbol table (the five namespaces of [§4](#4-symbol-binding-and-the-auto-binding-formula)) with one evaluator ([§6](#6-the-shared-expression-language)). -- **Stride-order layout:** layout represented as an array of per-dimension stride ranks, higher meaning - slower-varying, since tensors carry no layout enum ([§7](#7-layout-and-stride-order-constraints)). +- **Stride-order layout:** layout represented as an ordered list of logical dimension indices, + outermost first, since tensors carry no layout enum ([§7](#7-layout-and-stride-order-constraints)). - **Custom operation (native predicate):** the escape hatch; a registry-resolved JsonLogic operation a UMD invokes by its namespaced name for logic the built-in operators cannot state, carried as an operation name and typed arguments, never inline code ([§8](#8-native-predicate-escape-hatch)). @@ -1202,14 +1199,15 @@ above; reconciling them into RFC 0017's table is tracked as A `stride_order` comparison accepts either an integer array or an alias string; aliases expand to the array at compile time, and the array is the single canonical form ([§7](#7-layout-and-stride-order-constraints)). -An array MUST be a permutation of `0 .. rank-1` giving each logical dimension's stride rank, higher -meaning slower-varying. +An array MUST be a permutation of `0 .. rank-1` listing logical dimension indices outermost +(largest-stride) first, matching +[RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd). | Alias | Array | | Alias | Array | |---|---|---|---|---| -| `nchw` | `[3,2,1,0]` | | `ndhwc` | `[4,0,3,2,1]` | -| `nhwc` | `[3,0,2,1]` | | `bhsd` | `[3,2,1,0]` | -| `ncdhw` | `[4,3,2,1,0]` | | `contiguous` | descending stride ranks for the tensor's rank | +| `nchw` | `[0,1,2,3]` | | `ndhwc` | `[0,2,3,4,1]` | +| `nhwc` | `[0,2,3,1]` | | `bhsd` | `[0,1,2,3]` | +| `ncdhw` | `[0,1,2,3,4]` | | `contiguous` | ascending positions for the tensor's rank | `contiguous` needs a rank, so it is legal only on a tensor whose rank a `shape` short-hand has pinned (A.5); a fixed-rank alias compared against a differently-pinned tensor is refused at compile rather From 301a7695acfd3c04129c31a88db5f014ceff6096 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 14:22:18 -0600 Subject: [PATCH 55/60] fix(hip-kernel-provider): unbreak the Windows and Linux superbuilds Two independent CI build failures on the UMD matcher change. Windows: defines IN as an empty annotation macro, so the jsonlogic Op::IN enumerator expanded to nothing and JsonLogic.hpp failed to parse in any translation unit that also pulls in windows.h. TestUniversalGraphMatcher.cpp does, via the flatbuffers/test SDK headers, which is why it broke while TestJsonLogic.cpp did not. Rename the enumerator to Op::MEMBERSHIP; the JsonLogic operator key stays "in", so rules and the wire format are unchanged. Linux: the superbuild runs clang-tidy as part of the compile, and modernize-return-braced-init-list (warnings-as-errors) rejected graphRequiredSdkVersion's explicit std::string construction. Return a braced initializer list instead. (cherry picked from commit 1be18f1ea38d33457d51b2b1f0f875b501225629) (cherry picked from commit 03d1ce3abf433054495aa3fe2a0e1defe6bd4543) --- .../include/hip_kernel_provider_common/JsonLogic.hpp | 12 ++++++++---- .../umd/UniversalGraphMatcher.hpp | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp index 161d24ec7b86..2fc572774582 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.hpp @@ -432,7 +432,11 @@ enum class Op IF, AND, OR, - IN, + // Spelled MEMBERSHIP, not IN: defines IN as an empty + // annotation macro, so an enumerator named IN fails to compile on Windows + // in any translation unit that also pulls in windows.h. The JsonLogic + // operator key stays "in". + MEMBERSHIP, CEIL_DIV, ABS, POW, @@ -763,7 +767,7 @@ struct OpNode final : Node } return sawNull ? Value() : cur; } - case Op::IN: + case Op::MEMBERSHIP: { const std::vector v = evalArgs(d); if(anyNull(v)) @@ -881,7 +885,7 @@ inline const Op* lookupOp(const std::string& key) {"?:", Op::IF}, {"and", Op::AND}, {"or", Op::OR}, - {"in", Op::IN}, + {"in", Op::MEMBERSHIP}, {"ceil_div", Op::CEIL_DIV}, {"abs", Op::ABS}, {"pow", Op::POW}, @@ -942,7 +946,7 @@ inline void checkArity(Op op, std::size_t n, const std::string& key) case Op::IF: require(n >= 2); break; - case Op::IN: + case Op::MEMBERSHIP: case Op::POW: case Op::CEIL_DIV: case Op::VALUE_OR_DEFAULT: diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp index d9e666f7953a..9f395d56d9a4 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp @@ -179,7 +179,7 @@ class UniversalGraphMatcher const auto* required = graph.getGraph().min_required_engine_api_version(); if(required == nullptr) { - return std::string(UmdCompiler::K_DEFAULT_VERSION); + return {UmdCompiler::K_DEFAULT_VERSION}; } return std::to_string(required->major()) + "." + std::to_string(required->minor()); } From 584b8e415209e10c633c4c7724c9bb61e57a8959 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Tue, 4 Aug 2026 14:52:17 -0600 Subject: [PATCH 56/60] test(hipdnn): conform new UMD suite names to the naming convention The test-name validators require a suite to read (Test|Integration)[Gpu]FeatureName[Datatype]. The six suites added for JsonLogic and the UMD matcher omitted the Test prefix, so hipdnn_test_name_validation and hip-kernel-provider_test_name_validation reported 129 non-conforming names. These only surfaced now because the build failure upstream of them skipped the test step. Prefix the suites: JsonLogic, JsonDataSource, BindingContext, UmdCompiler, UniversalGraphMatcher, and UmdOpSchemaRegistry. Test case names are unchanged; the JsonLogic.md gtest_filter example follows the rename. (cherry picked from commit a9934715b2eef406f09396600d3b33c72cb158ae) (cherry picked from commit 47cae9ac0ed5680a4aef34532cdef46755a7a456) --- .../hip_kernel_provider_common/JsonLogic.md | 2 +- .../src/tests/core/TestJsonDataSource.cpp | 18 +- .../src/tests/core/TestJsonLogic.cpp | 46 ++--- .../tests/core/TestUniversalGraphMatcher.cpp | 178 +++++++++--------- .../tests/umd/TestOpSchemaRegistry.cpp | 16 +- 5 files changed, 130 insertions(+), 130 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md index c740553e4f15..3584a2e9038c 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/JsonLogic.md @@ -176,5 +176,5 @@ Unit tests live in `src/tests/core/TestJsonLogic.cpp` and build into the ```bash ctest -R hip_kernel_provider_tests # or, filtered directly on the binary: -./hip_kernel_provider_tests --gtest_filter=JsonLogic.* +./hip_kernel_provider_tests --gtest_filter=TestJsonLogic.* ``` diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp index 4e3d618c125d..b6d2595835c8 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonDataSource.cpp @@ -18,7 +18,7 @@ using V = jlogic::Value; // --------------------------------------------------------------------------- // JsonDataSource: the sample nlohmann::json-backed data source (getData/setData). // --------------------------------------------------------------------------- -TEST(JsonDataSource, GetResolvesPathsAndSubscripts) +TEST(TestJsonDataSource, GetResolvesPathsAndSubscripts) { const jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}, {"rows", {{{"name", "a0"}}, {{"name", "a1"}}}}, @@ -36,14 +36,14 @@ TEST(JsonDataSource, GetResolvesPathsAndSubscripts) EXPECT_EQ(src.getData("q.dims["), V()); // malformed subscript } -TEST(JsonDataSource, GetStripsOptionalSigil) +TEST(TestJsonDataSource, GetStripsOptionalSigil) { const jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; EXPECT_EQ(src.getData("$q.dims[0]"), src.getData("q.dims[0]")); EXPECT_EQ(src.getData("$q.dims[0]"), V(8)); } -TEST(JsonDataSource, SetScalarCreatesNestedObjects) +TEST(TestJsonDataSource, SetScalarCreatesNestedObjects) { jlogic::JsonDataSource src; src.setData("q.dims", V(4)); @@ -51,7 +51,7 @@ TEST(JsonDataSource, SetScalarCreatesNestedObjects) EXPECT_EQ(src.document(), (json{{"q", {{"dims", 4}}}})); } -TEST(JsonDataSource, SetSubscriptCreatesAndExtendsArray) +TEST(TestJsonDataSource, SetSubscriptCreatesAndExtendsArray) { jlogic::JsonDataSource src; // [N] forces array creation; gaps fill with null. @@ -61,7 +61,7 @@ TEST(JsonDataSource, SetSubscriptCreatesAndExtendsArray) EXPECT_EQ(src.getData("q.dims[0]"), V()); // gap reads as null } -TEST(JsonDataSource, SetOverwritesExistingArrayIndex) +TEST(TestJsonDataSource, SetOverwritesExistingArrayIndex) { jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; src.setData("$q.dims[0]", V(2)); // the motivating example @@ -69,7 +69,7 @@ TEST(JsonDataSource, SetOverwritesExistingArrayIndex) EXPECT_EQ(src.getData("q.dims[0]"), V(2)); } -TEST(JsonDataSource, SetWholeDocument) +TEST(TestJsonDataSource, SetWholeDocument) { jlogic::JsonDataSource src{json{{"x", 1}}}; src.setData("", V(V::Array{V(1), V(2)})); @@ -77,7 +77,7 @@ TEST(JsonDataSource, SetWholeDocument) EXPECT_EQ(src.getData("[1]"), V(2)); } -TEST(JsonDataSource, SetRoundTripsValueKinds) +TEST(TestJsonDataSource, SetRoundTripsValueKinds) { jlogic::JsonDataSource src; src.setData("b", V(true)); @@ -90,7 +90,7 @@ TEST(JsonDataSource, SetRoundTripsValueKinds) EXPECT_EQ(src.getData("a"), V(V::Array{V(1), V("two"), V(false)})); } -TEST(JsonDataSource, SetThenEvaluateReflectsChange) +TEST(TestJsonDataSource, SetThenEvaluateReflectsChange) { jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; const auto expr = jlogic::compile( @@ -100,7 +100,7 @@ TEST(JsonDataSource, SetThenEvaluateReflectsChange) EXPECT_EQ(expr(src), V(32)); } -TEST(JsonDataSource, SetRejectsMalformedPaths) +TEST(TestJsonDataSource, SetRejectsMalformedPaths) { jlogic::JsonDataSource src{json{{"q", {{"dims", {8, 16}}}}}}; EXPECT_THROW(src.setData("q.dims[0", V(1)), std::invalid_argument); // missing ']' diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp index 6f3202369115..3bcd748fce2f 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestJsonLogic.cpp @@ -41,7 +41,7 @@ V eval(const json& rule) } } // namespace -TEST(JsonLogic, Literals) +TEST(TestJsonLogic, Literals) { EXPECT_EQ(eval(42), V(42)); EXPECT_EQ(eval("hello"), V("hello")); @@ -49,7 +49,7 @@ TEST(JsonLogic, Literals) EXPECT_EQ(eval(json::array({1, 2, 3})), V(V::Array{V(1), V(2), V(3)})); } -TEST(JsonLogic, VarStockForm) +TEST(TestJsonLogic, VarStockForm) { EXPECT_EQ(eval(json({{"var", "x"}})), V(41)); EXPECT_EQ(eval(json({{"var", "nested.a.b"}})), V(7)); @@ -60,7 +60,7 @@ TEST(JsonLogic, VarStockForm) EXPECT_EQ(eval(json({{"var", "arr"}})), V(V::Array{V(10), V(20), V(30)})); } -TEST(JsonLogic, InlineVariables) +TEST(TestJsonLogic, InlineVariables) { EXPECT_EQ(eval("$x"), V(41)); EXPECT_EQ(eval("$nested.a.b"), V(7)); @@ -72,7 +72,7 @@ TEST(JsonLogic, InlineVariables) EXPECT_EQ(eval(json({{"==", json::array({"amd", "$name"})}})), V(true)); } -TEST(JsonLogic, SubscriptIndex) +TEST(TestJsonLogic, SubscriptIndex) { // [N] subscript, stock and inline forms EXPECT_EQ(eval(json({{"var", "arr[0]"}})), V(10)); @@ -90,7 +90,7 @@ TEST(JsonLogic, SubscriptIndex) EXPECT_EQ(eval("$nested[0]"), V()); } -TEST(JsonLogic, Arithmetic) +TEST(TestJsonLogic, Arithmetic) { EXPECT_EQ(eval(json({{"+", json::array({1, 2, 3})}})), V(6)); EXPECT_EQ(eval(json({{"+", json::array({"2", "3"})}})), V(5)); @@ -104,7 +104,7 @@ TEST(JsonLogic, Arithmetic) EXPECT_EQ(eval(json({{"+", json::array({{{"var", "x"}}, 1})}})), V(42)); } -TEST(JsonLogic, ComparisonIsStrict) +TEST(TestJsonLogic, ComparisonIsStrict) { EXPECT_EQ(eval(json({{"==", json::array({1, 1})}})), V(true)); EXPECT_EQ(eval(json({{"==", json::array({1, 1.0})}})), V(true)); @@ -122,7 +122,7 @@ TEST(JsonLogic, ComparisonIsStrict) EXPECT_EQ(eval(json({{"<", json::array({"abc", "abd"})}})), V(true)); } -TEST(JsonLogic, TruthinessAndLogic) +TEST(TestJsonLogic, TruthinessAndLogic) { EXPECT_EQ(eval(json({{"!", 0}})), V(true)); EXPECT_EQ(eval(json({{"!", 1}})), V(false)); @@ -134,7 +134,7 @@ TEST(JsonLogic, TruthinessAndLogic) EXPECT_EQ(eval(json({{"or", json::array({0, ""})}})), V("")); } -TEST(JsonLogic, IfTernary) +TEST(TestJsonLogic, IfTernary) { EXPECT_EQ(eval(json({{"if", json::array({true, "yes", "no"})}})), V("yes")); EXPECT_EQ(eval(json({{"if", json::array({false, "yes", "no"})}})), V("no")); @@ -144,7 +144,7 @@ TEST(JsonLogic, IfTernary) V(41)); } -TEST(JsonLogic, Composed) +TEST(TestJsonLogic, Composed) { EXPECT_EQ( eval(json({{"if", @@ -154,7 +154,7 @@ TEST(JsonLogic, Composed) V("small")); } -TEST(JsonLogic, Membership) +TEST(TestJsonLogic, Membership) { EXPECT_EQ(eval(json({{"in", json::array({20, "$arr"})}})), V(true)); EXPECT_EQ(eval(json({{"in", json::array({99, "$arr"})}})), V(false)); @@ -165,7 +165,7 @@ TEST(JsonLogic, Membership) EXPECT_EQ(eval(json({{"in", json::array({"z", "$name"})}})), V(false)); } -TEST(JsonLogic, MathExtensions) +TEST(TestJsonLogic, MathExtensions) { EXPECT_EQ(eval(json({{"ceil_div", json::array({100, 16})}})), V(7)); EXPECT_EQ(eval(json({{"ceil_div", json::array({32, 16})}})), V(2)); @@ -176,7 +176,7 @@ TEST(JsonLogic, MathExtensions) EXPECT_EQ(eval(json({{"rsqrt", 4}})), V(0.5)); } -TEST(JsonLogic, ValueOrDefault) +TEST(TestJsonLogic, ValueOrDefault) { EXPECT_EQ(eval(json({{"value_or_default", json::array({"$x", 99})}})), V(41)); EXPECT_EQ(eval(json({{"value_or_default", json::array({"$nope", 99})}})), V(99)); @@ -189,7 +189,7 @@ TEST(JsonLogic, ValueOrDefault) V("fallback")); } -TEST(JsonLogic, PresenceOperators) +TEST(TestJsonLogic, PresenceOperators) { // A resolving path is present; an unresolved one is not. EXPECT_EQ(eval(json({{"present", json::array({"$x"})}})), V(true)); @@ -217,7 +217,7 @@ TEST(JsonLogic, PresenceOperators) jlogic::JsonLogicCompileError); } -TEST(JsonLogic, NullPropagatesThroughEveryOtherOperator) +TEST(TestJsonLogic, NullPropagatesThroughEveryOtherOperator) { // An unresolved reference is "unknown", not a value. Every operator except // the presence pair and value_or_default must yield null rather than @@ -248,7 +248,7 @@ TEST(JsonLogic, NullPropagatesThroughEveryOtherOperator) EXPECT_TRUE(eval(json({{"!=", json::array({"$nope", "$missing"})}})).isNull()); } -TEST(JsonLogic, KleeneAndOrShortCircuitPastUnknown) +TEST(TestJsonLogic, KleeneAndOrShortCircuitPastUnknown) { // A definite false decides an `and` even beside an unknown, and a definite // true decides an `or`. This is what lets "absent, or present and @@ -264,7 +264,7 @@ TEST(JsonLogic, KleeneAndOrShortCircuitPastUnknown) EXPECT_EQ(eval(json({{"or", json::array({false, false})}})), V(false)); } -TEST(JsonLogic, DivisionAndDomainErrorsFailClosed) +TEST(TestJsonLogic, DivisionAndDomainErrorsFailClosed) { // A zero divisor declines instead of yielding inf/NaN, giving uniform // fail-closed zero-guarding (RFC 0018 A.7). @@ -281,7 +281,7 @@ TEST(JsonLogic, DivisionAndDomainErrorsFailClosed) EXPECT_EQ(eval(json({{"log2", 8}})), V(3)); } -TEST(JsonLogic, Umd0018ConstraintShapes) +TEST(TestJsonLogic, Umd0018ConstraintShapes) { EXPECT_EQ(eval(json({{"in", json::array({"$name", json::array({"amd", "xilinx"})})}})), V(true)); @@ -292,7 +292,7 @@ TEST(JsonLogic, Umd0018ConstraintShapes) EXPECT_EQ(eval(json({{"ceil_div", json::array({"$x", 16})}})), V(3)); } -TEST(JsonLogic, CompileOnceReuseAcrossData) +TEST(TestJsonLogic, CompileOnceReuseAcrossData) { const auto expr = jlogic::compile(json({{"*", json::array({"$x", 2})}})); @@ -302,7 +302,7 @@ TEST(JsonLogic, CompileOnceReuseAcrossData) EXPECT_EQ(expr(b), V(20)); } -TEST(JsonLogic, MalformedRulesThrowAtCompileTime) +TEST(TestJsonLogic, MalformedRulesThrowAtCompileTime) { EXPECT_THROW(jlogic::compile(json({{"nope", json::array({1, 2})}})), jlogic::JsonLogicCompileError); @@ -312,7 +312,7 @@ TEST(JsonLogic, MalformedRulesThrowAtCompileTime) jlogic::JsonLogicCompileError); } -TEST(JsonLogic, VariablesCollectsReferencedPaths) +TEST(TestJsonLogic, VariablesCollectsReferencedPaths) { using S = std::set; // Keep the Expression alive while draining the borrowed range into a set. @@ -335,7 +335,7 @@ TEST(JsonLogic, VariablesCollectsReferencedPaths) EXPECT_TRUE(vars(json(42)).empty()); } -TEST(JsonLogic, ReferencesVariableRootMatchesFirstToken) +TEST(TestJsonLogic, ReferencesVariableRootMatchesFirstToken) { const auto refs = [](const json& rule, std::string_view root) { const auto expr = jlogic::compile(rule); @@ -355,7 +355,7 @@ TEST(JsonLogic, ReferencesVariableRootMatchesFirstToken) EXPECT_FALSE(refs(json({{"+", json::array({1, 2})}}), "kernel")); } -TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) +TEST(TestJsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) { // range-for yields every occurrence, duplicates included const auto expr @@ -379,7 +379,7 @@ TEST(JsonLogic, VariablesRangeIsLazyAndKeepsDuplicates) EXPECT_TRUE(std::any_of(r.begin(), r.end(), [](const std::string& s) { return s == "y"; })); } -TEST(JsonLogic, WholeDocumentAndDynamicKeysRejected) +TEST(TestJsonLogic, WholeDocumentAndDynamicKeysRejected) { // whole-document references: inline sigil, empty var, empty array, null EXPECT_THROW(jlogic::compile(json("$")), jlogic::JsonLogicCompileError); diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index 131071e7c4fa..0bf109911aa7 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -124,12 +124,12 @@ constexpr umd::DeviceProperties K_DEVICE{/*ldsSize=*/65536, /*warpSize=*/64}; // ---- Compiler validation (A.10) ------------------------------------------ -TEST(UmdCompiler, AcceptsSdpaDescriptor) +TEST(TestUmdCompiler, AcceptsSdpaDescriptor) { EXPECT_NO_THROW(umd::UmdCompiler::compile(sdpaDescriptor())); } -TEST(UmdCompiler, PublishesReferencedBoundSymbols) +TEST(TestUmdCompiler, PublishesReferencedBoundSymbols) { const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); // The `shape` short-hands lower to `$q.rank` etc.; named dims are read @@ -140,56 +140,56 @@ TEST(UmdCompiler, PublishesReferencedBoundSymbols) EXPECT_TRUE(c.boundSymbols.count("attn_mask.present") == 1); } -TEST(UmdCompiler, RejectsWrongSchema) +TEST(TestUmdCompiler, RejectsWrongSchema) { json d = sdpaDescriptor(); d["schema"] = "hipdnn.umd/v2"; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsMalformedUuid) +TEST(TestUmdCompiler, RejectsMalformedUuid) { json d = sdpaDescriptor(); d["id"] = "not-a-uuid"; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsUnknownTopLevelKey) +TEST(TestUmdCompiler, RejectsUnknownTopLevelKey) { json d = sdpaDescriptor(); d["priority"] = 3; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsUnknownOperandName) +TEST(TestUmdCompiler, RejectsUnknownOperandName) { json d = sdpaDescriptor(); d["nodes"][0]["operands"]["not_a_name"] = "$bogus"; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsQuestionOnRequiredName) +TEST(TestUmdCompiler, RejectsQuestionOnRequiredName) { json d = sdpaDescriptor(); d["nodes"][0]["operands"]["q"] = "$q?"; // q is required EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsMissingQuestionOnOptionalName) +TEST(TestUmdCompiler, RejectsMissingQuestionOnOptionalName) { json d = sdpaDescriptor(); d["nodes"][0]["operands"]["attn_mask"] = "$attn_mask"; // optional, needs ? EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsReservedRootAsNodeId) +TEST(TestUmdCompiler, RejectsReservedRootAsNodeId) { json d = sdpaDescriptor(); d["nodes"][0]["id"] = "device"; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsTypeMismatchIntVsDtype) +TEST(TestUmdCompiler, RejectsTypeMismatchIntVsDtype) { json d = sdpaDescriptor(); // $q.head_size is Int; comparing it to a Dtype string is a compile error. @@ -197,28 +197,28 @@ TEST(UmdCompiler, RejectsTypeMismatchIntVsDtype) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsNonBooleanCriteria) +TEST(TestUmdCompiler, RejectsNonBooleanCriteria) { json d = sdpaDescriptor(); d["criteria"] = json::parse(R"({"+": ["$q.head_size", 1]})"); // Int, not Bool EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsCustomOperation) +TEST(TestUmdCompiler, RejectsCustomOperation) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"hipdnn.strides_fit_u32": ["$q"]})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsPresentOnRequiredOperand) +TEST(TestUmdCompiler, RejectsPresentOnRequiredOperand) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"!": "$q.present"})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsWrongArity) +TEST(TestUmdCompiler, RejectsWrongArity) { // Binary/unary operators with the wrong argument count must refuse at // compile with a UmdCompileError, not escape as std::out_of_range. @@ -235,21 +235,21 @@ TEST(UmdCompiler, RejectsWrongArity) } } -TEST(UmdCompiler, RejectsMalformedSubscript) +TEST(TestUmdCompiler, RejectsMalformedSubscript) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"==": ["$q.dims[x]", 2]})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsBareNodeIdAsValue) +TEST(TestUmdCompiler, RejectsBareNodeIdAsValue) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"==": ["$sdpa_fwd", 0]})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsLayoutAliasRankMismatch) +TEST(TestUmdCompiler, RejectsLayoutAliasRankMismatch) { // $q is pinned rank 4 by the shape short-hand; a rank-5 alias can never // match, so it is refused at compile rather than always declining. @@ -258,7 +258,7 @@ TEST(UmdCompiler, RejectsLayoutAliasRankMismatch) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, AcceptsWellFormedArithmeticAndIf) +TEST(TestUmdCompiler, AcceptsWellFormedArithmeticAndIf) { // Guard the arity checks against over-rejecting valid n-ary / binary / if // forms. @@ -270,7 +270,7 @@ TEST(UmdCompiler, AcceptsWellFormedArithmeticAndIf) // ---- Matcher: accept / reject -------------------------------------------- -TEST(UniversalGraphMatcher, OwnsSingleDescriptor) +TEST(TestUniversalGraphMatcher, OwnsSingleDescriptor) { // A matcher has a 1:1 relationship with its UMD: it exposes that one // descriptor's id and compiled form. @@ -280,7 +280,7 @@ TEST(UniversalGraphMatcher, OwnsSingleDescriptor) EXPECT_EQ(m.descriptor().nodes.size(), 1u); } -TEST(UniversalGraphMatcher, MatchesValidSdpaForward) +TEST(TestUniversalGraphMatcher, MatchesValidSdpaForward) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -291,7 +291,7 @@ TEST(UniversalGraphMatcher, MatchesValidSdpaForward) EXPECT_EQ(r.umdId, "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); } -TEST(UniversalGraphMatcher, DeclinesWrongHeadSize) +TEST(TestUniversalGraphMatcher, DeclinesWrongHeadSize) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 64}, data::DataType::BFLOAT16); @@ -299,7 +299,7 @@ TEST(UniversalGraphMatcher, DeclinesWrongHeadSize) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, DeclinesUnsupportedDtype) +TEST(TestUniversalGraphMatcher, DeclinesUnsupportedDtype) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::HALF); @@ -307,7 +307,7 @@ TEST(UniversalGraphMatcher, DeclinesUnsupportedDtype) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, DeclinesWhenUnsupportedOptionalPresent) +TEST(TestUniversalGraphMatcher, DeclinesWhenUnsupportedOptionalPresent) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16, /*withAttnMask=*/true); @@ -315,7 +315,7 @@ TEST(UniversalGraphMatcher, DeclinesWhenUnsupportedOptionalPresent) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, DeclinesOverrideShapeGraph) +TEST(TestUniversalGraphMatcher, DeclinesOverrideShapeGraph) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 128}, @@ -326,7 +326,7 @@ TEST(UniversalGraphMatcher, DeclinesOverrideShapeGraph) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, DeclinesGraphWithoutMatchingOpcode) +TEST(TestUniversalGraphMatcher, DeclinesGraphWithoutMatchingOpcode) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = hipdnn_test_sdk::utilities::createValidBlockScaleQuantizeGraph(); @@ -334,7 +334,7 @@ TEST(UniversalGraphMatcher, DeclinesGraphWithoutMatchingOpcode) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, DeclinesNodelessGraphWithoutThrowing) +TEST(TestUniversalGraphMatcher, DeclinesNodelessGraphWithoutThrowing) { // A valid but empty graph must fail closed, not throw (nodeWrappers() would // throw on a null nodes vector). @@ -346,7 +346,7 @@ TEST(UniversalGraphMatcher, DeclinesNodelessGraphWithoutThrowing) // ---- Bindings: queryable BindingContext (RFC 0018 §4/§15) ---------------- -TEST(BindingContext, ResolvesEveryNamespaceForm) +TEST(TestBindingContext, ResolvesEveryNamespaceForm) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -388,7 +388,7 @@ TEST(BindingContext, ResolvesEveryNamespaceForm) EXPECT_EQ(b.get("$device.warp_size").asInt(), 64); } -TEST(BindingContext, FailsClosedOnBadPaths) +TEST(TestBindingContext, FailsClosedOnBadPaths) { const umd::UniversalGraphMatcher m = makeSdpaMatcher(); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -407,7 +407,7 @@ TEST(BindingContext, FailsClosedOnBadPaths) // ---- Lowering: layout alias (A.8) ---------------------------------------- -TEST(UmdCompiler, LayoutAliasMatchesContiguousLayout) +TEST(TestUmdCompiler, LayoutAliasMatchesContiguousLayout) { const umd::UniversalGraphMatcher m(json::parse(R"JSON({ "schema": "hipdnn.umd/v1", @@ -427,7 +427,7 @@ TEST(UmdCompiler, LayoutAliasMatchesContiguousLayout) EXPECT_TRUE(m.match(K_DEVICE, g).matched); } -TEST(BindingContext, StrideOrderIsPublishedOutermostFirst) +TEST(TestBindingContext, StrideOrderIsPublishedOutermostFirst) { // The conversion is observable, not cosmetic: an NHWC tensor must publish // [0,2,3,1] (N,H,W,C read left to right), NOT extractStrideOrder's @@ -560,7 +560,7 @@ json twoNodeChainDescriptor() } // namespace -TEST(UmdCompiler, AcceptsMultiNodeDescriptor) +TEST(TestUmdCompiler, AcceptsMultiNodeDescriptor) { const umd::CompiledUmd c = umd::UmdCompiler::compile(twoNodeChainDescriptor()); EXPECT_EQ(c.nodes.size(), 2u); @@ -571,21 +571,21 @@ TEST(UmdCompiler, AcceptsMultiNodeDescriptor) ASSERT_NE(c.findTvar("mid"), nullptr); } -TEST(UmdCompiler, RejectsDuplicateNodeId) +TEST(TestUmdCompiler, RejectsDuplicateNodeId) { json d = twoNodeChainDescriptor(); d["nodes"][1]["id"] = "sdpa1"; // collides with node 0 EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsVariableProducedTwice) +TEST(TestUmdCompiler, RejectsVariableProducedTwice) { json d = twoNodeChainDescriptor(); d["nodes"][1]["results"]["o"] = "$mid"; // $mid produced by both nodes EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UniversalGraphMatcher, MatchesTwoNodeChainAndBindsEachNode) +TEST(TestUniversalGraphMatcher, MatchesTwoNodeChainAndBindsEachNode) { const umd::UniversalGraphMatcher m(twoNodeChainDescriptor()); auto builder = buildTwoNodeSdpaGraph(/*chained=*/true); @@ -605,7 +605,7 @@ TEST(UniversalGraphMatcher, MatchesTwoNodeChainAndBindsEachNode) EXPECT_FALSE(b.get("$sdpa2.alibi_mask").asBool()); } -TEST(UniversalGraphMatcher, DeclinesWhenEdgeIsInconsistent) +TEST(TestUniversalGraphMatcher, DeclinesWhenEdgeIsInconsistent) { const umd::UniversalGraphMatcher m(twoNodeChainDescriptor()); // node2 reads an independent Q, so no tensor satisfies the shared `$mid` @@ -615,7 +615,7 @@ TEST(UniversalGraphMatcher, DeclinesWhenEdgeIsInconsistent) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, PerNodeAttributesAreDistinct) +TEST(TestUniversalGraphMatcher, PerNodeAttributesAreDistinct) { const umd::UniversalGraphMatcher m(twoNodeChainDescriptor()); // Chained graph, but node2's alibi_mask is set; the criterion @@ -642,7 +642,7 @@ json sdpaKernelDescriptor() } // namespace -TEST(UmdCompiler, AcceptsKernelReference) +TEST(TestUmdCompiler, AcceptsKernelReference) { // A criterion over a $kernel field compiles (DYNAMIC wildcard) and the // field name it reads is recorded. @@ -652,13 +652,13 @@ TEST(UmdCompiler, AcceptsKernelReference) EXPECT_TRUE(c.boundSymbols.count("kernel.head_dim") == 1); } -TEST(UmdCompiler, StockDescriptorDoesNotReferenceKernel) +TEST(TestUmdCompiler, StockDescriptorDoesNotReferenceKernel) { const umd::CompiledUmd c = umd::UmdCompiler::compile(sdpaDescriptor()); EXPECT_TRUE(c.kernelFields.empty()); } -TEST(UmdCompiler, RecordsEveryDistinctKernelFieldRead) +TEST(TestUmdCompiler, RecordsEveryDistinctKernelFieldRead) { // The memoization key is the set of `$kernel.*` fields the matcher reads, // without the `kernel.` prefix; a field read twice contributes once, and a @@ -670,7 +670,7 @@ TEST(UmdCompiler, RecordsEveryDistinctKernelFieldRead) EXPECT_EQ(c.kernelFields, (std::set{"tile_m", "tile_n"})); } -TEST(UmdCompiler, KernelFieldUnifiesWithAnyScalarDomain) +TEST(TestUmdCompiler, KernelFieldUnifiesWithAnyScalarDomain) { // A $kernel field is DYNAMIC: it compiles against an int, a dtype string, // and a bool alike -- none is a static type error. @@ -685,14 +685,14 @@ TEST(UmdCompiler, KernelFieldUnifiesWithAnyScalarDomain) } } -TEST(UmdCompiler, KernelReferenceNeedsAField) +TEST(TestUmdCompiler, KernelReferenceNeedsAField) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"==": ["$kernel", 1]})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, KernelFieldAsIfCondition) +TEST(TestUmdCompiler, KernelFieldAsIfCondition) { // A DYNAMIC $kernel field is accepted in an `if` condition, consistent with // its acceptance in and/or/! boolean positions. @@ -701,7 +701,7 @@ TEST(UmdCompiler, KernelFieldAsIfCondition) EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); } -TEST(UmdCompiler, KernelFieldDoesNotUnifyWithArray) +TEST(TestUmdCompiler, KernelFieldDoesNotUnifyWithArray) { // DYNAMIC is a scalar wildcard: comparing a $kernel field to an array // literal is a static type error, not a runtime-deferred check. @@ -710,21 +710,21 @@ TEST(UmdCompiler, KernelFieldDoesNotUnifyWithArray) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, KernelIsReservedAsPatternVariable) +TEST(TestUmdCompiler, KernelIsReservedAsPatternVariable) { json d = sdpaDescriptor(); d["nodes"][0]["operands"]["q"] = "$kernel"; // bind a tensor var to the reserved root EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, KernelIsReservedAsNodeId) +TEST(TestUmdCompiler, KernelIsReservedAsNodeId) { json d = sdpaDescriptor(); d["nodes"][0]["id"] = "kernel"; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UniversalGraphMatcher, ReportsKernelMetadataReference) +TEST(TestUniversalGraphMatcher, ReportsKernelMetadataReference) { const umd::UniversalGraphMatcher mk(sdpaKernelDescriptor()); EXPECT_TRUE(mk.referencesKernelMetadata()); @@ -732,7 +732,7 @@ TEST(UniversalGraphMatcher, ReportsKernelMetadataReference) EXPECT_FALSE(m.referencesKernelMetadata()); } -TEST(UniversalGraphMatcher, PublishesKernelFieldsItReads) +TEST(TestUniversalGraphMatcher, PublishesKernelFieldsItReads) { // The set is the loader's KMD existence check and the per-kernel // memoization key (RFC 0017 §5): exactly the names read, prefix stripped. @@ -744,7 +744,7 @@ TEST(UniversalGraphMatcher, PublishesKernelFieldsItReads) EXPECT_TRUE(m.kernelFields().empty()); } -TEST(UniversalGraphMatcher, PublishesBoundSymbols) +TEST(TestUniversalGraphMatcher, PublishesBoundSymbols) { // A pack's dispatch descriptor is checked against the fields its matchers // bind, so the set must be reachable from the matcher (RFC 0017 §6). @@ -753,7 +753,7 @@ TEST(UniversalGraphMatcher, PublishesBoundSymbols) EXPECT_TRUE(m.boundSymbols().count("q.head_size") == 1); } -TEST(UniversalGraphMatcher, TwoArgMatchThrowsWhenDescriptorNeedsKernel) +TEST(TestUniversalGraphMatcher, TwoArgMatchThrowsWhenDescriptorNeedsKernel) { const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -762,7 +762,7 @@ TEST(UniversalGraphMatcher, TwoArgMatchThrowsWhenDescriptorNeedsKernel) EXPECT_THROW(m.match(K_DEVICE, g), std::logic_error); } -TEST(UniversalGraphMatcher, MatchesWithSuppliedKernelMetadata) +TEST(TestUniversalGraphMatcher, MatchesWithSuppliedKernelMetadata) { const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -774,7 +774,7 @@ TEST(UniversalGraphMatcher, MatchesWithSuppliedKernelMetadata) EXPECT_EQ(r.bindings.get("$kernel.head_dim").asInt(), 128); } -TEST(UniversalGraphMatcher, DeclinesWhenKernelMetadataMismatches) +TEST(TestUniversalGraphMatcher, DeclinesWhenKernelMetadataMismatches) { const umd::UniversalGraphMatcher m(sdpaKernelDescriptor()); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -783,7 +783,7 @@ TEST(UniversalGraphMatcher, DeclinesWhenKernelMetadataMismatches) EXPECT_FALSE(m.match(K_DEVICE, g, json{{"head_dim", 64}}).matched); } -TEST(UniversalGraphMatcher, EmptyKernelMetadataDeclinesComparison) +TEST(TestUniversalGraphMatcher, EmptyKernelMetadataDeclinesComparison) { // Defensive edge case: production metadata is fully resolved so every // referenced field is present, but an unbound/empty document must not @@ -796,7 +796,7 @@ TEST(UniversalGraphMatcher, EmptyKernelMetadataDeclinesComparison) // ---- Descriptor versions (RFC 0017 §4 / A.10 §1) ------------------------- -TEST(UmdCompiler, AcceptsAndPublishesWellFormedVersions) +TEST(TestUmdCompiler, AcceptsAndPublishesWellFormedVersions) { json d = sdpaDescriptor(); d["version"] = "1.0"; @@ -807,7 +807,7 @@ TEST(UmdCompiler, AcceptsAndPublishesWellFormedVersions) EXPECT_EQ(c.sdkVersion, "1.2"); } -TEST(UmdCompiler, VersionKeysAreOptionalAndDefaultToOneZero) +TEST(TestUmdCompiler, VersionKeysAreOptionalAndDefaultToOneZero) { // A descriptor omitting both still compiles, and both read "1.0": the // version a descriptor authored before either key existed implies @@ -818,7 +818,7 @@ TEST(UmdCompiler, VersionKeysAreOptionalAndDefaultToOneZero) EXPECT_EQ(c.sdkVersion, "1.0"); } -TEST(UmdCompiler, RejectsMalformedVersion) +TEST(TestUmdCompiler, RejectsMalformedVersion) { for(const char* bad : {"1", "1.0.0", "1.", ".0", "+1.0", "-1.0", "1.x", "one.zero", ""}) { @@ -831,14 +831,14 @@ TEST(UmdCompiler, RejectsMalformedVersion) } } -TEST(UmdCompiler, RejectsNonStringVersion) +TEST(TestUmdCompiler, RejectsNonStringVersion) { json d = sdpaDescriptor(); d["version"] = 1.0; EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UmdCompiler, RejectsVersionNewerThanTheRuntime) +TEST(TestUmdCompiler, RejectsVersionNewerThanTheRuntime) { // RFC 0017 §4: a minor newer than the runtime's carries features this // runtime cannot understand, so the descriptor is refused, never silently @@ -861,7 +861,7 @@ TEST(UmdCompiler, RejectsVersionNewerThanTheRuntime) EXPECT_THROW(umd::UmdCompiler::compile(newerSdk), umd::UmdCompileError); } -TEST(UmdCompiler, AcceptsAnOlderMinorWithinTheSameMajor) +TEST(TestUmdCompiler, AcceptsAnOlderMinorWithinTheSameMajor) { // RFC 0017 §4: "A file stamped 1.0 loads on a 1.1 runtime." An older minor // is never refused, so a descriptor stays loadable on the oldest runtime @@ -884,7 +884,7 @@ TEST(UmdCompiler, AcceptsAnOlderMinorWithinTheSameMajor) } } -TEST(UmdCompiler, VersionComparisonIsNumericNotLexicographic) +TEST(TestUmdCompiler, VersionComparisonIsNumericNotLexicographic) { // "1.10" is above "1.9" numerically but below it as text; a lexicographic // comparison would order these backwards. @@ -956,7 +956,7 @@ flatbuffers::FlatBufferBuilder buildSdpaGraphRequiringSchema(std::uint32_t major } // namespace -TEST(UniversalGraphMatcher, DeclinesAMatcherBelowTheGraphsRequiredSchema) +TEST(TestUniversalGraphMatcher, DeclinesAMatcherBelowTheGraphsRequiredSchema) { // RFC 0017 §4: a graph reports the schema version its own contents require, // and a matcher declaring less is skipped instead of asked -- it would @@ -976,7 +976,7 @@ TEST(UniversalGraphMatcher, DeclinesAMatcherBelowTheGraphsRequiredSchema) EXPECT_FALSE(m.match(K_DEVICE, gNewer).matched); } -TEST(UniversalGraphMatcher, MatcherAtOrAboveTheGraphsRequiredSchemaRuns) +TEST(TestUniversalGraphMatcher, MatcherAtOrAboveTheGraphsRequiredSchemaRuns) { // The same graph, matched by a descriptor that adopted the newer schema. json d = sdpaDescriptor(); @@ -993,7 +993,7 @@ TEST(UniversalGraphMatcher, MatcherAtOrAboveTheGraphsRequiredSchemaRuns) EXPECT_TRUE(m.match(K_DEVICE, gBaseline).matched); } -TEST(UniversalGraphMatcher, AnUnstampedGraphReadsAsTheBaselineFloor) +TEST(TestUniversalGraphMatcher, AnUnstampedGraphReadsAsTheBaselineFloor) { // A hand-built fixture or a graph written before the field existed carries // no stamp; it reads as the 1.0 baseline rather than declining every @@ -1026,7 +1026,7 @@ json sdpaNotPresentDescriptor() } // namespace -TEST(UmdCompiler, AcceptsPresenceOperators) +TEST(TestUmdCompiler, AcceptsPresenceOperators) { EXPECT_NO_THROW(umd::UmdCompiler::compile(sdpaNotPresentDescriptor())); @@ -1043,7 +1043,7 @@ TEST(UmdCompiler, AcceptsPresenceOperators) } } -TEST(UmdCompiler, RejectsPresenceOperatorOnNonReference) +TEST(TestUmdCompiler, RejectsPresenceOperatorOnNonReference) { for(const char* bad : {R"({"present": [4]})", R"({"present": ["BFLOAT16"]})", @@ -1057,14 +1057,14 @@ TEST(UmdCompiler, RejectsPresenceOperatorOnNonReference) } } -TEST(UmdCompiler, RejectsPresenceOperatorOnUnresolvedReference) +TEST(TestUmdCompiler, RejectsPresenceOperatorOnUnresolvedReference) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"present": ["$not_a_tensor"]})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(UniversalGraphMatcher, NotPresentOperatorMatchesWhenOptionalsAbsent) +TEST(TestUniversalGraphMatcher, NotPresentOperatorMatchesWhenOptionalsAbsent) { const umd::UniversalGraphMatcher m(sdpaNotPresentDescriptor()); auto builder = buildSdpaGraph({2, 8, 16, 128}, data::DataType::BFLOAT16); @@ -1072,7 +1072,7 @@ TEST(UniversalGraphMatcher, NotPresentOperatorMatchesWhenOptionalsAbsent) EXPECT_TRUE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, NotPresentOperatorDeclinesWhenAnyOptionalBound) +TEST(TestUniversalGraphMatcher, NotPresentOperatorDeclinesWhenAnyOptionalBound) { // The n-ary form is an `and`-fold: one supplied operand out of the list // decides the whole call. @@ -1082,7 +1082,7 @@ TEST(UniversalGraphMatcher, NotPresentOperatorDeclinesWhenAnyOptionalBound) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, PresenceOperatorsEvaluateRatherThanDecline) +TEST(TestUniversalGraphMatcher, PresenceOperatorsEvaluateRatherThanDecline) { // The key distinction from a bare field read: a field read on an absent // optional resolves null and declines, but `present`/`not_present` always @@ -1124,7 +1124,7 @@ json sdpaWithCriterion(const char* extra) } // namespace -TEST(UniversalGraphMatcher, FieldReadOnAnAbsentOptionalNeverAccepts) +TEST(TestUniversalGraphMatcher, FieldReadOnAnAbsentOptionalNeverAccepts) { // RFC 0017 §5: "A dtype or layout check on an absent $bias neither passes // nor fails, it simply does not run." The hazard is the negative and @@ -1145,7 +1145,7 @@ TEST(UniversalGraphMatcher, FieldReadOnAnAbsentOptionalNeverAccepts) } } -TEST(UniversalGraphMatcher, AbsentOrPresentAndConstrainedAcceptsBothShapes) +TEST(TestUniversalGraphMatcher, AbsentOrPresentAndConstrainedAcceptsBothShapes) { // RFC 0017 §5: the pair a pack writes when it serves an optional operand // only in a particular form. The `or` must see past the unresolved second @@ -1166,7 +1166,7 @@ TEST(UniversalGraphMatcher, AbsentOrPresentAndConstrainedAcceptsBothShapes) EXPECT_TRUE(m.match(K_DEVICE, gPresent).matched); } -TEST(UniversalGraphMatcher, DefiniteFalseStillDeclinesBesideAnUnresolvedCheck) +TEST(TestUniversalGraphMatcher, DefiniteFalseStillDeclinesBesideAnUnresolvedCheck) { // An unresolved sibling must not rescue a criterion that definitely fails: // `and` short-circuits on the definite false rather than going unresolved. @@ -1178,7 +1178,7 @@ TEST(UniversalGraphMatcher, DefiniteFalseStillDeclinesBesideAnUnresolvedCheck) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, PresentOperatorSeesABoundOptional) +TEST(TestUniversalGraphMatcher, PresentOperatorSeesABoundOptional) { // Same descriptor, but the graph supplies `attn_mask`: `present` now reads // true and the criterion declines, proving it tracks binding rather than @@ -1202,7 +1202,7 @@ TEST(UniversalGraphMatcher, PresentOperatorSeesABoundOptional) // ---- value_or_default with an expression fallback (RFC 0017 §5) ---------- -TEST(UmdCompiler, ValueOrDefaultAcceptsFieldReferenceFallback) +TEST(TestUmdCompiler, ValueOrDefaultAcceptsFieldReferenceFallback) { // "this field, else that one": both arms are Int, so the pair unifies. json d = sdpaDescriptor(); @@ -1211,7 +1211,7 @@ TEST(UmdCompiler, ValueOrDefaultAcceptsFieldReferenceFallback) EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); } -TEST(UmdCompiler, ValueOrDefaultAcceptsDynamicArm) +TEST(TestUmdCompiler, ValueOrDefaultAcceptsDynamicArm) { // A `$kernel.*` arm is DYNAMIC, so the other arm supplies the result kind // and the comparison against an Int still type-checks. @@ -1225,7 +1225,7 @@ TEST(UmdCompiler, ValueOrDefaultAcceptsDynamicArm) EXPECT_NO_THROW(umd::UmdCompiler::compile(e)); } -TEST(UmdCompiler, RejectsTypeIncompatibleValueOrDefaultArms) +TEST(TestUmdCompiler, RejectsTypeIncompatibleValueOrDefaultArms) { for(const char* bad : {R"({"==": [{"value_or_default": ["$q.head_size", "BFLOAT16"]}, 128]})", @@ -1238,7 +1238,7 @@ TEST(UmdCompiler, RejectsTypeIncompatibleValueOrDefaultArms) } } -TEST(UniversalGraphMatcher, ValueOrDefaultFallsBackToTheSecondField) +TEST(TestUniversalGraphMatcher, ValueOrDefaultFallsBackToTheSecondField) { // `$sdpa_fwd.dropout_probability` is an unset optional attribute, so the // fallback expression is evaluated and its value is what the comparison @@ -1254,7 +1254,7 @@ TEST(UniversalGraphMatcher, ValueOrDefaultFallsBackToTheSecondField) // ---- $graph.is_override_shape_enabled (RFC 0017 §5) ---------------------- -TEST(UmdCompiler, AcceptsGraphOverrideShapeFlag) +TEST(TestUmdCompiler, AcceptsGraphOverrideShapeFlag) { json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"!": "$graph.is_override_shape_enabled"})")); @@ -1263,7 +1263,7 @@ TEST(UmdCompiler, AcceptsGraphOverrideShapeFlag) EXPECT_TRUE(c.boundSymbols.count("graph.is_override_shape_enabled") == 1); } -TEST(UmdCompiler, RejectsGraphOverrideShapeFlagAgainstNonBool) +TEST(TestUmdCompiler, RejectsGraphOverrideShapeFlagAgainstNonBool) { // The flag is Bool; comparing it to an int is a static type error. json d = sdpaDescriptor(); @@ -1272,7 +1272,7 @@ TEST(UmdCompiler, RejectsGraphOverrideShapeFlagAgainstNonBool) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(BindingContext, ResolvesGraphOverrideShapeFlag) +TEST(TestBindingContext, ResolvesGraphOverrideShapeFlag) { // The descriptor key `allow_override_shape` is the MATCHER's opt-in; the // `$graph.is_override_shape_enabled` token is the GRAPH's state. A matcher @@ -1298,7 +1298,7 @@ TEST(BindingContext, ResolvesGraphOverrideShapeFlag) EXPECT_TRUE(ro.bindings.get("$graph.is_override_shape_enabled").asBool()); } -TEST(UniversalGraphMatcher, DeclinesOnGraphOverrideShapeFlagCriterion) +TEST(TestUniversalGraphMatcher, DeclinesOnGraphOverrideShapeFlagCriterion) { // An opted-in matcher can still refuse an override-shape graph by reading // the graph's own flag. @@ -1335,7 +1335,7 @@ json sdpaScaleDescriptor() } // namespace -TEST(UmdCompiler, AcceptsTensorValueFields) +TEST(TestUmdCompiler, AcceptsTensorValueFields) { json d = sdpaScaleDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"!": "$scale.is_runtime_pass_by_value"})")); @@ -1343,7 +1343,7 @@ TEST(UmdCompiler, AcceptsTensorValueFields) EXPECT_NO_THROW(umd::UmdCompiler::compile(d)); } -TEST(UmdCompiler, TensorValueFieldsAreTyped) +TEST(TestUmdCompiler, TensorValueFieldsAreTyped) { // is_runtime_pass_by_value is Bool and value_f32 is Float; using either in // the wrong domain is a static type error. @@ -1361,7 +1361,7 @@ TEST(UmdCompiler, TensorValueFieldsAreTyped) EXPECT_NO_THROW(umd::UmdCompiler::compile(f)); } -TEST(UmdCompiler, RejectsShapeDimNameCollidingWithTensorValueFields) +TEST(TestUmdCompiler, RejectsShapeDimNameCollidingWithTensorValueFields) { // A.10 §7: a `shape` short-hand may not name a dim after a reserved field. for(const char* name : {"is_runtime_pass_by_value", "value_f32"}) @@ -1373,7 +1373,7 @@ TEST(UmdCompiler, RejectsShapeDimNameCollidingWithTensorValueFields) } } -TEST(BindingContext, ResolvesTensorValueFields) +TEST(TestBindingContext, ResolvesTensorValueFields) { const umd::UniversalGraphMatcher m(sdpaScaleDescriptor()); auto builder = buildSdpaGraph({2, 8, 16, 128}, @@ -1397,7 +1397,7 @@ TEST(BindingContext, ResolvesTensorValueFields) EXPECT_TRUE(b.get("$q.value_f32").isNull()); } -TEST(BindingContext, ValueF32DeclinesWithoutACompileTimeValue) +TEST(TestBindingContext, ValueF32DeclinesWithoutACompileTimeValue) { // "present only when the tensor carries a compile-time value at all": the // union is NONE, so the token reads null and a criterion over it declines @@ -1412,7 +1412,7 @@ TEST(BindingContext, ValueF32DeclinesWithoutACompileTimeValue) EXPECT_FALSE(r.bindings.get("$q.value_f32").isNumber()); } -TEST(UniversalGraphMatcher, DeclinesOnValueF32OfAValuelessTensor) +TEST(TestUniversalGraphMatcher, DeclinesOnValueF32OfAValuelessTensor) { // The fail-closed consequence: a criterion reading value_f32 on a tensor // with no compile-time value declines the whole match. @@ -1424,7 +1424,7 @@ TEST(UniversalGraphMatcher, DeclinesOnValueF32OfAValuelessTensor) EXPECT_FALSE(m.match(K_DEVICE, g).matched); } -TEST(UniversalGraphMatcher, MatchesOnACompileTimeScaleValue) +TEST(TestUniversalGraphMatcher, MatchesOnACompileTimeScaleValue) { json d = sdpaScaleDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"==": ["$scale.value_f32", 1.0]})")); @@ -1526,7 +1526,7 @@ jlogic::Value scaleValueF32(flatbuffers::FlatBufferBuilder& builder) } // namespace -TEST(BindingContext, ValueF32CoercesEveryUnionArm) +TEST(TestBindingContext, ValueF32CoercesEveryUnionArm) { // RFC 0017 §5: the schema layer "coerces whichever arm is set to f32" and // publishes one typed token. Every arm must produce a number, not null. @@ -1568,7 +1568,7 @@ TEST(BindingContext, ValueF32CoercesEveryUnionArm) EXPECT_DOUBLE_EQ(boolean.toNumber(), 1.0); } -TEST(BindingContext, ValueF32DecodesFloat8ThroughTheTensorDataType) +TEST(TestBindingContext, ValueF32DecodesFloat8ThroughTheTensorDataType) { // Float8Value stores raw bits; the tensor's data_type says which 8-bit // format they encode. The bits 0x38 are 1.0 in E4M3 (exponent bias 7) but diff --git a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp index b5c7b1a49992..b380afd0b4bb 100644 --- a/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp +++ b/projects/hipdnn/flatbuffers_sdk/tests/umd/TestOpSchemaRegistry.cpp @@ -87,7 +87,7 @@ fb::FlatBufferBuilder buildSdpa() } // namespace -TEST(UmdOpSchemaRegistry, SdpaEntryResolvesByNameAndType) +TEST(TestUmdOpSchemaRegistry, SdpaEntryResolvesByNameAndType) { const auto* byName = umd::lookupOpByName("sdpa_fwd"); // the umd_opcode shorthand const auto* byType = umd::lookupOpByType(data::NodeAttributes::SdpaAttributes); @@ -100,7 +100,7 @@ TEST(UmdOpSchemaRegistry, SdpaEntryResolvesByNameAndType) EXPECT_EQ(umd::lookupOpByName("NoSuchOp"), nullptr); } -TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) +TEST(TestUmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); @@ -115,7 +115,7 @@ TEST(UmdOpSchemaRegistry, SdpaRequiredOperandsAndResult) EXPECT_FALSE(o->optional); } -TEST(UmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) +TEST(TestUmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); @@ -136,7 +136,7 @@ TEST(UmdOpSchemaRegistry, SdpaOptionalOperandsClassifiedOptional) } // Optionality parity with the schema `= null` defaults / generated Optional. -TEST(UmdOpSchemaRegistry, SdpaScalarAttributeOptionalityParity) +TEST(TestUmdOpSchemaRegistry, SdpaScalarAttributeOptionalityParity) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); @@ -166,7 +166,7 @@ TEST(UmdOpSchemaRegistry, SdpaScalarAttributeOptionalityParity) } } -TEST(UmdOpSchemaRegistry, SdpaScalarAttributeTypes) +TEST(TestUmdOpSchemaRegistry, SdpaScalarAttributeTypes) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); @@ -176,7 +176,7 @@ TEST(UmdOpSchemaRegistry, SdpaScalarAttributeTypes) EXPECT_EQ(findAttr(e, "diagonal_alignment")->type, umd::AttrType::DTYPE); } -TEST(UmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) +TEST(TestUmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); @@ -195,7 +195,7 @@ TEST(UmdOpSchemaRegistry, AccessorRoundTripReadsLiveValues) EXPECT_EQ(uid, 201); } -TEST(UmdOpSchemaRegistry, AbsentOptionalOperandReadsFalse) +TEST(TestUmdOpSchemaRegistry, AbsentOptionalOperandReadsFalse) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); @@ -208,7 +208,7 @@ TEST(UmdOpSchemaRegistry, AbsentOptionalOperandReadsFalse) EXPECT_FALSE(findInputTensor(e, "page_table_k")->read(a, uid)); } -TEST(UmdOpSchemaRegistry, ScalarReadersReflectPresenceAndValue) +TEST(TestUmdOpSchemaRegistry, ScalarReadersReflectPresenceAndValue) { const auto* e = sdpaEntry(); ASSERT_NE(e, nullptr); From 09d825b76d799e91c21eab0bfa638e66749b2d65 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 6 Aug 2026 12:51:02 -0600 Subject: [PATCH 57/60] chore(umd): regenerate op-schema registry for the new graph ops develop added ResampleBwdAttributes and MoeGroupedMatmulAttributes to the NodeAttributes union. The registry enumerates that union, so the committed header was two entries short. Neither table carries umd_* annotations, so both land as attribute-only entries keyed on their table names. (cherry picked from commit 6e09bbeff9aeab457ac6617b796d0e759be959ce) (cherry picked from commit 5afc3f60d5be209226106d210c1c10ee6d330177) --- .../umd/op_schema_registry_generated.hpp | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp index 77b2d80e8c0a..96050da6fe48 100644 --- a/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp +++ b/projects/hipdnn/flatbuffers_sdk/include/hipdnn_flatbuffers_sdk/umd/op_schema_registry_generated.hpp @@ -314,6 +314,31 @@ inline const AttrBinding attributes[] = { }; } // namespace op19 +// ResampleBwdAttributes +namespace op20 { +inline const AttrBinding attributes[] = { + {"dx_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dx_tensor_uid()); return s; }}, + {"dy_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->dy_tensor_uid()); return s; }}, + {"index_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->index_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"padding_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNamePaddingMode(static_cast(a)->padding_mode()); return s; }}, + {"resample_mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameResampleMode(static_cast(a)->resample_mode()); return s; }}, +}; +} // namespace op20 + +// MoeGroupedMatmulAttributes +namespace op21 { +inline const AttrBinding attributes[] = { + {"first_token_offset_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->first_token_offset_tensor_uid()); return s; }}, + {"mode", false, AttrType::DTYPE, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::DTYPE; s.present = true; s.dtype = ::hipdnn_flatbuffers_sdk::data_objects::EnumNameMoeGroupedMatmulMode(static_cast(a)->mode()); return s; }}, + {"output_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->output_tensor_uid()); return s; }}, + {"token_index_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->token_index_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"token_ks_tensor_uid", true, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; auto v = static_cast(a)->token_ks_tensor_uid(); s.present = v.has_value(); if(v) s.i = static_cast(*v); return s; }}, + {"token_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->token_tensor_uid()); return s; }}, + {"top_k", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->top_k()); return s; }}, + {"weight_tensor_uid", false, AttrType::INT, +[](const void* a) -> ScalarValue { ScalarValue s; s.type = AttrType::INT; s.present = true; s.i = static_cast(static_cast(a)->weight_tensor_uid()); return s; }}, +}; +} // namespace op21 + inline const OpSchemaEntry entries[] = { {"BatchnormInferenceAttributes", "BatchnormInferenceAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::BatchnormInferenceAttributes, nullptr, 0u, nullptr, 0u, op0::attributes, 6u}, {"PointwiseAttributes", "PointwiseAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::PointwiseAttributes, nullptr, 0u, nullptr, 0u, op1::attributes, 12u}, @@ -335,6 +360,8 @@ inline const OpSchemaEntry entries[] = { {"ReductionAttributes", "ReductionAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ReductionAttributes, nullptr, 0u, nullptr, 0u, op17::attributes, 4u}, {"ResampleFwdAttributes", "ResampleFwdAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ResampleFwdAttributes, nullptr, 0u, nullptr, 0u, op18::attributes, 6u}, {"LayernormBackwardAttributes", "LayernormBackwardAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::LayernormBackwardAttributes, nullptr, 0u, nullptr, 0u, op19::attributes, 10u}, + {"ResampleBwdAttributes", "ResampleBwdAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::ResampleBwdAttributes, nullptr, 0u, nullptr, 0u, op20::attributes, 5u}, + {"MoeGroupedMatmulAttributes", "MoeGroupedMatmulAttributes", ::hipdnn_flatbuffers_sdk::data_objects::NodeAttributes::MoeGroupedMatmulAttributes, nullptr, 0u, nullptr, 0u, op21::attributes, 8u}, }; } // namespace generated From bf1626cf948ab2a46185d9bd17599c342384d145 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Thu, 6 Aug 2026 12:58:14 -0600 Subject: [PATCH 58/60] fix(hip-kernel-provider): spell the SdpaPlanUtils include from an include root AsmSdpaConfigHelpers.cpp reached for SdpaPlanUtils.hpp through "../../engines/...", which resolves relative to the file's own directory (src/tests/engines/asm_sdpa_engine/plans, nonexistent) and to no -I root on the command line, so the translation unit did not compile. ConfigHelpers.cpp beside it already spells the same header relative to the src include root; match it. That root is not universal, though: this file is compiled into two targets. hip_kernel_provider_tests links hip_kernel_provider_private, which exports src as a PUBLIC include directory, so the root arrives there for free. hip_kernel_provider_integration_tests pulls the same source in but links only the SDKs, so it inherits nothing from the provider and has to name the root itself. Without it, the respelling merely moves the failure from one target to the other -- and only in a configuration that builds the integration tests, which is why it survived a unit-test-only build. Added beside the codegen and shared-helper entries the same block already lists. (cherry picked from commit a6e693de1f2fee6b8d4d5cbd7f47d9df9ce5726c) (cherry picked from commit 974237b3ad6bdb202f88254d854a1b1c517a3a24) --- .../src/integration_tests/CMakeLists.txt | 8 ++++++-- .../engines/asm_sdpa_engine/AsmSdpaConfigHelpers.cpp | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt b/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt index 385b02c990ec..3044c117c9a8 100644 --- a/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/src/integration_tests/CMakeLists.txt @@ -49,9 +49,13 @@ if(ENABLE_ASM_SDPA_ENGINE) ${CMAKE_CURRENT_SOURCE_DIR}/../tests/engines/asm_sdpa_engine/AsmSdpaConfigHelpers.cpp asm_sdpa_engine/IntegrationGpuSdpaForward.cpp ) - # Add include path for codegen-generated headers (e.g., asm_fmha_v3_fwd_configs.hpp) - # and the shared test helpers (AsmSdpaConfigHelpers.hpp) + # AsmSdpaConfigHelpers.cpp above is shared with hip_kernel_provider_tests and + # spells its provider includes from the src root, which that target inherits from + # hip_kernel_provider_private. This target links only the SDKs, so it has to name + # the root itself. The other two entries are the codegen-generated headers (e.g. + # asm_fmha_v3_fwd_configs.hpp) and the shared test helpers (AsmSdpaConfigHelpers.hpp). target_include_directories(hip_kernel_provider_integration_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. ${CMAKE_CURRENT_BINARY_DIR}/../engines/asm_sdpa_engine/generated ${CMAKE_CURRENT_SOURCE_DIR}/../tests/engines/asm_sdpa_engine ) diff --git a/dnn-providers/hip-kernel-provider/src/tests/engines/asm_sdpa_engine/AsmSdpaConfigHelpers.cpp b/dnn-providers/hip-kernel-provider/src/tests/engines/asm_sdpa_engine/AsmSdpaConfigHelpers.cpp index 5cc90ac82957..9d8395d48275 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/engines/asm_sdpa_engine/AsmSdpaConfigHelpers.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/engines/asm_sdpa_engine/AsmSdpaConfigHelpers.cpp @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT #include "AsmSdpaConfigHelpers.hpp" -#include "../../engines/asm_sdpa_engine/plans/SdpaPlanUtils.hpp" +#include "engines/asm_sdpa_engine/plans/SdpaPlanUtils.hpp" #include "hip_kernel_provider_common/SdpaConfigConstants.hpp" #include "hip_kernel_provider_common/SdpaConfigEnumerations.hpp" From 78d07b4c3ac2345c03103db0519d5ad26dce1323 Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Wed, 12 Aug 2026 11:03:48 -0600 Subject: [PATCH 59/60] refactor(umd): move the escape hatch out of the expression language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0018 §8 put the native-predicate hatch inside `criteria`: a custom operation named by a namespaced key, resolved against a predicate registry the compiler consults, nesting and negating like any built-in. That buys nothing the ingestor does not already provide, and it costs the property that makes the criteria language auditable -- that A.7 is exhaustive. A compiler that resolves names against a runtime registry cannot validate a descriptor from the descriptor alone: the same file compiles or fails depending on which predicates the running provider happens to ship, and the registry becomes a second, undocumented half of the operator reference. The hatch now sits beside the descriptor rather than inside it. A check that needs real C++ is a native matcher: an ordinary `GraphMatcherFn` registered in the provider's `NativeRegistry` and named by a `MatchDescriptor`'s `matchSymbol` (RFC 0017 §5). A pack lists it alongside its descriptor-backed matcher and the ingestor conjoins the verdicts -- a pack's graph-scoped matchers all run and all must pass -- so "this UMD's criteria AND this C++ predicate" is two matcher ids, not one nested inside the other. The two SDPA gates that motivated the hatch (strides_fit_u32, sdpa_mask_consistent) move out of the example descriptor's criteria on that basis. The compiler follows: with no registry to resolve against, a namespaced key is not a special case. Dropping the dedicated rejection lets it fall through to the unrecognized-operator error every other unknown key takes, which is the same fail-closed outcome by the general rule instead of a bespoke one. `RejectsCustomOperation` becomes `RejectsUnknownOperator` and now asserts that, rather than asserting a PoC-scope message that described a feature no longer planned. --- .../umd/UmdCompiler.hpp | 7 - .../tests/core/TestUniversalGraphMatcher.cpp | 15 +- .../hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md | 41 ++-- .../rfcs/0018_UniversalMatchDescriptor.md | 194 +++++++++++------- 4 files changed, 150 insertions(+), 107 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp index 2df715e50c11..9350aaefef66 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -1187,13 +1187,6 @@ class UmdCompiler { return inferIf(args); } - if(op.find('.') != std::string::npos) - { - // Custom operations (native predicates) are out of scope for this - // PoC: no predicate registry ships, so the name fails to resolve - // (RFC 0018 §8 / A.9: fail closed). - throw UmdCompileError("custom operation not supported in PoC: " + op); - } throw UmdCompileError("unrecognized operator: " + op); } diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index 0bf109911aa7..da9c1cbfabce 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -3,7 +3,7 @@ // // RFC 0018 Phase 1: proves the bindings + UMD compiler + matcher end to end. // - Compiler: A.10 validation (accept the SDPA descriptor; reject bad schema, -// mismatched `?`, unknown name, type errors, custom ops). +// mismatched `?`, unknown name, type errors, unknown operators). // - Lowering: `shape` -> rank check with named dims; layout alias -> array. // - Matcher: structural match + criteria evaluation over a live graph, with // fail-closed rejection on the declarative gates. @@ -38,9 +38,11 @@ using json = nlohmann::json; namespace { -// The RFC §18 SDPA-forward descriptor, with the two custom-operation gates -// (strides_fit_u32, sdpa_mask_consistent) removed -- the custom-op hook is out -// of scope for this PoC. Name keys are the lowercase op-schema registry names. +// The RFC §18 SDPA-forward descriptor. The two gates that need real C++ +// (strides_fit_u32, sdpa_mask_consistent) are not criteria: the expression +// language has no custom-operation hatch, so they live in a native matcher +// beside this UMD (RFC 0018 §8). Name keys are the lowercase op-schema +// registry names. json sdpaDescriptor() { return json::parse(R"JSON({ @@ -204,8 +206,11 @@ TEST(TestUmdCompiler, RejectsNonBooleanCriteria) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } -TEST(TestUmdCompiler, RejectsCustomOperation) +TEST(TestUmdCompiler, RejectsUnknownOperator) { + // A.7 is exhaustive and there is no registry a descriptor can extend it + // through, so a namespaced key -- the form a custom operation would have + // taken -- is refused as an unrecognized operator like any other. json d = sdpaDescriptor(); d["criteria"]["and"].push_back(json::parse(R"({"hipdnn.strides_fit_u32": ["$q"]})")); EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); diff --git a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md index 77c87aada8b7..5896dd02249f 100644 --- a/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md +++ b/projects/hipdnn/docs/rfcs/0018_UMD_PoC_Plan.md @@ -23,8 +23,8 @@ Source RFC: `projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md`. arithmetic/`min`/`max`/`ceil_div`/`abs`/`pow`/`log2`/`rsqrt`/`value_or_default`/`if`, and `compile(rule)` → `Expression` over any `getData(path)->Value` data source. Tests already include a `Umd0018ConstraintShapes` case (`src/tests/core/TestJsonLogic.cpp`). - **Gap: no `shape` short-hand (lowered by the UMD compiler, Phase 1). The custom-operation - (native-predicate) hook is out of scope for this PoC — see Scope decisions.** + **Gap: no `shape` short-hand (lowered by the UMD compiler, Phase 1). The operator set is closed — + there is no custom-operation hook to add; see Scope decisions.** - **UMD annotations already in the schema** — the table-level `umd_opcode` shorthand (e.g. `SdpaAttributes (umd_opcode: "sdpa_fwd")`) and the field-level `umd_input_tensor` / `umd_output_tensor` / `umd_name` are declared once (`flatbuffers_sdk/schemas/data_types.fbs`) and applied on the SDPA @@ -173,9 +173,10 @@ nlohmann_json + plan_utils; add dep on the generated registry). - **Graph fixtures:** both paths — manual (`createValidSdpaFwdGraph(...)`, head_size=128, bf16) and JSON (`json::to` from an inline JSON fixture) → `GraphWrapper`. -- **UMD fixture:** the §18 SDPA-forward descriptor JSON (head_size 128, bf16/fp8), **with the two - custom-operation gates (`strides_fit_u32`, `sdpa_mask_consistent`) removed** — the custom-op hook - is out of scope, so those gates are held constant in the graph battery, not expressed as criteria. +- **UMD fixture:** the §18 SDPA-forward descriptor JSON (head_size 128, bf16/fp8). The two gates that + need real C++ (`strides_fit_u32`, `sdpa_mask_consistent`) are not criteria at all — under RFC 0018 + §8 they belong to a native matcher beside the descriptor — so they are held constant in the graph + battery. Their native matcher is not written for this PoC, which is unplumbed. - **Match-equivalence (RFC §16 primary):** battery of accepting/rejecting graphs through both `SdpaFwdPlanBuilder::isApplicable` and the matcher; assert identical accept/reject on the declarative gates. Hold device=gfx942, a valid kernel key, and the two non-declarative gates @@ -212,15 +213,19 @@ nlohmann_json + plan_utils; add dep on the generated registry). - **JsonLogic core changes are limited to the presence predicates.** `present` / `not_present` are added because RFC 0017 §5 lists them in the closed operator vocabulary and they are the only way to write "absent, or present and constrained" — a bare field read on an absent operand declines rather - than answering. The custom-operation (native-predicate) hook is still skipped for this - PoC; the two SDPA gates that need it (uint32 stride fit, mask self-consistency) are held constant - in the test battery rather than expressed as criteria. `all` / `rank`-op / `divisible` are - unused by SDPA fwd — deferred (add if broader §A.7 coverage is wanted). + than answering. `all` / `rank`-op / `divisible` are unused by SDPA fwd — deferred (add if broader + §A.7 coverage is wanted). +- **The operator set is closed, permanently.** RFC 0018 §8 puts the C++ escape hatch in a native + matcher named by a `MatchDescriptor`'s `matchSymbol`, conjoined with the UMD's criteria by the + ingestor, rather than in a custom operation nested inside `criteria`. So a namespaced (dotted) + operator key needs no special handling: it is refused as an unrecognized operator like any other + (`TestUmdCompiler.RejectsUnknownOperator`) — that is the finished behavior, not a PoC stub awaiting + a predicate registry. - **Not plumbed as a provider engine** — matcher is exercised directly by tests. -- **Deferred (RFC-acknowledged):** custom-operation (native-predicate) hook and predicate registry - (§8), static/bytecode matcher (§11), fuzzing (§14), arbitration across multiple UKDs (§12), UDD - dispatch formulas, multi-node fusion patterns, and the whole applicability-time catalog and cache - (RFC 0017 §8) — the PoC matcher answers one UMD against one graph and holds no catalog. +- **Deferred (RFC-acknowledged):** static/bytecode matcher (§11), fuzzing (§14), arbitration across + multiple UKDs (§12), UDD dispatch formulas, multi-node fusion patterns, and the whole + applicability-time catalog and cache (RFC 0017 §8) — the PoC matcher answers one UMD against one + graph and holds no catalog. - **Deferred pack-level checks.** Two rules RFC 0018 §5 states are enforced by a KDP/KMD loader, not by a single descriptor's compile: that every quantity a kernel bakes is a KMD field pinned by a `$kernel.*` criterion, and that every pack carries an umbrella matcher checking the full graph @@ -230,9 +235,13 @@ nlohmann_json + plan_utils; add dep on the generated registry). - **Version floors are structural only.** `version` and `sdk_version` parse and gate against a compiled-in floor, but nothing yet publishes a real graph-schema version to compare against, so the floor is a constant rather than a negotiated value. -- **Two non-declarative gates out of the UMD.** `strides_fit_u32` and `sdpa_mask_consistent` - (RFC §8 custom operations) are excluded with the hook; match-equivalence covers the declarative - subset with those two gates held constant, exactly as arch/kernel-table are. +- **Two non-declarative gates out of the UMD.** `strides_fit_u32` and `sdpa_mask_consistent` are a + native matcher's job (RFC §8), not a descriptor's; match-equivalence covers the declarative subset + with those two gates held constant, exactly as arch/kernel-table are. +- **Open follow-up: native-matcher bindings.** A `GraphMatcherFn` receives `MatchContext`, not the + bindings its pack's UMD published, and `BoundTokens` (`string -> int64_t`) cannot carry a tensor — + so a native matcher re-locates q/k/v/o by hand. Recorded as RFC 0018 §20 open question 2; it is a + change to the matcher-invocation contract, outside this PoC. ## Primary risks diff --git a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md index 50789fbcf8ed..c69668b2f4dd 100644 --- a/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md +++ b/projects/hipdnn/docs/rfcs/0018_UniversalMatchDescriptor.md @@ -18,7 +18,7 @@ 5. [Constraint Vocabulary](#5-constraint-vocabulary) 6. [The Shared Expression Language](#6-the-shared-expression-language) 7. [Layout and Stride-Order Constraints](#7-layout-and-stride-order-constraints) -8. [Native-Predicate Escape Hatch](#8-native-predicate-escape-hatch) +8. [The Native-Matcher Escape Hatch](#8-the-native-matcher-escape-hatch) 9. [Composite Constraints](#9-composite-constraints) 10. [The Matcher: Compilation, Indexing, and Caching](#10-the-matcher-compilation-indexing-and-caching) 11. [Static Matcher (Sketch)](#11-static-matcher-sketch) @@ -60,7 +60,7 @@ A UMD has two parts, unchanged in intent from This document turns that frame into a concrete format and a concrete matcher. It specifies the pattern and criteria schema, the standard formula that auto-binds every tensor and attribute of a matched op, JsonLogic as the shared expression language (boolean criteria here, dispatch formulas in the UDD), -the layout representation as a stride-order index array, the custom-operation escape hatch, +the layout representation as a stride-order index array, the native-matcher escape hatch, deterministic arbitration, and the compile-once matcher with its indexing and caching. The static (compile-time) matcher is sketched as options, not fully designed, in this iteration. @@ -73,7 +73,7 @@ deterministic arbitration, and the compile-once matcher with its indexing and ca | Criteria as one JsonLogic expression: opcode, dtype (exact/set/relation), shape/rank, divisibility, stride order, packed, attribute, graph-structure (`node_count`, `virtual`), cross-tensor relation, optional operand, device property | Yes ([§5](#5-constraint-vocabulary)) | None | | JsonLogic as the shared expression language (UMD boolean + UDD value), `$`-variable convention | Yes ([§6](#6-the-shared-expression-language)) | New operators as needed | | Layout as a stride-order index array, with named aliases | Yes ([§7](#7-layout-and-stride-order-constraints)) | None | -| Custom-operation (native-predicate) escape hatch, registry-resolved | Yes ([§8](#8-native-predicate-escape-hatch)) | None | +| Escape hatch for checks needing C++, beside the descriptor rather than inside the expression language | Yes ([§8](#8-the-native-matcher-escape-hatch)) | None | | Composite criteria: `(A AND B) OR C` as one criteria expression, via JsonLogic `and`/`or`/`!`/`if` | Yes ([§9](#9-composite-constraints)) | None | | Compile-once matcher, root-opcode index, applicability-time match cache | Yes ([§10](#10-the-matcher-compilation-indexing-and-caching)) | None | | Static (compile-time / AOT-lowered) matcher | Options sketched ([§11](#11-static-matcher-sketch)) | Full design | @@ -278,7 +278,7 @@ as a single typed token — present only when the tensor carries a compile-time criterion over it declines on a tensor that does not. A precomputed field is declared in the hipDNN schema like any other field and versioned with it, so adding one is an additive schema change rather than a per-pack extension point. Precomputed fields sit between the built-in operators and the -native-predicate escape hatch ([§8](#8-native-predicate-escape-hatch)): reach for one when a check +native-matcher escape hatch ([§8](#8-the-native-matcher-escape-hatch)): reach for one when a check needs a derived fact, and for the hatch only when it needs real C++. **A `$` marks a reference.** Every reference to a bound field carries a leading `$`: tensors and @@ -287,14 +287,14 @@ their fields (`$q`, `$q.uid`, `$q.dims[2]`, `$q.rank`, `$q.seqlen_q`), a node's and `$device.lds_size`. Tokens without a `$` are literals: numbers, enum values (`"BFLOAT16"`, `"gfx942"`), opcodes, and layout aliases. This is the JsonLogic variable rule of [§6](#6-the-shared-expression-language): a `$`-string is a variable, everything else a literal, so no -`var` wrapper is needed and the same token reads identically in a criteria expression or a -custom-operation argument. +`var` wrapper is needed and the same token reads identically wherever a criteria expression names a +bound field. **Named dims via the `shape` short-hand.** `shape` is a criteria short-hand that names a tensor's dims and pins its rank to the list length: `{"shape": ["$q", ["batch", "num_heads", "seqlen_q", "head_size"]]}` makes those dims readable as `$q.batch` … `$q.head_size` and requires `$q` to be rank 4. Names are per-tensor, so a cross-tensor relation (same head dim across q/k) is an explicit criterion, -`{"==": ["$k.head_size", "$q.head_size"]}`, or a custom operation. A dim position may be left anonymous +`{"==": ["$k.head_size", "$q.head_size"]}`. A dim position may be left anonymous with `"_"`. When rank varies (NCHW vs NCDHW), name the fixed dims and capture the variable run as one vector, `["n", "c", "$spatial"]`, so one matcher accepts both ranks and still reaches those dims through `all` or a product; the exact `shape` grammar is specified in [Appendix A.5](#a5-the-shape-short-hand). @@ -310,10 +310,9 @@ The `criteria` field is a **single JsonLogic boolean expression** evaluated over normally an `and` of the individual tests, and reaches for `or` / `!` / `if` wherever a real disjunction is needed ([§9](#9-composite-constraints)). The table below is not a set of criterion *kinds* (there are none); it is the set of hand-written checks and the -JsonLogic sub-expression that expresses each, so no check needs code a JsonLogic expression cannot -state. The one residue is a **custom operation** (native predicate), itself a JsonLogic operation -resolved from the provider registry ([§8](#8-native-predicate-escape-hatch)), so it composes inside the -same expression as any built-in operator. +JsonLogic sub-expression that expresses each. The residue — the handful of checks that need real C++ +— is not an operator at all: it is a **native matcher** the pack lists beside the descriptor +([§8](#8-the-native-matcher-escape-hatch)), so the expression language itself stays closed. | Hand-written check | JsonLogic criterion | Lowers from | |---|---|---| @@ -332,7 +331,7 @@ same expression as any built-in operator. | **Graph structure (exact / fusion)** | `{"==": ["$graph.node_count", 3]}`, and each intermediate `"$conv_out.virtual"` | node-count gate, fusion legality | | **Cross-tensor / arithmetic** | `{"==": ["$q.dims[1]", "$k.dims[2]"]}`, `{"<": ["$q.head_size", 129]}`, `{"==": [{"%": ["$q.num_heads", "$k.kv_heads"]}, 0]}` | arithmetic and comparison gates | | **Device property** | `{"<=": ["$kernel.lds_per_block", "$device.lds_size"]}` (arch is a pack property, not a criterion) | LDS/occupancy budgets; `getDeviceString` arch → pack `arch` | -| **Custom operation** | `{"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]}` ([§8](#8-native-predicate-escape-hatch)) | overflow guards, table lookups, derived-shape relations | +| **Needs real C++** | not a criterion; a native matcher listed beside the descriptor ([§8](#8-the-native-matcher-escape-hatch)) | overflow guards, contradiction checks, derived-shape relations | Architecture gates *applicability* at pack selection via the KDP `arch` property and the per-arch `kpack` manifest ([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd), @@ -400,10 +399,11 @@ the per-element `all`; arithmetic `+`, `-`, `*`, `/`, `%`; the presence predicat a possibly-absent optional operand with a fallback); the conditional `if`; and the value-core arithmetic the UDD needs (`ceil_div`, `min`, `max`, `abs`, `pow`, `log2`, `rsqrt`). The full table with arities and types is [A.7](#a7-operator-reference); naming an operator outside it fails load -validation. Anything the built-ins cannot express is a **custom operation** -([§8](#8-native-predicate-escape-hatch)). Adding an operator is additive -([§13](#13-serialization-and-versioning)); it never introduces a new criterion *kind*, since the whole -`criteria` field is one JsonLogic expression. +validation, and the set is closed — there is no registry or namespace by which a descriptor +introduces one ([A.9](#a9-the-operator-set-is-closed)). Anything the built-ins cannot express is a +**native matcher** beside the descriptor ([§8](#8-the-native-matcher-escape-hatch)). Adding a built-in +operator is additive ([§13](#13-serialization-and-versioning)); it never introduces a new criterion +*kind*, since the whole `criteria` field is one JsonLogic expression. **Operators nest, and either side of a comparison may be computed.** A leaf is a literal or a `$`-prefixed reference, but either operand of a comparison can itself be an expression, so a check @@ -480,53 +480,80 @@ dimension stored at physical position `i`, so the array reads as the layout it d --- -## 8. Native-Predicate Escape Hatch +## 8. The Native-Matcher Escape Hatch -Some checks cannot be stated with the built-in operators: they need real C++. The UMD exposes them as a -**custom operation** (a native predicate) — a JsonLogic operation resolved from a provider-internal -registry and invoked by its registered (namespaced) name as the operator key, so it nests inside the -`criteria` expression exactly like a built-in operator and negates with `!`: +Some checks cannot be stated with the built-in operators: they need real C++. **The expression +language has no extension point for them.** There is no custom operation, no namespaced operator key, +and no predicate registry the criteria tree resolves against; an operation key outside +[A.7](#a7-operator-reference) is simply unrecognized and refused at compile time +([Appendix A.10](#a10-compile-time-validation-normative)). + +Instead, such a check is a **native matcher**: an ordinary `GraphMatcherFn` registered in the +provider's `NativeRegistry` and named by a `MatchDescriptor`'s `matchSymbol` +([RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd)). A pack lists it alongside +the descriptor-backed matcher, and the ingestor conjoins their verdicts: a pack's graph-scoped +matchers all run and all must pass, so "this UMD's criteria **and** this C++ predicate" is expressed +by listing two matcher ids, not by nesting one inside the other. ```jsonc -{"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} // a registered boolean operation -{"!": {"hipdnn.sdpa_mask_consistent": ["$sdpa_fwd"]}} // negated inside the same tree +// pack.matcherIds: [ , <"hipdnn.sdpa.strides_fit_u32">, ... ] ``` -The descriptor carries only the operation name and a typed argument list drawn from bound variables, -never inline code. Custom operations take explicit arguments, not the whole graph, so they stay auditable and -reusable. Both the build-time and drop-in paths resolve predicates from the same registry: a file that -names only shipped predicates loads identically either way, and a file naming a predicate the running -provider does not ship fails to resolve on the drop-in path. The registry a provider ships is therefore -part of its published contract. - -The grounded cases that need this hatch: +**Why the hatch sits beside the expression language rather than inside it.** An escape hatch that +nests inside `criteria` has to be resolved by the compiler, which means the criteria language grows a +registry, a signature table, and a per-argument type contract — a second extension mechanism running +parallel to the one RFC 0017 already defines, differing only in grain. One hatch, at the matcher +level, keeps the expression language closed: every operator in [A.7](#a7-operator-reference) is +total, statically typed against the op-schema registry, and means the same thing in every provider +that ships a UMD. A descriptor is then fully interpretable from the schema alone, which is what makes +the drop-in path and the static-matcher lowering of [§11](#11-static-matcher-sketch) tractable. + +**What this costs, and the follow-up it implies.** A nested custom operation would have received +*bound variables* (`["$q", "$k", "$v", "$o"]`); a `GraphMatcherFn` receives `MatchContext` — the raw +graph and a device id — and must locate those tensors itself, repeating the structural search the UMD +already performed, in hand-written code that can drift from the registry-driven binding. Nor can the +existing plumbing hand them over: `BoundTokens` is `string -> int64_t`, which carries a dim but not a +tensor. Closing this gap — letting a pack's native matcher read the bindings a descriptor-backed +matcher in the same pack already published — is the natural follow-up, and it is a change to how +matchers are invoked, not a change to the expression language. + +The grounded cases that take this path: - **Integer-overflow guards.** `wouldFwdByteStridesFitUint32` / `byteStrideFitsU32` (`SdpaFwdPlanBuilder.cpp:294`, `SdpaPlanUtils.hpp:159`): the kernarg struct stores byte strides as - `uint32`, so the check must be exact and fail closed. A predicate `hipdnn.strides_fit_u32`. + `uint32`, so the check must be exact and fail closed. - **Derived-quantity relations.** NumPy-broadcast affine-shape compatibility (`BatchnormApplicabilityChecks.cpp:169`), layernorm normalized-dim reconciliation (`LayernormApplicabilityChecks.cpp:68`), and RMSnorm `inv_rms` derived shape (`RMSnormApplicabilityChecks.cpp:106`) each compute a shape and compare it, beyond the constraint vocabulary. - **Contradiction checks.** `getMaskType` throws when mask attributes contradict - (`SdpaFwdPlanBuilder.cpp:276`); a predicate encodes "the mask attributes are self-consistent". -- **GQA divisibility.** `nhead_q % nhead_k == 0 && nhead_k != 0` (`SdpaBwdPlanBuilder.cpp:548`) is - expressible with `%` in [§6](#6-the-shared-expression-language), but a native predicate is an option - if the zero-guard and fail-closed semantics are better centralized. + (`SdpaFwdPlanBuilder.cpp:276`); a matcher encodes "the mask attributes are self-consistent". + +**GQA divisibility is not one of them.** `nhead_q % nhead_k == 0 && nhead_k != 0` +(`SdpaBwdPlanBuilder.cpp:548`) is expressible with `%` in +[§6](#6-the-shared-expression-language), and with no hatch inside the language there is no longer a +"centralize the zero-guard as a predicate" alternative to weigh: it stays declarative, and the +unknown-propagation rule of [A.7](#a7-operator-reference) supplies the fail-closed behavior. -**Kernel-table lookups are a migration artifact, not a lasting predicate.** The +**Kernel-table lookups are a migration artifact, not a lasting matcher.** The `getKernelNameKey` / CSV-registry lookups (`SdpaFwdPlanBuilder.cpp:287`, three in `SdpaBwdPlanBuilder.cpp:660`) exist because today the builder resolves which prebuilt code object serves a shape. Under the UKD model the KDP names the code object directly and the heuristic ranks candidates, so these lookups mostly dissolve into ordinary constraints plus the Launch's kernel source. Where a residual "is there a row for this exact combination" gate remains during coexistence, it is a native -predicate. +matcher. + +**The registry a provider ships is part of its published contract**, unchanged from +[RFC 0017 §5](0017_UniversalKernelDescriptor.md#5-matching-and-the-umd): a pack naming a `matchSymbol` +the running provider does not ship fails to resolve, and fails closed. What changes is that this is +the *only* place a name resolves to C++, so the drop-in story is simple — **a UMD file is pure data +and always loads identically; a pack that needs C++ is not a drop-in.** Together with the UDD's custom-plan hatch ([RFC 0017 §6](0017_UniversalKernelDescriptor.md#6-dispatch-and-workspace)), these form the graded -ladder: fully declarative constraints, then a named predicate for one step that needs C++, then a full -provider. +ladder: fully declarative constraints, then a native matcher beside the descriptor for one gate that +needs C++, then a full provider. --- @@ -543,8 +570,8 @@ natively, so `(A AND B) OR C` is stated directly in the one tree with no extra m ]} ``` -`and`, `or`, `!`, and `if` compose the same comparison, membership, and custom-operation -([§8](#8-native-predicate-escape-hatch)) tests used everywhere else, so the deferral and reserved-shape +`and`, `or`, `!`, and `if` compose the same comparison and membership tests used everywhere else, so +the deferral and reserved-shape workaround of earlier drafts is gone: composition is day-one. A UMD whose tests all conjoin simply makes the top-level expression an `and`, the common case. General N-ary commutative matching and unbounded chains remain deferred to the JIT follow-up, as in RFC 0017 §5. @@ -581,8 +608,8 @@ so the memoization key costs nothing extra. Results are cached across queries. short-circuiting: an `and` stops at its first false sub-expression, an `or` at its first true one, so a non-match is rejected as early as the author's structure allows. The author orders the tree; the compiler may hoist a cheap, highly selective sub-expression (a scalar attribute or dtype read) ahead of -an expensive one (a native predicate) as an internal optimization, but this never changes the result, -only when a decision is reached. +an expensive one (a `shape`-wide relation) as an internal optimization, but this never changes the +result, only when a decision is reached. **Matching runs during applicability, and the provider owns the cache.** The order and the cache are specified by [RFC 0017 §8](0017_UniversalKernelDescriptor.md#8-end-to-end-flow), which this RFC @@ -701,7 +728,7 @@ hazard. tensors ([RFC 0016](0016_RuntimePassByValueTensors.md)) to `1.2`. The matcher reads that field rather than deriving a second floor of its own. A graph carrying no stamp reads as the `1.0` baseline. -- **Additive evolution.** New JsonLogic operators, native predicates, and layout aliases are additive +- **Additive evolution.** New JsonLogic operators and layout aliases are additive within `v1` where they do not change the meaning of an existing descriptor; anything that would reinterpret existing fields bumps the version. - **Identity.** A UMD `id` is a **UUID**: globally unique with no central allocator, so descriptors @@ -723,9 +750,9 @@ untrusted or simply malformed, so they must be bounded and fail closed rather th provider. - **Checked arithmetic.** Shape, stride, and workspace arithmetic uses checked-width integers and fails closed on overflow rather than under-allocating or wrapping. This is the same class of bug the - `strides_fit_u32` predicate guards ([§8](#8-native-predicate-escape-hatch)). -- **Fail-closed evaluation.** An unknown symbol, unresolved native predicate, out-of-range axis, or - type error declines the match; it never matches by default. + `strides_fit_u32` native matcher guards ([§8](#8-the-native-matcher-escape-hatch)). +- **Fail-closed evaluation.** An unknown symbol, an unrecognized operator key, an out-of-range axis, + or a type error declines the match; it never matches by default. - **Quarantine, not cascade.** A bad descriptor is quarantined on load with a diagnostic; the rest load ([RFC 0017 §12](0017_UniversalKernelDescriptor.md#12-packaging-and-delivery)). - **Fuzzing.** A seed corpus of UMDs and graphs plus a fuzzer over the loader, matcher, and interpreter @@ -801,21 +828,23 @@ on the graphs that engine covers, at which point the hand-written code is retire The **SDPA-forward** `isApplicable` (`SdpaFwdPlanBuilder.cpp:167`) is the first conversion, because it exercises nearly the whole vocabulary (opcode, attribute gates, optional-operand absence, rank, dtype -relations, cross-tensor dim relations, and two custom operations) in one node. Its match-equivalence +relations, and cross-tensor dim relations) in one node, with its two non-declarative gates in the +paired native matcher. Its match-equivalence test ([§16](#16-testing-and-performance)) gates the cutover. The mlops builders follow, reusing the `IValidator` primitives (`dnn-providers/hip-kernel-provider/src/engines/hip_mlops_engine/plans/ApplicabilityChecks.cpp`) as the reference for their criteria lowering. The kernel-table lookups dissolve into the KDP as described in -[§8](#8-native-predicate-escape-hatch). +[§8](#8-the-native-matcher-escape-hatch). --- ## 18. Worked Example: SDPA Forward -The SDPA-forward check collapses into one UMD. Compared to the hand-written builder -(`SdpaFwdPlanBuilder.cpp:167-296`), each C++ gate becomes a sub-expression, and only the two genuinely -non-declarative gates (uint32 stride fit, mask self-consistency) remain as custom operations. Note -`$q.head_size` is bound from `$q`'s dim, not read as an attribute -([§2](#2-the-matchers-input-hipdnns-graph-model)). +The SDPA-forward check collapses into one UMD plus one native matcher. Compared to the hand-written +builder (`SdpaFwdPlanBuilder.cpp:167-296`), each C++ gate becomes a sub-expression, and only the two +genuinely non-declarative gates (uint32 stride fit, mask self-consistency) stay in C++ — as a native +matcher the pack lists beside this descriptor, not as criteria +([§8](#8-the-native-matcher-escape-hatch)). Note `$q.head_size` is bound from `$q`'s dim, not read as +an attribute ([§2](#2-the-matchers-input-hipdnns-graph-model)). This example is grounded on the asm-SDPA builder because that builder is this RFC's first migration target ([§17](#17-migration)), so the mapping table below doubles as the cutover checklist. It is @@ -852,11 +881,11 @@ the context of a full UKD, this one shows one hand-written `isApplicable` becomi {"or": [{"!": "$sdpa_fwd.generate_stats.present"}, {"==": ["$sdpa_fwd.generate_stats", false]}]}, // unsupported optional operands declined together; `not_present` always evaluates, // unlike a field read on an absent operand - {"not_present": ["$attn_mask", "$page_table_k", "$page_table_v"]}, - {"hipdnn.sdpa_mask_consistent": ["$sdpa_fwd"]}, // custom operation (needs C++) - {"hipdnn.strides_fit_u32": ["$q", "$k", "$v", "$o"]} // custom operation (needs C++) + {"not_present": ["$attn_mask", "$page_table_k", "$page_table_v"]} ]} // arch is a pack property (KDP.arch), not a match criterion + // uint32 stride fit and mask self-consistency are a native matcher the pack lists + // alongside this descriptor (§8), conjoined with these criteria by the ingestor } ``` @@ -873,9 +902,9 @@ Mapping to the hand-written code: | `q == k == v` dtype (:244) | `{"==": ["$k.dtype", "$q.dtype"]}` | | `k.dims[1] == v.dims[1]` head count (:251) | shared `kv_heads` name in `$k`/`$v` `shape` | | head dim == 128 | `{"==": ["$q.head_size", 128]}` | -| `getMaskType` throw-on-contradiction (:276) | `hipdnn.sdpa_mask_consistent` custom operation | -| `wouldFwdByteStridesFitUint32` (:294) | `hipdnn.strides_fit_u32` custom operation | -| `getKernelNameKey` table lookup (:287) | dissolves into the KDP's Launch ([§8](#8-native-predicate-escape-hatch)) | +| `getMaskType` throw-on-contradiction (:276) | native matcher ([§8](#8-the-native-matcher-escape-hatch)) | +| `wouldFwdByteStridesFitUint32` (:294) | native matcher ([§8](#8-the-native-matcher-escape-hatch)) | +| `getKernelNameKey` table lookup (:287) | dissolves into the KDP's Launch ([§8](#8-the-native-matcher-escape-hatch)) | The bound symbols (`$q..$o`, the named dims `$q.batch`, `$q.num_heads`, `$k.kv_heads`, `$q.seqlen_q`, `$k.seqlen_k`, `$q.head_size`, and every auto-bound dim/stride) are exactly what the paired UDD's grid @@ -896,9 +925,10 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m A change made for one subsystem can affect the other. Mitigation: one parser/validator/interpreter, a shared conformance suite, and a clear split (criteria are boolean JsonLogic, dispatch formulas are value JsonLogic). -- **Predicate registry as contract.** Native predicates are part of the published provider contract - ([§8](#8-native-predicate-escape-hatch)); a drop-in naming an unshipped predicate fails to resolve. - Mitigation: version and document the shipped predicate set; fail closed with a clear diagnostic. +- **Native-matcher symbols as contract.** A pack's `matchSymbol` names C++ the provider must ship + ([§8](#8-the-native-matcher-escape-hatch)); a drop-in pack naming an unshipped symbol fails to + resolve. Mitigation: version and document the shipped symbol set, fail closed with a clear + diagnostic, and keep pure-UMD packs free of symbols so they remain true drop-ins. - **Match overhead.** Per-candidate evaluation of the criteria expression is unbounded by the root-opcode index ([§10](#10-the-matcher-compilation-indexing-and-caching)). Mitigation: short-circuit evaluation, applicability-time caching, and the overhead test of [§16](#16-testing-and-performance). @@ -913,9 +943,9 @@ and argument formulas reference ([RFC 0017 §6](0017_UniversalKernelDescriptor.m 1. **Expression language home.** This RFC pins JsonLogic as the shared expression language and defines it here because the UMD needs it first; the UDD follow-up references this section. Confirm the split rather than each subsystem defining its own, and decide where the shared conformance suite lives. -2. **GQA divisibility.** Express `nhead_q % nhead_k == 0` with the `%` operator - ([§6](#6-the-shared-expression-language)) or centralize it as a native predicate for uniform - fail-closed zero-guarding ([§8](#8-native-predicate-escape-hatch))? +2. **Native-matcher bindings.** A native matcher gets `MatchContext`, not the bindings its pack's UMD + already published ([§8](#8-the-native-matcher-escape-hatch)), so it re-locates tensors by hand. + Extend the matcher-invocation contract to pass the published bindings, or accept the duplication? 3. **Static-matcher form.** Which of the [§11](#11-static-matcher-sketch) options becomes the AOT fast path, and does it also serve drop-in via a serialized bytecode? 4. **Feature-vector overlap.** The bound symbol table overlaps the feature vector a UHD consumes @@ -967,9 +997,10 @@ The design borrows established ideas; none is a dependency. These informed the U ([§6](#6-the-shared-expression-language)). - **Stride-order layout:** layout represented as an ordered list of logical dimension indices, outermost first, since tensors carry no layout enum ([§7](#7-layout-and-stride-order-constraints)). -- **Custom operation (native predicate):** the escape hatch; a registry-resolved JsonLogic operation a - UMD invokes by its namespaced name for logic the built-in operators cannot state, carried as an - operation name and typed arguments, never inline code ([§8](#8-native-predicate-escape-hatch)). +- **Native matcher:** the escape hatch; a `GraphMatcherFn` named by a `MatchDescriptor`'s + `matchSymbol` and conjoined with a UMD's criteria by the ingestor, for logic the built-in operators + cannot state. It lives beside the expression language, never inside it + ([§8](#8-the-native-matcher-escape-hatch)). - **Composite criteria:** any boolean combination of tests within the one `criteria` expression, written directly with JsonLogic `and` / `or` / `!` / `if` ([§9](#9-composite-constraints)). - **Arbitration:** the deterministic resolution when several UKDs match: heuristic (UHD) score, then @@ -1177,7 +1208,6 @@ All integer arithmetic uses checked-width integers and fails closed on overflow | `present`, `not_present` | n-ary | `Ref…` | `Bool` | Was this supplied? `present` is `true` iff every referenced operand/field is bound; `not_present` iff every one is absent. Unlike a field read, these **always evaluate** rather than declining on an absent reference, so one call declines a list of unsupported optional operands | | `if` | 3 or 2n+1 | `Bool, Value [, Bool, Value]… , Value` | `Value` | `if`/`elif`/`else` chain; branch results MUST share a type | | `shape` | 2 | `Tensor, EntryList` | `Bool` | A.5 | -| `.` | per registry | per registry | `Bool` or `Value` | Custom operation, A.9 | **Every operator except `present`, `not_present`, and `value_or_default` propagates an unknown** (an unresolved reference) rather than coercing it, so an absent optional operand can never satisfy a @@ -1213,14 +1243,19 @@ An array MUST be a permutation of `0 .. rank-1` listing logical dimension indice (A.5); a fixed-rank alias compared against a differently-pinned tensor is refused at compile rather than declining silently at match time. -### A.9 Custom operations +### A.9 The operator set is closed + +A.7 is exhaustive. An operation key that is not listed there is refused at compile time, and there is +no registry, namespace, or provider hook by which a descriptor can introduce one +([§8](#8-the-native-matcher-escape-hatch)). -A custom operation is a JsonLogic operation whose key is a namespaced name (`hipdnn.strides_fit_u32`) -resolved from the provider-internal registry ([§8](#8-native-predicate-escape-hatch)). It nests and -negates like any built-in. The descriptor carries only the name and an argument list of `expr`s (bound -variables or literals) whose types MUST match the predicate's registered signature; it never carries -inline code. A name the running provider does not ship fails to resolve and declines the match -([§8](#8-native-predicate-escape-hatch), [§14](#14-security-and-hostile-input)). +A **namespaced (dotted) key** such as `{"hipdnn.strides_fit_u32": [...]}` is the form a custom +operation would have taken, and gets no special treatment: it is simply not in A.7, so it is refused +as an unrecognized operator. The check it expresses belongs in a native matcher listed beside this +descriptor in the pack, not inside `criteria`. Adding a built-in operator to A.7 is additive +([§13](#13-serialization-and-versioning)); adding one out-of-band is not possible, which is what +makes a UMD interpretable from the schema alone +([§14](#14-security-and-hostile-input)). ### A.10 Compile-time validation (normative) @@ -1242,7 +1277,8 @@ quarantines) the descriptor with a diagnostic ([§10](#10-the-matcher-compilatio reserved `tensor-field`. 8. Every `$`-reference resolves to a bound symbol (the build and drop-in bound-symbol check of [§4](#4-symbol-binding-and-the-auto-binding-formula)). -9. Every operation's arity and argument types satisfy A.7; every layout alias resolves. +9. Every operation key appears in A.7 (A.9), every operation's arity and argument types satisfy A.7, + and every layout alias resolves. 10. `criteria` has static type `Bool`. --- From a900ec65e5274e7d85efbca48ac282dbe75102ee Mon Sep 17 00:00:00 2001 From: Daryl Hawkins Date: Wed, 12 Aug 2026 12:08:53 -0600 Subject: [PATCH 60/60] refactor(umd): carry a compiled UMD's id as UUID bytes A compiled descriptor kept its id as the descriptor's text UUID, while the plugin SDK's DescriptorId (Descriptors.hpp) is already a 128-bit hipdnn_flatbuffers_sdk::utilities::UuidBytes. Two representations of one identity means every future cross-reference between a matcher and the descriptor set that names it pays a parse. CompiledUmd::id, MatchResult::umdId, and UniversalGraphMatcher::umdId() now hold UuidBytes. The JSON wire form is unchanged: a descriptor still declares `"id": ""`, and the compiler parses it once. The hand-rolled isUuid() string check is gone; parseUuid() from the SDK is now the single source of truth for the A.10 section 1 well-formedness rule, with its std::invalid_argument translated to UmdCompileError so the fail-closed contract is unchanged. The accepted id set is identical to before. The $kernel-metadata routing diagnostic formats via formatUuid(), so its text is unchanged. --- .../umd/UmdCompiler.hpp | 35 ++++++++----------- .../umd/UniversalGraphMatcher.hpp | 7 ++-- .../tests/core/TestUniversalGraphMatcher.cpp | 27 ++++++++++++-- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp index 9350aaefef66..cd21b04e9963 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UmdCompiler.hpp @@ -30,6 +30,7 @@ #include "hip_kernel_provider_common/umd/UmdPathParse.hpp" #include +#include #include @@ -93,7 +94,9 @@ struct TensorVarSpec // lifetime. struct CompiledUmd { - std::string id; + // The descriptor's identity (RFC 0018 §13): a UUID, held as the 128-bit + // value hipDNN carries every descriptor and graph id in, not as text. + hipdnn_flatbuffers_sdk::utilities::UuidBytes id{}; std::string name; // Matcher format version and the hipDNN graph schema version this matcher // was authored against; empty when the descriptor declares neither @@ -296,7 +299,7 @@ class UmdCompiler { CompiledUmd out; validateTopLevel(d); - out.id = d.at("id").get(); + out.id = parseId(d.at("id")); out.name = d.at("name").get(); // Both version keys are optional. An omitted key means "1.0", the // version every descriptor authored against this revision implies @@ -368,9 +371,9 @@ class UmdCompiler { throw UmdCompileError("schema must equal \"hipdnn.umd/v1\" (A.10 §1)"); } - if(!d.at("id").is_string() || !isUuid(d.at("id").get())) + if(!d.at("id").is_string()) { - throw UmdCompileError("id must be a well-formed UUID (A.10 §1)"); + throw UmdCompileError("id must be a string (A.10 §1)"); } if(!d.at("name").is_string()) { @@ -388,27 +391,19 @@ class UmdCompiler } } - static bool isUuid(const std::string& s) + // A.10 §1: the id must be a well-formed UUID. Parsing it here is the check + // -- a malformed id is refused at compile rather than surviving as an + // unusable string. + static hipdnn_flatbuffers_sdk::utilities::UuidBytes parseId(const nlohmann::json& id) { - if(s.size() != 36) + try { - return false; + return hipdnn_flatbuffers_sdk::utilities::parseUuid(id.get()); } - for(std::size_t i = 0; i < s.size(); ++i) + catch(const std::invalid_argument&) { - if(i == 8 || i == 13 || i == 18 || i == 23) - { - if(s[i] != '-') - { - return false; - } - } - else if(std::isxdigit(static_cast(s[i])) == 0) - { - return false; - } + throw UmdCompileError("id must be a well-formed UUID (A.10 §1)"); } - return true; } // Both version keys are optional; when present each must be a diff --git a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp index 9f395d56d9a4..5e6e4455991a 100644 --- a/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp +++ b/dnn-providers/hip-kernel-provider/include/hip_kernel_provider_common/umd/UniversalGraphMatcher.hpp @@ -31,6 +31,7 @@ #include "hip_kernel_provider_common/umd/UmdCompiler.hpp" #include +#include #include #include @@ -58,7 +59,7 @@ struct DeviceProperties struct MatchResult { bool matched = false; - std::string umdId; + hipdnn_flatbuffers_sdk::utilities::UuidBytes umdId{}; BindingContext bindings; // valid when matched; queryable post-match }; @@ -83,7 +84,7 @@ class UniversalGraphMatcher } // The id of the descriptor this matcher matches against. - const std::string& umdId() const + const hipdnn_flatbuffers_sdk::utilities::UuidBytes& umdId() const { return _umd->id; } @@ -128,7 +129,7 @@ class UniversalGraphMatcher if(referencesKernelMetadata()) { throw std::logic_error( - "UMD '" + _umd->id + "UMD '" + hipdnn_flatbuffers_sdk::utilities::formatUuid(_umd->id) + "' references $kernel metadata; call the kernel-metadata match overload"); } return matchImpl(device, graph, nullptr); diff --git a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp index da9c1cbfabce..8a96bf9471ad 100644 --- a/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp +++ b/dnn-providers/hip-kernel-provider/src/tests/core/TestUniversalGraphMatcher.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include "hip_kernel_provider_common/umd/BindingContext.hpp" @@ -122,6 +123,10 @@ umd::UniversalGraphMatcher makeSdpaMatcher() constexpr umd::DeviceProperties K_DEVICE{/*ldsSize=*/65536, /*warpSize=*/64}; +// The sdpaDescriptor() `id`, in the form the compiled descriptor carries it. +const hipdnn_flatbuffers_sdk::utilities::UuidBytes K_SDPA_UMD_ID + = hipdnn_flatbuffers_sdk::utilities::parseUuid("9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); + } // namespace // ---- Compiler validation (A.10) ------------------------------------------ @@ -156,6 +161,22 @@ TEST(TestUmdCompiler, RejectsMalformedUuid) EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); } +TEST(TestUmdCompiler, RejectsWellSizedNonHexUuid) +{ + // Right shape, wrong alphabet: the id is parsed, not pattern-matched, so a + // non-hex digit must still surface as a compile error. + json d = sdpaDescriptor(); + d["id"] = "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5bZZ"; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + +TEST(TestUmdCompiler, RejectsNonStringUuid) +{ + json d = sdpaDescriptor(); + d["id"] = 42; + EXPECT_THROW(umd::UmdCompiler::compile(d), umd::UmdCompileError); +} + TEST(TestUmdCompiler, RejectsUnknownTopLevelKey) { json d = sdpaDescriptor(); @@ -280,8 +301,8 @@ TEST(TestUniversalGraphMatcher, OwnsSingleDescriptor) // A matcher has a 1:1 relationship with its UMD: it exposes that one // descriptor's id and compiled form. const umd::UniversalGraphMatcher m = makeSdpaMatcher(); - EXPECT_EQ(m.umdId(), "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); - EXPECT_EQ(m.descriptor().id, "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); + EXPECT_EQ(m.umdId(), K_SDPA_UMD_ID); + EXPECT_EQ(m.descriptor().id, K_SDPA_UMD_ID); EXPECT_EQ(m.descriptor().nodes.size(), 1u); } @@ -293,7 +314,7 @@ TEST(TestUniversalGraphMatcher, MatchesValidSdpaForward) const umd::MatchResult r = m.match(K_DEVICE, g); ASSERT_TRUE(r.matched); - EXPECT_EQ(r.umdId, "9c3f5b2a-7d41-4e88-b6a0-1f2e3d4c5b6a"); + EXPECT_EQ(r.umdId, K_SDPA_UMD_ID); } TEST(TestUniversalGraphMatcher, DeclinesWrongHeadSize)