KaedeTai commited on
Commit
78c877d
·
verified ·
1 Parent(s): 2589942

DFlash2 quantized-draft loader + M5 Max measurements

Browse files
Files changed (4) hide show
  1. README.md +121 -0
  2. config.json +65 -0
  3. load_draft_q4.py +56 -0
  4. results.json +94 -0
README.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: mlx
4
+ tags:
5
+ - mlx
6
+ - speculative-decoding
7
+ - dflash2
8
+ - apple-silicon
9
+ - quantization
10
+ - benchmark
11
+ base_model:
12
+ - Qwen/Qwen3.8-27B
13
+ ---
14
+
15
+ # DFlash2 on Apple Silicon — quantized-draft loader + M5 Max measurements
16
+
17
+ Two things this repo provides, neither of which is model weights:
18
+
19
+ 1. **`load_draft_q4.py`** — a drop-in loader that lets the
20
+ [`cfontes/qwen3.8-27b-mlx-dflash2`](https://huggingface.co/cfontes/qwen3.8-27b-mlx-dflash2)
21
+ server and benchmarks use a **quantized** DFlash2 drafter. The upstream loader builds a
22
+ bf16 skeleton and calls `load_weights` directly, so a 4-bit draft fails on shape
23
+ mismatch.
24
+ 2. **`config.json`** — the draft config that
25
+ [`ProCreations/Qwen3.8-27B-DFlash2-MLXFast-Q4`](https://huggingface.co/ProCreations/Qwen3.8-27B-DFlash2-MLXFast-Q4)
26
+ ships without. That repo contains only `model.safetensors`, so no standard loader can
27
+ instantiate it.
28
+
29
+ Plus the measurements that motivated both.
30
+
31
+ ## Headline: quantizing the *drafter* is close to free throughput
32
+
33
+ Apple **M5 Max**, 137 GB, macOS 26.4.1, MLX 0.32.0 / mlx-lm 0.31.3. Target:
34
+ `tozp/Qwen3.8-27B-OBLITERATED-V2-mlx-4bit`. Greedy, 300 tokens, K=4, interleaved A/B with a
35
+ 90 s cooldown before each measurement, two rounds.
36
+
37
+ | drafter | size | tok/s | accept |
38
+ |---|---:|---:|---:|
39
+ | `z-lab/Qwen3.8-27B-DFlash2` (bf16) | 3.85 GB | 59.8 / 60.3 | 87.9% |
40
+ | `ProCreations/...-MLXFast-Q4` (affine-4 g64) | **1.27 GB** | **76.1 / 76.0** | 86.9% |
41
+ | | −2.6 GB | **+26%** | −1.0 pp |
42
+
43
+ The drafter runs a forward pass every block, so shrinking it by 2.6 GB removes real
44
+ bandwidth from the hot loop. The prediction quality it costs — one point of acceptance — is
45
+ nowhere near enough to offset that.
46
+
47
+ Stacked against no speculation at all, on the same target and machine:
48
+
49
+ | configuration | tok/s | vs dense |
50
+ |---|---:|---:|
51
+ | dense (`mlx_lm.stream_generate`) | 33.3 | 1.00× |
52
+ | + bf16 drafter | 61.4 | 1.84× |
53
+ | **+ Q4 drafter** | **76.1** | **2.29×** |
54
+
55
+ A 27B **dense** model at 76 tok/s in ~15.5 GB.
56
+
57
+ ## Target-model comparison (same protocol)
58
+
59
+ | target | dense | DFlash2 (bf16 draft) | speedup | accept | greedy token-exact vs dense |
60
+ |---|---:|---:|---:|---:|---|
61
+ | `mlx-community/Qwen3.8-27B-mxfp4` | 35.4 | 54.0 | 1.52× | 82% | yes |
62
+ | Qwen3.8-27B abliterated w/ [Heretic](https://github.com/p-e-w/heretic) | 33.3 | 62.2 | 1.87× | 89% | one near-tie flip |
63
+ | `tozp/...-OBLITERATED-V2` | 33.3 | 61.4 | 1.84× | 88% | yes |
64
+
65
+ Round-to-round spread under 2% on every cell.
66
+
67
+ **The drafter transfers across quantization schemes and weight surgery.** It is trained
68
+ against the stock model; both alternatives are abliterated by unrelated methods (Optuna
69
+ search vs SVD+LEACE blend) *and* quantized differently (affine-4 g64 vs mxfp4 g32).
70
+ Acceptance went **up** 6–7 points rather than down. Two independent methods landing in the
71
+ same place suggests the cause is removing refusal directions as such — plausibly lower
72
+ next-token entropy, which is testable and untested here.
73
+
74
+ ## Measurement traps on this hardware
75
+
76
+ - **Thermal drift.** Running targets back-to-back without cooldowns, the same mxfp4
77
+ measurement read 34.9 t/s at the start and 26.8 t/s three minutes later — **−23%**, enough
78
+ to invert a model-vs-model conclusion. Interleaving with 90 s cooldowns took spread from
79
+ 23% to under 2%.
80
+ - **Cold start.** The first model load of a session measures far below steady state; one run
81
+ produced `dense 1.9 t/s / speedup 18.00x`. Warm up before timing.
82
+ - **`ioreg` "Device Utilization %" is useless here** — it reads 100% at rest. Check GPU
83
+ memory in use and whether other engines hold models instead. A 37 GB workload on the GPU
84
+ went undetected this way and produced a full set of wrong numbers.
85
+
86
+ ## Usage
87
+
88
+ ```bash
89
+ git clone https://huggingface.co/cfontes/qwen3.8-27b-mlx-dflash2 dflash2
90
+ cd dflash2
91
+ hf download ProCreations/Qwen3.8-27B-DFlash2-MLXFast-Q4 --local-dir models/draft-q4
92
+ cp /path/to/this/repo/config.json models/draft-q4/config.json
93
+ cp /path/to/this/repo/load_draft_q4.py bench/extra/load_draft_q4.py
94
+
95
+ # point the server at the quantized draft
96
+ python dflash2_mlx_server/server.py --main-dir models/<target> --draft-dir models/draft-q4
97
+ ```
98
+
99
+ `server.py` calls `dflash_port.local_load.load_draft_from_dir`; swap that import for
100
+ `load_draft_any` from `load_draft_q4.py`. The loader detects a `quantization` block and
101
+ falls back to the original function when there isn't one, so bf16 drafts keep working.
102
+
103
+ Verified end-to-end: server loads in 0.9 s and reports `accept_rate 0.917, tok_per_s 75.4`.
104
+
105
+ ## How the loader works
106
+
107
+ `nn.quantize` is applied to the draft skeleton **before** `load_weights`, with a predicate
108
+ that quantizes exactly the modules that have a `.scales` entry in the weight file — the same
109
+ test `mlx_lm` uses. It also normalizes the candidate-selector codebook keys, which the two
110
+ published drafts spell differently (`candidate_selector.predecessor_codebook` vs
111
+ `...codebook.weight`); the upstream rename raises `KeyError` on the Q4 layout.
112
+
113
+ ## Credits
114
+
115
+ - [z-lab](https://huggingface.co/z-lab/Qwen3.8-27B-DFlash2) — the DFlash2 drafter
116
+ - [cfontes](https://huggingface.co/cfontes/qwen3.8-27b-mlx-dflash2) — the MLX port and server this patches (MIT)
117
+ - [ProCreations](https://huggingface.co/ProCreations/Qwen3.8-27B-DFlash2-MLXFast-Q4) — the affine-4 quantization measured here
118
+ - [tozp](https://huggingface.co/tozp/Qwen3.8-27B-OBLITERATED-V2-mlx-4bit) and [Heretic](https://github.com/p-e-w/heretic) — the abliterated targets
119
+
120
+ No model weights are redistributed here. `config.json` is derived from z-lab's draft config
121
+ with a quantization block added.
config.json ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "DFlash2DraftModel"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "bos_token_id": null,
8
+ "is_causal": false,
9
+ "dflash_config": {
10
+ "block_size": 8,
11
+ "conv_group_size": 16,
12
+ "conv_kernel_size": 2,
13
+ "mask_token_id": 248070,
14
+ "selector_rank": 256,
15
+ "selector_top_k": 16,
16
+ "target_layer_ids": [
17
+ 5,
18
+ 19,
19
+ 33,
20
+ 47,
21
+ 61
22
+ ]
23
+ },
24
+ "dtype": "bfloat16",
25
+ "eos_token_id": 248044,
26
+ "head_dim": 128,
27
+ "hidden_act": "silu",
28
+ "hidden_size": 5120,
29
+ "initializer_range": 0.02,
30
+ "intermediate_size": 17408,
31
+ "layer_types": [
32
+ "sliding_attention",
33
+ "sliding_attention",
34
+ "sliding_attention",
35
+ "sliding_attention",
36
+ "sliding_attention"
37
+ ],
38
+ "max_position_embeddings": 262144,
39
+ "max_window_layers": 5,
40
+ "model_type": "qwen3",
41
+ "num_attention_heads": 32,
42
+ "num_hidden_layers": 5,
43
+ "num_key_value_heads": 8,
44
+ "num_target_layers": 64,
45
+ "pad_token_id": 248044,
46
+ "rms_norm_eps": 1e-06,
47
+ "rope_parameters": {
48
+ "rope_theta": 10000000,
49
+ "rope_type": "default"
50
+ },
51
+ "sliding_window": 2048,
52
+ "tie_word_embeddings": false,
53
+ "transformers_version": "5.15.0",
54
+ "use_cache": true,
55
+ "use_sliding_window": true,
56
+ "vocab_size": 248320,
57
+ "quantization": {
58
+ "group_size": 64,
59
+ "bits": 4
60
+ },
61
+ "quantization_config": {
62
+ "group_size": 64,
63
+ "bits": 4
64
+ }
65
+ }
load_draft_q4.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """支援量化 draft 的載入器(原 local_load 只支援 bf16)。"""
2
+ import json
3
+ from pathlib import Path
4
+ import mlx.core as mx
5
+ import mlx.nn as nn
6
+ from dflash_port.model_mlx import DFlash2DraftModel, DFlashDraftModel, DFlashConfig
7
+ from dflash_port import local_load as _ll
8
+
9
+
10
+ def load_draft_any(path):
11
+ path = Path(path)
12
+ cfg = json.loads((path / "config.json").read_text())
13
+ weights = {k: v for f in path.glob("*.safetensors") for k, v in mx.load(str(f)).items()}
14
+
15
+ # codebook 鍵名在兩種發佈裡不一致,統一成 .weight 結尾
16
+ for name in ("predecessor_codebook", "successor_codebook"):
17
+ bare = f"candidate_selector.{name}"
18
+ if bare in weights and f"{bare}.weight" not in weights:
19
+ weights[f"{bare}.weight"] = weights.pop(bare)
20
+
21
+ q = cfg.get("quantization") or {}
22
+ if not q: # 非量化 → 走原本的路徑
23
+ return _ll.load_draft_from_dir(path)
24
+
25
+ # 重用原載入器的 config 組裝(複製其邏輯的關鍵欄位)
26
+ d = cfg.get("dflash_config", {})
27
+ rope = cfg.get("rope_parameters") or cfg.get("rope_scaling")
28
+ layer_types = tuple(cfg.get("layer_types") or ["full_attention"] * cfg["num_hidden_layers"])
29
+ config = DFlashConfig(
30
+ hidden_size=cfg["hidden_size"], num_hidden_layers=cfg["num_hidden_layers"],
31
+ num_attention_heads=cfg["num_attention_heads"], num_key_value_heads=cfg["num_key_value_heads"],
32
+ head_dim=cfg["head_dim"], intermediate_size=cfg["intermediate_size"],
33
+ vocab_size=cfg["vocab_size"], rms_norm_eps=cfg["rms_norm_eps"],
34
+ rope_theta=cfg.get("rope_theta", (rope or {}).get("rope_theta", 10000.0)),
35
+ max_position_embeddings=cfg["max_position_embeddings"],
36
+ block_size=int(d.get("block_size", 16)), target_layer_ids=tuple(d["target_layer_ids"]),
37
+ num_target_layers=cfg["num_target_layers"], mask_token_id=d["mask_token_id"],
38
+ rope_scaling=rope, layer_types=layer_types, sliding_window=cfg.get("sliding_window"),
39
+ final_logit_softcapping=d.get("final_logit_softcapping", cfg.get("final_logit_softcapping")),
40
+ input_embedding_scale=float(d.get("input_embedding_scale", 1.0)),
41
+ output_multiplier=float(d.get("output_multiplier", 1.0)),
42
+ conv_kernel_size=int(d.get("conv_kernel_size", 0)), conv_group_size=int(d.get("conv_group_size", 0)),
43
+ selector_rank=int(d.get("selector_rank", 0)), selector_top_k=int(d.get("selector_top_k", 0)),
44
+ is_causal=cfg.get("is_causal"),
45
+ )
46
+ klass = DFlash2DraftModel if "DFlash2DraftModel" in (cfg.get("architectures") or []) else DFlashDraftModel
47
+ model = klass(config)
48
+
49
+ # 只量化「權重檔裡真的有 scales」的模組 —— 與 mlx-lm 的判斷方式一致
50
+ qset = {k[:-len(".scales")] for k in weights if k.endswith(".scales")}
51
+ nn.quantize(model, group_size=int(q["group_size"]), bits=int(q["bits"]),
52
+ class_predicate=lambda p, m: p in qset)
53
+ model.eval()
54
+ model.load_weights(list(weights.items()))
55
+ mx.eval(model.parameters())
56
+ return model
results.json ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "machine": "Apple M5 Max, 137 GB, macOS 26.4.1",
3
+ "stack": {
4
+ "mlx": "0.32.0",
5
+ "mlx_lm": "0.31.3"
6
+ },
7
+ "protocol": "interleaved A/B, 90s cooldown before each measurement, greedy, 300 tokens, K=4, GPU verified idle",
8
+ "draft_comparison": {
9
+ "target": "tozp/Qwen3.8-27B-OBLITERATED-V2-mlx-4bit",
10
+ "bf16_draft": {
11
+ "repo": "z-lab/Qwen3.8-27B-DFlash2",
12
+ "size_gb": 3.85,
13
+ "tok_per_s": [
14
+ 59.8,
15
+ 60.3
16
+ ],
17
+ "accept_pct": [
18
+ 87.9,
19
+ 87.9
20
+ ]
21
+ },
22
+ "q4_draft": {
23
+ "repo": "ProCreations/Qwen3.8-27B-DFlash2-MLXFast-Q4",
24
+ "size_gb": 1.27,
25
+ "tok_per_s": [
26
+ 76.1,
27
+ 76.0
28
+ ],
29
+ "accept_pct": [
30
+ 86.9,
31
+ 86.9
32
+ ]
33
+ },
34
+ "delta": {
35
+ "tok_per_s_pct": 26.4,
36
+ "accept_pp": -1.0,
37
+ "memory_gb": -2.58
38
+ }
39
+ },
40
+ "target_comparison_bf16_draft": {
41
+ "mxfp4": {
42
+ "dense": [
43
+ 35.4,
44
+ 35.0,
45
+ 35.6
46
+ ],
47
+ "dflash": [
48
+ 54.0,
49
+ 54.1,
50
+ 53.8
51
+ ],
52
+ "accept_pct": 82,
53
+ "token_exact": true
54
+ },
55
+ "heretic_ara": {
56
+ "dense": [
57
+ 33.0,
58
+ 33.3,
59
+ 33.5
60
+ ],
61
+ "dflash": [
62
+ 61.7,
63
+ 62.2,
64
+ 62.3
65
+ ],
66
+ "accept_pct": 89,
67
+ "token_exact": false
68
+ },
69
+ "obliterated_v2": {
70
+ "dense": [
71
+ 33.0,
72
+ 33.3,
73
+ 33.6
74
+ ],
75
+ "dflash": [
76
+ 61.2,
77
+ 61.4,
78
+ 61.6
79
+ ],
80
+ "accept_pct": 88,
81
+ "token_exact": true
82
+ }
83
+ },
84
+ "server_verification": {
85
+ "load_s": 0.9,
86
+ "accept_rate": 0.917,
87
+ "tok_per_s": 75.4
88
+ },
89
+ "thermal_drift_uncontrolled": {
90
+ "same_measurement_start": 34.9,
91
+ "after_3min": 26.8,
92
+ "pct": -23
93
+ }
94
+ }