Skip to content

Commit ae97717

Browse files
committed
runtime,runtime/metrics: use explicit histogram boundaries
This change modifies the semantics of runtime/metrics.Float64Histogram.Buckets to remove implicit buckets to that extend to positive and negative infinity and instead defines all bucket boundaries as explicitly listed. Bucket boundaries remain the same as before except /gc/heap/allocs-by-size:objects and /gc/heap/frees-by-size:objects no longer have a bucket that extends to negative infinity. This change simplifies the Float64Histogram API, making it both easier to understand and easier to use. Also, add a test for allocs-by-size and frees-by-size that checks them against MemStats. Fixes #43443. Change-Id: I5620f15bd084562dadf288f733c4a8cace21910c Reviewed-on: https://go-review.googlesource.com/c/go/+/281238 Run-TryBot: Michael Knyszek <mknyszek@google.com> TryBot-Result: Go Bot <gobot@golang.org> Reviewed-by: Austin Clements <austin@google.com> Reviewed-by: Michael Pratt <mpratt@google.com> Trust: Michael Knyszek <mknyszek@google.com>
1 parent a9ccd2d commit ae97717

File tree

4 files changed

+99
-30
lines changed

4 files changed

+99
-30
lines changed

src/runtime/histogram.go

+28-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package runtime
77
import (
88
"runtime/internal/atomic"
99
"runtime/internal/sys"
10+
"unsafe"
1011
)
1112

1213
const (
@@ -69,7 +70,13 @@ const (
6970
// for concurrent use. It is also safe to read all the values
7071
// atomically.
7172
type timeHistogram struct {
72-
counts [timeHistNumSuperBuckets * timeHistNumSubBuckets]uint64
73+
counts [timeHistNumSuperBuckets * timeHistNumSubBuckets]uint64
74+
75+
// underflow counts all the times we got a negative duration
76+
// sample. Because of how time works on some platforms, it's
77+
// possible to measure negative durations. We could ignore them,
78+
// but we record them anyway because it's better to have some
79+
// signal that it's happening than just missing samples.
7380
underflow uint64
7481
}
7582

@@ -107,14 +114,30 @@ func (h *timeHistogram) record(duration int64) {
107114
atomic.Xadd64(&h.counts[superBucket*timeHistNumSubBuckets+subBucket], 1)
108115
}
109116

117+
const (
118+
fInf = 0x7FF0000000000000
119+
fNegInf = 0xFFF0000000000000
120+
)
121+
122+
func float64Inf() float64 {
123+
inf := uint64(fInf)
124+
return *(*float64)(unsafe.Pointer(&inf))
125+
}
126+
127+
func float64NegInf() float64 {
128+
inf := uint64(fNegInf)
129+
return *(*float64)(unsafe.Pointer(&inf))
130+
}
131+
110132
// timeHistogramMetricsBuckets generates a slice of boundaries for
111133
// the timeHistogram. These boundaries are represented in seconds,
112134
// not nanoseconds like the timeHistogram represents durations.
113135
func timeHistogramMetricsBuckets() []float64 {
114-
b := make([]float64, timeHistTotalBuckets-1)
136+
b := make([]float64, timeHistTotalBuckets+1)
137+
b[0] = float64NegInf()
115138
for i := 0; i < timeHistNumSuperBuckets; i++ {
116139
superBucketMin := uint64(0)
117-
// The (inclusive) minimum for the first bucket is 0.
140+
// The (inclusive) minimum for the first non-negative bucket is 0.
118141
if i > 0 {
119142
// The minimum for the second bucket will be
120143
// 1 << timeHistSubBucketBits, indicating that all
@@ -141,8 +164,9 @@ func timeHistogramMetricsBuckets() []float64 {
141164

142165
// Convert the subBucketMin which is in nanoseconds to a float64 seconds value.
143166
// These values will all be exactly representable by a float64.
144-
b[i*timeHistNumSubBuckets+j] = float64(subBucketMin) / 1e9
167+
b[i*timeHistNumSubBuckets+j+1] = float64(subBucketMin) / 1e9
145168
}
146169
}
170+
b[len(b)-1] = float64Inf()
147171
return b
148172
}

src/runtime/metrics.go

+23-9
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ func initMetrics() {
4141
if metricsInit {
4242
return
4343
}
44-
sizeClassBuckets = make([]float64, _NumSizeClasses)
45-
for i := range sizeClassBuckets {
44+
45+
sizeClassBuckets = make([]float64, _NumSizeClasses, _NumSizeClasses+1)
46+
// Skip size class 0 which is a stand-in for large objects, but large
47+
// objects are tracked separately (and they actually get placed in
48+
// the last bucket, not the first).
49+
sizeClassBuckets[0] = 1 // The smallest allocation is 1 byte in size.
50+
for i := 1; i < _NumSizeClasses; i++ {
4651
// Size classes have an inclusive upper-bound
4752
// and exclusive lower bound (e.g. 48-byte size class is
4853
// (32, 48]) whereas we want and inclusive lower-bound
@@ -56,6 +61,8 @@ func initMetrics() {
5661
// boundaries.
5762
sizeClassBuckets[i] = float64(class_to_size[i] + 1)
5863
}
64+
sizeClassBuckets = append(sizeClassBuckets, float64Inf())
65+
5966
timeHistBuckets = timeHistogramMetricsBuckets()
6067
metrics = map[string]metricData{
6168
"/gc/cycles/automatic:gc-cycles": {
@@ -84,8 +91,10 @@ func initMetrics() {
8491
compute: func(in *statAggregate, out *metricValue) {
8592
hist := out.float64HistOrInit(sizeClassBuckets)
8693
hist.counts[len(hist.counts)-1] = uint64(in.heapStats.largeAllocCount)
87-
for i := range hist.buckets {
88-
hist.counts[i] = uint64(in.heapStats.smallAllocCount[i])
94+
// Cut off the first index which is ostensibly for size class 0,
95+
// but large objects are tracked separately so it's actually unused.
96+
for i, count := range in.heapStats.smallAllocCount[1:] {
97+
hist.counts[i] = uint64(count)
8998
}
9099
},
91100
},
@@ -94,8 +103,10 @@ func initMetrics() {
94103
compute: func(in *statAggregate, out *metricValue) {
95104
hist := out.float64HistOrInit(sizeClassBuckets)
96105
hist.counts[len(hist.counts)-1] = uint64(in.heapStats.largeFreeCount)
97-
for i := range hist.buckets {
98-
hist.counts[i] = uint64(in.heapStats.smallFreeCount[i])
106+
// Cut off the first index which is ostensibly for size class 0,
107+
// but large objects are tracked separately so it's actually unused.
108+
for i, count := range in.heapStats.smallFreeCount[1:] {
109+
hist.counts[i] = uint64(count)
99110
}
100111
},
101112
},
@@ -116,8 +127,11 @@ func initMetrics() {
116127
"/gc/pauses:seconds": {
117128
compute: func(_ *statAggregate, out *metricValue) {
118129
hist := out.float64HistOrInit(timeHistBuckets)
130+
// The bottom-most bucket, containing negative values, is tracked
131+
// as a separately as underflow, so fill that in manually and then
132+
// iterate over the rest.
119133
hist.counts[0] = atomic.Load64(&memstats.gcPauseDist.underflow)
120-
for i := range hist.buckets {
134+
for i := range memstats.gcPauseDist.counts {
121135
hist.counts[i+1] = atomic.Load64(&memstats.gcPauseDist.counts[i])
122136
}
123137
},
@@ -437,8 +451,8 @@ func (v *metricValue) float64HistOrInit(buckets []float64) *metricFloat64Histogr
437451
v.pointer = unsafe.Pointer(hist)
438452
}
439453
hist.buckets = buckets
440-
if len(hist.counts) != len(hist.buckets)+1 {
441-
hist.counts = make([]uint64, len(buckets)+1)
454+
if len(hist.counts) != len(hist.buckets)-1 {
455+
hist.counts = make([]uint64, len(buckets)-1)
442456
}
443457
return hist
444458
}

src/runtime/metrics/histogram.go

+16-13
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,28 @@ package metrics
66

77
// Float64Histogram represents a distribution of float64 values.
88
type Float64Histogram struct {
9-
// Counts contains the weights for each histogram bucket. The length of
10-
// Counts is equal to the length of Buckets (in the metric description)
11-
// plus one to account for the implicit minimum bucket.
9+
// Counts contains the weights for each histogram bucket.
1210
//
13-
// Given N buckets, the following is the mathematical relationship between
14-
// Counts and Buckets.
15-
// count[0] is the weight of the range (-inf, bucket[0])
16-
// count[n] is the weight of the range [bucket[n], bucket[n+1]), for 0 < n < N-1
17-
// count[N-1] is the weight of the range [bucket[N-1], inf)
11+
// Given N buckets, Count[n] is the weight of the range
12+
// [bucket[n], bucket[n+1]), for 0 <= n < N.
1813
Counts []uint64
1914

20-
// Buckets contains the boundaries between histogram buckets, in increasing order.
15+
// Buckets contains the boundaries of the histogram buckets, in increasing order.
2116
//
22-
// Because this slice contains boundaries, there are len(Buckets)+1 counts:
23-
// a count for all values less than the first boundary, a count covering each
24-
// [slice[i], slice[i+1]) interval, and a count for all values greater than or
25-
// equal to the last boundary.
17+
// Buckets[0] is the inclusive lower bound of the minimum bucket while
18+
// Buckets[len(Buckets)-1] is the exclusive upper bound of the maximum bucket.
19+
// Hence, there are len(Buckets)-1 counts. Furthermore, len(Buckets) != 1, always,
20+
// since at least two boundaries are required to describe one bucket (and 0
21+
// boundaries are used to describe 0 buckets).
22+
//
23+
// Buckets[0] is permitted to have value -Inf and Buckets[len(Buckets)-1] is
24+
// permitted to have value Inf.
2625
//
2726
// For a given metric name, the value of Buckets is guaranteed not to change
2827
// between calls until program exit.
28+
//
29+
// This slice value is permitted to alias with other Float64Histograms' Buckets
30+
// fields, so the values within should only ever be read. If they need to be
31+
// modified, the user must make a copy.
2932
Buckets []float64
3033
}

src/runtime/metrics_test.go

+32-4
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,34 @@ func TestReadMetrics(t *testing.T) {
7070
checkUint64(t, name, samples[i].Value.Uint64(), mstats.BuckHashSys)
7171
case "/memory/classes/total:bytes":
7272
checkUint64(t, name, samples[i].Value.Uint64(), mstats.Sys)
73+
case "/gc/heap/allocs-by-size:objects":
74+
hist := samples[i].Value.Float64Histogram()
75+
// Skip size class 0 in BySize, because it's always empty and not represented
76+
// in the histogram.
77+
for i, sc := range mstats.BySize[1:] {
78+
if b, s := hist.Buckets[i+1], float64(sc.Size+1); b != s {
79+
t.Errorf("bucket does not match size class: got %f, want %f", b, s)
80+
// The rest of the checks aren't expected to work anyway.
81+
continue
82+
}
83+
if c, m := hist.Counts[i], sc.Mallocs; c != m {
84+
t.Errorf("histogram counts do not much BySize for class %d: got %d, want %d", i, c, m)
85+
}
86+
}
87+
case "/gc/heap/frees-by-size:objects":
88+
hist := samples[i].Value.Float64Histogram()
89+
// Skip size class 0 in BySize, because it's always empty and not represented
90+
// in the histogram.
91+
for i, sc := range mstats.BySize[1:] {
92+
if b, s := hist.Buckets[i+1], float64(sc.Size+1); b != s {
93+
t.Errorf("bucket does not match size class: got %f, want %f", b, s)
94+
// The rest of the checks aren't expected to work anyway.
95+
continue
96+
}
97+
if c, f := hist.Counts[i], sc.Frees; c != f {
98+
t.Errorf("histogram counts do not much BySize for class %d: got %d, want %d", i, c, f)
99+
}
100+
}
73101
case "/gc/heap/objects:objects":
74102
checkUint64(t, name, samples[i].Value.Uint64(), mstats.HeapObjects)
75103
case "/gc/heap/goal:bytes":
@@ -154,11 +182,11 @@ func TestReadMetricsConsistency(t *testing.T) {
154182
if totalVirtual.got != totalVirtual.want {
155183
t.Errorf(`"/memory/classes/total:bytes" does not match sum of /memory/classes/**: got %d, want %d`, totalVirtual.got, totalVirtual.want)
156184
}
157-
if objects.alloc.Counts[0] > 0 {
158-
t.Error("found counts for objects of non-positive size in allocs-by-size")
185+
if b, c := len(objects.alloc.Buckets), len(objects.alloc.Counts); b != c+1 {
186+
t.Errorf("allocs-by-size has wrong bucket or counts length: %d buckets, %d counts", b, c)
159187
}
160-
if objects.free.Counts[0] > 0 {
161-
t.Error("found counts for objects of non-positive size in frees-by-size")
188+
if b, c := len(objects.free.Buckets), len(objects.free.Counts); b != c+1 {
189+
t.Errorf("frees-by-size has wrong bucket or counts length: %d buckets, %d counts", b, c)
162190
}
163191
if len(objects.alloc.Buckets) != len(objects.free.Buckets) {
164192
t.Error("allocs-by-size and frees-by-size buckets don't match in length")

0 commit comments

Comments
 (0)