Slide 1

Slide 1 text

BCMathを高速化した一部始終 をC言語でガチ目に解説する 高町咲衣

Slide 2

Slide 2 text

自己紹介 高町 咲衣(たかまち さき) BASE株式会社 PHP Foundation コア開発者 PHP 8.4 Release Manager 歌手 / 声優 / 空手黒帯 X: @takamachi1saki

Slide 3

Slide 3 text

BCMathとは? ● 「任意精度数学関数」 ● intやfloatと違い、巨大な桁数を扱える(メモリが許すなら数万桁も大丈夫) ● floatと違い、誤差が出ない ● GMP拡張と違い、別途ライブラリを用意する必要がない しかし遅かった => PHP 8.4で大幅に高速化

Slide 4

Slide 4 text

BCMathの中身を解説 bc_struct構造体 n_len 整数部の桁数 n_scale 小数部の桁数 *n_value 値のchar配列へのポインタ 値は、小数点無しで整数部、小数 部が連続して入っている ポインタの位置は先頭(最も大き い桁の位置) n_ref 参照数 メモリ解放の判断に使用する n_sign 符号(PLUS, MINUSのenum)

Slide 5

Slide 5 text

n_valueをもう少し詳しく 数値文字の実態は「数値文字を表す文字コード」なので、そのままでは計算で きません。 n_valueには、BCD(2進化10進数)に変換した値が入っています。

Slide 6

Slide 6 text

BCD(2進化10進数)とは BCDとは、通常の16進数(2進数)の区切りで桁が制御されるデータの持ち方に対し て、A〜Fの領域を捨てて、10進数と同じように桁を制御するデータの持ち方です。 つまり、BCDではA〜Fの値は登場しません。 4-bitごとに桁を配置する方式や、8-bitごとに配置する方式があります。 BCMathは8-bitごとの配置です。

Slide 7

Slide 7 text

BCMath関数の処理の大まかな内容 1. 文字列を解析する 2. メモリを確保して、文字列をBCDへ変換する 3. 計算処理 4. 計算結果のBCDを、文字表現へ変換する

Slide 8

Slide 8 text

1.文字列を解析する 文字列を解析し、次のことを行います。 ● 数値文字として不正な文字が含まれていないか確認 ● 小数点の有無の確認 ● 整数部の桁数、小数部の桁数の算出 ● 整数部の先頭に0がある場合は、その分桁数を小さくする ● 小数部の末尾に0がある場合も、同様に小さくする 0012.789000 って、つまり 12.789 だよね

Slide 9

Slide 9 text

2.メモリを確保して、文字列をBCDへ変換する 全体の桁数が解析から算出できたので、メモリを 確保します。 確保したメモリ領域へ、数値文字をBCDに変換し た値を書き込みます。 4.計算結果のBCDを、文字表現へ変換する 関数戻り値のzend_stringに、BCDを文字表現へ戻した値を書き込みます。 (3.計算処理は一旦割愛) 1.23は3桁だか ら、3-bytes確 保!

Slide 10

Slide 10 text

BCMath(<= 8.3)の何が遅かったのか ● そもそも、特に高速化を意識した作りではなかった ● なので、実装は至ってシンプルで、解析、変換、計算など、全てスタンダー ドなループで1桁ずつ実行 64-bit CPU char 1つずつということは、 1-byteずつ実行です。 CPU的には、7-bytes無駄に なってます!

Slide 11

Slide 11 text

誰がどのように高速化に取り組んだのか Niels Dossche わたし ● 変換と解析に時間がかかりすぎてい ることを突き止めた ● 変換と解析の効率をあげることで高 速化 ● メモリ確保の回数を減らし、可能な らヒープではなくスタック領域を使 用するようにした ● 四則演算はどれも1桁ずつの計算な ので、並列計算を試みた ● 加算と減算はBCD特有の方法で並列 計算し、乗算と除算は数値をベクト ル化して並列計算を行うようにした ※Nielsが計算ロジック、私が解析などをすることもありました

Slide 12

Slide 12 text

ベクトルとは SSE2で利用できる128-bit ベクトルは、中を16個(8-bitごと)、8個(16-bit ごと)、4個(32-bitごと)など、色々な切り方をしたデータの入れ物です。 区切られた区画同士は独立していて、ベクトル同士を足し算したりしても、他の 区画へ繰り上がったりすることはありません。

Slide 13

Slide 13 text

高速化について

Slide 14

Slide 14 text

文字列解析処理

Slide 15

Slide 15 text

文字列解析処理 ① 「数字ではない文字列」を検出する処理を128-bit並列化 (1/2)

Slide 16

Slide 16 text

文字列解析処理 ① 「数字ではない文字列」を検出する処理を128-bit並列化 (2/2) つまり、値の範囲の一番下に数値文字列が来るようにズラして比較を行なう ことで、比較が1回で簡単に済むような工夫をしつつ、128-bitの並列処理を 行います。

Slide 17

Slide 17 text

文字列解析処理 ② 小数部 末尾の0を取り除く処理の並列化 0の除去なので、0と等しいかどうかをチェックしています。

Slide 18

Slide 18 text

文字列解析処理 (備考) 並列処理は、通常のループ処理に比べるとわずかにオーバーヘッドがあります。 例えば処理対象が1〜7桁だけのような場合、ループ処理の方が早くなります。 なので、例えば「01.23」は最初の0を除去するべきですが、このようなデータが入ってく ることは実用上稀なため、整数部の先頭0の除去に並列処理は使用していません。 ※「1.230000」の末尾0も稀だと思われるかもしれませんが、いくつかのDBでは、データ を取り出した時、末尾を0で埋めて固定桁で返すような場合もあるため、実務ではDBからの 値を直接渡すケースがあると考え、これについては並列処理の対象としました。 短い数値に特化させると長い文字で遅くなり、その逆もありうるため、いくつかのベンチ マークを用意して全体的なバランスを見ながら並列化する箇所を決めています。

Slide 19

Slide 19 text

メモリ確保

Slide 20

Slide 20 text

メモリ確保 元々、何のメモリを確保する必要があった? bc_struct構造体とchar配列 であるn_valueのためのメモリ を確保する必要がありました。 見ての通り、最低でも2回、メ モリを割り当てる必要がありま す。

Slide 21

Slide 21 text

メモリ確保 ① 1回で割り当てを済ませる(1/2) これは確保するメモリのサイズ計算をしているコードです。 このように、bc_structのサイズに加えて、length(整数部の桁数)とscale(小数部の桁 数)のサイズも確保しています。 (ZEND_MM_ALIGNMENT - 1) は、アライメントによるズレでメモリがオーバーしてしま うのを防ぐための記述です。 ※ 64-bit環境では、ZEND_MM_ALIGNMENTは普通、8です。境界に7置いておけばオーバーしないのです。

Slide 22

Slide 22 text

メモリ確保 ① 1回で割り当てを済ませる(2/2) 確保したメモリの後半の方をchar配列に使用します。 なので、構造体自身のポインタの位置に構造体のサイズを足し進めると、そこがn_valueの ためのメモリ領域開始のポインタです。

Slide 23

Slide 23 text

メモリ確保 ② スタック領域とヒープ領域 スタック領域は、スタック(積む)の名の通り、メモリを積み上げていきます。 なので、メモリの解放順は確保順に依存し、新しいものを解放してからでないと古いものを 解放できません。 一方、ヒープ領域はヒープ(山盛り、乱雑に積んでいるような様子)の名の通り、自由にメ モリを確保・解放することができます。 メモリ領域には2種類あり、 制約があるけれど少し早いスタック領域 制約は緩いけれど少し遅いヒープ領域 があります。

Slide 24

Slide 24 text

メモリ確保 ② スタック領域を使用する(1/2) 通常、確保するべきメモリサイズが不明な場合は、上記コードのように動的にメモリを確保 します。 この場合、メモリはヒープ領域に確保されます。 ※ bc_numは、bc_structのポインタ用の型です。

Slide 25

Slide 25 text

メモリ確保 ② スタック領域を使用する(2/2) コンパイル時点でサイズが既知である配列は、スタック領域を使用します。 上記コードのように、256-bytesのスタック領域を関数実行時に確保し、データがこれに収 まりきる場合はスタック領域を利用するようにしました。 ※確保したスタック領域は、関数終了前に解放されます。 ※BcMath\Number クラスの場合は、解放タイミングがユーザー依存でありPHP側で保証 できないため、スタック領域は使用しません。

Slide 26

Slide 26 text

メモリ確保 (備考) 例えば、累乗を計算するpow()、平方根を求めるsqrt()、値を丸めるround()など、主要な 四則演算以外では、処理の中で内部的に四則演算を利用するものがあります。 BcMath\Number クラスで解放タイミングが保証できないのはユーザーランドでオブジェ クトとして存在するものであり、処理の中で一時的に現れる(ユーザーが見ることのない) データについては、解放タイミングを保証することができます。 そのようなデータについてはスタック領域を利用できるのですが、まだそこまでは改修が進 んでおらず、PHP 8.5に向けて作業を進めていく予定です。 3.5がround()で4になる時、内 部的には3 + 1をしているよ

Slide 27

Slide 27 text

文字列をBCDへ変換する

Slide 28

Slide 28 text

文字列をBCDへ変換する 解析と同じような 並列処理を導入 残り文字数が 16文字以上 => 128-bit ベクトルで並列 8文字以上 => 64-bitの整数型で並列 それ未満 => 通常のループ処理 なぜ引き算ではなくxor? => BCDから文字列へ戻す時には逆に足すことができ、コードを流用できるため

Slide 29

Slide 29 text

計算処理

Slide 30

Slide 30 text

計算処理 BC_VECTOR という入れ物を用意しておく 四則演算全てで利用する並列処理用の入 れ物として、BC_VECTOR という型を定 義しておきます。 charが1-byteに対してこれは8-bytesな ので、(データの形式が同じなら)8桁 格納することができます。 ※32-bit環境では半分のサイズで定義し てあります。

Slide 31

Slide 31 text

足し算

Slide 32

Slide 32 text

計算処理 - 足し算 (1/5) ※全て映すと長くなるので、ルー プ内の重要な箇所だけ抜き出し BC_VECTORにデータをコピーす ることで、8桁同士の足し算を一度 に行なっています。 ところどころで出てくる0xF6が重 要なので、次ページで解説しま す。

Slide 33

Slide 33 text

計算処理 - 足し算 (2/5) BCDの1桁は0x00〜0xFFの範囲を持つ一方、数値は0〜9であるため、型 に対してかなりゆとりがある状態になっています。 そのため、繰り上がってほしい計算で繰り上がってくれません。 繰り上がりが意図した通りにならない

Slide 34

Slide 34 text

計算処理 - 足し算 (3/5) そこで0xF6を足し、「繰り上がってほしい時に繰り上がる」ように、数値 全体を底上げします。 ここで重要なのは、下図のように、「繰り上がりが起きた桁」は、0xF6の 底上げが無くなっています。 これを利用して、底上げを正確に元に戻します。 数値を底上げして繰り上がりを起こす

Slide 35

Slide 35 text

計算処理 - 足し算 (4/5) 格桁の上位4-bitは0〜9の数値で使用しないビットなので、ここを見ると「底上げ」が残っているかどう か = 繰り上がりが起きたかどうかがわかります。 繰り上がりが「起きていない桁」だけ0xF6となるようなマスクを作成します。 繰り上がりを検知して、マスクを作成する

Slide 36

Slide 36 text

計算処理 - 足し算 (5/5) 作成したマスクを、計算結果から差し引きます。 計算結果の「底上げが残っている桁」のみから0xF6が差し引かれ、BCDとして正しい値になります。 繰り上がりを検知して、マスクを作成する 最上位桁が繰り上がった場合、数値はオーバーフローを起こしますが、繰り上がった先に足すべき1が消 えてしまうだけで、他の桁と同様に底上げが無くなった状態になります。 なので、最上位桁の繰り上がりを確認し、繰り上がっている場合は変数carryに1を、そうでないなら0を セットし、次のループの計算へ繰り上がりを渡します。

Slide 37

Slide 37 text

引き算

Slide 38

Slide 38 text

計算処理 - 引き算 (1/2) 複数桁同時に計算する方法自体は、足し算と同様です。 ただし、引き算には「繰り上がり」がなく、逆に「繰り下がり」が存在するため、繰り下がりの処理方 法の部分が足し算と異なります。 並列化の手法は足し算と同様

Slide 39

Slide 39 text

計算処理 - 引き算 (2/2) 足し算では、0xF6を足して値を「底上げ」していました。 引き算の繰り下がりが起こると、足し算の「底上げ」と同じ状態になります。 なので、足し算の底上げ除去と同様の手順で、繰り下がった値をBCDとして正しい状態にします。 繰り下がった値は、足し算の「底上げ」の状態と同じ 引き算の方が考え方が簡単だね。 実は、引き算を最初に高速化したよ! だから、足し算の「底上げ」を思いつい たんだ。

Slide 40

Slide 40 text

掛け算

Slide 41

Slide 41 text

計算処理 - 掛け算 (1/6) 掛け算は足し算引き算と違い、全ての桁に対して計算を行う必要があるため、BCDのままでは効率的に 計算を行うことができません。 同じ桁が何度か計算に登場するため、BCDから64-bit整数型(32-bit環境では32-bit)である BC_VECTOR型にあらかじめ値を変換しておく方が効率よく計算できます。 足し算・引き算との違い

Slide 42

Slide 42 text

計算処理 - 掛け算 (2/6) ただBC_VECTOR型にするだけでは、BCMathの場合は不十分です。もっと長い桁数の計算もできる必 要があるためです。 ここで、BCDをchar配列で表していたことを思い出してみます。同じように、BC_VECTOR型の配列で 数値を表すことにしました。 char配列は10進数でしたが、BC_VECTOR型は1億進数として表すことにします。 1億進数にしてしまう

Slide 43

Slide 43 text

計算処理 - 掛け算 (3/6) 掛け算は、被乗数と乗数の桁数の合計が、積の桁数となります。 つまり、BC_VECTORの範囲上限限界まで使った状態で1桁を計算すると、計算結果は型に収まり切らず にオーバーフローする可能性が高くなります。 オーバーフローしない範囲で、かつ扱いやすい桁数である8桁区切りになるので、1億進数としました。 なぜ1億進数なのか? 32-bit環境ではもちろん半分だよ!

Slide 44

Slide 44 text

計算処理 - 掛け算 (4/6) 分割統治法を用いて、素直に変換するよりも少な いステップ数で変換を行ないます(Niels案) BCDをBC_VECTORへ変換する

Slide 45

Slide 45 text

計算処理 - 掛け算 (5/6) あらかじめ8桁以内(64-bit環境の場合)同士の計算 だとわかっていれば、わざわざ配列を使う必要があり ません。BC_VECTOR1つで足りるからです。 そのような場合、高速パスを使用して、余計な処理を 省いた計算を行ないます。 計算処理は通常と高速の2つの経路がある

Slide 46

Slide 46 text

計算処理 - 掛け算 (6/6) BCDに値を戻す際、ルックアップテーブ ル(LUT)を使用しています。 これにより、計算せずにBCDへの変換が 可能です(Niels案) 1億進数からBCDに戻す charは1-byte、shortは2-bytes型だよ! ちなみに、intは4、long longは8だよ

Slide 47

Slide 47 text

割り算

Slide 48

Slide 48 text

計算処理 - 割り算 (1/5) 割り算も、掛け算と同様にBC_VECTORを使用、1億進数(64-bit環境)に変換して計算を行ないます。 変換処理も概ね同じですが、BCMathでは一般的な「回復型除算」を採用しているため、「足し戻し」の 処理が必要となります。 掛け算との違い 英語圏では「復元型(レ ストアリング)」の表現 の方が一般的だよ

Slide 49

Slide 49 text

計算処理 - 割り算 (2/5) 先ほどの計算例(30 ÷ 19)では、2回の足し戻しが必要でした。 商予測を1桁同士で行なっているので、商も1桁になります。足し戻し回数が多くなったとしても、商の 値より多くの回数足し戻すことはないので、最大で1桁回。たかが知れています。 では、1億進数の場合はどうでしょう? 数千万回の足し戻し????? 一体何回足し戻せばいい?

Slide 50

Slide 50 text

計算処理 - 割り算 (3/5) 1億進数だとしても、一工夫入れると、足し戻し1回でOK!

Slide 51

Slide 51 text

計算処理 - 割り算 (4/5) なぜ上位9桁を使うと、足し戻しが1回で済むの? (10^N)進数の多倍長除算では N + 1桁の除数上位桁を使って商予測すると、商予測の誤差は最大で1である という法則があるからです。商予測の誤差が1以内なら、当然足し戻しも1回になります。 例: 10000進数なら、これは10^4なので、4 + 1で5桁の除数上位桁を使用すればOKです。 正直に言うと、この法則が割り算高速化の全てだったと言っても過言ではないかもしれません。

Slide 52

Slide 52 text

計算処理 - 割り算 (5/5) コメントで証明を書いた この法則が既に世の中で発見されて名前がついているのかは知らな いのですが、私はこれを自力で見つけて証明しました。 私とレビュアーだけ把握していればいいものではないので、BCMath ライブラリのdiv.cというファイルに、コメントで証明を残してあり ます。 85行、大体(英字で)4000文字くらいです。 もし「php-src内、コメント長い選手権」があったら優勝候補です。 証明を書くのも、証明をレビューしてもらうのも、証明関係は何も かもに時間がかかりました。 (最終的に2ヶ月くらいかかったかも...) 何はともあれ、この法則を証明できたことにより、割り算でも1億進 数で効率的な計算ができるようになりました。

Slide 53

Slide 53 text

計算結果のBCDを、文字表現へ変換する 「文字列をBCDへ変換する」でxorを利用したコード にしたことにより一括で改善 (なので、説明は特にありません)

Slide 54

Slide 54 text

今後のBCMathで考えていること(いたこと) ● データを最初からBCDではなくBC_VECTORで持ったらどうか? => 遅くなったのでやめた ● 掛け算と割り算で、小さな桁数の時はもっとスタック領域を使う(あとPR作るだけ) ● 余りの計算(mod)がまだ効率悪いので改善する(あとPR作るだけ) ● (オブジェクト使用時)一時的にしか出てこないデータにスタック領域使いたい ● BC_VECTOR => BCDへの変換時、少ない桁でもLUTを使いたい ● 「1.3E3」のような、科学記法の小数値に対応するかもしれない(要議論 & RFC?)

Slide 55

Slide 55 text

ご清聴ありがとうございました!