Skip to content

npu: layer_types is matched exactly, so a kind the forward cannot run is refused instead of run as GDN (#1156) - #1172

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-layer-types-exact
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-layer-types-exact

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Root cause

npu/forward/src/q4nx_semantics.c decides whether a layer_types entry names a layer kind this forward can run.
For full_attention it compared exactly, but for the other two it asked a substring:

else if (strstr(nm, "sliding")) kind = Q4NX_LAYER_SLIDING;
else if (strstr(nm, "linear"))  kind = Q4NX_LAYER_LINEAR;   // the GDN recurrence

So the #1124 guarantee — refuse a layer kind this forward does not implement, by name — was exactly as wide as the
substring. Measured with a probe linking this file and a two-entry layer_types (full_attention, <spelling>):

spelling before after
linear_attention (HF's GDN spelling) LINEAR (correct) LINEAR (correct)
sliding_attention SLIDING (correct) SLIDING (correct)
full_attention FULL (correct) FULL (correct)
mamba2_linear LINEAR, unknown='' OTHER, unknown='mamba2_linear'
chunked_linear_attention LINEAR, unknown='' OTHER, unknown='chunked_linear_attention'
nonlinear_attention LINEAR, unknown='' OTHER, unknown='nonlinear_attention'
sliding_window_attention SLIDING OTHER, unknown='sliding_window_attention'
chunked_attention / conv_attention OTHER (refused) OTHER (refused)

Before, the four bold rows were served through the GDN recurrence or the sliding path and the container was accepted —
fluent output, wrong at every position, nothing in the log. That is the failure mode #1124 removed, re-opened by the
substring.

Fix

Exact match on the three spellings that name attention math this forward actually has — HF's own layer_types values
full_attention, sliding_attention, linear_attention. Everything else falls through to Q4NX_LAYER_OTHER, which the
forward already refuses with the spelling quoted (q4nx_forward.cpp: "config.json layer_types names "…", a layer kind
this forward does not implement …"
). Seven lines, no new state, no change to the refusal path.

Verification

  • tests/q4nx_layer_kinds_test.c: new case 5 pins the substring family (all four refused and the first one quoted, hybrid
    count included) and keeps the known-kind rows, so the fix cannot pass by matching nothing. passed (0 failures), 20
    assertions; the binary is the same add_test(NAME q4nx_layer_kinds …) target CI already runs, and
    q4nx_semantics.c compiles -Wall -Wextra clean.
  • Tested the test: flipping layer_types[2] to expect Q4NX_LAYER_LINEAR makes it print FAILED (1 failure), so the new
    rows really execute.
  • Nothing that runs today is refused, measured rather than argued: all 19 model dirs under ~/models/npu/* parsed
    through the patched q4nx_semantics_from_config — every one resolves to OTHER=0, refused_as='' (bonsai-2 27B 16 full +
    48 linear, d3-lite 6 + 18, the Qwen3/ZR1/MoE dirs without layer_types). The only refusal on the box is
    Qwen3-0.6B-layerkind-probe (conv_attention), which was already refused before this change.
  • tools/copyright.py --check rc 0 (both files already carry the notice).

Deliberately not changed

tools/npu_plan.py:85-87 accepts three extra aliases (linear, gdn, attention) that this parser never saw. Aligning them
would either widen the forward to guess what attention means, or start refusing configs whose plan file generates today —
neither is this issue. If the two should share one list, that is a one-line change in whichever file is declared the source,
and it is worth an owner call rather than a quiet guess here.

closes #1156

… is refused instead of run as GDN (#1156)

The #1124 guard refuses an unknown layer kind by name, but it decided "known" with strstr, so its width
was the substring: chunked_linear_attention, mamba2_linear and nonlinear_attention all contain "linear"
and were mapped to Q4NX_LAYER_LINEAR, i.e. the forward would serve attention math it does not have through
the GDN recurrence - fluent, silently wrong at every position, which is the failure mode #1124 exists to
remove. sliding_window_attention hit the same branch through "sliding".

Exact match on the three spellings that name attention math this forward has (HF's own layer_types
values: full_attention, sliding_attention, linear_attention); everything else stays Q4NX_LAYER_OTHER and
is refused by name. tests/q4nx_layer_kinds_test.c pins both sides: the three known kinds still map as
before, and a spelling that merely contains a known kind is refused and quoted.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY

The fix is exactly what #1156 asked for, and I checked the two things that decide whether it is safe: the spellings it now refuses are not in any container here, and the ones it keeps are the ones this box actually uses.

My own probe from the issue, rebuilt against this head (same q4nx_semantics.c, two-entry layer_types):

mamba2_linear              -> OTHER  unknown='mamba2_linear'
chunked_linear_attention   -> OTHER  unknown='chunked_linear_attention'
nonlinear_attention        -> OTHER  unknown='nonlinear_attention'
conv_attention             -> OTHER  unknown='conv_attention'
linear_attention           -> LINEAR(GDN)  unknown=''
sliding_attention          -> SLIDING      unknown=''
full_attention             -> FULL         unknown=''

so the four that used to be served through the GDN recurrence (or as sliding) are refused by name, which is the shape #1124 established and #1087 before it. The PR's own test agrees: q4nx_layer_kinds_test -> passed (0 failures), including a substring match is quoted by name, so the container is refused instead of served.

No container on this box regresses. Every layer_types spelling across the 19 Q4NX containers under ~/models/npu/:

known   full_attention     11
known   linear_attention   10
known   sliding_attention   3   (counting the mixed containers)
REFUSED conv_attention      1   <- npu/Qwen3-0.6B-layerkind-probe, the deliberate fixture

The one spelling that changes behaviour and is plausible in the wild is sliding_window_attention, which was SLIDING and is now OTHER. Nothing here uses it, our converter writes only full_attention/linear_attention (scripts/repack_gguf.py:169), and the failure mode is a named refusal at load rather than silent wrong maths - so the cost is discoverable in one message, which is the trade this PR is making deliberately.

One inconsistency this leaves behind, worth one line somewhere (not a blocker, and not introduced here): tools/npu_plan.py:85-87 accepts a wider set than the forward -

if t in ("linear_attention", "linear", "gdn"):   out.append("gdn")
elif t in ("full_attention", "sliding_attention", "attention"): out.append("attn")

and the forward refuses linear, gdn and attention (probe above). So a container declaring "linear" gets a plan.json claiming the gdn role and is then refused at load. gdn/attention were already in that state before this PR ("gdn" contains no linear/sliding, so it was OTHER before too); linear joins them now that the match is exact. The generator and the loader having two accepted-spelling lists is the kind of drift this PR is about, and one of them should be the other's authority.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 64fa749)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1124 - Partially compliant

Compliant requirements:

  • The first unrecognized spelling is kept in Q4nxSemantics::unknown_layer_type
  • The forward now refuses containers by name instead of serving unrecognized layer kinds as full attention
  • A new test file tests/q4nx_layer_kinds_test.c is added with 12 assertions

Non-compliant requirements:

  • None

Requires further human verification:

  • None

1156 - Partially compliant

Compliant requirements:

  • Substring matching is replaced with exact matching for known layer types
  • Spellings that merely contain known kinds are now refused instead of being misclassified
  • The test verifies the correct behavior for such cases

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Exact Matching for Layer Types

The change replaces substring matching with exact matching for known layer types, which prevents false positives where spellings like "mamba2_linear" or "nonlinear_attention" would incorrectly be mapped to the GDN recurrence. This ensures that only exact matches for "full_attention", "sliding_attention", and "linear_attention" are recognized, and all others are correctly classified as Q4NX_LAYER_OTHER and refused by the forward pass.

/* EXACT match, not a substring, on the spellings that name attention math this forward has.  A
 * substring test made the guard as wide as the substring: "chunked_linear_attention",
 * "mamba2_linear" and "nonlinear_attention" all contain "linear" and were mapped to the GDN
 * recurrence and served, fluently and silently, instead of refused (engine#1156).  Anything else
 * stays OTHER, which is what the forward refuses by name below. */
if (strcmp(nm, "full_attention") == 0) kind = Q4NX_LAYER_FULL;
else if (strcmp(nm, "sliding_attention") == 0) kind = Q4NX_LAYER_SLIDING;
else if (strcmp(nm, "linear_attention") == 0) kind = Q4NX_LAYER_LINEAR;
Test Coverage for Exact Matching

The new test cases verify that spellings containing known layer type names (e.g., "chunked_linear_attention") are correctly refused instead of being misclassified. This ensures that the exact matching logic works as intended and prevents the silent wrong answer scenario described in issue #1124.

/* 5. A spelling that merely CONTAINS a known kind is not that kind (engine#1156).  Every one of these used to
 * map to Q4NX_LAYER_LINEAR through strstr("linear"), i.e. the forward would have run the GDN recurrence over
 * layers whose config names attention math it does not have - the silent-wrong-answer shape #1124 exists to
 * remove, re-opened by the substring.  The whole list is refused, and the first spelling is the one quoted. */
write_config(dir,
             "{\"num_hidden_layers\": 5, \"layer_types\": [\"full_attention\", \"chunked_linear_attention\","
             " \"mamba2_linear\", \"nonlinear_attention\", \"sliding_window_attention\"]}",
             cfg, sizeof cfg);
q4nx_semantics_from_config(cfg, dir, &s);
check(s.n_layer_types == 5, "five entries parsed in the substring case");
check(s.layer_types[0] == Q4NX_LAYER_FULL, "full_attention is still full_attention");
check(s.layer_types[1] == Q4NX_LAYER_OTHER, "chunked_linear_attention -> OTHER, not the GDN path");
check(s.layer_types[2] == Q4NX_LAYER_OTHER, "mamba2_linear -> OTHER, not the GDN path");
check(s.layer_types[3] == Q4NX_LAYER_OTHER, "nonlinear_attention -> OTHER, not the GDN path");
check(s.layer_types[4] == Q4NX_LAYER_OTHER,
      "sliding_window_attention -> OTHER (the HF spelling is sliding_attention)");
check(strcmp(s.unknown_layer_type, "chunked_linear_attention") == 0,
      "a substring match is quoted by name, so the container is refused instead of served");
check(s.hybrid_layers == 4, "all four refused kinds count as hybrid, as any non-full layer does");

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…ot evidence against

Found by sweeping the box rather than by review: both real repack-cache directories
(~/.cache/1bit/q4nx/Qwen3-0.6B-Q4_K_M and ZR1-1.5B-Q4_K_M) were written by a repack older than the
field, so their stamp has no `tile_order` key.  The previous commit read `fields.get("tile_order", "")`
and fell through to the fatal branch, so `npu_plan.py` refused two directories that work today, with the
nonsense message `tile_order='' -- the repack measured these tiles as NOT the engine-1 order`.

A stamp without the key records provenance and says nothing about the tiles, so it takes the same path as
no stamp at all: default engine-1, silent.  The fatal branch is now only reachable from a stamp that
actually names a different order.

Re-measured: both cache dirs rc=0, tile_order=engine-1, 0 bytes of stderr; 19 directories with
config.json+model.q4nx on this box all plan cleanly except Qwen3-0.6B-layerkind-probe, whose
`conv_attention` refusal is identical on origin/main (pi-npufeat's #1172 fixture, not this change).
Test gains the verbatim pre-field stamp as a case (16 checks).
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…ot evidence against

Found by sweeping the box rather than by review: both real repack-cache directories
(~/.cache/1bit/q4nx/Qwen3-0.6B-Q4_K_M and ZR1-1.5B-Q4_K_M) were written by a repack older than the
field, so their stamp has no `tile_order` key.  The previous commit read `fields.get("tile_order", "")`
and fell through to the fatal branch, so `npu_plan.py` refused two directories that work today, with the
nonsense message `tile_order='' -- the repack measured these tiles as NOT the engine-1 order`.

A stamp without the key records provenance and says nothing about the tiles, so it takes the same path as
no stamp at all: default engine-1, silent.  The fatal branch is now only reachable from a stamp that
actually names a different order.

Re-measured: both cache dirs rc=0, tile_order=engine-1, 0 bytes of stderr; 19 directories with
config.json+model.q4nx on this box all plan cleanly except Qwen3-0.6B-layerkind-probe, whose
`conv_attention` refusal is identical on origin/main (pi-npufeat's #1172 fixture, not this change).
Test gains the verbatim pre-field stamp as a case (16 checks).
@bong-water-water-bong
bong-water-water-bong merged commit d013f86 into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npu-layer-types-exact branch November 5, 2026 23:41
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 64fa749

bong-water-water-bong pushed a commit that referenced this pull request Nov 6, 2026
…ot evidence against

Found by sweeping the box rather than by review: both real repack-cache directories
(~/.cache/1bit/q4nx/Qwen3-0.6B-Q4_K_M and ZR1-1.5B-Q4_K_M) were written by a repack older than the
field, so their stamp has no `tile_order` key.  The previous commit read `fields.get("tile_order", "")`
and fell through to the fatal branch, so `npu_plan.py` refused two directories that work today, with the
nonsense message `tile_order='' -- the repack measured these tiles as NOT the engine-1 order`.

A stamp without the key records provenance and says nothing about the tiles, so it takes the same path as
no stamp at all: default engine-1, silent.  The fatal branch is now only reachable from a stamp that
actually names a different order.

Re-measured: both cache dirs rc=0, tile_order=engine-1, 0 bytes of stderr; 19 directories with
config.json+model.q4nx on this box all plan cleanly except Qwen3-0.6B-layerkind-probe, whose
`conv_attention` refusal is identical on origin/main (pi-npufeat's #1172 fixture, not this change).
Test gains the verbatim pre-field stamp as a case (16 checks).
bong-water-water-bong pushed a commit that referenced this pull request Nov 6, 2026
…ot evidence against

Found by sweeping the box rather than by review: both real repack-cache directories
(~/.cache/1bit/q4nx/Qwen3-0.6B-Q4_K_M and ZR1-1.5B-Q4_K_M) were written by a repack older than the
field, so their stamp has no `tile_order` key.  The previous commit read `fields.get("tile_order", "")`
and fell through to the fatal branch, so `npu_plan.py` refused two directories that work today, with the
nonsense message `tile_order='' -- the repack measured these tiles as NOT the engine-1 order`.

A stamp without the key records provenance and says nothing about the tiles, so it takes the same path as
no stamp at all: default engine-1, silent.  The fatal branch is now only reachable from a stamp that
actually names a different order.

Re-measured: both cache dirs rc=0, tile_order=engine-1, 0 bytes of stderr; 19 directories with
config.json+model.q4nx on this box all plan cleanly except Qwen3-0.6B-layerkind-probe, whose
`conv_attention` refusal is identical on origin/main (pi-npufeat's #1172 fixture, not this change).
Test gains the verbatim pre-field stamp as a case (16 checks).
bong-water-water-bong added a commit that referenced this pull request Nov 6, 2026
…#1110) (#1187)

* npu_plan: the plan's tile_order is a measurement, not a constant (engine#1110)

`npu/plan/plan.h` refuses every `container.tile_order` but "engine-1", and that is the only thing
between a container whose tiles are another order and a clean HTTP 200 with noise in it.  But
`tools/npu_plan.py` wrote "engine-1" for whatever directory it was pointed at, unconditionally, so
the guard was green on exactly the containers it exists for.

Measured (Qwen3-0.6B-Q4_K_M, converter FLM_Q4NX_Converter d1d5232, no device): the repack refuses
the directory -- "matches NO layout: closest is flm with worst max|err| 2.474e+00" -- leaves its
tiles on disk, and `npu_plan.py` on that same directory then wrote
`container.tile_order = "engine-1"`, which the loader accepts.  One command undid the refusal.

The repack holds both the source GGUF and the tiles, so it is the only step that can measure.  Now:
- it writes the measured verdict into `repack-stamp.txt` on its REFUSING path too (`tile_order=<id>`
  via `tile_order_id()`, plus the verdict line and `refused=tile order`), so the measurement
  survives the refusal;
- `npu_plan.py` reads that stamp (`tile_order_claim()`) before it derives anything: `engine-1`
  claims engine-1 and says it was measured, `unverified` still claims engine-1 (the loader accepts
  nothing else) but says out loud that nothing checked the tiles, and any other id is fatal -- no
  plan is written;
- a directory with no stamp keeps the previous silent default, because those predate the field
  (`--check` on the box's real plans: ZR1-1.5B-npu-q4km-q8 and Qwen3-0.6B still "matches the model dir").

Test `tests/npu_plan_tile_order_test.py` (ctest `npu_plan_tile_order`, stdlib only) pins the mapping and
the joint between the two files -- the ids the repack stamps are the ids the planner refuses.

refs #1110 (the remaining jaw of it); no engine change, no device.

* npu_plan: a stamp written before tile_order existed is no evidence, not evidence against

Found by sweeping the box rather than by review: both real repack-cache directories
(~/.cache/1bit/q4nx/Qwen3-0.6B-Q4_K_M and ZR1-1.5B-Q4_K_M) were written by a repack older than the
field, so their stamp has no `tile_order` key.  The previous commit read `fields.get("tile_order", "")`
and fell through to the fatal branch, so `npu_plan.py` refused two directories that work today, with the
nonsense message `tile_order='' -- the repack measured these tiles as NOT the engine-1 order`.

A stamp without the key records provenance and says nothing about the tiles, so it takes the same path as
no stamp at all: default engine-1, silent.  The fatal branch is now only reachable from a stamp that
actually names a different order.

Re-measured: both cache dirs rc=0, tile_order=engine-1, 0 bytes of stderr; 19 directories with
config.json+model.q4nx on this box all plan cleanly except Qwen3-0.6B-layerkind-probe, whose
`conv_attention` refusal is identical on origin/main (pi-npufeat's #1172 fixture, not this change).
Test gains the verbatim pre-field stamp as a case (16 checks).

---------

Co-authored-by: pi-onebp <onebp@strixhalo.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npu: the layer_types refusal is a substring test, so a linear-attention variant it cannot run is accepted as GDN

1 participant