C++ 中的循环展开

2025年5月13日 | 阅读 9 分钟

C++ 循环展开简介

程序员用于提高性能的最后一种技术被称为循环展开。循环展开是一种提高循环处理速度并同时消除某些迭代指令的技术。简而言之,循环展开以每次迭代执行的指令数来交换循环迭代的次数。这可以显著提高索引性能,尤其是在应用程序需要大量计算周期的情况下,例如科学计算、图形处理和高性能数据处理。但是,要了解循环展开为什么有效,我们需要了解普通循环的形式以及其底层的开销。

什么是循环开销,为什么它很重要?

在传统的循环中,例如 C++ 中的 for 循环,有几个关键组成部分:

  • 初始化: 初始化循环最重要的元素之一——循环控制变量的过程。
  • 条件检查: 确定是继续执行循环还是停止。
  • 循环控制变量更新: 每次迭代后修改循环变量(例如,在 for (int i = 0; i < n; ++i) 中增加 'i' 的值)。
  • 循环体执行: 当我运行循环内的代码块时会发生什么?

在小型循环中,检查循环条件和更新循环变量所需的时间并不显著。然而,当循环在大型应用程序中执行数百万甚至数十亿次时,这些前置控制操作的成本很高。每次执行一次,在条件检查、分支和增量方面都会产生少量的计算成本。第三,当今的大多数处理器都经过优化,可以同时运行指令,这被称为指令级并行。然而,传统循环的结构有时不允许处理器充分利用此功能,例如,如果循环体的长度只有几条指令。

手动与编译器自动循环展开

我的意思是,循环展开可以手动执行,就像上面提到的那样,也可以由编译器执行。包括 GCC、Clang 和 MSVC 在内的现代编译器都能够根据某些条件对循环进行完全自动展开的优化。例如,使用 GCC 编译器,如果将优化级别设置为 -O3,它会指示编译器优化代码大小,并且它会检查的要求之一是,如果编译器认为合适,它将展开循环。

然而,编译器执行的展开仍然存在其缺点。一些编译器在使用展开时可能会很保守,因为添加比所需更多的代码可能会弊大于利。手动循环展开尤其有利,因为它允许程序员为代码的特定部分微调展开,在这些部分希望以特定的方式和特定的间隔进行展开。

循环展开的好处

循环展开的主要优势是消除了循环开销,这直接带来了期望的改进,尤其是在迭代次数很高的循环中。其次,展开增强了指令级并行性,因为处理器将能够通过一次执行这些指令来利用其资源。它还可以改善缓存使用,因为每次迭代处理更多数据在某些情况下可能导致更少的缓存未命中。

缺点和权衡

然而,需要注意的是,循环展开并非完全没有优点。第一个也是可能最显著的缺点是使用动态 对象 伴随的代码大小膨胀。每个展开的循环都会复制循环体,这会导致新的大型程序或大型程序的开发。这就是为什么当使用大型二进制文件时,它们倾向于影响内存消耗,在某些情况下甚至可能导致较差的缓存性能。此外,当手动展开循环时,代码可能会显得僵化且难以维护,可读性也较差,特别是当循环复杂时。这会使调试、修改甚至扩展其在未来发展中的功能代码形式变得更具挑战性。

循环展开如何工作:分步解析

循环展开是一种通过优化方法尝试从代码中去除重复控制形式的过程。for 或 while 任何基本类型的循环都存在测试和控制操作;测试条件、循环变量的增加或跳转回循环体指令都需要时间。虽然这里提到的步骤可能需要几个处理器周期,但如果应用于运行数千或数百万次的循环,其差异将是巨大的。循环展开试图避免频繁执行这类控制操作,因为目标是减少迭代次数,同时最大化每次循环迭代所做的工作。

  • 每次循环运行时都会执行这些操作,当循环次数很高时,这会花费大量时间。循环展开通过将循环扩展到包含一个循环操作中的多个循环操作来最小化这些控制操作的使用。
  • 例如,不是每次都将循环控制变量增加到下一个值并严格执行一次特定过程,循环展开涉及将其增加几倍(例如 2 或 4 倍)并每次执行四个不同的任务。这意味着需要较少的条件检查和较少的更新,从而有更多的时间用于计算。
  • 此外,借助循环展开,可以提高处理器同时执行多个指令的能力,这种能力称为指令级并行。当前处理器允许同时发生多个独立活动,因此,在循环的每次迭代中添加更多内容可以更好地利用这种能力。
  • 实际上,循环展开涉及到对循环内核处的循环控制开销成本与循环外处的代码大小成本进行比较。大代码很大,内存大小加上登录次数随迭代次数的增加而增加,这会影响循环体中的缓存利用率。然而,在性能比代码大小更受重视的情况下,确保响应复杂计算需求的技术是通过循环展开。

循环展开的类型

循环展开主要可以分为两类:完全展开和部分展开处理。这些变体提供了解决循环展开的冲突目标的能力,一方面,它带来了更高效的代码,另一方面,这些好处可能会被最大的代码大小和某些级别的代码复杂性所掩盖。

完全展开

  • 完全展开是指在程序中完全编码循环的整个迭代,以消除循环的使用。只有当迭代次数很小且在编译时已预先确定时,才可能采用此方法。虽然完全展开完全消除了与循环相关的所有循环控制操作,从而提供了循环展开的全部性能优势。
  • 在循环迭代次数固定且不太大的情况下,完全展开最有利,例如,在处理小型 数组 或进行一定数量的算术计算时。
  • 但是,如果迭代次数很大或不确定,手动编写完全展开是不可能的,因为它会导致代码重复。这可能会增加程序的总体大小,进而影响内存负载,从而影响缓存。这就是为什么展开仅偶尔使用,并且当其收益大于完全展开程序的缺点时使用。

部分展开

  • 部分展开是指仅按特定因子扩展循环体。为了在每次迭代中产生多个操作,它不需要完全展开循环——此处使用部分展开。例如,它可能通过因子 2、4 或 8 来重新计算循环变量,并在每次迭代中执行 2、4 或 8 个操作,通常称为循环展开。这样,循环变量的改变频率较低,并且条件信号的次数也较少。
  • 部分展开具有完全展开的许多优点,即减少了循环控制的开销,并增加了潜在的 ILP。然而,由于循环没有完全展开,部分展开通过避免完全展开引起的代码爆炸来最小化代码爆炸。因此,部分展开适用于迭代次数较大或未知的循环,因为可以在没有过度扩展代码的情况下进行优化。
  • 为部分展开选择正确的展开因子(例如 2、4 或 8)取决于许多因素,包括循环的复杂程度、目标处理器的潜在性能优势以及内存使用情况。可以看出,通过合理选择展开因子,可以在不显著增加代码大小的情况下实现循环展开带来的性能提升。在某些情况下,开发人员还可能尝试考虑其他展开因子,以在合理的内存开销下获得最高的性能提升。
  • 部分展开在循环可能迭代数千万次且每次循环迭代都要进行大量处理的应用中最有用。在这些情况下,无论是否期望,即使是循环开销的微小改进也会导致执行时间增加和明显的加速。同样,由于部分展开允许部分不固定的循环结构,因此它可以与其他优化结合使用,例如融合两个连续的循环或交换嵌套循环以提高性能。

编码

输出

 
Traditional loop sum: 100000000
Traditional loop duration: X.XXXX seconds
Partially unrolled loop sum: 100000000
Partially unrolled loop duration: Y.YYYY seconds
Further unrolled loop sum: 100000000
Further unrolled loop duration: Z.ZZZZ seconds   

结论

总之,循环展开 是一种有效的 C++ 优化技术,它减少了循环控制开销,从而加快了大型循环的执行速度。与通过增加每次迭代的工作量来最小化条件检查和 变量 更新等重复操作的展开不同,好的推测性存储合并软件缓存流编译器将使用这些缓存来增加每次迭代的工作量,从而减少推测性未命中。该技术旨在提供指令级并行性,因此适用于高性能应用程序。虽然引入部分或全部循环可以带来惊人的加速,但它会以代码大小和复杂性增加为代价。循环展开非常适合性能敏感的领域,在这些领域您需要速度胜过简洁性。通过适当的平衡,您可以获得足够的优化,而不会过度膨胀代码。