Skip to content

Commit 0c65db4

Browse files
authored
Merge pull request #68 from Basekick-Labs/perf/intern-dict-pool
perf: pool and pre-allocate interned-string dict (#66)
2 parents 7274cb0 + 2f52c69 commit 0c65db4

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)