¿Por qué const no acelera el código C / C ++?


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; // const on the local variable printf("%d\n", x); constFunc(&x); printf("%d\n", x); } 

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() :

 // 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() 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:

  1. Con algunas excepciones, el compilador se ve obligado a ignorarlo, ya que algunos códigos pueden desatar legalmente la const .
  2. 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) { // 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 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:

const
Sin constante
Mínimo
10,658
10,803
Mediana
11,571
11,519
Máxima
11,832
11,658
Media
11,531
11,492

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:

constSin constante
N100100
Categoría media (rango medio)121,3879,62
Mann-whitney u2912
Z-5,10
Valor p de 2 lados<10 -6
La diferencia promedio es HL
-0.056 s.
Intervalo de confianza del 95 por ciento
-0,077 ... -0,038 s.

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.

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


All Articles