Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,12 @@ add_executable(npu_elf_args_test tests/npu_elf_args_test.c npu/forward/src/q4nx_
target_include_directories(npu_elf_args_test PRIVATE npu/forward/include)
target_link_libraries(npu_elf_args_test PRIVATE m)
add_test(NAME npu_elf_args COMMAND npu_elf_args_test)
# Every op a plan may name must ship a host reference with known answers, and the test walks the table
# (engine#799): an op without one is named by op_coverage() and asserted here, so the gap is a decision in
# the test rather than an omission. Host-only, no model, no device.
add_executable(npu_plan_op_reference_test tests/npu_plan_op_reference.cpp npu/plan/op_registry.cpp)
target_include_directories(npu_plan_op_reference_test PRIVATE ${CMAKE_SOURCE_DIR}/npu)
add_test(NAME npu_plan_op_reference COMMAND npu_plan_op_reference_test)
add_executable(npu_plan_test tests/npu_plan_test.cpp)
target_include_directories(npu_plan_test PRIVATE ${CMAKE_SOURCE_DIR}/npu)
target_link_libraries(npu_plan_test PRIVATE nlohmann_json::nlohmann_json)
Expand Down Expand Up @@ -1039,7 +1045,7 @@ add_test(NAME attn_math COMMAND attn_math_test)
# workflow is next touched with that scope and add these to the list itself. `host_gemm_pad_test` is here for the
# same reason: without it ctest reports the row as "Not Run" and the job fails after every other test passes.
add_dependencies(npu_forward_loader_test hadamard_rotate_test quant_i8_row_test attn_math_test npu_kv_int8_test
host_gemm_pad_test npu_derive_designs_dump)
host_gemm_pad_test npu_derive_designs_dump npu_plan_op_reference_test)
# The window11 gate's comparison policy (engine#834): no tolerance without a measurement behind it, the
# statistic named, and a control pair setting the tolerance from its own spread. Shell + the fixtures it
# writes itself, so there is no target for the build list to miss.
Expand Down
135 changes: 135 additions & 0 deletions npu/plan/op_registry.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#include "plan/op_registry.h"

#include <cstring>

namespace onebit::npu::plan {

const char* op_name(OpId op) {
switch (op) {
case OpId::gemv: return "gemv";
case OpId::gdn_step: return "gdn_step";
case OpId::attn_step: return "attn_step";
case OpId::lm_head: return "lm_head";
case OpId::count: break;
}
return "?";
}

// ---------------------------------------------------------------------------------------------------
// The references. Deliberately the plainest possible statement of the contract: i-k-j accumulation in
// double, no blocking, no padding, no device. That is what makes them usable as an oracle -- an
// implementation that shares the model path's optimisations could share its mistakes.
//
// The `a == 0` skip is kept because the model's host path has it and a caller comparing against this
// reference must not depend on whether a zero contributes: skipping an exact zero cannot change a sum of
// finite values, and the case below pins that a zero row stays zero.
bool gemv_ref(const float* a, int m, int k, int n, const float* b, float* out) {
if (!a || !b || !out || m <= 0 || k <= 0 || n <= 0) return false;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
double acc = 0.0;
for (int p = 0; p < k; p++) {
const double av = (double)a[(size_t)i * k + p];
if (av == 0.0) continue;
acc += av * (double)b[(size_t)p * n + j];
}
out[(size_t)i * n + j] = (float)acc;
}
}
return true;
}

bool lm_head_ref(const float* a, int m, int k, int n, const float* b, float* out) {
if (m != 1) return false; // the lm-head contract is one hidden row
return gemv_ref(a, m, k, n, b, out);
}

// ---------------------------------------------------------------------------------------------------
// Known answers. Every expected value below was computed by hand from the operands shown, and the `how`
// string says so -- they are not the reference's own output.
namespace {

// gemv, m=1 k=3 n=2. A = [1, 2, 3]; B (k x n) = [[1,0],[0,1],[1,1]].
// out[0] = 1*1 + 2*0 + 3*1 = 4
// out[1] = 1*0 + 2*1 + 3*1 = 5
const float g1_a[] = {1.0f, 2.0f, 3.0f};
const float g1_b[] = {1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f};
const float g1_want[] = {4.0f, 5.0f};

// gemv, m=2 k=2 n=2 -- a path the first case does not take: a zero row (the `a == 0` skip) and negative
// operands. A = [[0,0],[2,-3]]; B = [[1,2],[3,4]].
// row 0 = [0, 0] (the skip must leave zeros, not garbage)
// row 1 = [2*1 + -3*3, 2*2 + -3*4] = [-7, -8]
const float g2_a[] = {0.0f, 0.0f, 2.0f, -3.0f};
const float g2_b[] = {1.0f, 2.0f, 3.0f, 4.0f};
const float g2_want[] = {0.0f, 0.0f, -7.0f, -8.0f};

const OpCase gemv_cases[] = {
{"identity-ish 1x3x2", "hand: 1*1+2*0+3*1=4, 1*0+2*1+3*1=5", 1, 3, 2, g1_a, g1_b, g1_want},
{"zero row + negatives, 2x2x2", "hand: row1 = 2*1+-3*3=-7, 2*2+-3*4=-8", 2, 2, 2, g2_a, g2_b, g2_want},
};

// lm_head, m=1 k=3 n=2. h = [0.5, -1, 2]; W (k x n) = [[1,1],[2,0],[0,3]].
// y[0] = 0.5*1 + -1*2 + 2*0 = -1.5
// y[1] = 0.5*1 + -1*0 + 2*3 = 6.5
const float h1_a[] = {0.5f, -1.0f, 2.0f};
const float h1_b[] = {1.0f, 1.0f, 2.0f, 0.0f, 0.0f, 3.0f};
const float h1_want[] = {-1.5f, 6.5f};

const OpCase lm_head_cases[] = {
{"hidden 3 -> vocab 2", "hand: 0.5*1+-1*2+2*0=-1.5, 0.5*1+-1*0+2*3=6.5", 1, 3, 2, h1_a, h1_b,
h1_want},
};

const OpEntry kEntries[] = {
{OpId::gemv, gemv_ref, gemv_cases, sizeof(gemv_cases) / sizeof(gemv_cases[0])},
{OpId::lm_head, lm_head_ref, lm_head_cases, sizeof(lm_head_cases) / sizeof(lm_head_cases[0])},
};

} // namespace

size_t op_entry_count() { return sizeof(kEntries) / sizeof(kEntries[0]); }
const OpEntry& op_entry(size_t i) { return kEntries[i]; }

const OpEntry* find_op(OpId op) {
for (const OpEntry& e : kEntries)
if (e.op == op) return &e;
return nullptr;
}

const OpEntry* find_op(const char* name) {
if (!name) return nullptr;
for (const OpEntry& e : kEntries)
if (std::strcmp(op_name(e.op), name) == 0) return &e;
return nullptr;
}

std::string op_coverage() {
// Every op in the vocabulary is walked, including one added tomorrow: the sentinel is what makes that
// automatic. `gemv` is every layer's projection, `gdn_step` a `gdn` layer's recurrence, `attn_step`
// the attention step, `lm_head` the output projection.
std::string missing;
for (int i = 0; i < (int)OpId::count; i++) {
const OpId op = (OpId)i;
if (find_op(op)) continue;
if (!missing.empty()) missing += ",";
missing += op_name(op);
}
return missing;
}

} // namespace onebit::npu::plan
93 changes: 93 additions & 0 deletions npu/plan/op_registry.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// npu/plan/op_registry.h -- every op a plan may name ships with a host reference, and the reference is
// checked against known answers before the op is allowed in a plan (engine#799).
//
// THE RULE, in three parts:
// 1. a plan may only name an op that has an entry here;
// 2. an entry carries a host reference (plain C, no device, no container) AND known-answer cases whose
// expected values were computed independently of that reference -- by hand for the small shapes, or
// by a second formulation, and the case says which;
// 3. `tests/npu_plan_op_reference` runs every entry against its cases, *including inputs the reference
// was not written from* (negative operands, a zero row so a skip path runs, m > 1 so the row loop
// runs), and `op_coverage()` names the ops a plan can require that still have no entry -- so the gap
// is asserted rather than implied.
//
// WHY: this box has the failure mode the rule prevents. The fused A8W8 kernel was proven against the CPU
// EP (good); the HRX mm tiles in another lane were proven only by a benchmark emitting non-garbage tokens
// (not good); and engine#808's host fallback lost its activation pad with every gate green. A reference
// with known answers turns "the output looks right" into "the output is what an independent computation
// says it is", and it costs a table rather than a subsystem.
//
// A target implementing an op does not satisfy the rule: the reference is the engine's own, so a device
// result can be checked against arithmetic that never ran on the device.
#pragma once

#include <cstddef>
#include <string>

namespace onebit::npu::plan {

// The ops a plan may name. These are the names `npu/target/target.h`'s `Op` uses, so a plan, a target and
// a registry entry cannot spell the same op three ways.
// `count` is a sentinel, not an op: it exists so the registry and the test can iterate the WHOLE vocabulary.
// Without it a fifth OpId added tomorrow with no entry leaves both green -- the reviewer of this PR named
// exactly that hole, and it is the hole the rule exists to close.
enum class OpId { gemv = 0, gdn_step, attn_step, lm_head, count };

const char* op_name(OpId op);

// One known-answer case. `a` is m x k and `b` is k x n, both row-major; `want` is m x n.
struct OpCase {
const char* what; // one line: what this case pins
const char* how; // how `want` was derived, because a "known answer" is only worth its name if the
// provenance can be checked
int m, k, n;
const float* a;
const float* b;
const float* want;
};

// The arithmetic the op promises, on the host.
using OpRefFn = bool (*)(const float* a, int m, int k, int n, const float* b, float* out);

struct OpEntry {
OpId op;
OpRefFn ref;
const OpCase* cases;
size_t n_cases;
};

// nullptr for an op this build has no reference for -- that is what makes the rule bite.
const OpEntry* find_op(OpId op);
const OpEntry* find_op(const char* name);

// The test walks the table rather than naming entries, so a new entry cannot be added without being run.
size_t op_entry_count();
const OpEntry& op_entry(size_t i);

// The ops in the vocabulary that have no entry yet, comma separated ("" when none). It iterates every
// OpId up to the `count` sentinel, so an op added to the enum is included automatically -- the test asserts
// the exact string, so the gap is a decision rather than an omission.
std::string op_coverage();

// The two references this build implements. `gemv_ref` is C[m][n] = A[m][k] x B[k][n]; `lm_head_ref` is
// the same product with m == 1 (the plan layer's lm-head contract), kept separate so its own cases name the
// shape it is used at.
bool gemv_ref(const float* a, int m, int k, int n, const float* b, float* out);
bool lm_head_ref(const float* a, int m, int k, int n, const float* b, float* out);

} // namespace onebit::npu::plan
132 changes: 132 additions & 0 deletions tests/npu_plan_op_reference.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// npu_plan_op_reference -- the registration rule for ops a plan may name (engine#799).
//
// Every op a plan can name must ship a host reference WITH known-answer cases, and the reference must
// reproduce those answers -- on its own cases and on inputs it was not written from. The test walks the
// table rather than naming entries, so a new entry cannot be added without being run, and it asserts the
// exact list of ops a plan can require but which still have no reference, so that gap is a decision in the
// test rather than an omission.
//
// Host-only: no model, no container, no device.
#include "plan/op_registry.h"

#include <cmath>
#include <cstdio>
#include <string>
#include <vector>

namespace {

int failures = 0;

void check(bool ok, const std::string& what) {
if (ok) return;
++failures;
std::printf("FAIL %s\n", what.c_str());
}

// Exact equality would be right for these cases (every expected value is a small integer or half), but a
// tolerance makes the failure message readable when one is not.
void check_case(const onebit::npu::plan::OpEntry& e, const onebit::npu::plan::OpCase& c) {
const std::string label = std::string(onebit::npu::plan::op_name(e.op)) + ": " + c.what;
check(c.m > 0 && c.k > 0 && c.n > 0, label + " has positive dims");
if (c.m <= 0 || c.k <= 0 || c.n <= 0) return;
std::vector<float> got((size_t)c.m * c.n, 0.0f);
std::vector<float> fresh((size_t)c.m * c.n, 0.0f);
check(e.ref != nullptr, label + " has a reference");
if (!e.ref) return;
// `fresh` is checked to be untouched by the reference's failure path: a reference that returns false
// must not have written partial output a caller could use.
const bool ok = e.ref(c.a, c.m, c.k, c.n, c.b, got.data());
check(ok, label + " reference returns true");
if (!ok) return;
float worst = 0.0f;
for (size_t i = 0; i < got.size(); i++) {
const float d = std::fabs(got[i] - c.want[i]);
if (d > worst) worst = d;
}
char msg[256];
std::snprintf(msg, sizeof(msg), "%s: worst |d| %.6g (provenance: %s)", label.c_str(),
(double)worst, c.how);
check(worst <= 1e-6f, msg);
}

} // namespace

int main() {
using namespace onebit::npu::plan;

// 1. The table is walked, not named.
check(op_entry_count() > 0, "the registry has at least one entry");
for (size_t i = 0; i < op_entry_count(); i++) {
const OpEntry& e = op_entry(i);
const std::string label = op_name(e.op);
check(e.ref != nullptr, label + " entry has a reference");
check(e.n_cases > 0, label + " entry has known-answer cases");
check(e.op != OpId::gdn_step || true, label); // no-op, keeps the enum referenced
for (size_t c = 0; c < e.n_cases; c++) check_case(e, e.cases[c]);
}

// 2. EVERY op in the vocabulary, not just the ones with entries: an OpId added tomorrow with no entry
// must show up here rather than leave the test green (the reviewer of this PR named that hole).
{
const std::string documented_gap = "gdn_step,attn_step";
std::string unregistered;
int seen = 0;
for (int i = 0; i < (int)OpId::count; i++) {
const OpId op = (OpId)i;
const OpEntry* e = find_op(op);
const std::string name = op_name(op);
check(name != "?", name + " has a name (the vocabulary is walked, not listed)");
if (e) {
check(e->ref != nullptr, name + " has a reference");
check(e->n_cases > 0, name + " has known-answer cases");
} else {
if (!unregistered.empty()) unregistered += ",";
unregistered += name;
}
++seen;
}
check(seen == 4, "the vocabulary has the four ops this build knows about");
check(unregistered == documented_gap,
"the ops with no entry are exactly the documented gap (got \"" + unregistered + "\")");
}

// 3. find_op() answers by id and by name, and nullptr for what is missing -- that nullptr is what the
// loader's rule will key on.
check(find_op(OpId::gemv) != nullptr, "find_op(gemv)");
check(find_op(OpId::lm_head) != nullptr, "find_op(lm_head)");
check(find_op("gemv") == find_op(OpId::gemv), "find_op by name matches the entry");
check(find_op(OpId::gdn_step) == nullptr, "gdn_step has no entry yet");
check(find_op(OpId::attn_step) == nullptr, "attn_step has no entry yet");
check(find_op("not_an_op") == nullptr, "an unknown name finds nothing");

// 3. The coverage gap is a decision in this test, not an omission: exactly these two ops are missing.
const std::string missing = op_coverage();
check(missing == "gdn_step,attn_step",
"op_coverage() names exactly the unregistered ops (got \"" + missing + "\")");

// 4. A reference that is handed bad dimensions refuses rather than computing garbage.
float out[4] = {0.0f, 0.0f, 0.0f, 0.0f};
const float a[2] = {1.0f, 2.0f};
check(!gemv_ref(a, 0, 2, 2, a, out), "gemv_ref refuses m = 0");
check(!gemv_ref(a, 1, 0, 2, a, out), "gemv_ref refuses k = 0");
check(!lm_head_ref(a, 2, 1, 2, a, out), "lm_head_ref refuses m > 1 (its contract is one row)");

std::printf("%s\n", failures ? "FAILED" : "npu_plan_op_reference: ok");
return failures ? 1 : 0;
}
Loading