HN 日本語サマリー

← 一覧へ戻る
プログラミング

Rustの新APIによる高速な浮動小数点演算

Faster floating point math with Rust's new API (pythonspeed.com)

93 pointsby subset33 コメント

要約

Rust 1.98で導入された新しいAPIにより、浮動小数点演算のパフォーマンスが向上しました。これは、コンパイラが浮動小数点演算の順序を保守的に扱うため、整数演算ほど最適化されないという問題を解決するものです。新しい「代数的」演算子を使用することで、コンパイラは浮動小数点演算の順序を変更できるようになり、SIMD命令などの最適化を活用して速度を向上させることができます。

全文翻訳

Rustの新APIによる高速な浮動小数点演算 Itamar Turner-Trauring著 最終更新日 2026年8月2日、初版 2026年8月2日 浮動小数点演算は、コンパイラがコードの最適化に関して保守的であるため、整数演算よりも遅くなることがよくあります。一部のプログラミング言語にはすでに何らかの解決策がありましたが、これまではRustにはこの制限に対処するための良い安定した方法がありませんでした。しかし、バージョン1.98から、Rustはコンパイラにコードをさらに最適化できると伝えることが可能になります。ただし、丸め誤差を最小限に抑えた数値アルゴリズムを記述できるような、追加の制御も可能です。この記事では、以下のことを学びます。 デフォルトでは、コンパイラがなぜ整数演算ほど浮動小数点演算を最適化しないのか。 この制限を解決するためのRustの新API。 この新しいAPIの使用例、その速度への影響、およびどこで使用できるかの制御方法。 整数の合計は高速です まず、ベースラインとして整数を使用した例から始め、どのようなパフォーマンスが可能かを示します。最も高速なコード生成を得るために、私はRustに、もう2004年ではないこと、そして過去約10年間のx86-64マシンという最新のハードウェアを必要とするCPU命令を生成できることを伝えます。具体的には、この記事のすべてのコードはRUSTFLAGS="-C target-cpu=x86-64-v3"でコンパイルされています。(最大限の互換性のために、実際の使用では古いコンピューター用のフォールバック実装を提供することもできます。) 以下は、int64数値のスライスを合計するRust関数です。 fn naive_sum_i64(values: &[i64]) -> i64 { let mut total = 0; for value in values { total += value; } total } これをPythonに公開するコードは省略しますが、以前の記事のRust/Pythonコードのバリアントです。ベンチマークするために、NumPyで整数の配列を作成します。 import numpy as np DATA_INT = np.ones((1_000_000,), dtype=np.int64) assert naive_sum_i64(DATA_INT) == 1_000_000 そして、この配列の合計速度を測定できます。 Code ➘ Elapsed µ-seconds ➘ CPU instructions per value naive_sum_i64(DATA_INT) 168.1 0.5 ➘ 低い方が良い これは値あたり0.5 CPU命令です!どうしてそんなことが可能なのでしょうか?おそらくコンパイラは、複数の値を一度にバッチ処理する、特殊なSingle Instruction, Multiple Data (SIMD) CPU命令を使用しているのでしょう。私が使用しているi7-12700K CPUには256ビットSIMD命令があり、これは一度に4つの64ビット整数に対して特定の操作を実行できることを意味します。特殊なSIMD合計CPU命令があれば、CPUはループを250,000回実行し、各イテレーションで4つの整数を合計するだけで済むでしょう。そして実際、以下のようになります。 Code ➘ Elapsed µ-seconds ➘ CPU instructions 256-bit SIMD integer instructions naive_sum_i64(DATA_INT) 156.8 521,280 250,003 ➘ 低い方が良い\n要するに、特殊なSIMD命令を使用することで、私のCPUは整数を非常に高速に合計できます。 浮動小数点数の合計は遅い?!しかし、浮動小数点数はどうでしょうか?それらも高速なのでしょうか?ここでも、100万個の浮動小数点値を生成します。 # 0から1の間の1M個のfloat64値の配列。 DATA = np.random.random((1_000_000,)) 単純な浮動小数点合計関数を実装します。 fn naive_sum(values: &[f64]) -> f64 { let mut total = 0.0; for value in values { total += value; } total } そして、整数と浮動小数点数の合計のパフォーマンスを比較します。 Code ➘ Elapsed µ-seconds ➘ CPU instructions 256-bit SIMD integer instructions 256-bit SIMD float instructions naive_sum_i64(DATA_INT) 151.9 521,214 250,003 0 naive_sum(DATA) 595.2 1,458,269 0 0 ➘ 低い方が良い 浮動小数点数の合計は整数合計よりもはるかに遅く、コンパイラはSIMD浮動小数点演算を使用しませんでした。なぜ違いがあるのでしょうか? 浮動小数点演算は結合法則を満たさない ほとんどのコンパイラと同様に、Rustはリリースモードでコードをコンパイルする際にコードを最適化し、(うまくいけば)高速化するためにさまざまな方法で変換します。しかし、コンパイラがこれを行う際に約束することがあります。それは、最適化されたコードが最適化されていないコードとまったく同じように動作することです。3つの整数a、b、cを足す場合、a + (b + c) == (a + b) + cです。これは、コンパイラに、例えばわずかに加算の順序を変更するSIMD操作を使用するなど、コードの実行方法を最適化するための十分な余地を与えます。 浮動小数点数は異なります。例えば、浮動小数点数は非常に小さい値から非常に大きい値まで広範囲にわたるため、十分に大きい数に十分に小さい数を加えると、その同じ大きい数になります。 print( "小さな数を加えても何も変わらないか?", 1e16 + 1.0 == 1e16 ) Does adding a small number do nothing? True より一般的に、浮動小数点数では、a + (b + c) は常に (a + b) + c と同じではありません。少なくとも、複数の数を連続して加算する場合です。1e16から始まり、その後に多くの1.0値が続く配列と、その逆の配列があるとしましょう。これらの配列を合計すると異なる結果が得られます。 import math HIGH_VALUE_FIRST = np.ones((1_000_000,), dtype=np.float64) HIGH_VALUE_FIRST[0] = 1e16 HIGH_VALUE_LAST = np.ones((1_000_000,), dtype=np.float64) HIGH_VALUE_LAST[-1] = 1e16 print( "合計は同じか?", naive_sum(HIGH_VALUE_FIRST) == naive_sum(HIGH_VALUE_LAST) ) Is the sum the same? False 合計の順序が結果に影響するため、コンパイラは、私がこの特定の順序を理由があって求めたと想定します。そして、その通りにすべきです。その結果、コンパイラはこれらの操作を並べ替えることはありません。また、結果のコードが遅くなるとしても、結果を変更する可能性のある他の最適化を適用することもありません。 Rustの新しい代数的演算子:コンパイラに柔軟性を持たせる時期を伝える コンパイラの保守的なデフォルト設定は正しいですが、プログラマーとして、操作の順序を変更しても問題ない場合を知っていることがあります。その状況では、コンパイラに、ここでは操作の順序を変更しないように、しかしあちらでは実際には問題ないことを伝えることができると良いでしょう。Rust 1.98から、それを可能にする新しい機能があります。浮動小数点数に対して通常の算術演算を実行できることに加えて、いわゆる「代数的」算術演算子の新しいセットがあり、ドキュメントによると「コンパイラが実数のすべての通常の代数的性質を使用して浮動小数点演算を最適化することを許可する」とあります。これには操作の順序の変更も含まれます。Rust 1.98のリリース日は2026年8月20日です。この記事を書いたのはそれより前なので、この記事のコードはベータ版チャンネルで実行しました。このチャンネルには同じ機能が含まれています。 例:最適化されたペアワイズ合計 これらの演算子がどのように機能し、高速なコードを可能にするかを見てみましょう。浮動小数点数の合計は予期しない動作をする可能性があるため(1e16 + 1.0 == 1e16 を思い出してください)、多数の浮動小数点数を合計したい場合は、結果の丸め誤差を最小限に抑えるために使用できるさまざまなアルゴリズムがあります。それらの間のトレードオフは、通常、速度と精度のどちらを優先するかです。numpy.sum() は、速度と累積誤差の削減のバランスが良い、ペアワイズ合計と呼ばれるアルゴリズムを主に利用しています。確かに、上記で naive_sum() で行ったように、1つずつ順に浮動小数点数を加算するよりも誤差の蓄積は少なくなります。ペアワイズ合計の基本的な考え方は、配列を2つに分割し、同じアルゴリズムで各側を再帰的に合計し、結果の2つの浮動小数点数を合計することです。配列サイズの特定のしきい値(NumPyの場合は128)を下回ると、合計は通常どおり実行されます...そしてそれは任意の順序で行うことができます。このアルゴリズムをRustで実装してみましょう。上位2つの浮動小数点数を加算する際には、通常の加算を使用します。ここでは、コンパイラは操作を並べ替えたり、出力を変更するようなことはできません。関数が通常の合計を行うしきい値に達したら、代数的加算に切り替えます。なぜなら、この時点では加算の順序は気にせず、速度だけを求めているからです。 fn pairwise_sum(values: &[f64]) -> f64 { let n = values.len(); if n > 128 { // アルゴリズムの2つの再帰的な適用による正確な加算 // let half = n / 2; // pairwise_sum(&values[0..half]) + pairwise_sum(&values[half..n]) } else { // 😎 通常の合計