GCC 14 引入了新的
-Wnrvo
标志:
新的
警告,如果未执行指定的返回值优化,则发出警告,尽管[class.copy.elision]允许这样做。请参阅手册了解更多信息。-Wnrvo
我决定看看如果我将标志添加到开源多媒体库SFML 3.x,会弹出什么警告,并且出现了一些警告,所有警告都与此处显示的情况类似:
struct JoystickCaps
{
unsigned int buttonCount{};
std::array<bool, 8> axes{}
};
// Early return version (original)
JoystickCaps JoystickImpl::getCapabilities() const
{
if (directInput)
return getCapabilitiesDInput(); // <- early return
JoystickCaps caps;
caps.buttonCount = m_caps.wNumButtons;
if (caps.buttonCount > Joystick::ButtonCount)
caps.buttonCount = Joystick::ButtonCount;
// ...set more members in 'caps'...
return caps;
}
此代码被 GCC 的新警告标记为:
./SFML/src/SFML/Window/Win32/JoystickImpl.cpp:
In member function 'JoystickCaps JoystickImpl::getCapabilities() const':
./SFML/src/SFML/Window/Win32/JoystickImpl.cpp:342:12:
warning: not eliding copy on return in
'JoystickCaps JoystickImpl::getCapabilities() const' [-Wnrvo]
342 | return caps;
| ^~~~
我决定更改代码,使警告静音,但我找到的唯一解决方案是这个:
// NRVO version
JoystickCaps JoystickImpl::getCapabilities() const
{
JoystickCaps caps;
if (directInput)
caps = getCapabilitiesDInput();
else
{
caps.buttonCount = m_caps.wNumButtons;
if (caps.buttonCount > Joystick::ButtonCount)
caps.buttonCount = Joystick::ButtonCount;
// ...set more members in 'caps'...
}
return caps;
}
不太满意,因为该解决方案的可读性较差,恕我直言,我决定对其进行基准测试,看看有什么好处,使用quick-bench.com。
事实证明,使用
-O3
,早期返回版本比 NRVO 版本快 3.8 倍。
问题:
在这种情况下,GCC 的警告是否具有误导性? 在可以提前返回的情况下,有目的地避免 NRVO 是否有意义?
为什么 NRVO 版本慢这么多? 我的基准测试有错误吗?有没有一种方法可以重写该函数,使其既支持 NRVO,又不会比原来的效率低?
TL;DR 这是误导性微基准的噩梦,被复杂的优化和初始化归零所混淆。该警告没有任何帮助,如果您处于性能关键环境中,则保留未初始化的值会产生影响。
我将放弃您在 Quickbench 中提供的
JoystickCaps
的定义,而不是 SFML 定义,因为这似乎与问题更相关
struct JoystickCaps
{
unsigned int buttonCount{};
int axes[16]{};
};
您要进行基准测试的循环是
bool b = true;
for (auto _ : state)
{
auto result = /* one of the functions passed with b as condition */;
benchmark::DoNotOptimize(result);
b = !b;
}
这里需要注意的重要一点是,进行基准测试的函数始终内联到循环本身中。此时,优化器看透
b
变量,然后将两个循环展开为一个循环并完全忽略 b = !b
。
由于我不太明白的原因,此时优化器放弃了将
result
的两个值构造 b
的公共部分折叠在一起,而是为每个值发出单独的指令集。这实际上是您观察到的主要放缓。
如果您改为对这个循环进行基准测试
bool b = true;
for (auto _ : state)
{
benchmark::DoNotOptimize(/* one of the functions passed with b as condition */);
b = !b;
}
优化器的麻烦更少,并且函数的两个版本编译为相同的程序集,这与您看到的“提前返回”版本一样快。
为了观察实际的 NRVO 或不生效,您可以强制函数不内联。在这种情况下,你会看到NRVO版本会先无条件清零
result
,然后根据分支条件决定填充什么值
f1(bool):
pxor xmm0, xmm0
mov DWORD PTR [rdi+64], 0
mov rax, rdi
movups XMMWORD PTR [rdi], xmm0
movups XMMWORD PTR [rdi+16], xmm0
movups XMMWORD PTR [rdi+32], xmm0
movups XMMWORD PTR [rdi+48], xmm0
test sil, sil
相比之下,早期返回版本首先分支并直接填写正确的值。同样,这是由于一些我不太理解的优化器怪癖造成的,就 C++ 标准而言,两者都是合法的。
对于 NRVO 版本来说,这累积起来速度会减慢约 1.2 倍。 与上面相同的快速工作台链接。
最后,解决初始化时的归零问题。如果您要将
JoystickCaps
定义为
struct JoystickCaps
{
unsigned int buttonCount;
int axes[16];
};
然后当且仅当需要时手动将
axes
清零,然后您将看到 result
对象是否存储为局部变量不再阻碍优化器,并且每个人都被编译为相同的快速版本。
此外,如果我们在这种情况下强制使用 noinline,NRVO 实际上会快约 1.7 倍。这种加速是无法保证的,您不应该仅仅因为看到警告就进行重构,因此警告是没有帮助的。