Подтвердить что ты не робот

Эффективность на 64-битной платформе: указатель против 32-разрядной индексации массивов

В одном из своих выступлений, Андрей Александреску, предполагает, что на 64-битной платформе использование 32-разрядной индексации массивов происходит быстрее, чем использование необработанного указателя:

Страница 16: http://www.slideshare.net/andreialexandrescu1/three-optimization-tips-for-c-15708507

На своей учетной записи в Facebook он более точно и говорит: "Предпочитайте индексирование массива указателям (этот, кажется, меняется каждые десять лет).

Я пробовал много вещей, чтобы найти разницу, но мне не удалось создать какую-либо программу, которая показывает эту разницу. Зная Андрея, я не удивлюсь, что разница составляет не более нескольких процентов, но я был бы рад, если бы кто-нибудь нашел такой пример.

Вот тест, который я сделал. Я выбираю n = 5000, достаточно большой, чтобы получить приличное время и достаточно маленький, чтобы все вписывалось в кеш L1. Я петлю несколько раз, чтобы частота процессора повышалась.

#include <iostream>
#include <chrono>

int main(int argc, const char* argv[]) {
  const int n{5000};
  int* p{new int[n]};

  // Warm up the cache
  for (int i{0}; i < n; i++) {
    p[i] += 1;
  }

  for (int j{0}; j < 5; j++) {
    {
      auto start_pointer = std::chrono::high_resolution_clock::now();
      for (int* q{p}; q != p + n; ++q) {
        ++(*q);
      }
      auto end_pointer = std::chrono::high_resolution_clock::now();
      auto time_pointer = std::chrono::duration_cast<std::chrono::nanoseconds>(
                              end_pointer - start_pointer)
                              .count();
      std::cout << " Pointer: " << time_pointer << std::endl;
    }

    {
      auto start_pointer = std::chrono::high_resolution_clock::now();
      for (int* q{p}; q != p + n; ++q) {
        ++(*q);
      }
      auto end_pointer = std::chrono::high_resolution_clock::now();
      auto time_pointer = std::chrono::duration_cast<std::chrono::nanoseconds>(
                              end_pointer - start_pointer)
                              .count();
      std::cout << " Pointer: " << time_pointer << std::endl;
    }

    {
      auto start_index_32 = std::chrono::high_resolution_clock::now();
      for (int i{0}; i < n; i++) {
        p[i] += 1;
      }
      auto end_index_32 = std::chrono::high_resolution_clock::now();
      auto time_index_32 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_32 - start_index_32)
                               .count();
      std::cout << "Index 32: " << time_index_32 << std::endl;
    }

    {
      auto start_index_32 = std::chrono::high_resolution_clock::now();
      for (int i{0}; i < n; i++) {
        p[i] += 1;
      }
      auto end_index_32 = std::chrono::high_resolution_clock::now();
      auto time_index_32 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_32 - start_index_32)
                               .count();
      std::cout << "Index 32: " << time_index_32 << std::endl;
    }

    {
      const std::size_t n_64{n};
      auto start_index_64 = std::chrono::high_resolution_clock::now();
      for (std::size_t i{0}; i < n_64; i++) {
        p[i] += 1;
      }
      auto end_index_64 = std::chrono::high_resolution_clock::now();
      auto time_index_64 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_64 - start_index_64)
                               .count();
      std::cout << "Index 64: " << time_index_64 << std::endl;
    }

    {
      const std::size_t n_64{n};
      auto start_index_64 = std::chrono::high_resolution_clock::now();
      for (std::size_t i{0}; i < n_64; i++) {
        p[i] += 1;
      }
      auto end_index_64 = std::chrono::high_resolution_clock::now();
      auto time_index_64 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_64 - start_index_64)
                               .count();
      std::cout << "Index 64: " << time_index_64 << std::endl;
    }
    std::cout << std::endl;
  }

  delete[] p;

  return 0;
}

Вот такой результат я получаю на своей машине (ядро i7). Все, что я получаю, это "шум".

 Pointer: 883
 Pointer: 485
Index 32: 436
Index 32: 380
Index 64: 372
Index 64: 429

 Pointer: 330
 Pointer: 316
Index 32: 336
Index 32: 321
Index 64: 337
Index 64: 318

 Pointer: 311
 Pointer: 314
Index 32: 318
Index 32: 319
Index 64: 316
Index 64: 301

 Pointer: 306
 Pointer: 325
Index 32: 323
Index 32: 313
Index 64: 318
Index 64: 305

 Pointer: 311
 Pointer: 319
Index 32: 313
Index 32: 324
Index 64: 315
Index 64: 303
4b9b3361

Ответ 1

Проблема с низкоуровневыми советами вроде этого (даже от Андрея Александреску) заключается в том, что он игнорирует тот факт, что компиляторы оптимизируются.

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

Одной из хорошо известных оптимизаций, которые используются современными компиляторами, является преобразование между циклами, основанными на индексах и указателях. В конкретном случае вашего теста, с большинством настроек оптимизации, gcc скомпилирует как петлю на основе указателя, так и 32-битный индекс на том же выходе ассемблера.

В дальнейшем я заменил материал chrono на ++sentry, где sentry является volatile, чтобы уменьшить размер кода. Выход сборки соответствует:

for (int* q{p}; q != p + n; ++q) ++(*q);
++sentry;
for (int i{0}; i < n; i++) p[i] += 1;

Скомпилировать с помощью -O2, это привело к следующему: (%rdi и %ebp все еще были инициализированы из цикла, в котором было заполнено p)

        movq    %rdi, %rdx
        cmpq    %rcx, %rdi
        je      .L10
.L16:
        addl    $1, (%rdx)
        addq    $4, %rdx
        cmpq    %rcx, %rdx
        jne     .L16
.L10:
        movl    sentry(%rip), %eax
        movq    %rdi, %rdx
        addl    $1, %eax
        movl    %eax, sentry(%rip)
        testl   %ebp, %ebp
        jle     .L8
.L14:
        addl    $1, (%rdx)
        addq    $4, %rdx
        cmpq    %rdx, %rsi
        jne     .L14
.L8:

Вы можете видеть, что между циклами в .L16 и .L14 нет различий между ними.

Различные настройки оптимизации дают разные результаты. С помощью -O3 петли векторизованы с использованием инструкций SIMD и устройства Duff, но снова почти идентичны. clang делает эту оптимизацию на -O2

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

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


Более интересным примером (возможно) является цикл над двумя массивами, где базовые элементы имеют разные размеры. Учитывая следующие две функции:

void d2f_ptr(float* out, const double* in, int n) {
  for (auto lim = out + n; out < lim;) *out++ = *in++;
}
void d2f_idx(float out[], const double in[], int n) {
  for (int i = 0; i < n; ++i) out[i] = in[i];
}

gcc (v5.3.0, -O2) создает разные циклы, а цикл на основе индекса - одна инструкция короче:

d2f_ptr(float*, double const*, int):    d2f_idx(float*, double const*, int):
        movslq  %edx, %rdx                      xorl    %eax, %eax
        leaq    (%rdi,%rdx,4), %rax             testl   %edx, %edx
        cmpq    %rax, %rdi                      jle     .L16
        jnb     .L11
.L15:                                   .L20:
        addq    $4, %rdi                        pxor    %xmm0, %xmm0
        addq    $8, %rsi                        cvtsd2ss (%rsi,%rax,8), %xmm0
        pxor    %xmm0, %xmm0                    movss   %xmm0, (%rdi,%rax,4)
        cvtsd2ss -8(%rsi), %xmm0                addq    $1, %rax
        movss   %xmm0, -4(%rdi)
        cmpq    %rdi, %rax                      cmpl    %eax, %edx
        ja      .L15                            jg      .L20
.L11:                                   .L16:
        ret                                     ret

Но измените double и float на объекты, размеры которых больше не позволяют использовать режим индексированной адресации чипов Intel, и компилятор еще раз преобразует индексный код в вариант с указателем.

Здесь код по существу тот же, что и раньше, но двойной был дополнен до 48 байтов:

struct Big { double val; char padding[40]; };
struct Small {
  float val;
  Small& operator=(const Big& other) {
    val = other.val;
    return *this;
  }
};

d2f_ptr(Small*, Big const*, int):       d2f_idx(Small*, Big const*, int):
        movslq  %edx, %rdx                      testl   %edx, %edx
        leaq    (%rdi,%rdx,4), %rax             jle     .L26
        cmpq    %rax, %rdi                      leal    -1(%rdx), %eax
        jnb     .L21                            leaq    4(%rdi,%rax,4), %rax
.L25:                                   .L29:
        addq    $48, %rsi                       pxor    %xmm0, %xmm0
        addq    $4, %rdi                        addq    $4, %rdi
        pxor    %xmm0, %xmm0                    cvtsd2ss (%rsi), %xmm0
        cvtsd2ss -48(%rsi), %xmm0               addq    $48, %rsi
        movss   %xmm0, -4(%rdi)                 movss   %xmm0, -4(%rdi)
        cmpq    %rdi, %rax                      cmpq    %rax, %rdi
        ja      .L25                            jne     .L29
.L21:                                   .L26:
        ret                                     ret

Возможно, стоит добавить, что для компиляторов не обязательно сложнее проанализировать, какой объект будет писать конкретная запись указателя. [ Отредактировано. Здесь была цитата из Alexandrescu, но это было не так важно, как я думал, поэтому я удалил ее, оставив этот раздел в основном соломой.]

Фактически, если указатель назначается только один раз, а все другие модификации - с помощью операций инкремента и декремента (включая += и -=), то компилятор полностью в пределах своих прав предположить, что указатель всегда указывает на один и тот же объект. Если некоторая аддитивная модификация указателя должна была перескочить в какой-то другой объект, это будет Undefined Behavior, и компилятор может отказаться от этой возможности. Достаточно легко отслеживать операции присваивания и inc/dec в потоковом графе, поэтому в тех случаях, когда указатель мог быть заменен выражением индекса, вполне возможно, что компилятор может понять это и, следовательно, знать, что другие объекты не являются будучи случайным образом мутированным путем записи через указатель.

Ответ 2

Его (Andrei Alexandrescu) рассуждения, по-видимому, основаны на том факте, что использование регистра для переменной указателя обычно сложнее для компилятора, поскольку указатель может указывать на глобальные данные. Но я не вижу ничего конкретного для индексирования 32-битных массивов (к моему чтению слайд не совсем ясен, если он действительно ссылался на 32-разрядные массивы или индексирование 32-битных систем массива)

Из устья лошади: (да, это ссылка на его учетную запись в Facebook:)

Свернуть запись массива

Чтобы быть быстрее, код должен уменьшать количество записей в массиве и многое другое как правило, записывает указатели.

На современных машинах с большими регистрационными файлами и достаточным регистром переименования оборудования, вы можете предположить, что большинство именных переменных (цифры, указатели) заканчиваются в регистрах. Работа с регистры быстро и играют в сильные стороны аппаратной настройки. Даже когда зависимости данных - главный враг уровня обучения parallelism - вступают в игру, у процессоров есть специальное оборудование, предназначенное для управление различными шаблонами зависимостей. Работа с регистрами (т. именованные переменные) делает ставку на дом. Сделайте это.

Напротив, операции массива (и общие косвенные обращения) меньше естественно для всей иерархии кэша компилятора-процессора. Сохранить для несколько очевидных шаблонов, обращения к массиву не регистрируются. Также, всякий раз, когда используются указатели, компилятор должен принимать указатели может указывать на глобальные данные, то есть любой вызов функции может меняться указывающие на данные произвольно. А для операций с массивами записи массива самое худшее из пакета. Учитывая, что весь трафик с памятью выполняется в степень детализации кеш-строки, запись одного слова в память по существу является чтение строки кэша, за которой следует запись в строке кэша. Поэтому, учитывая, что В любом случае, чтение в больших объемах неизбежно, этот совет сводится к тому, чтобы "избегать создания массивов, где это возможно".

Он также, кажется, предлагает, это общее предложение, а не всегда быстрее использовать индексирование массива (из одного сообщения):

Несколько хороших, но менее известных вещей, которые нужно сделать для быстрого кода:

Предпочитайте статический привязку и код, зависящий от положения (в отличие от PIC, независимого от положения кода).
Предпочитают 64-битный код и 32-разрядные данные.
Предпочитают индексирование массива указателям (этот, кажется, обращает все десять лет).
Предпочитают обычные шаблоны доступа к памяти. Минимизировать поток управления.
Избегайте зависимостей данных.

Ответ 3

Я написал письмо Андрею Александреску, и он был достаточно любезен, чтобы ответить. Вот его объяснение:

"Для того, чтобы ускорение было видимым, вам нужно использовать возможность ALU для запуска двух 32-разрядных операций или одной 64-разрядной операции за один цикл. Не каждый тест показывает ускорение."

Я понимаю это как "Инструкции SIMD обрабатывают больше данных за цикл с 32-битными данными, чем 64-разрядные данные". Мне еще предстоит найти тест (который не содержит никакого массива целых чисел), где это имеет значение. Но я предполагаю, что это будет сложно. Андрей работал в Facebook, где каждый процент был достоин.

Ответ 4

Не совсем ответ, но слишком сложный для комментария:

Ваш тест - действительно ограниченный тест арифметики указателя и индексации массива; в простых случаях, при оптимизации, каждый компилятор, заслуживающий своей соли, будет производить такую ​​же сборку для обоих. Когда индексная переменная не используется иным образом, компилятор совершенно свободен для переключения на арифметику указателя в сборке и в равной степени способен переключать арифметику указателя обратно на доступ к массиву, если он этого захочет.

Лучший пример, который я могу придумать, - это несколько лет назад (и, вероятно, несовместим с компилятором с компилятором, архитектурой и т.д.). Я играл с (для обучения) двумя версиями кода, которые в основном составляли операцию копирования массива:

for (unsigned i = 0; i < copycnt; ++i) {
    x[i] = y[i];
}

против.

while (copycnt--) {
    *x++ = *y++;
}

Я предполагаю, что был некоторый усложняющий фактор (или оптимизация компилятора изменилась, так как в прошлый раз я тестировал что-то вроде этого при высокой оптимизации, скомпилированной для той же сборки), но даже если компилятор мог тривиально преобразовать первый случай в второй (и теоретически сохранить регистр, избегать смещения нагрузки и хранить инструкции в пользу инструкций прямой загрузки и хранения, используйте testl для 0 вместо cmpl из двух значений за небольшую стоимость добавления одной инструкции декремента), компилятор вместо этого решил скомпилировать код, который будет приблизительно приблизительно равен:

const ptrdiff_t diff = y - x;
decltype(*x) *const end = x + copycnt;
while (x < end) {
    *x = *(x + diff);
    ++x;
}

Это, вероятно, превосходит любую из "нормальных" версий кода, если вы подсчитываете количество регистров, необходимых в цикле, количество инструкций в цикле (при условии, что нагрузка при фиксированном смещении регистра является комбинированной инструкцией, так как она находится на x86 машины, а не add, а затем прямая загрузка) и т.д., и компилятор определенно думал об этом, так как он выбрал эту версию по простой арифметике указателя (которую мог бы сделать любой компилятор здесь).

Но когда я скомпилировал простой арифметический код указателя, компилятор не смог определить связь (вероятно, из-за некоторого усложняющего фактора, отсутствующего в этой упрощенной версии, я не знаю ни x, y, ни copycnt был использован снова, так что это не вопрос сохранения исходных значений), и скомпилирован более или менее точно то, что я ему дал, два указателя, увеличивающиеся независимо.

Моя теория заключается в том, что использование индекса дало контекст компилятора для того, что я делал; это были не два несвязанных указателя, а два "массива" были доступны через общий индекс, и он знал, как улучшить скомпилированный код для этого шаблона. Арифметика указателя была "делать то, что я говорю", не указывая контекста "что я пытаюсь сделать", и компилятор не мог понять это, поэтому он не оптимизировал его.

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

Ответ 5

Этот тип оптимизации только на уровне металла, и вы должны игнорировать их. Я бы больше сосредоточился на других вещах, которые на самом деле создают шум для ваших тестов.

[ПРОБЛЕМЫ]

  • назначение указателя выполняется с помощью ++(*q), тогда как для интегральных типов быстрее выполнять (*q)++, но во всяком случае вы должны быть совместимы с тестами int32/int64 и делать *q+=1.
  • указатели проверяют каждый раз в вашем цикле конечный указатель, вы должны сделать это один раз int * ep{p+n} и проверить на это.
  • В целых тестах вы не должны использовать <, но != для оценки завершения.
  • Запуск всего пять раз не даст достаточно схем.
  • Вы должны установить cpu affinity
  • Вы должны сделать больше циклов и учесть только последний
  • У вас должна быть система, в которой компилятор был скомпилирован для металла
  • Вы не должны "разогревать кеш, как вы это делаете"
  • Вы должны рандомизировать свой ввод.

Я изменил ваш код, и вы можете получить его из здесь.

Вы должны скомпилировать с помощью:

g++ -O3 -march=native --std=c++11 -o intvsptr

и запустите с помощью

taskset 0x00000001 ./intvsptr

а затем вы должны получить согласованные результаты.

Указатель: 4396 Указатель: 4397 Индекс 32: 4395 Индекс 32: 4394 Индекс 64: 4394 Индекс 64: 4395

Указатель: 4395 Указатель: 4397 Индекс 32: 4397 Индекс 32: 4395 Индекс 64: 4393 Индекс 64: 4396

Указатель: 4395 Указатель: 4397 Индекс 32: 4396 Индекс 32: 4394 Индекс 64: 4394 Индекс 64: 4396

Указатель: 4396 Указатель: 4397 Индекс 32: 4397 Индекс 32: 4395 Индекс 64: 4394 Индекс 64: 4395

Указатель: 4395 Указатель: 7698 Индекс 32: 4471 Индекс 32: 4422 Индекс 64: 4425 Индекс 64: 4407

Указатель: 4399 Указатель: 4416 Индекс 32: 4394 Индекс 32: 4393 Индекс 64: 4399 Индекс 64: 4412

Точность этих тестов должна быть последней цифрой, но обычно нужно провести обширный анализ статистики.

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

Во всяком случае, вы можете игнорировать этот тип советов, которые могли иметь значение несколько лет назад, но не с текущими компиляторами.