Skip to content

Commit 1961aed

Browse files
committed
perf(codec): eliminate re.Key() allocs and O(N) child-dirty scan
Two further optimizations on top of the bson.Unmarshal → Raw.Elements fix: Fix #3 — O(1) child-dirty check (was O(N) per PartList): buildDoc previously called anyChildDirty(clp) for every PartList property. For a microflow with 90 activities this iterated all 90 children even when none were dirty. element.Base already propagates child-dirty state upward via IsChildDirty(). Gate the PartList scan on childMightBeDirty so the O(N) path is only entered when the parent element reports a dirty child. Fix #1 — zero-alloc BSON key reads (was 21 string allocs per buildDoc): bsoncore.Element.KeyBytes() returns a []byte view into the raw BSON without allocating. Replace rebuild map[string] with a []rebuildEntry slice and use bytes.Equal(re.KeyBytes(), ...) for lookup — O(M) where M = dirty props (1–5 typical), zero string copies. Use unsafe.String for bson.E.Key on clean-field passthrough (safe: elem.Raw() outlives Marshal). Result (FullEncode on 25 KB microflow, 90 activities): Before Fix#3+1: 13 µs / 57 allocs After Fix#3+1: 10 µs / 31 allocs (1.3× faster, 1.8× fewer allocs) Cumulative vs original bson.Unmarshal approach: 415 µs / 3 591 allocs → 10 µs / 31 allocs (41× faster, 116× fewer allocs) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent a5eb427 commit 1961aed

1 file changed

Lines changed: 88 additions & 30 deletions

File tree

modelsdk/codec/encoder.go

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package codec
22

33
import (
4+
"bytes"
45
"fmt"
6+
"unsafe"
57

68
"github.com/mendixlabs/mxcli/modelsdk/element"
79
"github.com/mendixlabs/mxcli/modelsdk/mpr"
810
"go.mongodb.org/mongo-driver/bson"
11+
"go.mongodb.org/mongo-driver/x/bsonx/bsoncore"
912
)
1013

1114
// Encoder serializes Element trees back to BSON bytes.
@@ -29,9 +32,32 @@ func (e *Encoder) Encode(elem element.Element) ([]byte, error) {
2932

3033
// rebuildEntry records which properties of an element need re-encoding.
3134
type rebuildEntry struct {
32-
wp element.WritableProperty
33-
cp element.ChildProperty
34-
clp element.ChildListProperty
35+
name string // property key; kept as string for stable ordering
36+
wp element.WritableProperty
37+
cp element.ChildProperty
38+
clp element.ChildListProperty
39+
}
40+
41+
// bytesOf returns a []byte view of s without copying.
42+
// Safe only for read-only use within the lifetime of s.
43+
func bytesOf(s string) []byte {
44+
if s == "" {
45+
return nil
46+
}
47+
return unsafe.Slice(unsafe.StringData(s), len(s))
48+
}
49+
50+
// rawKeyOf returns the key bytes of a BSON element without allocating.
51+
// bsoncore.Element is a []byte; KeyBytes() slices into it.
52+
func rawKeyOf(re bsoncore.Element) []byte { return re.KeyBytes() }
53+
54+
// stringOf converts a []byte to string without copying.
55+
// Safe only while the underlying []byte is alive.
56+
func stringOf(b []byte) string {
57+
if len(b) == 0 {
58+
return ""
59+
}
60+
return unsafe.String(unsafe.SliceData(b), len(b))
3561
}
3662

3763
// buildDoc constructs a bson.D for a dirty element.
@@ -43,9 +69,20 @@ type rebuildEntry struct {
4369
// bson.RawValue (zero-alloc). Only fields that are actually dirty are decoded
4470
// and re-encoded.
4571
func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
46-
// Build an index of properties that need rebuilding: dirty scalars,
47-
// dirty children, or child lists with at least one dirty member.
48-
rebuild := make(map[string]*rebuildEntry, len(elem.Properties()))
72+
// Determine cheaply whether any child element is dirty.
73+
// element.Base propagates child-dirty state up the container chain, so
74+
// a single IsChildDirty() call on the element avoids O(N) PartList scans
75+
// when no children are dirty (the common case for scalar-only edits).
76+
type childDirtyChecker interface{ IsChildDirty() bool }
77+
childMightBeDirty := true
78+
if cd, ok := elem.(childDirtyChecker); ok {
79+
childMightBeDirty = cd.IsChildDirty()
80+
}
81+
82+
// Build a compact slice of properties that need rebuilding. Using a slice
83+
// (not a map) lets us do zero-alloc byte comparison with re.KeyBytes()
84+
// in the main loop below, avoiding the 21 string allocations from re.Key().
85+
var rebuild []rebuildEntry // typically 1–5 entries; stays on stack for small N
4986
for _, prop := range elem.Properties() {
5087
wp, ok := prop.(element.WritableProperty)
5188
if !ok {
@@ -55,7 +92,8 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
5592
clp, _ := prop.(element.ChildListProperty)
5693

5794
needsRebuild := wp.Dirty()
58-
if !needsRebuild {
95+
if !needsRebuild && childMightBeDirty {
96+
// Only pay the O(N) scan cost when the parent reports a dirty child.
5997
if cp != nil {
6098
ch := cp.ChildElement()
6199
needsRebuild = ch != nil && ch.IsDirty()
@@ -64,28 +102,40 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
64102
}
65103
}
66104
if needsRebuild {
67-
rebuild[prop.Name()] = &rebuildEntry{wp, cp, clp}
105+
rebuild = append(rebuild, rebuildEntry{prop.Name(), wp, cp, clp})
68106
}
69107
}
70108

109+
// findRebuild returns the index into rebuild for a BSON key, or -1.
110+
// Uses zero-alloc byte comparison instead of map[string] lookup.
111+
// O(M) where M = len(rebuild) ≤ dirty properties (1–5 typical).
112+
findRebuild := func(keyB []byte) int {
113+
for i := range rebuild {
114+
if bytes.Equal(keyB, bytesOf(rebuild[i].name)) {
115+
return i
116+
}
117+
}
118+
return -1
119+
}
120+
// seen tracks which rebuild entries were found in the raw bytes.
121+
seen := make([]bool, len(rebuild))
122+
71123
raw := elem.Raw()
72124

73125
// === New element (no raw bytes) ===
74-
// Iterate elem.Properties() — not the rebuild map — to preserve stable
75-
// field ordering. Go map iteration is non-deterministic; using the map
76-
// as the source of ordering would produce different BSON byte sequences
77-
// across runs (TestSerializeWorkflowActivityGen_RoundTripIsStable).
126+
// Iterate elem.Properties() (a slice) for stable field ordering — not the
127+
// rebuild slice, whose order varies with property registration.
78128
if raw == nil {
79129
doc := bson.D{
80130
{Key: "$ID", Value: idToBinarySubtype0(elem.ID())},
81131
{Key: "$Type", Value: elem.TypeName()},
82132
}
83133
for _, prop := range elem.Properties() {
84-
rb, needsRebuild := rebuild[prop.Name()]
85-
if !needsRebuild {
134+
idx := findRebuild(bytesOf(prop.Name()))
135+
if idx < 0 {
86136
continue
87137
}
88-
val, err := e.encodeEntry(rb)
138+
val, err := e.encodeEntry(rebuild[idx])
89139
if err != nil {
90140
return nil, err
91141
}
@@ -97,40 +147,48 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
97147
}
98148

99149
// === Existing element — iterate raw bytes, pass clean fields through ===
100-
rawElems, err := bson.Raw(raw).Elements()
150+
// Uses bsoncore.Document.Elements() for access to KeyBytes() (zero-alloc
151+
// key reads). bsoncore.Value must be converted to bson.RawValue before
152+
// storing in bson.E, because bson.Marshal only has a codec for bson.RawValue.
153+
rawElems, err := bsoncore.Document(raw).Elements()
101154
if err != nil {
102155
return nil, fmt.Errorf("read raw elements for %s: %w", elem.TypeName(), err)
103156
}
104157

105158
doc := make(bson.D, 0, len(rawElems))
106159
for _, re := range rawElems {
107-
key := re.Key()
108-
rb, dirty := rebuild[key]
109-
if !dirty {
110-
// Clean field: pass through raw bytes without allocating any Go objects.
111-
doc = append(doc, bson.E{Key: key, Value: re.Value()})
160+
keyB := rawKeyOf(re)
161+
idx := findRebuild(keyB)
162+
if idx < 0 {
163+
// Clean field: zero-alloc key + raw value passthrough.
164+
// Convert bsoncore.Value → bson.RawValue so bson.Marshal uses
165+
// the registered RawValueEncodeValue codec (verbatim byte copy).
166+
cv := re.Value()
167+
doc = append(doc, bson.E{
168+
Key: stringOf(keyB),
169+
Value: bson.RawValue{Type: cv.Type, Value: cv.Data},
170+
})
112171
continue
113172
}
114173
// Dirty field: encode new value.
115-
val, err := e.encodeEntry(rb)
174+
val, err := e.encodeEntry(rebuild[idx])
116175
if err != nil {
117176
return nil, err
118177
}
119178
if val != nil {
120-
doc = append(doc, bson.E{Key: key, Value: val})
179+
doc = append(doc, bson.E{Key: rebuild[idx].name, Value: val})
121180
}
122-
// Mark handled so we don't append it again below.
123-
delete(rebuild, key)
181+
seen[idx] = true
124182
}
125183

126184
// Append dirty fields that didn't exist in the raw bytes (new properties).
127-
// Iterate Properties() for stable ordering — not the rebuild map.
185+
// Iterate Properties() for stable ordering.
128186
for _, prop := range elem.Properties() {
129-
rb, ok := rebuild[prop.Name()]
130-
if !ok {
187+
idx := findRebuild(bytesOf(prop.Name()))
188+
if idx < 0 || seen[idx] {
131189
continue
132190
}
133-
val, err := e.encodeEntry(rb)
191+
val, err := e.encodeEntry(rebuild[idx])
134192
if err != nil {
135193
return nil, err
136194
}
@@ -143,7 +201,7 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
143201
}
144202

145203
// encodeEntry produces the BSON value for a single dirty property.
146-
func (e *Encoder) encodeEntry(rb *rebuildEntry) (any, error) {
204+
func (e *Encoder) encodeEntry(rb rebuildEntry) (any, error) {
147205
wp := rb.wp
148206

149207
// Child (Part) property.

0 commit comments

Comments
 (0)