web-dev-qa-db-ja.com

小さな非構造化メッセージの大きなストリームを格納および取得する最速の方法

私は、多くの小さな非構造化メッセージを処理する必要があるIOTアプリケーションを開発しています(つまり、フィールドが時間とともに変化し、一部が表示され、一部が非表示になる可能性があります)。これらのメッセージには通常2〜15のフィールドがあり、その値は基本的なデータ型(int/long、文字列、ブール)に属しています。これらのメッセージは、JSONデータ形式(またはmsgpack)に非常によく適合します。

メッセージが到着順に処理されることが重要です(理解してください:メッセージは単一のスレッドで処理する必要があります-この部分を並列化する方法はありません)。これらのメッセージをリアルタイムで処理するための独自のロジックがあります(スループットは比較的小さく、最大で毎秒数十万メッセージです)が、エンジンを再生して以前の期間をシミュレーション/再生できるようにする必要性が高まっていますメッセージの履歴。最初はその目的で書かれていませんでしたが、私のイベント処理エンジン(Goで書かれています)は、履歴データを1秒間にフィードできれば、1秒あたり数十(数百)のメッセージを非常にうまく処理できました。十分な速度。

これがまさに問題です。私はこれらのメッセージの多く(数十億)を長期間(数年)にわたって、区切られたmsgpack形式( https://github.com/msgpack/msgpack-python#streaming)で保存しています。 -unpacking )。この設定やその他の設定(下記を参照)では、ディスクのIOが飽和するのとはほど遠い、最大200万メッセージ/秒(2019 Macbook Proでは解析のみ)のピーク解析速度をベンチマークすることができました。

IOについて話さなくても、次のことを行う:

import json
message = {
    'meta1': "measurement",
    'location': "NYC",
    'time': "20200101",
    'value1': 1.0,
    'value2': 2.0,
    'value3': 3.0,
    'value4': 4.0
}
json_message = json.dumps(message)

%%timeit
json.loads(json_message)

解析時間は3マイクロ秒/メッセージです。これは300kメッセージ/秒をわずかに超えています。標準ライブラリのjsonモジュールの代わりにujson、rapidjson、orjsonと比較すると、1マイクロ秒/メッセージ(ujsonを使用)のピーク速度、つまり約1Mメッセージ/秒を得ることができました。

Msgpackは少し優れています:

import msgpack
message = {
    'meta1': "measurement",
    'location': "NYC",
    'time': "20200101",
    'value1': 1.0,
    'value2': 2.0,
    'value3': 3.0,
    'value4': 4.0
}
msgpack_message = msgpack.packb(message)

%%timeit
msgpack.unpackb(msgpack_message)

約750ns /メッセージ(約100ns /フィールド)の処理時間(約130万メッセージ/秒)が得られます。私は当初、C++の方がはるかに高速だと思っていました。これは nlohmann/json の使用例ですが、これはmsgpackと直接比較することはできません。

#include <iostream>
#include "json.hpp"

using json = nlohmann::json;

const std::string message = "{\"value\": \"hello\"}";

int main() {
  auto jsonMessage = json::parse(message);
  for(size_t i=0; i<1000000; ++i) {
    jsonMessage = json::parse(message);
  }
  std::cout << jsonMessage["value"] << std::endl; // To avoid having the compiler optimize the loop away. 
};

Clang 11.0.3(std = c ++ 17、-O3)でコンパイルすると、同じMacbookで約1.4秒で実行されます。つまり、解析速度が〜700kメッセージ/秒で、Pythonの例。私はnlohmann/jsonがかなり遅くなる可能性があることを知っており、 simdjson のDOM APIを使用して約2Mメッセージ/秒の解析速度を得ることができました。

これは、私のユースケースではまだ遅すぎます。私は、Python、C++、Java(またはその他のJVM言語)またはGoの潜在的なアプリケーションでメッセージ解析速度を向上させるためのあらゆる提案を受け入れています。

ノート:

  • 私は必ずしもディスク上のメッセージのサイズを気にする必要はありません(提案する格納方法がメモリ効率が良い場合はプラスと考えてください)。
  • 必要なのは、基本的なデータ型のKey-Valueモデルだけです。入れ子になった辞書やリストは必要ありません。
  • 既存のデータの変換はまったく問題ありません。私は単に読み取り最適化されたものを探しています。
  • 私は必ずしも全体を構造体またはカスタムオブジェクトに解析する必要はなく、必要なときに一部のフィールドにアクセスするだけです(通常、各メッセージのフィールドのほんの一部が必要です)。これが来ても問題ありません。ペナルティがアプリケーション全体のスループットを破壊しない限り、ペナルティがあります。
  • 私はカスタム/少し安全ではないソリューションを受け入れています。
  • 私が使用することを選択したフォーマットは、メッセージがファイルに順次書き込まれるという意味で、自然に区切られている必要があります(現在、1日あたり1つのファイルを使用していますが、これは私のユースケースでは十分です)。過去に不適切に区切られたメッセージの問題がありました(Java Protobuf APIのwriteDelimitedToを参照してください-1バイトが失われ、ファイル全体が破壊されます)。

私がすでに探求したこと:

  • JSON:rapidjson、simdjson、nlohmann/jsonなどで実験済み...)
  • 区切られたmsgpackを使用したフラットファイル(このAPIを参照: https://github.com/msgpack/msgpack-python#streaming-unpacking ):メッセージの保存に現在使用しているもの。
  • プロトコルバッファ:やや高速ですが、データの構造化されていない性質には実際には適合しません。

ありがとう!!

4
Ben

メッセージには、基本タイプ(実行時に定義される)の名前付き属性はほとんど含まれておらず、これらの基本タイプは、たとえば文字列、整数、浮動小数点数であると想定しています。

実装を高速にするには、次のことをお勧めします。

  • テキストの解析を回避します(シーケンシャルで条件付きであるため、遅くなります);
  • メッセージの形式が正しくないかどうかの確認は避けてください(すべての形式が正しいため、ここでは不要です)。
  • 割り当てはできるだけ避けてください。
  • メッセージのチャンクに取り組みます。

したがって、最初にシンプルで高速なバイナリメッセージプロトコルを設計する必要があります。

バイナリメッセージには、その属性の数(1バイトでエンコード)が含まれ、その後に属性のリストが続きます。各属性には、そのサイズ(1バイトでエンコード)が前に付いた文字列と、その後に属性のタイプ(1バイトでエンコードされたstd :: variantのタイプのインデックス)と、属性値(サイズ-接頭辞付き文字列、64ビット整数または64ビット浮動小数点数)。

エンコードされた各メッセージは、大きなバッファーに収まるバイトストリームです(一度割り当てられ、複数の受信メッセージに再利用されます)。

生のバイナリバッファからメッセージをデコードするコードを次に示します。

#include <unordered_map>
#include <variant>
#include <climits>

// Define the possible types here
using AttrType = std::variant<std::string_view, int64_t, double>;

// Decode the `msgData` buffer and write the decoded message into `result`.
// Assume the message is not ill-formed!
// msgData must not be freed or modified while the resulting map is being used.
void decode(const char* msgData, std::unordered_map<std::string_view, AttrType>& result)
{
    static_assert(CHAR_BIT == 8);

    const size_t attrCount = msgData[0];
    size_t cur = 1;

    result.clear();

    for(size_t i=0 ; i<attrCount ; ++i)
    {
        const size_t keyLen = msgData[cur];
        std::string_view key(msgData+cur+1, keyLen);
        cur += 1 + keyLen;
        const size_t attrType = msgData[cur];
        cur++;

        // A switch could be better if there is more types
        if(attrType == 0) // std::string_view
        {
            const size_t valueLen = msgData[cur];
            std::string_view value(msgData+cur+1, valueLen);
            cur += 1 + valueLen;

            result[key] = std::move(AttrType(value));
        }
        else if(attrType == 1) // Native-endian 64-bit integer
        {
            int64_t value;

            // Required to not break the strict aliasing rule
            std::memcpy(&value, msgData+cur, sizeof(int64_t));
            cur += sizeof(int64_t);

            result[key] = std::move(AttrType(value));
        }
        else // IEEE-754 double
        {
            double value;

            // Required to not break the strict aliasing rule
            std::memcpy(&value, msgData+cur, sizeof(double));
            cur += sizeof(double);

            result[key] = std::move(AttrType(value));
        }
    }
}

おそらく、(同じ考えに基づいて)エンコード関数も作成する必要があります。

(json関連のコードに基づく)使用例は次のとおりです。

const char* message = "\x01\x05value\x00\x05hello";

void bench()
{
    std::unordered_map<std::string_view, AttrType> decodedMsg;
    decodedMsg.reserve(16);

    decode(message, decodedMsg);

    for(size_t i=0; i<1000*1000; ++i)
    {
        decode(message, decodedMsg);
    }

    visit([](const auto& v) { cout << "Result: " << v << endl; }, decodedMsg["value"]);
}

私のマシン(Intel i7-9700KFプロセッサーを搭載)で、ベンチマークに基づいて、nlohmann jsonライブラリーを使用したコードで2.7Mメッセージ/秒、新しいコードで35.4Mメッセージ/秒を取得しました。

このコードははるかに高速にすることができます。実際、ほとんどの時間は効率的なハッシュと割り当てに費やされています。より高速なハッシュマップ実装(例:boost :: container :: flat_mapまたはska :: bytell_hash_map)を使用するか、カスタムアロケーターを使用することで、問題を軽減できます。別の方法は、慎重に調整された独自のハッシュマップ実装を構築することです。別の方法は、キーと値のペアのベクトルを使用し、線形検索を使用してルックアップを実行することです(メッセージに多くの属性を含める必要がなく、メッセージごとに属性のごく一部が必要であると述べたため、これは高速になるはずです) )。ただし、メッセージが大きいほど、デコードは遅くなります。したがって、メッセージのチャンクをより速くデコードするには、並列処理を利用する必要がある場合があります。これにより、100 Mメッセージ以上に到達する可能性があります。

1