OPEN SOURCE DEEP DIVE
NInfer:单张 5090 上从零写的 C++/CUDA 推理引擎
从零实现的 C++/CUDA 推理引擎,只服务五个注册 Qwen 权重、只跑一张 RTX 5090:MTP/DFlash 投机解码、五种 KV 存储、24 万 token 上下文与 Device/Host 前缀检查点复用,C=8 聚合解码 1,146.9 tok/s。
从零写起:只为一张 5090 服务的推理引擎
NInfer 是一套完全从零实现的 C++/CUDA 推理引擎,服务对象是五个显式注册的 Qwen 权重,运行硬件限定为单张 NVIDIA GeForce RTX 5090(32 GiB)。它通过本地 CLI 或兼容 OpenAI / Anthropic 的 HTTP 接口处理文本、图像与视频提示。这里没有 PyTorch 运行时,也不是把上游引擎裁剪一遍:算子、KV 分配器、调度器、tokenizer 与对话模板前端、HTTP 协议层,全部是同一棵 C++20 构建树里的第一方代码。
这种特化是明写在 README 里的:一张 GPU、一个常驻模型、启动时固定的 1 到 8 路并发容量。构建阶段同样不留退路——除 sm_120a 以外的 CUDA 架构直接被拒,仓库也没有 install 目标和打包发行版,只能从源码构建树里直接跑。引擎拒绝做的每一件事,都是拿单卡吞吐换来的。
五个 artifact 身份,权重与模型一次锁定
一个 artifact 身份同时锁定具体模型和权重方案。每个 .ninfer 文件还内嵌了目标模型所需的 tokenizer、对话模板与媒体前端资源,运行时不需要再回头找 Hugging Face。快速上手示例用的是 Qwen3.8-27B NVFP4。
| 模型 | 权重方案 | artifact |
|---|---|---|
| Qwen3.6-27B | groupwise-int | qwen3_6_27b.ninfer |
| Qwen3.6-27B | nvfp4 | qwen3_6_27b_nvfp4.ninfer |
| Qwen3.8-27B | groupwise-int | qwen3_8_27b.ninfer |
| Qwen3.8-27B | nvfp4 | qwen3_8_27b_nvfp4.ninfer |
| Qwen3.6-35B-A3B | groupwise-int | qwen3_6_35b_a3b.ninfer |
已发布的 artifact 派生自 Qwen/Qwen3.6-27B、Qwen/Qwen3.8-27B 与 Qwen/Qwen3.6-35B-A3B(均为 Apache-2.0);两个 NVFP4 变体还分别使用了 rdtand/Qwen3.6-27B-PrismaSCOUT-Blackwell-NVFP4-BF16-vllm 的定点打包权重与 unsloth/Qwen3.8-27B-NVFP4 的 FP8/NVFP4 混合权重。
并发解码:单卡 1,146.9 tok/s
饱和解码的测量条件是 INT8 group-64 KV、CUDA Graph、MTP3,每路请求生成 8,192 token。表中数值是"聚合已提交解码吞吐 + MTP 接受率",只取实际解码批大小等于配置并发度的完整区间。
| 模型方案 | C=1 tok/s / 接受率 | C=2 | C=4 | C=8 | C8 / C1 |
|---|---|---|---|---|---|
Qwen3.6-27B groupwise-int | 185.8 / 68.2% | 247.0 / 69.0% | 309.5 / 68.4% | 535.0 / 68.3% | 2.88× |
Qwen3.6-27B nvfp4 | 202.4 / 69.3% | 399.7 / 71.4% | 699.7 / 69.3% | 1,146.9 / 68.6% | 5.67× |
Qwen3.6-35B-A3B groupwise-int | 593.0 / 67.2% | 877.7 / 68.2% | 1,166.0 / 69.8% | 1,313.8 / 67.3% | 2.22× |
Qwen3.8-27B nvfp4 | 143.8 / 48.9% | 267.6 / 48.1% | 461.1 / 45.8% | 766.6 / 46.0% | 5.33× |
MoE 那一路是吞吐异常值:Qwen3.6-35B-A3B 每 token 只激活约 3B 参数,C=1 就有 593.0 tok/s,C=8 达到 1,313.8 tok/s。接受率的落差同样值得注意——Qwen3.6 系列稳定在 67-71%,Qwen3.8 只有 46-49%,这正是新模型绝对解码速度反而更低的原因,尽管它才是快速上手示例的主角。
串行单请求服务用同一套 INT8 group-64 KV 与 CUDA Graph,1,024 token 的 prefill 分块,预热后跑五个固定随机种子:
| 模型方案 | 7,680 token prefill | 260,096 token prefill | 结构化输出 MTP3 解码 |
|---|---|---|---|
Qwen3.6-35B-A3B groupwise-int | 15,544.3 tok/s | 5,157.1 tok/s | 770.9 tok/s |
Qwen3.6-27B groupwise-int | 3,218.1 tok/s | 1,614.8 tok/s | 193.0 tok/s |
Qwen3.6-27B nvfp4 | 11,191.5 tok/s | 2,510.6 tok/s | 252.2 tok/s |
Qwen3.8-27B groupwise-int | 3,274.7 tok/s | 1,609.7 tok/s | 224.4 tok/s |
Qwen3.8-27B nvfp4 | 8,340.4 tok/s | 2,203.1 tok/s | 219.8 tok/s |
反直觉的结论:C=8 时"更小的权重"打赢"更快的权重"
另有一组 makespan(整批耗时)实验:完整投机解码语料(3 个长推理 fixture + 12 个代码/故事/翻译/结构化 fixture,每个 5 个固定种子,共 75 个请求),每个并发度都用同一份以种子 20260811 打乱过的发送顺序,从客户端全部放行计时到最后一个 HTTP 响应读完为止。结果推翻了"NVFP4 一定更快"的直觉:
- groupwise-int:C=1 到 C=8 的 makespan 从 4,622.59 s 降到 2,211.20 s,加速 2.09×,平均批大小升到 4.76,C=8 是最优点。
- nvfp4:C=4 时 1,647.74 s(2.83×)为最优,C=8 反而回退到 2,164.90 s,平均批大小只有 2.36。
原因是显存而不是算子。groupwise-int 的权重占用是 16.672 GiB,NVFP4 是 19.729 GiB;到 C=8 时更轻的方案能留下 313,984 token 的 Device KV,而不是 187,712 token——缓存几乎翻倍,可达批大小也就几乎翻倍。groupwise-int 的 --kv-capacity auto 在 C=1/2/4/8 分别解析为 131,072 / 262,144 / 341,952 / 313,984 token。两组实验各 300 个请求全部完成,没有请求失败、CUDA 错误或显存溢出;C=8 最多出现 4 个排队请求,未发生一次 spill、owner 降级/驱逐或搜索穷尽事件。
24 万 token 上下文,以及扛得住显存压力的前缀检查点
一个可复用的前缀检查点保存的是某条精确提示词边界上的 KV 加上完整的续写状态。驻留在 Device 上的检查点可以直接续跑;显存吃紧时,规划器会按"立即恢复的代价 + 之后复用的收益"权衡 Device 保留、pinned Host State/KV 与驱逐,而在跑的请求始终保留自己的完成额度。官方推荐的长上下文服务配置把这些层级写得很明白:
./build/apps/ninfer-serve models/qwen3_8_27b_nvfp4.ninfer \
--max-context 240000 \
--kv-capacity 240000 \
--max-concurrency 2 \
--kv-dtype fp8 \
--device-state-slots 2 \
--host-state-slots 8 \
--host-kv-mib 8192 \
--spec mtp --draft-tokens 3 \
--lm-head-draft \
--preserve-thinking
每个请求的逻辑上限是 240,000 token;共享的 240,000 token Device KV 池服务所有已准入请求,单独跑时一个请求可以吃满整池。除两个活跃 StateImage 之外,进程还持有 2 个 Device 检查点槽、8 个 pinned Host State 槽与 8 GiB pinned Host KV。--max-context 是单序列逻辑上限,--kv-capacity 决定共享 Main Text KV 池的大小(活跃请求与保留前缀共用),auto 则在启动时用扣掉权重后的剩余显存解出最大合法容量,并留 1 GiB 的分配余量;显式容量在进程生命周期内不变。tools/bench/ttft 压测工具覆盖热复用、Host 恢复、驱逐、共享前缀、调度边界与多模态负载,走的都是对外 HTTP 路径。
投机解码的常驻形态是启动期决策
GPU 常驻在 Engine 启动瞬间冻结,相关开关不是懒加载:没开 Vision 的 Engine 会拒绝媒体输入,之后也无法再打开。
--spec mtp:草稿窗口 1 到 5 个位置;已发布成绩用的是 MTP3(--draft-tokens 3 --lm-head-draft)。--spec dflash:仅 35B-A3B 目标支持,草稿窗口 1 到 15;实测点是块长 8(--draft-tokens 7)与原生满块 15。MTP 与 DFlash 不能同时开启。--lm-head-draft:加载优化后的 proposal head;若用完整 proposal head 则不加载它。--vision:加载 Vision 权重并把唯一的 Program workspace 扩到能跑 encode/handoff;默认关闭,同时省掉 Vision 专属的 unified-workspace 空间。
DFlash 可以和 Vision 一起开,但它加速的是多模态 prefill 之后的文本解码,不加速 Vision encode 本身。不带 --spec 时,MTP/DFlash 权重、状态与优化 proposal head 一概不加载,这就是最小常驻配置的来源。单请求 CLI 还额外跑在 root-only context 模式下,因此不会预留一个后续请求根本用不上的 Device 检查点 StateImage。
五种 KV 存储格式,一条分块 prefill 路径
所有注册模型 ID 都支持 BF16、INT8、FP8、NVFP4、K8V4 五种 KV 存储;prefill 分块可配(128 的整数倍,默认 1,024);解码走精确批大小的 CUDA Graph 且批规模在启动时封顶;另有离线因果困惑度打分、私有与共享的精确前缀复用(Device/Host 双层 State 与 KV 保留)、以及模型感知的采样默认值和显式采样器覆盖。所有已发布测量都用 INT8 group-64 KV。
接口面不大,但契约写得很死
| 方法与路径 | 行为 |
|---|---|
GET /health | Engine 就绪状态;全局故障后返回 503;无鉴权;队列饱和不算不可用 |
GET /v1/models、/v1/models/{id} | 配置的 OpenAI 别名与生效的 max_model_len |
POST /v1/chat/completions | OpenAI 风格对话生成,支持流式与非流式 |
POST /v1/responses | OpenAI Responses Core 生成、本地状态、类型化 Items、SSE |
POST /v1/responses/input_tokens | 只数提示词 token,不生成 |
GET/DELETE /v1/responses/{id}、/input_items | 读取、删除本地存储的 Response,或列出其归一化输入 Items |
POST /v1/messages | Anthropic 风格消息生成 |
POST /v1/messages/count_tokens | 按 checkpoint 原生展开的输入 token 计数 |
作为一个个人项目,传输层细节规定得相当到位:每个 OpenAI 兼容响应(含流式与错误响应)都带唯一 x-request-id;三个生成型 SSE 端点在 5 秒没有协议事件时发一条 : keep-alive 注释;Linux 上已接受的连接启用 TCP keepalive 与 15 秒 TCP_USER_TIMEOUT,因此对端死掉或不再确认时通常在约 20 秒内被取消,请求还在排队或 prefill 阶段也算。
能力处理走严格路线而不是宽松兼容。凡引擎无法提供其可观测行为的选项,一旦被真正请求就会被拒绝:JSON 约束输出、非零 logit_bias、要求 log probabilities、音频/文件输入或音频输出、strict:true、required 或指名 tool choice、启用工具时的 parallel_tool_calls:false、显式 low/high 图像 detail、web search、moderation、low/high verbosity、存储式 Chat Completions、非空的 legacy functions。已知的约束解码别名(grammar、structured_outputs、guided_json、guided_regex、guided_choice、guided_grammar)同样明确拒绝,而不是当成未知提示悄悄忽略。语义中性的字段则照收不改行为:全零 logit_bias、logprobs:false、top_logprobs:0、verbosity:"medium"、prediction,以及 metadata、用户/安全标识、service tier 与 prompt cache 提示;未知顶层字段直接忽略。每条拒绝都会点明受影响的字段和 NInfer 给不出的那条保证。
工具调用会被解析并返回给客户端,NInfer 自己不执行工具。思考模式默认开启,--reasoning-effort low|medium|xhigh 只在内嵌对话模板暴露该维度时生效;--thinking-budget N 给模型自产思考 token 设正上限:若在该边界上模型还没吐出 </think>,引擎会不采样地追加 Qwen 官方的提前收尾提示与闭合标记,经 reasoning 流对外发布,然后从更新后的上下文继续正常生成;边界上如果先遇到自然闭合、停止条件、取消或总量上限,则以它们为准,不插入。
能力分数是走服务路径测出来的
能力评测全程走 NInfer 自己的 OpenAI 兼容服务路径,开启思考、用 MTP3,工具是 EvalScope 1.9.0,0-shot 规则打分、每题一个样本——也就是说这些数字描述的是实际交付路径,不是另一套评测脚本。
| 模型方案 | AIME 2025 | AIME 2026 | GPQA-Diamond | ERQA | RealWorldQA |
|---|---|---|---|---|---|
Qwen3.6-27B groupwise-int | 86.67% | 93.33% | 86.87% | - | - |
| Qwen3.6-27B NVFP4 | 93.33% | 93.33% | 84.34% | - | - |
Qwen3.6-35B-A3B groupwise-int | 90.00% | 90.00% | 85.35% | - | - |
Qwen3.8-27B groupwise-int | 96.67% | 96.67% | 87.37% | 66.25% | 82.22% |
| Qwen3.8-27B NVFP4 | 96.67% | 96.67% | 90.40% | 66.25% | 83.53% |
采样参数按代际区分:Qwen3.6 各行用 temperature 0.6 + presence penalty 1.0,Qwen3.8 各行用 temperature 1.0 + presence penalty 0.0。多模态评测开 --vision 并把上下文限制在 81,920 token;文本评测用 262,144 token,只有 Qwen3.8-27B NVFP4 因为要在这张 5090 上装下权重而降到 252,928 token。每题一个样本,具体对/总题数与评测说明写在各自的 model card 里。
它明确不做的事
- 每个 Engine 只用一张 RTX 5090、一个常驻模型:没有多卡,没有分布式服务,没有权重 offload。
- 并发容量启动时固定在 1 到 8 路,入口是有界 FIFO;没有请求抢占,没有优先级/QoS,没有活跃请求换入换出。
- 活跃请求与保留前缀共用一个启动时定容的 KV 池。
- 不做运行时模型发现,未注册的 checkpoint 没有回落路径。
- 解析出的工具调用交回客户端,进程内不执行工具。
- 树内的 C++ 头文件不作为已安装 SDK 分发。
构建、运行与容器化
依赖清单:64 位 Linux、RTX 5090、CUDA Toolkit 13.1 及以上、CMake 3.28 及以上、C++20 宿主编译器、Ninja、pkg-config、FFmpeg 开发库(libavformat ≥ 60、libavcodec ≥ 60、libavutil ≥ 58、libswscale ≥ 7)与 libcurl ≥ 7.85。测试、基准与维护者工具默认不参与构建。
git clone https://github.com/Neroued/ninfer.git && cd ninfer
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
hf download neroued/Qwen3.8-27B-nvfp4-NInfer \
qwen3_8_27b_nvfp4.ninfer --local-dir models
./build/apps/ninfer models/qwen3_8_27b_nvfp4.ninfer \
--prompt "解释 prefill 与 decode 的区别,然后给出简短结论。" \
--max-context 32768 --max-new 8192 \
--kv-dtype fp8 --spec mtp --draft-tokens 3 --lm-head-draft
答案正文写 stdout;启动诊断以及 CLI 自带的推理内容、耗时、吞吐、显存与投机解码报告写 stderr,且都是不带前缀的产品输出,所以 > answer.txt 2> run.log 能把两者干净分开。终端下权重物化只有一行瞬态进度加一段紧凑的 Engine-ready 摘要,重定向到文件时则是不含回车与 ANSI 转义的可读进度。仓库提供 Dockerfile,装好 NVIDIA Container Toolkit 后 docker build --tag ninfer:local . 即可,容器里跑的是同一条服务命令,模型目录只读挂载。文档覆盖 CLI、HTTP 服务、性能方法论、困惑度评测与资源调度/上下文缓存算法,另有提交进仓库的文本、图像、视频、混合媒体、思考、长解码与长上下文示例。NInfer 采用 Apache-2.0,是维护者的个人兴趣项目,可通过 Ko-fi 自愿支持,不附带任何服务、功能承诺或路线决策权。