💪

memlab でメモリリークに立ち向かってみた

に公開

モチベーション

VRoid Studio で作ったモデルをウェブサイト上で展示するためのスクリプトを自分用に作っているのですが、three.js のリソース破棄が難しすぎます。
どうしてもメモリリークしている気がしてならないので調べようと思い、情報を集めた結果、memlab を使うと良いらしいので使ってみました。

https://facebook.github.io/memlab/

memlab そのものについては公式サイトをご参照ください。

memlab を使う

調査対象の概要

ウェブサイト上のとあるページで

を使って VRM ファイルと VRMA ファイルをブラウザ上で読み込み、自作の 3D モデルをアニメーションさせています。

Astro 製の静的なサイトですが、View Transitions を使っているので SPA 的に動いています。
そのため、対象ページを離れたらすべてのリソースを破棄する必要があります。

実行準備

memlab はグローバルにインストールしていいのですが、私はプロジェクトの devDependencies としてインストールしました。

インストールしたらシナリオを書きます。対象サイトでは対象のページに遷移したら勝手に VRM ファイルの読み込みが始まるため、シナリオはそのページへのリンクをクリックするという動作にしました。
シナリオファイルはどこに置いてもいいです。私はプロジェクト内にディレクトリを切って置きました。

~/project/.memlab/scenario.cjs
// initial page load's url
function url() {
  return "http://localhost:4321/";
}

// action where you suspect the memory leak might be happening
async function action(page) {
  await page.click('a[href="/vrm/"]');
}

// how to go back to the state before action
async function back(page) {
  await page.click('a[href="/"]');
}

module.exports = { action, back, url };

一点注意があります。
当初、公式ドキュメントの例をそのままコピーして拡張子を .js にしていたら

Invalid scenario file: ~/project/.memlab/scenario.js

というエラーになり動きませんでした。
issue を検索したところ、シナリオファイルを含んでいるプロジェクトの package.json が ESM 形式を指定しているとこのエラーが出ます。

https://github.com/facebook/memlab/issues/93

見た通りシナリオの中身は CommonJS なので、拡張子を .cjs にすると動きました。

実行

実行します。コマンドは下記のようにしました。

memlab run --scenario ~/project/.memlab/scenario.cjs --work-dir ~/project/.memlab  --verbose

ちなみに --work-dir を指定しない場合はデフォルトのディレクトリに結果が保存されてしまいます。
もし指定を忘れてどこに保存されているかわからなくなった場合は memlab get-default-work-dir でわかります。

また私は Windows 機の WSL 環境でやっているのですが、WSL に割り当てるメモリを制限している場合、メモリ不足で memlab が動かしている chromium がクラッシュする場合があります。
重いウェブサイトを確認するなら、メモリは多めにしておいたほうがいいです。
(自分が WSL にメモリを 4GB しか割り当てていないのを忘れていて小一時間格闘しました……。)

実行結果は下記のようになりました。

291 Fiber nodes and Detached elements
Filter and select 54 leaked trace
MemLab found 1 leak(s)
Number of clusters loaded: 0

--Similar leaks in this run: 54--
--Retained size of leaked objects: 10.3KB--
[(GC roots)] (synthetic) @3 [656.7KB]
  --10 (element)--->  [(Global handles)] (synthetic) @23 [196.4KB]
  --2 / DevTools console (internal)--->  [Jp] (object) @214559 [163.4KB]
  --_rendererManager (property)--->  [vl] (object) @121219 [120.6KB]
  --_renderer (property)--->  [Za] (object) @109479 [112.1KB]
  --domElement (property)--->  [Detached HTMLCanvasElement] (native) @71545 [7.5KB]
  --3 (element)--->  [Detached WebGL2RenderingContext] (native) @71563 [2.4KB]
  --10 (element)--->  [Detached InternalNode] (native) @13744 [960 bytes]
  --40 (element)--->  [Detached ExtensionTracker] (native) @14898 [24 bytes]

これを見て、私が作ったスクリプトの中にある _rendererManager._renderer.domElementHTMLCanvasElement を掴んだままになっていてメモリリークしているということがわかりました。
別に three.js のリソース破棄が難しいこととは関係ないですね。破棄できているつもりでいました。
場所は容易に特定できたので、実装を修正しました。

修正後の結果は下記です。

236 Fiber nodes and Detached elements
Filter and select 0 leaked trace
No leaks found
MemLab found 0 leak(s)
Number of clusters loaded: 0

メモリリークを解消できました!

本当にメモリリークはなくなったのか?

しかしながらそもそも three.js のリソースを破棄し忘れたら結果が変わるのか疑問に思い、あらゆるリソースを破棄しないバージョンを作って確認した結果、悲しいことにリーク判定はされませんでした……。

--trace-all-objects オプションつきで実行することによって leak filter を無視してすべてを見ることもできるのですが、そこから発見するのもなかなか難しそうでした。

直接 memlab が保存したメモリのスナップショットファイルを見てみたら、明らかに three.js 由来と思われるものが残ってはいました。
しかしミニマムなソースコードを作ったりして検討した結果、私が作った覚えのない Material などがメモリに存在・残留することもわかりまして、私の削除忘れと一概に言える状態ではありませんでした。
three.js のデフォルトの Material などもありそうですし、WebGL Context が明示的に破棄できないこともあり、描画を終えたからといって即時メモリから全部削除されるわけではないのかもしれません。

結局、当初の目的であった three.js のリソース破棄忘れを根絶できているかどうかはわかりませんでした……。

終わりに

現時点での私の知識不足もあり、はっきりとメモリリークを根絶できたとは言えなかったものの、memlab のおかげで少なくとも一つはメモリリークを解決することができましたし、色々検討するのもやりやすかったです。
とても使い勝手が良いので、メモリリークと戦うときはぜひ使ってみると良いと思います!

chot Inc. tech blog

Discussion