Skip to content

Commit 2f52c69

Browse files
author
Ignacio Van Droogenbroeck
committed
perf: pool and pre-allocate interned-string dict
Closes #66. Fixes map rehashing and slice growth on repeated interned encode/decode when the Encoder/Decoder is reused. New SetInternedStringsDictCap(n) on both Encoder and Decoder sets an initial capacity hint for the dict, avoiding rehashes as entries are added. Reset paths now reuse dict storage (clear-in-place for maps, truncate for slices) instead of dropping it to the GC every session; PutEncoder/PutDecoder drop oversized dicts (> 4096 entries) so a one-off large session doesn't permanently bloat pool memory. Introduces a dictOwned flag to track whether the dict was internally allocated or supplied by the caller via ResetDict/WithDict. The Encoder/Decoder will never clear, truncate, or append into a caller-owned dict — a regression test covers both the encoder-clear and decoder-alias clobber scenarios. A new allocation-based reuse test asserts 0 encoder allocs / 1 decoder alloc on the Reset+encode/decode hot loop (would fail on the pre-fix code).
1 parent 7274cb0 commit 2f52c69

5 files changed

Lines changed: 317 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- **decode:** `readCode` bsr fast path — when decoding from a byte slice, reads directly from the underlying array instead of dispatching through the `io.ByteReader` interface; eliminates ~900M interface calls/sec at Arc's throughput ([#57](https://github.com/Basekick-Labs/msgpack/issues/57)) (StructUnmarshal **-7.5%**, StructUnmarshalPartially **-6.1%**)
66
- **decode:** `PeekCode` bsr fast path — peeks directly at `bsr.data[bsr.pos]` instead of `ReadByte` + `UnreadByte` (two interface calls) ([#59](https://github.com/Basekick-Labs/msgpack/issues/59))
77
- **encode:** pool `OmitEmpty` filtered field slices via `sync.Pool` — when fields are actually omitted, the allocated `[]*field` slice is now returned to a pool for reuse instead of being GC'd ([#58](https://github.com/Basekick-Labs/msgpack/issues/58))
8+
- **encode/decode:** pool and pre-allocate interned-string dict — `SetInternedStringsDictCap(n)` pre-sizes the dict to avoid map rehashing and slice growth; pooled encoders/decoders now reuse dict storage across `Reset()` (cleared in place) instead of discarding it, and `Put*()` drops oversized dicts to keep the pool lean ([#66](https://github.com/Basekick-Labs/msgpack/issues/66))
89

910
---
1011

decode.go

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ func PutDecoder(dec *Decoder) {
5353
} else if dec.buf != nil {
5454
dec.buf = dec.buf[:0]
5555
}
56+
// Drop the interned-string dict if we own it and it grew large, so pool
57+
// entries don't permanently retain memory from a one-off large interning
58+
// session. A caller-owned dict is always dropped so we never hold a
59+
// reference to caller memory across pool round-trips. We check cap(),
60+
// not len(), because PutDecoder can see a truncated slice (len=0) whose
61+
// backing array is still large.
62+
if !dec.dictOwned {
63+
dec.dict = nil
64+
} else if cap(dec.dict) > internDictPoolCap {
65+
dec.dict = nil
66+
dec.dictOwned = false
67+
}
5668
decPool.Put(dec)
5769
}
5870

@@ -82,6 +94,8 @@ type Decoder struct {
8294
rec []byte
8395
dict []string
8496
flags uint32
97+
internCap int // initial capacity hint for the interned-string dict
98+
dictOwned bool // true when dict was lazily allocated by us, safe to mutate/pool
8599
}
86100

87101
// NewDecoder returns a new decoder that reads from r.
@@ -102,24 +116,34 @@ func (d *Decoder) Reset(r io.Reader) {
102116
}
103117

104118
// ResetDict is like Reset, but also resets the dict.
119+
//
120+
// A non-nil dict replaces the current dict; the decoder will not append to
121+
// it or otherwise mutate it (caller retains ownership). A nil dict keeps
122+
// any internally-owned dict storage for reuse, truncated to empty —
123+
// subsequent interned decodes skip the slice allocation.
105124
func (d *Decoder) ResetDict(r io.Reader, dict []string) {
106125
d.ResetReader(r)
107126
d.flags = 0
108127
d.structTag = ""
109-
d.dict = dict
128+
if dict != nil {
129+
d.dict = dict
130+
d.dictOwned = false
131+
}
110132
}
111133

112134
func (d *Decoder) WithDict(dict []string, fn func(*Decoder) error) error {
113-
oldDict := d.dict
135+
oldDict, oldOwned := d.dict, d.dictOwned
114136
d.dict = dict
137+
d.dictOwned = false
115138
err := fn(d)
116139
d.dict = oldDict
140+
d.dictOwned = oldOwned
117141
return err
118142
}
119143

120144
func (d *Decoder) ResetReader(r io.Reader) {
121145
d.mapDecoder = nil
122-
d.dict = nil
146+
d.releaseOrTruncateDict()
123147

124148
if br, ok := r.(bufReader); ok {
125149
d.r = br
@@ -144,7 +168,18 @@ func (d *Decoder) ResetBytes(data []byte) {
144168
d.mapDecoder = nil
145169
d.flags = 0
146170
d.structTag = ""
147-
d.dict = nil
171+
d.releaseOrTruncateDict()
172+
}
173+
174+
// releaseOrTruncateDict reuses the dict storage if we allocated it
175+
// ourselves; otherwise it drops the reference so a caller-supplied dict is
176+
// never aliased or appended into by a subsequent reset.
177+
func (d *Decoder) releaseOrTruncateDict() {
178+
if d.dictOwned {
179+
d.dict = d.dict[:0]
180+
} else {
181+
d.dict = nil
182+
}
148183
}
149184

150185
func (d *Decoder) SetMapDecoder(fn func(*Decoder) (interface{}, error)) {
@@ -187,6 +222,27 @@ func (d *Decoder) UseInternedStrings(on bool) {
187222
}
188223
}
189224

225+
// SetInternedStringsDictCap sets an initial capacity hint for the
226+
// interned-string dict, avoiding slice growth as entries are appended.
227+
// n is clamped to [0, maxDictLen]; 0 restores lazy allocation.
228+
//
229+
// The hint is consulted the next time the dict is allocated — typically on
230+
// the first interned decode after construction, Reset, or ResetDict. Call
231+
// it before decoding to guarantee it takes effect.
232+
//
233+
// When the decoder is managed by GetDecoder/PutDecoder, PutDecoder drops
234+
// dicts whose capacity exceeds an internal pool-retention threshold so a
235+
// single oversized session doesn't permanently bloat pool memory. Setting
236+
// n above that threshold forfeits cross-Put reuse of the dict.
237+
func (d *Decoder) SetInternedStringsDictCap(n int) {
238+
if n < 0 {
239+
n = 0
240+
} else if n > maxDictLen {
241+
n = maxDictLen
242+
}
243+
d.internCap = n
244+
}
245+
190246
// UsePreallocateValues enables preallocating values in chunks
191247
func (d *Decoder) UsePreallocateValues(on bool) {
192248
if on {

encode.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ func PutEncoder(enc *Encoder) {
5858
if cap(enc.wbuf) > 32*1024 {
5959
enc.wbuf = nil
6060
}
61+
// Drop the interned-string dict if we own it and it grew large, so pool
62+
// entries don't permanently retain memory from a one-off large interning
63+
// session. A caller-owned dict is always dropped so we never hold a
64+
// reference to caller memory across pool round-trips.
65+
if !enc.dictOwned {
66+
enc.dict = nil
67+
} else if len(enc.dict) > internDictPoolCap {
68+
enc.dict = nil
69+
enc.dictOwned = false
70+
}
6171
encPool.Put(enc)
6272
}
6373

@@ -94,6 +104,8 @@ type Encoder struct {
94104
buf []byte
95105
timeBuf []byte
96106
flags uint32
107+
internCap int // initial capacity hint for the interned-string dict
108+
dictOwned bool // true when dict was lazily allocated by us, safe to mutate/pool
97109
}
98110

99111
// NewEncoder returns a new encoder that writes to w.
@@ -116,23 +128,33 @@ func (e *Encoder) Reset(w io.Writer) {
116128
}
117129

118130
// ResetDict is like Reset, but also resets the dict.
131+
//
132+
// A non-nil dict replaces the current dict; the encoder will not mutate it
133+
// (caller retains ownership). A nil dict keeps any internally-owned dict
134+
// storage for reuse, cleared to empty — subsequent interned encodes skip
135+
// the map allocation.
119136
func (e *Encoder) ResetDict(w io.Writer, dict map[string]int) {
120137
e.ResetWriter(w)
121138
e.flags = 0
122139
e.structTag = ""
123-
e.dict = dict
140+
if dict != nil {
141+
e.dict = dict
142+
e.dictOwned = false
143+
}
124144
}
125145

126146
func (e *Encoder) WithDict(dict map[string]int, fn func(*Encoder) error) error {
127-
oldDict := e.dict
147+
oldDict, oldOwned := e.dict, e.dictOwned
128148
e.dict = dict
149+
e.dictOwned = false
129150
err := fn(e)
130151
e.dict = oldDict
152+
e.dictOwned = oldOwned
131153
return err
132154
}
133155

134156
func (e *Encoder) ResetWriter(w io.Writer) {
135-
e.dict = nil
157+
e.releaseOrClearDict()
136158
if bw, ok := w.(writer); ok {
137159
e.w = bw
138160
} else if w == nil {
@@ -150,7 +172,18 @@ func (e *Encoder) resetForMarshal() {
150172
e.w = &e.bsw
151173
e.flags = 0
152174
e.structTag = ""
153-
e.dict = nil
175+
e.releaseOrClearDict()
176+
}
177+
178+
// releaseOrClearDict reuses the dict storage if we allocated it ourselves;
179+
// otherwise it drops the reference so a caller-supplied dict is never
180+
// mutated by a subsequent reset.
181+
func (e *Encoder) releaseOrClearDict() {
182+
if e.dictOwned {
183+
clear(e.dict)
184+
} else {
185+
e.dict = nil
186+
}
154187
}
155188

156189
// SetSortMapKeys causes the Encoder to encode map keys in increasing order.
@@ -220,6 +253,27 @@ func (e *Encoder) UseInternedStrings(on bool) {
220253
}
221254
}
222255

256+
// SetInternedStringsDictCap sets an initial capacity hint for the
257+
// interned-string dict, avoiding map rehashing as entries are added.
258+
// n is clamped to [0, maxDictLen]; 0 restores lazy allocation.
259+
//
260+
// The hint is consulted the next time the dict is allocated — typically on
261+
// the first interned encode after construction, Reset, or ResetDict. Call
262+
// it before encoding to guarantee it takes effect.
263+
//
264+
// When the encoder is managed by GetEncoder/PutEncoder, PutEncoder drops
265+
// dicts whose length exceeds an internal pool-retention threshold so a
266+
// single oversized session doesn't permanently bloat pool memory. Setting
267+
// n above that threshold forfeits cross-Put reuse of the dict.
268+
func (e *Encoder) SetInternedStringsDictCap(n int) {
269+
if n < 0 {
270+
n = 0
271+
} else if n > maxDictLen {
272+
n = maxDictLen
273+
}
274+
e.internCap = n
275+
}
276+
223277
func (e *Encoder) Encode(v interface{}) error {
224278
switch v := v.(type) {
225279
case nil:

intern.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import (
1111
const (
1212
minInternedStringLen = 3
1313
maxDictLen = math.MaxUint16
14+
15+
// internDictPoolCap is the threshold above which a pooled Encoder/Decoder
16+
// drops its interned-string dict in Put*() instead of retaining it for
17+
// reuse. Mirrors the wbuf/buf cap-drop pattern: keeps hot, small dicts
18+
// warm without letting a one-off large session bloat pool memory.
19+
internDictPoolCap = 4096
1420
)
1521

1622
var internedStringExtID = int8(math.MinInt8)
@@ -63,7 +69,8 @@ func (e *Encoder) encodeInternedString(s string, intern bool) error {
6369

6470
if intern && len(s) >= minInternedStringLen && len(e.dict) < maxDictLen {
6571
if e.dict == nil {
66-
e.dict = make(map[string]int)
72+
e.dict = make(map[string]int, e.internCap)
73+
e.dictOwned = true
6774
}
6875
idx := len(e.dict)
6976
e.dict[s] = idx
@@ -227,6 +234,12 @@ func (d *Decoder) decodeInternedStringWithLen(n int, intern bool) (string, error
227234
}
228235

229236
if intern && len(s) >= minInternedStringLen && len(d.dict) < maxDictLen {
237+
if d.dict == nil {
238+
if d.internCap > 0 {
239+
d.dict = make([]string, 0, d.internCap)
240+
}
241+
d.dictOwned = true
242+
}
230243
d.dict = append(d.dict, s)
231244
}
232245

0 commit comments

Comments
 (0)