[HER Hack-Astron #5] Spark-X2.5-4B Q4_K_M 在无独显 Linux CPU(8核/16G)上的真实编码、吞吐与 OOM 边界

#3
by ljmodel - opened

把 Spark-X2.5-4B 放到一台 没有 NVIDIA、15GiB 内存被其他进程挤占 的 Linux 上,用官方 Spark 架构的 llama.cpp 跑通 Q4_K_M。结论很短:中文事实问答能对;默认 thinking 会把生成预算吃光;关掉 thinking 能吐代码,但正则分组写错,实测 FAIL;滑动窗口会掩盖长上下文的 KV 成本,--swa-full -c 131072 会把 16G 机器打进内核 OOM。

官方 BF16 GGUF(约 8.23GB)没有加载。本案例只用社区 Q4_K_M。


1. 真实使用:revision 与产物

实际加载 abenzerps/Spark-X2.5-4B-GGUF / Spark-X2.5-4B-Q4_K_M.gguf
Hub commit c3235f9779664f2a78c209d077dcab6f9bd6ff61
文件大小 2,600,223,552 bytes
SHA256 7934660bfc5b9bf04be0a0ac6179a1d16e1d4331b448857c86b8b2801b3ef72c
GGUF arch spark2_5
未加载 XHToken/Spark-X2.5-4B-GGUF BF16 80e7e595857290d80f93fd1c85e5bebfed02dc45(Hub 记录 8,229,920,352 bytes)
runtime XHToken/llama.cpp a698f1cc3252597a541bc1fdd2a9975184ae1684llama-cli 0.1.2-dev b1-a698f1c)
构建 cmake -S . -B build -DGGML_CUDA=OFF
精度/量化 Q4_K_M,mmap,无 mlock,-ngl 0

下载:

hf download abenzerps/Spark-X2.5-4B-GGUF Spark-X2.5-4B-Q4_K_M.gguf \
  --local-dir ./models

未上传任何 safetensors / GGUF 权重。


2. 环境

  • Linux 6.12.94+ x86_64,8× Intel Xeon(1 thread/core),无 NVIDIA
  • RAM 15GiB + swap 15GiB,实验时已被其他进程占用一部分(共享机器,计费:免费)
  • cmake 3.31.6,g++ 14.2.0
  • llama-cli 提示:no usable GPU found, --gpu-layers option will be ignored

必须 --fit off 并显式 -c。模型原生 ctx 是 1,048,576,默认 --fit on 会把上下文撑爆。

采样按模型卡:temperature=1.0, top_p=0.95。卡上 top_k=-1,本 CLI 合法值是 --top-k 0(disable),不能传 -1

TTFT: llama-cli 不打印首 token 毫秒。下面速度一律是 UI 行 [ Prompt: X t/s | Generation: Y t/s ] + 墙钟。峰值 RSS 来自 /proc/<pid>/status 的 VmRSS;mmap 下 RSS 含文件页,不是稳定的“权重大小”。


3. 实验 1:官方示例句(真实运行)

输入:

安徽的省会在哪里?

公共命令:

llama-cli -m Spark-X2.5-4B-Q4_K_M.gguf \
  -p '安徽的省会在哪里?' \
  -c 512 -b 128 -ub 64 \
  --temp 1.0 --top-p 0.95 --top-k 0 \
  --jinja -cnv --single-turn --reasoning on \
  --perf --no-display-prompt --color off \
  -ngl 0 --load-mode mmap --threads 4 --threads-batch 4 \
  --fit off --no-warmup
次数 n_predict thinking 墙钟 s Prompt t/s Gen t/s 峰值 RSS 结果
warmup 32 off 7.17 30.0 6.9 未采样 合肥(被 n=32 截断)
m1 128 on 21.67 29.7 6.7 ~4.98 GiB thinking 吃光预算,终答只剩「安徽省」
m2 256 on 38.51 36.3 6.5 ~4.93 GiB 合肥市
m3 256 on 34.54 33.4 5.8 ~4.94 GiB 合肥市

样本量:1 次 warmup + 3 次测量。Q4_K_M,-c 512,4 threads,mmap。生成约 5.8–6.9 tok/s,短 prompt 评估约 30–36 tok/s。无 TTFT ms。

m2 原始输出(节选,未改写):

> 安徽的省会在哪里?

[Start thinking]

我们需要回答用户的问题:“安徽的省会在哪里?”这是一个关于中国地理的问题。

首先,我需要确认安徽省的省会。安徽省的省会是合肥。
...
[End thinking]

安徽省的省会是**合肥市**。

合肥市是安徽省的省会,也是全省的政治、经济、文化、科教和交通中心,位于安徽省中部的江淮之间。

[ Prompt: 36.3 t/s | Generation: 6.5 t/s ]

4. 实验 2:真实编码(执行了,不是只看了一眼)

输入:

写一个纯 Python 3 函数 parse_access_log(line: str) -> dict | None,解析 Nginx combined 日志行,返回 ip, method, path, status, bytes。无效行返回 None。只输出代码,不要解释。然后我会用这三行测:
1.2.3.4 - - [01/Sep/2026:10:00:00 +0000] "GET /health HTTP/1.1" 200 12 "-" "curl/8.0"
bad line
10.0.0.1 - - [01/Sep/2026:10:00:01 +0000] "POST /api/v1/orders HTTP/1.1" 201 84 "-" "okhttp/4.12"

4a. --reasoning on-n 800-c 2048:没有代码

墙钟 233.4 s,Prompt 22.8 t/s,Generation 3.8 t/s。800 token 全部耗在 [Start thinking],没有 [End thinking],没有函数。

失败模式: 默认 thinking + 不大的 -n,会把用户可见答案饿死。

4b. --reasoning off-n 400:有代码,测试 FAIL

墙钟 47.5 s,Prompt 26.4 t/s,Generation 6.2 t/s。模型原始代码(未打补丁):

import re

def parse_access_log(line: str) -> dict | None:
    pattern = r'^(\S+) \S+ \S+ \[([^\]]+)\] "((\S+) (\S+) HTTP/\d\.\d)" (\d+) (\S+)'
    match = re.match(pattern, line)
    if not match:
        return None
    
    ip, timestamp, method, path, protocol, status, bytes_str = match.groups()
    try:
        return {
            'ip': ip,
            'method': method,
            'path': path,
            'status': int(status),
            'bytes': int(bytes_str)
        }
    except (ValueError, TypeError):
        return None

实测:

CASE 1 pass=False got={'ip': '1.2.3.4', 'method': 'GET /health HTTP/1.1', 'path': 'GET', 'status': 200, 'bytes': 12}
CASE 2 pass=True  got=None
CASE 3 pass=False got={'ip': '10.0.0.1', 'method': 'POST /api/v1/orders HTTP/1.1', 'path': 'POST', 'status': 201, 'bytes': 84}
OVERALL FAIL

原因:请求字符串外多包了一层捕获组,groups() 解包把整段 request 赋给了 method。无效行正确地返回了 None。ip / status / bytes 是对的。

编码题:FAIL。


5. 实验 3:边界(按验收标准必须写失败)

Spark GGUF 带 spark2_5.attention.sliding_window。默认 不开 --swa-full 时,llama.cpp 不会为 32k 上下文准备满 KV。所以:

ctx --swa-full 峰值 RSS 结果
8192 ~3.57 GiB 生成成功
32768 ~3.73 GiB 生成成功
32768 ~7.03 GiB 生成成功,系统 swap 几乎打满
131072 HWM ~11.67 GiB + ~9.5 GiB 进程 swap 失败

失败命令:

llama-cli -m Spark-X2.5-4B-Q4_K_M.gguf -p ping -n 4 -c 131072 --swa-full \
  --load-mode mmap --fit off --no-warmup ...

卡在 Loading model...,没有生成任何 token。110s timeout 后进程仍占约 11.8 GiB RSS。随后内核 OOM:llama-clioom_score_adj=-1000,kernel 杀掉了其他 Chrome 进程(oom_score_adj=300)。这是有害副作用,记在这里,不是成功案例。


6. 限制与建议

  1. 官方 8.23GB BF16 GGUF 不适合这台 16G 共享盒;Q4_K_M 才能真正跑起来。
  2. thinking 默认开启时,给编码任务留足 -n,或者显式 --reasoning off
  3. 不要把「-c 32k 也能跑」理解成满 KV 的 32k。先确认 --swa-full
  4. 在 16G 机器上不要对 4B Q4 开 --swa-full + 巨大 -c
  5. llama-cli 的 --top-k -1 无效,用 0
  6. 社区量化 ≠ 官方 BF16 比特一致。本帖只对 Q4_K_M + 该 llama.cpp commit 负责。

7. 对后来者

最小可复现路径:XHToken/llama.cpp CPU 构建 + abenzerps Q4_K_M + --fit off + 显式 -c。中文常识问答在 ~6 tok/s 可用;把它当编码 agent 前,先关 thinking 并 跑测试,不要只看它会不会写函数。

Sign up or log in to comment