Devin.KR

성능을 위한 설계 - 메모리 배치·복사 줄이기·측정

개발자KR 조회 8

이 장에서 배우는 것

앞 장에서 비동기 작업의 결과를 기다리는 방법을 살펴보았다. 작업을 여러 곳에서 실행할 수 있어도 각 작업이 불필요한 복사를 하거나 메모리를 비효율적으로 읽으면 처리량은 기대만큼 늘지 않는다. 이 장에서는 주문 접수와 검증을 마친 작은 주문 처리 엔진에서 체결 로그용 금액을 집계하는 부분을 살펴본다. 실행 시간을 줄이기 전에 어떤 비용을 줄이려는지 정하고, 결과가 같은지 확인한 뒤 측정하는 순서를 익힌다.

성능 설계는 짧은 코드를 고르는 일이 아니다. 소유권이 분명한 값 타입을 유지하면서 불필요한 복사를 없애고, 함께 사용하는 데이터를 가까이 두며, 실제 사용 조건에 맞는 수치를 얻는 일이다. 마지막에는 이러한 판단을 반복할 수 있는 작은 실험 프로그램을 완성한다.

  • 값 의미론(value semantics)의 장점을 유지하면서 복사와 이동의 발생 횟수, 실제 실행 비용을 구분한다.
  • reserve가 크기와 용량에 미치는 영향을 설명하고 재할당 경계를 다룬다.
  • 구조체 배열과 배열 구조체를 접근 패턴에 맞추어 비교한다.
  • 결과 검증, 준비 구간 분리, 반복 측정으로 벤치마크의 신뢰도를 높인다.
  • 측정값에 섞인 최적화 착시를 찾아내고 변경의 적용 범위를 판단한다.

문제 상황

주문 엔진은 검증을 통과한 주문을 벡터에 모은다. 각 주문에는 가격, 수량, 접수 경로를 설명하는 문자열이 있다. 장 마감 때에는 일정 수량 이상인 주문의 금액을 합산해 체결 로그의 집계 필드로 남긴다. 실제 체결 가격 결정과 로그 파일 기록은 이미 끝났다고 가정하고, 여기서는 메모리에 있는 기록의 집계만 다룬다.

처음 구현은 읽기 쉽다. 주문 한 건을 구조체 하나로 표현하고 벡터에 추가한 뒤 전체를 순회한다. 그런데 주문 수가 커지자 두 가지 의문이 생긴다. 수집 중 벡터가 여러 번 자라면서 문자열까지 복사하는지, 집계에 필요하지 않은 설명 문자열 때문에 필요한 숫자가 메모리에서 멀리 떨어져 있는지다.

곧바로 모든 주문을 포인터로 바꾸면 소유권과 간접 접근 비용이 늘어난다. 숫자 필드를 분리하면 집계는 유리해질 수 있지만 주문 추가와 삭제가 복잡해진다. 먼저 수집 단계의 복사와 집계 단계의 읽기 비용을 별개의 실험으로 나눈다. 최적화할 대상이 서로 다르기 때문이다.

이 장의 프로그램도 두 실험을 구분한다. 작은 객체로 복사 생성과 이동 생성 횟수를 확인하고, 별도의 주문 자료로 메모리 배치에 따른 집계 시간을 비교한다. 횟수를 세기 위한 계측 코드를 실제 시간 측정 대상에 섞지 않는다.

값의 소유권과 벡터의 성장 비용

복사 횟수는 비용의 단서다

값으로 보관한 주문은 컨테이너가 자신의 데이터를 소유한다. 외부 객체가 사라져도 주문 정보가 유지되고, 복사본을 수정해도 원본 주문은 바뀌지 않는다. 이 단순한 관계는 최적화 이후에도 지킬 가치가 있다. 값으로 저장한다는 이유만으로 복사가 항상 많이 발생하는 것도 아니다. 원본이 더 필요 없으면 이동할 수 있고, 반환 과정에서는 복사 생략이 적용될 수도 있다.

반면 복사 생성자가 한 번 호출되었다는 사실만으로 비용을 알 수는 없다. 정수 몇 개의 복사와 긴 문자열을 소유한 객체의 복사는 수행하는 일이 다르다. 작은 문자열은 구현에 따라 내부 저장 공간에 들어갈 수 있으므로 문자열 길이도 실험 조건에 포함해야 한다. 완성 코드에서는 길이 80의 문자열을 사용하지만, 이 길이에서 동적 할당이 발생한다는 사실을 언어 규칙으로 가정하지 않는다.

이동도 비용이 없는 연산은 아니다. 동적 자원의 소유권을 넘기는 이동은 큰 복사보다 저렴한 경우가 많지만, 이동의 실제 동작은 타입에 달려 있다. 특히 std::move는 이동을 수행하는 함수가 아니라 이동 가능한 형태로 표현식을 변환하는 도구다. 원본이 const이면 일반적인 이동 생성자가 선택되지 않아 복사가 일어날 수 있다.

벡터가 기존 원소를 새 저장 공간으로 옮길 때에는 예외 안전성도 고려한다. 이동 중 예외가 발생할 수 있고 복사가 가능하면 기존 원소를 복사하는 구현이 일반적이다. 자원 이동이 실제로 예외를 던지지 않는 타입에는 그 성질을 정확히 표현한다. 성능만을 위해 사실과 다른 noexcept를 붙이면 예외 발생 시 프로그램이 종료되는 다른 문제가 생긴다.

reserve는 원소를 만들지 않는다

size()는 살아 있는 원소 수이고 capacity()는 재할당 없이 보관할 수 있는 원소 수다. reserve(n)은 용량을 적어도 n까지 확보하도록 요청한다. 크기는 바꾸지 않으며, 요청값이 현재 용량 이하라면 재할당하지 않는다. 확보된 빈 용량에는 첨자 연산으로 새 원소를 쓸 수 없다.

추가할 주문 수의 합리적인 상한을 안다면 수집 전에 한 번 예약한다. 용량 안에서 끝에 원소를 추가하면 기존 원소를 다른 저장 공간으로 옮길 필요가 없다. 그러나 들어오는 주문마다 reserve(size() + 1)을 호출하면 컨테이너의 성장 전략을 방해할 수 있다. 용량이 매번 조금씩만 늘어나는 구현에서는 누적 이전 비용이 크게 증가한다.

재할당이 발생하면 기존 원소를 가리키던 포인터, 참조, 반복자가 모두 무효화된다. 예약은 이러한 무효화를 일시적으로 피하게 해 주지만, 주소 안정성을 타입의 계약으로 만들어 주지는 않는다. 예약한 용량을 넘어설 수 있다면 원소의 주소를 장기간 보관하지 않는 설계가 필요하다.

벡터의 크기와 용량은 서로 다른 동작을 제어한다
연산크기 변화기존 원소 주소주요 비용
reserve(n)변하지 않는다재할당 시 무효화된다저장 공간 확보와 원소 이전
resize(n)n이 된다재할당 시 무효화된다원소 생성 또는 파괴
용량 내 끝 삽입하나 증가한다기존 원소 주소는 유지된다새 원소 생성
용량 초과 끝 삽입하나 증가한다모두 무효화된다새 공간 확보와 기존 원소 이전
reserve는 원소 수를 늘리지 않으며 재할당이 발생하면 기존 원소 주소가 무효화된다

접근 패턴에 맞추어 메모리를 배치한다

구조체 배열(Array of Structures, AoS)은 주문 한 건의 필드들을 하나로 묶고 그 객체들을 연속 배치한다. std::vector<Order>가 대표적인 형태다. 한 주문의 가격, 수량, 설명을 함께 읽는 검증이나 로그 작성에서는 관계가 명확하고 사용하기도 쉽다.

배열 구조체(Structure of Arrays, SoA)는 가격 벡터, 수량 벡터, 설명 벡터를 따로 두고 같은 인덱스가 같은 주문을 나타내도록 만든다. 금액 집계가 가격과 수량만 읽는다면 필요한 숫자가 각각 조밀하게 놓인다. 설명 필드의 저장 공간을 건너뛰며 다음 숫자를 찾아갈 필요가 줄어든다.

프로세서는 메모리의 값을 하나씩만 가져오는 것이 아니라 캐시 라인(cache line)이라는 일정한 블록 단위로 가까운 데이터를 함께 가져온다. 구조체가 크고 그중 일부만 사용한다면 가져온 공간 중 실제 계산에 기여하는 비율이 낮아질 수 있다. 필드를 분리하면 같은 공간에 필요한 값이 더 많이 담길 가능성이 있다. 구체적인 캐시 라인 크기와 효과는 대상 하드웨어에서 확인해야 한다.

여기서 문자열 객체와 문자열 내용을 구분해야 한다. 구조체 배열에는 std::string 객체 자체가 들어 있다. 긴 문자열의 문자는 별도 저장 공간에 있을 수 있다. 집계에서 설명을 읽지 않는다고 해서 그 문자 전체를 매번 가져오는 것은 아니다. 이번 실험이 줄이려는 것은 주로 문자열 객체와 정렬 여백 때문에 숫자 필드 사이에 생기는 간격이다.

필드 분리가 항상 유리하지는 않다. 한 주문의 모든 필드를 읽는 작업에서는 여러 벡터에 접근해야 한다. 원소를 지울 때에는 모든 벡터에서 같은 위치를 처리해야 하며, 추가 중 일부 할당만 성공했을 때 길이 불일치가 생기지 않도록 관리해야 한다. 운영 코드에서는 병렬 벡터를 공개하기보다 길이와 인덱스 관계를 지키는 타입 안에 감추는 편이 좋다.

완성 코드의 변환 함수는 지역 객체 안에서 각 벡터를 채운 뒤 결과를 반환한다. 생성 중 예외가 발생하면 지역 객체가 정리되므로 미완성 결과가 호출자에게 공개되지 않는다. 변환 뒤에는 수정하지 않는 자료로 취급한다. 설명 벡터도 유지하여 두 표현이 같은 정보를 보유하도록 한다.

가격과 수량만 순회하는 집계에서는 필드별 배열이 필요한 숫자를 더 조밀하게 배치한다

측정은 비교 조건을 설계하는 일이다

벤치마크(benchmark)를 만들 때에는 먼저 질문을 문장으로 쓴다. 이번 질문은 “이미 준비된 주문 집합에서 수량 조건에 맞는 금액을 반복 집계할 때 어느 배치가 빠른가”다. 따라서 주문 생성, 문자열 할당, 배치 변환, 결과 출력은 측정 구간에서 제외한다. 서비스가 요청마다 배치를 변환한다면 이 실험만으로 응답 시간 개선을 주장할 수는 없다. 그때에는 변환을 포함한 별도 측정이 필요하다.

시간은 std::chrono::steady_clock으로 잰다. 달력 시각의 보정과 관계없이 구간의 경과 시간을 측정하기 위한 선택이다. 그래도 타이머 호출 자체의 비용과 해상도는 남는다. 너무 짧은 연산 하나를 재기보다 여러 번의 집계를 하나의 측정 구간에 넣고, 전체 시간과 작업량을 함께 기록한다.

완성 코드는 배치별로 먼저 한 번 실행해 결과를 비교한다. 이어서 각각 다섯 번 측정하고 실행 순서를 번갈아 바꾼다. 먼저 실행한 쪽만 늘 유리하거나 불리해지는 영향을 줄이려는 조치다. 시간은 정렬한 뒤 중앙값을 출력한다. 중앙값은 일시적인 지연의 영향을 줄일 뿐, 열 상태 변화나 실행 순서의 편향까지 제거하지는 않는다.

측정 대상의 계산 결과는 최종 출력에 반영한다. 사용하지 않는 계산을 최적화기가 지우는 일을 줄이기 위해서다. 다만 결과를 출력한다고 소스에 적은 모든 반복이 그대로 실행되는 것은 아니다. 컴파일러는 결과가 같음을 증명하면 반복을 합치거나 계산을 옮길 수 있다. steady_clock::now()도 모든 최적화를 막는 장벽은 아니다. 판단이 중요한 측정이라면 생성된 기계어와 프로파일 결과를 함께 살펴본다.

이번 실험은 같은 자료를 여러 번 읽는 반복 집계에 가깝다. 첫 접근의 페이지 준비 비용이나 디스크 입력, 주문 접수 경쟁은 다루지 않는다. 또한 반복 전 실행을 했다고 전체 자료가 캐시에 들어갔다고 단정할 수 없다. 자료 크기와 캐시 크기에 따라 결과가 달라진다.

완성 코드

다음 내용을 perf_design.cpp로 저장한다. 기본 실행은 재현 가능한 복사 횟수와 집계 결과를 출력한다. --bench를 지정하면 더 큰 자료를 만들고 시간 측정 결과를 추가한다. 기본 주문 네 건은 모두 검증을 통과했다고 가정한다.

#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

struct Tracked {
    std::string text;
    inline static std::size_t copies = 0;
    inline static std::size_t moves = 0;

    explicit Tracked(std::string value)
        : text(std::move(value)) {}

    Tracked(const Tracked& other) : text(other.text) {
        ++copies;
    }

    Tracked(Tracked&& other) noexcept
        : text(std::move(other.text)) {
        ++moves;
    }

    Tracked& operator=(const Tracked&) = default;
    Tracked& operator=(Tracked&&) noexcept = default;
};

std::pair<std::size_t, std::size_t> copy_probe(bool move_source) {
    std::vector<Tracked> source;
    source.reserve(4);
    for (int i = 0; i < 4; ++i) {
        source.emplace_back(std::string(80, 'x'));
    }

    std::vector<Tracked> target;
    target.reserve(source.size());
    Tracked::copies = 0;
    Tracked::moves = 0;

    for (auto& item : source) {
        if (move_source) {
            target.push_back(std::move(item));
        } else {
            target.push_back(item);
        }
    }
    return {Tracked::copies, Tracked::moves};
}

struct Order {
    std::uint64_t price;
    std::uint32_t quantity;
    std::string note;
};

struct Columns {
    std::vector<std::uint64_t> prices;
    std::vector<std::uint32_t> quantities;
    std::vector<std::string> notes;
};

std::vector<Order> make_orders(std::size_t count) {
    std::vector<Order> orders;
    orders.reserve(count);
    for (std::size_t i = 0; i < count; ++i) {
        orders.push_back(Order{
            100 + static_cast<std::uint64_t>(i % 100),
            static_cast<std::uint32_t>(1 + i % 4),
            std::string(80, 'x')
        });
    }
    return orders;
}

Columns make_columns(const std::vector<Order>& orders) {
    Columns result;
    result.prices.reserve(orders.size());
    result.quantities.reserve(orders.size());
    result.notes.reserve(orders.size());
    for (const auto& order : orders) {
        result.prices.push_back(order.price);
        result.quantities.push_back(order.quantity);
        result.notes.push_back(order.note);
    }
    return result;
}

std::uint64_t scan(const std::vector<Order>& orders,
                   std::uint32_t minimum) {
    std::uint64_t total = 0;
    for (const auto& order : orders) {
        if (order.quantity >= minimum) {
            total += order.price * order.quantity;
        }
    }
    return total;
}

std::uint64_t scan(const Columns& columns,
                   std::uint32_t minimum) {
    std::uint64_t total = 0;
    for (std::size_t i = 0; i < columns.prices.size(); ++i) {
        if (columns.quantities[i] >= minimum) {
            total += columns.prices[i] * columns.quantities[i];
        }
    }
    return total;
}

template <class Data>
std::uint64_t batch(const Data& data) {
    std::uint64_t total = 0;
    for (std::uint32_t round = 0; round < 12; ++round) {
        total += scan(data, 1 + round % 4);
    }
    return total;
}

template <class Data>
double measure(const Data& data, std::uint64_t& checksum) {
    const auto begin = std::chrono::steady_clock::now();
    const auto value = batch(data);
    const auto end = std::chrono::steady_clock::now();
    checksum += value;
    return std::chrono::duration<double, std::micro>(
        end - begin).count();
}

void run_benchmark() {
    const auto orders = make_orders(65536);
    const auto columns = make_columns(orders);
    const auto expected = batch(orders);
    if (batch(columns) != expected) {
        throw std::runtime_error("warmup mismatch");
    }

    std::array<double, 5> aos_times{};
    std::array<double, 5> soa_times{};
    std::uint64_t aos_checksum = 0;
    std::uint64_t soa_checksum = 0;

    for (std::size_t trial = 0; trial < aos_times.size(); ++trial) {
        if (trial % 2 == 0) {
            aos_times[trial] = measure(orders, aos_checksum);
            soa_times[trial] = measure(columns, soa_checksum);
        } else {
            soa_times[trial] = measure(columns, soa_checksum);
            aos_times[trial] = measure(orders, aos_checksum);
        }
    }

    if (aos_checksum != expected * aos_times.size() ||
        soa_checksum != aos_checksum) {
        throw std::runtime_error("measurement mismatch");
    }

    std::sort(aos_times.begin(), aos_times.end());
    std::sort(soa_times.begin(), soa_times.end());
    std::cout << "orders: " << orders.size() << '\n';
    std::cout << "sizeof(Order): " << sizeof(Order) << '\n';
    std::cout << "AoS median us / 12 scans: " << aos_times[2] << '\n';
    std::cout << "SoA median us / 12 scans: " << soa_times[2] << '\n';
    std::cout << "checksum: " << aos_checksum << '\n';
}

int main(int argc, char* argv[]) {
    const auto copied = copy_probe(false);
    const auto moved = copy_probe(true);
    const auto orders = make_orders(4);
    const auto columns = make_columns(orders);
    const auto aos_total = scan(orders, 3);
    const auto soa_total = scan(columns, 3);

    if (aos_total != soa_total) {
        throw std::runtime_error("total mismatch");
    }

    std::cout << "copy copies=" << copied.first
              << " moves=" << copied.second << '\n';
    std::cout << "move copies=" << moved.first
              << " moves=" << moved.second << '\n';
    std::cout << "AoS total: " << aos_total << '\n';
    std::cout << "SoA total: " << soa_total << '\n';
    std::cout << "equal: " << (aos_total == soa_total) << '\n';

    if (argc == 2 && std::string_view(argv[1]) == "--bench") {
        run_benchmark();
    }
}

줄별 해설

Tracked의 text는 복사와 이동을 관찰할 자원이다. 두 정적 카운터는 복사 생성자와 이동 생성자가 호출될 때만 증가한다. 대입 연산자는 기본 동작을 사용하므로 대입 횟수는 세지 않는다. 따라서 이 타입을 다른 실험에 재사용할 때 카운터를 모든 복사 연산의 합으로 해석해서는 안 된다.

copy_probe의 source.reserve(4)는 준비 과정의 재할당을 피한다. emplace_back은 문자열을 받아 원소를 직접 생성한다. 이때도 문자열을 만들고 옮기는 동작은 존재하지만 Tracked의 복사 생성이나 이동 생성과는 구별된다.

target.reserve(source.size()) 다음에 카운터를 초기화한다. 이후 추가할 네 원소는 확보한 용량 안에 들어가므로 기존 대상 원소의 이전은 없다. push_back(item)은 원본을 유지하는 복사를, push_back(std::move(item))은 원본 자원의 이동을 관찰한다. 두 호출은 서로 다른 함수 실행에서 새로 만든 원본을 사용한다.

Order의 가격은 정수 단위다. 이 예제에는 소수 금액의 반올림 문제가 필요하지 않으므로 부동소수점 타입을 쓰지 않는다. make_orders는 가격을 100부터 199까지, 수량을 1부터 4까지 반복한다. 이번 자료 크기와 반복 횟수에서 곱셈과 누적 결과는 std::uint64_t 범위 안에 들어간다. 운영 자료의 상한이 다르면 별도로 범위를 검증해야 한다.

return orders;와 return result;는 지역 객체를 그대로 반환한다. 여기에 습관적으로 std::move를 덧붙이지 않는다. 이름 있는 지역 객체의 반환에는 반환값 최적화가 적용될 수 있으며, 적용되지 않아도 이 코드의 벡터와 집합 타입은 이동할 수 있다.

make_columns는 세 벡터의 용량을 먼저 확보하고 같은 순서로 값을 넣는다. 설명 문자열을 복사하는 변환 비용은 존재한다. 다만 이번 질문이 변환 완료 후 집계 비용이므로 measure를 호출하기 전에 끝낸다. 더 빠른 집계를 얻기 위해 추가 표현을 보관하는 메모리 비용도 이 선택에 포함된다.

두 scan 함수는 같은 조건과 같은 정수 계산을 수행한다. 구조체 배열에서는 범위 기반 반복문으로 주문을 읽고, 배열 구조체에서는 동일한 인덱스로 수량과 가격을 연결한다. 두 번째 함수는 벡터 길이가 같다는 불변식에 의존한다. 이 불변식이 깨지면 단순한 성능 문제가 아니라 잘못된 접근이 된다.

batch는 최소 수량을 1, 2, 3, 4로 바꾸며 총 열두 번 집계한다. 조건을 바꾸어 한 가지 선택 비율만 측정하지 않도록 했다. 다만 실제 주문의 수량 분포가 이 반복 패턴과 다르면 분기와 계산 비용도 달라질 수 있다.

measure에서 두 시각 사이에는 집계만 들어간다. 체크섬 누적과 출력은 구간 밖이다. 반환하는 값은 마이크로초 단위의 실수이며 열두 번의 집계 전체 시간이다. 한 번의 집계 시간으로 읽으려면 12로 나누어야 한다.

run_benchmark는 사전 실행 결과를 기준값으로 삼아 측정 후 합계도 검증한다. 시간 배열을 정렬한 뒤 인덱스 2를 선택하므로 다섯 값의 중앙값이 나온다. sizeof(Order)는 현재 구현에서 원소가 차지하는 공간을 알려 주지만, 문자열이 별도로 확보한 문자 저장 공간까지 포함하지는 않는다.

실행 결과

기본 실행에는 시간값이 없으므로 다음 출력이 나온다. 수량 조건을 만족하는 주문은 가격 102에 수량 3인 주문과 가격 103에 수량 4인 주문이다. 금액 합은 306과 412를 더한 718이다.

c++ -std=c++20 -Wall -Wextra -pthread perf_design.cpp -o perf_design
./perf_design
copy copies=4 moves=0
move copies=0 moves=4
AoS total: 718
SoA total: 718
equal: 1

시간을 비교할 때에는 최적화 옵션을 명시해 다시 빌드한다. 아래 실행은 위의 다섯 줄 뒤에 주문 수, 구조체 크기, 배치별 중앙값, 체크섬을 추가로 출력한다. 구조체 크기는 표준 라이브러리와 ABI에 따라 달라지고 시간은 실행마다 달라지므로 고정된 예상 수치를 제시하지 않는다.

c++ -std=c++20 -O2 -DNDEBUG -Wall -Wextra -pthread perf_design.cpp -o perf_design
./perf_design --bench

측정 기록에는 컴파일러와 표준 라이브러리 버전, 최적화 옵션, CPU, 주문 수를 함께 적는다. 같은 실행 파일을 여러 차례 실행하여 중앙값의 변동도 확인한다. 두 배치의 차이가 실행 간 변동과 비슷하다면 어느 쪽이 빠르다는 결론을 보류한다. 작은 차이만 남았을 때에는 유지보수 비용이 낮은 표현을 선택할 근거가 충분하다.

정확성 검사는 새니타이저 빌드에서도 수행할 수 있다. 다만 그 실행 시간은 계측이 없는 배포 빌드의 성능과 구분한다. 메모리 검사를 위한 추가 작업과 변경된 배치가 비교 결과에 영향을 줄 수 있기 때문이다.

실무에서 자주 틀리는 것

예약한 공간을 이미 생성된 원소로 취급한다

다음 코드는 용량만 확보한 뒤 존재하지 않는 첫 원소에 접근한다. 저장 공간의 확보와 객체의 생성을 혼동한 코드다.

std::vector<Order> orders;
orders.reserve(100);
orders[0] = Order{100, 2, "accepted"};

원소를 하나 추가하려는 목적이라면 삽입 연산을 사용한다. 초기 상태에서 원소 100개가 실제로 필요하다면 resize나 크기를 받는 생성자를 별도로 선택한다.

std::vector<Order> orders;
orders.reserve(100);
orders.push_back(Order{100, 2, "accepted"});

const 원본에 move를 붙이고 이동했다고 판단한다

다음 order는 수정할 수 없다. 일반적인 이동 생성자는 수정 가능한 원본을 요구하므로 이 호출에서는 복사 생성자가 선택된다.

const Order order{100, 2, std::string(80, 'x')};
std::vector<Order> orders;
orders.push_back(std::move(order));

원본을 실제로 소비하려면 수정 가능한 값으로 준비한다. 이후 원본 문자열의 내용은 가정하지 않는다. 원본을 계속 사용해야 한다면 복사를 유지하는 것이 맞다.

Order order{100, 2, std::string(80, 'x')};
std::vector<Order> orders;
orders.push_back(std::move(order));

재할당 이후에도 이전 참조를 사용한다

다음 코드는 기존 용량보다 큰 용량을 요청하여 재할당을 일으킨다. 그 뒤 first를 읽는 것은 이미 무효화된 참조를 사용하는 일이다.

auto orders = make_orders(4);
const auto& first = orders.front();
orders.reserve(orders.capacity() + 1);
std::cout << first.price << '\n';

이 예제처럼 순서를 바꾸거나 원소를 지우지 않는 상황에서는 인덱스를 보관하고 변경 이후 다시 접근한다. 삽입이나 정렬로 위치까지 달라질 수 있다면 인덱스 대신 주문 식별자를 기준으로 찾는 설계를 검토한다.

auto orders = make_orders(4);
const std::size_t first_index = 0;
orders.reserve(orders.capacity() + 1);
std::cout << orders[first_index].price << '\n';

버리는 계산의 시간을 재고 빠르다고 결론짓는다

다음 코드에서 집계 결과는 관찰되지 않는다. 최적화기가 계산을 제거하면 두 시각 사이에 남는 일은 의도한 집계와 달라질 수 있다.

const auto orders = make_orders(65536);
const auto begin = std::chrono::steady_clock::now();
scan(orders, 3);
const auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration<double>(end - begin).count()
          << '\n';

결과를 외부에서 관찰할 수 있게 만들고 측정 구간을 충분히 길게 한다. 다음은 완성 코드의 측정 함수를 사용하는 형태다. 이것도 모든 컴파일러 변환을 막는 장치는 아니므로, 매우 작은 시간 차이를 판단할 때에는 실제 실행된 연산을 추가로 확인한다.

const auto orders = make_orders(65536);
std::uint64_t checksum = 0;
const double elapsed_us = measure(orders, checksum);
std::cout << elapsed_us << ' ' << checksum << '\n';

데이터 전체를 volatile로 바꾸는 방법은 일반적인 해법이 아니다. 접근 방식과 최적화 조건을 바꾸어 원래 프로그램과 다른 작업을 측정할 수 있다. 결과가 필요한 실제 호출 경로를 닮은 실험을 먼저 만들고, 필요하면 전용 측정 도구와 프로파일러를 보조 수단으로 사용한다.

한눈에 보기

성능 변경은 줄이려는 비용과 확인할 조건을 함께 정한다
선택줄이려는 비용확인할 조건남는 부담
값 타입 유지소유권 추적과 간접 접근실제 복사 발생 지점큰 값의 복사 비용
원본 이동소유 자원의 중복 생성원본을 소비해도 되는가이동 연산 자체의 비용
용량 사전 예약수집 중 재할당주문 수 추정이 합리적인가남는 용량과 초기 할당
구조체 배열한 주문의 여러 필드 접근필드들을 함께 읽는가사용하지 않는 필드의 간격
배열 구조체일부 필드 순회의 읽기 공간같은 인덱스 관계가 유지되는가변환, 추가 메모리, 갱신 관리
반복 측정과 중앙값일시적 지연의 영향빌드와 입력 조건이 같은가체계적인 편향은 남는다

실무의 변경 기록에는 가설, 입력 자료, 비교한 빌드, 결과 검증 방법, 측정 범위를 남긴다. “배열 구조체가 더 빠르다”보다 “이 주문 수와 수량 분포에서 변환 완료 후 반복 집계의 중앙값이 줄었다”가 다시 확인할 수 있는 설명이다. 성능 결과의 적용 범위를 좁고 정확하게 쓰는 것도 설계의 일부다.

이 책에서 사용한 주문 엔진은 자원을 소유하고, 실패를 표현하고, 작업 결과를 모으며, 이제 비용을 측정할 수 있게 되었다. 마지막 설계 판단에서도 출발점은 같은 결과와 명확한 수명이다. 읽기 쉬운 값 표현을 기준으로 두고, 측정으로 확인한 비용에만 복잡성을 추가한다.

연습 문제

  1. copy_probe에서 대상 벡터의 reserve 호출을 제거하라. 복사 경로와 이동 경로의 카운터가 각각 어떤 연산을 합산하게 되는지 설명하라. 특정 이동 횟수를 정답으로 고정할 수 있는지도 판단하라.
  2. 대상 벡터에 원소를 넣기 직전과 직후의 capacity()를 비교하여 용량 변경 횟수를 세어라. 최종 원소 수가 같더라도 복사·이동 횟수와 용량 변경 횟수가 다른 정보를 주는 이유를 설명하라.
  3. 최소 수량을 1과 5로 각각 고정하여 배치를 비교하라. 두 조건에서 계산되는 결과와 수행되는 작업의 차이를 설명하고, 이것만으로 운영 성능을 예측하기 어려운 이유를 적어라.
  4. 기존 구조체 배열을 그대로 집계하는 방법과, 배열 구조체로 변환한 뒤 집계하는 방법을 비교하려 한다. 변환 비용을 포함한 측정 구간을 설계하고 집계 반복 횟수에 따라 선택이 달라질 조건을 식으로 나타내라.

정답과 해설

  1. 복사 경로는 원본에서 새 원소를 만드는 복사와 재할당으로 기존 대상 원소를 이전하는 연산을 함께 센다. 이동 경로도 원본에서 새 원소를 만드는 이동에 기존 원소의 이전이 더해진다. 이 타입의 이동 생성자는 예외를 던지지 않으므로 일반적인 벡터 구현은 재할당 때 이동을 사용한다. 그러나 벡터의 용량 증가율은 고정된 규칙이 아니므로 재할당에 따른 이동 횟수를 이식 가능한 상수로 정할 수 없다. 컴파일러와 표준 라이브러리 구현을 함께 기록해야 한다.

  2. 삽입 직전의 용량을 변수에 저장하고, 삽입 후 용량이 다르면 카운터를 하나 증가시키면 된다. 이 실험에서는 용량이 바뀐 삽입이 재할당을 일으킨 삽입이다. 재할당 한 번에 이전해야 하는 기존 원소 수는 당시 크기에 따라 달라진다. 따라서 용량 변경 횟수가 같아도 이동한 원소 수는 다를 수 있다. 기존 원소가 없는 첫 할당도 용량 변경으로 세지만 이전할 기존 원소는 없다.

  3. 수량은 1부터 4까지이므로 최소 수량 1에서는 모든 주문의 금액을 합산하고, 5에서는 합계가 0이다. 소스 코드상으로는 두 경우 모두 수량을 검사하지만 조건이 5인 경우에는 가격을 읽어 곱하고 더할 필요가 없다. 컴파일러가 생성하는 명령은 달라질 수 있다. 두 극단은 선택 비율의 영향을 살피는 데 유용하지만, 실제 주문의 분포와 분기 예측 상태, 변경 빈도를 대표하지는 않는다. 두 배치에 같은 조건을 적용하고 실제 분포를 닮은 자료도 추가해야 한다.

  4. 공통 입력인 구조체 배열을 준비한 뒤, 첫 방법은 집계 반복 전체를 잰다. 두 번째 방법은 변환 시작부터 변환된 자료의 집계 반복 완료까지 잰다. 결과 객체의 파괴 비용을 포함할지도 두 측정의 수명 경계를 기준으로 정한다. 변환 비용을 C, 구조체 배열의 집계 한 번을 A, 배열 구조체의 집계 한 번을 S, 반복 횟수를 K라 하면 단순한 비용 모형에서는 C + K × S < K × A일 때 변환이 유리하다. A > S라면 K > C / (A − S)가 경계가 된다. 실제 시간은 캐시와 메모리 사용량의 영향을 받아 일정하지 않을 수 있으므로 이 식은 실험 범위를 정하는 가설로 사용한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.