【Rustで作る電卓】Payne-Hanek法 Rustで挑む三角関数の実装(3) 高精度レンジリダクション #20

はじめに

前回までの2回で周期性や余角の関係から範囲還元を行い三角関数の実装を行ってきました。しかし、この手法では初めに定義した定数(std::f64::consts::PI の精度(約16桁))に完全にの精度に依存してしまいます。
そこで今回は、高精度レンジリダクションの手法であるPayne-Hanek法(ペイン・ハネック法)を用いて、三角関数を巨大数でも精度を出せるように改良していこうと思います。

sinの実装と課題 : 【Rustで作る電卓】sin関数実装 Rustで挑む三角関数の実装(1) マクローリン展開と範囲還元 #18
余角とcosの実装: 【Rustで作る電卓】cos関数実装 Rustで挑む三角関数の実装(2) マクローリン展開と範囲還元 #19
Payne-Hanek法の詳細→Payne-Hanek 法詳解:ビットスライドで読み解く高精度な剰余演算と Rust による最小実装

payne-hanek法で1億ラジアンでも狂わない三角関数を実装する

1. なぜ大きな数で誤差が出るのか?

私たちが普段使っている \(x \pmod{2\pi}\) という計算は、数学的には \(x – k \cdot (2\pi)\) です。しかし、計算機内部では \(x\) に \(1/2\pi\) を掛ける動作に近い処理が行われています。(逆数をかけてから整数部分を捨てるみたいな操作です、追々modを自作するときに詳しく解説しようと思います。)
\[x \cdot \frac{1}{2\pi}\]
\(x\) が \(10^8\)(1億)のように巨大な場合、本来なら \(f64\) の精度(小数点以下16桁目以降)に隠れて見えないはずの 「\(1/2\pi\) のさらに後ろの桁」 が、掛け算によって有効数字の範囲内まで「這い上がって」きてしまいます。

「足りないなら、増やせばいい」。

これが Payne-Hanek 法の極めてシンプルな、しかし強力なコンセプトです。\(f64\) の枠を超えて、\(\pi\) の逆数(\(2/\pi\))を数百ビット分あらかじめ用意しておき、それを使って計算を行うのです。

2. Payne-Hanek 法の概要

理論を突き詰めると非常に複雑ですが、実装レベルで理解すべきポイントは以下の通りです。詳しくは、Payne-Hanek 法詳解:ビットスライドで読み解く高精度な剰余演算と Rust による最小実装にて、例を挙げながら解説しました。

  1. 高精度な定数テーブル: \(2/\pi\) を 64ビットや 128ビットの整数の塊として、必要な分だけ(通常数百〜数千ビット分)用意します。
  2. 固定小数点計算: 巨大な引数 \(x\)(浮動小数点)を整数として扱い、定数テーブルと掛け合わせます。
  3. 分数の抽出: 掛け算の結果から「整数部分」を捨て、「小数部分(\(0 \sim 1\) の範囲)」だけを抽出します。この小数部分こそが、極めて正確な「\(2\pi\) で割った余り」に対応します。

3. Rust による実装

いきなりすべてをフルスクラッチで書くと膨大な量になるため、今回はあらかじめ計算された \(2/\pi\) の断片テーブル を利用して、エッセンスを抽出したコードを紹介します。※計算で出す場合はガウス=ルジャンドル法等などを実装しないといけませんが、今回はあらかじめ計算する方針で実装します。

\(2/\pi\) のテーブルの用意の仕方は、Payne-Hanek法理論編で紹介したpythonスクリプトで計算することで得られます。これに加えて、詳しい処理の流れを紹介しているので興味があれば見てみてください。Payne-Hanek 法詳解:ビットスライドで読み解く高精度な剰余演算と Rust による最小実装

3.1 Payne-Hanek法(簡易版)

// 2/pi をビット列にしたもの(断片テーブル)を用意
const TWO_OVER_PI_TABLE: [u64; 10] = [
    0xA2F9836E4E441529, 0xFC271C4124204598, 0x23FB11315842E121,
    0xEE89B189FF1D91AD, 0x71245982399E4934, 0x61A2A83FB44A2941,
    0x6139824128084121, 0x124982399E493461, 0xA2A83FB44A294161,
    0x3982412808412161,
];

ub fn reduce_payne_hanek(x: f64) -> (f64, u64) {
    let bits = x.to_bits();
    let exp = ((bits >> 52) & 0x7ff) as i32 - 1023;
    let mantissa = (bits & 0x000f_ffff_ffff_ffff) | 0x0010_0000_0000_0000;

    let idx = (exp >> 6).max(0) as usize;
    let shift = (exp & 63) as u32;

    let m = mantissa as u128;
    // 3ワード分計算して精度を確保
    let p0 = m * (INV_PI_TABLE[idx] as u128);
    let p1 = m * (INV_PI_TABLE[idx + 1] as u128);

    // 小数点(1の位)がどこにあるかを計算
    // 理論上、m*T[idx]の結果において小数点位置は bit 116 から指数の余り分左にズレる
    // ここはマジックナンバーですが、下のコラムで説明します。
    let point_pos = 116 - shift;

    // 1. 象限(整数部の下位2ビット)の抽出
    let quadrant = (p0 >> point_pos) as u64 & 3;

    // 2. 小数部(剰余)の抽出
    // 小数点以下のビットを 128bit の固定小数として取得する
    let fraction_mask = (1u128 << point_pos) - 1;
    let frac_hi = p0 & fraction_mask;
    let frac_lo = p1;

    // 比率としての小数 [0, 1) を計算
    // frac_hi は 2^point_pos の重み、frac_lo はさらにその 2^-64 下
    let f_val = (frac_hi as f64) * 2.0f64.powi(-(point_pos as i32))
              + (frac_lo as f64) * 2.0f64.powi(-(point_pos as i32 + 64));

    // --- 精度向上のためのレンジ調整 ---
    // もし余りが 0.5 を超えていたら、次の象限に近いので反転させる
    // これにより reduced_x は常に [0, pi/4] に収まり、kernel の精度が最大化される
    let (final_frac, final_quadrant) = if f_val > 0.5 {
        (1.0 - f_val, quadrant + 1)
    } else {
        (f_val, quadrant)
    };

    let reduced_rad = final_frac * (std::f64::consts::PI / 2.0);

    (reduced_rad, final_quadrant)
}

// geminiにいい感じにコメントをつけてもらいました

3.2 Payne-Hanek法を統合した sin 関数

reduce_payne_hanekでは、割った結果と象限を返すようにしたのでそれをうけとって前回実装した分岐を実装しました。

/// Payne-Hanek法を統合した sin 関数 hanek_sin
pub fn sin(x: f64) -> f64 {
    let abs_x = x.abs();
    
    // 小さな数(1.0e6未満)は、これまでの安全な方法で計算する
    if abs_x < 1000000.0 {
        return  sin4small(x); // 前回実装したsin関数を名称変更しました。
    }

    let (reduced_x, quadrant) = reduce_payne_hanek(abs_x);
    let sign = if x < 0.0 { -1.0 } else { 1.0 };

    let res = match quadrant {
        0 => kernel_sin(reduced_x),
        1 => kernel_cos(reduced_x), // sin(pi/2 + x) = cos(x)
        2 => -kernel_sin(reduced_x), // sin(pi + x) = -sin(x)
        3 => -kernel_cos(reduced_x), // sin(3pi/2 + x) = -cos(x)
        _ => unreachable!(),
    };
    res * sign
}
let point_pos = 116 – shift;のマジックナンバー”116″

一言で言えば、116は「掛け算によって小数点が右に押し出された合計距離」です。

  • 仮数部 (52bit): 1.xxxx... という小数を整数として扱うため、小数点を右に 52回 移動。
  • テーブル (64bit): 0.yyyy... という小数を整数として保持するため、小数点を右に 64回 移動。

この2つを掛け合わせた結果、蓄積された小数点の移動距離は \(52 + 64 = 116\) ビット になります。
つまり、積(p0)の最下位ビットから数えて 116番目 が本来の「小数点」の位置です。ここから指数の重み(shift)を差し引くことで、巨大なビット列の中にある「1の位」を正確に指し示すことができます。

4. 結果:有効数字14桁に改善

Payne-Hanek法では、小さい数(0とかは特に)において不安定になる為、1000以下は以前実装した関数を直接呼出し、1000を超える数がきたら今回実装した手法で余りの計算をするといった実装にしています。

sin(11111)を自作関数で計算した結果とf64::sinを比較してみた

sin(11111)

sin(100 million)を自作関数で計算した結果とf64::sinを比較してみた

sin(“1億”)

結果を見ると、11111や1億を入力しても、小数14位まで標準関数や高精度計算と一致した結果を得られることを確認できました。

比較対象計算結果(\(sin(10^8)\))
f64::sin (Rust標準)0.931639027109726
自作sin (範囲還元版前回の簡易版)0.9316390256931886 (小数第9位以降は誤り)
自作sin (Payne-Hanek法)0.931639027109726
Python (80桁精度)0.9316390271097260080275166...

今回のPayne-Hanek法によって、前回の精度から大幅に改善したことを確認できます。

まとめ

ついに、巨大な引数に対しても標準ライブラリに匹敵する精度(14桁)を叩き出すことに成功しました。Payne-Hanek法による「ビットの境界線の切り出し」は、浮動小数点の仕組みを最大限に活用した、コンピュータサイエンスの勘所であると感じました。

さて、今回の sin の実装にはすでに kernel_cos が登場しています。これを使えば cos の実装もすぐそこです。

次回は、今回解説しきれなかった「精度の検証(なぜ14桁なのか?)」の深掘りと、今回のロジックを応用した cos の完成を目標に実装していこうと思います。
次回象限を読み替えてcosを実装する : 関連記事は、2026年5月29日に公開予定 (あと21時間)

ここまで読んでいただきありがとうございます。

では、次の記事で。 lumenHero

関連記事

以下の記事を読めば、自力で三角関数の電卓を実装できます。

シンプルなsinの実装と課題 : 【Rustで作る電卓】sin関数実装 Rustで挑む三角関数の実装(1) マクローリン展開と範囲還元 #18
余角とcosの実装: 【Rustで作る電卓】cos関数実装 Rustで挑む三角関数の実装(2) マクローリン展開と範囲還元 #19
Payne-Hanek法の詳細→Payne-Hanek 法詳解:ビットスライドで読み解く高精度な剰余演算と Rust による最小実装
巨大数対応sin関数【Rustで作る電卓】Payne-Hanek法 Rustで挑む三角関数の実装(3) 高精度レンジリダクション #20

ほかの関数の実装が気になる方以下がおすすめ

第11回 平方根の計算実装:【Rust自作TUI電卓】mod.rsを活用したスマートなモジュール管理と基本数学関数の実装 #11
第14回 ハレー法による立方根:
【Rust自作TUI電卓】ニュートン法を超えろ!ハレー法による立方根(cbrt)の極限最適化 #14
第15回 logの計算基本編:
【Rustで作る電卓】浮動小数点数の構造をハックして対数関数(log)を実装する #15

デフォルト画像 20 記事
シリーズ

Rustで作るTUI電卓入門

Rustを用い、TUI電卓を式の解析を行うLexerからPasserおよび、自作関数の読み込み機能などを盛り込んだ、自作...