Rust in Everything
基于 tenferro-runtime 0.8.0

binding shape mismatch for concrete-shape placeholder

主线位置:变换主线的「编译、准备 → 执行」:同一个函数换一个输入长度再调用。
版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native)
flowchart TB
  c["n 换成 2000"] --> jj["JAX jit"]
  jj --> rt["自动重新追踪"]
  c --> tp["tenferro Prepared"]
  tp --> err["形状不符报错"]
  c --> cache["按 n 查缓存"]
  cache --> miss["没有就建程序"]
  miss --> run["run"]
  cache --> run

实验代码:JAX 脚本一个,tenferro 程序 p09_shape_recompile(CpuEngine::serial(),release)。函数 f(x) = Σ sin(x)·x²,x_i = 2i/(n−1)。计时是单次调用的墙钟时间,只用来区分「建了新程序」和「命中缓存」,不当基准。平台 M2 Max、macOS 27、rustc 1.99.0。

现象

JAX 里 jit 一次,之后什么长度都能传,形状变了就重新追踪、编译,结果按形状缓存:

jf = jax.jit(f)
for n in (1000, 2000, 1000):
  xn = jax.block_until_ready(xs(n))
  y = jax.block_until_ready(jf(xn))
n=1000: 1235.325872458312688  19.26 ms  cache size 1
n=2000: 2470.067398026252249  21.30 ms  cache size 2
n=1000: 1235.325872458312688  0.05 ms  cache size 2

tenferro 里对应 jit 参数的是占位符。照「建一次、到处用」的习惯写:

let x = TracedTensor::input_concrete_shape(DType::F64, &[1000])?;
let p = GraphCompiler::new().compile_with_input_specs(&f(&x)?, &[(&x, DType::F64, &[1000][..])])?;
let pr = prepare(&rt, &eng, &p, &[&xs(1000)?])?;
run1(&rt, &mut eng, &pr, &[&xs(2000)?])
n=1000: 1235.325872458312915
n=2000: Err: binding shape mismatch for concrete-shape placeholder: expected [1000], got [2000]
prepare(n=2000): Err: binding shape mismatch for concrete-shape placeholder: expected [1000], got [2000]

n = 1000 时和 JAX 差 2.3e-13(量级 1.2e3,求和顺序不同)。换成 2000,run 报错,拿同一个程序重新 prepare 也报错。

出处

input_concrete_shape 和 compile_with_input_specs 里写的 [1000] 是程序的一部分,不是提示。执行前核对输入的元数据,形状不同就返回 Error::PlaceholderShapeMismatch,这就是标题里那条消息。JAX 的 jit 是一个以形状和 dtype 为键的缓存,键不命中就重新追踪;tenferro 把「追踪、编译、准备」交给调用方,没有这层缓存。占位符、Prepared 和 JAX 缓存的对照见记图与 jit 的「换一个形状」一节。

还有一条相近的错误:用符号形状的叶子编译、准备一次之后换形状 run,矩阵乘这类按具体维度特化的运算报 prepared specialization projection mismatch,那是 tenferro 卷的常见坑prepared specialization projection mismatch。两条的共同点:一个 Prepared 只服务一种形状。

修法

自己写 jit 那层缓存:按形状建程序,准备一次,存起来。

struct ShapeCache {
  progs: HashMap<usize, Prepared>,
}

impl ShapeCache {
  fn call(&mut self, rt: &Runtime, eng: &mut CpuEngine, x: &Tensor) -> R<f64> {
    let n = x.shape()[0];
    if !self.progs.contains_key(&n) {
      let ph = TracedTensor::input_concrete_shape(DType::F64, &[n])?;
      let p = GraphCompiler::new().compile_with_input_specs(&f(&ph)?, &[(&ph, DType::F64, &[n][..])])?;
      self.progs.insert(n, prepare(rt, eng, &p, &[x])?);
    }
    let pr = self.progs.get(&n).ok_or("cache")?;
    Ok(run1(rt, eng, pr, &[x])?[0])
  }
}
n=1000: 1235.325872458312915  377.4 us  cached shapes 1
n=2000: 2470.067398026252704  274.8 us  cached shapes 2
n=1000: 1235.325872458312915  9.7 us  cached shapes 2

和 JAX 的三次调用一一对应:两次新形状各建一个程序(tenferro 0.27–0.38 ms,JAX 19–21 ms),第三次命中缓存。缓存的键要包含所有会变的东西:多个输入时是每个输入的形状和 dtype。

形状只在少数几维上变、又不想为每种形状重建图时,可以用符号形状的数据叶子编译一次,换形状时只重新 prepare,见记图与 jit。

怎么确认没中招

  • 程序要处理的输入形状不止一种时,测试里至少用两种形状各调一次。
  • 看到 binding shape mismatch for concrete-shape placeholder,先查是不是把为别的形状准备的 Prepared 拿来复用了。
  • 缓存会一直增长:形状种类很多时(例如每步截断数都不同),给它设上限或按需清理。

小结

  • JAX 的 jit 是按形状缓存的,换长度自动重新编译;tenferro 的 Prepared 绑定一种具体形状,换长度报 binding shape mismatch for concrete-shape placeholder。
  • prepare 同一个程序也不行:形状写在程序里。
  • 修法是自己按形状缓存程序;符号形状的叶子可以省掉重建图,但仍要按形状重新 prepare。

💬 关联讨论

发起讨论
加载中...