LLM中的多线程同步如何做?如何提升推理吞吐?
2026/9/23 5:17:21 网站建设 项目流程

第29题:多线程同步如何做?如何提升推理吞吐?

1. 核心回答

这道题可以拆成两个层面:

  1. 多线程同步解决并发正确性问题:先识别共享状态,再根据访问模式选择mutexshared_mutexcondition_variable、atomic 等同步机制,同时缩短临界区并避免死锁。
  2. 推理吞吐优化解决资源利用率问题:重点通过动态 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::queue<Request>queue;voidpush(Request req){std::lock_guard<std::mutex>lock(mtx);queue.push(req);}

std::lock_guard使用 RAII 管理锁。

进入作用域时获得锁,离开作用域时自动释放锁,因此可以降低异常路径或提前return导致漏解锁的风险。

适合:

  • 请求队列;
  • Map;
  • Cache 元数据;
  • 模型实例状态;
  • 复杂共享对象。

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 推理线程 = Consumer

Producer 把请求放入队列。

Inference Worker 在没有请求时应该阻塞等待,避免不断轮询:

while(queue.empty()){// busy waiting}

更合适的方法是condition_variable

典型逻辑:

std::mutex mtx;std::condition_variable cv;std::queue<Request>queue;voidproducer(Request req){{std::lock_guard<std::mutex>lock(mtx);queue.push(req);}cv.notify_one();}voidconsumer(){while(true){std::unique_lock<std::mutex>lock(mtx);cv.wait(lock,[]{return!queue.empty();});Request req=queue.front();queue.pop();lock.unlock();run_inference(req);}}

这里有一个重要细节:

耗时的模型推理不要放在锁内部。

锁只保护:

取请求 修改队列状态

完成后立即释放。

否则一个 GPU 推理如果需要 100 ms,其他线程可能连续 100 ms 无法访问队列。


3.4 Atomic:简单共享状态

如果共享状态只是简单计数器或 Flag,例如:

std::atomic<int>request_count;std::atomic<bool>running;

可以使用 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/O;
  • GPU Synchronize;
  • 长时间 CPU 计算;
  • RPC;
  • 日志系统阻塞调用。

否则一个慢操作会扩大所有线程的等待时间。


4.3 多把锁需要固定顺序

假设存在:

Lock A Lock B

Thread 1:

Lock A ↓ Lock B

Thread 2:

Lock B ↓ Lock A

就可能发生死锁:

Thread 1 持有 A,等待 B Thread 2 持有 B,等待 A

因此需要统一锁顺序,例如:

永远先 A,再 B

C++ 中也可以使用std::scoped_lock等机制管理多个锁。


5. 推理吞吐应该怎样定义

吞吐通常表示单位时间能够完成多少任务。

普通模型可以使用:

Throughput=Completed RequestsTime Throughput= \frac{\text{Completed Requests}} {\text{Time}}Throughput=TimeCompleted Requests

单位例如:

requests/s

生成式大模型还经常关注:

tokens/s

但吞吐不能脱离延迟单独优化。

例如:

配置Throughputp95 Latency
A100 req/s30 ms
B180 req/s80 ms
C220 req/s500 ms

如果服务要求:

p95 < 100 ms

那么配置 C 即使吞吐最高,也无法满足服务目标。

因此更准确的优化目标是:

max⁡Throughput \max ThroughputmaxThroughput

约束:

p95 Latency≤Lmax⁡ p95\ Latency \leq L_{\max}p95LatencyLmax

或者同时约束 p99 latency。


6. 提高推理吞吐的第一优先级:Batching

6.1 Static Batching

最简单的方法是一次处理多个输入:

Request 1 ─┐ Request 2 ─┤ Request 3 ─┼── Batch ──> GPU Request 4 ─┘

例如单请求:

X∈R1×d X\in \mathbb{R}^{1\times d}XR1×d

改成:

X∈RB×d X\in \mathbb{R}^{B\times d}XRB×d

GPU 通常能够通过更大的矩阵计算提高计算单元利用率。


6.2 Dynamic Batching

在线服务中请求并不会天然同时到达。

因此可以维护一个短暂的请求队列。

例如:

t0: Request A t1: Request B t2: Request C t3: Request D

Scheduler 等待一个很短的时间窗口,然后组成:

[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 contention;
  • context/scheduling overhead;
  • latency 增加。

因此 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 memory;
  • asynchronous H2D/D2H;
  • CUDA Streams;
  • Buffer Pool;
  • CPU preprocessing thread pool。

其目标是让:

CPU preprocessing 数据传输 GPU计算 后处理

尽可能重叠。


9. 减少推理阶段本身的计算开销

9.1 正确使用推理模式

PyTorch 推理时通常应该使用:

model.eval()withtorch.inference_mode():output=model(x)

model.eval()负责让 Dropout、BatchNorm 等模块进入正确的评估行为。

torch.inference_mode()会关闭 Autograd 相关工作,并进一步减少 view tracking、version counter 等开销。

两者承担不同职责,因此通常需要同时使用。


9.2 降低数值精度

根据硬件和模型精度要求,可以考虑:

  • FP32;
  • BF16;
  • FP16;
  • INT8;
  • 更低比特量化。

例如:

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:t1,V1:t1

当前步骤只计算新 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 switch;
  • cache 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 负责组成 Batch;
  • Worker 数控制实际推理并行度;
  • GPU 负责批量计算;
  • LLM 场景增加 Continuous Batching 和 KV Cache;
  • 整个系统通过 benchmark 确定最佳参数。

14. 怎样证明吞吐真的提高了

不能只看 GPU Utilization。

我会固定:

  • 模型;
  • 输入长度分布;
  • 输出长度分布;
  • 硬件;
  • 精度;
  • 请求数据;

然后逐步改变:

Concurrency Batch Size Queue Delay Model Instance Count CPU Thread Count Precision

记录:

  • Requests/s;
  • Tokens/s;
  • p50 latency;
  • p95 latency;
  • p99 latency;
  • Queue Time;
  • GPU Compute Time;
  • GPU Utilization;
  • CPU Utilization;
  • 显存使用;
  • 错误率。

例如:

BatchThroughputp95GPU Util
1100 req/s20 ms35%
4260 req/s30 ms70%
8380 req/s55 ms90%
16410 req/s140 ms97%

如果 SLA 是:

p95<100 ms p95 < 100\text{ ms}p95<100ms

那么 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. 来源

  1. cppreference —std::lock_guard:RAII 方式管理 mutex。
  2. cppreference —std::shared_mutex:支持共享读和独占写。
  3. cppreference —std::condition_variable:用于 mutex 保护条件下的线程等待与通知。
  4. PyTorch Documentation —torch.set_num_threads:控制 CPU intra-op parallelism。
  5. PyTorch Documentation —torch.inference_mode:推理阶段关闭 Autograd 相关开销;文档同时说明仍需显式调用model.eval()
  6. NVIDIA Triton Inference Server — Dynamic Batcher:将多个请求动态合并成 Batch,提高推理吞吐。
  7. NVIDIA Triton Inference Server — Instance Groups:允许同一模型配置多个并行执行实例。
  8. NVIDIA Triton Inference Server — Continuous / Inflight Batching:在 LLM 解码过程中持续重新组织 Batch。
  9. NVIDIA TensorRT / TensorRT-LLM — KV Cache 与 KV Cache Reuse:保存和复用历史 Key/Value,减少自回归生成中的重复计算。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询