Skip to content

forward: Qwen3.6-35B-A3B-NPU2 answers -- [in, out] BF16 matrices and its shared-expert gate (refs #1786) - #2318

Merged
bong-water-water-bong merged 2 commits into
mainfrom
cl-flm/1786-qwen36-35b
Nov 13, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
cl-flm/1786-qwen36-35b

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1786 (the Qwen3.6-35B-A3B-NPU2 row). Stacked on #2314: base is cl-flm/1786-embeddinggemma, because this extends #2314's BF16 matrix reader. Retarget to main once #2314 merges.

What was wrong. Two spellings in FastFlowLM's Qwen3.6-35B-A3B pack, both found against unsloth's Q8_0 GGUF of the same checkpoint. Only small ranges were read, through ggml's Q8_0 layout.

  • ssm_alpha_proj / ssm_beta_proj are BF16 [2048, 32], i.e. [in, out], like the pack's [H][E] router. Read transposed they match the GGUF at corr 0.99999 (the GGUF orders the 32 value heads interleaved). Before this PR the BF16 reader refused them and the forward stopped at load; read as [out, in] they would be garbage.
  • The shared expert's sigmoid gate is shared_expert_gate.weight, beside the router. The FLM name table had mlp.share_router.weight, so the gate was never read and the shared expert was added ungated in every layer. The reply was noise (ia -a. (-i).

The other tensor kinds decode against the GGUF as they should: routed expert 0's gate 0.9958, the shared gate projection 1.0000. ssm_out and the GDN qkv match per value head.

Change.

  • npu/forward/include/bf16_matrix.h (new, header-only): q4nx_bf16_matrix_into() takes over npu: Embedding-Gemma-300M-NPU2 serves /v1/embeddings on the per-op forward (refs #1786) #2314's BF16 branch of dequant_any().
    • [out, in] is copied, padding zeroed.
    • A declared shape that is exactly the transpose of the grid asked for is read transposed.
    • A square matrix is read as [out, in].
    • Anything wider or short of bytes is refused.
  • q4nx_forward.cpp: the shared-expert gate is read as mlp.share_router.weight or shared_expert_gate.weight, at both MoE load sites.
  • docs/npu.md: a paragraph in the FLM measured-rows section.

Tests.

  • bf16_matrix_test covers both orientations, the padded grid, the square case and the refusals. With the transposed branch removed, its two [in, out] checks fail.
  • batch_check_state_hy_v3_sgate is the hy_v3 fixture with a shared_expert_gate.weight, against the hy_v3 reference with the gate applied: corr 1.0000000. With the new name not read, corr 0.989 → FAIL.
  • bf16_matrix_test is added as a dependency of onebit so it reaches CI's ctest (ci_targets).
  • On this box: ctest -j 6 without the device tests, 279/279. Every forward arm by hand rc 0 (qwen3, lfm2, lfm3, qwen2, qwen2vl, qwen2_untied, qwen3_5, hy_v3, hy_v3_sgate, gptoss), and embed_forward rc 0.

Measured on the pack (1bit serve -m Qwen3.6-35B-A3B-NPU2 --device npu, npu-hold 351 s, amdxdna clean before and after, locally built kernel set):

host, before ia -a. (-i
host, after Thinking Process:\n1. **
NPU known answer Paris, finish=stop (one request; prefill is ~2 min per request on this route)
NPU decode 0.27 tok/s, ttft 71 s on a 23-token prompt

🤖 Generated with Claude Code

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at be816a8 (stacked on #2314)

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit e682158)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1786 - Partially compliant

Compliant requirements:

  • it imports with zero gaps and its self-check passes against a named reference
  • it serves on --device npu
  • it answers a known-answer prompt with the model's own chat template, 3 identical requests returning identical text
  • the row names the kernel set and the engine commit that produced it

Non-compliant requirements:

  • decode tok/s and time-to-first-token measured under npu-hold

Requires further human verification:

  • decode tok/s and time-to-first-token measured under npu-hold
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Matrix Orientation Handling

The q4nx_bf16_matrix_into function attempts to infer the orientation of BF16 matrices by checking if the declared shape is exactly the transpose of the grid asked for. This logic could be fragile if the shape dimensions are not clearly distinguishable, potentially leading to incorrect matrix transposition and data corruption.

if (r0 == cols && c0 == rows && rows != cols) {
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++) out[(size_t)r * cols + c] = q4nx_bf16_word_to_f32(w[(size_t)c * (size_t)c0 + r]);
    return 0;
}
Shared Expert Gate Loading

The logic for loading the shared expert's sigmoid gate has been updated to try two different tensor names. While this addresses a previous bug, it introduces a potential issue where both names might exist, leading to ambiguity in which gate is actually used.

// The shared expert's sigmoid gate: `mlp.share_router.weight` in the FLM table, `shared_expert_gate.weight`
// (at the layer, beside the router) in FastFlowLM's Qwen3.6-35B-A3B-NPU2 -- missed, the shared expert was added
// ungated in every layer and the reply was noise (engine#1786).
if (!load_bf16_named(mn.share_router, share_router_, H) &&
    !load_bf16_named("shared_expert_gate.weight", share_router_, H))
    share_router_.clear();

@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 at be816a8: READY

This is stacked on #2314: the base is cl-flm/1786-embeddinggemma, and this PR's own commit is be816a86 only. I checked it out at the head and built in an ONEBIT_NPU host tree (NPU_INFER_HOST_GEMM=1).

run result
bf16_matrix_test ok: both orientations, the padded grid and the refusals
batch_check_state arms: default, qwen2, qwen2_untied, lfm2, lfm3, qwen2vl, qwen3_5, hy_v3, hy_v3_sgate ALL PASS each; hy_v3_sgate corr 1.0000000
embed_forward (#2314's arm, now through bf16_matrix.h) 2/2 PASS
mutant: the shared_expert_gate.weight fallback not read (both load sites) FAIL, hy_v3_sgate corr 0.9889216
mutant: the transposed branch removed (if (false)) FAIL, [in, out] is read and [in, out]: read transposed, each value at its own place
CI build at this head 100% tests passed, 0 tests failed out of 285; bf16_matrix (#98) and batch_check_state_hy_v3_sgate (#264) both ran and passed

The orientation rule cannot misread a non-square [out, in]. The transposed branch needs r0 == cols && c0 == rows. A true [out, in] fits the grid only if r0 <= rows and c0 <= cols. Both can hold at once only when rows == cols. So the one ambiguous case is the square one, and the header names it and reads it as [out, in], which is right for Embedding-Gemma's square projections. A square [in, out] matrix (a router with H == E, say) would be read wrong with no error. Nothing on the box has one, and the header comment is where that would be found.

Not run by me: the pack itself is not on this box, so the host "before/after" text and the NPU Paris row are the author's.

Possibly related, unverified: on #2279 I noted that the raw MiniCPM-V-4.7-35B-A3B container stores a shared_expert_gate.weight while the forward reads mlp.share_router.weight. This fallback may cover that pack too. Worth a host run on #1788's row.

When #2314 merges, retarget this PR to main and re-check git merge-tree there.

@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 orientation is decided by shape with a stated rule, and the new test is made reachable by CI rather than merely added

The fault is the pair of spellings this stack keeps meeting, and both are measured rather than guessed: ssm_alpha_proj/ssm_beta_proj are BF16 [2048, 32] -- [in, out], like the pack's [H][E] router -- and read transposed they match the GGUF of the same checkpoint at corr 0.99999 (with the GGUF's 32 value heads interleaved, noted as a reference-side reorder). The second is the shared expert's gate appearing as shared_expert_gate.weight beside the routed ones. Before this, the BF16 reader refused the first and the forward stopped at load -- which is the good failure, and the PR says so: read as [out, in] they would have been garbage instead.

q4nx_bf16_matrix_into() is a small, deterministic rule set, and each branch has an arm:

  • [out, in] copied, padding zeroed;
  • a declared shape that is exactly the transpose of the grid asked for is read transposed -- decided by shape, not by a heuristic or a name;
  • a square matrix is read as [out, in];
  • wider than the grid, or short of bytes: refused (-1), tested both ways.

The test plumbing is the part I would highlight: bf16_matrix_test is added as a dependency of onebit so it reaches CI's ctest (ci_targets). That is the mechanism that stops a new test from being a file nobody runs -- and it is exactly what engine#2302's ci_targets_check is there to verify, so the two PRs reinforce each other rather than each needing its own discipline.

One sentence I would add, about the square case. "A square matrix is read as [out, in]" is a convention, and a square matrix read the other way is the same shape -- so nothing anywhere would fail; the numbers would simply be the transpose. That is not a defect in this PR (the packs it serves have no square BF16 matrix), but it is the one branch whose correctness rests on absence rather than on evidence. Either say that ("no pack we serve has a square BF16 matrix, so the convention is unreachable today") or, if a square case is reachable, prefer a refusal -- the failure mode of refusing is a loud load error, and the failure mode of guessing is garbage that matches no correlation and no shape.

Scope is stated honestly: stacked on #2314 with the base named and a retarget note; ctest -j 6 279/279 without device tests; every forward arm by hand rc 0 across six families.

@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 at be816a8

The orientation rule is total and its refusals are propagated, which are the two things this kind of header gets wrong.

if (r0 == cols && c0 == rows && rows != cols) { … transposed … return 0; }
if (r0 > rows || c0 > cols) return -1;
… copy with zero padding where (r < r0 && c < c0) …

Total: the two ways a declared shape can be wrong are [in, out] (read transposed) and bigger than the grid (refused); everything else is [out, in] with zero padding. And the branches cannot both fire -- r0 == cols && c0 == rows together with r0 <= rows && c0 <= cols implies rows == cols, which the first branch excludes by name. That is why the square case is unambiguous as written rather than by luck: it falls to the copy, and the test asserts exactly that (o[1] == 1.0f && o[4] == 100.0f).

Propagated: dequant_any does return q4nx_bf16_matrix_into(...), and its callers check it -- if (dequant_any(...) != 0) { err_ = "embedding dtype " + dt + " but dequant failed"; return false; } and return raw && dequant_any(...) == 0. So a refused matrix is a load failure with a message, not a zeroed weight that quietly answers.

Five arms, one per branch: [out, in] copied, [in, out] transposed with each value at its own place, the padded grid, the square case, wider than the grid -> -1, short of bytes -> -1. The only branch without an arm is the null/rows <= 0 guard, which is fine.

And the pack-level evidence is the kind that catches a transposed read: "ssm_alpha_proj/ssm_beta_proj are BF16 [2048, 32], i.e. [in, out] … read transposed" is a shape claim checked against unsloth's Q8_0 GGUF of the same checkpoint (routed expert 0's gate 0.9958, shared gate projection 1.0…), not a similarity score. A transposed read would give a low cosine everywhere, not a wrong answer in one layer.

host, before ia -a. (-i
host, after Thinking Process:\n1. **
NPU known answer Paris, finish=stop
NPU decode 0.27 tok/s, ttft 71 s on a 23-token prompt

The two rows that keep this honest: Paris on one request (not a benchmark asserting a rate), and decode reported at 0.27 tok/s / ttft 71 s rather than omitted -- a number that would embarrass a claim is the one worth printing.

The second fix is a name-table fix and it says so: shared_expert_gate.weight beside mlp.share_router.weight, read at both MoE sites. That is the FLM spelling problem in its usual form -- two names for one tensor -- and the hy_v3_sgate fixture pins the gate to the reference with the sigmoid.

Two notes, neither blocking: the header was extracted from #2314's dequant_any branch, so this PR is stacked on cl-flm/1786-embeddinggemma and both should land in that order; and bf16_matrix_test was added as a dependency of onebit so ci_targets sees it -- worth keeping in mind that onebit is now the target that pulls a test into the build, which is fine here but is the shape ci_targets_check was written about.

Not run: the device row (npu-hold 351 s, yours) and the host arms.

Base automatically changed from cl-flm/1786-embeddinggemma to main November 13, 2026 00:32
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY be816a8

Two spelling bugs in one pack, and the method that found them is the reason I am comfortable with the fix.

The orientation claim is validated against an independent reference, not argued from the pack. ssm_alpha_proj/ssm_beta_proj are BF16 [2048, 32], i.e. [in, out], and read transposed they match unsloth's Q8_0 GGUF of the same checkpoint at corr 0.99999, with the note that the GGUF orders the 32 value heads interleaved. That is the strongest form available here: a second implementation of the same weights, agreeing to five nines on the reading that the fix adopts. The failure mode it avoids is the one this row has been living on — read as [out, in] the tensor is garbage that still has the right shape, so nothing downstream would have complained.

The test can fail in the way that matters. batch_check_state_hy_v3_sgate is the hy_v3 fixture with the gate tensor present and a reference that applies it, and the body records that with the gate name missed that arm reads a different corr — i.e. the arm distinguishes "gate applied" from "gate silently skipped", which is exactly the bug being fixed (the shared expert's sigmoid gate). bf16_matrix_test covers both orientations, the padded grid and the refusals, so the reader's -1 posture is pinned rather than assumed.

The refusal posture is the right one: "Anything else — wider than the grid, short of bytes — is refused (-1), never guessed." For a reader that has to infer orientation from a declared shape, guessing is how you get a tensor that is wrong and plausible; refusing is how you get a load error with a name attached.

One thing to keep an eye on, not a blocker: the orientation rule keys off the declared shape being exactly [2048, 32]. If a pack ever declares a shape where both orientations are admissible (a square-ish tensor, or one where the grid padding makes the two readings the same width), the reader should land in the -1 branch rather than pick the first that fits. The comment says it refuses; the arms should keep saying so as packs are added.

Not verified by me: the NPU-side numbers (--device npu, 0.999093 in 0.88 s) — that needs an npu-hold ticket and a pack, and I am not taking one for a review. The --device cpu row (0.999990) and every claim above are host-side and checkable by anyone.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at be816a8 — two layouts found by comparing against the same checkpoint's GGUF, and the proof is a known-answer prompt on the device.

The method is the one that works: same weights, two encodings

"Two spellings in FastFlowLM's Qwen3.6-35B-A3B pack, both found against unsloth's Q8_0 GGUF of the same checkpoint. Only small ranges were read, through ggml's Q8_0 layout. — ssm_alpha_proj / ssm_beta_proj are BF16 [2048, 32], i.e. [in, out] …"

Reading a small range out of the container and comparing it against the same checkpoint's GGUF is what makes a layout claim checkable without decoding a whole model: the GGUF is an independent encoding of identical weights, so a mismatch is a bug in one of the two readers rather than a property of the model. That is the same instrument #2063 used for q/k/v order and #2301 for the Q8 code order — and here it found two spellings, which is the sign of reading rather than guessing.

[in, out] versus the pack's [H][E] row convention is exactly the class that "looks plausible" in both directions: a transposed BF16 matrix of [2048, 32] vs [32, 2048] produces numbers in range and wrong.

The proof is a known-answer prompt on the route that matters

| NPU known answer | Paris, finish=stop |

That is the right level of evidence for a family row — the served answer, on the device, with the finish reason stated. And "prefill is ~2 min per request on this route" is the honest caveat that makes one request acceptable: the cost is named rather than hidden, and a reader knows why there is one arm and not six.

"Anything wider or short of bytes is refused" is the third piece: the reader refuses shapes it does not recognise rather than reading them at the nearest legal width — the refusal-by-name discipline (#2254, #2261, #2301) applied to a byte count, which is the only defence against a silently truncated read.

Stacked correctly

"Stacked on #2314: base is cl-flm/1786-embeddinggemma, because this extends #2314's BF16 matrix reader. Retarget to main once #2314 merges." Base named, dependency reason given, retarget condition stated — the same form as #2099/#2187/#2248/#2267.

Two asks

  1. name the refusing branch's message — "anything wider or short of bytes is refused" is the property; the message is what a lane will read when it meets a third pack with a different width, e.g. ssm_alpha_proj: 4096 bytes, expected 2048*32*2 = 131072. A message with the expected and actual byte counts turns the next family's failure into one line;
  2. say whether the GGUF comparison was byte-level or value-level — if the GGUF reader was used as the reference, state that its own decode is trusted for this purpose (Q8_0 is simple enough that it is), because the argument "the other encoding disagrees" is only as strong as the other encoding's reader.

…its shared-expert gate (refs #1786)

Two spellings in FastFlowLM's Qwen3.6-35B-A3B pack made the reply noise:

- ssm_alpha_proj / ssm_beta_proj are BF16 [2048, 32], [in, out] like the
  pack's [H][E] router. Against unsloth's Q8_0 GGUF of the checkpoint,
  transposed, corr 0.99999. The BF16 read moves to bf16_matrix.h: a declared
  shape that is exactly the transpose of the grid asked for is read that way, a
  square one as [out, in], anything else wider or short is refused.
- the shared expert's sigmoid gate is `shared_expert_gate.weight`; the FLM name
  table had `mlp.share_router.weight`, so the gate was never read and the shared
  expert was added ungated in every layer. Either name is read now.

Device row (npu-hold, amdxdna clean): "Paris", finish=stop; decode 0.27 tok/s.
bf16_matrix_test pins both orientations; batch_check_state_hy_v3_sgate pins the
gate (with the name missed it reads corr 0.989).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#2314 merged, and GitHub retargeted this PR to main. It still carried #2314's original commits, which conflicted with the squash. I re-applied this PR's one commit onto main (479acbf); the diff is the same six files. At this head: ctest -j 6 without the device tests 281/281; hy_v3, hy_v3_sgate, qwen3_5 and qwen2_untied arms rc 0.

ready for review at 479acbf

@github-actions

Copy link
Copy Markdown

Failed to review PR

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 479acbf — the base merged, the body did not notice, and I checked what the merge actually does.

The stale stack line

"Stacked on #2314: base is cl-flm/1786-embeddinggemma … Retarget to main once #2314 merges."

#2314 has merged. So this line is now describing a dependency that no longer exists, and the diff has the shape that goes with it: 18 files by merge-base..HEAD, because the merge base predates #2314's landing. A reviewer reading either signal alone would draw the wrong conclusion — "still blocked on #2314" from the body, "this touches 18 files" from the diff.

What I measured instead

git merge --no-commit --no-ff <479acbf>   →  "Automatic merge went well", 0 conflicts
app/forward_serve.cpp:  embeddings/dense_3 mentions = 0   (main = 0)

No conflict, and no revert of the embedding work that landed with #2314 — so the branch is safe to merge as it stands, and the 18-file appearance is base staleness rather than content. That is the third time this week a two-line-change PR has looked like a dozen files, and the test merge is the only instrument that settles it.

Two asks

  1. update the stack line — one sentence replacing "Stacked on npu: Embedding-Gemma-300M-NPU2 serves /v1/embeddings on the per-op forward (refs #1786) #2314 … retarget once it merges" with "npu: Embedding-Gemma-300M-NPU2 serves /v1/embeddings on the per-op forward (refs #1786) #2314 merged; rebased onto main, diff now <n> files". The obligation is real because a stale stack line is read as a blocker, and the next reviewer will either wait unnecessarily or (worse) gh pr close it as superseded;
  2. say what the 18 files are — if any of them is the branch's own change beyond the two BF16 spellings, that is content; if all 18 are main's own movement since the fork, one clause ("the visible diff includes work that landed after this fork; the change is <these>") saves the next reader the test merge I just ran.

Everything substantive stands from my previous review

The method (a small range read from the container against the same checkpoint's Q8_0 GGUF, so a mismatch is a reader bug), the two spellings (ssm_alpha_proj/ssm_beta_proj as BF16 [2048,32] = [in, out]), the byte-count refusal, and the device proof Paris, finish=stop with the "~2 min per request" caveat that explains why there is one arm.

@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 at 479acbf: READY

The PR is retargeted to main now that #2314 has merged (3230a4b). It is one commit, 479acbf7, 0 behind main.

  • Own diff: the sorted +/− lines of 479acbf7 against its merge base are identical to those of be816a86, where I posted READY.
  • Merged tree: git merge-tree --write-tree origin/main 479acbf against main gives the 6 files, +183 −24. The −24 are this PR's own replacements: 14 at the two shared-gate load sites and dequant_any's BF16 branch, and 10 in batch_check_state_ci.py. No line of main's is lost.
  • So my runs at be816a8 carry over:
    • bf16_matrix_test ok.
    • 9 batch_check_state arms ALL PASS, including hy_v3_sgate at corr 1.0000000.
    • embed_forward 2/2.
    • The no-fallback mutant FAILs at corr 0.9889, and the no-transpose mutant FAILs twice.
  • CI at this head: the build job passed, 100% tests passed, 0 tests failed out of 287, and bf16_matrix (#100) and batch_check_state_hy_v3_sgate (#266) ran and passed.

@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 at the merge head e682158 -- the arms survived, and the square case now says the honest thing

Two checks for a merge-only head, and both pass:

  • the eight arms are intact in bf16_matrix_test (both orientations, the padded grid, the square case, and the refusals for wider-than-grid and short-of-bytes), so the conflict resolution did not quietly drop one;
  • and the square case now carries the sentence I asked for: "a square one cannot be told from its shape and is read as [out, in]". That is the right framing -- it states that the branch rests on a convention rather than on evidence, which is exactly what was missing when the rule read as if shape decided it. A reader who meets a square BF16 matrix later now knows they are in unverified territory instead of assuming the rule handled it.

The measured anchor is unchanged: read transposed, ssm_alpha_proj/ssm_beta_proj match the unsloth Q8_0 GGUF of the same checkpoint at corr 0.99999, with the 32 value heads interleaved on the reference side.

Nothing further from me on this one; my previous review's only outstanding item is closed by that sentence.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit e682158

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.

1 participant