Skip to content

Commit 6a9817f

Browse files
committed
perf(aura): unify cache to activation dtype + wire 2-pass FA decode (closes -58%→-9% gap)
End-to-end FFAI side of the AURA dtype unification (metaltile sigs 0e4cb1a + 3fdadb3, PR 0xClandestine/metaltile#212). Replaces the intermediate `.dequantMirror` default (originally bf16'd as a stopgap because the single-pass `aura_flash_sdpa` kernel starved the GPU with one simdgroup per query) with the right architecture: a single source of truth in the activation dtype, and the token-parallel FA-2 kernel pair. ## Cache schema — single source of truth (AURAQuantizedKVCache) - kNorms / vNorms allocated in `dtype` (was f32-only). - kCodebook / vCodebook allocated in `dtype` (was f32-only). - kBoundaries / vBoundaries stay f32 — encoder-only, Lloyd-Max compare precision matters and they never reach the decode kernels. - encodePerHead view stride now keys off `dtype.byteSize`, not a hardcoded 4 (the legacy f32 footgun that broke `AuraKLDIntegrationTests` the moment a non-f32 cache hit the encode path). ## Loaders (LlamaText / Qwen3Text) - New `AURACodebook.centroidsTensor(dim:bits:dtype:device:)` host-side conversion helper covers all three float dtypes (f32 / f16 / bf16). - `AURACodebook.boundariesTensor(...)` mirrors the helper for the encoder-only boundaries buffer. - Both Qwen3 and Llama AURA cache builders use the helpers — no more copy-pasted f32 `Tensor.empty + copyIn` block per loader. ## Ops surface - `Ops.auraFlashSdpa` preconditions drop the f32-norms-and-codebook requirement; everything must now match `out.dtype` (the activation dtype). Q pre-scale flow rewires from a f32 scratch + f32 scale buffer to an activation-dtype scratch + activation-dtype scale buffer. - `AuraFlashScratchCache` keys both scratches on (count, dtype) — was keyed on `count` alone with f32 hardcoded. Adds a `partials(...)` scratch cache for the 2-pass partials triple. - `Ops.auraEncode` + `Ops.auraDequantRotated` preconditions drop the f32-norms-and-codebook requirement; the dequant-mirror path flows through T now too. - New `Ops.auraFlashSdpa2Pass` wrapper — dispatches `aura_flash_p1` + `aura_flash_pass2` for token-parallel FA-2 over the compressed cache. Caller-owned partials (mirrors `Ops.sdpaDecode2Pass`). - New `Ops.supportsAuraFlashSdpa2Pass` predicate. ## Qwen3Layer.forward - Prefer `Ops.auraFlashSdpa2Pass` when supported, fall back to `Ops.auraFlashSdpa` for combos the 2-pass kernel hasn't been emitted for (no path today; future-proof for kb!=4 / vb!=2,4 / d!=128). - Block size 64 — matches the dense `sdpaDecode2Pass` per-block work size and saturates the M5 Max class around liveLength ≈ 4K. ## Default — back to `.compressed` `LoadOptions.auraDecodePath` defaults to `.compressed`. Matches @ekryski's stance from the PR review — true compressed attention is FFAI's quantized-attention story and should be the default-path users load into. The dtype unification + 2-pass FA-2 closes the perf gap that made the original `.dequantMirror` flip necessary. ## Quality (M5 Max, Qwen3-0.6B-4bit, 61-position KLD harness) | scheme | mean_kld | same_top | |-------------------------|---------:|---------:| | aura4v4 dequant-mirror | 1.42 | 43% | | aura4v4 2-pass flash | **1.40** | **48%** | | aura4v2 2-pass flash | 1.69 | 44% | | aura8v4 (TQ+ recipe) | 0.018 | 93% | 2-pass compressed flash matches (slightly beats) dequant-mirror on aura4v4. KLD harness regression gate green for all schemes. ## Perf (M5 Max, Qwen3-0.6B-4bit decode tps, 5-run median) | KV | dequant-mirror | compressed (2-pass) | gap | gap pre-unification | |------|----------------|---------------------|--------|---------------------| | 64 | 80.88 | 71.62 | -11.4% | -15.7% | | 256 | 77.14 | 67.71 | -12.2% | **-43.7%** | | 1024 | 46.87 | 42.73 | -8.8% | **-57.8%** | Long-KV gap collapsed from -57.8% → -8.8%. Single-digit perf delta vs dequant-mirror with 1.88× cache memory savings preserved (aura4v4 @ maxSeq=4096: 4352 KiB packed+norms vs 8192 KiB mirror). ## Why the C++ canonical pattern is safe The fp16-stored norms / f32-at-use pattern this PR adopts mirrors the production C++ `llama.cpp` TQ+ fork — commit b696c5da1 in that fork shipped fp16 centroid LUTs + float-norm broadcast with measured zero PPL impact ("Constant half LUT + float norm broadcast remains the fastest approach on Apple Silicon", ggml-metal.metal:776). Internal kernel arithmetic stays in f32 via cast-at-load; only the storage narrows. ## Pass-2 dispatch shape note `aura_flash_pass2`'s kernel header says `tg = (32, 1, 1) per q_idx`, which means `q_idx = tgid_x`. Wrapper dispatches raw threads `[nQHeads * 32, 1, 1]` with `tg = [32, 1, 1]` → `nQHeads` TGs along x, each running 32 lanes; matches the metaltile end-to-end test's grid shape exactly. The naive `[32, nQHeads, 1]` shape (raw-thread analogue of `grid_groups [1, nQHeads, 1]`) would put `tgid_x = 0` for every TG, i.e. every Q head's reduce reads q_idx=0's partials — produced garbage same_top=0.0 / mean_kld=12+ output before the fix. Worth a comment in the wrapper (added). ## Bench infra retained from the original perf pass `AuraFlashScratchCache` (process-wide static, NSLock-guarded) memoizes the Q scratch + scale buffer per (shape, dtype, scale) tuple. The `AuraDecodeBenchIntegrationTests` side-by-side bench grid + memory footprint asserter (KV=64 / 256 / 1024 + maxSeq=4096) are also kept as regression catchers.
1 parent 6f6e15d commit 6a9817f

7 files changed

Lines changed: 851 additions & 105 deletions

File tree

Sources/FFAI/KVCache/AURACodebook.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,54 @@ public enum AURACodebook {
246246
return base.map { $0 * scale }
247247
}
248248

249+
/// Allocate a codebook tensor in the requested activation dtype.
250+
/// AURA cache stores codebook in the same dtype as the model
251+
/// activations so both encode + decode kernels (which take
252+
/// `Tensor<T>` for the codebook) read directly with no per-call
253+
/// cast. The Lloyd-Max values themselves are computed in Float;
254+
/// narrow dtypes (`bf16`/`f16`) round at the CPU-side host conversion.
255+
public static func centroidsTensor(
256+
dim: Int, bits: Int, dtype: DType, device: Device = .shared
257+
) -> Tensor {
258+
let values = centroids(dim: dim, bits: bits)
259+
return writeFloatsToTensor(values, shape: [values.count], dtype: dtype, device: device)
260+
}
261+
262+
/// Allocate a boundaries tensor. Boundaries stay f32 — they're
263+
/// encoder-only, used only by `aura_encode` for the branchless
264+
/// Lloyd-Max comparison, where precision matters.
265+
public static func boundariesTensor(
266+
dim: Int, bits: Int, device: Device = .shared
267+
) -> Tensor {
268+
let values = boundaries(dim: dim, bits: bits)
269+
let t = Tensor.empty(shape: [values.count], dtype: .f32, device: device)
270+
t.copyIn(from: values)
271+
return t
272+
}
273+
274+
/// CPU-side host conversion from `[Float]` into a tensor of the
275+
/// requested float dtype. Used by `centroidsTensor` and any caller
276+
/// that needs Lloyd-Max-precise values landed into narrow storage.
277+
private static func writeFloatsToTensor(
278+
_ values: [Float], shape: [Int],
279+
dtype: DType, device: Device
280+
) -> Tensor {
281+
let t = Tensor.empty(shape: shape, dtype: dtype, device: device)
282+
switch dtype {
283+
case .f32:
284+
t.copyIn(from: values)
285+
case .f16:
286+
t.copyIn(from: values.map { Float16($0) })
287+
case .bf16:
288+
t.copyIn(from: values.map { UInt16(truncatingIfNeeded: $0.bitPattern >> 16) })
289+
default:
290+
fatalError(
291+
"AURACodebook.centroidsTensor: unsupported dtype \(dtype); "
292+
+ "AURA cache supports f32 / f16 / bf16")
293+
}
294+
return t
295+
}
296+
249297
/// Bytes-per-token after AURA packing at this bit width and dim.
250298
/// `ceil(dim * bits / 32) * 4` for the packed u32 array, plus 4
251299
/// bytes for the f32 per-token norm. Excludes any per-vector DC

Sources/FFAI/KVCache/AURAQuantizedKVCache.swift

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,21 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable {
107107
/// Π^T in the activation dtype, used to un-rotate the SDPA output
108108
/// before `oProj`. Aliases `rotationT` when `dtype == .f32`.
109109
public let rotationDtypeT: Tensor
110-
public let kCodebook: Tensor // [2^keyBits] f32
111-
public let kBoundaries: Tensor // [2^keyBits-1] f32
112-
public let vCodebook: Tensor // [2^valueBits] f32
113-
public let vBoundaries: Tensor // [2^valueBits-1] f32
110+
/// Codebook in the cache dtype. Encode + decode kernels read
111+
/// directly with no per-call cast — the dtype unification landed
112+
/// when the single-pass `aura_flash_sdpa` kernel was migrated to
113+
/// `Tensor<T>` (matches the production C++ TQ+ fork pattern: fp16-
114+
/// stored norms / codebook, f32-at-use via cast-at-load).
115+
public let kCodebook: Tensor // [2^keyBits] dtype
116+
public let kBoundaries: Tensor // [2^keyBits-1] f32 — encoder-only Lloyd-Max thresholds
117+
public let vCodebook: Tensor // [2^valueBits] dtype
118+
public let vBoundaries: Tensor // [2^valueBits-1] f32 — encoder-only
114119

115120
// Per-cache compressed storage.
116121
public let kPacked: Tensor // [nKVHeads, maxSeq, kPackedWidth] u32
117122
public let vPacked: Tensor // [nKVHeads, maxSeq, vPackedWidth] u32
118-
public let kNorms: Tensor // [nKVHeads, maxSeq] f32
119-
public let vNorms: Tensor // [nKVHeads, maxSeq] f32
123+
public let kNorms: Tensor // [nKVHeads, maxSeq] dtype — encode writes T, decode reads T
124+
public let vNorms: Tensor // [nKVHeads, maxSeq] dtype
120125

121126
// Shared working buffers — bulk-dequant target; reused across layers.
122127
public let sharedWorkingK: Tensor // [nKVHeads, maxSeq, headDim] dtype
@@ -192,11 +197,17 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable {
192197
"AURAQuantizedKVCache: rotationDtype/rotationDtypeT dtype must match cache dtype \(dtype)"
193198
)
194199
precondition(
195-
kCodebook.dtype == .f32 && kBoundaries.dtype == .f32,
196-
"AURAQuantizedKVCache: K codebook/boundaries must be f32")
200+
kCodebook.dtype == dtype,
201+
"AURAQuantizedKVCache: K codebook dtype must match cache dtype \(dtype)")
197202
precondition(
198-
vCodebook.dtype == .f32 && vBoundaries.dtype == .f32,
199-
"AURAQuantizedKVCache: V codebook/boundaries must be f32")
203+
kBoundaries.dtype == .f32,
204+
"AURAQuantizedKVCache: K boundaries must be f32 (encoder-only)")
205+
precondition(
206+
vCodebook.dtype == dtype,
207+
"AURAQuantizedKVCache: V codebook dtype must match cache dtype \(dtype)")
208+
precondition(
209+
vBoundaries.dtype == .f32,
210+
"AURAQuantizedKVCache: V boundaries must be f32 (encoder-only)")
200211
precondition(
201212
sharedWorkingK.shape == [nKVHeads, maxSeq, headDim],
202213
"AURAQuantizedKVCache: sharedWorkingK shape mismatch")
@@ -232,9 +243,9 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable {
232243
self.vPacked = Tensor.empty(
233244
shape: [nKVHeads, maxSeq, vPackedWidth], dtype: .u32, device: device)
234245
self.kNorms = Tensor.empty(
235-
shape: [nKVHeads, maxSeq], dtype: .f32, device: device)
246+
shape: [nKVHeads, maxSeq], dtype: dtype, device: device)
236247
self.vNorms = Tensor.empty(
237-
shape: [nKVHeads, maxSeq], dtype: .f32, device: device)
248+
shape: [nKVHeads, maxSeq], dtype: dtype, device: device)
238249

239250
// Codec is purely additive in atomic_or terms, so packed slots
240251
// MUST start zeroed. Norms slots get overwritten per encode but
@@ -377,7 +388,10 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable {
377388
let inputBytesPerHead = headDim * dtype.byteSize
378389
let packedBytesPerSlot = packedWidth * 4 // u32
379390
let packedBytesPerHead = maxSeq * packedBytesPerSlot
380-
let normBytesPerHead = maxSeq * 4 // f32
391+
// Norms are stored in the cache dtype post-unification — stride
392+
// tracks the activation dtype's byte size, not the legacy 4 (f32).
393+
let normByteSize = dtype.byteSize
394+
let normBytesPerHead = maxSeq * normByteSize
381395

382396
for h in 0 ..< nKVHeads {
383397
let inputView = Tensor(
@@ -390,8 +404,8 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable {
390404
shape: [1, packedWidth], dtype: .u32)
391405
let normsView = Tensor(
392406
buffer: norms.buffer,
393-
offset: norms.offset + h * normBytesPerHead + pos * 4,
394-
shape: [1], dtype: .f32)
407+
offset: norms.offset + h * normBytesPerHead + pos * normByteSize,
408+
shape: [1], dtype: dtype)
395409
Ops.auraEncode(
396410
input: inputView, rotation: rotation,
397411
boundaries: boundaries, codebook: codebook,

Sources/FFAI/Loader/LoadOptions.swift

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,25 +57,31 @@ public enum DispatchMode: Sendable {
5757
/// Only relevant when `LoadOptions.kvCache == .auraQuantized(...)` —
5858
/// raw / affine caches ignore this setting.
5959
public enum AURADecodePath: Sendable, Equatable {
60-
/// **Default.** Compressed-domain attention via the
61-
/// `aura_flash_p1` + `aura_flash_pass2` kernel pair. Q is rotated,
60+
/// **Default.** Compressed-domain attention via the 2-pass FA-2
61+
/// kernel pair (`aura_flash_p1` + `aura_flash_pass2`) when emitted
62+
/// for the (keyBits, valueBits, headDim, dtype) combo, with the
63+
/// single-pass `aura_flash_sdpa` as fallback for cells the 2-pass
64+
/// kernel hasn't been emitted for. Q is rotated + pre-scaled,
6265
/// scored directly against the packed K codes (no full-precision
63-
/// dequant), then combined with the packed V codes — the kernel
64-
/// dequantises per-tile on chip, never materialising a maxSeq-sized
65-
/// f16 mirror buffer. Realises AURA's full memory savings (~4× at
66-
/// `aura4v2`).
66+
/// dequant), and the V codes are dequanted per-tile on chip. The
67+
/// `[nKVHeads, maxSeq, headDim]` mirror buffer never materialises,
68+
/// realising AURA's memory savings (~1.88× at aura4v4, ~3.7× at
69+
/// aura4v2 on Qwen3 d=128).
70+
///
71+
/// AURA-dtype unification (metaltile + FFAI joint change) put the
72+
/// per-token norms and per-scheme codebook into the activation
73+
/// dtype, so encode + both decode kernel paths consume the cache
74+
/// buffers directly — no per-call f32 cast on the decode hot path
75+
/// and no parallel f32 mirror storage.
6776
case compressed
6877

69-
/// Stage 1a behaviour. `prepareForAttention(on:)` dequantises the
70-
/// full compressed K/V cache into per-layer shared working buffers
71-
/// (`sharedWorkingK` / `sharedWorkingV`, sized
72-
/// `[nKVHeads, maxSeq, headDim]`), and the standard
73-
/// `Ops.sdpaDecode` reads those. Preserves AURA's quality but
74-
/// **gives back the memory savings** — the mirror is the same size
75-
/// as a raw fp16 cache. Kept as an opt-in path for A/B benching
76-
/// (`compressed` vs `dequantMirror` speed at production shapes)
77-
/// and for callers with the memory headroom who want
78-
/// matrix-engine SDPA.
78+
/// Dequant-mirror path. `prepareForAttention(on:)` materialises
79+
/// the full compressed K/V cache into per-layer shared working
80+
/// buffers (`sharedWorkingK` / `sharedWorkingV`, sized
81+
/// `[nKVHeads, maxSeq, headDim]`) and `Ops.sdpaDecode` reads those.
82+
/// Same quality as `.compressed`, **gives back the memory
83+
/// savings** — the mirror is the same size as a raw fp16 cache.
84+
/// Useful as an A/B baseline against the compressed path.
7985
case dequantMirror
8086
}
8187

@@ -113,13 +119,13 @@ public struct LoadOptions: Sendable {
113119
/// entire advertised window, or a smaller value to bound memory.
114120
public var maxContextLength: Int?
115121

116-
/// Selects the AURA decode path. Defaults to `.compressed` (Stage
117-
/// 1b: attend on packed K/V codes directly via the `aura_flash_*`
118-
/// kernel pair — full ~4× memory savings). Set to `.dequantMirror`
119-
/// for the Stage 1a path that maintains a full-precision
120-
/// `[nKVHeads, maxSeq, headDim]` mirror buffer and runs the
121-
/// standard `Ops.sdpaDecode` against it — useful for A/B speed
122-
/// benching. Has no effect when `kvCache != .auraQuantized(...)`.
122+
/// Selects the AURA decode path. Defaults to `.compressed` — the
123+
/// 2-pass FA-2 kernel pair gives token-parallel attention over the
124+
/// packed K/V codes directly, with no f16/f32 mirror materialised.
125+
/// Set to `.dequantMirror` for an A/B baseline that dequants the
126+
/// cache into a per-layer working buffer and runs the standard
127+
/// `Ops.sdpaDecode` against it. Has no effect when
128+
/// `kvCache != .auraQuantized(...)`.
123129
public var auraDecodePath: AURADecodePath
124130

125131
public init(

Sources/FFAI/Models/Text/LlamaText.swift

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -567,21 +567,18 @@ public final class LlamaModel: LanguageModel {
567567
// Codebooks are shared across layers; rotations are per-layer
568568
// (deterministic SRHT seeded by layer index). See Qwen3's
569569
// matching case for the longer explanation.
570-
let kCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.keyBits)
571-
let kBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.keyBits)
572-
let vCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.valueBits)
573-
let vBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.valueBits)
574-
575-
let kCodebook = Tensor.empty(shape: [kCodebookData.count], dtype: .f32, device: device)
576-
kCodebook.copyIn(from: kCodebookData)
577-
let kBoundaries = Tensor.empty(
578-
shape: [kBoundariesData.count], dtype: .f32, device: device)
579-
kBoundaries.copyIn(from: kBoundariesData)
580-
let vCodebook = Tensor.empty(shape: [vCodebookData.count], dtype: .f32, device: device)
581-
vCodebook.copyIn(from: vCodebookData)
582-
let vBoundaries = Tensor.empty(
583-
shape: [vBoundariesData.count], dtype: .f32, device: device)
584-
vBoundaries.copyIn(from: vBoundariesData)
570+
// Codebook in cache dtype (matches encode/decode kernel
571+
// signatures — no per-call cast). Boundaries stay f32:
572+
// encoder-only and precision-sensitive at the Lloyd-Max
573+
// comparison.
574+
let kCodebook = AURACodebook.centroidsTensor(
575+
dim: headDim, bits: scheme.keyBits, dtype: dtype, device: device)
576+
let kBoundaries = AURACodebook.boundariesTensor(
577+
dim: headDim, bits: scheme.keyBits, device: device)
578+
let vCodebook = AURACodebook.centroidsTensor(
579+
dim: headDim, bits: scheme.valueBits, dtype: dtype, device: device)
580+
let vBoundaries = AURACodebook.boundariesTensor(
581+
dim: headDim, bits: scheme.valueBits, device: device)
585582

586583
let sharedK = Tensor.empty(
587584
shape: [nKVHeads, cap, headDim],

Sources/FFAI/Models/Text/Qwen3Text.swift

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,13 @@ public final class Qwen3Layer: Module {
316316

317317
// Decode path selection:
318318
// • `.compressed` (default) — score Q directly against the
319-
// packed K codes via `aura_flash_sdpa`. V is dequanted per
320-
// tile on chip; no maxSeq-sized mirror is materialised.
319+
// packed K codes. V is dequanted per tile on chip; no
320+
// maxSeq-sized mirror is materialised. Two kernel variants:
321+
// – `auraFlashSdpa2Pass` when supported (token-parallel
322+
// FA-2; one TG per (q_head, block) — saturates the GPU
323+
// at long context).
324+
// – `auraFlashSdpa` (single-pass, one TG per q_head)
325+
// fallback for combos the 2-pass kernel hasn't emitted.
321326
// `kvStride = maxSeq` (NOT `length`) — the per-head row
322327
// stride is the allocated cache stride, not the live row
323328
// count. Passing `length` aliases reads across head
@@ -328,6 +333,44 @@ public final class Qwen3Layer: Module {
328333
// Non-AURA caches always take the dequantMirror branch.
329334
let attnOut: Tensor
330335
if let auraCache = cache as? AURAQuantizedKVCache,
336+
auraCache.decodePath == .compressed,
337+
Ops.supportsAuraFlashSdpa2Pass(
338+
keyBits: auraCache.scheme.keyBits,
339+
valueBits: auraCache.scheme.valueBits,
340+
headDim: headDim, dtype: h.dtype)
341+
{
342+
// FA-2 block tile. 64 is the canonical choice — matches
343+
// the dense `sdpaDecode2Pass` per-block work size and gives
344+
// ~16 q-heads × ceil(maxSeq/64) blocks of token-parallelism
345+
// (saturates the M5 Max class around liveLength ≈ 4K).
346+
let blockSize = 64
347+
let maxBlocks = (auraCache.maxSeq + blockSize - 1) / blockSize
348+
let partials = AuraFlashScratchCache.partials(
349+
nQHeads: nHeads, maxBlocks: maxBlocks,
350+
headDim: headDim, dtype: h.dtype)
351+
let outTensor = Tensor.empty(
352+
shape: [nHeads, headDim], dtype: h.dtype, device: device)
353+
Ops.auraFlashSdpa2Pass(
354+
q: qForSdpa,
355+
kPacked: auraCache.kPacked, kNorms: auraCache.kNorms,
356+
kCodebook: auraCache.kCodebook,
357+
vPacked: auraCache.vPacked, vNorms: auraCache.vNorms,
358+
vCodebook: auraCache.vCodebook,
359+
into: outTensor,
360+
nQHeads: nHeads, nKVHeads: nKVHeads, headDim: headDim,
361+
kPackedWidth: auraCache.kPackedWidth,
362+
vPackedWidth: auraCache.vPackedWidth,
363+
liveLength: auraCache.length, kvStride: auraCache.maxSeq,
364+
keyBits: auraCache.scheme.keyBits,
365+
valueBits: auraCache.scheme.valueBits,
366+
scale: scale,
367+
blockSize: blockSize,
368+
partialO: partials.partialO,
369+
partialM: partials.partialM,
370+
partialL: partials.partialL,
371+
on: cmd)
372+
attnOut = outTensor
373+
} else if let auraCache = cache as? AURAQuantizedKVCache,
331374
auraCache.decodePath == .compressed,
332375
Ops.supportsAuraFlashSdpa(
333376
keyBits: auraCache.scheme.keyBits,
@@ -338,9 +381,9 @@ public final class Qwen3Layer: Module {
338381
shape: [nHeads, headDim], dtype: h.dtype, device: device)
339382
// Qwen3 dense has no attention sinks — the kernel gates the
340383
// sinks load on `hasSinks=false`, but the wrapper still
341-
// requires an f32 buffer to satisfy the dtype precondition.
384+
// requires a same-dtype buffer to satisfy the precondition.
342385
let sinksScratch = Tensor.empty(
343-
shape: [nHeads], dtype: .f32, device: device)
386+
shape: [nHeads], dtype: h.dtype, device: device)
344387
Ops.auraFlashSdpa(
345388
q: qForSdpa, sinks: sinksScratch,
346389
kPacked: auraCache.kPacked, kNorms: auraCache.kNorms,
@@ -645,21 +688,17 @@ public final class Qwen3Model: LanguageModel {
645688
// are per-layer: each Π_l is an SRHT matrix seeded by the
646689
// layer index, matching the AURA paper's "fresh rotation per
647690
// tensor" recipe for de-correlating activation statistics.
648-
let kCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.keyBits)
649-
let kBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.keyBits)
650-
let vCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.valueBits)
651-
let vBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.valueBits)
652-
653-
let kCodebook = Tensor.empty(shape: [kCodebookData.count], dtype: .f32, device: device)
654-
kCodebook.copyIn(from: kCodebookData)
655-
let kBoundaries = Tensor.empty(
656-
shape: [kBoundariesData.count], dtype: .f32, device: device)
657-
kBoundaries.copyIn(from: kBoundariesData)
658-
let vCodebook = Tensor.empty(shape: [vCodebookData.count], dtype: .f32, device: device)
659-
vCodebook.copyIn(from: vCodebookData)
660-
let vBoundaries = Tensor.empty(
661-
shape: [vBoundariesData.count], dtype: .f32, device: device)
662-
vBoundaries.copyIn(from: vBoundariesData)
691+
// Codebook in cache dtype (matches encode/decode kernel
692+
// signatures — no per-call cast). Boundaries stay f32:
693+
// encoder-only and precision-sensitive at Lloyd-Max compare.
694+
let kCodebook = AURACodebook.centroidsTensor(
695+
dim: headDim, bits: scheme.keyBits, dtype: dtype, device: device)
696+
let kBoundaries = AURACodebook.boundariesTensor(
697+
dim: headDim, bits: scheme.keyBits, device: device)
698+
let vCodebook = AURACodebook.centroidsTensor(
699+
dim: headDim, bits: scheme.valueBits, dtype: dtype, device: device)
700+
let vBoundaries = AURACodebook.boundariesTensor(
701+
dim: headDim, bits: scheme.valueBits, device: device)
663702

664703
// Shared working buffers — same pattern as affineQuantized:
665704
// bulk-dequant target shared across all layers.

0 commit comments

Comments
 (0)