小芯片推理的并发边界
1. 边缘板卡并发一高,OOM Killer 就直接拔电源
把 1.5B 到 7B 规模的小语言模型(SLM,如 Qwen-1.5B-Chat INT4 量化版)移植到 RK3588 或 Jetson Orin Nano 这类 8GB/16GB 内存的边缘开发板上,单次 Prompt 测试通常非常顺利。推理耗时也能控制在 20 Token/s 左右,看似达到了上线标准。
然而一旦把设备放到真实测试环境,接入 5 个边缘 Sensor 或 HTTP 并发请求,噩梦就发生了。Linux 内核的 OOM Killer 会在毫无预兆的情况下弹出来,直接把推理进程杀掉。
# 查看 /var/log/messages 或 dmesg 中的 OOM Killer 杀死边缘推理进程日志 $ dmesg -T | grep -E -i "(oom|killed process)" [Sun Aug 24 14:22:05 2026] Out of memory: Kill process 32104 (llama_infer_node) score 852 or sacrifice child [Sun Aug 24 14:22:05 2026] Killed process 32104 (llama_infer_node) total-vm:14820116kB, anon-rss:7892100kB, file-rss:1240kB # 使用 numactl 和 systemd-cgtop 查看边缘侧 cgroup 内存占用 $ systemd-cgtop Control Group Tasks %CPU Memory Input/s Output/s /system.slice/edge-llm.service 14 385.2 7.6G - -问题出的很干脆:边缘小芯片没有云端 A100 那样动辄 80GB 的显存容量,统一内存架构(UMA)下 CPU 与 NPU/GPU 共享同一片物理 DRAM。当并发请求增加时,KV Cache(键值缓存)会随着上下文长度线性爆满。缺乏背压(Backpressure)机制的推理引擎,会在内存耗尽前一瞬间依然接纳新请求,直接触发内核保护机制。
2. KV Cache 的精确容量算账:别用感觉估计 SRAM
在写代码控制背压之前,必须算清楚边缘小芯片上每一字节内存的用途。
以 Qwen-1.5B 4-bit 量化模型为例:
- 模型权重静态占用:约 1.1 GB(4-bit 量化后)。
- 运行时框架上下文与 Scratchpad 内存:约 512 MB。
- KV Cache 动态内存:这是导致 OOM 的主凶。
KV Cache 的单 Token 内存开销计算公式如下:
$$\text{Memory}_{\text{per_token}} = 2 \times \text{Layers} \times \text{Heads} \times \text{Head_Dim} \times \text{BytesPerElement}$$
针对 Qwen-1.5B(28 层,12 个 Query 头/2 个 Key-Value 头 即 GQA,Head Dim 128,FP16 存储):
$$\text{Memory}_{\text{per_token}} = 2 \times 28 \times 2 \times 128 \times 2 \text{ bytes} = 28,672 \text{ bytes} \approx 28 \text{ KB}$$
如果一个请求上下文长度(Context Length)达到 4096,单个请求的 KV Cache 就需要:
$$4096 \times 28 \text{ KB} \approx 114.6 \text{ MB}$$
如果有 10 个并发请求,KV Cache 就要吃掉 1.14 GB 连续内存。对于总共只有 8GB 且还需要运行 Linux 系统和摄像头图像抓取服务的小板子来说,这种内存暴涨是致命的。在工程设计中,必须硬性规定物理容量上线,把预留的物理内存分块成固定大小的 Slot,绝不允许动态std::vector::push_back式的扩展。
3. 环形 Token 缓冲区与基于 Token 速率的动态背压闸门
为了在资源受限板卡上守住不挂机底线,背压控制必须做到两点:
- 静态预分配 Slot 机制:系统初始化时直接在 DRAM 中申请固定上限的 KV Cache 槽位(例如只允许最多 4 个并发 Slot,每个 Slot 锁定 Max Context=2048)。
- 基于 Token 算力速率的拒绝闸门:当 NPU 的 Token 吞吐速率(Token/s)低于请求输入速率时,实时拒绝新请求。
以下是边缘侧背压控制器的核心状态机实现逻辑:
#include <iostream> #include <atomic> #include <mutex> #include <chrono> class EdgeBackpressureController { public: struct Config { size_t max_concurrent_slots; // 最大并发槽位数 (如 4) size_t max_context_per_slot; // 单槽位最大 Context 长度 (如 2048) size_t total_memory_budget_mb;// 内存预算硬指标 (如 2048MB) }; explicit EdgeBackpressureController(Config cfg) : config_(cfg), active_slots_(0) { // 计算静态预留的 KV Cache 总需求 size_t single_token_bytes = 2 * 28 * 2 * 128 * 2; // Qwen-1.5B GQA size_t total_kv_needed_mb = (config_.max_concurrent_slots * config_.max_context_per_slot * single_token_bytes) / (1024 * 1024); std::cout << "[INIT] KV Cache 预留内存: " << total_kv_needed_mb << " MB\n"; if (total_kv_needed_mb > config_.total_memory_budget_mb) { throw std::runtime_error("配置失败:KV Cache 预留超出边缘物理预算!"); } } // 尝试获取推理 Slot bool try_acquire_slot(size_t prompt_tokens) { std::lock_guard<std::mutex> lock(mtx_); // 防线 1:并发槽位超限直接拒绝 if (active_slots_ >= config_.max_concurrent_slots) { std::cerr << "[REJECT] 并发槽位已满 (" << active_slots_ << "/" << config_.max_concurrent_slots << "), 拒绝请求\n"; return false; } // 防线 2:Prompt 长度超出单槽位 Max Context 直接拒绝 if (prompt_tokens > config_.max_context_per_slot) { std::cerr << "[REJECT] Prompt 长度 " << prompt_tokens << " 超出最大限制 " << config_.max_context_per_slot << "\n"; return false; } active_slots_++; std::cout << "[ACCEPT] 请求通过,当前激活 Slot 数: " << active_slots_ << "\n"; return true; } void release_slot() { std::lock_guard<std::mutex> lock(mtx_); if (active_slots_ > 0) { active_slots_--; std::cout << "[RELEASE] 槽位释放,当前激活 Slot 数: " << active_slots_ << "\n"; } } private: Config config_; size_t active_slots_; std::mutex mtx_; };4. 压测现场与容量防线底线总结
背压逻辑写入模块后,必须在真实嵌入式板卡上使用高并发压测工具(如vegeta或wrk)配合gdb进行极端验证。
配置 8 个并发 HTTP 客户端,以 10 Req/s 的速率强刷边缘 SLM 服务端口:
# 启动压测脚本 $ vegeta attack -targets=targets.txt -rate=10 -duration=30s | vegeta report # 压测期间观察端侧控制台日志输出 [ACCEPT] 请求通过,当前激活 Slot 数: 1 [ACCEPT] 请求通过,当前激活 Slot 数: 2 [ACCEPT] 请求通过,当前激活 Slot 数: 3 [ACCEPT] 请求通过,当前激活 Slot 数: 4 [REJECT] 并发槽位已满 (4/4), 拒绝请求 [REJECT] 并发槽位已满 (4/4), 拒绝请求 [RELEASE] 槽位释放,当前激活 Slot 数: 3 [ACCEPT] 请求通过,当前激活 Slot 数: 4压测报告显示,被拒绝的请求返回了标准的429 Too Many Requests,而系统内存(RSS)自始至终稳定挂在 2.8 GB 附近,未触发一次 OOM Killer,核心推理线程 P99 延迟稳定在 450ms(Prefill 阶段)与 45ms/Token(Decode 阶段)。
要在小芯片上守住大模型运行的底线,死记这三条法则:
- 彻底放弃动态内存扩充:在程序初始化时便根据硬件 RAM 划清上限,用静态池替代运行时的任何
malloc/new。 - 优先丢弃请求,绝不拖垮系统:边缘设备的防线第一原则是可用性,宁可优雅返回 429 报错,也不能把整台板卡打挂。
- Prompt 长度强制截断:长 Context 是内存杀手,在端侧网关入口就必须强行做 Token 截断,把风险阻断在推理 Engine 之外。