起因

刷到 Unsloth 发布了 GLM-5.2 的 GGUF 量化版,753B 参数,256 个专家。然后我看了一眼自己的笔记本——AMD Strix Halo,64GB 内存。

按常理,753B 模型光权重就要 375GB(FP16),64GB 内存连零头都不够。但 MoE 模型有个特性:每 token 只激活 8 个专家,实际参与计算的参数量只有总参数的 1% 左右。

如果用 mmap 按需加载,配合 LRU 缓存热专家……理论上,64GB 跑 753B 是可能的。

于是开干。结论先说:Windows 上跑不通。 但排查过程中有不少值得记录的发现,写下来给同样在折腾的人参考,也希望能吸引到懂 llama.cpp 底层的大佬帮忙看看。


一、环境

组件 规格
CPU AMD Ryzen AI MAX+ 392 (Strix Halo, Zen5, 12核24线程)
GPU AMD Radeon 8060S (集成, RDNA 3.5, 本次未使用)
内存 64GB DDR5
硬盘 2x 1TB NVMe SSD
系统 Windows 11
llama.cpp b10107 (commit c0bc8591e)

模型:GLM-5.2 UD-Q4_K_XL(Unsloth Dynamic 量化),11 个分片,435GB。

这里有个关键细节:Strix Halo 是 UMA 架构,CPU 和 GPU 共享 64GB 内存。但我故意不用 GPU,全程 -ngl 0 纯 CPU 推理。原因很简单——GLM-5.2 在 GPU 路径上已经有已知 bug(Issue #26027),我需要先确认 CPU 路径是否正常。

结果?CPU 路径也有问题。 这是后话。


二、为什么 64GB 能跑 753B?

在开始测试之前,先解释一下为什么这个看似不可能的组合实际上是可以工作的。理解了这一点,后面的速度分析才有意义。

2.1 MoE 的稀疏激活

GLM-5.2 的架构很特殊。前 3 层是普通的 dense transformer,从第 4 层开始变成 MoE(Mixture of Experts)。每层有 256 个专家,但每次只激活 8 个。这意味着:

dense 层参数: 3 层 x 约 200M = 约 600M
MoE 层参数: 76 层 x 8 个专家 x 21.75MB/专家 + 共享部分 = 约 15GB
总计: 约 15-20 GB 的激活参数

753B 的模型,实际参与计算的只有 15-20GB。这就是 MoE 的魔力。

2.2 mmap + LRU 缓存

llama.cpp 使用 mmap(memory-mapped file)加载模型。操作系统把文件映射到虚拟地址空间,但不立即读取数据。只有当程序实际访问某个地址时,才从磁盘加载对应的页面。

对于 MoE 模型:

  • 共享权重(embedding、attention、layer norm)被频繁访问,自然留在内存中
  • 热门专家的权重被频繁访问,形成 LRU 缓存
  • 冷门专家的权重可能永远不会被加载

实测 3 tok/s 的速度,说明缓存命中率约 95%——大部分专家数据都从内存缓存中读取,不需要从 SSD 加载。

2.3 速度理论模型

缓存命中率 实际 I/O 预期速度
0% (全冷) 13.2 GB/token 0.15 tok/s
80% 2.6 GB/token 0.75 tok/s
95% 0.66 GB/token 3 tok/s
99% 0.13 GB/token 5+ tok/s

计算过程:每 token 激活 8 个专家 x 76 MoE 层 x 21.75 MB/专家 = 13.2 GB。SSD 顺序读取约 2 GB/s,全冷加载需要 6.6 秒/token。95% 缓存命中率只需 0.66 GB,约 0.33 秒/token。


三、模型加载:第一次激动

启动命令:

llama-server -m GLM-5.2-UD-Q4_K_XL-00001-of-00011.gguf -c 256 -ngl 0 -t 22 --port 8080

2 分 29 秒后,看到这行日志:

2.29.877.692 I srv  llama_server: model loaded
2.29.877.858 I srv  llama_server: listening on http://127.0.0.1:8080

内存占用 43GB。435GB 的模型,只用了 43GB 就加载完成——mmap 的按需加载确实生效了。

但日志里有一大堆 warning:

0.00.921.030 W model has unused tensor blk.78.attn_norm.weight -- ignoring
0.00.921.134 W model has unused tensor blk.78.ffn_gate_exps.weight (1811939328 bytes) -- ignoring
0.00.921.140 W model has unused tensor blk.78.ffn_down_exps.weight (2214592512 bytes) -- ignoring
0.00.921.145 W model has unused tensor blk.78.ffn_up_exps.weight (1811939328 bytes) -- ignoring
...(blk.78 的 26 个 tensor 全部被跳过)

blk.78 是 NextN(Multi-Token Prediction)草稿层。NextN 是一种推测解码技术——模型在生成一个 token 的同时,预测接下来的多个 token,然后验证这些预测是否正确。如果正确,就可以一次生成多个 token,大幅提升速度。但当前版本的 llama.cpp 尚未完全支持 GLM-5.2 的 NextN 功能,所以这些 tensor 被跳过了。

跳过 NextN 层不影响主模型的推理正确性,只影响速度(没有推测解码加速)。

看到 model loaded 的那一刻,我是真的激动了。


四、推理测试:从激动到困惑

4.1 中文测试

{
  "messages": [{"role": "user", "content": "你好"}],
  "max_tokens": 30,
  "temperature": 0.1
}

返回:

{
  "choices": [{
    "message": {
      "content": "",
      "reasoning_content": "??????????????????????????????"
    }
  }],
  "timings": {
    "prompt_per_second": 4.16,
    "predicted_per_second": 3.03
  }
}

速度到了——3 tok/s。 但输出全是问号。

注意两个细节:

  1. content 为空,reasoning_content 全是问号:GLM-5.2 是推理模型,会先在 reasoning_content 中"思考",再在 content 中输出回答。但这里 reasoning_content 全是 ?,content 为空——模型完全没有产生有意义的输出。
  2. cache_n = 11:有 11 个 token 命中了 KV cache,说明 prompt 的部分内容被缓存了。

4.2 英文测试

{"messages": [{"role": "user", "content": "hi"}], "max_tokens": 10}

返回:"reasoning_content": "??????????"

英文也是乱码。 排除了中文 tokenizer 的嫌疑。

4.3 Raw completion(绕过 chat template)

{"prompt": "The capital of France is", "n_predict": 20}

同样是乱码。排除了 chat template 的嫌疑。

4.4 纯 CPU 版测试

为了排除 HIP 库的干扰,下载了 llama.cpp 的纯 CPU 版(17.4MB)重新测试:

Prompt processing:  10.67 tok/s
Token generation:   2.94 tok/s
Output:             ??????????????????

纯 CPU 版也是乱码。 说明问题不在 HIP 库,而在 llama.cpp 的 GLM-5.2 推理核心代码。

这里需要特别说明:纯 CPU 版的加载时间从 HIP 版的 2.5 分钟变成了 15 分钟。这是因为 HIP 版使用了 Zen4 优化的 CPU 后端(ggml-cpu-zen4.dll),而纯 CPU 版使用的是通用 x64 后端(ggml-cpu-x64.dll),计算效率低很多。但推理速度差异不大(3.03 vs 2.94 tok/s),说明瓶颈在 I/O 而不在计算。

这时候我有两个猜测:

  1. Tokenizer 坏了(中文词表丢失?)
  2. 推理引擎有 bug

我先去排查 tokenizer——因为如果 tokenizer 坏了,修起来简单;如果是引擎 bug,那就麻烦了。


五、排查 Tokenizer:一场意外的发现

5.1 看起来像是词表损坏

查看 GGUF 的 tokenizer 元数据:

tokenizer.ggml.model: gpt2
tokenizer.ggml.pre:   glm4
vocab_size:           154,880
merges:               321,649

154,880 个 token 中,0 个 CJK 字符

第一反应:完了,中文词表丢了。这不就是乱码的原因吗?

但仔细一想,不对。如果中文词表真的丢了,那 llama-tokenize 也应该报错才对。试了一下:

$ llama-tokenize -p "你好"
109377 -> '你好'

Tokenizer 完全正常。 “你好” 被正确编码成了 token 109377。

这是怎么回事?词表里 0 个 CJK 字符,但中文编码完全正常?

5.2 Byte-Level BPE:一个容易被误解的机制

查了 HuggingFace 上 GLM-5.2 的 tokenizer.json(19.7MB),发现了关键线索:

{
  "pre_tokenizer": [
    {"type": "Split", "pattern": {"Regex": "..."}},
    {"type": "ByteLevel", "add_prefix_space": false}
  ]
}

GLM-5.2 使用的是 GPT-2 风格的 byte-level BPE

这种 tokenizer 不在 Unicode 字符级别工作,而是在字节级别工作。核心思想是:把每个 UTF-8 字节映射到一个 printable Unicode 字符,然后在这些字符上做 BPE 合并。这样做的好处是:

  1. 词表大小固定(基础词表只有 256 个字节),不需要 UNK token
  2. 任何 Unicode 字符都可以表示(包括 emoji、CJK、特殊符号)
  3. 语言无关,同一个 tokenizer 可以处理任何语言

具体流程:

输入: "你好"
  |
UTF-8 编码: [0xE4, 0xBD, 0xA0]  (3 个字节)
  |
Byte-to-Unicode 映射:
  byte 0xE4 -> chr(0x00E4)  (a with diaeresis)
  byte 0xBD -> chr(0x00BD)  (vulgar fraction one half)
  byte 0xA0 -> chr(0x0142)  (l with stroke)
  |
BPE 合并: 查 merges 表,3 个字符合并成 1 个 token
  |
结果: [109377]

所以词表里没有 CJK 字符是正常的! 中文字符通过 UTF-8 字节编码,每个字节映射到一个 Latin 字符,然后在 Latin 字符级别做 BPE 合并。这就是 byte-level BPE 的设计。

5.3 彻底验证

写了个 Python 脚本,从 HF 的 tokenizer.json 加载词表,逐条对比 GGUF 的词表:

from gguf import GGUFReader
import json

# 加载 GGUF tokens
reader = GGUFReader('GLM-5.2-UD-Q4_K_XL-00001-of-00011.gguf', mode='r')
gguf_tokens = [reader.fields['tokenizer.ggml.tokens'].parts[i]
               for i in reader.fields['tokenizer.ggml.tokens'].data]

# 加载 HF tokens
with open('tokenizer.json', 'r', encoding='utf-8') as f:
    hf_vocab = json.load(f)['model']['vocab']
hf_token_list = [t[0] for t in sorted(hf_vocab.items(), key=lambda x: x[1])]

# 对比
mismatches = sum(1 for i in range(min(len(gguf_tokens), len(hf_token_list)))
                 if gguf_tokens[i] != hf_token_list[i].encode('utf-8'))

print(f"GGUF tokens: {len(gguf_tokens)}")
print(f"HF tokens:   {len(hf_token_list)}")
print(f"Mismatches:  {mismatches}")

输出:

GGUF tokens: 154,880
HF tokens:   154,820
Mismatches:  0

词表顺序完全一致。 GGUF 比 HF 多 60 个 special tokens,核心词表没有任何错位。

又用 llama-tokenize 测试了更多用例:

$ llama-tokenize -p "GLM-5.2是一个大语言模型"
3825 -> 'GLM'    44 -> '-'    12 -> '5'    20 -> '.'    13 -> '2'
99950 -> '是一个'    98324 -> '大'    100132 -> '语言'    100484 -> '模型'

$ llama-tokenize -p "人工智能"
104668 -> '人工智能'

$ llama-tokenize -p "今天天气真好"
99637 -> '今天'    101791 -> '天气'    117913 -> '真好'

Tokenizer 完全正常。 中英文都能正确编码,BPE 合并也工作正常。

结论:Tokenizer 排查完毕,不是问题。问题在推理引擎。


六、定位 Bug:从 GitHub 到实锤

6.1 找到 Issue #26027

搜索 GitHub,发现 3 天前(2026-07-23)有人提交了一个 issue:

Issue #26027: GLM-5.2 (glm_moe_dsa) dense-MLA CUDA path produces corrupted output for ANY real transformer layer offloaded to GPU

报告者用 2 张 RTX PRO 6000(各 96GB)测试,发现:

模式 结果
-ngl 0 (CPU only) 正确
-ngl 1 (output layer on GPU) 正确
-ngl 2 (output + MTP layer on GPU) 正确
-ngl 3 (first real transformer layer on GPU) 乱码

关键结论:只要有任何一个真正的 transformer 层在 GPU 上,输出就损坏。 CPU-only 模式是正常的。

6.2 但我们的情况更严重

Issue #26027 说 CPU-only 是正常的。但我们的测试显示:

环境 平台 量化 模式 结果
Issue #26027 Linux UD-IQ2_M CPU only 正确
Issue #26027 Linux UD-IQ2_M 部分 GPU 乱码
我们 Windows UD-Q4_K_XL CPU only (HIP) 乱码
我们 Windows UD-Q4_K_XL CPU only (纯CPU) 乱码

三个可能的差异点:

  1. 平台: Linux vs Windows
  2. 量化: UD-IQ2_M vs UD-Q4_K_XL
  3. 构建: CUDA build vs HIP/CPU build

我个人倾向于认为是 Windows 平台的浮点行为差异 导致的。MSVC 和 GCC/Clang 在浮点精度、舍入模式、NaN 处理上都有微妙差异,而 MLA 的计算涉及大量矩阵乘法和低秩投影,对数值精度非常敏感。


七、硬核发现:排查过程中的关键洞察

7.1 Expert Tensor 是 3D 打包的

分析 GGUF 的 tensor 结构时,发现了一个重要细节:

blk.N.ffn_gate_exps.weight: shape=[6144, 2048, 256]  -> 1728 MB
blk.N.ffn_up_exps.weight:   shape=[6144, 2048, 256]  -> 1728 MB
blk.N.ffn_down_exps.weight: shape=[2048, 6144, 256]  -> 2112 MB

所有 256 个专家打包在一个 3D tensor 里,不是 256 个独立 tensor。最后一个维度 256 就是专家数量。

单个专家的实际大小:

  • gate: 1728 MB / 256 = 6.75 MB
  • up: 1728 MB / 256 = 6.75 MB
  • down: 2112 MB / 256 = 8.25 MB
  • 总计: 21.75 MB / expert

一个常见的误解是每个专家有 1.3GB,实际上只有 21.75MB。这个发现直接影响速度模型——LRU 缓存可以容纳更多热专家,缓存命中率更高。

7.2 MLA 机制详解

GLM-5.2 使用 MLA(Multi-head Latent Attention),这是它和 DeepSeek-V2 共享的注意力创新。

标准 MHA 需要缓存每个头的 K 和 V,内存开销与头数成正比。MLA 通过低秩投影把 KV 压缩到 512 维(kv_lora_rank=512),推理时只缓存这个压缩向量。这就是为什么 GLM-5.2 能支持 1M token 的 context length。

但 MLA 的计算路径比标准 MHA 复杂得多:涉及 absorbed wk_b/wv_b、YaRN mscale、sigmoid-gated MoE 等特殊处理。这些复杂性正是 bug 的温床。

7.3 DSA Indexer

GLM-5.2 的架构名是 glm-dsa,DSA = Dynamic Sparse Attention。每个 MoE 层都有一个 indexer,用于决定哪些 token 需要被关注:

blk.N.indexer.attn_k.weight   (6144 x 128)
blk.N.indexer.attn_q_b.weight (2048 x 4096)
blk.N.indexer.k_norm.weight   (128)
blk.N.indexer.proj.weight     (6144 x 32)

indexer 输出一个 32 维的向量,用于稀疏注意力的 token 选择。这种机制可以进一步降低计算量,但也增加了实现的复杂性。

7.4 每层的完整 tensor 结构

每个 MoE 层包含 21 种 tensor:

blk.N.attn_q_a.weight        (6144 x 2048)       - Q LoRA 降维
blk.N.attn_q_a_norm.weight   (2048)               - Q LoRA 归一化
blk.N.attn_q_b.weight        (2048 x 16384)       - Q LoRA 升维
blk.N.attn_kv_a_mqa.weight   (6144 x 576)         - KV LoRA (MQA)
blk.N.attn_kv_a_norm.weight  (512)                - KV LoRA 归一化
blk.N.attn_k_b.weight        (192 x 512 x 64)     - K 投影
blk.N.attn_v_b.weight        (192 x 512 x 64)     - V 投影
blk.N.attn_output.weight     (16384 x 6144)       - 注意力输出投影
blk.N.attn_norm.weight       (6144)               - 注意力层归一化
blk.N.ffn_norm.weight        (6144)               - FFN 层归一化
blk.N.ffn_gate_inp.weight    (6144 x 256)         - MoE router
blk.N.ffn_gate_shexp.weight  (6144 x 2048)        - 共享专家 gate
blk.N.ffn_up_shexp.weight    (6144 x 2048)        - 共享专家 up
blk.N.ffn_down_shexp.weight  (2048 x 6144)        - 共享专家 down
blk.N.ffn_gate_exps.weight   (6144 x 2048 x 256)  - 256 个专家 gate
blk.N.ffn_up_exps.weight     (6144 x 2048 x 256)  - 256 个专家 up
blk.N.ffn_down_exps.weight   (2048 x 6144 x 256)  - 256 个专家 down
blk.N.indexer.attn_k.weight  (6144 x 128)         - DSA indexer K
blk.N.indexer.attn_q_b.weight (2048 x 4096)       - DSA indexer Q
blk.N.indexer.k_norm.weight  (128)                - DSA indexer K 归一化
blk.N.indexer.proj.weight    (6144 x 32)          - DSA indexer 投影

八、可能的修复方向

根据排查结果,我梳理了几条可能的修复路径。不一定对,但至少能缩小排查范围:

方向 1:检查 MSVC vs GCC 的浮点行为

MLA 的计算涉及 absorbed wk_b/wv_b 矩阵乘法和 YaRN mscale 缩放。如果 MSVC 和 GCC 在浮点舍入模式(round-to-nearest vs round-toward-zero)或 FMA(fused multiply-add)行为上有差异,可能导致数值发散。验证方法:在 Linux 上用 --fp32 强制 FP32 计算,看输出是否正确。

方向 2:检查 UD-Q4_K_XL 的 3D expert tensor 解包

3D expert tensor(shape=[6144, 2048, 256])的解包逻辑可能和标准 Q4_K 不同。Unsloth 的 Dynamic 量化可能在某些层使用了非标准的 block size 或 dequantization formula。验证方法:用标准 Q4_K_M 量化重新转换模型,看是否乱码。

方向 3:检查 Windows 上的 mmap 行为

Windows 的 mmap(CreateFileMapping / MapViewOfFile)和 Linux 的 mmap 在页面预取、缓存策略上有差异。如果 MLA 的计算依赖于特定的内存访问模式,Windows 的 mmap 行为可能导致权重加载顺序不同。验证方法:用 --no-mmap 强制全量加载(需要大量内存),看是否乱码。

方向 4:检查 DSA Indexer 的 Windows 兼容性

DSA Indexer 使用了 indexer.proj.weight(6144 x 32)做稀疏注意力的 token 选择。如果这个投影的计算在 Windows 上有数值问题,可能导致选错了 token,进而导致注意力输出损坏。验证方法:禁用 DSA Indexer(如果 llama.cpp 支持),改用标准注意力,看是否乱码。


九、求助

这个问题需要改 llama.cpp 的 C++ 代码,超出了我的能力范围。

我最想知道的一个问题:

llama.cpp 的 glm-dsa MLA 计算路径在 Windows(MSVC 编译)和 Linux(GCC/Clang 编译)上是否有数值行为差异?特别是 absorbed wk_b/wv_b 的矩阵乘法和 YaRN mscale 的缩放因子计算。

如果你有以下经验,恳请评论区指点:

  1. llama.cpp 的 MLA 实现细节 – absorbed wk_b/wv_b 的计算路径在 Windows 和 Linux 上有差异吗?
  2. GLM-5.2 的 glm-dsa 架构 – DSA indexer 的实现是否完整?和 DeepSeek-V2 的 MLA 有什么区别?
  3. UD-Q4_K_XL 量化格式 – Unsloth 的 Dynamic 量化和标准 Q4_K 在 tensor 布局上有区别吗?3D expert tensor 的解包逻辑正确吗?
  4. Windows 浮点行为 – MSVC 和 GCC/Clang 的浮点精度差异会影响 MLA 计算吗?

Issue 链接: llama.cpp #26027


十、排查过程中踩过的坑

如果你也在折腾 GLM-5.2 本地部署,这些信息可以帮你少走弯路:

常见疑问:

疑问 结论
词表里 0 个 CJK 字符是不是坏了? 不是,byte-level BPE 就是这样的,中文通过字节编码处理
blk.78 的 tensor 全是 unused 是不是加载出错了? 不是,NextN 草稿层暂不支持,不影响主模型
KTransformers 也乱码,是不是 KTransformers 的问题? 不是,底层也是 llama.cpp,同样的 bug
Ollama 能跑吗? 不能,Ollama 不支持多分片 GGUF

我试过的路(都走不通):

方案 结果
llama.cpp HIP 版 (-ngl 0) 乱码
llama.cpp 纯 CPU 版 (-ngl 0) 乱码
KTransformers v0.6.4 乱码
Ollama 不支持多分片

如果你也有 GLM-5.2 的调试经验,特别是 Windows 平台 的,恳请评论区交流。我目前卡在 llama.cpp 的 MLA 计算路径上,需要有 C++ 推理引擎开发经验的人帮忙看看。


十一、结论

项目 结果
64GB 能加载 753B 模型? 能(mmap,43GB 内存)
速度如何? 3 tok/s(LRU 缓存命中率约 95%)
输出正确吗? 乱码(glm-dsa MLA bug)
Tokenizer 有问题? 没有,byte-level BPE 验证通过
能修吗? 需要改 llama.cpp C++ 代码,见 #26027

给不同情况的建议:

  • 如果你想在 Windows 上跑 GLM-5.2:目前不行,等 llama.cpp 修复。别浪费时间重复排查。
  • 如果你在 Linux 上:CPU-only 模式(-ngl 0)可能可以,但 GPU offload 也有 bug。
  • 如果你想先跑个能用的:Qwen3.6 35B A3B(18GB,64GB 内存轻松跑)是目前性价比最高的选择。
  • 如果你懂 llama.cpp 的 MLA 实现:Issue #26027 需要你,评论区欢迎指点。

调试工具(GGUF 解析器、byte-level BPE tokenizer、词表对比脚本)需要的话评论区留言

最后,如果您觉得这篇博客对您有帮助,可否拜托各位看官点点赞和关注呢?谢谢您啦~

#GLM-5.2 #llama.cpp #MoE #大模型本地部署 #753B

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐