🕰️

Intl.DateTimeFormat を使うときは気をつけないと50倍くらい遅くなるしメモリももりもり食う

2023/01/15に公開

tl;dr

Intl.DateTimeFormat を使うときは、インスタンスを使い回さないと時間もかかるしメモリももりもり食うんで気をつけましょう。

可愛い顔をしているが、実は重いしメモリももりもり食う

Intl.DateTimeFormat

JavaScript で、日付や時刻を人が読める形式にしたいとき、 Intl.DateTimeFormat を使う方法がある。
Intl.DateTimeFormat - JavaScript | MDN

moment.js などのライブラリを使わなくて済むし、タイムゾーンの指定なども簡単なので、最近(2023-01-15 現在)のベストプラクティス的なやり方になっていると思う。

例えば new Date('2023-01-23T01:23:45Z')"2023/01/23 10:23:45" (日本時間) という形式にするには

const date = new Date('2023-01-23T01:23:45Z');
const formated = new Intl.DateTimeFormat('ja-JP', {
    dateStyle: 'medium',
    timeStyle: 'medium',
    timeZone: 'Asia/Tokyo',
}).format(date);
console.log(formated);
// 2023/01/23 10:23:45

のようにすれば良い。

詳細は他にも紹介してる記事が色々あるのでそちらを参照して欲しい。

ある日、なんか重いしメモリリークしてる

我々も JavaScript で日時を読みやすい文字列にする場合、この Intl.DateTimeFormat を使う機会が増えてきた。例えばフロントエンドで表示する箇所だったり、日時を含む CSV を生成するだったりで、大量の Date を読みやすい文字列に変換する場所もある。

数ヶ月前のある日、そのような処理をしているサーバーサイドアプリケーションについて調べていたら、そのエンドポイントがやたら時間がかかっており、メモリ利用量も異常に多いことがわかった。

幸い、社内の Node.js 製サーバーサイドアプリケーションでは datadog の APMランタイムメトリクスが使えるようになっていたで、これを使って調査した。
すると、明らかに Intl.DateTimeFormat() を行っている箇所のパフォーマンスが悪いとわかった。

重い原因

いろいろ考えていてわかったのだが、 Intl.DateTimeFormat() はめちゃめちゃ時間のかかるインスタンス生成処理である。

まず、実際に実行して検証してみよう。

検証情報

検証コード

performance.js
const N = 100000;

// 毎度愚直に `Intl.DateTimeFormat() ` を呼び出す
/** @type { (d: Date) => string } */
const convertDateNotReuseFormatter = (d) =>
    new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium', timeStyle: 'medium' }).format(d);
const notReuseStart = performance.now();
const notReuseDate = new Date();
for (let index = 0; index < N; index++) {
    convertDateNotReuseFormatter(notReuseDate);
}
const notReuseEnd = performance.now();
console.log('notReuse:', notReuseEnd - notReuseStart);

// formatter を定数において使い回す
const formatter = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium', timeStyle: 'medium' });
/** @type { (d: Date) => string } */
const convertDateReuseFormatter = (d) => formatter.format(d);
const reuseStart = performance.now();
const reuseDate = new Date();
for (let index = 0; index < N; index++) {
    convertDateReuseFormatter(reuseDate);
}
const reuseEnd = performance.now();
console.log('reuse:', reuseEnd - reuseStart);

// (参考) Date.prototype.toLocaleDateString & Date.prototype.toLocaleTimeString
/** @type { (d: Date) => string } */
const convertDateUsingDateMethod = (d) =>
    `${d.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' })} ${d.toLocaleTimeString('ja-JP', {
        timeZone: 'Asia/Tokyo',
    })}`;
const prototypeStart = performance.now();
const prototypeDate = new Date();
for (let index = 0; index < N; index++) {
    convertDateReuseFormatter(prototypeDate);
}
const prototypeEnd = performance.now();
console.log('prototype:', prototypeEnd - prototypeStart);

検証環境

  • macOS 12.5
  • node v18.7.0
notReuse: 12338.894042015076
reuse: 259.0689169764519
prototype: 256.17283296585083

new Intl.DateTimeFormat() で生成したインスタンスを使い回せなかった場合 (notReuse) は、使いまわした場合 (reuse) に比べて 50 倍近くの時間がかかっている。
使いまわした場合は Date.prototype.toLocaleDateString() などを使った場合とほぼ同じくらい高速だったので、ぜひ使いまわしたほうが良いだろう。

ちなみに、ブラウザ(Google Chrome 108.0.5359.124(Official Build) (arm64))で同じ処理を実行した場合も代替同じくらいの差になった。

notReuse: 6720.5999999940395
reuse: 127.5
prototype: 124.90000000596046

なんでインスタンス生成が重いのか(予測)

本当はインスタンス生成が重い理由まで特定できると良かったのだが、申し訳ないことに調べられていない。
Intl.DateTimeFormat あたりのコードは C++ で書かれており、けっこう大変な複雑さだったので、心が折れてしまった。すみません。

とはいえ、よく考えるとこの処理が重いのは当たり前で、いろんなインターナショナライゼーション用の情報をインスタンス生成時に引く必要があるからであろう。
まず、日本含む各地の日時表記については、和暦の対応表なども参照しないといけないので、重そう。
さらにタイムゾーンの扱いについても、 タイムゾーン呪いの書 (知識編) などでも言われている通り、大変そう。
おそらくそのへんが魔境なので、全体的に大変な現実世界の情報を引っ張る必要があるのだろう。

ちなみに node.js は Intl の実装のために、内部的に ICU を用いているらしい。

Node.js and the underlying V8 engine use International Components for Unicode (ICU) to implement these features in native C/C++ code.
https://nodejs.org/api/intl.html#internationalization-support

ICU のパフォーマンスがどんなもんかはわからないので、ご存じの方がいたら教えて欲しい

Discussion