Skip to content

Cpu optimized/qwen3 decode path -> downstream - #39

Open
DrJesseGlass wants to merge 26 commits into
downstreamfrom
cpu-optimized/qwen3-decode-path
Open

Cpu optimized/qwen3 decode path -> downstream#39
DrJesseGlass wants to merge 26 commits into
downstreamfrom
cpu-optimized/qwen3-decode-path

Conversation

@DrJesseGlass

Copy link
Copy Markdown
Owner

No description provided.

Performance kernels layered on the standardized CPU flash attention:

- RawInterleavedKvCache: an f16, head-major raw KV cache that halves the
  bytes streamed per decode step and lays K/V out so each kv-head reads
  contiguously; grows on demand.
- causal_decode_f16kv_interleaved / causal_prefill_f16kv_headmajor: decode
  and prefill kernels that read the interleaved f16 cache directly via raw
  slices (no per-step dequantization, no separate f32 KV copy), with software
  prefetch of the next K/V row.
- FLASH_DECODE_POOL: a dedicated rayon pool sized for the q_len=1 decode
  case (saturates well below the core count), parallelizing over kv-heads.

cpu_flash_attn tests pass (9/9).
@DrJesseGlass DrJesseGlass changed the title Cpu optimized/qwen3 decode path Cpu optimized/qwen3 decode path -> downstream Jun 19, 2026
Adds the int8 SDOT-based quantized GEMM path used for prefill and decode on
aarch64 (NEON dotprod) and x86 (AVX2):

- Row-tiled GEMM in k_quants::matmul: tile activation rows so each weight
  column streams once per tile, with rayon-parallel columns; the per-row
  activation quantization is parallelized over the pool.
- GgmlType::vec_dot_multi trait hook + a multi-row NEON kernel that unpacks
  each Q4_K weight superblock once and dots it against R activation rows,
  amortizing the nibble-unpack. Bit-identical to the single-row vec_dot.
- QStorage/QTensor::cat_rows for bit-exact row concatenation (used by fused
  projection pairs), plus a QStorage owned-data roundtrip test.

All 38 quantized_tests pass (bit-exact vs the scalar reference).

@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: b83be2efd5

ℹ️ 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".

Comment thread candle-core/src/quantized/k_quants.rs Outdated
pool.install(|| {
if m == 1 {
let lhs_row = &lhs_b[..k_in_blocks];
dst.into_par_iter()

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 Limit decode matmul to the declared output width

When m == 1, this iterates over the entire dst slice even though the function only requires m * n outputs and explicitly allows longer buffers by only rejecting dst.len() < m * n. Any caller that passes a scratch buffer with extra capacity, e.g. matmul((1, k, n), ..., &mut dst) where dst.len() > n, will compute col_idx >= n and slice rhs_t past the available columns, panicking instead of leaving the tail untouched as the previous row-sliced implementation did.

Useful? React with 👍 / 👎.

Comment thread candle-transformers/src/quantized_nn.rs Outdated
fn forward(&self, ids: &Tensor) -> Result<Tensor> {
let _enter = self.span.enter();
let dims = ids.dims().to_vec();
let ids = ids.flatten_all()?.to_vec1::<u32>()?;

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 Preserve supported embedding index dtypes

This CPU quantized embedding replaces the normal Embedding path, but to_vec1::<u32>() rejects index tensors with dtypes that index_select previously accepted, notably I64 (common for token IDs) and U8. In those cases quantized Qwen3 now fails before lookup on CPU while the dense/GPU path still works, so the gather should either mirror index_select's accepted index dtypes or normalize them before this call.

Useful? React with 👍 / 👎.

@DrJesseGlass
DrJesseGlass force-pushed the cpu-optimized/qwen3-decode-path branch from 3356a33 to f6f89ac Compare June 20, 2026 00:13
Add vdotq_s32_acc (accumulate-chain SDOT) and use it in both vec_dot_q4k_q8k
(decode GEMV) and vec_dot_q4k_q8k_xr (multi-row prefill): both sub-block-half
dots accumulate into one register via SDOT's native accumulate, then one
reduction - instead of vdupq_n_s32(0) + a vaddq per pair.

Integer accumulation is associative, so this is bit-identical to the prior
form (quantized_tests q4k pass). It brings candle's Q4_K kernels to
instruction parity with ggml's hand-tuned C: ~+9.7% cumulative 1-thread
decode and ~+4-5% prefill on Graviton2/N1.
When m=1 and the decode pool has a single thread (the Lambda 1-vCPU tier),
run the GEMV serially instead of pool.install + into_par_iter - that path is
pure rayon split/join overhead per call (decode issues ~200 matmuls/token)
with no parallelism to gain. Bit-identical (same per-column vec_dot); gated
CANDLE_DECODE_SERIAL (default on, =0 restores the rayon path). ~+2.5% N1
1-thread decode.
RoPE is ~13% of single-thread decode and almost all of it is per-Tensor-op
framework overhead - ~150 tiny ops/token (narrow / to_dtype / contiguous /
apply_op3, x2, per layer). rope_neox_f32 does the neox rotation directly on
the CPU f32 slice using the already-present zero-alloc cos_sin_at(pos),
building one output tensor - no intermediate cos/sin tensors, no apply_op3
dispatch.

Bit-identical to candle_nn::rotary_emb::rope by construction (same op order:
dst[i1]=a*cos-bb*sin, dst[i2]=a*sin+bb*cos). Gated by CANDLE_ROPE_FUSED
(default on); only taken on CPU f32, else the original path runs. Measured
~+3.4% M1 / ~+1.1% N1 1-thread decode.
causal_decode_f16kv_interleaved issues only ~h_kv tiny tasks; on a
single-thread decode pool (the Lambda 1-vCPU tier) the rayon split/join is
pure per-call overhead with no parallelism to gain. Factor the per-kv-head
work into a shared closure and run it serially when the pool has <=1 thread.

Bit-identical to the parallel path (same closure); gated CANDLE_FLASH_SERIAL
(default on, =0 forces rayon). ~+0.6% N1 1-thread decode.
Add dot_f16_f16 (aarch64 fmla .8h with two accumulators + a portable
reference) and route the decode flash score through it under the opt-in
f16-attn-dot cargo feature: each kv-head narrows its rk query rows to f16
once (amortized over kv_len), so the inner q.k is a pure f16.f16 dot.

Halves the dot's FMA count and drops the in-loop fcvtl; ~+1.4% N1 1-thread
decode. The f16 accumulator loses ~1e-3 rel (softmax + greedy argmax absorb
it; greedy text stays coherent), so this is NOT bit-exact and is default-OFF
- the exact f32-accumulating dot_f32_f16 runs unless the feature is enabled.
… deduplicated pool builders (simplification/reuse)
vec_dot_multi was fixed to vec_dot_q4k_q8k_xr::<2> (the Cortex-X925 tuning,
where R=4 spilled). On Neoverse-N1 a weight column needs only 4 nibble
vectors, so wider row groups fit the register file and amortize the unpack
further. Replace the fixed-R=2 loop with a descending 8->4->2->1 ladder
capped by CANDLE_Q4K_XR_R (clamped 2..=8, default 4 = the validated N1 win),
and raise the _xr kernel's group cap from 4 to 8.

Bit-identical to vec_dot (quantized_tests q4k pass at R=2/4/8). ~+5-9%
prefill on N1; the env knob keeps per-arch tuning available.
@DrJesseGlass

Copy link
Copy Markdown
Owner Author

@codex review

@DrJesseGlass
DrJesseGlass force-pushed the cpu-optimized/qwen3-decode-path branch from f6f89ac to 61c3d61 Compare June 20, 2026 17:01
@DrJesseGlass

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 61c3d61331

ℹ️ 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".

…embedding

Wire the CPU quantized Qwen3 model onto the serverless kernels:

- FusedPairProj: fuse q/k and gate/up into one quantized matmul via
  QTensor::cat_rows when the two weights share a dtype and inner dim.
  Rows are quantized independently in every ggml format, so this is a
  bit-exact row concatenation that halves dispatch/fork-join cost and
  shares one activation quantization. CPU only; other devices keep the
  split projections. attn_v stays separate (often a wider quant).

- f16 raw-KV flash path: prefill writes the f16 RawInterleavedKvCache and
  runs causal_prefill_f16kv_headmajor directly over it, the same cache
  decode reads via causal_decode_f16kv_interleaved. No separate f32 KV
  copy and no per-layer cat of the full cache. Drops the old
  InterleavedKvCache prefill staging.

- KV prealloc is the initial reservation only (the cache grows on demand);
  size it for serverless via CANDLE_KV_PREALLOC to cut idle RAM
  (~0.11 MB/position for Qwen3-0.6B). Default 1024.

- QuantizedEmbedding / EmbedTokens: on CPU keep the token embedding
  quantized and dequantize only the rows each forward needs, instead of
  materializing the full f32 table (~1 GB RSS at 128k vocab). The tied
  lm_head shares the same quantized bytes.

- Skip causal-mask materialization only when the CPU flash path actually
  runs (b == 1); batched CPU falls back to standard attention.
@DrJesseGlass
DrJesseGlass force-pushed the cpu-optimized/qwen3-decode-path branch from 414cbca to bdd7d79 Compare June 20, 2026 19:11
@DrJesseGlass

Copy link
Copy Markdown
Owner Author

@codex review

@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: bdd7d799da

ℹ️ 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".

let mut rows = Vec::with_capacity(ids.len() * self.row_bytes);
for &id in &ids {
let off = id as usize * self.row_bytes;
rows.extend_from_slice(&data[off..off + self.row_bytes]);

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 an error for out-of-range token ids

When the CPU quantized embedding path receives an id that is >= the embedding vocab size, this direct slice indexes past data and panics, whereas the previous dense Embedding path routed through index_select and returned a Result error. This can abort a CPU Qwen3 request for a tokenizer/model mismatch or otherwise invalid input id; check id against the row count before slicing and bail instead of panicking.

Useful? React with 👍 / 👎.

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