mlx-bun 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +4 -1
- package/docs/archive/investigations/openjarvis-fit.md +198 -0
- package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
- package/docs/design/batching.md +1321 -49
- package/docs/design/decode-speed-program.md +331 -1163
- package/docs/design/generic-model-support.md +79 -3
- package/docs/design/kv-cache.md +198 -54
- package/docs/design/speculative-decoding.md +387 -24
- package/docs/design/turboquant.md +123 -13
- package/docs/design/unified-engine-frontier-plan.md +144 -142
- package/docs/planning/PRODUCT_ROADMAP.md +16 -0
- package/docs/planning/release-notes-v0.4.0.md +45 -0
- package/docs/reference/benchmarks.md +2457 -6
- package/docs/reference/cli.md +41 -2
- package/docs/reference/distribution.md +16 -9
- package/docs/reference/environment.md +55 -5
- package/docs/reference/models.md +13 -4
- package/docs/reference/server-api.md +79 -25
- package/docs/reference/server-config.md +178 -84
- package/docs/reference/troubleshooting.md +13 -21
- package/package.json +1 -1
- package/src/backends/mlx/assistant-target.ts +46 -0
- package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
- package/src/backends/mlx/batch-group.ts +368 -224
- package/src/backends/mlx/cache-attachment.ts +67 -0
- package/src/backends/mlx/cache-layout.ts +31 -0
- package/src/backends/mlx/checkpoint-state.ts +53 -0
- package/src/backends/mlx/continuation-persistence.ts +106 -0
- package/src/backends/mlx/continuation-request.ts +43 -0
- package/src/backends/mlx/continuation.ts +77 -0
- package/src/backends/mlx/draft-target.ts +7 -22
- package/src/backends/mlx/full-kv-row-append.ts +32 -0
- package/src/backends/mlx/full-kv-row-donor.ts +54 -0
- package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
- package/src/backends/mlx/gateway-binding.ts +77 -18
- package/src/backends/mlx/kv-codec.ts +9 -0
- package/src/backends/mlx/kv-maintenance.ts +133 -0
- package/src/backends/mlx/kv-tensor-rows.ts +212 -0
- package/src/backends/mlx/model-serving.ts +1 -1
- package/src/backends/mlx/prefill-cohort.ts +89 -0
- package/src/backends/mlx/prefill-rows.ts +200 -0
- package/src/backends/mlx/prefill.ts +1 -1
- package/src/backends/mlx/projected-context-rows.ts +72 -0
- package/src/backends/mlx/request-state-policy.ts +41 -0
- package/src/backends/mlx/rollback.ts +41 -3
- package/src/backends/mlx/row-sampling.ts +49 -0
- package/src/backends/mlx/serial-executor.ts +35 -66
- package/src/backends/mlx/speculative-group.ts +359 -0
- package/src/backends/mlx/speculative-round.ts +161 -0
- package/src/backends/mlx/state-rows.ts +104 -0
- package/src/backends/mlx/state-views.ts +21 -1
- package/src/backends/mlx/target-layout-capability.ts +35 -0
- package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
- package/src/cli.ts +11 -36
- package/src/contracts/execution.ts +11 -0
- package/src/contracts/prefix-cache.ts +23 -0
- package/src/contracts/resources.ts +7 -0
- package/src/contracts/sampling.ts +23 -0
- package/src/contracts/scheduling.ts +18 -0
- package/src/engine/execution-plan.ts +18 -16
- package/src/engine/resources.ts +12 -1
- package/src/engine/scheduler.ts +10 -5
- package/src/fit.ts +29 -15
- package/src/generate.ts +77 -173
- package/src/inference/draft-acceptance.ts +41 -0
- package/src/inference/draft-output.ts +49 -0
- package/src/inference/rollback.ts +2 -2
- package/src/kv-scheme.ts +70 -12
- package/src/kv-store.ts +70 -16
- package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
- package/src/lab/paged-kv/paged-kv.ts +25 -11
- package/src/mlx/array.ts +2 -1
- package/src/model/artifact-identity.ts +73 -0
- package/src/model/batched-kv.ts +235 -0
- package/src/model/batched-mask.ts +12 -4
- package/src/model/batched-quant.ts +8 -2
- package/src/model/batched-quantized-kv.ts +144 -0
- package/src/model/batched-rotating-quant.ts +76 -15
- package/src/model/batched-rotating-state.ts +76 -0
- package/src/model/batched-rotating.ts +64 -22
- package/src/model/batched-row-storage.ts +52 -0
- package/src/model/batched-ssm.ts +52 -0
- package/src/model/batched-turboquant-kv.ts +93 -0
- package/src/model/compiled-decode.ts +7 -1
- package/src/model/decoded-kv-donor.ts +12 -0
- package/src/model/delayed-quantized-kv.ts +72 -0
- package/src/model/delayed-rotating-quantized-kv.ts +93 -0
- package/src/model/delayed-turboquant-kv.ts +55 -0
- package/src/model/full-prefill-padding.ts +56 -0
- package/src/model/full-prefill-row.ts +105 -0
- package/src/model/gemma4-base.ts +162 -80
- package/src/model/gemma4.ts +34 -26
- package/src/model/generated/gemma4-12b.ts +4 -1
- package/src/model/generated/gemma4-26b.ts +4 -1
- package/src/model/generated/gemma4-e4b.ts +4 -1
- package/src/model/glm52-cache.ts +80 -4
- package/src/model/glm52-mla.ts +12 -6
- package/src/model/glm52.ts +19 -40
- package/src/model/kv-attention-view.ts +67 -0
- package/src/model/minicpm5.ts +11 -7
- package/src/model/qwen3-delta-state.ts +74 -0
- package/src/model/qwen3-delta.ts +59 -36
- package/src/model/qwen3_5.ts +82 -42
- package/src/model/rotating-kv-donor.ts +35 -0
- package/src/model/rotating-kv-layout.ts +262 -0
- package/src/model/rotating-row-transaction.ts +35 -0
- package/src/model/speculative-rotating-kv.ts +101 -0
- package/src/model/ssm-prefill-padding.ts +39 -0
- package/src/model/trellis-linear.ts +2 -2
- package/src/model/turboquant-codec.ts +83 -0
- package/src/model/universal/dense.ts +14 -13
- package/src/preflight.ts +31 -15
- package/src/prompt-cache.ts +42 -44
- package/src/sampler.ts +36 -25
- package/src/serve/chat-stage.ts +2 -2
- package/src/serve/generation-gateway.ts +39 -30
- package/src/serve/model-host.ts +3 -4
- package/src/serve/request-plan.ts +4 -3
- package/src/serve/request-prep.ts +2 -4
- package/src/serve/text-completion-stage.ts +2 -2
- package/src/server.ts +64 -108
- package/src/spec/assistant-rows.ts +92 -0
- package/src/spec/assistant-source.ts +28 -18
- package/src/spec/cache-identity.ts +22 -0
- package/src/spec/deepspec-rows.ts +29 -0
- package/src/spec/deepspec-source.ts +12 -10
- package/src/spec/dflash-source.ts +28 -4
- package/src/spec/drafter.ts +153 -142
- package/src/spec/dspark/deepspec-module.ts +95 -120
- package/src/spec/dspark/module-dflash.ts +126 -170
- package/src/spec/glm52-mtp-graph.ts +29 -0
- package/src/spec/glm52-mtp-rows.ts +132 -0
- package/src/spec/glm52-mtp-source.ts +53 -136
- package/src/spec/glm52-mtp-state.ts +33 -0
- package/src/spec/ngram-source.ts +92 -20
- package/src/spec/projected-draft-rows.ts +73 -0
- package/src/spec/qwen-mtp-module.ts +171 -0
- package/src/spec/qwen-mtp-rows.ts +219 -0
- package/src/spec/qwen-mtp-source.ts +81 -226
- package/src/spec/qwen-mtp-state.ts +30 -0
- package/src/spec/serve-loop.ts +70 -68
- package/src/spec/source.ts +80 -15
- package/src/spec/two-model-rows.ts +61 -0
- package/src/spec/two-model.ts +43 -101
- package/src/ssd-cache.ts +33 -21
- package/src/ssd-durability.ts +11 -7
- package/src/web/app.js +57 -57
- package/src/backends/mlx/memory-guard.ts +0 -40
- package/src/spec/prefix-state.ts +0 -45
- /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/generate.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { appendFileSync } from "node:fs";
|
|
11
11
|
import { MlxArray, gpuStream } from "./mlx/array";
|
|
12
12
|
import {
|
|
13
|
+
Dtype,
|
|
13
14
|
activeMemory,
|
|
14
15
|
clearCache,
|
|
15
16
|
maxRecommendedWorkingSetSize,
|
|
@@ -18,8 +19,9 @@ import {
|
|
|
18
19
|
synchronize,
|
|
19
20
|
} from "./mlx/ffi";
|
|
20
21
|
import * as ops from "./mlx/ops";
|
|
21
|
-
import { KVCache, RotatingKVCache,
|
|
22
|
-
import {
|
|
22
|
+
import { KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
|
|
23
|
+
import { maybePageKv } from "./backends/mlx/request-state-policy";
|
|
24
|
+
export { maybePageKv } from "./backends/mlx/request-state-policy";
|
|
23
25
|
import { DiffusionGemmaModel } from "./model/diffusion-gemma";
|
|
24
26
|
import { denoiseAsync } from "./diffusion/diffusion-generate";
|
|
25
27
|
import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
|
|
29
31
|
type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
|
|
30
32
|
} from "./backends/mlx/autoregressive";
|
|
33
|
+
import { createKvMaintenance } from "./backends/mlx/kv-maintenance";
|
|
31
34
|
import { nextPrefillStep } from "./inference/prefill";
|
|
32
35
|
import { evalCacheState, executeMlxPrefillStep } from "./backends/mlx/prefill";
|
|
33
36
|
import { appendFillHidden } from "./backends/mlx/fill-append";
|
|
@@ -40,6 +43,7 @@ import { runtimeConfig, runtimeValue, withRuntimeConfig, type RuntimeConfig } fr
|
|
|
40
43
|
import {
|
|
41
44
|
disposeStepExtras,
|
|
42
45
|
makeStepSampler,
|
|
46
|
+
readStepExtras,
|
|
43
47
|
stepExtrasArrays,
|
|
44
48
|
type LogitsProcessorOptions,
|
|
45
49
|
type SamplerOptions,
|
|
@@ -57,8 +61,6 @@ import {
|
|
|
57
61
|
} from "./fill/fill-session";
|
|
58
62
|
|
|
59
63
|
export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
|
|
60
|
-
/** RAM budget for a model-owned paired target/draft prompt snapshot. */
|
|
61
|
-
speculativeCacheBytes?: number;
|
|
62
64
|
/** Resolved host policy. Direct compatibility calls resolve from their
|
|
63
65
|
* captured binding when this is absent. */
|
|
64
66
|
decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
|
|
@@ -121,14 +123,13 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
|
|
|
121
123
|
* Caller keeps ownership. */
|
|
122
124
|
multimodalMask?: MlxArray;
|
|
123
125
|
/** Quantize full-attention KV caches to this many bits (4 or 8).
|
|
124
|
-
*
|
|
125
|
-
* and upstream rotating-cache quantization is NYI. */
|
|
126
|
+
* Full-attention and rotating caches both convert when populated. */
|
|
126
127
|
kvBits?: number;
|
|
127
128
|
kvGroupSize?: number;
|
|
128
129
|
/** Per-layer mixed-precision KV from kv_config.json (config.kvQuant).
|
|
129
130
|
* Overrides kvBits, like optiq serve's --kv-config. layerIdx indexes
|
|
130
|
-
* the cache list (== layer index for the donor prefix)
|
|
131
|
-
* rotating/sliding caches
|
|
131
|
+
* the cache list (== layer index for the donor prefix), including
|
|
132
|
+
* rotating/sliding caches. */
|
|
132
133
|
kvConfig?: KvQuantSpec[];
|
|
133
134
|
/** Convert once a cache's offset reaches this (uniform-kvBits default
|
|
134
135
|
* 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
|
|
@@ -136,14 +137,14 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
|
|
|
136
137
|
/** TurboQuant scheme (docs/design/turboquant.md): rotation-based KV
|
|
137
138
|
* quantization, a CLI-only runtime lever in the same class as uniform
|
|
138
139
|
* kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
|
|
139
|
-
* checks
|
|
140
|
+
* checks turboQuant first, so set at most one). Full-attention
|
|
140
141
|
* KVCache layers convert via TurboQuantKVCache.fromKVCache;
|
|
141
142
|
* RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
|
|
142
143
|
* warning names the limitation, never a throw. */
|
|
143
144
|
turboQuant?: TurboQuantScheme;
|
|
144
145
|
/** OPTIONAL paged KV storage (docs/design/kv-cache.md): fresh
|
|
145
146
|
* full-attention KVCache layers are replaced with PagedKVCache (block
|
|
146
|
-
* pool + gather-to-contiguous) before prefill.
|
|
147
|
+
* pool + gather-to-contiguous) before prefill. Scope: B1/B>1
|
|
147
148
|
* Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
|
|
148
149
|
* turboQuant/draft/compiled decode (callers refuse the combos; the
|
|
149
150
|
* cache swap itself only ever touches plain empty KVCache entries).
|
|
@@ -230,88 +231,12 @@ export function shouldUseFill(
|
|
|
230
231
|
options.turboQuant === undefined;
|
|
231
232
|
}
|
|
232
233
|
|
|
233
|
-
/**
|
|
234
|
-
*
|
|
235
|
-
* caches convert too — Phase 9):
|
|
236
|
-
* - per-layer bits/group_size selection (kvConfig overrides kvBits,
|
|
237
|
-
* matching optiq's --kv-config precedence; shipped kv_config.json
|
|
238
|
-
* files cover EVERY cache-owning layer, sliding ones included —
|
|
239
|
-
* verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
|
|
240
|
-
* rotating quantization engages straight from the config; uniform
|
|
241
|
-
* kvBits — like optiq --kv-bits — reaches them too), and
|
|
242
|
-
* - STREAMING conversion (optiq streaming_kv_quant / serve.py
|
|
243
|
-
* patched_maybe_quantize): eval each layer's quantized triples and
|
|
244
|
-
* clear the buffer pool before building the next layer's conversion.
|
|
245
|
-
* Lazily batching every layer's toQuantized into one eval pins ALL
|
|
246
|
-
* layers' bf16 K/V as graph inputs alongside ALL quantized outputs —
|
|
247
|
-
* the exact transient optiq's fix kills (16.35 → 7.60 GB at 32k on a
|
|
248
|
-
* 24 GB Mac). Numerics untouched: same quantize math, only the eval
|
|
249
|
-
* ordering is forced (tests/parity/kv-quant.test.ts, tests/parity/rotating-kvq.test.ts). */
|
|
234
|
+
/** Compatibility entry point for callers that perform a single conversion.
|
|
235
|
+
* Execution sessions compose createKvMaintenance once and reuse it. */
|
|
250
236
|
export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
|
|
251
|
-
|
|
252
|
-
if (turboQuant) {
|
|
253
|
-
maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (!kvBits && !kvConfig?.length) return;
|
|
257
|
-
const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
|
|
258
|
-
const byLayer = kvConfig?.length
|
|
259
|
-
? new Map(kvConfig.map((e) => [e.layerIdx, e]))
|
|
260
|
-
: null;
|
|
261
|
-
for (let i = 0; i < cache.length; i++) {
|
|
262
|
-
const c = cache[i]!;
|
|
263
|
-
if (!(c instanceof KVCache || c instanceof RotatingKVCache) || c.offset < start) continue;
|
|
264
|
-
// OptiQ's mixed-KV hook skips empty caches: the first prompt prefill
|
|
265
|
-
// runs bf16, then the populated cache is quantized before decode.
|
|
266
|
-
// Converting empty caches at start=0 makes prefill itself quantized
|
|
267
|
-
// and diverges from the oracle path.
|
|
268
|
-
if (c.offset === 0) continue;
|
|
269
|
-
if (byLayer) {
|
|
270
|
-
const e = byLayer.get(i);
|
|
271
|
-
if (!e) continue;
|
|
272
|
-
cache[i] = c.toQuantized(e.groupSize, e.bits);
|
|
273
|
-
} else {
|
|
274
|
-
cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
|
|
275
|
-
}
|
|
276
|
-
// Streaming half: materialize THIS layer's conversion now so its bf16
|
|
277
|
-
// source (already released by toQuantized) frees before the next layer
|
|
278
|
-
// converts — the transient stays ~one layer, not the whole cache.
|
|
279
|
-
ops.evalAll(cache[i]!.state());
|
|
280
|
-
clearCache();
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Paged-KV conversion (docs/design/kv-cache.md): swap each FRESH
|
|
285
|
-
* plain full-attention KVCache for a PagedKVCache sized to hold
|
|
286
|
-
* `capacityTokens` (prompt + maxTokens — known exactly at generate()
|
|
287
|
-
* setup, so pool exhaustion is unreachable absent an accounting bug).
|
|
288
|
-
* Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
|
|
289
|
-
* prefill: paging changes storage layout, not arithmetic, so there is no
|
|
290
|
-
* "convert when populated" trigger. Sliding-window (RotatingKVCache)
|
|
291
|
-
* layers keep today's scheme — mixed paged-full + rotating-sliding is
|
|
292
|
-
* the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
|
|
293
|
-
* entirely: the serve lane bypasses prompt-cache reuse for paged
|
|
294
|
-
* requests, so this only guards library callers. */
|
|
295
|
-
export function maybePageKv(
|
|
296
|
-
cache: Cache[], options: GenerateOptions, capacityTokens: number,
|
|
297
|
-
): void {
|
|
298
|
-
if (!options.pagedKv) return;
|
|
299
|
-
if (cache.some((c) => c.offset > 0)) return;
|
|
300
|
-
const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
|
|
301
|
-
for (let i = 0; i < cache.length; i++) {
|
|
302
|
-
if (cache[i] instanceof KVCache) {
|
|
303
|
-
cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
|
|
304
|
-
cache[i] = new PagedKVCache(capacityTokens, blockSize);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
237
|
+
createKvMaintenance(options)(cache);
|
|
307
238
|
}
|
|
308
239
|
|
|
309
|
-
/** Emitted once per process: RotatingKVCache (sliding-window) layers are a
|
|
310
|
-
* documented v1 non-goal (docs/design/turboquant.md) — they stay bf16
|
|
311
|
-
* rather than throwing, so mixed full-attention/sliding-window models (e.g.
|
|
312
|
-
* Gemma) still serve correctly under --kv-quant turbo. */
|
|
313
|
-
let warnedTurboRotating = false;
|
|
314
|
-
|
|
315
240
|
/** Emitted once per process: token fast-forwarding skips models with
|
|
316
241
|
* sliding-window layers in v1 (see the gate in generateInner). */
|
|
317
242
|
let warnedFillRotating = false;
|
|
@@ -330,39 +255,6 @@ type RewindableCache = {
|
|
|
330
255
|
};
|
|
331
256
|
const rewindable = (c: unknown): RewindableCache => c as RewindableCache;
|
|
332
257
|
|
|
333
|
-
/** TurboQuant conversion chokepoint (mirrors the uniform/config branch
|
|
334
|
-
* above): only plain full-attention KVCache instances convert, via
|
|
335
|
-
* TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
|
|
336
|
-
* never throw). Same offset===0 skip-empty-cache rule as the affine path. */
|
|
337
|
-
function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
|
|
338
|
-
for (let i = 0; i < cache.length; i++) {
|
|
339
|
-
const c = cache[i]!;
|
|
340
|
-
if (c instanceof RotatingKVCache) {
|
|
341
|
-
if (!warnedTurboRotating) {
|
|
342
|
-
warnedTurboRotating = true;
|
|
343
|
-
console.warn(
|
|
344
|
-
"[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
|
|
345
|
-
"(full-attention only) — docs/design/turboquant.md.",
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
|
|
351
|
-
const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
|
|
352
|
-
cache[i] = tq;
|
|
353
|
-
// state() allocates fresh trimmed slice views for this cache kind
|
|
354
|
-
// (see evalCacheState) — dispose after materializing (throw included),
|
|
355
|
-
// or they leak.
|
|
356
|
-
const state = tq.state();
|
|
357
|
-
try {
|
|
358
|
-
ops.evalAll(state);
|
|
359
|
-
} finally {
|
|
360
|
-
for (const a of state) a.dispose();
|
|
361
|
-
}
|
|
362
|
-
clearCache();
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
258
|
export interface GenerateStats {
|
|
367
259
|
promptTokens: number;
|
|
368
260
|
/** An observed terminal cause takes precedence over the token-count limit. */
|
|
@@ -455,9 +347,11 @@ export class Generation implements AsyncIterable<GeneratedToken> {
|
|
|
455
347
|
// Scope semantics match the reference: set → generate → synchronize →
|
|
456
348
|
// restore; nothing stays pinned between generations. Re-entrant: only
|
|
457
349
|
// the outermost wiring scope touches the limit.
|
|
458
|
-
// macOS 26.6 reports a 24.96 GiB recommended set on
|
|
459
|
-
//
|
|
460
|
-
//
|
|
350
|
+
// macOS 26.6 reports a 26.8e9 B (24.96 GiB, 0.78 × RAM) recommended set on
|
|
351
|
+
// a 32 GB M1 Max (2026-09-08; the earlier "24 GB machine" attribution was
|
|
352
|
+
// wrong — 24.96 GiB exceeds a 24 GB box's RAM). At 0.75 × that the old
|
|
353
|
+
// fraction stopped wiring the 13-16 GiB Qwen/GLM models even though they
|
|
354
|
+
// page heavily without an explicit wired limit.
|
|
461
355
|
// Keep smaller 8-9 GiB models unwired while covering the large-model class.
|
|
462
356
|
const WIRE_THRESHOLD = 0.5;
|
|
463
357
|
let wiredScopeDepth = 0;
|
|
@@ -719,6 +613,7 @@ async function* generateInner(
|
|
|
719
613
|
throw error;
|
|
720
614
|
}
|
|
721
615
|
const graph = binding.graph;
|
|
616
|
+
const maintainKv = createKvMaintenance(options);
|
|
722
617
|
|
|
723
618
|
// logprobs capture (mlx_lm.server semantics — see GenerateOptions.logprobs).
|
|
724
619
|
// Everything below is gated: when neither flag is set, no extra ops, evals,
|
|
@@ -797,25 +692,6 @@ async function* generateInner(
|
|
|
797
692
|
(c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
|
|
798
693
|
);
|
|
799
694
|
closeBatchSetup?.();
|
|
800
|
-
/** Device-side logprob capture for one step, computed from the SAME lp the
|
|
801
|
-
* sampler saw (post-processors, pre-truncation) — read back lazily with the
|
|
802
|
-
* token so decode pipelining is preserved. */
|
|
803
|
-
/** Read extras back to JS (forces eval — they were async-dispatched with the
|
|
804
|
-
* token) without appending casts behind the next decode step. */
|
|
805
|
-
const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
|
|
806
|
-
if (!e) return undefined;
|
|
807
|
-
const out: TokenLogprobs = {};
|
|
808
|
-
if (e.sel) out.logprob = e.sel.toFloat32Host()[0]!;
|
|
809
|
-
if (e.topIdx && e.topVals) {
|
|
810
|
-
const ids = e.topIdx.toIntTokens();
|
|
811
|
-
const vals = e.topVals.toFloat32Host();
|
|
812
|
-
out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
|
|
813
|
-
(a, b) => b.logprob - a.logprob,
|
|
814
|
-
);
|
|
815
|
-
}
|
|
816
|
-
disposeStepExtras(e);
|
|
817
|
-
return out;
|
|
818
|
-
};
|
|
819
695
|
|
|
820
696
|
// logits [1,1,V] → sampled token array [1] (+ optional logprob capture,
|
|
821
697
|
// all on-device)
|
|
@@ -848,6 +724,7 @@ async function* generateInner(
|
|
|
848
724
|
let finished = false;
|
|
849
725
|
let threw = false;
|
|
850
726
|
let executionError: unknown;
|
|
727
|
+
let earlyUnforwardedToken: number | null = null;
|
|
851
728
|
let closeTokenZero: (() => void) | undefined;
|
|
852
729
|
const makeStats = (): GenerateStats => ({
|
|
853
730
|
promptTokens: options.originalPromptTokens ?? promptTokens.length,
|
|
@@ -948,7 +825,7 @@ async function* generateInner(
|
|
|
948
825
|
nextExtras = null;
|
|
949
826
|
// No-op under the fill exclusions (kv quant refuses fill); kept so the
|
|
950
827
|
// boundary stays correct if those exclusions ever loosen.
|
|
951
|
-
|
|
828
|
+
maintainKv(cache);
|
|
952
829
|
|
|
953
830
|
let checkpointMs = 0;
|
|
954
831
|
let roundOpen = false;
|
|
@@ -1061,7 +938,10 @@ async function* generateInner(
|
|
|
1061
938
|
// pending token was sampled from that exact state before the snapshot,
|
|
1062
939
|
// so resume starts directly at the decode loop without another forward.
|
|
1063
940
|
if (needsTokenHistory) stepSampler.seedHistory(promptTokens);
|
|
1064
|
-
|
|
941
|
+
// Device samplers return uint32. Preserve that signature on restore so
|
|
942
|
+
// compiled decode does not trace a different signed-token graph first.
|
|
943
|
+
using restoredToken = ops.fromInt32([options.initialPendingToken!], [1]);
|
|
944
|
+
pending = restoredToken.astype(Dtype.uint32);
|
|
1065
945
|
ops.asyncEvalAll([pending]);
|
|
1066
946
|
prefillMs = 0;
|
|
1067
947
|
closePrefill?.();
|
|
@@ -1072,7 +952,7 @@ async function* generateInner(
|
|
|
1072
952
|
mechanism: diagnostics.mechanism ?? "serial",
|
|
1073
953
|
boundary: "initial",
|
|
1074
954
|
});
|
|
1075
|
-
|
|
955
|
+
maintainKv(cache);
|
|
1076
956
|
closeInitialKv?.();
|
|
1077
957
|
tPrefill = performance.now();
|
|
1078
958
|
let h0: MlxArray;
|
|
@@ -1096,7 +976,7 @@ async function* generateInner(
|
|
|
1096
976
|
: undefined;
|
|
1097
977
|
const tailSplit = runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
1098
978
|
const forward = graph.forwardHidden.bind(graph);
|
|
1099
|
-
const maintain = () =>
|
|
979
|
+
const maintain = () => maintainKv(cache);
|
|
1100
980
|
while (true) {
|
|
1101
981
|
options.signal?.throwIfAborted();
|
|
1102
982
|
const step = nextPrefillStep({ length: promptTokens.length, position: pos,
|
|
@@ -1186,14 +1066,16 @@ async function* generateInner(
|
|
|
1186
1066
|
closeTokenZero?.();
|
|
1187
1067
|
closeTokenZero = undefined;
|
|
1188
1068
|
tDecode = performance.now();
|
|
1189
|
-
const logprobs =
|
|
1069
|
+
const logprobs = readStepExtras(curExtras);
|
|
1190
1070
|
curExtras = null;
|
|
1191
1071
|
pendingExtras = null;
|
|
1192
1072
|
// Count before yielding so an early return reports this token. Its
|
|
1193
1073
|
// KV is still absent; forwarded describes the prompt-only cache.
|
|
1194
1074
|
generated++;
|
|
1195
1075
|
yieldedEarly = true;
|
|
1076
|
+
earlyUnforwardedToken = firstToken;
|
|
1196
1077
|
yield { token: firstToken, index: stepIndex, ...(logprobs ? { logprobs } : {}) };
|
|
1078
|
+
earlyUnforwardedToken = null;
|
|
1197
1079
|
options.signal?.throwIfAborted();
|
|
1198
1080
|
}
|
|
1199
1081
|
}
|
|
@@ -1242,7 +1124,7 @@ async function* generateInner(
|
|
|
1242
1124
|
// contract); the next sampled token, if the budget and grammar allow
|
|
1243
1125
|
// one, comes from its last position. Compiled decode resumes on the
|
|
1244
1126
|
// following iteration (supports() re-checks the grown caches).
|
|
1245
|
-
|
|
1127
|
+
maintainKv(cache);
|
|
1246
1128
|
pushHistory(cur);
|
|
1247
1129
|
stepSampler.commitNumbers(jumpEmit);
|
|
1248
1130
|
const chunk = [grammarTok, ...jumpEmit];
|
|
@@ -1267,7 +1149,7 @@ async function* generateInner(
|
|
|
1267
1149
|
h.dispose(); // burst ends the generation (max_tokens or grammar done)
|
|
1268
1150
|
}
|
|
1269
1151
|
} else if (stepIndex + 1 < maxTokens && !options.grammar?.isTerminated) {
|
|
1270
|
-
|
|
1152
|
+
maintainKv(cache);
|
|
1271
1153
|
pushHistory(cur);
|
|
1272
1154
|
let logits: MlxArray | null = null;
|
|
1273
1155
|
let evalWith: MlxArray[] = [];
|
|
@@ -1347,7 +1229,7 @@ async function* generateInner(
|
|
|
1347
1229
|
}
|
|
1348
1230
|
// readExtras before the yield: if the consumer breaks at this yield,
|
|
1349
1231
|
// the extras are already read and disposed.
|
|
1350
|
-
const logprobs =
|
|
1232
|
+
const logprobs = readStepExtras(curExtras);
|
|
1351
1233
|
pendingExtras = null;
|
|
1352
1234
|
if (!yieldedEarly) yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
|
|
1353
1235
|
options.signal?.throwIfAborted();
|
|
@@ -1412,31 +1294,53 @@ async function* generateInner(
|
|
|
1412
1294
|
executionError = e;
|
|
1413
1295
|
throw e;
|
|
1414
1296
|
} finally {
|
|
1415
|
-
if (!finished) {
|
|
1416
|
-
pending?.dispose();
|
|
1417
|
-
nextPending?.dispose();
|
|
1418
|
-
disposeStepExtras(pendingExtras);
|
|
1419
|
-
disposeStepExtras(nextExtras);
|
|
1420
|
-
}
|
|
1421
1297
|
try {
|
|
1422
|
-
|
|
1423
|
-
|
|
1298
|
+
// An early consumer return leaves token zero outside the retained KV.
|
|
1299
|
+
// Finish that one M=1 forward before handing caller-owned caches back,
|
|
1300
|
+
// matching the ordinary pipeline's boundary. Otherwise a later prefix
|
|
1301
|
+
// hit folds the token into an M>1 prefill and changes native reductions.
|
|
1302
|
+
// Aborted requests and caches owned by this run need no retained state.
|
|
1303
|
+
if (!ownsCache && !threw && !options.signal?.aborted && earlyUnforwardedToken !== null) {
|
|
1304
|
+
maintainKv(cache);
|
|
1305
|
+
const ids = ops.reshape(pending!, [1, 1]);
|
|
1306
|
+
try {
|
|
1307
|
+
const hidden = await graph.forwardHidden(ids, cache);
|
|
1308
|
+
try { evalCacheState(cache); }
|
|
1309
|
+
finally { hidden.dispose(); }
|
|
1310
|
+
} finally { ids.dispose(); }
|
|
1311
|
+
forwarded.push(earlyUnforwardedToken);
|
|
1312
|
+
}
|
|
1424
1313
|
} catch (error) {
|
|
1425
|
-
|
|
1426
|
-
|
|
1314
|
+
threw = true;
|
|
1315
|
+
executionError = error;
|
|
1427
1316
|
throw error;
|
|
1428
1317
|
} finally {
|
|
1429
|
-
if (
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1318
|
+
if (!finished) {
|
|
1319
|
+
pending?.dispose();
|
|
1320
|
+
nextPending?.dispose();
|
|
1321
|
+
disposeStepExtras(pendingExtras);
|
|
1322
|
+
disposeStepExtras(nextExtras);
|
|
1323
|
+
}
|
|
1324
|
+
try {
|
|
1325
|
+
const closing = decoder?.close();
|
|
1326
|
+
if (closing) await closing;
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
if (threw) throw new AggregateError([executionError, error],
|
|
1329
|
+
"AR execution and decoder cleanup failed", { cause: executionError });
|
|
1330
|
+
throw error;
|
|
1331
|
+
} finally {
|
|
1332
|
+
if (ownsCache) for (const c of cache) c.dispose();
|
|
1333
|
+
// TokenizerInfo is process-cached; this request owns the matcher.
|
|
1334
|
+
options.grammar?.dispose();
|
|
1335
|
+
stepSampler.dispose();
|
|
1336
|
+
}
|
|
1337
|
+
if (!finished && !threw) {
|
|
1338
|
+
// forced early return (consumer break at a yield): still report
|
|
1339
|
+
// stats — `forwarded` only lists tokens whose KV actually entered
|
|
1340
|
+
// the cache, so cacheTokens stays exact for PromptCache.put().
|
|
1341
|
+
decodeMs = performance.now() - tDecode;
|
|
1342
|
+
return makeStats();
|
|
1343
|
+
}
|
|
1440
1344
|
}
|
|
1441
1345
|
}
|
|
1442
1346
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** One request's exact-token speculative acceptance walk. The backend owns
|
|
2
|
+
* logits and sampling; this state owns only the accepted prefix and stop facts.
|
|
3
|
+
* A group advances each live walk independently, regardless of batch size. */
|
|
4
|
+
export class DraftAcceptance {
|
|
5
|
+
position = 0;
|
|
6
|
+
accepted = 0;
|
|
7
|
+
correction: number | null = null;
|
|
8
|
+
readonly emitted: number[] = [];
|
|
9
|
+
sawEos = false;
|
|
10
|
+
grammarDone = false;
|
|
11
|
+
done = false;
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
readonly drafts: readonly number[],
|
|
15
|
+
readonly remaining: number,
|
|
16
|
+
readonly eosTokenIds: readonly number[],
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/** Supply one target sample after its sampler has updated request history
|
|
20
|
+
* and grammar. Once done, this request needs no further target samples. */
|
|
21
|
+
accept(token: number, grammarDone = false): void {
|
|
22
|
+
const matches = this.position < this.drafts.length && token === this.drafts[this.position];
|
|
23
|
+
this.position++;
|
|
24
|
+
if (matches) {
|
|
25
|
+
this.accepted++;
|
|
26
|
+
if (this.eosTokenIds.includes(token)) {
|
|
27
|
+
this.sawEos = this.done = true;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.emitted.push(token);
|
|
31
|
+
this.grammarDone = grammarDone;
|
|
32
|
+
this.done = grammarDone || this.emitted.length >= this.remaining;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.correction = token;
|
|
36
|
+
this.sawEos = this.eosTokenIds.includes(token);
|
|
37
|
+
if (!this.sawEos) this.emitted.push(token);
|
|
38
|
+
this.grammarDone = grammarDone;
|
|
39
|
+
this.done = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Cancellation, CancelReason, GenerationOutput, TokenLogprobs } from "../contracts/generation";
|
|
2
|
+
import type { DraftAcceptance } from "./draft-acceptance";
|
|
3
|
+
|
|
4
|
+
export interface DraftOutputRow {
|
|
5
|
+
readonly acceptance: DraftAcceptance;
|
|
6
|
+
readonly logprobs?: readonly (TokenLogprobs | undefined)[];
|
|
7
|
+
readonly output: Pick<GenerationOutput, "commit">;
|
|
8
|
+
readonly cancellation?: Cancellation;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Counts apply to this round. Accepted counts describe processed proposed
|
|
12
|
+
* inputs, excluding an unprocessed correction/bonus and any unpublished EOS.
|
|
13
|
+
* Failed/cancelled requests must be discarded, never checkpointed. */
|
|
14
|
+
export type DraftOutputResult = { readonly generated: number; readonly accepted: number } & (
|
|
15
|
+
| { readonly kind: "continue"; readonly pending: number }
|
|
16
|
+
| { readonly kind: "stop" | "length" }
|
|
17
|
+
| { readonly kind: "cancelled"; readonly reason: CancelReason }
|
|
18
|
+
| { readonly kind: "failed"; readonly error: unknown }
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/** Deliver verified content through the output interface before resolving
|
|
22
|
+
* retained state. A stopped or failed consumer affects only its own request.
|
|
23
|
+
* Token-sized publications preserve callbacks that stop inside a draft burst. */
|
|
24
|
+
export async function deliverDraftOutputs(rows: readonly DraftOutputRow[]): Promise<DraftOutputResult[]> {
|
|
25
|
+
const results: DraftOutputResult[] = [];
|
|
26
|
+
for (const row of rows) results.push(await deliver(row));
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function deliver(row: DraftOutputRow): Promise<DraftOutputResult> {
|
|
31
|
+
const { acceptance } = row;
|
|
32
|
+
let generated = 0;
|
|
33
|
+
const retained = () => ({ generated, accepted: Math.min(generated, acceptance.accepted) });
|
|
34
|
+
try {
|
|
35
|
+
for (const token of acceptance.emitted) {
|
|
36
|
+
const reason = row.cancellation?.reason;
|
|
37
|
+
if (reason) return { kind: "cancelled", reason, generated, accepted: 0 };
|
|
38
|
+
const metadata = row.logprobs?.[generated];
|
|
39
|
+
const more = await row.output.commit([token], metadata ? [metadata] : undefined);
|
|
40
|
+
generated++;
|
|
41
|
+
if (more === false) return { kind: "stop", ...retained() };
|
|
42
|
+
}
|
|
43
|
+
const reason = row.cancellation?.reason;
|
|
44
|
+
if (reason) return { kind: "cancelled", reason, generated, accepted: 0 };
|
|
45
|
+
if (acceptance.sawEos || acceptance.grammarDone) return { kind: "stop", ...retained() };
|
|
46
|
+
if (generated >= acceptance.remaining) return { kind: "length", ...retained() };
|
|
47
|
+
return { kind: "continue", pending: acceptance.correction!, ...retained() };
|
|
48
|
+
} catch (error) { return { kind: "failed", error, generated, accepted: 0 }; }
|
|
49
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** A complete transaction over every participating target state layer.
|
|
2
2
|
* A failed begin/resolve invalidates the run; its state must be discarded. */
|
|
3
|
-
export interface SpeculativeTransaction {
|
|
3
|
+
export interface SpeculativeTransaction<Acceptance = number> {
|
|
4
4
|
canBegin(drafts: number): boolean;
|
|
5
5
|
begin(drafts: number): void;
|
|
6
|
-
resolve(accepted:
|
|
6
|
+
resolve(accepted: Acceptance): void;
|
|
7
7
|
}
|
package/src/kv-scheme.ts
CHANGED
|
@@ -86,6 +86,42 @@ export function kvBytesAt(
|
|
|
86
86
|
value.linearStateBytes;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Head dims MLX 0.32.2's fused multi-query SDPA kernel serves for L > 8
|
|
90
|
+
* queries on M1–M4 GPUs (mlx/backend/metal/scaled_dot_product_attention.cpp
|
|
91
|
+
* `use_fallback`: 192 and 256 always fall back off-NAX; `has_fused_kernel`
|
|
92
|
+
* lists 64/72/80/96/128 for the full kernel). Every other head dim runs
|
|
93
|
+
* mlx/fast.cpp's composed fallback, which materializes the bf16 scores
|
|
94
|
+
* tensor [heads, L, S] plus a bool mask per full-attention layer. The
|
|
95
|
+
* decode kernel (L ≤ 8) fuses 256, so this term is a prefill-only cost. */
|
|
96
|
+
export const FUSED_SDPA_HEAD_DIMS: ReadonlySet<number> = new Set([64, 72, 80, 96, 128]);
|
|
97
|
+
const SDPA_SCORES_BYTES = 2; // bf16 scores in q's dtype
|
|
98
|
+
const SDPA_FUSED_MAX_QUERIES = 8;
|
|
99
|
+
|
|
100
|
+
/** Peak bytes of the SDPA-fallback scores tensor live during one prefill
|
|
101
|
+
* chunk of `chunk` queries against a `ctx`-token context: the last chunk
|
|
102
|
+
* sees the longest keys, and the lazy graph keeps one layer's scores live
|
|
103
|
+
* at a time. Zero when every attention layer's head dim fuses. Sliding
|
|
104
|
+
* layers see at most `window + L` keys. Admission-only (fit and the
|
|
105
|
+
* in-flight memory guard); nothing here changes what is computed. */
|
|
106
|
+
export function sdpaFallbackBytes(config: ModelConfig, chunk: number, ctx: number): number {
|
|
107
|
+
const text = config.text;
|
|
108
|
+
const heads = text.numAttentionHeads || 0;
|
|
109
|
+
const queries = Math.min(chunk, ctx);
|
|
110
|
+
if (heads <= 0 || queries <= SDPA_FUSED_MAX_QUERIES) return 0;
|
|
111
|
+
let peak = 0;
|
|
112
|
+
const layers = text.numHiddenLayers || text.layerTypes.length;
|
|
113
|
+
for (let layer = 0; layer < layers; layer++) {
|
|
114
|
+
const type = text.layerTypes[layer] ?? "full_attention";
|
|
115
|
+
if (type === "linear_attention") continue;
|
|
116
|
+
const sliding = type === "sliding_attention" && text.slidingWindow > 0;
|
|
117
|
+
const headDim = sliding ? text.headDim : text.globalHeadDim;
|
|
118
|
+
if (FUSED_SDPA_HEAD_DIMS.has(headDim)) continue;
|
|
119
|
+
const keys = sliding ? Math.min(ctx, text.slidingWindow + queries) : ctx;
|
|
120
|
+
peak = Math.max(peak, heads * queries * keys * SDPA_SCORES_BYTES);
|
|
121
|
+
}
|
|
122
|
+
return peak;
|
|
123
|
+
}
|
|
124
|
+
|
|
89
125
|
export class KvScheme {
|
|
90
126
|
readonly kind: KvSchemeKind;
|
|
91
127
|
readonly options: Readonly<ResolvedKvSchemeOptions>;
|
|
@@ -112,10 +148,17 @@ export class KvScheme {
|
|
|
112
148
|
get cacheKey(): string {
|
|
113
149
|
if (this.kind === "turbo") {
|
|
114
150
|
const turbo = this.options.turboQuant!;
|
|
115
|
-
|
|
151
|
+
const start = this.options.quantizedKvStart ?? 0;
|
|
152
|
+
return `turbo-k${turbo.kBits}v${turbo.vBits}${start > 0 ? `-start${start}` : ""}`;
|
|
153
|
+
}
|
|
154
|
+
if (this.kind === "affine-uniform") {
|
|
155
|
+
const start = this.options.quantizedKvStart ?? 5000, group = this.options.kvGroupSize ?? 64;
|
|
156
|
+
return `kv${this.options.kvBits}${group !== 64 ? `-g${group}` : ""}${start > 0 ? `-start${start}` : ""}`;
|
|
157
|
+
}
|
|
158
|
+
if (this.kind === "affine-config") {
|
|
159
|
+
const start = this.options.quantizedKvStart ?? 0;
|
|
160
|
+
return `config${start > 0 ? `-start${start}` : ""}`;
|
|
116
161
|
}
|
|
117
|
-
if (this.kind === "affine-uniform") return `kv${this.options.kvBits}`;
|
|
118
|
-
if (this.kind === "affine-config") return "config";
|
|
119
162
|
return "bf16";
|
|
120
163
|
}
|
|
121
164
|
|
|
@@ -154,14 +197,24 @@ export class KvScheme {
|
|
|
154
197
|
return this.kind === "turbo" ? {} : this.generationOptions;
|
|
155
198
|
}
|
|
156
199
|
|
|
157
|
-
/**
|
|
158
|
-
*
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
batchable(config: ModelConfig, canConvert?: (layerIdx: number) => boolean): boolean {
|
|
200
|
+
/** Affine layouts convert during solo prefill. TQ also has a transitional
|
|
201
|
+
* row layout that preserves each request's delayed conversion boundary. */
|
|
202
|
+
batchable(config: ModelConfig, canConvert?: (layerIdx: number) => boolean,
|
|
203
|
+
cacheLayerCount = config.text.numHiddenLayers, capabilities: { delayedAffine?: boolean } = {}): boolean {
|
|
162
204
|
if (this.kind === "bf16") return true;
|
|
163
|
-
if (this.kind !== "affine-config") return false;
|
|
205
|
+
if (this.kind !== "affine-config" && this.kind !== "affine-uniform" && this.kind !== "turbo") return false;
|
|
164
206
|
if (!canConvert) return false;
|
|
207
|
+
const start = this.options.quantizedKvStart ?? (this.kind === "affine-uniform" ? 5000 : 0);
|
|
208
|
+
if (start > 0 && this.kind !== "turbo" && !capabilities.delayedAffine) return false;
|
|
209
|
+
if (this.kind === "affine-uniform" || this.kind === "turbo") {
|
|
210
|
+
// Some models share the donor prefix's KV in later layers. Probe the
|
|
211
|
+
// actual cache list, just as uniform conversion does.
|
|
212
|
+
for (let layer = 0; layer < cacheLayerCount; layer++) {
|
|
213
|
+
if (config.text.layerTypes[layer] === "linear_attention") continue;
|
|
214
|
+
if (!canConvert(layer)) return false;
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
165
218
|
return this.options.kvConfig!.every((entry) => {
|
|
166
219
|
if (entry.layerIdx < 0 || entry.layerIdx >= config.text.numHiddenLayers) return false;
|
|
167
220
|
return (config.text.layerTypes[entry.layerIdx] ?? "full_attention") !== "linear_attention" &&
|
|
@@ -175,11 +228,15 @@ export function resolveKvScheme(input: {
|
|
|
175
228
|
turboQuant?: TurboQuantScheme;
|
|
176
229
|
config?: readonly KvQuantSpec[] | null;
|
|
177
230
|
missingConfig?: "bf16" | "error";
|
|
231
|
+
quantizedKvStart?: number;
|
|
178
232
|
}): KvScheme {
|
|
233
|
+
const start = input.quantizedKvStart;
|
|
234
|
+
if (start !== undefined && (!Number.isSafeInteger(start) || start < 0))
|
|
235
|
+
throw new Error("quantizedKvStart must be a nonnegative integer");
|
|
179
236
|
if (input.turboQuant) {
|
|
180
237
|
return new KvScheme("turbo", {
|
|
181
238
|
turboQuant: input.turboQuant,
|
|
182
|
-
quantizedKvStart: 0,
|
|
239
|
+
quantizedKvStart: start ?? 0,
|
|
183
240
|
});
|
|
184
241
|
}
|
|
185
242
|
if (input.override === "off" || input.override === undefined)
|
|
@@ -190,10 +247,11 @@ export function resolveKvScheme(input: {
|
|
|
190
247
|
throw new Error("model has no kv_config.json (--kv-quant config)");
|
|
191
248
|
return new KvScheme("bf16", {});
|
|
192
249
|
}
|
|
193
|
-
return new KvScheme("affine-config", { kvConfig: [...input.config]
|
|
250
|
+
return new KvScheme("affine-config", { kvConfig: [...input.config],
|
|
251
|
+
...(start === undefined ? {} : { quantizedKvStart: start }) });
|
|
194
252
|
}
|
|
195
253
|
return new KvScheme("affine-uniform", {
|
|
196
254
|
kvBits: input.override,
|
|
197
|
-
quantizedKvStart: 0,
|
|
255
|
+
quantizedKvStart: start ?? 0,
|
|
198
256
|
});
|
|
199
257
|
}
|