排序服务
cmeli 排序服务 2026 优化设计
1. 背景与目标
目标是在不换 RPC 框架的前提下,把单机 CPU 成本降到现在的一半左右,并把 P99 从“接近 15 ms”压到 10 ms 以内。这两个数字是设计目标,不是实测结论,落地前要用分阶段 profile 校准。
cmeli 是拼多多搜索广告的在线 CTR / CVR 预估服务,对外提供 Rank 和 Embedding 两个 gRPC 接口。按简历口径,它支撑 13,000 QPS、20 台机器,单机约 650 QPS,平均响应 8 ms,P99 低于 15 ms。
| 项 | 内容 |
|---|---|
| 目标 | 降低单请求 CPU;收敛长尾;过载时有梯度地降级;模型发布带质量闸门 |
| 非目标 | 更换 RPC 框架;改动对上游的 proto 接口;改变模型结构本身 |
| 保留 | 分片并行准备 + 合并一次推理;后进先出 + 丢最旧的排队策略;滚动更新与预热 |
| 约束 | 离线样本与在线打分必须继续共用同一份 FG 库和配置 |
本文所有“现状”描述都来自 master 分支代码,以及 fg_v2、opt_fg_2.0 两个分支的末端快照。FG 库本身(ads/feature-generate)的源码不在仓库里,相关判断只基于它的接口和配置。
2. 现状问题清单
最值得动的是三处:特征热路径的字符串处理、按 batch 重复的请求级工作、线程模型。下表每一条都能在代码里找到依据;“影响”一列是定性判断,没有 profile 数据支撑。
| # | 问题 | 代码依据 | 影响 |
|---|---|---|---|
| P1 | gRPC 异步 API 被当成同步用 | MeliRankData::Proceed() 直接调 Rank(),内部 blocking_counter.Wait();New() 在 Rank() 返回后才调用 |
在途请求上限等于 grpcThreadPoolSize(默认 32);超出部分排在 gRPC 内部,fast-fail 看不到 |
| P2 | 请求 / 响应队列角色与变量名相反 | RequestRank(..., request_queue_, response_queue_, this),而 gRPC 的参数顺序是 call_cq, notification_cq |
功能正常,但重试与轮询超时放在了相反的线程组上;一半处理线程几乎空转 |
| P3 | 线程超订 | 64 个 gRPC 处理线程 + 32 个 worker + TF 默认的 inter-op / intra-op 线程池(SessionOptions 未设置) |
上下文切换和缓存抖动,主要伤 P99 |
| P4 | 没有 deadline 检查 | service 目录下没有 IsCancelled() 或 deadline 相关代码 |
客户端已超时的请求仍被完整计算 |
| P5 | 按长度 fast-fail 基本不触发 | 待处理队列每个请求线程最多放 1 个元素(≤ 32),阈值 fastFailMaxQueueSize 默认 100 |
真正起作用的只有“排队超过 10 ms”那条规则 |
| P6 | 请求级工作按 batch 重复 | query 特征每个 batch 各读一次;FG 的 common_attrs 每个 batch 各生成一次 |
一个请求切 k 片就重复 k 遍 |
| P7 | FG 输出以字符串为中心 | 下游有 ColumnConfig(attr->column_name)、feature_processors_.find(column_name)、name.find(':')、StrSplit |
工作量是广告数 × 特征数,估计是 CPU 大头 |
| P8 | 物料侧特征每次重算 | 样例 fg.config 约 104 个特征,item 50 + cat 5 只依赖物料 |
约一半特征的结果与请求无关,却在每个请求里重算 |
| P9 | 去重放在请求时 | MeliDeduplicator 对每个广告做一遍 |
配置产出重复特征,本该在配置加载时解决 |
| P10 | 零拷贝用法脆弱 | set_allocated_* 把缓存里的 protobuf 指针挂到请求对象,靠 CleanOutSideReferenceMem() 释放;缓存消息以可变指针被多个请求共享,请求对象经 const_cast |
漏一次 release 就是重复释放 |
| P11 | 本地缓存读路径加锁改链表 | FeatureCache::GetFeature 在自旋锁下访问并维护 LRU 链表 |
高并发读下锁竞争 |
| P12 | 大对象树析构昂贵 | 单独的 MeliGarbageCollector 线程做延迟析构 |
说明释放已经是瓶颈,应该用 Arena 解决 |
| P13 | dump 在关键路径上 | DumpScore 在 Rank() 返回之前执行 |
占用响应时间 |
| P14 | 模型发布没有质量闸门 | ModelUpdateController 只按节点数分批滚动 |
坏模型会被滚动推到全量 |
3. 总体架构
进程内分七层,请求只经过接入、调度、特征、FG、推理五层;模型管理和旁路不在关键路径上。仍然是单进程部署,推理是否拆成独立服务由模型重量决定(见第 7 节)。
flowchart TD
U[上游广告引擎] --> A[接入层<br/>gRPC 回调 API + 准入]
A --> S[调度层<br/>请求内任务图 + 执行器]
S --> F[特征层]
S --> G[FG 层<br/>编译后的特征计划]
S --> I[推理层<br/>用户子图 + 广告子图]
S --> B[旁路<br/>dump / 指标 / trace]
F --> F1[物料全量表<br/>快照 + 增量]
F --> F2[用户特征<br/>异步远程读]
K[Kafka 增量流] --> F1
F1 -. 更新时预计算 .-> G
M[模型管理<br/>版本 / 灰度 / 闸门] --> I
M --> G
实线是请求路径,虚线是数据更新时发生的预计算;模型管理同时下发模型和与之配套的 FG 配置。
| 层 | 职责 | 与现状的主要差别 |
|---|---|---|
| 接入层 | 收请求、算 deadline、准入判断、回包 | 2~4 个轮询线程,不再阻塞;请求用 protobuf Arena |
| 调度层 | 按实验配置实例化任务图,驱动节点执行、取消、超时 | 取代“阻塞线程 + worker 池 + 扫描线程” |
| 特征层 | 物料特征走内存全量表,用户特征走异步远程读 | 取消“远程 KV + 本地 LRU + 预热”这套逻辑 |
| FG 层 | 按编译好的计划生成特征,直接写列式缓冲区 | 去掉字符串;物料侧结果预计算;公共特征请求级只算一次 |
| 推理层 | 用户子图跑一次,广告子图按合并 batch 跑一次 | 不再把用户特征复制 N 行;显式设置推理线程数 |
| 模型管理 | 版本检测、后台加载、预热、原子切换、滚动发布 | 增加灰度质量闸门和自动回滚;embedding 与稠密部分分开更新 |
| 旁路 | 打分 dump、指标、采样 trace | 全部挪到回包之后 |
4. 请求执行模型:任务图
每个请求按一张十个节点左右的静态任务图执行,关键路径是“最慢的一次远程读 → 分片 FG → 广告子图推理”。主要收益来自把现在每个 batch 重复做的请求级工作提到请求级只做一次,而不是来自调度器本身。
flowchart LR
P[解析与准入] --> E[实验与模型版本]
P --> UF[取用户特征<br/>IO]
P --> QF[取 query 特征<br/>IO]
P --> IF[查物料特征<br/>内存表]
E --> CFG[公共特征 FG<br/>请求级一次]
UF --> CFG
QF --> CFG
CFG --> UT[用户子图推理]
IF --> SH[分片 FG + 写 tensor<br/>parallel-for × k]
CFG --> SH
SH --> AD[广告子图推理<br/>合并 batch]
UT --> AD
AD --> CAL[校准与组响应]
CAL --> R[回包]
R --> SIDE[dump / 指标<br/>回包之后]
用户子图推理和分片 FG 并行;各分片往同一块预分配 tensor 的不同区间写,不加锁也不需要合并拷贝。
| 节点 | 类型 | 依赖 | 依赖性质 | 时间预算 |
|---|---|---|---|---|
| 解析与准入 | CPU | 无 | — | 内联在接入线程 |
| 实验与模型版本 | CPU | 解析 | 硬 | 内联 |
| 取用户特征 | IO | 解析 | 软,超时用默认值 | 2 ms |
| 取 query 特征 | IO | 解析 | 软,超时用默认值 | 2 ms |
| 查物料特征 | CPU(内存查表) | 解析 | 硬;缺失的广告走默认分 | — |
| 公共特征 FG | CPU | 实验、用户、query | 硬 | — |
| 用户子图推理 | CPU(推理线程) | 公共特征 FG | 硬 | — |
| 分片 FG + 写 tensor | CPU,parallel-for | 物料、公共特征 FG | 硬 | — |
| 广告子图推理 | CPU(推理线程) | 全部分片、用户子图 | 硬 | — |
| 校准与组响应 | CPU | 广告子图 | 硬 | 内联 |
| dump / 指标 | 旁路 | 回包 | — | 不占响应时间 |
2 ms 的软依赖预算是初始值,需要按 Codis 的实际延迟分布调整。
调度规则:
- 静态编译。 图的形状由实验配置决定,配置加载时建图并拓扑排序。每个请求只实例化一个计数器数组,不动态建图,不按名字查节点。
- 控制粒度。 单个节点至少几十到一百微秒的工作量。去重和写 tensor 融进 FG 节点,算子级依赖放在 FG 内部处理,由 6.3 的
FeaturePlan负责,不进任务图。 - 分片用 parallel-for。 分片数按候选数和空闲核数动态决定,均匀切分。第一片在当前线程内联执行,其余走 work-stealing。
- IO 节点不占线程。 异步 pipeline 读,完成后由回调把后继节点入队。
- 软依赖带预算。 超时就用默认值继续,并打点记录,长尾由预算封顶。
- 取消传播。 请求超时或被 fast-fail 时置取消标记,未执行的节点在入口处检查并跳过。
- 内联优先。 后继只剩一个未完成依赖时,由完成该依赖的线程直接执行后继,省一次入队。
如果所有场景的流水线形状都一样,用 C++20 协程加 when_all 就够了。从分支名看(search、display_scene、cvr、pre_rank、creative),各场景的图并不相同,所以这里选择任务图。
4.1 不同场景的图确实不同
同一个排序服务承接搜索广告和信息流广告,两者的差别不是参数不同,而是节点和边不同。
搜索广告的一次请求,特征来源和依赖关系是这样的:
flowchart LR
P[解析请求] --> QU[query 理解<br/>分词 / 改写 / 类目预测<br/>远程调用 8 ms]
P --> UF[用户特征<br/>远程 KV]
QU --> IF[物料特征<br/>需要 query 类目做交叉]
QU --> REL[相关性模型<br/>query × 标题]
UF --> FG[特征生成]
IF --> FG
REL --> FG
FG --> CTR[CTR 模型]
CTR --> CVR[CVR 模型<br/>输入含 CTR 分]
CVR --> RANK[计费排序 ecpm]
信息流广告没有 query,但多了用户实时行为和页面上下文,而且 CTR 和 CVR 是两个独立模型并行跑:
flowchart LR
P[解析请求] --> UF[用户画像<br/>远程 KV]
P --> RT[实时行为序列<br/>软依赖,预算 3 ms]
P --> CTX[页面上下文特征<br/>本地计算]
P --> IF[物料特征<br/>内存表]
UF --> FG[特征生成]
RT --> FG
CTX --> FG
IF --> FG
FG --> CTR[CTR 模型]
FG --> CVR[CVR 模型]
CTR --> RANK[排序]
CVR --> RANK
| 差别 | 搜索广告 | 信息流广告 |
|---|---|---|
| 物料特征什么时候能查 | 必须等 query 理解完成,交叉特征要用 query 类目 | 第一步就能查 |
| CTR 与 CVR | 串行,CVR 输入含 CTR 分 | 并行,两个独立模型 |
| 特有节点 | 相关性模型 | 实时行为序列,软依赖:3 ms 没回来用默认值继续 |
再加上粗排:同样是排序,粗排的图是“物料特征 → 双塔打分”,没有用户特征远程读,因为候选有几千个,延迟预算只有 2 ms。
4.2 协程手写在什么条件下会失控
单独一张图,co_await when_all(query_understand(), user_features()) 再串下去,几十行写完,非常清楚。问题出在三件事同时发生:
- 图的数量。 三个场景,每个场景两三种分支(有没有相关性、要不要实时行为),排列组合之后是十几条不同的调用链,每条都是一段手写的
when_all嵌套,改一个公共节点要改所有链。 - 实验。 算法要在搜索场景 10% 流量上加一个“query 意图模型”节点,输出喂给特征生成。协程写法是复制一条链、加一个节点、部署代码;任务图写法是配置里加一个节点和两条边,按实验分桶选图。广告排序服务同时在线的实验通常有几十个。
- 横切关注点。 每个节点的排队时间、执行时间、超时率、软依赖降级次数、按实验聚合的 P99,手写协程链要在每个
co_await前后埋点,任务图执行器在节点边界统一做。
任务图成为必需的条件是:一个服务承接多个拓扑不同的场景,并且拓扑会被实验频繁改动。两个条件都不满足,协程更好;都满足,任务图是唯一不会失控的写法。任务图本身可以用协程实现节点,两者不冲突。
4.3 开源项目里的任务图
| 项目 | 定位 | 说明 |
|---|---|---|
Taskflow(taskflow/taskflow) |
通用 C++ 任务图库 | 静态图加条件分支,工作窃取调度,头文件库,学术界出身、工业界用得多 |
CGraph(ChunelFeng/CGraph) |
C++ 图调度框架 | 国内推荐和广告团队常用,节点、边、参数传递、超时都是为在线服务设计的 |
| Intel TBB flow graph | 通用并行库里的图模块 | 节点类型丰富,调度和 TBB 线程池绑定 |
Havenask 的 navi(alibaba/havenask) |
阿里搜索引擎里的图调度引擎 | 检索、排序、特征都是 navi 图上的节点,是搜索广告类服务使用任务图的直接例子 |
| Triton Inference Server 的 ensemble / BLS | 模型服务里的流水线 | 预处理、多个模型、后处理连成图,粒度是模型级,不是特征级 |
| Ray Serve deployment graph | Python 服务编排 | 部署级的 DAG,适合对延迟不敏感的场景 |
前四个是进程内的任务图,适合毫秒级预算的排序服务;后两个是跨进程编排,粒度太粗,不能替代请求内的调度。各大厂内部的排序框架都是自研任务图,没有开源,Havenask 的 navi 可以当作它们的公开样本。
5. 接口草图一:接入层与调度层
接入线程只做三件事:建请求上下文、准入判断、把任务图交给执行器;回包由图的最后一个节点触发。以下代码是接口草图(C++20),用来说明边界和数据流,不是可编译的实现。
5.1 请求上下文
一次 RPC 内的所有对象都分配在同一个 Arena 上,请求结束时整块释放,取代现在的 MeliGarbageCollector 延迟析构。
;
5.2 接入与准入
改用 gRPC 的回调(reactor)API,接入线程不阻塞,积压全部发生在执行器自己的队列里。
;
;
grpc::ServerUnaryReactor*
5.3 任务图与执行器
图在配置加载时编译一次;节点之间不传指针,只读写 RequestSlots 里约定好的槽位。
;
;
using NodeDone = std::function<void>;
using NodeFn = void ; // IO 节点保存 done,稍后回调
;
;
;
每个节点执行前检查 rc->cancelled 和 rc->deadline,任一成立就直接以失败完成,图会快速收敛到回包节点并返回默认分。
6. 接口草图二:特征存储与 FG
物料特征从“远程 KV + 本地 LRU”改成内存全量表,FG 从“输出字符串”改成“按编译好的计划直接写列式缓冲区”。这一层预计是 CPU 收益最大的地方,对应问题 P6~P11。
6.1 物料全量表
广告库是有界的(几百万量级),可以整体放进内存:只读快照加 Kafka 增量,与现在广告正排“HBase 全量 + Kafka 增量”的做法一致。读路径无锁,旧快照用 epoch 方式回收。
;
;
不同实验的 FG 配置不同,所以预计算的是所有在线配置中物料特征的并集;每个特征计划在编译时把自己的槽位映射到并集的列上。
6.2 用户与 query 特征
用户特征放不进内存,仍走远程读;每个请求只发一次异步 pipeline,不占线程。
;
6.3 编译后的特征计划
配置加载时把 FG 配置和模型签名一起编译,请求时不再出现特征名字符串。
; // 请求级 / 物料级(预计算)/ 交叉
;
;
FeaturePlan 内部也是一张有向无环图,但它和第 4 节的任务图不是同一层:任务图的“分片 FG + 写 tensor”节点内部执行的就是 FeaturePlan。
FeaturePlan(特征算子 DAG) |
任务图(第 4 节) | |
|---|---|---|
| 节点 | 一个特征或算子:get_log_int(price)、combine_word_key(title_words, cat_id) |
一个阶段:取用户特征、查物料、公共特征 FG、分片 FG、推理 |
| 边 | 特征之间的数据依赖:交叉特征依赖两个基础特征的中间结果 | 阶段之间的依赖:分片 FG 要等物料和公共特征都到 |
| 节点数 | 几百到上千 | 十个左右 |
| 单节点工作量 | 对一个分片算一列,微秒级 | 几十微秒到毫秒 |
| 执行方式 | 按拓扑序在一个线程内顺序执行,节点之间不并行 | 节点分发到线程池并发执行 |
| 并行来源 | 任务图把候选切成分片,每个分片各跑一遍完整 DAG | 节点之间并行,分片 FG 用 parallel-for |
算子不进任务图,是因为单个算子的耗时远小于一次调度的开销,第 4 节“控制粒度”的约束就是为此设的。
6.4 列式输出与生成器
输出是按槽位组织的列,行是候选;各分片写同一块缓冲区的不同行区间,这块缓冲区就是模型的输入 tensor。
;
;
离线拼样本继续使用同一份 FeaturePlan 和算子实现,保证一致性。column_name 形式的字符串只在 Explain 和调试时按需渲染,不再出现在打分路径上。
为什么不把 FG 编进 TF 图。 推理框架是 TensorFlow,另一条路是把 FG 算子写成 TF 自定义算子挂进模型图(tf.feature_column、TFX Transform 的 tf.Transform 都是这种做法:线上收原始字段,特征变换作为图的一部分在推理时执行),FG 和推理合成一次 Session::Run。这里不选这条路,原因有三:
- FG 库要和离线拼样本共用,写成 TF 算子后离线链路也得跑 TF 图,改动面超出本次范围。
- 物料侧特征要在数据更新时预计算(6.1),预计算不在请求路径上,也不在模型图里。
- 本次不换推理框架,FG 保持独立 C++ 库,模型侧只看到固定签名的输入 tensor。
FeatureGenerator 直接写模型输入 tensor 的前提是 FG 和推理在同一进程。如果按第 7 节把推理拆成远程服务,FeatureColumns 的输出就退化为一个列式 batch,序列化后再发送。
6.5 本地缓存的读路径优化
物料特征改成全量内存表(阶段 4)之后这层缓存就不需要了;本小节是过渡期的优化,也适用于仍走远程读的用户特征和实时特征缓存。目标是让读路径不加锁、不写共享内存。
现状的开销不在“抢同一把锁”。缓存已经分了很多桶(实时特征缓存 8192 个),单个桶上的竞争很小。真正的开销有三处:
FeatureCache::GetFeature每次读都在自旋锁内调MarkAsHot,挪动 LRU 链表节点,读操作变成了写操作。- 锁内拷贝
shared_ptr,触发原子引用计数。 - 上面两点让同一条缓存行在多个核之间来回传递,热点 key 尤其明显。
| 手段 | 做法 | 解决什么 |
|---|---|---|
| 淘汰策略换成 CLOCK 或 S3-FIFO | 每个条目带一个原子访问位;读命中时只置位,不动链表;淘汰线程扫描时清位,位为 0 的条目被淘汰 | 读路径不再改链表,可以不拿锁 |
| 不可变 map 整体替换 | 读线程读一张只读 map;后台刷新线程构造新 map 后原子替换指针,旧 map 用 epoch 或 RCU 延迟回收 | 读多写少的桶完全无锁;代码里已有 mutable / immutable 两张 map 的雏形 |
| 批量查询 | 先把一个分片的 key 按桶分组,每个桶只进一次临界区 | 锁次数从“key 数”降到“涉及的桶数” |
| 引用计数移到锁外 | 锁内只取裸指针,对象存活由 epoch 保证,不再逐个拷贝 shared_ptr |
去掉热点 key 上的原子计数竞争 |
| 缓存值存预处理结果 | 缓存的不是 protobuf 消息,而是 6.1 的 ItemRow(已预计算的列) |
命中后不再做反射和字段访问 |
CLOCK 与 S3-FIFO 的差别:CLOCK 是一个环形队列加访问位,实现最简单;S3-FIFO 用一小一大两个 FIFO 队列加一个只记 key 的幽灵队列,对“只访问一次”的长尾 key 更友好,命中率通常更高。广告特征的访问分布是长尾的,建议先上 CLOCK 拿到无锁读,再用线上命中率数据决定要不要换 S3-FIFO。
;
验证方式:对比改造前后 merge_feature 阶段的耗时和缓存命中率;命中率不应明显下降。
6.6 特征算子优化
算子从“每个广告、每个特征调一次”改成“每个特征对整个分片调一次”,并把能在配置加载时做的事全部挪到配置加载时。FG 库源码不在仓库里,下面哪些已经做了需要拿到代码后核对。
| 手段 | 做法 | 对应的算子举例 |
|---|---|---|
| 分派放到编译期 | FeaturePlan::Compile 把算子名解析成函数指针或模板实例,参数(桶边界、前缀哈希)预先算好放进算子状态;请求路径上不查表、无虚函数 |
全部 |
| 按列批处理 | 一个算子一次处理分片内全部 N 个候选,输入输出都是连续数组;循环体小,编译器容易做 SIMD | get_log_int、get_discrete_ctr |
| 公共子表达式只算一次 | 编译时找出被多个特征共用的中间结果,提成独立的中间列;样例配置里有 6 个交叉特征依赖同一个 query_seg_info_list |
query 分词及其哈希、标题分词 |
| 无分支分桶 | 桶边界预先排序,用无分支二分;桶数少时线性比较再求和;取值范围小时直接查表 | get_discrete_ctr、get_discrete_Int64、relevance_discrete |
| 交叉特征用哈希组合 | 不拼字符串再哈希,改成 mix(slot_seed, hash(词), hash(广告键));两侧的哈希各自预先算好 |
combine_word_key、combine_word_word、concat |
| 零分配 | 结果直接写入 6.4 的预分配列;变长特征先统计长度再一次写入;缺失值直接填默认值,不走异常路径 | 全部 |
| 稠密特征归一化用 SIMD | (v - mean) / std 再截断到固定区间,整列一次做完 |
现有 MeanAndVar::Compute 的逻辑 |
| 物料侧预计算 | 只依赖物料的算子在数据更新时执行,请求路径上只做列拷贝(见 6.1) | item、cat 两组特征 |
哈希组合会改变特征 ID 的取值,必须和离线样本生成同时切换,并且需要重训模型;其余手段不改变特征值,可以逐槽位对账后直接上线。
// 一个算子 = 一个按列执行的纯函数 + 编译期算好的只读状态
;
;
using SparseOp = void ; // out 就是 FeatureColumns 里的列
using DenseOp = void ;
// 示例:CTR 分桶,无分支,整列一次算完
void
// 示例:query 词 × 广告键 的交叉,两侧哈希都已预先算好
void ;
验证方式:同一批回流请求分别跑新旧算子,逐槽位对比输出;再对比 fg_cost 阶段的耗时。
7. 接口草图三:推理与模型管理
推理拆成“用户子图跑一次、广告子图按合并 batch 跑一次”,模型和与之配套的特征计划作为一个版本一起发布。发布流程在现有滚动更新的基础上增加影子打分和质量闸门。
7.1 模型版本与推理引擎
;
;
;
; // 来自实验配置
void ;
运行形态按模型重量选择,接口不变:
| 模型量级 | 运行形态 | 理由 |
|---|---|---|
| DeepFM / PNN 一类的 MLP | 进程内 CPU,编译型运行时 + int8 / fp16 量化 | 没有额外一跳,AVX-512 / AMX 足够 |
| 带用户行为序列 attention 的重模型 | 独立 GPU 推理服务,跨请求动态 batch | GPU 需要大 batch 才划算;代价是多一跳和 tensor 序列化 |
推理线程数显式配置并绑核,不再使用 TF 默认的 inter-op / intra-op 线程池大小。
7.2 Embedding 与稠密部分分开更新
;
稀疏 embedding 走流式增量,稠密网络走全量版本发布,不再每次整包重载 SavedModel。
7.3 模型注册表与发布闸门
;
;
;
stateDiagram-v2
[*] --> 发现新版本
发现新版本 --> 后台加载
后台加载 --> 真实流量预热
真实流量预热 --> 影子打分
影子打分 --> 灰度节点: 闸门通过
影子打分 --> 回滚: 闸门不通过
灰度节点 --> 分批滚动: 线上指标正常
灰度节点 --> 回滚: 指标漂移
分批滚动 --> 全量
回滚 --> [*]
全量 --> [*]
影子打分是指新版本在灰度节点上对采样流量只算不用;“分批滚动”沿用现有基于 ZooKeeper 的按节点数分批逻辑。
8. 过载保护与分级降级
过载时先降质量、再拒绝,默认分是最后一级。默认分会让广告排序失真,直接伤收入,所以不应该是唯一的降级手段。
| 级别 | 触发条件 | 动作 | 对效果的影响 |
|---|---|---|---|
| 0 正常 | 在途请求数低于并发上限 | 完整打分 | 无 |
| 1 截断候选 | 在途请求数接近上限 | 按上游粗排分只保留前 N 个候选,其余给默认分 | 尾部候选排序变粗 |
| 2 轻模型 | 持续超过上限 | 切到同实验配置的轻量模型(如 LR / FM) | 整体精度下降,但排序仍有意义 |
| 3 拒绝 | 剩余预算小于预估处理时间,或队列继续增长 | 直接返回默认分 | 该请求排序失真 |
级别 1 依赖上游在请求里带粗排分,当前 proto 里是否有这个字段需要确认。
机制:
- 自适应并发上限。 用 AIMD 或梯度算法,按“最小延迟 / 当前延迟”调整上限,取代固定阈值(排队 10 ms、队列长度 100)。延迟样本来自
AdmissionController::OnFinish。 - deadline 两次检查。 入队时和每个节点开始执行时各检查一次,剩余预算小于该实验近期 P50 处理时间的请求直接拒绝。
- 排队策略保留。 继续使用现在的“后进先出 + 从队头丢最旧”:过载时保新请求的延迟,放弃大概率已超时的旧请求。
- 模型隔离。 每个模型或实验有独立的并发配额,实验用的重模型打满自己的配额后只降级自己,不影响主流量。
- 软依赖超时不算失败。 实时特征、embedding 超过预算就用默认值,单独打点,不计入请求失败率。
9. 部署、发布与可观测
部署从“ZK 自注册 + HDFS FUSE 挂载”迁到 K8s,监控从逐请求 CAT 打点改成“分阶段直方图 + 采样 trace”。这一节的改动与性能无关,可以和前面的阶段并行推进。
| 方面 | 现状 | 方案 |
|---|---|---|
| 服务发现 | 进程自己往 ZooKeeper 注册,start.sh 退出时反注册 |
K8s Service 或注册中心 sidecar;保留“先摘节点、再延迟关闭”的下线顺序 |
| 模型分发 | hdfs-mount 把 HDFS 挂成本地目录 |
对象存储 + 节点本地缓存,按版本号拉取并校验 |
| 就绪判断 | 启动时等模型和特征初始化,最长 1 小时 | 就绪探针挂在“快照加载完成 + 模型预热完成”;物料快照用 mmap,重启秒级 |
| 模型放置 | 每个节点按 meli_group 加载一组模型 |
保留分组,按实验路由到对应分组,不要求每个节点加载全部模型 |
| 扩缩容 | 代码仓库里看不到 | 按 CPU 使用率和分阶段延迟自动扩容 |
| 工具链 | gcc 4.8.5、C++11、boost::shared_ptr |
C++20,标准库智能指针与 std::span |
可观测性:
- 分阶段直方图。 执行器自动记录每个节点的排队时间和执行时间,按实验聚合,直接得到关键路径。
- 采样 trace。 按比例采样完整请求链路,替代手写的 CAT transaction。
- 特征健康度。 每个槽位的覆盖率、默认值比例、软依赖超时率,延续现有的
featureMonitor。 - 预估分布。 各模型预估均值、分位数和校准偏差,同时作为发布闸门的输入。
- 线上线下对账。 旁路继续把特征和打分结果写入 Kafka,离线用同一份
FeaturePlan重算并逐槽位对比。 - 持续 profiling。 perf / eBPF 常驻采样,用来验证每个阶段的收益。
10. 迁移路线与优先级
分七个阶段(0~6)落地,每个阶段都能单独上线和回滚;阶段 0 的数据决定后面的顺序要不要调整。预期收益是定性判断,每个阶段上线后用同一套分阶段指标验证。
| 阶段 | 内容 | 解决的问题 | 验证方式 | 预期收益 |
|---|---|---|---|---|
| 0 | 分阶段打点和 profile:排队、取特征、FG、组 tensor、推理 | — | 得到各阶段耗时占比和火焰图 | 确认后续优先级,本身无性能收益 |
| 1 | 收敛线程数,显式设置 TF 线程;gRPC 改回调 API;加 deadline 检查;dump 挪到回包后 | P1~P5、P13 | 同流量下对比 P99 和上下文切换次数 | 主要改善 P99,改动小 |
| 2 | 请求级工作只做一次:query 特征和公共特征 FG 提到请求级;引入任务图执行器 | P6 | 多分片请求的 FG 耗时应接近单分片 | 候选数多的请求收益明显 |
| 3 | FG 去字符串:特征计划编译、槽位化输出、直写 tensor、配置期去重 | P7、P9 | 新旧路径逐槽位对账一致后切流 | 预计 CPU 成本下降最多的一步 |
| 4 | 物料全量内存表 + 物料侧特征预计算;下线本地 LRU、预热、版本刷新 | P8、P10、P11 | 对比缓存未命中长尾和重启耗时 | 消除长尾,删掉大量代码 |
| 5 | 用户子图 / 广告子图拆分;编译型运行时与量化;Arena 取代 GC 线程 | P12 | 推理耗时与精度(AUC、校准)对比 | 随模型变深收益变大 |
| 6 | 自适应并发、分级降级、发布闸门、模型隔离 | P14 | 压测下的降级曲线;故意发布坏模型验证回滚 | 稳定性,不提升常态性能 |
部署和工具链升级(第 9 节)与上述阶段无依赖,可以并行。阶段 3 和阶段 4 要改 FG 库,需要和离线样本链路一起发版。
不建议做的事:单纯替换 RPC 框架。单机约 650 QPS 的量级下 RPC 不是瓶颈,这是投入产出比最低的一项。
11. 风险与待确认
最大的不确定性是没有 profile 数据:“特征热路径是 CPU 大头”是从代码结构推断的,阶段 0 的结果可能改变阶段 3~5 的顺序。
待确认:
- 线上真实配置:
grpcThreadPoolSize、workThreadPoolSize、rankBatchsize和单请求候选数分布。仓库里只有默认值(32 / 32 / 100),真实值在 Lion 配置中心。 - FG 库源码:算子实现、
column_name的拼接方式、是否已经做了公共子表达式复用。拿到代码后再细化 6.3 和 6.4。 - 请求 / 响应队列角色相反(P2)是按 gRPC API 语义推出来的,建议加一行日志验证新请求到底到在哪个队列上。
- 物料全量表的内存预算:广告数 × 每行大小(含预计算结果和 cross 用的原始字段),以及快照切换时的双份占用。
- 降级级别 1 需要上游在请求里带粗排分,需确认 proto 是否已有该字段。
- 模型能否拆成用户子图和广告子图,取决于模型结构;交叉层很早就融合用户与广告特征的模型拆分收益有限。
风险:
| 风险 | 影响 | 应对 |
|---|---|---|
| FG 改造破坏线上线下一致性 | 模型效果下降且不易察觉 | 新旧路径并行运行,逐槽位对账一致后再切流;离线与在线同时发版 |
| 物料预计算结果与特征计划版本错配 | 特征错位 | 行上带 item_fingerprint,不匹配时回退到现算 |
| 软依赖预算设得过紧 | 实时特征大面积走默认值 | 超时率单独告警,预算按延迟分位数配置 |
| 任务图框架过度设计 | 维护成本高于收益 | 节点控制在十个左右;若各场景图一致,退回协程方案 |
| gRPC 回调 API 下请求消息的 Arena 分配 | 需要自定义消息分配器 | 先只对中间对象用 Arena,请求消息后续再迁 |
本文依据的代码:meli_server.cc、service/meli_rank_data.cc、service/meli_service_impl.cc、common/meli_config.h、predict/model_predictor.cc、feature/model_feature_builder.cc、feature/feature_cache.h、model/tf_model_core.cc、model/tensorflow/tensor_builder.cc、model/opt_deep_model_core.cc、model/model_update_controller.cc、test_data/fg_test/fg.config,以及 gRPC v1.15 的 service_type.h。
讨论中的问答和背景知识见附录。
12. 附录:设计说明与问答
这一页收录讨论设计时的问答,按主题分三组;标注“通用知识”的条目没有对照 cmeli 代码,其余都对应代码里的实现。
一、特征与 FG
1. 什么是 query 特征
query 特征是由用户搜索词派生的特征,对同一个请求里的所有候选广告都相同,属于请求级数据。cmeli 里有四类:归一化搜索词的哈希(norm_query_hash)、分词结果(query_seg_info_list)、搜索词到类目的预测分布(q2c_info)、query 向量(FEATURE_QUERY_EMBEDDING)。
“按 batch 重复”指每个 batch 在自己的 GetFeatures 里各查一次,并各自用 set_allocated_query_feature 挂到候选上。第一次查询之后多半命中本地缓存,浪费不大;更大的重复是下一条的 common_attrs。
2. common_attrs 有哪些
common_attrs 是 FG 2.0 输出里“对本批所有候选都相同”的那部分特征,也就是只依赖用户和请求上下文的特征;每个广告自己的特征在 results[i].attrs 里。哪些特征进 common_attrs 由 FG 库内部决定,源码不在仓库里,下表是按样例配置 fg.config 的依赖关系推断的。
| 特征 | 算子 | 是否请求级 | 说明 |
|---|---|---|---|
norm_query_hash |
直接取值 | 是 | 搜索词哈希 |
query_ctr_seg1 / seg3 / seg7 / seg15 / seg30 |
get_discrete_ctr |
是 | 搜索词在各时间窗的 CTR 分桶 |
| 用户画像类特征 | — | 是 | 样例配置的 user_feature 组是空的,线上配置应该有 |
query_seg_info_list、q2c_info |
无 | — | 只是交叉特征的原始输入,自己不输出特征 |
title_seg_info_list(get_term / get_word / get_word_tag)、relevance_fea |
— | 否 | 虽然放在 context_feature 组,但标题是广告的,逐广告不同 |
所以配置里的分组名不等于作用域:context_feature 组里既有请求级特征,也有广告级特征。样例配置中请求级输出特征只有 6 个,线上加上用户特征会多一些。
cmeli 对它的用法:model_predictor.cc 先对 common_attrs 去重一次,再把指针 push 进每个广告的特征列表;tf_model_core.cc 第 165 行单独处理它。因为每个 batch 各自调一次 Generate,一个请求切 k 片就生成 k 遍。
3. “FG 输出以字符串为中心”怎么改
分三步,每一步都能单独上线:
- 下游改用槽位下标。 配置加载时给每个特征分配
slot_id,存进FormatAttr;下游用它做数组下标,不再用column_name查 map。只改 cmeli,FG 只加一个字段。 - 不再拼字符串。
column_name只在调试或 Explain 时生成;交叉特征的哈希改成组合已有哈希。 - 输出改成列式。 FG 直接写预分配的缓冲区,即主文档 6.4。
4. 物料侧特征是不是“算好然后缓存”
是的,计算发生在物料数据更新的时候,结果和该广告的那一行数据存在一起(主文档 6.1)。需要处理三种失效:
- 物料数据变了。 增量流到达时重算这一行。
- FG 配置变了。 行上带特征定义的指纹,对不上就回退到现算,等后台重算完再切回。
- 特征随时间变化。 各时间窗的 CTR 统计本来就以数据更新的形式到达,走第一种情况。
不能预计算的有交叉特征、上下文特征、变化很快的 1 小时实时 CTR。代价是每行多存一份预计算结果的内存。
5. 为什么要做特征交叉,电商里交叉哪些特征(通用知识)
CTR 预估的核心信号是“这个用户、这个搜索词和这个商品是否匹配”,这是乘性关系。线性模型学不到;DNN 能学但效率低,对长尾组合记不住。显式交叉提供“记忆”能力,也就是 Wide&Deep 里 wide 侧的作用。FM、DeepFM、DCN 在模型内自动做交叉,工业系统通常两者并用。
| 交叉类型 | 例子 |
|---|---|
| query × 物料 | 搜索词分词 × 标题分词(相关性);搜索词预测类目 × 商品类目;搜索词 × 广告 / 店铺 / 类目 ID。样例配置里的 combine_word_key、relevance_discrete、q2c × cat_id 属于这一类 |
| 用户 × 物料 | 用户偏好的类目、品牌、价格带 × 商品属性;用户对该商品、店铺、类目的历史点击或购买次数;性别、年龄段 × 类目 |
| 上下文 × 物料 | 时段 × 类目;广告位 × 商品;网络或机型 × 价格带 |
| 用户 × query | 用户历史搜索词与当前搜索词的关系 |
6. “按槽位组织的列”是什么,SparseCol 和 DenseCol 是什么
槽位就是模型的一个输入字段。把输入想成一张表:行是候选广告,列是槽位。
| 候选 | ad_id(稀疏) | cat_id(稀疏) | price_bucket(稀疏) | ctr_7d(稠密) |
|---|---|---|---|---|
| 广告 1 | 9001 | 12 | 5 | 0.031 |
| 广告 2 | 9002 | 12 | 7 | 0.018 |
| 广告 3 | 9003 | 40 | 2 | 0.044 |
- 现在是按行存。 每个广告一串“名字:值”,特征名重复 N 遍。
- 列式是按列存。
ad_id这一列就是长度为 N 的 int64 数组[9001, 9002, 9003],本身就是模型的一个输入 tensor。 - SparseCol 存类别型特征的 ID(int64),模型拿它去查 embedding。叫“稀疏”是因为它的 one-hot 表示几乎全是 0。
- DenseCol 存连续值(float),直接进网络,不查 embedding。
- VarLenCol 存一个候选有多个值的特征,比如标题分词,用“偏移数组 + 值数组”表示。
二、内存与缓存
7. set_allocated_* 和 CleanOutSideReferenceMem() 的代码在哪
| 内容 | 位置 | 说明 |
|---|---|---|
| 挂指针 | feature/model_feature_builder.cc 的 BuildFgContext(),第 168~267 行 |
一串 set_allocated_ad_feature、set_allocated_cat_feature、set_allocated_query_feature 等调用 |
| 指针来源 | context/predict_context.h 第 73 行的 cache_item_holder |
一组 shared_ptr<Message>,保证请求期间缓存对象不被回收 |
| 释放 | context/predict_context.h 第 25~48 行,逐个调 release_* |
RankContext 析构函数(rank_context.h 第 65 行)会调用;meli_service_impl.cc 第 322、361、419、443 行也主动调用 |
const_cast |
model_feature_builder.cc 第 153 行;embedding_context.h 第 21~26 行 |
作用在请求对象上,不是缓存对象上:去掉请求的 const 才能把它的子消息挂出去 |
缓存对象这一侧用的是 C 风格强转(如 (::feature::AdFeature*)),指针本来就是可变的。真正的问题是同一个可变对象被多个并发请求同时挂着,并且漏一次 release 就会重复释放。
8. 本地缓存怎么降低锁竞争
完整方案和接口草图在主文档 6.5。要点是:缓存已经分了很多桶,开销不在抢锁,而在每次读都在锁内改 LRU 链表和拷贝 shared_ptr;改法是淘汰策略换 CLOCK 或 S3-FIFO、读路径用不可变 map、批量查询、引用计数移到锁外。
9. Arena 的原理
Arena 先向系统申请几大块内存,对象分配只是把块内的指针往后移(bump pointer),单个对象从不单独释放,请求结束时把这几大块一次性归还。
收益:
- 分配只是一次指针加法,没有锁,不进 malloc 的慢路径。
- 同一个请求的对象在内存里挨在一起,缓存局部性好。
- 析构不遍历对象树。protobuf 对 Arena 上的消息跳过逐字段析构,销毁耗时只和内存块数有关。现在
MeliGarbageCollector线程处理的就是“遍历对象树逐个释放”这部分开销。
代价:
- 内存要到请求结束才释放。
- 跨 Arena 的
set_allocated_*会退化成深拷贝,现在的零拷贝挂指针技巧不能照搬;FG 应改为直接读缓存对象的只读视图。 - 旧版 protobuf 的字符串字段仍在堆上分配,字符串多的消息收益要打折。
10. 特征算子怎么优化
完整方案和算子接口草图在主文档 6.6。要点是:分派放到编译期、按列批处理、公共子表达式只算一次、无分支分桶、交叉特征用哈希组合、零分配、物料侧预计算。
三、模型与推理
11. TF Serving 模型预热的原理(通用知识)
TF 有很多初始化是懒加载的:图优化、算子内核初始化、内存池增长、GPU 上的算法自动调优,都发生在第一次执行时。所以新模型的头几个请求会从几毫秒变成几百毫秒,预热就是在接流量之前先把这些慢路径走完。
做法:
- 在 SavedModel 目录下放文件
assets.extra/tf_serving_warmup_requests。 - 文件是 TFRecord 格式,每条记录是一个
PredictionLog,也就是一条真实的 Predict 请求。 - TF Serving 加载新版本时先回放这些请求,回放完才把版本标记为可用。
记录数有上限,我记得是千条量级,可以配置回放轮数。预热有效的前提是请求的形状(batch 大小、特征长度)覆盖线上的真实分布,否则遇到新形状仍会触发一次慢路径。
cmeli 自己实现了同样的机制:TFModelCore::WarmUp 从模型目录读取压缩的真实样本,最多 4 万条,按每批 400 条跑完后才切换版本。
12. 电商里带用户行为序列 attention 的重模型(通用知识,年份凭记忆)
| 模型 | 出处 | 要点 |
|---|---|---|
| DIN | 阿里,2018 | 用候选商品对用户历史行为做 attention |
| DIEN | 阿里,2019 | 在 DIN 上加兴趣演化,GRU 加 attention |
| DSIN | 阿里 | 按会话切分行为序列 |
| BST | 阿里,2019 | 用 Transformer 处理行为序列 |
| MIMN、SIM | 阿里,2019~2020 | 超长序列;SIM 先检索相关行为再做 attention |
| ETA、SDIM | 阿里、美团 | 用哈希近似加速长序列 |
| TWIN | 快手,2023 | 检索阶段与 attention 阶段保持一致 |
| CAN | 阿里 | 特征协同作用网络 |
| HSTU、OneRec | Meta 2024、快手 2025 | 生成式推荐 |
共同特点是 attention 的计算量等于“候选数 × 序列长度”,是 MLP 类模型的几十到上百倍。这类模型才值得上 GPU,也才值得拆成独立推理服务。
13. “embedding 流式增量、稠密全量发布”怎么理解
这类模型 99% 以上的参数在 embedding 表里,按 ID 索引,新广告和新商品不断出现,变化很快;稠密网络只有几 MB 到几十 MB,变化慢。每次整包重载 SavedModel,加载慢、内存翻倍,也做不到分钟级更新。
做法:
- 训练侧把发生变化的 embedding 行以“key → 向量”的形式写进消息流,在线服务消费后更新内存里的哈希表,对应主文档 7.2 的
EmbeddingTable::ApplyDelta。 - 稠密部分按小时或天发布带版本号的完整模型,走正常的发布闸门。
- embedding 查表必须从 TF 图里拿出来:在线服务自己查表,把向量作为稠密输入喂给模型,或者用自定义算子读外部的表。
要注意两点:稠密部分是在某个 embedding 快照上训练的,漂移太久效果会下降,需要定期全量对齐;增量流要保证顺序并支持断点续传。
14. TF 有哪些模型格式,为什么这么多(通用知识)
| 格式 | 包含什么 | 用途 |
|---|---|---|
| Checkpoint | 变量值和优化器状态 | 训练断点续训 |
GraphDef(.pb) |
只有计算图结构 | TF1 时代交换图 |
| Frozen Graph | 计算图,变量固化成常量 | TF1 时代的部署格式,单文件,不能再训练 |
| SavedModel | 图、变量、资源文件和签名 | 服务端部署的标准格式,TF Serving 和 cmeli 都用它 |
Keras H5 / .keras |
网络结构、权重和训练配置 | Keras 高层 API 的保存与恢复 |
| TFLite(FlatBuffer) | 量化和裁剪后的图 | 手机和边缘设备 |
| TF.js | 分片权重加 JSON 描述的图 | 浏览器 |
| TF-TRT 转换后的 SavedModel | 部分子图替换成 TensorRT 引擎 | GPU 推理加速 |
格式多有两个原因。一是不同阶段需求不同:训练要能恢复全部状态,服务端要稳定的输入输出签名和版本管理,端侧要体积小、依赖少。二是历史包袱:TF1 是图和变量分离的,TF2 和 Keras 是面向对象的,旧格式都没有废弃。在线服务只需要关心 SavedModel 和由它转换出来的加速格式。
15. 直接用 TF Serving 是否可行
可行,但它只替换推理和模型管理这一层,不碰特征和 FG,还会多一跳。对 cmeli 现在“8 ms 平均、MLP 量级模型、CPU 推理”的情况,不建议作为远程服务使用;要用的话,同机 sidecar 更合适。耗时数字是经验估计,以压测为准。
| 形态 | 适合的情况 | 代价 |
|---|---|---|
| 进程内推理(现状和主文档方案) | MLP 量级、CPU 推理、延迟预算紧 | 模型管理代码自己维护 |
| TF Serving 同机 sidecar | 想删掉模型管理代码,能接受每个请求多零点几毫秒 | tensor 序列化;两个进程抢 CPU,需要绑核隔离 |
| 独立推理集群(TF Serving 或 Triton) | 模型很重、需要 GPU,或多个业务共用模型服务 | 多一跳;单独的容量规划;要设计“用户侧只发一次”的输入 |
它不解决或带来新问题的地方:
- 取特征、FG、校准、实验分流、过载降级都不在它的范围内。
- 服务端 batching 要求所有输入的第 0 维相同,和“用户子图 batch = 1、广告子图 batch = N”冲突,要保留这个优化就得关掉 batching。
- LR、FM、FFM、XGBoost 这些非 TF 模型进不去,降级用的轻模型也属于这一类。
- 集群级的分批滚动和质量闸门仍需外部编排;切换版本时新旧两份模型同时在内存里。
- 不支持稀疏 embedding 的分钟级增量更新,更新粒度是整个 SavedModel 版本。
- 推理本身不会变快,底层是同一个 TF 运行时。
主文档 7.1 的 Subgraph 接口就是为这种替换留的:远程推理只是它的一种实现,上层的任务图和 FG 不用改。
暂无评论,欢迎留下第一条评论。