DEVGRU

プログラミングと競馬予想について書きます

JavaScript の正規表現リテラルの評価タイミングとパフォーマンス

正規表現リテラルと正規表現オブジェクトの評価について、誤解していたのでメモ。

広告

以下の2つのJavaScriptコードを実行した際のパフォーマンスを考える。

regexp-literal.js

for (let i = 0; i < 1000000; i++) {
        /^(3|5|9)/.test(i);
}

regexp-object.js

for (let i = 0; i < 1000000; i++) {
        new RegExp("^(3|5|9)").test(i);
}

手元のV8で実行するとこうなる。正規表現リテラルのほうが正規表現オブジェクトより2倍速い。

time d8 regexp-literal.js
d8 regexp-literal.js  0.10s user 0.03s system 98% cpu 0.135 total
time d8 regexp-object.js
d8 regexp-object.js  0.20s user 0.03s system 97% cpu 0.236 total

しかし、この差がどこから来るのかという点を完全に誤解していた。

最初は正規表現リテラルはコンパイル時に一度作られたっきりで以降は再利用されると思っていたが、どうやら違うようだ。

つまり、このようなコードに近いと思っていた。

regexp-literal-optimized.js

const r = /^(3|5|9)/.test(i);
for (let i = 0; i < 1000000; i++) {
        r.test(i);
}

しかし、それが違うということはバイトコードを出力するとわかる。

$ d8 --print-bytecode regexp-literal.js
[generated bytecode for function:  (0x3b600821248d <SharedFunctionInfo>)]
Parameter count 1
Register count 5
Frame size 40
         0x3b6008212512 @    0 : 0b                LdaZero
         0x3b6008212513 @    1 : 26 f9             Star r1
         0x3b6008212515 @    3 : 0d                LdaUndefined
         0x3b6008212516 @    4 : 26 fa             Star r0
         0x3b6008212518 @    6 : 01 0c 40 42 0f 00 LdaSmi.ExtraWide [1000000]
         0x3b600821251e @   12 : 6a f9 00          TestLessThan r1, [0]
         0x3b6008212521 @   15 : 9b 29             JumpIfFalse [41] (0x3b600821254a @ 56)
         0x3b6008212523 @   17 : 7a 00 01 00       CreateRegExpLiteral [0], [1], #0
         0x3b6008212527 @   21 : 26 f7             Star r3
         0x3b6008212529 @   23 : 28 f7 01 02       LdaNamedProperty r3, [1], [2]
         0x3b600821252d @   27 : 26 f8             Star r2
         0x3b600821252f @   29 : 12 02             LdaConstant [2]
         0x3b6008212531 @   31 : 26 f6             Star r4
         0x3b6008212533 @   33 : 25 f9             Ldar r1
         0x3b6008212535 @   35 : 35 f6 04          Add r4, [4]
         0x3b6008212538 @   38 : 26 f6             Star r4
         0x3b600821253a @   40 : 5a f8 f7 f6 05    CallProperty1 r2, r3, r4, [5]
         0x3b600821253f @   45 : 26 fa             Star r0
         0x3b6008212541 @   47 : 25 f9             Ldar r1
         0x3b6008212543 @   49 : 4d 07             Inc [7]
         0x3b6008212545 @   51 : 26 f9             Star r1
         0x3b6008212547 @   53 : 8b 2f 00          JumpLoop [47], [0] (0x3b6008212518 @ 6)
         0x3b600821254a @   56 : 25 fa             Ldar r0
         0x3b600821254c @   58 : ab                Return
Constant pool (size = 3)
Handler Table (size = 0)
Source Position Table (size = 0)

CreateRegExpLiteralTestLessThanJumpIfFalse の内側にあることから、ループ内で正規表現リテラルの評価が行われている事がわかる。

評価タイミングが予想していたのと全く異なるのだ 1

ちなみに正規表現オブジェクトの場合はこのようになる。

$ d8 --print-bytecode regexp-object.js
[generated bytecode for function:  (0x16e00821248d <SharedFunctionInfo>)]
Parameter count 1
Register count 5
Frame size 40
         0x16e008212516 @    0 : 0b                LdaZero
         0x16e008212517 @    1 : 26 f9             Star r1
         0x16e008212519 @    3 : 0d                LdaUndefined
         0x16e00821251a @    4 : 26 fa             Star r0
         0x16e00821251c @    6 : 01 0c 40 42 0f 00 LdaSmi.ExtraWide [1000000]
         0x16e008212522 @   12 : 6a f9 00          TestLessThan r1, [0]
         0x16e008212525 @   15 : 9b 35             JumpIfFalse [53] (0x16e00821255a @ 68)
         0x16e008212527 @   17 : 13 00 01          LdaGlobal [0], [1]
         0x16e00821252a @   20 : 26 f7             Star r3
         0x16e00821252c @   22 : 12 01             LdaConstant [1]
         0x16e00821252e @   24 : 26 f6             Star r4
         0x16e008212530 @   26 : 25 f7             Ldar r3
         0x16e008212532 @   28 : 66 f7 f6 01 03    Construct r3, r4-r4, [3]
         0x16e008212537 @   33 : 26 f7             Star r3
         0x16e008212539 @   35 : 28 f7 02 05       LdaNamedProperty r3, [2], [5]
         0x16e00821253d @   39 : 26 f8             Star r2
         0x16e00821253f @   41 : 12 03             LdaConstant [3]
         0x16e008212541 @   43 : 26 f6             Star r4
         0x16e008212543 @   45 : 25 f9             Ldar r1
         0x16e008212545 @   47 : 35 f6 07          Add r4, [7]
         0x16e008212548 @   50 : 26 f6             Star r4
         0x16e00821254a @   52 : 5a f8 f7 f6 08    CallProperty1 r2, r3, r4, [8]
         0x16e00821254f @   57 : 26 fa             Star r0
         0x16e008212551 @   59 : 25 f9             Ldar r1
         0x16e008212553 @   61 : 4d 0a             Inc [10]
         0x16e008212555 @   63 : 26 f9             Star r1
         0x16e008212557 @   65 : 8b 3b 00          JumpLoop [59], [0] (0x16e00821251c @ 6)
         0x16e00821255a @   68 : 25 fa             Ldar r0
         0x16e00821255c @   70 : ab                Return
Constant pool (size = 4)
Handler Table (size = 0)
Source Position Table (size = 0)

こちらはRegExpがないが、Construct のところで new RegExp() の評価をしていることがわかる。


さて、じゃあどこから実行時間の差が来るのか、というところをバイトコードを踏まえて考察すると、正規表現オブジェクトのコンストラクタ呼び出しにかかるコストの差ではないかと予想される。バイトコードで直に呼び出すのとの少しだけ差が実行時間に現れた、という理解をしている。

さて、ループ外で正規表現リテラルを作ってそれを参照する場合にはどの程度速くなるのだろうか。

time d8 regexp-literal-optimized.js                                                                                                                                                          
d8 regexp-literal-optimized.js  0.10s user 0.03s system 93% cpu 0.135 total

0.10s → 0.10s なので、ほとんど変わらない。

この謎はV8のソースコードの compilation-cache.h30) で解けた。正規表現をキャッシュしているのである。

つまるところ、ループ内外や正規表現リテラルかオブジェクトの違いに関係なく、一番重い処理である正規表現のコンパイルは1度しか行われず、そこに至る道の微妙な違いがパフォーマンスの差として出てくるだけだった。

よくよく考えると、コンパイル時評価はパフォーマンスとメモリフットプリントの観点で問題がある。使わない正規表現がたくさんある場合にコンパイル時評価をするとその分パフォーマンス低下が起きるし、メモリも無駄に消費する。それを考えると、V8 のように on-demand で評価するのが正解なのだ。


  1. この誤解の原因は明白で、コンパイル時に正規表現リテラルを評価する実装を作ったことがあるからだ