Ollama 的并发模型深度分析:从请求队列到 GPU Stream 的任务分派与同步机制 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: Vecu32, // 输入 Token IDs max_tokens: u32, // 最大生成 Token 数 callback: tokio::sync::oneshot::SenderVecString, } 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: MutexVecStreamHandle, // 信号量控制并发度防止 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 而非 MutexCondvar /// 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::ReceiverInferenceRequest, pool: ArcGpuStreamPool, ) { let mut pending: BinaryHeapstd::cmp::ReverseInferenceRequest 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 计算密度。

本周精选

本月热点