Rust in Everything
基于 tenferro-runtime 0.8.0

矩阵乘结果不对,也没有报错

主线位置:直接计算主线的第 1 步。TypedTensor::from_vec_col_major 建张量,这一步决定后面每个运算看到的是哪个矩阵。为什么拥有型张量只有列主序、视图怎么表示步长,见列主序与视图。

flowchart TB
  rows["按行写的 Vec"] --> ctor["from_vec_col_major"]
  ctor --> chk{"元素个数对吗"}
  chk -->|对| t["按列读进张量"]
  t --> wrong["矩阵错位"]
  wrong --> mm["matmul 照常算"]
  mm --> out["结果错,无报错"]
  rows --> fix["from_vec_row_major"]
  fix --> ok["重排成列主序"]

实验 crate exp-tenferro8/pit1-pit(tenferro 0.8.0,默认 native provider),bin p01_row_major,release;M2 Max、macOS 27、rustc 1.99.0。

现象

想算 A = [[1,2,3],[4,5,6]] 乘 B = [[1,0],[0,1],[1,1]],答案是 [[4,5],[10,11]]。照 C、NumPy、Rust 嵌套 Vec 的习惯把两个矩阵按行摊平:

let mut engine = CpuEngine::serial();
let a_row = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0];
let b_row = vec![1.0_f64, 0.0, 0.0, 1.0, 1.0, 1.0];
let a = TypedTensor::<f64>::from_vec_col_major(vec![2, 3], a_row.clone())?;
let b = TypedTensor::<f64>::from_vec_col_major(vec![3, 2], b_row.clone())?;
let c = a.matmul(&b, &mut engine)?;
wrong: a rows [[1.0, 3.0, 5.0], [2.0, 4.0, 6.0]]
wrong: a*b rows [[1.0, 9.0], [2.0, 12.0]]

(rows 是实验里的小函数,按 d[i + m * j] 把列主序缓冲区按行打印。)两次构造都成功,matmul 也成功,只是 a 已经不是 A:第一列是 [1, 2],第二列是 [3, 4]。四个结果没有一个对。

方阵更隐蔽,形状完全没有机会出错,张量恰好是想要的矩阵的转置:

square: m*[1,1] = [4.0, 6.0] (want [3.0, 7.0])

(m 用 vec![1.0, 2.0, 3.0, 4.0] 构造,本意是 [[1,2],[3,4]],乘全 1 列向量本该得到行和 [3, 7],得到的是列和。)对称矩阵上完全看不出来,测试只用单位阵、全 1 矩阵或对称矩阵时,这个错误能一直留到真实数据上。

出处

TypedTensor::from_vec_col_major 最后落到 try_typed_tensor_from_vec_col_major:

    let layout = tenferro_tensor_core::col_major_strides(shape.as_ref())
        .map_err(|err| tensor_layout_error(op, err))?;
    try_checked_shape_len(shape.as_ref(), data.len(), op)?;
    Ok(TypedTensor {
        shape,
        placement: default_placement(),
        layout,
        storage: DynamicStorage::Host(HostStorage {
            data: HostData::new(data),
        }),
    })

它只检查「形状之积等于 data.len()」,然后把 Vec 原样收进存储,步长是紧凑列主序。一串数字本身不带「我是按行排的」这个信息,构造函数无从判断。README 的「Conventions you must know before writing code」第一条写的就是这件事:「Row-major data passed to from_vec_col_major is silently reinterpreted as column-major — permuted/wrong values, never rejected.」

修法

0.8.0 有显式的行主序入口 from_vec_row_major,TypedTensor 和动态的 Tensor(Tensor::from_vec_row_major)上都有。它把数据重排一次,再交给列主序构造函数,结果仍是普通的紧凑列主序张量。另一种做法是自己按列写缓冲区:

// 一:显式按行导入
let a1 = TypedTensor::<f64>::from_vec_row_major(vec![2, 3], a_row.clone())?;
let b1 = TypedTensor::<f64>::from_vec_row_major(vec![3, 2], b_row.clone())?;

// 二:直接按列写
let a2 = TypedTensor::<f64>::from_vec_col_major(vec![2, 3], vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0])?;
let b2 = TypedTensor::<f64>::from_vec_col_major(vec![3, 2], vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0])?;

// 动态张量同样有
let t = Tensor::from_vec_row_major(vec![2, 3], a_row.clone())?;
fix from_vec_row_major: a*b rows [[4.0, 5.0], [10.0, 11.0]]
fix col-major buffer:   a*b rows [[4.0, 5.0], [10.0, 11.0]]
Tensor::from_vec_row_major buffer [1.0, 4.0, 2.0, 5.0, 3.0, 6.0]

from_vec_row_major 是单线程的逐元素复制。数据大、手里又已经有引擎时,把行主序缓冲区包成步长 [n, 1] 的视图再 engine.to_contiguous 更快,几种做法的耗时对比和这个入口的来历见列主序与视图。

怎么确认没中招

  • 单元测试里至少放一个非对称、非方阵的输入,并抽查一个元素:

    check a1.get([0,1]) = Ok(2.0) (want 2.0), a.get([0,1]) = Ok(3.0)
    

    get(&[i, j]) 按逻辑下标取值,和布局无关;[0, 1] 应该是第 0 行第 1 列的 2。

  • 搜一遍代码里所有 from_vec_col_major 的调用点,看数据从哪里来:嵌套 Vec 的 flatten()、ndarray 默认(C 序)数组的 into_raw_vec、按行读的 CSV、PyTorch 和 NumPy 导出的缓冲区,都是行主序,应该换成 from_vec_row_major。

  • 读回结果时同样要按列主序解释:host_data() 和 as_slice() 给的是列主序缓冲区,按行打印要用 i + m·j 取下标。

小结

  • from_vec_col_major 只查元素个数,发现不了数据是按行排的;方阵上的结果恰好是转置。
  • 行主序数据用 from_vec_row_major(TypedTensor 和 Tensor 都有),它重排一次,结果仍是列主序。
  • 测试用非对称、非方阵的输入,抽查 get 一个已知元素。

💬 关联讨论

发起讨论
加载中...