.NET Coreで作成していたプログラムを変更する必要ができたのですが、C#だと大量のデータを扱うための適当なライブラリーがなかったのでPythonを使ってみました。そうしたら、なんと.NET Coreの時より半分の時間で処理できるようになりました。C#はコンパイラー言語なので処理が速いと思っていたのですが、どうもそうではありません。その原因を、ベンチマークをしながら分析してみました。概要を参考までにメモしておきます。
テスト結果 - LinqはPythonより8倍遅い
100万件のCSVデータを読み込んで掛け算をして整数化後、グループ集計をしてJSONで書き出すという処理をさせて見ました。その結果は、次のとおりで、全体の処理時間では.NET Coreが3倍ぐらい遅いという結果になりました。また、列の掛け算やグリープ集計というLinqでおこなう計算の部分だけをみると8倍ぐらい遅いという結果になりました。
項目 | 処理時間 | 列の掛け算 | グループ集計 |
---|---|---|---|
Python | 0.42秒 | 0.02秒 | 0.05秒 |
.NET Core | 1.40秒 | 0.16秒 | 0.48秒 |
Pickleは爆速
入力方法を変えた場合のテスト結果は、以下のとおりです。Pythonオブジェクトをシリアル化したPickle形式で保存したものを使うと爆速です。これぐらい速く処理できればオンラインリアルタイム処理にしても問題がなくなります。
JSONとPostgrSQLでは、データの読み込み時間にあまり差がなかったので、主として計算時間の違いになっています。
項目 | Python | .NET Core |
---|---|---|
CSV | 0.42秒 | 1.40秒 |
JSON | 1.98秒 | 2.56秒 |
PostgreSQL | 2.88秒 | 3.35秒 |
Pickle | 0.12秒 | -- |
Linqが遅い理由
Linqが遅い理由は、通常のRDBで使用される行ストア、すなわち行と列を含むテーブルとして論理的に編成され行方向のデータ形式で物理的に格納されている場合は、個別のデータを取り出すのは速いのですが集計や列の計算をするのは遅いのです。MicrosoftがSQL Serverの宣伝でいっている「インメモリにして列ストア インデックスを使うと従来より100倍以上高速な分析ができる」というものです。C#において、最近Span<T>
が導入されたのも、現在のListやArrayではこうした処理を遅いためです。単純に言うとList<T>
だと、Tのメンバーxの列を取り出すのにループ処理をするしかないというのが問題なのです。
一方のPythonの方は、Pythonそのものは死ぬほど処理が遅いですが、Pythonから呼び出しているNumPyはC言語で書かれていてガリガリにチューニングされていて非常に高速なベクトル演算が可能です。
なお、Qiitaの記事LINQが遅いと言われてたので速度比較してみたでは、LINQ使うと数値上は遅くなるが「誤差の範囲」程度でしかないと結論していますが、サンプルデータにList<float>
という1列のデータを使っているためです。SQL Server等のRDBからデータを取得すると一般的には列数の多いテーブルになりますが、そういうテーブルだとLINQはforeach
で処理をするよりも2倍以上遅くなることが普通にあります。他の列が邪魔をして必要な列のデータを取得するために時間がかかるのが原因だと思われます。
結論
データ量が多くなるとはっきり言ってLINQは遅いです。LINQ がSpan<T>
でガリガリにチューニングされるまでは、お金があれば SQL Server Enterprise を買って(借りて)SQL Serverのインメモリで処理させましょう。でも高いです。Azureで、16コア、メモリー128GBのLinuxマシンが1年間予約で月6万2千円で借りられますが、それにSQL Server Enterprise をいれると月55万円になってしまいます。
普通の人は、Pythonを勉強してPandas, Numpyを使った方が、処理速度も速くなる上に、プログラムを書く時間も短縮できます。データサイエンティストがPythonを使うから、Pythonではデータ処理がより速くより効率的になるようにどんどん進化しています。一方で、LINQ がSpan<T>
でガリガリにチューニングするって誰がするですかね?MicrosoftはそれをするよりSQL Serverを売ったほうがずっといいのです。C#とPythonを比較するのではなくて、PythonをSQLと同じものと思ってしまえばいいのです。そう思えば、Pythonは通常の業務処理でも使えます。業務処理の日次処理や月次処理のかなりの部分はデータの集計や分析からできているはずです。
業務といえば事務処理ではExcelがよく使われますが、ExcelにPythonが搭載されれば、ExcelのワークシートとPandasのDataFrameは非常に相性がいいので、VBAで行っていた処理をPythonの豊富なライブラリを使って簡単に処理でき、そして処理速度はうまく使えばVBAと比べると爆速になります。レコード数100万件でテストしたのは、Excelで扱える最大行数が1,048,576行なので、それを意識しています。ExcelにPythonが搭載されることを楽しみにして待ちたいと思っています。
サンプルデータ
サンプルデータは、Pythonが得意なので、Pythonの方で作成しました。aとbが文字列、xとyがdoubleのレコード件数は100万件です。例えば、項目が品番、得意先、数量、単価でできている売上データと思ってもらえればいいと思います。作成に使ったコードは、以下のとおりです。
import pandas as pd
import numpy as np
from sqlalchemy import create_engine
# レコード数
N = 1000000
chars1 = pd.util.testing.rands_array(5, 100)
chars2 = pd.util.testing.rands_array(5, 10000)
df = pd.DataFrame({'a': np.random.choice(chars1, size=N),
'b': np.random.choice(chars2, size=N),
'x': np.random.rand(N) * 100,
'y': np.random.rand(N) * 100})
df['x'] = df['x'].round(2)
df['y'] = df['y'].round(2)
df.to_csv("test.csv", index=False, float_format='%.2f')
df.to_json('test.json', orient='records', double_precision=2)
engine = create_engine('postgresql://weather:xxxxxxx@localhost/test')
df.to_sql('test', engine, if_exists='append')
df.to_msgpack('test.msgpack')
df.to_pickle('test.pkl')
テスト用プログラム - Python
csvファイルになっているデータを読み込んだあとで、xとyを掛け算した後で整数化しています。数量と単価を掛けて金額を出すという処理です。1円未満を切り捨てる処理をしていますが、浮動小数点の処理では精度の問題があって、1
が0.99999999999
というような数字になる場合があるので小さな数字を足してから(負の場合は引いてから)整数化しています。浮動小数点数の精度は16桁です。今回は、数は最大が100で小数点以下2桁の数の掛け算なので計算後の精度は少数点以下12桁となます。0.0001よりも小さく、0.000000000001よりも大きい数字を足してやればいいことになります。その後、aの値でグループ集計をして、JSONデータに書き出しています。
Pythonの処理を速くするためには、できる限りnumpyでベクトル演算をさせることです。ここでは、掛け算をして整数化する処理には、numpyの関数を使っています。df.applyを使うと各行・各列に適用できるので弾力的に使えるかわりに処理速度は遅くなります。
import pandas as pd
import numpy as np
import time
def multiply_to_int(x, y):
return np.where(x > 0, (x * y + 0.0000001).astype(np.int), (x * y - 0.0000001).astype(np.int))
start = time.time()
df = pd.read_csv('test.csv')
# 以下は、MessagePackとpicklの場合
# df = pd.read_msgpack('test.msgpack')
# df = pd.read_pickle('test.pkl')
df['z'] = multiply_to_int(df['x'].values, df['y'].values)
df_group = df[['a', 'z']].groupby('a').sum()
df_group['a'] = df_group.index
df_group[['a', 'z']].to_json('result.json', orient='records')
end = time.time()
print(f"所要時間:{end - start}[sec]")
テスト用プログラム - .NET Core
pythonの場合と同じ処理をしています。プログラムの方もPythonよりもかなり長くなります
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
namespace app
{
class Program
{
static void Main(string[] args)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
using (var reader = new StreamReader("App_Data/test.csv"))
{
reader.ReadLine();
while (!reader.EndOfStream)
{
string s = reader.ReadLine();
var columns = s.Split(',');
testData.Add(new TestData
{
a = columns[0],
b = columns[1],
x = double.Parse(columns[2]),
y = double.Parse(columns[3])
});
}
}
var testData_0 = testData.Select(d => new {d.a, d.b, d.x, d.y, z = MultiplyToInt(d.x, d.y)}).ToList();
var testData_1 = testData_0.GroupBy(d => d.a)
.Select(g => new {a = g.Key, sum = g.Sum(d => d.z)}).ToList();
using (StreamWriter file = File.CreateText("App_Data/result.json"))
{
JsonSerializer serializer = new JsonSerializer();
serializer.Serialize(file, testData_1);
}
var ts = stopwatch.Elapsed;
Console.WriteLine($"処理時間: {ts.TotalSeconds}秒");
}
static int MultiplyToInt(double x, double y)
{
if (x > 0)
return (int)(x * y + 0.0000001);
return (int)(x * y - 0.0000001);
}
}
class TestData
{
public string a { get; set; }
public string b { get; set; }
public double x { get; set; }
public double y { get; set; }
}
}
追記
この投稿に関連して、以下の反応がありました。簡単に意見を述べたいと思います。
LINQ を使う時に一般的に気を付けること via C#でLinqを使うよりPythonの方が2倍速かったのでベンチマークをしてみた
C# の Linq が python の2倍遅い、は嘘
まず、データの集計ではGroupBy等は通常使うものです。こういうのを使うと遅くなるのは当然だといっていないで、マイクロフトにブーイングを言うべきです。そうしないとプログラム言語として進歩しないと思います。Pythonだと数行で書けるのを何倍も書く必要があるのでは、そもそも話にならないでしょう。
「C# の Linq が python の2倍遅い、は嘘」の方では、GroupByを使わなくすることで 0.17秒速くなっています。Pythonの方の列の掛け算やグリープ集計の時間は0.07秒ぐらいです。GroupByだけで、Pythonの場合の2倍の時間がかかっています。自分の場合は愚直にしか書いていないから2倍以上速くなりました。
もう一点速くなる部分があって、以下の表は、C#のプログラムを「C# の Linq が python の2倍遅い、は嘘」のものに変更してGCPのVMで計測したものです。pythonの方は入力データをMessagePack形式にすると1/2になり、さらにPickle形式にするとその1/2になります。この効果でプログラムが本当に速くなりました。これには本当に感激しました。
なお、入力データというのは、集計用のキャッシュデータで、例えばNoSQLやRedisのデータを集計するのは難しいのですが、更新分のみを取り出すのは簡単なのでキャッシュデータをそれで定期的に更新していくと集計ができます。
環境 GCP Ubuntu 16.04 (xenial) 2vCPU 7.5GB
項目 | Python | .NET Core |
---|---|---|
CSV | 0.62秒 | 0.85秒 |
MessagePack | 0.39秒 | 0.86秒 |
Pickle | 0.19秒 | -- |