Skip to content

Commit 285529b

Browse files
authored
Reduce metric emission and construction overhead (#132)
Optimizes the spectator-go v2 hot paths that regressed relative to the pre-spectatord aggregating registry (v0.2.0), focused on the dominant `registry.Counter(name, tags).Increment()`-per-call pattern. Zero-allocation emission via typed writer methods: - Add WriteLine/WriteInt/WriteUint/WriteFloat to the writer.Writer interface and all implementations. Buffered writers append the line prefix and value directly into their backing storage (LowLatencyBuffer into its shard chunks, LineBuffer into its builder), and numeric values are formatted with strconv.Append* into a stack buffer, so emission no longer allocates the intermediate "prefix + value" string. Non-buffered writers concatenate as a fallback, no worse than the previous Write path. LowLatencyBuffer shards on the prefix alone (the meter id lies between the first and last ':', both in the prefix), equivalent to full-line sharding. Cheaper metric-id sanitization: - writeSanitized now byte-scans for validity and, when the input is already clean (the common case), writes it in one shot instead of a per-rune UTF-8 decode + re-encode. Byte-identical output; only clean-vs-dirty routing added. Direct meter construction from (name, tags): - Registry (name, tags) methods build the line prefix directly via buildLinePrefix, without allocating an *Id or copying the tags map. The meter stores only its line prefix; MeterId() reconstructs an Id on demand for the rarely-used introspection path. extraCommonTags are merged into the prefix (common tags win on key collision), so meters built this way carry the same infrastructure tags as the WithId path. Measured, cached meter -> LowLatencyBuffer: Counter.Increment: 41 ns, 96 B, 1 alloc -> 24 ns, 0 B, 0 allocs Counter.Add: 59 ns, 119 B, 2 allocs -> 33 ns, 0 B, 0 allocs Gauge.Set/Timer.Record/DistSummary.Record likewise reach 0 allocs. Measured, construct-per-call (registry.Counter(name,tags).Increment()): v2 before: 544 ns, 5 allocs v2 now: 175 ns, 2 allocs (via registry) v0.2.0: 384 ns, 6 allocs so v2 is now faster than the version the pattern regressed from, with no registry meter cache. Notes: - Adds methods to the exported writer.Writer interface: a breaking change for any external Writer implementation (there is no supported way to inject a custom Writer, so in-tree impact is nil). Worth a release-note callout. - MeterId() on meters built via the (name, tags) path returns an Id whose name/tags are the sanitized, prefix-reconstructed forms (lossy for unclean input) rather than the caller's originals. Includes end-to-end emit benchmarks and correctness tests: WriteLine/typed output is byte-identical to Write(prefix+value); direct-vs-WithId identity matches; and the (name, tags) path preserves common tags (with merge/override).
1 parent dad0974 commit 285529b

28 files changed

Lines changed: 930 additions & 111 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
.idea/
22
CLAUDE.md
3+
4+
# Go test binaries and profiles
5+
*.test
6+
*.prof

spectator/meter/age_gauge.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package meter
22

33
import (
4-
"strconv"
5-
64
"github.com/Netflix/spectator-go/v2/spectator/writer"
75
)
86

@@ -24,19 +22,27 @@ func NewAgeGauge(id *Id, writer writer.Writer) *AgeGauge {
2422
return &AgeGauge{id, writer, "A:" + id.spectatordId + ":"}
2523
}
2624

27-
// MeterId returns the meter identifier.
25+
// NewAgeGaugeDirect generates a new age gauge directly from a name and tags,
26+
// without allocating an *Id or copying the tags map. commonTags carries the
27+
// registry's extraCommonTags.
28+
func NewAgeGaugeDirect(name string, tags, commonTags map[string]string, writer writer.Writer) *AgeGauge {
29+
return &AgeGauge{nil, writer, buildLinePrefix("A", name, tags, commonTags)}
30+
}
31+
32+
// MeterId returns the meter identifier, reconstructing it from the line prefix
33+
// if the gauge was created directly from a name and tags.
2834
func (g *AgeGauge) MeterId() *Id {
29-
return g.id
35+
return resolveMeterId(g.id, g.linePrefix)
3036
}
3137

3238
// Set records the current time in seconds since the epoch.
3339
func (g *AgeGauge) Set(seconds int64) {
3440
if seconds >= 0 {
35-
g.writer.Write(g.linePrefix + strconv.FormatInt(seconds, 10))
41+
g.writer.WriteInt(g.linePrefix, seconds)
3642
}
3743
}
3844

3945
// Now records the current time in epoch seconds, using a spectatord feature.
4046
func (g *AgeGauge) Now() {
41-
g.writer.Write(g.linePrefix + "0")
47+
g.writer.WriteLine(g.linePrefix, "0")
4248
}

spectator/meter/counter.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package meter
22

33
import (
4-
"strconv"
5-
64
"github.com/Netflix/spectator-go/v2/spectator/writer"
75
)
86

@@ -24,26 +22,34 @@ func NewCounter(id *Id, writer writer.Writer) *Counter {
2422
return &Counter{id, writer, "c:" + id.spectatordId + ":"}
2523
}
2624

27-
// MeterId returns the meter identifier.
25+
// NewCounterDirect generates a new counter directly from a name and tags,
26+
// without allocating an *Id or copying the tags map. commonTags carries the
27+
// registry's extraCommonTags. MeterId reconstructs an Id on demand.
28+
func NewCounterDirect(name string, tags, commonTags map[string]string, writer writer.Writer) *Counter {
29+
return &Counter{nil, writer, buildLinePrefix("c", name, tags, commonTags)}
30+
}
31+
32+
// MeterId returns the meter identifier, reconstructing it from the line prefix
33+
// if the counter was created directly from a name and tags.
2834
func (c *Counter) MeterId() *Id {
29-
return c.id
35+
return resolveMeterId(c.id, c.linePrefix)
3036
}
3137

3238
// Increment increments the counter.
3339
func (c *Counter) Increment() {
34-
c.writer.Write(c.linePrefix + "1")
40+
c.writer.WriteLine(c.linePrefix, "1")
3541
}
3642

3743
// Add adds an int64 delta to the current measurement.
3844
func (c *Counter) Add(delta int64) {
3945
if delta > 0 {
40-
c.writer.Write(c.linePrefix + strconv.FormatInt(delta, 10))
46+
c.writer.WriteInt(c.linePrefix, delta)
4147
}
4248
}
4349

4450
// AddFloat adds a float64 delta to the current measurement.
4551
func (c *Counter) AddFloat(delta float64) {
4652
if delta > 0.0 {
47-
c.writer.Write(c.linePrefix + strconv.FormatFloat(delta, 'f', 6, 64))
53+
c.writer.WriteFloat(c.linePrefix, delta)
4854
}
4955
}

spectator/meter/dist_summary.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package meter
22

33
import (
4-
"strconv"
5-
64
"github.com/Netflix/spectator-go/v2/spectator/writer"
75
)
86

@@ -25,14 +23,22 @@ func NewDistributionSummary(id *Id, writer writer.Writer) *DistributionSummary {
2523
return &DistributionSummary{id, writer, "d:" + id.spectatordId + ":"}
2624
}
2725

28-
// MeterId returns the meter identifier.
26+
// NewDistributionSummaryDirect generates a new distribution summary directly from
27+
// a name and tags, without allocating an *Id or copying the tags map. commonTags
28+
// carries the registry's extraCommonTags.
29+
func NewDistributionSummaryDirect(name string, tags, commonTags map[string]string, writer writer.Writer) *DistributionSummary {
30+
return &DistributionSummary{nil, writer, buildLinePrefix("d", name, tags, commonTags)}
31+
}
32+
33+
// MeterId returns the meter identifier, reconstructing it from the line prefix
34+
// if the summary was created directly from a name and tags.
2935
func (d *DistributionSummary) MeterId() *Id {
30-
return d.id
36+
return resolveMeterId(d.id, d.linePrefix)
3137
}
3238

3339
// Record records a value to track within the distribution.
3440
func (d *DistributionSummary) Record(amount int64) {
3541
if amount >= 0 {
36-
d.writer.Write(d.linePrefix + strconv.FormatInt(amount, 10))
42+
d.writer.WriteInt(d.linePrefix, amount)
3743
}
3844
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package meter
2+
3+
// End-to-end emit benchmarks: meter -> real LowLatencyBuffer (backed by a
4+
// NoopWriter so we exercise the buffer append path without socket I/O).
5+
// Uses only the public Counter API, so the loop body is identical before and
6+
// after the WriteLine interface extension.
7+
8+
import (
9+
"runtime"
10+
"testing"
11+
"time"
12+
13+
"github.com/Netflix/spectator-go/v2/spectator/writer"
14+
)
15+
16+
type emitQuietLogger struct{}
17+
18+
func (emitQuietLogger) Debugf(string, ...interface{}) {}
19+
func (emitQuietLogger) Infof(string, ...interface{}) {}
20+
func (emitQuietLogger) Errorf(string, ...interface{}) {}
21+
22+
// bufWriter adapts a LowLatencyBuffer to the Writer interface (its Close has no
23+
// error return).
24+
type bufWriter struct{ b *writer.LowLatencyBuffer }
25+
26+
func (w *bufWriter) Write(line string) { w.b.Write(line) }
27+
func (w *bufWriter) WriteLine(prefix, value string) { w.b.WriteLine(prefix, value) }
28+
func (w *bufWriter) WriteInt(prefix string, v int64) { w.b.WriteInt(prefix, v) }
29+
func (w *bufWriter) WriteUint(prefix string, v uint64) { w.b.WriteUint(prefix, v) }
30+
func (w *bufWriter) WriteFloat(prefix string, v float64) { w.b.WriteFloat(prefix, v) }
31+
func (w *bufWriter) WriteBytes(line []byte) { w.b.Write(string(line)) }
32+
func (w *bufWriter) WriteString(line string) { w.b.Write(line) }
33+
func (w *bufWriter) Close() error { w.b.Close(); return nil }
34+
35+
func newBenchBuffer(b *testing.B) *bufWriter {
36+
b.Helper()
37+
shards := runtime.NumCPU()
38+
bufferSize := 2 * 16 * 60 * 1024 * shards
39+
buf := writer.NewLowLatencyBuffer(&writer.NoopWriter{}, emitQuietLogger{}, bufferSize, time.Millisecond)
40+
w := &bufWriter{buf}
41+
b.Cleanup(func() { w.Close() })
42+
return w
43+
}
44+
45+
var emitBenchTags = map[string]string{
46+
"app": "gandalfAgent",
47+
"result": "allowed",
48+
"policy": "explicit",
49+
}
50+
51+
// Cached counter (constructed once), Increment in the loop — isolates the emit path.
52+
func BenchmarkEmitBuffer_Counter_Increment(b *testing.B) {
53+
c := NewCounter(NewId("gandalfAgent.authzEngine.appAuthzResult", emitBenchTags), newBenchBuffer(b))
54+
b.ReportAllocs()
55+
b.ResetTimer()
56+
for i := 0; i < b.N; i++ {
57+
c.Increment()
58+
}
59+
}
60+
61+
// Cached counter, Add(delta) in the loop — value requires formatting.
62+
func BenchmarkEmitBuffer_Counter_Add(b *testing.B) {
63+
c := NewCounter(NewId("gandalfAgent.authzEngine.appAuthzResult", emitBenchTags), newBenchBuffer(b))
64+
b.ReportAllocs()
65+
b.ResetTimer()
66+
for i := 0; i < b.N; i++ {
67+
c.Add(int64(i) + 1)
68+
}
69+
}
70+
71+
// Cached gauge, Set(float) in the loop.
72+
func BenchmarkEmitBuffer_Gauge_Set(b *testing.B) {
73+
g := NewGauge(NewId("gandalfAgent.gauge", emitBenchTags), newBenchBuffer(b))
74+
b.ReportAllocs()
75+
b.ResetTimer()
76+
for i := 0; i < b.N; i++ {
77+
g.Set(3.14159)
78+
}
79+
}
80+
81+
// Cached timer, Record in the loop.
82+
func BenchmarkEmitBuffer_Timer_Record(b *testing.B) {
83+
t := NewTimer(NewId("gandalfAgent.timer", emitBenchTags), newBenchBuffer(b))
84+
b.ReportAllocs()
85+
b.ResetTimer()
86+
for i := 0; i < b.N; i++ {
87+
t.Record(time.Duration(i) * time.Millisecond)
88+
}
89+
}
90+
91+
// Cached distribution summary, Record in the loop.
92+
func BenchmarkEmitBuffer_DistSummary_Record(b *testing.B) {
93+
d := NewDistributionSummary(NewId("gandalfAgent.distsummary", emitBenchTags), newBenchBuffer(b))
94+
b.ReportAllocs()
95+
b.ResetTimer()
96+
for i := 0; i < b.N; i++ {
97+
d.Record(int64(i))
98+
}
99+
}
100+
101+
// Construct-per-call (gandalf's actual pattern) over a real buffer, via NewId
102+
// (the WithId path) for comparison.
103+
func BenchmarkEmitBuffer_Counter_ConstructPerCall(b *testing.B) {
104+
w := newBenchBuffer(b)
105+
b.ReportAllocs()
106+
b.ResetTimer()
107+
for i := 0; i < b.N; i++ {
108+
NewCounter(NewId("gandalfAgent.authzEngine.appAuthzResult", emitBenchTags), w).Increment()
109+
}
110+
}
111+
112+
// Construct-per-call via the direct (name, tags) path — what registry.Counter
113+
// now uses. No Id, no tag-map copy.
114+
func BenchmarkEmitBuffer_Counter_ConstructPerCall_Direct(b *testing.B) {
115+
w := newBenchBuffer(b)
116+
b.ReportAllocs()
117+
b.ResetTimer()
118+
for i := 0; i < b.N; i++ {
119+
NewCounterDirect("gandalfAgent.authzEngine.appAuthzResult", emitBenchTags, nil, w).Increment()
120+
}
121+
}

spectator/meter/gauge.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package meter
22

33
import (
44
"fmt"
5-
"strconv"
65

76
"github.com/Netflix/spectator-go/v2/spectator/writer"
87
"time"
@@ -32,18 +31,31 @@ func NewGaugeWithTTL(id *Id, writer writer.Writer, ttl time.Duration) *Gauge {
3231
return &Gauge{id, writer, fmt.Sprintf("g,%d", int(ttl.Seconds())) + ":" + id.spectatordId + ":"}
3332
}
3433

35-
// MeterId returns the meter identifier.
34+
// NewGaugeDirect generates a new gauge directly from a name and tags, without
35+
// allocating an *Id or copying the tags map. commonTags carries the registry's
36+
// extraCommonTags.
37+
func NewGaugeDirect(name string, tags, commonTags map[string]string, writer writer.Writer) *Gauge {
38+
return &Gauge{nil, writer, buildLinePrefix("g", name, tags, commonTags)}
39+
}
40+
41+
// NewGaugeDirectWithTTL generates a new gauge with a ttl directly from a name and tags.
42+
func NewGaugeDirectWithTTL(name string, tags, commonTags map[string]string, writer writer.Writer, ttl time.Duration) *Gauge {
43+
return &Gauge{nil, writer, buildLinePrefix(fmt.Sprintf("g,%d", int(ttl.Seconds())), name, tags, commonTags)}
44+
}
45+
46+
// MeterId returns the meter identifier, reconstructing it from the line prefix
47+
// if the gauge was created directly from a name and tags.
3648
func (g *Gauge) MeterId() *Id {
37-
return g.id
49+
return resolveMeterId(g.id, g.linePrefix)
3850
}
3951

4052
// Set records the current value.
4153
func (g *Gauge) Set(value float64) {
42-
g.writer.Write(g.linePrefix + strconv.FormatFloat(value, 'f', 6, 64))
54+
g.writer.WriteFloat(g.linePrefix, value)
4355
}
4456

4557
// SetInt records the current value as an integer, avoiding the overhead of
4658
// float64 conversion and formatting.
4759
func (g *Gauge) SetInt(value int64) {
48-
g.writer.Write(g.linePrefix + strconv.FormatInt(value, 10))
60+
g.writer.WriteInt(g.linePrefix, value)
4961
}

0 commit comments

Comments
 (0)