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] : "";