存储与所有权
主线位置:直接计算主线的两头。开头
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 的设计记录取代。
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)
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。
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 与线程池。
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,而它的文档写的是「Returnscrate::Error::HostAccesswithHostAccessError::Unsupportedwhen 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(TensorBuffertrait 的唯一方法,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只剩接口和测试里的假域。