
矩阵行优先与列优先存储对 SIMD 向量化展开的影响深度剖析在通用矩阵乘法GEMM, $C A \times B$以及各类深度学习张量算子中数据的物理存储排布Memory Layout是决定计算能否被打满的核心生命线。很多初学者在编写算子时往往只把矩阵视为数学上的二维网格 $A_{i,j}$在内存中随手以 C 语言默认的行优先Row-Major或 Fortran 的列优先Column-Major铺平却从未深入思考过这两种排布方式在向量寄存器SIMD Register与硬件加载指令上的巨大鸿沟。当采用 AVX2256 位或 AVX-512 向量指令集时硬件单条加载指令如_mm256_loadu_ps只能一次性从物理上连续相邻的地址读取连续的 8 个或 16 个浮点数。如果算法需要的数据在物理内存中是不连续的跨步StridedCPU 硬件就不得不退化为低效的汇聚加载Gather_mm256_i32gather_ps或者通过多次标量加载在寄存器中进行繁复的混洗重排Shuffle / Permute。本文我们将深入 x86 微架构剖析行优先与列优先在 GEMM 内层循环中的指令级差异并推导最优的内存转置与重排策略。一、行优先 vs 列优先物理内存展开的几何差异假设矩阵 $M \in \mathbb{R}^{4 \times 4}$元素为浮点数行优先Row-MajorC/C 标准内存排布$A_{0,0}, A_{0,1}, A_{0,2}, A_{0,3}, A_{1,0}, A_{1,1}, \dots$特征同一行内的相邻元素在内存中是紧挨着的步长为 1同一列内的相邻元素在内存中间隔了整整一行的字节数Stride N * sizeof(float)。列优先Column-MajorFortran / BLAS / MATLAB 标准内存排布$A_{0,0}, A_{1,0}, A_{2,0}, A_{3,0}, A_{0,1}, A_{1,1}, \dots$特征同一列内的相邻元素在内存中紧挨着步长为 1同一行内的相邻元素在内存中间隔了一整列。在矩阵乘法 $C_{i,j} \sum_{k} A_{i,k} \cdot B_{k,j}$ 中我们沿着矩阵 $A$ 的第 $i$ 行向右扫描遍历 $k$同时沿着矩阵 $B$ 的第 $j$ 列向下扫描遍历 $k$。如果 $A$ 和 $B$ 都是 C 语言默认的行优先存储$A_{i,k}$ 随着 $k$ 的增加是物理连续的但 $B_{k,j}$ 随着 $k$ 的增加是在按列向下跨步跳跃每次 $k$ 递增 1访问 $B$ 的地址就要跳过整整一行$N$ 个浮点数。二、连续加载 vs Gather 汇聚加载的微架构惩罚为了在寄存器中计算 $A$ 的一行与 $B$ 的一列的点积如果直接用向量化去加载 $B$ 的列元素#include immintrin.h #include span #include iostream // 低效实现在行优先矩阵 B 上强行按列 Gather 加载 __m256 load_column_gather(const float* B, size_t col_idx, size_t stride_N) { // 构造跨步索引向量: [0*N, 1*N, 2*N, ..., 7*N] __m256i v_index _mm256_set_epi32( 7 * stride_N, 6 * stride_N, 5 * stride_N, 4 * stride_N, 3 * stride_N, 2 * stride_N, 1 * stride_N, 0 * stride_N ); // 触发硬件 Gather 指令 return _mm256_i32gather_ps(B col_idx, v_index, 4); }这段代码在微架构层面代价极其高昂普通的连续加载指令vmovups只需要1 个时钟周期且每个周期可以发射 2 条吞吐高达 64 字节/cycle而vgatherdps指令在硬件内部会被微码引擎拆解为 8 次独立的标量内存寻址和多次寄存器合并执行延迟高达15 到 20 个时钟周期且会锁死内存执行端口在密集浮点计算中滥用 Gather 指令会直接让算子吞吐暴跌 80% 以上。三、行优先转置为列优先消除 Gather 的黄金法则要实现极致的向量化最经典也是工业级 BLAS 库如 OpenBLAS、Intel MKL的标准做法是在计算前对矩阵 $B$ 进行离线打包转置Pack Transpose使矩阵 $B$ 在物理内存中转变为列优先存储或者将其划分为适合向量寄存器尺寸的微子块Micro-Panels。当矩阵 $B$ 被转置为 $B^T$ 后$B_{k,j}$ 变成了 $B^T_{j,k}$随着 $k$ 递增访存瞬间变为了步长为 1 的绝对物理连续访问// 高性能矩阵转置微内核利用 AVX2 寄存器内解包混洗 (4x4 单精度块) void transpose_4x4_avx(const float* src, float* dst, size_t lda, size_t ldb) { __m128 row0 _mm_loadu_ps(src 0 * lda); __m128 row1 _mm_loadu_ps(src 1 * lda); __m128 row2 _mm_loadu_ps(src 2 * lda); __m128 row3 _mm_loadu_ps(src 3 * lda); // 两两交叉混洗低半部与高半部 __m128 tmp0 _mm_unpacklo_ps(row0, row1); __m128 tmp1 _mm_unpackhi_ps(row0, row1); __m128 tmp2 _mm_unpacklo_ps(row2, row3); __m128 tmp3 _mm_unpackhi_ps(row2, row3); // 最终组合成列向量并连续写出 __m128 col0 _mm_movelh_ps(tmp0, tmp2); __m128 col1 _mm_movehl_ps(tmp2, tmp0); __m128 col2 _mm_movelh_ps(tmp1, tmp3); __m128 col3 _mm_movehl_ps(tmp3, tmp1); _mm_storeu_ps(dst 0 * ldb, col0); _mm_storeu_ps(dst 1 * ldb, col1); _mm_storeu_ps(dst 2 * ldb, col2); _mm_storeu_ps(dst 3 * ldb, col3); }转置后的 GEMM 内层循环完全变为两条纯粹的连续加载与融合乘加FMA// A 的第 i 行连续片段 与 B^T 的第 j 行连续片段 进行点积 __m256 va _mm256_loadu_ps(A i * K k); __m256 vb _mm256_loadu_ps(B_transposed j * K k); v_acc _mm256_fmadd_ps(va, vb, v_acc); // 纯向量单周期高速吞吐四、外积广播计算中的排布逆转除了转置矩阵 $B$现代高性能算子还有另一种更为主流的设计范式外积更新Outer Product与标量广播。在外积计算范式中矩阵 $A$ 按列加载矩阵 $B$ 保持行优先按行加载每次从 $A$ 中提取一个标量使用_mm256_set1_ps广播到整个 256 位寄存器中然后直接与矩阵 $B$ 连续的整行向量执行 FMA 乘加累加到矩阵 $C$ 的对应行上。在这种计算模式下矩阵 $B$ 保持行优先反而是最完美的因为每一次对 $B$ 的访问本身就是整行连续加载根本不需要做昂贵的全局转置。五、工程选型指南小矩阵与低延迟场景小 Batch不要盲目进行矩阵转置。转置本身需要遍历全内存在矩阵较小时转置本身的开销甚至超过了计算本身。此时优先采用外积广播模型利用_mm256_set1_ps消除跨步开销。超大矩阵计算密集型场景提前对权重矩阵做预打包Pre-pack。在大模型推理中权重在离线加载时直接在内存中重排为列优先的分块面板Packed Panels使在线推理完全享受极致的连续向量加载吞吐。时刻对齐物理连续性SIMD 向量化优化的本质就是消除一切非连续内存访问让处理器的内存加载单元始终保持在最大并发带宽的饱和状态。