はじめに
「ブラウザで 50GB のテキストファイルは開けない」——私もそう思っていました。
私は UwView という大容量テキストビューアを作っています。C# / Avalonia 製で、Windows・macOS・Linux で動くデスクトップアプリです。Avalonia には Avalonia.Browser というブラウザ向けの実行系があるので、「せっかくだから WebAssembly でも動かしてみるか」と、ほとんどおまけのつもりで Browser ヘッドを足しました。
結果から書きます。同じ C# コードが、ブラウザの中で 50GB のファイルを開き、全文検索を完走します。ネイティブ版に対して開くのが約3倍、検索が約1.7倍遅いだけです。250GB は読み込み 51% で止まったので、そこが今の限界です。ただし 50GB はあくまで「動くか」を確かめた数字で、普段使いは 3GB(1億行)くらいまで——そのサイズなら、ブラウザの中でストレスなく開いて検索できます。
この記事は、「なぜそれが可能なのか」と「Wasm に持っていくときに何で転んだか」を、コードの構造に沿って書きます。Wasm そのものの解説記事ではなく、巨大ファイルを Wasm で扱うときの設計の話です。
実測:ブラウザ版とネイティブ版、同じコードでどれだけ違うか
まず数字です。ファイルは OpenStreetMap の XML(3GB は日本の一部を切り出した約1億行、10GB・50GB・250GB は同系統の大きいもの)。マシンは Mac(M4 / 32GB)、ファイルは外付け USB SSD、ブラウザは Chromium 系です。「開く」は索引構築が終わって行番号付きの表示になるまで、「検索」は全文検索でヒット一覧が出そろうまでの秒数です。
| ファイル | ネイティブ版 開く | ブラウザ版 開く | ネイティブ版 検索 | ブラウザ版 検索 |
|---|---|---|---|---|
| 3GB | 6.2 s | 10.4 s | 0.25 s※ | 5.8 s |
| 10GB | 21.7 s | 61 s | 18.9 s | 29.9 s |
| 50GB | 106.8 s | 307 s | 87.9 s | 150 s |
| 250GB | 532.6 s | 51% で停止 | 449.4 s | — |
※ ネイティブ版の 3GB 検索は直前に開いた OS キャッシュが効いた値です。
ネイティブ版とブラウザ版は、後述する I/O 層(数百行)以外は完全に同じコードです。索引の作り方も、検索のアルゴリズムも、描画も同じ。それで開くのが 1.7〜2.9 倍、検索が 1.6〜1.7 倍。「Wasm だから桁で遅い」というほどの差は出ませんでした。
そして、これをやっている間のファイル本体のメモリ常駐は最大 16MB です。50GB のファイルを開いていても、です。
なぜ 16MB で済むのか:全体を載せない設計
上位層は「ファイルの読み方」を知らない
UwView の中で、ファイルの実バイトに触る API はこれ一つです。
public interface IByteSource : IAsyncDisposable
{
long Length { get; }
// offset から buffer.Length バイト読み、読めたバイト数を返す
int Read(long offset, Span<byte> buffer);
// 非同期読み(索引構築・検索・文字コード判定はこちら)
ValueTask<int> ReadAsync(long offset, Memory<byte> buffer, CancellationToken ct = default)
=> new(Read(offset, buffer.Span));
}
「任意の位置から任意の長さを読む」だけ。行の概念も、ファイルパスも、ストリームもありません。デスクトップ版はこれをメモリマップドファイルで実装し、ブラウザ版は Blob.slice で実装します。索引・検索・描画の層は、どちらの上で動いているかを一切知りません。
Wasm 対応で書いたのは、この IByteSource の Blob 実装と、ファイル選択ダイアログ、あとは JS 側の数十行だけです。
索引は 256 行に 1 つしか持たない
巨大ファイルのビューアで最初に効いてくるのは「N 行目はファイルの何バイト目か」という変換です。全行のオフセットを持つと、2億行で 1.6GB(long × 2億)。ブラウザでは論外です。
UwView は 256 行ごとに 1 つだけ先頭バイトオフセットを記録します。2億行でも約 6MB。N 行目が欲しければ、直近のチェックポイントから最大 255 行ぶん \n を数えて進みます。1 回の順次読みで構築できるので、索引構築 = ファイルを頭から 1 回舐める処理、それだけです。
/// N 行(既定 256)ごとに 1 つだけバイトオフセットを記録するため、
/// 2 億行でも索引は約 6MB。
public sealed class SparseLineIndex { ... }
開いた瞬間は「ページモード」
索引ができるまで待たせるのは論外なので、開いた直後は索引なしで先頭から見せるモードで表示します(ページモード)。行番号は出ませんが、スクロールも検索もできます。裏で索引が完成した時点で、その表示位置を保ったまま「行モード」へ切り替えます。
これはデスクトップ版のためにあった設計ですが、ブラウザ版では 1 秒未満で 50GB の先頭が表示されるという形で、そのまま効きました。
Blob.slice でランダム読みを作る
ブラウザには mmap がありません。ローカルファイルは <input type="file"> で選ばれた File(= Blob)として渡され、中身は blob.slice(offset, end).arrayBuffer() で非同期に取り出すしかありません。
そこで、256KB 単位のチャンクを LRU で最大 64 個(= 16MB)キャッシュする BlobByteSource を書きました。
public sealed class BlobByteSource : IByteSource, INotifyDataArrived
{
private const int ChunkSize = 256 * 1024;
private const int MaxChunks = 64; // 合計 16MB
private readonly LruCache<long, byte[]> _chunks = new(MaxChunks);
...
}
ポイントは、Read(同期)と ReadAsync(非同期)の役割を分けたことです。
-
ReadAsyncは索引構築と検索が使う経路。チャンクが無ければawaitして取りに行きます。ファイルを頭から舐める処理は、この経路で素直に書けます。 -
Readは描画が使う経路。UI スレッドでawaitはできないので、キャッシュにある分だけ返して、無い分は裏で取得を発火する。取得が終わったらDataArrivedイベントで再描画を促します。ついでに次のチャンクも先読みしておくと、スクロール時の空白がすぐ埋まります。
if (!_chunks.TryGet(chunkIdx, out var chunk))
{
// 未取得: 裏で取得を発火し、揃った分だけ返す(描画側は DataArrived で再描画)
_ = FetchChunkAsync(chunkIdx, notify: true);
long next = chunkIdx + 1;
if (next * ChunkSize < Length) _ = FetchChunkAsync(next, notify: true);
break;
}
つまりブラウザ版では、「読もうとした場所のデータが、まだ手元に無い」という状態が日常的に発生します。デスクトップ版には存在しなかった状態です。後で書きますが、Wasm 対応の不具合はほぼ全部ここから出ました。
JS 境界で転んだ 2 つのこと
Promise<byte[]> は返せない
.NET の [JSImport] は Promise<byte[]> を扱えません。なので「非同期で読んでトークンを返す」「トークンを渡して同期で取り出す」の 2 段に分けています。
[JSImport("readSliceBegin", "blobRead")]
[return: JSMarshalAs<JSType.Promise<JSType.Number>>]
internal static partial Task<int> ReadSliceBeginAsync(int id, double offset, int length);
[JSImport("readSliceTake", "blobRead")]
internal static partial int ReadSliceTake(
int token, [JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> buffer);
export async function readSliceBegin(id, offset, length) {
const f = files.get(id);
const end = Math.min(offset + length, f.size);
const data = new Uint8Array(await f.slice(offset, end).arrayBuffer());
const token = nextToken++;
results.set(token, data);
return token;
}
export function readSliceTake(token, buffer) {
const d = results.get(token);
results.delete(token);
const n = Math.min(d.length, buffer.length);
buffer.set(n === d.length ? d : d.subarray(0, n)); // .NET 側バッファへ一括コピー
return n;
}
1 バイトずつ double に変換されていた
最初のバージョンは byte[] を JSType.Array<JSType.Number> で返していました。これがバイトごとに JS の number(double)へ変換する経路で、桁違いに遅い。22MB のファイルで約 2,300 万回の変換です。当時の計測では、22.4MB・40 万行の索引構築がデスクトップ 19〜28ms に対してブラウザ 473ms、検索が 4〜5ms に対して 531ms でした。検索処理そのものより、データの受け渡しの方が重いという状態です。
JSType.MemoryView で .NET 側のバッファを JS に見せ、Uint8Array.set で一括コピーする形に変えました。上の readSliceTake がそれです。今の 50GB の数字は、この変更後のものです。
「まだ届いていない」という状態が無かったせいで起きたこと
デスクトップ版から持ってきたコードには、「読めない」という状態がそもそもありませんでした。mmap なら 10 億行目の先頭バイトはその場で読めるからです。ブラウザ版で起きた不具合は、ほぼ全部この一点に帰着します。
本文が空になる。 未到着のチャンクを Read が 0 バイトで返すのを、「ファイルの終端」と判定していました。終端なら空が正しい結果なので、そのままキャッシュに焼き付く。データが後から届いても、キャッシュを見に行くので直りません。対処は「未到着」を明示的に検出して不完全な結果をキャッシュしないこと、そして届いた時点で描き直すことです。
検索結果の行番号がずれる。 5,000 行目のヒットが 4,865 と出る。原因は同じで、行番号を求める途中で未到着に当たると別の行を数えていました。検索結果一覧は行番号を経由せず行頭のバイトオフセットから直接読む方式に変えました。
タブが固まる。 検索結果を「前後 ±1 行」表示に切り替えると、全ヒットの行番号を求める処理が未到着に当たり、「取得を要求 → 再計算 → まだ無い → また要求」と回り続けました。Wasm は単一スレッドなので、このループは画面ごと止めます。結果を保持してやり直さない非同期処理に変え、ヒット行だけ先に出して前後行は後から足すようにしました。
Wasm 固有ではないもので言うと、日本語 IME の変換中のキー入力がフレームワークをすり抜けて toukyou東京 になる(ブラウザ側で変換中のキーを止めた)、Wasm にはシステムフォントが無いので Noto Sans JP を同梱した、といったこともありました。トリミング(PublishTrimmed)はリフレクション経由のローカライズを壊したので切っています。
残っている限界
正直に書きます。
- 250GB は読み込み 51% で止まります。 原因はまだ特定できていません。索引構築の進捗が 51% から先へ進まなくなる、という症状です。
- タブを背面にすると処理がほぼ止まります。 ブラウザが非表示タブを抑制する上に、Avalonia が画面更新に同期して処理を進めるためです。50GB を開く 5 分間はタブを前に出しておく必要があります。
-
ネイティブとの速度差は構造的です。
Blob.sliceの非同期経路と、Wasm の CPU 性能によるもので、上の JS 境界の改善のような「桁で縮む」余地はもう無いと思っています。 - 毎回ファイル選択ダイアログからしか開けません(パス直開き不可)。追記中ログの tail もできません(Blob はスナップショットなので)。
なので、ブラウザ版の価値は速度ではなく「インストール不要で、その場で一通り動く」ことです。50GB は「動くことを確かめた」数字であって、普段使いを勧めるサイズではありません。
ファイルサイズで使い分ける
同じ UwView でも 3 つの形があるので、私自身の使い分けを書いておきます。数字は上の実測です。
| ファイルサイズ | 勧めるもの | 目安 |
|---|---|---|
| 〜3GB(1億行くらいまで) | ブラウザ版(Wasm) | 開く 10 秒・検索 6 秒。インストール不要で、ストレスなく使えます。共有 PC や、ちょっと覗きたいだけの場面はこれで足ります |
| 3GB〜50GB | デスクトップ版(無料) | 50GB で開く 107 秒・検索 88 秒。ブラウザ版だと 5 分と 2.5 分かかるうえ、タブを前に出しっぱなしにする必要があります |
| 50GB 以上、または同じファイルを何度も検索する | UwView Pro(有料) | 索引をファイルに保存して 2 回目以降は即座に開き、検索も無料版より速い(250GB で開く 318 秒・検索 32 秒)。ここは有料版の領分なので、詳細はリンク先に譲ります |
要するに、ブラウザ版は 3GB 以下で使ってください。それ以上はデスクトップ版を入れたほうが、時間もタブも節約できます。
Wasm で巨大ファイルを扱うときの設計パターン(一般化)
自分の経験を切り出すと、次の 5 つになります。Wasm に限らず、「ファイル全体を持てない環境」全般に効くと思っています。
- 「位置と長さで読む」だけの I/O 抽象を一枚置く。 mmap でも Blob でも HTTP Range でも、その下に差し替えられる。上位層はファイルの読み方を知らない。
- 索引は疎に持つ。 N 行に 1 つのチェックポイントで、2億行が数 MB に収まる。
- 「まだ届いていない」を第一級の状態として扱う。 終端と区別し、不完全な結果はキャッシュしない。届いたら再描画。
-
同期の描画経路と、非同期の重い処理経路を分ける。 描画は「あるものだけ描いて裏で取りに行く」、索引と検索は素直に
awaitする。 -
JS 境界はバルク転送。 要素単位のマーシャリングは避けて、
MemoryViewに一括で書く。
3 番が一番大事で、一番後回しにしていたものでした。
試す
-
ブラウザ版デモ(インストール不要・Chromium 系推奨): https://amru195704.github.io/UwView/
-
ソースコード(OSS): https://github.com/amru195704/UwView —
UwView.Browser/BlobByteSource.csとwwwroot/blobRead.jsがこの記事の中身です -
環境: .NET 10 / Avalonia 12 /
Avalonia.Browser -
デスクトップ版(無料): https://github.com/amru195704/UwView/releases
-
UwView Pro: https://uvp.y42u.net/pro/
手元に 1〜3GB くらいのログがあれば、ぜひブラウザ版に放り込んでみてください。「開いた瞬間に先頭が出て、10 秒後に行番号が付く」のを見ると、Wasm の印象が少し変わると思います。