最近在折腾一套语义搜索服务的时候,我又把 Perplexity 的公开技术分享翻出来看了一遍。Perplexity 这个产品看起来是个“搜索框 + 大模型回答”,但真正撑住它每天海量查询的,是背后那条大规模搜索结果服务链路。而这条链路里最容易被低估、又最影响成本与响应速度的,恰恰是 GPU 嵌入推理和批处理这两个环节。
我之前做搜索系统改造时,第一版嵌入模型直接跑在 CPU 上,上线初期 QPS 不高还看不出问题,等流量一上来,P99 直接从 80ms 飙到 800ms,整条检索链路全卡在向量化这一步。后来把嵌入推理迁到 GPU,并且重写了批处理逻辑,吞吐才真正上去。这篇文章我就顺着 Perplexity 这类 AI 搜索服务的技术路线,把 GPU 嵌入推理、批处理、显存规划、性能调优这些事一次讲透。
1. 大规模搜索服务为什么对嵌入推理如此敏感
1.1 从关键词匹配到语义匹配:嵌入推理成为搜索链路的前置依赖
传统搜索引擎的核心是倒排索引,你输入“GPU 推理”,系统按词匹配返回包含这几个词的网页。这套方案成熟、快,但有个天然缺陷——它不理解语义。搜“显卡计算加速”的时候,它不知道你其实想找的是 GPU 相关的内容。
AI 搜索产品之所以叫 AI 搜索,本质上是把“文本匹配”换成了“向量匹配”。用户 query 进来之后,第一步不是查倒排索引,而是先把 query 送入嵌入模型,变成一个高维向量,再拿这个向量去向量数据库里做相似度检索。这就带来一个结构性变化:嵌入推理从“可选的后处理”变成了“搜索链路的前置依赖”。一旦这一步阻塞,后面的大模型生成、重排序全部无从谈起。
Perplexity 的做法本质上也是这套路,只不过规模更大、要求更苛刻。它每天的查询量级决定了嵌入推理不可能用 CPU 慢慢磨,必须是 GPU 集群来扛。而且嵌入推理还出现在多个环节:不是只有 query 需要向量化,候选文档、知识库切片、历史对话上下文都可能需要实时向量化。链路越长,GPU 嵌入推理的压力就越大。
1.2 查询流量特性:为什么搜索场景的推理负载并不均匀
很多人以为搜索的流量曲线是平滑的,实际完全不是。真实搜索流量的特点是白天高、夜间低,热点事件到来时瞬间洪峰。更麻烦的是,query 的语义分布极不均衡——高频 query 可能占总量的 30%,而这些高频 query 的文本长度、语言分布都有明显偏向。
这对嵌入推理意味着什么?意味着 GPU 资源的分配不能按平均值来算。如果按峰值流量准备 GPU,平时就是巨大的浪费;如果按均值准备,热点一来直接打爆。Perplexity 这类服务之所以强调批处理,本质上就是因为流量波动越大,批处理能带来的吞吐增益就越明显。
还有一个容易忽略的点:query 文本长度差异极大。有人搜“GPU”,两个 token 完事;有人会粘贴一整段话进来。嵌入模型对输入长度是敏感的,不同长度的样本混合在一起,GPU 的计算效率完全不同。这就引出了后面要讲的批处理策略——好的批处理不是简单把请求堆在一起,而是要考虑长度分布、显存占用、计算密度的匹配。
2. GPU 嵌入推理服务的设计:模型、显存与并发模型
2.1 嵌入模型的选择与显存预算计算
做嵌入服务,第一个绕不开的问题是模型选型。Perplexity 早期公开的资料里提到过它用多个模型做检索和重排,具体细节不一定完全公开,但整个行业用的无非是几个方向:开源的有 BGE、E5、GTE、text-embedding 系列,闭源的有 OpenAI、Cohere 的 API。
自建 GPU 嵌入服务,模型选择主要看三个指标:维度、最大输入长度、推理时延。
- 维度直接决定向量数据库的存储成本和检索带宽。768 维和 3072 维,存储差四倍,检索耗时也可能差一倍。
- 最大输入长度决定你能不能处理长文档。很多搜索场景需要把整篇网页切片后向量化,模型最长只能吃 512 token 的话,切片策略就要跟着调整。
- 推理时延决定单卡吞吐。测试时要看的是“batch size 拉满之后的平均时延”,而不是单条数据的处理速度。
显存预算这块有一个很实用的估算公式。以 BGE-large 这类模型为例,参数量大约 335M,FP16 精度下模型权重占用约 670MB。但这只是“模型本身”,真正跑推理时还有几块开销:
| 开销项 | 说明 | 典型占比 |
|---|---|---|
| 模型权重 | FP16 权重的显存占用 | 约 1/4 |
| 激活值 | 前向计算时每层产生的中间结果 | 随 batch 增长 |
| CUDA context | 框架初始化占用的基础显存 | 约 500MB - 1GB |
| 推理引擎缓存 | TensorRT、ONNX Runtime 的临时缓存 | 不定 |
实际上,一张 24GB 的 4090 或者 A5000,跑 BGE-large 这种规模的模型,batch size 开到 64 左右比较稳。如果你用的是更大的模型,比如 7B 参数的嵌入模型(现在确实有人这么干),显存直接按 FP16 权重 14GB 起步算,基本就是一张 A10 或者 A100 才能带得动。
2.2 单卡起步还是多卡并行:真实业务的决策路径
很多团队一上来就想着多卡并行,我的建议是能单卡先单卡。嵌入推理和 LLM 推理不一样,它不需要 KV Cache,也不存在自回归的串行依赖,单卡的并发吞吐其实非常可观。
以 BGE-large-zh 为例,A100 上单卡跑动态批处理,实测吞吐做到几千 QPS 是常态。对一个日请求量百万级的搜索服务来说,一张卡绰绰有余。真正的瓶颈往往不在单卡算力,而在下游的向量检索——你向量化再快,向量数据库扛不住也没用。
那什么时候必须上多卡?两种情况:一是单卡吞吐确实不够,而且已经确认瓶颈在 embedding 服务本身;二是需要多副本做高可用,单点故障会直接导致搜索服务不可用。Perplexity 这种体量肯定是要多副本的,但它也不会为了追求“看起来分布式”而盲目上多卡。
多卡并行还有一个容易踩坑的地方——数据并行 vs 模型并行。嵌入模型体量通常不大,正常用数据并行就够了,也就是每张卡放一份完整模型,请求按一定策略分摊到不同卡上。千万别一上来就搞张量并行、流水线并行,那是给大模型用的,嵌入模型这样做纯属徒增通信开销。
3. 批处理机制:把 GPU 吞吐拉到极限的关键
3.1 嵌入推理批处理的数学基础
GPU 和 CPU 的本质区别在于:CPU 适合做“少量复杂任务”,GPU 适合做“大量简单并行计算”。嵌入模型的本质是 Transformer 的前向计算,包含大量矩阵乘法,天然适合 GPU。但如果你一个一个请求地喂给 GPU,每个请求之间的 GPU 计算单元大量空闲,利用率可能只有 5% 到 10%。
批处理解决的就是这个问题。一次前向计算同时处理 N 个样本,矩阵乘法从 [1, D] 变成 [N, D],GPU 的计算单元被充分占满,单样本的平均计算成本大幅下降。
这里有一个关键参数:吞吐与 batch size 的关系不是线性的,而是近似收益递减。
- batch size 从 1 提到 8,吞吐提升非常明显,可能直接翻 4 倍。
- 从 8 提到 32,还能再涨一截,但幅度变小。
- 从 32 提到 64,提升已经很有限,反而显存占用和单请求延迟在增加。
所以实际调优时,不是无脑把 batch size 调大,而是要找到一个“吞吐平缓区”。这个平缓区因模型和 GPU 而异,需要压测来确定。我一般在 batch size 为 16、32、64、128 时各跑一轮压测,画一张吞吐曲线,从中挑拐点位置。
3.2 动态批处理队列:实现细节与参数调优
生产环境的嵌入服务不能像离线任务那样手动指定 batch size,因为线上请求是不断到达的、稀疏且不规律的。如果每次来一个请求就推理一次,GPU 浪费;如果非要攒够 64 个请求才开始推理,那第一个请求的等待时间可能长达数秒,完全不可接受。
正确做法是动态批处理(dynamic batching)。核心机制如下:
- 请求进入队列后设置一个最大等待窗口,比如 10ms 或 20ms。
- 在这个窗口内,新到的请求不断加入当前批次。
- 一旦窗口到期或者达到最大 batch size,立即把当前批次送入 GPU 执行。
窗口大小是个值得细调的参数。窗口太短,batch 攒不满,吞吐上不去;窗口太长,尾延迟爆炸。Perplexity 这类大规模服务的经验是在线服务的默认等待窗口往往取 5-20ms 之间,具体取决于 QPS 和可接受的整体延迟。
有意思的是,动态批处理在高 QPS 和低 QPS 场景下的表现逻辑完全不同。高 QPS 时,队列里几乎总有积压请求,窗口还没到期批次就满了,等待时间接近于零。低 QPS 时,每个请求可能都要等满整个窗口,这时候就得权衡:是让单条请求多等 10ms 换取更高的吞吐,还是直接牺牲吞吐保证低延迟。我的经验是在线搜索服务优先保障延迟,低峰期宁可让 batch 小一点,也别让用户多等。
另外,动态批处理还需要考虑长度分桶(length bucketing)。嵌入模型是定长 padding 的,如果 batch 里一条 500 token 的长文本和一堆 10 token 的短文本混在一起,所有短文本都要被 padding 到 500 token,计算量白白浪费一大截。实测中这种 padding 浪费可能让吞吐缩水 30% 以上。处理方式是按输入长度分桶,把长度相近的请求放在一个 batch 里,短文本和长文本走不同批次,虽然实现复杂度高一点,收益却非常直接。
4. 性能调优实录:一次完整的接入压测与回归
4.1 压测方案与关键指标
纸上谈兵没用。我把自己最近做的一次嵌入服务压测过程完整贴出来,大家可以照着这个思路去验证自己系统。
压测环境:
| 配置项 | 参数 |
|---|---|
| GPU | NVIDIA A10(24GB) |
| 模型 | BGE-large-zh(约 335M 参数,FP16) |
| 推理框架 | ONNX Runtime + CUDA EP |
| 动态批处理窗口 | 10ms |
| 压测工具 | 自研 Python 异步客户端 + Locust |
| 输入样本 | 平均长度 128 token,最大 512 token |
关键指标就两个:平均吞吐(QPS)和 P99 延迟。给每个指标设了目标线:目标吞吐 2000 QPS,P99 不超过 100ms。
压测时我特意分了三轮:
- 第一轮 batch size 固定为 8,不开动态批处理,作为基线。
- 第二轮固定 batch size 32,同样不开动态批处理。
- 第三轮开启动态批处理,窗口 10ms,batch 上限 64。
4.2 实测结果与参数调整
三轮结果如下:
| 场景 | 平均吞吐 | P99 延迟 |
|---|---|---|
| batch=8,无动态 | 780 QPS | 23ms |
| batch=32,无动态 | 1520 QPS | 38ms |
| 动态批处理,上限 64 | 2310 QPS | 71ms |
看到数据,我的第一反应是“动态批处理的 P99 怎么比固定 batch 高了这么多”。细看日志才发现问题不在批处理本身,而在于动态批处理引入了排队延迟——高峰期排队的请求比固定 batch 场景多,尾部请求等得更久。
这里有一个非常重要的调优原则:追求吞吐不能以牺牲 P99 为代价。哪怕平均延迟很好看,只要 P99 破线,用户侧的体感就是“卡顿”。
之后的调整方向有两个:
- 把动态批处理窗口从 10ms 改到 5ms,代价是吞吐降了约 8%,P99 从 71ms 降到 49ms。
- 给队列加了一个最大长度限制,超出后对请求执行“降级处理”——直接单样本推理,不进批处理队列。这样洪峰时个别请求会牺牲一点计算效率,但延迟可控。
最终我们把参数定为:窗口 5ms,batch 上限 48,队列最大积压 256。上线后高峰期吞吐稳定在 2100 QPS 左右,P99 约 55ms,完全满足业务要求。
这个压测过程给我们的教训是:不要只看平均吞吐,P99 和尾延迟才是搜索服务的生命线。动态批处理参数不是一个固定值,你必须要根据实际流量曲线反复压测,找到吞吐和延迟都满足要求的平衡点。
5. 经验总结与常见坑:GPU假忙、批处理失效、显存估算错误
5.1 GPU 利用率很低但请求就是慢:排查路径
嵌入服务上线一段时间后,我们遇到过一个诡异问题。监控面板上 GPU 利用率只有 7%,但接口平均延迟却高达 300ms。按常理想,GPU 应该很闲才对,怎么会慢成这样?
排查链路是这样的:
- 第一步看 CPU 负载。发现 CPU 跑满了,但 CPU 是给预处理用的,数据很快就交给 GPU 了。
- 第二步看 GPU 的显存占用和计算利用率。显存占用很稳定,但 SMI 里显示“Compute”和“Memory”都不忙。
- 第三步看推理框架日志。发现每一批数据进入 GPU 之前,都要等 CPU 端的数据预处理做 padding、tokenize、转 tensor——这一步是单线程的,完全成了瓶颈。
问题根源是数据预处理 pipeline 没有和推理解耦。传统的同步流程是“接请求 → 预处理 → 推理 → 返回”,遇到慢的预处理,GPU 就空转等待。
解决办法是引入生产者-消费者模型,预处理线程和推理线程分离,预处理完的数据放进缓冲区,推理线程拿到数据立即执行。这样 GPU 利用率直接涨到 60% 以上,延迟也降了一半。这个坑非常隐蔽,如果你的嵌入服务 GPU 利用率低,先别怀疑 GPU 性能,排查一下 CPU 预处理是不是没跟上。
5.2 批处理在长尾查询下的退化问题
动态批处理也不是万能的。我在低 QPS 时段观察到一个现象:batch 里经常只有 1-2 条请求,批处理几乎失效,GPU 利用率和没开批处理时一样低。
这本质上是一个流量密度问题。低峰期 QPS 只有几十,动态批处理窗口内攒不到足够多的请求,批处理自然没法生效。Perplexity 这种全球性产品因为流量足够大,可能对这个问题感受不明显,但做垂直领域搜索服务的团队大概率会碰到。
我的建议是提前做好弹性伸缩策略:低峰期把服务缩容到单卡,只保证最低可用性;高峰期再扩容。另一种优化思路是引入“延迟容忍队列”——非实时性的请求(比如离线文档入库的向量化任务)可以在低峰期复用 GPU 算力,把空闲窗口填满。这个方案我们在实际中也验证过,高峰期不抢资源,低峰期能提升约 40% 的整体吞吐。
5.3 显存估算错误导致线上 OOM
最后一个常见的坑是显存估算误差。很多人按“模型权重 + 激活值”算显存,但忽略了推理引擎本身的显存开销。
有一次我们把嵌入模型从 PyTorch 换成 TensorRT 加速,想着 TRT 优化后显存应该更省,结果一部署就 OOM。查监控发现 TRT 的显存不是一次性分配的,而是根据 batch size 动态增长。我们设置的 batch 上限是 128,整个推理引擎预留了超大显存块,和系统中的其他 CUDA context 撞在一起,直接打爆显存。
解决方式是两层:一是限制 batch 上限到 64,换回 ONNX Runtime;二是在推理引擎初始化时设置 GPU memory pool 的 preallocation 比例,不要让框架一次性把显存全部占了。
这个案例想说明的是——显存估算不能只看模型本身,要看推理框架的完整显存占用曲线。任何一个框架在初始化、预热、峰值 batch 三个阶段的显存占用都可能完全不同。生产环境上线前,务必用最大 batch size 跑一次真实的压测,而不是纸上算一算就拍板上线。
搜索引擎里嵌入推理这件事,看起来只是一个小环节,实际上直接决定了整体链路的质量和成本。Perplexity 能支撑大规模搜索结果服务,GPU 嵌入推理和批处理功不可没。希望这篇从原理到实践的拆解,能帮你在做自己搜索服务时少走一些弯路,把钱花在刀刃上。