Rust in Everything
基于 tenferro-runtime 0.8.0

越界下标不报错,取到边上的值

主线位置:直接计算主线和 traced 主线上的索引运算,take_along_axis / gather / dynamic_slice。take_along_axis 是怎么分解成一个 gather 的,见 神经网络运算。

flowchart TB
  idx["下标 5 和 -1"] --> tak["take_along_axis"]
  tak --> gat["gather"]
  gat --> clamp["夹到 0 到 dim-1"]
  clamp --> v["取到边上的值"]
  idx --> chk["调用前自己检查"]
  chk --> err["越界就报错"]

实验 crate exp-tenferro8/pit2-exp,bin p29_take_along_axis,CPU(CpuEngine::serial()),debug,M2 Max、macOS 27、rustc 1.99.0。

现象

x = [[10, 20], [11, 21], [12, 22]](3×2),沿第 0 轴取,每列一个下标:

let x = TypedTensor::<f64>::from_vec_col_major(vec![3, 2], vec![10.0, 11.0, 12.0, 20.0, 21.0, 22.0])?;
let ok = Tensor::from_vec_col_major(vec![1, 2], vec![2_i64, 0])?;
x.take_along_axis(&ok, 0, &mut engine)?;
let bad = Tensor::from_vec_col_major(vec![1, 2], vec![5_i64, -1])?;
x.take_along_axis(&bad, 0, &mut engine);
// traced 版同样的 [5, -1]
idx [2, 0]: [12.0, 20.0]
idx [5, -1] (axis 0 has 3): Ok([12.0, 20.0])
traced idx [5, -1]: [12.0, 20.0]
  • 第 0 轴只有 3 个元素,下标 5 和 −1 都越界,结果是 Ok。
  • 5 被夹成 2(最后一行),−1 被夹成 0(第一行),结果和合法的 [2, 0] 完全一样。
  • NumPy 的 take_along_axis 遇到 5 抛 IndexError,遇到 −1 取倒数第一个(第二列就是 22);PyTorch 的 gather 遇到越界报错。从这两边移植过来的代码,下标算错时在 tenferro 上不会有任何信号。

出处

take_along_axis 是组合运算,文档写着「Indices must be in [0, x.shape[axis]); out-of-range indices follow gather」,实现就是一个 gather。TracedTensor::gather 的文档有一节「Clamping」:

    /// # Clamping
    ///
    /// Index values are clamped with StableHLO gather clamp semantics: an
    /// out-of-range or negative index is clamped into `[0, dim - window]` rather than rejected.
    /// Only a `slice_sizes` window that exceeds its operand dimension is rejected,
    /// which is a graph-build failure.

CPU 上 gather 编译成 strided-basic 的一个 gather 计划执行,夹取在它的 clamp_window_start:

    fn clamp_window_start(&self, start: i64, operand_dim: usize) -> usize {
        let dim_size = self.operand_dims[operand_dim];
        let window_size = self.spec.slice_sizes[operand_dim];
        let max_start = dim_size.saturating_sub(window_size) as i64;
        start.clamp(0, max_start) as usize
    }

take_along_axis 的窗口沿这一轴长 1,所以范围是 [0, 2]。tenferro-cpu 里另有一个同名函数,在 gather、scatter、dynamic_slice、dynamic_update_slice 执行前只拿它检查「窗口不大于轴长」,起点照样不检查。

这是有意的:StableHLO 规定 gather 和 dynamic slice 夹取,XLA 照此执行,tenferro 把它定为后端的约定,tenferro-ad 有专门的测试钉住这个行为。夹取让图里不需要数据相关的报错分支,代价是下标错误被静默吞掉。

📝NOTE
冷知识:文档曾经承诺会报错

PR #2157(提交 6ed2a521,首次进入 v0.8.0)是一批 GPU 正确性和 CPU 缺陷修复,其中一条针对 issue #2068:提交说明写着,gather 和 dynamic_slice 的 rustdoc 之前「promised a deferred typed rejection of out-of-range runtime starts/indices that no execution path performs」,即文档说越界会在执行时得到一个类型化的错误,可没有任何后端这样做。修法不是补上检查,而是改文档:StableHLO 的夹取语义被定为后端的约定,文档改成上面那节「Clamping」,再加一个契约测试,要求文档里必须出现「rather than rejected」这几个字、不许出现「Deferred errors」标题,防止文档和后端再次各说各话。

修法

下标来自数据、可能越界时,调用前自己检查:

let idx = [5_i64, -1];
let n = x.shape()[0] as i64;
let bad_pos: Vec<usize> = idx.iter().enumerate().filter(|(_, &i)| i < 0 || i >= n).map(|(p, _)| p).collect();
println!("out-of-range positions: {bad_pos:?}");
out-of-range positions: [0, 1]
  • 要 NumPy 那种负下标,先自己换算:i < 0 时加上轴长。
  • traced 程序里下标是图里的值,没法在建图时检查。可以在图里算一个「越界个数」(compare 出两个布尔掩码、转成数再 reduce_sum)作为额外的输出,执行后在主机上判断;或者在喂进图之前检查输入。
  • 下标真的可能落在范围外、而你想要的就是「贴边」时(例如插值时的边界延拓),夹取正好是想要的行为,不用改。

怎么确认没中招

  • 搜 take_along_axis、gather、dynamic_slice,看下标的来源:是 argmax 这类保证在范围内的结果就没事;是外部数据、算术算出来的、可能为负的,就要检查。
  • 测试里故意放一个越界下标,确认你的代码在这时报错,而不是拿到一个看似正常的结果。
  • 从 NumPy 移植的代码,搜负下标的用法。

小结

  • take_along_axis、gather、dynamic_slice 按 StableHLO 的语义夹取越界下标,不报错:5 变成最后一个、−1 变成第一个。
  • 这是 v0.8.0 定下的约定,文档在 PR #2157 之前曾经承诺会报错。
  • 下标可能越界时,调用前自己检查;负下标要自己换算。

💬 关联讨论

发起讨论
加载中...