「Rustでの14倍のスピードブーストには、Polarsプラグインの使用がおすすめです」

「ポラーズプラグインを使用することで、Rustのスピードが14倍向上する!」

native Polarsライブラリ外で高速処理を達成する方法

DALL-E 3によって生成された画像

導入

Polarsはその速度、メモリ効率、美しいAPIのおかげで世界中で注目されています。強力さを知りたい場合は、DuckDBのベンチマークをご覧ください。しかも、これらは最新バージョンのPolarsさえ使用していません。

一方、Polarsはすべての計算においてPandasよりも優れたソリューションではなかったといえます。Polarsにはパフォーマンスを上回らないいくつかの例外があります。しかし、最近リリースされたRust向けのPolarsプラグインシステムにより、それはもはや当てはまらないかもしれません。

Polarsプラグイン

Polarsプラグインとは何でしょうか?それは、ネイティブのRustを使用して独自のPolars式を作成し、それらをカスタム名前空間を使用して式に組み込む方法です。これにより、速度の高さとPolarsの組み込みツールを活用しながら、Polars DataFrameで計算を実行することができます。

具体的な例をいくつか見てみましょう。

逐次計算

Polarsには、DataFrameの以前の値を知る必要があるような演算がいくつか欠けていると見えます。連続的な性格を持つ計算は、ネイティブのPolars式で書くのが簡単で効率的ではありません。以下は具体的な例です。

以下のアルゴリズムを使用して、特定の実行(同じ符号を持つ数値のセット)に対する数値配列の累積値を計算します。例えば:

┌───────┬───────────┐│ value ┆ run_value ││ ---   ┆ ---       ││ i64   ┆ i64       │╞═══════╪═══════════╡│ 1     ┆ 1         │   # 最初の実行が開始されます│ 2     ┆ 3         ││ 3     ┆ 6         ││ -1    ┆ -1        │   # 実行がリセットされます│ -2    ┆ -3        ││ 1     ┆ 1         │   # 実行がリセットされます└───────┴───────────┘

したがって、値の符号が正から負または負から正に切り替わるたびにリセットされる列の累積和が必要です。

まず、Pandasで書かれたベースラインバージョンから始めましょう。

def calculate_runs_pd(s: pd.Series) -> pd.Series:    out = []    is_positive = True    current_value = 0.0    for value in s:        if value > 0:            if is_positive:                current_value += value            else:                current_value = value                is_positive = True        else:            if is_positive:                current_value = value                is_positive = False            else:                current_value += value        out.append(current_value)    return pd.Series(out)

シリーズを繰り返し処理し、各位置で現在の実行値を計算し、新しいPandasシリーズを返します。

ベンチマーキング

次に進む前に、いくつかのベンチマークを行います。pytest-benchmarkpytest-memrayを使用して、実行速度とメモリ使用量の両方を測定します。問題を設定し、エンティティ列、時間列、フィーチャーカラムを持つようにします。データの各エンティティに対して、時間を跨いで実行値を計算することが目的です。エンティティ数とタイムスタンプ数をそれぞれ1,000に設定し、1,000,000行のDataFrameを作成します。

私たちがPandasのgroupby apply機能を使用してベンチマークを実行すると、次の結果が得られます:

Pandas Apply Pytest-Benchmark (Image by Author)
Memray Output for Pandas Apply (Image by Author)

Polarsの素朴な実装

それでは、この同じ機能をPolarsで実装してみましょう。まずは非常に似たような見た目のバージョンから始めて、関数をPolars GroupByオブジェクトにマッピングして適用します。

def calculate_runs_pl_apply(s: pl.Series) -> pl.DataFrame:    out = []    is_positive = True    current_value = 0.0    for value in s:        if value is None:            pass        elif value > 0:            if is_positive:                current_value += value            else:                current_value = value                is_positive = True        else:            if is_positive:                current_value = value                is_positive = False            else:                current_value += value        out.append(current_value)    return pl.DataFrame(pl.Series("run", out))

さて、この結果をオリジナルのPandasのベンチマークと比較してみましょう。

Pandas Apply vs Polars Apply Pytest-Benchmark (Image by Author)
Memray Output for Polars Apply (Image by Author)

うーん、あまりうまくいきませんでしたね。しかし、それは驚くことではありません。Polarsの執筆者たちは、Pandasの非常に一般的なgroupby applyのアプローチは、Polarsでの計算を行う効率的な方法ではないことを明確に述べています。ここでは、それが示されています。速度とメモリの消費量の両方が、オリジナルのPandasの実装よりも悪いです。

Polarsの式による実装

さて、この同じ機能をネイティブのPolars式として書いてみましょう。これはPolarsで作業するための推奨される最適化された方法です。アルゴリズムは少し異なるかもしれませんが、同じ出力を計算するために以下のようなものが考え出されました。

def calculate_runs_pl_native(df: pl.LazyFrame, col: str, by: str) -> pl.LazyFrame:    return (        df.with_columns((pl.col(col) > 0).alias("__is_positive"))        .with_columns(            (pl.col("__is_positive") != pl.col("__is_positive").shift(1))            .over(by)            .fill_null(False)            .alias("__change_sides")        )        .with_columns(pl.col("__change_sides").cumsum().over(by).alias("__run_groups"))        .with_columns(pl.col(col).cumsum().over(by, "__run_groups").alias("runs"))        .select(~cs.starts_with("__"))    )

ここで行っていることを簡単に説明しましょう:

  • 特徴が正であるすべての行を見つける
  • __is_positive列が前の行と異なる行を見つける
  • __change_sidesの累積和を取り、各異なる実行をマークする
  • 各異なる実行ごとに値の累積和を取る

これでネイティブのPolars関数が完成しました。さあ、再びベンチマークを行いましょう。

Pandas Apply vs Polars Apply vs Polars Native Pytest-Benchmark(画像:著者)
Polars Native の Memray 出力(画像: 著者)

残念ながら、関数の実行速度は改善されませんでした。これは、計算をするために行わなければならないoverステートメントの数が多いためだと考えられます。しかし、期待通りメモリ使用量は減少しました。Polarsの式を使用してさらに良い方法があるかもしれませんが、今は心配しません。

Polars プラグイン

では、新しいPolarsプラグインを見てみましょう。これらの設定方法についてのチュートリアルが必要な場合は、こちらのドキュメントをご覧ください。ここでは、特定のプラグインの実装方法を紹介します。まず、Rustでアルゴリズムを記述します。

use polars::prelude::*; use pyo3_polars::derive::polars_expr; #[polars_expr(output_type=Float64)] fn calculate_runs(inputs: &[Series]) -> PolarsResult<Series> { let values = inputs[0].f64()?; let mut run_values: Vec<f64> = Vec::with_capacity(values.len()); let mut current_run_value = 0.0; let mut run_is_positive = true; for value in values { match value { None => { run_values.push(current_run_value); } Some(value) => { if value > 0.0 { if run_is_positive { current_run_value += value; } else { current_run_value = value; run_is_positive = true; } } else if run_is_positive { current_run_value = value; run_is_positive = false; } else { current_run_value += value; } run_values.push(current_run_value); } } } Ok(Series::from_vec("runs", run_values)) }

ここで気づくでしょうが、これはPythonで書いたアルゴリズムとかなり似ています。ここでは特別なRustのテクニックは使っていません! polarsが提供するマクロを使用して出力タイプを指定し、それだけです。その後、新しい関数を式として登録することができます。

from polars import selectors as cs from polars.utils.udfs import _get_shared_lib_location lib = _get_shared_lib_location(__file__) @pl.api.register_expr_namespace("runs") class RunNamespace: def __init__(self, expr: pl.Expr): self._expr = expr def calculate_runs( self, ) -> pl.Expr: return self._expr.register_plugin( lib=lib, symbol="calculate_runs", is_elementwise=False, cast_to_supertypes=True, )

そして、次のように実行することができます:

from polars_extension import RunNamespace df.select( pl.col(feat_col).runs.calculate_runs().over(entity_col).alias("run_value")).collect()

では、結果をチェックしてみましょう!

All Implementations Pytest-Benchmark(画像:著者)
Polars Plugin のメモリ出力(画像:著者)

これこそが私たちが求めていたものです!14倍の速度向上と、メモリ割り当て量が約57MiBから約8MiBに減少しました。

Polarsプラグインを使用するタイミング

プラグインの利用方法の力を示したので、いつプラグインを使用しない方が良いかについて話しましょう。プラグインを使用しない理由(それぞれに注意が必要です):

  • ネイティブのPolars式を使って非常に高速なバージョンの計算を簡単に記述できる場合。 Polarsの開発者は本当に優秀です。彼らよりも劇的に高速な関数を書けるとは、私自身は賭けません。Polarsのツールはそこにあります。彼らの得意な分野を活用しましょう!
  • 計算に自然な並列処理が存在しない場合。 例えば、上記の問題を複数のエンティティで実行していなかった場合、スピードアップは著しく少なくなるでしょう。私たちはRustの速度と、Polarsが複数のグループに一度にRust関数を適用する自然な能力の両方の恩恵を受けました。
  • 最高の速度やメモリパフォーマンスが必要でない場合。 多くの人々は、Rustを書く方がPythonを書くよりも難しく時間がかかると考えるでしょう。ですので、関数の実行に2秒かかっても200ミリ秒かかっても問題ない場合は、プラグインを使用する必要はありません。

上記のことを念頭に置いて、以下は私がプラグインを使用しなければならないと感じるいくつかの要件です:

  • 速度とメモリが非常に重要であること。 最近、データパイプラインの多くの機能をPolarsプラグインで書き直しました。Polarsと他のツールを行ったり来たりしていたため、メモリ割り当てが大きくなりすぎました。望んでいたデータ量でパイプラインを実行することが困難になっていました。プラグインにより、同じパイプラインをはるかに短い時間で実行し、より小さなマシンで実行することが容易になりました。
  • ユニークなユースケースがあること。 Polarsには多くの組み込み関数が存在します。しかし、それは広範な問題に広く適用可能な汎用ツールセットです。時には、そのツールセットが解決しようとしている問題に特に適用できない場合があります。そのような場合には、プラグインがまさに求めているものかもしれません。私が遭遇した中でも最も一般的な例は、クロスセクショナルな線形回帰や、ここで示したような逐次(行ベース)の計算です。

新しいプラグインシステムは、Polarsが既に備えているカラムベースの計算に完璧な補完となります。この追加により、Polarsは機能の美しい拡張性を可能にしています。独自のプラグインを作成するだけでなく、プラグインを自分で作成する必要がないまま、開発されるいくつかの素晴らしいPolarsプラグインパッケージにも注目してください!

Polarsは急速に進化し、話題を集めています。このプロジェクトを見て、使用を開始し、彼らがリリースしてくれる他の素晴らしい機能にも注意してください。そして、Rustを少し学び始めるかもしれません!

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more