Skip to content

에뮬레이터가 고쳐야 했던 너무나 나쁜 코드

컴파일러의 최적화 시도는 64KB 메모리를 0으로 초기화하기 위해 256KB의 코드를 생성했습니다. 이 엄청난 실패로 인해 에뮬레이터는 손상된 코드를 실시간으로 다시 작성해야 했고, '영리한' 코드에 대한 시대를 초월한 교훈을 드러냈습니다.

Nora Vance
Hero image for: 에뮬레이터가 고쳐야 했던 너무나 나쁜 코드

요약 / 핵심 포인트

컴파일러의 최적화 시도는 64KB 메모리를 0으로 초기화하기 위해 256KB의 코드를 생성했습니다. 이 엄청난 실패로 인해 에뮬레이터는 손상된 코드를 실시간으로 다시 작성해야 했고, '영리한' 코드에 대한 시대를 초월한 교훈을 드러냈습니다.

64KB 작업을 위한 256KB 버그

단 64킬로바이트의 스택 메모리를 초기화하기 위해 무려 256킬로바이트에 달하는 기계 코드를 생성할 정도로 엄청나게 잘못 인도된 컴파일러를 상상해 보세요. 이것은 획기적인 AI 모델이나 복잡한 시뮬레이션을 위한 것이 아니었습니다. 이 프로그램의 유일하고 믿을 수 없을 정도로 기본적인 목표는 메모리 블록을 0으로 만드는 것이었습니다. 이는 몇 개의 명령으로 실행되어야 하는 효율성의 전형이 되어야 할 기본적인 작업입니다. 이는 작은 못을 박기 위해 큰 망치를 사용하는 것과 같은 디지털적 비유입니다.

그러나 Windows x86 emulator 시절, 한 컴파일러는 순수한 프로그래밍적 오만을 저질렀습니다. 메모리를 지우기 위한 간결하고 효율적인 루프를 생성하는 대신, 전체 작업을 완전히 언롤(unroll)했습니다. 이 치명적인 '최적화'는 65,000개 이상의 개별 바이트 쓰기 명령으로 불어났고, 각 명령은 바이너리에서 별개의 고유한 명령이었습니다.

모든 명령은 단일 바이트를 0으로 설정하는 별개의 고된 단계가 되었습니다. 결과적으로 실행 가능한 코드는 초기화하려던 데이터 크기의 4배로 부풀어 올랐고, 터무니없는 4:1 코드 대 데이터 크기 비율을 만들었습니다. 컴파일러 휴리스틱이 얼마나 심각하게 실패할 수 있는지를 극명하게 보여주는 이 엄청난 코드 비대화는 에뮬레이터 팀이 '그래, 우린 저걸 실행하지 않을 거야'라고 선언하고 접근 방식을 근본적으로 재구성하게 만들었습니다.

'최적화'가 문제가 될 때

컴파일러 설계자들은 종종 영리한 최적화를 통해 성능 향상을 추구하며, 루프 언롤링(loop unrolling)이 대표적인 예입니다. 이 합법적인 기술은 루프 제어 오버헤드(카운터 증가 및 조건부 분기 제거)를 줄이고 명령어 수준 병렬성을 노출하여 이론적으로 명령어 파이프라이닝을 개선하고 메모리 지연을 숨기는 것을 목표로 합니다. 본질적으로, 이는 반복적인 제어 로직을 더 길고 직선적인 일련의 연산으로 대체하는 것입니다.

그러나 Windows x86 emulator 시절에 작동하던 이 컴파일러는 이 개념을 상식을 넘어섰습니다. 간결한 루프 대신, 단 64킬로바이트의 스택 메모리를 0으로 만들기 위해 각각 단일 바이트를 쓰는 65,000개 이상의 개별 명령을 생성했습니다. 이것은 최적화가 아니었습니다. 이는 기계 코드를 256킬로바이트로 부풀린 치명적인 오산이었고, 코드 대 데이터 비율이 무려 4:1에 달했습니다.

이러한 극심한 코드 비대화(code bloat)명령어 캐시(instruction cache)를 완전히 망가뜨렸습니다. 언롤링으로 인한 이론적인 속도 향상은 CPU가 느린 메모리에서 새로운 명령어를 끊임없이 가져오면서 중복된 코드로 캐시를 낭비함에 따라 사라졌습니다. 이 순진한 컴파일러 휴리스틱은 추상적인 최적화 이론과 하드웨어 제약의 불변하는 현실 사이의 심각한 단절을 보여주며 엄청나게 실패했습니다. 에뮬레이터 팀의 '그래, 우린 저걸 실행하지 않을 거야'라는 직설적인 평가는 그 부조리함을 완벽하게 담아냈습니다.

런타임의 영웅: JIT Compiler의 실시간 재작성

Windows x86 emulators 시대에 기이한 시나리오가 펼쳐졌습니다. 이들은 런타임에 x86 코드를 네이티브 명령어 세트로 번역하도록 설계된 정교한 시스템입니다. 이 에뮬레이터들은 Dynamic Binary Translation (DBT)을 사용했으며, '기본적으로 JIT compiler처럼' 작동하여 다른 아키텍처용으로 컴파일된 애플리케이션을 실행했습니다. 이는 컴파일러의 실수를 막는 유일한 방어 수단이 되는 경우가 많았던 중요한 기능이었습니다.

Emulator 엔지니어들은 실시간으로 병적인 비효율성을 빠르게 발견했습니다. 64 kilobytes의 stack memory를 0으로 만드는 작업만을 위해 256 kilobytes의 풀린 machine code를 마주했을 때, 그들의 집단적인 반응은 단호했습니다: "네, 저희는 그걸 실행하지 않을 겁니다." 65,000개가 넘는 개별 byte-writing instructions에 달하는 엄청난 코드 비대화는 성능을 저하시키고 코드를 사용할 수 없게 만들었습니다.

영웅적인 runtime 솔루션이 나타났습니다. Emulator 팀은 그들의 binary translator 내부에 특별한 감지 기능을 구현했습니다. 시스템이 이 특정하고 극도로 최적화되지 않은 패턴을 만났을 때, 손상된 코드를 가로챘습니다. compiler의 재앙적인 결과물을 실행하는 대신, runtime system은 이를 버리고 즉석에서 적절하고 tight loop를 동적으로 생성하여 메모리 0 초기화를 정확하고 효율적으로 수행했습니다. 이 실시간 재작성은 궁극적인 runtime 영웅이었습니다. 이러한 역사적인 emulator의 영웅적인 이야기에 대해 더 자세히 알아보려면 다음을 참조하십시오: The time the x86 emulator team found code so bad that they fixed it during emulation - The Old New Thing.

너무 과도하게 노력한 Compiler로부터의 교훈

256KB 버그가 생생하게 보여주듯이, Optimization은 위험한 균형 잡기입니다. 단 64KB의 stack memory를 초기화하기 위한 compiler의 공격적인 loop unrolling은 터무니없는 4:1의 code-to-data bloat를 초래하여 65,000개 이상의 instructions을 생성했습니다. 이 병적인 결과는 "더 최적화된" 것이 종종 "훨씬 더 나쁜" 것을 의미할 수 있음을 증명합니다.

Enjoying this? Get one like it in your inbox each morning.

one email a day · unsubscribe in two clicks · no third-party tracking

다행히도, 현대의 compilers는 이 교훈을 배웠습니다. 오늘날 LLVM 및 GCC와 같은 도구에서 사용하는 정교한 비용-편익 모델은 code size, cache locality, instruction pipeline efficiency와 같은 요소를 세심하게 측정합니다. 이러한 모델은 한때 성능을 저하시켰던 무분별한 optimization을 방지합니다.

결정적으로, JIT compilers와 dynamic binary translators는 여전히 중요합니다. Java Virtual Machines, .NET Runtimes, Apple’s Rosetta 2와 같은 시스템은 runtime에 코드를 지속적으로 모니터링하고 조정합니다. 이들은 일반적인 경우만을 위해 optimize하는 것이 아니라, 특정 workloads와 hardware에 맞춰 동적으로 튜닝하며, 중요한 방어 계층 역할을 합니다.

Better Stack의 "This Code Was So Bad the Emulator Rewrote It Live"에서 강조된 이 역사적인 일화는 runtime systems의 심오한 힘을 강조합니다. 이들은 성능 튜닝뿐만 아니라 상위 code generation의 엄청난 오류를 적극적으로 수정하여, 관리할 수 없는 비대화를 즉석에서 효율적인 실행으로 바꾸는 중요한 최후의 방어선을 제공합니다.

자주 묻는 질문

compiler optimization에서 loop unrolling이란 무엇입니까?

Loop unrolling은 compiler가 루프를 루프 본문의 반복 시퀀스로 대체하는 기술입니다. 이는 루프 제어 오버헤드(예: 카운터 확인)를 줄이지만 전체 code size를 증가시킵니다.

compiler는 왜 64KB의 memory를 0으로 만들기 위해 256KB의 code를 생성했습니까?

compiler는 loop unrolling을 극단적으로 적용하여, 간단한 메모리 0 초기화 루프를 각 byte당 하나씩, 65,000개 이상의 개별 instructions으로 변환했습니다. 이로 인해 간단한 작업에 대해 4배의 엄청난 code bloat가 발생했습니다.

JIT (Just-In-Time) compiler란 무엇입니까?

Just-In-Time (JIT) compiler는 많은 runtime systems의 기능으로, 실행 중에 code를 machine instructions으로 번역합니다. 이를 통해 코드가 실제로 어떻게 사용되는지에 따라 적응형 optimizations을 수행할 수 있습니다.

emulator는 비효율적인 code를 어떻게 수정했습니까?

x86 emulator는 runtime에 instructions의 특정하고 병적인 패턴을 감지하는 dynamic binary translator (JIT와 같은)를 사용했습니다. 그런 다음 256KB의 잘못된 code를 버리고 즉석에서 단일하고 효율적인 루프로 동적으로 대체했습니다.

Found this useful? Share it.

One short daily email of tools worth shipping. No drip funnel.

one email a day · unsubscribe in two clicks · no third-party tracking

🚀더 알아보기

AI 트렌드를 앞서가세요

Stork.AIが엄선한 최고의 AI 도구, 에이전트, MCP 서버를 만나보세요.

P.S. 쓸 만한 걸 만드셨나요? Stork에 등록