Por que const não acelera o código C / C ++?


Alguns meses atrás, mencionei em um post que isso é um mito, como se o const ajude a habilitar otimizações do compilador em C e C ++ . Decidi que essa afirmação deveria ser explicada, principalmente porque eu mesmo acreditava nesse mito antes. Vou começar com a teoria e exemplos artificiais, e depois passar para experimentos e benchmarks baseados em uma base de código real - SQLite.

Teste simples


Vamos começar com, como me pareceu, o exemplo mais simples e óbvio de acelerar o código C com const . Digamos que temos duas declarações de função:

 void func(int *x); void constFunc(const int *x); 

E suponha que haja duas versões do código:

 void byArg(int *x) { printf("%d\n", *x); func(x); printf("%d\n", *x); } void constByArg(const int *x) { printf("%d\n", *x); constFunc(x); printf("%d\n", *x); } 

Para executar printf() , o processador deve recuperar *x da memória através de um ponteiro. Obviamente, a execução de constByArg() pode ser um pouco mais rápida, porque o compilador sabe que *x é uma constante; portanto, não há necessidade de carregar seu valor novamente após constFunc() . Certo? Vamos ver o código do assembler gerado pelo GCC com as otimizações ativadas:

 $ gcc -S -Wall -O3 test.c $ view test.s 

E aqui está o resultado completo do assembler para byArg() :

 byArg: .LFB23: .cfi_startproc pushq %rbx .cfi_def_cfa_offset 16 .cfi_offset 3, -16 movl (%rdi), %edx movq %rdi, %rbx leaq .LC0(%rip), %rsi movl $1, %edi xorl %eax, %eax call __printf_chk@PLT movq %rbx, %rdi call func@PLT # The only instruction that's different in constFoo movl (%rbx), %edx leaq .LC0(%rip), %rsi xorl %eax, %eax movl $1, %edi popq %rbx .cfi_def_cfa_offset 8 jmp __printf_chk@PLT .cfi_endproc 

A única diferença entre o código do assembler gerado por byArg() e constByArg() é que constByArg() possui uma call constFunc@PLT , como no código-fonte. const próprio const não faz diferença.

Ok, isso foi o GCC. Talvez precisemos de um compilador mais inteligente. Diga Clang.

 $ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll 

Aqui está o código intermediário. É mais compacto que o assembler, e vou abandonar as duas funções, para que você entenda o que quero dizer com "nenhuma diferença, exceto a chamada":

 ; Function Attrs: nounwind uwtable define dso_local void @byArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @func(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } ; Function Attrs: nounwind uwtable define dso_local void @constByArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @constFunc(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } 

Opção que (tipo) funciona


E aqui está o código no qual a presença de const realmente importa:

 void localVar() { int x = 42; printf("%d\n", x); constFunc(&x); printf("%d\n", x); } void constLocalVar() { const int x = 42; // const on the local variable printf("%d\n", x); constFunc(&x); printf("%d\n", x); } 

O código do assembler para localVar() , que contém duas instruções otimizadas fora de constLocalVar() :

 localVar: .LFB25: .cfi_startproc subq $24, %rsp .cfi_def_cfa_offset 32 movl $42, %edx movl $1, %edi movq %fs:40, %rax movq %rax, 8(%rsp) xorl %eax, %eax leaq .LC0(%rip), %rsi movl $42, 4(%rsp) call __printf_chk@PLT leaq 4(%rsp), %rdi call constFunc@PLT movl 4(%rsp), %edx # not in constLocalVar() xorl %eax, %eax movl $1, %edi leaq .LC0(%rip), %rsi # not in constLocalVar() call __printf_chk@PLT movq 8(%rsp), %rax xorq %fs:40, %rax jne .L9 addq $24, %rsp .cfi_remember_state .cfi_def_cfa_offset 8 ret .L9: .cfi_restore_state call __stack_chk_fail@PLT .cfi_endproc 

O middleware LLVM é um pouco mais limpo. load antes da segunda chamada para printf() foi otimizado fora de constLocalVar() :

 ; Function Attrs: nounwind uwtable define dso_local void @localVar() local_unnamed_addr #0 { %1 = alloca i32, align 4 %2 = bitcast i32* %1 to i8* call void @llvm.lifetime.start.p0i8(i64 4, i8* nonnull %2) #4 store i32 42, i32* %1, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 42) call void @constFunc(i32* nonnull %1) #4 %4 = load i32, i32* %1, align 4, !tbaa !2 %5 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) call void @llvm.lifetime.end.p0i8(i64 4, i8* nonnull %2) #4 ret void } 

Portanto, constLocalVar() ignorou com êxito a reinicialização *x , mas você pode notar algo estranho: nos corpos localVar() e constLocalVar() a mesma chamada para constFunc() . Se o compilador pode descobrir que constFunc() não modificou *x em constLocalVar() , por que ele não entende que a mesma chamada de função não modificou *x em localVar() ?

A explicação é por que const em C é impraticável para usar como otimização. Em C, const tem essencialmente dois significados possíveis:

  • isso pode significar que uma variável é um pseudônimo somente leitura para alguns dados, que podem ou não ser constantes.
  • ou pode significar que a variável é realmente uma constante. Se você desamarrar const de um ponteiro para um valor constante e depois escrever nele, obterá um comportamento indefinido. Por outro lado, não haverá problema se const for um ponteiro para um valor que não é uma constante.

Aqui está um exemplo explicativo de implementação de constFunc() :

 // x is just a read-only pointer to something that may or may not be a constant void constFunc(const int *x) { // local_var is a true constant const int local_var = 42; // Definitely undefined behaviour by C rules doubleIt((int*)&local_var); // Who knows if this is UB? doubleIt((int*)x); } void doubleIt(int *x) { *x *= 2; } 

localVar() forneceu constFunc() um ponteiro const para uma variável não const . Como a variável não era const inicialmente, constFunc() pode se tornar um mentiroso e modificar a variável com força sem iniciar o UB. Portanto, o compilador não pode assumir que, após retornar constFunc() variável terá o mesmo valor. A variável constLocalVar() realmente é const ; portanto, o compilador não pode assumir que não será alterado, pois desta vez será UB para constFunc() , para que o compilador desvincule const e constFunc() a variável.

As byArg() e constByArg() do primeiro exemplo são inúteis, porque o compilador não pode constByArg() se *x é const .

Mas de onde veio a inconsistência? Se o compilador puder assumir que constFunc() não altera seu argumento quando chamado de constLocalVar() , ele pode aplicar as mesmas otimizações às chamadas constFunc() , certo? Não. O compilador não pode assumir que constLocalVar() jamais será chamado. E se isso não acontecer (por exemplo, porque é apenas um resultado adicional do gerador de código ou da operação de macro), o constFunc() poderá alterar silenciosamente os dados sem iniciar o UB.

Você pode precisar ler os exemplos e explicações acima várias vezes. Não se preocupe, isso soa absurdo - é. Infelizmente, gravar em variáveis const é o pior tipo de UB: na maioria das vezes, o compilador nem sabe se será UB. Portanto, quando o compilador vê const , deve proceder do fato de que alguém pode alterá-lo em algum lugar, o que significa que o compilador não pode usar const para otimização. Na prática, isso é verdade, porque muitos códigos C reais contêm uma rejeição de const no estilo de "eu sei o que estou fazendo".

Em resumo, existem muitas situações em que o compilador não pode usar const para otimização, incluindo a recuperação de dados de outro escopo usando um ponteiro ou a colocação de dados em um heap. Ou pior ainda, geralmente em situações em que o compilador não pode usar const , isso não é necessário. Por exemplo, qualquer compilador que se preze pode entender sem const que neste código x é uma constante:

 int x = 42, y = 0; printf("%d %d\n", x, y); y += x; printf("%d %d\n", x, y); 

Portanto, const quase inútil para otimização, porque:

  1. Com algumas exceções, o compilador é forçado a ignorá-lo, pois algum código pode desatar legalmente a const .
  2. Na maioria das exceções acima, o compilador ainda pode entender que a variável é uma constante.

C ++


Se você escreve em C ++, const pode afetar a geração de código através da sobrecarga de funções. Você pode ter sobrecargas const e non- const da mesma função e o non- const pode ser otimizado (por um programador, não por um compilador), por exemplo, para copiar menos.

 void foo(int *p) { // Needs to do more copying of data } void foo(const int *p) { // Doesn't need defensive copies } int main() { const int x = 42; // const-ness affects which overload gets called foo(&x); return 0; } 

Por um lado, não acho que, na prática, isso seja frequentemente aplicado no código C ++. Por outro lado, para que realmente faça a diferença, um programador deve fazer suposições que não estão disponíveis para o compilador, pois elas não são garantidas pelo idioma.

Experimente o SQLite3


Teoria suficiente e exemplos absurdos. Que efeito o const na base de código real? Decidi experimentar o SQLite DB (versão 3.30.0), porque:

  • Ele usa const.
  • Esta é uma base de código não trivial (mais de 200 KLOC).
  • Como banco de dados, ele inclui vários mecanismos, começando com o processamento de valores de sequência e terminando com a conversão de números para a data.
  • Pode ser testado com uma carga limitada do processador.

Além disso, o autor e os programadores envolvidos no desenvolvimento já passaram anos melhorando a produtividade, para que possamos assumir que eles não perderam nada óbvio.

Preparação


Fiz duas cópias do código fonte . Um compilado no modo normal e o segundo pré-processado usando um hack para transformar const em um comando ocioso:

 #define const 

(GNU) sed pode adicionar isso no topo de cada arquivo com o comando sed -i '1i#define const' *.c *.h .

O SQLite complica um pouco as coisas, usando scripts para gerar código durante a compilação. Felizmente, os compiladores apresentam muito ruído ao misturar código com const e sem const , para que você possa perceber e configurar imediatamente os scripts para adicionar meu código anti- const .

A comparação direta dos códigos compilados não faz sentido, pois uma pequena alteração pode afetar todo o esquema de memória, o que levará a uma alteração nos ponteiros e nas chamadas de função no código inteiro. Portanto, tomei uma objdump -d libSQLite3.so.0.8.6 desmontada ( objdump -d libSQLite3.so.0.8.6 ) como o tamanho do nome binário e mnemônico de cada instrução. Por exemplo, esta função:

 000000000005d570 <SQLite3_blob_read>: 5d570: 4c 8d 05 59 a2 ff ff lea -0x5da7(%rip),%r8 # 577d0 <SQLite3BtreePayloadChecked> 5d577: e9 04 fe ff ff jmpq 5d380 <blobReadWrite> 5d57c: 0f 1f 40 00 nopl 0x0(%rax) 

Se transforma em:

 SQLite3_blob_read 7lea 5jmpq 4nopl 

Ao compilar, não alterei as configurações de montagem do SQLite.

Análise de código compilada


Para libSQLite3.so, a versão com const ocupava 4.740.704 bytes, aproximadamente 0,1% a mais que a versão sem const com 4.736.712 bytes. Nos dois casos, 1374 funções foram exportadas (sem contar as funções auxiliares de baixo nível no PLT) e 13 apresentaram diferenças nas projeções.

Algumas mudanças foram relacionadas ao hack de pré-processamento. Por exemplo, aqui está uma das funções alteradas (removi algumas definições específicas do SQLite):

 #define LARGEST_INT64 (0xffffffff|(((int64_t)0x7fffffff)<<32)) #define SMALLEST_INT64 (((int64_t)-1) - LARGEST_INT64) static int64_t doubleToInt64(double r){ /* ** Many compilers we encounter do not define constants for the ** minimum and maximum 64-bit integers, or they define them ** inconsistently. And many do not understand the "LL" notation. ** So we define our own static constants here using nothing ** larger than a 32-bit integer constant. */ static const int64_t maxInt = LARGEST_INT64; static const int64_t minInt = SMALLEST_INT64; if( r<=(double)minInt ){ return minInt; }else if( r>=(double)maxInt ){ return maxInt; }else{ return (int64_t)r; } } 

Se removermos const , essas constantes se transformarão em variáveis static . Não entendo por que alguém que não se importa com const tornar essas variáveis static . Se removermos static e const , o GCC considerará novamente como constantes e obteremos o mesmo resultado. Devido a essas variáveis static const , alterações em três funções em treze resultaram falsas, mas eu não as corrigi.

O SQLite usa muitas variáveis ​​globais, e a maioria das verdadeiras otimizações de const está conectada a isso: como substituir uma comparação por uma variável por uma constante ou reverter parcialmente o loop em uma etapa (para entender que tipo de otimização foi feita, usei o Radare ). Algumas mudanças não merecem menção. SQLite3ParseUri() contém 487 instruções, mas const fez apenas uma alteração: fez essas duas comparações:

 test %al, %al je <SQLite3ParseUri+0x717> cmp $0x23, %al je <SQLite3ParseUri+0x717> 

E trocou:

 cmp $0x23, %al je <SQLite3ParseUri+0x717> test %al, %al je <SQLite3ParseUri+0x717> 

Benchmarks


O SQLite vem com um teste de regressão para medir o desempenho, e eu o executei centenas de vezes para cada versão do código usando as configurações de compilação padrão do SQLite. Tempo de execução em segundos:

const
Sem const
Mínimo
10.658
10.803
Mediana
11.571
11.519
Máximo
11.832
11.658
Média
11.531
11.492

Pessoalmente, não vejo muita diferença. Eu removi o const de todo o programa; portanto, se havia uma diferença notável, era fácil perceber. No entanto, se o desempenho é extremamente importante para você, mesmo pequenas acelerações podem agradá-lo. Vamos fazer uma análise estatística.

Eu gosto de usar o teste Mann-Whitney U para essas tarefas.É semelhante ao teste t mais conhecido, projetado para determinar diferenças em grupos, mas é mais resistente a variações aleatórias complexas que ocorrem ao medir o tempo em computadores (devido a mudanças imprevisíveis de contexto, erros em páginas de memória, etc.). Aqui está o resultado:

constSem const
N100100
Categoria do meio (classificação média)121,3879,62
Mann-whitney u2912
Z-5,10
Valor de p frente e verso<10 -6
A diferença média é HL
-0,056 s.
Intervalo de confiança de 95%
-0,077 ... -0,038 s.

O teste U encontrou uma diferença estatisticamente significativa no desempenho. Mas - uma surpresa! - A versão sem const acabou sendo mais rápida, em cerca de 60 ms, ou seja, em 0,5%. Parece que o pequeno número de “otimizações” feitas não valeu o aumento na quantidade de código. É improvável que o const ativado grandes otimizações, como a auto-vetorização. Obviamente, sua milhagem pode depender de vários sinalizadores no compilador, ou em sua versão, ou na base de código ou em outra coisa. Mas me parece honesto dizer que, mesmo que o const melhorado o desempenho de C, eu não percebi isso.

Então, para que const é necessário?


Por todas as suas falhas, a const em C / C ++ é útil para fornecer segurança de tipo. Em particular, se você usar const em combinação com mover semântica e std::unique_pointer , poderá implementar a propriedade explícita do ponteiro. A incerteza sobre a propriedade do ponteiro foi um grande problema nas bases de código C ++ mais antigas, com mais de 100 KLOC, por isso sou grato a const por resolvê-lo.

No entanto, antes de ir além do uso de const para fornecer segurança de tipo. Ouvi dizer que era considerado correto usar o const mais ativamente possível para melhorar o desempenho. Ouvi dizer que se o desempenho é realmente importante, é necessário refatorar o código para adicionar mais const , mesmo que o código se torne menos legível. Parecia razoável na época, mas desde então percebi que isso não era verdade.

Source: https://habr.com/ru/post/pt464777/


All Articles