跳到主要内容

张量与 Rust 类型相互转换

该文档的一部分内容已经在前面两节 (张量创建张量解构与所有权) 中有所说明。特别是 张量结构与所有权,其内容倾向于通过一些代码,解释 RSTSR 的特性。

用户在使用张量库时,很多时候需要与其他类型 (包括 Vec<T>, &[T] 或 Faer 等其他线性代数库) 作交互。这一节从程序使用的角度,尽可能系统阐述 RSTSR 张量与其他 Rust 类型的相互转换可以怎样实现。

该文档仅针对 CPU 后端成立。目前尚未实现其他类型后端;对于未来的 RSTSR,下述文档不一定适用于其他类型后端。

1. 与 Vec<T> 的相互转换

1.1 From Vec<T>:asarray

RSTSR 的张量 Tensor<T, B, D> 可以通过 rt::asarray 函数,读入向量原始数据、维度、设备等信息给出。rt::asarray 函数具有多种重载,我们未来会在 API 文档中作详细说明。

下述程序可以将原始数据储存为 (2, 3) 维度的、在 16 核并行的 OpenBLAS 设备下的张量;需要注意,该程序在 row-major 与 col-major 下有不同的行为:

let device = DeviceOpenBLAS::new(16);
let vec = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let tensor = rt::asarray((vec, [2, 3], &device));
println!("{tensor:8.4}");
// output (row-major):
// [[ 1.0000 2.0000 3.0000]
// [ 4.0000 5.0000 6.0000]]
// output (col-major):
// [[ 1.0000 3.0000 5.0000]
// [ 2.0000 4.0000 6.0000]]

1.2 From Vec<T>:从头构建

如前一节所述,RSTSR 的张量实际上具有多层结构。rt::asarray 函数尽管非常直观,但它掩盖了构造 RSTSR 张量的具体过程。

下述程序展示了 RSTSR 实际上是如何一步一步地,从基础的 Vec<T> 数据存储单元,构建完整的张量的具体过程。

use rstsr_core::prelude_dev::*;

// step 1: wrap vector into data representation
let vec = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let data: DataOwned<Vec<f64>> = DataOwned::from(vec);

// step 2: construct device
let device: DeviceOpenBLAS = DeviceOpenBLAS::new(16);

// step 3: construct storage that pinned to device
let storage: Storage<DataOwned<Vec<f64>>, f64, DeviceOpenBLAS> = Storage::new(data, device);

// step 4: construct layout (row-major case that last stride is 1)
// this will give 2-D layout with dynamic shape
// arguments: ([nrow, ncol], [stride_row, stride_col], offset)
let layout = Layout::new(vec![2, 3], vec![3, 1], 0).unwrap();
// if you insist to use static shape, you can use:
// let layout = Layout::new([2, 3], [3, 1], 0).unwrap();

// step 5: construct tensor
let tensor = Tensor::new(storage, layout);

println!("{tensor:8.4}");
// output:
// [[ 1.0000 2.0000 3.0000]
// [ 4.0000 5.0000 6.0000]]

1.3 Into Vec<T>:into_vec 函数

对于 CPU 后端,该函数可以将 1-D 张量存储为 Vec<T> 向量。

请留意,该函数有如下副作用

  • 该函数禁止处理 2-D 等高维张量转为向量:

    println!("{:?}", tensor.shape());
    // output: [2, 3]
    let vec = tensor.into_vec();
    println!("{:?}", vec);
    does_not_compile

    如果您确实希望将 2-D 等高维张量转为向量,您首先需要先进行 into_shapeinto_contig 以转为 1-D 张量。

  • 对于 Tensor<T, B, D> 类型,即占有数据的张量类型,该函数通常不会复制数据,即几乎没有运行代价;

    // create tensor
    let vec_raw = vec![1, 2, 3, 4, 5, 6];
    let ptr_raw = vec_raw.as_ptr();
    let tensor = rt::asarray(vec_raw);

    // convert tensor to vector
    let vec_out = tensor.into_vec();
    let ptr_out = vec_out.as_ptr();
    println!("{vec_out:?}");

    // data is moved and no copy occurs
    assert_eq!(ptr_raw, ptr_out);

    但如果 offset 非零、stride 非一、底层数据长度与维度信息不相等,那么仍然会复制数据:

    // create tensor with stride -1
    // by flip the tensor along the 0-th axis
    let vec_raw = vec![1, 2, 3, 4, 5, 6];
    let ptr_raw = vec_raw.as_ptr();
    let tensor = rt::asarray(vec_raw).into_flip(0);
    println!("{tensor:?}");
    // output: [6, 5, 4, 3, 2, 1]

    // convert tensor to vector
    let vec_out = tensor.into_vec();
    let ptr_out = vec_out.as_ptr();
    println!("{vec_out:?}");
    // output: [6, 5, 4, 3, 2, 1]

    // data is cloned, so this `into_vec` is expensive
    assert_ne!(ptr_raw, ptr_out);
  • 对于引用类型 (如 TensorView<'_, T, B, D>),该函数会复制数据。

1.4 Into Vec<T>:自顶解构

这部分讨论仅针对 Tensor<T, B, D> 即占有数据的张量类型。

RSTSR 的张量可以以 Vec<T> 为起点从头构建,也可以从 Tensor<T, B, D> 自顶解构。解构张量需要运行两次 into_raw_parts 与一次 into_raw

// construct tensor by asarray
let device = DeviceOpenBLAS::new(16);
let vec = vec![1, 2, 3, 4, 5, 6];
let tensor = rt::asarray((vec, [2, 3], &device));

// step 1: tensor -> (storage, layout)
let (storage, layout) = tensor.into_raw_parts();
println!("{layout:?}");

// step 2: storage -> (data, device)
let (data, device) = storage.into_raw_parts();
println!("{device:?}");

// step 3: data -> raw, where DeviceOpenBLAS::Raw = Vec<T>
let vec = data.into_raw();
println!("{vec:?}");
// output: [1, 2, 3, 4, 5, 6]

但需要指出,该函数也有副作用。该函数仅仅返回其底层储存数据所用的向量,而不关心向量是以怎样的 layout 储存的。对于任意维度的 (包括 2-D 等高维度的) 张量,这种 into_raw_parts 也可以分解出 Vec<T> 的数据;但这段数据与 into_vec 函数所给出的数据未必是一致的。这可以通过 stride 不为 1 的张量作为例子展示:

let vec_raw = vec![1, 2, 3, 4, 5, 6];
let ptr_raw = vec_raw.as_ptr();
let tensor = rt::asarray(vec_raw).into_flip(0);

// step 1: tensor -> (storage, layout)
let (storage, layout) = tensor.into_raw_parts();
println!("{layout:?}");
// output:
// 1-Dim (dyn), contiguous: Custom
// shape: [6], stride: [-1], offset: 5

// step 2: storage -> (data, device)
let (data, device) = storage.into_raw_parts();
println!("{device:?}");

// step 3: data -> raw, where DeviceOpenBLAS::Raw = Vec<T>
// in this way, original `vec` will be returned
let vec_out = data.into_raw();
let ptr_out = vec_out.as_ptr();
assert_eq!(ptr_raw, ptr_out);
println!("{vec_out:?}");
// output: [1, 2, 3, 4, 5, 6]

// please note that, `tensor.into_vec` will give
// output: [6, 5, 4, 3, 2, 1]

因此,如果要通过自顶解构的方式得到 Vec<T>,用户需要自行保证该张量或向量的 layout 也是符合预期的。

1.5 To Vec<T>:to_vec 函数

该函数与 into_vec 函数基本一致,包括其使用方式与副作用。该函数不破坏传入的张量,但必然会复制内存。

2. 与 &[T]/&mut [T] 或指针类型的相互转换

在 Rust 中,&[T] (或 &mut [T]) 与指针类型是非常相似的:&[T] 相对于指针多一个长度的保证。因此,在具有 Rust 下 *const Tusize 的长度的信息的情况下,其处理的思路与 &[T] 是完全一致的。

2.1 From &[T]:asarray

Vec<T> 类似地,RSTSR 的张量视窗 TensorView<'_, T, B, D> 可以通过 rt::asarray 函数给出。但与 Vec<T> 不同地,它返回的是张量视窗 TensorView<'_, T, B, D> 而非占有数据的张量本身 Tensor<'_, T, B, D>。需要注意,该程序在 row-major 与 col-major 下有不同的行为:

let device = DeviceOpenBLAS::new(16);
let vec = vec![1, 2, 3, 4, 5, 6];
let tensor = rt::asarray((&vec, [2, 3], &device));
println!("{tensor}");
// output (row-major):
// [[ 1 2 3]
// [ 4 5 6]]
// output (col-major):
// [[ 1 3 5]
// [ 2 4 6]]

同样地,&mut [T] 通过类似程序可以给出可变视窗 TensorMut<'_, T, B, D>

2.2 From &[T]:从头构建

这里的思路与 Vec<T> 从头构建是一致的。但需要注意,RSTSR 的 CPU 后端在面对引用类型时,始终是 &Vec<T> 而非 &[T] 进行处理1。因此,在 RSTSR 中,我们要求首先自 &[T] 类型构建 Vec<T>;该向量将不自动进行析构、且有声明周期标注。具体来说,

use rstsr_core::prelude_dev::*;
use std::mem::ManuallyDrop;

let vec = vec![1, 2, 3, 4, 5, 6];
let vec_ref: &[usize] = &vec;

// step 1: wrap reference into data representation
// this uses `ManuallyDrop` to avoid double free
let vec_manual_drop: ManuallyDrop<Vec<usize>> = ManuallyDrop::new(unsafe {
Vec::from_raw_parts(vec_ref.as_ptr() as *mut _, vec_ref.len(), vec_ref.len())
});
let data: DataRef<Vec<usize>> = DataRef::ManuallyDropOwned(vec_manual_drop);

// step 2: construct device
let device: DeviceOpenBLAS = DeviceOpenBLAS::new(16);

// step 3: construct storage that pinned to device
let storage: Storage<DataRef<Vec<usize>>, usize, DeviceOpenBLAS> = Storage::new(data, device);

// step 4: construct layout (row-major case that last stride is 1)
// this will give 2-D layout with dynamic shape
// arguments: ([nrow, ncol], [stride_row, stride_col], offset)
let layout = Layout::new(vec![2, 3], vec![3, 1], 0).unwrap();
// if you insist to use static shape, you can use:
// let layout = Layout::new([2, 3], [3, 1], 0).unwrap();

// step 5: construct tensor
let tensor = TensorView::new(storage, layout);

println!("{tensor}");
// output:
// [[ 1 2 3]
// [ 4 5 6]]

同样地,&mut [T] 通过类似过程可以给出可变视窗 TensorMut<'_, T, B, D>

2.3 To &Vec<T>:raw 函数

我们可以返回底层数据所对应的引用:

let vec = vec![1, 2, 3, 4, 5, 6];
let tensor = rt::asarray((vec, [2, 3]));
println!("{tensor}");
// output (row-major):
// [[ 1 2 3]
// [ 4 5 6]]

let slc: &Vec<usize> = tensor.raw();
println!("{slc:?}");
// output: [1, 2, 3, 4, 5, 6]

这实际上与自顶解构得到 Vec<T> 是一样的,只是我们只需要获得引用而不需要解构张量,因此可以有一个更为简单的函数 raw 来实现这一点。

同样地,对于占有数据的张量 Tensor<T, B, D>、或可变视窗 TensorMut<'_, T, B, D>,通过函数 raw_mut 可以得到可变引用 &mut Vec<T>

同时,该函数也具有副作用

RSTSR 不检查 raw 函数导出的 &Vec<T> 是否符合 layout

这一点与自顶解构得到 Vec<T> 是一样的。RSTSR 仅仅返回数据的引用;至于它是否符合 layout 的规则 (譬如 c/f-contiguous),其引用的第一个元素是否就指代了张量对应的第一个元素,则需要由用户去保证。

从这个角度出发,使用 raw 函数是有风险的;但它不涉及内存安全,因此该函数没有被视为 unsafe。但用户在使用 raw 函数时,仍然需要格外仔细。

一种非常典型的调用错误 (库开发者自己出现过的失误) 是,用户没有合理地在指针上增加 offset。我们从下述 Cholesky 分解问题作为例子,解释这一情况。假设我们有如下 3×33 \times 3 f-contiguous 矩阵:

// prepare tensor
let vec: Vec<f64> = vec![1.0, 0.5, 2.0, 0.5, 5.0, 1.5, 2.0, 1.5, 8.0];
let device = DeviceFaer::default();
let tensor = rt::asarray((vec.clone(), [3, 3].f(), &device));
println!("{tensor:8.4}");
// output:
// [[ 1.0000 0.5000 2.0000]
// [ 0.5000 5.0000 1.5000]
// [ 2.0000 1.5000 8.0000]]

如果我们想对右下角 2×22 \times 2 部分,给出其下三角 Cholesky 分解,则在 RSTSR 中的标准做法是

// standard way to perform Cholesky by RSTSR
let sub_mat = tensor.i((1..3, 1..3));
// [[ 5.0000 1.5000]
// [ 1.5000 8.0000]]
let sub_chol = rt::linalg::cholesky((&sub_mat, Lower));
println!("{sub_chol:8.4}");
// [[ 2.2361 0.0000]
// [ 0.6708 2.7477]]

假设我们出于其他原因,需要通过其他 Rust 类型,传到 crate lapack 中作 Cholesky 分解。这件事在 RSTSR 中,通过 raw_mut 函数也是容易做到的;但由于没有向 raw_mut 生成的切片增加合理的 offset,下述调用过程是错误的!

// wrong way to perform Cholesky by LAPACK
let mut tensor = rt::asarray((vec.clone(), [3, 3].f(), &device));
let mut sub_mat = tensor.i_mut((1..3, 1..3));
let mut info = 0;
unsafe { lapack::dpotrf(b'L', 2, sub_mat.raw_mut(), 3, &mut info) };
println!("{sub_mat:8.4}");
// This is not what we want! The `sub_mat.raw_mut()` points to 1.0 instead of 5.0!
// It actually diagonalizes tensor[0:2, 0:2] instead of tensor[1:3, 1:3]!
// [[ 2.1794 1.5000]
// [ 1.5000 8.0000]]
not_desired_behavior

正确的做法需要向 raw_mut 后增加 offset,以保证传入 FFI 的指针指向了 sub_mat 的第一个元素:

// correct way to perform Cholesky by LAPACK
let mut tensor = rt::asarray((vec.clone(), [3, 3].f(), &device));
let mut sub_mat = tensor.i_mut((1..3, 1..3));
let offset = sub_mat.offset(); // offset is 4
let mut info = 0;
// notice that we need to add offset to the pointer
unsafe { lapack::dpotrf(b'L', 2, &mut sub_mat.raw_mut()[offset..], 3, &mut info) };
println!("{sub_mat:8.4}");
// [[ 2.2361 1.5000] upper-triangular does not matter
// [ 0.6708 2.7477]]

3. 与 Faer 类型的相互转换

目前,RSTSR 也支持与少部分其他 Rust 类型作相互转换。

对于 Faer 的 MatRefMatMut,RSTSR 支持相互转换。以 MatRef 为例,由于其是引用类型,因此整个过程不涉及内存复制:

let vec: Vec<i32> = vec![1, 2, 3, 4, 5, 6];
let device = DeviceFaer::default();
let tensor = rt::asarray((vec, [2, 3], &device));
println!("{tensor}");
// [[ 1 2 3]
// [ 4 5 6]]

// convert to faer tensor
use faer_ext::IntoFaer;
let faer_tensor = tensor.view().into_dim::<Ix2>().into_faer();
println!("{faer_tensor:?}");
// [
// [1, 2, 3],
// [4, 5, 6],
// ]

// convert back to rstsr tensor
use rstsr_core::tensor::ext_conversion::IntoRSTSR;
let rstsr_tensor = faer_tensor.into_rstsr();
println!("{rstsr_tensor}");
// [[ 1 2 3]
// [ 4 5 6]]

Footnotes

  1. RSTSR 对引用类型的存储,与目前绝大多数矩阵或张量库不同。高级用户可能会对下述讨论感兴趣。

    关于张量视窗类型的底层存储

    RSTSR 采用比较简单的方式对占有与引用类型作储存:

    pub struct DataOwned<C> {
    pub(crate) raw: C,
    }

    pub enum DataRef<'a, C> {
    TrueRef(&'a C),
    ManuallyDropOwned(ManuallyDrop<C>),
    }

    其中,对于 CPU 后端,上述泛型参数 C 一般指代 Vec<T>。这么做的方便之处,在于生命周期的定义清晰,所有一切都可以用 Vec<T> 描述,对于库开发非常便利。

    但从定义上,引用类型不应该是 &Vec<T>,而应该是 &[T] (同时参考 clippy ptr_arg)。指针 *const T、长度、生命周期也可以共同表示对一段内存的引用 &[T]。包括 ndarray、Faer、nalgebra 在内的绝大多数矩阵与张量库是通过这种方式定义引用类型的。

    很难说对于这两种方法,哪一种更好。但考虑到 RSTSR 的后端未必是 CPU,底层数据可能是硬盘或 GPU 中储存;而硬盘或 GPU 的引用类型未必可以用 &[T] 或指针描述。因此,RSTSR 目前采用 &Vec<T> 表示引用类型。这么做的副作用是当用户只有 &[T] 而没有对应的 Vec<T> 的数据时,用户必须要先将 &[T] 通过 ManuallyDrop 的方式手动泄漏内存 (以避免 double free),构建不会析构的 Vec<T> 类型,随后再对其引用。

    rt::asarray 作为高级函数,将 &[T] 通过 ManuallyDrop 转为 Vec<T> 的过程封装了起来。这对一般的数据类型 (如 f64, Complex<f64>) 一般不会有什么影响;但对于具有析构过程的类型,用户在使用 RSTSR 时可能需要多作留意。