Por que há um grande impacto no desempenho ao fazer um loop sobre uma matriz com 240 ou mais elementos?

230

Ao executar um loop de soma em uma matriz no Rust, notei uma enorme queda de desempenho quando CAPACITY> = 240. CAPACITY= 239 é cerca de 80 vezes mais rápido.

Existe otimização de compilação especial que Rust está fazendo para matrizes "curtas"?

Compilado com rustc -C opt-level=3.

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}
Guy Korland
fonte
4
Talvez com 240 você esteja transbordando uma linha de cache da CPU? Se for esse o caso, seus resultados seriam muito específicos da CPU.
Rodrigo
11
Reproduzido aqui . Agora, acho que isso tem algo a ver com o desenrolar do loop.
Rodrigo

Respostas:

355

Resumo : abaixo de 240, o LLVM desenrola completamente o loop interno e isso permite que ele perceba que ele pode otimizar o loop de repetição, quebrando sua referência.



Você encontrou um limite mágico acima do qual o LLVM para de executar determinadas otimizações . O limite é 8 bytes * 240 = 1920 bytes (sua matriz é uma matriz de usizes, portanto, o comprimento é multiplicado por 8 bytes, assumindo a CPU x86-64). Nesse benchmark, uma otimização específica - realizada apenas para o comprimento 239 - é responsável pela enorme diferença de velocidade. Mas vamos começar devagar:

(Todo o código nesta resposta é compilado -C opt-level=3)

pub fn foo() -> usize {
    let arr = [0; 240];
    let mut s = 0;
    for i in 0..arr.len() {
        s += arr[i];
    }
    s
}

Esse código simples produzirá aproximadamente o conjunto que se esperaria: um loop adicionando elementos. No entanto, se você alterar 240para 239, o conjunto emitido difere bastante. Veja no Godbolt Compiler Explorer . Aqui está uma pequena parte da montagem:

movdqa  xmm1, xmmword ptr [rsp + 32]
movdqa  xmm0, xmmword ptr [rsp + 48]
paddq   xmm1, xmmword ptr [rsp]
paddq   xmm0, xmmword ptr [rsp + 16]
paddq   xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq   xmm0, xmmword ptr [rsp + 1840]
paddq   xmm1, xmmword ptr [rsp + 1856]
paddq   xmm0, xmmword ptr [rsp + 1872]
paddq   xmm0, xmm1
pshufd  xmm1, xmm0, 78
paddq   xmm1, xmm0

É o que se chama desenrolamento de loop : o LLVM cola o corpo do loop por um bocado de tempo para evitar a execução de todas essas "instruções de gerenciamento de loop", ou seja, aumentando a variável do loop, verifique se o loop terminou e o salto para o início do loop. .

Caso você esteja se perguntando: as paddqinstruções e similares são instruções SIMD que permitem resumir vários valores em paralelo. Além disso, dois registros SIMD de 16 bytes ( xmm0e xmm1) são usados ​​em paralelo para que o paralelismo no nível da instrução da CPU possa executar basicamente duas dessas instruções ao mesmo tempo. Afinal, eles são independentes um do outro. No final, os dois registros são somados e, em seguida, somados horizontalmente até o resultado escalar.

As modernas CPUs x86 convencionais (não Atom de baixa potência) podem realmente realizar 2 cargas vetoriais por clock quando atingem o cache L1d, e a paddqtaxa de transferência também é de pelo menos 2 por clock, com latência de 1 ciclo na maioria das CPUs. Consulte https://agner.org/optimize/ e também esta seção de perguntas e respostas sobre vários acumuladores para ocultar a latência (do FP FMA para um produto escalar) e gargalo na taxa de transferência.

O LLVM desenrola pequenos loops alguns quando não está totalmente desenrolando e ainda usa vários acumuladores. Geralmente, gargalos de largura de banda de front-end e latência de back-end não são um grande problema para os loops gerados por LLVM, mesmo sem o desenrolamento completo.


Mas o desenrolar do loop não é responsável por uma diferença de desempenho do fator 80! Pelo menos não se desenrola sozinho. Vamos dar uma olhada no código de benchmarking real, que coloca um loop dentro de outro:

const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;

pub fn foo() -> usize {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }

    let mut sum = 0;
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }

    sum
}

( No Godbolt Compiler Explorer )

A montagem CAPACITY = 240parece normal: dois loops aninhados. (No início da função, há bastante código apenas para inicializar, o que ignoraremos.) Para 239, no entanto, parece muito diferente! Vemos que o loop de inicialização e o loop interno foram desenrolados: até agora, o esperado.

A diferença importante é que, para o 239, o LLVM conseguiu descobrir que o resultado do loop interno não depende do loop externo! Como conseqüência, o LLVM emite código que basicamente executa apenas o loop interno (calculando a soma) e depois simula o loop externo adicionando sumvárias vezes!

Primeiro, vemos quase a mesma montagem acima (a montagem que representa o loop interno). Depois vemos isso (comentei para explicar a assembléia; os comentários com *são especialmente importantes):

        ; at the start of the function, `rbx` was set to 0

        movq    rax, xmm1     ; result of SIMD summing up stored in `rax`
        add     rax, 711      ; add up missing terms from loop unrolling
        mov     ecx, 500000   ; * init loop variable outer loop
.LBB0_1:
        add     rbx, rax      ; * rbx += rax
        add     rcx, -1       ; * decrement loop variable
        jne     .LBB0_1       ; * if loop variable != 0 jump to LBB0_1
        mov     rax, rbx      ; move rbx (the sum) back to rax
        ; two unimportant instructions omitted
        ret                   ; the return value is stored in `rax`

Como você pode ver aqui, o resultado do loop interno é obtido, somado quantas vezes o loop externo seria executado e retornado. O LLVM só pode executar essa otimização porque entendeu que o loop interno é independente do loop externo.

Isso significa que o tempo de execução muda de CAPACITY * IN_LOOPSparaCAPACITY + IN_LOOPS . E isso é responsável pela enorme diferença de desempenho.


Uma observação adicional: você pode fazer algo sobre isso? Na verdade não. O LLVM precisa ter limites mágicos, pois sem eles as otimizações do LLVM podem levar uma eternidade para serem concluídas em determinado código. Mas também podemos concordar que esse código era altamente artificial. Na prática, duvido que uma diferença tão grande ocorra. A diferença devido ao desenrolar do loop completo geralmente não é o fator 2 nesses casos. Portanto, não precisa se preocupar com casos de uso reais.

Como uma última observação sobre o código Rust idiomático: arr.iter().sum()é uma maneira melhor de resumir todos os elementos de uma matriz. E alterar isso no segundo exemplo não leva a diferenças notáveis ​​na montagem emitida. Você deve usar versões curtas e idiomáticas, a menos que tenha medido que isso prejudica o desempenho.

Lukas Kalbertodt
fonte
2
@ lukas-kalbertodt obrigado pela ótima resposta! agora também entendo por que o código original atualizado sumdiretamente em um local não sestava sendo executado muito mais lentamente. for i in 0..arr.len() { sum += arr[i]; }
Guy Korland 12/08/19
4
@LukasKalbertodt Algo mais está acontecendo no LLVM, ativando o AVX2, não deve fazer muita diferença. Repro'd de ferrugem também
Mgetz
4
@Mgetz Interesting! Mas não me parece muito louco tornar esse limite dependente das instruções SIMD disponíveis, pois isso determina o número de instruções em um loop completamente desenrolado. Mas, infelizmente, não posso dizer com certeza. Seria bom ter um LLVM dev respondendo a isso.
Lukas Kalbertodt 12/08/19
7
Por que o compilador ou o LLVM não percebem que todo o cálculo pode ser feito em tempo de compilação? Eu esperava que o resultado do loop fosse codificado. Ou é o uso de Instantimpedir isso?
Nome não criativo
4
@ Joseph Garvin: Eu suponho que é porque o desenrolar total acontece para permitir que o passe de otimização posterior veja isso. Lembre-se de que otimizar compiladores ainda se preocupa em compilar rapidamente, além de tornar eficientes asm, portanto eles precisam limitar a pior complexidade possível de qualquer análise que fizerem, para que não demore horas / dias para compilar algum código-fonte desagradável com loops complicados . Mas sim, isso obviamente é uma otimização perdida para tamanho> = 240. Gostaria de saber se não otimizar loops ausentes dentro dos loops é intencional para evitar a quebra de parâmetros simples? Provavelmente não, mas talvez.
Peter Cordes
30

Além da resposta de Lukas, se você quiser usar um iterador, tente o seguinte:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

Obrigado a Chris Morgan pela sugestão sobre o padrão de alcance.

A montagem otimizada é muito boa:

example::bar:
        movabs  rax, 14340000000
        ret
mja
fonte
3
Ou melhor ainda, (0..CAPACITY).sum::<usize>() * IN_LOOPSque produz o mesmo resultado.
22619 Chris Morgan
11
Na verdade, eu explicaria que a montagem não está realmente fazendo o cálculo, mas o LLVM pré-computou a resposta neste caso.
Josep
Estou meio surpreso que rustcesteja perdendo a oportunidade de fazer essa redução de força. Nesse contexto específico, porém, isso parece ser um loop de tempo e você deseja deliberadamente que não seja otimizado. O ponto principal é repetir o cálculo esse número de vezes do zero e dividir pelo número de repetições. Em C, o idioma (não oficial) para isso é declarar o contador de loop como volatile, por exemplo, o contador BogoMIPS no kernel do Linux. Existe uma maneira de conseguir isso no Rust? Pode haver, mas eu não sei. Ligar para um externo fnpode ajudar.
Davislor
1
@ Davislor: volatileforça a memória a estar sincronizada. A aplicação ao contador de loops força apenas o recarregamento / armazenamento real do valor do contador de loops. Não afeta diretamente o corpo do loop. É por isso que uma maneira melhor de usá-lo é normalmente atribuir o resultado realmente importante volatile int sinkou algo após o loop (se houver uma dependência transportada por loop) ou toda iteração, para permitir que o compilador otimize o contador de loop da forma que desejar, mas forçá-lo para materializar o resultado desejado em um registro para que ele possa armazená-lo.
Peter Cordes
1
@ Davidislor: Eu acho que o Rust possui a sintaxe inline asm, algo como o GNU C. Você pode usar o inline asm para forçar o compilador a materializar um valor em um registro sem forçá-lo a armazená-lo. Usar isso no resultado de cada iteração de loop pode impedir a otimização. (Mas também da vetorização automática, se você não for cuidadoso). por exemplo, "Escape" e "Clobber" equivalentes no MSVC explicam 2 macros (enquanto perguntam como portá-los para o MSVC, o que não é realmente possível) e links para a palestra de Chandler Carruth, onde ele mostra seu uso.
Peter Cordes