Nova Kwok's Awesome Blog

Rust 维护 Lighter 交易所本地 Orderbook 练习——从 220µs 到 492ns 的小笔记

许多时候当我们需要对接一个交易所的数据用于本地分析或者别的什么事情的时候都需要从 Websocket 获得连续的市场信息并在本地构建一个 Orderbook 方便分析,本文是本人作为一个 Rust 新手的一次练习笔记。

Raw data

这次的目标练习交易所是 Lighter,建立 Websocket 连接之后通过发送如下信息即可开始订阅:

{
    "type": "subscribe",
    "channel": "order_book/{MARKET_INDEX}"
}

得到的数据节选如下,订阅成功后会先得到一个全量的 orderbook,这里有所删减,实际上获得的第一条快照数据 subscribed/order_bookasksbids 都有 2500 条以上。

{
  "channel": "order_book:1",
  "offset": 12837513,
  "order_book": {
    "code": 0,
    "asks": [
      {
        "price": "87194.4",
        "size": "1.03736"
      },
      {
        "price": "87194.5",
        "size": "0.02980"
      }
    ],
    "bids": [
      {
        "price": "87194.0",
        "size": "0.00020"
      },
      {
        "price": "87191.6",
        "size": "0.03676"
      }
    ],
    "offset": 12837513,
    "nonce": 3901590619
  },
  "timestamp": 1766059985139,
  "type": "subscribed/order_book"
}

后续的 update/order_book 数据结构类似如下:

{
  "channel": "order_book:1",
  "offset": 12837556,
  "order_book": {
    "code": 0,
    "asks": [
      {
        "price": "87208.2",
        "size": "0.00000"
      }
    ],
    "bids": [
      {
        "price": "87173.2",
        "size": "0.01389"
      }
    ],
    "offset": 12837556,
    "nonce": 3901590736
  },
  "timestamp": 1766059985417,
  "type": "update/order_book"
}

不难理解,对于第一次推送是给了 orderbook 的一个全量快照,后续是给的增量数据,如果 size 是 “0.00000” ,那么就是删除 orderbook 中对应的数据,此外需要根据 offset 严格自增判断,不能丢任何一条数据,丢了就得断连之后重新从快照开始。

Cargo run

以下代码运行方式均为: cargo run ,而不是 cargo run --release

First attempt——220µs

作为新手,比较直观的想法肯定是——我先跑起来再说!

示意图如下(Gemini 画图)

+-----------------------------------------------------------------------+
|                    🧠 核心思路: "直观映射" (Intuitive)                 |
|             "JSON 是什么样,我就定义什么样的 Struct,存下来再说!"          |
+-----------------------------------------------------------------------+
        |
        |  1. JSON 输入 (Incoming Data)
        V
+--------------------------+
| JSON Object              |
|  {                       |
|    "type": "...",        |     serde 自动反序列化
|    "bids": [...],        | ========================>
|    "asks": [...]         |
|  }                       |
+--------------------------+
        |
        |  2. Rust 内存结构 (Memory Layout)
        V
+-------------------------------------------------------------+
| struct LighterOrderBook (Root)                              |
+-------------------------------------------------------------+
| - channel:   Option<String>                                 |
| - offset:    Option<u64>                                    |
| - type_:     String                                         |
| - timestamp: Option<u64>                                    |
|                                                             |
| [!] 嵌套层级 (Nested Layer)                                  |
| - order_book: Option -------------------------------------> +
+-------------------------------------------------------------+
                                                              |
          +---------------------------------------------------+
          |
          v
+-------------------------------------------------------------+
| struct LighterOrderBookData                                 |
+-------------------------------------------------------------+
| - code: u32                                                 |
|                                                             |
| [!] 列表直接转 Vec (List -> Vec)                             |
| - asks: Vec<LighterOrderBookEntry> ---+                     |
| - bids: Vec<LighterOrderBookEntry> ---|---+                 |
+-------------------------------------------------------------+
                                        |   |
          +-----------------------------+   |
          |                                 |
          v                                 v
+----------------------------+    +----------------------------+
| Entry (Ask)                |    | Entry (Bid)                |
+----------------------------+    +----------------------------+
| - price: String (Heap 🐢)  |    | - price: String (Heap 🐢)  |
| - size:  String (Heap 🐢)  |    | - size:  String (Heap 🐢)  |
+----------------------------+    +----------------------------+
       ^                                ^
       |                                |
       +--- "既然是 List,那就存俩 List 本地更新完事" ---+

所以第一反应无脑想法是跟着 JSON 的结构搞一个 Rust 的 Struct 出来:

bidsasks 不是俩 List 么?那我也存俩 List 本地更新不就完事了?

#[derive(Debug, Deserialize)]
pub struct LighterOrderBookEntry {
    pub price: String,
    pub size: String,
}

#[derive(Debug, Deserialize)]
pub struct LighterOrderBookData {
    pub code: u32,
    pub asks: Vec<LighterOrderBookEntry>,
    pub bids: Vec<LighterOrderBookEntry>,
}

#[derive(Debug, Deserialize)]
pub struct LighterOrderBook {
    pub channel: Option<String>,
    pub offset: Option<u64>,
    pub order_book: Option<LighterOrderBookData>,
    #[serde(rename = "type")]
    pub type_: String,
    pub timestamp: Option<u64>,
}

然后对于每一次 Websocket 收到的数据都去更新一下我们本地的一个 Orderbook:

fn update_order_book_data(
    order_book_data: &mut LighterOrderBookData,
    updated_entries: &LighterOrderBookData,
) {
    for update_order in &updated_entries.asks {
        let new_size_float: f64 =
            update_order.size.parse().unwrap();

        if new_size_float == 0.0 {
            order_book_data.asks.retain(|order| {
                order.price != update_order.price
            });
        } else if let Some(existing_order) = order_book_data
            .asks
            .iter_mut()
            .find(|order| order.price == update_order.price)
        {
            existing_order.size = update_order.size.clone();
        } else {
            order_book_data.asks.push(
                LighterOrderBookEntry {
                    price: update_order.price.clone(),
                    size: update_order.size.clone(),
                },
            );
        }
    }

    for update_order in &updated_entries.bids {
        // bids 是一样的道理
    }
}

然后我们来监控一下这里的耗时:

let start_time = Instant::now();
update_order_book_data(order_book_data, update_order_book);
let duration = start_time.elapsed();
total_parsed_txs += 1;
total_parse_time_cost += duration;
let average_parse_time_cost =
    total_parse_time_cost / total_parsed_txs;
println!("update_order_book_data 耗时: {:?}", duration);
println!(
    "update_order_book_data 平均耗时: {:?}",
    average_parse_time_cost
);

结果如下:

update_order_book_data 平均耗时: 225.065µs
update_order_book_data 耗时: 181.152µs
update_order_book_data 平均耗时: 224.999µs
update_order_book_data 耗时: 503.89µs
update_order_book_data 平均耗时: 225.416µs
update_order_book_data 耗时: 115.778µs
update_order_book_data 平均耗时: 225.252µs
update_order_book_data 耗时: 80.782µs
update_order_book_data 平均耗时: 225.037µs
update_order_book_data 耗时: 79.84µs
update_order_book_data 平均耗时: 224.821µs
update_order_book_data 耗时: 85.672µs
update_order_book_data 平均耗时: 224.614µs
update_order_book_data 耗时: 320.204µs
update_order_book_data 平均耗时: 224.756µs

这里有一个好消息和一个好消息,好消息1是对于一个Rust超级新手的我来说,这个代码跑起来了,Orderbook也在正确更新了(在别的地方通过 Log 确认),第二个好消息就是——这平均 230µs 的时间看上去也太快了叭!

BTreeMap Refined——20µs(4µs average)

但是显然我们都知道这个实现非常的挫,在前文中我们知道「实际上 asksbids 都有 2500 条以上」,那这里每一次 retain.iter_mut().find() 这类遍历数组+复制内存都是挺要命的操作。

所以不要被交易所返回的结构带沟里了,这里我们优化一下存储的思路,本地每个 Price-Size 对不要作为 Vec 的一个元素了,而是放到一个 BTreeMap 里面。

我们将存储的结构做一些修改,将 Price 作为 Key,同时为了避免掉进 String 对比价格无法正确获得盘口(Asks 最小和 Bids 最大)的坑,我们引入 ordered-float 包,现在我们本地的 Orderbook 定义如下:

#[derive(Debug, Deserialize, Default)]
pub struct FastLighterOrderBookData {
    pub code: u32,
    // Key: Price, Value: Size
    pub asks: BTreeMap<OrderedFloat<f64>, String>,
    pub bids: BTreeMap<OrderedFloat<f64>, String>,
}

这样我们的解析+更新本地 Orderbook 的函数就可以被大幅简化:

fn update_order_book_data(
    local_order_book_data: &mut FastLighterOrderBookData,
    updated_entries: &LighterOrderBookData,
) {
    for update_order_entry in &updated_entries.asks {
        let new_size_float: f64 =
            update_order_entry.size.parse().unwrap();
        let new_price_float_key = OrderedFloat(
            update_order_entry.price.parse().unwrap(),
        );

        if new_size_float == 0.0 {
            local_order_book_data
                .asks
                .remove(&new_price_float_key);
        } else {
            local_order_book_data.asks.insert(
                new_price_float_key,
                update_order_entry.size.clone(),
            );
        }
    }

    for update_order_entry in &updated_entries.bids {
        let new_size_float: f64 =
            update_order_entry.size.parse().unwrap();
        let new_price_float_key = OrderedFloat(
            update_order_entry.price.parse().unwrap(),
        );

        if new_size_float == 0.0 {
            local_order_book_data
                .bids
                .remove(&new_price_float_key);
        } else {
            local_order_book_data.bids.insert(
                new_price_float_key,
                update_order_entry.size.clone(),
            );
        }
    }
}

再也不需要遍历数组或者复制内存了,我们运行一段时间看看效果:

update_order_book_data 平均耗时: 18.807µs
update_order_book_data 耗时: 13.515µs
update_order_book_data 平均耗时: 18.795µs
update_order_book_data 耗时: 12.674µs
update_order_book_data 平均耗时: 18.782µs
update_order_book_data 耗时: 33.072µs
update_order_book_data 平均耗时: 18.812µs
update_order_book_data 耗时: 9.097µs
update_order_book_data 平均耗时: 18.792µs
update_order_book_data 耗时: 27.842µs
update_order_book_data 平均耗时: 18.811µs

好,10 倍的性能提升就这么摸出来了!

Ratio

这里数量级已经比较小了,为了更加精确统计,而不要受到实际更新的数量的影响,这里修改一下统计的部分代码:

let update_order_book_total_length =
update_order_book.asks.len()
    + update_order_book.bids.len();
let start_time = Instant::now();
update_order_book_data(
    &mut local_lighter_order_book,
    update_order_book,
);
let duration = start_time.elapsed();
let ratio_duration = duration
    / update_order_book_total_length.try_into().unwrap();
total_parsed_txs += 1;
total_parse_time_cost += ratio_duration;
let average_parse_time_cost =
    total_parse_time_cost / total_parsed_txs;
println!(
    "update_order_book_data 加权耗时: {:?}, 实际总耗时: {:?}",
    ratio_duration, duration
);
println!(
    "update_order_book_data 加权平均耗时: {:?}",
    average_parse_time_cost
);

每次统计的耗时会除以实际的需要被修改的 bids/asks 的数量,获得一个平均耗时,重新运行上述代码,数据如下:

update_order_book_data 加权耗时: 5.48µs, 实际总耗时: 5.48µs
update_order_book_data 加权平均耗时: 3.994µs
update_order_book_data 加权耗时: 1.77µs, 实际总耗时: 37.181µs
update_order_book_data 加权平均耗时: 3.989µs
update_order_book_data 加权耗时: 1.38µs, 实际总耗时: 48.311µs
update_order_book_data 加权平均耗时: 3.982µs
update_order_book_data 加权耗时: 1.32µs, 实际总耗时: 33.002µs
update_order_book_data 加权平均耗时: 3.976µs
update_order_book_data 加权耗时: 1.188µs, 实际总耗时: 30.898µs
update_order_book_data 加权平均耗时: 3.969µs

String to f64——12µs(2.9µs average)

现在有另一个明显的可以优化的地方,就是这里:

BTreeMap<OrderedFloat<f64>, String>

由于交易所给的数据是字符串,这里想当然立即写了个 String,但是由于

A String is a wrapper over a Vec.

所以 String 的分配是在堆上,在一个高频对数据修修改改的程序来说,在堆上使用内存绝对不是一个什么好的注意:

if you are allocating, you are losing

所以这里的一个小优化就是修改为全部 f64 的结构,如下:

#[derive(Debug, Deserialize, Default)]
pub struct FastLighterOrderBookData {
    pub code: u32,
    // Key: Price, Value: Size
    pub asks: BTreeMap<OrderedFloat<f64>, f64>,
    pub bids: BTreeMap<OrderedFloat<f64>, f64>,
}

然后在每次收到了 WS 数据的时候将 String 转换为 f64 并存储,此时运行效果如下:

update_order_book_data 加权耗时: 3.354µs, 实际总耗时: 20.128µs
update_order_book_data 加权平均耗时: 2.909µs
update_order_book_data 加权耗时: 2.995µs, 实际总耗时: 5.991µs
update_order_book_data 加权平均耗时: 2.91µs
update_order_book_data 加权耗时: 4.114µs, 实际总耗时: 12.343µs
update_order_book_data 加权平均耗时: 2.914µs

从 4µs 进一步稳定下降到了 2.9µs!

Tick Table——**µs(990ns average)

使用 BTreeMap 已经极大的提升了我们的速度,但是 BTreeMap 背后的树状结构和指针还是太慢了,我让 Gemini 画了个 ASCII 的图方便大家理解:

+------------------+
|   Stack (栈)     |
+------------------+
|  root_ptr      ----------------+
+------------------+             | (1. 指针跳转)
                                 v
                        +---------------------------+
                        | Heap Addr: 0x1000 (Root)  | <--- 第一次内存读取
                        |---------------------------|      (耗时 ~70ns)
                        | Keys: [88000, 89000...]   |
                        | Children Ptrs:            |
                        |  [0x5000, 0x9000...]      |
                        +------------+--------------+
                                     |
                                     | (2. 指针跳转 - Pointer Chasing)
                                     v
                        +---------------------------+
                        | Heap Addr: 0x9000 (Node)  | <--- 第二次内存读取
                        |---------------------------|      (耗时 ~70ns)
                        | Keys: [88700, 88800...]   |
                        | Children Ptrs:            |
                        |  [0x2000, 0x3000...]      |
                        +------------+--------------+
                                     |
                                     | (3. 指针跳转 - Pointer Chasing)
                                     v
                        +---------------------------+
                        | Heap Addr: 0x3000 (Leaf)  | <--- 第三次内存读取
                        |---------------------------|      (耗时 ~70ns)
                        | Key: 88797.9              |
                        | Value: 0.03268            | <--- 终于找到数据
                        +---------------------------+

当我们查找价格 88797.9 时,CPU 必须像“跳房子”一样,从一个内存地址跳到另一个完全不相关的内存地址。每一次指针跳转 (Pointer Chasing) 都可能导致 CPU 缓存未命中 (Cache Miss),迫使 CPU 停下来等待慢速的主内存响应。

现在这里进一步优化的思路是将树状结构的操作改为数组的操作——Tick Table。

const TICK_SIZE_MULTIPLIER: f64 = 10.0; // 对应 tick_size 0.1
const MAX_PRICE_CAPACITY: usize = 2_000_000;

#[derive(Debug, Deserialize, Default)]
pub struct FlatLighterOrderBookData {
    pub code: u32,
    // {"price":"88797.9","size":"0.03268"}
    // ->
    // asks[887979] -> "0.03268"
    pub asks: Vec<f64>,
    pub bids: Vec<f64>,

    pub best_ask_idx: usize,
    pub best_bid_idx: usize,
}

原理如下:将 Price * TICK_SIZE 变成整数作为数组的 Key, size 直接作为数组的 Value,例如假设这里是处理的 BTC,交易所返回的数据都是精确到 0.1 (见上文),所以可以将实际数据乘以 10 作为 Key,同时维护两个游标——best_ask_idx 和 best_bid_idx 用于方便得到盘口 BBO。

示意图如下:

数组索引 (Index)还原价格 (Price)Asks 数组 (Size)Bids 数组 (Size)游标状态 (Cursors)
88798188798.10.550000.00000
88798088798.01.020000.00000⬅️ best_ask_idx (卖一)
88797988797.90.000000.03268⬅️ best_bid_idx (买一)
88797888797.80.000000.15000
88797788797.70.000000.00000(空档位,size为0)

这个时候对于价格的更新就是直接在数组中进行更新:

#[inline(always)]
fn price_to_index(price: f64) -> usize {
    // 加 0.5 是为了处理浮点数精度误差 (epsilon),例如 0.999999 -> 1.0
    (price * TICK_SIZE_MULTIPLIER + 0.5) as usize
}

pub fn update(&mut self, price: f64, size: f64, is_bid: bool) {
    let price_idx = Self::price_to_index(price);

    if price_idx >= MAX_PRICE_CAPACITY {
        eprintln!("Error: Price {} out of bounds", price);
        return;
    }

    if is_bid {
        self.bids[price_idx] = size;

        if size > 0.0 {
            if price_idx > self.best_bid_idx {
                self.best_bid_idx = price_idx;
            }
            // Same as current best_bid_idx and size == 0
            // best_bid_idx is deleted
        } else if price_idx == self.best_bid_idx {
            let mut scan_price_idx = price_idx;

            loop {
                if scan_price_idx == 0 {
                    self.best_bid_idx = 0; // 流动性没了
                    break;
                }
                scan_price_idx -= 1;
                if self.bids[scan_price_idx] > 0.0 {
                    self.best_bid_idx = scan_price_idx;
                    break;
                }
            }
        }
    } else {
        self.asks[price_idx] = size;
        
        // 剩余部分和上面 Bids 差不多
    }
}

我们再来运行一下看看这次的耗时情况。

update_order_book_data 加权耗时: 618ns, 实际总耗时: 2.475µs
update_order_book_data 加权平均耗时: 959ns
update_order_book_data 加权耗时: 1.245µs, 实际总耗时: 4.98µs
update_order_book_data 加权平均耗时: 963ns
update_order_book_data 加权耗时: 2.274µs, 实际总耗时: 2.274µs
update_order_book_data 加权平均耗时: 978ns
update_order_book_data 加权耗时: 1.132µs, 实际总耗时: 2.264µs
update_order_book_data 加权平均耗时: 980ns
update_order_book_data 加权耗时: 1.613µs, 实际总耗时: 1.613µs
update_order_book_data 加权平均耗时: 987ns
update_order_book_data 加权耗时: 663ns, 实际总耗时: 2.655µs
update_order_book_data 加权平均耗时: 984ns
update_order_book_data 加权耗时: 1.703µs, 实际总耗时: 1.703µs
update_order_book_data 加权平均耗时: 992ns

进一步将更新时间下降到了 990ns 附近!

最后,我们 cargo run --release 来看看效果吧:

update_order_book_data 加权耗时: 576ns, 实际总耗时: 4.608µs
update_order_book_data 加权平均耗时: 477ns
update_order_book_data 加权耗时: 1.052µs, 实际总耗时: 1.052µs
update_order_book_data 加权平均耗时: 483ns
update_order_book_data 加权耗时: 127ns, 实际总耗时: 1.273µs
update_order_book_data 加权平均耗时: 479ns
update_order_book_data 加权耗时: 360ns, 实际总耗时: 1.443µs
update_order_book_data 加权平均耗时: 478ns
update_order_book_data 加权耗时: 1.372µs, 实际总耗时: 1.372µs
update_order_book_data 加权平均耗时: 488ns
update_order_book_data 加权耗时: 947ns, 实际总耗时: 1.894µs
update_order_book_data 加权平均耗时: 492ns

492ns!

总结

我们一步步从模仿交易所的 asks 和 bids 数组,到改用 BTreeMap ,到 String -> f64 的小优化,最后到使用 Tick Table 的实践,从写了一个看上去实际可用的程序的出发学到了 Rust 的一些内容(还有一些 Tokio 等运行时并没有在本文中包括)。

当然,我们也得时不时回头看看我们的优化价值到底有多大,毕竟—— https://apidocs.lighter.xyz/docs/account-types

Premium Account (Opt-in) – Suitable for HFT, the lowest latency on Lighter.

Fees: 0.002% Maker, 0.02% Taker
maker/cancel latency: 0ms
taker latency: 150ms
part of volume quota program

Standard Account (Default) – Suitable for retail and latency insensitive traders.

fees: 0 maker / 0 taker
taker latency: 300ms
maker: 200ms
cancel order: 100ms

夺少?!150ms?! Suitable for HFT?!

#Chinese #Crypto