Skip to content

GGUF-on-NPU: all six architectures answer on --device npu (qwen3moe, qwen35moe, GLM fixes), tokenizer and serve fixes - #209

Merged
bong-water-water-bong merged 12 commits into
mainfrom
npu-gguf-arches
Sep 28, 2026
Merged

bong-water-water-bong merged 12 commits into
mainfrom
npu-gguf-arches

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

This PR lands the pi agents' GGUF-on-NPU work from strixhalo (branch npu-glm-kb-fix in ~/1bit-engine-gguf-npu; 9 commits were local only), rebased onto main, plus one fix of mine. The agents' commits keep their own authorship.

Agents' commits (goals muhz0s39 / mulmuy25):

  • a58c976, d22ef1a: GLM-4.7-Flash (deepseek2/MLA). The repack neutralises the inverted k_b transpose, and the forward re-syncs two MLA fixes: no expert transpose, and the v_b index. Each is checked element by element against the GGUF (correlation 0.9992/0.9969 against ~0 for the old indexing).
  • 6484552, 9ad4fdf: Qwen3 MoE (Qwen3-Coder-30B-A3B) and qwen35moe (Qwen3.6-35B-A3B, GDN + MoE) on the per-op forward. MoE geometry is now parsed independently of MLA, and the shared expert gets its sigmoid gate.
  • e5b9f38: the NPU serve takes /tmp/1bit-npu-device.lock, like the ELF runner, so two serves no longer fail on CREATE_HWCTX.
  • 669c105: the repack records the GGUF arch for the provenance stamp check.
  • 97a43e2: tokenizer. Accepts a Unigram tokenizer.json (array vocab, no merges) and strips SentencePiece's dummy-prefix space.
  • 4784b06, b6b5864: registry rows for the six GGUF-on-NPU arches, plus tests/anchor_rerun.sh.
  • 1f800d2: the LM head uses the widest K=H design, which fewer launches and also fixes a SIGFPE on MLA. Adds an NPU_PROFILE=1 dispatch profiler. Adds opt-in forward->lane routing (ONEBIT_NPU_LANE_DIR, off by default).

My commit: 97a43e2 stripped the leading space from every decode. Both servers decode one streamed token at a time, so every word-initial SentencePiece token lost its space: the agents' benchmark shows MiniCPM4 streaming "…-TheUnitedStates". decode(ids) still strips it for whole texts; decode(id), the streaming form, keeps it.

Evidence (captured by the agents on strixhalo, ~/evidence/gguf-npu-six/benchmark-2026-09-28.md): 1bit serve -m <gguf> --device npu answers " Paris" on all six arches (llama MiniCPM5-1B, qwen2 Qwen2.5-7B, qwen3moe Qwen3-Coder-30B-A3B, deepseek2 GLM-4.7-Flash, minicpm MiniCPM4-8B, qwen35moe Qwen3.6-35B-A3B). Speed is 0.006–0.17 tok/s, because it is still the per-op forward (~173 synchronous dispatches per token). The fused per-layer path is the agents' next goal.

Verified here: the rebase onto main is clean. ctest passes 12/12. The MiniCPM4 tokenizer golden (llama.cpp oracle ids) passes 5/5 against the repacked tokenizer.json.

Review notes, not blocking:

  • The ~40-line MoE loader is duplicated in load_layer and load_gdn_layer.
  • leading_dense_block_count now defaults to 0 when missing. GLM's config carries it, so GLM is unaffected.
  • ONEBIT_NPU_LANE_DIR leaves an npu symlink in the repack cache dir. The GGUF path doesn't read that dir afterwards.

🤖 Generated with Claude Code

agent-872bf5 and others added 11 commits September 28, 2026 20:02
The converter's 'k_b to the MLA math grid' transpose (FLM_Q4NX_Converter
62594563f) is inverted.  Measured directly with the converter's own
_pack_2d_float on GLM's blk.0.attn_k_b.weight: the dequantised per-head slice is
[512][192] (kv_lora x nope) and packing it AS-IS gives the 12 tiles/head the
forward reads (load_heads3 with kb_rows_ = qk_nope_ = 192, cols = kv_lora_ = 512),
while transposing it to [192][512] gives 16 tiles/head.  The forward then reads
past the slice and '1bit serve --device npu' segfaults on the first request.

A repack via scripts/repack_gguf.py therefore emits k_b [20][16][5120] while the
Q4NX the GLM capture was verified against (/tmp/glm_v3) has [20][12][5120].
The engine's repack now runs the converter from a patched copy with that gate
disabled, the same way it already forces Q4_1, and prints why.  Verified: the
patched repack writes k_b [20][12][5120] (v_b unchanged at [20][16][5120]).
…grid)

The MLA/MoE path in this re-synced forward was missing two canonical
npu-infer fixes, so `1bit serve -m GLM-4.7-Flash-Q4_K_M.gguf --device npu`
decoded " jp" instead of " Paris":

1. mla_expert() transposed every routed expert.  Its tile decode already
   reproduces the GGUF's flat order, which IS the logical [N][K] weight, so
   the transpose made every routed expert ~10-20x wrong and flipped the
   top-k selection (canonical f336dfc72).  Now a plain std::copy.
2. v_b was indexed in-major (wv[k*vh+n]); the Q4NX's logical index is
   out-major (h*256+n)*512+k (canonical 3d1b252ec).  Now wv[n*kv_lora+k].

Verified on the NPU: /v1/completions "The capital of France is" -> " Paris"
(4m4s).  Q4NX is byte-identical to the verified glm_npu2 dir; the two bugs
were the only functional deltas from canonical.
…-device npu)

Qwen3-Coder-30B-A3B (qwen3moe) is the first NON-MLA MoE arch: the FFN is a
128-expert top-8 softmax router with no shared expert and no router bias.  Four
changes make `1bit serve -m <gguf> --device npu` answer " Paris":

1. forward: parse the MoE params (n_expert/top_k/expert_im/dense_im/
   leading_dense/gating/scale) INDEPENDENTLY of mla_, so a non-MLA MoE arch gets
   its expert geometry (they were read only inside `if (mla_)`).
2. forward: load the MoE tensors (bf16 router + pooled gate/up/down experts) in
   the standard path when n_expert_>0, instead of the dense gate/up/down; the
   runner bias and shared expert stay optional (moe_mlp already handles both).
3. forward: dispatch moe_mlp in the standard path for n_expert_>0 (deepseek2
   still reaches moe_mlp from the MLA branch, which continues first).
4. forward: `dense_weights_cached` must NOT skip load_layer for a MoE arch --
   moe_router_ is a HOST vector reloaded per layer, so skipping it left every
   layer using the last-loaded layer's router on tokens >= 1 (pos 0 was fine,
   which is why it looked plausible but decoded ".").

scripts/repack_gguf.py: write the MoE config keys for qwen3moe/qwen35moe -- the
per-EXPERT intermediate (768) must replace the dense feed_forward_length (5472)
so the G/U/D designs ARE the expert MLP, plus n_expert/top_k/softmax gating.

Verified: anchor -> " Paris" (3m54s); experts/q_proj/o_proj/router all decode
corr >= 0.9989 vs the GGUF; GLM (deepseek2) still answers " Paris" (regression).
The device exposes one NPU hardware context; a second concurrent
'1bit serve --device npu' dies inside DRM_IOCTL_AMDXDNA_CREATE_HWCTX (EINVAL)
and exits 1.  npu_engine_universal has always held this flock; the serve route
did not, so siblings could not serialise (and my qwen3moe run hit exactly this
when a leftover GLM serve held the context).
Qwen3.6-35B-A3B is a hybrid: 30 GDN (gated delta-net linear attention) layers
interleaved with 10 full-attention layers, each FFN a 256-expert top-8 softmax
router plus a gated shared expert.  Extends the qwen3moe work:

- forward: load_gdn_layer now takes the MoE branch (router + pooled experts +
  the optional shared expert) when n_expert_ > 0, instead of the dense
  gate/up/down; the GDN layers' FFN was already dispatched by the common MLP
  section (it sees n_expert_ > 0 and calls moe_mlp).
- forward: the shared expert (share_gate/up/down_exps_proj) and its [H] gate
  (share_router) load in both MoE branches; moe_mlp applies sigmoid(x . g) to
  the shared expert output (qwen35moe), and leaves it unscaled when absent
  (deepseek2 has no such gate).
- repack: write the GDN geometry (linear_num_key_heads/key_head_dim/
  value_head_dim/num_value_heads/conv_kernel_dim from the GGUF ssm.* fields),
  attn_output_gate (the q_proj emits q|gate) and the per-layer layer_types list
  the forward dispatches on.

Kernels generated for the six new geometries (QKV K2048 N9216 for the gated
full-attn, G/U K2048 N512, D K512 N2048, QKV K2048 N8192 (GDN qkv), Z K2048
N4096).  Q4NX repacked (21 GB) + the GDN config keys verified.
The stamp records converter_path/commit; --check-stamp re-resolves the converter
from the dir's config.  It used model_type, which for qwen35moe is the HF name
'qwen3_5_moe' (no q4nx/models/qwen3_5_moe.py), so it fell back to the canonical
checkout while the dir was built by the iso-build one -> always 'stale' -> the
serve discarded and re-repacked the 21 GB dir on every start (and twice hit
ENOSPC doing it).  config.json now carries "arch" (the GGUF architecture) and
the check prefers it.
…er.json

Two defects on the SentencePiece path (MiniCPM4-8B, the only SentencePiece model of
the four blocked targets), both found by testing against oracle ids rather than by
reading the code.

1. Every SPM decode gained a spurious leading space.  decode() mapped every metaspace
   to ' ', including the one spm() synthesises as SentencePiece's add_dummy_prefix
   marker, so 'The capital of France is' -> ' The capital of France is' and
   'Paris' -> ' Paris'.  Strip exactly that one leading space: stripping one rather
   than all also preserves a genuinely space-leading input, since ' Paris' encodes to
   the double metaspace [1345, 3837, 1360] and must decode back to ' Paris'.

2. A correctly-typed tokenizer.json could not be loaded at all.  model.at("merges")
   throws [json.exception.out_of_range.403] when the key is absent, and a
   SentencePiece/Unigram tokenizer.json has no such key -- the throw aborted the
   constructor before the SPM detection, so the unigram path was unreachable and
   every SPM model fell back to raw bytes.  Also accept the Unigram list-form vocab
   ([[token, score], ...], id = position) as well as the BPE object form.  Verified
   with a negative control: the old expression throws on a Unigram file, the new one
   returns an empty merge set.

Test: tests/data/minicpm4_tokenizer_golden.json, whose ids come from llama.cpp's own
tokenizer (llama-server /tokenize on MiniCPM4-8B-Q4_K_M.gguf), not from this
tokenizer.  Results: MiniCPM4 as repacked 5/5 (was 0/3), MiniCPM4 as a proper Unigram
file 5/5 (previously unloadable), Qwen3 BPE golden 14/14 unchanged.
- tools/registry_check.py: an npu .gguf row resolves under --models and reads
  its arch from the GGUF header (the GGUF-on-NPU route repacks it itself),
  instead of being treated as a Q4NX model directory.
- registry/check_models.tsv: add the six npu GGUF rows (qwen2, qwen3moe,
  qwen35moe, deepseek2/glm, minicpm, llama).
- registry/checked.json: refresh the Qwen3-0.6B npu-directory entry to the
  current HEAD.
- tests/anchor_rerun.sh: harness to re-run the anchor completion across a set
  of served models and capture the results.
The GGUF-on-NPU route serves six arches, each verified by a captured
`--device npu` run (goal muhz0s39-8c9l9o). Record them so the registry
reflects what the NPU actually carries — text decoders only:

- qwen2 (Qwen2.5-7B), qwen3moe (Qwen3-Coder-30B), qwen3_5_moe (Qwen3.6-35B),
  deepseek2 (GLM-4.7-Flash), minicpm (MiniCPM4-8B), llama (MiniCPM5-1B).

Regenerates registry/architectures.json (npu count 1 -> 7).
…ane routing

- run_lm_head picks the widest design with K=H (G/U geometry, N=18944) instead of
  the first (QKV): 9 head launches per token instead of 33, ~24 fewer synchronous
  NPU launches (each ~131 ms). Captured qwen2: 0.018 -> 0.020 tok/s, anchor ' Paris'
  preserved; the five other arches re-verified (evidence/gguf-npu-six/after-speed/).
- npu_gemm gains an env-gated NPU_PROFILE hook: ~173 dispatches/token, each
  ~183 ms (52 pack+quant, 2.3 quant+launch, 131 wait, 0.1 readback).
- app/serve.cpp: opt-in forward->lane routing (ONEBIT_NPU_LANE_DIR) for models with
  a matching fused-lane kernel set; off by default because the repack data packing
  differs from the lane kernels' (lane ' in the city of' vs forward ' Paris').

Goal mulmuy25-vnl62c.
97a43e2 strips the first metaspace (SentencePiece's add_dummy_prefix marker)
from every decode. Both servers decode one generated token at a time
(app/forward_serve.cpp, app/unified.cpp), so every word-initial token lost
its space: MiniCPM4 streamed "...-TheUnitedStates". The marker belongs to a
whole text's first token only; in a stream the space after the prompt or
the previous word is real. decode(ids) keeps stripping it (whole texts, the
tokenizer goldens); decode(id), the stream's form, keeps it.

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

context7 Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Docs7 for 1bit-monster/engine

Result Status Action
Deployment ➖ Not used —
Content review ➖ Did not run. This site has no agent runs available this month. Wait for the monthly reset or check your Docs7 plan. —

Commit 7dfccad

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 7dfccad)

Here are some key observations to aid the review process:

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

Possible Issue

The change to mla_attn in q4nx_forward.cpp modifies how k_b and v_b are indexed, which is critical for correctness. The new indexing logic assumes a flat order that reproduces the GGUF's flat order exactly, but this change could introduce a correctness issue if the tensor layout or indexing assumptions differ between the GGUF and the forward pass. This is especially concerning since the PR description mentions that the old indexing was "pinned" against a specific oracle, and the new logic explicitly states it was verified elementwise against the GGUF.

// k_b: Q4NX tile grid [kv_lora][nope padded to 256], indexed flat[n*kv_lora+k].
// v_b: the Q4NX tiles v_b with cols=256 while its LOGICAL row is 512 wide, so
// the per-head [512][256] tile grid is NOT the logical matrix.  Decoding the
// whole tensor at (rows=10240, cols=256) reproduces the GGUF's flat order
// exactly (corr 0.996860 vs the 0.996870 a faithful Q4_1 requant scores),
// i.e. the logical index is (h*256+n)*512 + k = out-major.  Indexing flat
// [k*vh+n] instead permuted the whole MLA value path; the earlier check that
// "pinned" it compared a single SUM, which a permutation can still match.
Possible Issue

The MoE implementation in q4nx_forward.cpp now parses MoE parameters independently of MLA, which fixes a bug where MoE configs were not read for non-MLA models. However, the code introduces a new potential issue where the MoE parameters are read only once during initialization, and if the model's architecture changes dynamically (which is unlikely but possible), this could lead to incorrect behavior. This is a subtle correctness concern.

// MoE params are parsed INDEPENDENTLY of mla_ so a non-MLA MoE arch (qwen3moe /
// qwen35moe) gets its expert geometry too; reading them only inside `if (mla_)`
// left n_expert_/top_k_/expert_im_ zero and the standard MoE path dead.
// expert_im_ is the per-EXPERT intermediate, which c_.IM already holds for a MoE
// config (the converter writes the per-expert FFN width as intermediate_size).
n_expert_ = q4nx_config_int(cfg_path.c_str(), "n_expert", 0);
if (n_expert_ > 0) {
    top_k_ = q4nx_config_int(cfg_path.c_str(), "top_k", 0);
    expert_im_ = c_.IM;
    dense_im_ = q4nx_config_int(cfg_path.c_str(), "dense_intermediate_size", 0);
    leading_dense_ = q4nx_config_int(cfg_path.c_str(), "leading_dense_block_count", 0);
    expert_gating_sigmoid_ =
        q4nx_config_int(cfg_path.c_str(), "expert_gating_func", 1) == 2;  // 2 = sigmoid
    expert_w_scale_ = read_config_float(cfg_path, "\"expert_weights_scale\"", 1.0f);
    if (top_k_ <= 0 || expert_im_ <= 0) {
        fprintf(stderr, "  [moe] config incomplete (n_expert=%d top_k=%d im=%d)\n",
                n_expert_, top_k_, expert_im_);
        err_ = "MoE config incomplete";
        return false;
    }
    fprintf(stderr, "  [moe] %sMoE: NH=%d n_expert=%d top%d im=%d dense_im=%d gating=%s\n",
            mla_ ? "MLA+" : "", c_.NH, n_expert_, top_k_, expert_im_, dense_im_,
            expert_gating_sigmoid_ ? "sigmoid" : "softmax");
}
Possible Issue

The mla_expert function in q4nx_forward.cpp has been changed to not transpose the expert weights, which was previously done. This change is claimed to be verified elementwise against the GGUF, but if the tensor layout or indexing assumptions are incorrect, this could lead to incorrect expert routing or incorrect computation of the MoE layer. The change is significant and should be carefully validated.

// Do NOT transpose.  The tile decode at (rows=K, cols=N) already reproduces the
// GGUF's flat order, and that flat sequence IS the logical [N][K] = [out][in]
// weight.  Verified elementwise against the GGUF per expert: reshape(N,K) gives
// corr 0.999198 (gate) / 0.996949 (down), while transposing gives -0.001079 /
// 0.000417 -- i.e. the transpose destroyed the expert entirely, making every
// routed expert's contribution ~10-20x wrong and flipping the top-k selection.
std::copy(kn.begin(), kn.end(), out.begin());
Possible Issue

The run_lm_head function in q4nx_forward.cpp now selects the widest design with K=H for the head, which is intended to reduce the number of launches. However, this change could potentially lead to performance degradation if the selected design is not optimal for the specific model or if the number of tiles is not well-balanced. This change should be validated with performance benchmarks.

// Reuse an existing GEMM geometry for the head (the variable keeps its old name
// for the block below).  QKV when the arch has one; the MLA/deepseek2 arch
// (GLM-4.7-Flash) has QA/QB/KVA/O instead, so idx_of(QKV) returned -1 and T was
// read out of designs_[-1] -- garbage, and the ntiles division below raised
// SIGFPE.  Only this NPU path uses T (the host path is a plain matvec), which is
// why GLM crashed here the first time the ELF path was exercised.
// Pick the WIDEST design with K=H for the head.  Each tile is a separate
// synchronous launch (~one NPU round-trip each, ~constant regardless of N),
// so fewer/larger tiles mean fewer launches for the same math.
int i_qkv = -1;
for (int d = 0; d < ndesigns_; d++) {
    if (designs_[d].K != H || designs_[d].N <= 0) continue;
    if (i_qkv < 0 || designs_[d].N > designs_[i_qkv].N) i_qkv = d;
}
if (i_qkv < 0) {
    err_ = "LM head: no GEMM design with K=H available";
    return false;
}
const int T = designs_[i_qkv].N;
Possible Issue

The NPU device lock mechanism in forward_serve.cpp uses flock with LOCK_EX | LOCK_NB and falls back to LOCK_EX if the first attempt fails. This could lead to a race condition where two processes might attempt to acquire the lock simultaneously, potentially causing one to wait indefinitely or fail to acquire the lock. This is a critical issue for concurrent access to the NPU device.

if (!std::getenv("NPU_NO_DEVICE_LOCK")) {
    // flock needs only a read fd, so the file stays 0644 and other users can
    // still open it; O_NOFOLLOW refuses a symlink planted in /tmp.
    const char* lk = "/tmp/1bit-npu-device.lock";
    int lfd = ::open(lk, O_CREAT | O_RDONLY | O_NOFOLLOW | O_CLOEXEC, 0644);
    if (lfd >= 0) {
        if (::flock(lfd, LOCK_EX | LOCK_NB) != 0) {
            std::fprintf(stderr, "[npu] waiting for the exclusive device lock (%s)...\n", lk);
            if (::flock(lfd, LOCK_EX) != 0)
                std::fprintf(stderr, "[npu] device lock unavailable; continuing unlocked\n");
            else
                std::fprintf(stderr, "[npu] device lock acquired\n");
        }
    }
}
Possible Issue

The repack_gguf.py script introduces a new mechanism to patch the converter for specific architectures like deepseek2. The patching logic modifies the converter's source code, which could introduce subtle bugs or inconsistencies if the patching is not done correctly or if the patching logic itself has errors. This is a high-risk change that should be carefully validated.

_kb_file = os.path.join(converter_dir, "q4nx", "models", _arch + ".py")
if _arch == "deepseek2" and os.path.exists(_kb_file):
    try:
        if 'transpose_slice = "k_b_proj" in nm' in open(_kb_file).read():
            _kb_patch = True
    except Exception:
        pass
if (_t and _t != "Q4_1") or _kb_patch:
    import shutil as _shutil, tempfile as _tempfile
    run_dir = _tempfile.mkdtemp(prefix="q4nx-converter-")
    _shutil.copytree(converter_dir, run_dir, dirs_exist_ok=True)
    if _t and _t != "Q4_1":
        _c["default_tensor_type"] = "Q4_1"
        with open(os.path.join(run_dir, "configs", _arch + ".json"), "w") as f:
            json.dump(_c, f, indent=4)
        print(f"repack: {_arch} config asks for default_tensor_type={_t}; the NPU "
              f"forward reads Q4_1 tiles, so the converter runs from a patched "
              f"copy ({run_dir})")
    if _kb_patch:
        _p = os.path.join(run_dir, "q4nx", "models", _arch + ".py")
        _s2 = open(_p).read().replace(
            'transpose_slice = "k_b_proj" in nm',
            'transpose_slice = False  # engine: k_b packs as-is -> 12 tiles/head')
        with open(_p, "w") as f:
            f.write(_s2)
        print(f"repack: {_arch} k_b transpose is inverted (gives 16 tiles/head, the "
              f"forward reads 12); neutralised in the patched copy ({run_dir})")

Comment thread app/forward_serve.cpp Fixed
Comment thread app/forward_serve.cpp Fixed
flock works on a read-only descriptor, so the lock file no longer needs to
be world-writable, and the path no longer comes from the environment.
O_NOFOLLOW refuses a symlink planted at the /tmp path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 7dfccad

@bong-water-water-bong
bong-water-water-bong merged commit 4c8e861 into main Sep 28, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the npu-gguf-arches branch September 28, 2026 23:11
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Post-merge note: scripts/repack_gguf.py still has the source-text k_b workaround (_kb_patch, matching 'transpose_slice = "k_b_proj" in nm'). The converter reverted that transpose at the source (5fc52149b), so the block is dead code today. It would silently rewrite the converter again if that line ever comes back for another reason. Please remove it in a follow-up and rely on a load-time tile-count check instead.

bong-water-water-bong added a commit that referenced this pull request Sep 29, 2026
…UF on the NPU, 1BP; blog post (#214)

README gets a Direction note (HRX with Loom kernels plus the NPU, as geramyL proposed;
Vulkan stays the default until the RFC #213 gates are met) and current status lines:
six GGUF architectures answer on the NPU (0.006-0.17 tok/s), DwarfStar reads 1BP, and
Laya classifies conversations with its scorer on HRX at 15-16 ms. PORTING's NPU and
Laya rows gain #179/#181/#209 and #187/#206/#208. The blog post covers the same.

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants