用性能工具剖析 std::ranges¶
标题:Throwing Tools at Ranges
日期:2024/12/04
作者:Tina Ulbrich
链接:https://www.youtube.com/watch?v=2O-tW5Cts5U
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:
转录 ≠ 演讲有价值,我是转录后才看的,不及格。这只是写一段代码然后跑结果,没了。
觉得信息熵太低,想直接跳到幻灯片看总结?你只会看到极其抽象的玩意……
嗨,大家好。欢迎来到我关于在范围(std::ranges
)上使用分析工具的演讲。我非常感激你们今天在这里听我演讲。自从我上次谈论如何对 fire code 使用范围以来,很多人问我:但这会如何影响运行时性能?范围在运行时表现好吗?我在之前的演讲中做了一些基准测试,但我并没有真正详细分析它。所以我想这次演讲可能会有所帮助,使用一些不同的工具,试图看看范围代码的实际性能表现如何。这只是运行时,不是编译时。
首先,我将开始谈论我的设置以及我是如何生成结果的。接下来,我将解释我们实际上要分析的代码。如果你想将你的范围代码与某些东西进行比较,你需要有其他的东西来比较它。所以我用我称之为“C 风格 C++”(不使用任何标准库)、“C++ 17”(使用算法)和“C++ 23”(主要使用范围(ranges)和范围适配器(range adapters))编写了完全相同的代码。它们都做完全相同的事情。我将使用不同的工具分析这些代码示例:Google Benchmark、Cachegrind 和 Visual Studio Profiler。虽然我以前用过 Visual Studio Profiler,但我对 Linux 工具非常陌生。而且,特别是,因为我主要在 Windows 机器上工作,私人使用和工作都是如此,但对你们来说,情况可能正好相反。那个,Visual Studio Profiler 的演示将是现场进行的。希望一切顺利。希望一切顺利。然后,之后我们将对代码进行一些优化,然后再次运行这些工具。最后会有一些结论。
那么让我们从设置开始。正如我之前所说,我一直是 Windows 用户,在工作中,我们也是在 Windows 上开发。所以,我真的从来没有动力去学习 Linux 及其工具。所以你们可能已经猜到我使用 WSL 来处理 Linux 相关的东西,也就是 Windows Subsystem for Linux(Windows 的 Linux 子系统)。所以这是一个默认安装 Ubuntu 的虚拟机,你可以像使用常规的 Linux 机器一样使用它。它,非常容易使用,并且可以集成到 Visual Studio 中,这样你就可以在 Windows 上使用你的 Visual Studio,然后连接到你的 WSL,例如使用 GCC 进行构建。所以它超级方便。
另一件可能有趣的事情是我正在使用的电脑。如果你们之前看过这个演讲,我当时有一台非常糟糕的笔记本电脑,我设法升级了它,我真的很喜欢现在这台。它不是游戏本之类的,但它是台好笔记本。但是,我们将在这台笔记本上运行 Visual Studio Profiler,但是,所有其他结果,Cachegrind 和来自 Google Benchmark 的基准测试,都是在这台 PC 上生成的,这是我的主力 PC。这是我的游戏 PC。它稍微强大一点,一点点,只是,你知道,为了比较。如果你们,觉得,你知道,速度和我幻灯片中展示的不匹配,那是因为我用了两台不同的电脑分别运行。
现在我们来看实际的代码。我试图找到一个真实的例子,在那里我可以尽可能多地使用范围适配器。结果发现这实际上相当困难。所以我编了一个无意义的代码。所以这是那个无意义的代码幻灯片。它只是用了我想要的所有范围适配器。它计算了一些东西,但它没有意义。它没有价值。所以你们可以获取代码。它在我的 GitHub 上,但是,为什么你们要,你知道,如果你们想的话,可以详细看看它。但是,是的,它不会帮你解决任何现实世界的问题。
是的,我们从 C++ 23 版本开始。我写了,三个子函数,然后是一个调用它们全部的函数。这某种程度上是为了模拟现实世界的问题或代码,就像你有不同的函数做不同的事情那样。我非常不擅长命名。它叫 random
,它没有计算任何随机数。它是一个做随机事情的随机函数,但,不是计算随机数。抱歉。我们将从一个我真的很喜欢的视图(view
)开始,它应该有那个我真的很喜欢的东西,它叫做笛卡尔积。笛卡尔积将所有范围的所有元素彼此组合在一起,这是一种花哨的说法,表示这是一个嵌套循环。你,把你所有想要放在你的嵌套循环中的范围放进去。这里的这个范围是嵌套循环的外层范围。这是我们输入函数的向量的范围。然后,我的嵌套循环的第二个范围只是一个 iota_view
,从 1 数到 5。然后我还对输入视图做了一些变换(transform
),因为我还没有其他东西。现在,在花括号的结尾这里,我还没有做任何事。我只是做了一个巨大的视图,所有元素都被组合在一起并存储在元组中。它叫做笛卡尔积,我认为这个名字暗示了某种乘法。但它没有,它只是所有元素的组合。所以现在我们必须对我们刚刚创建的范围做一些事情,我们为此也使用了变换。正如我所说,我们在一个元组中获取所有元素,然后,我喜欢,解构我的元组。通常我会给它们合适的名字,但既然这段代码没有做任何正经事,我真的没有合适的名字。然后我做了一些随机计算,你知道,就像我说的,只是为了做点事情。正如你们可能看到的,我返回这个视图,这个笛卡尔视图或带有变换的笛卡尔视图。关于范围或范围适配器的一个有趣之处是它们是惰性求值的。所以在这一点上,甚至在这个分号处,即使我在这里返回,也还没有进行任何计算。我从这个函数返回的是一个关于我希望如何计算元素的语句。这就是惰性求值。只有当我触发实际计算时,我才会得到我想要的元素,而这里不是这个地方。记住这一点对于范围和范围适配器很重要。
对于 C++ 17 版本,我为输出创建了一个向量,并且我从函数中返回了这个向量。所以这是与 C++ 23 代码的第一个重大区别。这是急切求值的。所以如果你比较惰性和急切,当函数运行时,当函数被调用时,这个会立即被求值,那个 C++ 23 的,你可以调用函数,但你不会得到任何值。你不会得到任何计算结果,除非你需要范围本身的实际值。
所以对于 C++ 17,我也写了一个嵌套循环,因为我们没有一个能接受任意数量输入范围的变换变体。我们可以有两个输入范围,但不能有更多。所以,我写出了嵌套循环。我使用基于范围的 for 循环,迭代外层范围,迭代我从 1 到 4 的计数,然后迭代内层范围。我做在变换视图(transform_view
)中做过的计算。我只是在,在 for 循环体中直接做,并且我也预先保留了大小,然后压入元素。这就是我们之前看到的范围版本的等价物,只是区别在于它实际上被立即求值了。好的。C 风格非常相似。别害怕。有一个 new
,
我们,我们稍后会删除它。
对于 C 风格,不使用我的基于范围的循环,我使用索引循环,但它和我们之前看到的完全一样。只是,你知道,有更多的索引,更多的字符和更多的东西。我也返回了,我创建的动态数组。
然后我有第二个函数。对于第二个函数,我真的想使用 zip_transform
。因为正如我所说,使用标准算法,使用 transform
算法,你只能有一个或两个输入范围,但使用 zip_transform
,你可以拥有任意多个输入范围,你可以把它们放进你的 zip_transform
。这就像有一个基于范围的 for 循环然后调用 zip
把你所有的范围压缩在一起一样。但这也是一种非常方便的表示法。对于 zip_transform
,你必须,你必须放入函数的第一个参数是,你想要执行的函数。在我的例子中,它是一个只做一些随机计算的 lambda。这样构建是因为,所以 transform
,接受任意数量的范围,这是一个非常复杂的模板。而且你知道,你的参数包必须在你的函数中最后出现。所以这就是为什么顺序是反过来的。所以通常,如果你知道标准算法,你想要执行的函数或 lambda 是最后一个参数,但在这里它必须是第一个,因为可变参数模板的原因,然后你输入所有你想对其做些事情的范围。这就是 zip_transform
。我真的很喜欢这个。还有,你们看,我又用了 iota_view
。我从 10 开始,没有给它结束点,而 zip_transform
和 zip
一样,在最短的范围处终止。所以这里范围一,我放进去的第一个是最短的范围,也就是 iota
,它终止于这个范围的大小。所以如果你需要做类似的事情,这也是相当方便的。对于 C++ 17 版本,我再次创建一个向量,我不打算处理那个,我用外层范围做过的范围。那个从 10 开始。我只是在我的 std::transform
外面有一个变量来处理这些值,并且我做完全相同的计算,但是用 std::transform
。注意,这是 C++ 17。所以我们还没有这些算法的基于范围的版本。这就是为什么我总是使用 std::begin(range)
, std::end(range)
。对于 C 风格,非常相似。又是一个 new
。别害怕。我,然后是一个索引循环,它基本上和我们之前在变换中看到的计算相同,但现在是在一个 for 循环中。
再次,我不会在代码上花太多时间。如果你们感兴趣,以后可以详细查看。现在来看第三个函数,我实际上有一些临时结果。所以我想计算一个部分和。在这个演讲的上一个版本中,我使用了 range-v3。这是我忘记提到的。如果你们之前看过,我用了 range-v3。现在我只用标准库。我升级到了 GCC 14,现在可以使用标准库了,不再有 range-v3 了。但是 range-v3 有更多的范围适配器,它有一个 partial_sum
范围适配器,而标准库没有。也许在下一个标准中会有一个,但现在我们什么也没有。所以因为我想要这个,也作为一个视图,是的,也作为一个惰性视图,我用这个 lambda 写了我自己的部分和。你们可能会注意到一些很酷的东西,比如,我用零初始化了 sum
。我用了这个 mutable
,关键字在这里。这意味着这个 lambda 是一个可变 lambda,我可以实际地,改变 lambda 中 sum
的值,因为通常,你为 lambda 创建的所有值都是 const 的,并且,而且这里有一个赋值,你可以使用,那个 C++ Insights 工具来看看。我认为这是一个完美的案例,可以看看,lambda 实际上在做什么,以及为什么,你的 lambda 可能没有按你期望的方式执行,它们在编译器看来是什么样子,以及 mutable
lambda 实际上改变了什么。所以 C++ Insights 是查看这类东西并了解,这将如何实际展开的一个很棒的工具。
而且通常,在,生产代码中,我不会这样做。如果我想这样实现,我会写一个叫做 partial_sum
的函数,我会在那个函数里做所有的计算。因为注释应该描述你为什么做某事,而不是,你在做什么。“做什么”应该是函数名,然后,你知道,你就不再需要这个注释了。所以这是一个经验法则,我们在我们的代码库中使用的。如果你看到一个像那样的注释,描述它下面的代码块在做什么,那么这段代码就应该被提取成一个独立的函数。它应该被重构出来。这就是一个独立的函数。然后,我有一个 step
和 temp
,第二个临时结果,我想使用 slide_view
,我在那里,迭代,相邻的元素。你也可以用 pairwise
来做这个,或者,adjacent
,同样带参数 2
。区别在于,slide
有一个运行时参数用于窗口大小,而 adjacent
有一个编译时参数用于窗口大小。所以,你知道,你可以选择你喜欢的,最终你得到几乎相同的东西。slide
返回一个子范围(subrange
),adjacent
返回一个包含值的元组。我本可以在这里使用 pairwise
,因为我有一个,在编译时已知的窗口大小。
然后我又做了一次变换,只是对子范围做了一些处理,没什么重要的。最后,我要调用 inner_product
。数值库让我有点伤心,因为我觉得标准库,标准委员会讨厌它。我喜欢它,因为我认为 inner_product
是一个非常棒的函数,我们甚至没有它的基于范围的版本,因为它在 <numeric>
头文件里,而不是在 <algorithm>
头文件里。我可以写我自己的 inner_product
,我以前也写过,但后来我想,好吧,我就用我现有的变体,也就是那个非基于范围的版本。它工作正常,但它有个好名字。你知道它在做什么,你不需要,把它写出来。是的,我希望这至少能是,基于范围的,或者是一个范围适配器。range-v3 有一个基于范围的 inner_product
版本。是的。而且我还需要偏移第一个输入范围,这里,因为第二个输入范围短一个元素。因为你在处理相邻元素,你的输出比输入短一个元素。这只是,你知道,处理两个相邻元素的结果。是的。
外层范围。这是否意味着调用者有责任确保两个范围,两个输入范围大小相同?是的。好的。但据我所知。是的。好的。但是在这个例子中,因为你的输入范围是从函数的参数推导出来的,这是否意味着这个函数要求它的输入范围 rng1
和 rng2
大小相同?是的。它们,是的,它们需要大小相同。是的。我,是的,我也没有在这里做任何检查,所以我没有检查大小。我没有检查,空范围。你知道,我,我没有做任何那种事情。是的。
最后我想在这段代码中强调的一点是,到目前为止我们在范围中看到的是惰性求值。正如我之前所说,它们都是范围适配器。直到我们碰到,我打算用我的激光笔,直到我们碰到这个分号,因为 inner_product
是第一个实际需要我的范围值的函数。这就是所有东西被求值的地方。了解这一点关于惰性求值很重要。
是的。我做的,在 C++ 17 中,我基本上一样。我有一个临时向量。我在标准库中有一个 partial_sum
版本,我将在这里使用。正如我所说,我不想在 C++ 23 版本中使用它。我本可以用的,但我想让所有东西都保持惰性直到 inner_product
。而这个也是急切求值的,我不想让它在这个点被求值。
然后我使用 adjacent_difference
,这也是一个绝佳函数的糟糕命名的好例子,因为你可以,你可以用这个函数的标准参数计算差分,但你不必那样,你可以获取你的相邻元素并对它们做任何你想做的事情。所以它也许应该叫做 adjacent
或者类似 adjacent_elements
的名字。我不知道。但我给它一个 lambda,我就对我的元素做任何我想做的事。我不管。所以是绝佳的函数,糟糕的名字。然后我使用了和之前完全相同的 inner_product
调用,但我需要,也将第二个临时变量偏移一个元素,因为 adjacent_difference
的一个非常恼人的事情是,你的输出范围大小和输入范围相同,因为标准库的规定。但是,问题在于,如果你在处理相邻元素,你知道,输出会少一个元素。我不喜欢他们当初的设计决定,把输入范围的第一个元素存储为你输出范围的第一个元素。如果他们存储的是最后一个元素,也许你可以直接把输出范围的大小调整为小一个,那么一切就都好了。但如果你想切掉第一个元素,你需要在内存中复制整个向量来切掉你不需要的第一个元素。我认为这不好,我不知道为什么那样做。我没有做过任何研究,但我不喜欢它。我觉得这很烦人。对于 C 风格,现在我们有一些 new
,new
。所有这些都用索引循环。我甚至懒得,在上面加注释。我希望很明显我不喜欢这段代码。我认为它不可读。我知道这很主观。我知道有人认为这比范围版本或算法版本可读性强得多。但我觉得如果我看着这段代码,我不知道它在做什么。所以我需要一个注释或者一个好的函数名来真正知道它在做什么或打算做什么。所以我不是它的粉丝,但它做的是完全相同的事。不必相信我。同样在这里,我们需要删除临时变量,这样我们就不会泄漏任何内存。我们只是假设一切正常,直到我们删除内存。
最后,我只是调用这些函数。我有一个向量作为输入。这是第一个函数的输入。然后第一个函数的输出是第二个函数的输入。然后这两个输出是第三个函数的输入。就是这样。你知道,就像我说的,这没有任何意义。就是这样。所有版本都一样,包括一些 delete
。一些 delete
。
现在是我使用的第一个工具,Google Benchmark。我真的很喜欢 Google Benchmark,因为它是平台无关的。设置起来超级容易。你可以一次性对你代码的所有不同版本进行基准测试。设置这个,你写一个 static void
函数,它接收一个基准测试状态作为输入。然后你做所有你不想被基准测试的准备工作。然后你有一个在状态上循环。然后你做所有你想被基准测试的东西。如你所见,我在这里使用了 Google Benchmark 的 DoNotOptimize
版本或选项,因为事实证明编译器超级聪明。它们能识别出如果你在计算某些东西却没有对结果做任何事。然后它们可能会省略整个计算。所以为了防止这种情况,你可以使用 DoNotOptimize
。然后你有这个宏,在那里你注册你的基准测试。我把输出也改成了毫秒。默认是纳秒。所以总是一个巨大的数字。然后你调用 benchmark_main
。然后你就完成了。这就是整个设置。我觉得它太容易用了。然后你会得到一些输出。所以第一个输出是针对 C 风格 C++ 的。我们用那个代码花了 23 秒。C++ 17 花了 33 秒。哎呀。C++ 23 代码可能有点问题。我们将找出问题所在。我们会修复它。这是个剧透。我们会修复它。但是的。所以这是第一个提示,表明有些不对劲。
现在我们要用 Cachegrind。我一直想用 Valgrind 和它的工具。这是个谎言。我没有。但我觉得分析这些不同程序的缓存使用情况可能很有趣。所以 Cachegrind 是一个性能分析工具。你可以用它来优化你程序的缓存使用。它试图模拟处理器缓存层次结构的行为,并提供一些关于你程序如何使用缓存的洞察。
所以这就是你如何为你的程序运行 Cachegrind。当然有一个命令行工具。你给它一堆不同的命令。第一个是它是 Valgrind 工具集的一部分,但你想执行 cachegrind
。第二个是 --branch-sim=yes
,它开启分支预测器。下一个是 --cache-sim=yes
。在第一次做这个演讲和这次演讲之间,Cachegrind 有一个更新。我将从发布说明中读一下:“缓存模拟是旧的,不太可能匹配任何真实的现代机器。这意味着默认情况下只收集 I1 事件。但这是迄今为止最有用的。” 所以他们不鼓励你把这个设为 yes
。我仍然有包含完整输出的结果。我仍然认为看看它很有趣。但要持保留态度。甚至 Cachegrind 的程序员自己都说对于现代机器来说,它不准确。然后最后一个参数是你要执行的程序。
所以我只是浏览所有的块。我们看到的第一个块是如果你用默认参数运行 Cachegrind 仍然会得到的那个。然后你会得到一堆其他输出,这些现在需要设置才能得到。
所以我们从 C 风格版本开始。第一个块包含所谓的指令缓存或缓存统计。指令缓存包含执行你程序的指令。处理器需要一条指令。它首先检查缓存。如果它在那里找到了指令,那么它可以从那里获取,这非常快。这叫做缓存命中。如果它在那里找不到你的指令,它必须从主内存或另一个缓存中获取。那就比较慢,那叫缓存未命中。所以这就是当你们谈论缓存时听到的,比如缓存命中、缓存未命中等等。你想优化缓存命中,而不想有很多缓存未命中。
我们有不同级别的缓存。一级是最快的缓存。它是最小的,但也是最快的缓存。然后你的电脑可能有更多核心和不同的缓存,它可以在较低级别的缓存中存储更多东西,那些缓存稍慢一些。最慢的是你的硬盘。所以如果它不在任何缓存中,它就在你的硬盘上,然后你需要加载它,那就很慢了。是的。
然后我们有数据引用,这和指令缓存非常相似。你有一些数据需要在你的程序中交互。就像我们有一个带一些值的向量,它需要存储在某个地方。它在我的内存中的某个地方,可能不是硬盘,就像我之前说的,可能是我的内存。所以是的,我在内存中有一些值。这是我的巨大向量。它放不进任何缓存。现在我的程序必须从主内存获取值,你知道,这取决于索引和我在程序中的位置。所以我这里也有一些缓存未命中,并且你应该努力降低缓存未命中率,因为再次访问数据,即使是和指令缓存一样,也是一个慢操作。
下一个块是最后一级缓存,这只是把所有你电脑的最后一级缓存、较高级别的缓存捆绑在一起。这个最后一级缓存,是的,它包含,它由多个缓存组成,它是最高级别的,并且,在多个进程和核心之间共享。所以这是一个更大的缓存。但它同时存储指令和数据,总是包含指令和你的数据。
然后,最后我们有分支,你代码中的分支。是的,那些用于执行你程序的分支,这些分支分解为条件分支和,是的,条件分支,根据条件改变程序的执行流程。这可以是在 if
语句中,也可以是在循环中,任何你有条件的地方。那么你有一个条件分支改变。然后你有间接分支,这用于跳转到程序中、内存中的不同地址。这可能发生在,函数调用期间。然后我们有,一些误预测。那也是,当分支预测错误发生时。这是当你的处理器的分支预测机制预测了错误的分支。然后如果它预测了错误的分支,那么它就不在过去的缓存中。然后它必须获取一个不同的分支并加载它。那又变慢了。所以再次强调,所有的缓存未命中,所有的误预测,你都希望尽可能低。并且,使用现代 C++,我们可以尝试优化分支误预测。我从未用过这些关键字,但有一些关键字 likely
、unlikely
,可以放进你的代码中,也许能帮助分支预测器预测你的哪个分支更可能执行,哪个不太可能,但我还没有尝试过。所以我们还没有真正看数字。
但是,如果我凭我有限的经验看这个,程序其实表现得很好。我们有,相对较高的数据未命中率,但整体的数据未命中率也没那么高,我们可以在与其他程序的比较中看到。然后我对 C++ 17 做了同样的事。当然,你们现在脑子里都有我刚才展示的结果,现在你们在比较所有的值。好的。你们不必做,我替你们做了。只是,如果你们拿到幻灯片,想看看所有的数字,你们可以那样做。所以现在我们看到了不同版本之间的差异,你们可以看到 C++ 23 代码的指令引用超级高。再次,这与,我们遇到的性能问题有关,我们将修复它。另外我也不太确定为什么 C++ 17 也总是那么高。也许标准库有一些,一些更多的重载。其实不太确定。我觉得有趣的是,指令未命中的实际数字在所有这些中都非常相似。它们都是 0%。然后还有数据引用,在 C++ 23 版本中要多得多。但超级有趣的是,对于 C++ 23 版本,未命中率非常低。这与惰性求值有关,因为对于所有其他程序,我们创建了一些临时变量,我们在内存中有更多的数据需要读写。但对于 C++ 23 版本,我们没有那些。我们只有一个输入向量,我们在需要的地方就地计算所有我们需要的值。这就是为什么我们不需要在内存中到处去获取这些值。同样,是的,这里 C 风格的数据未命中率相当高。我不是这方面的专家。我无法解释为什么会这样。如果你们是专家,你们可以解释。还有最后一级缓存,对于 C++ 23 版本又没什么可做的,因为所有的数据,是的,都在一个输入范围里,它不需要做所有的读写操作。而分支方面,C++ 17 也是,很多很多分支。我不知道为什么。我真的不知道。也许我应该用 C++ Insights 看看并比较一下。也许行。我不确定。但再次,超级有趣的是,误预测率在所有版本中实际数字是相同的。所以是的。
所以如果我想优化这些程序,我会看这三个值。
现在我们尝试一个现场演示。所以我要用 Visual Studio Profiler。我,我,我猜你们对这个很熟悉。是的。好的。没有太多时间了。所以,我为我的三个版本都写了一个主函数。我逐个运行它们。我处于 Release 模式。如果你要执行你的分析器,你进入“调试”>“性能探查器”,然后你有很多东西可以更改。我从不更改任何东西。我只用 CPU 使用率然后点开始。我喜欢简单的工具。我喜欢只点一个按钮。这个花了三秒钟完成。现在你这里有一些统计数据,不会详细讲这些统计数据,我不确定这个对 C++ 代码的分析效果如何。我觉得如果你有像 C# 代码,你想看你花了多少时间在计算内核上,花了多少时间在你的框架之类的东西上,它更有价值。通常为了访问这个,我点击这里的任何一个函数。然后,你可以选择不同的视图。你有“调用方/被调用方”、“调用树”、“模块”、“函数”和“火焰图”。我喜欢火焰,所以我转到火焰图,因为它给了我一个很好的概览,显示我的程序实际在哪里花费了时间。它把所有时间都花在了 main
上,这很好。我们想要这样。然后我有我的三个不同的子函数。如你所见,它在第三个函数上花了最多时间。所以对于第一轮代码优化,我不会太关注第一个,但我会关注我花了最多时间的那个。然后我们应该做你的,高亮显示,我们在哪里花费了所有的计算时间。我们在这个循环中有 18%。我们在那个循环中有 18%。然后在这个循环中有 8%。我猜这个,这里的 8% 也属于这个循环。它并不总是超级准确,但你知道,通常所有东西属于哪里。所以,我首先要优化的东西,我看到我有两个临时变量。我在那里有三个循环,我可以把所有东西塞进一个循环里。因此,我可以省略两个临时变量,你知道,所有数据读写内存的事情都发生在那里。我也可以,加快执行时间,因为我只剩下一个 for 循环,而不是执行三四个循环。所以这就是我们要做的来优化这个版本。我认为这个诊断会话真的很有帮助。
现在我们转到 C++ 17。做完全一样的事情。只需在性能探查器上点击开始。这不是一个关于分析器的教程。我知道你可以设置一些东西。是的,这里时间实际上更短,但我们在 Google Benchmark 中看到它运行时间更长,正如我说的,这也是因为我用的电脑不同。再次,我转到火焰图,看看我在哪里花了所有时间。这里我也有一个很好的概览,我调用的不同子函数,我看到完全相同的模式。我在第三个函数花了最多时间,这并不奇怪。那是因为第三个函数又有三个循环。它隐藏得更深一些,因为我们调用了算法,但一个算法就是一个循环。是的,所以这里的优化实际上会和 C 风格 C++ 完全一样。尝试省略临时变量。尝试省略循环。如果你在优化性能,你知道,如果你的程序已经够快了,你想让它漂亮可读等等,你可以保持原样,但只是,你知道,如果你想优化什么,这是我会开始的一个点。
现在我们要找出为什么我们的范围表现不佳。所以让我们看看。我觉得这是有趣的部分。这里计算超级快。现在我一头雾水。我知道我在 main
里。很好。然后我在这个函数里,这是对的。我不在这行。我在这行,但我所有的时间都花在 inner_product
上,这是对的,因为这是我需要我的值被求值的地方。然后我有一些来自标准库的内存地址,我在 inner_product
里。这是对的。然后我,在标准库的某个地方,我完全不知道发生了什么。我在 advance
里。我在推进元素然后获取值。我不知道你们怎么看,但这没帮我弄清楚我的问题出在哪。我的性能问题在哪里?为什么这么慢?我尝试做同样的事,在调试模式下做,我事先准备好了这个,因为它运行了三分半钟。所以我想也许在调试模式下我能看到更多。它在调试模式下不可靠,它会警告你这一点。但有时你能看到些东西?所以如果我们看调试模式的输出,我们看到多一点。但再次,我们,在最后一个函数里,我们现在在 main
函数。我们在 inner_product
里。这是 inner_product
,但现在我有稍微多一点输出。当我看到这个输出时,有些东西在并行运行。当我看到这个输出,这实际上是我们返回并传递通过,我们函数的视图的类型。当我看着这些类型时,不断回到我脑海的一件事是,是笛卡尔积。它一直回到我脑海里,我一直看到它无处不在。我对自己说,等等,我只是,我,我调用这个一次。为什么笛卡尔积到处都是?所以笛卡尔积,如我所说,是一个嵌套循环,对吧?然后我回到代码,试图弄清楚,发生了什么。为什么我的笛卡尔积到处都是?问题是我自己,典型地。如我在这段代码中说的,我真的很,真的很,真的很,真的很,真的很,真的很。而在这段代码中,我返回的是一个关于我希望如何计算值的描述。这就是笛卡尔积。如果我们去看调用所有东西的那个函数,你会看到我们把向量输入到第一个函数。现在你得想象一下,这个 range1
就像是你的笛卡尔积视图和所有东西。然后我把这个放进第二个函数。现在这只是添加了指令。所以现在 range2
,这个临时变量,是 stuff
和 stuff
的笛卡尔积。然后所有在第二个函数中添加的东西。然后我把这两个关于我希望如何计算值的描述都放进第三个函数。然后它分别对它们两个进行求值。这就是问题所在。我们将在幻灯片中看到如何非常容易地修复这个问题。但那样就不再全是惰性的了。
所以,好的。我们从 C 风格 C++ 开始。再次,我不会太深入细节。你们得相信我,我把所有三个循环合并成了一个循环,并且一切工作正常。所以第一个循环是,是,到了这部分。所以是部分和计算。第二个循环是相邻元素处理,到了循环的这个部分。然后第三件事是计算内积。然后那到了循环的那个部分。所以现在这是优化版本,所有东西都放进一个循环来计算一切。现在它甚至比之前更不可读了。我觉得你可能需要一些注释在上面。是的。并且,对于 C++ 17,如我所说,基本上是相同的优化,去掉临时变量,去掉循环。但因为我在 C++ 17 里,我喜欢用算法,我用,我打算用,我打算用相同的东西。我用 accumulate
,因为 inner_product
,本质上,累积一个乘积的结果。
是的。我喜欢 accumulate
。所以我在这里用了它。
然后优化 C++ 23 版本。我在 Visual Studio 代码里给你们展示了,我的输入在哪里,它们是如何,在程序中传递的。并且,再次,range1
, range2
包含关于我希望如何计算我的值的指令,但它们,range2
包含和 range1
完全相同的指令。然后它被计算了两次或求值了两次。然后你那个大嵌套循环就被求值了两次。
要修复这个问题,这是代码,用于修复的。我只改了一件事。是 range2 = range2 | ranges::to<std::vector>();
。我们在这个会议上也听到过,超级,超级方便。你可以存储,你的视图、你的适配器、所有东西的输出,到一个标准容器或非标准容器里。如果它符合,范围到接口,你知道,如果我返回一个 pair
,我可以创建一个 map
。那,那超级容易。而且因为我到处都用 auto
,我什么都不用改。整个代码仍然运行,仍然有效。这是我唯一需要改变的东西,但现在这不再是惰性的了。这是急切求值的。但在这个案例中,我想要这样,因为我只想让这个大嵌套循环被求值一次。这就是我如何强制它在这个时刻被求值。
所以我们再次对这个进行基准测试。我们可以看到,对于 C 风格 C++,我们把时间减半了。对于 C++ 17 版本,我们也把时间减半了。然后对于,范围版本,我们变得超级,超级快,仅仅通过求值了实际的嵌套循环。我们超级,超级快。我运行了多次。它总是比 C 风格版本快一点点。我对结果非常满意。
所以我再次运行了 Cachegrind,我不会过多讨论这个细节。你们记住了之前的输出,现在在比较。我知道,我替你们做了比较。所以所有的统计数据也都下降了,这说得通。程序更简单了。更容易了。你有更少的临时变量。你有更少的循环要执行。是的,数据引用也下降了。未命中实际上减半了。百分比仍然很高,但那也是因为数据引用,我猜,超级低。所以百分比才那么高。不确定这是否是个实际问题。如果你们在代码中遇到它,再次,我不是专家。
分支也下降了,因为程序更简单了。你有更少的 for 循环。你有更少的东西。你的程序更简单了。分支下降了。这是 C++ 17 版本。再次,我为你们做了比较。是的。所以指令引用也下降了,这说得通。和 C 风格相同的优化。数据引用也下降了,这也说得通。少了临时变量,要读写的数据更少了。最后一级缓存也下降了,更少的工作要做。分支也下降了。所以程序比之前简单了一点。再次,这说得通。分支仍然相当高。就这样。
然后对于 C++ 23,如果我们看那个,我们看到,指令引用大幅下降。数据引用整体也下降了,但未命中率上升了。现在未命中率上升是因为我之前提到的事情。现在我们创建了一个额外的临时变量,而不是在其他部分省略一个。现在我们内存中有更多的数据需要读写。所以我们自然会有更多的缓存或数据未命中,在最后一级缓存中也有多一点事情要做。分支也下降了。是的。
这是它们所有在一起的比较。不确定我的动画是否有效。是的。所以,我,我实际上高亮了,至少之前标出了哪里,是的,哪个是最好的。这并不意味着那个程序是最好的程序。我的意思是,这只是,你知道,我之前想说的。这只是统计数据。这并不意味着仅仅因为你的数字在某个版本的某部分低,并不意味着这是最好的程序。你知道,它不一定意味着它是,最快的。所以 C 风格在指令引用和数据引用以及分支方面得分很高,但它比 C++ 23 的范围版本稍微慢一点。所以仅仅因为这里的数字高,并不意味着程序差。它只是意味着,你知道,如果你在优化某个特定方面,这可能是一个你想关注的地方。
那么接下来该做什么?接下来,如果性能对你来说足够好,我会就此打住,但是,你可以再次使用分析器,看看,你是否能进一步改进代码,你在这方面的进展如何,也许你可以使用其他工具来分析不同的事情,那些你真正想强调或改进的东西。是的,那将是接下来的步骤。
所以总结一下,我通过设置这些东西学到了很多关于 Linux 系统、WSL 等等的知识。我真的很喜欢它们协同工作的方式。在你选择,编写代码的任何编码风格中,都有需要注意的事项。范围仍然非常新,它们的陷阱不像 C 风格 C++ 那样为人所知。所以如果你看到一个原始循环,你知道你必须检查你的索引。你必须确保你不会,越界访问容器的内存等等。但这是因为我们习惯了。我们知道要注意什么,但范围是超级新的。这就是我们需要学习要注意什么的地方。
我遇到的,问题不是范围的问题,而是惰性求值的问题,因为我不习惯惰性求值,因为到目前为止我做的一切都是急切求值的。所以这是一种不同的思维方式,当你使用范围时,你必须记住这一点。而且,如你所见,范围的性能非常好,没有牺牲可读性。我知道可读性也是人们争论的事情。有些人根本不觉得它可读。我觉得它非常可读。但再次,当你更习惯使用这个特性时,你会发现它更可读。而且,是的,你应该使用,不同的工具来,分析你程序的不同方面。我这里只用了三个工具,因为我没法再塞更多了。但还有更多工具可以用来分析你代码的不同部分。
但是有一件事我想强调,我认为我们需要对范围和惰性求值提供更好一点的支持,因为我不知道你们怎么看,但我无法从 Visual Studio Profiler 中弄清楚我的问题到底出在哪里。我的性能瓶颈在哪里?为什么我的程序表现不佳?因为我们都在标准库里。我们在 advance
里。是的。它完全没有帮到我。所以,如果你在构建工具,构建一个能帮助的工具,也许能可视化惰性求值,它可能出错的地方。是的。所以我认为我们需要更多这方面的支持。
是的,就这样。谢谢。
所以,我,我有个问题。那个 C++ 23 的性能问题,你通过急切求值那个在两个地方被使用的范围修复了。是的。所以直观上,我会期望惰性求值那个会导致大约两倍的减速,但我们实际上看到它导致了大约二十倍的减速。你对这是如何发生的有何直觉吗?
所以我的猜测是,这是因为计算实际有多复杂。问题不在于惰性本身,而在于,它其实是三个范围的嵌套循环。所以嵌套循环总是,你知道,很慢,求值它们需要一段时间。我认为性能就耗在那里。如果那个范围更简单一些,我可能还在寻找我的程序在哪里变慢了。这之所以超级明显,只是因为我看到这个笛卡尔积类型像无处不在一样出现在巨大的类型名里。但这是我唯一的线索,表明这是问题所在。是的。没有更多问题了。并且谢谢你。谢谢。谢谢。