В C ++ существует ли еще плохая практика вернуть вектор из функции?

Короткая версия. Обычно во многих языках программирования возвращаются большие объекты, такие как векторы / массивы. Является ли этот стиль приемлемым в C ++ 0x, если class имеет конструктор перемещения или программисты на С ++ считают его странным / уродливым / мерзостью?

Длинная версия: В C ++ 0x это все еще считается плохой формой?

std::vector BuildLargeVector(); ... std::vector v = BuildLargeVector(); 

Традиционная версия будет выглядеть так:

 void BuildLargeVector(std::vector& result); ... std::vector v; BuildLargeVector(v); 

В новой версии значение, возвращаемое из BuildLargeVector является rvalue, поэтому v будет построено с использованием конструктора move std::vector , если предположить, что (N) RVO не выполняется.

Даже до C ++ 0x первая форма часто была бы «эффективной» из-за (N) RVO. Однако (N) RVO находится по усмотрению компилятора. Теперь, когда у нас есть ссылки rvalue, гарантируется отсутствие глубокой копии.

Изменить : вопрос действительно не о оптимизации. Обе представленные формы имеют почти идентичную производительность в реальных программах. Принимая во внимание, что в прошлом первая форма могла иметь худшие показатели по порядку величины. В результате первая форма была основным запахом кода в программировании на C ++ в течение длительного времени. Надеюсь, не больше?

Дэйв Абрахамс имеет довольно всесторонний анализ скорости передачи / возвращения значений .

Короткий ответ, если вам нужно вернуть значение, верните значение. Не используйте выходные ссылки, потому что компилятор это делает. Конечно, есть предостережения, поэтому вы должны прочитать эту статью.

По крайней мере, ИМО, это, как правило, плохая идея, но не по соображениям эффективности. Это плохая идея, потому что рассматриваемая функция обычно должна быть написана как общий алгоритм, который выводит свой результат через iterator. Почти любой код, который принимает или возвращает контейнер вместо работы на iteratorах, должен считаться подозрительным.

Не поймите меня неправильно: бывают времена, когда имеет смысл передавать объекты, подобные друг другу (например, строки), но в приведенном примере я бы рассматривал передачу или возrotation вектора в плохую идею.

Суть заключается в следующем:

Скопировать Elision и RVO можно избежать «страшных копий» (компилятор не требуется для реализации этих оптимизаций, и в некоторых ситуациях он не может применяться)

Ссылки на C ++ 0x RValue позволяют реализовать реализацию строк / векторов.

Если вы можете отказаться от старых компиляторов / реализаций STL, верните векторы свободно (и убедитесь, что ваши собственные объекты также поддерживают его). Если ваша база кода должна поддерживать «меньшие» компиляторы, придерживайтесь старого стиля.

К сожалению, это имеет большое влияние на ваши интерфейсы. Если C ++ 0x не является опцией, и вам нужны гарантии, вы можете использовать вместо них объекты с подсчетом ссылок или копирования на запись в некоторых сценариях. Тем не менее, у них есть недостатки в многопоточности.

(Я бы хотел, чтобы один ответ на C ++ был простым и понятным и без каких-либо условий).

Я все еще думаю, что это плохая практика, но стоит отметить, что моя команда использует MSVC 2008 и GCC 4.1, поэтому мы не используем последние компиляторы.

Ранее многие горячие точки, показанные в vtune с MSVC 2008, дошли до строкового копирования. У нас был такой код:

 String Something::id() const { return valid() ? m_id: ""; } 

… отметим, что мы использовали собственный тип String (это было необходимо, потому что мы предоставляем набор для разработки программного обеспечения, в котором разработчики плагинов могут использовать разные компиляторы и, следовательно, разные, несовместимые реализации std :: string / std :: wstring).

Я сделал простое изменение в ответ на сеанс профилирования выборки диаграммы вызовов, показывающий, что String :: String (const String &) займет значительное количество времени. Методы, подобные приведенному выше, были наибольшими вкладчиками (на самом деле сеанс профилирования показал, что распределение памяти и освобождение памяти являются одним из самых больших горячих точек, причем основным источником для распределения является конструктор экземпляра String).

Изменение, которое я сделал, было простым:

 static String null_string; const String& Something::id() const { return valid() ? m_id: null_string; } 

Тем не менее это создало мир разницы! Точка доступа ушла в последующие сеансы профилировщика, и в дополнение к этому мы делаем много тщательного модульного тестирования, чтобы отслеживать нашу производительность приложения. После этих простых изменений все виды тестов производительности значительно снизились.

Заключение: мы не используем абсолютные последние компиляторы, но мы по-прежнему не можем зависеть от того, как компилятор оптимизирует процесс копирования для достоверного возврата (по крайней мере, не во всех случаях). Это может быть не для тех, кто использует более новые компиляторы, такие как MSVC 2010. Я с нетерпением жду, когда мы сможем использовать C ++ 0x и просто будем использовать ссылки rvalue, и вам никогда не придется беспокоиться о том, что мы пессимизируем наш код, возвращая сложный classы по значению.

[Изменить] Как указал Нейт, RVO применяется к возвращаемым временным рядам, созданным внутри функции. В моем случае таких временных рядов не было (кроме недействительной ветви, где мы строим пустую строку), и поэтому RVO не применимо.

Действительно, поскольку C ++ 11, стоимость копирования std::vector ушла в большинстве случаев.

Однако следует иметь в виду, что стоимость построения нового вектора (тогда его разрушение ) все еще существует, и использование выходных параметров вместо возврата по значению по-прежнему полезно, когда вы хотите повторно использовать емкость вектора. Это задокументировано как исключение в F.20 Основных принципов C ++.

Давайте сравним:

 std::vector BuildLargeVector1(size_t vecSize) { return std::vector(vecSize, 1); } 

с:

 void BuildLargeVector2(/*out*/ std::vector& v, size_t vecSize) { v.assign(vecSize, 1); } 

Теперь предположим, что нам нужно вызывать эти методы numIter время в узком цикле и выполнять некоторые действия. Например, давайте вычислим сумму всех элементов.

Используя BuildLargeVector1 , вы бы сделали:

 size_t sum1 = 0; for (int i = 0; i < numIter; ++i) { std::vector v = BuildLargeVector1(vecSize); sum1 = std::accumulate(v.begin(), v.end(), sum1); } 

Используя BuildLargeVector2 , вы бы сделали:

 size_t sum2 = 0; std::vector v; for (int i = 0; i < numIter; ++i) { BuildLargeVector2(/*out*/ v, vecSize); sum2 = std::accumulate(v.begin(), v.end(), sum2); } 

В первом примере существует много ненужных динамических распределений / деаллокаций, которые предотвращаются во втором примере, используя выходной параметр по-старому, повторно используя уже выделенную память. Независимо от того, стоит ли эта оптимизация, зависит от относительной стоимости распределения / освобождения по сравнению со стоимостью вычисления / изменения значений.

эталонный тест

Давайте сыграем со значениями vecSize и numIter . Мы будем поддерживать константу vecSize * numIter так, чтобы «теоретически» она должна занимать одно и то же время (= имеется одинаковое количество присвоений и дополнений с одинаковыми значениями), а разница во времени может быть получена только из стоимости распределения, освобождения и более эффективного использования кеша.

В частности, давайте использовать vecSize * numIter = 2 ^ 31 = 2147483648, потому что у меня 16 ГБ ОЗУ, и это число гарантирует, что выделено не более 8 ГБ (sizeof (int) = 4), гарантируя, что я не переключаюсь на диск ( все остальные программы были закрыты, у меня было ~ 15 ГБ при запуске теста).

Вот код:

 #include  #include  #include  #include  #include  class Timer { using clock = std::chrono::steady_clock; using seconds = std::chrono::duration; clock::time_point t_; public: void tic() { t_ = clock::now(); } double toc() const { return seconds(clock::now() - t_).count(); } }; std::vector BuildLargeVector1(size_t vecSize) { return std::vector(vecSize, 1); } void BuildLargeVector2(/*out*/ std::vector& 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 v = BuildLargeVector1(vecSize); sum1 = std::accumulate(v.begin(), v.end(), sum1); } } double time1 = t.toc(); t.tic(); size_t sum2 = 0; { std::vector 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; } 

И вот результат:

 $ 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)

Обозначение: mem (v) = v.size () * sizeof (int) = v.size () * 4 на моей платформе.

Неудивительно, что когда numIter = 1 (т. numIter = 1 (v) = 8 ГБ), времена совершенно идентичны. В самом деле, в обоих случаях мы выделяем один раз огромный вектор размером 8 ГБ в памяти. Это также доказывает, что при использовании BuildLargeVector1 () не произошло никакой копии: у меня не хватило бы оперативной памяти для копирования!

Когда numIter = 2 , повторное использование векторной емкости вместо повторного выделения второго вектора на 1,37 раза быстрее.

Когда numIter = 256 , повторное использование векторной емкости (вместо выделения / деаллокации вектора снова и снова 256 раз ...) на 2.45x быстрее 🙂

Мы можем заметить, что time1 в значительной степени постоянен от numIter = 1 до numIter = 256 , а это означает, что выделение одного огромного вектора 8GB в значительной степени дорогостоящее, чем выделение 256 векторов 32 МБ. Однако выделение одного огромного вектора 8 ГБ является, безусловно, более дорогостоящим, чем выделение одного вектора 32 МБ, поэтому повторное использование емкости вектора обеспечивает прирост производительности.

От numIter = 512 (mem (v) = 16MB) до numIter = 8M (mem (v) = 1kB) - это сладкое пятно: оба метода точно такие же быстрые и быстрее, чем все другие комбинации numIter и vecSize. вычисление, два метода в значительной степени быстро. Вероятно, это связано с тем, что размер кеша L3 моего процессора составляет 8 МБ, так что вектор почти полностью вписывается в кеш. Я действительно не объясняю, почему внезапный скачок time1 для mem (v) = 16 МБ, было бы логичнее, как только после этого, когда mem (v) = 8 МБ. Обратите внимание, что на удивление, в этом сладком месте, не повторное использование емкости на самом деле немного быстрее! Я этого не объясняю.

Когда numIter > 8M вещи начинают становиться уродливыми. Оба метода замедляются, но возврат вектора по значению становится еще медленнее. В худшем случае с вектором, содержащим только один единственный int , повторное использование емкости вместо возврата по значению в 3,3 раза быстрее. Предположительно, это связано с фиксированными затратами на malloc (), которые начинают доминировать.

Обратите внимание на то, что кривая для времени2 более гладкая, чем кривая для времени1: не только повторная использование векторной емкости, как правило, быстрее, но, возможно, что более важно, она более предсказуема .

Также обратите внимание, что в сладком месте мы смогли выполнить 2 миллиарда дополнений из 64-битных целых чисел в ~ 0,5 с, что вполне оптимально на 64-битном процессоре с частотой 4,2 ГГц. Мы могли бы добиться большего, распараллеливая вычисления, чтобы использовать все 8 ядер (в приведенном выше тесте используется только одно kernel ​​за раз, которое я проверил путем повторного запуска теста при мониторинге использования ЦП). Наилучшая производительность достигается, когда mem (v) = 16kB, который является порядком величины кеша L1 (кеш данных L1 для i7-7700K равен 4x32kB).

Разумеется, различия становятся все менее актуальными, чем больше вычислений, которые вы на самом деле должны делать с данными. Ниже приведены результаты, если мы заменим sum = std::accumulate(v.begin(), v.end(), sum); by for (int k : v) sum += std::sqrt(2.0*k); :

Тест 2

Выводы

  1. Использование выходных параметров вместо возврата по значению может обеспечить прирост производительности за счет повторного использования емкости.
  2. На современном настольном компьютере это кажется применимым только для больших векторов (> 16 МБ) и небольших векторов (<1 кБ).
  3. Избегайте выделения миллионов / миллиардов маленьких векторов (<1 кБ). Если возможно, повторите использование емкости или, еще лучше, спроектируйте свою архитектуру по-разному.

Результаты могут отличаться на других платформах. Как обычно, если производительность важна, напишите тесты для вашего конкретного варианта использования.

Просто немного подшучивать: во многих языках программирования нередко возвращать массивы из функций. В большинстве из них возвращается ссылка на массив. В C ++ ближайшая аналогия будет возвращать boost::shared_array

Если производительность является реальной проблемой, вы должны понимать, что семантика перемещения не всегда быстрее, чем копирование. Например, если у вас есть строка, которая использует небольшую оптимизацию строк, то для небольших строк конструктор перемещения должен выполнять ту же работу, что и обычный конструктор копирования.

Давайте будем гением компьютера.