2016-07-27 2 views
1

Как рассчитать приблизительный квадратный корень из массива быстрее на процессоре с popcnt и SSE4.2?Более быстрый приближенный обратный квадратный корень массива

Ввод положительных целых чисел (от 0 до 200 000), хранящихся в массиве поплавков.

Выход представляет собой массив поплавков.

Оба массива имеют правильное выравнивание памяти для sse.

ниже код использовать только 1 XMM регистр, работает на Linux, и может быть составлен gcc -O3 code.cpp -lrt -msse4.2

Спасибо.

#include <iostream> 
#include <emmintrin.h> 
#include <time.h> 

using namespace std; 
void print_xmm(__m128 xmm){ 
    float out[4]; 
    _mm_storeu_ps(out,xmm); 
    int i; 
    for (i = 0; i < 4; ++i) std::cout << out[i] << " "; 
    std::cout << std::endl; 
} 

void print_arr(float* ptr, size_t size){ 
    size_t i; 
    for(i = 0; i < size; ++i){ 
     cout << ptr[i] << " "; 
    } 
    cout << endl; 
} 

int main(void){ 
    size_t size = 25000 * 4; 
     // this has to be multiple of 4 
    size_t repeat = 10000; 
     // test 10000 cycles of the code 
    float* ar_in = (float*)aligned_alloc(16, size*sizeof(float)); 
    float* ar_out = (float*)aligned_alloc(16, size*sizeof(float)); 
    //fill test data into the input array 
    //the data is an array of positive numbers. 
    size_t i; 
    for (i = 0; i < size; ++i){ 
     ar_in[i] = (i+1) * (i+1); 
    } 
    //prepare for recipical square root. 
    __m128 xmm0; 
    size_t size_fix = size*sizeof(float)/sizeof(__m128); 
    float* ar_in_end = ar_in + size_fix; 
    float* ar_out_now; 
    float* ar_in_now; 
    //timing 
    struct timespec tp_start, tp_end; 
    i = repeat; 
    clock_gettime(CLOCK_MONOTONIC, &tp_start); 
    //start timing 
    while(--i){ 
     ar_out_now = ar_out; 
     for(ar_in_now = ar_in; 
      ar_in_now != ar_in_end; 
      ar_in_now += 4, ar_out_now+=4){ 
      //4 = sizeof(__m128)/sizeof(float); 
      xmm0 = _mm_load_ps(ar_in_now); 
      //cout << "load xmm: "; 
      //print_xmm(xmm0); 
      xmm0 = _mm_rsqrt_ps(xmm0); 
      //cout << "rsqrt xmm: "; 
      //print_xmm(xmm0); 
      _mm_store_ps(ar_out_now,xmm0); 
     } 
    } 
    //end timing 
    clock_gettime(CLOCK_MONOTONIC, &tp_end); 
    double timing; 
    const double nano = 0.000000001; 

    timing = ((double)(tp_end.tv_sec - tp_start.tv_sec) 
      + (tp_end.tv_nsec - tp_start.tv_nsec) * nano)/repeat; 

    cout << " timing per cycle: " << timing << endl; 
    /* 
    cout << "input array: "; 
    print_arr(ar_in, size); 
    cout << "output array: "; 
    print_arr(ar_out,size); 
    */ 
    //free mem 
    free(ar_in); 
    free(ar_out); 
    return 0; 
} 
+3

На чем-то вроде skylake, 'rsqrtps' имеет задержку в 4 цикла, но конвейерно настроен, поэтому каждый цикл может быть выдан новый' rsqrtps'. Вы могли бы получить некоторое ускорение, развернув цикл до четырех раз, но так как результаты сохраняются сразу, а не обрабатываются дальше, регистрация переименования и исполнение вне порядка, вероятно, означает, что вы не получаете намного лучше, чем вы есть сейчас. – EOF

+0

Если я пишу параллельную версию этого кода, должен ли я думать обо всем, кроме ложного обмена? – rxu

+0

Вам нужно быть уверенным, чтобы избежать сбоев данных. Например, если вы разбиваете массивы, убедитесь, что нет перекрытия. В то время как вы думаете, что одно и то же одно и то же результат дважды будет в порядке, это не так, если вы не объявите массив как атомный. – EOF

ответ

4

Насколько велик ваш массив поплавков? Если он уже горячий в L1 (или, возможно, L2), gcc5.3 для узких мест этого кода на пропускной способности UOP на современных процессорах Intel, поскольку он создает цикл с 6-мя фьюзическими доменами, который делает один вектор на итерацию. (Таким образом, он будет работать с одним вектором за 2 цикла).

Для достижения 1 векторной пропускной способности часов на современных процессорах Intel, вам нужно, чтобы цикл был развернут (см. Ниже, почему неподдерживаемый asm не может работать). Наличие компилятора для вас, вероятно, хорошо (вместо того, чтобы делать это вручную в источнике C++). например используйте оптимизацию на основе профиля (gcc -fprofile-use) или просто слепо используйте -funroll-loops.


16 байтов в часах достаточно теоретически, чтобы насытить пропускную способность основной памяти одним ядром. Тем не менее, IIRC Z Boson обнаружил лучшую пропускную способность от использования нескольких ядер, возможно, потому, что несколько ядер сохраняют больше запросов, а стойка на одном ядре не оставляет память в режиме ожидания. Если входной сигнал горячего в L2 на ядре, то, вероятно, лучше всего обработать данные с помощью этого ядра.

On Haswell или более поздняя версия, 16 байт, загруженные и сохраненные за такт, составляют половину полосы пропускания L1, поэтому вам нужна версия AVX для максимальной пропускной способности для каждого ядра.

Если у вас узкое место в памяти, you might want to do a Newton-Raphson iteration to get a nearly-full accuracy 1/sqrt(x), особенно если вы используете несколько потоков для большого массива. (Потому что тогда нормально, если одна нить не может поддерживать одну нагрузку + хранилище за такт.)

Или, может быть, просто используйте rsqrt на лету при загрузке этих данных позже. Это очень дешево, с высокой пропускной способностью, но все же латентность похожа на добавление FP. Опять же, если это большой массив, который не подходит в кеше, увеличение вычислительной интенсивности, делая меньшее количество отдельных проходов над данными, является большой проблемой. (Cache Blocking aka Loop Tiling также было бы хорошей идеей, если вы можете сделать это: выполните несколько шагов вашего алгоритма на куске ваших данных.)

Используйте только кеш-обходные хранилища NT в качестве крайней меры, если вы можете не найти способ эффективного использования кеша. Это намного лучше, если вы можете конвертировать некоторые данные, которые вы собираетесь использовать, поэтому он находится в кеше, когда он будет использоваться позже.


Основной цикл (от .L31 to jne .L31 on the Godbolt compiler explorer) 6 микрооперации для Intel SNB-семейных процессоров, потому indexed addressing modes don't micro-fuse. (К сожалению, это еще не документально описано в Agner Fog's microarch pdf).

Это 4 платных домена на Nehalem, только с тремя ALU, поэтому Nehalem должен запускать это на 1 за такт.

.L31: # the main loop: 6 uops on SnB-family, 4 uops on Nehalem 
    rsqrtps xmm0, XMMWORD PTR [rbx+rax]  # tmp127, MEM[base: ar_in_now_10, index: ivtmp.51_61, offset: 0B] 
    movaps XMMWORD PTR [rbp+0+rax], xmm0  # MEM[base: ar_out_now_12, index: ivtmp.51_61, offset: 0B], tmp127 
    add  rax, 16 # ivtmp.51, 
    cmp  rax, 100000  # ivtmp.51, 
    jne  .L31  #, 

Так как вы хотите написать отдельный пункт назначения, нет никакого способа, чтобы получить петлю до 4 слита-домен микрооперации, поэтому он может работать на одном вектор за такт без разворачивания. (И загрузка, и магазин должны быть режимами однорежимной адресации, поэтому трюк использования src - dst с индексом current_dst вместо увеличения src не работает).

Изменение вашего C++, так что gcc использовал бы приращение указателя, сохранял бы только один uop, потому что вам нужно увеличивать src и dst. то естьfloat *endp = start + length; и for (p = start ; p < endp ; p+=4) {} бы сделать петлю как

.loop: 
    rsqrtps xmm0, [rsi] 
    add  rsi, 16 
    movaps [rdi], xmm0 
    add  rdi, 16 
    cmp  rdi, rbx 
    jne  .loop 

Будем надеяться GCC будет делать что-то вроде этого, отматывая, иначе rsqrtps + movaps будет 4 из плавленого домена микрооперации сами по себе, если они по-прежнему использовать индексированные режим адресации, и никакая развертка не заставит ваш цикл работать с одним вектором за такт.

+1

Одна интересная вещь: согласно таблицам инструкций Agner Fog, вам лучше использовать скалярные 'rsqrtss' для пропускной способности на Intel Atom, что весело, если это правда. – EOF

1

Вы должны измерить его, конечно, но есть известный код для вычисления (не очень точно) обратный квадратный корень, проверить https://www.beyond3d.com/content/articles/8/

float InvSqrt (float x) { 
    float xhalf = 0.5f*x; 
    int i = *(int*)&x; 
    i = 0x5f3759df - (i>>1); 
    x = *(float*)&i; 
    x = x*(1.5f - xhalf*x*x); 
    return x; 
} 

Испытано (с VS2015 и GCC 5.4.0) преобразование в SSE2, link

__m128 InvSqrtSSE2(__m128 x) { 
    __m128 xhalf = _mm_mul_ps(x, _mm_set1_ps(0.5f)); 

    x = _mm_castsi128_ps(_mm_sub_epi32(_mm_set1_epi32(0x5f3759df), _mm_srai_epi32(_mm_castps_si128(x), 1))); 

    return _mm_mul_ps(x, _mm_sub_ps(_mm_set1_ps(1.5f), _mm_mul_ps(xhalf, _mm_mul_ps(x, x)))); 
} 

ОБНОВЛЕНИЕ Я

М ea culpa! Благодаря @EOF, которые заметили неправильное преобразование, они заменяются листами

+4

Нет способа, который может быть быстрее, чем 'rsqrtps', только два умножения уже медленнее – harold

+0

@harold. Я подозреваю, что так же, но был бы заинтересован в измерениях –

+1

Вы понимаете, что а) код, который вы указали, имеет неопределенное поведение в C и b) точка неопределенного поведения состоит в том, что он пытается интерпретировать поразрядное представление аргумента 'float' как' int', в отличие от вашего предлагаемого кода, который преобразует значение? – EOF

3

Поскольку это потоковый расчет с очень низкой арифметической интенсивностью, вы почти наверняка связаны с памятью. Скорее всего, вы найдете измеримое ускорение, если используете невременные нагрузки и магазины.

// Hint to the CPU that we don't want to use the cache 
// Be sure to update this if you use manual loop unrolling 
_mm_prefetch(reinterpret_cast<char*>(ar_in+4), _MM_HINT_NTA); 

// Hint to the CPU that we don't need to write back through the cache 
_mm_stream_ps(ar_out_now,xmm0); 

EDIT:

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

Весь код был скомпилирован с помощью GCC-6.1 с

gcc -std=c++14 -O3 -march=native -mavx -mfpmath=sse -ffast-math 

Intel i3-3120M Ядра @ 2.5GHz; 3MB кэш

Intel Core i7-6500U CPU @ 2.50GHz; 4MB кэш

OP's code:    205 +- 5 milliseconds 
NTA Prefetch:   220 +- 2 milliseconds 
NTA Prefetch+NTA store: 200 +- 5 milliseconds 

Увеличение размера проблемы до 2Мб, магазин NTA Prefetch + NTA видит снижение на ~ 30% во время выполнения над раствором ФП в.

Результаты: размер проблемы слишком мал, чтобы существенно выиграть от NTA. На старых архитектурах это вредно. На новых архитектурах он находится на одном уровне с решением OP.

Заключение: Наверное, не стоит прилагать дополнительных усилий в этом случае.

+1

Mmm ... Ручная предварительная выборка для линейного шаблона доступа к ОЗУ не имеет смысла, хотя снова я хотел бы взглянуть на измерения –

+0

Речь идет не о акте предварительной выборки и больше о намеках к ЦП для хранения данных из кеша. Если используется подсказка, CPU не будет делать никаких выселений. – Tim

+2

Действительно ли 'prefetchNTA' действительно влияет на память WB на реальном оборудовании? Я не тестировал его, но я предполагаю, что они могут вставлять вновь загруженные строки в позицию LRU вместо позиции MRU set-ассоциативных кешей. См. [Раздел загрузки NT в моем ответе здесь] (http://stackoverflow.com/questions/35516878/acquire-release-semantics-with-non-temporal-stores-on-x64). Также обратите внимание, что размер проблемы OP 6250 * 16 floats * 4B/float - всего 2/3rds размера L2. Обход кеша, вероятно, плохая идея. –

Смежные вопросы