From efde6ab894e51266babd87597feb79ddc1c2b674 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Tue, 10 Nov 2026 23:07:51 -0400 Subject: [PATCH] npu-pack test: the pack leg's head tile count follows the stored rows, so a padded tail head passes A head read from the .fconst tail keeps the pack's padding to whole 64-row tiles (Hunyuan: 120,818 rows are stored as 120,832). The pack leg compared the container's tile count with the vocabulary's, so it failed one check on every such pack although the import was right (Hunyuan-1.8B: 15,104 tiles written, 15,100 expected). head_stored_rows() states the rule once; the import uses it for its tile count and the test for its expectation. Co-Authored-By: Claude Fable 5.2 --- npu/pack_import.cpp | 7 ++++++- npu/pack_import.h | 6 ++++++ tests/npu_pack_import_test.cpp | 25 ++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/npu/pack_import.cpp b/npu/pack_import.cpp index d1e844bc7..f2ad9c895 100644 --- a/npu/pack_import.cpp +++ b/npu/pack_import.cpp @@ -3363,6 +3363,11 @@ ContainerGeometry container_geometry(const json& meta, const json& config) { return g; } +int64_t head_stored_rows(int64_t vocab, bool from_fconst_tail) { + if (!from_fconst_tail || vocab <= 0) return vocab; + return (vocab + kTileRows - 1) / kTileRows * kTileRows; +} + std::string qk_norm_refusal(bool declared, bool found) { if (declared && !found) return "this pack declares use_qk_norm and its per-head q/k gains are not where the block layout says " @@ -5350,7 +5355,7 @@ Report import_pack(const std::string& pack_dir, const std::string& out_dir, cons // (Hunyuan-0.5B: 120,818 rows = 1,887.8 tiles, so the tail holds 1,888 and the last one is partly // padding). The rows past V are dropped before the tensor is written, so the container's head has // exactly V rows. - const int64_t rows = (V + kTileRows - 1) / kTileRows; + const int64_t rows = head_stored_rows(V, true) / kTileRows; // The envelope is the same `64 + 2*hidden` the layer blocks open with (eps, padding, the norm), not a // constant: 1,856 is only its qwen2.5-0.5B value. Qwen3-1.7B's tail is 4,160 + 2,374 x 73,984 to the // byte, which the constant would have missed by 2,304. diff --git a/npu/pack_import.h b/npu/pack_import.h index 513025dbf..5fc37dc35 100644 --- a/npu/pack_import.h +++ b/npu/pack_import.h @@ -96,6 +96,12 @@ struct QkGainOffsets { QkGainOffsets qk_gain_offsets(int64_t gap_start, int64_t head_dim, int64_t gap_bytes); std::string qk_norm_refusal(bool declared, bool found); +// How many rows a head read from the .fconst tail occupies in the container. The pack stores the head in whole 64-row +// tiles, so a vocabulary that is not a multiple of 64 is carried with the pack's own padding rows (Hunyuan: 120,818 -> +// 120,832) while `vocab_size` and the plan's tensor shape stay the vocabulary. A head that is a graph initializer does +// not go through this (its own padding, to the 32-row tile quantum, is engine#1992's). This is the number a container's lm_head tile count follows from. +int64_t head_stored_rows(int64_t vocab, bool from_fconst_tail); + // Why a pack's two layout witnesses cannot be reconciled, or "" when they can (engine#1985). // // `vote_hdrs` is the number of in-block headers the epsilon-anchor vote recorded (0 when it could not win); `declared_qk` diff --git a/tests/npu_pack_import_test.cpp b/tests/npu_pack_import_test.cpp index 57e3e8862..0dcad1ff0 100644 --- a/tests/npu_pack_import_test.cpp +++ b/tests/npu_pack_import_test.cpp @@ -3007,10 +3007,13 @@ void test_pack(const std::string& pack, const std::string& golden_dir) { "pack: 64 columns per gate|up unit and per down_proj group"); int norms = 0, attn_planned = 0, mlp_planned = 0, lm_tiles_planned = 0; + bool head_in_tail = false; for (const auto& t : p.tensors) { if (t.name.find("layernorm") != std::string::npos || t.name == "model.norm.weight") ++norms; - if (t.dtype == "q4_1" && t.refusal.empty() && t.name.compare(0, 9, "lm_head.w") == 0) + if (t.dtype == "q4_1" && t.refusal.empty() && t.name.compare(0, 9, "lm_head.w") == 0) { lm_tiles_planned = int((t.shape[0] / 32) * ((t.shape[1] + 255) / 256)); + head_in_tail = t.source == "fconst:tail"; + } if (t.dtype == "q4_1" && t.refusal.empty() && t.name.find("self_attn") != std::string::npos) ++attn_planned; if (t.dtype == "q4_1" && t.refusal.empty() && t.name.find(".mlp.") != std::string::npos) ++mlp_planned; } @@ -3143,8 +3146,12 @@ void test_pack(const std::string& pack, const std::string& golden_dir) { // the container must open with the engine's own loader onebit::npu::Model m(out); const auto& head = m.tensor("lm_head.weight"); - check(head.shape.at(0) == int64_t((head_rows / 32) * ((p.tensors.at(1).shape.at(1) + 255) / 256)), - "pack: lm_head tile count matches the container"); + // A head read from the .fconst tail keeps the pack's padding to whole 64-row tiles (Hunyuan: 120,818 rows are stored + // as 120,832), so the container's count follows the stored rows; the plan's shape stays the vocabulary. + const int64_t stored_rows = pi::head_stored_rows(head_rows, head_in_tail); + check(head.shape.at(0) == (stored_rows / 32) * ((p.tensors.at(1).shape.at(1) + 255) / 256), + "pack: lm_head tile count matches the container (" + std::to_string(head.shape.at(0)) + " tiles for " + + std::to_string(stored_rows) + " stored rows)"); check(m.tensor("model.layers.0.input_layernorm.weight").shape.at(0) == p.dims.hidden, "pack: a layer norm is `hidden` bf16 values, whatever the hidden size"); check(m.tensor("model.norm.weight").shape.at(0) == int64_t(p.dims.hidden), @@ -5616,6 +5623,17 @@ void test_q4_writer_code_range() { // engine#1889: the arithmetic that claims the per-head QK gains out of the 640 B Hunyuan-7B carries between its last qkv // tile and o_proj. A wrong offset there reads tile bytes as bf16 and mis-rotates every pack of the family, so both the // right shape (2 * (64 + head_dim*2) = 640 at head_dim 128) and the neighbours that must NOT be taken for it are driven. +// The rows a tail head occupies in the container: the pack's own padding to whole 64-row tiles stays. The pack leg's +// tile-count check used the vocabulary, so it failed on every pack whose vocabulary is not a multiple of 64 although +// the import was right (Hunyuan-1.8B: 15,104 tiles written, 15,100 expected). +void test_head_stored_rows() { + check(pi::head_stored_rows(120818, true) == 120832, "head rows: Hunyuan's 120,818 are stored as 120,832"); + check(pi::head_stored_rows(151936, true) == 151936, "head rows: a multiple of 64 is stored as it is"); + check(pi::head_stored_rows(49152 + 32, true) == 49152 + 64, "head rows: a multiple of 32 that is not one of 64 is padded"); + check(pi::head_stored_rows(120818, false) == 120818, "head rows: a graph head is not padded"); + check(pi::head_stored_rows(0, true) == 0, "head rows: no vocabulary, no rows"); +} + void test_qk_gain_offsets() { const pi::QkGainOffsets o = pi::qk_gain_offsets(1000, 128, 640); // engine#1934: block 0 is Q. Hunyuan-7B's own model.onnx carries both gains as FP16 initializers, and q_norm is the @@ -5678,6 +5696,7 @@ int main(int argc, char** argv) { test_encode_tile_code_range(); test_q4_writer_code_range(); test_qk_norm_refusal(); + test_head_stored_rows(); test_qk_gain_offsets(); test_tail_head_placement(); const std::string golden_dir = argc > 1 ? argv[1] : "";