벤치마크
이 챕터에서 다루는 것
파트 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이 나은 이유가 셋이다.
- 타이머를 자동으로 관리한다. 첫 호출에서 타이머를 리셋하므로 루프 앞의
준비 코드가 측정에서 빠지고,
false를 반환할 때 멈추므로 뒤의 정리 코드도 빠진다.b.ResetTimer()/b.StopTimer()를 손으로 부를 일이 크게 줄어든다. - 벤치마크 함수를 측정당 한 번만 실행한다.
b.N방식은 반복 횟수를 찾느라 함수 전체를 여러 번 다시 부른다. 준비 비용이 큰 벤치마크에서 차이가 크다. - 루프 본문의 값을 살려 둔다. 다음 절의 주제다.
b.Loop이 false를 반환한 뒤에는 b.N에 총 반복 횟수가 들어 있다.
둘을 섞어 쓰지는 않는다 — b.Loop을 쓰거나 b.N 루프를 쓰거나 하나다.
사라지는 벤치마크
컴파일러는 결과를 쓰지 않는 계산을 지울 수 있다. 벤치마크에서 이것은 치명적이다. "0.25ns/op"라는 눈부신 결과가 사실은 아무것도 실행하지 않은 것일 수 있다.
// Package vanish는 컴파일러 최적화로 사라지는 벤치마크를 보여 준다.
package vanish
// Square는 인라인될 만큼 작다. 결과를 안 쓰면 호출 자체가 지워질 수 있다.
func Square(n int) int { return n * n }
여섯 가지 변형을 나란히 놓고 재 본다.
// 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
읽는 법부터 본다. 이름 뒤의 -10은 GOMAXPROCS 값이다. 그다음이 반복 횟수,
그다음이 회차당 시간이다.
이제 결론이다.
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)보다 작은 연산은 루프 방식으로 잴 수
없다. 한 회차에 여러 번을 묶고 나눠 읽는다.
// 한 회차가 나노초 미만이면 루프 오버헤드가 측정값을 덮어쓴다.
// 그럴 때는 한 회차에 여러 번을 묶고 결과를 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의
빚)를 갚는다.
// 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()을 부르지 않으면 다음 사용자가 남의 데이터
뒤에 이어 쓴다. 조용히 잘못된 결과가 나오는 종류의 버그다.
예제의 TestFreshAndPooledAgree가 Pooled를 연속으로 세 번 부르는 이유가
이것이다. 한 번만 부르면 풀이 비어 있어 항상 새 버퍼가 나오고, 재사용 경로를
밟지 않아 버그가 드러나지 않는다.
:::
병렬 벤치마크와 -cpu
b.RunParallel은 GOMAXPROCS개의 고루틴을 띄우고 총 반복 횟수를 나눠 준다.
경합이 있는 코드는 이렇게 재야 의미가 있다.
여기서 RWMutex가 정말 빠른가(7-5의 빚)를
갚는다.
// sum은 임계 구역 안에서 하는 실제 일이다.
// 인자의 길이가 곧 임계 구역의 길이가 된다 — 이것이 이 실험의 손잡이다.
func sum(b []byte) uint64 {
var s uint64
for _, c := range b {
s = s*31 + uint64(c)
}
return s
}
같은 인터페이스를 Mutex, RWMutex, sync.Map 셋으로 구현하고, 값의 크기를
바꿔 가며 읽기 전용 부하를 건다. 값이 클수록 임계 구역이 길어진다.
// 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%뿐이다. RLock은 Lock보다
그 자체로 비싸고, 임계 구역이 짧으면 그 비용이 병렬성 이득을 거의 다 먹는다.
임계 구역이 길면(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이 미뤄 둔 마지막 질문이다. 두 가지 분배 방식을 비교한다.
// 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
}
// 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가 유의미의 기본 임계다.
연습문제
-
cache벤치마크에 읽기 90% / 쓰기 10% 혼합 부하를 추가해 보자 (pb.Next()루프 안에서 10번에 한 번Set). 쓰기가 섞이면RWMutex와sync.Map의 순위가 바뀌는가? 쓰기 비율을 얼마까지 올리면 그냥Mutex가 이기기 시작하는가? -
bufpool의Pooled에서buf.Reset()을 지우고TestFreshAndPooledAgree를 돌려 보자. 실패하는가? 그다음 그 테스트에서Pooled를 한 번만 부르도록 고치면 어떻게 되는가? 이 실험이 "재사용 경로를 밟는 테스트"의 필요성을 어떻게 보여 주는가? -
workers의ProcessChunked를 인터리브 분할(워커 k가 인덱스 k, k+n, k+2n…을 맡음)로 바꿔 보자. 청크 분할과 비교해 최적 워커 수가 달라지는가? 캐시 지역성 관점에서 어느 쪽이 유리할 것 같은지 먼저 예측하고, 그다음benchstat으로 확인해 보자.