JSの性能・メモリ・数値:jsc で計った落とし穴と実測値

JavaScript 講座 6:2^53 超の ID、53 倍遅い BigInt、+= 文字列の罠(2ms 対 564ms)、穴あき配列、JIT 段階別 31→5ms、Map と WeakMap のリーク。

シリーズ:JavaScript 講座 6/6(上級)。 JavaScript を本番に出していて、API から受け取った ID がずれた、データが増えるほど画面が遅くなった、メモリが上がり続けた、のどれかを経験した開発者向けです。読み終えると、JavaScript の数値がどこから不正確になるかとその対処、どの文字列・配列・オブジェクトの書き方が速い/遅いのかとその理由、JIT コンパイラが計測値をどう変えるか、キャッシュとリークをどう見分けるかが分かります。

「文字列は += ではなく配列に集めて join すべき、と聞いたのですが本当ですか?」と新人から聞かれたことがあります。JavaScript の性能の助言には、ある年のあるエンジンでだけ正しかった俗説がたくさんあります。唯一の対策は、実際に出荷するエンジンで計ることです。以下の計測はすべて、Safari と iOS のすべてのブラウザの中にあるエンジン JavaScriptCore で、固定の入力・固定のシード・繰り返し実行で行い、V8(Chrome、Node.js)に持ち込めそうな結果とそうでない結果を分けて書きました。

実行環境: macOS 26.6.2 の JavaScriptCore(フレームワーク版 21624.5.1.11.3)を jsc シェルで実行、Apple Silicon Mac(M1 Pro)。Node.js、Deno、ブラウザは実行していません。時間は performance.now() で計りましたが、この jsc では 20µs 刻みでした。そのため 0.1ms 前後より短い単発の計測は載せず、5〜21 回の中央値を使い、多くは新しいプロセスで何度か実行しています。fullGC() と gcHeapSize() は jsc 専用の関数です。第 1 回のエイリアス:alias jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc

数値:double が正確さを失う境目

JavaScript の普通の数値型は 1 つだけ、IEEE 754 の 64 ビット浮動小数点(double)です。精度は 53 ビットなので、整数が正確なのは 2^53 − 1 まで、0.1 のような小数は決して正確ではありません。実務で出会う境目を 1 つのファイルにまとめました。

// numbers.js — where a double stops being exact
print(Number.MAX_SAFE_INTEGER, 2 ** 53 === 2 ** 53 + 1, Number.isSafeInteger(2 ** 53));
print(9007199254740993, 9007199254740993 === 9007199254740992);
const row = JSON.parse('{"id": 9007199254740993, "idStr": "9007199254740993"}');
print(row.id, row.idStr, BigInt(row.idStr));
print(0.1 + 0.2, 0.1 + 0.2 === 0.3, Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);
print((1.005).toFixed(2), (1.005).toPrecision(20), Math.round(1.005 * 100) / 100);
print(parseInt(0.0000005), String(0.0000005), parseInt("08"), parseInt("1e3"), Number("1e3"));
print(Number.MAX_VALUE * 2, -1 / 0, 0 / 0, Object.is(-0, 0), -0 === 0);
print([0.1, 0.2, 0.3].reduce((a, b) => a + b), Math.sumPrecise([0.1, 0.2, 0.3]));
// money: integers of the smallest unit
const pricesYen = [1980, 2480, 980]; const taxRate = 10;
print(pricesYen.reduce((a, b) => a + b) * (100 + taxRate) / 100);
const cents = [1999, 2999, 99]; print((cents.reduce((a, b) => a + b) / 100).toFixed(2));
$ jsc numbers.js
9007199254740991 true false
9007199254740992 true
9007199254740992 9007199254740993 9007199254740993
0.30000000000000004 false true
1.00 1.0049999999999998934 1
5 5e-7 8 1 1000
Infinity -Infinity NaN false true
0.6000000000000001 0.6
5984
50.97

1 行ずつ落とし穴があります。

  • 2^53 と 2^53 + 1 は同じ数です。 リテラル 9007199254740993 はソースを読んだ時点で ...992 になります。
  • JSON.parse は大きな ID を黙って変えます。 数値の id は ...992 で返り、文字列の idStr は無事でした。スノーフレーク方式の ID や 64 ビットの主キーが典型的な被害者です。
  • toFixed は 1.005 を切り上げません。 1.005 は実際には 1.00499999999999989... だからです。お金の丸めは整数で行います。
  • parseInt は文字列を受け取ります。 parseInt(0.0000005) は 5 です。数値がまず "5e-7" に変換されるからです。数値には Math.trunc を使いましょう。
  • Math.sumPrecise(ごく最近の追加、この jsc でのみ確認)は途中で丸めずに足します。0.6000000000000001 ではなく 0.6 になりました。
  • お金は最小単位(円、セント)の整数で持てば正確です。5984 と "50.97"。

よくある間違い 1:JSON の 64 ビット ID

こう書きがち: const user = await res.json(); fetch("/users/" + user.id)。サーバーは 64 ビット整数の ID を返します。

実際に起きること: 9,007,199,254,740,991 を超える ID は最も近い double に丸められ、別のレコードを取得(あるいは更新!)してしまい、エラーは一切出ません。

直し方: いちばん良いのはサーバー側でそうした ID を JSON の文字列で返すことです。サーバーを変えられないなら、新しいエンジンでは JSON.parse の reviver が元のテキストを見られ、JSON.rawJSON で数値を失わずに書き戻せます。

// bigids.js — keep 64-bit IDs exact when parsing and writing JSON
const text = '{"id": 9007199254740993, "price": 1980}';
print("plain parse:", JSON.parse(text).id);
// 1) reviver with source text (newer API: check your target runtimes)
const row = JSON.parse(text, (key, value, ctx) => key === "id" ? BigInt(ctx.source) : value);
print("reviver:", row.id, typeof row.id, row.price);
// 2) write it back as a bare JSON number without losing digits
print("stringify:", JSON.stringify(row, (k, v) => typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v));
$ jsc bigids.js
plain parse: 9007199254740992
reviver: 9007199254740993 bigint 1980
stringify: {"id":9007199254740993,"price":1980}

どちらの API もこの jsc では動きましたが、古いブラウザや Node にはありません(ここでは未実行)。頼る前に対象環境を確かめてください。JSON の数値を double で受け取るほかの言語にも同じ問題があります。PHP の JSON 数値型の記事も参考にどうぞ。

BigInt:正確で、厳格で、約 53 倍遅い

BigInt(ES2020)は大きさに制限のない整数で、末尾に n を付けて書きます。Number と黙って混ざることを拒むのは良いことで、エラーメッセージは覚えておく価値があります。

// bigint.js — exact big integers, and the errors you will meet
const big = 2n ** 64n;
print(big, typeof big, 7n / 2n, -7n / 2n, 10n ** 30n);
try { print(1n + 1); } catch (e) { print(e); }
try { print(Math.max(1n, 2n)); } catch (e) { print(e); }
try { JSON.stringify({ id: 1n }); } catch (e) { print(e); }
print(JSON.stringify({ id: 1n }, (k, v) => typeof v === "bigint" ? v.toString() : v));
print(1n == 1, 1n === 1, 2n > 1, Number(2n ** 60n), BigInt.asUintN(8, 257n));
try { BigInt(1.5); } catch (e) { print(e); }
try { new BigInt(1); } catch (e) { print(e); }
const h = new BigInt64Array(2); h[0] = 2n ** 63n - 1n; h[1] = h[0] + 1n; print(h[0], h[1]);
$ jsc bigint.js
18446744073709551616 bigint 3 -3 1000000000000000000000000000000
TypeError: Invalid mix of BigInt and other type in addition.
TypeError: Conversion from 'BigInt' to 'number' is not allowed.
TypeError: JSON.stringify cannot serialize BigInt.
{"id":"1"}
true false true 1152921504606847000 1
RangeError: Not an integer
TypeError: function is not a constructor (evaluating 'new BigInt(1)')
9223372036854775807 -9223372036854775808

BigInt の割り算は 0 方向に切り捨てます(-7n / 2n は -3n)。Math の関数は BigInt を受け取らず、JSON.stringify は拒否し、BigInt64Array の要素は C の int64_t のように 2^63 で回り込みます。Number() で戻すとまた精度を失います(...847000)。

同じループを 2 通りに書いてコストを計りました(プロセスごとに 7 回、3 プロセス)。

// bigint_speed.js — sum of i*i for i < 1,000,000 as Number and as BigInt
const N = 1_000_000;
function numSum() { let s = 0; for (let i = 0; i < N; i++) s += i * i; return s; }
function bigSum() { let s = 0n; for (let i = 0n; i < 1_000_000n; i++) s += i * i; return s; }
for (const [name, fn] of [["Number", numSum], ["BigInt", bigSum]]) {
  const ts = []; let r;
  for (let k = 0; k < 7; k++) { const t = performance.now(); r = fn(); ts.push(performance.now() - t); }
  ts.sort((a, b) => a - b);
  print(`${name}: median ${ts[3].toFixed(2)} ms  result=${r}  exact=${BigInt(r) === 333332833333500000n}`);
}
$ jsc bigint_speed.js
Number: median 0.94 ms  result=333332833333127550  exact=false
BigInt: median 49.86 ms  result=333332833333500000  exact=true

3 プロセスの中央値は Number 0.94ms、BigInt 49.86ms で、約 53 倍でした。そして Number の結果を見てください。合計が途中で 2^53 を超えたため、黙って間違っています。速さは答えが正しくなってからの話です。

判断図。2 の 53 乗を超えうる ID なら JSON では文字列のまま持ち、計算するときだけ BigInt にする。お金なら銭やセントなど最小単位の整数を Number で持つ。計算用の大量の数値なら Float64Array などの型付き配列を使う。それ以外は普通の Number のままでよい。
どの数値表現を使うか。たいていの値は Number で足りる。落とし穴は 2^53 を超える ID、お金、巨大な配列。

文字列:+= で問題ない。作っている途中で読まなければ

「文字列は += ではなく配列に集めて join」という昔からの助言を確かめました。20 万行の CSV(3,446,270 文字)を 4 通りで作り、方式ごとに新しいプロセスで 7 回ずつ、2 周実行しました。

// strings.js — build a 200,000-line CSV four ways (each strategy in its own process)
// usage: jsc strings.js -- plus | join | template | concat
const N = 200_000;
const row = (i) => i + "," + (i * 7 % 1000) + ",item" + (i % 97) + "\n";
const strategies = {
  plus()     { let s = ""; for (let i = 0; i < N; i++) s += row(i); return s; },
  join()     { const a = []; for (let i = 0; i < N; i++) a.push(row(i)); return a.join(""); },
  template() { let s = ""; for (let i = 0; i < N; i++) s = `${s}${i},${i * 7 % 1000},item${i % 97}\n`; return s; },
  concat()   { let s = ""; for (let i = 0; i < N; i++) s = s.concat(row(i)); return s; },
};
const name = arguments[0], fn = strategies[name];
const times = [];
let len = 0, check = 0;
for (let rep = 0; rep < 7; rep++) {
  const t = performance.now();
  const s = fn();
  check = s.charCodeAt(s.length >> 1);      // touch the middle: forces a flat string
  times.push(performance.now() - t); len = s.length;
}
const sorted = [...times].sort((a, b) => a - b);
print(`${name.padEnd(8)} len=${len} first=${times[0].toFixed(1)}ms median=${sorted[3].toFixed(1)}ms min=${sorted[0].toFixed(1)}ms`);
$ for v in plus join template concat; do jsc strings.js -- $v; done
plus     len=3446270 first=19.7ms median=13.8ms min=12.6ms
join     len=3446270 first=16.0ms median=15.6ms min=14.5ms
template len=3446270 first=14.1ms median=12.0ms min=10.7ms
concat   len=3446270 first=15.7ms median=13.7ms min=12.4ms

2 周目の中央値は 13.5、15.2、11.4、13.7ms でした。4 つとも互いに 30% 以内で、+= はいちばん遅くもありませんでした。JavaScriptCore は(V8 も)a + b をコピーせず、両方を指す小さなノード、つまりロープ(rope)で表すからです。コピーは誰かが文字を必要としたときに 1 回だけ起きます(平坦化、flattening)。

だとすれば、+= を遅くする方法も見えてきます。毎回文字列の中身を読み、そのたびに平坦化(全体のコピー)を起こせばよいのです。

// strings2.js — += is fine, until you read the string while it grows
const row = (i) => i + "," + (i * 7 % 1000) + ",item" + (i % 97) + "\n";
function plusOnly(N)  { let s = ""; for (let i = 0; i < N; i++) s += row(i); return s.charCodeAt(s.length >> 1); }
function plusPeek(N)  { let s = ""; let c = 0; for (let i = 0; i < N; i++) { s += row(i); c += s.charCodeAt(s.length >> 1); } return c; }
function joinPeek(N)  { const a = []; let c = 0; for (let i = 0; i < N; i++) { a.push(row(i)); c += a[a.length >> 1].charCodeAt(0); } return a.join("").length + c; }
for (const N of [5_000, 10_000, 20_000, 40_000]) {
  const r = {};
  for (const [name, fn] of [["plusOnly", plusOnly], ["plusPeek", plusPeek], ["joinPeek", joinPeek]]) {
    const ts = []; for (let k = 0; k < 5; k++) { const t = performance.now(); fn(N); ts.push(performance.now() - t); }
    ts.sort((a, b) => a - b); r[name] = ts[2].toFixed(1);
  }
  print(`N=${String(N).padStart(6)}  += only ${r.plusOnly.padStart(6)} ms   += and read middle ${r.plusPeek.padStart(7)} ms   array + read ${r.joinPeek.padStart(5)} ms`);
}
$ jsc strings2.js
N=  5000  += only    0.5 ms   += and read middle    14.3 ms   array + read   0.4 ms
N= 10000  += only    0.7 ms   += and read middle    46.3 ms   array + read   0.7 ms
N= 20000  += only    1.7 ms   += and read middle   123.0 ms   array + read   1.5 ms
N= 40000  += only    2.3 ms   += and read middle   629.7 ms   array + read   4.3 ms

N を 2 倍にすると真ん中の列はおよそ 4 倍。二乗の時間です。3 プロセスで N = 40,000 の「+= しながら読む」は 629.7、513.4、563.7ms、「+= のみ」は 2.2〜2.3ms でした。

4 万行の棒グラフ。+= で追加するだけなら 2.2 ミリ秒。配列に push して join し、毎回要素を 1 つ読んでも 3.4 ミリ秒。+= で追加しながら伸びていく文字列の中央の文字を毎回読むと 563.7 ミリ秒かかった。
4 万行の CSV 文字列を組み立てる。ただの += は速いが、毎回文字列の途中を読むと二乗の時間になる。3 プロセスの中央値(各 5 回の中央値)、macOS 26.6.2 の jsc、Apple M1 Pro。

実際のコードでは、この「読む」はたいてい無害そうなチェックに隠れています。charCodeAt の代わりによくあるチェックを 4 つ入れてみました(N = 40,000、5 回の中央値、2 プロセス)。

$ jsc strings3.js     # s += row(i); then one check per iteration
length    5.9 ms
endsWith  511.9 ms
lastChar  504.3 ms
includes  605.3 ms

2 つ目のプロセスは 1.5、486.7、476.4、587.0ms でした。s.length はロープに保存されているので安いままですが、s.endsWith(",")、s[s.length - 1]、s.includes(...) は文字が必要なので毎回全体のコピーを起こします。作りながら確認したいなら、断片を配列に持ち、最後の断片を確認しましょう。

配列:穴、混在する型、型付き配列

穴(抜けたインデックス)は undefined ではなく、メソッドごとに扱いが違います。

// holes.js — holes are not undefined
const a = new Array(3);
print(a.length, a, 0 in a, a.map(() => 1), Array.from(a, () => 1));
const b = [1, , 3];
print(b.indexOf(undefined), b.includes(undefined), Object.keys(b));
b.forEach((v, i) => print("forEach visits", i));
const u8 = new Uint8Array([250, 255]); u8[0] += 10; u8[1] = 256;
const c8 = new Uint8ClampedArray([250]); c8[0] += 10;
print(u8, c8, new Int8Array([200]));
const f32 = new Float32Array([0.1]); print(f32[0], f32[0] === 0.1);
try { new Float64Array(-1); } catch (e) { print(e); }
const t = new Int32Array(4); t.push?.(1); print(typeof t.push, t.length);
$ jsc holes.js
3 ,, false ,, 1,1,1
-1 true 0,2
forEach visits 0
forEach visits 2
4,0 255 -56
0.10000000149011612 false
RangeError: length cannot be negative
undefined 4

new Array(3).map(...) はまた穴を返しました。map は穴を飛ばすからで、Array.from は飛ばしません。indexOf は undefined がないと言い、includes はあると言います。型付き配列は長さと型が固定で、黙って回り込みます(260 は 4、256 は 0、Int8Array の 200 は -56)。Float32Array は 0.1 を普通の数値より粗く保存します。

配列の内部の種類は速度に影響するでしょうか。固定シードの生成器で double を 100 万個、5 通りで詰め、合計を 11 回計算しました(新しいプロセスで 3 周、ヒープのバイト数は fullGC() 後の gcHeapSize())。

// arrays.js — same 1,000,000 doubles, different containers (fixed LCG seed)
// usage: jsc arrays.js -- push | prealloc | holey | mixed | f64
const N = 1_000_000;
let seed = 12345; const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const kind = arguments[0];
fullGC(); const before = gcHeapSize();
let a;
if (kind === "push")      { a = []; for (let i = 0; i < N; i++) a.push(rnd()); }
if (kind === "prealloc")  { a = new Array(N); for (let i = 0; i < N; i++) a[i] = rnd(); }
if (kind === "holey")     { a = []; for (let i = 0; i < N; i++) a.push(rnd()); delete a[N >> 1]; }
if (kind === "mixed")     { a = []; for (let i = 0; i < N; i++) a.push(rnd()); a[N >> 1] = "x"; }
if (kind === "f64")       { a = new Float64Array(N); for (let i = 0; i < N; i++) a[i] = rnd(); }
fullGC(); const bytes = gcHeapSize() - before;
function sum(arr) { let s = 0; for (let i = 0; i < arr.length; i++) { const v = arr[i]; if (typeof v === "number") s += v; } return s; }
const ts = []; let s = 0;
for (let r = 0; r < 11; r++) { const t = performance.now(); s = sum(a); ts.push(performance.now() - t); }
const first = ts[0]; ts.sort((x, y) => x - y);
print(`${kind.padEnd(8)} heap=${(bytes / N).toFixed(1)} B/elem  sum first=${first.toFixed(2)}ms median=${ts[5].toFixed(2)}ms  s=${s.toFixed(3)}`);
$ for k in push prealloc holey mixed f64; do jsc arrays.js -- $k; done
push     heap=11.9 B/elem  sum first=2.24ms median=0.94ms  s=495272.410
prealloc heap=8.1 B/elem  sum first=2.32ms median=0.94ms  s=495272.410
holey    heap=11.9 B/elem  sum first=3.60ms median=3.56ms  s=495272.275
mixed    heap=11.9 B/elem  sum first=3.88ms median=3.58ms  s=495272.275
f64      heap=8.1 B/elem  sum first=3.12ms median=0.94ms  s=495272.410
配列 要素あたりのヒープ 100 万個の合計(3 周の中央値)
double 100 万個を push 11.9 B 0.94 ms
new Array(N) のあと全インデックスを埋める 8.1 B 0.94 ms
push のあと要素を1 つ delete 11.9 B 3.58 ms
push のあと文字列の要素を1 つ 11.9 B 3.58 ms
Float64Array 8.1 B 0.94 ms

100 万個の数値の中に delete 1 つ、文字列 1 つがあるだけで、その後の合計がすべて約 3.8 倍遅くなりました。エンジンが配列全体をより汎用的な格納方式に切り替え、ループが要素ごとに確認しなければならなくなったからです。push が要素あたり約 3.8 バイト多いのは、内部の格納領域が余裕を持って伸びるためです。new Array(N) で先に確保した版は、読む前に全インデックスを埋めたのでここでは問題ありませんでした。V8 では new Array(N) が穴あきの種類で始まり元に戻らないことが知られているため(ここでは未実行)、移植性を考えるなら Array.from({length: N}, fn) か push が無難です。Float64Array は同じ速さで最も小さく、穴あきにも混在にもなりえません。数値計算のコード(音声、画像、ML)が型付き配列を使う理由です。

オブジェクト:形が 1 つなら 16 より速い

エンジンは、同じキーを同じ順序で持つオブジェクトに隠れた形(shape)を共有させます(JavaScriptCore では Structure、V8 では Map や hidden class と呼ぶ)。1 種類の形しか見ないプロパティ読み出し箇所は速く、多くの形を見ると探す手間が増えます。すべて x を持つ 100 万個のオブジェクトに追加のキーを入れて形を 1、2、4、8、16 種類にし、x の合計を 21 回計算しました。

// shapes.js — same property read, objects built with 1, 4 or 16 different key orders
// usage: jsc shapes.js -- <number of shapes>
const SHAPES = Number(arguments[0]), N = 1_000_000;
const keys = ["a","b","c","d","e"];
function make(i) {                 // all objects have x; extra keys added in an order that depends on i % SHAPES
  const o = {}; const k = i % SHAPES;
  for (let j = 0; j < 4; j++) if (k & (1 << j)) o[keys[j]] = j;
  o.x = i % 10; return o;
}
const objs = Array.from({ length: N }, (_, i) => make(i));
function sumX(arr) { let s = 0; for (let i = 0; i < arr.length; i++) s += arr[i].x; return s; }
const ts = []; let s = 0;
for (let r = 0; r < 21; r++) { const t = performance.now(); s = sumX(objs); ts.push(performance.now() - t); }
ts.sort((a, b) => a - b);
print(`shapes=${String(SHAPES).padStart(2)}  median=${ts[10].toFixed(2)} ms per 1,000,000 reads  s=${s}`);
$ for n in 1 2 4 8 16; do jsc shapes.js -- $n; done
shapes= 1  median=1.02 ms per 1,000,000 reads  s=4500000
shapes= 2  median=1.12 ms per 1,000,000 reads  s=4500000
shapes= 4  median=1.54 ms per 1,000,000 reads  s=4500000
shapes= 8  median=2.90 ms per 1,000,000 reads  s=4500000
shapes=16  median=3.04 ms per 1,000,000 reads  s=4500000

3 周の中央値は 1.02、1.12、1.54、2.90、3.00ms。形が 1 つから 8 つになると同じ arr[i].x が約 2.8 倍遅くなり、そこから先は横ばいでした。実務のルールは、同じ種類のオブジェクトは 1 か所(コンストラクタかファクトリ関数)で、すべてのフィールドを同じ順序で作り、あとからプロパティを足したり delete したりしないことです。オブジェクトの大きさが違うことによるメモリ配置の影響も一部混ざっている可能性がありますが、両者は切り分けていません。

JIT のウォームアップ:初回の計測はうそをつく

JavaScriptCore には実行の段階が 4 つあります。インタプリタ(LLInt)、手早い Baseline JIT、最適化する DFG JIT、最も積極的な FTL JIT です。コードは熱くなるほど上の段階へ上がります。jsc シェルでは段階を止められるので、それぞれの効果が見えます。同じ固定の処理(10 回呼び出しのまとまり 40 個、ループ合計 80 万回)を設定ごとに 5 プロセスずつ実行しました。

// tiers.js — fixed workload, timed in batches; run with different JIT tiers switched off
function score(n) {
  let h = 0;
  for (let i = 0; i < n; i++) h = (h * 31 + i) % 1000003;
  return h / 7;
}
const batches = []; let out = 0;
const T0 = performance.now();
for (let b = 0; b < 40; b++) {
  const t = performance.now();
  for (let k = 0; k < 10; k++) out += score(2000);   // 10 calls x 2,000 iterations
  batches.push(performance.now() - t);
}
const total = performance.now() - T0;
const us = (x) => (x * 1000).toFixed(0);
print(`total=${total.toFixed(1)}ms  batch#1=${us(batches[0])}us #2=${us(batches[1])}us #5=${us(batches[4])}us #10=${us(batches[9])}us #40=${us(batches[39])}us  check=${out.toFixed(1)}`);
$ jsc --useJIT=false tiers.js
total=31.1ms  batch#1=820us #2=800us #5=800us #10=780us #40=740us  check=39407657.1
$ jsc --useDFGJIT=false tiers.js
total=25.8ms  batch#1=780us #2=620us #5=640us #10=640us #40=640us  check=39407657.1
$ jsc --useFTLJIT=false tiers.js
total=5.0ms  batch#1=500us #2=120us #5=100us #10=120us #40=120us  check=39407657.1
$ jsc tiers.js
total=5.1ms  batch#1=480us #2=120us #5=120us #10=120us #40=100us  check=39407657.1
棒グラフ。JIT をすべて止めたインタプリタだけでは 30.8 ミリ秒。ベースライン JIT を許すと 25.8 ミリ秒。最適化 JIT の DFG を許すと 5.0 ミリ秒に下がり、FTL まで含む既定設定はこの短い処理では 5.1 ミリ秒だった。
同じ固定の処理(400 回の呼び出し、ループ 80 万回)を、JavaScriptCore の JIT 段階を 1 つずつ止めて実行。5 プロセスの中央値、macOS 26.6.2 の jsc、Apple M1 Pro。

5 プロセスの中央値は、インタプリタのみ 30.8ms、Baseline 追加 25.8ms、DFG 追加 5.0ms、既定 5.1ms でした。既定設定では最初のまとまりが 480〜620µs、以降は 100〜120µsでした。初回はコードそのものより、主にインタプリタとコンパイルの時間を計っていたことになります。5ms の処理では FTL の効果はなく、50 倍長い実行(2,000 まとまり、3 プロセス)では既定 230.3ms、FTL 停止 249.0ms で約 8% 速くなりました。

実務のルールは 2 つ。ベンチマークは十分に繰り返し、初回と安定状態を分けて報告すること(この記事の計測はすべてそうしています)。そして、ユーザーは初回しか体験しないことが多いと覚えておくこと。ページ読み込み時に 1 回だけ動く関数はインタプリタの速さで動きます。4 つの設定で check の値が同じなので、同じ計算をしていることも確認できます。

メモリ:キャッシュか、リークか

ガベージコレクションのある言語は、誰も参照しなくなったオブジェクトを解放します。だから JavaScript のリークはほぼ必ず忘れていた参照で、オブジェクトをキーにした Map のキャッシュがとてもよくある原因です。

// leak.js — a "cache" keyed by objects: Map keeps them alive, WeakMap does not
// usage: jsc leak.js -- Map | WeakMap
const Kind = globalThis[arguments[0]];
const cache = new Kind();
const MB = (b) => (b / 1048576).toFixed(1) + " MB";
fullGC(); const base = gcHeapSize();
function handleRequest(i) {                     // each request object is used once, then dropped
  const req = { id: i, body: new Array(64).fill(i) };
  cache.set(req, { parsedAt: i });              // "memoize" per request
  return req.body.length;
}
let n = 0;
for (let i = 0; i < 50_000; i++) n += handleRequest(i);
fullGC();
print(`${arguments[0].padEnd(7)} after 50,000 requests + full GC: heap +${MB(gcHeapSize() - base)}  size=${cache.size ?? "(WeakMap has no size)"}`);
$ jsc leak.js -- Map; jsc leak.js -- WeakMap
Map     after 50,000 requests + full GC: heap +36.8 MB  size=50000
WeakMap after 50,000 requests + full GC: heap +0.1 MB  size=(WeakMap has no size)

3 回とも同じでした。Map はすべてのリクエストを生かしたまま(36.8MB、1 件あたり約 770 バイト、サーバーなら際限なく増える)、WeakMap は手放しました。WeakMap のエントリはキーを生かし続けないからです。代わりに、WeakMap には size がなく、列挙できず、キーはオブジェクトでなければなりません。ブラウザなら DevTools のヒープスナップショット、Node なら --expose-gc と process.memoryUsage() で確認します(どちらも未実行)。

判断表

状況 こうする ここでの実測
2^53 を超えうる整数 ID JSON は文字列、計算時だけ BigInt ...993 が ...992 に
お金 最小単位の整数 1.005 の toFixed(2) が 1.00
正確な大きい整数の計算 BigInt Number の 53 倍遅いが正確
大きな文字列を作る += でも join でもよい 3.4MB で 11〜16ms
作りながら確認する 文字列ではなく断片を確認 564ms 対 2.2ms
大きな数値配列 型付き配列か、穴なし・単一型の配列 穴あき・混在で 3.8 倍遅い
同じ種類のオブジェクトが大量 コンストラクタ 1 つ、同じキー順、delete しない 形 8 種類で 2.8 倍遅い
ベンチマーク ウォームアップし、初回と安定状態を分けて報告 初回のまとまりは 4〜5 倍遅い
オブジェクトごとのキャッシュ WeakMap 36.8MB 対 0.1MB

練習問題

  1. 予想してから jsc で実行してください。print(JSON.parse("[12345678901234567890]")[0]); print((2.675).toFixed(2)); print(Number.MAX_SAFE_INTEGER + 2);
  2. 同僚のベンチマークが関数を 1 回だけ実行して「12ms」と報告しました。tiers.js を手本に、代わりに求めるべき 2 つの数値を書き、score(2000) で計ってください。

解答

  1. 12345678901234567000、2.67、9007199254740992。20 桁の数は最も近い double に丸められ、2.675 は実際には 2.67499999...、MAX_SAFE_INTEGER + 2 は表せない 2^53 + 1 なので 2^53 に丸められます。
  2. 初回の時間と、時間がそれ以上下がらなくなるまで回したあとの安定状態の中央値です。どちらも新しいプロセスで何度か計ります。私たちの計測では、10 回呼び出しの最初のまとまりが 480〜620µs、安定状態が 100〜120µs でした。「12ms」という 1 つの数字では、どちらなのか分かりません。

正直な限界・バージョンメモ

  • エンジンは 1 つ。 数値はすべて macOS 26.6.2 の JavaScriptCore、Apple M1 Pro のものです。V8(Chrome、Node.js、Deno)は段階(Ignition、Sparkplug、Maglev、TurboFan)もヒューリスティックも違います。傾向(穴や型混在のコスト、多数の形のコスト、ウォームアップの重要性、ロープによる += の安さ)は V8 でもよく知られていますが、比率は違うはずです。V8 は実行していません。
  • jsc 専用: print、fullGC、gcHeapSize、--useJIT=false などの段階オプションは jsc シェルにしかありません。ヒープサイズは JavaScriptCore 自身の集計です。
  • タイマーの分解能: ここでの performance.now() は 20µs 刻みだったので、短い呼び出し 1 回ではなく、まとまりを計りました。ブラウザはセキュリティのためにタイマーをさらに粗くします(未実行)。
  • バージョン: 型付き配列は ES2015、BigInt と BigInt64Array は ES2020、WeakRef/FinalizationRegistry は ES2021。JSON.rawJSON、JSON.parse の reviver からの元テキスト参照、Math.sumPrecise はずっと新しく、この jsc で動くことだけを確認しました。

前回: 第 5 回:非同期、Promise、イベントループ · この記事がシリーズ最終回です。

シリーズ:1 型と等価比較 · 2 関数、スコープ、クロージャ · 3 オブジェクトと配列 · 4 クラス、プロトタイプ、モジュール · 関連:Dart の性能:JIT と AOT、メモリ · Java の JVM、JIT、GC · プログラミング学習ロードマップ

よくある質問

JSON.parse で 17 桁や 19 桁の ID が変わってしまうのはなぜですか?

JavaScript の数値はすべて 64 ビットの double で、整数が正確なのは 2^53 - 1 = 9007199254740991 までです。JSON.parse('{"id": 9007199254740993}') はエラーなしで 9007199254740992 を返しました。大きな ID は文字列でやり取りするか、新しいエンジンなら reviver の元テキスト(ctx.source)から BigInt を作ります。どちらも jsc で確認しました。

JavaScript で += による文字列連結は遅いですか?

それ自体は遅くありません。JavaScriptCore で 20 万行を += で作ると約 14ms で、配列 push + join(約 15ms)と同程度でした。伸びていく文字列の中身を毎回読むと二乗の時間になります。4 万行で += と charCodeAt 1 回を組み合わせると 564ms、+= だけなら 2.2ms でした。

BigInt は Number より遅いですか?

かなり遅いです。100 万個の i*i の合計は Number で 0.94ms、BigInt で 49.9ms、約 53 倍でした(jsc、Apple M1 Pro)。ただし Number の結果は 2^53 を超えて黙って間違っていました。2^53 を超える正確さが要る所(ID、暗号、正確なカウンタ)だけ BigInt にし、ほかは Number で十分です。

  • #javascript
  • #上級
  • #性能
  • #メモリ
  • #bigint
  • #jit
  • #jsc
  • #javascriptcore

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑