Rust in Everything
基于 burn 0.22.1

没有 drop_last,多 worker 打乱不完整

主线位置:训练主线的入口,forward 之前:每一步拿到的 batch 有多大、由哪些样本组成。加载器的整体对照见 Dataset 与 DataLoader。

版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB
  d["10 个样本"] --> one["第一次 iter 打乱一次"]
  one --> w0["worker 0 分到 8 个"]
  one --> w1["worker 1 分到 2 个"]
  w0 --> r0["每轮只在段内打乱"]
  w1 --> r1["永远同一批"]
  r0 --> tail["不满的批照样送出"]

实验代码:PyTorch 侧 shuffle_drop.py,burn 侧 crate pitb-exec-data(burn =0.22.1,default-features = false,feature std、flex、autodiff、optim、dataset,另加 rand =0.10.3 用来构造 StdRng;release)。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

模型里有 BatchNorm 时,最后一批只剩 1 个样本就会出错,所以训练加载器常常这样写:

DataLoader(ds, batch_size=4, shuffle=True, generator=g, num_workers=2, drop_last=True)

9 个样本、批大小 4,不打乱,先看最后一批进 BatchNorm1d(4)(训练模式):

bn = nn.BatchNorm1d(4)
for drop in (False, True):
  for ids, x in DataLoader(Data(9), batch_size=4, drop_last=drop):
    try:
      y = bn(x)
      print(f"drop_last={drop}: batch {ids.tolist()} -> bn out row 0 {y[0].tolist()}")
    except ValueError as e:
      print(f"drop_last={drop}: batch {ids.tolist()} -> ValueError: {e}")
drop_last=False: batch [0, 1, 2, 3] -> bn out row 0 [-1.3416072130203247, -1.3416072130203247, -1.3416070938110352, -1.3416072130203247]
drop_last=False: batch [4, 5, 6, 7] -> bn out row 0 [-1.3416073322296143, -1.3416073322296143, -1.3416073322296143, -1.341606855392456]
drop_last=False: batch [8] -> ValueError: Expected more than 1 value per channel when training, got input size torch.Size([1, 4])
drop_last=True: batch [0, 1, 2, 3] -> bn out row 0 [-1.3416072130203247, -1.3416072130203247, -1.3416070938110352, -1.3416072130203247]
drop_last=True: batch [4, 5, 6, 7] -> bn out row 0 [-1.3416073322296143, -1.3416073322296143, -1.3416073322296143, -1.341606855392456]

10 个样本、打乱、drop_last=True,每个 epoch 丢掉的是打乱之后排在末尾的两个,每次不同:

drop_last=True, shuffle, epoch 0: [[1, 3, 5, 7], [9, 4, 6, 2]] dropped [0, 8]
drop_last=True, shuffle, epoch 1: [[5, 1, 6, 0], [9, 7, 8, 4]] dropped [2, 3]
drop_last=True, shuffle, epoch 2: [[4, 3, 2, 8], [1, 0, 6, 5]] dropped [7, 9]

2 个 worker、打乱、20 个 epoch,统计每个样本和多少个不同的样本同过一批(10 个样本,最多 9 个):

shuffle, 2 workers, first 3 epochs: [[[1, 3, 5, 7], [9, 4, 6, 2], [8, 0]], [[6, 5, 9, 0], [8, 4, 2, 1], [7, 3]], [[5, 9, 0, 3], [4, 7, 6, 8], [2, 1]]]
shuffle, 2 workers, 20 epochs, distinct batch-mates per sample: [9, 9, 9, 9, 9, 9, 9, 9, 9, 9]

(这里开了 persistent_workers=True,避免每个 epoch 都等 worker 退出;它不改变打乱的方式。)

搬到 burn 的现象

不满的批照样送出

DataLoaderBuilder 没有 drop_last。同样 9 个样本,batch_size(4),加载器建在 Device::flex().autodiff() 上,每批过 BatchNormConfig::new(4).init(&device):

batch [0, 1, 2, 3] -> bn out row 0 [-1.3416073, -1.3416073, -1.3416073, -1.3416071]
batch [4, 5, 6, 7] -> bn out row 0 [-1.3416075, -1.3416073, -1.3416073, -1.3416071]
batch [8] -> bn out row 0 [0.0, 0.0, 0.0, 0.0]

前两批和 PyTorch 只差在最后一位。只有 1 个样本的那一批不报错:每个通道的批内方差是 0,x - mean 也是 0,归一化之后全是 0,再乘 gamma 加 beta(初始化成 1 和 0)还是 0。这一批的输出和输入无关,训练照常往下走,不会有任何提示。

多 worker 只在分段内打乱

同样的统计,burn num_workers(2)、shuffle(7)、20 个 epoch(bin mates):

shuffle(7), 2 workers, first 3 epochs: [[[3, 4], [6, 1, 0, 7], [9, 8, 2, 5]], [[0, 7, 2, 5], [1, 8, 9, 6], [3, 4]], [[9, 0, 2, 6], [4, 3], [7, 1, 5, 8]]]
shuffle(7), 2 workers, 20 epochs, distinct batch-mates per sample: [7, 7, 7, 1, 1, 7, 7, 7, 7, 7]

样本 3 和 4 在 20 个 epoch 里只和对方同过批,其余 8 个样本也只在彼此之间轮换,从来碰不到 3 和 4。PyTorch 同样的设置是每个样本 9 个。

种子不通用

同一个种子,两边顺序不同,连第一个 epoch 也对不上(上面 PyTorch 第一个 epoch 是 [1, 3, 5, 7] 开头)。这一点和对齐的办法在 Dataset 与 DataLoader 的「打乱:种子不通用」一节里已经实测过,这里不重复;burn 自己的种子还会因为 to_device 改变顺序,见 burn 卷的同一个种子,顺序不一样。

原因

batch_size(n) 装上的是 FixBatchStrategy。迭代器取完数据集之后调一次 FixBatchStrategy::batch(force 为真),这时不检查数量,剩下的样本全部作为最后一批送出:

    fn batch(&mut self, force: bool) -> Option<Vec<I>> {
        if self.items.len() < self.batch_size && !force {
            return None;
        }
        // ...

builder 只有 batch_size、shuffle、num_workers、set_device 这几个设置,没有换批策略的入口。

多 worker 的打乱在 MultiThreadDataLoader::initialize 里:第一次 iter() 时用种子把整个数据集打乱一次,再用 PartialDataset::split_chunks 按批切给各个 worker(10 个样本、3 批、2 个 worker:8 个和 2 个)。之后每个 epoch,每个 worker 只在自己的那一段里重新打乱。分段在第一次之后就固定了,第二个 worker 的两个样本只能和对方同批。机制和输出在 Dataset 与 DataLoader 的「多 worker」一节和它的动脑筋答案里有更详细的说明。

origin/main(2026-10-28)上,builder 加了 prefetch,BatchStrategy 加了一个 expected_num_items 方法(文档里提到策略可能丢弃样本);没有 drop_last 选项,多 worker 的分段打乱也没有改。

改法

drop_last:换掉批策略

BatchStrategy 是公开的 trait,BatchDataLoader::new 也是公开的,可以绕过 builder 传一个自己的策略。force 时也不交出不满的那一批,就是 drop_last:

struct DropLast {
  items: Vec<Sample>,
  batch_size: usize,
}

impl BatchStrategy<Sample> for DropLast {
  fn add(&mut self, item: Sample) {
    self.items.push(item);
  }
  fn batch(&mut self, _force: bool) -> Option<Vec<Sample>> {
    if self.items.len() < self.batch_size {
      return None; // also when forced: the short tail is dropped
    }
    Some(self.items.drain(..self.batch_size).collect())
  }
  fn clone_dyn(&self) -> Box<dyn BatchStrategy<Sample>> {
    Box::new(DropLast { items: Vec::new(), batch_size: self.batch_size })
  }
  fn batch_size(&self) -> Option<usize> {
    Some(self.batch_size)
  }
}

let l: Arc<dyn DataLoader<Batch>> = Arc::new(BatchDataLoader::new(
  Box::new(DropLast { items: Vec::new(), batch_size: 4 }),
  Arc::new(InMemDataset::new(samples(10))),
  Arc::new(MlpBatcher),
  Device::flex(),
  Some(StdRng::seed_from_u64(7)),
));
DropLast, shuffle 7, epoch 0: [[5, 2, 7, 3], [1, 6, 8, 9]] dropped [0, 4]
DropLast, shuffle 7, epoch 1: [[1, 3, 7, 4], [5, 0, 2, 9]] dropped [6, 8]
DropLast, shuffle 7, epoch 2: [[5, 8, 6, 9], [1, 2, 3, 7]] dropped [0, 4]
FixBatchStrategy via BatchDataLoader::new, epoch 0: [[5, 2, 7, 3], [1, 6, 8, 9], [4, 0]]
  • 打乱发生在批策略之前,所以和 PyTorch 一样,每个 epoch 丢掉的是那一轮排在末尾的样本。
  • Some(StdRng::seed_from_u64(7)) 就是 builder 里 shuffle(7) 做的事(build 里是 self.shuffle.map(StdRng::seed_from_u64)),最后一行用 FixBatchStrategy 对照,前两批和 DropLast 相同,只多了 [4, 0]。
  • 这条路只适用于单线程加载器。MultiThreadDataLoader::new 也接受一个策略,但每个 worker 拿到的是它的一份拷贝(clone_dyn),只对自己那一段起作用。同样的 DropLast 换成 2 个 worker(bin droplastmt,5 个 epoch):
DropLast, 2 workers, shuffle 7, epoch 0: [[6, 1, 0, 7], [9, 8, 2, 5]] dropped [3, 4]
DropLast, 2 workers, shuffle 7, epoch 1: [[0, 7, 2, 5], [1, 8, 9, 6]] dropped [3, 4]
DropLast, 2 workers, shuffle 7, epoch 2: [[9, 0, 2, 6], [7, 1, 5, 8]] dropped [3, 4]
DropLast, 2 workers, shuffle 7, epoch 3: [[5, 8, 6, 0], [9, 1, 7, 2]] dropped [3, 4]
DropLast, 2 workers, shuffle 7, epoch 4: [[1, 7, 0, 2], [5, 6, 9, 8]] dropped [3, 4]

第二个 worker 的那一段只有 2 个样本,凑不满一批,每个 epoch 都被丢掉:样本 3 和 4 从此不参与训练。

  • 多 worker 时在训练循环里跳过不满的批(if batch.x.dims()[0] < 4 { continue; })。它丢的是这一轮里那个不满的批,在分段固定的情况下同样总是那几个样本,所以要和下面的「每个 epoch 换一个排列」一起用。用 PartialDataset 在打乱之前截掉末尾也不等价:被截掉的样本永远用不上(Dataset 与 DataLoader 实测过)。

多 worker 下的全局打乱:每个 epoch 换一个排列

加载器本身不打乱,每个 epoch 自己生成一个新排列,用 SelectionDataset::from_indices_checked 包一层,再建一个加载器:

let mut rng = StdRng::seed_from_u64(7);
let es: Vec<Vec<Vec<usize>>> = (0..n_epochs)
  .map(|_| {
    let mut perm: Vec<usize> = (0..n).collect();
    perm.shuffle(&mut rng);
    let ds = SelectionDataset::from_indices_checked(InMemDataset::new(samples(n)), perm);
    let l = DataLoaderBuilder::new(MlpBatcher).batch_size(4).num_workers(workers).set_device(Device::flex()).build(ds);
    ids(&l)
  })
  .collect();
fresh permutation per epoch, 2 workers, first 3 epochs: [[[1, 3, 4, 6], [0, 7, 2, 5], [8, 9]], [[9, 7, 6, 5], [0, 2, 1, 4], [3, 8]], [[4, 8, 0, 3], [7, 5, 1, 2], [9, 6]]]
fresh permutation per epoch, 2 workers, 20 epochs, distinct batch-mates per sample: [9, 9, 9, 9, 9, 9, 9, 9, 9, 9]

每个样本都和其余 9 个同过批,和 PyTorch 一样。批的组成由排列决定,worker 只是并行地把各段做成 batch;批的到达先后仍然不固定(谁先做完谁先送)。代价是每个 epoch 新建一次加载器,worker 线程也跟着重建。

怎么检查

  • 训练循环里打印或断言每批的大小:batch.x.dims()[0]。最后一批比 batch_size 小,就是没有 drop_last。
  • 模型里有 BatchNorm 时,留意只有 1 个样本的批:burn 不报错,输出全是 beta。
  • 开了多个 worker 又打乱时,像上面那样统计几轮的「同批样本数」:明显小于 样本数 - 1 就是分段打乱。
  • 两边逐步对比训练时,不要指望同一个种子给出同样的顺序,把 PyTorch 的顺序导出来交给 SelectionDataset。

小结

  • burn 0.22.1 没有 drop_last,不满的最后一批照样送出;1 个样本的批过 BatchNorm 得到全 0,PyTorch 会报 Expected more than 1 value per channel when training。
  • 单线程加载器可以用 BatchDataLoader::new 传一个 force 时也不出尾批的 BatchStrategy,行为和 drop_last 一样;同一个策略交给多 worker 加载器,每个 epoch 丢的都是同样的样本(3 和 4)。
  • 多 worker 加打乱只在第一次分段时全局打乱一次,之后每个 worker 在段内打乱:20 个 epoch 里样本 3 和 4 只和对方同批。要全局打乱,每个 epoch 用新排列建 SelectionDataset。
  • 种子在两边不通用;origin/main 上没有 drop_last,分段打乱也没有变。

💬 关联讨论

发起讨论
加载中...