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이므로 std::vector(N) RVO가 발생하지 않는다고 가정 하고의 이동 생성자를 사용하여 v가 생성됩니다 .

C ++ 0x 이전에도 첫 번째 형식은 (N) RVO 때문에 종종 “효율적”이었습니다. 그러나 (N) RVO는 컴파일러의 재량에 달려 있습니다. 이제 우리를 rvalue 참조를 가지고 그것을됩니다 보장 더 깊은 복사가 발생하지 것이다.

편집 : 질문은 실제로 최적화에 관한 것이 아닙니다. 표시된 두 형식 모두 실제 프로그램에서 거의 동일한 성능을 보입니다. 반면, 과거에는 첫 번째 형식이 성능이 훨씬 더 나쁠 수있었습니다. 그 결과 첫 번째 형태는 오랫동안 C ++ 프로그래밍에서 주요 코드 냄새였습니다. 더 이상은 아니겠습니까?



답변

Dave Abrahams는 값 전달 / 반환 속도에 대해 매우 포괄적 인 분석을 제공 합니다 .

짧은 대답, 값을 반환해야하는 경우 값을 반환합니다. 어쨌든 컴파일러가 수행하므로 출력 참조를 사용하지 마십시오. 물론주의 사항이 있으므로 해당 기사를 읽어야합니다.


답변

적어도 IMO는 일반적으로 좋지 않지만 효율성상의 이유로는 아닙니다 . 문제의 함수는 일반적으로 반복기를 통해 출력을 생성하는 일반 알고리즘으로 작성되어야하므로 좋지 않습니다. 반복기에서 작동하는 대신 컨테이너를 수락하거나 반환하는 거의 모든 코드는 의심스러운 것으로 간주되어야합니다.

오해하지 마세요. 컬렉션과 같은 객체 (예 : 문자열)를 전달하는 것이 합리적 일 때가 있지만 인용 된 예에서는 벡터를 전달하거나 반환하는 것이 좋지 않은 생각입니다.


답변

요점은 다음과 같습니다.

Copy Elision 및 RVO “무서운 복사본”을 피할 수 있습니다 (컴파일러는 이러한 최적화를 구현하는 데 필요하지 않으며 일부 상황에서는 적용 할 수 없습니다).

C ++ 0x RValue 참조 이를 보장 하는 문자열 / 벡터 구현을 허용 합니다.

오래된 컴파일러 / STL 구현을 포기할 수 있다면 벡터를 자유롭게 반환하십시오 (그리고 자신의 개체도이를 지원하는지 확인하십시오). 코드베이스가 “더 적은”컴파일러를 지원해야하는 경우 이전 스타일을 고수하십시오.

불행히도 이는 인터페이스에 큰 영향을 미칩니다. C ++ 0x가 옵션이 아니고 보증이 필요한 경우 일부 시나리오에서 참조 카운트 또는 쓰기시 복사 객체를 대신 사용할 수 있습니다. 하지만 멀티 스레딩에는 단점이 있습니다.

(C ++에서 단 하나의 답변이 조건없이 간단하고 간단하기를 바랍니다.)


답변

사실, C ++ (11) 이후의 비용 복사std::vector대부분의 경우에 사라 졌어요.

그러나 새로운 벡터 를 생성 하는 비용 (그런 다음이를 파괴 )은 여전히 ​​존재하며, 벡터의 용량을 재사용하려는 경우 값으로 반환하는 대신 출력 매개 변수를 사용하는 것이 여전히 유용하다는 점을 명심해야합니다 . 이것은 C ++ 핵심 지침의 F.20 에 예외로 문서화되어 있습니다.

비교해 봅시다 :

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

첫 번째 예에서는 불필요한 동적 할당 / 할당 해제가 많이 발생하며, 두 번째 예에서는 이전 방식으로 이미 할당 된 메모리를 재사용하여 출력 매개 변수를 사용하여 방지합니다. 이 최적화가 가치가 있는지 여부는 값을 계산 / 변이하는 비용과 비교하여 할당 / 할당 해제의 상대적 비용에 따라 다릅니다.

기준

vecSize및 의 값을 가지고 놀아 봅시다 numIter. vecSize * numIter를 일정하게 유지하여 “이론상”동일한 시간이 걸리고 (= 정확히 동일한 값을 가진 동일한 수의 할당 및 추가가 있음) 시간 차이는 다음 비용에서만 발생할 수 있습니다. 할당, 할당 해제 및 캐시의 더 나은 사용.

더 구체적으로, vecSize * numIter = 2 ^ 31 = 2147483648을 사용하겠습니다. 왜냐하면 16GB의 RAM이 있고이 숫자는 8GB 이하가 할당되도록 보장하기 때문입니다 (sizeof (int) = 4). 다른 모든 프로그램은 닫 혔고 테스트를 실행할 때 15GB를 사용할 수있었습니다.)

다음은 코드입니다.

#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

벤치 마크 결과

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

표기법 : mem (v) = v.size () * sizeof (int) = v.size () * 4 on my platform.

당연히 numIter = 1(즉, mem (v) = 8GB) 시간이 완벽하게 동일합니다. 실제로 두 경우 모두 메모리에 8GB의 거대한 벡터를 한 번만 할당합니다. 이것은 또한 BuildLargeVector1 ()을 사용할 때 복사가 발생하지 않았 음을 증명합니다. 복사를 수행하기에 충분한 RAM이 없을 것입니다!

numIter = 2경우 두 번째 벡터를 다시 할당하는 대신 벡터 용량을 재사용하면 1.37 배 더 빠릅니다.

경우 numIter = 256(… 반복해서 256 회 벡터를 할당 해제 / 대신 할당) 벡터 용량을 재사용하는 것은 빠른 2.45x이다 :

우리는 시간 1가에서 거의 일정한 것을 알 수 있습니다 numIter = 1numIter = 2568기가바이트 중 하나 큰 벡터를 할당하는 것은 거의 32메가바이트의 256 벡터를 할당하는만큼 비용이 많이 드는 것을 의미한다. 그러나 8GB의 거대한 벡터 하나를 할당하는 것은 32MB의 벡터 하나를 할당하는 것보다 확실히 더 비싸므로 벡터의 용량을 재사용하면 성능이 향상됩니다.

에서 numIter = 512(MEM (V) = 16메가바이트)에 numIter = 8M(MEM은 (V) = 1KB) 달콤한 장소입니다 : 두 가지 방법이 빠르게 numIter 및 vecSize의 모든 다른 조합보다 더 빨리 정확하게, 그리고. 이것은 아마도 내 프로세서의 L3 캐시 크기가 8MB라는 사실과 관련이 있으므로 벡터가 캐시에 거의 완벽하게 맞습니다. 나는 time1mem (v) = 16MB 의 갑작스러운 점프가 왜 mem (v) = 8MB 인 직후에 일어나는 것이 더 논리적으로 보일지 설명하지 않습니다 . 놀랍게도이 스위트 스팟에서는 용량을 재사용하지 않는 것이 실제로 약간 더 빠릅니다! 나는 이것을 정말로 설명하지 않는다.

numIter > 8M상황이 추악 해지기 시작할 때 . 두 방법 모두 느려지지만 값으로 벡터를 반환하는 것은 더 느려집니다. 최악의 경우 단일 단일을 포함하는 벡터의 경우 int값으로 반환하는 대신 용량을 재사용하는 것이 3.3 배 더 빠릅니다. 아마도 이것은 지배하기 시작하는 malloc ()의 고정 비용 때문일 것입니다.

time2에 대한 곡선이 time1에 대한 곡선보다 더 매끄럽다는 점에 유의하십시오. 벡터 용량을 재사용하는 것이 일반적으로 더 빠를뿐만 아니라 더 중요한 것은 더 예측 가능 하다는 것입니다 .

또한 스윗 스팟에서는 ~ 0.5 초 내에 20 억 개의 64 비트 정수를 추가 할 수 있었는데, 이는 4.2Ghz 64 비트 프로세서에서 매우 최적입니다. 8 개의 코어를 모두 사용하기 위해 계산을 병렬화하면 더 잘할 수 있습니다 (위의 테스트에서는 한 번에 하나의 코어 만 사용하며 CPU 사용량을 모니터링하면서 테스트를 다시 실행하여 확인했습니다). 최고의 성능은 mem (v) = 16kB 일 때 달성되며, 이는 L1 캐시의 크기입니다 (i7-7700K의 L1 데이터 캐시는 4x32kB).

물론 데이터에 대해 실제로 수행해야하는 계산이 많을수록 차이는 점점 더 관련성이 낮아집니다. 우리가 대체 할 경우 아래의 결과입니다 sum = std::accumulate(v.begin(), v.end(), sum);으로 for (int k : v) sum += std::sqrt(2.0*k);:

벤치 마크 2

결론

  1. 값으로 반환하는 대신 출력 매개 변수를 사용하면 용량을 재사용하여 성능을 향상 시킬 수 있습니다 .
  2. 최신 데스크톱 컴퓨터에서는 큰 벡터 (> 16MB)와 작은 벡터 (<1kB)에만 적용 할 수 있습니다.
  3. 수백만 / 십억 개의 작은 벡터 (<1kB)를 할당하지 마십시오. 가능하면 용량을 재사용하거나 더 나은 방법으로 아키텍처를 다르게 설계하십시오.

다른 플랫폼에서는 결과가 다를 수 있습니다. 평소처럼 성능이 중요한 경우 특정 사용 사례에 대한 벤치 마크를 작성하십시오.


답변

여전히 나쁜 습관이라고 생각하지만 우리 팀이 MSVC 2008 및 GCC 4.1을 사용하고 있으므로 최신 컴파일러를 사용하지 않는다는 점에 주목할 가치가 있습니다.

이전에는 MSVC 2008과 함께 vtune에 표시된 많은 핫스팟이 문자열 복사로 내려갔습니다. 다음과 같은 코드가 있습니다.

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 참조를 사용할 수있을 때를 고대하고 있으며 복잡한 코드를 반환하여 코드를 비관적으로 사용하는 것에 대해 걱정할 필요가 없습니다. 가치에 의한 클래스.

[편집] Nate가 지적했듯이 RVO는 함수 내부에서 생성 된 임시 반환에 적용됩니다. 제 경우에는 그러한 임시 (빈 문자열을 구성하는 유효하지 않은 분기 제외)가 없었으므로 RVO를 적용 할 수 없었을 것입니다.


답변

간단히 말해서, 많은 프로그래밍 언어에서 함수에서 배열을 반환하는 것은 일반적이지 않습니다. 대부분의 경우 배열에 대한 참조가 반환됩니다. C ++에서 가장 가까운 비유는 다음과 같습니다.boost::shared_array


답변

성능이 진짜 문제라면 이동 의미론이 항상 복사보다 빠르지 는 않다는 것을 알아야합니다 . 예를 들어 작은 문자열 최적화 를 사용하는 문자열이있는 경우 작은 문자열의 경우 이동 생성자가 일반 복사 생성자와 똑같은 작업을 수행해야합니다.