首页
学习
活动
专区
圈层
工具
发布
社区首页 >问答首页 >英特尔x86与AMD x86 CPU的非对齐访问性能

英特尔x86与AMD x86 CPU的非对齐访问性能
EN

Stack Overflow用户
提问于 2022-06-02 11:36:25
回答 2查看 239关注 0票数 3

我使用一个结构内存布局实现了一个简单的线性探测散列映射。结构保存键、值和指示条目是否有效的标志。默认情况下,这个结构由编译器填充,因为键和值是64位整数,但是条目只占用8个bools。因此,我还尝试以未对齐访问为代价打包结构。由于内存密度较高,我希望从打包/非对齐版本中获得更好的性能(我们在传输填充字节时不会浪费带宽)。

当在IntelXeonGold5220SCPU(单线程,gcc 11.2,-O3和-march=native)上对此哈希图进行基准测试时,我发现填充版本和未对齐版本之间没有性能差异。但是,在AMD EPYC 7742 CPU (相同的设置)上,我发现未对齐和填充之间的性能差异。下面的图表描述了在x轴(0,25,50,75,100)上不同的成功查询率下哈希映射加载因子25 %和50%的结果:

正如您所看到的,在Intel上,灰色和蓝色(圆和方形)线几乎是重叠的,结构包装的好处是有限的。然而,在AMD上,表示未对齐/打包结构的线路始终更高,也就是说,我们有更多的吞吐量。

为了研究这个问题,我试图建立一个更小的微基准。在这个微基准测试中,我们执行类似的基准测试,但是没有哈希映射查找逻辑(也就是说,我们只是在数组中选择随机索引并在那里稍微前进)。请在此找到基准:

代码语言:javascript
复制
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <random>
#include <vector>

void ClobberMemory() { std::atomic_signal_fence(std::memory_order_acq_rel); }

template <typename T>
void doNotOptimize(T const& val) {
  asm volatile("" : : "r,m"(val) : "memory");
}

struct PaddedStruct {
  uint64_t key;
  uint64_t value;
  bool is_valid;

  PaddedStruct() { reset(); }

  void reset() {
    key = uint64_t{};
    value = uint64_t{};
    is_valid = 0;
  }
};

struct PackedStruct {
  uint64_t key;
  uint64_t value;
  uint8_t is_valid;

  PackedStruct() { reset(); }

  void reset() {
    key = uint64_t{};
    value = uint64_t{};
    is_valid = 0;
  }
} __attribute__((__packed__));

int main() {
  const uint64_t size = 134217728;
  uint16_t repetitions = 0;
  uint16_t advancement = 0;

  std::cin >> repetitions;
  std::cout << "Got " << repetitions << std::endl;
  std::cin >> advancement;
  std::cout << "Got " << advancement << std::endl;
  std::cout << "Initializing." << std::endl;

  std::vector<PaddedStruct> padded(size);
  std::vector<PackedStruct> unaligned(size);
  std::vector<uint64_t> queries(size);

  // Initialize the structs with random values + prefault
  std::random_device rd;
  std::mt19937 gen{rd()};
  std::uniform_int_distribution<uint64_t> dist{0, 0xDEADBEEF};
  std::uniform_int_distribution<uint64_t> dist2{0, size - advancement - 1};

  for (uint64_t i = 0; i < padded.size(); ++i) {
    padded[i].key = dist(gen);
    padded[i].value = dist(gen);
    padded[i].is_valid = 1;
  }

  for (uint64_t i = 0; i < unaligned.size(); ++i) {
    unaligned[i].key = padded[i].key;
    unaligned[i].value = padded[i].value;
    unaligned[i].is_valid = 1;
  }

  for (uint64_t i = 0; i < unaligned.size(); ++i) {
    queries[i] = dist2(gen);
  }

  std::cout << "Running benchmark." << std::endl;

  ClobberMemory();
  auto start_padded = std::chrono::high_resolution_clock::now();
  PaddedStruct* padded_ptr = nullptr;
  uint64_t sum = 0;
  for (uint16_t j = 0; j < repetitions; j++) {
    for (const uint64_t& query : queries) {
      for (uint16_t i = 0; i < advancement; i++) {
        padded_ptr = &padded[query + i];
        if (padded_ptr->is_valid) [[likely]] {
          sum += padded_ptr->value;
        }
      }
      doNotOptimize(sum);
    }
  }

  ClobberMemory();
  auto end_padded = std::chrono::high_resolution_clock::now();
  uint64_t padded_runtime = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(end_padded - start_padded).count());
  std::cout << "Padded Runtime (ms): " << padded_runtime << " (sum = " << sum << ")" << std::endl;  // print sum to avoid that it gets optimized out

  ClobberMemory();
  auto start_unaligned = std::chrono::high_resolution_clock::now();
  uint64_t sum2 = 0;
  PackedStruct* packed_ptr = nullptr;
  for (uint16_t j = 0; j < repetitions; j++) {
    for (const uint64_t& query : queries) {
      for (uint16_t i = 0; i < advancement; i++) {
        packed_ptr = &unaligned[query + i];
        if (packed_ptr->is_valid) [[likely]] {
          sum2 += packed_ptr->value;
        }
      }
      doNotOptimize(sum2);
    }
  }
  ClobberMemory();
  auto end_unaligned = std::chrono::high_resolution_clock::now();
  uint64_t unaligned_runtime = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(end_unaligned - start_unaligned).count());
  std::cout << "Unaligned Runtime (ms): " << unaligned_runtime << " (sum = " << sum2 << ")" << std::endl;
}

在运行基准测试时,我选择repetitions =3和advancement = 5,也就是说,在编译和运行它之后,您必须输入3(并按newline),然后输入5并按enter/newline。我更新了源代码,以(a)避免编译器展开的循环,因为重复/升级是硬编码的;(b)切换到指向该向量的指针,因为它更接近于哈希映射所做的工作。

在英特尔的CPU上,我得到:

填充运行时(ms):13204未对齐运行时(ms):12185

在AMD CPU上,我得到:

填充运行时(ms):28432未对齐运行时(ms):22926

因此,虽然在这个微基准,英特尔仍然有一点好处,从未对齐的访问,对于AMD CPU,无论是绝对的和相对的改善更高。我无法解释这一点。通常,根据我从相关SO线程中了解到的信息,对单个成员的非对齐访问与对齐访问一样昂贵,只要它停留在单个缓存行(1)内。同样在(1)中,给出了对(2)的引用,它声称缓存获取的宽度可能与缓存行大小不同。但是,除了Linus邮件之外,我无法在处理器中找到缓存获取宽度的任何其他文档,特别是我的具体的两个CPU无法知道这是否与此有关。

有没有人知道为什么AMD CPU从结构包装中获益更多?如果是关于减少内存带宽消耗,我应该能够看到对两个CPU的影响。如果带宽的使用是相似的,我不明白是什么导致了这里的差异。

非常感谢。

(1)相关SO线程:How can I accurately benchmark unaligned access speed on x86_64?

(2) https://www.realworldtech.com/forum/?threadid=168200&curpostid=168779

EN

回答 2

Stack Overflow用户

发布于 2022-06-03 23:50:51

英特尔XeonGold5220S(以及所有其他Skylake/CascadeLake处理器)上的L1数据缓存获取宽度每个负载的自然对齐字节数可达64个。

核心可以执行两个负荷,每个周期的任何组合的大小和对齐,不跨越一个背书边界。我还没有在SKX/CLX处理器上测试所有的组合,但是在Haswell/Broadwell上,每当一个负载跨越一个直线边界时,吞吐量就会降低到每个循环一个负载,而且我假设SKX/CLX是相似的。这可以看作是必要的特性,而不是“惩罚”--分行负载可能需要使用两个端口来加载一对相邻的行,然后将请求的行部分合并到目标寄存器的有效负载中。

跨页面边界的负载有更大的性能损失,但是要度量它,您必须非常小心地理解和控制两个页面的页面表条目的位置: DTLB、STLB、缓存中或主内存中。我记得,最常见的情况是非常快的--部分原因是“下一页预取器”非常擅长在加载序列到达第一页末尾之前将下一页的PTE条目预加载到TLB。唯一慢得令人痛苦的情况是跨页面边界的存储,而Intel编译器非常努力地避免这种情况。

我还没有详细查看示例代码,但是如果我执行此分析,我将小心地将处理器频率固定在一起,测量指令和循环计数,并计算每次更新的平均指令数和周期数。(我通常将核心频率设置为标称(TSC)频率,以使数字更容易处理。)对于自然对齐的情况,应该非常容易查看组装代码并估计循环计数应该是什么。如果度量与这种情况下的观测相似,那么您可以开始查看未对齐访问的开销,以便更可靠地理解基线。

硬件性能计数器对于这种情况也很有价值,特别是DTLB_LOAD_MISSES事件和L1D.REPLACEMENT事件。它只需要几个高延迟的TLB错过或L1D错过事件来倾斜平均值。

票数 3
EN

Stack Overflow用户

发布于 2022-06-06 21:16:44

当使用24字节数据结构时,缓存行访问的次数可能与使用17字节数据结构时相同。

请看这个博客文章:https://lemire.me/blog/2022/06/06/data-structure-size-and-cache-line-accesses/

票数 0
EN
页面原文内容由Stack Overflow提供。腾讯云小微IT领域专用引擎提供翻译支持
原文链接:

https://stackoverflow.com/questions/72475623

复制
相关文章

相似问题

领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档