1. 推荐系统的不规则数据与 GPU 的算力错位
先说个我实际遇到的场景。线上用户的点击序列长度差异非常大,有的人一天能产生几百条行为,有的人注册半年只点过两三个商品。召回阶段筛出来的候选集同样是参差不齐的,热门 item 能带上几千个相似结果,长尾 item 可能凑不够十个候选。
这种情况放到 CPU 上处理其实还好,循环逐个样本算,序列长短只影响单条样本的计算量。但一旦想把这批数据丢到 GPU 上加速,问题就来了:GPU 是个“处女座”,它最喜欢的输入是整齐划一的矩形张量,比如[batch_size, seq_len, hidden_size]这种三维规整结构。你把[128, 24]、[1, 512]、[64, 3]这些长度不一的序列混在一起喂进去,第一个报错就够你喝一壶的。
这个错位本质上不是“长度的错”,而是 GPU 硬件设计与软件抽象之间的结构性冲突。现代 GPU 的线程调度单位是 warp,一个 warp 固定包含 32 个线程,所有线程在同一时刻执行同一条指令。当你要对一批不等长序列做矩阵乘法或者注意力计算时,GPU 只能取这批序列的最大长度作为统一计算边界,短的序列被强制“补齐”后才能参与并行计算。
于是就有了这篇文章的核心问题:把不规则的推荐序列改造成 GPU 擅长的规整计算,具体有哪些路线,各自适合什么场景,又有哪些坑。
先看一张我在实际项目中整理的对比表,方便对三条路线有个整体印象:
| 方案路线 | 核心思路 | 适用场景 | 主要代价 |
|---|---|---|---|
| Padding + Mask | 补齐到定长,用掩码屏蔽无效位 | 序列长度方差小、padding率低 | 显存浪费、无效算力 |
| Bucketing + 动态Batch | 按长度分桶,桶内规整,桶间分 batch 调度 | 长度分布跨度大、长尾明显 | 数据排序开销、批大小波动 |
| 稀疏化/压缩表示 | 用坐标表或二部图压缩不规则信息 | 候选集极大但单条稀疏、图召回场景 | 工程复杂度高、算子依赖深 |
下面逐条拆开讲。
2. Padding 到定长 + Mask:最朴素的手段,也是最多人用错的手段
2.1 把变长序列塞进定长张量的标准做法
所谓 Padding,说人话就是把所有序列都“补”到同一个长度。假设有 4 条用户行为序列,长度分别是 3、8、5、12,目标定长 12,那么不足 12 的位置就用 0 补齐。因为大部分深度学习框架的嵌入层、注意力层都只接受规整输入,所以这一步几乎绕不开。
具体到 PyTorch 里,我推荐直接用torch.nn.utils.rnn.pad_sequence,而不是手写循环拼接。函数接口是这样的:
import torch from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence sequences = [ torch.tensor([1, 2, 3]), # 长度 3 torch.tensor([4, 5, 6, 7, 8, 9, 10, 11]), # 长度 8 torch.tensor([12, 13, 14, 15, 16]), # 长度 5 torch.tensor([17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28]), # 长度 12 ] padded = pad_sequence(sequences, batch_first=True, padding_value=0) # 输出 shape: [4, 12]batch_first=True会把输出拼成[batch, max_len]的布局,比默认的[max_len, batch]更符合推荐系统里 embedding 查询的书写习惯。padding_value=0这个参数我单独拿出来说,很多初学者会漏掉。
2.2 Mask 的两种构造方式,漏掉一个就是线上事故
Padding 之后必须配 Mask,否则模型会把补的 0 当真。这里有两种 Mask 容易搞混,我分别说。
第一种是Padding Mask,用来告诉模型“哪些位置是真实数据,哪些是补位”。构造方式非常简单:
# 假设 padded shape: [batch, max_len] # 真实长度记录在 lengths 里 mask = torch.arange(max_len, device=padded.device).unsqueeze(0) < lengths.unsqueeze(1)逻辑就是逐个位置比较下标是否小于真实长度。小于的是有效位,置 True;大于等于的说明是 padding 位,置 False。
第二种是Causal Mask(因果掩码),用在 Transformer 结构里,确保位置 i 只能看到 i 之前的信息,不能“偷看”未来的行为。这个在实际推荐模型里特别关键,因为用户后续的行为序列是训练标签,不能用它来预测自己。
causal_mask = torch.tril(torch.ones((max_len, max_len), dtype=torch.bool))真正用的时候要把两种 Mask 做与运算:final_mask = padding_mask & causal_mask。我遇到过一次线上事故,就是只做了 padding mask,忘了因果掩码,导致模型在训练时“作弊”,离线指标异常高,上线直接崩。排查过程极其痛苦,整整盯了两天才定位到。
2.3 显存账本:padding 比例过高等于烧钱
Padding 方案最大的问题不是功能,而是成本。假设你的 batch size 是 256,最长的序列长度是 512,但 80% 的用户序列实际长度只有 64,那么你每条样本实际只利用了 64/512 = 12.5% 的显存空间。剩下 87.5% 全是在算空气。
这个账在推荐系统里尤其要命。用户的点击序列是典型的幂律分布——绝大部分用户的序列很短,但总有一小撮“重度用户”把 max_len 顶得很高。如果你的代码里把 max_len 设成 512,那你每天训练的显存成本就是理论需求的好几倍。
我曾经做过一个统计实验,对一个有 2000 万用户的交互序列做长度分析,发现 99 分位数是 180,但最大值有 1200。如果直接按最大值 padding,显存成本膨胀 6 倍以上。这时候如果还硬着头皮用 Padding 方案,纯粹是在拿钱换省事。
2.4 什么时候该放弃 Padding
如果发现 padding 率(即填充位置占全矩阵的比例)超过 50%,就该认真考虑别的方案了。我个人的经验阈值是这样的:
- padding 率低于 30%,直接用 Padding + Mask,简单稳妥,性能最好
- padding 率在 30% ~ 60% 之间,考虑 Bucketing 方案
- padding 率高于 60%,建议走稀疏化路线,别硬塞矩形
还有一个细节值得注意:即使采用 Padding,也建议做一次“温和截断”。比如把最长序列从 1200 截到 200,然后单独统计被截断的样本占比。推荐系统里序列过长部分的信息增益极低,但计算代价却是线性的。对 99% 的应用来说,只保留最近 200 个行为已经够用了。
3. 按长度分桶(Bucketing)+ 动态 Batch:治标更治本的第二招
3.1 分桶的核心思路,就是把“参差不齐”变成“内部整齐”
Bert 时代有个著名技巧叫 dynamic padding,最早用在文本领域:与其让一个 batch 里所有样本都按全局最大长度填充,不如先把样本按长度排序/分桶,让长度相近的样本进入同一个 batch,然后每个 batch 只按自己的“桶内最大长度”做 padding。
我举个例子。假设有 8 条序列,长度分别为 3, 5, 6, 8, 9, 200, 210, 220。如果全部丢进同一个 batch,max_len 就是 220,前 4 条的 padding 率高达 97%。但如果我们把前 4 条分到一个 bucket,后 4 条分到另一个 bucket,那么第一个 batch 的 max_len 是 9,第二个 batch 的 max_len 是 220,总的 paddding 量大幅下降。
推荐系统完全可以直接照搬这套思路。用户行为序列的长度分布天然符合长尾,分桶后每个 batch 内部都是“近似的矩形”,GPU 的利用率会好很多。
3.2 桶内排序:要不要做,怎么做才不破坏训练稳定性
这里有个工程细节:分桶之后要不要在桶内继续按长度排序?我的建议是“要做,但别排太死”。
完全排序会导致一个副作用——同一个 batch 内的样本在每次迭代中高度相似,严重时会产生梯度估计偏差。想象一下,如果某个 batch 里全是 10 条超长序列的样本,模型会在这个 batch 上被“超长”样本的梯度主导,而另一个 batch 全是短序列,模型又被“短”样本主导。这种训练信号的震荡,直接拖垮离线指标。
更稳的做法是“分桶 + 轻度 shuffle”。即先把所有样本按长度分到若干个 bucket 里(比如 5 个 bucket),每个 bucket 内部随机打乱,再在每个 bucket 内部连续切 batch。这样既保证了每个 batch 内部长度接近,又避免了某个 batch 全是同一长度类型的样本。
3.3 桶边界怎么定:看分位数,不建议拍脑袋
桶的数量和边界是需要仔细算的。我一般是这样处理的:
第一步,对训练数据做一次长度统计,拿到长度分布的全量信息。比如计算 20 分位、40 分位、60 分位、80 分位的具体长度值。
第二步,根据分位数设定 5 个桶的边界:P20 以下一档,P20-P40 一档,P40-P60 一档,P60-P80 一档,P80 以上一档。
第三步,每个桶内独立设置 max_len。这里不是直接用桶内最大长度,而是用桶内 95 分位,避免极端值把桶内 padding 率拉高。
下面是我在实际项目里用过的一组分桶配置,读者可以直接参考:
import numpy as np # 假设 lengths 是全量训练数据的序列长度 lengths = np.array([...]) # 实际使用时替换成你的长度数组 percentiles = np.percentile(lengths, [20, 40, 60, 80, 95]) print("分位点:", percentiles) # 输出示例:[12, 28, 43, 71, 138] def bucket_id(seq_len): if seq_len <= percentiles[0]: return 0 elif seq_len <= percentiles[1]: return 1 elif seq_len <= percentiles[2]: return 2 elif seq_len <= percentiles[3]: return 3 else: return 4对于每个 bucket,最大长度我建议取其内部 95 分位数而不是绝对最大值,这样能把极端异常值挡在外面。被截断的超长序列通常占比很校,信息损失可以接受。
3.4 动态 batch 与显存管理的配合:一个容易被忽略的调参点
分桶之后,batch size 也是个变量。我的建议是:不同 bucket 使用不同的 batch size,原则是“长度越长,batch 越小”,让每个 batch 的总 token 数接近固定值。
假设你想让每个 batch 控制在 2048 个 token 左右,bucket 内平均长度是 20,那么 batch size 可以设在 96;如果 bucket 内平均长度是 100,那么 batch size 设 20 就足够了。用总 token 数来约束 batch size,比固定 batch size 更科学,因为它直接把显存消耗盯住了。
代码里可以用一个简单的除法加向下取整来动态决定:
TARGET_TOKENS = 2048 batch_size = max(1, TARGET_TOKENS // bucket_avg_len)这种动态 batch 策略在维持 GPU 利用率方面非常有效。实测中对比固定 batch size,在长度分布极端的场景下,训练吞吐量可以提升 1.5 到 2.5 倍。
不过它有一个副作用:batch 大小不同会导致 BN(Batch Normalization)层的统计量不稳定。如果你在模型里使用了 BN,建议改成 Layer Normalization。推荐系统的模型以 transformer 和 DNN 为主,通常本来就是 LN 居多,问题不大,但如果你的模型结构有 BN,这一点要处理。
4. 更激进的改造:把不规则序列压缩成稀疏计算与图结构
4.1 稀疏化表示:从变长序列到坐标表
有了分桶和动态 padding,大部分推荐序列已经能吃得下 GPU 了。但碰上候选集规模特别大的场景,比如召回阶段要给每个 user 从 10 万量级的 item 池里算相似度,Padding 方案直接就不现实——你不可能把 10 万列全填成一个矩形。这时候就要换一种思路:与其把序列拉成矩形,不如承认它就是稀疏的。
稀疏化的典型做法是把数据从“稠密矩形”改成“坐标表”(COO 格式)。比如你有一个不规则的用户-物品交互矩阵,不规则的交互行为记录可以表示成三个等长数组:user_indices、item_indices、values。每个数组的长度等于实际交互次数之和,而不是等于“用户数 × 物品数”。
这种表示方法的好处是内存占用只与实际数据量成正比,不再跟全连接矩阵的大小挂钩。在 GPU 上你可以用torch.sparse_coo_tensor做存储,搭配稀疏矩阵乘法直接在图结构上做 embedding 聚合。
4.2 二部图和邻接矩阵:把“候选集不等长”变成“边稀疏”
推荐系统里经常要处理“每个 user 有数量不等的候选 item”这类问题,这本质上可以抽象成一个二部图:左边是 user 节点,右边是 item 节点,边表示“用户与物品有交互”。用户与物品的度(即连接边数)天然是长尾分布——这就是不规则性的来源。
处理它的方式不是去“补齐”,而是直接用邻接矩阵的稀疏表示。GPU 对稀疏矩阵运算是专门优化的,诸如 Sparse Matrix-Matrix Multiplication(SpMM)和 Sampled Dense-Dense Matrix Multiplication(SDDMM)在 Graph Neural Network 和图卷积推荐模型里都是核心算子。
我举一个实际的例子。在训练一个图召回模型的时候,用户-物品交互图有 1000 万节点、8000 万条边。如果把它展开成稠密邻接矩阵,完全不可能放进显存;但用torch.sparse_coo_tensor存储,8000 万条边只占 8000 万 × 3 个数值的空间(行索引、列索引、值),轻松放进单张 A100。
4.3 进阶玩法:分段 Kernel 与 CUDA Graph,直接控制硬件
如果前面几条路都覆盖不了你的场景,或者你想榨干 GPU 的性能,那就得下沉到算子层了。这里介绍两个实用方向:分段 Kernel 和 CUDA Graph。
分段 Kernel 的思路一句话概括:保留“不规则”本身,但让硬件各管一段。比如对不同长度的序列,写不同的 CUDA Kernel 分别处理;短序列用简单的单线程 kernel,长序列用大规模并行 kernel。这套思路在 NVIDIA 的CUB库和CuPy里都有对应实现。比强制 padding 更高效,缺点是你要真的会写 CUDA。
CUDA Graph 则是从调度层减少开销:它把 GPU 上的一串 kernel 启动动作预先录制下来,变成一个可随时重放的图。这样原本每个 kernel 之间的启动开销(通常每个 kernel 约 5-10 微秒)就能被砍掉大半。当你的序列长度被分桶之后,每个桶内的计算图是固定的,每轮迭代只需要换数据、重放图。这个技术在处理大规模推荐模型时非常适合,尤其是推理阶段,收益非常直观。
# 伪代码示意:先捕获一组固定的计算图 g = torch.cuda.CUDAGraph() with torch.cuda.graph(g): output = model(fixed_shape_input) # 后续迭代直接重放 for batch in dataloader: fixed_shape_input.copy_(batch) g.replay()这个方案的边界条件是输入 shape 必须固定,所以必须配合分桶策略。你为每个 bucket 捕获一个 CUDA Graph,运行时根据样本长度选择对应的图。工程复杂度高,但实测推理吞吐能提升 20% 到 40%。
5. 工程落地的细节:框架 API、数据加载与多卡同步
5.1 不要手写 Padding 循环,框架 API 比你更懂内存
很多新人在处理 GPU 上手写 Padding 时容易写出这样的代码:先torch.zeros一块大内存,然后手动把每条序列复制进去。这种写法的问题是会导致 GPU 显存的反复分配和拷贝,很伤性能。
正确做法是用框架提供的内存管理工具。PyTorch 里有pad_sequence,TensorFlow 里有tf.keras.preprocessing.sequence.pad_sequences,拿过来就用。框架层面对显存做了缓存和复用,比你自己管理高效得多。如果嫌默认接口不够快,可以配合torch.utils.data.DataLoader的collate_fn参数自定义 batch 拼接逻辑。
5.2 pack_padded_sequence 的坑:它不会自动帮你做一切
在 RNN 里处理变长序列,PyTorch 提供了torch.nn.utils.rnn.pack_padded_sequence,它能打包已 padding 的序列,让 RNN 只在有效长度上计算。这个函数很好用,但它的输入要求挺严格——序列必须按长度降序排列,否则会报错或者悄悄算错。
我建议把排序逻辑放在collate_fn里统一做,别放到训练主循环里。示意代码如下:
def collate_fn(batch): sequences, lengths = zip(*batch) sequences = [torch.tensor(seq) for seq in sequences] lengths = torch.tensor(lengths) sorted_idx = torch.argsort(lengths, descending=True) sorted_seqs = [sequences[i] for i in sorted_idx] sorted_lengths = lengths[sorted_idx] padded = pad_sequence(sorted_seqs, batch_first=True) packed = pack_padded_sequence(padded, sorted_lengths, batch_first=True, enforce_sorted=True) return packedenforce_sorted=True是强制校验排序,如果忘了排会当场报错,比悄悄地算错要好太多。
5.3 数据加载器的长度统计与显存预分配
分桶训练的前提是 DataLoader 的collate_fn能实时算出当前 batch 的长度分布。我的建议是加载器里维护一张长度索引表,存储每个样本的长度,分桶操作就在collate_fn里完成。不要等到拿 batch 的时候再当场统计,那样每轮迭代都要做一次扫描,白占 CPU。
还有一个细节:当序列长度变化比较大时,torch.zeros分配出来的显存可能碎片化严重。Apex 或者 PyTorch 自带的torch.cuda.memory_reserved能看到显存分配情况。如果发现显存碎片化,可以用torch.cuda.empty_cache()手动整理。但别频繁调用,这个操作开销很大。
5.4 多卡训练的等长约束
现在训练推荐模型很少用单卡。多卡同步训练时有一个致命的约束:跨卡 batch 的 shape 不一定一致,这会导致torch.nn.parallel.DistributedDataParallel报错。原因是 DDP 在同步梯度时,需要 all-reduce 所有参数张量,而不同 rank 上的 batch 长度不同时,计算出来的梯度张量 size 是一样的,问题不在参数,而在数据并行时每个 rank 的数据 loading 必须同步 return。
解决思路有两个。第一个是全局定长:让所有 rank 使用同一个桶边界,即便某些 rank 上的数据长度阈值宽松,也统一到一个固定的 max_len。第二个是先本地排序再全局平均:每个 rank 在数据加载时计算好各自 batch 的平均长度,然后用跨 rank 的全局平均值做平衡。实际中第二种更省显存,但实现起来烦琐,我通常建议项目初期先用全局定长方案,跑通之后再优化。
6. 验证改造是否有效:先看 Profiler,再谈观感
6.1 判断瓶颈是算力密集型还是带宽密集型
改完代码别急着跑实验。先要判断你的模型瓶颈在哪里,因为不同的瓶颈对应的优化手段完全不同。
- 算力密集型:大部分时间花在矩阵乘法和卷积上,GPU 的 SM(流处理器)利用率应该很高。此时改不规则序列的收益主要在减少无效计算。
- 带宽密集型:大量时间花在显存读写上,比如 embedding 查询、embedding 聚合。此时改序列格式的收益主要来自减少无效的数据搬运。
用nvidia-smi看 GPU 利用率和显存带宽使用率是个粗筛手段,但精确判断得靠 Profiler。
6.2 用 Nsight Systems/Compute 看哪些指标
我通常先用nsys profile看全局时间轴,定位 kernel 之间是否有大段的空白,这往往是 kernel 启动开销或者数据等待。再用ncu --metrics gpu__time_duration.avg看单个 kernel 的实际执行时间。
重点看这几个指标:
sm__throughput.avg.pct_of_peak_sustained_elapsed:SM 利用率,如果低于 50% 说明 GPU 没吃饱gpu__compute_memory_throughput.avg.pct_of_peak_sustained_elapsed:存储带宽占用率dram__bytes_read.sum和dram__bytes_write.sum:显存读写量,如果 padding 占比高,这个数字会很难看
如果发现 DRAM 读写量远大于理论计算需求量,就可以断言 padding 带来的显存浪费是主要矛盾。
6.3 一次真实对比:同一套模型,三种数据处理方式
最后分享一个我当时跑的实测数据,模型是一个标准的 DIN 结构(Deep Interest Network),处理的是用户行为序列,batch size 固定 256,训练 10 万步。
| 方案 | GPU 利用率 | 训练耗时(相对 Padding 基线) | 显存峰值 |
|---|---|---|---|
| Padding + Mask(全局最大长度512) | 58% | 1.0x | 11.2GB |
| 分 5 桶 + 动态 Batch(桶内95分位) | 82% | 0.58x | 6.4GB |
| 分 10 桶 + CUDA Graph | 91% | 0.47x | 5.8GB |
可以看到,仅仅是引入分桶方案,训练速度就快了接近一倍,显存峰值降了 43%。进一步做 CUDA Graph,在推理场景下还会有额外收益,但训练阶段的提升幅度主要靠桶划分达到了天花板,所以如果你的 GPU 利用率已经到 80% 以上,先别急着上 Graph,先检查数据加载和 CPU 预处理瓶颈。
我自己实际踩坑下来,最稳妥的路径是:先做分桶 + 动态 Batch,把 GPU 利用率打上去,再根据 Profile 的结果决定要不要上稀疏化和 CUDA Graph。上来就写 CUDA 不可取,因为你大概率会在调度上浪费大量时间,而收益未必比基础方案高多少。
最后再说一个容易被忽略的小点:分桶方案对数据加载顺序有要求,模型训练时建议在 DataLoader 里做一次 epoch 级别的 shuffle,但同一个 epoch 内部保持分桶数据按桶聚合。这个顺序如果处理不好,会严重影响收敛速度。用 PyTorch 的DistributedSampler时要小心它在多卡场景下对顺序的重排,我就在这里吃过亏。