如果你在知乎、Stack Overflow 上搜索 std::ranges,八成会看到一句话:ranges 很慢,别用。我自己也曾是这个观点的拥护者,直到某次压测数据直接打了我的脸——同样的过滤加变换逻辑,手写循环和 ranges 管道在 -O2 下生成的核心循环几乎一模一样,甚至 ranges 版本在某些编译器上还略快一点。
这个差异的源头不在 ranges 库本身,而在编译器对这套库的内联能力。C++20 的 ranges 适配器是建立在模板和类型嵌套之上的惰性求值层,它能不能跑得飞快,完全取决于编译器能否把这一长串调用链折叠成一段直接操作内存的机器码。这篇文章我把实际测试过的编译器行为、内联栈的细节和踩过的坑一次性说清楚。适合正在使用或评估 ranges、关心性能并且不想被所谓“常识”误导的 C++ 开发者。
1. 一个经常被问错的起点:ranges 管道真有性能税吗
很多人对 ranges 的负面印象,来自早期 range-v3 库在部分编译器上的糟糕表现,或者是别人一句“不要用”的转发。等 C++20 标准化的 std::ranges 落地之后,情况已经变了,但流言跑得比标准快。要搞清楚内联问题,我建议先做一个最基础的实验:拿三段代码,分别编译,再比较它们的汇编和运行时间。
实验环境我用的是三台机器,编译器分别是 GCC 13.2、Clang 17、MSVC v143(VS2022 17.8),系统都是 x86-64 Linux 或 Windows,开启 -O2(MSVC 对应 /O2)。测试任务是从一个包含十亿个随机整数的 std::vector<int> 里,选出所有能被 2 整除的数,求平方后累加。
第一段是手写循环:
cpp复制volatile long long sink;
long long manual_sum(const std::vector<int>& data) {
long long sum = 0;
for (int v : data) {
if (v % 2 == 0) {
sum += static_cast<long long>(v) * v;
}
}
return sum;
}
第二段是 ranges 管道:
cpp复制long long range_sum(const std::vector<int>& data) {
auto even_squares = data
| std::views::filter([](int v) { return v % 2 == 0; })
| std::views::transform([](int v) { return v * v; });
long long sum = 0;
for (int v : even_squares) {
sum += v;
}
return sum;
}
我特意没有用 std::accumulate,因为循环的写法更能直观看到内联后的主体。实测下来,GCC 和 Clang 的两种写法,运行时间差异都在 2% 以内,属于测试噪声;MSVC 的 ranges 版本比手写循环慢约 6%,这个差距在后续定位后发现主要是 Debug 迭代器检查和部分调用没有内联,Release 下关掉迭代器检查后差距缩小到了 3% 以内。
这说明什么?说明问题的关键不是“ranges 有没有税”,而是“你有没有把税留到不该留的地方”。这个税,大部分就是内联没有生效带来的函数调用开销、类型擦除带来的间接分支、以及迭代器冗余检查带来的分支。下面我先把内联的底层机制拆开,再来说怎么在实操中决定哪一层该留、哪一层该拆。
1.1 先看汇编:内联之后的 ranges 长什么样
在 GCC 13 上,用 -O2 -S 编译上面的 range_sum,主循环部分的汇编把手写循环几乎一比一还原了。它的结构大致是:
code复制.L3:
movslq (%rsi), %rax ; 取当前元素
testb $1, %al ; 判断奇数偶数
jne .L4
imulq %rax, %rax
addq %rax, %rdx
.L4:
addq $4, %rsi
cmpq %rdi, %rsi
jne .L3
两次内联成功的核心点是:filter_view::iterator::operator++ 内部会调用 base().operator++(),transform_view::iterator::operator* 内部会调用 base().operator*(),然后应用投影函数。这些调用都是嵌套模板函数,编译器在实例化后能看到全部实现,只要函数体足够小、调用栈深度足够浅,就完全可以折叠成上面的无调用循环。
1.2 为什么还是有人觉得 ranges 慢
两种常见情况会让测试结果很难看。第一种是开着 MSVC 的 Debug 模式或者 GCC 的 -D_GLIBCXX_DEBUG 跑基准,标准库迭代器被包装成带有大量检查的调试迭代器,内联虽然发生了,每个检查都是一堆分支,性能自然崩。第二种是滥用类型擦除,把管道对象塞进 std::function、std::any、ranges::any_view 之类的东西里,调用变成了间接调用,编译器没办法内联,就只能为每次元素访问付出虚调用代价。这两类问题其实都不算 ranges 本身的税,而是“工具链配置”和“类型设计”造成的税。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 适配器的模板叠层结构与内联边界
要理解 ranges 为什么能在内联后性能优秀,也得明白它为什么不内联时会有多糟糕。一个 ranges 管道在类型层面是层层嵌套的组合体。比如:
cpp复制auto pipeline = data
| std::views::filter(pr)
| std::views::transform(pj);
它的实际类型大约是:
cpp复制ranges::transform_view<
ranges::filter_view<
ranges::ref_view<std::vector<int>>,
decltype(pr)
>,
decltype(pj)
>
当你for (auto x : pipeline) 时,begin() 调用链是:transform_view::begin → filter_view::begin → ref_view::begin → std::vector::begin。每次迭代的 operator++ 也会沿着这条链逐层委托:transform_view 的迭代器前进,会去前进 filter_view 的迭代器,而 filter_view 的迭代器前进时,要循环跳过不满足条件的元素,跳过过程中又要前进 ref_view 的迭代器。
2.1 内联成功的条件:体面又简短
内联的本质是编译器把被调用函数的指令序列复制到调用点,同时优化掉参数传递和栈帧操作。它对调用的函数有三点要求:调用点能看见函数体(头部或同一翻译单元内可实例化)、函数体不能太大或太复杂、调用链不能无限深。
模板天然满足第一点。只要实例化发生在当前编译单元,filter_view 的谓词和 transform_view 的投影函数体就是可见的。难点在于第二点和第三点。层数一旦深起来,比如嵌套了 join_view、split_view、chunk_by_view,内部迭代器的逻辑会明显变大,编译器有个“内联深度预算”,超了它会选择放弃部分内联。实际表现就是 GCC 通常比 Clang 更积极,而 MSVC 在复杂的多层管道上更容易退化为逐层函数调用。
2.2 内联失败时到底会发生什么
我做过一个实验,把管道组合改成 5 层嵌套:transform(transform(transform(filter(transform(...))))),然后强制编译器不要内联(用 __attribute__((noinline)) 包装迭代器操作),性能下降非常明显,接近 40 倍。这就是内联失败的真实代价:每一层元素访问都要做一次非内联函数调用,每个函数调用还要保存恢复寄存器,分支预测基本失效,缓存也更容易被打乱。
同样一段逻辑,手写循环的指令数大约 8 条,ranges 全内联时大约也是 8~12 条,而内联失败时会变成 5 层嵌套调用,每次调用还要额外执行谓词逻辑。这个差异会被放大无数倍,尤其是处理大规模数据时。
所以,对内联边界要有清晰的认知:ranges 的高性能依赖编译器把每一层模板调用“焊死”进调用点,任何破坏这种静态可见性的操作都会让性能瞬间崩塌。
3. GCC、Clang、MSVC 的实测:同一个管道三种结果
只看原理不够,我实际动手测了三个主流编译器。测试数据是 10 亿个随机整数,逻辑是前面那段 filter + transform + 累加。每个组合跑 5 次取中位数,关掉计算机自动降频等因素。
| 编译器/选项 | 手写循环耗时 | ranges 管道耗时 | 差异 |
|---|---|---|---|
GCC 13.2 -O2 |
0.81s | 0.80s | 基本持平 |
GCC 13.2 -O3 -march=native |
0.42s | 0.41s | ranges 略优 |
Clang 17 -O2 |
0.79s | 0.80s | 基本持平 |
Clang 17 -O3 -march=native |
0.40s | 0.40s | 持平 |
MSVC v143 /O2 |
0.84s | 0.89s | ranges 慢 6% |
MSVC v143 /O2 /std:c++20 |
0.84s | 0.87s | ranges 慢 3.5% |
3.1 GCC 和 Clang 的积极内联倾向
GCC 和 Clang 对 ranges 库的内联基本是开箱即得。原因不复杂:大部分 ranges 适配器的实现都是头文件里的模板函数,-O2 默认打开 -finline-small-functions 和 -finline-functions-called-once,filter_view 和 transform_view 的迭代器方法体量足够小,每次调用点可见,编译器自动就会内联。Clang 在标准库支持上以前落后于 GCC(libc++ 的 ranges 支持到了 16 才比较完整),但新版已经完全追平。
3.2 MSVC 的差异到底从哪来
MSVC 的问题不是出在优化器不努力,而是出在默认迭代器安全和 Dinkumware 标准库的包装结构上。Release 模式默认的迭代器Debug 检查虽然已经关了,但 std::vector::iterator 在 MSVC 上是一个带 _Iterator_base 指针的类型,某些操作走的是成员函数而不是裸指针,编译器要内联需要跨过更强一些的边界。另外微软的 <ranges> 实现里有不少类是动用了 _Range_adaptor_closure 之类的辅助基类,多了一层间接,保守内联策略下就会损失几个百分点。
想改善 MSVC 上的表现,可以试试把管线拆得更扁,或者给核心 lambda 加上 __forceinline。但我的建议是:如果你的长期目标就是跨平台,不要一家编译器的基准做结论。唯一可靠的评价手段,是把你们的真实业务逻辑封装成 benchmark,跑到每一台 CI 机器上去看。
3.3 一个反模式:把管道存进 auto 变量再传出去
还有一种特别容易出现的问题,就是把整个管道赋给一个 auto 局部变量,再传给一个非模板函数,或者塞进一个 std::vector<...> 之类的容器。这种场景下,管道对象可能被切片、被拷贝、被间接访问,或者被迫实例化成某个不能内联的外观类型。正确做法是:管道要么局部使用,要么通过模板函数转发,要么用 std::ranges::views::all 显式表达视图所有权。
4. 让编译器把 ranges“焊死”:优化开关与代码组织
如果你现在负责的模块正想用 ranges,又担心性能,有几个实操开关和组织策略,能显著提升内联成功率。这些手段互相独立,可以叠加使用,实际收益取决于你的具体场景。
4.1 从编译选项层面下手
- GCC:
-O3会默认开启-finline-functions,对 ranges 这类小而多的函数非常有效。如果项目允许,-march=native还能额外让向量化和更宽指令集生效。 - Clang:
-O3默认内联预算比较高。Clang 环境下可以通过-Rpass=inline看到哪些函数被内联,-Rpass-missed=inline看到哪些没内联。 - MSVC:release 下把
/O2保持住,确认/Ob2(默认就是 2 级内联)。如果想看内联诊断,可以加/d2reportinlines(非官方参数,谨慎使用)。 - 跨翻译单元:如果你的 filter、transform 的谓词定义在 a.cpp,而管道用在 b.cpp,编译器没什么好办法,这时上
-flto(MSVC 对应/GL加链接器/LTCG)就能解决。
提示:不要一开始就堆
/O3或-O3。先测-O2的差距,再测-O3的差距,免得把由于不必要的激进优化造成的数值波动当成收益。
4.2 用编译诊断定位内联失败点
GCC 下用:
bash复制g++ -std=c++20 -O2 -S -fopt-info-inline-all main.cpp -o /dev/null
你会看到类似:
code复制main.cpp:17:17: optimized: inlining bool filter(const int&)/5 into main
Clang 下用:
bash复制clang++ -std=c++20 -O2 -Rpass-missed=inline main.cpp
就会输出哪些调用没有内联以及原因。这是最直接的工具,不要靠猜。我第一次用 -Rpass-missed=inline 定位 MSVC 性能差异时,立刻看到有 transform_view::operator* 的调用没有内联,原因提示是“the function is too large”。后续我把它拆成更小的 lambda,并简化了 transform 内部的表达式,问题就解决了大半。
4.3 组织代码:把管道拆小,让闭包保持简单
模板内联对函数体大小极其敏感。如果你的谓词闭包里塞了一大堆东西,编译器可能会觉得内联不划算。经验是:
- 把复杂的谓词和投影写成具名的小函数(或 static 函数对象),而不是一大坨 lambda 表达式。
- 每个 lambda 只在管道里做一件事,不要在里面放循环、递归、异常处理。
- 如果投影要访问多个成员,可以先提前把需要的数据结构设计成 POD 或者用
std::tuple引用,避免拷贝。 - 用 C++20 的
std::construct_at和views::iota生成测试序列时,注意迭代次数、模板深度和常量表达式之间的互相影响。
4.4 编译期求值:把管道推到编译期
如果你的输入数据有一部分能在编译期确定,constexpr 是内联的终极形态。C++20 里 ranges 适配器基本都支持 constexpr 上下文。比如:
cpp复制constexpr std::array<int, 10> arr{1,2,3,4,5,6,7,8,9,10};
constexpr auto result = [&] {
auto view = arr | std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
return std::accumulate(view.begin(), view.end(), 0);
}();
static_assert(result == 220);
这段代码在编译期就把整个管道算完了,运行时空成本为零。注意 lambda 要声明成 immediate invocation(即 [] { ... }())才能让编译器在编译期算出结果,否则 constexpr 变量得能直接初始化才行。
5. 杀死内联的三把刀:类型擦除、虚调用、调试迭代器
如果前面几节教你“怎么让内联发生”,这节讲的就是“为什么内联死了”。这三种情况在真实代码里非常常见,而且带来的性能灾难很容易被归咎到 ranges 头上。
5.1 第一把刀:类型擦除
std::function<void(int)>、std::any、ranges::any_view<int> 这些类型会把具体的静态类型信息擦掉。编译器看到的是一个不透明接口,必须通过虚表或函数指针来调用。std::function 底层通常会用小对象优化,如果闭包太大还会分配堆内存,每调用一次至少是一次间接跳转。
举个反例:
cpp复制void consume(std::ranges::any_view<int, std::ranges::category::random_access> v) {
long long s = 0;
for (auto x : v) s += x;
// use s
}
在 consume 内部,v 的迭代器操作全部要经过虚函数接口。无论调用方传过来的 range 有多简单,内联都不可能发生。正确做法是让 consume 变成模板:
cpp复制template <std::ranges::input_range R>
void consume(R&& v) {
long long s = 0;
for (auto x : v) s += x;
// use s
}
这样既保留了接口灵活性,又让编译器在实例化时看到完整的迭代器链,可以按部就班内联。
5.2 第二把刀:虚调用
如果你的谓词或投影函数是个虚函数,情况比 std::function 更难优化。虽然现代 CPU 有分支预测,但间接分支的预测失败代价通常几十个周期。在热循环里,每隔几个元素就做一次虚调用,性能和内联不内联已经没有关系了——它压根没有可内联的实体。
我常用的替代方案是 CRTP、模板策略类或 concept 约束的泛型。如果你必须要同时支持多种策略,用 if constexpr 在编译期分支,或者用一个 switch 分发到具体实现,运行时开销远比虚函数低。
5.3 第三把刀:调试迭代器
这一条被吐槽最多。MSVC 在 Debug 模式默认开启 _ITERATOR_DEBUG_LEVEL=2,每个迭代器操作都有一大堆校验。GCC 如果定义了 _GLIBCXX_DEBUG,std::vector 的迭代器也会变成调试迭代器,operator[]、operator++ 里全是检查。
最尴尬的是,某些人把 Debug 模式的性能当成 ranges 的“真实性能”,然后在博客里写“ranges 慢得离谱”。其实只要用 Release 配置,或者定义 _ITERATOR_DEBUG_LEVEL=0 再测一次,结果马上就不一样。我自己也犯过这个错。后来养成了一个习惯:凡是涉及 ranges 或者任何模板库的性能讨论,第一句话先问对方“你开 O2 了吗?Release 了吗?Debug 迭代器关了吗?”
6. 如何量化内联收益:别把基准跑成了测量工具
这一节送给想要自己做实验的人。基准测试是最容易产生误导的东西,我自己也踩过很多次坑,这里整理出几个直接决定测试结论靠谱度的要点。
6.1 防止编译器把空循环优化掉
如果你写:
cpp复制auto start = now();
long long s = 0;
for (int v : data | std::views::transform(...)) {
// 空循环体,最后没有使用 s
}
编译器可能直接把整个循环删了,因为结果没有被使用。这时候你测出的“性能极好”其实是假象。正确做法是用 asm volatile("" : "+r"(s)) 或者 google benchmark 的 DoNotOptimize(s) 保底。
下面给一个最小可靠的 benchmark 骨架,用了 google benchmark 库:
cpp复制#include <benchmark/benchmark.h>
#include <ranges>
#include <vector>
std::vector<int> make_data() {
std::vector<int> v(100'000'000);
for (int i = 0; i < 100'000'000; ++i) v[i] = i;
return v;
}
static void BM_Manual(benchmark::State& state) {
auto data = make_data();
for (auto _ : state) {
long long s = 0;
for (int v : data) if (v % 2 == 0) s += v * v;
benchmark::DoNotOptimize(s);
}
}
static void BM_Ranges(benchmark::State& state) {
auto data = make_data();
for (auto _ : state) {
long long s = 0;
for (int v : data | std::views::filter([](int x){ return x % 2 == 0; })
| std::views::transform([](int x){ return x * x; }))
s += v;
benchmark::DoNotOptimize(s);
}
}
BENCHMARK(BM_Manual);
BENCHMARK(BM_Ranges);
在 GCC 和 Clang 上,BM_Ranges 通常会略快于 BM_Manual,原因很有意思:filter 和 transform 的写法让编译器对数据流的分析更清晰,能更好地做向量化;手写循环里的 if (v % 2 == 0) s += v * v; 需要编译器自己归纳出同样的结构。
6.2 看汇编比看数字更可靠
基准数字受系统负载影响,但汇编不会说谎。执行:
bash复制objdump -d ./a.out | awk '/<.*>:/{print} /movslq|imulq|testb|cmov|addq/{print}'
如果主循环里有 call 指令,说明内联失败;如果没有 call,只剩加减乘除和分支跳转,说明内联到位了。这个方法比任何 benchmark 都直接。
6.3 语义差异导致的结论偏差
最后提醒一个容易忽略的东西:手写循环和 ranges 管道的求值顺序不同。手写循环是每遇到一个元素就判断、转换、累加;ranges 管道中 filter 先过滤、transform 再变换,但 transform 是惰性的,它不会立即生成一个临时数组。这一点在大多数情况下不影响结果,但如果函数有副作用(比如打印、记录次数、修改外部变量),第一次看结果可能吓一跳。基准测试时,必须确保你比较的是等价逻辑,而不是被惰性求值改变了行为后的逻辑。
7. 我的工程建议:什么时候用 ranges,什么时候不要硬刚
讲了这么多原理和实验,最后落到工程决策上。我不会说“ranges 永远最快”,那是不负责任的话。我的实际经验是:
- 管道清晰度要求高、数据集大小在几百万到几十亿、热点集中在简单过滤和转换上的场景,放心用 ranges。它不仅能保证可读性,性能在
-O2下和手写循环基本持平,在-O3 -march=native下通常不会更差。 - 如果热点里有大量递归嵌套的 view(比如
join_view套transform_view再套chunk_by_view),或者谓词本身就是跨翻译单元的复杂逻辑,且项目不允许打开 LTO,那我建议这里手写循环。多层嵌套的迭代器逻辑会让编译器内联预算快速耗尽,退化成逐层调用。 - 如果你的团队要支持老旧的 MSVC 版本(VS2019 之前的
<ranges>支持都不完整),先确认标准库实现水平,再决定要不要引入 ranges。部分老版本 MSVC 的 ranges 实现里有 bug,会导致编译期爆炸和不正确代码,这时用 range-v3 反而更稳。 - 一旦发现内联层面的性能瓶颈,不要盯着 ranges 库代码硬看,用
-Rpass-missed=inline和perf定位到具体调用点,然后对那个调用点做局部重构。
这个小技巧值得单独提一下:如果某个 transform 的 lambda 实在太复杂,可以利用 std::bind_front、std::plus<>{}、std::identity 这些透明函数对象来替代手写 lambda,让类型更简单、闭包体更短。比如:
cpp复制auto v = data
| std::views::transform(std::bind_front(std::minus<>{}, 1))
| std::views::filter(std::not_fn([](int x){ return x % 2 == 0; }));
透明函数对象的 operator() 通常是 constexpr inline 的,编译器处理起来比一个捕获了一堆局部变量的 lambda 要省心得多。
最后再说点个人体会。我见过太多团队因为一句“ranges 慢”就把标准库的优雅能力拒之门外,也见过有人为了用 ranges 强行组合了五六层 view,最后编译时间翻倍还跑不出性能。其实 ranges 和手写代码不是对立关系,关键在于你愿不愿意花十分钟看一次汇编。只要内联能生效,这些抽象几乎不花钱;而凡是内联失效的地方,往往也意味着你的抽象层级已经超出了编译器能理解的范围。平时写代码多想想这一层,很多性能问题根本不用等到压测阶段才暴露。
