Skip to content
Closed
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
229 changes: 228 additions & 1 deletion npu/pack_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3306,6 +3306,39 @@ std::string Plan::manifest() const {
c["tensors"] = container_tensors;
c["payload_bytes"] = container_payload_bytes;
c["dtypes"] = container_dtypes;
// engine#1786 item F1 slice 3b fills the two fields on the plan; this is what makes them readable from `info`
// without a caller linking the plan struct. `named` is derived so it cannot disagree with the list.
const int64_t unnamed = int64_t(container_roles_unassigned.size());
c["roles"] = {{"named", container_tensors - unnamed},
{"unassigned", unnamed},
{"counts", container_role_counts}};
if (!container_roles_unassigned.empty()) c["roles"]["unassigned_names"] = container_roles_unassigned;
c["payload_rows"] = container_payload_rows;
c["role_shapes"] = container_role_shapes;
{
json wp;
json items = json::object();
for (const auto& it : container_write_plan.items)
items[it.field] = {{"source", it.source}, {"of", it.count}, {"shape", it.shape}};
wp["items"] = items;
wp["count"] = container_write_plan.items.size();
if (!container_write_plan.ambiguous.empty()) wp["ambiguous"] = container_write_plan.ambiguous;
if (!container_write_plan.no_source.empty()) wp["no_source"] = container_write_plan.no_source;
c["write_plan"] = wp;
}
if (!container_payload_anomalies.empty()) {
json a = json::array();
for (const auto& x : container_payload_anomalies)
a.push_back({{"name", x.name}, {"row_bytes", x.row_bytes}});
c["payload_anomalies"] = a;
}
if (container_vision_tensors >= 0)
c["vision"] = {{"tensors", container_vision_tensors},
{"payload_bytes", container_vision_payload_bytes},
{"roles_named", container_vision_roles_named},
{"role_counts", container_vision_role_counts},
{"note", "a second container (vision_weight.q4nx); it has its own reader "
"(npu/vision_pack.cpp), so this row reports what is there rather than planning it"}};
c["note"] = container_note;
j["container"] = c;
}
Expand Down Expand Up @@ -3409,6 +3442,120 @@ std::string header_witness_refusal(int vote_hdrs, bool declared_qk) {
// engine#1786 (item F1), slice 3a: roles from names. The rule is ordered -- the first match wins -- so `q_proj.bias`
// classifies as a bias of q_proj rather than as an unknown tensor, and a name that matches nothing lands in `unassigned`
// with its own name rather than being guessed from its shape.
// engine#1788 / slice 3c step 2: the role -> forward-field table, and the refusal that makes it a check.
const char* container_role_field(const std::string& role) {
static const std::pair<const char*, const char*> kMap[] = {
{"embed", "embed_table"}, {"lm_head", "lm_head_weight"},
{"input_norm", "input_layernorm_weight"}, {"post_norm", "post_attention_layernorm_weight"},
{"q_norm", "q_norm_weight"}, {"k_norm", "k_norm_weight"},
{"q_proj", "q_proj_weight"}, {"k_proj", "k_proj_weight"},
{"v_proj", "v_proj_weight"}, {"o_proj", "o_proj_weight"},
{"gate_proj", "gate_proj_weight"}, {"up_proj", "up_proj_weight"},
{"down_proj", "down_proj_weight"}, {"self_attn_gate_proj", "self_attn_gate_proj_weight"},
{"up_exps", "up_exps_weight"}, {"gate_exps", "gate_exps_weight"},
{"down_exps", "down_exps_weight"}, {"share_up_exps", "share_up_exps_weight"},
{"share_gate_exps", "share_gate_exps_weight"}, {"share_down_exps", "share_down_exps_weight"},
{"moe_router", "moe_router_weight"}, {"shared_expert_gate", "shared_expert_gate_weight"},
{"shortconv_in_proj", "shortconv_in_proj_weight"}, {"shortconv_conv", "shortconv_conv_weight"},
{"shortconv_out_proj", "shortconv_out_proj_weight"},
};
for (const auto& kv : kMap)
if (role == kv.first) return kv.second;
return "";
}

std::map<int64_t, int64_t> container_payload_rows(const std::vector<Q4nxTensor>& tensors) {
std::map<int64_t, int64_t> out;
for (const auto& t : tensors)
if (t.dtype == "I8" && t.shape.size() == 3) out[t.shape[2]]++;
return out;
}

ContainerWritePlan container_write_plan(const std::vector<Q4nxTensor>& tensors) {
ContainerWritePlan out;
const ContainerRoles roles = container_roles(tensors);
// role -> the tensors that carry it
std::map<std::string, std::vector<const Q4nxTensor*>> by_role;
for (const auto& t : tensors) {
const auto it = roles.role_of.find(t.name);
if (it != roles.role_of.end()) by_role[it->second].push_back(&t);
}
const auto shape_text = [](const Q4nxTensor& t) {
std::string s = "[";
for (size_t i = 0; i < t.shape.size(); ++i) s += (i ? "," : "") + std::to_string(t.shape[i]);
return s + "] " + t.dtype;
};
// every field the table knows, in table order: does a role that names it have a source?
static const char* kRoles[] = {
"embed", "lm_head", "input_norm", "post_norm", "q_norm", "k_norm", "q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj", "self_attn_gate_proj", "up_exps", "gate_exps", "down_exps",
"share_up_exps", "share_gate_exps", "share_down_exps", "moe_router", "shared_expert_gate",
"shortconv_in_proj", "shortconv_conv", "shortconv_out_proj"};
for (const char* role : kRoles) {
const auto it = by_role.find(role);
if (it == by_role.end()) {
out.no_source.push_back(std::string(container_role_field(role)) + " (role `" + role + "` absent)");
continue;
}
// distinct shapes for this role
std::map<std::string, const Q4nxTensor*> first;
for (const Q4nxTensor* t : it->second) first.emplace(shape_text(*t), t);
if (first.size() > 1) {
out.ambiguous.push_back(std::string(container_role_field(role)) + ": role `" + role + "` appears in " +
std::to_string(first.size()) + " shapes");
continue;
}
out.items.push_back({container_role_field(role), first.begin()->second->name, first.begin()->first,
int64_t(it->second.size())});
}
return out;
}

std::map<std::string, std::map<std::string, int64_t>> container_role_shapes(const std::vector<Q4nxTensor>& tensors) {
std::map<std::string, std::map<std::string, int64_t>> out;
const ContainerRoles roles = container_roles(tensors);
std::map<std::string, std::map<std::string, int64_t>> by_name;
for (const auto& t : tensors) {
std::string s = "[";
for (size_t i = 0; i < t.shape.size(); ++i) s += (i ? "," : "") + std::to_string(t.shape[i]);
s += "]";
by_name[t.name][s + " " + t.dtype]++;
}
for (const auto& kv : roles.role_of) {
const auto it = by_name.find(kv.first);
if (it == by_name.end()) continue;
for (const auto& shape : it->second) out[kv.second][shape.first] += shape.second;
}
return out;
}

std::vector<ContainerPayloadAnomaly> container_payload_anomalies(const std::vector<Q4nxTensor>& tensors) {
std::vector<ContainerPayloadAnomaly> out;
for (const auto& t : tensors) {
if (t.dtype != "I8" || t.shape.size() != 3) continue;
const int64_t row_bytes = t.shape[2];
if (row_bytes < 640 || (row_bytes - 640) % 2 != 0) out.push_back({t.name, row_bytes, -1});
}
return out;
}

std::vector<std::string> container_role_field_conflicts(const std::map<std::string, int>& counts) {
std::map<std::string, std::vector<std::string>> by_field;
for (const auto& kv : counts) {
const char* f = container_role_field(kv.first);
if (f == nullptr || *f == '\0') continue;
by_field[f].push_back(kv.first);
}
std::vector<std::string> out;
for (const auto& kv : by_field)
if (kv.second.size() > 1) {
std::string s = std::string(kv.first) + " <- ";
for (size_t i = 0; i < kv.second.size(); ++i) s += (i ? ", " : "") + kv.second[i];
out.push_back(s);
}
return out;
}

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 All @@ -3429,9 +3576,28 @@ ContainerRoles container_roles(const std::vector<Q4nxTensor>& tensors) {
{"k_proj", "k_proj"}, {"k_proj", "attn_k"}, {"k_proj", "wk."},
{"v_proj", "v_proj"}, {"v_proj", "attn_v"}, {"v_proj", "wv."},
{"o_proj", "o_proj"}, {"o_proj", "attn_output"}, {"o_proj", "wo."},
// engine#1788: an ATTENTION output gate is not the FFN gate. MiniCPM-V 4.7 carries `attn_output_gate: true`,
// so every one of its 30 `self_attn.gate_proj.weight` tensors was filed under `gate_proj` -- the dense-MLP role
// -- and a consumer would have wired an attention gate into the FFN path. The forward already keeps them apart
// (model.h: `gate_proj_weight` vs `self_attn_gate_proj_weight`), so this uses its name and must precede the
// `gate_proj` needle.
{"self_attn_gate_proj", "self_attn.gate_proj"},
{"gate_proj", "gate_proj"}, {"gate_proj", "ffn_gate"},
{"up_proj", "up_proj"}, {"up_proj", "ffn_up"},
{"down_proj", "down_proj"}, {"down_proj", "ffn_down"},
// engine#1788 acceptance (F1 slice 3a gap): the MoE families MiniCPM-V 4.7 names, mapped to the roles the NPU
// forward ALREADY reads -- npu/forward/include/model.h carries up_exps_weight / gate_exps_weight /
// down_exps_weight / share_{up,gate,down}_exps_weight / moe_router_weight / shared_expert_gate_weight with
// those exact container names in their comments. So this is translation to an existing vocabulary, not a new
// one. The `share_*` spellings come first because `share_gate_exps_proj` contains `gate_exps_proj`.
{"share_gate_exps", "share_gate_exps_proj"},
{"share_up_exps", "share_up_exps_proj"},
{"share_down_exps", "share_down_exps_proj"},
{"gate_exps", "gate_exps_proj"},
{"up_exps", "up_exps_proj"},
{"down_exps", "down_exps_proj"},
{"moe_router", "moe_router"},
{"shared_expert_gate","shared_expert_gate"},
{"input_norm", "input_layernorm"},{"input_norm", "attn_norm"}, {"input_norm", "attention_norm"},
{"post_norm", "post_attention_layernorm"}, {"post_norm", "ffn_norm"},
{"final_norm", "final_layernorm"},
Expand All @@ -3441,6 +3607,24 @@ ContainerRoles container_roles(const std::vector<Q4nxTensor>& tensors) {
// and `model.mtp.*` is a draft head rather than a model.
static const char* kSsm[] = {"linear_attn.", "ssm_alpha_proj", "ssm_beta_proj", "ssm_conv1d", "ssm_dt",
"ssm_norm", "ssm_out_proj"};
// engine#1788: gemma3/gemma4/bolt carry five tensor families this engine's forward does NOT read -- sandwich norms
// (`pre_/post_feedforward_layernorm`), an input gate, a per-layer projection (and its `_prefill` sibling) and a layer
// output scale. Nothing here has a field in npu/forward/include/model.h, so they are RECOGNISED rather than mapped:
// `unassigned` has to keep meaning "nothing knows this", and these are known -- measured on gemma4-e2b (319 of 709)
// and bolt-e4b (382 of 849), which were the two packs the classifier could not finish.
// `post_layernorm` and `per_layer_model_proj` are here for a measured reason, not a guess: gemma4-e2b carries
// BOTH `post_attention_layernorm` (35, classified `post_norm`) and `post_layernorm` (35), so the second cannot be
// the forward's post-attention norm -- they are different tensors in the same model. gemma3-270m has no
// `post_layernorm` at all, which is why only gemma4/bolt were left with it.
static const char* kGemmaLocal[] = {"pre_feedforward_layernorm", "post_feedforward_layernorm", "inp_gate",
"layer_output_scale", "per_layer_projection", "post_layernorm",
"per_layer_model_proj"};
// engine#1788: the last three on gemma4/bolt are recognised for a different reason than the gemma ones, so they get
// their own role. `rope_freqs.weight` is a file the forward only FLAGS (`model_config.h`: has_rope_freqs_file), and
// `per_layer_token_embd` (with its `.scale` sibling) is the per-layer token embedding that our ONNX path reads as
// `embed_tokens_per_layer` (engine#1725) but the container path has no field for. Recognised, not mapped: neither
// has a field to fill, and calling them "unassigned" would say nothing knows them when the opposite is true.
static const char* kContainerExtras[] = {"rope_freqs", "per_layer_token_embd"};
static const char* kMtp[] = {"model.mtp."};
ContainerRoles out;
for (const auto& t : tensors) {
Expand All @@ -3458,12 +3642,24 @@ ContainerRoles container_roles(const std::vector<Q4nxTensor>& tensors) {
matched = true;
break;
}
if (!matched && n.find("norm.weight") != std::string::npos && n.find("layers.") == std::string::npos)
// "never inside a layer" has to be spelled positively enough to survive the container's own naming:
// MiniCPM-V 4.7 spells its blocks `model.layer.N.` (singular), so a guard against "layers." alone filed
// 30 per-layer linear_attn.ssm_norm tensors as final_norm -- ahead of the kSsm loop below (engine#1788).
if (!matched && n.find("norm.weight") != std::string::npos && n.find("layers.") == std::string::npos &&
n.find("layer.") == std::string::npos)
role = "final_norm"; // `model.norm.weight`: the top-level norm, never inside a layer
if (!matched && role.empty()) {
for (const char* s : kSsm)
if (n.find(s) != std::string::npos) { role = "ssm"; matched = true; break; }
}
if (!matched && role.empty()) {
for (const char* e : kContainerExtras)
if (n.find(e) != std::string::npos) { role = "container_extra"; matched = true; break; }
}
if (!matched && role.empty()) {
for (const char* g : kGemmaLocal)
if (n.find(g) != std::string::npos) { role = "gemma_local"; matched = true; break; }
}
if (!matched && role.empty()) {
for (const char* s : kMtp)
if (n.find(s) != std::string::npos) { role = "mtp"; matched = true; break; }
Expand Down Expand Up @@ -3600,6 +3796,26 @@ Plan plan(const std::string& pack_dir) {
geom += "; no source states ";
for (size_t i = 0; i < g.missing.size(); ++i) geom += (i ? ", " : "") + g.missing[i];
}
// engine#1788: the vision tower is a SECOND container with its own reader (npu/vision_pack.cpp), so this
// reports what is there for a human reading `info` rather than planning it. 347 tensors of a 1080-tensor
// pack were invisible here, which is how a pack looks importable when half of it has never been looked at.
{
std::error_code ecv;
const std::string vision_path = pack_dir + "/vision_weight.q4nx";
if (fs::exists(vision_path, ecv)) {
const Q4nxContainer v = read_q4nx_container(vision_path);
const ContainerRoles vr = container_roles(v.tensors);
p.container_vision_tensors = int64_t(v.tensors.size());
p.container_vision_payload_bytes = v.payload_bytes;
p.container_vision_roles_named = int64_t(vr.role_of.size());
for (const auto& kv : vr.counts) p.container_vision_role_counts[kv.first] = int64_t(kv.second);
}
}
// engine#1788, 3c: what the writer needs to know about the payload, as fact rather than judgement.
p.container_payload_rows = container_payload_rows(c.tensors);
p.container_role_shapes = container_role_shapes(c.tensors);
p.container_write_plan = container_write_plan(c.tensors);
p.container_payload_anomalies = container_payload_anomalies(c.tensors);
p.container_note = c.why.empty()
? "the pack is a safetensors container (model.q4nx): " + std::to_string(c.tensors.size()) +
" tensors, " + std::to_string(c.payload_bytes) + " B of payload, no graph; " + geom +
Expand Down Expand Up @@ -4672,6 +4888,17 @@ Report import_pack(const std::string& pack_dir, const std::string& out_dir, cons
// Census yes, writer no (see the note where graph_weights is decided): this genre's weights are 1-D external-data
// blocks, and until the repacker exists an import would write a container nothing can read. Refused here rather
// than in plan(), because `info` must keep reporting the genre it can now see.
// engine#1786 (item F1) / engine#1788: a container-genre pack has no `model.onnx` by design, so without a branch here
// the first byte fails with "cannot open <pack>/model.onnx" -- a missing-file message about a pack that has no graph
// at all. Refused by name, the same way the external-data genre below is, until the container-genre writer exists;
// `info` keeps reporting what the container holds, which is how the roles get to anyone building that writer.
if (p.container_tensors >= 0) {
throw std::runtime_error(
"this pack is a safetensors container (model.q4nx): " + std::to_string(p.container_tensors) +
" tensor(s), " + std::to_string(p.container_roles_unassigned.size()) +
" name(s) nothing knows; importing it needs the container-genre writer (engine#1786, item F1), which the "
"importer does not have yet. `1bit npu-pack info` reports what the container holds");
}
if (p.graph_packed_siblings > 0) {
// The genre has a writer now (engine#1503): decode -> convert -> split by role -> the same I8 tile chunks,
// with its own function because import_pack is built around the .fconst envelope.
Expand Down
Loading