Якщо вам не потрібна дуже якісна випадковість, і близький до рівномірного розподілу досить хороший, ви можете піти дуже швидко, особливо на сучасному процесорі з ефективними цілими векторами SIMD, як x86 з SSE2 або AVX2.
Це подібно до відповіді @ NominalAnimal, оскільки ми обидва мали однакову ідею, але вручну векторизувались на x86. (І з гіршими якісними випадковими числами, але все ще, мабуть, досить хорошими для багатьох випадків використання.) Це працює приблизно в 15 або 30 разів швидше, ніж код @ Nominal, при ~ 13 Гб / с ASCII на виході 2,5 ГГц Intel Haswell Процесор з AVX2. Це все ще менше, ніж теоретична максимальна пропускна здатність основної пам’яті (двоканальний DDR3-1600 - це близько 25,6 Гб / с), але я вчасно записував до / dev / null, тому насправді це просто перезапис буфера, який залишається гарячим у кеші. Skylake повинен виконувати цей самий код значно швидше, ніж Haswell (див. Нижню частину цієї відповіді).
Якщо припустити, що ви фактично є вузьким місцем на вході / виводу на диску або трубопроводі це десь, швидка реалізація означає, що ваш процесор навіть не повинен працювати на годиннику вище, ніж у режимі очікування. Він використовує набагато менше загальної енергії для отримання результату. (Термін служби акумулятора / тепло / глобальне потепління.)
Це настільки швидко, що ви, мабуть, не хочете записувати його на диск. Просто повторно генеруйте за потребою (з того ж насіння, якщо ви знову бажаєте однакових даних). Навіть якщо ви хочете подати його в багатопотоковий процес, який може використовувати всі процесори, запустивши це, щоб передати дані до нього, він залишить його гарячим в кеш-пам'яті L3 (і кеш-пам'ять L2 в ядрі, яка це написала), і використовувати це дуже небагато часу для процесора. (Але зауважте, що трубопровід додає багато накладних витрат, ніж написання /dev/null
. На Skylake i7-6700k, переході до wc -c
іншої програми, яка щойно читає + відкидає свій вхід, це приблизно в 8 разів повільніше, ніж запис/dev/null
, і використовує лише 70% Процесор, але це ще 4,0 Гб / с на процесорі 3,9 ГГц.
Повторне генерування його швидше, ніж повторне зчитування, навіть із швидкого SSD, підключеного до PCIe, але IDK, якщо він більш енергоефективний (вектор-цілочисельний множник залишається досить зайнятим, і він, ймовірно, досить потужний, разом з іншими AVX2 256b векторних АЛУ). ОТОН, я не знаю, скільки часу для читання процесора з диска забирає щось, що максимізувало всі ядра, що обробляють цей вхід. Я здогадуюсь, що контекстний перемикач для повторного генерування в 128-кілограмових фрагментах може бути конкурентоспроможним із запуском файлової системи / коду кеш-сторінки та розподілом сторінок для читання даних з диска. Звичайно, якщо в кеш-пам’яті сторінок вже гаряче, це в основному memcpy. ОТО, ми вже пишемо про це так само швидко, як і memcpy! (яка повинна розділяти пропускну здатність основної пам'яті між читанням і записом). (Також зауважте, що запис на пам'ять, що "rep movsb
(оптимізовано memcpy та memset у мікрокоді, що дозволяє уникнути RFO, оскільки впровадження його Енді Гліу в P6 (Pentium Pro) )).
Поки що це лише доказ концепції, а обробка нового рядка лише приблизно коректна. Це неправильно навколо кінців буфера потужності-2. Що більше часу на розробку. Я впевнений, що я міг би знайти більш ефективний спосіб вставити нові рядки, що також абсолютно правильно, з накладними витратами, принаймні настільки низькими, як це (порівняно з виведенням лише пробілів). Я думаю, це щось на зразок від 10 до 20%. Мене цікавить лише те, як швидко ми могли б зробити цей пробіг, а не мати насправді відшліфовану версію, тому я залишу цю частину як вправу для читача, з коментарями, що описують деякі ідеї.
На Haswell i5 з його максимальною турбіною 2,5 ГГц, з оперативною пам’яттю DDR3-1600MHz , приурочений до 100 Гбіт, але зменшився. (Призначено для cygwin64 на Win10 з gcc5.4 -O3 -march=native
, пропущено, -funroll-loops
оскільки у мене було достатньо важкого часу, щоб отримати пристойні терміни запуску на цьому запозиченому ноутбуці. Потрібно тільки запустити Linux на USB).
запис у / dev / null, якщо не вказано інше.
- Джеймс Холліс: (не перевірено)
- Версія Fwrite номіналу: ~ 2.21с
- це (SSE2): ~ 0,142 секунди ( несказаний час = реальний = 14,232s, користувач = 13,999s, sys = 0,187s).
- це (AVX-128): ~ 0,140с
- це (AVX2): ~ 0,073s (без масштабу : real = 0m7,291s, користувач = 0m7,125s, sys = 0m0,155s).
- це (AVX2) цигвін-трубопровід
wc -c
, з розміром буфера 128 Кб: 0,32 с процесором на 2,38 ГГц (макс. двоядерний турбо). (Нерозрахунковий час: реальний = 32.466s користувач = 11.468s sys = 41.092s, включаючи і це, і те wc
). Лише половину даних було фактично скопійовано, оскільки моя дурна програма передбачає, що запис виконує повний буфер, хоча це не так, а cygwin write () складає лише 64 к за один дзвінок у трубу.
Так що з SSE2 це приблизно в 15 разів швидше, ніж скалярний код @Nominal Animal. З AVX2 це приблизно в 30 разів швидше. Я не пробував версію коду Nominal, яка просто використовується write()
замість fwrite()
, але, імовірно, для великих буферів stdio здебільшого не виходить із шляху. Якщо це копіювання даних, це призведе до значного уповільнення.
Час для отримання 1 Гб даних на Core2Duo E6600 (Merom 2,4 ГГц, 32кіБ приватний L1, 4MiB спільний кеш L2), DDR2-533 МГц у 64-бітному Linux 4.2 (Ubuntu 15.10). Незважаючи на те, що розмір буфера розміром 128 Кб для write (), не дослідив цей вимір.
запис у / dev / null, якщо не вказано інше.
- (SSE2) це з обробкою нового рядка та 4 векторами цифр від кожного вектора випадкових байтів: 0,183s (приурочено до 100GiB за 18,3s, але аналогічні результати для запуску 1GiB). 1,85 інструкцій за цикл.
- (SSE2) це, посилаючись на
wc -c
: 0,593s (без масштабу : real = 59,266s user = 20,148s sys = 1m6,548s, включаючи час процесора wc). Така ж кількість системних викликів write (), як і у cygwin, але насправді переносить усі дані, оскільки Linux обробляє всі 128k запису () в трубу.
- NominalAnimal в
fwrite()
версії (gcc5.2 -O3 -march=native
), що запускаються з ./decdig 100 $((1024*1024*1024/200)) > /dev/null
: 3.19s +/- 0,1%, з 1,40 інструкції за один цикл. -funroll-петлі зробили, можливо, невелику різницю. clang-3.8 -O3 -march=native
: 3,42s +/- 0,1%
- Nominal-
fwrite
трубопроводів для wc -c
: реальний = 3.980s користувач = 3.176s SYS = 2.080s
clang++-3.8 -O3 -march=native
Повна версія Джеймса Холліса ( ): 22,885s +/- 0,07%, з 0,84 інструкціями за цикл. (г ++ 5,2 було трохи повільніше: 22,98 с). Запис лише одного рядка за один раз, ймовірно, значно зашкодить.
- Стефан Шазелас
tr < /dev/urandom | ...
: реальний = 41.430s користувач = 26.832s sys = 40.120s. tr
більшу частину часу отримував все ядро центрального процесора, витрачаючи майже весь свій час на драйвер ядра, генеруючи випадкові байти та копіюючи їх у трубу. Інший сердечник на цій двоядерній машині пройшов решту конвеєра.
time LC_ALL=C head -c512M </dev/urandom >/dev/null
: тобто просто читати стільки випадкових випадків без трубопроводів: real = 35.018s user = 0.036s sys = 34.940s.
- Програма Perl Lưu Vĩnh Phúc (perl v5.20.2 від Ubuntu15.10)
LANG=en_CA.UTF-8
:: real = 4m32.634s user = 4m3.288s sys = 0m29.364.
LC_ALL=C LANG=C
: real = 4m18.637s user = 3m50.324s sys = 0m29.356s. Ще дуже повільно.
- (SSE2) це без обробки нового рядка і 3 або 4 вектори цифр від кожного вектора випадкових байтів (майже точно однакова швидкість:
dig3 = v%10
крок приблизно беззбитковості на цьому HW): 0,166s (1,82 інструкції за цикл) . Це в основному нижня межа для того, що ми можемо наблизитись до ідеально ефективної обробки нового рядка.
- (SSE2) Стара версія цього без будь - якої обробки нового рядка, але тільки отримувати одну цифри за uint16_t елемента з допомогою
v%10
, 0,222 секунд +/- 0,4%, 2,12 інструкцій за такт. (Скомпільовано з gcc5.2,. Розмотування -march=native -O3 -funroll-loops
циклів відбувається, щоб допомогти цьому коду на цьому обладнання. Не використовуйте його наосліп, особливо для великих програм).
- (SSE2) Стара версія цього, запис у файл (на RAID10f2 з 3 швидких магнітних жорстких дисків, не дуже оптимізованих для запису): ~ 4 секунди. Можна піти швидше, налаштувавши параметри буфера вводу-виводу ядра, щоб дозволити набагато більше брудних даних перед блоками write (). "Системний" час все ще ~ 1,0 секунди, набагато більший, ніж "користувальницький" час. У цій старій системі з повільною оперативною пам’яттю DDR2-533 оператору потрібно ~ 4 рази більше, щоб ядро запомняло дані в кеш сторінки і запускало функції XFS, ніж це робиться для мого циклу, щоб тримати перезапис його на місці в буфер, який залишається гарячим в кеш.
Як це робиться
Швидкий PRNG очевидно важливий. xorshift128 + може бути векторизований, тому у вас є два або чотири 64-бітні генератори паралельно, в елементах SIMD-вектора. Кожен крок виробляє повний вектор випадкових байтів. ( Тут реалізована 256b реалізація AVX2 із вбудованими технологіями Intel ). Я обрав це за вибором Номінального числа xorshift *, тому що 64-бітове множення векторних цілих чисел можливе лише в SSE2 / AVX2 із застосуванням розширеної точності .
Враховуючи вектор випадкових байтів, ми можемо порубати кожен 16-бітний елемент на кілька десяткових цифр. Ми виробляємо декілька векторів 16-бітних елементів, кожен з яких є одним ASCII цифрою + пробілом ASCII . Ми зберігаємо це безпосередньо у вихідному буфері.
Моя оригінальна версія щойно використовується x / 6554
для отримання однієї випадкової цифри від кожного елемента uint16_t вектора. Це завжди між 0 і 9 включно. Це упереджено 9
, бо (2^16 -1 ) / 6554
це лише 9,99923. (6554 = ceil ((2 ^ 16-1) / 10), що забезпечує коефіцієнт завжди <10)
x/6554
можна обчислити з одним множенням на "магічну" константу ( зворотна фіксована точка ) і правильний зсув результату з високою половиною. Це найкращий випадок ділення на постійну; деякі підрозділи проводять більше операцій, а підписаний відділ вимагає додаткової роботи. x % 10
має подібний ухил і не настільки дешевий для обчислення. (вихідний сигнал ASC еквівалентний x - 10*(x/10)
, тобто додаткове множення і віднімання вгорі ділення за допомогою модульної мультиплікативної зворотної.) Також найнижчий біт xorshift128 + не настільки високої якості , тому краще ділити для отримання ентропії від високих бітів ( для якості, а також швидкості), ніж модуль, щоб взяти ентропію з низьких біт.
Однак ми можемо використовувати більше ентропії в кожній uint16_t, переглядаючи низькі десяткові цифри, як-от digit()
функція @ Nominal . Для досягнення максимальної продуктивності я вирішив взяти низькі 3 десяткових цифри і x/6554
, щоб зберегти одну PMULLW та PSUBW (і, мабуть, деяку MOVDQA) проти вищої якості, щоб взяти 4 низьких десяткових цифри. x / 6554 незначно позначається на 3-х десяткових цифрах, тому існує деяка кореляція між цифрами від одного і того ж елемента (8 або 16 розрядів на виході ASCII, залежно від ширини вектора).
Я думаю, що gcc ділиться на 100 та 1000, а не довший ланцюг, який послідовно ділиться на 10, тому, ймовірно, не суттєво скорочується довжина ланцюга залежностей, що не переносяться циклами, що дає 4 результати з кожного виходу PRNG. port0 (вектор множення та зміщення) - це вузьке місце через модульні мультиплікативні обертання та зрушення в xorshift +, тому, безумовно, корисно зберегти вектор-множення.
xorshift + настільки швидкий, що навіть використання лише ~ 3,3 біт випадковості від кожні 16 (тобто ефективність 20%) не набагато повільніше, ніж їх розбивання на кілька десяткових цифр. Ми лише наближаємо рівномірний розподіл, оскільки ця відповідь орієнтована на швидкість, доки якість не надто погана.
Будь-яка умовна поведінка, яка зберігає змінну кількість елементів, зайняла б набагато більше роботи. (Але, можливо, це все-таки можна зробити дещо ефективніше, використовуючи методи лівого пакування SIMD . Однак, це стає менш ефективним для невеликих розмірів елементів; таблиці пошуку гігантських перетасовок-масок не є життєздатними, і немає перемикання смуг AVX2 з розміром менше 32- бітові елементи. Версія PSHUFB 128b, можливо, все ще зможе генерувати маску під час руху з BMI2 PEXT / PDEP, як і для AVX2 з більшими елементами , але це складно, оскільки 64-бітове ціле число вміщує лише 8 байт. Посилання Godbolt у цьому відповіді є якийсь код, який може працювати для більшої кількості елементів.)
Якщо затримка RNG - це вузьке місце, ми можемо піти ще швидше, запустивши паралельно два вектори генераторів, чергуючи той, який ми використовуємо. Компілятор все ще може легко зберігати все в регістрах у розкрученому циклі, і це дозволяє обидва ланцюги залежності працювати паралельно.
У поточній версії, підбиваючи вихід PRNG, ми фактично вузьке місце на пропускній спроможності порту 0, а не затримку PRNG, тому в цьому немає необхідності.
Код: версія AVX2
Повна версія з додатковими коментарями щодо провідника компілятора Godbolt .
Не дуже охайно, вибачте, що я мушу спати і хочу опублікувати це.
Щоб отримати версію SSE2, s/_mm256/_mm
, s/256/128/
, s/v16u/v8u/
, і змінити vector_size(32)
до 16. Крім того, змінити приріст нового рядка з 4 * 16 на 4 * 8. (Як я вже говорив, код безладний і не надто налаштований для компіляції двох версій. Спочатку не планував робити версію AVX2, але тоді я дуже хотів протестувати на процесорі Haswell, до якого я мав доступ.)
#include <immintrin.h>
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
//#include <string.h>
// This would work equally fast 128b or 256b at a time (AVX2):
// https://stackoverflow.com/questions/24001930/avx-sse-version-of-xorshift128
struct rngstate256 {
__m256i state0;
__m256i state1;
};
static inline __m256i xorshift128plus_avx2(struct rngstate256 *sp)
{
__m256i s1 = sp->state0;
const __m256i s0 = sp->state1;
sp->state0 = s0;
s1 = _mm256_xor_si256(s1, _mm256_slli_epi64(s1, 23));
__m256i state1new = _mm256_xor_si256(_mm256_xor_si256(_mm256_xor_si256(s1, s0),
_mm256_srli_epi64(s1, 18)),
_mm256_srli_epi64(s0, 5));
sp->state1 = state1new;
return _mm256_add_epi64(state1new, s0);
}
// GNU C native vectors let us get the compiler to do stuff like %10 each element
typedef unsigned short v16u __attribute__((vector_size(32)));
__m256i* vec_store_digit_and_space(__m256i vec, __m256i *restrict p)
{
v16u v = (v16u)vec;
v16u ten = (v16u)_mm256_set1_epi16(10);
v16u divisor = (v16u)_mm256_set1_epi16(6554); // ceil((2^16-1) / 10.0)
v16u div6554 = v / divisor; // Basically the entropy from the upper two decimal digits: 0..65.
// Probably some correlation with the modulo-based values, especially dig3, but we do this instead of
// dig4 for more ILP and fewer instructions total.
v16u dig1 = v % ten;
v /= ten;
v16u dig2 = v % ten;
v /= ten;
v16u dig3 = v % ten;
// dig4 would overlap much of the randomness that div6554 gets
const v16u ascii_digitspace = (v16u)_mm256_set1_epi16( (' '<<8) | '0');
v16u *vecbuf = (v16u*)p;
vecbuf[0] = div6554 | ascii_digitspace;
vecbuf[1] = dig1 | ascii_digitspace;
vecbuf[2] = dig2 | ascii_digitspace;
vecbuf[3] = dig3 | ascii_digitspace;
return p + 4; // always a constant number of full vectors
}
void random_decimal_fill_buffer(char *restrict buf, size_t len, struct rngstate256 *restrict rngstate)
{
buf = __builtin_assume_aligned(buf, 32);
// copy to a local so clang can keep state in register, even in the non-inline version
// restrict works for gcc, but apparently clang still thinks that *buf might alias *rngstate
struct rngstate256 rng_local = *rngstate;
__m256i *restrict p = (__m256i*restrict)buf;
__m256i *restrict endbuf = (__m256i*)(buf+len);
static unsigned newline_pos = 0;
do {
__m256i rvec = xorshift128plus_avx2(&rng_local);
p = vec_store_digit_and_space(rvec, p); // stores multiple ASCII vectors from the entropy in rvec
#if 1
// this is buggy at the end or start of a power-of-2 buffer:
// usually there's a too-short line, sometimes a too-long line
const unsigned ncols = 100;
newline_pos += 4*16;
if (newline_pos >= ncols) {
newline_pos -= ncols;
char *cur_pos = (char*)p;
*(cur_pos - newline_pos*2 - 1) = '\n';
}
#endif
// Turning every 100th space into a newline.
// 1) With an overlapping 1B store to a location selected by a counter. A down-counter would be more efficient
// 2) Or by using a different constant for ascii_digitspace to put a newline in one element
// lcm(200, 16) is 400 bytes, so unrolling the loop enough to produce two full lines makes a pattern of full vectors repeat
// lcm(200, 32) is 800 bytes
// a power-of-2 buffer size doesn't hold a whole number of lines :/
// I'm pretty sure this can be solved with low overhead, like maybe 10% at worst.
} while(p <= endbuf-3);
*rngstate = rng_local;
}
#define BUFFER_SIZE (128 * 1024)
const static size_t bufsz = BUFFER_SIZE;
__attribute__((aligned(64))) static char static_buf[BUFFER_SIZE];
int main(int argc, char *argv[])
{
// TODO: choose a seed properly. (Doesn't affect the speed)
struct rngstate256 xorshift_state = {
_mm256_set_epi64x(123, 456, 0x123, 0x456),
_mm256_set_epi64x(789, 101112, 0x789, 0x101112)
};
for (int i=0; i < 1024ULL*1024*1024 / bufsz * 100; i++) {
random_decimal_fill_buffer(static_buf, bufsz, &xorshift_state);
size_t written = write(1, static_buf, bufsz);
(void)written;
//fprintf(stderr, "wrote %#lx of %#lx\n", written, bufsz);
}
}
Компілюйте з gcc, clang або ICC (або, сподіваємось, будь-яким іншим компілятором, який розуміє діалект CN GNU C99 та інтеграли Intel). Векторні розширення GNU C дуже зручні для отримання компілятора для генерації магічних чисел для поділу / модуля за допомогою модульних мультиплікативних обертів, а випадкові __attribute__
s корисні.
Це можна записати портативно, але знадобиться більше коду.
Примітки щодо виконання:
Перекриття магазину для вставки нових рядків має значні накладні витрати, щоб вирішити, де його розмістити (непередбачувані гілки та вузькі місця в Core2), але сам магазин не впливає на продуктивність. Коментуючи саме цю інструкцію щодо зберігання в ASM компілятора (залишаючи всі розгалуження однаковими), залишилася продуктивність на Core2 повністю незмінною, при цьому повторні запуски давали в той же час +/- менше 1%. Тож я роблю висновок, що буфер магазину / кеш-пам'яті справляється з ним просто чудово.
Однак використання якогось обертового вікна ascii_digitspace
з одним елементом, що має новий рядок, може бути ще швидшим, якщо ми розкрутимо достатньо, щоб будь-які лічильники / розгалуження відійшли.
Запис у / dev / null в основному не працює, тому буфер, ймовірно, залишається гарячим у кеш-пам'яті L2 (256кіБ на ядро Haswell). Очікується ідеальна швидкість руху від 128b до 256b векторів: додаткових інструкцій немає, і все (включаючи магазини) відбувається з подвійною шириною. Однак гілка введення нового рядка береться вдвічі частіше, хоча. Я, на жаль, не встиг про встановити Хасвелл Cygwin з цією частиною #ifdef
.
2,5 ГГц * 32В / 13,7 Гб / с = 5,84 циклів в магазині AVX2 на Haswell. Це досить добре, але може бути швидше. Можливо, в системі цигвін є якісь накладні витрати, ніж я думав. Я не намагався коментувати їх у виході ASM компілятора (що б гарантувало, що нічого не оптимізоване.)
Кеш-пам'ять L1 може підтримувати один запам'ятовуючий пристрій 32В на добу, а L2 - не набагато менша пропускна здатність (хоча більша затримка).
Коли я переглянув IACA кілька версій тому (без розгалуження для нових рядків, але отримуючи лише один вектор ASCII на RNG-вектор), він передбачив щось подібне до одного магазину векторів 32В на 4 або 5 годин.
Я сподівався отримати більшу швидкість, ніж витягнути більше даних з кожного результату RNG, базуючись на перегляді asm, враховуючи посібники Agner Fog та інші ресурси оптимізації, на які я додав посилання у вікі тегів SO x86 .)
Ймовірно, це було б значно швидше на Skylake , де векторне ціле число множення та зсув може працювати на два рази більше портів (p0 / p1) порівняно з Haswell (лише p0). xorshift і видобуток цифр використовують багато змін і множень. ( Оновлення: Skylake запускає його на 3,02 IPC, даючи нам 3,77 циклів на 32-байтовому сховищі AVX2 , приуроченому до 0,030 секунди за 1 Гб ітерації, записуючи /dev/null
на Linux 4.15 на i7-6700k на частоті 3,9 ГГц.
Для його роботи не потрібен 64-бітний режим . Версія SSE2 настільки ж швидка при компіляції -m32
, оскільки їй не потрібно дуже багато векторних регістрів, і вся 64-бітова математика проводиться у векторах, а не в регістрах загального призначення.
Насправді це трохи швидше в 32-розрядному режимі на Core2, тому що порівняння / гілка макро-синтезу працює лише в 32-бітному режимі, тому менше ядра для ядра поза замовленням (18,3 секунди (1,85 інструкції за такт) vs 16,9s (2,0 IPC)). Менший розмір коду відсутності префіксів REX також допомагає декодерам Core2.
Крім того, деякі переміщення вектора reg-reg замінюються навантаженнями, оскільки не всі константи фіксуються у векторних рег. Оскільки пропускна здатність навантаження з кешу L1 не є вузьким місцем, це фактично допомагає. (наприклад, множення на постійний вектор set1(10)
: movdqa xmm0, xmm10
/ pmullw xmm0, xmm1
перетворюється на movdqa xmm0, [constant]
/ pmullw xmm0, xmm1
.) Оскільки для reg-reg MOVDQA потрібен порт ALU, він конкурує з реальною роботою, яка виконується, але навантаження MOVDQA конкурує лише за пропускну здатність декодування переднього кінця. (Наявність 4-байтної адреси всередині багатьох інструкцій скасовує велику вигоду від збереження префіксів REX.
Я не був би здивований, якщо врятувати ALU MOVDQA Uops - це те, звідки беруться справжні вигоди, оскільки фронтенд повинен бути в ногу з середнім рівнем 2.0 IPC.
Всі ці відмінності зникають на Haswell, де вся справа повинна запускатися з кешованого декоду взагалі кеша, якщо не буфера зворотного зв'язку. ALU + макро-синтез гілки працює в обох режимах з часів Негалема.