之前在腾讯做过一个面向推荐场景的深度学习框架(内部叫「无量」),我当时负责其中 Serving 相关的工作。后来和朋友聊起,有人问我:把一个推荐模型部署上线,是不是就是加载模型文件,然后来一个请求做一次 forward?
我发现这个问题很难用一句话回答。如果顺着数学公式去理解,推理确实就是一次 forward;但在真实的工程环境里,特别是推荐排序场景下,真实的执行路径要复杂得多。
当时的 embedding 占据了模型的大部分空间,单次请求却只会访问其中少量参数。为了处理这种访问模式,我们引入了远程存储、本地缓存和压缩,forward 之前也就多出了一段参数读取的路径。
一种过度简化的直觉#
顺着普通的神经网络经验,最自然的 心智模型 可能长这样:
1model weights
2 ↓
3 load
4 ↓
5 input
6 ↓
7 forward
8 ↓
9 output但这套模型真正在线上跑起来时,实际路径是这样的:
模型参数太大,无法全部按普通 dense model 的方式加载到本地。
巨大的稀疏参数空间与 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 ...只读取请求用到的 embedding,就不需要在每个 Serving 节点放下整个 embedding table。
在当时的架构里,两类参数采用了不同的处理方式:
- 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 压缩。
在这条路径上,压缩收益会体现在参数存储、网络传输和缓存几个环节。
但这种压缩不是免费的。为了让后端的 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,系统就只为当前真正被消费的数据支付恢复成本。
省下存储和传输开销之后,请求路径上多了格式转换。转换本身也可能拖慢推理,当时遇到的性能问题记录在 CPU Serving 的压缩优化里。
启动时还要配置线程#
在我们的架构里,服务层是基于腾讯内部 RPC 框架 tRPC 开发的,启动时会从内部配置中心(七彩石)拉取一套 runtime configuration。
这套配置里,包含了一些直接影响 TensorFlow runtime 执行行为的参数,比如:
intra_op_thread_num:控制一个 TensorFlow op 内部执行时可使用的线程并行度。inter_op_thread_num:控制不同、可以独立执行的 TensorFlow ops 之间的并发执行程度。
同一个模型 graph,线程配置不同,op 的并行执行方式也会变化。排查 Serving 耗时时,除了模型本身,还要看这些 runtime 配置。
回到 Serving 的执行路径#
这套系统里,一次 forward 之前还有 embedding lookup、缓存查询和格式恢复;模型加载之后还有 runtime 的线程配置。只测 TensorFlow graph 的耗时,会漏掉这些开销。
对我来说,回看这套架构最有用的地方,是把每个组件重新放回请求经过的路径上:它持有什么数据,减少了哪一次访问,又把成本转移到了哪里。这样才方便继续判断该优化模型计算,还是参数读取。