3月21日、火曜日。今日は祝日ですね。
はい、地獄は朝9時10分になりました。
おはようございます。mimeiのkeethこどくわはるです。
はい、では本日も朝活を始めていきたいと思います。
本日はですけどもですね、タイトルになります。
Speeding up the JavaScript ecosystem。
今回はですね、第2章というか、パート2のモジュールリソリューションのブログを読もうと思っておりますが、
今朝見たら、ちょうどパート4ですね。
NPMスクリプトの記事が出ていまして、これもすごく読みたくなったので、
今日、モジュールリソリューションの記事、多分今日終わると思うので、
終わったらパート1のポストCSSとかSVG語とか、
いきたいと思ったんですけど、NPMスクリプトでいくかちょっと悩ましいですね。
そのざっと概要だけ見て決めようと思いますけど、
もうちょっとこのシリーズでですね、
Speeding up the JavaScript ecosystemのシリーズは読んでいこうかなと思っております。
はい、では早速今日のパート2のところを入っていこうかなと思います。
じゃあ最初はTLDRからですね、いきたいと思います。
JavaScriptのビルド、テスト、リントン、いずれかにおいてですね、
おいてもモジュール解決ってのは常に全ての核となるものになりますと。
私たちのツールの中で中心的な位置を占めているにも関わらず、
この側面を高速化することにあまり時間が割かれてきませんでした。
このブログポストで説明する変更により、
ツールを30%も高速化することが一応できますよというお話ですね。
今日はそういうところを見ていきたいと思いますということでした。
もうちょっと概要が続いてますね。
このシリーズの第1回では、JavaScriptツールで使用される、
様々なライブラリを高速化する方法をいくつか紹介しました。
このような低レベルのパッチによってビルド時間の合計が大幅に改善された一方で、
私たちのツールにはもっと根本的な改善点があるのではないかというふうに考えていました。
バンドルだったりテストだったりそのリンティングというような、
一般的なJavaScriptタスクの操縦感に対して、
もっと大きな影響を与えるものがあるんじゃないかなというふうに関心があったと。
そこで数日間かけて私たちの業界でよく使われている様々なタスクやツールの
CPUプロファイルを12個ほど集めましたと。
これらのタスクの操縦効時間に30%もの影響を及ぼしているということが分かりました。
これは私たちのインフラストラクチャーにとって非常に重要で影響力のある部分であり、
独自のブログ記事を書く価値があるというふうになりますと。
その重要な部分とはモジュール解決と呼ばれるものですね。
そして私が見た全てのトレースでは、
ソースコードの解析よりも多くの時間を要していたというふうになります。
これが一応概要ですね。
では早速本体の方に入っていきたいと思いますが、
まずセクション1はスタックトレースをキャプチャするためのコストですね。
はい、いきたいと思います。
そのきっかけはエラーオブジェクトにスタックトレースを添付するノード内部の関数、
キャプチャーラージャースタックトレースというものがあるんですね。
というメソッドにあって、
トレースの中で最も時間がかかっていることに気がつきました。
この2つのタスクはエラーが投げられた形跡がないまま成功していることから、
ちょっと普通では考えられないことだというふうに思いました。
一応画像がペタられていて、
トレース1とトレース2の実際のプロファイリングのデータが出ています。
トレース1はトータルで1.62秒かかっています。
実に全体の27%ですね。
これはキャプチャーラージャースタックトレースというメソッドの実行時間ですね。
その後はリードですね。
読み込みの時間が発生して、それが387.21ミリ秒ですね。
ミリ秒かかっています。
次にトレース2ですね。
トレース2もやはりキャプチャーラージャースタックトレースというメソッドが実行されて、
こちらが1.22秒ですね。かかっています。
これも全体の29%かかっています。
続いてこっちですね。
トレース2の方ではプロバイドシンクというのが走っているそうですね。
こちらにも1.52秒かかっていて、これは全体の40%近くに行きます。
最後にリードの時間がかかっていて、
こっちが9.88秒ですね。
これは3.5%ということで、
本当に3.5%で9.88秒はおかしいでしょ。
どういうものの比較に対して。
何に対しての3.5%かちょっとわからないですけど、
それぐらい実際に時間がかかっているということですね。
結構トレース2時間がかかっているということですね。
では本文に戻りましょう。
プロファイリングデータの発生箇所というのをクリックすると、
何が起こっているのかというのが明確になりました。
ほぼ全てのエラーはノードのネイティブなfs.statSync関数の呼び出しによるものです。
その関数はisFileという関数内で呼び出されていました。
ファイルかどうかというのを判定しながらこれは。
ドキュメントによるとfs.statSync関数というのは基本的に
POSIXのfs.statコマンドと同等。
POSIXって何だろう。
全部大文字でPOSIXというのがあるんですね。
これちょっと僕は不勉強でわからないんですけど。
というもののfs.statコマンドと同様に、
ディスク上にパスが存在するか、
ファイルかディレクトリーかをチェックするための
一般的に使用されるということです。
このことを考慮するとファイルが存在しない、
読み込み権利がないなど、
例外的な使用方法の場合にのみエラーが発生するそうです。
そろそろそのisFileの操作を覗いてみることにしましょうということで、
関数でfunction isFileでファイルというのが引き継がされるらしいですね。
全体Try-Catchで挟まれています。
Tryの方はfs.statSyncでそのファイルですね。
引数に受け取ったファイルを引数に渡してあげて、
そのままですね。
それを実行した結果を変数で持っていると。
conststatという変数に持っています。
returnでstat.isFileというのをもう一回実行しますと。
これメソッドですね。
stat.isFileを実行する。
それがtrueかどうかフォルスかというのを返しますと。
またはstat.isFIFoと言っていますね。
関数をさらにシンプルにすることができますと。
単純に最初からthrowIfNotEntry、
エントリーがなければ、つまりファイルじゃなければ、
そのままフォルスを返すというようなオプションを渡してしまえば、
try-catchということをしなくてよくなるということですね。
このたった一つの変更で、
プロジェクトのリントンにかかる時間が7%も短縮されました。
さらにすごいのは、
同じ変更でテストも同様に拘束されたことです。
単純にtry-catchの分のリントンにかかる時間って、
バカにならないですね。
結構詰め重なっていくんですね、これ。
また、この人の実行結果でいくと、
絶対にキャッチに入るケースがないと言っていますね。
一つもなかったと言っているんですけど、
この人のやった実行結果というのは、
どういう結果なのかちょっとわからないですけど、
このESLintですかね、
何かの対象としているモジュールのテストを実行した結果、
全テストケース、全パターンで、
実はisfileメソッドのキャッチ分に入らなかった、
キャッチ区間に入らなかったのかなと思いますね。
であれば確かにもういらないよねというのはそう思いますので、
このisfileメソッドを自分の作った独自の方で置き換えると、
7%短縮されますよという話をしていますね。
これはすごくありがたいお話でした。
では続いていきましょう。
続いてはセクション2ですけど、
ファイルシステムが結構高価であります。
高価でも価値が高いって書くんですけど、
これは多分高価っていうのは価値ではなくて単純に時間がかかってますよってことですよね。
その関数のストックトレースのオーバーヘッドがなくなったことで、
まだ何かあるような気がしていたんです。
2、3回のエラーは数分間で取得したトレースでは全く表示されないはずですよと。
そこでこの関数に簡単なカウンターで注入してみて、
どれくらいのヒントで呼び出されているか調べてみました。
その結果この関数は約15K回、15K、1万5千回呼ばれていまして、
このファイル内、プロジェクト内のファイル数の約10倍もあることが分かりました。
これは改善の余地がありそうですね。
それはすごいですね。
この関数1万5千回も呼ばれてるの。
本題ですね。
この記事のあるとおりですけど、
モジュールの話にちょっと入っていきたいと思います。
はいでは続いて、今のが相対的なモジュールですね。
続いて2つ目が絶対的、絶対パスですね。
アブソリュートモジュールインポートですね。
これはスラッシュ風だったり、スラッシュ風バー、スラッシュボブみたいなやつですね。
最後3つ目はパッケージインポートですね。
直接インポートしてるやつですけど。
これは単純に風だったり、アット風スラッシュバーみたいな感じ。
以上3つですね。
とりあえずツールのインポートの識別値というのは3つあって、
相対パス、絶対パス、パッケージ直接のインポートですね。
この3つのうちパフォーマンスの観点から最も興味深いのは最後の1つです。
パッケージインポートですね。
これは裸のインポート指定し、つまりドットやスラッシュ等で始まらないものは
通常NPMパッケージを参照する特別な種類のインポートになります。
このアルゴリズムはノードのドキュメントで詳しく説明されていますので、
詳しいのはノードのドキュメントで見てみてください。
これはこれで読んでいきたいですね。
その要点はパッケージ名を解析し、ファイルシステムのルートに到達するまで、
モジュールを含む特別なノードモジュールズディレクトリが存在するかどうかをチェックするために
情報にトラバースしようとすることです。
それをRAID.jsを説明してみましょう。
例えば、スラッシュユーザーズですね。
いわゆる僕らのPC、パソコンの中のパスですけど。
スラッシュユーザーズ、スラッシュユーザー名、スラッシュマイプロジェクト、スラッシュソース、
フィーチャー、ディテールページ、コンポーネンツ、レイアウト、インデックス.jsという
絶対パスで書かれていますけど、すごく長いんですけど、
特定のプロジェクトのところまで行って、
そのソースの下のフィーチャーズディレクトリの下のディテールページの下にある
コンポーネンツディレクトリの下のレイアウトの下のインデックス.jsですね。
というようなファイルを見ると、とりあえずしましょう。
このファイルにある中で、モジュール風というのを
インポートをしようとしたとしましょう。
はい、OKです。
そしてアルゴリズムは以下の場所をチェックしますので、
1、2、3、4、5、6、7、8個のパスがリストで書かれていますけど、
最初は、これ全部絶対パスなんですけど、
プロジェクト内のレイアウトディレクトリの下のノードモジュール風を見に行きますと。
そんなものはないと。
続いて、同ディレクトリの1個上ですね。
レイアウトの1個上はコンポーネンツディレクトリです。
コンポーネンツディレクトリの下でノードモジュール風を見に行くと。
これをずっと繰り返していくわけですね。
どんどんどんどん繰り返して、
最終的にはもうユーザーの下まで行くということですね。
このプロジェクトの外まで、
ノードモジュール風を最終的には探しに行こうとしているということですね。
途中で見つかればそのチェックはやめるんでしょうけど。
というのをやっていくと。
それは知りませんでしたね。
まさかのシステムのところのユーザーズまで戻ろうとするんですね。
最終的にはスラッシュまでは行かないですね。
パソコンの中の絶対バスで、
オリジンオブオリジンのところまでは見に行かないということですね。
これは多くのファイルシステムが呼び出されるということになりますと。
一言で言えば、
全てのディレクトリがモジュールディレクトリを含んでいるかというのをチェックします。
チェックの量は、
インポートするファイルがあるディレクトリの数に整比例します。
それはそうだよね。
そして問題は、
これがフーインポートされる全てのファイルに対して起こるということです。
つまり、
フーが他の場所にあるファイルでインポートされた場合、
モジュールを含むノードモジュールディレクトリを見つけるまで、
ディレクトリツリー全体を再び情報にクロールすることになりますと。
このように解決されたモジュールをキャッシュすることは非常に有効です。
それはそうだよね。
というわけで、
もちろんパスですね。
ネストをどこまで深くするかですけど、
深ければ深いほど、
読みに行く情報へのクロールの量も増えていくし、
数も増えていくということなので、
ネストする量もできれば、
あんまり深くしていないほうがいいよということになりますけど、
それでも、
するときは、
ちゃんとキャッシュするとかなり有効ですよねということでした。
しかしでも、
キャッシュもいいけど、
さらに良いことがあります。
多くのプロジェクトでは、
パスマッピングエリアを利用して、
タイピングの手間を省くことができるようになっています。
これは、
通常、タイプスクリプトのパスコンパイラーオプションだったりとか、
バンドラーのResolveエリアによっても行われます。
この場合、
パッケージのインポートと見分けがつかないという問題があります。
あ、はいはい、そうですね。
features-directory-users...
プロジェクト直下のソース・フィーチャーズに
パスマッピングを追加して、
インポートなんちゃら、
from features-details-pagesのように
インポートを宣言する。
使えるようにすれば、
全てのツールはこのことを知ることができるようになると。
これは、
絶対パス・相対パスという、
自分で作った独自のパスのところを
設定するみたいな感じじゃないですかね。
パッケージのときにも同じことができるのであればそうなんでしょう。
では、ちょっとバッドで言葉が続きますね。
しかし、そうでない場合はどうでしょう。
全てのJavaScriptツールが使用する
一元的なモジュール解決パッケージは存在しないため、
様々なレベルの機能がサポートされた
複数の競合するものが存在します。
私の場合、プロジェクトはパスマッピングを対応しており、
TypeScriptのtsconfig.jsonで定義されている
パスマッピングを意識していない
リンティングプラグインというのが含まれていました。
当然、そのフィーチャーズスラッシュのディテールページが
ノードモジュールを参照していると思い込んでしまい、
そのモジュールを見つけるために
再期的に情報探索のダンスをすることになりました。
しかし、結局見つからずさらにエラーになりました。
実は下にあったのかな。
上に見つけようとしてなくてエラーになっているけど、
実は下にあったのかですかね。
何かしらエラーが発生したということですね。
依存するライブラリとかにもやっぱりこういう話が出てくるので、
結構使うものは慎重に選ばなきゃいけないかもしれないですね。
まあいいやっていうところで続いてのセクションですね。
あらゆるものをキャッシュ化するという話に入ります。
じゃあ次にロギングを強化してこの関数が呼び出されたときに
ユニークなファイルパスがいくつあるか、
また常に同じ結果が返されるかどうかというのを
ちょっと確認しましたと。
はい。
適当性も一応見てみたんですね。
また、ISファイルの呼び出しでユニークなファイルパスを持つのは
約2.5回、5Kですね。
2500回だけで渡されたファイルの引数と戻り値の間には
強い1対1のマッピングがありました。
これはプロジェクト内のファイル数よりはまだ多いんですけど、
合計15000回呼び出されたことに比べればはるかに少ないですと。
実は5分の1ですからね。
もしファイルシステムにアクセスするのを避けるために
その周りにキャッシュを追加したらどうなるでしょうかというので
そのキャッシュを追加する関数、参考に1個貼られています。
const cache="new map",ですね。
マップオブジェクトからのインスタンスを1回作って
cacheという変数名で保てますと。
function resolveという関数を1個作っておいて
引数に先程の場所にファイルというのを渡しますと。
cache.getでそのファイルを見てみますと。
引数に渡されたファイルという引数をそのまま
getの引数にも渡してあげます。
戻ってきたものがconst cacheという変数にとりあえず入れておきます。
if cacheがundefinedだったらreturn cacheを返せばいいと。
undefinedじゃなければ。
つまりキャッシュの中身があるのであれば
そのままキャッシュを返してあげれば良いと。
そうじゃなければconst resolve="isfile",ですね。
isfileメソッドを実行します。
その時にこの関数、本体の方に渡されたファイルという
もちろん拡張子の順番を変えてプロジェクトで最もよく使われる拡張子を
先頭に置くことももちろんできます。そうすれば正しい拡張子が早く見つかる可能性が
高まりますけど問題は完全に解消できるわけではないです。
ES2015ですね。
2015の仕様の一部として
新しいモジュールシステムというのが提案されました。
詳細はまだ決まっていませんでしたが当時は公文は決まっていました。
インポート文はツールとしてはコモンJSよりも非常に優れているため
すぐに採用されました。
その静的な性質からツリーシェイクのようなより多くのツールの
強化機能のためのスペースが開かれて
未使用のモジュールやモジュール内の関数さえも
簡単に検出し本番ビルドから取り除くことができるようになりました。
当然誰もがこの新しいインポート公文に飛びつきました。
その結果が今の感じですね。
モジュールシステムとかリソリューションの環境になってきたという感じですね。
しかし一つ問題がありました。
それは公文だけが確定しており
実際のモジュールの読み込みや解決はどのように行われるべきかということではなかったということですね。
このギャップを埋めるためにツールはコモンJSの
既存のセマンティックスを再利用しました。
このツールはほとんどのコードベースを移植する際に必要なのは
公文の変更だけであり、これらはコードモッズによって
自動化できているため採用に非常に
高都合だったということですね。
これは採用の中で素晴らしい観点でした。
しかしそれは同時にインポート指定詞がどのファイル拡張子に解決すべきかという
推測ゲームを引き継ぐことも意味します。
結局根本解決にはなっていないということです。
ただ動作することを前提として
再利用したということですね。
この曖昧さの原因を取り除き常に拡張機能を追加することで
一連の問題回避することはできます。
ツールもずっと早くなりますし、いろんなことが解決できます。
しかしツールが曖昧さに対処するために
適応しているためエコシステムが前進するまで
あるいは前進したとしても結構時間がかかるでしょうということです。
では最後のセクションです。
ではここから我々はどうすればいいのかというので
閉められています。
今回の調査を通してモジュール改造との最適化に対して
これほどまでに改善の余地があることに少し驚きました。
この記事で紹介したいくつかの変更によりリント処理にかかる時間は
合計すると30%ほど短縮されました。
こうなったいくつかの最適化は実はJavaScriptに限ったことではありません。
他のプラグラミング言語のツールでも見られるような最適化です。
他の言語でも似たようなことがあるわけですよね。
パス問題の解決は他の言語でもありました。
サーバーサイトだとネームスペースの話もあったりするので
そこが結構回帰してくれるのかもしれないですけどね。
とはいえ結局パス問題はどの言語であろうと
宿命的には逃れられない話ですね。
モジュール解決に関して言えば4つの主要な観点があります。
1つ目はファイルシステムからの呼び出しはできるだけ避けましょう。
2つ目はファイルシステムへの呼び出しを避けるため
できる限りキャッシュをしましょう。
3つ目はfs.statまたはfs.statsyncを使用している場合は
常にslowifnoentryをfalseというのを設定しておけば
速くなりますよと。
これだけで確か15%ほど解決しますよね。