Skip to content

Commit 40ff199

Browse files
authored
perf: human log sink performance bump via reduction of allocs/total allocated memory (#220)
Signed-off-by: Callum Styan <[email protected]>
1 parent 9df5e0a commit 40ff199

File tree

10 files changed

+486
-61
lines changed

10 files changed

+486
-61
lines changed

internal/entryhuman/entry.go

Lines changed: 166 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -88,60 +88,160 @@ func formatValue(v interface{}) string {
8888

8989
const tab = " "
9090

91-
// Fmt returns a human readable format for ent.
91+
// bracketedLevel is an optimization to avoid extra allocations and calls to strings.ToLower
92+
// when we want to translate/print the lowercase version of a log level.
93+
func bracketedLevel(l slog.Level) string {
94+
switch l {
95+
case slog.LevelDebug:
96+
return "[debu]"
97+
case slog.LevelInfo:
98+
return "[info]"
99+
case slog.LevelWarn:
100+
return "[warn]"
101+
case slog.LevelError:
102+
return "[erro]"
103+
case slog.LevelCritical:
104+
return "[crit]"
105+
case slog.LevelFatal:
106+
return "[fata]"
107+
default:
108+
return "[unkn]"
109+
}
110+
}
111+
112+
func writeSignedInt(w io.Writer, n int64) (bool, error) {
113+
var a [20]byte
114+
_, err := w.Write(strconv.AppendInt(a[:0], n, 10))
115+
return true, err
116+
}
117+
118+
func writeUnsignedInt(w io.Writer, n uint64) (bool, error) {
119+
var a [20]byte
120+
_, err := w.Write(strconv.AppendUint(a[:0], n, 10))
121+
return true, err
122+
}
123+
124+
// Optimization to avoid allocation of heap allocations/temporary strings via formatValue when dealing with primitive types.
125+
// It returns (handled, error). When handled is false, the caller should fall back to formatValue.
126+
func writeValueFast(w io.Writer, v interface{}) (bool, error) {
127+
switch x := v.(type) {
128+
case string:
129+
_, err := w.Write([]byte(quote(x)))
130+
return true, err
131+
case bool:
132+
if x {
133+
_, err := w.Write([]byte("true"))
134+
return true, err
135+
}
136+
_, err := w.Write([]byte("false"))
137+
return true, err
138+
139+
// signed ints
140+
case int:
141+
return writeSignedInt(w, int64(x))
142+
case int8:
143+
return writeSignedInt(w, int64(x))
144+
case int16:
145+
return writeSignedInt(w, int64(x))
146+
case int32:
147+
return writeSignedInt(w, int64(x))
148+
case int64:
149+
return writeSignedInt(w, x)
150+
151+
// unsigned ints
152+
case uint:
153+
return writeUnsignedInt(w, uint64(x))
154+
case uint8:
155+
return writeUnsignedInt(w, uint64(x))
156+
case uint16:
157+
return writeUnsignedInt(w, uint64(x))
158+
case uint32:
159+
return writeUnsignedInt(w, uint64(x))
160+
case uint64:
161+
return writeUnsignedInt(w, x)
162+
163+
// floats: prefer 'g' to keep output bounded (matches fmt default)
164+
case float32:
165+
var a [32]byte
166+
_, err := w.Write(strconv.AppendFloat(a[:0], float64(x), 'g', -1, 32))
167+
return true, err
168+
case float64:
169+
var a [32]byte
170+
_, err := w.Write(strconv.AppendFloat(a[:0], x, 'g', -1, 64))
171+
return true, err
172+
default:
173+
return false, nil
174+
}
175+
}
176+
177+
// Fmt returns a human readable format for ent. Assumes we have a bytes.Buffer
178+
// which we will more easily be able to assume underlying reallocation of it's size is possible
179+
// if necessary than for an arbitrary io.Writer/io.StringWriter
180+
// Note that while bytes.Buffer can in theory return an error for writes, it only does so if the buffer size will
181+
// exceed our architectures max integer size. If the system is actually OOM and more memory cannot be allocated
182+
// it will panic instead.
92183
//
93184
// We never return with a trailing newline because Go's testing framework adds one
94185
// automatically and if we include one, then we'll get two newlines.
95186
// We also do not indent the fields as go's test does that automatically
96187
// for extra lines in a log so if we did it here, the fields would be indented
97188
// twice in test logs. So the Stderr logger indents all the fields itself.
98-
func Fmt(
99-
buf interface {
100-
io.StringWriter
101-
io.Writer
102-
}, termW io.Writer, ent slog.SinkEntry,
103-
) {
189+
func Fmt(buf *bytes.Buffer, termW io.Writer, ent slog.SinkEntry) {
104190
reset(buf, termW)
105-
ts := ent.Time.Format(TimeFormat)
106-
buf.WriteString(render(termW, timeStyle, ts+" "))
107191

108-
level := ent.Level.String()
109-
level = strings.ToLower(level)
110-
if len(level) > 4 {
111-
level = level[:4]
112-
}
113-
level = "[" + level + "]"
114-
buf.WriteString(render(termW, levelStyle(ent.Level), level))
192+
// Timestamp + space
193+
buf.WriteString(render(termW, timeStyle, ent.Time.Format(TimeFormat)))
194+
buf.WriteString(" ")
195+
196+
// Level label + two spaces
197+
lvl := bracketedLevel(ent.Level) // e.g. "[debu]", "[info]"
198+
buf.WriteString(render(termW, levelStyle(ent.Level), lvl))
115199
buf.WriteString(" ")
116200

201+
// Logger names: name1.name2.name3: (no strings.Join allocation)
117202
if len(ent.LoggerNames) > 0 {
118-
loggerName := quoteKey(strings.Join(ent.LoggerNames, ".")) + ": "
119-
buf.WriteString(loggerName)
203+
for i, name := range ent.LoggerNames {
204+
if i > 0 {
205+
buf.WriteString(".")
206+
}
207+
buf.WriteString(quoteKey(name))
208+
}
209+
buf.WriteString(": ")
120210
}
121211

212+
// Message (detect multiline)
122213
var multilineKey string
123214
var multilineVal string
124215
msg := strings.TrimSpace(ent.Message)
125216
if strings.Contains(msg, "\n") {
126217
multilineKey = "msg"
127218
multilineVal = msg
128-
msg = "..."
129-
msg = quote(msg)
219+
msg = quote("...")
130220
}
131221
buf.WriteString(msg)
132222

223+
keyStyle := timeStyle
224+
equalsStyle := timeStyle
225+
226+
// Write trace/span directly (do not mutate ent.Fields)
133227
if ent.SpanContext.IsValid() {
134-
ent.Fields = append(slog.M(
135-
slog.F("trace", ent.SpanContext.TraceID),
136-
slog.F("span", ent.SpanContext.SpanID),
137-
), ent.Fields...)
228+
buf.WriteString(tab)
229+
230+
buf.WriteString(render(termW, keyStyle, quoteKey("trace")))
231+
buf.WriteString(render(termW, equalsStyle, "="))
232+
buf.WriteString(ent.SpanContext.TraceID().String())
233+
buf.WriteString(tab)
234+
buf.WriteString(render(termW, keyStyle, quoteKey("span")))
235+
buf.WriteString(render(termW, equalsStyle, "="))
236+
buf.WriteString(ent.SpanContext.SpanID().String())
138237
}
139238

239+
// Find a multiline field without mutating ent.Fields.
240+
multiIdx := -1
140241
for i, f := range ent.Fields {
141242
if multilineVal != "" {
142243
break
143244
}
144-
145245
var s string
146246
switch v := f.Value.(type) {
147247
case string:
@@ -153,46 +253,66 @@ func Fmt(
153253
if !strings.Contains(s, "\n") {
154254
continue
155255
}
156-
157-
// Remove this field.
158-
ent.Fields = append(ent.Fields[:i], ent.Fields[i+1:]...)
256+
multiIdx = i
159257
multilineKey = f.Name
160258
multilineVal = s
259+
break
161260
}
162261

163-
keyStyle := timeStyle
164-
// Help users distinguish logs by keeping some color in the equal signs.
165-
equalsStyle := timeStyle
166-
262+
// Print fields (skip multiline field index).
167263
for i, f := range ent.Fields {
264+
if i == multiIdx {
265+
continue
266+
}
168267
if i < len(ent.Fields) {
169268
buf.WriteString(tab)
170269
}
270+
171271
buf.WriteString(render(termW, keyStyle, quoteKey(f.Name)))
172272
buf.WriteString(render(termW, equalsStyle, "="))
173-
valueStr := formatValue(f.Value)
174-
buf.WriteString(valueStr)
273+
274+
if ok, err := writeValueFast(buf, f.Value); err != nil {
275+
// return err
276+
} else if !ok {
277+
buf.WriteString(formatValue(f.Value))
278+
}
175279
}
176280

281+
// Multiline value block
177282
if multilineVal != "" {
178283
if msg != "..." {
179284
buf.WriteString(" ...")
180285
}
181286

182-
// Proper indentation.
183-
lines := strings.Split(multilineVal, "\n")
184-
for i, line := range lines[1:] {
185-
if line != "" {
186-
lines[i+1] = strings.Repeat(" ", len(multilineKey)+2) + line
187-
}
188-
}
189-
multilineVal = strings.Join(lines, "\n")
190-
191-
multilineKey = render(termW, keyStyle, multilineKey)
192287
buf.WriteString("\n")
193-
buf.WriteString(multilineKey)
288+
buf.WriteString(render(termW, keyStyle, multilineKey))
194289
buf.WriteString("= ")
195-
buf.WriteString(multilineVal)
290+
291+
// First line up to first newline
292+
s := multilineVal
293+
if n := strings.IndexByte(s, '\n'); n >= 0 {
294+
buf.WriteString(s[:n])
295+
s = s[n+1:]
296+
} else {
297+
buf.WriteString(s)
298+
s = ""
299+
}
300+
301+
indent := strings.Repeat(" ", len(multilineKey)+2)
302+
for len(s) > 0 {
303+
buf.WriteString("\n")
304+
// Only indent non-empty lines.
305+
if s[0] != '\n' {
306+
buf.WriteString(indent)
307+
}
308+
if n := strings.IndexByte(s, '\n'); n >= 0 {
309+
buf.WriteString(s[:n])
310+
s = s[n+1:]
311+
} else {
312+
buf.WriteString(s)
313+
break
314+
}
315+
}
196316
}
197317
}
198318

internal/entryhuman/entry_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"flag"
77
"fmt"
88
"io"
9+
"math"
910
"os"
1011
"testing"
1112
"time"
@@ -142,6 +143,81 @@ func TestEntry(t *testing.T) {
142143
),
143144
},
144145
},
146+
{
147+
"primitiveTypes",
148+
slog.SinkEntry{
149+
Level: slog.LevelInfo,
150+
Message: "primitives",
151+
Time: kt,
152+
Fields: slog.M(
153+
slog.F("bool_true", true),
154+
slog.F("bool_false", false),
155+
slog.F("int", 42),
156+
slog.F("int8", int8(-8)),
157+
slog.F("int16", int16(-16)),
158+
slog.F("int32", int32(-32)),
159+
slog.F("int64", int64(-64)),
160+
slog.F("uint", uint(42)),
161+
slog.F("uint8", uint8(8)),
162+
slog.F("uint16", uint16(16)),
163+
slog.F("uint32", uint32(32)),
164+
slog.F("uint64", uint64(64)),
165+
slog.F("float32", float32(3.14)),
166+
slog.F("float64", 2.71828),
167+
),
168+
},
169+
},
170+
{
171+
"primitiveEdgeCases",
172+
slog.SinkEntry{
173+
Level: slog.LevelWarn,
174+
Message: "edge cases",
175+
Time: kt,
176+
Fields: slog.M(
177+
slog.F("zero_int", 0),
178+
slog.F("neg_int", -999),
179+
slog.F("max_int64", math.MaxInt64),
180+
slog.F("min_int64", math.MinInt64),
181+
// math.MaxUint64 is an untyped constant, and by default the compiler will assume a number
182+
// value is an int, so we need an explicit cast to uint64 here.
183+
slog.F("max_uint64", uint64(math.MaxUint64)),
184+
slog.F("zero_float", 0.0),
185+
slog.F("neg_float", -123.456),
186+
),
187+
},
188+
},
189+
{
190+
"mixedPrimitiveAndComplex",
191+
slog.SinkEntry{
192+
Level: slog.LevelDebug,
193+
Message: "mixed types",
194+
Time: kt,
195+
Fields: slog.M(
196+
slog.F("count", 100),
197+
slog.F("name", "test"),
198+
slog.F("enabled", true),
199+
slog.F("ratio", 0.95),
200+
slog.F("data", []byte("bytes")),
201+
slog.F("nil_val", nil),
202+
),
203+
},
204+
},
205+
{
206+
"allLogLevels",
207+
slog.SinkEntry{
208+
Level: slog.LevelCritical, // Test Critical
209+
Message: "critical",
210+
Time: kt,
211+
},
212+
},
213+
{
214+
"fatalLevel",
215+
slog.SinkEntry{
216+
Level: slog.LevelFatal, // Test Fatal
217+
Message: "fatal",
218+
Time: kt,
219+
},
220+
},
145221
}
146222
if *updateGoldenFiles {
147223
ents, err := os.ReadDir("testdata")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2000-02-05 04:04:04.000 [crit] critical
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2000-02-05 04:04:04.000 [fata] fatal
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2000-02-05 04:04:04.000 [debu] mixed types count=100 name=test enabled=true ratio=0.95 data="bytes" nil_val=<nil>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2000-02-05 04:04:04.000 [warn] edge cases zero_int=0 neg_int=-999 max_int64=9223372036854775807 min_int64=-9223372036854775808 max_uint64=18446744073709551615 zero_float=0 neg_float=-123.456
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2000-02-05 04:04:04.000 [info] primitives bool_true=true bool_false=false int=42 int8=-8 int16=-16 int32=-32 int64=-64 uint=42 uint8=8 uint16=16 uint32=32 uint64=64 float32=3.14 float64=2.71828

0 commit comments

Comments
 (0)