Rust in Everything
基于 tenferro-runtime 0.8.0

scatter 把更新加了上去

主线位置:张量主线的第二步「计算」,按下标写入(JAX 的 .at[i].set)。

版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native)
flowchart TB
  w["写 99 到下标 1"] --> js["JAX at set"]
  js --> j99["10, 99, 12"]
  w --> ts["tenferro scatter"]
  ts --> t110["10, 110, 12"]
  w --> fix["掩码加 where"]
  fix --> j99

实验代码:JAX 脚本一个,tenferro 程序 p05_scatter(traced,CpuEngine::serial(),release)。平台 M2 Max、macOS 27、rustc 1.99.0。

现象

JAX 数组不可变,按下标写入用 .at[...],set 和 add 是两种操作:

x = jnp.array([10.0, 11.0, 12.0])
x.at[1].set(99.); x.at[1].add(99.); x.at[jnp.array([1, 1])].set(99.); x.at[5].set(99.); x.at[-1].set(99.)
x.at[1].set(99.)             [10.0, 99.0, 12.0]
x.at[1].add(99.)             [10.0, 110.0, 12.0]
x.at[jnp.array([1, 1])].set(99.) [10.0, 99.0, 12.0]
x.at[5].set(99.)             [10.0, 11.0, 12.0]
x.at[-1].set(99.)            [10.0, 11.0, 99.0]

tenferro 里对应的原语只有一个 scatter。把 .at[i].set(99) 换成它:

let scfg = ScatterConfig {
  update_window_dims: vec![],
  inserted_window_dims: vec![0],
  scatter_dims_to_operand_dims: vec![0],
  index_vector_dim: 1,
};
x.scatter(&idx(&[1])?, &upd99, scfg)?    // idx: [n, 1] 的 I64 下标
scatter idx [1] upd 99: [10.0, 110.0, 12.0]
scatter idx [1, 1] upd 99: [10.0, 209.0, 12.0]
scatter idx [5] upd 99: [10.0, 11.0, 111.0]
scatter idx [-1] upd 99: [109.0, 11.0, 12.0]

四种情况和 .at[].set 都不一样:

  • 下标 1:加上去,得 110。和 .at[1].add 相同。
  • 重复下标 [1, 1]:加两次,得 209。
  • 越界下标 5:JAX 丢弃这个更新,tenferro 把它夹到最后一个元素加进去,得 111。
  • 负下标 −1:JAX 写到最后一个,tenferro 夹到第一个,得 109。

出处

tenferro 的 scatter 是加法 scatter。CPU 内核 scatter_with_pool 拒绝 Bool 时报的就是「Bool data tensors are not supported by additive scatter」;原语规格 primitive-catalog.md 给它的备注是「Transpose of Gather. Needed for AD of Gather and for embed_diag」:它首先是给 gather 求导用的,而 gather 的反向必须把重复下标的余切加在一起。TracedTensor::scatter 的文档写明下标「is clamped rather than rejected」,负下标也不换算(同x[-1] 取到的是第一个元素)。JAX 的 .at[i].set 是另一个 StableHLO scatter(更新函数是「取新值」),越界时默认丢弃。并排对照见整数运算的下标一节。

修法

想要的是 .at[i].add、下标都在范围内时,scatter 就是对的。想要 .at[i].set 时,把 JAX 在下标上做的事搬到主机上(负下标加 n、丢掉越界的、重复的只留最后一个),再用两次 scatter 和 where_select 把「累加」变成「写入」:

fn set_at(x: &TracedTensor, ids: &[i64], upd: &[f64]) -> R<TracedTensor> {
  let n = x.concrete_shape()?[0] as i64;
  let mut kept: Vec<(i64, f64)> = Vec::new();
  for (&i, &u) in ids.iter().zip(upd) {
    let i = if i < 0 { i + n } else { i };
    if (0..n).contains(&i) {
      kept.retain(|(j, _)| *j != i);
      kept.push((i, u));
    }
  }
  let zeros = TracedTensor::from_vec_col_major(vec![n as usize], vec![0.0_f64; n as usize])?;
  if kept.is_empty() {
    return Ok(x.clone());
  }
  let ki: Vec<i64> = kept.iter().map(|p| p.0).collect();
  let ku: Vec<f64> = kept.iter().map(|p| p.1).collect();
  let ones = TracedTensor::from_vec_col_major(vec![ki.len()], vec![1.0_f64; ki.len()])?;
  let vals = TracedTensor::from_vec_col_major(vec![ki.len()], ku)?;
  let mask = zeros.scatter(&idx(&ki)?, &ones, scfg())?.compare(&zeros, CompareDir::Gt)?;
  let written = zeros.scatter(&idx(&ki)?, &vals, scfg())?;
  Ok(TracedTensor::where_select(&mask, &written, x)?)
}
set_at idx [1] upd 99: [10.0, 99.0, 12.0]
set_at idx [1, 1] upd 99: [10.0, 99.0, 12.0]
set_at idx [5] upd 99: [10.0, 11.0, 12.0]
set_at idx [-1] upd 99: [10.0, 11.0, 99.0]

四行和 JAX 的 .at[].set 逐项相同。往全 0 张量里 scatter 时,下标已经去重,「累加」和「写入」结果一样;掩码决定哪些位置用新值。这个写法要求下标在主机上已知。下标是图里算出来的时,去重和越界检查没有现成的原语,要按具体问题另想办法(例如保证下标唯一且在范围内,再用这里的掩码法)。

怎么确认没中招

  • 在 scatter 的测试里让原值不为 0(例如 10、11、12):原值是 0 时累加和写入看不出区别。
  • 测一次重复下标和一次越界下标,期望值按你要的 JAX 操作(set 还是 add)写。
  • 代码评审时把每个 scatter 调用旁边注明它对应 .at[].add 还是 .at[].set。

小结

  • tenferro 的 scatter 是累加,对应 .at[i].add,不是 .at[i].set;重复下标加多次。
  • 越界和负下标都夹到边上再加进去,JAX 的 set / add 是丢弃越界、负下标从末尾数。
  • 要 set:主机上整理下标,往全 0 张量里 scatter 出新值和掩码,用 where_select 合成。

💬 关联讨论

发起讨论
加载中...