File size: 19,293 Bytes
12eb3a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
// SDQ expert pager:SSD → RAM → CPU 的 MoE 專家分層快取
//
// 設計要點
//   * 分頁單位 = 一個 expert(layer, expert id),大小 ~1.8 MB(三個矩陣:gate/up/down)
//   * RAM 端是一個固定槽位的 arena(slab),每槽 2 MiB,O(1) 配置、不碎片化
//   * 淘汰策略:LRU + 本 token 使用中(pinned)的保護
//   * 預取:依「上一個 token 的專家選擇」用一階 Markov 轉移預測下一個 token 的專家,
//           交給背景執行緒 pread(),量測「預取命中率 / 預取浪費量」
//   * 指標:hit/miss、SSD 讀取位元組、demand 停滯時間、prefetch 領先時間、arena 使用量
//
// 不使用 swap:所有權重資料只存在 arena(實體 RAM)與檔案(SSD)兩處。

#pragma once

#include <ggml.h>
#include <ggml-backend.h>
#include "llama.h"

#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

namespace sdq {

// ---------------------------------------------------------------- GGUF 讀取
struct gguf_info {
    int64_t n_layer = 0;
    int64_t n_expert = 0;
    int64_t n_expert_used = 0;
    int64_t n_embd = 0;
    int64_t n_ff_exp = 0;
    int64_t n_ff_shexp = 0;
    int64_t n_head = 0;
    int64_t n_head_kv = 0;
    int64_t n_embd_head_k = 0;
    int64_t n_embd_head_v = 0;
    int64_t context_length = 0;
    int64_t full_attention_interval = 4;
    int64_t is_recr[512] = {0};
    std::string arch;
    std::string name;
};

// 一個 expert 槽位:gate/up/down 三段(有些模型把 gate+up 合成 gate_up)
struct seg_desc {
    uint64_t off = 0;  // 檔案內位元組偏移
    uint64_t size = 0; // 位元組數
};

enum part : int { GATE = 0, UP = 1, DOWN = 2, COUNT = 3 };

struct expert_desc {
    seg_desc seg[3];
    int64_t slot_off[3] = {0, 0, 0}; // 三段在 arena 槽位內的相對偏移
    int64_t ne0 = 0;         // gate/up row 長度(= n_embd)
    int64_t ne1 = 0;         // gate/up row 數(= n_ff_exp)
    int64_t down_ne0 = 0;    // down row 長度(= n_ff_exp)
    int64_t down_ne1 = 0;    // down row 數(= n_embd)
    ggml_type type[3] = {GGML_TYPE_COUNT, GGML_TYPE_COUNT, GGML_TYPE_COUNT};
    int64_t bytes = 0;       // 三段總大小
    bool fused_gate_up = false;
};

// 一層的所有 expert 描述(40 層 × 256 個)
struct layer_desc {
    std::vector<expert_desc> experts;
    int64_t bytes_per_expert = 0;
    // 這個層的 expert 比「大多數層」大(Q4_K/Q5_K vs Q6_K)→ 需要大槽位。
    // 不然的話所有槽位都得按最大的那種配,白白浪費 6.8% 的 arena。
    bool needs_big_slot = false;
};

// ---------------------------------------------------------------- 指標
struct stats {
    std::atomic<uint64_t> expert_requests{0};   // 每個 (layer, token) 需要幾個 expert
    std::atomic<uint64_t> expert_hits{0};
    std::atomic<uint64_t> expert_misses{0};
    std::atomic<uint64_t> ssb_read_bytes_demand{0};
    std::atomic<uint64_t> ssb_read_bytes_prefetch{0};
    std::atomic<uint64_t> prefetch_issued{0};
    std::atomic<uint64_t> prefetch_completed{0};
    std::atomic<uint64_t> prefetch_used{0};     // 預取後真的被用到
    std::atomic<uint64_t> prefetch_duplicate{0};// 預取時已經在 RAM 裡
    std::atomic<uint64_t> evictions{0};
    std::atomic<uint64_t> demand_wait_us{0};    // 因為缺頁而停下來等 SSD 的時間
    std::atomic<uint64_t> prefetch_wait_us{0};
    std::atomic<uint64_t> tokens{0};
    std::atomic<uint64_t> moe_calls{0};
    std::atomic<uint64_t> flops{0};
};

// ---------------------------------------------------------------- pager
struct config {
    size_t   arena_bytes   = 0;   // 0 = 自動(依 RAM 預算)
    size_t   slot_bytes    = 0;   // 0 = 自動(依模型推導)
    int      n_threads     = 0;
    bool     prefetch      = true;
    int      prefetch_ahead_layers = 0; // >0 時限制同時在飛的預取數量
    bool     fadvise_dontneed = true;  // 讀完丟掉 kernel page cache,讓 RAM 帳目誠實
    bool     verbose       = false;
    uint64_t ram_budget_bytes = 0;    // 0 = 自動(實體 RAM 的一小部分)
    std::string model_path;
    std::string stats_path;
    int      slot_bias = 0;

    // ---- I/O 執行緒池 ----
    // 為什麼需要:一個 expert 在檔案上是 gate / up / down 三段「不相鄰」的資料,
    // 循序讀要 3 次 pread,而單一 pread 在本機量到約 7 ms(1.9 MB / 275 MB/s)。
    // 也就是說「讀一個 expert」的延遲完全由這三段循序決定,執行緒再多也沒用。
    // 把三段交給三條執行緒同時讀,單一 expert 的延遵掉到約 1/2~1/3。
    // (實測:8 個 expert 全部 miss 的一層,序列讀 12.6 ms → 段平行 7.6 ms。)
    bool     io_parallel = true;      // SDQ_IO_PARALLEL=0 可關(回到序列讀)
    bool     io_uring    = false;     // SDQ_IO_URING=1:以 liburing 提交同批非同步讀取
    int      io_threads   = 0;         // 0 = 自動(3 × n_threads,上限 48)
    int      io_split     = 1;         // SDQ_IO_SPLIT:每一段再切成幾塊並行讀(實測 1 最好)

    // ---- Strata 式「熱門 expert 剖面」(OPT-1)----
    // 離線用代表性文字跑一次,把每個 (layer, expert) 的請求次數記成剖面;
    // 執行時把排行榜前 hot_pin 個釘成「熱頁」:熱頁可以被讀取,但**永遠不被淘汰**,
    // 淘汰時一律先從冷頁挑。這解決的是「prefill 的一次性專家把熱區洗掉」。
    std::string profile_in;    // 讀取剖面(SDQ_PROFILE_IN)
    std::string profile_out;   // 結束時輸出剖面(SDQ_PROFILE_OUT)
    int         hot_pin = 0;   // 釘選前幾名(SDQ_HOT_PIN)
    int         admit_min_hits = 1;  // 請求次數低於此值就不收進快取(SDQ_ADMIT_MIN_HITS)
};

class pager {
public:
    static pager & instance();

    // 從 GGUF 檔頭建立 expert 頁面表,並配置 RAM arena
    void init(const config & cfg, ggml_context * ctx);

    bool ready() const { return ready_; }
    size_t arena_bytes() const { return arena_bytes_; }
    size_t slot_bytes() const { return slot_bytes_; }
    size_t n_slots() const { return n_slots_; }
    size_t small_bytes() const { return small_bytes_; }
    size_t big_bytes() const { return big_bytes_; }
    size_t n_small_slots() const { return n_small_; }
    size_t n_big_slots() const { return n_big_; }
    const gguf_info & info() const { return info_; }

    // 取得 layer 描述(idx 越界或沒資料回 nullptr)
    const layer_desc * layer(int il) const;

    // 一次把「連續編號的一串 expert」讀進 RAM:同一層的三個矩陣(gate/up/down)本來
    // 就各自連續存放,所以 k 個連續 expert 只需要 3 次大讀取(而不是 3k 次)。
    // 對高延遲的儲存裝置(每個 request 都貴)這是最大的單項加速。
    //   experts 必須已依編號遞增排序;slots[i] 是 experts[i] 對應的槽位(由呼叫端配置)
    // 回傳實際讀取的位元組數。
    uint64_t read_expert_span(int il, const int32_t * experts, int n, const int32_t * slots);

    // 只配置槽位(不讀資料):回傳槽位索引,-1 表示沒有空槽
    int reserve_slot(int il, int ie);

    // 查這個 expert 是否已經在 RAM:命中則 pin 並回傳槽位,未命中回傳 -1(不配置)
    int probe(int il, int ie);

    // 記錄一次合併讀取的位元組數(給指標用)
    void note_span_read(uint64_t bytes);

    // 確保 (layer, expert) 在 RAM,回傳槽位索引;-1 表示失敗
    // pinned: 本 token 使用中,不可被淘汰;blocking: 缺頁時是否同步讀 SSD
    int acquire(int il, int ie, bool blocking);

    // 背景預取:把 page 讀進 arena(非同步),依歷史預測下一個 token 會用到的 expert
    void prefetch_expert(int il, int ie);
    // 「下一層」預取:在 layer L 的 op 開頭呼叫,替 layer L+1 排預取。
    // 與 end_token() 的差別在時機 —— 後者在一個 token 的 40 層全算完才跑,
    // 那時排的預取沒有任何計算可以重疊,實測使用率只有 0~8 %。
    void prefetch_layer_ahead(int il);

    // 每層計算開始時呼叫:解除上一層的 pin(本層用到的槽位才受保護)
    void unpin_all();
    // 用完一個槽位後立刻放掉 pin(成對呼叫 acquire())。
    //
    // 為什麼需要:原本 pin 是「整層釘住、下一層開頭才 unpin_all」。mode C 的
    // expert_forward 回傳之後就再也不讀那個槽位(最後的歸約只讀各執行緒自己的
    // partial 累加緩衝),所以繼續釘著只是白白把工作集撐大。
    // 撐大的後果很具體:prefill 一個 ubatch 有 128 個 token,一層最多需要
    // 256 個不同的 expert → 整層釘住 = 256 個槽位不能回收。大小兩種槽位之下,
    // 大槽位只有約 n_big_layers × n_expert_used 個,於是**必定**在 prefill 時
    // 「找不到能放 il=34 ie=… 的槽位」而中止。
    void release(int s);
    void note_used(int il, int ie);
    void end_token();

    // 依歷史預測某層下一個 token 的 top-k expert(回傳信心 0..1)
    void predict(int il, int32_t * out, int k);

    void shutdown();
    stats & get_stats() { return stats_; }

    // 把當前指標寫成一行 JSON(append 到 stats_path)
    void dump_stats(const char * phase);
    void print_summary() const;

    // arena 內的槽位指標
    // 槽位索引空間是統一的:[0, n_small_) 是小槽位(連續),
    // [n_small_, n_slots_) 是大槽位(接在小槽位區之後)。
    uint8_t * slot_ptr(int s) {
        return (size_t) s < n_small_
             ? arena_ + (size_t) s * small_bytes_
             : arena_ + small_bytes_ * n_small_ + (size_t)(s - (int) n_small_) * big_bytes_;
    }
    size_t slot_size_of(int s) const {
        return (size_t) s < n_small_ ? small_bytes_ : big_bytes_;
    }
    bool need_big_slot(int il, int ie) const;

    // 這個槽位算不算「大槽位」。
    //
    // 判斷依據是**槽位的實際大小**,不是索引 >= n_small_:
    // 統一尺寸(SDQ_FORCE_UNIFORM_SLOT=1,或模型只有一種 expert 大小)時,
    // 所有槽位的索引都 < n_small_ 但大小都是大的 —— 用索引判斷會讓大 expert
    // 永遠配不到槽位,整個推論卡在 admit 的重試迴圈裡。
    bool is_big_slot(int s) const {
        return big_bytes_ && slot_size_of(s) >= big_bytes_;
    }

    size_t used_bytes() const { return used_bytes_snapshot(); }
    double  hit_rate() const;

    // arena 裡實際被占用的位元組數。大小兩種槽位之後不能用「槽位數 × slot_bytes_」
    // 計算(slot_bytes_ 是大的那一種),所以真的把槽位走一遍。
    // 呼叫端:dump_stats(每次解碼階段結束)與 print_summary,不是熱路徑。
    size_t used_bytes_snapshot() const {
        size_t b = 0;
        for (size_t s = 0; s < slots_.size(); s++) {
            if (slots_[s].layer >= 0) {
                b += slot_size_of((int) s);
            }
        }
        return b;
    }

    // 讀 SSD(同步,給 acquire 用)
    bool read_expert(int il, int ie, uint8_t * dst);

    // ---- I/O 執行緒池(見 config::io_parallel 的說明)----
    // 一個「讀取批次」的完成狀態;放在呼叫端的 stack 上,worker 只在 remaining==0
    // 之後才會再碰它,所以離開 read_expert_par() 之後不會有 dangling pointer。
    struct io_ctx {
        std::atomic<int> remaining{0};
        bool ok = true;
    };
    struct io_job {
        uint64_t off;
        uint8_t * dst;
        size_t   size;
        io_ctx * ctx;
    };

    void io_pool_start();
    void io_pool_stop();
    void io_worker(size_t wid);
    bool read_expert_par(int il, int ie, uint8_t * dst);
    bool read_expert_uring(int il, int ie, uint8_t * dst);
    bool read_range_staged(uint64_t off, uint8_t * dst, size_t size,
                           uint8_t * stage, size_t stage_sz);

    // I/O 執行緒池每條 worker 自帶的 O_DIRECT 對齊緩衝(啟動時配置,已計入 RSS)
    struct io_stage { uint8_t * buf = nullptr; size_t sz = 0; };
    std::vector<io_stage> io_stage_;

    bool     io_pool_on_ = false;
    int      io_threads_ = 0;
    int      io_split_   = 1;
    std::vector<std::thread> io_pool_;
    std::mutex io_mu_;
    std::condition_variable io_cv_;       // 有工作 / 要停
    std::condition_variable io_done_cv_;  // 某個批次讀完了
    std::deque<io_job> io_q_;
    bool io_stop_ = false;
    std::atomic<uint64_t> io_jobs_{0};
    std::atomic<uint64_t> io_wait_ns_{0};

private:
    pager() = default;
    ~pager();

    struct slot_state {
        int32_t layer  = -1;
        int32_t expert = -1;
        uint64_t last_use = 0;   // LRU 記時
        uint32_t freq = 0;       // 這個 expert 被用到幾次(衰減式;用來做 LFU 混合策略)
        uint32_t pin = 0;        // >0 時不可淘汰
        bool busy = false;       // 背景執行緒正在寫入
        bool is_hot = false;     // 屬於剖面排行榜:淘汰時最後才會被選中
    };

    struct pf_item {
        int slot;
        int il;
        int ie;
        uint64_t issue_time_us;
    };

    void touch_freq_locked(slot_state & st);
    // ---- OPT-1:剖面 / 熱頁 ----
    void  count_request(int il, int ie);            // 記一次請求(給剖面用)
    bool  load_profile(const std::string & path);   // 讀剖面並標記前 hot_pin 名
    void  dump_profile(const std::string & path);   // 輸出排行榜
    bool  hot_lookup(int il, int ie) const;
    void load_gguf_header(const std::string & path);
    void build_page_map(const std::string & path);
    void alloc_arena();
    int  find_slot_locked(int il, int ie);
    int  admit_slot_locked(int il, int ie);
    int  victim_locked() { return admit_slot_locked(-1, -1); }
    int  victim_stale_locked(uint64_t stale_before);
    int  victim_stale_for(int il, int ie, uint64_t stale_before);
    void pf_worker();
    bool read_range(uint64_t off, uint8_t * dst, size_t size);
    bool read_range_direct(uint64_t off, uint8_t * dst, size_t size);
    void open_model_fd();

    bool     ready_ = false;
    config   cfg_;
    gguf_info info_;
    std::vector<layer_desc> layers_;

    uint8_t * arena_ = nullptr;
    size_t    arena_bytes_ = 0;
    size_t    slot_bytes_ = 0;   // = max(small_bytes_, big_bytes_),保留給舊的訊息輸出
    size_t    small_bytes_ = 0;  // 小槽位大小(大多數層用)
    size_t    big_bytes_   = 0;  // 大槽位大小;0 = 這個模型沒有大層
    size_t    n_small_     = 0;
    size_t    n_big_       = 0;
    size_t    n_slots_ = 0;
    int       fd_ = -1;
    int       fd_direct_ = -1;   // O_DIRECT:完全繞過 kernel page cache
    bool      use_direct_ = false;

    mutable std::mutex mu_;
    std::vector<slot_state> slots_;
    std::unordered_map<int64_t, int32_t> page_; // (layer<<16|expert) -> slot
    std::vector<int32_t> free_slots_;   // 空的小槽位
    std::vector<int32_t> free_big_slots_;

    // ---- OPT-1:剖面 / 熱頁(Strata 式:靜態排行榜 + 熱頁不淘汰)----
    std::vector<uint32_t> prof_;   // 每個 (layer, expert) 的累計請求次數
    std::vector<uint8_t>  hot_;    // 是否在排行榜前 hot_pin 名
    int                   n_hot_ = 0;
    std::string           profile_out_;
    uint32_t freq_tick_ = 0;
    std::atomic<size_t> used_{0};
    uint64_t clock_ = 0;

    // 背景預取
    std::thread pf_;
    std::mutex pf_mu_;
    std::condition_variable pf_cv_;
    std::deque<pf_item> pf_q_;
    bool pf_stop_ = false;
    std::atomic<uint64_t> pf_inflight_{0};

    // 路由歷史(一階 Markov:上個 token 的 expert -> 這次被選到的次數)
    struct trans { int32_t to[64]; uint32_t cnt[64]; int32_t n = 0; };
    std::vector<std::vector<trans>> hist_; // [layer][prev_expert]
    std::vector<std::vector<int32_t>>  prev_used_; // [layer] 上一個 token 用到的 expert(預測用)
    std::vector<std::vector<int32_t>>  cur_used_;  // [layer] 當前 token 用到的 expert
    std::mutex hist_mu_;   // 保護 cur_used_ / prev_used_ / hist_(多執行緒會同時 note_used)
    std::atomic<uint64_t> hist_lock_{0};

    stats stats_;
};

// ---------------------------------------------------------------- 工具
uint64_t now_us();
size_t   process_rss_bytes();
size_t   process_anon_bytes();
size_t   enforced_memory_ceiling_bytes();
size_t   physical_ram_bytes();

// ---------------------------------------------------------------- ggml 介接
// 自訂 buffer type:expert 權重佔 0 個 RAM 位元組,資料留在檔案裡
ggml_backend_buffer_type_t sdq_expert_buft();

// 註冊 model params 的 tensor buffer override(expert 權重 → 我們的 buffer)
void sdq_install_buft_overrides(llama_model_params * params);

// 建立被分頁的 MoE FFN(替代 build_moe_ffn 裡的三次 mul_mat_id)
ggml_tensor * sdq_build_moe(ggml_context * ctx, ggml_tensor * cur,
                            ggml_tensor * ids, ggml_tensor * weights, int il,
                            int64_t n_expert, int64_t n_expert_used,
                            float swiglu_limit);

// 設定 arena 大小 / 預取開關等(CLI 在載入模型前呼叫)
bool sdq_init(const std::string & model_path);
void sdq_set_config(const config & c);
const config & sdq_get_config();
void sdq_set_enabled(bool on);
void sdq_shutdown();

bool sdq_enabled();

// 效能計時探針的結果(SDQ_PROFILE_TIME=1)
void sdq_print_time_profile();

// 自我測試:算一次指定 expert 的 MoE 輸出(給 NumPy 對照用)
int sdq_moe_selftest(int il, int ie, const float * x, float * out_io,
                     int64_t n_embd, int64_t n_ff, int n_tok);

// 建立我們的 MoE 自訂節點。
// **必須把 gf 傳進來並呼叫 ggml_build_forward_expand(gf, out)**:
// ggml 的排程器只會為「進了圖的節點」配置 compute buffer,沒註冊的節點其輸出
// 緩衝區會被 liveness 分析判成可重用,導致 layer>=1 的結果被其他張量覆蓋
// (實測症狀:layer 0 正確、layer 1 起整個錯掉)。
ggml_tensor * sdq_build_moe(ggml_cgraph * gf, ggml_context * ctx, ggml_tensor * cur,
                            ggml_tensor * ids, ggml_tensor * weights, int il,
                            int64_t n_expert, int64_t n_expert_used, float swiglu_limit);

// 這一層的分頁是否可用(graph build 時呼叫)
bool sdq_pager_active_for_layer(int il);

// 除錯/驗證用:把某個 F32 張量 dump 到檔案。
// 必須在「原生 mul_mat_id 路徑」與「SDQ 分頁路徑」兩邊都呼叫,才能做真正的 A/B 對照
// (env: SDQ_MOE_OUT_DUMP=prefix、SDQ_MOE_OUT_DUMP_LAYER=層編號,-1 = 全部)
ggml_tensor * sdq_maybe_dump_tensor(ggml_context * ctx, ggml_tensor * t, int il);


} // namespace sdq