🍆

TypeScript プロジェクトのコンパイル時間を改善してみた話

TypeScript プロジェクトのコンパイルのボトルネックを調査・解消してみた話

はじめに

こんにちは。エンジニアの辻󠄀です。

私は社内のとある TypeScript プロジェクトで開発業務を行っていました。
ある日、新たに関数の実装を追加しようとしたその時、tsserver(TypeScript の language server)による補完候補がなかなか表示されないことに気づいたのです。
どうやら、コンパイルの時間が非常に長くなっていたことが原因のようです。

この記事では、tsc のコンパイルにおけるボトルネックを調査する方法と、そこで適用した2つの改善方法について紹介します。
コンパイル時間が低下する原因はプロジェクトの性質によって大きく異なるため、ここで述べる改善方法が任意のプロジェクトに有効だとは言えませんが、少なくとも調査方法については役に立つのではないかと思います。

ボトルネックの調査・改善方法

  • 調査方法
    • tsc の trace を生成・解析する
  • 改善方法
    • プロジェクト内で .ts コードを生成している場合、事前に .js と .d.ts にコンパイルしておく
    • 無名の巨大な型は小さな interface の合成で表現し、キャッシュが効くようにする

プロジェクトの概要

便宜上、以降このプロジェクトをPと呼ぶことにします。
Pの規模は TypeScript コード35,000行程度で、その4割が openapi の json 仕様から生成されています。
生成物のほとんどは型定義ファイル(.d.ts)なのですが、openapi 仕様に従ったオブジェクトの validation を行うために Zod[1] schema を含む .ts ファイルも生成しています。

Pは開発規模としてはそれほど大きくないにも関わらず、そのコンパイルに14秒もかかっていました。
この問題は tsc の skipLibCheck オプションを有効にすることである程度は解消できましたが、
型検査が緩くなるデメリットもあるため、他の抜本的な解決方法を模索していました。

調査方法について

コンパイルに14秒もかかっているので何かしらのボトルネックがあるのだろうことは見当がついていたのですが、ここまで速度が低下することは経験したことがなかったため、原因箇所の特定に関する知見を持ち合わせていませんでした。
P中の .ts ファイルを半分削除してコンパイル時間を計測するのを繰り返すような、半ば強引な二分探索をするのは気が進まなかったため、tsc のデバッグ情報を取り出せないか調査してみました。
すると、TypeScript の GitHub Wiki にPerformance Tracing という項目があることに気づきました。

tsc には generateTrace というオプションがあり、これを用いることでコンパイルの統計を生成できるようです。

$ tsc -p /path/to/ts-project --generateTrace /path/to/trace --incremental false

これを実行してみると、/path/to/trace に以下の構造を持つディレクトリが生成されました。

trace
+- trace.json
+- types.json

trace.json には統計情報、types.json には型情報が保存されます。
今回はコンパイル時間の統計を知りたいので、trace.json のみを用いました。

この json ファイルは容量がMB単位と非常に大きいため、解析・可視化を支援する以下の2つのツールを試すことにしました。

  • chrome://tracing
  • @typescript/analyze-trace

以降、使用例を交えて軽く説明していきます。

chrome://tracing

これは Google Chrome に標準搭載されている機能で、URL 欄に chrome://tracing と入力することでアクセスできます。
今回生成した trace.json を読み込ませたところ、以下のような表示が得られました。

chrome-tracing

これはコンパイル中の処理とそれにかかった時間を並べたもので、左端から右端にかけて14秒程度かかっていることがわかります。

グラフは以下の3つの区分から構成されています。

  • createProgram
  • bindSourceFile
  • checkSourceFile

最も時間がかかっている checkSourceFile では、プログラム中の宣言や式の検査をファイル単位で行っています。
そして特にグラフ中央に大きな解析時間を占める checkSourceFile に注目してクリックすると、下図のような情報が表示されました。

経過時間

Wall Duration は1ファイルの解析にかかった時間であるため、すなわち1ファイルの解析に5.4秒もかかっていることになります。
なんとこのファイルの中身は、6,500 行に渡る単一の巨大な Zod schema でした。
他にも同様に解析に時間がかかっている Zod schema のファイルが1つあり、これら計2ファイルが計 6.6 秒も時間を食っているボトルネックであることがわかりました。

以降、この2ファイルは順に F₁、F₂ と呼ぶことにします。

@TypeScript/analyze-trace

@typescript/analyze-tracegenerateTrace
オプションで生成した trace ディレクトリからボトルネックとなるファイルの情報のみを抽出してくれる cli ツールです。
以下を実行すると、最も時間のかかっているファイルを順に得ることができます(詳細は省略)。

$ npx analyze-trace /path/to/trace

例えば今回のケースでは、以下のようなデータが出力されました(詳細は省略)。

$ npx analyze-trace /path/to/trace | grep 'Check file'
├─ Check file F₁.ts (5420ms)
├─ Check file F₂.ts (1162ms)
...

この出力からも同様に、Zod schema を含む2ファイルのコンパイルに時間がかかっていることがわかります。
ボトルネックになっているファイルを確認したいだけの場合は、こちらのツールを使った方が楽かもしれません。

解決方法について

今回は、以下の2つの方法を試してみました。

  • プロジェクト内で .ts コードを生成している場合、事前に .js と .d.ts にコンパイルしておく
    • 方法1:Zod schema を事前にコンパイルする
  • 無名の巨大な型は小さな interface の合成で表現し、キャッシュが効くようにする
    • 方法2:Zod schema の型を、構成要素ごとに interface で生成する

これらの対応により、コンパイル時間が大幅に短縮されただけでなく補完の速度もかなり改善され、快適な開発体験を取り戻すことができました。

方法1:Zod schema を事前にコンパイルする

最もボトルネックになっているファイルは 6,500 行に渡る巨大な単一の Zod schema であり、これには型注釈が付いていなかったので、tsc はその型をコンパイルのたびに推論する必要がありました。
生成元の openapi 仕様はほぼ固定であったため、json ファイルから .ts ファイルを生成する段階で事前に .js ファイルと .d.ts ファイルにコンパイルしてしまえば推論の時間を削減できそうです。

これを実装して再度 analyze-trace を用いてコンパイル時間を計測したところ、以下のようになりました(詳細は省略)。

$ npx analyze-trace /path/to/trace
├─ Check file F₁.d.ts (1286ms)
├─ Check file F₂.d.ts (245ms)
...

結果として、コンパイル時間は5秒程度短縮されました。(単位は[ms])

.ts .js + .d.ts(方法1) diff(方法1)
F₁ 5420 1286 -4134
F₂ 1162 245 -917
6582 1531 -5051

方法2:Zod schema の分解

前述の事前コンパイルの結果は、依然として単一の Zod schema および型になっています。
たとえば、.d.ts ファイルは以下のような内容になりました。

import { z } from "zod";
export declare const schema: {
    foo: {
        bar: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
        baz: z.ZodObject<{
            hoge: z.ZodObject<{
                ...
            }>
        }>
    },
    ...
};`

この schema は 4,000 行を超える巨大な型が名前なしで直接与えられています。
tsc において、型の再計算を防ぐキャッシュの仕組みがどのような条件で効くのかについては十分な考証は行えていないのですが、
少なくとも TypeScript Wiki のNaming Complex Types を見る限りは、
複雑かつ直接展開されている型は命名をすることでより多くの情報がキャッシュされるようでした。

そこでまず、openapi 仕様から ts ファイルを生成する際は、巨大な単一 schema ではなく小さな複数の schema の合成として表現するように変更しました。
そして、各 schema の型も schema と同名の interface として生成することで、部分型関係の検査などの型に関連する計算でキャッシュが効くようにしました。
すると、この ts ファイルから生成される d.ts ファイルは以下のようになりました。

import { z } from "zod";
export declare const schema: {
    foo: {
        bar: BarSchema;
        baz: BazSchema;
    },
    ...
};

// Bar プロパティの Zod schema
export declare const BarSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
type _BarSchema = typeof BarSchema;

// Bar プロパティの Zod schema の型
export interface BarSchema extends _BarSchema {}

// Baz プロパティの Zod schema
export declare const BazSchema: z.ZodObject<{
     hoge: HogeSchema;
}>;
type _BazSchema = typeof BazSchema;

// Baz プロパティの Zod schema の型
export interface BazSchema extends _BazSchema {}

ここで type alias を type ではなく interface として生成・export しているのは、同 Wiki の Preferring Interfaces Over Intersections にて、interface 間の型関係はキャッシュされるとの記述があったためです。

以上の対応により Zod schema の型に名前が付き、型検査におけるキャッシュが効きやすくなったことでしょう。
同様にコンパイル時間を analyze-trace で計測してみたところ、以下のような結果になりました(詳細は省略)。

$ npx analyze-trace /path/to/trace
├─ Check file F₁.d.ts (471ms)
├─ Check file F₂.d.ts (233ms)
...

結果として、方法1単体よりもさらにコンパイル時間が短縮されており、結果として6秒程度短縮されました。(単位は[ms])

.ts .js + .d.ts(方法1+方法2) diff(方法1+方法2)
F₁ 5420 471 -4949
F₂ 1162 233 -929
6582 704 -5878

これらの2つの方法によりコンパイル時間が大きく削減され、tsserver も以前と見違えるほど高速に補完候補を表示してくれるようになりました。

まとめ

今回は TypeScript プロジェクトのコンパイルにおけるボトルネックの特定と改善に挑戦し、約6秒の短縮とそれに伴う開発体験の向上に成功しました。
TypeScript プロジェクトでコンパイル速度が遅く困っている方に、この記事がお役に立てば幸いです。

脚注
  1. Zod は、オブジェクトが特定の schema によって定められたルールに従っているか否かを動的に検査する仕組みを提供するライブラリ。 ↩︎

FORCIA Tech Blog

Discussion