Rust in Everything
基于 tenferro-runtime 0.8.0

存储与所有权

主线位置:直接计算主线的两头。开头 from_vec_col_major 把一个 Vec 交给张量,结尾 product.host_data()? 把结果读回来;中间 matmul 的输出缓冲从哪来、用完去哪。本页讲张量的两种持有方式(直接持有 Vec,或交给 tenferro-tensor/src/storage/ 的分配组),什么时候共享、什么时候复制,输出缓冲怎么回池,设备侧的分配怎么标识自己。引擎和缓冲池本身在 engine 与线程池 和 逐元素、融合与缓冲池。

flowchart TB
  typed["TypedTensor"] -->|Host 支| hd["HostData"]
  hd --> vec["Vec T"]
  hd --> rec["Weak 回收器"]
  typed -->|Group 支| otg["OwnedTensorGroup"]
  otg --> group["AllocationGroup"]
  group --> alloc["OwnedStorage"]
  group --> descs["DescriptorRecord"]
  alloc --> pin["RootResourcePin"]
  pin -->|HostF64 等| host["HostAllocation"]
  pin -->|Backend| be["BackendAllocation"]
  descs -->|allocation| alloc

本页的实验程序是 exp-tenferro8/tensor-exp 的 storage(tenferro 0.8.0,默认 native provider,引擎一律 CpuEngine::serial()),release,M2 Max、macOS 27、rustc 1.99.0。本机没有 CUDA,设备侧的存储只读源码。

普通张量不带分配组

0.7.1 的每个张量都内联了一个 AllocationGroup。0.8.0 不再这样:数据模型讲过,TypedTensor 的存储由表示参数 D 决定,默认 from_vec_col_major 造出来的张量走 Host 那一支,存储就是一个 HostData:

struct HostData<T> {
    buffer: StorageBuffer<T>,
    recycler: Option<std::sync::Weak<dyn crate::HostBufferRecycler<T>>>,
}

buffer 在这一支里永远是 StorageBuffer::Host(Vec<T>);recycler 是指向某个缓冲池的弱引用,用户自己建的张量没有,CPU 引擎的输出有(后面讲)。设计文档 tensor-session-redesign-1938.md 的 D2 是这样定的:「For a plain host tensor, adopt a Vec<T> directly; no group, Arc or extra metadata box is required」。实测:

from_vec_col_major keeps Vec buffer: true
host tensor allocation_domain = None, allocation_id = None, placement = Placement { memory_kind: UnpinnedHost, device: None, cpu_affinity: None }
as_view shares buffer: true
duplicate new buffer: true
map_read on a host tensor = [1.0, 2.0, 3.0, 4.0]
with_host_read sum = 10
into_vec_col_major shape [2, 2] same Vec buffer: true

from_vec_col_major 之后 host_data() 的指针就是原来 Vec 的指针,into_vec_col_major 交回来的还是同一个 Vec(HostData::into_vec 先清掉回收器再取出)。进出都不复制。

主机张量没有分配编号。allocation_domain 和 allocation_id 对 Host 那一支直接返回 None,不经过任何查表。在 CPU 上判断两个张量是否共用内存,只能比 host_data() 的指针。

读回有三条路:

  • host_data():Host 那一支直接借出 Vec 的切片;分配组那一支用构造时缓存的主机地址和字节长度拼一个切片(OwnedTensorGroup 的 host_slice),没有主机地址就报「backend storage cannot be borrowed as host data; download explicitly first」,不会偷偷下载。
  • map_read():0.8.0 新加的公开方法,返回一个 HostReadGuard。Host 表示上它不会失败;Dynamic 上返回 Result,分配组那一支走的也是上面那个缓存地址。
  • with_host_read(|s| ..)(#[doc(hidden)],上游教程在用):先要求视图是紧凑列主序,再请后端缓冲区的 map_read 或分配组的 prepare_host_read 把字节映射出来。设备侧的张量能不能这样读,由 provider 决定,最后一节有实测。

分配组:一个所有者,若干描述符

分配组在三种情况下出现:张量被收进 AllocationGroup(runtime 执行编译好的程序、eager 记录值时都这样做);显式 promote() 成 Gpu 表示;或者张量来自一个后端缓冲区(from_buffer_col_major)。storage/ 目录不含 storage/tests/ 共 4849 行:root.rs(所有者和内存根)、group.rs(分配组)、prepared.rs(校验过的访问)、identity.rs、span.rs、diagnostics.rs、mod.rs。模块是私有的,对外只转出 AllocationGroup、DescriptorSlot、GroupError,另有几个 #[doc(hidden)] 的类型给设备 provider 用。

AllocationGroup 只有两张表:

pub struct AllocationGroup {
    // Most public tensors contain one root and one descriptor. Keep that
    // common case inline so the ownership boundary does not add a per-result
    // metadata allocation to CPU hot paths; the vectors still grow for
    // explicit multi-descriptor groups.
    allocations: TinyVec<[Option<OwnedStorage>; 1]>,
    descriptors: TinyVec<[Option<DescriptorRecord>; 1]>,
}
  • allocations 里每一项是一个 OwnedStorage:一个内存根,加一个对这块内存字节范围的唯一凭证。两者都不能克隆,这就是契约文档说的「one-owner rule」。
  • descriptors 里每一项是一个 DescriptorRecord:指向哪个所有者、dtype、校验过的布局和字节范围、provider 种类、Placement。描述符不拥有内存,多个描述符可以指向同一个所有者。
  • DescriptorSlot 只是 descriptors 的下标(u32),可以 Copy,但不带任何权限:只有借着这个组去查,它才有意义。

内存根 RootResourcePin 是一个八变体的枚举:七个主机变体(HostF32 到 HostC64,各装一个 HostAllocation<T>)内联,第八个 Backend(Box<dyn BackendAllocation>) 装设备 provider 的分配。BackendAllocation 是 unsafe trait:实现它就是向内核保证 map_read / map_write 返回的字节确实属于这块分配。

主机 Vec 收进组时,import_host_vec 给它发一个新的分配键:AllocationDomainId::fresh()(全局原子计数器)加一个本地编号。所以每个收进组的主机 Vec 自成一个「分配域」;不进组的普通张量不领编号。但组的 backend_identity 遇到 CPU provider 的描述符一律返回 None,这个键对外看不见。

上游为这套写了一份两千行的契约文档 storage-ownership-contracts.md。它仍九处提到 Arc<RootResource>,而 v0.8.0 的 storage/ 里没有这个类型:RootResourcePin 是上面那个枚举,所有者直接持有它。文档里描述普通张量也带分配组的部分,同样已被 #1938 的设计记录取代。

💡TIP
打个比方:仓库、货物和提货单

AllocationGroup 是一间仓库。每批货物(OwnedStorage)只有一张产权证,不能复印。货架上贴的标签是描述符(DescriptorRecord):这批货按什么规格(dtype)、怎么摆(布局)、放在哪个仓区(Placement)去读。标签的编号(DescriptorSlot)只在这间仓库里有效。来看货的人(视图)只能看,同时可以有很多人;要把一批货连同产权证运走(take_tensor),得先确认没有别的标签还指着它。0.8.0 的变化是:零散的小件(普通主机张量)不再进仓库,自己拎着走;只有需要多方登记的货才入库。

比方到此为止:真实仓库里撕掉的标签位置会贴新标签,AllocationGroup 的描述符槽位只追加、从不复用。

实测把两个普通张量收进一个组,再取出来:

group = AllocationGroup { allocation_count: 2, descriptor_count: 2 }, slots = [DescriptorSlot(0), DescriptorSlot(1)]
read_views shapes = [[3], [2]]
take_tensor same buffer: true
taken tensor into_host -> Err: TypedTensor::into_host: runtime state failure: the tensor is group-backed rather than plain host storage
group after take = AllocationGroup { allocation_count: 2, descriptor_count: 2 }
second take -> Err: descriptor slot 0 is vacant

from_tensors 把每个张量的 Vec 移进组,不复制。take_tensor 只在这个所有者没有别的描述符指着时成功,取走后两张表的那一格都变成 None,计数不变,槽位不复用,同一个槽再取一次报 vacant。取出来的张量指针没变,但已经是分配组那一支(into_host 失败):进组是单向的,出来以后它带着一个只有一项的组。

把张量拆进组时,私有的 into_group_parts 有一行特别的代码:

        let (mut group, slot) = group.into_parts();
        group.publish_live_descriptor_placement(slot, placement);
        (group, slot)
📝NOTE
冷知识:设好的 Placement 曾经在组里走一圈就丢了

Placement 有两份:张量自己的 placement 字段(set_placement 写、placement() 读)和描述符里的那份。#1824(「fix(tensor): publish tensor placement before the allocation-group handover」,首次进入 v0.6.0)的提交说明讲了问题:描述符的那份「is seeded with the default placement and only written by the backend-buffer constructors」,交出组时丢掉了张量那份,take_tensor / read_view 又从描述符重建张量,「so a placement set on a tensor was lost across the round trip」。修复就是上面那行 publish_live_descriptor_placement。0.8.0 重写了张量的结构,两份副本的设计还在,这一行也还在。

什么共享,什么复制

操作内存依据
from_vec_col_major、into_vec_col_major移交同一个 Vec实测指针相同
as_view、transpose_view、slice_view、reshape_view借用,不复制实测指针相同(见 列主序与视图)
into_dynamic、into_host、promote、Tensor::from_typed、into_typed、try_into_rank搬移,不复制实测指针相同
AllocationGroup::from_tensors、take_tensor搬移所有者实测指针相同
.clone()只有 Host 表示有,深拷贝Dynamic 没有 Clone
duplicate()新分配并复制实测指针不同;视图上只接受紧凑视图
from_vec_row_major、视图的 to_col_major新分配并重排单线程逐元素复制
引擎上的运算、engine.to_contiguous新输出(从缓冲池取或新分配)下一节
CUDA upload_tensor / download_tensor新分配并传输用户文档:「Every successful transfer has a new destination allocation」(本机未运行)
EagerTensor / TracedTensor 的 clone共享它们是句柄

拥有型的默认张量没有 Clone,是这套设计的支点。用户文档 storage-ownership.md 的说法是「Copies are named」:每一次物理复制都要写出名字。0.8.0 只给 Host 开了口子,因为它的内容一定在主机上,clone 不会变成偷偷的下载。burn 0.22 的张量 clone 只加引用计数(见 Tensor 与 Kind),candle 也是(见 Tensor 的结构);tenferro 把「共享」交给借用检查器,把「复制」交给显式调用。

可变借用一次只能有一个(上游的 trybuild 测试 second_mutable_typed_view 固定了 E0499)。构造可变视图时,core 的 validate_mutable_no_overlap 证明不同的逻辑下标落在不同的物理位置;要同时写两块,用 try_multi_slice_mut,它比较两个切片能触及的元素区间(最小到最大偏移),不相交才切开:

columns 0 | 1: split ok
rows 0 | 1: None
after column writes: [0.0, 1.0, 2.0, 0.0]

2×2 列主序矩阵的两列各占一段连续内存,可以切开;两行的元素是交错的(偏移 0、2 和 1、3),逐个看不重叠,但区间 [0, 2] 和 [1, 3] 重叠,只好返回 None。设计文档 D3 明说不打算做更聪明的判断:「this redesign does not promise a solver for arbitrary interleaved-region disjointness」。

输出缓冲:池与整块覆盖

引擎运算的输出不是 vec![0.0; n] 新分配再清零的。CPU 引擎用 PooledUninitOutput 从自己的缓冲池取一个 Vec(池里没有合适的就新分配),以 MaybeUninit 的形式交给内核写,写满之后用 assume_init_recycled 变成张量交出去。这个名字里的 recycled 是关键:它调 from_vec_col_major_with_recycler,把一个指向池的 Weak 挂在输出的 HostData 上。张量 drop 时,HostData 的 Drop 把 Vec 还给池:

impl<T> Drop for HostData<T> {
    fn drop(&mut self) {
        let Some(recycler) = self.recycler.take().and_then(|weak| weak.upgrade()) else {
            return;
        };
        if let StorageBuffer::Host(data) = &mut self.buffer {
            recycler.recycle(std::mem::take(data));
        }
    }
}

用的是 Weak:引擎先没了,upgrade 失败,Vec 就按普通方式释放,输出不会让池活得比引擎长。into_vec_col_major 把 Vec 交给用户时先清掉回收器,这块内存就和池无关了。进了分配组的主机内存(HostAllocation)有同样的 Drop,promote 时回收器跟着搬过去。

0.8.0 里 PooledUninitOutput 只剩 assume_init_recycled 和 assume_init_as_recycled 两种收尾方式;一个没收尾就被丢掉的输出(运算中途失败)也在它自己的 Drop 里把缓冲还给池。所以输出回池不再取决于运算族。实测:

pool before:            BufferPoolStats { buffers: 0, capacity_bytes: 0 }
pool with first alive:  BufferPoolStats { buffers: 0, capacity_bytes: 0 }
pool after drop(first): BufferPoolStats { buffers: 1, capacity_bytes: 32768 }
p2 == p1 (first dropped): true
pool with second alive: BufferPoolStats { buffers: 0, capacity_bytes: 0 }
p3 == p2 (second alive):  false
control: unrelated Vec got the freed block: false, next matmul got it: true
after add output dropped: BufferPoolStats { buffers: 1, capacity_bytes: 32768 }
exp output reuses add's block: true
empty pool, reduce_sum output dropped: BufferPoolStats { buffers: 1, capacity_bytes: 512 }
empty pool, transpose output dropped: BufferPoolStats { buffers: 1, capacity_bytes: 32768 }
...
output survives its engine: sum = 262144
new engine gets p1: false
output placement = Placement { memory_kind: UnpinnedHost, device: None, cpu_affinity: None }
fresh pool after old engine's output dropped: BufferPoolStats { buffers: 0, capacity_bytes: 0 }
  • 64×64 的 f64 matmul 输出 drop 之后,池里多了一个 32768 字节(4096 个 f64)的缓冲,下一次 matmul 拿到的正是这一块。对照组在 drop 之后先分配一个同样大小的普通 Vec,它没拿到那一块,下一次 matmul 拿到了:这次是池在起作用,不是系统分配器碰巧把同一块又给了出去。
  • add 的输出还回去,exp 接着用;reduce_sum(512 字节)和 transpose 的输出也都回池。矩阵乘、逐元素、规约、结构运算四类都实测过。
  • 输出比引擎活得长没有问题;引擎没了以后再 drop 它,Vec 走普通释放,新引擎的池不受影响。
  • 输出的 Placement 里 cpu_affinity 是 None。
📝NOTE
冷知识:一个后缀决定了缓冲回不回池

0.7.1 的 PooledUninitOutput 有两对收尾方法,签名一样,只差一个后缀:assume_init 把缓冲交给张量、drop 时交给系统分配器;assume_init_recycled 挂回收器、drop 时回池。0.7.1 里只有 CPU FFT 用后者,矩阵乘、逐元素、to_contiguous 的输出 drop 时都不回池。PR #2103(首次进入 v0.8.0)把前者删了,提交说明:「The plain names did the wrong thing, so a call site that simply forgot the suffix abandoned its buffer — which is how the 32 MiB cliff got into the analytic, contraction and every other output path」。「32 MiB 悬崖」说的是 glibc:这个大小以上释放的块会被 munmap,下一次调用要把整块缺页重新换进来;同一组提交里 #2100 量过 2048×2048 的 matmul 五次调用,次缺页从 58,535 降到 25,767。删掉而不是标记废弃也写了理由:「the migration is 23 call sites that now fail to compile unless somebody writes _recycled, which is exactly the review that was missing」。

「先证明全写满、再当作已初始化」这条约定落在每个调用点上:内核拿到的是 MaybeUninit 的视图,写完以后 unsafe { out.assume_init_recycled() } 旁边要写一条 SAFETY 注释说明为什么全写满了。列主序与视图里 to_contiguous 那段的注释是「the successful copy replay writes every logical destination element」;矩阵乘那一处(contraction.rs)写的是「BinaryPlan succeeded on MaybeUninit storage and proves complete physical coverage」,保证来自 tcontract 的收缩计划。上游的 uninitialized-output-contract.md(issue #1690)描述的 CpuUninitGemmProvider 等 unsafe trait 在 0.8.0 的 tenferro-cpu 里已经不存在了(provider 换成了单独发布的 crate,见 provider),文档仍标着「Status: design」。省掉的是一遍整块清零,文档估计这些路径上的内存流量因此差不多翻倍(「≈ 2× memory traffic」)。池的大小上限和保留策略在 engine 与线程池。

❗IMPORTANT
动脑筋:同一个 CpuEngine 上做一次 64×64 的 f64 matmul,用 into_vec_col_major 把结果拆成 (shape, Vec),再把这个 Vec drop 掉。buffer_pool_stats() 里有几个缓冲?如果改成把结果交给 engine.reclaim_buffer(Tensor::from_typed(c)) 呢?

分配域:设备侧的身份

分配编号在设备侧才有用。BackendStorage<T> 是 provider 的缓冲区要实现的 trait,allocation_domain()、allocation_id() 默认返回 None,map_read / map_write 默认返回 HostAccessError::Unsupported。导入组时两个编号缺一就报 MissingAllocationIdentity,所以每个进了组的设备缓冲区都有一对 (AllocationDomainId, AllocationId),两张量的这一对相同就是同一块物理内存。

0.8.0 唯一的设备 provider 是 CUDA(只读源码)。tenferro-gpu 的 CudaStorage 装一个 t4a-cuda-runtime 的分配,域号取自它所属的运行时(每个运行时一个),分配号来自一个全局计数器;它没有覆盖 map_read,所以 CUDA 上的张量既不能 host_data() 也不能 with_host_read,要读就显式下载。

SharedTensorAllocationDomain 是另一种用法:让 CPU 引擎把结果分配到一个主机可见的共享域里。

pub trait SharedTensorAllocationDomain: Debug + Send + Sync + 'static {
    /// Return the stable identity shared by every allocation from this owner.
    fn id(&self) -> AllocationDomainId;

    /// Allocate an uninitialized compact column-major tensor in this domain.
    ///
    /// # Errors
    ///
    /// Returns a typed validation, unsupported-dtype, or backend allocation error.
    fn allocate(&self, dtype: DType, shape: &[usize]) -> crate::Result<Tensor>;
}

0.7.1 里它唯一的生产实现是 tenferro-gpu 的 Apple 共享内存上下文(Metal 与 CPU 共用一块内存)。那条 WebGPU/Metal 路线在 PR #2291 里整体删除(提交 d8ddd68d 的说明:「Removed: ... the WebGPU/Metal lane (webgpu, apple, its tests, tutorial binaries and examples)」),而 trait 和 CpuEngine::with_allocation_domain 留了下来。v0.8.0 里实现它的只剩 tenferro-cpu、tenferro-fft、tenferro-linalg 测试里的假域。为了看这条路还通不通,实验照 tenferro-cpu 测试里的 FakeDomain 写了一个最小的域(BackendStorage 用 Mutex<Vec<f64>> 实现 map_read / map_write,memory_kind 是 Managed):

domain id = AllocationDomainId(3); tensor allocation_domain = Some(AllocationDomainId(3)), allocation_id = Some(AllocationId(1))
placement = Placement { memory_kind: Managed, device: None, cpu_affinity: None }
host_data -> Err: TypedTensor::host_data: runtime state failure: backend storage cannot be borrowed as host data; download explicitly first
with_host_read = [1.0, 2.0, 3.0, 4.0]
map_read -> Err: TypedTensor::host_data: runtime state failure: backend storage cannot be borrowed as host data; download explicitly first | RuntimeState { op: "TypedTensor::host_data", message: "backend storage cannot be borrowed as host data; download explicitly first" }
domain engine to_contiguous_read: allocation_domain = Some(AllocationDomainId(3)), allocation_id = Some(AllocationId(2)), values = [1.0, 2.0, 3.0, 4.0]
domain engine add -> Err: add: runtime state failure: CPU backend received backend buffer; download to host before CPU execution
plain engine copy -> Err: CpuEngine::to_contiguous_read: runtime state failure: CPU backend requires source host placement for CpuEngine::to_contiguous_read, got Managed
plain engine add -> Err: add: runtime state failure: CPU backend received backend buffer; download to host before CPU execution
  • 域号是 3:前面两次把主机 Vec 收进组各领过一个,不进组的普通张量不领。
  • 张量有分配编号,host_data() 拒绝;隐藏的 with_host_read 经 provider 的 map_read 读到了值。公开的 map_read() 却走 host_data 那条路,报的是 RuntimeState,而它的文档写的是「Returns crate::Error::HostAccess with HostAccessError::Unsupported when the group's allocation is not host-accessible」;这块内存其实能映射。
  • 装了域的引擎做 to_contiguous_read,结果留在域 3 里、分配编号是新的 2:同一个域里的复制。没装域的引擎直接拒绝 Managed 输入。
  • 逐元素 add 在两种引擎上都拒绝:托管路径只覆盖部分运算(to_contiguous_read,以及 FFT、Cholesky 等少数运算族里的托管分支)。

用户文档 storage-ownership.md 的「Explicit device movement」一节仍列着 tenferro_gpu::webgpu::{WebGpuBackend, ...} 和 tenferro_gpu::apple::AppleContext,这两个命名空间在 v0.8.0 已经不存在。CUDA 怎么分配、上传、下载,见 CUDA。

动脑筋答案

after into_vec_col_major + drop(Vec): BufferPoolStats { buffers: 0, capacity_bytes: 0 }
after reclaim_buffer: BufferPoolStats { buffers: 1, capacity_bytes: 32768 }
  • into_vec_col_major 把 Vec 交给调用方之前清掉了回收器,之后这个 Vec 就是普通的 Vec,drop 时交给系统分配器,池里是 0 个。想拿走数据又想让池复用,只能自己复制一份再 drop 张量。
  • reclaim_buffer(TensorBuffer trait 的唯一方法,CpuEngine 实现了它)仍然存在,把张量的缓冲直接收进池,池里是 1 个、32768 字节。0.8.0 里输出 drop 本来就会回池,它只在「张量要交给引擎、但还不想 drop」的场合有用,runtime 的执行器和 eager einsum 用它回收中间结果。

小结

  • 普通张量(Host 支)直接持有一个 Vec 加一个可选的池回收器,不带分配组、没有分配编号;Vec 进出都不复制。
  • AllocationGroup 只在张量进组、promote() 或来自后端缓冲区时出现:唯一所有者 OwnedStorage 加若干描述符,DescriptorSlot 只是下标,槽位只追加不复用;进组后取出的张量仍是分配组那一支。
  • 读回:host_data() 用缓存的主机地址,设备张量直接报错;公开的 map_read() 走同一条路;隐藏的 with_host_read 经 provider 映射读。
  • 拥有型的默认张量没有 Clone,只有 Host 有;复制必须写出 duplicate、from_vec_row_major、to_col_major、上传下载或一次计算。
  • CPU 引擎的输出从池里以未初始化的形式取出,写满后 assume_init_recycled;输出 drop 时经 Weak 回收器回池,四类运算都实测过(PR #2103 删掉了不回池的收尾方式)。
  • 分配编号属于设备侧:CUDA 存储有域号和分配号、不能映射到主机(只读源码);Apple 共享内存随 WebGPU/Metal 一起删除,SharedTensorAllocationDomain 只剩接口和测试里的假域。

💬 关联讨论

发起讨论
加载中...