Понимание std:: transform и как его бить

Я пытаюсь понять ориентированный на данные дизайн по простой, конкретной проблеме. Приносим извинения заранее людям, ориентированным на данные, если я делаю что-то очень глупое, но мне трудно понять, почему и где мои рассуждения терпят неудачу.

Предположим, что у меня простая операция, т.е. float_t result = int_t(lhs) / int_t(rhs). Если я сохраняю все переменные в своих соответствующих контейнерах, например, std::vector<float_t> и std::vector<int_t>, и я использую std::transform, я получаю правильный результат. Затем для конкретного примера, где using float_t = float и using int_t = int16_t, я предполагаю, что упаковка этих переменных внутри struct, в 64-битной архитектуре, и сбор их внутри контейнера должен обеспечить лучшую производительность.

Я думаю, что 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. Я был бы признателен, если бы вы помогли мне понять поведение. Является результатом результата

  • Я не правильно понимаю принципы, ориентированные на данные, или
  • некоторый артефакт этого очень простого примера, такого как места памяти, которые распределяются очень близко друг к другу и каким-то образом оптимизируются с помощью компилятора очень эффективно?

Наконец, есть ли способ превзойти std::transform в этом примере или просто ли он достаточно хорош, чтобы стать подходящим решением? Я не эксперт ни в оптимизации компилятора, ни в ориентированном на данные проекте, и поэтому сам не мог ответить на этот вопрос.

Спасибо!

EDIT. Я изменил способ проверки обоих методов в соответствии с предложением @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;
}

с соответствующей оболочкой (рыбой) script

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 раза.

Ответ 1

Здесь скомпилированный цикл случая 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 результата написаны с одной инструкцией).

Во втором случае, однако, это не так. Компилятор должен был испустить много операций перемещения данных (перемешивания), и каждый результат записывается отдельной инструкцией. Таким образом, вход/выход не находится в дружественном формате SIMD.

У меня нет времени для анализа этой проблемы дальше, но если вы просто принимаете тот факт, что оба этих фрагмента имеют одинаковый размер, аналогичные инструкции, но первые процессы в два раза больше, чем элементы второго, вы может возникнуть идея, почему вторая медленнее. Извините за небрежное объяснение.

Ответ 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. Такие случаи, как правило, предлагают гораздо больше возможностей для оптимизации как для человека, так и для компилятора, и вероятность того, что ваш конкретный случай принесет пользу больше всего, как указывает Геза.

Наконец, есть способ превзойти std:: transform в этом примере или это просто достаточно хорошо, чтобы быть готовым решением?

Я думаю, что попытка превзойти std::transform при выполнении одинаковых требований - это неправильный способ взглянуть на него, но вы можете сжать некоторые приросты производительности, слегка изменив их. Например, вместо std::divides вы можете заранее подготовить данные, чтобы превратить это в умножение. Самое главное, что я хотел бы найти в вашем случае, - проверить, может ли код быть выполнен параллельно с более быстрым представлением SoA ( "unpacked" ). Вы можете обрабатывать заданный диапазон индексов каждого контейнера SoA в каждом отдельном потоке, все еще используя std::transform в каждом из них.