วัดของจริง — benchmark harness ที่เขียนเอง: p50/p95/p99 และ throughput
หกบทที่ผ่านมาเราสร้าง kaen-kvstore จนครบวง: wire protocol เอง, append-only log + hash index, ลำดับ write→fsync→ack, tombstone/compaction, ThreadPool และชุดทดสอบ crash-consistency ที่ยิง SIGKILL จริง ตลอดทางเราพูดซ้ำประโยคเดียว — “fsync คือราคาที่แพงที่สุดของการเขียนแบบ durable” บทนี้เราหยุดพูดแล้ว วัดมันออกมาเป็นตัวเลข ด้วยเครื่องมือที่เราต่อเอง ไม่พึ่ง criterion
เป้าหมายไม่ใช่แค่ได้ตัวเลขสวยๆ แต่คือ ออกแบบการวัดให้เปิดโปงคอขวดที่ถูกตัว — benchmark ที่วัดผิดวิธีจะซ่อน fsync tax ไว้ใต้ค่าเฉลี่ย แล้วหลอกให้เราไปปรับจูนผิดจุด เราจึงวัดด้วยวินัยของคนทำ storage engine จริง: percentile ไม่ใช่ mean, wall clock รอบนอกสำหรับ throughput, และเทียบ fsync-ต่อ-write กับ batched ให้เห็นความต่างระดับสิบเท่ากับตา
code ลงมือของคอร์สนี้อยู่ใน repo kaen-kvstore (code ตัวอย่างกำลังจัดทำ) — บทนี้เพิ่ม src/bin/bench.rs: harness วัดสมรรถนะที่เขียนเองด้วย std::time::Instant ล้วน (ไม่มี criterion) หยิบ store จาก บท2–3 มาเป็นตัวถูกวัด แล้วรายงาน latency แบบ percentile คู่กับ throughput ยังคง Rust std ล้วน — ZERO external crate ทุก snippet compile และรันได้จริงบน musl
ทำไมต้องเขียน benchmark เอง (และทำไมไม่ใช่ criterion)
หัวข้อที่มีชื่อว่า “ทำไมต้องเขียน benchmark เอง (และทำไมไม่ใช่ criterion)”benchmarkbenchmarkการวัดสมรรถนะที่เขียนเองด้วย `Instant` (ไม่ใช้ criterion) คือการวัดสมรรถนะของ code อย่างเป็นระบบ ในโลก Rust เครื่องมือมาตรฐานคือ criterion — มันดีมาก ทำ statistical analysis, กัน outlier, วาด graph ให้ครบ แต่คอร์สนี้ pin ไว้ที่ std ล้วน และที่สำคัญกว่านั้น: สิ่งที่เราจะวัดไม่ใช่ function CPU-bound บริสุทธิ์ที่ criterion ถนัด แต่เป็น op ที่ราคาเกือบทั้งหมดอยู่ที่ การรอ fsync คืนค่า — งาน I/O-bound ที่หน่วงเป็นมิลลิวินาที มี tail กระจายกว้าง การวัดแบบนี้เราต่อเองด้วย Instant ได้ตรงไปตรงมาและ เข้าใจทุกบรรทัดว่ากำลังวัดอะไร ซึ่งคือทั้งประเด็นของบทนี้
std::time::Instant คือนาฬิกา monotonic — มันไม่มีวันเดินถอยหลังแม้ระบบจะปรับเวลา (ต่างจาก SystemTime ที่ไป-กลับได้) จึงเป็นเครื่องมือที่ถูกต้องสำหรับวัด ระยะเวลา: Instant::now() จับจุดเริ่ม แล้ว .elapsed() คืน Duration ของช่วงที่ผ่านไป เราดึงเป็น nanosecond ด้วย .as_nanos() ซึ่งคืน u128
percentile ไม่ใช่ mean — เพราะ tail คือความจริง
หัวข้อที่มีชื่อว่า “percentile ไม่ใช่ mean — เพราะ tail คือความจริง”latencylatencyเวลาต่อ1 operation รายงานเป็น percentile p50/p95/p99 คือเวลาต่อ1 operation คำถามคือจะ สรุป latency ของหลายหมื่น op เป็นตัวเลขไม่กี่ตัวได้อย่างไร คำตอบที่มือใหม่เลือกคือ mean (ค่าเฉลี่ย) — และมันคือกับดัก ลองนึกภาพ: 99 op วิ่งที่ 2 ms แต่มี 1 op โดน writeback stall ของ kernel ค้างไป 300 ms ค่าเฉลี่ยจะออกมา ~5 ms ดูเหมือนทุกอย่างปกติ ทั้งที่มี request หนึ่งใน 100 ที่ผู้ใช้รอนานกว่าปกติ 150 เท่า — mean กลบหาง ที่เป็นความจริงของประสบการณ์ผู้ใช้
ทางที่ถูกคือรายงาน percentile: p50 (มัธยฐาน — ครึ่งหนึ่งของ request เร็วกว่านี้), p95, p99 และ max ยิ่ง percentile สูง ยิ่งเห็นหาง ยิ่งเห็นความจริงเรื่อง stall เราใช้วิธี nearest-rank ที่ตรงไปตรงมาที่สุด: เรียงตัวอย่างจากน้อยไปมาก แล้ว rank = ceil(p/100 · n), ตำแหน่งใน slice คือ rank − 1 (clamp ไม่ให้หลุดขอบ):
/// nearest-rank percentile บน slice ที่ "เรียงจากน้อยไปมากแล้ว"fn percentile(sorted: &[u128], p: f64) -> u128 { if sorted.is_empty() { return 0; } let rank = (p / 100.0 * sorted.len() as f64).ceil() as usize; let idx = rank.saturating_sub(1).min(sorted.len() - 1); sorted[idx]}fn us(ns: u128) -> f64 { ns as f64 / 1000.0 } // nanosecond -> microsecond ตอนพิมพ์percentile ตรวจสอบง่ายด้วย unit test: ป้อน 1..=100 แล้ว p50 ต้องได้ 50, p95→95, p99→99, p100→100, slice ว่าง→0, ตัวเดียว→ตัวนั้น รันจริงแล้วพิมพ์ percentile ok — เพราะ function วัดผลเองก็ต้องถูกก่อน ไม่งั้นตัวเลขทั้งตารางเชื่อไม่ได้
วินัยการวัด 4 ข้อที่ห้ามพลาด
หัวข้อที่มีชื่อว่า “วินัยการวัด 4 ข้อที่ห้ามพลาด”ก่อนเขียน loop จับเวลา ต้องรักษากติกาสี่ข้อ ไม่งั้น benchmark จะโกหก:
- จับเวลาเฉพาะ op — กัน
format!ออกนอกช่วง — สร้าง key/value (ซึ่ง allocate memory) ก่อน เรียกInstant::now()เสมอ ไม่งั้นเรากำลังวัดเวลา allocate string ปนเข้าไปกับเวลาเขียน disk ราคาที่เราสนใจคือstore.setล้วนๆ - throughput วัดจาก wall clock รอบนอก ก้อนเดียว ไม่ใช่ผลรวมของตัวอย่าง —
Instant::now()อีกตัวคร่อมทั้ง loop; throughput =N / wall.elapsed()การเอา per-op sample มาบวกกันจะพลาดเวลาที่ใช้ ระหว่าง op (loop overhead, การจัดสรร) และให้ throughput ที่สูงเกินจริง - warmup ก่อนวัด — วน set สัก keyspace หนึ่งรอบทิ้งก่อนเริ่มจับเวลา เพื่อจ่ายค่า first-touch: หน้า page cache ถูกจอง, file ถูกขยาย, allocator อุ่นเครื่อง ตัวอย่างชุดแรกที่ยังไม่ warmup จะมี latency สูงผิดปกติที่ไม่ได้สะท้อน steady state
sort_unstable— ก่อนหา percentile ต้องเรียง;sort_unstableเร็วกว่าsortและเราไม่แคร์ลำดับเดิมของDurationที่เท่ากัน (มันแยกกันไม่ออกอยู่แล้ว)
loop จับเวลา — fsync-ต่อ-write
หัวข้อที่มีชื่อว่า “loop จับเวลา — fsync-ต่อ-write”ตัวถูกวัดคือ KvStore แบบ fsync-ต่อ-write จาก บท3: ทุก set คือ write_all → sync_data → update index → ack เก็บตัวอย่างลง Vec<u128> (nanosecond):
use std::time::Instant;
const N: usize = 8_000;const KEYSPACE: usize = 2_000;
let mut store = /* KvStore แบบ fsync-ต่อ-write จาก บท3 */;for i in 0..KEYSPACE { store.set(format!("key-{i}").as_bytes(), b"warmup")?; // warmup: จ่าย first-touch}
let mut set_ns: Vec<u128> = Vec::with_capacity(N);let wall = Instant::now(); // wall clock รอบนอก ก้อนเดียวfor i in 0..N { let key = format!("key-{}", i % KEYSPACE); let val = format!("val-{i}"); let t = Instant::now(); // จับเวลา "เฉพาะ" op — format! อยู่นอกแล้ว store.set(key.as_bytes(), val.as_bytes())?; set_ns.push(t.elapsed().as_nanos());}let elapsed = wall.elapsed();set_ns.sort_unstable();
let tput = N as f64 / elapsed.as_secs_f64();println!("fsync-per-write n={N} p50={:.1}us p95={:.1}us p99={:.1}us max={:.1}us {tput:.0} ops/s", us(percentile(&set_ns, 50.0)), us(percentile(&set_ns, 95.0)), us(percentile(&set_ns, 99.0)), us(*set_ns.last().unwrap()));รันจริงบน musl ในแซนด์บ็อกซ์นี้ ได้ผลประมาณ:
fsync-per-write n=8000 p50=3148.4us p95=8162.5us p99=13479.5us max=274372.0us 253 ops/sอ่านตารางนี้ให้ออก: p50 อยู่ที่ ~3.1 มิลลิวินาที (นี่คือราคาของ fdatasync หนึ่งครั้งต่อ1 op), p99 พุ่งไป ~13 ms, และ max แตะ 274 ms — หางยาวมากจาก writeback stall ของ kernel ที่ mean จะกลบหมด และ throughput อยู่แค่ ~250 ops/s ทั้งที่เครื่องนี้มีหลาย core ว่างๆ นี่คือหลักฐานตรงๆ ว่า store ของเรา durability-bound ไม่ใช่ CPU-bound — CPU ไม่ได้ทำงานหนัก มันแค่ รอ fsync คืนค่า
ตัวเลขข้างบนมาจากการรันจริงในแซนด์บ็อกซ์รอบนี้ (debug build, ระบบ file และ disk ของเครื่องนี้) — มันคือ datapoint สอนความจริง ไม่ใช่ค่าที่คุณต้องได้เป๊ะ ตัวเลขจริงขึ้นกับ disk (NVMe vs HDD vs USB), ระบบ file, มี --release ไหม, และ OS รันเองบนเครื่องคุณ p50/p99/throughput จะเลื่อน แต่ รูปทรง จะเหมือนเดิมเสมอ: fsync-ต่อ-write ให้ throughput หลักร้อยถึงหลักพันต่อวินาที มี tail ยาว เพราะทุก write block รอ storage จริง
เทียบกับ batched — เปิดโปง fsync tax ให้เห็นกับตา
หัวข้อที่มีชื่อว่า “เทียบกับ batched — เปิดโปง fsync tax ให้เห็นกับตา”ตัวเลขเดี่ยวๆ ยังไม่พิสูจน์ว่า fsync คือตัวการ เราต้องมีตัวเปรียบเทียบที่ต่างกันแค่จุดเดียว: เอา fsync ออกจาก hot path version นี้ยัง write_all ทุก op เหมือนเดิม (syscall เขียนลง page cache) แต่ เลื่อน sync_data ไปเรียกครั้งเดียวต่อทุกๆ 64 op — นี่คือแก่นของ group commit ที่ฐานข้อมูลจริงใช้ (Petrov ch5): amortize ราคา fsync หนึ่งครั้งไปหารเฉลี่ยกับหลาย write ที่รอ commit พร้อมกัน
const BATCH: usize = 64;let mut set_ns: Vec<u128> = Vec::with_capacity(N);let wall = Instant::now();for i in 0..N { let key = format!("key-{}", i % KEYSPACE); let val = format!("val-{i}"); let t = Instant::now(); store.append_nosync(key.as_bytes(), val.as_bytes())?; // write_all อย่างเดียว ยังไม่ fsync if (i + 1) % BATCH == 0 { store.sync()?; // fsync หนึ่งครั้งต่อ 64 write (group commit) } set_ns.push(t.elapsed().as_nanos());}store.sync()?; // fsync หางที่ค้าง ให้ durable ครบรันเทียบกันในโปรแกรมเดียว ได้ผลประมาณ:
fsync-per-write n=8000 p50=3148.4us p95=8162.5us p99=13479.5us max=274372.0us 253 ops/sbatched/64 n=8000 p50=5.4us p95=33.1us p99=2620.5us max=18035.3us 15123 ops/sความต่างพูดแทนทุกอย่าง: throughput กระโดดจาก ~250 เป็น ~15,000 ops/s (~60 เท่า) และ p50 ร่วงจาก ~3,150 us เหลือ ~5 us — เพราะ 63 ใน 64 op ไม่ต้องรอ disk เลย มันแค่ write_all ลง page cache แล้วจบ สังเกตว่า p99 ของ batched ยังอยู่ที่ ~2,620 us — นั่นคือ op ทุกตัวที่ บังเอิญตกขอบ batch แล้วต้องแบก fsync ของทั้งก้อนไว้คนเดียว หางยังอยู่ แค่เกิดถี่น้อยลง 64 เท่า
group commit ไม่ใช่ของฟรี — ระหว่าง fsync สองครั้ง มี write ได้ถึง 64 ตัวที่ ack ไปแล้วในระดับ API แต่ยัง ค้างใน page cache ยังไม่ลง storage ถ้าไฟดับตอนนั้น write เหล่านั้นหายทั้งหมด นี่คือการแลก: durability ที่ละเอียดระดับราย-op (fsync-ต่อ-write) แลกกับ throughput ฐานข้อมูลจริงจึงให้คุณ เลือก — เช่น “group commit ทุก N ms หรือทุก M write แล้วแต่อันไหนถึงก่อน” — และ ห้าม ack ให้ client ว่า durable ก่อน fsync ของ batch นั้นคืนค่า version ดิบข้างบนที่ push sample ก่อน sync() เป็นแค่การ วัด latency ของ write ต่อ page cache; ในของจริง ack ต้องรอ commit ของ batch (เหมือน quorum ack ของ #23 — “W replica fsync แล้ว” คือ ack ก้อนนี้นับรวมกันข้ามเครื่อง)
flowchart LR
A["สร้าง key/val<br/>(format! นอกช่วงวัด)"] --> B["t = Instant::now()"]
B --> C["store.set() — 1 op"]
C --> D["set_ns.push(<br/>t.elapsed().as_nanos())<br/>เก็บ Vec u128"]
D --> E{"ครบ N?"}
E -->|ยัง| A
E -->|ครบ| F["sort_unstable"]
F --> G["percentile nearest-rank<br/>p50 / p95 / p99 / max"]
W["wall = Instant::now()<br/>รอบนอก ก้อนเดียว"] -.คร่อมทั้ง loop.-> E
W --> H["throughput = N / wall.elapsed()"]
คำบรรยายภาพ: pipeline ของ benchmark harness ที่เขียนเอง — จับเวลาราย op (โดยกัน format! ออกนอกช่วง) เก็บ nanos ลง Vec<u128> เรียงด้วย sort_unstable แล้วสรุปเป็น percentile แบบ nearest-rank; ส่วน throughput วัดแยกจาก wall clock รอบนอกก้อนเดียวที่คร่อมทั้ง loop ไม่ใช่ผลรวมของตัวอย่าง
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้เราต่อ benchmark harness เองด้วย std::time::Instant ล้วน ไม่พึ่ง criterion: จับเวลาราย op เก็บ Vec<u128> เรียงแล้วรายงาน percentile แบบ nearest-rank (p50/p95/p99/max) เพราะ mean กลบหางที่เป็นความจริง; วัด throughput จาก wall clock รอบนอกก้อนเดียว; รักษาวินัยสี่ข้อ — กัน format! ออกนอกช่วงวัด, warmup จ่าย first-touch, sort_unstable, ตรวจ percentile ด้วย unit test ก่อน แล้วเราวัดของจริงออกมา: fsync-ต่อ-write ให้ ~250 ops/s หางยาวถึง ~274 ms — durability-bound ไม่ใช่ CPU-bound — เทียบกับ batched/64 ที่ ~15,000 ops/s ให้เห็น fsync tax กับตา ทุกตัวเลขเป็นภาพประกอบที่ขึ้นกับเครื่อง ไม่ใช่ spec
คำถามที่ตารางนี้ทิ้งไว้: ถ้า store เรา durability-bound อยู่แค่หลักร้อย-พันต่อวินาที แล้วทางออกที่ อยู่ในขอบเขต ของ single-node storage engine คืออะไร และเมื่อไรปัญหาถึงเปลี่ยนจาก “fsync ช้า” ไปเป็น “connection เยอะเกินจนต้องใช้ async”? บท8 (capstone) ประกอบทั้ง kaen-kvstore เข้าด้วยกัน ตอบคำถามนี้ด้วย group commit ที่เราเพิ่งชิมไป แล้ววาดเส้นแบ่ง std-กับ-async ให้ชัด ก่อนส่งไม้ต่อให้คอร์ส #23 (Distributed Systems) ที่เอา write→fsync→ack ก้อนนี้ไปนับเป็น quorum ack ข้ามเครื่อง
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- std
time::Instant(เข้าถึง 2026-07-24) — นาฬิกา monotonic ที่ไม่เดินถอยหลัง;now()+elapsed()คืนDuration,.as_nanos()คืนu128— เครื่องมือที่ถูกต้องสำหรับวัดระยะเวลา - Alex Petrov, Database Internals — ch5 Transaction Processing & Recovery (WAL force policy, group commit) (2019, เข้าถึง 2026-07-24) —
fsyncคือราคาที่แพงที่สุดของ durable write; ฐานข้อมูลจริง amortize ด้วย group commit — batch หลาย write แล้ว fsync พร้อมกันครั้งเดียว - Martin Kleppmann, Designing Data-Intensive Applications — ch3 Storage & Retrieval (2017/2026, เข้าถึง 2026-07-24) — model log-structured/Bitcask ที่ store ตัวถูกวัดในบทนี้อิงอยู่ (write เป็น sequential append, durability เป็นสมบัติของ single node)
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ทำไม benchmark ในบทนี้ถึงรายงาน percentile (p50/p95/p99) แทนที่จะรายงานค่าเฉลี่ย (mean) ของ latency?