现代处理器实现高吞吐量不仅靠提高时钟频率,更靠每个周期执行更多任务。尽管流水线让指令在时间上重叠,但向量化采用单指令多数据(SIMD)能力在空间上重叠执行。对于深度学习编译器而言,将标量数学描述映射到这些向量单元是提升算术密度的最有效方法。SIMD抽象在标准标量执行模型中,像数组相加这样的操作,需要从每个数组加载一个元素,相加,然后存储结果。这个过程对每个元素都重复进行。SIMD架构引入了向量寄存器,它们能同时存储多个数据元素(或称通道)。例如,一个512位的寄存器(如AVX-512指令集中的)可以存储十六个32位浮点数。一条单独的硬件指令vaddps就能并行触发所有十六对数据的相加。为使用此硬件,编译器必须识别出迭代独立且连续的循环。考虑标准的向量相加循环:$$C[i] = A[i] + B[i]$$在标量编译中,CPU执行$N$次加载、相加和存储序列。在向量化编译中,若向量宽度为$W$,CPU执行$N/W$次序列。理论上,效率提升与向量宽度呈线性关系,但内存带宽常成为饱和点。digraph G { rankdir=TB; node [fontname="Helvetica", shape=box, style="filled"]; subgraph cluster_scalar { label="标量执行 (4 周期)"; style=filled; color="#f8f9fa"; s1 [label="加载 A[0]\n加载 B[0]\n相加\n存储 C[0]", fillcolor="#a5d8ff", color="#74c0fc"]; s2 [label="加载 A[1]\n加载 B[1]\n相加\n存储 C[1]", fillcolor="#a5d8ff", color="#74c0fc"]; s3 [label="加载 A[2]\n加载 B[2]\n相加\n存储 C[2]", fillcolor="#a5d8ff", color="#74c0fc"]; s4 [label="加载 A[3]\n加载 B[3]\n相加\n存储 C[3]", fillcolor="#a5d8ff", color="#74c0fc"]; s1 -> s2 -> s3 -> s4; } subgraph cluster_vector { label="SIMD执行 (1 周期)"; style=filled; color="#f8f9fa"; v1 [label="向量加载 A[0:3]\n向量加载 B[0:3]\n向量相加\n向量存储 C[0:3]", fillcolor="#b2f2bb", color="#40c057", width=3]; } }标量迭代处理与向量宽度为4的并行SIMD处理的比较。数据依赖与合法性并非所有循环都适合向量化。主要限制是数据依赖。编译器进行依赖分析,以确保并行执行一组迭代能得到与顺序执行相同的结果。最常见的阻碍是循环携带依赖,即某次迭代依赖于适合同一向量块的先前迭代的结果。考虑以下累加:$$A[i] = A[i-1] + B[i]$$这里,$A[i]$必须在$A[i-1]$已知后才能计算。这种写后读(RAW)依赖强制顺序执行。但归约操作(例如数组求和)是特殊情况。它们在数学上有依赖性,但编译器会采用基于树的归约策略或特定的横向硬件指令来有效向量化这些操作。内存对齐与步长当数据从与向量大小对齐的内存地址加载时(例如,512位向量需要64字节对齐),向量单元的运行效率最高。未对齐的内存访问常会带来性能损失,或需要多条加载指令来构建单个向量寄存器。在生成代码时,编译器会分析内存访问模式,即所谓的步长。单位步长(步长为1): 元素在内存中是连续的(例如,A[i], A[i+1])。这能实现高效的块加载。恒定步长: 元素间隔固定(例如,访问每第4个元素)。编译器可能会使用“收集”指令,但这比块加载慢得多。随机访问: 索引是动态计算的(例如,A[B[i]])。这通常会阻止有效向量化,或者需要昂贵的收集操作,可能使性能低于标量级别。处理循环边界:剥离与尾部循环执行次数$N$很少是向量宽度$W$的完美倍数。编译器必须生成代码来处理这些边界。这通常会产生一个三阶段结构:剥离循环: 如果起始指针地址未对齐,编译器可能会生成一个标量循环,执行最初的几次迭代,直到内存指针到达一个对齐的地址。向量主体: 主要核心使用对齐的向量指令处理大部分数据。余数(尾部)循环: 向量主体完成后,可能会剩下少量迭代($N \pmod W$)。这些迭代会顺序执行,或使用带掩码的向量操作。现代架构,如SVE(可扩展向量扩展)和AVX-512,引入了谓词寄存器(掩码),使得向量循环无需退回标量指令即可处理尾部,只需停用与越界索引对应的通道。{"layout": {"title": {"text": "吞吐量与数组大小的关系(尾部效应)", "font": {"family": "Helvetica", "size": 16}}, "xaxis": {"title": "元素数量", "showgrid": true, "gridcolor": "#e9ecef"}, "yaxis": {"title": "GFLOPS", "showgrid": true, "gridcolor": "#e9ecef"}, "plot_bgcolor": "white", "margin": {"t": 50, "l": 50, "r": 20, "b": 50}, "width": 600, "height": 350}, "data": [{"type": "scatter", "mode": "lines+markers", "x": [128, 130, 136, 144, 160, 162, 168, 176, 192], "y": [100, 60, 75, 85, 100, 65, 78, 88, 100], "line": {"color": "#228be6", "width": 3}, "marker": {"size": 6, "color": "#1c7ed6"}, "name": "性能"}]}当数据大小不是向量宽度的倍数时,性能会周期性下降,这会强制执行较慢的标量尾部循环。谓词与控制流循环中的控制流(if/else语句)通常会破坏向量化,因为SIMD单元共享一个指令指针。如果通道0执行true分支而通道1执行false分支,标准处理器无法同时执行两者。为解决此问题,编译器会采用谓词(或称掩码)。编译器会为所有通道执行两个分支,但使用位掩码仅提交有效的结果。对于像这样的语句: $$R[i] = (A[i] > 0) : ? : B[i] : C[i]$$向量化逻辑按以下步骤进行:将向量$A$与0比较以生成掩码$M$。计算向量$B$(执行“then”块)。计算向量$C$(执行“else”块)。使用$M$合并结果:当$M$为1时从$B$中选取,当$M$为0时从$C$中选取。这种技术,常通过vblend或带掩码的移动指令实现,将控制依赖转换为数据依赖。它保留了并行性,但增加了总指令数,因为两条路径都实际计算了。转换为内部函数在MLIR或TVM等框架中,向量化是明确的。高层调度定义向量宽度,而转换过程将其转译为硬件特定的内部函数或LLVM IR向量类型。例如,MLIR的vector.transfer_read操作将抽象张量数据映射到向量寄存器。随后的操作处理vector<4xf32>类型而非标量f32。这确保了当代码到达后端(LLVM)时,指令选择器能轻松地将这些操作映射到目标ISA(指令集架构),例如x86 AVX2或ARM NEON。有效的向量化常需与循环分块配合。分块确保数据驻留在L1缓存中,而向量化则确保数据一旦进入寄存器,就能以最大算术强度进行处理。