Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
121 commits
Select commit Hold shift + click to select a range
ca51ade
feat(stubs+ops): rebuild against metaltile-emit naming overhaul
TheTom May 21, 2026
be2b85a
feat(ffai): wire bm8 BGEMM into MoELayer + add dequantGemmDynamicM dr…
TheTom May 21, 2026
8bd1ba9
fix(qwen35): restore in-flight Qwen3.6 load-path fixes
TheTom May 21, 2026
b60e2c4
feat(qwen35): fused GDN prep step path via Ops.gatedDeltaPrepStep
TheTom May 21, 2026
eb6e11b
feat(qwen35): fused gated mixer norm path — drop phase-2 host loop
TheTom May 21, 2026
77bf7b0
perf(qwen35,moe): drop redundant per-layer host stalls — let Metal pi…
TheTom May 21, 2026
71cb172
perf(qwen35): chain fused-GDN mixer + residual + FFN on one cmd buffer
TheTom May 21, 2026
6adb7a8
perf(qwen35): drop scaleBySigmoidGate35 host wait — +4 % decode
TheTom May 21, 2026
c20b0b3
perf(qwen35): fused mt_sigmoid_scalar_fma — drop shared-expert host d…
TheTom May 21, 2026
4e936ac
refactor(qwen35): drop dead scaleBySigmoidGate35 helper
TheTom May 21, 2026
7a08300
feat(ops): Ops.dequantGemvIndirect + GPU correctness tests
TheTom May 21, 2026
88e2f5a
perf(moe): fused mt_swiglu in MoELayer expert path — decode +6.1 % (5…
TheTom May 21, 2026
e5c75d6
feat(moe): Ops.moeGatherDequantGemmInt4M1 + FFAI_MOE_M1=1 wiring (neg…
TheTom May 21, 2026
9e1956c
perf(qwen35): pre-allocate GDN scratch tensors on mixer — decode +3.5 %
TheTom May 21, 2026
d270529
perf(moe): MoERouter.topKIndices O(n log n) → O(n log k) — decode +6.…
TheTom May 21, 2026
e087e33
perf(moe): MoERouter fast-path — skip full softmax when normTopKProb …
TheTom May 21, 2026
5fe03fb
refactor(moe): cache env-flag lookups at MoELayer init
TheTom May 21, 2026
d782573
feat(qwen35): sliceHeadHalvesMany35 — T-batched gate-split gather
TheTom May 21, 2026
8f46d4a
feat(layers): AnyLinear.callMany — T-batched projection dispatch
TheTom May 21, 2026
7edca2c
feat(qwen35): Qwen35AttentionMixer.forwardMany — T-batched mixer
TheTom May 21, 2026
2a52d52
feat(qwen35): Qwen35AttentionLayer.decodeMany — batched mixer + per-r…
TheTom May 21, 2026
c59d20c
feat(qwen35,ops): batched forwardMany + Ops.copy blit + equivalence test
TheTom May 21, 2026
ce18ac6
fix(ops): dequantGemmDynamicM slow-path blits on cmd, not host memcpy
TheTom May 21, 2026
128f093
test(qwen36): forwardManyBench harness — A/B prefill batched vs per-t…
TheTom May 21, 2026
b48acfb
feat(qwen35): GDN forwardMany + decodeMany — prefill +21% at T=32
TheTom May 21, 2026
ae17e5a
feat(moe,qwen35): MoELayer.decodeMany + FFN forwardMany — prefill +26…
TheTom May 21, 2026
f99f0da
feat(ops,moe): moeGatherDequantGemmInt4Bm64Mpp wrapper (default off, …
TheTom May 21, 2026
e3f5c98
docs(ops): document bm64_mpp correctness issue + correct grid shape
TheTom May 22, 2026
2ae8b47
feat(moe,ops): fused mt_moe_unpermute scatter-sum — prefill 62 → 84 t…
TheTom May 22, 2026
cc55491
test(qwen36): proper batched warmup in forwardManyBench — 83 → 92 tps
TheTom May 22, 2026
a8a2d2d
test(qwen36): T=128 forwardMany bench + 2-iter warmup — 8.5× at T=128
TheTom May 22, 2026
ed75ea4
test(qwen36): T=512 forwardMany bench — 16.74× speedup, 142.11 tps
TheTom May 22, 2026
2149ee6
perf(qwen35): in-place silu in GDN inner loop — T=512 +6%, T=128 +22%
TheTom May 22, 2026
9e9c263
perf(ops,qwen35): Ops.siluCastToF32 — T=512 prefill 150 → 157 tps (+4%)
TheTom May 22, 2026
ce0b2b0
adapt(stubs,ops): align FFAI with metaltile-emit drop in 0xClandestin…
TheTom May 22, 2026
d4a683f
fix(metaltile): live-compile MPP kernels in PSOCache to dodge SDK skew
TheTom May 22, 2026
a9f0ed5
merge(aura-port): resolve conflicts; gate Qwen3.5 VL arch through tex…
TheTom May 22, 2026
6352549
fix: resolve PR #9 merge regressions against metaltile #145
ekryski May 22, 2026
634f732
fix: drop FFAIStubs.swift — indirect kernels now emitted by tile emit
ekryski May 22, 2026
144c07c
perf(moe): pre-dequantize 8-bit gate + mt_steel_gemm batched dispatch
TheTom May 22, 2026
87e7460
test(qwen36): T=128 forwardMany equivalence pins steel-gemm gate path
TheTom May 22, 2026
01ac37f
perf(moe): hybrid steel-gemm + ffai-gemm dispatch for 8-bit gate
TheTom May 22, 2026
5a20250
perf(qwen35): single cmd-buffer for shared-expert sigmoidScalarFMA fa…
TheTom May 22, 2026
c84c68a
perf(qwen35): batched aRaw/bRaw castToF32 out of GDN T-loop
TheTom May 22, 2026
426043f
perf(moe): parallelise router.route across T tokens
TheTom May 22, 2026
c88751b
perf(device): pin model weights in MTLResidencySet after load
TheTom May 22, 2026
7290cbb
perf(caches): pin KV / GDN / conv state buffers in residency set
TheTom May 22, 2026
1e2ff82
perf(qwen35): chunked GDN prep+recurrence lands +22% T=512 prefill
TheTom May 22, 2026
bf95d4e
perf(qwen35): T-batched mixer norm in chunked GDN path lands +2% T=512
TheTom May 22, 2026
4cb308e
docs(qwen35): record why silu+cast stays per-row in chunked GDN path
TheTom May 22, 2026
c4d9f7f
perf(qwen35): flip chunked GDN prep+recurrence to default-on
TheTom May 22, 2026
c025bb5
test(qwen36): add forwardManyBench2K + 4K (batched-only, skip per-tok…
TheTom May 22, 2026
c22d412
profile(qwen35): per-layer wallclock + forwardManyProfile512 test
TheTom May 22, 2026
51f9365
perf(moe): flip bm64_mpp NAX MoE BGEMM default-on at mTotal ≥ 64
TheTom May 23, 2026
0ee6221
test(qwen36): add forwardManyProfile2K + parameterise profile helper
TheTom May 23, 2026
8e4b834
profile(qwen35): decode T=1 phase breakdown — MoE FFN is 87% per-step
TheTom May 23, 2026
2cbd2dc
fix(moe): bump bm64_mpp threshold from mTotal ≥ 64 to ≥ 256
TheTom May 23, 2026
137bbe4
test(qwen36): add forwardManyBench T=2/4/8/16 spec-decode ceiling probes
TheTom May 23, 2026
6cb6448
feat(qwen35): forwardManyAllLogits — per-position logits for spec-decode
TheTom May 23, 2026
19b109c
fix(moe): re-tune bm64_mpp threshold mTotal ≥ 256 → ≥ 1024
TheTom May 23, 2026
bcc4e88
docs(ops): bm16_mpp bf16 variant negative result + cite-in-source
TheTom May 23, 2026
b05f5ed
test(qwen36): add forwardManyBench8K long-context ceiling
TheTom May 23, 2026
650e65d
perf(pso): enable supportIndirectCommandBuffers on all kernel PSOs
TheTom May 23, 2026
75e6ec8
test(metaltileswift): ICB smoke — verify recorded compute commands ex…
TheTom May 23, 2026
46dc4d7
test(metaltileswift): ICB-replay perf demo — 5.43× over direct encode…
TheTom May 23, 2026
4ba0697
test(metaltileswift): smoke for auto-generated `_record` ICB wrappers
TheTom May 23, 2026
1fb8643
feat(metaltileswift): ICBRecorder helper + 600-command chain smoke
TheTom May 23, 2026
45677c7
test(metaltileswift): realistic per-dispatch ICB bench
TheTom May 23, 2026
595bacc
test(metaltileswift): real-decode-shape qmm ICB bench — 2.82× per-dis…
TheTom May 23, 2026
f031eb4
feat(gdn): GDNStateCache.snapshot/restore — spec-decode rollback prim…
TheTom May 23, 2026
5711736
feat(conv): ConvStateCache.snapshot/restore — spec-decode rollback fo…
TheTom May 23, 2026
758ae54
feat(cache): CacheSnapshot — composite snapshot/restore over [any Lay…
TheTom May 23, 2026
1ede6c1
feat(spec-decode): end-to-end greedy spec-decode driver — γ=2 lands 1…
TheTom May 23, 2026
d11a47a
feat(opsicb): Ops-layer ICB recording proves 4.19× per-dispatch reduc…
TheTom May 23, 2026
9d8a9dc
feat(moe): scalarFMA replaces Tensor.filled+mul+add chain — +18% deco…
TheTom May 23, 2026
48660db
test(spec-decode): verify-cost bench — batched is 2.2× faster at T=3 …
TheTom May 23, 2026
35cf6de
docs(drafter): document unigram-fallback negative result — break-even…
TheTom May 23, 2026
1bbadd7
test(ane): ANEMTPInspectTests — Day 1 mlpackage load + schema dump
TheTom May 23, 2026
428543e
test(ane): ANEMTPValidation — Day 2 end-to-end harness + forwardWithH…
TheTom May 23, 2026
a0c1d76
test(ane): MTP validation harness — A/B knobs for pre-norm + ground-t…
TheTom May 23, 2026
52e2a0c
perf(gdn): castToF32Three batches 3 GDN casts into one encoder — +0.7…
TheTom May 23, 2026
903327e
perf(moe): cache accumulator + topK scalars per layer — +2% decode (1…
TheTom May 23, 2026
4a82e34
perf(gdn): fuse silu+cast for convAct via siluCastToF32 in fused-prep…
TheTom May 23, 2026
c3f8821
perf(rmsnorm): cache eps buffer by value — +4.3% decode (~19.5→20.34 …
TheTom May 23, 2026
dc9f12d
perf(layernorm): reuse cached eps buffer (ITER 6) — consistency with …
TheTom May 23, 2026
d5b2db7
perf(qlin): ITER 7 negative — outScratch caching regresses ~1.5%
TheTom May 23, 2026
71b113a
perf(moe): auto-disable BGEMM at decode T=1 — 2× speedup (20.34 → ~42…
TheTom May 23, 2026
bbc672c
perf(kv): batch K+V kvCacheUpdate into shared encoder (ITER 10)
TheTom May 23, 2026
4b6fea7
perf(attn): fused mt_sigmoid_mul kernel for attn output gate (ITER 11)
TheTom May 23, 2026
0c2f02b
perf(attn): batch q_norm + k_norm rmsNormRows in shared encoder (ITER…
TheTom May 23, 2026
525fcd9
perf(attn): use sigmoidMul on prefill path too (ITER 14)
TheTom May 23, 2026
d439c67
perf(moe): dense fp16 gate gemv at decode T=1 (ITER 15)
TheTom May 23, 2026
2c4460a
perf(qwen35): use Ops.swiglu in Qwen35DenseMLP + MoE shared expert (I…
TheTom May 23, 2026
71460d8
perf(qwen35): use Ops.swiglu in MoE shared expert too (ITER 16 cont.)
TheTom May 23, 2026
81c7c39
perf(models): use Ops.swiglu across Granite/LFM2/Jamba (ITER 17 — cov…
TheTom May 23, 2026
8356499
perf(gdn): default FFAI_GDN_FUSED_PREP to ON (ITER 19) — 4.5× speedup
TheTom May 23, 2026
de223f8
perf(metaltile): default maxCommandBufferCount 16 → 64 (ITER 20)
TheTom May 23, 2026
e7b7470
perf(attn): ropePartialTwo shares encoder for q + k (ITER 21)
TheTom May 23, 2026
942a087
perf(attn): cache sliceHeadHalves35 idx tensors at init (ITER 22)
TheTom May 23, 2026
d241626
perf(gdn): batched 4-projection shared-encoder dequantGemv (ITER 23)
TheTom May 23, 2026
c8db3ee
perf(attn): batched 3-projection shared-encoder QKV proj (ITER 24)
TheTom May 23, 2026
bda196b
perf(moe): batched 2-projection shared-encoder gate+up qmm (ITER 25) …
TheTom May 23, 2026
084fbc1
perf(moe-shared): batched gate+up shared-encoder qmm (ITER 26)
TheTom May 23, 2026
36950b3
perf(gdn): pin batched-input scratches in MTLResidencySet (ITER 27)
TheTom May 23, 2026
65f373c
perf(attn): pin sliceHeadHalves35 idx scratches (ITER 28)
TheTom May 23, 2026
65dce1c
perf(moe): batched 8-expert down qmm via dequantGemvInt4Many — +4% de…
TheTom May 23, 2026
ecac6cc
perf(moe): batched 8-expert swiglu shared encoder (ITER 30)
TheTom May 23, 2026
78f648e
perf(moe): batch ALL 16 (gate+up) qmms across 8 experts via dequantGe…
TheTom May 23, 2026
9a54531
Revert ITER 29/30/31 — OOM during decode loop bench
TheTom May 23, 2026
e8922dc
perf(moe): cache per-expert g/u scratches at init — fixes OOM (ITER 32)
TheTom May 23, 2026
85e26c7
perf(snapshot): cache snapshot tensors in GDN/Conv state caches — lea…
TheTom May 23, 2026
6328fa0
perf(moe-shared): cache shared expert scratches (sg, su, result) — le…
TheTom May 23, 2026
e23d6fe
perf(attn): cache q_norm/k_norm output scratches (ITER 35)
TheTom May 23, 2026
e240c59
perf(moe): cache per-expert swiglu inner scratches (ITER 36)
TheTom May 23, 2026
8e50a2e
perf(moe): re-do ITER 29 with cached scratches — batched 8-expert dow…
TheTom May 23, 2026
2ada9a1
perf(moe): re-do ITER 30 — batched 8-expert swiglu in ONE encoder (IT…
TheTom May 23, 2026
e411385
perf(attn): cache sliceHeadHalves35 gather outputs (ITER 40)
TheTom May 23, 2026
6bae6cb
perf(layers): cache postMix + result residual-add scratches (ITER 41)
TheTom May 23, 2026
ce24e1b
perf(moe): cache dense gate gemv output (ITER 42)
TheTom May 23, 2026
9d468c1
ITER 43: cache sdpaDecode + sigmoidMul outputs (Qwen35AttentionMixer)
TheTom May 23, 2026
3758b2d
cleanup: drop 3 unused dt/dtBytes locals in batched-prefill helpers
TheTom May 23, 2026
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
117 changes: 117 additions & 0 deletions Sources/FFAI/CacheSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// CacheSnapshot — composite snapshot/restore over `[any LayerCacheProtocol]`.
//
// Speculative decode needs to roll back ALL of a model's per-layer
// caches on draft rejection: KVCache (attention), GDNStateCache +
// ConvStateCache (GDN). This file gives the spec-decode driver one
// call to snapshot every layer + one call to restore.
//
// Per-cache-kind snapshot payload:
// * KVCache → `length` integer only (the KV slots
// beyond `length` are unused; appending
// after restore overwrites them).
// * GDNStateCache → Tensor snapshot of `current` +
// `length` integer.
// * ConvStateCache → Tensor snapshot of `state`.
// * Qwen35GDNLayerCache → both of the above (conv + GDN +
// shared `length`).
//
// Cost on Qwen3.6-A3B: 60 MiB GDN state snapshots + 900 KiB conv
// snapshots + ~zero attention metadata = ~61 MiB per spec step. On
// Apple silicon unified memory at ~400 GB/s that's ~150 µs in
// aggregate blit time — negligible vs ~60 ms decode step.

import Foundation

/// Per-layer snapshot. The enum cases match the concrete cache types
/// the model layer slots take; the spec-decode driver routes per
/// layer.
public enum LayerCacheSnapshot {
case kv(length: Int, absolutePosition: Int)
case gdn(currentState: Tensor, length: Int)
case conv(state: Tensor)
case gdnLayer(conv: Tensor, gdnState: Tensor, length: Int)
}

public extension Array where Element == any LayerCacheProtocol {
/// Snapshot every layer cache. Cost is ~150 µs aggregate blit time
/// on Qwen3.6-A3B (60 MiB GDN + 900 KB conv).
func snapshotAll(device: Device = .shared) -> [LayerCacheSnapshot] {
return self.map { cache -> LayerCacheSnapshot in
if let composite = cache as? Qwen35GDNLayerCache {
return .gdnLayer(
conv: composite.conv.snapshot(device: device),
gdnState: composite.gdn.snapshot(device: device),
length: composite.length)
}
if let gdn = cache as? GDNStateCache {
return .gdn(
currentState: gdn.snapshot(device: device),
length: gdn.length)
}
if let conv = cache as? ConvStateCache {
return .conv(state: conv.snapshot(device: device))
}
if let kv = cache as? KVCache {
return .kv(length: kv.length, absolutePosition: kv.absolutePosition)
}
preconditionFailure(
"CacheSnapshot: unhandled LayerCacheProtocol subtype \(type(of: cache)). Add a case if a new cache kind ships."
)
}
}

/// Restore every layer cache from a snapshot taken via `snapshotAll`.
/// Caller must pass the same `[any LayerCacheProtocol]` instance
/// the snapshot came from — order + identity must match.
func restoreAll(from snapshots: [LayerCacheSnapshot],
device: Device = .shared) {
precondition(self.count == snapshots.count,
"CacheSnapshot.restoreAll: layer count mismatch (caches=\(self.count), snapshots=\(snapshots.count))")
for (cache, snap) in zip(self, snapshots) {
switch (cache, snap) {
case let (composite as Qwen35GDNLayerCache, .gdnLayer(convT, gdnT, length)):
composite.conv.restore(from: convT, device: device)
composite.gdn.restore(from: gdnT, device: device)
// Truncate `length` back to the snapshotted value.
// Qwen35GDNLayerCache only exposes `advance()`; rewind
// by re-creating the counter via internal state — see
// the type's `restoreLength` accessor below.
composite.restoreLength(to: length)
case let (gdn as GDNStateCache, .gdn(currentT, length)):
gdn.restore(from: currentT, device: device)
gdn.restoreLength(to: length)
case let (conv as ConvStateCache, .conv(stateT)):
conv.restore(from: stateT, device: device)
case let (kv as KVCache, .kv(length, _)):
kv.truncate(toLength: length)
default:
preconditionFailure(
"CacheSnapshot.restoreAll: cache/snapshot mismatch at layer — cache=\(type(of: cache)), snapshot=\(snap)"
)
}
}
}
}

// `length` rewind helpers — `swap()` / `advance()` only move forward.
// Spec decode needs to move backward on reject.

public extension GDNStateCache {
/// Rewind the position counter without touching tensor state.
/// Used by spec-decode AFTER the buffers have been restored via
/// `restore(from:)`. Was previously implemented as reset+swap loop,
/// which zeroed the state buffers — see `setLength` for the safe
/// path.
func restoreLength(to length: Int) {
self.setLength(length)
}
}

public extension Qwen35GDNLayerCache {
/// Composite-level length-only restore. Tensor state is restored
/// separately by the caller; this fixes the position counter
/// without disturbing the just-restored conv/gdn buffers.
func restoreLength(to length: Int) {
self.setLength(length)
}
}
60 changes: 60 additions & 0 deletions Sources/FFAI/ConvStateCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,71 @@ public final class ConvStateCache: @unchecked Sendable {
self.state = Tensor.empty(shape: [kernelSize - 1, nChannels],
dtype: dtype, device: device)
self.state.zero()
// Conv state lives for the lifetime of a generation. Pin it in
// the device residency set — conv1dCausalStep touches it every
// token, every layer.
device.markWeightsResident([self.state.buffer])
}

/// Reset to zero. Used between sessions; cheap (small fp16/bf16 buffer).
public func reset() { state.zero() }

/// Snapshot the current rolling window to a fresh `Tensor`. Used by
/// speculative decode — the conv1d_causal_step kernel mutates this
/// window in-place each token (drops state[0], appends current
/// input as state[K-2]), and the mutation isn't trivially
/// reversible without remembering the exact dropped slot and old
/// state contents. Snapshot/restore is the cleanest rollback.
///
/// Cost: `(kernelSize - 1) * nChannels` × dtype-size bytes per
/// layer. At Qwen3.6-A3B convDim=5120, kernelSize=4, bf16:
/// 3 * 5120 * 2 = 30 720 bytes per layer × 30 GDN layers ≈ 900 KB
/// — small enough to snapshot every spec step without measurable
/// host overhead.
/// Cached snapshot tensor (ITER 33 — bound spec-decode churn).
private var cachedSnapshot: Tensor?

public func snapshot(device: Device = .shared) -> Tensor {
if cachedSnapshot == nil {
cachedSnapshot = Tensor.empty(shape: [kernelSize - 1, nChannels],
dtype: dtype, device: device)
}
let snap = cachedSnapshot!
let cmd = device.makeCommandBuffer()
guard let blit = cmd.makeBlitCommandEncoder() else {
preconditionFailure("ConvStateCache.snapshot: makeBlitCommandEncoder failed")
}
let bytes = (kernelSize - 1) * nChannels * dtype.byteSize
blit.copy(from: state.buffer, sourceOffset: state.offset,
to: snap.buffer, destinationOffset: snap.offset,
size: bytes)
blit.endEncoding()
cmd.commit()
cmd.waitUntilCompleted()
return snap
}

/// Restore the rolling window from a snapshot. Overwrites the
/// state buffer with the snapshot contents.
public func restore(from snapshot: Tensor, device: Device = .shared) {
let expected = (kernelSize - 1) * nChannels
precondition(snapshot.elementCount == expected,
"ConvStateCache.restore: snapshot has \(snapshot.elementCount) elements, expected \(expected)")
precondition(snapshot.dtype == dtype,
"ConvStateCache.restore: snapshot dtype \(snapshot.dtype) ≠ state dtype \(dtype)")
let cmd = device.makeCommandBuffer()
guard let blit = cmd.makeBlitCommandEncoder() else {
preconditionFailure("ConvStateCache.restore: makeBlitCommandEncoder failed")
}
let bytes = expected * dtype.byteSize
blit.copy(from: snapshot.buffer, sourceOffset: snapshot.offset,
to: state.buffer, destinationOffset: state.offset,
size: bytes)
blit.endEncoding()
cmd.commit()
cmd.waitUntilCompleted()
}

/// Bytes occupied by the rolling window. Constant w.r.t. sequence
/// length — that's the streaming-decode design.
public var bytesAllocated: Int {
Expand Down
43 changes: 43 additions & 0 deletions Sources/FFAI/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ public final class Device: @unchecked Sendable {
public let mtlDevice: MTLDevice
public let commandQueue: MTLCommandQueue

/// Lazy MTLResidencySet holding the model's weight buffers. Marked
/// resident after `Model.load` finishes; saves per-command-buffer
/// residency tracking on every prefill / decode dispatch. Disable
/// via `FFAI_NO_RESIDENCY_SET=1`. Stored as `Any?` so the deployment
/// target stays < macOS 15; the actual cast lives inside the
/// `@available` block below.
private var weightResidencySet: Any?
private let residencyLock = NSLock()

public static let shared: Device = {
// Reuse the same MTLDevice + queue MetalTileSwift uses, so PSOs
// and buffers are guaranteed compatible.
Expand Down Expand Up @@ -37,4 +46,38 @@ public final class Device: @unchecked Sendable {
}
return cb
}

/// Mark `buffers` as permanently resident so every command buffer
/// the queue runs skips per-allocation residency tracking. Apple's
/// Metal driver otherwise re-validates a buffer's residency state at
/// each encode (the cost shows as host gap when there are tens of
/// thousands of small dispatches per prefill). One residency set
/// is shared across all weight buffers; subsequent calls add to it.
/// Requires macOS 15+ / iOS 18+. Opt-out via `FFAI_NO_RESIDENCY_SET=1`.
public func markWeightsResident(_ buffers: [MTLBuffer]) {
if ProcessInfo.processInfo.environment["FFAI_NO_RESIDENCY_SET"] != nil { return }
guard #available(macOS 15.0, iOS 18.0, *) else { return }
residencyLock.lock()
defer { residencyLock.unlock() }
if weightResidencySet == nil {
let descriptor = MTLResidencySetDescriptor()
descriptor.label = "FFAI weights"
descriptor.initialCapacity = max(buffers.count, 1024)
do {
let set = try mtlDevice.makeResidencySet(descriptor: descriptor)
commandQueue.addResidencySet(set)
weightResidencySet = set
} catch {
// Driver couldn't create the set — fall back silently.
weightResidencySet = nil
return
}
}
guard let set = weightResidencySet as? MTLResidencySet else { return }
for buf in buffers {
set.addAllocation(buf)
}
set.commit()
set.requestResidency()
}
}
114 changes: 114 additions & 0 deletions Sources/FFAI/Drafter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Drafter — interface for speculative-decode candidate proposal.
//
// A drafter takes the token history so far and proposes one or more
// candidate next tokens. The target model then verifies via
// `forwardManyAllLogits([lastAccepted, ...candidates])` and either
// accepts (commit candidates) or rejects (restore caches, commit the
// model's actual choice from the verify logits).
//
// Implementations live alongside this protocol:
// * `NGramDrafter` — prompt-lookup n-gram. Zero ML cost.
// * `ANEMTPDrafter` — runs the trained MTP head on the ANE via the
// mlpackage built at /Users/tom/models/Qwen3.6-35B-A3B-mtp.mlpackage.
// (Implementation lands when the ANEDrafter Swift wrapper is wired.)
// * `GreedyDrafter` — stub that always returns nil. Useful for
// bench-comparing the spec-decode driver overhead vs raw decode.

import Foundation

/// A drafter proposes candidate next tokens for the target model to
/// verify in a speculative-decode loop.
public protocol Drafter: AnyObject {
/// Propose up to `gamma` candidate tokens. Implementations may
/// return fewer (or `nil` / empty) if they can't make a confident
/// proposal — the driver falls back to a plain decode step in
/// that case.
///
/// `history` is the full sequence so far (prompt + generated).
/// `gamma` is the maximum candidates the driver is asking for.
func propose(history: [Int], gamma: Int) -> [Int]
}

/// Prompt-lookup n-gram drafter — zero ML cost; works well on
/// repetitive contexts (code, structured chat).
///
/// Algorithm (matches the typical prompt-lookup decoding paper):
/// 1. Take the last `nMatch` tokens of `history` as the lookup key.
/// 2. Scan backwards through `history` looking for a previous
/// occurrence of that key.
/// 3. If found, return the next `gamma` tokens AFTER that earlier
/// occurrence — those are the candidate next tokens.
/// 4. If not found, fall back to shorter keys (`nMatch - 1`,
/// `nMatch - 2`, ...) before giving up.
///
/// Common heuristic: start with `nMatch = 3` (trigrams) and fall back
/// to bigrams + unigrams.
public final class NGramDrafter: Drafter {
/// Largest match length to try first. Falls back to shorter lengths
/// if no longer match is found. Typical: 3 (trigram).
public let maxNMatch: Int
/// Smallest match length the drafter will try before giving up.
/// Default 1 (unigram lookup is usually too noisy; raising to 2
/// avoids spurious draft.)
public let minNMatch: Int

public init(maxNMatch: Int = 3, minNMatch: Int = 2) {
precondition(maxNMatch >= minNMatch && minNMatch >= 1,
"NGramDrafter: maxNMatch (\(maxNMatch)) must be ≥ minNMatch (\(minNMatch)) ≥ 1")
self.maxNMatch = maxNMatch
self.minNMatch = minNMatch
}

public func propose(history: [Int], gamma: Int) -> [Int] {
precondition(gamma >= 0, "NGramDrafter.propose: gamma must be ≥ 0")
guard gamma > 0, !history.isEmpty else { return [] }

// Try longest match first, fall back to shorter. Returns the
// FIRST (most-recent) occurrence's continuation. High accept
// probability when the trigram repeats.
//
// Empirically (Qwen3.6-A3B + bench in SpecDecodeBenchTests):
// trigram acceptance ~83%, bigram ~50%, unigram-frequency ~35%.
// Spec-decode break-even acceptance at γ=2 with the current
// verify-cost ratio is ~70% — so unigram is a NET LOSS even
// though it increases proposal rate. Better to return [] and
// let the driver run a single decode step (52 ms) than waste
// a 77 ms batched verify on a low-confidence proposal.
for nMatch in stride(from: maxNMatch, through: minNMatch, by: -1) {
guard history.count >= nMatch else { continue }
let keyStart = history.count - nMatch
let key = Array(history[keyStart..<history.count])
// Scan backwards from just-before the key (so we don't
// match the key against itself).
var probe = keyStart - 1
while probe >= nMatch - 1 {
if matches(history, at: probe - (nMatch - 1), key: key) {
let candidateStart = probe + 1
let candidateEnd = Swift.min(candidateStart + gamma,
history.count)
if candidateEnd > candidateStart {
return Array(history[candidateStart..<candidateEnd])
}
}
probe -= 1
}
}
return []
}

@inline(__always)
private func matches(_ history: [Int], at start: Int, key: [Int]) -> Bool {
if start < 0 || start + key.count > history.count { return false }
for i in 0..<key.count {
if history[start + i] != key[i] { return false }
}
return true
}
}

/// Stub drafter that never proposes anything. Used to measure pure
/// spec-decode driver overhead against the no-spec baseline.
public final class NeverDrafter: Drafter {
public init() {}
public func propose(history: [Int], gamma: Int) -> [Int] { [] }
}
Loading