间接成本 ~ 3 倍的浮点乘法,真的吗?(带演示)

indirection cost ~ 3x of float multiplication, really? (with demo)

本文关键字:真的吗 浮点乘      更新时间:2023-10-16

我刚刚发现间接成本约为浮点数乘法的 3 倍!
这是意料之中的吗? 我的测试有误吗?

背景

在我读到指针间接对效率有多大影响?之后,我对间接寻址成本感到恐慌。

由于现代 CPU 的工作方式,通过指针间接寻址的速度可能会慢得多。

在我过早地优化我真正的代码之前,我想确保它的成本真的像我担心的那样高。

我做了一些技巧来找到粗略的数字(3x),如下所示:-

步骤 1

  • 测试1:没有间接 ->计算一些东西
  • 测试2:间接 ->计算某些东西(相同)

我发现Test2Test1花费更多时间。
这里没有什么惊喜。

步骤 2

  • 测试1:没有间接 ->计算昂贵的东西
  • 测试2:间接 ->计算便宜的东西

我尝试将我的代码更改为calculate something expensive,使其逐渐更昂贵,以使两个测试成本几乎相同。

结果

最后,我发现使两个测试使用相同时间(即收支平衡)的可能功能之一是:-

  • 测试1:无间接 ->返回float*float*...3次
  • Test2:间接寻址 -> 只需返回一个float

这是我的测试用例(ideone演示):-

class C{
public: float hello;  
public: float hello2s[10];  
public: C(){
hello=((double) rand() / (RAND_MAX))*10;
for(int n=0;n<10;n++){
hello2s[n]= ((double) rand() / (RAND_MAX))*10;
}
}
public: float calculateCheap(){
return hello;
}
public: float calculateExpensive(){
float result=1;
result=hello2s[0]*hello2s[1]*hello2s[2]*hello2s[3]*hello2s[4];
return result;
}
};

这是主要的:-

int main(){
const int numTest=10000;
C  d[numTest];
C* e[numTest];
for(int n=0;n<numTest;n++){
d[n]=C();
e[n]=new C();
}
float accu=0;
auto t1= std::chrono::system_clock::now();
for(int n=0;n<numTest;n++){
accu+=d[n].calculateExpensive();  //direct call
}
auto t2= std::chrono::system_clock::now();
for(int n=0;n<numTest;n++){
accu+=e[n]->calculateCheap();     //indirect call
}
auto t3= std::chrono::system_clock::now();
std::cout<<"direct call time ="<<(t2-t1).count()<<std::endl;
std::cout<<"indirect call time ="<<(t3-t2).count()<<std::endl;
std::cout<<"print to disable compiler cheat="<<accu<<std::endl;
}

直接呼叫时间和间接呼叫时间调整为与上述类似(通过编辑calculateExpensive)。

结论

间接成本 = 3 倍浮点乘法。
在我的桌面(带有-O2的Visual Studio 2015)中,它是7倍。

问题

是否可以预期间接成本约为浮点数乘法的 3 倍?
如果不是,我的测试怎么会出错?

(感谢enhzflep提出改进建议,它被编辑了。

简单地说,你的测试非常不具有代表性,实际上并没有准确地衡量你可能认为它的作用。

请注意,您调用new C()100'000 次。这将在您的内存中创建 100'000 个 C 分散实例,每个实例都非常小。现代硬件非常擅长预测您的内存访问是否正常。由于每次分配,每次对new的调用,都独立于其他分配而发生,因此内存地址将无法很好地组合在一起,从而使此预测更加困难。这会导致所谓的缓存未命中。

分配为数组(new C[numTest])可能会产生完全不同的结果,因为在这种情况下,地址是非常可预测的。将您的内存尽可能紧密地分组在一起并以线性、可预测的方式访问它通常会提供更好的性能。这是因为大多数缓存和地址预取程序都希望这种模式在常见程序中发生。

次要添加:像这样初始化C d[numTest] = {};将调用每个元素上的构造函数

间接寻址的成本由缓存未命中数主导。 因为老实说,缓存未命中比您正在谈论的任何其他内容都要贵得多,其他所有内容最终都是舍入误差。

缓存未命中和间接寻址的成本可能比测试指示的要昂贵得多。

这主要是因为您只有 100,000 个元素,而 CPU 缓存可以缓存这些浮点数中的每一个。 顺序堆分配将趋于聚集。

你会得到一堆缓存未命中,但不是每个元素都有一个。

你的两个案例都是间接的。 "间接"情况必须遵循两个指针,"直接"情况必须执行一个指针算术实例。 "昂贵"的情况可能适用于某些 SIMD,尤其是在浮点精度放宽(允许乘法重新排序)的情况下。

如此处所示,或者这个图像(不是内联的,我没有权利),主内存引用的数量将主导上述代码中的几乎所有其他内容。 2 Ghz CPU 的周期时间为 0.5 ns,主存储器基准为 100 ns 或 200 个周期的延迟。

同时,如果可以提取矢量化代码,则桌面CPU每个周期可以达到8+浮点运算。 这比单个缓存未命中快 1600 倍的浮点操作。

间接寻址可能会使您无法使用矢量化指令(8 倍减速),并且如果所有内容都在缓存中,则仍然需要 L2 缓存引用(14 倍减速)的频率高于替代方案。 但与200 ns主存储器参考延迟相比,这些减速幅度很小。

请注意,并非所有 CPU 都具有相同级别的矢量化,正在付出一些努力来加速 CPU/主内存延迟,FPU 具有不同的特性,以及无数其他复杂性。

你的问题没有一个简单的答案。 这取决于硬件的功能和特性(CPU、RAM、总线速度等)。

在过去,浮点乘法可能需要数十个甚至数百个周期。 内存访问的速度与 CPU 频率相似(想想这里的兆赫兹),浮点乘法比间接寻址花费更长的时间。

从那以后,情况发生了很大变化。 现代硬件可以在一两个周期内执行浮点乘法,而间接(内存访问)可能只需要几个周期到数百个周期,具体取决于要读取的数据所在的位置。 可以有多个级别的缓存。 在极端情况下,通过间接寻址访问的内存已交换到磁盘,需要读回。 这将有数千个周期的延迟。

通常,获取浮点乘法的操作数和解码指令的开销可能比实际乘法花费更长的时间。