diff --git a/npu/pack_import.cpp b/npu/pack_import.cpp index 80f31d3cd..e7ab438f3 100644 --- a/npu/pack_import.cpp +++ b/npu/pack_import.cpp @@ -3487,6 +3487,76 @@ RoleLayerReport container_role_layer_types(const std::vector& tensor return out; } +// engine#1788, slice 3c: the roles a layer type does not change, and the engine slot each becomes. Every right-hand +// side below is a literal that exists in npu/forward/src (grep the strings, do not infer them from HF names). +static const struct { const char* role; const char* slot; } kRoleSlots[] = { + {"embed", "model.embed_tokens.weight"}, + {"final_norm", "model.norm.weight"}, + {"input_norm", "input_layernorm.weight"}, + {"post_norm", "post_attention_layernorm.weight"}, + {"lm_head", "lm_head.weight"}, + {"q_proj", "self_attn.q_proj.weight"}, + {"k_proj", "self_attn.k_proj.weight"}, + {"v_proj", "self_attn.v_proj.weight"}, + {"o_proj", "self_attn.o_proj.weight"}, + {"self_attn_gate_proj","self_attn.gate_proj.weight"}, + {"gate_proj", "mlp.gate_proj.weight"}, + {"up_proj", "mlp.up_proj.weight"}, + {"down_proj", "mlp.down_proj.weight"}, + {"gate_exps", "mlp.experts.gate_proj.weight"}, + {"up_exps", "mlp.experts.up_proj.weight"}, + {"down_exps", "mlp.experts.down_proj.weight"}, + {"shortconv_in_proj", "shortconv.in_proj.weight"}, +}; + +// The roles this slice does NOT place, each with the reason. A number here is honest; a guessed slot is not. +static const struct { const char* role; const char* why; } kRoleNoSlot[] = { + {"qkv_proj", "the slot prefix is the LAYER TYPE's (`linear_attn.` or `self_attn.`), so it needs the second key"}, + {"q_norm", "the engine's normaliser slots are per-architecture; none is verified for this role yet"}, + {"k_norm", "same: the engine's `k_norm` string is a config field, not a verified weight slot"}, + {"gate_up_proj", "fused in some containers, two slots in the engine; needs a measured split"}, + {"moe_router", "the engine reads `mlp.router.bias`; the weight slot is not verified"}, + {"share_gate_exps", "the shared expert's slots differ per architecture and none is verified"}, + {"share_up_exps", "same"}, + {"share_down_exps", "same"}, + {"shared_expert_gate", "same"}, +}; + +// The layer types that are linear attention, as an EXACT set rather than a substring: the defect family this stack +// exists to fix is substring matching where an identity was meant (#1840, #1841, #2277's needle order). A value that +// merely contains "linear" is not linear attention, and a new family (gdn, mamba-style) must be added here on purpose. +static const char* const kLinearAttentionLayerTypes[] = {"linear_attention"}; + +static bool is_linear_attention(const std::string& layer_type) { + for (const char* t : kLinearAttentionLayerTypes) if (layer_type == t) return true; + return false; +} + +std::string container_role_slot(const std::string& role, const std::string& layer_type) { + if (role == "qkv_proj") { + // The engine's only fused-qkv slot is the linear-attention one; `self_attn.qkv_proj` is not a name it reads. + return is_linear_attention(layer_type) ? "linear_attn.qkv_proj.weight" : std::string(); + } + for (const auto& kv : kRoleSlots) if (role == kv.role) return kv.slot; + return std::string(); +} + +RoleSlotReport container_role_slots(const ContainerRoles& roles) { + RoleSlotReport r; + for (const auto& kv : kRoleSlots) r.slot_of[kv.role] = kv.slot; + for (const auto& kv : kRoleNoSlot) r.why[kv.role] = kv.why; + // Every role the caller actually met must be either placed or explained: a role that is in neither is a hole in + // this table, and saying so is the point of the report. + for (const auto& c : roles.counts) { + if (r.slot_of.count(c.first)) r.placed.push_back(c.first); + else if (r.why.count(c.first)) r.no_slot.push_back(c.first); + else { r.no_slot.push_back(c.first); r.why[c.first] = "a role this table does not know: it needs a decision"; } + } + std::sort(r.placed.begin(), r.placed.end()); + std::sort(r.no_slot.begin(), r.no_slot.end()); + return r; +} + ContainerRoles container_roles(const std::vector& tensors) { struct Rule { const char* role; const char* needle; }; // Longest/most specific spellings first: `qkv_proj` before `q_proj`, `gate_up_proj` before `gate_proj`. diff --git a/npu/pack_import.h b/npu/pack_import.h index eaee101bc..b5f8633f4 100644 --- a/npu/pack_import.h +++ b/npu/pack_import.h @@ -386,6 +386,25 @@ struct ContainerRoles { }; ContainerRoles container_roles(const std::vector& tensors); +// engine#1788, slice 3c, the writer's destination side. A role says WHAT a tensor is; the engine reads weights by SLOT +// name (the string literals in npu/forward/src), so an import needs role -> slot. Only the roles whose destination does +// not depend on the LAYER TYPE are placed here. The rest are named in `no_slot` with a reason instead of guessed: +// a wrong slot does not fail loudly, it loads a tensor into a field the engine reads with another shape, and the +// symptom is a wrong number rather than an error. +struct RoleSlotReport { + std::map slot_of; // role -> engine slot; absent when this slice does not place it + std::vector placed; // placed roles, sorted + std::vector no_slot; // deliberately unplaced roles, sorted + std::map why; // role -> why it is not placed here +}; +RoleSlotReport container_role_slots(const ContainerRoles& roles); + +// engine#1788, slice 3c: the one role whose destination the LAYER TYPE decides, and the reason it decides it. The engine +// carries `linear_attn.qkv_proj.weight` and has NO `self_attn.qkv_proj` slot at all (grep the literals in npu/forward/src), +// so the fused qkv has a destination on a linear layer and nowhere else. Empty string = no destination; the caller must +// not treat that as "unchanged", which is why this returns a string and the report above returns the reasons. +std::string container_role_slot(const std::string& role, const std::string& layer_type); + // engine#1788, slice 3c: a container's roles need a SECOND key, and the second key is the layer type. // Measured: gemma4-e2b's `q_proj` is N=384 on all 28 `sliding_attention` layers and N=768 on all 7 `full_attention` ones; // bolt-e4b's is (640,5120) on all 35 sliding and (1280,5120) on all 7 full. A perfect partition, structural in origin diff --git a/tests/npu_pack_import_test.cpp b/tests/npu_pack_import_test.cpp index 182dbe612..823340e17 100644 --- a/tests/npu_pack_import_test.cpp +++ b/tests/npu_pack_import_test.cpp @@ -6232,6 +6232,71 @@ static void selfcheck_head_rule_arms() { std::printf(" head fit rule: 15 arms (two measured heads, scaled/rms/noisy/no-corr controls, fit_head at blocks 32 and 128, gemma2 by name)\n"); } +// engine#1788, slice 3c: the writer's destination side. The arm with teeth is the last one: a role the classifier can +// emit must be EITHER placed or explained, so adding a recogniser without deciding its destination fails here rather +// than in a pack that loads a tensor into the wrong slot. +static void test_container_role_slots() { + using namespace onebit::npu::pack_import; + { + ContainerRoles r; + r.counts["q_proj"] = 1; r.counts["gate_exps"] = 1; r.counts["input_norm"] = 1; + const RoleSlotReport s = container_role_slots(r); + check(s.slot_of.at("q_proj") == "self_attn.q_proj.weight", "slots: the attention query projection"); + check(s.slot_of.at("gate_exps") == "mlp.experts.gate_proj.weight", "slots: the routed gate is the expert gate"); + check(s.slot_of.at("input_norm") == "input_layernorm.weight", "slots: the pre-attention norm"); + check(s.no_slot.empty(), "slots: three placed roles leave nothing unexplained"); + } + { + // the layer type decides exactly one destination, and it decides it the way the ENGINE does: the only fused-qkv + // slot npu/forward/src reads is the linear-attention one, so a full-attention qkv has no destination at all. + check(container_role_slot("qkv_proj", "linear_attention") == "linear_attn.qkv_proj.weight", + "slots: the fused qkv on a linear layer"); + check(container_role_slot("qkv_proj", "full_attention").empty(), + "slots: a full-attention qkv has no engine slot, so it is not placed"); + check(container_role_slot("q_proj", "full_attention") == "self_attn.q_proj.weight", + "slots: the layer type does not move a role it does not decide"); + check(container_role_slot("qkv_proj", "sliding_attention").empty(), + "slots: sliding attention is not linear, so the fused qkv stays unplaced"); + // the arm for the substring defect: a value that CONTAINS "linear" and is not linear attention must not match + check(container_role_slot("qkv_proj", "nonlinear_attention").empty(), + "slots: a layer type that merely contains 'linear' is not linear attention"); + check(container_role_slot("qkv_proj", "linear_sliding_attention").empty(), + "slots: neither is a compound name containing it"); + } + { + // no two roles may claim the same slot: a collision silently drops one of them + std::map seen; + for (const auto& kv : container_role_slots(ContainerRoles{}).slot_of) { + check(!seen.count(kv.second), "slots: no two roles share a destination (" + kv.second + ")"); + seen[kv.second] = kv.first; + } + } + { + // qkv_proj is the one role everybody gets wrong: its PREFIX is the layer type's, so this slice must refuse it + ContainerRoles r; r.counts["qkv_proj"] = 1; + const RoleSlotReport s = container_role_slots(r); + check(!s.slot_of.count("qkv_proj"), "slots: the fused qkv is not placed without the layer type"); + check(s.why.count("qkv_proj") && s.why.at("qkv_proj").size() > 20, "slots: and the refusal says why"); + } + { + // the classifier's whole vocabulary: every role either placed or explained, none unknown + static const char* kRoles[] = {"embed", "final_norm", "input_norm", "post_norm", "lm_head", "q_proj", "k_proj", + "v_proj", "o_proj", "q_norm", "k_norm", "self_attn_gate_proj", "gate_proj", "up_proj", "down_proj", + "gate_up_proj", "qkv_proj", "moe_router", "gate_exps", "up_exps", "down_exps", "share_gate_exps", + "share_up_exps", "share_down_exps", "shared_expert_gate", "shortconv_in_proj"}; + ContainerRoles r; + for (const char* k : kRoles) r.counts[k] = 1; + const RoleSlotReport s = container_role_slots(r); + check(int64_t(s.placed.size() + s.no_slot.size()) == int64_t(sizeof(kRoles) / sizeof(kRoles[0])), + "slots: every role the classifier can emit is accounted for"); + for (const auto& kv : s.why) { + check(kv.second.find("does not know") == std::string::npos, + "slots: no role falls through the table (" + kv.first + ")"); + } + check(s.placed.size() >= 17, "slots: at least the layer-type-independent ones are placed"); + } +} + int main(int argc, char** argv) { selfcheck_head_rule_arms(); // engine#1907: every crafted 1.9 import writes a canonical copy into the scratch cache. Without this the suite left 0-byte @@ -6294,6 +6359,7 @@ int main(int argc, char** argv) { test_container_roles(); test_container_roles_gemma_local(); test_container_roles_moe_and_singular_layer(); + test_container_role_slots(); test_container_role_layer_types(); test_container_plan(golden_dir.empty() ? "." : golden_dir); test_hybrid_pack_import(golden_dir.empty() ? "." : golden_dir);