单卡部署 Nemotron 3 Embed 这类 embedding 模型,把批大小压到显存红线附近,通常拿不到最高吞吐:显存越紧,分配器可腾挪的空间越小,批再往上加多半换来延迟抬升、偶发显存不足,甚至整体变慢。可以先把批大小从 1 开始逐档压测,记录每档的吞吐和峰值显存,找出吞吐不再上涨的那一档作为配置上限,再按线上并发波动留出一档显存余量。
适用场景是单卡、输入长度相对稳定的本地 embedding 服务。操作顺序是:先测空载显存和单条延迟作为基线,再按 1、2、4、8 这类档位逐档压测,记录吞吐与峰值显存,把吞吐增量明显收窄的上一档定为候选批大小;上线前再用并发压测补测峰值显存,留出至少一档批大小的余量。边界在于:输入长度分布变化、并发突增都会移动拐点,拐点只能作为参考上限,不是恒定最优值。
测空载显存与单条推理延迟
这一步只建立基线,不做优化。先记录两个数:模型加载完、没有请求进来时的显存占用,以及单条请求的延迟分布。后续每档批大小的峰值显存都拿这两个数当参照,才能判断是模型本身占得多,还是批大小推高了峰值。
显存观测用采样落盘的方式,比手工看屏幕可靠:
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu \
`--format`=csv,noheader -lms 200 > gpu_samples.csv
单条延迟的测法:固定一段或几段代表性文本(长度分布贴近线上),先跑若干次预热并丢弃结果,再连续跑多次取中位数和尾部分位数。如果服务是 HTTP 接口,就在客户端计时;如果是进程内调用,就在调用前后用 time.perf_counter() 计时。基线记录表建议长这样,数值按实测填写:
| 项目 | 记录值 | 说明 |
|---|---|---|
| 空载显存 | 模型加载后、无请求 | |
| 单条峰值显存 | batch=1 压测期间采样最大值 | |
| 单条延迟 P50 / P95 | 预热后多轮统计 | |
| 输入长度 | 压测用的固定长度或分布 |
批大小逐档递增,同时记录吞吐与峰值显存
压测脚本的骨架是同一个:同一份文本、同一批档位、每档固定轮数,每轮记录耗时,并同步抓取该档期间的显存峰值。下面的骨架用可替换的 call_model 占位,换成你实际的调用方式即可。
import statistics, time
TEXTS = [t.strip() for t in open('corpus.txt', encoding='utf-8') if t.strip()]
def call_model(batch):
# 替换为真实调用:进程内推理或 requests.post(ENDPOINT, json={'input': batch})
raise NotImplementedError
def run_batch(batch_size, rounds=20):
lat = []
for _ in range(rounds):
batch = TEXTS[:batch_size]
t0 = time.perf_counter()
call_model(batch)
lat.append(time.perf_counter() - t0)
p50 = statistics.median(lat)
lat_sorted = sorted(lat)
return {
'batch_size': batch_size,
'p50_s': round(p50, 4),
'p95_s': round(lat_sorted[max(0, int(len(lat_sorted) * 0.95) - 1)], 4),
'throughput_qps': round(batch_size / p50, 3),
}
for bs in [1, 2, 4, 8, 16, 32]:
try:
print(run_batch(bs))
except Exception as e:
print({'batch_size': bs, 'error': repr(e)})
break
每档跑的时候让显存采样在同时间段内进行,事后取该档窗口内的最大值。对照表按档位纵向排列:
| 批大小 | 单批 P50 | 吞吐(条/秒) | 峰值显存 | 现象 |
|---|---|---|---|---|
| 1 | 基线 | |||
| 2 | ||||
| 4 | ||||
| 8 | ||||
| 16 |
失败档位要写清现象,不要只写“失败”:是直接抛出显存不足报错,还是没报错但单批耗时明显拉长、吞吐回落;是每轮都失败,还是只有连续跑若干轮后才失败。后者往往和显存碎片、缓存复用有关,说明这一档虽然偶尔能跑通,但不适合当线上配置。
找出吞吐不再上涨的那一档
拐点判定用相邻两档对照,不看单点。做法是算每档相对前一档的吞吐增量:增量还在明显变大,就继续往上加;增量收窄到接近于平,甚至吞吐已经回落,而峰值显存还在涨,就停在前一档。阈值自己定,比如增量不到前一档增量的一半就视为收窄,重要的是判定规则事先写下来,避免事后挑数据。
数据呈现建议直接画两条线:横轴批大小,一条是吞吐,一条是峰值显存。吞吐线开始走平、显存线仍在爬升的位置,就是拐点区间。相邻两档的对照表把差异摆出来更直观:
| 档位 | 吞吐 | 较前一档增量 | 峰值显存 | 结论 |
|---|---|---|---|---|
| 拐点前一档 | 候选上限 | |||
| 拐点档 | 继续加收益有限 | |||
| 拐点后一档 | 吞吐回落或失败 |
注意,拐点是在你压测用的输入长度下测出来的。如果线上有长文本,单条激活占用会变大,同一档批大小可能直接顶到显存上限,所以候选上限最好按长文本再复测一次。
线上并发波动时需要留多少显存余量
拐点只说明“再加批没收益”,不说明“这样配不会被打崩”。线上请求是并发进来的,队列里排着的请求同样会占用显存或占用调度资源,所以余量要按并发峰值估,而不是按批大小的峰值估。
估算方法偏保守:先取拐点档测出的峰值显存,再加上至少一档批大小对应的显存增量作为缓冲,剩余部分才是可被并发占用的空间。如果服务端把并发请求也做动态合批,那么 max_batch_size 与在途并发要一起限制,二者任一放大都可能把峰值推高。
观测步骤:用并发客户端(如固定并发数的压测工具,或简单的多线程请求脚本)从低并发逐步往上打,每档稳定运行一段时间,同时用前面的 nvidia-smi 采样命令记录显存曲线,并记录该并发下的延迟分位数、超时数和报错数。记录方式建议一行一档:
并发数, 峰值显存, P50延迟, P95延迟, 超时数, OOM次数
4, ..., ..., ..., ..., ...
8, ..., ..., ..., ..., ...
当某一档开始出现超时或显存报错,就取它前一档的并发值,与拐点批大小一起作为线上上限。这里的关键是记录“最先出问题的信号是什么”,它比一个笼统的可用并发数更有用。
把最终批大小与并发上限写进配置
把压测结论固化成配置字段,写明来源和适用条件,方便复现和回滚。示例字段如下,数值按你的压测结果填写:
embedding:
model: nemotron-3-embed
max_batch_size: <拐点前一档> # 来源:本地逐档压测,非显存上限
max_concurrency: <并发观测的安全档>
max_input_tokens: <压测使用的长度上限>
queue_timeout_ms: <超过则快速失败,避免排队拖高显存>
gpu_memory_reserve: <按一档批大小估算的余量>
灰度验证方式:先只改一份配置副本或一个实例,用与压测相同的输入分布打一轮中等并发,观察日志里的峰值显存、超时计数和批大小实际分布,确认实际生效的批大小没有长期贴着 max_batch_size 上限运行。确认稳定后再同步到其他实例。
回滚判据提前写死,常见几条:出现显存不足报错、P95 延迟超过业务可接受阈值、超时率出现可见上升、或者日志中批大小长期顶格且延迟随之抖动。任一条触发就回滚到上一版配置,并把当时的显存采样和日志留存,作为下一轮调参的输入。