背景(誰のための記事?)
JavaScriptプログラマのみなさまこんにちは。
最近のお仕事の傾向として、マイクロサービス化といいいますか、オブジェクト指向の延長といいますか、MVVM化といいますか、下回りは速度&効率重視でC++で構築し、中間は保守(メンテ)&書きやすさ+ちょっぴり速度も重視で node.js(JavaScript)、上層のUIはなるべく広範囲で使えるように考慮して HTML+CSS 、という3層構造
・上層:HTML + CSS
・中層:node.js(JavaScript)
・下層:C++
が流行っていまして、今回の事件は中層の node.js スクリプトの部分で発生した問題でした。しかも、下層で生成された巨大なデータを中層で処理し、上層で表示しようとするとある条件で遅延が発生し、表示がカクつくという問題で、その「ある条件」が長期間に渡って特定できない、、、といった難問でした。いや、もうホントに大変でした。
最終的には、中層の JavaScript 内で計算していた「ヒストグラムの算出コードがピンポイントでバグっていた」ってことで決着したのですが、これ↓が該当する JavaScript コードなんですが、どこにバグがあるかわかりますか?
わからない人、あなたが対象の読者です。
const histogram100 = Array(100).fill(0)
for ( const u16 of uint16_array )
{
histogram100[ parseInt( u16 / 655.36 ) ] ++
}
やっていることは、単純に、Uint16 (0~65535) のデータ頻度を 100 段階のヒストグラム表でカウントしているだけなんですけどね😋。
本題: parseInt() の罠、しかも2つ!!
JavaScript で実数を整数に変換したいシチュエーションは割と頻繁にあります。他の言語にある、いわゆる型変換(キャスト)に近い使い方です。
JavaScript には、実数を整数に変換するやりかたはいくつか用意されていて「どれを選ぶのがよいか」普通に悩みます。「悩むくらいなら、速度とコードの読みやすさを優先すればいい」と思い、速度的にも、記述的にも、問題のなさそうな(無難そうな) parseInt() を選択したのですが、ハマった。見事にハマった。しかも、2パターン!!もう使わない💢
さて、実数の整数化(小数点以下切り捨て)でよく見かけるこのコード。
const i = parseInt( r ) // 小数以下切り捨て
ここに問題があります。しかも2つも!!
・1, 変換速度の問題
・2, 期待通りに変換されない問題
罠1,変換速度の問題
いろいろ調査していくと、parseInt() には「ある条件」で時間がかかっていることがわかりました。その「ある条件」ってのが曲者で、普通に計測してもわからないのです。
その「ある条件」がわかるよう書いた計測コードがこちらです。
0.0 ~ 5.0 をループの回数(百万)で分割して、実数から整数に変換しています。
const { performance } = require('perf_hooks');
const p = (...a) => console.log( ...a )
const loop = 1000000 // 百万回
const m = 5 // 0.0 ~ 5.0 まで
const time = _f =>
{
const tm = Array(m).fill(0.0)
for ( const k of Array(loop).keys() )
{
const v = k / loop * m // 0.0 ~ 5.0 を生成
const s = performance.now()
const i = _f( v )
const e = performance.now()
tm[i] += ( e - s )
}
p( tm, 'ms.' ) // 測定結果を表示
}
//---------------------------------------------------
p( 'JavaScript parseInt() と Math.floor() のその速度比較' )
p( '--------' )
p( 'parseInt( v ) の速度を計測' )
time( v => parseInt( v ) )
p( '--------' )
p( 'Math.floor( v ) の速度を計測' )
time( v => Math.floor( v ) )
p( '--------' )
p( 'Math.trunc( v ) の速度を計測' )
time( v => Math.trunc( v ) )
p( '--------' )
p( 'v | 0 の速度を計測' )
time( v => v | 0 )
p( '--------' )
p( '結論 -> parseInt は 0付近のみ異常に遅い')
p( process.versions ) // node.js の version を表示
手元の PC での動作結果。
> node a.js
JavaScript parseInt() と Math.floor() のその速度比較
--------
parseInt( v ) の速度を計測
[
58.30886514484882,
9.344345089048147,
8.635206025093794,
8.61915310844779,
8.612711075693369
] ms.
--------
Math.floor( v ) の速度を計測
[
12.951601061969995,
9.484790936112404,
8.923886973410845,
8.971577974036336,
11.075583156198263
] ms.
--------
Math.trunc( v ) の速度を計測
[
9.280376160517335,
8.596518969163299,
10.931750077754259,
9.853166952729225,
8.850848948583007
] ms.
--------
v | 0 の速度を計測
[
9.219559032469988,
8.488848010078073,
10.57026295363903,
8.928800100460649,
9.20000709220767
] ms.
--------
結論 -> parseInt は 0付近のみ異常に遅い
{
node: '12.18.3',
v8: '7.8.279.23-node.39',
uv: '1.38.0',
zlib: '1.2.11',
brotli: '1.0.7',
ares: '1.16.0',
modules: '72',
nghttp2: '1.41.0',
napi: '6',
llhttp: '2.0.4',
http_parser: '2.9.3',
openssl: '1.1.1g',
cldr: '37.0',
icu: '67.1',
tz: '2019c',
unicode: '13.0'
}
>
グラフにすると parseInt() の '0' のみ突出して遅いのがよくわかります。やく5倍の違いがあります。
最初のヒストグラムのコードで言うと、'0' 付近、すなわち、なにも入っていない配列データを入れると5倍遅くなります。例えば、データが入っていれば1秒間隔でヒストグラムが表示されていたのに、ゼロばかりのデータのときは5秒間隔になる!という💢
ちなみに、むりやり論理演算 v | 0
に突っ込んで整数化の高速化を試みてもぜんぜん早くないし、意図がわかりにくい、バグの元だし、メンテ不可になるのだけなのでまったくオススメしません。
'0'付近のみ遅い理由(推測)
そもそも、parseInt() は「文字列」を整数に変換する関数です。ですので、「実数」など文字列以外の値を渡した場合は「文字列に変換されてから」parseInt() に渡り、その後、整数に変換されます。ここが最初に注意するポイントでした。
そして、parseInt() は '0x' で始まる16進数の文字列の変換をサポートしています。'0'で始まる文字列の場合のみ16進数への変換が可能かどうか、その切り分け処理に時間がかかるのではないかと推測しています。
追記:(2022-01-12)コメント欄でparseInt()の内部の情報をいただきました。ありがとうございます。たいへんたすかります。
DeepL さまのお力で日本語に変換してみた結果がこちら。
19.2.5 parseInt ( string, radix )
parseInt関数は、指定された基数に従って文字列引数の内容を解釈することによって、 整数値を生成する。
文字列の先頭の空白は無視される。
基数が未定義または 0 の場合、10 と見なされる。
ただし、数値がコードユニット 0x または 0X で始まる場合は、基数は 16 と見なされる。
基数が16の場合、数値はオプションでコードユニットの組0xまたは0Xで始まることもある。
parseInt 関数は組込みオブジェクトです。
parseInt 関数が呼ばれると、次のような手順で処理されます。
1. inputString を ToString(string)とする。
2. S を TrimString(inputString, start) とする。
3. sign を 1 とする。
4. S が空でなく、Sの最初のコードユニットが0x002D(HYPHEN-MINUS)の場合、signに-1をセットする。
5. S が空でなく、Sの最初のコードユニットがコードユニット0x002B (PLUS SIGN) またはコードユニット0x002D (HYPHEN-MINUS) である場合、
S から最初のコードユニットを削除する。
6. R を ToInt32(radix) とする。
7. stripPrefix を true とする。
8. R≠0 ならば
a. R<2 または R>36 のとき、NaN を返す。
b. R ≠ 16 ならば、stripPrefix を false に設定する。
9. Else
a. R を10に設定する。
10. stripPrefix が true の場合
a. S の長さが2以上であり、Sの最初の2つの符号単位が「0x」または「0X」である場合
i. S から最初の2つのコードユニットを削除する。
ii. R を 16 に設定する。
11. S が基数 R でない符号単位を含む場合、そのような最初の符号単位の S 内のインデックスを end とし、
そうでない場合、end を S の長さとする。
12. S の 0 から end までの部分文字列を Z とする。
13. Z が空であれば、NaNを返す。
14. Z を基数 R 表記で表した整数値を mathInt とし,10 から 35 までの数字を A-Z と a-z で表す。
(ただし,R が 10 で Z の有効桁数が 20 以上の場合は,実装の任意で 20 以後の有効桁を 0 に置換してもよい.R が 2,4,8,10,16,32 以外の場合は,基数R表記で Z で表した整数値に対して,mathInt が実装上の近似整数であってもよい)
15. mathInt = 0 のとき,次のとおりとする。
a. sign = -1 の場合、-0 を返す。
b. +0 を返す。
16. (sign × mathInt)を返します。
注意
parseIntは、文字列の先頭部分のみを整数値として解釈し、整数の記法の一部として解釈できないコード単位は無視し、
そのようなコード単位が無視されたことを示す指示は出さない。
www.DeepL.com/Translator(無料版)で翻訳しました。
わたしに理解できる範囲では、おそらくこの処理
14. Z を基数 R 表記で表した整数値を mathInt とし,10 から 35 までの数字を A-Z と a-z で表す
の内部だろうと推測しています。ということで、Z から mathInt への変換仕様を探しに行くことになりました。旅は続く、、、
罠2,期待通りに変換されない問題
上の問題に盛大にハマりましたが、深刻度としてはこっちのほうが大きい(凶悪である)と思います。
まずは以下を見てください。Chrome DevTool の Console での動作確認時のキャプチャです。
この動き。これはみなさんの期待通りに変換されていると言えますか?この動きで大丈夫ですか?
このバグにしか見えない parseInt() の動きですが「仕様の通り」のようです。
「どんな仕様だよ!!💢」と言う前に、先程も書きましたが、parseInt() は「文字列を整数に変換する関数」です。
ではどのような文字列がparseInt() に渡っているのか確認してみましょう。
おわかりでしょうか。'e'表記になっています。e表記はparseInt()のサポート外なのです。
結論 Math.floor() を使え → いや、Math.trunc() を使え
「実数」を整数に変換したいのなら
Math.floor() orMath.trunc() を使うこと。parseInt() は「文字列を」整数に変換したいときのみ使うこと。
追記:(2022-01-12)他の言語の型変換と同じ動きにしたいなら Math.floor() ではなく Math.trunc() が適切なので訂正させていただきました。Math.floor() はマイナスのときの動きが異なります。
最初のコードを書き直すと
const histogram100 = Array(100).fill(0)
for ( const u16 of uint16_array )
{
histogram100[ Math.trunc( u16 / 655.36 ) ] ++ // ❌: parseInt() -> ⭕: Math.trunc()
}
以上です。
それではみなさま、引き続きプログラミングライフをお楽しみください。
参考
追記
node.js のバージョンが古かったので、2022-01-06 時点での最新 node.js 16.13.1 で再計測してみましたが、結果に大きな違いはありませんでした。