С++: Таинственное огромное ускорение от хранения одного операнда в регистре

Я пытаюсь получить представление о влиянии наличия массива в кеше L1 против памяти, задав процедуру, которая масштабирует и суммирует элементы массива, используя следующий код (я знаю, что я должен просто масштабировать результат "a" в конце: точка состоит в том, чтобы сделать как умножение, так и добавление в цикле - пока компилятор не вычислил, чтобы разделить "a" ):

double sum(double a,double* X,int size)
{
    double total = 0.0;
    for(int i = 0;  i < size; ++i)
    {
        total += a*X[i];
    }
    return total;
}

#define KB 1024
int main()
{
    //Approximately half the L1 cache size of my machine
    int operand_size = (32*KB)/(sizeof(double)*2);
    printf("Operand size: %d\n", operand_size);
    double* X = new double[operand_size];
    fill(X,operand_size);

    double seconds = timer();
    double result;
    int n_iterations = 100000;
    for(int i = 0; i < n_iterations; ++i)
    {
        result = sum(3.5,X,operand_size);
        //result += rand();  
    }
    seconds = timer() - seconds; 

    double mflops = 2e-6*double(n_iterations*operand_size)/seconds;
    printf("Vector size %d: mflops=%.1f, result=%.1f\n",operand_size,mflops,result);
    return 0;
}

Обратите внимание, что процедуры таймера() и fill() не включены для краткости; их полный источник можно найти здесь, если вы хотите запустить код:

http://codepad.org/agPWItZS

Теперь, вот где это становится интересным. Это результат:

Operand size: 2048
Vector size 2048: mflops=588.8, result=-67.8

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

g++ -O3 -S -fno-asynchronous-unwind-tables register_opt_example.cpp

Я замечаю одну странность в цикле функций суммы:

L55:
    movsd   (%r12,%rax,8), %xmm0
    mulsd   %xmm1, %xmm0
    addsd   -72(%rbp), %xmm0
    movsd   %xmm0, -72(%rbp)
    incq    %rax
    cmpq    $2048, %rax
    jne L55

Инструкции:

    addsd   -72(%rbp), %xmm0
    movsd   %xmm0, -72(%rbp)

указывает, что он хранит значение "total" в sum() в стеке и считывает и записывает его на каждой итерации цикла. Я изменил сборку так, чтобы этот операнд хранился в регистре:

...
addsd   %xmm0, %xmm3
...

Это небольшое изменение создает повышение огромного:

Operand size: 2048
Vector size 2048: mflops=1958.9, result=-67.8

TL;DR Мой вопрос: почему замена одного места доступа к памяти с помощью регистра, так сильно ускоряет его, учитывая, что одно место должно храниться в кеше L1? Какие архитектурные факторы делают это возможным? Кажется очень странным, что запись одного места в стеке неоднократно полностью разрушала бы эффективность кеша.

Приложение

Моя версия gcc:

Target: i686-apple-darwin10
Configured with: /var/tmp/gcc/gcc-5646.1~2/src/configure --disable-checking --enable-werror --prefix=/usr --mandir=/share/man --enable-languages=c,objc,c++,obj-c++ --program-transform-name=/^[cg][^.-]*$/s/$/-4.2/ --with-slibdir=/usr/lib --build=i686-apple-darwin10 --with-gxx-include-dir=/include/c++/4.2.1 --program-prefix=i686-apple-darwin10- --host=x86_64-apple-darwin10 --target=i686-apple-darwin10
Thread model: posix
gcc version 4.2.1 (Apple Inc. build 5646) (dot 1)

Мой процессор:

Intel Xeon X5650

Ответ 1

Вероятно, это комбинация более длинной цепочки зависимостей, а также Load Misprediction *.


Более длинная цепочка зависимостей:

Сначала мы определяем пути критической зависимости. Затем мы рассмотрим задержки команд, предоставленные: http://www.agner.org/optimize/instruction_tables.pdf (стр. 117)

В неоптимизированной версии критический путь зависимостей:

  • addsd -72(%rbp), %xmm0
  • movsd %xmm0, -72(%rbp)

Внутри он, вероятно, разбивается на:

  • load (2 цикла)
  • addedd (3 цикла)
  • магазин (3 цикла)

Если мы посмотрим на оптимизированную версию, просто:

  • addedd (3 цикла)

Итак, у вас есть 8 циклов против 3 циклов. Почти в 3 раза.

Я не уверен, насколько чувствительна процессорная линия Nehalem для зависимостей хранения и загрузки, forwarding. Но разумно полагать, что это не ноль.


Misprediction загрузочного хранилища:

Современные процессоры используют предсказание по-разному, как вы можете себе представить. Наиболее известным из них является, вероятно, Отраслевое предсказание. Одним из менее известных является Load Prediction.

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

Если более ранняя запись оказывается в конфликте с нагрузкой, тогда загрузка должна быть повторно выполнена, и вычисление будет возвращено в точку загрузки. (почти так же, как отклонения от ветки назад)

Как это уместно здесь:

Излишне говорить, что современные процессоры смогут выполнять несколько итераций этого цикла одновременно. Таким образом, процессор будет пытаться выполнить загрузку (addsd -72(%rbp), %xmm0) до того, как он закончит сохранение (movsd %xmm0, -72(%rbp)) с предыдущей итерации.

Результат? Предыдущий магазин конфликтует с нагрузкой - таким образом, неверное предсказание и откат.

* Обратите внимание, что я не уверен в имени "Load Prediction". Я только читал об этом в документах Intel, и они, похоже, не дали ему имени.

Ответ 2

Я бы предположил, что проблема не в доступе к кэшу/памяти, а в процессоре (исполнение вашего кода). Здесь есть несколько видимых узких мест.

Показатели производительности здесь были основаны на ящиках, которые я использовал (либо на песчаном мостике, либо на Westmere)

Пиковая производительность для скалярной математики составляет 2,7 ГГц x2 FLOPS/Clock x2, так как процессор может добавлять и умножать одновременно. Теоретическая эффективность кода составляет 0,6/(2,7 * 2) = 11%

Необходимая полоса пропускания: 2 удвоения на (+) и (x) → 4 байта/флоп 4 байта * 5.4GFLOPS = 21,6 ГБ/с

Если вы знаете, что в последнее время его читали скорее всего в L1 (89 ГБ/с), L2 (42 ГБ/с) или L3 (24 ГБ/с), поэтому мы можем исключить кеш B/W

Система хранения данных составляет 18,9 ГБ/с, поэтому даже в основной памяти максимальная производительность должна достигать 18,9/21,6 ГБ/с = 87,5%

  • Может захотеть как можно раньше выполнить загрузку запросов (путем разворачивания)

Даже с умозрительным исполнением tot + = a * X [i] добавление будет сериализовано, потому что tot (n) нужно будет eval'd, прежде чем tot (n + 1) можно будет отпустить

Первый цикл разворота
переместите я на 8 и сделайте

{//your func
    for( int i = 0; i < size; i += 8 ){
        tot += a * X[i];
        tot += a * X[i+1];
        ...
        tot += a * X[i+7];
    }
    return tot
}

Использование нескольких аккумуляторов
Это приведет к разрыву зависимостей и позволит избежать остановки при добавлении конвейера

{//your func//
    int tot,tot2,tot3,tot4;
    tot = tot2 = tot3 = tot4 = 0
    for( int i = 0; i < size; i += 8 ) 
        tot  += a * X[i];
        tot2 += a * X[i+1];
        tot3 += a * X[i+2];
        tot4 += a * X[i+3];
        tot  += a * X[i+4];
        tot2 += a * X[i+5];
        tot3 += a * X[i+6];
        tot4 += a * X[i+7];
    }
    return tot + tot2 + tot3 + tot4;
}

UPDATE После запуска этого в окне SandyBridge у меня есть доступ к: (2.7GHZ SandyBridge с -O2 -march = native -mtune = native

Исходный код:

Operand size: 2048  
Vector size 2048: mflops=2206.2, result=61.8  
2.206 / 5.4 = 40.8%

Улучшенный код:

Operand size: 2048  
Vector size 2048: mflops=5313.7, result=61.8  
5.3137 / 5.4 = 98.4%  

Ответ 3

Я не могу воспроизвести это, потому что мой компилятор (gcc 4.7.2) хранит total в регистре.

Я подозреваю, что основная причина медленности не связана с кэшем L1, а скорее связана с зависимостью данных между хранилищем в

movsd   %xmm0, -72(%rbp)

и нагрузка на последующую итерацию:

addsd   -72(%rbp), %xmm0