プログラミング
8バイトはすでに数値である
Eight Bytes Are a Number (blog.sebastiansastre.co)
要約
Rustにおけるデータパース処理において、`Vec`によるヒープ確保を避けるゼロコピートラッキング技術の重要性を解説しています。これにより、不要なメモリ割り当てやシステムコールを削減し、特に高速なデータ処理が求められるシステムでパフォーマンスを大幅に向上させることができます。
全文翻訳
下流の処理のためにデータをパースする必要があるとき、処理結果を迅速に得るための熱意は、パースのコストという興味深いニュアンスを見落とさせる可能性があります。
この「u64をパースしてください」関数を例にとってみましょう。
fn parse_id(bytes: &[u8]) -> Result<u64, ParserError> {
if bytes.len() < 8 {
return Err(ParserError::InputTooShortForU64);
}
let owned = bytes[..8].to_vec();
Ok(u64::from_le_bytes(owned.try_into().map_err(ParserError::InvalidU64)?))
}
これは長さをチェックし、スライスから適切なバイト数をVecにコピーしてu64としてパースします。プロダクション用に適切なエラーを返します。すべて短く安全に見えます。
では、より注意深く調べてみましょう。
数値のような単純な型に対して、ヒープを使用すべきでしょうか、それとも使用しないべきでしょうか?システムコールはどうでしょうか?
Rustでは、Vecは常にヒープを使用します。その割り当ては、アロケータがすでに空きブロックを持っているか、OSにメモリを要求する必要があるかによって、システムコールを必要とする場合があります。これは負荷がかかっているときによく起こり得ます。
多くのアプリケーションはこの詳細を気にしないでしょうが、気にする必要があるものもあります。
気にする場合、何が起こるでしょうか?
バッファ内の8バイトはすでにu64であり、from_le_bytesはそれらをu64に読み取る方法を知っているので、.to_vec()で割り当てたVecは結果への道のりにおける偶発的な複雑さであると言えます。目的を達成するための内部的な複雑さの一種です。
しかし、もしそれが必要ない方法を見つけたらどうでしょうか?
最終結果のために「場所を確保する」必要がない場合、私たちは時間、労力、実行時コスト、そしてその「場所を確保する」というコードメンテナンスを節約できます。
インプレースで読み取るように設計を進めてみましょう。何が起こるか見てみましょう。
私たちはすでに読み取る場所への参照 &[u8] を受け取っているので、次にu64に正確にどれだけ読み取るかを知る必要があります。
この「インプレースで読み取る」テクニックは、ゼロコピーと呼ばれるものです。なぜなら、処理のために何をしていても、それを開始するためにコピーを必要としないからです(&[u8]で作業することはその契約です)。
これらの8バイトはfrom_le_bytesのためにスタックに移動しますが、ヒープでの操作はスキップしました。
Rustがその契約(型)で持つ強みは、プログラムでゼロコスト抽象化を生み出すために使用されます。
したがって、データ内の正しい型を読み取り、すべてのエラーモードが適切にモデル化され処理されている場合、得られるのはその実行時におけるすべての強みです。
コピーしない抽象化を使用しましょう。
前の例をゼロコピー実装で示します。
fn parse_id(bytes: &[u8]) -> Result<u64, ParserError> {
let raw: [u8; 8] = bytes.get(..8)
.ok_or(ParserError::InputTooShortForU64)?
.try_into()
.map_err(ParserError::InvalidU64)?;
Ok(u64::from_le_bytes(raw))
}
今回は、バイトがすでに存在する場所から読み取り、8バイトの有効なバイトを読み取れることを検証し、それらを使用して確実にu64を生成します。
そして、型がu64よりも複雑な場合はどうでしょうか?
より長いメッセージは同じテクニックの繰り返しであり、新しい契約の形状に合わせて変更されます。
結局のところ、この再利用性がゼロコピーをテクニックたらしめているのです。
例えば、私が注文フローのために書いているコマンドコーデックでは、コマンドは列挙型です。
リミットオーダーはアカウント、クライアントオーダーID、その他の詳細を保持します。オーダーIDによるキャンセルは2つの整数です。マーケットオーダーには価格フィールドがありません。
バッファ内のバイトは、各エンジンコマンドで幅が異なります。
/// EngineCommand は注文状態を変更するためのクライアントリクエストです。
/// マッチングエンジンが作業を受け取るとき、この型を使用するため、バリアントがレイアウトになります。
/// NewLimit と NewMarket は別々であり、New にはエンジン OrderId がありません。
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum EngineCommand {
/// リミットオーダーを開くリクエスト。
NewLimit {
account_id: AccountId,
client_order_id: ClientOrderId,
instrument_id: InstrumentId,
side: Side,
price: Price,
quantity: Quantity,
},
/// マーケットオーダーを開くリクエスト。
NewMarket {
account_id: AccountId,
client_order_id: ClientOrderId,
instrument_id: InstrumentId,
side: Side,
quantity: Quantity,
},
/// エンジン OrderId によるキャンセル。
CancelByOrder { order_id: OrderId },
...
}
これらのコマンドでは、種類バイトがどのレイアウトを見ているかを示します。各レイアウトには1つの長さがあります。その長さをチェックし、フィールドを新しい型に読み込みます。
NewLimitを例にとると、価格はティック数(i64)、数量はロット数です。残りは?すべて意図的にCopyです。
これにより、コマンドは非常にコンパクトになるだけでなく、全体として何かを解き放ちます。
その定義にある #[derive(Debug, Copy... に注目してください? enum EngineCommand のどの部分もバッファを所有していません。それらのCopynessは、列挙型全体の「copyness」を解き放ちます。
これは、エンジンがゼロコピーテクニックを使用してパースできるようにするための非常に意図的な設計上の選択であり、パースだけでなく、コマンドのさらなる下流処理も「インプレース」で行える可能性があります。
const NEW_LIMIT_LEN: usize = 57;
fn decode_new_limit(payload: &[u8]) -> Result<EngineCommand, DecodeError> {
if payload.len() != NEW_LIMIT_LEN {
return Err(DecodeError::Length);
}
Ok(EngineCommand::NewLimit {
account_id: AccountId::new(read_u64(payload, 8)),
client_order_id: ClientOrderId::new(read_u64(payload, 16)),
instrument_id: InstrumentId::new(read_u64(payload, 24)),
side: decode_side(payload[32])?,
price: Price::new(read_i64(payload, 33)),
quantity: Quantity::new(read_u128(payload, 41)),
})
}
予想できるように、これらのコマンドはシーケンス番号と種類を持つエンベロープ内に存在し、どのようにストリームから読み取るかを定義します。
match kind {
Kind::NewLimit => decode_new_limit(payload),
Kind::NewMarket => decode_new_market(payload),
Kind::CancelByOrder => decode_cancel_by_order(payload),
Kind::CancelByClient => decode_cancel_by_client(payload),
Kind::Replace => decode_replace(payload),
}
NewLimitは57バイトです。CancelByOrderは16バイトです。すべて、コンパイラによって保証される方法で読み取ることが決定されています。
残っているのは、無効なデータを取り込めないようにすることです。
これは、考えられるすべての失敗モードを拡張的に処理することで行います。
書き込みについてはどうでしょうか?
ゼロコピーに使用したのと同じ設計意図を、書き込みにも適用します。
つまり、エンコーダーは、呼び出し元がすでに所有しているバッファに同じオフセットを書き込みます。
各バリアントは、データバッファに予測可能に収まる整数の固定パイルです。
書き込み中に中間的な割り当ては必要ありません。
ジャーナルフレームとデータグラムはどちらもペイロード領域を引き渡し、1つの書き込みを共有します。
どちらのパスもコマンドのプライベートコピーを成長させません。
この列挙型は、バリアントがメモリレイアウトを変更しますが、ヒープから解放されている点で興味深いです。
これらの8バイトがすでに数値であったのと同じように、リミットオーダーは既知の場所に57バイトです。
残りは、シーケンス番号と種類バイトを持つエンベロープでそれらをラップし、安全かつ一貫して、正しい順序でどこを見るべきかを教えてもらうことです。
そして、Rustパーサーの仕事は、データがすでに配置されている場所からコマンドに応答し、エンジンが下流で処理できる毎秒数百ミリオンのコマンドを提供することです。
decode_frame/stream/cancel_by_order time: [4.0990 ns 4.1100 ns 4.1243 ns]
thrpt: [242.46 Melem/s 243.31 Melem/s 243.96 Melem/s]
decode_frame/stream/new_limit time: [4.1835 ns 4.1934 ns 4.2058 ns]
thrpt: [237.76 Melem/s 238.47 Melem/s 239.03 Melem/s]
decode_frame/stream/new_market time: [4.0677 ns 4.0850 ns 4.1051 ns]
thrpt: [243.60 Melem/s 244.80 Melem/s 245.84 Melem/s]
*Melem/s: Mega elements per second
検証ボーナス# これを直接証拠として収集するために、RustとDockerをインストールして、新しいプログラムを作成してください。
cargo new count-mem-syscalls-check
そしてこれを試してください。
fn parse_id(bytes: &[u8]) -> u64 {
let mut owned = bytes[..8].to_vec(); // glibc serves allocations above 128 KiB with mmap // so we force a larger allocation by reserving more space
owned.reserve(1024 * 1024);
let id = u64::from_le_bytes(owned[..8].try_into().unwrap());
std::hint::black_box(owned);
id
}
fn zero_copy_parse_id(bytes: &[u8]) -> u64 {
u64::from_le_bytes(bytes[..8].try_into().unwrap())
}
fn main() {
let bytes = 0x0123_4567_89ab_cdefu64.to_le_bytes();
let id = parse_id(&bytes);
// let id_2 = zero_copy_pa