Если вам не требуется случайность очень высокого качества, а распределение, близкое к равномерному, достаточно хорошее, вы можете работать очень быстро, особенно на современном процессоре с эффективными целочисленными векторами 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 векторных ALU). OTOH, я не знаю, сколько процессорного времени чтения с диска отнимает у чего-то, что максимизирует все ядра, обрабатывающие этот ввод. Я полагаю, что переключение контекста для повторной генерации в кусках по 128 КБ может быть конкурентоспособным с запуском кода файловой системы / кэша страниц и выделением страниц для чтения данных с диска. Конечно, если в страничном кэше уже жарко, то это просто memcpy. ОТО, мы уже пишем о так быстро, как memcpy! (который должен разделять пропускную способность основной памяти между чтением и записью). (Также обратите внимание, что запись в память, чтоrep movsb
(оптимизированы memcpy и memset в микрокоде, что позволяет избежать RFO, поскольку Энди Глью реализовал его в P6 (Pentium Pro) )).
Пока что это только подтверждение концепции, а обработка новой строки только приблизительно верна. Это неправильно на концах буфера степени 2. С большим временем разработки. Я уверен, что смог бы найти более эффективный способ вставки новых строк, который также был бы абсолютно правильным, с минимальными издержками, как это (по сравнению с выводом только пробелов). Я думаю, что это что-то вроде от 10 до 20%. Меня интересует только то, как быстро мы сможем сделать этот прогон, а не на самом деле иметь его отполированную версию, поэтому я оставлю эту часть в качестве упражнения для читателя с комментариями, описывающими некоторые идеи.
На Haswell i5 с максимальной турбо- частотой 2,5 ГГц , с оперативной памятью DDR3-1600 МГц, с тактовой частотой 100 ГБ, но с уменьшением. (Приурочен к cygwin64 на Win10 с gcc5.4 -O3 -march=native
, опущен, -funroll-loops
так как мне было достаточно трудно получить приличную синхронизацию на этом заимствованном ноутбуке. Должен был только загрузить Linux на USB).
запись в / dev / null, если не указано иное.
- Джеймс Холлис: (не проверено)
- Номинальная версия fwrite: ~ 2.21 с
- это (SSE2): ~ 0,142 с ( немасштабированное время = реальное = 14,232 с, пользователь = 13,999 с, sys = 0,187 с).
- это (AVX-128): ~ 0,140 с
- это (AVX2): ~ 0,073 с ( немасштабированное : действительное = 0m7,291 с, пользовательское = 0m7,125 с, sys = 0m0,155 с).
- это (AVX2) трубопровод
wc -c
Cygwin с размером буфера 128 кБ: 0,32 с процессором на частоте 2,38 ГГц (макс. двухъядерный Turbo). (немасштабированное время: реальное = 32,466 с, пользователь = 11,468 с, с = 41,092 с, включая это и wc
). Правда, только половина данных была скопирована, потому что моя глупая программа предполагает, что запись выполняет полный буфер, хотя это не так, и cygwin write () делает только 64 КБ на вызов в канал.
Так что с SSE2 это примерно в 15 раз быстрее, чем скалярный код @Nominal Animal. С AVX2 это примерно в 30 раз быстрее. Я не пробовал версию кода Nominal, которая просто использует write()
вместо fwrite()
, но, по-видимому, для больших буферов stdio в основном остаётся в стороне. Если это копирование данных, это будет причиной большого замедления.
Времена для производства 1 ГБ данных на Core2Duo E6600 (Merom 2,4 ГГц, 32 КБ частного L1, 4 МБ общего кэша L2), DDR2-533 МГц в 64-битном Linux 4.2 (Ubuntu 15.10). Все еще используя буфер размером 128 КБ для write (), не исследовал это измерение.
запись в / dev / null, если не указано иное.
- (SSE2) это с обработкой новой строки и 4 векторами цифр из каждого вектора случайных байтов: 0,183 с (при времени 100 ГБ за 18,3 с, но аналогичные результаты для прогонов 1 ГБ). 1,85 инструкции за цикл.
- (SSE2) это, трубопровод
wc -c
: 0,593 с ( немасштабированный : реальный = 59,266 с, пользователь = 20,148 с, sys = 1m6,548 с, включая время процессора в wc). Такое же количество системных вызовов write (), как и в cygwin, но на самом деле передает все данные, потому что Linux обрабатывает все 128k write () в канал.
- 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.42 с +/- 0,1%
- Номинальный
fwrite
трубопровод wc -c
: реальный = 3,980 с, пользователь = 3,176 с, sys = 2,080 с.
clang++-3.8 -O3 -march=native
Линейная версия Джеймса Холлиса ( ): 22,885 с +/- 0,07%, с 0,84 инструкциями за цикл. (g ++ 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,166 с (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 * в Nominal, потому что 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
имеет аналогичное смещение и не так дешево вычислять. (Вывод asm для gcc эквивалентен 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- битовые элементы. 128-битная версия PSHUFB все еще может генерировать маску на лету с BMI2 PEXT / PDEP, как вы можете для AVX2 с более крупными элементами , но это сложно, потому что 64-битное целое число содержит только 8 байтов. в этом ответе есть код, который может работать для большего количества элементов.)
Если задержка ГСЧ является узким местом, мы могли бы пойти еще быстрее, запустив два вектора генераторов параллельно, чередуя один из которых мы используем. Компилятор все еще может легко хранить все в регистрах в развернутом цикле, и это позволяет двум цепочкам зависимостей работать параллельно.
В текущей версии, сокращая выход 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 (или, надеюсь, любого другого компилятора, который понимает диалект C GNU C от C99 и присущи Intel). Векторные расширения GNU C очень удобны для того, чтобы компилятор генерировал магические числа для деления / по модулю, используя модульные мультипликативные инверсии, и случайные __attribute__
s полезны.
Это может быть написано переносимо, но это займет больше кода.
Примечания по производительности:
Хранилище с перекрытием для вставки новых строк имеет значительные накладные расходы, чтобы решить, где его разместить (неправильные прогнозы веток и узкие места внешнего интерфейса в Core2), но само хранилище не влияет на производительность. Комментируя только эту инструкцию хранилища в ассемблере компилятора (оставляя все ветвления одинаковыми), мы практически не изменили производительность на Core2, при повторных запусках то же самое время +/- менее 1%. Таким образом, я пришел к выводу, что хранилище буфера / кеша справляется просто.
Тем не менее, использование некоторого вида вращающегося окна ascii_digitspace
с одним элементом, имеющим символ новой строки, может быть даже быстрее, если мы развернем достаточно, чтобы исчезли какие-либо счетчики / ответвления.
Запись в / dev / null в основном не работает, поэтому, вероятно, буфер остается горячим в кеше L2 (256 кБ на ядро в Haswell). Ожидается идеальное ускорение от 128b векторов до 256b векторов: никаких дополнительных инструкций нет, и все (включая магазины) происходит с удвоенной шириной. Тем не менее, ветвь вставки новой строки используется в два раза чаще. К сожалению, у меня не было времени на настройку Haswell cygwin с этой частью #ifdef
.
2,5 ГГц * 32B / 13,7 ГБ / с = 5,84 цикла на магазин AVX2 в Haswell. Это довольно хорошо, но может быть быстрее. Возможно, в системных вызовах cygwin есть некоторые издержки, чем я думал. Я не пытался комментировать их в выводе asm компилятора (что гарантировало бы, что ничего не оптимизировано).
Кэш-память L1 может поддерживать одно хранилище 32B за такт, а уровень L2 не намного ниже пропускной способности (хотя и более высокая задержка).
Когда я смотрел на IACA несколько версий назад (без разветвления для новых строк, но получая только один вектор ASCII на вектор RNG), он предсказывал что-то вроде одного хранилища векторов 32B на 4 или 5 тактов.
Я надеялся получить больше ускорения от извлечения большего количества данных из каждого результата ГСЧ, основываясь на том, как я сам смотрю на ассемблер, учитывая руководства Агнера Фога и другие ресурсы по оптимизации, ссылки на которые я добавил в вики-тэге 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 инструкций за такт) против 16,9 с (2,0 МПК)). Меньший размер кода из-за отсутствия префиксов 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 в мопах, поскольку внешний интерфейс должен очень хорошо отставать от среднего значения 2,0 IPC.
Все эти различия исчезают в Haswell, где все это должно запускаться из кэша decoded-uop, если не из буфера обратной петли. ALU + ветвление макро-синтеза работает в обоих режимах начиная с Nehalem.