Na verdade, desde o C ++ 11, o custo de cópia do C ++ std::vector
acabou na maioria dos casos.
No entanto, deve-se ter em mente que o custo de construir o novo vetor (e então destruí- lo) ainda existe, e usar parâmetros de saída em vez de retornar por valor ainda é útil quando você deseja reutilizar a capacidade do vetor. Isso é documentado como uma exceção em F.20 das Diretrizes Básicas C ++.
Vamos comparar:
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
com:
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
Agora, suponha que precisemos chamar esses métodos numIter
em um loop apertado e executar alguma ação. Por exemplo, vamos calcular a soma de todos os elementos.
Usando BuildLargeVector1
, você faria:
size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
std::vector<int> v = BuildLargeVector1(vecSize);
sum1 = std::accumulate(v.begin(), v.end(), sum1);
}
Usando BuildLargeVector2
, você faria:
size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
BuildLargeVector2(/*out*/ v, vecSize);
sum2 = std::accumulate(v.begin(), v.end(), sum2);
}
No primeiro exemplo, há muitas alocações / desalocações dinâmicas desnecessárias acontecendo, que são evitadas no segundo exemplo usando um parâmetro de saída da maneira antiga, reutilizando a memória já alocada. Se essa otimização vale ou não a pena, depende do custo relativo da alocação / desalocação em comparação com o custo de calcular / alterar os valores.
Benchmark
Vamos brincar com os valores de vecSize
e numIter
. Manteremos vecSize * numIter constante para que "em teoria" demore o mesmo tempo (= há o mesmo número de atribuições e adições, com exatamente os mesmos valores), e a diferença de tempo só pode vir do custo de alocações, desalocações e melhor uso do cache.
Mais especificamente, vamos usar vecSize * numIter = 2 ^ 31 = 2147483648, porque eu tenho 16 GB de RAM e esse número garante que não mais do que 8 GB sejam alocados (sizeof (int) = 4), garantindo que não estou trocando para o disco ( todos os outros programas foram fechados, eu tinha ~ 15 GB disponíveis ao executar o teste).
Aqui está o código:
#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>
class Timer {
using clock = std::chrono::steady_clock;
using seconds = std::chrono::duration<double>;
clock::time_point t_;
public:
void tic() { t_ = clock::now(); }
double toc() const { return seconds(clock::now() - t_).count(); }
};
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
int main() {
Timer t;
size_t vecSize = size_t(1) << 31;
size_t numIter = 1;
std::cout << std::setw(10) << "vecSize" << ", "
<< std::setw(10) << "numIter" << ", "
<< std::setw(10) << "time1" << ", "
<< std::setw(10) << "time2" << ", "
<< std::setw(10) << "sum1" << ", "
<< std::setw(10) << "sum2" << "\n";
while (vecSize > 0) {
t.tic();
size_t sum1 = 0;
{
for (int i = 0; i < numIter; ++i) {
std::vector<int> v = BuildLargeVector1(vecSize);
sum1 = std::accumulate(v.begin(), v.end(), sum1);
}
}
double time1 = t.toc();
t.tic();
size_t sum2 = 0;
{
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
BuildLargeVector2(/*out*/ v, vecSize);
sum2 = std::accumulate(v.begin(), v.end(), sum2);
}
} // deallocate v
double time2 = t.toc();
std::cout << std::setw(10) << vecSize << ", "
<< std::setw(10) << numIter << ", "
<< std::setw(10) << std::fixed << time1 << ", "
<< std::setw(10) << std::fixed << time2 << ", "
<< std::setw(10) << sum1 << ", "
<< std::setw(10) << sum2 << "\n";
vecSize /= 2;
numIter *= 2;
}
return 0;
}
E aqui está o resultado:
$ g++ -std=c++11 -O3 main.cpp && ./a.out
vecSize, numIter, time1, time2, sum1, sum2
2147483648, 1, 2.360384, 2.356355, 2147483648, 2147483648
1073741824, 2, 2.365807, 1.732609, 2147483648, 2147483648
536870912, 4, 2.373231, 1.420104, 2147483648, 2147483648
268435456, 8, 2.383480, 1.261789, 2147483648, 2147483648
134217728, 16, 2.395904, 1.179340, 2147483648, 2147483648
67108864, 32, 2.408513, 1.131662, 2147483648, 2147483648
33554432, 64, 2.416114, 1.097719, 2147483648, 2147483648
16777216, 128, 2.431061, 1.060238, 2147483648, 2147483648
8388608, 256, 2.448200, 0.998743, 2147483648, 2147483648
4194304, 512, 0.884540, 0.875196, 2147483648, 2147483648
2097152, 1024, 0.712911, 0.716124, 2147483648, 2147483648
1048576, 2048, 0.552157, 0.603028, 2147483648, 2147483648
524288, 4096, 0.549749, 0.602881, 2147483648, 2147483648
262144, 8192, 0.547767, 0.604248, 2147483648, 2147483648
131072, 16384, 0.537548, 0.603802, 2147483648, 2147483648
65536, 32768, 0.524037, 0.600768, 2147483648, 2147483648
32768, 65536, 0.526727, 0.598521, 2147483648, 2147483648
16384, 131072, 0.515227, 0.599254, 2147483648, 2147483648
8192, 262144, 0.540541, 0.600642, 2147483648, 2147483648
4096, 524288, 0.495638, 0.603396, 2147483648, 2147483648
2048, 1048576, 0.512905, 0.609594, 2147483648, 2147483648
1024, 2097152, 0.548257, 0.622393, 2147483648, 2147483648
512, 4194304, 0.616906, 0.647442, 2147483648, 2147483648
256, 8388608, 0.571628, 0.629563, 2147483648, 2147483648
128, 16777216, 0.846666, 0.657051, 2147483648, 2147483648
64, 33554432, 0.853286, 0.724897, 2147483648, 2147483648
32, 67108864, 1.232520, 0.851337, 2147483648, 2147483648
16, 134217728, 1.982755, 1.079628, 2147483648, 2147483648
8, 268435456, 3.483588, 1.673199, 2147483648, 2147483648
4, 536870912, 5.724022, 2.150334, 2147483648, 2147483648
2, 1073741824, 10.285453, 3.583777, 2147483648, 2147483648
1, 2147483648, 20.552860, 6.214054, 2147483648, 2147483648
(Intel i7-7700K @ 4,20 GHz; 16 GB DDR4 2400 MHz; Kubuntu 18,04)
Notação: mem (v) = v.size () * sizeof (int) = v.size () * 4 na minha plataforma.
Não surpreendentemente, quando numIter = 1
(isto é, mem (v) = 8GB), os tempos são perfeitamente idênticos. De fato, em ambos os casos, estamos alocando apenas uma vez um grande vetor de 8 GB na memória. Isso também prova que nenhuma cópia aconteceu ao usar BuildLargeVector1 (): Eu não teria RAM suficiente para fazer a cópia!
Quando numIter = 2
, reutilizar a capacidade do vetor em vez de realocar um segundo vetor é 1,37x mais rápido.
Quando numIter = 256
, reutilizar a capacidade do vetor (em vez de alocar / desalocar um vetor repetidamente 256 vezes ...) é 2,45x mais rápido :)
Podemos notar que time1 é praticamente constante de numIter = 1
a numIter = 256
, o que significa que alocar um grande vetor de 8 GB é quase tão caro quanto alocar 256 vetores de 32 MB. No entanto, alocar um vetor enorme de 8 GB é definitivamente mais caro do que alocar um vetor de 32 MB, portanto, reutilizar a capacidade do vetor fornece ganhos de desempenho.
De numIter = 512
(mem (v) = 16 MB) a numIter = 8M
(mem (v) = 1kB) é o ponto ideal: ambos os métodos são exatamente tão rápidos e mais rápidos do que todas as outras combinações de numIter e vecSize. Isso provavelmente tem a ver com o fato de que o tamanho do cache L3 do meu processador é de 8 MB, de modo que o vetor cabe quase completamente no cache. Eu realmente não explico por que o salto repentino de time1
é para mem (v) = 16 MB, pareceria mais lógico acontecer logo depois, quando mem (v) = 8 MB. Observe que, surpreendentemente, neste ponto ideal, não reutilizar a capacidade é, na verdade, um pouco mais rápido! Eu realmente não explico isso.
Quando as numIter > 8M
coisas começam a ficar feias. Ambos os métodos ficam mais lentos, mas o retorno do vetor por valor fica ainda mais lento. No pior caso, com um vetor contendo apenas um int
, reutilizar a capacidade em vez de retornar por valor é 3,3x mais rápido. Presumivelmente, isso se deve aos custos fixos de malloc () que começam a dominar.
Observe como a curva para o tempo2 é mais suave do que a curva para o tempo1: não apenas reutilizar a capacidade do vetor é geralmente mais rápido, mas talvez o mais importante, é mais previsível .
Observe também que, no ponto ideal, fomos capazes de realizar 2 bilhões de adições de inteiros de 64 bits em ~ 0,5s, o que é bastante ideal em um processador de 64 bits de 4,2 GHz. Poderíamos fazer melhor paralelizando a computação para usar todos os 8 núcleos (o teste acima usa apenas um núcleo por vez, o que eu verifiquei reexecutando o teste enquanto monitora o uso da CPU). O melhor desempenho é obtido quando mem (v) = 16kB, que é a ordem de magnitude do cache L1 (o cache de dados L1 para o i7-7700K é 4x32kB).
É claro que as diferenças se tornam cada vez menos relevantes quanto mais cálculos você realmente precisa fazer nos dados. Abaixo estão os resultados se substituirmos sum = std::accumulate(v.begin(), v.end(), sum);
por for (int k : v) sum += std::sqrt(2.0*k);
:
Conclusões
- Usar parâmetros de saída em vez de retornar por valor pode fornecer ganhos de desempenho ao reutilizar a capacidade.
- Em um computador desktop moderno, isso parece aplicável apenas a vetores grandes (> 16 MB) e vetores pequenos (<1 kB).
- Evite alocar milhões / bilhões de vetores pequenos (<1kB). Se possível, reutilize a capacidade ou, melhor ainda, projete sua arquitetura de maneira diferente.
Os resultados podem ser diferentes em outras plataformas. Como de costume, se o desempenho for importante, escreva benchmarks para seu caso de uso específico.