컴파일러가 벤치마크 코드를 최적화로 지워 버리는 문제와 이를 피하는 실용적인 방법을 설명한다.
2025년 12월 9일
컴파일러는 교활한 괴물이다. 다음과 같은 코드를 측정해 보면:
zigvar total: u32 = 0; for (0..N) |i| total += i; print("total={}", .{total});
LLVM이 가우스라는 이름의 영리한 꼬마만큼이나 똑똑해서, 이 합을 동치인 공식 (N(N+1)/2)로 대체한다는 사실을 알게 될 것이다.
게다가 total += i + 2*i*i - i*i*i처럼 더 복잡한 코드를 작성해도, LLVM은 그것 역시 닫힌형(closed-form) 식으로 바꾸어 낸다(내가 11학년 때 자랑스럽게 스스로 발견했던, 가우스 트릭의 일반화다). 직접 확인해 보라: https://godbolt.org/z/T9EcTb8zq
보통은 이런 일이 바람직하다 — 코드가 더 빠르게 실행되니까! 다만, 당신이 실제로 하려는 것이 코드를 벤치마크하는 것인데, 그 대신 정교한 no-op(아무 일도 하지 않는 연산)을 벤치마크하게 되어 버리는 상황만 아니라면 말이다.
벤치마킹에는 두 가지 함정이 있다. 첫째,
zigconst start = now(); _ = computation(); const elapsed = now() - start;
합리적인 컴파일러라면 computation의 결과가 사용되지 않는다는 점을 눈치채고, 전체 계산을 통째로 없애 버릴 수 있다.
둘째,
zigconst parameter_a = 1_000_000; const parameter_b = 1_000; const start = now(); _ = computation(parameter_a, parameter_b); const elapsed = now() - start;
여기서는 계산 전체가 제거되지 않는다 하더라도, 매개변수 값이 컴파일 타임에 알려져 있다는 사실을 이용해, 그 일부를 상수 폴딩(constant folding)해 버릴 수 있다.
보통 언어들은 “이건 최적화하지 말아 주세요”라고 명시적으로 말하는 함수 비슷한 것을 제공한다. Rust의 hint::black_box나 Zig의 mem.doNotOptimizeAway 같은 것들이다. 하지만 이런 것들은 언제나 나에게 용(龍) 기름(dragon oil, 가짜 만병통치약)처럼 느껴졌다.
예시로 설명하는 편이 더 쉽다. 이진 탐색을 벤치마크한다고 해 보자.
zigfn insertion_point(xs: []const u32, x: u32) usize { ... }
나는 다음과 같은 벤치마크 뼈대를 사용할 것이다:
zigfn benchmark(arena: Allocator) !void { const element_count = try parameter("element_count", 1_000_000); const search_count = try parameter("search_count", 10_000); const elements: []const u32 = make_elements(arena, element_count); const searches: []const u32 = make_searches(arena, search_count); const start = now(); var hash: u32 = 0; for (searches) |key| { hash +%= insertion_point(elements, key); } const elapsed = now().duration_since(start); print("hash={}\n", .{hash}); print("elapsed={}\n", .{elapsed}); } fn parameter(comptime name: []const u8, default: u64) !u64 { const value = if (process.hasEnvVarConstant(name)) try process.parseEnvVarInt(name, u64, 10) else default; print(name ++ "={}\n", .{value}); }
입력 측면에서 parameter 함수는 심볼릭 이름과 기본값을 받는다. 이 함수는 환경 변수에서 값을 찾아보고, 없으면 기본값을 사용한다. 값이 런타임에 지정될 수 있기 때문에, 컴파일러는 특정 상수를 가정하고 최적화할 수 없다. 그리고 벤치마크를 다시 돌릴 때마다 매개변수를 바꾸기 위해 재컴파일할 필요 없이, 편리하게 값을 조정할 수 있는 이점도 얻는다.
출력 측면에서는 결과들의 (극도로 약한) “해시(hash)”를 계산한다. 우리의 이진 탐색 예제에서는 단순히 모든 인덱스의 합이다. 그리고 이 해시를 시간 정보와 함께 출력한다. 이렇게 계산 결과를 실제로 사용하기 때문에, 컴파일러는 그것을 최적화로 없애 버릴 수 없다!
parameter 함수와 비슷하게, 여기서도 공짜로 덤 기능을 하나 더 얻는다. “불필요한” 기능을 삭제해서 코드를 더 빠르게 만드는 것을 또 누가 좋아하겠는가? 바로 나다! 물론 나는 컴파일러만큼 똑똑하지 못해서, 실제로 정답을 얻는 데 필요한 코드를 지워 버리는 경우가 자주 있다. 해시를 사용하면, 내가 최적화 작업을 망쳐서 엉뚱한 결과가 나오게 되면, 그 사실이 예상치 못한 해시 값의 변화로 즉시 드러난다.
다음 번 벤치마크에서는 블랙박스를 사용하는 것을 피해 보라. 대신, 자연스러운 방식의 “반(反) 최적화 컴파일러 처방”을 따르자.