[zenpix] そのAVIF変換、サーバーを重くしていませんか?

に公開
この記事に出てくる用語

画像フォーマットの比較

フォーマット 圧縮方式 透過 特徴
JPEG 非可逆 写真向け。1992年から使われる枯れた規格
PNG 可逆 ファイルサイズが大きい。透過が必要なグラフィック向け
WebP 非可逆/可逆 Google製。JPEGより約30%小さく、PNGの代替にもなる
AVIF 非可逆/可逆 AV1動画コーデック技術ベース。WebPよりさらに高圧縮・高画質

AVIF(AV1 Image File Format)のブラウザ対応

ブラウザ 対応バージョン
Chrome 85+(2020年)
Firefox 93+(2021年)
Safari 16+(2022年、iOS 16+ / macOS Ventura+)
Edge 121+(2024年)

2026年時点で主要ブラウザの現行バージョンはすべて対応済み。ただし古いiOS(15以前)やSafari 15以前は非対応のため、対象ユーザーが古いデバイスを使う場合はフォールバックを用意するのが安全。

zenpixはWebPにも対応しているため、AVIF・WebPの両方をサーバー側で生成できる。

import { decode, resize, encodeAvif, encodeWebp } from "zenpix";
import { readFileSync } from "fs";

const img = decode(readFileSync("input.png"));
const resized = resize(img, { width: 960, height: 540 });

const avif = encodeAvif(resized, { quality: 60, speed: 6 }); // メイン
const webp = encodeWebp(resized, { quality: 80 });           // フォールバック

HTMLでは <picture> 要素でブラウザに選ばせる。

<picture>
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="...">
</picture>

AVIFのトレードオフ

AVIF WebP JPEG
ファイルサイズ(同画質) 最小
エンコード速度 遅い(JPEGの数十倍のCPU時間) 速い
デコード速度(ブラウザ) やや遅い 速い 速い

エンコードが遅いことがAVIFの最大のデメリット。低スペックなサーバーでAVIF変換リクエストが重なるとCPUを食い潰す——これがこの記事のテーマだ。

FFI(Foreign Function Interface)
異なる言語間でコードを呼び出す仕組み。この記事では「TypeScriptからCのネイティブライブラリ関数を直接呼び出す」ことを指す。Node.js/BunはFFI経由でOSのネイティブバイナリ(.dylib/.so/.dll)を呼べる。

libavif / libaom
libavifはAVIF形式の読み書きを担うC製ライブラリ、libaomはAV1コーデックの実際のエンコード・デコードを担うC製ライブラリ。libaviflibaomを内部で呼び出す構成になっている。

libvips
画像処理C製ライブラリ。Sharpが内部で使っており、ストリーミング処理とマルチスレッド並列化を自動で行う。1変換で複数スレッドを立ち上げるため、VPSのようなコア数が少ない環境ではCPU使用率が高くなりやすい。

SIMD(Single Instruction, Multiple Data)
1命令で複数のデータを同時処理するCPU命令セット。ARMではNEON、x86ではSSE2/AVXがこれに相当する。画像処理の内積・色変換・フィルタ計算など「同じ演算を大量のピクセルに繰り返す」処理で大幅な速度向上が得られる。

ICCプロファイル
画像に埋め込まれた色域の定義データ。sRGB・Display P3など、どの色空間で撮影・編集されたかを記録する。変換時にプロファイルを引き継がないと、色味が変わって見える場合がある。

はじめに

自分のポートフォリオサイト tsukasa-art.com では、アップロードした画像をAVIFに変換して配信しています。変換には Sharp を使っていたのですが、低スペックなVPS(2vCPU / 2GB RAM)で複数の変換リクエストが重なると、サーバーが重くなる問題に気づきました。

「Sharpが遅い」わけではありません。でも低スペックVPSでは詰まる。この記事ではその理由を掘り下げ、C製の画像処理ライブラリ zenpix を自作して解決した話を書きます。

Sharpで「詰まる」とはどういうことか

wall-clockとCPU user時間の違い

処理時間の指標には2種類あります。

  • wall-clock:処理の開始から終了まで、時計で測った時間
  • CPU user時間:その処理がCPUを実際に消費した時間

Sharpは内部でlibvipsを使い、マルチスレッドで処理を並列化します。1回の変換のwall-clockが短くても、複数のスレッドが同時にCPUを使うため、CPU user時間はwall-clockを大きく上回ります。

2vCPUのVPSで何が起きているか

2vCPUのVPSで動かせるスレッドは同時に2本だけです。Sharpが1変換で複数スレッドを使い倒すと、他のリクエストはCPUが空くまで待たされます。

今回の計測(Ubuntu / 2vCPU / 2GB RAM)では、キャラ絵1枚のFHD変換でSharpは約1.8秒のCPU時間を消費します(詳細は後述)。4リクエストが同時に来ると合計で約7.4秒のCPU時間が必要になり、2vCPUで捌くには実時間で約3.7秒かかります。

この間、他のリクエストはCPUを待ち続けます。画像変換以外のAPIリクエストも巻き込まれる。これが「詰まる」の正体です。

zenpixを作った

既存の代替ライブラリより自作を選んだのには理由があります。

なぜCか

画像処理の実績があるCライブラリ(libavif、libaom、libpng、libjpeg-turbo、libwebp)をそのまま活かしたかったためです。これらはすべてC製なので、C で書けば API を直接叩けます。

CMake + vcpkg で依存関係を管理し、クロスコンパイルも素直に書けます。SIMD(NEON/SSE2)を直接記述できる点も C を選んだ理由のひとつです。

// avif_encode.c:libavif を直接呼び出す例
avifImage *image = avifImageCreate(width, height, 8, AVIF_PIXEL_FORMAT_YUV444);
avifEncoder *encoder = avifEncoderCreate();
encoder->quality = quality;
encoder->maxThreads = threads;
avifEncoderWrite(encoder, image, &output);

アーキテクチャ

C (src/) → libpict.{dylib,so,dll} → Node.js / Bun / Deno(FFI)
         → CLI(npx zenpix)

TypeScript 層から FFI 経由で C のネイティブライブラリを呼び出す構成です。メモリ管理は C 側で完結させ、JS 側にはポインタのみ渡します。

C + TypeScript の2層構成がこのライブラリの特徴です。実績ある C ライブラリを直接束ねてネイティブバイナリに仕上げ、TypeScript から型安全に呼び出せます。

デフォルトはシングルスレッド

Sharpが1変換で複数スレッドを使うのに対し、zenpixはデフォルト threads=1(シングルスレッド)で動きます。1変換あたりのCPU消費が1コア分に収まるため、低スペックVPSで並列リクエストが来てもCPUの取り合いが起きません。これが前述のCPU user時間の差の正体です。

Sharpはlibvipsがスレッド数を自動で決めるため、呼び出し側から制御できません。zenpixは呼び出しごとに threads を指定でき、サーバー・バッチ・その中間と、用途に合わせてスレッド数を明示的に制御できるのが特徴です:

// サーバー(デフォルト、threads=1)
const avif = encodeAvif(image, { quality: 60, speed: 6 });
import os from "os";

// バッチ処理(コア数フルで速度優先)
const avif = encodeAvif(image, { quality: 60, speed: 6, threads: os.cpus().length });
import os from "os";

// 中間(他の処理にCPUを残しつつバッチ処理したい場合)
const avif = encodeAvif(image, { quality: 60, speed: 6, threads: Math.floor(os.cpus().length / 2) });

画質:YUV 4:4:4 で色情報を完全保持

zenpix の AVIF エンコードは YUV 4:4:4(クロマサブサンプリングなし)を使用しています。Sharp のデフォルトは YUV 4:2:0 で、色差成分を水平・垂直それぞれ 1/2 に間引くため、色情報の 75% が失われます。

同じ quality=60 でも、彩度の高い色やパステルのグラデーションが多いイラストでは出力の見た目に差が出ます。アルファチャンネルは常にロスレスエンコードです。

この記事のメインテーマはサーバー負荷の話ですが、「品質も落とさずに速くなる」という点は強調しておきたいポイントです。

カラープロファイルを保持したまま変換

decode() 時にJPEG / PNG / WebPの埋め込みICCプロファイルを抽出し、resize() を経由してエンコード時にAVIF・WebP・PNGチャンクとして再埋め込みします。色域が変換前後で正確に保たれるため、イラストや写真の色味がずれません。

npm install一発で動く

npm install zenpix
# または
bun add zenpix

インストール時にOS/アーキテクチャに対応した libpict バイナリ(optional dependency)が自動で入ります。libavifやlibaomを別途インストールする必要はありません(静的リンク済み)。

対応環境:Node.js / Bun / Deno、macOS(arm64/x64)、Linux x64、Windows x64
ブラウザ・エッジ環境向けには zenpix-wasm も提供しています。

計測方法

環境

  • VPS:Ubuntu / 2vCPU / 2GB RAM
  • AVIF設定:quality=60 / speed=6

テスト画像

自作イラスト6種類を使用しました。キャラ絵2枚・風景画3枚・合成ノイズ画像1枚です。

この記事では代表として キャラ絵(FHDシナリオ:1920×1080 → 960×540) の結果を中心に示します。画像の種類によって結果が変わるため、後述のnoteで補足します。

ベンチマーク設計

Sequential bench(従来の計測)
1変換ずつ順番に実行。warmup 2回 + 計測10回の中央値。VPS上で3回走らせてその中央値を採用。

Concurrent bench(今回新規)
N個の変換プロセスを同時起動し、全プロセスが終わるまでのwall-clock時間を計測。各Nにつき3回走らせて中央値を採用。CPU user時間はN個のプロセスの合計値。

計測結果

Sequential bench(1変換ずつ)

VPS実測・3run中央値。キャラ絵でのwall-clock比較:

シナリオ zenpix Sharp ratio
FHD(960×540出力) 1,307ms 1,770ms 1.35×
WQHD(1280×720出力) 2,158ms 2,717ms 1.26×
4K(1920×1080出力) 4,366ms 5,174ms 1.19×

Concurrent bench(N並列)

VPS実測・3run中央値。キャラ絵FHDシナリオ:

N zenpix wall Sharp wall wall ratio zenpix CPU合計 Sharp CPU合計 CPU ratio
1 1,418ms 1,912ms 1.35× 1,343ms 1,849ms 1.38×
2 1,435ms 1,918ms 1.34× 2,689ms 3,659ms 1.36×
4 2,975ms 3,983ms 1.34× 5,517ms 7,605ms 1.38×
8 5,933ms 8,197ms 1.38× 11,068ms 15,679ms 1.42×

wall ratio / CPU ratio = Sharp ÷ zenpix(>1でzenpixが有利)

数字の読み方

N=1 ≈ N=2 のwall-clock(1,418ms vs 1,435ms):2vCPUで2ジョブが真に並列実行されている証拠です。N=4でほぼ2倍、N=8でほぼ4倍と、2vCPUの上限通りにスケールしています。

ratioがN=1〜8で1.34〜1.38×で安定:並列数が増えてもzenpixの優位性が崩れません。スループット改善がリクエスト数に比例して効いています。

N=8のCPU合計:Sharpは15,679ms、zenpixは11,068ms。差分の4,611msが「他のリクエストに使えたはずのCPU時間」です。2vCPUのVPSでは約2秒分の余裕に相当します。

使い方

import { decode, resize, encodeAvif } from "zenpix";
import { readFileSync } from "fs";

const input = readFileSync("input.png");
const img = decode(input);
const small = resize(img, { width: 960, height: 540 });
const avif = encodeAvif(small, { quality: 60, speed: 6 });
// avif は Uint8Array

AVIFだけでなく、WebP・PNGも同じAPIで扱えます:

import { decode, resize, encodeWebp } from "zenpix";

const img = decode(readFileSync("input.png"));
const small = resize(img, { width: 960, height: 540 });
const webp = encodeWebp(small, { quality: 80 });

convert() を使えばワンライナーで書けます:

import { convert } from "zenpix";

const result = convert(readFileSync("input.jpg"), {
  resize: { width: 960, height: 540, fit: "cover" },
  encode: { format: "avif", quality: 60 }, // "webp" / "png" も指定可
});

CLIも使えます:

npx zenpix input.jpg output.avif --quality 60
npx zenpix *.jpg --out-dir ./avif/   # バッチ変換

Denoの場合(--allow-ffi フラグが必要です):

import { decode, resize, encodeAvif } from "npm:zenpix/deno";

ブラウザで試したい方は、ベンチマークページで自分の画像を使って変換を体験できます(zenpix-wasm による変換と、ブラウザ Canvas API との比較も確認できます)。

まとめ

  • Sharpが「遅い」のではなく、CPU user時間が多いことが低スペックVPSのボトルネックになる
  • zenpixはキャラ絵・風景画でwall-clockも約35%速くCPU消費は約27〜29%少ない
  • 並列リクエストが増えても比率が安定しており、スループット改善がスケールする
  • デフォルトシングルスレッドでCPU消費を抑制、threads オプションでバッチ処理にも対応
  • AVIF エンコードに YUV 4:4:4 を採用。Sharp(YUV 4:2:0)より色情報を完全保持
  • ICCプロファイルをデコード→リサイズ→エンコードを通じて保持
  • Node.js / Bun / Deno 対応、npm install一発で動く

リポジトリ:https://github.com/tsukasa-art/zenpix
npm(サーバー):https://www.npmjs.com/package/zenpix
npm(WASM):https://www.npmjs.com/package/zenpix-wasm

GitHubで編集を提案

Discussion