之前在腾讯做过一个面向推荐场景的深度学习框架(内部叫「无量」),我当时负责其中 Serving 相关的工作。后来和朋友聊起,有人问我:把一个推荐模型部署上线,是不是就是加载模型文件,然后来一个请求做一次 forward?
我发现这个问题很难用一句话回答。如果顺着数学公式去理解,推理确实就是一次 forward;但在真实的工程环境里,特别是推荐排序场景下,真实的执行路径要复杂得多。
回看当时的实现,最值得记录的是 embedding 的存放和读取:它占据了模型的大部分空间,却只有少量参数会在单次请求里被访问。远程存储、本地缓存和压缩,都和这个访问模式有关。
一种过度简化的直觉#
顺着普通的神经网络经验,最自然的 Mental Model 可能长这样:
1model weights
2 ↓
3 load
4 ↓
5 input
6 ↓
7 forward
8 ↓
9 output但这套模型真正在线上跑起来时,实际路径是这样的:
为什么不直接 load 然后 forward?问题卡在了一个非常特殊的系统约束上。
巨大的稀疏参数空间与 Sparse Access#
推荐模型里最占地方的是 embedding,整个 embedding table 往往非常巨大。
但单次请求对 embedding 的访问是稀疏的。
整个 embedding table 虽然很大,但对于某一个特定的请求来说,它真正需要访问的 embedding 只是这片巨大 parameter space 中的极小一部分:
1Huge Embedding Table
2 │
3 │ current request
4 ▼
5 key_17
6 key_42
7 key_891
8 ...这种强烈的 Sparse Access 模式意味着,在数学上它们都属于“模型参数”,但在系统层面,我们完全不应该对它们采用相同的存储和 Serving 策略。
在当时的架构里,两类参数采用了不同的处理方式:
- Sparse Parameters (Embedding):变成了基于 key 的远程数据检索(remote lookup)。
- Dense Parameters:变成了本地的 TensorFlow 模型推理(local dense computation)。
Remote Lookup 与本地热点缓存#
当时我们把这部分参数放进远程服务。Serving 会根据请求里的 sparse key,通过 RPC 跨网络从 Ronda Cache 去获取对应的 embedding。
这解决了“放不下”的问题,但跨网络带来了新的麻烦:如果每一个 key 的查询都去跑一次 RPC,请求的 latency、网络带宽消耗以及后端的 backend load 都会爆炸。
推荐流量里有一部分 key 会被频繁重复访问。
于是我们在 embedding lookup 发生前,又垫了一层 Local Hot-Key Cache。
1 ┌── Local Hot-Key Cache
2Embedding Key ┤
3 └── Ronda Cache命中本地缓存的 key 不需要再发起远程查询,既减少请求等待,也减轻后端负载。
用计算换数据:Embedding 压缩与 On-demand Restore#
在当时的系统里,我们在 Ronda Cache 存储侧对 Embedding 做了 FP16 和 INT8 压缩。
在这条路径上,压缩收益会体现在参数存储、网络传输和缓存几个环节。
压缩 embedding,意味着更小的 storage footprint,也意味着更小的 memory footprint,最关键的是,它大幅度削减了 network transmission volume 和 cache footprint。
但这种压缩不是免费的。为了让后端的 TensorFlow dense model 继续推理,这些 FP16/INT8 的数据取回来之后,必须恢复成 FP32。这会带来额外的计算开销。
所以真正的执行路径更像是一种 On-demand Restore(按需恢复):
1compressed embedding space
2 ↓
3 lookup required keys
4 ↓
5 obtain required vectors
6 ↓
7 restore these vectors to FP32
8 ↓
9 model computation因为单个请求只访问极小一部分 keys,系统就只为当前真正被消费的数据支付恢复成本。
这里面藏着一个非常典型的 trade-off:我们是在用额外的计算成本,去交换更低的数据存储与移动成本。**在现代系统里,多执行一些 arithmetic 并不自动等价于更慢;相反,data movement 本身可能极其昂贵。**转换本身带来的性能问题,记录在CPU Serving 的压缩优化里。
服务启动:被忽略的 Runtime 配置#
在画 request hot path 时,我们很容易把模型当作一个静态黑盒。但真实的 Serving 系统还有一个关键部分:服务启动与 Runtime 配置。
在我们的架构里,服务层是基于腾讯内部 RPC 框架 tRPC 开发的,启动时会从内部配置中心(七彩石)拉取一套 runtime configuration。
这套配置里,包含了一些直接影响 TensorFlow runtime 执行行为的参数,比如:
intra_op_thread_num:控制一个 TensorFlow op 内部执行时可使用的线程并行度。inter_op_thread_num:控制不同、可以独立执行的 TensorFlow ops 之间的并发执行程度。
这两个参数告诉我一个直觉:即便扔给机器完全一样的模型 graph,只要 runtime execution configuration 不同,依然会产生非常不同的 Serving 行为。
Serving performance 绝不只取决于“模型长什么样”,也取决于“底层 runtime 到底是怎么调度和执行它的”。
回到 Serving 的执行路径#
这套系统里,一次 forward 之前还有 embedding lookup、缓存查询和格式恢复;模型加载之后还有 runtime 的线程配置。只测 TensorFlow graph 的耗时,会漏掉这些开销。
对我来说,回看这套架构最有用的地方,是把每个组件重新放回请求经过的路径上:它持有什么数据,减少了哪一次访问,又把成本转移到了哪里。这样才方便继续判断该优化模型计算,还是参数读取。