小芯片运行大模型的评估脚手架

1. 演示现场突然崩溃:Killed 信号背后的内存踩爆

在嵌入式开发板 RK3588 上跑 4-bit 量化的 Qwen1.5-1.8B 时,很容易被单次对话的顺畅表现蒙蔽。单句问答延迟不到半秒,字符打字机效果也很丝滑。

然而把测试场景切到连续多轮对话或者传入 2K Token 以上的长文本时,程序打到一半瞬间崩溃:

root@rk3588-board:~# ./llama-cli -m qwen1.5-1.8b-chat-q4_k_m.gguf -n 512 -p "请详细阐述 Linux 内核虚拟文件系统..."
加载模型成功,分配内存 1.1GB...
生成中...
Killed

没有堆栈报错,也没有 C++ 异常信息。控制台只有一个冰冷的 Killed

执行 dmesg -T | tail -n 25 查看内核日志,真相立刻浮出水面:

[Tue Aug 25 14:22:01 2026] Out of memory: Kill process 4102 (llama-cli) score 852 or sacrifice child
[Tue Aug 25 14:22:01 2026] Killed process 4102 (llama-cli) total-vm:2892100kB, anon-rss:1845120kB, file-rss:12400kB, shmem-rss:0kB
[Tue Aug 25 14:22:01 2026] oom_reaper: reaped process 4102 (llama-cli), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

嵌入式板卡总共只有 2GB 可用 SRAM/DRAM。模型权重文件静态占据了约 1.1GB,看似给系统留出了接近 900MB 的剩余空间。但实际上,由于 KV Cache(键值缓存)随着上下文长度呈线性爆发增长,叠加 C++ 运行时动态分配的临时 Tensor 空间,物理内存被瞬间拉爆,触发了内核的 OOM Killer。

单凭感觉评估边缘端 LLM 的能力不可靠。演示时的成功不代表生产环境的稳定,必须搭建一套能够实时抓取内存、吞吐与 Cache 命中的可复现评估脚手架。


2. 用 perf 与 smaps 分析 KV Cache 动态增长曲线

为了定量分析大模型在小芯片上的内存吃水线,不能只依赖 free -mtop 这类粗颗粒度的统计工具,因为它们的采样间隔通常在秒级,根本抓不到爆发性的内存分配峰值。

我们需要结合 Linux 内核的 /proc/[pid]/smaps 节点与 perf 事件。

编写诊断命令,监控进程在推理启动、上下文预热(Context Prefill)以及逐 Token 吐字(Decode)三个维度的物理内存(PSS/RSS)变化:

# 捕获 llama-cli 在推导过程中的内存细粒度分布
PID=$(pgrep llama-cli)
watch -n 0.1 "cat /proc/$PID/smaps 2>/dev/null | awk '/Rss:/{sum+=\$2} END {print \"Total RSS: \" sum \" KB\"}'"

同时,使用 perf 指令观察 TLB Miss 以及 Cache 缺失情况:

perf stat -e page-faults,L1-dcache-load-misses,L1-dcache-loads,tlb_flush \
  ./llama-cli -m qwen1.5-1.8b-chat-q4_k_m.gguf -c 2048 -n 128 -p "Benchmark test prompt..."

测试输出的指标暴露了极具价值的细节:

 Performance counter stats for './llama-cli -m qwen1.5-1.8b-chat-q4_k_m.gguf ...':

            24,102      page-faults              #   12.301 K/sec
     1,842,109,240      L1-dcache-load-misses    #   28.45% of all L1-dcache accesses
     6,475,201,980      L1-dcache-loads
               412      tlb_flush

       1.959281940 seconds time elapsed

在 Context Prefill 阶段,page-faults 频率极高,说明内存页在频繁进行物理分配与映射。当上下文窗口达到 1500 个 Token 时,KV Cache 占用的内存从初始的 40MB 飙升到了 420MB。这直接把留给系统其他进程的缓冲挤干了。


3. 边缘端 LLM 内存预配与 Token 采样推演图

问题的根本出在“未受限的上下文增长”与“未作预分配的内存池”。为了在边缘小芯片上让 LLM 稳定运行,脚手架必须具备两项硬性功能:

  1. 静态硬上限封顶:在模型初始化阶段就将 KV Cache 的最大空间一次性 Allocate 完成,禁止运行时动态再申请内存。
  2. 上下文滑窗丢弃:当 Token 长度达到预设闸门时,自动对历史对话进行 Slot 挤压或 Summarize,杜绝无限制膨胀。

4. 搭建自动化内存与 Token 吞吐压测脚手架

为了能够自动化量化不同量化参数(Q4_0, Q4_K_M, Q8_0)在芯片上的表现,我们构建了一个兼顾内存捕获与吞吐计算的 Python 评估脚手架。

该脚手架子进程启动推理引擎,使用 psutil 异步高频采样 RSS 内存峰值,同时计算 Prefill 速度(Tokens/s)与 Decode 速度(Tokens/s)。

import subprocess
import time
import psutil
import csv
import sys

def benchmark_edge_llm(cmd, timeout_sec=120):
    """
    在边缘板卡上对 LLM 推理程序进行内存与 Token 吞吐的自动化压测脚手架
    """
    print(f"[INFO] 开始压测指令: {' '.join(cmd)}")
    
    start_time = time.time()
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    
    ps_proc = psutil.Process(proc.pid)
    max_rss_bytes = 0
    rss_samples = []

    # 高频轮询采样物理内存 RSS
    while proc.poll() is None:
        try:
            rss = ps_proc.memory_info().rss
            if rss > max_rss_bytes:
                max_rss_bytes = rss
            rss_samples.append(rss)
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            break
        time.sleep(0.05) # 50ms 采样精度

    stdout, stderr = proc.communicate()
    elapsed_time = time.time() - start_time

    if proc.returncode != 0:
        print(f"[ALERT] 推理异常退出,Exit Code: {proc.returncode}")
        print(f"[ALERT] Stderr 输出: {stderr}")
        return None

    # 解析 llama-cli 标准输出中的性能指标
    eval_tokens = 0
    tps = 0.0
    for line in stderr.splitlines():
        if "eval time =" in line:
            # 解析形如: llama_print_timings: eval time = 4500 ms / 128 runs (35.15 ms per token, 28.44 tokens per second)
            parts = line.split(",")
            for p in parts:
                if "tokens per second" in p:
                    tps = float(p.strip().split()[0])

    max_rss_mb = max_rss_bytes / (1024 * 1024)
    print(f"[RESULT] 内存峰值 RSS: {max_rss_mb:.2f} MB")
    print(f"[RESULT] 解码吞吐速度: {tps:.2f} Tokens/s")
    print(f"[RESULT] 总运行时间: {elapsed_time:.2f} s")

    return {
        "max_rss_mb": max_rss_mb,
        "tps": tps,
        "elapsed_sec": elapsed_time
    }

if __name__ == "__main__":
    test_cmd = [
        "./llama-cli",
        "-m", "qwen1.5-1.8b-chat-q4_k_m.gguf",
        "-c", "1024",
        "-n", "256",
        "-p", "用 100 字介绍操作系统的分页机制。"
    ]
    res = benchmark_edge_llm(test_cmd)
    if res and res["max_rss_mb"] > 1600:
        print("[WARN] 内存占用已接近边缘板卡警戒线,建议下调上下文长度 -c 参数!")

5. 避免基准测试虚高:上下文窗口的硬性隔离设置

拿到压测脚手架的数据后,还需要避免评估陷入“指标陷阱”。很多演示场景之所以看起来速度极快,是因为将上下文窗口(Context Window)设为了默认的 256 或 512。这在实际生产问答中根本不可用。

在编译 llama.cpp 或 NCNN 推理引擎时,必须显式配置编译参数与运行时锁死标志,封死动态内存追加的路径:

# 在 cmake 构建时针对低内存设备加入硬性隔离参数
cmake -B build \
  -DLLAMA_FATAL_WARNINGS=ON \
  -DGGML_DISABLE_DYNAMIC_ALLOC=ON \
  -DCMAKE_BUILD_TYPE=Release

cmake --build build --config Release -j4

运行时通过配置文件固定硬限制参数:

# edge_llm_config.ini
[memory]
# 锁死最大 KV Cache 为 1024 帧,防止长文本拖垮系统
max_context_tokens = 1024
# 启动即一次性预分配完整张量池,分配失败则立即报错退出,拒绝运行时动态扩容
preallocate_tensor_pool = true
# 强制开启内存锁定,防止系统 Swap 交换导致卡顿
mlock = true

把脚手架接入 Jenkins 或 GitHub Actions 的自托管 Runner 板卡上。每次提交新的模型量化权重文件或 C++ 引擎修改时,自动运行脚手架。如果 RSS 峰值突破了板卡物理内存的 80%,构建立即熔断。这样就能在开发阶段打绝 OOM 故障,避免把“跑不动”的盲目自信带到线上演示现场。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐