十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

张量到底是什么?从多维数组到Tensor Core的工程实践深度解析

张量到底是什么?从多维数组到Tensor Core的工程实践深度解析 入行头几年我一度觉得“张量”这个词是用来唬人的。搞深度学习之前线性代数里学的都是向量、矩阵突然有一天所有框架的文档都开始讲Tensor讲张量的形状、维度、轴讲你对一个高维数组做各种花样操作。当时我唯一的感受是这不就是多维数组吗换个名字显得高级后来真到了做推理引擎优化、做算子移植、甚至写CUDA kernel的时候我才意识到“张量”这个概念远比“多维数组”这四个字藏的东西多。它不只是一种数据结构它背后绑定了张量积、缩并、坐标变换、内存布局、硬件加速等一系列工程和数学问题。如果不把张量这个概念吃透后面遇到形状对不上、内存越界、精度差一截、性能上不去这类问题基本只能瞎猜。所以这篇东西我想从一个干过多年工程实践的角度把张量从头到尾掰开揉碎讲一遍。从最基础的标量、向量、矩阵讲起一步步走到高维数组再深入到张量乘法、Tensor Core硬件加速、内存对齐、仿真软件里的非对角化张量这些实际场景。内容不算短但每一段都尽量把“是什么”和“为什么”讲透适合刚入门深度学习或者做AI基础设施的同学也适合写过一段时间代码但始终对张量底层不够清晰的人。1. 从标量到张量一个被反复误解的基础概念1.1 张量到底“张”的是什么量先回答一个最基本的问题张量这个名词英语叫Tensor词根来自拉丁语tendere意思是“拉伸”。物理里最早用它描述应力、应变这类需要多个分量才能完整表达的物理量。一根杆子被拉长你关心它在某个方向的拉力这不是一个单独的数字能描述的它跟截面的朝向有关。于是你需要一个对象它的具体数值会随你选取的坐标系不同而变化但对象本身又代表一个客观存在的物理量——这就是张量。这个“随坐标变换而保持物理意义不变”的特性是张量跟普通多维数组最本质的区别。换句话说任何多维数组都可以叫张量但并不是任何多维数组都配叫张量。只有当你给它定义了坐标变换规则之后它才真正具备张量的身份。当然在深度学习领域大家日常说的“Tensor”基本就是指多维数组不刻意强调坐标变换这个概念简化是合理的因为数据本身就在固定网格上。我记得有人问过一个问题为什么图像是张量一张RGB彩色图片在PyTorch里的形状是(3, H, W)。第一个维度是通道第二、三维是空间坐标。你可以把它看成三维数组也可以把它看成三个二维矩阵叠在一起。但它本质描述的是一个物理场景在每个像素位置、每个频段上的光强分布这个分布是客观存在的而你选择的存储格式只是它的一个视图。这正是张量的精髓对象本身和它的表达方式可以分离。1.2 标量、向量、矩阵张量的低阶形式张量按阶数区分最常见的几个低阶形式大家早就见过。0阶张量就是标量Scalar只有一个数字没有方向概念。房间里温度是26度这杯水500毫升都是标量。在编程里就是一个单值变量形状是空的或者说形状为()。1阶张量就是向量Vector一组有序数字有长度概念。一个词的词向量维度是128那么它就是一个形状为(128,)的张量。注意在PyTorch里向量是(128,)在NumPy里是(128,)这个写法表示它是一维的只有一个轴轴上有128个元素。2阶张量就是矩阵Matrix排成矩形阵列的数字。一个大小为100行50列的特征矩阵形状就是(100, 50)。它有两个轴轴0长度为100轴1长度为50。从第3阶开始情况就变得有意思了。一个形状为(8, 100, 50)的三阶张量可以理解为8个矩阵叠在一起。一个形状为(4, 8, 100, 50)的四阶张量又可以理解为4组三阶张量的堆叠。数据维度每增加一阶描述的信息结构就多一层。深度学习里batch size、通道数、序列长度、特征维度这些概念最后全部折叠进张量的不同轴上。1.3 为什么不能简单叫“多维数组”很多人觉得叫多维数组就够了但实际工程里“张量”比“多维数组”多出来的那部分语义恰恰决定了你能否正确使用各种框架和硬件。多维数组强调的是“存储一组数字”它对轴的含义没有任何约束。而张量在深度学习里每个轴都有明确的语义轴0可能是batch轴1可能是通道轴2可能是高度轴3可能是宽度。当你做transpose、squeeze、unsqueeze这些操作时实际上是在重新解释这些轴的语义。如果不了解轴的含义你根本不知道一次reshape会不会打乱数据。另外“张量”这个概念天然和“变换”绑定。矩阵是线性变换的表示两个矩阵相乘是在做操作的复合张量与张量相乘是在做更复杂的代数运算。而“多维数组”这个概念不承载这类语义。所以虽然存储层面两者完全一致但理解层面张量要大得多。准备一个涉及GPU底层指令的kernel你对张量轴布局的理解直接决定你能不能让计算跑得又快又稳。2. 张量的阶、形状与轴概念虽小坑却不少2.1 阶、形状、维度三个词别搞混张量有三个最基础的属性很多新手一开始分不清这三个属性搞混了后面看文档全是障碍。阶Rank指的是张量有多少个轴。标量阶为0向量阶为1矩阵阶为2三维数组阶为3以此类推。在PyTorch里你用tensor.ndimension()或者tensor.dim()查看NumPy里是ndim。形状Shape指的是每个轴上有多少个元素。一个形状为(3, 4, 5)的张量表示它有3个轴轴0长度3轴1长度4轴2长度5。总共包含3×4×560个元素。维度Dimension这个词最容易被误用。日常对话里说“这个张量是三维的”可能指的是阶为3也可能指的是形状里某个值为3。为了避免歧义我习惯说“几阶张量”或者“形状是多少”而不是“几维”。举个例子一个形状为(3, 1, 224, 224)的批量图片张量它确实是4阶张量有4个轴。但你说它是三维图像吗不每张图是二维的只是batch和通道存在额外轴上。这种裸语义分歧导致的bug我在code review里见过不下十次。2.2 一个轴的语义从PyTorch到NumPy的视角差异关于张量轴最让我头疼的是不同框架的表示方式差异。NumPy里轴0是行轴1是列。PyTorch基本沿用这个习惯但上了GPU之后由于行主序内存布局和CUDA的grid/block映射关系轴0的操作可能并不是性能最优的。举个例子一个形状为(1024, 1024)的矩阵在GPU上做行求和和列求和性能差异非常大。因为GPU的线程是按block组织的如果一个block处理一行数据那么行求和可以连续读取显存而列求和则每次要跨行访问访存效率差一个量级。这就是轴的语义直接影响工程性能的典型案例。还有一个常见的坑是transpose。你有一个形状为(2, 3)的张量转置后变成(3, 2)这没问题。但如果你有一个形状为(2, 3, 4)的三阶张量你想把轴0和轴2交换在NumPy里是transpose(2, 1, 0)在PyTorch里是permute(2, 1, 0)。如果在不熟悉的情况下直接使用.T属性它只会对2D张量做最后两轴交换放在3D张量上直接报错或者得到完全错误的结果。我建议所有刚接触张量的朋友遇到轴操作时先在纸上画一个小例子把每个轴的数字标出来再执行操作对照结果是否正确。这个习惯可以帮你少写很多调试代码。2.3 广播机制隐式复制带来的便利与危机张量的广播机制broadcasting是我又爱又恨的功能。爱它是因为它让代码简洁恨它是因为它掩盖了太多内存分配细节出了问题非常难查。广播的核心规则是从最后一个轴开始逐一比较如果两个张量在某个轴上的长度相同或者其中一个长度为1则可以进行广播。如果长度为1它会自动扩展成另一个张量的长度且不实际复制数据至少在NumPy和PyTorch的实现里是视图级别操作。这个机制的强大之处在于你不需要手动把(3, 1)扩展成(3, 4)再相加直接(3, 1) (4,)就能得到(3, 4)的结果。但它也带来了隐患当你意外地把形状(3, 4)和(3, 1)组合在一起时你得到的不一定是报错而可能是一个看似合理但完全不是你本意的(3, 4)矩阵。这种隐含的语义错误比显式报错要难发现得多。我在团队里经常说一句话广播是语法糖但爱吃糖也要记得刷牙。写代码时明确写出目标形状必要时用注释标注每个轴的语义这比之后花两小时排查形状问题划算得多。3. 张量运算的底层逻辑乘法家族与缩并3.1 点积、外积、Hadamard积到底哪里不同说到张量的运算最多人问的就是“张量的乘积”。其实张量的乘积有好几种它们的数学意义和工程用途完全不同。点积/内积Dot Product两个形状相同的向量把对应位置元素相乘后求和得到一个标量。它是衡量两个向量相似度的最基本方式。在深度学习里注意力机制的核心就是计算Query和Key的点积。外积Outer Product两个向量做外积得到一个矩阵。形状为(m,)的向量和形状为(n,)的向量做外积结果是(m, n)的矩阵每个元素是第一个向量第i个分量与第二个向量第j个分量的乘积。Hadamard积逐元素乘积两个形状完全相同的张量对应位置相乘结果形状不变。这是最“无脑”的乘法但也是卷积、全连接层里用得到的基础操作。矩阵乘法MatMul形状为(m, k)和(k, n)的两个矩阵结果形状为(m, n)。它要求内维相等计算过程是左边矩阵的每一行与右边矩阵的每一列做点积。这是线性代数的核心操作也是深度学习计算量的主要来源。放到工程视角看矩阵乘法之所以重要是因为它可以被硬件优化到极致。GPU上的Tensor Core、CPU上的SIMD指令、NPU上的脉动阵列本质上都是为矩阵乘法服务的。你用PyTorch写一行torch.matmul(a, b)底层可能是几十上百个CUDA kernel的调度结果。3.2 张量缩并从矩阵乘法到高维推广矩阵乘法的定义可以推广到高维张量这个过程叫“缩并”contraction。所谓缩并简单说就是对两个张量共同轴上做求和并把这些轴去掉。举个例子两个二阶张量A和B做矩阵乘法C A B实际上是对A的轴1列和B的轴0行做缩并这组轴长度必须相同结果张量保留A的轴0和B的轴1。写成爱因斯坦求和约定就是C[i, j] sum_k A[i, k] * B[k, j]。对于更高阶的张量你可以选择不同的轴对做缩并。比如四阶张量A形状为(2, 3, 4, 5)三阶张量B形状为(6, 4, 7)你可以让A的轴2和B的轴1配对结果形状为(2, 3, 5, 6, 7)。这个操作在物理仿真、量子化学、电子结构计算里非常常见而深度学习框架里torch.einsum、np.einsum就是为这类操作准备的。einsum的写法特别有意思它用字符串显式标注每个轴的语义。比如“bij,bjk-bik”表示两个三维张量共享batch维b缩并掉j轴。这个表示法一方面非常清晰另一方面也像个迷宫——新手看到混合了字母和逗号的字符串就头大。其实用einsum调试高维张量运算比一堆reshape和transpose要直观得多。我经常把复杂的矩阵乘法链改写成einsum形式对照结果发现问题后再优化回显式的matmul。这个“先明确语义再优化性能”的工作流几乎踩遍了所有形状错误。3.3 Tensor Core里的张量乘法为什么要bit-accurate建模2017年NVIDIA推出Volta架构Tensor Core首次出现这算硬件开始直接面向张量运算的里程碑。Tensor Core能在单个时钟周期内完成4×4矩阵乘法累加精度通常是FP16输入FP32累加。后来Ampere、Hopper架构又引入了BF16、TF32、FP8等更多格式计算能力成倍增长。但Tensor Core有个特点它的计算方式不是“先乘后加”这种教科书写法而是把多个乘法结果直接累加到一个更高精度的累加器里中间结果不截断。这看起来是好事实际上导致了一个问题——Tensor Core的结果跟用CPU逐元素算出的结果最终浮点结果可能会有差异。因为累加顺序不同、中间舍入策略不同默认情况下不可能完全位级一致。这就是热词里提到的mma-sim这类“bit-accurate reference model”存在的意义。它本质上是一个模拟Tensor Core指令如mma.m16n8k16的参考模型能够逐位精确地复现硬件上的计算结果。你做算子精度验证的时候不可能每跑一次都调真机但模拟器可以帮你验证算法逻辑是否正确再拿到硬件上跑。我做过一个矩阵乘法kernel的精度对比实验。用同一个输入在GPU Tensor Core上算出来的结果和用双精度CPU逐项累加再转回FP32的结果相比相对误差大概在1e-6量级。这个误差对大多数深度学习任务来说完全可接受但那一次我做的是科学计算算子用户对精度要求极高必须逐位一致。这就逼着我改算法先把大矩阵拆分再逐块定点化累加最后误差才压到可接受范围。如果你在做AI推理引擎或者科学计算库千万别忽视这个细节。Tensor Core的高吞吐背后是精度上的特殊处理理解它你才能在性能和精度之间找到正确的平衡点。4. 内存、对齐与布局看不见但决定性能的暗线4.1 行主序与列主序同一个张量不同的世界里张量在内存里总是线性排列的一维数组按顺序存。但二维以上的张量线性排列的顺序有两种选择行主序row-major和列主序column-major。行主序的意思是同一行的元素在内存里是连续的。比如一个2×3的矩阵[[1, 2, 3], [4, 5, 6]]在行主序下存储为1, 2, 3, 4, 5, 6在列主序下存储为1, 4, 2, 5, 3, 6。Python的NumPy和PyTorch默认行主序Fortran、MATLAB默认列主序而底层C/C里按你malloc的顺序自己决定。这个差异最直接的影响是当你把一个用PyTorch训练出的权重导出成numpy数组再想导入到某个用Fortran顺序的库时直接reshape会得到完全错乱的数据。我碰到过一个真实案例一个同事把PyTorch的模型权重导出为.npy文件用matlab读取后热力图完全错乱查了两天才发现是内存布局不一致。解决办法也简单导出时用np.asfortranarray()或者读取时用np.ascontiguousarray()重新布局。4.2 内存与张量对齐为什么有“对齐”这个概念所谓内存对齐是指数据在内存中的起始地址和长度尽量是某个值通常是32字节、64字节的整数倍。CPU和GPU访存时一次读取的最小单位可能是一整个缓存行cache line一般是64字节或一个向量寄存器宽度。如果数据没有对齐一次访存可能需要两次甚至更多次读取性能直接翻车。在张量场景里对齐问题最典型出现在两个地方。一是使用SIMD指令如AVX512处理数据时load操作要求地址按32字节对齐二是GPU上给Tensor Core喂数据时通常要求每个矩阵的leading dimension按16字节或32字节对齐这对应某些专门指令的要求。解决对齐问题的常用手段是padding在tensor的最后一个维度上填充无效元素让总大小变成对齐单位的整数倍。深度学习框架里卷积的im2col操作、Transformer的attention矩阵很多时候都会做padding来保证对齐。我自己调过一个BERT推理算子输入序列长度为127注意力矩阵大小是127×127逻辑上完全正确但性能比预期低15%。后来发现问题不是计算量而是127×2字节254字节不是16字节的整数倍导致每次访存都要多读一段。把序列padding到128之后性能立刻恢复。所有你看到的“batch size调成8的倍数”、“序列长度调成16的倍数”这类经验底层逻辑基本就是内存对齐。4.3 非连续张量与视图别被“零成本”骗了PyTorch和NumPy都有一个特性很多操作只改变元数据不复制数据。比如transpose、slice、view返回的是原数据的视图。这个概念非常强大但也带来了“非连续张量”的陷阱。一个连续张量的数据是一块完整的连续内存逻辑顺序和存储顺序一致。而当你对张量做transpose之后逻辑顺序和存储顺序不再一致此时它是一个非连续张量。对这个非连续张量做任何逐元素操作框架必须按逻辑索引逐元素访问数据无法使用SIMD优化性能骤降。更麻烦的是某些操作对非连续张量直接报错。比如torch.Tensor.view要求张量在内存中是连续的不连续就报错。解决办法是调用.contiguous()方法但它是会真实复制数据的。也就是说你以为的“零成本转置视图”在后续操作中可能被强制变成一次真实拷贝。我在做Transformer推理的时候经常需要在Q、K、V三个张量之间做permute和reshape。如果顺序调不对可能在中间层引爆一次大规模内存拷贝导致延迟比正常情况高一截。后来养成一个习惯任何为了效率而进行的permute、squeeze、view都主动检查.is_contiguous()必要时主动复制而不是依赖框架在最后一刻强制复制。5. 高维张量的真实现场AI、电磁仿真与科学计算5.1 深度学习框架里的张量生活深度学习里张量几乎是无处不在的核心数据结构。输入是张量权重是张量梯度是张量中间激活值也是张量。框架做的所有事情本质上就是张量的搬运和运算。以Transformer为例。输入文本经过tokenizer变成token id序列查表映射为形状为(batch, seq_len, hidden_dim)的embedding张量。这个张量接下来会经历多头注意力里的线性投影矩阵乘法、分头reshape、transpose、注意力得分计算又是乘法、softmax归一化、加权求和、拼接、再投影……每一步都在对张量做形状变换和乘法运算。在这个过程中你会深刻体会到张量的轴顺序设计直接影响代码复杂度。PyTorch的Transformer默认布局是(batch, seq_len, hidden_dim)也就是seq维度在中间而把它改成(batch, hidden_dim, seq_len)后某些张量操作的效率可能更好因为通道维和特征维连续分布。深度学习框架不断演进的实际工作量有很大一部分是在跟张量轴布局做斗争。5.2 电磁仿真里的非对角化张量材料属性的张量表达张量的应用不仅限于AI。电磁仿真软件里材料属性经常用张量来描述。比如CST Studio Suite这类工具里处理超表面、铁氧体、等离子体等材料时介电常数和磁导率根本不是一个单一数值而是一个3×3的矩阵甚至可能是六阶张量这就是热词里“cst非对角化张量”出现的原因。简单说普通各向同性材料的介电常数是一个标量电场加在哪个方向电位移方向就与电场方向一致。但各向异性材料的介电常数是张量电场加在x方向可能不仅产生x方向的电位移还会产生y方向、z方向的分量。这个3×3介电常数张量非对角项如果是非零的就是“非对角化张量”的典型例子。在工程上处理这种材料核心难点在于坐标变换。仿真模型里有个全局坐标系但材料的本构参数可能定义在局部坐标系里。你需要对张量做旋转转换才能正确映射到网格单元上。这一转换如果在代码里处理不当就会导致计算发散或者结果完全不符合物理。仿真结果“感觉不对”的时候先查材料张量是否经过正确的坐标变换这是老工程师常用的第一直觉。5.3 科学计算中的高阶张量不止是AI的特权除了电磁仿真量子化学里的电子关联能计算、流体力学里的应力张量、广义相对论里的黎曼曲率张量全都是高阶张量的典型应用场景。这些领域对张量的运算需求比深度学习方法要“重”得多。它们不只需要矩阵乘法还需要张量缩并、张量分解、特征值分解等高阶运算。在这个领域einsum几乎是标准语言。量子化学计算中四阶张量的缩并可能动辄涉及数个TB的数据量必须精心设计循环顺序和内存复用策略。在这个层面张量已经不只是“多维数组”而是一种你需要像管理数据库一样精心规划存储、索引和缓存的资源系统。有一次一个物理背景的博士生问我为什么同样一个张量缩并操作他写的Python循环要跑3天而我在GPU上用cuTensor库改写后只要2小时。差异就在于他按数学公式的顺序遍历而我按内存访问模式重排了循环嵌套顺序把不常改动的维度放到最外层把连续访问的维度放到最内层。张量操作性能优化的一半工作是理解数据在硬件上的移动路径而这一半恰恰是教科书不太讲的。6. 常见问题与排查技巧实录6.1 形状对不上高频报错背后的数学直觉做张量运算最常遇到的错误就是shape mismatch。新手一看报错就慌其实这类报错信息已经写得非常清晰了。以PyTorch为例它会明确告诉你两个张量的形状和哪个轴不匹配。关键是怎么快速定位代码逻辑上的问题。我的排查思路是先问三个问题这两个张量的轴分别代表什么语义缩并轴应该是哪一个结果形状应该是什么然后把中间结果逐个打印出来对照维度检查。这个方法比从头到尾读代码快得多。另外要提醒的是矩阵乘法的哑元轴。torch.matmul在二维情况下要求第二维相等在三维情况下要求batch维一致。很多人把(batch, m, k)和(m, n)相乘时以为会自动广播实际上PyTorch要求前导维要么一致要么可广播。出现形状报错时先check是不是高维广播的问题。6.2 内存溢出张量的“体积”比你想象的大张量的内存占用 元素个数 × 每个元素的字节数。一个形状为(1024, 1024, 1024)的float32张量元素个数是1,073,741,824每个4字节总占用4GB。很多人在显存不够用的时候才意识到这个算数。排查内存溢出的思路一般是先定位哪个张量占用了大头再用batch拆分、梯度检查点、混合精度等手段降低占用。特别要注意的是中间激活值在训练过程中可能比模型权重本身大好几倍。Transformer训练时每个token在每个层都要保存注意力得分和中间结果这些非常吃显存。如果你在做推理优化优先考虑量化。从FP32压到FP16显存直接减半再压到INT8又减半。但量化不是无脑操作需要做精度校准。这里再次回到Tensor Core的精度问题混合精度训练要用FP16存数据、FP32做累加量化推理要用INT8存权重。每一步都有门道。6.3 精度对不上先怀疑顺序再怀疑舍入前面提到Tensor Core的计算结果跟CPU逐项计算可能有细微差异这在很多场景下是可接受的。但如果你实现的算子在数值验证时对不上很可能是操作的顺序不同导致的。排查精度问题我建议按以下步骤走第一步把输入换成全整数或全1的简单数据确认逻辑正确。第二步把运算改用双精度跑一遍作为基准值。第三步把算子内部的每一步单独计算并对比精度的差异出现在哪一步。第四步确认框架是否有类似TF32这样的模式在悄悄改变精度。Tensor Core的TF32模式是很多精度问题的隐蔽来源默认情况下某些框架可能开启TF32导致深度学习模型精度异常。我在一次算子验证中就踩过TF32的坑。逻辑完全正确但结果总差一点点最后发现是环境变量里设的NVIDIA_TF32_OVERRIDE1所有FP32运算都被压低到TF32精度了。6.4 性能上不去张量操作里的“隐形拷贝”代码跑得慢最高频的原因之一是非预期拷贝。PyTorch里很多操作看起来是视图实际上会触发数据复制比如.permute()后再做.contiguous()又比如把GPU张量转到CPU再转回来。每次复制都是时间和内存的双重消耗。还有一个容易忽略的问题是GPU缓存命中率。如果你的kernel访存模式是跳跃式的比如每隔一大段取一个数那么显存带宽再高也没用缓存会被浪费。设计算子时尽量保证同一block处理的数据在内存里尽量连续这样能最大化利用缓存行。性能调优时我习惯先用profiler跑一遍看每个算子的耗时占比和访存带宽利用率再决定从哪个算子动手。跳过profiler直接优化十有八九是在白费功夫。写在最后把张量当“人”看很多问题就好懂了我做了这么多年跟张量打交道的工作最大的体会是张量其实很像一个“有性格的对象”。它有自己的形状、自己的存储习惯、自己的内存偏好。你以为它在某个位置它可能在你看不见的地方悄悄复制了一块你以为它可以随意扭曲它一转身就报错给你看。摸透了它的脾气你才能写出又对又快的代码。最后分享一个小经验遇到难排查的张量问题永远先把它画出来。画成小网格、标上每个轴的名字和长度再动手改代码。纸上画一分钟能帮你省下一个小时的调试时间。这些内容说起来不多但每一条都是我在项目里真实踩过的坑换来的。希望对你有用。
返回列表