分析转置函数的性能



我写了一个天真的和;优化的";包含双精度复数的3阶张量的转置函数,我想分析它们的性能。

朴素转置函数的近似代码

#pragma omp for schedule(static)
for (auto i2 = std::size_t(0); i2 < n2; ++i2)
{
for (auto i1 = std::size_t{}; i1 < n1; ++i1)
{
for (auto i3 = std::size_t{}; i3 < n3; ++i3)
{
tens_tr(i3, i2, i1) = tens(i1, i2, i3);
}
}
}

优化转置函数的近似代码(未显示余数循环,假设可分割):

#pragma omp for schedule(static)
for (auto i2 = std::size_t(0); i2 < n2; ++i2)
{        
// blocked loop
for (auto bi1 = std::size_t{}; bi1 < n1; bi1 += block_size)
{
for (auto bi3 = std::size_t{}; bi3 < n3; bi3 += block_size)
{
for (auto i1 = std::size_t{}; i1 < block_size; ++i1)
{
for (auto i3 = std::size_t{}; i3 < block_size; ++i3)
{
cache_buffer[i3 * block_size + i1] = tens(bi1 + i1, i2, bi3 + i3);
}
}

for (auto i1 = std::size_t{}; i1 < block_size; ++i1)
{
for (auto i3 = std::size_t{}; i3 < block_size; ++i3)
{
tens_tr(bi3 + i1, i2, bi1 + i3) = cache_buffer[i1 * block_size + i3];
}
}
}
}
}

假设:我决定使用流函数作为参考,因为我认为转置函数在其完美实现中与任何带宽饱和的流函数非常相似。

为此,我选择了DAXPY循环作为参考。

#pragma omp parallel for schedule(static)
for (auto i1 = std::size_t{}; i1 < tens_a_->get_n1(); ++i1)
{
auto* slice_a = reinterpret_cast<double*>(tens_a_->get_slice_data(i1));
auto* slice_b = reinterpret_cast<double*>(tens_b_->get_slice_data(i1));
const auto slice_size = 2 * tens_a_->get_slice_size(); // 2 doubles for a complex

#pragma omp simd safelen(8)
for (auto index = std::size_t{}; index < slice_size; ++index)
{
slice_b[index] += lambda_ * slice_a[index]; // fp_count: 2, traffic: 2+1
}
}

此外,我使用了一个简单的复制内核作为第二个参考。

#pragma omp parallel for schedule(static)
for (auto i1 = std::size_t{}; i1 < tens_a_->get_n1(); ++i1)
{
const auto* op1_begin = reinterpret_cast<double*>(tens_a_->get_slice_data(index));
const auto* op1_end = op1_begin + 2 * tens_a_->get_slice_size(); // 2 doubles in a complex
auto* op2_iter = reinterpret_cast<double*>(tens_b_->get_slice_data(index));

#pragma omp simd safelen(8)
for (auto* iter = op1_begin; iter != op1_end; ++iter, ++op2_iter)
{
*op2_iter = *iter;
}
}

硬件:

  1. Intel(R)Xeon(X)Platinum 8168(Skylake),24核,2.70 GHz,L1、L2和L3缓存大小分别为32 kB、1 MB和33 MB
  2. 内存为48 GiB@2666 MHz。英特尔顾问的屋顶线视图显示,内存带宽为115 GB/s

基准测试:20次热身跑,100次定时实验,每次都有新分配的数据";触摸";使得页面错误将不会被测量。

编译器和标志:OneAPI 2022.1.0中的英特尔编译器,优化标志-O3;-ffast-math;-march=native;-qopt-zmm-usage=high

结果(假设尺寸足够大):

  1. 使用固定在24个内核上的24个线程(两个张量的总大小约为10 GiB):
    DAXPY 102 GB/s
    复制101 GB/s
    初始转置91 GB/s
    优化转置93 GB/s

  2. 使用固定在单个核心上的1个线程(两个张量的总大小约为10 GiB):
    DAXPY 20 GB/s
    复制20 GB/s
    初始转置9.3 GB/s
    优化转置9.3 GB/s

问题:

  1. 为什么我的天真转置函数执行这么好
  2. 当只使用一个线程时,为什么引用函数和转置函数之间的性能差异如此之大

我很高兴收到对上述任何问题的任何形式的输入。此外,我很乐意在需要时提供更多信息。不幸的是,由于每个基准程序的大小和复杂性,我无法提供最小的复制器。非常感谢您的时间和提前提供的帮助!

更新:

  1. 可能是英特尔编译器对初始转置函数执行了循环阻塞作为优化吗

上述假设有效吗?[编辑前询问]

不是。

在某些平台上,大型阵列的换位往往不会使RAM的带宽饱和。这可能是由于缓存效果,如缓存垃圾。有关这方面的更多信息,你可以阅读这篇文章。在您的具体情况下,一切都很顺利(见下文)。

在NUMA平台上,NUMA节点上的数据页分布会对性能产生很大影响。这可能是由于某些NUMA节点的RAM(临时)饱和、(临时)不平衡的页面分布、不均匀的延迟、不一致的吞吐量。NUMA可以在最近的AMD处理器上看到,但也可以在一些英特尔处理器上看到(例如,自Skylake以来,请参阅本文)。

即使假设以上几点不适用于您的情况,考虑完美的情况,而天真的代码可能表现得不像完美的换位,也可能导致错误的解释。例如,如果这个假设被打破,结果可能会高估天真实现的性能。

为什么我天真的转置函数执行得这么好?

良好的吞吐量并不意味着计算速度快。如果需要从RAM传输更多的数据,则计算速度可能较慢,吞吐量较高。这可能是由于缓存未命中造成的。更具体地说,使用天真的访问模式,缓存线可以更频繁地被替换,重用率更低(由于缓存破坏),因此墙上的时钟时间应该更高。您需要测量挂钟时间。度量可以很好地理解正在发生的事情,但不能衡量内核的性能。

在这种特定情况下,所选的大小(即1050)不应导致太多冲突未命中,因为它不可被除以2的大幂。在初始版本中,当i1增加时,tens_tr写入将部分填充许多高速缓存行(1050),然后它们可以被重用(因此需要多达8个后续增加来填充高速缓存行)。这意味着,一个给定的i2的基于i1-i3的换位需要缓存的1050 * 64 ~= 66 KiB来完成。高速缓存行不能与其他i2值一起重用,因此高速缓存不需要太大就可以相对高效地进行换位。也就是说,还应该考虑tens读取(尽管它可以很快从缓存中逐出)。最终,1MIB的16路关联L2高速缓存应该足够了。请注意,由于L2高速缓存不应该足够大,因此高速缓存线不能完全重用(导致数据从存储器层次结构中多次重新加载,通常是从L3顺序加载和从RAM并行加载),因此,对于明显更大的阵列,天真的实现应该表现不佳。还要注意的是,如果您计划将输入数组的大小更改为可被2的幂整除的大小,那么在具有较小缓存的处理器(例如,x86-64桌面处理器,最近的那些通常具有较大缓存的处理器除外)上,天真的换位也会表现得很差。

虽然阻塞可以更好地使用一级缓存,但在您的特定情况下,它并不那么重要。事实上,天真计算并没有从L1高速缓存中受益,但效果很小,因为换位应该由L3高速缓存和RAM限制。也就是说,更好的一级缓存使用率可以帮助减少一点与目标处理器架构有关的延迟。您应该主要在明显较小的数组上看到这种效果。

并行地,L3缓存足够大,因此24个内核可以并行运行,而不会发生太多冲突未命中。即使L3表现不佳,内核也主要是内存绑定的,因此缓存未命中的影响不会太明显。

当只使用一个线程时,为什么引用函数和转置函数之间的性能差异如此之大?

这可能是由于内存操作的延迟。换位以的巨大步伐执行内存读/写硬件预取器可能无法完全缓解L3缓存或主RAM的巨大延迟。事实上,每个内核的挂起缓存行请求数量是有限的(在Skylake上只有十几个),因此内核受到请求延迟的限制,因为没有足够的并发性来完全重叠它们的延迟。

对于DAXPY/copy,硬件预取器可以更好地减少延迟,但与Xeon处理器上的延迟相比,并发量仍然太小,因此无法用1个线程使RAM完全饱和。这是一个非常合理的体系结构限制,因为这样的处理器被设计为在许多核心上执行可扩展的应用程序。

对于许多线程,每个内核的限制消失了,取而代之的是一个更强的限制:实用的RAM带宽。

可能是英特尔编译器为初始转置函数执行了循环阻塞作为优化吗?

这在理论上是可能的,因为英特尔编译器(ICC)有这样的优化器,但ICC在3D转置代码上不太可能做到这一点(因为这是一个非常复杂、相对特定的用例)。最好是分析程序集代码,以便确定。


关于优化换位效率的注记

由于x86-64处理器(如Xeon处理器)上的缓存线写入分配,我预计如果不考虑这种影响,换位的吞吐量会更低。事实上,处理器需要读取tens_tr缓存行以填充它们,因为它不知道它们是否会提前完全填充(对于天真的换位来说,这将是疯狂的),并且它们可能会在之前被逐出(例如,在上下文切换期间,由另一个运行的程序)。

有几个可能的原因可以解释:

  • 这个假设是错误的,这意味着1/3的带宽被用来读取实际写入的缓存线所浪费
  • DAXPY代码也有同样的问题,并且报告的最大带宽也不是真正正确的(不太可能)
  • ICC成功地重写了换位,从而有效地使用缓存,并且还生成了非临时存储指令,从而避免了这种影响(不太可能)

基于可能的原因,我认为测量的吞吐量已经考虑了写分配,并且可以进一步优化换位实现。事实上,进行复制的优化版本可以使用非临时存储,从而在不读取数组的情况下将数组写回内存。这在天真的实现中是不可能的。通过这样的优化,吞吐量可以是相同的,但执行时间可以降低约33%(由于更好地使用了存储器带宽)。这是一个很好的例子,表明最初的假设是错误的。

最新更新