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;
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()
:
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:
- Com algumas exceções, o compilador é forçado a ignorá-lo, pois algum código pode desatar legalmente a
const
.
- 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) {
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:
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:
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.