Ollama 的并发模型深度分析:从请求队列到 GPU Stream 的任务分派与同步机制
一、多用户并发推理时 GPU 利用率不饱和的根因追问
Ollama 作为本地 LLM 推理的流行方案,单用户场景下表现良好。但部署为内部推理服务后,多用户并发访问时 GPU 利用率常低于 30%。问题不在于显存不足——模型已完整加载——而在于请求调度和 GPU Stream 管理策略未能充分利用 GPU 的计算并行度。
Ollama 的服务端基于 Go 语言实现(底层调用 llama.cpp 的 C++ 推理引擎),其并发模型面临一个核心矛盾:GPU 在同一时刻只能执行一个 CUDA Stream 中的 Kernel,但不同 Stream 之间可以通过异步执行实现计算与传输的 Overlap。如何在保证推理正确性的前提下最大化 GPU 利用率,是并发模型设计的关键命题。
二、从请求入队到 Kernel 执行的端到端调度路径
flowchart TB subgraph "HTTP 接入层" A[HTTP Request] --> B[参数校验] B --> C[请求入队] end subgraph "调度层" C --> D{调度策略} D -->|连续批处理| E[合并为新 Batch] D -->|独立处理| F[分配 GPU Stream] E --> F end subgraph "GPU 执行层" F --> G[Stream-0: 上下文编码] F --> H[Stream-1: 逐 Token 生成] G --> I[CUDA Event 同步] H --> I I --> J[结果回传] end subgraph "内存管理" K[KV Cache 分配器] K -.->|为每个请求分配| G K -.->|为每个请求分配| H endOllama 采用的调度策略是"请求级串行 + 批处理内并行"。当新请求到达时,如果处于生成(Decode)阶段,请求需要排队等待当前批次完成。这种设计简化了 KV Cache 的管理——同一批次的请求共享一次 Kernel Launch——但代价是 GPU 在批次切换间存在闲置窗口。
值得注意的是,llama.cpp 内部使用多 CUDA Stream 来重叠 Prefill(上下文编码)和 Decode(逐 Token 生成)的计算。但 Stream 间的同步依赖 CUDA Event,如果事件等待链条过长,实际并发的收益会被稀释。
三、自定义调度层的 GPU Stream 管理实现
以下代码实现一个基于优先级队列的请求调度器,核心思想是通过预分配 GPU Stream Pool 避免创建/销毁开销:
use std::sync::Arc; use tokio::sync::{Mutex, Semaphore, mpsc}; use std::collections::BinaryHeap; /// 推理请求的优先级定义 /// 设计原因:BinaryHeap 是最大堆,Rust 中 Reverse 包装后变为最小堆 /// 使用 (priority, timestamp) 元组排序:同优先级按到达时间 FIFO #[derive(Eq, PartialEq)] struct InferenceRequest { priority: u8, // 1=最高(实时对话), 5=最低(批处理) arrival_time: u64, // 到达时间戳,保证同优先级 FIFO input_tokens: Vec<u32>, // 输入 Token IDs max_tokens: u32, // 最大生成 Token 数 callback: tokio::sync::oneshot::Sender<Vec<String>>, } impl Ord for InferenceRequest { fn cmp(&self, other: &Self) -> std::cmp::Ordering { // 优先级低的值排前面(对应更高优先级) self.priority.cmp(&other.priority) .then_with(|| other.arrival_time.cmp(&self.arrival_time)) } } /// GPU Stream 池管理器 /// 设计原因:创建 CUDA Stream 是昂贵的 GPU 操作(约 1-5μs) /// 预创建 Stream 池并复用,避免运行时创建开销 /// 每个 Stream 绑定一个推理上下文,保持 KV Cache 的内存局部性 struct GpuStreamPool { available: Mutex<Vec<StreamHandle>>, // 信号量控制并发度,防止 GPU 显存超分 concurrency_limit: Semaphore, } struct StreamHandle { id: u32, // CUDA Stream 的不透明句柄 // 设计原因:使用 cudarc 或 cust 库的包装类型 // 直接传裸指针存在生命周期管理风险 stream: std::ffi::c_void, // 实际为 CUstream // 该 Stream 当前绑定的 KV Cache 内存区域偏移量 kv_cache_offset: usize, // 该 Stream 的最大可用显存(字节) available_vram: usize, } impl GpuStreamPool { /// 创建 Stream 池 /// pool_size 不应超过 GPU 可支持的并发 Kernel 数上限 /// 通常取 4-8,受限于显存中的 KV Cache 容量 pub fn new(pool_size: usize, total_vram: usize) -> Self { let vram_per_stream = total_vram / pool_size; let mut streams = Vec::with_capacity(pool_size); for i in 0..pool_size { // 实际场景中调用 cuStreamCreate streams.push(StreamHandle { id: i as u32, stream: std::ptr::null_mut(), kv_cache_offset: i * vram_per_stream, available_vram: vram_per_stream, }); } GpuStreamPool { available: Mutex::new(streams), concurrency_limit: Semaphore::new(pool_size), } } /// 获取一个可用的 Stream /// 设计原因:使用 Semaphore 而非 Mutex+Condvar /// Semaphore 的 await 在无可用许可时挂起,无需自旋或轮询 /// 等待期间不消耗 CPU pub async fn acquire(&self) -> StreamHandle { let _permit = self.concurrency_limit.acquire().await .expect("Semaphore closed unexpectedly"); let mut available = self.available.lock().await; available.pop().expect( "Semaphore permit acquired but no stream available") } /// 归还 Stream 到池中 pub async fn release(&self, handle: StreamHandle) { let mut available = self.available.lock().await; available.push(handle); // Semaphore permit 在 _permit Drop 时自动释放 } } /// 调度主循环 /// 设计原因:使用 tokio::select! 同时监听请求队列和批次完成信号 /// 避免忙碌等待(busy-wait),让渡 CPU 给其他任务 async fn scheduler_loop( mut request_rx: mpsc::Receiver<InferenceRequest>, pool: Arc<GpuStreamPool>, ) { let mut pending: BinaryHeap<std::cmp::Reverse<InferenceRequest>> = BinaryHeap::new(); loop { tokio::select! { Some(req) = request_rx.recv() => { pending.push(std::cmp::Reverse(req)); } _ = tokio::time::sleep(std::time::Duration::from_millis(1)) => { // 定时尝试调度:从队列中取优先级最高的请求 // 设计原因:不等待队列满才调度,减少首 Token 延迟 if let Some(std::cmp::Reverse(req)) = pending.pop() { let pool = pool.clone(); tokio::spawn(async move { let stream = pool.acquire().await; // 在此 Stream 上执行推理 run_inference(stream, req).await; pool.release(stream).await; }); } } } } } async fn run_inference( _stream: StreamHandle, _req: InferenceRequest) { // 实际推理逻辑:调用 llama.cpp 的 C API todo!("集成 llama.cpp inference") }核心设计决策在于Semaphore的使用。相比自行维护可用 Stream 数量并通过条件变量通知,Semaphore直接提供了"等待可用资源"的语义,代码简洁且无忙等开销。on_drop的自动释放保证即使在 panic 路径上也不会泄漏许可。
四、调度模型的适用边界
基于优先级的请求调度在高负载下有饥饿风险——低优先级请求可能无限期等待。解决方案是引入 Aging 机制:随着等待时间增长,动态提升请求优先级。在实现中,arrival_time字段配合定期扫描即可实现:每次调度时,将等待超过阈值的请求的priority字段递减。
GPU Stream Pool 的固定大小是一个显式的 Trade-off:池大小为 N 意味着最多 N 个请求同时使用 GPU。如果请求的上下文长度差异大(有的 100 Token,有的 100K Token),固定 KV Cache 分配策略会导致空间浪费——短请求的 KV Cache 区域闲置。动态 KV Cache 分配(vLLM 的 PagedAttention 方案)可以缓解此问题,但实现复杂度显著增加。
对于吞吐优先的离线批量推理场景(如数据标注、Embedding 生成),请求级优先级调度的收益有限。此时应优先使用连续批处理(Continuous Batching),让新请求加入正在执行的批次,最大化 GPU 的计算密度。
五、总结
- Ollama 的请求级串行调度是 GPU 利用率偏低的根因,瓶颈在批次切换的闲置窗口而非 GPU 算力。
- 预创建 GPU Stream Pool + Semaphore 并发控制,消除 Stream 创建开销并实现无忙等的资源分配。
- 优先级队列结合 Aging 机制,可在保证高优先级请求低延迟的同时避免低优先级请求饿死。
- 固定 KV Cache 分区策略简化内存管理但浪费显存,高并发场景需考虑 PagedAttention 等动态分配方案。
- 离线批量推理场景的优化重点应从请求调度转向连续批处理,以最大化 GPU 计算密度。