Rust in Everything
基于 tenferro-runtime 0.8.0

列主序与视图

主线位置:直接计算主线的第一步,from_vec_col_major(vec![2, 2], data) 里那个 data 怎么解释。本页讲 tenferro 唯一的拥有型布局(紧凑列主序)、只改元数据的视图,以及行主序数据怎么正确地接进来。视图交给 CPU 引擎时转成 strided-rs 的 StridedView,那之后的遍历在 逐元素、融合与缓冲池。

flowchart TB
  rm["行主序 Vec"] -->|直接传| bad["from_vec_col_major"]
  bad --> wrong["值被重排 不报错"]
  rm -->|显式导入| rowc["from_vec_row_major"]
  rowc --> ok["紧凑列主序"]
  rm -->|带步长包装| view["TypedTensorView"]
  view -->|engine 复制| ok
  view -->|to_col_major| ok
  cm["列主序 Vec"] --> ok

本页的实验程序是 exp-tenferro8/tensor-exp 的 col_major 和 view_dot(tenferro 0.8.0,默认 native provider,另用 ndarray 0.16),release,M2 Max、macOS 27、rustc 1.99.0。

只有一种拥有型布局

形状 [d0, d1, d2] 的紧凑列主序步长是 [1, d0, d0·d1]:最左边的维变得最快。col_major_strides 就是这个累乘,每一步用 checked_mul,溢出返回 IntegerOverflow:

pub fn col_major_strides(shape: &[usize]) -> Result<StrideVec> {
    let mut strides = StrideVec::new();
    let mut stride = 1isize;
    for &extent in shape {
        strides.push(stride);
        let extent = isize::try_from(extent).map_err(|_| ValidationError::IntegerOverflow)?;
        stride = stride
            .checked_mul(extent)
            .ok_or(ValidationError::IntegerOverflow)?;
    }
    Ok(strides)
}

TypedTensor 和 Tensor 这些拥有型张量只有这一种布局:构造函数调用它算出步长、缓存在张量的 layout 字段里(数据模型讲过这 80 字节),偏移是 0,没有参数可以改。实测 [2, 3] 张量的 strides() 是 [1, 2]。任意步长、非零偏移、负步长只出现在视图(TypedTensorView)或单独的 TensorLayout 上。上游的设计文档 tensor.md 写的是「Owned runtime tensors are compact column-major」,用户指南 memory-order.md 也这样写,代码也是这样。

为什么是列主序?README 的定位写的是给「needs column-major storage, LAPACK/Fortran/Julia-friendly layouts」的 Rust 科学计算代码用:LAPACK 和 BLAS 按列存矩阵,Julia、Fortran、MATLAB 的数组也是;tenferro 的稠密线代走 faer(0.8.0 用的是分叉 t4a-faer),faer 的拥有型矩阵也是列主序。tenferro 的线代和 einsum 都约定批维放在右边(上游规格 tensor-semantics.md 第 VII 节:「Shape [M, N, B1, B2, ...] means B1*B2*... independent M x N matrices」),这在列主序下正好让每个批次的 [M, N] 矩阵占一段连续内存。实测,形状 [2, 2, 3] 的张量按第三维切出第 1 个批次,再 reshape_view 成 [2, 2]:

batch k=1: shape [2, 2] strides [1, 2] offset 4 compact true as_slice [4.0, 5.0, 6.0, 7.0] same buffer true
leading-axis slice: shape [1, 2, 3] strides [1, 2, 4] offset 1 compact false
reshape_view of leading-axis slice -> Err: TypedTensorView::reshape_view: view is not slice-contiguous; materialize with to_contiguous before requesting a borrowed slice

批次切片是紧凑的,as_slice 直接借出原缓冲区里偏移 4 开始的四个数;沿第一维切就不连续了,连 reshape_view 都不接受。PyTorch、JAX 习惯把批维放左边,移植时要换过来。

对照:candle 新建张量的布局是行主序,最后一维步长为 1(见 布局与视图);burn 的 Flex 后端 Layout::contiguous 也按行主序算步长(见 Flex)。

行主序数据:静默算错

README 的「Conventions you must know」第一条在 0.8.0 没变:「Row-major data passed to from_vec_col_major is silently reinterpreted as column-major — permuted/wrong values, never rejected.」构造函数只检查元素个数等于形状之积,它不可能知道这六个数原本是按行排的。

❗IMPORTANT
动脑筋:A = [[1,2,3],[4,5,6]],B = [[1,0],[0,1],[1,1]],A·B = [[4,5],[10,11]]。把两个矩阵的行主序缓冲区 [1,2,3,4,5,6] 和 [1,0,0,1,1,1] 原样交给 from_vec_col_major(vec![2,3], ..)、from_vec_col_major(vec![3,2], ..),再 matmul,得到什么?形状对得上,所以不会报错。先把两个缓冲区按「先填满第一列」排回矩阵。

0.8.0 给了一个显式的入口:from_vec_row_major。它在所有拥有型构造的地方都有:TypedTensor::from_vec_row_major(任意 R,默认 Dynamic)、Host 表示的 from_host_vec_row_major、动态的 Tensor::from_vec_row_major。三者都经同一个私有函数 row_major_reorder:按行主序步长逐个读、按列主序顺序推进一个新 Vec,然后交给列主序构造函数。所以它不会产生「行主序张量」,结果和别的张量一样是紧凑列主序,只是多了一次 O(元素数) 的复制:

TypedTensor::from_vec_row_major a shape [2, 3] buffer [1.0, 4.0, 2.0, 5.0, 3.0, 6.0] rows [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
from_vec_row_major copies: true
a_rm*b_rm shape [2, 2] buffer [4.0, 10.0, 5.0, 11.0] 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]
Rank<2> from_vec_row_major [[1,0]] = 4, [[0,2]] = 3
row-major len mismatch -> Err: from_vec_row_major: shape product 6 does not match data length 5

这修的是「有入口可用」,不是「误用会被发现」:from_vec_col_major 拿到行主序数据仍然静默算错(下面的动脑筋答案在 0.8.0 上重跑过,结果和 0.7.1 一样)。结果的缓冲区 [4, 10, 5, 11] 也是列主序;要按行打印,读的下标是 i + m·j。

📝NOTE
冷知识:行主序张量存在过四天,行主序构造函数回来了两次

PR #850(「Add owned contiguous memory order support」,2026-05-12)给拥有型张量加了一个运行期的 MemoryOrder 标签。四天后,PR #859 里的提交「refactor(tensor): remove row-major tensor surface」把它删掉;同一个 PR 加的决策文档写了理由:「The current runtime MemoryOrder tag is not represented in the type system」。之后 PR #877 加过一个只在边界上重排的 from_vec_row_major,2026-06-18 的清理提交 59741b35 又删了它,这些都在第一个发布 v0.1.0 之前。0.8.0 把重排入口加了回来,仍然只在边界上重排:TypedTensor 的版本随 PR #1944 进来,Tensor 的版本是 PR #2014 里的提交 0d9dca0b,说明写的正是这个坑:「the dtype-erased Tensor only offered from_vec_col_major, which silently reinterprets row-major data. Tensor::from_vec_row_major reorders once into column-major storage」。两个 PR 都首次进入 v0.8.0。

视图:只换布局

TypedTensorView 有五个字段:借来的存储引用 buffer(主机切片或后端缓冲区)、可选的分配组读视图 root、一个 TensorLayout<R>、一份 Placement,以及表示标记 D。它有 Clone,克隆只复制这些元数据。as_view() 从拥有型张量借出一个视图,之后的转置、切片、reshape 都只算新的布局。0.7.1 的 try_slice、try_slice_axis、try_reshape 在 0.8.0 改名为 slice_view、slice_axis_view、reshape_view,和 transpose_view 统一成 _view 后缀,旧名字没有留别名。

转置落到 core 的 TensorLayout::transpose_view:

    pub fn transpose_view(&self, axes: impl AsRef<[usize]>) -> Result<Self> {
        let axes = axes.as_ref();
        validate_permutation(self.shape().len(), axes)?;
        let shape = axes
            .iter()
            .map(|&axis| self.shape()[axis])
            .collect::<ShapeVec>();
        let strides = axes
            .iter()
            .map(|&axis| self.strides()[axis])
            .collect::<StrideVec>();
        Ok(Self {
            shape: R::shape_from_vec(shape)?,
            strides: R::strides_from_vec(strides)?,
            offset: self.offset,
        })
    }

它接受任意轴置换,名字叫 transpose,实际就是 permute;collect 进的是内联的 TinyVec,秩 8 以内不分配(数据模型里的计数分配器实测:as_view、transpose_view、slice_view、reshape_view 都是 0 次)。切片走 core 的 slice_view,用户侧的参数是 StridedSliceSpec(起点、可选的终点、步长,reverse() 是步长 −1);它把起点折算进偏移、步长乘进去,并用 validate_reachable_bounds 确认能走到的最小、最大偏移都在缓冲区里。对上面那个 [2, 3] 的 A:

transpose_view: shape [3, 2] strides [2, 1] offset 0 same buffer true compact false
reverse columns: shape [2, 3] strides [1, -2] offset 4 get([0,0]) = Some(3.0)
columns 1..: shape [2, 2] strides [1, 2] offset 2 compact true as_slice Ok([2.0, 5.0, 3.0, 6.0])
row 1: shape [1, 3] strides [1, 2] offset 1 compact false
  • 转置后步长 [2, 1],host_storage() 的指针和原张量是同一块内存。
  • 反转列:步长变成负的 −2,偏移移到最后一列的开头 4。tenferro 的步长是 isize,翻转不用复制。
  • 取后两列:仍是紧凑的列主序,只是偏移 2,as_slice 能直接借出那四个数。
  • 取一行:三个数在偏移 1、3、5,不连续。

不连续的视图不能当切片读,也不能用 duplicate() 复制。要变成紧凑张量,有三条路,都分配新内存:

transposed as_slice -> Err: TypedTensorView::as_slice: view is not slice-contiguous; materialize with to_contiguous before requesting a borrowed slice
transposed duplicate -> Err: TypedTensorView::as_slice: view is not slice-contiguous; materialize with to_contiguous before requesting a borrowed slice
transposed with_host_read -> Err: TypedTensorView::with_host_read: unsupported operation: host guard access requires a compact descriptor
to_col_major(transpose): buffer [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] new buffer true
engine.to_contiguous(transpose): shape [3, 2] buffer [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] new buffer true
engine transpose: buffer [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] new buffer true

duplicate 的实现是 self.as_slice()?.to_vec(),所以报的是 as_slice 的错。这是有意的:上游的 storage-ownership.md 写着非紧凑视图「is not silently downloaded or canonicalized by this host duplicate boundary」。三条路是:

  • to_col_major:0.8.0 新加在视图上的方法,不要引擎,逐个元素按列主序顺序读出来推进一个新 Vec。设计文档把它定为「Explicit sequential data-container copy for T: Clone, including noncompact views」,只对主机视图有效。
  • 引擎的 to_contiguous(TensorViewCanonicalization trait,CpuEngine 实现了它),下一节讲。
  • 引擎上的运算,例如 a.transpose(&[1, 0], &mut engine)。

接上 strided-rs

tenferro-tensor 自己不依赖 strided-rs:TensorLayout 和上面这些视图运算都在 tenferro-tensor-core 里。strided-rs 在 CPU 引擎里接进来。CpuEngine::to_contiguous 落到 typed_materialize_view_with_pool:

    if view.backend_buffer().is_some() {
        return Err(cpu_backend_buffer_error(op));
    }
    validate_cpu_host_placement(op, "source", view.placement())?;
    let src: StridedView<'_, T, Identity> = StridedView::new(
        view.host_storage()?,
        view.shape(),
        view.strides(),
        view.offset(),
    )
    .map_err(|err| crate::Error::backend_source(op, err))?;
    let mut out = PooledUninitOutput::<T>::new(buffers, view.shape())?;
    // INVARIANT: `copy_into_uninit` is the full-overwrite entry: it selects the
    // blocked permutation copy on native floats in a sequential context and
    // the element-wise map elsewhere, so the destination is written completely
    // before typed exposure either way.
    copy_into_uninit(&mut out.as_uninit_view_mut()?, &src, exec)
        .map_err(|err| crate::Error::backend_source(op, err))?;
    // SAFETY: the successful copy replay writes every logical destination element.
    let mut out = unsafe { out.assume_init_as_recycled::<R>()? };

视图的形状、步长、偏移原样搬进 strided-view 的 StridedView(经 strided-kernel 转出),输出是缓冲池里一块未初始化的内存。strided-basic 的 copy_into_uninit 按它的文档注释选路:f32/f64 的非连续复制走 strided-perm 的分块置换引擎,其余逐元素映射,exec 是 threadteam 的执行令牌,决定能不能并行。写满之后才 assume_init_as_recycled,这个「先证明全写满、再当作已初始化」以及「输出 drop 时回池」的约定在 存储与所有权 里讲。

strided-view 的模块文档自称「matching Julia's StridedViews.jl data model」。它另有一个类型参数 Op 表示读元素时惰性施加的运算(Identity、Conj、Transpose、Adjoint),所以它自己的 permute、conj 也不复制;上面这段 tenferro 代码写明了 Identity,即读出原值。0.8.0 用的 strided-view 0.7.1 把形状、步长从 0.4 时的 Arc<[_]> 换成了 tinyvec::ArrayVec,模块文档说「every constructor, clone, and metadata transform allocates nothing up to that rank」,和 tenferro 这边的 TinyVec 一致。

版本:tenferro 0.8.0 的工作区 Cargo.toml 用普通的 crates.io 版本要求(strided-view = "0.7.0"、strided-basic/-kernel "0.9.0"),不再用 git rev 锁;本页的实验解析到 strided-view 0.7.1、strided-basic / strided-kernel 0.9.0、strided-perm 0.9.1。

桥接行主序数据

正确的做法有四种,都实测过,结果都是 [[1,2,3],[4,5,6]]、列主序缓冲区 [1,4,2,5,3,6]。

一、from_vec_row_major。 上面讲过,一次重排。

二、带步长的视图包进来,再复制。 行主序的 [m, n] 矩阵在列主序的眼里就是步长 [n, 1] 的视图。TypedTensorView::from_slice 接受任意形状、步长、偏移和一个借来的切片:

let a_view = TypedTensorView::from_slice([2, 3], [3, 1], 0, &a_row)?;
let b_view = TypedTensorView::from_slice([3, 2], [2, 1], 0, &b_row)?;
let a_fix = engine.to_contiguous(&a_view)?;
let b_fix = engine.to_contiguous(&b_view)?;
let c_fix = a_fix.matmul(&b_fix, &mut engine)?;
a_view strides [3, 1] get([0,1]) = Some(2.0)
a_fix shape [2, 3] buffer [1.0, 4.0, 2.0, 5.0, 3.0, 6.0] rows [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
a_fix*b_fix shape [2, 2] buffer [4.0, 10.0, 5.0, 11.0] rows [[4.0, 5.0], [10.0, 11.0]]

包装本身不复制,get([0, 1]) 已经按行主序读出 2;to_contiguous(或不要引擎的 to_col_major)复制一次。

三、当成转置。 行主序的 m×n 缓冲区,逐字节就是列主序的 n×m(它的转置)。先 from_vec_col_major(vec![3, 2], a_row),再 transpose(&[1, 0], &mut engine),同样复制一次。

四、ndarray。 ndarray 的步长也以元素计、带符号,可以原样交给 from_slice:

let nd_c = Array2::from_shape_vec((2, 3), a_row.clone())?;
let nd_strides: Vec<isize> = nd_c.strides().to_vec();
let nd_view = TypedTensorView::from_slice(
  nd_c.shape(), &nd_strides, 0, nd_c.as_slice_memory_order().ok_or("no slice")?)?;
ndarray C strides [3, 1] -> view compact false
ndarray F strides [1, 2] -> view compact true as_slice same buffer true
ndarray C .t().iter() -> [1.0, 4.0, 2.0, 5.0, 3.0, 6.0]

默认(C 序)的 ndarray 数组步长是 [3, 1],包出来的视图不紧凑,要复制;用 (2, 3).f() 建的 Fortran 序数组步长 [1, 2],包出来就是紧凑列主序,as_slice 借出的就是 ndarray 自己的内存,一次复制都没有。只想要一个列主序 Vec 的话,a.t().iter() 按转置的逻辑顺序走一遍正好是 a 的列主序。

这几种复制贵不贵?n×n 的 f64,一个按行主序步长 [n, 1] 包起来的视图(from_vec_row_major 用同样的数据),每项 7 次取最小值,单线程(CpuEngine::serial())和 8 线程(Pool::builder().threads(8))各跑三轮(负载 2.6 左右):

n线程engine.to_contiguousview.to_col_majorfrom_vec_row_major紧凑张量 matmulto_contiguous 加 matmul
25610.020 到 0.029 ms0.35 到 0.40 ms0.17 到 0.19 ms0.72 到 0.82 ms0.74 到 0.84 ms
25680.010 ms0.37 ms0.18 ms0.12 ms0.13 到 0.14 ms
102410.96 到 1.09 ms6.5 到 6.6 ms3.5 到 3.6 ms43.1 到 44.3 ms44.5 到 45.7 ms
102480.17 ms6.5 到 6.7 ms3.6 ms6.8 ms6.9 到 7.0 ms
  • 引擎的复制最快:1024²(8 MB)单线程约 1 ms,8 线程 0.17 ms,是同尺寸 matmul 的 2% 到 3%。它走 strided-perm 的分块置换,还能用池里的缓冲区。
  • from_vec_row_major 和 to_col_major 都是单线程的逐元素循环,不随引擎线程变。1024² 时 from_vec_row_major 要 3.5 ms,是单线程引擎复制的 3 倍多、8 线程的 20 倍,在 8 线程下已经是一次 matmul 的一半;to_col_major 每个元素都要算一次偏移,又慢一倍。它们的好处是不需要引擎;数据大、又已经有引擎时,包成视图再 engine.to_contiguous 更划算。
📝NOTE
没有蠢问题

问: README 说「strided views bridge row-major data without eager copies」,那为什么上面每种做法都要复制一次?

答: 包装确实不复制,复制发生在把视图交给公开运算的时候。直接计算主线上公开的运算方法(TypedTensorSessionOpsExt::matmul 等)收的是 &TypedTensor,只有拥有型紧凑张量能进去。接受视图的是 _read 系列,0.8.0 里 TensorDot::dot_general_read 仍标着 #[doc(hidden)],CpuEngine 实现了它。实测把两个步长 [n, 1] 的视图直接传进去,结果正确(2×3 的例子得到 [4.0, 10.0, 5.0, 11.0]);1024×1024 单线程两轮是 43.1 和 42.3 ms,同一轮里「to_contiguous 加 matmul」是 43.4 和 43.3 ms;8 线程是 6.4、6.6 ms 对 6.6、6.5 ms(各 7 次取最小)。引擎复制已经便宜到和测量噪声同量级,绕开它几乎省不下什么。上游的 ndarray 对照页 的建议仍是「kernels are tuned for column-major contiguity, so materialize a copy when performance matters and the wrapped buffer is row-major」。所以「without eager copies」指的是包装时不复制。

动脑筋答案

a_bad shape [2, 3] buffer [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] rows [[1.0, 3.0, 5.0], [2.0, 4.0, 6.0]]
b_bad shape [3, 2] buffer [1.0, 0.0, 0.0, 1.0, 1.0, 1.0] rows [[1.0, 1.0], [0.0, 1.0], [0.0, 1.0]]
a_bad*b_bad shape [2, 2] buffer [1.0, 2.0, 9.0, 12.0] rows [[1.0, 9.0], [2.0, 12.0]]
  • 按列填:A 变成 [[1,3,5],[2,4,6]],B 变成 [[1,1],[0,1],[0,1]]。这两个矩阵都不是原来那个的转置,是按另一种顺序重排的结果。
  • 乘出来 [[1,9],[2,12]],正确答案是 [[4,5],[10,11]],四个数没有一个对。整个过程没有任何错误或警告;换成 from_vec_row_major 就是正确答案。
  • 方阵的情况更隐蔽:行主序的方阵被读成它自己的转置,乘出来的是 Aᵀ·Bᵀ = (B·A)ᵀ,形状永远对得上,数值看上去也「像回事」。

小结

  • 拥有型张量只有紧凑列主序一种布局,步长 [1, d0, d0·d1, ...] 构造时算好缓存,偏移 0;批维放右边,每个批次是一段连续内存。
  • 行主序缓冲区交给 from_vec_col_major 仍不会被拒绝,matmul 结果静默错误;0.8.0 的 from_vec_row_major 在 TypedTensor(各种 R、Host)和 Tensor 上都有,重排一次,结果仍是列主序。
  • 视图只换布局:transpose_view(实为任意置换)、slice_view、slice_axis_view、reshape_view(0.7.1 的 try_* 改名而来)、负步长反转都不复制、不分配;非紧凑视图不能 as_slice 或 duplicate,要用 to_col_major、引擎的 to_contiguous 或引擎上的 transpose。
  • tenferro-tensor 不依赖 strided-rs;CPU 引擎把视图交给 strided-view 的 StridedView,用 strided-basic 的 copy_into_uninit 写进未初始化的池缓冲区。
  • 1024² f64 的复制:引擎单线程约 1 ms、8 线程 0.17 ms;不要引擎的 from_vec_row_major 约 3.5 ms、to_col_major 约 6.5 ms,都是单线程。Fortran 序的 ndarray 可以零复制借用。

💬 关联讨论

发起讨论
加载中...