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_v2opt_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 在关键路径上 DumpScoreRank() 返回之前执行 占用响应时间
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 延迟析构。

struct RequestContext {
  google::protobuf::Arena arena;        // 请求、响应、中间对象都在这里
  const CMeliRankRequest* req;
  CMeliRankResponse*      resp;
  Deadline                deadline;     // min(gRPC deadline, 服务端上限)
  std::atomic<bool>       cancelled{false};
  const ExperimentPlan*   plan;         // 只读:任务图 + 模型版本 + 特征计划
  RequestSlots            slots;        // 节点间传数据的定长槽位,见 5.3
  int                     degrade_level = 0;
  StageTimers             timers;       // 每个节点的排队与执行耗时
};

5.2 接入与准入

改用 gRPC 的回调(reactor)API,接入线程不阻塞,积压全部发生在执行器自己的队列里。

enum class Admit { kAccept, kDegrade, kReject };

class AdmissionController {
 public:
  Admit OnArrive(const RequestContext& ctx);  // 自适应并发上限 + 剩余预算检查
  void  OnFinish(Duration latency, bool ok);  // 反馈给并发上限算法
  int   DegradeLevel() const;                 // 0 正常,1 截断候选,2 轻模型
};

grpc::ServerUnaryReactor* MeliService::Rank(grpc::CallbackServerContext* gctx,
                                            const CMeliRankRequest* req,
                                            CMeliRankResponse* resp) {
  auto* reactor = gctx->DefaultReactor();
  auto* rc = NewRequestContext(gctx, req, resp);     // 算 deadline,选 ExperimentPlan
  switch (admission_.OnArrive(*rc)) {
    case Admit::kReject:
      FillDefaultScores(rc);
      reactor->Finish(grpc::Status::OK);
      return reactor;
    case Admit::kDegrade:
      rc->degrade_level = admission_.DegradeLevel();
      break;
    case Admit::kAccept:
      break;
  }
  executor_.Run(rc->plan->graph, rc, [this, reactor, rc] {
    reactor->Finish(grpc::Status::OK);               // 先回包
    sidecar_.Post(rc);                               // 再 dump、打点,最后释放 rc
  });
  return reactor;
}

5.3 任务图与执行器

图在配置加载时编译一次;节点之间不传指针,只读写 RequestSlots 里约定好的槽位。

enum class NodeKind { kCpu, kIo, kParallelFor, kInfer };
enum class DepPolicy { kHard, kSoftWithBudget };

using NodeDone = std::function<void(bool ok)>;
using NodeFn   = void (*)(RequestContext&, NodeDone done);  // IO 节点保存 done,稍后回调

struct NodeSpec {
  const char*      name;
  NodeKind         kind;
  std::vector<int> deps;      // 编译后是节点下标,不是名字
  DepPolicy        policy;
  Duration         budget;    // 仅 kSoftWithBudget 使用
  NodeFn           fn;
};

class GraphPlan {              // 不可变,跨请求共享
 public:
  static std::unique_ptr<GraphPlan> Compile(const ExperimentConfig& cfg);
  std::span<const NodeSpec> nodes() const;       // 已拓扑排序
  std::span<const int>      successors(int node) const;
};

class GraphExecutor {
 public:
  void Run(const GraphPlan& plan, RequestContext* rc, std::function<void()> on_done);
 private:
  WorkStealingPool cpu_pool_;    // 约等于 核数 − 推理线程数
  PinnedPool       infer_pool_;  // 绑核,线程数与推理运行时的配置一致
  IoCompletion     io_;          // 1~2 个线程,只负责把后继节点入队
};

每个节点执行前检查 rc->cancelledrc->deadline,任一成立就直接以失败完成,图会快速收敛到回包节点并返回默认分。

6. 接口草图二:特征存储与 FG

物料特征从“远程 KV + 本地 LRU”改成内存全量表,FG 从“输出字符串”改成“按编译好的计划直接写列式缓冲区”。这一层预计是 CPU 收益最大的地方,对应问题 P6~P11。

6.1 物料全量表

广告库是有界的(几百万量级),可以整体放进内存:只读快照加 Kafka 增量,与现在广告正排“HBase 全量 + Kafka 增量”的做法一致。读路径无锁,旧快照用 epoch 方式回收。

struct ItemRow {                         // 一行 = 一个广告,物料侧特征已预计算
  int64_t ad_id, goods_id, mall_id, cat_id;
  std::span<const int64_t>  sparse;      // 按物料特征并集的列顺序排列
  std::span<const float>    dense;
  std::span<const RawField> raw_for_cross;  // cross 特征要用的原始字段,如标题分词
  uint64_t item_fingerprint;             // 预计算所用特征定义的指纹
};

class ItemTable {
 public:
  // 批量查询;未命中填 nullptr,该广告走默认分
  void Lookup(std::span<const int64_t> ad_ids,
              std::span<const ItemRow*> out, const EpochGuard& guard) const;
  void ApplyDelta(const ItemDelta& delta);              // Kafka 增量:重算该行的预计算结果
  void SwapSnapshot(std::unique_ptr<ItemSnapshot> s);   // 新快照上线,旧的延迟回收
};

不同实验的 FG 配置不同,所以预计算的是所有在线配置中物料特征的并集;每个特征计划在编译时把自己的槽位映射到并集的列上。

6.2 用户与 query 特征

用户特征放不进内存,仍走远程读;每个请求只发一次异步 pipeline,不占线程。

class UserFeatureClient {
 public:
  // 一次 pipeline 读齐:用户画像、用户实时计数、query 特征与 query 向量
  void AsyncGet(const UserKeys& keys, Deadline budget,
                std::function<void(const UserFeatures*, Status)> done);
};

6.3 编译后的特征计划

配置加载时把 FG 配置和模型签名一起编译,请求时不再出现特征名字符串。

enum class Scope { kCommon, kItem, kCross };   // 请求级 / 物料级(预计算)/ 交叉

struct FeatureDef {
  SlotId                slot;     // 模型输入里的位置,编译时解析
  Scope                 scope;
  OpId                  op;       // get_log_int、get_discrete_ctr、combine_word_key …
  std::vector<FieldRef> inputs;   // 原始字段,或另一个特征的中间结果
  ValueType             type;     // int64 id / float / 变长 id 列表
};

class FeaturePlan {               // 不可变,跨请求共享
 public:
  // 编译期完成:名字→槽位、重复特征消除、公共子表达式提取、按 scope 分三段
  static std::unique_ptr<FeaturePlan> Compile(const FgConfig& cfg,
                                              const ModelSignature& sig);
  std::span<const FeatureDef> common() const;
  std::span<const FeatureDef> item() const;
  std::span<const FeatureDef> cross() const;
  uint64_t item_fingerprint() const;           // 决定物料预计算结果能否复用
};

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。

class FeatureColumns {
 public:
  std::span<int64_t> SparseCol(SlotId slot, RowRange rows);   // 定长 id 列
  std::span<float>   DenseCol(SlotId slot, RowRange rows);
  VarLenWriter       VarLenCol(SlotId slot, RowRange rows);   // offsets + values
};

class FeatureGenerator {
 public:
  // 请求级一次:用户、上下文、query 特征
  void GenerateCommon(const FeaturePlan& plan, const UserFeatures& user,
                      const ContextFeature& ctx, CommonFeatures* out);
  // 每个分片一次:拷贝预计算的物料列,只现算 cross 特征
  void GenerateShard(const FeaturePlan& plan, const CommonFeatures& common,
                     std::span<const ItemRow*> items, RowRange rows,
                     FeatureColumns* out);
};

离线拼样本继续使用同一份 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。

template <class K, class V>
class ConcurrentCache {
 public:
  // 读路径:无锁。命中时只做一次 visited.store(true, relaxed)
  const V* Get(const K& key, const EpochGuard& guard) const;

  // 批量读:内部按桶分组;未命中的下标写入 misses,由调用方去远程补
  void MultiGet(std::span<const K> keys, std::span<const V*> out,
                std::vector<uint32_t>* misses, const EpochGuard& guard) const;

  // 写路径:只有后台刷新线程和回填线程调用,桶内加锁
  void Put(const K& key, std::unique_ptr<const V> value);

 private:
  struct Entry {
    K                      key;
    std::unique_ptr<const V> value;   // 不可变,更新 = 换一个新 Entry
    mutable std::atomic<bool> visited{false};   // CLOCK 访问位
  };
  struct Bucket {
    std::atomic<const ReadOnlyMap<K, Entry*>*> map;  // 不可变快照,整体替换
    SpinLock write_lock;                             // 只保护写和淘汰
    ClockHand hand;
  };
  std::vector<Bucket> buckets_;   // 桶数保持 2 的幂,沿用 id & mask 的分桶方式
  EpochManager epochs_;           // 旧 map 和被淘汰的 Entry 延迟到所有读者离开后释放
};

验证方式:对比改造前后 merge_feature 阶段的耗时和缓存命中率;命中率不应明显下降。

6.6 特征算子优化

算子从“每个广告、每个特征调一次”改成“每个特征对整个分片调一次”,并把能在配置加载时做的事全部挪到配置加载时。FG 库源码不在仓库里,下面哪些已经做了需要拿到代码后核对。

手段 做法 对应的算子举例
分派放到编译期 FeaturePlan::Compile 把算子名解析成函数指针或模板实例,参数(桶边界、前缀哈希)预先算好放进算子状态;请求路径上不查表、无虚函数 全部
按列批处理 一个算子一次处理分片内全部 N 个候选,输入输出都是连续数组;循环体小,编译器容易做 SIMD get_log_intget_discrete_ctr
公共子表达式只算一次 编译时找出被多个特征共用的中间结果,提成独立的中间列;样例配置里有 6 个交叉特征依赖同一个 query_seg_info_list query 分词及其哈希、标题分词
无分支分桶 桶边界预先排序,用无分支二分;桶数少时线性比较再求和;取值范围小时直接查表 get_discrete_ctrget_discrete_Int64relevance_discrete
交叉特征用哈希组合 不拼字符串再哈希,改成 mix(slot_seed, hash(词), hash(广告键));两侧的哈希各自预先算好 combine_word_keycombine_word_wordconcat
零分配 结果直接写入 6.4 的预分配列;变长特征先统计长度再一次写入;缺失值直接填默认值,不走异常路径 全部
稠密特征归一化用 SIMD (v - mean) / std 再截断到固定区间,整列一次做完 现有 MeanAndVar::Compute 的逻辑
物料侧预计算 只依赖物料的算子在数据更新时执行,请求路径上只做列拷贝(见 6.1) item、cat 两组特征

哈希组合会改变特征 ID 的取值,必须和离线样本生成同时切换,并且需要重训模型;其余手段不改变特征值,可以逐槽位对账后直接上线。

// 一个算子 = 一个按列执行的纯函数 + 编译期算好的只读状态
struct OpState {                       // 由 FeaturePlan::Compile 构造,跨请求共享
  std::vector<double> boundaries;      // 分桶边界,已排序
  uint64_t            slot_seed;       // 该槽位的哈希种子
  float               mean, inv_std;   // 归一化参数
};

struct OpInput {                       // 输入列:原始字段或中间列,长度 = 分片内候选数
  std::span<const int64_t> i64;
  std::span<const float>   f32;
  VarLenView               terms;      // offsets + values,用于分词类输入
};

using SparseOp = void (*)(const OpState&, std::span<const OpInput> in,
                          std::span<int64_t> out);   // out 就是 FeatureColumns 里的列
using DenseOp  = void (*)(const OpState&, std::span<const OpInput> in,
                          std::span<float> out);

// 示例:CTR 分桶,无分支,整列一次算完
void DiscreteCtr(const OpState& s, std::span<const OpInput> in, std::span<int64_t> out) {
  const auto& b = s.boundaries;
  for (size_t i = 0; i < out.size(); ++i) {
    int64_t bucket = 0;
    for (double edge : b) bucket += (in[0].f32[i] >= edge);   // 桶少时比二分更快
    out[i] = static_cast<int64_t>(Mix(s.slot_seed, bucket));
  }
}

// 示例:query 词 × 广告键 的交叉,两侧哈希都已预先算好
void CombineWordKey(const OpState& s, std::span<const OpInput> in, VarLenWriter out);

验证方式:同一批回流请求分别跑新旧算子,逐槽位对比输出;再对比 fg_cost 阶段的耗时。

7. 接口草图三:推理与模型管理

推理拆成“用户子图跑一次、广告子图按合并 batch 跑一次”,模型和与之配套的特征计划作为一个版本一起发布。发布流程在现有滚动更新的基础上增加影子打分和质量闸门。

7.1 模型版本与推理引擎

class Subgraph {                       // 屏蔽运行时差异:TF / ONNX Runtime / XLA AOT
 public:
  virtual Status Run(const TensorView& in, TensorView* out) = 0;
  virtual ~Subgraph() = default;
};

struct ModelBundle {                   // 一个可发布的版本,不可变
  std::string                  name;
  ModelVersion                 version;
  std::unique_ptr<FeaturePlan> plan;        // 与模型一起发布,避免特征与模型错配
  std::unique_ptr<Subgraph>    user_tower;  // 可为空:模型未拆分时退化为单图
  std::unique_ptr<Subgraph>    ad_tower;
  std::vector<PredictTarget>   targets;     // CTR、CVR、EMBEDDING
};

class InferenceEngine {
 public:
  // 请求级一次,batch = 1;Embedding 接口到这一步就可以返回
  Status RunUser(const ModelBundle& m, const CommonFeatures& common, UserState* out);
  // 所有分片写完后一次,batch = 候选数;用户侧中间结果在图内广播
  Status RunAds(const ModelBundle& m, const UserState& user,
                const FeatureColumns& ads, ScoreColumns* out);
};

struct Calibration { double coeff, pow, down_sampling_bias, min, max; };  // 来自实验配置
void Calibrate(const Calibration& c, std::span<float> raw, std::span<float> out);

运行形态按模型重量选择,接口不变:

模型量级 运行形态 理由
DeepFM / PNN 一类的 MLP 进程内 CPU,编译型运行时 + int8 / fp16 量化 没有额外一跳,AVX-512 / AMX 足够
带用户行为序列 attention 的重模型 独立 GPU 推理服务,跨请求动态 batch GPU 需要大 batch 才划算;代价是多一跳和 tensor 序列化

推理线程数显式配置并绑核,不再使用 TF 默认的 inter-op / intra-op 线程池大小。

7.2 Embedding 与稠密部分分开更新

class EmbeddingTable {
 public:
  void Lookup(SlotId slot, std::span<const int64_t> ids, std::span<float> out) const;
  void ApplyDelta(const EmbeddingDelta& d);   // 流式增量,分钟级
  uint64_t version() const;
};

稀疏 embedding 走流式增量,稠密网络走全量版本发布,不再每次整包重载 SavedModel。

7.3 模型注册表与发布闸门

class ModelRegistry {
 public:
  // 读路径无锁;请求期间持有 shared_ptr,旧版本在最后一个请求结束后释放
  std::shared_ptr<const ModelBundle> Acquire(std::string_view name) const;
};

struct GateResult {
  bool   pass;
  double mean_score_ratio;   // 新旧模型平均预估值之比
  double calibration_error;  // 预估与实际点击率的偏差
  double p99_latency_ratio;
};

class ReleaseGate {
 public:
  GateResult Evaluate(const ModelBundle& candidate, const ModelBundle& baseline,
                      const ShadowStats& stats);
};
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 的顺序。

待确认:

  • 线上真实配置:grpcThreadPoolSizeworkThreadPoolSizerankBatchsize 和单请求候选数分布。仓库里只有默认值(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.ccservice/meli_rank_data.ccservice/meli_service_impl.cccommon/meli_config.hpredict/model_predictor.ccfeature/model_feature_builder.ccfeature/feature_cache.hmodel/tf_model_core.ccmodel/tensorflow/tensor_builder.ccmodel/opt_deep_model_core.ccmodel/model_update_controller.cctest_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_listq2c_info 只是交叉特征的原始输入,自己不输出特征
title_seg_info_listget_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 输出以字符串为中心”怎么改

分三步,每一步都能单独上线:

  1. 下游改用槽位下标。 配置加载时给每个特征分配 slot_id,存进 FormatAttr;下游用它做数组下标,不再用 column_name 查 map。只改 cmeli,FG 只加一个字段。
  2. 不再拼字符串。 column_name 只在调试或 Explain 时生成;交叉特征的哈希改成组合已有哈希。
  3. 输出改成列式。 FG 直接写预分配的缓冲区,即主文档 6.4。

4. 物料侧特征是不是“算好然后缓存”

是的,计算发生在物料数据更新的时候,结果和该广告的那一行数据存在一起(主文档 6.1)。需要处理三种失效:

  • 物料数据变了。 增量流到达时重算这一行。
  • FG 配置变了。 行上带特征定义的指纹,对不上就回退到现算,等后台重算完再切回。
  • 特征随时间变化。 各时间窗的 CTR 统计本来就以数据更新的形式到达,走第一种情况。

不能预计算的有交叉特征、上下文特征、变化很快的 1 小时实时 CTR。代价是每行多存一份预计算结果的内存。

5. 为什么要做特征交叉,电商里交叉哪些特征(通用知识)

CTR 预估的核心信号是“这个用户、这个搜索词和这个商品是否匹配”,这是乘性关系。线性模型学不到;DNN 能学但效率低,对长尾组合记不住。显式交叉提供“记忆”能力,也就是 Wide&Deep 里 wide 侧的作用。FM、DeepFM、DCN 在模型内自动做交叉,工业系统通常两者并用。

交叉类型 例子
query × 物料 搜索词分词 × 标题分词(相关性);搜索词预测类目 × 商品类目;搜索词 × 广告 / 店铺 / 类目 ID。样例配置里的 combine_word_keyrelevance_discreteq2c × 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.ccBuildFgContext(),第 168~267 行 一串 set_allocated_ad_featureset_allocated_cat_featureset_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 上的算法自动调优,都发生在第一次执行时。所以新模型的头几个请求会从几毫秒变成几百毫秒,预热就是在接流量之前先把这些慢路径走完。

做法:

  1. 在 SavedModel 目录下放文件 assets.extra/tf_serving_warmup_requests
  2. 文件是 TFRecord 格式,每条记录是一个 PredictionLog,也就是一条真实的 Predict 请求。
  3. 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 不用改。