Skip to content

log/slog: reduce allocs on Any value handling #74646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/log/slog/encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package slog

type encoder interface {
reset()
free()
encode(v any) error
bytes() []byte
}
21 changes: 13 additions & 8 deletions src/log/slog/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,12 +380,13 @@ func (h *commonHandler) attrSep() string {
// The initial value of sep determines whether to emit a separator
// before the next key, after which it stays true.
type handleState struct {
h *commonHandler
buf *buffer.Buffer
freeBuf bool // should buf be freed?
sep string // separator to write before next key
prefix *buffer.Buffer // for text: key prefix
groups *[]string // pool-allocated slice of active groups, for ReplaceAttr
h *commonHandler
buf *buffer.Buffer
freeBuf bool // should buf be freed?
sep string // separator to write before next key
prefix *buffer.Buffer // for text: key prefix
groups *[]string // pool-allocated slice of active groups, for ReplaceAttr
anyEncoder encoder
}

var groupPool = sync.Pool{New: func() any {
Expand Down Expand Up @@ -416,6 +417,10 @@ func (s *handleState) free() {
*gs = (*gs)[:0]
groupPool.Put(gs)
}
if s.anyEncoder != nil {
s.anyEncoder.free()
s.anyEncoder = nil
}
s.prefix.Free()
}

Expand Down Expand Up @@ -558,7 +563,7 @@ func (s *handleState) appendTwoStrings(x, y string) {
buf = appendEscapedJSONString(buf, x)
buf = appendEscapedJSONString(buf, y)
buf.WriteByte('"')
case !needsQuoting(x) && !needsQuoting(y):
case !needsQuotingString(x) && !needsQuotingString(y):
buf.WriteString(x)
buf.WriteString(y)
default:
Expand All @@ -574,7 +579,7 @@ func (s *handleState) appendString(str string) {
s.buf.WriteByte('"')
} else {
// text
if needsQuoting(str) {
if needsQuotingString(str) {
*s.buf = strconv.AppendQuote(*s.buf, str)
} else {
s.buf.WriteString(str)
Expand Down
11 changes: 11 additions & 0 deletions src/log/slog/internal/benchmarks/benchmarks.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ var (
testInt = 32768
testDuration = 23 * time.Second
testError = errors.New("fail")
testEvent = &event{
ID: "testing_event_#01",
Description: "Some text for this description to simulate a real scenario.",
CreatedAt: time.Date(2025, time.July, 5, 0, 0, 0, 0, time.UTC),
}
)

var testAttrs = []slog.Attr{
Expand All @@ -48,3 +53,9 @@ var testAttrs = []slog.Attr{
}

const wantText = "time=1651363200 level=0 msg=Test logging, but use a somewhat realistic message length. string=7e3b3b2aaeff56a7108fe11e154200dd/7819479873059528190 status=32768 duration=23000000000 time=1651363200 error=fail\n"

type event struct {
ID string `json:"id"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
4 changes: 2 additions & 2 deletions src/log/slog/internal/benchmarks/benchmarks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func BenchmarkAttrs(b *testing.B) {
slog.Int("status", testInt),
slog.Duration("duration", testDuration),
slog.Time("time", testTime),
slog.Any("error", testError),
slog.Any("event", testEvent),
)
},
},
Expand Down Expand Up @@ -133,7 +133,7 @@ func BenchmarkAttrs(b *testing.B) {
slog.Int("status", testInt),
slog.Duration("duration", testDuration),
slog.Time("time", testTime),
slog.Any("error", testError),
slog.Any("event", testEvent),
)
},
},
Expand Down
65 changes: 54 additions & 11 deletions src/log/slog/json_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"log/slog/internal/buffer"
"strconv"
"sync"
"time"
Expand Down Expand Up @@ -113,7 +112,7 @@ func appendJSONValue(s *handleState, v Value) error {
// json.Marshal is funny about floats; it doesn't
// always match strconv.AppendFloat. So just call it.
// That's expensive, but floats are rare.
if err := appendJSONMarshal(s.buf, v.Float64()); err != nil {
if err := appendJSONMarshal(s, v.Float64()); err != nil {
return err
}
case KindBool:
Expand All @@ -129,27 +128,71 @@ func appendJSONValue(s *handleState, v Value) error {
if err, ok := a.(error); ok && !jm {
s.appendString(err.Error())
} else {
return appendJSONMarshal(s.buf, a)
return appendJSONMarshal(s, a)
}
default:
panic(fmt.Sprintf("bad kind: %s", v.Kind()))
}
return nil
}

func appendJSONMarshal(buf *buffer.Buffer, v any) error {
// Use a json.Encoder to avoid escaping HTML.
var bb bytes.Buffer
enc := json.NewEncoder(&bb)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
func appendJSONMarshal(s *handleState, v any) error {
// Aquire a new encoder if not asigned.
if s.anyEncoder == nil {
s.anyEncoder = newJSONMarshalEncoder()
}

enc := s.anyEncoder
enc.reset() // reset the encoder, if was reused
if err := enc.encode(v); err != nil {
return err
}
bs := bb.Bytes()
buf.Write(bs[:len(bs)-1]) // remove final newline

bs := enc.bytes()
s.buf.Write(bs[:len(bs)-1]) // remove final newline
return nil
}

type jsonMarshalEncoder struct {
buf *bytes.Buffer
enc *json.Encoder
}

func newJSONMarshalEncoder() encoder {
if enc := jsonEncoderPool.Get(); enc != nil {
return enc.(*jsonMarshalEncoder)
}

buf := &bytes.Buffer{}

// Use a json.Encoder to avoid escaping HTML.
jsonEnc := json.NewEncoder(buf)
jsonEnc.SetEscapeHTML(false)

return &jsonMarshalEncoder{buf: buf, enc: jsonEnc}
}

func (e *jsonMarshalEncoder) reset() {
e.buf.Reset()
}

func (e *jsonMarshalEncoder) encode(v any) error {
return e.enc.Encode(v)
}

func (e *jsonMarshalEncoder) bytes() []byte {
return e.buf.Bytes()
}

func (e *jsonMarshalEncoder) free() {
const maxBufferSize = 8 << 10
if e.buf.Cap() <= maxBufferSize {
jsonEncoderPool.Put(e)
}
}

var jsonEncoderPool = sync.Pool{}

// appendEscapedJSONString escapes s for JSON and appends it to buf.
// It does not surround the string in quotation marks.
//
Expand Down
75 changes: 69 additions & 6 deletions src/log/slog/text_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package slog

import (
"bytes"
"context"
"encoding"
"fmt"
Expand Down Expand Up @@ -114,7 +115,21 @@ func appendTextValue(s *handleState, v Value) error {
s.buf.WriteString(strconv.Quote(string(bs)))
return nil
}
s.appendString(fmt.Sprintf("%+v", v.Any()))
if s.anyEncoder == nil {
s.anyEncoder = newTextMarshalEncoder()
}

enc := s.anyEncoder
enc.reset()
enc.encode(v.Any())
bs := enc.bytes()
if needsQuotingBytes(bs) {
s.buf.WriteByte('"')
s.buf.Write(bs)
s.buf.WriteByte('"')
return nil
}
s.buf.Write(bs)
default:
*s.buf = v.append(*s.buf)
}
Expand All @@ -136,12 +151,60 @@ func byteSlice(a any) ([]byte, bool) {
return nil, false
}

func needsQuoting(s string) bool {
if len(s) == 0 {
type textMarshalEncoder struct {
buf *bytes.Buffer
}

func newTextMarshalEncoder() encoder {
if enc := textEncoderPool.Get(); enc != nil {
return enc.(*textMarshalEncoder)
}

buf := &bytes.Buffer{}

return &textMarshalEncoder{buf: buf}
}

func (e *textMarshalEncoder) reset() {
e.buf.Reset()
}

func (e *textMarshalEncoder) encode(v any) error {
fmt.Fprintf(e.buf, "%+v", v)
return nil
}

func (e *textMarshalEncoder) bytes() []byte {
return e.buf.Bytes()
}

func (e *textMarshalEncoder) free() {
const maxBufferSize = 4 << 10
if e.buf.Cap() <= maxBufferSize {
textEncoderPool.Put(e)
}
}

var textEncoderPool = sync.Pool{}

func needsQuotingString(s string) bool {
return needsQuoting(s, func(i int) (rune, int) {
return utf8.DecodeRuneInString(s[i:])
})
}

func needsQuotingBytes(b []byte) bool {
return needsQuoting(b, func(i int) (rune, int) {
return utf8.DecodeRune(b[i:])
})
}

func needsQuoting[T string | []byte](text T, decodeRune func(i int) (rune, int)) bool {
if len(text) == 0 {
return true
}
for i := 0; i < len(s); {
b := s[i]
for i := 0; i < len(text); {
b := text[i]
if b < utf8.RuneSelf {
// Quote anything except a backslash that would need quoting in a
// JSON string, as well as space and '='
Expand All @@ -151,7 +214,7 @@ func needsQuoting(s string) bool {
i++
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
r, size := decodeRune(i)
if r == utf8.RuneError || unicode.IsSpace(r) || !unicode.IsPrint(r) {
return true
}
Expand Down
9 changes: 7 additions & 2 deletions src/log/slog/text_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,14 @@ func TestNeedsQuoting(t *testing.T) {
{"a b", true},
{"badutf8\xF6", true},
} {
got := needsQuoting(test.in)
got := needsQuotingString(test.in)
if got != test.want {
t.Errorf("%q: got %t, want %t", test.in, got, test.want)
t.Errorf("needsQuotingString: %q: got %t, want %t", test.in, got, test.want)
}

got = needsQuotingBytes([]byte(test.in))
if got != test.want {
t.Errorf("needsQuotingBytes: %q: got %t, want %t", test.in, got, test.want)
}
}
}