Примеры предварительной выборки?
Может ли кто-нибудь привести пример или ссылку на пример, который использует __builtin_prefetch
в GCC (или просто в инструкции asm prefetcht0 в целом), чтобы получить существенное преимущество в производительности? В частности, я бы хотел, чтобы пример соответствовал следующим критериям:
- Это простой, маленький, самостоятельный пример.
- Удаление
__builtin_prefetch
инструкция приводит к снижению производительности. - Замена
__builtin_prefetch
инструкция с соответствующим доступом к памяти приводит к снижению производительности.
То есть я хочу самый короткий пример, показывающий __builtin_prefetch
выполнить оптимизацию, которая не может быть выполнена без нее.
5 ответов
Вот фактический кусок кода, который я вытащил из более крупного проекта. (Извините, это самый короткий, который я могу найти, у которого было заметное ускорение от предварительной выборки.) Этот код выполняет очень большую транспонирование данных.
В этом примере используются инструкции предварительной выборки SSE, которые могут совпадать с инструкциями GCC.
Для запуска этого примера вам нужно скомпилировать его для x64 и иметь более 4 ГБ памяти. Вы можете запустить его с меньшим размером данных, но это будет слишком быстро со временем.
#include <iostream>
using std::cout;
using std::endl;
#include <emmintrin.h>
#include <malloc.h>
#include <time.h>
#include <string.h>
#define ENABLE_PREFETCH
#define f_vector __m128d
#define i_ptr size_t
inline void swap_block(f_vector *A,f_vector *B,i_ptr L){
// To be super-optimized later.
f_vector *stop = A + L;
do{
f_vector tmpA = *A;
f_vector tmpB = *B;
*A++ = tmpB;
*B++ = tmpA;
}while (A < stop);
}
void transpose_even(f_vector *T,i_ptr block,i_ptr x){
// Transposes T.
// T contains x columns and x rows.
// Each unit is of size (block * sizeof(f_vector)) bytes.
//Conditions:
// - 0 < block
// - 1 < x
i_ptr row_size = block * x;
i_ptr iter_size = row_size + block;
// End of entire matrix.
f_vector *stop_T = T + row_size * x;
f_vector *end = stop_T - row_size;
// Iterate each row.
f_vector *y_iter = T;
do{
// Iterate each column.
f_vector *ptr_x = y_iter + block;
f_vector *ptr_y = y_iter + row_size;
do{
#ifdef ENABLE_PREFETCH
_mm_prefetch((char*)(ptr_y + row_size),_MM_HINT_T0);
#endif
swap_block(ptr_x,ptr_y,block);
ptr_x += block;
ptr_y += row_size;
}while (ptr_y < stop_T);
y_iter += iter_size;
}while (y_iter < end);
}
int main(){
i_ptr dimension = 4096;
i_ptr block = 16;
i_ptr words = block * dimension * dimension;
i_ptr bytes = words * sizeof(f_vector);
cout << "bytes = " << bytes << endl;
// system("pause");
f_vector *T = (f_vector*)_mm_malloc(bytes,16);
if (T == NULL){
cout << "Memory Allocation Failure" << endl;
system("pause");
exit(1);
}
memset(T,0,bytes);
// Perform in-place data transpose
cout << "Starting Data Transpose... ";
clock_t start = clock();
transpose_even(T,block,dimension);
clock_t end = clock();
cout << "Done" << endl;
cout << "Time: " << (double)(end - start) / CLOCKS_PER_SEC << " seconds" << endl;
_mm_free(T);
system("pause");
}
Когда я запускаю его с включенным ENABLE_PREFETCH, это вывод:
bytes = 4294967296
Starting Data Transpose... Done
Time: 0.725 seconds
Press any key to continue . . .
Когда я запускаю его с отключенным ENABLE_PREFETCH, это вывод:
bytes = 4294967296
Starting Data Transpose... Done
Time: 0.822 seconds
Press any key to continue . . .
Таким образом, скорость предварительной загрузки составляет 13%.
РЕДАКТИРОВАТЬ:
Вот еще несколько результатов:
Operating System: Windows 7 Professional/Ultimate
Compiler: Visual Studio 2010 SP1
Compile Mode: x64 Release
Intel Core i7 860 @ 2.8 GHz, 8 GB DDR3 @ 1333 MHz
Prefetch : 0.868
No Prefetch: 0.960
Intel Core i7 920 @ 3.5 GHz, 12 GB DDR3 @ 1333 MHz
Prefetch : 0.725
No Prefetch: 0.822
Intel Core i7 2600K @ 4.6 GHz, 16 GB DDR3 @ 1333 MHz
Prefetch : 0.718
No Prefetch: 0.796
2 x Intel Xeon X5482 @ 3.2 GHz, 64 GB DDR2 @ 800 MHz
Prefetch : 2.273
No Prefetch: 2.666
Двоичный поиск - простой пример, который может извлечь выгоду из явной предварительной выборки. Схема доступа в бинарном поиске выглядит довольно случайной для аппаратного средства предварительной выборки, поэтому маловероятно, что он точно предскажет, что выбрать.
В этом примере я предварительно выбираю два возможных "средних" местоположения следующей итерации цикла в текущей итерации. Одна из предварительных выборок, вероятно, никогда не будет использоваться, но другая будет использоваться (если это не последняя итерация).
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
int binarySearch(int *array, int number_of_elements, int key) {
int low = 0, high = number_of_elements-1, mid;
while(low <= high) {
mid = (low + high)/2;
#ifdef DO_PREFETCH
// low path
__builtin_prefetch (&array[(mid + 1 + high)/2], 0, 1);
// high path
__builtin_prefetch (&array[(low + mid - 1)/2], 0, 1);
#endif
if(array[mid] < key)
low = mid + 1;
else if(array[mid] == key)
return mid;
else if(array[mid] > key)
high = mid-1;
}
return -1;
}
int main() {
int SIZE = 1024*1024*512;
int *array = malloc(SIZE*sizeof(int));
for (int i=0;i<SIZE;i++){
array[i] = i;
}
int NUM_LOOKUPS = 1024*1024*8;
srand(time(NULL));
int *lookups = malloc(NUM_LOOKUPS * sizeof(int));
for (int i=0;i<NUM_LOOKUPS;i++){
lookups[i] = rand() % SIZE;
}
for (int i=0;i<NUM_LOOKUPS;i++){
int result = binarySearch(array, SIZE, lookups[i]);
}
free(array);
free(lookups);
}
Когда я компилирую и запускаю этот пример с включенным DO_PREFETCH, я вижу сокращение времени выполнения на 20%:
$ gcc c-binarysearch.c -DDO_PREFETCH -o with-prefetch -std=c11 -O3
$ gcc c-binarysearch.c -o no-prefetch -std=c11 -O3
$ perf stat -e L1-dcache-load-misses,L1-dcache-loads ./with-prefetch
Performance counter stats for './with-prefetch':
356,675,702 L1-dcache-load-misses # 41.39% of all L1-dcache hits
861,807,382 L1-dcache-loads
8.787467487 seconds time elapsed
$ perf stat -e L1-dcache-load-misses,L1-dcache-loads ./no-prefetch
Performance counter stats for './no-prefetch':
382,423,177 L1-dcache-load-misses # 97.36% of all L1-dcache hits
392,799,791 L1-dcache-loads
11.376439030 seconds time elapsed
Обратите внимание, что мы делаем вдвое больше загрузок кэша L1 в версии предварительной выборки. На самом деле мы делаем гораздо больше работы, но схема доступа к памяти более дружественна к конвейеру. Это также показывает компромисс. Хотя этот блок кода работает быстрее в изоляции, мы загрузили много мусора в кеши, и это может оказать большее давление на другие части приложения.
Я многому научился из превосходных ответов, предоставленных @JamesScriven и @Mystical. Тем не менее, их примеры дают лишь скромный импульс - цель этого ответа - представить (я должен признаться, несколько искусственно) пример, где предварительная выборка оказывает большее влияние (о факторе 4 на моей машине).
Для современных архитектур существует три возможных узких места: скорость процессора, ширина полосы памяти и задержка памяти. Предварительная выборка - это всего лишь сокращение задержки доступа к памяти.
В идеальном сценарии, где задержка соответствует X этапам вычисления, у нас будет оракул, который скажет нам, к какой памяти мы будем обращаться в X этапах вычисления, будет запущена предварительная выборка этих данных, и она будет получена только в Время X вычисление шагов позже.
Для многих алгоритмов мы (почти) в этом идеальном мире. Для простого цикла for легко предсказать, какие данные понадобятся через X шагов. Выполнение не по порядку и другие аппаратные уловки делают здесь очень хорошую работу, почти полностью скрывая задержку.
Вот почему в примере @ Mystical есть такое скромное улучшение: предварительный сборщик уже довольно хорош - просто не так много возможностей для улучшения. Задача также связана с памятью, поэтому, вероятно, осталось немного ширины полосы - она может стать ограничивающим фактором. Я мог в лучшем случае увидеть улучшение на 8% на моей машине.
Важнейшее понимание из примера @JamesScriven: ни мы, ни процессор не знают следующий адрес доступа до того, как текущие данные будут извлечены из памяти - эта зависимость довольно важна, в противном случае выполнение не по порядку может привести к упущению и аппаратное обеспечение сможет предварительно выбирать данные. Однако, поскольку мы можем спекулировать только об одном шаге, здесь не так много потенциала. Я не смог получить более 40% на моей машине.
Итак, давайте подстроим конкуренцию и подготовим данные таким образом, чтобы мы знали, к какому адресу обращаются в шагах X, но не позволяем аппаратным средствам его выяснить из-за зависимостей от еще не получивших доступ данных (см. В конце всю программу ответа):
//making random accesses to memory:
unsigned int next(unsigned int current){
return (current*10001+328)%SIZE;
}
//the actual work is happening here
void operator()(){
//set up the oracle - let see it in the future oracle_offset steps
unsigned int prefetch_index=0;
for(int i=0;i<oracle_offset;i++)
prefetch_index=next(prefetch_index);
unsigned int index=0;
for(int i=0;i<STEP_CNT;i++){
//use oracle and prefetch memory block used in a future iteration
if(prefetch){
__builtin_prefetch(mem.data()+prefetch_index,0,1);
}
//actual work, the less the better
result+=mem[index];
//prepare next iteration
prefetch_index=next(prefetch_index); #update oracle
index=next(mem[index]); #dependency on `mem[index]` is VERY important to prevent hardware from predicting future
}
}
Некоторые замечания:
- данные подготовлены таким образом, что оракул всегда прав.
- что может быть удивительно, чем меньше нагрузка на процессор, тем больше ускорение: мы можем почти полностью скрыть задержку, таким образом ускорение
CPU-time+original-latency-time/CPU-time
,
Компиляция и выполнение приводит:
>>> g++ -std=c++11 prefetch_demo.cpp -O3 -o prefetch_demo
>>> ./prefetch_demo
#preloops time no prefetch time prefetch factor
...
7 1.0711102260000001 0.230566831 4.6455521002498408
8 1.0511602149999999 0.22651144600000001 4.6406494398521474
9 1.049024333 0.22841439299999999 4.5926367389641687
....
для ускорения между 4 и 5.
Листинг prefetch_demp.cpp
:
//prefetch_demo.cpp
#include <vector>
#include <iostream>
#include <iomanip>
#include <chrono>
const int SIZE=1024*1024*1;
const int STEP_CNT=1024*1024*10;
unsigned int next(unsigned int current){
return (current*10001+328)%SIZE;
}
template<bool prefetch>
struct Worker{
std::vector<int> mem;
double result;
int oracle_offset;
void operator()(){
unsigned int prefetch_index=0;
for(int i=0;i<oracle_offset;i++)
prefetch_index=next(prefetch_index);
unsigned int index=0;
for(int i=0;i<STEP_CNT;i++){
//prefetch memory block used in a future iteration
if(prefetch){
__builtin_prefetch(mem.data()+prefetch_index,0,1);
}
//actual work:
result+=mem[index];
//prepare next iteration
prefetch_index=next(prefetch_index);
index=next(mem[index]);
}
}
Worker(std::vector<int> &mem_):
mem(mem_), result(0.0), oracle_offset(0)
{}
};
template <typename Worker>
double timeit(Worker &worker){
auto begin = std::chrono::high_resolution_clock::now();
worker();
auto end = std::chrono::high_resolution_clock::now();
return std::chrono::duration_cast<std::chrono::nanoseconds>(end-begin).count()/1e9;
}
int main() {
//set up the data in special way!
std::vector<int> keys(SIZE);
for (int i=0;i<SIZE;i++){
keys[i] = i;
}
Worker<false> without_prefetch(keys);
Worker<true> with_prefetch(keys);
std::cout<<"#preloops\ttime no prefetch\ttime prefetch\tfactor\n";
std::cout<<std::setprecision(17);
for(int i=0;i<20;i++){
//let oracle see i steps in the future:
without_prefetch.oracle_offset=i;
with_prefetch.oracle_offset=i;
//calculate:
double time_with_prefetch=timeit(with_prefetch);
double time_no_prefetch=timeit(without_prefetch);
std::cout<<i<<"\t"
<<time_no_prefetch<<"\t"
<<time_with_prefetch<<"\t"
<<(time_no_prefetch/time_with_prefetch)<<"\n";
}
}
Из документации:
for (i = 0; i < n; i++)
{
a[i] = a[i] + b[i];
__builtin_prefetch (&a[i+j], 1, 1);
__builtin_prefetch (&b[i+j], 0, 1);
/* ... */
}
Предварительная выборка данных может быть оптимизирована до размера строки кэша, который для большинства современных 64-разрядных процессоров составляет 64 байта, например, для предварительной загрузки uint32_t[16] одной инструкцией.
Например, в ArmV8 я обнаружил в процессе эксперимента приведение указателя памяти к матричному вектору uint32_t 4x4 (размером 64 байта), который вдвое уменьшил требуемые инструкции, так как раньше мне приходилось увеличивать на 8, поскольку он загружал только половину данных, хотя Насколько я понимаю, он получает полную строку кэша.
Предварительная выборка исходного кода uint32_t[32]...
int addrindex = &B[0];
__builtin_prefetch(&V[addrindex]);
__builtin_prefetch(&V[addrindex + 8]);
__builtin_prefetch(&V[addrindex + 16]);
__builtin_prefetch(&V[addrindex + 24]);
После...
int addrindex = &B[0];
__builtin_prefetch((uint32x4x4_t *) &V[addrindex]);
__builtin_prefetch((uint32x4x4_t *) &V[addrindex + 16]);
По некоторым причинам тип данных int для индекса index/offset дал лучшую производительность. Протестировано с GCC 8 на Cortex-a53. Использование эквивалентного 64-байтового вектора на других архитектурах может дать такое же улучшение производительности, если вы обнаружите, что он не выполняет предварительную выборку всех данных, как в моем случае. В моем приложении с циклом итерации в один миллион это повысило производительность на 5%. Были дополнительные требования для улучшения.
128-мегабайтное выделение памяти "V" должно было быть выровнено до 64 байтов.
uint32_t *V __attribute__((__aligned__(64))) = (uint32_t *)(((uintptr_t)(__builtin_assume_aligned((unsigned char*)aligned_alloc(64,size), 64)) + 63) & ~ (uintptr_t)(63));
Кроме того, мне пришлось использовать операторы C вместо Neon Intrinsics, так как они требуют регулярных указателей типов данных (в моем случае это было uint32_t *
) в противном случае новый встроенный метод предварительной выборки имел регрессию производительности.
Мой пример из реального мира можно найти по адресу https://github.com/rollmeister/veriumMiner/blob/main/algo/scrypt.c в scrypt_core() и его внутренней функции, которые легко читаются. Тяжелая работа сделана GCC8. Общее улучшение производительности составило 25%.