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
63 changes: 54 additions & 9 deletions docs/npu.md
Original file line number Diff line number Diff line change
Expand Up @@ -1845,7 +1845,7 @@ engine#587 measured the route on it):
| `gemma4` | `FastFlowLM/Nemotron-3.5-Bolt-E4B-NPU2`, `FastFlowLM/Gemma4-12B-IT-NPU2` | hrx | not served: no gemma4 model type or kernels in the route |
| `hunyuan-dense` | `FastFlowLM/Hy-MT2-1.8B-NPU2` | hrx | not served |
| `gemma3` | `FastFlowLM/medgemma-1.5-4b-it-NPU2` | hrx | not served |
| `lfm2` | `FastFlowLM/LFM2-2.6B-NPU2`, `FastFlowLM/LFM2-1.2B-NPU2` | hrx | not served |
| `lfm2` | `FastFlowLM/LFM2-2.6B-NPU2`, `FastFlowLM/LFM2-1.2B-NPU2`, `FastFlowLM/LFM2.5-1.2B-NPU2` | hrx, npu | `LFM2.5-1.2B-NPU2` **served and measured** on the per-op route ("FastFlowLM's own containers" below); `LFM2-1.2B-NPU2` serves too (one paired ticket, same section); `LFM2-2.6B-NPU2` was not run |
| `gpt-oss` | `FastFlowLM/GPT-OSS-20B-NPU2`, `FastFlowLM/GPT-OSS-Safeguard-20b-NPU2` | hrx | not served |
| `hy_v3` | `FastFlowLM/Hy-Memory-30B-A3B-NPU2` | hrx | not served; the first table above records this one |

Expand Down Expand Up @@ -1900,6 +1900,8 @@ does on a geometry that has a sealed set: `Qwen3-1.7B-NPU2` is 3.0 tok/s here an
| `Llama-3.2-3B-NPU2` | `llama` | identical, `Paris.` | 1.7 to 1.8 tok/s |
| `Phi4-mini-Instruct-NPU2` | `phi3` | identical, `Paris`, once the turn end is read from the template (below) | 1.4 to 1.5 tok/s |
| `Qwen3-VL-4B-Instruct-NPU2`, text tower only | `qwen3` | identical, `Paris` | 1.4 to 1.5 tok/s |
| `LFM2.5-1.2B-NPU2` | `lfm2`, hybrid: 10 conv layers of 16 | identical, `Paris`; against FastFlowLM's runtime below | 3.5 to 4.6 tok/s |
| `LFM2-1.2B-NPU2` | `lfm2`, hybrid: 10 conv layers of 16 | `Paris`; one request per prompt, against FastFlowLM's runtime below | 3.7 tok/s |

`D3-Nano-NPU2` is a decision pack: it loads on the `Qwen3.5-2B` kernel set, its readout converts with
`tools/safetensors_to_q4nx.py decision`, and `/v1/systemone` returned three identical answers to the documented
Expand All @@ -1912,18 +1914,61 @@ a stream that was sent after the generation had ended; reasoning that the templa
decision-only pack. "What ends an answer" and [npu-chat-template.md](npu-chat-template.md) describe the rules
that came out of them.

**Refused by name at load: the `lfm2` packs.** `LFM2.5-1.2B-NPU2` has all four dense kernel shapes on the box
and its container loads, but ten of its sixteen layers are gated short convolutions (`model.layers.N.shortconv.*`),
which the loader reads and the forward does not run. The serve exits at load with `forward init failed:
model.q4nx has 10 gated short-convolution layer(s) of 16 (the first is model.layers.0.shortconv.*, LFM2's hybrid),
a layer kind this forward does not implement …`. Before that refusal existed it ran those layers as attention,
failed its warm-up with a size error that named neither the layer nor the kind, and announced itself ready.
**The `lfm2` packs: a hybrid, and a second kind of weight tile.** Ten of `LFM2.5-1.2B-NPU2`'s sixteen layers
have no attention: they are gated short convolutions (`model.layers.N.shortconv.*`). The forward runs them
(engine#1786): `in_proj` (hidden to three times hidden, the fused-projection kernel at `K2048_N6144`) and `out_proj`
(the attention output kernel) on the array, and between them the gate and a three-tap causal depthwise convolution
on the host, whose only state is the last two positions of one product per channel. Which layers are convolutions
and how long the kernel is are read from the container's own tensors, not from `full_attn_idxs` or `conv_L_cache`.
The kernel directory is the `Llama-3.2-1B` set's four shapes plus that one `QKV_K2048_N6144` pair.

Two things in these packs were read wrong before, and neither is about convolutions. Their 4-bit weights are a
**symmetric** kind of the 5,120-byte tile (FastFlowLM's list calls the pack `Q4_0` where the others are `Q4_1`):
the same byte layout with every zero-point stored as 0, a scale that carries a sign, and a two's-complement code.
Nothing in the container names the kind (the name is in FastFlowLM's model list, which is not part of a pack), so
the decoder reads it from the weights: a tile is symmetric when its 256 zero-points are all zero **and** some scale
is negative, a tensor is one kind (tiles that decide nothing follow their tensor), and a tensor holding both kinds
is refused. A zero offset alone decides nothing: an asymmetric tile may have one, and the engine's own converter
test writes such a tile. Of 1,889,236 tiles in the other packs on the box none has an all-zero zero-point block or
a negative scale; of 285,696 in the two LFM2 packs every one has both.
Read as the usual kind, each LFM2 projection correlated -0.39 with the same tensor of the upstream
GGUF, and the serve answered in fluent-looking fragments of unrelated words; read as symmetric, 0.99, and the
pack's quantised `lm_head` sits within one step of its own bf16 embedding (mean 0.24 of a step). And the
attention layers carry per-head `q_norm`/`k_norm`, which the `lfm2` model type did not switch on: with only the
tiles fixed, "What is the capital of France? Answer with one word." was answered `France`.

Measured on `LFM2.5-1.2B-NPU2` on 2026-11-11, same array, same `model.q4nx` (sha256 `b341d42021e617a5…`), five
greedy prompts of up to 40 tokens, FastFlowLM 1.0.12's runtime first and this route second in one ticket:
three answers byte-identical (`Paris`; the count from one to ten; a German sentence), two not. One leaves
FastFlowLM's text after 181 characters, the other at its first token (`Here's…` here, `Certainly!…` there). At
both points this route's choice is not a near-tie: the first token is `Here` -0.64 against `Certain` -1.26 on the
array and -0.66 against -1.23 with host float GEMMs on the same weights, and the upstream GGUF on the CPU opens
with `Here's` too. FastFlowLM's own `logprobs` for that token are `Certain` -0.81, `Here` -1.06, `S` -1.81: the
same candidates, the first two in the other order, on a 0.25 grid; its answer was the same three times running.
So the two runtimes agree on the model and differ in arithmetic, and which of them a reader should call right is
not something this row decides. Also on this pack: batched prefill equals token-by-token on every chunk checked
(`NPU_BATCH_CHECK=1`, chunks of 1 to 26 rows, all `MATCH`); a request answered from a restored prefix (351 of 352
prompt tokens cached, conv state included) returns the bytes it returns with the prefix cache off; and decode was
4.6 tok/s at load 3 to 4 and 3.5 to 3.7 at load 8 to 10, against FastFlowLM's 48 to 83 tok/s on the same replies,
the gap "Per-op route" above describes.

`LFM2-1.2B-NPU2` (sha256 `2755ab40cd927e3c…`) went through the same paired ticket on the same kernel directory,
at the merged head of that change: two of five answers byte-identical (`Paris`; the count from one to ten), three
that share 21, 58 and 53 characters with FastFlowLM's and then continue differently, each a fluent answer to the
question (a Fibonacci function that returns `0` for `n <= 0` where theirs returns a message). Decode 3.7 tok/s
against 42 to 47. Its logprobs at the three points were not taken, and it was asked each prompt once, so this row
does not carry the three-identical-answers condition.

`LFM2.5-1.2B-Thinking-NPU2` loads on the same directory too: its template is read from the pack's
`chat_template.jinja`, the reasoning is split out, and at 96 tokens every one of the five replies was still
reasoning, so there is no answer text to compare and no row; the reasoning differs from FastFlowLM's from its
first words.

Not established by any row here: that a pack goes through the importer (`npu-pack` still reports these
containers as recognised, not importable), anything about a vision or audio tower, and any pack whose geometry
has no kernel set on the box. Of the 57 `*-NPU2` repos with a readable geometry on 2026-11-11, 19 had all four
dense kernel shapes already built in the blocked family; the families the forward may not model at all (`lfm2`,
`hunyuan_v1_dense`, the multimodal towers) are a separate question from the shapes.
dense kernel shapes already built in the blocked family; the families the forward may not model at all
(`hunyuan_v1_dense`, the multimodal towers) are a separate question from the shapes.

### Sealed kernel sets: the manifest is what says a set is ours

Expand Down
13 changes: 13 additions & 0 deletions npu/forward/include/lfm2_shortconv.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ inline bool shortconv_step(ShortConvState& st, const float* bcx, const float* ta
return true;
}

// Advance the state by one position WITHOUT computing an output: `bx` is that position's B*X, H values. This is
// what a rolled-back batch re-applies for the rows it keeps (the outputs were already produced; only the window
// has to match the accepted prefix). Leaves exactly the state shortconv_step() leaves for the same token.
inline bool shortconv_push(ShortConvState& st, const float* bx) {
if (!st.ready() || bx == nullptr) return false;
const int H = st.hidden, L = st.taps;
if (L > 1) {
if (L > 2) std::memmove(st.history.data(), st.history.data() + H, size_t(L - 2) * size_t(H) * sizeof(float));
std::memcpy(st.history.data() + size_t(L - 2) * size_t(H), bx, size_t(H) * sizeof(float));
}
return true;
}

// M tokens in order: `bcx` is [M][3H] row-major, `y` is [M][H]. Defined as M calls of shortconv_step(), in that
// order, so there is nothing for it to disagree with.
inline bool shortconv_rows(ShortConvState& st, const float* bcx, int rows, const float* taps, float* y) {
Expand Down
42 changes: 41 additions & 1 deletion npu/forward/include/q4nx_dequant.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,34 @@
// and
// value[row][col] = code * scale[g][row] + zero_point[g][row]
//
// A tile of that size comes in two kinds. The one above is the asymmetric kind (FastFlowLM lists such packs as
// "Q4_1"): codes 0..15 and a zero-point that is the group's offset. The symmetric kind (listed as "Q4_0"; the
// LFM2 packs) has the SAME byte layout with every zero-point stored as 0, a scale that carries a sign, and the
// code as a two's-complement nibble:
// value[row][col] = (code >= 8 ? code - 16 : code) * scale[g][row]
// Nothing in the container names the kind (no metadata, no config key; the name is in FastFlowLM's model list,
// which is not part of a pack), so it is read from the weights, with three rules:
// - a tile is SYMMETRIC on its own evidence only when all 256 zero-points are zero AND some scale is below
// zero. The sign is what an asymmetric tile with no offset does not have: a zero offset with positive scales
// is a legitimate asymmetric tile (weights 0..15 * scale; tests/npu_moe_container_test.cpp writes one), and it
// reads as it always did. A symmetric scale is max/-8 of its group, so it is negative whenever the group's
// largest weight is positive: about half of them, and never none of 256 in a real tile.
// - a TENSOR is one kind. The tensor-level readers look at all of its tiles first: any symmetric tile makes
// the tensor symmetric, and its undecided tiles (all zero-points zero, no negative scale: padding, mostly)
// follow it; a tensor with both a symmetric and an asymmetric tile is refused, by name, on stderr.
// The fused int8 packer (q4nx_pack_q4_1_tiles_to_int8) takes the kind the same way, over the whole tensor.
// - the one-row reader (q4nx_dequant_tile_row) takes it from the tiles of the row's own tile-row: it runs once
// per token on an embedding it exists to avoid paging in, so it does not scan the table.
// - q4nx_dequant_tile() on one tile, with no tensor around it, uses that tile's own evidence.
// Measured 2026-11-11 on every 5120-byte tile of the fifteen packs on the box: 1,889,236 tiles in the other
// packs, none with an all-zero zero-point block and none with a negative scale; 285,696 tiles in the two LFM2
// packs, every one with both. Read as asymmetric, an LFM2 projection correlates -0.39 with the same tensor of
// the upstream GGUF; read as above, 0.99.
// The same tie test that settled the asymmetric layout settles this reading: LFM2.5-1.2B's lm_head is the
// quantised copy of its own bf16 token_embd, and over the first 2048 rows |dequant - embedding| / |scale| has
// mean 0.243 and maximum 1.000 (0.48 % of elements above 0.5, all of them the clip of +8 steps to +7). The
// other two candidate readings, (code - 8) * scale and unsigned code * scale, are 8 and 7 steps off on average.
//
// NOTE: this is NOT the layout asserted by the older tests/verify_i4_dequant.py
// and tests/verify_q4nx_format.py (per-row 20-byte [scale][zp][16B nibbles]
// groups with signed nibbles). That layout drives the reconstruction to NaN
Expand Down Expand Up @@ -123,8 +151,20 @@ int q4nx_packed_grid(size_t nbytes, int rows, int cols, int* cb_out, int* rp_out
int q4nx_dequant_tensor_packed(const uint8_t* data, size_t nbytes, int rows, int cols,
int rows_packed, int cols_packed, int chunk_bytes, float* out);

/* Dequantize one 5120-byte tile into out[32][ld] (row-major, strided by ld). */
/* What one 5120-byte tile says about its kind (see the layout notes above). */
#define Q4NX_TILE_ASYMMETRIC 0 /* a zero-point is present */
#define Q4NX_TILE_SYMMETRIC 1 /* every zero-point is +-0 and some scale is below zero */
#define Q4NX_TILE_UNDECIDED 2 /* every zero-point is +-0, no scale below zero: asymmetric unless its tensor says otherwise */
int q4nx_tile_kind(const uint8_t* tile);

/* The kind of `ntiles` 5120-byte tiles of ONE tensor, `stride` bytes apart: Q4NX_TILE_SYMMETRIC when any tile is
* symmetric and none asymmetric, Q4NX_TILE_ASYMMETRIC when none is symmetric, -1 when both occur. */
int q4nx_tiles_kind(const uint8_t* data, size_t ntiles, size_t stride);

/* Dequantize one 5120-byte tile into out[32][ld] (row-major, strided by ld), on the tile's own evidence. */
void q4nx_dequant_tile(const uint8_t* tile, float* out, int ld);
/* The same with the kind given by the caller, which knows the tensor the tile belongs to (symmetric != 0). */
void q4nx_dequant_tile_as(const uint8_t* tile, float* out, int ld, int symmetric);

/* Dequantize a whole INT4 tensor of logical shape [rows, cols] whose packed
* bytes are a row-major grid of 32x256 tiles, or a PLAIN bf16 tensor of that
Expand Down
15 changes: 15 additions & 0 deletions npu/forward/include/q4nx_forward.h
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,21 @@ class Q4nxNpuForward {
bool gdn_layer_batch(int l, const std::vector<float>& xn, int M,
std::vector<float>& out);
bool load_gdn_layer(int l);

// ── LFM2 gated short-convolution layers ──────────────────────────────
// A layer that carries shortconv.* tensors has no attention: in_proj (H -> 3H, the QKVLIN design, weight
// key 0) and out_proj (H -> H, the O design, key 1, the slot an attention layer's o_proj has) run on the
// device, and everything between them is lfm2_shortconv.h on the host. The only state is the last L-1
// values of B*X per channel, kept in conv_state_[l] so snapshot/restore/reset cover it with no new code.
bool shortconv_ = false; // the model has at least one such layer
int sc_taps_ = 0; // L, from shortconv.conv.weight's own shape
std::vector<uint8_t> sc_layer_; // [NL] 1 = conv layer
std::vector<std::vector<float>> sc_w_; // [NL][H * L] the depthwise taps, bf16-decoded once
std::vector<std::vector<float>> sc_rows_; // [NL][rows * H] B*X of a verify batch's rows (see begin_verify)
bool is_conv_layer(int l) const { return shortconv_ && l >= 0 && l < (int)sc_layer_.size() && sc_layer_[(size_t)l]; }
bool conv_layer(int l, const std::vector<float>& xn, std::vector<float>& out);
bool conv_layer_batch(int l, const std::vector<float>& xn, int M, std::vector<float>& out);
void conv_apply_rows(int rows); // re-apply the first `rows` recorded rows after a restore
std::string err_;
std::string backend_ = "uninitialised";
bool ready_ = false;
Expand Down
7 changes: 7 additions & 0 deletions npu/forward/include/q4nx_pack.h
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ typedef struct {
int qk_nope; /* per-head qk width excluding rope */
int dense_im; /* leading dense layer's intermediate width */
int mla_kva_pad; /* KVA design N pad: ceil((kv_lora+rope_dim)/128)*128 */
/* LFM2's gated short-convolution layers: in_proj is one fused (K=H, N=3*H)
* projection [B | C | X] and out_proj is (K=H, N=H), which is the O design
* whenever NH*HD == H. shortconv_n is in_proj's N; 0 means the model has no
* such layer. It derives one QKVLIN design and nothing else (no Z), and is
* exclusive with lin_kd: a model has GDN layers or conv layers, not both.
* Appended last so positional initialisers keep their meaning. */
int shortconv_n;
} Q4nxModelDims;

/* No model-specific geometry constant lives here by design: every caller
Expand Down
Loading
Loading