SVX日記
2023-10-12(Thu) WebAssembly $41
偶然「RubyがWebAssemblyで動く」という記事を目にした。「WebAssembly」と聞くとブラウザ上で機械語が動くらしい印象を受けるが、具体的にはサッパリわからない。一般的なブラウザでRubyが動くなら実に喜ばしいことだが。しかし、Rubyを動かすためにはファイルシステムが……みたいな話になっている。よく理解できない。フムン。まずはRuby抜きでWebAssembly自体について学んでみたくなってきたな。
そもそも、自分は8bitの頃にコンピュータを始めたクチで、その頃はBASICを理解したら、次は機械語というのが定番のルートだった。自分はSHARP党だったので、X1ではZ80、X68000ではMC68000、Oh!XキッカケでPICマイコンのアセンブラにも手を染めたことがあるが、そもそもWebAssemblyの「Assembly」はアセンブラの意味で正しいのだろうか?
調べ始めると、どうもそれで正しいらしい。JavaScriptよりも高速な処理を目指したもので、そのバイナリは仮想マシンで処理されるものの、数倍の性能が得られるらしい。数倍……か。Z80の頃には、BASICと機械語では軽く数十倍以上の性能が得られたことに比べればずいぶん控えめな値ではあるが。
しかし驚いたのは、あらゆるI/O機能が「ない」ことだ。つまり計算やメモリ操作しかできない。そんなもん何に使ったらいいのか。せっかく懐かしくアセンブラに手を染められるかと思ったのに、使い道のないものを学んでも仕方がない……ん? いや、あるッ! ひとつだけ思いついたぞ。うむ、じゃ、やってみるか。
ということで、Web上でWebAssemblyの記事を探すと、CやRustから使う記事が多い。いや、そうじゃないんだ。せっかくなんだから、直接にニーモニック(mnemonic)を書きたいんだ。で、しつこく探すとそういう記事も多少は見つかる。
;; https://developer.mozilla.org/ja/docs/WebAssembly/Understanding_the_text_format
(module
(func (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add
)
)
# dnf search webassem
wabt.x86_64 : The WebAssembly Binary Toolkit
# dnf install wabt
# wat2wasm add.wat
# ndump add.wasm
#Address +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F 'Character
#00000000 = 00 61 73 6D 01 00 00 00 01 07 01 60 02 7F 7F 01 '.asm....:...`....
#00000010 = 7F 03 02 01 00 0A 09 01 07 00 20 00 20 01 6A 0B '........:.. . .j.
# wasm2wat add.wasm
(module
(type (;0;) (func (param i32 i32) (result i32)))
(func (;0;) (type 0) (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add))
しかし、なにしろ動かしてみないことには面白くない。なんでも、wasmバイナリを動かすには、ブラウザでJavaScriptを実行し、そこから呼び出す形式になるらしい。まさに昔、BASICプログラムから機械語サブルーチンを呼び出す場合の「CALL」や「USR」と同じ形式ということだな。
;; https://developer.mozilla.org/ja/docs/WebAssembly/Understanding_the_text_format
(module
(func (export "add") (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add
)
)
<!-- https://developer.mozilla.org/ja/docs/WebAssembly/Understanding_the_text_format -->
<HTML>
<HEAD>
<TITLE>WebAssembly Add Test</TITLE>
</HEAD>
<BODY>
<SCRIPT>
WebAssembly.instantiateStreaming(fetch("add.wasm")).then(
(obj) => {
console.log(obj.instance.exports.add(1, 2));
}
);
</SCRIPT>
</BODY>
</HTML>
JavaScriptを含むhtmlと、機械語サブルーチンであるwasmは、/var/www/htmlの下に配置し、手元でApacheを上げ、ブラウザからhttp://localhost/add.htmlでアクセスする。何も画面に表示されなくても焦ってはいけない。「Chrome」なら右クリックからの「検証」「Console」し、ログに結果である「3」が……アレ? 出てないな。代わりにエラーが出ている。
Uncaught (in promise) TypeError: Failed to execute 'compile' on 'WebAssembly':
Incorrect response MIME type. Expected 'application/wasm'.
TypeError: Response has unsupported MIME type
よく読めばChromeの方が(ちょっと過剰にw)わかりやすい。Apacheがwasmを返す時の「MIME type」は「application/wasm」じゃないの? って言ってるワケだ。これは、Apache側に設定追加してやる必要がある。
/etc/httpd/conf/httpd.conf
<IfModule mime_module>
:
AddType application/wasm .wasm
:
</IfModule>
2023-10-13(Fri) WebAssembly $42
「一般的なブラウザでRubyが動くなら実に喜ばしい」などと思って始めたWebAssemblyだが、だんだんRubyはどうでもよくなってきた。Z80やX68000でやってたようなアセンブラ遊びをまたやれる。これだけCPUパフォーマンスが上がってきた今日この頃、ちゃんと価値を伴ったアセンブラ遊びが再びできるとは思ってもみなかった。調べるとWebAssemblyは、スタックマシンでレジスタの数に制限はないようだ。絶妙にネイティブアセンブラと違うところが面白いではないか。
さて、WebAssemblyにおける「hello, world」に当たる足し算が終わったところで、次はJavaScriptとWebAssembly間で情報を共有できる「グローバル変数」をテストしてみることにする。
;; https://github.com/mdn/webassembly-examples/blob/main/js-api-examples/global.wat
(module
(import "js" "global" (global $g (mut i32)))
(func (export "getGlobal") (result i32)
global.get $g
)
(func (export "incGlobal")
global.get $g
i32.const 1
i32.add
global.set $g
)
)
<!-- https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/Global -->
<HTML>
<HEAD>
<TITLE>WebAssembly Global Test</TITLE>
</HEAD>
<BODY>
<SCRIPT>
const global = new WebAssembly.Global({ value: 'i32', mutable: true }, 8);
console.log('a:', global.value);
global.value = 16;
console.log('b:', global.value);
global.value += 8;
console.log('c:', global.value);
const importObjects = {
js: { global },
};
WebAssembly.instantiateStreaming(fetch('global.wasm'), importObjects).then(
(obj) => {
let g = obj.instance.exports.getGlobal();
console.log('d:', g);
obj.instance.exports.incGlobal();
console.log('e:', global.value);
},
);
console.log('f:', global.value);
</SCRIPT>
</BODY>
</HTML>
a: 8
b: 16
c: 24
f: 24
d: 24
e: 25
WebAssembly.Globalでglobalというグローバル変数インスタンスを作り、JavaScript内で演算する様子をa, b, cで、WebAssembly内で演算する様子をd, e, fで確認する……と、意外な結果に。fの結果がdより前に出ていて、WebAssembly内の演算結果が反映されていない。
どうも、参考にしたサンプルの「WebAssembly.instantiateStreaming」というwasmを読み込むメソッドに、並列実行を行う性質があることが原因のようだ。いやしかしこんな挙動は扱いにくいだけで、完全に余計なお世話なんだが……。
だいぶアレコレと調べ上げウンウンと数多くの試行を繰り返した挙句、落ち着いたのが以下のJavaScriptコードだ。「Streaming」なのにワザワザ「await」するという冗談みたいなコードだが、これが一番シンプルで機械語サブルーチンを扱いやすい形にできる方法だった。
<!-- https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/Global -->
<HTML>
<HEAD>
<TITLE>WebAssembly Global Test</TITLE>
</HEAD>
<BODY>
<SCRIPT>
async function main() {
const global = new WebAssembly.Global({ value: 'i32', mutable: true }, 8);
console.log('a:', global.value);
global.value = 16;
console.log('b:', global.value);
global.value += 8;
console.log('c:', global.value);
const importObjects = {
js: { global },
};
const obj = await WebAssembly.instantiateStreaming(fetch('global.wasm'), importObjects);
let g = obj.instance.exports.getGlobal();
console.log('d:', g);
obj.instance.exports.incGlobal();
console.log('e:', global.value);
console.log('f:', global.value);
}
main();
</SCRIPT>
</BODY>
</HTML>
a: 8
b: 16
c: 24
d: 24
e: 25
f: 25
2023-10-14(Sat) WebAssembly $43
さて「グローバル変数」によりJavaScriptとWebAssembly間で情報を共有する方法をテストできたが、WebAssemblyは計算やメモリ操作しかできないので、どちらかというと大量のデータを処理する用途に向くわけだから、大きなメモリを共有してこそ真価が発揮されるわけだ。そこで次はJavaScriptとWebAssembly間でメモリ空間を共有できる「共有メモリ」をテストしてみることにする。
;; https://github.com/mdn/webassembly-examples/blob/main/js-api-examples/memory.wat
(module
(memory $mem (import "js" "mem") 1)
(func $log (import "console" "log") (param i32))
(func (export "memsize")
memory.size
call $log
)
(func (export "memStoreTest")
i32.const 0 ;; adr
i32.const 518 ;; val 0x_0000_0206
i32.store
i32.const 8 ;; adr
i32.const 524 ;; val 0x_0000_020C
i32.store
i32.const 5 ;; adr
i32.const 0x55
i32.store8
i32.const 6 ;; adr
i32.const 0xAA
i32.store8
i32.const 0x0d ;; adr
i32.const 0x55AA
i32.store16
i32.const -1
call $log
)
(func (export "memLoadTest") (param $adr i32)
local.get $adr
i32.load
call $log
local.get $adr
i32.load8_u
call $log
)
)
<!-- https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/Memory -->
<HTML>
<HEAD>
<TITLE>WebAssembly Memory Test</TITLE>
</HEAD>
<BODY>
<SCRIPT>
async function main() {
const memory = new WebAssembly.Memory({ initial: 3, maximum: 8 }); // 1 PAGE = 64 KB
const importObjects = {
js: { mem: memory },
console: { log: (arg) => console.log(arg) },
};
const obj = await WebAssembly.instantiateStreaming(fetch('memory.wasm'), importObjects);
obj.instance.exports.memsize();
memory.grow(2); // +2 pages
obj.instance.exports.memsize();
const i8 = new Uint8Array(memory.buffer);
const i32 = new Uint32Array(memory.buffer);
function dump(i8) { // memory dump
const dump = [];
for(let p = 0; p < 16; p++) {
dump.push(i8[p].toString(16));
}
console.log('dump:', dump.join(' '));
}
dump(i8);
for(let p = 0; p < 16; p++) { // 8bit store by JavaScript
i8[p] = 0x80 + (p << 1);
}
dump(i8);
for(let p = 0; p < 16; p++) { // 32bit store by JavaScript
i32[p] = 0x80 + (p << 1);
}
dump(i8); // little endian
obj.instance.exports.memStoreTest(); // store by WebAssembly
dump(i8);
obj.instance.exports.memLoadTest(0); // load by WebAssembly
obj.instance.exports.memLoadTest(8);
}
main();
</SCRIPT>
</BODY>
</HTML>
3
5
dump: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
dump: 80 82 84 86 88 8a 8c 8e 90 92 94 96 98 9a 9c 9e
dump: 80 0 0 0 82 0 0 0 84 0 0 0 86 0 0 0
-1
dump: 6 2 0 0 82 55 aa 0 c 2 0 0 86 aa 55 0
518
6
524
12
その後、メモリの先頭16バイトをダンプ表示。JavaScript側では、Cの共用体のようにメモリをchar配列とint32配列とで共用する形にして、順に数字を格納していって、バイトオーダがリトルエンディアンであることを確認している。
2023-10-15(Sun) WebAssembly $00
;; https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/Control_flow
(module
(func $log (import "console" "log") (param i32))
(func (export "ifTest") (param $val i32)
local.get $val
if (result i32)
i32.const 456 ;; not 0: True
else
i32.const 123 ;; 0: False
end
call $log
)
(func (export "loopTest") (param $times i32) (result i32)
(local $i i32)
(local $sum i32)
i32.const 0 ;; 初期値
local.tee $i
local.set $sum
loop $loop1
local.get $i ;; 処理1
call $log
local.get $sum ;; 処理2 $sum += $i
local.get $i
i32.add
local.set $sum
local.get $i ;; $i += 1
i32.const 1
i32.add
local.tee $i
local.get $times
i32.lt_s ;; $i < $times
br_if $loop1
end
local.get $sum
)
(func (export "whileTest") (param $times i32)
(local $i i32)
local.get $times ;; 初期値
local.set $i
block $loop1
loop $loop2
local.get $i ;; $i == 0
i32.const 0
i32.eq
br_if $loop1 ;; break
local.get $i ;; 処理1
call $log
local.get $i ;; $i -= 1
i32.const 1
i32.sub
local.set $i
br $loop2 ;; continue
end
end
)
)
<!-- https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/Control_flow -->
<HTML>
<HEAD>
<TITLE>WebAssembly Control Flow Test</TITLE>
</HEAD>
<BODY>
<SCRIPT>
async function main() {
const importObjects = {
console: { log: (arg) => console.log(arg) },
};
const obj = await WebAssembly.instantiateStreaming(fetch('controlflow.wasm'), importObjects);
obj.instance.exports.ifTest(0);
obj.instance.exports.ifTest(1);
obj.instance.exports.ifTest(2);
let sum = obj.instance.exports.loopTest(11);
console.log('total:', sum);
obj.instance.exports.whileTest(5);
}
main();
</SCRIPT>
</BODY>
</HTML>
123
456
456
0
1
2
3
4
5
6
7
8
9
10
total: 55
5
4
3
2
1
まずは条件分岐(if)。WebAssemblyのifはスタックの先頭の値がゼロか非ゼロかで処理を分岐するものだ。JavaScriptから0を渡すとelseの123が返り、非ゼロを渡すと456が返っている。ちょっとクセがあるのは、アセンブラにはスタックのズレを検出する機能があり、if構造を抜けた際にスタックが高くなって(ズレて)いると、それが検出されてエラーになってしまうところだ。そういう場合「スタックに返り値を積んだんのですよ」と明示する必要がある。ifの後の「(result i32)」がそれだ。思い返せば、Z80の頃にプログラムを暴走させてしまう一番の原因はPUSHとPOPのズレだったよなぁ。
次は繰り返し(loop)。カウンタは0から加算され、指定した回数だけ実行される。中身に処理がないのもアレなんでカウンタの累計を計算して返すようにしてみた。11回のループで0〜10が加算され結果の55が返っている。
2023-10-23(Mon) WebAssemblyでイメージを操作
というわけで「WebAssemblyのひとつだけの使い道」のプログラミングを始めた。やりたいのは要するにイメージの操作だ。単純な計算を山ほどループで繰り返す。実にWebAssembly向きの処理である。
いきなりだが、以下が機械語サブルーチン部分。イメージを表すRGBAの羅列を渡すと、GとBを抜いてくれるというもの。だいぶカリカリにチューン済み。結局、ループの最適解は減算&非ゼロ判定だな。それはそうと、見慣れないニーモニックが入っている。
(module
(import "js" "mem" (memory $mem 1))
;; filter(ソースの末尾 + 1 のアドレス)
(func (export "filter") (param $src_adr i32)
(local $dst_adr i32)
push src_adr ;; dst_adr = src_adr << 1
i32.push 1
i32.shl
pop dst_adr
loop1: loop
push dst_adr ;; dst_adr -= 4
i32.push 4
i32.sub
pop_push dst_adr ;; [ dst_adr
push src_adr ;; src_adr -= 4
i32.push 4
i32.sub
pop_push src_adr ;; [ dst_adr src_adr
i32.load ;; [ dst_adr color
i32.push 0xFF0000FF ;; [ dst_adr color mask
i32.and ;; [ dst_adr color
i32.store ;; [
push src_adr
jp_nz loop1 ;; src_adr != 0 loop
end
)
)
つうか、ちょっとは努力をしたつもりなのだが「i32.const」とか「local.set」とか「local.get」とか……ダメだわ。まったく頭に入ってこない。まったくスタックに出し入れしている感じが湧かない。結局、だいぶ前にZ80ライクニーモニックからPICニーモニックに変換するRubyスクリプトを書いた時と同じく、Z80ライクニーモニックからWebAssemblyのwat形式に変換するRubyスクリプトを書いてしまった。PIC用に作ったそれに比べれば、恐ろしく単純な変換しかしていないが、自分にはそれで十分にわかりやすく書ける。はて、自分は頭が固いのか柔らかいのか、どっちなのだろう……ゼッパチの魂百まで。怖い。
#!/usr/bin/env ruby
# coding: utf-8
wat80src = ARGV[0]
file_in = open(wat80src, 'r')
file_out = open(wat80src.gsub(/\.[^.]+$/, '') + '.wat', 'w', 0444)
file_in.each {|line|
break if(line =~ /^__END__$/)
unless(line =~ /^#/)
line.chomp!
if(line =~ /^(\w+):\s*(\w+)(\s*.*)/) # loop1: loop
line = "\t\t%s\t\t$%s%s" % [$2, $1, $3]
end
if(line =~ /^(\s+br\w*)\s+(\w+)(\s*.*)/) # br_if loop1
line = "%s\t\t$%s%s" % [$1, $2, $3]
end
if(line =~ /^(\s+)jp_nz\s+(\w+)(\s*.*)/) # jp_nz loop1
line = "%sbr_if\t\t$%s%s" % [$1, $2, $3]
end
if(line =~ /^(\s+\w+)\.push\s+([\d-]+)(\s*.*)/) # i32.push 10
line = "%s.const\t\t%s%s" % [$1, $2, $3]
end
if(line =~ /^(\s+)pop\s+(\w+)(\s*.*)/) # pop i
line = "%slocal.set\t\t$%s%s" % [$1, $2, $3]
end
if(line =~ /^(\s+)push\s+(\w+)(\s*.*)/) # push i
line = "%slocal.get\t\t$%s%s" % [$1, $2, $3]
end
if(line =~ /^(\s+)pop_push\s+(\w+)(\s*.*)/) # pop_push i
line = "%slocal.tee\t\t$%s%s" % [$1, $2, $3]
end
if(line =~ /^(\s+)call\s+(\w+)(\s*.*)/) # call log
line = "%scall\t\t$%s%s" % [$1, $2, $3]
end
line += $/
end
file_out.write line
}
<HTML>
<HEAD>
<TITLE>WebAssembly Graphics Test</TITLE>
</HEAD>
<BODY>
<CANVAS id='canvas1' width='512' height='384'></CANVAS>
<SCRIPT type='text/javascript' src='graphics.js'></SCRIPT>
</BODY>
</HTML>
で、例によって、JavaScriptはCoffeeScriptに書き直した。CoffeeScriptでasyncやawaitはどう書くのかと思ったら、awaitが含まれる関数は、自動的にasyncを付けてくれるらしい。CoffeeScript 1.xではダメで、2.0以上が必要なようだが。
'use strict'
# ソースイメージを読み込む
image_element = new Image
image_element.src = 'gra2.png'
# ソースイメージの読み込み完了を待つ
prep = ->
if(!image_element.complete)
setTimeout(prep, 100)
else
main()
main = ->
screen_canvas_element = document.getElementById('canvas1')
screen_context = screen_canvas_element.getContext('2d')
# 枠描画、ソースイメージを描画
screen_context.fillStyle = 'lightgray'
screen_context.fillRect( 0, 0, image_element.width + 32, image_element.height + 32)
screen_context.fillStyle = 'gray'
screen_context.fillRect(16, 16, image_element.width, image_element.height)
screen_context.drawImage(image_element, 16, 16)
console.log('image_element:', image_element)
# ソースイメージをデータ化
work_canvas_element = document.createElement('canvas')
work_canvas_element.width = image_element.width
work_canvas_element.height = image_element.height
work_context = work_canvas_element.getContext('2d')
work_context.drawImage(image_element, 0, 0)
# source_image = work_context.getImageData(0, 0, image_element.width, image_element.height)
source_bytes = work_context.getImageData(0, 0, image_element.width, image_element.height).data
source_longs = new BigUint64Array(source_bytes.buffer, 0, source_bytes.length >> 3) # コピーの高速化のために共用体化
# console.log('source_image:', source_image)
console.log('source_bytes:', source_bytes)
# ワークメモリを確保
work_memory = new WebAssembly.Memory({ initial: 1, maximum: 1 }) # 1 PAGE = 64 KB
work_bytes = new Uint8ClampedArray(work_memory.buffer, 0, source_bytes.length)
work_longs = new BigUint64Array(work_memory.buffer, 0, source_longs.length) # コピーの高速化のために共用体化
# ソースイメージデータをワークメモリにコピー
for p in [0...source_longs.length]
work_longs[p] = source_longs[p]
# wasmをロード、メモリ操作(イメージデータの加工生成)を実行
importObjects = {
js: { mem: work_memory },
console: { log: (arg) => console.log(arg) },
}
obj = await WebAssembly.instantiateStreaming(fetch('graphics.wasm'), importObjects)
obj.instance.exports.filter(source_bytes.length)
# 生成データをイメージ化
filtered_bytes = new Uint8ClampedArray(work_memory.buffer, source_bytes.length, source_bytes.length)
filtered_image = new ImageData(filtered_bytes, image_element.width, image_element.height)
console.log('filtered_bytes:', filtered_bytes)
console.log('filtered_image:', filtered_image)
# スプライト(=キャンバス要素)を生成
sprite_canvas_element = document.createElement('canvas')
sprite_canvas_element.width = image_element.width
sprite_canvas_element.height = image_element.height
sprite_context = sprite_canvas_element.getContext('2d')
sprite_context.putImageData(filtered_image, 0, 0)
# 枠描画、スプライトを描画
screen_context.fillStyle = 'lightgray'
screen_context.fillRect( 0, 64, image_element.width + 32, image_element.height + 32)
screen_context.fillStyle = 'gray'
screen_context.fillRect(16, 80, image_element.width, image_element.height)
screen_context.drawImage(sprite_canvas_element, 16, 80)
console.log('sprite_canvas_element:', sprite_canvas_element)
prep()
結構、長い処理になってしまった。仕様上、何度も変換する必要があるのが面倒くさい。
- 元となるpngを内部CANVASに描き、getImageDataでUint8ClampedArrayの形で取り出す。
- それをWebAssembly.Memoryの領域にコピーする。
- WebAssemblyの側でフィルタ処理を行う。
- WebAssembly.Memoryの領域にUint8ClampedArrayの枠を被せて、ImageDataとして取り込む。
- それを新たにスプライトとして扱うCANVASにputImageDataで描く、までが準備作業。
最後に、表示されているCANVASに、drawImageでスプライトCANVASを重ねて完成だ。結果はこんな感じ。