Si no necesita una aleatoriedad de muy alta calidad, y una distribución casi uniforme es lo suficientemente buena, puede ir muy rápido, especialmente en una CPU moderna con vectores enteros SIMD eficientes como x86 con SSE2 o AVX2.
Esto es como la respuesta de @ NominalAnimal ya que ambos teníamos la misma idea, pero vectorizados manualmente para x86. (Y con números aleatorios de peor calidad, pero probablemente lo suficientemente buenos para muchos casos de uso). Esto se ejecuta aproximadamente 15 o 30 veces más rápido que el código de @ Nominal, a ~ 13 GB / s de salida ASCII en un Intel Haswell de 2.5 GHz CPU con AVX2. Eso es aún menos que el ancho de banda máximo teórico de la memoria principal (DDR3-1600 de doble canal es de aproximadamente 25.6GB / s), pero estaba cronometrando la escritura en / dev / null, por lo que en realidad solo está reescribiendo un búfer que permanece caliente en la caché. Skylake debería ejecutar este mismo código significativamente más rápido que Haswell (consulte la parte inferior de esta respuesta).
Suponiendo que realmente cuente con un cuello de botella en E / S en el disco o conecte esto en alguna parte, una implementación rápida significa que su CPU ni siquiera tiene que registrar un reloj más alto que inactivo. Utiliza mucha menos energía total para producir el resultado. (Duración de la batería / calor / calentamiento global).
Esto es tan rápido que probablemente no desee escribirlo en el disco. Simplemente vuelva a generar según sea necesario (de la misma semilla si desea los mismos datos nuevamente). Incluso si desea alimentarlo a un proceso de subprocesos múltiples que puede usar todas las CPU, ejecutar esto para canalizar los datos lo dejará caliente en el caché L3 (y el caché L2 en el núcleo que lo escribió), y lo usará muy Poco tiempo de CPU. (Pero tenga en cuenta que las tuberías agregan una gran cantidad de gastos generales en lugar de escribir en /dev/null
. En un Skylake i7-6700k, las tuberías wc -c
u otro programa que solo lee + descarta su entrada, es aproximadamente 8 veces más lento que escribir en/dev/null
, y solo usa el 70% de un CPU. Pero eso sigue siendo 4.0GB / s en una CPU de 3.9GHz.
Regenerarlo es más rápido que volver a leerlo incluso desde un SSD rápido conectado a PCIe, pero IDK si es más eficiente en energía (el multiplicador de enteros vectoriales se mantiene bastante ocupado, y probablemente consuma mucha energía, junto con otros AVX2 256 ALU de vector). OTOH, no sé cuánto tiempo de CPU tomaría la lectura del disco de algo que estaba maximizando todos los núcleos que procesan esta entrada. Supongo que un cambio de contexto para volver a generar en trozos de 128k podría ser competitivo con la ejecución de código de sistema de archivos / caché de página y la asignación de páginas para leer datos del disco. Por supuesto, si ya está caliente en el caché de página, es básicamente memcpy. ¡OTOH, ya escribimos sobre tan rápido como memcpy! (que tiene que dividir el ancho de banda de la memoria principal entre lectura y escritura). (También tenga en cuenta que escribir en la memoria que 'rep movsb
(memcpy y memset optimizados en microcódigo, lo que evita RFO, desde la implementación de Andy Glew en P6 (Pentium Pro) )).
Hasta ahora, esto es solo una prueba de concepto, y el manejo de la nueva línea es aproximadamente correcto. Está mal alrededor de los extremos de un búfer de potencia de 2. Con más tiempo de desarrollo. Estoy seguro de que podría encontrar una forma más eficiente de insertar nuevas líneas que también sea exactamente correcta, con una sobrecarga al menos tan baja como esta (en comparación con la salida de solo espacios). Creo que esto es algo así como del 10 al 20%. Solo estoy interesado en saber qué tan rápido podríamos hacer que esto funcione, no en tener una versión pulida, por lo que dejaré esa parte como un ejercicio para el lector, con comentarios que describen algunas ideas.
En un Haswell i5 con su turbo máximo de 2.5GHz, con DDR3-1600MHz RAM , cronometrado produciendo 100GiB pero reducido. (Programado en cygwin64 en Win10 con gcc5.4 -O3 -march=native
, omitido -funroll-loops
ya que estaba teniendo dificultades para obtener tiempos decentes en esta computadora portátil prestada. Debería haber arrancado Linux en un USB).
escribiendo a / dev / null a menos que se especifique lo contrario.
- James Hollis's: (no probado)
- Versión de escritura de Nominal: ~ 2.21s
- esto (SSE2): ~ 0.142s (tiempos sin escala = real = 14.232s, usuario = 13.999s, sys = 0.187s).
- esto (AVX-128): ~ 0.140s
- esto (AVX2): ~ 0.073s (sin escala : real = 0m7.291s, usuario = 0m7.125s, sys = 0m0.155s).
- Esta tubería de
wc -c
Cygwin (AVX2) , con un tamaño de búfer de 128 kB: 0,32 s con CPU a 2,38 GHz (turbo máximo de doble núcleo). (tiempos sin escala: real = 32.466s usuario = 11.468s sys = 41.092s, incluidos ambos y wc
). Sin embargo, solo la mitad de los datos se copiaron realmente, porque mi tonto programa supone que la escritura hace el búfer completo, aunque ese no es el caso y cygwin write () solo hace 64k por llamada en una tubería.
Entonces, con SSE2, esto es aproximadamente 15 veces más rápido que el código escalar de @Nominal Animal. Con AVX2, es aproximadamente 30 veces más rápido. No probé una versión del código de Nominal que solo usa en write()
lugar de fwrite()
, pero presumiblemente para buffers grandes, stdio generalmente se mantiene fuera del camino. Si está copiando los datos, eso explicaría mucha desaceleración.
Tiempos para producir 1GB de datos en un Core2Duo E6600 (Merom 2.4GHz, 32kiB privado L1, 4MiB cachés L2 compartidos), DDR2-533MHz en Linux 4.2 de 64 bits (Ubuntu 15.10). Aún utilizando un tamaño de búfer de 128 kB para write (), no he explorado esa dimensión.
escribiendo a / dev / null a menos que se especifique lo contrario.
- (SSE2) esto con manejo de nueva línea y 4 vectores de dígitos de cada vector de bytes aleatorios: 0.183s (cronometrado haciendo 100GiB en 18.3s, pero resultados similares para ejecuciones de 1GiB). 1.85 instrucciones por ciclo.
- (SSE2) esto, canalizando a
wc -c
: 0.593s (sin escala : real = 59.266s usuario = 20.148s sys = 1m6.548s, incluido el tiempo de CPU de wc). El mismo número de llamadas al sistema write () que con cygwin, pero en realidad canaliza todos los datos porque Linux maneja los 128k de write () en una tubería.
- NominalAnimal de
fwrite()
versión (gcc5.2 -O3 -march=native
), ejecuta con ./decdig 100 $((1024*1024*1024/200)) > /dev/null
: 3.19s +/- 0,1%, con 1,40 instrucción por ciclo. -funroll-loops hizo tal vez una pequeña diferencia. clang-3.8 -O3 -march=native
: 3.42s +/- 0.1%
fwrite
Tubería nominal a wc -c
: real = 3.980s usuario = 3.176s sys = 2.080s
- Versión de línea a tiempo de James Hollis (
clang++-3.8 -O3 -march=native
): 22.885s +/- 0.07%, con 0.84 instrucciones por ciclo. (g ++ 5.2 fue ligeramente más lento: 22.98s). Escribir solo una línea a la vez probablemente duele significativamente.
- Stéphane Chazelas's
tr < /dev/urandom | ...
: real = 41.430s usuario = 26.832s sys = 40.120s. tr
estaba obteniendo todo el núcleo de la CPU para sí mismo la mayor parte del tiempo, pasando casi todo su tiempo en el controlador del núcleo generando bytes aleatorios y copiándolos en una tubería. El otro núcleo en esta máquina de doble núcleo estaba ejecutando el resto de la tubería.
time LC_ALL=C head -c512M </dev/urandom >/dev/null
: es decir, solo leer tanta aleatoriedad sin tuberías: real = 35.018s usuario = 0.036s sys = 34.940s.
- Programa perl de Lưu Vĩnh Phúc (perl v5.20.2 de Ubuntu15.10)
LANG=en_CA.UTF-8
:: real = 4m32.634s usuario = 4m3.288s sys = 0m29.364.
LC_ALL=C LANG=C
: real = 4m18.637s usuario = 3m50.324s sys = 0m29.356s. Aún muy lento.
- (SSE2) esto sin manejo de línea nueva , y 3 o 4 vectores de dígitos de cada vector de bytes aleatorios (casi exactamente la misma velocidad: el
dig3 = v%10
paso es sobre el punto de equilibrio en este HW): 0.166s (1.82 instrucciones por ciclo) . Este es básicamente el límite inferior para lo que podemos acercarnos con un manejo de nueva línea perfectamente eficiente.
- (SSE2) Versión anterior de esto sin manejo de línea nueva, pero solo obteniendo un dígito por elemento uint16_t usando
v%10
, 0.222 segundos +/- 0.4%, 2.12 instrucciones por ciclo. (Compilado con gcc5.2,. -march=native -O3 -funroll-loops
Desenrollar bucles sí ayuda para este código en este hardware. No lo use a ciegas, especialmente para programas grandes).
- (SSE2) Versión anterior de esto, escribiendo en un archivo (en un RAID10f2 de 3 discos duros magnéticos rápidos, no muy optimizado para escrituras): ~ 4 segundos. Podría ir más rápido ajustando la configuración del búfer de E / S del kernel para permitir una mayor cantidad de datos sucios antes de los bloques write (). El tiempo del "sistema" sigue siendo ~ 1.0 segundos, mucho más alto que el tiempo del "usuario". En este viejo sistema con RAM DDR2-533 lenta, el núcleo tarda ~ 4 veces más en memorizar los datos en el caché de página y ejecutar funciones XFS que en mi bucle para seguir reescribiéndolo en su lugar en un búfer que permanece caliente cache.
Cómo está hecho
Un PRNG rápido es obviamente esencial. xorshift128 + se puede vectorizar, por lo que tiene dos o cuatro generadores de 64 bits en paralelo, en elementos de un vector SIMD. Cada paso produce un vector completo de bytes aleatorios. ( Implementación de 256b AVX2 aquí con intrínsecos Intel ). Lo elegí por la elección de xorshift * de Nominal, porque la multiplicación de enteros vectoriales de 64 bits solo es posible en SSE2 / AVX2 con técnicas de precisión extendida .
Dado un vector de bytes aleatorios, podemos dividir cada elemento de 16 bits en múltiples dígitos decimales. Producimos múltiples vectores de elementos de 16 bits que son cada uno un dígito ASCII + espacio ASCII . Lo almacenamos directamente en nuestro búfer de salida.
Mi versión original solo solía x / 6554
obtener un dígito aleatorio de cada elemento uint16_t de un vector. Siempre está entre 0 y 9, inclusive. Está sesgado 9
, porque (2^16 -1 ) / 6554
es solo 9.99923. (6554 = ceil ((2 ^ 16-1) / 10), lo que garantiza que el cociente sea siempre <10.)
x/6554
se puede calcular con una multiplicación por una constante "mágica" ( el recíproco de punto fijo ) y un desplazamiento a la derecha del resultado de la mitad superior. Este es el mejor caso para la división por una constante; algunos divisores realizan más operaciones, y la división firmada requiere trabajo adicional. x % 10
tiene un sesgo similar y no es tan barato de calcular. (la salida asm de gcc es equivalente a x - 10*(x/10)
, es decir, una multiplicación y resta adicionales en la parte superior de la división usando un inverso multiplicativo modular.) Además, el bit más bajo de xorshift128 + no es de alta calidad , por lo que es mejor dividir para tomar entropía de bits altos ( para calidad y velocidad) que el módulo para tomar entropía de bits bajos.
Sin embargo, podemos usar más de la entropía en cada uint16_t mirando los dígitos decimales bajos, como la digit()
función de @ Nominal . Para obtener el máximo rendimiento, decidí tomar los 3 dígitos decimales bajos y x/6554
, para guardar un PMULLW y PSUBW (y probablemente algunos MOVDQA) frente a la opción de mayor calidad de tomar los 4 dígitos decimales bajos. x / 6554 se ve ligeramente afectado por los 3 dígitos decimales bajos, por lo que existe cierta correlación entre los dígitos del mismo elemento (separación de 8 o 16 dígitos en la salida ASCII, dependiendo del ancho del vector).
Creo que gcc se divide por 100 y por 1000, en lugar de una cadena más larga que se divide sucesivamente por 10, por lo que probablemente no esté acortando significativamente la longitud de la cadena de dependencia no transportada en bucle que produce 4 resultados de cada salida PRNG. port0 (multiplicación y cambio de vectores) es el cuello de botella debido a las inversas multiplicativas modulares y los cambios en xorshift +, por lo que definitivamente es útil guardar una multiplicación de vectores.
xorshift + es tan rápido que incluso usar solo ~ 3.3 bits de aleatoriedad de cada 16 (es decir, 20% de eficiencia) no es mucho más lento que dividirlo en varios dígitos decimales. Solo aproximamos la distribución uniforme, porque esta respuesta se centra en la velocidad siempre que la calidad no sea tan mala.
Cualquier tipo de comportamiento condicional que mantenga un número variable de elementos requeriría mucho más trabajo. (Pero tal vez podría hacerse de manera algo eficiente usando las técnicas de empaquetado a la izquierda SIMD . Sin embargo, eso se vuelve menos eficiente para tamaños de elementos pequeños; las tablas de búsqueda de máscara aleatoria gigante no son viables, y no hay una mezcla aleatoria de cruce de carriles AVX2 con menos de 32- elementos de bit. Una versión de 128b PSHUFB aún podría generar una máscara sobre la marcha con BMI2 PEXT / PDEP, como puede hacerlo con AVX2 con elementos más grandes , pero es complicado porque un entero de 64 bits solo contiene 8 bytes. en esa respuesta tiene algún código que podría funcionar para conteos de elementos más altos).
Si la latencia del RNG es un cuello de botella, podríamos ir aún más rápido ejecutando dos vectores de generadores en paralelo, alternando cuál usamos. El compilador aún puede mantener fácilmente todo en registros en un bucle desenrollado, y eso permite que las dos cadenas de dependencia se ejecuten en paralelo.
En la versión actual, cortando la salida del PRNG, realmente tenemos un cuello de botella en el rendimiento del puerto 0, no en la latencia PRNG, por lo que no hay necesidad de eso.
El código: versión AVX2
Versión completa con más comentarios sobre el explorador del compilador Godbolt .
No muy ordenado, lo siento, tengo que dormir y quiero publicar esto.
Para obtener la versión SSE2, s/_mm256/_mm
, s/256/128/
, s/v16u/v8u/
, y el cambio vector_size(32)
a 16. También cambiar el incremento de nueva línea de 4 x 16 a 4 * 8. (Como dije, el código es desordenado y no está bien configurado para compilar dos versiones. Originalmente no planeaba hacer una versión AVX2, pero realmente quería probar en una CPU Haswell a la que tenía acceso).
#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);
}
}
Compile con gcc, clang o ICC (o con suerte cualquier otro compilador que comprenda el dialecto GNU C de C99 y los intrínsecos de Intel). Las extensiones de vector GNU C son muy convenientes para que el compilador genere los números mágicos para la división / módulo utilizando inversos multiplicativos modulares, y ocasionalmente __attribute__
s son útiles.
Esto podría escribirse de forma portátil, pero tomaría más código.
Notas de rendimiento:
La tienda superpuesta para insertar nuevas líneas tiene una sobrecarga considerable para decidir dónde colocarla (predicciones erróneas de sucursales y cuellos de botella frontend en Core2), pero la tienda en sí no tiene ningún impacto en el rendimiento. Al comentar solo esa instrucción de tienda en el compilador del compilador (dejando todas las ramificaciones iguales), el rendimiento en Core2 no cambió por completo, y las ejecuciones repetidas dieron el mismo tiempo a +/- menos del 1%. Así que concluyo que el búfer / caché de la tienda lo maneja bien.
Aún así, usar algún tipo de ventana giratoria ascii_digitspace
con un elemento que tenga una nueva línea podría ser aún más rápido, si desenrollamos lo suficiente como para que desaparezcan los contadores / ramificaciones.
Escribir en / dev / null es básicamente un no-op, por lo que el búfer probablemente se mantiene caliente en la caché L2 (256 kB por núcleo en Haswell). Se espera una aceleración perfecta de 128b a 256b: no hay instrucciones adicionales y todo (incluidas las tiendas) ocurre con el doble de ancho. Sin embargo, la rama de inserción de nueva línea se toma el doble de veces. Desafortunadamente, no tuve tiempo en mi configuración de Haswell cygwin con esa parte #ifdef
editada.
2.5GHz * 32B / 13.7GB / s = 5.84 ciclos por AVX2-store en Haswell. Eso es bastante bueno, pero podría ser más rápido. Quizás haya algo de sobrecarga en las llamadas al sistema cygwin de lo que pensaba. No intenté comentarlos en la salida asm del compilador (lo que garantizaría que nada se optimice).
La memoria caché L1 puede mantener una tienda de 32B por reloj, y L2 no tiene un ancho de banda mucho menor (sin embargo, una latencia más alta).
Cuando miré a IACA hace algunas versiones (sin la ramificación de las nuevas líneas, pero solo obtenía un vector ASCII por vector RNG), estaba prediciendo algo así como una tienda de vectores 32B por 4 o 5 relojes.
Esperaba obtener una mayor velocidad al extraer más datos de cada resultado de RNG, basándome en mirar el asm, considerando las guías de Agner Fog y otros recursos de optimización para los que he agregado enlaces en el wiki de etiquetas SO x86 .
Probablemente sería significativamente más rápido en Skylake , donde la multiplicación y el desplazamiento de enteros vectoriales pueden ejecutarse en el doble de puertos (p0 / p1) en comparación con Haswell (solo p0). xorshift y la extracción de dígitos utilizan muchos cambios y multiplicaciones. ( Actualización: Skylake lo ejecuta a 3.02 IPC, dándonos 3.77 ciclos por tienda AVX2 de 32 bytes , cronometrado a 0.030s por iteración de 1GB, escribiendo /dev/null
en Linux 4.15 en i7-6700k a 3.9GHz.
No requiere el modo de 64 bits para funcionar bien . La versión SSE2 es igual de rápida cuando se compila -m32
, porque no necesita muchos registros de vectores, y toda la matemática de 64 bits se realiza en vectores, no en registros de propósito general.
En realidad, es un poco más rápido en modo de 32 bits en Core2, porque la macro fusión de comparación / ramificación solo funciona en modo de 32 bits, por lo que hay menos uops para el núcleo fuera de orden (18.3s (1.85 instrucciones por reloj) vs 16.9s (2.0 IPC)). El tamaño de código más pequeño por no tener prefijos REX también ayuda a los decodificadores de Core2.
Además, algunos movimientos de vector reg-reg se reemplazan con cargas, ya que ya no se fijan todas las constantes en los registros vectoriales. Dado que el rendimiento de carga del caché L1 no es un cuello de botella, esto realmente ayuda. (por ejemplo, multiplicar por un vector constante de set1(10)
: movdqa xmm0, xmm10
/ se pmullw xmm0, xmm1
convierte en movdqa xmm0, [constant]
/ pmullw xmm0, xmm1
.) Dado que MOVDQA reg-reg requiere un puerto ALU, compite con el trabajo real que se realiza, pero una carga MOVDQA solo compite por el ancho de banda de decodificación front-end. (Tener una dirección de 4 bytes dentro de muchas instrucciones cancela gran parte de la ganancia al guardar los prefijos REX.
No me sorprendería si salvando ALU MOVDQA uops es de donde provienen las ganancias reales, ya que la interfaz debería mantenerse bastante bien con el promedio de 2.0 IPC.
Todas estas diferencias desaparecen en Haswell, donde todo debería ejecutarse desde la memoria caché decodificada-uop, si no el búfer de bucle invertido. La macro fusión de rama ALU + funciona en ambos modos desde Nehalem.