← กลับไปยังบทความ
July 2, 2026
อ่าน 5 นาที

ภาษี IPC: เอา Backtest Engine ไปไว้หลัง Socket แล้วเสีย 13% — แต่แทบไม่มีส่วนไหนเลยเป็นความผิดของ Socket

ภาษี IPC: เอา Backtest Engine ไปไว้หลัง Socket แล้วเสีย 13% — แต่แทบไม่มีส่วนไหนเลยเป็นความผิดของ Socket
#algotrading
#backtest
#ประสิทธิภาพ
#ipc
#rust
#สถาปัตยกรรม
Part 4 of 4 · Collection
High-Performance Backtest Engines

ส่วนหนึ่งของซีรีส์ "Backtests Without Illusions"

📄 บทความนี้ขยายกลายเป็นเปเปอร์วิจัย backtest kernel แบบ path-dependent หนึ่งตัวถูก port แบบ line-for-line จาก numba ไปเป็น Rust แล้วเรียกใช้งานข้าม process/language boundary สี่แบบ พร้อม equivalence gate ที่ยืนยันว่า PnL ต่อ combo เหมือนกันทุกประการ — บวกกับการวัด pure IPC latency curve, serialization tax และ spawn cost แยกกันแบบ isolated อ่านเปเปอร์ฉบับเต็มออนไลน์ (เวอร์ชันอินเทอร์แอกทีฟ + PDF) ได้ที่ ipc-tax.marketmaker.cc โค้ดและข้อมูลอยู่ที่ github.com/suenot/ipc-tax

Backtest engine ทุกตัวที่เร็วขึ้นในที่สุดก็จะจุดบทสนทนาเดียวกันเสมอ ของเราก็มาตามกำหนด บันไดความเร็ว เพิ่งพา parameter sweep 80 combo จาก pandas ที่ใช้เวลา 69.9 วินาที ลงมาเหลือประมาณ 2 วินาทีด้วย numba แบบ single-threaded และอาการคันถัดไปที่เกิดขึ้นตามธรรมชาติคือ: ทำไมต้องหยุดแค่ Python JIT? เขียน kernel ใหม่เป็น Rust สิ ทำให้มันเป็น engine service ที่แท้จริง — binary ที่ compile แล้วหนึ่งตัวอยู่หลัง socket เรียกใช้ได้จากทุก research script ทุกภาษา และจาก live trader ด้วย kernel เดียว ความจริงเดียว ไม่มี logic ที่ซ้ำซ้อน

แล้วข้อโต้แย้งก็มาถึงตามกำหนดเช่นกัน: ทันทีที่คุณออกจาก process, IPC จะกินคุณ ข้อมูลต้องถูก serialize ส่งข้าม boundary แล้ว deserialize ทุกการเรียกต้องจ่ายค่า syscall และ context switch Rust kernel สวยงามของคุณจะใช้ชีวิตทั้งชีวิตรอคอยอยู่บน pipe อยู่ใน process เดิมไปเถอะ ทุกคนรู้เรื่องนี้ดี

บทความนี้วัดสิ่งที่ทุกคนรู้กันอยู่แล้ว และผลการวัดกลับน่าสนใจกว่าทั้งสองฝ่ายของข้อโต้แย้ง ความเชื่อพื้นบ้าน — "engine ข้ามภาษาที่เร็วกว่าจะแพ้ numba แบบ in-process เพราะ IPC ฆ่าคุณ" — กลายเป็นว่า ผิดโดยทั่วไป และถูกเฉพาะภายใต้เงื่อนไขบางอย่างเท่านั้น การข้าม boundary หนึ่งครั้ง เป็น raw bytes มีค่าใช้จ่ายประมาณ 2 มิลลิวินาที บนงานที่ใช้เวลาสองวินาที: แค่ rounding error ภาษีไม่ได้อยู่ที่ boundary มันอยู่ที่ วิธีที่คุณข้ามมัน ต่างหาก — และสามวิธีที่ engine service มักถูก deploy ในโลกจริง (JSON API, การเรียกทีละหน่วยงาน, process spawn ต่อการเรียกหนึ่งครั้ง) แต่ละแบบล้วนเป็นส่วนหนึ่งของหายนะที่ตำนานเล่าขานกันไว้ ซึ่งวัดผลได้จริง

นี่คือการทดลองทั้งหมดที่แสดงไว้ล่วงหน้า ส่วนที่เหลือด้านล่างคือกายวิภาคของแต่ละบรรทัด

สถาปัตยกรรม สิ่งที่ข้าม boundary ต่อหนึ่ง sweep เวลาจริง vs in-process
in-process numba ไม่มีอะไรเลย — เรียกโดยตรง 2.010 s 1.00x
Rust server, batched (Unix socket) round-trip เดียว: ทั้ง series + parameter set ทั้ง 80 ชุด 2.276 s 1.13x
Rust server, batched, get_unchecked kernel round-trip เดียวเหมือนกัน — kernel variant ที่ไม่มี bounds check (ดูส่วนคำตัดสิน) 2.337 s 1.16x
Rust server, chatty (Unix socket) 80 round-trip: series ถูกส่งซ้ำทุก combo 2.383 s 1.19x
Rust spawn (stdin/stdout) process spawn + คำขอหนึ่งครั้งผ่าน pipe 2.300 s 1.14x

Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0 (release build, ไม่มี external crate) 150,000 บาร์ × 80 combo, ค่าธรรมเนียม round-trip 0.09%, seed 42; close series มีขนาด 1,200,000 byte (1.2 MB) บน wire ค่ามัธยฐานจาก 10 รันต่อสถาปัตยกรรม; ช่วง min–max อยู่ภายใน ~2% ทั้งห้าสถาปัตยกรรมรัน HMA/HMA3 stop-and-reverse sweep เดียวกัน และ equivalence gate ยืนยันว่าผลลัพธ์ (PnL, จำนวน trade) ต่อ combo ของ Rust kernel ทั้งสอง variant ตรงกับ numba ทุกประการ — fingerprint PnL −5165.58 บน 57,029 trade ตรงกับ numba kernel ของ speed-ladder study ทุกไบต์บน seed เดียวกัน เรากำลังเปรียบเทียบ boundary ไม่ใช่ implementation

อ่านแถว batched ให้ดี เพราะมันแบกทั้ง thesis ของบทความนี้ไว้ สถาปัตยกรรม Rust-over-a-socket ช้ากว่า numba แบบ in-process 1.13x — ตามหลังอยู่ 266 ms บน sweep เต็ม (คำนวณ: 2.276 − 2.010) เรื่องเล่าพื้นบ้านบอกว่ามิลลิวินาทีเหล่านั้นคือ IPC แต่ไม่ใช่ ประมาณ 2 ms ของช่องว่างนั้นคือ boundary — close series ทั้ง 1.2 MB ที่ส่งเข้าไป ผลลัพธ์ที่ส่งกลับมา วัดโดยตรง อีก ~264 ms ที่เหลือคือ Rust kernel แบบไร้เดียงสาของเราคำนวณ sweep ช้ากว่า numba kernel ประมาณ 13% เท่านั้นเอง (คำนวณ: 2.276 s ลบ boundary ~2 ms ≈ 2.274 s ของ Rust compute เทียบกับ 2.010 s ของ numba) ภาษา Rust ไม่ได้แพ้ภาษา Python; scalar loop ที่ compile ด้วย LLVM ตัวหนึ่งแพ้การแข่งขัน codegen ให้กับอีกตัวหนึ่ง — และเราไม่สามารถชี้ความผิดไปที่ผู้ต้องสงสัยที่ชัดเจนได้ด้วยซ้ำ: build ของ kernel เดียวกันที่ใช้ get_unchecked แบบไม่มี bounds check กลับไม่เร็วขึ้นเลย (2.337 s; ส่วนคำตัดสินจะชำแหละเรื่องนี้) socket แทบไม่เกี่ยวข้องกับเรื่องนี้เลย

จับทั้งสองครึ่งของประโยคนี้ไว้ boundary นั้นแทบจะฟรี เมื่อข้ามอย่างถูกวิธี — และ "เขียนใหม่เป็น Rust" ซื้อให้คุณแค่ deployment boundary ไม่ใช่ชัยชนะด้าน compute แบบอัตโนมัติ ทั้งสองข้อเท็จจริงขัดกับสัญชาตญาณทั่วไป และทั้งคู่อยู่ในตารางแล้ว

Kernel เดียว สองภาษา สี่ Boundary

Workload นี้จงใจใช้ตัวเดียวกับที่ บันไดความเร็ว ตรึงไว้ เพื่อให้ทั้งสอง study ยึดโยงกัน kernel คือ HMA/HMA3 cross — ระบบ stop-and-reverse บน Hull-style moving average สองตัว, weighted-moving-average pass เจ็ดครั้งต่อหนึ่ง parameter combination บวกกับ event loop แบบ stateful ทีละบาร์ที่ถือ position ไว้ บันทึก PnL ลบด้วยค่าธรรมเนียม round-trip 0.09% ทุกครั้งที่เกิด cross แล้วกลับทิศทาง ข้อมูลคือ 150,000 บาร์ของ synthetic geometric Brownian motion ที่มี seed คงที่ (seed=42); grid คือความยาว HMA 80 ค่า กระจายอยู่ใน [6,200][6, 200] reference แบบ in-process คือขั้น numba single-threaded ของบันได ที่วัดซ้ำสำหรับ study นี้: 1.98 s ที่นั่น, 2.010 s ที่นี่ — kernel เดียวกัน เครื่องเดียวกัน น่าเบื่ออย่างน่าอุ่นใจ

Engine ข้ามภาษาคือการ port numba kernel ตัวนั้นไปเป็น Rust แบบ line-for-line — loop เดียวกัน การจัดการ NaN เดียวกัน เลขคณิตค่าธรรมเนียมเดียวกัน — compile ในโหมด release โดยไม่มี external crate เลย ดังนั้นการทดลองทั้งหมดจึงไม่มี dependency และทำซ้ำได้ มันพูด binary protocol ที่จงใจทำให้เรียบง่ายที่สุด: frame ที่มี length-prefix หนึ่งอันในแต่ละทิศทาง ทุกอย่างเป็น little-endian

request:  [u32 body_len][body]
body:     [u8 opcode][u32 n_bars][u32 n_combos]
          [n_bars × f64 close][n_combos × 6 × i64 params]

opcode 0 = sweep : reply = [n_combos × f64 pnl][n_combos × i64 trades]
opcode 1 = echo  : reply = the close array, verbatim

opcode echo คือมีดผ่าตัดของ study นี้: round-trip ที่ควบคุมขนาดได้และไม่คำนวณอะไรเลย ทำให้วัด pure boundary cost แบบแยกเดี่ยวได้ — serialization, syscall, socket transit, deserialization และไม่มีอะไรอื่นอีก

ห้าสถาปัตยกรรมที่วัดผล — สี่ boundary pattern บวกกับ kernel variant หนึ่งตัว:

  • in_process — เรียก numba kernel โดยตรง ไม่มี boundary reference
  • rust_batch_unix — Rust server แบบ persistent บน Unix domain socket round-trip หนึ่งครั้ง ส่ง close series ทั้งชุดบวก parameter set ทั้ง 80 ชุด; Rust คำนวณทุก combo; คำตอบกลับมาหนึ่งครั้ง การเรียกแบบ chunky
  • rust_batch_unchecked — batched boundary เดียวกัน แต่ kernel index ด้วย get_unchecked (ไม่มี bounds check ใน hot path) มันมีไว้เพื่อทดสอบสมมติฐานเฉพาะเกี่ยวกับช่องว่างด้าน compute; ส่วนคำตัดสินจะใช้มันจนหมด
  • rust_chatty_unix — server เดียวกัน แต่ round-trip หนึ่งครั้ง ต่อ combo series ขนาด 1.2 MB ถูกส่งซ้ำทุกครั้ง สถาปัตยกรรม RPC-per-unit-of-work แบบไร้เดียงสา
  • rust_spawn_stdin — spawn binary ต่อหนึ่ง sweep แล้ว pipe request ผ่าน stdin pattern แบบ "shell out ไปยัง CLI engine"; ต้องจ่ายค่า process creation

และ equivalence gate ที่ถ้าไม่มีมันเรื่องทั้งหมดนี้จะไม่มีความหมายอะไรเลย: หลังจับเวลาแล้ว vector (PnL, จำนวน trade) ต่อ combo ของ Rust แต่ละ variant จะถูกเทียบกับของ numba — จำนวน trade ต้องตรงเป๊ะ PnL ต้องอยู่ใน absolute tolerance 10610^{-6} การรันที่ commit ไว้รายงาน all_ok: true ทั้งสำหรับ build แบบ safe-indexing และ get_unchecked fingerprint ของ combo แรก — PnL −5165.58 percentage point บน 57,029 trade — ตรงกับ numba kernel ของ speed-ladder study ทุกหลักทุกตัว ซึ่งตรึงทั้งสองเปเปอร์ไว้กับ kernel เดียวกันบน seed เดียวกัน การ port ข้ามภาษาคือจุดที่การเบี่ยงเบนแบบเงียบๆ ชอบซ่อนตัวอยู่พอดี (ค่าธรรมเนียมที่ถูกใส่ก่อนแทนที่จะเป็นหลังการแปลงเป็นเปอร์เซ็นต์, การเปรียบเทียบ NaN ที่ branch ต่างกัน, off-by-one ใน window — บั๊กสายพันธุ์เดียวกับที่ look-ahead taxonomy ของเราแสดงให้เห็นว่าสามารถสร้าง Sharpe 15 ขึ้นมาจาก noise ได้) benchmark ของสอง engine ที่คำนวณสิ่งที่ต่างกันไม่ใช่ benchmark มันคือโปรแกรมสองตัวที่ไม่เกี่ยวข้องกันกำลังแข่งกันวิ่ง

เมื่อ equivalence ถูกยืนยันแล้ว ทุกความแตกต่างในตารางข้างต้นคือ boundary และ compute — ไม่มีอะไรอื่นอีก

การข้ามจริงๆ มีค่าใช้จ่ายเท่าไหร่: echo curve

ค่าใช้จ่ายที่วัดได้ของการข้าม boundary: latency curve ที่ราบเรียบที่สิบสี่ไมโครวินาทีสำหรับ payload เล็กๆ โค้งขึ้นก็ต่อเมื่อเกินหนึ่งหมื่น float แล้วเท่านั้น ไปถึงสองมิลลิวินาทีสำหรับ series เต็ม 1.2 เมกะไบต์

เริ่มด้วยมีดผ่าตัดกันก่อน echo op ทำ round-trip payload ขนาด nn float ผ่าน Rust server — Python สร้าง frame, server parse float ทั้ง nn ตัว, encode ใหม่ แล้วส่งกลับมา ทั้งสองทิศทางต้องจ่ายค่า serialization, syscall และ socket transit นี่คือ curve ที่วัดได้ (ค่ามัธยฐานจาก 10 รัน):

Payload (float) Byte ต่อทิศทาง Round-trip
1 8 14.1 µs
100 800 16.4 µs
1,000 8,000 18.1 µs
10,000 80,000 192.5 µs
100,000 800,000 1,367.3 µs
150,000 1,200,000 2,043.4 µs

มีข้อเท็จจริงเชิงโครงสร้างสองข้ออยู่ในตารางนี้

ข้อแรก floor round-trip ที่แทบไม่ได้พาอะไรไปเลย — 8 byte — มีค่าใช้จ่าย 14 µs นั่นคือราคาที่ลดทอนไม่ได้ของการ เรียกใช้งานเลยแม้แต่ครั้งเดียว บน transport นี้: syscall write สองครั้ง, syscall read สองครั้ง, กลไก socket ของ kernel, การปลุก scheduler สังเกตว่า curve ราบเรียบแค่ไหนทางด้านซ้าย: จาก 1 float ไปจนถึง 1,000 float ค่าใช้จ่ายแทบไม่ขยับเลย (14.1 → 18.1 µs) ต่ำกว่าประมาณ 8 KB คุณกำลังจ่ายค่า การเรียก ไม่ใช่ค่า byte ตัวเลขนี้ — latency floor — คือค่าคงที่ที่สำคัญที่สุดตัวเดียวในทั้ง study และเราจะสร้าง break-even arithmetic บนมันด้านล่าง

ข้อสอง slope เกิน ~10,000 float ไปแล้ว curve จะกลายเป็น bandwidth-bound และเกือบเป็นเส้นตรง series เต็ม 1.2 MB — เคลื่อนย้ายรวม 2.4 MB ทั้งไปและกลับ รวมถึงการ parse และ re-encode float 150,000 ตัวเต็มรูปแบบบนฝั่ง Rust — มีค่าใช้จ่าย 2,043.4 µs นั่นเทียบเท่ากับ ~1.2 GB/s ที่ effective ผ่านทั้ง stack แบบไร้เดียงสา (คำนวณ: 2.4 MB / 2.04 ms) — Unix domain socket ที่มี length-prefixed frame และ float parser แบบ byte-by-byte ไม่มีเทคนิค zero-copy ไม่มี shared memory ไม่มีอะไรฉลาดเป็นพิเศษเลย

โมเดลอย่างสมเหตุสมผลของการข้ามหนึ่งครั้ง โดยที่ค่าคงที่ทั้งสองตัวถูกวัดไว้แล้ว:

Tcall(b)    14 μsfloor  +  2b1.2 GB/spayload, both waysT_{\text{call}}(b) \;\approx\; \underbrace{14\ \mu\text{s}}_{\text{floor}} \;+\; \underbrace{\frac{2b}{1.2\ \text{GB/s}}}_{\text{payload, both ways}}

ทีนี้ลองใส่ตัวเลขพาดหัวลงในบริบทดู sweep เต็มใช้เวลา 2.010 s แบบ in-process การส่ง dataset ทั้งชุดข้าม boundary แล้วกลับมามีค่าใช้จ่าย ~2.0 ms — ประมาณ 0.1% ของงานทั้งหมด (คำนวณ: 2.0434 ms / 2.010 s) ถ้าคุณข้ามแค่ครั้งเดียว เป็น raw byte boundary ก็แค่ rounding error นั่นคือครึ่งหนึ่งของความเชื่อพื้นบ้านที่ตายก่อนใคร: ความกลัวไม่เคยเกี่ยวกับอะไรที่ถูกขนาดนี้เลย

ฝั่ง Rust ของการข้ามครั้งนี้ก็ไม่หรูหราอะไรเลย เหมือน systems code ทั่วไป — ดัดแปลงมาจาก engine/src/main.rs:

fn read_frame<R: Read>(r: &mut R) -> Option<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf).ok()?;
    let len = u32::from_le_bytes(len_buf) as usize;
    let mut body = vec![0u8; len];
    r.read_exact(&mut body).ok()?;
    Some(body)
}

fn write_frame<W: Write>(w: &mut W, body: &[u8]) {
    w.write_all(&(body.len() as u32).to_le_bytes()).unwrap();
    w.write_all(body).unwrap();
    w.flush().unwrap();
}

// the server is a loop: read frame -> compute -> write frame
for stream in listener.incoming() {
    serve_stream(stream.unwrap());
}

ข้อสังเกตเรื่องขอบเขตอย่างตรงไปตรงมาก่อนไปต่อ: ตัวเลข boundary ทั้งหมดใน study นี้คือ Unix domain socket บนโฮสต์เดียว engine ยังพูด TCP ได้ด้วย (พร้อม TCP_NODELAY) แต่เราไม่ได้วัดมัน; loopback TCP อยู่สูงกว่า floor พวกนี้เล็กน้อย และ network hop จริงๆ ก็เป็นระบบที่ต่างออกไปโดยสิ้นเชิง — floor ระดับมิลลิวินาที ไม่ใช่ไมโครวินาที ดังนั้นทุกอย่างที่นี่จึงเป็น near-best case สำหรับการข้าม boundary แบบนี้ ซึ่งทำให้ภาษีที่จะวัดต่อไปน่าตำหนิยิ่งขึ้นไปอีก: มันคือสิ่งที่คุณจ่าย เพิ่มเติมจาก นั้น โดยเลือกเอง

ภาษี Serialization: 1348 เท่าสำหรับการเลือกใช้ JSON

การ encode สอง array float 150,000 ตัวเดียวกันวางเทียบกัน: raw-bytes memcpy ที่วัดเป็นไมโครวินาที เทียบกับ JSON text encoding ที่สูงกว่าถึงสามอันดับขนาด

ตรงนี้แหละที่ความเชื่อพื้นบ้านเรื่อง "IPC overhead" กลายเป็นการติดป้ายผิด เราวัดค่าใช้จ่ายของการ encode close series 150,000 float เดียวกันสามวิธี — payload เดียวกันเป๊ะกับที่ทุกสถาปัตยกรรมข้างต้นส่งออกไป:

Encoding เวลาในการ encode float 1.2 MB vs raw
raw bytes (.tobytes()) 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON (json.dumps(close.tolist())) 66,243 µs 1348x

raw path ก็คือ memcpy ที่สวมคราบเป็น function call:

def build_request(opcode, close, params):
    body = bytes([opcode]) + struct.pack("<II", len(close), len(params))
    body += close.astype("<f8").tobytes()      # 150,000 floats -> 1.2 MB in 49 µs
    body += np.asarray(params, dtype="<i8").reshape(-1).tobytes()
    return struct.pack("<I", len(body)) + body  # length-prefixed frame

(pickle จริงๆ แล้วถูกกว่า raw path ของเราเล็กน้อยด้วยซ้ำ เพราะ astype ต้องจ่ายค่า copy จากการแปลง dtype แม้ dtype จะตรงกันอยู่แล้ว ทั้งสองอย่างอยู่ในคลาส memcpy และทั้งคู่เป็น rounding error ตระกูล binary ทั้งหมดอยู่ต่ำกว่าตระกูล text ถึงสามอันดับขนาด)

และ text path คือสิ่งที่แทบทุก deployment แบบ "มาทำ engine ให้เป็น microservice กันเถอะ" ส่งออกไปจริงๆ:

body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})

หกสิบหก มิลลิวินาที แค่เพื่อ encode json.dumps(close.tolist()) ห่อ float ทุกตัวเป็น Python object แล้ว render แต่ละตัวเป็น decimal text — heap allocation 150,000 ครั้ง และการแปลง float-to-string 150,000 ครั้ง ในขณะที่ raw path ทำแค่ block copy ครั้งเดียว แถม wire payload ก็บวมขึ้นด้วย (float64 มีค่าใช้จ่าย 8 byte แบบ binary และประมาณสองถึงสามเท่าของนั้นเมื่อเป็น decimal text — เรายังไม่ได้คิดค่า transit ที่เพิ่มขึ้นด้วยซ้ำ)

ทีนี้มาขยายสเกลแบบที่ deployment จริงเป็นกัน 66 ms นั้นคือ encode ครั้งเดียว ฝั่งเดียว call เดียว JSON service ต้องจ่ายค่า encode และ decode บน ทั้งสอง ฝั่งของ boundary ใน ทุก call การเรียกแบบ batched ครั้งเดียวผ่าน JSON จะเผางบประมาณ compute ของทั้ง sweep ไปประมาณ ~3.3% แค่กับการ encode ฝั่ง client เท่านั้น (คำนวณ: 66 ms / 2.010 s) ลองเอา JSON ไปใส่ในสถาปัตยกรรม chatty — เรียกทีละ combo, pattern ด้านล่าง — แล้วการ encode ฝั่ง client อย่างเดียวก็มีค่าใช้จ่าย 80 × 66 ms = 5.3 s: มากกว่างานที่มีประโยชน์ทั้งหมดถึงสองเท่าครึ่ง (คำนวณ) ก่อนที่ byte แม้แต่ตัวเดียวจะเคลื่อนที่ และก่อนที่ server จะ parse อะไรเลยด้วยซ้ำ

นี่คือ "ภาษี IPC" ตัวจริงที่หลายทีมวัดเจอใน production โดยไม่รู้ตัว มันไม่เคยเป็น inter-process communication เลย มันคือ text serialization ของ numeric array — 1348 เท่าที่สร้างขึ้นเองบนส่วนที่ควรจะถูกที่สุดของ boundary โลกของข้อมูลแบบ columnar เรียนรู้บทเรียนนี้มาหลายปีแล้ว และเป็นบทเรียนเดียวกับที่ Polars vs pandas study ของเราเจอซ้ำแล้วซ้ำเล่าจากฝั่ง data pipeline: format อย่าง Arrow มีอยู่ก็เพื่อให้ array data ข้าม process และ language boundary ได้ในรูปแบบ raw columnar byte ไม่ใช่ text ถ้า engine service ของคุณพูด JSON สำหรับ price array ไม่มีการ tune socket ไหนช่วยคุณได้ — protocol คือ คอขวด

Chatty vs Chunky: กฎของ Fowler วัดผลจริง

สถาปัตยกรรม chunky ที่ส่ง payload ขนาดใหญ่หนึ่งก้อนข้าม boundary ครั้งเดียว เทียบกับสถาปัตยกรรม chatty ที่ทำ round-trip เล็กๆ แปดสิบครั้ง แต่ละครั้งลาก dataset ทั้งชุดไปด้วย

First Law of Distributed Object Design ของ Martin Fowler — "อย่า distribute object ของคุณ" — มาพร้อม corollary ที่เขาพูดไว้ในลมหายใจเดียวกัน: ถ้าคุณจำเป็นต้องข้าม boundary interface ต้องเป็นแบบ coarse-grained เพราะ remote call มีค่าใช้จ่ายมากกว่า local call หลายอันดับขนาด veteran ด้าน distributed system ทุกคนพยักหน้าเห็นด้วย แต่แทบไม่มีใครมีตัวเลขสำหรับ workload ของตัวเองเลย นี่คือตัวเลขของเรา

สถาปัตยกรรม chunky และ chatty รันบน server เดียวกัน protocol เดียวกัน data เดียวกัน — ต่างกันแค่ granularity ของการเรียก:

srv.call(0, close, params)

[srv.call(0, close, [params[k]]) for k in range(n)]

Chunky: 2.276 s (1.13x) Chatty: 2.383 s (1.19x) — ช้ากว่า 107 ms (คำนวณ: 2.383 − 2.276) เพื่อให้แม่นยำว่า delta นี้คืออะไรและไม่ใช่อะไร: echo curve ให้การพยากรณ์แบบไร้เดียงสาไว้ — การส่ง series เต็มเพิ่มอีก 79 ครั้ง ที่ประมาณครึ่งหนึ่งของ round-trip แบบ full-payload 2,043 µs แต่ละครั้ง รวมประมาณ 81 ms — ซึ่งต่ำกว่าตัวเลขที่วัดได้จริง 107 ms อยู่ราว 25%; ส่วนที่เหลือคือการสร้าง request และ framing ต่อ call ฝั่ง Python ซึ่งการพยากรณ์จาก echo ไม่ได้รวมไว้ ไม่ว่าจะมองแบบไหนก็ตกอยู่ที่ ~1.4 ms ต่อการข้ามเพิ่มหนึ่งครั้ง (คำนวณ: 107 / 79); คำตอบกลับนั้นเล็กน้อยจนไม่ต้องนับ — 16 byte ต่อ combo

มีสองมุมมองต่อ 107 ms นี้ และทั้งคู่สำคัญ

มุมมองแบบผ่อนปรน: มันแค่ ~4.5% ของเวลาทั้งหมด ไม่ใช่หายนะ จริง — และคุ้มค่าที่จะเข้าใจว่า ทำไม หายนะตามตำนานถึงไม่เกิดขึ้นจริงตรงนี้ แต่ละ chatty call ยังคงพา compute จริง 25,130 µs ติดไปด้วย (ค่าของหนึ่ง combo — ต้นทุนต่อ combo แบบ in-process ที่วัดได้) ดังนั้น boundary overhead ต่อ call ที่ ~1.4 ms จึงยังต่ำกว่างานต่อ call อยู่หนึ่งอันดับขนาด สถาปัตยกรรม chatty ไม่ถึงตายเมื่อแต่ละ call หนักจริงๆ มันจะเริ่มถึงตายเมื่อ granularity เล็กลง — ซึ่งเป็นหัวข้อทั้งหมดของส่วน break-even

มุมมองแบบตำหนิ: ภาษีนี้ เป็นทางเลือกล้วนๆ และมันขยายตามจำนวน call × payload chatty pattern ส่ง dataset ซ้ำทุก call ด้วยเหตุผลเดียวเท่านั้น: service เป็นแบบ stateless ดังนั้นทุก request ต้องพา context ทั้งหมดไปด้วย นั่นคือรูปทรง default ของ "sweep endpoint" แบบไร้เดียงสา — และของแทบทุก REST microservice ที่เคยถูกร่างบนกระดานไวท์บอร์ด server แบบ stateful — โหลด series ครั้งเดียว แล้วส่ง parameter frame ขนาด 48 byte — จะทำให้แต่ละ call ต่อ combo อยู่ใกล้ปลาย tiny-payload ของ echo curve: ประมาณ 16 µs ต่อ call รวมทั้ง 80 call ประมาณ 1.3 ms (คำนวณจาก echo floor; เป็นการวิเคราะห์ ไม่ได้วัดแยกต่างหาก) ค่าปรับของ chatty จะไม่แค่เล็กลง — มัน หายไปเลย บทเรียนนี้แม่นยำ: ปัญหาไม่ใช่การเรียกหลายครั้ง — มันคือการส่ง state ซ้ำเพราะ protocol แสร้งทำเป็นว่าทุก call คือ call แรก

โหลดข้อมูลไว้ล่วงหน้า ส่งแค่ parameter ข้าม boundary อย่างมีเจตนา ไม่ใช่แบกทั้งโลกไปในกระเป๋าเดินทางทุกครั้ง

ค่าใช้จ่ายของ Spawn: เช่า Engine เป็นรายครั้ง

engine binary ถูก spawn ขึ้นมาใหม่ตั้งแต่ต้นสำหรับ request เดียว: process creation, loader และ pipe setup เรียงซ้อนกันเป็นด่านเก็บเงินขนาดคงที่อยู่หน้างานที่มีประโยชน์ช่วงสั้นๆ

deployment pattern ที่สามคือแบบเก่าแก่ที่สุด: ไม่มี server เลย spawn engine binary ขึ้นมา, pipe request หนึ่งครั้งผ่าน stdin, อ่านคำตอบจาก stdout แล้วปล่อยให้มันตายไป สัญชาตญาณของ shell scripter ทุกคน, การ integration แบบ "เรียก CLI จาก Python ก็พอ" ทุกแบบ, hyperparameter framework ทุกตัวที่ตั้งค่าให้ launch binary ต่อหนึ่ง trial

วัดได้: 2.300 s (1.14x) — สูงกว่า persistent-server batch ประมาณ 24 ms (คำนวณ: 2.300 − 2.276) 24 มิลลิวินาทีนั้นซื้อ fork/exec, dynamic loader, pipe setup และ process teardown และสังเกตว่าสิ่งที่วัดตรงนี้ใกล้เคียงกับ floor ของ pattern นี้แล้ว: native binary เล็กๆ ที่ไม่มี dependency และ warm อยู่ใน page cache การ spawn อะไรก็ตามที่มี runtime — JVM, Python interpreter ที่มี import — มีค่าใช้จ่ายมากกว่านี้เยอะ; เราไม่ได้วัดสิ่งเหล่านั้นตรงนี้ แต่ทิศทางไม่ต้องสงสัยเลย

สิ่งที่สำคัญคือโครงสร้างของภาษีนี้: มัน คงที่ต่อ call ไม่สนใจว่า call นั้นพางานมากแค่ไหน หากคิดเฉลี่ยตลอด sweep 80 combo เต็ม 24 ms คิดเป็นประมาณ 1% — คือ noise แต่ถ้า respawn ต่อ combo ค่าคงที่เดียวกันนี้กลายเป็น 80 × ~24 ms ≈ 1.9 s — แทบจะเผางานที่มีประโยชน์ทั้งหมดไปกับ process creation (คำนวณ; เชิงวิเคราะห์) ถ้า respawn ต่อ bar เลขคณิตแม้แต่จะเขียนออกมาก็ยังไม่ไหว

Fixed cost หรือ granularity ละเอียด: เลือกมาอย่างเดียว pattern ที่ต้องจ่ายค่า spawn จะสมเหตุสมผลก็ต่อเมื่อ spawn นั้นเกิดขึ้นไม่บ่อย และ payload ที่อยู่เบื้องหลังมันใหญ่มาก — เหมือนกับที่เราวัด one-spawn-per-sweep เป๊ะ และตรงข้ามกับวิธีที่สถาปัตยกรรม per-symbol-subprocess มักถูกใช้งานจริงเมื่อจำนวน symbol เพิ่มขึ้น

เลขคณิตแบบ Break-even: Floor คือ Hurdle Rate

เลขคณิตแบบ break-even บนตาชั่ง: boundary floor สิบสี่ไมโครวินาทีอยู่ด้านหนึ่ง ชั่งกับ compute ที่แต่ละ call พาไปด้วย โดย call ต่อ combo ลอยอยู่เหนือน้ำไปไกล ส่วน call ต่อ bar จมมิดหัว

ทุกอย่างที่วัดมาจนถึงตอนนี้บีบอัดลงเหลือกฎการออกแบบข้อเดียว และกฎนั้นคือเลขคณิต ไม่ใช่ความคิดเห็น

การข้าม boundary ทุกครั้งมีค่าใช้จ่ายอย่างน้อยเท่ากับ latency floor — 14 µs ในที่นี้ คือ echo round-trip แบบ tiny-payload และใกล้เคียงกับสิ่งที่ดีที่สุดที่ transport นี้ให้ได้ floor นั้นคือ hurdle rate: การเรียกข้าม boundary จะคุ้มค่าก็ต่อเมื่อ compute ที่มันพาไปด้วยข้าม hurdle นั้นไปได้หลายเท่าตัวอย่างสบายๆ นิยาม granularity ratio

G  =  Tcompute per callTfloorG \;=\; \frac{T_{\text{compute per call}}}{T_{\text{floor}}}

และสัดส่วนของ boundary ในเวลาจริงของคุณอยู่ที่ประมาณ 1/(1+G)1/(1+G) — บวกกับ payload transit เพิ่มเข้ามาถ้า call นั้นพาข้อมูลไปด้วย

ทีนี้ลองเอาตัวเลขของ sweep มาใส่ในสูตรนี้ดู ต้นทุนแบบ in-process ที่วัดได้ของหนึ่ง combo คือ 25,130 µs ที่ granularity ระดับ per-combo:

G  =  25,130 μs14 μs    1795G \;=\; \frac{25{,}130\ \mu\text{s}}{14\ \mu\text{s}} \;\approx\; 1795

call ต่อ combo อยู่ สูงกว่า floor ประมาณ ~1,795 เท่า — boundary กิน well under หนึ่งในสิบเปอร์เซ็นต์ต่อ call นี่คือเหตุผลที่แม้แต่สถาปัตยกรรม chatty ก็เสียไปแค่ 107 ms: ที่ granularity ของ workload นี้ ทุก crossing pattern ที่ไม่ส่งข้อมูลซ้ำหรือพูด text จะถูก amortize ได้อย่างปลอดภัย call ระดับ combo, ระดับ fold, ระดับ sweep ล้วนอยู่ลึกในโซนที่ถูกทั้งหมด

ทีนี้พลิกไปสุดขั้วตรงข้ามกันบ้าง อันนี้เป็น การประมาณการข้าม workload เชิงตัวอย่าง — ไม่ใช่ variant ของ sweep ของเรา แต่เป็นรูปทรง workload ที่มีอยู่จริงในโลกจริง: engine ถูกปรึกษา ทีละ bar engine service แบบ per-tick สไตล์ live; gRPC-per-bar signal stream; "strategy server" ที่ถูก poll หนึ่งครั้งต่อ bar หนึ่งใน 150,000 bar compute ที่มีประโยชน์ต่อ bar ใน kernel นี้คือ 25,130 µs / 150,000 ≈ 0.17 µs (คำนวณ) — แต่ละ call จะพางานที่มีประโยชน์ไปด้วยแค่ประมาณ 1/84 ของค่าใช้จ่าย boundary ของตัวมันเอง (คำนวณ: floor 14.05 µs เทียบกับ compute 0.168 µs) ผลรวมแย่กว่าที่อัตราส่วนฟังดูเสียอีก:

150,000 calls×14 μs    2.1 s of pure IPC150{,}000 \ \text{calls} \times 14\ \mu\text{s} \;\approx\; \mathbf{2.1\ s\ of\ pure\ IPC}

— มากกว่า งาน in-process เต็มทั้ง 2.010 s เสียอีก ที่ถูกใช้ไปก่อนที่ remote engine จะคำนวณตัวเลขแม้แต่ตัวเดียว และมันจะยังคงเป็น 2.1 s แม้ว่า engine อีกฝั่งจะเร็วแบบ infinite ก็ตาม (คำนวณ: 150,000 × 14 µs) ไม่มีความได้เปรียบด้าน compute ใดรอดจาก granularity ที่ละเอียดขนาดนี้ได้ และอย่าลืมว่า floor นี้คือ Unix socket บนโฮสต์เดียว ถ้าทำ call ต่อ bar แบบนี้ไปยัง service ข้ามเครือข่าย floor จะโตขึ้นสองถึงสามอันดับขนาด บน 150,000 call

boundary floor บนเครื่องเดียวกันในฐานะทางเลือกด้าน implementation: round-trip แบบ Python-over-Unix-socket ที่สิบสี่ไมโครวินาที สูงตระหง่านเหนือการข้าม shared-memory ring ที่สามสิบเก้านาโนวินาที ห่างกันสามอันดับขนาด

ขอ calibrate อย่างตรงไปตรงมาอีกเรื่องหนึ่ง เพราะ 14 µs ก็ไม่ใช่กฎฟิสิกส์เหมือนกัน — มันคือราคาของ transport ของเรา: Python client, kernel socket, syscall ทั้งสองทิศทาง transport ที่สร้างมาเฉพาะสำหรับเครื่องเดียวกันสามารถต่ำกว่านี้ได้มาก ZigBolt — messaging bus แบบ open-source ที่เราเขียนด้วย Zig สำหรับ workload แบบ HFT ซึ่งถูก benchmark แบบ native บนเครื่องเดียวกันนี้ — ทำ shared-memory ring round-trip ได้ในเวลาเฉลี่ยประมาณ 39 ns (one-way p50 ที่ 10/20/30 ns บนข้อความขนาด 64/256/1024 byte) นั่นต่ำกว่า socket floor ของเราประมาณ 360 เท่า (คำนวณ: 14.05 µs / 39 ns) การเปรียบเทียบนี้จงใจเป็นแบบ apples-to-oranges และเราขอชี้แจงไว้ตรงนี้: 14 µs ของเราคือ round-trip แบบ Python-client socket ส่วน 39 ns ของ ZigBolt คือ native Zig บน shared memory ดังนั้นช่องว่างนี้จึงปนกันระหว่าง transport และ runtime อ่านมันไม่ใช่ในฐานะการแข่งขันระหว่างสองสิ่งนี้ แต่ในฐานะ ช่วงที่ floor บนเครื่องเดียวกันสามารถอยู่ได้: ประมาณสามอันดับขนาด ขึ้นอยู่กับการเลือก implementation นี่คือบทเรียนเก่าของ Lightweight RPC (Bershad et al., 1990) ในชุดใหม่ — การข้ามบนเครื่องเดียวกันถูกครอบงำโดยกลไกของ protocol และมันจะยุบตัวลงเมื่อ transport ถูกสร้างมาสำหรับกรณีเครื่องเดียวกันโดยเฉพาะ break-even arithmetic ด้านบนไม่เปลี่ยนรูปทรง แค่ hurdle ขยับตำแหน่งเท่านั้น ที่ floor 39 ns แม้แต่ granularity ระดับ per-bar ก็จะข้าม hurdle ได้ (150,000 × 39 ns ≈ 5.9 ms, คำนวณ) — ซึ่งเป็นเหตุผลที่ระบบ HFT สามารถแบก boundary ที่ REST service แบกไม่ไหวได้พอดี

นี่คือเรื่องราวทั้งหมดของ break-even ในประโยคเดียว: boundary ไม่สนใจว่า engine ของคุณเร็วแค่ไหน มันคิดค่าใช้จ่ายต่อการข้ามหนึ่งครั้ง ดังนั้นตัวแปรที่คุณควบคุมได้คืองานที่แต่ละการข้ามพาไปด้วยมากแค่ไหน — และการข้ามนั้นทำจากอะไร Batch ต่อ sweep แล้ว GG จะเกินแสน Batch ต่อ combo, G1795G \approx 1795 — ยังโอเคอยู่ เรียกทีละ bar ผ่าน socket, G<1G < 1 — สถาปัตยกรรมนั้นตายไปแล้วก่อนที่จะเริ่ม optimize เสียอีก และไม่มีการเขียน engine ใหม่ ไม่ว่าจะเป็น Rust หรืออะไรก็ตาม จะชุบชีวิตมันขึ้นมาได้

1.13x นั้นอยู่ที่ไหนกันแน่ — และคำตัดสิน

ช่องว่าง 266 มิลลิวินาทีถูกชำแหละ: เศษเสี้ยวสองมิลลิวินาทีที่ติดป้ายว่าเป็น boundary อยู่ข้างก้อนใหญ่ของความแตกต่างด้าน codegen ที่วัดได้ระหว่าง scalar kernel ที่ compile แล้วสองตัว โดยความเชื่อพื้นบ้านถูกขีดฆ่าทิ้ง

ถึงเวลาชำแหละช่องว่างพาดหัวอย่างตรงไปตรงมา เพราะมันแบก finding ที่ขัดสัญชาตญาณที่สุดของ study นี้ไว้

สถาปัตยกรรม Rust แบบ batched ตามหลัง numba แบบ in-process อยู่ 266 ms (คำนวณ: 2.276 − 2.010) ส่วนประกอบของ boundary ที่วัดได้: round trip แบบ full-payload หนึ่งครั้งที่ ~2.0 ms, raw serialization ที่ 49 µs, frame header ไม่กี่ byte — เรียกรวมบิล boundary ทั้งหมดว่า ~2 ms ดังนั้นกว่า 99% ของช่องว่างนี้จึง ไม่ใช่ boundary เลยแม้แต่น้อย มันคือ compute: เมื่อตัด IPC ออก Rust server ใช้เวลา ~2.274 s ทำ sweep ที่ numba ทำได้ใน 2.010 s — Rust kernel แบบไร้เดียงสาช้ากว่าที่ raw compute ประมาณ 13% (คำนวณ)

ย่อหน้านี้ควรพูดตรงๆ อย่างไม่หลบเลี่ยง เพราะ "เขียนใหม่เป็น Rust แล้วจะเร็วขึ้น" ก็เป็นความเชื่อพื้นบ้านพอๆ กับ "IPC จะฆ่าคุณ" kernel ทั้งสองตัวลงเอยที่ LLVM ทั้งคู่ — numba ลด Python bytecode ผ่านมัน, rustc ลด MIR ผ่านมัน — และทั้งคู่น่าจะรันเป็น loop แบบ scalar ทั้งคู่: sum ด้านในของ WMA คือ floating-point reduction ซึ่ง LLVM จะไม่ auto-vectorize ให้เว้นแต่จะได้รับใบอนุญาต fast-math reassociation ที่ @njit ของ numba ไม่ได้ให้เป็นค่า default และ port ของเราก็ไม่ได้ขอ ดังนั้น ~13% นี้คือช่องว่างด้าน codegen ที่วัดได้ระหว่าง scalar loop ที่ compile ด้วย LLVM สองตัว — และแทนที่จะยืนยันสาเหตุลอยๆ เราทดสอบสาเหตุที่ชัดเจนที่สุดแทน ผู้ต้องสงสัยตามธรรมชาติคือ safe indexing ของ Rust: hot loop ของ WMA ทำ bounds check ทุกครั้งที่เข้าถึง array ในขณะที่ @njit ของ numba compile โดยปิด bounds check ไว้ เราจึงสร้าง variant ของ kernel เดียวกันที่ตรวจสอบ equivalence แล้วโดยใช้ get_unchecked — ไม่มี bounds check เลยใน hot path — แล้วจับเวลามันเป็นสถาปัตยกรรมที่ห้า ผลคือมัน ไม่ ปิดช่องว่างนั้นเลย: 2.337 s (1.16x) ช้ากว่า build แบบมี bounds check ที่ 2.276 s เล็กน้อยด้วยซ้ำ สมมติฐานถูกทดสอบแล้ว สมมติฐานถูกปฏิเสธ สถานะความรู้ที่ตรงไปตรงมา: ~13% นั้นเป็นเรื่องจริงและทำซ้ำได้ (ค่ามัธยฐานจาก 10 รัน ช่วงกระจายภายใน ~2%) และในตอนนี้ ยังหาสาเหตุไม่ได้ — อาจเป็นความแตกต่างบางอย่างใน allocation behavior, โครงสร้าง loop หรือ instruction scheduling ที่มีแต่การ profiling ระดับ assembly เท่านั้นที่จะชี้ขาดได้ บทเรียนยังคงอยู่ครบถ้วน: Rust แบบไร้เดียงสาไม่ได้เร็วกว่า numba ที่ดีโดยอัตโนมัติ และ language boundary ที่ซื้อมาด้วยสมมติฐานว่าจะได้ compute win ฟรีๆ อาจมาพร้อม compute loss ติดมาด้วย Rust kernel ที่ tune แล้ว — buffer ที่ preallocate ไว้, SIMD แบบ explicit, thread ข้าม combo — ก็ยังอาจพลิกเครื่องหมายได้ แต่นั่นเป็นคำถามเรื่อง compute ที่ต้องชี้ขาดด้วย profiling และงาน kernel ส่วนคำถามของ study นี้คือ boundary คำตอบของ boundary คือ: ข้ามครั้งเดียว เป็น byte มีค่าใช้จ่าย ~0.1%

ทีนี้มาประกอบคำตัดสินทั้งหมด ทุกข้อของมันวัดไว้แล้วข้างต้น

engine service ข้ามภาษาจะชนะเมื่อทุกข้อต่อไปนี้เป็นจริง:

  • ความได้เปรียบด้าน compute เป็นเรื่องจริง — วัดจาก kernel ของคุณเอง ไม่ใช่สมมติเอาจากชื่อเสียงของภาษา (ของเราคือ −13% จนกว่าจะพิสูจน์เป็นอย่างอื่นได้ — และคำอธิบายที่ "ชัดเจน" ข้อแรกสำหรับส่วนขาดนี้ก็ตายไปตอนทดสอบ)
  • คุณข้ามแบบ coarse — call เดียวต่อ sweep หรือต่อ fold สูงกว่า floor 14 µs หลายพันเท่า แบบที่สถาปัตยกรรม batch แสดงให้เห็นด้วยตัวเลขรวม 1.13x (~0.1% boundary)
  • คุณพูด binary — raw array แบบ length-prefixed, Arrow, อะไรก็ตามในคลาส memcpy ที่ 49 µs ต่อ 1.2 MB; ไม่ใช่ text ที่ 66,243 µs
  • ข้อมูลถูกโหลดไว้ล่วงหน้า — stateful server รับ call ที่มีแค่ param ที่ปลาย ~16 µs ของ echo curve แทนที่จะส่ง megabyte ซ้ำ

มันแพ้เมื่อถูก deploy แบบที่ engine service มักถูก deploy กัน:

  • JSON/REST microservice — จ่ายภาษี serialization 1348 เท่าทุก call ทั้งสองทิศทาง; ที่ granularity แบบ chatty นั่นคือ encoding 5.3 s บนงานที่ใช้เวลา 2 s
  • RPC ต่อหนึ่งหน่วยงาน — ต่อ combo มีค่าใช้จ่าย 107 ms ในที่นี้ และรอดมาได้ก็เพราะแต่ละ call พา compute 25,130 µs ไปด้วย; ต่อ bar มันคือ IPC ล้วนๆ ~2.1 s ก่อนที่งานใดๆ จะเกิดขึ้น บนงานที่ใช้เวลา 2.0 s
  • spawn ต่อ call — fixed cost ~24 ms ทุกครั้ง ไม่เป็นอันตรายถ้าเกิดครั้งเดียวต่อ sweep แต่เกือบสองวินาทีถ้าต้องจ่ายต่อ combo

กล่าวคือ: สถาปัตยกรรมที่ล้มเหลวไม่ใช่อะไรแปลกใหม่เลย JSON REST engine, per-symbol subprocess, gRPC-per-tick — นั่นคือสำมะโนที่ยุติธรรมของวิธีที่ "มาแยก backtest engine ออกมากันเถอะ" มักถูกสร้างขึ้นจริงๆ ความเชื่อพื้นบ้านมีมูลฐานเชิงประจักษ์ดี ในฐานะคำอธิบายของ common practice แต่ผิดเชิงประจักษ์ ในฐานะกฎธรรมชาติ boundary ไม่เคยเป็นปัญหา วิธี default ในการข้ามมันต่างหากที่เป็นปัญหา

ข้อโต้แย้งหนึ่งที่สนับสนุน boundary สมควรมีประโยคของตัวเอง เพราะมันคือเหตุผลที่เราทำ study นี้ขึ้นมาตั้งแต่แรก compiled kernel ตัวเดียวที่อยู่หลัง boundary ที่ออกแบบมาอย่างดี สามารถให้บริการทั้ง research sweep และ live trading loop ได้ — binary เดียวกัน เลขคณิตเดียวกัน ตรงกันทุก bit backtest-live parity study ของเราได้บันทึกไว้ว่า research engine กับ production engine เบี่ยงเบนออกจากกันอย่างไรเมื่อมันเป็นสองโค้ดเบส engine service คือยาแก้ที่แข็งแกร่งที่สุดเชิงโครงสร้างสำหรับความเบี่ยงเบนนั้น และ study นี้ตั้งราคายาแก้นั้นอย่างตรงไปตรงมา: ทำถูกวิธี ประมาณ 0.1% ของเวลาจริง บวกกับ equivalence gate ที่พิสูจน์ว่าไม่มีอะไรเปลี่ยนไปในการแปล การแลกเปลี่ยนนั้น — process boundary เฉพาะทางเพื่อแลกกับ one-kernel parity — จากตัวเลขเหล่านี้ ถือเป็นข้อตกลงที่คุ้มค่า ทำผิดวิธี ไอเดียเดียวกันนี้จะส่งภาษี serialization 1348 เท่าไปสู่ production พร้อมกับ PnL ของคุณที่นั่งทับอยู่ข้างบนมัน

สรุปประเด็นสำคัญ

  1. Boundary แทบจะฟรี ความเชื่อพื้นบ้านผ่านการวัดไม่ได้ การทำ round-trip close series ทั้ง 1.2 MB ผ่าน Unix socket — รวมการ parse และ re-encode เต็มรูปแบบ — มีค่าใช้จ่าย 2,043.4 µs ประมาณ 0.1% ของงานที่ใช้เวลา 2.010 s (คำนวณ) สถาปัตยกรรม Rust-over-socket แบบ batched ลงเอยที่ 1.13x รวม และแม้แต่ในช่องว่างนั้น ~99% ก็ไม่ใช่ IPC
  2. "เขียนใหม่เป็น Rust" คือข้อเรียกร้องเชิง compute — ตรวจสอบก่อนซื้อ boundary Rust port แบบ line-for-line ของเราคำนวณช้ากว่า numba kernel ประมาณ ~13% (คำนวณ: 2.274 s เทียบกับ 2.010 s) — ช่องว่างด้าน codegen ที่ทำซ้ำได้ระหว่าง scalar loop ที่ compile ด้วย LLVM สองตัวซึ่งยังหาสาเหตุไม่ได้: เราทดสอบผู้ต้องสงสัยที่ชัดเจนที่สุดแล้วปฏิเสธมันไป เพราะ build แบบ get_unchecked ที่ตรวจสอบ equivalence แล้วโดยไม่มี bounds check เลย กลับไม่ได้เร็วขึ้น (2.337 s เทียบกับ 2.276 s) Rust แบบไร้เดียงสาไม่ได้เร็วกว่าโดยอัตโนมัติ; kernel ที่ tune แล้วอาจเร็วกว่าได้จริง — วัดก่อน แล้วค่อยตัดสินใจ
  3. ภาษีตัวจริงคือ text การ encode float 150,000 ตัวเป็น JSON มีค่าใช้จ่าย 66,243 µs เทียบกับ raw ที่ 49.1 µs — 1348 เท่า จ่ายต่อทิศทาง ต่อ call บนทั้งสองฝั่ง deployment แบบ JSON ที่ chatty เผา encoding ไป 5.3 s บนงานที่ใช้เวลา 2 s (คำนวณ) พูด binary ข้าม boundary: raw frame, Arrow — ไม่ใช่ json.dumps บน price array เด็ดขาด
  4. Chatty vs chunky วัดผลได้จริง และ statelessness คือตัวการ call ต่อ combo ที่ส่งข้อมูลซ้ำ: 1.19x เทียบกับ 1.13x ของ batch (+107 ms, คำนวณ; การพยากรณ์แบบ one-way ของ echo curve ที่ ~81 ms ต่ำกว่านั้นอยู่ราว 25% ส่วนที่เหลือคือ framing ต่อ call) stateful server ที่โหลดข้อมูลไว้ล่วงหน้าจะทำ 80 call เดิมที่ ~16 µs ต่อครั้ง — รวมประมาณ 1.3 ms (คำนวณจาก echo floor) ส่งแค่ parameter ไม่ใช่ dataset
  5. เคารพ floor — และรู้ว่า floor คือทางเลือกหนึ่ง การข้ามแบบ Python-over-Unix-socket ของเรามี floor ที่ 14 µs; granularity ระดับ per-combo ข้ามมันไปได้ ~1,795 เท่า (compute 25,130 µs ต่อ call) — ปลอดภัย pattern แบบ per-bar (เป็นตัวอย่างสุดขั้วข้าม workload: live per-tick engine ไม่ใช่ sweep นี้) จะต้องจ่าย IPC ล้วนๆ 150,000 × 14 µs ≈ 2.1 s บนงานที่ใช้เวลา 2.0 s (คำนวณ) — ตายตั้งแต่เกิดแม้ engine ฝั่งนั้นจะเร็วแบบ infinite ก็ตาม การ spawn ต่อ call เพิ่ม fixed cost ~24 ms (คำนวณ) และ transport แบบ shared-memory ที่สร้างมาเฉพาะทางอย่าง ZigBolt ทำ round-trip ได้ใน ~39 ns แบบ native บนเครื่องนี้ — ต่ำกว่า socket floor ของเราประมาณ ~360 เท่า (คำนวณ; native Zig เทียบกับ Python client ดังนั้นอ่านมันเป็นช่วงที่ floor สามารถอยู่ได้ ไม่ใช่การแข่งขัน)
  6. ข้ามครั้งเดียว เป็น byte โดยข้อมูลอยู่ตรงนั้นอยู่แล้ว — และ boundary ซื้อ parity ให้คุณด้วยราคา ~0.1% kernel เดียวให้บริการทั้ง research และ live โดยมี equivalence check เป็นประตู (PnL −5165.58, 57,029 trade เหมือนกันทุกประการข้ามภาษาและข้าม build ของ Rust ทั้งสองแบบ) คือกรณีที่ตรงไปตรงมาสำหรับ engine service กรณีที่ไม่ตรงไปตรงมา — JSON, chatty, spawn-per-call — คือสิ่งที่ทำให้ IPC มีชื่อเสียงแบบนั้น

การทดลองทั้งหมด — Rust engine, wire protocol, harness สำหรับ echo และ serialization, equivalence gate และตัวเลขทุกตัวในบทความนี้ที่สร้างซ้ำได้จาก script แบบ deterministic เพียงตัวเดียว — อยู่ในเปเปอร์คู่กันที่ ipc-tax.marketmaker.cc พร้อมโค้ดและข้อมูลที่ github.com/suenot/ipc-tax

socket ไม่เคยเป็นปัญหาเลย สองมิลลิวินาทีสำหรับ dataset ทั้งหมด แบบ round trip — ตำนานเข้าใจผิดไปถึงสามอันดับขนาด และผิดไปพร้อมกันทั้งสองทิศทาง: มองโลกในแง่ร้ายเกินไปเรื่อง byte และให้อภัย text มากเกินไป ข้ามมันราวกับว่ามันมีค่าใช้จ่าย แล้วมันจะไม่มี

ข้อจำกัดความรับผิดชอบ: ข้อมูลที่ให้ไว้ในบทความนี้มีไว้เพื่อการศึกษาและให้ข้อมูลเท่านั้น และไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการเทรด การเทรดสกุลเงินดิจิทัลมีความเสี่ยงสูงที่จะขาดทุน

ผู้เขียน

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

ก้าวนำหน้าตลาด

สมัครรับจดหมายข่าวของเราเพื่อรับข้อมูลเชิงลึกการเทรดด้วย AI เฉพาะ การวิเคราะห์ตลาด และการอัปเดตแพลตฟอร์ม

เราเคารพความเป็นส่วนตัวของคุณ ยกเลิกการสมัครได้ทุกเมื่อ