Skip to content

qwen3: 0.6B correctness + 2.6x decode speedup - #7

Merged
Lazarus-931 merged 14 commits into
mainfrom
dev-qwen3
May 13, 2026
Merged

qwen3: 0.6B correctness + 2.6x decode speedup#7
Lazarus-931 merged 14 commits into
mainfrom
dev-qwen3

Conversation

@Lazarus-931

@Lazarus-931 Lazarus-931 commented May 13, 2026

Copy link
Copy Markdown
Owner

Qwen3 0.6B SK port — correct, +2.6x decode.

  • argmax 358 matches HF, all 28 layers rel_err < 2%
  • 27.3 → 71.0 tok/s on lexie M4 (llama.cpp Q8_0 = 118.8)
  • wins: M=1 `gemv_fp16_m1` fast-path, skip Q/K/V/O transposes at T==1, fused `gemv_swiglu_fp16_m1`, transposed-weight LM-head matvec

next: Q8_0 quant to close the last 40% (agent in flight).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e76138fcb4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

float rope_beta_slow;

float eps;
uint32_t tie_word_embeddings; // 1 = tie LM head to embedding (Qwen3-0.6B), 0 = separate lm_head (Qwen3-8B)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve Python ABI for sk_qwen_config

Adding tie_word_embeddings to sk_qwen_config changes the C struct layout, but models/qwen/qwen.py still defines _Config with fields only through eps. When Python passes this shorter struct to sk_qwen_create, h->cfg = *cfg reads past the ctypes buffer, so tie_word_embeddings becomes undefined and model behavior (tied vs untied LM head path) becomes nondeterministic for all Python callers.

Useful? React with 👍 / 👎.

const void* w_gate;
const void* w_up;
const void* w_down;
const void* w_lm_head; // optional (untied); may be null when tie_word_embeddings=1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve Python ABI for sk_qwen_weights

Adding w_lm_head to sk_qwen_weights also breaks the existing ctypes binding: models/qwen/qwen.py builds _Weights from _WEIGHT_FIELDS that do not include this member. sk_qwen_load_weights now reads w->w_lm_head from memory beyond the Python struct, and if that garbage value is non-null the memcpy path can dereference an invalid address and crash.

Useful? React with 👍 / 👎.

if (!hp || !out_fp16) return -1;
auto* h = reinterpret_cast<meow::qwen::Handle*>(hp);
std::memcpy(out_fp16, h->bufs.capture->contents(), h->bufs.capture->length());
return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return failure when capture layer was not produced

sk_qwen_get_capture always returns success and copies the capture buffer, but the API comment says it should return -1 when the requested layer was not captured. As implemented, calling get_capture before any matching forward pass (or with an out-of-range layer index) returns stale/zero data as if valid, which can silently corrupt debugging or evaluation workflows.

Useful? React with 👍 / 👎.

Lazarus-931 and others added 8 commits May 13, 2026 12:13
…memory determinism bug

The Python _Config ctypes struct was missing the tie_word_embeddings field
added in e76138f, so the C side read uninitialized memory. Garbage
nonzero values flipped Qwen3-0.6B into untied-lm_head mode, allocating
an unloaded zero buffer for w_lm_head and producing nondeterministic
post-loop logits / silent hangs in layer_diff at L>=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, not whole T_max buffer)

Previously memcpy'd the full capture buffer (T_max * d_model fp16) into the
caller's output slot, smashing the stack/heap whenever the caller sized for
the actual seq used at forward (e.g. T=2 → 4KB) instead of seq_max
(e.g. 128 → 256KB). This was causing SIGSEGV in layer_diff_06b after the
first cap_layer() call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two compounding bugs that made SK Qwen3 diverge from HF reference:

1. `gated_mlp` Metal kernel was structurally broken: its threadgroup
   `intermediate` scratch had shape (BM, BN=64) but the down-projection
   reduction loop iterated `bk` over all of N_int (e.g. 3072), reading
   the same 32 stale intermediate columns N_int/BK times. Also the write
   index used absolute `gc` instead of `gc % BN`, OOB-writing in every
   TG with col>0. Replaced its invocation in the qwen launcher with
   three separate `gemm_fp16` passes (gate, up, down) plus a tiny new
   `silu_mul_f16` elementwise kernel.

2. `qwen_rope_qk` writes Q/K in seq-major (T, H, D) layout, but
   `kv_cache_write` and `mha_causal` expect head-major (H, T, D). With
   T=1 the two layouts coincide so single-token decode appeared to work;
   prefill with T>=2 scrambled K-cache rows across heads/positions and
   blew up to fp16 saturation by layer 2.

   Added `transpose_seq_to_head_f16` / `transpose_head_to_seq_f16`
   kernels and four transpose calls per layer: Q/K/V seq→head after
   RoPE, attn_out head→seq before o_proj.

Per-layer residual rel_err (Qwen3-0.6B, "Hi!" prompt, T=2):
  before:  L0=0.51, L1=0.76, L2..L27 ~ 1.0 (fp16 saturation)
  after:   L0=0.0018, L1=0.0031, L27=0.0172
SK last-token argmax = 358 (matches HF). Top-5 identical to HF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously dispatch_layer encoded every GEMM (QKV, O, gate, up, down)
through the tile-MMA gemm_fp16 kernel even at decode (M=1), wasting
31/32 rows of every BM=32 tile. Added a gemv_m1 PSO to LayerPSOs and
taught encode_gemm to switch to the specialized M=1 matvec when the
caller passes the optional pso. Same trick the gemm.c++ host wrapper
already uses for ad-hoc calls; it just wasn't wired into the model
launcher.

bench (Qwen3-0.6B decode, lexie M4):
  before: 27.3 tok/s
  after:  64.0 tok/s  (+134%)

correctness preserved (full 28-layer diff < 2% rel, argmax=358, top-5
identical to HF).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
At seq==1 the (T,H,D) and (H,T,D) layouts are bytewise identical, so
the 4 transpose copies per layer are pure waste. Alias the source
buffers into kv_cache_write / mha_causal / o_proj instead. Prefill
(T>=2) path unchanged.

bench: 64.0 → 64.4 tok/s (Qwen3-0.6B decode on lexie M4)
correctness: argmax=358, top-5 identical to HF (decode path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two M=1 decode-path kernels:

1. gemv_swiglu_fp16_m1: fuses gate-GEMV, up-GEMV, and silu_mul into
   one dispatch. m_in is loaded once into TG memory and used to dot
   against both W_gate and W_up per column; SiLU(gate)*up is written
   to the output buffer directly. Replaces 3 dispatches with 1 in the
   MLP critical path (no speedup observed in isolation — we are
   bandwidth-bound on the weight reads — but reduces encode overhead
   and frees an intermediate buffer round-trip).

2. gemv_t_fp16_m1: M=1 matvec for transB=1 (LM-head case where weights
   are stored (V, D) row-major). Each thread owns one output row and
   walks its weight vector contiguously. Replaces the LM-head GEMM
   (151936 × 1024 fp16, 311 MB) which had been falling back to the
   tile-MMA gemm_fp16 path at M=1.

bench (Qwen3-0.6B decode, lexie M4):
  prior: 64.4 tok/s
  now:   71.0 tok/s  (+10%)

correctness preserved (argmax=358, top-5 identical to HF).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Lazarus-931 Lazarus-931 changed the title :> qwen3: 0.6B correctness + 2.6x decode speedup May 13, 2026
Lazarus-931 and others added 4 commits May 13, 2026 14:45
- Add Dtype::Q8_0 to weight_store.
- Map ggml type 8 → Dtype::Q8_0 in gguf loader.
- New kernel kernels/gemm/q8_0_matvec.metal: simdgroup-reduced Q8_0 weight
  × fp16 activation matvec for decode (M=1). NSG=4 SGs/TG, NR0=2 rows/SG,
  NQ=8 acts/lane; tiles K dimension in blocks of NSG*NQ*32. Output fp16.

Launcher/Qwen dispatch wiring NOT yet plumbed; this commit only lands the
prerequisites so the kernel binary and dtype tag are available.
Changes:
- inference/weight_store.h: add dtype_bytes(d, n_elems) helper.
- kernels/gemm/q8_0_matvec.metal: fix dispatch contract — each TG writes
  NR0=2 rows (not NSG*NR0=8). All 4 simdgroups cooperate over K for the
  same 2 rows. The previous comment claimed 8 rows/TG which caused the
  host dispatch to undercount TGs and leave 3/4 of the output zero.
  Also guard final simd_sum so all 32 lanes participate.
- models/qwen/qwen_model.h: encode_gemm_qaware routes M=1 (decode) and
  M>1 (prefill, per-row loop) through q8_0_matvec when dt_w==Q8_0. Per-
  projection dtype fields on LayerBuffers/ModelWeights; per-layer byte
  offsets use dtype_bytes. LM-head also routes to q8_0_matvec when
  dt_lm_head==Q8_0. Skip fused SwiGLU GEMV when MLP weights are Q8_0.
- models/qwen/launcher.c++: register q8_0_matvec PSO (nullptr-tolerant).
- models/qwen/weights.{c++,h}: sk_qwen_load_gguf parses GGUF, dequants
  norms/embed to fp16, reallocates 6 projection buffers to Q8_0 size and
  memcpys raw blocks, allocates Q8_0 lm_head from token_embd (tied) or
  output.weight (untied). Exposes sk_qwen_debug_q8_matvec for unit tests.
- models/qwen/qwen.py: Qwen.load_gguf(path) helper.

Validation (Qwen3-0.6B, M1):
- last-token argmax ("Hi!"): Q8_0 = 358 = FP16 = HF.
- L0 residual rel_err 1.06%, logits rel_err 4.51%.
- Decode: 136.1 tok/s (fp16 baseline 71.7 -> 1.90x; >=120 target met;
  llama.cpp Q8_0 reference 118.8).
- Standalone q8_0_matvec unit test: rel_err 0.59% vs numpy reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Qwen now inherits SuperKittens.inference.generation.Model and
  implements the _forward / _last_logits / reset contract so the
  shared generate() / chat() loop works.
- Add bake_and_set_rope() and bind sk_qwen_set_rope_tables /
  sk_qwen_get_last_logits in the ctypes shim.
- New SuperKittens/models/qwen/__init__.py registers "qwen3-0.6b"
  with sk.load, derives Config from the HF snapshot config.json,
  loads Q8_0 GGUF (or fp16 safetensors), bakes RoPE tables, and
  attaches a tokenizer via the HF tokenizer.json with the qwen3
  chat template.
- Qwen.chat() accepts a string or messages list and applies the
  chat template by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename the factory parameter spec -> variant so it doesn't clash
with the positional spec argument used by sk.api.register().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Lazarus-931
Lazarus-931 merged commit eccdb6e into main May 13, 2026
@Lazarus-931
Lazarus-931 deleted the dev-qwen3 branch May 13, 2026 22:54
@Lazarus-931
Lazarus-931 restored the dev-qwen3 branch July 8, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant