为什么我们选择自研 C 和 C++ 推理引擎
Why we write our own C and C++ inference engines

原始链接: https://localai.io/blog/why-we-write-our-own-engines/

LocalAI 开发了 18 个自定义 C/C++ 推理引擎,主要目的是为了避免 Python/PyTorch 依赖带来的臃肿和复杂性。通过将庞大的 Python 环境替换为自包含、轻量级的二进制文件(通常小于 100 MiB),LocalAI 在保持与 vLLM 和 ONNX 等行业标准框架近乎完美输出一致性的同时,显著减少了内存占用并缩短了安装时间。 开发过程优先考虑准确性而非原始速度:引擎的构建首先是将权重转换为 GGUF 格式,针对参考张量执行严格的组件级测试,仅在验证输出一致性后才进行优化。虽然这些移植版通过消除解释器开销和冗余计算,在 CPU 上往往优于 Python 等效版本,但在 GPU 上仍与 cuDNN 等成熟内核保持同等性能。 LocalAI 仅在 Python 依赖受限或不存在 C++ 实现的特殊用例中才使用这些自定义移植版。对于像 `llama.cpp` 或 `vLLM` 这样快速迭代的项目,团队仍会继续使用现有的封装。最终,这种方法提供了一个可预测、可移植且高效的推理栈,同时兼顾了生产部署所需的可靠性。

```Hacker News 新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 为什么我们要自研 C 和 C++ 推理引擎 (localai.io) 11 个积分 由 eatonphil 于 1 小时前发布 | 隐藏 | 往期 | 收藏 | 3 条评论 帮助 dennis16384 7 分钟前 | 下一条 [–] 我在 Model2Vec 静态嵌入和 NER 推理(均为 GGUF,针对 WASM 编译)上也取得了类似的成功,从 ONNX Runtime 移植到了纯 C。Wasm 大小从 30Mb 减小到 300kb,速度提升了 1.5 倍。为了性能或分发包大小,这绝对是值得的。 回复 stephbook 30 分钟前 | 上一条 | 下一条 [–] 应该先从写好你自己的博客文章开始。 回复 nnevatie 8 分钟前 | 父评论 | 下一条 [–] 我也是这么想的。读这些充斥着 AI 生成内容的文章真的很累,它们看起来全都“千篇一律”。 回复 考虑申请 YC 2026 年秋季批次!申请截止日期为 7 月 27 日。 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索: ```
相关文章

原文

Most LocalAI backends wrap somebody else’s engine, and that is the right default. llama.cpp, vLLM, whisper.cpp, stable-diffusion, MLX and the rest are maintained by people who are better at those models than we are, and wrapping them costs a Dockerfile and a gRPC shim.

Eighteen of our backends do not wrap anything. They are C or C++ ports we wrote from scratch, and each one exists because wrapping the upstream engine would have meant shipping something we could not ship: a multi-gigabyte Python install, a non-portable CUDA-only stack, or a model that had no C++ implementation at all. This post is about what those ports buy, measured, and what they cost.

What you get: one file, and memory you can predict

Deploying a Python inference stack means resolving a dependency tree at install time, on the target machine, against whatever CUDA and glibc it has. Deploying a ggml port means copying a shared library and a GGUF file.

The clearest measurement of that difference is vllm.cpp, our C++20 port of vLLM’s V1 serving architecture. Installing vLLM produces a 9.1 GiB virtualenv. Installing vllm.cpp produces a 66 MiB binary. The engine implements the same things the Python original does, including paged KV cache, continuous batching, prefix caching, the scheduler and the sampler, with no Python, no PyTorch and no ggml at inference.

The obvious question is what that costs in throughput. On an NVIDIA GB10 running Qwen3.6-27B in NVFP4, greedy, closed loop, against vLLM in its production graphed configuration rather than --enforce-eager:

Concurrency12481632
vllm.cpp tok/s86.05159.68292.34508.77801.761095.01
vLLM tok/s82.32158.03290.31505.46789.161076.25
Ratio1.045x1.011x1.007x1.007x1.016x1.017x

We are ahead at all six points, and five of those six are ties. Our run-to-run noise band is 0.5%, and concurrency 2 through 32 land between 0.7% and 1.7%, so the honest reading is that only the single-stream case (4.5%) is clearly outside noise. Output is token-for-token identical to vLLM at every point on that curve. Peak host memory is 24.88 GiB against 28.18 GiB.

A tie against a mature CUDA stack is a good result for a 66 MiB binary, and it means the footprint saving is not paid for in throughput. Against llama.cpp on CPU from the same GGUF file, prefill runs 1.18x faster (223.8 against 177.3 tok/s), decode is a tie inside llama.cpp’s own spread, and the tokens are byte-identical to its greedy decode. Against MLX-LM on an Apple M4, prefill time to first token is 1.5% ahead and warm total throughput is 97.6% of MLX-LM, a real 2.4% gap that sits entirely in decode.

Sometimes the port is simply faster

depth-anything.cpp is a port of ByteDance’s Depth Anything 3, which gives you metric depth in metres from one ordinary photo, plus per-pixel confidence, camera intrinsics and extrinsics, and a back-projected point cloud. On CPU it is faster than PyTorch running the same model.

EngineQuantModel MBLoad msInfer msPeak RAM MBvs PyTorch
PyTorchf32516749416.913281.00x
C++/ggmlq8_014240319.43631.31x

Same model, 1.31x the speed, 27% of the memory, and a load that finishes in 40 ms instead of 749 ms, on a Ryzen 9 9950X3D at 504x336 with 16 threads. The quantized q4_k build is a 99 MB file and stays near-lossless. Output correlates 1.0 with the reference forward pass, component by component, across 37 parity tests.

The reason it is faster has nothing to do with writing better matmul kernels than PyTorch. Two positional embeddings, the DPT head’s UV embedding and the backbone’s bicubic position embedding, were being recomputed on every forward pass with single-threaded scalar sin, cos and bicubic loops, even though they depend only on the input geometry and are identical every call. Caching them removed about 95 ms of host-side overhead per forward, which is most of the gap. PyTorch builds the same embeddings with vectorized operations and never paid that cost.

That is the general shape of these wins. The heavy GEMMs are close to a wash, because everyone is calling into the same class of BLAS kernel. The difference sits in host-side work that a Python reference implementation never bothered to optimize, and in not loading an interpreter and a framework to do inference. On GPU the picture flips back to parity: with the ggml CUDA backend and flash attention on a GB10, depth-anything.cpp ties PyTorch’s tuned cuDNN at 47.3 ms per forward, and wins only the cold start, loading 1.75x to 2.9x faster.

Parity is the gate, speed is the follow-up

face-detect.cpp and voice-detect.cpp replaced LocalAI’s Python insightface and speaker-recognition backends. Both are the case where we do not claim a CPU speed win, and both shipped anyway.

face-detect.cpp runs the whole insightface buffalo chain, so SCRFD detection, five-landmark similarity-transform alignment to 112x112, and the ArcFace embedding, out of one self-contained GGUF with no Python and no onnxruntime. Detector boxes and landmarks match insightface to within 1 pixel, and the recognition embedding matches to cosine 1.000000, held at any thread count. On CPU it is slower than onnxruntime: SCRFD detect runs at about 0.83x at one thread and 0.69x at eight, ArcFace embed at about 0.61x and 0.84x. onnxruntime’s MLAS convolution kernels sit at the FMA-port peak, and a custom AVX2 Winograd path narrowed the gap without closing it. On GPU, routing the same convolutions through cuDNN takes SCRFD from 14.8 ms to 6.4 ms and lands at torch-cuDNN parity.

voice-detect.cpp is the same story with a memory result attached. A WeSpeaker verification peaks at about 62 MB in our binary against about 334 MB for the CPU-only Python, torch and onnxruntime path, roughly 5.4x lower, with an identical verdict and embedding cosine 1.000000. End to end on CPU the two land within 10 to 15% of each other, trading the lead by model and thread count, and on GPU the conv encoders match the reference.

For a biometric pipeline, matching the reference exactly matters more than being faster than it. An embedding that differs in the fourth decimal place changes verification decisions at a threshold, and every enrolled template in a deployment would have to be recomputed. Parity is what makes the replacement a drop-in rather than a migration.

The method

Every port follows the same sequence, and the order is the important part.

Convert the weights first, into one GGUF with the tokenizer, the vocabulary and any auxiliary model embedded, so that deploying the model is copying a file.

Port the graph second, and gate it component by component against reference tensors dumped from the original implementation. depth-anything.cpp has 37 ctest cases covering preprocessing, backbone, attention, the DPT head, depth, pose, the ray head, the ray to pose solver and the exporters. parakeet.cpp gates on transcript agreement with NeMo at WER 0. face-detect.cpp gates on box and landmark distance in pixels and embedding cosine. A port that is fast and slightly wrong is worthless, and without a per-component gate you find out it is wrong months later.

Optimize third, with a profiler, and only after parity holds. In parakeet.cpp the decisive win was caching a prediction-network LSTM forward pass that was 97% of transducer decode time and mostly redundant. In depth-anything.cpp it was two cached positional embeddings. Neither was a kernel rewrite, and neither would have been findable without a working baseline to profile.

Expose a flat C ABI last. LocalAI dlopens the shared library through purego and calls that ABI directly, so there is no subprocess, no gRPC hop to a Python server, and no interpreter in the serving path.

What it costs

Maintenance, mostly. Each engine is a repository with its own CI, its own benchmark suite, its own GGUF conversion script and its own parity baselines, and upstream keeps releasing new checkpoints that need converter work.

GPU kernels are the weak spot. ggml’s generic CUDA convolution and attention kernels trail NVIDIA’s tuned cuDNN on the conv-heavy models, which is why face-detect.cpp needs an explicit cuDNN path to reach parity, and why parakeet.cpp’s GPU margin over NeMo is a median 1.25x while its CPU margin is wider.

Porting also does not scale to everything. llama.cpp, vLLM, whisper.cpp, MLX and diffusers stay wrapped, because those projects are large, fast-moving and already excellent at what they do. We write an engine when a model has no C++ implementation, when the Python dependency is heavier than the model, or when the thing we need does not exist yet. Everything else we install from somebody else.

Every engine listed above keeps its own benchmark suite, its parity gates and its methodology in its own repository, including the runs that did not work. The full list of them is the “Backends built by us” table in the LocalAI README.

联系我们 contact @ memedata.com