Afirmei a um colega de trabalho que if (i < input.size() - 1) print(0);
seria otimizado nesse loop para que input.size()
não seja lido em todas as iterações, mas acontece que esse não é o 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 acordo com o Compiler Explorer com as opções gcc -O3 -fno-exceptions
, na verdade, estamos lendo input.size()
cada iteração e usando lea
para executar uma subtração!
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, no Rust, essa otimização ocorre. Parece que i
é substituído por uma variável j
que é decrementada a cada iteração, e o teste i < input.size() - 1
é substituído por algo parecido 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);
}
}
}
No Explorer do compilador, o assembly relevante se parece com isso:
cmpq %r12, %rbx
jae .LBB0_4
Eu verifiquei e tenho certeza que r12
é xs.len() - 1
e rbx
é o contador. Anteriormente, há um add
for rbx
e um mov
fora do loop r12
.
Por que é isso? Parece que, se o GCC for capaz de alinhar o size()
e, operator[]
como fez, deve saber que size()
isso não muda. Mas talvez o otimizador do GCC julgue que não vale a pena puxá-lo para uma variável? Ou talvez haja algum outro efeito colateral possível que tornaria isso inseguro - alguém sabe?
println
é provavelmente um método complexo, o compilador pode ter problemas para provar queprintln
não altera o vetor.cout.operator<<()
. O compilador não sabe que essa função de caixa preta não obtém uma referência aostd::vector
global.println
ouoperator<<
é fundamental.Respostas:
A função não-inline chamada para
cout.operator<<(int)
é uma caixa preta para o otimizador (porque a biblioteca é apenas escrita em C ++ e tudo o que o otimizador vê é um protótipo; consulte a discussão nos comentários). Ele deve assumir que qualquer memória que possa ser apontada por um var global foi modificada.(Ou a
std::endl
chamada. BTW, por que forçar um flush de cout nesse ponto em vez de apenas imprimir um'\n'
?)por exemplo, pelo que sabemos,
std::vector<int> &input
é uma referência a uma variável global e uma dessas chamadas de função modifica essa variável global . (Ou há um global emvector<int> *ptr
algum lugar, ou há uma função que retorna um ponteiro para astatic vector<int>
em alguma outra unidade de compilação, ou de alguma outra maneira que uma função possa obter uma referência a esse vetor sem que seja passada uma referência a ele por nós.Se você tivesse uma variável local cujo endereço nunca tivesse sido usado, o compilador poderia assumir que chamadas de função não embutidas não poderiam modificá-la. Porque não haveria maneira de nenhuma variável global manter um ponteiro para esse objeto. ( Isso é chamado de Análise de escape ). É por isso que o compilador pode manter
size_t i
um registro nas chamadas de função. (int i
pode simplesmente ser otimizado porque é ocultadosize_t i
e não usado de outra forma).Poderia fazer o mesmo com um local
vector
(ou seja, para os ponteiros base, end_size e end_capacity.)ISO C99 tem uma solução para este problema:
int *restrict foo
. Muitas compilações de C ++ oferecem suporteint *__restrict foo
para prometer que a memória apontada porfoo
é acessada apenas por esse ponteiro. Mais comumente útil em funções que levam 2 matrizes e você deseja prometer ao compilador que elas não se sobrepõem. Portanto, ele pode se auto-vetorizar sem gerar código para verificar isso e executar um loop de fallback.O OP comenta:
Isso explica por que o Rust pode fazer essa otimização, mas o C ++ não.
Otimizando seu C ++
Obviamente, você deve usar
auto size = input.size();
uma vez no topo de sua função para que o compilador saiba que é um loop invariável. As implementações de C ++ não resolvem esse problema para você, então você deve fazer isso sozinho.Também pode ser necessário
const int *data = input.data();
elevar cargas do ponteiro de dados dostd::vector<int>
"bloco de controle". É lamentável que a otimização possa exigir alterações muito não-idiomáticas da fonte.Rust é uma linguagem muito mais moderna, projetada após os desenvolvedores de compiladores aprenderem o que era possível na prática para compiladores. Isso realmente mostra em outras maneiras, também, incluindo portably expondo alguns dos fresco CPUs coisas pode fazer via
i32.count_ones
, girar, bit-scan, etc. É realmente estúpido que a ISO C ++ ainda não expõe qualquer um destes portably, excetostd::bitset::count()
.fonte
operator<<
desses tipos de operandos; portanto, no C ++ padrão, não é uma caixa preta e o compilador pode assumir que faz o que a documentação diz. Talvez eles queiram apoiar desenvolvedores de bibliotecas adicionando comportamento fora do padrão ...cout
permite que um objeto de uma classe definida pelo usuário derivadastreambuf
seja associado ao fluxo usandocout.rdbuf
. Da mesma forma, um objeto derivadoostream
pode ser associadocout.tie
.this
ponteiro é passado implicitamente. Isso pode acontecer na prática tão cedo quanto o construtor. Considere este loop simples - verifiquei apenas o loop principal do gcc (deL34:
parajne L34
), mas ele definitivamente está se comportando como se os membros do vetor tivessem escapado (carregando-os da memória a cada iteração).