なぜ Ruby はこれよりも遅いのか

概要

ビット演算子は通常の演算子よりも速いはずだと常々思っていたので、Ruby num >> 1 が num/2 よりも遅いことに驚きました。

# ruby 3.0.3

require "benchmark/ips"

Benchmark.ips do |bm|
    num = 1000000

    bm.report("num/2") do
        num/2
    end

    bm.report("num >> 1") do
        num >> 1
    end

    bm.compare!
end
# num/2:        16045584.8 i/s
# num >> 1:     14591335.3 i/s - 1.10x  slower


Benchmark.ips do |bm|
    num = 1000000

    bm.report("num * 2") do
        num * 2
    end

    bm.report("num << 1") do
        num << 1
    end

    bm.compare!
end
# num * 2:      18252815.2 i/s
# num << 1:     14289700.6 i/s - 1.28x  slower

解決策

コメントによると、テストに実際の反復を含めることに失敗していました。これは修正され、それに応じてコードと結果が更新されました。

Benchmark/ips はコア gem や Ruby 標準ライブラリの一部ではないため、テストしませんでした。ただし、標準の Benchmark#bmbm メソッドを使用したいくつかのリファクタリングされたテストに基づくと、Ruby の一部のバージョンでの String 割り当てのオーバーヘッドによって結果の一部が歪められる可能性があるようです。凍結を使用しない場合、複数の実行で若干の差異が見られることに気づきました。文字列。また、C Ruby で YJIT を有効にしないと、若干安定した結果が得られました。これは、最適化によってアプリケーション全体の速度が向上した場合でも、可変量のオーバーヘッドが追加されるようです。ただし、Ruby 3.3.0 では、凍結文字列の有無や、YJIT が有効かどうかに関係なく、大きな違いはわかりませんでした。

Ruby 3.3.0 で凍結された文字列を使用する次のことを考えてみましょう。

# frozen_string_literal: true

require "benchmark"

NUM  = 1_000_000
ITER = 100_000_000

Benchmark.bmbm do |x|
    x.report("NUM / 2") do ITER.times { NUM / 2 } end
    x.report("NUM >> 1") do ITER.times { NUM >> 1 } end

    x.report("NUM * 2") do ITER.times { NUM * 2 } end
    x.report("NUM << 1") do ITER.times { NUM << 1 } end
end

YJIT と凍結文字列の両方を有効にした Ruby 3.3.0 で 1 億回の反復でも、ruby –yjit shft_ops.rb を使用して 1 億回反復すると、一貫して次のプラスまたはマイナス数ミリ秒が得られます。

Rehearsal --------------------------------------------
NUM / 2    2.172535   0.000201   2.172736 (  2.176693)
NUM >> 1   2.257154   0.000226   2.257380 (  2.261687)
NUM * 2    2.078395   0.000204   2.078599 (  2.081848)
NUM << 1   2.101787   0.000122   2.101909 (  2.104296)
----------------------------------- total: 8.610624sec

               user     system      total        real
NUM / 2    2.153104   0.000117   2.153221 (  2.156942)
NUM >> 1   2.296988   0.000197   2.297185 (  2.301160)
NUM * 2    2.114520   0.000157   2.114677 (  2.118208)
NUM << 1   2.121999   0.000158   2.122157 (  2.125518)

1 億回の反復では、反復ごとに実時間の 3 µs 未満しかかかりません。私にとっては、実用的な目的には十分な速さだと思われます。各メソッドの合計時間が最高値と最低値の間で反復ごとに約 1.44 フェムト秒しか変わらない場合、1 秒あたりの反復数を心配するのは、最も厳密なユースケース以外では不合理に思えます。より一般的な使用例では、その小さなデルタは重要ではない可能性があります。ただし、別のエンジンを使用すると、さらに良い結果を得ることができます。

リファレンス Ruby 実装よりも高速なパフォーマンスが本当に必要な場合は、スレッド、ラクター、または同時実行性を最適化するマルチコア アプローチの使用を検討してください。並列 gem は、メインライン Ruby でそれを支援します。他のエンジンでは追加の代替手段が提供される場合があります。

ファイルを呼び出すのではなく IRB 内で実行している場合は、次のように IRB を開始する必要があることに注意してください。

RUBYOPT="--enable=frozen_string_literal" irb --nocolorize

凍結された文字列がデフォルトで有効になっていることを確認し、ANSI カラーがパフォーマンスに影響を与えないようにするためです。

ループしていない場合、上記のコードは JRuby や TruffleRuby よりも C Ruby で高速に実行されたことは注目に値します。これらのエンジンはさまざまなユースケースに合わせて最適化されており、オーバーヘッドが高いため、これは予想されることですが、それでも、広範囲に反復する必要がない場合、リファレンス実装が現時点で最も高速であることは注目に値します。

本当に何百万もの操作を実行する必要がある場合、ネイティブ モードの TruffleRuby は非常に高速です。 TruffleRuby 24.0.0 で同じ 1 億回の反復を行うと、他のコード変更やエンジン固有の最適化を行わずに、ruby shft_ops.rb として実行され、次の結果が得られます。

Rehearsal --------------------------------------------
NUM / 2    0.344032   0.009242   0.353274 (  0.265151)
NUM >> 1   0.303473   0.002182   0.305655 (  0.259223)
NUM * 2    0.372648   0.001641   0.374289 (  0.337829)
NUM << 1   0.355533   0.001281   0.356814 (  0.326600)
----------------------------------- total: 1.390032sec

               user     system      total        real
NUM / 2    0.353571   0.000581   0.354152 (  0.245380)
NUM >> 1   0.337815   0.000339   0.338154 (  0.253574)
NUM * 2    0.336717   0.000570   0.337287 (  0.246457)
NUM << 1   0.351651   0.000632   0.352283 (  0.246673)

これにより、1 億回の操作にかかる時間が反復あたり約 2.54μs に短縮され、非常に大規模な操作セットに比べて合計時間が 900% 向上しました。小さいセットでは改善は大幅に小さくなりますが、ミリ秒の何分の 1 かの違いを表現しようとすると、誰かが私の計算を間違いで指摘するでしょう。

言い換えれば、別の Ruby エンジンを使用して計算を実行するだけで、必要なパフォーマンスの向上が得られる可能性があります。これは間違いなく検討する価値があります。

また、TruffleRuby を複数回実行してテストする場合、または反復回数を増やす場合、リハーサル中はシフト演算子が乗算や除算演算子を上回ることがよくありますが、ウォームアップ後はほとんど勝てないことも注目に値します。リハーサル後のベンチマーク実行中に両方のエンジンの合計時間は短縮されましたが、非常に大規模な反復では、>> メソッドは / メソッドよりも著しく長い時間がかかることがよくありましたが、<< は * と比較してはるかに小さい差を示しました。同じことが C Ruby にも当てはまり、どのタイプのほとんどの実行でも同様のパターンが示されましたが、デルタは常に幅が広くなりました。

私の知識に基づいた推測では、確実に基盤となる VM または C コードを調べる必要がありますが、Ruby コードでは * と / の方が頻繁に使用されるメソッドであると考えられます。システム プログラミングを特に対象とする言語とは異なり、乗算と除算の手法には、バイナリ シフト手法よりも多くの時間と注意が払われ、VM の最適化が行われてきたのではないかと思います。特に、>> は、何らかの理由で単に Ruby の呼び出しがより高価になる可能性があります。

とはいえ、生のパフォーマンスが目標である場合は、実際のコードのベンチマークに基づいて利用可能な最もパフォーマンスの高い方法を使用する必要があります。使用する方法に関係なく、これらの結果はほとんどの実用的な目的には十分すぎる速度であるように見えますが、問題が Ruby 言語自体ではなく、基盤となる VM またはそのエンジンのバイナリ シフトの実装にある可能性が高いことを強調しています。

それが言語や実装設計の決定ではなくバグであると思われる場合は、Ruby 問題トラッカーでいつでも報告できます。ただし、純粋に実用的な観点から見ると、すべてのオプションは、ほとんどの実用的な使用例に対して十分に高速である可能性があります。さらに、独自のネイティブ C 拡張機能を作成するか、1 秒あたりの高い命令速度で実行する必要がある数値計算アプリケーション専用のツールを Ruby から呼び出すかを常に選択できます。