ことの発端
html の canvas 要素からピクセルデータを取得して、hex文字列にしなきゃいけない場面があり、こんなコードを書いてみた。
function toString(image: ImageData){
const hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] as readonly string [];
return image.data.map(x => hex[x >> 4 & 0xF] + hex[x & 0xF]).join("");
}
そうすると、こんな怒られ方をする。
Argument of type '(x: number) => string' is not assignable to parameter of type '(value: number, index: number, array: Uint8ClampedArray) => number'.
Type 'string' is not assignable to type 'number'.
う〜〜〜ん、Uint8ClampedArray の map() って、ちょっと変わった子なのねと分かったものの、さて、どうしたものか。。。
あ、兄弟の Uint8Array なんかも同様ですね。
forEach で回しながら文字列を作成する
ベタな方法です。
string の "+=" がどんだけ頭が良いかで、パフォーマンスに影響出そうです。
function toString(image: ImageData){
const hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] as readonly string [];
let s = "";
image.data.forEach((x) => {
s += hex[x >> 4 & 0xF] + hex[x & 0xF];
});
return s;
}
Array.from() で number[] に変換してから map() と join() でやっつける
お〜、何だかシンプル。
ただ、Array.from() って、メモリコピーが発生するか否かで、かなりパフォーマンスに影響出るんじゃないかな?
function toString(image: ImageData){
const hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] as readonly string [];
return Array.from(image.data).map(x => hex[x >> 4 & 0xF] + hex[x & 0xF]).join("");
}
実際に速度計測してみる
こういう時は、実際に速度計測するのが間違いないってことで、こんなコードを書いてみる。
ここでは、Uint8ClampedArrayじゃなくて、
const hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] as readonly string [];
const arr: number[] = [];
/* Array のサンプルデータを作成 */
for(let i = 0; i < 0x1000; i++) arr[i] = i & 0xFF;
/* Uint8ClampedArray のサンプルデータを作成 */
const u8arr = Uint8ClampedArray.from(arr);
/* 結果を入れる器を初期化 */
const re_1: number[] = [];
const re_2: number[] = [];
const re_3: number[] = [];
const re_4: number[] = [];
for(let i = 0; i < 100; i++){
/* (1) Array を map で変換しつつ join */
{
const t0 = performance.now();
const s = arr.map(x => hex[x >> 4 & 0xF] + hex[x & 0xF]).join("");
const t1 = performance.now();
//console.log(`time: ${t1 - t0}ms`);
re_1.push(t1 - t0);
console.log(s.length);
}
/* (2) Array を forEachで回しつつ文字列を追加 */
{
const t0 = performance.now();
const s = (() => {
let _s = "";
arr.forEach((x) => {
_s += hex[x >> 4 & 0xF] + hex[x & 0xF];
});
return _s;
})();
const t1 = performance.now();
//console.log(`time: ${t1 - t0}ms`);
re_2.push(t1 - t0);
console.log(s.length);
}
/* (3) Uint8ClampedArray を forEachで回しつつ文字列を追加 */
{
const t0 = performance.now();
const s = (() => {
let _s = "";
u8arr.forEach((x) => {
_s += hex[x >> 4 & 0xF] + hex[x & 0xF];
});
return _s;
})();
const t1 = performance.now();
//console.log(`time: ${t1 - t0}ms`);
re_3.push(t1 - t0);
console.log(s.length);
}
/* (4) Uint8ClampedArray Array.from で Array にしてから、map で変換しつつ join */
{
const t0 = performance.now();
const s = Array.from(u8arr).map(x => hex[x >> 4 & 0xF] + hex[x & 0xF]).join("");
const t1 = performance.now();
//console.log(`time: ${t1 - t0}ms`);
re_4.push(t1 - t0);
console.log(s.length);
}
}
/* 初回はキャッシュの影響とかあるかな〜と思い、結果を捨てる */
re_1.shift();
re_2.shift();
re_3.shift();
re_4.shift();
/* 平均を表示 */
console.log(`(1) ${re_1.reduce((p, c) => p + c) / re_1.length}ms`);
console.log(`(2) ${re_2.reduce((p, c) => p + c) / re_2.length}ms`);
console.log(`(3) ${re_3.reduce((p, c) => p + c) / re_3.length}ms`);
console.log(`(4) ${re_4.reduce((p, c) => p + c) / re_4.length}ms`);
そして、結果は。。。
(1) 0.2080303030849947ms
(2) 0.1918181818965799ms
(3) 0.26181818211258323ms
(4) 0.32621212136805894ms
ブラウザのメモリ使用状況とか、他のプロセスも影響があると思うけど、概ね順位はこうなります。
1位: Array を forEachで回しつつ文字列を追加
2位: Array を map で変換しつつ join
3位: Uint8ClampedArray を forEachで回しつつ文字列を追加
4位: Uint8ClampedArray Array.from で Array にしてから、map で変換しつつ join
結論
map() と join() って、 map() で新たな配列をメモリ上に生成して、join() で更にメモリ上に文字列を生成するという、パフォーマンスに大きく影響するんだなぁ〜と実感。関数型プログラミングも使いどころによっては、パフォーマンスに悪影響が出るという感じかな。
(reactive extensionで from() -> map() -> zip() で繋げればパフォーマンス出そうな予感がするが試してない)
そして、Array.from はメモリアクセスの最適化は行われていないので、やっぱりコピーするんですね。(多分)
つまり、Array.from() で楽しようなんて考えちゃいけません。
(パフォーマンスを求めないのであれば可読性を考えてアリだけどね)
ということで、頭悪そうなコードだけど、Uint8ClampedArray を forEach() でぶん回す方法を採用。