Nemotron 3 Embed 在本地起嵌入服务 / 批大小决定显存峰值

文章导读
本地把 Nemotron 3 Embed 起成嵌入服务,模型加载成功、单条能返回向量,但一批量就显存打满报错,最常见的成因不是显存不够,而是批大小和单条文本长度在共同抬高显存峰值。建议按变量隔离的方式定位:先记录空载基线,固定文本长度只抬批大小,再固定批大小改长度,把两条趋势对照后,决定启动参数里的最大长度和最大并发序列数该压到哪一档。
📋 目录
  1. Ⅰ 先跑通单条短文本并记录空载显存
  2. Ⅱ 固定文本长度,只把批大小逐档往上抬
  3. Ⅲ 反过来固定批大小,改文本长度看峰值
  4. Ⅳ 把批大小与最大长度写进启动参数
  5. Ⅴ 并发请求下的排队与超时观察
A A

本地把 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 直接打,确认返回结构里确实有向量字段再进入下一节:

Nemotron 3 Embed 在本地起嵌入服务 / 批大小决定显存峰值
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 数在中文、代码、混合文本上差别很大,用字符分档容易得出错误结论。

Nemotron 3 Embed 在本地起嵌入服务 / 批大小决定显存峰值
# 固定批大小 = 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 节同一份批数据脚本原样再跑一次,对照峰值表看失败档位有没有上移、成功档位峰值是否回落到安全线以下。回落不明显说明限制没生效,先检查参数是否被配置文件或环境变量覆盖。

Nemotron 3 Embed 在本地起嵌入服务 / 批大小决定显存峰值

并发请求下的排队与超时观察

批大小管的是「一次前向塞多少」,并发数管的是「同时来多少请求」,两者在显存和延迟上的表现不一样。单卡服务常见的行为是把并发请求拆成多个批次排队执行,此时显存峰值不一定继续上涨,但排队时间会拉长,客户端容易先超时。所以并发这一节要同时看显存和等待时间。

# 简易并发压测骨架: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 或连接被重置
  • 客户端侧超时次数,以及超时请求对应用户侧的输入长度

如果显存峰值在并发下基本不再上涨、延迟却随并发线性拉长,说明瓶颈已经从显存转移到排队,此时该调的是并发上限和超时,而不是继续压批大小。如果并发一上来显存就顶到上限,把最大并发序列数降到单批压测确认安全的档位,再观察一次。