🚀 VS Codeでで入手しましょう!

名前マングリングによるVS Codeの縮小

2023年7月20日 Matt Bierner 著、@mattbierner

先日、Visual Studio Codeの出荷されたJavaScriptのサイズを20%削減しました。これは、3.9MB強の節約になります。確かに、これはリリースノートの個々のgif画像の一部よりも小さいですが、それでも軽視できるものではありません!この削減は、ダウンロードしてディスクに保存する必要があるコードが少なくなるだけでなく、JavaScriptが実行される前にスキャンする必要があるソースコードが少なくなるため、起動時間も改善されます。コードを削除したり、コードベースを大幅にリファクタリングしたりすることなく、この削減を実現できたことを考えると、非常に素晴らしい成果です。その代わりに必要だったのは、新しいビルドステップ、つまり名前マングリングだけでした。

この記事では、この最適化の機会をどのように特定し、問題へのアプローチを探求し、最終的に20%のサイズ削減を実現したかについて共有したいと思います。名前マングリングの具体的な内容に焦点を当てるのではなく、VS Codeチームがエンジニアリングの問題にどのようにアプローチするかについてのケーススタディとして扱いたいと思います。名前マングリングは素晴らしい手法ですが、多くのコードベースでは価値がない可能性があり、私たちの特定のマングリングアプローチは改善の余地がある可能性があります(または、プロジェクトの構築方法によってはまったく必要ないかもしれません)。

問題の特定

VS Codeチームは、ホットコードパスの最適化、UIの再レイアウトの削減、起動時間の短縮など、パフォーマンスに情熱を注いでいます。この情熱には、VS CodeのJavaScriptのサイズを小さく保つことも含まれています。コードサイズは、デスクトップアプリケーションに加えてVS CodeがWeb(https://vscode.dev)で出荷されるようになったことで、さらに焦点が当てられるようになりました。コードサイズを積極的に監視することで、VS Codeチームのメンバーはコードサイズが変化したときに気づくことができます。

残念ながら、これらの変化はほとんど常に増加でした。VS Codeにどのような機能を組み込むかについて多くの検討を重ねていますが、長年にわたって新しい機能を追加することで、必然的に出荷するコードの量が増加しました。たとえば、VS CodeのコアJavaScriptファイルの1つ(workbench.js)は、8年前の約4倍のサイズになっています。8年前には、VS Codeにはエディタータブや組み込みターミナルなど、今日では不可欠と考えられる多くの機能が欠けていたことを考えると、この増加はそれほどひどいものではないかもしれませんが、それでも無視できるものではありません。

The size of 'workbench.js' has slowly increased over the past eight years

その4倍のサイズ増加は、多くの継続的なパフォーマンスエンジニアリング作業の後でもあります。この作業も主に、コードサイズを追跡し、増加するのを見るのを本当に嫌うために行われます。すでに、コードをesbuildに通してminifyするなど、多くの簡単なコードサイズ最適化を既に行っています。さらなる節約を見つけることは、長年にわたってますます困難になっています。多くの潜在的な節約は、それらがもたらすリスクや、実装および維持するために必要な追加のエンジニアリング労力に見合うものでもありません。これは、JavaScriptのサイズがゆっくりと増加していくのを見守らざるを得なかったことを意味します。

しかし、昨年のvscode.devでminifyされたソースコードをデバッグしているときに、驚くべきことに気づきました。minifyされたJavaScriptには、extensionIgnoredRecommendationsServiceのような長い識別子名がまだたくさん含まれていました。これは私を驚かせました。esbuildはすでにこれらの識別子を短縮していると思っていました。そして、esbuildは実際には「マングリング」(JavaScriptツールがコンパイル言語のわずかに類似したプロセスから借用したと思われる用語)と呼ばれるプロセスを通じて、場合によっては識別子を短縮することがわかりました。

minify中に、マングリングは長い識別子名を短縮し、次のようなコードを変換します。

const someLongVariableName = 123;
console.log(someLongVariableName);

はるかに短いものに。

const x = 123;
console.log(x);

JavaScriptはソーステキストとして出荷されるため、識別子名の長さを短縮すると、プログラムのサイズが実際に小さくなります。コンパイル言語出身の方にとっては、この最適化は少し馬鹿げているように思えるかもしれませんが、ここJavaScriptの素晴らしい世界では、このような勝利を喜んで受け入れます!

変数をすべて1文字にリネームするために急いで飛び出す前に、このような最適化には慎重なアプローチが必要であることを強調したいと思います。潜在的な最適化によってソースコードの可読性や保守性が低下したり、多大な手作業が必要になったりする場合、本当に素晴らしい改善をもたらさない限り、ほとんど価値がありません。あちこちで数バイトを削るのは良いことですが、素晴らしいとは言えません。

ビルドツールに自動的に実行させるなど、このような素晴らしい最適化を基本的に無料で得られるのであれば、その計算は変わります。そして実際、esbuildのようなスマートツールは、すでに識別子マングリングを実装しています。つまり、veryLongAndDescriptiveNamesThatWouldMakeEvenObjectiveCProgrammersBlushを書き続けても、ビルドツールが短縮してくれるのです!

esbuildはマングリングを実装していますが、デフォルトでは、マングリングがコードの動作を変更しないと確信できる場合にのみ、名前をマングリングします。結局のところ、バンドラーがコードを壊してしまうのは本当にひどいことです。実際には、esbuildはローカル変数名と引数名をマングリングします。これは、コードが本当に馬鹿げたことをしていない限り安全です(その場合、コードサイズよりも心配すべき大きな問題がある可能性が高いです)。

しかし、esbuildの保守的なアプローチは、変更することが安全であると確信できないため、多くの名前のマングリングをスキップすることを意味します。問題が発生する可能性のある簡単な例として、次を考えてみましょう。

const obj = { longPropertyName: 123 };

function lookup(prop) {
  return obj[prop];
}

console.log(lookup('longPropertyName'));

マングリングによってlongPropertyNamexに変更された場合、次の行の動的ルックアップは機能しなくなります。

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コードベースではもっとうまくできるはずだという感覚を拭い去ることができませんでした。すべての名前をマングリングできなくても、少なくとも安全にマングリングできる名前のサブセットを見つけることができるかもしれません。

プライベートプロパティでの誤ったスタート

minifyされたソースを振り返ってみると、もう1つ飛び込んできたのは、_で始まる長い名前が非常に多いことでした。慣例により、これはプライベートプロパティを示します。確かにプライベートプロパティは安全にマングリングでき、クラス外のコードは何も知らないはずですよね?そして、待ってください、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を使用していることで、1つの大きな利点があることに気づきました。私は(ほとんど)健全なコードベースで作業していました。動的なプライベートプロパティアクセスや不正な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.3MBから10.6MBに減少し、約14%の削減になりました。これにより、スキャンする必要があるソーステキストが少なくなるため、コードのロードも5%高速化されました。ソース内の安全でないパターンに対するいくつかの非常に小さな修正を除けば、これらの節約は基本的に無料であったことを考えると、まったく悪くありません。

学びと今後の作業

プライベートプロパティのマングリングは、大規模なコード変更やコストのかかる書き換えに頼ることなく、VS Codeで依然として大幅な改善が見られる可能性があることを示しています。この場合、長年にわたって他の人々がVS Codeのminifyされたソースを見て、これらの長い名前について疑問に思っていたのではないかと思います。しかし、これに対処することは安全に行うことが不可能であるか、おそらく潜在的に大規模なエンジニアリング投資に見合う価値がないように思えたのでしょう。

今回の成功の鍵は、名前マングリングが安全である可能性が高く、最適化が依然として大幅な改善をもたらすケース(プライベートプロパティ)を特定したことでした。次に、この変更を可能な限り安全に行う方法について考えました。これは、まずTypeScriptのツールを使用して識別子を自信を持ってリネームし、次にTypeScriptを再度使用して、新しくマングリングされたソースコードが引き続き正しくコンパイルされることを確認することを意味しました。その過程で、私たちのコードがすでにほとんどのTypeScriptベストプラクティスに従っており、多くの一般的なVS Codeコードパスをカバーするテストが導入されているという事実に大きく助けられました。これらすべてが組み合わさって、Johと私は余暇を利用して、VS Codeで作業している他の開発者にほとんど影響を与えることなく、かなり大幅な変更を出荷することができました。

しかし、マングリングの話はこれで終わりではありません。新しくマングリングおよびminifyされたソースを見てみると、provideWorkspaceTrustExtensionProposalsや他の多くの長い名前が見えてがっかりしました。最も注目すべきは、約5000件のlocalize(UIに表示される文字列に使用する関数)の出現でした。明らかに、まだ改善の余地がありました。

プライベートプロパティのマングリングと同じアプローチと手法を使用して、私はすぐに、投資収益率の高い安全にマングリングできる別の一般的なコードパターンを特定しました。エクスポートされたシンボル名です。エクスポートが内部でのみ使用されている限り、コードの動作を変更することなく短縮できると確信していました。

これはほぼ正しいことが証明されましたが、ここでもいくつかの複雑な点がありました。たとえば、拡張機能が使用するAPIに誤って触れないように注意する必要があり、TypeScriptからエクスポートされたが、型指定されていないJavaScriptから呼び出される(通常、これらはワーカースレッドまたはプロセスのエントリポイントです)いくつかのシンボルを除外する必要もありました。

エクスポートマングリング作業は前回のイテレーションで出荷され、workbench.jsのサイズが10.6MBから9.8MBにさらに削減されました。合計削減量として、このファイルはマングリングなしの場合よりも20%小さくなりました。VS Code全体では、マングリングにより、コンパイルされたソースから3.9MBのJavaScriptコードが削除されます。ダウンロードサイズとインストールサイズの削減だけでなく、VS Codeを起動するたびにスキャンする必要があるJavaScriptも3.9MB少なくなります。

このグラフは、workbench.jsのサイズの経時変化を示しています。右側の2つのドロップに注目してください。VS Code 1.74の最初の大きなドロップは、プライベートプロパティのマングリングの結果です。1.80の2番目の小さなドロップは、エクスポートのマングリングによるものです。

Zoomed in chart showing the drops from mangling

The size of 'workbench.js' over all VS Code releases, including the mangling work

minifyされたソースにはまだ多くの長い名前が含まれているため、私たちのマングリングの実装は間違いなく改善できます。そうすることが価値があり、安全なアプローチを考案できる場合は、これらをさらに調査する可能性があります。理想的には、いつの日か、この作業の多くが不要になるでしょう。ネイティブプライベートプロパティはすでに自動的にマングリングされており、私たちのビルドツールは、コードベース全体でコードを最適化することに優れていくでしょう。現在のマングリングの実装を確認できます。

私たちは常にVS Codeとコードベースをより良くすることに努めており、マングリング作業は私たちがこれにどのようにアプローチしているかの素晴らしいデモンストレーションであると思います。最適化は、1回限りのものではなく、継続的なプロセスです。コードサイズを継続的に監視することで、コードサイズが時間の経過とともにどのように成長してきたかを認識していました。この認識は、コードサイズがこれまで以上に拡大するのを防ぐのに間違いなく役立ち、常に改善点を探すように促しています。マングリングは魅力的な手法のように見えましたが、最初は真剣に検討するにはリスクが高すぎました。このリスクを軽減し、適切な安全ネットを作成し、マングリングを採用するコストをほぼゼロにして初めて、最終的にビルドで有効にすることに自信を持つことができました。最終的な結果と、それをどのように達成したかについて非常に誇りに思っています。

ハッピーコーディング!

Matt Bierner、VS Codeチームメンバー @mattbierner


マングリングの実装におけるJohannes Riekenの重要な貢献、マングリングを安全に実装できるツールを構築してくれたTypeScriptチーム、驚くほど高速なバンドラーであるesbuild、そしてこのような最適化に適したコードベースを構築してくれたVS Codeチーム全体に感謝します。そして最後に、V8チームと、私たちが彼らに投げつける大量のひどくマングリングされたJavaScriptにもかかわらず、常に私たちを速く見せてくれる他のすべてのJSエンジンに心から感謝します。