検索
連載

Rustは本当に動作が高速なのか? Pythonとの比較で分かる、Rustのパフォーマンス特性WebエンジニアからみたRust(2)

Web開発者としての興味、関心に基づきRustを端的に紹介し、その強みや弱みについて理解を深める本連載。第2回は、Pythonとの比較を通じてRustのパフォーマンス特性を整理、考察します。

Share
Tweet
LINE
Hatena

 今回は、Rustのパフォーマンス特性を理解し、Pythonとの比較を通じてRustの構文、記述性を簡潔に紹介します。そのために構文、パフォーマンスを比較するための課題(要件)を設け、それぞれの言語でどのようなプログラムになるのかを確認していきます。いろいろと高速化させたり要件を変化させたりすることで、改めてRustの強み(あるいはPythonの強み)を浮き彫りにしていきます。この記事を作成するに当たり関連コードを収録したGithubリポジトリも用意しましたので、検証したい方はぜひご利用ください。

課題設定

 昨今のコロナ禍の情勢において、感染者数の7日間移動平均というデータをニュースでよく見掛けます。N日間移動平均とは、ある日次(時系列)データに対して、直近N日間の平均を計算して得られるデータのことです。例えば[10,20,30,40]という日次データに対して、2日間移動平均を計算すると、[(10+20)/2,(20+30)/2,(30+40)/2]=[15,25,35]というデータが得られます。


周期7の摂動(変動)を持つ時系列データと7日間移動平均:7日間移動平均を取ることで曜日ごとの摂動を時系列データから取り除くことができます

 大した計算ではないですが、考察を深める上で良い題材なのでこれを課題にします。今回のプログラムの要件をひとまず以下のように設定します(後で少し変更します)。

  1. 64bit浮動小数点で表現できる1000万個のデータ点がある時系列データのcsvを用意
  2. この7日間移動平均を計算し、メモリに保持する

パフォーマンス比較の形式

 PythonやRustには「line_profiler」や「criterion-rs」など、それぞれ優れたプロファイリングのためのライブラリやツールがあります。基本的にはこれらのツールを使うべきですが、計測自体が計測対象に影響を与えてしまいますし(特にline_profiler)、異なる言語間で比較する必要があるため、経過時間をprint出力する方式で進めます。環境によって結果は異なるのですが、参考までに筆者のプログラムの実行環境を以下に記載しました。

  • OS:ArchLinux(kernel 5.7.10-arch1-1)
  • CPU:AMD Ryzen 9 3950X 16-Core Processor
  • RAM:G.Skill F4-3200C16-32GVK×4(DDR4-3200 32GB×4)
  • SSD:Crucial CT1000MX500SSD1(1000GB Serial ATA 6Gb/s)

最適化無しのPythonで実装する

 まずは最適化一切なしの純粋なPythonで要件を満たすためのコードを記述しました。Pythonを利用したことがない人でも雰囲気がつかめるよう、コードにコメントを付記しています。

import csv
import math
import sys
import psutil
from datetime import datetime
from typing import List
import pandas as pd
def process_memory_usage_mb():
    """
    実行プロセスのメモリ使用量を取得する(単位はMB)
    """
    return psutil.Process().memory_info().rss/1e6
def read_csv(relative_path):
    res = []
    with open(relative_path) as f:
        reader = csv.reader(f)
        next(reader) # ヘッダをskipする
        for row in reader:
            res.append(float(row[0])) # csvの全行をfloat型に変換して読み込む
    return res
  
def calc_batch_list(calc_strategy, average_length) -> List:
    """
    csvからPythonのlistを読み込み、バッチ的に移動平均を計算させる
    """
    before_read = datetime.utcnow()  # データ読み込み前の時刻記録
    nums = read_csv("../data/time_series.csv") # データ一括読み込み 
    after_read = datetime.utcnow()  # データ読み込み後の時刻記録
    moving_averages = calc_strategy(nums, average_length)  # 関数を用いて移動平均計算
    after_calc = datetime.utcnow()  # 移動平均計算後の時刻記録
    print(f"移動平均計算に使用した関数:{calc_strategy}")
    print(f"移動平均の長さ:{average_length}")
    print(f"移動平均の最後の要素:{moving_averages[-1]}")
    print(f"csvロードにかかった時間:{after_read - before_read }秒")
    print(f"移動平均計算にかかった時間:{after_calc - after_read}秒")
    print(f"リストのメモリ使用量(参考):{sys.getsizeof(moving_averages)/1e6}MB")
    print(f"プロセスメモリ使用量(参考):{process_memory_usage_mb()}MB")
    return moving_averages
def moving_average_batch_python(nums: List, average_length: int) -> List:
    """
    Pythonのlistを使う、移動平均を素直に計算する
    """
    assert len(nums) - average_length + 1 > 0  # データが不足する場合は例外を送出する
    # 直近N日間の総計を計算しそれをNで割る
    res = [sum(nums[i-average_length+1:i+1]) / average_length for i in range(average_length-1, len(nums))]
    return res
if __name__ == "__main__":
    ma1 = calc_batch_list(calc_strategy=moving_average_batch_python, average_length=7)
言語(計算手法,移動平均長) csvロード時間 計算時間 総計算時間 変数のメモリ使用量 プロセスのメモリ使用量
Python(Naive,7) 3.3秒 2.6秒 5.9秒 89MB 870MB

 1000万行のデータを処理したことを考えれば決して悪くはない結果です。6秒以内で処理を完結させることができます。

Rustで実装する

 次にRustで要件を満たすためのコードを記述しました。エラーを扱う上で便利な「anyhow」など幾つか外部のクレート(ライブラリ)を使用しています。

// リポジトリのルートディレクトリを起点とした絶対パスを取得する(Github参照)
fn get_csv_path(relative_path: &str) -> std::path::PathBuf {
    let project_path = env!("CARGO_MANIFEST_DIR"); // RustのプロジェクトファイルCargo.tomlがあるディレクトリ
    std::path::Path::new(project_path)
        .parent() // project_pathの1つ上のディレクトリ(=リポジトリのルート)
        .unwrap() 
        .join(relative_path)
}
fn read_csv(relative_path: &str) -> anyhow::Result<Vec<f64>> {
    let csv_path = get_csv_path(relative_path); // csvデータの絶対パスを取得する
    let mut csv_reader = csv::Reader::from_path(csv_path)?;
    let nums = csv_reader
        .deserialize::<f64>() // 何もしないと行データは文字列として読み込まれるので、f64に変換する
        .filter_map(|row_result| row_result.ok()) // f64として読み込めなかった行を無視する
        .collect::<Vec<_>>(); // 可変長配列に格納する
    Ok(nums)
}
fn moving_average_batch_naive(nums: &[f64], average_length: usize) -> anyhow::Result<Vec<f64>> {
    let size = nums.len() as i64 - average_length as i64 + 1; // 出力される移動平均の数列のサイズ
    if size <= 0 {
        // サイズが0以下ならばエラー値を関数の戻り値として返す
        return Err(anyhow::anyhow!(
            "average length must be less than nums array length"
        ));
    }
    let averages = nums
        .windows(average_length) // 直近N個のデータを記憶しながらループを回す
        .map(|window| window.iter().sum::<f64>() / (window.len() as f64)) // 直近N個のデータの総和をとり、Nで割る
        .collect::<Vec<_>>(); // 結果を可変長配列に格納する
    Ok(averages) // 可変長配列を関数の戻り値として返す、returnは省略している
}
fn calc_batch<F: FnOnce(&[f64], usize) -> anyhow::Result<Vec<f64>>>(
    strategy: F,
    average_length: usize,
) -> anyhow::Result<Vec<f64>> {
    let before_read = chrono::Utc::now(); //  データ読み込み前の時刻記録
    let nums = read_csv("data/time_series.csv")?; // 指定したcsvデータをf64の可変長配列として読み取る
    let after_read = chrono::Utc::now(); // データ読み込み後の時刻記録
    let moving_averages = strategy(&nums, average_length)?; // 関数を用いて移動平均計算
    let after_calc = chrono::Utc::now(); // 移動平均計算後の時刻記録
    println!(
        "移動平均計算に使用した関数:{:?}",
        std::any::type_name::<F>()
    );
    println!("移動平均の長さ:{}", average_length);
    println!(
        "移動平均の最後の要素:{:?}",
        moving_averages[moving_averages.len() - 1]
    );
    let load_time = after_read - before_read;
    let calc_time = after_calc - after_read;
    println!(
        "csvロードにかかった時間:{:?}秒",
        load_time.num_nanoseconds().unwrap() as f64 / 1e9
    );
    println!(
        "移動平均計算にかかった時間:{:?}秒",
        calc_time.num_nanoseconds().unwrap() as f64 / 1e9
    );
    println!(
        "Vecの使用メモリ量(参考):{:?}MB",
        std::mem::size_of_val(&*moving_averages) as f64 / 1e6
    );
    println!(
        "プロセスの使用メモリ量(参考):{:?}MB",
        psutil::process::Process::new(std::process::id())
            .unwrap()
            .memory_info()
            .unwrap()
            .rss() as f64
            / 1e6
    );
    Ok(moving_averages)
}
fn main() -> anyhow::Result<()> {
    let ma1 = calc_batch(moving_average_batch_naive, 7)?;
    Ok(())
}

Rustの優れた開発体験:Rustはほとんど型推論される。型推論の結果はrust-analyzerという開発ツールを用いると非常に簡単に確認できる
言語(計算手法,移動平均長) csvロード時間 計算時間 総計算時間 変数のメモリ使用量 プロセスのメモリ使用量
Rust(Naive,7) 0.65秒 0.047秒 0.7秒 80MB 162MB

 純粋なPython実装と比べて処理が高速化されています。また、メモリ使用量についてもかなりの部分が説明可能であることも分かります。f64(8バイト)のデータが1000万行あるので、1000万×8バイト=80MBで、それがnums変数とmoving_averages変数に保持されているため160MBが必要最小限のメモリと計算できますが、それに非常に近い値となっています。

 ソースコードの面で比較してみると、RustのコードはPythonに比べてやや冗長です。冗長となっている理由を簡単にまとめると、以下のようになります。

  • 型を明示する必要がある(関数の入出力、計算時の細かな型変換など)
  • エラー値の処理を細かく記述している(読み込めない行があった場合どうするか、移動平均を計算できない場合どうするかなど)
  • (抽象化の余地があり、大した差ではないが)相対パスの解決のためにget_csv_path関数を作っている

 これらを除外して見比べてみると、RustはPythonと非常に似通ったプログラム構造で記述可能であることが分かります。

Numpyを利用したPythonで実装する

 ある程度データ分析に習熟したPython使用者にとって、上記の比較はフェアではないと思うことでしょう。Pythonが機械学習分野という多くの計算量を必要とする分野で利用され続けている理由は、それを支えるエコシステムがあるからです。その代表格といえる「Numpy」を利用して、要件を満たすコードを記述します(一度書いた関数は再利用します)。

Copyright © ITmedia, Inc. All Rights Reserved.

ページトップに戻る