ข้าม​ไป​ยัง​เนื้อหา

วัด​ของ​จริง — 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 ให้​เห็น​ความ​ต่าง​ระดับ​สิบ​เท่ากับ​ตา

📦 kaen-kvstore

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

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

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 วัดผล​เอง​ก็​ต้อง​ถูก​ก่อน ไม่​งั้น​ตัวเลข​ทั้ง​ตาราง​เชื่อ​ไม่​ได้

ก่อน​เขียน loop จับ​เวลา ต้อง​รักษา​กติกา​สี่​ข้อ ไม่​งั้น benchmark จะ​โกหก:

สี่​กติกา​ที่​รักษา​ความ​ซื่อสัตย์​ของ benchmark
  1. จับ​เวลา​เฉพาะ op — กัน format! ออก​นอก​ช่วง — สร้าง key/value (ซึ่ง allocate memory) ก่อน เรียก Instant::now() เสมอ ไม่​งั้น​เรา​กำลัง​วัด​เวลา allocate string ปน​เข้าไป​กับ​เวลา​เขียน disk ราคา​ที่​เรา​สนใจ​คือ store.set ล้วนๆ
  2. throughput วัด​จาก wall clock รอบ​นอก ก้อน​เดียว ไม่ใช่​ผล​รวม​ของ​ตัวอย่างInstant::now() อีก​ตัว​คร่อม​ทั้ง loop; throughput = N / wall.elapsed() การ​เอา per-op sample มาบวก​กัน​จะ​พลาด​เวลา​ที่​ใช้ ระหว่าง op (loop overhead, การ​จัดสรร) และ​ให้ throughput ที่​สูง​เกิน​จริง
  3. warmup ก่อน​วัด — วน set สัก keyspace หนึ่ง​รอบ​ทิ้ง​ก่อน​เริ่ม​จับ​เวลา เพื่อ​จ่าย​ค่า first-touch: หน้า page cache ถูก​จอง, file ถูก​ขยาย, allocator อุ่นเครื่อง ตัวอย่าง​ชุด​แรก​ที่​ยัง​ไม่ warmup จะมี latency สูง​ผิด​ปกติ​ที่​ไม่​ได้​สะท้อน steady state
  4. sort_unstable — ก่อน​หา percentile ต้อง​เรียง; sort_unstable เร็ว​กว่า sort และ​เรา​ไม่​แคร์​ลำดับ​เดิม​ของ Duration ที่​เท่า​กัน (มัน​แยก​กัน​ไม่​ออก​อยู่​แล้ว)

ตัว​ถูกวัด​คือ 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 คืน​ค่า

ตัวเลข​นี้​เป็น​ภาพประกอบ ไม่ใช่ spec

ตัวเลข​ข้าง​บน​มา​จาก​การ​รัน​จริง​ใน​แซนด์บ็อกซ์​รอบ​นี้ (debug build, ระบบ file และ disk ของ​เครื่อง​นี้) — มัน​คือ datapoint สอน​ความ​จริง ไม่ใช่​ค่าที่​คุณ​ต้อง​ได้​เป๊ะ ตัวเลข​จริง​ขึ้น​กับ disk (NVMe vs HDD vs USB), ระบบ file, มี --release ไหม, และ OS รัน​เอง​บน​เครื่อง​คุณ p50/p99/throughput จะ​เลื่อน แต่ รูปทรง จะ​เหมือน​เดิม​เสมอ: fsync-ต่อ-write ให้ throughput หลัก​ร้อย​ถึง​หลัก​พัน​ต่อ​วินาที มี tail ยาว เพราะ​ทุก write block รอ storage จริง

ตัวเลข​เดี่ยวๆ ยัง​ไม่​พิสูจน์​ว่า 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/s
batched/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 เท่า

batched เร็ว​ขึ้น แต่​แลก​มา​ด้วย durability window ที่​ต้อง​พูดตรงๆ

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?