Skip to content

Commit a467462

Browse files
Some performance improvements (#39)
1 parent 7630a0d commit a467462

4 files changed

Lines changed: 235 additions & 56 deletions

File tree

level.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ const (
2929
errorString = "ERROR"
3030
)
3131

32+
// Pre-converted label bytes so the hot path can append them with
33+
// [hue.Style.AppendText] without allocating a fresh []byte each log call.
34+
var (
35+
debugBytes = []byte(debugString)
36+
infoBytes = []byte(infoString)
37+
warnBytes = []byte(warnString)
38+
errorBytes = []byte(errorString)
39+
)
40+
3241
// String returns the stylised representation of the log level.
3342
func (l Level) String() string {
3443
switch l {
@@ -44,3 +53,21 @@ func (l Level) String() string {
4453
return "unknown"
4554
}
4655
}
56+
57+
// appendTo appends the stylised level label to dst and returns the extended
58+
// slice. It is the allocation-light equivalent of [Level.String] used on the
59+
// logging hot path.
60+
func (l Level) appendTo(dst []byte) []byte {
61+
switch l {
62+
case LevelDebug:
63+
return debugStyle.AppendText(dst, debugBytes)
64+
case LevelInfo:
65+
return infoStyle.AppendText(dst, infoBytes)
66+
case LevelWarn:
67+
return warnStyle.AppendText(dst, warnBytes)
68+
case LevelError:
69+
return errorStyle.AppendText(dst, errorBytes)
70+
default:
71+
return append(dst, "unknown"...)
72+
}
73+
}

log.go

Lines changed: 133 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,34 @@
99
package log // import "go.followtheprocess.codes/log"
1010

1111
import (
12-
"bytes"
1312
"io"
1413
"log/slog"
1514
"slices"
1615
"strconv"
17-
"strings"
1816
"sync"
19-
"sync/atomic"
2017
"time"
2118
"unicode"
2219
"unicode/utf8"
2320

2421
"go.followtheprocess.codes/hue"
2522
)
2623

24+
const (
25+
// scratchSize is the size of the stack buffer used to format a timestamp
26+
// before styling it; comfortably larger than any standard time layout.
27+
scratchSize = 64
28+
29+
// bufferSize is the initial capacity of a pooled log line buffer, chosen to
30+
// hold a typical line without reallocating.
31+
bufferSize = 256
32+
33+
// base10 is the radix used to format integer attribute values.
34+
base10 = 10
35+
36+
// float64Bits is the bit size used to format floating point attribute values.
37+
float64Bits = 64
38+
)
39+
2740
// Styles.
2841
const (
2942
timestampStyle = hue.Dim
@@ -37,15 +50,17 @@ const (
3750

3851
// Logger is a command line logger. It is safe to use across concurrently
3952
// executing goroutines.
53+
//
54+
// The zero value is not usable; construct a Logger with [New].
4055
type Logger struct {
4156
w io.Writer // Where to write logs to
4257
timeFunc func() time.Time // A function to get the current time, defaults to [time.Now] (with UTC)
4358
mu *sync.Mutex // Protects w, pointer so that child loggers share the same mutex
4459
timeFormat string // The time format layout string, defaults to [time.RFC3339]
45-
prefix string // Optional prefix to prepend to all log messages
60+
prefix []byte // Optional prefix to prepend to all log messages, stored as bytes for the hot path
4661
attrs []slog.Attr // Persistent key value pairs
4762
level Level // The configured level of this logger, logs below this level are not shown
48-
isDiscard atomic.Bool // w == [io.Discard], cached
63+
isDiscard bool // w == [io.Discard], cached. Only written during construction, before the logger is shared
4964
}
5065

5166
// New returns a new [Logger] configured to write to w.
@@ -59,10 +74,9 @@ func New(w io.Writer, options ...Option) *Logger {
5974
timeFormat: time.RFC3339,
6075
timeFunc: func() time.Time { return time.Now().UTC() },
6176
mu: &sync.Mutex{},
77+
isDiscard: w == io.Discard,
6278
}
6379

64-
logger.isDiscard.Store(w == io.Discard)
65-
6680
for _, option := range options {
6781
option(logger)
6882
}
@@ -87,7 +101,7 @@ func (l *Logger) With(attrs ...slog.Attr) *Logger {
87101
func (l *Logger) Prefixed(prefix string) *Logger {
88102
sub := l.clone()
89103

90-
sub.prefix = prefix
104+
sub.prefix = []byte(prefix)
91105

92106
return sub
93107
}
@@ -114,62 +128,111 @@ func (l *Logger) Error(msg string, attrs ...slog.Attr) {
114128

115129
// log logs the given levelled message.
116130
func (l *Logger) log(level Level, msg string, attrs ...slog.Attr) {
117-
if l.isDiscard.Load() || l.level > level {
131+
if l.isDiscard || l.level > level {
118132
// Do as little work as possible
119133
return
120134
}
121135

122-
// Buffer the output as e.g. stderr is not buffered by default. Do this
123-
// by fetching and putting buffers from a [sync.Pool] so we don't have to
124-
// constantly allocate new buffers
125-
buf := getBuffer()
126-
defer putBuffer(buf)
136+
// Build the line in a byte buffer fetched from a [sync.Pool] so we don't
137+
// constantly allocate. Styled, known-ahead text (timestamp, level, prefix)
138+
// is appended with hue's allocation-free AppendText.
139+
bufp := getBuffer()
140+
defer putBuffer(bufp)
127141

128-
buf.WriteString(timestampStyle.Text(l.timeFunc().Format(l.timeFormat)))
129-
buf.WriteByte(' ')
130-
buf.WriteString(level.String())
142+
// Dereference the working copy so we don't have to dereference every call
143+
buf := *bufp
131144

132-
if l.prefix != "" {
133-
buf.WriteString(" ")
134-
buf.WriteString(prefixStyle.Text(l.prefix))
135-
}
145+
// Format the timestamp into a stack scratch buffer so we avoid allocating
146+
// an intermediate string before styling it.
147+
var scratch [scratchSize]byte
136148

137-
buf.WriteByte(':')
149+
timestamp := l.timeFunc().AppendFormat(scratch[:0], l.timeFormat)
150+
buf = timestampStyle.AppendText(buf, timestamp)
138151

139-
padding := 2
140-
if level == LevelDebug || level == LevelError {
141-
padding = 1
142-
}
152+
buf = append(buf, ' ')
153+
buf = level.appendTo(buf)
143154

144-
buf.WriteString(strings.Repeat(" ", padding))
145-
buf.WriteString(msg)
155+
if len(l.prefix) != 0 {
156+
buf = append(buf, ' ')
157+
buf = prefixStyle.AppendText(buf, l.prefix)
158+
}
146159

147-
if totalAttrs := len(l.attrs) + len(attrs); totalAttrs != 0 {
148-
all := slices.Concat(l.attrs, attrs)
160+
buf = append(buf, ':')
149161

150-
for _, attr := range all {
151-
buf.WriteByte(' ')
162+
// DEBUG and ERROR are 5 characters, INFO and WARN are 4. Pad the shorter
163+
// labels with an extra space so the message always starts in the same column.
164+
buf = append(buf, ' ')
165+
if level == LevelInfo || level == LevelWarn {
166+
buf = append(buf, ' ')
167+
}
152168

153-
key := keyStyle.Text(attr.Key)
154-
val := attr.Value.String()
169+
buf = append(buf, msg...)
155170

156-
if needsQuotes(val) || val == "" {
157-
val = strconv.Quote(val)
158-
}
171+
for _, attr := range l.attrs {
172+
buf = appendAttr(buf, attr)
173+
}
159174

160-
buf.WriteString(key)
161-
buf.WriteByte('=')
162-
buf.WriteString(val)
163-
}
175+
for _, attr := range attrs {
176+
buf = appendAttr(buf, attr)
164177
}
165178

166-
buf.WriteByte('\n')
179+
buf = append(buf, '\n')
180+
181+
// Put it back
182+
*bufp = buf
167183

168-
// WriteTo drains the buffer
169184
l.mu.Lock()
170185
defer l.mu.Unlock()
171186

172-
buf.WriteTo(l.w) //nolint: errcheck // Just like printing
187+
l.w.Write(buf) //nolint: errcheck // Just like printing
188+
}
189+
190+
// appendAttr appends a single " key=value" pair to dst and returns the
191+
// extended slice. The key is quoted if it contains whitespace or is empty.
192+
func appendAttr(dst []byte, attr slog.Attr) []byte {
193+
dst = append(dst, ' ')
194+
195+
key := attr.Key
196+
if key == "" || needsQuotes(key) {
197+
key = strconv.Quote(key)
198+
}
199+
200+
dst = append(dst, keyStyle.Text(key)...)
201+
dst = append(dst, '=')
202+
203+
return appendValue(dst, attr.Value)
204+
}
205+
206+
// appendValue appends the textual form of v to dst and returns the extended slice.
207+
//
208+
// Scalar kinds are written straight into the buffer, skipping the "needs quotes" check
209+
// as their text can never contain whitespace and so are never quoted.
210+
//
211+
// Other kinds fall back to [slog.Value.String], quoted if they contain whitespace or are empty.
212+
func appendValue(dst []byte, v slog.Value) []byte {
213+
// Resolve any [slog.LogValuer]
214+
// See https://github.com/golang/example/blob/master/slog-handler-guide/README.md
215+
if v.Kind() == slog.KindLogValuer {
216+
v = v.Resolve()
217+
}
218+
219+
switch v.Kind() {
220+
case slog.KindInt64:
221+
return strconv.AppendInt(dst, v.Int64(), base10)
222+
case slog.KindUint64:
223+
return strconv.AppendUint(dst, v.Uint64(), base10)
224+
case slog.KindFloat64:
225+
return strconv.AppendFloat(dst, v.Float64(), 'g', -1, float64Bits)
226+
case slog.KindBool:
227+
return strconv.AppendBool(dst, v.Bool())
228+
default:
229+
s := v.String()
230+
if s == "" || needsQuotes(s) {
231+
return strconv.AppendQuote(dst, s)
232+
}
233+
234+
return append(dst, s...)
235+
}
173236
}
174237

175238
// clone returns an exact clone of the calling logger.
@@ -182,32 +245,32 @@ func (l *Logger) clone() *Logger {
182245
attrs: l.attrs,
183246
level: l.level,
184247
mu: l.mu,
248+
isDiscard: l.isDiscard,
185249
}
186250

187-
clone.isDiscard.Store(l.isDiscard.Load())
188-
189251
return clone
190252
}
191253

192254
// Each log method (Debug, Info, Warn) etc. gets a buffer from this pool
193255
// so as not to keep re-allocating and destroying them.
194256
var bufPool = sync.Pool{
195257
New: func() any {
196-
return new(bytes.Buffer)
258+
buf := make([]byte, 0, bufferSize)
259+
return &buf
197260
},
198261
}
199262

200263
// getBuffer fetches a buffer from the pool, the returned buffer
201264
// is empty and ready to use.
202-
func getBuffer() *bytes.Buffer {
203-
buf := bufPool.Get().(*bytes.Buffer) //nolint:revive,errcheck,forcetypeassert // We are in total control of this
204-
buf.Reset()
265+
func getBuffer() *[]byte {
266+
bufp := bufPool.Get().(*[]byte) //nolint:revive,errcheck,forcetypeassert // We are in total control of this
267+
*bufp = (*bufp)[:0] // Reset
205268

206-
return buf
269+
return bufp
207270
}
208271

209272
// putBuffer puts the buffer back into the pool.
210-
func putBuffer(buf *bytes.Buffer) {
273+
func putBuffer(bufp *[]byte) {
211274
// Proper usage of a sync.Pool requires each entry to have approximately
212275
// the same memory cost. To obtain this property when the stored type
213276
// contains a variably-sized buffer, we add a hard limit on the maximum buffer
@@ -217,19 +280,34 @@ func putBuffer(buf *bytes.Buffer) {
217280

218281
// Approx 65kb
219282
const maxSize = 64 << 10
220-
if buf.Cap() > maxSize {
283+
if cap(*bufp) > maxSize {
221284
return
222285
}
223286

224-
bufPool.Put(buf)
287+
bufPool.Put(bufp)
225288
}
226289

227290
// needsQuotes returns whether s should be displayed as "s".
228291
func needsQuotes(s string) bool {
229-
for _, char := range s {
292+
for i := 0; i < len(s); {
293+
// ASCII fast path: most keys and values are printable ASCII
294+
// Anything <= space (control characters and space itself) or DEL needs quoting.
295+
if b := s[i]; b < utf8.RuneSelf {
296+
if b <= ' ' || b == 0x7f {
297+
return true
298+
}
299+
300+
i++
301+
302+
continue
303+
}
304+
305+
char, size := utf8.DecodeRuneInString(s[i:])
230306
if char == utf8.RuneError || unicode.IsSpace(char) || !unicode.IsPrint(char) {
231307
return true
232308
}
309+
310+
i += size
233311
}
234312

235313
return false

0 commit comments

Comments
 (0)