美しきBarrett乗算よ(詠嘆)
はじめに
Barrett乗算を実装してみて、その美しさに気付いたので共有します。とくに、以下の2点が顕著です。
- ゼロ除算を除く任意の
u64 % u32を高速に計算できる。当然、u32 * u32 % u32も計算できる。 - Lemireの高速剰余アルゴリズムとマジックナンバーを共有している。
他にも優れた点はたくさんあります。
注意事項
- 多倍長の演算を避けるため、64bitマシンで32bitの乗算を考えます。
- 1での除算には追加の処理が必要なので除外します。対応の一例も紹介します。
Barret乗算のここがすごい
オーバーヘッドが小さい
struct Barrett {
// 64bitで持っておくと便利
modulus: u64,
magic: u64,
}
impl Barrett {
fn new(modulus: u32) -> Self {
// 後述のように、1除算は特別な対応が必要。必要ないので切り捨てる。
assert!(modulus > 1);
let modulus = modulus as u64;
// 詳細は後述。1除算の場合のみ、オーバーフローして0になる。
let magic = (u64::MAX / modulus).wrapping_add(1);
Self { modulus, magic }
}
}
Barrett乗算はマジックナンバーを1つしか必要としません。Montgomery乗算とPlantard乗算は2つ必要です[1]。さらに、マジックナンバーの計算は除算と加算をそれぞれ1回だけです。2回使えば損失はほとんどゼロです。
時間的にも空間的にもオーバーヘッドは僅かです。
あらゆる32bit剰余乗算ができる
実装例の前にアルゴリズムの概要を説明します。Barrett乗算のマジックナンバーは、法を
任意の整数
つまり、
証明
定義より、次式が成り立ちます。
したがって、
明らかに、
以上より、Barrett乗算は次のように書けます。
impl Barrett {
fn residue64(&self, x: u64) -> u64 {
// `magic + x + 0`の上位64bit。widening_mul()はまだnightly。
let quot = self.magic.carrying_mul(x, 0);
// `quot`は32bitなので、オーバーフローしない。
// アンダーフローしたら、`quot`を1だけ大きく見積もっていたことになる。
let (residue, b) = x.overflowing_sub(quot * self.modulus);
if b { residue.wrapping_add(self.modulus) } else { residue }
}
fn mul(&self, a: u32, b: u32) -> u32 {
self.residue64(a as u64 * b as u64) as u32
}
}
1除算の対応
1除算の場合、
整数をそのまま使える
Montgomery乗算やPlantard乗算では、剰余と一対一対応した別の値を内部的に利用します。一方、Barrett乗算ではそのような工夫は必要ありません。上記の事実と合わせて次のような性質を導けます。
- 特別な初期化処理が要らない。
- 自由に法を変えることができる。
Lemireの高速剰余アルゴリズムをゼロコストで使える
Barrett乗算は商を近似することで剰余を計算します。これには正規化のコストがかかります。Lemireの高速剰余アルゴリズムでは、商を介することなく剰余を直接求めます。おもしろいことに、マジックナンバーは同じです。
64bitマシンではu32 % u32を乗算2回で計算できます。さらに、乗算1回でu32 % u32 == 0を計算できます。
impl Barrett {
fn residue32(&self, x: u32) -> u32 {
// `residue64()`では商を近似するために、`hi`を利用していた。
// 剰余を計算するには`lo`を使えば良い。
let lo = self.magic.wrapping_mul(x as u64);
// `lo * modulus + 0`の上位64bit
lo.carrying_mul(self.modulus, 0).1 as u32
}
fn can_divide(&self, x: u32) -> bool {
let lo = self.magic.wrapping_mul(x as u64);
// 1除算では`magic`がオーバーフローしているので、
// `lo <= self.magic.wrapping_sub(1)`とする。
lo < self.magic
}
}
Barret乗算のここがちょっと……
作業メモリが大きい
Barret乗算乗算はu32 * u32 % u32の計算をするために、96bitのスペースを利用します。そのため、32bit環境では多倍長の計算をせざるを得ません。一方、Montgomery乗算は64bitのスペースで良いため、mul_high()を利用して効率的な実装が可能です。64bit環境でu64 * u64 % u64をするときも同じです。
次のような使い分けが可能です。なお、数値は64bit環境での値です。
| 手法 | 法 | 特徴 |
|---|---|---|
| Barrett乗算 | 偶数を法にとれる | |
| Plantard乗算 |
|
速い |
| Montgomery乗算 |
u64の奇数 |
大きな法をとれる |
乗法逆元の計算に除算が必要
Montgomery乗算やPlantard乗算では法が奇数なので、2の乗法逆元が存在します。このため、拡張Binary GCDアルゴリズムを高速化できます[2]。Barrett乗算では偶数の法が許されるので、このような高速化はできません。拡張Binary GCDは比較的遅く、拡張Euclid互除法の方が速い傾向にあります。
まとめ
作業メモリの大きさが致命的ですが、Barrett乗算は美しいアルゴリズムです。Lemireの高速除算アルゴリズムとのマジックナンバーの一致に気付いたときは、感動してしまいました。
最後に少しだけ一般論を扱います。u{P} % u{Q}の除算で、マジックナンバーを
Discussion
Lemireの高速剰余アルゴリズムですが、被除数を32bit とする範囲では商を求めるための正規化は必要ないと思います。恐らくこのように言い換えられるでしょうか。
このとき
が成り立つ。
この主張は、k=32,\ B=2^{64} のとき次の
u32用実装に対応する。コメントありがとうございます。Barrett乗算の性質を活かした良い設計ですね。
商を求めると剰余がタダになるとは。剰余を求めると商がタダでした。投稿されたコードに1か所だけバグがあります。商を誤差なしで求める条件は次の通りです。
頂いたリンクを改変しナイーブな除算を追加しました。コンパイラに勝てるようです。
Lemireの方法で剰余は誤差なしで求め、Barrettの方法で商を高々1の誤差で求めるのが記事の結論です。商の誤差は剰余を求める際にアンダーフローしたかどうかで識別できますが、剰余の誤差を求めるのは商を求めてみる必要があります。つまり、剰余の誤差はコスト面で許容できません。
異なる手法でも近いコードが出せます。
除数が2 の場合の反例はどのようなものがあるでしょうか。おそらく、 2^{R-Q} \ge 2^P + 2^Q は商や余りを正しく計算できるかどうか示す、鋭い条件ではないと思われます。
余りに関してはまだ整理をしていませんが、商に関しては以下のようなコードで
K < ⌊⌈β/D⌉*x/β⌋ - ⌊x/D⌋を満たす最小の非負整数xを求めることができるかと思います。正確に評価しました。
これが2^R よりも小さければよいので、
ご指摘通り、R = P + Q で良いです。
もう少し分かりやすそうな命題も出しておきます。
命題
正の整数D,\beta に対し
とおく。整数x が
を満たすとき、次の等式が成り立つ。
証明
より、天井関数の基本性質から
である。
ここでx は
を満たすとする。x は整数なので
であり、また
であるから
を得る。したがって
である。
さて
とおくと、ある整数r が存在して
と書ける。よって
である。
一方、
なので、上で得た不等式より
したがって
また
ゆえに
したがって床関数をとれば
以上より
が示された。
1除算対応v2
1除算でpanicはfootgunなので。
mask = 0、mask = !0とする。最後にmaskをかける。パイプライン処理ならOK。Result<Self, E>を返し、Eは0除算と1除算をバリアントにもつ。入力を殺す必要があるかもしれないが、重たい処理をスキップできるのはうれしい。