ORMのブレーキはどこにありますか?

オーバーヘッドのためのいくつかのpython ORMの分析



はじめに



Python djangoでアプリケーションを開発しているときに、不十分なブレーキングに遭遇しました。

かなり複雑な計算アルゴリズムを改善するためのいくつかの試みの後、これらのアルゴリズムの大幅な改善が非常に控えめな結果につながったことに気付きました-ボトルネックはアルゴリズムにまったくないと結論付けました。



その後の分析では、実際、計算に必要なデータにアクセスするために使用されたdjango ORMが、プロセッサリソースの主な非生産的な消費者であることが判明しました。

この質問に興味を持つようになったので、ORMを使用する場合の非生産的な費用を確認することにしました。 結果を取得するために、最も基本的な操作を使用しました。新しく作成された新しいデータベースの最初で唯一のユーザーのユーザー名を取得します。



データベースとして、ローカルホスト(MyISAMテーブル)にあるMySQLを使用しました。



最初の「ロールモデル」として、djangoの仕様を最小限に使用し、必要な値をほぼ最適に取得するコードを使用しました。



def test_native(): from django.db import connection, transaction cursor = connection.cursor() t1 = datetime.datetime.now() for i in range(10000): cursor.execute("select username from auth_user limit 1") f = cursor.fetchone() u = f[0][0] t2 = datetime.datetime.now() print "native req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10. t1 = datetime.datetime.now() for i in range(10000): cursor.execute("select username,first_name,last_name,email,password,is_staff,is_active,is_superuser,last_login,date_joined from auth_user limit 1") f = cursor.fetchone() u = f[0][0] t2 = datetime.datetime.now() print "native (1) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10. t1 = datetime.datetime.now() for i in range(10000): cursor = connection.cursor() cursor.execute("select username,first_name,last_name,email,password,is_staff,is_active,is_superuser,last_login,date_joined from auth_user limit 1") f = cursor.fetchone() u = f[0][0] t2 = datetime.datetime.now() print "native (2) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.
      
      







このコードを実行した結果:



 >>> test_native()
ネイティブreq / seq:8873.05935101 req time(ms):0.1127007
ネイティブ(1)要求/シーケンス:5655.73751948要求時間(ミリ秒):0.1768116
ネイティブ(2)要求/シーケンス:3815.78751558要求時間(ミリ秒):0.2620691




したがって、最適な「サンプル」は、データベースに毎秒約8千5千件のヒットを与えます。

通常、djangoおよびその他のORMは、データベースからオブジェクトを取得するときに、オブジェクトの他の属性を取得する必要があります。 簡単にわかるように、残りのテーブルフィールドから「機関車」を取得すると、結果がかなり悪化しました。1秒あたり最大5万5千のリクエストです。 ただし、計算結果を取得するには複数のデータフィールドが必要になることが多いため、この劣化は相対的です。

新しいカーソルを取得する操作はかなり難しいことが判明しました-約0.1ミリ秒かかり、コードの実行速度を約1.5倍低下させます。



サンプルの2番目の結果を例に取り、ORMが取得したインジケーターに追加する損失を確認します。



ジャンゴオーム



最も単純なものから始めて、いくつかのクエリオプションを実行し、djangoツールを一貫して使用してクエリを最適化してみましょう。

最初に、Userタイプから直接開始して、目的の属性を取得するための最も気取らないコードを実行します。

次に、最初にリクエストオブジェクトを保存して、結果の改善を試みます。

次に、唯一の()メソッドの使用を思い出して、結果をさらに改善してください。

最後に、状況を改善しようとする別のバージョンは、values()メソッドを使用することです。これにより、ターゲットオブジェクトを作成する必要がなくなります。

これが私たちの努力の結果をチェックする最終的なコードです。

 def test_django():   t1 = datetime.datetime.now()   for i in range(10000):       u = User.objects.all()[0].username   t2 = datetime.datetime.now()   print "django req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   t1 = datetime.datetime.now()   q = User.objects.all()   for i in range(10000):       u = q[0].username   t2 = datetime.datetime.now()   print "django (1) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   t1 = datetime.datetime.now()   q = User.objects.all().only('username')   for i in range(10000):       u = q[0].username   t2 = datetime.datetime.now()   print "django (2) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   t1 = datetime.datetime.now()   q = User.objects.all().values('username')   for i in range(10000):       u = q[0]['username']   t2 = datetime.datetime.now()   print "django (3) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.
      
      





実行結果は落胆しています:

 >>> test_django()
ジャンゴリクエスト/シーケンス:1106.3929598リクエスト時間(ミリ秒):0.903838
 django(1)必要/シーケンス:1173.20686476必要時間(ms):0.8523646
 django(2)req / seq:695.949871009 req time(ms):1.4368851
ジャンゴ(3)要求/シーケンス:1383.74156246要求時間(ミリ秒):0.7226783


まず、ORMの使用自体が、最適ではない「サンプル」と比較しても5倍以上(!)パフォーマンスを低下させました。 要求の準備をサイクルの外側に移動しても、結果は大幅には改善されませんでした(10%未満)。 しかし、()のみを使用すると、写真が完全に台無しになります-予想される改善ではなく、ほぼ2倍の結果の悪化が見られます。 同時に、興味深いことに、オブジェクトの作成を除外すると、生産性が20%向上しました。

したがって、django ORMは、1つのオブジェクトを受け取るために約0.7226783-0.1768116 = 0.5458667msだけ非生産的な費用を増加させます。

追加のオブジェクトとテーブルの作成を必要とするさらなる実験を省略すると、これらの結果はオブジェクトのリストを取得する場合にも当てはまることをお知らせします: オブジェクトのコレクション内の個々のオブジェクトを取得すると(単一のクエリの結果)、各オブジェクトで0.5ミリ秒以上のオーダーの損失が発生します。

MySQLを使用する場合、これらの損失はコード実行の5倍以上の減速に相当します。



SQLAlchemy



SQLAlchemyの場合、標準のdjango.contrib.auth.models.Userクラスに対応するデータ構造を宣言的に宣言するAUserクラスを作成しました。

最大のパフォーマンスを達成するために、ドキュメントといくつかの実験を熟考して読んだ後、単純なクエリキャッシュを使用しました。

 query_cache = {} engine = create_engine('mysql://testalchemy:testalchemy@127.0.0.1/testalchemy', execution_options={'compiled_cache':query_cache})
      
      





パフォーマンステストは、オブジェクトへのアクセスの「フロント」バージョンで最初に実行されます。

次に、要求の準備をループから外して最適化を試みます。

次に、ターゲットオブジェクトの作成を排除して最適化を試みます。

次に、要求されたフィールドのセットを制限することにより、クエリをさらに最適化します。

 def test_alchemy():   t1 = datetime.datetime.now()   for i in range(10000):       u = session.query(AUser)[0].username   t2 = datetime.datetime.now()   print "alchemy req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   q = session.query(AUser)   t1 = datetime.datetime.now()   for i in range(10000):       u = q[0].username   t2 = datetime.datetime.now()   print "alchemy (2) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   from sqlalchemy.sql import select   table = AUser.__table__   sel = select([table],limit=1)   t1 = datetime.datetime.now()   for i in range(10000):       u = sel.execute().first()['username']   t2 = datetime.datetime.now()   print "alchemy (3) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   table = AUser.__table__   sel = select(['username'],from_obj=table,limit=1)   t1 = datetime.datetime.now()   for i in range(10000):       u = sel.execute().first()['username']   t2 = datetime.datetime.now()   print "alchemy (4) req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.
      
      





テスト結果は次のとおりです。

 >>> test_alchemy()
錬金要求/シーケンス:512.719730527要求時間(ミリ秒):1.9503833
錬金術(2)要求/シーケンス:526.34332554要求時間(ミリ秒):1.8999006
錬金術(3)要求/シーケンス:1341.40897306要求時間(ミリ秒):0.7454848
錬金術(4)要求/シーケンス:1995.34167532要求時間(ミリ秒):0.5011673


最初の2つのケースでは、錬金術はIDにかかわらずリクエストをキャッシュしませんでした(理由はわかりましたが、開発者は何らかのコードでそれをかき消すことを申し出ており、コードに固執すると約束しましたが、私はしませんでした)。 キャッシュされたクエリにより、錬金術はdjango ORMのパフォーマンスを30%〜35%上回ることができます。

django ORMとSQLAlchemyによって生成されたSQLはほぼ同一であり、テストに最小限の歪みしか生じないことにすぐに注意します。



膝の上のORM





当然のことながら、このような結果の後、処理アルゴリズムでデータを受信したすべてのコードをリクエストにリダイレクトするために再編集しました。 直接クエリコードでの作業は不便です。したがって、最も頻繁に実行される操作を、ORMに似たタスクを実行する単純なクラスにラップしました。



 class S:   def __init__(self,**kw):       self.__dict__.update(kw)   @classmethod   def list_from_cursor(cls,cursor):       return [cls(**dict(zip([col[0] for col in cursor.description],row))) for row in cursor.fetchall()]   @classmethod   def from_cursor(cls,cursor):       row = cursor.fetchone()       if row:           return cls(**dict(zip([col[0] for col in cursor.description],row)))   def __str__(self):       return str(self.__dict__)   def __repr__(self):       return str(self)   def __getitem__(self,ind):       return getattr(self,ind)
      
      







このクラスを使用して導入されたパフォーマンスの損失を測定します。



 def test_S():   from django.db import connection, transaction   import util   cursor = connection.cursor()   t1 = datetime.datetime.now()   for i in range(10000):       cursor.execute("select * from auth_user limit 1")       u = util.S.from_cursor(cursor).username   t2 = datetime.datetime.now()   print "S req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.   t1 = datetime.datetime.now()   for i in range(10000):       cursor.execute("select username from auth_user limit 1")       u = util.S.from_cursor(cursor).username   t2 = datetime.datetime.now()   print "S opt req/seq:",10000/(t2-t1).total_seconds(),'req time (ms):',(t2-t1).total_seconds()/10.
      
      





テスト結果:

 >>> test_S()
 S req / seq:4714.92835902 req time(ms):0.2120923
 S opt req / seq:7473.3388636 req time(ms):0.133809


ご覧のとおり、損失はごくわずかです。最適でない場合は0.2120923-0.1768116 = 0.0352807ms、最適な場合は0.133809-0.1127007 = 0.0211083msです。 膝の上で行われるORMでは、本格的なpythonオブジェクトが作成されます。



一般的な結論



強力なユニバーサルORMを使用すると、パフォーマンスが非常に顕著に低下します。 MySQLなどの高速DBMSエンジンを使用する場合、データアクセスのパフォーマンスは3〜5倍以上低下します 。 Intel Pentium Dual CPU E2200 @ 2.20GHzプラットフォームで1つのオブジェクトにアクセスする場合、パフォーマンスの低下は約0.5ms以上です。

損失のかなりの部分は、データベースから取得したデータの文字列からオブジェクトを作成することです:約0.1ms。 さらに0.1msはカーソルの作成を使い果たしますが、これはORMで取り除くのは非常に困難です。

残りの損失の原因は不明のままでした。 データ処理層の抽象化により、結果を処理する際の呼び出しの数によって、十分な量の損失が発生する可能性があるとのみ想定できます。



十分なパフォーマンスを達成するために、ORM開発者は抽象化、クエリデザイン、およびORMに固有の他の操作のレイヤーを通過するコードの損失に留意する必要があります。 真に生産的なORMでは、このORMを使用する開発者がパラメーター化されたクエリを一度準備してから、全体的なパフォーマンスへの影響を最小限に抑えながらさまざまなパラメーターで使用できるようにする必要があります 。 このアプローチを実装する方法の1つは、生成されたSQL式と基礎となるDBMSに固有の準備されたクエリハンドルにキャッシュを使用することです。 驚いたことに、このような最適化はSQLAlchemyで行われたという事実にもかかわらず、パフォーマンスは多少低下しますが、それほど深刻ではありません。



個人的には、両方のORMで1つのオブジェクトを読み取るときに0.3-0.4msの損失が発生するという謎のままです。 両方のORMがプロセッサリソースをほぼ均等に生産的に費やすことが特徴です。 これにより、ローカルのORMの問題(djangoからの準備済みクエリのキャッシュの不足など)ではなく、おそらく両方のORMでおそらく同じアーキテクチャ要素が原因であると考えられます。 専門的なコメントを寄せてくれたコミュニティに感謝します。



All Articles