Le reclamé a un compañero de trabajo que if (i < input.size() - 1) print(0);
se optimizaría en este ciclo para que input.size()
no se lea en cada iteración, ¡pero resulta que este no es el caso!
void print(int x) {
std::cout << x << std::endl;
}
void print_list(const std::vector<int>& input) {
int i = 0;
for (size_t i = 0; i < input.size(); i++) {
print(input[i]);
if (i < input.size() - 1) print(0);
}
}
De acuerdo con el Explorador de compiladores con opciones de gcc, -O3 -fno-exceptions
¡en realidad estamos leyendo input.size()
cada iteración y usándola lea
para realizar una resta!
movq 0(%rbp), %rdx
movq 8(%rbp), %rax
subq %rdx, %rax
sarq $2, %rax
leaq -1(%rax), %rcx
cmpq %rbx, %rcx
ja .L35
addq $1, %rbx
Curiosamente, en Rust se produce esta optimización. Parece que i
se reemplaza con una variable j
que se reduce cada iteración, y la prueba i < input.size() - 1
se reemplaza con algo así j > 0
.
fn print(x: i32) {
println!("{}", x);
}
pub fn print_list(xs: &Vec<i32>) {
for (i, x) in xs.iter().enumerate() {
print(*x);
if i < xs.len() - 1 {
print(0);
}
}
}
En el Explorador del compilador, el ensamblaje relevante se ve así:
cmpq %r12, %rbx
jae .LBB0_4
Lo comprobé y estoy bastante seguro de que r12
es xs.len() - 1
y rbx
es el contador. Anteriormente hay un add
for rbx
y un mov
fuera del bucle en r12
.
¿Por qué es esto? Parece que si GCC puede en línea size()
y, operator[]
como lo hizo, debería saber que eso size()
no cambia. ¿Pero quizás el optimizador de GCC considera que no vale la pena convertirlo en una variable? O tal vez exista algún otro efecto secundario posible que lo haga inseguro. ¿Alguien sabe?
println
es probablemente un método complejo, el compilador puede tener problemas para probar queprintln
no muta el vector.cout.operator<<()
. El compilador no sabe que esta función de recuadro negro no obtiene una referenciastd::vector
de un global.println
ooperator<<
es clave.Respuestas:
La llamada a la función no en línea
cout.operator<<(int)
es un cuadro negro para el optimizador (porque la biblioteca está escrita en C ++ y todo lo que el optimizador ve es un prototipo; vea la discusión en los comentarios). Tiene que suponer que cualquier memoria que pueda ser señalada por una var global ha sido modificada.(O la
std::endl
llamada. Por cierto, ¿por qué forzar una descarga de color en ese punto en lugar de simplemente imprimir un'\n'
?)por ejemplo, por lo que sabe,
std::vector<int> &input
es una referencia a una variable global, y una de esas llamadas a funciones modifica esa variable global . (O hay un global envector<int> *ptr
alguna parte, o hay una función que devuelve un puntero a astatic vector<int>
en alguna otra unidad de compilación, o de alguna otra manera en que una función podría obtener una referencia a este vector sin que le pasemos una referencia a él.Si tuviera una variable local cuya dirección nunca se hubiera tomado, el compilador podría suponer que las llamadas a funciones no en línea no podrían mutarla. Porque no habría forma de que ninguna variable global mantenga un puntero a este objeto. ( Esto se llama análisis de escape ). Es por eso que el compilador puede mantener
size_t i
un registro en todas las llamadas a funciones. (int i
puede optimizarse simplemente porque está sombreadosize_t i
y no se usa de otra manera).Podría hacer lo mismo con un local
vector
(es decir, para los punteros base, end_size y end_capacity).ISO C99 tiene una solución para este problema:
int *restrict foo
. Muchas C ++ compila apoyanint *__restrict foo
a prometer que la memoria a la que apuntafoo
es solamente accesible a través de ese puntero. Más comúnmente útil en funciones que toman 2 matrices, y desea prometer al compilador que no se superponen. Por lo tanto, puede auto-vectorizarse sin generar código para verificar eso y ejecutar un ciclo de reserva.El OP comenta:
Eso explica por qué Rust puede hacer esta optimización, pero C ++ no.
Optimizando su C ++
Obviamente, debe usar
auto size = input.size();
una vez en la parte superior de su función para que el compilador sepa que es un bucle invariante. Las implementaciones de C ++ no resuelven este problema por usted, por lo que debe hacerlo usted mismo.Es posible que también deba
const int *data = input.data();
izar cargas del puntero de datos desde elstd::vector<int>
"bloque de control". Es lamentable que la optimización pueda requerir cambios de fuente muy poco idiomáticos.Rust es un lenguaje mucho más moderno, diseñado después de que los desarrolladores del compilador aprendieron lo que era posible en la práctica para los compiladores. Realmente también se muestra de otras maneras, incluida la exposición portátil de algunas de las cosas interesantes que las CPU pueden hacer a través de
i32.count_ones
, rotar, escanear bits, etc. Es realmente tonto que ISO C ++ todavía no exponga ninguno de estos dispositivos portátiles, exceptostd::bitset::count()
.fuente
operator<<
esos tipos de operandos; así que en Standard C ++ no es un cuadro negro y el compilador puede asumir que hace lo que dice la documentación. Tal vez quieran apoyar a los desarrolladores de bibliotecas agregando comportamientos no estándar ...cout
permite que un objeto de una clase definida por el usuario derivada destreambuf
se asocie con la secuencia usandocout.rdbuf
. Del mismo modo,ostream
se puede asociar un objeto derivado decout.tie
.this
puntero se pasa implícitamente. Esto podría suceder en la práctica ya desde el constructor. Considere este bucle simple : solo verifiqué el bucle principal gcc (deL34:
ajne L34
), pero definitivamente se comporta como si los miembros del vector hubieran escapado (cargándolos de la memoria en cada iteración).