파서 리팩터링 중 `bool as u32` 캐스트가 Rust 컴파일러의 실제 오컴파일을 드러낸 이야기와, 이를 최소 재현 예제로 좁혀 이슈를 올리고 하루도 안 되어 수정 PR이 올라온 과정을 다룹니다.
모든 프로그래머는 적어도 한 번쯤 "문제는 내가 아니라 컴파일러야!"라고 생각해 본 적이 있다. 보통은 우리가 틀리지만, 이번 이야기는 내가 실제로 맞았던 그때의 이야기다.
토요일 저녁, 늘 그렇듯이 나는 내 JavaScript 엔진의 파서를 리팩터링하고 있었고, 그보다 앞서 프로젝트에 이런 코드를 작성해 두었었다:
impl LexerConsumer { #[inline] pub fn consume(&mut self) { self.0 += 1; }
#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
if self.peek(store) == expected {
self.consume();
true
} else {
false
}
}
}
이 자체로는 파서에서 꽤 흔한 패턴이지만, 나는 이 코드의 형태가 썩 마음에 들지 않았다. 각 분기에서 비교 결과 값을 그대로 반환하고 있으니, 그렇다면 내가 직접 그 값을 반환하면 어떨까?
그래서 작업에 들어가 이렇게 바꿔 썼고, 단독으로 생성된 asm의 모양은 꽤 마음에 들었다. 몇 바이트 더 짧았고 분기도 없었다.
#[inline] pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool { let x = self.peek(store) == expected; self.0 += x as u32; x }
mov eax,DWORD PTR [rdi] mov ecx,eax and ecx,0x3f movzx ecx,BYTE PTR [rsi+rcx1] cmp cl,dl jne 233263 inc eax mov DWORD PTR [rdi],eax cmp cl,dl sete al ret mov ecx,DWORD PTR [rdi] mov r8d,ecx and r8d,0x3f xor eax,eax cmp BYTE PTR [rsi+r81],dl sete al add ecx,eax mov DWORD PTR [rdi],ecx ret
그래서 곧바로 간단한 문장을 파싱해 보려 했고, bash 히스토리 맨 위에는 마침 for 루프를 파싱하는 명령이 있었다.
joe parse - 'for (var lol; false; false) {}' Error was found Diagnostic { kind: E079, flag: Flag(11529215046068469773), byte: 5, current_token: Var } Parse error
잠깐! 방금 무슨 일이 일어난 거지?! 내가 함수 구현을 잘못 건드린 걸까? 어쩌면 x as u32의 의미를 내가 기억하는 것과 다를 수도 있겠다... 일단 더 명시적으로 써 보자.
#[inline] pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool { let x = self.peek(store) == expected; self.0 += if x { 1 } else { 0 }; x }
그런데 이상하게도 이 버전은 제대로 동작했다!
joe parse - 'for (var lol; false; false) {}' Parsed 8 nodes in 14ns
Raw nodes: [0] POS=0 ScriptStart payload=0 [1] POS=9 VariableDeclaration payload=0 [2] POS=14 FalseLit payload=0 [3] POS=21 FalseLit payload=0 [4] POS=28 BlockStatementStart payload=0 [5] POS=29 BlockStatement payload=0 [6] POS=0 ForStatement payload=0 [7] POS=0 Script payload=0
POS=000 [7] Script POS=000 [6] ForStatement POS=009 [1] VariableDeclaration @A0 POS=014 [2] FalseLit POS=021 [3] FalseLit POS=029 [5] BlockStatement
잠깐 동안은 내 제정신을 의심했지만, bool as u32가 무엇을 하는지는 꽤 확신하고 있었다. 나는 그 캐스트를 정말 수백 번은 써 왔으니까. 그래서 그 순간 나는 절박하고 약간 혼란스러운 프로그래머라면 누구나 할 법한 말을 했다.
범인은 내가 아니라 컴파일러야!
내 for 문 파서가 실제로 무엇을 하고 있는지 봐야 했다. 다행히도 내가 직접 만든 사내용 커스텀 빌드 시스템 덕분에 어떤 함수의 어셈블리든 명령 한 번이면 볼 수 있었다. 그리고 아니다, 이건 cargo asm 래퍼가 아니다. 내 프로젝트는 cargo를 쓰지 않는다. TypeScript로 직접 빌드 시스템을 만들었고 Deno 위에서 돌리기 때문이다. 게다가 Deno는 --dry 모드도 지원해서 내부적으로 어떤 명령을 실행하는지도 볼 수 있다. 만세, 투명성!
x -b --fn ForOrInOfStatement 1 --dry
MKDIR ./out RUN rustc src/main.rs --crate-name=joe --crate-type=staticlib --edition=2024 --out-dir=./out --target=x86_64-unknown-linux-gnu --cfg joe_no_libc --emit=link,obj -Crelocation-model=static -Copt-level=3 -Clto -Ccodegen-units=1 -Cdebuginfo=line-tables-only --diagnostic-width=150 -Cpanic=abort -Ztemps-dir=out/tmp -Zhuman-readable-cgu-names --extern proc=./out/libproc.so
RUN mold -melf_x86_64 -o ./out/joe ./out/joe.o --static --package-metadata="Joe!" -zrelro -znoexecstack --discard-locals --build-id --gc-sections --no-undefined --icf=safe --compress-debug-sections=zlib
RUN bash -c nm out/joe | rustfilt | grep ForOrInOfStatement | grep -oP '^[0-9a-f]+ t\s+\K.+' | sed -n '1p' | xargs -I {} objdump -WK -M intel -d --disassembler-color=on --visualize-jumps=color --demangle=rust ./out/joe --disassemble={} 2>/dev/null | grep -v 'Disassembly of section' | grep -v './out/joe:' | less -R
아무튼... 다시 어셈블리를 살펴보자. 하지만 아마 먼저 Rust 코드 버전을 보는 편이 좋겠다. 그래서 몇백 줄짜리 실제 함수 대신 다듬은 축약 버전을 가져왔다 :D
fn ForOrInOfStatement( store: &mut Storage, mut lex: LexerConsumer, mut emit: EmitBuffer, mut stack: StateStack, ) -> Termination { debug_assert_eq!(lex.peek(store), TokenKind::For); lex.consume();
if lex.peek_test(store, TokenKind::Await) {
if !stack.has_flag(Flag::AWAIT) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E056);
}
if !lex.consume_test(store, TokenKind::LParen) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E057);
}
wip!()
}
if !lex.consume_test(store, TokenKind::LParen) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E058);
}
match lex.peek(store) {
TokenKind::Var => { }
_ => {
stack.use_flags(store, FlagDiff::clear(Flag::IN));
stack.push(store, State::ForOrInOfStatement_decide);
LeftHandSideExpression(store, lex, emit, stack)
},
}
}
fn PrimaryExpression( store: &mut Storage, mut lex: LexerConsumer, mut emit: EmitBuffer, mut stack: StateStack, ) -> Termination { match lex.peek(store) {
_ => raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E079),
}
}
그리고 이제 공개의 순간:
x -b --fn ForOrInOfStatement 1 000000000021e290 joe::fe::parser_handlers::ForOrInOfStatement: 21e290: ff c6 inc esi 21e292: 89 f0 mov eax,esi 21e294: 83 e0 3f and eax,0x3f 21e297: 0f b6 04 07 movzx eax,BYTE PTR [rdi+rax1] 21e29b: 83 f8 04 cmp eax,0x4 # cmp TokenKind::LParen 21e29e: ,----- 75 70 jne 21e310 joe::fe::parser_handlers::ForOrInOfStatement+0x80 21e2a0: | 4d 85 c0 test r8,r8 21e2a3: | ,-- 78 14 js 21e2b9 joe::fe::parser_handlers::ForOrInOfStatement+0x29 21e2a5: | | 41 0f b6 c0 movzx eax,r8b 21e2a9: | | c6 84 07 10 56 00 00 mov BYTE PTR [rdi+rax1+0x5610],0x20 21e2b0: | | 20 21e2b1: | | 49 ff c0 inc r8 21e2b4: | | e9 d7 45 00 00 jmp 222890 joe::fe::parser_handlers::LeftHandSideExpression 21e2b9: | '-> 48 b8 00 ff ff ff ff movabs rax,0x7fffffffffffff00 21e2c0: | ff ff 7f 21e2c3: | 4c 21 c0 and rax,r8 21e2c6: | 45 0f b6 c8 movzx r9d,r8b 21e2ca: | 42 c6 84 0f 10 56 00 mov BYTE PTR [rdi+r91+0x5610],0x0 21e2d1: | 00 00 21e2d3: | 45 89 c9 mov r9d,r9d 21e2d6: | 4d 89 c2 mov r10,r8 21e2d9: | 49 c1 ea 10 shr r10,0x10 21e2dd: | 49 bb 00 00 00 00 ff movabs r11,0xffff00000000 21e2e4: | ff 00 00 21e2e7: | 4d 21 d3 and r11,r10 21e2ea: | 4e 89 9c cf d0 56 00 mov QWORD PTR [rdi+r98+0x56d0],r11 21e2f1: | 00 21e2f2: | 41 ff c0 inc r8d 21e2f5: | 45 0f b6 c0 movzx r8d,r8b 21e2f9: | 49 09 c0 or r8,rax 21e2fc: | 41 0f b6 c0 movzx eax,r8b 21e300: | c6 84 07 10 56 00 00 mov BYTE PTR [rdi+rax*1+0x5610],0x20 21e307: | 20 21e308: | 49 ff c0 inc r8 21e30b: | e9 80 45 00 00 jmp 222890 joe::fe::parser_handlers::LeftHandSideExpression 21e310: '----> 48 89 ca mov rdx,rcx 21e313: 83 f8 6e cmp eax,0x6e # cmp TokenKind::Await 21e316: ,-- 75 15 jne 21e32d joe::fe::parser_handlers::ForOrInOfStatement+0x9d 21e318: | 4c 89 c1 mov rcx,r8 21e31b: | 49 0f ba e0 3d bt r8,0x3d 21e320: ,--|-- 72 19 jb 21e33b joe::fe::parser_handlers::ForOrInOfStatement+0xab 21e322: | | 41 b8 37 00 00 00 mov r8d,0x37 21e328: | | e9 b3 ae 00 00 jmp 2291e0 joe::fe::parser::raise_diagnostic 21e32d: | '-> 4c 89 c1 mov rcx,r8 21e330: | 41 b8 39 00 00 00 mov r8d,0x39 21e336: | e9 a5 ae 00 00 jmp 2291e0 joe::fe::parser::raise_diagnostic 21e33b: '----> 41 b8 38 00 00 00 mov r8d,0x38 21e341: e9 9a ae 00 00 jmp 2291e0 joe::fe::parser::raise_diagnostic
아! 이거였구나?! 내 match 문은 어디로 간 거지?! 바꾸기 전의 ForOrInOfStatement가 어떤 모습이었는지는 이미 알고 있었다. 그건 그 주 초에 없애려고 하던 레지스터-스택 spill이 몇 개 섞인 괴물 같은 함수였다. 하지만 asm을 더 자세히 보면 TokenKind::LParen과의 비교가 보이는데, 이건 거의 확실히 consume_test(store, TokenKind::LParen)에서 온 것이다. 다만 그 전에 파서의 ABI, 특히 지금 Rust의 기본 동작인 SysV ABI에 대해 잠깐 짚고 가자.
pub struct LexerConsumer(u32 , u32 ); pub struct EmitBuffer(usize); pub struct StateStack(u64);
fn Foo(
store: &mut Storage,
mut lex: LexerConsumer,
mut emit: EmitBuffer,
mut stack: StateStack,
) -> Termination {...}
이제 어셈블리는 아주 읽기 쉬워진다:
for21e290: ff c6 inc esi
21e292: 89 f0 mov eax,esi 21e294: 83 e0 3f and eax,0x3f
21e297: 0f b6 04 07 movzx eax,BYTE PTR [rdi+rax*1]
21e29b: 83 f8 04 cmp eax,0x4
21e29e: 75 70 jne 21e310 joe::fe::parser_handlers::ForOrInOfStatement+0x80
consume_test 안에 있다.self.0 += x as u32가 있지 않았나?INC ESI는 누가 먹어치운 거야?!그래서 맞다. 사실상 이걸로 컴파일러가 잘못됐다는 확인이 끝난 셈이었다. 그 다음은 최소 재현 예제를 만드는 일이었다. 그리고 내가 1.94.0-nightly를 쓰고 있었기 때문에 혹시 nightly에서는 이미 고쳐졌나 싶어 확인해 봤지만, 아니었다! 그러니 원인을 찾아봐야 했다. 첫 시도는 최적화 전 LLVM IR을 보는 것이었고, 그래서 빌드 플래그에 -Cno-prepopulate-passes를 추가했다. 그러자 초기화되지 않은 메모리에서 값을 읽는 load가 보였다. 그리고 이 문제가 rust/mir 쪽인지 llvm 쪽인지 구분해 보기 위해 -Zmir-opt-level=0도 써 봤는데, 그러자 문제가 사라졌다.
나는 이슈를 열었고, 처음에는 조금 긴장했다. "이게 이렇게 흔한 패턴처럼 보이는데, 혹시 내가 그냥 멍청한 실수를 한 거라 공개적으로 망신당하는 건 아닐까?" 그래서 이슈 제목에 "Suspected miscompilation"이라고 썼다. 혹시 내가 바보 같은 고양이였을 경우를 대비해 그 단어 뒤에 숨으려 했던 것이다 (야옹). 그리고 이슈를 올릴 때쯤에는 이미 늦은 시간이어서 자러 가야 했다.
다음 날 눈을 떠 보니, 이미 수정 PR이 올라와 있었고, Hanna Kruppe가 올린 것이었다. 이보다 더 좋을 수 없는 대응 속도였다.
이 이슈에는 p-critical과 i-miscompile 라벨이 붙었다. 6만 1천 개가 넘는 Rust 이슈 중에서 이 둘이 동시에 붙은 것은 이것을 포함해 겨우 7개뿐이다. 내게 이런 종류의 버그는 컴파일러가 가질 수 있는 버그 중 가장 위험하다. 프로그래머와 언어 사이의 계약을 깨뜨리기 때문이다. 우리가 작성한 모든 safe 코드가 실제로 safe한 것은 아니라는 사실을 보여 준다. 그리고 더 일반적으로 봐도 p-critical 이슈 자체가 애초에 247개밖에 없다.
가장 좋았던 부분은 커뮤니티가 이 이슈에 정말 빠르게 반응했고, 실제 수정이 18시간 안에 머지되었다는 점이었다. 이 이야기에서 가장 인상적이었던 부분이 바로 그것이고, 빠른 대응을 해 준 tmiasko와 hanna-kruppe에게 큰 박수를 보낸다.
이제 나는 rustc와 싸워서 이긴 날의 이야기를 살아서 전할 수 있게 됐다 :D