
作为常年和性能较劲的 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 标准从 C98 走到 C20每个时代都有对应最顺手的写法。我逐个拆解三种。2.1 老派的递归模板展开C98 时代没有变参模板连 enable_if 都用得蹑手蹑脚当年做编译期循环展开只能靠递归模板。核心思想很直白设计一个模板结构体它在编译期调用自己并附带一个递减计数器当计数器归零时特化结束递归。看代码比看解释直观template size_t N struct Unroller { template typename F static void step(F f) { // 先在当前层执行再去下一层 f(std::integral_constantsize_t, N - 1{}); UnrollerN - 1::step(std::forwardF(f)); } }; template struct Unroller0 { template typename F static void step(F) { // 什么都不做递归在这里终止 } };调用方式是这样的Unroller4::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 C17 的折叠表达式与 integer_sequence到了 C14std::index_sequence 引入配合变参模板展开逻辑从“递归下降”变成了“参数包展开”。再到 C17 有了折叠表达式终于可以用一行代码处理整包参数。我推荐的写法长这样template typename F, size_t... I constexpr void unroll_impl(F f, std::index_sequenceI...) { (f(std::integral_constantsize_t, I{}), ...); } template size_t N, typename F constexpr void unroll(F f) { unroll_impl(std::forwardF(f), std::make_index_sequenceN{}); }这里的技巧是 std::make_index_sequence 会在编译期生成 0 到 N-1 的一串整数然后作为模板参数包展开。注意那个逗号折叠表达式(f(std::integral_constantsize_t, I{}), ...)它把对 f 的多次调用用逗号运算符串起来按从左到右的顺序执行。为什么能保证顺序因为逗号表达式有严格求值顺序C17 折叠表达式对逗号运算符同样保证从左到右。调用方式和递归版完全一样但代码量砍了一半。我后来接手过一套老代码里面递归模板几十行换成 index_sequence 之后维护难度明显下降。如果从 C17 起步我建议直接用这种不要再去写递归模板了。2.3 if constexpr 与 C20 新玩法C17 带来了 if constexpr这改变了很多人写编译期递归的方式。现在可以这样写template size_t N, typename F constexpr void unroll_rec(F f) { if constexpr (N 0) { f(std::integral_constantsize_t, N - 1{}); unroll_recN - 1(std::forwardF(f)); } }对比老式递归模板少了一个特化版本终止条件直接写在函数体里代码结构更像普通的递归函数可读性好了很多。编译期照样展开if constexpr 保证了 N 等于 0 时整个函数体被丢弃不产生运行期分支。C20 有一堆新特性进一步强化了这一体系。比如 consteval 函数可以强制编译期求值lambda 也可以声明为 consteval 的这意味着你可以在编译期调用一个带展开逻辑的 lambda。C20 还允许模板参数出现在 lambda 的参数列表里比如[]size_t I() { ... }配合 unroll 直接传入比 std::integral_constant 又顺手一点template size_t N, typename F constexpr void unroll(F f) { []size_t... I(std::index_sequenceI...) { (f.template operator()I(), ...); }(std::make_index_sequenceN{}); }调用变成unroll4([]size_t I() { std::cout I I \n; });这里f.template operator()I()这个语法有点丑但如果你把 lambda 做成了函数对象这就是正路。它的好处是lambda 内部拿到的 I 就是编译期常量不再需要拆 integral_constant。我个人在实际项目里反而更喜欢 2.2 的 integral_constant 版本因为兼容性更稳编译器对 C17 折叠表达式支持已经很成熟而模板 lambda 的写法在某些早期编译器版本上踩过不兼容的坑。这三种写法各有适用场景但核心思想是同一个用模板参数把运行期变量换成编译期常量让循环在实例化阶段就变成一条平铺的指令序列。3. 实战拿一个 FIR 滤波器来开刀原理讲太多不如跑一遍。我做了一个非常典型的例子固定阶数的 FIR 滤波器输入是一路采样数据输出是滤波后的结果。这种代码在信号处理、音频处理、传感器数据处理中随处可见计算模式固定非常适合展示模板展开的收益。3.1 为什么选 FIR 滤波器FIR 滤波器的核心计算是卷积每一个输出样本等于过去 N 个输入样本与 N 个滤波器系数的乘积之和。写成运行期循环大概是float fir_run(const std::vectorfloat x, size_t pos, const std::vectorfloat 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::arrayfloat, N输入指针仍然在运行期传入。这样既保留了数据流的灵活又让循环控制彻底编译期化。代码是这样的#include array #include cstddef #include utility template typename F, size_t... I constexpr void unroll_impl(F f, std::index_sequenceI...) { (f(std::integral_constantsize_t, I{}), ...); } template size_t N, typename F constexpr void unroll(F f) { unroll_impl(std::forwardF(f), std::make_index_sequenceN{}); } template size_t N float fir_unroll(const float* x, size_t pos, const std::arrayfloat, N coeff) { float acc 0.0f; unrollN([](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-N1] 共 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_tpos 是 size_t结果是 size_t一般没事。但如果你把 k 直接拿去做整数模板实参而它被推导成 int 而不是 size_t某些编译器的报错信息会指向一个不存在的模板参数特别迷惑。我的做法是统一用 size_t。在展开 lambda 内部用 constexpr size_t k decltype(index)::value所有数组索引都用 size_t避免隐式转换。还有一处坑是折叠表达式的求值顺序。早期有人为了省事写(f(index), ...)如果括号或运算符写错位置C17 里语法不合法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 看向 C20 和 C23 以及模板生成方向C20 以后编译期编程的工具箱变得更大。constexpr 容器、consteval、模板 lambda、编译期字符串处理等技术让“模板”和“编译期”两个词有了更丰富的含义。比如“模板字符串”如果全部用 consteval 实现可以在编译期完成格式化检查再配合本文的 unroll 思路把字符串中的每个字符在编译期依次处理。这种模板生成器的思路也在新一代代码生成工具里出现用模板语言在构建期生成代码再把生成的代码交给编译器做循环展开。工具链不断递进但核心理念一以贯之把重复劳动交给机器把决策留在编译期。看到这个方向的人建议把 std::make_index_sequence、std::integer_sequence、if constexpr、consteval 四样东西组合起来练习。这四个组合起来能覆盖从“展开固定次数操作”到“编译期生成函数表”的很多场景。比如编译期生成一张查找表就是先定义编译期函数再在 constexpr 上下文中展开生成 std::array最后放入只读存储。很多信号处理库里的查表法滤波器就是这么干的。我从第一次写递归模板到现在印象最深的一点是模板循环展开真正的价值并不只是在快那几拍而是把“循环边界”这个运行期的不确定性提前消除掉。项目里一旦把 N 锁死成一个模板参数后面所有优化的前提就都成立了。最后分享一个实操小技巧不要一上来就全展开先用#pragma unroll或者模板展开 4 次跑一次基准对比指令缓存和流水线行为确认收益后再往上加。我见过太多人把 128 次循环全部展开最后被指令缓存打脸。展开到刚刚好比展开到极限更考验功力。