大きな画像を瞬時に開く技術

画像ビューアーの第一印象を決めるのは初回表示までの遅延——ダブルクリックしてから画像が見えるまでの数十〜数百ミリ秒です。この時間のほとんどは「デコード」、つまりディスク上の圧縮されたバイト列を、画面に描けるピクセルへ戻す処理に費やされています。

GuoheView は既製のデコードライブラリを 1 つ組み込んで終わりにはしていません。形式ごとに最速の経路を選び、最もよく使われる JPEG / TIFF については独自の並列化最適化を施しています。この記事ではデコードだけを取り上げます。

画像はどうやってピクセルになるのか

JPEG を例にとると、デコードはおおよそ 3 つの段階からなる流れ作業です:

① エントロピー復号 Huffman ビット列 ② 逆量子化 + IDCT 周波数領域 → 空間領域 ③ 色変換 YCbCr → RGB 本質的に逐次処理 SIMD で並列化可能 SIMD で並列化可能

後半の 2 段階(IDCT、色変換)はピクセルブロックごとに独立した演算なので、もともと SIMD 命令による並列化に向いています。難しいのは最初の段階、エントロピー復号です。これは可変長のビット列で、次のデータの位置は直前のデータが何ビットだったかで決まるため、1 ビットずつ順番に読むしかないように見えます。後で見るように、まさにここでデコーダーの差がつきます。

JPEG:3 つのデコード経路

Windows 標準の WIC

WIC(Windows Imaging Component)は Windows に組み込まれた画像コーデックのフレームワークで、COM コンポーネントとして提供されています。長所は依存関係ゼロで何でもデコードできること(JPEG / PNG / GIF / BMP / TIFF…)で、システム API を 1 つ呼ぶだけでピクセルが得られます。

その代償は「汎用」であることです。あらゆる形式・あらゆる状況に対応しなければならないため、特定の形式向けに極限まで最適化することはできず、マルチコア CPU を自ら使い切ることもありません。そのため GuoheView では WIC はフォールバック専用です。専用の高速経路を持たない形式(BMP、GIF、ICO など)を任せており、手間がかからず信頼できます。

libjpeg-turbo:SIMD で高速化

libjpeg-turbo は業界標準の JPEG ライブラリです。オリジナルの libjpeg からの重要な改良点は、IDCT、アップサンプリング、色変換といったピクセル単位のホットスポットを SIMD 命令(SSE2 / AVX2)で書き直したことです。1 回に 1 つずつではなく、1 命令で 8 個、16 個のデータを同時に処理します。

スカラー 1 回に 1 個、N 回ループ SIMD 1 命令でまとめて処理

しかし libjpeg-turbo の SIMD が高速化するのは流れ作業の後半 2 段階だけです。エントロピー復号は依然としてシングルスレッドの逐次処理です。例外は、JPEG ファイルに RST(リスタート)マーカーが書き込まれ、ビット列が独立した区間に分割されている場合だけです。問題は、ほとんどの JPEG(スマートフォン、カメラ、スクリーンショット)に RST マーカーがないことで、エントロピー復号はマルチコア時代に分担できないボトルネックになっています。

自社開発の pjdec:逐次的なエントロピー復号も並列化

これが GuoheView のデコードエンジンの中核です。私たちが答えを出したかった問いは、RST マーカーのない普通の JPEG でも、エントロピー復号でマルチコアを使い切れるか? というものでした。

直感的には無理です。ビット列は可変長で、しかも各データブロックの明るさ(DC 係数)は直前のブロックとの差分として記録され、すべてが連鎖しているからです。しかし私たちは「投機的並列化」という方法を使いました:

従来:シングルスレッドで順番に復号 . 1 つのコアが最初から最後まで読み、他のコアは遊んでいる pjdec:区間に分け、各コアが区間の先頭から「見切りで」復号 コア1 コア2 コア3 コア4 破線 = バイト境界での見切り復号の開始点(位置がずれている可能性あり) ● = 数ブロック後に自動的に「噛み合う」同期点。以降はビット単位で正確 検証 OK → 同期点以降の結果を採用。噛み合わない場合 → 順番に復号し直して保証

この方法は、Huffman 符号の不思議な性質である自己同期性に支えられています。間違った位置から復号を始めても、数個の符号語を読み進めるうちに、デコーダーは自動的に本来の符号語の境界に「揃う」のです。そこで各コアは自分の区間の先頭にあるバイト境界から見切りで復号を始め、まずは「位置が正しくないかもしれない」結果を得ると同時に、各データブロックの開始位置(チェックポイント)を記録しておきます。

続いて軽い逐次検証を行います。前の区間を正しく復号した終了位置が、次の区間のいずれかのチェックポイントと完全に一致すれば、符号化の決定性により、そこから先は両者がビット単位で同一であることが保証されるので、次の区間の結果をそのまま採用します。連鎖している DC の差分については、見切り復号では 0 から数え始めるため真の値とは定数分だけずれていますが、同期後に差分を一括で加えれば済みます(16 ビット整数のラップアラウンドを利用するため、コストはゼロです)。

万一ある区間がいつまでも噛み合わない場合(ごくまれです)は、その小さな区間だけを順番に復号し直します。正しさには常に保証があり、並列度が少し下がるだけです。最終的に、普通の大きな JPEG でもエントロピー復号でマルチコアを使い切れるようになり、初回表示が目に見えて速くなります。

TIFF:2 つのデコード経路

libtiff

libtiff は TIFF を扱う事実上の標準ライブラリで、形式のカバー範囲が最も広い(各種圧縮、CMYK、マルチページ)ライブラリです。GuoheView は互換性を確保するためにこれを土台にしており、libtiff で読める TIFF ならすべて開けます。その制約もやはり、データブロック(strip / tile)を 1 つずつシングルスレッドで順番に処理することです。

自社開発の tiff_mt:ブロック単位で並列化

TIFF の構造は実はもともと並列化に向いています。画像は互いに独立した多数のストリップ(strip)やタイル(tile)に分割されているからです。私たちの tiff_mt デコーダーは、その上でマルチスレッド化を行います:

TIFF のタイル / ストリップは互いに独立 コア1コア2コア3コア4 コア1コア2コア3コア4 スレッド数を自動調整: コア数・圧縮方式・ 処理量に応じて動的に決定 大きな JPEG ブロック 1 つ → pjdec のストリーム内並列を再利用

単に「スレッドを増やすだけ」より、さらに 2 歩踏み込んでいます:

  • スレッド数の自動調整:何も考えずに全コアを使うわけではありません。非圧縮の TIFF はメモリ帯域に律速され、スレッドを増やすとかえって帯域の奪い合いになります。処理量が少なすぎる場合はスレッドを立てる価値がありません。コア数、圧縮方式、データ量に応じて動的に決めます。
  • 2 種類の並列化の連携:TIFF が JPEG 圧縮されていて、大きなブロックがほんの数個しかない場合、単純な「ブロック単位の並列化」ではほとんどのコアが遊んでしまいます。そのとき tiff_mt はストリーム内並列化に切り替え、前述の pjdec の投機的並列化をそのまま再利用して、その大きな JPEG ストリームを分割します。2 つの自社開発デコーダーがここでつながっています。

私たちの戦略

GuoheView の内部にはデコーダーの登録表があり、各デコーダーは自分が認識できる形式の特徴(ファイルヘッダーのマジック)と優先度を宣言しています。ファイルを開くと、エンジンはファイルヘッダーで正確に照合し、優先度が最も高い専用デコーダーを選びます。JPEG は pjdec、TIFF は tiff_mt に回り、どれにも該当しない場合にだけ WIC にフォールバックします。