Hacker News

Writing Efficient C++ Code (2013)

효율적인 C++ 코드 작성법

2013년에 작성된 C++ 성능 최적화 글입니다. 포인터로 흩어진 객체보다 연속된 메모리와 데이터 접근 패턴을 우선하고, 캐시 지역성·AOS와 SOA·할당 재사용·포인터 별칭을 이해하라고 설명합니다. Hacker News에서는 조언의 여전한 유효성과 현대 C++의 변화, 최적화가 필요한 영역을 두고 의견이 갈렸습니다.

AI 요약

고수준 언어가 많은 용도에 적합하더라도 실시간 처리, 게임, 서버, 모바일 기기처럼 성능과 자원 사용량이 중요한 곳에서는 효율적인 코드가 필요합니다. 이 글은 C++ 사용만으로 성능이 보장되지 않는다고 짚으며, 하드웨어가 데이터를 다루는 방식을 이해하고 설계 단계부터 데이터 배치와 접근을 고려하자고 제안합니다. 글은 2013년에 작성됐으므로 컴파일러와 표준 라이브러리에 관한 구체적인 언급은 당시 환경을 기준으로 읽어야 합니다.

객체보다 데이터 흐름을 먼저 봅니다

객체지향 설계는 개념을 표현하고 코드를 재사용하는 데 유용하지만, 작은 객체를 따로 할당하고 포인터로 연결하면 메모리 곳곳을 건너뛰며 접근하게 됩니다. 이때 캐시 미스가 잦아지고, 객체 메서드가 다른 객체에 어떤 영향을 주는지 파악하기 어려워 멀티스레드 병렬화도 까다로워집니다. 반대로 배열처럼 규칙적인 자료구조를 순회하고 각 요소에 독립적인 연산을 적용하면 작업을 나누기 쉽습니다. 저자는 기능을 불필요하게 감싸는 추상화나 실제로 필요하지 않은 범용 인터페이스를 경계하고, 저장할 데이터와 수행할 연산을 직접 표현하라고 권합니다.

캐시를 고려해 메모리에 배치합니다

프로세서와 메모리의 속도 차이를 설명하며, 3GHz 프로세서의 예에서 연산 한 번은 약 0.33ns, L1 캐시 접근은 약 1ns, RAM 접근은 약 83ns라고 제시합니다. 당시 글의 추정치로 RAM 접근은 약 250사이클에 해당합니다. 캐시는 필요한 바이트 하나만 가져오는 대신 예를 들어 64바이트 단위의 캐시 라인을 읽으므로, 함께 쓰는 값을 메모리에서도 가깝게 배치하면 캐시를 더 잘 활용합니다. 따라서 연속 저장을 제공하는 std::vector를 권하고, 연결 리스트나 노드 기반 트리처럼 포인터를 따라가는 자료구조는 실제 접근 패턴을 따져 보라고 합니다.

정렬된 데이터를 한 번 만든 뒤 삽입·삭제하지 않는다면 std::set이나 std::map 대신 배열을 정렬하고 이진 탐색하는 방법도 제시합니다. 두 방법 모두 탐색의 점근 복잡도는 로그 시간이며, 연속 배열은 순회할 때 캐시 지역성에서 이점을 얻습니다. 다만 글은 모든 상황에서 배열이 낫다고 주장하기보다 자료구조의 복잡도뿐 아니라 상수 비용과 실제 메모리 배치를 고려하라고 설명합니다.

AOS와 SOA는 사용 패턴에 맞춰 고릅니다

파티클 시스템 예제에서는 각 파티클의 위치·속도·색상을 한 구조체에 묶는 AOS(Array of Structures)와, 위치 배열·속도 배열·색상 배열로 분리하는 SOA(Structure of Arrays)를 비교합니다. 모든 파티클의 위치를 속도로 갱신할 때 색상 데이터가 끼어들지 않는 SOA가 더 효율적일 수 있습니다. 어떤 배치가 나은지는 실제로 함께 읽고 쓰는 데이터에 달려 있습니다.

작은 구현 선택도 성능에 영향을 줍니다

글은 예외 처리와 RTTI를 쓰지 않는 프로그램이라면 컴파일러 설정으로 끌 수 있다고 제안합니다. 시스템 기본 메모리 할당기는 범용 도구이며, 크기가 고정된 객체를 제한된 수만큼 할당하는 상황에서는 사전 할당한 풀과 free list가 더 적합할 수 있다는 예도 듭니다. STL 컨테이너도 내부 구현을 알아야 합니다. 글의 설명에 따르면 std::list, std::set, std::map 계열은 원소를 개별 할당하고, std::vector는 연속된 메모리에 저장합니다.

포인터 별칭(pointer aliasing) 사례에서는 함수 인자로 받은 포인터가 같은 메모리를 가리킬 수 있다고 컴파일러가 판단하면, 루프 안에서 값을 매번 다시 읽을 수 있다고 설명합니다. 값을 지역 변수로 복사하거나 값으로 전달하는 방법, 비표준 __restrict 키워드가 대안으로 나옵니다. std::string을 반복문 안에서 매번 만들던 예제를 반복문 밖에서 선언하고 clear()로 재사용하자, 저자의 컴퓨터에서 200만 회 실행 시간이 평균 0.36초에서 0.27초로 줄었습니다. Visual C++ 2012의 문자열 구현에서는 clear()가 길이를 0으로 바꾸지만 저장 공간은 유지했기 때문입니다.

마지막으로 글은 컴파일러 최적화를 맹신하거나 모든 성능 작업을 프로파일링 이후로 미루지 말라고 합니다. 어셈블리어로 코드를 다시 쓸 필요는 드물지만, 컴파일 결과를 읽을 정도의 지식은 도움이 됩니다. Knuth의 문구도 작은 효율을 대체로 무시하라는 맥락이지 성능을 전혀 고려하지 말라는 뜻은 아니라고 덧붙입니다.

Hacker News 반응

  • @112233 — “훌륭한 조언이 많습니다. 지난 10년 동안 C++가 효율적이고 단순한 저수준 코드를 쓰기 더 어렵게 만드는 방향으로 움직였다니 아쉽습니다.”
    • @fooblaster — “어떤 점에서요? 오늘날에도 똑같은 저수준 코드를 쓸 수 있습니다.”
    • @beached_whale — “저도 같은 생각입니다. 요즘 C++에는 예전에는 훨씬 많은 코드를 쓰거나 컴파일 단계 도구를 써야 표현할 수 있었던 기능이 많습니다. 실행 시점에는 돌지 않는 코드를 컴파일 시점에 실행하는 능력도 큽니다. #embed를 쓰면 링커 스크립트나 컴파일러별 도구 없이 다른 도구의 출력을 가져올 수 있습니다. 과거 코드 대부분도 여전히 작동합니다.”
  • @hn_submit — “저는 거의 매일 C++로 코드를 쓰지만 속도를 최적화할 일이 없습니다. 평범하게 작성해도 이미 엄청나게 빠릅니다.”
    • @cjbgkagh — “저는 C++를 거의 쓰지 않지만 쓸 때는 속도 때문입니다. 공들여 만든 intrinsic 코드가 순진한 구현보다 10배 빨라지는 일도 드물지 않습니다.”
    • @gbin — “분야에 따라 다를 겁니다. 로보틱스에서는 CPU, 메모리 대역폭, GPU, 배터리 수명이 모두 한정된 자원입니다. 임베디드도 마찬가지일 겁니다. HFT나 통신 같은 오프라인 분야도 있고요.”
    • @glouwbug — “맞습니다. 다만 다형 포인터 목록을 std::variant로 바꾸면 TLB와 캐시 지역성에서 최소 2~3배 빨라집니다. SOA로 바꾸면 다시 4~8배 개선됩니다. 데이터 중심으로 설계하면 거의 25배 개선을 기대할 수 있습니다.”
    • @cenamus — “std::variant가 다형성보다 빠른 건 간접 참조를 줄이기 때문인가요?”
    • @glouwbug — “그 점도 있고, 컴파일러가 가상 호출 인라이닝을 따질 필요가 없어집니다. std::variant는 경우에 따라 한 캐시 라인에 객체를 여러 개 담습니다. TLB는 4096바이트 페이지를 다루므로 다형 객체 32개가 각각 다른 페이지에 놓이면 주소 변환이 32번 필요할 수 있지만, std::variant 방식에서는 하나면 됩니다.”
  • @MaxBarracough — “분기 예측, 컨텍스트 전환, 동기화 이야기가 없습니다. 하는 일에 따라 큰 영향을 줄 수 있습니다. 병렬 처리와 SIMD 언급도 짧습니다. 고성능 프로그래밍은 범위가 넓고, 블로그 글 하나로 다루기에는 너무 큽니다. 글 자체가 나쁘다는 뜻은 아니지만, 연재나 책에 어울리는 주제입니다.”
    • @creata — “조금 오래됐고 빠진 내용도 있지만 Agner Fog의 매뉴얼을 좋아합니다.”
    • @Jeaye — “이 주제에 관해 꼭 읽어야 할 자료를 추천해 주실 수 있나요?”
    • @MaxBarracough — “전문가는 아니지만, @creata가 언급한 Agner Fog의 자료는 좋아 보이고 무료로 볼 수 있습니다. C++ High Performance도 이런 주제를 다루는 것 같습니다. 다만 분기 예측 같은 컴퓨터 구조 내용을 자세히 다루지는 않는 듯합니다.”
  • @asveikau — “이 글은 2000년대에 보이기 시작한 성능 조언을 떠올리게 합니다. 알고리즘 복잡도를 낮추려고 포인터가 많은 자료구조를 만드는 대신 데이터를 vector에 넣으라는 이야기입니다. 컴퓨터 과학 교재에서 더 느리다고 하는 알고리즘을 써도 데이터가 캐시에 들어가면 상관없습니다. 포인터를 따라 이곳저곳 읽으면서 생기는 캐시 미스가 더 큰 비용입니다.”
    • @jeffbee — “완성된 애플리케이션에서 성능을 고려하지 않은 설계를 고치기는 어렵습니다. 처음부터 성능을 신경 써야 합니다.”
    • @bluGill — “이 조언이 틀렸다는 뜻은 아니지만 오해를 부를 수 있습니다. 제 벤치마크에서는 원소가 9개를 넘으면 std::map이 vector보다 빨랐습니다. 그보다 적으면 선형 탐색이 낫습니다. 물론 직접 쓸 데이터로 벤치마크해야 합니다.”
    • @danbolt — “제가 있던 팀에서는 작은 컨테이너를 주로 다뤄 std::vector가 낫다는 결과를 얻었습니다. 그런 선택을 해야 하는 단계라면 측정이 필요합니다.”
  • @einpoklum — “volatile의 의미는 꽤 미묘합니다. ‘volatile은 컴파일러가 값을 레지스터에 보관하지 못하게 한다’고 단정할 수는 없습니다.”
  • @add2 — “대부분의 경우 성능 개선은 미미했고 코드만 복잡해졌습니다. ‘성급한 최적화는 모든 악의 근원’입니다.”

원문: Hacker News / 번역·요약: Trawling