プログラミング
私ではなく、コンパイラの問題だ
It's not me, it's the compiler (parsa.wtf)
要約
この記事は、開発者がJavaScriptエンジンのパーサーをリファクタリング中に遭遇したコンパイラバグの体験談です。当初、`bool`値を`u32`にキャストするコードをより簡潔な形式に変更したところ、予期せぬパースエラーが発生しました。明示的なif文を使ったバージョンに戻すと問題が解消したため、開発者はコンパイラの問題だと確信し、その原因をアセンブリコードを調査して突き止めました。
全文翻訳
私ではなく、コンパイラの問題だ!
すべてのプログラマーは、少なくとも一度は「私ではなく、コンパイラの問題だ!」と思ったことがあるでしょう。通常、それは間違いなのですが、今回は実際に私が正しかった時の話です。
土曜の夜、いつものように、私はJavaScriptエンジンのパーサーをリファクタリングしていました。以前、プロジェクトに次のようなコードを書いていました。
```rust
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
}
}
}
```
これはそれ自体では非常に一般的なパターンですが、このコードの形は気に入っていませんでした。各分岐は比較の結果を返していたので、自分で値を返すようにしたらどうなるか?と考えました。
作業に取り掛かり、代わりにこのバージョンを書き、生成されたアセンブリの美しさに満足しました。それは数バイト短く、分岐もありませんでした。
```rust
#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
let x = self.peek(store) == expected;
self.0 += x as u32;
x
}
```
生成されたアセンブリ:
```asm
mov eax,DWORD PTR [rdi]
mov ecx,eax
and ecx,0x3f
movzx ecx,BYTE PTR [rsi+rcx*1]
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+r8*1],dl
sete al
add ecx,eax
mov DWORD PTR [rdi],ecx
ret
```
そこで、簡単なステートメントをパースしてみたところ、bash履歴のトップにあったforループをパースするコマンドを実行しました。
```bash
> 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` は私が覚えているのとは異なるセマンティクスを持っているのか…?もっと明示的に書いてみよう。
```rust
#[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
}
```
すると、なぜかこのバージョンは動作しました!
```bash
> 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] BlockStatementFor
```
一瞬、自分の正気を疑いましたが、`bool`が`u32`にキャストされることがどういう意味かを知っていると確信していました。私はその正確なキャストを何百回も書いてきました。その瞬間、私は絶望的でわずかに混乱したプログラマーなら誰でも言うであろうことを言いました。「私ではなく、コンパイラの問題だ!」
変更前のforステートメントパーサーが実際に行っていることを調べる必要がありました。幸いなことに、私の社内カスタムビルドシステムを使えば、どんな関数のアセンブリを見るのもコマンド一つで済みます。そして、それはcargoのasmラッパーではありません。私のプロジェクトはcargoを使用していません。TypeScriptで独自のビルドシステムを作成し、Denoで実行しているため、`--dry`モードもサポートしており、内部で何が実行されているかを確認できます。透明性万歳!
```bash
> 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 --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
# line breaks added for readability, btw. you're welcome.
```
とにかく…アセンブリの検査に戻りますが、おそらくまずRustのバージョンを見るべきでしょう。なので、コードをトリムしたバージョンを以下に示します。実際の関数は数百行あります:D
```rust
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(); // `for`
if lex.peek_test(store, TokenKind::Await/*=0x6e*/) {
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/*=0x04*/) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E058);
}
match lex.peek(store) {
TokenKind::Var => {
/* */
}
// ...
_ => {
stack.use_flags(store, FlagDiff::clear(Flag::IN)); // [~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),
}
}
```
そして、その公開:
```asm
> 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+rax*1]
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+rax*1+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+r9*1+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+r9*8+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`がどうなっていたかはすでに知っていました。それはレジスタからスタックへのスピルがいくつかあるモンスターで、それをなくそうとしていました。