自作軽量 TS コンパイラが tsc より高速になった / mints v0.1

2021/11/29に公開

この記事の続編です

https://zenn.dev/mizchi/articles/minimum-tsc-mints

おさらい: mints とは

軽量 typescript コンパイラです。ブラウザや edge worker に埋め込んで使うことを想定しています。他の typescript compiler と違い、最小限の transform しかしません。es5 への変換や require の変形を実装していません。soucemap 対応もありません。

https://github.com/mizchi/mints/tree/main/packages/mints

今回は npm に publish したので npm 経由で使えます。

yarn add @mizchi/mints
npm install @mizchi/mints --save
import { transformSync } from "@mizchi/mints";
const code = "const x: number = 1;";
console.log(transformSync(code).code); // => const x=1;

注意する点として、} で終わらない文法は;で終了することが必須になっています。(これは tokenizer の実装を軽量にするためです)

基本的に、prettier のデフォルト設定で整形されたコードが実行できることを確認しています。prettier は ↑ のポリシーで整形します。

mints はブラウザ上で試せます。

https://mints-playground.netlify.app/

ベンチマーク

前回の記事時点では他のコンパイラと比べバンドルサイズ自体は軽量でしたが、コンパイル速度が遅いという問題がありました。

前回、適当なファイルで試したベンチマークです。

case1:
compileTsc: 92ms
compileMints: 286ms

case2:
compileTsc: 132ms
compileMints: 775ms

実際、コード量に比例して遅くなります。

これは tokenize のステップがなく、すべての構文ルールが正規表現を個別に実行するのが遅い理由でした。またそのせいで空白制御のために構文定義が冗長になっていました。

そのため、事前に tokenize ステップを用意し、pargen を事前に分割された token 列を受け取るパーサコンビネータとして再実装しました。(元の pargen はあれはあれで使いやすいので別実装になってます)

https://github.com/mizchi/mints/tree/main/packages/pargen-tokenized

mints v0.1 の ベンチマーク

試した環境は MacBookPro M1 Max 64GB です。

--------- 2416chars
[tsc] 58ms
[esbuild] 14ms
[mints] 6ms
[mints_para] 12ms
--------- e2981chars
[tsc] 14ms
[esbuild] 1ms
[mints] 9ms
[mints_para] 12ms
--------- 5118chars
[tsc] 18ms
[esbuild] 1ms
[mints] 12ms
[mints_para] 22ms
--------- 21153chars
[tsc] 55ms
[esbuild] 3ms
[mints] 59ms
[mints_para] 53ms
--------- 18584chars
[tsc] 39ms
[esbuild] 2ms
[mints] 39ms
[mints_para] 32ms
--------- 3844chars
[tsc] 12ms
[esbuild] 1ms
[mints] 9ms
[mints_para] 17ms
--------- 38611chars
[tsc] 72ms
[esbuild] 3ms
[mints] 57ms
[mints_para] 45ms
--------- 6935chars
[tsc] 13ms
[esbuild] 1ms
[mints] 13ms
[mints_para] 7ms

実際何をコンパイルしているかは、 https://github.com/mizchi/mints/tree/main/packages/mints/benchmark/cases を御覧ください。mints_para は mints の並列化ビルドした版です。

結果として、入力するコードによりますが、ビルド速度は tsc と同等かちょっと速い程度です。特筆すべきは初回実行が早い点で、 tsc が 1.9MB と巨大なのに対して、mints は 17kb ほどなのが効いてそうです。

esbuild が異次元で速いのですが、初回起動だけは esbuild に勝っています。このベンチをみると、さすがに JS 実装で esbuild と勝負しようとは思えませんでした…

並列化はよほど大きなファイルでないとシングルスレッド版を上回ることはなかったですが、ある程度サイズが大きくなる場合は有効です。というかコードサイズが小さいときにビルドが遅いのは、 event loop で受け渡しするときの最小 tick の関係な気がします。

これは node 環境のベンチですが、実際ブラウザで動かすときはバンドルサイズが効いてくるので、他より優位になるはずです。

まだまだ細かい文法の実装ミスもあるとは思いますが、軽量な ts コンパイラが欲しい人はぜひ試してみてください。


以下、実装した細かい工夫について。

tokenizer の実装

JS の構文には次の特徴があります。

  • } または ; で Statement を終了する
  • {} のペアは必ず対応する
  • 変数名として使えない制御文字毎に token が分割される

mints では {}() のペアをカウントしながらトークナイズして、statement を認識する毎に行単位でコンパイルします。

また、行ごとに並列にビルドできるように、 tokenizer をジェネレータとして実装しました。

https://github.com/mizchi/mints/blob/main/packages/mints/src/runtime/tokenizer.ts

これで一度にコンパイルする分量を少なくして、メモリを消費しがちな膨らみがちな packrat cache に優しくなりました。

ただ、この実装方法には問題があって、正規表現が安全に分割できません。

例えば、 1 / 1 / 1/ で挟まれた部分/ 1 / が正規表現か除法なのか構文解析しないとわからないです。なので、正規表現の 1 文字目は ではない、という仮定を置いています。(一応次の改行コードまでに、対応する / があるか?というチェックはしていますが)

これは prettier で整形されたコードなら問題ないですが、minify されたコードでは動く保証がありません。mints を使う際に安全側に寄せるなら、 new RegExp() で正規表現を宣言したほうが安全かもしれません。

バイナリエンコードして base64 でインライン化

pargen のパーサジェネレータで構築された構文定義をバイナリにシリアライズして、実行時はそれを読み込むだけの実装にしました。

バイナリは cbor のサブセットに変換して、実行時にデコードしています。デコーダを軽量にするために、文字列の実装を省いています。

CBOR — Concise Binary Object Representation | Overview

試してみたところ、ブラウザ環境で起動後に外部からバイナリを読み込むのがどうにも遅かったので、バイナリをさらに base64 にエンコードしてインライン化するとだいぶ高速化しました。base64 からデコード自体する速度を懸念しましたが、1~2ms 程度で済みました。

gzip したビルドサイズは 5.6kb から 7.6kb と膨らんでしまったものの、ランタイムの起動速度は高速化しました。

https://github.com/mizchi/mints/tree/main/packages/mints

cbor を借りずにカリカリにチューニングすればもっと軽量になるとは思いますが、一旦これで満足しています。

初回起動のベンチマーク

node で mints や各種コンパイラを起動して const x: number = 1; をコンパイルするだけの速度を測ってみました。

mints

________________________________________________________
Executed in   60.74 millis    fish           external
   usr time   59.59 millis    0.07 millis   59.52 millis
   sys time   10.67 millis    1.72 millis    8.95 millis

esbuild

________________________________________________________
Executed in   95.78 millis    fish           external
   usr time   91.11 millis    0.07 millis   91.04 millis
   sys time   16.72 millis    1.62 millis   15.10 millis

tsc

________________________________________________________
Executed in  219.34 millis    fish           external
   usr time  211.33 millis    0.08 millis  211.25 millis
   sys time   18.57 millis    1.77 millis   16.80 millis

CLI を高頻度で起動して小さなファイルをコンパイルする、という用途なら強いかもしれません。

今後

まだパースエラーも多いのですが、部分的には tsc を超えて基本的に満足しています。(とはいえ、tsc はビルド速度を高速化する意図を感じない実装ですが…)

mints の裏テーマで、最近は rust-wasm でフロントエンドツールチェインを全部作り直すトレンドがあるのですが、まだまだ bundle size に難があり、ビルドサイズを抑えるなら生 JS にまだ分があるだろう、と思いチャレンジしました。実際軽量実装というゴールは実現できたと思います。

とはいえ、結局 cbor を借用したとはいえ手作業でバイナリへのエンコードをやってしまったので、今後は ランタイムだけ rust にする、というのもできるかもしれません。余裕があったらやってみます。 rust も 全部スクラッチで書けば軽量にできるかもしれません。

swdev での利用

元々やろうとしていたこととして service-woker に埋め込んで .ts のファイルの読み込みを透過的にするツールを作ろうとしています。ちょっと前にこういうツールを自作しました。

mizchi/swdev: No bundle frontend by service-worker

これは動くには動くのですが、 service-worker 内部で tsc を埋め込んでる関係で初回起動に難があり、ブラウザに埋め込める軽量な ts コンパイラが必要になり、 mints を作り始めた、という経緯があります。また、 typescript を vite でビルドするととても遅いという問題もありました。リクエストが並列でくるので並列ビルド対応をやっていたのもあります。開発環境で使うことを考えると、ビルドキャッシュを再利用する差分ビルド対応もやりたいですね…

deno 対応版はあとで作りますが、シングルファイルなので https://esm.sh/@mizchi/mints を読み込めば使えると思います。

Discussion