Hace unos meses, mencioné en una publicación que
esto es un mito, como si const ayudara a habilitar las optimizaciones del compilador en C y C ++ . Decidí que esta declaración debería explicarse, especialmente porque yo mismo creía en este mito antes. Comenzaré con teoría y ejemplos artificiales, y luego pasaré a experimentos y puntos de referencia basados en una base de código real: SQLite.
Prueba simple
Comencemos con, como me pareció, el ejemplo más simple y obvio de acelerar el código C con
const
. Digamos que tenemos dos declaraciones de funciones:
void func(int *x); void constFunc(const int *x);
Y supongamos que hay dos versiones del 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 ejecutar
printf()
, el procesador debe recuperar
*x
de la memoria a través de un puntero. Obviamente, la ejecución de
constByArg()
puede ser un poco más rápida, porque el compilador sabe que
*x
es una constante, por lo que no es necesario cargar su valor nuevamente después de que
constFunc()
haya hecho. Derecho? Veamos el código de ensamblador generado por GCC con optimizaciones habilitadas:
$ gcc -S -Wall -O3 test.c $ view test.s
Y aquí está el resultado completo del ensamblador 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
La única diferencia entre el código ensamblador generado por
byArg()
y
constByArg()
es que
constByArg()
tiene una
call constFunc@PLT
, como en el código fuente.
const
sí mismo no hace ninguna diferencia.
Bien, eso fue GCC. Quizás necesitamos un compilador más inteligente. Di Clang
$ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll
Aquí está el código intermedio. Es más compacto que el ensamblador, y eliminaré ambas funciones, para que entienda lo que quiero decir con "sin diferencia, excepto por la llamada":
; 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 }
Opción que (tipo) funciona
Y aquí está el código en el que la presencia 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;
El código del ensamblador para
localVar()
, que contiene dos instrucciones optimizadas fuera 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
El middleware LLVM es un poco más limpio.
load
antes de que la segunda llamada a
printf()
se optimizara fuera 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 }
Entonces,
constLocalVar()
ignoró con éxito el reinicio
*x
, pero podría notar algo extraño: en los cuerpos
localVar()
y
constLocalVar()
la misma llamada a
constFunc()
. Si el compilador puede descubrir que
constFunc()
no modificó
*x
en
constLocalVar()
, entonces ¿por qué no puede entender que la misma llamada de función no modificó
*x
en
localVar()
?
La explicación es por qué no es práctico usar
const
en C como optimización. En C,
const
tiene esencialmente dos significados posibles:
- puede significar que una variable es un seudónimo de solo lectura para algunos datos, que puede ser constante o no.
- o puede significar que la variable es realmente una constante. Si desatas la
const
de un puntero a un valor constante y luego le escribes, obtendrás un comportamiento indefinido. Por otro lado, no habrá problema si const
es un puntero a un valor que no es una constante.
Aquí hay un ejemplo explicativo de implementación de
constFunc()
:
localVar()
le dio a
constFunc()
un puntero
const
a una variable no
const
. Dado que la variable no era
const
inicialmente,
constFunc()
puede resultar ser un mentiroso y modificar a la fuerza la variable sin iniciar UB. Por lo tanto, el compilador no puede suponer que después de devolver
constFunc()
variable tendrá el mismo valor. La variable en
constLocalVar()
realmente es
const
, por lo que el compilador no puede suponer que no se cambiará, porque esta vez
será UB para
constFunc()
, de modo que el compilador desenlazará
const
y escribirá en la variable.
Las
byArg()
y
constByArg()
del primer ejemplo son inútiles, porque el compilador no puede
constByArg()
si
*x
es
const
.
¿Pero de dónde vino la inconsistencia? Si el compilador puede suponer que
constFunc()
no cambia su argumento cuando se lo llama desde
constLocalVar()
, entonces puede aplicar las mismas optimizaciones a las llamadas
constFunc()
, ¿verdad? No El compilador no puede asumir que
constLocalVar()
vez se llamará a
constLocalVar()
. Y si no lo hace (por ejemplo, porque es solo un resultado adicional del generador de código o la operación de macro), entonces
constFunc()
puede cambiar silenciosamente los datos sin iniciar UB.
Es posible que deba leer los ejemplos y la explicación anteriores varias veces. No te preocupes que suene absurdo, lo es. Desafortunadamente, escribir en variables
const
es el peor tipo de UB: la mayoría de las veces, el compilador ni siquiera sabe si será UB. Por lo tanto, cuando el compilador ve
const
, debe proceder del hecho de que alguien puede cambiarlo en algún lugar, lo que significa que el compilador no puede usar
const
para la optimización. En la práctica, esto es cierto, porque una gran cantidad de código C real contiene un rechazo de
const
en el estilo de "Sé lo que estoy haciendo".
En resumen, hay muchas situaciones en las que el compilador no puede usar
const
para la optimización, incluida la recuperación de datos de otro ámbito mediante un puntero o la colocación de datos en un montón. O peor aún, generalmente en situaciones donde el compilador no puede usar
const
, esto no es necesario. Por ejemplo, cualquier compilador que se respete puede comprender sin
const
que en este código
x
es una constante:
int x = 42, y = 0; printf("%d %d\n", x, y); y += x; printf("%d %d\n", x, y);
Entonces
const
casi inútil para la optimización, porque:
- Con algunas excepciones, el compilador se ve obligado a ignorarlo, ya que algunos códigos pueden desatar legalmente la
const
.
- En la mayoría de las excepciones anteriores, el compilador aún puede entender que la variable es una constante.
C ++
Si escribe en C ++, entonces
const
puede afectar la generación de código a través de la sobrecarga de funciones. Puede tener sobrecargas
const
y no
const
de la misma función, y no
const
puede ser optimizado (por un programador, no un compilador), por ejemplo, para copiar menos.
void foo(int *p) {
Por un lado, no creo que en la práctica esto se aplique a menudo en código C ++. Por otro lado, para que realmente marque la diferencia, un programador debe hacer suposiciones que no están disponibles para el compilador, ya que no están garantizadas por el lenguaje.
Experimente con SQLite3
Suficiente teoría y ejemplos descabellados. ¿Qué efecto tiene
const
en la base de código real? Decidí experimentar con SQLite DB (versión 3.30.0) porque:
- Utiliza
const.
- Esta es una base de código no trivial (más de 200 KLOC).
- Como base de datos, incluye varios mecanismos, comenzando con el procesamiento de valores de cadena y terminando con la conversión de números hasta la fecha.
- Se puede probar con una carga limitada del procesador.
Además, el autor y los programadores involucrados en el desarrollo ya han pasado años mejorando la productividad, por lo que podemos suponer que no se perdieron nada obvio.
Preparación
Hice dos copias del
código fuente . Uno compilado en modo normal, y el segundo preprocesado utilizando un hack para convertir a
const
en un comando inactivo:
#define const
(GNU)
sed
puede agregar esto encima de cada archivo con el comando
sed -i '1i#define const' *.c *.h
.
SQLite complica las cosas un poco, usando scripts para generar código durante la compilación. Afortunadamente, los compiladores introducen mucho ruido al mezclar código con
const
y sin
const
, por lo que inmediatamente puede notar y configurar los scripts para agregar mi código anti-
const
.
La comparación directa de los códigos compilados no tiene sentido, ya que un pequeño cambio puede afectar todo el esquema de memoria, lo que conducirá a un cambio en los punteros y las llamadas de función en todo el código. Por lo tanto, tomé un elenco desmontado (
objdump -d libSQLite3.so.0.8.6
) como el tamaño del binario y el nombre mnemotécnico de cada instrucción. Por ejemplo, esta función:
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 convierte en:
SQLite3_blob_read 7lea 5jmpq 4nopl
Al compilar, no cambié la configuración del ensamblaje SQLite.
Análisis de código compilado
Para libSQLite3.so, la versión con
const
ocupó 4.740.704 bytes, aproximadamente un 0.1% más que la versión sin
const
con 4.736.712 bytes. En ambos casos, se exportaron 1374 funciones (sin contar las funciones auxiliares de bajo nivel en el PLT), y 13 tuvieron diferencias en los lanzamientos.
Algunos cambios estaban relacionados con el hack de preprocesamiento. Por ejemplo, aquí está una de las funciones modificadas (eliminé algunas definiciones específicas de 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; } }
Si eliminamos
const
, estas constantes se convierten en variables
static
. No entiendo por qué alguien a quien no le importa
const
hacer que estas variables sean
static
. Si eliminamos tanto la
static
como la
const
, GCC nuevamente las considerará constantes y obtendremos el mismo resultado. Debido a estas variables
static const
, los cambios en tres funciones de trece resultaron ser falsas, pero no las solucioné.
SQLite usa muchas variables globales, y la mayoría de las optimizaciones
const
verdaderas están conectadas con esto: como reemplazar una comparación con una variable con una comparación con una constante o retroceder parcialmente el ciclo un paso (para comprender qué tipo de optimizaciones se hicieron, usé
Radare ). No vale la pena mencionar algunos cambios.
SQLite3ParseUri()
contiene 487 instrucciones, pero
const
realizó solo un cambio: tomó estas dos comparaciones:
test %al, %al je <SQLite3ParseUri+0x717> cmp $0x23, %al je <SQLite3ParseUri+0x717>
Y cambiado:
cmp $0x23, %al je <SQLite3ParseUri+0x717> test %al, %al je <SQLite3ParseUri+0x717>
Puntos de referencia
SQLite viene con una prueba de regresión para medir el rendimiento, y lo ejecuté cientos de veces para cada versión del código usando la configuración de compilación estándar de SQLite. Tiempo de ejecución en segundos:
Personalmente, no veo mucha diferencia. Eliminé
const
de todo el programa, así que si había una diferencia notable, entonces era fácil notarlo. Sin embargo, si el rendimiento es extremadamente importante para usted, incluso una pequeña aceleración puede complacerlo. Hagamos un análisis estadístico.
Me gusta usar la prueba U de Mann-Whitney para tales tareas. Es similar a la prueba t más conocida, diseñada para determinar las diferencias en los grupos, pero es más resistente a las variaciones aleatorias complejas que ocurren al medir el tiempo en las computadoras (debido a cambios de contexto impredecibles, errores en páginas de memoria, etc.). Aquí está el resultado:
La prueba U encontró una diferencia estadísticamente significativa en el rendimiento. Pero, ¡una sorpresa! - La versión sin
const
resultó ser más rápida, en unos 60 ms, es decir, en un 0,5%. Parece que el pequeño número de "optimizaciones" realizadas no valió el aumento en la cantidad de código. Es poco probable que
const
activado alguna optimización importante, como la vectorización automática. Por supuesto, su kilometraje puede depender de varios indicadores en el compilador, o en su versión, o en la base del código, o en otra cosa. Pero me parece honesto decir que incluso si
const
mejora el rendimiento de C, no me di cuenta de esto.
Entonces, ¿para qué se necesita const?
Para todos sus defectos,
const
en C / C ++ es útil para proporcionar seguridad de tipo. En particular, si usa
const
en combinación con semántica de movimiento y
std::unique_pointer
, puede implementar la propiedad explícita del puntero. La incertidumbre sobre la propiedad del puntero fue un gran problema en las bases de código C ++ más antiguas de más de 100 KLOC, por lo que estoy agradecido de poder
const
para resolverlo.
Sin embargo, antes de ir más allá de usar
const
para proporcionar seguridad de tipo. Escuché que se considera correcto usar
const
tan activamente como sea posible para mejorar el rendimiento. Escuché que si el rendimiento es realmente importante, entonces debes refactorizar el código para agregar más
const
, incluso si el código se vuelve menos legible. Parecía razonable en ese momento, pero desde entonces me di cuenta de que esto no era cierto.