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
70 changes: 70 additions & 0 deletions npu/pack_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3487,6 +3487,76 @@ RoleLayerReport container_role_layer_types(const std::vector<Q4nxTensor>& 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<Q4nxTensor>& 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`.
Expand Down
19 changes: 19 additions & 0 deletions npu/pack_import.h
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,25 @@ struct ContainerRoles {
};
ContainerRoles container_roles(const std::vector<Q4nxTensor>& 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<std::string, std::string> slot_of; // role -> engine slot; absent when this slice does not place it
std::vector<std::string> placed; // placed roles, sorted
std::vector<std::string> no_slot; // deliberately unplaced roles, sorted
std::map<std::string, std::string> 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
Expand Down
66 changes: 66 additions & 0 deletions tests/npu_pack_import_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, std::string> 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
Expand Down Expand Up @@ -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);
Expand Down
Loading