11

我试图在一个简单、具体的问题上理解面向数据的设计。提前向面向数据的设计人员道歉,如果我做了一些非常愚蠢的事情,但我很难理解我的推理为什么以及在哪里失败。

假设我有一个简单的操作,, float_t result = int_t(lhs) / int_t(rhs)。如果我将所有变量保存在它们相应的容器中,例如std::vector<float_t>std::vector<int_t>,并且我使用std::transform,我会得到正确的结果。using float_t = float然后,对于 where和的具体示例using int_t = int16_t,我假设struct在 64 位架构上将这些变量打包在 a 中,并将它们收集到容器中应该会产生更好的性能。

我认为它struct构成了一个 64 位对象,并且对 的单个内存访问struct将为我提供所需的所有变量。另一方面,当所有这些变量都收集在不同的容器中时,我将需要三种不同的内存访问来获取所需的信息。以下是我设置环境的方法:

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <vector>

using namespace std::chrono;

template <class float_t, class int_t> struct Packed {
  float_t sinvl;
  int_t s, l;
  Packed() = default;
  Packed(float_t sinvl, int_t s, int_t l) : sinvl{sinvl}, s{s}, l{l} {}
  void comp() { sinvl = float_t(l) / s; }
};

using my_float = float;
using my_int = int16_t;

int main(int argc, char *argv[]) {
  constexpr uint32_t M{100};
  for (auto N : {1000, 10000, 100000}) {
    double t1{0}, t2{0};
    for (uint32_t m = 0; m < M; m++) {
      std::vector<my_float> sinvl(N, 0.0);
      std::vector<my_int> s(N, 3), l(N, 2);
      std::vector<Packed<my_float, my_int>> p1(
          N, Packed<my_float, my_int>(0.0, 3, 2));

      // benchmark unpacked
      auto tstart = high_resolution_clock::now();
      std::transform(l.cbegin(), l.cend(), s.cbegin(), sinvl.begin(),
                     std::divides<my_float>{}); // 3 different memory accesses
      auto tend = high_resolution_clock::now();
      t1 += duration_cast<microseconds>(tend - tstart).count();

      if (m == M - 1)
        std::cout << "sinvl[0]: " << sinvl[0] << '\n';

      // benchmark packed
      tstart = high_resolution_clock::now();
      for (auto &elem : p1) // 1 memory access
        elem.comp();
      tend = high_resolution_clock::now();
      t2 += duration_cast<microseconds>(tend - tstart).count();

      if (m == M - 1)
        std::cout << "p1[0].sinvl: " << p1[0].sinvl << '\n';
    }
    std::cout << "N = " << N << ", unpacked: " << (t1 / M) << " us.\n";
    std::cout << "N = " << N << ", packed: " << (t2 / M) << " us.\n";
  }
  return 0;
}

g++ -O3在我的机器上编译后的代码,

sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 1000, unpacked: 0 us.
N = 1000, packed: 1 us.
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 10000, unpacked: 5.06 us.
N = 10000, packed: 12.97 us.
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 100000, unpacked: 52.31 us.
N = 100000, packed: 124.49 us.

基本上,std::transform通过2.5x. 如果您能帮助我理解这种行为,我将不胜感激。结果是由于

  1. 我没有正确掌握面向数据的设计原则,或者,
  2. 这个非常简单的例子的一些人工制品,例如内存位置被分配得非常接近,并且在某种程度上被编译器非常有效地优化?

最后,有没有办法std::transform在这个例子中击败,或者,它是否足以成为首选解决方案?我既不是编译器优化方面的专家,也不是面向数据的设计方面的专家,因此,我自己无法回答这个问题。

谢谢!

编辑。根据@bolov 在评论中的建议,我已经更改了测试这两种方法的方式。

现在代码看起来像:

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <vector>

using namespace std::chrono;

template <class float_t, class int_t> struct Packed {
  float_t sinvl;
  int_t s, l;
  Packed() = default;
  Packed(float_t sinvl, int_t s, int_t l) : sinvl{sinvl}, s{s}, l{l} {}
  void comp() { sinvl = float_t(l) / s; }
};

using my_float = float;
using my_int = int16_t;

int main(int argc, char *argv[]) {
  uint32_t N{1000};
  double t{0};

  if (argc == 2)
    N = std::stoul(argv[1]);

#ifndef _M_PACKED
  std::vector<my_float> sinvl(N, 0.0);
  std::vector<my_int> s(N, 3), l(N, 2);

  // benchmark unpacked
  auto tstart = high_resolution_clock::now();
  std::transform(l.cbegin(), l.cend(), s.cbegin(), sinvl.begin(),
                 std::divides<my_float>{}); // 3 different memory accesses
  auto tend = high_resolution_clock::now();
  t += duration_cast<microseconds>(tend - tstart).count();

  std::cout << "sinvl[0]: " << sinvl[0] << '\n';
  std::cout << "N = " << N << ", unpacked: " << t << " us.\n";
#else
  std::vector<Packed<my_float, my_int>> p1(N,
                                           Packed<my_float, my_int>(0.0, 3, 2));
  // benchmark packed
  auto tstart = high_resolution_clock::now();
  for (auto &elem : p1) // 1 memory access
    elem.comp();
  auto tend = high_resolution_clock::now();
  t += duration_cast<microseconds>(tend - tstart).count();

  std::cout << "p1[0].sinvl: " << p1[0].sinvl << '\n';
  std::cout << "N = " << N << ", packed: " << t << " us.\n";
#endif

  return 0;
}

使用相应的 shell (fish) 脚本

g++ -Wall -std=c++11 -O3 transform.cpp -o transform_unpacked.out
g++ -Wall -std=c++11 -O3 transform.cpp -o transform_packed.out -D_M_PACKED
for N in 1000 10000 100000
  echo "Testing unpacked for N = $N"
  ./transform_unpacked.out $N
  ./transform_unpacked.out $N
  ./transform_unpacked.out $N
  echo "Testing packed for N = $N"
  ./transform_packed.out $N
  ./transform_packed.out $N
  ./transform_packed.out $N
end

这给出了以下内容:

Testing unpacked for N = 1000
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
Testing packed for N = 1000
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
Testing unpacked for N = 10000
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
Testing packed for N = 10000
p1[0].sinvl: 0.666667
N = 10000, packed: 17 us.
p1[0].sinvl: 0.666667
N = 10000, packed: 13 us.
p1[0].sinvl: 0.666667
N = 10000, packed: 13 us.
Testing unpacked for N = 100000
sinvl[0]: 0.666667
N = 100000, unpacked: 64 us.
sinvl[0]: 0.666667
N = 100000, unpacked: 66 us.
sinvl[0]: 0.666667
N = 100000, unpacked: 66 us.
Testing packed for N = 100000
p1[0].sinvl: 0.666667
N = 100000, packed: 180 us.
p1[0].sinvl: 0.666667
N = 100000, packed: 198 us.
p1[0].sinvl: 0.666667
N = 100000, packed: 177 us.

我希望我已经正确理解了正确的测试方法。尽管如此,差异仍然是2-3倍。

4

2 回答 2

4

这是std::transform案例的编译循环:

  400fd0:       f3 41 0f 7e 04 47       movq   xmm0,QWORD PTR [r15+rax*2]
  400fd6:       66 0f 61 c0             punpcklwd xmm0,xmm0
  400fda:       66 0f 72 e0 10          psrad  xmm0,0x10
  400fdf:       0f 5b c0                cvtdq2ps xmm0,xmm0
  400fe2:       f3 0f 7e 0c 43          movq   xmm1,QWORD PTR [rbx+rax*2]
  400fe7:       66 0f 61 c9             punpcklwd xmm1,xmm1
  400feb:       66 0f 72 e1 10          psrad  xmm1,0x10
  400ff0:       0f 5b c9                cvtdq2ps xmm1,xmm1
  400ff3:       0f 5e c1                divps  xmm0,xmm1
  400ff6:       41 0f 11 04 80          movups XMMWORD PTR [r8+rax*4],xmm0
  400ffb:       f3 41 0f 7e 44 47 08    movq   xmm0,QWORD PTR [r15+rax*2+0x8]
  401002:       66 0f 61 c0             punpcklwd xmm0,xmm0
  401006:       66 0f 72 e0 10          psrad  xmm0,0x10
  40100b:       0f 5b c0                cvtdq2ps xmm0,xmm0
  40100e:       f3 0f 7e 4c 43 08       movq   xmm1,QWORD PTR [rbx+rax*2+0x8]
  401014:       66 0f 61 c9             punpcklwd xmm1,xmm1
  401018:       66 0f 72 e1 10          psrad  xmm1,0x10
  40101d:       0f 5b c9                cvtdq2ps xmm1,xmm1
  401020:       0f 5e c1                divps  xmm0,xmm1
  401023:       41 0f 11 44 80 10       movups XMMWORD PTR [r8+rax*4+0x10],xmm0
  401029:       48 83 c0 08             add    rax,0x8
  40102d:       48 83 c1 02             add    rcx,0x2
  401031:       75 9d                   jne    400fd0 <main+0x570>

在每个循环周期中,它处理 8 个元素(有两条divps指令,每条执行 4 次除法)。

这是另一种情况:

  401190:       f3 0f 6f 42 04          movdqu xmm0,XMMWORD PTR [rdx+0x4]
  401195:       f3 0f 6f 4a 14          movdqu xmm1,XMMWORD PTR [rdx+0x14]
  40119a:       66 0f 70 c9 e8          pshufd xmm1,xmm1,0xe8
  40119f:       66 0f 70 c0 e8          pshufd xmm0,xmm0,0xe8
  4011a4:       f2 0f 70 d0 e8          pshuflw xmm2,xmm0,0xe8
  4011a9:       66 0f 6c c1             punpcklqdq xmm0,xmm1
  4011ad:       66 0f 72 e0 10          psrad  xmm0,0x10
  4011b2:       0f 5b c0                cvtdq2ps xmm0,xmm0
  4011b5:       f2 0f 70 c9 e8          pshuflw xmm1,xmm1,0xe8
  4011ba:       66 0f 62 d1             punpckldq xmm2,xmm1
  4011be:       66 0f 61 ca             punpcklwd xmm1,xmm2
  4011c2:       66 0f 72 e1 10          psrad  xmm1,0x10
  4011c7:       0f 5b c9                cvtdq2ps xmm1,xmm1
  4011ca:       0f 5e c1                divps  xmm0,xmm1
  4011cd:       f3 0f 11 02             movss  DWORD PTR [rdx],xmm0
  4011d1:       0f 28 c8                movaps xmm1,xmm0
  4011d4:       0f c6 c9 e5             shufps xmm1,xmm1,0xe5
  4011d8:       f3 0f 11 4a 08          movss  DWORD PTR [rdx+0x8],xmm1
  4011dd:       0f 28 c8                movaps xmm1,xmm0
  4011e0:       0f 12 c9                movhlps xmm1,xmm1
  4011e3:       f3 0f 11 4a 10          movss  DWORD PTR [rdx+0x10],xmm1
  4011e8:       0f c6 c0 e7             shufps xmm0,xmm0,0xe7
  4011ec:       f3 0f 11 42 18          movss  DWORD PTR [rdx+0x18],xmm0
  4011f1:       48 83 c2 20             add    rdx,0x20
  4011f5:       48 83 c1 fc             add    rcx,0xfffffffffffffffc
  4011f9:       75 95                   jne    401190 <main+0x730>

在每个循环周期中,它处理 4 个元素(有一条divps指令)。

在第一种情况下,数据格式良好,SIMD 指令可以(几乎)在没有任何数据移动的情况下对其进行操作,并且可以轻松写入结果(用一条指令写入 4 个结果)。

然而,在第二种情况下,情况并非如此。编译器必须发出大量数据移动(shuffle)操作,并且每个结果都用单独的指令编写。所以输入/输出不是 SIMD 友好的格式。

我没有时间进一步分析这个问题,但如果你只是认为这两个片段的大小相似,指令相似,但第一个处理的元素是第二个的两倍,你可以拥有知道为什么第二个更慢。对草率的解释感到抱歉。

于 2017-11-01T13:30:09.530 回答
2

[...] 并将它们收集在容器中应该会产生更好的性能。

我认为对于顺序访问案例,您的直觉有点倒退。仅考虑非平凡大小输入上的顺序循环,SoA 几乎总是与顺序访问的 AoS 一样快或更快。

在许多情况下,SoA 实际上会比 AoS 获得更少的缓存未命中总数,因为它避免了结构填充(不面临交错非同质字段的对齐要求)并且您可能能够避免为给定循环加载冷字段(例如:物理模拟器在应用物理时可能能够避免加载color仅用于渲染的粒子场)。您的案子不会从其中任何一个中受益,但请记住这一点。

AoS 的优势在于随机访问。在这种情况下,如果您加载第 4096 个元素,您可能只需要一个带有 AoS 的高速缓存行来获取所有三个字段。如果您使用 SoA,则需要 3 并且它可能会加载大量相邻元素的数据(元素 4097、4098、4099 等的数据),由于内存访问模式。但是对于顺序访问,所有这些相邻数据通常都会被使用,即使是在驱逐之前的 SoA 代表。

因此,就缓存未命中而言,在您按顺序循环非平凡输入大小的情况下,SoA 通常会平局或获胜。

但此外,在所有相邻数据将在逐出之前使用的这种顺序访问模式中,当您考虑数据从缓存加载到 SIMD 寄存器时的动态时,SoA 提供同质数据。它可以以 和 的形式加载内存,float, float, float, float, ...int16, int16, int16, int16, ...不是float, int16, int16, float, int16, int16 ...在 SIMD 寄存器中执行垂直/对称操作。这样的案例往往会为人类和编译器提供更多的优化机会,正如 Geza 指出的那样,这可能是您的特定案例受益最多的地方。

最后,有没有办法在这个例子中击败 std::transform ,或者,它是否足以成为首选解决方案?

我认为在满足相同要求的同时尝试击败std::transform是错误的看待它的方式,但你可以通过稍微改变它们来挤压一些性能提升。例如std::divides,您可以提前准备数据以将其转换为乘法,而不是 。在您的情况下,我要寻找的最重要的事情是查看代码是否可以与更快的 SoA(“解包”)代表并行执行。您可能能够在每个单独的线程中处理每个 SoA 容器的给定索引范围,仍然std::transform在每个线程中使用。

于 2017-12-23T04:47:23.593 回答