Паралельне випадкове читання, здається, працює добре - чому?


18

Розглянемо наступну дуже просту комп'ютерну програму:

for i = 1 to n:
    y[i] = x[p[i]]

Тут і - -елементних масивів байтів, а - -елементний масив слів. Тут є великим, наприклад, (так що лише незначна частка даних вписується в будь-яку пам'ять кешу).хунpннн=231

Припустимо, що складається з випадкових чисел , рівномірно розподілених між і .p1н

З погляду сучасного обладнання, це повинно означати наступне:

  • читання є дешевим (послідовне читання)p[i]
  • читання є дуже дорогим (випадкові читання; майже всі читання - це пропуски кешу; нам доведеться вибирати кожен байт з основної пам'яті)х[p[i]]
  • писати - дешево (послідовне записування).у[i]

І це справді те, що я спостерігаю. Програма дуже повільна в порівнянні з програмою, яка робить тільки послідовне читання та запис. Чудово.

Тепер виникає питання: наскільки добре ця програма паралелізується на сучасних багатоядерних платформах?


Моя гіпотеза полягала в тому, що ця програма не є паралельною. Адже вузьке місце є основною пам’яттю. Одне ядро ​​вже витрачає більшу частину свого часу, просто чекаючи деяких даних з головної пам'яті.

Однак це не те, що я спостерігав, коли я почав експериментувати з деякими алгоритмами, де вузьким місцем була така операція!

Я просто замінив наївний цикл на OpenMP паралельним for-loop (по суті, він просто розділить діапазон на більш дрібні частини і паралельно запустить ці частини на різних ядрах процесора).[1,н]

На комп’ютерах низького класу прискорення дійсно були незначними. Але на платформах вищого класу мене здивувало, що я отримую відмінні майже лінійні скорочення. Деякі конкретні приклади (точні моменти часу можуть бути трохи відхилені, випадкових варіацій дуже багато; це були лише швидкі експерименти):

  • 2 x 4-ядерний Xeon (загалом 8 ядер): коефіцієнт 5-8 прискорень порівняно з однопотоковою версією.

  • 2 x 6-ядерний Xeon (загалом 12 ядер): прискорення з коефіцієнтом 8-14 порівняно з однопотоковою версією.

Тепер це було абсолютно несподівано. Запитання:

  1. Саме чому така програма паралельно працює так добре ? Що відбувається з обладнанням? (Моя теперішня здогадка - це щось у цьому напрямку: випадкові читання з різних потоків є "конвеєрними", а середня швидкість отримання відповідей на них набагато вища, ніж у випадку з однією ниткою.)

  2. Чи потрібно використовувати кілька потоків і декілька ядер, щоб отримати будь-які прискорення? Якщо якийсь конвеєрний процес дійсно має місце в інтерфейсі між основною пам'яттю та процесором, не вдалось би однопотоковому додатку повідомити головній пам'яті, що незабаром знадобиться , , ... і комп'ютер може почати вибирати відповідні рядки кешу з основної пам'яті? Якщо це можливо в принципі, як я це досягти на практиці?х[p[i]]х[p[i+1]]

  3. Яку правильну теоретичну модель можна використати для аналізу такого роду програм (та правильних прогнозів ефективності)?


Редагувати: Зараз доступні деякі вихідні коди та результати порівняння: https://github.com/suomela/parallel-random-read

Деякі приклади фігур бального парку ( ):н=232

  • бл. 42 ns за ітерацію (випадкове зчитування) однією ниткою
  • бл. 5 нс на ітерацію (випадкове зчитування) з 12 ядрами.

Відповіді:


9

Забудьте на мить усі питання, пов’язані з доступом до основної пам'яті та кешу 3 рівня. З паралельної точки зору, ігноруючи ці проблеми, програма ідеально паралелізується при використанні процесорів (або ядер), завдяки тому, що, як тільки ви розділите роботу, яку потрібно виконати шляхом розкладання домену, кожне ядро ​​повинне обробити або npабоnнpелементів і немає зв'язку та / або синхронізації накладних витрат, оскільки функціональна залежність між процесорами не існує. Тому, ігноруючи проблеми з пам'яттю, ви очікуєте прискорення, рівногоp.нpp

Тепер давайте врахуємо проблеми пам'яті. Надлінійне прискорення, яке ви фактично спостерігали на своєму високому рівні, на базі Xeon-вузла, виправдано так.

нн/pp

н=231

н

Нарешті, окрім QSM (Queuing Shared Memory) , мені не відома жодна інша теоретична паралельна модель, що враховує на тому ж рівні суперечку щодо доступу до спільної пам'яті (у вашому випадку, коли використовується OpenMP, основна пам'ять поділяється між ядрами , а кеш завжди поділяється також серед ядер). У будь-якому випадку, хоча модель цікава, вона не отримала великого успіху.


1
Це також може допомогти розглянути це як кожне ядро, що забезпечує більш-менш фіксовану кількість паралелізму рівня пам'яті, наприклад, 10 x [] завантажень, що перебувають у процесі роботи в даний момент часу. Маючи 0,5% шансу потрапити в спільний L3, одна нитка матиме шанс 0,995 ** 10 (95 +%), щоб вимагати від усіх цих навантажень чекати основного відгуку пам'яті. Із 6 ядер, що забезпечують 60 x [] очікувань на очікування, існує майже 26% шансів, що принаймні одне зчитування потрапить у L3. Крім того, чим більше MLP, тим більше контролер пам'яті може запланувати доступ для збільшення реальної пропускної здатності.
Пол А. Клейтон

5

Я вирішив спробувати __builtin_prefetch () сам. Я розміщую його тут як відповідь у випадку, якщо інші захочуть перевірити його на своїх машинах. Результати близькі до того, що описує Jukka: Приблизно на 20% скорочення часу роботи під час попереднього вибору 20 елементів вперед проти попереднього вибору 0 елементів вперед.

Результати:

prefetch =   0, time = 1.58000
prefetch =   1, time = 1.47000
prefetch =   2, time = 1.39000
prefetch =   3, time = 1.34000
prefetch =   4, time = 1.31000
prefetch =   5, time = 1.30000
prefetch =   6, time = 1.27000
prefetch =   7, time = 1.28000
prefetch =   8, time = 1.26000
prefetch =   9, time = 1.27000
prefetch =  10, time = 1.27000
prefetch =  11, time = 1.27000
prefetch =  12, time = 1.30000
prefetch =  13, time = 1.29000
prefetch =  14, time = 1.30000
prefetch =  15, time = 1.28000
prefetch =  16, time = 1.24000
prefetch =  17, time = 1.28000
prefetch =  18, time = 1.29000
prefetch =  19, time = 1.25000
prefetch =  20, time = 1.24000
prefetch =  19, time = 1.26000
prefetch =  18, time = 1.27000
prefetch =  17, time = 1.26000
prefetch =  16, time = 1.27000
prefetch =  15, time = 1.28000
prefetch =  14, time = 1.29000
prefetch =  13, time = 1.26000
prefetch =  12, time = 1.28000
prefetch =  11, time = 1.30000
prefetch =  10, time = 1.31000
prefetch =   9, time = 1.27000
prefetch =   8, time = 1.32000
prefetch =   7, time = 1.31000
prefetch =   6, time = 1.30000
prefetch =   5, time = 1.27000
prefetch =   4, time = 1.33000
prefetch =   3, time = 1.38000
prefetch =   2, time = 1.41000
prefetch =   1, time = 1.41000
prefetch =   0, time = 1.59000

Код:

#include <stdlib.h>
#include <time.h>
#include <stdio.h>

void cracker(int *y, int *x, int *p, int n, int pf) {
    int i;
    int saved = pf;  /* let compiler optimize address computations */

    for (i = 0; i < n; i++) {
        __builtin_prefetch(&x[p[i+saved]]);
        y[i] += x[p[i]];
    }
}

int main(void) {
    int n = 50000000;
    int *x, *y, *p, i, pf, k;
    clock_t start, stop;
    double elapsed;

    /* set up arrays */
    x = malloc(sizeof(int)*n);
    y = malloc(sizeof(int)*n);
    p = malloc(sizeof(int)*n);
    for (i = 0; i < n; i++)
        p[i] = rand()%n;

    /* warm-up exercise */
    cracker(y, x, p, n, pf);

    k = 20;
    for (pf = 0; pf < k; pf++) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }
    for (pf = k; pf >= 0; pf--) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }

    return 0;
}

4
  1. Доступ до DDR3 дійсно конвеєрний. http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdf слайди 20 та 24 показують, що відбувається в шині пам'яті під час операцій зчитування з конвеєрного каналу.

  2. (частково неправильно, див. нижче) Кілька потоків не потрібні, якщо архітектура процесора підтримує попередній вибір кешу. Сучасні x86 та ARM, а також багато інших архітектури мають чітку інструкцію попереднього вибору. Багато з них намагаються виявити шаблони в доступі до пам'яті і зробити попереднє завантаження автоматично. Підтримка програмного забезпечення залежить від компілятора, наприклад, GCC та Clang мають __builtin_prefech () властиві для явного попереднього завантаження.

Схоже, що гіпертрафікація в стилі Intel працює дуже добре для програм, які проводять більшу частину часу в очікуванні пропусків кешу. На мій досвід, при обчислювально-інтенсивному навантаженні прискорення дуже мало перевищує кількість фізичних ядер.

EDIT: Я помилявся в пункті 2. Схоже, що під час попереднього вибору можна оптимізувати доступ до пам’яті для одноядерних, комбінована пропускна здатність пам’яті з декількох ядер перевищує пропускну здатність одного ядра. Наскільки більше, залежить від процесора.

Апаратне попереднє програмне забезпечення та інші оптимізації разом роблять тестування дуже складним. Можна побудувати випадки, коли явне попереднє вилучення має дуже помітний або неіснуючий вплив на продуктивність, цей показник є одним з останніх.


__builtin_prefech звучить дуже перспективно. На жаль, у моїх швидких експериментах, здається, це не дуже допомогло з однотоковою продуктивністю (<10%). Яких великих поліпшень швидкості слід очікувати в цьому додатку?
Юкка Суомела

Я очікував більше. Оскільки я знаю, що prefetch має суттєвий ефект у DSP та іграх, мені довелося експериментувати. Виявилося, що кроляча нора заглиблюється ...
Джухані Сімола

Моєю першою спробою було створення фіксованого випадкового порядку, збереженого в масиві, потім повторення в цьому порядку з попереднім вилученням і без нього ( gist.github.com/osimola/7917602 ). Це принесло різницю приблизно в 2% на Core i5. Звучить, що або попередній вибір не працює взагалі, або апаратний предиктор розуміє непрямість.
Юхані Сімола

1
Отже, тестуючи на це, друга спроба ( gist.github.com/osimola/7917568 ) здійснює доступ до пам'яті в послідовності, породженій фіксованим випадковим насінням. Цього разу версія попереднього завантаження виявилася приблизно в 2 рази швидшою, ніж не попереднє завантаження, і в 3 рази швидше, ніж попереднє завантаження на 1 крок вперед. Зауважте, що версія попереднього завантаження робить більше обчислень на доступ до пам'яті, ніж версія, що не попереднє завантаження.
Юхані Сімола

Це, здається, залежить від машини. Я спробував код Пат Моріна нижче (не можу коментувати цю посаду, оскільки не маю репутації), і мій результат знаходиться в межах 1,3% для різних значень попереднього вибору.
Juhani Simola
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.