regressão de desempenho std :: vector ao ativar o C ++ 11


235

Encontrei uma regressão interessante de desempenho em um pequeno trecho de C ++, quando habilito o C ++ 11:

#include <vector>

struct Item
{
  int a;
  int b;
};

int main()
{
  const std::size_t num_items = 10000000;
  std::vector<Item> container;
  container.reserve(num_items);
  for (std::size_t i = 0; i < num_items; ++i) {
    container.push_back(Item());
  }
  return 0;
}

Com g ++ (GCC) 4.8.2 20131219 (pré-lançamento) e C ++ 03, recebo:

milian:/tmp$ g++ -O3 main.cpp && perf stat -r 10 ./a.out

Performance counter stats for './a.out' (10 runs):

        35.206824 task-clock                #    0.988 CPUs utilized            ( +-  1.23% )
                4 context-switches          #    0.116 K/sec                    ( +-  4.38% )
                0 cpu-migrations            #    0.006 K/sec                    ( +- 66.67% )
              849 page-faults               #    0.024 M/sec                    ( +-  6.02% )
       95,693,808 cycles                    #    2.718 GHz                      ( +-  1.14% ) [49.72%]
  <not supported> stalled-cycles-frontend 
  <not supported> stalled-cycles-backend  
       95,282,359 instructions              #    1.00  insns per cycle          ( +-  0.65% ) [75.27%]
       30,104,021 branches                  #  855.062 M/sec                    ( +-  0.87% ) [77.46%]
            6,038 branch-misses             #    0.02% of all branches          ( +- 25.73% ) [75.53%]

      0.035648729 seconds time elapsed                                          ( +-  1.22% )

Com o C ++ 11 ativado, por outro lado, o desempenho diminui significativamente:

milian:/tmp$ g++ -std=c++11 -O3 main.cpp && perf stat -r 10 ./a.out

Performance counter stats for './a.out' (10 runs):

        86.485313 task-clock                #    0.994 CPUs utilized            ( +-  0.50% )
                9 context-switches          #    0.104 K/sec                    ( +-  1.66% )
                2 cpu-migrations            #    0.017 K/sec                    ( +- 26.76% )
              798 page-faults               #    0.009 M/sec                    ( +-  8.54% )
      237,982,690 cycles                    #    2.752 GHz                      ( +-  0.41% ) [51.32%]
  <not supported> stalled-cycles-frontend 
  <not supported> stalled-cycles-backend  
      135,730,319 instructions              #    0.57  insns per cycle          ( +-  0.32% ) [75.77%]
       30,880,156 branches                  #  357.057 M/sec                    ( +-  0.25% ) [75.76%]
            4,188 branch-misses             #    0.01% of all branches          ( +-  7.59% ) [74.08%]

    0.087016724 seconds time elapsed                                          ( +-  0.50% )

Alguém pode explicar isso? Até agora, minha experiência foi que o STL fica mais rápido ao ativar o C ++ 11, esp. graças a mover semântica.

EDIT: Como sugerido, o uso container.emplace_back();do desempenho é semelhante à versão C ++ 03. Como a versão C ++ 03 pode alcançar o mesmo push_back?

milian:/tmp$ g++ -std=c++11 -O3 main.cpp && perf stat -r 10 ./a.out

Performance counter stats for './a.out' (10 runs):

        36.229348 task-clock                #    0.988 CPUs utilized            ( +-  0.81% )
                4 context-switches          #    0.116 K/sec                    ( +-  3.17% )
                1 cpu-migrations            #    0.017 K/sec                    ( +- 36.85% )
              798 page-faults               #    0.022 M/sec                    ( +-  8.54% )
       94,488,818 cycles                    #    2.608 GHz                      ( +-  1.11% ) [50.44%]
  <not supported> stalled-cycles-frontend 
  <not supported> stalled-cycles-backend  
       94,851,411 instructions              #    1.00  insns per cycle          ( +-  0.98% ) [75.22%]
       30,468,562 branches                  #  840.991 M/sec                    ( +-  1.07% ) [76.71%]
            2,723 branch-misses             #    0.01% of all branches          ( +-  9.84% ) [74.81%]

   0.036678068 seconds time elapsed                                          ( +-  0.80% )

1
Se você compilar na montagem, poderá ver o que está acontecendo sob o capô. Veja também stackoverflow.com/questions/8021874/…
Cogwheel

8
O que acontece se você mudar push_back(Item())para emplace_back()na versão C ++ 11?
Cogwheel

8
Veja acima, que "corrige" a regressão. Ainda me pergunto por que push_back regride no desempenho entre C ++ 03 e C ++ 11.
milianw

1
@ milianw Acontece que eu estava compilando o programa errado. Ignore meus comentários.

2
Com o clang3.4, a versão do C ++ 11 é mais rápida, 0.047s vs 0.058 para a versão do C ++ 98
Pretoriano

Respostas:


247

Posso reproduzir seus resultados na minha máquina com as opções que você escreve em sua postagem.

No entanto, se eu também habilitar a otimização do tempo do link (eu também passo o -fltosinalizador para o gcc 4.7.2), os resultados são idênticos:

(Estou compilando seu código original, com container.push_back(Item());)

$ g++ -std=c++11 -O3 -flto regr.cpp && perf stat -r 10 ./a.out 

 Performance counter stats for './a.out' (10 runs):

         35.426793 task-clock                #    0.986 CPUs utilized            ( +-  1.75% )
                 4 context-switches          #    0.116 K/sec                    ( +-  5.69% )
                 0 CPU-migrations            #    0.006 K/sec                    ( +- 66.67% )
            19,801 page-faults               #    0.559 M/sec                  
        99,028,466 cycles                    #    2.795 GHz                      ( +-  1.89% ) [77.53%]
        50,721,061 stalled-cycles-frontend   #   51.22% frontend cycles idle     ( +-  3.74% ) [79.47%]
        25,585,331 stalled-cycles-backend    #   25.84% backend  cycles idle     ( +-  4.90% ) [73.07%]
       141,947,224 instructions              #    1.43  insns per cycle        
                                             #    0.36  stalled cycles per insn  ( +-  0.52% ) [88.72%]
        37,697,368 branches                  # 1064.092 M/sec                    ( +-  0.52% ) [88.75%]
            26,700 branch-misses             #    0.07% of all branches          ( +-  3.91% ) [83.64%]

       0.035943226 seconds time elapsed                                          ( +-  1.79% )



$ g++ -std=c++98 -O3 -flto regr.cpp && perf stat -r 10 ./a.out 

 Performance counter stats for './a.out' (10 runs):

         35.510495 task-clock                #    0.988 CPUs utilized            ( +-  2.54% )
                 4 context-switches          #    0.101 K/sec                    ( +-  7.41% )
                 0 CPU-migrations            #    0.003 K/sec                    ( +-100.00% )
            19,801 page-faults               #    0.558 M/sec                    ( +-  0.00% )
        98,463,570 cycles                    #    2.773 GHz                      ( +-  1.09% ) [77.71%]
        50,079,978 stalled-cycles-frontend   #   50.86% frontend cycles idle     ( +-  2.20% ) [79.41%]
        26,270,699 stalled-cycles-backend    #   26.68% backend  cycles idle     ( +-  8.91% ) [74.43%]
       141,427,211 instructions              #    1.44  insns per cycle        
                                             #    0.35  stalled cycles per insn  ( +-  0.23% ) [87.66%]
        37,366,375 branches                  # 1052.263 M/sec                    ( +-  0.48% ) [88.61%]
            26,621 branch-misses             #    0.07% of all branches          ( +-  5.28% ) [83.26%]

       0.035953916 seconds time elapsed  

Quanto aos motivos, é necessário observar o código de montagem gerado ( g++ -std=c++11 -O3 -S regr.cpp). No modo C ++ 11, o código gerado é significativamente mais confuso do que no modo C ++ 98, e a inserção de função
void std::vector<Item,std::allocator<Item>>::_M_emplace_back_aux<Item>(Item&&)
falha no modo C ++ 11 com o padrão inline-limit.

Esta falha na linha tem um efeito dominó. Não porque essa função está sendo chamada (nem sequer é chamada!), Mas porque temos que estar preparados: Se for chamada, a função argments ( Item.ae Item.b) já deve estar no lugar certo. Isso leva a um código bastante confuso.

Aqui está a parte relevante do código gerado para o caso em que a inclusão é bem - sucedida :

.L42:
    testq   %rbx, %rbx  # container$D13376$_M_impl$_M_finish
    je  .L3 #,
    movl    $0, (%rbx)  #, container$D13376$_M_impl$_M_finish_136->a
    movl    $0, 4(%rbx) #, container$D13376$_M_impl$_M_finish_136->b
.L3:
    addq    $8, %rbx    #, container$D13376$_M_impl$_M_finish
    subq    $1, %rbp    #, ivtmp.106
    je  .L41    #,
.L14:
    cmpq    %rbx, %rdx  # container$D13376$_M_impl$_M_finish, container$D13376$_M_impl$_M_end_of_storage
    jne .L42    #,

Este é um loop for agradável e compacto. Agora, vamos comparar isso com o caso inline com falha :

.L49:
    testq   %rax, %rax  # D.15772
    je  .L26    #,
    movq    16(%rsp), %rdx  # D.13379, D.13379
    movq    %rdx, (%rax)    # D.13379, *D.15772_60
.L26:
    addq    $8, %rax    #, tmp75
    subq    $1, %rbx    #, ivtmp.117
    movq    %rax, 40(%rsp)  # tmp75, container.D.13376._M_impl._M_finish
    je  .L48    #,
.L28:
    movq    40(%rsp), %rax  # container.D.13376._M_impl._M_finish, D.15772
    cmpq    48(%rsp), %rax  # container.D.13376._M_impl._M_end_of_storage, D.15772
    movl    $0, 16(%rsp)    #, D.13379.a
    movl    $0, 20(%rsp)    #, D.13379.b
    jne .L49    #,
    leaq    16(%rsp), %rsi  #,
    leaq    32(%rsp), %rdi  #,
    call    _ZNSt6vectorI4ItemSaIS0_EE19_M_emplace_back_auxIIS0_EEEvDpOT_   #

Esse código é confuso e há muito mais acontecendo no loop do que no caso anterior. Antes da função call(última linha mostrada), os argumentos devem ser colocados adequadamente:

leaq    16(%rsp), %rsi  #,
leaq    32(%rsp), %rdi  #,
call    _ZNSt6vectorI4ItemSaIS0_EE19_M_emplace_back_auxIIS0_EEEvDpOT_   #

Mesmo que isso nunca seja realmente executado, o loop organiza as coisas antes:

movl    $0, 16(%rsp)    #, D.13379.a
movl    $0, 20(%rsp)    #, D.13379.b

Isso leva ao código confuso. Se não houver nenhuma função callporque o inlining for bem-sucedido, temos apenas 2 instruções de movimento no loop e não há problemas com o %rsp(ponteiro da pilha). No entanto, se o inline falhar, temos 6 jogadas e mexemos muito com a %rsp.

Apenas para substanciar minha teoria (observe o -finline-limit), ambos no modo C ++ 11:

 $ g++ -std=c++11 -O3 -finline-limit=105 regr.cpp && perf stat -r 10 ./a.out

 Performance counter stats for './a.out' (10 runs):

         84.739057 task-clock                #    0.993 CPUs utilized            ( +-  1.34% )
                 8 context-switches          #    0.096 K/sec                    ( +-  2.22% )
                 1 CPU-migrations            #    0.009 K/sec                    ( +- 64.01% )
            19,801 page-faults               #    0.234 M/sec                  
       266,809,312 cycles                    #    3.149 GHz                      ( +-  0.58% ) [81.20%]
       206,804,948 stalled-cycles-frontend   #   77.51% frontend cycles idle     ( +-  0.91% ) [81.25%]
       129,078,683 stalled-cycles-backend    #   48.38% backend  cycles idle     ( +-  1.37% ) [69.49%]
       183,130,306 instructions              #    0.69  insns per cycle        
                                             #    1.13  stalled cycles per insn  ( +-  0.85% ) [85.35%]
        38,759,720 branches                  #  457.401 M/sec                    ( +-  0.29% ) [85.43%]
            24,527 branch-misses             #    0.06% of all branches          ( +-  2.66% ) [83.52%]

       0.085359326 seconds time elapsed                                          ( +-  1.31% )

 $ g++ -std=c++11 -O3 -finline-limit=106 regr.cpp && perf stat -r 10 ./a.out

 Performance counter stats for './a.out' (10 runs):

         37.790325 task-clock                #    0.990 CPUs utilized            ( +-  2.06% )
                 4 context-switches          #    0.098 K/sec                    ( +-  5.77% )
                 0 CPU-migrations            #    0.011 K/sec                    ( +- 55.28% )
            19,801 page-faults               #    0.524 M/sec                  
       104,699,973 cycles                    #    2.771 GHz                      ( +-  2.04% ) [78.91%]
        58,023,151 stalled-cycles-frontend   #   55.42% frontend cycles idle     ( +-  4.03% ) [78.88%]
        30,572,036 stalled-cycles-backend    #   29.20% backend  cycles idle     ( +-  5.31% ) [71.40%]
       140,669,773 instructions              #    1.34  insns per cycle        
                                             #    0.41  stalled cycles per insn  ( +-  1.40% ) [88.14%]
        38,117,067 branches                  # 1008.646 M/sec                    ( +-  0.65% ) [89.38%]
            27,519 branch-misses             #    0.07% of all branches          ( +-  4.01% ) [86.16%]

       0.038187580 seconds time elapsed                                          ( +-  2.05% )

De fato, se pedirmos ao compilador que tente um pouco mais difícil incorporar essa função, a diferença no desempenho desaparece.


Então, qual é a vantagem dessa história? As falhas de linha podem custar muito e você deve fazer uso total dos recursos do compilador: só posso recomendar a otimização do tempo do link. Isso deu um aumento significativo no desempenho dos meus programas (até 2,5x) e tudo que eu precisava fazer era passar a -fltobandeira. Isso é um bom negócio! ;)

No entanto, eu não recomendo lixeira seu código com a palavra-chave inline; deixe o compilador decidir o que fazer. (O otimizador pode tratar a palavra-chave embutida como espaço em branco de qualquer maneira.)


Ótima pergunta, +1!


3
NB: inlinenão tem nada a ver com função embutida; significa "inline definido" e não "inline this". Se você realmente deseja solicitar inlining, use __attribute__((always_inline))ou similar.
Jon Purdy

2
@ JonPurdy Não é bem assim, por exemplo, as funções de membro da classe estão implicitamente alinhadas. inlinetambém é uma solicitação ao compilador de que você gostaria que a função fosse incorporada e, por exemplo, o Intel C ++ Compiler usado para emitir avisos de desempenho, se não atender à sua solicitação. (Não verifiquei o icc recentemente, se ainda o faz.) Infelizmente, tenho visto pessoas alterando seu código inlinee esperando o milagre acontecer. Eu não usaria __attribute__((always_inline)); é provável que os desenvolvedores do compilador saibam melhor o que incorporar e o que não incorporar. (Apesar do contraexemplo aqui.)
Ali

1
@JonPurdy Por outro lado, se você definir uma função embutida que não é uma função membro de uma classe , não terá outra opção a não ser marcá-la embutida, caso contrário, você obterá vários erros de definição no vinculador. Se é isso que você quis dizer, tudo bem.
Ali

1
Sim, foi isso que eu quis dizer. O padrão diz "O inlineespecificador indica para a implementação que a substituição em linha do corpo da função no ponto de chamada deve ser preferida ao mecanismo usual de chamada de função". (§7.1.2.2) No entanto, as implementações não são necessárias para executar essa otimização, pois é em grande parte uma coincidência que as inlinefunções geralmente sejam boas candidatas a inlining. Portanto, é melhor ser explícito e usar um pragma do compilador.
Jon Purdy

3
@ JonPurdy Quanto ao primeiro semestre: Sim, foi isso que eu quis dizer com "O otimizador pode tratar a palavra-chave inline como espaço em branco de qualquer maneira". Quanto ao pragma do compilador, eu não usaria isso, deixaria à otimização do tempo do link incorporado ou não. Faz um bom trabalho; também resolveu automaticamente esse problema discutido aqui na resposta.
Ali
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.