先说一个很多团队都会踩的场景:模型在训练环境里精度好得不行,一上生产就“反应迟钝”,业务方拿着压测报告过来问“到底能扛多少并发”。这时候你得有一套能快速给出量化结论的方案。我最近在做某个图像分类服务的上线前验证,用的就是PaddlePaddle Serving镜像部署模型,配合Locust做压力测试,整体流程跑通之后,结论和调优方向都清晰了很多。这篇就把完整链路拆开讲清楚,从镜像选型、服务部署到Locust脚本编写、指标解读和瓶颈定位,适合刚接触模型压测、或者正在为上线评估容量的同学参考。
先说清楚这套组合解决什么问题:PaddlePaddle Serving镜像负责把训练好的模型包装成一个标准化的推理服务,对外提供HTTP接口;Locust负责模拟大量并发用户,用真实请求去“撞”这个服务,从而测出服务的吞吐量、响应延迟、错误率等关键指标。两者一结合,你就能回答“当前部署配置下,模型服务在多少QPS时开始变慢”“P95延迟是否满足SLA”“需不需要加机器或调batch”这类问题。
1. 方案整体拆解:为什么是PaddlePaddle Serving镜像 + Locust
1.1 模型服务化部署的常见选型与取舍
模型训练完之后,直接拿Python脚本起一个Flask服务也能对外提供推理,但生产环境远不止“能响应请求”这么简单。你需要考虑模型版本管理、多模型串联、请求排队、批处理优化、GPU显存复用、指标监控、优雅扩缩容等问题。从头造轮子成本太高,所以现实里绝大多数团队会走两条路:一条是直接用模型的官方服务化组件,另一条是上通用的推理服务框架。
PaddlePaddle Serving就是前者的典型代表。它的核心价值在于深度适配Paddle模型:从模型转换、加载、算子优化到推理引擎的底层调度,都做了针对性优化。用官方镜像部署,意味着不操心CUDA版本、推理引擎编译、模型格式转换这些底层细节,直接把训练好的模型丢进约定目录,启动容器就得到一套带HTTP/gRPC接口的服务。这对于“训练团队顺手把模型推上线”的场景特别友好,不需要专门配一个推理平台工程师。
通用框架也不是不能选,比如Triton Inference Server这类方案在多模型管理、动态批处理、异构硬件支持上确实更强,但配置复杂度明显更高,需要额外理解模型仓库的目录约定和框架自身的参数语义。如果你的模型以Paddle生态为主,团队也没有专门做推理平台的人力,优先用PaddlePaddle Serving镜像投入产出比最高。我见过一些团队先把服务跑在通用框架上,遇到性能问题排查时发现很大一部分精力花在了框架本身的调参上,反而偏离了“验证模型服务性能”这件事本身。
1.2 Locust相比其他压测工具的优势
压测工具这块,可选方案不少:ApacheBench、wrk、JMeter、Locust等,各有各的适用场景。我选Locust,核心原因是它用Python写测试脚本,能把“模拟用户行为”这件事情做到很细。
ApacheBench和wrk的优势是并发能力强、性能开销极低,但它们更偏向“无脑打满请求”的基准测试。wrk虽然能用Lua脚本扩展,但生态和上手成本对不熟悉Lua的人不太友好。JMeter功能全面,但脚本通常要依托它的GUI和特定组件体系,想实现“按业务比例混合请求”“逐渐增加并发用户”这类行为,配置成本偏高。Locust则非常直白:每个并发用户就是一个Python协程,你写清楚用户要做什么请求,剩下的事情框架帮你编排。
Locust还有一个非常实用的特性是分布式压测。压测机本身也是资源,当单台机器跑不出足够大的压力时,可以启动一个master和多个worker,把压力生成能力横向扩展。而且它的Web界面自带实时统计:当前RPS、响应时间分布、失败率都在一个页面上,不用自己写监控脚本。对于模型服务压测这种“需要观察长时间稳定性、同时又要多次调整参数重跑”的场景,这个实时面板能节省大量时间。
注意:压测工具选型没有绝对的“最好”,关键看你要回答什么问题。如果只是快速确认接口通不通、大概能打多少请求,wrk一把梭更省事;但涉及多场景混合、排队行为模拟、并发模型接近真实业务,Locust的优势就出来了。
2. 前置准备:镜像启动、模型加载与接口验证
2.1 环境检查与镜像启动细节
动手之前先确认三件事:GPU驱动和CUDA版本与所选镜像是否匹配、Docker是否支持GPU(比如是否装了nvidia-container-toolkit)、模型目录的文件权限是否正常。这三项里任何一项出问题,都会在压测阶段以诡异的现象暴露出来,比如容器日志一切正常但请求一直超时,或者GPU利用率死活上不去。
启动命令大致是这个形态:
docker run -d --name paddle-serving \ -p 9393:9393 \ --gpus all \ -v /data/models:/models \ -e SERVING_THREADS=4 \ -e SERVING_OP=batch_op \ paddlepaddle/serving:latest-gpu几个关键参数说下。端口映射保证宿主机能访问到服务;--gpus all让容器使用所有GPU,如果你有多卡想指定某张卡,改成--gpus '"device=0,1"'即可。模型目录挂载到容器的/models下,PaddlePaddle Serving会按约定的子目录结构去加载模型配置。SERVING_THREADS控制服务线程数,这个值不是越大越好,后面压测调优部分会细说。
2.2 确认模型服务接口的正确姿势
镜像起来之后别急着上压测。先用curl手动请求一次,确认接口路径、请求体格式、响应结构都符合预期。这个步骤能过滤掉大量低级问题,比如路径写错、输入字段名不匹配、返回值解析错了。
curl -X POST http://127.0.0.1:9393/ocr/prediction \ -H "Content-Type: application/json" \ -d '{"image": "base64字符串"}'一个容易忽略的细节是:PaddlePaddle Serving的接口路径由模型配置决定,不是统一的/predict,你需要到模型目录下的serving_server_conf.prototxt里确认实际路径。我在第一次部署时默认以为路径是/inference,结果curl返回404,排查了半天发现是模型配置里定义的endpoint是/ocr/prediction。省时间的方法:进容器看日志,启动成功时服务会打印监听端口和注册的接口路径。
2.3 简单基准先行:用命令行工具探底
正式用Locust压测之前,先用一段极简脚本探探服务的吞吐底。这一步的目的是心理有数:当前服务大概在什么量级,Locust脚本里初始并发数该给多少比较合理。如果服务本身只能扛50 QPS,你直接上500并发,结果全是超时,反而没法区分问题是服务弱还是压测配置不合理。
ab -n 1000 -c 20 -p request.json -T application/json http://127.0.0.1:9393/ocr/prediction或者用wrk配合一个简单的POST脚本:
wrk -t 4 -c 50 -d 30s -s post.lua http://127.0.0.1:9393/ocr/prediction这个阶段不用追求压满,拿到“这个服务大概能承受多少QPS而不报错”就够。注意观察服务端GPU利用率,如果GPU已经接近100%而CPU还有余量,说明瓶颈大概率在推理计算侧;反过来GPU利用率不高、CPU爆满,则要先怀疑预处理、数据拷贝或服务框架本身的瓶颈。
3. Locust压测脚本编写与执行
3.1 从HttpUser开始:写一个最小压测脚本
Locust的核心编程模型是:定义一类用户,用户继承HttpUser,在@task装饰的函数里写具体的请求逻辑。最小可用的压测脚本长这样:
from locust import HttpUser, task, between class PaddleInferenceUser(HttpUser): wait_time = between(0.1, 0.5) @task def predict(self): payload = { "image": "这里放base64编码的测试图片" } with self.client.post( "/ocr/prediction", json=payload, catch_response=True, name="/ocr_prediction" ) as resp: if resp.status_code != 200: resp.failure(f"Unexpected status: {resp.status_code}") else: resp.success()两点说明:wait_time模拟用户在两次请求之间的思考时间,压测模型服务时这个值不宜设得太大,否则并发用户产生的实际QPS远低于预期;catch_response=True必须加上,这样遇到非200响应时会记录为失败,而不是让Locust抛出异常中断压测。
跑起来的方式有两种。开发调试阶段用Web模式最直观:
locust -f locustfile.py --host http://127.0.0.1:9393浏览器打开http://localhost:8089,填上总用户数和每秒新增用户数,就能实时看到RPS、响应时间、失败率等指标。自动化或定时压测场景则用无界面模式:
locust -f locustfile.py --host http://127.0.0.1:9393 \ --headless -u 200 -r 10 --run-time 30m \ --csv=result/paddle_serve_result-u 200表示总并发200个用户,-r 10表示每秒增加10个用户,--run-time控制总时长,--csv会把统计数据落到文件里,方便事后画曲线图。
3.2 混合业务场景:让模拟更接近真实调用
真实业务里,模型服务通常不是只被一种调用方式打。比如健康检查接口会被监控系统频繁探测,推理接口被主业务调用,可能还有一小部分请求在拿错误参数做校验。这些不同性质的请求对服务的压力模式不同,Locust用不同任务的权重来模拟。
class PaddleMixedUser(HttpUser): wait_time = between(0.2, 1.0) @task(8) def predict(self): # 推理请求,占比最高 pass @task(2) def health_check(self): with self.client.get("/health", catch_response=True) as resp: if resp.status_code != 200: resp.failure("Health check failed") @task(1) def bad_request(self): with self.client.post( "/ocr/prediction", json={"invalid_key": "value"}, catch_response=True ) as resp: if resp.status_code < 400: resp.failure("Should have returned 4xx")我习惯把健康检查以较小权重混进来,是因为线上真实情况中探活请求一直存在,压测时完全忽略它会低估服务实际承受的请求总量。这个细节在长稳态压测中尤其明显,跑几小时后你翻数据会发现健康检查请求在总请求里占了一小部分,虽然不会压垮服务,但会影响日志量和连接数。
3.3 关于测试数据的准备:不要用同一张图打到底
一个新手常犯的错误是压测全程只发同一张测试图片。短时间跑可能看不出问题,长时间跑会导致服务端对同一输入的缓存逻辑被激活,QPS虚高,或者反过来因为某张特殊图片触发了某个算子分支,导致结果偏慢。我通常准备一个小批量的数据集,循环使用,保证不同请求之间的输入有一定差异。比如准备几十张不同尺寸和内容的图片,转成base64后放在内存列表里,每个用户随机取。
import random import base64 with open("test_images.txt") as f: image_pool = [line.strip() for line in f.readlines()] class PaddleInferenceUser(HttpUser): wait_time = between(0.1, 0.3) @task def predict(self): img = random.choice(image_pool) payload = {"image": img} ...这个细节直接影响压测数据的可信度。你压测的目的是评估服务在“接近真实负载”下的表现,而真实请求的输入千变万化,输入分布太单一会让结果失真。
3.4 分布式压测:当单机压力生成不够时
Locust的分布式模式并不复杂:一台机器作为master,负责汇总统计和Web展示;其他机器作为worker,只负责发请求。启动方式是在worker机器上指定master的地址。
# master locust -f locustfile.py --host http://target-server:9393 --master # worker locust -f locustfile.py --host http://target-server:9393 --worker --master-host=master-ip什么时候需要分布式?判断标准很简单:观察worker机器的CPU使用率是否接近100%,如果压测机CPU已经吃满但目标服务RPS还在低位,说明压力生成端成了瓶颈。模型服务压测尤其是GPU服务,单机压测机经常能跑到一两千QPS,所以大多数场景单机足够,但如果你压的是高吞吐的文本分类模型,单机生成的压力可能不够打满目标。
提示:分布式模式下尽量让worker和master之间的网络延迟足够低,最好在同一个内网。master和worker通信频繁,跨公网跑会导致统计延迟偏高,影响控制台数据的实时性。
4. 运行结果解读、瓶颈识别与服务端调优
4.1 先看懂Locust报告里的关键指标
Locust结果页面上指标不少,我判断服务健康状况时重点看四个:RPS、响应时间百分位、失败率和当前用户数。RPS就是服务实际每秒处理的请求数,等价于QPS。响应时间重点看P95和P99而不是平均值,均值容易掩盖长尾问题。失败率超过0.1%就要警觉,模型服务跟普通网站不同,个别请求超时往往意味着推理服务内部已经出现了排队积压。
跑完一轮压测把CSV保存下来,用Python画一下响应时间随时间变化的曲线,能直观看到服务在哪个时间点开始恶化。比如我压某个模型时,200并发以前P95稳定在30毫秒左右,300并发时P95突然跳到100毫秒以上,这个拐点就是服务的容量预警线。
4.2 定位瓶颈:CPU、GPU还是网络层
Locust报告展示的是客户端视角,想定位问题根因必须结合服务端的观测。我压测时会额外开几个窗口,实时看nvidia-smi输出和容器CPU占用。
- GPU利用率接近100%,同时GPU显存有波动:瓶颈在推理计算侧,优先优化模型或调整推理参数。
- GPU利用率不高,CPU多核跑满:先怀疑预处理逻辑、数据解码、请求反序列化等CPU密集操作。
- GPU和CPU利用率都不高,但响应延迟高:先看网络层,确认压测机和目标服务之间的带宽、连接数、TCP参数是否到位。
- 响应时间分布呈现“周期性尖峰”:大概率是某个定时任务、日志刷新或垃圾回收在干扰,持续观察几个周期才能确认。
模型服务的延迟特征跟普通Web服务非常不同。对于静态页面,网络传输和Web框架开销占大头;对于推理服务,GPU计算本身是强耗时环节,输入数据大小、batch策略、模型结构都会显著影响单次推理时间。压测时要对“响应时间为什么这么高”有合理的预期。
4.3 关键调优手段:从并发、Batch到线程数
如果压测结果显示服务在并发上升时性能急剧下降,不要急着加机器。模型服务领域有几个经典调优方向可以先用上。
第一个是调整并发线程数。Paddle Serving的SERVING_THREADS不是越大越好,线程过多会导致CPU上下文切换开销盖过推理本身的收益。我压过一个模型,4线程时QPS 300,改成8线程反而掉到250。合理的线程数与模型的计算特点和CPU核数相关,没有固定公式,只能通过短时压测来试探。
第二个是启用动态batch。推理服务的一个特点是单请求推理时GPU利用率往往不高,把小请求攒起来一次性推理能大幅提升吞吐。Paddle Serving支持在配置里打开batch功能,实现原理是多个请求在队列里积攒一定数量或等待短暂时间后合并成一个大batch输入模型。这个优化对短文本分类、小尺寸图片这类轻量推理场景收益极大。实际压测对比中,我见过开启动态batch后QPS从600一路冲到2000的案例。但要注意,动态batch会引入额外延迟,因为请求需要排队等待“凑批”,对于延迟敏感业务要谨慎权衡。
第三个是设置合理的超时时间。推理服务压测时如果超时时间设得太长,大量慢请求长时间占着连接,会让Locust一侧的资源堆积,造成“服务端没倒、压测端先倒了”的假象。我习惯把请求超时设置在正常P95延迟的2到3倍左右,超过这个值直接标记失败。
4.4 压测结论的量化输出
跑完所有压测之后,最终的结论不是一个数字,而是一组带有边界条件的表述。我通常这样整理压测报告:
| 并发用户数 | 平均QPS | P95延迟(ms) | P99延迟(ms) | 失败率 | GPU利用率 |
|---|---|---|---|---|---|
| 50 | 183 | 42 | 55 | 0% | 60% |
| 100 | 310 | 48 | 72 | 0% | 82% |
| 200 | 405 | 89 | 140 | 0.02% | 96% |
| 300 | 420 | 150 | 230 | 0.15% | 97% |
这样一张表既能看到服务的能力上限在哪个区间,也能给业务方一个明确回答:当前配置能稳定支持什么样的流量规模。同时注意把“压测条件”写清楚,比如用了哪种输入数据分布、压测机配置、网络环境、是否开启动态batch,缺少这些边界条件的压测结论很容易误导决策。
5. 常见问题排查与实操体会
5.1 典型故障速查表
这段时间跑下来,把最容易遇到的现象和对应排查思路整理成一个表,遇到类似情况可以直接查。
| 现象 | 可能原因 | 排查/解决思路 |
|---|---|---|
| curl返回404 | 接口路径不对 | 查看模型配置文件里的endpoint字段 |
| 请求一直超时 | 模型未成功加载 | 进容器看日志,检查模型目录路径和文件权限 |
| 偶发连接拒绝 | 服务线程耗尽 | 增加SERVING_THREADS或后端队列容量 |
| GPU利用率低但CPU高 | 预处理/请求解码耗CPU | 检查输入数据格式,避免多余base64解码 |
| 高并发下错误率飙升 | 动态batch排队超时 | 调整batch等待时间或增加worker数 |
| QPS上不去且延迟平稳 | 压测机侧瓶颈 | 看压测机CPU,必要时用分布式模式 |
| 响应时间周期性尖峰 | 日志刷盘/定时任务干扰 | 关掉debug日志,观察周期是否消失 |
| 服务异常退出 | 显存不足 | 减小batch,或者换更大显存的GPU |
5.2 我的几条实操心得
第一,压测请求的body不要每次现拼。Python里base64编码和JSON序列化都是CPU密集操作,如果在Locust脚本里每次都现场处理输入,压测机本身的CPU会先撑不住。正确做法是启动时一次性把测试数据加载到内存,压测运行期间只做random.choice和请求发送。
第二,压测机的网络参数值得提前调一下。默认的Linux网络栈参数在短连接高并发场景下会限制端口和连接数。我用一个通用优化脚本,主要是扩大端口范围、开启time_wait复用、调大文件描述符上限,可以在压测时明显减少connection reset异常。
第三,长时间稳定性压测是必须的。很多性能问题在短时压测里根本露不出来,比如内存泄漏、句柄泄漏、显存碎片化,只有跑到30分钟以上才能暴露。我压测某次跑了4个小时,在1小时左右开始出现响应时间缓慢上涨,最后定位到是服务端日志队列积压导致的。
第四,压测结果要用变化趋势来看,不要只看瞬时值。压测过程中用户数是逐步增加的,RPS和延迟都有一个“追上压力”的过程。用保存的CSV数据画图,观察曲线渐变过程,能更准确判断服务的容量拐点。
5.3 关于Paddle Serving镜像本身的一些补充
使用官方镜像时有个常见的坑:新手经常把训练时的模型目录直接挂载进去,但这个目录里的文件格式未必是 Serving 能直接加载的。正确流程是先用工具做模型转换,确保目录下包含serving_server_conf.prototxt这类配置文件。如果日志里报模型加载失败,不要急着改代码,先用镜像自带的模型检查工具确认文件格式。
镜像版本跟Paddle框架版本要对齐,这是个我踩过一次的坑。训练环境里用的是较新版本的Paddle框架,镜像里的Serving组件版本过旧,结果模型转换时某些算子不兼容,服务启动时报了一堆op错误。解决方式是统一训练和部署的版本,或者在模型转换时禁用不兼容的算子。
还有一个与压测相关的镜像参数:SERVING_BATCH_OP相关的队列配置。这个参数决定了请求在进入动态batch队列前能积攒多少,直接影响高并发下的吞吐和平滑度。压测时如果发现QPS曲线剧烈波动,可以先从这类队列参数入手调整。