
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_firstTrue, padding_value0) # 输出 shape: [4, 12]batch_firstTrue会把输出拼成[batch, max_len]的布局比默认的[max_len, batch]更符合推荐系统里 embedding 查询的书写习惯。padding_value0这个参数我单独拿出来说很多初学者会漏掉。2.2 Mask 的两种构造方式漏掉一个就是线上事故Padding 之后必须配 Mask否则模型会把补的 0 当真。这里有两种 Mask 容易搞混我分别说。第一种是Padding Mask用来告诉模型“哪些位置是真实数据哪些是补位”。构造方式非常简单# 假设 padded shape: [batch, max_len] # 真实长度记录在 lengths 里 mask torch.arange(max_len, devicepadded.device).unsqueeze(0) lengths.unsqueeze(1)逻辑就是逐个位置比较下标是否小于真实长度。小于的是有效位置 True大于等于的说明是 padding 位置 False。第二种是Causal Mask因果掩码用在 Transformer 结构里确保位置 i 只能看到 i 之前的信息不能“偷看”未来的行为。这个在实际推荐模型里特别关键因为用户后续的行为序列是训练标签不能用它来预测自己。causal_mask torch.tril(torch.ones((max_len, max_len), dtypetorch.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。如果全部丢进同一个 batchmax_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 大小不同会导致 BNBatch 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 MultiplicationSpMM和 Sampled Dense-Dense Matrix MultiplicationSDDMM在 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_sequenceTensorFlow 里有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, descendingTrue) sorted_seqs [sequences[i] for i in sorted_idx] sorted_lengths lengths[sorted_idx] padded pad_sequence(sorted_seqs, batch_firstTrue) packed pack_padded_sequence(padded, sorted_lengths, batch_firstTrue, enforce_sortedTrue) return packedenforce_sortedTrue是强制校验排序如果忘了排会当场报错比悄悄地算错要好太多。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_elapsedSM 利用率如果低于 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全局最大长度51258%1.0x11.2GB分 5 桶 动态 Batch桶内95分位82%0.58x6.4GB分 10 桶 CUDA Graph91%0.47x5.8GB可以看到仅仅是引入分桶方案训练速度就快了接近一倍显存峰值降了 43%。进一步做 CUDA Graph在推理场景下还会有额外收益但训练阶段的提升幅度主要靠桶划分达到了天花板所以如果你的 GPU 利用率已经到 80% 以上先别急着上 Graph先检查数据加载和 CPU 预处理瓶颈。我自己实际踩坑下来最稳妥的路径是先做分桶 动态 Batch把 GPU 利用率打上去再根据 Profile 的结果决定要不要上稀疏化和 CUDA Graph。上来就写 CUDA 不可取因为你大概率会在调度上浪费大量时间而收益未必比基础方案高多少。最后再说一个容易被忽略的小点分桶方案对数据加载顺序有要求模型训练时建议在 DataLoader 里做一次 epoch 级别的 shuffle但同一个 epoch 内部保持分桶数据按桶聚合。这个顺序如果处理不好会严重影响收敛速度。用 PyTorch 的DistributedSampler时要小心它在多卡场景下对顺序的重排我就在这里吃过亏。