Metal 上越界的下标不报错,读出 0
主线位置:训练主线的 forward → loss:
Embedding用 token id 取行,交叉熵用标签取 log 概率,两处都是「按下标读」。
版本:PyTorch 2.15.0(CPU 和 MPS)、burn 0.22.1(Flex 设备和 Metal 设备)
flowchart TB
id["越界的 id 或标签"] --> dev{"设备"}
dev -->|"torch CPU"| e1["当场 IndexError"]
dev -->|"torch MPS"| e2["同步时报错"]
dev -->|"burn Flex"| e3["当场 panic"]
dev -->|"burn Metal"| z["读出 0"]
z --> l["loss 偏小 梯度为 0"]
实验代码:PyTorch 侧 oob.py,burn 侧 crate pitb-exec-gpu(burn = { version = "=0.22.1", features = ["flex", "metal"] },打开默认 feature,所以 Metal 上有 Fusion;release)。每个程序跑两遍,结果相同。平台 M2 Max、macOS 27、rustc 1.99.0。
PyTorch 的写法
词表 4 个词,有一个 token id 写成了 4(多了一个特殊符号、忘了扩词表);3 分类,有一个标签是 3(标签从 1 开始编号)。这在 PyTorch 里不会走远:
emb = nn.Embedding(4, 3).to(dev)
ids = torch.tensor([[1, 4]], device=dev)
out = emb(ids)
print(f"{dev}: embedding([[1, 4]]) returned")
print(f"{dev}: values {out.cpu().flatten().tolist()}")
logits = torch.tensor([[2.0, 0.5, -1.0], [0.1, 0.2, 0.3]], device=dev)
for t in ([0, 2], [0, 3], [0, 300]):
loss = F.cross_entropy(logits, torch.tensor(t, device=dev))
print(f"{dev}: cross_entropy targets {t} returned")
print(f"{dev}: loss {loss.item()}")
(实验代码里每次调用都包在 try 里,打印异常类型和第一行信息。)
cpu: embedding raised IndexError: index out of range in self
cpu: cross_entropy targets [0, 2] returned
cpu: loss 0.6216270923614502
cpu: cross_entropy targets [0, 3] raised IndexError: Target 3 is out of bounds.
cpu: cross_entropy targets [0, 300] raised IndexError: Target 300 is out of bounds.
mps: embedding([[1, 4]]) returned
mps: embedding raised AcceleratorError: index 4 is out of bounds for dimension with size 4
mps: cross_entropy targets [0, 2] returned
mps: loss 0.6216270327568054
mps: cross_entropy targets [0, 3] returned
mps: cross_entropy targets [0, 3] raised AcceleratorError: Target 3 is out of bounds.
mps: cross_entropy targets [0, 300] returned
mps: cross_entropy targets [0, 300] raised AcceleratorError: Target 300 is out of bounds.
CPU 在调用那一行报。MPS 上调用先返回,读结果(.cpu()、.item())时报 AcceleratorError,信息里有越界的下标。两种设备都不会让一个越界的下标悄悄算下去。
搬到 burn 的现象
同样的模型和数据,EmbeddingConfig::new(4, 3),权重第 k 行是 [10k, 10k+1, 10k+2]:
let mut emb = EmbeddingConfig::new(4, 3).init(&device);
let w: Vec<f32> = (0..4).flat_map(|k| (0..3).map(move |c| (10 * k + c) as f32)).collect();
emb.weight = Param::from_tensor(Tensor::<1>::from_floats(w.as_slice(), &device).reshape([4, 3]));
let ids = Tensor::<2, Int>::from_ints([[1, 4]], &device);
let out = emb.forward(ids);
let logits = Tensor::<2>::from_floats([[2.0, 0.5, -1.0], [0.1, 0.2, 0.3]], &device);
let loss_fn = CrossEntropyLossConfig::new().init(&device);
for t in [[0, 2], [0, 3], [0, 300]] {
let targets = Tensor::<1, Int>::from_ints(t, &device);
let l = loss_fn.forward(logits.clone(), targets);
// print l
}
Flex:
thread 'main' (8781369) panicked at .../burn-flex-0.22.1/src/ops/gather_scatter.rs:144:5:
index 4 out of bounds for dimension of size 4
flex: embedding([[1, 4]]) panicked
flex: cross_entropy targets [0, 2] -> loss [0.6216271]
thread 'main' (8781399) panicked at .../burn-flex-0.22.1/src/ops/gather_scatter.rs:144:5:
index 3 out of bounds for dimension of size 3
flex: cross_entropy targets [0, 3] panicked
thread 'main' (8781399) panicked at .../burn-flex-0.22.1/src/ops/gather_scatter.rs:144:5:
index 300 out of bounds for dimension of size 3
flex: cross_entropy targets [0, 300] panicked
Metal:
metal: embedding([[1, 4]]) returned, dims [1, 2, 3], values [10.0, 11.0, 12.0, 0.0, 0.0, 0.0]
metal: cross_entropy targets [0, 2] -> loss [0.62162685]
metal: cross_entropy targets [0, 3] -> loss [0.12065555]
metal: cross_entropy targets [0, 300] -> loss [0.12065555]
- Flex 和 PyTorch CPU 一样,在调用时就停下来。
- Metal 上没有任何报错,读结果也没有。越界的那个 token 拿到一行 0,形状完全正确。
- 越界的标签让 loss 变小了:
0.12065555正好是第一个样本的交叉熵除以 2(PyTorch 算cross_entropy([[2.0, 0.5, -1.0]], [0]) / 2是0.12065566331148148,差在 f32 的最后几位)。第二个样本取到的 log 概率是 0,等于「模型对这个标签有 100% 把握」,它对 loss 的贡献是 0。 - 标签是 3 还是 300,loss 一样。
这个样本也不会产生梯度。logits 建在 Device::metal(..).autodiff() 上,require_grad() 之后 backward,看第二行的梯度(bin cegrad):
metal: targets [0, 2] grad row 1 [0.15030481, 0.16611251, -0.31641728]
metal: targets [0, 3] grad row 1 [0.0, 0.0, 0.0]
训练照常进行,数据里标错的样本对模型没有任何作用,也没有任何提示。
读出来的也不保证总是 0。x = [1, 2, 3] 在 Metal 上 select 不同的越界下标(bin neighbor,之前先建了一个 64 个 7.0 的张量):
metal: x.select(0, [3]) -> [0.0]
metal: x.select(0, [4]) -> [0.0]
metal: x.select(0, [5]) -> [0.0]
metal: x.select(0, [64]) -> [9e-44]
metal: x.select(0, [1000]) -> [0.0]
metal: x.select(0, [1000000]) -> [0.0]
下标 64 读到的是一个非零的极小数,是内存里碰巧有的位模式。读到什么取决于那块显存里有什么。
原因
burn 的 Embedding 没有自己的内核:Embedding::forward 调 embedding,后端的 ModuleOps::embedding 默认实现把下标展平后调 float_select。交叉熵在 forward_default 里用 log_softmax(logits, 1).gather(1, target_indices) 取每个样本标签处的 log 概率。
Metal 走 cubecl 的内核。select_kernel 和 gather_kernel 都声明成 #[cube(launch_unchecked, ...)],只检查输出位置有没有超出输出张量,不检查读出来的下标值:
#[cube(launch_unchecked, address_type = "dynamic")]
fn gather_kernel<T: Numeric, I: Numeric>(
// ...
) {
if !indices.is_in_bounds(ABSOLUTE_POS) {
terminate!();
}
// ...
offset += usize::cast_from(indices.read(ABSOLUTE_POS)) * input.stride(dim);
output.write(ABSOLUTE_POS, input[offset]);
}
input[offset] 越界时读到什么,cubecl 没有规定。launch_unchecked 和 launch 的区别、#[cube(launch)] 实际上也不截断越界访问,见 burn 卷的自定义内核越界读不报错。
Flex 在 checked_index 里逐个检查,越界就 panic(上面 gather_scatter.rs:144 的那条信息来自它调用的 index_oob)。所以同一份代码,CPU 上报错、GPU 上不报。
Tensor::gather 的文档写着:
/// # Warning
/// Not all backends have runtime bound checks for the indices, so make sure the they are valid.
/// Otherwise, out of bounds indices could lead to unexpected results instead of panicking.
Tensor::select 的文档没有这一段,Embedding 和 CrossEntropyLoss 的文档也没有提。origin/main(2026-10-28)上 select_kernel、gather_kernel 没有改动,select 的文档仍然没有这条警告。这是上游的问题:越界下标在 GPU 后端上是未定义的读,没有任何检查可以打开。
select 的越界警告在一次搬家里丢了PR #2462(「Add warning in docstring for indices bound checks」,首次发布于 v0.16.0)给 gather、scatter、select、select_assign 四个方法加了上面那段「# Warning」。十个月后的 PR #3681(「Add NumPy-like take operation with multi-dimensional index support」,首次发布于 v0.19.0)让 select 和 select_assign 支持负数的 dim,把这两个方法从 numeric.rs 挪到 base.rs。diff 里旧的 select 连同它的警告一起删掉,新写的 select 文档只有 Arguments 和 Example,select_assign 则带着警告重新写了一遍。从那以后,Embedding 底下用的 select 就不再提醒「下标要自己保证在范围内」。
改法
在 CPU 上先跑一遍。 同样的数据用 Device::flex() 跑一个 epoch(或者只跑数据管道和 loss),越界的下标当场 panic,信息里有下标和维度大小。
在 GPU 上自己检查。 下标是数据的一部分,在送进 Embedding、select 或 loss 之前检查一次范围:
fn checked_select(x: Tensor<2>, ids: Tensor<1, Int>) -> Result<Tensor<2>, String> {
let n = x.dims()[0] as i64;
let lo: i64 = ids.clone().min().into_scalar();
let hi: i64 = ids.clone().max().into_scalar();
if lo < 0 || hi >= n {
return Err(format!("index range [{lo}, {hi}] outside 0..{n}"));
}
Ok(x.select(0, ids))
}
flex: checked_select [1, 0] -> Ok [10.0, 11.0, 12.0, 0.0, 1.0, 2.0]
flex: checked_select [1, 2] -> Err index range [1, 2] outside 0..2
metal: checked_select [1, 0] -> Ok [10.0, 11.0, 12.0, 0.0, 1.0, 2.0]
metal: checked_select [1, 2] -> Err index range [1, 2] outside 0..2
into_scalar 要把结果读回主机,等于一次同步,每一步都做会打断 Metal 上的攒批(eager 和延迟)。下标通常在 CPU 上就有(从文件读出来的 token id、标签),更便宜的做法是在 Batcher 里、建张量之前检查原始的 Vec,或者在数据集准备阶段检查一次。
不要用 clamp 把下标夹进范围:那样不报错,越界的 token 变成词表最后一个词,问题照样被藏起来。
怎么检查
- 词表大小和标签数是模型配置的一部分,在数据管道里断言
max(id) < vocab_size、max(label) < num_classes、min >= 0。 - 训练前用 Flex 跑一遍数据和 loss;有越界 panic 时信息形如
index 4 out of bounds for dimension of size 4。 - Metal 上 loss 比预期低、某些样本的梯度全是 0、
Embedding的输出里有整行的 0,都值得回头查下标。
小结
- PyTorch 的越界下标 CPU 当场报
IndexError,MPS 在读结果时报AcceleratorError;burn Flex 当场 panic,burn Metal 不报,读出 0 或别的值。 Embedding(经select)和CrossEntropyLoss(经gather)都受影响:越界标签的样本 loss 贡献为 0、梯度为 0,loss 看着反而更好。- 原因是 cubecl 的
select_kernel/gather_kernel用launch_unchecked,不检查下标值;gather的文档有警告,select的警告在 #3681 时丢了;origin/main 上没有变。 - 改法:先在 Flex 上跑一遍;GPU 上在建张量之前检查下标范围,不要用
clamp掩盖。