八个字节就是一个数
Eight Bytes Are a Number

原始链接: https://blog.sebastiansastre.co/posts/eight-bytes-are-already-a-number/

本文介绍了 Rust 解析器如何通过直接从输入切片中读取值来避免不必要的分配。它不会使用 `to_vec()` 将字节复制到堆上的 `Vec`,而是通过 `bytes.get(..8)?.try_into()?` 得到一个固定大小的数组,再交给 `u64::from_le_bytes` 安全处理,全程无需堆分配。 这种零拷贝方式也适用于结构复杂但布局固定的消息。命令封装中的种类字节决定其具体变体、长度和字段偏移。如果所有字段及外层枚举都实现了 `Copy`,解析过程——以及潜在的后续处理——就无需持有缓冲区或创建中间分配。编码时,同样可以将固定字段直接写入调用方提供的缓冲区。 该设计还强调使用建模错误、严格的长度检查以及由编译器保证的布局,以拒绝格式错误的输入。 基准测试显示,每条命令的解析耗时约为 4.1 纳秒,每秒可处理约 2.38 亿至 2.45 亿条命令。`strace` 示例进一步表明,强制预留大型 `Vec` 会触发额外的 `mmap`/`munmap` 系统调用,而零拷贝解析则避免了这些调用。实际分配成本取决于分配器的状态和系统负载。

Hacker News 最新 | 往期 | 评论 | 提问 | 展示 | 工作 | 提交 登录 [已标记] sebastianconcpt 1 天前 | 隐藏 | 往期 | 收藏 magackame 1 天前 | 下一个 [–] “什么意思,ParseError::InvalidU64?任意 8 个字节都是有效的 u64。你已经检查过长度了,直接解包就行。” sltkr 1 天前 | 上一个 [–] 请少发些 AI 垃圾内容。 请申请 YC 2027 年冬季批次! 申请开放至 11 月 2 日。 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请加入 YC | 联系我们 搜索:
相关文章

原文

When you need to parse data for downstream processing, the enthusiasm to get the processing outcome quickly might induce you to overlook one interesting nuance: the cost of how it’s parsed.

See this “parse me a u64” function for example:

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)?))
}

It checks the length, copies the right number of bytes from the slice into a Vec and parses those as a u64. Returns adequate Errs for production. All looks short and safe.

Now let’s examine it more carefully.

For a type as simple as a number, should the heap be used or not? What about syscalls?

In Rust, a Vec always uses the heap. That allocation might or might not require a syscall. That would depend on whether the allocator already has a free block or has to ask the OS for more memory which can easily happen under load.

A lot of applications will not care about this detail but some need to care.

What happens when you care?

Since eight bytes in a buffer are already a u64, and from_le_bytes knows how to read them into a u64, it is fair to say the Vec I allocated in that .to_vec() is accidental complexity on the way to the result. A kind of internal complexity to achieve an end. But what if we find ways to not need that? If “making room” for the final result is not required, then we save time, effort, runtime cost, and code maintenance of that “making room”.

Let’s push design to read in place and see what happens.

We’re already receiving a reference &[u8] of where to read, so the next thing is to know how much exactly to read into a u64. This “reading in place” technique is what people call zero-copy, because whatever you’re doing for the processing, you are already not requiring a copy to start doing it (working on &[u8] is that contract).

These eight bytes still move to the stack for from_le_bytes, but we skipped doing anything with the heap.

The strength that Rust has in its contracts (types) is used to produce in a program zero-cost abstractions. So when it reads the right types in data and has all error modes properly modeled and handled, what you get is all that strength in its runtime.

Let’s use abstractions that won’t copy now.

Here is the previous example but in a zero-copy implementation instead:

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))
}

This time we read the bytes in whichever place they already are, validate we can read 8 valid bytes there and use them to infallibly produce a u64.

And what if a type is more complex than a u64?

A longer message is the same technique, repeated over a new contract shaped differently. After all, this reusability is what makes zero-copy a technique.

For example in the command codec I am writing for orderflow, a command is an enum. A limit order carries an account, a client order id and other details. A cancel by order id is two integers. A market order has no price field. The bytes in the buffer are a different width for each engine command.

/// EngineCommand is a client request to change order state.
/// When a matching engine receives work, it uses this type so the variant is the
/// layout: NewLimit and NewMarket are separate, and New has no engine OrderId.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum EngineCommand {
    /// Request to open a limit order.
    NewLimit {
        account_id: AccountId,
        client_order_id: ClientOrderId,
        instrument_id: InstrumentId,
        side: Side,
        price: Price,
        quantity: Quantity,
    },

    /// Request to open a market order.
    NewMarket {
        account_id: AccountId,
        client_order_id: ClientOrderId,
        instrument_id: InstrumentId,
        side: Side,
        quantity: Quantity,
    },

    /// Request to cancel by engine OrderId.
    CancelByOrder { order_id: OrderId },
    ...

For these commands, a kind byte says which layout I am looking at. Each layout has one length. I check that length, then I read fields into new types. Take NewLimit for example, price is a count of ticks, an i64, quantity is a count of lots, and for the rest? All of them are intentionally Copy. This makes commands not only very compact but, together, also unlock something.

Notice the #[derive(Debug, Copy... in its definition?

Nothing in enum EngineCommand owns a buffer.

Their Copyness unlocks the full enum’s “copyness”.

It’s a very deliberate design choice to enable the engine to parse using the zero-copy technique so not only the parsing but maybe further downstream processing of commands can be done “in place”.

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)),
    })
}

As you can expect, these commands live in an envelope with a sequence number and a kind type that defines how they are read from the stream:

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 is 57 bytes. CancelByOrder is 16. Everything is determined how it is to be read, and that’s guaranteed by the compiler. What’s left is making invalid data impossible to ingest. Something you do by handling extensibly each possible failure mode.

And for the writes?

Well, we do the same design intent we used for the zero-copy, but for writing. That is, the encoder writes those same offsets into a buffer the caller already owns. Every variant is a fixed pile of integers that fits predictably in the data buffer. No intermediate allocations required while writing.

The journal frame and the datagram both hand over the payload region and share one write. Neither path grows a private copy of the command.

This enum is interesting because the variant changes the memory layout but it stays free of the heap.

In the same way these eight bytes were already a number, a limit order is fifty-seven, in known places. The rest is getting them wrapped in an envelop with a sequence number and kind byte that tells me where to look for them safely, consistently, and in the right order.

Then the Rust parser’s job is to answer the command from where the data is already sitting and give me these sweet hundreds of millions commands per second the engine can process downstream:

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


To collect firsthand evidence on this, with Rust and Docker installed, make a new program with:

cargo new count-mem-syscalls-check

And try this:

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_parse_id(&bytes);
    println!("{id}");
    // println!("{id_2}");
}

Then measure system calls running strace twice. First with parse_id which allocates. Then comment that call out, uncomment zero_copy_parse_id, and run it again.

Each time, check the table this measurement shows you:

docker run --rm \
  -v "$PWD":/src \
  -w /src \
  -e CARGO_TARGET_DIR=/tmp/target \
  -e DEBIAN_FRONTEND=noninteractive \
  rust:1-slim \
  sh -c 'apt-get update -qq && apt-get install -y -qq strace >/dev/null && cargo build --release --quiet && strace -c -e trace=memory /tmp/target/release/count-mem-syscalls-check && strace -e mmap,brk,munmap,write /tmp/target/release/count-mem-syscalls-check'

Here is mine when using parse_id (31 syscalls):

81985529216486895
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 44.01    0.000136           9        14           mmap
 31.07    0.000096          13         7           mprotect
 18.77    0.000058           8         7           munmap
  6.15    0.000019           6         3           brk
------ ----------- ----------- --------- --------- ----------------
100.00    0.000309           9        31           total
...

brk(NULL)                               = 0xaaaaf8b05000
brk(0xaaaaf8b26000)                     = 0xaaaaf8b26000
mmap(NULL, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0xffff9571e000
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff953ef000
munmap(0xffff953ef000, 1052672)         = 0
write(1, "81985529216486895\n", 18)     = 18
munmap(0xffff9571e000, 20480)           = 0
+++ exited with 0 +++

Here is mine when using zero_copy_parse_id (29 syscalls):

81985529216486895
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         3           brk
  0.00    0.000000           0         6           munmap
  0.00    0.000000           0        13           mmap
  0.00    0.000000           0         7           mprotect
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000           0        29           total
...
brk(NULL)                               = 0xaaaae88cb000
brk(0xaaaae88ec000)                     = 0xaaaae88ec000
mmap(NULL, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0xffffb07d6000
81985529216486895
write(1, "81985529216486895\n", 18)     = 18
munmap(0xffffb07d6000, 20480)           = 0
+++ exited with 0 +++
Column Meaning
calls How many times that syscall ran
errors How many of those calls failed. Blank means zero
seconds Total time spent inside that syscall
usecs/call Average time per call, in microseconds
% time That syscall’s share of the time in this table

That tells us:

  1. brk(NULL) asks for the current end of the heap.
  2. The next brk moves that end forward, which is the allocator creating the heap during startup.
  3. The mmap of 20480 bytes is marked MAP_STACK, and its matching munmap is after the write, during exit.

Note munmap(0xffff953ef000, 1052672) is the drop of the Vec with reserved RAM that required the extra syscall in its corresponding mmap.

联系我们 contact @ memedata.com