Se você não precisa de aleatoriedade de qualidade muito alta e a distribuição quase uniforme é boa o suficiente, você pode ir muito rápido, especialmente em uma CPU moderna com vetores inteiros SIMD eficientes como x86 com SSE2 ou AVX2.
É a resposta do @ NominalAnimal, já que ambos tínhamos a mesma idéia, mas vetorizamos manualmente o x86. (E com números aleatórios de pior qualidade, mas provavelmente bons o suficiente para muitos casos de uso.) Isso é executado 15 ou 30 vezes mais rápido que o código do @ Nominal, a ~ 13 GB / s de saída ASCII em um Intel Haswell de 2,5 GHz CPU com AVX2. Isso ainda é menos do que a largura de banda máxima teórica da memória principal (o DDR3-1600 de canal duplo é de cerca de 25,6 GB / s), mas eu estava escrevendo no / dev / null, então na verdade é apenas reescrevendo um buffer que permanece quente no cache. O Skylake deve executar esse mesmo código significativamente mais rápido que o Haswell (veja o final desta resposta).
Supondo que você realmente gargalo na E / S para o disco ou canalizando isso em algum lugar, uma implementação rápida significa que sua CPU nem precisa ter um clock mais alto do que ocioso. Ele usa muito menos energia total para produzir o resultado. (Vida útil da bateria / aquecimento / aquecimento global.)
Isso é tão rápido que você provavelmente não deseja gravá-lo em disco. Apenas gere novamente conforme necessário (da mesma semente, se desejar os mesmos dados novamente). Mesmo se você quiser alimentá-lo com um processo multiencadeado que possa usar todas as CPUs, executar isso para canalizar os dados para ele deixará quente no cache L3 (e cache L2 no núcleo que o escreveu) e use muito pouco tempo de CPU. (Porém, observe que o encanamento acrescenta muita sobrecarga em relação à gravação /dev/null
. Em um Skylake i7-6700k, o encanamento para wc -c
ou outro programa que apenas lê + descarta sua entrada, é cerca de 8x mais lento do que gravar/dev/null
e usa apenas 70% de um CPU, mas ainda são 4,0 GB / s em uma CPU de 3,9 GHz.
Gerá-lo é mais rápido do que relê-lo, mesmo a partir de um SSD rápido conectado ao PCIe, mas o IDK é mais eficiente em termos de energia (o multiplicador de vetores inteiros fica muito ocupado e provavelmente consome muita energia, juntamente com outros AVX2 ALUs vetoriais 256b). OTOH, não sei quanto tempo de CPU lendo no disco levaria para algo que estava maximizando todos os núcleos que processavam essa entrada. Eu acho que uma alternância de contexto para gerar novamente em blocos de 128k pode ser competitiva com a execução de código do sistema de arquivos / pagecache e a alocação de páginas para ler dados do disco. Claro, se já está quente no pagecache, é basicamente um erro. OTOH, já escrevemos sobre o mais rápido que o memcpy! (que precisa dividir a largura de banda da memória principal entre leitura e gravação). Observe também que escrever na memória que 'rep movsb
(memcpy e memset otimizados em microcódigo, que evitam a RFO, desde a implementação de Andy Glew no P6 (Pentium Pro )).
Até agora, isso é apenas uma prova de conceito, e o tratamento da nova linha está apenas aproximadamente correto. Está errado nas extremidades de um buffer de potência de 2. Com mais tempo de desenvolvimento. Estou confiante de que poderia encontrar uma maneira mais eficiente de inserir novas linhas também exatamente corretas, com sobrecarga pelo menos tão baixa quanto essa (em comparação com a saída de apenas espaços). Eu acho que isso é algo como 10 a 20%. Estou interessado apenas em saber o quão rápido poderíamos fazer essa execução, não em ter uma versão aprimorada, então deixarei essa parte como um exercício para o leitor, com comentários descrevendo algumas idéias.
Em um Haswell i5 em seu turbo máximo de 2,5 GHz, com RAM DDR3-1600MHz, o tempo produziu 100 GiB, mas diminuiu. (Cronometrado no cygwin64 no Win10 com o gcc5.4 -O3 -march=native
, omitido -funroll-loops
porque eu estava tendo bastante dificuldade em obter execuções decentes neste laptop emprestado. Deveria ter inicializado o Linux em um USB).
escrevendo para / dev / null, a menos que seja especificado de outra forma.
- James Hollis: (não testado)
- Versão fwrite do nominal: ~ 2.21s
- this (SSE2): ~ 0.142s (tempos não escalados = real = 14.232s, usuário = 13.999s, sys = 0.187s).
- this (AVX-128): ~ 0.140s
- this (AVX2): ~ 0.073s (sem escala: real = 0m7.291s, usuário = 0m7.125s, sys = 0m0.155s).
- essa tubulação cygwin (AVX2) para
wc -c
, com tamanho de buffer de 128 kB: 0,32s com CPU a 2,38 GHz (máximo de turbo de núcleo duplo). (tempos não dimensionados: real = 32.466s usuário = 11.468s sys = 41.092s, incluindo isso e wc
). Porém, apenas metade dos dados foi realmente copiada, porque meu programa bobo pressupõe que a gravação faça o buffer completo, mesmo que esse não seja o caso, e o cygwin write () faça apenas 64k por chamada em um canal.
Portanto, com o SSE2, isso é cerca de 15 vezes mais rápido que o código escalar do @Nominal Animal. Com o AVX2, é cerca de 30 vezes mais rápido. Eu não tentei uma versão do código do Nominal que apenas usa em write()
vez de fwrite()
, mas presumivelmente para buffers grandes, o stdio geralmente fica fora do caminho. Se estiver copiando os dados, isso representaria muita desaceleração.
Tempos para produzir 1 GB de dados em um Core2Duo E6600 (caches L2 compartilhados de 2,4 GHz, L1 privado 32kiB, L4 compartilhados com 4MiB), DDR2-533MHz no Linux 4.2 de 64 bits (Ubuntu 15.10). Ainda usando um tamanho de buffer de 128 kB para write (), ainda não exploramos essa dimensão.
escrevendo para / dev / null, a menos que seja especificado de outra forma.
- (SSE2) isso com manipulação de nova linha e 4 vetores de dígitos de cada vetor de bytes aleatórios: 0,183s (tempo de execução de 100GiB em 18,3s, mas resultados semelhantes para execuções de 1GiB). 1,85 instruções por ciclo.
- (SSE2), canalizando para
wc -c
: 0.593s (sem escala: real = 59.266s usuário = 20.148s sys = 1m6.548s, incluindo o tempo de CPU do wc). O mesmo número de chamadas do sistema write () que o cygwin, mas na verdade canaliza todos os dados porque o Linux lida com todos os 128k de write () em um pipe.
- A
fwrite()
versão do NominalAnimal (gcc5.2 -O3 -march=native
) é executada com ./decdig 100 $((1024*1024*1024/200)) > /dev/null
: 3.19s +/- 0.1%, com 1,40 instrução por ciclo. -funroll-loops fez talvez uma pequena diferença. clang-3.8 -O3 -march=native
: 3,42s +/- 0,1%
- Canal nominal
fwrite
para wc -c
: real = 3.980s usuário = 3.176s sys = 2.080s
- A versão linha de cada vez de James Hollis (
clang++-3.8 -O3 -march=native
): 22.885s +/- 0,07%, com 0,84 instruções por ciclo. (g ++ 5.2 foi um pouco mais lento: 22,98s). Escrever apenas uma linha de cada vez provavelmente doeu significativamente.
- Stéphane Chazelas
tr < /dev/urandom | ...
: real = 41.430s usuário = 26.832s sys = 40.120s. tr
estava obtendo todo o núcleo da CPU para si próprio na maioria das vezes, gastando quase todo o tempo no driver do kernel, gerando bytes aleatórios e copiando-os para um pipe. O outro núcleo nessa máquina de núcleo duplo estava executando o restante do pipeline.
time LC_ALL=C head -c512M </dev/urandom >/dev/null
: ou seja, apenas lendo tanta aleatoriedade sem tubulação: real = 35.018s usuário = 0.036s sys = 34.940s.
- O programa perl do Phúc de Phúc (perl v5.20.2 do Ubuntu15.10)
LANG=en_CA.UTF-8
:: real = 4m32.634s usuário = 4m3.288s sys = 0m29.364.
LC_ALL=C LANG=C
: real = 4m18.637s usuário = 3m50.324s sys = 0m29.356s. Ainda muito lento.
- (SSE2) isso sem manipulação de nova linha e 3 ou 4 vetores de dígitos de cada vetor de bytes aleatórios (quase exatamente a mesma velocidade: o
dig3 = v%10
passo é o ponto de equilíbrio neste HW): 0,166s (instruções de 1,82 por ciclo) . Esse é basicamente o limite mais baixo para o que podemos chegar perto com o manuseio de nova linha perfeitamente eficiente.
- (SSE2) Versão antiga sem manipulação de nova linha, mas obtendo apenas um dígito por elemento uint16_t usando
v%10
, 0.222 segundos +/- 0.4%, 2.12 instruções por ciclo. (Compilado com gcc5.2 -march=native -O3 -funroll-loops
,. Desenrola os loops para ajudar nesse código neste hardware. Não o use cegamente, especialmente para programas grandes).
- (SSE2) Versão antiga disso, gravando em um arquivo (em um RAID10f2 de 3 discos rígidos magnéticos rápidos, pouco otimizados para gravações): ~ 4 segundos. Poderia ir mais rápido, ajustando as configurações do buffer de E / S do kernel para permitir muito mais dados sujos antes dos blocos write (). O tempo do "Sistema" ainda é ~ 1,0 segundos, muito maior que o tempo do "usuário". Nesse sistema antigo, com RAM DDR2-533 lenta, leva cerca de 4x mais tempo para o kernel memorizar os dados no pagecache e executar as funções XFS do que para o meu loop reescrevê-lo no local em um buffer que permanece quente em cache.
Como isso é feito
Um PRNG rápido é obviamente essencial. O xorshift128 + pode ser vetorizado, para que você tenha dois ou quatro geradores de 64 bits em paralelo, em elementos de um vetor SIMD. Cada etapa produz um vetor completo de bytes aleatórios. ( Implementação de 256b AVX2 aqui com intrínsecas da Intel ). Optei pela escolha nominal de xorshift * da Nominal, porque a multiplicação de inteiros vetoriais de 64 bits só é possível no SSE2 / AVX2 com técnicas de precisão estendida .
Dado um vetor de bytes aleatórios, podemos dividir cada elemento de 16 bits em vários dígitos decimais. Produzimos vários vetores de elementos de 16 bits, cada um com um dígito ASCII + espaço ASCII . Armazenamos isso diretamente em nosso buffer de saída.
Minha versão original costumava x / 6554
obter um dígito aleatório de cada elemento uint16_t de um vetor. É sempre entre 0 e 9, inclusive. É tendencioso 9
, porque (2^16 -1 ) / 6554
é apenas 9.99923. (6554 = ceil ((2 ^ 16-1) / 10), que garante que o quociente seja sempre <10.)
x/6554
pode ser calculado com uma multiplicação por uma constante "mágica" ( o ponto fixo recíproco ) e um deslocamento à direita do resultado da metade alta. Este é o melhor caso de divisão por uma constante; alguns divisores realizam mais operações e a divisão assinada exige trabalho extra. x % 10
tem um viés semelhante e não é tão barato de calcular. (a saída asm do gcc é equivalente a x - 10*(x/10)
, ou seja, uma multiplicação e subtração extras no topo da divisão usando um inverso multiplicativo modular.) Além disso, o bit mais baixo do xorshift128 + não é de alta qualidade , portanto é melhor dividir para obter entropia de bits altos ( qualidade e velocidade) do que o módulo para obter entropia de bits baixos.
No entanto, podemos usar mais da entropia em cada uint16_t observando os dígitos decimais baixos, como a digit()
função @ Nominal . Para obter o desempenho máximo, decidi x/6554
usar os 3 dígitos decimais baixos e , para salvar um PMULLW e PSUBW (e provavelmente algum MOVDQA) versus a opção de qualidade mais alta, usar os 4 dígitos decimais baixos. x / 6554 é levemente afetado pelos três dígitos decimais baixos, portanto, existe alguma correlação entre os dígitos do mesmo elemento (separação de 8 ou 16 dígitos na saída ASCII, dependendo da largura do vetor).
Eu acho que o gcc está dividindo por 100 e 1000, em vez de uma cadeia mais longa que sucessivamente se divide por 10, portanto, provavelmente não está diminuindo significativamente o comprimento da cadeia de dependência não transportada por loop que produz 4 resultados de cada saída PRNG. port0 (multiplicação e deslocamento de vetores) é o gargalo por causa dos inversos multiplicativos modulares e as mudanças no xorshift +, portanto é definitivamente útil salvar uma multiplicação de vetores.
O xorshift + é tão rápido que mesmo usando apenas ~ 3,3 bits de aleatoriedade a cada 16 (ou seja, 20% de eficiência) não é muito mais lento do que dividi-lo em vários dígitos decimais. Apenas aproximamos a distribuição uniforme, porque essa resposta é focada na velocidade, desde que a qualidade não seja muito ruim.
Qualquer tipo de comportamento condicional que mantenha um número variável de elementos exigiria muito mais trabalho. (Mas talvez ainda possa ser feito de maneira eficiente usando as técnicas de empacotamento à esquerda do SIMD . No entanto, isso fica menos eficiente para tamanhos de elementos pequenos; as tabelas de pesquisa de máscara aleatória gigantes não são viáveis e não há embaralhamento de faixa de AVX2 com menos de 32 Uma versão PSHUFB de 128b ainda pode gerar uma máscara em tempo real com o BMI2 PEXT / PDEP, como você pode no AVX2 com elementos maiores , mas é complicado porque um número inteiro de 64 bits contém apenas 8 bytes. nessa resposta tem algum código que pode funcionar para contagens mais altas de elementos.)
Se a latência do RNG for um gargalo, poderíamos ir ainda mais rápido executando dois vetores de geradores em paralelo, alternando qual deles usamos. O compilador ainda pode facilmente manter tudo em registros em um loop desenrolado, e isso permite que as duas cadeias de dependência sejam executadas em paralelo.
Na versão atual, detalhando a saída do PRNG, na verdade, gargalos na taxa de transferência da porta 0, não na latência do PRNG, portanto, não há necessidade disso.
O código: versão AVX2
Versão completa com mais comentários sobre o Godbolt compiler explorer .
Não é muito arrumado, desculpe, eu tenho que dormir e quero postar isso.
Para obter a versão SSE2, s/_mm256/_mm
, s/256/128/
, s/v16u/v8u/
, e mudança vector_size(32)
para 16. Também alterar o incremento de nova linha de 4 * 16-4 * 8. (Como eu disse, o código é confuso e não está bem configurado para compilar duas versões. Não planejava originalmente fazer uma versão AVX2, mas eu realmente queria testar em uma CPU Haswell a que tinha acesso.)
#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 com gcc, clang ou ICC (ou, esperançosamente, qualquer outro compilador que entenda o dialeto GNU C de C99 e as intrínsecas da Intel). As extensões vetoriais GNU C são altamente convenientes para que o compilador gere os números mágicos para divisão / módulo usando inversos multiplicativos modulares, e __attribute__
s ocasionais são úteis.
Isso pode ser escrito de forma portável, mas seria necessário mais código.
Notas de desempenho:
A loja sobreposta para inserir novas linhas possui uma sobrecarga significativa para decidir onde colocá-la (previsões incorretas de ramificação e gargalos de front-end no Core2), mas a própria loja não tem impacto no desempenho. Comentar apenas as instruções de armazenamento no asm do compilador (deixando todas as ramificações iguais) deixou o desempenho no Core2 completamente inalterado, com execuções repetidas dando o mesmo tempo a +/- menos de 1%. Portanto, concluo que o buffer / cache da loja lida com isso muito bem.
Ainda assim, usar algum tipo de janela rotativa ascii_digitspace
com um elemento com uma nova linha pode ser ainda mais rápido, se desenrolarmos o suficiente para que qualquer contador / ramificação desapareça.
Gravar em / dev / null é basicamente não operacional, portanto o buffer provavelmente permanece quente no cache L2 (256 kB por núcleo em Haswell). A aceleração perfeita de vetores 128b para 256b é esperada: não há instruções extras e tudo (incluindo as lojas) acontece com o dobro da largura. Porém, a ramificação de inserção de nova linha é obtida duas vezes mais. Infelizmente, não tive tempo na minha configuração de cywell do Haswell com essa parte #ifdef
editada.
2.5GHz * 32B / 13.7GB / s = 5.84 ciclos por loja AVX2 em Haswell. Isso é muito bom, mas poderia ser mais rápido. Talvez haja alguma sobrecarga nas chamadas do sistema cygwin do que eu pensava. Não tentei comentar isso na saída asm do compilador (o que garantiria que nada fosse otimizado).
O cache L1 pode sustentar um armazenamento de 32B por relógio, e o L2 não possui uma largura de banda muito menor (latência mais alta).
Quando examinei a IACA algumas versões atrás (sem a ramificação de novas linhas, mas apenas obtendo um vetor ASCII por vetor RNG), estava prevendo algo como um armazenamento de vetor 32B por 4 ou 5 relógios.
Eu esperava obter mais agilidade ao extrair mais dados de cada resultado RNG, com base em olhar para mim, considerando os guias de Agner Fog e outros recursos de otimização aos quais adicionei links no wiki de tags do SO x86 .)
Provavelmente seria significativamente mais rápido no Skylake , onde o número inteiro de vetores se multiplica e o deslocamento pode ser executado em duas vezes mais portas (p0 / p1) em comparação com Haswell (apenas p0). O xorshift e a extração de dígitos usam muitos turnos e multiplicam. ( Atualização: o Skylake o executa em 3,02 IPC, fornecendo-nos 3,77 ciclos por loja AVX2 de 32 bytes , com tempo de 0,030s por iteração de 1 GB, gravando /dev/null
no Linux 4.15 no i7-6700k a 3.9GHz.
Não requer o modo de 64 bits para funcionar bem . A versão SSE2 é tão rápida quando compilada -m32
, porque não precisa de muitos registros vetoriais, e toda a matemática de 64 bits é feita em vetores, não em registros de uso geral.
Na verdade, é um pouco mais rápido no modo de 32 bits no Core2, porque a macro-fusão de comparação / ramificação só funciona no modo de 32 bits, portanto há menos uops para o núcleo fora de ordem (18,3s (1,85 instruções por relógio) vs 16,9 s (2,0 IPC)). O tamanho do código menor, por não ter prefixos REX, também ajuda os decodificadores do Core2.
Além disso, alguns movimentos do vetor reg-reg são substituídos por cargas, já que nem todas as constantes se fixam mais no vetor regs. Como a taxa de transferência de carga do cache L1 não é um gargalo, isso realmente ajuda. (por exemplo, multiplicando por um vetor constante de set1(10)
: movdqa xmm0, xmm10
/ pmullw xmm0, xmm1
transforma-se em movdqa xmm0, [constant]
/ pmullw xmm0, xmm1
.) Como o MOVDQA reg-reg requer uma porta ALU, ele compete com o trabalho real que está sendo feito, mas uma carga MOVDQA compete apenas pela largura de banda da decodificação do front-end. (Ter um endereço de 4 bytes em muitas instruções cancela muito do ganho ao salvar os prefixos REX.
Eu não ficaria surpreso se salvar ALU MOVDQA for de onde vêm os ganhos reais, já que o front-end deve acompanhar muito bem a média de 2,0 IPC.
Todas essas diferenças desaparecem em Haswell, onde tudo deve ser executado a partir do cache decodificado, se não o buffer de loopback. A macro fusão de ramo ALU + funciona nos dois modos desde Nehalem.