컴파일러와 데이터베이스 옵티마이저가 왜 예측 불가능하고 제어·검증이 어려운지, 그리고 문서화·디버깅·가이던스·회귀 테스트 인프라를 통해 이를 어떻게 개선할 수 있는지에 대한 제안.
컴파일러 개발 경험과 새 최적화들에 대해 읽어온 것, 그리고 더 최근에는 Postgres에서 느린 쿼리를 디버깅하려고 애쓴 경험을 바탕으로 보면, 옵티마이저는 보통 다음과 같은 공통 특성을 가지는 듯하다:
예를 들어 GC가 있는 언어에서는, 어떤 상황에서는 어떤 할당도 일어나지 않게 해야 한다. 이를 위해 언어가 기본적으로 거의 모든 것을 박싱한다면, 옵티마이저가 해당 박싱 연산들을 언박싱하도록 확실히 만들어야 한다.
C++ 같은 더 저수준 언어의 맥락에서는, 문제의 코드에 따라 컴파일러가 루프를 제대로 언롤하는지, 분기 없는(branchless) 명령을 사용하는지(또는 피하는지), 올바른 SIMD 명령 세트를 쓰는지 등등이 중요할 수 있다.
참조 카운팅과 copy-on-write 컨테이너 타입을 함께 쓰는 언어에서는, 라이프타임 축소(lifetime contraction)가 단지 상수 계수뿐 아니라 알고리즘 복잡도 자체를 바꾸는 경우도 있다.
대개 이는 컴파일러 프라그마나 특수 builtin/intrinsic의 형태이고, 가끔 언어 기능으로 제공되기도 한다. 몇 가지 예:
* Rust에는 벤치마킹을 위해 옵티마이저 배리어를 편리하게 둘 수 있는 [black_box](https://doc.rust-lang.org/beta/std/intrinsics/fn.black_box.html) intrinsic이 있다.
* 많은 언어에는 인라이닝 정보를 표시하는 방법이 있다. 예를 들어 Swift stdlib에는 현재 [`@inline(__always)`](https://sourcegraph.com/search?q=context:global+repo:swiftlang/swift+file:%5Estdlib/+%40inline%28__always%29+lang:Swift+count:all&patternType=keyword&sm=0)가 890번, [`@inlinable`](https://sourcegraph.com/search?q=context:global+repo:swiftlang/swift+file:%5Estdlib/+%40inlinable+lang:Swift+count:all&patternType=keyword&sm=0)가 3.1K번 사용되고 있다.
* MySQL에는 매직 코멘트로 작성되는 [optimizer hints](https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html)가 있다. 적용할 수 없는 힌트는 경고 없이 조용히 무시된다.
EDIT(2024 Oct 28): 어떤 경우에는, 언어가 의미(semantic) 검사 단계에서 성능 관련 성질을 강제하는 기능을 제공하기도 한다. 그러면 옵티마이저에 의존하는 대신 의미 검사와 통합된 더 큰 제어권을 얻게 된다:
* D는 함수를 [“no GC”](https://dlang.org/spec/function.html#nogc-functions)로 표시하는 1급 지원을 제공한다.
* C#은 `ref` 파라미터와 타입을 지원하며, [제한된 형태의 borrow-checking](https://em-tg.github.io/csborrow/)을 제공한다.
3. 제한된 테스트 가능성(Limited testability): 옵티마이저의 출력을 확인하는 수단은 디버깅용으로만 설계되어 있고, 표준 테스트 기법에 잘 맞지 않는다.
보통은 컴파일러 출력을 보는 방식인데, 저수준 언어에서는 어셈블리, 고수준 언어에서는 중간 표현(IR)의 형태다. 하지만 이 출력을 다시 도구에 넣어, 새 버전의 코드(또는 더 최신 도구 버전으로 컴파일한 동일 코드)가 대략 동일한 최적화 결과를 내는지 확인할 방법이 없다.
여기서 흥미로운 반례로는 많은 컴파일러에서 명시적 검사를 지원하는 꼬리 호출 제거(tail call elimination)가 있다: Clang과 GCC에는 musttail, Dotty(Scala 3)에는 tailrec, OCamlc에는 @tailcall이 있다.
예를 들어 최근 Postgres 사례에서는, Postgres가 기본 키 인덱스 대신 선택도가 매우 낮은 인덱스를 선호하는 인덱스 선택을 해 당황했다. 더 까다로운 점은, 프로덕션에서 선택되는 인덱스가 개발 환경에서 선택되는 인덱스와 일치하지 않는다는 것이다. 그렇다, 프로덕션에서 인덱스 통계가 최신인지도 확인했다.
컴파일러 버그 트래커를 훑어보면, 사용자가 기대할 때 최적화가 발동하지 않는 상황을 어렵지 않게 접한다. 때로는 사용자 기대가 잘못된 것이고, 때로는 옵티마이저 버그이며, 때로는 “충분한 똑똑함 부족”(이 부분은 곧 더 이야기한다) 때문이다.
마지막으로, 이는 옵티마이저 자체라기보다 최적화 전반의 이야기인데: 최적화는 디버깅을 방해하는 경향이 있어, AOT(ahead-of-time) 컴파일에서는 맥락(로컬 개발 vs 프로덕션)에 따라 서로 다른 최적화 레벨을 쓰도록 유도한다. 반례로는, Go는 관례적으로 dev/release 빌드를 분리하지 않는다.
고수준 언어에서는, 그럴듯한 성능을 내려면 최소한 어느 정도 최적화가 필요하며, 언어에 따라 최소한 일부 디버깅 정보가 지워질 수밖에 없다. Tail-call elimination은 흔한 예이며, Scheme 같은 언어에서는 때때로 필수다. Roc, Lean, Koka에서 쓰이는 Functional-But-In-Place technique도 아마 같은 문제가 있을 것이라 생각한다.
앞 섹션을 보면, 첫 번째 항목인 조건부 핵심성과 나머지 항목들 사이에 모순이 있는 듯하다. 어떤 시스템이 프로그램 동작에 핵심적이라면, 그것을 검사하고, 이해하고, 제어하고, 검증할 수 있는 좋은 메커니즘이 당연히 필요하지 않을까?
소프트웨어 공학은 시간에 대해 적분된 프로그래밍이다
비슷한 맥락에서, DB 쿼리 플래너에 관한 Nelson Elhage의 Some opinionated thoughts on SQL databases에서 더 긴 인용을 가져오면:
나는 쿼리 플래너가 싫다
[..] 일관된 데이터 접근 패턴과 높은 처리량을 가진 온라인 애플리케이션에서, 성능은 데이터베이스 인터페이스의 일부다; 데이터베이스가 쿼리를 계속 처리하더라도 지연 시간이 크게 나빠진다면, 그건 인시던트다!
쿼리 플래너는 예측 가능성의 정반대다; _대부분_의 시간에는 올바르게 선택하겠지만, 언제 그렇지 않을지나, 그렇지 않을 때 영향이 무엇일지 알기 어렵다. [..] 그들의 플래너가 충분히 똑똑한지의 문제가 아니다 [..] 어느 시점에서는 투명성, 명시성, 예측 가능성이 정말 중요한 가치다.
두 인용을 잇는 주제는 유지보수성이다: “투명성, 명시성, 예측 가능성”은 유지보수에 중요하며, 따라서 도구가 우리가 작성하는 소프트웨어에 이런 가치를 내재화할 수 있도록 지원하는 것이 중요하다.
이 점을 옵티마이저와 최적화에 적용하면, 어떤 최적화가 전체 시스템 기능에 핵심적이고, 시스템을 오랜 기간 유지보수해야 한다면, 프로그래머는 다음을 할 수 있어야 한다:
옵티마이저가 할 수 있는 것과 할 수 없는 것에 대한 좋은 멘탈 모델을 갖는다.
최적화를 놓친 상황을 쉽게 디버깅한다.
옵티마이저가 스스로 알아내지 못한다면, 원하는 방향으로 쉽게 조향한다. 옵티마이저가 프로그래머의 가이던스를 따르지 못하면 반드시 오류를 내야 한다. 선택적으로, 옵티마이저는 프로그래머가 “약한 가이던스(weak guidance)”를 제공해 최적화 누락을 오류 대신 경고로 낮출 수 있게 할 수도 있다.
옵티마이저의 기대 동작에 대한 회귀 테스트를 쉽게 작성한다. 이러한 테스트는 일반적으로 좋은 테스트가 갖춰야 할 다른 성질도 가질 수 있어야 한다. 테스트가 만족해야 할 성질의 비완전한 목록: (1) 정확성 - 필요한 최적화가 발동하지 않으면 테스트가 실패해야 함 (2) 견고성 - 무관한 요인 때문에 테스트가 실패하면 안 됨 (3) 디버깅 가능성 - 실패는 재현과 디버깅이 쉬워야 함 (4) 자동화 - 버전 관리에서 유지보수하기 쉽고 일반적인 CI 환경에서 실행 가능해야 함 (5) 성능 - 테스트는 합리적으로 빠르게 실행되어야 함.
옵티마이저는 툴링과 문서화를 포함해 위에서 정리한 4가지 성질을 지원하도록 진화해야 한다. 인터넷이니, 의례적인 면책 조항을 덧붙이자면: 이 일을 무료로 해달라고 요구하는 것은 아니다.
옵티마이저는 수행하는 최적화들에 대한 명확한 문서와, 최적화가 적용될 수 있는 순서에 대한 문서를 가져야 한다.
최적화가 잘 발동하는 긍정적 예시는 문서에 포함되어야 하며, 회귀를 막기 위해 doctest로 머신 체크되어야 한다.
아마 더 중요한 것은, 최적화가 자동으로 발동하지 않는 부정적 예시들이다. 이런 예시에는 왜 특정 상황에서 최적화가 발동하지 않는지에 대한 설명이 있어야 하고, 이상적으로는 이슈 트래커에 반영된 사용자 오해를 기반으로 해야 한다. 마지막으로, 부정적 예시에는 해당 맥락에서 의미가 있다면(예: 어떤 가정이 충족되지 않을 때 최적화가 비건전함을 도입해선 안 되는 경우), 사용자가 원하는 바를 옵티마이저에 가이드하는 방법에 대한 권장도 포함되어야 한다.
LLVM에는 Optimization Remarks라는 흥미로운 기능이 있다. 이 remark는 어떤 최적화가 수행되었는지 또는 놓쳤는지를 추적한다. Clang은 -fsave-optimization-record로 remark 기록을 지원하고, Rustc는 -Zremark-dir=<blah>를 지원한다. 또한 출력의 조회·이해를 돕는 도구(opt-viewer.py, optview2)도 있다.
다른 옵티마이저들도 이 접근에서 영감을 받아 비슷한 것을 할 수 있을 것이다.
최소한의 기준으로, LLVM을 사용하는 컴파일러는 동일한 데이터를 꺼내는 방법을 노출해야 한다. 자체 IR에서 최적화를 수행하는 컴파일러는 자체 최적화 레코드를 노출할 수 있다. 예를 들어 Swift는 자체 중간 언어에 대해 SIL optimization remarks를 제공한다.
데이터베이스 맥락에서는, 관계 연산자(relational operators) 수준에서 유사한 접근을 쓸 수 있다고 생각한다. 단, 최적화 의사결정의 일부로 사용된 다른 모든 런타임 정보도 함께 기록해야 한다는 점이 추가적인 주의사항이다.
사용 편의를 위해 이 툴링을 LSP 같은 에디터 확장과 통합하는 것도 가치가 있다. 사용량이 늘면 툴링 개선이 필요해질 가능성이 크고, 이는 다시 사용을 촉진해, 유지보수자의 자원(시간/역량)을 효과적으로 관리할 수 있다면 선순환을 만들 수도 있다.
마지막으로, 사용자는 놓친 최적화 리포트를 옵티마이저 개발자와 쉽게 공유할 수 있어야 한다. 내 경험상 가장 큰 난점은, 특히 여러 함수가 얽히면, 경험이 적은 사용자가 코드를 최소화(minimize)하고 익명화(anonymize)하는 데 시간이 많이 든다는 것이다. 버그 리포팅 툴링에 코드 익명화를 통합하는 것은 사용자에게 큰 도움이 될 것이다. 여기서 “코드 익명화”란, 식별자 일괄 이름 바꾸기, 주석 제거, 문자열/바이트/문자 리터럴의 난독화 등으로, 본질적으로 제어 흐름 구조만 보존되도록 하는 것을 의미한다. 물론 리플렉션처럼 이를 더 복잡하게 만들 수 있는 언어 기능들이 있다는 것은 안다. 하지만 최적화를 위한 익명화는 언어 기능의 100%를 지원할 필요는 없다.
성능 문제를 디버깅하느라 애쓰고, 옵티마이저를 어떻게든 도와주려는 방법을 찾고 있다면, 그때 할 수 있는 최선이 옵티마이저가 조용히 무시해도 되는 “힌트” 정도인 것은 아마 원치 않을 것이다.
사용자는 옵티마이저에 가이던스를 제공할 수 있어야 하며, 옵티마이저는 그 가이던스를 따르지 못할 때 조용히 넘어가는 대신, 따를 수 없음을 크게 알릴 수 있어야 한다. EDIT(2024 Oct 28): 예를 들어 앞서 문서화한 D와 C#의 경우처럼, 의미 분석 동안 추가 검사를 수행해 코드가 옵티마이저에 도달하기 전부터 성능 관련 성질을 강제할 수 있다. 이는 “여기서는 GC 없음” 같은 특정 흔한 성질에는 합리적인 설계 선택이지만, 사람들이 관심을 갖는 프로그램의 다양한 성능 측면을 포괄하는 완전한 해법으로는 충분하지 않다. 이는 맥락에 따라 오류일 수도, 경고일 수도 있다. EDIT(2024 Oct 28): 예를 들어 브라우저에서의 JavaScript나 WebAssembly처럼 최종 사용자 기기에서 동적으로 컴파일되는 코드의 경우, 브라우저 엔진은 “경고” 수준만 허용하고 싶어할 수도 있다.
반론이 있을 수 있다: 이렇게 하면 옵티마이저의 새 버전이 나올 때 코드가 더 취약해지지 않을까? 예를 들어, 의존성 X가 어떤 최적화 가이던스를 사용하고 있다. 툴체인을 업그레이드했더니, 갑자기 최적화가 발동하지 않아 X가 컴파일되지 않는다.
이에 대한 답은 몇 가지가 있다.
"optimization-guidance" 키-값 쌍을 명시하도록 요구할 수 있다. 이는 자동 생성 문서에 노출되어 다운스트림 소비자가 툴체인 업그레이드 시 회귀 위험을 더 잘 이해하도록 할 수 있다.데이터베이스 맥락에서는 특히, 더 많은 매직 코멘트를 추가하는 대신 SQL보다 더 명시적인 쿼리 언어를 지원하는 것이 말이 될 수도 있다. 느슨하게 관련되지만 읽을 가치가 있는 글: Jamie Brandon의 Against SQL. EDIT(Oct 28 2024): 이는 SQL을 없애거나 쿼리 플래닝을 완전히 없애자는 뜻이 아니다. 오히려 더 명시적인 플래닝은 데이터베이스 사용자의 도구상자에 추가되는 도구가 될 것이다.
프로덕션에서 어떤 성능 민감 코드의 처리량이 기대보다 훨씬 느린 버그를 마주쳤다고 하자. 문서를 읽어 옵티마이저가 어떻게 동작하는지 이해한다. 문제를 디버깅해서 최적화 누락 버그로 좁힌다. 이슈를 리포트하고 어떤 엔지니어가 이를 고친다; 그들은 최소 예제로 회귀 테스트를 추가한다. 당신은 그 수정이 최소하지 않은(즉, 실제) 코드에서도 동작함을 확인하고, 도구 버전을 업그레이드하고, 팀 Slack 채널에 승전보를 올린 뒤, 의기양양하게 의자에 기대어 쉰다.
몇 릴리스 후 다시 같은 루프를 밟아야 한다면 정말 아쉬울 것이다, 그렇지? 그 의기양양함은 금세 짜증으로 바뀔 것이다.
최적화가 순수하게 컴파일 타임 정보에만 기반한다면, 가이던스를 추가하는 것만으로 테스트로 충분하다; 가이던스가 위반되면 빌드가 실패할 테니. 하지만 이 접근은 유연성이 떨어진다는 단점이 있다: 새 가이던스마다 툴체인 변경이 필요하다. 이는 할당 회피나 루프 언롤링 같은 흔한 것에는 말이 될 수 있다. 하지만 특정 명령을 확인하는 것 같은 더 특수한 용례에서는, 테스트 코드에서 최적화된 코드(IR이든 어셈블리든)에 대한 어서션을 할 수 있으면 가치가 있다. 물론 이는 IR 설계, API 설계, 옵티마이저 버전 간 호환성 유지 같은 새로운 도전과제를 도입한다는 것도 안다. 지금 당장 모든 해법을 갖고 있진 않다; 내 요점은 이것이 도전할 가치가 있는 문제처럼 보인다는 것이다.
EDIT(2024/10/24): Brendan Zabarauskas가 inspection testing을 위한 Glasgow Haskell Compiler(GHC) 플러그인을 상기시켜줬다. 이는 할당에 대한 고수준 어서션, 특정 함수 사용 여부 확인 등등을 가능하게 한다. (usage example) 고마워요 Brendan!
또 어떤 경우에는 최적화가 순수한 컴파일 타임 정보에만 기반하지 않는다: 인터프리터와 JIT(just-in-time) 컴파일러가 그런 경우다.
그런 경우에는 서로 다른 입력 클래스 전반에서 생성된 코드의 성질을 단언하는 테스트를 작성할 수 있어야 한다.
예를 들어, 어떤 테이블 T에 대해 특정 인덱스 Y를 index-only scan에 사용하라는 최적화 가이던스를 데이터베이스 쿼리에 추가하고 싶다고 하자. 전제는 인덱스 Y를 사용하는 것이 T의 다른 인덱스를 사용하는 것보다 항상 낫다는 것이다. 하지만 이 전제는 인덱스 통계에 따라, 또는 동료(혹은 미래의 나)가 실제로 더 적합한 다른 인덱스 Z를 추가할 때 깨질 수 있다.
가이던스의 강도에 따라, 테스트로 이 전제를 포착하는 방법은 두 가지가 있다:
쿼리 플래너가 Y를 쓰도록 강제하는 강한 가이던스를 사용한다면: * 다양한 인덱스 통계에 걸친 테스트를 추가해(효과를 위해서는 아마도 프로퍼티 기반 테스트가 되어야 하거나, 어쩌면 abstract interpretation까지도 필요할 수 있다) Y를 쓰는 쿼리 플랜이 대안 선택보다 더 저렴함을 보여줄 수 있다(예: 선택을 옵티마이저에 맡겨 대안 플랜을 생성하게 함으로써). 이 테스트는 Z가 추가될 때 실패해야 한다.
Y를 사용하지 않으면 쿼리 플래너가 경고를 내는 약한 가이던스를 사용한다면: * 관련 입력 통계 집합에 대한 테스트를 추가해, 통계가 기대 운용 범위에 들어올 때 쿼리 플래너가 항상 Y를 고르도록 단언한다. * 약한 가이던스 위반을 알림 인프라에 연결한다. * 런타임의 실제 통계가 테스트에서 사용한 운용 범위에 들어오는지 확인하는 알림을 설정한다.
전반적으로 이는 풀기 어려운 문제이며, 테스트 API 측면에서 만능 해법은 없을 것이라 생각하지만, 해결할 가치가 있는 문제처럼 보인다.
Proebstring’s Law을 들어본 적이 있을 것이다:
컴파일러 최적화의 발전은 18년마다 컴퓨팅 파워를 두 배로 만든다
알고 보니 실제로는 그보다 더 나쁘다. 정확히 무엇을 보느냐에 따라, 그 숫자는 36년이거나 그보다 더 나쁠 수도 있다. 내가 찾은 글과 논문 일부는 다음과 같다:
끝에서 Proebsting은 이렇게 결론짓는다:
어쩌면 이는 프로그래밍 언어 연구가 최적화가 아닌 다른 것에 집중해야 함을 의미할지도 모른다. 어쩌면 프로그래머 생산성이 더 비옥한 분야일지도 모른다.
나는 프로그래머 생산성과 최적화의 교차점을 탐구할 기회가 적지 않다고 생각한다. 옵티마이저를 더 이해 가능하고, 신뢰할 수 있고, 예측 가능하게 만들고, 뒤에서 조용히 움직이는 존재가 아니라 프로그래머와 함께 일하도록 설계함으로써 말이다.
Discussions: Hacker News, Lobste.rs