1. 为什么要做这个 Sidecar
Sidecar 做压测的时候,发现 Python 后端在并发下无法完全发挥,这是 GIL 的经典症状。
一、 它对推理服务有什么影响?
因为 GIL,Python 的多线程无法利用多核 CPU 的优势。在需要大量 CPU 计算的场景下,多线程不能提速,反而会因为频繁切换线程而变慢。
当高并发请求涌入 Python 推理服务时:
- 如果使用多线程来处理并发,哪怕服务器有 64 核 CPU,GIL 也会强制所有线程排队使用单核来执行预处理和后处理。
- 结果:单核占用率到 100%,其他核心看戏,服务吞吐量极低,响应延迟随着并发量暴增。
二、 为什么不能直接在 Python 内部解决?
1. C-API 与内存管理
Python 内部使用引用计数(Reference Counting)来管理内存。每次创建、销毁或传递对象,引用计数都会加减。
- 如果没有 GIL,多线程同时修改同一个对象的引用计数,就会引发数据竞争,导致内存泄漏或崩溃。
- 更细粒度的锁: 加锁、释放锁的开销大,单线程性能将直接下降。
- 大量底层的 C 扩展库依赖 GIL 保证线程安全,要保障 Python 生态的第三方库。
2. Python “多进程”是伪并行的“内存怪兽”
Python 自带的 multiprocessing(多进程)可以让每个进程都有独立的 GIL,确实能用满多核,但它带来了新的痛点:
- 内存开销巨大:在 AI 推理中,一个大模型动辄几 GB 到几十 GB。如果开 8 个进程,模型权重就会在内存里复制 8 份,服务器内存(或显存)直接爆掉。
3. 异步编程无法拯救 CPU 密集型任务
FastAPI 等异步框架现在很流行,async/await 可以在单线程下通过协程高效处理成 I/O 并发。
- 但是:一旦协程开始执行 CPU 密集型的推理预处理(比如用 Python 循环处理一个大矩阵),整个线程就会被阻塞。处理完之前,其他请求的 I/O 事件都得不到响应。
2. 控制面为什么选 Go
调度层从 Python 剥离出来之后,如何重写控制面?Go 是这个场景下的优秀选择,原因有三。
1. 原生且轻量的高并发(Goroutines)
控制面需要同时处理多节点或客户端的连接与状态汇报。
- 传统多线程( C++/Java) :一个线程占几兆(MB)内存,线程切换由操作系统内核调度,开销巨大,面对超高并发时容易把内存撑爆、CPU 耗尽。
- Go 的协程:一个协程仅占用几 KB 内存,成千上万个 Goroutine 可以在少数几个 OS 线程上平滑切换(M:N 调度模型),切换由 Go 运行时(Runtime)在用户态完成。
- 结果:Go 适合编写处理大量并发网络连接的服务,且代码直观。
2. 批处理通信 (Channel)
动态批处理(Dynamic Batching)是提升吞吐量的关键。Go 语言的 Channel(通道) 和 select 关键字,是实现这种异步队列和批处理的高效工具。
Go 侧攒好 batch 之后,通过 gRPC 下发给 Python backend——相比 REST,gRPC 的二进制编码在高频小包场景下延迟更低,也更适合内部服务间通信。
func (b *Batcher) collectBatch() []*Request {
var batch []*Request
deadline := time.After(b.dynamicWaitMs())
for {
select {
case req := <-b.queue:
batch = append(batch, req)
if len(batch) >= b.cfg.MaxBatchSize {
return batch // full batch — flush immediately
}
case <-deadline:
return batch // time up — flush whatever we have
}
}
}
3. CGo 联调 Rust
Go 在调度和开发效率上做到了极致,但涉及高性能计算/内存安全的关键组件时,需要 Rust 这种追求零成本抽象和内存安全的语言。Go 提供了 CGo 机制,成为了连接 Go 调度系统与 Rust 性能利器的桥梁。
3. Rust 作用
Tokenization(分词)不只是简单的字符串查找,实际上,BPE 算法在推理预处理中是严重的 CPU 瓶颈。
bpe 算法
1. 文本预切分(Pre-tokenization)
输入一段长文本,先通过复杂的正则表达式将其切分为单词或标点符号的片段,这在 CPU 上是非常消耗周期的操作。
2. 字节级编码与无限循环合并
BPE 的词表(Vocabulary)通常很大。对于切分出的每一个单词:
- 首先将单词拆解为单个字节/字符。
- 核心瓶颈:在词表中循环查找相邻两个子词(Pair)组合后词频最高(Rank 最小)的那一个,将其合并为一个新的子词。
- 这个“查找-合并-再查找”的过程是一个死循环,直到找不到可以合并的 Pair 为止。
3. 大吞吐量的并发压力
当控制面(Go)并发扔过来多个用户的 Prompt 请求时,每个请求都有成百上千个字符。如果在 Python 或 Go 中纯靠 CPU 循环去做这个 BPE 合并,CPU 占用率会瞬间飙满。
做了一个 benchmark:用相同的空格计数任务对比 Python 和 Rust FFI,Rust 快约 2.3 倍——这说明在计算量足够大时,FFI 的切换开销完全可以被摊薄。但 BPE encode 的瓶颈在算法复杂度本身,不在语言边界。
FFI 边界的 Trade-off
通过 CGo,Go 可以调用由 Rust 编译出来的 C 静态库。但跨越语言边界(FFI)仍存在着性能权衡。
通过 CGo 调用一个 Rust 函数,需要经历:切换 Go 的调度栈、保存寄存器、进入 C 调用栈、执行 Rust 代码、恢复 Go 栈。单次调用的固定开销大约在 50~100 纳秒。
如果 Rust 做简单的加法或裁剪短字符串,CGo 的切换开销会远远超过 Rust 节省下来的时间,反而变慢。只有当 Rust 内部的计算耗时远大于 1 微秒时(比如处理一整段长文本的 BPE 编码),跨越 FFI 才是划算的。
另外,用 Go 实现 BPE 也可以,但 Rust 的特性让它在处理大量字节操作时更可靠,而且已经有成熟的 Rust 实现可以直接集成。
4. Python 作用
在现代 AI 推理架构中,Python 被留在纯粹的模型执行层。不需要管高并发调度,也不需要管接入层 I/O,它只需要做一件事:深度学习框架(PyTorch)与底层硬件(GPU/NPU)之间的“接口”。
1. 模型的加载
深度学习模型本质上是复杂的张量(Tensor)计算拓扑图。
- 定义与初始化:Python 负责在服务启动时读取权重文件,并在显存中初始化模型结构。
- 黑盒化导出:如果需要将模型编译为 TensorRT、ONNX 或 TorchScript,“编译和导出”的脚本依然需要 Python 来调用底层编译器。
2. PyTorch / C++ 的调度员
AI 推理的核心算子(如卷积、矩阵乘法 GEMM、FlashAttention)都是用 C++ 或 CUDA 写的。Python 提供一个简单的控制手柄。
- Python 代码执行
outputs = model(inputs),实际上只是在 CPU 端向 GPU 驱动发送了一连串的指针和计算指令。 - 真正的大头计算完全在显卡上跑,Python 此时处于非阻塞等待状态,GIL 在这不是瓶颈。
所以 Python 职责明确:它不参与任何调度决策,不做准入控制,只接收 Go 侧 flush 下来的批次,调用执行,返回结果。边界清晰,GIL 的问题自然就不在此。
5. 组合代价
Go + Rust + Python 的三语言混合架构,在性能、并发和生态上找到了各自的平衡,但它的代价是飙升的系统复杂度。
1. 构建
- 多阶段、多工具链的交织: 构建一个容器镜像,需要同时准备:Go 工具链(编译控制面)、Rust 工具链、以及 Python 运行时与各类 C 扩展依赖。
- 跨平台编译彻底失效: Go 极其简单的跨平台编译,一旦引入 CGo 和 Rust 静态库,就变成了奢望。需要为目标平台配置复杂的编译器(如
x86_64-linux-gnu-gcc或musl-gcc),处理版本不兼容问题。
2. 调试难度
在纯 Go 或纯 Rust 编程中,两者的编译器都极度安全,少有内存崩溃,但是 CGo 打破安全区。
- 调试工具链断层:想跨语言追踪一个传递指针的 Bug 时,没有 IDE 或调试器能完美同时单步执行这两边的代码。需两边挂载调试器,或者使用最原始的打印法。
- 生命周期错位导致Bug: Go 的 GC 随时可能移动内存。为了把指针安全传给 Rust,必须使用
unsafe.Pointer并遵循严苛的物理对齐和固定(Pinning)规则。一旦没有严格遵守,写出了“在 Go 侧已被释放,但 Rust 仍在异步读取”的逻辑,可能造成只在特定并发下才会触发的“幽灵内存损坏”。
6. 如果重来会怎么选
阶段一:极速交付,Python
使用 FastAPI 作为 Web 框架,配合 Ray 或 Celery 做异步任务队列,利用 python 社区现成的生态验证项目。
阶段二:解耦,Go + Python
当流量开始激增,流式传输(SSE)断连、高并发请求导致排队严重、服务器内存因为 Python 多进程加载模型而爆掉时,架构到达第一个拐点。
引入 Go 语言,把系统切分为“控制面”与“纯执行层”。
- Go(网关与调度) :负责所有的 HTTP/WebSocket 连接、用户鉴权、流量限流,以及最重要的动态批处理(Dynamic Batching) 。
- Python(单线程 Worker) :让 Python 退化为一个纯粹的 RPC 服务。Go 通过高效率的 gRPC 将拼好的 Batch 数据喂给 Python。
阶段三:局部重构,按需引入
当服务稳定运行,GPU 算力充分利用时。分析性能,如果 CPU 做 BPE Tokenization 和数据预处理耗时过长,需引入高性能处理方案。
微服务化 Rust:
- 方案 A(首选) :将 Rust 的 Tokenizer 打包成一个极轻量的高性能微服务,挂在 Go 网关后面。Go 收到请求,先发给 Rust 节点分词,再把 Token ID 传给 Python 节点。
- 方案 B(CGo) :如果网络 I/O 成了分词的瓶颈,再在 Go 内部通过 CGo 调用 Rust 编译的静态库。
技术选型只有 trade-off。这套组合助我研究学习推理服务,但其不适合所有情况。
欢迎提出意见:github.com/Li-PengShen…