hello, robot

20ms 제어 주기 안에 콜백이 끝나야 하는 코드에서는 평균 속도가 기준이 아니에요. 가장 느렸던 한 번이 기준이고, 그 한 번을 만드는 주범이 힙 할당이에요. 그래서 순서가 정해져요. 재고, 핫루프에서 할당을 치우고, 같이 읽는 데이터를 붙여 놓는 거예요.

느릴 것 같은 곳이 아니라 느리다고 잰 곳

성능 작업의 첫 규칙은 추측하지 않는 거예요. 직감은 이 분야에서 유난히 잘 틀려요. 비교 한 번, 나눗셈 한 번을 아끼는 동안 진짜 비용인 할당과 복사와 캐시 미스는 다른 줄에 숨어 있거든요.

구간 측정 관용구는 이 정도가 전부예요.

template <typename F>
double elapsed_ms(F&& work) {
    using clock = std::chrono::steady_clock;
    auto t0 = clock::now();
    work();
    auto t1 = clock::now();
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

시계는 steady_clock으로 고정해요. 절대 뒤로 가지 않는 스톱워치라서 그래요. system_clock은 벽시계라 시간 보정이 들어오면 값이 앞뒤로 튀고, 그 순간 구간 측정이 음수가 되기도 해요. C++ 동시성 — 센서 수신과 제어 루프의 제어 루프가 같은 시계를 쓴 것과 같은 이유예요.

duration<double, std::milli>로 받으면 0.37ms 같은 소수가 그대로 나와요. 정수로 자르는 duration_cast보다 계측에는 이쪽이 편해요.

주의는 둘이에요. 디버그 빌드 측정은 사실상 다른 프로그램을 재는 것이니 배포와 같은 최적화 옵션으로 재야 하고, 한 번 잰 값은 스케줄링과 캐시 상태에 따라 흔들리니 여러 번 돌려 중앙값을 봐야 해요.

복사 횟수는 외우지 말고 세어 보면 돼요

인자 전달 규칙은 표로만 상기할게요.

의도시그니처이유
읽기만 한다const T&복사 없음 + 수정 불가 계약
작은 값 (int·double·포인터)T 값 전달참조가 오히려 손해
가져가서 저장한다T 값 + 내부 std::move호출자가 이동·복사를 선택

여기에 얹을 습관이 하나 있어요. “이 호출에서 복사가 몇 번 일어나나”는 논쟁할 일이 아니라 세어 볼 수 있는 값이에요. 복사 생성자에 카운터를 심은 타입을 통과시키면 끝이에요.

struct CopyCounter {
    static int copies;
    CopyCounter() = default;
    CopyCounter(const CopyCounter&) { ++copies; }   // 복사될 때마다 +1
};

값 전달 함수에 넣으면 1이 찍히고 const& 함수에 넣으면 0이 찍혀요. 헷갈리는 시그니처를 만나면 이걸 통과시켜 보는 게 가장 빨라요.

vector가 커질 때 실제로 일어나는 일

capacity가 모자란 순간에 재할당이 일어나요. 더 큰 블록을 새로 할당하고, 기존 원소를 전부 새 블록으로 이사시키고, 옛 블록을 해제하는 세 단계예요.

push_back이 싸다고 하는 건 이 이사가 가끔만 일어나기 때문이에요. 빈 벡터에 1,000개를 넣으면 용량이 1→2→4→…→1024로 커지면서 재할당은 열한 번쯤 일어나요. 평균으로는 싸지만 그 열한 번의 순간에 원소 전체를 옮기는 비용이 몰려요. 평균이 아니라 최악 지연이 기준인 코드에서는 이 “가끔”이 바로 주기를 넘기는 스파이크예요.

개수를 미리 알면 이사 자체를 없앨 수 있어요.

std::vector<Waypoint> path;
path.reserve(traj_len);                  // 목적지 크기로 한 번에 할당
for (int i = 0; i < traj_len; ++i)
    path.emplace_back(xs[i], ys[i]);     // 재할당 0회

emplace_back도 여기서 의미가 생겨요. push_back(Waypoint{x, y})는 임시 객체를 만든 뒤 벡터 안으로 옮기지만, emplace_back(x, y)는 생성자 인자를 받아 벡터 안 자리에서 바로 만들어요. 임시 객체 하나가 사라지는 거예요. int나 float처럼 작은 타입에서는 차이가 없고, string 멤버가 있는 구조체처럼 생성이 무거운 타입에서 값어치가 나와요.

소스에 new가 없는데 힙을 쓰는 자리

이 주제에서 가장 자주 사고가 나는 곳이에요. 로봇 코드에서 가장 흔한 모양은 주기 콜백 안에서 컨테이너를 새로 만드는 거예요.

// 30Hz 카메라 콜백 — 매 프레임 불린다
void on_frame(const std::vector<Box>& raw) {
    std::vector<Box> stable;                  // 매 콜백 새 벡터 — 힙 할당
    for (const Box& b : raw)
        if (b.score > 0.5f)
            stable.push_back(b);              // 커질 때마다 재할당
    track(stable);
}                                             // 콜백 끝 — 통째로 해제

한 프레임만 보면 아무 문제가 없어요. 문제는 빈도예요. 30Hz면 할당과 해제 쌍이 초당 서른 번, 재할당까지 합치면 수백 번의 힙 왕복이 매초 일어나요.

증상이 고약한 이유는 힙 할당의 소요 시간이 일정하지 않다는 데 있어요. 할당자가 빈 블록을 찾는 시간이 상태에 따라 달라지고, 다른 스레드와 힙을 놓고 경합하면 더 늘어져요. 그래서 평소엔 멀쩡히 돌다가 가끔 한 번씩 주기를 넘겨요. 지터라고 부르는 그 증상이고, 평균만 보는 측정에는 안 잡혀요.

패턴은 하나예요. 핫루프에서는 힙을 만나지 않아요. 콜백마다 만들던 지역 벡터를 멤버로 올리고 매번 비워서 다시 써요.

class BoxFilter {
    std::vector<Box> stable_;                 // 지역 → 멤버로
public:
    void on_frame(const std::vector<Box>& raw) {
        stable_.clear();                      // size만 0 — capacity는 그대로
        for (const Box& b : raw)
            if (b.score > 0.5f)
                stable_.push_back(b);         // 확보된 용량 안 — 할당 없음
        track(stable_);
    }
};

핵심은 clear()가 확보한 용량을 건드리지 않는다는 거예요. 원소만 파괴하고 메모리는 쥔 채로 있어서, 첫 몇 콜백 동안 용량이 최대 프레임 크기만큼 자라고 나면 그 뒤로는 할당이 0이에요. 반대로 shrink_to_fit()은 쥔 메모리를 반납하니 이 패턴에서는 부르면 안 돼요. 다음 콜백에서 할당이 되살아나거든요.

문자열판도 같아요. std::string name = "cam_" + std::to_string(id);는 임시 string을 연달아 만들고, 내용이 길면 힙을 써요. 짧은 문자열은 객체 안에 담는 최적화 덕에 무사할 때가 많아서 오히려 더 헷갈려요. 매 콜백 도는 로그나 토픽 이름 조합이 전형적인 자리예요.

어디까지 신경 쓸지의 기준은 처음으로 돌아가요. 초기화 코드의 할당은 아무래도 좋아요. 주기 콜백과 안쪽 루프만 대상이고, 의심되면 재서 스파이크를 확인한 뒤에 고쳐요.

같이 읽는 데이터는 붙여 놓아요

CPU는 메모리를 한 바이트씩이 아니라 64바이트 덩어리로 읽어 와요. 그래서 방금 읽은 데이터의 바로 옆을 곧 또 읽는 코드는 빠르고, 여기저기 건너뛰는 코드는 느려요.

같은 데이터라도 배치가 둘이에요. 구조체를 배열에 담는 방식과, 필드별로 배열을 나누는 방식이요.

struct Point3 { float x, y, z; };
std::vector<Point3> cloud;                 // 메모리: x y z x y z x y z …
 
struct CloudSoA {
    std::vector<float> xs, ys, zs;         // 메모리: x x x … / y y y … / z z z …
};

차이는 한 필드만 훑는 루프에서 나요. 포인트클라우드 10만 점에서 바닥을 걸러내려고 높이만 검사한다고 해볼게요. 앞쪽 배치에서는 z를 하나 읽을 때마다 이웃 점의 x와 y까지 딸려 오는데 그건 안 쓰고 버려요. 뒤쪽 배치의 zs는 z끼리 붙어 있어서 딸려 온 64바이트가 전부 다음에 쓸 z예요. 같은 연산인데 메모리에서 읽어 오는 총량이 세 배쯤 차이 나고, 큰 배열을 훑는 루프는 대개 연산이 아니라 메모리 대역폭이 병목이라 그 차이가 그대로 시간이 돼요.

실무 판단은 이래요. 점 하나를 통째로 다루는 코드는 앞쪽 배치가 자연스럽고 코드도 단순하니 그게 기본값이에요. 한 필드만 훑는 핫루프가 측정에서 병목으로 지목될 때, 그 데이터만 뒤쪽 배치로 바꿔요. 센서 메시지나 수치 라이브러리가 채널별 배열로 데이터를 주는 것도 이 배치가 필드 단위 연산에 유리해서예요.

순서가 곧 방법이에요

성능 작업은 재고, 할당을 치우고, 배치를 바꾸는 순서예요. steady_clock으로 재서 지목된 자리에만 손을 대되, 핫루프에서 힙을 만나지 않기와 같이 읽는 데이터를 붙여 놓기라는 두 규칙이 최악 지연의 대부분을 잡아요.

객체를 언제 지울지 정하는 쪽은 C++ 소유권과 수명 — 스마트 포인터에 있어요. 이 글이 다룬 건 언제 만들지 않을지에 가까워요.