本地把 Nemotron 3 Embed 起成嵌入服务,模型加载成功、单条能返回向量,但一批量就显存打满报错,最常见的成因不是显存不够,而是批大小和单条文本长度在共同抬高显存峰值。建议按变量隔离的方式定位:先记录空载基线,固定文本长度只抬批大小,再固定批大小改长度,把两条趋势对照后,决定启动参数里的最大长度和最大并发序列数该压到哪一档。
本地嵌入服务的显存峰值基本由「批大小 × 单条长度」这个乘积决定,两者权重不同。可操作路径是先跑通单条短文本拿到空载基线,再逐档抬批大小,记录首次失败的档位,然后反固定批大小、把长度翻档,看哪条曲线更陡。保守配置取首次失败档位的前一档,写进启动参数并在重启后用同一批数据复跑验证峰值回落。所有判断以你自己机器上的 nvidia-smi 记录为准,不要套用他人机器的档位。
先跑通单条短文本并记录空载显存
这一步只做一件事:确认服务本身能正常返回向量,并把「没发请求时已经占了多少显存」这个基线固定下来。后面所有峰值都相对这条基线算增量,否则很容易把模型权重占用的部分误判成批大小的锅。
启动命令骨架如下,推理框架和参数名请替换成你本地实际安装的版本,比如换成 TEI 的 launcher 也一样,只要保证最大长度和最大并发是显式指定的、而不是走默认值:
# 骨架示例,参数名以本地安装版本为准
python -m vllm.entrypoints.openai.api_server \
`--model` /models/nemotron-3-embed \
`--task` embed \
`--served-model-name` nemotron-embed \
`--max-model-len` 2048 \
`--max-num-seqs` 8 \
`--gpu-memory-utilization` 0.85 \
`--port` 8000
显存观测建议在另一个终端常驻轮询,把输出重定向到文件,方便事后和压测脚本的时间戳对齐:
# 每秒采样一次已用显存,Ctrl+C 结束
nvidia-smi `--query-gpu`=index,memory.used,memory.total `--format`=csv,noheader -l 1 | tee mem.log
# 想直接看最小值/最大值曲线也可以用
nvidia-smi dmon -s m -d 1
基线记录表建议按下面这个结构填,只填你自己看到的数值,不要凭印象补:
| 阶段 | 触发方式 | 显存已用(MiB) | 备注 |
|---|---|---|---|
| 空载 | 服务未启动 | 待填 | 系统常驻占用 |
| 加载完成 | 启动日志出现监听端口 | 待填 | 不含任何请求 |
| 单条短文本 | 输入 1 条约 32 token 的文本 | 待填 | 增量应很小 |
| 单条长文本 | 输入 1 条约 2048 token 的文本 | 待填 | 观察长度单独作用 |
单条请求可以用 curl 直接打,确认返回结构里确实有向量字段再进入下一节:
curl -s http://127.0.0.1:8000/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{"model":"nemotron-embed","input":"一段用于冒烟测试的短文本"}' | head -c 400
固定文本长度,只把批大小逐档往上抬
把输入文本锁死成同一段,长度保持不变,只改一次请求里塞几条。这样涨上去的显存就只能归因于批大小。档位建议取 1、2、4、8、16、32、64,遇到报错就停,不要为了凑档位调大超时硬等。
import time, requests, subprocess
BASE = "http://127.0.0.1:8000/v1/embeddings"
MODEL = "nemotron-embed"
SAMPLE = "固定成同一段文本,长度不要变" # 本节唯一变量是批大小
def used_mib():
out = subprocess.check_output([
"nvidia-smi", "`--query-gpu`=memory.used",
"`--format`=csv,noheader,nounits"
]).decode().strip().splitlines()[0]
return int(out)
peak = 0
for bs in [1, 2, 4, 8, 16, 32, 64]:
payload = {"model": MODEL, "input": [SAMPLE] * bs}
t0 = time.time()
try:
r = requests.post(BASE, json=payload, timeout=180)
cur = used_mib()
peak = max(peak, cur)
print(f"bs={bs} code={r.status_code} "
f"elapsed={time.time()-t0:.2f}s peak={peak}MiB")
except Exception as e:
# 这里往往就是首次 OOM 或超时的档位
print(f"bs={bs} FAIL {type(e).__name__}: {e}")
break
time.sleep(3) # 留出回收窗口,避免把上一档的残留算进下一档
每档结果填进下面这张表,最后一列写清楚失败时的报错关键词,OOM、CUDA error、超时是三件不同的事,处理方向不一样:
| 批大小 | 峰值显存(MiB) | 相对基线增量 | 耗时(s) | 结果 |
|---|---|---|---|---|
| 1 | 待填 | 待填 | 待填 | 成功 / 失败 |
| 4 | 待填 | 待填 | 待填 | 成功 / 失败 |
| 8 | 待填 | 待填 | 待填 | 成功 / 失败 |
| 16 | 待填 | 待填 | 待填 | 成功 / 失败 |
首次失败出现的那一档,建议直接记为「批大小上限的实测边界」。如果失败档位是 16,那么 8 就是当前长度下比较安全的取值;如果第一个档位就失败,问题多半不在批大小,先回去检查单条长文本那一行。
反过来固定批大小,改文本长度看峰值
把批大小锁在一个已知能成功的值上,比如上一节确认可用的 4,然后把输入长度按 128、256、512、1024、2048、4096 token 分档往上走。长度要按 token 算而不是按字符算,字符数和 token 数在中文、代码、混合文本上差别很大,用字符分档容易得出错误结论。
# 固定批大小 = 4,只改单条长度
BATCH = 4
for n in [128, 256, 512, 1024, 2048, 4096]:
SAMPLE = "字" * n # 换成按 token 计数的样本文本更准
payload = {"model": MODEL, "input": [SAMPLE] * BATCH}
# 其余同上:发请求、读 nvidia-smi、记录峰值、失败即停
把两次的结果并排放,判断就不是靠感觉了:
| 变量 | 取值 | 峰值显存(MiB) | 相对上一档增量 |
|---|---|---|---|
| 仅长度变化(批=4) | 512 token | 待填 | 待填 |
| 仅长度变化(批=4) | 2048 token | 待填 | 待填 |
| 仅批大小变化(长度固定) | 4 → 8 | 待填 | 待填 |
| 仅批大小变化(长度固定) | 8 → 16 | 待填 | 待填 |
这一节的结论只写观测到的趋势,不写因果机理之外的推断。可以这样落笔:「在本机、批大小固定为 4 的条件下,单条长度从 512 提到 2048 时峰值显存上升幅度大于批大小从 8 提到 16 的上升幅度」,并注明样本类型和卡型。反过来,如果长度翻倍时增量平缓、批大小翻倍时陡增,那上限就该压批大小,长度可以放宽。两边都陡的情况,说明乘积才是瓶颈,两个参数都得同时收紧。
把批大小与最大长度写进启动参数
测出来的档位如果不落到启动参数里,下次重启服务又会回到默认值,等于白测。保守做法是把最大长度设成实测最长的安全长度,把并发批上限设成首次失败档位的前一档,显存占用比例留一点余量给碎片和峰值抖动。
# 保守配置示例,数值来自上面两张表,不要直接照抄
python -m vllm.entrypoints.openai.api_server \
`--model` /models/nemotron-3-embed \
`--task` embed \
`--served-model-name` nemotron-embed \
`--max-model-len` 1024 \
`--max-num-seqs` 8 \
`--gpu-memory-utilization` 0.80 \
`--port` 8000
换成 TEI 一类框架时,对应的通常是最大批 token 数和最大并发请求数这两类参数,含义一致:前者限制一次前向处理的 token 总量,后者限制同时在途的请求数。参数名以本地版本为准,改完先看启动日志有没有被框架悄悄截断或覆盖。
验证步骤固定成三步,每次改配置都重跑一遍:重启服务,等启动日志出现端口监听;用第 1 节的命令确认空载显存落在预期区间;把第 2 节同一份批数据脚本原样再跑一次,对照峰值表看失败档位有没有上移、成功档位峰值是否回落到安全线以下。回落不明显说明限制没生效,先检查参数是否被配置文件或环境变量覆盖。
并发请求下的排队与超时观察
批大小管的是「一次前向塞多少」,并发数管的是「同时来多少请求」,两者在显存和延迟上的表现不一样。单卡服务常见的行为是把并发请求拆成多个批次排队执行,此时显存峰值不一定继续上涨,但排队时间会拉长,客户端容易先超时。所以并发这一节要同时看显存和等待时间。
# 简易并发压测骨架:10 个请求同时到达
seq 10 | xargs -P 10 -I{} curl -s -o /dev/null -w \
'{} code=%{http_code} total=%{time_total}\n' \
http://127.0.0.1:8000/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{"model":"nemotron-embed","input":["并发测试文本"]}'
客户端要显式设置读超时,服务端要把队列上限设成可预期的值,不要留无限排队:
# 服务端:限制同时在途的序列数 / 请求数,超出的排队或拒绝
`--max-num-seqs` 8
# 客户端:给读超时留出排队窗口,但不要设成无限
requests.post(url, json=payload, timeout=(5, 60))
并发跑起来后建议盯这几项,前两项决定服务稳不稳,后几项决定调用方体验:
- nvidia-smi 采到的峰值显存是否超过单批压测时的峰值
- 服务日志里是否出现排队、拒绝或批次合并的提示
- 每个请求的 total 时间分布,最小值与最大值差多少
- 返回码里是否出现 429、503 或连接被重置
- 客户端侧超时次数,以及超时请求对应用户侧的输入长度
如果显存峰值在并发下基本不再上涨、延迟却随并发线性拉长,说明瓶颈已经从显存转移到排队,此时该调的是并发上限和超时,而不是继续压批大小。如果并发一上来显存就顶到上限,把最大并发序列数降到单批压测确认安全的档位,再观察一次。