Go to list of users who liked
Share on X(Twitter)
Share on Facebook
More than 3 years have passed since last update.
WebAssembly で画像のリサイズ処理をやってみたら JavaScript + Canvas API より遅かった話
WebAssembly(WASM) は JavaScript より計算処理が速いという話題がしばしば聞かれます。
では、単純な数値計算ではないけれど、JavaScript を利用した通常のフロントエンド開発だと時間がかかる処理を移植するとどうなるのでしょうか1。本記事ではその一例として、巨大画像(4K~)を指定したピクセル数までリサイズする処理を書いてみました。
リポジトリ:https://github.com/yokra9/wasm-image-resizer
JavaScript + Canvas API でリサイズしてみる
まず、比較のため JavaScript でリサイズ処理を書いておきましょう。とはいえ、TypeScript からコンパイルして生成します。
consturl="./img/sample.jpg";constresp=awaitfetch(url);constb=awaitresp.blob();// JavaScript でリサイズconstblob=awaitresizeImageLegacy(b,512,512);// 画面上に処理結果を表示するdescribeImageFromBlob(blob,"sample");/** * resize image(JS-native) * @param {Blob} file image * @param {number} width width * @param {number} height height * @returns {Promise<Blob>} image */functionresizeImageLegacy(file:Blob,width:number,height:number):Promise<Blob>{returnnewPromise((resolve,reject)=>{constimage=newImage();console.log(`Original:${file.size} Bytes`);constobjectURL=URL.createObjectURL(file);image.onload=()=>{constcanvas=document.createElement('canvas');canvas.width=width;canvas.height=height;constctx=canvas.getContext('2d');if(ctx==null){reject('cannot get context.');return;}ctx.drawImage(image,0,0,image.naturalWidth,image.naturalHeight,0,0,canvas.width,canvas.height);canvas.toBlob((blob)=>{if(blob==null){reject('cannot convert canvas to blob.');return;}console.log(`Resized:${blob.size} Bytes`);resolve(blob);},"image/jpeg",0.8);};image.src=objectURL;});}流れとしては以下の通りです:
- リサイズ後の大きさで Canvas を作成する
BlobをURL.createObjectURL()で ObjectURL に変換する- 作成した ObjectURL を image にセットする
- Canvas に画像を描画する
HTMLCanvasElement.toBlob()でBlobにして返却する
Canvas API を利用しているので、画像のデコード・エンコード処理やリサイズ処理はブラウザの実装任せで、JavaScript として書いているわけではありません2
実行結果(JS)
| 試行 | サンプル1 (17446953 Bytes) | サンプル2 (14725447 Bytes) | サンプル3 ( 6985698 Bytes) |
|---|---|---|---|
| 1 | 467.768 | 425.427 | 267.401 |
| 2 | 475.222 | 430.402 | 267.401 |
| 3 | 484.113 | 424.918 | 243.935 |
| 4 | 484.113 | 424.918 | 243.935 |
| 5 | 484.113 | 438.421 | 248.328 |
| 平均(ms) | 479.066 | 428.817 | 254.200 |
16MB の画像を処理するのに 0.5 秒かかっています。メインスレッドがブロックされる秒数として微妙なところですね。console.time() を仕掛けてボトルネックになっている箇所を調べてみます:
URL.createObjectURL(): 0.117919921875 msload image from ObjectURL: 29.1630859375 msDocument.createElement(): 0.05419921875 msHTMLCanvasElement.getContext(): 0.070068359375 msCanvasRenderingContext2D.drawImage(): 222.634033203125 msHTMLCanvasElement.toBlob(): 212.616943359375 ms##### resizeImageLegacy #####: 465.1611328125 msCanvas に対する操作に要する時間が大きいようです。ところで、この計測は Microsoft Edge 上で実施しており、Canvas に対するハードウェアアクセラレーションが有効になっています。
この後作成する WASM 版のコードだとハードウェアアクセラレーションが働かないので、ハードウェアアクセラレーションを切って測定してみます。
Canvas がボトルネックになっているのであれば明確に遅くなっていそうなものですが、実際はどうでしょうか:
| 試行 | サンプル1 (17446953 Bytes) | サンプル2 (14725447 Bytes) | サンプル3 ( 6985698 Bytes) |
|---|---|---|---|
| 1 | 367.937 | 331.860 | 189.027 |
| 2 | 371.219 | 347.422 | 186.721 |
| 3 | 375.982 | 351.710 | 185.914 |
| 4 | 389.980 | 356.300 | 187.158 |
| 5 | 379.367 | 348.955 | 175.298 |
| 平均(ms) | 376.897 | 347.249 | 184.824 |
URL.createObjectURL(): 0.11572265625 msload image from ObjectURL: 29.06494140625 msDocument.createElement(): 0.072998046875 msHTMLCanvasElement.getContext(): 0.137939453125 msCanvasRenderingContext2D.drawImage(): 161.45703125 msHTMLCanvasElement.toBlob(): 181.51513671875 ms##### resizeImageLegacy #####: 379.366943359375 ms…なぜか速くなってしまいましたが、やはり Canvas に対する操作に要する時間が大きいようです。メインスレッドのブロッキングを防ぐために WebWorker で別スレッドで動かそうにも、OffscreenCanvas は Chromium 系でしか動作しません。果たして WASM を利用すれば、さらなるパフォーマンスを引き出すことはできるのでしょうか?(タイトルでオチていますが…)
WASM(Rust) でリサイズしてみる
流れとしては以下の通りです3:
BlobをUint8Arrayに変換するUint8Arrayとリサイズ後の大きさを WASM に渡すUint8Arrayをバッファ(Vec<u8>)にコピーするimage::load_from_memory()で画像を読み込むimage::resize_exact()でリサイズするimage::write_to()で結果をバッファ(Vec<u8>)に書き込むVec<u8>をUint8Arrayに変換して JS に渡すUint8ArrayをBlobに変換する
Rust の世界でBlob を扱うのが若干面倒だったのでUint8Array 経由で画像を受け渡ししています。Rust 側では有名なimage クレートを利用して画像の読み込み・リサイズを行っています:
externcrateconsole_error_panic_hook;externcratewasm_bindgen;useimage::*;usejs_sys::*;usewasm_bindgen::prelude::*;# [wasm_bindgen]extern"C"{#[wasm_bindgen(js_namespace=console)]pubfntime(s:&str);#[wasm_bindgen(js_namespace=console)]pubfntimeEnd(s:&str);}# [wasm_bindgen]pubfnresize_image(arr:Uint8Array,width:usize,height:usize,fmt:&str)->Uint8Array{console_error_panic_hook::set_once();// Uint8Array から Vec にコピーするletbuffer=arr.to_vec();// バッファから画像を読み込むletimg=load_from_memory(&buffer).expect("Error occurs at load image from buffer.");// 指定サイズに画像をリサイズするletresized=img.resize_exact(widthasu32,heightasu32,imageops::FilterType::Triangle);// バッファに画像を書き出すletresult=save_to_buffer(resized,fmt);// バッファから Uint8Array を作成Uint8Array::new(&unsafe{Uint8Array::view(&result)}.into())}// バッファに画像を書き出すfnsave_to_buffer(img:DynamicImage,fmt_str:&str)->Vec<u8>{console_error_panic_hook::set_once();letfmt=matchfmt_str{"png"=>ImageOutputFormat::Png,"gif"=>ImageOutputFormat::Gif,"bmp"=>ImageOutputFormat::Bmp,"jpg"=>ImageOutputFormat::Jpeg(80),unsupport=>ImageOutputFormat::Unsupported(String::from(unsupport)),};// バッファを確保して画像を書き出すletmutresult:Vec<u8>=Vec::new();img.write_to(&mutresult,fmt).expect("Error occurs at save image from buffer.");result}呼び出し側のコードは以下のような形です:
importtype*asWASMfrom"wasm-image-resizer"typeWasm=typeofWASM;// WASM の Shim を動的インポートするconstjs=import("wasm-image-resizer");js.then(asyncwasm=>{consturl="./img/sample.jpg";constresp=awaitfetch(url);constb=awaitresp.blob();// WASMでリサイズconstblob=awaitresizeImageWasm(b,512,512,"jpg",wasm);// 画面上に処理結果を表示するdescribeImageFromBlob(blob,"sample");});/** * resize image(WASM) * @param {Blob} file image * @param {number} width width * @param {number} height height * @param {string} format format * @param {Wasm} wasm WASM * @returns {Promise<Blob>} image */asyncfunctionresizeImageWasm(file:Blob,width:number,height:number,format:string,wasm:Wasm):Promise<Blob>{console.log(`Original:${file.size} Bytes`);constarr=newUint8Array(awaitfile.arrayBuffer());constresult=wasm.resize_image(arr,width,height,format);constblob=newBlob([result]);console.log(`Resized:${blob.size} Bytes`);returnblob}実行結果(WASM(Rust))
| 試行 | サンプル1 (17446953 Bytes) | サンプル2 (14725447 Bytes) | サンプル3 ( 6985698 Bytes) |
|---|---|---|---|
| 1 | 3906.262 | 4072.797 | 2876.642 |
| 2 | 3903.539 | 4072.797 | 2854.539 |
| 3 | 3992.005 | 4092.231 | 2918.119 |
| 4 | 3917.787 | 4073.364 | 2865.451 |
| 5 | 3917.787 | 4077.866 | 2877.314 |
| 平均(ms) | 3927.476 | 4077.811 | 2878.413 |
JS の 10 倍遅いです4。こ、こんなはずでは…。今回もconsole.time() を仕掛けてボトルネックになっている箇所を調べてみます:
Blob to Uint8Array: 9.274169921875 msUint8Array to Vec<u8>: 33.488037109375 msimage::load_from_memory(): 3275.39013671875 msimage::resize_exact(): 685.47216796875 mssave_to_buffer: 39.794921875 msVec<u8> to Uint8Array: 0.174072265625 msUint8Array to Blob: 0.496826171875 ms##### WebAssembly #####: 4045.272705078125 msメイン処理となるimage::resize_exact() だけでも JS より遅いので論外ですが、ボトルネックはimage::load_from_memory() だったようです。5
今回のように WASM(Rust) の世界で速度が出ない事象は、web_sys クレート経由でブラウザ機能を呼び出したり6、呼び出し側やインライン7の JS 関数を呼び出すことで回避できます。
WASM(OpenCV) でリサイズしてみる
他の著名な画像操作ライブラリとしてOpenCV がありますが、現状OpenCV クレートは WASM をターゲットとしたビルドができません。8
そこで、OpenCV.js を試すことにしました。OpenCV.js は OpenCV のサブセットを WASM をターゲットとしてビルドした公式プロジェクトです。こちらも、TypeScript から呼び出してみます。9
typeWindow={cv:any,}declarevarwindow:Window;consturl="./img/sample.jpg";constresp=awaitfetch(url);constb=awaitresp.blob();// OpenCV.js でリサイズconstblob=awaitresizeImageCV(b,512,512,window.cv);// 画面上に処理結果を表示するdescribeImageFromBlob(blob,"sample");/** * resize image(OpenCV) * @param {Blob} file image * @param {number} width width * @param {number} height height * @param {any} cv OpenCV.js * @returns {Promise<Blob>} image */functionresizeImageCV(file:Blob,width:number,height:number,cv:any):Promise<Blob>{returnnewPromise((resolve,reject)=>{constimage=newImage();console.log(`Original:${file.size} Bytes`);constobjectURL=URL.createObjectURL(file);image.onload=()=>{constcanvas=document.createElement('canvas');canvas.width=width;canvas.height=height;letsrc=cv.imread(image);letdst=newcv.Mat();letdsize=newcv.Size(width,height);cv.resize(src,dst,dsize,0,0,cv.INTER_LINEAR_EXACT);cv.imshow(canvas,dst);src.delete();dst.delete();canvas.toBlob((blob)=>{if(blob==null){reject('cannot convert canvas to blob.');return;}resolve(blob);},"image/jpeg",0.8);};image.src=objectURL;});}流れは以下の通りです:
- リサイズ後の大きさで Canvas を作成する
BlobをURL.createObjectURL()で ObjectURL に変換する- 作成した ObjectURL を image にセットする
- image 内の画像をバッファに読み込む
- OpenCV(WASM)でリサイズしてバッファに書き込む
- Canvas にバッファ内の画像を表示する
HTMLCanvasElement.toBlob()でBlobにして返却する
Canvas API を利用してはいるものの、リサイズ処理は WASM で行っています。
実行結果(WASM(OpenCV.js))
| 試行 | サンプル1 (17446953 Bytes) | サンプル2 (14725447 Bytes) | サンプル3 ( 6985698 Bytes) |
|---|---|---|---|
| 1 | 701.307 | 692.949 | 478.998 |
| 2 | 638.878 | 614.146 | 385.925 |
| 3 | 715.814 | 683.853 | 416.562 |
| 4 | 724.004 | 645.536 | 415.177 |
| 5 | 713.559 | 623.795 | 418.676 |
| 平均(ms) | 698.712 | 652.056 | 423.068 |
Canvas API 程ではないですが、実用的な速度になりました。ボトルネックになっている箇所を調べてみます:
URL.createObjectURL(): 0.238037109375 msload image from ObjectURL: 22.555908203125 msDocument.createElement(): 0.084716796875 mscreate dense array: 467.43798828125 mscv::resize(): 12.4599609375 mscv::imshow(): 14.69384765625 msdelete dense array: 0.0068359375 msHTMLCanvasElement.toBlob(): 189.468017578125 ms##### OpenCV.js #####: 707.7109375 msリサイズ処理部分と Canvas への描画を合わせても 27 ms と、Canvas API による描画より速度が出ています。一方でバッファ関連がボトルネックになっており、image クレートと共通する問題と言えます。
この結果を見ると、Rust のimage クレートと WASM の相性が特別悪い可能性も考えられますね。Rust 1.54 からは WASM で SIMD が使えるようになる10ので、image クレートの処理が高速化することに期待しています。11
まとめ
重い処理を WASM に持っていけばなんでも早くなるわけではない、という結果になりました。当然ではありますが、問題の箇所が本当に高速化できるのか、コードを書いて実測するべきですね。
残念ながら Canvas API よりも速度は出ませんでしたが、 WASM は WebWorker 上で動作できるので、メインスレッドをブロックしたくない場合には有用でしょう。
また、WASM 側で利用するライブラリの選定やコードの修正により JavaScript より高速になる可能性もあるはずです。本稿を読んだ方で、WASM 版の処理をもっと速くできるよ! という方はコメント欄などで教えてくださると幸いです。
(2021-09-06追記)Rustのimage::load_from_memory遅すぎ問題という記事で、本稿の内容を検証していただいております。「一ヶ月ほど調査、試行錯誤して」いただいたということで、本当にありがたい限りです。image::load_from_memory() の仕様とphoton を参考に再実装を行ったところ、実用的な速度が出る範囲まで高速化できたということです。ここまでお読みになられた方は、ぜひ上記の記事にも目を通していただければと思います。
参考リンク
- Can't get image::load_from_memory() to work when compiled to WebAssembly
- WebAssemblyをちょろっと触って速度測ってみる。
- WebAssemblyとJavaScriptで浮動小数点演算の速度を比較する
- WebAssemblyは本当に速いのか? [数値計算編]
- OffscreenCanvas
- web-sys: canvas hello world - The
wasm-bindgenGuide - Geometric Transformations of Images
ブラウザ上で利用可能な各種Web_API(CanvasAPI等)は利用するものとします。従って、JavaScriptの計算処理によって画像のデコード・エンコード処理やリサイズ処理を行うという意味ではありません。↩
ちなみにCanvasのSpecはECMAScriptではなくHTML_Living_Standardにあります。↩
他にも
WebAssembly.Memory()でWASMインスタンスのメモリを掴み生ポインタを触る方法もあります。試してみましたが、複雑な割に速度は大きく変わりませんでしたので紹介しません。↩wasm-pack build --releaseでリリースフラグを立てた状態でこの速度です。え…私のコード、遅すぎ?!↩load_from_memoryという名前ですが、実際にはフォーマットに応じたデコード処理も含まれています。↩https://rustwasm.github.io/docs/wasm-bindgen/examples/2d-canvas.html↩
https://rustwasm.github.io/docs/wasm-bindgen/reference/js-snippets.html↩
anyが残るコードになっているのは無念ですが、公式には型定義ファイルが見つかりませんでした。実際のwindow.cvをJSON.stringfy()してquicktypeに通す手も考えましたが、WASMコードが含まれているとJSON化できませんでした。実プロジェクトで使いたい場合は、仕様理解のためにも型定義ファイルを作った方がいいと思います。↩https://tech-blog.optim.co.jp/entry/2021/07/30/080000#arch_wasm↩
ただしRust_1.54+image_0.23.14でビルドしたところ
image::resize_exact()の実行速度は10ms程度早くなりましたが、ボトルネックとなるimage::load_from_memory()ではほぼ改善が見られませんでした。もともとムラは大きかったため、誤差の範囲に収まっているように見えます。↩
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme


