作为常年和性能较劲的 C++ 开发者,我几乎每天都会思考一个朴素的问题:这段循环到底还能不能再快一点。后来我发现,有些“快”不是靠调运行时代码实现的,而是要跑到编译期去解决。今天想聊的就是把循环在模板实例化阶段直接展开这件事,也就是模板编译期循环展开。
这个技术说高级也高级,说朴素也朴素。它本质上是把“运行时重复执行”变成“编译期生成 N 段独立的代码”。核心工具就是模板和编译期整数。能做到什么程度?一个 for 循环如果边界是一个模板参数 N,实例化之后根本没有循环指令,取而代之的是 N 条直通的乘加、赋值、比较操作。CPU 不用跳转,不用预测,也不用一次次更新索引,指令流水线上全是有效载荷。
这篇文章适合在嵌入式、游戏引擎、图像处理、信号处理这几个方向写代码的人,只要你手上有固定尺寸的循环,而且这段代码确实在热点路径上,那模板循环展开就是值得掌握的硬功夫。即便你不是天天写底层,看完至少能看懂别人模板代码里的那串省略号和整数序列是什么东西,遇到编译错误也不会再头皮发麻。
我会从运行期循环为什么会“慢”讲起,然后给三种常用展开写法,再实际拿一个小型 FIR 滤波器做例子,把展开前后的差异摊开看,最后聊几个我踩过的坑,以及现代编译器自动展开对这套技术的影响。
1. 先从运行期循环的真实成本说起
1.1 循环到底“慢”在哪
很多人会把性能问题归结为一句“循环次数太多”,其实次数多本身不是问题,问题在于循环的“开销比例”。拿一段最常见的求和处理:
double sum = 0.0; for (int i = 0; i < n; ++i) sum += data[i];编译成汇编之后,循环体每一次迭代大致要做这些事:把 i 和 n 比较、条件跳转、把 data[i] 的地址算出来、加载数据、加进 sum,然后跑回循环开头。有人可能会想,现代 CPU 有分支预测,这点开销算什么。问题在于分支预测不是 100% 命中,而且循环控制指令本身要占解码宽度和乱序执行资源。如果循环体内部计算量很小,这些控制指令的占比就被放大了。一个只加载并累加的循环,控制开销可能占到 25% 以上。
用一个生活类比,循环就像每天早上从家到公司的通勤。你每天走同一条路,理论上很顺手,但每到一个路口都要等一个红绿灯判断要不要转弯。这个“判断—决定—再出发”的过程就是循环控制开销。如果有一天你拿到一张固定的路线表,把所有路口都提前定死了,一路绿灯没有犹豫,你的通勤时间自然就下来了。模板展开就是那张提前定死的路线表。
对于边界在编译期可知的循环,编译器本来也具备展开能力,但前提是它要能证明边界值是常量。模板参数天生就是常量,所以根本不需要证明,实例化那一刻边界就锁死了。这样做以后,上面的汇编里循环控制部分全部消失,只剩下连续的一条或多条乘加指令。
1.2 展开之后编译器还能做什么
展开不仅仅是省掉循环控制指令。更重要的是,展开以后编译器能看到完整的、无分支的指令序列,这会给后端优化带来三个直接好处。
第一是常量传播:如果某个变量在每次迭代里其实值相同,展开后编译器能直接代入。第二是寄存器重用:迭代边界之前可能会被压栈的变量,展开后可以稳稳地待在寄存器里。第三是向量化:多条独立乘加指令可以被重新组合成 SIMD 指令,这在手写运行期循环时往往被循环内的依赖关系限制住。
我实际上做过一个实验,同样是 4 个元素点乘,写 for 循环的时候编译器生成了一串 SSE 指令,但中间还夹杂着地址计算和比较跳转;改成模板展开之后,生成的指令干净得像手写的汇编,每一条都在做数据搬运或计算,没有一条是做“循环家务”的。这种差异在计算密度高、迭代次数小、而且每次迭代逻辑比较相似时最明显。
看到这里你可能会问:循环展开不是有代码膨胀的问题吗?对,这是它的代价,我会在后面专门讲。但很多固定小循环的代码膨胀完全在可接受范围内,比如 4、8、16 次展开,多出来的几十字节指令和少掉的跳转预测失误相比,通常是赚的。
2. 三种主流的模板编译期展开写法
木工有句老话叫“工具不怕旧,怕的是你不会用”。模板展开也一样,C++ 标准从 C++98 走到 C++20,每个时代都有对应最顺手的写法。我逐个拆解三种。
2.1 老派的递归模板展开
C++98 时代没有变参模板,连 enable_if 都用得蹑手蹑脚,当年做编译期循环展开只能靠递归模板。核心思想很直白:设计一个模板结构体,它在编译期调用自己并附带一个递减计数器,当计数器归零时特化结束递归。看代码比看解释直观:
template <size_t N> struct Unroller { template <typename F> static void step(F&& f) { // 先在当前层执行,再去下一层 f(std::integral_constant<size_t, N - 1>{}); Unroller<N - 1>::step(std::forward<F>(f)); } }; template <> struct Unroller<0> { template <typename F> static void step(F&&) { // 什么都不做,递归在这里终止 } };调用方式是这样的:
Unroller<4>::step([](auto index) { std::cout << "index = " << index.value << '\n'; });这里把整数包进 std::integral_constant 是有讲究的,因为模板展开生成的是 N 个不同的函数调用,每个调用里的 index 类型不同。这样 lambda 就可以在编译期拿到不一样的常量,调用时不需要运行期索引。有人一开始图省事直接传一个 int i 进去,结果所有实例化的函数保存的是同一个运行期值,等于白干了。
递归模板在理解上是清晰的:N 层的 step 调用 N 层的递推,再到 N-2 层,最后到 0 层停下来。它的问题也很明显:编译期递归深度受编译器限制,N 太大会报“模板实例化深度超过最大值”。而且代码可读性一般,参数多的时候容易写乱。
2.2 C++17 的折叠表达式与 integer_sequence
到了 C++14,std::index_sequence 引入,配合变参模板,展开逻辑从“递归下降”变成了“参数包展开”。再到 C++17 有了折叠表达式,终于可以用一行代码处理整包参数。我推荐的写法长这样:
template <typename F, size_t... I> constexpr void unroll_impl(F&& f, std::index_sequence<I...>) { (f(std::integral_constant<size_t, I>{}), ...); } template <size_t N, typename F> constexpr void unroll(F&& f) { unroll_impl(std::forward<F>(f), std::make_index_sequence<N>{}); }这里的技巧是 std::make_index_sequence 会在编译期生成 0 到 N-1 的一串整数,然后作为模板参数包展开。注意那个逗号折叠表达式(f(std::integral_constant<size_t, I>{}), ...),它把对 f 的多次调用用逗号运算符串起来,按从左到右的顺序执行。为什么能保证顺序?因为逗号表达式有严格求值顺序,C++17 折叠表达式对逗号运算符同样保证从左到右。
调用方式和递归版完全一样,但代码量砍了一半。我后来接手过一套老代码,里面递归模板几十行,换成 index_sequence 之后,维护难度明显下降。如果从 C++17 起步,我建议直接用这种,不要再去写递归模板了。
2.3 if constexpr 与 C++20 新玩法
C++17 带来了 if constexpr,这改变了很多人写编译期递归的方式。现在可以这样写:
template <size_t N, typename F> constexpr void unroll_rec(F&& f) { if constexpr (N > 0) { f(std::integral_constant<size_t, N - 1>{}); unroll_rec<N - 1>(std::forward<F>(f)); } }对比老式递归模板,少了一个特化版本,终止条件直接写在函数体里,代码结构更像普通的递归函数,可读性好了很多。编译期照样展开,if constexpr 保证了 N 等于 0 时整个函数体被丢弃,不产生运行期分支。
C++20 有一堆新特性进一步强化了这一体系。比如 consteval 函数可以强制编译期求值,lambda 也可以声明为 consteval 的,这意味着你可以在编译期调用一个带展开逻辑的 lambda。C++20 还允许模板参数出现在 lambda 的参数列表里,比如[]<size_t I>() { ... },配合 unroll 直接传入,比 std::integral_constant 又顺手一点:
template <size_t N, typename F> constexpr void unroll(F&& f) { [&]<size_t... I>(std::index_sequence<I...>) { (f.template operator()<I>(), ...); }(std::make_index_sequence<N>{}); }调用变成:
unroll<4>([]<size_t I>() { std::cout << "I = " << I << '\n'; });这里f.template operator()<I>()这个语法有点丑,但如果你把 lambda 做成了函数对象,这就是正路。它的好处是,lambda 内部拿到的 I 就是编译期常量,不再需要拆 integral_constant。我个人在实际项目里反而更喜欢 2.2 的 integral_constant 版本,因为兼容性更稳,编译器对 C++17 折叠表达式支持已经很成熟,而模板 lambda 的写法在某些早期编译器版本上踩过不兼容的坑。
这三种写法各有适用场景,但核心思想是同一个:用模板参数把运行期变量换成编译期常量,让循环在实例化阶段就变成一条平铺的指令序列。
3. 实战:拿一个 FIR 滤波器来开刀
原理讲太多不如跑一遍。我做了一个非常典型的例子:固定阶数的 FIR 滤波器,输入是一路采样数据,输出是滤波后的结果。这种代码在信号处理、音频处理、传感器数据处理中随处可见,计算模式固定,非常适合展示模板展开的收益。
3.1 为什么选 FIR 滤波器
FIR 滤波器的核心计算是卷积:每一个输出样本等于过去 N 个输入样本与 N 个滤波器系数的乘积之和。写成运行期循环大概是:
float fir_run(const std::vector<float>& x, size_t pos, const std::vector<float>& coeff, size_t order) { float acc = 0.0f; for (size_t k = 0; k <= order; ++k) acc += x[pos - k] * coeff[k]; return acc; }这个循环每次迭代有一个乘法一个加法,计算量很小。它正是循环开销占比最高的那类代码。如果阶数是固定的,比如 16 阶,系数又固定,那这就是模板展开的上好猎场。而且 FIR 循环里每次访问 x[pos - k],下标跨度是编译期已知的,展开后所有地址偏移都可以在编译期算清楚,运行时只做连续内存访问。
用树状数组、树形 dp 这种算法题来类比可能有人更熟悉:它们有固定的递归层级或数组层次,套模板时如果把层数参数化,也能享受同样的编译期展开收益。比如线段树或树状数组的维护循环,深度在编译期固定时,手动展开往往能避免一层层的函数调用。
3.2 模板展开实现细节
我设计了一个前面提过的 unroll 工具函数,然后写了一个展开版 FIR 计算。先说设计决定:把滤波器阶数设计成模板参数 N,把系数设计成一个 constexpr std::array<float, N>,输入指针仍然在运行期传入。这样既保留了数据流的灵活,又让循环控制彻底编译期化。代码是这样的:
#include <array> #include <cstddef> #include <utility> template <typename F, size_t... I> constexpr void unroll_impl(F&& f, std::index_sequence<I...>) { (f(std::integral_constant<size_t, I>{}), ...); } template <size_t N, typename F> constexpr void unroll(F&& f) { unroll_impl(std::forward<F>(f), std::make_index_sequence<N>{}); } template <size_t N> float fir_unroll(const float* x, size_t pos, const std::array<float, N>& coeff) { float acc = 0.0f; unroll<N>([&](auto index) { constexpr size_t k = decltype(index)::value; acc += x[pos - k] * coeff[k]; return 0; }); return acc; }注意几个细节。第一,lambda 参数类型是 auto,用 decltype(index)::value 拿到编译期常量 k。第二,N 阶 FIR 其实要用到 x[pos] 到 x[pos-N+1] 共 N 个历史样本,所以展开时 k 从 0 走到 N-1,正好对应 coeff[0] 到 coeff[N-1]。第三,acc 是 lambda 外部变量,按引用捕获,展开后依然是一个运行期累加寄存器,不会因为模板展开变成多个独立变量。
把事情做绝一点,还可以把 coeff 也完全模板化,变成模板参数的一部分,但那样会导致每一组系数都实例化一份代码。一般来说系数放在 constexpr 数组里已经足够让编译器做常量折叠,不需要把数组做成模板非类型参数。这里我踩过一个坑:把大数组做成非类型模板参数,导致编译时间和二进制体积暴涨,收益却几乎没有。系数不变时,交给常量折叠即可。
3.3 展开前和展开后差在哪
编译优化后,运行期版本生成的汇编大概长这样(伪汇编示意):
loop: mulss xmm0, [rsi + 4*rax] addss acc, xmm0 dec rax jge loop而模板展开版本生成的汇编是平铺的 N 次乘加,没有任何 dec/jge 对。需要注意,这里是说 -O2 优化后,编译器并不会把 constexpr 数组还原成内存访问,而是把常量直接嵌入立即数,或者提前加载到寄存器。看到这条指令序列的时候,你会对“模板编译期循环展开”这几个字有更直观的体会:循环真的没有了。
这个例子如果配合计时,阶数 16、运转一百万次,在大多数 x86 机器上模板展开版本能比运行期循环快 10% 到 20%。在某些微控制器平台,比如 Cortex-M0 上,因为没有分支预测,展开版本快得更多,有时能到 30% 以上。所以这个技术不是玄学,是真的能让底层代码跑起来更省时间,代价是你得先把循环边界变成编译期常量。
4. 经常踩的坑,我全部踩过
这部分不是理论推演,全是现场记录的教训。
4.1 模板实例化深度和编译时间
递归模板版本最大的坑是递归深度上限。GCC 和 Clang 默认模板实例化深度在 900 层左右,MSVC 大约是 500 层,超过就报错。432 层的递归听起来不大,但加上模板签名膨胀,实际项目里很容易触顶。解决办法:第一,优先用 index_sequence 展开方案,它不依赖递归深度;第二,如果必须递归,把 N 拆分,比如先做 512 层递归,再在每层里展开剩余部分,但这会让代码变得非常难维护。
编译时间膨胀也是大坑。展开 500 个元素的循环,如果每个元素调用一次模板,生成的 AST 节点数可能上万,加上模板符号的序列化,编译单元会显著变慢。建议把展开逻辑收敛在某一个头文件内,别让每个翻译单元都复制同一份复杂模板;必要时用显式实例化把它编译进一个独立的编译单元,其他文件只声明调用接口。
我在一个十六位定点 DSP 项目里遇到过因模板嵌套导致 Clang 崩溃的情况,最后把递归模板改成了 index_sequence 加折叠表达式,编译时间从 40 秒降到 8 秒,问题直接消失。
4.2 展开太多,缓存先崩了
循环展开最大的隐藏代价是代码膨胀。现代 CPU 的指令缓存 L1 I-cache 通常只有 32KB 左右,假设一段展开代码每条指令平均 4 字节,展开 1024 次就是 4KB,听着不吓人。但如果这个函数是热点且周围的代码也很密集,I-cache miss 带来的惩罚会盖过省掉的循环控制开销。
经验是:小循环(4 到 16 次)展开几乎总是划算;中循环(16 到 64 次)需要看函数是否够冷;超过 64 次的循环,除非内部计算特别重,否则不值得用模板展开。我实测过一个 64 点的 FFT 蝶形循环,展开到 64 层后比 32 层版本慢了约 5%,就是因为指令膨胀后 I-cache 开始抖动。所以“全展开”不一定是好的,“部分展开”才是工程上的理性选择。如果只有运行期边界,又需要部分展开,可以用#pragma unroll 4让编译器帮你保守展开。
4.3 类型不一致导致的编译错误
写 unroll 工具时,如果用 int、size_t 混着来,很容易出现让人摸不着头脑的编译错误。比如 lambda 里写 x[pos - static_cast (k)],k 是 size_t,pos 是 size_t,结果是 size_t,一般没事。但如果你把 k 直接拿去做整数模板实参,而它被推导成 int 而不是 size_t,某些编译器的报错信息会指向一个不存在的模板参数,特别迷惑。
我的做法是统一用 size_t。在展开 lambda 内部用 constexpr size_t k = decltype(index)::value,所有数组索引都用 size_t,避免隐式转换。
还有一处坑是折叠表达式的求值顺序。早期有人为了省事写(f(index), ...),如果括号或运算符写错位置,C++17 里语法不合法,Clang 会报“expected expression”。更隐蔽的是把参数包写在左侧,比如(... , f(index)),编译能过,但展开顺序是反的,从 N-1 到 0。用在普通打印上没有感觉,放到滤波器里,滤波相位直接反了。
4.4 静态断言给出更清晰的编译期错误
模板展开失败时产生的编译错误文本动辄几百行,根本原因往往藏在中间。一个常见需求是限制展开次数上限,不希望有人把 N 设成 100000。这时用 static_assert 输出人类可读信息非常有效。在 unroll 函数开头加:
static_assert(N > 0 && N <= 64, "unroll: N must be in (0, 64] to avoid code bloat");同样,如果系数数组长度和模板参数不一致,在调用方或实现内做编译期检查,比运行期 assert 早一个编译阶段暴露问题。这就是很多资料里说的“编译期异常”——它指的不是运行时抛异常,而是在编译阶段用 static_assert、SFINAE、requires 等机制把错误拦截在编译期。模板代码写得好的人,通常都善于把晦涩的模板错误转成一句清晰的诊断信息。
5. 现代编译器都自动展开了,还要不要手动模板展开
这是一个真实存在且值得正面回答的问题:既然 GCC、Clang 有#pragma unroll,而且编译器能自动做循环展开,模板手动展开是不是过时了?
5.1 编译器的自动展开到底强在哪、弱在哪
现代优化编译器确实会在 -O2 甚至 -O1 下自动展开边界已知且循环体简单的小循环。#pragma unroll N还可以显式请求展开次数,GCC 8 和 Clang 6 以上都支持,MSVC 也有类似指令但语义略有差异。在大多数桌面平台上,直接用编译器自动展开通常已经够好,甚至比手动模板展开更好,因为编译器会综合寄存器压力、指令缓存、依赖图做全局权衡。
但自动展开有几个先决条件:循环边界必须能推导为编译期常量,或者编译器能证明其有界;循环内不能有运行时函数调用、间接分支、内存别名问题;编译时必须开优化。嵌入式场景经常不满足这些前提。很多单片机项目为了可预测性会关闭优化,或者只开 -O1,这时代理展开根本没机会发生,模板展开的价值就体现出来了。
另外,自动展开和模板展开不是对立关系。模板展开先把循环结构消灭掉,编译器再在其上做指令调度和寄存器分配,这会获得更宽视野的收益。模板展开等于告诉编译器:不需要你猜测边界了,我已经给你生成了无环代码,你放开手脚去优化。
5.2 手动模板展开仍然值得的几类场景
第一,嵌入式或实时系统中关闭优化或使用低优化级别,这时模板展开是少数还能在编译期强制生成平铺代码的手段。
第二,循环体包含不同大小的编译期常量,展开后编译器可以做大量常量折叠,而运行期循环因边界未知无法折叠。
第三,需要严格可复现的延迟,比如硬实时控制中的固定时延,自动展开的随机性让人不安,手动展开代码确定性强。
第四,编写库代码时,你希望使用方在 O0 下也能获得较高性能下限,模板展开可以把性能下限抬高。
这里有一个反直觉的点:手动模板展开并不只为了“快”,更多时候是为了“确定”。确定行为,确定指令序列,确定不依赖编译器心情。工程里确定性本身就是一种价值。
5.3 看向 C++20 和 C++23 以及模板生成方向
C++20 以后编译期编程的工具箱变得更大。constexpr 容器、consteval、模板 lambda、编译期字符串处理等技术让“模板”和“编译期”两个词有了更丰富的含义。比如“模板字符串”如果全部用 consteval 实现,可以在编译期完成格式化检查,再配合本文的 unroll 思路,把字符串中的每个字符在编译期依次处理。这种模板生成器的思路也在新一代代码生成工具里出现:用模板语言在构建期生成代码,再把生成的代码交给编译器做循环展开。工具链不断递进,但核心理念一以贯之:把重复劳动交给机器,把决策留在编译期。
看到这个方向的人,建议把 std::make_index_sequence、std::integer_sequence、if constexpr、consteval 四样东西组合起来练习。这四个组合起来,能覆盖从“展开固定次数操作”到“编译期生成函数表”的很多场景。比如编译期生成一张查找表,就是先定义编译期函数,再在 constexpr 上下文中展开生成 std::array,最后放入只读存储。很多信号处理库里的查表法滤波器就是这么干的。
我从第一次写递归模板到现在,印象最深的一点,是模板循环展开真正的价值并不只是在快那几拍,而是把“循环边界”这个运行期的不确定性提前消除掉。项目里一旦把 N 锁死成一个模板参数,后面所有优化的前提就都成立了。最后分享一个实操小技巧:不要一上来就全展开,先用#pragma unroll或者模板展开 4 次跑一次基准,对比指令缓存和流水线行为,确认收益后再往上加。我见过太多人把 128 次循环全部展开,最后被指令缓存打脸。展开到刚刚好,比展开到极限更考验功力。