跳转到内容

C顺序与Fortran顺序

理解 NumPy 数组的内存布局对于优化计算性能和理解 PyTorch、TensorFlow 等深度学习框架的底层行为至关重要。NumPy 支持两种基本的内存布局:C 顺序(C-order,也称为行优先)和 Fortran 顺序(Fortran-order,也称为列优先)。这种区别直接影响数组的遍历方式、内存访问模式,以及与底层 BLAS 库的交互效率。在训练大型语言模型时,选择正确的内存布局可以显著提升数据加载和计算的效率。

内存布局的基本概念

计算机内存是一维的线性地址空间,但 NumPy 数组是多维的。将多维数组映射到一维内存时,有两种主要方式:

C 顺序(行优先):在最右边的维度上连续存储元素。这意味着第 0 维(行)的变化最慢,最后一维(列)的变化最快。相当于 C/C++、Python 默认的二维数组布局。

Fortran 顺序(列优先):在最左边的维度上连续存储元素。这意味着第 0 维(行)的变化最快,最后一维(列)的变化最慢。相当于 Fortran、MATLAB 的默认布局。

python
import numpy as np

# 创建一个 2x3 的数组
arr = np.array([[1, 2, 3],
                [4, 5, 6]])

print(f"数组:\n{arr}")
print(f"数组形状: {arr.shape}")

# C 顺序:按行展开
print(f"\nC 顺序展开: {arr.ravel(order='C')}")  # [1,2,3,4,5,6]
# Fortran 顺序:按列展开
print(f"Fortran 顺序展开: {arr.ravel(order='F')}")  # [1,4,2,5,3,6]

数组的 flags 属性

NumPy 数组的 flags 属性揭示了其内存布局的详细信息:

python
arr = np.array([[1, 2, 3], [4, 5, 6]])

print("数组 flags:")
print(f"  C_CONTIGUOUS (C顺序): {arr.flags['C_CONTIGUOUS']}")
print(f"  F_CONTIGUOUS (Fortran顺序): {arr.flags['F_CONTIGUOUS']}")
print(f"  内存地址: {arr.ctypes.data}")

一个数组可以同时是 C-contiguous 和 F-contiguous(如 1-D 数组),但对于多维数组,通常只有一个为 True。

python
# 2x3 数组:C 顺序为 True,Fortran 顺序为 False
arr = np.array([[1, 2, 3], [4, 5, 6]])
print(f"形状 {arr.shape}: C={arr.flags['C_CONTIGUOUS']}, F={arr.flags['F_CONTIGUOUS']}")

# 转置后变成 3x2:C 顺序变为 False,Fortran 顺序变为 True
arr_t = arr.T
print(f"形状 {arr_t.shape}: C={arr_t.flags['C_CONTIGUOUS']}, F={arr_t.flags['F_CONTIGUOUS']}")

创建指定顺序的数组

使用 np.ascontiguousarraynp.asfortranarray 可以确保数组使用特定的内存布局:

python
# 确保 C 顺序(行优先)
arr_c = np.ascontiguousarray(arr)
print(f"C 顺序: {arr_c.flags['C_CONTIGUOUS']}")

# 确保 Fortran 顺序(列优先)
arr_f = np.asfortranarray(arr)
print(f"Fortran 顺序: {arr_f.flags['F_CONTIGUOUS']}")

在创建数组时指定顺序:

python
# 直接指定顺序创建数组
arr_c = np.array([[1, 2], [3, 4], [5, 6]], order='C')
arr_f = np.array([[1, 2], [3, 4], [5, 6]], order='F')

print(f"C 顺序数组:\n{arr_c}\nflags C={arr_c.flags['C_CONTIGUOUS']}")
print(f"\nFortran 顺序数组:\n{arr_f}\nflags F={arr_f.flags['F_CONTIGUOUS']}")

步长(Strides)

内存布局的详细信息可以通过 strides 属性查看。strides 指示在每个维度上移动到下一个元素需要跳过多少字节:

python
arr = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float64)

print(f"数组形状: {arr.shape}")
print(f"元素大小: {arr.dtype.itemsize} bytes")
print(f"strides: {arr.strides}")  # (24, 8) 表示行间跳24字节,列间跳8字节

# C 顺序:(n_bytes_per_row, n_bytes_per_element)
# Fortran 顺序:(n_bytes_per_element, n_bytes_per_col)

在LLM场景中的应用

Transformer 的注意力矩阵

在 Transformer 的注意力机制中,我们计算 Q、K、V 矩阵。理解内存布局有助于优化这些矩阵运算:

python
def compute_attention_scores(Q, K, attention_mask=None):
    """计算注意力分数

    Q, K: (batch, heads, seq_len, head_dim)
    返回: (batch, heads, seq_len, seq_len)
    """
    # 矩阵乘法:Q @ K^T
    # 如果 Q 是 (B, H, L, D),K 是 (B, H, L, D)
    # Q @ K^T 需要 Q 的最后两维和 K 的最后两维
    # 使用 transpose 确保维度正确

    # 假设 Q, K 都是 C 连续(通常情况)
    scores = np.matmul(Q, K.transpose(0, 1, 3, 2))

    # 应用注意力掩码(如果有)
    if attention_mask is not None:
        scores = scores + attention_mask

    return scores

# 示例
batch_size, heads, seq_len, head_dim = 2, 8, 128, 64
Q = np.random.randn(batch_size, heads, seq_len, head_dim)
K = np.random.randn(batch_size, heads, seq_len, head_dim)

# NumPy 默认使用 C 顺序存储
print(f"Q 的内存布局: C={Q.flags['C_CONTIGUOUS']}, F={Q.flags['F_CONTIGUOUS']}")
print(f"Q 的 strides: {Q.strides}")

PyTorch/TensorFlow 的底层存储

PyTorch 和 TensorFlow 在底层都使用 C 顺序存储张量。这意味着当你从 NumPy 数组转换为 PyTorch 张量时:

python
import torch

# NumPy 数组默认是 C 顺序
np_array = np.random.randn(32, 768)
print(f"NumPy: C={np_array.flags['C_CONTIGUOUS']}")

# 转换为 PyTorch 张量 - 高效,因为布局匹配
torch_tensor = torch.from_numpy(np_array)
print(f"PyTorch 张量布局: {torch_tensor.stride()}")
# PyTorch 默认也是 C 顺序,所以 stride 应该是 (768, 1)

如果 NumPy 数组是 Fortran 顺序的,PyTorch 会创建一个新的 C 连续张量(触发复制)。

性能考量

不同的内存布局会影响缓存命中率和计算效率:

BLAS/LAPACK 期望:底层线性代数库(如 OpenBLAS、MKL)针对不同的操作期望不同的内存布局。矩阵乘法 gemm 在 C 顺序下通常更高效,因为数据局部性更好。

python
import time

A = np.random.randn(1000, 1000)
B = np.random.randn(1000, 1000)

# C 连续矩阵乘法
A_c = np.ascontiguousarray(A)
B_c = np.ascontiguousarray(B)

start = time.time()
for _ in range(10):
    C = A_c @ B_c
print(f"C 顺序矩阵乘法: {time.time()-start:.3f}s")

避免不必要的数据转换:在数据流水线上,保持一致的内存布局可以避免隐式的内存复制,提高吞吐量。

python
# 检查并转换
if not A.flags['C_CONTIGUOUS']:
    A = np.ascontiguousarray(A)

常见误区

误区一:混淆数组顺序和轴顺序

内存布局(order)指的是元素在内存中如何连续存储,与数组的维度顺序(shape)是两回事。一个 (3, 4, 5) 形状的数组可以是 C 连续或 Fortran 连续,取决于内存中元素的排列方式。

python
arr = np.zeros((3, 4, 5), order='C')
print(f"形状 {arr.shape}, C={arr.flags['C_CONTIGUOUS']}, strides={arr.strides}")

arr_f = np.zeros((3, 4, 5), order='F')
print(f"形状 {arr_f.shape}, F={arr_f.flags['F_CONTIGUOUS']}, strides={arr_f.strides}")

误区二:假设转置不创建副本

transpose 返回一个视图(view),不复制数据,但会改变 strides。T.transpose() 改变维度顺序,但不改变内存布局。

python
arr = np.random.randn(3, 4)
arr_t = arr.T  # 转置

print(f"原始: shape={arr.shape}, C={arr.flags['C_CONTIGUOUS']}")
print(f"转置: shape={arr_t.shape}, C={arr_t.flags['C_CONTIGUOUS']}")
print(f"共享内存: {np.shares_memory(arr, arr_t)}")

误区三:忽视连续性检查

在性能敏感代码中,应该检查数组是否连续,避免隐式复制:

python
def process_array(arr):
    """处理数组,如果需要则转换"""
    if not arr.flags['C_CONTIGUOUS']:
        arr = np.ascontiguousarray(arr)
    # 然后进行高效处理
    return arr

API 总结

函数描述
arr.ravel(order='C')展平数组,指定顺序
np.ascontiguousarray(arr)确保数组是 C 连续
np.asfortranarray(arr)确保数组是 Fortran 连续
arr.flags['C_CONTIGUOUS']检查是否是 C 连续
arr.strides查看步长信息
np.shares_memory(a, b)检查两个数组是否共享内存

理解 C 顺序和 Fortran 顺序的区别,能帮助你更好地理解深度学习框架的底层行为,并在数据处理流水线上做出更高效的实现选择。

基于 MIT 许可发布