↓ Skip to main content
  1. Posts/

推荐系统的动态算力:当机器跟不上流量,如何让 Workload 具备弹性

·4405 words·9 mins
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

在 2022 年的年终总结里,我提过当时做的一个“动态算力”项目。那一年,我和另一位同事一起推进了这套系统,陆续接入腾讯新闻、QQ 音乐、全民 K 歌等业务,部分场景节省了约 30% 的成本。

当时遇到的问题是,推荐流量涨起来很快,机器却没法马上准备好。按峰值配机器,低峰时又有大量算力闲着。我们最后调节的是每个请求参与排序的候选数量:忙的时候少算一些,空闲时多算一些。

机器为什么来不及扩?
#

推荐流量有明显的潮汐效应,突发热点还会在日常高峰之外带来额外的请求。

遇到容量不足,很自然会想到 autoscaling。但推荐模型的 serving 扩容后,还要经历服务启动、模型加载、warm-up 和流量切换。新增机器到能接流量之间有一段等待时间,赶不上突然涌入的请求。

当时比较保守的做法,是按一周甚至更长时间里可能出现的峰值准备机器。高峰有了余量,但大多数时间机器利用率偏低。

如果暂时动不了机器数量,能不能减少每个请求需要的计算量?我再看这件事时,会把容量问题拆成两边:一边是机器能提供多少算力,另一边是请求要消耗多少算力。扩容调的是供给,推荐链路还有机会调需求。

潮汐流量与容量预估

从候选数量入手
#

多阶段推荐链路里,每个排序阶段都要决定有多少 candidate item 进入计算。在这套系统里,我们把这个数量叫作 quota。

推荐链路与 Quota

最开始主要接的是计算成本较高的精排,后来也有粗排场景接入。控制系统不区分具体阶段,业务只需要暴露一个可调的 quota。

quota 增大,参与排序的 item 变多,计算开销也随之增加。推荐效果可能改善,但增益通常会逐渐减小,具体收益需要通过业务 A/B Test 确认。

推荐效果与计算开销

如果多算一批候选只能带来很小的效果增益,却仍然要支付计算成本,那么在负载高的时候,减少这部分候选就可能是划算的。候选变少,排序结果也可能变化;业务接受这部分效果差异,才换来了计算量的调节空间。

quota 让推荐效果与计算成本之间的取舍变成了一个可调参数。 业务通过实验确定能接受的范围,Infra 才能在这个范围里调节。这个前提很重要:一个要求精确结果、计算步骤不能省略的任务,就不能直接照搬这种做法。

流量涨了,每个请求少算一点
#

流量上升时,同一组机器能分给单个请求的算力变少。系统降低 quota,让每次请求少排一些候选,减轻负载。低峰时则提高 quota,利用闲置算力尝试改善推荐效果。

高峰与低峰时的 Quota 调节

机器数量暂时不变,单个请求的计算量随负载调整。 高峰时,少算一些候选,争取让更多请求按时返回;低峰时,既然机器已经在那里,就尝试把闲置算力换成推荐效果。两种情况下,业务想要的收益并不一样。

所以 CPU 利用率升高本身不能算成功。低峰多算的候选有没有带来效果收益,要看业务实验;高峰少算以后,延迟和成功率有没有保住,也要单独看。长期来看,如果这套机制让业务能在可接受的效果和可用性下,用更少的机器承接原有流量,才形成了成本收益。它仍然需要容量规划,只是规划时多了一个可以调节的维度。

当时为什么没直接用 CPU utilization?
#

CPU 高就降 quota,CPU 低就升 quota,看起来很直接。问题出在当时监控平台的反馈速度上:CPU utilization 的采集粒度最低也在数秒量级,而流量毛刺可能只有 100ms 左右。

等到监控读数反映出负载变化,请求可能已经在排队、超时。分钟平均 CPU 没有打满,也不能说明这段时间内没有发生过载。

所以,当时没有把监控平台的 CPU utilization 直接接进实时控制回路。限制来自这条监控链路的延迟,不能据此认定 CPU 指标在所有系统里都不适合做反馈。

安全水位也没有统一的数值。模型、服务实现、请求分布和业务 SLA 都会影响它,需要业务结合线上运行情况确定。

在执行排序之前统计 quota
#

请求还没开始执行排序计算时,已经知道要排多少个 item。相比等待 CPU 监控更新,这个信息在请求入口就能拿到。

我们因此改用 quota 消耗来估计负载。过去一段时间里请求消耗的 quota 越多,意味着进入排序阶段的计算任务越多。

只数请求也不够:同样是一个请求,排序 100 个候选和 1000 个候选,带来的负载并不一样。统计 quota 消耗,把请求数量和单请求计算规模一起纳入了估计。

这里从“机器已经忙成什么样”往前挪了一步,去看“请求正在带来多少工作”。控制信号除了要能反映负载,还得早到足以让调节产生作用。 当时的 CPU 监控更接近资源消耗后的结果,quota 在排序开始前就能统计,给调节留下了时间。

这也有代价:quota 只是计算开销的近似,不同模型、不同请求的单位候选成本可能不同。模型变化后,需要重新检查它与实际负载的关系。入口统计减少了等待监控更新的延迟,也不意味着整个控制回路没有延迟。

信号延迟对比

一组机器能承担多少 quota?
#

只有消耗量还不够,还需要一个用来比较的预算:total_quota,表示这组 serving machines 在一个时间窗口内大致能承担多少 quota。

当时主要结合业务、模型、机器规模、历史运行情况和 CPU 峰值利用率,粗略确定这个值。CPU 指标仍然参与容量评估,只是不直接用于每次实时调节。

业务负责给出经过验证的容量预算,控制系统在这个预算下调整 quota。换模型或改变机器规模后,原来的预算也需要重新评估。

这样拆开以后,控制系统不必先解出某个模型在某批机器上的精确成本函数,才能开始工作。业务把模型和机器的差异折算成预算,Infra 负责跟踪消耗并调节候选数量。同一套控制逻辑能接到不同排序阶段,依赖的就是这个分工。

用 Redis 统计滑动窗口内的消耗
#

线上用 Redis 维护全局 sliding window,统计窗口内所有请求 quota 的总和,记作 quota_sum。它与 total_quota 使用相同的时间窗口口径。

随着流量和接入业务扩大,这部分统计本身也遇到了 Redis 性能问题。当年的Redis 笔记记录的就是相关优化。

PID 怎么调 quota?
#

控制器比较窗口内的预算与消耗:

$$ e(t) = total\_quota - quota\_sum(t) $$

PID 根据这个差值调整 base quota。系统支持配置 Kp / Ki / Kd,生产中绝大多数业务使用默认参数,当时没有观察到明显的振荡。

PID 反馈闭环

业务还会设置 quota 的上下限,限制控制器的输出:

$$ quota = \operatorname{clamp}(PID(e), q_{min}, q_{max}) $$

下限约束忙时能减少多少候选,上限约束闲时能增加多少计算量。它们也带着业务判断:候选减到哪里,效果损失还可以接受;候选加到哪里,额外收益已经不值得继续投入算力。PID 本身不知道这些答案,只能在业务给定的范围里调节。

我理解这里的分工是:业务确定能接受怎样的效果与成本取舍,控制器根据当前负载选择这个范围内的运行位置。换一种反馈算法,也不会替业务回答前一个问题。

控制对象是一组承载同一业务的 serving machines。它们共享一个计算预算,由一个逻辑 PID 控制器控制,而不是各台机器分别调节。

集群级控制

有限的算力应该分给谁?
#

前面的调节只看整体负载,同一时刻的请求使用同一个 base quota。这样做隐含了一个分配选择:给每个请求相同的候选数量。

但不同用户对业务的价值并不一样。对某些用户,业务更愿意投入算力来改善推荐体验;如果所有请求一起等比例缩减,就没有表达这种优先级。预算有限时,除了决定总共少算多少,还需要决定把算力优先留给谁。

后来加入的 user_value,就是把这个业务判断带进资源分配。业务为请求传入权重,比如 0.5 到 1.5,再乘到 base quota 上:

$$ quota_{request} = quota_{base} \times user\_value $$

用一个简化的例子看这个分配关系:base quota 为 100 时,权重 1.5 的请求得到 150 个候选,权重 0.5 的请求得到 50 个。负载上升、base quota 降到 60 时,两者分别变成 90 和 30。整体都在收缩,但业务价值较高的用户仍然分到更多候选。

基于 user_value 的资源分配

base quota 表达当前负载下能给多少,user_value 表达业务愿意优先给谁。 前者随系统负载变化,后者来自业务对用户价值的判断。把它们拆开,容量调节和差异化分配就可以同时发生。

用户价值怎么定义,仍然由业务负责。会员权益、长期留存、付费贡献等都可能成为业务考虑的因素,具体选择取决于业务目标。Infra 接收这个判断的结果,不把某个产品的用户分层规则写进控制器。业务调整价值判断时,也就不需要同时重写负载控制逻辑。

这个权重表达的是分配偏好,并不等于精确的收益预测。权重为 1.5,不代表多算一个候选的收益一定是别人的 1.5 倍。高价值用户是否确实能从更多候选中受益,仍然需要业务实验验证。否则,虽然算力按价值分了层,也可能没有换来预期收益。

加权也不能绕过总预算。统计消耗时,要按请求最终参与排序的候选数量记账;如果只统计 base quota,高权重请求增加的开销就会漏掉。这样,按用户价值分配算力的同时,整体负载仍然受同一个预算约束。

控制系统故障时怎么办?
#

这套机制采用旁路 / side-car 方式接入。如果 Redis 不可用或 controller 故障,上游业务回退到自己配置的 default_quota,继续走固定 quota 的推荐链路。

Side-car 旁路设计

引入控制器和 Redis,也引入了新的故障点。如果推荐请求必须等它们恢复才能继续处理,那么原本用来保护可用性的系统,反而会扩大主链路的故障范围。旁路接入把这部分依赖限制住:控制能力暂时丢失时,推荐链路仍然可以独立运行。

回退并不保证高峰时仍然安全。固定 quota 失去了随负载收缩的能力,默认值仍需要业务合理配置。它解决的是控制系统故障时不要额外阻断请求,不能替代主链路自身的过载保护。

一次突发流量
#

2022 年 7 月 8 日安倍晋三遇刺后,腾讯新闻流量大幅增长。当时接入动态算力模块的服务保持了稳定,一些没有接入相同保护机制的模块出现了大量请求失败。

固定 quota 下,请求数量增长会直接推高总计算需求。自适应模块在 quota_sum 接近预算时降低 base quota,减少每个请求参与排序的候选数量,缓解过载。

少排一些候选,可能损失一部分推荐效果;但请求超时,用户连这次推荐结果都拿不到。负载超过容量时,继续坚持每个请求都用原来的 quota,并不等于保住了原来的业务收益,反而可能把局部的效果损失扩大成大量请求失败。

这也是业务需要提前确定降级范围的原因。quota 的下限越高,可以减少的候选越少,能够缓解的负载也越有限。即使已经降到下限,流量仍然可能超出机器承载能力。动态 quota 提供的是一段可控的退让空间。

回头看这套动态算力
#

那次突发流量检验了高峰时的保护能力,但日常低峰里的取舍同样是这套系统的一部分。机器已经配在那里,多算一些候选能不能改善效果;流量涨上来后,少算多少还能接受。这些问题放在一起,才解释了为什么推荐链路适合做动态算力:候选数量允许在效果与成本之间连续调节,单个请求的计算需求因此也有了弹性。

加入 user_value 后,调节又多了一层业务含义。同样一份预算,平均分给所有请求只是其中一种选择;业务可以根据用户价值,把更多算力投入到更值得改善的体验上。总量随负载调整,分配随业务价值区分。 能承接多少请求,以及这些计算带来多少业务收益,需要一起考虑。

Infra 要把这两层判断接到一个能及时反馈、故障时可以退出的控制回路上。业务确定容量预算、效果底线和用户权重,控制器跟踪实际消耗、调整 quota。这个分工让系统可以服务于不同业务,也保留了各个业务对效果和成本的决定权。

最近写的训练集群 Quota 反馈回路也让我重新想起这段经历。再面对容量问题时,我会先看看:每个请求的计算量是否真的必须固定?如果业务允许调整,省下或多花的这部分算力,分别意味着什么?把这两件事想清楚,才知道控制器应该调什么,以及为什么值得调。

Related

为什么一个 Python Library 既要锁依赖,又不能锁依赖

·5605 words·12 mins
起因 # 最近在团队内部重新整理 Python 项目的打包、发布和依赖管理规范,逐渐把各个工程统一收敛到: 1pyproject.toml 2uv 3uv.lock 4wheel 5src layout 6internal package registry 在这个过程中,大家交流最多的往往是一些具体的工具用法:私有源怎么配、build backend 选哪一个、uv 的命令参数怎么写。但我越梳理越发现,这些工程细节很容易盖住一个更基础的问题:

模型压缩之后,为什么推理反而变慢了:一次 CPU Serving 的性能优化实践

·2821 words·6 mins
之前我负责过一个推荐系统深度学习 Serving 框架的功能开发与性能优化。 我们的系统主要跑在 CPU 上,以 TensorFlow 为主力 inference engine。随着模型迭代,尤其是 Embedding 层越来越大,模型导出、传输和上线变得非常慢,Serving 节点的内存成本也眼看着往上飙。