В C++, это все-таки плохая практика, чтобы вернуть вектор из функции?



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



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



std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();


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



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


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



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



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

1190   7  

7 ответов:

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

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

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

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

суть:

копировать 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: "";
}

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

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

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

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

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

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

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

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

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

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

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);
}

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

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

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);
}

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

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);
}

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

Benchmark

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

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

здесь это код:

#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;
}

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

$ 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

Benchmark results

(Intel i7-7700K @ 4.20 GHz; 16GB DDR4 2400Mhz; Kubuntu 18.04)

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

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

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

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

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

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

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

обратите внимание, как кривая для time2 является более гладкой, чем кривая для time1: не только повторное использование векторной емкости, как правило, быстрее, но, возможно, что более важно, это больше предсказуемым.

также обратите внимание, что в сладком месте мы смогли выполнить 2 миллиарда добавлений 64-битных целых чисел за ~0,5 С, что вполне оптимально на 64-битном процессоре 4,2 ГГц. Мы могли бы сделать лучше, распараллелив вычисления, чтобы использовать все 8 ядер (тест выше использует только одно ядро за раз, что я проверил, повторно запустив тест при мониторинге использования ЦП). Наилучшая производительность достигается при 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);:

Benchmark 2

выводы

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

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

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

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

Comments

    Ничего не найдено.