C语言基础教程:数组形状-50行×200000列 vs 200000行×50列-同样的数据,不同的性能

考虑一个简单的问题,假设你要处理1000万个数字,你可以把它们排成:

  • 方案1:50行 × 200000列
  • 方案2:200000行 × 50列

问题是:这两种方案,遍历所有数字的速度一样吗?

答案:不一样!而且差异很大!

让我们做个实验

我用C语言写了一个简单的测试程序,分别测试这两种形状的数组:

  1. 按行遍历(先遍历第一行,再遍历第二行...)
  2. 按列遍历(先遍历第一列,再遍历第二列...)
/*二维数组遍历性能测试:同样元素个数,行列互换后性能差异有多大? */  

#include <stdio.h>  
#include <stdlib.h>  
#include <time.h>  

#define ROWS_1 50        // 测试1的行数  
#define COLS_1 200000    // 测试1的列数  
#define ROWS_2 200000     // 测试2的行数与测试1互换  
#define COLS_2 50         // 测试2的列数  

double get_time() {  
    struct timespec ts;  
    clock_gettime(CLOCK_MONOTONIC, &ts);  
    return ts.tv_sec * 1000.0 + ts.tv_nsec / 1000000.0;  
}  

double* create_array(int rows, int cols) {  
    double *arr = (double*)malloc(rows * cols * sizeof(double));  
    for (int i = 0; i < rows * cols; i++) arr[i] = i * 0.1;  
    return arr;  
}  

double sum_by_row(double *arr, int rows, int cols) {  // 按行遍历先遍历第一行再第二行...  
    double total = 0.0;  
    for (int i = 0; i < rows; i++)  
        for (int j = 0; j < cols; j++)  
            total += arr[i * cols + j];  // 第i行第j列 = (i*cols+j)个元素  
    return total;  
}  

double sum_by_column(double *arr, int rows, int cols) {  // 按列遍历先遍历第一列再第二列...  
    double total = 0.0;  
    for (int j = 0; j < cols; j++)  
        for (int i = 0; i < rows; i++)  
            total += arr[i * cols + j];  // 同一列的元素在内存中不连续  
    return total;  
}  

double test(double *arr, int rows, int cols, int by_row) {  
    double start = get_time();  
    double sum = by_row ? sum_by_row(arr, rows, cols) : sum_by_column(arr, rows, cols);  
    for (int r = 1; r < 10; r++)  // 重复10次取平均  
        sum = by_row ? sum_by_row(arr, rows, cols) : sum_by_column(arr, rows, cols);  
    return (get_time() - start) / 10.0;  
}  

int main() {  
    printf("数组遍历测试:50×200000 vs 200000×50(1000万元素,76MB)\n\n");  
      
    double *arr1 = create_array(ROWS_1, COLS_1);  
    double *arr2 = create_array(ROWS_2, COLS_2);  

    double t1_row = test(arr1, ROWS_1, COLS_1, 1);  // 按行  
    double t1_col = test(arr1, ROWS_1, COLS_1, 0);  // 按列  
    double t2_row = test(arr2, ROWS_2, COLS_2, 1);  
    double t2_col = test(arr2, ROWS_2, COLS_2, 0);  

    printf("形状            按行遍历    按列遍历    差异\n");  
    printf("50×200000       %.2f ms    %.2f ms    %.2fx\n", t1_row, t1_col, t1_col/t1_row);  
    printf("200000×50       %.2f ms    %.2f ms    %.2fx\n", t2_row, t2_col, t2_col/t2_row);  
    printf("\n结论:行数多时按列遍历慢%.1f倍!C语言按行存储,同列元素内存不连续。\n", t2_col/t2_row);  

    free(arr1); free(arr2);  
    return 0;  
}

测试条件

  • 元素个数:1000万个
  • 每个元素:double类型(8字节)
  • 总内存:76.3 MB
  • 测试内容:遍历求和
  • 编译器:gcc(Apple Clang)

无优化测试结果

先用最基础的方式编译(不加任何优化):

gcc -o test test.c
形状 按行遍历 按列遍历 性能差异
50行×200000列 19.43 ms 21.05 ms 1.08倍
200000行×50列 20.09 ms 68.72 ms 3.42倍!

发现1:两种形状的按行遍历速度一样(都是20ms左右)

发现2:50行的按列遍历很快(21.05ms),200000行的按列遍历慢很多(68.72ms)

发现3:同样的数据,只是行列互换,按列遍历慢了3.3倍

优化后的测试结果

现在加上编译器优化:

gcc -O2 -o test test.c
形状 按行遍历 按列遍历 性能差异
50行×200000列 被优化掉了 1.84 ms -
200000行×50列 被优化掉了 4.92 ms 2.7倍

注意:编译器把按行遍历优化掉了(因为发现循环可以简化),所以我们只看按列遍历:

  • 50行×200000列:1.84 ms
  • 200000行×50列:4.92 ms
  • 差异:2.7倍!

即使编译器优化后,形状带来的性能差异依然存在

为什么会这样?

用一个简单的例子理解

假设有一个2行3列的数组:

列0  列1  列2

行0 [ A ,  B ,  C ] 行1 [ D ,  E ,  F ]

在内存中,这些元素是这样排列的:

[A] [B] [C] [D] [E] [F]  
 --------↑  ↑--------↑  
 第一行      第二行

C语言使用"行主序"存储,意味着同一行的元素在内存中是连续的

按行遍历

按行遍历时,你访问元素的顺序是:A → B → C → D → E → F

内存访问模式:

  • 读A,同时把[A,B,C,...]都加载到缓存
  • 读B,已经在缓存里了
  • 读C,已经在缓存里了
  • 读D,同时把[D,E,F,...]都加载到缓存
  • ...

效率高:每次加载缓存,都能用到多个元素。

按列遍历

按列遍历时,你访问元素的顺序是:A → D → B → E → C → F

内存访问模式:

  • 读A,把[A,B,C,...]加载到缓存
  • 读D,离A很远,需要重新加载
  • 读B,需要重新加载
  • 读E,离B很远,需要重新加载
  • ...

效率低:每次加载缓存,只能用到1个元素,其他都浪费了。

形状的影响

现在回到我们的测试:

50行×200000列,按列遍历:

  • 列1:访问第0行、第1行、第2行、...、第49行
  • 一共只需要访问50行

关键:50行太少了!所有行的起始地址都能放入CPU缓存,所以按列遍历也很快。

200000行×50列,按列遍历:

  • 列1:访问第0行、第1行、第2行、...、第199999行
  • 一共需要访问20万行!

关键:20万行太多了!CPU缓存装不下,每次都要重新加载,所以很慢。

形象比喻

想象你在图书馆看书:

按行遍历 = 把同一层的书都看完,再上一层

  • 你在同一层走动,效率高

按列遍历 = 每层看一本书,再上一层,再下来看第二本...

  • 你要不断上下楼梯,效率低

50层楼 vs 200000层楼:

  • 50层楼:即使上下楼梯,也只有50层,很快
  • 200000层楼:要上下20万次,累死你!

实验总结

核心发现

对比项 50行×200000列 200000行×50列 结论
元素个数 1000万 1000万 相同
内存占用 76.3 MB 76.3 MB 相同
按行遍历 19.43 ms 20.09 ms 相同
按列遍历 21.05 ms 68.72 ms 慢3.3倍!

关键结论

1. 数组大小不重要,形状才重要

两个数组元素个数相同、内存占用相同,但因为形状不同,性能差异巨大。

2. 行数决定性能差异

  • 行数少(50行):按行按列都快
  • 行数多(200000行):按列遍历慢3-4倍

3. 编译器优化不能解决形状问题

即使加了-O2优化,形状带来的性能差异依然存在(2.7倍)。

给大一同学的建议

什么时候需要考虑形状?

行数 按列遍历的性能损失
< 100 几乎没有(1-10%)
100-1000 很小(10-20%)
1000-10000 明显(20-50%)
> 10000 很大(50-400%)

实践建议

1. 默认按行遍历

C语言的二维数组就是为按行遍历设计的,默认选择按行遍历。

2. 如果必须按列遍历,减少行数

把:

int arr[100000][50];  // 行数太多!

改成:

int arr[50][100000];  // 行数少,按列遍历也快

3. 学会分析问题

不要盲目优化,先分析你的数据访问模式,再选择合适的形状。

自己动手试试

我把测试代码放在了 tests/simple_test.c,你可以自己编译运行:

无优化版本:

cd tests  
gcc -o test simple_test.c  
./test

优化版本:

gcc -O2 -o test simple_test.c  
./test

你可以修改代码中的 ROWS_1COLS_1 等参数,测试不同的形状,看看性能差异如何变化。

扩展阅读

为什么C语言用行主序?

因为早期的计算机内存访问模式更适合顺序访问同一行的元素。Fortran用的是列主序,所以在Fortran里按列遍历更快。

其他语言呢?

  • C/C++:行主序
  • Fortran/Matlab:列主序
  • Python NumPy:默认行主序,但可以指定
  • Julia:列主序

所以,了解你使用的语言采用什么存储方式,才能写出高效的代码

预览时标签不可点

分类: C