使用静态tmp变量为简单类型C++重新实现std::swap()

Reimplementing std::swap() with static tmp variable for simple types C++

本文关键字:swap 新实现 实现 std C++ tmp 静态 变量 类型 简单      更新时间:2023-10-16

我决定为简单类型(如intstruct或在字段中仅使用简单类型的class)的交换函数的实现进行基准测试,其中包含statictmp变量,以防止在每次交换调用中分配内存。所以我写了一个简单的测试程序:

#include <iostream>
#include <chrono>
#include <utility>
#include <vector>

template<typename T>
void mySwap(T& a, T& b)     //Like std::swap - just for tests
{
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
template<typename T>
void mySwapStatic(T& a, T& b)   //Here with static tmp
{
static T tmp;
tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
class Test1 {       //Simple class with some simple types
int foo;
float bar;
char bazz;
};
class Test2 {       //Class with std::vector in it
int foo;
float bar;
char bazz;
std::vector<int> bizz;
public:
Test2()
{
bizz = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
}
};
#define Test Test1      //choosing class
const static unsigned int NUM_TESTS = 100000000;
static Test a, b;   //making it static to prevent throwing out from code by compiler optimizations
template<typename T, typename F>
auto test(unsigned int numTests, T& a, T& b, const F swapFunction )     //test function
{
std::chrono::system_clock::time_point t1, t2;
t1 = std::chrono::system_clock::now();
for(unsigned int i = 0; i < NUM_TESTS; ++i)    {
swapFunction(a, b);
}
t2 = std::chrono::system_clock::now();
return std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count();
}
int main()
{
std::chrono::system_clock::time_point t1, t2;
std::cout << "Test 1. MySwap Result:tt" << test(NUM_TESTS, a, b, mySwap<Test>) << " nanosecondsn";   //caling test function
t1 = std::chrono::system_clock::now();
for(unsigned int i = 0; i < NUM_TESTS; ++i)    {
mySwap<Test>(a, b);
}
t2 = std::chrono::system_clock::now();
std::cout << "Test 2. MySwap2 Result:tt" << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count() << " nanosecondsn"; //This result slightly better then 1. why?!
std::cout << "Test 3. MySwapStatic Result:t" << test(NUM_TESTS, a, b, mySwapStatic<Test>) << " nanosecondsn"; //test function with mySwapStatic
t1 = std::chrono::system_clock::now();
for(unsigned int i = 0; i < NUM_TESTS; ++i)    {
mySwapStatic<Test>(a, b);
}
t2 = std::chrono::system_clock::now();
std::cout << "Test 4. MySwapStatic2 Result:t" << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count() << " nanosecondsn"; //And again - it's better then 3...
std::cout << "Test 5. std::swap Result:t" << test(NUM_TESTS, a, b, std::swap<Test>) << " nanosecondsn";   //calling test function with std::swap for comparsion. Mostly similar to 1...
return 0;
}

Test定义为Test1(g++(Ubuntu 4.8.2-19ubuntu1)4.8.2称为g++main.cpp-O3-std=c++11)的一些结果:

测试1。MySwap结果:625105480纳秒

测试2。MySwap2结果:528701547纳秒

测试3。MySwapStatic结果:338484180纳秒

测试4。MySwapStatic2结果:228228156纳秒

测试5。std::swap结果:564863184纳秒

我的主要问题是:使用这个实现来交换简单类型好吗?我知道,例如,如果你用它来交换向量的类型,那么std::swap会更好,你只需将Test的定义更改为Test2就可以看到它。

第二个问题:为什么测试1、2、3和4的结果如此不同?我在测试函数实现方面做错了什么?

首先回答第二个问题:在测试2和测试4中,编译器内联函数,因此它提供了更好的性能(测试4中还有更多内容,但我稍后会介绍)。

总的来说,使用静态温度变量可能是个坏主意。

为什么?首先,需要注意的是,在x86汇编中,没有从内存复制到内存的指令。这意味着当您交换时,CPU寄存器中不是有一个,而是有两个临时变量。这些临时变量必须在CPU寄存器中,不能将mem复制到mem,因此静态变量将添加第三个内存位置以进行传输。

静态临时的一个问题是它会阻碍内联。想象一下,如果您交换的变量已经在CPU寄存器中。在这种情况下,编译器可以内联交换,并且从不将任何内容复制到内存中,这要快得多。现在,如果强制使用静态临时,编译器要么删除它(无用),要么强制添加内存副本。这就是测试4中发生的情况,GCC删除了对静态变量的所有读取。它只是毫无意义地向它写入更新的值,因为你告诉它这样做。读取删除解释了良好的性能增益,但它可能会更快。

你的测试用例是有缺陷的,因为它们没有显示这一点。

现在你可能会问:那为什么我的静态函数表现得更好呢<罢工>我不知道(最后回答)

我很好奇,所以我用MSVC编译了你的代码,结果发现MSVC做得很好,GCC做得很奇怪。在O2优化级别,MSVC检测到两个交换是非操作的,并将其快捷化,但即使在O1,非内联生成的代码也比在O3使用GCC的所有测试用例更快。(编辑:实际上,MSVC也做得不对,请参阅最后的解释。)

MSVC生成的程序集看起来确实更好,但当比较GCC生成的静态和非静态程序集时,我不知道为什么静态程序集表现更好。

无论如何,我认为即使GCC生成了奇怪的代码,内联问题也应该值得使用std::swap,因为对于较大的类型,额外的内存拷贝可能会很昂贵,而较小的类型可以提供更好的内联。


以下是所有测试用例生成的程序集,如果有人知道为什么GCC静态程序集比非静态程序集表现更好,尽管它更长,使用了更多的内存移动编辑:最后回答

GCC非静态(性能570ms):

00402F90 44 8B 01 mov r8d,双字ptr[rcx]00402F93 F3 0F 10 41 04 movss xmm0,双字ptr[rcx+4]00402F98 0F B6 41 08 movzx eax,字节ptr[rcx+8]00402F9C 4C 8B 0A mov r9,qword ptr[rdx]00402F9F 4C 89 09 mov qword ptr[rcx],r900402FA2 44 0F B6 4A 08 movzx r9d,字节ptr[rdx+8]00402FA7 44 88 49 08 mov字节ptr[rcx+8],r9b00402FAB 44 89 02 mov双字ptr[rdx],r8d00402FAE F3 0F 11 42 04 movss双字ptr[rdx+4],xmm000402FB3 88 42 08 mov字节ptr[rdx+8],al

GCC静态和MSVC静态(性能275ms):

00402F10 48 8B 01 mov rax,qword ptr[rcx]00402F13 48 89 05 66 11 00 mov qword ptr【404080h】,rax00402F1A 0F B6 41 08 movzx eax,字节ptr[rcx+8]00402F1E 88 05 64 11 00 00 mov字节ptr[4040088h],al00402F24 48 8B 02 mov rax,qword ptr[rdx]00402F27 48 89 01 mov qword ptr[rcx],rax00402F2A 0F B6 42 08 movzx eax,字节ptr[rdx+8]00402F2E 88 41 08 mov字节ptr[rcx+8],al00402F31 48 8B 05 48 11 00 mov rax,qword ptr【404080h】00402F38 48 89 02 mov qword ptr[rdx],rax00402F3B 0F B6 05 46 11 00 00 movzx eax,字节ptr[4040088h]00402F42 88 42 08 mov字节ptr[rdx+8],al

MSVC非静态(性能215ms):

00000f2 0f 10 02 movsdx xmm0,QWORD PTR[rdx]00004 f2 0f 10 09 movsdx xmm1,QWORD PTR[rcx]00008 44 8b 41 08 mov r8d,DWORD PTR[rcx+8]0000c f2 0f 11 01 movsdx QWORD PTR[rcx],xmm000010 8b 42 08 mov eax,DWORD PTR[rdx+8]00013 89 41 08 mov DWORD PTR[rcx+8],eax00016 f2 0f 11 0a movsdx QWORD PTR[rdx],xmm10001a 44 89 42 08 mov DWORD PTR[rdx+8],r8d

std::交换版本都与非静态版本相同。


经过一些有趣的调查,我发现了GCC非静态版本性能不佳的可能原因。现代处理器有一个称为存储加载转发的功能。当内存加载与以前的内存存储相匹配时,此功能就会启动,并缩短内存操作以使用已知值。在这种情况下,GCC以某种方式对参数A和B使用非对称加载/存储。A使用4+4+1字节复制,B使用8+1字节复制。这意味着类的前8个字节将不会被存储匹配到加载转发,从而失去宝贵的CPU优化。为了检查这一点,我手动将8+1副本替换为4+4+1副本,性能如预期一样提高(下面的代码)。最后,GCC没有考虑到这一点是有过错的。

GCC修补代码,更长,但利用存储转发(性能220ms):

00402F90 44 8B 01 mov r8d,双字ptr[rcx]00402F93 F3 0F 10 41 04 movss xmm0,双字ptr[rcx+4]00402F98 0F B6 41 08 movzx eax,字节ptr[rcx+8]00402F9C 4C 8B 0A mov r9,qword ptr[rdx]00402F9F 4C 89 09 mov qword ptr[rcx],r900402F9 C 44 8B 0A mof r9d,dword ptr[rdx]00402F9F 44 89 09 mov双字ptr[rcx],r9d00402FA2 44 8B 4A 04 mov r9d,双字ptr[rdx+4]00402FA6 44 89 49 04 mov双字ptr[rcx+4],r9d00402FAA 44 0F B6 4A 08 movzx r9d,字节ptr[rdx+8]00402FAF 44 88 49 08 mov字节ptr[rcx+8],r9b00402FB3 44 89 02 mov双字ptr[rdx],r8d00402FB6 F3 0F 11 42 04 movss双字ptr[rdx+4],xmm000402FBB 88 42 08 mov byte ptr[rdx+8],al

实际上,这个复制指令(对称4+4+1)是正确的方法。在这些测试中,我们只做复制,在这种情况下,MSVC版本无疑是最好的。问题是,在实际情况下,类成员将被单独访问,从而生成4个字节的读/写。MSVC 8字节的批处理副本(也是GCC为一个参数生成的)将阻止单个成员将来的存储转发。我对副本旁边的成员操作进行了一次新的测试,结果表明,修补后的4+4+1版本确实优于所有其他版本。乘以接近x2的因子。遗憾的是,没有现代编译器生成这种代码。