五根支柱裡沒有任何一根是「演算法更聰明」。它們全部在做同一件事——把作業系統從 hot path 上踢出去:不要 syscall、不要 kernel copy、不要 OS 排程器決定你的 thread 何時跑、不要 clock_gettime 去問核心現在幾點。把 OS 拿掉之後剩下的,才是硬體本身的延遲。
低延遲交易系統的五根支柱——從 NIC kernel bypass 到 p99,把 wire-to-wire 拆到微秒級
把一筆 market data 從網卡進來、跑完策略、把 order 從網卡送出去,這條路徑叫 wire-to-wire。量它的方式很直接:order_send_timestamp - market_data_receive_timestamp。一個調校過的 C++ 系統把這段壓到 p95 < 50µs;同樣邏輯的 Java 系統連 p50 500µs 都摸不到。差距不在語言「比較快」這種模糊說法,而在五個具體的工程決定,每一個都針對 wire-to-wire 路徑上某一段由作業系統造成的固定開銷。這篇把這五根支柱逐一拆開——DPDK kernel bypass、preallocated memory pool、CPU/NUMA affinity、TSC 計時、network tuning——並且強調一件常被略過的前提:沒有一張 baseline latency histogram,這五個優化全部是不可見的,你根本不知道自己有沒有改善。
先把整條路徑攤平。一個 packet 從光纖進到你的策略邏輯能讀到它,傳統 kernel 路徑要經過:NIC DMA 進 ring buffer、觸發 interrupt、kernel 在 softirq context 處理、把 payload 從 kernel buffer copy 到 socket buffer、喚醒在 recvmsg() 上 block 的 user thread、context switch 回 user space、再 copy 一次到 application buffer。送出去是這條路反過來走。這中間每一個 interrupt、每一次 copy、每一次 context switch、每一個 syscall,都是固定開銷——跟你的策略多複雜無關,packet 進來就得付。五根支柱的共同主題,就是把這些固定開銷一個一個抽掉。下面的互動圖把這五根支柱放回它們在 wire-to-wire 路徑上對應的位置;點任一根支柱,看它負責抽掉哪一段、又刻意不負責什麼。
click a pillar above
DPDK bypass · 責任邊界
用 polling mode driver 直接跟 NIC 對話,繞過 kernel network stack——packet 直接 DMA 進 user-space 管理的 ring,不經 interrupt、不經 kernel-to-user copy、不經 socket syscall。調校得當時 market data 處理可省 20 到 45µs。
不負責的事:packet 進到 user space 之後怎麼配記憶體(memory pool 的事)、跑在哪個 core(NUMA 的事)。DPDK 只保證「byte 從 NIC 到你手上沒繞 kernel」。
memory pool · 責任邊界
啟動時一次性 pre-allocate 大塊記憶體,在其上自建 allocator,runtime 從 pool 切配,把所有 syscall 從 hot path 上完全消除。critical path 上沒有 malloc、沒有 page fault、沒有 brk/mmap。
不負責的事:byte 怎麼進來(DPDK 的事)。pool 只回答「這段記憶體不會在 hot path 觸發 kernel」。
NUMA pin · 責任邊界
把 critical thread pin 到同一個 NUMA node 內的 core,記憶體也配在同一 node 的本地 DRAM。範例的 2-node × 8-core 機器,可以「跑兩組獨立策略、各佔一個 NUMA node,同時把 logging 與 observability 隔離在 critical path 之外」。
不負責的事:thread 內部做什麼。affinity 只保證「你的 thread 不會被 OS 排到別的 core、記憶體存取不會跨 node」。
TSC clock · 責任邊界
用 CPU 的 Time Stamp Counter 當時間源,rdtsc 是一條指令、不進 kernel;取代 clock_gettime 這類 syscall。HFT 在 nanosecond granularity 量測,所以時間源本身的開銷必須接近零。
不負責的事:correctness of skew。TSC 會在同一顆 CPU 的不同 core 之間 drift,這個 skew 必須另外校正——否則它會偽裝成真的延遲尖峰。
net tuning · 責任邊界
關掉 interrupt 改用 polling 避免打斷 application flow、加大 RX buffer 降低 packet loss、加大 TX backlog queue。這是 DPDK 之上的細部旋鈕——bypass 把 kernel 拿掉,tuning 把剩下的 NIC 行為對齊「永遠在跑、永遠不被打斷」。
不負責的事:packet 內容語意。tuning 只調「NIC 與 driver 的節奏」。
互動圖表
DPDK、memory pool、NUMA、TSC、net tuning 五根支柱各自拿掉一段 OS 固定開銷,全部到位才達 p95 < 50µs。
注意這五根支柱不是「依序執行的步驟」,而是同一條 hot path 上五個不同位置的固定開銷被五種手段各自移除。它們是 compose 而非 sequence:DPDK 把 packet 帶進 user space,但如果你接著在 hot path 上 malloc 一個 buffer,剛剛省下的 20µs 又被一次 page fault 吃回去。NUMA affinity 把 thread pin 好了,但如果你還在用 clock_gettime 打時間戳,每次量測都進一次 kernel。五根支柱要全部到位,wire-to-wire 才會真的落在 sub-50µs;少一根,那一根對應的開銷就整段回來。下面的互動小工具讓你逐根加上支柱,看 wire-to-wire 預算如何從 syscall 主導的數百微秒收斂——拉動 slider,每多開一根支柱,預算就往 sub-50µs 靠近一步。
說明性估算,非單一 benchmark:以 source 的 p95 < 50µs 目標、DPDK −20…45µs、…
五根支柱全啟用後 p95 從約 520µs 降至約 48µs;第一根 DPDK 降幅最大(520→300µs),其餘每根都有實質壓降。
DPDK kernel bypass:直接對著 NIC 講話
第一根支柱拿掉的是整條路徑上最大的一塊固定開銷——kernel network stack。傳統路徑下,packet 進到你能讀它,要付 interrupt、softirq、kernel-to-socket copy、wake-up、context switch、再一次 socket-to-application copy。DPDK(Data Plane Development Kit)的做法是繞過這整套:NIC 用 poll mode driver,packet 直接 DMA 進一塊 user space 管理的 ring buffer,application 在 user space 直接 polling 這個 ring。沒有 interrupt(你主動去問 NIC「有沒有新 packet」而不是等它敲你),沒有 kernel-to-user 的 double copy(packet 落地的那塊記憶體就是你要讀的記憶體),沒有 recvmsg() syscall。source 給的數字很具體:調校得當時,market data 處理可以省下 roughly 20 to 45 microseconds。
要理解這 20–45µs 從哪裡來,得把傳統路徑上每一筆開銷攤開。一次 hardware interrupt 從觸發到 handler 開始跑大約是 single-digit microsecond;softirq 排程與處理又是幾微秒;kernel buffer 到 socket buffer 的 copy、socket buffer 到 application buffer 的 copy,每一次都是 memory bandwidth 與 cache pollution 的代價;最貴的是把 block 在 recvmsg() 上的 thread 喚醒並 context switch 回來——這一下 scheduler 介入、TLB 可能被刷、L1/L2 cache 被換掉,動輒就是十幾微秒的尾巴。DPDK 把這些全部換成一個 tight polling loop:CPU 100% 燒在「問 NIC ring 有沒有新 descriptor」上,一旦有就立刻處理,packet-to-application 的延遲塌縮成幾次記憶體存取。
代價也得講清楚。Poll mode driver 意味著那顆 core 永遠在 100% 跑——它不睡、不讓出、不省電。對一台桌機這是荒謬的浪費,對 HFT 這正是要的:你寧可一顆 core 永遠燒著,也不要任何一個 packet 因為 CPU 剛好在 idle 而多等一次 wake-up。另一個代價是 DPDK 接管了 NIC,這張卡 kernel 就看不到了——你不能再用 tcpdump、不能用一般的 socket API,network stack(如果你需要 TCP)得自己在 user space 重建或用 DPDK 生態的實作。對只收 UDP multicast market data、送 order 走簡單協定的交易系統,這個 trade 划算;對需要完整 TCP 語意的一般後端,門檻就高了。
// 傳統 kernel 路徑(每個 packet 都付這些固定開銷)
NIC → interrupt → softirq → kernel buffer
→ copy to socket buffer
→ wake recvmsg() thread → context switch
→ copy to application buffer // double copy + syscall + wakeup
// DPDK poll mode(hot path 上一條 syscall 都沒有)
loop forever: // 一顆 core 100% 燒在這
n = rte_eth_rx_burst(port, queue, bufs, BURST)
if n > 0:
for pkt in bufs[0..n]:
handle(pkt) // pkt 落地的記憶體就是你讀的記憶體
// 省下:interrupt + softirq + 2× copy + wakeup + context switch ≈ 20–45µs
這根支柱是其他四根的前提。沒有 bypass,packet 還在 kernel 裡繞,後面 memory pool 省下的那點 allocation 時間、TSC 省下的那點計時開銷,相對於 kernel stack 的幾十微秒都是零頭。先把最大的固定開銷拿掉,剩下四根才有意義——這也是為什麼上面的 slider 第一格(0→1 根)掉得最猛:DPDK 一上,預算就從 ~520µs 砍到 ~300µs。
preallocated memory pool:hot path 上不准 malloc
第二根支柱對付的是一個在一般後端被視為理所當然、在 hot path 上卻是災難的東西——runtime heap allocation。一次 malloc 看起來只是拿一塊記憶體,但它背後可能觸發 brk 或 mmap syscall(進 kernel)、可能觸發 page fault(第一次 touch 那頁時 kernel 要配實體頁)、可能在 allocator 內部走 free-list 搜尋或 lock contention。這些開銷不固定、不可預測——大部分時候 malloc 很快,但偶爾一次就是幾十微秒的尾巴,而 p99/p99.9 量的就是這些「偶爾一次」。source 的描述很直接:做法是「在啟動時 pre-allocate 記憶體段、在其上自建 custom allocator 來 runtime 分配,首要目標是把所有 syscall 從 hot path 上完全消除」。
具體做法是啟動時一次大的 heap allocation 涵蓋最大記憶體需求,之後交易期間由 custom allocator 在這塊預配好的記憶體裡切配,不再呼叫 OS。常見的形態是 object pool 與 ring buffer:每種 hot-path 物件(order、market data tick、message)預先配好固定數量,用完歸還、不釋放回 OS。配與還都退化成指標運算或 free-list push/pop,O(1)、不進 kernel、不觸發 page fault(因為那些頁在啟動時就 touch 過、甚至用 mlock 鎖在 RAM 裡,不會被換出)。
// 啟動時:一次性 pre-allocate,touch 每一頁讓 kernel 真的配實體頁
pool = mmap(MAX_BYTES, MAP_POPULATE) // 一次 syscall,啟動時付
mlock(pool, MAX_BYTES) // 鎖在 RAM,不准換出
for page in pool: page[0] = 0 // 預先 fault-in,避免 hot path page fault
// hot path:配與還都不進 kernel、O(1)
Order* acquire(): return free_list.pop() // 指標運算
void release(o): free_list.push(o) // 指標運算
// 沒有 malloc、沒有 brk/mmap、沒有 page fault、沒有 lock
這根支柱跟前一根是互補的:DPDK 把 byte 帶進 user space,memory pool 確保你處理這些 byte 時不會因為一次 allocation 把剛省下的時間吐回去。一個典型的失誤是 hot path 上某條罕見分支(例如錯誤處理、罕見的訂單型別)裡藏了一個 std::string 或 std::vector 的隱式 allocation——平常 90% 的 packet 走不到那條分支,所以 p50、p95 都漂亮,但那 1% 走到的 packet 就吃一次 malloc 尾巴,p99.9 直接爆掉。這也是為什麼第六節要講 baseline histogram:如果你只看 p50,這種尾巴你永遠看不到。
C++ 在這裡的優勢不是「跑得快」,而是「allocation 是顯式的、可控的」。你可以 placement new 進 pool、可以 override operator new、可以用 PMR(polymorphic memory resource)把整個 subsystem 綁到一個 monotonic buffer。Java / C# 的問題正相反:GC 的存在意味著 allocation 永遠在背景發生,而 GC pause——即便是 concurrent collector——也會在不可預測的時刻插進來,造成幾百微秒甚至毫秒級的 stop-the-world 尾巴。這就是為什麼 source 說 Java 系統連 p50 500µs 都摸不到:不是 Java 的 throughput 差,是它的 tail latency 被 GC 與 runtime allocation 釘死在那個量級。
CPU / NUMA affinity:把 thread 釘在一個 NUMA node 上
第三根支柱對付的是兩個不可預測來源:OS scheduler 與 cross-NUMA 記憶體存取。預設情況下 Linux scheduler 會為了「公平」把你的 thread 在 core 之間搬來搬去——每搬一次,那顆 core 上辛苦 warm 起來的 L1/L2 cache 全部作廢,新 core 上要重新 warm,這中間就是 cache miss 的延遲。更糟的是 cross-NUMA:現代多 socket 機器的記憶體是分 node 的,core 存取本地 node 的 DRAM 快,存取另一個 node 的 DRAM 要走 inter-socket interconnect(QPI/UPI),latency 高出可觀的一截。如果你的 thread 在 node 0 的 core 上跑,但資料配在 node 1 的記憶體,每次存取都付這個 cross-node tax。
解法是把 critical thread pin 死在特定 core 上(pthread_setaffinity_np 或 taskset),並確保它用的記憶體配在同一個 NUMA node 的本地 DRAM(numactl --membind 或 libnuma)。source 給的範例是一台 8-core-per-node、2-node 的機器:可以「跑兩組獨立策略、各佔一個 NUMA node,同時把 logging 與 observability 隔離在 critical path 之外」。這個 layout 的關鍵在「隔離」——hot path 的 thread 各自獨佔 core,永遠不被排程器打斷;而 logging、metrics 收集、observability 這些不在 hot path、但又必須跑的東西,被推到別的 core(甚至別的 node),不准碰 critical thread 的 core。
# 把 hot-path thread pin 到 node 0 的 core 2,記憶體綁 node 0 本地 DRAM
$ numactl --cpunodebind=0 --membind=0 ./strategy_engine
# 進一步:把該 core 從 kernel scheduler 手上整顆隔離出來
# (boot cmdline)讓 scheduler 永遠不會把別的 task 排到它上面
isolcpus=2,3,10,11 nohz_full=2,3,10,11 rcu_nocbs=2,3,10,11
# 結果:core 2 上只有你的 thread 在跑,沒有 timer tick、沒有 RCU callback、
# 沒有別的 task——cache 永遠 warm,沒有 context switch 尾巴
這裡有一個常被忽略的細節:DPDK 的 polling thread、策略 thread、與資料所在的記憶體,三者最好都在同一個 NUMA node。如果 NIC 接在 node 0 的 PCIe lane 上,packet DMA 落在 node 0 的記憶體,但你的策略 thread 跑在 node 1,那 DPDK 省下的 kernel bypass 又被 cross-node 存取吃掉一截。所以這三根支柱(bypass、pool、affinity)在實務上是綁在一起設計的:NIC、ring buffer、memory pool、策略 thread,全部對齊到同一個 node。affinity 不負責 thread 內部做什麼,它只保證「你選的 core 永遠是你的、你存取的記憶體永遠是本地的」——把 OS scheduler 與 interconnect 這兩個不可預測來源從 tail latency 裡拿掉。
TSC 計時:用一條指令取代一個 syscall
第四根支柱很容易被當成細節略過,但它同時是「優化」也是「量測前提」。要算 wire-to-wire latency,你得在 packet 進來時打一個時間戳、在 order 送出時打另一個,相減。問題是「打時間戳」這個動作本身有開銷——如果你用 clock_gettime(CLOCK_MONOTONIC),在最好的情況(vDSO)它不進 kernel,但仍有函式呼叫與計算開銷;在最差情況它就是一個 syscall,幾百 nanosecond 起跳。當你的整條 wire-to-wire 才幾十微秒、而你又想在路徑上的多個點打時間戳做 profiling,計時開銷本身就會污染你要量的東西。
解法是用 TSC(Time Stamp Counter)。TSC 是 CPU 內建的一個 64-bit counter,每個 clock cycle 遞增,用 rdtsc(或 rdtscp)一條指令就能讀出來,不進 kernel、不需要 syscall。source 的部署建議直接寫「Use TSC for all timing on the critical path」。讀一次 TSC 是個位數 nanosecond 等級,比任何 clock syscall 都低一兩個數量級——這讓你可以在 hot path 上密集打點而不擾動量測。
但 TSC 有一個會咬人的陷阱,而且 source 特別點名這個陷阱「specifically bites HFT systems」,因為只有在 nanosecond granularity 量測時它才會浮現。原文講得很清楚:「TSC can drift across cores on the same CPU. Left uncorrected, that skew introduces measurement errors that look like real latency spikes and can obscure genuine performance bugs.」——同一顆 CPU 的不同 core 上的 TSC 可能有偏移;若不校正,這個 skew 造成的量測誤差會「看起來像真的延遲尖峰」,把真正的 performance bug 蓋掉。想像你在 core 2 上 packet 進來時讀 TSC、在 core 3 上 order 送出時讀 TSC,兩個 counter 之間有 200ns 的 skew——你算出來的 wire-to-wire 就憑空多(或少)200ns,而這個誤差會以 spike 的形態出現在你的 histogram 上,讓你以為系統有間歇性問題,其實是計時本身在說謊。
下面這個 tab 把第四、第五兩根支柱裡「為什麼換掉預設行為」的對照攤開:時間源從 syscall clock 換成 TSC、網路從 interrupt 換成 busy-poll,並把 TSC 的 skew 陷阱單獨列一格——因為它是這篇唯一一個「優化本身會製造假資料」的地方。
switch tabs to compare 3 framings · 3 tabs
計時這件事在 wire-to-wire 量測裡是元操作——你要量的東西的精度,受限於量它的工具的開銷。
syscall clock(預設)
clock_gettime() 最好情況走 vDSO 不進 kernel,最差情況是一次 syscall,幾百 ns 起跳。在 hot path 上多點打時間戳時,計時開銷本身污染量測。
TSC(critical path 用這個)
rdtsc / rdtscp 一條指令、不進 kernel,個位數 ns 等級。source:「Use TSC for all timing on the critical path」。讓你能密集打點而不擾動。
DPDK 把 kernel 拿掉之後,NIC 與 driver 的行為還有三個旋鈕要對齊「永遠在跑、永遠不被打斷」。
interrupt 模式(預設)
NIC 收到 packet 敲 interrupt 打斷 application flow——每次打斷都是 context switch 與 cache pollution 的代價。
busy-poll + 加大 buffer
關掉 interrupt、改 polling 避免打斷 application flow;加大 RX buffer 降 packet loss;加大 TX backlog queue。三者都是「用 CPU 換確定性」。
這是全篇唯一一個「優化本身會製造假資料」的地方,也是 source 特別點名 TSC「specifically bites HFT systems」的原因。
未校正的 cross-core skew
「TSC can drift across cores on the same CPU.」在 core 2 讀進入時間、core 3 讀送出時間,兩個 counter 間若有 200ns 偏移,算出的 wire-to-wire 就憑空多或少 200ns。
校正後
確認 constant_tsc / nonstop_tsc、量測 per-core offset 並補償,或把進入與送出的計時固定在同一 core。否則 skew「look like real latency spikes」,把真 bug 蓋掉。
互動圖表
rdtsc 一條指令取時間、無需 syscall;但 cross-core 偏移若未校正,量測誤差會偽裝成延遲尖峰,遮蔽真正的 bug。
把 panel 三單獨拉出來講,是因為它違反了一般優化的直覺:通常你加一個優化,最壞情況是它沒效果;但未校正的 TSC 是「加了之後它會主動騙你」。一個團隊如果在沒校正的 TSC 上看到 histogram 出現週期性 spike,很可能花幾天去追一個根本不存在的 GC-like pause 或 lock contention,結果只是兩顆 core 的 counter 沒對齊。這把我們帶到最後、也是貫穿全篇的前提——所有這些優化,沒有一張可信的 baseline histogram 就全部不可量測。
baseline histogram:沒有它,前五根支柱全部不可見
前面五根支柱每一根都宣稱「省下 X 微秒」或「移除 Y 開銷」。問題是:你怎麼知道?延遲不是一個數字,是一個分布。一個系統的 p50 可能漂亮到 20µs,但 p99.9 是 800µs——對交易來說那條尾巴可能就是你被別人吃掉的價差。只看平均、只看 p50,等於閉著眼睛優化:你做了一個改動,p50 沒動,你以為沒效果,其實你砍掉了 p99.9 的一個尾巴;或者反過來,你「優化」了某段邏輯,p50 好看了 2µs,但引入了一個罕見的 allocation,把 p99.9 拉高了 300µs,而你完全沒看到。
所以第一步、在碰任何一根支柱之前,要先建一張 baseline latency histogram,量 wire-to-wire 以及路徑上每一段的 p50、p95、p99、p99.9。source 列的觀測項很完整:「track p50, p95, p99, and p99.9 latencies at both levels」(wire-to-wire 與路徑分段兩個層級),外加 syscall latency、CPU utilization、NIC metrics,工具用 bcc / bpftrace。有了這張 baseline,每一根支柱的效果才變成可驗證的命題:上 DPDK 前後,wire-to-wire 的 p95 應該掉 20–45µs;若沒掉,要嘛沒調對、要嘛瓶頸不在那裡。下面這張表把「為什麼要看整條分布而不是平均」量化出來——同一套邏輯,C++ tuned、C++ untuned、Java 三種配置在四個 percentile 上的差距。點欄位標題可以排序,你會發現按 p50 排跟按 p99.9 排,名次完全不同。
click column header to sort · 5 columns × 3 rows
| 配置 | p50 | p95 | p99 | p99.9 |
|---|---|---|---|---|
| C++ 五根支柱全到位 | 28 | 48 | 72 | 140 |
| C++ 未調校(kernel path) | 95 | 210 | 380 | 720 |
| Java(GC + runtime alloc) | 520 | 1,100 | 2,400 | 6,800 |
互動圖表
C++ 五根支柱全到位時 p50 為 28µs、p99.9 為 140µs;Java 的 p50 就已 520µs、p99.9 高達 6800µs。
表把四個 percentile 並排,但它沒給你「分布長什麼形狀」。延遲的右尾不是線性的——它通常是一條長到離譜的細尾。下面這張 CDF(累積分布)把同一組數字畫成曲線:X 軸是延遲(對數),Y 軸是「有多少比例的 packet 在這個延遲以內完成」。看曲線右端怎麼翹,比看四個離散數字更直觀——C++ tuned 的曲線在 ~140µs 處就貼到 100%,Java 的曲線拖到 6,800µs 還沒收。
說明性 CDF:Y = 完成比例,X = wire-to-wire 延遲(對數,µs)
C++ tuned p99 為 72µs、p99.9 為 140µs;Java 的 p99.9 直衝 6800µs,GC pause 把右尾釘在毫秒量級。
表裡最值得盯著看的是最右那欄 p99.9。C++ tuned 的 p50 是 28µs、p99.9 是 140µs——尾巴只比中位數高五倍,因為 OS 已經被踢出 hot path,沒有 GC、沒有 page fault、沒有 context switch 來製造長尾。Java 的 p50 已經 520µs,p99.9 直接衝到 6,800µs——十三倍的尾巴,而那一條尾巴的來源正是 GC pause 與 runtime allocation 的不可預測性。這就是 source 那句「Java 系統連 p50 500µs 都摸不到」的全貌:不是 Java 平均慢,是它的分布右尾被 runtime 釘死在毫秒量級,而交易系統被尾巴決定生死。
histogram 還回答一個更實際的問題:你的瓶頸在哪一段。把 wire-to-wire 拆成「NIC→user space」「parse + 策略」「order encode→NIC」三段各自打 TSC 時間戳、各自畫 histogram,你會立刻看到哪一段的尾巴最肥。下面這個小工具把這三段攤開,各自標上典型 p50/p99 與「這段的尾巴通常由誰製造」——點任一段看它對應到哪一根支柱。
把 wire-to-wire 拆成三段,各自打 TSC 時間戳、各自畫 histogram
click a segment above
S1 · NIC → user space
packet 從光纖進到策略邏輯能讀它。這段的尾巴若還在幾十微秒,代表 DPDK 沒裝好或沒繞乾淨——還在走 kernel stack 的 interrupt / copy / wakeup。
對應支柱:DPDK kernel bypass + network tuning。
S2 · parse + 策略
parse market data、跑策略、產生 order。p50/p95 漂亮但 p99.9 偶爾爆,典型病因是某條罕見分支(錯誤處理、罕見訂單型別)藏了隱式 allocation,90% 的 packet 走不到、1% 走到就吃一次 malloc 尾巴。
對應支柱:preallocated memory pool + NUMA affinity。
S3 · order encode → NIC
把 order 序列化、交給 NIC 送出。TX 段的尾巴常來自 interrupt 模式打斷 flow、或 TX backlog queue 太小造成排隊。
對應支柱:network tuning(busy-poll、加大 TX buffer)。
互動圖表
wire-to-wire 三段:NIC→user p99 14µs、parse+策略 p99 40µs、encode→NIC p99 18µs,優先砍最肥段。
如果「NIC→user space」那段 p99 還是幾十微秒,那 DPDK 沒裝好或沒繞乾淨;如果「parse + 策略」那段 p99.9 偶爾爆,十之八九是某條罕見分支藏了 allocation。沒有分段 histogram,你只能猜;有了它,優化從「憑感覺改」變成「看著數字砍尾巴」。這也是為什麼這篇把它放在最後當作壓軸而不是開頭的細節——前五根支柱是手段,baseline histogram 是讓手段變成可驗證命題的儀器。
最後把這六件事收成一張可以照著走的清單,適用於任何對 latency 敏感的後端而不只是交易系統。第一,先建 baseline histogram(p50/p95/p99/p99.9 + 分段),沒有它後面全是盲改。第二,kernel bypass 拿掉最大塊的固定開銷(DPDK,−20…45µs)。第三,hot path 上不准 runtime allocation(memory pool,把 syscall 清零)。第四,把 thread 與記憶體 pin 到同一 NUMA node(affinity,拿掉 scheduler 與 interconnect 的不確定性)。第五,計時用 TSC 不用 syscall——但記得校正 cross-core skew,否則它會騙你。第六,網路 busy-poll、加大 RX/TX buffer,把 interrupt 也拿掉。六步走完,你量的就不再是 OS,而是硬體。
What this enables:把作業系統從 hot path 上系統性地移除之後,wire-to-wire 延遲不再被 GC、syscall、interrupt、scheduler 這些不可預測的 OS 行為決定,而塌縮到由硬體本身決定的 sub-50µs——而那張 baseline histogram,是讓這六根支柱從「信仰」變成「可驗證命題」的唯一儀器。