Skip to content
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## Unreleased

### Performance

- **decode:** reuse caller-supplied destination map for `map[string]interface{}` — `Decode(&m)`/`Unmarshal(data, &m)` with a non-nil `m` now decodes into the existing map (entries merged) instead of replacing it with a fresh allocation, matching the long-standing `map[string]string` behavior. Applies to all decode paths for the type: the `Decode()` fast path, struct fields, and named map types. Callers that `clear(m)` and reuse the destination get zero map allocations per decode ([#61](https://github.com/Basekick-Labs/msgpack/issues/61)) (decoding a 4-key map into a reused destination, v6 vs this change on the same benchmark: **-22.9% ns/op**, **-80.8% B/op**, 12 → 10 allocs/op). Note: this diverges from upstream, which replaces a non-nil `map[string]interface{}` destination; pass a nil map to keep replace semantics.

---

## v6.1.0 (2026-04-27)

### Performance
Expand Down
26 changes: 26 additions & 0 deletions bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,32 @@ func BenchmarkMapStringInterfaceMsgpack(b *testing.B) {
benchmarkEncodeDecode(b, src, &dst)
}

// Decode into a reused destination map (issue #61): the caller clears and
// re-passes the same map, so the decoder allocates no map per iteration.
func BenchmarkUnmarshalMapStringInterfaceReuse(b *testing.B) {
src := map[string]interface{}{
"hello": "world",
"foo": "bar",
"one": int64(1111111),
"two": int64(2222222),
}
data, err := msgpack.Marshal(src)
if err != nil {
b.Fatal(err)
}
dst := make(map[string]interface{}, len(src))

b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
clear(dst)
if err := msgpack.Unmarshal(data, &dst); err != nil {
b.Fatal(err)
}
}
}

func BenchmarkMapStringInterfaceJSON(b *testing.B) {
src := map[string]interface{}{
"hello": "world",
Expand Down
33 changes: 31 additions & 2 deletions decode_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,41 @@ func decodeMapStringInterfaceValue(d *Decoder, v reflect.Value) error {
return d.decodeMapStringInterfacePtr(ptr)
}

// decodeMapStringInterfacePtr decodes into an existing map when the caller
// supplies one (entries are merged in, matching decodeMapStringStringPtr),
// avoiding a map allocation per decode for callers that reuse destinations.
func (d *Decoder) decodeMapStringInterfacePtr(ptr *map[string]interface{}) error {
m, err := d.DecodeMap()
size, err := d.DecodeMapLen()
if err != nil {
return err
}
*ptr = m
if size == -1 {
*ptr = nil
return nil
}

m := *ptr
if m == nil {
ln := size
if d.flags&disableAllocLimitFlag == 0 {
ln = min(size, maxMapSize)
}
*ptr = make(map[string]interface{}, ln)
m = *ptr
}

for i := 0; i < size; i++ {
mk, err := d.DecodeString()
if err != nil {
return err
}
mv, err := d.decodeInterfaceCond()
if err != nil {
return err
}
m[mk] = mv
}

return nil
}

Expand Down
47 changes: 47 additions & 0 deletions msgpack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,53 @@ func (t *MsgpackTest) TestMap() {
}
}

func (t *MsgpackTest) TestMapStringInterfaceReuse() {
in := map[string]interface{}{"hello": "world", "n": int8(1)}
t.Nil(t.enc.Encode(in))

// A caller-supplied map is decoded into in place (entries merged),
// matching the map[string]string behavior.
dst := map[string]interface{}{"existing": true, "hello": "stale"}
t.Nil(t.dec.Decode(&dst))
t.Equal(map[string]interface{}{
"existing": true, // retained
"hello": "world",
"n": int8(1),
}, dst)

// msgpack nil still resets the destination to nil.
t.Nil(t.enc.Encode(map[string]interface{}(nil)))
t.Nil(t.dec.Decode(&dst))
t.Nil(dst)

// And a nil destination map is allocated as before.
t.Nil(t.enc.Encode(in))
dst = nil
t.Nil(t.dec.Decode(&dst))
t.Equal(in, dst)

// An empty msgpack map leaves a pre-populated destination untouched.
t.Nil(t.enc.Encode(map[string]interface{}{}))
dst = map[string]interface{}{"existing": true}
t.Nil(t.dec.Decode(&dst))
t.Equal(map[string]interface{}{"existing": true}, dst)
}

func (t *MsgpackTest) TestMapStringInterfaceStructFieldReuse() {
// Struct fields of type map[string]interface{} get the same merge
// semantics as map[string]string fields always had: a pre-populated
// field map is decoded into, not replaced.
type withMap struct {
Data map[string]interface{}
}

t.Nil(t.enc.Encode(withMap{Data: map[string]interface{}{"new": "value"}}))

out := withMap{Data: map[string]interface{}{"existing": true}}
t.Nil(t.dec.Decode(&out))
t.Equal(map[string]interface{}{"existing": true, "new": "value"}, out.Data)
}

func (t *MsgpackTest) TestStructNil() {
var dst *nameStruct

Expand Down
Loading