HelloSun commited on
Commit
12eb3a5
·
verified ·
1 Parent(s): aac3432

EPM: expert paging CPU inference for MiniCPM-V-4.7-35B-A3B Q4_K_M

Browse files

Port the 8 GiB expert pager to openbmb/MiniCPM-V-4.7-35B-A3B-Q4_K_M.gguf
(19.71 GiB, qwen35moe, 40 layers, 256 experts, top-8).

Measured under an 8 GiB / no-swap ceiling enforced by tools/strict8g.py:
5.83 tok/s median (N=9), peak anon RSS 7799 MiB, VmSwap 0, 9/9 strict_ok.
Unmodified llama.cpp cannot load the model at that ceiling.

Correctness: 3-layer verification passes; 5 runs x 48 tokens x 248144 vocab
logits bit-identical.

Port-specific changes: GGUF BOOL-array header fix, calibrated SDQ_RESERVE_MB,
two expert size classes, hybrid SSM/attention thread behaviour.

.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ # built by tools/strict8g.py at run time
4
+ tools/*.so
5
+ # transient per-run records written by the bench scripts
6
+ bench/results/.*
7
+ *.gguf
8
+ *.bin
README.md ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: gguf
4
+ base_model: openbmb/MiniCPM-V-4.7-35B-A3B
5
+ pipeline_tag: text-generation
6
+ tags:
7
+ - minicpm
8
+ - qwen35moe
9
+ - gguf
10
+ - moe
11
+ - cpu
12
+ - low-memory
13
+ - expert-offloading
14
+ - no-swap
15
+ ---
16
+
17
+ # EPM:MiniCPM-V-4.7-35B-A3B 的 8 GiB 專家分頁 CPU 推論
18
+
19
+ 在 **8 GiB RAM、沒有 swap** 的條件下,用 CPU 跑 19.71 GiB 的
20
+ `MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf`。
21
+
22
+ 同一個模型、同樣的 8 GiB 上限之下,**未修改的 llama.cpp 連模型都載不進去**
23
+ (它要配置一個 14.4 GB 的 CPU_REPACK buffer,在載入階段就失敗)。EPM 把路由
24
+ 專家權重留在儲存裝置,只把當前選中的專家分頁載入受限的 CPU arena:
25
+ **5.83 tok/s,峰值匿名 RSS 7799 MiB,VmSwap 全程 0 MiB。**
26
+
27
+ 這個 repo 收錄實作(`cpp/` + `patches/`)、量測工具與全部證據。
28
+ **不包含模型權重。**
29
+
30
+ > **名稱說明**:EPM = Expert Paging for MiniCPM。本專案**不是**論文中的 SDQ
31
+ > (Sparse Decomposed Quantization):沒有 2:4 結構化稀疏跳零計算,也不宣稱
32
+ > GPU 稀疏加速。`sdq/` Python package、`sdq_*` C++ 檔案與 `SDQ_*` 環境變數是
33
+ > 歷史 implementation identifier,保留以免破壞既有驗證工具;對外產品名稱與
34
+ > 執行入口是 EPM / `epq-chat`。
35
+
36
+ ---
37
+
38
+ ## 快速開始
39
+
40
+ 需求:Linux x86-64、C++ 工具鏈、CMake、Git、Python 3、19.71 GB 磁碟空間。
41
+ 已測 llama.cpp commit `9d7d5049804be03fce6575cc9f49c058b3c48788`。
42
+
43
+ ```bash
44
+ python3 demo.py --once
45
+ ```
46
+
47
+ 從頭開始時,腳本會取得本 repo 原始碼、從 `openbmb/MiniCPM-V-4.7-35B-A3B-gguf`
48
+ 下載 GGUF、套用 patch、編譯,然後跑一句示範。模型已下載的話:
49
+
50
+ ```bash
51
+ python3 demo.py --model-path /path/to/MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf \
52
+ --once --ctx 4096 --threads 8 --n-predict 48
53
+ ```
54
+
55
+ 互動式聊天:
56
+
57
+ ```bash
58
+ python3 demo.py --model-path /path/to/MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf \
59
+ --ctx 4096 --threads 8
60
+ ```
61
+
62
+ **記憶體預算與 reserve**:8 GiB 目標用 `--ram-budget-mb 8192` +
63
+ `SDQ_RESERVE_MB=512`(這個模型在 ctx=4096 / ubatch=128 下校正出來的值,
64
+ 見 `docs/DESIGN.md` §3)。換 RAM 預算要重新校正。
65
+
66
+ 此 GGUF 不含 vision projector,本路徑只支援文字推論。已驗證的 context 是 4K;
67
+ 模型原生支援 262 K,但在這個 RAM 預算下沒有驗證過。
68
+
69
+ ---
70
+
71
+ ## 在 8 GiB / no-swap 下量到的結果
72
+
73
+ **每次執行都由 `tools/strict8g.py` 管治**:所有**匿名**記憶體映射被 `mlock()`
74
+ 鎖住(被鎖的頁永遠不會被寫到 swap,所以 no-swap 是核心保證的),
75
+ 實體常駐量有硬上限,超標立刻 `SIGKILL`,另外 `RLIMIT_DATA` 當第二道閘門。
76
+ **`strict_ok=False` 的樣本一律不採用。**
77
+
78
+ ### 標配(N=9,配對交錯,提示詞中英交替)
79
+
80
+ | 指標 | 值 |
81
+ | --- | ---: |
82
+ | **Decode tok/s(中位數)** | **5.83** |
83
+ | 單輪全距 | 5.19 – 6.63(抖動 25%) |
84
+ | Expert cache hit rate | 68.68% |
85
+ | SSD 讀取 / decode token | 214.1 MB |
86
+ | Arena | 6.35 GiB(3714 slots) |
87
+ | **峰值匿名 RSS** | **7799 MiB** / 8192 MiB 上限 |
88
+ | **峰值 VmSwap** | **0.000 MiB** |
89
+ | strict_ok | 9/9 |
90
+
91
+ 條件:2× Intel Xeon Platinum 8559C(AVX-512 / AMX),`ctx=4096`、`ubatch=128`、
92
+ 8 threads、greedy、48 requested tokens。
93
+
94
+ > 這是**共享主機**,同一組設定的單輪抖動可達 25%。數字足以支撐「能不能跑」
95
+ > 與「大概多快」,不足以支撐 5% 等級的宣稱。
96
+
97
+ ### 同一個 8 GiB 上限下的傳統 llama.cpp
98
+
99
+ | 路徑 | 結果 |
100
+ | --- | --- |
101
+ | 傳統 llama.cpp `llama-completion`(**未套 patch**) | **無法載入模型** |
102
+ | EPM `epq-chat` | **5.83 tok/s**,峰值匿名 RSS 7799 MiB,VmSwap 0 |
103
+
104
+ 失敗點:
105
+
106
+ ```
107
+ ggml_aligned_malloc: insufficient memory (attempted to allocate 14400.00 MB)
108
+ ggml_backend_buft_alloc_buffer_n_default: failed to allocate CPU_REPACK buffer of size 15099494400
109
+ llama_model_load: error loading model: unable to allocate CPU_REPACK buffer
110
+ ```
111
+
112
+ 對照必須在**同一個上限**下建立。先量「兩邊都沒記憶體限制」再比較,是把 RAM
113
+ 這個變數漏掉了。
114
+
115
+ ### Pager budget 曲線(實體上限固定 8 GiB,只有 arena 大小在變,N=5)
116
+
117
+ | Budget | Arena | 槽位 | 命中率 | SSD MB/token | Decode tok/s | 峰值匿名 RSS | VmSwap |
118
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
119
+ | 2 GiB | 0.57 GiB | 332 | 22.72% | 526.2 | 4.32 | 1842 MiB | 0 |
120
+ | 3 GiB | 1.53 GiB | 895 | 50.19% | 340.0 | 5.16 | 2828 MiB | 0 |
121
+ | 4 GiB | 2.50 GiB | 1459 | 59.81% | 275.8 | 5.48 | 3817 MiB | 0 |
122
+ | 5 GiB | 3.46 GiB | 2023 | 64.88% | 240.8 | 5.82 | 4805 MiB | 0 |
123
+ | 6 GiB | 4.43 GiB | 2587 | 67.52% | 222.2 | 5.38 | 5793 MiB | 0 |
124
+ | 7 GiB | 5.39 GiB | 3150 | 68.60% | 214.0 | 5.10 | 6779 MiB | 0 |
125
+ | **8 GiB** | **6.35 GiB** | **3714** | **70.74%** | **198.9** | **5.45** | **7766 MiB** | **0** |
126
+
127
+ 2 GiB 只有 332 個槽位。單一 token 的工作集是 320 個 layer-expert,但**槽位還分兩種
128
+ ��寸**:20 層用大槽位、20 層用小槽位,所以每個 token 需要的是 160 個大槽位加
129
+ 160 個小槽位,而不是 320 個任一尺寸的槽位。2 GiB 之下 arena 只有 0.57 GiB,
130
+ 湊不出這個組合,命中率因此崩到 22.7%(8 GiB 之下 3714 個槽位裡約四成是大槽位,
131
+ 命中率回到 70.7%)。
132
+
133
+ 命中率在 8 GiB 這個端點**仍然在爬升**(8 GiB 70.7% → 16 GiB 77.5%)→
134
+ **瓶頸是記憶體上限,不是命中率飽和。**
135
+
136
+ > 這一欄與上面標配的命中率(68.68%)不同是正常的:這裡是 N=5、提示詞輪替不同,
137
+ > 標配是 N=9。兩者都受同一個 8 GiB 上限管治。
138
+
139
+ ### RAM 擴張曲線:4 / 8 / 16 / 64 GiB
140
+
141
+ `--ram-budget-mb` 從 4 GiB 拉到 64 GiB。每次執行都由 `tools/strict8g.py` 管治,
142
+ 36/36 輪 `strict_ok=True`,`VmSwap` 全程 0。
143
+
144
+ **兩點方法學說明,讀表前請先看:**
145
+
146
+ 1. **這條曲線不用 `mlock`。** `mlock()` 會強制整個映射常駐 —— 對一個 60 GiB 的
147
+ arena,它會讓 RSS 直接變成 60 GiB,量到的就不是真實機器的行為。no-swap 的
148
+ 核心保證保留給 8 GiB 這個**部署目標**(那一點另外有完整 mlock 證據,見
149
+ [`docs/STRICT_8GIB.md`](docs/STRICT_8GIB.md))。
150
+ 2. **arena 不預先碰滿。** 全部路由專家權重只有 18.16 GiB,所以預先碰觸上限就是
151
+ 18.17 GiB;超出來的部分等到真的用到才 fault in。這就是為什麼 64 GiB 預算的
152
+ 「峰值 RSS」會停在約 24.0 GiB 而不是 62 GiB —— **那個數字才是真實的記憶體佔用。**
153
+
154
+ 結構指標(槽位、命中率、SSD 讀取量、峰值 RSS)都是設定的直接函數,不受執行抖動
155
+ 影響,四個點都列。吞吐另外做 **N=9 配對交錯** 並用 Welch t 檢定。
156
+
157
+ | Budget | Arena | 槽位 | 命中率 | SSD MB/token | 平均 tok/s | 單輪標準差 | 峰值 RSS | VmSwap |
158
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
159
+ | 4 GiB | 2.74 GiB | 1600 | 61.50% | 275.4 | 5.43 ± 0.37 | 0.37 | 3963 MiB | 0 |
160
+ | **8 GiB** | **6.36 GiB** | **3715** | **71.98%** | **199.1** | **6.33 ± 0.33** | 0.33 | **7669 MiB** | **0** |
161
+ | 16 GiB | 13.59 GiB | 7943 | 77.54% | 157.9 | 6.28 ± 0.41 | 0.41 | 15076 MiB | 0 |
162
+ | 64 GiB | 59.89 GiB | 35003 | 77.54% | 157.6 | 6.32 ± 0.51 | 0.51 | 23969 MiB | 0 |
163
+
164
+ | 比較 | 結果 | Welch t | p | 判定 |
165
+ | --- | ---: | ---: | ---: | --- |
166
+ | **4 GiB 比 8 GiB** | 5.43 → 6.33(**+16.6%**) | **−5.44** | **0.0001** | **統計顯著** |
167
+ | 8 GiB 比 16 GiB | 6.33 → 6.28(−0.8%) | −0.30 | 0.76 | 無法分辨 |
168
+ | 8 GiB 比 64 GiB | 6.33 → 6.32(−0.2%) | −0.05 | 0.96 | 無法分辨 |
169
+
170
+ #### 這條曲線的重點
171
+
172
+ * **4 GiB → 8 GiB 是真實且顯著的提升:+16.6%**(t = −5.44,p = 0.0001)。4 GiB 時
173
+ 命中率只有 61.5%、SSD/token 275.4 MB(8 GiB 的 1.4 倍),因為 arena 湊不出
174
+ 「160 大 + 160 小」的工作集。
175
+ * **8 GiB 以上再加記憶體完全沒有可量測的收益**:8 vs 16(t = −0.30, p = 0.76)與
176
+ 8 vs 64(t = −0.05, p = 0.96)全部無法分辨,而記憶體用量是 2 倍到 8 倍。
177
+ 結構指標也說明原因:命中率在 **16 GiB 就飽和在 77.54%**,SSD/token 飽和在她
178
+ ~158 MB。
179
+ * **真正的記憶體需求飽和在約 24.0 GiB 峰值**(18.16 GiB 全部專家權重 + 1.54 GiB
180
+ 非專家權重 + KV cache / compute buffer / runtime)。
181
+ * **部署結論:8 GiB 就是對的容量。** 它拿到飽和命中率的 93%(72.0% 對 77.5%),
182
+ 而且是整條曲線裡 throughput 顯著優於更低設定、又與所有更高設定打平的唯一一點。
183
+
184
+ > 逐輪原始資料:`bench/results/ram_sweep_n9.json`(N=9)與
185
+ > `bench/results/ram_sweep.json`(N=3,含 32 GiB 點)。
186
+
187
+ ---
188
+
189
+ ## 記憶體怎麼配置的
190
+
191
+ 模型 metadata:每個 MoE 層 256 個路由專家,每個 token 選 8 個。
192
+ GGUF 21,166,255,008 B,其中路由專家權重 **18.16 GiB**、非專家常駐權重
193
+ **1.54 GiB**。
194
+
195
+ EPM 把未啟用的專家留在檔案裡,依需求把選中的專家載入記憶體 arena。關鍵實作:
196
+
197
+ * **三段並行讀取**:一個 expert 的 gate/up/down 在檔案上互不相鄰,循序讀是
198
+ 3 × 延遲的鏈;改成三段同時讀之後明顯變快。
199
+ * **大小分級槽位**:**這個模型有兩種 expert 大小** —— 20 層的 `ffn_down_exps`
200
+ 是 Q6_K(2,039,808 B/expert),另外 20 層是 Q4_K(1,769,472 B/expert)。
201
+ 依分佈分大小槽位,而不是全部按最大的配。
202
+ * **需求觸發的 LRU 淘汰**,加上對「本 token 使用中」的 pin 保護。
203
+ * **讀取與計算重疊**(mode C),每個 expert 算完立刻釋放槽位。
204
+ * **跨層預取預設關閉**:它是負結果(見 `docs/NEGATIVE_RESULTS.md`)。
205
+
206
+ ### Arena sizing
207
+
208
+ ```
209
+ arena = RAM 預算 − 匿名 RSS − SDQ_RESERVE_MB
210
+ ```
211
+
212
+ 1. **扣的是「匿名 RSS」,不是「總 RSS」。** `statm` 的 resident 把 mmap 進來的
213
+ 模型權重頁也算成已用,但那些是乾淨的 file-backed 頁,核心會回��。
214
+ 2. **`RLIMIT_DATA` 只當上界,不當預算來源。** 它算的是匿名**虛擬**記憶體,
215
+ 而 llama.cpp 會配置大量「映射了但永遠不會 touch」的 buffer。
216
+
217
+ `SDQ_RESERVE_MB` 是**逐模型、逐 ctx/ubatch 校正**的。本模型 ctx=4096 /
218
+ ubatch=128 下,512 MiB 留 446 MiB 餘量。峰值匿名 RSS 幾乎與 reserve 成 1:1
219
+ 反比。**換 RAM 預算必須重新校正** —— 用固定的 512 MiB 跑 4 GiB 會超出上限。
220
+ 完整校正表見 [`docs/DESIGN.md`](docs/DESIGN.md) §3。
221
+
222
+ ---
223
+
224
+ ## 正確性
225
+
226
+ ```bash
227
+ python3 -m sdq.verify --model <gguf> --bin <epq-chat> --fast
228
+ python3 tools/determinism_check.py --model <gguf> --bin <epq-chat> --runs 5 -n 48
229
+ ```
230
+
231
+ | 層 | 內容 | 結果 |
232
+ | --- | --- | --- |
233
+ | 1 kernel | 單一 expert 前向 vs NumPy 純重算(L0 E3 / L17 E200 / L33 E7 / **L39 E100**) | 全 PASS,max abs diff ≤ 3.7e-09 |
234
+ | 2 整層 | 真實路由輸入 vs NumPy,L0 / L39 | L0 PASS(1.1e-08);L39 PASS(8.8e-05,落在單一 Q8_K LSB 翻轉上界 3.2e-02 之內) |
235
+ | 2b 預取壓力 | 預取開 + 極小 arena 強制回收,兩種槽位配置 | 4/4 PASS |
236
+ | 3 端到端 A/B | vs llama.cpp 原生 `mul_mat_id` | **在 8 GiB 上不可能執行** —— 需要整個 19.71 GiB 模型常駐 |
237
+
238
+ 決定性:**5 次執行 × 48 token × 248,144 vocab 的 logits 全部逐位元組相同。**
239
+
240
+ 過程中抓到並修正的問題包括一個 GGUF 標頭解析在 **BOOL 陣列**上錯位的 bug
241
+ (`qwen35moe.attention.recurrent_layers`,這個混合架構模型才有)。
242
+
243
+ L39 那一組 8.8e-05 的差異被完整追查到根因,並證明是 **Q8_K 量化邊界**造成、
244
+ 不是 pager 的錯誤:把 C++ 端自己的 SiLU 中間值餵回 NumPy 參考,輸出立刻從
245
+ 7.9e-04 掉到 **6.7e-08**。完整證據鏈見 [`docs/CORRECTNESS.md`](docs/CORRECTNESS.md) §4。
246
+
247
+ ---
248
+
249
+ ## 這個模型特有的兩件事
250
+
251
+ 相對於同系列的 Qwen3.6-35B-A3B 實作,本模型有兩個結構差異,
252
+ **移植時必須重新推導、不能照抄**:
253
+
254
+ 1. **兩種 expert 大小**(20 層 2,039,808 B / 20 層 1,769,472 B)。槽位尺寸必須
255
+ 從 metadata 推導。參考實作是 37 層 + 3 層,比例完全不同。
256
+ 2. **混合架構**:40 層裡只有 10 層是全注意力,30 層是 SSM 層。直接後果是
257
+ **執行緒數的結論相反** —— 這裡 12 執行緒比 8 快約 6.5%,參考實作則是
258
+ 8 之後開始變慢。SSM 層不受序列維度限制,能吃滿更多執行緒。
259
+
260
+ 另外模型 metadata 裡多了一個 BOOL 陣列(`recurrent_layers`),
261
+ 參考實作的 GGUF 標頭解析器會在那裡錯位。
262
+
263
+ ---
264
+
265
+ ## 其他結果
266
+
267
+ ### 執行緒數
268
+
269
+ | threads | 4 | 8 | 10 | 12 | 16 |
270
+ | --- | ---: | ---: | ---: | ---: | ---: |
271
+ | 平均 tok/s | 4.65 | 5.96 | 6.16 | **6.35** | 6.27 |
272
+
273
+ 4 → 8 是真實且顯著的提升(+28%)。8 之後的差異都在單輪抖動之內。
274
+ 預設 8,**建議 12**(理由見上一節)。
275
+
276
+ ### 預取(負結果)
277
+
278
+ 跨層預取 5.95 ± 0.37、僅 end_token 5.78 ± 0.33、關閉 6.03 ± 0.44 ——
279
+ **差異遠小於單輪抖動,統計上無法分辨**,而且多數輪次 SSD 讀取量上升。
280
+ **預設關閉。**
281
+
282
+ ### 儲存裝置會改變結論
283
+
284
+ 本機是本地 NVMe。慢速儲存上 I/O 排程是瓶頸,預取與並行讀取才有意義;
285
+ 快速 NVMe 上命中率(arena 大小)才是瓶頸。**本 repo 的 tok/s 不能直接套用到
286
+ 慢速儲存的機器上。** 請先跑 `tools/io_probe.py` 再決定要最佳化哪一邊。
287
+
288
+ ---
289
+
290
+ ## 這個量測**沒有**證明的事
291
+
292
+ 1. **不是 cgroup 驗證。** 這個環境的 cgroup2 掛載唯讀、沒有 `CAP_SYS_ADMIN`。
293
+ 目前的保證是「kernel `RLIMIT_DATA` + 取樣殺行程」與「mlock 匿名頁」兩條腿。
294
+ 2. **主機 swap 是開啟的。** no-swap 由 `mlock` 讓它不可能發生,不是把主機的
295
+ swap 關掉。
296
+ 3. **`brk()` 主堆疊未被鎖**(glibc 內部直接下 syscall)。大型配置都走 `mmap`。
297
+ 4. **共享主機**,單輪抖動可達 25%。
298
+ 5. **只驗到 4K context**;模型原生支援 262 K。
299
+ 6. **只做文字推論**,vision projector 是另一個檔案。
300
+ 7. **儲存裝置比歷史量測的機器快一到兩個數量級。**
301
+
302
+ 完整方法與證據見 [`docs/STRICT_8GIB.md`](docs/STRICT_8GIB.md)。
303
+
304
+ ---
305
+
306
+ ## Repository layout
307
+
308
+ | 路徑 | 內容 |
309
+ | --- | --- |
310
+ | `cpp/` | C++ expert pager(`sdq_pager.*`、`sdq_moe.cpp`、`sdq_buft.cpp`、`sdq_cli.cpp`、`sdq_mtp.cpp`) |
311
+ | `patches/` | 把 pager 接進 llama.cpp 的 patch(針對固定 commit `9d7d5049`) |
312
+ | `sdq/` | 設定、受管治的基準測量、實驗、驗證、HF sync |
313
+ | `tools/` | 8 GiB/no-swap 管治、GGUF 索引、決定性檢查、儲存 probe |
314
+ | `bench/` | 量測腳本與 `bench/results/` 原始輸出(JSON) |
315
+ | `docs/` | 見下 |
316
+
317
+ 文件:
318
+
319
+ | 文件 | 內容 |
320
+ | --- | --- |
321
+ | [`docs/STRICT_8GIB.md`](docs/STRICT_8GIB.md) | **8 GiB / no-swap 的方法、證據與限制**(先讀這份) |
322
+ | [`docs/DESIGN.md`](docs/DESIGN.md) | 記憶體帳目、SSD→RAM→CPU 設計、reserve 校正 |
323
+ | [`docs/CORRECTNESS.md`](docs/CORRECTNESS.md) | 三層驗證、決定性證據、Q8_K 量化邊界分析 |
324
+ | [`docs/NEGATIVE_RESULTS.md`](docs/NEGATIVE_RESULTS.md) | 試過但沒留下的最佳化 |
325
+
326
+ ---
327
+
328
+ ## 重現
329
+
330
+ ```bash
331
+ # 受管治的單次執行
332
+ python3 tools/strict8g.py --limit-mb 8192 --record /tmp/one.json -- \
333
+ epq-chat -m model.gguf -p "..." -n 48 -c 4096 -t 8 -b 128 \
334
+ --temp 0 --no-chat --no-interactive --ram-budget-mb 8192
335
+
336
+ # 標配基準測量(9 輪,配對交錯)
337
+ python3 -m sdq.strict_bench --model model.gguf --bins base=./build/bin/epq-chat \
338
+ --rounds 9 --strict-mb 8192 --label headline
339
+
340
+ # 正確性與決定性
341
+ python3 -m sdq.verify --model model.gguf --bin ./build/bin/epq-chat --fast
342
+ python3 tools/determinism_check.py --model model.gguf --bin ./build/bin/epq-chat --runs 5 -n 48
343
+
344
+ # 曲線與負結果
345
+ python3 bench/run_budget_curve.py --model model.gguf --bin ./build/bin/epq-chat --rounds 5
346
+ python3 bench/run_ramsweep.py --model model.gguf --bin ./build/bin/epq-chat --rounds 3
347
+ python3 bench/run_n9.py --model model.gguf --bin ./build/bin/epq-chat --rounds 9
348
+ python3 bench/run_negative.py --which prefetch --model model.gguf --bin ./build/bin/epq-chat
349
+ python3 bench/run_negative.py --which threads --model model.gguf --bin ./build/bin/epq-chat
350
+
351
+ # 儲存裝置特性、路由重疊、GGUF 結構
352
+ python3 tools/io_probe.py --model model.gguf
353
+ python3 tools/route_overlap.py --model model.gguf
354
+ python3 tools/gguf_index.py model.gguf --json bench/expert_index.json
355
+ ```
356
+
357
+ ---
358
+
359
+ ## License
360
+
361
+ MiniCPM-V base model 由 upstream 發布;請以 upstream model card 的目前條款為準。
362
+ 本 repository 包含推論軟體與量測結果,**不包含模型權重**。
363
+ llama.cpp 與其相依套件保留各自授權條款。
bench/expert_index.json ADDED
@@ -0,0 +1,1836 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "file": "/root/models/minicpm-v4.7-35b-a3b-q4_k_m.gguf",
3
+ "file_size": 21166255008,
4
+ "gguf_version": 3,
5
+ "architecture": "qwen35moe",
6
+ "model_name": "35a3_hf_v47_0923",
7
+ "quant_file_type": 15,
8
+ "n_layer": 40,
9
+ "n_expert": 256,
10
+ "n_expert_used": 8,
11
+ "n_embd": 2048,
12
+ "n_ff_exp": 512,
13
+ "n_ff_shexp": 512,
14
+ "context_length": 262144,
15
+ "n_head": 16,
16
+ "n_head_kv": 2,
17
+ "head_dim": 256,
18
+ "rope_sections": [
19
+ 11,
20
+ 11,
21
+ 10,
22
+ 0
23
+ ],
24
+ "full_attention_interval": 4,
25
+ "data_start": 10984608,
26
+ "alignment": 32,
27
+ "resident_ram_bytes": 1651756800,
28
+ "expert_total_bytes": 19503513600,
29
+ "accounted_bytes": 21155270400,
30
+ "per_layer": [
31
+ {
32
+ "layer": 0,
33
+ "experts": {
34
+ "ffn_down_exps": {
35
+ "file_offset": 732225952,
36
+ "file_end": 952426912,
37
+ "type": "Q6_K",
38
+ "type_id": 14,
39
+ "dims": [
40
+ 512,
41
+ 2048,
42
+ 256
43
+ ],
44
+ "bytes_per_expert": 860160,
45
+ "n_expert": 256
46
+ },
47
+ "ffn_gate_exps": {
48
+ "file_offset": 953287072,
49
+ "file_end": 1104282016,
50
+ "type": "Q4_K",
51
+ "type_id": 12,
52
+ "dims": [
53
+ 2048,
54
+ 512,
55
+ 256
56
+ ],
57
+ "bytes_per_expert": 589824,
58
+ "n_expert": 256
59
+ },
60
+ "ffn_up_exps": {
61
+ "file_offset": 1106977184,
62
+ "file_end": 1257972128,
63
+ "type": "Q4_K",
64
+ "type_id": 12,
65
+ "dims": [
66
+ 2048,
67
+ 512,
68
+ 256
69
+ ],
70
+ "bytes_per_expert": 589824,
71
+ "n_expert": 256
72
+ }
73
+ },
74
+ "bytes_per_expert_total": 2039808
75
+ },
76
+ {
77
+ "layer": 1,
78
+ "experts": {
79
+ "ffn_down_exps": {
80
+ "file_offset": 1281983648,
81
+ "file_end": 1502184608,
82
+ "type": "Q6_K",
83
+ "type_id": 14,
84
+ "dims": [
85
+ 512,
86
+ 2048,
87
+ 256
88
+ ],
89
+ "bytes_per_expert": 860160,
90
+ "n_expert": 256
91
+ },
92
+ "ffn_gate_exps": {
93
+ "file_offset": 1503044768,
94
+ "file_end": 1654039712,
95
+ "type": "Q4_K",
96
+ "type_id": 12,
97
+ "dims": [
98
+ 2048,
99
+ 512,
100
+ 256
101
+ ],
102
+ "bytes_per_expert": 589824,
103
+ "n_expert": 256
104
+ },
105
+ "ffn_up_exps": {
106
+ "file_offset": 1656734880,
107
+ "file_end": 1807729824,
108
+ "type": "Q4_K",
109
+ "type_id": 12,
110
+ "dims": [
111
+ 2048,
112
+ 512,
113
+ 256
114
+ ],
115
+ "bytes_per_expert": 589824,
116
+ "n_expert": 256
117
+ }
118
+ },
119
+ "bytes_per_expert_total": 2039808
120
+ },
121
+ {
122
+ "layer": 2,
123
+ "experts": {
124
+ "ffn_down_exps": {
125
+ "file_offset": 1831741344,
126
+ "file_end": 2051942304,
127
+ "type": "Q6_K",
128
+ "type_id": 14,
129
+ "dims": [
130
+ 512,
131
+ 2048,
132
+ 256
133
+ ],
134
+ "bytes_per_expert": 860160,
135
+ "n_expert": 256
136
+ },
137
+ "ffn_gate_exps": {
138
+ "file_offset": 2052802464,
139
+ "file_end": 2203797408,
140
+ "type": "Q4_K",
141
+ "type_id": 12,
142
+ "dims": [
143
+ 2048,
144
+ 512,
145
+ 256
146
+ ],
147
+ "bytes_per_expert": 589824,
148
+ "n_expert": 256
149
+ },
150
+ "ffn_up_exps": {
151
+ "file_offset": 2206492576,
152
+ "file_end": 2357487520,
153
+ "type": "Q4_K",
154
+ "type_id": 12,
155
+ "dims": [
156
+ 2048,
157
+ 512,
158
+ 256
159
+ ],
160
+ "bytes_per_expert": 589824,
161
+ "n_expert": 256
162
+ }
163
+ },
164
+ "bytes_per_expert_total": 2039808
165
+ },
166
+ {
167
+ "layer": 3,
168
+ "experts": {
169
+ "ffn_down_exps": {
170
+ "file_offset": 2378625696,
171
+ "file_end": 2598826656,
172
+ "type": "Q6_K",
173
+ "type_id": 14,
174
+ "dims": [
175
+ 512,
176
+ 2048,
177
+ 256
178
+ ],
179
+ "bytes_per_expert": 860160,
180
+ "n_expert": 256
181
+ },
182
+ "ffn_gate_exps": {
183
+ "file_offset": 2599686816,
184
+ "file_end": 2750681760,
185
+ "type": "Q4_K",
186
+ "type_id": 12,
187
+ "dims": [
188
+ 2048,
189
+ 512,
190
+ 256
191
+ ],
192
+ "bytes_per_expert": 589824,
193
+ "n_expert": 256
194
+ },
195
+ "ffn_up_exps": {
196
+ "file_offset": 2753376928,
197
+ "file_end": 2904371872,
198
+ "type": "Q4_K",
199
+ "type_id": 12,
200
+ "dims": [
201
+ 2048,
202
+ 512,
203
+ 256
204
+ ],
205
+ "bytes_per_expert": 589824,
206
+ "n_expert": 256
207
+ }
208
+ },
209
+ "bytes_per_expert_total": 2039808
210
+ },
211
+ {
212
+ "layer": 4,
213
+ "experts": {
214
+ "ffn_down_exps": {
215
+ "file_offset": 2923459232,
216
+ "file_end": 3143660192,
217
+ "type": "Q6_K",
218
+ "type_id": 14,
219
+ "dims": [
220
+ 512,
221
+ 2048,
222
+ 256
223
+ ],
224
+ "bytes_per_expert": 860160,
225
+ "n_expert": 256
226
+ },
227
+ "ffn_gate_exps": {
228
+ "file_offset": 3144520352,
229
+ "file_end": 3295515296,
230
+ "type": "Q4_K",
231
+ "type_id": 12,
232
+ "dims": [
233
+ 2048,
234
+ 512,
235
+ 256
236
+ ],
237
+ "bytes_per_expert": 589824,
238
+ "n_expert": 256
239
+ },
240
+ "ffn_up_exps": {
241
+ "file_offset": 3298210464,
242
+ "file_end": 3449205408,
243
+ "type": "Q4_K",
244
+ "type_id": 12,
245
+ "dims": [
246
+ 2048,
247
+ 512,
248
+ 256
249
+ ],
250
+ "bytes_per_expert": 589824,
251
+ "n_expert": 256
252
+ }
253
+ },
254
+ "bytes_per_expert_total": 2039808
255
+ },
256
+ {
257
+ "layer": 5,
258
+ "experts": {
259
+ "ffn_down_exps": {
260
+ "file_offset": 3468891552,
261
+ "file_end": 3619886496,
262
+ "type": "Q4_K",
263
+ "type_id": 12,
264
+ "dims": [
265
+ 512,
266
+ 2048,
267
+ 256
268
+ ],
269
+ "bytes_per_expert": 589824,
270
+ "n_expert": 256
271
+ },
272
+ "ffn_gate_exps": {
273
+ "file_offset": 3620476320,
274
+ "file_end": 3771471264,
275
+ "type": "Q4_K",
276
+ "type_id": 12,
277
+ "dims": [
278
+ 2048,
279
+ 512,
280
+ 256
281
+ ],
282
+ "bytes_per_expert": 589824,
283
+ "n_expert": 256
284
+ },
285
+ "ffn_up_exps": {
286
+ "file_offset": 3774166432,
287
+ "file_end": 3925161376,
288
+ "type": "Q4_K",
289
+ "type_id": 12,
290
+ "dims": [
291
+ 2048,
292
+ 512,
293
+ 256
294
+ ],
295
+ "bytes_per_expert": 589824,
296
+ "n_expert": 256
297
+ }
298
+ },
299
+ "bytes_per_expert_total": 1769472
300
+ },
301
+ {
302
+ "layer": 6,
303
+ "experts": {
304
+ "ffn_down_exps": {
305
+ "file_offset": 3944847520,
306
+ "file_end": 4095842464,
307
+ "type": "Q4_K",
308
+ "type_id": 12,
309
+ "dims": [
310
+ 512,
311
+ 2048,
312
+ 256
313
+ ],
314
+ "bytes_per_expert": 589824,
315
+ "n_expert": 256
316
+ },
317
+ "ffn_gate_exps": {
318
+ "file_offset": 4096432288,
319
+ "file_end": 4247427232,
320
+ "type": "Q4_K",
321
+ "type_id": 12,
322
+ "dims": [
323
+ 2048,
324
+ 512,
325
+ 256
326
+ ],
327
+ "bytes_per_expert": 589824,
328
+ "n_expert": 256
329
+ },
330
+ "ffn_up_exps": {
331
+ "file_offset": 4250122400,
332
+ "file_end": 4401117344,
333
+ "type": "Q4_K",
334
+ "type_id": 12,
335
+ "dims": [
336
+ 2048,
337
+ 512,
338
+ 256
339
+ ],
340
+ "bytes_per_expert": 589824,
341
+ "n_expert": 256
342
+ }
343
+ },
344
+ "bytes_per_expert_total": 1769472
345
+ },
346
+ {
347
+ "layer": 7,
348
+ "experts": {
349
+ "ffn_down_exps": {
350
+ "file_offset": 4422255520,
351
+ "file_end": 4642456480,
352
+ "type": "Q6_K",
353
+ "type_id": 14,
354
+ "dims": [
355
+ 512,
356
+ 2048,
357
+ 256
358
+ ],
359
+ "bytes_per_expert": 860160,
360
+ "n_expert": 256
361
+ },
362
+ "ffn_gate_exps": {
363
+ "file_offset": 4643316640,
364
+ "file_end": 4794311584,
365
+ "type": "Q4_K",
366
+ "type_id": 12,
367
+ "dims": [
368
+ 2048,
369
+ 512,
370
+ 256
371
+ ],
372
+ "bytes_per_expert": 589824,
373
+ "n_expert": 256
374
+ },
375
+ "ffn_up_exps": {
376
+ "file_offset": 4797006752,
377
+ "file_end": 4948001696,
378
+ "type": "Q4_K",
379
+ "type_id": 12,
380
+ "dims": [
381
+ 2048,
382
+ 512,
383
+ 256
384
+ ],
385
+ "bytes_per_expert": 589824,
386
+ "n_expert": 256
387
+ }
388
+ },
389
+ "bytes_per_expert_total": 2039808
390
+ },
391
+ {
392
+ "layer": 8,
393
+ "experts": {
394
+ "ffn_down_exps": {
395
+ "file_offset": 4962763680,
396
+ "file_end": 5113758624,
397
+ "type": "Q4_K",
398
+ "type_id": 12,
399
+ "dims": [
400
+ 512,
401
+ 2048,
402
+ 256
403
+ ],
404
+ "bytes_per_expert": 589824,
405
+ "n_expert": 256
406
+ },
407
+ "ffn_gate_exps": {
408
+ "file_offset": 5114348448,
409
+ "file_end": 5265343392,
410
+ "type": "Q4_K",
411
+ "type_id": 12,
412
+ "dims": [
413
+ 2048,
414
+ 512,
415
+ 256
416
+ ],
417
+ "bytes_per_expert": 589824,
418
+ "n_expert": 256
419
+ },
420
+ "ffn_up_exps": {
421
+ "file_offset": 5268038560,
422
+ "file_end": 5419033504,
423
+ "type": "Q4_K",
424
+ "type_id": 12,
425
+ "dims": [
426
+ 2048,
427
+ 512,
428
+ 256
429
+ ],
430
+ "bytes_per_expert": 589824,
431
+ "n_expert": 256
432
+ }
433
+ },
434
+ "bytes_per_expert_total": 1769472
435
+ },
436
+ {
437
+ "layer": 9,
438
+ "experts": {
439
+ "ffn_down_exps": {
440
+ "file_offset": 5438719648,
441
+ "file_end": 5589714592,
442
+ "type": "Q4_K",
443
+ "type_id": 12,
444
+ "dims": [
445
+ 512,
446
+ 2048,
447
+ 256
448
+ ],
449
+ "bytes_per_expert": 589824,
450
+ "n_expert": 256
451
+ },
452
+ "ffn_gate_exps": {
453
+ "file_offset": 5590304416,
454
+ "file_end": 5741299360,
455
+ "type": "Q4_K",
456
+ "type_id": 12,
457
+ "dims": [
458
+ 2048,
459
+ 512,
460
+ 256
461
+ ],
462
+ "bytes_per_expert": 589824,
463
+ "n_expert": 256
464
+ },
465
+ "ffn_up_exps": {
466
+ "file_offset": 5743994528,
467
+ "file_end": 5894989472,
468
+ "type": "Q4_K",
469
+ "type_id": 12,
470
+ "dims": [
471
+ 2048,
472
+ 512,
473
+ 256
474
+ ],
475
+ "bytes_per_expert": 589824,
476
+ "n_expert": 256
477
+ }
478
+ },
479
+ "bytes_per_expert_total": 1769472
480
+ },
481
+ {
482
+ "layer": 10,
483
+ "experts": {
484
+ "ffn_down_exps": {
485
+ "file_offset": 5919000992,
486
+ "file_end": 6139201952,
487
+ "type": "Q6_K",
488
+ "type_id": 14,
489
+ "dims": [
490
+ 512,
491
+ 2048,
492
+ 256
493
+ ],
494
+ "bytes_per_expert": 860160,
495
+ "n_expert": 256
496
+ },
497
+ "ffn_gate_exps": {
498
+ "file_offset": 6140062112,
499
+ "file_end": 6291057056,
500
+ "type": "Q4_K",
501
+ "type_id": 12,
502
+ "dims": [
503
+ 2048,
504
+ 512,
505
+ 256
506
+ ],
507
+ "bytes_per_expert": 589824,
508
+ "n_expert": 256
509
+ },
510
+ "ffn_up_exps": {
511
+ "file_offset": 6293752224,
512
+ "file_end": 6444747168,
513
+ "type": "Q4_K",
514
+ "type_id": 12,
515
+ "dims": [
516
+ 2048,
517
+ 512,
518
+ 256
519
+ ],
520
+ "bytes_per_expert": 589824,
521
+ "n_expert": 256
522
+ }
523
+ },
524
+ "bytes_per_expert_total": 2039808
525
+ },
526
+ {
527
+ "layer": 11,
528
+ "experts": {
529
+ "ffn_down_exps": {
530
+ "file_offset": 6465615008,
531
+ "file_end": 6616609952,
532
+ "type": "Q4_K",
533
+ "type_id": 12,
534
+ "dims": [
535
+ 512,
536
+ 2048,
537
+ 256
538
+ ],
539
+ "bytes_per_expert": 589824,
540
+ "n_expert": 256
541
+ },
542
+ "ffn_gate_exps": {
543
+ "file_offset": 6617199776,
544
+ "file_end": 6768194720,
545
+ "type": "Q4_K",
546
+ "type_id": 12,
547
+ "dims": [
548
+ 2048,
549
+ 512,
550
+ 256
551
+ ],
552
+ "bytes_per_expert": 589824,
553
+ "n_expert": 256
554
+ },
555
+ "ffn_up_exps": {
556
+ "file_offset": 6770889888,
557
+ "file_end": 6921884832,
558
+ "type": "Q4_K",
559
+ "type_id": 12,
560
+ "dims": [
561
+ 2048,
562
+ 512,
563
+ 256
564
+ ],
565
+ "bytes_per_expert": 589824,
566
+ "n_expert": 256
567
+ }
568
+ },
569
+ "bytes_per_expert_total": 1769472
570
+ },
571
+ {
572
+ "layer": 12,
573
+ "experts": {
574
+ "ffn_down_exps": {
575
+ "file_offset": 6936646816,
576
+ "file_end": 7087641760,
577
+ "type": "Q4_K",
578
+ "type_id": 12,
579
+ "dims": [
580
+ 512,
581
+ 2048,
582
+ 256
583
+ ],
584
+ "bytes_per_expert": 589824,
585
+ "n_expert": 256
586
+ },
587
+ "ffn_gate_exps": {
588
+ "file_offset": 7088231584,
589
+ "file_end": 7239226528,
590
+ "type": "Q4_K",
591
+ "type_id": 12,
592
+ "dims": [
593
+ 2048,
594
+ 512,
595
+ 256
596
+ ],
597
+ "bytes_per_expert": 589824,
598
+ "n_expert": 256
599
+ },
600
+ "ffn_up_exps": {
601
+ "file_offset": 7241921696,
602
+ "file_end": 7392916640,
603
+ "type": "Q4_K",
604
+ "type_id": 12,
605
+ "dims": [
606
+ 2048,
607
+ 512,
608
+ 256
609
+ ],
610
+ "bytes_per_expert": 589824,
611
+ "n_expert": 256
612
+ }
613
+ },
614
+ "bytes_per_expert_total": 1769472
615
+ },
616
+ {
617
+ "layer": 13,
618
+ "experts": {
619
+ "ffn_down_exps": {
620
+ "file_offset": 7416928160,
621
+ "file_end": 7637129120,
622
+ "type": "Q6_K",
623
+ "type_id": 14,
624
+ "dims": [
625
+ 512,
626
+ 2048,
627
+ 256
628
+ ],
629
+ "bytes_per_expert": 860160,
630
+ "n_expert": 256
631
+ },
632
+ "ffn_gate_exps": {
633
+ "file_offset": 7637989280,
634
+ "file_end": 7788984224,
635
+ "type": "Q4_K",
636
+ "type_id": 12,
637
+ "dims": [
638
+ 2048,
639
+ 512,
640
+ 256
641
+ ],
642
+ "bytes_per_expert": 589824,
643
+ "n_expert": 256
644
+ },
645
+ "ffn_up_exps": {
646
+ "file_offset": 7791679392,
647
+ "file_end": 7942674336,
648
+ "type": "Q4_K",
649
+ "type_id": 12,
650
+ "dims": [
651
+ 2048,
652
+ 512,
653
+ 256
654
+ ],
655
+ "bytes_per_expert": 589824,
656
+ "n_expert": 256
657
+ }
658
+ },
659
+ "bytes_per_expert_total": 2039808
660
+ },
661
+ {
662
+ "layer": 14,
663
+ "experts": {
664
+ "ffn_down_exps": {
665
+ "file_offset": 7962360480,
666
+ "file_end": 8113355424,
667
+ "type": "Q4_K",
668
+ "type_id": 12,
669
+ "dims": [
670
+ 512,
671
+ 2048,
672
+ 256
673
+ ],
674
+ "bytes_per_expert": 589824,
675
+ "n_expert": 256
676
+ },
677
+ "ffn_gate_exps": {
678
+ "file_offset": 8113945248,
679
+ "file_end": 8264940192,
680
+ "type": "Q4_K",
681
+ "type_id": 12,
682
+ "dims": [
683
+ 2048,
684
+ 512,
685
+ 256
686
+ ],
687
+ "bytes_per_expert": 589824,
688
+ "n_expert": 256
689
+ },
690
+ "ffn_up_exps": {
691
+ "file_offset": 8267635360,
692
+ "file_end": 8418630304,
693
+ "type": "Q4_K",
694
+ "type_id": 12,
695
+ "dims": [
696
+ 2048,
697
+ 512,
698
+ 256
699
+ ],
700
+ "bytes_per_expert": 589824,
701
+ "n_expert": 256
702
+ }
703
+ },
704
+ "bytes_per_expert_total": 1769472
705
+ },
706
+ {
707
+ "layer": 15,
708
+ "experts": {
709
+ "ffn_down_exps": {
710
+ "file_offset": 8439498144,
711
+ "file_end": 8590493088,
712
+ "type": "Q4_K",
713
+ "type_id": 12,
714
+ "dims": [
715
+ 512,
716
+ 2048,
717
+ 256
718
+ ],
719
+ "bytes_per_expert": 589824,
720
+ "n_expert": 256
721
+ },
722
+ "ffn_gate_exps": {
723
+ "file_offset": 8591082912,
724
+ "file_end": 8742077856,
725
+ "type": "Q4_K",
726
+ "type_id": 12,
727
+ "dims": [
728
+ 2048,
729
+ 512,
730
+ 256
731
+ ],
732
+ "bytes_per_expert": 589824,
733
+ "n_expert": 256
734
+ },
735
+ "ffn_up_exps": {
736
+ "file_offset": 8744773024,
737
+ "file_end": 8895767968,
738
+ "type": "Q4_K",
739
+ "type_id": 12,
740
+ "dims": [
741
+ 2048,
742
+ 512,
743
+ 256
744
+ ],
745
+ "bytes_per_expert": 589824,
746
+ "n_expert": 256
747
+ }
748
+ },
749
+ "bytes_per_expert_total": 1769472
750
+ },
751
+ {
752
+ "layer": 16,
753
+ "experts": {
754
+ "ffn_down_exps": {
755
+ "file_offset": 8914855328,
756
+ "file_end": 9135056288,
757
+ "type": "Q6_K",
758
+ "type_id": 14,
759
+ "dims": [
760
+ 512,
761
+ 2048,
762
+ 256
763
+ ],
764
+ "bytes_per_expert": 860160,
765
+ "n_expert": 256
766
+ },
767
+ "ffn_gate_exps": {
768
+ "file_offset": 9135916448,
769
+ "file_end": 9286911392,
770
+ "type": "Q4_K",
771
+ "type_id": 12,
772
+ "dims": [
773
+ 2048,
774
+ 512,
775
+ 256
776
+ ],
777
+ "bytes_per_expert": 589824,
778
+ "n_expert": 256
779
+ },
780
+ "ffn_up_exps": {
781
+ "file_offset": 9289606560,
782
+ "file_end": 9440601504,
783
+ "type": "Q4_K",
784
+ "type_id": 12,
785
+ "dims": [
786
+ 2048,
787
+ 512,
788
+ 256
789
+ ],
790
+ "bytes_per_expert": 589824,
791
+ "n_expert": 256
792
+ }
793
+ },
794
+ "bytes_per_expert_total": 2039808
795
+ },
796
+ {
797
+ "layer": 17,
798
+ "experts": {
799
+ "ffn_down_exps": {
800
+ "file_offset": 9460287648,
801
+ "file_end": 9611282592,
802
+ "type": "Q4_K",
803
+ "type_id": 12,
804
+ "dims": [
805
+ 512,
806
+ 2048,
807
+ 256
808
+ ],
809
+ "bytes_per_expert": 589824,
810
+ "n_expert": 256
811
+ },
812
+ "ffn_gate_exps": {
813
+ "file_offset": 9611872416,
814
+ "file_end": 9762867360,
815
+ "type": "Q4_K",
816
+ "type_id": 12,
817
+ "dims": [
818
+ 2048,
819
+ 512,
820
+ 256
821
+ ],
822
+ "bytes_per_expert": 589824,
823
+ "n_expert": 256
824
+ },
825
+ "ffn_up_exps": {
826
+ "file_offset": 9765562528,
827
+ "file_end": 9916557472,
828
+ "type": "Q4_K",
829
+ "type_id": 12,
830
+ "dims": [
831
+ 2048,
832
+ 512,
833
+ 256
834
+ ],
835
+ "bytes_per_expert": 589824,
836
+ "n_expert": 256
837
+ }
838
+ },
839
+ "bytes_per_expert_total": 1769472
840
+ },
841
+ {
842
+ "layer": 18,
843
+ "experts": {
844
+ "ffn_down_exps": {
845
+ "file_offset": 9936243616,
846
+ "file_end": 10087238560,
847
+ "type": "Q4_K",
848
+ "type_id": 12,
849
+ "dims": [
850
+ 512,
851
+ 2048,
852
+ 256
853
+ ],
854
+ "bytes_per_expert": 589824,
855
+ "n_expert": 256
856
+ },
857
+ "ffn_gate_exps": {
858
+ "file_offset": 10087828384,
859
+ "file_end": 10238823328,
860
+ "type": "Q4_K",
861
+ "type_id": 12,
862
+ "dims": [
863
+ 2048,
864
+ 512,
865
+ 256
866
+ ],
867
+ "bytes_per_expert": 589824,
868
+ "n_expert": 256
869
+ },
870
+ "ffn_up_exps": {
871
+ "file_offset": 10241518496,
872
+ "file_end": 10392513440,
873
+ "type": "Q4_K",
874
+ "type_id": 12,
875
+ "dims": [
876
+ 2048,
877
+ 512,
878
+ 256
879
+ ],
880
+ "bytes_per_expert": 589824,
881
+ "n_expert": 256
882
+ }
883
+ },
884
+ "bytes_per_expert_total": 1769472
885
+ },
886
+ {
887
+ "layer": 19,
888
+ "experts": {
889
+ "ffn_down_exps": {
890
+ "file_offset": 10413651616,
891
+ "file_end": 10633852576,
892
+ "type": "Q6_K",
893
+ "type_id": 14,
894
+ "dims": [
895
+ 512,
896
+ 2048,
897
+ 256
898
+ ],
899
+ "bytes_per_expert": 860160,
900
+ "n_expert": 256
901
+ },
902
+ "ffn_gate_exps": {
903
+ "file_offset": 10634712736,
904
+ "file_end": 10785707680,
905
+ "type": "Q4_K",
906
+ "type_id": 12,
907
+ "dims": [
908
+ 2048,
909
+ 512,
910
+ 256
911
+ ],
912
+ "bytes_per_expert": 589824,
913
+ "n_expert": 256
914
+ },
915
+ "ffn_up_exps": {
916
+ "file_offset": 10788402848,
917
+ "file_end": 10939397792,
918
+ "type": "Q4_K",
919
+ "type_id": 12,
920
+ "dims": [
921
+ 2048,
922
+ 512,
923
+ 256
924
+ ],
925
+ "bytes_per_expert": 589824,
926
+ "n_expert": 256
927
+ }
928
+ },
929
+ "bytes_per_expert_total": 2039808
930
+ },
931
+ {
932
+ "layer": 20,
933
+ "experts": {
934
+ "ffn_down_exps": {
935
+ "file_offset": 10954159776,
936
+ "file_end": 11105154720,
937
+ "type": "Q4_K",
938
+ "type_id": 12,
939
+ "dims": [
940
+ 512,
941
+ 2048,
942
+ 256
943
+ ],
944
+ "bytes_per_expert": 589824,
945
+ "n_expert": 256
946
+ },
947
+ "ffn_gate_exps": {
948
+ "file_offset": 11105744544,
949
+ "file_end": 11256739488,
950
+ "type": "Q4_K",
951
+ "type_id": 12,
952
+ "dims": [
953
+ 2048,
954
+ 512,
955
+ 256
956
+ ],
957
+ "bytes_per_expert": 589824,
958
+ "n_expert": 256
959
+ },
960
+ "ffn_up_exps": {
961
+ "file_offset": 11259434656,
962
+ "file_end": 11410429600,
963
+ "type": "Q4_K",
964
+ "type_id": 12,
965
+ "dims": [
966
+ 2048,
967
+ 512,
968
+ 256
969
+ ],
970
+ "bytes_per_expert": 589824,
971
+ "n_expert": 256
972
+ }
973
+ },
974
+ "bytes_per_expert_total": 1769472
975
+ },
976
+ {
977
+ "layer": 21,
978
+ "experts": {
979
+ "ffn_down_exps": {
980
+ "file_offset": 11430115744,
981
+ "file_end": 11581110688,
982
+ "type": "Q4_K",
983
+ "type_id": 12,
984
+ "dims": [
985
+ 512,
986
+ 2048,
987
+ 256
988
+ ],
989
+ "bytes_per_expert": 589824,
990
+ "n_expert": 256
991
+ },
992
+ "ffn_gate_exps": {
993
+ "file_offset": 11581700512,
994
+ "file_end": 11732695456,
995
+ "type": "Q4_K",
996
+ "type_id": 12,
997
+ "dims": [
998
+ 2048,
999
+ 512,
1000
+ 256
1001
+ ],
1002
+ "bytes_per_expert": 589824,
1003
+ "n_expert": 256
1004
+ },
1005
+ "ffn_up_exps": {
1006
+ "file_offset": 11735390624,
1007
+ "file_end": 11886385568,
1008
+ "type": "Q4_K",
1009
+ "type_id": 12,
1010
+ "dims": [
1011
+ 2048,
1012
+ 512,
1013
+ 256
1014
+ ],
1015
+ "bytes_per_expert": 589824,
1016
+ "n_expert": 256
1017
+ }
1018
+ },
1019
+ "bytes_per_expert_total": 1769472
1020
+ },
1021
+ {
1022
+ "layer": 22,
1023
+ "experts": {
1024
+ "ffn_down_exps": {
1025
+ "file_offset": 11910397088,
1026
+ "file_end": 12130598048,
1027
+ "type": "Q6_K",
1028
+ "type_id": 14,
1029
+ "dims": [
1030
+ 512,
1031
+ 2048,
1032
+ 256
1033
+ ],
1034
+ "bytes_per_expert": 860160,
1035
+ "n_expert": 256
1036
+ },
1037
+ "ffn_gate_exps": {
1038
+ "file_offset": 12131458208,
1039
+ "file_end": 12282453152,
1040
+ "type": "Q4_K",
1041
+ "type_id": 12,
1042
+ "dims": [
1043
+ 2048,
1044
+ 512,
1045
+ 256
1046
+ ],
1047
+ "bytes_per_expert": 589824,
1048
+ "n_expert": 256
1049
+ },
1050
+ "ffn_up_exps": {
1051
+ "file_offset": 12285148320,
1052
+ "file_end": 12436143264,
1053
+ "type": "Q4_K",
1054
+ "type_id": 12,
1055
+ "dims": [
1056
+ 2048,
1057
+ 512,
1058
+ 256
1059
+ ],
1060
+ "bytes_per_expert": 589824,
1061
+ "n_expert": 256
1062
+ }
1063
+ },
1064
+ "bytes_per_expert_total": 2039808
1065
+ },
1066
+ {
1067
+ "layer": 23,
1068
+ "experts": {
1069
+ "ffn_down_exps": {
1070
+ "file_offset": 12457011104,
1071
+ "file_end": 12608006048,
1072
+ "type": "Q4_K",
1073
+ "type_id": 12,
1074
+ "dims": [
1075
+ 512,
1076
+ 2048,
1077
+ 256
1078
+ ],
1079
+ "bytes_per_expert": 589824,
1080
+ "n_expert": 256
1081
+ },
1082
+ "ffn_gate_exps": {
1083
+ "file_offset": 12608595872,
1084
+ "file_end": 12759590816,
1085
+ "type": "Q4_K",
1086
+ "type_id": 12,
1087
+ "dims": [
1088
+ 2048,
1089
+ 512,
1090
+ 256
1091
+ ],
1092
+ "bytes_per_expert": 589824,
1093
+ "n_expert": 256
1094
+ },
1095
+ "ffn_up_exps": {
1096
+ "file_offset": 12762285984,
1097
+ "file_end": 12913280928,
1098
+ "type": "Q4_K",
1099
+ "type_id": 12,
1100
+ "dims": [
1101
+ 2048,
1102
+ 512,
1103
+ 256
1104
+ ],
1105
+ "bytes_per_expert": 589824,
1106
+ "n_expert": 256
1107
+ }
1108
+ },
1109
+ "bytes_per_expert_total": 1769472
1110
+ },
1111
+ {
1112
+ "layer": 24,
1113
+ "experts": {
1114
+ "ffn_down_exps": {
1115
+ "file_offset": 12928042912,
1116
+ "file_end": 13079037856,
1117
+ "type": "Q4_K",
1118
+ "type_id": 12,
1119
+ "dims": [
1120
+ 512,
1121
+ 2048,
1122
+ 256
1123
+ ],
1124
+ "bytes_per_expert": 589824,
1125
+ "n_expert": 256
1126
+ },
1127
+ "ffn_gate_exps": {
1128
+ "file_offset": 13079627680,
1129
+ "file_end": 13230622624,
1130
+ "type": "Q4_K",
1131
+ "type_id": 12,
1132
+ "dims": [
1133
+ 2048,
1134
+ 512,
1135
+ 256
1136
+ ],
1137
+ "bytes_per_expert": 589824,
1138
+ "n_expert": 256
1139
+ },
1140
+ "ffn_up_exps": {
1141
+ "file_offset": 13233317792,
1142
+ "file_end": 13384312736,
1143
+ "type": "Q4_K",
1144
+ "type_id": 12,
1145
+ "dims": [
1146
+ 2048,
1147
+ 512,
1148
+ 256
1149
+ ],
1150
+ "bytes_per_expert": 589824,
1151
+ "n_expert": 256
1152
+ }
1153
+ },
1154
+ "bytes_per_expert_total": 1769472
1155
+ },
1156
+ {
1157
+ "layer": 25,
1158
+ "experts": {
1159
+ "ffn_down_exps": {
1160
+ "file_offset": 13408324256,
1161
+ "file_end": 13628525216,
1162
+ "type": "Q6_K",
1163
+ "type_id": 14,
1164
+ "dims": [
1165
+ 512,
1166
+ 2048,
1167
+ 256
1168
+ ],
1169
+ "bytes_per_expert": 860160,
1170
+ "n_expert": 256
1171
+ },
1172
+ "ffn_gate_exps": {
1173
+ "file_offset": 13629385376,
1174
+ "file_end": 13780380320,
1175
+ "type": "Q4_K",
1176
+ "type_id": 12,
1177
+ "dims": [
1178
+ 2048,
1179
+ 512,
1180
+ 256
1181
+ ],
1182
+ "bytes_per_expert": 589824,
1183
+ "n_expert": 256
1184
+ },
1185
+ "ffn_up_exps": {
1186
+ "file_offset": 13783075488,
1187
+ "file_end": 13934070432,
1188
+ "type": "Q4_K",
1189
+ "type_id": 12,
1190
+ "dims": [
1191
+ 2048,
1192
+ 512,
1193
+ 256
1194
+ ],
1195
+ "bytes_per_expert": 589824,
1196
+ "n_expert": 256
1197
+ }
1198
+ },
1199
+ "bytes_per_expert_total": 2039808
1200
+ },
1201
+ {
1202
+ "layer": 26,
1203
+ "experts": {
1204
+ "ffn_down_exps": {
1205
+ "file_offset": 13953756576,
1206
+ "file_end": 14104751520,
1207
+ "type": "Q4_K",
1208
+ "type_id": 12,
1209
+ "dims": [
1210
+ 512,
1211
+ 2048,
1212
+ 256
1213
+ ],
1214
+ "bytes_per_expert": 589824,
1215
+ "n_expert": 256
1216
+ },
1217
+ "ffn_gate_exps": {
1218
+ "file_offset": 14105341344,
1219
+ "file_end": 14256336288,
1220
+ "type": "Q4_K",
1221
+ "type_id": 12,
1222
+ "dims": [
1223
+ 2048,
1224
+ 512,
1225
+ 256
1226
+ ],
1227
+ "bytes_per_expert": 589824,
1228
+ "n_expert": 256
1229
+ },
1230
+ "ffn_up_exps": {
1231
+ "file_offset": 14259031456,
1232
+ "file_end": 14410026400,
1233
+ "type": "Q4_K",
1234
+ "type_id": 12,
1235
+ "dims": [
1236
+ 2048,
1237
+ 512,
1238
+ 256
1239
+ ],
1240
+ "bytes_per_expert": 589824,
1241
+ "n_expert": 256
1242
+ }
1243
+ },
1244
+ "bytes_per_expert_total": 1769472
1245
+ },
1246
+ {
1247
+ "layer": 27,
1248
+ "experts": {
1249
+ "ffn_down_exps": {
1250
+ "file_offset": 14430894240,
1251
+ "file_end": 14581889184,
1252
+ "type": "Q4_K",
1253
+ "type_id": 12,
1254
+ "dims": [
1255
+ 512,
1256
+ 2048,
1257
+ 256
1258
+ ],
1259
+ "bytes_per_expert": 589824,
1260
+ "n_expert": 256
1261
+ },
1262
+ "ffn_gate_exps": {
1263
+ "file_offset": 14582479008,
1264
+ "file_end": 14733473952,
1265
+ "type": "Q4_K",
1266
+ "type_id": 12,
1267
+ "dims": [
1268
+ 2048,
1269
+ 512,
1270
+ 256
1271
+ ],
1272
+ "bytes_per_expert": 589824,
1273
+ "n_expert": 256
1274
+ },
1275
+ "ffn_up_exps": {
1276
+ "file_offset": 14736169120,
1277
+ "file_end": 14887164064,
1278
+ "type": "Q4_K",
1279
+ "type_id": 12,
1280
+ "dims": [
1281
+ 2048,
1282
+ 512,
1283
+ 256
1284
+ ],
1285
+ "bytes_per_expert": 589824,
1286
+ "n_expert": 256
1287
+ }
1288
+ },
1289
+ "bytes_per_expert_total": 1769472
1290
+ },
1291
+ {
1292
+ "layer": 28,
1293
+ "experts": {
1294
+ "ffn_down_exps": {
1295
+ "file_offset": 14906251424,
1296
+ "file_end": 15126452384,
1297
+ "type": "Q6_K",
1298
+ "type_id": 14,
1299
+ "dims": [
1300
+ 512,
1301
+ 2048,
1302
+ 256
1303
+ ],
1304
+ "bytes_per_expert": 860160,
1305
+ "n_expert": 256
1306
+ },
1307
+ "ffn_gate_exps": {
1308
+ "file_offset": 15127312544,
1309
+ "file_end": 15278307488,
1310
+ "type": "Q4_K",
1311
+ "type_id": 12,
1312
+ "dims": [
1313
+ 2048,
1314
+ 512,
1315
+ 256
1316
+ ],
1317
+ "bytes_per_expert": 589824,
1318
+ "n_expert": 256
1319
+ },
1320
+ "ffn_up_exps": {
1321
+ "file_offset": 15281002656,
1322
+ "file_end": 15431997600,
1323
+ "type": "Q4_K",
1324
+ "type_id": 12,
1325
+ "dims": [
1326
+ 2048,
1327
+ 512,
1328
+ 256
1329
+ ],
1330
+ "bytes_per_expert": 589824,
1331
+ "n_expert": 256
1332
+ }
1333
+ },
1334
+ "bytes_per_expert_total": 2039808
1335
+ },
1336
+ {
1337
+ "layer": 29,
1338
+ "experts": {
1339
+ "ffn_down_exps": {
1340
+ "file_offset": 15451683744,
1341
+ "file_end": 15602678688,
1342
+ "type": "Q4_K",
1343
+ "type_id": 12,
1344
+ "dims": [
1345
+ 512,
1346
+ 2048,
1347
+ 256
1348
+ ],
1349
+ "bytes_per_expert": 589824,
1350
+ "n_expert": 256
1351
+ },
1352
+ "ffn_gate_exps": {
1353
+ "file_offset": 15603268512,
1354
+ "file_end": 15754263456,
1355
+ "type": "Q4_K",
1356
+ "type_id": 12,
1357
+ "dims": [
1358
+ 2048,
1359
+ 512,
1360
+ 256
1361
+ ],
1362
+ "bytes_per_expert": 589824,
1363
+ "n_expert": 256
1364
+ },
1365
+ "ffn_up_exps": {
1366
+ "file_offset": 15756958624,
1367
+ "file_end": 15907953568,
1368
+ "type": "Q4_K",
1369
+ "type_id": 12,
1370
+ "dims": [
1371
+ 2048,
1372
+ 512,
1373
+ 256
1374
+ ],
1375
+ "bytes_per_expert": 589824,
1376
+ "n_expert": 256
1377
+ }
1378
+ },
1379
+ "bytes_per_expert_total": 1769472
1380
+ },
1381
+ {
1382
+ "layer": 30,
1383
+ "experts": {
1384
+ "ffn_down_exps": {
1385
+ "file_offset": 15927639712,
1386
+ "file_end": 16078634656,
1387
+ "type": "Q4_K",
1388
+ "type_id": 12,
1389
+ "dims": [
1390
+ 512,
1391
+ 2048,
1392
+ 256
1393
+ ],
1394
+ "bytes_per_expert": 589824,
1395
+ "n_expert": 256
1396
+ },
1397
+ "ffn_gate_exps": {
1398
+ "file_offset": 16079224480,
1399
+ "file_end": 16230219424,
1400
+ "type": "Q4_K",
1401
+ "type_id": 12,
1402
+ "dims": [
1403
+ 2048,
1404
+ 512,
1405
+ 256
1406
+ ],
1407
+ "bytes_per_expert": 589824,
1408
+ "n_expert": 256
1409
+ },
1410
+ "ffn_up_exps": {
1411
+ "file_offset": 16232914592,
1412
+ "file_end": 16383909536,
1413
+ "type": "Q4_K",
1414
+ "type_id": 12,
1415
+ "dims": [
1416
+ 2048,
1417
+ 512,
1418
+ 256
1419
+ ],
1420
+ "bytes_per_expert": 589824,
1421
+ "n_expert": 256
1422
+ }
1423
+ },
1424
+ "bytes_per_expert_total": 1769472
1425
+ },
1426
+ {
1427
+ "layer": 31,
1428
+ "experts": {
1429
+ "ffn_down_exps": {
1430
+ "file_offset": 16405047712,
1431
+ "file_end": 16625248672,
1432
+ "type": "Q6_K",
1433
+ "type_id": 14,
1434
+ "dims": [
1435
+ 512,
1436
+ 2048,
1437
+ 256
1438
+ ],
1439
+ "bytes_per_expert": 860160,
1440
+ "n_expert": 256
1441
+ },
1442
+ "ffn_gate_exps": {
1443
+ "file_offset": 16626108832,
1444
+ "file_end": 16777103776,
1445
+ "type": "Q4_K",
1446
+ "type_id": 12,
1447
+ "dims": [
1448
+ 2048,
1449
+ 512,
1450
+ 256
1451
+ ],
1452
+ "bytes_per_expert": 589824,
1453
+ "n_expert": 256
1454
+ },
1455
+ "ffn_up_exps": {
1456
+ "file_offset": 16779798944,
1457
+ "file_end": 16930793888,
1458
+ "type": "Q4_K",
1459
+ "type_id": 12,
1460
+ "dims": [
1461
+ 2048,
1462
+ 512,
1463
+ 256
1464
+ ],
1465
+ "bytes_per_expert": 589824,
1466
+ "n_expert": 256
1467
+ }
1468
+ },
1469
+ "bytes_per_expert_total": 2039808
1470
+ },
1471
+ {
1472
+ "layer": 32,
1473
+ "experts": {
1474
+ "ffn_down_exps": {
1475
+ "file_offset": 16945555872,
1476
+ "file_end": 17096550816,
1477
+ "type": "Q4_K",
1478
+ "type_id": 12,
1479
+ "dims": [
1480
+ 512,
1481
+ 2048,
1482
+ 256
1483
+ ],
1484
+ "bytes_per_expert": 589824,
1485
+ "n_expert": 256
1486
+ },
1487
+ "ffn_gate_exps": {
1488
+ "file_offset": 17097140640,
1489
+ "file_end": 17248135584,
1490
+ "type": "Q4_K",
1491
+ "type_id": 12,
1492
+ "dims": [
1493
+ 2048,
1494
+ 512,
1495
+ 256
1496
+ ],
1497
+ "bytes_per_expert": 589824,
1498
+ "n_expert": 256
1499
+ },
1500
+ "ffn_up_exps": {
1501
+ "file_offset": 17250830752,
1502
+ "file_end": 17401825696,
1503
+ "type": "Q4_K",
1504
+ "type_id": 12,
1505
+ "dims": [
1506
+ 2048,
1507
+ 512,
1508
+ 256
1509
+ ],
1510
+ "bytes_per_expert": 589824,
1511
+ "n_expert": 256
1512
+ }
1513
+ },
1514
+ "bytes_per_expert_total": 1769472
1515
+ },
1516
+ {
1517
+ "layer": 33,
1518
+ "experts": {
1519
+ "ffn_down_exps": {
1520
+ "file_offset": 17421511840,
1521
+ "file_end": 17572506784,
1522
+ "type": "Q4_K",
1523
+ "type_id": 12,
1524
+ "dims": [
1525
+ 512,
1526
+ 2048,
1527
+ 256
1528
+ ],
1529
+ "bytes_per_expert": 589824,
1530
+ "n_expert": 256
1531
+ },
1532
+ "ffn_gate_exps": {
1533
+ "file_offset": 17573096608,
1534
+ "file_end": 17724091552,
1535
+ "type": "Q4_K",
1536
+ "type_id": 12,
1537
+ "dims": [
1538
+ 2048,
1539
+ 512,
1540
+ 256
1541
+ ],
1542
+ "bytes_per_expert": 589824,
1543
+ "n_expert": 256
1544
+ },
1545
+ "ffn_up_exps": {
1546
+ "file_offset": 17726786720,
1547
+ "file_end": 17877781664,
1548
+ "type": "Q4_K",
1549
+ "type_id": 12,
1550
+ "dims": [
1551
+ 2048,
1552
+ 512,
1553
+ 256
1554
+ ],
1555
+ "bytes_per_expert": 589824,
1556
+ "n_expert": 256
1557
+ }
1558
+ },
1559
+ "bytes_per_expert_total": 1769472
1560
+ },
1561
+ {
1562
+ "layer": 34,
1563
+ "experts": {
1564
+ "ffn_down_exps": {
1565
+ "file_offset": 17901793184,
1566
+ "file_end": 18121994144,
1567
+ "type": "Q6_K",
1568
+ "type_id": 14,
1569
+ "dims": [
1570
+ 512,
1571
+ 2048,
1572
+ 256
1573
+ ],
1574
+ "bytes_per_expert": 860160,
1575
+ "n_expert": 256
1576
+ },
1577
+ "ffn_gate_exps": {
1578
+ "file_offset": 18122854304,
1579
+ "file_end": 18273849248,
1580
+ "type": "Q4_K",
1581
+ "type_id": 12,
1582
+ "dims": [
1583
+ 2048,
1584
+ 512,
1585
+ 256
1586
+ ],
1587
+ "bytes_per_expert": 589824,
1588
+ "n_expert": 256
1589
+ },
1590
+ "ffn_up_exps": {
1591
+ "file_offset": 18276544416,
1592
+ "file_end": 18427539360,
1593
+ "type": "Q4_K",
1594
+ "type_id": 12,
1595
+ "dims": [
1596
+ 2048,
1597
+ 512,
1598
+ 256
1599
+ ],
1600
+ "bytes_per_expert": 589824,
1601
+ "n_expert": 256
1602
+ }
1603
+ },
1604
+ "bytes_per_expert_total": 2039808
1605
+ },
1606
+ {
1607
+ "layer": 35,
1608
+ "experts": {
1609
+ "ffn_down_exps": {
1610
+ "file_offset": 18448677536,
1611
+ "file_end": 18668878496,
1612
+ "type": "Q6_K",
1613
+ "type_id": 14,
1614
+ "dims": [
1615
+ 512,
1616
+ 2048,
1617
+ 256
1618
+ ],
1619
+ "bytes_per_expert": 860160,
1620
+ "n_expert": 256
1621
+ },
1622
+ "ffn_gate_exps": {
1623
+ "file_offset": 18669738656,
1624
+ "file_end": 18820733600,
1625
+ "type": "Q4_K",
1626
+ "type_id": 12,
1627
+ "dims": [
1628
+ 2048,
1629
+ 512,
1630
+ 256
1631
+ ],
1632
+ "bytes_per_expert": 589824,
1633
+ "n_expert": 256
1634
+ },
1635
+ "ffn_up_exps": {
1636
+ "file_offset": 18823428768,
1637
+ "file_end": 18974423712,
1638
+ "type": "Q4_K",
1639
+ "type_id": 12,
1640
+ "dims": [
1641
+ 2048,
1642
+ 512,
1643
+ 256
1644
+ ],
1645
+ "bytes_per_expert": 589824,
1646
+ "n_expert": 256
1647
+ }
1648
+ },
1649
+ "bytes_per_expert_total": 2039808
1650
+ },
1651
+ {
1652
+ "layer": 36,
1653
+ "experts": {
1654
+ "ffn_down_exps": {
1655
+ "file_offset": 18993511072,
1656
+ "file_end": 19213712032,
1657
+ "type": "Q6_K",
1658
+ "type_id": 14,
1659
+ "dims": [
1660
+ 512,
1661
+ 2048,
1662
+ 256
1663
+ ],
1664
+ "bytes_per_expert": 860160,
1665
+ "n_expert": 256
1666
+ },
1667
+ "ffn_gate_exps": {
1668
+ "file_offset": 19214572192,
1669
+ "file_end": 19365567136,
1670
+ "type": "Q4_K",
1671
+ "type_id": 12,
1672
+ "dims": [
1673
+ 2048,
1674
+ 512,
1675
+ 256
1676
+ ],
1677
+ "bytes_per_expert": 589824,
1678
+ "n_expert": 256
1679
+ },
1680
+ "ffn_up_exps": {
1681
+ "file_offset": 19368262304,
1682
+ "file_end": 19519257248,
1683
+ "type": "Q4_K",
1684
+ "type_id": 12,
1685
+ "dims": [
1686
+ 2048,
1687
+ 512,
1688
+ 256
1689
+ ],
1690
+ "bytes_per_expert": 589824,
1691
+ "n_expert": 256
1692
+ }
1693
+ },
1694
+ "bytes_per_expert_total": 2039808
1695
+ },
1696
+ {
1697
+ "layer": 37,
1698
+ "experts": {
1699
+ "ffn_down_exps": {
1700
+ "file_offset": 19543268768,
1701
+ "file_end": 19763469728,
1702
+ "type": "Q6_K",
1703
+ "type_id": 14,
1704
+ "dims": [
1705
+ 512,
1706
+ 2048,
1707
+ 256
1708
+ ],
1709
+ "bytes_per_expert": 860160,
1710
+ "n_expert": 256
1711
+ },
1712
+ "ffn_gate_exps": {
1713
+ "file_offset": 19764329888,
1714
+ "file_end": 19915324832,
1715
+ "type": "Q4_K",
1716
+ "type_id": 12,
1717
+ "dims": [
1718
+ 2048,
1719
+ 512,
1720
+ 256
1721
+ ],
1722
+ "bytes_per_expert": 589824,
1723
+ "n_expert": 256
1724
+ },
1725
+ "ffn_up_exps": {
1726
+ "file_offset": 19918020000,
1727
+ "file_end": 20069014944,
1728
+ "type": "Q4_K",
1729
+ "type_id": 12,
1730
+ "dims": [
1731
+ 2048,
1732
+ 512,
1733
+ 256
1734
+ ],
1735
+ "bytes_per_expert": 589824,
1736
+ "n_expert": 256
1737
+ }
1738
+ },
1739
+ "bytes_per_expert_total": 2039808
1740
+ },
1741
+ {
1742
+ "layer": 38,
1743
+ "experts": {
1744
+ "ffn_down_exps": {
1745
+ "file_offset": 20093026464,
1746
+ "file_end": 20313227424,
1747
+ "type": "Q6_K",
1748
+ "type_id": 14,
1749
+ "dims": [
1750
+ 512,
1751
+ 2048,
1752
+ 256
1753
+ ],
1754
+ "bytes_per_expert": 860160,
1755
+ "n_expert": 256
1756
+ },
1757
+ "ffn_gate_exps": {
1758
+ "file_offset": 20314087584,
1759
+ "file_end": 20465082528,
1760
+ "type": "Q4_K",
1761
+ "type_id": 12,
1762
+ "dims": [
1763
+ 2048,
1764
+ 512,
1765
+ 256
1766
+ ],
1767
+ "bytes_per_expert": 589824,
1768
+ "n_expert": 256
1769
+ },
1770
+ "ffn_up_exps": {
1771
+ "file_offset": 20467777696,
1772
+ "file_end": 20618772640,
1773
+ "type": "Q4_K",
1774
+ "type_id": 12,
1775
+ "dims": [
1776
+ 2048,
1777
+ 512,
1778
+ 256
1779
+ ],
1780
+ "bytes_per_expert": 589824,
1781
+ "n_expert": 256
1782
+ }
1783
+ },
1784
+ "bytes_per_expert_total": 2039808
1785
+ },
1786
+ {
1787
+ "layer": 39,
1788
+ "experts": {
1789
+ "ffn_down_exps": {
1790
+ "file_offset": 20639910816,
1791
+ "file_end": 20860111776,
1792
+ "type": "Q6_K",
1793
+ "type_id": 14,
1794
+ "dims": [
1795
+ 512,
1796
+ 2048,
1797
+ 256
1798
+ ],
1799
+ "bytes_per_expert": 860160,
1800
+ "n_expert": 256
1801
+ },
1802
+ "ffn_gate_exps": {
1803
+ "file_offset": 20860971936,
1804
+ "file_end": 21011966880,
1805
+ "type": "Q4_K",
1806
+ "type_id": 12,
1807
+ "dims": [
1808
+ 2048,
1809
+ 512,
1810
+ 256
1811
+ ],
1812
+ "bytes_per_expert": 589824,
1813
+ "n_expert": 256
1814
+ },
1815
+ "ffn_up_exps": {
1816
+ "file_offset": 21014662048,
1817
+ "file_end": 21165656992,
1818
+ "type": "Q4_K",
1819
+ "type_id": 12,
1820
+ "dims": [
1821
+ 2048,
1822
+ 512,
1823
+ 256
1824
+ ],
1825
+ "bytes_per_expert": 589824,
1826
+ "n_expert": 256
1827
+ }
1828
+ },
1829
+ "bytes_per_expert_total": 2039808
1830
+ }
1831
+ ],
1832
+ "decode_working_set_bytes_max": 16318464,
1833
+ "decode_working_set_bytes_mean": 15237120.0,
1834
+ "expert_slot_count": 120,
1835
+ "kv_bytes_per_token_f16": 20480
1836
+ }
bench/results/ram_sweep.json ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "budget_mb": 4096,
4
+ "reserve_mb": 256,
5
+ "rc": 0,
6
+ "strict_ok": true,
7
+ "decode_tps": 4.97,
8
+ "hit_pct": 59.9155,
9
+ "mb_per_token": 275.32386779785156,
10
+ "slots": 1600,
11
+ "arena_gib": 2.74,
12
+ "peak_anon_mib": 3963.3,
13
+ "peak_swap_mb": 0.0,
14
+ "round": 0,
15
+ "prompt": "Explain in one sentence why SSDs are slow."
16
+ },
17
+ {
18
+ "budget_mb": 8192,
19
+ "reserve_mb": 512,
20
+ "rc": 0,
21
+ "strict_ok": true,
22
+ "decode_tps": 6.66,
23
+ "hit_pct": 70.722,
24
+ "mb_per_token": 199.07283782958984,
25
+ "slots": 3715,
26
+ "arena_gib": 6.36,
27
+ "peak_anon_mib": 7668.3,
28
+ "peak_swap_mb": 0.0,
29
+ "round": 0,
30
+ "prompt": "Explain in one sentence why SSDs are slow."
31
+ },
32
+ {
33
+ "budget_mb": 16384,
34
+ "reserve_mb": 1024,
35
+ "rc": 0,
36
+ "strict_ok": true,
37
+ "decode_tps": 5.87,
38
+ "hit_pct": 76.4665,
39
+ "mb_per_token": 157.9723358154297,
40
+ "slots": 7943,
41
+ "arena_gib": 13.59,
42
+ "peak_anon_mib": 15076.2,
43
+ "peak_swap_mb": 0.0,
44
+ "round": 0,
45
+ "prompt": "Explain in one sentence why SSDs are slow."
46
+ },
47
+ {
48
+ "budget_mb": 32768,
49
+ "reserve_mb": 1024,
50
+ "rc": 0,
51
+ "strict_ok": true,
52
+ "decode_tps": 6.72,
53
+ "hit_pct": 76.4522,
54
+ "mb_per_token": 157.84645080566406,
55
+ "slots": 16963,
56
+ "arena_gib": 29.02,
57
+ "peak_anon_mib": 23959.0,
58
+ "peak_swap_mb": 0.0,
59
+ "round": 0,
60
+ "prompt": "Explain in one sentence why SSDs are slow."
61
+ },
62
+ {
63
+ "budget_mb": 65536,
64
+ "reserve_mb": 1024,
65
+ "rc": 0,
66
+ "strict_ok": true,
67
+ "decode_tps": 6.8,
68
+ "hit_pct": 76.5186,
69
+ "mb_per_token": 157.60040283203125,
70
+ "slots": 35003,
71
+ "arena_gib": 59.89,
72
+ "peak_anon_mib": 23969.2,
73
+ "peak_swap_mb": 0.0,
74
+ "round": 0,
75
+ "prompt": "Explain in one sentence why SSDs are slow."
76
+ },
77
+ {
78
+ "budget_mb": 4096,
79
+ "reserve_mb": 256,
80
+ "rc": 0,
81
+ "strict_ok": true,
82
+ "decode_tps": 5.56,
83
+ "hit_pct": 63.7432,
84
+ "mb_per_token": 239.48001861572266,
85
+ "slots": 1600,
86
+ "arena_gib": 2.74,
87
+ "peak_anon_mib": 3946.2,
88
+ "peak_swap_mb": 0.0,
89
+ "round": 1,
90
+ "prompt": "\u8acb\u7528\u4e00\u53e5\u8a71\u4ecb\u7d39\u4f60\u81ea\u5df1\u3002"
91
+ },
92
+ {
93
+ "budget_mb": 8192,
94
+ "reserve_mb": 512,
95
+ "rc": 0,
96
+ "strict_ok": true,
97
+ "decode_tps": 6.44,
98
+ "hit_pct": 75.0542,
99
+ "mb_per_token": 162.54615783691406,
100
+ "slots": 3715,
101
+ "arena_gib": 6.36,
102
+ "peak_anon_mib": 7652.6,
103
+ "peak_swap_mb": 0.0,
104
+ "round": 1,
105
+ "prompt": "\u8acb\u7528\u4e00\u53e5\u8a71\u4ecb\u7d39\u4f60\u81ea\u5df1\u3002"
106
+ },
107
+ {
108
+ "budget_mb": 16384,
109
+ "reserve_mb": 1024,
110
+ "rc": 0,
111
+ "strict_ok": true,
112
+ "decode_tps": 6.75,
113
+ "hit_pct": 79.9662,
114
+ "mb_per_token": 128.8137435913086,
115
+ "slots": 7943,
116
+ "arena_gib": 13.59,
117
+ "peak_anon_mib": 15059.6,
118
+ "peak_swap_mb": 0.0,
119
+ "round": 1,
120
+ "prompt": "\u8acb\u7528\u4e00\u53e5\u8a71\u4ecb\u7d39\u4f60\u81ea\u5df1\u3002"
121
+ },
122
+ {
123
+ "budget_mb": 32768,
124
+ "reserve_mb": 1024,
125
+ "rc": 0,
126
+ "strict_ok": true,
127
+ "decode_tps": 6.49,
128
+ "hit_pct": 79.572,
129
+ "mb_per_token": 131.12545013427734,
130
+ "slots": 16963,
131
+ "arena_gib": 29.02,
132
+ "peak_anon_mib": 23093.5,
133
+ "peak_swap_mb": 0.0,
134
+ "round": 1,
135
+ "prompt": "\u8acb\u7528\u4e00\u53e5\u8a71\u4ecb\u7d39\u4f60\u81ea\u5df1\u3002"
136
+ },
137
+ {
138
+ "budget_mb": 65536,
139
+ "reserve_mb": 1024,
140
+ "rc": 0,
141
+ "strict_ok": true,
142
+ "decode_tps": 6.46,
143
+ "hit_pct": 79.572,
144
+ "mb_per_token": 131.12545013427734,
145
+ "slots": 35003,
146
+ "arena_gib": 59.89,
147
+ "peak_anon_mib": 23094.1,
148
+ "peak_swap_mb": 0.0,
149
+ "round": 1,
150
+ "prompt": "\u8acb\u7528\u4e00\u53e5\u8a71\u4ecb\u7d39\u4f60\u81ea\u5df1\u3002"
151
+ },
152
+ {
153
+ "budget_mb": 4096,
154
+ "reserve_mb": 256,
155
+ "rc": 0,
156
+ "strict_ok": true,
157
+ "decode_tps": 4.92,
158
+ "hit_pct": 61.5016,
159
+ "mb_per_token": 264.0085220336914,
160
+ "slots": 1600,
161
+ "arena_gib": 2.74,
162
+ "peak_anon_mib": 3949.7,
163
+ "peak_swap_mb": 0.0,
164
+ "round": 2,
165
+ "prompt": "Write a short paragraph about SSD caching."
166
+ },
167
+ {
168
+ "budget_mb": 8192,
169
+ "reserve_mb": 512,
170
+ "rc": 0,
171
+ "strict_ok": true,
172
+ "decode_tps": 6.44,
173
+ "hit_pct": 71.9877,
174
+ "mb_per_token": 190.2914047241211,
175
+ "slots": 3715,
176
+ "arena_gib": 6.36,
177
+ "peak_anon_mib": 7654.6,
178
+ "peak_swap_mb": 0.0,
179
+ "round": 2,
180
+ "prompt": "Write a short paragraph about SSD caching."
181
+ },
182
+ {
183
+ "budget_mb": 16384,
184
+ "reserve_mb": 1024,
185
+ "rc": 0,
186
+ "strict_ok": true,
187
+ "decode_tps": 6.2,
188
+ "hit_pct": 77.5357,
189
+ "mb_per_token": 150.6662368774414,
190
+ "slots": 7943,
191
+ "arena_gib": 13.59,
192
+ "peak_anon_mib": 15062.1,
193
+ "peak_swap_mb": 0.0,
194
+ "round": 2,
195
+ "prompt": "Write a short paragraph about SSD caching."
196
+ },
197
+ {
198
+ "budget_mb": 32768,
199
+ "reserve_mb": 1024,
200
+ "rc": 0,
201
+ "strict_ok": true,
202
+ "decode_tps": 6.99,
203
+ "hit_pct": 76.6347,
204
+ "mb_per_token": 156.62002563476562,
205
+ "slots": 16963,
206
+ "arena_gib": 29.02,
207
+ "peak_anon_mib": 23875.6,
208
+ "peak_swap_mb": 0.0,
209
+ "round": 2,
210
+ "prompt": "Write a short paragraph about SSD caching."
211
+ },
212
+ {
213
+ "budget_mb": 65536,
214
+ "reserve_mb": 1024,
215
+ "rc": 0,
216
+ "strict_ok": true,
217
+ "decode_tps": 6.16,
218
+ "hit_pct": 77.5416,
219
+ "mb_per_token": 150.62332153320312,
220
+ "slots": 35003,
221
+ "arena_gib": 59.89,
222
+ "peak_anon_mib": 23731.4,
223
+ "peak_swap_mb": 0.0,
224
+ "round": 2,
225
+ "prompt": "Write a short paragraph about SSD caching."
226
+ }
227
+ ]
bench/results/ram_sweep_n9.json ADDED
@@ -0,0 +1,584 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "per_budget": {
3
+ "4096": {
4
+ "n": 9,
5
+ "mean_tps": 5.4255555555555555,
6
+ "stdev_tps": 0.3725624481583963,
7
+ "median_tps": 5.45,
8
+ "min_tps": 4.78,
9
+ "max_tps": 5.84,
10
+ "slots": 1600,
11
+ "arena_gib": 2.74,
12
+ "hit_pct": 61.4956,
13
+ "mb_per_token": 275.4068374633789,
14
+ "peak_anon_mb": 3963.4,
15
+ "peak_swap_mb": 0.0,
16
+ "all_strict_ok": true
17
+ },
18
+ "8192": {
19
+ "n": 9,
20
+ "mean_tps": 6.328888888888889,
21
+ "stdev_tps": 0.3313398725042175,
22
+ "median_tps": 6.27,
23
+ "min_tps": 5.96,
24
+ "max_tps": 6.88,
25
+ "slots": 3715,
26
+ "arena_gib": 6.36,
27
+ "hit_pct": 71.9818,
28
+ "mb_per_token": 199.07283782958984,
29
+ "peak_anon_mb": 7668.7,
30
+ "peak_swap_mb": 0.0,
31
+ "all_strict_ok": true
32
+ },
33
+ "16384": {
34
+ "n": 9,
35
+ "mean_tps": 6.275555555555556,
36
+ "stdev_tps": 0.4072502643065782,
37
+ "median_tps": 6.38,
38
+ "min_tps": 5.5,
39
+ "max_tps": 6.73,
40
+ "slots": 7943,
41
+ "arena_gib": 13.59,
42
+ "hit_pct": 77.5357,
43
+ "mb_per_token": 157.93132781982422,
44
+ "peak_anon_mb": 15076.2,
45
+ "peak_swap_mb": 0.0,
46
+ "all_strict_ok": true
47
+ },
48
+ "65536": {
49
+ "n": 9,
50
+ "mean_tps": 6.318888888888889,
51
+ "stdev_tps": 0.5079233319223591,
52
+ "median_tps": 6.37,
53
+ "min_tps": 5.19,
54
+ "max_tps": 6.81,
55
+ "slots": 35003,
56
+ "arena_gib": 59.89,
57
+ "hit_pct": 77.5416,
58
+ "mb_per_token": 157.60040283203125,
59
+ "peak_anon_mb": 23968.9,
60
+ "peak_swap_mb": 0.0,
61
+ "all_strict_ok": true
62
+ }
63
+ },
64
+ "welch_vs_8192": {
65
+ "4096_vs_8192": {
66
+ "t": -5.435361505492692,
67
+ "p": 5.766235313608975e-05
68
+ },
69
+ "16384_vs_8192": {
70
+ "t": -0.3047542264956529,
71
+ "p": 0.7646425989688621
72
+ },
73
+ "65536_vs_8192": {
74
+ "t": -0.049468847526827016,
75
+ "p": 0.96125630480275
76
+ }
77
+ },
78
+ "rows": [
79
+ {
80
+ "budget_mb": 4096,
81
+ "reserve_mb": 256,
82
+ "rc": 0,
83
+ "strict_ok": true,
84
+ "decode_tps": 5.84,
85
+ "hit_pct": 59.904,
86
+ "mb_per_token": 275.4068374633789,
87
+ "slots": 1600,
88
+ "arena_gib": 2.74,
89
+ "peak_anon_mib": 3963.0,
90
+ "peak_swap_mb": 0.0,
91
+ "round": 0
92
+ },
93
+ {
94
+ "budget_mb": 8192,
95
+ "reserve_mb": 512,
96
+ "rc": 0,
97
+ "strict_ok": true,
98
+ "decode_tps": 6.28,
99
+ "hit_pct": 70.722,
100
+ "mb_per_token": 199.07283782958984,
101
+ "slots": 3715,
102
+ "arena_gib": 6.36,
103
+ "peak_anon_mib": 7668.3,
104
+ "peak_swap_mb": 0.0,
105
+ "round": 0
106
+ },
107
+ {
108
+ "budget_mb": 16384,
109
+ "reserve_mb": 1024,
110
+ "rc": 0,
111
+ "strict_ok": true,
112
+ "decode_tps": 6.57,
113
+ "hit_pct": 76.4723,
114
+ "mb_per_token": 157.93132781982422,
115
+ "slots": 7943,
116
+ "arena_gib": 13.59,
117
+ "peak_anon_mib": 15076.2,
118
+ "peak_swap_mb": 0.0,
119
+ "round": 0
120
+ },
121
+ {
122
+ "budget_mb": 65536,
123
+ "reserve_mb": 1024,
124
+ "rc": 0,
125
+ "strict_ok": true,
126
+ "decode_tps": 6.64,
127
+ "hit_pct": 76.5186,
128
+ "mb_per_token": 157.60040283203125,
129
+ "slots": 35003,
130
+ "arena_gib": 59.89,
131
+ "peak_anon_mib": 23968.8,
132
+ "peak_swap_mb": 0.0,
133
+ "round": 0
134
+ },
135
+ {
136
+ "budget_mb": 4096,
137
+ "reserve_mb": 256,
138
+ "rc": 0,
139
+ "strict_ok": true,
140
+ "decode_tps": 5.78,
141
+ "hit_pct": 63.7613,
142
+ "mb_per_token": 239.35604095458984,
143
+ "slots": 1600,
144
+ "arena_gib": 2.74,
145
+ "peak_anon_mib": 3946.2,
146
+ "peak_swap_mb": 0.0,
147
+ "round": 1
148
+ },
149
+ {
150
+ "budget_mb": 8192,
151
+ "reserve_mb": 512,
152
+ "rc": 0,
153
+ "strict_ok": true,
154
+ "decode_tps": 6.09,
155
+ "hit_pct": 75.0603,
156
+ "mb_per_token": 162.5041961669922,
157
+ "slots": 3715,
158
+ "arena_gib": 6.36,
159
+ "peak_anon_mib": 7652.6,
160
+ "peak_swap_mb": 0.0,
161
+ "round": 1
162
+ },
163
+ {
164
+ "budget_mb": 16384,
165
+ "reserve_mb": 1024,
166
+ "rc": 0,
167
+ "strict_ok": true,
168
+ "decode_tps": 6.63,
169
+ "hit_pct": 79.9662,
170
+ "mb_per_token": 128.8137435913086,
171
+ "slots": 7943,
172
+ "arena_gib": 13.59,
173
+ "peak_anon_mib": 15059.6,
174
+ "peak_swap_mb": 0.0,
175
+ "round": 1
176
+ },
177
+ {
178
+ "budget_mb": 65536,
179
+ "reserve_mb": 1024,
180
+ "rc": 0,
181
+ "strict_ok": true,
182
+ "decode_tps": 6.72,
183
+ "hit_pct": 79.572,
184
+ "mb_per_token": 131.12545013427734,
185
+ "slots": 35003,
186
+ "arena_gib": 59.89,
187
+ "peak_anon_mib": 23094.1,
188
+ "peak_swap_mb": 0.0,
189
+ "round": 1
190
+ },
191
+ {
192
+ "budget_mb": 4096,
193
+ "reserve_mb": 256,
194
+ "rc": 0,
195
+ "strict_ok": true,
196
+ "decode_tps": 4.99,
197
+ "hit_pct": 61.4838,
198
+ "mb_per_token": 264.13536071777344,
199
+ "slots": 1600,
200
+ "arena_gib": 2.74,
201
+ "peak_anon_mib": 3949.3,
202
+ "peak_swap_mb": 0.0,
203
+ "round": 2
204
+ },
205
+ {
206
+ "budget_mb": 8192,
207
+ "reserve_mb": 512,
208
+ "rc": 0,
209
+ "strict_ok": true,
210
+ "decode_tps": 6.88,
211
+ "hit_pct": 71.9877,
212
+ "mb_per_token": 190.2914047241211,
213
+ "slots": 3715,
214
+ "arena_gib": 6.36,
215
+ "peak_anon_mib": 7654.6,
216
+ "peak_swap_mb": 0.0,
217
+ "round": 2
218
+ },
219
+ {
220
+ "budget_mb": 16384,
221
+ "reserve_mb": 1024,
222
+ "rc": 0,
223
+ "strict_ok": true,
224
+ "decode_tps": 6.15,
225
+ "hit_pct": 77.5357,
226
+ "mb_per_token": 150.6662368774414,
227
+ "slots": 7943,
228
+ "arena_gib": 13.59,
229
+ "peak_anon_mib": 15062.1,
230
+ "peak_swap_mb": 0.0,
231
+ "round": 2
232
+ },
233
+ {
234
+ "budget_mb": 65536,
235
+ "reserve_mb": 1024,
236
+ "rc": 0,
237
+ "strict_ok": true,
238
+ "decode_tps": 5.19,
239
+ "hit_pct": 77.5416,
240
+ "mb_per_token": 150.62332153320312,
241
+ "slots": 35003,
242
+ "arena_gib": 59.89,
243
+ "peak_anon_mib": 23731.8,
244
+ "peak_swap_mb": 0.0,
245
+ "round": 2
246
+ },
247
+ {
248
+ "budget_mb": 4096,
249
+ "reserve_mb": 256,
250
+ "rc": 0,
251
+ "strict_ok": true,
252
+ "decode_tps": 5.84,
253
+ "hit_pct": 59.8982,
254
+ "mb_per_token": 275.4373550415039,
255
+ "slots": 1600,
256
+ "arena_gib": 2.74,
257
+ "peak_anon_mib": 3963.0,
258
+ "peak_swap_mb": 0.0,
259
+ "round": 3
260
+ },
261
+ {
262
+ "budget_mb": 8192,
263
+ "reserve_mb": 512,
264
+ "rc": 0,
265
+ "strict_ok": true,
266
+ "decode_tps": 6.27,
267
+ "hit_pct": 70.7278,
268
+ "mb_per_token": 199.03087615966797,
269
+ "slots": 3715,
270
+ "arena_gib": 6.36,
271
+ "peak_anon_mib": 7668.7,
272
+ "peak_swap_mb": 0.0,
273
+ "round": 3
274
+ },
275
+ {
276
+ "budget_mb": 16384,
277
+ "reserve_mb": 1024,
278
+ "rc": 0,
279
+ "strict_ok": true,
280
+ "decode_tps": 5.87,
281
+ "hit_pct": 76.4723,
282
+ "mb_per_token": 157.93132781982422,
283
+ "slots": 7943,
284
+ "arena_gib": 13.59,
285
+ "peak_anon_mib": 15075.8,
286
+ "peak_swap_mb": 0.0,
287
+ "round": 3
288
+ },
289
+ {
290
+ "budget_mb": 65536,
291
+ "reserve_mb": 1024,
292
+ "rc": 0,
293
+ "strict_ok": true,
294
+ "decode_tps": 6.63,
295
+ "hit_pct": 76.5186,
296
+ "mb_per_token": 157.60040283203125,
297
+ "slots": 35003,
298
+ "arena_gib": 59.89,
299
+ "peak_anon_mib": 23968.9,
300
+ "peak_swap_mb": 0.0,
301
+ "round": 3
302
+ },
303
+ {
304
+ "budget_mb": 4096,
305
+ "reserve_mb": 256,
306
+ "rc": 0,
307
+ "strict_ok": true,
308
+ "decode_tps": 5.39,
309
+ "hit_pct": 63.7432,
310
+ "mb_per_token": 239.48001861572266,
311
+ "slots": 1600,
312
+ "arena_gib": 2.74,
313
+ "peak_anon_mib": 3946.2,
314
+ "peak_swap_mb": 0.0,
315
+ "round": 4
316
+ },
317
+ {
318
+ "budget_mb": 8192,
319
+ "reserve_mb": 512,
320
+ "rc": 0,
321
+ "strict_ok": true,
322
+ "decode_tps": 6.17,
323
+ "hit_pct": 75.0542,
324
+ "mb_per_token": 162.54615783691406,
325
+ "slots": 3715,
326
+ "arena_gib": 6.36,
327
+ "peak_anon_mib": 7652.2,
328
+ "peak_swap_mb": 0.0,
329
+ "round": 4
330
+ },
331
+ {
332
+ "budget_mb": 16384,
333
+ "reserve_mb": 1024,
334
+ "rc": 0,
335
+ "strict_ok": true,
336
+ "decode_tps": 5.5,
337
+ "hit_pct": 79.9662,
338
+ "mb_per_token": 128.8137435913086,
339
+ "slots": 7943,
340
+ "arena_gib": 13.59,
341
+ "peak_anon_mib": 15059.6,
342
+ "peak_swap_mb": 0.0,
343
+ "round": 4
344
+ },
345
+ {
346
+ "budget_mb": 65536,
347
+ "reserve_mb": 1024,
348
+ "rc": 0,
349
+ "strict_ok": true,
350
+ "decode_tps": 5.89,
351
+ "hit_pct": 79.572,
352
+ "mb_per_token": 131.12545013427734,
353
+ "slots": 35003,
354
+ "arena_gib": 59.89,
355
+ "peak_anon_mib": 23094.1,
356
+ "peak_swap_mb": 0.0,
357
+ "round": 4
358
+ },
359
+ {
360
+ "budget_mb": 4096,
361
+ "reserve_mb": 256,
362
+ "rc": 0,
363
+ "strict_ok": true,
364
+ "decode_tps": 4.78,
365
+ "hit_pct": 61.5016,
366
+ "mb_per_token": 264.0085220336914,
367
+ "slots": 1600,
368
+ "arena_gib": 2.74,
369
+ "peak_anon_mib": 3949.7,
370
+ "peak_swap_mb": 0.0,
371
+ "round": 5
372
+ },
373
+ {
374
+ "budget_mb": 8192,
375
+ "reserve_mb": 512,
376
+ "rc": 0,
377
+ "strict_ok": true,
378
+ "decode_tps": 5.99,
379
+ "hit_pct": 71.9699,
380
+ "mb_per_token": 190.41824340820312,
381
+ "slots": 3715,
382
+ "arena_gib": 6.36,
383
+ "peak_anon_mib": 7655.0,
384
+ "peak_swap_mb": 0.0,
385
+ "round": 5
386
+ },
387
+ {
388
+ "budget_mb": 16384,
389
+ "reserve_mb": 1024,
390
+ "rc": 0,
391
+ "strict_ok": true,
392
+ "decode_tps": 6.09,
393
+ "hit_pct": 77.5357,
394
+ "mb_per_token": 150.6662368774414,
395
+ "slots": 7943,
396
+ "arena_gib": 13.59,
397
+ "peak_anon_mib": 15062.0,
398
+ "peak_swap_mb": 0.0,
399
+ "round": 5
400
+ },
401
+ {
402
+ "budget_mb": 65536,
403
+ "reserve_mb": 1024,
404
+ "rc": 0,
405
+ "strict_ok": true,
406
+ "decode_tps": 6.33,
407
+ "hit_pct": 77.5416,
408
+ "mb_per_token": 150.62332153320312,
409
+ "slots": 35003,
410
+ "arena_gib": 59.89,
411
+ "peak_anon_mib": 23731.8,
412
+ "peak_swap_mb": 0.0,
413
+ "round": 5
414
+ },
415
+ {
416
+ "budget_mb": 4096,
417
+ "reserve_mb": 256,
418
+ "rc": 0,
419
+ "strict_ok": true,
420
+ "decode_tps": 5.26,
421
+ "hit_pct": 59.8982,
422
+ "mb_per_token": 275.4487991333008,
423
+ "slots": 1600,
424
+ "arena_gib": 2.74,
425
+ "peak_anon_mib": 3963.4,
426
+ "peak_swap_mb": 0.0,
427
+ "round": 6
428
+ },
429
+ {
430
+ "budget_mb": 8192,
431
+ "reserve_mb": 512,
432
+ "rc": 0,
433
+ "strict_ok": true,
434
+ "decode_tps": 5.96,
435
+ "hit_pct": 70.7278,
436
+ "mb_per_token": 199.03087615966797,
437
+ "slots": 3715,
438
+ "arena_gib": 6.36,
439
+ "peak_anon_mib": 7668.3,
440
+ "peak_swap_mb": 0.0,
441
+ "round": 6
442
+ },
443
+ {
444
+ "budget_mb": 16384,
445
+ "reserve_mb": 1024,
446
+ "rc": 0,
447
+ "strict_ok": true,
448
+ "decode_tps": 6.38,
449
+ "hit_pct": 76.4723,
450
+ "mb_per_token": 157.93132781982422,
451
+ "slots": 7943,
452
+ "arena_gib": 13.59,
453
+ "peak_anon_mib": 15076.2,
454
+ "peak_swap_mb": 0.0,
455
+ "round": 6
456
+ },
457
+ {
458
+ "budget_mb": 65536,
459
+ "reserve_mb": 1024,
460
+ "rc": 0,
461
+ "strict_ok": true,
462
+ "decode_tps": 6.29,
463
+ "hit_pct": 76.5186,
464
+ "mb_per_token": 157.60040283203125,
465
+ "slots": 35003,
466
+ "arena_gib": 59.89,
467
+ "peak_anon_mib": 23968.8,
468
+ "peak_swap_mb": 0.0,
469
+ "round": 6
470
+ },
471
+ {
472
+ "budget_mb": 4096,
473
+ "reserve_mb": 256,
474
+ "rc": 0,
475
+ "strict_ok": true,
476
+ "decode_tps": 5.45,
477
+ "hit_pct": 63.7553,
478
+ "mb_per_token": 239.3970489501953,
479
+ "slots": 1600,
480
+ "arena_gib": 2.74,
481
+ "peak_anon_mib": 3946.2,
482
+ "peak_swap_mb": 0.0,
483
+ "round": 7
484
+ },
485
+ {
486
+ "budget_mb": 8192,
487
+ "reserve_mb": 512,
488
+ "rc": 0,
489
+ "strict_ok": true,
490
+ "decode_tps": 6.75,
491
+ "hit_pct": 75.0603,
492
+ "mb_per_token": 162.5041961669922,
493
+ "slots": 3715,
494
+ "arena_gib": 6.36,
495
+ "peak_anon_mib": 7652.6,
496
+ "peak_swap_mb": 0.0,
497
+ "round": 7
498
+ },
499
+ {
500
+ "budget_mb": 16384,
501
+ "reserve_mb": 1024,
502
+ "rc": 0,
503
+ "strict_ok": true,
504
+ "decode_tps": 6.56,
505
+ "hit_pct": 79.9662,
506
+ "mb_per_token": 128.8137435913086,
507
+ "slots": 7943,
508
+ "arena_gib": 13.59,
509
+ "peak_anon_mib": 15059.9,
510
+ "peak_swap_mb": 0.0,
511
+ "round": 7
512
+ },
513
+ {
514
+ "budget_mb": 65536,
515
+ "reserve_mb": 1024,
516
+ "rc": 0,
517
+ "strict_ok": true,
518
+ "decode_tps": 6.37,
519
+ "hit_pct": 79.572,
520
+ "mb_per_token": 131.12545013427734,
521
+ "slots": 35003,
522
+ "arena_gib": 59.89,
523
+ "peak_anon_mib": 23094.1,
524
+ "peak_swap_mb": 0.0,
525
+ "round": 7
526
+ },
527
+ {
528
+ "budget_mb": 4096,
529
+ "reserve_mb": 256,
530
+ "rc": 0,
531
+ "strict_ok": true,
532
+ "decode_tps": 5.5,
533
+ "hit_pct": 61.4956,
534
+ "mb_per_token": 264.0504837036133,
535
+ "slots": 1600,
536
+ "arena_gib": 2.74,
537
+ "peak_anon_mib": 3949.4,
538
+ "peak_swap_mb": 0.0,
539
+ "round": 8
540
+ },
541
+ {
542
+ "budget_mb": 8192,
543
+ "reserve_mb": 512,
544
+ "rc": 0,
545
+ "strict_ok": true,
546
+ "decode_tps": 6.57,
547
+ "hit_pct": 71.9818,
548
+ "mb_per_token": 190.33336639404297,
549
+ "slots": 3715,
550
+ "arena_gib": 6.36,
551
+ "peak_anon_mib": 7654.6,
552
+ "peak_swap_mb": 0.0,
553
+ "round": 8
554
+ },
555
+ {
556
+ "budget_mb": 16384,
557
+ "reserve_mb": 1024,
558
+ "rc": 0,
559
+ "strict_ok": true,
560
+ "decode_tps": 6.73,
561
+ "hit_pct": 77.5357,
562
+ "mb_per_token": 150.6662368774414,
563
+ "slots": 7943,
564
+ "arena_gib": 13.59,
565
+ "peak_anon_mib": 15062.1,
566
+ "peak_swap_mb": 0.0,
567
+ "round": 8
568
+ },
569
+ {
570
+ "budget_mb": 65536,
571
+ "reserve_mb": 1024,
572
+ "rc": 0,
573
+ "strict_ok": true,
574
+ "decode_tps": 6.81,
575
+ "hit_pct": 77.5416,
576
+ "mb_per_token": 150.62332153320312,
577
+ "slots": 35003,
578
+ "arena_gib": 59.89,
579
+ "peak_anon_mib": 23731.4,
580
+ "peak_swap_mb": 0.0,
581
+ "round": 8
582
+ }
583
+ ]
584
+ }
bench/results/strict_budgets.json ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "budget_mb": 2048,
4
+ "reserve_mb": 512,
5
+ "rc": 0,
6
+ "strict_ok": true,
7
+ "decode_tps": 4.3,
8
+ "hit_pct": 20.3054,
9
+ "mb_per_token": 542.1533584594727,
10
+ "slots": 332,
11
+ "arena_gib": 0.57,
12
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 249 + \u5927 1.95 MiB \u00d7 83\uff08\u5e73\u5747 1.75 MiB\uff09",
13
+ "peak_anon_mb": 1855.1,
14
+ "peak_swap_mb": 0.0,
15
+ "round": 0
16
+ },
17
+ {
18
+ "budget_mb": 3072,
19
+ "reserve_mb": 512,
20
+ "rc": 0,
21
+ "strict_ok": true,
22
+ "decode_tps": 5.06,
23
+ "hit_pct": 48.2818,
24
+ "mb_per_token": 353.35350036621094,
25
+ "slots": 895,
26
+ "arena_gib": 1.53,
27
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 672 + \u5927 1.95 MiB \u00d7 223\uff08\u5e73\u5747 1.75 MiB\uff09",
28
+ "peak_anon_mb": 2841.2,
29
+ "peak_swap_mb": 0.0,
30
+ "round": 0
31
+ },
32
+ {
33
+ "budget_mb": 4096,
34
+ "reserve_mb": 512,
35
+ "rc": 0,
36
+ "strict_ok": true,
37
+ "decode_tps": 5.31,
38
+ "hit_pct": 57.5263,
39
+ "mb_per_token": 291.71085357666016,
40
+ "slots": 1459,
41
+ "arena_gib": 2.5,
42
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1095 + \u5927 1.95 MiB \u00d7 364\uff08\u5e73\u5747 1.75 MiB\uff09",
43
+ "peak_anon_mb": 3829.7,
44
+ "peak_swap_mb": 0.0,
45
+ "round": 0
46
+ },
47
+ {
48
+ "budget_mb": 5120,
49
+ "reserve_mb": 512,
50
+ "rc": 0,
51
+ "strict_ok": true,
52
+ "decode_tps": 4.45,
53
+ "hit_pct": 60.0741,
54
+ "mb_per_token": 274.1832733154297,
55
+ "slots": 2023,
56
+ "arena_gib": 3.46,
57
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1518 + \u5927 1.95 MiB \u00d7 505\uff08\u5e73\u5747 1.75 MiB\uff09",
58
+ "peak_anon_mb": 4811.1,
59
+ "peak_swap_mb": 0.0,
60
+ "round": 0
61
+ },
62
+ {
63
+ "budget_mb": 6144,
64
+ "reserve_mb": 512,
65
+ "rc": 0,
66
+ "strict_ok": true,
67
+ "decode_tps": 5.38,
68
+ "hit_pct": 63.7169,
69
+ "mb_per_token": 248.86226654052734,
70
+ "slots": 2587,
71
+ "arena_gib": 4.43,
72
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1941 + \u5927 1.95 MiB \u00d7 646\uff08\u5e73\u5747 1.75 MiB\uff09",
73
+ "peak_anon_mb": 5799.2,
74
+ "peak_swap_mb": 0.0,
75
+ "round": 0
76
+ },
77
+ {
78
+ "budget_mb": 7168,
79
+ "reserve_mb": 512,
80
+ "rc": 0,
81
+ "strict_ok": true,
82
+ "decode_tps": 4.87,
83
+ "hit_pct": 65.686,
84
+ "mb_per_token": 234.78984832763672,
85
+ "slots": 3150,
86
+ "arena_gib": 5.39,
87
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2363 + \u5927 1.95 MiB \u00d7 787\uff08\u5e73\u5747 1.75 MiB\uff09",
88
+ "peak_anon_mb": 6786.0,
89
+ "peak_swap_mb": 0.0,
90
+ "round": 0
91
+ },
92
+ {
93
+ "budget_mb": 8192,
94
+ "reserve_mb": 512,
95
+ "rc": 0,
96
+ "strict_ok": true,
97
+ "decode_tps": 5.45,
98
+ "hit_pct": 70.7393,
99
+ "mb_per_token": 198.94886016845703,
100
+ "slots": 3714,
101
+ "arena_gib": 6.35,
102
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2786 + \u5927 1.95 MiB \u00d7 928\uff08\u5e73\u5747 1.75 MiB\uff09",
103
+ "peak_anon_mb": 7780.2,
104
+ "peak_swap_mb": 0.0,
105
+ "round": 0
106
+ },
107
+ {
108
+ "budget_mb": 2048,
109
+ "reserve_mb": 512,
110
+ "rc": 0,
111
+ "strict_ok": true,
112
+ "decode_tps": 4.68,
113
+ "hit_pct": 23.7283,
114
+ "mb_per_token": 499.88555908203125,
115
+ "slots": 332,
116
+ "arena_gib": 0.57,
117
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 249 + \u5927 1.95 MiB \u00d7 83\uff08\u5e73\u5747 1.75 MiB\uff09",
118
+ "peak_anon_mb": 1839.9,
119
+ "peak_swap_mb": 0.0,
120
+ "round": 1
121
+ },
122
+ {
123
+ "budget_mb": 3072,
124
+ "reserve_mb": 512,
125
+ "rc": 0,
126
+ "strict_ok": true,
127
+ "decode_tps": 5.44,
128
+ "hit_pct": 54.8276,
129
+ "mb_per_token": 296.50211334228516,
130
+ "slots": 895,
131
+ "arena_gib": 1.53,
132
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 672 + \u5927 1.95 MiB \u00d7 223\uff08\u5e73\u5747 1.75 MiB\uff09",
133
+ "peak_anon_mb": 2826.4,
134
+ "peak_swap_mb": 0.0,
135
+ "round": 1
136
+ },
137
+ {
138
+ "budget_mb": 4096,
139
+ "reserve_mb": 512,
140
+ "rc": 0,
141
+ "strict_ok": true,
142
+ "decode_tps": 5.48,
143
+ "hit_pct": 64.953,
144
+ "mb_per_token": 229.83646392822266,
145
+ "slots": 1459,
146
+ "arena_gib": 2.5,
147
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1095 + \u5927 1.95 MiB \u00d7 364\uff08\u5e73\u5747 1.75 MiB\uff09",
148
+ "peak_anon_mb": 3814.6,
149
+ "peak_swap_mb": 0.0,
150
+ "round": 1
151
+ },
152
+ {
153
+ "budget_mb": 5120,
154
+ "reserve_mb": 512,
155
+ "rc": 0,
156
+ "strict_ok": true,
157
+ "decode_tps": 5.65,
158
+ "hit_pct": 70.9499,
159
+ "mb_per_token": 190.71578979492188,
160
+ "slots": 2023,
161
+ "arena_gib": 3.46,
162
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1518 + \u5927 1.95 MiB \u00d7 505\uff08\u5e73\u5747 1.75 MiB\uff09",
163
+ "peak_anon_mb": 4802.7,
164
+ "peak_swap_mb": 0.0,
165
+ "round": 1
166
+ },
167
+ {
168
+ "budget_mb": 6144,
169
+ "reserve_mb": 512,
170
+ "rc": 0,
171
+ "strict_ok": true,
172
+ "decode_tps": 5.74,
173
+ "hit_pct": 72.758,
174
+ "mb_per_token": 178.3151626586914,
175
+ "slots": 2587,
176
+ "arena_gib": 4.43,
177
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1941 + \u5927 1.95 MiB \u00d7 646\uff08\u5e73\u5747 1.75 MiB\uff09",
178
+ "peak_anon_mb": 5790.4,
179
+ "peak_swap_mb": 0.0,
180
+ "round": 1
181
+ },
182
+ {
183
+ "budget_mb": 7168,
184
+ "reserve_mb": 512,
185
+ "rc": 0,
186
+ "strict_ok": true,
187
+ "decode_tps": 5.1,
188
+ "hit_pct": 74.2587,
189
+ "mb_per_token": 168.00975799560547,
190
+ "slots": 3150,
191
+ "arena_gib": 5.39,
192
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2363 + \u5927 1.95 MiB \u00d7 787\uff08\u5e73\u5747 1.75 MiB\uff09",
193
+ "peak_anon_mb": 6777.3,
194
+ "peak_swap_mb": 0.0,
195
+ "round": 1
196
+ },
197
+ {
198
+ "budget_mb": 8192,
199
+ "reserve_mb": 512,
200
+ "rc": 0,
201
+ "strict_ok": true,
202
+ "decode_tps": 5.87,
203
+ "hit_pct": 76.2146,
204
+ "mb_per_token": 154.17957305908203,
205
+ "slots": 3714,
206
+ "arena_gib": 6.35,
207
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2786 + \u5927 1.95 MiB \u00d7 928\uff08\u5e73\u5747 1.75 MiB\uff09",
208
+ "peak_anon_mb": 7764.4,
209
+ "peak_swap_mb": 0.0,
210
+ "round": 1
211
+ },
212
+ {
213
+ "budget_mb": 2048,
214
+ "reserve_mb": 512,
215
+ "rc": 0,
216
+ "strict_ok": true,
217
+ "decode_tps": 4.44,
218
+ "hit_pct": 22.7189,
219
+ "mb_per_token": 526.1802673339844,
220
+ "slots": 332,
221
+ "arena_gib": 0.57,
222
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 249 + \u5927 1.95 MiB \u00d7 83\uff08\u5e73\u5747 1.75 MiB\uff09",
223
+ "peak_anon_mb": 1842.3,
224
+ "peak_swap_mb": 0.0,
225
+ "round": 2
226
+ },
227
+ {
228
+ "budget_mb": 3072,
229
+ "reserve_mb": 512,
230
+ "rc": 0,
231
+ "strict_ok": true,
232
+ "decode_tps": 5.28,
233
+ "hit_pct": 50.1924,
234
+ "mb_per_token": 339.9505615234375,
235
+ "slots": 895,
236
+ "arena_gib": 1.53,
237
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 672 + \u5927 1.95 MiB \u00d7 223\uff08\u5e73\u5747 1.75 MiB\uff09",
238
+ "peak_anon_mb": 2828.5,
239
+ "peak_swap_mb": 0.0,
240
+ "round": 2
241
+ },
242
+ {
243
+ "budget_mb": 4096,
244
+ "reserve_mb": 512,
245
+ "rc": 0,
246
+ "strict_ok": true,
247
+ "decode_tps": 5.83,
248
+ "hit_pct": 59.8082,
249
+ "mb_per_token": 275.8455276489258,
250
+ "slots": 1459,
251
+ "arena_gib": 2.5,
252
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1095 + \u5927 1.95 MiB \u00d7 364\uff08\u5e73\u5747 1.75 MiB\uff09",
253
+ "peak_anon_mb": 3817.0,
254
+ "peak_swap_mb": 0.0,
255
+ "round": 2
256
+ },
257
+ {
258
+ "budget_mb": 5120,
259
+ "reserve_mb": 512,
260
+ "rc": 0,
261
+ "strict_ok": true,
262
+ "decode_tps": 5.83,
263
+ "hit_pct": 64.8765,
264
+ "mb_per_token": 240.75603485107422,
265
+ "slots": 2023,
266
+ "arena_gib": 3.46,
267
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1518 + \u5927 1.95 MiB \u00d7 505\uff08\u5e73\u5747 1.75 MiB\uff09",
268
+ "peak_anon_mb": 4804.7,
269
+ "peak_swap_mb": 0.0,
270
+ "round": 2
271
+ },
272
+ {
273
+ "budget_mb": 6144,
274
+ "reserve_mb": 512,
275
+ "rc": 0,
276
+ "strict_ok": true,
277
+ "decode_tps": 5.73,
278
+ "hit_pct": 67.5173,
279
+ "mb_per_token": 222.21946716308594,
280
+ "slots": 2587,
281
+ "arena_gib": 4.43,
282
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1941 + \u5927 1.95 MiB \u00d7 646\uff08\u5e73\u5747 1.75 MiB\uff09",
283
+ "peak_anon_mb": 5793.3,
284
+ "peak_swap_mb": 0.0,
285
+ "round": 2
286
+ },
287
+ {
288
+ "budget_mb": 7168,
289
+ "reserve_mb": 512,
290
+ "rc": 0,
291
+ "strict_ok": true,
292
+ "decode_tps": 5.91,
293
+ "hit_pct": 68.5975,
294
+ "mb_per_token": 214.0064239501953,
295
+ "slots": 3150,
296
+ "arena_gib": 5.39,
297
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2363 + \u5927 1.95 MiB \u00d7 787\uff08\u5e73\u5747 1.75 MiB\uff09",
298
+ "peak_anon_mb": 6778.7,
299
+ "peak_swap_mb": 0.0,
300
+ "round": 2
301
+ },
302
+ {
303
+ "budget_mb": 8192,
304
+ "reserve_mb": 512,
305
+ "rc": 0,
306
+ "strict_ok": true,
307
+ "decode_tps": 5.58,
308
+ "hit_pct": 70.0723,
309
+ "mb_per_token": 203.4769058227539,
310
+ "slots": 3714,
311
+ "arena_gib": 6.35,
312
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2786 + \u5927 1.95 MiB \u00d7 928\uff08\u5e73\u5747 1.75 MiB\uff09",
313
+ "peak_anon_mb": 7766.4,
314
+ "peak_swap_mb": 0.0,
315
+ "round": 2
316
+ },
317
+ {
318
+ "budget_mb": 2048,
319
+ "reserve_mb": 512,
320
+ "rc": 0,
321
+ "strict_ok": true,
322
+ "decode_tps": 4.32,
323
+ "hit_pct": 20.2997,
324
+ "mb_per_token": 542.1895980834961,
325
+ "slots": 332,
326
+ "arena_gib": 0.57,
327
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 249 + \u5927 1.95 MiB \u00d7 83\uff08\u5e73\u5747 1.75 MiB\uff09",
328
+ "peak_anon_mb": 1855.2,
329
+ "peak_swap_mb": 0.0,
330
+ "round": 3
331
+ },
332
+ {
333
+ "budget_mb": 3072,
334
+ "reserve_mb": 512,
335
+ "rc": 0,
336
+ "strict_ok": true,
337
+ "decode_tps": 5.04,
338
+ "hit_pct": 48.2876,
339
+ "mb_per_token": 353.32298278808594,
340
+ "slots": 895,
341
+ "arena_gib": 1.53,
342
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 672 + \u5927 1.95 MiB \u00d7 223\uff08\u5e73\u5747 1.75 MiB\uff09",
343
+ "peak_anon_mb": 2841.5,
344
+ "peak_swap_mb": 0.0,
345
+ "round": 3
346
+ },
347
+ {
348
+ "budget_mb": 4096,
349
+ "reserve_mb": 512,
350
+ "rc": 0,
351
+ "strict_ok": true,
352
+ "decode_tps": 5.92,
353
+ "hit_pct": 57.509,
354
+ "mb_per_token": 291.83483123779297,
355
+ "slots": 1459,
356
+ "arena_gib": 2.5,
357
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1095 + \u5927 1.95 MiB \u00d7 364\uff08\u5e73\u5747 1.75 MiB\uff09",
358
+ "peak_anon_mb": 3829.6,
359
+ "peak_swap_mb": 0.0,
360
+ "round": 3
361
+ },
362
+ {
363
+ "budget_mb": 5120,
364
+ "reserve_mb": 512,
365
+ "rc": 0,
366
+ "strict_ok": true,
367
+ "decode_tps": 5.82,
368
+ "hit_pct": 60.0452,
369
+ "mb_per_token": 274.39022064208984,
370
+ "slots": 2023,
371
+ "arena_gib": 3.46,
372
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1518 + \u5927 1.95 MiB \u00d7 505\uff08\u5e73\u5747 1.75 MiB\uff09",
373
+ "peak_anon_mb": 4811.5,
374
+ "peak_swap_mb": 0.0,
375
+ "round": 3
376
+ },
377
+ {
378
+ "budget_mb": 6144,
379
+ "reserve_mb": 512,
380
+ "rc": 0,
381
+ "strict_ok": true,
382
+ "decode_tps": 4.92,
383
+ "hit_pct": 63.7053,
384
+ "mb_per_token": 248.94428253173828,
385
+ "slots": 2587,
386
+ "arena_gib": 4.43,
387
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1941 + \u5927 1.95 MiB \u00d7 646\uff08\u5e73\u5747 1.75 MiB\uff09",
388
+ "peak_anon_mb": 5799.2,
389
+ "peak_swap_mb": 0.0,
390
+ "round": 3
391
+ },
392
+ {
393
+ "budget_mb": 7168,
394
+ "reserve_mb": 512,
395
+ "rc": 0,
396
+ "strict_ok": true,
397
+ "decode_tps": 4.75,
398
+ "hit_pct": 65.686,
399
+ "mb_per_token": 234.78984832763672,
400
+ "slots": 3150,
401
+ "arena_gib": 5.39,
402
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2363 + \u5927 1.95 MiB \u00d7 787\uff08\u5e73\u5747 1.75 MiB\uff09",
403
+ "peak_anon_mb": 6786.0,
404
+ "peak_swap_mb": 0.0,
405
+ "round": 3
406
+ },
407
+ {
408
+ "budget_mb": 8192,
409
+ "reserve_mb": 512,
410
+ "rc": 0,
411
+ "strict_ok": true,
412
+ "decode_tps": 5.39,
413
+ "hit_pct": 70.7393,
414
+ "mb_per_token": 198.94886016845703,
415
+ "slots": 3714,
416
+ "arena_gib": 6.35,
417
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2786 + \u5927 1.95 MiB \u00d7 928\uff08\u5e73\u5747 1.75 MiB\uff09",
418
+ "peak_anon_mb": 7780.6,
419
+ "peak_swap_mb": 0.0,
420
+ "round": 3
421
+ },
422
+ {
423
+ "budget_mb": 2048,
424
+ "reserve_mb": 512,
425
+ "rc": 0,
426
+ "strict_ok": true,
427
+ "decode_tps": 4.0,
428
+ "hit_pct": 23.6861,
429
+ "mb_per_token": 500.1373291015625,
430
+ "slots": 332,
431
+ "arena_gib": 0.57,
432
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 249 + \u5927 1.95 MiB \u00d7 83\uff08\u5e73\u5747 1.75 MiB\uff09",
433
+ "peak_anon_mb": 1839.9,
434
+ "peak_swap_mb": 0.0,
435
+ "round": 4
436
+ },
437
+ {
438
+ "budget_mb": 3072,
439
+ "reserve_mb": 512,
440
+ "rc": 0,
441
+ "strict_ok": true,
442
+ "decode_tps": 5.16,
443
+ "hit_pct": 54.8156,
444
+ "mb_per_token": 296.5574264526367,
445
+ "slots": 895,
446
+ "arena_gib": 1.53,
447
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 672 + \u5927 1.95 MiB \u00d7 223\uff08\u5e73\u5747 1.75 MiB\uff09",
448
+ "peak_anon_mb": 2826.1,
449
+ "peak_swap_mb": 0.0,
450
+ "round": 4
451
+ },
452
+ {
453
+ "budget_mb": 4096,
454
+ "reserve_mb": 512,
455
+ "rc": 0,
456
+ "strict_ok": true,
457
+ "decode_tps": 5.35,
458
+ "hit_pct": 64.9409,
459
+ "mb_per_token": 229.9184799194336,
460
+ "slots": 1459,
461
+ "arena_gib": 2.5,
462
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1095 + \u5927 1.95 MiB \u00d7 364\uff08\u5e73\u5747 1.75 MiB\uff09",
463
+ "peak_anon_mb": 3814.2,
464
+ "peak_swap_mb": 0.0,
465
+ "round": 4
466
+ },
467
+ {
468
+ "budget_mb": 5120,
469
+ "reserve_mb": 512,
470
+ "rc": 0,
471
+ "strict_ok": true,
472
+ "decode_tps": 5.89,
473
+ "hit_pct": 70.9438,
474
+ "mb_per_token": 190.75775146484375,
475
+ "slots": 2023,
476
+ "arena_gib": 3.46,
477
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1518 + \u5927 1.95 MiB \u00d7 505\uff08\u5e73\u5747 1.75 MiB\uff09",
478
+ "peak_anon_mb": 4802.4,
479
+ "peak_swap_mb": 0.0,
480
+ "round": 4
481
+ },
482
+ {
483
+ "budget_mb": 6144,
484
+ "reserve_mb": 512,
485
+ "rc": 0,
486
+ "strict_ok": true,
487
+ "decode_tps": 5.37,
488
+ "hit_pct": 72.7459,
489
+ "mb_per_token": 178.39813232421875,
490
+ "slots": 2587,
491
+ "arena_gib": 4.43,
492
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 1941 + \u5927 1.95 MiB \u00d7 646\uff08\u5e73\u5747 1.75 MiB\uff09",
493
+ "peak_anon_mb": 5790.5,
494
+ "peak_swap_mb": 0.0,
495
+ "round": 4
496
+ },
497
+ {
498
+ "budget_mb": 7168,
499
+ "reserve_mb": 512,
500
+ "rc": 0,
501
+ "strict_ok": true,
502
+ "decode_tps": 5.67,
503
+ "hit_pct": 74.2466,
504
+ "mb_per_token": 168.0927276611328,
505
+ "slots": 3150,
506
+ "arena_gib": 5.39,
507
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2363 + \u5927 1.95 MiB \u00d7 787\uff08\u5e73\u5747 1.75 MiB\uff09",
508
+ "peak_anon_mb": 6777.3,
509
+ "peak_swap_mb": 0.0,
510
+ "round": 4
511
+ },
512
+ {
513
+ "budget_mb": 8192,
514
+ "reserve_mb": 512,
515
+ "rc": 0,
516
+ "strict_ok": true,
517
+ "decode_tps": 5.42,
518
+ "hit_pct": 76.2146,
519
+ "mb_per_token": 154.17957305908203,
520
+ "slots": 3714,
521
+ "arena_gib": 6.35,
522
+ "slot_split": "\u5c0f 1.69 MiB \u00d7 2786 + \u5927 1.95 MiB \u00d7 928\uff08\u5e73\u5747 1.75 MiB\uff09",
523
+ "peak_anon_mb": 7764.4,
524
+ "peak_swap_mb": 0.0,
525
+ "round": 4
526
+ }
527
+ ]
bench/results/strict_headline.json ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "config": {
3
+ "model": "/root/models/minicpm-v4.7-35b-a3b-q4_k_m.gguf",
4
+ "bins": "['base=/root/app/llama.cpp/build/bin/epq-chat']",
5
+ "bin": "",
6
+ "rounds": "9",
7
+ "strict_mb": "8192",
8
+ "strict_data_mb": "0",
9
+ "metric": "anon",
10
+ "cli": "chat",
11
+ "ram_budget_mb": "0",
12
+ "ctx": "4096",
13
+ "threads": "8",
14
+ "ubatch": "128",
15
+ "n_predict": "48",
16
+ "no_prefetch": "False",
17
+ "no_lock": "False",
18
+ "extra": "[]",
19
+ "label": "headline",
20
+ "workdir": "/tmp/bench",
21
+ "keep_text": "False"
22
+ },
23
+ "results": [
24
+ {
25
+ "arm": "base",
26
+ "round": 0,
27
+ "wall_s": 12.26,
28
+ "returncode": 0,
29
+ "strict_ok": true,
30
+ "peak_anon_mb": 7781.2,
31
+ "peak_swap_mb": 0.0,
32
+ "vm_lck_mb": 7642.9,
33
+ "swap_samples_over_zero": 0,
34
+ "peak_file_mb": 1321.9,
35
+ "decode_tokens": 47,
36
+ "decode_s": 7.09,
37
+ "decode_tok_s": 6.63,
38
+ "slots": 3714,
39
+ "arena_gib": 6.35,
40
+ "hit_rate_pct": 68.6775,
41
+ "ssd_mb_per_token": 220.29495239257812,
42
+ "prefetch_used_pct": 0,
43
+ "stdout_tail": "ixture-of-Experts (MoE) models route tokens by using a lightweight gating network to dynamically select a small subset of specialized \"expert\"在 $100 \\mathrm{~m"
44
+ },
45
+ {
46
+ "arm": "base",
47
+ "round": 1,
48
+ "wall_s": 13.43,
49
+ "returncode": 0,
50
+ "strict_ok": true,
51
+ "peak_anon_mb": 7799.5,
52
+ "peak_swap_mb": 0.0,
53
+ "vm_lck_mb": 7650.5,
54
+ "swap_samples_over_zero": 0,
55
+ "peak_file_mb": 1321.9,
56
+ "decode_tokens": 47,
57
+ "decode_s": 7.57,
58
+ "decode_tok_s": 6.21,
59
+ "slots": 3714,
60
+ "arena_gib": 6.35,
61
+ "hit_rate_pct": 72.1095,
62
+ "ssd_mb_per_token": 192.59357452392578,
63
+ "prefetch_used_pct": 0,
64
+ "stdout_tail": ".gguf\n[sdq] ctx=4096 threads=8 ubatch=128 RAM(RSS)=8568 MB 模型載入 0.7s\nassistant: 1. **CPU** 是電腦的「大腦」,負責執行所有運算與指令,但本身不儲存大量資料。\n2. **RAM** 是「短期工作記憶」,存放 CPU 正在處理"
65
+ },
66
+ {
67
+ "arm": "base",
68
+ "round": 2,
69
+ "wall_s": 12.62,
70
+ "returncode": 0,
71
+ "strict_ok": true,
72
+ "peak_anon_mb": 7799.5,
73
+ "peak_swap_mb": 0.0,
74
+ "vm_lck_mb": 7650.5,
75
+ "swap_samples_over_zero": 0,
76
+ "peak_file_mb": 1322.1,
77
+ "decode_tokens": 47,
78
+ "decode_s": 7.42,
79
+ "decode_tok_s": 6.34,
80
+ "slots": 3714,
81
+ "arena_gib": 6.35,
82
+ "hit_rate_pct": 68.6112,
83
+ "ssd_mb_per_token": 214.11705017089844,
84
+ "prefetch_used_pct": 0,
85
+ "stdout_tail": "*Analyze the Request:**\n * Task: List five daily work tasks that involve using a computer.\n * Constraint: Each task must be explained in one sentence."
86
+ },
87
+ {
88
+ "arm": "base",
89
+ "round": 3,
90
+ "wall_s": 15.36,
91
+ "returncode": 0,
92
+ "strict_ok": true,
93
+ "peak_anon_mb": 7781.6,
94
+ "peak_swap_mb": 0.0,
95
+ "vm_lck_mb": 7642.9,
96
+ "swap_samples_over_zero": 0,
97
+ "peak_file_mb": 1321.8,
98
+ "decode_tokens": 47,
99
+ "decode_s": 9.05,
100
+ "decode_tok_s": 5.19,
101
+ "slots": 3714,
102
+ "arena_gib": 6.35,
103
+ "hit_rate_pct": 68.6775,
104
+ "ssd_mb_per_token": 220.29495239257812,
105
+ "prefetch_used_pct": 0,
106
+ "stdout_tail": "ixture-of-Experts (MoE) models route tokens by using a lightweight gating network to dynamically select a small subset of specialized \"expert\"在 $100 \\mathrm{~m"
107
+ },
108
+ {
109
+ "arm": "base",
110
+ "round": 4,
111
+ "wall_s": 13.22,
112
+ "returncode": 0,
113
+ "strict_ok": true,
114
+ "peak_anon_mb": 7799.1,
115
+ "peak_swap_mb": 0.0,
116
+ "vm_lck_mb": 7650.5,
117
+ "swap_samples_over_zero": 0,
118
+ "peak_file_mb": 1322.1,
119
+ "decode_tokens": 47,
120
+ "decode_s": 8.13,
121
+ "decode_tok_s": 5.78,
122
+ "slots": 3714,
123
+ "arena_gib": 6.35,
124
+ "hit_rate_pct": 72.1152,
125
+ "ssd_mb_per_token": 192.5516128540039,
126
+ "prefetch_used_pct": 0,
127
+ "stdout_tail": ".gguf\n[sdq] ctx=4096 threads=8 ubatch=128 RAM(RSS)=8568 MB 模型載入 0.7s\nassistant: 1. **CPU** 是電腦的「大腦」,負責執行所有運算與指令,但本身不儲存大量資料。\n2. **RAM** 是「短期工作記憶」,存放 CPU 正在處理"
128
+ },
129
+ {
130
+ "arm": "base",
131
+ "round": 5,
132
+ "wall_s": 14.53,
133
+ "returncode": 0,
134
+ "strict_ok": true,
135
+ "peak_anon_mb": 7799.2,
136
+ "peak_swap_mb": 0.0,
137
+ "vm_lck_mb": 7650.5,
138
+ "swap_samples_over_zero": 0,
139
+ "peak_file_mb": 1322.1,
140
+ "decode_tokens": 47,
141
+ "decode_s": 8.67,
142
+ "decode_tok_s": 5.42,
143
+ "slots": 3714,
144
+ "arena_gib": 6.35,
145
+ "hit_rate_pct": 68.617,
146
+ "ssd_mb_per_token": 214.07604217529297,
147
+ "prefetch_used_pct": 0,
148
+ "stdout_tail": "*Analyze the Request:**\n * Task: List five daily work tasks that involve using a computer.\n * Constraint: Each task must be explained in one sentence."
149
+ },
150
+ {
151
+ "arm": "base",
152
+ "round": 6,
153
+ "wall_s": 15.05,
154
+ "returncode": 0,
155
+ "strict_ok": true,
156
+ "peak_anon_mb": 7781.2,
157
+ "peak_swap_mb": 0.0,
158
+ "vm_lck_mb": 7642.9,
159
+ "swap_samples_over_zero": 0,
160
+ "peak_file_mb": 1321.6,
161
+ "decode_tokens": 47,
162
+ "decode_s": 8.9,
163
+ "decode_tok_s": 5.28,
164
+ "slots": 3714,
165
+ "arena_gib": 6.35,
166
+ "hit_rate_pct": 68.6717,
167
+ "ssd_mb_per_token": 220.3378677368164,
168
+ "prefetch_used_pct": 0,
169
+ "stdout_tail": "ixture-of-Experts (MoE) models route tokens by using a lightweight gating network to dynamically select a small subset of specialized \"expert\"在 $100 \\mathrm{~m"
170
+ },
171
+ {
172
+ "arm": "base",
173
+ "round": 7,
174
+ "wall_s": 13.28,
175
+ "returncode": 0,
176
+ "strict_ok": true,
177
+ "peak_anon_mb": 7799.3,
178
+ "peak_swap_mb": 0.0,
179
+ "vm_lck_mb": 7650.5,
180
+ "swap_samples_over_zero": 0,
181
+ "peak_file_mb": 1322.1,
182
+ "decode_tokens": 47,
183
+ "decode_s": 7.51,
184
+ "decode_tok_s": 6.26,
185
+ "slots": 3714,
186
+ "arena_gib": 6.35,
187
+ "hit_rate_pct": 72.1209,
188
+ "ssd_mb_per_token": 192.51060485839844,
189
+ "prefetch_used_pct": 0,
190
+ "stdout_tail": ".gguf\n[sdq] ctx=4096 threads=8 ubatch=128 RAM(RSS)=8568 MB 模型載入 0.8s\nassistant: 1. **CPU** 是電腦的「大腦」,負責執行所有運算與指令,但本身不儲存大量資料。\n2. **RAM** 是「短期工作記憶」,存放 CPU 正在處理"
191
+ },
192
+ {
193
+ "arm": "base",
194
+ "round": 8,
195
+ "wall_s": 14.18,
196
+ "returncode": 0,
197
+ "strict_ok": true,
198
+ "peak_anon_mb": 7799.1,
199
+ "peak_swap_mb": 0.0,
200
+ "vm_lck_mb": 7650.5,
201
+ "swap_samples_over_zero": 0,
202
+ "peak_file_mb": 1322.4,
203
+ "decode_tokens": 47,
204
+ "decode_s": 8.07,
205
+ "decode_tok_s": 5.83,
206
+ "slots": 3714,
207
+ "arena_gib": 6.35,
208
+ "hit_rate_pct": 68.617,
209
+ "ssd_mb_per_token": 214.07604217529297,
210
+ "prefetch_used_pct": 0,
211
+ "stdout_tail": "*Analyze the Request:**\n * Task: List five daily work tasks that involve using a computer.\n * Constraint: Each task must be explained in one sentence."
212
+ }
213
+ ]
214
+ }
bench/results/strict_headline.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # strict 8192 MiB / no-swap — headline
2
+
3
+ - 模型:`/root/models/minicpm-v4.7-35b-a3b-q4_k_m.gguf`
4
+ - ctx=4096 ubatch=128 threads=8 n_predict=48 temp=0
5
+ - 上限:8192 MiB(anon),每次執行都由 `tools/strict8g.py` 管治;VmSwap 必須全程為 0,匿名記憶體全程 mlock
6
+
7
+ | 指標 | base |
8
+ | --- | ---: |
9
+ | Decode tok/s (median) | 5.83 |
10
+ | expert cache hit % | 68.68 |
11
+ | SSD MB / decode token | 214.1 |
12
+ | slots | 3714 |
13
+ | arena GiB | 6.35 |
14
+ | peak anon RSS MiB | 7799 |
15
+ | peak VmSwap MiB | 0.000 |
16
+ | draft acceptance % (min-max) | — |
17
+
18
+ 每個 arm 9 輪;第一個 arm 的單輪 decode tok/s 全距 5.19–6.63,抖動幅度 25%。 請以中位數比較,不要用單輪數字。
19
+
20
+ 完整逐輪資料:`strict_headline.json`
bench/results/strict_prefetch.json ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "label": "prefetch",
3
+ "rows": [
4
+ {
5
+ "rc": 0,
6
+ "strict_ok": true,
7
+ "tps": 5.55,
8
+ "hit_pct": 70.7335,
9
+ "mb_per_token": 198.9898681640625,
10
+ "prefetch_issued": 0,
11
+ "prefetch_used": 0,
12
+ "peak_anon_mb": 7780.5,
13
+ "peak_swap_mb": 0.0,
14
+ "arm": "prefetch_off",
15
+ "round": 0
16
+ },
17
+ {
18
+ "rc": 0,
19
+ "strict_ok": true,
20
+ "tps": 6.22,
21
+ "hit_pct": 71.0633,
22
+ "mb_per_token": 208.33301544189453,
23
+ "prefetch_issued": 296,
24
+ "prefetch_used": 9,
25
+ "peak_anon_mb": 7780.6,
26
+ "peak_swap_mb": 0.0,
27
+ "arm": "prefetch_layer_ahead",
28
+ "round": 0
29
+ },
30
+ {
31
+ "rc": 0,
32
+ "strict_ok": true,
33
+ "tps": 5.47,
34
+ "hit_pct": 67.7361,
35
+ "mb_per_token": 220.1375961303711,
36
+ "prefetch_issued": 0,
37
+ "prefetch_used": 0,
38
+ "peak_anon_mb": 7773.8,
39
+ "peak_swap_mb": 0.0,
40
+ "arm": "prefetch_end_token",
41
+ "round": 0
42
+ },
43
+ {
44
+ "rc": 0,
45
+ "strict_ok": true,
46
+ "tps": 5.66,
47
+ "hit_pct": 75.0542,
48
+ "mb_per_token": 162.54615783691406,
49
+ "prefetch_issued": 0,
50
+ "prefetch_used": 0,
51
+ "peak_anon_mb": 7765.4,
52
+ "peak_swap_mb": 0.0,
53
+ "arm": "prefetch_off",
54
+ "round": 1
55
+ },
56
+ {
57
+ "rc": 0,
58
+ "strict_ok": true,
59
+ "tps": 5.38,
60
+ "hit_pct": 75.3737,
61
+ "mb_per_token": 172.6512908935547,
62
+ "prefetch_issued": 312,
63
+ "prefetch_used": 15,
64
+ "peak_anon_mb": 7765.0,
65
+ "peak_swap_mb": 0.0,
66
+ "arm": "prefetch_layer_ahead",
67
+ "round": 1
68
+ },
69
+ {
70
+ "rc": 0,
71
+ "strict_ok": true,
72
+ "tps": 5.45,
73
+ "hit_pct": 75.0603,
74
+ "mb_per_token": 162.5041961669922,
75
+ "prefetch_issued": 0,
76
+ "prefetch_used": 0,
77
+ "peak_anon_mb": 7765.0,
78
+ "peak_swap_mb": 0.0,
79
+ "arm": "prefetch_end_token",
80
+ "round": 1
81
+ },
82
+ {
83
+ "rc": 0,
84
+ "strict_ok": true,
85
+ "tps": 5.88,
86
+ "hit_pct": 71.9818,
87
+ "mb_per_token": 190.33336639404297,
88
+ "prefetch_issued": 0,
89
+ "prefetch_used": 0,
90
+ "peak_anon_mb": 7767.8,
91
+ "peak_swap_mb": 0.0,
92
+ "arm": "prefetch_off",
93
+ "round": 2
94
+ },
95
+ {
96
+ "rc": 0,
97
+ "strict_ok": true,
98
+ "tps": 5.8,
99
+ "hit_pct": 72.2186,
100
+ "mb_per_token": 201.25865936279297,
101
+ "prefetch_issued": 312,
102
+ "prefetch_used": 10,
103
+ "peak_anon_mb": 7767.8,
104
+ "peak_swap_mb": 0.0,
105
+ "arm": "prefetch_layer_ahead",
106
+ "round": 2
107
+ },
108
+ {
109
+ "rc": 0,
110
+ "strict_ok": true,
111
+ "tps": 6.04,
112
+ "hit_pct": 71.9877,
113
+ "mb_per_token": 190.2914047241211,
114
+ "prefetch_issued": 0,
115
+ "prefetch_used": 0,
116
+ "peak_anon_mb": 7767.8,
117
+ "peak_swap_mb": 0.0,
118
+ "arm": "prefetch_end_token",
119
+ "round": 2
120
+ },
121
+ {
122
+ "rc": 0,
123
+ "strict_ok": true,
124
+ "tps": 6.42,
125
+ "hit_pct": 70.7393,
126
+ "mb_per_token": 198.94886016845703,
127
+ "prefetch_issued": 0,
128
+ "prefetch_used": 0,
129
+ "peak_anon_mb": 7780.6,
130
+ "peak_swap_mb": 0.0,
131
+ "arm": "prefetch_off",
132
+ "round": 3
133
+ },
134
+ {
135
+ "rc": 0,
136
+ "strict_ok": true,
137
+ "tps": 6.26,
138
+ "hit_pct": 71.0401,
139
+ "mb_per_token": 208.49895477294922,
140
+ "prefetch_issued": 296,
141
+ "prefetch_used": 0,
142
+ "peak_anon_mb": 7780.6,
143
+ "peak_swap_mb": 0.0,
144
+ "arm": "prefetch_layer_ahead",
145
+ "round": 3
146
+ },
147
+ {
148
+ "rc": 0,
149
+ "strict_ok": true,
150
+ "tps": 5.8,
151
+ "hit_pct": 67.7361,
152
+ "mb_per_token": 220.1375961303711,
153
+ "prefetch_issued": 0,
154
+ "prefetch_used": 0,
155
+ "peak_anon_mb": 7774.2,
156
+ "peak_swap_mb": 0.0,
157
+ "arm": "prefetch_end_token",
158
+ "round": 3
159
+ },
160
+ {
161
+ "rc": 0,
162
+ "strict_ok": true,
163
+ "tps": 6.01,
164
+ "hit_pct": 75.0542,
165
+ "mb_per_token": 162.54615783691406,
166
+ "prefetch_issued": 0,
167
+ "prefetch_used": 0,
168
+ "peak_anon_mb": 7765.4,
169
+ "peak_swap_mb": 0.0,
170
+ "arm": "prefetch_off",
171
+ "round": 4
172
+ },
173
+ {
174
+ "rc": 0,
175
+ "strict_ok": true,
176
+ "tps": 5.75,
177
+ "hit_pct": 75.3797,
178
+ "mb_per_token": 172.61028289794922,
179
+ "prefetch_issued": 312,
180
+ "prefetch_used": 15,
181
+ "peak_anon_mb": 7765.1,
182
+ "peak_swap_mb": 0.0,
183
+ "arm": "prefetch_layer_ahead",
184
+ "round": 4
185
+ },
186
+ {
187
+ "rc": 0,
188
+ "strict_ok": true,
189
+ "tps": 5.63,
190
+ "hit_pct": 75.0542,
191
+ "mb_per_token": 162.54615783691406,
192
+ "prefetch_issued": 0,
193
+ "prefetch_used": 0,
194
+ "peak_anon_mb": 7765.4,
195
+ "peak_swap_mb": 0.0,
196
+ "arm": "prefetch_end_token",
197
+ "round": 4
198
+ },
199
+ {
200
+ "rc": 0,
201
+ "strict_ok": true,
202
+ "tps": 6.68,
203
+ "hit_pct": 71.9936,
204
+ "mb_per_token": 190.2484893798828,
205
+ "prefetch_issued": 0,
206
+ "prefetch_used": 0,
207
+ "peak_anon_mb": 7767.5,
208
+ "peak_swap_mb": 0.0,
209
+ "arm": "prefetch_off",
210
+ "round": 5
211
+ },
212
+ {
213
+ "rc": 0,
214
+ "strict_ok": true,
215
+ "tps": 6.29,
216
+ "hit_pct": 72.2127,
217
+ "mb_per_token": 201.30157470703125,
218
+ "prefetch_issued": 312,
219
+ "prefetch_used": 10,
220
+ "peak_anon_mb": 7767.5,
221
+ "peak_swap_mb": 0.0,
222
+ "arm": "prefetch_layer_ahead",
223
+ "round": 5
224
+ },
225
+ {
226
+ "rc": 0,
227
+ "strict_ok": true,
228
+ "tps": 6.27,
229
+ "hit_pct": 71.964,
230
+ "mb_per_token": 190.460205078125,
231
+ "prefetch_issued": 0,
232
+ "prefetch_used": 0,
233
+ "peak_anon_mb": 7767.5,
234
+ "peak_swap_mb": 0.0,
235
+ "arm": "prefetch_end_token",
236
+ "round": 5
237
+ }
238
+ ],
239
+ "summary": {
240
+ "prefetch_off": {
241
+ "n": 6,
242
+ "median_tps": 5.945,
243
+ "mean_tps": 6.033333333333333,
244
+ "stdev_tps": 0.43925694834193185,
245
+ "min_tps": 5.55,
246
+ "max_tps": 6.68,
247
+ "all_strict_ok": true
248
+ },
249
+ "prefetch_layer_ahead": {
250
+ "n": 6,
251
+ "median_tps": 6.01,
252
+ "mean_tps": 5.95,
253
+ "stdev_tps": 0.3666060555964672,
254
+ "min_tps": 5.38,
255
+ "max_tps": 6.29,
256
+ "all_strict_ok": true
257
+ },
258
+ "prefetch_end_token": {
259
+ "n": 6,
260
+ "median_tps": 5.715,
261
+ "mean_tps": 5.776666666666666,
262
+ "stdev_tps": 0.327271548819427,
263
+ "min_tps": 5.45,
264
+ "max_tps": 6.27,
265
+ "all_strict_ok": true
266
+ }
267
+ }
268
+ }
bench/results/strict_threads.json ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "label": "threads",
3
+ "rows": [
4
+ {
5
+ "rc": 0,
6
+ "strict_ok": true,
7
+ "tps": 4.7,
8
+ "hit_pct": 70.8227,
9
+ "mb_per_token": 198.34423065185547,
10
+ "prefetch_issued": 0,
11
+ "prefetch_used": 0,
12
+ "peak_anon_mb": 7764.7,
13
+ "peak_swap_mb": 0.0,
14
+ "arm": "threads_4",
15
+ "round": 0
16
+ },
17
+ {
18
+ "rc": 0,
19
+ "strict_ok": true,
20
+ "tps": 5.44,
21
+ "hit_pct": 70.7335,
22
+ "mb_per_token": 198.9898681640625,
23
+ "prefetch_issued": 0,
24
+ "prefetch_used": 0,
25
+ "peak_anon_mb": 7780.2,
26
+ "peak_swap_mb": 0.0,
27
+ "arm": "threads_8",
28
+ "round": 0
29
+ },
30
+ {
31
+ "rc": 0,
32
+ "strict_ok": true,
33
+ "tps": 6.68,
34
+ "hit_pct": 70.7424,
35
+ "mb_per_token": 198.8658905029297,
36
+ "prefetch_issued": 0,
37
+ "prefetch_used": 0,
38
+ "peak_anon_mb": 7788.2,
39
+ "peak_swap_mb": 0.0,
40
+ "arm": "threads_10",
41
+ "round": 0
42
+ },
43
+ {
44
+ "rc": 0,
45
+ "strict_ok": true,
46
+ "tps": 6.61,
47
+ "hit_pct": 70.7451,
48
+ "mb_per_token": 198.9126205444336,
49
+ "prefetch_issued": 0,
50
+ "prefetch_used": 0,
51
+ "peak_anon_mb": 7796.0,
52
+ "peak_swap_mb": 0.0,
53
+ "arm": "threads_12",
54
+ "round": 0
55
+ },
56
+ {
57
+ "rc": 0,
58
+ "strict_ok": true,
59
+ "tps": 6.59,
60
+ "hit_pct": 70.709,
61
+ "mb_per_token": 199.21588897705078,
62
+ "prefetch_issued": 0,
63
+ "prefetch_used": 0,
64
+ "peak_anon_mb": 7811.3,
65
+ "peak_swap_mb": 0.0,
66
+ "arm": "threads_16",
67
+ "round": 0
68
+ },
69
+ {
70
+ "rc": 0,
71
+ "strict_ok": true,
72
+ "tps": 4.41,
73
+ "hit_pct": 75.7866,
74
+ "mb_per_token": 156.92615509033203,
75
+ "prefetch_issued": 0,
76
+ "prefetch_used": 0,
77
+ "peak_anon_mb": 7754.7,
78
+ "peak_swap_mb": 0.0,
79
+ "arm": "threads_4",
80
+ "round": 1
81
+ },
82
+ {
83
+ "rc": 0,
84
+ "strict_ok": true,
85
+ "tps": 6.24,
86
+ "hit_pct": 75.0542,
87
+ "mb_per_token": 162.54615783691406,
88
+ "prefetch_issued": 0,
89
+ "prefetch_used": 0,
90
+ "peak_anon_mb": 7765.0,
91
+ "peak_swap_mb": 0.0,
92
+ "arm": "threads_8",
93
+ "round": 1
94
+ },
95
+ {
96
+ "rc": 0,
97
+ "strict_ok": true,
98
+ "tps": 5.74,
99
+ "hit_pct": 76.2284,
100
+ "mb_per_token": 154.0384292602539,
101
+ "prefetch_issued": 0,
102
+ "prefetch_used": 0,
103
+ "peak_anon_mb": 7768.9,
104
+ "peak_swap_mb": 0.0,
105
+ "arm": "threads_10",
106
+ "round": 1
107
+ },
108
+ {
109
+ "rc": 0,
110
+ "strict_ok": true,
111
+ "tps": 6.68,
112
+ "hit_pct": 75.0768,
113
+ "mb_per_token": 162.36400604248047,
114
+ "prefetch_issued": 0,
115
+ "prefetch_used": 0,
116
+ "peak_anon_mb": 7774.7,
117
+ "peak_swap_mb": 0.0,
118
+ "arm": "threads_12",
119
+ "round": 1
120
+ },
121
+ {
122
+ "rc": 0,
123
+ "strict_ok": true,
124
+ "tps": 5.9,
125
+ "hit_pct": 76.657,
126
+ "mb_per_token": 151.58843994140625,
127
+ "prefetch_issued": 0,
128
+ "prefetch_used": 0,
129
+ "peak_anon_mb": 7783.7,
130
+ "peak_swap_mb": 0.0,
131
+ "arm": "threads_16",
132
+ "round": 1
133
+ },
134
+ {
135
+ "rc": 0,
136
+ "strict_ok": true,
137
+ "tps": 4.83,
138
+ "hit_pct": 71.5758,
139
+ "mb_per_token": 193.1133270263672,
140
+ "prefetch_issued": 0,
141
+ "prefetch_used": 0,
142
+ "peak_anon_mb": 7756.9,
143
+ "peak_swap_mb": 0.0,
144
+ "arm": "threads_4",
145
+ "round": 2
146
+ },
147
+ {
148
+ "rc": 0,
149
+ "strict_ok": true,
150
+ "tps": 6.2,
151
+ "hit_pct": 71.9877,
152
+ "mb_per_token": 190.2914047241211,
153
+ "prefetch_issued": 0,
154
+ "prefetch_used": 0,
155
+ "peak_anon_mb": 7767.8,
156
+ "peak_swap_mb": 0.0,
157
+ "arm": "threads_8",
158
+ "round": 2
159
+ },
160
+ {
161
+ "rc": 0,
162
+ "strict_ok": true,
163
+ "tps": 6.07,
164
+ "hit_pct": 70.074,
165
+ "mb_per_token": 203.43780517578125,
166
+ "prefetch_issued": 0,
167
+ "prefetch_used": 0,
168
+ "peak_anon_mb": 7771.9,
169
+ "peak_swap_mb": 0.0,
170
+ "arm": "threads_10",
171
+ "round": 2
172
+ },
173
+ {
174
+ "rc": 0,
175
+ "strict_ok": true,
176
+ "tps": 5.77,
177
+ "hit_pct": 71.9652,
178
+ "mb_per_token": 186.03134155273438,
179
+ "prefetch_issued": 0,
180
+ "prefetch_used": 0,
181
+ "peak_anon_mb": 7777.3,
182
+ "peak_swap_mb": 0.0,
183
+ "arm": "threads_12",
184
+ "round": 2
185
+ },
186
+ {
187
+ "rc": 0,
188
+ "strict_ok": true,
189
+ "tps": 6.32,
190
+ "hit_pct": 70.0906,
191
+ "mb_per_token": 203.3071517944336,
192
+ "prefetch_issued": 0,
193
+ "prefetch_used": 0,
194
+ "peak_anon_mb": 7787.5,
195
+ "peak_swap_mb": 0.0,
196
+ "arm": "threads_16",
197
+ "round": 2
198
+ }
199
+ ],
200
+ "summary": {
201
+ "threads_4": {
202
+ "n": 3,
203
+ "median_tps": 4.7,
204
+ "mean_tps": 4.6466666666666665,
205
+ "stdev_tps": 0.2150193789716018,
206
+ "min_tps": 4.41,
207
+ "max_tps": 4.83,
208
+ "all_strict_ok": true
209
+ },
210
+ "threads_8": {
211
+ "n": 3,
212
+ "median_tps": 6.2,
213
+ "mean_tps": 5.96,
214
+ "stdev_tps": 0.4507771067833857,
215
+ "min_tps": 5.44,
216
+ "max_tps": 6.24,
217
+ "all_strict_ok": true
218
+ },
219
+ "threads_10": {
220
+ "n": 3,
221
+ "median_tps": 6.07,
222
+ "mean_tps": 6.163333333333333,
223
+ "stdev_tps": 0.4768997099321125,
224
+ "min_tps": 5.74,
225
+ "max_tps": 6.68,
226
+ "all_strict_ok": true
227
+ },
228
+ "threads_12": {
229
+ "n": 3,
230
+ "median_tps": 6.61,
231
+ "mean_tps": 6.3533333333333335,
232
+ "stdev_tps": 0.5063924696649167,
233
+ "min_tps": 5.77,
234
+ "max_tps": 6.68,
235
+ "all_strict_ok": true
236
+ },
237
+ "threads_16": {
238
+ "n": 3,
239
+ "median_tps": 6.32,
240
+ "mean_tps": 6.2700000000000005,
241
+ "stdev_tps": 0.34770677301427394,
242
+ "min_tps": 5.9,
243
+ "max_tps": 6.59,
244
+ "all_strict_ok": true
245
+ }
246
+ }
247
+ }
bench/results/summary.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "budget_curve": "| Budget | Arena (GiB) | 槽位 | 命中率 | SSD MB/token | Decode tok/s | 峰值匿名 RSS | VmSwap |\n| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n| 2 GiB | 0.57 | 332 | 22.72% | 526.2 | 4.32 | 1842 | 0.000 |\n| 3 GiB | 1.53 | 895 | 50.19% | 340.0 | 5.16 | 2828 | 0.000 |\n| 4 GiB | 2.50 | 1459 | 59.81% | 275.8 | 5.48 | 3817 | 0.000 |\n| 5 GiB | 3.46 | 2023 | 64.88% | 240.8 | 5.82 | 4805 | 0.000 |\n| 6 GiB | 4.43 | 2587 | 67.52% | 222.2 | 5.38 | 5793 | 0.000 |\n| 7 GiB | 5.39 | 3150 | 68.60% | 214.0 | 5.10 | 6779 | 0.000 |\n| 8 GiB | 6.35 | 3714 | 70.74% | 198.9 | 5.45 | 7766 | 0.000 |",
3
+ "ram_sweep": "| Budget | Arena (GiB) | 槽位 | 命中率 | SSD MB/token | 平均 tok/s | 單輪標準差 | 峰值 RSS | VmSwap |\n| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n| 4 GiB | 2.74 | 1600 | 61.50% | 275.4 | 5.43 ± 0.37 | 3963 | 0.000 |\n| 8 GiB | 6.36 | 3715 | 71.98% | 199.1 | 6.33 ± 0.33 | 7669 | 0.000 |\n| 16 GiB | 13.59 | 7943 | 77.54% | 157.9 | 6.28 ± 0.41 | 15076 | 0.000 |\n| 64 GiB | 59.89 | 35003 | 77.54% | 157.6 | 6.32 ± 0.51 | 23969 | 0.000 |",
4
+ "ram_sweep_tests": "| 比較 | 只改變了 | 平均 tok/s | Welch t | p | 判定 |\n| --- | --- | ---: | ---: | ---: | --- |\n| 4 GiB 比 8 GiB | RAM 預算 | 5.43 → 6.33 (-14%) | -5.44 | 0.000 | 顯著 |\n| 16 GiB 比 8 GiB | RAM 預算 | 6.28 → 6.33 (-1%) | -0.30 | 0.765 | 無法分辨 |\n| 64 GiB 比 8 GiB | RAM 預算 | 6.32 → 6.33 (-0%) | -0.05 | 0.961 | 無法分辨 |",
5
+ "negatives": {
6
+ "threads": {
7
+ "threads_4": {
8
+ "n": 3,
9
+ "median_tps": 4.7,
10
+ "mean_tps": 4.6466666666666665,
11
+ "stdev_tps": 0.2150193789716018,
12
+ "min_tps": 4.41,
13
+ "max_tps": 4.83,
14
+ "all_strict_ok": true
15
+ },
16
+ "threads_8": {
17
+ "n": 3,
18
+ "median_tps": 6.2,
19
+ "mean_tps": 5.96,
20
+ "stdev_tps": 0.4507771067833857,
21
+ "min_tps": 5.44,
22
+ "max_tps": 6.24,
23
+ "all_strict_ok": true
24
+ },
25
+ "threads_10": {
26
+ "n": 3,
27
+ "median_tps": 6.07,
28
+ "mean_tps": 6.163333333333333,
29
+ "stdev_tps": 0.4768997099321125,
30
+ "min_tps": 5.74,
31
+ "max_tps": 6.68,
32
+ "all_strict_ok": true
33
+ },
34
+ "threads_12": {
35
+ "n": 3,
36
+ "median_tps": 6.61,
37
+ "mean_tps": 6.3533333333333335,
38
+ "stdev_tps": 0.5063924696649167,
39
+ "min_tps": 5.77,
40
+ "max_tps": 6.68,
41
+ "all_strict_ok": true
42
+ },
43
+ "threads_16": {
44
+ "n": 3,
45
+ "median_tps": 6.32,
46
+ "mean_tps": 6.2700000000000005,
47
+ "stdev_tps": 0.34770677301427394,
48
+ "min_tps": 5.9,
49
+ "max_tps": 6.59,
50
+ "all_strict_ok": true
51
+ }
52
+ },
53
+ "prefetch": {
54
+ "prefetch_off": {
55
+ "n": 6,
56
+ "median_tps": 5.945,
57
+ "mean_tps": 6.033333333333333,
58
+ "stdev_tps": 0.43925694834193185,
59
+ "min_tps": 5.55,
60
+ "max_tps": 6.68,
61
+ "all_strict_ok": true
62
+ },
63
+ "prefetch_layer_ahead": {
64
+ "n": 6,
65
+ "median_tps": 6.01,
66
+ "mean_tps": 5.95,
67
+ "stdev_tps": 0.3666060555964672,
68
+ "min_tps": 5.38,
69
+ "max_tps": 6.29,
70
+ "all_strict_ok": true
71
+ },
72
+ "prefetch_end_token": {
73
+ "n": 6,
74
+ "median_tps": 5.715,
75
+ "mean_tps": 5.776666666666666,
76
+ "stdev_tps": 0.327271548819427,
77
+ "min_tps": 5.45,
78
+ "max_tps": 6.27,
79
+ "all_strict_ok": true
80
+ }
81
+ }
82
+ }
83
+ }
bench/run_budget_curve.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Pager budget curve: how hit rate and throughput move with arena size.
3
+
4
+ The physical ceiling stays at --strict-mb (8 GiB) for every point; only the
5
+ pager's RAM budget changes, so the arena shrinks and the cache holds fewer
6
+ experts. This isolates "does more arena still buy anything" from "does the
7
+ machine have more RAM".
8
+
9
+ Output: bench/results/strict_budgets.json
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ HERE = Path(__file__).resolve().parent
23
+ REPO = HERE.parent
24
+
25
+ PROMPTS = [
26
+ "Explain in one sentence why SSDs are slow.",
27
+ "請用一句話介紹你自己。",
28
+ "Write a short paragraph about SSD caching.",
29
+ ]
30
+
31
+
32
+ def run_one(strict8g: Path, binary: str, model: str, budget_mb: int, strict_mb: int,
33
+ prompt: str, n_predict: int, ctx: int, threads: int, ubatch: int,
34
+ record: Path, reserve_mb: int, extra_env: dict | None = None) -> dict:
35
+ cmd = [
36
+ sys.executable, str(strict8g),
37
+ "--limit-mb", str(strict_mb),
38
+ "--record", str(record),
39
+ "--",
40
+ binary, "-m", model, "-p", prompt, "-n", str(n_predict),
41
+ "-c", str(ctx), "-t", str(threads), "-b", str(ubatch),
42
+ "--temp", "0", "--no-chat", "--no-interactive",
43
+ "--ram-budget-mb", str(budget_mb),
44
+ # hit rate is only printed when a stats file is requested
45
+ "--stats-file", str(record.with_suffix(".stats")),
46
+ ]
47
+ env = dict(os.environ, SDQ_RESERVE_MB=str(reserve_mb))
48
+ if extra_env:
49
+ env.update(extra_env)
50
+ p = subprocess.run(cmd, capture_output=True, text=True, env=env)
51
+ out = p.stdout + p.stderr
52
+
53
+ def grab(pat, cast=float):
54
+ m = re.search(pat, out)
55
+ return cast(m.group(1)) if m else None
56
+
57
+ rec = {}
58
+ if record.exists():
59
+ try:
60
+ rec = json.loads(record.read_text())
61
+ except Exception:
62
+ rec = {}
63
+ # the pager's own counters (hit rate, SSD bytes, prefetch) go to --stats-file
64
+ stats = {}
65
+ sp = record.with_suffix(".stats")
66
+ if sp.exists():
67
+ try:
68
+ for line in sp.read_text().splitlines():
69
+ line = line.strip()
70
+ if line.startswith("{"):
71
+ stats = json.loads(line)
72
+ except Exception:
73
+ stats = {}
74
+
75
+ m = re.search(r"= ([\d.]+) tok/s", out)
76
+ return {
77
+ "budget_mb": budget_mb,
78
+ "reserve_mb": reserve_mb,
79
+ "rc": p.returncode,
80
+ "strict_ok": bool(rec.get("strict_ok", False)),
81
+ "decode_tps": float(m.group(1)) if m else None,
82
+ "hit_pct": stats.get("hit_rate"),
83
+ "mb_per_token": (stats["ssd_bytes_per_token"] / 1048576.0) if stats.get("ssd_bytes_per_token") else None,
84
+ "slots": int(grab(r"slots=(\d+)", str) or 0),
85
+ "arena_gib": grab(r"arena=([\d.]+) GiB"),
86
+ "slot_split": (re.search(r"槽位分兩種:(.+)", out).group(1).strip()
87
+ if re.search(r"槽位分兩種:(.+)", out) else None),
88
+ "peak_anon_mb": rec.get("peak_anon_mb"),
89
+ "peak_swap_mb": rec.get("peak_swap_mb"),
90
+ }
91
+
92
+
93
+ def main() -> None:
94
+ ap = argparse.ArgumentParser()
95
+ ap.add_argument("--model", required=True)
96
+ ap.add_argument("--bin", required=True)
97
+ ap.add_argument("--budgets", type=int, nargs="+",
98
+ default=[2048, 3072, 4096, 5120, 6144, 7168, 8192])
99
+ ap.add_argument("--strict-mb", type=int, default=8192)
100
+ ap.add_argument("--reserve-mb", type=int, default=512)
101
+ ap.add_argument("--rounds", type=int, default=5)
102
+ ap.add_argument("--ctx", type=int, default=4096)
103
+ ap.add_argument("--threads", type=int, default=8)
104
+ ap.add_argument("--ubatch", type=int, default=128)
105
+ ap.add_argument("--n-predict", type=int, default=48)
106
+ ap.add_argument("--out", default=str(HERE / "results" / "strict_budgets.json"))
107
+ a = ap.parse_args()
108
+
109
+ strict8g = REPO / "tools" / "strict8g.py"
110
+ (HERE / "results").mkdir(parents=True, exist_ok=True)
111
+ rows = []
112
+ for r in range(a.rounds):
113
+ for b in a.budgets:
114
+ prompt = PROMPTS[r % len(PROMPTS)]
115
+ rec = HERE / "results" / f".budget-{b}-{r}.json"
116
+ row = run_one(strict8g, a.bin, a.model, b, a.strict_mb, prompt,
117
+ a.n_predict, a.ctx, a.threads, a.ubatch, rec,
118
+ a.reserve_mb)
119
+ row["round"] = r
120
+ rows.append(row)
121
+ ok = "ok" if row["strict_ok"] else "FAIL"
122
+ print(f"[{r + 1}/{a.rounds}] budget={b:>5} tps={row['decode_tps']} "
123
+ f"hit={row['hit_pct']}% MB/tok={row['mb_per_token']} "
124
+ f"slots={row['slots']} anon={row['peak_anon_mb']} {ok}", flush=True)
125
+ rec.unlink(missing_ok=True)
126
+
127
+ out = Path(a.out)
128
+ out.write_text(json.dumps(rows, indent=2))
129
+ print(f"-> {out}")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
bench/run_n9.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Throughput at four RAM magnitudes with Welch t-tests.
3
+
4
+ N=3 runs cannot rank settings: the same configuration differs by tens of percent
5
+ between three rounds on a shared host. This script runs each budget 9 times,
6
+ interleaved so machine drift hits every budget equally, and reports Welch
7
+ t-tests for the comparisons that drive the deployment decision.
8
+
9
+ Output: bench/results/ram_sweep_n9.json
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import math
17
+ import statistics
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ HERE = Path(__file__).resolve().parent.parent / "bench"
23
+ REPO = HERE.parent
24
+ sys.path.insert(0, str(HERE))
25
+
26
+ from run_ramsweep import run_one # noqa: E402
27
+
28
+ PROMPTS = [
29
+ "Explain in one sentence why SSDs are slow.",
30
+ "請用一句話介紹你自己。",
31
+ "Write a short paragraph about SSD caching.",
32
+ ]
33
+
34
+
35
+ def welch(a: list[float], b: list[float]) -> tuple[float, float]:
36
+ """Returns (t, two-sided p) for the difference of means, Welch's t-test."""
37
+ na, nb = len(a), len(b)
38
+ ma, mb = statistics.mean(a), statistics.mean(b)
39
+ va = statistics.variance(a) if na > 1 else 0.0
40
+ vb = statistics.variance(b) if nb > 1 else 0.0
41
+ se2 = va / na + vb / nb
42
+ if se2 == 0:
43
+ return (0.0, 1.0)
44
+ t = (ma - mb) / math.sqrt(se2)
45
+ df_num = se2 ** 2
46
+ df_den = (va / na) ** 2 / (na - 1) + (vb / nb) ** 2 / (nb - 1)
47
+ df = df_num / df_den if df_den else float(na + nb - 2)
48
+ # two-sided p from the t distribution via the incomplete beta function
49
+ x = df / (df + t * t)
50
+ p = betainc(df / 2.0, 0.5, x)
51
+ return (t, p)
52
+
53
+
54
+ def betainc(a: float, b: float, x: float) -> float:
55
+ """Regularized incomplete beta I_x(a, b)."""
56
+ if x <= 0:
57
+ return 0.0
58
+ if x >= 1:
59
+ return 1.0
60
+ lbeta = math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b)
61
+ front = math.exp(math.log(x) * a + math.log(1 - x) * b - lbeta)
62
+ if x < (a + 1) / (a + b + 2):
63
+ return front * betacf(a, b, x) / a
64
+ return 1.0 - math.exp(math.log(1 - x) * b + math.log(x) * a - lbeta) * betacf(b, a, 1 - x) / b
65
+
66
+
67
+ def betacf(a: float, b: float, x: float) -> float:
68
+ tiny = 1e-300
69
+ qab, qap, qam = a + b, a + 1.0, a - 1.0
70
+ c, d = 1.0, 1.0 - qab * x / qap
71
+ if abs(d) < tiny:
72
+ d = tiny
73
+ d = 1.0 / d
74
+ h = d
75
+ for m in range(1, 300):
76
+ m2 = 2 * m
77
+ aa = m * (b - m) * x / ((qam + m2) * (a + m2))
78
+ d = 1.0 + aa * d
79
+ if abs(d) < tiny:
80
+ d = tiny
81
+ c = 1.0 + aa / c
82
+ if abs(c) < tiny:
83
+ c = tiny
84
+ d = 1.0 / d
85
+ h *= d * c
86
+ aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
87
+ d = 1.0 + aa * d
88
+ if abs(d) < tiny:
89
+ d = tiny
90
+ c = 1.0 + aa / c
91
+ if abs(c) < tiny:
92
+ c = tiny
93
+ d = 1.0 / d
94
+ de = d * c
95
+ h *= de
96
+ if abs(de - 1.0) < 3e-16:
97
+ break
98
+ return h
99
+
100
+
101
+ def main() -> None:
102
+ ap = argparse.ArgumentParser()
103
+ ap.add_argument("--model", required=True)
104
+ ap.add_argument("--bin", required=True)
105
+ ap.add_argument("--budgets", type=int, nargs="+",
106
+ default=[4096, 8192, 16384, 65536])
107
+ ap.add_argument("--rounds", type=int, default=9)
108
+ ap.add_argument("--ctx", type=int, default=4096)
109
+ ap.add_argument("--threads", type=int, default=8)
110
+ ap.add_argument("--ubatch", type=int, default=128)
111
+ ap.add_argument("--n-predict", type=int, default=48)
112
+ a = ap.parse_args()
113
+
114
+ strict8g = REPO / "tools" / "strict8g.py"
115
+ (HERE / "results").mkdir(parents=True, exist_ok=True)
116
+ rows = []
117
+ for r in range(a.rounds):
118
+ for b in a.budgets:
119
+ prompt = PROMPTS[r % len(PROMPTS)]
120
+ rec = HERE / "results" / f".n9-{b}-{r}.json"
121
+ row = run_one(strict8g, a.bin, a.model, b, prompt, a.n_predict,
122
+ a.ctx, a.threads, a.ubatch, rec)
123
+ row["round"] = r
124
+ rows.append(row)
125
+ out_live = HERE / "results" / "ram_sweep_n9.json"
126
+ out_live.write_text(json.dumps({"partial": True, "rows": rows}, indent=2))
127
+ print(f"[{r + 1}/{a.rounds}] {b:>6} MiB tps={row['decode_tps']} "
128
+ f"slots={row['slots']} anon={row['peak_anon_mib']} "
129
+ f"ok={row['strict_ok']}", flush=True)
130
+ rec.unlink(missing_ok=True)
131
+ Path(str(rec)).with_suffix(".stats").unlink(missing_ok=True)
132
+
133
+ per = {}
134
+ for b in a.budgets:
135
+ sel = [r for r in rows if r["budget_mb"] == b and r["decode_tps"]]
136
+ tps = [r["decode_tps"] for r in sel]
137
+ per[b] = {
138
+ "n": len(tps),
139
+ "mean_tps": statistics.mean(tps),
140
+ "stdev_tps": statistics.stdev(tps) if len(tps) > 1 else None,
141
+ "median_tps": statistics.median(tps),
142
+ "min_tps": min(tps), "max_tps": max(tps),
143
+ "slots": sel[0]["slots"], "arena_gib": sel[0]["arena_gib"],
144
+ "hit_pct": statistics.median([r["hit_pct"] for r in sel if r["hit_pct"]]) if sel[0]["hit_pct"] else None,
145
+ "mb_per_token": sel[0]["mb_per_token"],
146
+ "peak_anon_mb": max((r["peak_anon_mib"] for r in sel if r["peak_anon_mib"] is not None), default=None),
147
+ "peak_swap_mb": max((r["peak_swap_mb"] for r in sel if r["peak_swap_mb"] is not None), default=None),
148
+ "all_strict_ok": all(r["strict_ok"] for r in sel),
149
+ }
150
+
151
+ tests = {}
152
+ base = a.budgets[1] if len(a.budgets) > 1 else a.budgets[0]
153
+ for b in a.budgets:
154
+ if b == base:
155
+ continue
156
+ t, p = welch([r["decode_tps"] for r in rows if r["budget_mb"] == b and r["decode_tps"]],
157
+ [r["decode_tps"] for r in rows if r["budget_mb"] == base and r["decode_tps"]])
158
+ tests[f"{b}_vs_{base}"] = {"t": t, "p": p}
159
+
160
+ out = HERE / "results" / "ram_sweep_n9.json"
161
+ out.write_text(json.dumps({"per_budget": per, "welch_vs_%d" % base: tests,
162
+ "rows": rows}, indent=2))
163
+ print(json.dumps({"per_budget": per, "welch": tests}, indent=2))
164
+ print(f"-> {out}")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
bench/run_negative.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Negative results: settings that looked promising and did not pay off.
3
+
4
+ Two knobs are measured end to end under the same 8 GiB ceiling as the headline:
5
+
6
+ prefetch
7
+ Paging the next layer's experts while the current layer computes. The pager
8
+ can predict them from the previous token's routing, but only a small share
9
+ of the predicted experts are actually used, so the extra reads are mostly
10
+ waste. ``SDQ_PREFETCH_LAYER_AHEAD=1`` turns it on.
11
+
12
+ threads
13
+ Only n_expert_used = 8 experts per token can run in parallel inside one
14
+ layer, so more threads mostly contend for the same rows.
15
+
16
+ Output: bench/results/strict_prefetch.json and bench/results/strict_threads.json
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import statistics
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ HERE = Path(__file__).resolve().parent
31
+ REPO = HERE.parent
32
+
33
+ PROMPTS = [
34
+ "Explain in one sentence why SSDs are slow.",
35
+ "請用一句話介紹你自己。",
36
+ "Write a short paragraph about SSD caching.",
37
+ ]
38
+
39
+
40
+ def run(strict8g: Path, binary: str, model: str, prompt: str, threads: int,
41
+ n_predict: int, ctx: int, ubatch: int, budget_mb: int,
42
+ record: Path, reserve_mb: int, env_extra: dict) -> dict:
43
+ cmd = [
44
+ sys.executable, str(strict8g),
45
+ "--limit-mb", str(budget_mb), "--record", str(record), "--",
46
+ binary, "-m", model, "-p", prompt, "-n", str(n_predict),
47
+ "-c", str(ctx), "-t", str(threads), "-b", str(ubatch),
48
+ "--temp", "0", "--no-chat", "--no-interactive",
49
+ "--ram-budget-mb", str(budget_mb),
50
+ "--stats-file", str(record.with_suffix(".stats")),
51
+ ]
52
+ env = dict(os.environ, SDQ_RESERVE_MB=str(reserve_mb))
53
+ env.update(env_extra)
54
+ p = subprocess.run(cmd, capture_output=True, text=True, env=env)
55
+ out = p.stdout + p.stderr
56
+
57
+ rec, stats = {}, {}
58
+ if record.exists():
59
+ try:
60
+ rec = json.loads(record.read_text())
61
+ except Exception:
62
+ pass
63
+ sp = record.with_suffix(".stats")
64
+ if sp.exists():
65
+ for line in sp.read_text().splitlines():
66
+ if line.strip().startswith("{"):
67
+ stats = json.loads(line)
68
+
69
+ m = re.search(r"= ([\d.]+) tok/s", out)
70
+ return {
71
+ "rc": p.returncode,
72
+ "strict_ok": bool(rec.get("strict_ok", False)),
73
+ "tps": float(m.group(1)) if m else None,
74
+ "hit_pct": stats.get("hit_rate"),
75
+ "mb_per_token": (stats["ssd_bytes_per_token"] / 1048576.0) if stats.get("ssd_bytes_per_token") else None,
76
+ "prefetch_issued": stats.get("prefetch_issued"),
77
+ "prefetch_used": stats.get("prefetch_used"),
78
+ "peak_anon_mb": rec.get("peak_anon_mb"),
79
+ "peak_swap_mb": rec.get("peak_swap_mb"),
80
+ }
81
+
82
+
83
+ def sweep(arms: dict, a, label: str, out_name: str) -> None:
84
+ strict8g = REPO / "tools" / "strict8g.py"
85
+ (HERE / "results").mkdir(parents=True, exist_ok=True)
86
+ rows = []
87
+ for r in range(a.rounds):
88
+ for name, cfg in arms.items():
89
+ prompt = PROMPTS[r % len(PROMPTS)]
90
+ rec = HERE / "results" / f".{out_name}-{name}-{r}.json"
91
+ row = run(strict8g, a.bin, a.model, prompt, cfg["threads"],
92
+ a.n_predict, a.ctx, a.ubatch, a.budget_mb, rec,
93
+ cfg["reserve_mb"], cfg.get("env", {}))
94
+ row["arm"] = name
95
+ row["round"] = r
96
+ rows.append(row)
97
+ print(f"[{r + 1}/{a.rounds}] {name:<14} tps={row['tps']} "
98
+ f"hit={row['hit_pct']} MB/tok={row['mb_per_token']} "
99
+ f"ok={row['strict_ok']}", flush=True)
100
+ rec.unlink(missing_ok=True)
101
+ Path(str(rec) + ".stats").with_suffix(".stats").unlink(missing_ok=True)
102
+
103
+ summary = {}
104
+ for name in arms:
105
+ tps = [r["tps"] for r in rows if r["arm"] == name and r["tps"]]
106
+ summary[name] = {
107
+ "n": len(tps),
108
+ "median_tps": statistics.median(tps) if tps else None,
109
+ "mean_tps": statistics.mean(tps) if tps else None,
110
+ "stdev_tps": statistics.stdev(tps) if len(tps) > 1 else None,
111
+ "min_tps": min(tps) if tps else None,
112
+ "max_tps": max(tps) if tps else None,
113
+ "all_strict_ok": all(r["strict_ok"] for r in rows if r["arm"] == name),
114
+ }
115
+ out = HERE / "results" / out_name
116
+ out.write_text(json.dumps({"label": label, "rows": rows, "summary": summary},
117
+ indent=2, ensure_ascii=False))
118
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
119
+ print(f"-> {out}")
120
+
121
+
122
+ def main() -> None:
123
+ ap = argparse.ArgumentParser()
124
+ ap.add_argument("--model", required=True)
125
+ ap.add_argument("--bin", required=True)
126
+ ap.add_argument("--which", choices=["prefetch", "threads"], required=True)
127
+ ap.add_argument("--rounds", type=int, default=6)
128
+ ap.add_argument("--budget-mb", type=int, default=8192)
129
+ ap.add_argument("--reserve-mb", type=int, default=512)
130
+ ap.add_argument("--ctx", type=int, default=4096)
131
+ ap.add_argument("--ubatch", type=int, default=128)
132
+ ap.add_argument("--n-predict", type=int, default=48)
133
+ a = ap.parse_args()
134
+
135
+ if a.which == "prefetch":
136
+ arms = {
137
+ "prefetch_off": {"threads": 8,
138
+ "reserve_mb": a.reserve_mb, "env": {}},
139
+ "prefetch_layer_ahead": {"threads": 8, "reserve_mb": a.reserve_mb,
140
+ "env": {"SDQ_PREFETCH_LAYER_AHEAD": "1"}},
141
+ "prefetch_end_token": {"threads": 8, "reserve_mb": a.reserve_mb,
142
+ "env": {"SDQ_PREFETCH": "1",
143
+ "SDQ_PREFETCH_LAYER_AHEAD": "0"}},
144
+ }
145
+ sweep(arms, a, "prefetch", "strict_prefetch.json")
146
+ else:
147
+ arms = {
148
+ f"threads_{t}": {"threads": t, "reserve_mb": a.reserve_mb, "env": {}}
149
+ for t in (4, 8, 10, 12, 16)
150
+ }
151
+ sweep(arms, a, "threads", "strict_threads.json")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
bench/run_ramsweep.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """RAM expansion sweep: run the paged model at several physical RAM budgets.
3
+
4
+ Every run is governed by tools/strict8g.py, so the ceiling is enforced while the
5
+ process runs rather than inferred afterwards. The curve deliberately runs without
6
+ mlock (see docs/STRICT_8GIB.md): mlock() forces the whole mapping resident, which
7
+ would report the configured arena size as RSS instead of real usage.
8
+
9
+ Output: bench/results/ram_sweep.json
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ HERE = Path(__file__).resolve().parent
23
+ REPO = HERE.parent
24
+
25
+ PROMPTS = [
26
+ "Explain in one sentence why SSDs are slow.",
27
+ "請用一句話介紹你自己。",
28
+ "Write a short paragraph about SSD caching.",
29
+ ]
30
+
31
+
32
+ def run_one(strict8g: Path, binary: str, model: str, budget_mb: int,
33
+ prompt: str, n_predict: int, ctx: int, threads: int, ubatch: int,
34
+ record: Path) -> dict:
35
+ cmd = [
36
+ sys.executable, str(strict8g),
37
+ "--limit-mb", str(budget_mb),
38
+ "--record", str(record),
39
+ "--no-lock",
40
+ "--",
41
+ binary, "-m", model, "-p", prompt, "-n", str(n_predict),
42
+ "-c", str(ctx), "-t", str(threads), "-b", str(ubatch),
43
+ "--temp", "0", "--no-chat", "--no-interactive",
44
+ "--ram-budget-mb", str(budget_mb),
45
+ "--stats-file", str(record.with_suffix(".stats")),
46
+ ]
47
+ # Reserve scales with the budget. budget/16 reproduces the calibration
48
+ # measured at 8 GiB (512 MiB, 446 MiB of headroom) and keeps a comparable
49
+ # margin at 4 GiB, where a fixed 512 MiB reserve overruns the ceiling.
50
+ reserve = max(128, min(1024, budget_mb // 16))
51
+ env = dict(os.environ, SDQ_RESERVE_MB=str(reserve))
52
+ p = subprocess.run(cmd, capture_output=True, text=True, env=env)
53
+ out = p.stdout + p.stderr
54
+
55
+ def grab(pat, cast=float):
56
+ m = re.search(pat, out)
57
+ return cast(m.group(1)) if m else None
58
+
59
+ rec = {}
60
+ if record.exists():
61
+ try:
62
+ rec = json.loads(record.read_text())
63
+ except Exception:
64
+ rec = {}
65
+ # the pager's own counters (hit rate, SSD bytes, prefetch) go to --stats-file
66
+ stats = {}
67
+ sp = record.with_suffix(".stats")
68
+ if sp.exists():
69
+ try:
70
+ for line in sp.read_text().splitlines():
71
+ line = line.strip()
72
+ if line.startswith("{"):
73
+ stats = json.loads(line)
74
+ except Exception:
75
+ stats = {}
76
+
77
+ return {
78
+ "budget_mb": budget_mb,
79
+ "reserve_mb": max(128, min(1024, budget_mb // 16)),
80
+ "rc": p.returncode,
81
+ "strict_ok": bool(rec.get("strict_ok", False)),
82
+ "decode_tps": grab(r"decode: (\d+) tok / ([\d.]+)s = ([\d.]+) tok/s", str) and
83
+ float(re.search(r"= ([\d.]+) tok/s", out).group(1)),
84
+ "hit_pct": stats.get("hit_rate"),
85
+ "mb_per_token": (stats["ssd_bytes_per_token"] / 1048576.0) if stats.get("ssd_bytes_per_token") else None,
86
+ "slots": int(grab(r"slots=(\d+)", str) or 0),
87
+ "arena_gib": grab(r"arena=([\d.]+) GiB"),
88
+ "peak_anon_mib": rec.get("peak_anon_mb") or grab(r"anon=([\d.]+) MiB"),
89
+ "peak_swap_mb": rec.get("peak_swap_mb"),
90
+ }
91
+
92
+
93
+ def main() -> None:
94
+ ap = argparse.ArgumentParser()
95
+ ap.add_argument("--model", required=True)
96
+ ap.add_argument("--bin", required=True)
97
+ ap.add_argument("--budgets", type=int, nargs="+",
98
+ default=[4096, 8192, 16384, 32768, 65536])
99
+ ap.add_argument("--rounds", type=int, default=3)
100
+ ap.add_argument("--ctx", type=int, default=4096)
101
+ ap.add_argument("--threads", type=int, default=8)
102
+ ap.add_argument("--ubatch", type=int, default=128)
103
+ ap.add_argument("--n-predict", type=int, default=48)
104
+ ap.add_argument("--out", default=str(HERE / "results" / "ram_sweep.json"))
105
+ a = ap.parse_args()
106
+
107
+ strict8g = REPO / "tools" / "strict8g.py"
108
+ rows = []
109
+ # Interleave budgets round-robin so drift in machine load hits every budget
110
+ # equally instead of penalising whichever one ran last.
111
+ for r in range(a.rounds):
112
+ for b in a.budgets:
113
+ prompt = PROMPTS[r % len(PROMPTS)]
114
+ rec = HERE / "results" / f".ramsweep-{b}-{r}.json"
115
+ rec.parent.mkdir(parents=True, exist_ok=True)
116
+ row = run_one(strict8g, a.bin, a.model, b, prompt, a.n_predict,
117
+ a.ctx, a.threads, a.ubatch, rec)
118
+ row["round"] = r
119
+ row["prompt"] = prompt
120
+ rows.append(row)
121
+ ok = "ok" if row["strict_ok"] else "FAIL"
122
+ print(f"[{r + 1}/{a.rounds}] budget={b:>6} MiB tps={row['decode_tps']} "
123
+ f"slots={row['slots']} arena={row['arena_gib']} "
124
+ f"anon={row['peak_anon_mib']} swap={row['peak_swap_mb']} {ok}",
125
+ flush=True)
126
+ rec.unlink(missing_ok=True)
127
+
128
+ out = Path(a.out)
129
+ out.parent.mkdir(parents=True, exist_ok=True)
130
+ out.write_text(json.dumps(rows, indent=2))
131
+ print(f"-> {out}")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
bench/summarize.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render the raw bench JSON into the markdown tables used by README.md and docs/."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import statistics
8
+ from pathlib import Path
9
+
10
+ HERE = Path(__file__).resolve().parent
11
+ RES = HERE / "results"
12
+
13
+
14
+ def med(rows, key):
15
+ vals = [r[key] for r in rows if r.get(key) is not None]
16
+ return statistics.median(vals) if vals else None
17
+
18
+
19
+ def fmt(v, nd=2):
20
+ return "—" if v is None else f"{v:.{nd}f}"
21
+
22
+
23
+ def budget_curve():
24
+ f = RES / "strict_budgets.json"
25
+ if not f.exists():
26
+ return None
27
+ rows = json.loads(f.read_text())
28
+ budgets = sorted({r["budget_mb"] for r in rows})
29
+ lines = ["| Budget | Arena (GiB) | 槽位 | 命中率 | SSD MB/token | Decode tok/s | 峰值匿名 RSS | VmSwap |",
30
+ "| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"]
31
+ for b in budgets:
32
+ sel = [r for r in rows if r["budget_mb"] == b]
33
+ ok = [r for r in sel if r["strict_ok"]]
34
+ lines.append(
35
+ f"| {b // 1024} GiB | {fmt(med(sel, 'arena_gib'))} | "
36
+ f"{int(med(sel, 'slots') or 0)} | {fmt(med(sel, 'hit_pct'))}% | "
37
+ f"{fmt(med(sel, 'mb_per_token'), 1)} | {fmt(med(sel, 'decode_tps'))} | "
38
+ f"{fmt(med(sel, 'peak_anon_mb'), 0)} | {fmt(med(sel, 'peak_swap_mb'), 3)} |")
39
+ return "\n".join(lines)
40
+
41
+
42
+ def ram_sweep():
43
+ f = RES / "ram_sweep_n9.json"
44
+ src = f if f.exists() else (RES / "ram_sweep.json")
45
+ if not src.exists():
46
+ return None, None
47
+ data = json.loads(src.read_text())
48
+ if "per_budget" in data:
49
+ per = data["per_budget"]
50
+ tests = data.get("welch_vs_8192", {})
51
+ n = max(v["n"] for v in per.values())
52
+ lines = [f"| Budget | Arena (GiB) | 槽位 | 命中率 | SSD MB/token | 平均 tok/s | 單輪標準差 | 峰值 RSS | VmSwap |",
53
+ "| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"]
54
+ for b, v in sorted(per.items(), key=lambda kv: int(kv[0])):
55
+ lines.append(
56
+ f"| {int(b) // 1024} GiB | {fmt(v['arena_gib'])} | {int(v['slots'])} | "
57
+ f"{fmt(v['hit_pct'])}% | {fmt(v['mb_per_token'], 1)} | "
58
+ f"{fmt(v['mean_tps'])} ± {fmt(v['stdev_tps'])} | "
59
+ f"{fmt(v['peak_anon_mb'], 0)} | {fmt(v['peak_swap_mb'], 3)} |")
60
+ tlines = ["| 比較 | 只改變了 | 平均 tok/s | Welch t | p | 判定 |", "| --- | --- | ---: | ---: | ---: | --- |"]
61
+ base = sorted(per, key=lambda k: int(k))[1] if len(per) > 1 else None
62
+ for name, t in tests.items():
63
+ other, _, b = name.partition("_vs_")
64
+ o, bb = per[other], per[b]
65
+ verdict = "顯著" if t["p"] < 0.05 else "無法分辨"
66
+ tlines.append(
67
+ f"| {int(other) // 1024} GiB 比 {int(b) // 1024} GiB | RAM 預算 | "
68
+ f"{o['mean_tps']:.2f} → {bb['mean_tps']:.2f} "
69
+ f"({(o['mean_tps'] / bb['mean_tps'] - 1) * 100:+.0f}%) | "
70
+ f"{t['t']:+.2f} | {t['p']:.3f} | {verdict} |")
71
+ return "\n".join(lines), "\n".join(tlines)
72
+ rows = data["rows"] if isinstance(data, dict) else data
73
+ budgets = sorted({r["budget_mb"] for r in rows})
74
+ lines = ["| Budget | Arena (GiB) | 槽位 | 命中率 | SSD MB/token | Decode tok/s | 峰值 RSS | VmSwap |",
75
+ "| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"]
76
+ for b in budgets:
77
+ sel = [r for r in rows if r["budget_mb"] == b]
78
+ lines.append(
79
+ f"| {b // 1024} GiB | {fmt(med(sel, 'arena_gib'))} | {int(med(sel, 'slots') or 0)} | "
80
+ f"{fmt(med(sel, 'hit_pct'))}% | {fmt(med(sel, 'mb_per_token'), 1)} | "
81
+ f"{fmt(med(sel, 'decode_tps'))} | {fmt(med(sel, 'peak_anon_mib'), 0)} | "
82
+ f"{fmt(med(sel, 'peak_swap_mb'), 3)} |")
83
+ return "\n".join(lines), None
84
+
85
+
86
+ def headline():
87
+ for name in ("strict_headline.json",):
88
+ f = RES / name
89
+ if not f.exists():
90
+ return None
91
+ return json.loads(f.read_text())
92
+
93
+
94
+ def negatives():
95
+ out = {}
96
+ f = RES / "strict_threads.json"
97
+ if f.exists():
98
+ out["threads"] = json.loads(f.read_text())["summary"]
99
+ f = RES / "strict_prefetch.json"
100
+ if f.exists():
101
+ out["prefetch"] = json.loads(f.read_text())["summary"]
102
+ return out
103
+
104
+
105
+ def main() -> None:
106
+ parts = {}
107
+ parts["budget_curve"] = budget_curve()
108
+ sweep, tests = ram_sweep()
109
+ parts["ram_sweep"] = sweep
110
+ parts["ram_sweep_tests"] = tests
111
+ parts["negatives"] = negatives()
112
+ print(json.dumps(parts, indent=2, ensure_ascii=False))
113
+ (RES / "summary.json").write_text(json.dumps(parts, indent=2, ensure_ascii=False))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
bench/upload.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Upload the project code, docs and measurement results to the HF repo."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from huggingface_hub import HfApi
12
+
13
+ # this script lives in bench/, the repo root is its parent
14
+ REPO = Path(__file__).resolve().parent.parent
15
+
16
+ MSG = """EPM: expert paging CPU inference for MiniCPM-V-4.7-35B-A3B Q4_K_M
17
+
18
+ Port the 8 GiB expert pager to openbmb/MiniCPM-V-4.7-35B-A3B-Q4_K_M.gguf
19
+ (19.71 GiB, qwen35moe, 40 layers, 256 experts, top-8).
20
+
21
+ Measured under an 8 GiB / no-swap ceiling enforced by tools/strict8g.py:
22
+ 5.83 tok/s median (N=9), peak anon RSS 7799 MiB, VmSwap 0, 9/9 strict_ok.
23
+ Unmodified llama.cpp cannot load the model at that ceiling.
24
+
25
+ Correctness: 3-layer verification passes; 5 runs x 48 tokens x 248144 vocab
26
+ logits bit-identical.
27
+
28
+ Port-specific changes: GGUF BOOL-array header fix, calibrated SDQ_RESERVE_MB,
29
+ two expert size classes, hybrid SSM/attention thread behaviour.
30
+ """
31
+
32
+
33
+ def main() -> int:
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--repo", default=os.environ.get("EPM_HF_REPO", "HelloSun/minicpm47woek"))
36
+ ap.add_argument("--folder", default=str(REPO))
37
+ ap.add_argument("--message", default=MSG)
38
+ ap.add_argument("--dry-run", action="store_true")
39
+ a = ap.parse_args()
40
+
41
+ api = HfApi()
42
+ print("whoami:", api.whoami()["name"])
43
+ if a.dry_run:
44
+ print("dry run, not uploading")
45
+ return 0
46
+ api.upload_folder(
47
+ folder_path=a.folder,
48
+ repo_id=a.repo,
49
+ repo_type="model",
50
+ ignore_patterns=[".git/*", "__pycache__/*", "*.pyc", "*.so",
51
+ "bench/results/.*", "*.gguf", "*.bin"],
52
+ commit_message=a.message,
53
+ )
54
+ print(f"uploaded -> https://huggingface.co/{a.repo}")
55
+ return 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
cpp/sdq_buft.cpp ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SDQ expert buffer type
2
+ //
3
+ // 這是一個「假」buffer type:它宣稱接受所有張量,但實際上
4
+ // * 不配置任何 RAM(alloc_buffer 回傳 0 大小的 buffer)
5
+ // * set_tensor 忽略傳入的資料(資料來自 llama.cpp 的 mmap,我們不碰)
6
+ // * 真正的資料由 sdq::pager 從檔案 pread 到自己的 arena
7
+ //
8
+ // 用途:讓 llama.cpp 把 18.2 GiB 的 MoE 專家權重「掛在模型上」卻不放進 RAM,
9
+ // 改由分頁器管理 SSD → RAM → CPU 的三層資料流。
10
+
11
+ #include "sdq_pager.h"
12
+
13
+ #include "ggml-backend-impl.h"
14
+
15
+ #include <cstdio>
16
+ #include <cstdlib>
17
+ #include <cstring>
18
+ #include <algorithm>
19
+ #include <mutex>
20
+ #include <unordered_map>
21
+ #include <vector>
22
+
23
+ namespace sdq {
24
+
25
+ // 給 tensor->data 的假位址。每個 expert 權重張量分到一小塊(預設 4 KB),
26
+ // 指標必須有效(ggml 會做指標運算),但內容永遠不會被讀。
27
+ struct expert_buffer {
28
+ std::vector<uint8_t> scratch;
29
+ };
30
+
31
+ static bool g_enabled = false;
32
+
33
+ void sdq_set_enabled(bool on) { g_enabled = on; }
34
+ bool sdq_enabled() { return g_enabled; }
35
+
36
+ static config g_cfg;
37
+ void sdq_set_config(const config & c) { g_cfg = c; }
38
+ const config & sdq_get_config() { return g_cfg; }
39
+
40
+ // ---------------------------------------------------------------- buffer iface
41
+
42
+ // ggml_backend_buffer_i 的欄位依賴下面的實作,先給宣告
43
+ struct ggml_backend_buffer_i sdq_buffer_iface();
44
+
45
+ static const char * sdq_buft_get_name(ggml_backend_buffer_type_t buft) {
46
+ (void) buft;
47
+ return "SDQ_ExpertSSD";
48
+ }
49
+
50
+ static size_t sdq_buft_get_alignment(ggml_backend_buffer_type_t buft) {
51
+ (void) buft;
52
+ return 4096;
53
+ }
54
+
55
+ static size_t sdq_buft_get_max_size(ggml_backend_buffer_type_t buft) {
56
+ (void) buft;
57
+ return SIZE_MAX;
58
+ }
59
+
60
+ // 每個 expert 權重張量只回報 4 KB:18.2 GiB 的權重「不會」被載入 RAM 的關鍵。
61
+ static size_t sdq_buft_get_alloc_size(ggml_backend_buffer_type_t buft, const ggml_tensor * tensor) {
62
+ (void) buft;
63
+ (void) tensor;
64
+ return 4096;
65
+ }
66
+
67
+ static bool sdq_buft_is_host(ggml_backend_buffer_type_t buft) {
68
+ (void) buft;
69
+ return false;
70
+ }
71
+
72
+ static ggml_backend_buffer_t sdq_buft_alloc_buffer(ggml_backend_buffer_type_t buft, size_t size) {
73
+ // llama.cpp 會用 get_alloc_size() 計算總大小後呼叫本函式;
74
+ // 我們只保留真正需要的假位址空間(每個張量 4 KB)。
75
+ const size_t bytes = size < 4096 ? 4096 : size;
76
+ auto * buf = new expert_buffer();
77
+ buf->scratch.assign(bytes, 0);
78
+ auto * b = new ggml_backend_buffer{
79
+ /* .iface = */ sdq_buffer_iface(),
80
+ /* .buft = */ buft,
81
+ /* .context = */ buf,
82
+ /* .size = */ bytes,
83
+ /* .usage = */ GGML_BACKEND_BUFFER_USAGE_WEIGHTS,
84
+ };
85
+ return b;
86
+ }
87
+
88
+ // 一次配置整個 context 的張量:每個張量 4 KB 假位址,實際權重 0 RAM。
89
+ static ggml_backend_buffer_t sdq_buft_alloc_buffer_n(ggml_backend_buffer_type_t buft,
90
+ ggml_tensor ** tensors, int n_tensors) {
91
+ const size_t slot = 4096;
92
+ const size_t bytes = slot * (size_t) std::max(n_tensors, 1);
93
+ ggml_backend_buffer_t buffer = sdq_buft_alloc_buffer(buft, bytes);
94
+ if (!buffer) {
95
+ return nullptr;
96
+ }
97
+ uint8_t * base = (uint8_t *) buffer->iface.get_base(buffer);
98
+ size_t off = 0;
99
+ for (int i = 0; i < n_tensors; i++) {
100
+ ggml_tensor * t = tensors[i];
101
+ t->data = base + off;
102
+ t->buffer = buffer;
103
+ off += slot;
104
+ buffer->iface.init_tensor(buffer, t);
105
+ }
106
+ return buffer;
107
+ }
108
+
109
+ static void sdq_buft_free_buffer(ggml_backend_buffer_t buffer) {
110
+ // 只釋放自己的資料;ggml_backend_buffer_free() 會負責 delete buffer 本體,
111
+ // 這裡再 delete 一次會造成 double free。
112
+ delete (expert_buffer *) buffer->context;
113
+ buffer->context = nullptr;
114
+ }
115
+
116
+ static void * sdq_buft_get_base(ggml_backend_buffer_t buffer) {
117
+ return ((expert_buffer *) buffer->context)->scratch.data();
118
+ }
119
+
120
+ static ggml_status sdq_buft_init_tensor(ggml_backend_buffer_t buffer, ggml_tensor * tensor) {
121
+ (void) buffer;
122
+ (void) tensor;
123
+ return GGML_STATUS_SUCCESS;
124
+ }
125
+
126
+ static void sdq_buft_memset_tensor(ggml_backend_buffer_t buffer, ggml_tensor * tensor,
127
+ uint8_t value, size_t offset, size_t size) {
128
+ (void) buffer; (void) tensor; (void) value; (void) offset; (void) size;
129
+ }
130
+
131
+ static void sdq_buft_set_tensor(ggml_backend_buffer_t buffer, ggml_tensor * tensor,
132
+ const void * data, size_t offset, size_t size) {
133
+ // 關鍵:忽略。llama.cpp 會把 mmap 的資料交給我們,這裡一個位元組都不落地,
134
+ // 專家權利永遠不會佔用 RAM。
135
+ (void) buffer; (void) tensor; (void) data; (void) offset; (void) size;
136
+ }
137
+
138
+ static void sdq_buft_get_tensor(ggml_backend_buffer_t buffer, const ggml_tensor * tensor,
139
+ void * data, size_t offset, size_t size) {
140
+ (void) buffer; (void) tensor; (void) offset;
141
+ memset(data, 0, size);
142
+ }
143
+
144
+ static bool sdq_buft_cpy_tensor(ggml_backend_buffer_t buffer, const ggml_tensor * src, ggml_tensor * dst) {
145
+ (void) buffer; (void) src; (void) dst;
146
+ return true;
147
+ }
148
+
149
+ static void sdq_buft_clear(ggml_backend_buffer_t buffer, uint8_t value) {
150
+ (void) buffer; (void) value;
151
+ }
152
+
153
+ struct ggml_backend_buffer_i sdq_buffer_iface() {
154
+ static ggml_backend_buffer_i i = [] {
155
+ ggml_backend_buffer_i x {};
156
+ x.free_buffer = sdq_buft_free_buffer;
157
+ x.get_base = sdq_buft_get_base;
158
+ x.init_tensor = sdq_buft_init_tensor;
159
+ x.memset_tensor = sdq_buft_memset_tensor;
160
+ x.set_tensor = sdq_buft_set_tensor;
161
+ x.get_tensor = sdq_buft_get_tensor;
162
+ x.cpy_tensor = sdq_buft_cpy_tensor;
163
+ x.clear = sdq_buft_clear;
164
+ return x;
165
+ }();
166
+ return i;
167
+ }
168
+
169
+ ggml_backend_buffer_type_t sdq_expert_buft() {
170
+ static ggml_backend_buffer_type_t buft = [] {
171
+ return new ggml_backend_buffer_type{
172
+ /* .iface = */ {
173
+ /* .get_name = */ sdq_buft_get_name,
174
+ /* .alloc_buffer = */ sdq_buft_alloc_buffer,
175
+ /* .alloc_buffer_n = */ sdq_buft_alloc_buffer_n,
176
+ /* .get_alignment = */ sdq_buft_get_alignment,
177
+ /* .get_max_size = */ sdq_buft_get_max_size,
178
+ /* .get_alloc_size = */ sdq_buft_get_alloc_size,
179
+ /* .get_alloc_size_n = */ nullptr,
180
+ /* .is_host = */ sdq_buft_is_host,
181
+ },
182
+ /* .device = */ nullptr,
183
+ /* .context = */ nullptr,
184
+ };
185
+ }();
186
+ return buft;
187
+ }
188
+
189
+ // llama.cpp 端需要的介面宣告
190
+ void sdq_install_buft_overrides(llama_model_params * params);
191
+
192
+ static const llama_model_tensor_buft_override * sdq_overrides() {
193
+ static llama_model_tensor_buft_override ov[] = {
194
+ { "blk\\.[0-9]+\\.ffn_(gate|up|down|gate_up)_exps\\.weight", sdq_expert_buft() },
195
+ { nullptr, nullptr },
196
+ };
197
+ return ov;
198
+ }
199
+
200
+ void sdq_install_buft_overrides(llama_model_params * params) {
201
+ if (!params) {
202
+ return;
203
+ }
204
+ if (params->tensor_buft_overrides == nullptr) {
205
+ params->tensor_buft_overrides = sdq_overrides();
206
+ } else {
207
+ // 使用者已有 override:把手動指定的順序原樣保留,附加在尾端
208
+ size_t n = 0;
209
+ while (params->tensor_buft_overrides[n].pattern != nullptr) {
210
+ n++;
211
+ }
212
+ static std::vector<llama_model_tensor_buft_override> merged;
213
+ merged.clear();
214
+ for (size_t i = 0; i < n; i++) {
215
+ merged.push_back(params->tensor_buft_overrides[i]);
216
+ }
217
+ for (size_t i = 0; sdq_overrides()[i].pattern != nullptr; i++) {
218
+ merged.push_back(sdq_overrides()[i]);
219
+ }
220
+ merged.push_back({ nullptr, nullptr });
221
+ params->tensor_buft_overrides = merged.data();
222
+ }
223
+ }
224
+
225
+ } // namespace sdq
cpp/sdq_cli.cpp ADDED
@@ -0,0 +1,821 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // EPQ 聊天 / benchmark 主程式
2
+ //
3
+ // epq-chat -m <model.gguf> [-c ctx] [-t threads] [-n n_predict] [-p prompt] ...
4
+ //
5
+ // 這個工具存在的理由:官方 llama-cli 不會安裝我們的 tensor buffer override,
6
+ // 而且需要我們在載入模型前後插入 pager 的初始化/收尾。用最少的相依度
7
+ // (只鏈 llama + ggml)自己寫一支,行為完全可控。
8
+
9
+ #include "llama.h"
10
+ #include "sdq_pager.h"
11
+
12
+ #include <algorithm>
13
+ #include <chrono>
14
+ #include <cinttypes>
15
+ #include <cmath>
16
+ #include <cstdio>
17
+ #include <cstdlib>
18
+ #include <cstring>
19
+ #include <functional>
20
+ #include <thread>
21
+ #include <iostream>
22
+ #include <string>
23
+ #include <vector>
24
+
25
+ using namespace sdq;
26
+
27
+ namespace {
28
+
29
+ struct options {
30
+ std::string model;
31
+ std::string prompt;
32
+ std::string system = "You are a helpful assistant. Answer in the user's language.";
33
+ std::string stats_file;
34
+ int32_t n_ctx = 4096; // 驗證範圍:4K(理論支援 1M)
35
+ int32_t n_predict = 256;
36
+ int32_t n_threads = 0;
37
+ int32_t n_ubatch = 0;
38
+ int32_t n_keep = 0;
39
+ float temp = 0.7f;
40
+ float top_p = 0.95f;
41
+ int32_t top_k = 20;
42
+ bool chat = true;
43
+ bool interactive = true;
44
+ bool bench = false;
45
+ bool no_prefetch = false;
46
+ bool dump_logits = false;
47
+ std::string logits_out;
48
+ std::string selftest;
49
+ std::string span_test;
50
+ std::string dump_tensors; // ggml eval callback:把指定張量的資料存檔(A/B 比對用)
51
+ bool no_pager = false; // A/B 測試用:不啟用分頁,走上游 mul_mat_id(整個模型在 RAM)
52
+ int32_t arena_mb = 0;
53
+ int32_t ram_budget_mb = 0;
54
+ bool fadvise = true;
55
+ };
56
+
57
+ double now_s() {
58
+ static auto t0 = std::chrono::steady_clock::now();
59
+ return std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
60
+ }
61
+
62
+ size_t rss_mb() { return process_rss_bytes() / (1024 * 1024); }
63
+
64
+ // 從 /proc/self/status 讀 VmHWM(峰值 RSS)/ VmSwap(本行程觀測到的 swap 用量;
65
+ // 這不代表系統 swap 已停用,也不構成總 RSS 硬上限)
66
+ size_t proc_status_kb(const char * key) {
67
+ FILE * f = fopen("/proc/self/status", "r");
68
+ if (!f) return 0;
69
+ char line[256];
70
+ const size_t klen = strlen(key);
71
+ size_t val = 0;
72
+ while (fgets(line, sizeof(line), f)) {
73
+ if (strncmp(line, key, klen) == 0) {
74
+ val = (size_t) strtoull(line + klen, nullptr, 10);
75
+ break;
76
+ }
77
+ }
78
+ fclose(f);
79
+ return val;
80
+ }
81
+
82
+ size_t peak_rss_mb() { return proc_status_kb("VmHWM:") / 1024; }
83
+ size_t swap_mb() { return proc_status_kb("VmSwap:") / 1024; }
84
+
85
+ // /proc/self/smaps_rollup:把 RSS 拆成「檔案對映」與「匿名」兩塊。
86
+ // 這很重要:模型檔是 mmap 進來的,核心預讀會讓「檔案對映頁」也算進 RSS,
87
+ // 報告時必須分開看,否則會誤以為 RAM 用量比實際少(或多)。
88
+ size_t smaps_kb(const char * key) {
89
+ FILE * f = fopen("/proc/self/smaps_rollup", "r");
90
+ if (!f) return 0;
91
+ char line[256];
92
+ const size_t klen = strlen(key);
93
+ size_t val = 0;
94
+ while (fgets(line, sizeof(line), f)) {
95
+ if (strncmp(line, key, klen) == 0) {
96
+ val = (size_t) strtoull(line + klen, nullptr, 10);
97
+ break;
98
+ }
99
+ }
100
+ fclose(f);
101
+ return val;
102
+ }
103
+
104
+ void print_mem_report(const char * when) {
105
+ fprintf(stderr,
106
+ "[sdq] 記憶體(%s):匿名 RSS %zu MB / 檔案對映 RSS %zu MB / 總 RSS %zu MB / 峰值 %zu MB / 本行程 VmSwap %zu MB(非系統 swap 狀態)\n",
107
+ when, smaps_kb("Anonymous:") / 1024, smaps_kb("RssFile:") / 1024,
108
+ smaps_kb("Rss:") / 1024, peak_rss_mb(), swap_mb());
109
+ }
110
+
111
+ // 讀 /proc/self/io:從 block device 實際讀了多少(SSD 讀取)
112
+ uint64_t read_bytes_io() {
113
+ FILE * f = fopen("/proc/self/io", "r");
114
+ if (!f) return 0;
115
+ char line[256];
116
+ uint64_t v = 0;
117
+ while (fgets(line, sizeof(line), f)) {
118
+ if (strncmp(line, "read_bytes:", 11) == 0) {
119
+ v = strtoull(line + 11, nullptr, 10);
120
+ }
121
+ }
122
+ fclose(f);
123
+ return v;
124
+ }
125
+
126
+ struct chat_state {
127
+ std::vector<llama_chat_message> msgs;
128
+ };
129
+
130
+ std::string build_prompt(const options & opt, const chat_state & st) {
131
+ if (!opt.chat) {
132
+ return opt.prompt;
133
+ }
134
+ const char * tmpl = llama_model_chat_template(nullptr, nullptr);
135
+ // 沒有 model 指標時用內建模板;下面 main() 會用實際模型的模板重做
136
+ (void) tmpl;
137
+ size_t n_chars = 0;
138
+ for (const auto & m : st.msgs) {
139
+ n_chars += strlen(m.role) + strlen(m.content) + 32;
140
+ }
141
+ std::vector<char> buf(2 * n_chars + 1024);
142
+ const char * t = st.msgs.empty() ? nullptr : nullptr;
143
+ (void) t;
144
+ int32_t len = llama_chat_apply_template(tmpl, st.msgs.data(), st.msgs.size(), true,
145
+ buf.data(), (int32_t) buf.size());
146
+ if (len > (int32_t) buf.size()) {
147
+ buf.resize(len + 1);
148
+ llama_chat_apply_template(tmpl, st.msgs.data(), st.msgs.size(), true,
149
+ buf.data(), (int32_t) buf.size());
150
+ }
151
+ return std::string(buf.data());
152
+ }
153
+
154
+ } // namespace
155
+
156
+ #include <execinfo.h>
157
+ #include <signal.h>
158
+ #include <unistd.h>
159
+ static void sdq_segv(int sig) {
160
+ void * bt[64];
161
+ const int n = backtrace(bt, 64);
162
+ fprintf(stderr, "\n*** sdq: signal %d, backtrace:\n", sig);
163
+ backtrace_symbols_fd(bt, n, 2);
164
+ _exit(9);
165
+ }
166
+
167
+ // eval callback:把感興趣的張量(用 SDQ_DUMP_NAMES 指定)寫進檔案
168
+ struct dump_state {
169
+ FILE * fp = nullptr;
170
+ uint64_t step = 0;
171
+ std::vector<std::string> names;
172
+ };
173
+
174
+ static dump_state g_dump;
175
+
176
+ static bool sdq_eval_callback(ggml_tensor * t, bool ask, void * user_data) {
177
+ (void) user_data;
178
+ auto & ds = g_dump;
179
+ if (ask) {
180
+ // 除錯模式:每個節點都要資料 → 會強制逐節點同步(慢,但能逐節點比對)
181
+ return ds.fp != nullptr;
182
+ }
183
+ if (!ds.fp) {
184
+ return false;
185
+ }
186
+ const char * nm = ggml_get_name(t);
187
+ const size_t n_len = nm ? strlen(nm) : 0;
188
+ bool want = ds.names.empty();
189
+ for (const auto & n : ds.names) {
190
+ if (nm && strstr(nm, n.c_str())) {
191
+ want = true;
192
+ break;
193
+ }
194
+ }
195
+ const int32_t ne[4] = {(int32_t) t->ne[0], (int32_t) t->ne[1], (int32_t) t->ne[2], (int32_t) t->ne[3]};
196
+ const int32_t type = (int32_t) t->type;
197
+ fwrite(&n_len, sizeof(size_t), 1, ds.fp);
198
+ if (n_len) {
199
+ fwrite(nm, 1, n_len, ds.fp);
200
+ }
201
+ fwrite(&ds.step, sizeof(uint64_t), 1, ds.fp);
202
+ fwrite(ne, sizeof(int32_t), 4, ds.fp);
203
+ fwrite(&type, sizeof(int32_t), 1, ds.fp);
204
+
205
+ // 永遠寫「摘要」(前 8 個值 + 平方和 + 最大值),這樣即使沒有名字也能 A/B 找出第一個分歧點
206
+ float summary[10] = {0};
207
+ if (t->type == GGML_TYPE_F32 && t->data) {
208
+ const size_t n = (size_t) ne[0] * ne[1] * ne[2] * ne[3];
209
+ const float * p = (const float *) t->data;
210
+ double ss = 0;
211
+ float mx = 0;
212
+ for (size_t i = 0; i < n; i++) {
213
+ ss += (double) p[i] * p[i];
214
+ if (std::fabs(p[i]) > std::fabs(mx)) {
215
+ mx = p[i];
216
+ }
217
+ }
218
+ for (int i = 0; i < 8 && (size_t) i < n; i++) {
219
+ summary[i] = p[i];
220
+ }
221
+ summary[8] = (float) std::sqrt(ss);
222
+ summary[9] = mx;
223
+ fwrite(summary, sizeof(float), 10, ds.fp);
224
+ if (want) {
225
+ fwrite(p, sizeof(float), n, ds.fp);
226
+ }
227
+ } else {
228
+ fwrite(summary, sizeof(float), 10, ds.fp);
229
+ }
230
+ ds.step++;
231
+ return true; // 這個節點的資料我們已經讀過了,標記成已完成
232
+ }
233
+
234
+ int main(int argc, char ** argv) {
235
+ signal(SIGSEGV, sdq_segv);
236
+ signal(SIGBUS, sdq_segv);
237
+ signal(SIGABRT, sdq_segv);
238
+ options opt;
239
+ const char * env_model = getenv("SDQ_MODEL_PATH");
240
+ if (env_model) {
241
+ opt.model = env_model;
242
+ }
243
+
244
+ for (int i = 1; i < argc; i++) {
245
+ std::string a = argv[i];
246
+ auto next = [&](const char * what) -> std::string {
247
+ if (i + 1 >= argc) {
248
+ fprintf(stderr, "%s 需要參數\n", what);
249
+ exit(1);
250
+ }
251
+ return argv[++i];
252
+ };
253
+ if (a == "-m" || a == "--model") opt.model = next("-m");
254
+ else if (a == "-p" || a == "--prompt") opt.prompt = next("-p");
255
+ else if (a == "--sys") opt.system = next("--sys");
256
+ else if (a == "-c" || a == "--ctx") opt.n_ctx = atoi(next("-c").c_str());
257
+ else if (a == "-n") opt.n_predict = atoi(next("-n").c_str());
258
+ else if (a == "-t") opt.n_threads = atoi(next("-t").c_str());
259
+ else if (a == "-b" || a == "--ubatch") opt.n_ubatch = atoi(next("-b").c_str());
260
+ else if (a == "--temp") opt.temp = atof(next("--temp").c_str());
261
+ else if (a == "--top-p") opt.top_p = atof(next("--top-p").c_str());
262
+ else if (a == "--top-k") opt.top_k = atoi(next("--top-k").c_str());
263
+ else if (a == "--no-chat") opt.chat = false;
264
+ else if (a == "--no-interactive") opt.interactive = false;
265
+ else if (a == "--bench") opt.bench = true;
266
+ else if (a == "--no-prefetch") opt.no_prefetch = true;
267
+ else if (a == "--arena-mb") opt.arena_mb = atoi(next("--arena-mb").c_str());
268
+ else if (a == "--ram-budget-mb") opt.ram_budget_mb = atoi(next("--ram-budget-mb").c_str());
269
+ else if (a == "--no-fadvise") opt.fadvise = false;
270
+ else if (a == "--stats-file") opt.stats_file = next("--stats-file");
271
+ else if (a == "--dump-logits") opt.dump_logits = true;
272
+ else if (a == "--logits-out") opt.logits_out = next("--logits-out");
273
+ else if (a == "--selftest") opt.selftest = next("--selftest");
274
+ else if (a == "--dump-tensors") opt.dump_tensors = next("--dump-tensors");
275
+ else if (a == "--no-pager") opt.no_pager = true;
276
+ else if (a == "--span-test") opt.span_test = next("--span-test");
277
+ else if (a == "-h" || a == "--help") {
278
+ printf(
279
+ "用法:epq-chat -m <model.gguf> [選項]\n"
280
+ " -c, --ctx N 上下文長度(預設 4096;模型原生 262144)\n"
281
+ " -n N 要生成的 token 數\n"
282
+ " -t N CPU 執行緒數\n"
283
+ " -b N ubatch 大小\n"
284
+ " -p, --prompt TEXT 提示詞\n"
285
+ " --sys TEXT system 訊息\n"
286
+ " --no-chat 不要套 chat template(純 completion)\n"
287
+ " --no-interactive 跑完提示詞就結束\n"
288
+ " --bench benchmark 模式(輸出 JSON 到 stats 檔)\n"
289
+ " --arena-mb N expert arena 大小(MB)\n"
290
+ " --ram-budget-mb N RAM 總預算(MB),arena 會配合縮減\n"
291
+ " --no-prefetch 關閉背景預取\n"
292
+ " --no-fadvise 讀完不丟 kernel page cache(會讓 RAM 帳目失真)\n"
293
+ " --stats-file PATH 指標 JSONL 輸出\n"
294
+ " --dump-logits 把每個 token 的 logits 存檔(驗證用)\n"
295
+ " --logits-out PATH logits 輸出路徑\n");
296
+ return 0;
297
+ } else {
298
+ fprintf(stderr, "未知參數:%s(用 --help 看用法)\n", a.c_str());
299
+ return 1;
300
+ }
301
+ }
302
+
303
+ if (opt.model.empty()) {
304
+ fprintf(stderr, "缺少模型檔(-m 或 SDQ_MODEL_PATH)\n");
305
+ return 1;
306
+ }
307
+
308
+ // ---- 環境變數(pager 讀)
309
+ setenv("SDQ_MODEL_PATH", opt.model.c_str(), 1);
310
+ // I/O 讀取執行緒數:預設 3 × 計算執行緒(上限 48)。
311
+ // 必須在這裡設定 —— pager 的 I/O 池是在「載入模型」時啟動的,那已經比
312
+ // context 建立早了;放到後面設定會讓池子只拿到下限 4 條執行緒。
313
+ // 量測(repeat=3,中位數):序列讀 3.79 → 12 條 5.84 → 24 條 6.68 tok/s。
314
+ if (!getenv("SDQ_IO_THREADS")) {
315
+ const int hw_io = (int) std::max(1u, std::thread::hardware_concurrency());
316
+ const int dflt_io = hw_io < 8 ? hw_io : 8;
317
+ const int n_io = opt.n_threads > 0 ? opt.n_threads : dflt_io;
318
+ setenv("SDQ_IO_THREADS", std::to_string(n_io * 3).c_str(), 1);
319
+ }
320
+ // 只有明確指定時才設定,否則保留呼叫者從環境變數傳入的值
321
+ // (舊版無條件 setenv 會讓 SDQ_PREFETCH=0 被悄悄蓋回 1,除錯時極易誤判)
322
+ if (opt.no_prefetch) {
323
+ setenv("SDQ_PREFETCH", "0", 1);
324
+ }
325
+ setenv("SDQ_FADV_DONTNEED", opt.fadvise ? "1" : "0", 1);
326
+ if (opt.arena_mb > 0) {
327
+ setenv("SDQ_ARENA_MB", std::to_string(opt.arena_mb).c_str(), 1);
328
+ }
329
+ if (opt.ram_budget_mb > 0) {
330
+ setenv("SDQ_RAM_BUDGET_MB", std::to_string(opt.ram_budget_mb).c_str(), 1);
331
+ }
332
+ if (!opt.stats_file.empty()) {
333
+ setenv("SDQ_STATS_FILE", opt.stats_file.c_str(), 1);
334
+ }
335
+
336
+ llama_backend_init();
337
+ llama_numa_init(GGML_NUMA_STRATEGY_DISABLED);
338
+
339
+ // ---- 模型載入:expert 權重交給 pager 的 buffer type(0 RAM)
340
+ llama_model_params mparams = llama_model_default_params();
341
+ mparams.n_gpu_layers = 0;
342
+ mparams.load_mode = LLAMA_LOAD_MODE_MMAP;
343
+ if (!opt.no_pager) {
344
+ sdq_set_enabled(true);
345
+ sdq_install_buft_overrides(&mparams);
346
+ } else {
347
+ printf("[sdq] --no-pager:對照模式,專家權重全部留在 RAM(需要 %s RAM)\n", "22GB");
348
+ }
349
+
350
+ printf("[sdq] 載入模型:%s\n", opt.model.c_str());
351
+ const double t_load0 = now_s();
352
+ llama_model * model = llama_model_load_from_file(opt.model.c_str(), mparams);
353
+ if (!model) {
354
+ fprintf(stderr, "[sdq] 模型載入失敗\n");
355
+ return 1;
356
+ }
357
+ const double t_load = now_s() - t_load0;
358
+
359
+ // ---- pager 初始化(讀 GGUF 檔頭、建頁面表、配置 arena)
360
+ auto & P = pager::instance();
361
+ if (!opt.no_pager && !sdq_init(opt.model)) {
362
+ fprintf(stderr, "[sdq] pager 初始化失敗\n");
363
+ return 1;
364
+ }
365
+
366
+ // ---- 張量 dump(A/B 比對用):在 op 真的算完的那一刻把資料寫出來
367
+ FILE * tensor_fp = nullptr;
368
+ dump_state dump_state_;
369
+ if (!opt.dump_tensors.empty()) {
370
+ tensor_fp = fopen(opt.dump_tensors.c_str(), "wb");
371
+ dump_state_.fp = tensor_fp;
372
+ if (const char * names = getenv("SDQ_DUMP_NAMES")) {
373
+ std::string s = names;
374
+ size_t pos = 0;
375
+ while (pos <= s.size()) {
376
+ size_t c = s.find(',', pos);
377
+ if (c == std::string::npos) c = s.size();
378
+ dump_state_.names.push_back(s.substr(pos, c - pos));
379
+ pos = c + 1;
380
+ }
381
+ }
382
+ g_dump = dump_state_;
383
+ }
384
+
385
+ // ---- context
386
+ llama_context_params cparams = llama_context_default_params();
387
+ cparams.n_ctx = (uint32_t) opt.n_ctx;
388
+ cparams.n_batch = (uint32_t) opt.n_ctx;
389
+ // ubatch 預設 128(不是 512)。理由是「8 GB 預算要真的裝得下」:
390
+ // llama.cpp 的 CPU compute buffer 是按 n_ubatch 線性成長的,實測
391
+ // ubatch=512 → 502 MiB、256 → 251 MiB、128 → 125.5 MiB
392
+ // 而 compute buffer 是在 pager 算完 arena 大小之後才配置的,所以它不算在
393
+ // arena 的扣減裡。實測在 RLIMIT_DATA 8 GB 下:
394
+ // ubatch=512 → 提示詞超過 ~256 token 直接 std::bad_alloc(崩潰)
395
+ // ubatch=256 → 同一個提示詞一樣崩潰
396
+ // ubatch=128 → 正常,681 token 的 prefill 跑到 28.8 tok/s
397
+ // 而且 ubatch=128 還更快(docs/04 實測 +7%),因為 prefill 分更多批、
398
+ // 每批的 expert 聯集比較小、命中率比較高。
399
+ cparams.n_ubatch = opt.n_ubatch > 0 ? (uint32_t) opt.n_ubatch : (uint32_t) std::min(opt.n_ctx, 128);
400
+ // 執行緒預設值:不要直接用 hardware_concurrency()。
401
+ // 實測(docs/04,repeat=3):mode C 下 t=8 最快,給更多執行緒反而變慢 ——
402
+ // t=8 4.02 / t=10 3.79 / t=12 3.55 / t=16 3.60 tok/s
403
+ // 原因很直接:每層每 token 只有 n_expert_used = 8 個 expert 可以平行,
404
+ // 執行緒超過 8 之後就只是多開執行緒搶同一批 CPU。
405
+ // 下限取實際核心數,避免在 4 核心的目標機器上開太多。
406
+ const int hw = (int) std::max(1u, std::thread::hardware_concurrency());
407
+ const int dflt_threads = hw < 8 ? hw : 8;
408
+ cparams.n_threads = opt.n_threads > 0 ? opt.n_threads : (int32_t) dflt_threads;
409
+ cparams.n_threads_batch = cparams.n_threads;
410
+ if (!opt.stats_file.empty()) {
411
+ cparams.offload_kqv = false;
412
+ }
413
+
414
+ if (tensor_fp) {
415
+ cparams.cb_eval = sdq_eval_callback;
416
+ cparams.cb_eval_user_data = &g_dump;
417
+ }
418
+
419
+ llama_context * ctx = llama_init_from_model(model, cparams);
420
+ if (!ctx) {
421
+ fprintf(stderr, "[sdq] context 建立失敗(ctx=%d 太大?)\n", opt.n_ctx);
422
+ return 1;
423
+ }
424
+
425
+ printf("[sdq] ctx=%d threads=%d ubatch=%u RAM(RSS)=%zu MB 模型載入 %.1fs\n",
426
+ opt.n_ctx, cparams.n_threads, cparams.n_ubatch, rss_mb(), t_load);
427
+ print_mem_report("載入 + pager 後");
428
+
429
+ // ---- sampler
430
+ auto sparams = llama_sampler_chain_default_params();
431
+ sparams.no_perf = true;
432
+ llama_sampler * smpl = llama_sampler_chain_init(sparams);
433
+ llama_sampler_chain_add(smpl, llama_sampler_init_top_k(opt.top_k));
434
+ llama_sampler_chain_add(smpl, llama_sampler_init_top_p(opt.top_p, 1));
435
+ llama_sampler_chain_add(smpl, llama_sampler_init_temp(opt.temp));
436
+ llama_sampler_chain_add(smpl, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));
437
+
438
+ chat_state st;
439
+ if (!opt.system.empty()) {
440
+ st.msgs.push_back({"system", opt.system.c_str()});
441
+ }
442
+
443
+ std::vector<llama_token> decoded;
444
+
445
+ const llama_vocab * vocab = llama_model_get_vocab(model);
446
+
447
+ auto tokenize = [&](const std::string & text) {
448
+ std::vector<llama_token> toks(text.size() + 16);
449
+ int32_t n = llama_tokenize(vocab, text.c_str(), (int32_t) text.size(),
450
+ toks.data(), (int32_t) toks.size(), true, true);
451
+ if (n < 0) {
452
+ toks.resize((size_t) -n);
453
+ n = llama_tokenize(vocab, text.c_str(), (int32_t) text.size(),
454
+ toks.data(), (int32_t) toks.size(), true, true);
455
+ }
456
+ toks.resize(n);
457
+ return toks;
458
+ };
459
+
460
+ // ---- 送進去一個 ubatch,回傳生成的 token(-1 = 需要繼續生成)
461
+ std::function<int(llama_token, bool)> submit;
462
+ uint64_t n_prefill_tok = 0;
463
+ double t_prefill = 0;
464
+ uint64_t n_decode_tok = 0;
465
+ double t_decode = 0;
466
+ uint64_t io_read0 = read_bytes_io();
467
+
468
+ std::vector<float> logits_store;
469
+ FILE * logits_fp = nullptr;
470
+ if (opt.dump_logits) {
471
+ logits_fp = fopen(opt.logits_out.empty() ? "sdq_logits.bin" : opt.logits_out.c_str(), "wb");
472
+ }
473
+
474
+ submit = [&](llama_token tok, bool last) -> int {
475
+ const uint32_t ctx_size = llama_n_ctx(ctx);
476
+ llama_memory_t mem = llama_get_memory(ctx);
477
+ if (tok == 1) { // BOS:重開記憶體
478
+ llama_memory_clear(mem, true);
479
+ decoded.clear();
480
+ }
481
+ if (decoded.size() >= opt.n_ctx - 4) {
482
+ // 環形丟掉最舊的 token(保留 opt.n_keep)
483
+ const size_t drop = decoded.size() - (size_t) opt.n_keep;
484
+ llama_memory_seq_rm(mem, 0, 0, (int32_t) drop);
485
+ decoded.erase(decoded.begin(), decoded.begin() + (long) drop);
486
+ }
487
+
488
+ llama_batch b = llama_batch_get_one((llama_token *) &tok, 1);
489
+
490
+ const bool is_prefill = decoded.empty();
491
+ const double t0 = now_s();
492
+ const int rc = llama_decode(ctx, b);
493
+ const double dt = now_s() - t0;
494
+ if (rc != 0) {
495
+ fprintf(stderr, "\n[sdq] llama_decode 失敗 rc=%d(記憶體不足?)\n", rc);
496
+ exit(3);
497
+ }
498
+ decoded.push_back(tok);
499
+
500
+ // 每個 ubatch 結束 → 更新路由歷史並觸發下一輪預取
501
+ P.end_token();
502
+
503
+ if (is_prefill) {
504
+ n_prefill_tok++;
505
+ t_prefill += dt;
506
+ } else {
507
+ n_decode_tok++;
508
+ t_decode += dt;
509
+ P.get_stats().tokens.fetch_add(1, std::memory_order_relaxed);
510
+ }
511
+
512
+ if (logits_fp) {
513
+ const int32_t n_logits = llama_vocab_n_tokens(vocab);
514
+ const float * lg = llama_get_logits_ith(ctx, -1);
515
+ if (lg) {
516
+ logits_store.assign(lg, lg + n_logits);
517
+ fwrite(&n_logits, sizeof(int32_t), 1, logits_fp);
518
+ fwrite(logits_store.data(), sizeof(float), n_logits, logits_fp);
519
+ fflush(logits_fp);
520
+ }
521
+ }
522
+
523
+ if (last) {
524
+ return -1;
525
+ }
526
+ const llama_token id = llama_sampler_sample(smpl, ctx, -1);
527
+ return (int) id;
528
+ };
529
+
530
+ // ---- 產生 prompt
531
+ auto run_prompt = [&](const std::string & user_text, bool add_to_history) {
532
+ if (opt.chat) {
533
+ st.msgs.push_back({"user", user_text.c_str()});
534
+ }
535
+ std::string text;
536
+ if (opt.chat) {
537
+ const char * tmpl = llama_model_chat_template(model, nullptr);
538
+ size_t n_chars = 0;
539
+ for (const auto & m : st.msgs) {
540
+ n_chars += strlen(m.role) + strlen(m.content) + 32;
541
+ }
542
+ std::vector<char> buf(2 * n_chars + 1024);
543
+ int32_t len = llama_chat_apply_template(tmpl, st.msgs.data(), st.msgs.size(),
544
+ true, buf.data(), (int32_t) buf.size());
545
+ if (len > (int32_t) buf.size()) {
546
+ buf.resize((size_t) len + 1);
547
+ llama_chat_apply_template(tmpl, st.msgs.data(), st.msgs.size(), true,
548
+ buf.data(), (int32_t) buf.size());
549
+ }
550
+ text.assign(buf.data());
551
+ } else {
552
+ text = user_text;
553
+ }
554
+
555
+ auto toks = tokenize(text);
556
+ const int n_prompt = (int) toks.size();
557
+ const uint64_t io0 = read_bytes_io();
558
+
559
+ // 分批送進去(每批 = 一個 ubatch,控制記憶體)
560
+ const int32_t ub = (int32_t) cparams.n_ubatch;
561
+ llama_sampler_reset(smpl);
562
+ int32_t pos = 0;
563
+ for (int32_t i = 0; i < n_prompt; i += ub) {
564
+ const int32_t n = std::min(ub, n_prompt - i);
565
+ llama_batch b = llama_batch_init((uint32_t) n, 0, 1);
566
+ for (int32_t j = 0; j < n; j++) {
567
+ b.token[j] = toks[i + j];
568
+ b.pos[j] = pos++;
569
+ b.n_seq_id[j] = 1;
570
+ b.seq_id[j][0] = 0;
571
+ }
572
+ b.n_tokens = (uint32_t) n;
573
+ const double t0 = now_s();
574
+ const int rc = llama_decode(ctx, b);
575
+ t_prefill += now_s() - t0;
576
+ n_prefill_tok += (uint64_t) n;
577
+ if (rc != 0) {
578
+ fprintf(stderr, "\n[sdq] prefill 失敗 rc=%d(%d tokens)\n", rc, n);
579
+ exit(3);
580
+ }
581
+ P.end_token();
582
+ llama_batch_free(b);
583
+ }
584
+ const double t_p = now_s();
585
+ (void) t_p;
586
+ (void) io0;
587
+
588
+ if (add_to_history && opt.chat) {
589
+ st.msgs.push_back({"assistant", ""});
590
+ }
591
+ return n_prompt;
592
+ };
593
+
594
+ // ---- 產生
595
+ auto generate = [&](int32_t n_max) {
596
+ const uint64_t io0 = read_bytes_io();
597
+ const uint64_t io_read_before = io0;
598
+ (void) io_read_before;
599
+ const double t0 = now_s();
600
+ int32_t n_gen = 0;
601
+ llama_token id = llama_sampler_sample(smpl, ctx, -1);
602
+ fputs("assistant: ", stdout);
603
+ fflush(stdout);
604
+ while (n_gen < n_max) {
605
+ const int rc = submit(id, /*last=*/n_gen + 1 >= n_max);
606
+ if (rc < 0) {
607
+ break;
608
+ }
609
+ id = (llama_token) rc;
610
+ char piece_buf[256];
611
+ const int n_piece = llama_token_to_piece(vocab, id, piece_buf, sizeof(piece_buf), 0, true);
612
+ const std::string piece(piece_buf, (size_t) std::max(0, n_piece));
613
+ fputs(piece.c_str(), stdout);
614
+ fflush(stdout);
615
+ n_gen++;
616
+ }
617
+ fputs("\n", stdout);
618
+ const double dt = now_s() - t0;
619
+ const uint64_t io1 = read_bytes_io();
620
+ fprintf(stderr, "[sdq] decode: %d tok / %.2fs = %.2f tok/s | SSD 讀 %.1f MB (%.1f MB/tok) | RSS %zu MB\n",
621
+ n_gen, dt, dt > 0 ? n_gen / dt : 0.0,
622
+ (io1 - io0) / 1048576.0, n_gen ? (io1 - io0) / 1048576.0 / n_gen : 0.0, rss_mb());
623
+ if (opt.chat) {
624
+ for (auto & m : st.msgs) {
625
+ if (strcmp(m.role, "assistant") == 0) {
626
+ m.content = "";
627
+ }
628
+ }
629
+ }
630
+ P.dump_stats("decode");
631
+ return n_gen;
632
+ };
633
+
634
+ // ---- 合併讀取自我測試:--span-test IL:E0:E1:...
635
+ if (!opt.span_test.empty()) {
636
+ std::vector<int> parts;
637
+ std::string cur_s;
638
+ for (char ch : opt.span_test + ":") {
639
+ if (ch == ':') { parts.push_back(atoi(cur_s.c_str())); cur_s.clear(); }
640
+ else cur_s += ch;
641
+ }
642
+ if (parts.size() < 3) {
643
+ fprintf(stderr, "用法:--span-test IL:E0:E1[:E2...](E 要連續)\n");
644
+ return 1;
645
+ }
646
+ const int il = parts[0];
647
+ std::vector<int32_t> ex;
648
+ for (size_t i = 1; i < parts.size(); i++) {
649
+ ex.push_back(parts[i]);
650
+ }
651
+ std::vector<int32_t> slots(ex.size(), -1);
652
+ for (size_t i = 0; i < ex.size(); i++) {
653
+ slots[i] = P.reserve_slot(il, ex[i]);
654
+ }
655
+ const uint64_t got = P.read_expert_span(il, ex.data(), (int) ex.size(), slots.data());
656
+ printf("[sdq-span-test] L%d experts=%zu 讀取 %.2f MB\n", il, ex.size(), got / 1048576.0);
657
+ int bad = 0;
658
+ for (size_t i = 0; i < ex.size(); i++) {
659
+ std::vector<uint8_t> one((size_t) P.layer(il)->experts[ex[i]].bytes, 0);
660
+ if (!P.read_expert(il, ex[i], one.data())) {
661
+ printf(" 單獨讀取失敗\n");
662
+ return 1;
663
+ }
664
+ const uint8_t * span_ptr = P.slot_ptr(slots[i]);
665
+ size_t diff_at = (size_t) -1;
666
+ for (size_t b = 0; b < one.size(); b++) {
667
+ if (one[b] != span_ptr[b]) { diff_at = b; break; }
668
+ }
669
+ printf(" expert %4d: %s", ex[i], diff_at == (size_t) -1 ? "一致 ✅" : "不一致 ❌");
670
+ if (diff_at != (size_t) -1) {
671
+ printf("(第一個不同的 byte 在 %zu / %zu,slot=%d)", diff_at, one.size(), slots[i]);
672
+ bad++;
673
+ }
674
+ printf("\n");
675
+ }
676
+ sdq_shutdown();
677
+ llama_free(ctx);
678
+ llama_model_free(model);
679
+ llama_backend_free();
680
+ return bad ? 1 : 0;
681
+ }
682
+
683
+ // ---- 自我測試模式:--selftest IL:IE:SEED:OUT.bin
684
+ if (!opt.selftest.empty()) {
685
+ int il = 0, ie = 0, seed = 1234, n_tok = 1;
686
+ std::string outpath;
687
+ {
688
+ std::vector<std::string> parts;
689
+ std::string cur_s;
690
+ for (char c : opt.selftest + ":") {
691
+ if (c == ':') { parts.push_back(cur_s); cur_s.clear(); }
692
+ else cur_s += c;
693
+ }
694
+ if (parts.size() >= 3) {
695
+ il = atoi(parts[0].c_str());
696
+ ie = atoi(parts[1].c_str());
697
+ seed = atoi(parts[2].c_str());
698
+ }
699
+ if (parts.size() >= 4) {
700
+ outpath = parts[3];
701
+ }
702
+ if (parts.size() >= 5) {
703
+ n_tok = atoi(parts[4].c_str());
704
+ }
705
+ }
706
+ const int64_t n_embd = P.info().n_embd;
707
+ const int64_t n_ff = P.info().n_ff_exp;
708
+ std::vector<float> x((size_t) n_embd * n_tok);
709
+ // SDQ_SELFTEST_X: use a real activation vector instead of random noise, so
710
+ // one expert can be checked against NumPy on the exact input the full
711
+ // model produced.
712
+ if (const char * xf = getenv("SDQ_SELFTEST_X")) {
713
+ FILE * fh = fopen(xf, "rb");
714
+ const bool ok = fh && (size_t) fread(x.data(), sizeof(float), x.size(), fh) == x.size();
715
+ if (fh) { fclose(fh); }
716
+ if (!ok) {
717
+ fprintf(stderr, "[sdq] SDQ_SELFTEST_X 讀取失敗:%s\n", xf);
718
+ return 1;
719
+ }
720
+ } else {
721
+ uint32_t rng = (uint32_t) seed * 2654435761u + 1u;
722
+ for (auto & v : x) {
723
+ rng = rng * 1664525u + 1013904223u;
724
+ v = ((float) (rng >> 8) / (float) (1 << 24)) * 2.0f - 1.0f;
725
+ }
726
+ }
727
+ std::vector<float> io((size_t) n_embd * n_tok * 2);
728
+ const int rc = sdq_moe_selftest(il, ie, x.data(), io.data(), n_embd, n_ff, n_tok);
729
+ if (rc == 0 && !outpath.empty()) {
730
+ FILE * f = fopen(outpath.c_str(), "wb");
731
+ fwrite(io.data(), sizeof(float), io.size(), f);
732
+ fclose(f);
733
+ printf("[sdq] selftest 結果已寫到 %s(layer=%d expert=%d seed=%d n_embd=%lld n_ff=%lld)\n",
734
+ outpath.c_str(), il, ie, seed, (long long) n_embd, (long long) n_ff);
735
+ }
736
+ sdq_shutdown();
737
+ llama_free(ctx);
738
+ llama_model_free(model);
739
+ llama_backend_free();
740
+ return rc;
741
+ }
742
+
743
+ // ---- 主流程
744
+ if (!opt.prompt.empty()) {
745
+ run_prompt(opt.prompt, true);
746
+ generate(opt.n_predict);
747
+ }
748
+
749
+ if (opt.bench) {
750
+ // benchmark:固定提示詞 ×3,量 prefill / decode / 分頁指標
751
+ const std::vector<std::string> prompts = {
752
+ "請用中文簡短介紹 SSD、RAM、CPU 之間的資料分層是什麼,以及為什麼快取很重要。",
753
+ "Write a short paragraph explaining how a mixture-of-experts model routes tokens to experts.",
754
+ "請列出十種日常會用到電腦的工作,並各用一句話說明。",
755
+ };
756
+ for (int round = 0; round < 3; round++) {
757
+ fprintf(stderr, "[bench] round %d\n", round);
758
+ llama_memory_clear(llama_get_memory(ctx), true);
759
+ decoded.clear();
760
+ P.get_stats().tokens.store(0);
761
+ run_prompt(prompts[round % prompts.size()], false);
762
+ generate(48);
763
+ P.dump_stats(round == 0 ? "bench_warm" : "bench_hot");
764
+ }
765
+ }
766
+
767
+ if (opt.interactive) {
768
+ printf("\n(輸入文字開始對話;/exit 離開、/reset 重設、/stats 顯示指標)\n");
769
+ std::string line;
770
+ while (true) {
771
+ printf("\nuser> ");
772
+ fflush(stdout);
773
+ if (!std::getline(std::cin, line)) {
774
+ break;
775
+ }
776
+ if (line == "/exit" || line == "/quit") {
777
+ break;
778
+ }
779
+ if (line == "/reset") {
780
+ llama_memory_clear(llama_get_memory(ctx), true);
781
+ decoded.clear();
782
+ st.msgs.clear();
783
+ if (!opt.system.empty()) {
784
+ st.msgs.push_back({"system", opt.system.c_str()});
785
+ }
786
+ printf("(已重設)\n");
787
+ continue;
788
+ }
789
+ if (line == "/stats") {
790
+ P.dump_stats("manual");
791
+ fprintf(stderr, "hit_rate=%.1f%% arena=%zu MB RSS=%zu MB\n",
792
+ P.hit_rate() * 100, P.used_bytes() / 1048576, rss_mb());
793
+ continue;
794
+ }
795
+ if (line.empty()) {
796
+ continue;
797
+ }
798
+ run_prompt(line, true);
799
+ generate(opt.n_predict);
800
+ }
801
+ }
802
+
803
+ P.get_stats().moe_calls.fetch_add(0);
804
+ sdq_shutdown();
805
+
806
+ fprintf(stderr, "\n[sdq] ===== 總結 =====\n");
807
+ fprintf(stderr, "[sdq] prefill: %" PRIu64 " tok / %.2fs = %.1f tok/s\n",
808
+ n_prefill_tok, t_prefill, t_prefill > 0 ? n_prefill_tok / t_prefill : 0.0);
809
+ const uint64_t io_end = read_bytes_io();
810
+ fprintf(stderr, "[sdq] 全程 SSD 實際讀取: %.2f GB\n", (io_end - io_read0) / 1073741824.0);
811
+ print_mem_report("結束時");
812
+
813
+ if (logits_fp) {
814
+ fclose(logits_fp);
815
+ }
816
+ llama_sampler_free(smpl);
817
+ llama_free(ctx);
818
+ llama_model_free(model);
819
+ llama_backend_free();
820
+ return 0;
821
+ }
cpp/sdq_moe.cpp ADDED
@@ -0,0 +1,755 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SDQ 被分頁的 MoE FFN(自訂 ggml op)
2
+ //
3
+ // 為什麼要自己算:上游 build_moe_ffn() 用 ggml_mul_mat_id 直接對
4
+ // blk.N.ffn_{gate,up,down}_exps.weight 這個「三維大張量」做矩陣乘,
5
+ // 權重必須整個在 RAM 裡。20.6 GiB 的專家權重在 8 GB 機器上不可能。
6
+ //
7
+ // 這裡改成:
8
+ // 1. 路由(gate_inp matmul + softmax + top-k + 權重正規化)仍由 ggml 圖算完,
9
+ // 我們只拿 selected_experts [n_expert_used, n_tokens] 與 weights。
10
+ // 2. 每個 (layer, token) 需要的 8 個 expert 由 pager 保證在 RAM:
11
+ // 缺頁 → 從 SSD pread 進 arena(背景執行緒的預取可先後到)。
12
+ // 3. 用 ggml 自己最佳化的量化點積(ggml_vec_dot_*,AVX2/AVX512)逐 expert 算
13
+ // gate/up → SiLU → down → 加權累加。
14
+ // 4. 多執行緒:ith/nth 由 ggml 分配;分頁階段各執行緒各自 pread,餵飽 SSD 佇列深度。
15
+
16
+ #include "sdq_pager.h"
17
+
18
+ #include "ggml-cpu/quants.h"
19
+
20
+ #include <algorithm>
21
+ #include <atomic>
22
+ #include <chrono>
23
+ #include <cmath>
24
+ #include <condition_variable>
25
+ #include <cstdio>
26
+ #include <cstring>
27
+ #include <mutex>
28
+ #include <utility>
29
+ #include <string>
30
+ #include <vector>
31
+
32
+ namespace sdq {
33
+
34
+ // ---------------------------------------------------------------- 效能計時探針
35
+ // 用途:找出「自訂 MoE op」裡面時間到底花在哪裡。開 SDQ_PROFILE_TIME=1 才啟用,
36
+ // 關閉時是零成本(幾個 if constexpr 級別的布林檢查)。
37
+ struct moe_prof {
38
+ std::atomic<uint64_t> op_ns{0}; // 整個 op
39
+ std::atomic<uint64_t> page_ns{0}; // probe / reserve_slot(含鎖)
40
+ std::atomic<uint64_t> setup_ns{0}; // op 前置:unpin_all + 建 tok_of_expert + 前兩道 barrier
41
+ std::atomic<uint64_t> bar_ns{0}; // 所有 barrier 的等待時間
42
+ std::atomic<uint64_t> read_ns{0}; // read_expert_span / acquire 的 I/O
43
+ std::atomic<uint64_t> fwd_ns{0}; // expert_forward 合計
44
+ std::atomic<uint64_t> qact_ns{0}; // 其中:量化 activation
45
+ std::atomic<uint64_t> dot_ns{0}; // 其中:gate/up/down 三次點積
46
+ std::atomic<uint64_t> act_ns{0}; // 其中:SiLU + 加權
47
+ std::atomic<uint64_t> calls{0};
48
+ std::atomic<uint64_t> experts{0};
49
+ std::atomic<uint64_t> grouped_batches{0};
50
+ std::atomic<uint64_t> grouped_tokens{0};
51
+ };
52
+ static moe_prof g_prof;
53
+
54
+ static inline bool prof_on() {
55
+ static const bool on = [] { const char * e = getenv("SDQ_PROFILE_TIME"); return e && *e && *e != '0'; }();
56
+ return on;
57
+ }
58
+ static inline uint64_t now_ns() {
59
+ return (uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(
60
+ std::chrono::steady_clock::now().time_since_epoch()).count();
61
+ }
62
+ #define PROF_T0_x(v) const uint64_t v = prof_on() ? now_ns() : 0
63
+ #define PROF_ADD(field, tv) do { if (prof_on()) g_prof.field += now_ns() - (tv); } while (0)
64
+
65
+ // ---------------------------------------------------------------- 執行緒柵欄
66
+
67
+ class gen_barrier {
68
+ public:
69
+ void sync(int n) {
70
+ PROF_T0_x(_tbar);
71
+ std::unique_lock<std::mutex> lk(mu_);
72
+ const unsigned long g = gen_;
73
+ if (++count_ == n) {
74
+ count_ = 0;
75
+ gen_++;
76
+ cv_.notify_all();
77
+ PROF_ADD(bar_ns, _tbar);
78
+ return;
79
+ }
80
+ cv_.wait(lk, [this, g] { return gen_ != g; });
81
+ PROF_ADD(bar_ns, _tbar);
82
+ }
83
+
84
+ private:
85
+ int count_ = 0;
86
+ unsigned long gen_ = 0;
87
+ std::mutex mu_;
88
+ std::condition_variable cv_;
89
+ };
90
+
91
+ // ---------------------------------------------------------------- 量化點積派送
92
+
93
+ using vec_dot_fn = void (*)(int, float *, size_t, const void *, size_t, const void *, size_t, int);
94
+
95
+ static vec_dot_fn dot_for(ggml_type t) {
96
+ switch (t) {
97
+ case GGML_TYPE_Q4_K: return ggml_vec_dot_q4_K_q8_K;
98
+ case GGML_TYPE_Q5_K: return ggml_vec_dot_q5_K_q8_K;
99
+ case GGML_TYPE_Q6_K: return ggml_vec_dot_q6_K_q8_K;
100
+ case GGML_TYPE_Q3_K: return ggml_vec_dot_q3_K_q8_K;
101
+ case GGML_TYPE_Q2_K: return ggml_vec_dot_q2_K_q8_K;
102
+ case GGML_TYPE_Q8_0: return ggml_vec_dot_q8_0_q8_0;
103
+ case GGML_TYPE_Q4_0: return ggml_vec_dot_q4_0_q8_0;
104
+ case GGML_TYPE_Q5_0: return ggml_vec_dot_q5_0_q8_0;
105
+ case GGML_TYPE_IQ4_NL: return ggml_vec_dot_iq4_nl_q8_0;
106
+ case GGML_TYPE_IQ4_XS: return ggml_vec_dot_iq4_xs_q8_K;
107
+ default: return nullptr;
108
+ }
109
+ }
110
+
111
+ // K-quant 的權重配 Q8_K activation;其餘配 Q8_0(與 ggml 的 vec_dot_type 一致)
112
+ static ggml_type act_type_for(ggml_type w) {
113
+ switch (w) {
114
+ case GGML_TYPE_Q4_K:
115
+ case GGML_TYPE_Q5_K:
116
+ case GGML_TYPE_Q6_K:
117
+ case GGML_TYPE_Q3_K:
118
+ case GGML_TYPE_Q2_K:
119
+ case GGML_TYPE_IQ4_XS:
120
+ return GGML_TYPE_Q8_K;
121
+ default:
122
+ return GGML_TYPE_Q8_0;
123
+ }
124
+ }
125
+
126
+ // 把 activation 量化成對應的 dot 型別
127
+ // 注意:ggml_quantize_chunk() 不支援 Q8_K(Q8_K 只在 mul_mat kernel 內部產生),
128
+ // 所以這裡直接呼叫 ggml-cpu 的 quantize_row_q8_K。
129
+ static void quantize_act(ggml_type t, const float * x, void * dst, int64_t n) {
130
+ switch (t) {
131
+ case GGML_TYPE_Q8_K: quantize_row_q8_K(x, dst, n); break;
132
+ case GGML_TYPE_Q8_0: quantize_row_q8_0(x, dst, n); break;
133
+ default: ggml_quantize_chunk(t, x, dst, 0, 1, n, nullptr); break;
134
+ }
135
+ }
136
+
137
+ // ⚠️ 不要在這裡「批次化」點積。
138
+ // ggml 的 x86 版 K-quant kernel(ggml/src/ggml-cpu/arch/x86/quants.c)開頭就是
139
+ // assert(nrc == 1); UNUSED(nrc); UNUSED(bx); UNUSED(by); UNUSED(bs);
140
+ // 只有 ARM 的 SVE+MATMUL 版本才支援 nrc==2。而 Release 編譯(-DNDEBUG)會把 assert
141
+ // 編譯掉,於是「傳 nrc=16」不會報錯、只會安靜地算第 1 列,其餘 15 列留在記憶體裡
142
+ // 是舊資料 —— 產出看起來合理但數值全錯。
143
+ // (本專案真的踩過:微基準測量到「nrc=16 快 16 倍」,其實是「只做了 1/16 的工作」,
144
+ // 而且權重填 0x5A 讓 checksum 兩邊都是 0,完美掩蓋了錯誤。教訓見 docs/04。)
145
+ // 想要真的多列 SIMD 效能,只能自己寫 AVX512-VNNI 的 kernel,不是靠改參數。
146
+
147
+ // Debug hook: when set, expert_forward writes its gate/up/SwiGLU intermediates for
148
+ // the first token of the call. Used by tools/ to compare an intermediate against
149
+ // NumPy; unset in normal runs so the hot path is unaffected.
150
+ static float * g_dbg_buf = nullptr;
151
+ static int64_t g_dbg_cap = 0;
152
+
153
+ static inline void dot_row(vec_dot_fn fn, const uint8_t * row, const void * xq,
154
+ size_t stride_row, int n, float & acc) {
155
+ float s = 0.0f;
156
+ fn(n, &s, 0, row, stride_row, xq, 0, 1);
157
+ acc += s;
158
+ }
159
+
160
+ // 一次算 SDQ_DOT_BATCH 個輸出列。
161
+ // 為什麼需要:ggml 的 K-quant 點積 kernel 是為「多列」設計的,nrc>1 會走完全不同的
162
+ // SIMD 路徑。實測(tools 裡的 microdot):nrc=1 只有 24~28 GMAC/s,nrc=16 有
163
+ // 347~438 GMAC/s(快 14~16 倍),而且 **checksum 完全相同**(逐位元組等價)。
164
+ // 舊程式逐列呼叫 nrc=1,等於把 14 倍的效能丟掉。
165
+ static const int SDQ_DOT_BATCH = 16;
166
+
167
+ static inline void dot_rows(vec_dot_fn fn, const uint8_t * rows, size_t row_stride,
168
+ const void * xq, int n, int64_t n_rows, float * out) {
169
+ int64_t r = 0;
170
+ for (; r + SDQ_DOT_BATCH <= n_rows; r += SDQ_DOT_BATCH) {
171
+ fn(n, out + r, 1, rows + (size_t) r * row_stride, row_stride, xq, 0, SDQ_DOT_BATCH);
172
+ }
173
+ for (; r < n_rows; r++) {
174
+ fn(n, out + r, 0, rows + (size_t) r * row_stride, row_stride, xq, 0, 1);
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------- 路由紀錄(SDQ_ROUTE_LOG)
179
+ // 用途:量測「連續 k 個 token 的 expert 集合重疊多少」。
180
+ // 這決定多 token 批次(投機解碼 / 批次解碼)能不能省下 SSD 讀取:
181
+ // 每層的關鍵路徑是「有沒有任何一個 expert 沒命中」,所以 U(k) < 8k 就有賺。
182
+ // 格式(每行一次 op 呼叫,空白分欄):
183
+ // <layer> <n_tok> <t0:e0,e1,...> <t1:...> ...
184
+ static void sdq_route_log(int il, int64_t n_tok, const ggml_tensor * ids,
185
+ size_t ids_nb0, size_t ids_nb1, int n_used) {
186
+ static const char * path = getenv("SDQ_ROUTE_LOG");
187
+ if (!path || !*path) {
188
+ return;
189
+ }
190
+ static std::mutex mtx;
191
+ static FILE * f = nullptr;
192
+ {
193
+ std::lock_guard<std::mutex> lk(mtx);
194
+ if (!f) {
195
+ f = fopen(path, "w");
196
+ }
197
+ if (!f) {
198
+ return;
199
+ }
200
+ fprintf(f, "%d %lld", il, (long long) n_tok);
201
+ const uint8_t * base = (const uint8_t *) ids->data;
202
+ for (int64_t t = 0; t < n_tok; t++) {
203
+ fprintf(f, " %lld:", (long long) t);
204
+ for (int k = 0; k < n_used; k++) {
205
+ const int32_t e = *(const int32_t *) (base + (size_t) k * ids_nb0 + (size_t) t * ids_nb1);
206
+ fprintf(f, "%s%d", k ? "," : "", e);
207
+ }
208
+ }
209
+ fputc('\n', f);
210
+ }
211
+ }
212
+
213
+ // ---------------------------------------------------------------- 每層執行紀錄
214
+
215
+ // 重要:input 張量的指標**不能**在建圖時暫存起來 —— llama.cpp 每次 decode 都會
216
+ // 重新建圖,graph context 的配置器可能把張量搬到不同位址(形狀不同時尤其明顯)。
217
+ // 所以 op 執行時一律從 dst->src[] 取得,這才是唯一可靠的來源。
218
+ struct layer_rec {
219
+ int il = -1;
220
+ int64_t n_expert = 0;
221
+ int64_t n_expert_used = 0;
222
+ float swiglu_limit = 0.0f;
223
+
224
+ // 每個 expert 需要處理的 (token, topk 位置) 配對
225
+ std::vector<std::vector<std::pair<int32_t, int32_t>>> tok_of_expert;
226
+ // 每個 token 用到的 (expert, topk 位置) —— token 切分時用(避免輸出競爭)
227
+ std::vector<std::vector<std::pair<int32_t, int32_t>>> exp_of_tok;
228
+ std::vector<int32_t> need; // 本層要用的 expert 編��
229
+ std::vector<int32_t> need_sorted; // 依編號排序(合併讀取用)
230
+ std::vector<int32_t> need_slot; // 對應的 arena 槽位
231
+ std::vector<std::vector<float>> partial; // mode A:每個執行緒一份累加緩衝
232
+ std::vector<int32_t> slot_of_expert; // [expert] -> arena 槽位(-1 = 沒分頁)
233
+ std::vector<int32_t> need_slot_of_expert; // 合併讀取時暫存槽位
234
+ std::vector<int32_t> miss_sorted; // 需要從 SSD 讀的 expert(已排序)
235
+ gen_barrier bar;
236
+ std::mutex mtx;
237
+ bool warned = false;
238
+ };
239
+
240
+ static std::vector<layer_rec *> g_recs;
241
+
242
+ static layer_rec * rec_for(int il) {
243
+ static std::mutex m;
244
+ std::lock_guard<std::mutex> lk(m);
245
+ if ((int) g_recs.size() <= il) {
246
+ g_recs.resize(il + 1, nullptr);
247
+ }
248
+ if (!g_recs[il]) {
249
+ g_recs[il] = new layer_rec();
250
+ g_recs[il]->il = il;
251
+ }
252
+ return g_recs[il];
253
+ }
254
+
255
+ // ---------------------------------------------------------------- 自訂 op 本體
256
+
257
+ // mode C(預設,partial 緩衝 <= 上限):依 expert 流水線,取→算交錯、無中途 barrier。
258
+ // mode B(partial 緩衝太大時的退路):先分頁完再一起算,會有 barrier 等待。
259
+ // 上限給一個很大的值=永遠走 mode C;給 0=強制 mode B(除錯用)。
260
+ static size_t env_partial_cap_mb() {
261
+ const char * e = getenv("SDQ_PARTIAL_CAP_MB");
262
+ return e ? (size_t) atoi(e) : 4096; // MB,預設 4096 MB → 一律 mode C
263
+ }
264
+
265
+ // ---------------------------------------------------------------- 單一 expert 的數學
266
+ // 從 arena 的槽位記憶體算出一個 expert 對多個 token 的輸出(加權後直接累加到 out)
267
+ static void expert_forward(const expert_desc & ed, const uint8_t * base,
268
+ const float * cur, int64_t n_embd,
269
+ const std::vector<std::pair<int32_t, int32_t>> & toks,
270
+ const float * wts, size_t wts_nb0, size_t wts_nb1, int /*n_used*/,
271
+ float swiglu_limit,
272
+ float * out, std::vector<uint8_t> * /*scratch*/) {
273
+ int64_t gate_rows = ed.ne1;
274
+ int64_t up_rows = ed.ne1;
275
+ size_t gate_row = ggml_row_size(ed.type[part::GATE], ed.ne0);
276
+ size_t up_row = gate_row;
277
+ if (ed.fused_gate_up) {
278
+ gate_rows = ed.ne1 / 2;
279
+ up_rows = gate_rows;
280
+ } else {
281
+ up_row = ggml_row_size(ed.type[part::UP], ed.ne0);
282
+ }
283
+
284
+ const vec_dot_fn f_gate = dot_for(ed.type[part::GATE]);
285
+ const vec_dot_fn f_up = ed.fused_gate_up ? f_gate : dot_for(ed.type[part::UP]);
286
+ const vec_dot_fn f_down = dot_for(ed.type[part::DOWN]);
287
+ if (!f_gate || !f_up || !f_down) {
288
+ fprintf(stderr, "[sdq] 不支援的專家量化型別:gate=%s up=%s down=%s\n",
289
+ ggml_type_name(ed.type[part::GATE]),
290
+ ggml_type_name(ed.type[part::UP]),
291
+ ggml_type_name(ed.type[part::DOWN]));
292
+ return;
293
+ }
294
+
295
+ const uint8_t * wg = base + ed.slot_off[part::GATE];
296
+ const uint8_t * wu = base + ed.slot_off[part::UP];
297
+ const uint8_t * wd = base + ed.slot_off[part::DOWN];
298
+ const size_t down_row = ggml_row_size(ed.type[part::DOWN], ed.down_ne0);
299
+
300
+ const ggml_type act_gate = act_type_for(ed.type[part::GATE]);
301
+ const ggml_type act_down = act_type_for(ed.type[part::DOWN]);
302
+ const size_t xq_sz = ggml_row_size(act_gate, ed.ne0);
303
+ const size_t hq_sz = ggml_row_size(act_down, ed.down_ne0);
304
+ std::vector<float> gate_batch(toks.size() * (size_t) gate_rows);
305
+ std::vector<float> up_batch(toks.size() * (size_t) up_rows);
306
+ std::vector<float> h_batch(toks.size() * (size_t) ed.down_ne0);
307
+
308
+ // Pack all activations for this expert contiguously. This keeps the
309
+ // routed token group in one cache-friendly allocation. The current GGML
310
+ // x86 dot ABI is still invoked one activation at a time for correctness.
311
+ std::vector<uint8_t> xq_batch(toks.size() * xq_sz);
312
+ std::vector<uint8_t> hq_batch(toks.size() * hq_sz);
313
+ for (size_t i = 0; i < toks.size(); i++) {
314
+ const int32_t t = toks[i].first;
315
+ PROF_T0_x(_tb);
316
+ quantize_act(act_gate, cur + (size_t) t * n_embd,
317
+ xq_batch.data() + i * xq_sz, ed.ne0);
318
+ PROF_ADD(qact_ns, _tb);
319
+ }
320
+ if (toks.size() > 1) {
321
+ g_prof.grouped_batches.fetch_add(1, std::memory_order_relaxed);
322
+ g_prof.grouped_tokens.fetch_add(toks.size(), std::memory_order_relaxed);
323
+ }
324
+
325
+ PROF_T0_x(_ta); // expert_forward 總計時
326
+ PROF_T0_x(_tc); // gate / up dot products
327
+ // A safe grouped kernel for the current GGML ABI: keep one weight row hot
328
+ // while evaluating every routed token. This is intentionally not calling
329
+ // dot_rows with nrc > 1; x86 K-quant dot functions do not expose a stable
330
+ // multi-activation ABI. A future VNNI kernel can replace only this block.
331
+ for (int64_t r = 0; r < gate_rows; r++) {
332
+ for (size_t ti = 0; ti < toks.size(); ti++) {
333
+ float acc = 0.0f;
334
+ dot_row(f_gate, wg + r * gate_row, xq_batch.data() + ti * xq_sz,
335
+ gate_row, (int) ed.ne0, acc);
336
+ gate_batch[ti * (size_t) gate_rows + (size_t) r] = acc;
337
+ }
338
+ }
339
+ const uint8_t * up_rows_base = ed.fused_gate_up ? wg + gate_rows * gate_row : wu;
340
+ const size_t up_stride = ed.fused_gate_up ? gate_row : up_row;
341
+ for (int64_t r = 0; r < up_rows; r++) {
342
+ for (size_t ti = 0; ti < toks.size(); ti++) {
343
+ float acc = 0.0f;
344
+ dot_row(f_up, up_rows_base + r * up_stride,
345
+ xq_batch.data() + ti * xq_sz, up_stride, (int) ed.ne0, acc);
346
+ up_batch[ti * (size_t) up_rows + (size_t) r] = acc;
347
+ }
348
+ }
349
+ PROF_ADD(dot_ns, _tc);
350
+
351
+ if (g_dbg_buf != nullptr && g_dbg_cap >= 3 * (int64_t) gate_rows) {
352
+ memcpy(g_dbg_buf, gate_batch.data(), sizeof(float) * (size_t) gate_rows);
353
+ memcpy(g_dbg_buf + gate_rows, up_batch.data(), sizeof(float) * (size_t) up_rows);
354
+ }
355
+
356
+ for (size_t ti = 0; ti < toks.size(); ti++) {
357
+ const int32_t t = toks[ti].first;
358
+ const int32_t k = toks[ti].second;
359
+ PROF_T0_x(_td); // SiLU + activation quantization
360
+ for (int64_t r = 0; r < ed.down_ne0; r++) {
361
+ const float g = gate_batch[ti * (size_t) gate_rows + (size_t) r];
362
+ const float u = up_batch[ti * (size_t) up_rows + (size_t) r];
363
+ float act;
364
+ if (swiglu_limit > 1e-6f) {
365
+ const float uc = std::max(-swiglu_limit, std::min(swiglu_limit, u));
366
+ float gc = g / (1.0f + std::exp(-g));
367
+ gc = std::max(-swiglu_limit, std::min(swiglu_limit, gc));
368
+ act = gc * uc;
369
+ } else {
370
+ act = (g / (1.0f + std::exp(-g))) * u;
371
+ }
372
+ h_batch[ti * (size_t) ed.down_ne0 + (size_t) r] = act;
373
+ }
374
+ if (g_dbg_buf != nullptr && ti == 0 && g_dbg_cap >= 3 * (int64_t) gate_rows) {
375
+ memcpy(g_dbg_buf + 2 * gate_rows, h_batch.data(), sizeof(float) * (size_t) ed.down_ne0);
376
+ }
377
+ quantize_act(act_down, h_batch.data() + ti * (size_t) ed.down_ne0,
378
+ hq_batch.data() + ti * hq_sz, ed.down_ne0);
379
+ PROF_ADD(act_ns, _td);
380
+
381
+ const float w = *(const float *) ((const uint8_t *) wts + k * wts_nb0 + (size_t) t * wts_nb1);
382
+ float * o = out + (size_t) t * n_embd;
383
+ for (int64_t r = 0; r < ed.down_ne1; r++) {
384
+ float acc = 0.0f;
385
+ dot_row(f_down, wd + r * down_row, hq_batch.data() + ti * hq_sz,
386
+ down_row, (int) ed.down_ne0, acc);
387
+ o[r] += acc * w;
388
+ }
389
+ }
390
+ PROF_ADD(fwd_ns, _ta);
391
+ }
392
+
393
+
394
+ static void moe_custom_op(ggml_tensor * dst, int ith, int nth, void * userdata) {
395
+ layer_rec * R = (layer_rec *) userdata;
396
+ pager & P = pager::instance();
397
+
398
+ // 從 op 的 src 取得輸入(保證是「這一次」真的被計算的張量)
399
+ const ggml_tensor * src_cur = dst->src[0];
400
+ const ggml_tensor * src_ids = dst->src[1];
401
+ const ggml_tensor * src_wts = dst->src[2];
402
+ if (!src_cur || !src_ids || !src_wts) {
403
+ if (ith == 0) {
404
+ fprintf(stderr, "[sdq] op 缺少 src(%d)\n", R->il);
405
+ }
406
+ return;
407
+ }
408
+
409
+ const layer_desc * L = P.layer(R->il);
410
+ PROF_T0_x(_tg); // op 總計時
411
+ g_prof.calls++;
412
+ const int64_t n_embd = src_cur->ne[0];
413
+ const int64_t n_tok = src_cur->ne[2];
414
+ const int64_t n_used = R->n_expert_used;
415
+ // ids 是 2D [n_expert_used, n_tokens] → 用 nb[0]/nb[1]
416
+ // wts 是 3D [1, n_expert_used, n_tokens] → 用 nb[1]/nb[2](nb[0] 會是 4 而不是 n_used*4!)
417
+ const size_t ids_nb0 = src_ids->nb[0], ids_nb1 = src_ids->nb[1];
418
+ const size_t wts_nb0 = src_wts->nb[src_wts->ne[0] == 1 ? 1 : 0];
419
+ const size_t wts_nb1 = src_wts->nb[src_wts->ne[0] == 1 ? 2 : 1];
420
+ const float * cur = (const float *) src_cur->data;
421
+ const float * wts = (const float *) src_wts->data;
422
+
423
+ if (!L || L->experts.empty() || dst->type != GGML_TYPE_F32) {
424
+ if (ith == 0 && !R->warned) {
425
+ R->warned = true;
426
+ fprintf(stderr, "[sdq] 層 %d 沒有可用的分頁描述\n", R->il);
427
+ }
428
+ return;
429
+ }
430
+
431
+ if (R->il == 0 && getenv("SDQ_DBG_OP")) {
432
+ fprintf(stderr, "[sdq-op] L%d n_tok=%lld nth=%d need=%d ids_nb1=%zu wts_nb1=%zu\n",
433
+ R->il, (long long) n_tok, nth, (int) R->need.size(), ids_nb1, wts_nb1);
434
+ }
435
+ // ---- 1) 解除上一層的 pin(mode B 直接累加輸出,需要先清空)
436
+ PROF_T0_x(_tsetup);
437
+ if (ith == 0) {
438
+ P.unpin_all();
439
+ }
440
+ if (nth > 1) { R->bar.sync(nth); }
441
+
442
+ // ---- 2) 依 expert 分組 token(只有執行緒 0 做,其餘在此等待)
443
+ if (ith == 0) {
444
+ std::lock_guard<std::mutex> lk(R->mtx);
445
+ R->tok_of_expert.assign((size_t) R->n_expert, {});
446
+ R->exp_of_tok.assign((size_t) n_tok, {});
447
+ const uint8_t * ids_base = (const uint8_t *) src_ids->data;
448
+ for (int64_t t = 0; t < n_tok; t++) {
449
+ for (int64_t k = 0; k < n_used; k++) {
450
+ const int32_t e = *(const int32_t *) (ids_base + k * ids_nb0 + (size_t) t * ids_nb1);
451
+ if (e >= 0 && e < R->n_expert) {
452
+ auto & v = R->tok_of_expert[(size_t) e];
453
+ bool seen = false;
454
+ for (const auto & p : v) {
455
+ if (p.first == (int32_t) t) { seen = true; break; }
456
+ }
457
+ if (!seen) {
458
+ v.emplace_back((int32_t) t, (int32_t) k);
459
+ }
460
+ R->exp_of_tok[(size_t) t].emplace_back(e, (int32_t) k);
461
+ }
462
+ }
463
+ }
464
+ R->need.clear();
465
+ for (int64_t e = 0; e < R->n_expert; e++) {
466
+ if (!R->tok_of_expert[(size_t) e].empty()) {
467
+ R->need.push_back((int32_t) e);
468
+ }
469
+ }
470
+ R->need_sorted = R->need;
471
+ std::sort(R->need_sorted.begin(), R->need_sorted.end());
472
+ R->need_slot.assign(R->need.size(), -1); // 只能由 ith==0 配置,其餘執行緒唯讀
473
+ R->slot_of_expert.assign((size_t) R->n_expert, -1);
474
+ R->need_slot_of_expert.assign((size_t) R->n_expert, -1);
475
+ R->miss_sorted.clear();
476
+ R->miss_sorted.reserve((size_t) R->n_expert);
477
+ if (R->partial.size() < 64) {
478
+ R->partial.resize(64);
479
+ }
480
+ sdq_route_log(R->il, n_tok, src_ids, ids_nb0, ids_nb1, (int) n_used);
481
+ }
482
+ if (nth > 1) { R->bar.sync(nth); }
483
+ PROF_ADD(setup_ns, _tsetup);
484
+
485
+ const int n_need = (int) R->need.size();
486
+
487
+ // ---- 3+4) 分頁與計算融合成一條 pipeline
488
+ //
489
+ // 關鍵效能點:不要「先把整層的 expert 都讀完再開始算」。做法是每個執行緒
490
+ // 自己依序處理它負責的 expert:讀一個 → 立刻算它 → 再讀下一個。
491
+ // 這樣 SSD 讀取和 CPU 計算自然重疊,而且 8 條執行緒各自發出 pread,
492
+ // SSD 佇列深度也夠(不需要再靠 queue depth)。
493
+ //
494
+ // 平行切分:mode A 依 expert 切(每個執行緒有自己的累加緩衝,最後相加);
495
+ // mode B 依 token 切(同一個 token 的所有 expert 由同一執行緒算,
496
+ // 但必須先把該 token 的 expert 都備齊,所以先分頁再計算)。
497
+ const int64_t out_elems = n_embd * n_tok;
498
+ const size_t partial_bytes = (size_t) out_elems * 4 * (size_t) nth;
499
+ const bool mode_a = partial_bytes <= (size_t) env_partial_cap_mb() << 20;
500
+ float * out = (float *) dst->data;
501
+
502
+ if (!mode_a) {
503
+ memset(dst->data, 0, (size_t) ggml_nbytes(dst));
504
+ }
505
+
506
+ if (mode_a) {
507
+ // ===================== mode C(預設):依 expert 流水線 =====================
508
+ // 三個設計重點,每個都來自實測:
509
+ //
510
+ // (1) **取→算交錯,中間沒有 barrier**。
511
+ // 舊的 mode B 是「先把整層 8 個 expert 全部讀完 → barrier → 才開始算」。
512
+ // 量測顯示 decode 有 66% 的時間卡在 barrier 上等最慢的執行緒
513
+ // (SDQ_PROFILE_TIME 的 barrier 等待 = op 時間的 70%)。改成交錯後
514
+ // 讀取與計算自然重疊,實測 2.79 → 3.49 tok/s。
515
+ //
516
+ // (2) **分頁走 acquire(),不用 reserve_slot + read_expert_span**。
517
+ // reserve_slot() 會在資料還沒讀進來之前就把 (layer,expert) publish 到
518
+ // page_ 表;一旦該層的 pread 失敗或被中斷,這個槽就會留在 page_ 裡
519
+ // 卻是舊資料,之後的 probe() 會把它當成命中 → 靜默算錯。
520
+ // acquire() 是「先讀完再 publish」,成本相同但不會有這個失效模式。
521
+ //
522
+ // (3) **i += nth 交錯分配**,不是切成連續區塊。
523
+ // 每個 expert 的 SSD 延遲差異很大,交錯分配讓快執行緒自然多幫忙。
524
+ //
525
+ // 輸出不直接寫 dst(同一個 token 的不同 expert 會撞在一起),
526
+ // 所以仍然用「每執行緒一份累加緩衝 + 最後歸約」。
527
+ std::vector<float> & mine = R->partial[(size_t) ith];
528
+ mine.assign((size_t) out_elems, 0.0f);
529
+ std::vector<uint8_t> scratch;
530
+
531
+ // 在取本層的 expert **之前**先替下一層排預取。
532
+ //
533
+ // 實測結論(S7):**預設關閉,而且量到開啟反而慢 8%。**
534
+ // 預測來自 prev_used_[il+1](上一個 token 在該層選的專家)。想用它躲在
535
+ // 「本層的讀取+計算」後面,但量到的結果是:
536
+ // 開(5.68 tok/s,預取使用率 0.48 %)vs 關(6.20 tok/s,0.28 %)
537
+ // 命中率幾乎一樣(70.5 % vs 70.7 %),但 SSD 位元組從 201.5 升到 216.0 MB/token
538
+ // → 預測不夠準,多讀的全是白讀,而且還要和本層的 demand 讀搶頻寬。
539
+ // 另外開啟時同一提示詞的輸出不一致 → 預取佔用的槽���(busy)會讓
540
+ // acquire() 偶爾拿不到槽位而**靜默跳過一個 expert**,這是正確性問題。
541
+ //
542
+ // 保留程式碼是為了讓後人不用重新發明;文件見 docs/DESIGN.md §3.3。
543
+ // SDQ_PREFETCH_LAYER_AHEAD=1 可以打開來重現那個負結果。
544
+ static const bool layer_ahead = [] {
545
+ const char * e = getenv("SDQ_PREFETCH_LAYER_AHEAD");
546
+ return e ? atoi(e) != 0 : false;
547
+ }();
548
+ if (layer_ahead && ith == 0) {
549
+ P.prefetch_layer_ahead(R->il + 1);
550
+ }
551
+
552
+ for (int i = ith; i < n_need; i += nth) {
553
+ const int32_t e = R->need[(size_t) i];
554
+ const int32_t sl = P.acquire(R->il, e, true);
555
+ if (sl < 0) {
556
+ continue;
557
+ }
558
+ P.note_used(R->il, e);
559
+ expert_forward(L->experts[(size_t) e], P.slot_ptr(sl), cur, n_embd,
560
+ R->tok_of_expert[(size_t) e], wts, wts_nb0, wts_nb1,
561
+ (int) n_used, R->swiglu_limit, mine.data(), &scratch);
562
+ // expert_forward 回傳後這個槽位就沒人讀了(最後的歸約只讀 mine),
563
+ // 所以立刻放掉 pin,不要讓它被釘到下一層開頭。
564
+ // 不放的話 prefill 一層會把整層用到的 expert 全部釘住(最多 256 個),
565
+ // 大槽位不夠時就會中止(見 pager::release 的說明)。
566
+ P.release(sl);
567
+ }
568
+
569
+ if (nth > 1) { R->bar.sync(nth); }
570
+ for (int64_t idx = ith; idx < out_elems; idx += nth) {
571
+ float acc = 0.0f;
572
+ for (int k = 0; k < nth; k++) {
573
+ acc += R->partial[(size_t) k][(size_t) idx];
574
+ }
575
+ out[idx] = acc;
576
+ }
577
+ } else {
578
+ // mode B:token 多到 partial 緩衝太大時,依 token 切分
579
+ // 先把所有需要的 expert 分頁(各執行緒平行 pread)
580
+ {
581
+ PROF_T0_x(_th); // 分頁 / SSD 讀取
582
+ for (int i = ith; i < n_need; i += nth) {
583
+ const int32_t e = R->need[(size_t) i];
584
+ const int s = P.acquire(R->il, e, true);
585
+ g_prof.experts++;
586
+ R->need_slot[(size_t) i] = s;
587
+ if (s >= 0) {
588
+ R->slot_of_expert[(size_t) e] = s;
589
+ P.note_used(R->il, e);
590
+ }
591
+ }
592
+ PROF_ADD(read_ns, _th);
593
+ }
594
+ if (nth > 1) { R->bar.sync(nth); }
595
+ const float * cur_p = (const float *) src_cur->data;
596
+ const float * wts_p = (const float *) src_wts->data;
597
+ for (int64_t t = ith; t < n_tok; t += nth) {
598
+ std::vector<uint8_t> scratch;
599
+ for (const auto & ek : R->exp_of_tok[(size_t) t]) {
600
+ const int slot = R->slot_of_expert[(size_t) ek.first];
601
+ if (slot < 0) {
602
+ continue;
603
+ }
604
+ std::vector<std::pair<int32_t, int32_t>> one = {{(int32_t) t, ek.second}};
605
+ expert_forward(L->experts[(size_t) ek.first], P.slot_ptr(slot), cur_p, n_embd,
606
+ one, wts_p, wts_nb0, wts_nb1, (int) n_used,
607
+ R->swiglu_limit, out, &scratch);
608
+ }
609
+ }
610
+ }
611
+
612
+ // 除錯:把某一層的實際輸入/輸出 dump 出來,用 NumPy 對照。
613
+ // 必須先 barrier:上面的歸約是由各執行緒分片寫 out 的,沒有這一道會讀到半成品。
614
+ if (nth > 1) { R->bar.sync(nth); }
615
+ if (const char * dump_path = getenv("SDQ_MOE_DUMP")) {
616
+ const int dump_layer = getenv("SDQ_MOE_DUMP_LAYER") ? atoi(getenv("SDQ_MOE_DUMP_LAYER")) : -1;
617
+ if ((dump_layer < 0 || R->il == dump_layer) && ith == 0) {
618
+ std::string path = std::string(dump_path) + "." + std::to_string(R->il);
619
+ FILE * f = fopen(path.c_str(), "wb");
620
+ if (f) {
621
+ const int32_t meta[6] = {(int32_t) n_embd, (int32_t) n_tok, (int32_t) n_used,
622
+ (int32_t) R->n_expert, 1, 2};
623
+ fwrite(meta, sizeof(int32_t), 6, f);
624
+ fwrite(cur, sizeof(float), (size_t) (n_embd * n_tok), f);
625
+ const uint8_t * ids_base = (const uint8_t *) src_ids->data;
626
+ for (int64_t t = 0; t < n_tok; t++) {
627
+ for (int64_t k = 0; k < n_used; k++) {
628
+ const int32_t e = *(const int32_t *) (ids_base + k * ids_nb0 + (size_t) t * ids_nb1);
629
+ fwrite(&e, sizeof(int32_t), 1, f);
630
+ }
631
+ }
632
+ fwrite(wts, sizeof(float), (size_t) (n_used * n_tok), f);
633
+ fwrite(out, sizeof(float), (size_t) (n_embd * n_tok), f);
634
+ fclose(f);
635
+ fprintf(stderr, "[sdq] dumped layer %d -> %s\n", R->il, path.c_str());
636
+ }
637
+ }
638
+ }
639
+
640
+ if (nth > 1) { R->bar.sync(nth); }
641
+ PROF_ADD(op_ns, _tg);
642
+ }
643
+
644
+ // 輸出計時結果(sdq_shutdown 呼叫)
645
+ void sdq_print_time_profile() {
646
+ if (!prof_on()) {
647
+ return;
648
+ }
649
+ const double ms = 1e6;
650
+ auto v = [ms](const std::atomic<uint64_t> & a) { return a.load() / ms; };
651
+ const double op = v(g_prof.op_ns);
652
+ const double fwd = v(g_prof.fwd_ns);
653
+ fprintf(stderr, "[sdq-prof] 自訂 MoE op(%llu 次呼叫、%llu 個 expert 分頁)\n",
654
+ (unsigned long long) g_prof.calls.load(), (unsigned long long) g_prof.experts.load());
655
+ fprintf(stderr, "[sdq-prof] op 合計 %9.1f ms\n", op);
656
+ fprintf(stderr, "[sdq-prof] expert_forward %9.1f ms (%5.1f%%) 其中:\n", fwd, op > 0 ? 100.0 * fwd / op : 0.0);
657
+ fprintf(stderr, "[sdq-prof] grouped batches=%llu routed_tokens=%llu\n",
658
+ (unsigned long long) g_prof.grouped_batches.load(),
659
+ (unsigned long long) g_prof.grouped_tokens.load());
660
+ fprintf(stderr, "[sdq-prof] 量化 activation%9.1f ms (%5.1f%% of fwd)\n", v(g_prof.qact_ns), fwd > 0 ? 100.0 * v(g_prof.qact_ns) / fwd : 0.0);
661
+ fprintf(stderr, "[sdq-prof] 三次點積 %9.1f ms (%5.1f%% of fwd)\n", v(g_prof.dot_ns), fwd > 0 ? 100.0 * v(g_prof.dot_ns) / fwd : 0.0);
662
+ fprintf(stderr, "[sdq-prof] SiLU+加權 %9.1f ms (%5.1f%% of fwd)\n", v(g_prof.act_ns), fwd > 0 ? 100.0 * v(g_prof.act_ns) / fwd : 0.0);
663
+ fprintf(stderr, "[sdq-prof] SSD 讀取/分頁 %9.1f ms (%5.1f%%)\n", v(g_prof.read_ns), op > 0 ? 100.0 * v(g_prof.read_ns) / op : 0.0);
664
+ fprintf(stderr, "[sdq-prof] 前置 setup %9.1f ms (%5.1f%%)\n", v(g_prof.setup_ns), op > 0 ? 100.0 * v(g_prof.setup_ns) / op : 0.0);
665
+ fprintf(stderr, "[sdq-prof] barrier 等待 %9.1f ms (%5.1f%%)\n", v(g_prof.bar_ns), op > 0 ? 100.0 * v(g_prof.bar_ns) / op : 0.0);
666
+ }
667
+
668
+ // ---------------------------------------------------------------- graph 建構
669
+
670
+ ggml_tensor * sdq_build_moe(ggml_cgraph * gf, ggml_context * ctx, ggml_tensor * cur,
671
+ ggml_tensor * ids, ggml_tensor * weights, int il, int64_t n_expert,
672
+ int64_t n_expert_used, float swiglu_limit) {
673
+ layer_rec * R = rec_for(il);
674
+ R->n_expert = n_expert;
675
+ R->n_expert_used = n_expert_used;
676
+ R->swiglu_limit = swiglu_limit;
677
+
678
+ // 呼叫端給的是 2D [n_embd, n_tokens],這裡跟上游一樣先 reshape 成 3D
679
+ // [n_embd, 1, n_tokens](上游 mul_mat_id 路徑也是這樣拿 token 數的)
680
+ ggml_tensor * cur3 = (cur->ne[1] == 1) ? cur : ggml_reshape_3d(ctx, cur, cur->ne[0], 1, cur->ne[1]);
681
+ const int64_t n_tok = cur3->ne[2];
682
+
683
+ ggml_tensor * args[3] = { cur3, ids, weights };
684
+ ggml_tensor * out = ggml_custom_4d(ctx, GGML_TYPE_F32, cur3->ne[0], n_tok, 1, 1,
685
+ args, 3, moe_custom_op, GGML_N_TASKS_MAX, R);
686
+ ggml_set_name(out, "sdq_moe_out");
687
+ // 關鍵:把這個節點註冊進圖,ggml 才會為它配置獨立的 compute buffer。
688
+ // 少了這一行,layer>=1 的輸出會被其他張量覆蓋(layer 0 僥倖正確)。
689
+ ggml_build_forward_expand(gf, out);
690
+ return out;
691
+ }
692
+
693
+ } // namespace sdq
694
+ namespace sdq {
695
+ // 自我測試:對指定 (layer, expert) 用 pager 的資料算一次 MoE 輸出,
696
+ // 讓 Python 用純 NumPy 從同一段 GGUF 位元組重算並比對。
697
+ // 輸出:out[0..n_embd) = 輸出,out[n_embd..2*n_embd) = 輸入 activation
698
+ int sdq_moe_selftest(int il, int ie, const float * x, float * out_io,
699
+ int64_t n_embd, int64_t n_ff, int n_tok) {
700
+ pager & P = pager::instance();
701
+ const layer_desc * L = P.layer(il);
702
+ if (!L || ie < 0 || ie >= (int) L->experts.size()) {
703
+ fprintf(stderr, "[sdq-selftest] 層/expert 不存在:%d/%d\n", il, ie);
704
+ return 1;
705
+ }
706
+ const int slot = P.acquire(il, ie, true);
707
+ if (slot < 0) {
708
+ fprintf(stderr, "[sdq-selftest] 分頁失敗\n");
709
+ return 2;
710
+ }
711
+ const expert_desc & ed = L->experts[(size_t) ie];
712
+ if (ed.ne0 != n_embd || ed.ne1 != n_ff) {
713
+ fprintf(stderr, "[sdq-selftest] 維度不符:ne0=%lld ne1=%lld(預期 %lld/%lld)\n",
714
+ (long long) ed.ne0, (long long) ed.ne1, (long long) n_embd, (long long) n_ff);
715
+ return 3;
716
+ }
717
+ std::vector<std::pair<int32_t, int32_t>> toks;
718
+ std::vector<float> wts((size_t) n_tok, 1.0f);
719
+ for (int t = 0; t < n_tok; t++) {
720
+ toks.emplace_back(t, 0);
721
+ }
722
+ std::vector<float> out((size_t) n_embd * n_tok, 0.0f);
723
+ std::vector<uint8_t> scratch;
724
+ std::vector<float> dbg;
725
+ if (getenv("SDQ_SELFTEST_DBG")) {
726
+ dbg.assign((size_t) (3 * n_ff), 0.0f);
727
+ g_dbg_buf = dbg.data();
728
+ g_dbg_cap = 3 * n_ff;
729
+ }
730
+ expert_forward(ed, P.slot_ptr(slot), x, n_embd, toks, wts.data(), 4, 4, 1, 0.0f,
731
+ out.data(), &scratch);
732
+ g_dbg_buf = nullptr;
733
+ if (!dbg.empty()) {
734
+ std::string dp = std::string("sdq-dbg-") + std::to_string(il) + "-" + std::to_string(ie) + ".bin";
735
+ FILE * df = fopen(dp.c_str(), "wb");
736
+ if (df) {
737
+ fwrite(dbg.data(), sizeof(float), dbg.size(), df);
738
+ fclose(df);
739
+ fprintf(stderr, "[sdq-selftest] intermediates -> %s (gate/up/h each %lld)\n",
740
+ dp.c_str(), (long long) n_ff);
741
+ }
742
+ }
743
+ for (int64_t t = 0; t < n_tok; t++) {
744
+ for (int64_t i = 0; i < n_embd; i++) {
745
+ out_io[t * n_embd + i] = out[(size_t) t * n_embd + i];
746
+ out_io[n_tok * n_embd + t * n_embd + i] = x[(size_t) t * n_embd + i];
747
+ }
748
+ }
749
+ P.unpin_all();
750
+ fprintf(stderr, "[sdq-selftest] L%d E%d n_tok=%d 計算完成(gate %s / up %s / down %s)\n",
751
+ il, ie, n_tok, ggml_type_name(ed.type[part::GATE]), ggml_type_name(ed.type[part::UP]),
752
+ ggml_type_name(ed.type[part::DOWN]));
753
+ return 0;
754
+ }
755
+ } // namespace sdq
cpp/sdq_mtp.cpp ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // EPQ target with Qwen3.6 MTP draft context.
2
+ #include "llama.h"
3
+ #include "sdq_pager.h"
4
+ #include "common.h"
5
+ #include "sampling.h"
6
+ #include "speculative.h"
7
+
8
+ #include <algorithm>
9
+ #include <chrono>
10
+ #include <cstdio>
11
+ #include <cstdlib>
12
+ #include <string>
13
+ #include <vector>
14
+
15
+ using namespace sdq;
16
+
17
+ namespace {
18
+ struct opt_t { std::string model, prompt; int ctx=4096, threads=8, n=48, draft_n=2, ram=8192, ubatch=128; bool no_pf=false; };
19
+ bool parse(int argc, char ** argv, opt_t & o) {
20
+ auto next = [&](int & i) -> const char * { return ++i < argc ? argv[i] : nullptr; };
21
+ for (int i=1; i<argc; i++) {
22
+ const std::string a=argv[i]; const char * v=nullptr;
23
+ if (a=="-m" || a=="--model") { if (!(v=next(i))) return false; o.model=v; }
24
+ else if (a=="-p" || a=="--prompt") { if (!(v=next(i))) return false; o.prompt=v; }
25
+ else if (a=="-n") { if (!(v=next(i))) return false; o.n=atoi(v); }
26
+ else if (a=="--draft-n") { if (!(v=next(i))) return false; o.draft_n=atoi(v); }
27
+ else if (a=="-c") { if (!(v=next(i))) return false; o.ctx=atoi(v); }
28
+ else if (a=="-t") { if (!(v=next(i))) return false; o.threads=atoi(v); }
29
+ else if (a=="-b") { if (!(v=next(i))) return false; o.ubatch=atoi(v); }
30
+ else if (a=="--ram-budget-mb") { if (!(v=next(i))) return false; o.ram=atoi(v); }
31
+ else if (a=="--no-prefetch") o.no_pf=true;
32
+ else return false;
33
+ }
34
+ return !o.model.empty() && !o.prompt.empty();
35
+ }
36
+ double now_s() { return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count(); }
37
+ }
38
+
39
+ int main(int argc, char ** argv) {
40
+ opt_t o;
41
+ if (!parse(argc, argv, o)) return 1;
42
+ setenv("SDQ_MODEL_PATH", o.model.c_str(), 1);
43
+ setenv("SDQ_IO_THREADS", std::to_string(o.threads*3).c_str(), 1);
44
+ setenv("SDQ_RAM_BUDGET_MB", std::to_string(o.ram).c_str(), 1);
45
+ setenv("SDQ_FADV_DONTNEED", "1", 1);
46
+ if (o.no_pf) setenv("SDQ_PREFETCH", "0", 1);
47
+ llama_backend_init();
48
+ llama_numa_init(GGML_NUMA_STRATEGY_DISABLED);
49
+ llama_model_params mp=llama_model_default_params();
50
+ mp.n_gpu_layers=0; mp.load_mode=LLAMA_LOAD_MODE_MMAP; mp.load_mtp=true;
51
+ sdq_set_enabled(true); sdq_install_buft_overrides(&mp);
52
+ llama_model * model=llama_model_load_from_file(o.model.c_str(), mp);
53
+ if (!model || !sdq_init(o.model)) return 2;
54
+ llama_context_params c=llama_context_default_params();
55
+ c.n_ctx=o.ctx; c.n_seq_max=1; c.n_batch=std::max(o.ubatch,o.draft_n+1); c.n_ubatch=o.ubatch;
56
+ c.n_outputs_max=o.draft_n+1; c.n_outputs_max_per_seq=o.draft_n+1;
57
+ c.n_threads=o.threads; c.n_threads_batch=o.threads; c.ctx_type=LLAMA_CONTEXT_TYPE_DEFAULT;
58
+ llama_context * ctx=llama_init_from_model(model,c);
59
+ if (!ctx) return 3;
60
+
61
+ common_params p;
62
+ p.model.path=o.model; p.n_ctx=o.ctx; p.n_batch=c.n_batch; p.n_ubatch=o.ubatch;
63
+ p.cpuparams.n_threads=o.threads; p.cpuparams_batch.n_threads=o.threads;
64
+ p.speculative.types={COMMON_SPECULATIVE_TYPE_DRAFT_MTP};
65
+ p.speculative.draft.n_max=o.draft_n;
66
+ auto init=common_speculative_init_from_params(p,model,ctx);
67
+ llama_context * dctx=init->context();
68
+ p.speculative.draft.ctx_tgt=ctx; p.speculative.draft.ctx_dft=dctx;
69
+ if (!dctx) { fprintf(stderr,"[sdq-mtp] MTP context init failed\n"); return 4; }
70
+ common_speculative * spec=common_speculative_init(p.speculative,1);
71
+ if (!spec) { fprintf(stderr,"[sdq-mtp] speculative init failed\n"); return 5; }
72
+ common_params_sampling sp; sp.temp=0; sp.top_k=1;
73
+ common_sampler * sampler=common_sampler_init(model,sp);
74
+ auto input=common_tokenize(ctx,o.prompt,true,true);
75
+ if (input.empty()) return 6;
76
+ common_batch pb(ctx);
77
+ for (size_t i=0;i+1<input.size();i++) pb.add(input[i],(llama_pos)i,0,false);
78
+ if (pb.size() && llama_process(ctx,LLAMA_PROCESS_TYPE_DECODE,pb.get())!=0) return 7;
79
+ pager::instance().end_token(); common_speculative_process(spec,pb);
80
+ llama_tokens prompt(input.begin(),input.end()-1); common_speculative_begin(spec,0,prompt);
81
+ llama_token last=input.back(); int past=(int)prompt.size(), gen=0, drafted=0, accepted=0;
82
+ common_prompt_checkpoint ck; llama_tokens draft;
83
+ const double t0=now_s(); printf("assistant: "); fflush(stdout);
84
+ while (gen<o.n) {
85
+ ck.update_pos(prompt.size(),llama_memory_seq_pos_min(llama_get_memory(ctx),0),llama_memory_seq_pos_max(llama_get_memory(ctx),0));
86
+ ck.update_tgt(ctx,0,LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY); ck.update_dft(dctx,0,LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
87
+ if (draft.empty()) { auto & dp=common_speculative_get_draft_params(spec,0); dp={}; dp.drafting=true; dp.n_max=std::min(o.draft_n,o.n-gen-1); dp.pos0=past; dp.id_last=last; dp.prompt=&prompt; dp.result=&draft; common_speculative_draft(spec); }
88
+ ck.load_dft(dctx,0,LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
89
+ common_batch vb(ctx); vb.add(last,past++,0,true); for(size_t i=0;i<draft.size();i++) vb.add(draft[i],past+(llama_pos)i,0,true);
90
+ if (llama_process(ctx,LLAMA_PROCESS_TYPE_DECODE,vb.get())!=0) break;
91
+ pager::instance().end_token();
92
+ if(!common_speculative_process(spec,vb)) break;
93
+ auto ids=common_sampler_sample_and_accept_n(sampler,ctx,draft); if(ids.empty()) break;
94
+ drafted+=(int)draft.size(); accepted+=(int)ids.size()-1;
95
+ if(ids.size()-1<draft.size()) { ck.load_tgt(ctx,0,LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY); ck.load_dft(dctx,0,LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY); prompt.resize(ck.n_tokens); past=ck.n_tokens; draft=std::move(ids); continue; }
96
+ for(auto id:ids) { if(gen>=o.n) break; printf("%s",common_token_to_piece(ctx,id).c_str()); gen++; prompt.push_back(last); last=id; }
97
+ common_speculative_accept(spec,0,(uint16_t)(ids.size()-1)); past+=(int)ids.size()-1;
98
+ }
99
+ printf("\n"); double dt=now_s(); fprintf(stderr,"[sdq-mtp] generated=%d drafted=%d accepted=%d acceptance=%.1f%% speed=%.2f tok/s\n",gen,drafted,accepted,drafted?100.0*accepted/drafted:0,dt-t0?gen/(dt-t0):0);
100
+ common_speculative_print_stats(spec); common_speculative_free(spec); common_sampler_free(sampler); sdq_shutdown(); llama_free(ctx); llama_model_free(model); llama_backend_free(); return 0;
101
+ }
cpp/sdq_pager.cpp ADDED
@@ -0,0 +1,2091 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SDQ expert pager 實作:見 sdq_pager.h 的設計說明
2
+ #include "sdq_pager.h"
3
+
4
+ #include <algorithm>
5
+ #include <cinttypes>
6
+ #include <cmath>
7
+ #include <cstdio>
8
+ #include <cstdlib>
9
+ #include <cstring>
10
+ #include <fcntl.h>
11
+ #include <ctime>
12
+ #include <fstream>
13
+ #include <map>
14
+ #include <sstream>
15
+ #include <stdexcept>
16
+ #include <sys/mman.h>
17
+ #include <sys/resource.h>
18
+ #include <sys/stat.h>
19
+ #include <sys/types.h>
20
+ #include <unistd.h>
21
+
22
+ #if defined(SDQ_HAVE_LIBURING)
23
+ #include <liburing.h>
24
+ #endif
25
+
26
+ namespace sdq {
27
+
28
+ // ============================================================ 小工具
29
+
30
+ uint64_t now_us() {
31
+ struct timespec ts;
32
+ clock_gettime(CLOCK_MONOTONIC, &ts);
33
+ return (uint64_t) ts.tv_sec * 1000000ull + (uint64_t) ts.tv_nsec / 1000ull;
34
+ }
35
+
36
+ size_t process_rss_bytes() {
37
+ std::ifstream f("/proc/self/statm");
38
+ size_t total = 0, resident = 0;
39
+ if (f >> total >> resident) {
40
+ return resident * (size_t) sysconf(_SC_PAGESIZE);
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ size_t process_anon_bytes() {
46
+ // RssAnon 是 kernel 維護的精確計數器:不可回收、真正佔住實體 RAM 的部分。
47
+ // arena sizing 必須用這個而不是 statm 的 resident,原因見 alloc_arena()。
48
+ std::ifstream f("/proc/self/status");
49
+ std::string key;
50
+ size_t value = 0;
51
+ while (f >> key) {
52
+ if (key == "RssAnon:") {
53
+ if (f >> value) {
54
+ return value * 1024;
55
+ }
56
+ return 0;
57
+ }
58
+ f.ignore(4096, '\n');
59
+ }
60
+ // 沒有 RssAnon 的舊核心:退回 statm,把 shared 視為可回收的近似值。
61
+ std::ifstream g("/proc/self/statm");
62
+ size_t total = 0, resident = 0, shared = 0;
63
+ if (g >> total >> resident >> shared) {
64
+ const size_t page = (size_t) sysconf(_SC_PAGESIZE);
65
+ return (resident > shared ? resident - shared : 0) * page;
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ size_t enforced_memory_ceiling_bytes() {
71
+ // 部署時真正管用的是 kernel 強制的上限,不是 CLI 上的一個數字。
72
+ // RLIMIT_DATA 在 Linux 4.7 之後涵蓋 brk + 私有匿名 mmap,正好等於
73
+ // 「這支程式最多能用多少不可回收的記憶體」;RLIMIT_AS 會把 22 GB 的
74
+ // 檔案對映也算進來,那不是我們要的語意(檔案頁是可回收的)。
75
+ struct rlimit rl;
76
+ for (int res : { RLIMIT_DATA }) {
77
+ if (getrlimit(res, &rl) == 0 && rl.rlim_cur != RLIM_INFINITY) {
78
+ return (size_t) rl.rlim_cur;
79
+ }
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ size_t physical_ram_bytes() {
85
+ std::ifstream f("/proc/meminfo");
86
+ std::string key;
87
+ size_t value = 0;
88
+ std::string unit;
89
+ while (f >> key >> value >> unit) {
90
+ if (key == "MemTotal:") {
91
+ return value * 1024;
92
+ }
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ static size_t env_size(const char * name, size_t dflt) {
98
+ const char * v = getenv(name);
99
+ if (!v || !*v) {
100
+ return dflt;
101
+ }
102
+ return (size_t) strtoull(v, nullptr, 10);
103
+ }
104
+
105
+ static bool env_flag(const char * name, bool dflt) {
106
+ const char * v = getenv(name);
107
+ if (!v || !*v) {
108
+ return dflt;
109
+ }
110
+ return !(v[0] == '0' && v[1] == '\0');
111
+ }
112
+
113
+ // ============================================================ GGUF 檔頭解析
114
+
115
+ namespace {
116
+
117
+ struct gguf_reader {
118
+ const uint8_t * p = nullptr;
119
+ size_t size = 0;
120
+ size_t o = 0;
121
+
122
+ uint8_t u8() { return p[o++]; }
123
+ uint32_t u32() { uint32_t v; memcpy(&v, p + o, 4); o += 4; return v; }
124
+ uint64_t u64() { uint64_t v; memcpy(&v, p + o, 8); o += 8; return v; }
125
+ int64_t i64() { int64_t v; memcpy(&v, p + o, 8); o += 8; return v; }
126
+ float f32() { float v; memcpy(&v, p + o, 4); o += 4; return v; }
127
+ std::string str() {
128
+ uint64_t n = u64();
129
+ std::string s((const char *) p + o, (size_t) n);
130
+ o += n;
131
+ return s;
132
+ }
133
+ void skip(uint64_t n) { o += (size_t) n; }
134
+ };
135
+
136
+ struct kv_value {
137
+ enum kind { NONE, INT, REAL, STR, ARR } k = NONE;
138
+ int64_t i = 0;
139
+ double f = 0;
140
+ std::string s;
141
+ std::vector<int64_t> arr;
142
+ };
143
+
144
+ struct tensor_info {
145
+ std::string name;
146
+ std::vector<int64_t> dims;
147
+ ggml_type type = GGML_TYPE_COUNT;
148
+ uint64_t offset = 0;
149
+ };
150
+
151
+ size_t ggml_type_size_of(ggml_type t) {
152
+ return ggml_row_size(t, 1);
153
+ }
154
+
155
+ } // namespace
156
+
157
+ // ============================================================ pager
158
+
159
+ pager & pager::instance() {
160
+ static pager p;
161
+ return p;
162
+ }
163
+
164
+ void pager::load_gguf_header(const std::string & path) {
165
+ const int fd = open(path.c_str(), O_RDONLY);
166
+ if (fd < 0) {
167
+ throw std::runtime_error("無法開啟 GGUF:" + path);
168
+ }
169
+
170
+ struct stat st;
171
+ fstat(fd, &st);
172
+
173
+ // 只讀檔頭:metadata + tensor 資訊大約 11 MB
174
+ const size_t want = (size_t) std::min<int64_t>(st.st_size, 64ull << 20);
175
+ std::vector<uint8_t> buf(want);
176
+ ssize_t got = 0;
177
+ while (got < (ssize_t) want) {
178
+ ssize_t n = pread(fd, buf.data() + got, want - got, got);
179
+ if (n <= 0) {
180
+ break;
181
+ }
182
+ got += n;
183
+ }
184
+ buf.resize((size_t) std::max<ssize_t>(got, 0));
185
+
186
+ gguf_reader r{buf.data(), buf.size(), 0};
187
+ if (memcmp(buf.data(), "GGUF", 4) != 0) {
188
+ close(fd);
189
+ throw std::runtime_error("不是 GGUF 檔:" + path);
190
+ }
191
+ r.o = 4;
192
+ const uint32_t version = r.u32();
193
+ if (version < 2 || version > 3) {
194
+ close(fd);
195
+ throw std::runtime_error("不支援的 GGUF 版本");
196
+ }
197
+ const uint64_t n_tensors = r.u64();
198
+ const uint64_t n_kv = r.u64();
199
+
200
+ std::map<std::string, kv_value> kv;
201
+ for (uint64_t i = 0; i < n_kv; i++) {
202
+ std::string key = r.str();
203
+ const uint32_t t = r.u32();
204
+ kv_value v;
205
+ switch (t) {
206
+ case 0: v.k = kv_value::INT; v.i = (int64_t) r.u32(); v.f = (double) v.i; break;
207
+ case 1: v.k = kv_value::INT; v.i = (int64_t) (int8_t) r.u32(); v.f = (double) v.i; break;
208
+ case 2: v.k = kv_value::INT; v.i = r.u32(); v.f = (double) v.i; break;
209
+ case 3: v.k = kv_value::INT; v.i = (int16_t) r.u32(); v.f = (double) v.i; break;
210
+ case 4: v.k = kv_value::INT; v.i = (int64_t) (uint32_t) r.u32(); v.f = (double) v.i; break;
211
+ case 5: v.k = kv_value::INT; v.i = (int32_t) r.u32(); v.f = (double) v.i; break;
212
+ case 6: v.k = kv_value::REAL; v.f = r.f32(); v.i = (int64_t) v.f; break;
213
+ case 7: v.k = kv_value::INT; v.i = r.u8(); v.f = (double) v.i; break; // BOOL = 1 byte
214
+ case 8: v.k = kv_value::STR; v.s = r.str(); break;
215
+ case 9: {
216
+ v.k = kv_value::ARR;
217
+ const uint32_t et = r.u32();
218
+ const uint64_t n = r.u64();
219
+ for (uint64_t j = 0; j < n; j++) {
220
+ switch (et) {
221
+ case 2: v.arr.push_back(r.u32()); break;
222
+ case 3: v.arr.push_back((int16_t) r.u32()); break;
223
+ case 4: v.arr.push_back((uint32_t) r.u32()); break;
224
+ case 5: v.arr.push_back((int32_t) r.u32()); break;
225
+ case 6: v.arr.push_back((int64_t) r.f32()); break;
226
+ // BOOL is 1 byte. Reading it as u32 desyncs the whole
227
+ // header parser (e.g. qwen35moe.attention.recurrent_layers).
228
+ case 7: v.arr.push_back((int64_t) r.u8()); break;
229
+ case 10: v.arr.push_back((int64_t) r.u64()); break;
230
+ case 11: v.arr.push_back(r.i64()); break;
231
+ case 12: v.arr.push_back((int64_t) (double) 0); break; // 極少見
232
+ case 8: r.skip(r.u64()); v.arr.push_back(0); break;
233
+ // Silently skipping an unknown element type would leave
234
+ // r.o pointing into the middle of the value and corrupt
235
+ // every later key. Fail loudly instead.
236
+ default:
237
+ close(fd);
238
+ throw std::runtime_error("GGUF array element type 不支援: "
239
+ + std::to_string(et));
240
+ }
241
+ }
242
+ } break;
243
+ case 10: v.k = kv_value::INT; v.i = (int64_t) r.u64(); v.f = (double) v.i; break;
244
+ case 11: v.k = kv_value::INT; v.i = r.i64(); v.f = (double) v.i; break;
245
+ case 12: v.k = kv_value::REAL; { double d; memcpy(&d, buf.data() + r.o, 8); r.o += 8; v.f = d; v.i = (int64_t) d; } break;
246
+ default: close(fd); throw std::runtime_error("GGUF kv 型別不支援: " + std::to_string(t));
247
+ }
248
+ kv[key] = v;
249
+ }
250
+
251
+ std::vector<tensor_info> tensors;
252
+ tensors.reserve(n_tensors);
253
+ for (uint64_t i = 0; i < n_tensors; i++) {
254
+ tensor_info ti;
255
+ ti.name = r.str();
256
+ const uint32_t nd = r.u32();
257
+ for (uint32_t d = 0; d < nd; d++) {
258
+ ti.dims.push_back((int64_t) r.u64());
259
+ }
260
+ ti.type = (ggml_type) r.u32();
261
+ ti.offset = r.u64();
262
+ tensors.push_back(std::move(ti));
263
+ }
264
+ const uint64_t align = kv.count("general.alignment") ? (uint64_t) kv["general.alignment"].i : 32;
265
+ const uint64_t data_start = (r.o + align - 1) / align * align;
266
+
267
+ close(fd);
268
+
269
+ // ---- hparams
270
+ auto gi = [&](const std::string & k, int64_t dflt) -> int64_t {
271
+ auto it = kv.find(k);
272
+ return it == kv.end() ? dflt : it->second.i;
273
+ };
274
+ info_.arch = kv.count("general.architecture") ? kv["general.architecture"].s : "";
275
+ info_.name = kv.count("general.name") ? kv["general.name"].s : "";
276
+ const std::string pfx = info_.arch + ".";
277
+ info_.n_layer = gi(pfx + "block_count", 0);
278
+ info_.n_expert = gi(pfx + "expert_count", 0);
279
+ info_.n_expert_used= gi(pfx + "expert_used_count", 0);
280
+ info_.n_embd = gi(pfx + "embedding_length", 0);
281
+ info_.n_ff_exp = gi(pfx + "expert_feed_forward_length", 0);
282
+ info_.n_ff_shexp = gi(pfx + "expert_shared_feed_forward_length", 0);
283
+ info_.n_head = gi(pfx + "attention.head_count", 0);
284
+ info_.n_head_kv = gi(pfx + "attention.head_count_kv", 0);
285
+ info_.n_embd_head_k= gi(pfx + "attention.key_length", 0);
286
+ info_.n_embd_head_v= gi(pfx + "attention.value_length", info_.n_embd_head_k);
287
+ info_.context_length = gi(pfx + "context_length", 0);
288
+ info_.full_attention_interval = gi(pfx + "full_attention_interval", 4);
289
+
290
+ if (info_.n_expert <= 0 || info_.n_layer <= 0) {
291
+ throw std::runtime_error("這不是 MoE 模型(expert_count=" + std::to_string(info_.n_expert) + ")");
292
+ }
293
+
294
+ // ---- 頁面表:每個 (layer, expert) 的三段
295
+ layers_.assign((size_t) info_.n_layer, {});
296
+ std::map<std::string, const tensor_info *> by_name;
297
+ for (const auto & ti : tensors) {
298
+ by_name[ti.name] = &ti;
299
+ }
300
+
301
+ size_t max_expert_bytes = 0;
302
+ for (int64_t il = 0; il < info_.n_layer; il++) {
303
+ const std::string b = "blk." + std::to_string(il) + ".";
304
+ layer_desc & L = layers_[(size_t) il];
305
+ L.experts.assign((size_t) info_.n_expert, {});
306
+
307
+ auto get = [&](const std::string & suffix) -> const tensor_info * {
308
+ auto it = by_name.find(b + suffix);
309
+ return it == by_name.end() ? nullptr : it->second;
310
+ };
311
+
312
+ const tensor_info * t_gate = get("ffn_gate_exps.weight");
313
+ const tensor_info * t_up = get("ffn_up_exps.weight");
314
+ const tensor_info * t_gu = get("ffn_gate_up_exps.weight");
315
+ const tensor_info * t_down = get("ffn_down_exps.weight");
316
+
317
+ if (!t_down || (t_gate && !t_up && !t_gu)) {
318
+ continue; // 這層不是 MoE(理論上不會發生)
319
+ }
320
+
321
+ for (int64_t e = 0; e < info_.n_expert; e++) {
322
+ expert_desc & ed = L.experts[(size_t) e];
323
+ if (t_gate || t_gu) {
324
+ const tensor_info * t = t_gate ? t_gate : t_gu;
325
+ const uint64_t nb = (uint64_t) ggml_row_size(t->type, t->dims[0]) * (uint64_t) t->dims[1];
326
+ ed.seg[part::GATE].off = data_start + t->offset + (uint64_t) e * nb;
327
+ ed.seg[part::GATE].size = nb;
328
+ ed.ne0 = t->dims[0];
329
+ ed.ne1 = t->dims[1];
330
+ ed.type[part::GATE] = t->type;
331
+ }
332
+ if (t_up) {
333
+ const uint64_t nb = (uint64_t) ggml_row_size(t_up->type, t_up->dims[0]) * (uint64_t) t_up->dims[1];
334
+ ed.seg[part::UP].off = data_start + t_up->offset + (uint64_t) e * nb;
335
+ ed.seg[part::UP].size = nb;
336
+ ed.type[part::UP] = t_up->type; // gate/up 都是 Q4_K,型別一定要各自填
337
+ if (!t_gate) {
338
+ ed.ne0 = t_up->dims[0];
339
+ ed.ne1 = t_up->dims[1];
340
+ ed.type[part::GATE] = t_up->type;
341
+ }
342
+ }
343
+ {
344
+ const uint64_t nb = (uint64_t) ggml_row_size(t_down->type, t_down->dims[0]) * (uint64_t) t_down->dims[1];
345
+ ed.seg[part::DOWN].off = data_start + t_down->offset + (uint64_t) e * nb;
346
+ ed.seg[part::DOWN].size = nb;
347
+ ed.type[part::DOWN] = t_down->type;
348
+ ed.down_ne0 = t_down->dims[0];
349
+ ed.down_ne1 = t_down->dims[1];
350
+ }
351
+ ed.fused_gate_up = (t_gate == nullptr && t_gu != nullptr);
352
+ ed.bytes = 0;
353
+ int64_t acc = 0;
354
+ for (int p = 0; p < 3; p++) {
355
+ if (ed.seg[p].size == 0) {
356
+ continue;
357
+ }
358
+ acc = (acc + 63) & ~(int64_t) 63; // 每段 64B 對齊
359
+ ed.slot_off[p] = acc;
360
+ acc += (int64_t) ed.seg[p].size;
361
+ }
362
+ ed.bytes = acc;
363
+ max_expert_bytes = std::max(max_expert_bytes, (size_t) ed.bytes);
364
+ }
365
+ L.bytes_per_expert = L.experts.empty() ? 0 : L.experts[0].bytes;
366
+ }
367
+
368
+ slot_bytes_ = (max_expert_bytes + 4095) & ~(size_t) 4095;
369
+ if (slot_bytes_ < (256ull << 10)) {
370
+ slot_bytes_ = 256ull << 10;
371
+ }
372
+
373
+ // ---- 大小兩種槽位 ----------------------------------------------------
374
+ // 這個模型有 37 層的 expert 是 1900544 B(Q4_K + Q5_K),但層 34/38/39 是
375
+ // 2039808 B(down 換成 Q6_K)。若所有槽位都照最大的配,arena 有 6.8% 被浪費
376
+ // ——而 arena 就是 8 GB 預算裡最寶貴的資源(實測 +5% 槽位 ≈ +9% tok/s)。
377
+ //
378
+ // 解法:槽位分成兩區。「小槽位」給 37 層用,「大槽位」只給那 3 層用,
379
+ // 大槽位的數量按「那 3 層佔請求的比例」分配(3/40 = 7.5%)。
380
+ // 選「大多數層」當小的:用眾數而不是最大值,這樣即使某個模型只有一種大小,
381
+ // 也不會誤判。
382
+ {
383
+ std::map<size_t, int> hist;
384
+ for (const auto & L : layers_) {
385
+ hist[(size_t) L.bytes_per_expert]++;
386
+ }
387
+ size_t small = 0;
388
+ int best_n = -1;
389
+ for (const auto & kv : hist) {
390
+ if (kv.second > best_n) {
391
+ best_n = kv.second;
392
+ small = kv.first;
393
+ }
394
+ }
395
+ size_t big = 0;
396
+ for (const auto & L : layers_) {
397
+ if ((size_t) L.bytes_per_expert > small) {
398
+ big = std::max(big, (size_t) L.bytes_per_expert);
399
+ }
400
+ }
401
+ small_bytes_ = (small + 4095) & ~(size_t) 4095;
402
+ big_bytes_ = big ? ((big + 4095) & ~(size_t) 4095) : 0;
403
+ if (big_bytes_ && big_bytes_ <= small_bytes_) {
404
+ big_bytes_ = 0; // 其實一樣大,別分兩區
405
+ }
406
+ slot_bytes_ = big_bytes_ ? big_bytes_ : small_bytes_;
407
+ int n_big_layers = 0;
408
+ for (auto & L : layers_) {
409
+ L.needs_big_slot = big_bytes_ && (size_t) L.bytes_per_expert > small_bytes_;
410
+ if (L.needs_big_slot) {
411
+ n_big_layers++;
412
+ }
413
+ }
414
+ fprintf(stderr,
415
+ "[sdq] 槽位大小:小的 %zu B(%zu 層)/大的 %zu B(%d 層)\n",
416
+ small_bytes_, (int) layers_.size() - n_big_layers, big_bytes_, n_big_layers);
417
+ }
418
+ }
419
+
420
+ bool pager::need_big_slot(int il, int ie) const {
421
+ if (!big_bytes_) {
422
+ return false;
423
+ }
424
+ const layer_desc * L = layer(il);
425
+ if (!L) {
426
+ return false;
427
+ }
428
+ if (L->needs_big_slot) {
429
+ return true;
430
+ }
431
+ return ie >= 0 && ie < (int) L->experts.size() &&
432
+ (size_t) L->experts[(size_t) ie].bytes > small_bytes_;
433
+ }
434
+
435
+ void pager::alloc_arena() {
436
+ const size_t slot = cfg_.slot_bytes ? cfg_.slot_bytes : slot_bytes_;
437
+ slot_bytes_ = slot;
438
+
439
+ // ---- arena 大小怎麼決定
440
+ //
441
+ // 預算 = 這個行程「最多可以用多少不可回收的記憶體」,也就是部署機器的實體
442
+ // RAM 預算。來源只有 --ram-budget-mb / SDQ_RAM_BUDGET_MB,沒有就退回實體
443
+ // RAM 的 70%。
444
+ //
445
+ // 這裡「不」把 RLIMIT_DATA 當成預算來源,是刻意的:RLIMIT_DATA 算的是匿名
446
+ // **虛擬**記憶體,而 llama.cpp 會配置大量「映射了但永遠不會 touch」的圖與
447
+ // compute buffer。實測(ctx=4096、ubatch=128)那些沒碰過的映射約 1.5 GiB,
448
+ // 拿它當預算會讓 arena 無謂地少 1.5 GiB。
449
+ // RLIMIT_DATA 只用來當**上界**:使用者給的預算比 kernel 的上限還大時,
450
+ // 以 kernel 的為準,因為那才是真的會配置失敗的界線。
451
+ size_t budget = cfg_.ram_budget_bytes;
452
+ const size_t ceiling = enforced_memory_ceiling_bytes();
453
+ if (ceiling) {
454
+ budget = budget ? std::min(budget, ceiling) : 0;
455
+ if (budget) {
456
+ fprintf(stderr, "[sdq] RAM 預算取 min(--ram-budget-mb, RLIMIT_DATA)=%.2f GiB\n",
457
+ budget / 1073741824.0);
458
+ }
459
+ }
460
+
461
+ // ---- 已用量要用「匿名 RSS」,不是「總 RSS」
462
+ //
463
+ // statm 的 resident 把 mmap 進來的模型權重頁也算進去,但那些是乾淨的
464
+ // file-backed 頁:記憶體吃緊時核心會直接回收它們,既不佔用寶貴的實體 RAM,
465
+ // 也不會產生 swap I/O(cgroup memory.max 的行為也是回收而不是換出)。
466
+ // 把它們算進「已用」會讓 arena 被無謂地縮水 —— 實測在 8 GiB 上多算了
467
+ // 約 1.8 GiB,白白少掉近 1000 個槽位。
468
+ // 真正會跟 arena 搶實體 RAM 的,是 arena / KV cache / compute buffer / 執行堆疊,
469
+ // 也就是匿名記憶體。
470
+ const size_t rss_now = process_rss_bytes();
471
+ const size_t anon_now = process_anon_bytes();
472
+ const size_t used = anon_now ? anon_now : rss_now;
473
+
474
+ // 預留量要涵蓋 arena 之外、但在 pager sizing 之後才長出來的記憶體:
475
+ // * 非專家權重:sizing 的當下只有一部分被 touch 進來,其餘在第一次
476
+ // forward 時才 fault in(Qwen3.6-35B-A3B 約 1 GiB 的 VA)
477
+ // * llama.cpp 的 CPU compute buffer(125 MiB @ubatch=128、502 MiB @512)
478
+ // * KV cache(ctx=4096 約 80 MiB)與圖、執行緒堆疊
479
+ // 舊版固定抓 900 MB,而且扣的是「總 RSS」——兩個問題疊在一起,讓 8 GiB
480
+ // 預算下的 arena 只有 3.68 GiB。
481
+ //
482
+ // 640 MB 是用強制 8 GiB 實測校正出來的(tools/strict8g.py,見 docs/09):
483
+ // 預留 380 MB → 峰值匿名 RSS 8151 MB(離 8192 只剩 41 MB,太緊)
484
+ // 預留 560 MB → 峰值匿名 RSS 7979 MB(餘 213 MB)
485
+ // 預留 640 MB → 峰值匿名 RSS 約 7900 MB(餘約 290 MB)
486
+ // 預留 660 MB → 峰值匿名 RSS 7873 MB(餘 319 MB)
487
+ // 仍然可用 SDQ_RESERVE_MB 覆寫;換 ctx 或 ubatch 時必須重新校正,
488
+ // 因為 KV cache 與 compute buffer 會跟著長大。
489
+ const size_t reserve = (size_t) env_size("SDQ_RESERVE_MB", 640) << 20;
490
+
491
+ size_t arena;
492
+ if (budget) {
493
+ size_t avail = budget > used + reserve ? budget - used - reserve : 512ull << 20;
494
+ arena = avail;
495
+ if (cfg_.arena_bytes) {
496
+ arena = std::min(arena, cfg_.arena_bytes);
497
+ }
498
+ fprintf(stderr,
499
+ "[sdq] RAM 預算 %.2f GiB:已用(匿名 RSS) %.2f GiB / 總 RSS %.2f GiB、"
500
+ "預留 %.2f GiB → arena %.2f GiB\n",
501
+ budget / 1073741824.0,
502
+ used / 1073741824.0, rss_now / 1073741824.0,
503
+ reserve / 1073741824.0, arena / 1073741824.0);
504
+ } else {
505
+ const size_t phys = physical_ram_bytes();
506
+ const size_t phys_budget = (size_t) (phys * 0.70);
507
+ arena = cfg_.arena_bytes ? cfg_.arena_bytes : std::min(phys_budget, (size_t) (4ull << 30));
508
+ }
509
+ if (arena < slot_bytes_ * 16) {
510
+ throw std::runtime_error("expert arena 太小");
511
+ }
512
+ // 切成兩區:大槽位只分給「需要大槽位的層」,比例 = 那些層佔全部層的比例。
513
+ const bool force_uniform = env_flag("SDQ_FORCE_UNIFORM_SLOT", false);
514
+ if (big_bytes_ && small_bytes_ && !layers_.empty() && !force_uniform) {
515
+ int n_big_layers = 0;
516
+ for (const auto & L : layers_) {
517
+ if (L.needs_big_slot) {
518
+ n_big_layers++;
519
+ }
520
+ }
521
+ const double f = (double) n_big_layers / (double) layers_.size();
522
+ const double avg = f * (double) big_bytes_ + (1.0 - f) * (double) small_bytes_;
523
+ size_t n_total = (size_t)((double) arena / avg);
524
+ size_t nb = (size_t)(n_total * f);
525
+ // 大槽位的下限不是「有點就好」,而是**工作集的下限**:
526
+ // 一個 token 在每個大槽位層要 8 個大 expert,這個模型有 3 個大槽位層
527
+ // → 至少要 3 × 8 = 24 個大槽位,而且還要留給預取的空間。
528
+ // 只給 16 個時量到過「找不到能放 il=34 ie=114 的槽位」而中止
529
+ // (這是在 verify 新增的預取壓力測試 [2b] 抓到的)。
530
+ const size_t big_need = (size_t) (n_big_layers * info_.n_expert_used);
531
+ if (nb < big_need) {
532
+ nb = big_need;
533
+ }
534
+ if (nb > n_total / 4) {
535
+ nb = n_total / 4;
536
+ }
537
+ // 連工作集下限都放不進去時,寧可讓小槽位少一點(n_total 保持不變),
538
+ // 否則會算出一個「必定會中止」的配置。
539
+ n_big_ = nb;
540
+ n_small_ = n_total > nb ? n_total - nb : 0;
541
+ arena = n_small_ * small_bytes_ + n_big_ * big_bytes_;
542
+ } else {
543
+ // 單一尺寸:全部槽位都是「大的那一種」(slot_bytes_)。
544
+ // 這是 SDQ_FORCE_UNIFORM_SLOT=1 與「模型只有一種 expert 大小」時的路徑。
545
+ //
546
+ // 這裡必須記成 n_small_=0 / n_big_=全部,不能記成「n_small_=全部、單位用大的」:
547
+ // slot_ptr()/slot_size_of() 是用「索引 < n_small_ → 小槽位」來分支的,
548
+ // 把它記成全部都是小槽位的話,大 expert 會拿到小槽位的 stride
549
+ // (寫出槽位邊界、算錯),而且大 expert 永遠配不到槽位 → 推論卡死。
550
+ // (第一版就是這樣,實驗跑到一半 hang 才找出來。)
551
+ const size_t unit = slot_bytes_ ? slot_bytes_ : small_bytes_;
552
+ n_small_ = 0;
553
+ n_big_ = arena / unit;
554
+ arena = n_big_ * unit;
555
+ }
556
+ if (n_small_ == 0 && n_big_ == 0) {
557
+ throw std::runtime_error("expert arena 太小(算不出槽位)");
558
+ }
559
+ n_slots_ = n_small_ + n_big_;
560
+ if (n_slots_ < 16) {
561
+ throw std::runtime_error("expert arena 太小(算不出 16 個槽位)");
562
+ }
563
+
564
+ void * p = nullptr;
565
+ const size_t alloc = arena + (2ull << 20);
566
+ if (posix_memalign(&p, 2ull << 20, alloc) != 0 || !p) {
567
+ throw std::runtime_error("無法配置 expert arena(" + std::to_string(arena >> 20) + " MB)");
568
+ }
569
+ madvise(p, alloc, MADV_HUGEPAGE);
570
+
571
+ // 只「預先碰」有可能真的被用到的部分,不要碰整個 arena。
572
+ //
573
+ // arena 是快取,不是工作集:它的上限由 RAM 預算決定,但它的**使用量**被
574
+ // 路由決定 —— 一個 expert 只會在它被選中時被寫入,而且全部路由專家權重
575
+ // 加起來只有 expert_total(Qwen3.6-35B-A3B 約 18.22 GiB)。
576
+ // 舊版無條件 memset 整個 arena,等於要 core 為「永遠不會被用到」的配置
577
+ // 也實體化一份 RAM。實測:--ram-budget-mb 65536 會讓行程在啟動時就吃掉
578
+ // 64.8 GiB RSS(雖然它最多只會用到約 18 GiB),在 64 GiB 單一行程上限的
579
+ // 主機上直接被殺,而且光是 memset 就要花掉 20 秒以上的啟動時間。
580
+ //
581
+ // 預先碰觸的好處是真的(decode 期間不會卡在 page fault 與 THP 配置上),
582
+ // 所以保留 —— 但上限就是 expert_total,多出來的不碰。
583
+ size_t expert_total = 0;
584
+ for (const auto & L : layers_) {
585
+ expert_total += (size_t) L.experts.size() * (size_t) L.bytes_per_expert;
586
+ }
587
+ size_t prefault = alloc;
588
+ if (expert_total && prefault > expert_total + (2ull << 20)) {
589
+ prefault = expert_total + (2ull << 20);
590
+ }
591
+ // SDQ_PREFAULT_MB 可強制指定;給 0 = 完全不預先碰(純 lazy)。
592
+ const size_t pf_mb = env_size("SDQ_PREFAULT_MB", (size_t) -1);
593
+ if (pf_mb != (size_t) -1) {
594
+ prefault = std::min(alloc, pf_mb << 20);
595
+ }
596
+ memset(p, 0, prefault);
597
+ arena_ = (uint8_t *) p;
598
+ arena_bytes_ = arena;
599
+ fprintf(stderr, "[sdq] arena %.2f GiB(%zu 槽):預先碰 %.2f GiB%s\n",
600
+ arena / 1073741824.0, n_slots_, prefault / 1073741824.0,
601
+ prefault < alloc ? "(其餘用到才碰)" : "");
602
+
603
+ // OPT-1:剖面計數表(n_layer × n_expert,40×256 = 10240 筆 ≈ 40 KB,可忽略)
604
+ prof_.assign((size_t) info_.n_layer * (size_t) info_.n_expert, 0);
605
+ if (!cfg_.profile_in.empty()) {
606
+ load_profile(cfg_.profile_in);
607
+ }
608
+ profile_out_ = cfg_.profile_out;
609
+
610
+ slots_.assign(n_slots_, {});
611
+ free_slots_.reserve(n_small_);
612
+ for (size_t i = n_small_; i-- > 0;) {
613
+ free_slots_.push_back((int32_t) i);
614
+ }
615
+ free_big_slots_.reserve(n_big_);
616
+ for (size_t i = n_slots_; i-- > n_small_;) {
617
+ free_big_slots_.push_back((int32_t) i);
618
+ }
619
+ used_.store(0);
620
+
621
+ hist_.assign((size_t) info_.n_layer, {});
622
+ for (auto & per_layer : hist_) {
623
+ per_layer.assign((size_t) info_.n_expert, {});
624
+ }
625
+ prev_used_.assign((size_t) info_.n_layer, {});
626
+ cur_used_.assign((size_t) info_.n_layer, {});
627
+ }
628
+
629
+ void pager::init(const config & cfg, ggml_context * ctx) {
630
+ (void) ctx;
631
+ if (ready_) {
632
+ return;
633
+ }
634
+ cfg_ = cfg;
635
+ if (cfg_.model_path.empty()) {
636
+ const char * v = getenv("SDQ_MODEL_PATH");
637
+ cfg_.model_path = v ? v : "";
638
+ }
639
+ if (cfg_.model_path.empty()) {
640
+ throw std::runtime_error("SDQ 缺少模型路徑(設定 SDQ_MODEL_PATH)");
641
+ }
642
+
643
+ open_model_fd();
644
+ if (fd_ < 0) {
645
+ throw std::runtime_error("無法開啟模型檔:" + cfg_.model_path);
646
+ }
647
+
648
+ load_gguf_header(cfg_.model_path);
649
+ // 順序有意義:I/O 池的對齊緩衝必須在 alloc_arena() 之前配置,
650
+ // 否則 arena 會把它當成「不存在的 RAM」,實際上就偷用了 8 GB 預算。
651
+ io_pool_start();
652
+ alloc_arena();
653
+
654
+ if (cfg_.prefetch) {
655
+ pf_ = std::thread([this] { pf_worker(); });
656
+ }
657
+ ready_ = true;
658
+ print_summary();
659
+ }
660
+
661
+ const layer_desc * pager::layer(int il) const {
662
+ if (il < 0 || il >= (int) layers_.size()) {
663
+ return nullptr;
664
+ }
665
+ const layer_desc & L = layers_[(size_t) il];
666
+ if (L.experts.empty()) {
667
+ return nullptr;
668
+ }
669
+ return &L;
670
+ }
671
+
672
+ void pager::open_model_fd() {
673
+ fd_ = open(cfg_.model_path.c_str(), O_RDONLY);
674
+ if (fd_ < 0) {
675
+ return;
676
+ }
677
+ // O_DIRECT:讀資料完全不進 kernel page cache。
678
+ // 這是「RAM 帳目誠實」的關鍵 —— 否則核心預讀會讓 mmap 檔案的頁算進 RSS
679
+ // (實測 4K prefill 時檔案對映 RSS 可以多到 6 GB 以上),而且量到的
680
+ // 「SSD 讀取量」會被上一輪的 page cache 灌水。
681
+ const char * no_direct = getenv("SDQ_NO_DIRECT_IO");
682
+ if (no_direct && *no_direct && *no_direct != '0') {
683
+ return;
684
+ }
685
+ #ifdef O_DIRECT
686
+ fd_direct_ = open(cfg_.model_path.c_str(), O_RDONLY | O_DIRECT);
687
+ if (fd_direct_ >= 0) {
688
+ use_direct_ = true;
689
+ fprintf(stderr, "[sdq] 使用 O_DIRECT 讀取模型檔(不佔用 kernel page cache)\n");
690
+ } else {
691
+ fprintf(stderr, "[sdq] O_DIRECT 不可用,改用 buffered + fadvise(DONTNEED)\n");
692
+ }
693
+ #endif
694
+ }
695
+
696
+ // 有對齊的緩衝區(O_DIRECT 要求),thread_local 避免競爭
697
+ static thread_local uint8_t * g_direct_buf = nullptr;
698
+ static thread_local size_t g_direct_sz = 0;
699
+
700
+ // 與 read_range_direct 相同,但用呼叫端提供的緩衝區(已配置好、已算進 RSS)。
701
+ // I/O 執行緒池走這條:每條 worker 在啟動時就配置好對齊緩衝,於是 arena sizing 時
702
+ // 這筆記憶體已經在 RSS 裡,不會變成「額外偷用的 RAM」。
703
+ bool pager::read_range_staged(uint64_t off, uint8_t * dst, size_t size,
704
+ uint8_t * stage, size_t stage_sz) {
705
+ if (!use_direct_) {
706
+ return read_range(off, dst, size);
707
+ }
708
+ const size_t blk = 4096;
709
+ const uint64_t start = off & ~(uint64_t)(blk - 1);
710
+ const uint64_t end = (off + size + blk - 1) & ~(uint64_t)(blk - 1);
711
+ const size_t bytes = (size_t)(end - start);
712
+ if (stage_sz < bytes) {
713
+ // 預先配置不足(不該發生):退回 thread_local 的通用路徑
714
+ return read_range_direct(off, dst, size);
715
+ }
716
+ size_t done = 0;
717
+ while (done < bytes) {
718
+ const ssize_t n = pread(fd_direct_, stage + done, bytes - done,
719
+ (off_t)(start + done));
720
+ if (n <= 0) {
721
+ return false;
722
+ }
723
+ done += (size_t) n;
724
+ }
725
+ memcpy(dst, stage + (off - start), size);
726
+ return true;
727
+ }
728
+
729
+ bool pager::read_range_direct(uint64_t off, uint8_t * dst, size_t size) {
730
+ if (!use_direct_) {
731
+ return read_range(off, dst, size);
732
+ }
733
+ const size_t blk = 4096;
734
+ const uint64_t start = off & ~(uint64_t)(blk - 1);
735
+ const uint64_t end = (off + size + blk - 1) & ~(uint64_t)(blk - 1);
736
+ const size_t bytes = (size_t)(end - start);
737
+ if (g_direct_sz < bytes) {
738
+ if (g_direct_buf) {
739
+ free(g_direct_buf);
740
+ }
741
+ if (posix_memalign((void **) &g_direct_buf, blk, bytes) != 0) {
742
+ g_direct_buf = nullptr;
743
+ g_direct_sz = 0;
744
+ use_direct_ = false;
745
+ return read_range(off, dst, size);
746
+ }
747
+ g_direct_sz = bytes;
748
+ }
749
+ size_t done = 0;
750
+ while (done < bytes) {
751
+ const ssize_t n = pread(fd_direct_, g_direct_buf + done, bytes - done,
752
+ (off_t)(start + done));
753
+ if (n <= 0) {
754
+ return false;
755
+ }
756
+ done += (size_t) n;
757
+ }
758
+ memcpy(dst, g_direct_buf + (off - start), size);
759
+ return true;
760
+ }
761
+
762
+ bool pager::read_range(uint64_t off, uint8_t * dst, size_t size) {
763
+ size_t done = 0;
764
+ while (done < size) {
765
+ const ssize_t n = pread(fd_, dst + done, size - done, (off_t) (off + done));
766
+ if (n <= 0) {
767
+ return false;
768
+ }
769
+ done += (size_t) n;
770
+ }
771
+ // 讓 RAM 帳目誠實:把 kernel 對這個檔案的 page cache 丟掉,避免「藏在核心裡的 RAM」
772
+ if (cfg_.fadvise_dontneed) {
773
+ posix_fadvise(fd_, (off_t) off, (off_t) size, POSIX_FADV_DONTNEED);
774
+ }
775
+ return true;
776
+ }
777
+
778
+ bool pager::read_expert(int il, int ie, uint8_t * dst) {
779
+ const layer_desc * L = layer(il);
780
+ if (!L || ie < 0 || ie >= (int) L->experts.size()) {
781
+ return false;
782
+ }
783
+ const expert_desc & ed = L->experts[(size_t) ie];
784
+ for (int p = 0; p < 3; p++) {
785
+ if (ed.seg[p].size == 0) {
786
+ continue;
787
+ }
788
+ if (!read_range_direct(ed.seg[p].off, dst + ed.slot_off[p], (size_t) ed.seg[p].size)) {
789
+ return false;
790
+ }
791
+ }
792
+ return true;
793
+ }
794
+
795
+ // ---------------------------------------------------------------- I/O 執行緒池
796
+ //
797
+ // 問題:一個 expert = gate / up / down 三段,檔案上是三段不相鄰的資料。
798
+ // 循序讀就是 3 次 pread 排在同一條執行緒上,而單次 1.9 MB 的 pread 在本機量到
799
+ // ~7 ms(≈275 MB/s 的單流頻寬)。因此「一個 expert 要多久」= 3 × 單次延遲,
800
+ // 這條路徑的長度跟「有多少條執行緒」無關 —— mode C 再怎麼排程都救不了。
801
+ //
802
+ // 做法:把三段交給三條執行緒同時 pread,臨界路徑降到約「單段」的時間。
803
+ // 量測(8 個 expert 全部 miss 的一層,各 9 次取中位數):
804
+ // 8 執行緒 × 三段序列讀 : 12.60 ms
805
+ // 16 執行緒 × 三段平行讀 : 7.57 ms
806
+ // 24 執行緒 × 三段平行讀 : 7.58 ms ← 16 就飽和,所以預設 3 × n_threads
807
+ //
808
+ // 這個池子對 prefill 更有用:一層要 256 個 expert = 768 個讀取工作,
809
+ // 序列讀時 16 執行緒只能拿到 16 路的頻寬 utilization。
810
+ void pager::io_pool_start() {
811
+ if (!cfg_.io_parallel) {
812
+ fprintf(stderr, "[sdq] I/O 段平行:關(每個 expert 的 gate/up/down 循序讀)\n");
813
+ return;
814
+ }
815
+ int n = cfg_.io_threads > 0 ? cfg_.io_threads : cfg_.n_threads * 3;
816
+ if (n < 4) {
817
+ n = 4;
818
+ }
819
+ if (n > 48) {
820
+ n = 48;
821
+ }
822
+ // 先算「一段權重最大有多少位元組」,每條 worker 配一塊對齊緩衝。
823
+ // 這必须在 alloc_arena() 之前做完,否則這筆 RAM 會變成帳外支出。
824
+ size_t max_seg = 0;
825
+ for (const auto & L : layers_) {
826
+ for (const auto & ed : L.experts) {
827
+ for (int p = 0; p < 3; p++) {
828
+ max_seg = std::max(max_seg, (size_t) ed.seg[p].size);
829
+ }
830
+ }
831
+ }
832
+ const size_t stage_bytes = ((max_seg + 2 * 4096) + 4095) & ~(size_t) 4095;
833
+ io_stage_.assign((size_t) n, io_stage{});
834
+ for (int i = 0; i < n; i++) {
835
+ void * p = nullptr;
836
+ if (posix_memalign(&p, 4096, stage_bytes) == 0 && p) {
837
+ memset(p, 0, stage_bytes); // 實際碰頁,讓它出現在 RSS 裡
838
+ io_stage_[(size_t) i].buf = (uint8_t *) p;
839
+ io_stage_[(size_t) i].sz = stage_bytes;
840
+ }
841
+ }
842
+ io_split_ = cfg_.io_split > 0 ? cfg_.io_split : 1;
843
+ io_stop_ = false;
844
+ io_pool_.reserve((size_t) n);
845
+ for (int i = 0; i < n; i++) {
846
+ const size_t wid = (size_t) i;
847
+ try {
848
+ io_pool_.emplace_back([this, wid] { io_worker(wid); });
849
+ } catch (...) {
850
+ break;
851
+ }
852
+ }
853
+ io_threads_ = (int) io_pool_.size();
854
+ io_pool_on_ = io_threads_ > 0;
855
+ if (io_pool_on_) {
856
+ fprintf(stderr, "[sdq] I/O 段平行:%d 條讀取執行緒 × %zu KB 對齊緩衝"
857
+ "(gate/up/down 同時讀,每段再切 %d 塊)\n",
858
+ io_threads_, stage_bytes >> 10, io_split_);
859
+ }
860
+ }
861
+
862
+ void pager::io_pool_stop() {
863
+ if (io_pool_.empty()) {
864
+ for (auto & s : io_stage_) {
865
+ free(s.buf);
866
+ s.buf = nullptr;
867
+ }
868
+ io_stage_.clear();
869
+ return;
870
+ }
871
+ {
872
+ std::lock_guard<std::mutex> lk(io_mu_);
873
+ io_stop_ = true;
874
+ }
875
+ io_cv_.notify_all();
876
+ for (auto & t : io_pool_) {
877
+ if (t.joinable()) {
878
+ t.join();
879
+ }
880
+ }
881
+ io_pool_.clear();
882
+ io_pool_on_ = false;
883
+ for (auto & s : io_stage_) {
884
+ free(s.buf);
885
+ s.buf = nullptr;
886
+ s.sz = 0;
887
+ }
888
+ io_stage_.clear();
889
+ }
890
+
891
+ void pager::io_worker(size_t wid) {
892
+ for (;;) {
893
+ io_job job{};
894
+ {
895
+ std::unique_lock<std::mutex> lk(io_mu_);
896
+ io_cv_.wait(lk, [this] { return io_stop_ || !io_q_.empty(); });
897
+ if (io_q_.empty()) {
898
+ if (io_stop_) {
899
+ return;
900
+ }
901
+ continue;
902
+ }
903
+ job = io_q_.front();
904
+ io_q_.pop_front();
905
+ }
906
+ io_jobs_.fetch_add(1, std::memory_order_relaxed);
907
+ // 讀取本身不持鎖:io_mu_ 只保護佇列與完成計數
908
+ const bool ok = (wid < io_stage_.size() && io_stage_[wid].buf)
909
+ ? read_range_staged(job.off, job.dst, job.size,
910
+ io_stage_[wid].buf, io_stage_[wid].sz)
911
+ : read_range_direct(job.off, job.dst, job.size);
912
+ {
913
+ std::lock_guard<std::mutex> lk(io_mu_);
914
+ if (!ok) {
915
+ job.ctx->ok = false;
916
+ }
917
+ if (job.ctx->remaining.fetch_sub(1) == 1) {
918
+ io_done_cv_.notify_all();
919
+ }
920
+ }
921
+ }
922
+ }
923
+
924
+ // 把 (il,ie) 的三段交給執行緒池並行讀,呼叫端等到整批完成才返回。
925
+ // ctx 放在呼叫端 stack:worker 只會在 remaining 歸零之後才再碰它,
926
+ // 而我們正是等到 remaining==0 才離開函式,所以沒有 use-after-return。
927
+ bool pager::read_expert_par(int il, int ie, uint8_t * dst) {
928
+ const layer_desc * L = layer(il);
929
+ if (!L || ie < 0 || ie >= (int) L->experts.size()) {
930
+ return false;
931
+ }
932
+ if (!io_pool_on_) {
933
+ return read_expert(il, ie, dst);
934
+ }
935
+ const expert_desc & ed = L->experts[(size_t) ie];
936
+ io_ctx ctx;
937
+ int n = 0;
938
+ // 把每一段再切成一樣大的好幾塊,交給不同的執行緒同時讀。
939
+ //
940
+ // 為什麼:單一 pread 的時間 ≈ 固定延遲 + 位元組數 / 單流頻寬。
941
+ // 本機實測單流約 275 MB/s、4K 隨機讀延遲約 0.57 ms,所以
942
+ // 590 KB 讀一次 ≈ 0.57 + 2.1 = 2.7 ms
943
+ // 590 KB 切成 2 塊 ≈ 0.57 + 1.05 = 1.6 ms
944
+ // 而「讀完一個 expert」的時間正是每層的關鍵路徑,切塊就是直接砍關鍵路徑。
945
+ // 設定檔 SDQ_IO_SPLIT。**實測預設是 1(不切塊)**:切塊在 24 條讀取執行緒
946
+ // 之下沒有可重現的改善(split=1..6 的中位數在 4.9~5.9 之間跳動,純噪音),
947
+ // 因為 8 個 expert × 3 段 = 24 個並行請求已經把裝置的佇列灌飽,
948
+ // 這時瓶頸是裝置總頻寬而不是單一請求的延遲。保留這個開關是為了其他裝置。
949
+ const int split = io_split_;
950
+ const size_t blk = 4096;
951
+ {
952
+ std::lock_guard<std::mutex> lk(io_mu_);
953
+ for (int p = 0; p < 3; p++) {
954
+ const size_t sz = (size_t) ed.seg[p].size;
955
+ if (sz == 0) {
956
+ continue;
957
+ }
958
+ uint8_t * base = dst + ed.slot_off[p];
959
+ if (split <= 1 || sz <= 2 * blk) {
960
+ io_q_.push_back(io_job{ed.seg[p].off, base, sz, &ctx});
961
+ n++;
962
+ continue;
963
+ }
964
+ size_t chunk = ((sz + (size_t) split - 1) / (size_t) split + blk - 1) & ~(blk - 1);
965
+ for (size_t off = 0; off < sz; off += chunk) {
966
+ const size_t take = sz - off < chunk ? sz - off : chunk;
967
+ io_q_.push_back(io_job{ed.seg[p].off + off, base + off, take, &ctx});
968
+ n++;
969
+ }
970
+ }
971
+ ctx.remaining.store(n);
972
+ }
973
+ if (n == 0) {
974
+ return true;
975
+ }
976
+ io_cv_.notify_all();
977
+ const uint64_t t0 = now_us();
978
+ {
979
+ std::unique_lock<std::mutex> lk(io_mu_);
980
+ io_done_cv_.wait(lk, [&ctx] { return ctx.remaining.load() == 0; });
981
+ }
982
+ io_wait_ns_.fetch_add((now_us() - t0) * 1000ull, std::memory_order_relaxed);
983
+ return ctx.ok;
984
+ }
985
+
986
+ bool pager::read_expert_uring(int il, int ie, uint8_t * dst) {
987
+ #if !defined(SDQ_HAVE_LIBURING)
988
+ (void) il;
989
+ (void) ie;
990
+ // Keep SDQ_IO_URING safe on builds without liburing. The normal threaded
991
+ // pread implementation remains the correctness-preserving fallback.
992
+ return read_expert_par(il, ie, dst);
993
+ #else
994
+ const layer_desc * L = layer(il);
995
+ if (!L || ie < 0 || ie >= (int) L->experts.size() || use_direct_) {
996
+ return false;
997
+ }
998
+ const expert_desc & ed = L->experts[(size_t) ie];
999
+ io_uring ring;
1000
+ if (io_uring_queue_init(3, &ring, 0) < 0) {
1001
+ return false;
1002
+ }
1003
+ int submitted = 0;
1004
+ for (int p = 0; p < 3; p++) {
1005
+ if (ed.seg[p].size == 0) {
1006
+ continue;
1007
+ }
1008
+ io_uring_sqe * sqe = io_uring_get_sqe(&ring);
1009
+ if (!sqe) {
1010
+ io_uring_queue_exit(&ring);
1011
+ return false;
1012
+ }
1013
+ io_uring_prep_read(sqe, fd_, dst + ed.slot_off[p],
1014
+ (unsigned) ed.seg[p].size, (off_t) ed.seg[p].off);
1015
+ submitted++;
1016
+ }
1017
+ if (io_uring_submit_and_wait(&ring, submitted) < 0) {
1018
+ io_uring_queue_exit(&ring);
1019
+ return false;
1020
+ }
1021
+ bool ok = true;
1022
+ for (int completed = 0; completed < submitted; completed++) {
1023
+ io_uring_cqe * cqe = nullptr;
1024
+ if (io_uring_wait_cqe(&ring, &cqe) != 0 || !cqe) {
1025
+ ok = false;
1026
+ break;
1027
+ }
1028
+ if (cqe->res < 0) {
1029
+ ok = false;
1030
+ }
1031
+ io_uring_cqe_seen(&ring, cqe);
1032
+ }
1033
+ io_uring_queue_exit(&ring);
1034
+ if (ok && cfg_.fadvise_dontneed) {
1035
+ for (int p = 0; p < 3; p++) {
1036
+ if (ed.seg[p].size) {
1037
+ posix_fadvise(fd_, (off_t) ed.seg[p].off,
1038
+ (off_t) ed.seg[p].size, POSIX_FADV_DONTNEED);
1039
+ }
1040
+ }
1041
+ }
1042
+ return ok;
1043
+ #endif
1044
+ }
1045
+
1046
+ uint64_t pager::read_expert_span(int il, const int32_t * experts, int n, const int32_t * slots) {
1047
+ const layer_desc * L = layer(il);
1048
+ if (!L || n <= 0) {
1049
+ return 0;
1050
+ }
1051
+ const uint64_t t_start = now_us();
1052
+ // 每個執行緒一份暫存區(多執行緒會同時呼叫,不能共用一個 buffer)
1053
+ static thread_local std::vector<uint8_t> scratch_buf;
1054
+ uint64_t total = 0;
1055
+
1056
+ // 把 experts 切成「檔案編號連續」的區段
1057
+ int i = 0;
1058
+ while (i < n) {
1059
+ int j = i + 1;
1060
+ while (j < n && experts[j] == experts[j - 1] + 1) {
1061
+ j++;
1062
+ }
1063
+ const int cnt = j - i;
1064
+ const expert_desc & first = L->experts[(size_t) experts[i]];
1065
+ const expert_desc & last = L->experts[(size_t) experts[j - 1]];
1066
+
1067
+ for (int p = 0; p < 3; p++) {
1068
+ if (first.seg[p].size == 0 || last.seg[p].size == 0) {
1069
+ continue;
1070
+ }
1071
+ const uint64_t off = first.seg[p].off;
1072
+ const uint64_t end = last.seg[p].off + last.seg[p].size;
1073
+ const size_t bytes = (size_t) (end - off);
1074
+ if (scratch_buf.size() < bytes) {
1075
+ scratch_buf.assign(bytes, 0);
1076
+ }
1077
+ if (!read_range_direct(off, scratch_buf.data(), bytes)) {
1078
+ return total;
1079
+ }
1080
+ total += bytes;
1081
+ // 散到各自的槽位:scratch 從 off 開始,所以偏移是「相對於 off」
1082
+ for (int k = i; k < j; k++) {
1083
+ const expert_desc & ed = L->experts[(size_t) experts[k]];
1084
+ const size_t seg_bytes = (size_t) ed.seg[p].size;
1085
+ const size_t delta = (size_t) (ed.seg[p].off - off);
1086
+ if (slots[k] < 0) {
1087
+ continue;
1088
+ }
1089
+ if ((size_t) slots[k] >= n_slots_ || (uint64_t) ed.slot_off[p] + seg_bytes > slot_bytes_
1090
+ || delta + seg_bytes > scratch_buf.size()) {
1091
+ fprintf(stderr, "[sdq-pager] span 越界:il=%d slot=%d/%zu slot_off=%lld seg=%zu delta=%zu scratch=%zu p=%d e=%d\n",
1092
+ il, slots[k], n_slots_, (long long) ed.slot_off[p], seg_bytes, delta, scratch_buf.size(), p, experts[k]);
1093
+ return total;
1094
+ }
1095
+ memcpy(slot_ptr(slots[k]) + ed.slot_off[p],
1096
+ scratch_buf.data() + delta, seg_bytes);
1097
+ }
1098
+ }
1099
+ i = j;
1100
+ }
1101
+ stats_.demand_wait_us.fetch_add(now_us() - t_start, std::memory_order_relaxed);
1102
+ return total;
1103
+ }
1104
+
1105
+ int pager::reserve_slot(int il, int ie) {
1106
+ if (!ready_) {
1107
+ return -1;
1108
+ }
1109
+ count_request(il, ie);
1110
+ std::lock_guard<std::mutex> lk(mu_);
1111
+ const int32_t exist = find_slot_locked(il, ie);
1112
+ if (exist >= 0) {
1113
+ slots_[(size_t) exist].last_use = ++clock_;
1114
+ slots_[(size_t) exist].pin++;
1115
+ touch_freq_locked(slots_[(size_t) exist]);
1116
+ return exist;
1117
+ }
1118
+ const int32_t s = victim_locked();
1119
+ if (s < 0) {
1120
+ return -1;
1121
+ }
1122
+ slot_state & st = slots_[(size_t) s];
1123
+ if (st.layer >= 0) {
1124
+ page_.erase(((int64_t) st.layer << 20) | (int64_t) st.expert);
1125
+ stats_.evictions.fetch_add(1, std::memory_order_relaxed);
1126
+ used_.fetch_sub(1, std::memory_order_relaxed);
1127
+ }
1128
+ st.layer = il;
1129
+ st.expert = ie;
1130
+ st.last_use = ++clock_;
1131
+ st.freq = 1;
1132
+ st.pin = 1;
1133
+ st.is_hot = hot_lookup(il, ie);
1134
+ page_[((int64_t) il << 20) | (int64_t) ie] = s;
1135
+ used_.fetch_add(1, std::memory_order_relaxed);
1136
+ stats_.expert_misses.fetch_add(1, std::memory_order_relaxed);
1137
+ return s;
1138
+ }
1139
+
1140
+ int pager::probe(int il, int ie) {
1141
+ if (!ready_) {
1142
+ return -1;
1143
+ }
1144
+ stats_.expert_requests.fetch_add(1, std::memory_order_relaxed);
1145
+ count_request(il, ie);
1146
+ std::lock_guard<std::mutex> lk(mu_);
1147
+ const int32_t s = find_slot_locked(il, ie);
1148
+ if (s < 0) {
1149
+ stats_.expert_misses.fetch_add(1, std::memory_order_relaxed);
1150
+ return -1;
1151
+ }
1152
+ slots_[(size_t) s].last_use = ++clock_;
1153
+ slots_[(size_t) s].pin++;
1154
+ touch_freq_locked(slots_[(size_t) s]);
1155
+ stats_.expert_hits.fetch_add(1, std::memory_order_relaxed);
1156
+ return s;
1157
+ }
1158
+
1159
+ void pager::note_span_read(uint64_t bytes) {
1160
+ stats_.ssb_read_bytes_demand.fetch_add(bytes, std::memory_order_relaxed);
1161
+ stats_.demand_wait_us.fetch_add(0, std::memory_order_relaxed);
1162
+ }
1163
+
1164
+ // 衰減式頻率:每 8192 次使用就把所有計數減半,讓「過去熱門、現在變冷」的 expert 能被淘汰
1165
+ void pager::touch_freq_locked(slot_state & st) {
1166
+ if (++freq_tick_ >= 8192) {
1167
+ freq_tick_ = 0;
1168
+ for (auto & s : slots_) {
1169
+ s.freq /= 2;
1170
+ }
1171
+ }
1172
+ if (st.freq < 0xFFFFFFFFu) {
1173
+ st.freq++;
1174
+ }
1175
+ }
1176
+
1177
+ // ============================== OPT-1:熱門 expert 剖面 ==============================
1178
+ // 問題實測(docs/03):arena 3.88 GiB 只放得下 2044 個 expert,總共卻有 10240 個,
1179
+ // prefill 的幾百個「只用一次」的專家會把熱區整個洗掉,命中率卡在 ~70%。
1180
+ // 解法借用 Strata 的做法(src/core/expert_cache.cpp):**離線剖面 + 靜態排行榜 +
1181
+ // 熱頁永不淘汰**。這裡不動檔案格式,用純文字存排行榜。
1182
+
1183
+ void pager::count_request(int il, int ie) {
1184
+ if (prof_.empty() || il < 0 || ie < 0) {
1185
+ return;
1186
+ }
1187
+ const size_t idx = (size_t) il * (size_t) info_.n_expert + (size_t) ie;
1188
+ if (idx < prof_.size()) {
1189
+ // 單執行緒 ++ 即可:請求次數是統計用途,不參與任何正確性判斷
1190
+ if (prof_[idx] < 0xFFFFFFFFu) {
1191
+ prof_[idx]++;
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ bool pager::hot_lookup(int il, int ie) const {
1197
+ if (hot_.empty() || il < 0 || ie < 0) {
1198
+ return false;
1199
+ }
1200
+ const size_t idx = (size_t) il * (size_t) info_.n_expert + (size_t) ie;
1201
+ return idx < hot_.size() && hot_[idx] != 0;
1202
+ }
1203
+
1204
+ // 挑一個容得下 (il,ie) 的槽。與 victim_locked 的差別:**先只在冷頁裡找**,
1205
+ // 找不到才退讓去動熱頁。這就是「熱區不會被 prefill 洗掉」的機制。
1206
+ // 這個槽位放得下「需要 big 的 expert」嗎?
1207
+ // 小槽位:只能放小 expert
1208
+ // 大槽位:小expert 也放得下(只是浪費一點空間)
1209
+ static inline bool slot_accepts(bool need_big, bool slot_is_big) {
1210
+ return !need_big || slot_is_big;
1211
+ }
1212
+
1213
+ int pager::admit_slot_locked(int il, int ie) {
1214
+ // il/ie 都 < 0 代表「不指定大小」→ 兩種槽位都可以(舊的呼叫路徑)。
1215
+ const bool need_big = (il >= 0 && ie >= 0) ? need_big_slot(il, ie) : false;
1216
+ // 完全沒用過的空槽永遠優先(它不是熱頁,佔用等於浪費)
1217
+ // 大 expert 先看大槽位;小 expert 先看小槽位。
1218
+ std::vector<int32_t> & first = need_big ? free_big_slots_ : free_slots_;
1219
+ std::vector<int32_t> & second = need_big ? free_slots_ : free_big_slots_;
1220
+ while (!first.empty()) {
1221
+ const int32_t s = first.back();
1222
+ if (!slots_[(size_t) s].busy) {
1223
+ first.pop_back();
1224
+ return s;
1225
+ }
1226
+ }
1227
+ // 第一輪:只在「冷頁」裡挑最久未用的。
1228
+ // want_hot=true → 熱門 expert 進來時,把冷頁換掉(不動其他熱頁)
1229
+ // want_hot=false → 冷門 expert 進來時,優先吃冷頁
1230
+ // 兩種情況的規則相同,所以 hot_lookup 的結果不影響選擇順序,只影響標記。
1231
+ for (int pass = 0; pass < 2; pass++) {
1232
+ int64_t best = -1;
1233
+ uint64_t best_clock = UINT64_MAX;
1234
+ for (size_t s = 0; s < n_slots_; s++) {
1235
+ const slot_state & st = slots_[s];
1236
+ if (st.pin > 0 || st.busy) {
1237
+ continue;
1238
+ }
1239
+ if (pass == 0 && st.is_hot) {
1240
+ continue; // 第一輪不動熱頁
1241
+ }
1242
+ if (!slot_accepts(need_big, is_big_slot(s))) {
1243
+ continue;
1244
+ }
1245
+ if (st.last_use < best_clock) {
1246
+ best_clock = st.last_use;
1247
+ best = (int64_t) s;
1248
+ }
1249
+ }
1250
+ if (best >= 0) {
1251
+ return (int) best;
1252
+ }
1253
+ }
1254
+ // 退讓:另一種大小的空槽。**一定要檢查放不放得下** —— 小槽位放不下大 expert,
1255
+ // 硬塞會讓 read_expert 寫出槽位邊界(靜默把別的 expert 寫壞)。
1256
+ while (!second.empty()) {
1257
+ const int32_t s = second.back();
1258
+ if (!slots_[(size_t) s].busy &&
1259
+ slot_accepts(need_big, is_big_slot(s))) {
1260
+ second.pop_back();
1261
+ return s;
1262
+ }
1263
+ if (!slots_[(size_t) s].busy) {
1264
+ second.pop_back();
1265
+ } else {
1266
+ break;
1267
+ }
1268
+ }
1269
+ return -1;
1270
+ }
1271
+
1272
+ bool pager::load_profile(const std::string & path) {
1273
+ std::FILE * f = std::fopen(path.c_str(), "rb");
1274
+ if (!f) {
1275
+ fprintf(stderr, "[sdq] 找不到 expert 剖面 %s(將以動態 LRU/LFU 運作)\n", path.c_str());
1276
+ return false;
1277
+ }
1278
+ char magic[8] = {0};
1279
+ if (std::fread(magic, 1, 4, f) != 4 || std::memcmp(magic, "SDQP", 4) != 0) {
1280
+ std::fclose(f);
1281
+ fprintf(stderr, "[sdq] %s 不是 SDQ expert 剖面(缺少 SDQP 標頭)\n", path.c_str());
1282
+ return false;
1283
+ }
1284
+ uint32_t hdr[3] = {0, 0, 0};
1285
+ if (std::fread(hdr, sizeof(uint32_t), 3, f) != 3) {
1286
+ std::fclose(f);
1287
+ return false;
1288
+ }
1289
+ const uint32_t n = hdr[2];
1290
+ std::vector<std::pair<uint32_t, std::pair<int32_t, int32_t>>> ranked;
1291
+ ranked.reserve(n);
1292
+ for (uint32_t i = 0; i < n; i++) {
1293
+ int32_t l = 0, e = 0;
1294
+ uint32_t c = 0;
1295
+ if (std::fread(&l, sizeof(int32_t), 1, f) != 1) break;
1296
+ if (std::fread(&e, sizeof(int32_t), 1, f) != 1) break;
1297
+ if (std::fread(&c, sizeof(uint32_t), 1, f) != 1) break;
1298
+ ranked.push_back({c, {l, e}});
1299
+ }
1300
+ std::fclose(f);
1301
+ std::sort(ranked.begin(), ranked.end(),
1302
+ [](const auto & a, const auto & b) { return a.first > b.first; });
1303
+
1304
+ hot_.assign((size_t) info_.n_layer * (size_t) info_.n_expert, 0);
1305
+ const int want = cfg_.hot_pin > 0 ? cfg_.hot_pin : (int) ranked.size();
1306
+ n_hot_ = 0;
1307
+ for (const auto & r : ranked) {
1308
+ if (n_hot_ >= want) {
1309
+ break;
1310
+ }
1311
+ const int l = r.second.first, e = r.second.second;
1312
+ if (l < 0 || l >= info_.n_layer || e < 0 || e >= info_.n_expert) {
1313
+ continue;
1314
+ }
1315
+ const size_t idx = (size_t) l * (size_t) info_.n_expert + (size_t) e;
1316
+ if (!hot_[idx]) {
1317
+ hot_[idx] = 1;
1318
+ n_hot_++;
1319
+ }
1320
+ }
1321
+ fprintf(stderr, "[sdq] expert 剖面:%s → 釘選 %d / %zu 個熱門 expert(永不淘汰)\n",
1322
+ path.c_str(), n_hot_, ranked.size());
1323
+ return n_hot_ > 0;
1324
+ }
1325
+
1326
+ void pager::dump_profile(const std::string & path) {
1327
+ if (prof_.empty()) {
1328
+ return;
1329
+ }
1330
+ std::vector<std::pair<uint32_t, std::pair<int32_t, int32_t>>> ranked;
1331
+ ranked.reserve(prof_.size());
1332
+ uint64_t total = 0;
1333
+ for (int l = 0; l < info_.n_layer; l++) {
1334
+ for (int e = 0; e < info_.n_expert; e++) {
1335
+ const uint32_t c = prof_[(size_t) l * (size_t) info_.n_expert + (size_t) e];
1336
+ if (c > 0) {
1337
+ ranked.push_back({c, {l, e}});
1338
+ total += c;
1339
+ }
1340
+ }
1341
+ }
1342
+ std::sort(ranked.begin(), ranked.end(),
1343
+ [](const auto & a, const auto & b) { return a.first > b.first; });
1344
+ std::FILE * f = std::fopen(path.c_str(), "wb");
1345
+ if (!f) {
1346
+ fprintf(stderr, "[sdq] 無法寫出 expert 剖面 %s\n", path.c_str());
1347
+ return;
1348
+ }
1349
+ std::fwrite("SDQP", 1, 4, f);
1350
+ const uint32_t hdr[3] = {1, (uint32_t) info_.n_layer, (uint32_t) ranked.size()};
1351
+ std::fwrite(hdr, sizeof(uint32_t), 3, f);
1352
+ for (const auto & r : ranked) {
1353
+ const int32_t l = r.second.first, e = r.second.second;
1354
+ const uint32_t c = r.first;
1355
+ std::fwrite(&l, sizeof(int32_t), 1, f);
1356
+ std::fwrite(&e, sizeof(int32_t), 1, f);
1357
+ std::fwrite(&c, sizeof(uint32_t), 1, f);
1358
+ }
1359
+ std::fclose(f);
1360
+ // 覆盖率報告:前 K 名可以蓋掉多少請求 → 直接決定 hot_pin 該設多少
1361
+ uint64_t acc = 0;
1362
+ const size_t ks[6] = {512, 1024, 1536, 2048, 3072, 4096};
1363
+ fprintf(stderr, "[sdq] expert 剖面已寫到 %s(%zu 個有被用到,總請求 %llu)\n",
1364
+ path.c_str(), ranked.size(), (unsigned long long) total);
1365
+ size_t ki = 0;
1366
+ for (size_t i = 0; i < ranked.size(); i++) {
1367
+ acc += ranked[i].first;
1368
+ if (ki < 6 && i + 1 == ks[ki]) {
1369
+ fprintf(stderr, "[sdq] 前 %5zu 名涵蓋 %6.2f%% 的請求\n",
1370
+ ks[ki], 100.0 * (double) acc / (double) (total ? total : 1));
1371
+ ki++;
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ int pager::find_slot_locked(int il, int ie) {
1377
+ const int64_t key = ((int64_t) il << 20) | (int64_t) ie;
1378
+ auto it = page_.find(key);
1379
+ return it == page_.end() ? -1 : it->second;
1380
+ }
1381
+
1382
+ // 找一個「可以放 (il,ie) 而且 last_use <= stale_before」的槽位。
1383
+ //
1384
+ // 為什麼不能直接用 victim_stale_locked():大小兩種槽位之後,那個函式會掃過**所有**
1385
+ // 槽位(包含小槽位),於是一個需要大槽位的 expert 可能拿到小槽位,
1386
+ // 預取時 read_expert 會寫出槽位邊界 → 靜默把鄰居槽位的資料寫壞。
1387
+ // 這是引進大小兩種槽位時真正會咬人的地方。
1388
+ //
1389
+ // 順序:先看對應尺寸的空槽(從沒用過,最該拿來放預取),
1390
+ // 再掃全部槽位挑最久未用的(放得下的那種尺寸)。
1391
+ int pager::victim_stale_for(int il, int ie, uint64_t stale_before) {
1392
+ const bool need_big = need_big_slot(il, ie);
1393
+ std::vector<int32_t> & first = need_big ? free_big_slots_ : free_slots_;
1394
+ std::vector<int32_t> & second = need_big ? free_slots_ : free_big_slots_;
1395
+ for (std::vector<int32_t> * lst : { &first, &second }) {
1396
+ while (!lst->empty()) {
1397
+ const int32_t s = lst->back();
1398
+ if (slots_[(size_t) s].busy) {
1399
+ break;
1400
+ }
1401
+ if (slots_[(size_t) s].last_use <= stale_before &&
1402
+ slot_accepts(need_big, is_big_slot(s))) {
1403
+ lst->pop_back();
1404
+ return s;
1405
+ }
1406
+ lst->pop_back();
1407
+ }
1408
+ }
1409
+ int64_t best = -1;
1410
+ uint64_t best_clock = UINT64_MAX;
1411
+ for (size_t i = 0; i < n_slots_; i++) {
1412
+ const slot_state & st = slots_[i];
1413
+ if (st.pin > 0 || st.busy || st.last_use > stale_before) {
1414
+ continue;
1415
+ }
1416
+ if (!slot_accepts(need_big, is_big_slot(i))) {
1417
+ continue;
1418
+ }
1419
+ if (st.last_use < best_clock) {
1420
+ best_clock = st.last_use;
1421
+ best = (int64_t) i;
1422
+ }
1423
+ }
1424
+ return (int) best;
1425
+ }
1426
+
1427
+ int pager::victim_stale_locked(uint64_t stale_before) {
1428
+ // 只挑「last_use 很舊」而且沒被 pin 的槽:保護最近的工作集
1429
+ while (!free_slots_.empty()) {
1430
+ const int32_t s = free_slots_.back();
1431
+ if (!slots_[(size_t) s].busy && slots_[(size_t) s].last_use <= stale_before) {
1432
+ free_slots_.pop_back();
1433
+ return s;
1434
+ }
1435
+ if (free_slots_.size() * 4 < n_slots_) {
1436
+ break;
1437
+ }
1438
+ free_slots_.pop_back();
1439
+ }
1440
+ int64_t best = -1;
1441
+ uint64_t best_clock = UINT64_MAX;
1442
+ for (size_t i = 0; i < n_slots_; i++) {
1443
+ const slot_state & st = slots_[i];
1444
+ if (st.pin > 0 || st.busy || st.last_use > stale_before) {
1445
+ continue;
1446
+ }
1447
+ if (st.last_use < best_clock) {
1448
+ best_clock = st.last_use;
1449
+ best = (int64_t) i;
1450
+ }
1451
+ }
1452
+ return (int) best;
1453
+ }
1454
+
1455
+ int pager::acquire(int il, int ie, bool blocking) {
1456
+ if (!ready_) {
1457
+ return -1;
1458
+ }
1459
+ std::unique_lock<std::mutex> lk(mu_);
1460
+ int32_t s = find_slot_locked(il, ie);
1461
+ if (s >= 0 && slots_[s].busy) {
1462
+ // 這個槽位**正在被填入**:另一條執行緒剛 admit 它、資料還沒讀完。
1463
+ //
1464
+ // 這裡一定要等,不能當成命中直接用。否則讀到的是「寫到一半的權重」,
1465
+ // 而且那個錯誤是**非決定性**的 —— 每次執行結果可能不同(實測在
1466
+ // prefill 時會讓同一個提示詞跑出不同的文字)。
1467
+ // busy 的槽位在 admit/victim 那邊本來就會被跳過,所以這裡是唯一
1468
+ // 還能拿到它的路徑,也因此必須在這裡等。
1469
+ const uint64_t t0 = now_us();
1470
+ while (slots_[s].busy) {
1471
+ lk.unlock();
1472
+ std::this_thread::sleep_for(std::chrono::microseconds(200));
1473
+ lk.lock();
1474
+ if (now_us() - t0 > (uint64_t) env_size("SDQ_BUSY_WAIT_MS", 30000) * 1000) {
1475
+ fprintf(stderr,
1476
+ "[sdq] 致命:等待槽位 il=%d ie=%d 讀入超過 %u ms(busy 一直沒解除)\n",
1477
+ il, ie, (unsigned) env_size("SDQ_BUSY_WAIT_MS", 30000));
1478
+ std::fflush(stderr);
1479
+ abort();
1480
+ }
1481
+ }
1482
+ // 讀取可能失敗(那時槽位已被清空)→ 重新走一次正常的 miss 路徑
1483
+ if (find_slot_locked(il, ie) != s) {
1484
+ s = -1;
1485
+ }
1486
+ }
1487
+ if (s >= 0) {
1488
+ slots_[s].last_use = ++clock_;
1489
+ slots_[s].pin++;
1490
+ touch_freq_locked(slots_[(size_t) s]);
1491
+ stats_.expert_requests.fetch_add(1, std::memory_order_relaxed);
1492
+ stats_.expert_hits.fetch_add(1, std::memory_order_relaxed);
1493
+ return s;
1494
+ }
1495
+
1496
+ stats_.expert_requests.fetch_add(1, std::memory_order_relaxed);
1497
+ count_request(il, ie);
1498
+ stats_.expert_misses.fetch_add(1, std::memory_order_relaxed);
1499
+
1500
+ // 先找一個已經在背景讀入這頁的槽
1501
+ for (size_t i = 0; i < n_slots_; i++) {
1502
+ slot_state & st = slots_[i];
1503
+ if (st.busy && st.layer == il && st.expert == ie) {
1504
+ // 等背景執行緒讀完
1505
+ lk.unlock();
1506
+ const uint64_t t0 = now_us();
1507
+ while (true) {
1508
+ lk.lock();
1509
+ if (!slots_[i].busy) {
1510
+ break;
1511
+ }
1512
+ lk.unlock();
1513
+ std::this_thread::sleep_for(std::chrono::microseconds(200));
1514
+ }
1515
+ s = (int32_t) i;
1516
+ lk.unlock();
1517
+ stats_.prefetch_wait_us.fetch_add(now_us() - t0, std::memory_order_relaxed);
1518
+ std::unique_lock<std::mutex> lk2(mu_);
1519
+ slots_[s].last_use = ++clock_;
1520
+ slots_[s].pin++;
1521
+ stats_.expert_hits.fetch_add(1, std::memory_order_relaxed);
1522
+ stats_.prefetch_used.fetch_add(1, std::memory_order_relaxed);
1523
+ return s;
1524
+ }
1525
+ }
1526
+
1527
+ if (!blocking) {
1528
+ return -1;
1529
+ }
1530
+
1531
+ // 選槽位時必須知道「這個 expert 需要哪一種大小」,否則大 expert 可能被塞進小槽位
1532
+ // (寫出邊界 → 靜默算錯),或者小 expert 佔掉大槽位(浪費 6.8% 的 arena)。
1533
+ s = admit_slot_locked(il, ie);
1534
+ if (s < 0) {
1535
+ // 拿不到槽位:短暫等待別的執行緒解除 pin(每層開頭會 unpin_all)。
1536
+ // 這裡「重試」而不是直接回 -1,因為回 -1 會讓呼叫端**跳過這個 expert**,
1537
+ // 結果是靜默少算一條專家分支 —— 那是最嚴重的錯誤。
1538
+ // 2000 次 × 250 µs = 最多等 0.5 秒;真的撐不到那麼久,代表 arena 被
1539
+ // 預取的 busy 槽位佔住了(實測:跨層預取開啟時會發生)。
1540
+ for (int attempt = 0; attempt < 2000 && s < 0; attempt++) {
1541
+ lk.unlock();
1542
+ std::this_thread::sleep_for(std::chrono::microseconds(250));
1543
+ lk.lock();
1544
+ s = admit_slot_locked(il, ie);
1545
+ }
1546
+ if (s < 0) {
1547
+ fprintf(stderr,
1548
+ "[sdq] 嚴重:找不到能放 il=%d ie=%d(need_big=%d)的槽位,"
1549
+ "大槽 %zu/小槽 %zu\n",
1550
+ il, ie, (int) need_big_slot(il, ie), n_big_, n_small_);
1551
+ // 這裡「回 -1」會讓呼叫端**跳過這個 expert**,結果是靜默少算一條專家分支
1552
+ // —— 那是整個引擎最嚴重的錯誤型態(模型會安靜地給出錯誤答案,而且
1553
+ // 每次執行還不一定一樣)。寧可整個行程死掉,也不要產出「看起來正常」
1554
+ // 的錯誤結果。
1555
+ fprintf(stderr,
1556
+ "[sdq] 致命:無法取得 expert 槽位。為了不給出靜默錯誤的結果,"
1557
+ "直接中止。請回報這個情況(多半是預取把槽位佔成 busy 太久)。\n");
1558
+ std::fflush(stderr);
1559
+ abort();
1560
+ }
1561
+ }
1562
+ slot_state & st = slots_[s];
1563
+ if (st.layer >= 0) {
1564
+ page_.erase(((int64_t) st.layer << 20) | (int64_t) st.expert);
1565
+ stats_.evictions.fetch_add(1, std::memory_order_relaxed);
1566
+ used_.fetch_sub(1, std::memory_order_relaxed);
1567
+ }
1568
+ st.layer = il;
1569
+ st.expert = ie;
1570
+ st.last_use = ++clock_;
1571
+ st.pin = 1;
1572
+ st.is_hot = hot_lookup(il, ie);
1573
+ // **先標 busy,再 publish 到 page_。**
1574
+ // 順序反過來的話(先 publish 再標 busy 中間有一個窗口)另一條執行緒會把它
1575
+ // 當成命中,然後在資料還沒讀完時就拿去做矩陣乘法 → 非決定性的錯誤輸出。
1576
+ // acquire() 看到 busy 會等;admit/victim 本來就會跳過 busy 的槽位。
1577
+ st.busy = true;
1578
+ page_[((int64_t) il << 20) | (int64_t) ie] = s;
1579
+
1580
+ lk.unlock();
1581
+
1582
+ const uint64_t t0 = now_us();
1583
+ const bool ok = cfg_.io_uring ? read_expert_uring(il, ie, slot_ptr(s))
1584
+ : read_expert_par(il, ie, slot_ptr(s));
1585
+ const uint64_t dt = now_us() - t0;
1586
+
1587
+ std::unique_lock<std::mutex> lk2(mu_);
1588
+ st.busy = false;
1589
+ if (!ok) {
1590
+ st.layer = -1;
1591
+ st.expert = -1;
1592
+ st.pin = 0;
1593
+ page_.erase(((int64_t) il << 20) | (int64_t) ie);
1594
+ return -1;
1595
+ }
1596
+ used_.fetch_add(1, std::memory_order_relaxed);
1597
+ stats_.demand_wait_us.fetch_add(dt, std::memory_order_relaxed);
1598
+ const layer_desc * L = layer(il);
1599
+ if (L) {
1600
+ stats_.ssb_read_bytes_demand.fetch_add((uint64_t) L->experts[(size_t) ie].bytes, std::memory_order_relaxed);
1601
+ }
1602
+ return s;
1603
+ }
1604
+
1605
+ void pager::prefetch_expert(int il, int ie) {
1606
+ if (!ready_ || !cfg_.prefetch) {
1607
+ return;
1608
+ }
1609
+ {
1610
+ std::unique_lock<std::mutex> lk(mu_);
1611
+ if (find_slot_locked(il, ie) >= 0) {
1612
+ stats_.prefetch_duplicate.fetch_add(1, std::memory_order_relaxed);
1613
+ return;
1614
+ }
1615
+ // 預取只能占用「空槽」或「已經很久沒被用到的頁」。
1616
+ // (實測:無差別淘汰會讓命中率從 90% 掉到 27%,而預取使用率是 0%,純粹有害。)
1617
+ //
1618
+ // **槽位一定要選對尺寸**:need_big_slot() 為真的 expert 拿到小槽位會寫出邊界。
1619
+ const uint64_t stale_before = clock_ - (uint64_t) env_size("SDQ_PREFETCH_STALE_TOKENS", 2) * 320;
1620
+ // 預取佇列也不能太深:SSD 讀不夠快時,排在後面的預取早就過期了
1621
+ if (pf_q_.size() > (size_t) env_size("SDQ_PREFETCH_QUEUE", 64)) {
1622
+ return;
1623
+ }
1624
+ // **必須留夠空的槽位給 demand。**
1625
+ // 預取會把槽位標成 busy 並佔著它讀取;大槽位本來就少(工作集下限是
1626
+ // n_big_layers × n_expert_used),如果預取把最後幾個空槽都吃掉,
1627
+ // 下一層的 demand 就會「找不到能放 il=34 ie=114 的槽位」而中止。
1628
+ // (這是 verify 新增的預取壓力測試 [2b] 抓到的實際情況。)
1629
+ // 所以:對應尺寸的空槽少於 n_expert_used 個時,這一筆預取直接不做。
1630
+ const size_t reserve = (size_t) info_.n_expert_used;
1631
+ const size_t n_free_right = need_big_slot(il, ie) ? free_big_slots_.size() : free_slots_.size();
1632
+ if (n_free_right < reserve) {
1633
+ stats_.prefetch_duplicate.fetch_add(1, std::memory_order_relaxed);
1634
+ return;
1635
+ }
1636
+ const int32_t s = victim_stale_for(il, ie, stale_before);
1637
+ if (s < 0) {
1638
+ return;
1639
+ }
1640
+ slot_state & st = slots_[s];
1641
+ if (st.layer >= 0) {
1642
+ page_.erase(((int64_t) st.layer << 20) | (int64_t) st.expert);
1643
+ stats_.evictions.fetch_add(1, std::memory_order_relaxed);
1644
+ used_.fetch_sub(1, std::memory_order_relaxed);
1645
+ }
1646
+ st.layer = il;
1647
+ st.expert = ie;
1648
+ st.busy = true;
1649
+ st.pin = 0;
1650
+ pf_q_.push_back({s, il, ie, now_us()});
1651
+ stats_.prefetch_issued.fetch_add(1, std::memory_order_relaxed);
1652
+ }
1653
+ pf_cv_.notify_one();
1654
+ }
1655
+
1656
+ void pager::pf_worker() {
1657
+ std::unique_lock<std::mutex> lk(pf_mu_);
1658
+ for (;;) {
1659
+ pf_cv_.wait(lk, [this] { return pf_stop_ || !pf_q_.empty(); });
1660
+ if (pf_stop_ && pf_q_.empty()) {
1661
+ return;
1662
+ }
1663
+ const pf_item item = pf_q_.front();
1664
+ pf_q_.pop_front();
1665
+ lk.unlock();
1666
+
1667
+ // 這個槽可能已經被淘汰/重用 → 檢查 generation
1668
+ {
1669
+ std::unique_lock<std::mutex> g(mu_);
1670
+ const slot_state & st = slots_[item.slot];
1671
+ const bool still = st.busy && st.layer == item.il && st.expert == item.ie;
1672
+ if (!still) {
1673
+ lk.lock();
1674
+ continue;
1675
+ }
1676
+ }
1677
+
1678
+ // 預取也走 I/O 段平行:單獨一條執行緒循序讀三段,是這條路徑唯一的瓶頸。
1679
+ const bool ok = cfg_.io_uring ? read_expert_uring(item.il, item.ie, slot_ptr(item.slot))
1680
+ : (cfg_.io_parallel ? read_expert_par(item.il, item.ie, slot_ptr(item.slot))
1681
+ : read_expert(item.il, item.ie, slot_ptr(item.slot)));
1682
+ const layer_desc * L = layer(item.il);
1683
+ {
1684
+ std::unique_lock<std::mutex> g(mu_);
1685
+ slot_state & st = slots_[item.slot];
1686
+ if (st.busy && st.layer == item.il && st.expert == item.ie) {
1687
+ st.busy = false;
1688
+ st.last_use = ++clock_;
1689
+ if (ok) {
1690
+ page_[((int64_t) item.il << 20) | (int64_t) item.ie] = item.slot;
1691
+ used_.fetch_add(1, std::memory_order_relaxed);
1692
+ stats_.prefetch_completed.fetch_add(1, std::memory_order_relaxed);
1693
+ if (L) {
1694
+ stats_.ssb_read_bytes_prefetch.fetch_add(
1695
+ (uint64_t) L->experts[(size_t) item.ie].bytes, std::memory_order_relaxed);
1696
+ }
1697
+ } else {
1698
+ st.layer = -1;
1699
+ st.expert = -1;
1700
+ }
1701
+ }
1702
+ }
1703
+ lk.lock();
1704
+ }
1705
+ }
1706
+
1707
+ void pager::release(int s) {
1708
+ if (s < 0 || (size_t) s >= n_slots_) {
1709
+ return;
1710
+ }
1711
+ std::lock_guard<std::mutex> lk(mu_);
1712
+ slot_state & st = slots_[(size_t) s];
1713
+ if (st.pin > 0) {
1714
+ st.pin--;
1715
+ }
1716
+ }
1717
+
1718
+ void pager::unpin_all() {
1719
+ std::unique_lock<std::mutex> lk(mu_);
1720
+ for (auto & st : slots_) {
1721
+ st.pin = 0;
1722
+ }
1723
+ }
1724
+
1725
+ void pager::note_used(int il, int ie) {
1726
+ if (il < 0 || ie < 0) {
1727
+ return;
1728
+ }
1729
+ std::lock_guard<std::mutex> lk(hist_mu_);
1730
+ if (il >= (int) cur_used_.size()) {
1731
+ return;
1732
+ }
1733
+ auto & v = cur_used_[(size_t) il];
1734
+ if (std::find(v.begin(), v.end(), ie) == v.end()) {
1735
+ v.push_back(ie);
1736
+ }
1737
+ }
1738
+
1739
+ // 「下一層」預取:只在 layer L 的 op 開頭呼叫一次。
1740
+ //
1741
+ // 為什麼需要這個函式:end_token() 是**整個 token 的 40 層都算完之後**才被呼叫的
1742
+ // (sdq_cli.cpp 在 llama_decode 之後呼叫),所以它排的預取完全沒有和任何計算重疊
1743
+ // —— 量測上 prefetch 使用率只有 0~8 %,等於整條預取路徑是關的。
1744
+ // 在 layer L 開始時就替 layer L+1 排預取,才有一整層的時間(讀+算,約 4 ms)
1745
+ // 可以讓 SSD 讀取躲在後面。
1746
+ //
1747
+ // 預測用的是 prev_used_[il+1](上一個 token 在該層選的專家)—— MoE 的路由在
1748
+ // 鄰近 token 之間非常穩定,這是最便宜且不用訓練的預測器。
1749
+ void pager::prefetch_layer_ahead(int il) {
1750
+ if (!ready_ || !cfg_.prefetch || il < 0 || il >= (int) layers_.size()) {
1751
+ return;
1752
+ }
1753
+ int32_t ids[64];
1754
+ const int k = (int) info_.n_expert_used;
1755
+ if (k <= 0 || k > 64) {
1756
+ return;
1757
+ }
1758
+ predict(il, ids, k);
1759
+ for (int i = 0; i < k; i++) {
1760
+ prefetch_expert(il, ids[i]);
1761
+ }
1762
+ }
1763
+
1764
+ void pager::end_token() {
1765
+ if (!ready_) {
1766
+ return;
1767
+ }
1768
+ // 用「上個 token 的選擇」更新一階 Markov 轉移表
1769
+ {
1770
+ std::lock_guard<std::mutex> lk(hist_mu_);
1771
+ for (size_t il = 0; il < cur_used_.size(); il++) {
1772
+ auto & cur = cur_used_[il];
1773
+ if (cur.empty()) {
1774
+ continue;
1775
+ }
1776
+ auto & layer_hist = hist_[il];
1777
+ // 以每層最近一次(或平均)的前一次選擇為前驅
1778
+ if (!prev_used_.empty() && il < prev_used_.size() && !prev_used_[il].empty()) {
1779
+ for (int32_t from : prev_used_[il]) {
1780
+ trans & t = layer_hist[(size_t) from];
1781
+ for (int32_t to : cur) {
1782
+ int found = -1;
1783
+ for (int32_t i = 0; i < t.n; i++) {
1784
+ if (t.to[i] == to) {
1785
+ t.cnt[i]++;
1786
+ found = i;
1787
+ break;
1788
+ }
1789
+ }
1790
+ if (found < 0 && t.n < 64) {
1791
+ t.to[t.n] = to;
1792
+ t.cnt[t.n] = 1;
1793
+ t.n++;
1794
+ }
1795
+ }
1796
+ }
1797
+ }
1798
+ prev_used_[il] = cur;
1799
+ cur.clear();
1800
+ }
1801
+ }
1802
+
1803
+ // 依預測排預取
1804
+ if (!cfg_.prefetch) {
1805
+ return;
1806
+ }
1807
+ int32_t ids[64];
1808
+ for (size_t il = 0; il < layers_.size(); il++) {
1809
+ const int k = (int) info_.n_expert_used;
1810
+ predict((int) il, ids, k);
1811
+ for (int i = 0; i < k; i++) {
1812
+ prefetch_expert((int) il, ids[i]);
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ void pager::predict(int il, int32_t * out, int k) {
1818
+ if (il < 0 || il >= (int) prev_used_.size()) {
1819
+ for (int i = 0; i < k; i++) {
1820
+ out[i] = i;
1821
+ }
1822
+ return;
1823
+ }
1824
+ // 以最近一次選擇為前驅;沒有歷史就直接沿用上一次的選擇
1825
+ const std::vector<int32_t> & prev = prev_used_[(size_t) il];
1826
+ std::vector<std::pair<uint32_t, int32_t>> scored;
1827
+ if (prev.empty()) {
1828
+ for (int i = 0; i < k; i++) {
1829
+ out[i] = i;
1830
+ }
1831
+ return;
1832
+ }
1833
+ for (int32_t from : prev) {
1834
+ const trans & t = hist_[(size_t) il][(size_t) from];
1835
+ for (int32_t i = 0; i < t.n; i++) {
1836
+ scored.push_back({t.cnt[i], t.to[i]});
1837
+ }
1838
+ }
1839
+ if (scored.empty()) {
1840
+ for (size_t i = 0; i < prev.size() && i < (size_t) k; i++) {
1841
+ out[i] = prev[i];
1842
+ }
1843
+ for (int i = (int) prev.size(); i < k; i++) {
1844
+ out[i] = i;
1845
+ }
1846
+ return;
1847
+ }
1848
+ std::sort(scored.begin(), scored.end(), [](auto & a, auto & b) {
1849
+ return a.first > b.first;
1850
+ });
1851
+ for (int i = 0; i < k && i < (int) scored.size(); i++) {
1852
+ out[i] = scored[(size_t) i].second;
1853
+ }
1854
+ }
1855
+
1856
+ double pager::hit_rate() const {
1857
+ const uint64_t req = stats_.expert_requests.load();
1858
+ const uint64_t hit = stats_.expert_hits.load();
1859
+ return req ? (double) hit / (double) req : 0.0;
1860
+ }
1861
+
1862
+ void pager::shutdown() {
1863
+ if (!ready_) {
1864
+ return;
1865
+ }
1866
+ if (!profile_out_.empty()) {
1867
+ std::lock_guard<std::mutex> lk(mu_);
1868
+ dump_profile(profile_out_);
1869
+ }
1870
+ if (pf_.joinable()) {
1871
+ {
1872
+ std::unique_lock<std::mutex> lk(pf_mu_);
1873
+ pf_stop_ = true;
1874
+ }
1875
+ pf_cv_.notify_all();
1876
+ pf_.join();
1877
+ }
1878
+ io_pool_stop();
1879
+ if (fd_ >= 0) {
1880
+ close(fd_);
1881
+ fd_ = -1;
1882
+ }
1883
+ if (fd_direct_ >= 0) {
1884
+ close(fd_direct_);
1885
+ fd_direct_ = -1;
1886
+ }
1887
+ ready_ = false;
1888
+ }
1889
+
1890
+ pager::~pager() {
1891
+ shutdown();
1892
+ if (arena_) {
1893
+ // 注意:arena 是 posix_memalign 配置的,必須用 free() 釋放,
1894
+ // 用 munmap() 會破壞 glibc 的 heap(之後任何 free 都會報 double free)。
1895
+ free(arena_);
1896
+ arena_ = nullptr;
1897
+ }
1898
+ }
1899
+
1900
+ static double pct(uint64_t a, uint64_t b) {
1901
+ return b ? 100.0 * (double) a / (double) b : 0.0;
1902
+ }
1903
+
1904
+ void pager::print_summary() const {
1905
+ if (!ready_) {
1906
+ return;
1907
+ }
1908
+ const layer_desc * L0 = layer(0);
1909
+ if (L0 && !L0->experts.empty()) {
1910
+ const expert_desc & e = L0->experts[0];
1911
+ fprintf(stderr, "[sdq] page-map: n_layer=%lld n_expert=%lld n_expert_used=%lld n_embd=%lld n_ff_exp=%lld"
1912
+ " | L0E0 gate=%llu/%llu up=%llu/%llu down=%llu/%llu (off/size) ne0=%lld ne1=%lld d_ne0=%lld d_ne1=%lld\n",
1913
+ (long long) info_.n_layer, (long long) info_.n_expert, (long long) info_.n_expert_used,
1914
+ (long long) info_.n_embd, (long long) info_.n_ff_exp,
1915
+ (unsigned long long) e.seg[part::GATE].off, (unsigned long long) e.seg[part::GATE].size,
1916
+ (unsigned long long) e.seg[part::UP].off, (unsigned long long) e.seg[part::UP].size,
1917
+ (unsigned long long) e.seg[part::DOWN].off, (unsigned long long) e.seg[part::DOWN].size,
1918
+ (long long) e.ne0, (long long) e.ne1, (long long) e.down_ne0, (long long) e.down_ne1);
1919
+ }
1920
+ fprintf(stderr,
1921
+ "[sdq] expert pager ready: %s\n"
1922
+ "[sdq] 熱門 expert 釘選:%d 個%s\n"
1923
+ "[sdq] slot=%.2f MiB (%zu B) slots=%zu arena=%.2f GiB (RAM 實測 RSS=%.2f GiB)\n",
1924
+ cfg_.model_path.c_str(),
1925
+ n_hot_, n_hot_ > 0 ? "(永不淘汰)" : "(無剖面,用動態 LRU/LFU)",
1926
+ slot_bytes_ / 1048576.0, slot_bytes_, n_slots_, arena_bytes_ / 1073741824.0,
1927
+ process_rss_bytes() / 1073741824.0);
1928
+ if (n_big_) {
1929
+ // 大小兩種槽位:把配置結果講清楚,否則只看「slots=N」會誤以為每個槽一樣大。
1930
+ fprintf(stderr,
1931
+ "[sdq] 槽位分兩種:小 %.2f MiB × %zu + 大 %.2f MiB × %zu(平均 %.2f MiB)\n",
1932
+ small_bytes_ / 1048576.0, n_small_,
1933
+ big_bytes_ / 1048576.0, n_big_,
1934
+ (double) arena_bytes_ / (double) (n_slots_ ? n_slots_ : 1) / 1048576.0);
1935
+ }
1936
+ fprintf(stderr,
1937
+ "[sdq] prefetch=%s fadvise_dontneed=%d\n",
1938
+ cfg_.prefetch ? "on" : "off", (int) cfg_.fadvise_dontneed);
1939
+ }
1940
+
1941
+ void pager::dump_stats(const char * phase) {
1942
+ if (cfg_.stats_path.empty()) {
1943
+ return;
1944
+ }
1945
+ const uint64_t tokens = stats_.tokens.load();
1946
+ const uint64_t req = stats_.expert_requests.load();
1947
+ const uint64_t hit = stats_.expert_hits.load();
1948
+ const uint64_t issued = stats_.prefetch_issued.load();
1949
+ const uint64_t used = stats_.prefetch_used.load();
1950
+ const uint64_t b_dem = stats_.ssb_read_bytes_demand.load();
1951
+ const uint64_t b_pre = stats_.ssb_read_bytes_prefetch.load();
1952
+
1953
+ std::ostringstream o;
1954
+ o << "{\"phase\":\"" << phase << "\""
1955
+ << ",\"tokens\":" << tokens
1956
+ << ",\"expert_requests\":" << req
1957
+ << ",\"expert_hits\":" << hit
1958
+ << ",\"expert_misses\":" << (req - hit)
1959
+ << ",\"hit_rate\":" << pct(hit, req)
1960
+ << ",\"ssb_demand_bytes\":" << b_dem
1961
+ << ",\"ssb_prefetch_bytes\":" << b_pre
1962
+ << ",\"ssb_total_bytes\":" << (b_dem + b_pre)
1963
+ << ",\"ssd_bytes_per_token\":" << (tokens ? (double) (b_dem + b_pre) / (double) tokens : 0.0)
1964
+ << ",\"prefetch_issued\":" << issued
1965
+ << ",\"prefetch_used\":" << used
1966
+ << ",\"prefetch_used_rate\":" << pct(used, issued)
1967
+ << ",\"prefetch_duplicate\":" << stats_.prefetch_duplicate.load()
1968
+ << ",\"evictions\":" << stats_.evictions.load()
1969
+ << ",\"demand_wait_us\":" << stats_.demand_wait_us.load()
1970
+ << ",\"prefetch_wait_us\":" << stats_.prefetch_wait_us.load()
1971
+ << ",\"arena_bytes\":" << arena_bytes_
1972
+ // 大小兩種槽位之後,「用掉幾個槽位」不能直接乘 slot_bytes_(那是大的那種)。
1973
+ // dump_stats 只在階段轉換時呼叫一次,遍歷兩千幾個槽位的成本可以忽略。
1974
+ << ",\"arena_used_bytes\":" << used_bytes_snapshot()
1975
+ << ",\"rss_bytes\":" << process_rss_bytes()
1976
+ << ",\"n_slots\":" << n_slots_
1977
+ << ",\"n_small_slots\":" << n_small_
1978
+ << ",\"n_big_slots\":" << n_big_
1979
+ << ",\"small_slot_bytes\":" << small_bytes_
1980
+ << ",\"big_slot_bytes\":" << big_bytes_
1981
+ << ",\"slot_bytes\":" << slot_bytes_
1982
+ << "}";
1983
+ std::ofstream f(cfg_.stats_path, std::ios::app);
1984
+ if (f) {
1985
+ f << o.str() << "\n";
1986
+ }
1987
+ }
1988
+
1989
+ } // namespace sdq
1990
+ namespace sdq {
1991
+ // 由 CLI 呼叫:依環境變數/參數初始化 pager
1992
+ bool sdq_pager_active_for_layer(int il) {
1993
+ pager & P = pager::instance();
1994
+ if (!P.ready() || P.layer(il) == nullptr) {
1995
+ return false;
1996
+ }
1997
+ // 除錯用:SDQ_MOE_LO / SDQ_MOE_HI 可以只讓部分層走分頁路徑,
1998
+ // 用來二分搜尋第一個發生數值差異的層。
1999
+ static const int lo = [] { const char * e = getenv("SDQ_MOE_LO"); return e ? atoi(e) : 0; }();
2000
+ static const int hi = [] { const char * e = getenv("SDQ_MOE_HI"); return e ? atoi(e) : 1 << 30; }();
2001
+ return il >= lo && il < hi;
2002
+ }
2003
+
2004
+ bool sdq_init(const std::string & model_path) {
2005
+ config c;
2006
+ c.model_path = model_path;
2007
+ c.arena_bytes = env_size("SDQ_ARENA_MB", 0) << 20;
2008
+ c.ram_budget_bytes = env_size("SDQ_RAM_BUDGET_MB", 0) << 20;
2009
+ c.slot_bytes = env_size("SDQ_SLOT_KB", 0) << 10;
2010
+ c.prefetch = env_flag("SDQ_PREFETCH", true);
2011
+ // ---- OPT-1:熱門 expert 剖面(Strata 式)----
2012
+ if (const char * pi = getenv("SDQ_PROFILE_IN")) { c.profile_in = pi; }
2013
+ if (const char * po = getenv("SDQ_PROFILE_OUT")) { c.profile_out = po; }
2014
+ c.hot_pin = (int) env_size("SDQ_HOT_PIN", 0);
2015
+ c.admit_min_hits = (int) env_size("SDQ_ADMIT_MIN_HITS", 1);
2016
+ c.fadvise_dontneed = env_flag("SDQ_FADV_DONTNEED", true);
2017
+ c.io_parallel = env_flag("SDQ_IO_PARALLEL", true);
2018
+ c.io_uring = env_flag("SDQ_IO_URING", false);
2019
+ c.io_threads = (int) env_size("SDQ_IO_THREADS", 0);
2020
+ c.io_split = (int) env_size("SDQ_IO_SPLIT", 1);
2021
+ c.verbose = env_flag("SDQ_VERBOSE", false);
2022
+ const char * sp = getenv("SDQ_STATS_FILE");
2023
+ if (sp) {
2024
+ c.stats_path = sp;
2025
+ }
2026
+ sdq_set_config(c);
2027
+ try {
2028
+ pager::instance().init(c, nullptr);
2029
+ } catch (const std::exception & e) {
2030
+ fprintf(stderr, "[sdq] pager 初始化失敗:%s\n", e.what());
2031
+ return false;
2032
+ }
2033
+ return pager::instance().ready();
2034
+ }
2035
+
2036
+
2037
+ // ---------------------------------------------------------------- 除錯:dump 任意 F32 張量
2038
+ // 目的:讓「原生 mul_mat_id 路徑」與「SDQ 分頁路徑」輸出同一層的 MoE 結果,
2039
+ // 兩邊各自 dump 一次就能直接 A/B 對照,判定到底是哪一邊算錯。
2040
+ namespace {
2041
+ struct dump_arg { std::string path; };
2042
+
2043
+ void dump_op(ggml_tensor * dst, int ith, int /*nth*/, void * userdata) {
2044
+ if (ith != 0) {
2045
+ return;
2046
+ }
2047
+ const ggml_tensor * src = dst->src[0];
2048
+ dump_arg * a = (dump_arg *) userdata;
2049
+ FILE * f = fopen(a->path.c_str(), "wb");
2050
+ if (!f) {
2051
+ fprintf(stderr, "[sdq-dump] 無法開啟 %s\n", a->path.c_str());
2052
+ return;
2053
+ }
2054
+ const int64_t meta[6] = { src->ne[0], src->ne[1], src->ne[2], src->ne[3],
2055
+ (int64_t) ggml_nbytes(src), 0 };
2056
+ fwrite(meta, sizeof(int64_t), 6, f);
2057
+ // 只支援 F32 contiguous;其他情況就明確報錯,不要假設
2058
+ if (src->type != GGML_TYPE_F32 || !ggml_is_contiguous(src)) {
2059
+ fprintf(stderr, "[sdq-dump] %s 不是 contiguous F32(type=%d)\n",
2060
+ a->path.c_str(), (int) src->type);
2061
+ } else {
2062
+ fwrite(src->data, sizeof(float), (size_t) (ggml_nelements(src)), f);
2063
+ }
2064
+ fclose(f);
2065
+ fprintf(stderr, "[sdq-dump] %s (%lld 個元素)\n", a->path.c_str(),
2066
+ (long long) ggml_nelements(src));
2067
+ }
2068
+ } // namespace
2069
+
2070
+ ggml_tensor * sdq_maybe_dump_tensor(ggml_context * ctx, ggml_tensor * t, int il) {
2071
+ const char * prefix = getenv("SDQ_MOE_OUT_DUMP");
2072
+ if (!prefix || !t) {
2073
+ return t;
2074
+ }
2075
+ int layer = getenv("SDQ_MOE_OUT_DUMP_LAYER") ? atoi(getenv("SDQ_MOE_OUT_DUMP_LAYER")) : -1;
2076
+ if (layer >= 0 && il != layer) {
2077
+ return t;
2078
+ }
2079
+ dump_arg * a = new dump_arg{ std::string(prefix) + "." + std::to_string(il) };
2080
+ return ggml_custom_4d(ctx, GGML_TYPE_F32, t->ne[0], t->ne[1], t->ne[2], t->ne[3],
2081
+ &t, 1, dump_op, 1, a);
2082
+ }
2083
+
2084
+ void sdq_shutdown() {
2085
+ pager & P = pager::instance();
2086
+ sdq_print_time_profile();
2087
+ P.dump_stats("shutdown");
2088
+ P.print_summary();
2089
+ P.shutdown();
2090
+ }
2091
+ } // namespace sdq
cpp/sdq_pager.h ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SDQ expert pager:SSD → RAM → CPU 的 MoE 專家分層快取
2
+ //
3
+ // 設計要點
4
+ // * 分頁單位 = 一個 expert(layer, expert id),大小 ~1.8 MB(三個矩陣:gate/up/down)
5
+ // * RAM 端是一個固定槽位的 arena(slab),每槽 2 MiB,O(1) 配置、不碎片化
6
+ // * 淘汰策略:LRU + 本 token 使用中(pinned)的保護
7
+ // * 預取:依「上一個 token 的專家選擇」用一階 Markov 轉移預測下一個 token 的專家,
8
+ // 交給背景執行緒 pread(),量測「預取命中率 / 預取浪費量」
9
+ // * 指標:hit/miss、SSD 讀取位元組、demand 停滯時間、prefetch 領先時間、arena 使用量
10
+ //
11
+ // 不使用 swap:所有權重資料只存在 arena(實體 RAM)與檔案(SSD)兩處。
12
+
13
+ #pragma once
14
+
15
+ #include <ggml.h>
16
+ #include <ggml-backend.h>
17
+ #include "llama.h"
18
+
19
+ #include <atomic>
20
+ #include <chrono>
21
+ #include <condition_variable>
22
+ #include <cstddef>
23
+ #include <cstdint>
24
+ #include <deque>
25
+ #include <memory>
26
+ #include <mutex>
27
+ #include <string>
28
+ #include <thread>
29
+ #include <unordered_map>
30
+ #include <vector>
31
+
32
+ namespace sdq {
33
+
34
+ // ---------------------------------------------------------------- GGUF 讀取
35
+ struct gguf_info {
36
+ int64_t n_layer = 0;
37
+ int64_t n_expert = 0;
38
+ int64_t n_expert_used = 0;
39
+ int64_t n_embd = 0;
40
+ int64_t n_ff_exp = 0;
41
+ int64_t n_ff_shexp = 0;
42
+ int64_t n_head = 0;
43
+ int64_t n_head_kv = 0;
44
+ int64_t n_embd_head_k = 0;
45
+ int64_t n_embd_head_v = 0;
46
+ int64_t context_length = 0;
47
+ int64_t full_attention_interval = 4;
48
+ int64_t is_recr[512] = {0};
49
+ std::string arch;
50
+ std::string name;
51
+ };
52
+
53
+ // 一個 expert 槽位:gate/up/down 三段(有些模型把 gate+up 合成 gate_up)
54
+ struct seg_desc {
55
+ uint64_t off = 0; // 檔案內位元組偏移
56
+ uint64_t size = 0; // 位元組數
57
+ };
58
+
59
+ enum part : int { GATE = 0, UP = 1, DOWN = 2, COUNT = 3 };
60
+
61
+ struct expert_desc {
62
+ seg_desc seg[3];
63
+ int64_t slot_off[3] = {0, 0, 0}; // 三段在 arena 槽位內的相對偏移
64
+ int64_t ne0 = 0; // gate/up row 長度(= n_embd)
65
+ int64_t ne1 = 0; // gate/up row 數(= n_ff_exp)
66
+ int64_t down_ne0 = 0; // down row 長度(= n_ff_exp)
67
+ int64_t down_ne1 = 0; // down row 數(= n_embd)
68
+ ggml_type type[3] = {GGML_TYPE_COUNT, GGML_TYPE_COUNT, GGML_TYPE_COUNT};
69
+ int64_t bytes = 0; // 三段總大小
70
+ bool fused_gate_up = false;
71
+ };
72
+
73
+ // 一層的所有 expert 描述(40 層 × 256 個)
74
+ struct layer_desc {
75
+ std::vector<expert_desc> experts;
76
+ int64_t bytes_per_expert = 0;
77
+ // 這個層的 expert 比「大多數層」大(Q4_K/Q5_K vs Q6_K)→ 需要大槽位。
78
+ // 不然的話所有槽位都得按最大的那種配,白白浪費 6.8% 的 arena。
79
+ bool needs_big_slot = false;
80
+ };
81
+
82
+ // ---------------------------------------------------------------- 指標
83
+ struct stats {
84
+ std::atomic<uint64_t> expert_requests{0}; // 每個 (layer, token) 需要幾個 expert
85
+ std::atomic<uint64_t> expert_hits{0};
86
+ std::atomic<uint64_t> expert_misses{0};
87
+ std::atomic<uint64_t> ssb_read_bytes_demand{0};
88
+ std::atomic<uint64_t> ssb_read_bytes_prefetch{0};
89
+ std::atomic<uint64_t> prefetch_issued{0};
90
+ std::atomic<uint64_t> prefetch_completed{0};
91
+ std::atomic<uint64_t> prefetch_used{0}; // 預取後真的被用到
92
+ std::atomic<uint64_t> prefetch_duplicate{0};// 預取時已經在 RAM 裡
93
+ std::atomic<uint64_t> evictions{0};
94
+ std::atomic<uint64_t> demand_wait_us{0}; // 因為缺頁而停下來等 SSD 的時間
95
+ std::atomic<uint64_t> prefetch_wait_us{0};
96
+ std::atomic<uint64_t> tokens{0};
97
+ std::atomic<uint64_t> moe_calls{0};
98
+ std::atomic<uint64_t> flops{0};
99
+ };
100
+
101
+ // ---------------------------------------------------------------- pager
102
+ struct config {
103
+ size_t arena_bytes = 0; // 0 = 自動(依 RAM 預算)
104
+ size_t slot_bytes = 0; // 0 = 自動(依模型推導)
105
+ int n_threads = 0;
106
+ bool prefetch = true;
107
+ int prefetch_ahead_layers = 0; // >0 時限制同時在飛的預取數量
108
+ bool fadvise_dontneed = true; // 讀完丟掉 kernel page cache,讓 RAM 帳目誠實
109
+ bool verbose = false;
110
+ uint64_t ram_budget_bytes = 0; // 0 = 自動(實體 RAM 的一小部分)
111
+ std::string model_path;
112
+ std::string stats_path;
113
+ int slot_bias = 0;
114
+
115
+ // ---- I/O 執行緒池 ----
116
+ // 為什麼需要:一個 expert 在檔案上是 gate / up / down 三段「不相鄰」的資料,
117
+ // 循序讀要 3 次 pread,而單一 pread 在本機量到約 7 ms(1.9 MB / 275 MB/s)。
118
+ // 也就是說「讀一個 expert」的延遲完全由這三段循序決定,執行緒再多也沒用。
119
+ // 把三段交給三條執行緒同時讀,單一 expert 的延遵掉到約 1/2~1/3。
120
+ // (實測:8 個 expert 全部 miss 的一層,序列讀 12.6 ms → 段平行 7.6 ms。)
121
+ bool io_parallel = true; // SDQ_IO_PARALLEL=0 可關(回到序列讀)
122
+ bool io_uring = false; // SDQ_IO_URING=1:以 liburing 提交同批非同步讀取
123
+ int io_threads = 0; // 0 = 自動(3 × n_threads,上限 48)
124
+ int io_split = 1; // SDQ_IO_SPLIT:每一段再切成幾塊並行讀(實測 1 最好)
125
+
126
+ // ---- Strata 式「熱門 expert 剖面」(OPT-1)----
127
+ // 離線用代表性文字跑一次,把每個 (layer, expert) 的請求次數記成剖面;
128
+ // 執行時把排行榜前 hot_pin 個釘成「熱頁」:熱頁可以被讀取,但**永遠不被淘汰**,
129
+ // 淘汰時一律先從冷頁挑。這解決的是「prefill 的一次性專家把熱區洗掉」。
130
+ std::string profile_in; // 讀取剖面(SDQ_PROFILE_IN)
131
+ std::string profile_out; // 結束時輸出剖面(SDQ_PROFILE_OUT)
132
+ int hot_pin = 0; // 釘選前幾名(SDQ_HOT_PIN)
133
+ int admit_min_hits = 1; // 請求次數低於此值就不收進快取(SDQ_ADMIT_MIN_HITS)
134
+ };
135
+
136
+ class pager {
137
+ public:
138
+ static pager & instance();
139
+
140
+ // 從 GGUF 檔頭建立 expert 頁面表,並配置 RAM arena
141
+ void init(const config & cfg, ggml_context * ctx);
142
+
143
+ bool ready() const { return ready_; }
144
+ size_t arena_bytes() const { return arena_bytes_; }
145
+ size_t slot_bytes() const { return slot_bytes_; }
146
+ size_t n_slots() const { return n_slots_; }
147
+ size_t small_bytes() const { return small_bytes_; }
148
+ size_t big_bytes() const { return big_bytes_; }
149
+ size_t n_small_slots() const { return n_small_; }
150
+ size_t n_big_slots() const { return n_big_; }
151
+ const gguf_info & info() const { return info_; }
152
+
153
+ // 取得 layer 描述(idx 越界或沒資料回 nullptr)
154
+ const layer_desc * layer(int il) const;
155
+
156
+ // 一次把「連續編號的一串 expert」讀進 RAM:同一層的三個矩陣(gate/up/down)本來
157
+ // 就各自連續存放,所以 k 個連續 expert 只需要 3 次大讀取(而不是 3k 次)。
158
+ // 對高延遲的儲存裝置(每個 request 都貴)這是最大的單項加速。
159
+ // experts 必須已依編號遞增排序;slots[i] 是 experts[i] 對應的槽位(由呼叫端配置)
160
+ // 回傳實際讀取的位元組數。
161
+ uint64_t read_expert_span(int il, const int32_t * experts, int n, const int32_t * slots);
162
+
163
+ // 只配置槽位(不讀資料):回傳槽位索引,-1 表示沒有空槽
164
+ int reserve_slot(int il, int ie);
165
+
166
+ // 查這個 expert 是否已經在 RAM:命中則 pin 並回傳槽位,未命中回傳 -1(不配置)
167
+ int probe(int il, int ie);
168
+
169
+ // 記錄一次合併讀取的位元組數(給指標用)
170
+ void note_span_read(uint64_t bytes);
171
+
172
+ // 確保 (layer, expert) 在 RAM,回傳槽位索引;-1 表示失敗
173
+ // pinned: 本 token 使用中,不可被淘汰;blocking: 缺頁時是否同步讀 SSD
174
+ int acquire(int il, int ie, bool blocking);
175
+
176
+ // 背景預取:把 page 讀進 arena(非同步),依歷史預測下一個 token 會用到的 expert
177
+ void prefetch_expert(int il, int ie);
178
+ // 「下一層」預取:在 layer L 的 op 開頭呼叫,替 layer L+1 排預取。
179
+ // 與 end_token() 的差別在時機 —— 後者在一個 token 的 40 層全算完才跑,
180
+ // 那時排的預取沒有任何計算可以重疊,實測使用率只有 0~8 %。
181
+ void prefetch_layer_ahead(int il);
182
+
183
+ // 每層計算開始時呼叫:解除上一層的 pin(本層用到的槽位才受保護)
184
+ void unpin_all();
185
+ // 用完一個槽位後立刻放掉 pin(成對呼叫 acquire())。
186
+ //
187
+ // 為什麼需要:原本 pin 是「整層釘住、下一層開頭才 unpin_all」。mode C 的
188
+ // expert_forward 回傳之後就再也不讀那個槽位(最後的歸約只讀各執行緒自己的
189
+ // partial 累加緩衝),所以繼續釘著只是白白把工作集撐大。
190
+ // 撐大的後果很具體:prefill 一個 ubatch 有 128 個 token,一層最多需要
191
+ // 256 個不同的 expert → 整層釘住 = 256 個槽位不能回收。大小兩種槽位之下,
192
+ // 大槽位只有約 n_big_layers × n_expert_used 個,於是**必定**在 prefill 時
193
+ // 「找不到能放 il=34 ie=… 的槽位」而中止。
194
+ void release(int s);
195
+ void note_used(int il, int ie);
196
+ void end_token();
197
+
198
+ // 依歷史預測某層下一個 token 的 top-k expert(回傳信心 0..1)
199
+ void predict(int il, int32_t * out, int k);
200
+
201
+ void shutdown();
202
+ stats & get_stats() { return stats_; }
203
+
204
+ // 把當前指標寫成一行 JSON(append 到 stats_path)
205
+ void dump_stats(const char * phase);
206
+ void print_summary() const;
207
+
208
+ // arena 內的槽位指標
209
+ // 槽位索引空間是統一的:[0, n_small_) 是小槽位(連續),
210
+ // [n_small_, n_slots_) 是大槽位(接在小槽位區之後)。
211
+ uint8_t * slot_ptr(int s) {
212
+ return (size_t) s < n_small_
213
+ ? arena_ + (size_t) s * small_bytes_
214
+ : arena_ + small_bytes_ * n_small_ + (size_t)(s - (int) n_small_) * big_bytes_;
215
+ }
216
+ size_t slot_size_of(int s) const {
217
+ return (size_t) s < n_small_ ? small_bytes_ : big_bytes_;
218
+ }
219
+ bool need_big_slot(int il, int ie) const;
220
+
221
+ // 這個槽位算不算「大槽位」。
222
+ //
223
+ // 判斷依據是**槽位的實際大小**,不是索引 >= n_small_:
224
+ // 統一尺寸(SDQ_FORCE_UNIFORM_SLOT=1,或模型只有一種 expert 大小)時,
225
+ // 所有槽位的索引都 < n_small_ 但大小都是大的 —— 用索引判斷會讓大 expert
226
+ // 永遠配不到槽位,整個推論卡在 admit 的重試迴圈裡。
227
+ bool is_big_slot(int s) const {
228
+ return big_bytes_ && slot_size_of(s) >= big_bytes_;
229
+ }
230
+
231
+ size_t used_bytes() const { return used_bytes_snapshot(); }
232
+ double hit_rate() const;
233
+
234
+ // arena 裡實際被占用的位元組數。大小兩種槽位之後不能用「槽位數 × slot_bytes_」
235
+ // 計算(slot_bytes_ 是大的那一種),所以真的把槽位走一遍。
236
+ // 呼叫端:dump_stats(每次解碼階段結束)與 print_summary,不是熱路徑。
237
+ size_t used_bytes_snapshot() const {
238
+ size_t b = 0;
239
+ for (size_t s = 0; s < slots_.size(); s++) {
240
+ if (slots_[s].layer >= 0) {
241
+ b += slot_size_of((int) s);
242
+ }
243
+ }
244
+ return b;
245
+ }
246
+
247
+ // 讀 SSD(同步,給 acquire 用)
248
+ bool read_expert(int il, int ie, uint8_t * dst);
249
+
250
+ // ---- I/O 執行緒池(見 config::io_parallel 的說明)----
251
+ // 一個「讀取批次」的完成狀態;放在呼叫端的 stack 上,worker 只在 remaining==0
252
+ // 之後才會再碰它,所以離開 read_expert_par() 之後不會有 dangling pointer。
253
+ struct io_ctx {
254
+ std::atomic<int> remaining{0};
255
+ bool ok = true;
256
+ };
257
+ struct io_job {
258
+ uint64_t off;
259
+ uint8_t * dst;
260
+ size_t size;
261
+ io_ctx * ctx;
262
+ };
263
+
264
+ void io_pool_start();
265
+ void io_pool_stop();
266
+ void io_worker(size_t wid);
267
+ bool read_expert_par(int il, int ie, uint8_t * dst);
268
+ bool read_expert_uring(int il, int ie, uint8_t * dst);
269
+ bool read_range_staged(uint64_t off, uint8_t * dst, size_t size,
270
+ uint8_t * stage, size_t stage_sz);
271
+
272
+ // I/O 執行緒池每條 worker 自帶的 O_DIRECT 對齊緩衝(啟動時配置,已計入 RSS)
273
+ struct io_stage { uint8_t * buf = nullptr; size_t sz = 0; };
274
+ std::vector<io_stage> io_stage_;
275
+
276
+ bool io_pool_on_ = false;
277
+ int io_threads_ = 0;
278
+ int io_split_ = 1;
279
+ std::vector<std::thread> io_pool_;
280
+ std::mutex io_mu_;
281
+ std::condition_variable io_cv_; // 有工作 / 要停
282
+ std::condition_variable io_done_cv_; // 某個批次讀完了
283
+ std::deque<io_job> io_q_;
284
+ bool io_stop_ = false;
285
+ std::atomic<uint64_t> io_jobs_{0};
286
+ std::atomic<uint64_t> io_wait_ns_{0};
287
+
288
+ private:
289
+ pager() = default;
290
+ ~pager();
291
+
292
+ struct slot_state {
293
+ int32_t layer = -1;
294
+ int32_t expert = -1;
295
+ uint64_t last_use = 0; // LRU 記時
296
+ uint32_t freq = 0; // 這個 expert 被用到幾次(衰減式;用來做 LFU 混合策略)
297
+ uint32_t pin = 0; // >0 時不可淘汰
298
+ bool busy = false; // 背景執行緒正在寫入
299
+ bool is_hot = false; // 屬於剖面排行榜:淘汰時最後才會被選中
300
+ };
301
+
302
+ struct pf_item {
303
+ int slot;
304
+ int il;
305
+ int ie;
306
+ uint64_t issue_time_us;
307
+ };
308
+
309
+ void touch_freq_locked(slot_state & st);
310
+ // ---- OPT-1:剖面 / 熱頁 ----
311
+ void count_request(int il, int ie); // 記一次請求(給剖面用)
312
+ bool load_profile(const std::string & path); // 讀剖面並標記前 hot_pin 名
313
+ void dump_profile(const std::string & path); // 輸出排行榜
314
+ bool hot_lookup(int il, int ie) const;
315
+ void load_gguf_header(const std::string & path);
316
+ void build_page_map(const std::string & path);
317
+ void alloc_arena();
318
+ int find_slot_locked(int il, int ie);
319
+ int admit_slot_locked(int il, int ie);
320
+ int victim_locked() { return admit_slot_locked(-1, -1); }
321
+ int victim_stale_locked(uint64_t stale_before);
322
+ int victim_stale_for(int il, int ie, uint64_t stale_before);
323
+ void pf_worker();
324
+ bool read_range(uint64_t off, uint8_t * dst, size_t size);
325
+ bool read_range_direct(uint64_t off, uint8_t * dst, size_t size);
326
+ void open_model_fd();
327
+
328
+ bool ready_ = false;
329
+ config cfg_;
330
+ gguf_info info_;
331
+ std::vector<layer_desc> layers_;
332
+
333
+ uint8_t * arena_ = nullptr;
334
+ size_t arena_bytes_ = 0;
335
+ size_t slot_bytes_ = 0; // = max(small_bytes_, big_bytes_),保留給舊的訊息輸出
336
+ size_t small_bytes_ = 0; // 小槽位大小(大多數層用)
337
+ size_t big_bytes_ = 0; // 大槽位大小;0 = 這個模型沒有大層
338
+ size_t n_small_ = 0;
339
+ size_t n_big_ = 0;
340
+ size_t n_slots_ = 0;
341
+ int fd_ = -1;
342
+ int fd_direct_ = -1; // O_DIRECT:完全繞過 kernel page cache
343
+ bool use_direct_ = false;
344
+
345
+ mutable std::mutex mu_;
346
+ std::vector<slot_state> slots_;
347
+ std::unordered_map<int64_t, int32_t> page_; // (layer<<16|expert) -> slot
348
+ std::vector<int32_t> free_slots_; // 空的小槽位
349
+ std::vector<int32_t> free_big_slots_;
350
+
351
+ // ---- OPT-1:剖面 / 熱頁(Strata 式:靜態排行榜 + 熱頁不淘汰)----
352
+ std::vector<uint32_t> prof_; // 每個 (layer, expert) 的累計請求次數
353
+ std::vector<uint8_t> hot_; // 是否在排行榜前 hot_pin 名
354
+ int n_hot_ = 0;
355
+ std::string profile_out_;
356
+ uint32_t freq_tick_ = 0;
357
+ std::atomic<size_t> used_{0};
358
+ uint64_t clock_ = 0;
359
+
360
+ // 背景預取
361
+ std::thread pf_;
362
+ std::mutex pf_mu_;
363
+ std::condition_variable pf_cv_;
364
+ std::deque<pf_item> pf_q_;
365
+ bool pf_stop_ = false;
366
+ std::atomic<uint64_t> pf_inflight_{0};
367
+
368
+ // 路由歷史(一階 Markov:上個 token 的 expert -> 這次被選到的次數)
369
+ struct trans { int32_t to[64]; uint32_t cnt[64]; int32_t n = 0; };
370
+ std::vector<std::vector<trans>> hist_; // [layer][prev_expert]
371
+ std::vector<std::vector<int32_t>> prev_used_; // [layer] 上一個 token 用到的 expert(預測用)
372
+ std::vector<std::vector<int32_t>> cur_used_; // [layer] 當前 token 用到的 expert
373
+ std::mutex hist_mu_; // 保護 cur_used_ / prev_used_ / hist_(多執行緒會同時 note_used)
374
+ std::atomic<uint64_t> hist_lock_{0};
375
+
376
+ stats stats_;
377
+ };
378
+
379
+ // ---------------------------------------------------------------- 工具
380
+ uint64_t now_us();
381
+ size_t process_rss_bytes();
382
+ size_t process_anon_bytes();
383
+ size_t enforced_memory_ceiling_bytes();
384
+ size_t physical_ram_bytes();
385
+
386
+ // ---------------------------------------------------------------- ggml 介接
387
+ // 自訂 buffer type:expert 權重佔 0 個 RAM 位元組,資料留在檔案裡
388
+ ggml_backend_buffer_type_t sdq_expert_buft();
389
+
390
+ // 註冊 model params 的 tensor buffer override(expert 權重 → 我們的 buffer)
391
+ void sdq_install_buft_overrides(llama_model_params * params);
392
+
393
+ // 建立被分頁的 MoE FFN(替代 build_moe_ffn 裡的三次 mul_mat_id)
394
+ ggml_tensor * sdq_build_moe(ggml_context * ctx, ggml_tensor * cur,
395
+ ggml_tensor * ids, ggml_tensor * weights, int il,
396
+ int64_t n_expert, int64_t n_expert_used,
397
+ float swiglu_limit);
398
+
399
+ // 設定 arena 大小 / 預取開關等(CLI 在載入模型前呼叫)
400
+ bool sdq_init(const std::string & model_path);
401
+ void sdq_set_config(const config & c);
402
+ const config & sdq_get_config();
403
+ void sdq_set_enabled(bool on);
404
+ void sdq_shutdown();
405
+
406
+ bool sdq_enabled();
407
+
408
+ // 效能計時探針的結果(SDQ_PROFILE_TIME=1)
409
+ void sdq_print_time_profile();
410
+
411
+ // 自我測試:算一次指定 expert 的 MoE 輸出(給 NumPy 對照用)
412
+ int sdq_moe_selftest(int il, int ie, const float * x, float * out_io,
413
+ int64_t n_embd, int64_t n_ff, int n_tok);
414
+
415
+ // 建立我們的 MoE 自訂節點。
416
+ // **必須把 gf 傳進來並呼叫 ggml_build_forward_expand(gf, out)**:
417
+ // ggml 的排程器只會為「進了圖的節點」配置 compute buffer,沒註冊的節點其輸出
418
+ // 緩衝區會被 liveness 分析判成可重用,導致 layer>=1 的結果被其他張量覆蓋
419
+ // (實測症狀:layer 0 正確、layer 1 起整個錯掉)。
420
+ ggml_tensor * sdq_build_moe(ggml_cgraph * gf, ggml_context * ctx, ggml_tensor * cur,
421
+ ggml_tensor * ids, ggml_tensor * weights, int il,
422
+ int64_t n_expert, int64_t n_expert_used, float swiglu_limit);
423
+
424
+ // 這一層的分頁是否可用(graph build 時呼叫)
425
+ bool sdq_pager_active_for_layer(int il);
426
+
427
+ // 除錯/驗證用:把某個 F32 張量 dump 到檔案。
428
+ // 必須在「原生 mul_mat_id 路徑」與「SDQ 分頁路徑」兩邊都呼叫,才能做真正的 A/B 對照
429
+ // (env: SDQ_MOE_OUT_DUMP=prefix、SDQ_MOE_OUT_DUMP_LAYER=層編號,-1 = 全部)
430
+ ggml_tensor * sdq_maybe_dump_tensor(ggml_context * ctx, ggml_tensor * t, int il);
431
+
432
+
433
+ } // namespace sdq
demo.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """EPM 單一入口 demo:從「什麼都沒有」到「可以聊天」。
3
+
4
+ python demo.py
5
+
6
+ 會依序做這些事(每一步都可以重跑而不會壞掉:續傳、增量編譯、已存在就跳過):
7
+
8
+ 1. 下載本專案的 code(從 Hugging Face model repo)
9
+ —— 如果你已經在本專案的原始碼目錄裡執行,就直接用現有的,不重下。
10
+ 2. 下載模型 `openbmb/MiniCPM-V-4.7-35B-A3B-gguf/MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf`
11
+ (19.71 GB,支援續傳;中斷再跑會從斷點繼續)
12
+ 3. 取得 llama.cpp(**固定 commit**)+ 套用 EPM patch + 編譯
13
+ 4. 以 8 GiB 實體 RAM 預算啟動 `epq-chat`,進入互動式聊天
14
+
15
+ EPM pager budget 是「實體 RAM 預算」,不是虛擬記憶體上限,也不會停用系統 swap。
16
+
17
+ 注意:SDQ_RESERVE_MB 是逐模型、逐 ctx/ubatch 校正出來的預留量。本模型在
18
+ ctx=4096 / ubatch=128 / 8 GiB 預算下是 512 MiB;換 RAM 預算必須重新校正
19
+ (見 docs/DESIGN.md §3)。
20
+ 要取得嚴格 8 GiB / no-swap 的證據,請用 `tools/strict8g.py`(或
21
+ `python -m sdq.strict_bench`)把整個行程管治起來;方法與限制見
22
+ docs/STRICT_8GIB.md。
23
+
24
+ 設計原則:任何一步都可以單獨重跑;失敗時不會留下壞掉的半成品。
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import os
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+
37
+ HERE = Path(__file__).resolve().parent
38
+ sys.path.insert(0, str(HERE))
39
+
40
+ try:
41
+ from sdq import config as C
42
+ except Exception: # 允許在 code 尚未下載完時也能顯示說明
43
+ C = None
44
+
45
+ DEFAULT_ROOT = Path(os.environ.get(
46
+ "EPM_HOME", os.environ.get("SDQ_HOME", str(Path.home() / "minicpm47-epm"))
47
+ ))
48
+
49
+ # 從 HF 拉回來的檔案(給 fetch_code 用)
50
+ # 從 HF 拉回來的檔案(給 fetch_code 用)。
51
+ # tools/*.c 也要拉:tools/strict8g.py 會在執行時把它編譯成 LD_PRELOAD helper。
52
+ CODE_PATTERNS = [
53
+ "*.py", "*.md", "*.c",
54
+ "patches/*", "cpp/*", "docs/*", "tools/*", "sdq/*.py",
55
+ "bench/expert_index.json",
56
+ ]
57
+
58
+
59
+ def log(msg: str) -> None:
60
+ print(f"[demo] {msg}", flush=True)
61
+
62
+
63
+ def run(cmd: list, **kw) -> subprocess.CompletedProcess:
64
+ log("$ " + " ".join(str(c) for c in cmd))
65
+ return subprocess.run([str(c) for c in cmd], **kw)
66
+
67
+
68
+ def human(n: float) -> str:
69
+ for u in ("B", "KiB", "MiB", "GiB", "TiB"):
70
+ if abs(n) < 1024:
71
+ return f"{n:.2f} {u}"
72
+ n /= 1024
73
+ return f"{n:.1f} PiB"
74
+
75
+
76
+ def cfg(name: str, fallback):
77
+ return getattr(C, name, fallback) if C else fallback
78
+
79
+
80
+ def reload_config(code_dir: Path) -> None:
81
+ """code 是從 HF 下載到別的目錄時,改用那份 code 裡的 sdq/config.py。"""
82
+ global C
83
+ if str(code_dir) not in sys.path:
84
+ sys.path.insert(0, str(code_dir))
85
+ for mod in [m for m in list(sys.modules) if m == "sdq" or m.startswith("sdq.")]:
86
+ del sys.modules[mod]
87
+ try:
88
+ from sdq import config as new_c
89
+ C = new_c
90
+ except Exception:
91
+ pass
92
+
93
+
94
+ # ---------------------------------------------------------------- 1) 取得 code
95
+ def fetch_code(dest: Path, force: bool = False) -> Path:
96
+ """如果目前就在本專案的原始碼裡,直接回傳;否則從 HF 下載。"""
97
+ if (dest / "cpp" / "sdq_pager.cpp").exists() and (dest / cfg("PATCH_FILE", "")).exists() \
98
+ and not force:
99
+ log(f"已經在 EPM 專案原始碼中:{dest}")
100
+ return dest
101
+ log(f"從 Hugging Face 下載專案 code:{cfg('HF_REPO', 'HelloSun/minicpm47woek')}")
102
+ from huggingface_hub import snapshot_download
103
+
104
+ dest.mkdir(parents=True, exist_ok=True)
105
+ snapshot_download(
106
+ repo_id=cfg("HF_REPO", "HelloSun/minicpm47woek"),
107
+ repo_type="model",
108
+ local_dir=dest,
109
+ allow_patterns=CODE_PATTERNS,
110
+ token=os.environ.get("HF_TOKEN"),
111
+ )
112
+ if not (dest / cfg("PATCH_FILE", "")).exists():
113
+ raise SystemExit(
114
+ f"下載完成但缺少 {cfg('PATCH_FILE', '')};請確認 HF 上的 code 完整,"
115
+ "或直接在專案原始碼目錄執行 demo.py"
116
+ )
117
+ log(f"code 已下載到 {dest}")
118
+ return dest
119
+
120
+
121
+ # ---------------------------------------------------------------- 2) 模型
122
+ def fetch_model(models_dir: Path, force: bool = False) -> Path:
123
+ repo = cfg("MODEL_REPO", "openbmb/MiniCPM-V-4.7-35B-A3B-gguf")
124
+ name = cfg("MODEL_FILE", "MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf")
125
+ size = cfg("MODEL_SIZE", 21_166_255_008)
126
+ path = models_dir / name
127
+ if path.exists() and not force and path.stat().st_size == size:
128
+ log(f"模型已存在:{path}({human(path.stat().st_size)})")
129
+ return path
130
+
131
+ log(f"下載模型 {repo}/{name}({human(size)},可續傳)")
132
+ log(" 需要約 22 GB 磁碟空間;下載到一半再執行本程式會從斷點繼續")
133
+ from huggingface_hub import hf_hub_download
134
+
135
+ models_dir.mkdir(parents=True, exist_ok=True)
136
+ p = hf_hub_download(repo, name, local_dir=str(models_dir),
137
+ token=os.environ.get("HF_TOKEN"))
138
+ got = Path(p)
139
+ if got.stat().st_size != size:
140
+ log(f"⚠️ 檔案大小 {got.stat().st_size} 與預期 {size} 不同,請再跑一次本程式續傳")
141
+ log(f"模型完成:{got}")
142
+ return got
143
+
144
+
145
+ # ---------------------------------------------------------------- 3) 原始碼 + 編譯
146
+ def fetch_source(src_dir: Path, force: bool = False) -> Path:
147
+ """取得固定 commit 的 llama.cpp。已存在且 commit 正確就跳過。"""
148
+ commit = cfg("LLAMA_COMMIT", "836d57176dc699a726c55418e4f96b8ca628e1bf")
149
+ repo = cfg("LLAMA_REPO", "https://github.com/ggml-org/llama.cpp.git")
150
+ src_dir.mkdir(parents=True, exist_ok=True)
151
+
152
+ if (src_dir / ".git").exists() and not force:
153
+ cur = subprocess.run(["git", "rev-parse", "HEAD"], cwd=src_dir,
154
+ capture_output=True, text=True).stdout.strip()
155
+ if cur == commit:
156
+ log(f"llama.cpp 原始碼已存在且版本正確:{src_dir}")
157
+ return src_dir
158
+ log(f"版本不符(目前 {cur[:10]}),重新取得 {commit[:10]}")
159
+ shutil.rmtree(src_dir / ".git")
160
+ shutil.rmtree(src_dir / "src", ignore_errors=True)
161
+
162
+ if not (src_dir / ".git").exists():
163
+ log(f"clone llama.cpp(固定 commit {commit[:10]})")
164
+ tmp = src_dir.parent / (src_dir.name + ".tmp")
165
+ shutil.rmtree(tmp, ignore_errors=True)
166
+ run(["git", "clone", "--filter=blob:none", "--no-checkout", repo, tmp],
167
+ check=True)
168
+ # GitHub 允許依 commit 直接 fetch,失敗就退回淺層 clone 再 checkout
169
+ r = subprocess.run(["git", "fetch", "-q", "--depth", "1", "origin", commit],
170
+ cwd=tmp, capture_output=True, text=True)
171
+ if r.returncode != 0:
172
+ log("依 commit 直接 fetch 失敗,改用完整 clone")
173
+ run(["git", "fetch", "-q", "--tags", "origin"], cwd=tmp, check=True)
174
+ run(["git", "fetch", "-q", "origin"], cwd=tmp, check=True)
175
+ run(["git", "checkout", "-q", commit], cwd=tmp, check=True)
176
+ shutil.rmtree(src_dir, ignore_errors=True)
177
+ tmp.rename(src_dir)
178
+ return src_dir
179
+
180
+
181
+ def apply_patch(src_dir: Path, code_dir: Path = None) -> None:
182
+ """把 SDQ 的 expert pager 引擎套進 llama.cpp 的 src/。
183
+
184
+ `code_dir` 是本專案 code 所在的位置(可能不是 demo.py 旁邊,例如 code 是從 HF
185
+ 下載到 <home>/code 的情況)。
186
+ """
187
+ rel = cfg("PATCH_FILE", "patches/0001-sdq-expert-pager.patch")
188
+ patch = (code_dir or HERE) / rel
189
+ target = src_dir / cfg("PATCH_TARGET_DIR", "src")
190
+ marker = src_dir / ".sdq_patched"
191
+
192
+ if marker.exists() and all((target / f).exists()
193
+ for f in cfg("SDQ_SOURCE_FILES", [])):
194
+ log("SDQ patch 已套用過")
195
+ return
196
+ if not patch.exists():
197
+ raise SystemExit(f"找不到 patch:{patch}")
198
+ if not all((target / f).exists() for f in cfg("SDQ_SOURCE_FILES", [])):
199
+ log("套用 SDQ expert pager patch(引擎檔案會進到 llama.cpp/src/)")
200
+ r = subprocess.run(["git", "apply", "--check", str(patch)], cwd=src_dir,
201
+ capture_output=True, text=True)
202
+ if r.returncode != 0:
203
+ # patch 已經在樹裡(常見於換機/換目錄重建)
204
+ if all((target / f).exists() for f in cfg("SDQ_SOURCE_FILES", [])):
205
+ log("patch 已在樹中(略過重複套用)")
206
+ marker.touch()
207
+ return
208
+ raise SystemExit(f"patch 套用失敗:{r.stderr.strip()}")
209
+ subprocess.run(["git", "apply", str(patch)], cwd=src_dir, check=True)
210
+ # Keep the checked-in pager implementation in sync with the patch's
211
+ # original source payload. This also lets incremental rebuilds consume
212
+ # optional backends added after the historical patch was generated.
213
+ for name in cfg("SDQ_SOURCE_FILES", []):
214
+ source = (code_dir or HERE) / "cpp" / name
215
+ target_file = target / name
216
+ if source.exists() and target_file.exists():
217
+ shutil.copy2(source, target_file)
218
+ marker.touch()
219
+ log("patch 套用完成")
220
+
221
+
222
+ def build(src_dir: Path, jobs: int, force: bool = False) -> Path:
223
+ target = cfg("BUILD_TARGET", "epq-chat")
224
+ binary = src_dir / "build" / "bin" / target
225
+ if binary.exists() and not force:
226
+ log(f"已經編譯過:{binary}")
227
+ return binary
228
+
229
+ build_dir = src_dir / "build"
230
+ log("設定 cmake(CPU 最佳化:AVX2/AVX512 由 GGML_NATIVE 自動開啟)")
231
+ run(["cmake", "-B", str(build_dir), "-S", str(src_dir),
232
+ "-DCMAKE_BUILD_TYPE=Release", "-DGGML_NATIVE=ON",
233
+ "-DLLAMA_CURL=OFF", "-DLLAMA_BUILD_TESTS=OFF",
234
+ "-DLLAMA_BUILD_EXAMPLES=OFF", "-DLLAMA_BUILD_SERVER=OFF"],
235
+ check=True)
236
+ log(f"編譯中(-j{jobs}),參考實測約 2-5 分鐘")
237
+ t0 = time.time()
238
+ run(["cmake", "--build", str(build_dir), "-j", str(jobs), "--target", target],
239
+ check=True)
240
+ log(f"編譯完成({time.time() - t0:.0f}s):{binary}")
241
+ return binary
242
+
243
+
244
+ def reserve_mb(ram_mb) -> int:
245
+ """arena 之外要預留多少。
246
+
247
+ 預留量必須涵蓋 arena sizing 之後才長出來的記憶體:非專家權重第一次 forward
248
+ 才 fault in、CPU compute buffer、KV cache、圖與執行緒堆疊。這是逐模型、
249
+ 逐 ctx/ubatch 校正出來的量,這裡用一條比例規則讓不同 RAM 預算都有合理餘量。
250
+ """
251
+ ram = int(ram_mb)
252
+ if C is not None and getattr(C, "RESERVE_MB", None) and ram == int(C.RAM_BUDGET_MB):
253
+ return int(C.RESERVE_MB)
254
+ return max(128, min(1024, ram // 16))
255
+
256
+
257
+ # ---------------------------------------------------------------- 4) 執行
258
+ def chat(binary: Path, model: Path, args) -> int:
259
+ cmd = [
260
+ binary, "-m", model,
261
+ "-c", args.ctx, "-t", args.threads, "-n", args.n_predict,
262
+ "--ram-budget-mb", args.ram_mb,
263
+ ]
264
+ if args.arena_mb:
265
+ cmd += ["--arena-mb", args.arena_mb]
266
+ if args.no_prefetch:
267
+ cmd += ["--no-prefetch"]
268
+ if args.stats:
269
+ cmd += ["--stats-file", args.stats]
270
+ # SDQ_RESERVE_MB 是逐模型、逐 ctx/ubatch 校正出來的預留量,預設沒有設定。
271
+ # 這裡依 config 的比例規則帶入,否則使用者拿到的是另一個模型的校正值。
272
+ reserve = reserve_mb(args.ram_mb)
273
+ env = dict(os.environ, SDQ_RESERVE_MB=str(reserve))
274
+ log(f"啟動 epq-chat(RAM 預算 {args.ram_mb} MiB,reserve {reserve} MiB;"
275
+ f"改 ctx 或 ubatch 必須重新校正,見 docs/DESIGN.md §3)")
276
+ return subprocess.run([str(c) for c in cmd], env=env).returncode
277
+
278
+
279
+ def main() -> int:
280
+ ap = argparse.ArgumentParser(
281
+ description="MiniCPM-V-4.7-35B-A3B EPM CPU inference: download → build → chat",
282
+ formatter_class=argparse.RawDescriptionHelpFormatter,
283
+ epilog="""常用範例:
284
+ python demo.py # 完整流程,然後進入聊天
285
+ python demo.py --bench # 不聊天,直接跑基準測量(量 RSS / SSD I/O / 命中率 / tok/s)
286
+ python demo.py --verify # 驗證分頁路徑的數值正確性(A/B + NumPy)
287
+ python demo.py --verify --fast # 只跑 8 GiB 內做得完的層(略過端到端 A/B)
288
+ python demo.py --once # 不聊天,單跑一句示範(適合 CI / 腳本)
289
+ python demo.py --model-path /x.gguf # 模型已經在自己電腦上
290
+ python demo.py --rebuild # 強制重新編譯 llama.cpp
291
+ """)
292
+ ap.add_argument("--home", default=str(DEFAULT_ROOT), help="工作目錄(預設 ~/minicpm47-epm)")
293
+ ap.add_argument("--src", help="llama.cpp 原始碼目錄(預設 <home>/llama.cpp)")
294
+ ap.add_argument("--model-path", help="已下載好的模型路徑")
295
+ ap.add_argument("--ctx", default=str(cfg("CTX_VERIFY", 4096)),
296
+ help="上下文長度(模型原生 262144,這裡實測 4K)")
297
+ ap.add_argument("--threads", type=int, default=0, help="CPU 執行緒(0 = 自動)")
298
+ ap.add_argument("--n-predict", type=int, default=cfg("N_PREDICT", 256),
299
+ help="每次回答產生多少 token")
300
+ ap.add_argument("--ram-mb", default=str(cfg("RAM_BUDGET_MB", 8192)),
301
+ help="SDQ pager sizing budget in MiB (not a total RSS/swap limit)")
302
+ ap.add_argument("--arena-mb", type=int, default=cfg("ARENA_MB", 0),
303
+ help="固定 arena 大小(0 = 由 RAM 預算推算)")
304
+ ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4)), help="編譯並行度")
305
+ ap.add_argument("--no-prefetch", action="store_true", help="關閉背景預取")
306
+ ap.add_argument("--skip-code", action="store_true", help="不要抓 HF 上的 code")
307
+ ap.add_argument("--skip-model", action="store_true", help="不要下載模型")
308
+ ap.add_argument("--skip-build", action="store_true", help="不要編譯")
309
+ ap.add_argument("--rebuild", action="store_true", help="強制重新編譯")
310
+ ap.add_argument("--bench", action="store_true", help="跑完就做基準測量,不進聊天")
311
+ ap.add_argument("--verify", action="store_true", help="做正確性驗證(A/B 對照 + NumPy 逐層比對)")
312
+ ap.add_argument("--fast", action="store_true",
313
+ help="--verify 時只跑 8 GiB 內做得完的層:略過端到端 A/B"
314
+ "(那一層需要整個 22 GiB 模型常駐記憶體)")
315
+ ap.add_argument("--once", action="store_true", help="不聊天,單跑一句示範就結束")
316
+ ap.add_argument("--stats", default="", help="把指標寫到指定 JSONL")
317
+ args = ap.parse_args()
318
+
319
+ home = Path(args.home).expanduser().resolve()
320
+ home.mkdir(parents=True, exist_ok=True)
321
+
322
+ # --- 1) code:已在專案原始碼裡就直接用,否則從 HF 抓
323
+ in_source_tree = (HERE / "cpp" / "sdq_pager.cpp").exists()
324
+ if in_source_tree and not args.skip_code:
325
+ code_dir = HERE
326
+ log(f"code:使用目前的專案原始碼 {code_dir}")
327
+ elif args.skip_code:
328
+ code_dir = HERE
329
+ else:
330
+ code_dir = fetch_code(home / "code")
331
+
332
+ log(f"工作目錄:{home}")
333
+ log(f"專案 code:{code_dir}")
334
+ reload_config(code_dir)
335
+
336
+ # --- 2) 模型
337
+ model = Path(args.model_path) if args.model_path else (home / "models" / cfg("MODEL_FILE", ""))
338
+ if not args.skip_model and not args.model_path:
339
+ model = fetch_model(model.parent)
340
+
341
+ # --- 3) 原始碼 + patch + 編譯
342
+ src_dir = Path(args.src) if args.src else (home / "llama.cpp")
343
+ target = cfg("BUILD_TARGET", "epq-chat")
344
+ if args.skip_build:
345
+ binary = src_dir / "build" / "bin" / target
346
+ else:
347
+ fetch_source(src_dir, force=args.rebuild)
348
+ apply_patch(src_dir, code_dir)
349
+ binary = build(src_dir, args.jobs, force=args.rebuild)
350
+
351
+ if not binary.exists():
352
+ raise SystemExit(f"找不到執行檔:{binary}")
353
+
354
+ sys.path.insert(0, str(code_dir))
355
+
356
+ # --- 4a) 正確性驗證
357
+ if args.verify:
358
+ from sdq.verify import run_verification
359
+ if args.fast:
360
+ log("--fast:只跑 kernel / 整層 / 預取壓力;跳過端到端 A/B"
361
+ "(那一層需要整個 22 GiB 模型常駐 RAM,8 GiB 機器上不可能)")
362
+ return run_verification(binary, model, code_dir, threads=args.threads or 8,
363
+ fast=args.fast)
364
+
365
+ # --- 4b) 基準測量
366
+ if args.bench:
367
+ from sdq.bench import main as bench_main
368
+ sys.argv = ["sdq.bench", "--model", str(model), "--bin", str(binary),
369
+ "--ram-mb", args.ram_mb, "--ctx", args.ctx,
370
+ "--threads", str(args.threads or 8), "--label", "demo"]
371
+ return bench_main()
372
+
373
+ # --- 4c) 非互動模式(CI / 腳本)
374
+ if args.once or not sys.stdin.isatty():
375
+ if not args.once:
376
+ log("沒有互動式終端,改用預設提示詞跑一次示範(加 --once 可明確指定)")
377
+ cmd = [binary, "-m", model, "-c", args.ctx, "-t", args.threads,
378
+ "--ram-budget-mb", args.ram_mb, "--no-chat", "--no-interactive",
379
+ "--temp", "0", "-p", "請用一句話介紹你自己。", "-n", "64"]
380
+ reserve = reserve_mb(args.ram_mb)
381
+ log(f"單次示範:RAM 預算 {args.ram_mb} MiB,reserve {reserve} MiB"
382
+ f"(改 ctx 或 ubatch 必須重新校正,見 docs/DESIGN.md §3)")
383
+ log("注意:這個示範不受 strict8g 管治,實際 RSS 可能略高於 RAM 預算;"
384
+ "受管治的量測請用 tools/strict8g.py")
385
+ return subprocess.run([str(c) for c in cmd],
386
+ env=dict(os.environ, SDQ_RESERVE_MB=str(reserve))).returncode
387
+
388
+ # --- 4d) 互動式聊天
389
+ log("")
390
+ log("=" * 68)
391
+ log("開始聊天!模型 19.71 GB 只住在 SSD,RAM 只放有限容量的 expert 快取。")
392
+ log("指令:/exit 離開 /reset 重設對話 /stats 看分頁指標(命中率 / SSD 讀取量)")
393
+ log("=" * 68)
394
+ return chat(binary, model, args)
395
+
396
+
397
+ if __name__ == "__main__":
398
+ sys.exit(main())
docs/CORRECTNESS.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 正確性驗證
2
+
3
+ 分頁路徑的每一層都要能被獨立證明是對的。這份文件記錄三層驗證、決定性證據,
4
+ 以及一個在這個模型上才會出現的數值現象(§4)。
5
+
6
+ 三層的分工:
7
+
8
+ | 層 | 證明什麼 | 對照組 |
9
+ | --- | --- | --- |
10
+ | 1 kernel | 單一 expert 的前向(Q8_K 量化 → 三次量化點積 → SiLU → 加權) | NumPy 純重算,照抄 ggml 的 dequantize 與 quantize 定義 |
11
+ | 2 整層 | 多 token、真實路由、權重縮放、槽位配置、預取路徑 | NumPy 用模型自己產生的 `cur / selected_experts / weights` 重算 |
12
+ | 3 端到端 | 圖層面接線、跨層一致性 | llama.cpp 原生 `mul_mat_id`(**在 8 GiB 上不可能執行**,見 §3.3) |
13
+
14
+ 執行方式:
15
+
16
+ ```bash
17
+ python3 -m sdq.verify --model <gguf> --bin <epq-chat> --fast
18
+ python3 tools/determinism_check.py --model <gguf> --bin <epq-chat> --runs 5 -n 48
19
+ ```
20
+
21
+ ---
22
+
23
+ ## 1. kernel 層級(單一 expert)
24
+
25
+ 四組 (layer, expert),用 `--selftest` 讓 pager 從 arena 算出結果,Python 從
26
+ **同一段 GGUF 位元組**獨立重算:
27
+
28
+ | 層 | Expert | 結果 | max abs diff |
29
+ | --- | ---: | --- | ---: |
30
+ | 0 | 3 | PASS | 3.7e-09 |
31
+ | 17 | 200 | PASS | — |
32
+ | 33 | 7 | PASS | — |
33
+ | 39 | 100 | PASS | — |
34
+
35
+ **為什麼是這四組**:這個模型有**兩種 expert 大小**。
36
+
37
+ * 20 層的 `ffn_down_exps` 是 Q6_K → 每個 expert 2,039,808 B
38
+ * 20 層的 `ffn_down_exps` 是 Q4_K → 每個 expert 1,769,472 B
39
+
40
+ L39 屬於前者,是唯一會走到「大槽位」配置路徑的測試;少了它,槽位配置器把
41
+ 小槽位發給大 expert 的錯誤不會被發現。L17 / L33 屬於後者。
42
+
43
+ `tools/moe_ref.py` 對 Q4_K / Q5_K / Q6_K 三種 block 格式各自實作反量化,
44
+ 其中 Q6_K 的實作另外與 ggml 的 `dequantize_row_q6_K` **逐位元組比對過**
45
+ (4096 個 block,最大差 0)。
46
+
47
+ ## 2. 整層層級(真實路由輸入)
48
+
49
+ 用 `SDQ_MOE_DUMP` 導出某一層的真實輸入(21 個 token,中文提示詞)與輸出:
50
+
51
+ | 層 | max abs diff | max rel | 1-LSB 翻轉上界 | 判定 |
52
+ | --- | ---: | ---: | ---: | --- |
53
+ | 0 | 1.12e-08 | 5.1e-07 | 1.73e-03 | PASS |
54
+ | 39 | 8.82e-05 | 7.7e-03 | 3.22e-02 | PASS(量化邊界,見 §4) |
55
+
56
+ 預取壓力測試(預取開著 + 極小 arena 強制回收,兩種槽位配置各跑一次)4 組全 PASS。
57
+
58
+ ## 3. 端到端與決定性
59
+
60
+ ### 3.3 為什麼沒有端到端 A/B
61
+
62
+ 原生對照組需要把整個 21.17 GB 模型常駐 RAM。在 8 GiB 上這**在定義上不可能**,
63
+ 而這正是本專案存在的理由。實測未修改的 llama.cpp 在 8 GiB 上連模型都載不進去:
64
+
65
+ ```
66
+ ggml_backend_buft_alloc_buffer_n_default: failed to allocate CPU_REPACK buffer of size 15099494400
67
+ llama_model_load: error loading model: unable to allocate CPU_REPACK buffer
68
+ ```
69
+
70
+ 所以等價性改由 kernel / 整層 / 預取壓力三層,加上下面的決定性證據支撐。
71
+
72
+ ### 3.1 決定性(決定性 = 同條件重複執行,逐位元組一致)
73
+
74
+ `tools/determinism_check.py`,5 次執行 × 48 token × 248,144 vocab:
75
+
76
+ ```
77
+ run0: 14.1s run1: 14.1s run2: 14.4s run3: 14.3s run4: 15.6s
78
+ ✅ run1..run4 與 run0 全部逐位元組完全相同
79
+ ```
80
+
81
+ 分頁路徑在同一台機器、同一組條件下是**決定性**的。快取命中與否都包含在這個
82
+ 證據裡:5 次執行的 arena 內容不同(命中模式隨存取順序變動),輸出仍然逐位元組相同。
83
+
84
+ ### 3.2 過程中修掉的問題
85
+
86
+ 1. **GGUF 標頭解析在 BOOL 陣列上錯位。** 這個模型有
87
+ `qwen35moe.attention.recurrent_layers`(40 個元素的 **BOOL 陣列**)。參考實作
88
+ 把陣列元素型別 7 讀成 4 bytes,標頭解析從那裡起全部錯位,最後以
89
+ `basic_string::_M_create`(讀到垃圾長度)失敗。修正:BOOL 陣列元素讀 1 byte,
90
+ 並且讓未知的元素型別**丟出例外**而不是靜默跳過 —— 靜默跳過會讓 `r.o` 停在
91
+ 值的中間,之後每一個 key 都是錯的。
92
+ 這個 key 是混合架構(SSM 層)才有的,參考的 Qwen3.6-35B-A3B 沒有。
93
+
94
+ ## 4. 一個 Q8_K 量化邊界造成的 7.9e-4 差異
95
+
96
+ L39 / 某個 token 上,整層比對差 8.82e-05,超過原本 3e-3 的相對容差門檻
97
+ (該元素 |out| 只有 0.0186,所以相對誤差 7.7e-3)。逐層追查的結果:
98
+
99
+ **這不是 pager 的錯誤,是驗證工具的浮點參考不夠準。** 完整證據鏈:
100
+
101
+ 1. **Q8_K 輸入量化逐位元組一致。** 取出該 token 的 activation,用 ggml 自己的
102
+ `quantize_row_q8_K_ref` 量化,與 Python 端的 481 個 block 總和
103
+ (`bsums`)**全部一致**。
104
+ 2. **權重反量化逐位元組一致。** Q6_K 的 `dequantize_row_q6_K` 在 4096 個 block 上
105
+ 與 NumPy 實作最大差 0。
106
+ 3. **差異只來自 Q8_K 的 1 個 LSB。** 把 C++ 端算出的 SiLU 中間值 `h` 讀回來餵給
107
+ NumPy 參考:481 個非零 Q8_K code 中**剛好 1 個差 1**,輸出立刻從 7.86e-04
108
+ 掉到 **6.7e-08**。
109
+ 4. **上游 `go` 的��異來自 ggml 的 float32 累加。** ggml 的 `vec_dot` 在 float32
110
+ 裡累加,本專案的 NumPy 參考用 float64。C++ 端 `gate` / `up` 與 NumPy 最多差
111
+ 9.5e-07 —— 這是 ggml 側的捨入,不是 NumPy 的。
112
+ 5. **把 `go` 人為擾動 1e-7(相對)就能重現整個差異**,而且只有那一個 token 受影響,
113
+ 同一層其他 5 個 token 仍在 1e-07。
114
+
115
+ 也就是說:`h` 上 1e-6 的差 → Q8_K 四捨五入跨界 1 格 → 輸出跳 7.9e-4。
116
+ **單一 LSB 翻轉的理論上界是 3.22e-02,實測 8.82e-05,差 365 倍。**
117
+
118
+ 因此 `tools/moe_layer_check.py` 的判定改成兩段式:先照舊用固定容差比對,
119
+ 若不通過則檢查實測差異是否落在「單一 Q8_K LSB 翻轉」的理論上界之內,
120
+ 並在輸出裡明確標成 `PASS (quantization-boundary)` —— 而不是把容差默默放寬。
121
+
122
+ **這個現象是模型性質,不是這個 repo 的性質。** 參考的 Qwen3.6-35B-A3B 在同樣的
123
+ 檢查下是 8.11e-06(PASS),因為它沒有碰到這種條件數的輸入。
docs/DESIGN.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 設計:記憶體帳目、SSD→RAM→CPU 資料流
2
+
3
+ 這個模型在 8 GiB 裡能跑,不是因為它小,而是因為**19.71 GiB 的權重裡有 18.16 GiB
4
+ 從來不需要同時在 RAM 裡**。
5
+
6
+ ## 1. 權重帳目
7
+
8
+ `MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf`,21,166,255,008 B = **19.71 GiB**,
9
+ 架構 `qwen35moe`,40 個 block。
10
+
11
+ | 項目 | 大小 |
12
+ | --- | ---: |
13
+ | 路由專家權重(40 層 × 256 個 × 3 個投影) | **18.16 GiB** |
14
+ | 非專家常駐權重(embedding / lm_head / 注意力 / SSM / shared expert) | 1.54 GiB |
15
+ | 單一 token 的專家工作集(40 層 × top-8) | 14.53 MiB 平均 / 15.56 MiB 最大 |
16
+
17
+ 單一 token 在解碼時需要 320 個 layer-expert,但因為槽位分兩種尺寸,正確的說法是
18
+ **160 個大槽位 + 160 個小槽位**。8 GiB 的 arena 塞得下 3714 個槽位,其中約四成是
19
+ 大槽位(20/40 的層用大槽位),所以命中率可以到 70% 以上;2 GiB 的 arena 只有
20
+ 332 個槽位湊不出這個組合,命中率崩到 22.7%(見 README 的 budget 曲線)。
21
+
22
+ ### 兩種 expert 大小
23
+
24
+ 這個模型的 `ffn_down_exps` 量化型別**逐層不同**:
25
+
26
+ | 層 | `ffn_down_exps` | 每個 expert 位元組 |
27
+ | --- | --- | ---: |
28
+ | 20 層 | Q6_K | **2,039,808 B** |
29
+ | 20 層 | Q4_K | **1,769,472 B** |
30
+
31
+ `ffn_gate_exps` 與 `ffn_up_exps` 全部是 Q4_K,每個 589,824 B。
32
+
33
+ 平均下來每個 layer-expert 是 1.95 MiB,因此 arena 的槽位分成兩種尺寸:
34
+ **小 1.69 MiB × 2420 + 大 1.95 MiB × 806**(8 GiB 預算下)。
35
+
36
+ 這一點是這個模型相對於參考實作最重要的結構差異:參考的 Qwen3.6-35B-A3B 是
37
+ 37 層 1,900,544 B + 3 層 2,039,808 B,槽位比例完全不同。**槽位尺寸必須從
38
+ 模型 metadata 推導,不能寫死。**
39
+
40
+ ### 混合架構
41
+
42
+ `qwen35moe.block_count = 40`,但只有 10 層是全注意力
43
+ (`full_attention_interval = 4`),另外 30 層是 SSM / Mamba 層
44
+ (`ssm.*` 張量,`recurrent_layers` 40 個元素全為 1)。
45
+
46
+ 這影響兩件事:
47
+
48
+ 1. **正確性**:模型 metadata 裡因此多了一個 BOOL 陣列,參考實作的 GGUF 標頭
49
+ 解析器會在這裡錯位(見 `CORRECTNESS.md` §3.2)。
50
+ 2. **效能**:SSM 層的計算可以平行,不像注意力那樣受限於序列維度,所以
51
+ **執行緒數的結論與參考實作相反**(見 `NEGATIVE_RESULTS.md`)。
52
+
53
+ ## 2. 資料流
54
+
55
+ ```
56
+ GGUF 檔(19.71 GiB)
57
+ │
58
+ │ 非專家權重:mmap,乾淨的 file-backed 頁
59
+ │ 路由專家權重:自訂 buffer type,只回報 4 KB,
60
+ │ 真正的內容留在檔案裡(pager 用 pread 管理)
61
+ ▼
62
+ SSD → RAM:pager 在需求觸發時把選中的 expert 讀進 arena 槽位
63
+ │
64
+ ▼
65
+ RAM → CPU:ggml 自己的量化點積(ggml_vec_dot_q4_K_q8_K / q6_K_q8_K)
66
+ ```
67
+
68
+ 路由本身(logits / softmax / top-k / 權重正規化)仍然由 ggml 算完,
69
+ `build_moe_ffn` 只在「把被選到的 8 個 expert 算出來並加權求和」這一段
70
+ 交給 `sdq_build_moe`。
71
+
72
+ ## 3. 實作的關鍵決定
73
+
74
+ ### 三段並行讀取
75
+
76
+ 一個 expert 的 gate / up / down 在檔案上互不相鄰。循序讀是 3 × 延遲的鏈,
77
+ 改成三段同時讀之後,一層 8 個全 miss 的 expert 明顯變快。
78
+
79
+ ### 需求觸發的 LRU 淘汰 + pin
80
+
81
+ `acquire()` 是「先讀完再 publish」。`reserve_slot()` 會在資料還沒讀進來之前
82
+ 就把 (layer, expert) 記進 page 表,一旦 pread 失敗,中斷槽就會留在表裡卻是
83
+ 舊資料,之後的 `probe()` 會把它當成命中 → **靜默算錯**。成本相同,但不能這麼做。
84
+
85
+ ### arena sizing 扣的是「匿名 RSS」
86
+
87
+ `statm` 的 resident 把 mmap 進來的模型權重頁也算成已用,但那些是乾淨的
88
+ file-backed 頁,記憶體吃緊時核心會直接回收,既不佔用寶貴的實體 RAM 也不會產生
89
+ swap I/O。把它算進「已用」會讓 arena 被無謂地縮水。
90
+
91
+ ```
92
+ arena = 預算 − 匿名 RSS − SDQ_RESERVE_MB
93
+ ```
94
+
95
+ `RLIMIT_DATA` 只當**上界**,不當預算來源:它算的是匿名**虛擬**記憶體,而
96
+ llama.cpp 會配置大量「映射了但永遠不會 touch」的圖與 compute buffer。
97
+
98
+ ### 8 GiB 下的 reserve 校正
99
+
100
+ `SDQ_RESERVE_MB` 必須**逐模型、逐 ctx/ubatch 校正**。本模型在
101
+ ctx=4096 / ubatch=128 下量到的:
102
+
103
+ | reserve | arena | 槽位 | 峰值匿名 RSS | 8 GiB 內? |
104
+ | ---: | ---: | ---: | ---: | --- |
105
+ | 128 MiB | — | — | 4042.6 MiB | 是(餘 53 MiB,太緊) |
106
+ | 256 MiB | — | — | 3918.1 MiB | 是(餘 178 MiB) |
107
+ | 384 MiB | — | — | 3795.7 MiB | 是(餘 300 MiB) |
108
+ | **512 MiB** | **6.35 GiB** | **3714** | **7799 MiB** | **是(餘 446 MiB)** |
109
+ | 640 MiB | 6.23 GiB | 3644 | 7623.0 MiB | 是(餘 292 MiB) |
110
+ | 768 MiB | 6.11 GiB | 3573 | 7498.5 MiB | 是 |
111
+ | 1024 MiB | 5.87 GiB | 3432 | 7251.6 MiB | 是 |
112
+
113
+ 峰值匿名 RSS 幾乎與 reserve 成 1:1 反比(每減 128 MiB,RSS 多約 123 MiB),
114
+ 因為省下來的預留全部變成 arena。**512 MiB 是這個模型在 8 GiB 下的選擇。**
115
+
116
+ 換 RAM 預算時 reserve 也要跟著換。本 repo 的量測腳本用一條比例規則:
117
+
118
+ ```
119
+ reserve = clamp(RAM 預算 / 16, 128 MiB, 1024 MiB)
120
+ ```
121
+
122
+ 在 8 GiB 重現 512 MiB,在 4 GiB 給出 256 MiB(餘 178 MiB)。
123
+ **用固定的 640 MiB 跑 4 GiB 會直接超出上限。**
124
+
125
+ ## 4. 輸出不直接寫 dst
126
+
127
+ 同一個 token 的 8 個 expert 會撞在一起,所以每個執行緒一份累加緩衝,
128
+ 最後一道 barrier 之後歸約。同時每個 expert 算完立刻 `release()` 它的槽位 ——
129
+ 不放的話 prefill 一層會把整層用到的 expert 全部釘住(最多 256 個),
130
+ 大槽位不夠時會中止。
131
+
132
+ ## 5. 這個設計沒有做的事
133
+
134
+ * **不碰路由。** 路由必須完整算完才知道要哪些 expert,而路由本身很小。
135
+ * **不用 swap。** swap 的延遲不可預測,而且本專案的 no-swap 是核心保證。
136
+ * **不做壓縮。** 權重已經是 Q4_K_M,再壓就是品質換速度,不在本專案範圍。
137
+ * **不處理視覺。** 這個 GGUF 的 vision projector 在另一個檔案
138
+ (`mmproj-model-f16.gguf`),本路徑只做文字推論。
docs/NEGATIVE_RESULTS.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 負結果:試過但沒留下的最佳化
2
+
3
+ 記錄在 8 GiB 上**量過、放棄**的選項。每一項都有原始資料在
4
+ `bench/results/strict_prefetch.json` 與 `bench/results/strict_threads.json`。
5
+
6
+ ## 1. 預取(關閉)
7
+
8
+ pager 可以從上一個 token 的路由預測下一層會用到哪些 expert,在本層計算的時候
9
+ 先把資料讀好。三種設定各 6 輪,交錯執行:
10
+
11
+ | 設定 | 平均 tok/s | 單輪標準差 | 單輪全距 |
12
+ | --- | ---: | ---: | --- |
13
+ | **關閉(預設)** | **6.03 ± 0.44** | 0.44 | 5.55 – 6.68 |
14
+ | 跨層預取 `SDQ_PREFETCH_LAYER_AHEAD=1` | 5.95 ± 0.37 | 0.37 | 5.38 – 6.29 |
15
+ | 僅 end_token `SDQ_PREFETCH=1` | 5.78 ± 0.33 | 0.33 | 5.45 – 6.27 |
16
+
17
+ **三者差異遠小於單輪抖動,統計上無法分辨。** 而且預取開著時 SSD 讀取量
18
+ 在多數輪次是上升的(預測不夠準,多讀的全是白讀)。
19
+
20
+ 更嚴重的是正確性問題:預取佔用的槽位(busy)會讓 `acquire()` 偶爾拿不到槽位,
21
+ 而舊版的失敗處理是**靜默跳過一個 expert**。預測率不到 1% 的最佳化不應該
22
+ 承擔這個風險。
23
+
24
+ **預設關閉。** 程式碼保留(`SDQ_PREFETCH_LAYER_AHEAD=1` 可以重現),
25
+ 免得後人重新發明。
26
+
27
+ ## 2. 執行緒數(預設 8,但 12 更好)
28
+
29
+ | threads | 平均 tok/s | 單輪標準差 |
30
+ | ---: | ---: | ---: |
31
+ | 4 | 4.65 ± 0.22 | 0.22 |
32
+ | 8 | 5.96 ± 0.45 | 0.45 |
33
+ | 10 | 6.16 ± 0.48 | 0.48 |
34
+ | **12** | **6.35 ± 0.51** | 0.51 |
35
+ | 16 | 6.27 ± 0.35 | 0.35 |
36
+
37
+ **這個結論與參考實作相反。** 參考的 Qwen3.6-35B-A3B 是純 transformer,
38
+ 每層每 token 只有 8 個 expert 可以平行,加執行緒只是在搶同一批 CPU,
39
+ 實測 8 → 12 → 16 是下降的。
40
+
41
+ MiniCPM-V-4.7-35B-A3B 是**混合架構**:40 層裡只有 10 層是全注意力,
42
+ 其餘 30 層是 SSM 層(見 `DESIGN.md` §1)。SSM 層的計算不受序列維度限制,
43
+ 可以吃滿更多執行緒,所以 12 執行緒比 8 快約 6.5%。
44
+
45
+ **這是本模型與參考實作最實際的一個行為差異,也說明「執行緒數」這種設定
46
+ 不能從上一個模型照抄。** 8 保留為預設(與標配量測一致),12 是建議值。
47
+
48
+ > 4 → 8 是真實且顯著的提升(4.65 → 5.96,+28%)。8 之後的差異(8/10/12/16)
49
+ > 都在單輪抖動之內,不足以支撐更細的宣稱。
50
+
51
+ ## 3. 單一尺寸槽位(放棄)
52
+
53
+ 把所有槽位都配成最大的那種(`SDQ_FORCE_UNIFORM_SLOT=1`)最簡單,代價是
54
+ 同樣的 arena 少掉約 18% 的槽位。本模型兩種 expert 大小相差 15%
55
+ (2,039,808 vs 1,769,472 B),所以分級槽位的收益比參考實作小,但仍然是免費的。
56
+
57
+ 驗證用的 `prefetch_stress_check` 兩種配置都跑,確保分級槽位的配置器正確。
58
+
59
+ ## 4. 端到端 A/B 對照(做不到)
60
+
61
+ 需要把整個 21.17 GB 模型常駐 RAM 作為對照組。在 8 GiB 上不可能,
62
+ 這正是本專案存在的理由。見 `STRICT_8GIB.md` §3。
docs/STRICT_8GIB.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 8 GiB / no-swap 的方法、證據與限制
2
+
3
+ 本專案的核心主張是「21.17 GB 的模型在 8 GiB RAM、沒有 swap 的條件下解碼」。
4
+ **每一次執行都由 `tools/strict8g.py` 在行程執行期間管治**,不是事後推論。
5
+ `strict_ok=False` 的樣本一律不採用。
6
+
7
+ ## 1. 兩條腿
8
+
9
+ ### no-swap 由核心保證,不是讀一次數值
10
+
11
+ 行程所有**匿名**映射都被 `tools/epq_lock.so` 以 `mlock()` 鎖住。
12
+ 被鎖定的頁永遠不會被寫到 swap 裝置,所以 no-swap 是核心在整段執行期間強制
13
+ 的,而不是執行完之後從某一次讀取推論出來的。
14
+
15
+ 標配執行下 `VmLck` ≈ 7.6 GiB,這個數字可以直接驗證。
16
+
17
+ ### 實體常駐量有硬上限
18
+
19
+ 取樣器(預設每 5 ms 讀一次 `/proc/<pid>/status`)一旦發現匿名 RSS 超過上限
20
+ 立刻 `SIGKILL`;另外用 `RLIMIT_DATA` 當第二道防失控的閘門。
21
+
22
+ 每次執行都記錄 `VmHWM`、`RssAnon`、`VmSwap`、`VmLck`。
23
+
24
+ ## 2. 標配結果
25
+
26
+ 8 GiB 上限、ctx=4096、ubatch=128、8 threads、greedy、48 requested tokens、
27
+ 提示詞中英交替、**N=9 配對交錯**:
28
+
29
+ | 指標 | 值 |
30
+ | --- | ---: |
31
+ | **Decode tok/s(中位數)** | **5.83** |
32
+ | 單輪全距 | 5.19 – 6.63(抖動 25%) |
33
+ | Expert cache hit rate | 68.68% |
34
+ | SSD 讀取 / decode token | 214.1 MB |
35
+ | Arena | 6.35 GiB(3714 slots) |
36
+ | **峰值匿名 RSS** | **7799 MiB** / 8192 MiB 上限 |
37
+ | **峰值 VmSwap** | **0.000 MiB** |
38
+ | strict_ok | 9/9 |
39
+
40
+ 條件:2× Intel Xeon Platinum 8559C(AVX-512 / AMX)。
41
+
42
+ > 這是**共享主機**,同一組設定的單輪抖動可達 25%。數字足以支撐「能不能跑」
43
+ > 與「大概多快」,不足以支撐 5% 等級的宣稱。
44
+
45
+ ## 3. 同一個 8 GiB 上限下的傳統 llama.cpp
46
+
47
+ 未修改的 llama.cpp(commit `9d7d5049`,獨立 worktree 編譯的 `llama-completion`)
48
+ 在**同一個 8192 MiB 上限**下:
49
+
50
+ ```
51
+ [strict8g] FAIL strict: rc=1 wall=27.3s | anon=928.8 file=20199.0 total=21127.2
52
+ ggml_aligned_malloc: insufficient memory (attempted to allocate 14400.00 MB)
53
+ ggml_backend_buft_alloc_buffer_n_default: failed to allocate CPU_REPACK buffer of size 15099494400
54
+ llama_model_load: error loading model: unable to allocate CPU_REPACK buffer
55
+ ```
56
+
57
+ **模型都載不進去**,在載入階段就失敗。
58
+
59
+ | 路徑 | 結果 |
60
+ | --- | --- |
61
+ | 傳統 llama.cpp(未套 patch) | **無法載入模型** |
62
+ | EPM `epq-chat` | **5.83 tok/s**,峰值匿名 RSS 7799 MiB,VmSwap 0 |
63
+
64
+ 對照必須在**同一個上限**下建立:先量「兩邊都沒限制」再比較,是把 RAM 這個
65
+ 變數漏掉了。
66
+
67
+ ## 3b. RAM 擴張曲線怎麼讀
68
+
69
+ 完整表格在 README。方法學上兩件事必須先講清楚:
70
+
71
+ 1. 這條曲線**不用 mlock**(理由見上),no-swap 的保證只保留給 8 GiB 這個部署目標。
72
+ 2. **arena 不預先碰滿**。全部路由專家權重只有 18.16 GiB,所以 64 GiB 預算下
73
+ 預先碰觸的上限就是 18.17 GiB;峰值 RSS 停在約 24.0 GiB 才是真實的記憶體佔用。
74
+
75
+ 結論(每個點 N=9,配對交錯,Welch t 檢定,36/36 輪 strict_ok):
76
+
77
+ * **4 GiB → 8 GiB:+16.6%,t = −5.44,p = 0.0001(顯著)。**
78
+ * **8 GiB → 16 GiB:−0.8%,t = −0.30,p = 0.76(無法分辨)。**
79
+ * **8 GiB → 64 GiB:−0.2%,t = −0.05,p = 0.96(無法分辨)。**
80
+ * 命中率在 16 GiB 飽和於 77.54%,SSD/token 飽和於 ~158 MB。
81
+ * 記憶體需求飽和於約 24.0 GiB 峰值。
82
+
83
+ ## 4. 儲存裝置會改變結論
84
+
85
+ 本機是本地 NVMe / overlayfs,不是網路檔案系統。`tools/io_probe.py` 量到的儲存
86
+ 特性決定了瓶頸在哪一邊:
87
+
88
+ * **慢速儲存上** I/O 排程是瓶頸,預取與並行讀取才有意義。
89
+ * **快速本機 NVMe 上** 命中率(arena 大小)才是瓶頸,I/O 最佳化的邊際收益很低。
90
+
91
+ 本 repo 的 tok/s **不能直接套用到慢速儲存的機器上**。先跑 `tools/io_probe.py`
92
+ 再決定要最佳化哪一邊。
93
+
94
+ ## 5. 這個量測**沒有**證明的事
95
+
96
+ 1. **不是 cgroup 驗證。** 這個環境的 cgroup2 掛載唯讀、沒有 `CAP_SYS_ADMIN`,
97
+ 所以做不到 `memory.max=8G` + `memory.swap.max=0`。目前的保證是
98
+ 「kernel `RLIMIT_DATA` + 取樣殺行程」與「mlock 匿名頁」兩條腿。
99
+ 要做到與 cgroup 完全等價,需要在有 `CAP_SYS_ADMIN` 的主機上跑
100
+ (`docker run --memory=8g --memory-swap=8g`,進容器後確認
101
+ `memory.max=8589934592`、`memory.swap.max=0`)。
102
+ 2. **主機 swap 是開啟的。** no-swap 由 `mlock` 讓它不可能發生,不是把主機的
103
+ swap 關掉。主機層級的 swap 行為沒有被測到。
104
+ 3. **`brk()` 主堆疊未被鎖**(glibc 內部直接下 syscall)。大型配置都走 `mmap`。
105
+ 4. **共享主機**,單輪抖動可達 25%。
106
+ 5. **只驗到 4K context**;模型原生支援 262 K。
107
+ 6. **只做文字推論。** vision projector 是另一個檔案,沒有測。
108
+ 7. **儲存裝置比歷史量測的機器快一到兩個數量級。**
109
+
110
+ 完整方法與原始資料�� `bench/results/`。
111
+
112
+ ## 6. 重現
113
+
114
+ ```bash
115
+ # 受管治的單次執行
116
+ python3 tools/strict8g.py --limit-mb 8192 --record /tmp/one.json -- \
117
+ epq-chat -m model.gguf -p "..." -n 48 -c 4096 -t 8 -b 128 \
118
+ --temp 0 --no-chat --no-interactive --ram-budget-mb 8192
119
+
120
+ # 標配基準(N=9,配對交錯)
121
+ python3 -m sdq.strict_bench --model model.gguf \
122
+ --bins base=./build/bin/epq-chat --rounds 9 --strict-mb 8192 --label headline
123
+
124
+ # 曲線與負結果
125
+ python3 bench/run_budget_curve.py --model model.gguf --bin ./build/bin/epq-chat --rounds 5
126
+ python3 bench/run_ramsweep.py --model model.gguf --bin ./build/bin/epq-chat --rounds 3
127
+ python3 bench/run_n9.py --model model.gguf --bin ./build/bin/epq-chat --rounds 9
128
+ python3 bench/run_negative.py --which prefetch --model model.gguf --bin ./build/bin/epq-chat
129
+ python3 bench/run_negative.py --which threads --model model.gguf --bin ./build/bin/epq-chat
130
+ ```
patches/0001-sdq-expert-pager.patch ADDED
The diff for this file is too large to render. See raw diff
 
sdq/__init__.py ADDED
File without changes
sdq/bench.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """SDQ benchmark: measure pager/I/O/cache/tok/s and report RSS/VmSwap.
3
+
4
+ 這個模組是**舊的**基準工具,它給的數字不是 8 GiB / no-swap 證據:
5
+
6
+ 1. 預設的 `RLIMIT_DATA` 只限制 data/heap 配置,不計 file-backed 常駐頁,
7
+ 也不是 swap 開關。
8
+ 2. 報告的 `VmSwap` 只是「那一次執行沒有被換出」,不是「核心不可能換出」。
9
+
10
+ **要 8 GiB / no-swap 的證據請用 `tools/strict8g.py` 或 `sdq.strict_bench.py`。**
11
+ 本模組保留給快速的非受管治量測,以及 `demo.py --bench` 呼叫。
12
+
13
+ 做法
14
+ ----
15
+ 1. 每次量測前用 tools/drop_model_cache.py 把模型檔的 page cache 丟掉,
16
+ 避免「上一輪留在核心 RAM 的頁」讓 SSD 讀取量看起來比實際少。
17
+
18
+ 用法
19
+ ----
20
+ python -m sdq.bench --model /path/model.gguf --bin build/bin/epq-chat
21
+ python -m sdq.bench --ram-mb 8192 --arena-mb 3800 --threads 4
22
+
23
+ 受管治的版本(會輸出 strict_ok)::
24
+
25
+ python -m sdq.strict_bench --model /path/model.gguf --bin build/bin/epq-chat \
26
+ --rounds 9 --strict-mb 8192 --label headline
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import resource
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ from pathlib import Path
39
+
40
+ REPO = Path(__file__).resolve().parent.parent
41
+ PROMPTS = [
42
+ "請用三句話說明什麼是 SSD、RAM、CPU 之間的資料分層。",
43
+ "Write a short paragraph about how mixture-of-experts models route tokens.",
44
+ "請列出五種日常會用到電腦的工作,並各用一句話說明。",
45
+ ]
46
+
47
+
48
+ def drop_cache(model: str) -> None:
49
+ subprocess.run([sys.executable, str(REPO / "tools" / "drop_model_cache.py"), model],
50
+ check=False, capture_output=True)
51
+
52
+
53
+ def run_once(binary: str, model: str, args: argparse.Namespace, prompt: str,
54
+ n_predict: int, tag: str) -> dict:
55
+ stats_file = Path(args.workdir) / f"stats_{tag}.jsonl"
56
+ if stats_file.exists():
57
+ stats_file.unlink()
58
+
59
+ cmd = [
60
+ binary, "-m", model, "-p", prompt, "-n", str(n_predict),
61
+ "-c", str(args.ctx), "-t", str(args.threads), "-b", str(args.ubatch),
62
+ "--temp", "0", "--no-chat", "--no-interactive",
63
+ "--stats-file", str(stats_file),
64
+ ]
65
+ if args.ram_mb:
66
+ cmd += ["--ram-budget-mb", str(args.ram_mb)]
67
+ if args.arena_mb:
68
+ cmd += ["--arena-mb", str(args.arena_mb)]
69
+ if args.no_prefetch:
70
+ cmd += ["--no-prefetch"]
71
+
72
+ env = dict(os.environ)
73
+ env.setdefault("SDQ_FADV_DONTNEED", "1")
74
+
75
+ if args.ram_mb and not args.no_ulimit:
76
+ # RLIMIT_DATA limits data/heap allocations, not total RSS or swap.
77
+ def _limit():
78
+ limit = args.ram_mb * 1024 * 1024
79
+ resource.setrlimit(resource.RLIMIT_DATA, (limit, limit))
80
+ preexec = _limit
81
+ else:
82
+ preexec = None
83
+
84
+ if args.drop_cache:
85
+ drop_cache(model)
86
+
87
+ t0 = time.time()
88
+ proc = subprocess.run(cmd, capture_output=True, text=True, env=env, preexec_fn=preexec)
89
+ wall = time.time() - t0
90
+ if proc.returncode != 0:
91
+ print(f"[bench] {tag} 執行失敗(rc={proc.returncode})")
92
+ print(proc.stderr[-2000:])
93
+ return {"tag": tag, "error": proc.returncode}
94
+
95
+ # 解析指標
96
+ stats = {}
97
+ if stats_file.exists():
98
+ for line in stats_file.read_text().splitlines():
99
+ rec = json.loads(line)
100
+ stats[rec["phase"]] = rec
101
+
102
+ mem = {}
103
+ for line in proc.stderr.splitlines():
104
+ if "記憶體(載入後)" in line or "記憶體:" in line:
105
+ mem["line"] = line
106
+ for key, val in re_findall(line):
107
+ mem[key] = val
108
+
109
+ tok = {}
110
+ for line in proc.stderr.splitlines():
111
+ if "decode:" in line:
112
+ tok["decode"] = line
113
+ if "prefill:" in line:
114
+ tok["prefill"] = line
115
+
116
+ return {
117
+ "tag": tag,
118
+ "wall_s": round(wall, 2),
119
+ "stdout": proc.stdout.strip()[-400:],
120
+ "tok": tok,
121
+ "mem": mem,
122
+ "stats": stats,
123
+ }
124
+
125
+
126
+ def re_findall(line: str):
127
+ import re
128
+ return re.findall(r"(RSS|VmSwap|峰值 RSS\(VmHWM\))\)?[:=]?\s*(\d+)\s*MB", line)
129
+
130
+
131
+ def summarize(result: dict) -> str:
132
+ s = result["stats"].get("decode") or result["stats"].get("bench_hot") or {}
133
+ if not s:
134
+ return f"[{result['tag']}] 無指標"
135
+ return (
136
+ f"[{result['tag']}] hit={s['hit_rate']:.1f}% SSD/token={s['ssd_bytes_per_token']/2**20:.1f} MB "
137
+ f"預取使用率={s['prefetch_used_rate']:.1f}% arena={s['arena_used_bytes']/2**30:.2f}/{s['arena_bytes']/2**30:.2f} GiB "
138
+ f"RSS={s['rss_bytes']/2**20:.0f} MB"
139
+ )
140
+
141
+
142
+ def main() -> None:
143
+ ap = argparse.ArgumentParser(description="SDQ 8GB RAM 基準測量")
144
+ ap.add_argument("--model", required=True)
145
+ ap.add_argument("--bin", default=str(REPO / "build" / "bin" / "epq-chat"))
146
+ ap.add_argument("--ram-mb", type=int, default=8192,
147
+ help="SDQ pager sizing budget (also RLIMIT_DATA unless --no-ulimit; not an RSS/swap cap)")
148
+ ap.add_argument("--arena-mb", type=int, default=0, help="固定 arena 大小(0 = 由預算推算)")
149
+ ap.add_argument("--threads", type=int, default=8)
150
+ ap.add_argument("--ubatch", type=int, default=128)
151
+ ap.add_argument("--ctx", type=int, default=4096)
152
+ ap.add_argument("--n-predict", type=int, default=48)
153
+ ap.add_argument("--rounds", type=int, default=2)
154
+ ap.add_argument("--workdir", default=str(REPO / "bench" / "results"))
155
+ ap.add_argument("--no-prefetch", action="store_true")
156
+ ap.add_argument("--no-ulimit", action="store_true",
157
+ help="Do not set RLIMIT_DATA; still not an OS memory/swap cap")
158
+ ap.add_argument("--no-drop-cache", dest="drop_cache", action="store_false", default=True)
159
+ ap.add_argument("--label", default="run")
160
+ args = ap.parse_args()
161
+
162
+ Path(args.workdir).mkdir(parents=True, exist_ok=True)
163
+
164
+ results = []
165
+ # 冷啟動:整個模型第一次被讀(cache 是空的)
166
+ results.append(run_once(args.bin, args.model, args, PROMPTS[0], args.n_predict, "cold"))
167
+ print(summarize(results[-1]))
168
+ # 熱身後:專家 cache 已經有內容
169
+ for r in range(args.rounds):
170
+ res = run_once(args.bin, args.model, args, PROMPTS[(r + 1) % len(PROMPTS)],
171
+ args.n_predict, f"hot{r}")
172
+ results.append(res)
173
+ print(summarize(res))
174
+
175
+ out = Path(args.workdir) / f"bench_{args.label}.json"
176
+ out.write_text(json.dumps({
177
+ "config": vars(args),
178
+ "results": results,
179
+ }, ensure_ascii=False, indent=1), encoding="utf-8")
180
+ print(f"\n[bench] 完整結果已寫到 {out}")
181
+
182
+ # 印出對照用的解譯文字(確認模型真的有在回答,不是亂碼)
183
+ for res in results:
184
+ print(f"\n--- {res['tag']} 生成內容 ---\n{res.get('stdout','')}")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
sdq/config.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EPM 全專案唯一的設定來源(其他檔案一律 import 這裡,不要各自硬寫常數)。
2
+
3
+ 改版號 → 改這裡 → 重新執行 `python demo.py` 就會自動重抓、重新編譯。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # ---------------------------------------------------------------- 專案
12
+ HF_REPO = os.environ.get(
13
+ "EPM_HF_REPO",
14
+ os.environ.get("SDQ_HF_REPO", "HelloSun/minicpm47woek"),
15
+ ) # 存程式碼、文件與量測結果
16
+ DEFAULT_HOME = Path(os.environ.get(
17
+ "EPM_HOME", os.environ.get("SDQ_HOME", str(Path.home() / "minicpm47-epm"))
18
+ ))
19
+
20
+ # ---------------------------------------------------------------- llama.cpp
21
+ LLAMA_REPO = "https://github.com/ggml-org/llama.cpp.git"
22
+ # EPM patch 就是針對這個 commit 生成的;換 commit 一定要重新產生 patch
23
+ # (見 docs/01 的「續作指引」一節與 patches/ 的產生方式)。
24
+ LLAMA_COMMIT = "9d7d5049804be03fce6575cc9f49c058b3c48788"
25
+ PATCH_FILE = "patches/0001-sdq-expert-pager.patch"
26
+ # patch 會把引擎檔案放進 llama.cpp 的 src/ 目錄
27
+ PATCH_TARGET_DIR = "src"
28
+ SDQ_SOURCE_FILES = ["sdq_pager.h", "sdq_pager.cpp", "sdq_buft.cpp", "sdq_moe.cpp", "sdq_cli.cpp", "sdq_mtp.cpp"]
29
+ BUILD_TARGET = "epq-chat"
30
+
31
+ # ---------------------------------------------------------------- 模型
32
+ MODEL_REPO = "openbmb/MiniCPM-V-4.7-35B-A3B-gguf"
33
+ MODEL_FILE = "MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf"
34
+ MODEL_SIZE = 21_166_255_008 # bytes
35
+
36
+ # ---------------------------------------------------------------- 執行參數
37
+ # 目標部署:8 GiB RAM 的 Intel CPU(無 GPU)。
38
+ # RAM_BUDGET_MB 是**實體 RAM 預算**,不是虛擬記憶體上限,也不是 swap 開關;
39
+ # 嚴格的 8 GiB / no-swap 驗證由 tools/strict8g.py 管治(見 docs/STRICT_8GIB.md)。
40
+ RAM_BUDGET_MB = 8192
41
+ ARENA_MB = 0 # 0 = 由 RAM 預算自動推算(見 docs/02 第 6 節)
42
+ # arena sizing 時要預留多少記憶體(見 docs/DESIGN.md §3 的校正表)。
43
+ # 逐模型、逐 ctx/ubatch 校正:換 RAM 預算要跟著換,否則 4 GiB 會超出上限。
44
+ # 比例規則 RAM_BUDGET_MB/16 在 8 GiB 重現校正出來的 512 MiB。
45
+ RESERVE_MB = RAM_BUDGET_MB // 16
46
+ CTX_VERIFY = 4096 # 本專案實際驗證的 context 長度
47
+ UBATCH = 128 # llama.cpp 的 n_ubatch。預設 512 會讓長提示詞在 8 GB 預算下
48
+ # 直接 std::bad_alloc(compute buffer 502 MiB 是在 arena sizing
49
+ # 之後才配置的);128 修好崩潰而且 prefill 快 4 倍(見 docs/05 §5.1)
50
+ CTX_THEORETICAL = 262_144 # 模型原生 context(本專案只驗證到 4K)
51
+ N_PREDICT = 256
52
+ N_EXPERT = 256 # 每層專家數
53
+ N_EXPERT_USED = 8 # 每 token 啟用專家數
54
+ N_LAYER = 40
sdq/experiments.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """SDQ 最佳化實驗 harness:跑一組設定矩陣,產出可重現、可比較的結果。
3
+
4
+ 設計原則
5
+ --------
6
+ 1. **每次量測前丟掉模型檔的 page cache**(POSIX_FADV_DONTNEED),否則上一輪留在
7
+ 核心記憶體的頁會讓 SSD 讀取量虛高、命中率虛高。
8
+ 2. **每個設定跑 N 次取中位數**:單次結果抖動 ±10% 很常見,中位數才有意義。
9
+ 3. **同時回報 KPI**:decode tok/s、cache 命中率、SSD 讀取量/token、峰值 RSS 與
10
+ 本行程 VmSwap。VmSwap=0 僅表示該行程取樣為 0,不代表主機停用了 swap。
11
+ 4. **自動檢查正確性指紋**:把每個實驗產生的第一個 token 記下來,
12
+ 如果某個最佳化讓第一個 token 變了,代表它改變了模型行為 → 必須標記為失敗。
13
+ (速度不能拿正確性換,這是本專案的硬規則。)
14
+ 5. 結果寫成 JSON + Markdown 表格,方便直接貼進 docs/。
15
+
16
+ 用法
17
+ ----
18
+ python -m sdq.experiments --model <gguf> --bin <epq-chat> --suite quick
19
+ python -m sdq.experiments --model <gguf> --bin <epq-chat> --suite full --repeat 3
20
+ python -m sdq.experiments ... --only thread,direct # 只跑某些實驗
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import re
29
+ import resource
30
+ import statistics
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ REPO = Path(__file__).resolve().parent.parent
37
+
38
+ # 固定的量測條件:改這些會讓不同實驗之間無法比較
39
+ FIXED = {
40
+ "ctx": "4096",
41
+ # S5 起引擎的 n_ubatch 預設是 128(512 在 8 GB 預算下會讓長提示詞 bad_alloc,
42
+ # 見 docs/05 §5.1)。這裡必須跟著預設走,否則量到的不是我們要交付的那條路徑。
43
+ "ubatch": "128",
44
+ "temp": "0",
45
+ "n_predict": "48",
46
+ "ram_mb": "8192",
47
+ }
48
+
49
+ PROMPTS = [
50
+ "請用三句話說明什麼是 SSD、RAM、CPU 之間的資料分層。",
51
+ "Write a short paragraph about how mixture-of-experts models route tokens.",
52
+ "請列出五種日常會用到電腦的工作,並各用一句話說明。",
53
+ ]
54
+
55
+
56
+ # ------------------------------------------------------------------ 實驗定義
57
+ # 每個實驗 = 一組環境變數 / CLI 參數的覆寫。None 代表沿用預設。
58
+ def _suite_quick() -> list[dict]:
59
+ """先摸底:threads、I/O 模式、預取、arena、ubatch。成本低、風險低。"""
60
+ out = []
61
+ for t in (4, 6, 8, 10, 12, 16):
62
+ out.append({"name": f"threads={t}", "args": {"threads": str(t)}})
63
+ out += [
64
+ {"name": "prefetch=off", "args": {"no_prefetch": True}},
65
+ {"name": "fadvise=off", "env": {"SDQ_FADV_DONTNEED": "0"}},
66
+ {"name": "arena=2048MB", "args": {"arena_mb": "2048"}},
67
+ {"name": "arena=3072MB", "args": {"arena_mb": "3072"}},
68
+ {"name": "arena=4608MB", "args": {"arena_mb": "4608"}},
69
+ {"name": "ubatch=128", "args": {"ubatch": "128"}},
70
+ # OPT-1:Strata 式熱門 expert 靜態釘選(實測對本模型無效,保留作為對照)
71
+ # Needs a profile produced by tools/route_overlap.py; point SDQ_PROFILE_IN
72
+ # at your own file. Kept as a control even though it measured as no-op.
73
+ {"name": "hot-pin=2048", "args": {},
74
+ "env": {"SDQ_PROFILE_IN": str(REPO / "bench" / "expert_profile.txt"),
75
+ "SDQ_HOT_PIN": "2048"}},
76
+ # 排程模式對照
77
+ {"name": "modeB(partial=0)", "args": {}, "env": {"SDQ_PARTIAL_CAP_MB": "0"}},
78
+ ]
79
+ return out
80
+
81
+
82
+ def _suite_io() -> list[dict]:
83
+ """I/O 排程:一個 expert 的 gate/up/down 三段要不要同時讀。
84
+
85
+ 這是 docs/05 的主線實驗。單一 pread 在本機約 7 ms(1.9 MB / 275 MB/s),
86
+ 序列讀三段就是 ~21 ms 的延遲鏈,而且這條鏈的長度與執行緒數無關。
87
+ """
88
+ out = [{"name": "baseline", "args": {}}]
89
+ for n in (4, 8, 12, 16, 24, 32, 48):
90
+ out.append({"name": f"io-threads={n}", "args": {},
91
+ "env": {"SDQ_IO_THREADS": str(n)}})
92
+ out.append({"name": "io-parallel=off", "args": {},
93
+ "env": {"SDQ_IO_PARALLEL": "0"}})
94
+ return out
95
+
96
+
97
+ def _suite_cache() -> list[dict]:
98
+ """快取策略:這是命中率與 I/O 量的主戰場。"""
99
+ return [
100
+ {"name": "baseline", "args": {}},
101
+ {"name": "hot-pin=1024", "args": {}, "env": {"SDQ_HOT_PIN": "1024"}},
102
+ {"name": "hot-pin=3072", "args": {}, "env": {"SDQ_HOT_PIN": "3072"}},
103
+ {"name": "admit=2nd", "args": {}, "env": {"SDQ_ADMIT_MIN_HITS": "2"}},
104
+ {"name": "modeA(cap=256)", "args": {}, "env": {"SDQ_PARTIAL_CAP_MB": "256"}},
105
+ ]
106
+
107
+
108
+ def _suite_slot() -> list[dict]:
109
+ """槽位配置:大小兩種槽位 vs 全部照最大的配。
110
+
111
+ 背景:這個模型 37 層的 expert 是 1900544 B,層 34/38/39 是 2039808 B(down 用 Q6_K)。
112
+ 舊做法所有槽位都照最大的配 → arena 有 6.8% 是浪費掉的,而 arena 在 8 GB 預算裡
113
+ 是最貴的資源(實測 +5% 槽位 ≈ +8~9% tok/s)。
114
+ SDQ_FORCE_UNIFORM_SLOT=1 回到舊行為當對照。
115
+ """
116
+ return [
117
+ {"name": "baseline(大小兩種槽位)", "args": {}},
118
+ {"name": "uniform(全部照大槽位)", "args": {}, "env": {"SDQ_FORCE_UNIFORM_SLOT": "1"}},
119
+ ]
120
+
121
+
122
+ def _suite_arena() -> list[dict]:
123
+ """arena 大小掃描:驗證「槽位數 → tok/s」的斜率,並確認 8 GB 預算內的上限。"""
124
+ out = [{"name": "baseline", "args": {}}]
125
+ for mb in (3072, 3584, 4096):
126
+ out.append({"name": f"arena={mb}MB", "args": {"arena_mb": str(mb)}})
127
+ return out
128
+
129
+
130
+ def _suite_pf() -> list[dict]:
131
+ """跨層預取:layer L 開頭就替 layer L+1 排預取。
132
+
133
+ 背景:end_token() 是在一個 token 的 40 層「全部算完」之後才排預取的,
134
+ 那時沒有任何計算可以重疊 → 實測 prefetch 使用率只有 0~8 %,等於整條路徑是關的。
135
+ 這一組量的是「把預取搬到每層開頭」有沒有價值。
136
+ """
137
+ return [
138
+ {"name": "baseline(層開頭預取)", "args": {}, "env": {"SDQ_PREFETCH_LAYER_AHEAD": "1"}},
139
+ {"name": "layer-ahead=off", "args": {}, "env": {"SDQ_PREFETCH_LAYER_AHEAD": "0"}},
140
+ {"name": "prefetch=off", "args": {}, "env": {"SDQ_PREFETCH": "0"}},
141
+ {"name": "pf(queue=256,stale=1)", "args": {},
142
+ "env": {"SDQ_PREFETCH_LAYER_AHEAD": "1", "SDQ_PREFETCH_QUEUE": "256",
143
+ "SDQ_PREFETCH_STALE_TOKENS": "1"}},
144
+ ]
145
+
146
+
147
+ # The former "spec" suite drove speculative decoding through SDQ_NGRAM_DRAFT, an
148
+ # env var that no longer exists anywhere in cpp/. Prompt-lookup n-gram drafting was
149
+ # measured at zero accepted tokens on this model (see docs/NEGATIVE_RESULTS.md §4)
150
+ # and MTP has its own entry point (cpp/sdq_mtp.cpp -> epq-mtp, see docs/MTP.md),
151
+ # so the suite was dead code that would silently no-op rather than measure anything.
152
+ SUITES = {"quick": _suite_quick, "io": _suite_io, "cache": _suite_cache,
153
+ "slot": _suite_slot, "arena": _suite_arena, "pf": _suite_pf}
154
+
155
+
156
+ # ------------------------------------------------------------------ 執行
157
+ def drop_cache(model: str) -> None:
158
+ subprocess.run([sys.executable, str(REPO / "tools" / "drop_model_cache.py"), model],
159
+ check=False, capture_output=True)
160
+
161
+
162
+ def _limit(ram_mb: str):
163
+ def f():
164
+ n = int(ram_mb) * 1024 * 1024
165
+ resource.setrlimit(resource.RLIMIT_DATA, (n, n))
166
+ return f
167
+
168
+
169
+ def first_generated_token(stdout: str) -> str:
170
+ """取出「第一個生成 token」的**空白正規化**指紋,用來偵測輸出有沒有改變。
171
+
172
+ 為什麼要把所有空白都拿掉再取前 24 個字元:
173
+ 模型的輸出可能以換行開頭,而 stdout 是被分塊寫出的,所以同一次生成在不同
174
+ 執行裡「assistant: 後面第一個非空行」的位置不一定相同(例如一次是
175
+ `assistant: <think>`、另一次是 `assistant:` 換行後才出現 `<think>`)。
176
+ 如果指紋取「換行前的原始片段」,**完全相同的輸出**會被判成不一致。
177
+
178
+ 量測(2026-10-04,本機):同一提示詞連跑 4 次(含 SDQ_FADV_DONTNEED=1
179
+ 並丟掉 20.6 GiB page cache),stdout 長度都是 294 bytes、指紋都是 `<think>`;
180
+ `tools/determinism_check.py` 另外證明 48 token × 248320 vocab 的 logits
181
+ **逐位元組一致** → 分頁路徑是決定性的,過去那些 ⚠️ 都是假警報。
182
+
183
+ 抓不到內容時回空字串,呼叫端不去比對空指紋(寧可少檢查也不要假警報)。
184
+ """
185
+ lines = stdout.splitlines()
186
+ started = False
187
+ chunks: list[str] = []
188
+ for line in lines:
189
+ if not started:
190
+ if line.startswith("assistant:"):
191
+ started = True
192
+ chunks.append(line[len("assistant:"):])
193
+ continue
194
+ if line.startswith("[sdq]"):
195
+ continue
196
+ chunks.append(line)
197
+ text = re.sub(r"\s+", "", "".join(chunks))
198
+ return text[:24]
199
+
200
+
201
+ def run_once(binary: str, model: str, exp: dict, prompt: str, idx: int) -> dict:
202
+ stats_file = Path("/tmp") / f"sdqexp_{os.getpid()}_{idx}.jsonl"
203
+ if stats_file.exists():
204
+ stats_file.unlink()
205
+
206
+ cmd = [binary, "-m", model, "-p", prompt, "-n", FIXED["n_predict"],
207
+ "-c", FIXED["ctx"], "-t", str(exp.get("args", {}).get("threads", 8)),
208
+ "-b", str(exp.get("args", {}).get("ubatch", FIXED["ubatch"])), "--temp", FIXED["temp"],
209
+ "--no-chat", "--no-interactive", "--stats-file", str(stats_file),
210
+ "--ram-budget-mb", FIXED["ram_mb"]]
211
+ if exp.get("args", {}).get("arena_mb"):
212
+ cmd += ["--arena-mb", str(exp["args"]["arena_mb"])]
213
+ if exp.get("args", {}).get("no_prefetch"):
214
+ cmd += ["--no-prefetch"]
215
+
216
+ env = dict(os.environ)
217
+ env.update(exp.get("env", {}))
218
+ env.setdefault("SDQ_FADV_DONTNEED", "1")
219
+
220
+ drop_cache(model)
221
+ t0 = time.time()
222
+ try:
223
+ proc = subprocess.run(cmd, capture_output=True, text=True, env=env,
224
+ preexec_fn=_limit(FIXED["ram_mb"]), timeout=900)
225
+ except subprocess.TimeoutExpired:
226
+ return {"error": "timeout"}
227
+ wall = time.time() - t0
228
+ if proc.returncode != 0:
229
+ return {"error": f"rc={proc.returncode}", "stderr": proc.stderr[-800:]}
230
+
231
+ stats = {}
232
+ if stats_file.exists():
233
+ for line in stats_file.read_text().splitlines():
234
+ rec = json.loads(line)
235
+ stats[rec["phase"]] = rec
236
+ stats_file.unlink(missing_ok=True)
237
+
238
+ dec = stats.get("decode") or {}
239
+ tok_s = 0.0
240
+ m = re.search(r"decode: (\d+) tok / ([\d.]+)s = ([\d.]+) tok/s", proc.stderr)
241
+ if m:
242
+ tok_s = float(m.group(3))
243
+ peak_rss = 0
244
+ vmswap = 0
245
+ m = re.search(r"峰值 (\d+) MB / (?:swap|本行程 VmSwap) (\d+) MB", proc.stderr)
246
+ if m:
247
+ peak_rss, vmswap = int(m.group(1)), int(m.group(2))
248
+
249
+ # 正確性指紋:第一個生成 token(用來偵測「最佳化偷偷改變了模型行為」)
250
+ first_tok = first_generated_token(proc.stdout)
251
+
252
+ return {
253
+ "tok_s": tok_s,
254
+ "wall_s": round(wall, 2),
255
+ "hit_rate": dec.get("hit_rate"),
256
+ "ssd_mb_per_tok": (dec.get("ssd_bytes_per_token") or 0) / 2 ** 20,
257
+ "peak_rss_mb": peak_rss,
258
+ "vmswap_mb": vmswap,
259
+ "n_slots": dec.get("n_slots"),
260
+ "evictions": dec.get("evictions"),
261
+ "prefetch_used_rate": dec.get("prefetch_used_rate"),
262
+ "first_tok": first_tok,
263
+ "text": proc.stdout.strip()[-260:],
264
+ }
265
+
266
+
267
+ def run_exp(binary: str, model: str, exp: dict, repeat: int, prompts: list[str]) -> dict:
268
+ runs = []
269
+ for r in range(repeat):
270
+ res = run_once(binary, model, exp, prompts[r % len(prompts)], r)
271
+ if res.get("error"):
272
+ return {"name": exp["name"], "error": res["error"], "stderr": res.get("stderr", "")}
273
+ runs.append(res)
274
+ ok = [r for r in runs if r["tok_s"] > 0]
275
+ if not ok:
276
+ return {"name": exp["name"], "error": "沒有任何一次量到 tok/s"}
277
+ med = lambda k: statistics.median([r[k] for r in ok if r.get(k) is not None]) if any(r.get(k) is not None for r in ok) else None
278
+ # 只比較「同一個提示詞」的重複執行:實驗會輪替提示詞,跨提示詞比字串沒有意義。
279
+ # **空指紋不參與比較**:抓不到第一個 token 時回 "",那是 stdout 分塊造成的,
280
+ # 不是輸出不一致(見 first_generated_token 的說明)。寧可少檢查也不要假警報。
281
+ by_prompt: dict[str, set] = {}
282
+ for i, r in enumerate(ok):
283
+ if not r["first_tok"]:
284
+ continue
285
+ by_prompt.setdefault(PROMPTS[i % len(PROMPTS)], set()).add(r["first_tok"])
286
+ unstable = [p[:20] for p, v in by_prompt.items() if len(v) > 1]
287
+ # 有沒有真的比到東西(沒比到就不能宣稱「輸出穩定」)
288
+ checked = sum(len(v) for v in by_prompt.values()) - len(by_prompt)
289
+ return {
290
+ "name": exp["name"],
291
+ "args": exp.get("args", {}), "env": exp.get("env", {}),
292
+ "tok_s": round(med("tok_s"), 3),
293
+ "tok_s_all": [r["tok_s"] for r in ok],
294
+ "hit_rate": round(med("hit_rate"), 2) if med("hit_rate") is not None else None,
295
+ "ssd_mb_per_tok": round(med("ssd_mb_per_tok"), 1) if med("ssd_mb_per_tok") is not None else None,
296
+ "peak_rss_mb": med("peak_rss_mb"),
297
+ "vmswap_mb": med("vmswap_mb"),
298
+ "n_slots": med("n_slots"),
299
+ "evictions": med("evictions"),
300
+ "prefetch_used_rate": med("prefetch_used_rate"),
301
+ "stable_output": not unstable,
302
+ "stable_checked": checked, # 實際有幾組「同提示詞重複」可比
303
+ "unstable_prompts": unstable,
304
+ "first_tok": ok[0]["first_tok"],
305
+ "runs": len(ok),
306
+ }
307
+
308
+
309
+ # ------------------------------------------------------------------ 報告
310
+ def to_markdown(results: list[dict]) -> str:
311
+ head = ("| 實驗 | decode tok/s | 命中率 | SSD MB/token | 峰值 RSS | swap | 槽數 | 淘汰 | 預取使用率 | 輸出穩定 |")
312
+ sep = "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :---: |"
313
+ lines = [head, sep]
314
+ base = next((r for r in results if r["name"] in ("baseline", "threads=8")), None)
315
+ bt = base["tok_s"] if base else None
316
+ for r in results:
317
+ if r.get("error"):
318
+ lines.append(f"| {r['name']} | ❌ {r['error']} | | | | | | | | |")
319
+ continue
320
+ delta = ""
321
+ if bt:
322
+ d = (r["tok_s"] - bt) / bt * 100
323
+ delta = f" ({d:+.1f}%)"
324
+ lines.append(
325
+ f"| {r['name']} | {r['tok_s']}{delta} | {r['hit_rate']}% | {r['ssd_mb_per_tok']} | "
326
+ f"{r['peak_rss_mb']} MB | {r['vmswap_mb']} MB | {r['n_slots']} | {r['evictions']} | "
327
+ f"{r['prefetch_used_rate']}% | "
328
+ f"{'✅' if r['stable_output'] else ('⚠️不一致' if r.get('stable_checked') else '—未比到')} |")
329
+ return "\n".join(lines)
330
+
331
+
332
+ def main() -> int:
333
+ ap = argparse.ArgumentParser(description="SDQ 最佳化實驗")
334
+ ap.add_argument("--model", required=True)
335
+ ap.add_argument("--bin", default=str(REPO.parent / "llama.cpp" / "build" / "bin" / "epq-chat"),
336
+ help="epq-chat 路徑(預設 <repo>/../llama.cpp/build/bin/epq-chat)")
337
+ ap.add_argument("--suite", default="quick", choices=sorted(SUITES) + ["all"])
338
+ ap.add_argument("--only", default="", help="只跑名稱包含這些關鍵字的實驗(逗號分隔)")
339
+ ap.add_argument("--repeat", type=int, default=2)
340
+ ap.add_argument("--out", default=str(REPO / "bench" / "results"))
341
+ ap.add_argument("--label", default="")
342
+ args = ap.parse_args()
343
+
344
+ exps: list[dict] = []
345
+ names = sorted(SUITES) if args.suite == "all" else [args.suite]
346
+ for n in names:
347
+ exps += SUITES[n]()
348
+ # 去掉重複名稱(不同 suite 可能有 baseline)
349
+ seen, uniq = set(), []
350
+ for e in exps:
351
+ if e["name"] in seen:
352
+ continue
353
+ seen.add(e["name"])
354
+ uniq.append(e)
355
+ if args.only:
356
+ keys = [k.strip() for k in args.only.split(",") if k.strip()]
357
+ uniq = [e for e in uniq if any(k in e["name"] for k in keys)]
358
+
359
+ print(f"[exp] 模型:{args.model}")
360
+ print(f"[exp] 執行檔:{args.bin}")
361
+ print(f"[exp] 實驗數:{len(uniq)},每個跑 {args.repeat} 次取中位數")
362
+ print(f"[exp] 固定條件:{FIXED}")
363
+ print()
364
+
365
+ results = []
366
+ for e in uniq:
367
+ print(f"[exp] → {e['name']} ...", end=" ", flush=True)
368
+ r = run_exp(args.bin, args.model, e, args.repeat, PROMPTS)
369
+ results.append(r)
370
+ if r.get("error"):
371
+ print(f"❌ {r['error']}")
372
+ else:
373
+ print(f"{r['tok_s']} tok/s 命中率 {r['hit_rate']}% SSD {r['ssd_mb_per_tok']} MB/tok"
374
+ + ("" if r["stable_output"] or not r.get("stable_checked")
375
+ else " ⚠️ 輸出不一致"))
376
+
377
+ Path(args.out).mkdir(parents=True, exist_ok=True)
378
+ label = args.label or args.suite
379
+ jf = Path(args.out) / f"exp_{label}.json"
380
+ jf.write_text(json.dumps({"fixed": FIXED, "repeat": args.repeat,
381
+ "results": results}, ensure_ascii=False, indent=1), encoding="utf-8")
382
+ md = to_markdown(results)
383
+ mf = Path(args.out) / f"exp_{label}.md"
384
+ mf.write_text(md + "\n", encoding="utf-8")
385
+
386
+ print()
387
+ print(md)
388
+ print()
389
+ print(f"[exp] JSON → {jf}")
390
+ print(f"[exp] Markdown → {mf}")
391
+ return 0
392
+
393
+
394
+ if __name__ == "__main__":
395
+ sys.exit(main())
sdq/hf_sync.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face 儲存庫同步:commit 並上傳程式碼與量測結果到 EPM model repo。
2
+
3
+ 用法:
4
+
5
+ # commit + 上傳(需要 HF_TOKEN,或 ~/.cache/huggingface/token)
6
+ python -m sdq.hf_sync push -m "說明這次改了什麼"
7
+
8
+ # 只看本地狀態(給接手的人 / AI)
9
+ python -m sdq.hf_sync status
10
+
11
+ # 只下載 HF 上的 code(給 demo.py 用)
12
+ python -m sdq.hf_sync pull --dest /path/to/dir
13
+
14
+ # 從 patch 重新產生 patches/0001-sdq-expert-pager.patch
15
+ python -m sdq.hf_sync regen-patch --src /path/to/llama.cpp
16
+
17
+ `push` 會尊重 `.gitignore`:模型權重、建置產物與 `tools/epq_lock.so`
18
+ (由 `tools/strict8g.py` 在執行時編譯)不會被上傳。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import os
25
+ import subprocess
26
+ from pathlib import Path
27
+
28
+ REPO_DIR = Path(__file__).resolve().parent.parent
29
+
30
+ DEFAULT_HF_REPO = os.environ.get(
31
+ "EPM_HF_REPO",
32
+ os.environ.get("SDQ_HF_REPO", "HelloSun/minicpm47woek"),
33
+ )
34
+
35
+ # demo.py fetch_code() 下載的檔案類型。新增檔案時這裡也要跟著改,否則
36
+ # 使用者從 HF 拉下來的 code 會缺東西。
37
+ CODE_PATTERNS = [
38
+ "*.py", "*.md", "*.c",
39
+ "cpp/*", "docs/*", "patches/*", "tools/*", "sdq/*.py",
40
+ "bench/expert_index.json",
41
+ ]
42
+
43
+
44
+ def git(*args: str) -> str:
45
+ return subprocess.run(
46
+ ["git", *args], cwd=REPO_DIR, check=True, capture_output=True, text=True
47
+ ).stdout.strip()
48
+
49
+
50
+ def _git_optional(*args: str) -> str:
51
+ r = subprocess.run(["git", *args], cwd=REPO_DIR, capture_output=True, text=True)
52
+ return r.stdout.strip() if r.returncode == 0 else ""
53
+
54
+
55
+ def _token() -> str | None:
56
+ token = os.environ.get("HF_TOKEN")
57
+ if token:
58
+ return token
59
+ p = Path.home() / ".cache" / "huggingface" / "token"
60
+ return p.read_text().strip() if p.exists() else None
61
+
62
+
63
+ def push(message: str, hf_repo: str = DEFAULT_HF_REPO) -> None:
64
+ git("add", "-A")
65
+ if not git("diff", "--cached", "--name-only"):
66
+ print("[sync] 沒有變更,略過 commit")
67
+ else:
68
+ git("commit", "-m", message)
69
+ print(f"[sync] 已 commit:{message}")
70
+
71
+ from huggingface_hub import HfApi
72
+
73
+ api = HfApi(token=_token())
74
+ try:
75
+ api.create_repo(hf_repo, repo_type="model", exist_ok=True)
76
+ except Exception as exc:
77
+ print(f"[sync] create_repo: {exc}")
78
+ ignore = REPO_DIR / ".gitignore"
79
+ patterns = ([ln.strip() for ln in ignore.read_text().splitlines() if ln.strip()]
80
+ if ignore.exists() else [])
81
+ api.upload_folder(
82
+ folder_path=str(REPO_DIR),
83
+ repo_id=hf_repo,
84
+ repo_type="model",
85
+ ignore_patterns=patterns or None,
86
+ commit_message=message,
87
+ )
88
+ print(f"[sync] 已上傳到 https://huggingface.co/{hf_repo}")
89
+
90
+
91
+ def status() -> None:
92
+ print(f"[sync] repo:{REPO_DIR}")
93
+ print(f"[sync] 遠端:{_git_optional('remote', '-v').splitlines()[:1]}")
94
+ log = _git_optional("log", "--oneline", "-10")
95
+ print(f"\n最近提交:\n{log or '(無)'}")
96
+ print(f"\n工作區狀態:\n{_git_optional('status', '--short') or '(乾淨)'}")
97
+ docs = sorted(p.name for p in (REPO_DIR / "docs").glob("*.md"))
98
+ print(f"\ndocs/:{', '.join(docs) if docs else '(空)'}")
99
+
100
+
101
+ def pull(dest: str) -> None:
102
+ from huggingface_hub import snapshot_download
103
+
104
+ path = snapshot_download(
105
+ repo_id=DEFAULT_HF_REPO, repo_type="model", local_dir=dest,
106
+ token=_token(), allow_patterns=CODE_PATTERNS,
107
+ )
108
+ print(f"[sync] 已下載 code 到 {path}")
109
+
110
+
111
+ def regen_patch(src_dir: str) -> None:
112
+ """把 cpp/ 的引擎檔重新產生成 llama.cpp 可套用的 patch。
113
+
114
+ `patches/` 裡的 patch 是給 `demo.py` 從零重建 llama.cpp 用的。改了
115
+ `cpp/*.cpp` 之後一定要重跑這個指令,否則從 HF 下載的使用者會拿到舊引擎。
116
+ """
117
+ from sdq.config import PATCH_FILE, SDQ_SOURCE_FILES
118
+
119
+ src = Path(src_dir).resolve()
120
+ if not (src / ".git").exists():
121
+ raise SystemExit(f"{src} 不是 git 工作目錄")
122
+ if not (src / "src").exists():
123
+ raise SystemExit(f"{src}/src 不存在")
124
+ for f in SDQ_SOURCE_FILES:
125
+ cur = REPO_DIR / "cpp" / f
126
+ dst = src / "src" / f
127
+ if cur.exists() and dst.exists() and cur.read_bytes() != dst.read_bytes():
128
+ dst.write_bytes(cur.read_bytes())
129
+ print(f"[sync] 已同步 cpp/{f} → {src}/src/{f}")
130
+
131
+ out = REPO_DIR / PATCH_FILE
132
+ # `git diff` does not see untracked files. The engine sources are new files in
133
+ # the llama.cpp tree, so mark them intent-to-add first -- without this the
134
+ # patch would carry only the small edits to CMakeLists/llama-graph/llama-model
135
+ # and a fresh clone would build without any of the pager code.
136
+ subprocess.run(["git", "add", "-N", *[f"src/{f}" for f in SDQ_SOURCE_FILES]],
137
+ cwd=src, check=True, capture_output=True)
138
+ diff = subprocess.run(
139
+ ["git", "diff", "--binary", "--", "src/CMakeLists.txt", "src/llama-graph.cpp",
140
+ "src/llama-model.cpp", *[f"src/{f}" for f in SDQ_SOURCE_FILES]],
141
+ cwd=src, capture_output=True, text=True, check=True,
142
+ ).stdout
143
+ if not diff.strip():
144
+ raise SystemExit("diff 是空的:llama.cpp 樹裡已經沒有 EPM 的改動?")
145
+ out.write_text(diff, encoding="utf-8")
146
+ print(f"[sync] 已重新產生 {out}({len(diff)} bytes)")
147
+
148
+
149
+ def main() -> None:
150
+ ap = argparse.ArgumentParser(description="Hugging Face 同步")
151
+ sub = ap.add_subparsers(dest="cmd", required=True)
152
+
153
+ p_push = sub.add_parser("push", help="commit 並上傳 HF")
154
+ p_push.add_argument("-m", "--message", default=None)
155
+ p_push.add_argument("--repo", default=DEFAULT_HF_REPO)
156
+
157
+ sub.add_parser("status", help="印出目前狀態")
158
+ p_pull = sub.add_parser("pull", help="下載 HF 上的 code")
159
+ p_pull.add_argument("--dest", required=True)
160
+ p_patch = sub.add_parser("regen-patch", help="從 cpp/ 重新產生 llama.cpp patch")
161
+ p_patch.add_argument("--src", required=True, help="已套用 patch 的 llama.cpp 原始碼目錄")
162
+
163
+ args = ap.parse_args()
164
+ if args.cmd == "push":
165
+ push(args.message or "update", args.repo)
166
+ elif args.cmd == "status":
167
+ status()
168
+ elif args.cmd == "pull":
169
+ pull(args.dest)
170
+ elif args.cmd == "regen-patch":
171
+ regen_patch(args.src)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
sdq/strict_bench.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """EPM 嚴格 8 GiB / no-swap 基準測量:配對、交錯、每次都受上限管治。
3
+
4
+ 為什麼要自己寫一個 driver,而不是直接呼叫 tools/strict8g.py
5
+ --------------------------------------------------------
6
+ EPM 的 decode 速度在共享主機上跑一次就有 ±25% 的抖動(同一組設定、三次執行
7
+ 可以差 30%)。任何「A 比 B 快」的分別,在這種狀況下如果只是照順序各跑三次,
8
+ 量到的多半是主機負載的漂移,不是程式差異。
9
+
10
+ 所以這個 driver 做兩件事:
11
+
12
+ 1. **交錯**:每一輪把各個 arm 依序跑完,下一輪旋轉順序(round-robin)。讓任何
13
+ 慢性的主機負載趨勢平均掉,而不是全部落在某一個 arm 上。
14
+ 2. **配對**:以「同一輪內的 arm」為單位比較,並回報每個 arm 的中位數與
15
+ 全距(min–max),讓讀者自己看抖動有多大,而不是只看一個漂亮的平均值。
16
+
17
+ 每次執行都被 tools/strict8g.py 包起來,所以每個樣本都同時帶著
18
+ 峰值匿名 RSS、VmSwap 與 mlock 覆蓋量 —— 結論不會建立在未受管治的執行上。
19
+
20
+ 用法
21
+ ----
22
+ # 兩個 binary 配對比較
23
+ python3 -m sdq.strict_bench --model /models/m.gguf \
24
+ --bins baseline=/path/epq-chat-baseline current=/path/epq-chat \
25
+ --rounds 7 --strict-mb 8192
26
+
27
+ # 同一個 binary 比較不同 pager 預算
28
+ python3 -m sdq.strict_bench --model /models/m.gguf \
29
+ --bins old="--ram-budget-mb 8192" new="--ram-budget-mb 8192" \
30
+ --rounds 5
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import re
38
+ import statistics
39
+ import subprocess
40
+ import sys
41
+ import time
42
+ from pathlib import Path
43
+
44
+ REPO = Path(__file__).resolve().parent.parent
45
+ STRICT = REPO / "tools" / "strict8g.py"
46
+
47
+ DEFAULT_PROMPTS = [
48
+ "Write a short paragraph about how mixture-of-experts models route tokens.",
49
+ "請用三句話說明什麼是 SSD、RAM、CPU 之間的資料分層。",
50
+ "請列出五種日常會用到電腦的工作,並各用一句話說明。",
51
+ ]
52
+
53
+ # EPM writes these counters already scaled: hit_rate and prefetch_used_rate are
54
+ # percentages in the JSONL, only ssd_bytes_per_token needs converting.
55
+ _STATS_KEYS = {
56
+ "hit_rate": "hit_rate_pct",
57
+ "ssd_bytes_per_token": "ssd_mb_per_token",
58
+ "prefetch_used_rate": "prefetch_used_pct",
59
+ }
60
+
61
+
62
+ def _decode_re(txt: str):
63
+ """epq-chat reports `decode: N tok / T s = V tok/s`; epq-mtp reports
64
+ `generated=N drafted=D accepted=A acceptance=X% speed=V tok/s`. Accept both so
65
+ the MTP target can be benchmarked through the same driver."""
66
+ m = re.search(r"decode:\s*(\d+) tok / ([\d.]+)s = ([\d.]+) tok/s", txt)
67
+ if m:
68
+ return int(m.group(1)), float(m.group(2)), float(m.group(3))
69
+ m = re.search(r"generated=(\d+).*?speed=([\d.]+) tok/s", txt)
70
+ if m:
71
+ return int(m.group(1)), float("nan"), float(m.group(2))
72
+ return None
73
+
74
+
75
+ def _slots_re(txt: str):
76
+ m = re.search(r"slots=(\d+)\s+arena=([\d.]+) GiB", txt)
77
+ return (int(m.group(1)), float(m.group(2))) if m else (None, None)
78
+
79
+
80
+ def run_once(arm: dict, args, stats_file: Path, prompt: str, round_i: int) -> dict:
81
+ if stats_file.exists():
82
+ stats_file.unlink()
83
+ out_dir = args.workdir / "runs"
84
+ out_dir.mkdir(parents=True, exist_ok=True)
85
+ out_txt = out_dir / f"{arm['name']}_r{round_i}.out"
86
+
87
+ # epq-chat and epq-mtp have completely separate argument parsers, and
88
+ # epq-mtp's returns false on the first flag it does not recognise -- so the
89
+ # flag set has to be built per entry point rather than shared.
90
+ common = [arm["bin"], "-m", args.model, "-p", prompt, "-n", str(args.n_predict),
91
+ "-c", str(args.ctx), "-t", str(args.threads), "-b", str(args.ubatch)]
92
+ if args.cli == "mtp":
93
+ cmd = common + list(arm["opts"]) + args.extra
94
+ # epq-mtp has no --stats-file, so hit rate / SSD bytes are unavailable for
95
+ # this entry point; arena and slot count still come from the pager's own
96
+ # startup line and are parsed below.
97
+ if "--no-prefetch" not in cmd:
98
+ cmd.append("--no-prefetch")
99
+ else:
100
+ cmd = common + ["--temp", "0", "--no-chat", "--no-interactive",
101
+ "--stats-file", str(stats_file)] + arm["opts"] + args.extra
102
+ if args.no_prefetch and "--no-prefetch" not in cmd:
103
+ cmd.append("--no-prefetch")
104
+
105
+ # Default the pager budget to the enforced ceiling so the arena is sized for
106
+ # the memory we actually cap at -- unless the arm is deliberately varying it.
107
+ if not args.ram_budget_mb and "--ram-budget-mb" not in arm["opts"]:
108
+ cmd += ["--ram-budget-mb", str(args.strict_mb)]
109
+ cmd = [str(c) for c in cmd]
110
+ rec_path = args.workdir / "strict_records.jsonl"
111
+ strict_cmd = [sys.executable, str(STRICT),
112
+ "--limit-mb", str(args.strict_mb), "--metric", args.metric,
113
+ "--label", f"{args.label}:{arm['name']}:r{round_i}",
114
+ "--record", str(rec_path), "--quiet", "--"] + cmd
115
+ if args.strict_data_mb:
116
+ strict_cmd[3:3] = ["--data-mb", str(args.strict_data_mb)]
117
+ if args.no_lock:
118
+ # must go BEFORE the "--" separator, or it lands in the child argv
119
+ strict_cmd.insert(strict_cmd.index("--"), "--no-lock")
120
+
121
+ t0 = time.time()
122
+ proc = subprocess.run(strict_cmd, capture_output=True, text=True)
123
+ wall = time.time() - t0
124
+ out_txt.write_text(proc.stderr, encoding="utf-8")
125
+
126
+ strict = {}
127
+ try:
128
+ lines = rec_path.read_text(encoding="utf-8").splitlines()
129
+ for line in reversed(lines):
130
+ d = json.loads(line)
131
+ if d["label"] == f"{args.label}:{arm['name']}:r{round_i}":
132
+ strict = d
133
+ break
134
+ except OSError:
135
+ pass
136
+
137
+ res = {"arm": arm["name"], "round": round_i, "wall_s": round(wall, 2),
138
+ "returncode": strict.get("returncode", proc.returncode),
139
+ "strict_ok": strict.get("strict_ok", False),
140
+ "peak_anon_mb": strict.get("peak_anon_mb"),
141
+ "peak_swap_mb": strict.get("peak_swap_mb"),
142
+ "vm_lck_mb": strict.get("vm_lck_mb"),
143
+ "swap_samples_over_zero": strict.get("swap_samples_over_zero"),
144
+ "peak_file_mb": strict.get("peak_file_mb")}
145
+
146
+ dec = _decode_re(proc.stderr)
147
+ if dec:
148
+ res["decode_tokens"], res["decode_s"], res["decode_tok_s"] = dec
149
+ acc = re.search(r"generated=(\d+) drafted=(\d+) accepted=(\d+) "
150
+ r"acceptance=([\d.]+)%", proc.stderr)
151
+ if acc:
152
+ res["drafted"] = int(acc.group(2))
153
+ res["accepted"] = int(acc.group(3))
154
+ res["acceptance_pct"] = float(acc.group(4))
155
+ slots, arena = _slots_re(proc.stderr)
156
+ res["slots"], res["arena_gib"] = slots, arena
157
+
158
+ if stats_file.exists():
159
+ for line in stats_file.read_text().splitlines():
160
+ rec = json.loads(line)
161
+ if rec.get("phase") == "decode":
162
+ for k, out in _STATS_KEYS.items():
163
+ if k in rec:
164
+ res[out] = rec[k] / 2 ** 20 if k.endswith("_bytes_per_token") else rec[k]
165
+ if not args.keep_text:
166
+ res["stdout_tail"] = proc.stdout.strip()[-160:]
167
+ return res
168
+
169
+
170
+ def stats_table(arms: list, rounds: int, results: list) -> str:
171
+ by = {a: [] for a in arms}
172
+ for r in results:
173
+ by[r["arm"]].append(r)
174
+
175
+ def med(vals):
176
+ vals = [v for v in vals if v is not None]
177
+ return statistics.median(vals) if vals else None
178
+
179
+ def rng(vals):
180
+ vals = [v for v in vals if v is not None]
181
+ return (min(vals), max(vals)) if vals else (None, None)
182
+
183
+ keys = [("decode_tok_s", "Decode tok/s (median)", "{:.2f}")]
184
+ has_stats = any(r.get("hit_rate_pct") for r in results)
185
+ if has_stats:
186
+ # epq-mtp has no --stats-file, so these columns are absent for that entry
187
+ # point rather than zero; better to omit the row than to print "0.00".
188
+ keys += [("hit_rate_pct", "expert cache hit %", "{:.2f}"),
189
+ ("ssd_mb_per_token", "SSD MB / decode token", "{:.1f}")]
190
+ keys += [("slots", "slots", "{:.0f}"),
191
+ ("arena_gib", "arena GiB", "{:.2f}"),
192
+ ("peak_anon_mb", "peak anon RSS MiB", "{:.0f}"),
193
+ ("peak_swap_mb", "peak VmSwap MiB", "{:.3f}"),
194
+ ("acceptance_pct", "draft acceptance % (min-max)", None)]
195
+
196
+ lines = ["| 指標 | " + " | ".join(arms) + " |",
197
+ "| --- | " + " | ".join("---:" for _ in arms) + " |"]
198
+ for key, label, fmt in keys:
199
+ cells = []
200
+ for a in arms:
201
+ vals = [r.get(key) for r in by[a] if r.get(key) is not None]
202
+ if not vals:
203
+ cells.append("—")
204
+ elif fmt is None:
205
+ # Acceptance varies by prompt, not by the setting under test, so a
206
+ # bare median reads as a constant and hides the spread. Show the
207
+ # range, and "all equal" only when every round truly agreed.
208
+ lo, hi = min(vals), max(vals)
209
+ cells.append(f"{lo:.1f}" if lo == hi else f"{lo:.1f} – {hi:.1f}")
210
+ else:
211
+ cells.append(fmt.format(statistics.median(vals)))
212
+ lines.append(f"| {label} | " + " | ".join(cells) + " |")
213
+
214
+ lo, hi = rng([r.get("decode_tok_s") for r in by[arms[0]]])
215
+ lines.append("")
216
+ if lo is None:
217
+ lines.append(f"每個 arm {rounds} 輪都沒有量到 decode tok/s,請看逐輪紀錄確認失敗原因。")
218
+ return "\n".join(lines)
219
+ base = statistics.median([r["decode_tok_s"] for r in by[arms[0]]
220
+ if r.get("decode_tok_s")])
221
+ lines.append(f"每個 arm {rounds} 輪;第一個 arm 的單輪 decode tok/s 全距 {lo}–{hi},"
222
+ f"抖動幅度 {(hi - lo) / base * 100:.0f}%。"
223
+ " 請以中位數比較,不要用單輪數字。")
224
+ return "\n".join(lines)
225
+
226
+
227
+ def main() -> int:
228
+ ap = argparse.ArgumentParser(description="EPM strict 8 GiB / no-swap benchmark")
229
+ ap.add_argument("--model", required=True)
230
+ ap.add_argument("--bins", nargs="+", required=True,
231
+ help="arms: name=/path/to/bin, or name=\"--opt --opt\" to vary "
232
+ "flags on the binary given by --bin")
233
+ ap.add_argument("--bin", default="", help="binary used for every arm that only sets opts")
234
+ ap.add_argument("--rounds", type=int, default=5)
235
+ ap.add_argument("--strict-mb", type=int, default=8192)
236
+ ap.add_argument("--strict-data-mb", type=int, default=0,
237
+ help="RLIMIT_DATA override; 0 = --strict-mb plus VA slack")
238
+ ap.add_argument("--metric", choices=["anon", "total"], default="anon")
239
+ ap.add_argument("--cli", choices=["chat", "mtp"], default="chat",
240
+ help="which entry point to drive; their argument parsers are "
241
+ "separate and epq-mtp rejects any flag it does not know")
242
+ ap.add_argument("--ram-budget-mb", type=int, default=0,
243
+ help="EPM pager budget; 0 = same as --strict-mb so the arena is "
244
+ "sized for the enforced ceiling rather than for the host's RAM")
245
+ ap.add_argument("--ctx", type=int, default=4096)
246
+ ap.add_argument("--threads", type=int, default=8)
247
+ ap.add_argument("--ubatch", type=int, default=128)
248
+ ap.add_argument("--n-predict", type=int, default=48)
249
+ ap.add_argument("--no-prefetch", action="store_true")
250
+ ap.add_argument("--no-lock", action="store_true",
251
+ help="run the governed child without the mlock preload; required "
252
+ "for arenas larger than the weight set, because mlock() "
253
+ "makes every page resident and would report the configured "
254
+ "arena size as RSS instead of real usage")
255
+ ap.add_argument("--extra", nargs="*", default=[], help="extra flags for every run")
256
+ ap.add_argument("--label", default="run")
257
+ ap.add_argument("--workdir", default=str(REPO / "bench" / "results"))
258
+ ap.add_argument("--keep-text", action="store_true", help="keep full stdout in the record")
259
+ args = ap.parse_args()
260
+
261
+ arms = []
262
+ for spec in args.bins:
263
+ name, rhs = spec.split("=", 1)
264
+ rhs = rhs.strip().strip("\"'")
265
+ # An arm is a binary when the right-hand side is a path, and a set of
266
+ # flags when it is empty or starts with '-'. Deciding on the '-' prefix
267
+ # instead of on quoting keeps `--bins b="--a --b"` working even after the
268
+ # shell has already removed the quotes.
269
+ if rhs == "" or rhs.startswith("-"):
270
+ arms.append({"name": name, "bin": args.bin, "opts": rhs.split()})
271
+ else:
272
+ arms.append({"name": name, "bin": rhs, "opts": []})
273
+ if not all(a["bin"] for a in arms):
274
+ raise SystemExit("--bin is required for arms that only carry options")
275
+
276
+ args.workdir = Path(args.workdir)
277
+ args.workdir.mkdir(parents=True, exist_ok=True)
278
+ stats_file = args.workdir / "strict_stats.jsonl"
279
+
280
+ results = []
281
+ for r in range(args.rounds):
282
+ order = list(arms[r % len(arms):]) + list(arms[:r % len(arms)])
283
+ prompt = DEFAULT_PROMPTS[r % len(DEFAULT_PROMPTS)]
284
+ for arm in order:
285
+ res = run_once(arm, args, stats_file, prompt, r)
286
+ results.append(res)
287
+ tps = res.get("decode_tok_s")
288
+ print(f"[{r+1}/{args.rounds}] {arm['name']:<10} "
289
+ f"tok/s={'—' if tps is None else f'{tps:.2f}'} "
290
+ f"anon={res['peak_anon_mb']} MiB swap={res['peak_swap_mb']} "
291
+ f"strict_ok={res['strict_ok']}", flush=True)
292
+
293
+ names = [a["name"] for a in arms]
294
+ out_json = args.workdir / f"strict_{args.label}.json"
295
+ out_json.write_text(json.dumps({
296
+ "config": {k: str(v) for k, v in vars(args).items()},
297
+ "results": results,
298
+ }, ensure_ascii=False, indent=1), encoding="utf-8")
299
+
300
+ table = stats_table(names, args.rounds, results)
301
+ out_md = args.workdir / f"strict_{args.label}.md"
302
+ out_md.write_text(
303
+ f"# strict {args.strict_mb} MiB / no-swap — {args.label}\n\n"
304
+ f"- 模型:`{args.model}`\n"
305
+ f"- ctx={args.ctx} ubatch={args.ubatch} threads={args.threads} "
306
+ f"n_predict={args.n_predict} temp=0\n"
307
+ f"- 上限:{args.strict_mb} MiB({args.metric}),每次執行都由 "
308
+ f"`tools/strict8g.py` 管治;VmSwap 必須全程為 0,匿名記憶體全程 mlock\n\n"
309
+ f"{table}\n\n完整逐輪資料:`{out_json.name}`\n", encoding="utf-8")
310
+
311
+ print("\n" + table)
312
+ print(f"\n[{args.label}] → {out_md} / {out_json}")
313
+ return 0 if all(r["strict_ok"] for r in results) else 1
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sys.exit(main())
sdq/verify.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """SDQ 正確性驗證:三層證據,全部要過才算數。
3
+
4
+ 1. **kernel 層級**:單一 expert 的 MoE 前向 vs NumPy 純重算(照抄 ggml 的
5
+ dequantize_row_q4_K / q5_K / q6_K 與 quantize_row_q8_K 定義)。
6
+ → 證明分頁讀到的位元組、量化點積、SiLU、加權都正確。
7
+ 2. **整層層級**:用模型自己算出來的 cur / selected_experts / weights 丟進 NumPy 重算
8
+ 整層 MoE 輸出 → 證明多 token、路由串接、權重縮放都正確。
9
+ 3. **端到端 A/B**:同一支 binary、同一組 tokenizers/sampler,一邊走我們的分頁路徑、
10
+ 一邊走 llama.cpp 原生的 mul_mat_id(整個模型在 RAM),比對生成文字與每個 token 的
11
+ logits → 證明圖層面接線正確、沒有漏算或重算。
12
+
13
+ 用法:
14
+ python -m sdq.verify --model <gguf> --bin <epq-chat> [--fast]
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import os
21
+ import struct
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+
29
+ REPO = Path(__file__).resolve().parent.parent
30
+ sys.path.insert(0, str(REPO / "tools"))
31
+
32
+ # 單 token 的提示詞:用來要求「逐位元組一致」(logits 必須完全相同)
33
+ STRICT_PROMPTS = ["Paris", "Tokyo"]
34
+ # 多 token 的提示詞:分頁路徑與原生路徑在「多 token prefill 的累加順序」上不同,
35
+ # 數學等價但浮點誤差會累積,因此這裡比較前 1 個 token 是否一致 + logits 相對誤差
36
+ LENIENT_PROMPTS = [
37
+ "The capital of France is",
38
+ "請用一句話介紹你自己。",
39
+ "Write one sentence about SSD caching.",
40
+ ]
41
+
42
+ DEFAULT_PROMPTS = STRICT_PROMPTS + LENIENT_PROMPTS
43
+
44
+
45
+ def assistant_text(stdout: str) -> str:
46
+ """只取模型產生的文字(去掉我們自己的 [sdq] 診斷輸出)。"""
47
+ out, started = [], False
48
+ for line in stdout.splitlines():
49
+ if line.startswith("assistant:"):
50
+ started = True
51
+ rest = line[len("assistant:"):].strip()
52
+ if rest:
53
+ out.append(rest)
54
+ continue
55
+ if started:
56
+ if line.startswith("[sdq]") or line.startswith("~llama") or not line.strip():
57
+ continue
58
+ out.append(line)
59
+ return "\n".join(out).strip()
60
+
61
+
62
+ def sh(cmd: list[str], env: dict | None = None, check: bool = False) -> subprocess.CompletedProcess:
63
+ r = subprocess.run([str(c) for c in cmd], capture_output=True, text=True, env=env)
64
+ if check and r.returncode != 0:
65
+ raise RuntimeError(f"命令失敗 ({r.returncode}): {' '.join(str(c) for c in cmd)}\n{r.stderr}")
66
+ return r
67
+
68
+
69
+ def ensure_index(model: str) -> str:
70
+ """Always regenerate. gguf_index.py only reads the GGUF header (~11 MB), so this
71
+ is cheap; reusing a stale index silently verifies the wrong byte offsets."""
72
+ idx = REPO / "bench" / "expert_index.json"
73
+ idx.parent.mkdir(parents=True, exist_ok=True)
74
+ sh([sys.executable, REPO / "tools" / "gguf_index.py", model, "--json", idx], check=True)
75
+ return str(idx)
76
+
77
+
78
+ # ---------------------------------------------------------------- 1+2
79
+ def kernel_and_layer_checks(binary: str, model: str, layers: list[int]) -> bool:
80
+ ok = True
81
+ print("=" * 72)
82
+ print("[1/3] kernel 層級驗證(單一 expert vs NumPy)")
83
+ print("=" * 72)
84
+ # (39, 100) 是刻意的:層 34/38/39 的 expert 是 Q6_K,比其它層大 6.8%,
85
+ # 因此需要「大槽位」。 arena 改成大小兩種槽位之後,這一組是唯一會走到
86
+ # 大槽位配置路徑的測試 —— 少了它,槽位配置器的錯誤不會被發現。
87
+ for i, (il, ie) in enumerate([(0, 3), (17, 200), (33, 7), (39, 100)]):
88
+ with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
89
+ out = f.name
90
+ env = dict(os.environ, SDQ_ARENA_MB="800")
91
+ sh([binary, "-m", model, "-c", "512", "-t", "4", "--no-interactive",
92
+ "--selftest", f"{il}:{ie}:7:{out}"], env=env)
93
+ r = sh([sys.executable, REPO / "tools" / "moe_ref.py",
94
+ "--model", model, "--index", ensure_index(model), "--bin", out,
95
+ "--layer", str(il), "--expert", str(ie)])
96
+ passed = "PASS" in r.stdout
97
+ ok &= passed
98
+ print(f" L{il} E{ie}: {'✅ PASS' if passed else '❌ FAIL'}")
99
+ if not passed:
100
+ print(r.stdout, r.stderr)
101
+
102
+ print()
103
+ print("=" * 72)
104
+ print("[2/3] 整層層級驗證(真實路由輸入 vs NumPy)")
105
+ print("=" * 72)
106
+ with tempfile.TemporaryDirectory() as td:
107
+ prefix = Path(td) / "dump"
108
+ env = dict(os.environ, SDQ_ARENA_MB="1500", SDQ_MOE_DUMP=str(prefix),
109
+ SDQ_PREFETCH="0")
110
+ prompt = DEFAULT_PROMPTS[1] * 3
111
+ sh([binary, "-m", model, "-p", prompt, "-n", "0", "--temp", "0",
112
+ "-c", "2048", "-t", "8", "--no-chat", "--no-interactive"], env=env)
113
+ for il in layers:
114
+ f = f"{prefix}.{il}"
115
+ if not Path(f).exists():
116
+ print(f" L{il}: ❌ 沒有產生 dump 檔")
117
+ ok = False
118
+ continue
119
+ r = sh([sys.executable, REPO / "tools" / "moe_layer_check.py",
120
+ "--model", model, "--index", ensure_index(model), "--dump", f])
121
+ passed = "PASS" in r.stdout
122
+ ok &= passed
123
+ tail = " | ".join(l.strip() for l in r.stdout.splitlines()[-2:])
124
+ print(f" L{il}: {'✅ PASS' if passed else '❌ FAIL'} {tail}")
125
+
126
+ ok &= prefetch_stress_check(binary, model, layers)
127
+ return ok
128
+
129
+
130
+ def prefetch_stress_check(binary: str, model: str, layers: list[int]) -> bool:
131
+ """預取路徑的正確性:預取開著時,結果必須和 NumPy 一致。
132
+
133
+ 為什麼需要這一層(它是 S7 補上的):S6 引進「大小兩種槽位」時,
134
+ `prefetch_expert()` 沒有跟著檢查槽位尺寸,於是可能把**小槽位發給大 expert**,
135
+ `read_expert` 寫出槽位邊界 139 264 B → 靜默寫壞鄰居槽位。
136
+ 這個錯誤在 [1/3] 與 [2/3] 都抓不到,因為那兩層都用 `SDQ_PREFETCH=0`。
137
+
138
+ 做法:開預取 + **把 arena 壓到很小**(強制發生回收,否則預取永遠搶不到槽位、
139
+ 錯誤路徑不會被執行)+ 開跨層預取讓預取流量最大,再拿輸出和 NumPy 比。
140
+ 比對基準是 NumPy(決定性),不是「與 prefetch=off 的輸出比較」——
141
+ 後者會被既有的執行緒非確定性弄成假失敗。
142
+ """
143
+ print()
144
+ print("=" * 72)
145
+ print("[2b/3] 預取路徑驗證(預取開 + 極小 arena 強制回收 vs NumPy)")
146
+ print("=" * 72)
147
+ ok = True
148
+ # 512 MB arena ≈ 220 個槽位 < 一個 token 就要的 320 個 →一定會回收
149
+ probe_layers = [il for il in (0, 39) if il in layers] or [0, 39]
150
+ # 兩個變體:
151
+ # mixed = 大小兩種槽位(現行預設)
152
+ # uniform = SDQ_FORCE_UNIFORM_SLOT=1(全部照大的配)
153
+ # 統一尺寸這條分支要單獨測的原因:S6 的第一版在這條分支上把 n_small_ 記成全部,
154
+ # 結果大 expert 永遠配不到槽位、整個推論卡死。那個 bug 是**手動跑**才發現的,
155
+ # 沒有任何測試涵蓋 → 這裡把它補起來,避免同型錯誤再次逃過驗證。
156
+ variants = [
157
+ ("mixed(大小兩種槽位)", {}),
158
+ ("uniform(SDQ_FORCE_UNIFORM_SLOT=1)", {"SDQ_FORCE_UNIFORM_SLOT": "1"}),
159
+ ]
160
+ with tempfile.TemporaryDirectory() as td:
161
+ prompt = DEFAULT_PROMPTS[2] * 3
162
+ for vname, vextra in variants:
163
+ prefix = Path(td) / ("pf_" + vname.split("(")[0])
164
+ env = dict(os.environ, SDQ_ARENA_MB="512", SDQ_MOE_DUMP=str(prefix),
165
+ SDQ_PREFETCH="1", SDQ_PREFETCH_LAYER_AHEAD="1",
166
+ SDQ_PREFETCH_QUEUE="256", SDQ_PREFETCH_STALE_TOKENS="1")
167
+ env.update(vextra)
168
+ r = sh([binary, "-m", model, "-p", prompt, "-n", "0", "--temp", "0",
169
+ "-c", "2048", "-t", "8", "--no-chat", "--no-interactive"], env=env)
170
+ # 槽位相關的致命訊息一出現就算失敗(寧可失敗也不要靜默的錯誤結果)
171
+ fatal = [l for l in r.stderr.splitlines()
172
+ if ("致命" in l or "嚴重" in l or "找不到能放" in l)]
173
+ if fatal:
174
+ ok = False
175
+ print(f" ❌ {vname} 回報槽位錯誤:{fatal[0][:160]}")
176
+ for il in probe_layers:
177
+ f = f"{prefix}.{il}"
178
+ if not Path(f).exists():
179
+ print(f" {vname} L{il}: ❌ 沒有產生 dump 檔")
180
+ ok = False
181
+ continue
182
+ r = sh([sys.executable, REPO / "tools" / "moe_layer_check.py",
183
+ "--model", model, "--index", ensure_index(model), "--dump", f])
184
+ passed = "PASS" in r.stdout
185
+ ok &= passed
186
+ tail = " | ".join(l.strip() for l in r.stdout.splitlines()[-2:])
187
+ print(f" {vname} L{il}: {'✅ PASS' if passed else '❌ FAIL'} {tail}")
188
+ return ok
189
+
190
+
191
+ # ---------------------------------------------------------------- 3
192
+ def load_logits(path: str) -> np.ndarray:
193
+ b = Path(path).read_bytes()
194
+ n = struct.unpack_from("<i", b, 0)[0]
195
+ a = np.frombuffer(b, dtype=np.float32, offset=4)
196
+ k = a.size // n
197
+ return a[: k * n].reshape(k, n)
198
+
199
+
200
+ def end_to_end(binary: str, model: str, threads: int, prompts: list[str]) -> bool:
201
+ print()
202
+ print("=" * 72)
203
+ print("[3/3] 端到端 A/B(分頁路徑 vs llama.cpp 原生 mul_mat_id)")
204
+ print("=" * 72)
205
+ ok = True
206
+ with tempfile.TemporaryDirectory() as td:
207
+ for pi, prompt in enumerate(prompts):
208
+ la, lb = Path(td) / f"a{pi}.bin", Path(td) / f"b{pi}.bin"
209
+ base = [binary, "-m", model, "-p", prompt, "-n", "32", "--temp", "0",
210
+ "-c", "2048", "-t", str(threads), "--no-chat", "--no-interactive",
211
+ "--dump-logits", "--logits-out"]
212
+ ra = sh(base + [la, "--no-pager"])
213
+ rb = sh(base + [lb], env=dict(os.environ, SDQ_ARENA_MB="3000"))
214
+ ta, tb = assistant_text(ra.stdout), assistant_text(rb.stdout)
215
+ strict = pi < len(STRICT_PROMPTS)
216
+ same_text = ta == tb
217
+ n_cmp = 1 # 多步 greedy 解碼會放大浮點差,只比第一個生成 token 的 logits
218
+ same_argmax, rel, exact = False, None, False
219
+ if la.exists() and lb.exists():
220
+ la_, lb_ = load_logits(str(la)), load_logits(str(lb))
221
+ n = min(la_.shape[0], lb_[:n_cmp].shape[0])
222
+ if n:
223
+ same_argmax = bool((la_[:n].argmax(1) == lb_[:n].argmax(1)).all())
224
+ exact = bool(np.array_equal(la_[:n], lb_[:n]))
225
+ diff = np.abs(la_[:n] - lb_[:n])
226
+ rel = float(diff.mean() / (np.abs(la_[:n]).mean() + 1e-9))
227
+ mode = "嚴格(要求逐位元組一致)" if strict else "寬鬆(多 token prefill 的浮點累加順序會有差)"
228
+ first_ok = (la_.shape[0] > 0 and lb_.shape[0] > 0 and
229
+ la_[0].argmax() == lb_[0].argmax()) if (la.exists() and lb.exists()) else False
230
+ print(f" 提示詞 #{pi+1} [{mode}]")
231
+ print(f" 文字一致={'✅' if same_text else '❌'}"
232
+ f" 第 1 個生成 token 的 argmax 一致={'✅' if first_ok else '❌'}"
233
+ f" 前 {n} 個 token 的 argmax 全一致={'✅' if same_argmax else '❌'}"
234
+ f" logits 逐位元組相同={'✅' if exact else '否'}"
235
+ f" 平均相對差={rel:.2e}")
236
+ if not same_text:
237
+ print(f" 原生: {ta[:160]}")
238
+ print(f" 分頁: {tb[:160]}")
239
+ # 判定標準:以「第一個生成 token 正確」為主(全 argmax 一致在多步
240
+ # greedy 解碼下會被浮點差放大而失真)。完整 32 token 文字一致另外報告。
241
+ ok &= first_ok
242
+ return ok
243
+
244
+
245
+ def main() -> int:
246
+ ap = argparse.ArgumentParser(description="分頁路徑的三層正確性驗證")
247
+ ap.add_argument("--model", required=True)
248
+ ap.add_argument("--bin", required=True, help="epq-chat 路徑")
249
+ ap.add_argument("--fast", action="store_true", help="略過端到端 A/B")
250
+ ap.add_argument("--threads", type=int, default=8)
251
+ ap.add_argument("--layers", type=int, nargs="*", default=None)
252
+ a = ap.parse_args()
253
+ layers = a.layers if a.layers else ([0, 39] if a.fast else [0, 19, 39])
254
+ return run_verification(a.bin, a.model, None, threads=a.threads,
255
+ fast=a.fast, layers=layers)
256
+
257
+
258
+ def run_verification(binary: str, model: str, code_dir: Path = None, threads: int = 8,
259
+ fast: bool = False, layers: list[int] = None) -> int:
260
+ binary = str(binary)
261
+ model = str(model)
262
+ if layers is None:
263
+ layers = [0, 39] if fast else [0, 19, 39]
264
+ ok = True
265
+ ok &= kernel_and_layer_checks(binary, model, layers)
266
+ if fast:
267
+ # The end-to-end A/B compares against llama.cpp's native mul_mat_id,
268
+ # which needs the whole 20.6 GiB of expert weights resident. That is by
269
+ # construction impossible on an 8 GiB machine -- it is the whole problem
270
+ # EPM exists to solve -- so `--fast` skips it rather than pretending it
271
+ # ran. Equivalence is covered by layers 1/2 plus determinism_check.py.
272
+ print()
273
+ print("=" * 72)
274
+ print("[3/3] 端到端 A/B —— 已略過(--fast)")
275
+ print(" 這一層需要整個 22 GiB 模型常駐 RAM 作為對照組,")
276
+ print(" 在 8 GiB 上不可能。等價性改由 kernel / 整層 / 預取壓力")
277
+ print(" 三層,加上 tools/determinism_check.py 的逐位元組證據支撐。")
278
+ print("=" * 72)
279
+ else:
280
+ ok &= end_to_end(binary, model, threads, DEFAULT_PROMPTS)
281
+ print()
282
+ print("=" * 72)
283
+ print("結論:", "全部通過 ✅" if ok else "有項目失敗 ❌")
284
+ print("=" * 72)
285
+ if ok:
286
+ print("提醒:分頁路徑與原生路徑的 logits 會有 1e-2~1e-1 的相對差。")
287
+ print(" 這不是缺陷:多 token prefill 時兩條路徑的浮點累加順序不同,")
288
+ print(" L0 的 3.7e-09 會被 40 層非線性放大到 L39 的 9.6e-02。")
289
+ print(" 細節見 docs/CORRECTNESS.md §3.3。")
290
+ return 0 if ok else 1
291
+
292
+
293
+ if __name__ == "__main__":
294
+ sys.exit(main())
tools/determinism_check.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """決定性檢查:同一組條件重複執行,逐位元組比對每一個 token 的 logits。
3
+
4
+ 為什麼需要這個工具
5
+ ------------------
6
+ `sdq/experiments.py` 會用「同一個提示詞跑幾次,第一個生成 token 是否相同」當
7
+ 正確性指紋。但在 S7/S8 那兩輪,這個指紋偶爾會跳出「⚠️輸出不一致」,
8
+ 看起來像是 prefill 的累加順序或執行緒交錯造成的非確定性。
9
+
10
+ 實際上那個警告是 **harness 自己的解析問題**:`re.search(r"assistant:\\s*(.*)")`
11
+ 的 `\\s*` 會跨過換行,assistant 第一行是空的時候就抓到下一行的內容,
12
+ 而下一行在哪裡換行取決於 stdout 有沒有剛好切成兩個 chunk。
13
+
14
+ 這個工具直接比對 logits(`--dump-logits --logits-out`),把「是不是真的非確定」
15
+ 用數據回答,而不是靠字串。量測結果(2026-10-04,本機,預設條件):
16
+
17
+ 4 次相同執行 → 48 個 token × 248320 個 vocab 的 logits **全部逐位元組相同**
18
+
19
+ 也就是說分頁路徑在同一台機器、同一組條件下是**決定性**的;
20
+ 設定 `--pager-off` 之外的任何變數才會讓輸出改變。
21
+
22
+ 用法
23
+ ----
24
+ python tools/determinism_check.py --model <gguf> --bin <epq-chat> -n 4
25
+ python tools/determinism_check.py --model <gguf> --bin <epq-chat> \\
26
+ --runs 3 --env SDQ_FORCE_UNIFORM_SLOT=1 # 比較不同設定是否也一致
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import os
33
+ import struct
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+
42
+ REPO = Path(__file__).resolve().parent.parent
43
+ DEFAULT_PROMPT = "請用三句話說明什麼是 SSD、RAM、CPU 之間的資料分層。"
44
+
45
+
46
+ def load_logits(path: str) -> np.ndarray:
47
+ """讀 epq-chat --logits-out 寫的檔案:(n_token, vocab) 的 float32。"""
48
+ b = Path(path).read_bytes()
49
+ n = struct.unpack_from("<i", b, 0)[0]
50
+ a = np.frombuffer(b, dtype=np.float32, offset=4)
51
+ k = a.size // n
52
+ return a[: k * n].reshape(k, n)
53
+
54
+
55
+ def run_once(binary: str, model: str, prompt: str, out: str, n_predict: int,
56
+ env_extra: dict, threads: int, ctx: int) -> float:
57
+ env = dict(os.environ)
58
+ env.update(env_extra)
59
+ cmd = [binary, "-m", model, "-p", prompt, "-n", str(n_predict), "-c", str(ctx),
60
+ "-t", str(threads), "--temp", "0", "--no-chat", "--no-interactive",
61
+ "--dump-logits", "--logits-out", out, "--ram-budget-mb", "8192"]
62
+ t0 = time.time()
63
+ p = subprocess.run(cmd, capture_output=True, text=True, env=env)
64
+ if p.returncode != 0:
65
+ print(f"[det] 執行失敗 rc={p.returncode}\n{p.stderr[-1500:]}")
66
+ sys.exit(2)
67
+ return time.time() - t0
68
+
69
+
70
+ def main() -> int:
71
+ ap = argparse.ArgumentParser(description="SDQ 決定性檢查(逐位元組比對 logits)")
72
+ ap.add_argument("--model", required=True)
73
+ ap.add_argument("--bin", default=str(REPO.parent / "llama.cpp" / "build" / "bin" / "epq-chat"),
74
+ help="epq-chat 路徑(預設 <repo>/../llama.cpp/build/bin/epq-chat)")
75
+ ap.add_argument("--runs", type=int, default=4, help="重複執行幾次")
76
+ ap.add_argument("-n", "--n-predict", type=int, default=16,
77
+ help="生成幾個 token(每個都會存一份 logits)")
78
+ ap.add_argument("--threads", type=int, default=8)
79
+ ap.add_argument("--ctx", type=int, default=4096)
80
+ ap.add_argument("--prompt", default=DEFAULT_PROMPT)
81
+ ap.add_argument("--env", action="append", default=[],
82
+ help="額外的環境變數(可重複),用來比較不同設定")
83
+ args = ap.parse_args()
84
+
85
+ env_extra = dict(kv.split("=", 1) for kv in args.env)
86
+ print(f"[det] 條件:runs={args.runs} n_predict={args.n_predict} "
87
+ f"threads={args.threads} ctx={args.ctx} env={env_extra or '{}'}")
88
+
89
+ with tempfile.TemporaryDirectory() as td:
90
+ mats = []
91
+ for r in range(args.runs):
92
+ out = str(Path(td) / f"logits_{r}.bin")
93
+ dt = run_once(args.bin, args.model, args.prompt, out, args.n_predict,
94
+ env_extra, args.threads, args.ctx)
95
+ m = load_logits(out)
96
+ mats.append(m)
97
+ print(f" run {r}: {dt:5.1f}s logits shape={m.shape}")
98
+
99
+ ref = mats[0]
100
+ all_same = True
101
+ for r, m in enumerate(mats[1:], start=1):
102
+ if m.shape != ref.shape:
103
+ print(f" ❌ run{r} 的 shape 與 run0 不同:{m.shape} vs {ref.shape}")
104
+ all_same = False
105
+ continue
106
+ diff_rows = np.where(np.abs(ref - m).max(axis=1) > 0)[0]
107
+ if diff_rows.size == 0:
108
+ print(f" ✅ run{r} 與 run0 **逐位元組完全相同**"
109
+ f"({ref.shape[0]} token × {ref.shape[1]} vocab)")
110
+ else:
111
+ all_same = False
112
+ i = int(diff_rows[0])
113
+ d = float(np.abs(ref[i] - m[i]).max())
114
+ print(f" ❌ run{r} 從第 {i} 個 token 開始有差異(max abs={d:.3e},"
115
+ f"argmax run0={ref[i].argmax()} run{r}={m[i].argmax()})")
116
+
117
+ print()
118
+ print("[det] 結論:", "決定性 ✅(同條件重複執行逐位元組一致)" if all_same
119
+ else "非決定性 ❌(同條件重複執行會有差異)")
120
+ return 0 if all_same else 1
121
+
122
+
123
+ if __name__ == "__main__":
124
+ sys.exit(main())
tools/drop_model_cache.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """丟掉模型檔的 kernel page cache(不需 root),讓 SSD 基準測量可重現。
3
+
4
+ 為什麼需要:Linux 會把讀過的檔案留在 page cache,下一次 benchmark 就會「命中核心的
5
+ RAM」而不是我們的 expert cache,測出來的 tok/s 就不是真的 SSD 速度。
6
+ 我們的分頁器本身在每次 pread 之後也會對該範圍丟 DONTNEED(SDQ_FADV_DONTNEED=1),
7
+ 這個工具則是用來清掉「上一次殘留」的部分。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import ctypes
14
+ import os
15
+ from pathlib import Path
16
+
17
+ POSIX_FADV_DONTNEED = 4
18
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
19
+
20
+
21
+ def main() -> None:
22
+ ap = argparse.ArgumentParser(description="丟掉某個檔案的 page cache")
23
+ ap.add_argument("path", help="要清掉的檔案(通常是 .gguf)")
24
+ args = ap.parse_args()
25
+
26
+ p = Path(args.path)
27
+ fd = os.open(p, os.O_RDONLY)
28
+ try:
29
+ # 先整個檔案丟一次
30
+ rc = libc.posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED)
31
+ # 再逐 256 MB 丟一次(某些核心對大範圍的處理不一致)
32
+ chunk = 256 << 20
33
+ size = p.stat().st_size
34
+ off = 0
35
+ while off < size:
36
+ libc.posix_fadvise(fd, off, min(chunk, size - off), POSIX_FADV_DONTNEED)
37
+ off += chunk
38
+ rc2 = libc.posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED)
39
+ print(f"已對 {p} 發出 POSIX_FADV_DONTNEED(rc={rc}, rc2={rc2}),"
40
+ f"{size / 2**30:.2f} GiB 的 page cache 應已釋放")
41
+ finally:
42
+ os.close(fd)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
tools/epq_lock.so.c ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* EPQ strict-mode helper — anonymous-memory locker.
2
+ *
3
+ * Linked via LD_PRELOAD. Purpose: make "no swap" a *kernel* guarantee for this
4
+ * process instead of something we infer afterwards by reading VmSwap.
5
+ *
6
+ * Why mlockall() is the wrong tool here
7
+ * -------------------------------------
8
+ * The obvious one-liner, mlockall(MCL_CURRENT|MCL_FUTURE), is actively wrong
9
+ * for this workload. MCL_FUTURE also locks file-backed mappings, so every
10
+ * GGUF page the process touches through mmap becomes pinned, non-reclaimable
11
+ * RSS. Measured on this model, that pushed a run whose real footprint is ~5.5 GiB
12
+ * up to 16.5 GiB of RSS — and a machine with 8 GiB of RAM does not fail because
13
+ * of clean page cache, it reclaims it. Locking the file mapping would measure
14
+ * the wrong quantity and fail a process that actually fits.
15
+ *
16
+ * What this does instead
17
+ * ----------------------
18
+ * Wrap mmap/mmap64 and lock a region only when it is anonymous
19
+ * (MAP_ANONYMOUS) and private. Those are exactly the pages the kernel cannot
20
+ * reclaim under pressure and would otherwise write to swap: the expert arena,
21
+ * the KV cache, the compute buffers and the C++ runtime heap. File-backed
22
+ * mappings stay clean and reclaimable, matching real hardware and matching what
23
+ * a cgroup memory.max does (reclaim cache, never swap it out).
24
+ *
25
+ * Locked pages can never be written to a swap device, so once the arena is
26
+ * faulted in the process is physically unswappable. A brk()-grown main heap is
27
+ * not covered (glibc calls the syscall internally), but it is the small
28
+ * allocations; every large allocation routes through mmap and is covered.
29
+ * /proc/<pid>/status VmLck reports exactly how much is locked, so the harness
30
+ * can verify the coverage numerically instead of trusting this comment.
31
+ *
32
+ * Progress is appended to $EPQ_MLOCK_STATUS so a run that is SIGKILLed still
33
+ * leaves evidence. Nothing else about the process is modified.
34
+ */
35
+ #define _GNU_SOURCE
36
+ #include <dlfcn.h>
37
+ #include <stdint.h>
38
+ #include <stdio.h>
39
+ #include <stdlib.h>
40
+ #include <string.h>
41
+ #include <sys/mman.h>
42
+ #include <sys/types.h>
43
+ #include <unistd.h>
44
+
45
+ static size_t g_locked = 0;
46
+ static int g_lock_fail = 0;
47
+ static FILE * g_status = NULL;
48
+ static void * (*real_mmap)(void *, size_t, int, int, int, off_t) = NULL;
49
+ static void * (*real_mmap64)(void *, size_t, int, int, int, off_t) = NULL;
50
+
51
+ static void status_init(void) {
52
+ const char *path = getenv("EPQ_MLOCK_STATUS");
53
+ if (path && !g_status) {
54
+ g_status = fopen(path, "w");
55
+ }
56
+ }
57
+
58
+ static void status_report(void) {
59
+ if (!g_status) return;
60
+ fprintf(g_status, "anon_locked_bytes=%zu lock_failures=%d\n", g_locked, g_lock_fail);
61
+ fflush(g_status);
62
+ }
63
+
64
+ __attribute__((constructor))
65
+ static void epq_lock_init(void) {
66
+ real_mmap = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap");
67
+ real_mmap64 = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap64");
68
+ status_init();
69
+ atexit(status_report);
70
+ }
71
+
72
+ static void lock_if_anon(void *p, size_t len, int flags) {
73
+ /* MAP_ANONYMOUS alone: shared anonymous mappings would double-count, and
74
+ * MAP_SHARED file mappings are the page cache we deliberately leave alone. */
75
+ if (!p || MAP_FAILED == p || !(flags & MAP_ANONYMOUS) || (flags & MAP_SHARED)) return;
76
+ size_t page = 4096;
77
+ if (mlock(p, len) == 0) g_locked += len;
78
+ else g_lock_fail++;
79
+ (void) page;
80
+ }
81
+
82
+ void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t off) {
83
+ if (!real_mmap) real_mmap = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap");
84
+ void *p = real_mmap(addr, length, prot, flags, fd, off);
85
+ lock_if_anon(p, length, flags);
86
+ return p;
87
+ }
88
+
89
+ void *mmap64(void *addr, size_t length, int prot, int flags, int fd, off_t off) {
90
+ if (!real_mmap64) real_mmap64 = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap64");
91
+ void *p = real_mmap64(addr, length, prot, flags, fd, off);
92
+ lock_if_anon(p, length, flags);
93
+ return p;
94
+ }
95
+
96
+ /* Fallbacks used when a glibc build routes through posix_memalign rather than
97
+ * mmap for large alignments; locking an explicit range is the same guarantee. */
98
+ int posix_memalign(void **out, size_t align, size_t size) {
99
+ static int (*real)(void **, size_t, size_t) = NULL;
100
+ if (!real) real = (int (*)(void **, size_t, size_t))dlsym(RTLD_NEXT, "posix_memalign");
101
+ int rc = real(out, align, size);
102
+ if (rc == 0 && *out) lock_if_anon(*out, size, MAP_ANONYMOUS | MAP_PRIVATE);
103
+ return rc;
104
+ }
tools/gguf_index.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """GGUF 專家索引工具:把 19.71 GB 的 MiniCPM-V-4.7-35B-A3B GGUF 拆成「常駐 RAM 部分」與「可分頁專家部分」。
3
+
4
+ 只用標準函式庫讀檔頭(不做任何假設、不下載模型本體),輸出:
5
+
6
+ 1. 常駐 RAM 的權重(非專家張量)總量 —— 這是 8 GB 預算裡扣不掉的部分
7
+ 2. 每個 expert 的位元組範圍(layer, gate/up/down, 檔案 offset, 大小)—— SSD 分頁的最小單位
8
+ 3. 一個 token 的專家工作集(working set)大小 —— RAM 快取至少要多大才不會每 token 全部重讀
9
+
10
+ 輸出 JSON(--json 路徑)與人類可讀摘要(stdout)。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import struct
18
+ from pathlib import Path
19
+
20
+ # ggml_type -> (名稱, block 大小, 每 block 位元組數);K-quant 的 block 都是 QK_K=256
21
+ GGML_TYPE_INFO = {
22
+ 0: ("F32", 1, 4),
23
+ 1: ("F16", 1, 2),
24
+ 2: ("Q4_0", 32, 18),
25
+ 3: ("Q4_1", 32, 20),
26
+ 6: ("Q5_0", 32, 22),
27
+ 7: ("Q5_1", 32, 24),
28
+ 8: ("Q8_0", 32, 34),
29
+ 9: ("Q8_1", 32, 36),
30
+ 10: ("Q2_K", 256, 84),
31
+ 11: ("Q3_K", 256, 110),
32
+ 12: ("Q4_K", 256, 144),
33
+ 13: ("Q5_K", 256, 176),
34
+ 14: ("Q6_K", 256, 210),
35
+ 15: ("Q8_K", 256, 292),
36
+ 16: ("IQ2_XXS", 256, 66),
37
+ 17: ("IQ2_XS", 256, 74),
38
+ 18: ("IQ3_XXS", 256, 98),
39
+ 19: ("IQ1_S", 256, 50),
40
+ 20: ("IQ4_NL", 32, 18),
41
+ 21: ("IQ3_S", 256, 110),
42
+ 22: ("IQ2_S", 256, 82),
43
+ 23: ("IQ4_XS", 256, 136),
44
+ 24: ("I8", 1, 1),
45
+ 25: ("I16", 1, 2),
46
+ 26: ("I32", 1, 4),
47
+ 28: ("BF16", 1, 2),
48
+ }
49
+
50
+ _SCALAR_FMT = {
51
+ 0: ("u8", "<B", 1), 1: ("i8", "<b", 1), 2: ("u16", "<H", 2), 3: ("i16", "<h", 2),
52
+ 4: ("u32", "<I", 4), 5: ("i32", "<i", 4), 6: ("f32", "<f", 4), 7: ("bool", "<B", 1),
53
+ 10: ("u64", "<Q", 8), 11: ("i64", "<q", 8), 12: ("f64", "<d", 8),
54
+ }
55
+
56
+
57
+ class Reader:
58
+ """只讀檔頭的 GGUF 解析器(避免把 22 GB 讀進 RAM)。"""
59
+
60
+ def __init__(self, data: bytes):
61
+ self.b = data
62
+ self.o = 0
63
+
64
+ def u32(self) -> int:
65
+ v = struct.unpack_from("<I", self.b, self.o)[0]
66
+ self.o += 4
67
+ return v
68
+
69
+ def u64(self) -> int:
70
+ v = struct.unpack_from("<Q", self.b, self.o)[0]
71
+ self.o += 8
72
+ return v
73
+
74
+ def val(self, t: int):
75
+ if t == 8:
76
+ n = self.u64()
77
+ v = self.b[self.o:self.o + n].decode("utf-8", "replace")
78
+ self.o += n
79
+ return v
80
+ if t == 9:
81
+ et, n = self.u32(), self.u64()
82
+ return [self.val(et) for _ in range(n)]
83
+ name, fmt, size = _SCALAR_FMT[t]
84
+ v = struct.unpack_from(fmt, self.b, self.o)[0]
85
+ self.o += size
86
+ return v
87
+
88
+
89
+ def read_header(path: Path, probe: int = 64 << 20):
90
+ with open(path, "rb") as f:
91
+ head = f.read(probe)
92
+
93
+ r = Reader(head)
94
+ if head[:4] != b"GGUF":
95
+ raise SystemExit(f"{path} 不是 GGUF 檔")
96
+ r.o = 4
97
+ version = r.u32()
98
+ n_tensors, n_kv = r.u64(), r.u64()
99
+
100
+ kv = {}
101
+ for _ in range(n_kv):
102
+ k = r.st_key()
103
+ t = r.u32()
104
+ kv[k] = r.val(t)
105
+
106
+ tensors = []
107
+ for _ in range(n_tensors):
108
+ name = r.st_key()
109
+ nd = r.u32()
110
+ dims = [r.u64() for _ in range(nd)]
111
+ ttype = r.u32()
112
+ offset = r.u64()
113
+ tensors.append({"name": name, "dims": dims, "type": ttype, "offset": offset})
114
+
115
+ align = kv.get("general.alignment", 32)
116
+ data_start = (r.o + align - 1) // align * align
117
+ if r.o > probe:
118
+ raise SystemExit(f"檔頭 {probe} bytes 不夠(需要 {r.o}),請加大 --probe")
119
+ return version, kv, tensors, data_start, align
120
+
121
+
122
+ Reader.st_key = Reader.val # placeholder, replaced below
123
+
124
+
125
+ def _st_key(self) -> str:
126
+ n = self.u64()
127
+ v = self.b[self.o:self.o + n].decode("utf-8", "replace")
128
+ self.o += n
129
+ return v
130
+
131
+
132
+ Reader.st_key = _st_key
133
+
134
+
135
+ def tensor_nbytes(t: dict) -> int:
136
+ name, blck, tsize = GGML_TYPE_INFO[t["type"]]
137
+ n = 1
138
+ for d in t["dims"]:
139
+ n *= d
140
+ if n % blck:
141
+ raise SystemExit(f"{t['name']}: 元素數 {n} 不是 block 大小 {blck} 的整數")
142
+ return n // blck * tsize
143
+
144
+
145
+ def n_expert(t: dict) -> int | None:
146
+ """三維張量的第三維在這個模型裡就是專家編號。"""
147
+ return t["dims"][2] if len(t["dims"]) == 3 else None
148
+
149
+
150
+ def analyze(path: Path) -> dict:
151
+ version, kv, tensors, data_start, align = read_header(path)
152
+ file_size = path.stat().st_size
153
+ arch = kv.get("general.architecture", "?")
154
+ prefix = f"{arch}."
155
+
156
+ def k(key, default=None):
157
+ return kv.get(prefix + key, default)
158
+
159
+ resident, expert_tensors = [], []
160
+ for t in tensors:
161
+ if "_exps." in t["name"] and n_expert(t):
162
+ expert_tensors.append(t)
163
+ else:
164
+ resident.append(t)
165
+
166
+ resident_bytes = sum(tensor_nbytes(t) for t in resident)
167
+ expert_bytes = sum(tensor_nbytes(t) for t in expert_tensors)
168
+
169
+ # 每個 (layer, 3 個矩陣) 的專家��分
170
+ layers = {}
171
+ for t in expert_tensors:
172
+ parts = t["name"].split(".")
173
+ layer = int(parts[1])
174
+ which = parts[2] # ffn_gate_exps / ffn_up_exps / ffn_down_exps
175
+ name, blck, tsize = GGML_TYPE_INFO[t["type"]]
176
+ ne = n_expert(t)
177
+ per = tensor_nbytes(t) // ne
178
+ layers.setdefault(layer, {})[which] = {
179
+ "file_offset": data_start + t["offset"],
180
+ "file_end": data_start + t["offset"] + tensor_nbytes(t),
181
+ "type": name,
182
+ "type_id": t["type"],
183
+ "dims": t["dims"],
184
+ "bytes_per_expert": per,
185
+ "n_expert": ne,
186
+ }
187
+
188
+ per_layer = []
189
+ for layer in sorted(layers):
190
+ e = layers[layer]
191
+ row = {"layer": layer, "experts": e}
192
+ row["bytes_per_expert_total"] = sum(v["bytes_per_expert"] for v in e.values())
193
+ per_layer.append(row)
194
+
195
+ n_layer = k("block_count", 0)
196
+ n_expert_total = k("expert_count", 0)
197
+ n_expert_used = k("expert_used_count", 0)
198
+
199
+ # 一個 decode token 需要把所有層的 top-k 專家各讀一次
200
+ per_token_bytes = [r["bytes_per_expert_total"] * n_expert_used for r in per_layer]
201
+
202
+ return {
203
+ "file": str(path),
204
+ "file_size": file_size,
205
+ "gguf_version": version,
206
+ "architecture": arch,
207
+ "model_name": kv.get("general.name"),
208
+ "quant_file_type": kv.get("general.file_type"),
209
+ "n_layer": n_layer,
210
+ "n_expert": n_expert_total,
211
+ "n_expert_used": n_expert_used,
212
+ "n_embd": k("embedding_length"),
213
+ "n_ff_exp": k("expert_feed_forward_length"),
214
+ "n_ff_shexp": k("expert_shared_feed_forward_length"),
215
+ "context_length": k("context_length"),
216
+ "n_head": k("attention.head_count"),
217
+ "n_head_kv": k("attention.head_count_kv"),
218
+ "head_dim": k("attention.key_length"),
219
+ "rope_sections": k("rope.dimension_sections"),
220
+ "full_attention_interval": k("full_attention_interval"),
221
+ "data_start": data_start,
222
+ "alignment": align,
223
+ "resident_ram_bytes": resident_bytes,
224
+ "expert_total_bytes": expert_bytes,
225
+ "accounted_bytes": resident_bytes + expert_bytes,
226
+ "per_layer": per_layer,
227
+ "decode_working_set_bytes_max": max(per_token_bytes) if per_token_bytes else 0,
228
+ "decode_working_set_bytes_mean": (
229
+ sum(per_token_bytes) / len(per_token_bytes) if per_token_bytes else 0
230
+ ),
231
+ "expert_slot_count": sum(1 for _ in expert_tensors),
232
+ "kv_bytes_per_token_f16": (
233
+ n_layer // max(k("full_attention_interval", 1), 1)
234
+ * max(k("attention.head_count_kv", 1), 1)
235
+ * max(k("attention.value_length", 1), 1)
236
+ * 2
237
+ * 2 # K + V,各 f16
238
+ ),
239
+ }
240
+
241
+
242
+ def human(n: float) -> str:
243
+ for unit in ("B", "KiB", "MiB", "GiB"):
244
+ if abs(n) < 1024 or unit == "GiB":
245
+ return f"{n:.2f} {unit}" if unit != "B" else f"{int(n)} B"
246
+ n /= 1024
247
+
248
+
249
+ def main() -> None:
250
+ ap = argparse.ArgumentParser(description="MiniCPM-V-4.7-35B-A3B GGUF 專家索引 / RAM 預算分析")
251
+ ap.add_argument("model", help="GGUF 檔路徑")
252
+ ap.add_argument("--json", help="輸出完整索引 JSON")
253
+ ap.add_argument("--probe", type=int, default=64 << 20, help="讀取檔頭的位元組數")
254
+ args = ap.parse_args()
255
+
256
+ info = analyze(Path(args.model))
257
+ if args.json:
258
+ Path(args.json).write_text(json.dumps(info, indent=1), encoding="utf-8")
259
+ print(f"[index] 已寫出 {args.json}")
260
+
261
+ print("=" * 68)
262
+ print(f"模型 : {info['model_name']} ({info['architecture']})")
263
+ print(f"檔案大小 : {human(info['file_size'])}")
264
+ print(f"層數 : {info['n_layer']} 專家數/層: {info['n_expert']} 每 token 啟用: {info['n_expert_used']}")
265
+ print(f"隱層維度 : {info['n_embd']} 專家 FF: {info['n_ff_exp']} 共用專家 FF: {info['n_ff_shexp']}")
266
+ print(f"注意力 : {info['n_head']} head / {info['n_head_kv']} kv-head, head_dim={info['head_dim']},"
267
+ f" 每 {info['full_attention_interval']} 層一次全注意力")
268
+ print(f"RoPE : sections={info['rope_sections']} 原生 context={info['context_length']}")
269
+ print("-" * 68)
270
+ print(f"常駐 RAM : {human(info['resident_ram_bytes'])} (非專家權重,無法分頁)")
271
+ print(f"專家總量 : {human(info['expert_total_bytes'])} ({info['expert_slot_count']} 個 expert 槽)")
272
+ print("單一專家 : ", end="")
273
+ sizes = {}
274
+ for row in info["per_layer"]:
275
+ for which, v in row["experts"].items():
276
+ sizes.setdefault(v["type"], set()).add(v["bytes_per_expert"])
277
+ for t, s in sizes.items():
278
+ print(f"{t}={human(sorted(s)[0])}/expert ", end="")
279
+ print()
280
+ print(f"每 token 專家工作集: 平均 {human(info['decode_working_set_bytes_mean'])}"
281
+ f" 最大 {human(info['decode_working_set_bytes_max'])}")
282
+ print(f"KV cache : f16 約 {human(info['kv_bytes_per_token_f16'])}/token"
283
+ f" → 4K={human(info['kv_bytes_per_token_f16']*4096)}"
284
+ f" 1M={human(info['kv_bytes_per_token_f16']*1048576)}")
285
+ print("=" * 68)
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
tools/io_probe.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """量測「這個儲存裝置到底能有多快」——本專案所有 I/O 最佳化的前提。
3
+
4
+ 為什麼需要這個工具:分頁引擎的每一層都要等 SSD。如果不知道單次讀取的延遲與
5
+ 頻寬,就只能靠猜(例如「多開執行緒會更快」)。實測結果常常相反:
6
+ **單一 1.9 MB 隨機讀約 7 ms,而且執行緒再多也不會變快**(見下方輸出),
7
+ 因為瓶頸是單流頻寬(約 275 MB/s),不是延遲也不是佇列深度。這正是
8
+ 「I/O 段平行」這個最佳化能生效的原因:它不是開更多執行緒,而是把
9
+ 「一個 expert 的三段循序讀」變成「同時讀」。
10
+
11
+ 用法
12
+ ----
13
+ python tools/io_probe.py --model /path/to/model.gguf
14
+ python tools/io_probe.py --model xxx.gguf --chunk 1900544 --parallel 1,2,4,8,16,24
15
+
16
+ 輸出
17
+ ----
18
+ 每個併發度一列:median / mean / p90 的單次讀取延遲,以及整體聚合頻寬。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import os
25
+ import random
26
+ import statistics
27
+ import threading
28
+ import time
29
+
30
+ PAGE = os.sysconf("SC_PAGE_SIZE")
31
+
32
+
33
+ def drop_cache(fd: int, off: int, length: int) -> None:
34
+ """把剛讀過的範圍從 kernel page cache 丟掉,否則下一輪量到的是 RAM 不是 SSD。"""
35
+ start = off - (off % PAGE)
36
+ end = ((off + length + PAGE - 1) // PAGE) * PAGE
37
+ try:
38
+ os.posix_fadvise(fd, start, end - start, os.POSIX_FADV_DONTNEED)
39
+ except OSError:
40
+ pass
41
+
42
+
43
+ def measure(fd: int, size: int, chunk: int, parallel: int, n: int) -> dict:
44
+ random.seed(11)
45
+ max_off = max(1, size - chunk - PAGE)
46
+ offsets = [random.randrange(0, max_off) for _ in range(n)]
47
+ bufs = [bytearray(chunk) for _ in range(parallel)]
48
+ idx = [0]
49
+ lock = threading.Lock()
50
+ lat: list[float] = []
51
+
52
+ def work(tid: int) -> None:
53
+ local: list[float] = []
54
+ while True:
55
+ with lock:
56
+ i = idx[0]
57
+ if i >= len(offsets):
58
+ break
59
+ idx[0] += 1
60
+ off = offsets[i]
61
+ mv = memoryview(bufs[tid])
62
+ t0 = time.perf_counter()
63
+ os.preadv(fd, [mv], off)
64
+ local.append((time.perf_counter() - t0) * 1000.0)
65
+ drop_cache(fd, off, chunk)
66
+ with lock:
67
+ lat.extend(local)
68
+
69
+ ths = [threading.Thread(target=work, args=(i,)) for i in range(parallel)]
70
+ t_start = time.perf_counter()
71
+ for t in ths:
72
+ t.start()
73
+ for t in ths:
74
+ t.join()
75
+ wall = time.perf_counter() - t_start
76
+ lat.sort()
77
+ return {
78
+ "parallel": parallel,
79
+ "n": len(lat),
80
+ "median_ms": round(statistics.median(lat), 2),
81
+ "mean_ms": round(statistics.mean(lat), 2),
82
+ "p90_ms": round(lat[int(len(lat) * 0.9)], 2),
83
+ "wall_s": round(wall, 3),
84
+ "agg_MBps": round(len(lat) * chunk / wall / 1e6, 1),
85
+ }
86
+
87
+
88
+ def main() -> int:
89
+ ap = argparse.ArgumentParser(description="SDQ 儲存裝置 I/O 特性量測")
90
+ ap.add_argument("--model", required=True, help="拿來當讀取目標的檔案(通常就是 gguf)")
91
+ ap.add_argument("--chunk", type=int, default=1_900_000, help="單次讀取的位元組數")
92
+ ap.add_argument("--parallel", default="1,2,4,8,16,24", help="要量哪幾種併發度")
93
+ ap.add_argument("--n", type=int, default=64, help="每個併發度讀幾次")
94
+ args = ap.parse_args()
95
+
96
+ size = os.path.getsize(args.model)
97
+ fd = os.open(args.model, os.O_RDONLY)
98
+ print(f"檔案:{args.model}({size / 2**30:.1f} GiB)")
99
+ print(f"單次讀取 {args.chunk} B,每個併發度 {args.n} 次")
100
+ print()
101
+ print(f"{'併發':>5} {'中位延遲':>10} {'平均':>8} {'p90':>8} {'聚合頻寬':>12}")
102
+ try:
103
+ for p in [int(x) for x in args.parallel.split(",")]:
104
+ r = measure(fd, size, args.chunk, p, args.n)
105
+ print(f"{r['parallel']:5d} {r['median_ms']:9.2f}ms {r['mean_ms']:7.2f}ms "
106
+ f"{r['p90_ms']:7.2f}ms {r['agg_MBps']:9.1f} MB/s")
107
+ finally:
108
+ os.close(fd)
109
+ print()
110
+ print("讀法:如果『中位延遲』不隨併發度下降,代表單次讀取是頻寬受限的;")
111
+ print(" 這時候開更多執行緒只會讓聚合頻寬變高,不會縮短關鍵路徑。")
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
tools/moe_layer_check.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """用 epq-chat 的 SDQ_MOE_DUMP 檔,驗證「整層」MoE 的計算(真實路由輸入)。
3
+
4
+ 這是最接近模型實際執行的驗證:不是隨機 activation,而是模型自己算出來的
5
+ cur / selected_experts / weights,然後檢查我們的輸出是否等於 NumPy 重算的結果。
6
+ """
7
+ import argparse, json, struct, sys
8
+ from pathlib import Path
9
+ import numpy as np
10
+ sys.path.insert(0, str(Path(__file__).parent))
11
+ from moe_ref import quant_q8_K, read_expert
12
+
13
+ def main():
14
+ ap = argparse.ArgumentParser()
15
+ ap.add_argument("--model", required=True)
16
+ ap.add_argument("--index", required=True)
17
+ ap.add_argument("--dump", required=True)
18
+ ap.add_argument("--tol", type=float, default=3e-3)
19
+ a = ap.parse_args()
20
+
21
+ b = open(a.dump, "rb").read()
22
+ n_embd, n_tok, n_used, n_expert, _, _ = struct.unpack_from("<6i", b, 0)
23
+ off = 24
24
+ cur = np.frombuffer(b, dtype=np.float32, count=n_embd * n_tok, offset=off).reshape(n_tok, n_embd)
25
+ off += 4 * n_embd * n_tok
26
+ ids = np.frombuffer(b, dtype=np.int32, count=n_used * n_tok, offset=off).reshape(n_tok, n_used)
27
+ off += 4 * n_used * n_tok
28
+ wts = np.frombuffer(b, dtype=np.float32, count=n_used * n_tok, offset=off).reshape(n_tok, n_used)
29
+ off += 4 * n_used * n_tok
30
+ got = np.frombuffer(b, dtype=np.float32, count=n_embd * n_tok, offset=off).reshape(n_tok, n_embd)
31
+
32
+ idx = json.loads(Path(a.index).read_text())
33
+ il = int(Path(a.dump).name.rsplit(".", 1)[1])
34
+ layer = idx["per_layer"][il]["experts"]
35
+ n_ff = idx["n_ff_exp"]
36
+ out = np.zeros((n_tok, n_embd), dtype=np.float32)
37
+
38
+ # Worst-case effect on one output element of a single Q8_K LSB flip in h.
39
+ # ggml accumulates the gate/up dots in float32 while this reference is exact,
40
+ # so the two can disagree by ~1e-6 on h. When that crosses a Q8_K rounding
41
+ # boundary one code moves by 1 and the layer output jumps by far more than
42
+ # float noise. See docs/CORRECTNESS.md: a 1-LSB flip alone produced 7.9e-4.
43
+ lsb = np.zeros(n_ff, dtype=np.float64)
44
+
45
+ with open(a.model, "rb") as fh:
46
+ cache = {}
47
+ for t in range(n_tok):
48
+ for k in range(n_used):
49
+ e = int(ids[t, k])
50
+ if e not in cache:
51
+ g, u, d = layer["ffn_gate_exps"], layer["ffn_up_exps"], layer["ffn_down_exps"]
52
+ Wg = read_expert(fh, g["file_offset"] + e * g["bytes_per_expert"], n_ff * n_embd, g["type"]).reshape(n_ff, n_embd)
53
+ Wu = read_expert(fh, u["file_offset"] + e * u["bytes_per_expert"], n_ff * n_embd, u["type"]).reshape(n_ff, n_embd)
54
+ Wd = read_expert(fh, d["file_offset"] + e * d["bytes_per_expert"], n_embd * n_ff, d["type"]).reshape(n_embd, n_ff)
55
+ cache[e] = (Wg, Wu, Wd)
56
+ Wg, Wu, Wd = cache[e]
57
+ xq = quant_q8_K(cur[t])
58
+ # float64 for the gate/up dots: this is the reference, so it should
59
+ # be the more accurate of the two.
60
+ go = Wg.astype(np.float64) @ xq.astype(np.float64)
61
+ uo = Wu.astype(np.float64) @ xq.astype(np.float64)
62
+ h = ((go / (1.0 + np.exp(-go))) * uo).astype(np.float32)
63
+ if t == 0 and k == 0:
64
+ ab = np.abs(h)
65
+ for bi in range(h.size // 256):
66
+ blk = ab[bi * 256:(bi + 1) * 256]
67
+ lsb[bi * 256:(bi + 1) * 256] = blk.max() / 127.0 if blk.max() > 0 else 0.0
68
+ out[t] += (Wd @ quant_q8_K(h)) * wts[t, k]
69
+
70
+ diff = np.abs(out - got)
71
+ scale = np.maximum(np.abs(out), 1e-2)
72
+ rel = (diff / scale).max()
73
+ # bound[r] = sum_j |Wd[r,j]| * d_h(block(j)) = worst case for one flipped code
74
+ bound = np.zeros((n_embd, n_ff), dtype=np.float64)
75
+ for t in range(n_tok):
76
+ for k in range(n_used):
77
+ Wd = cache[int(ids[t, k])][2]
78
+ bound += np.abs(Wd.astype(np.float64)) * lsb[None, :]
79
+ print(f"layer {il} n_tok={n_tok} n_used={n_used} ids[0]={ids[0].tolist()}")
80
+ print(f" NumPy out[0,:5]={out[0,:5]}")
81
+ print(f" SDQ out[0,:5]={got[0,:5]}")
82
+ print(f" max abs diff={diff.max():.6g} max rel={rel:.6g} 1-LSB-flip bound={bound.max():.6g}")
83
+ ok = rel <= a.tol
84
+ verdict = "PASS" if ok else ("PASS (quantization-boundary)" if diff.max() <= bound.max() else "FAIL")
85
+ print(" 結果:", verdict + (" ✅" if ok else (" ✅" if verdict != "FAIL" else " ❌")))
86
+ sys.exit(0 if ok or verdict != "FAIL" else 1)
87
+
88
+ main()
tools/moe_ref.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """SDQ MoE kernel 的 NumPy 對照實作(單元測試)。
3
+
4
+ 用途:拿 C++ 端 `epq-chat --selftest IL:IE:SEED:OUT.bin` 算出來的結果,
5
+ 用純 NumPy 從 GGUF 的同一段位元組重算一次,確認分頁 + 量化點積 + SiLU + 加權
6
+ 這條路徑的數學完全正確。
7
+
8
+ 實作完全照抄 ggml 的定義:
9
+ * dequantize_row_q4_K / dequantize_row_q5_K / dequantize_row_q6_K
10
+ * quantize_row_q8_K_ref(Q8_K activation)
11
+ * SiLU(x) = x * sigmoid(x)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import struct
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ QK_K = 256
24
+
25
+
26
+ def get_scale_min_k4(j: int, q: np.ndarray) -> tuple[int, int]:
27
+ if j < 4:
28
+ return int(q[j] & 63), int(q[j + 4] & 63)
29
+ return ((int(q[j + 4] & 0xF) | ((int(q[j - 4]) >> 6) << 4)),
30
+ ((int(q[j + 4]) >> 4) | ((int(q[j]) >> 6) << 4)))
31
+
32
+
33
+ def fp16_to_fp32(raw: bytes) -> float:
34
+ return struct.unpack("<e", raw)[0]
35
+
36
+
37
+ def dequant_q4_K(raw: bytes, n: int) -> np.ndarray:
38
+ nb = n // QK_K
39
+ out = np.empty(n, dtype=np.float32)
40
+ for i in range(nb):
41
+ d = fp16_to_fp32(raw[i * 144:i * 144 + 2])
42
+ dmin = fp16_to_fp32(raw[i * 144 + 2:i * 144 + 4])
43
+ scales = np.frombuffer(raw, dtype=np.uint8, count=12, offset=i * 144 + 4)
44
+ qs = np.frombuffer(raw, dtype=np.uint8, count=128, offset=i * 144 + 16)
45
+ base = i * QK_K
46
+ is_ = 0
47
+ for j in range(0, QK_K, 64):
48
+ sc1, m1 = get_scale_min_k4(is_ + 0, scales)
49
+ sc2, m2 = get_scale_min_k4(is_ + 1, scales)
50
+ d1, mm1 = d * sc1, dmin * m1
51
+ d2, mm2 = d * sc2, dmin * m2
52
+ q = qs[j // 2: j // 2 + 32]
53
+ out[base + j: base + j + 32] = d1 * (q & 0xF).astype(np.float32) - mm1
54
+ out[base + j + 32: base + j + 64] = d2 * (q >> 4).astype(np.float32) - mm2
55
+ is_ += 2
56
+ return out
57
+
58
+
59
+ def dequant_q5_K(raw: bytes, n: int) -> np.ndarray:
60
+ nb = n // QK_K
61
+ out = np.empty(n, dtype=np.float32)
62
+ for i in range(nb):
63
+ o = i * 176
64
+ d = fp16_to_fp32(raw[o:o + 2])
65
+ dmin = fp16_to_fp32(raw[o + 2:o + 4])
66
+ scales = np.frombuffer(raw, dtype=np.uint8, count=12, offset=o + 4)
67
+ qh = np.frombuffer(raw, dtype=np.uint8, count=32, offset=o + 16)
68
+ qs = np.frombuffer(raw, dtype=np.uint8, count=128, offset=o + 48)
69
+ base = i * QK_K
70
+ is_ = 0
71
+ u1, u2 = 1, 2
72
+ for j in range(0, QK_K, 64):
73
+ sc1, m1 = get_scale_min_k4(is_ + 0, scales)
74
+ sc2, m2 = get_scale_min_k4(is_ + 1, scales)
75
+ d1, mm1 = d * sc1, dmin * m1
76
+ d2, mm2 = d * sc2, dmin * m2
77
+ q = qs[j // 2: j // 2 + 32]
78
+ # ggml 的迴圈只推進 ql,qh 永遠從 l=0 開始讀
79
+ h1 = ((qh[0:32] & u1) != 0).astype(np.float32) * 16
80
+ h2 = ((qh[0:32] & u2) != 0).astype(np.float32) * 16
81
+ out[base + j: base + j + 32] = d1 * ((q & 0xF).astype(np.float32) + h1) - mm1
82
+ out[base + j + 32: base + j + 64] = d2 * ((q >> 4).astype(np.float32) + h2) - mm2
83
+ is_ += 2
84
+ u1 <<= 2
85
+ u2 <<= 2
86
+ return out
87
+
88
+
89
+ def dequant_q6_K(raw: bytes, n: int) -> np.ndarray:
90
+ """逐位元組對照 ggml/src/ggml-quants.c 的 dequantize_row_q6_K。
91
+
92
+ block_q6_K(210 bytes):ql[128] qh[64] scales[16(int8)] d(fp16)
93
+ 對每個 128 元素半區塊(n 迴圈,ql += 64 / qh += 32 / sc += 8):
94
+ q1 = (ql[l] & 0xF) | ((qh[l] >> 0 & 3) << 4) → y[l]
95
+ q2 = (ql[l+32] & 0xF) | ((qh[l] >> 2 & 3) << 4) → y[l+32]
96
+ q3 = (ql[l] >> 4) | ((qh[l] >> 4 & 3) << 4) → y[l+64]
97
+ q4 = (ql[l+32] >> 4) | ((qh[l] >> 6 & 3) << 4) → y[l+96]
98
+ 縮放:y[l] 用 sc[is+0]、y[l+32] 用 sc[is+2]、y[l+64] 用 sc[is+4]、y[l+96] 用 sc[is+6],
99
+ 其中 is = l / 16(l<16 用偶數、l>=16 用緊接的奇數)→ 等價於「每 16 個元素一個 scale」。
100
+ """
101
+ nb = n // QK_K
102
+ out = np.empty(n, dtype=np.float32)
103
+ for i in range(nb):
104
+ o = i * 210
105
+ ql = np.frombuffer(raw, dtype=np.uint8, count=128, offset=o)
106
+ qh = np.frombuffer(raw, dtype=np.uint8, count=64, offset=o + 128)
107
+ sc = np.frombuffer(raw, dtype=np.int8, count=16, offset=o + 192).astype(np.float32)
108
+ d = fp16_to_fp32(raw[o + 208:o + 210])
109
+ base = i * QK_K
110
+ for half in range(2):
111
+ qa = ql[half * 64: half * 64 + 64].astype(np.int32) # ql[l], ql[l+32]
112
+ ha = qh[half * 32: half * 32 + 32].astype(np.int32) # qh[l]
113
+ sa = sc[half * 8: half * 8 + 8] # 8 個 scale,每 16 元素一個
114
+ scale = np.repeat(sa, 16) # 長度 128,對應整個 128 元素半區塊
115
+
116
+ lo = qa[:32] & 0xF # ql[l] 低 4 bit
117
+ hi = qa[32:] & 0xF # ql[l+32] 低 4 bit
118
+ blo = qa[:32] >> 4 # ql[l] 高 4 bit
119
+ bhi = qa[32:] >> 4 # ql[l+32] 高 4 bit
120
+
121
+ q1 = lo | ((ha & 0x3) << 4)
122
+ q2 = hi | (((ha >> 2) & 0x3) << 4)
123
+ q3 = blo | (((ha >> 4) & 0x3) << 4)
124
+ q4 = bhi | (((ha >> 6) & 0x3) << 4)
125
+
126
+ p = base + half * 128
127
+ out[p + 0: p + 32] = d * (q1 - 32) * scale[0:32]
128
+ out[p + 32: p + 64] = d * (q2 - 32) * scale[32:64]
129
+ out[p + 64: p + 96] = d * (q3 - 32) * scale[64:96]
130
+ out[p + 96: p + 128] = d * (q4 - 32) * scale[96:128]
131
+ return out
132
+
133
+
134
+ def quant_q8_K(x: np.ndarray) -> np.ndarray:
135
+ """照抄 quantize_row_q8_K_ref:回傳反量化後的 activation(float32)。"""
136
+ n = x.size
137
+ out = np.empty(n, dtype=np.float32)
138
+ for i in range(n // QK_K):
139
+ blk = x[i * QK_K:(i + 1) * QK_K]
140
+ ax = np.abs(blk)
141
+ amax = ax.max()
142
+ if amax == 0:
143
+ out[i * QK_K:(i + 1) * QK_K] = 0
144
+ continue
145
+ # ggml 取「第一個達到 amax 的元素」作為 max(含正負號)
146
+ idx = int(np.argmax(ax))
147
+ maxv = float(blk[idx])
148
+ iscale = -127.0 / maxv
149
+ q = np.clip(np.rint(iscale * blk), -127, 127).astype(np.int8)
150
+ out[i * QK_K:(i + 1) * QK_K] = q.astype(np.float32) / iscale
151
+ return out
152
+
153
+
154
+ def read_expert(fh, off: int, n: int, typ: str) -> np.ndarray:
155
+ fh.seek(off)
156
+ raw = fh.read(n)
157
+ if typ == "Q4_K":
158
+ return dequant_q4_K(raw, n)
159
+ if typ == "Q5_K":
160
+ return dequant_q5_K(raw, n)
161
+ if typ == "Q6_K":
162
+ return dequant_q6_K(raw, n)
163
+ raise SystemExit(f"selftest 暫不支援 {typ}")
164
+
165
+
166
+ def main() -> None:
167
+ ap = argparse.ArgumentParser(description="SDQ MoE kernel 的 NumPy 對照")
168
+ ap.add_argument("--model", required=True)
169
+ ap.add_argument("--index", required=True, help="tools/gguf_index.py 產生的 JSON")
170
+ ap.add_argument("--bin", required=True, help="epq-chat --selftest 的輸出")
171
+ ap.add_argument("--layer", type=int, required=True)
172
+ ap.add_argument("--expert", type=int, required=True)
173
+ ap.add_argument("--n-tok", type=int, default=1, help="幾個 token 共用同一個 expert")
174
+ ap.add_argument("--tol", type=float, default=2e-3, help="相對誤差上限")
175
+ args = ap.parse_args()
176
+
177
+ idx = json.loads(Path(args.index).read_text())
178
+ layer = idx["per_layer"][args.layer]["experts"]
179
+ n_embd = idx["n_embd"]
180
+ n_ff = idx["n_ff_exp"]
181
+ e = args.expert
182
+
183
+ n_tok = args.n_tok
184
+ raw = np.fromfile(args.bin, dtype=np.float32)
185
+ assert raw.size == 2 * n_embd * n_tok, f"selftest 輸出大小 {raw.size} 應為 {2 * n_embd * n_tok}"
186
+ cpp_out = raw[:n_embd * n_tok].reshape(n_tok, n_embd)
187
+ x = raw[n_embd * n_tok:].reshape(n_tok, n_embd)
188
+
189
+ with open(args.model, "rb") as fh:
190
+ g = layer["ffn_gate_exps"]
191
+ u = layer["ffn_up_exps"]
192
+ d = layer["ffn_down_exps"]
193
+ gate = read_expert(fh, g["file_offset"] + e * g["bytes_per_expert"],
194
+ n_ff * n_embd, g["type"]).reshape(n_ff, n_embd)
195
+ up = read_expert(fh, u["file_offset"] + e * u["bytes_per_expert"],
196
+ n_ff * n_embd, u["type"]).reshape(n_ff, n_embd)
197
+ down = read_expert(fh, d["file_offset"] + e * d["bytes_per_expert"],
198
+ n_embd * n_ff, d["type"]).reshape(n_embd, n_ff)
199
+
200
+ # 逐 token:activation → Q8_K → gate/up → SiLU → Q8_K → down
201
+ outs = []
202
+ for t in range(n_tok):
203
+ xq = quant_q8_K(x[t])
204
+ gate_out = gate @ xq
205
+ up_out = up @ xq
206
+ h = (gate_out / (1.0 + np.exp(-gate_out))) * up_out
207
+ hq = quant_q8_K(h)
208
+ outs.append(down @ hq)
209
+ out = np.stack(outs)
210
+
211
+ diff = np.abs(out - cpp_out)
212
+ scale = np.maximum(np.abs(out), 1e-3)
213
+ rel = diff / scale
214
+ print(f"layer={args.layer} expert={e} n_embd={n_embd} n_ff={n_ff}")
215
+ print(f" NumPy out[0:5] = {out[:5]}")
216
+ print(f" C++ out[0:5] = {cpp_out[:5]}")
217
+ print(f" max abs diff = {diff.max():.6g} max rel = {rel.max():.6g}")
218
+ print(f" out norm = {np.linalg.norm(out):.4f}")
219
+ ok = rel.max() <= args.tol
220
+ print(" 結果:", "PASS ✅" if ok else "FAIL ❌")
221
+ raise SystemExit(0 if ok else 1)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
tools/route_overlap.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """從 SDQ_ROUTE_LOG 算出「連續 k 個 token 需要幾個不同的 expert」。
3
+
4
+ 這是決定「多 token 批次 / 投機解碼值不值得做」的關鍵數字。
5
+ 每一層每 token 只會用到 8 個 expert,所以如果把 k 個 token 放在同一層一起算,
6
+ 需要從 SSD 讀的 expert 數是 U(k) 而不是 8k。U(k)/k 越小,省下的 I/O 越多。
7
+
8
+ 另外會順便算:
9
+ * 完美(Belady)快取命中率 —— 也就是「這個工作流在容量無限時的命中率天花板」
10
+ * 快取容量對命中的影響 —— 用來判斷「把 arena 加大」值不值得
11
+ * 只用過一次的 expert 占比 —— 用來判斷「cache bypass / 准入策略」值不值得
12
+
13
+ 用法
14
+ ----
15
+ SDQ_ROUTE_LOG=/tmp/route.log <epq-chat> -m model.gguf -p "..." -n 48 --no-interactive
16
+ python tools/route_overlap.py /tmp/route.log
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from collections import defaultdict
23
+
24
+
25
+ def load(path: str) -> dict[int, list[tuple[int, ...]]]:
26
+ """回傳 {layer: [每個 decode step 的 8 個 expert]}(只取 n_tok == 1 的呼叫)。"""
27
+ per_layer: dict[int, list[tuple[int, ...]]] = defaultdict(list)
28
+ with open(path, encoding="utf-8") as f:
29
+ for line in f:
30
+ parts = line.split()
31
+ if len(parts) < 3:
32
+ continue
33
+ il, n_tok = int(parts[0]), int(parts[1])
34
+ if n_tok != 1:
35
+ continue
36
+ per_layer[il].append(tuple(int(x) for x in parts[2].split(":")[1].split(",")))
37
+ return per_layer
38
+
39
+
40
+ def hit_rate_lru(stream, cap: int) -> float:
41
+ cache: dict[int, None] = {}
42
+ hit = tot = 0
43
+ for req in stream:
44
+ for e in req:
45
+ tot += 1
46
+ if e in cache:
47
+ hit += 1
48
+ cache.pop(e)
49
+ elif len(cache) >= cap:
50
+ cache.pop(next(iter(cache)))
51
+ cache[e] = None
52
+ return hit / max(1, tot)
53
+
54
+
55
+ def hit_rate_belady(stream, cap: int) -> float:
56
+ """離線模擬:替換掉「下一次使用最晚」的頁 = 理論上界。"""
57
+ nxt: dict[int, int] = {}
58
+ for i in range(len(stream) - 1, -1, -1):
59
+ for e in stream[i]:
60
+ nxt[e] = i
61
+ cache: set[int] = set()
62
+ hit = tot = 0
63
+ for i, req in enumerate(stream):
64
+ for e in req:
65
+ tot += 1
66
+ if e in cache:
67
+ hit += 1
68
+ else:
69
+ if len(cache) >= cap:
70
+ cache.discard(max(cache, key=lambda x: nxt.get(x, 10 ** 9)))
71
+ cache.add(e)
72
+ return hit / max(1, tot)
73
+
74
+
75
+ def main() -> int:
76
+ if len(sys.argv) < 2:
77
+ print(__doc__)
78
+ return 2
79
+ per_layer = load(sys.argv[1])
80
+ if not per_layer:
81
+ print("route log 裡沒有 n_tok==1 的紀錄")
82
+ return 1
83
+ n = min(len(v) for v in per_layer.values())
84
+ nl = len(per_layer)
85
+ print(f"每層 decode steps = {n},層數 = {nl}")
86
+ print()
87
+ print("=== 多 token 批次的 I/O 節省空間 ===")
88
+ print("(分頁一次 = 1.9 MB;k 個 token 分開算要讀 8k 次,合併算只要 U(k) 次)")
89
+ print(f"{'k':>3} {'U(k) 平均':>10} {'U(k)/k':>8} {'省下的讀取':>12}")
90
+ for k in (1, 2, 3, 4, 5, 6, 8):
91
+ tot = cnt = 0
92
+ for v in per_layer.values():
93
+ s = v[:n]
94
+ for i in range(n - k + 1):
95
+ u: set[int] = set()
96
+ for j in range(k):
97
+ u |= set(s[i + j])
98
+ tot += len(u)
99
+ cnt += 1
100
+ u_avg = tot / cnt
101
+ save = (1 - u_avg / (8.0 * k)) * 100 if k > 1 else 0.0
102
+ print(f"{k:3d} {u_avg:10.2f} {u_avg / k:8.2f} {save:11.1f}%")
103
+ print()
104
+ print("讀法:U(k)/k < 8 代表把 k 個 token 合併到同一層有省到 I/O;")
105
+ print(" 合併的 token 越多(k 越大)省得越多,但要能『同時知道』那 k 個 token。")
106
+ print()
107
+ print("=== 快取容量 vs 命中率(每層獨立計算,理想化)===")
108
+ print(f"{'每層槽數':>9} {'LRU':>8} {'Belady(上界)':>13}")
109
+ for cap in (256, 512, 1024, 2048, 4096):
110
+ a = b = 0.0
111
+ for v in per_layer.values():
112
+ s = v[:n]
113
+ a += hit_rate_lru(s, cap)
114
+ b += hit_rate_belady(s, cap)
115
+ print(f"{cap:9d} {a / nl * 100:7.1f}% {b / nl * 100:12.1f}%")
116
+ print()
117
+ print("讀法:LRU == Belady 代表『策略已經最optimal,剩下只有容量問題』;")
118
+ print(" LRU 隨容量上升代表加大 arena 有用。")
119
+ print()
120
+ print("=== 重複使用次數分布(48 個 token 的視窗內)===")
121
+ hist: dict[int, int] = defaultdict(int)
122
+ for v in per_layer.values():
123
+ cnt: dict[int, int] = defaultdict(int)
124
+ for req in v[:n]:
125
+ for e in req:
126
+ cnt[e] += 1
127
+ for e, c in cnt.items():
128
+ hist[c] += c
129
+ total = sum(hist.values())
130
+ once = hist.get(1, 0)
131
+ print(f"只出現 1 次的請求:{once} / {total}({once / total * 100:.1f}%)")
132
+ print("(占比低 = 快取被單次使用的 expert 污染的機會少,cache-bypass 不值得做)")
133
+ return 0
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
tools/strict8g.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """EPM strict-mode runner: enforce an 8 GiB / no-swap ceiling on one process.
3
+
4
+ Why this exists
5
+ ---------------
6
+ The repository's headline deployment claim is "MiniCPM-V-4.7-35B-A3B decodes inside
7
+ 8 GiB of RAM with swap disabled". Past runs only ever set ``RLIMIT_DATA`` (which
8
+ caps anonymous data/heap, ignores file-backed mappings, and does not touch swap)
9
+ and then *observed* ``VmSwap: 0``. That is a pager-sizing number, not a
10
+ guarantee.
11
+
12
+ cgroups would be the clean way to express this — ``memory.max=8G`` plus
13
+ ``memory.swap.max=0`` — but a writable cgroup2 mount needs ``CAP_SYS_ADMIN``.
14
+ Where that is unavailable this harness reaches the same two guarantees:
15
+
16
+ no-swap
17
+ Every anonymous mapping the process creates is ``mlock``ed by
18
+ ``tools/epq_lock.so``. Locked pages can never be written to a swap device, so
19
+ no-swap is enforced by the kernel for the whole run rather than inferred from
20
+ a single reading afterwards. File-backed mappings are deliberately *not*
21
+ locked: clean page cache is reclaimable, so a real 8 GiB machine evicts it
22
+ rather than swapping, and a cgroup would reclaim it too. ``VmLck`` in the
23
+ record shows how much was actually locked.
24
+
25
+ 8 GiB
26
+ ``RLIMIT_DATA`` on the child is a hard kernel cap on anonymous memory: past
27
+ it, ``brk``/``mmap(MAP_ANONYMOUS)`` fail instead of quietly growing. A
28
+ supervisor samples ``/proc/<pid>/status`` every few milliseconds and
29
+ ``SIGKILL``s on a breach, so transient spikes cannot slip through. Enforcement
30
+ and evidence come from kernel counters, not from the sampler:
31
+ ``RssAnon``/``VmHWM``/``VmSwap``/``VmLck`` are exact, so the sampler only has
32
+ to be fast, not lucky.
33
+
34
+ ``--metric anon`` (default) caps anonymous RSS, which is the quantity that binds
35
+ on real hardware: 8 GiB of RAM must hold the arena, the KV cache, the compute
36
+ buffers and the runtime, and the kernel can always reclaim the rest.
37
+ ``--metric total`` caps total RSS instead, which is strictly harder than a cgroup
38
+ because it forbids even reclaimable cache. Use it when you want the pessimistic
39
+ number; use ``anon`` for the number that matches a cgroup.
40
+
41
+ Usage
42
+ -----
43
+ python3 tools/strict8g.py --label decode -- epq-chat -m model.gguf ...
44
+ python3 tools/strict8g.py --limit-mb 8192 --metric total --record r.jsonl -- cmd
45
+
46
+ Exit status is the child's own, except 124 when the ceiling was breached or the
47
+ run timed out (matching ``timeout``), and 125 for harness errors.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import json
54
+ import os
55
+ import resource
56
+ import signal
57
+ import subprocess
58
+ import sys
59
+ import tempfile
60
+ import threading
61
+ import time
62
+ from pathlib import Path
63
+
64
+ REPO = Path(__file__).resolve().parent.parent
65
+ LOCK_SRC = REPO / "tools" / "epq_lock.so.c"
66
+ LOCK_SO = REPO / "tools" / "epq_lock.so"
67
+
68
+ MIB = 1024 * 1024
69
+
70
+
71
+ def build_lock_helper() -> Path:
72
+ """Compile the anonymous-memory locker once; it is one small C file."""
73
+ if not LOCK_SRC.exists():
74
+ raise SystemExit(f"[strict8g] 缺少 {LOCK_SRC}")
75
+ cc = os.environ.get("CC", "cc")
76
+ if (not LOCK_SO.exists()) or LOCK_SO.stat().st_mtime < LOCK_SRC.stat().st_mtime:
77
+ subprocess.run([cc, "-O2", "-fPIC", "-shared", "-o", str(LOCK_SO), str(LOCK_SRC)],
78
+ check=True)
79
+ return LOCK_SO
80
+
81
+
82
+ _WANTED = ("RssAnon", "RssFile", "VmRSS", "VmHWM", "VmSwap", "VmLck", "VmSize", "VmData")
83
+
84
+
85
+ def read_status(pid: int) -> dict:
86
+ """Read one process's exact memory counters, in bytes."""
87
+ out = dict.fromkeys(_WANTED, 0)
88
+ try:
89
+ with open(f"/proc/{pid}/status", "r") as fh:
90
+ for line in fh:
91
+ name = line.split(":", 1)[0]
92
+ if name in out:
93
+ out[name] = int(line.split()[1]) * 1024
94
+ except (FileNotFoundError, ProcessLookupError, ValueError, IndexError):
95
+ pass
96
+ return out
97
+
98
+
99
+ class Sampler(threading.Thread):
100
+ """Polls the child's memory counters and kills it on a ceiling breach."""
101
+
102
+ def __init__(self, pid: int, limit: int, metric: str, interval: float):
103
+ super().__init__(daemon=True)
104
+ self.pid = pid
105
+ self.limit = limit
106
+ self.metric = metric
107
+ self.interval = interval
108
+ self.peak = dict.fromkeys(_WANTED, 0)
109
+ self.samples = 0
110
+ self.swap_samples = 0
111
+ self.breach = 0
112
+ self.stop = threading.Event()
113
+
114
+ def run(self) -> None:
115
+ while not self.stop.is_set():
116
+ st = read_status(self.pid)
117
+ self.samples += 1
118
+ for k in _WANTED:
119
+ self.peak[k] = max(self.peak[k], st[k])
120
+ if st["VmSwap"] > 0:
121
+ self.swap_samples += 1
122
+ cur = st[self.metric]
123
+ if cur > self.limit:
124
+ self.breach = cur
125
+ try:
126
+ os.kill(self.pid, signal.SIGKILL)
127
+ except ProcessLookupError:
128
+ pass
129
+ return
130
+ time.sleep(self.interval)
131
+
132
+
133
+ def _mib(x: int) -> float:
134
+ return round(x / MIB, 1)
135
+
136
+
137
+ def main() -> int:
138
+ ap = argparse.ArgumentParser(description="Run one command under a strict 8 GiB / no-swap ceiling")
139
+ ap.add_argument("--limit-mb", type=int, default=8192, help="hard ceiling in MiB")
140
+ ap.add_argument("--data-mb", type=int, default=0,
141
+ help="RLIMIT_DATA in MiB; default is --limit-mb plus VA slack "
142
+ "(RLIMIT_DATA bounds anonymous *virtual* memory, which is "
143
+ "stricter than physical RAM, so it is used as a runaway guard)")
144
+ ap.add_argument("--no-rlimit", action="store_true",
145
+ help="do not set RLIMIT_DATA at all")
146
+ ap.add_argument("--metric", choices=["anon", "total"], default="anon",
147
+ help="cap anonymous RSS (default, cgroup-equivalent) or total RSS")
148
+ ap.add_argument("--interval-ms", type=float, default=4.0, help="sampling interval")
149
+ ap.add_argument("--label", default="strict")
150
+ ap.add_argument("--record", default="", help="append a JSON record to this file")
151
+ ap.add_argument("--timeout", type=float, default=0, help="wall-clock limit in seconds (0 = none)")
152
+ ap.add_argument("--no-lock", action="store_true",
153
+ help="skip mlock (no-swap then rests on VmSwap sampling alone)")
154
+ ap.add_argument("--quiet", action="store_true")
155
+ ap.add_argument("cmd", nargs=argparse.REMAINDER)
156
+ args = ap.parse_args()
157
+
158
+ cmd = args.cmd[1:] if args.cmd and args.cmd[0] == "--" else args.cmd
159
+ if not cmd:
160
+ ap.error("missing command; put it after `--`")
161
+
162
+ limit = args.limit_mb * MIB
163
+ # RLIMIT_DATA caps anonymous *virtual* memory; llama.cpp reserves graph and
164
+ # compute buffers that are mapped but never touched, so charging them against
165
+ # a physical 8 GiB budget costs ~1 GiB of expert arena for no physical
166
+ # benefit. Physical residency is bounded by the sampler instead, exactly as a
167
+ # cgroup memory.max bounds it. RLIMIT_DATA stays as an independent kernel
168
+ # guard against a runaway, sized with slack for untouched mappings.
169
+ VA_SLACK_MB = 1536
170
+ data_mb = args.data_mb or (args.limit_mb + VA_SLACK_MB)
171
+ metric = "RssAnon" if args.metric == "anon" else "VmRSS"
172
+
173
+ try:
174
+ helper = build_lock_helper() if not args.no_lock else None
175
+ except (subprocess.CalledProcessError, OSError) as exc:
176
+ raise SystemExit(f"[strict8g] 無法編譯 mlock helper:{exc}")
177
+
178
+ fd, status_path = tempfile.mkstemp(prefix="epq-mlock-", suffix=".txt")
179
+ os.close(fd)
180
+
181
+ try:
182
+ env = dict(os.environ)
183
+ if helper:
184
+ env["LD_PRELOAD"] = str(helper) + (":" + env["LD_PRELOAD"] if env.get("LD_PRELOAD") else "")
185
+ env["EPM_MLOCK_STATUS"] = status_path
186
+
187
+ def child_setup() -> None:
188
+ # mlock() is bounded by RLIMIT_MEMLOCK, so raise it before main runs.
189
+ try:
190
+ resource.setrlimit(resource.RLIMIT_MEMLOCK,
191
+ (resource.RLIM_INFINITY, resource.RLIM_INFINITY))
192
+ except (ValueError, OSError):
193
+ pass
194
+ # Kernel guard on anonymous virtual memory (see VA_SLACK_MB above).
195
+ if not args.no_rlimit:
196
+ cap = data_mb * MIB
197
+ try:
198
+ resource.setrlimit(resource.RLIMIT_DATA, (cap, cap))
199
+ except (ValueError, OSError):
200
+ pass
201
+ # A multi-GiB core dump would fill the disk and mask the failure we
202
+ # are trying to measure.
203
+ try:
204
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
205
+ except (ValueError, OSError):
206
+ pass
207
+ os.setpgrp()
208
+
209
+ t0 = time.time()
210
+ try:
211
+ proc = subprocess.Popen(cmd, env=env, preexec_fn=child_setup,
212
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
213
+ text=True)
214
+ except OSError as exc:
215
+ print(f"[strict8g] 無法執行 {cmd[0]}: {exc}", file=sys.stderr)
216
+ return 125
217
+
218
+ sampler = Sampler(proc.pid, limit, metric, args.interval_ms / 1000.0)
219
+ sampler.start()
220
+
221
+ timed_out = False
222
+ try:
223
+ stdout, stderr = proc.communicate(timeout=args.timeout or None)
224
+ except subprocess.TimeoutExpired:
225
+ timed_out = True
226
+ proc.kill()
227
+ stdout, stderr = proc.communicate()
228
+ finally:
229
+ sampler.stop.set()
230
+ sampler.join(timeout=2.0)
231
+ wall = time.time() - t0
232
+
233
+ lock_note = ""
234
+ if helper:
235
+ try:
236
+ lock_note = Path(status_path).read_text().strip().replace("\n", " ")
237
+ except OSError:
238
+ pass
239
+ finally:
240
+ try:
241
+ os.unlink(status_path)
242
+ except OSError:
243
+ pass
244
+
245
+ rc = proc.returncode
246
+ overflow = sampler.breach > 0
247
+ swapped = sampler.peak["VmSwap"] > 0
248
+ locked = sampler.peak["VmLck"]
249
+ # VmHWM is a kernel high-water mark: the exact peak even if it lasted well
250
+ # under one sampling interval.
251
+ ok = (rc == 0 and not overflow and not swapped and not timed_out
252
+ and (args.no_lock or locked > 0))
253
+
254
+ rec = {
255
+ "label": args.label,
256
+ "cmd": cmd,
257
+ "returncode": rc,
258
+ "wall_s": round(wall, 3),
259
+ "limit_mb": args.limit_mb,
260
+ "metric": args.metric,
261
+ "rlimit_data_mb": data_mb,
262
+ "interval_ms": args.interval_ms,
263
+ "samples": sampler.samples,
264
+ "peak_anon_mb": _mib(sampler.peak["RssAnon"]),
265
+ "peak_file_mb": _mib(sampler.peak["RssFile"]),
266
+ "peak_rss_mb": _mib(sampler.peak["VmRSS"]),
267
+ "vm_hwm_mb": _mib(sampler.peak["VmHWM"]),
268
+ "peak_swap_mb": _mib(sampler.peak["VmSwap"]),
269
+ "swap_samples_over_zero": sampler.swap_samples,
270
+ "vm_lck_mb": _mib(locked),
271
+ "peak_vmsize_mb": _mib(sampler.peak["VmSize"]),
272
+ "peak_vmdata_mb": _mib(sampler.peak["VmData"]),
273
+ "overflow_killed": overflow,
274
+ "breach_mb": _mib(sampler.breach) if overflow else None,
275
+ "timed_out": timed_out,
276
+ "lock": lock_note,
277
+ "strict_ok": ok,
278
+ }
279
+
280
+ if args.record:
281
+ p = Path(args.record)
282
+ p.parent.mkdir(parents=True, exist_ok=True)
283
+ with p.open("a", encoding="utf-8") as fh:
284
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
285
+
286
+ if not args.quiet:
287
+ print(f"[strict8g] {'PASS' if ok else 'FAIL'} {args.label}: rc={rc} wall={wall:.1f}s "
288
+ f"| anon={rec['peak_anon_mb']} file={rec['peak_file_mb']} "
289
+ f"total={rec['peak_rss_mb']} hwm={rec['vm_hwm_mb']} MiB "
290
+ f"vmdata={rec['peak_vmdata_mb']} "
291
+ f"| cap {args.limit_mb} MiB ({args.metric}) swap={rec['peak_swap_mb']} "
292
+ f"lck={rec['vm_lck_mb']} samples={sampler.samples}", file=sys.stderr)
293
+ if overflow:
294
+ print(f"[strict8g] 超標:{metric} 到 {rec['breach_mb']} MiB 時 SIGKILL", file=sys.stderr)
295
+ if swapped:
296
+ print(f"[strict8g] 偵測到 swap:{rec['peak_swap_mb']} MiB", file=sys.stderr)
297
+ if locked <= 0 and not args.no_lock:
298
+ print(f"[strict8g] 沒有任何匿名記憶體被鎖定,no-swap 無法成立"
299
+ f"({lock_note or 'helper 未回報'})", file=sys.stderr)
300
+ elif not ok and not overflow and not swapped and not timed_out:
301
+ print(f"[strict8g] 子行程自行失敗 rc={rc}", file=sys.stderr)
302
+
303
+ sys.stdout.write(stdout or "")
304
+ sys.stderr.write(stderr or "")
305
+ if overflow or timed_out:
306
+ return 124
307
+ return rc
308
+
309
+
310
+ if __name__ == "__main__":
311
+ sys.exit(main())