名前マングリングでVS Codeを縮小
2023年7月20日 Matt Bierner (@mattbierner)
私たちは最近、Visual Studio Codeが出荷するJavaScriptのサイズを20%削減しました。これは3.9MB以上の削減に相当します。確かに、リリースノートにある個々のGIF画像よりは小さいかもしれませんが、それでも決して軽視できるものではありません!この削減は、ダウンロードしてディスクに保存する必要があるコードが少なくなるだけでなく、JavaScriptが実行される前にスキャンする必要があるソースコードが少なくなるため、起動時間の改善にもつながります。コードを一切削除せず、コードベースの大きなリファクタリングもなしにこの削減を達成したことを考えると、なかなかの成果ではないでしょうか。代わりに必要だったのは、新しいビルドステップ、すなわち名前のマングリング(name mangling)だけでした。
この記事では、私たちがどのようにしてこの最適化の機会を見つけ、問題へのアプローチを探り、最終的にこの20%のサイズ削減を実現したかをお話ししたいと思います。これは、マングリングの具体的な詳細に焦点を当てるというよりは、VS Codeチームがどのように技術的な問題に取り組んでいるかというケーススタディとして扱いたいと思います。名前のマングリングは便利なテクニックですが、多くのコードベースではその価値がないかもしれませんし、私たちの特定のマングリング方法は改善の余地があるかもしれません(または、プロジェクトのビルド方法によってはまったく必要ないかもしれません)。
問題の特定
VS Codeチームはパフォーマンスに情熱を注いでいます。それは、頻繁に実行されるコードパスの最適化、UIの再レイアウトの削減、起動時間の短縮など多岐にわたります。この情熱には、VS CodeのJavaScriptのサイズを小さく保つことも含まれます。デスクトップアプリケーションに加えて、Web版(https://vscode.dev)でもVS Codeが提供されるようになったことで、コードサイズはさらに重要な焦点となっています。コードサイズを積極的に監視することで、VS Codeチームのメンバーはその変化に常に気づくことができます。
残念ながら、これらの変化はほとんどの場合、増加という形でした。私たちはVS Codeにどのような機能を組み込むかについて多くのことを考えていますが、長年にわたって新機能を追加してきた結果、出荷するコードの量は必然的に増えてきました。例えば、VS CodeのコアJavaScriptファイルの1つ(workbench.js
)は、8年前の約4倍のサイズになっています。しかし、8年前のVS Codeには、エディタータブや内蔵ターミナルなど、今日では多くの人が不可欠と考える機能が欠けていたことを考えると、この増加は聞こえるほどひどいものではないかもしれませんが、無視できるものでもありません。
その4倍のサイズ増加も、継続的なパフォーマンスエンジニアリングの多くの作業を経た後のものです。繰り返しになりますが、この作業が行われるのは、私たちがコードサイズを追跡し、それが増加するのを本当に嫌っているからです。私たちはすでに、コードをesbuildに通して最小化するなど、簡単なコードサイズの最適化を数多く行ってきました。さらなる削減を見つけることは年々難しくなってきています。潜在的な削減の多くは、それがもたらすリスクや、実装・維持に必要な追加のエンジニアリング努力に見合わないものでもあります。これは、JavaScriptのサイズがゆっくりと増え続けるのを見守るしかなかったことを意味します。
しかし、昨年vscode.devで最小化されたソースコードをデバッグしているときに、私は驚くべきことに気づきました。最小化されたJavaScriptには、extensionIgnoredRecommendationsService
のような長い識別子名がまだたくさん含まれていたのです。これには驚きました。esbuildがすでにこれらの識別子を短縮していると思っていたからです。そして、esbuildは実際、一部のケースでは「マングリング」(JavaScriptツールがコンパイル言語での似て非なるプロセスから借用したであろう用語)と呼ばれるプロセスを通じて識別子を短縮することがわかりました。
最小化の過程で、マングリングは長い識別子名を短縮し、次のようなコードを変換します。
const someLongVariableName = 123;
console.log(someLongVariableName);
より短いコードへ
const x = 123;
console.log(x);
JavaScriptはソーステキストとして出荷されるため、識別子名の長さを減らすことは、実際にプログラムのサイズを減少させます。コンパイル言語出身の方には、この最適化は少しばかげているように思えるかもしれませんが、ここJavaScriptの素晴らしい世界では、このような利益はどこでも喜んで受け入れます!
さて、すべての変数を一文字の名前に変更しようと急ぐ前に、このような最適化には慎重に取り組む必要があることを強調しておきたいと思います。潜在的な最適化がソースコードの可読性や保守性を損なったり、大幅な手作業を必要としたりする場合、それが本当に素晴らしい改善をもたらさない限り、ほとんどの場合その価値はありません。あちこちで数バイトを削るのは良いことですが、素晴らしいとは言えません。
しかし、ビルドツールに自動的に行わせるなど、このような素晴らしい最適化を実質的に無料で手に入れることができれば、話は変わってきます。そして実際、esbuildのような賢いツールはすでに識別子のマングリングを実装しています。つまり、私たちはObjectiveCプログラマーでさえも赤面するような非常に長くて説明的な名前
を書き続け、ビルドツールにそれを短縮させることができるのです!
esbuildはマングリングを実装していますが、デフォルトでは、マングリングがコードの動作を変更しないと確信できる場合にのみ名前をマングルします。結局のところ、バンドラーにコードを壊されるのは本当に嫌なことです。実際には、これはesbuildがローカル変数名と引数名をマングルすることを意味します。これは、コードが本当に馬鹿げたことをしていない限り安全です(その場合、コードサイズよりも心配すべきもっと大きな問題があるでしょう)。
しかし、esbuildの保守的なアプローチは、変更が安全であると確信できないために、多くの名前のマングリングをスキップすることを意味します。物事がうまくいかなくなる簡単な例として、次を考えてみましょう。
const obj = { longPropertyName: 123 };
function lookup(prop) {
return obj[prop];
}
console.log(lookup('longPropertyName'));
マングリングによってlongPropertyName
がx
に変更されると、次の行の動的な参照は機能しなくなります。
const obj = { x: 123 }; // Here `longPropertyName` gets rewritten to `x`
function lookup(prop) {
return obj[prop];
}
console.log(lookup('longPropertyName')); // But this reference doesn't and now the lookup is broken
上記のコードでは、プロパティ自体がマングリング中に変更されたにもかかわらず、プロパティにアクセスするためにlongPropertyName
をまだ使用しようとしていることに注意してください。
この例は作為的ですが、実際のコードではこのような破壊が起こる方法はたくさんあります。
- 動的なプロパティアクセス。
- オブジェクトのシリアライズや、期待されるオブジェクト形式へのJSONのパース。
- 公開するAPI(利用者は新しいマングルされた名前を知りません)。
- 利用するAPI(DOM APIを含む)。
esbuildに、見つかったほぼすべての名前を強制的にマングルさせることもできますが、そうすると、上記の理由でVS Codeは完全に壊れてしまいます。
それでも、VS Codeのコードベースではもっとうまくやれるはずだという感覚を拭い去れませんでした。すべての名前をマングルできないとしても、少なくとも安全にマングルできる名前のサブセットを見つけることができるのではないか、と。
プライベートプロパティでの失敗
最小化されたソースを振り返ってみると、_
で始まる長い名前がたくさんあることにも気づきました。慣例上、これはプライベートプロパティを示します。プライベートプロパティなら安全にマングルでき、クラス外のコードは何も気づかないはずですよね?そして、待ってください、esbuildはすでにこれを私たちのためにやってくれているべきではないでしょうか?しかし、esbuildを書いた人々は怠け者ではないと知っていました。esbuildがプライベートプロパティをマングルしていないなら、それはほぼ間違いなく正当な理由があるはずです。
問題をさらに考えてみると、プライベートプロパティも上記のlongPropertyName
の例で示した動的なプロパティ参照の問題の影響を受けることに気づきました。あなたのような賢いTypeScriptプログラマーは決してそんなコードを書かないと思いますが、動的なパターンは実際のコードベースでは十分に一般的であるため、esbuildは安全策を取ることを選択します。
また、TypeScriptのprivate
キーワードは、実際にはただの丁寧な提案に過ぎないことを心に留めておいてください。TypeScriptコードがJavaScriptにコンパイルされると、private
キーワードは基本的に削除されます。つまり、クラス外の行儀の悪いコードが内部に手を出してプライベートプロパティに好き勝手にアクセスするのを止めるものは何もありません。
class Foo {
private bar = 123;
}
const foo: any = new Foo();
console.log(foo.bar);
あなたのコードが直接このような疑わしいことをしていないことを願いますが、プロパティ名を不用意に変更すると、オブジェクトのスプレッド、シリアライズ、異なるクラスが共通のプロパティ名を共有する場合など、たくさんの楽しい予期せぬ方法で問題に遭遇する可能性があります。
幸いなことに、VS Codeでは私には一つ大きな利点があることに気づきました。私は(ほとんど)健全なコードベースで作業していたのです。動的なプライベートプロパティのアクセスや悪いany
アクセスがないなど、esbuildができない多くの仮定を立てることができました。これにより、私が直面していた問題はさらに単純化されました。
そこで、Johannes Rieken (@johannesrieken) と一緒に、プライベートプロパティのマングリングの調査を始めました。最初のアイデアは、コードベース全体でJavaScriptネイティブの#private
フィールドを採用してみることでした。プライベートフィールドは上記で詳述したすべての問題の影響を受けないだけでなく、esbuildによって自動的にマングルされます。プレーンなJavaScriptに近づくことも魅力的でした。
しかし、このアプローチは、パラメータープロパティの使用をすべて削除するなど、大規模な(つまりリスクの高い)コード変更が必要になるため、すぐに却下しました。比較的新しい機能であるため、プライベートフィールドはまだすべてのランタイムで最適化されていません。それらを使用すると、無視できる程度から約95%の速度低下を引き起こす可能性があります!長期的にはこれが正しい変更かもしれませんが、今すぐ必要なものではありませんでした。
次に、esbuildが特定の正規表現に一致するプロパティを選択的にマングルできることを発見しました。しかし、この正規表現は識別子名に対してのみ一致します。これは、プロパティがTypeScriptでprivate
と宣言されているかどうかを知ることはできないことを意味しましたが、_
で始まるすべてのプロパティをマングルしてみることはできました。これがプライベートおよびプロテクテッドプロパティのみを含むことを期待していました。
まもなく、すべての_
プロパティがマングルされた動作するビルドが完成しました。素晴らしい!これにより、プライベートプロパティのマングリングが可能であることが証明され、まずまずの削減がもたらされましたが、期待していたほどではありませんでした。
残念ながら、名前だけに基づくマングリングにはいくつかの深刻な欠点があります。例えば、コードベース内のすべてのプライベートプロパティが_
で始まる必要があります。VS Codeのコードベースはこの命名規則に一貫して従っておらず、_
で始まるパブリックプロパティがいくつかある場所もあります(通常、これはプロパティが外部からアクセス可能である必要があるが、APIとして扱われるべきでない場合、例えばテストなどで使用されます)。
また、マングルされたコードが実際に正しいという自信も完全には持てませんでした。確かに、テストを実行したり、VS Codeを起動してみたりすることはできましたが、これは時間がかかり、あまり一般的でないコードパスを見落としたらどうなるでしょうか?他のコードに触れずにプライベートプロパティのみをマングルしていると100%確信することはできませんでした。このアプローチはリスクが高すぎ、採用するには負担が大きすぎると感じました。
TypeScriptで自信を持ってマングリングする
マングリングのビルドステップにどうすればもっと自信を持てるか考えているうちに、新しいアイデアが浮かびました。TypeScriptがマングルされたコードを検証してくれたらどうだろう?TypeScriptが通常のコードで未知のプロパティアクセスをキャッチできるように、TypeScriptコンパイラはプロパティがマングルされたにもかかわらず、それへの参照が正しく更新されていないケースをキャッチできるはずです。コンパイルされたJavaScriptをマングルする代わりに、TypeScriptのソースコードをマングルし、その後、マングルされた識別子名を持つ新しいTypeScriptをコンパイルすることができます。マングルされたソースコードに対するコンパイルステップは、誤ってコードを壊していないというはるかに大きな自信を与えてくれます。
それだけでなく、TypeScriptを使用することで、私たちは(単に_
で始まるプロパティではなく)本当にすべてのprivate
プロパティを見つけることができます。さらに、TypeScriptの既存のrename
機能を使用して、予期せぬ方法でオブジェクトの形状を変更することなく、賢くシンボルをリネームすることもできます。
この新しいアプローチを試すことに熱心になり、すぐに次のような新しいマングリングビルドステップを考え出しました。
for each private or protected property in codebase (found using TypeScript's AST):
if the property should be mangled:
Compute a new name by looking for an unused symbol name
Use TypeScript to generate a rename edit for all references to the property
Apply all rename edits to our typescript source
Compile the new edited TypeScript sources with the mangled names
そして、このような単純に見えるアプローチにしては驚くべきことに、それはうまくいきました!まあ、ほとんどはですが。
TypeScriptがコードベース全体で何千もの正しい編集を生成できたことには間違いなく感銘を受けましたが、いくつかのエッジケースを処理するためのロジックも追加する必要がありました。
-
新しいプライベートプロパティ名が現在のクラス内で一意であるだけでは不十分で、現在のクラスのすべてのスーパークラスおよびサブクラス全体でも一意である必要があります。ここでも根本的な原因は、TypeScriptの
private
キーワードが単なるコンパイル時の装飾であり、スーパークラスやサブクラスがプライベートプロパティにアクセスできないことを実際には強制しないことです。注意しないと、リネームによって名前の衝突が発生する可能性があります(幸いにもTypeScriptはこれらをエラーとして報告します)。 -
コードのいくつかの場所で、サブクラスが継承したプロテクテッドプロパティをパブリックにしていました。これらの多くは間違いでしたが、これらのケースでマングリングを無効にするコードも追加しました。
これらのケースに対応するコードを追加した後、すぐに動作するビルドが完成しました。プライベートプロパティをマングルすることで、VS Codeの主要なworkbench.js
スクリプトのサイズは12.3 MBから10.6 MBへと、14%近く削減されました。これにより、スキャンするソーステキストが少なくなるため、コードの読み込みも5%高速化しました。ソース内の安全でないパターンに対するいくつかの非常に軽微な修正を除けば、これらの削減は基本的に無料であったことを考えると、まったく悪くありません。
学びと今後の作業
プライベートプロパティのマングリングは、大規模なコード変更やコストのかかる書き直しに頼ることなく、VS Codeでまだ大きな改善が見つかることを示しています。このケースでは、長年にわたって他の人々がVS Codeの最小化されたソースを見て、それらの長い名前に疑問を抱いていたのではないかと推測します。しかし、これに対処することは安全に行うのが不可能に思えたか、あるいは潜在的に大規模なエンジニアリング投資に見合わないと思われていたのでしょう。
今回成功した鍵は、名前のマングリングが安全である可能性が高く、かつ最適化が依然として大きな改善をもたらすケース(プライベートプロパティ)を特定したことでした。次に、この変更をできるだけ安全に行う方法を考えました。これは、まずTypeScriptのツールを使用して自信を持って識別子をリネームし、次に再びTypeScriptを使用して新しくマングルされたソースコードがまだ正しくコンパイルされることを確認することを意味しました。その過程で、私たちのコードがすでにほとんどのTypeScriptのベストプラクティスに従っており、またVS Codeの一般的なコードパスの多くをカバーするテストが整備されていたという事実に大いに助けられました。これらすべてがうまく組み合わさった結果、Johと私は空き時間を使って、VS Codeで作業している他の開発者にほとんど影響を与えることなく、かなり抜本的な変更をリリースすることができました。
しかし、マングリングの話はこれで終わりではありません。新しくマングルされ、最小化されたソースを見ると、provideWorkspaceTrustExtensionProposals
や他の多くの長い名前を見てがっかりしました。最も注目すべきは、localize
(UIに表示される文字列に使用する関数)が約5000回出現することでした。明らかにまだ改善の余地がありました。
プライベートプロパティのマングリングと同じアプローチとテクニックを使用して、私はすぐに、投資対効果が高く安全にマングルできる別の一般的なコードパターンを特定しました。それは、エクスポートされたシンボル名です。エクスポートが内部でのみ使用されている限り、コードの動作を変更することなくそれらを短縮できると確信しました。
これは大部分が正しかったのですが、またしてもいくつかの複雑な問題がありました。例えば、拡張機能が使用するAPIに誤って触れないようにする必要があり、また、TypeScriptからエクスポートされた後、型付けされていないJavaScriptから呼び出されるいくつかのシンボルを除外する必要がありました(これらは通常、ワーカースレッドやプロセスのエントリポイントです)。
エクスポートのマングリング作業は前回のイテレーションでリリースされ、workbench.js
のサイズは10.6 MBから9.8 MBへとさらに削減されました。すべての削減を合わせると、このファイルはマングリングなしの場合よりも20%小さくなりました。VS Code全体では、マングリングによってコンパイル済みソースから3.9 MBのJavaScriptコードが削除されます。これはダウンロードサイズとインストールサイズの素晴らしい削減であるだけでなく、VS Codeを起動するたびにスキャンする必要があるJavaScriptが3.9 MB少なくなることも意味します。
このチャートはworkbench.js
のサイズを経時的に示しています。右側の2つの下降に注目してください。VS Code 1.74での最初の大きな下降はプライベートプロパティのマングリングの結果です。1.80での2番目の小さな下降はエクスポートのマングリングによるものです。
私たちのマングリング実装は、最小化されたソースにまだたくさんの長い名前が含まれているため、間違いなく改善の余地があります。それが価値があると思われ、安全なアプローチを思いつくことができれば、これらをさらに調査するかもしれません。理想的には、いつかこの作業の多くが全く不要になることです。ネイティブのプライベートプロパティはすでに自動的にマングルされますし、ビルドツールもコードベース全体でコードを最適化する能力が向上するでしょう。現在のマングリング実装はこちらで確認できます。
私たちは常にVS Codeと私たちのコードベースをより良くしようと努めており、マングリングの作業は私たちがどのようにこれに取り組んでいるかを示す素晴らしい例だと思います。最適化は一度きりのものではなく、継続的なプロセスです。コードサイズを継続的に監視することで、時間の経過とともにそれがどのように増大してきたかを認識していました。この認識は、コードサイズがこれまで以上に拡大するのを防ぐのに間違いなく役立ち、また、常に改善を探求することを奨励してくれます。マングリングは魅力的に見えるテクニックでしたが、当初は真剣に検討するにはリスクが高すぎました。このリスクを減らし、適切なセーフティネットを作成し、マングリングを採用するコストをほぼゼロにする作業を行った後で初めて、ビルドで有効にすることに十分な自信を持つことができました。私は最終結果を本当に誇りに思うと同時に、それを達成した方法も同じくらい誇りに思います。
ハッピーコーディング、
Matt Bierner, VS Codeチームメンバー @mattbierner
マングリングの実装に重要な役割を果たしてくれたJohannes Rieken氏、安全にマングリングを実装できるツールを構築してくれたTypeScriptチーム、驚異的な速さのバンドラーを提供してくれたesbuild、そしてこのような最適化に適したコードベースを構築してくれたVS Codeチーム全体に感謝します。そして最後に、私たちが投げかける大量のひどくマングルされたJavaScriptにもかかわらず、常に私たちを速く見せてくれるV8チームとその他すべてのJSエンジンに心から感謝します。