¿Por qué GCC no puede suponer que std :: vector :: size no cambiará en este ciclo?

14

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 leapara 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 ise reemplaza con una variable jque se reduce cada iteración, y la prueba i < input.size() - 1se 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 r12es xs.len() - 1y rbxes el contador. Anteriormente hay un addfor rbxy un movfuera 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?

Jonathan Chan
fuente
1
También printlnes probablemente un método complejo, el compilador puede tener problemas para probar que printlnno muta el vector.
Mooing Duck
1
@MooingDuck: Otro hilo sería la carrera de datos UB. Los compiladores pueden y asumen que eso no sucede. El problema aquí es la llamada a la función no en línea cout.operator<<(). El compilador no sabe que esta función de recuadro negro no obtiene una referencia std::vectorde un global.
Peter Cordes
@PeterCordes: tienes razón en que otros hilos no son una explicación independiente, y la complejidad de printlno operator<<es clave.
Mooing Duck
El compilador no conoce la semántica de estos métodos externos.
user207421

Respuestas:

10

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::endlllamada. 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> &inputes una referencia a una variable global, y una de esas llamadas a funciones modifica esa variable global . (O hay un global en vector<int> *ptralguna parte, o hay una función que devuelve un puntero a a static 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 iun registro en todas las llamadas a funciones. ( int ipuede optimizarse simplemente porque está sombreado size_t iy 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 apoyan int *__restrict fooa prometer que la memoria a la que apunta fooes 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:

En Rust, una referencia no mutable es una garantía global de que nadie más está mutando el valor al que tiene una referencia (equivalente a C ++ restrict)

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 el std::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, excepto std::bitset::count().

Peter Cordes
fuente
1
El código de OP todavía tiene la prueba si el vector se toma por valor. Entonces, aunque GCC podría optimizar en ese caso, no lo hace.
nogal
1
El estándar define el comportamiento de 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 ...
MM
2
El optimizador podría recibir el comportamiento que exige el estándar, mi punto es que esta optimización está permitida por el estándar, pero el proveedor del compilador elige implementar de la manera que usted describe y renuncia a esta optimización
MM
2
@MM No decía objeto aleatorio, dije un vector definido de implementación. No hay nada en el estándar que prohíba que una implementación tenga un vector definido de implementación que el operador << modifique y que permita el acceso a este vector de una manera definida por la implementación. coutpermite que un objeto de una clase definida por el usuario derivada de streambufse asocie con la secuencia usando cout.rdbuf. Del mismo modo, ostreamse puede asociar un objeto derivado de cout.tie.
Ross Ridge
2
@PeterCordes: no estaría tan seguro de los vectores locales: tan pronto como cualquier función miembro se desalinee, los locales se han escapado efectivamente porque el thispuntero 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 (de L34:a jne L34), pero definitivamente se comporta como si los miembros del vector hubieran escapado (cargándolos de la memoria en cada iteración).
BeeOnRope