聊聊我在小红书做推荐算法时踩过的性能优化坑
凌晨两点半,上海张江的夜风从窗户缝里钻进来,吹得我后脖颈有点凉。我裹了裹那件洗得发白的公司文化衫,盯着屏幕上跳动的监控曲线,灌了一口已经凉透的瑞幸。
这是我来小红书的第二年,推荐算法组的。租的房子离公司骑车十分钟,当初选这里就是图个方便——毕竟搞算法的,谁还没经历过半夜被oncall叫醒看线上指标的日子呢。比起通勤一小时挤地铁,我宁愿把时间花在写代码上,深夜的效率是真的比白天高太多了,整个楼层就剩我和保安大叔,安静得能听见服务器风扇的嗡嗡声。
今天想聊的,不是什么高大上的模型架构,而是这一年多来我在性能优化上踩过的坑、做过的一些技术探索。说实话,这些东西在论文里看不到,在技术大会上也很少有人讲,但恰恰是每天真实在发生的事。
从一次线上事故说起
去年双十一大促前夕,大概是11月8号,晚上十点多,我正窝在家里用Bolt.new搭一个个人项目的原型——对,就是那个最近很火的AI全栈开发工具。突然钉钉疯狂弹消息,值班群里炸了。
"推荐接口P99延迟飙到800ms了!"
"用户侧反馈刷笔记卡顿!"
我当时脑子嗡的一下,赶紧打开电脑连VPN。查了一圈发现,是我们新上的一个内容理解模块,在处理用户实时行为序列的时候,因为特征拼接的逻辑写得有问题,导致每次请求都要做一次全量的序列重组。平时流量低的时候不明显,大促流量一上来,直接就把CPU打满了。
那个晚上我改到凌晨四点,临时把特征计算逻辑从请求链路里拆出来,改成异步预计算+缓存的方式,才把延迟压回200ms以内。
第二天复盘会上,leader没骂我,但说了一句让我印象很深的话:"性能优化不是出了事才做的事,它应该是一种习惯。"
从那以后,我就开始系统性地梳理我们推荐链路上的性能瓶颈,也顺便研究了一波业界的做法。这篇文章,就是想把这些东西整理出来,跟大伙儿聊聊。
推荐系统的性能瓶颈到底在哪
很多人觉得推荐系统嘛,核心就是模型,把AUC搞上去就完事了。但实际工程中,模型推理可能只占整个链路耗时的20%-30%,剩下的时间全花在了数据准备、特征工程、结果排序、后处理这些环节上。
我们当时的链路大概是这样的:
用户请求 → 召回(多路) → 粗排 → 精排 → 重排 → 结果返回
每一路都有性能问题,但最要命的是精排阶段。精排模型用的是一个多目标融合的DIN变体,特征维度大概有3000多维,其中用户行为序列特征占了大头。问题就出在这个行为序列上——用户的笔记浏览历史、点赞历史、收藏历史,这些序列长度是不固定的,有的活跃用户序列能到上千条。
在请求进来的时候,我们需要实时去Redis里拉这些序列数据,然后在内存里做特征拼接、padding、masking,最后才送进模型。这个过程,在序列比较长的时候,耗时能到100ms以上。
100ms什么概念?我们整个精排的预算是150ms,光特征处理就吃掉了一大半,留给模型推理的时间就很紧张了。
特征工程的性能优化:从实时到预计算
第一个动刀的地方就是特征工程。
思路其实不复杂:把能提前算的东西提前算好,别等到请求来了再算。但说起来容易,做起来全是坑。
用户行为序列的预计算
我们做了一个离线任务,每隔5分钟跑一次,把每个活跃用户的行为序列提前拼接好,存成protobuf格式扔进Redis。请求来的时候,直接拿过来用,省掉了实时拼接的开销。
这里有个细节要注意:用户的行为是持续产生的,如果只靠离线任务,会有最多5分钟的数据延迟。对于推荐系统来说,5分钟可能意味着用户刚点赞了一个猫咪视频,但推荐流里还没反映出这个偏好变化。
我们的解法是做了一层增量更新:用户每次产生新行为,会通过消息队列触发一个轻量级的更新任务,只更新增量部分。这样既保证了时效性,又避免了全量重算的开销。
# 增量更新的核心逻辑
class BehaviorSequenceUpdater:
def __init__(self, redis_client, max_seq_len=500):
self.redis = redis_client
self.max_seq_len = max_seq_len
def append_behavior(self, user_id, behavior_item):
"""追加一条新行为到用户序列"""
cache_key = f"user_seq:{user_id}"
# 从缓存读取当前序列
current_seq = self.redis.get(cache_key)
if current_seq:
seq = SequenceProto.FromString(current_seq)
else:
seq = SequenceProto()
# 追加新行为
seq.items.append(behavior_item)
# 截断到最大长度
if len(seq.items) > self.max_seq_len:
seq.items[:] = seq.items[-self.max_seq_len:]
# 写回缓存,设置过期时间
self.redis.setex(cache_key, 7200, seq.SerializeToString())
这个改动上线后,特征处理阶段的耗时从平均85ms降到了12ms,效果还是很明显的。
特征哈希的优化
另一个耗时点在于特征哈希。3000多维特征,每维都要做hash映射到embedding table的索引。我们之前用的是Python原生的hash函数,在大规模特征下性能很差。
后来换成了murmurhash3,配合C++写的扩展模块,直接通过pybind11暴露给Python调用。这个改动比较trivial,但效果立竿见影:
| 优化项 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 特征哈希耗时 | 35ms | 4ms | 8.7x |
| 特征拼接耗时 | 50ms | 8ms | 6.2x |
| 总体特征处理 | 85ms | 12ms | 7x |
模型推理优化:TensorRT的坑与收获
特征处理优化完之后,下一个大头就是模型推理了。
我们用的是PyTorch训练的模型,线上部署用的是TorchScript。但TorchScript的性能说实话一般,特别是对于动态shape的输入(我们的行为序列长度就是动态的),优化空间很有限。
于是我开始调研TensorRT。这玩意是NVIDIA出的推理加速引擎,理论上能把推理速度提升2-5倍。但实际用起来,坑是真的多。
动态shape的支持
TensorRT对动态shape的支持一直是个痛点。我们的模型输入里,行为序列的长度是变化的,这就意味着input shape是动态的。在TensorRT 8.5之前,处理这种情况需要手动设置optimization profile,指定min/opt/max shape,然后在不同shape区间内做优化。
import tensorrt as trt
# 创建Builder和Network
builder = trt.Builder(TRT_LOGGER)
network = builder.create_network(
1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
)
# 配置动态shape
config = builder.create_builder_config()
profile = builder.create_optimization_profile()
# 用户行为序列输入,batch_size=1-256, seq_len=1-500
profile.set_shape(
"user_behavior_seq",
min=(1, 1, 128), # 最小shape
opt=(64, 100, 128), # 最优shape(最常见的情况)
max=(256, 500, 128) # 最大shape
)
config.add_optimization_profile(profile)
# 构建engine
engine = builder.build_engine(network, config)
这里有个经验:opt shape的设置很重要,要尽量贴近真实的请求分布。我们统计了线上数据,发现80%的请求序列长度在50-150之间,所以opt设的是100。如果opt设得太偏,实际推理性能会打折扣。
算子兼容性问题
另一个大坑是算子兼容性。我们模型里用了一些自定义的attention变体,TensorRT不支持,需要自己写plugin。写CUDA plugin这件事,说实话,对于算法工程师来说有点超纲了。
当时的解决方案是:把不支持的算子替换成等价的、TensorRT支持的标准算子组合。比如我们有一个custom softmax,用了一些trick来加速,但TensorRT不支持。最后改成用标准的softmax + 一些前置的数学变换来等价实现,精度损失在0.01%以内,但能跑TensorRT了。
最终模型推理的耗时从原来的95ms(TorchScript)降到了28ms(TensorRT FP16),提升还是很可观的。不过整个过程花了大概三周,中间有好几个晚上改CUDA代码改到怀疑人生。
召回阶段的优化:向量检索的那些事
召回阶段我们用的是多路召回,其中向量召回是重要的一路。用的是双塔模型,用户侧和物品侧分别编码成向量,然后用ANN(近似最近邻)检索。
向量检索用的是Faiss,但线上部署的时候发现,当索引规模到了亿级别,单机Faiss的内存占用和检索延迟都不太理想。
索引分片与量化
我们把索引做了分片,分布在多台机器上,每台机器负责一部分向量。检索的时候并行查所有分片,然后merge结果。
同时用了IVF-PQ(倒排文件+乘积量化)来压缩向量存储。128维的float32向量,原始大小是512字节,PQ16压缩后只有16字节,内存直接省了32倍。精度上有一些损失,但在召回阶段是可以接受的——反正后面还有精排来兜底。
import faiss
# 构建IVF-PQ索引
dim = 128
nlist = 4096 # 聚类中心数
m = 16 # PQ子空间数
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, 8)
# 训练索引
index.train(training_vectors)
index.add(all_vectors)
# 设置nprobe,控制精度和性能的trade-off
index.nprobe = 64 # 搜索时检查的聚类中心数
nprobe的设置也是个学问。nprobe越大,召回率越高,但延迟也越大。我们做了个实验,画了nprobe和召回率、延迟的关系曲线,最后选了64,在这个点上召回率已经接近饱和,再增大nprobe收益很小但延迟还在涨。
| nprobe | Recall@100 | 延迟(ms) |
|---|---|---|
| 16 | 78.3% | 3.2 |
| 32 | 85.1% | 5.8 |
| 64 | 89.7% | 10.2 |
| 128 | 91.2% | 18.5 |
| 256 | 91.8% | 35.1 |
聊聊最近关注的一些新技术
说到技术探索,最近半年AI领域出了不少让人兴奋的东西,虽然跟我日常做的推荐算法不是直接相关,但有些思路是相通的,值得聊聊。
Sora带来的思考
Sora刚出来的时候,整个技术圈都炸了。作为一个搞算法的,我第一反应不是"卧槽好牛",而是"这玩意背后的工程架构得有多复杂"。
后来仔细看了OpenAI的技术报告,发现他们在视频生成的效率优化上做了很多工作。比如DiT(Diffusion Transformer)架构的选择,相比传统的U-Net,Transformer在并行计算上更有优势,更适合大规模GPU集群的分布式训练。
这其实跟我们推荐系统的优化思路是类似的:选择更适合并行化的架构,才能在大规模场景下把性能做上去。我们后来在精排模型上也尝试了一些Transformer化的改造,把原来串行的特征交叉逻辑改成并行的self-attention,推理效率提升了不少。
v0和Bolt.new:AI辅助开发的新范式
说到开发效率,最近用的两个工具让我感触挺深的。
v0是Vercor出的AI UI生成工具,给它一段描述,它能直接生成React组件代码。说实话,刚开始我觉得这就是个玩具,但用了几次之后发现,对于一些后台管理页面的原型搭建,效率确实高得离谱。
Bolt.new更猛,直接在浏览器里跑一个完整的开发环境,能生成全栈应用。上周五晚上我用它搭了一个数据可视化的dashboard,从描述需求到看到能跑的页面,大概花了20分钟。以前这种活我至少得搞半天。
当然,这些工具生成的代码质量嘛……emmm,只能说能跑。但作为一个快速验证想法的工具,已经非常够用了。
Prompt工程:被低估的技术能力
最后聊聊Prompt工程。很多人觉得写prompt就是"跟ChatGPT聊天",没什么技术含量。但实际工作中,我发现prompt设计的好坏,直接影响了很多环节的效率。
比如我们在做内容理解的时候,需要用LLM来给笔记打标签。一开始prompt写得比较随意,准确率只有70%出头。后来我花了两天时间系统地优化prompt,包括:
- 明确角色定义和输出格式
- 加入few-shot examples
- 用chain-of-thought引导推理过程
- 针对不同品类的内容设计不同的prompt模板
优化之后准确率提到了91%,而且输出格式的稳定性也好了很多,下游解析不容易出错了。
# 优化前的prompt
"给这篇笔记打个标签"
# 优化后的prompt
"""
你是一个小红书内容分类专家。请根据以下笔记内容,从给定的标签体系中
选择最合适的1-3个标签。
标签体系:
- 一级分类:美妆、穿搭、美食、旅行、家居、母婴、健身、数码、其他
- 二级分类:(根据一级分类展开,此处省略)
要求:
1. 先分析笔记的核心内容和用户意图
2. 然后从标签体系中选择最匹配的标签
3. 输出格式:{"一级分类": "xxx", "二级分类": ["xxx", "xxx"], "置信度": 0.95}
示例:
笔记内容:"今天分享一个超简单的家常菜做法,10分钟搞定!"
输出:{"一级分类": "美食", "二级分类": ["家常菜", "快手菜"], "置信度": 0.92}
现在请对以下笔记进行分类:
{note_content}
"""
这件事让我意识到,prompt工程其实是一种"接口设计"能力。你怎么定义输入输出的规范,怎么约束模型的行为,怎么在准确率和灵活性之间做trade-off,这些都是实打实的技术活。
性能优化的一些心得
做了这么多优化,总结几条个人心得,不一定对,但都是自己踩过坑之后悟出来的:
1. 先测量,再优化
别凭直觉猜哪里慢。上profiler,看火焰图,看链路追踪。我们之前有个case,团队里一个同学花了一周优化特征计算逻辑,结果上线一看,整体延迟只降了2ms。后来一查,真正的瓶颈在下游的一个序列化步骤上。白干了一周。
2. 优化要分优先级
不是所有地方的优化都值得做。优先优化那些高频调用、耗时占比大的环节。一个只占整体耗时1%的模块,就算你把它优化10倍,整体也就提升1%。
3. 精度和性能的trade-off要量化
做量化、做剪枝、做近似计算,都会损失精度。但这个损失到底是多少,能不能接受,要用数据说话。我们做PQ量化的时候,专门跑了一组离线评估,确认召回率的损失在可控范围内,才敢上线。
4. 缓存是万恶之源,也是万善之源
用缓存能解决很多性能问题,但缓存一致性、缓存穿透、缓存雪崩这些问题也会随之而来。用缓存之前,一定要想清楚这些问题的应对方案。
5. 别过度优化
这条最重要。有时候一个方案能解决80%的问题,但要做到100%需要10倍的工程量。这时候要懂得取舍。工程不是科研,不需要追求极致,够用就好。
写在最后
写这篇文章的时候,窗外天已经蒙蒙亮了。看了一眼时间,早上六点半。
说实话,做性能优化这件事,很多时候是枯燥的。不像搞模型创新那样有发paper的成就感,也不像做产品功能那样能看到直观的用户反馈。它就是在一个个毫秒级的细节里死磕,把95ms变成90ms,再变成85ms。
但每次看到监控曲线上那条延迟线稳稳地压在目标值以下,每次大促流量洪峰来的时候系统扛得住不崩,心里还是很有成就感的。
这大概就是工程师的快乐吧——朴素,但真实。
好了,不说了,代码还没跑完,等结果出来我得看看今晚的AB实验数据。如果这次实验组的效果显著,明天就能推全量了。
对了,如果你也在做推荐系统或者对性能优化感兴趣,欢迎来找我交流。虽然我不一定都懂,但至少能陪你一起吐槽产品经理的需求有多离谱。
溜了溜了,继续写代码去了。🫡


评论 0