常数优化技巧

前言

相同渐进时间复杂度的算法,实际运行速度可能不尽相同,这是因为在讨论渐进时间复杂度时我们会使用大 O O O 记号忽略掉常数因子所带来的影响
例如,扫描 1 1 1 遍数组和扫描 10 10 10 遍数组的渐进复杂度是相同的,但是后者明显会花费更多的时间: 0.5 0.5 0.5 秒和 5 5 5 秒这两种运行结果对于同样是常数的时间限制来说是不同的。类似的例子还有树状数组、线段树和平衡树:尽管它们的结构、功能、复杂度类似,但是速度上存在着明显的差异。
与此同时,计算机的体系结构特性也会导致不同代码性能上存在差距。举例而言,对于矩阵乘法来说,一种实现是这样的:

const int N = 512, n = 512;
void multiply_ijk(int c[N][N], int a[N][N], int b[N][N]) { // c = a * b
	memset(c, 0, sizeof(int) * N * N);
	for (int i = 0; i < n; i++)
		for (int j = 0; j < n; j++)
			for (int k = 0; k < n; k++)
				c[i][j] += a[i][k] * b[k][j];
	return ;
}

还有一种实现是这样的:

const int N = 512, n = 512;
void multiply_ikj(int c[N][N], int a[N][N], int b[N][N]) {
	memset(c, 0, sizeof(int) * N * N);
	for (int i = 0; i < n; i++)
		for (int k = 0; k < n; k++)
			for (int j = 0; j < n; j++)
				c[i][j] += a[i][k] * b[k][j];
	return ;
}

上面两份在吗仅仅交换了两层循环,看起来几乎完全一致。然而,将这两份代码在测测你的矩阵乘法一题中提交以下的话可以发现:它们的性能存在显著差异

方法时间(C++20 O2)
multiply_ijk 249.8 m s 249.8\rm ms 249.8ms
multiply_ikj 79 m s 79\rm ms 79ms

我们将在后面了解到,后者相比前者能够更好地利用计算机的缓存。

算法竞赛形式是编写代码实现算法,即使渐进复杂度正确也不一定能够通过;与此同时,渐进复杂度大也不一定超时。因此,掌握算法设计和理论分析固然是学习算法竞赛的首要目标,追求程序实现高效亦是有价值的。

以下是一些对于优化的忠告:

  1. “过早的优化是万恶之源” [ 1 ] ^{[1]} [1]
    提前考虑优化技巧可能会破坏程序的可读性,并会因为额外的代码引入潜在的 b u g \rm bug bug。因此,请考虑在比赛时写出一份最直白、好些的程序,保证正确性后在对其进行修改来优化参数。
  2. 只进行有效的优化
    只有对真正耗时高的部分优化是有效的。我们对程序进行常数优化时,重点应放在最占时间的部分上,如循环内部、被调用次数最多的函数以及递归的尾巴等。如果花费精力优化运行时间占比较小的部分,那么不仅浪费代码时间、将代码变得更加晦涩难懂,而且收益甚微。
    你可以在程序内加入计时器来计算每部分的耗时,以判断哪部分最需要优化。通常你可以使用以下代码来简单测试一个函数使用的时间,在 W i n d o w s \rm Windows Windows 上输出的是以毫秒为单位的时间,在 L i n u x \rm Linux Linux 上则是以为微秒为单位的时间。
time_t start = clock();
// 这里写测试代码
time_t duration = clock() - start;
cerr << "time = " << duration << endl;

如果你需要更精细的测量,可以使用 C++11 \text{C++11} C++11 以后有的 <chrono> 头文件里提供的纳秒级别时钟。以下代码输出的是以秒为单位的小数时间。

auto start = chrono::system_clock::now().time_since_epoch().count();
// 这里写测试代码
auto duration = chrono::system_clock::now().time_since_epoch().count() - start;
cerr << "time = " << duration / 1e9 << endl;

在测速比较两个方法时,需要做好控制变量。例如,应当用同样的输入数据和相同的编译器选项;尽可能规避生成数据、输入输出、其他程序占用 C P U \rm CPU CPU 等可能引起测速不稳定的因素;重复测试几次以规避而然因素等。
如果你习惯使用外部工具来检查的话, g c c \rm gcc gcc 中提供的 g p r o f \rm gprof gprof 或许是个不错的选择,这个工具可以显示每个函数花费的时间——尽管在调试的优化等级下每个函数占比和开启优化开关后略有不同。

  1. 不要局限于常数优化
    有时,我们可能因为提交代码恰好超时一点而抱怨出题人卡常数。事实上,看起来卡常数的题目或许存在更优秀的解法。对较差复杂度算法进行常数优化来提高分数只是比赛策略,学习更好的算法仍然是最重要的

接下来我们介绍一些常数优化的技巧,并分析这么做的原因。

读写优化

计算复杂度时,输入输出复杂度很容易被忽略。
例如,某题目想考察 O ( n ) O(n) O(n) 复杂度的算法,因此要求输入超过 1 0 6 10^6 106 个整数。此时,读入这些数的复杂度就已经达到了 O ( n log ⁡ W ) O(n\log W) O(nlogW),其中 W W W 为值域,取常用的 W = 1 0 9 W=10^9 W=109 时输入文件可能超过 10   M i B 10\space\rm MiB 10 MiB。这意味着,尽管主程序可能很快,但是读入却需要处理超过 1 0 7 10^7 107 个字符的时间。
标准库提供了强大的输入输出方法。但是,出于设计与安全上的原因,它们的性能不一定能满足我们的要求,因此在面对巨大的输入输出文件时可能需要考虑优化。

结论:

  • 通常来说,不需要特别在意读写优化。
  • 如果习惯 cin/cout 的话,在文件较大时需要记得关闭流同步、使用 \n 而不是 endl
  • 输入输出文件大小十分极端时,才需要考虑手写读写优化。这通常意味着输入输出文件超过了 1 0 6 10^6 106int:即使在这个量级下,手写输入与标准库的差距任通常不到 0.1 s 0.1\rm s 0.1s

分析:
我们通常使用的 scanf/printfcin/cout 在算法竞赛时性能并不一定能使最优的:

  • 通用库功能强大,但代价是会做一些额外的安全性保障,例如异常处理、线程安全等。
  • s c a n f / p r i n t f \rm scanf/printf scanf/printf 需要在程序运行时解析格式串,这会花费不少时间。
  • c i n / c o u t \rm cin/cout cin/cout 为了和 s c a n f / p r i n t f \rm scanf/printf scanf/printf s t d i o \rm stdio stdio 库同步,内部缓冲区被禁用,写入或读取每个字符时都需要与 s t d i o \rm stdio stdio 交互。因此,即使 c i n / c o u t \rm cin/cout cin/cout 在编译期确定读入格式,性能也通常比 s c a n f / p r i n t f \rm scanf/printf scanf/printf 差。
    当然,可以通过以下代码关闭流同步来提升 cin/cout 性能。关闭流同步后,cin/cout 会拥有自己的缓冲区,与 scanf/printf 混用会带来异常。
std::ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
  • cout << endl 会使用系统调用刷新输出缓冲区,输出大量换行时非常缓慢。
    如果需要手写输入函数,使用 getchar 读取整数是一个有效减少输入时间的手段。
int read() {
	int ret = 0, sgn = 0, ch = getchar();
	while (!isdigit(ch)) sgn |= ch == '-', ch = getchar(); // 假设 getchar 必然成功
	while (isdigit(ch)) ret = ret * 10 + ch - '0', ch = getchar();
	return sgn ? -ret : ret;
}

以上是一种可能的实现。有以下几点需要注意:

  • 在输入文件到达文件结束时继续读取,getchar() 会返回 -1,导致死循环。如果遇到输入可能不完整的情况,需要在第一个 while 里判断 EOF
  • 在读取 − 2 32 -2^{32} 232 时,尽管返回值通常是正确的,但是 ret 会整形溢出,出发未定义行为。
  • 输入均为整数时,可以删掉 sgn 相关语句。如果不习惯写 sgn,读负数时需要千万注意!!!

上面的程序中 getchar() 仍可以优化。 s t d i o \rm stdio stdio 库里维护了一个缓冲区,以减少读取硬盘的次数:我们可以自己调用 fread,来进一步减少读取硬盘的次数和标准库边界检查的shij。标准库里通常缓冲区的大小为 8   K i B 8\space\rm KiB 8 KiB,事实上这是一个合理的大小:更大的缓冲区性能提升非常小。我们可以选择稍大一些的 2 2 2 的次幂作为缓冲区的大小,以同时兼顾硬盘性能、缓存友好和内存开销。

const int SIZE = 1 << 14; // 16 KiB
char getc() {
	static char buf[SIZE], *begin = buf, *end = buf;
	if (begin == end) {
		begin = buf;
		end = buf + fread(buf, 1, SIZE, stdin); // 假设 fread 必然成功
	}
	return *begin++;
}

使用以上代码中的 getc() 函数替代 getchar(),可以获得部分性能提升。
如果评测系统为 L i n u x \rm Linux Linux,可以考虑直接使用 getchar_unlocked() 替代 getchar(),效率接近 fread。这是一种放弃了线程安全的函数,但是对于算法竞赛来说没有弊端。
手写输出和输入并没有本质差别,惟需注意一点:如果自己维护了缓冲区,那么在程序结束时一定要记得清空缓冲区。
本地测试读入 2 × 1 0 6 2\times10^6 2×106 1 0 9 10^9 109 以内的随机数( 19.4   M i B 19.4\space\rm MiB 19.4 MiB),结果如下:

方法时间(C++17 O2)
cin 440 m s 440\rm ms 440ms
cin 关闭同步 120 m s 120\rm ms 120ms
scanf 120 m s 120\rm ms 120ms
getchar 53 m s 53\rm ms 53ms
getchar_unlocked(Linux) 39 m s 39\rm ms 39ms
fread 37 m s 37\rm ms 37ms

再关闭流同步时,计算 1 0 6 10^6 106 A + B \rm A+B A+B 问题并输出,值域 1 0 9 10^9 109,测试结果如下:

方法时间(C++17 O2)
endl 652 m s 652\rm ms 652ms
\n 180 m s 180\rm ms 180ms

读写优化参考:快读&快写模板【附O2优化】

缓存优化

很多时候我们认为常数优化时“玄学”:一点微小的实现差别可能带来截然不同的性能表现,甚至相同的代码在不同的评测环境下都可能有不同的表现。但是,常数理应是可以分析的,这种分析很多时候都取决于计算机体系结构与操作系统相关的知识。

结论:

要利用好缓存的特性,简而言之,理想的状态是访问缓存存得下的一段连续内存;其次是连续访问的内存;再次是随机访问的内存与完全不连续的访问;最坏情况下是接连访问地址低位完全相同的不同地址,例如同时访问多个大小为 2 2 2 的次幂的数组的相同下标。通常来说,可以在数组最低维是 2 2 2 的高次幂时多开 1 1 1,以很小的代价规避这样的问题。

分析:
我们从最简单的 a + b \rm a+b a+b 程序出发。

int a, b, c;
int main() {
	scanf("%d %d", &a, &b);
	c = a + b;
	printf("%d\n", c);
}

c = a + b 这一句在 G C C   9.3.0 \rm GCC\space9.3.0 GCC 9.3.0(不开优化)下,这句话被翻译为:

mov edx, DWORD PTR a[rip]; 从 a 的地址读取值存放到 edx
mov eax, DWORD PTR b[rip]; 从 b 的地址读取值存放到 eax
add eax, edx; eax += edx
mov DWORD PTR c[rip], eax; 将 eax 存放到 c 的地址

代码里出现的 eax edx 被称为寄存器( R e g i s t e r \rm Register Register。寄存器内置在 C P U \rm CPU CPU 里,通常只有几十个。 C P U \rm CPU CPU 只能直接对寄存器进行计算,所有经过 C P U \rm CPU CPU 处理的数据都需要被加载到寄存器里;同时,寄存器也是数据处理的最小单位。
C P U \rm CPU CPU 会花一个固定的时间来进行一系列基本操作,这个固定的时间被称为 C P U \rm CPU CPU周期,通常说的 C P U \rm CPU CPU 频率就是每秒执行的周期数。理想情况下,整数的加减法,位运算等简单的指令需要花费一个周期,对于整数乘除法、浮点数操作等复杂的指令则需要更多的时间。
寄存器非常珍贵,应该让最需要被处理的数据使用,而其他变量和常量储存在内存中,例如上面程序里的三个全局变量:mov 指令的作用是读取和写入内存。内存容量很大,但是由于在 C P U \rm CPU CPU 外,所以访问起来相对比较慢:内存典型延迟接近 100 n s 100\rm ns 100ns,而如今 C P U \rm CPU CPU 典型频率在 4 G H z 4\rm GHz 4GHz 左右,这意味着一次读写内存会花去几百个周期。
为了加速内存访问, C P U \rm CPU CPU 中引入了缓存( C a c h e \rm Cache Cache,存取最近用到的一部分数据。缓存利用了程序运行时的时空局部性来加速运算:程序运行时关心的很多数据都是连续访问的。缓存会依据访问地址按块加载访问目标附近的数据(称为缓存行,常见为 64 B \rm 64B 64B)以减少内存访问,并将剩余地址位划分为高位标签( t a g \rm tag tag)和低位索引( i n d e x \rm index index)两部分,对于最简单的缓存模型来说,访问时会查缓存里下标为 i n d e x \rm index index 的位置是否存在 t a g \rm tag tag,若存在则直接交给缓存处理,称为缓存命中;否则成为缓存未命中,继续交给内存来处理,并在内存访问后替换 i n d e x \rm index index 的内容。
因此,如果 i n d e x \rm index index 冲突非常多,也就是地址低位相同的访问很集中,那么缓存效率将会大大降低,而这种降低通常体现为访问步长为 2 2 2 的次幂间隔的内存引起的,这也就是不建议将二维数组低维开 2 2 2 的高次幂的原因:连续访问低维相同的下标相同的内容会引起严重的缓存冲突。相比之下,随机访问引起的问题仅仅是缓存行的利用率低,并不会达到最坏情况。
从更高层次的观点来说,可以把整个储存结构看作一个缓存的结构,每层是下一层的缓存。缓存之于寄存器,与内存之于缓存、硬盘之于内存本质相同:越靠近 C P U \rm CPU CPU 则越快,越小,同时越昂贵;离 C P U \rm CPU CPU 越远,容量越大,同时相对更便宜。硬盘的读写也有类似缓存行的概念,叫做扇,这也是读入优化是缓冲区大小取 4   K i B 4\space\rm KiB 4 KiB 的倍数的原因:现代机械硬盘的扇区大小是 4   K i B 4\space\rm KiB 4 KiB

下表是 2018 2018 2018 年之后 N O I \rm NOI NOI 系列比赛中评测机的( C P U \rm CPU CPU I n t e l \rm Intel Intel 酷睿 i 7   8700 k \rm i7\space 8700k i7 8700k,主频 3.70 G H z \rm 3.70GHz 3.70GHz;内存 32 G i B 32\rm GiB 32GiB)的相关参数 [ 2 ] ^{[2]} [2]

位置大小访问延迟
寄存器 < 1 K i B <1\rm KiB <1KiB一个周期( ≈ 0.27 n s \approx0.27\rm ns 0.27ns
L 1 \rm L1 L1 缓存每核 32   K i B 32\rm\space KiB 32 KiB 指令, 32   K i B 32\rm\space KiB 32 KiB 数据 ≈ 1 n s \approx1\rm ns 1ns
L 2 \rm L2 L2 缓存 1.5   M i B 1.5\space\rm MiB 1.5 MiB ≈ 4 n s \approx4\rm ns 4ns
L 3 \rm L3 L3 缓存 12   M i B 12\space\rm MiB 12 MiB L 2 \rm L2 L2 10 10 10
主存 32   G i B 32\space\rm GiB 32 GiB L 3 \rm L3 L3 2 2 2
硬盘-比主存慢 100 100 100 倍起

关于缓存优化的实际应用,请待更新。

注释

[ 1 ] [1] [1]:这是《计算机程序设计的艺术》的作者 Donald Kunth 的名言。
[ 2 ] [2] [2]:评测机处理器型号在 N O I \rm NOI NOI 官网上公布; C P U \rm CPU CPU 的具体参数可以在英特尔官网上查到

  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三日连珠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值