简单 for 循环的意外无意义优化尝试(在 Visual C++ 2017 中)

Unexpected senseless optimization tries of a simple for loop (in Visual C++ 2017)

提问人:Andreas H. 提问时间:8/6/2017 最后编辑:Andreas H. 更新时间:10/13/2017 访问量:320

问:

我正在玩 Visual C++ 2017 编译器,做一些关于如何实现各种东西以从代码中获得最大性能的测试,当我遇到一个我没有预料到也无法解释的行为时。

我创建了一个简单的 foreach 方法来处理容器中的所有值。容器本身仅存储 a 和 .size_t sizeint *ptr

这是 foreach 方法的代码,在 f 中采用 Lambda:

template<class F>
__declspec(noinline) void foreach(F f)
{
    for (size_t i = 0; i < size; ++i)
        f(ptr[i]);
}

我用

int sum = 0;
v.foreach([&](int item) { sum += item; });

编译时,二进制文件的一部分如下所示:

        for (size_t i = 0; i < size; ++i)
00007FF6B3382310  xor         eax,eax  
00007FF6B3382312  mov         qword ptr [f],rdx  
00007FF6B3382317  cmp         qword ptr [rcx],rax  
00007FF6B338231A  jbe         MyVector::foreach<<lambda_c1957c9a484ac2f96c41b63c392e4950> >+2Ah (07FF6B338233Ah)  
00007FF6B338231C  nop         dword ptr [rax]  
            f(ptr[i]);
00007FF6B3382320  mov         r8,qword ptr [rcx+8]  
00007FF6B3382324  mov         r9d,dword ptr [r8+rax*4]  
        for (size_t i = 0; i < size; ++i)
00007FF6B3382328  inc         rax  
            f(ptr[i]);
00007FF6B338232B  add         dword ptr [rdx],r9d  
        for (size_t i = 0; i < size; ++i)
00007FF6B338232E  cmp         rax,qword ptr [rcx]  
00007FF6B3382331  jae         MyVector::foreach<<lambda_c1957c9a484ac2f96c41b63c392e4950> >+2Ah (07FF6B338233Ah)  
00007FF6B3382333  mov         rdx,qword ptr [f]  
00007FF6B3382338  jmp         MyVector::foreach<<lambda_c1957c9a484ac2f96c41b63c392e4950> >+10h (07FF6B3382320h)  
    }
00007FF6B338233A  ret  

分析:

  • 的地址也是如此,也是 的地址(对象的第一个成员也是如此)。rcxsizethissize
  • rdx看作是 Lambda 函子的地址,如果是 Lambda 的成员,它也是地址。sum
  • 在地址 xxx320 中,加载到寄存器 r8 中。ptr
  • 从 xxx324 到 xxx338 是主循环。将 ptr[i] 的值加载到 r9d 中,rax (ptr) 递增,并将 r9d 添加到 Lambda 对象的 sum 成员中。

我的 3 个问题中的前 2 个是:

  • 为什么地址 xxx320 是循环的一部分?R8 未更改,PTR 成员未标记 。xxx338 中的 jmp 不应该指向 xxx324 吗?volatile

  • 为什么 lambda 地址在每个循环中都从 xxx312 中的 to 缓存并恢复到 xxx333 中?RDX 没有改变,那么为什么编译器要重新加载它呢?rdx[f]rdx

我试图摆脱这些“低效率”,发现以下源代码创建了更合理的机器代码:

template<class F>
__declspec(noinline) void foreach(F f)
{
    register auto f2 = f;
    register auto p = ptr;

    for (size_t i = 0; i < size; ++i)
        f2(p[i]);
}

生成的机器码是

    register auto f2 = f;
        register auto p = ptr;
00007FF7E79A2340  mov         r9,qword ptr [rcx+8]  

        for (size_t i = 0; i < size; ++i)
00007FF7E79A2344  xor         eax,eax  
00007FF7E79A2346  cmp         qword ptr [rcx],rax  
00007FF7E79A2349  jbe         MyCheckedVector::foreach<<lambda_27103c2606044b6f9a288cfb44283d2c> >+1Fh (07FF7E79A235Fh)  
00007FF7E79A234B  nop         dword ptr [rax+rax]  
            f2(p[i]);
00007FF7E79A2350  mov         r8d,dword ptr [r9+rax*4]  

        for (size_t i = 0; i < size; ++i)
00007FF7E79A2354  inc         rax  
            f2(p[i]);
00007FF7E79A2357  add         dword ptr [rdx],r8d  

        for (size_t i = 0; i < size; ++i)
00007FF7E79A235A  cmp         rax,qword ptr [rcx]  
00007FF7E79A235D  jb          MyCheckedVector::foreach<<lambda_27103c2606044b6f9a288cfb44283d2c> >+10h (07FF7E79A2350h)  
    }
00007FF7E79A235F  ret  

它修复了原始来源的问题:

  • ptr或仅在方法的开头加载。r9
  • rdx(lambda 的地址)不会被缓存,而只是存储在寄存器中。rdx
  • 主循环仅从 xxx350 tp xxx35D 迭代(将 ptr[i] 加载到 ,在 中增加 i,将 ptr[i] 缓存到 Lambda 成员总和中 address,检查 >= size in address atr8draxr8drdxraxrcx

我的第三个问题是:

如果机器代码实际上以与原始版本相同的速度运行,为什么会出现这部分?它每个循环只有 5 条指令,而原始循环中有 8 条指令,所有 5 条指令也都存在于原始循环中。

也许我还应该提到我的优化设置:

  • 全面优化
  • 内联功能扩展:任何合适的
  • 内部函数:是
  • 偏爱快速代码
  • 全程优化
  • 增强型指令集:高级矢量扩展 2 (AVX2)

我知道我应该把优化留给编译器,但有时我会出于好奇而深入研究机器代码。我真的很感激任何解释!

---更新---

我创建了一个示例,它不是 100% 的原始代码,而是创建相同的机器代码。该示例在内部循环中仅使用 1K 个值(4000 字节数据),因此内存访问不应限制性能。

#include <iostream>
#include <ctime>
#include <conio.h>
#include <algorithm>
#include <vector>

static const auto OuterLoops = 100000;

class MyContainer
{
    static const auto Items = 100000000 / OuterLoops;

public:
    MyContainer()
        : size(Items),
        ptr(new int[Items])
    {
        // Make sure memory is paged
        std::fill(ptr, ptr + size, 0xA5);
    }

    template<class F> __declspec(noinline) auto foreach1(F f)
    {
        for (size_t i = 0; i < size; ++i)
            f(ptr[i]);
    }

    template<class F> __declspec(noinline) auto foreach2(F f)
    {
        const register auto sizer = size;
        const register auto fr = f;
        const register auto ptrr = ptr;

        for (size_t i = 0; i < sizer; ++i)
            fr(ptrr[i]);
    }

private:
    size_t size;
    int *ptr;
};

template<class F>
void measureSpeed(const char *const caption, F f)
{
    std::vector<int> results(11);

    for (auto& result : results)
    {
        for (auto a = clock(); a == clock(); );
        const auto start1 = clock();

        for(int i = 0; i < OuterLoops; ++i)
            f();

        result = clock() - start1;
    }

    std::sort(results.begin(), results.end());
    std::cout << caption << ": " << results[results.size() / 2] << " (";
    for (const auto& result : results)
        std::cout << result << ' ';
    std::cout << "\b)" << std::endl;

}

int main(int argc, char **argv)
{
    MyContainer c;

    int s1 = 0;
    measureSpeed("foreach1", [&]() { c.foreach1([&](const auto v) { s1 += v; }); });

    int s2 = 0;
    measureSpeed("foreach2", [&]() { c.foreach2([&](const auto v) { s2 += v; }); });

    if (s1 != s2)
        std::cerr << "Comparing nonsense" << std::endl;

    _getch();
    return 0;
}

我得到的结果有点不同,但 foreach2 总是比 foreach1 慢 5% 左右:

foreach1: 195 (190 191 192 193 195 195 195 195 196 196 197)
foreach2: 207 (202 202 205 206 206 207 208 208 212 213 214)

(第一个值是所有测试运行的中位数,后跟所有测试运行的排序计时。这些值来自 clock() 并且未转换,但至少在 Visual Studio 平台工具集 v140 中,这些值是毫秒。

C 循环 程序集 visual-c++ 优化

评论

0赞 Rakete1111 8/6/2017
也许这是给定的,但您是在启用优化的情况下进行编译的,即在发布模式下,对吧?
0赞 Andreas H. 8/6/2017
我想我正在使用最大优化。我已将我的设置添加到问题中。
0赞 doynax 8/6/2017
您是否尝试过向数组指针添加限定符,以防优化器在针对 lambda 状态反驳别名时遇到问题?你会认为它不会,但话又说回来,它不会受到伤害。__restrict
0赞 doynax 8/6/2017
无论如何,尽管丢弃指令,但性能没有提高,这通常表明瓶颈在其他地方。在这种情况下,如果数组很大,我倾向于怀疑内存访问。
1赞 AndyG 8/6/2017
你能发布一个最小的可重复的例子吗?

答:

0赞 mksteve 10/13/2017 #1

为什么地址 xxx320 是循环的一部分?R8 未更改,PTR 成员未标记为易失性。xxx338 中的 jmp 不应该指向 xxx324 吗?

为什么 lambda 地址在 xxx312 中从 rdx 缓存到 [f],并在每个循环中恢复到 xxx333 中的 rdx?RDX 没有改变,那么为什么编译器要重新加载它呢?

R8 和 RDX 都是易失性寄存器,它们在函数调用中无法存活,因此在某些时候,优化器会认为对 f() 的调用会导致值溢出。

当它优化掉实际的函数调用时,它可能不会将代码作为单个指令流进行检查并删除冗余的重新加载。

性能是由 add 函数驱动的,这意味着额外的不需要的操作不会驱动代码的性能。