私はかつてFlashをいじっていたのですが、その頃はマシンの性能もあまり良くはなく、Flashの実行エンジン自体もそこまで高速ではなかったので、様々な高速化テクニックに頼ることもありました。
ふと思いました。あの頃の高速化テクニック、今のJavaScript通用するのかな、と。今や超高機能なプロセッサと超高速な実行エンジンで動くモンスターと化してしまったJavaScriptですが、かつての兄弟(ActionScript)の技を受け継ぐことができるのでしょうか。
注意事項
この記事はノルタルジー重点です。あんまり真面目に計測してないです。実用性も薄いです。
というかこの記事に書いてあるような高速化は、仮に効果があったとしても実務では絶対に使わないでください。夜道で刺されると思います。どうしようもなく読みにくくなるので……。
計測条件
計測はChromeとFirefoxで行います。計測関数は以下のようなものを準備しました。
function measure(func) {
const startMs = performance.now(); // 開始時間(ミリ秒)
for(let i = 0; i < 100000; i++) {
func(); // 10万回やる
}
const endMs = performance.now(); // 終了時間(ミリ秒)
return endMs - startMs; // 経過時間がミリ秒で返る
}
こんだけ回すと途中で妙な最適化が入る可能性はありますが、まあ、参考程度ということで。
いろいろやってみる
ループのアンロール
ループのアンロール(unroll)という言葉を聞いたことがあるでしょうか。アンロールとは展開のことで、例えば普通はforループで10回まわすところを、10行に分けて書いてループをなくす……というような処理を指します。昔は良く使われてたようです。
ループによるジャンプや条件判定を減らすことにより性能の向上が望める、というのが理屈ですが、JavaScriptというかなり高級な環境で、しかも今時の実行エンジンで効果があるかは怪しいところです。ちなみにかつてのFlashではめちゃくちゃ効果がありました。
以下のようなコードを計測してみます:
// 普通のループ
function loop() {
let sum = 0;
for(let i = 0; i < 10000; i++) {
sum += i;
}
return sum;
}
// アンローリング
function unrolled() {
let sum = 0;
for(let i = 0; i < 10000; i+=10) {
sum += i;
sum += i + 1;
sum += i + 2;
sum += i + 3;
sum += i + 4;
sum += i + 5;
sum += i + 6;
sum += i + 7;
sum += i + 8;
sum += i + 9;
}
return sum;
}
結果は……。
通常(ミリ秒) | アンロール(ミリ秒) | |
Chrome | 560 | 284 |
Firefox | 851 | 290 |
うそでしょ。アンロールした方が速いみたいです。ただし今回はループ内の処理が単純ということもあって速度に直結しやすかっただけという事情もあるので、処理がもう少し複雑になってくるとわからないですね。ループ内に大きめの関数呼び出しとか入ると実行エンジンの最適化の度合いも変わってくるでしょうし。
ループ内の規模によって大きく変わってくるでしょうし、あくまで参考値ということで。
ルックアップテーブル(LUT)
計算コストの高いものをあらかじめ計算しておいてあとで使い回す、というのがルックアップテーブルになります。これは今でも分野によっては使われてるんじゃないですかね。
昔だと三角関数のルックアップテーブルを作るのがよくありました。角度(整数に限定する)に対して三角関数の値が格納されている配列を作るというやつです。今ではどうなんでしょうね。もうあんまり聞かなくなりましたね。
以下のコードを計測:
// ルックアップテーブルを生成しておく
const sinLut = [];
const cosLut = [];
const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;
for(let i = 0; i < 360; i++) {
sinLut.push(Math.sin(i * DEG_TO_RAD));
cosLut.push(Math.cos(i * DEG_TO_RAD));
}
// データ準備
const radList = new Array(1000).fill()
.map(() => Math.random() * 2 * Math.PI);
// Math使った計算
function math() {
let sum = 0;
for(const rad of radList) {
sum += Math.sin(rad) + Math.cos(rad);
}
return sum;
}
// LUT使った計算
function lut() {
let sum = 0;
for(const rad of radList) {
const deg = Math.floor(rad * RAD_TO_DEG);
sum += sinLut[deg] + cosLut[deg];
}
return sum;
}
結果は:
Math(ミリ秒) | LUT(ミリ秒) | |
Chrome | 3757 | 591 |
Firefox | 2068 | 649 |
ええぇ……。これも効果あるんですか。というかMath.sin, Math.cosが遅いのかな。これ場合によっては普通に実用できるのではという気分にもなりますね。
Math.max/Math.minより条件分岐
FlashだとMath.maxとminが異様に遅いという現象がありました。ifを使った方が速かった記憶があります。JavaScriptだとどうなんでしょ。
// データの準備
const numList = new Array(10000).fill().map(() => Math.random());
// Math.max
function mathMax() {
let max = Number.MIN_VALUE;
for(let i = 0; i < 10000; i++) {
max = Math.max(max, numList[i]);
}
return max;
}
// if
function ifMax() {
let max = Number.MIN_VALUE;
for(let i = 0; i < 10000; i++) {
if(numList[i] > max) {
max = numList[i];
}
}
return max;
}
これを計測すると:
Math.max(ミリ秒) | if(ミリ秒) | |
Chrome | 1377 | 1213 |
Firefox | 1055 | 1198 |
あんまり変わらないですね。素直にMath.max/min使いましょう。
ビット演算
Flashでは普通に計算するよりもビット演算の方が速いことがありました。この辺りの最適化って普通はコンパイラや実行エンジンの仕事だと思うんですが、Flashくんは特に何もしてくれませんでした。
簡単な例として「1とAND取ると偶奇判定できる」というのがあります。やってみましょう。
// 普通に%を使う
function mod() {
let result = false;
for(let i = 0; i < 10000; i++) {
result = i % 2 === 0;
}
return result;
}
// ビット演算でやる
function bit() {
let result = false;
for(let i = 0; i < 10000; i++) {
result = (i & 1) === 0;
}
return result;
}
結果は:
%(ミリ秒) | &(ミリ秒) | |
Chrome | ||
Firefox |
ビット演算の方が遅いですね。%も%で重い処理のはずですが、JavaScriptのビット演算は数値を32bitの整数に変換する処理が入るから……とかですかね。あとは実行エンジンが%演算をうまいこと最適化しているとか?
(2019/02/13 追記)ビット演算を囲っていなかったため演算子の優先度で i & (1 === 0)になっていました……つまりi & falseになっていたわけで、そりゃ遅いですよね。きちんと測りなおしたのが以下になります:
%(ミリ秒) | &(ミリ秒) | |
Chrome | 531 | 521 |
Firefox | 778 | 685 |
(追記続き)ビット演算の方がちょろっと速いですね。ちょろっとだけ。普通の余りの計算が高速化されてるのか、ビット演算が遅いのかはわかりませんが、ほとんど差はないですね。
さいごに
当初の目的だと完全に冷やかしというか、「もうこういう手法は時代遅れだよね〜」って再確認するための記事だったのですが、意外と性能出る手法もあってビビってます。V8とかもうカリカリにチューニングされてるイメージしかなくて、こういう小手先のテクニックが効くとは全く思っておらず……。
Flashでの最適化というとオブジェクトのプーリングなんてのもあったのですが、普通に性能出るのわかりきってるのと、扱う対象作るの面倒なので省きました。あとFlashで多かったのは画像処理周りですね。速度出すためにいろんな工夫してました。ただFlashに特化した最適化だったのでcanvasに応用できる部分がほとんどないんですよね。
そんなこんなで、意外な発見がある実験でした。将来何が役にたつかわからんもんですね。
また、冒頭にも書きましたが、こんな重箱の隅突くような最適化はできるだけ実務では避けてください。こういった妙な最適化はコードの可読性を損ないます。より重大なボトルネックがあるならば、先にそちらを解決すべきです。細かい最適化は手を出すにしても最初の最後にしましょう。