考虑一个简单的问题,假设你要处理1000万个数字,你可以把它们排成:
- 方案1:50行 × 200000列
- 方案2:200000行 × 50列
问题是:这两种方案,遍历所有数字的速度一样吗?
答案:不一样!而且差异很大!
让我们做个实验
我用C语言写了一个简单的测试程序,分别测试这两种形状的数组:
- 按行遍历(先遍历第一行,再遍历第二行...)
- 按列遍历(先遍历第一列,再遍历第二列...)
/*二维数组遍历性能测试:同样元素个数,行列互换后性能差异有多大? */
#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_1、COLS_1 等参数,测试不同的形状,看看性能差异如何变化。
扩展阅读
为什么C语言用行主序?
因为早期的计算机内存访问模式更适合顺序访问同一行的元素。Fortran用的是列主序,所以在Fortran里按列遍历更快。
其他语言呢?
- C/C++:行主序
- Fortran/Matlab:列主序
- Python NumPy:默认行主序,但可以指定
- Julia:列主序
所以,了解你使用的语言采用什么存储方式,才能写出高效的代码。
预览时标签不可点