1つの最適化のストーリー:戦闘結果の転送と処理

今日は、大規模プロジェクトの一部であるWorld of Tanksについて説明します。 あなたの多くはおそらくユーザー側からWorld of Tanksを知っていますが、開発者の観点から見ることをお勧めします。 この記事では、プロジェクトの技術的ソリューションの1つ、つまり戦闘結果の転送と処理の進化に焦点を当てます。

戦闘闘後








ボンネットの下



検討中の問題の本質を理解するために、まず「タンク」の配置方法を説明します。 このプロジェクトは分散型クライアント/サーバーアプリケーションであり、サーバー側では、さまざまなタイプの複数のノードで表されます。 すべての物理学とゲーム内ロジックはサーバー上で計算され、クライアントはユーザーとのやり取り-ユーザー入力の処理と結果の表示を担当します。 単純化されたクラスター図は次のようになります。

クラスター図






BaseAppノードは、クライアントとの対話を担当します。 ネットワーク経由でユーザーから情報を受信し、クラスター内で送信するとともに、クラスター内で発生したイベントに関する情報をユーザーに送信します。 アリーナで発生するすべての物理的相互作用は、CellAppノードで計算されます。 また、戦闘中に戦車で発生したイベントに関する情報も収集されます。ショット、ヒット、貫通などの数です。1つの戦闘は複数のCellAppで処理でき、それぞれの異なる戦闘の複数のユーザーをカウントできます。 戦闘の終わりに、すべての戦車の統計パックがCellAppsからBaseAppに送信されます。 パッケージを集約し、戦闘の結果を処理します。罰金と報酬を計算し、クエストの完了を確認し、メダルを発行します。つまり、ユーザーに送信する他のデータパケットを形成します。 CellAppsとBaseAppsは独立したプロセスであることに注意することが重要です。 それらのいくつかは、同じクラスター内にある他のマシン上にある場合があります。 そのため、それらの間のデータ転送はネットワークインターフェイスを介して行われ、クライアントとクラスターの間ではインターネットを介して行われます。

データパケットの送信には、UDPで実装された保証付き配信プロトコルが使用されます。 内部では、すべてのI \ OおよびUDPを介した保証付き配信について、BigWorldテクノロジープラットフォームの一部であるMercuryライブラリが責任を負います。 実際、送信前にのみデータに影響を与えることができます-送信/受信のコストを最適化するような方法でデータを準備します。



明らかな理由により、ノード間で送信されるデータの量を最小限に抑えたいと考えています。これにより、トラフィックの急増、受信と送信の遅延が減少し、バトルの結果に関する情報とともにクライアントに送信されるデータ量が大幅に減少します。 データの前処理に100ミリ秒に等しい1ティックが割り当てられ、その間に他のイベントも処理できます。 したがって、前処理の時間はできるだけ短くする必要があります。 理想的には、それはまったく時間がかかりません。



開始する



データ準備プロセスを見てみましょう。

World of Tanksのゲーム内ロジックのほとんどは、Pythonで記述されています。 CellAppとBaseAppの間で送信されるデータ、および戦闘終了時にユーザーに送信されるデータは、単純な値(整数/小数またはTrue / False値など)から複合値(辞書およびシーケンス)までのさまざまな値を持つ辞書です。 コードオブジェクトもクラスもカスタムクラスのインスタンスも、あるノードから別のノードに渡されることはありません。 これは、セキュリティとパフォーマンスの要件によるものです。



さらに資料を検討しやすくするために、さらに作業するデータの例を示します。

data = { "someIntegerValue" : 1, "someBoolValue" : True, "someListOfIntsValue" : [1, 2, 3], "someFloatValue" : 0.5, "someDictionaryValue" : { "innerVal" : 1, "innerVal2" : 2, "listOfVals" : [1, 2, 3, 4], }, "2dArray" : [ [1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12], [13, 14, 15, 16, 17, 18], [19, 20, 21, 22, 23, 24] ] }
      
      





ノード間で直接データを転送するには、dictインスタンスを最小サイズのバイナリデータパケットに変換する必要があります。 リモート呼び出しを行うとき、BigWorldエンジンは、プログラマがcPikcleモジュールを使用してPythonオブジェクトをバイナリデータに、またはその逆に透過的に変換する機能を提供します。

転送されるデータの量は少ないものの、送信する前にcPickleモジュールを使用して単純にバイナリ形式に変換されました(この交換プロトコルバージョン0を呼び出しましょう)。 このような形で、0.1戦車の最初のクローズドベータ版が2010年にリリースされました。



この方法の利点には、最終的なバイナリ表現の速度とコンパクトさの両方の点で単純さと十分な効率が含まれます。

 >>> p = cPickle.dumps(data, -1) >>> len(p) 277
      
      







欠点は、この方法の利点、特にこの方法の単純さから得られます。辞書からの文字列キーもクラスターノード間およびクラスターからユーザーに送信されます。 場合によっては、これらの文字列キーは、送信されたバイナリパッケージのかなりの部分を占める可能性があります。



最適化



時間が経つにつれて、データと同時オンラインの量が大幅に増加し、その結果、同時に終了した戦闘の数と、戦闘の終了時に送信される情報の量が増加しました。 トラフィックを減らすために、転送されるデータから文字列キーを破棄し、リスト内のインデックスに置き換えました。 このような操作はデータの損失にはつながりませんでした。キーの順序を知っていて、元の辞書を復元するのは簡単です。



キーの削除および回復操作を実行するには、すべての可能なキーと対応する機能が保存されたテンプレートが必要でした。

 NAMES = ( "2dArray", "someListOfIntsValue", "someDictionaryValue", "someFloatValue", "someIntegerValue", "someBoolValue", ) INDICES = {x[1] : x[0] for x in enumerate(NAMES)} def dictToList(indices, d): l = [None, ] * len(indices) for name, index in indices.iteritems(): l[index] = d[name] return l def listToDict(names, l): d = {} for x in enumerate(names): d[x[1]] = l[x[0]] return d >>> dictToList(INDICES, data) [[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12], [13, 14, 15, 16, 17, 18], [19, 20, 21, 22, 23, 24]], [1, 2, 3], {'listOfVals': [1, 2, 3, 4], 'innerVal2': 2, 'innerVal': 1}, 0.5, 1, True] >>> >>> len(cPickle.dumps(dictToList(INDICES, data), -1) 165
      
      





例からわかるように、バイナリ表現はバージョン0よりもコンパクトになりました。しかし、コンパクトにするために、データの前処理とサポートが必要な新しいコードの追加に時間を費やしました。

このソリューションは、2015年の初めにバージョン0.9.5でリリースされました。 dictをリストに変換し、その後にpickle.dumps(データ、-1)が続くプロトコルは、バージョン1と呼ばれます。



さらに最適化します



新しい「Superiority」モードのリリースにより、データはタンクごとに個別に収集されるため、データ量が大幅に増加しました。このモードでは、ユーザーは3台のマシンで戦闘に参加してデータを生成できます。 そのため、データをさらに厳密に増やす必要が生じました。

さらに冗長な情報を送信するために、次の手法を適用しました。



1.入れ子になった辞書ごとに、メイン辞書と同じアプローチを使用しました。キーを破棄し、辞書をリストに変換しました。 したがって、データを辞書からリストに、またはその逆に変換する「テンプレート」は再帰的になりました。



2.送信する前にデータを注意深く調べたところ、場合によっては、シーケンスがsetまたはfrozensetタイプのコンテナに囲まれていることに気付きました。 cPickleバージョン2プロトコルでのこれらのコンテナーのバイナリ表現は、さらに多くのスペースを占有します。

 >>> l = list(xrange(3)) >>> cPickle.dumps(set(l), -1) '\x80\x02c__builtin__\nset\nq\x01]q\x02(K\x00K\x01K\x02e\x85Rq\x03.' >>> >>> cPickle.dumps(frozenset(l), -1) '\x80\x02c__builtin__\nfrozenset\nq\x01]q\x02(K\x00K\x01K\x02e\x85Rq\x03.' >>> >>> cPickle.dumps(l, -1) '\x80\x02]q\x01(K\x00K\x01K\x02e.'
      
      





送信する前にsetとfrozensetをリストに変換することにより、さらに数バイトを節約しました。 通常、受信側は特定のタイプのシーケンスには関心がなく、データのみが重要であるため、このような置換はエラーにつながりませんでした。



3.多くの場合、辞書のすべてのキーに値があるわけではありません。 それらのいくつかは存在しないかもしれませんが、他は送信側と受信側で事前に知られているデフォルト値と変わらないかもしれません。 また、異なるタイプのデータの「デフォルト」値には異なるバイナリ表現があることに注意する必要があります。 まれですが、特定の型の空の値よりも少し複雑なデフォルト値があります。 この例では、これらは1つのフィールドに結合されたいくつかのカウンターであり、ゼロのシーケンスの形式で表示されます。 これらの場合、デフォルト値はノード間で送信されるバイナリデータの多くのスペースを占有する可能性があります。 さらに節約するために、送信する前に、デフォルト値をNoneに置き換えます。 その結果、すべての場合において、バイナリ表現はよりコンパクトになるか、長さが変化しません。

 >>> len(cPickle.dumps(None, -1)) 4 >>> len(cPickle.dumps((), -1)) 4 >>> len(cPickle.dumps([], -1)) 4 >>> len(cPickle.dumps({}, -1)) 4 >>> len(cPickle.dumps(False, -1)) 4 >>> len(cPickle.dumps(0, -1)) 5 >>> len(cPickle.dumps(0.0, -1)) 12 >>> len(cPickle.dumps([0, 0, 0], -1)) 14
      
      





例について考えると、cPickleはヘッダーとターミネーターをバイナリパッケージに追加し、その合計量は3バイトであり、シリアル化されたデータの実際の量は(X - 3)



です。Xは例の値です。

さらに、デフォルト値を置き換えることは、バイナリデータのzlib圧縮にも役立ちます。 バイナリ表現では、リスト要素はセパレータなしで次々に移動します。 Noneに置き換えられたいくつかの連続したデフォルトは、十分にアーカイブできる同一のバイトのシーケンスとして表示されます。



4.データは、圧縮レベル1でzlibによってアーカイブされます。このレベルでは、作業の時間に対するアーカイブの度合いの最適な比率を達成できるためです。



手順1〜3をまとめると、次のようになります。

 class DictPacker(object): def __init__(self, *metaData): self._metaData = tuple(metaData) # Packs input dataDict into a list. def pack(self, dataDict): metaData = self._metaData l = [None] * len(metaData) for index, metaEntry in enumerate(metaData): try: name, transportType, default, packer = metaEntry default = copy.deepcopy(default) # prevents modification of default. v = dataDict.get(name, default) if v is None: pass elif v == default: v = None elif packer is not None: v = packer.pack(v) elif transportType is not None and type(v) is not transportType: v = transportType(v) if v == default: v = None l[index] = v except Exception as e: LOG_DEBUG_DEV("error while packing:", index, metaEntry, str(e)) return l # Unpacks input dataList into a dict. def unpack(self, dataList): ret = {} for index, meta in enumerate(self._metaData): val = dataList[index] name, _, default, packer = meta default = copy.deepcopy(default) # prevents modification of default. if val is None: val = default elif packer is not None: val = packer.unpack(val) ret[name] = val return ret PACKER = DictPacker( ("2dArray", list, 0, None), ("someListOfIntsValue", list, [], None), ("someDictionaryValue", dict, {}, DictPacker( ("innerVal", int, 0, None), ("innerVal2", int, 0, None), ("listOfVals", list, [], None), ) ), ("someFloatValue", float, 0.0, None), ("someIntegerValue", int, 0, None), ("someBoolValue", bool, False, None), ) >>> PACKER.pack(data) [[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12], [13, 14, 15, 16, 17, 18], [19, 20, 21, 22, 23, 24]], [1, 2, 3], [1, 2, [1, 2, 3, 4]], 0.5, 1, True] >>> len(cPickle.dumps(PACKER.pack(data), -1)) 126
      
      





その結果、これらのトリックはすべて、2015年5月にバージョン0.9.8でリリースされたプロトコルバージョン2に適用されました。

はい、予備準備に費やす時間をさらに増やしましたが、一方で、バイナリパッケージのボリュームは大幅に減少しました。



結果の比較



実際のデータで上記の手法を使用した結果を確認するために、バージョンに応じて異なるバージョンで、戦闘の終了時にCellAppからBaseAppに送信された1つのタンクのデータパケットサイズのサイズのグラフを示します。

BaseAppかCellAppに送信する必要がありますクアン情報パケットサイズ






「Superiority」モードバージョン0.9.8では、プレイヤーは3つの戦車で戦闘に参加できるため、データの合計量は3倍に増加することを思い出してください。



そして、送信する前に同じデータを処理するのにかかる時間。

BaseAppかCellAppに送信し、パッケージを前処理する時間






0.9.8uはzlibによる圧縮なし(非圧縮)で処理され、0.9.8cは圧縮(圧縮)を使用しています。 時間は、10,000回の反復ごとに秒単位で示されます。



データはバージョン0.9.8で収集され、使用されたキーを考慮して0.9.5および0.1で概算されたことに注意してください。 さらに、データの量はプレイヤーの行動(検出された敵の数、損傷など)に直接依存するため、ユーザーと戦車ごとにデータは大きく異なります。 そのため、グラフはトレンドの説明として扱う必要があります。



おわりに



私たちの場合、ノード間で転送されるデータの量を減らすことが重要でした。 前処理時間を短縮することも望ましいことでした。 したがって、2番目のバージョンのプロトコルが最適なソリューションになりました。 次の論理的なステップは、シリアル化機能を別のバイナリモジュールに配置することです。このモジュールは、すべての操作を単独で行い、データを記述する情報をpickleなどのバイナリストリームに保存しません。 これにより、データ量がさらに削減され、処理時間が短縮される可能性があります。 ただし、上記のソリューションを使用している間は。



All Articles