Trên thực tế, kể từ khi C ++ 11, chi phí của việc sao chép các std::vector
biến mất trong hầu hết các trường hợp.
Tuy nhiên, cần lưu ý rằng chi phí xây dựng vectơ mới (sau đó hủy nó) vẫn tồn tại và việc sử dụng các tham số đầu ra thay vì trả về theo giá trị vẫn hữu ích khi bạn muốn sử dụng lại dung lượng của vectơ. Điều này được ghi lại như một ngoại lệ trong F.20 của Nguyên tắc cốt lõi C ++.
Hãy so sánh:
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
với:
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
Bây giờ, giả sử chúng ta cần gọi các phương thức này numIter
trong một vòng lặp chặt chẽ và thực hiện một số hành động. Ví dụ, hãy tính tổng của tất cả các phần tử.
Sử dụng BuildLargeVector1
, bạn sẽ làm:
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);
}
Sử dụng BuildLargeVector2
, bạn sẽ làm:
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);
}
Trong ví dụ đầu tiên, có nhiều phân bổ động / phân bổ động không cần thiết xảy ra, chúng được ngăn chặn trong ví dụ thứ hai bằng cách sử dụng tham số đầu ra theo cách cũ, sử dụng lại bộ nhớ đã được cấp phát. Việc tối ưu hóa này có đáng thực hiện hay không phụ thuộc vào chi phí tương đối của việc phân bổ / phân bổ giao dịch so với chi phí tính toán / thay đổi các giá trị.
Điểm chuẩn
Hãy chơi với các giá trị của vecSize
và numIter
. Chúng tôi sẽ giữ vecSize * numIter không đổi để "trên lý thuyết", nó sẽ mất cùng một thời gian (= có cùng một số lần gán và bổ sung, với cùng giá trị) và chênh lệch thời gian chỉ có thể đến từ chi phí phân bổ, phân bổ giao dịch và sử dụng bộ nhớ đệm tốt hơn.
Cụ thể hơn, hãy sử dụng vecSize * numIter = 2 ^ 31 = 2147483648, vì tôi có 16GB RAM và con số này đảm bảo rằng không quá 8GB được phân bổ (sizeof (int) = 4), đảm bảo rằng tôi không hoán đổi sang đĩa ( tất cả các chương trình khác đã bị đóng, tôi có ~ 15GB khả dụng khi chạy thử nghiệm).
Đây là mã:
#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;
}
Và đây là kết quả:
$ 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,20GHz; 16GB DDR4 2400Mhz; Kubuntu 18,04)
Ký hiệu: mem (v) = v.size () * sizeof (int) = v.size () * 4 trên nền tảng của tôi.
Không có gì đáng ngạc nhiên, khi numIter = 1
(tức là, mem (v) = 8GB), thời gian hoàn toàn giống hệt nhau. Thật vậy, trong cả hai trường hợp, chúng tôi chỉ cấp phát một lần một vector khổng lồ 8GB trong bộ nhớ. Điều này cũng chứng minh rằng không có bản sao nào xảy ra khi sử dụng BuildLargeVector1 (): Tôi sẽ không có đủ RAM để sao chép!
Khi numIter = 2
sử dụng lại công suất vectơ thay vì phân bổ lại vectơ thứ hai nhanh hơn 1,37 lần.
Khi numIter = 256
sử dụng lại dung lượng vectơ (thay vì phân bổ / phân bổ một vectơ lặp đi lặp lại 256 lần ...) nhanh hơn 2,45 lần :)
Chúng ta có thể nhận thấy rằng time1 khá nhiều không đổi từ numIter = 1
đến numIter = 256
, có nghĩa là việc phân bổ một vectơ khổng lồ 8GB là khá tốn kém như phân bổ 256 vectơ 32MB. Tuy nhiên, việc phân bổ một vectơ khổng lồ 8GB rõ ràng là đắt hơn so với phân bổ một vectơ 32MB, vì vậy việc sử dụng lại dung lượng của vectơ sẽ mang lại hiệu suất tăng.
Từ numIter = 512
(mem (v) = 16MB) đến numIter = 8M
(mem (v) = 1kB) là điểm hấp dẫn: cả hai phương pháp đều nhanh như nhau và nhanh hơn tất cả các kết hợp khác của numIter và vecSize. Điều này có thể liên quan đến thực tế là kích thước bộ nhớ cache L3 của bộ xử lý của tôi là 8MB, do đó vector khá phù hợp hoàn toàn trong bộ nhớ cache. Tôi không thực sự giải thích tại sao sự nhảy vọt đột ngột time1
là đối với mem (v) = 16MB, nó có vẻ hợp lý hơn nếu xảy ra ngay sau đó, khi mem (v) = 8MB. Lưu ý rằng đáng ngạc nhiên là ở điểm ngọt ngào này, việc không sử dụng lại dung lượng trên thực tế sẽ nhanh hơn một chút! Tôi không thực sự giải thích điều này.
Khi numIter > 8M
mọi thứ bắt đầu trở nên tồi tệ. Cả hai phương pháp đều chậm hơn nhưng trả về vectơ theo giá trị thậm chí còn chậm hơn. Trong trường hợp xấu nhất, với một vectơ chỉ chứa một đơn lẻ int
, khả năng tái sử dụng thay vì trả về theo giá trị sẽ nhanh hơn 3,3 lần. Có lẽ, điều này là do chi phí cố định của malloc () bắt đầu chiếm ưu thế.
Lưu ý rằng đường cong cho time2 mượt mà hơn đường cong cho time1: không chỉ sử dụng lại công suất vectơ nói chung nhanh hơn, mà có lẽ quan trọng hơn, nó dễ dự đoán hơn .
Cũng lưu ý rằng ở điểm đáng chú ý, chúng tôi có thể thực hiện 2 tỷ phép cộng số nguyên 64bit trong ~ 0,5 giây, khá tối ưu trên bộ xử lý 64bit 4,2Ghz. Chúng tôi có thể làm tốt hơn bằng cách tính toán song song để sử dụng tất cả 8 lõi (thử nghiệm ở trên chỉ sử dụng một lõi tại một thời điểm, mà tôi đã xác minh bằng cách chạy lại thử nghiệm trong khi theo dõi việc sử dụng CPU). Hiệu suất tốt nhất đạt được khi mem (v) = 16kB, là thứ tự độ lớn của bộ đệm L1 (bộ đệm dữ liệu L1 cho i7-7700K là 4x32kB).
Tất nhiên, sự khác biệt ngày càng ít liên quan hơn khi bạn thực sự phải tính toán nhiều hơn trên dữ liệu. Dưới đây là kết quả nếu chúng tôi thay thế sum = std::accumulate(v.begin(), v.end(), sum);
bằng for (int k : v) sum += std::sqrt(2.0*k);
:
Kết luận
- Sử dụng các tham số đầu ra thay vì trả về theo giá trị có thể mang lại hiệu suất tăng bằng cách sử dụng lại công suất.
- Trên máy tính để bàn hiện đại, điều này dường như chỉ áp dụng cho các vectơ lớn (> 16MB) và vectơ nhỏ (<1kB).
- Tránh phân bổ hàng triệu / tỷ vectơ nhỏ (<1kB). Nếu có thể, hãy tái sử dụng công suất, hoặc tốt hơn, hãy thiết kế kiến trúc của bạn theo cách khác.
Kết quả có thể khác nhau trên các nền tảng khác. Như thường lệ, nếu hiệu suất quan trọng, hãy viết điểm chuẩn cho trường hợp sử dụng cụ thể của bạn.