この連載の前の4回は、どれも有料版 uvp の索引の話でした。索引を作らない無料版 uvf には、別の遅さが残っていました。
全体テストの結果を眺めていて、無料版だけが ripgrep に負けている式が1つ見つかりました。追いかけると、原因は検索ではなく「ヒットした行を書き出すところ」でした。ついでに見つかった2つも合わせて、v1.8.1.9 “Finder Scope” で3つ直しています。v1.9.0 “Full Spectrum” にもそのまま入っています。
- 先に結論: 書き出しは 4.70秒→2.68秒・確保メモリ 28GB→0.4GB。でも ripgrep 2.83秒 との差は 1.06倍の同等どまり。先読みの式は ripgrep より 1.65倍速くなり、** の一覧作りは 1.38倍の同等どまり
- 問題: 検索ではなく、書き出しが重かった
- 直し1: ヒットした行ごとに文字列を作るのをやめた
- 直し2: 先読み (?=…) の中にある語で、先に候補の行を絞る
- 直し3: ** の展開で、下のフォルダーの一覧を先読みする
- 没にしたもの
- 数字: 3つの直しをまとめて
- 負け: 追いついただけ、効く範囲が狭い、本物では働かない
- 正しさ: 出力がバイト単位で同じことを確かめてから入れた
- 持ち帰れる一般論: 遅いところは、探すところにあるとは限らない
- uvf ならこう書く(uvp ならこう書く、も同じです)
- リンク
先に結論: 書き出しは 4.70秒→2.68秒・確保メモリ 28GB→0.4GB。でも ripgrep 2.83秒 との差は 1.06倍の同等どまり。先読みの式は ripgrep より 1.65倍速くなり、** の一覧作りは 1.38倍の同等どまり
負けを先に書きます。
直す前は、3GB の \d+\.\d{6,} を -H 付きで書き出す式が、ripgrep の方が 1.85倍速い向きでした。 そのときの uvf は確保したメモリが 28GB まで膨らんでいました。直した後は 4.70秒 → 2.68秒、確保は 28GB → 0.4GB。それでも ripgrep は 2.83秒で、1.06倍(1.5倍未満なので同等。uvf が速い向き)どまりです。追いついただけで、勝ってはいません。
3つの直しの効果を並べます。
- ヒットした行の書き出し(3GB・
\d+\.\d{6,}・-H): 4.70秒 → 2.68秒(ripgrep 2.83秒・1.06倍の同等)。確保メモリ 28GB → 0.4GB - 肯定の先読み
(?=…)を使う式(Linux カーネル6万本): 1.52秒 → 0.57秒(ripgrep 0.94秒・1.65倍速い)。候補の行が多い1本のファイルは画面の本体に任せて 4.68秒 → 2.18秒 **でフォルダーを広げる(6万本の写し): 一覧作りが 0.33秒 → 0.24秒(1.38倍の同等どまり)。しかも.gitのある本物のsrc/linuxでは働きません
uvf・uvpは私が作っている UwView の検索コマンドです(uvfは無料版、uvpは有料版 UwView Pro)。この記事の3つは v1.8.1.9 “Finder Scope” で入り、v1.9.0 “Full Spectrum” に含まれています。v1.9 には「Full Spectrum」というペットネームを付けました。Shift_JIS・EUC-JP・UTF-16 のファイルも中を UTF-8 にそろえ、文字コードやファイルの種類を問わず、同じ速さで探せるようになった版だからです。秒数はある1台の Mac(Apple M4・10コア・メモリ32GB・外付け USB SSD)で 2026年10月6〜7日に測った値です。書き出しの 4.70秒 → 2.68秒 は v1.8.1.9 のリリースノートに載った値、先読みと**の2つは開発版(試験版 1.8.1.2・1.8.1.3)での測定です。比べた ripgrep の版は、この測定の記録に残っていないので書きません。
問題: 検索ではなく、書き出しが重かった
v1.8.1 の全体テストのまとめに「気になるところ」という節を作り、前の版と比べて遅い、または ripgrep に負けている項目を4つ並べていました。そのうちの1つが uvf の \d+\.\d{6,} です。
この式は「小数点の後ろに6桁以上の数字が続く数」を探します。相手は 3GB の OpenStreetMap の XML で、緯度・経度がほぼ全行に入っています。つまり 2,830万行がヒットします。 検索としてはほぼ全行が答えになる、極端な式です。
ほかの3つは、調べたら版の変化ではありませんでした。uvf の kernel の2回目が1割遅く見えたのは、3つの版を交互に測ると5問の合計が 10.98/10.96/10.95秒で同じで、ripgrep も同じだけ遅くなっていました(7.46秒 → 7.91秒)。3GB 平文の1回目 11.87秒も、測り直すと 3.09秒(ripgrep 3.31秒)で再現しませんでした。有料版の索引込み 13.88秒も、温まった状態では版によらず 6.1〜6.4秒でした。
残った1つだけが本物でした。 そして原因は、式の照合ではありませんでした。
直し1: ヒットした行ごとに文字列を作るのをやめた
前の作りは、ヒットした行を出すたびにその行の文字列を3回作っていました。行番号を付ける、ファイル名を付ける、改行を付ける——そのたびに新しい領域を取っていたわけです。
1行なら気になりません。2,830万行だと、確保したメモリの合計が 28GB になりました。 実メモリが 32GB の機械なので、ほとんど使い切っています。検索そのものは終わっているのに、出すところで時間を使っていました。
直し方は単純です。使い回す1つの領域に直接書き、そのまま流します。 行ごとの文字列は作りません。
- 3GB・
\d+\.\d{6,}・-H: 4.70秒 → 2.68秒(1.75倍速くなった) - 確保メモリ: 28GB → 0.4GB(70分の1)
- 出力は10通りの組み合わせで、直す前と1バイトも同じ
ここで気をつけたいのは、この直しが効く範囲が狭いことです。 ヒットが少ない検索と、カーネル6万本のような多数ファイルは、配布版とまったく同じ速さでした。行ごとに領域を取る無駄は、行が多いときだけ積み上がります。「メモリを無駄に使っている」と「遅い」は、同じ条件でしか重なりません。
直し2: 先読み (?=…) の中にある語で、先に候補の行を絞る
2つめは正規表現です。^(?=.*User: …)(?=.*Action: …)(?=.*Time: …) のような式は、肯定の先読みを並べて「3つすべてを含む行」を表します。
前の作りは、この式を全部の行に当てていました。必ず含む文字列を式から取り出して絞る仕組みは前からあるのに、先読みの中は読んでいなかったのです。
読めるようにした理由は、照合が1行ずつで、. が改行にヒットしないからです。先読みの中身は、ヒットする行の中に必ずあります。 だから Action: を含む行だけに式を当てれば足ります。
- Linux カーネル6万本: 1.52秒 → 0.57秒(2.67倍速くなった)。ripgrep は 0.94秒で、1.65倍速くなりました
- 1語だけを探す式も 0.64秒 → 0.56秒(1.14倍の同等)
否定の先読みと後読みは、今までどおり扱いません。 「含まない」条件からは、必ず含む語を取り出せないからです。先読みの中が読めなくても、式の残りから取れた語は今までどおり使います。安全側に倒した結果が性質テストで守られているかは、先読み・後読みを混ぜたランダムな式を自動試験に足して確かめています。
候補が多い1本のファイルは、画面の本体に任せる
この絞り込みを入れると、副作用が出ました。1GB の1本のログで候補の行が 約370万行ある式は、「全部の行に当てる式」という扱いから外れ、先読みのまま 4.68秒かかりました。
uvf の本体は、起動を速くするために前もって機械語にしておく作り(AOT)です。正規表現をその場でコンパイルする仕組みは持っていません。画面のアプリ側は持っています。そこで、候補の行が多い1本のファイルだけ、画面の本体に渡すことにしました。
- 1GB の1本・候補 約370万行: 4.68秒 → 2.18秒(2.15倍速くなった)
- 判定は、先頭 8MB で数えた割合をファイル全体に引き延ばして見積もり、30万行以上なら渡す
- 束ねた索引(カーネル6万本)は渡しません
- 単純な式(
User: (\S+))は、候補が多くても渡しません。 測ると 0.93秒 対 1.02秒で、渡した方が遅い向きでした
最後の1行が、この判定を「先読みの式だけ」に絞った理由です。「コンパイルできる方が速い」は、式が重いときだけ成り立ちました。
直し3: ** の展開で、下のフォルダーの一覧を先読みする
3つめはファイルを広げるところです。'linux/**' のように書くと、下のフォルダーをたどってファイルの一覧を作ります。前はたどりながら1つずつ読んでいました。
見つけた先から下の一覧を先読みするようにしました。前もって読むのは一覧だけで、除外の判定は今までどおりたどる側がします。 結果は変わりません。
- カーネルの写し6万本: 一覧作り 0.33秒 → 0.24秒(1.38倍で、1.5倍未満なので同等。先読みが速い向き)
そして、この直しは本物のソースツリーでは働きません。 .ignore・.gitignore・.git があるフォルダーから下では深く先読みしません。始めのフォルダーやその上に除外の決まりがあれば、まったくしません。除外されるフォルダーを裏で読む無駄を避けるためです。.git のある本物の src/linux では、今までどおりの速さです。 効いたのは、.git を外した写しだけでした。
没にしたもの
伏せ字(-mask)を画面の本体に任せる案は、作って測って外しました。1GB・230万行で 1.97秒 対 2.08秒。渡しても速くなりませんでした。 直し2と同じ道具を広げようとして、効かなかった例です。
一覧作りの2回目の読み取り(リンクを数えるもの)も、外しても1割しか縮みませんでした。リンクを安く見分ける方法が無いので、そのままにしています。
数字: 3つの直しをまとめて
単位は見出しに書きます。前後の比は、書き出しだけが v1.8.1.9 のリリースノートの値で、残りの2つは開発版での測定です。
| 直したところ | 土俵 | 前(秒) | 後(秒) | 前の版より何倍速いか(倍) | ripgrep(秒) |
|---|---|---|---|---|---|
| ヒットした行の書き出し | 3GB・\d+\.\d{6,}・-H |
4.70 | 2.68 | 1.75 | 2.83 |
先読み (?=…) の必須の語で絞る |
カーネル6万本 | 1.52 | 0.57 | 2.67 | 0.94 |
| 候補が多い1本を本体に任せる | 1GB・候補 約370万行 | 4.68 | 2.18 | 2.15 | — |
** の展開で一覧を先読み |
6万本の写し・一覧作り | 0.33 | 0.24 | 1.38(同等) | — |
| (没)伏せ字を本体に任せる | 1GB・230万行 | 1.97 | 2.08 | 速くならず | — |
| (参考)1語だけの式 | カーネル6万本 | 0.64 | 0.56 | 1.14(同等) | — |
ripgrep と比べられるのは上の2つだけです。書き出しは 2.68秒 対 2.83秒で、uvf が速い向きの同等。先読みの式は 0.57秒 対 0.94秒で、uvf が 1.65倍速い。 下の3つは、ripgrep に同じ仕組みがありません(本体に渡す・** の先読み・伏せ字はどれも ripgrep の持ち物ではないので、前の版の uvf と比べています)。
確保メモリは 28GB → 0.4GB です。ripgrep 側のメモリは測っていないので、並べません。
負け: 追いついただけ、効く範囲が狭い、本物では働かない
1. 書き出しは、追いついただけです。 2.68秒 対 2.83秒は 1.06倍で、同等の範囲です。直す前が 1.85倍負けていたので負けを消したのは事実ですが、勝ったとは書けません。
2. 書き出しの直しは、ヒットが多い式でしか効きません。 ヒットが少ない検索とカーネル6万本は、配布版と同じ速さでした。2,830万行という極端な条件で初めて 2秒分が出てきます。
3. ** の先読みは .git のある本物のソースツリーでは働きません。 0.33秒 → 0.24秒 は .git を外した写しでの値で、しかも 1.38倍の同等どまりです。この直しが効く場面は、測定用のデータだけかもしれません。
4. 否定の先読みと後読みは、絞り込みに使えません。 「含まない」条件から必ず含む語は取り出せないので、今までどおりの速さです。
5. 本体に渡す判定は、見積もりです。 先頭 8MB の割合を全体に引き延ばしているので、前半と後半で行の形が違うファイルでは外れます。外れたときは、渡さなくてよいものを渡す(または逆)だけで答えは変わりませんが、秒数は読み切れません。
6. 伏せ字を渡す案は没です。 1.97秒 → 2.08秒。作って測った分の時間は戻りません。
正しさ: 出力がバイト単位で同じことを確かめてから入れた
3つとも「答えを変えずに速くする」直しなので、出力が1バイトも変わらないことが入れる条件でした。
- 書き出し: 10通りの組み合わせで、直す前と1バイトも同じ。自動試験は UwView.Core.Tests 765件(新規9件)・UwView.Pro.Core.Tests 899件がすべて通りました。
- 先読みの絞り込み: 1GB の1本とカーネル6万本の式を含む計8件で、直す前と1バイトも同じ。ヒットする行は必ず取り出した語を含むという性質を、先読み・後読みを混ぜたランダムな式で確かめる試験を足しました。
**の先読み: 前もって読むのは一覧だけで、除外の判定はたどる側が今までどおり行います。判定の順番を変えていないので、結果は変わりません。- 本体に渡す判定: 渡した場合と渡さない場合で同じ出力になることを確かめています。
-maskを渡さない判断も、同じ比べ方で決めました。
持ち帰れる一般論: 遅いところは、探すところにあるとは限らない
3つを直して残ったのは、3つの教訓です。
出すところも測ります。 \d+\.\d{6,} が遅かったのは、照合ではなく書き出しでした。検索の道具を速くしようとすると、つい式の当て方だけを見ます。行ごとに領域を取る作りは、行が少ないうちは誰も気づきません。
確保したメモリは、遅さの手がかりになります。 28GB という数字が出た時点で、「1行につき3回作っている」まではすぐ届きました。秒数だけを見ていると、どこを直せばよいか分かりません。
効く条件を書き留めておきます。 書き出しの直しはヒットが多い式だけ、** の先読みは .git が無いときだけ、本体に渡すのは先読みの式だけ。どれも「いつでも速くなる」ではありません。 条件を書かずに「速くなった」と覚えておくと、次に測ったとき効いていない理由が分からなくなります。
uvf ならこう書く(uvp ならこう書く、も同じです)
uvf japan.osm '\d+\.\d{6,}' -E -H # ヒットした行をファイル名付きで書き出す
uvf 'linux/**' '^(?=.*User: )(?=.*Action: )' -E # 肯定の先読みを並べた式(中の語で絞る)
uvf 'linux/**' 'User: (\S+)' -E # 単純な式(本体には渡さない)
uvf 'linux/**' --files # 広げたファイルの一覧だけを出す
uvp japan.osm '\d+\.\d{6,}' -E -H # 有料版も同じ書き方(索引を使う)
uvp 'linux/**' '^(?=.*User: )(?=.*Action: )' -E # 同じ式を索引つきで
書き方は無料版と有料版で同じです。終了コードは grep と同じで、0=ヒットあり・1=ヒットなし・2=エラーまたは結果が不完全です。無料版は GitHub Releases から入ります(Mac は brew install --cask amru195704/uwview/uwview、Windows は Scoop、Linux は Snap)。
リンク
- Full Spectrum①: 索引の中を UTF-8 にそろえた話: https://uvp.y42u.net/blog/uwview-fs01-sjis-utf8-index/
- Full Spectrum②: ヒットしない行のかたまりを読まずに飛ばす「行の骨格」: https://uvp.y42u.net/blog/uwview-fs02-line-skeleton/
- Full Spectrum③: 開かなくてよいブロックを先に知る「ブロックの印」: https://uvp.y42u.net/blog/uwview-fs03-block-marks/
- Full Spectrum④: 10GB の2回目はどこで縮んだか: https://uvp.y42u.net/blog/uwview-fs04-10g-second-search/
- v1.9.0「Full Spectrum」の公開告知(この版の全体像): https://uvp.y42u.net/blog/uwview-v190-full-spectrum/
- 正規表現から「必ず含む文字列」を取り出して絞る話: https://uvp.y42u.net/blog/uwview-fl04-regex-required-literal/
- ripgrep が勝つ条件をまとめた回: https://uvp.y42u.net/blog/uwview-wf14-where-ripgrep-wins/
- 実測の全データ(ベンチマーク一覧): https://uvp.y42u.net/benchmarks/
- ソースコード(GitHub): https://github.com/amru195704/UwView
そして、同じ語を何度も探す巨大なログを、2回目から一段速く探したいなら UwView Pro をどうぞ。永続索引・圧縮キャッシュ検索・約 1/9 保管で、開き直しも検索も速くなります(全 OS 対応・買い切りと月額の2通り・14日間の無料試用つき。料金は価格ページをご覧ください)。
開発者より: アプリ・Kindle 本・公開プロジェクトの一覧は GitHub: amru195704 にまとめています。
