はじめに
TypeScriptで書いた簡単な画像処理のコードを高速化したいので、ChatGPT o1にお願いして、AssemblyScriptで書いてWebAssemblyとして動かせるようにしようと思いました。
それぞれの言語で使える型の扱いに違いがあるので、その微妙なあたりをうまく対応してくれるのか?を確認するところがポイントになります。
別記事「vite-react-comlink-worker-assemblyscript-webgpu-boilerplateを作った」では、4x4平均化を施す画像処理フィルタのJavaScript版のコードをもとに、JavaScript+WebWorker, AssemblyScript, AssemblyScript+WebWorker, WebGPUの 4つの実装をして、これらを比較するデモを開発したことについて説明しました。
デモはこちらで試すことができます:
移植の結果はというと...
動画内の該当部分 | 実装 | 実行時間 | UIスレッドのブロック |
---|---|---|---|
0:00-0:08 | JavaScript | 4.52秒 | される |
0:09-0:15 | JavaScript + WebWorker | 4.80秒 | されない |
0:16-0:18 | AssemblyScript | 1.84秒 | される |
0:19-0:21 | AssemblyScript + WebWorker | 1.82秒 | されない |
0:22-0:23 | WebGPU Compute Shader | 0.07秒 | されない |
JavaScript版で4.52秒かかっていた処理が、AssemblyScript版では1.84秒に短縮されました!
(WebGPU Compute Shader版では、それよりも、さらにずっと短縮されました)
こちらの記事では、こうした4x4平均化の画像フィルタを、いろいろな方式で実装をする作業をしたときに、ChatGPTを使いながらの開発が実際のところ、どのようなものであったのかについて、紹介をするものです。
元にしたTypeScriptのコード
/**
3x3平均化フィルタを画像に適用する
@param width 画像の幅
@param height 画像の高さ
@param param data 1ピクセルをRGBAのそれぞれ8bitで表した画像データ
@param iteration 繰り返し適用回数
*/
export function applyAverageFilter(
width: number,
height: number,
data: Uint8ClampedArray,
iteration: number,
): void {
const inputData = Uint8ClampedArray.from(data);
const outputData = Uint8ClampedArray.from(data);
[...Array(iteration)].forEach((_: any, index:number)=>{
inputData.set(outputData);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let r = 0,
g = 0,
b = 0,
a = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const index = ((y + dy) * width + (x + dx)) * 4;
r += inputData[index + 0];
g += inputData[index + 1];
b += inputData[index + 2];
a += inputData[index + 3];
}
}
const i = (y * width + x) * 4;
outputData[i + 0] = Math.floor(r / 9);
outputData[i + 1] = Math.floor(g / 9);
outputData[i + 2] = Math.floor(b / 9);
outputData[i + 3] = Math.floor(a / 9);
}
}
});
}
inputDataとoutputDataを入れかえながら処理をするダブルバッファリングの手法を使えば、画像処理の繰り返しごとのinputData.set(outputData);
は不要になりますが、ここでは単純化のため、このようなコードにしています。
ChatGPTでAssemblyScriptに移植してもらう
私:
これから私から、TypeScriptによる3x3平均化画像フィルタの関数のコードを示します。それをもとに、このコードをAssemblyScriptに書き換えてください。
ChatGPT:(8 秒の間、Thought about TypeScript to AssemblyScript conversion)
以下は、もとの TypeScript のコードを AssemblyScript 用に書き換えた例です。AssemblyScript では
Uint8ClampedArray
が標準では使用できないため、代わりにUint8Array
を用いています。また、Array.from や Spread 構文など、一部のJavaScript特有の機能は使用できないため、該当箇所は AssemblyScript の文法に合わせて書き換えています。
このコードは、AssemblyScript としてコンパイル可能な形を想定したサンプルです。実際にビルドや実行する場合は、asconfig.json、assembly/index.ts などの設定や、WASM のエクスポート・インポートのための設定が必要になる場合があります。」
...ん?Uint8XlampedArrayは標準では使用できない?など、気になる記述がありますが、とりあえず、実際にChatGPTが書いてきたコードを見てみましょう。
ChatGPTが最初に書いてきたコード
// AssemblyScript 例
// ファイル例: assembly/index.ts
// AssemblyScriptにはMath.floorは整数演算で不要になります(整数同士の割り算は自動的に整数化)。
// また、Uint8ClampedArrayは標準では用意されていないため、Uint8Arrayで対応します。
// 元コードの動作を踏襲するため、RGB値加算後の9分割で整数トランケーション(切り捨て)が自然に行われます。
export function applyAverageFilter(
width: i32,
height: i32,
dataPtr: usize, // データを指すポインタ(呼び出し側でメモリ割り当て済みとする)
dataLength: i32, // data の長さ (width * height * 4)
iteration: i32
): void {
// dataへのアクセスには、AssemblyScriptのTypedArrayビューを使う
let data = Uint8Array.wrap(changetype<ArrayBuffer>(dataPtr), 0, dataLength);
// 入出力用配列をコピーで用意する
let inputData = new Uint8Array(dataLength);
let outputData = new Uint8Array(dataLength);
for (let i = 0; i < dataLength; i++) {
inputData[i] = data[i];
outputData[i] = data[i];
}
// iteration回繰り返す
for (let it = 0; it < iteration; it++) {
// inputDataに前回の出力を反映
for (let i = 0; i < dataLength; i++) {
inputData[i] = outputData[i];
}
// 3x3平均フィルタ適用
// 画像外周はループ外でスキップする(元コードと同様に境界は処理なし)
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let r: i32 = 0;
let g: i32 = 0;
let b: i32 = 0;
let a: i32 = 0;
// 3x3領域の画素値合計
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
let idx = ((y + dy) * width + (x + dx)) * 4;
r += inputData[idx + 0];
g += inputData[idx + 1];
b += inputData[idx + 2];
a += inputData[idx + 3];
}
}
let i = (y * width + x) * 4;
// 整数除算で自動的に切り捨てが行われる
outputData[i + 0] = <u8>(r / 9);
outputData[i + 1] = <u8>(g / 9);
outputData[i + 2] = <u8>(b / 9);
outputData[i + 3] = <u8>(a / 9);
}
}
}
// 最終結果を元のdataに反映
for (let i = 0; i < dataLength; i++) {
data[i] = outputData[i];
}
}
ChatGPTは次のように説明を続けてくれています。
ChatGPT:
ポイント:
AssemblyScriptではUint8ClampedArrayが標準で存在しないため、Uint8Arrayを使用しています。もとの処理は単純な整数演算なので、問題なく代用できます。平均化時のクリップは0〜255以内であれば不要ですが、万が一オーバーフローを想定する場合は キャストによって0〜255に収まります。
Array.from や スプレッド構文(...)が使用できないため、配列のコピーは明示的なループで行っています。
Math.floorは整数計算が標準のAssemblyScriptでは不要です。整数割り算は自動的に切り捨てられます。
関数引数として、WebAssemblyでやり取りすることを想定してdataはポインタと長さを受け取る形にしています。このあたりはプロジェクトの構成やホスト側(呼び出し元)とのインタフェースに応じて変更してください。もとのTypeScriptではUint8ClampedArrayそのものを引数にしていましたが、AssemblyScript/wasm ではメモリ境界を跨いだオブジェクト受け渡しはできないため、このような形が一般的です。
このような変更で、AssemblyScript 環境でも同等の 3x3 平均化フィルタ処理が可能になります。
ChatGPTの書いたコードを検証する
詳しく検証してみましょう。
嘘:「AssemblyScript では Uint8ClampedArray が標準では使用できない」?
ChatGPT:
AssemblyScript では Uint8ClampedArray が標準では使用できないため、代わりに Uint8Array を用いています。
現在は、AssemblyScriptは、Uint8ClampedArrayに対応しています。次の表は、The AssemblyScript BookのTypedArrayの章からの引用です。
よって、次のように指示することにします。
- 現行バージョンのAssemblyScriptではUint8ClampedArrayが標準で使えるので、Uint8Arrayを使う必要はありません。Uint8ClampedArrayを用いてください。
謎: 関数呼び出しの仕様を勝手に忖度して変更されている
私のコード、
/**
3x3平均化フィルタを画像に適用する
@param width 画像の幅
@param height 画像の高さ
@param param data 1ピクセルをRGBAのそれぞれ8bitで表した画像データ
@param iteration 繰り返し適用回数
*/
export function applyAverageFilter(
width: number,
height: number,
data: Uint8ClampedArray,
iteration: number,
): void {
これが、
export function applyAverageFilter(
width: i32,
height: i32,
dataPtr: usize, // データを指すポインタ(呼び出し側でメモリ割り当て済みとする)
dataLength: i32, // data の長さ (width * height * 4)
iteration: i32
): void {
このように勝手に変えられてしまいました。次のように指示することにします。
- 関数の引数で、元のコードではdataを渡していたものが、データを指すポインタを渡すように勝手に変えられていますが、変えないでください(AssemblyScriptでの関数では、モジュール側で確保済みのバッファについてのUint8ClampedArrayを作成したものを用意してあるものとし、この関数では、その内容をdataという引数で渡す形にしてください)。
謎:for文で配列のコピーをする形に変更されている
元のコードで、
const inputData = Uint8ClampedArray.from(data);
const outputData = Uint8ClampedArray.from(data);
と書いていたものが、
for (let i = 0; i < dataLength; i++) {
inputData[i] = data[i];
outputData[i] = data[i];
}
になっています。
また、
inputData.set(outputData);
と書いていたものが、
// inputDataに前回の出力を反映
for (let i = 0; i < dataLength; i++) {
inputData[i] = outputData[i];
}
になっています。これらについては、次のように指示することにします。
- Uint8ClampedArrayの値のコピーには、なるべくset関数を使ってください。
謎:符号なしの合計値の計算のための変数の型に、符号付きを用いている
「JavaScript の数値 (Number) 型は IEEE 754 の倍精度 64ビットバイナリー形式」です。
let r = 0,
g = 0,
b = 0,
a = 0;
そのため、上のような記述をすると、r,g,b,aのそれぞれの変数の型は、Number型となります。
ChatGPTは、これを次のように書き換えています。
let r: i32 = 0;
let g: i32 = 0;
let b: i32 = 0;
let a: i32 = 0;
しかし、題材とするコードでは、RGBAそれぞれ8ビットの符号なしの値(Uint8ClampedArrayの要素)を合計し、9で割って平均を出すというものであるため、符号ありにする必要はありません。
そこで、次のように指示します。
- 変数r,g,b,aの型はi32ではなくu32にしてください。
謎:不要な型のキャスト
元のコードで
outputData[i] = Math.floor(r / 9);
outputData[i + 1] = Math.floor(g / 9);
outputData[i + 2] = Math.floor(b / 9);
outputData[i + 3] = Math.floor(a / 9);
としていたものが、
outputData[i + 0] = <u8>(r / 9);
outputData[i + 1] = <u8>(g / 9);
outputData[i + 2] = <u8>(b / 9);
outputData[i + 3] = <u8>(a / 9);
のように書き換えられています。しかし、ArrayScriptでは、0〜255の範囲の値をとるu32型の値を、u8型に代入をするときには、問題なく自動的にキャストできると考えられます。そこで、次のように指示します。
- 「i32型ではなくu32型に修正したので、outputDataのu8型への代入については、ここでは、わざわざでキャストする必要はありません。
追加依頼:unchecked関数の使用
ChatGPTは、
r += inputData[idx + 0];
g += inputData[idx + 1];
b += inputData[idx + 2];
a += inputData[idx + 3];
というシンプルなコードを示しています。しかし、AssemblyScriptでは、実行時に配列の引数が範囲を超えないということを前提とし、範囲チェックを省略するという指示をすることができます。具体的には、配列の操作をする箇所をunchecked
という関数で囲むと、パフォーマンスがよくなるのです。
つまり、次のように書いてもらいたいところです。
r += unchecked(inputData[idx + 0]);
g += unchecked(inputData[idx + 1]);
b += unchecked(inputData[idx + 2]);
a += unchecked(inputData[idx + 3]);
さらに、こちらの部分も同様に、
outputData[i + 0] = <u8>(r / 9);
outputData[i + 1] = <u8>(g / 9);
outputData[i + 2] = <u8>(b / 9);
outputData[i + 3] = <u8>(a / 9);
を、次のように書いてもらいたいのです。
unchecked(outputData[i + 0] = r / 9);
unchecked(outputData[i + 1] = g / 9);
unchecked(outputData[i + 2] = b / 9);
unchecked(outputData[i + 3] = a / 9);
というわけで、次のように指示します。
*配列の操作はunchecked関数でラップしてください。
ChatGPTに指示をする内容まとめ
まとめると、次のような指示をすることになります。
私:
- 現行バージョンのAssemblyScriptではUint8ClampedArrayが標準で使えるので、Uint8Arrayを使う必要はありません。Uint8ClampedArrayを用いてください。
- 関数の引数で、元のコードではdataを渡していたものが、データを指すポインタを渡すように勝手に変えられていますが、変えないでください(AssemblyScriptでの関数では、モジュール側で確保済みのバッファについてのUint8ClampedArrayを作成したものを用意してあるものとし、この関数では、その内容をdataという引数で渡す形にしてください)。
- Uint8ClampedArrayの値のコピーには、なるべくset関数を使ってください。
- 変数r,g,b,aの型はi32ではなくu32にしてください。
- 「i32型ではなくu32型に修正したので、outputDataのu8型への代入については、ここでは、わざわざでキャストする必要はありません。
*配列の操作はunchecked関数でラップしてください。
ChatGPTからの修正版コード
ChatGPTに指示を出し直すと、次のように返答がありました。
ChatGPT:
以下は、ユーザーからの追加要望に対応したAssemblyScriptコード例です。
主な変更点は以下のとおりです。
- 関数の引数では元のコード同様data: Uint8ClampedArrayを直接受け取ります(ポインタや長さは受け取らない)。
- Uint8ClampedArrayが利用可能な前提でUint8ArrayではなくUint8ClampedArrayを使用します。
- 配列のコピーではset()メソッドを使用します(一部の初期化などでset()を用いる)。
- 配列へのアクセスはunchecked()関数でラップしています。
- r, g, b, a は u32 型にします。
- 除算後の値代入時に キャストは行いません(Uint8ClampedArrayへの代入と整数除算で自動的に適切な値になります)。
- Math.floorは不要です。
// AssemblyScript Example
// ファイル例: assembly/index.ts
export function applyAverageFilter(
width: i32,
height: i32,
data: Uint8ClampedArray,
iteration: i32
): void {
// data配列と同サイズのinputData, outputDataを用意
let inputData = new Uint8ClampedArray(data.length);
let outputData = new Uint8ClampedArray(data.length);
// 初回はoutputDataにdataをコピー
outputData.set(data);
for (let it = 0; it < iteration; it++) {
// inputDataにoutputDataをコピー
inputData.set(outputData);
// 3x3平均フィルタ処理
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let r: u32 = 0;
let g: u32 = 0;
let b: u32 = 0;
let a: u32 = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
let idx = ((y + dy) * width + (x + dx)) << 2; // *4 の代わりにシフト演算
// uncheckedを用いて配列アクセス
r += unchecked(inputData[idx + 0]);
g += unchecked(inputData[idx + 1]);
b += unchecked(inputData[idx + 2]);
a += unchecked(inputData[idx + 3]);
}
}
let i = (y * width + x) << 2; // (y*width+x)*4
// u32同士の除算は整数切り捨て
// Uint8ClampedArrayへの代入で0~255範囲にクランプ
unchecked(outputData[i + 0] = r / 9);
unchecked(outputData[i + 1] = g / 9);
unchecked(outputData[i + 2] = b / 9);
unchecked(outputData[i + 3] = a / 9);
}
}
}
// 処理結果を元のdataに反映
data.set(outputData);
}
ChatGPT:
このようにすることで、以下が満たされます。
- data引数をUint8ClampedArrayとして直接受け取り、そのまま処理を実行。
- Uint8ClampedArrayを使用し、set()関数でコピーを行う。
- 配列アクセス時にはunchecked()を使用。
- r, g, b, aはu32型。
- キャスト不要でUint8ClampedArrayに代入。
希望通り、修正されました!
まとめ
ChatGPTの知識のうちどれが古いのか、判断のどれが曖昧なのかについてを、ユーザーがきちんと指摘できるならば、最小限の修正指示をするだけで、希望するコードを書いてもらえるということがわかります。なお、AssemblyScriptのようなマイナーな変換対象ではなくて、もっとメジャーなものであれば、より精度高い変換をしてくれるだろう、というようにも思われます。
2024年末時点で、ChatGPTを使ったコーディングの現状がこんなものだった、ということとして、記録に留めておくことにします。