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

Benchmarks

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 เอง

Go 1.24 แนะนำ b.Loop() เป็นทางเลือกที่ดีกว่า classic b.N counter loop:

// Classic form — works in all Go versions
func 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 และมีโอกาสผิดพลาดน้อยกว่า

หาก benchmark ต้องการ setup ที่ใช้เวลามาก (เปิดไฟล์, สร้าง slice ขนาดใหญ่) ให้ทำก่อน loop รูปแบบ classic b.N ต้องเรียก b.ResetTimer() เองเพื่อไม่ให้เวลา setup ถูกนับ:

// Classic b.N form — needs an explicit ResetTimer
func 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 อัตโนมัติ

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 results
from 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 variables
with 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/op
BenchmarkFibLoop-11 185444034 6.493 ns/op 0 B/op 0 allocs/op

benchmark แบบ b.N ที่ assign ผลลัพธ์ให้ local variable แล้วไม่ใช้อีกเลยไม่มีการป้องกันแบบนี้ — compiler มีอิสระที่จะลบการเรียกที่คิดว่าไม่มีใครสังเกตทิ้งไป

Unit tests ไม่ รัน benchmarks โดยค่าเริ่มต้น ใช้ flag -bench:

Terminal window
# Run all benchmarks, show memory allocations
go test -bench=. -benchmem ./...
# Run only benchmarks matching a name pattern
go test -bench=BenchmarkFib -benchmem ./...
# Run benchmarks for at least 5 seconds per function
go test -bench=. -benchtime=5s ./...

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/op
BenchmarkFibSizes/n=20-11 188798964 6.398 ns/op
BenchmarkFibSizes/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/op
BenchmarkFib-11 58771 20169 ns/op 0 B/op 0 allocs/op
คอลัมน์ความหมาย
suffix -11ค่า GOMAXPROCS (logical CPUs ของเครื่องที่รัน — เครื่องคุณอาจต่างออกไป)
60018จำนวน iterations ที่ benchmark รัน
19425 ns/opnanoseconds ต่อ operation (ขึ้นกับ hardware ด้วย — อย่าเทียบข้ามเครื่อง)
0 B/opbytes ที่ allocate ต่อ operation (ต้องใช้ -benchmem)
0 allocs/opheap allocations ต่อ operation (ต้องใช้ -benchmem)

Fibonacci ไม่ allocate อะไรเลยเพราะใช้แค่ stack variables — นั่นคือเหตุผลที่ทั้งสองคอลัมน์ alloc เป็นศูนย์ทั้งสองแถว function ที่สร้าง []string จะแสดงค่าที่ไม่ใช่ศูนย์

-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/op
BenchmarkWithMetric-11 337696298 3.497 ns/op 55.00 fib-sum/op 0 B/op 0 allocs/op

248 B/op กับ 5 allocs/op สำหรับ string ยาว 100 byte คือสิ่งที่คุณคงเดาไม่ถูกจากการอ่านโค้ดเฉย ๆ — strings.Builder โต internal buffer แบบ geometric ดังนั้นการสร้าง string 100 byte จึงมีค่าใช้จ่ายแค่ realloc ไม่กี่ครั้ง ไม่ใช่ 1 allocation ต่อการเรียก WriteString แต่ละครั้ง

การรัน benchmark ครั้งเดียวมี noise — GC pause, CPU frequency scaling และ background process ล้วนเพิ่ม variance benchstat (golang.org/x/perf/cmd/benchstat) รับ output ของ go test -bench สองชุดขึ้นไป แล้วคำนวณว่าความต่างนั้นจริงหรือแค่ noise พร้อม p-value

Terminal window
go test -bench BenchmarkFibLoop -count=10 . > old.txt
# ... change the implementation ...
go test -bench BenchmarkFibLoop -count=10 . > new.txt
benchstat 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 เพียงพอต่อฝั่งให้พูดแบบนั้นได้; รันแค่ครั้งเดียวต่อฝั่งอาจแสดงทิศทางตรงข้ามได้ง่าย ๆ ด้วยความบังเอิญ

เมื่อผลลัพธ์ benchmark บอกว่าอะไรบางอย่างช้าแต่ไม่บอกว่า อะไร ให้ capture CPU profile แล้วตรวจสอบมัน:

Terminal window
go test -bench BenchmarkFibLoop -cpuprofile=cpu.prof .
go tool pprof -top cpu.prof

Output จริง 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.asyncPreempt

flat คือเวลาที่ใช้ใน 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-calibrationframework กำหนด 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 calibrate b.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 ของ package strconv เองอยู่ที่ strconv/quote_test.go — ทั้งสองเป็นไฟล์ _test.go ธรรมดาที่ใช้ pattern เดียวกับในบทนี้เป๊ะ ๆ

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"]
go test calibrate benchmark อย่างไรก่อนรายงานผลลัพธ์

benchmark ที่ generate มามักจะ compile ผ่านและได้ตัวเลขออกมา — ซึ่งเป็นปัญหาพอดี เพราะตัวเลขที่ดูสมเหตุสมผลไม่เหมือนกับตัวเลขที่มีความหมาย เช็กเรื่องพวกนี้:

  • setup ที่มีค่าใช้จ่ายสูง (สร้าง slice, เปิดไฟล์, format string) ถูกเขียนไว้ ข้างใน body ของ for b.Loop() หรือ for i := 0; i < b.N; i++ แทนที่จะอยู่ก่อนมันหรือเปล่า? นั่นทำให้ทุกการวัดพองขึ้นด้วยงานที่ benchmark ไม่ได้ตั้งใจจะวัด
  • ใน benchmark แบบ b.N classic ผลลัพธ์ของการเรียกถูก 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 เร็วแค่ไหน” ไม่ใช่คำถามที่มันอ้างว่าตอบ

benchmark framework ทำอะไรกับ b.N ระหว่างการรัน benchmark?
flag ใดทำให้ go test รายงาน bytes ที่ allocate ต่อ operation?
ทำไมผลลัพธ์ของ benchmark นี้ถึงเชื่อถือไม่ได้เทียบกับการ assign ให้ sink?
var sink int
func BenchmarkCompute(b *testing.B) {
    for i := 0; i < b.N; i++ {
        r := Compute(42)
        _ = r
    }
}
การรัน go test -bench สองครั้งก่อนและหลังเปลี่ยนโค้ด แสดง old=84.00ns และ new=208.50ns โดย benchstat รายงาน p=0.010, n=10 p-value นี้บอกอะไร?
รูปแบบ benchmark loop ที่แนะนำใน Go 1.24+ คืออะไร?
benchmark นี้วัดอะไรจริง ๆ?
func BenchmarkSort(b *testing.B) {
    for b.Loop() {
        data := makeRandomSlice(10000) // fresh 10,000-element slice
        sort.Ints(data)
    }
}