1 ポイント 投稿者 GN⁺ 2024-09-08 | 1件のコメント | WhatsAppで共有
  • GitHub PagesがBrotliをサポートしていないため、HTMLを可逆WebP画像にエンコードし、ブラウザの画像デコーダとJavaScriptで復元して転送量を減らす実験
  • WASM Brotliデコーダは71 KiB~200 KBの追加コストがかかり、Compression Streams APIはgzipdeflatedeflate-rawのみをサポートするため、Brotliデコードの迂回路になりにくい
  • VP8L可逆WebPは予測変換、16x16ブロックごとのHuffman木の再利用、color cacheのおかげで、テキスト系データでもgzipより小さい結果を出せる
  • テストHTML 439,478バイトはgzipで94,683バイト、WebPで43,182バイトまで縮小され、Brotliの37 KiBよりは大きいがgzip比で約2.2倍小さかった
  • Canvas 2Dのanti-fingerprintingノイズ、WebGL readPixelsの変更、初期の空白画面、スクロール復元の問題により、実運用というよりブラウザの制約をあらわにするハックに近い

GitHub PagesでBrotliを使えない問題

  • ページ読み込み時間を短縮する際は、HTML minifyよりHTTP圧縮のほうが効果が大きい
  • HTTPはContent-EncodingヘッダでgzipBrotliをサポートする
    • gzipは低コストなので一般的にデフォルトで有効化される
    • Brotliは通常gzipより圧縮率が良いが、はるかに遅い
  • GitHub PagesはBrotliをサポートしていないため、サイトで最も長い記事Recovering garbled Bitcoin addressesは、Brotliなら37 KiBで済むところがgzipでは92 KiBになる
  • この差のせいで読み込み時間が不要に2.5倍になる

最初に思いついた代替案と行き詰まった点

  • GitHubが事前圧縮したBrotliファイルのアップロードと配信をサポートすれば問題を回避できるが、その機能は提供されていない
  • クライアント側でJavaScriptにより直接展開する案は、WASMデコーダのサイズのため利点が薄れる
  • ブラウザのHTTPスタックにはBrotliデコーダがあるが、Compression Streams APIDecompressionStreamgzipdeflatedeflate-rawしか受け付けない
  • gzipをZopfliで事前圧縮しても86 KiBで、なおBrotliより大きい

画像フォーマットを圧縮コンテナのように使う

  • ブラウザはすでに画像をデコードできるため、データを画像ピクセルに入れてCanvas APIで読み戻せば、展開ロジックを新たに実装しなくてよい
  • GIFはrow-major順でデータを並べてからLZWを適用するが、gzipのDEFLATEはLZWを置き換えるために設計された方式なので、有利さは期待しにくい
  • PNGはDEFLATEを使うが、ピクセル原本ではなく隣接ピクセルとの差分を圧縮する予測変換を先に適用する
    • 例: [a, b, c, d]の代わりに[a, b-a, c-b, d-c]を圧縮
    • 予測値と実値の差が小さいほどHuffman圧縮に有利
  • 実験の核心は、可逆WebPであるVP8Lを汎用バイトデータ圧縮に活用すること

VP8Lがgzipと異なる点

  • WebPには非可逆版と可逆版があり、ここでは可逆フォーマットのVP8Lのみを扱う
  • VP8LはPNGのように予測変換を使うが、DEFLATEの代わりにGoogle製のDEFLATE類似方式を使う
  • DEFLATEはファイルを複数チャンクに分け、それぞれに適したHuffman木を持たせられる
    • JavaScript、SVG、マークアップが1つのHTMLに混在する場合、異なる木のほうが有利なことがある
  • VP8Lは任意に大きなHuffman木テーブルを定義し、各16x16ピクセルブロックごとに異なる木を使える
    • JavaScriptの次にCSS、その後またJavaScriptが来る場合、DEFLATEでは似た木を何度もエンコードする可能性がある
    • VP8Lは木の再利用が可能で、より頻繁に、より低コストで木を切り替えられる
  • VP8Lのcolor cacheでは、特定の性質を持つ直近のピクセルをコピーせよ、という形で値を短く表現できる

最初のWebP圧縮実験

  • テストファイルはRecovering garbled Bitcoin addressesのHTML
    • 元サイズ: 439,478バイト
    • gzip --best: 94,683バイト
  • Rustのwebp crateでバイト列をgrayscale RGB画像に変換し、可逆WebPとして圧縮した
  • grayscaleを使った理由は、WebPのsubtract green変換のため
    • grayscaleではGチャンネルをR/Bから引くと、R/Bは実質0になる
    • WebPは3チャンネルを個別のHuffman木でエンコードするため、固定値チャンネルはO(1)空間に近くなる
  • 当初は1xN画像にしようとしたが、WebPは最大16383x16383までしかサポートせず、VP8_ENC_ERROR_BAD_DIMENSIONエラーが発生した
  • 16383xNの形に合わせると結果は45,604バイトとなり、gzipの2倍小さく、bzip2の49,764バイトよりも小さかった

WebP向けの調整

  • 幅広い画像でrow-major順を使うと、16x16ブロック内に入力上で離れたバイトが混ざってしまう
  • 画像形状を縦長の27x16383に変えると、圧縮結果は43,232バイトまで減った
  • cwebpの圧縮性能設定であるmethod0~6まで比較した
    • method 0: 48,902
    • method 1: 43,546
    • method 2: 43,442
    • method 3: 43,292
    • method 4: 43,232
    • method 5: 43,182
    • method 6: 43,182
  • method 5はmethod 6と同じサイズを出しつつ、より高速な選択肢として採用した
  • この時点でWebPはgzipより2.2倍小さく、Brotliより1.2倍大きい

複数ファイルでのベンチマーク

  • 比較対象はsnappy testdataCanterbury CorpusとLarge Corpus、SVGファイル2件だった
  • 比較フォーマットはgzip --bestbrotli --bestbzip2 --best、WebP圧縮スクリプト
  • WebPは、ごく小さいファイルgrammar.lspxargs.1と一部例外を除けば、ほぼ常にgzipより良かった
  • 例外はkennedy.xlspaper-100k.pdf
    • paper-100k.pdf19 KBのXMLの後ろに圧縮データがあり、実質的には小さなデータを測っている状況
    • kennedy.xlsはBrotli/bzip2との相対性能も不自然で、近接位置に異質なデータが多く、圧縮器が扱いにくいファイルなのかもしれない
  • WebPはbzip2よりやや劣る傾向だが、場合によっては上回る
  • WebPはfireworks.jpegのような、ほぼ均一なランダムblobに近い例外を除けば、常にBrotliより劣っていた
  • 大きなplain-textデータではgzipに対して測定可能な改善を示した
    • SVGファイルでも改善があった
    • html_x_4ではWebPが3.3%の圧縮率を記録し、Brotliの2.8%には及ばないがgzipの13%より大幅に良かった

JavaScriptで復元する

  • WebPのデコード自体はfetchcreateImageBitmapOffscreenCanvasgetImageDataTextDecoderで実装できる
  • ピクセルのRチャンネルを元のHTMLバイトとして使い、これをUTF-8でデコードしてdocument.documentElement.innerHTMLに入れる構成
  • Canvas APIはfingerprintingによく使われるため、一部ブラウザはgetImageDataの結果にノイズを加える
    • Firefoxのstrict tracking protectionでは1%未満のピクセルが影響を受ける可能性がある
    • HTMLではこのノイズが誤字のように現れる
  • WebGLのreadPixelsを使うと、当時はノイズなしで動作した
    • WebGLはテクスチャを安定して2048x2048までしかサポートしないため、再びサイズ制約を合わせる必要がある
    • この復元コードはminify後で約550バイトだった
  • WebPとコードを合わせて44 KiBとなり、gzip 92 KiB、Brotli 37 KiBと比較された
  • 2026年4月15日以降、FirefoxがreadPixelsにもanti-fingerprintingを追加したため、この方式はそのままではもはや動作しない

画面のちらつきとスクロールの問題

  • awaitはpromiseベースで処理されるため、WebPのダウンロードが終わる前にブラウザはスクリプト実行が終わったと判断する
  • DOMがまだ空なので、ユーザーは短時間真っ白な空画面を見ることになる
  • 緩和策として、スタイルとページ上部の約8 KiBはgzip HTMLに残し、ビューポート下のコンテンツだけをWebP圧縮できる
  • 再読み込み時のスクロール位置復元も問題になる
    • たとえばY = 5000pxの位置で再読み込みしたのにページ高さが0pxだと、位置が初期化される
    • 非常に大きい一時divを追加すると役に立つ
  • document.writeではなくdocument.documentElement.innerHTMLへ代入する必要があり、そうすることで現在の文書を新しい文書に置き換えず更新できる

WebPをJavaScriptに直接埋め込む

  • 遅延を少しでも減らすため、WebPをJavaScript内に直接埋め込める
  • 最も単純な方法はbase64 data URL
  • base64は元サイズを1.33倍に増やすが、gzipがこの増加分をほぼ相殺する
    • compressed.webpをbase64にすると57,576バイト
    • これをgzip --bestで圧縮すると43,519バイト
  • WebPのような圧縮済みblobはほぼ均一なランダムデータであり、base64の8ビット-to-6ビット変換結果に対してgzipのHuffman木がほぼ逆変換のように働く
  • UnicodeやUTF-16を使うこともできるが、base64は十分な第一解として残る

実際の適用とその後の状態

  • 記事執筆時点では、このページ自体が古いブラウザやJavaScript無効環境でない限り、「Fool me twice」セクション以降をWebPで圧縮していた
  • ページのWebP画像は実際のコードでは縦長で細いが、見やすい正方形WebPの例も提供されている
  • 画像の明るい上部と下部はテキストとコード、約1/5地点のハッチング領域は図、暗い領域の大半は図中のテキストだった
  • 実際の削減幅は限定的だった
    • 既存のgzipページ: 88 KiB
    • WebP適用後のgzipページ: 83 KiB
    • Brotli想定: 69 KiB
  • 2026年4月15日以降、Firefox訪問者に壊れたコンテンツが表示されないよう、現在は非圧縮ページに戻されている
  • Rustコード、コーパス、その他のファイルはGitHubで公開されている

1件のコメント

 
GN⁺ 2024-09-08
Hacker Newsの意見
  • レイテンシを無視すればそうかもしれないが、実際にはロード時間が 0.001% ほど増える程度に見える
    サイズの増加分はラウンドトリップの遅延に比べれば意味がなく、55KiB転送量を減らして節約できる時間より、展開にかかる時間のほうが大きい可能性もある
    面白い実験ではあるが、この場合ユーザー体験はむしろ悪くなる可能性が高く、速度はほぼ同じなのに互換性だけが下がりそう

    • ロード時間だけを最適化し、全員のデータ通信速度を仮定するならその通りだが、Webサイトやアプリの作者に、私の代わりに 速度とデータ量のトレードオフをあまり簡単に決めてほしくないことが多い
      問題は、TFAの筆者のように100KBを50KBに減らそうと努力する人もいる一方で、ローミングデータでレストランの営業時間だけを見たい私に、画像を何十MBも平然と送りつけるところもあるということ
      リソースへの意識は存在するが、残念ながら非常に偏って広がっている
    • 展開時間だけの問題ではない。全体をダウンロードしてからでないと展開できないが、ブラウザはサーバーからストリーミングされる HTMLを即座に展開してレンダリングできる
      接続が切れればすべて失い、ダウンロード済みの部分だけでも読むことも不可能になる
      通常の接続では差は意味を持たず、50KBが重要になるほど非常に遅いか不安定な接続では、この方式は確実に悪くなる。面白い実験だが、サイトには適用しないでほしい
    • キャッシュにないなら 850Kの Symbols-2048-em%20Nerd%20Font%20Complete.woff2 ファイルがあるので、その差をほぼ帳消しにしてしまう
    • その程度のサイズ差は、必要なラウンドトリップ回数に影響するほど大きい。妥当な現代的な 初期輻輳ウィンドウ の値なら、おおよそ1ラウンドトリップは減るはず
      2.5倍の差ではないだろうが、0.001%でもない
    • TCP受信ウィンドウ1つ分より少ない節約なら、レイテンシには差が出ないはず
      損失のあるネットワークでは差が出るかもしれないが、確信は持てない
  • readPixels がなぜフィンガープリント対策の対象ではないのか分からない。ほとんど見えないタイポをページ全体に撒き散らすわけではないので、私には問題ない
    gzipされたHTMLにはスタイルとページ上部の約8KiBだけを置き、ビューポート下のコンテンツだけをWebPで圧縮するという部分を見て、記事が任意の文の途中で突然切れて空白ページが続いた理由が分かった
    LibreWolf を使っているのでWebGLがオフになっており、WebGLが必要なランダムなWebゲームはChromiumで使っている。WebGLをオンにすると記事はちゃんと動作し、正直かなりすっきりした手法だと思う

    • すべてのモダンWebブラウザで、しかもフィンガープリント保護が有効な状態でも動作せず、古いブラウザ向けのフォールバックもないなら、すっきりしているとは言いにくい
      WWWは 普通のHTML から始めて段階的に強化する形で、普遍的にアクセス可能であるべき
  • Webブラウザで Brotliを直接使うこと も可能ではあるが、当然制約がある
    2022年のJS1024応募作 [1] がこの概念の最初のデモだと思っており、任意圧縮用の概念実証コードもある。残念ながら、本来の目的だったサイズコーディングには合わなかった
    主要な制約は、事実上ASCII文字に限られることと、明白な理由でレンダリングスタックに非常に敏感なこと。今はFirefoxでは動作しないように見える

    [1] https://js1024.fun/demos/2022/18/readme

    [2] https://gist.github.com/lifthrasiir/1c7f9c5a421ad39c1af19a9c...

    • このアプローチを理解するうえで重要なのは、実際にどう詰め込んだのかまで掘り下げなくてもよい次の部分
      Brotliがもともと設計された WOFF2フォントファイル形式 を使うことしかできないが、これを活用するにはフォントファイル全体を作る必要がある
      最近のブラウザは、信頼できないフォントファイルをシステムに直接入れるのは非常に危険なので、通常はOpenType Sanitizer(OTS)でフォントをサニタイズする。そのため、OTSが受け入れる程度には正常でありながら、望むバイト列を内部に含み、取り出せるWOFF2ファイルを作らなければならない
      多くの失敗の末、ほぼ制約なく2バイト符号付き整数列としてエンコードされるグリフ幅、つまりadvanceに落ち着いたという発想が見事
    • 訂正すると、Firefoxでもまだ動作する。ただ、Firefoxでは 拡大/縮小率が正確に100% でなければならないことを忘れていた
    • この手法は本当に驚くべきもので、私の記事よりずっと素晴らしい。称賛したい
  • Chromium側が長らく阻んでいたが、zstd も今やWebに入ってきている。ついにChromeに入ったので、あとはSafariが追随すればよい

    • 全部 Zstandard に行きたいが、この特定のケースでは、私の知る限り、展開器のメモリ使用量が同じ場合、BrotliとZstandardはほぼ同程度
    • 少なくともToDoリストには載っているようだ: https://webkit.org/standards-positions/#position-168
  • Batch Compress(https://batchcompress.com/en)を作っていて、最近 WebP 対応を追加してから間もなくデフォルトに変更した
    私の知る限り、すでにウェブ圧縮ツールの中で最小級の JPEG を作れていたが、WebP は JPEG の約 50% のサイズにしかならなかった。対応追加後すぐデフォルトに変えるのは簡単な判断だった
    サイトの利用者はかなり多いので、WebP をデフォルトに変えた後に多少不満が出ると思っていたが、1か月ほど経った今、WebP 関連の問い合わせや不満は1件だけだった
    今ではほぼすべてのツールとブラウザが WebP をサポートしているようだ。最近、WebP 画像のアップロードを正しく処理できず次のステップに進めないウェブサイトを1つ見ただけで、最近はほとんど問題なくサポートされている

    • WebP が JPEG に比べて 15〜20%以上ファイルサイズを削減するなら、その削減分は圧縮の改善ではなく品質低下によるものだ
      JPEG をうまく圧縮・最適化すれば、WebP に大きく劣るはずはない
      JPEG よりほぼ同じに見える WebP を作って、いつでもファイルサイズを減らすことはできるが、ほぼ同じに見える JPEG として再圧縮しても同じことだ
      これはすべての非可逆圧縮コーデックの特性で、品質が上がるほどファイルサイズは指数関数的に大きくなるため、人々はほとんど見えないごく小さな品質低下だけでファイルサイズが大きく変わることにいつも驚く
    • WebP が JPEG の約 50% のサイズというのは、どの 品質比較指標に基づくのか気になる
      WebP は暗部のディテールを壊すひどいデフォルト設定で悪名高かった
  • ソースを見ていて、doctype 宣言に空白が抜けているのに気づいた。現在の形は誤りで、空白が入るべきだ

  • このトリックを以前使ったことがある。不思議なことに何に使ったのかは覚えていないが、おそらく可能かどうかを見るためだったようで、ここにもコメントを残していた: https://gist.github.com/gasman/2560551?permalink_comment_id=...
    かなり昔のプロトタイプも見つけたが、単なるテストだったようだ: https://retr0.id/stuff/bee_movie.webp.html

    • そのページは私の マウスジェスチャー拡張機能を壊す
      今ではアドオンではなく拡張機能と呼ばれるスクリプト注入のようなものだが、いずれにせよ、最初に「ゴミ」を渡して後ろに JS を付け、ページに戻すというアプローチは興味深い
      私の中のセキュリティオタクとしては、コメントフォームのようなユーザー提供データがあると攻撃が可能になるのか気になる
      誰かがコメントに入れるバイト列を見つけ、圧縮後に私のスクリプトより前に位置して実行される script タグに変わるようにできるのではないかと思う
    • 私の経験では、WebP はこの手法が実際に役立つ一般的なケース、つまり 10KB 未満のデータにはあまり向いていなかった
      WebP ロスレスが PNG に追加しているものの大半はエンコーディングではなくモデリングに関するものであり、この種のテキスト圧縮では WebP のエンコーディング部分だけを使うことになるからだ
  • Google Fonts を外しても、ページ読み込み時間は少し改善するはずだ。リモートサーバーから読み込まれ、追加のハンドシェイクが必要になるためだ

    • ただし、十分に多くの他サイトもそのフォントを使っているなら、すでにローカルにあるかもしれない
  • このページは少なくとも Sailfish OS ブラウザでは壊れる。次の段落の後に長い空白がある
    “Alright, so we’re dealing with 92 KiB for gzip vs 37 + 71 KiB for Brotli. Umm…”
    とはいえ、gzip と Brotli による HTML 圧縮のオーバーヘッドは、最近のウェブサイトが使う JS、画像、動画の量に比べれば何でもない

    • Orion、Safari、LibreWolf でも同じ。これは Chrome 専用ページなのか?
    • Mull でも同じ
  • 個人的にはこの形式があまり好きではない。画像を保存したら WebP として保存されると、ウェブブラウザ以外では対応しているところがなく、編集したり意味のある形で使ったりする前に変換しなければならない
    単に余計な手順を強制されているように感じる

    • 皮肉なことに、Google 製品である Slides でさえ WebP 画像をサポートしていない
      それでも対応がさらに増えるなら悪くないと思う。20年に1回くらい新しい形式が出る程度なら我慢できる
      .webm は消えてもいい
    • 変換は2秒で済む。macOS では文字どおり右クリックメニューにあり、サイズも小さくなるので、特に問題ではない