본문으로 건너뛰기

벤치마크

이 챕터에서 다루는 것

파트 7은 세 가지 질문을 "파트 8의 벤치마크로 재라"며 미뤄 뒀다.

  • 7-5: RWMutex가 정말 Mutex보다 빠른가?
  • 7-5: sync.Pool은 언제 이득인가?
  • 7-8: 워커는 몇 개가 좋은가?

이 챕터는 도구를 익히고, 그 도구로 세 질문에 이 기계에서의 숫자로 답한다.

:::warning 이 챕터의 모든 숫자는 이 기계의 것이다 darwin/arm64, Apple M4, 논리 코어 10개, go1.26.5에서 측정했다. 절대값을 외우지 않는다. 배우는 것은 측정 방법과 해석 방법이고, 숫자는 당신의 기계에서 다시 나와야 한다. :::

형태

벤치마크는 _test.go 파일에 func BenchmarkXxx(b *testing.B)로 쓴다. go test는 기본적으로 벤치마크를 돌리지 않으므로 -bench를 줘야 한다.

cd examples/08-testing
go test -run '^$' -bench . ./05-benchmarks/vanish

-run '^$'테스트는 하나도 매치되지 않게 하는 관용구다. 벤치마크만 돌린다.

b.Loop이 기본이다

func BenchmarkLoop(b *testing.B) {
i := 0
for b.Loop() {
Square(i)
i++
}
}

예전 형태는 이랬다.

// 오래된 코드에서 보게 될 형태다. 새로 쓸 때는 b.Loop을 쓴다.
func BenchmarkOldStyle(b *testing.B) {
for i := 0; i < b.N; i++ {
Square(i)
}
}

b.Loop이 나은 이유가 셋이다.

  1. 타이머를 자동으로 관리한다. 첫 호출에서 타이머를 리셋하므로 루프 앞의 준비 코드가 측정에서 빠지고, false를 반환할 때 멈추므로 뒤의 정리 코드도 빠진다. b.ResetTimer()/b.StopTimer()를 손으로 부를 일이 크게 줄어든다.
  2. 벤치마크 함수를 측정당 한 번만 실행한다. b.N 방식은 반복 횟수를 찾느라 함수 전체를 여러 번 다시 부른다. 준비 비용이 큰 벤치마크에서 차이가 크다.
  3. 루프 본문의 값을 살려 둔다. 다음 절의 주제다.

b.Loopfalse를 반환한 뒤에는 b.N에 총 반복 횟수가 들어 있다. 둘을 섞어 쓰지는 않는다 — b.Loop을 쓰거나 b.N 루프를 쓰거나 하나다.

사라지는 벤치마크

컴파일러는 결과를 쓰지 않는 계산을 지울 수 있다. 벤치마크에서 이것은 치명적이다. "0.25ns/op"라는 눈부신 결과가 사실은 아무것도 실행하지 않은 것일 수 있다.

examples/08-testing/05-benchmarks/vanish/vanish.go
// Package vanish는 컴파일러 최적화로 사라지는 벤치마크를 보여 준다.
package vanish

// Square는 인라인될 만큼 작다. 결과를 안 쓰면 호출 자체가 지워질 수 있다.
func Square(n int) int { return n * n }

여섯 가지 변형을 나란히 놓고 재 본다.

examples/08-testing/05-benchmarks/vanish/vanish_test.go
// sink는 결과를 패키지 밖으로 흘려보내 최적화를 막는 전통적인 장치다.
var sink int

// 빈 루프. 두 방식의 반복 자체가 얼마나 비싼지를 보는 기준선이다.
func BenchmarkOldStyleEmpty(b *testing.B) {
for i := 0; i < b.N; i++ {
}
}

func BenchmarkLoopEmpty(b *testing.B) {
for b.Loop() {
}
}

// 구식 b.N 루프 + 결과 버리기. 컴파일러가 호출을 통째로 지운다.
func BenchmarkOldStyleVanishes(b *testing.B) {
for i := 0; i < b.N; i++ {
Square(i)
}
}

// 마지막 결과만 대입하는 것으로는 부족할 수 있다.
// 컴파일러는 "마지막 한 번만 계산하면 된다"고 판단할 여지가 있다.
func BenchmarkOldStyleLastValueOnly(b *testing.B) {
var s int
for i := 0; i < b.N; i++ {
s = Square(i)
}
sink = s
}

// 매 회차의 결과를 누적하면 모든 호출이 살아남는다.
func BenchmarkOldStyleAccumulate(b *testing.B) {
var s int
for i := 0; i < b.N; i++ {
s += Square(i)
}
sink = s
}

// b.Loop은 루프 본문의 함수 호출 결과를 살려 둔다.
// sink 같은 장치 없이도 호출이 사라지지 않는다.
func BenchmarkLoop(b *testing.B) {
i := 0
for b.Loop() {
Square(i)
i++
}
}
go test -run '^$' -bench . -benchtime 300ms ./05-benchmarks/vanish
goos: darwin
goarch: arm64
pkg: example.com/testing-and-quality/05-benchmarks/vanish
cpu: Apple M4
BenchmarkOldStyleEmpty-10 1000000000 0.2514 ns/op
BenchmarkLoopEmpty-10 204507738 1.763 ns/op
BenchmarkOldStyleVanishes-10 1000000000 0.2518 ns/op
BenchmarkOldStyleLastValueOnly-10 1000000000 0.2515 ns/op
BenchmarkOldStyleAccumulate-10 1000000000 0.2691 ns/op
BenchmarkLoop-10 203893852 1.766 ns/op
BenchmarkLoopBatched-10 311968 1152 ns/op 1.152 ns/square

읽는 법부터 본다. 이름 뒤의 -10GOMAXPROCS이다. 그다음이 반복 횟수, 그다음이 회차당 시간이다.

이제 결론이다.

BenchmarkOldStyleVanishes(0.2518ns)는 BenchmarkOldStyleEmpty(0.2514ns)와 같다.Square 호출이 통째로 사라졌다. 이것이 구식 루프의 함정이다.

LastValueOnly도 사라졌다. sink에 대입하는 전통적인 처방이 여기서는 듣지 않는다. 마지막 반복의 값만 필요하니 앞의 계산을 전부 지울 수 있기 때문이다. Accumulate(0.2691ns)는 매 회차를 누적하므로 살아남지만, 차이가 0.02ns라 사실상 루프 오버헤드에 묻힌다.

b.Loop은 장치 없이도 호출을 살려 둔다. 다만 LoopEmpty가 1.763ns인 것에 주목한다 — b.Loop 자체의 회차당 오버헤드가 약 1.76ns다. BenchmarkLoop이 1.766ns인 것은 Square가 그 오버헤드에 완전히 묻혔다는 뜻이다.

:::warning 나노초 단위 연산은 루프로 재지 않는다 b.Loop의 오버헤드(이 기계에서 약 1.76ns)보다 작은 연산은 루프 방식으로 잴 수 없다. 한 회차에 여러 번을 묶고 나눠 읽는다.

examples/08-testing/05-benchmarks/vanish/vanish_test.go
// 한 회차가 나노초 미만이면 루프 오버헤드가 측정값을 덮어쓴다.
// 그럴 때는 한 회차에 여러 번을 묶고 결과를 batch로 나눠 읽는다.
func BenchmarkLoopBatched(b *testing.B) {
const batch = 1000
var s int
for b.Loop() {
for i := range batch {
s += Square(i)
}
}
sink = s
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N*batch), "ns/square")
}

결과는 1.152 ns/square였다. 이제 Square 하나의 비용이 실제로 보인다. b.ReportMetric은 임의의 이름으로 지표를 추가하는 함수다. :::

:::note Go 1.26의 변경 Go 1.25까지 b.Loop은 루프 본문의 인라인을 막았다. 1.26에서 그 제약이 없어져, 이제 b.Loop 안의 코드도 평소처럼 인라인된다. 최적화 환경이 실제 코드와 같아졌다는 뜻이다. :::

메모리 측정

-benchmem을 붙이거나 벤치마크 안에서 b.ReportAllocs()를 부르면 회차당 할당 바이트와 할당 횟수가 나온다.

여기서 sync.Pool은 언제 이득인가(7-5의 빚)를 갚는다.

examples/08-testing/05-benchmarks/bufpool/bufpool.go
// Fresh는 호출할 때마다 새 버퍼를 만든다.
func Fresh(vals []int) string {
var buf bytes.Buffer
write(&buf, vals)
return buf.String()
}

var pool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}

// Pooled는 풀에서 버퍼를 빌려 쓰고 돌려준다.
//
// 돌려주기 전에 Reset을 부르는 것이 핵심이다. 빼먹으면 다음 사용자가
// 남의 데이터를 이어 쓰게 된다 — sync.Pool의 단골 버그다.
func Pooled(vals []int) string {
buf := pool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
pool.Put(buf)
}()
write(buf, vals)
return buf.String()
}
go test -run '^$' -bench . -benchmem -benchtime 200ms ./05-benchmarks/bufpool
goos: darwin
goarch: arm64
pkg: example.com/testing-and-quality/05-benchmarks/bufpool
cpu: Apple M4
BenchmarkFormat/n=4/impl=fresh-10 4915845 42.85 ns/op 80 B/op 2 allocs/op
BenchmarkFormat/n=4/impl=pooled-10 6665925 34.57 ns/op 16 B/op 1 allocs/op
BenchmarkFormat/n=1000/impl=fresh-10 17055 13998 ns/op 25024 B/op 994 allocs/op
BenchmarkFormat/n=1000/impl=pooled-10 18384 13072 ns/op 8708 B/op 986 allocs/op
BenchmarkFormatParallel/impl=fresh-10 32743 7121 ns/op 25024 B/op 994 allocs/op
BenchmarkFormatParallel/impl=pooled-10 61988 3891 ns/op 8709 B/op 986 allocs/op

읽어 보면 이렇다.

  • 직렬 실행에서는 이득이 작다. n=1000에서 13998 → 13072ns, 6.6%다. 할당 바이트는 25KB → 8.7KB로 줄었지만 할당 횟수는 994 → 986으로 거의 그대로다. 대부분의 할당이 버퍼가 아니라 strconv.Itoa와 최종 String()에서 나오기 때문이다.
  • 동시 실행에서는 이득이 크다. 7121 → 3891ns, 45% 단축이다. sync.Pool은 P마다 로컬 캐시를 두므로 할당자 경합이 있을 때 진가가 난다.

7-5의 결론이 그대로 확인된다. sync.Pool은 "큰 객체를 높은 동시성으로 반복 할당할 때" 쓰는 것이고, 그 조건이 아니면 Get/Put 비용만 든다. 그리고 그 판단은 이렇게 재고 나서 한다.

:::danger Reset을 빼먹으면 pool.Put(buf) 전에 buf.Reset()을 부르지 않으면 다음 사용자가 남의 데이터 뒤에 이어 쓴다. 조용히 잘못된 결과가 나오는 종류의 버그다.

예제의 TestFreshAndPooledAgreePooled를 연속으로 세 번 부르는 이유가 이것이다. 한 번만 부르면 풀이 비어 있어 항상 새 버퍼가 나오고, 재사용 경로를 밟지 않아 버그가 드러나지 않는다. :::

병렬 벤치마크와 -cpu

b.RunParallelGOMAXPROCS개의 고루틴을 띄우고 총 반복 횟수를 나눠 준다. 경합이 있는 코드는 이렇게 재야 의미가 있다.

여기서 RWMutex가 정말 빠른가(7-5의 빚)를 갚는다.

examples/08-testing/05-benchmarks/cache/cache.go
// sum은 임계 구역 안에서 하는 실제 일이다.
// 인자의 길이가 곧 임계 구역의 길이가 된다 — 이것이 이 실험의 손잡이다.
func sum(b []byte) uint64 {
var s uint64
for _, c := range b {
s = s*31 + uint64(c)
}
return s
}

같은 인터페이스를 Mutex, RWMutex, sync.Map 셋으로 구현하고, 값의 크기를 바꿔 가며 읽기 전용 부하를 건다. 값이 클수록 임계 구역이 길어진다.

examples/08-testing/05-benchmarks/cache/cache_test.go
// benchRead는 여러 고루틴이 동시에 읽기만 하는 부하를 건다.
// RunParallel은 GOMAXPROCS개의 고루틴을 띄우고 b.N번의 일을 나눠 준다.
func benchRead(b *testing.B, s Store, valSize int) {
keys := fill(s, valSize)
b.ResetTimer()

b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
s.Get(keys[i%keyCount])
i++
}
})
}

func BenchmarkReadOnly(b *testing.B) {
for _, valSize := range []int{8, 4096} {
b.Run(fmt.Sprintf("val=%dB", valSize), func(b *testing.B) {
b.Run("impl=mutex", func(b *testing.B) { benchRead(b, NewMutexStore(), valSize) })
b.Run("impl=rwmutex", func(b *testing.B) { benchRead(b, NewRWStore(), valSize) })
b.Run("impl=syncmap", func(b *testing.B) { benchRead(b, NewSyncMapStore(), valSize) })
})
}
}

:::tip 벤치마크 전에 정확성을 확인한다 같은 패키지의 TestStoresAgree가 세 구현이 같은 답을 내는지 먼저 검사한다. 틀린 코드가 빠른 것은 아무 의미가 없다. 벤치마크만 있고 테스트가 없는 비교 실험은 믿을 수 없다. :::

go test -run '^$' -bench . -benchtime 200ms ./05-benchmarks/cache
goos: darwin
goarch: arm64
pkg: example.com/testing-and-quality/05-benchmarks/cache
cpu: Apple M4
BenchmarkReadOnly/val=8B/impl=mutex-10 2482506 95.01 ns/op
BenchmarkReadOnly/val=8B/impl=rwmutex-10 2782996 84.24 ns/op
BenchmarkReadOnly/val=8B/impl=syncmap-10 78344961 2.574 ns/op
BenchmarkReadOnly/val=4096B/impl=mutex-10 71970 3311 ns/op
BenchmarkReadOnly/val=4096B/impl=rwmutex-10 518689 443.3 ns/op
BenchmarkReadOnly/val=4096B/impl=syncmap-10 539931 437.2 ns/op

임계 구역이 짧으면(8바이트) RWMutex의 이득은 11%뿐이다. RLockLock보다 그 자체로 비싸고, 임계 구역이 짧으면 그 비용이 병렬성 이득을 거의 다 먹는다.

임계 구역이 길면(4096바이트) 7.5배 차이가 난다. 3311ns → 443ns. 10개 코어가 실제로 동시에 읽기 때문이다.

sync.Map이 작은 값에서 압도적인 것(2.57ns)은 읽기 경로에 잠금이 없기 때문이다. 큰 값에서는 sum 계산이 병목이 되어 RWMutex와 같아진다.

-cpu로 흔들기

병렬성이 없으면 어떻게 되는지 보면 이야기가 완성된다.

go test -run '^$' -bench 'ReadOnly/val=4096B' -cpu 1,4,10 -benchtime 200ms ./05-benchmarks/cache
BenchmarkReadOnly/val=4096B/impl=mutex 77392 3095 ns/op
BenchmarkReadOnly/val=4096B/impl=mutex-4 70566 3408 ns/op
BenchmarkReadOnly/val=4096B/impl=mutex-10 71389 3323 ns/op
BenchmarkReadOnly/val=4096B/impl=rwmutex 77344 3093 ns/op
BenchmarkReadOnly/val=4096B/impl=rwmutex-4 275818 857.7 ns/op
BenchmarkReadOnly/val=4096B/impl=rwmutex-10 528886 442.7 ns/op
BenchmarkReadOnly/val=4096B/impl=syncmap 77556 3099 ns/op
BenchmarkReadOnly/val=4096B/impl=syncmap-4 280850 839.9 ns/op
BenchmarkReadOnly/val=4096B/impl=syncmap-10 527047 435.4 ns/op

GOMAXPROCS=1에서는 셋이 똑같다 (3095 / 3093 / 3099ns). 이름 뒤에 숫자가 없는 줄이 -cpu 1이다. RWMutex의 이득은 전적으로 병렬 실행에서 나온다. 코어 하나짜리 컨테이너에 배포한다면 RWMutex를 쓸 이유가 없다.

-cpu는 벤치마크뿐 아니라 테스트에도 쓸 수 있다. 동시성 테스트를 흔드는 용도는 8-7에서 다룬다.

:::note -bench 패턴에도 앵커가 없다 -bench 'val=4096B'는 아무것도 매치하지 않는다. 패턴이 /로 쪼개져 각 단계에 적용되는데, 첫 조각 val=4096B가 최상위 이름 ReadOnly와 맞지 않기 때문이다. -bench 'ReadOnly/val=4096B'처럼 최상위부터 쓴다. 8-1-run과 같은 규칙이다. :::

워커는 몇 개가 좋은가

7-8이 미뤄 둔 마지막 질문이다. 두 가지 분배 방식을 비교한다.

examples/08-testing/05-benchmarks/workers/workers.go
// Process는 items를 n개의 워커로 나눠 work를 적용하고,
// 입력과 같은 순서의 슬라이스를 돌려준다.
//
// 각 고루틴이 자기 인덱스 칸에만 쓰므로 잠금이 필요 없다 —
// 7-9에서 쓴 인덱스 슬롯 기법이다.
func Process(items []int, n int, work func(int) int) []int {
out := make([]int, len(items))
if len(items) == 0 {
return out
}
if n < 1 {
n = 1
}

jobs := make(chan int) // 인덱스를 실어 나른다
var wg sync.WaitGroup

for range n {
wg.Go(func() {
for i := range jobs {
out[i] = work(items[i])
}
})
}

for i := range items {
jobs <- i
}
close(jobs)
wg.Wait()

return out
}
examples/08-testing/05-benchmarks/workers/workers.go
// ProcessChunked는 items를 n개의 연속 구간으로 나눠 고루틴 하나씩 맡긴다.
// 채널이 없으므로 작업 한 건당 디스패치 비용이 0이다.
// 대신 구간마다 걸리는 시간이 다르면 늦은 구간을 기다리게 된다.
func ProcessChunked(items []int, n int, work func(int) int) []int {
out := make([]int, len(items))
if len(items) == 0 {
return out
}
if n < 1 {
n = 1
}
if n > len(items) {
n = len(items)
}

size := (len(items) + n - 1) / n
var wg sync.WaitGroup
for start := 0; start < len(items); start += size {
end := min(start+size, len(items))
wg.Go(func() {
for i := start; i < end; i++ {
out[i] = work(items[i])
}
})
}
wg.Wait()

return out
}

작업 한 건의 무게를 rounds로 조절한다. 1000건을 처리한다.

go test -run '^$' -bench . -benchtime 200ms ./05-benchmarks/workers
BenchmarkProcess/rounds=1/serial-10 119438 1981 ns/op
BenchmarkProcess/rounds=1/chan/workers=1-10 2762 87972 ns/op
BenchmarkProcess/rounds=1/chan/workers=2-10 2094 112679 ns/op
BenchmarkProcess/rounds=1/chan/workers=4-10 1328 178368 ns/op
BenchmarkProcess/rounds=1/chan/workers=8-10 1170 203916 ns/op
BenchmarkProcess/rounds=1/chan/workers=10-10 1150 212851 ns/op
BenchmarkProcess/rounds=1/chan/workers=20-10 987 240332 ns/op
BenchmarkProcess/rounds=1/chan/workers=100-10 728 336008 ns/op
BenchmarkProcess/rounds=1/chunk/workers=1-10 73674 3227 ns/op
BenchmarkProcess/rounds=1/chunk/workers=2-10 69259 3470 ns/op
BenchmarkProcess/rounds=1/chunk/workers=4-10 72910 3212 ns/op
BenchmarkProcess/rounds=1/chunk/workers=8-10 65103 3688 ns/op
BenchmarkProcess/rounds=1/chunk/workers=10-10 57507 3921 ns/op
BenchmarkProcess/rounds=1/chunk/workers=20-10 47910 5025 ns/op
BenchmarkProcess/rounds=1/chunk/workers=100-10 15936 15056 ns/op
BenchmarkProcess/rounds=2000/serial-10 79 2984580 ns/op
BenchmarkProcess/rounds=2000/chan/workers=1-10 60 3863997 ns/op
BenchmarkProcess/rounds=2000/chan/workers=2-10 100 2352911 ns/op
BenchmarkProcess/rounds=2000/chan/workers=4-10 153 1561887 ns/op
BenchmarkProcess/rounds=2000/chan/workers=8-10 193 1227774 ns/op
BenchmarkProcess/rounds=2000/chan/workers=10-10 204 1165395 ns/op
BenchmarkProcess/rounds=2000/chan/workers=20-10 229 1043295 ns/op
BenchmarkProcess/rounds=2000/chan/workers=100-10 261 914525 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=1-10 80 2989333 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=2-10 158 1512111 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=4-10 242 987519 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=8-10 412 582400 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=10-10 393 609087 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=20-10 477 522794 ns/op
BenchmarkProcess/rounds=2000/chunk/workers=100-10 500 479896 ns/op

읽을 것이 많다.

1. 작업이 가벼우면 동시성은 손해다. rounds=1에서 직렬은 1981ns인데, 채널 워커 풀은 워커 1개일 때조차 87972ns — 44배 느리다. 작업당 채널 송수신 한 쌍이 약 86ns인데 작업 자체는 2ns니까 당연하다. 워커를 늘릴수록 더 나빠진다(100개에서 336µs). 동시성이 공짜가 아니라는 것의 정량적 증거다.

2. 디스패치 방식이 워커 수보다 중요하다. rounds=2000에서 워커 8개일 때 채널 방식은 1227µs, 청크 방식은 582µs — 2.1배 차이다. 같은 워커 수인데 작업 한 건마다 채널을 지나느냐 아니냐가 갈랐다.

3. 코어 수가 최적점이 아니다. 코어가 10개인데 청크 방식의 최적은 20개나 100개 쪽이다(522µs, 479µs). 두 가지 때문이다. Apple 실리콘은 성능 코어와 효율 코어가 섞여 있어 같은 크기의 청크가 같은 시간에 안 끝나고, 청크를 잘게 쪼갤수록 런타임 스케줄러가 부하를 고르게 나눌 수 있다.

결론은 7-8이 말한 그대로다. "CPU 바운드면 GOMAXPROCS 근처"는 출발점이지 정답이 아니다. 진짜 답은 당신의 작업 크기와 당신의 기계에서 재는 것이다.

benchstat으로 비교하기

숫자 하나는 신뢰할 수 없다. -count로 여러 번 재고 benchstat으로 통계 처리한다.

go test -run '^$' -bench . -count 6 -benchtime 200ms ./05-benchmarks/cache > cache.txt
go run golang.org/x/perf/cmd/benchstat@latest -col /impl cache.txt
goos: darwin
goarch: arm64
pkg: example.com/testing-and-quality/05-benchmarks/cache
cpu: Apple M4
│ mutex │ rwmutex │ syncmap │
│ sec/op │ sec/op vs base │ sec/op vs base │
ReadOnly/val=8B-10 97.240n ± 3% 86.950n ± 12% -10.58% (p=0.002 n=6) 2.562n ± 11% -97.37% (p=0.002 n=6)
ReadOnly/val=4096B-10 3328.0n ± 1% 447.1n ± 3% -86.57% (p=0.002 n=6) 437.3n ± 6% -86.86% (p=0.002 n=6)
geomean 568.9n 197.2n -65.34% 33.47n -94.12%

읽는 법이다.

  • ± 뒤가 변동 폭이다. 86.950n ± 12%는 측정이 꽤 흔들렸다는 뜻이다. 이 값이 크면 결론을 믿기 전에 -count를 늘리거나 기계를 조용하게 만든다.
  • p=0.002가 통계적 유의성이다. 기본 임계는 0.05이고, 그보다 크면 차이를 ~로 표시한다.
  • geomean은 전체 벤치마크의 기하 평균이다.

-col /impl이 하는 일은 벤치마크 이름의 impl=xxx 부분을 열로 뽑는 것이다. 그래서 예제의 서브 벤치마크 이름이 impl=mutex 형태다 — benchstat이 이해하는 키=값 규칙을 따르면 표가 저절로 정리된다.

두 파일 비교와 "차이 없음"

코드를 고치기 전후를 비교할 때는 결과를 두 파일에 저장하고 나란히 넘긴다. 여기서는 아무것도 바꾸지 않고 같은 벤치마크를 두 번 돌려 봤다.

go test -run '^$' -bench 'ReadOnly/val=4096B' -count 6 -benchtime 200ms ./05-benchmarks/cache > old.txt
go test -run '^$' -bench 'ReadOnly/val=4096B' -count 6 -benchtime 200ms ./05-benchmarks/cache > new.txt
go run golang.org/x/perf/cmd/benchstat@latest old.txt new.txt
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
ReadOnly/val=4096B/impl=mutex-10 3.324µ ± 1% 3.332µ ± 0% ~ (p=0.459 n=6)
ReadOnly/val=4096B/impl=rwmutex-10 445.0n ± 4% 448.4n ± 1% ~ (p=0.513 n=6)
ReadOnly/val=4096B/impl=syncmap-10 438.9n ± 2% 439.5n ± 2% ~ (p=0.485 n=6)
geomean 865.8n 869.1n +0.38%

~가 "차이가 노이즈와 구별되지 않는다"는 표시다. geomean은 +0.38%지만 개별 항목은 전부 ~다. 코드를 안 바꿨으니 정확히 그래야 한다.

이 실행이 알려 주는 것은 당신 기계의 노이즈 바닥이다. 최적화를 했는데 개선폭이 이 바닥보다 작다면 잰 것이 아니라 본 것뿐이다.

벤치마크를 신뢰할 수 있게 만들기

  • -count를 6 이상 준다. 한 번 재고 결론 내지 않는다.
  • -race는 끈다. 2~20배 느려지므로 측정이 무의미하다 (7-6).
  • 다른 프로그램을 끈다. 브라우저, 빌드, 백업이 돌면 노이즈가 커진다.
  • b.ResetTimer()로 준비 비용을 뺀다. b.Loop은 첫 호출에서 자동으로 하지만, 루프 밖에서 무거운 준비를 하고 RunParallel을 쓰는 경우에는 직접 부른다.
  • CI에서 벤치마크로 성능 게이트를 걸지 않는다. 공유 러너는 노이즈가 너무 크다. 벤치마크는 전용 기계에서 추세로 본다.

흔히 하는 실수

1. 벤치마크 결과가 사라진 줄 모른다

0.25 ns/op가 나오면 의심한다. 빈 루프와 같은 값이면 코드가 지워진 것이다. b.Loop을 쓰거나 결과를 누적한다.

2. b.N을 직접 나눈다

for i := 0; i < b.N; i++ { /* ... */ }
fmt.Println(elapsed / time.Duration(b.N)) // 프레임워크가 이미 해 준다

ns/op는 자동 계산된다. 별도 지표가 필요하면 b.ReportMetric을 쓴다.

3. 준비 비용이 측정에 들어간다

func BenchmarkBad(b *testing.B) {
for b.Loop() {
data := buildHugeInput() // 이것도 같이 측정된다
Process(data)
}
}

준비는 루프 밖으로 뺀다. 루프 안에서 꼭 필요하면 b.StopTimer()/b.StartTimer()로 감싼다(회차마다 부르면 그 자체가 비싸다는 점은 감안한다).

4. 벤치마크만 있고 테스트가 없다

두 구현의 속도를 비교하면서 두 구현이 같은 답을 내는지는 확인하지 않는다. 정확성 테스트가 먼저다.

5. 한 번 재고 "N% 빨라졌다"고 말한다

benchstat으로 p 값을 보기 전까지는 노이즈일 수 있다. 위의 두 파일 비교가 그 기준선을 보여 준다.

6. 마이크로벤치마크 결과를 시스템 성능으로 착각한다

함수 하나가 30% 빨라져도 그 함수가 전체 시간의 2%면 시스템은 0.6% 빨라진다. 어디가 병목인지는 프로파일링이 알려 준다 — 파트 12의 pprof가 그 자리다.

정리

  • func BenchmarkXxx(b *testing.B) + -bench 플래그. -run '^$'로 테스트를 제외하는 것이 관용구다.
  • for b.Loop()이 기본 형태다. 타이머 자동 관리, 함수 한 번만 실행, 루프 본문 값 유지. for i := 0; i < b.N; i++는 옛 코드를 읽기 위한 형태다.
  • 결과를 안 쓰면 컴파일러가 지운다. 빈 루프와 같은 값이 나오면 의심한다. sink에 마지막 값만 대입하는 처방은 듣지 않을 수 있다.
  • b.Loop 자체에 회차당 오버헤드가 있다(이 기계에서 약 1.76ns). 그보다 싼 연산은 배치로 묶고 b.ReportMetric으로 나눠 읽는다.
  • -benchmem / b.ReportAllocs()B/op, allocs/op를 본다.
  • b.RunParallel + -cpu 1,4,10으로 병렬성의 영향을 본다.
  • RWMutex는 임계 구역이 길고 병렬성이 있을 때만 이긴다. 짧으면 11%, 길면 7.5배, GOMAXPROCS=1에서는 0%였다.
  • sync.Pool은 동시성이 있을 때 값한다. 직렬 6.6% vs 병렬 45%. Put 전에 Reset을 잊지 않는다.
  • 워커 수보다 디스패치 방식이 크게 좌우한다. 작업당 채널 vs 청크 분할이 2.1배 차이였고, 작업이 가벼우면 직렬이 44배 빨랐다.
  • benchstat으로 -count 6 이상을 통계 처리한다. ~는 노이즈, p < 0.05가 유의미의 기본 임계다.

연습문제

  1. cache 벤치마크에 읽기 90% / 쓰기 10% 혼합 부하를 추가해 보자 (pb.Next() 루프 안에서 10번에 한 번 Set). 쓰기가 섞이면 RWMutexsync.Map의 순위가 바뀌는가? 쓰기 비율을 얼마까지 올리면 그냥 Mutex가 이기기 시작하는가?

  2. bufpoolPooled에서 buf.Reset()을 지우고 TestFreshAndPooledAgree를 돌려 보자. 실패하는가? 그다음 그 테스트에서 Pooled를 한 번만 부르도록 고치면 어떻게 되는가? 이 실험이 "재사용 경로를 밟는 테스트"의 필요성을 어떻게 보여 주는가?

  3. workersProcessChunked인터리브 분할(워커 k가 인덱스 k, k+n, k+2n…을 맡음)로 바꿔 보자. 청크 분할과 비교해 최적 워커 수가 달라지는가? 캐시 지역성 관점에서 어느 쪽이 유리할 것 같은지 먼저 예측하고, 그다음 benchstat으로 확인해 보자.