From 49e04750d9d04adeebfcd36f6d8abb2a34445bcf Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Mon, 2 Nov 2026 20:29:03 -0400 Subject: [PATCH] npu forward: refuse a launch whose buffer is narrower than the ELF's declared argument A full ELF states, in its .dynsym, the byte width of every kernel argument (the driver patches those very symbols), and the kernel's shim DMA touches exactly that much. A launcher that passes a narrower buffer does not fail: the DMA walks past the mapping, the IOMMU logs IO_PAGE_FAULTs, and the run returns garbage. Measured on 2026-11-02 with an 896-hidden OGA pack: its ELF family is (weights, activations, output) = (655,360, 2,048, 4,096) B, the int8 launcher passes (MD*KD, KD*ND, MD*ND*4) = (16,384, 1,048,576, 65,536) B at M=16, so 639 KB of weight stream was read past a 16 KB activation BO. Three IO_PAGE_FAULT bursts, no amdxdna ERROR, no TDR, and a wrong answer that looked like a model problem. - q4nx_elf_arg_sizes() (npu/forward/src/q4nx_pack.c) reads the three widths out of the artifact: 32-bit LE ELF, section headers, SHT_DYNSYM, STT_OBJECT symbols named "0".."2". Returns how many it found, or -1 for a non-ELF / no .dynsym, zeroing out[] first. - I8Ctx::init_elf() refuses the load when a declared width exceeds the buffer the launch would pass, printing both numbers, the geometry and the argument's role. One-directional by design: a declared width may be smaller than the buffer (the tile-family ELFs address a tile of a larger BO through the control text) but never larger -- so the tile family every validated model uses is unaffected, and the check cannot fire on a working geometry. tests/npu_elf_args_test.c builds the ELF32 fixtures by hand (no aiecc, no model, no device): the attention-ELF widths, a narrow tile-family ELF, and the three refusals; added to the CI target list so ctest does not report it "Not Run". Not fixed here, and the reason this is a gate rather than a launcher: driving this ELF family needs (weights, activations, output) with the per-core weight stream layout forge_bench uses, which is a separate change with its own evidence. --- .github/workflows/ci.yml | 2 +- CMakeLists.txt | 4 + npu/forward/engine-src/npu_engine_i8ctx_inc.h | 45 +++++++ npu/forward/include/q4nx_pack.h | 10 ++ npu/forward/src/q4nx_pack.c | 64 ++++++++++ tests/npu_elf_args_test.c | 117 ++++++++++++++++++ 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 tests/npu_elf_args_test.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e41ab3e7..20b4a6b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: tokenizer_test think_split_test gguf_meta_test mem_admit_test pm_routes_test \ laya_encoder_test http_guard_test moe_gguf_index_test laya_bounds_test \ npu_model_bounds_test npu_forward_loader_test npu_sampling_test \ - npu_flm_head_import_test \ + npu_flm_head_import_test npu_elf_args_test \ tokenizer_linear_test hrx_placement_test hrx_cwsr_test npu_device_lock_test -j - name: Test # One hung test must not hold the job for hours: each test gets 10 minutes. diff --git a/CMakeLists.txt b/CMakeLists.txt index 285d99ba..1341edd2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -497,6 +497,10 @@ add_dependencies(npu_sampling_test systemone_route_test) # The plan.json loader (npu/plan/plan.h, engine #799 P1): schema, refusals and the decision fields that # replaced the filename probes. No model, no container, no device. +add_executable(npu_elf_args_test tests/npu_elf_args_test.c npu/forward/src/q4nx_pack.c) +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) 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) diff --git a/npu/forward/engine-src/npu_engine_i8ctx_inc.h b/npu/forward/engine-src/npu_engine_i8ctx_inc.h index 8758db37..206e3e01 100644 --- a/npu/forward/engine-src/npu_engine_i8ctx_inc.h +++ b/npu/forward/engine-src/npu_engine_i8ctx_inc.h @@ -187,10 +187,55 @@ struct I8Ctx { group_scales.resize(NL); // packB() writes group_scales[l] for (int l = 0; l < NL; l++) layerB[l] = mkbo(w_bytes()); + if (!args_fit_elf(ep)) return false; initialized = true; return true; } + // ── The launch contract, checked against the artifact ── + // A full ELF declares each argument's byte width in .dynsym (the driver patches those very + // symbols), and the kernel's shim DMA touches exactly that much. Passing a narrower buffer does + // not fail: the DMA walks past the mapping, the IOMMU logs IO_PAGE_FAULTs and the run returns + // garbage (measured 2026-11-02 on the 896-hidden OGA pack, whose ELF family is + // (weights, activations, output) = (655,360, 2,048, 4,096) B while the int8 launcher passed + // (16,384, 1,048,576, 65,536) B). The check is one-directional on purpose: a declared width may + // be *smaller* than the buffer (the tile-family ELFs address a tile of a larger BO through the + // control text), but it may never be larger. + bool args_fit_elf(const char* ep) { + std::vector raw; + { + std::FILE* f = std::fopen(ep, "rb"); + if (!f) return true; // cannot read it: nothing to check against + std::fseek(f, 0, SEEK_END); + const long len = std::ftell(f); + std::rewind(f); + if (len <= 0) { std::fclose(f); return true; } + raw.resize((size_t)len); + const size_t got = std::fread(raw.data(), 1, raw.size(), f); + std::fclose(f); + if (got != raw.size()) return true; + } + uint64_t declared[3] = {0, 0, 0}; + if (q4nx_elf_arg_sizes(raw.data(), raw.size(), declared) < 0) return true; // not our shape + const uint64_t passed[3] = {(uint64_t)MD * (uint64_t)KD, + (uint64_t)w_bytes(), + (uint64_t)MD * (uint64_t)ND * 4u * (pb16i ? 2u : 1u)}; + // I8Ctx carries no error string; the caller's err_ names the ELF, so print the numbers here. + static const char* const kWhat[3] = {"activations", "weights", "output"}; + for (int i = 0; i < 3; i++) { + if (!declared[i] || declared[i] <= passed[i]) continue; + std::fprintf(stderr, + " I8Ctx::init_elf: %s declares argument %d (%s) = %llu B, this launch would " + "pass %llu B -- the kernel's DMA would run past the buffer (M=%d K=%d N=%d). " + "The artifact's argument width is its contract and this ELF family is not the " + "one this launcher drives; refusing before any dispatch.\n", + ep, i, kWhat[i], (unsigned long long)declared[i], + (unsigned long long)passed[i], MD, KD, ND); + return false; + } + return true; + } + // ── Resident-expert (MoE) helpers: pack/launch against an arbitrary BO ── // Decode is M=1 with top-1 routing; re-streaming the selected expert's // weights into a shared per-layer BO every token costs a memcpy + sync on diff --git a/npu/forward/include/q4nx_pack.h b/npu/forward/include/q4nx_pack.h index 15469685..9121ad10 100644 --- a/npu/forward/include/q4nx_pack.h +++ b/npu/forward/include/q4nx_pack.h @@ -214,7 +214,17 @@ int q4nx_assemble_B(const Q4nxDesignGeom* g, const Q4nxProjections* p, float* B) float q4nx_pack_B_int8(const float* B, int K, int N, int8_t* out, int ld, float* col_scales); +/* The three buffer widths a full ELF's runtime sequence declares (its .dynsym STT_OBJECT symbols + * named "0".."2"), read from the artifact instead of assumed. A launcher must pass buffers at + * least that wide: the kernel's shim DMA touches exactly these widths, and a narrower buffer makes + * the DMA walk past the mapping (IO_PAGE_FAULTs, garbage results, no error). Measured 2026-11-02: + * a pack whose ELF family is (weights, activations, output) = (655,360, 2,048, 4,096) B driven by a + * launcher that passed (16,384, 1,048,576, 65,536) B. Returns the number of widths found (0..3) or + * -1 when the file is not a 32-bit LE ELF / has no .dynsym; undeclared entries stay 0. */ +int q4nx_elf_arg_sizes(const void* elf, size_t n, uint64_t out[3]); + #ifdef __cplusplus + } #endif #endif /* NPU_INFER_Q4NX_PACK_H */ diff --git a/npu/forward/src/q4nx_pack.c b/npu/forward/src/q4nx_pack.c index 529d6e74..44d90bfc 100644 --- a/npu/forward/src/q4nx_pack.c +++ b/npu/forward/src/q4nx_pack.c @@ -458,3 +458,67 @@ float q4nx_pack_B_int8(const float* B, int K, int N, int8_t* out, int ld, } return (float)(ssum / (double)N); } + +/* ── The launch contract: the argument widths a full ELF declares ─────────────────────────────── + * + * An aiecc full ELF carries one STT_OBJECT symbol per kernel argument in .dynsym, named "0", "1", + * "2", whose st_size is the byte width the kernel's shim DMA touches — the driver patches those very + * symbols, so this is the artifact's own contract and not a copy of one. A launcher that passes a + * narrower buffer does not fail loudly: the DMA runs past the mapping, the IOMMU logs IO_PAGE_FAULTs + * and the kernel multiplies whatever follows (measured 2026-11-02 on the 896-hidden OGA pack, whose + * ELF family is (weights, activations, output) = (655,360, 2,048, 4,096) B while the int8 launcher + * passed (16,384, 1,048,576, 65,536) B: 639 KB of weight stream read past a 16 KB BO). Reading the + * widths here lets a load refuse instead of dispatching. + * + * Returns the number of argument widths found (0..3), or -1 when the file is not a 32-bit + * little-endian ELF or has no .dynsym. out[] entries that were not declared stay 0. */ +int q4nx_elf_arg_sizes(const void* elf, size_t n, uint64_t out[3]) { + if (out) { out[0] = out[1] = out[2] = 0; } + if (!elf || !out || n < 52) return -1; + const uint8_t* b = (const uint8_t*)elf; + if (memcmp(b, "\x7f" "ELF", 4) || b[4] != 1 || b[5] != 1) return -1; /* ELF32, LSB */ +#define RD16(p) ((uint32_t)(p)[0] | ((uint32_t)(p)[1] << 8)) +#define RD32(p) ((uint64_t)(p)[0] | ((uint64_t)(p)[1] << 8) | ((uint64_t)(p)[2] << 16) | \ + ((uint64_t)(p)[3] << 24)) + const uint64_t shoff = RD32(b + 0x20); + const uint32_t shentsize = RD16(b + 0x2E), shnum = RD16(b + 0x30); + if (shentsize < 40 || !shnum || shnum > 512) return -1; /* real artifacts have ~13 */ + if (shoff + (uint64_t)shentsize * shnum > n) return -1; + struct sh { uint64_t off, size, link, entsize, type; }; + struct sh sect[512]; + for (uint32_t i = 0; i < shnum; i++) { + const uint8_t* p = b + shoff + (uint64_t)i * shentsize; + sect[i].type = RD32(p + 4); + sect[i].off = RD32(p + 16); + sect[i].size = RD32(p + 20); + sect[i].link = RD32(p + 24); + sect[i].entsize = RD32(p + 36); + if (sect[i].type != 8 /* SHT_NOBITS */ && sect[i].off + sect[i].size > n) return -1; + } + int found = 0; + for (uint32_t i = 0; i < shnum; i++) { + if (sect[i].type != 11 /* SHT_DYNSYM */ || sect[i].link >= shnum) continue; + const struct sh* st = §[sect[i].link]; /* the symbols' string table */ + const uint32_t esz = sect[i].entsize ? (uint32_t)sect[i].entsize : 16; + if (esz < 16) continue; + for (uint64_t s = 1; s * esz + 16 <= sect[i].size; s++) { + const uint8_t* sym = b + sect[i].off + s * esz; + const uint64_t name = RD32(sym); + const uint64_t size = RD32(sym + 8); + const uint8_t info = sym[12]; + if ((info & 0xF) != 1 /* STT_OBJECT */ || !size) continue; + if (name >= st->size) continue; + const uint8_t* nm = b + st->off + name; + const uint64_t room = st->size - name; + if (room < 1) continue; + /* the argument symbols are named by their index: "0", "1", "2" */ + if (room >= 2 && nm[1] != 0) continue; + if (nm[0] < '0' || nm[0] > '2') continue; + const int idx = nm[0] - '0'; + if (!out[idx]) { out[idx] = size; found++; } + } + } +#undef RD16 +#undef RD32 + return found ? found : -1; +} diff --git a/tests/npu_elf_args_test.c b/tests/npu_elf_args_test.c new file mode 100644 index 00000000..152a54e4 --- /dev/null +++ b/tests/npu_elf_args_test.c @@ -0,0 +1,117 @@ +// 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_elf_args_test -- q4nx_elf_arg_sizes() reads the argument widths a full ELF declares. +// +// The widths are the launcher's contract: the kernel's shim DMA touches exactly the bytes the ELF's +// .dynsym declares for each argument, so a narrower buffer makes the DMA walk past the mapping. +// That is not hypothetical: on 2026-11-02 an OGA pack's ELF family -- (weights, activations, +// output) = (655,360, 2,048, 4,096) B -- was driven by the int8 launcher, which passes +// (MD*KD, KD*ND, MD*ND*4) = (16,384, 1,048,576, 65,536) B, and the array logged IO_PAGE_FAULTs +// while returning garbage instead of failing. The gate in I8Ctx::init_elf compares these two sets +// and refuses; this test pins the reader it depends on. +// +// Host-only: the fixtures are hand-built ELF32 images, so no aiecc build and no device are needed. +#include "q4nx_pack.h" + +#include +#include +#include +#include + +static int failures; + +static void check(int cond, const char* what) { + printf("%s %s\n", cond ? "ok " : "FAIL", what); + if (!cond) failures++; +} + +static void put32(unsigned char* p, uint32_t v) { memcpy(p, &v, 4); } +static void put16(unsigned char* p, uint16_t v) { memcpy(p, &v, 2); } + +// A minimal ELF32 (LSB) with [ehdr][.dynsym][.strtab][.shstrtab][shtab] and one STT_OBJECT symbol +// per argument, named "0".."2" -- the shape aiecc --get-full-elf writes for a full ELF. +static unsigned char* build_elf(const uint32_t sizes[3], uint32_t* out_len) { + enum { NSYM = 4, NDYN = NSYM * 16, NSTR = 8, NSHSTR = 24, NSH = 5 }; + const uint32_t off_dyn = 52, off_str = off_dyn + NDYN, off_shstr = off_str + NSTR; + const uint32_t off_sht = off_shstr + NSHSTR, total = off_sht + NSH * 40; + unsigned char* elf = calloc(1, total); + if (!elf) return NULL; + memcpy(elf, "\x7f" "ELF", 4); + elf[4] = 1; elf[5] = 1; elf[6] = 1; // ELF32, little-endian, version 1 + put32(elf + 0x20, off_sht); put16(elf + 0x2E, 40); put16(elf + 0x30, NSH); put16(elf + 0x32, 4); + memcpy(elf + off_str, "\0" "0" "\0" "1" "\0" "2" "\0", NSTR); // names at 1, 3, 5 + memcpy(elf + off_shstr, "\0.dynsym\0.strtab\0.shstrtab\0", NSHSTR); + for (int i = 1; i < NSYM; i++) { + unsigned char* sym = elf + off_dyn + i * 16; + put32(sym, (uint32_t)(2 * i - 1)); // 1, 3, 5 + put32(sym + 8, sizes[i - 1]); + sym[12] = 0x11; // STB_GLOBAL | STT_OBJECT + put16(sym + 14, 5); + } + struct { const char* name; uint32_t type, off, size, link, entsize; } sh[NSH] = { + {"", 0, 0, 0, 0, 0}, + {".dynsym", 11, off_dyn, NDYN, 2, 16}, + {".strtab", 3, off_str, NSTR, 0, 0}, + {".shstrtab", 3, off_shstr, NSHSTR, 0, 0}, + {"", 0, 0, 0, 0, 0}}; + const uint32_t name_off[NSH] = {0, 1, 9, 17, 0}; + for (int i = 0; i < NSH; i++) { + unsigned char* p = elf + off_sht + i * 40; + const uint32_t f[10] = {name_off[i], sh[i].type, 0, 0, sh[i].off, sh[i].size, sh[i].link, 0, 4, + sh[i].entsize}; + memcpy(p, f, sizeof f); + } + *out_len = total; + return elf; +} + +int main(void) { + // the OGA pack's attention ELF: weights, activations, output + const uint32_t sizes[3] = {163840, 2048, 1024}; + uint32_t len = 0; + unsigned char* elf = build_elf(sizes, &len); + if (!elf) { printf("FAIL out of memory\n"); return 1; } + + uint64_t got[3] = {0, 0, 0}; + check(q4nx_elf_arg_sizes(elf, len, got) == 3, "three argument widths are found"); + check(got[0] == 163840 && got[1] == 2048 && got[2] == 1024, "widths match the declared symbols"); + + // the tile family declares much narrower arguments; the reader must report them as they are + const uint32_t tile[3] = {512, 1024, 4096}; + unsigned char* e2 = build_elf(tile, &len); + uint64_t got2[3] = {0, 0, 0}; + check(e2 && q4nx_elf_arg_sizes(e2, len, got2) == 3 && got2[0] == 512 && got2[2] == 4096, + "a narrow tile-family ELF reads back as declared"); + + // refusals: not an ELF, no .dynsym, a truncated header -- and out[] is zeroed first either way + uint32_t len0 = 0; + unsigned char* bad = build_elf(sizes, &len0); + bad[0] = 'X'; + uint64_t z[3] = {7, 7, 7}; + check(q4nx_elf_arg_sizes(bad, len0, z) == -1, "a non-ELF is refused"); + check(z[0] == 0 && z[1] == 0 && z[2] == 0, "out[] is zeroed before the refusal"); + bad[0] = 0x7f; + const uint32_t zero_type = 0; + const uint32_t off_dyn = 52, off_sht = off_dyn + 4 * 16 + 8 + 24; + memcpy(bad + off_sht + 40 + 4, &zero_type, 4); // .dynsym -> SHT_NULL + check(q4nx_elf_arg_sizes(bad, len0, z) == -1, "an ELF with no .dynsym is refused"); + check(q4nx_elf_arg_sizes(elf, 16, z) == -1, "a truncated header is refused"); + check(q4nx_elf_arg_sizes(NULL, len, z) == -1, "a null image is refused"); + + free(elf); free(e2); free(bad); + printf("%s\n", failures ? "FAILED" : "all cases ok"); + return failures ? 1 : 0; +}