首页
/ PyTorch 推理服务端到端基准:基于 ResNet-18 的前后端队列仿真与性能测量

PyTorch 推理服务端到端基准:基于 ResNet-18 的前后端队列仿真与性能测量

2026-09-06 18:37:18作者:宗隆裙

本指南以 benchmarks/inference/README.md 为核心,完整梳理 PyTorch 仓库内一套“模拟 Python 推理服务”的端到端性能基准:它用 multiprocessing 队列串联一个后端推理工作进程与一个前端压力/测量进程,围绕 ResNet-18 测量冷启动(warmup)延迟、平均/最大/最小延迟、吞吐量与 GPU 利用率。读完本文,你将掌握这套基准的架构设计、server.py 的每个命令行参数、单次运行与批量扫描(sweep)的完整操作方法,以及结果 CSV / Markdown 的生成规则,并可对照源码理解其 H2D 拷贝、推理、D2H 回传的流水线与 CUDA 流同步细节。

一、目录定位与实验目标

benchmarks/inference 目录是 PyTorch 仓库中一个 work in progress(WIP)的推理服务仿真模块,其 v0 版本的设计目标是:在不引入真实推理服务框架的前提下,复现在线推理服务最核心的请求循环——接收请求 → GPU 推理 → 返回响应,并把各阶段开销量化为可对比的指标。

从目录结构看,该模块由以下几部分组成:

  • README.md:使用与参数说明(本文骨架来源);
  • server.py:主程序,同时实现后端推理工作进程与前端请求/测量进程;
  • runner.sh:批量扫描脚本,遍历多种 batch size 与 compile 开关组合并汇总统计;
  • process_metrics.py:把 CSV 原始结果聚合为均值 ± 标准差并写入 Markdown;
  • CHANGELOG.md:记录该基准在迭代开发过程中的关键改动与结论;
  • results/:sweep 产出的指标表(如 output_128_true.md);
  • src/:开发过程中记录的两张性能对比图。

需要特别说明的是,README 明确写出当前版本刻意省略了数据预处理与结果后处理,因此这套基准测量的是“裸模型推理”链路的最短延迟,属于推理服务耗时下界的一个近似。

二、总体架构:前端进程 + 后端进程

v0 版本采用“一个后端 + 一个前端”的双进程模型,进程间通过 torch.multiprocessing.Queue 传递数据。请求与响应的载体均为 (tensor, request_time) 二元组:

  • 前端 → 后端:(tensor, request_time) 放入 request queue
  • 后端 → 前端:(output, request_time) 放入 response queue

时间戳由发送方在放入队列的时刻记录,接收方在取出响应的时刻读取系统时钟,二者相减即得到该请求的端到端延迟。

1. 后端工作进程(BackendWorker)

后端是一个单进程实现(见 server.py 中的 BackendWorker),职责如下:

  1. 将 ResNet-18 加载到 cuda:0 并对模型进行编译(默认开启 torch.compile());
  2. 持续轮询 request queue,取到请求后完成 GPU 推理;
  3. (output, request_time) 写回 response queue。

加载模型的具体实现值得注意:BackendWorker._setup() 先在 meta device 上通过 ResNet(BasicBlock, [2, 2, 2, 2]) 创建 ResNet-18 的结构,再使用 torch.load(..., mmap=True, map_location="cuda:0") 加载预训练权重 resnet18-f37072fd.pthassign=True 直接原位填充,最后切换到 m.eval()。这样既避免了在 GPU 上重复初始化参数,也让加载耗时(torch_load_time)与编译耗时(m_compile_time)可以被单独计时并写入指标。

2. 前端工作进程(FrontendWorker)

前端是一个进程,内部运行 三条线程(见 server.py):

  1. 请求发送线程_send_requests):生成指定 batch size 的假数据——CPU 上的 torch.randn(batch_size, 3, 250, 250) 张量——放入 request queue;
  2. 指标采集线程_run_metrics):从 response queue 读取响应,统计首响应的冷启动时间,以及平均、最小、最大响应延迟与吞吐量;
  3. GPU 利用率线程_run_gpu_utilization):每 100ms 通过 nvidia-smi --query-gpu=utilization.gpu --id=0 轮询一次 GPU 利用率,最后取平均值。

三个线程通过 threading.Lock 保护共享的 metrics_dict(一个 multiprocessing.Manager 字典),保证写入线程安全。

3. 两段式请求节奏:warmup 与非 warmup

前端线程在正式测量前会先发送一个 warmup 请求,随后才发送 num_iters 个待测量的请求(见 server.py)。代码通过 read_requests_eventwarmup_event 两个事件把流程切成两段:

  1. 前端把 warmup 数据放入队列,set 一个事件通知后端开始消费;
  2. 后端完成第一次请求(_setup() 也在此发生,见下文“编译时机的说明”)后返回响应;
  3. 前端收到首个响应后 set(warmup_event),既唤醒 GPU 利用率线程开始轮询,又再次通知后端继续消费剩余请求;
  4. 后端通过再次 wait 该事件实现“只 setup 一次、之后直接消费队列”的逻辑。

这种编排保证了 warmup 期间产生的编译/加载开销不会混入正式测量窗口。

三、请求处理流水线:H2D 拷贝 / 推理 / D2H 回传(源码级解析)

虽然 README 对 v0 的描述是“后端进程消费请求并返回响应”,当前 server.py 的实现已经比最初的单线程阻塞模型复杂得多。BackendWorker.run() 是一个 asyncio 协程,内部使用三个线程池将一次请求拆成三段流水并行推进:

阶段 线程池 作用
H2D 拷贝 h2d_pool(1 线程) 将 CPU 请求张量 pin_memory()copy_(..., non_blocking=True) 拷入预先分配的 CUDA input_buffer,拷贝在专用 self.h2d_stream 上进行
模型推理 worker_poolmax_workers=num_workers 等待 H2D 事件完成后,在该 worker 私有的 cuda.Stream() 上执行 model(input_buffer)
D2H 回传 d2h_pool(1 线程) 等待 compute 事件完成后,在 self.d2h_stream 上执行 .cpu() 并把结果放入 response queue

线程之间使用 threading.Semaphore + torch.cuda.Event 做同步:每个请求创建一对 copy_sem / compute_sem(初值 0)与 copy_event / compute_eventcopy_data 完成 H2D 后记录 copy_event 并释放 copy_semmodel_predictacquire(copy_sem)wait_event(copy_event) 确保数据拷贝完成,推理结束后记录 compute_event 并释放 compute_semrespond 线程 acquire(compute_sem)wait_event(compute_event) 后再把输出搬到 CPU。

为了让多个推理 worker 互不阻塞,每个 worker 线程在初始化(ThreadPoolExecutorinitializer 回调)时都会创建并登记一条私有的 torch.cuda.Stream()(见 server.py)。这是该基准中 num_workers 参数真正的意义:它控制的是并发执行模型预测的线程数,而不是模型内部的并行度。异步地把每个阶段丢进独立的执行器,还能让后端在等待拷贝/回传期间不被阻塞、持续轮询队列拉取新请求。

四、命令行参数与单次基准运行

1. 参数一览

README 与 server.pyargparse 解析出的可开关参数如下表所示:

参数 默认值 含义
--num_iters 100 发送给后端的待测请求数(不含第一个 warmup 请求)
--batch_size 32 每个请求的张量 batch size,假数据形状为 (batch_size, 3, 250, 250)
--model_dir . 从该目录加载预训练 checkpoint
--compile / --no-compile compile(开) 是否对模型执行 torch.compile(),通过 argparse.BooleanOptionalAction 提供成对开关
--output_file output.csv 写入 results/ 目录的 CSV 文件名
--num_workers 4 传给模型预测 ThreadPoolExecutormax_threads
--profile off (代码中额外提供)是否用 torch.profiler 分析后端并导出 trace.json,README 未列出

注意一个文档与代码不一致的细节:README 参数表中标注 num_workers 默认值为 2,而当前 server.pyargparse 实际默认值是 4。若你的实验依赖默认值,请以源码为准或显式传入该参数,避免统计口径偏差。

2. 运行前置条件

  • 环境变量/依赖:torchtorchvisionnumpypandas;脚本默认把推理设备硬编码为 cuda:0,因此需要一块可用 GPU;
  • 需要系统提供 nvidia-smi(GPU 利用率指标依赖它,若轮询失败该项会记为 N/A 并被跳过);
  • checkpoint:若 model_dir 下不存在 resnet18-f37072fd.pth,主程序会自动通过 wget 下载官方 ResNet-18 预训练权重;若本次运行完成了下载,程序退出时会自动删除该文件做清理;
  • 工作目录:脚本把结果写在相对路径 ./results/ 下,因此请在 benchmarks/inference 目录内执行(results/ 子目录需已存在)。

3. 样例命令

README 给出的单次基准运行命令为:

python -W ignore server.py --num_iters 1000 --batch_size 32

-W ignore 用于抑制第三方库的告警输出,避免干扰终端可读性。运行结束后,结果以追加写的方式落在 results/output.csv;如果该文件已存在,新行会被追加到末尾,不会覆盖历史数据。CSV 的列由 metrics_dict 中的键构成,大致包括:batch_sizecompiletorch_load_timem_compile_time(仅开启编译时存在)、warmup_latencyaverage_latencymax_latencymin_latencythroughputgpu_util

4. 输出指标的语义

结合 server.py 的实现,各核心指标的定义如下:

  • warmup_latency:首个(warmup)请求的端到端耗时,即服务“冷启动”到能返回第一个结果的时间。它包含了模型加载与(若开启)PT2/torch.compile() 首轮真正编译的开销;
  • average_latency / max_latency / min_latency:对 num_iters 个正式请求的端到端延迟做均值、最大值、最小值统计;
  • throughput(num_iters * batch_size) / (end_recv_time - start_send_time),即从开始发送正式请求到收到最后一个响应之间的样本吞吐量(samples/sec);
  • gpu_util:warmup 完成后 GPU 利用率线程轮询期间的平均 GPU 利用率(百分比)。

5. 关于 m.compile() 计时的特别说明

README 特别提醒一个容易误读的细节:CSV 中的 m_compile_time 并不是 torch.compile() 真正完成图编译的耗时。对 PyTorch 2.x(PT2)而言,模型的实际编译发生在第一次迭代(首个请求)执行期间,而 m_compile_time 记录的只是 m.compile() 调用返回前、PT2 组件(例如 Triton 等后端)被惰性导入所花费的时间。因此解读该列时,应把它视为“编译基础设施初始化成本”,而不是端到端编译延迟。

五、批量扫描(Sweep):runner.sh 与 process_metrics.py

单次运行只能反映某一组配置下的表现,而 README 设计的场景是跨 batch size × 是否编译的组合扫描。

1. runner.sh 的扫描流程

runner.sh 接收一个必填参数 experiment_name(例如 ./runner.sh my_first_sweep),随后执行:

  1. 定义扫描矩阵:batch_size_values=(1 32 64 128 256)compile_values=(true false),共 10 种组合;每个组合内的重复次数 num_iters=10
  2. 若工作目录缺少 checkpoint,先下载 resnet18-f37072fd.pth
  3. 对每种 (batch_size, compile) 组合:
    • output_{batch_size}_{compile}.csv(如 output_128_true.csv)为输出文件名,用 --compile--no-compile 反复运行 server.py 共 10 次;
    • 调用 python process_metrics.py --csv output_128_true.csv --name <experiment_name> 聚合该组合的统计结果;
    • 删除中间 CSV,避免污染下一次循环;
  4. 若 checkpoint 由脚本下载,则在结束时删除并提示清理完成。

2. process_metrics.py 的结果聚合

process_metrics.py 读取 CSV 后,先从文件名解析出 batch_sizecompile(文件名格式约定为 output_{batch_size}_{compile}.csv),然后对 warmup_latencyaverage_latencythroughputgpu_util 四项分别计算均值与标准差,把结果作为一行追加到 results/output_{batch_size}_{compile}.md

Markdown 表头在文件首次创建时写入一次,列结构为:

Experiment Warmup_latency (s) Average_latency (s) Throughput (samples/sec) GPU Utilization (%)

每行格式为:实验名 | warmup 均值 ± 标准差 | 平均延迟均值 ± 标准差 | 吞吐量均值 ± 标准差 | GPU 利用率均值 ± 标准差。这与 README 中“如果文件已存在则作为新行追加到 Markdown 表格”的描述一致——同一组合的多次实验(用不同 experiment_name 标识)会被累积在同一张表中,便于纵向比较改动前后或不同配置的差异。

3. 仓库中已沉淀的示例结果

results/ 目录当前保留了几组真实运行记录,可作为理解输出的样例:

  • results/output_128_true.md(batch size 128、compile 开):记录了两个实验 originalh2d_d2h_threads 的对比,其中 h2d_d2h_threads 版本平均延迟从约 14.250 s 降至 12.774 s、吞吐量从约 523 samples/sec 提升至 601 samples/sec
  • results/output_1_false.md(batch size 1、compile 关):记录了 originalh2d_d2h_threads 以及 2/3/4_predict_workers 四个实验,其中 2_predict_workers 在平均延迟 0.369 s、吞吐量 186.485 samples/sec 上表现最好。

六、开发演进:从单线程到多 worker 流水线(CHANGELOG 视角)

该基准的迭代过程记录在 CHANGELOG.md 中,对理解当前代码形态与结论很有价值:

  • 最初形态:后端是一个独立进程,串行“读队列 → 跑 forward → 放回响应队列”。随后引入单 worker 的 ThreadPoolExecutor,把 forward 与响应异步化,使后端在等待期间不阻塞队列轮询。实验表明 warmup 延迟改善(后端不再需要新起进程),但其余指标反而变差。
  • 指标口径修复:修掉了两个测量 bug——其一,请求之间原本夹着 CPU 上生成 torch.randn 假数据的时间,导致间隔随 batch size 不成比例地放大、延迟不可跨 batch size 比较;其二,吞吐量从 (num_batches * batch_size) / sum(response_times) 修正为 (num_batches * batch_size) / (last_response_time - first_request_time)。同时修复了“响应以 GPU 张量发给前端”的问题,并用信号量保证多线程对 metrics_dict 写入的线程安全。由于口径变化,旧基线被重置。
  • H2D / D2H 双线程池:为数据拷贝与结果回传各建一个单 worker 线程池并配独立 cuda.Stream,期望把 H2D/D2H 与计算重叠。实测小 batch(1/32/64)下指标反而变差,而大 batch(128/256)下平均延迟、吞吐量与 GPU 利用率都得到改善——这也解释了为何 results/ 中保留的表格同时含有 originalh2d_d2h_threads 两组实验名。
  • 多推理 worker:新增 --num_workers,使模型预测线程池可大于 1,每个 worker 线程初始化时创建私有 CUDA 流。需要留意的是,开发记录明确说明该组实验只在 compile=False 下进行,因为当时 torch.compile() 并非线程安全。2 个 worker 时所有 batch size 指标相对单 worker 都有改善,其中 batch 128/256 甚至全面超越原始基线(如 batch 256 吞吐量提升约 300 samples/sec)。

对应的两张结果图存放在 src/throughput_plot.pngsrc/avg_latency_plot.png,直观展示了不同 worker 数量下吞吐量与平均延迟随 batch size 的变化。

不同 worker 数量下吞吐量随 batch size 的变化

不同 worker 数量下平均延迟随 batch size 的变化

七、适用范围与注意事项

使用与解读这套基准时,有几个边界需要明确:

  1. 这是仿真而非完整服务:README 将其定义为 WIP,且明确省略数据预处理与结果后处理,测量结果代表推理链路的耗时下限;
  2. 硬件强相关:设备被硬编码为 cuda:0,GPU 利用率依赖 nvidia-smi;不同 GPU、驱动、PyTorch 版本下的绝对数值不可直接横向比较,更适合作为同一环境内改动前后的相对参照;
  3. torch.compile() 的线程安全限制:开发记录表明多推理 worker 实验仅在 --no-compile 下开展;若你需要在编译开启时提高 num_workers,应先确认所用 PyTorch 版本的线程安全性;
  4. 结果文件是累积式的:CSV 与 Markdown 均采用追加语义,多次运行同一 --output_file 或同一 (batch_size, compile) 组合会把新行追加到既有文件,对比时请留意行与 experiment_name 的对应关系;
  5. 文档与代码存在小差异:除上文提到的 num_workers 默认值(README 写 2、源码为 4)外,README 的示例命令省略了 --output_file 等参数,实际以 server.pyargparse 的声明为准。

总体而言,benchmarks/inference 提供了一条轻量、可复现的路径来量化“服务化推理”场景下的端到端延迟与吞吐:以 server.py 跑单点实验、以 runner.sh 跑批量矩阵、以 process_metrics.py 沉淀均值/标准差报表。若你正在评估 torch.compile、batch size 策略或推理并发度对服务端性能的影响,可以直接复用这套队列驱动的前后端骨架与指标口径。

登录后查看全文
热门项目推荐
相关项目推荐