公司动态
LLM中的多线程同步如何做?如何提升推理吞吐?
第29题多线程同步如何做如何提升推理吞吐1. 核心回答这道题可以拆成两个层面多线程同步解决并发正确性问题先识别共享状态再根据访问模式选择mutex、shared_mutex、condition_variable、atomic 等同步机制同时缩短临界区并避免死锁。推理吞吐优化解决资源利用率问题重点通过动态 Batch、异步流水线、多个模型实例、推理模式、低精度计算以及大模型场景中的 Continuous Batching 和 KV Cache 提高 GPU 利用率。一个比较完整的推理服务可以组织成请求线程 ↓ 并发安全请求队列 ↓ Batch Scheduler ↓ 一个或多个 Inference Worker ↓ GPU ↓ 结果队列 ↓ 返回请求线程主要负责请求接收、预处理、调度和结果返回。GPU 推理请求则经过统一调度避免大量线程各自提交很小的推理任务。2. 多线程同步首先要确定共享状态线程同步的第一步是明确哪些数据能够被多个线程同时访问以及这些数据需要满足什么一致性条件。例如一个推理服务可能存在请求队列Batch Buffer模型实例池KV Cache 管理器请求状态表统计计数器GPU Stream 池内存 Buffer Pool。如果一个变量只属于当前线程就没有必要加锁。真正需要同步的是共享可变状态。例如Thread A ─┐ ├── Shared Queue Thread B ─┤ │ Thread C ─┘如果多个线程同时修改队列就需要保证操作的原子性和可见性。3. 常用同步机制怎么选择3.1mutex共享数据需要互斥修改最常见的情况是多个线程都会修改同一个数据结构。例如std::mutex mtx;std::queueRequestqueue;voidpush(Request req){std::lock_guardstd::mutexlock(mtx);queue.push(req);}std::lock_guard使用 RAII 管理锁。进入作用域时获得锁离开作用域时自动释放锁因此可以降低异常路径或提前return导致漏解锁的风险。适合请求队列MapCache 元数据模型实例状态复杂共享对象。3.2shared_mutex读多写少如果共享数据绝大多数时候只读取可以使用读写锁。例如Reader 1 ─┐ Reader 2 ─┼── shared lock Reader 3 ─┘ Writer ───── exclusive lock读取时std::shared_locklock(mtx);多个读线程可以同时进入。修改时std::unique_locklock(mtx);写线程获得独占访问。这种模式适合模型配置路由表只偶尔更新的 Cache Index服务配置。shared_mutex的收益取决于实际读写比例和锁竞争。锁开销本身也需要通过 benchmark 判断。3.3condition_variable生产者—消费者推理服务中更典型的情况是请求线程 Producer 推理线程 ConsumerProducer 把请求放入队列。Inference Worker 在没有请求时应该阻塞等待避免不断轮询while(queue.empty()){// busy waiting}更合适的方法是condition_variable。典型逻辑std::mutex mtx;std::condition_variable cv;std::queueRequestqueue;voidproducer(Request req){{std::lock_guardstd::mutexlock(mtx);queue.push(req);}cv.notify_one();}voidconsumer(){while(true){std::unique_lockstd::mutexlock(mtx);cv.wait(lock,[]{return!queue.empty();});Request reqqueue.front();queue.pop();lock.unlock();run_inference(req);}}这里有一个重要细节耗时的模型推理不要放在锁内部。锁只保护取请求 修改队列状态完成后立即释放。否则一个 GPU 推理如果需要 100 ms其他线程可能连续 100 ms 无法访问队列。3.4 Atomic简单共享状态如果共享状态只是简单计数器或 Flag例如std::atomicintrequest_count;std::atomicboolrunning;可以使用 atomic。典型场景包括请求计数简单状态位引用计数无锁数据结构中的基本状态。Atomic 更适合简单状态转换。如果一次操作涉及多个变量并要求它们满足联合不变量通常仍需要 mutex 或其他更完整的同步机制。4. 多线程同步最重要的工程原则4.1 临界区尽可能小例如lock();pop_request();unlock();preprocess();copy_to_gpu();inference();postprocess();锁只覆盖真正需要保护的共享状态。避免lock();pop_request();preprocess();GPUinference();networksend();unlock();因为这会严重降低并发度。4.2 锁内部避免阻塞操作锁内部尽量避免文件 I/O网络 I/OGPU Synchronize长时间 CPU 计算RPC日志系统阻塞调用。否则一个慢操作会扩大所有线程的等待时间。4.3 多把锁需要固定顺序假设存在Lock A Lock BThread 1Lock A ↓ Lock BThread 2Lock B ↓ Lock A就可能发生死锁Thread 1 持有 A等待 B Thread 2 持有 B等待 A因此需要统一锁顺序例如永远先 A再 BC 中也可以使用std::scoped_lock等机制管理多个锁。5. 推理吞吐应该怎样定义吞吐通常表示单位时间能够完成多少任务。普通模型可以使用ThroughputCompleted RequestsTime Throughput \frac{\text{Completed Requests}} {\text{Time}}ThroughputTimeCompleted Requests单位例如requests/s生成式大模型还经常关注tokens/s但吞吐不能脱离延迟单独优化。例如配置Throughputp95 LatencyA100 req/s30 msB180 req/s80 msC220 req/s500 ms如果服务要求p95 100 ms那么配置 C 即使吞吐最高也无法满足服务目标。因此更准确的优化目标是maxThroughput \max ThroughputmaxThroughput约束p95 Latency≤Lmax p95\ Latency \leq L_{\max}p95Latency≤Lmax或者同时约束 p99 latency。6. 提高推理吞吐的第一优先级Batching6.1 Static Batching最简单的方法是一次处理多个输入Request 1 ─┐ Request 2 ─┤ Request 3 ─┼── Batch ── GPU Request 4 ─┘例如单请求X∈R1×d X\in \mathbb{R}^{1\times d}X∈R1×d改成X∈RB×d X\in \mathbb{R}^{B\times d}X∈RB×dGPU 通常能够通过更大的矩阵计算提高计算单元利用率。6.2 Dynamic Batching在线服务中请求并不会天然同时到达。因此可以维护一个短暂的请求队列。例如t0: Request A t1: Request B t2: Request C t3: Request DScheduler 等待一个很短的时间窗口然后组成[A, B, C, D]一次送入 GPU。NVIDIA Triton 将这一机制称为Dynamic Batching。核心参数包括max_batch_size最大排队时间请求并发量队列策略。Batch 增大通常有利于吞吐同时也可能增加请求等待时间。所以需要通过实验搜索Batch Size × Queue Delay × Concurrency找到满足 latency budget 的最大吞吐配置。7. 增加并发模型实例如果单个模型实例没有充分占满 GPU可以运行多个 model instance。例如Request Queue │ ├── Model Instance 1 ──┐ ├── Model Instance 2 ──┼── GPU └── Model Instance 3 ──┘Triton 的instance_group就支持这种执行方式。这种方法适用于单次推理 kernel 较小单模型 GPU 利用率较低CPU/GPU pipeline 存在空洞多个请求具有足够并发量。需要实际测量。如果一个模型实例已经接近 GPU 计算或显存带宽上限继续增加实例可能引起显存压力kernel contentioncontext/scheduling overheadlatency 增加。因此 instance 数量也是 benchmark 参数。8. 使用异步 Pipeline 隐藏等待时间推理流程通常包括CPU preprocessing ↓ Host → Device ↓ GPU inference ↓ Device → Host ↓ CPU postprocessing如果完全串行Request 1: CPU → H2D → GPU → D2H → CPU Request 2: CPU → H2D → GPU → ...GPU 和 CPU 都可能存在空闲阶段。可以改成流水线时间 → Request A: CPU | H2D | GPU | D2H | Post Request B: CPU | H2D | GPU | D2H | Post Request C: CPU | H2D | GPU | D2H | Post常见优化包括异步预处理pinned memoryasynchronous H2D/D2HCUDA StreamsBuffer PoolCPU preprocessing thread pool。其目标是让CPU preprocessing 数据传输 GPU计算 后处理尽可能重叠。9. 减少推理阶段本身的计算开销9.1 正确使用推理模式PyTorch 推理时通常应该使用model.eval()withtorch.inference_mode():outputmodel(x)model.eval()负责让 Dropout、BatchNorm 等模块进入正确的评估行为。torch.inference_mode()会关闭 Autograd 相关工作并进一步减少 view tracking、version counter 等开销。两者承担不同职责因此通常需要同时使用。9.2 降低数值精度根据硬件和模型精度要求可以考虑FP32BF16FP16INT8更低比特量化。例如FP32 ↓ FP16 / BF16 ↓ INT8通常可以减少显存占用内存带宽压力部分计算开销。最终需要验证模型精度是否满足要求。9.3 Kernel Fusion 和编译优化多个小算子Op1 ↓ Op2 ↓ Op3可能产生多次 kernel launch 和中间内存读写。如果能够融合成Fused Kernel就可以减少kernel launch overhead中间 Tensor显存访问。TensorRT、torch.compile等推理优化方案都会在不同程度上进行图优化或算子优化。10. LLM 推理还可以使用 Continuous Batching生成模型和普通分类模型存在一个重要差异不同请求的生成长度不同。例如A: 生成 20 tokens B: 生成 500 tokens C: 生成 50 tokens传统 Static Batch 可能需要A 完成后等待 C 完成后等待 直到 B 完成这会浪费 Batch Slot。Continuous Batching / Inflight Batching 会在每轮生成过程中动态管理请求Step 1: [A B C] Step 2: [A B C] ... A结束 下一轮: [D B C] ... C结束 下一轮: [D B E]已经结束的请求立即释放位置新请求进入执行 Batch。NVIDIA Triton 将这一机制用于 LLM inference并明确说明这种持续重新组成 Batch 的方式能够提高吞吐和资源利用率。11. LLM 推理还需要利用 KV Cache自回归 Transformer 在第ttt步生成 token 时历史 token 的 Key 和 Value 已经计算过。如果每一步都重新计算token 1 token 1~2 token 1~3 ... token 1~t会产生大量重复计算。KV Cache 保存历史的K1:t−1,V1:t−1 K_{1:t-1},V_{1:t-1}K1:t−1,V1:t−1当前步骤只计算新 token 对应的Kt,Vt K_t,V_tKt,Vt再与历史 Cache 一起执行 Attention。这样可以显著减少 autoregressive decoding 中的重复计算。如果大量请求共享相同 system prompt还可以进一步使用 KV Cache Reuse复用共同前缀对应的 Cache。12. CPU 推理还需要注意线程过度订阅如果模型运行在 CPU 上还需要区分两层并发请求线程和模型算子内部的intra-op threads例如8 个请求线程 × 每个模型调用 16 个算子线程理论上可能产生大量竞争线程。PyTorch 提供torch.set_num_threads(n)用于设置 CPU intra-op parallelism 的线程数量。因此 CPU 场景需要联合调整请求线程数 × 模型实例数 × intra-op threads线程数量增加到一定程度后CPU Core 已经饱和。继续增加线程会带来context switchcache miss调度开销内存带宽竞争。最终吞吐可能下降。13. 我会怎样设计一个实际推理服务一个比较合理的架构是┌──────────────────┐ Request ────────│ Request Threads │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ Thread-safe Queue│ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ Dynamic Batcher │ └────────┬─────────┘ │ ┌───────┴────────┐ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Worker / GPU│ │ Worker / GPU│ │ Instance 1 │ │ Instance 2 │ └──────┬──────┘ └──────┬──────┘ │ │ └───────┬─────────┘ ▼ ┌──────────────────┐ │ Response Queue │ └────────┬─────────┘ │ ▼ Client这里mutex / condition_variable保证 CPU 请求队列正确Scheduler 负责组成 BatchWorker 数控制实际推理并行度GPU 负责批量计算LLM 场景增加 Continuous Batching 和 KV Cache整个系统通过 benchmark 确定最佳参数。14. 怎样证明吞吐真的提高了不能只看 GPU Utilization。我会固定模型输入长度分布输出长度分布硬件精度请求数据然后逐步改变Concurrency Batch Size Queue Delay Model Instance Count CPU Thread Count Precision记录Requests/sTokens/sp50 latencyp95 latencyp99 latencyQueue TimeGPU Compute TimeGPU UtilizationCPU Utilization显存使用错误率。例如BatchThroughputp95GPU Util1100 req/s20 ms35%4260 req/s30 ms70%8380 req/s55 ms90%16410 req/s140 ms97%如果 SLA 是p95100 ms p95 100\text{ ms}p95100ms那么 Batch 8 可能是更合理的配置。最终优化目标应通过这种性能曲线确定。15. 面试时可以压缩成下面这段多线程同步我会先看共享状态和访问模式。如果多个线程修改同一个对象就用 mutex读多写少可以考虑 shared_mutex生产者—消费者队列可以用 mutex 配合 condition_variable简单计数器和状态位可以用 atomic。工程上重点是缩小临界区、避免持锁做 I/O 或 GPU 推理并统一多把锁的获取顺序来防止死锁。推理吞吐方面我首先会做 profiling判断瓶颈在 CPU、数据传输还是 GPU。GPU 利用率不足时优先考虑 Dynamic Batching把多个小请求合并成 Batch然后根据资源情况测试多个 model instance 和异步 preprocessing/H2D/inference pipeline。推理阶段使用 eval 和 inference_mode并根据精度要求使用 FP16、BF16、INT8 等优化。如果是 LLM还会重点使用 Continuous Batching 和 KV Cache。Continuous Batching 可以让已经完成的请求立即退出 Batch新请求及时补入KV Cache 可以避免每一步重新计算历史 token 的 Key 和 Value。最后我会联合扫描 concurrency、batch size、queue delay 和 instance 数在固定 p95/p99 latency SLA 下寻找最大 requests/s 或 tokens/s。线程数和 Batch 都属于需要实测确定的参数。16. 来源cppreference —std::lock_guardRAII 方式管理 mutex。cppreference —std::shared_mutex支持共享读和独占写。cppreference —std::condition_variable用于 mutex 保护条件下的线程等待与通知。PyTorch Documentation —torch.set_num_threads控制 CPU intra-op parallelism。PyTorch Documentation —torch.inference_mode推理阶段关闭 Autograd 相关开销文档同时说明仍需显式调用model.eval()。NVIDIA Triton Inference Server — Dynamic Batcher将多个请求动态合并成 Batch提高推理吞吐。NVIDIA Triton Inference Server — Instance Groups允许同一模型配置多个并行执行实例。NVIDIA Triton Inference Server — Continuous / Inflight Batching在 LLM 解码过程中持续重新组织 Batch。NVIDIA TensorRT / TensorRT-LLM — KV Cache 与 KV Cache Reuse保存和复用历史 Key/Value减少自回归生成中的重复计算。