Benchmarks
Benchmark คืออะไร?
หัวข้อที่มีชื่อว่า “Benchmark คืออะไร?”benchmark วัดว่าโค้ดใช้เวลานานเท่าไร Go benchmarks อยู่ในไฟล์ _test.go เคียงข้าง unit tests และรันด้วยคำสั่ง go test เดียวกัน — เพียงแต่ใช้ flags ต่างกัน
function benchmark มี signature ดังนี้:
func BenchmarkXxx(b *testing.B) { // ... setup code (not measured) ... for b.Loop() { // Go 1.24+ preferred form // code under measurement }}ค่า testing.B ขับเคลื่อน loop framework จะเริ่มด้วยค่า b.N ขนาดเล็กและเพิ่มขึ้นเรื่อยๆ จนกว่า benchmark จะรันนานพอที่จะให้การวัดที่เสถียร คุณไม่ต้องกำหนด b.N เอง
รูปแบบ loop สองแบบ
หัวข้อที่มีชื่อว่า “รูปแบบ loop สองแบบ”Go 1.24 แนะนำ b.Loop() เป็นทางเลือกที่ดีกว่า classic b.N counter loop:
// Classic form — works in all Go versionsfunc BenchmarkFibClassic(b *testing.B) { for i := 0; i < b.N; i++ { Fibonacci(20) }}
// Modern form — Go 1.24+func BenchmarkFib(b *testing.B) { for b.Loop() { Fibonacci(20) }}ทั้งสองรูปแบบถูกต้อง b.Loop() แนะนำใน Go 1.24+ เพราะจัดการ timer reset ภายในเอง ตั้งแต่ Go 1.26 ไม่ขวางการ inline ของโค้ดที่ benchmark อีกแล้ว จึงไม่เหลือเหตุผลด้าน performance ที่จะใช้ b.N และยังเป็นรูปแบบที่แนะนำใน Go 1.27 และมีโอกาสผิดพลาดน้อยกว่า
Setup ภายนอก loop
หัวข้อที่มีชื่อว่า “Setup ภายนอก loop”หาก benchmark ต้องการ setup ที่ใช้เวลามาก (เปิดไฟล์, สร้าง slice ขนาดใหญ่) ให้ทำก่อน loop รูปแบบ classic b.N ต้องเรียก b.ResetTimer() เองเพื่อไม่ให้เวลา setup ถูกนับ:
// Classic b.N form — needs an explicit ResetTimerfunc BenchmarkIsPrimeListClassic(b *testing.B) { numbers := make([]int, 1000) for i := range numbers { numbers[i] = i + 2 } b.ResetTimer() // start measuring from here
for i := 0; i < b.N; i++ { for _, n := range numbers { IsPrime(n) } }}b.Loop() reset timer ให้เองตอนถูกเรียกครั้งแรก (go doc testing.B.Loop) รูปแบบ modern ที่เทียบเท่าจึงข้าม b.ResetTimer() ไปเลย:
func BenchmarkIsPrimeList(b *testing.B) { numbers := make([]int, 1000) for i := range numbers { numbers[i] = i + 2 }
for b.Loop() { for _, n := range numbers { IsPrime(n) } }}b.Loop ยังทำให้ผลลัพธ์ของ loop ถูก keep alive อยู่ ทำให้ compiler ไม่สามารถ optimize โค้ดที่ต้องการวัดทิ้งได้ — นี่คือเหตุผลหลักที่ควรเลือกใช้แทน b.N ไม่ใช่แค่เรื่อง reset timer อัตโนมัติ
ทำไม b.Loop กัน dead-code elimination ได้ และ sink idiom สำหรับ b.N
หัวข้อที่มีชื่อว่า “ทำไม b.Loop กัน dead-code elimination ได้ และ sink idiom สำหรับ b.N”compiler มีสิทธิ์ลบ computation ใดก็ตามที่ไม่มีใครใช้ผลลัพธ์ benchmark body ที่คำนวณอะไรบางอย่างแล้วทิ้งผลลัพธ์ไปคือ dead code แบบนั้นเป๊ะ ๆ — และ compiler ที่ optimize เก่งพอจะพิสูจน์ได้ว่า Fibonacci(20) ถูกเรียกด้วยค่าคงที่และไม่มีใครสังเกตผลลัพธ์ ก็สามารถข้ามการเรียกมันไปเลยในทางทฤษฎี go doc testing.B.Loop ระบุการแก้ไขที่ฝังอยู่ในรูปแบบ modern:
Within the body of a "for b.Loop() { ... }" loop, arguments to and resultsfrom function calls and assigned variables within the loop are kept alive,preventing the compiler from fully optimizing away the loop body. Currently,this is implemented as a compiler transformation that wraps such variableswith a runtime.KeepAlive intrinsic call.รูปแบบ classic b.N ไม่ได้รับการป้องกันนี้เลย ดังนั้นวิธีป้องกัน idiomatic คือ sink idiom: assign ผลลัพธ์ให้ตัวแปรระดับ package เพื่อไม่ให้ compiler พิสูจน์ได้ว่ามันไม่มีใครสังเกต
var sink int
// The sink idiom for the classic b.N form: assigning to a package-level// variable prevents the compiler from proving the result is unused.func BenchmarkFibSink(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = Fibonacci(20) } sink = r}ยืนยันแล้วจริง — ทั้งสองรูปแบบวัด function เดียวกันได้ต้นทุนใกล้เคียงกัน ยืนยันว่า sink ทำหน้าที่ของมันโดยไม่เพิ่ม overhead ที่มีนัยสำคัญ:
BenchmarkFibSink-11 183465904 5.967 ns/op 0 B/op 0 allocs/opBenchmarkFibLoop-11 185444034 6.493 ns/op 0 B/op 0 allocs/opbenchmark แบบ b.N ที่ assign ผลลัพธ์ให้ local variable แล้วไม่ใช้อีกเลยไม่มีการป้องกันแบบนี้ — compiler มีอิสระที่จะลบการเรียกที่คิดว่าไม่มีใครสังเกตทิ้งไป
การรัน benchmarks
หัวข้อที่มีชื่อว่า “การรัน benchmarks”Unit tests ไม่ รัน benchmarks โดยค่าเริ่มต้น ใช้ flag -bench:
# Run all benchmarks, show memory allocationsgo test -bench=. -benchmem ./...
# Run only benchmarks matching a name patterngo test -bench=BenchmarkFib -benchmem ./...
# Run benchmarks for at least 5 seconds per functiongo test -bench=. -benchtime=5s ./...Sub-benchmark สำหรับการ sweep ขนาด
หัวข้อที่มีชื่อว่า “Sub-benchmark สำหรับการ sweep ขนาด”b.Run ทำงานข้างใน benchmark ได้เหมือนกับข้างใน test ทุกประการ: มันจัดกลุ่มการวัดที่เกี่ยวข้องไว้ใต้ parent เดียว และตั้งชื่อแต่ละตัวใน output การใช้งานที่พบบ่อยที่สุดคือ sweep ขนาด input เพื่อดูว่าต้นทุน scale อย่างไร:
func BenchmarkFibSizes(b *testing.B) { for _, n := range []int{10, 20, 30} { b.Run(name(n), func(b *testing.B) { for b.Loop() { sink = Fibonacci(n) } }) }}Output จริง — สังเกตต้นทุนที่เพิ่มตาม n ตามที่คาดสำหรับ Fibonacci แบบ iterative นี้:
BenchmarkFibSizes/n=10-11 309100298 3.922 ns/opBenchmarkFibSizes/n=20-11 188798964 6.398 ns/opBenchmarkFibSizes/n=30-11 126663669 9.526 ns/op-bench 'FibSizes/n=20' รันเฉพาะขนาดนั้นตัวเดียว — กฎ filter แบบ Parent/sub เดียวกับ t.Run
การอ่านผลลัพธ์
หัวข้อที่มีชื่อว่า “การอ่านผลลัพธ์”วัดจริงในเครื่องด้วย go test -bench=. -benchmem (Go 1.26.5, Apple M3 Pro) กับ benchmark Fibonacci(20) ด้านบน:
BenchmarkFibClassic-11 60018 19425 ns/op 0 B/op 0 allocs/opBenchmarkFib-11 58771 20169 ns/op 0 B/op 0 allocs/op| คอลัมน์ | ความหมาย |
|---|---|
suffix -11 | ค่า GOMAXPROCS (logical CPUs ของเครื่องที่รัน — เครื่องคุณอาจต่างออกไป) |
60018 | จำนวน iterations ที่ benchmark รัน |
19425 ns/op | nanoseconds ต่อ operation (ขึ้นกับ hardware ด้วย — อย่าเทียบข้ามเครื่อง) |
0 B/op | bytes ที่ allocate ต่อ operation (ต้องใช้ -benchmem) |
0 allocs/op | heap allocations ต่อ operation (ต้องใช้ -benchmem) |
Fibonacci ไม่ allocate อะไรเลยเพราะใช้แค่ stack variables — นั่นคือเหตุผลที่ทั้งสองคอลัมน์ alloc เป็นศูนย์ทั้งสองแถว function ที่สร้าง []string จะแสดงค่าที่ไม่ใช่ศูนย์
b.ReportAllocs, ReportMetric และ SetBytes
หัวข้อที่มีชื่อว่า “b.ReportAllocs, ReportMetric และ SetBytes”-benchmem บน command line เปิด allocation reporting ให้ทุก benchmark ในการรันนั้น; b.ReportAllocs() เปิดมันเฉพาะ benchmark function ตัวใดตัวหนึ่งไม่ว่า flag จะตั้งไว้อย่างไร — มีประโยชน์เมื่อมีแค่บาง benchmark ใน suite ใหญ่ที่สนใจเรื่อง allocation b.SetBytes(n) เพิ่มคอลัมน์ throughput (MB/s) โดยบอกว่า 1 iteration ประมวลผลกี่ byte b.ReportMetric(value, "unit/op") เพิ่มคอลัมน์กำหนดเองสำหรับการวัดเฉพาะทางได้
func BenchmarkBuildString(b *testing.B) { b.ReportAllocs() var out string for b.Loop() { var sb strings.Builder for i := 0; i < 100; i++ { sb.WriteString("x") } out = sb.String() } b.SetBytes(int64(len(out)))}
func BenchmarkWithMetric(b *testing.B) { var n int for b.Loop() { n += Fibonacci(10) } b.ReportMetric(float64(n)/float64(b.N), "fib-sum/op")}Output จริง:
BenchmarkBuildString-11 5196698 212.0 ns/op 471.78 MB/s 248 B/op 5 allocs/opBenchmarkWithMetric-11 337696298 3.497 ns/op 55.00 fib-sum/op 0 B/op 0 allocs/op248 B/op กับ 5 allocs/op สำหรับ string ยาว 100 byte คือสิ่งที่คุณคงเดาไม่ถูกจากการอ่านโค้ดเฉย ๆ — strings.Builder โต internal buffer แบบ geometric ดังนั้นการสร้าง string 100 byte จึงมีค่าใช้จ่ายแค่ realloc ไม่กี่ครั้ง ไม่ใช่ 1 allocation ต่อการเรียก WriteString แต่ละครั้ง
เปรียบเทียบผลรันด้วย benchstat
หัวข้อที่มีชื่อว่า “เปรียบเทียบผลรันด้วย benchstat”การรัน benchmark ครั้งเดียวมี noise — GC pause, CPU frequency scaling และ background process ล้วนเพิ่ม variance benchstat (golang.org/x/perf/cmd/benchstat) รับ output ของ go test -bench สองชุดขึ้นไป แล้วคำนวณว่าความต่างนั้นจริงหรือแค่ noise พร้อม p-value
go test -bench BenchmarkFibLoop -count=10 . > old.txt# ... change the implementation ...go test -bench BenchmarkFibLoop -count=10 . > new.txtbenchstat old.txt new.txtการเปรียบเทียบจริงระหว่าง Fibonacci แบบ iterative สอง implementation ที่ต่างกันจริงใน scratch module ของบทนี้ -count=10 ทั้งสองข้าง:
│ old.txt │ new.txt │ │ sec/op │ sec/op vs base │FibLoop-11 6.430n ± 2% 6.619n ± 1% +2.94% (p=0.000 n=10)p=0.000 (ต่ำกว่า threshold ปกติที่ 0.05) หมายความว่า benchstat มั่นใจว่าความต่าง +2.94% เป็นของจริง ไม่ใช่ noise — -count=10 ให้ sample เพียงพอต่อฝั่งให้พูดแบบนั้นได้; รันแค่ครั้งเดียวต่อฝั่งอาจแสดงทิศทางตรงข้ามได้ง่าย ๆ ด้วยความบังเอิญ
Profiling ด้วย -cpuprofile และ go tool pprof
หัวข้อที่มีชื่อว่า “Profiling ด้วย -cpuprofile และ go tool pprof”เมื่อผลลัพธ์ benchmark บอกว่าอะไรบางอย่างช้าแต่ไม่บอกว่า อะไร ให้ capture CPU profile แล้วตรวจสอบมัน:
go test -bench BenchmarkFibLoop -cpuprofile=cpu.prof .go tool pprof -top cpu.profOutput จริง function อันดับต้นตาม self time (flat):
Duration: 1.32s, Total samples = 1150ms (86.91%) flat flat% sum% cum cum% 940ms 81.74% 81.74% 960ms 83.48% example.com/bm.Fibonacci (inline) 120ms 10.43% 92.17% 120ms 10.43% testing.(*B).Loop (inline) 70ms 6.09% 98.26% 1150ms 100% example.com/bm.BenchmarkFibLoop 20ms 1.74% 100% 20ms 1.74% runtime.asyncPreemptflat คือเวลาที่ใช้ใน function นั้นเอง; cum (cumulative) รวมทุกอย่างที่มันเรียกด้วย go tool pprof -http=:8080 cpu.prof (Go 1.26+ เปิดตรงไปที่ flame-graph view เลย) ให้มุมมองแบบ interactive ซูมได้ของข้อมูลเดียวกันในเบราว์เซอร์ — เดินดูง่ายกว่าตาราง text มากเมื่อ profile มี hot function มากกว่าไม่กี่ตัว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
go test -bench | วัด performance ด้วยเครื่องมือ built-in | ต้องรันซ้ำหลายครั้ง ระวัง noise จาก GC และ CPU throttling |
b.N auto-calibration | framework กำหนด iteration count ให้ได้ผลที่เสถียร | ถ้าใส่ setup ใน loop = benchmark นับ setup ด้วย; ต้องมี sink idiom กัน dead-code elimination |
b.ReportAllocs() | เห็น heap allocation ต่อ operation ชัดเจน | ต้องเข้าใจ Escape Analysis เพื่อตีความผลลัพธ์ |
benchstat ด้วย -count=10 | เปลี่ยน “รู้สึกว่าเร็วขึ้น” ให้เป็น p-value | ใช้เวลานานกว่ารันครั้งเดียว 10 เท่าต่อฝั่ง |
| pprof profiling | วิเคราะห์ CPU/memory hotspot เชิงลึก | overhead ขณะ profiling — อย่าใช้ใน production โดยไม่ตั้งใจ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- benchmark result บน dev machine = production — ต้องคำนึง CPU throttling, GC state, background processes ที่ต่างกัน
b.Nคือจำนวนที่กำหนดเอง — Go calibrateb.Nอัตโนมัติให้ benchmark ใช้เวลาประมาณ 1 วินาที — อย่ากำหนดค่าb.Nเองallocs/op = 0หมายถึง optimal เสมอ — zero allocation ดี แต่ถ้า CPU work เพิ่มขึ้นแทน อาจไม่ได้ดีกว่า- benchmark สำหรับ micro-optimization เท่านั้น — benchmark ช่วย validate architectural decision ได้ด้วย เช่น channel vs mutex, slice vs map
- รัน go test -bench ครั้งเดียวก็พอจะพูดว่า “X เร็วกว่า Y” — รันครั้งเดียวแยกผลจริงจาก noise ของเครื่องไม่ได้ นี่คือสิ่งที่
benchstatกับ-count=10(หรือมากกว่า) มีไว้ทำ - รูปแบบ classic
for i := 0; i < b.N; i++ปลอดภัยจาก dead-code elimination เหมือนb.Loop()อัตโนมัติ — ไม่จริง ถ้าไม่มี sink idiom compiler ที่ optimize เก่งพอลบการเรียกที่ผลลัพธ์ถูก assign ให้ local variable ที่ไม่มีใครใช้ได้ตามกฎหมาย
💡 ตัวอย่างจากของจริง
Go standard library —
encoding/json/bench_test.go(เปิดดูได้ที่$(go env GOROOT)/src/encoding/json/bench_test.go) benchmark การ encode และ decode กับ payload ตัวแทนหลายแบบ และ benchmark ของ packagestrconvเองอยู่ที่strconv/quote_test.go— ทั้งสองเป็นไฟล์_test.goธรรมดาที่ใช้ pattern เดียวกับในบทนี้เป๊ะ ๆ
การ calibrate และวัดผล benchmark
หัวข้อที่มีชื่อว่า “การ calibrate และวัดผล benchmark”flowchart TD
A["go test -bench=Name"] --> B["start with a small b.N / call b.Loop()"]
B --> C["run the benchmarked code"]
C --> D{"ran long enough for a stable measurement?"}
D -->|no| E["increase b.N, or b.Loop() keeps returning true"]
E --> C
D -->|yes| F["report ns/op, B/op, allocs/op"] อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”benchmark ที่ generate มามักจะ compile ผ่านและได้ตัวเลขออกมา — ซึ่งเป็นปัญหาพอดี เพราะตัวเลขที่ดูสมเหตุสมผลไม่เหมือนกับตัวเลขที่มีความหมาย เช็กเรื่องพวกนี้:
- setup ที่มีค่าใช้จ่ายสูง (สร้าง slice, เปิดไฟล์, format string) ถูกเขียนไว้ ข้างใน body ของ
for b.Loop()หรือfor i := 0; i < b.N; i++แทนที่จะอยู่ก่อนมันหรือเปล่า? นั่นทำให้ทุกการวัดพองขึ้นด้วยงานที่ benchmark ไม่ได้ตั้งใจจะวัด - ใน benchmark แบบ
b.Nclassic ผลลัพธ์ของการเรียกถูก assign ให้ตัวแปรระดับ package (sink idiom), return, หรือถูกสังเกตด้วยวิธีอื่นหรือเปล่า — หรือมันแค่หายไปใน local variable ที่ไม่มีใครอ่านต่อ? - benchmark แบบ
b.Nเรียกb.ResetTimer()หลัง setup หรือเปล่า หรือลืมมันไปแล้วรวมต้นทุน setup ไว้ในทุกตัวเลขที่รายงานอย่างเงียบ ๆ? -benchmem(หรือb.ReportAllocs()) ถูกใช้จริงก่อนที่ summary ที่ generate มาจะอ้างว่าการเปลี่ยนแปลง “efficient ขึ้น” หรือเปล่า? การเทียบแค่ CPU อย่างเดียวซ่อน regression ที่แลก CPU time กับ allocation ที่เพิ่มขึ้นได้- benchmark รันบนเครื่อง dev ที่ยุ่งอยู่ — VS Code, browser, server ที่กำลังรัน — แล้วคำตอบปฏิบัติต่อ
ns/opที่ได้เหมือนเป็นตัวเลขแม่นยำที่เทียบข้ามเครื่องได้หรือเปล่า?
🐛 หา bug ในโค้ดนี้
func BenchmarkParseConfig(b *testing.B) { for i := 0; i < b.N; i++ { data := loadTestFixture() // reads a file from disk every iteration ParseConfig(data) }}เฉลย
loadTestFixture() อ่านไฟล์จาก disk ทุก iteration ดังนั้น benchmark จึงวัด disk I/O บวกกับการ parse ไม่ใช่ ParseConfig เพียงอย่างเดียว — และเพราะใช้รูปแบบ classic b.N โดยไม่มี b.ResetTimer() จึงไม่มี setup phase ให้ย้ายไปอยู่ตรงนั้นด้วย การแก้คือปรับโครงสร้าง benchmark ใหม่: load fixture data ครั้งเดียวก่อน loop แล้วส่ง byte ที่ load แล้วให้ ParseConfig ข้างใน — for b.Loop() { ParseConfig(data) } โดยมี data := loadTestFixture() อยู่เหนือ loop ตามที่เขียนไว้ benchmark นี้จะรายงานตัวเลขออกมา และมันจะดูมีความหมาย แต่มันตอบคำถาม “disk I/O บวก parsing เร็วแค่ไหน” ไม่ใช่คำถามที่มันอ้างว่าตอบ