← 전체 글

성능 최적화

수정 2026-10-29 · 출처 확인 2026-10-11

목차

성능 최적화는 측정한 병목과 애플리케이션 지표를 연결하는 작업이다. 식을 짧게 쓰는 것보다 할당, 메모리 전송, 중복 작업, 경합을 줄이는 일이 더 중요할 때가 많다. 아래 모형은 Dean과 Ghemawat의 Performance Hints에 나오는 몇 가지 접근을 설명한다. 수치 예시는 특정 장비의 측정값이 아니라 가정한 작업이다.

지표와 측정

지연시간, 처리량, CPU 시간, 최대 메모리, 할당 횟수, 에너지는 서로 반대 방향으로 변할 수 있다. 배치는 대기를 늘려 처리량을 높이고, 캐시는 메모리와 무효화 작업을 써서 계산을 줄인다. 대표성 있는 벤치마크에는 입력 분포, 웜·콜드 상태, 스레드, 하드웨어, 컴파일러, 플래그, 보존할 동작이 필요하다.

동등한 빌드를 비교하고 결과를 실제로 소비해야 컴파일러가 작업을 제거하지 않는다. 웜·콜드 실행은 따로 측정하고, 사용자가 초기화 비용을 부담한다면 측정에도 포함한다. 반복 시간 \(T_i\)는 최솟값만이 아니라 분포로 나타낸다. 실행 순서를 번갈아 배치하거나 무작위화하면 시간에 따른 편향을 줄인다. 짝지은 차이 \(D_i=T_i^{\mathrm{candidate}}-T_i^{\mathrm{baseline}}\)는 공통 조건을 제거할 수 있지만 자기상관까지 제거하지는 않는다. CPU·할당 프로파일과 하드웨어 카운터는 비용의 후보를 찾는 근거이지 특정 소스 줄이 원인이라는 증명은 아니다. 결과에는 바이너리, 입력, 호스트, 반복 수, 집계 방식, 불확실성이 따라야 한다.

암달의 법칙

어떤 구성요소가 원래 실행시간의 \(f\)를 차지하고 \(s\)배 빨라지면 전체 속도 향상은 다음과 같다.

\[S=\frac1{(1-f)+f/s}.\]

원래 시간을 1로 두면 나머지 \(1-f\)는 그대로이고 해당 구성요소는 \(f/s\)로 줄어든다. 작업, 상호작용, 다른 비용이 바뀌지 않는다는 가정이다. \(f=0.2\), \(s=5\)이면 전체 속도 향상은 \(5\)배가 아니라 \(1/(0.8+0.04)\approx1.19\)배다. 구성요소를 완전히 없애도 한계는 \(1/(1-f)=1.25\)다. 할당 제거로 다른 곳의 캐시 미스까지 줄어드는 등 구성요소 사이에 영향이 있으면 전체 시간을 다시 측정해야 한다.

메모리 전송

연산 수 \(F\), 전송 바이트 수 \(B\), 처리율 상한 \(P\), 대역폭 상한 \(W\)에 대해 다음이 성립한다.

\[T\ge\max\left(\frac{F}{P},\frac{B}{W}\right),\]

\(F\le PT\)와 \(B\le WT\)가 각각 성립하기 때문이다. 의존성 대기, 캐시, 경합은 시간을 늘릴 수 있다. 상한 대신 측정한 유효 처리율을 넣으면 보장된 경계가 아니라 추정식이 된다. \(B=1\)GB를 \(W=50\)GB/s로 읽는 가상의 스캔에서 대역폭 항은 \(20\)ms다. 산술 연산을 바꿔도 이 항은 줄어들지 않는다.

레코드 \(n\)개가 연속해서 놓이고 간격이 \(r\)바이트, 캐시 라인이 \(L\)바이트라면 촘촘한 스캔은 대략 \(\lceil nr/L\rceil\)개 라인을 건드린다. 일부 필드만 읽으면 structure-of-arrays로 전송량을 줄일 수 있지만, 함께 읽는 필드, SIMD, 프리페치, 배치 오버헤드에 따라 결과가 달라진다. 연속 저장소의 인덱스는 포인터 간접 참조와 폭을 줄일 수 있다. 범위, 핸들 수명, 재배치 규칙은 여전히 API 계약이다.

할당과 수명

reserve(n)은 \(n\)개 원소를 생성하지 않고 용량을 확보한다. resize(n)은 크기를 바꾸고 원소를 생성한다.

std::vector<Result> out;
out.reserve(items.size());
for (const auto& item : items) {
    out.push_back(compute(item));
}

기하급수적 용량 증가는 추가 한 번당 상각 재할당 작업을 상수로 만들지만, 개별 재할당에서 지연시간이 튈 수 있다. 매번 reserve(size()+1)을 호출하면 복사가 이차적으로 늘어날 수 있다. 용량을 재사용하면 할당을 피할 수 있다.

std::string buffer;
for (const auto& item : items) {
    buffer.clear();
    encode_into(item, buffer);
    consume(buffer);
}

여기서 consume은 다음 변경 이후까지 뷰를 보유해서는 안 된다. 저장소를 소유하지 않는 std::string_view, 벡터 참조, 반복자는 수명과 무효화 규칙을 따른다(C++ Core Guidelines).

캐시와 빠른 경로

작업을 생략하려면 불변식을 유지해야 한다. “대기 작업 없음” 플래그가 오래되면 결과가 누락된다. 캐시 적중률 \(h\), 적중 비용 \(c_h\), 미스·재계산 비용 \(c_m\)에 대해 다음과 같다.

\[\mathbb E[C]=hc_h+(1-h)c_m.\]

여기에는 무효화와 메모리 효과가 빠져 있다. 해싱, 키, 퇴출, 저장 공간, 의존성 버전에도 비용이 든다. 출력에 영향을 주는 입력이 키에서 빠지면 오래된 답을 반환할 수 있다. 사전 계산은 충분히 재사용할 때 이득이다. 정렬 벡터의 탐색에는 \(O(\log n)\)번 비교가 필요하고, 해싱은 해시·부하 가정 아래 기대 \(O(1)\) 탐색을 제공한다. 지역성, 메모리 사용량, 적대적 입력의 차이는 남는다.

동시성과 대기행렬

독립적인 작업이 동기화·스케줄링 비용보다 커야 병렬화가 이득이다. 거짓 공유는 독립적으로 수정하는 변수가 같은 캐시 라인에 놓일 때 생긴다. 패딩은 경합을 줄이면서 메모리 사용량을 늘릴 수 있다. 평균을 일관되게 정의한 안정적인 시스템에서 리틀의 법칙은 다음과 같다.

\[L=\lambda W\]

\(L\)은 시스템 안의 평균 작업 수, \(\lambda\)는 도착률, \(W\)는 평균 체류시간이다. 이 식만으로 지연의 원인은 알 수 없다. 이상적인 M/M/1 모형은 포아송 도착, 지수 서비스 시간, \(\lambda<\mu\)을 가정하며 평균 체류시간은 \(1/(\mu-\lambda)\)다. 포화에 가까워질 때의 민감도를 보여 줄 뿐 실제 운영의 꼬리 지연시간을 예측하는 식은 아니다.

배치는 고정 비용을 나누는 대신 대기를 더한다. 충분한 표본으로 부하와 p50/p95/p99를 측정하면, 응답을 기다린 뒤 다음 요청을 보내는 폐루프 벤치마크가 놓칠 수 있는 효과를 볼 수 있다.

API 비용

일괄 연산은 반복 파싱·검증·동기화를 줄이지만 취소, 부분 실패, 소유권도 바꾼다. 공유 버퍼에는 유효기간이 있고, 이동 연산이 언제나 무료인 것도 아니다. 유효한 최적화는 작은 입력과 드문 경로까지 결과, 순서, 오류 동작, 수명, 동시 접근 규칙을 보존한다. 의사결정 기록은 병목, 프로파일, 측정한 개선, 불확실성, 유지보수 비용을 연결해야 한다. 마이크로벤치마크 비율만으로 애플리케이션의 속도 향상을 입증할 수 없다. 컴파일러·프로파일러 버전과 해당 장비의 처리율도 그 근거에 속한다.

참고문헌

↑