低延迟系统开发#14

预取、预热与双缓冲

热路径的时间很多花在等数据上。软件预取只在硬件猜不到地址、你又提前知道地址时有用;预热要在真正干活的那个核上做;双数组加原子索引让读的一方不加锁,但写得快了会写坏正在被读的那一份,实测 2 秒里读到 1500 多次新旧混杂的数据。

约 7 分钟
本页目录

热路径上处理一条行情,很多时间花在等数据:要读的表项不在缓存里,一次就是 70–120 ns,按 4 GHz 是 280–480 个周期。这一篇是三种让数据先到、或者一直在缓存里的办法,每种都有一个容易被忽略的前提。软件预取只在硬件猜不到地址、而你提前知道地址时才有用。预热要在真正读数据的那个核上做。双数组加原子索引能让读的一方不加锁,可写的一方写得快了,会写坏读的一方正在读的那一份。

1. 软件预取:硬件猜不到、你又提前知道的地址

1.1 硬件已经在预取什么

预取(prefetch)是在真正用到数据之前,先发出请求,把那条缓存行搬进缓存,等用到时就不用等。CPU 自己一直在做这件事。硬件预取器盯着缓存缺失的地址:发现在顺着地址走,或者每次隔固定的步长,就提前把后面几条行取进来,还会顺带取相邻的行。乱序执行也在帮忙:后面几条互相独立的读,不必等前面那条读完,可以先发出去(速查 2.3)。所以顺序扫数组、按固定步长走,用不着手写预取,这也是为什么手写预取在多数测试里看不出收益。

硬件预取有两个够不着的地方。一是它通常不跨 4 KiB 的页:下一页的物理地址不一定挨着,它不知道该取哪里。二是地址要从数据里算出来的访问,它没有规律可学:查哈希表时桶的位置由键算出来,a[idx[i]] 的地址要先读出 idx[i],链表的下一个节点要先读出当前节点。论文里有各种想学这些模式的预取器,常见的 CPU 里主要还是按步长、按相邻区域预取的那几种。

1.2 __builtin_prefetch:参数对应哪条指令

GCC 和 Clang 用 __builtin_prefetch(addr, rw, locality) 发预取:rw 是 0(为读预取)或 1(为写预取),locality 从 3 到 0 表示数据会被用多少次、该放得离核多近。实测对应的 x86 指令是:

写法指令含义
__builtin_prefetch(p, 0, 3)prefetcht0取进所有级别的缓存,包括 L1
__builtin_prefetch(p, 0, 2)prefetcht1取进 L2 和更外层
__builtin_prefetch(p, 0, 1)prefetcht2取进 L3 和更外层
__builtin_prefetch(p, 0, 0)prefetchnta非时间性:用一次就走,尽量不挤占缓存
__builtin_prefetch(p, 1, 3)prefetchw为写预取,提前拿到独占权;-march 不支持时退回 prefetcht0

预取只是提示:地址无效也不会出错,也不会让后面的指令等它。但它和一次普通的缺失一样,要占一个 LFB(line fill buffer,行填充缓冲)槽位,取进来的行也会挤掉别的行。

1.3 什么时候有用:一批互相独立、地址要算出来的访问

典型的是一批订单号,逐个去哈希表里查。每次查找的地址要用订单号算,硬件猜不到;但这一批订单号都已经在手里,后面第几次要查哪一格,现在就能算出来。于是处理第 i 个的时候,先给第 i + D 个发预取:

struct Slot { uint64_t key; uint64_t value; };

inline size_t slot_of(uint64_t id, int bits) { return (id * 0x9E3779B97F4A7C15ull) >> (64 - bits); }

uint64_t lookup_batch(const Slot* slots, int bits, const uint64_t* ids, int n) {
    constexpr int D = 8;                                         // 预取距离:提前 8 次查找
    uint64_t sum = 0;
    for (int i = 0; i < n; ++i) {
        if (i + D < n)
            __builtin_prefetch(&slots[slot_of(ids[i + D], bits)]);   // 后面第 D 次要读的那一格
        sum += slots[slot_of(ids[i], bits)].value;                 // 这一次要读的那一格
    }
    return sum;
}

这些查找互相独立,乱序执行本来也能同时发出几个,但它能往前看多远,受 ROB 大小和每次查找的指令数限制:每次查找要做的事一多,窗口就够不着后面的查找。预取把“提前发请求”这件事从窗口里拿出来,最多能让 $D$ 个缺失同时在路上。8 次查找都落到内存,一次接一次地等是 $8 \times 90 = 720$ ns,同时在路上的话,总共接近一次内存延迟。$D$ 要够大,预取发出后的这段时间要盖住一次内存延迟:

$$D \approx \frac{\text{内存延迟}}{\text{每次迭代的时间}}$$

内存延迟约 90 ns、每次迭代约 10 ns 时,$D$ 取 9 左右。$D$ 太小,用到时数据还没回来;太大,取回来的行等不到用就被挤掉了。

反过来,顺序扫一个 int 数组时预取后面第 8 个元素,等于提前 32 字节,多半还在同一条缓存行里,硬件早就取好了,只是白白多了几条指令。

链表这种指针追逐,预取帮不了一条链:下一个节点的地址要等当前节点读回来才知道,没法提前发(#10 第 2 节)。能做的是同时走几条互相独立的链,让几个缺失同时在路上,或者干脆把链换成数组。

2. 预热:让热路径的数据和指令一直在缓存里

2.1 为什么会冷

交易的热路径不是一直在跑:行情一阵一阵地来,订单更是偶尔才发。空下来的时候,别的代码在跑,写日志、做统计、跑别的策略,它们用到的数据和指令会把热路径的缓存行从 L1、L2 里挤出去,分支预测器里记的历史也被冲掉。下一条行情来时,热路径的表项要从 L3 甚至内存里读,指令要重新取,分支要重新学,第一笔往往特别慢。

2.2 数据预热和指令预热(干跑)

预热就是在空闲时主动把这些东西拉回来。数据预热是把热路径要读的数据定期读一遍,每条缓存行读一个元素就够,用 volatile 防止编译器把这些没人用的读删掉。指令预热更进一步:拿假的行情,把从收行情、算信号、风控到生成订单的整条路径跑一遍,只在真正发出订单之前停下。这叫干跑(dry run)。它碰到的正好是真实路径会碰的数据、指令和分支,比只读一遍数据更全,连 TLB 里的地址翻译也一起热了。

2.3 必须在干活的那个核上做

L1 和 L2 是每个核私有的(#2)。在另一个线程里读一遍数据,热起来的是那个线程所在的核的 L1、L2;真正干活的核去读时,行要么在 L3,要么在别的核的缓存里,要从那边搬过来,约 20–100 ns(速查 2.1)。所以预热要放在热线程自己身上,比如忙等收行情的循环里,一段时间没有新消息就预热一小段。

还有两点。预热只读不写:写会把行拿成独占,别的核手里的副本全部作废。每次预热的量要小,几条缓存行就停,回去看有没有新消息,不能让一条真行情排在一大段预热后面。

3. 双数组加原子索引:读的一方不加锁

3.1 做法

参数表、合约信息这类数据,控制线程偶尔更新,交易线程一直在读。加锁的话,控制线程拿着锁写的时候,交易线程只能等。双数组(double buffering)的做法是存两份:写的一方先把新数据写进没人在读的那一份,写完再改一个原子下标,指向新的那份;读的一方先读下标,再读它指向的那份:

template<typename T>
class DoubleBuffer {
public:
    DoubleBuffer(size_t size) : currentIndex(0) {
        buffers[0] = std::vector<T>(size);
        buffers[1] = std::vector<T>(size);
    }
    void update(const std::vector<T>& newData) {
        size_t nextIndex = 1 - currentIndex.load(std::memory_order_relaxed);
        std::copy(newData.begin(), newData.end(), buffers[nextIndex].begin());
        std::atomic_thread_fence(std::memory_order_release);   // 数据写完,才改下标
        currentIndex.store(nextIndex, std::memory_order_relaxed);
    }
    std::vector<T> read() const {
        size_t index = currentIndex.load(std::memory_order_relaxed);
        std::atomic_thread_fence(std::memory_order_acquire);   // 读到下标之后,才读数据
        return buffers[index];
    }
private:
    std::vector<T> buffers[2];
    std::atomic<size_t> currentIndex;
};

两道屏障是配对的:写的一方的 release 屏障保证数据都写完了才改下标,读的一方的 acquire 屏障保证读到新下标之后,读的数据是写完的那份(#2 讲过 acquire / release)。

3.2 写得快,就会写到正在被读的那一份

这个做法默认读的一方读完一份之前,写的一方不会回到这一份上。可没人保证这一点。读的一方读到下标 0,开始拷 buffers[0];这时写的一方更新一次,写 buffers[1],下标改成 1;紧接着又更新一次,下一份就是 buffers[0],正在被拷的那一份。读的一方拷出来的数据,一半是旧的,一半是新的。

实测:写线程不停更新,每次把整个数组(4096 个 int)填成同一个数;读线程每次读一份,检查是不是全相等。2 秒里读了约 130 万次,有 1551–1933 次读到了新旧混杂的数据,跑 3 次都是这样。

要用这种结构,就得保证写的一方回到一份之前,读这份的人都已经读完。常见的几种做法:

  • 版本号(seqlock):写之前把版本号加成奇数,写完再加成偶数;读的一方读数据前后各读一次版本号,是奇数或者前后不一样就重读。适合小块数据,比如一档盘口快照。
  • 三缓冲(triple buffer):多一份空闲的缓冲。写的一方总是写那份空闲的,写完用一次原子交换把它和“最新的那份”对调;读的一方要读时,也用一次原子交换把手里那份换成最新的。写的一方永远碰不到读的一方手里那份,代价是只能拿到最新的数据,中间的更新可能被跳过,正好适合“只要最新行情、最新参数”的场合。
  • 等读者走完再回收:每次更新写一份新的,换掉指针,旧的那份等所有读者都离开之后再回收,这是 RCU 的思路。

另外,read() 按值返回一个 std::vector,每读一次都要分配内存、拷一整份,热路径上应该原地读,不拷贝。

3.3 预热放错了核

这类双数组常配一个“干跑预热”:每次 update 之后,把两份数据各按缓存行读一遍。可 update 跑在写的那个线程上,按 2.3 的道理,热起来的是写线程那个核的缓存,交易线程那个核什么也没得到。备用的那一份下次更新就会被覆盖,预热它也只对写的一方有用。要预热,就放到交易线程自己的空闲时间里。