Skip to content

Commit a5eb427

Browse files
committed
perf(codec): eliminate bson.Unmarshal deep parse in encoder, speed up BinaryToUUID
Root causes found via go test -cpuprofile / -memprofile: encoder.go — buildDoc used bson.Unmarshal(raw, &bson.D) which recursively decoded the entire BSON tree into Go objects before re-encoding. For a 25 KB microflow with 90+ activities this produced 3 591 allocations per call even when only a single scalar field (Name) was dirty — making IncrementalEncode as slow as FullEncode (both ~440 µs, 3 591 allocs). Fix: iterate the raw bytes with bson.Raw.Elements() and pass clean fields through as bson.RawValue (zero-alloc slice into the original buffer). Only fields present in the dirty-properties index are decoded and re-encoded. Stable field ordering is preserved by using elem.Properties() (a slice) as the iteration source, never the rebuild map (Go map iteration is non-deterministic — the previous approach broke TestSerializeWorkflowActivityGen_RoundTripIsStable). Before: FullEncode 415 µs / 3 591 allocs After: FullEncode 13 µs / 57 allocs (32× faster, 63× fewer allocs) decoder.go — BinaryToUUID used fmt.Sprintf to format 16 bytes into a 36-char UUID string. fmt.Sprintf was the #2 allocation source in DecodeRegisteredType (32.6% of all allocation objects). Fix: stack-allocate a [36]byte buffer and fill it with direct hex lookups; convert once with string(buf[:]). Before: DecodeRegisteredType 1 010 ns/op After: DecodeRegisteredType 310 ns/op (3.3× faster) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent f8fe392 commit a5eb427

2 files changed

Lines changed: 188 additions & 110 deletions

File tree

modelsdk/codec/decoder.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,43 @@ func decodeIDValue(val bson.RawValue) element.ID {
170170
return ""
171171
}
172172

173+
const hexchars = "0123456789abcdef"
174+
173175
// BinaryToUUID converts a 16-byte binary to a UUID string using Microsoft GUID
174176
// format (little-endian first 3 groups) to match Mendix standard representation.
177+
//
178+
// Uses a stack-allocated [36]byte buffer instead of fmt.Sprintf to avoid
179+
// the extra allocation and formatting overhead (fmt.Sprintf was the dominant
180+
// alloc in DecodeRegisteredType — 32% of all allocation objects).
175181
func BinaryToUUID(data []byte) string {
176182
if len(data) != 16 {
177183
return ""
178184
}
179-
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
180-
data[3], data[2], data[1], data[0],
181-
data[5], data[4],
182-
data[7], data[6],
183-
data[8], data[9],
184-
data[10], data[11], data[12], data[13], data[14], data[15])
185+
var buf [36]byte
186+
// Group 1 (4 bytes, little-endian): data[3..0]
187+
buf[0] = hexchars[data[3]>>4]; buf[1] = hexchars[data[3]&0xf]
188+
buf[2] = hexchars[data[2]>>4]; buf[3] = hexchars[data[2]&0xf]
189+
buf[4] = hexchars[data[1]>>4]; buf[5] = hexchars[data[1]&0xf]
190+
buf[6] = hexchars[data[0]>>4]; buf[7] = hexchars[data[0]&0xf]
191+
buf[8] = '-'
192+
// Group 2 (2 bytes, little-endian): data[5..4]
193+
buf[9] = hexchars[data[5]>>4]; buf[10] = hexchars[data[5]&0xf]
194+
buf[11] = hexchars[data[4]>>4]; buf[12] = hexchars[data[4]&0xf]
195+
buf[13] = '-'
196+
// Group 3 (2 bytes, little-endian): data[7..6]
197+
buf[14] = hexchars[data[7]>>4]; buf[15] = hexchars[data[7]&0xf]
198+
buf[16] = hexchars[data[6]>>4]; buf[17] = hexchars[data[6]&0xf]
199+
buf[18] = '-'
200+
// Group 4 (2 bytes, big-endian): data[8..9]
201+
buf[19] = hexchars[data[8]>>4]; buf[20] = hexchars[data[8]&0xf]
202+
buf[21] = hexchars[data[9]>>4]; buf[22] = hexchars[data[9]&0xf]
203+
buf[23] = '-'
204+
// Group 5 (6 bytes, big-endian): data[10..15]
205+
buf[24] = hexchars[data[10]>>4]; buf[25] = hexchars[data[10]&0xf]
206+
buf[26] = hexchars[data[11]>>4]; buf[27] = hexchars[data[11]&0xf]
207+
buf[28] = hexchars[data[12]>>4]; buf[29] = hexchars[data[12]&0xf]
208+
buf[30] = hexchars[data[13]>>4]; buf[31] = hexchars[data[13]&0xf]
209+
buf[32] = hexchars[data[14]>>4]; buf[33] = hexchars[data[14]&0xf]
210+
buf[34] = hexchars[data[15]>>4]; buf[35] = hexchars[data[15]&0xf]
211+
return string(buf[:])
185212
}

modelsdk/codec/encoder.go

Lines changed: 155 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ type Encoder struct{}
1313

1414
// Encode serializes an element to []byte.
1515
// Clean elements passthrough raw bytes unchanged.
16-
// Dirty elements rebuild: start from raw fields, overlay dirty property values,
17-
// recursively encode child elements.
16+
// Dirty elements rebuild using buildDoc.
1817
func (e *Encoder) Encode(elem element.Element) ([]byte, error) {
1918
raw := elem.Raw()
2019
if raw != nil && !elem.IsDirty() {
@@ -28,115 +27,180 @@ func (e *Encoder) Encode(elem element.Element) ([]byte, error) {
2827
return bson.Marshal(doc)
2928
}
3029

31-
// buildDoc constructs a bson.D for an element, merging raw fields with dirty overrides.
30+
// rebuildEntry records which properties of an element need re-encoding.
31+
type rebuildEntry struct {
32+
wp element.WritableProperty
33+
cp element.ChildProperty
34+
clp element.ChildListProperty
35+
}
36+
37+
// buildDoc constructs a bson.D for a dirty element.
38+
//
39+
// Key optimization over the previous implementation: instead of calling
40+
// bson.Unmarshal(raw, &bson.D) — which recursively decodes the entire BSON
41+
// tree into Go objects (3 591 allocs for a 25 KB microflow) — we iterate the
42+
// raw bytes with bson.Raw.Elements() and pass clean fields through as
43+
// bson.RawValue (zero-alloc). Only fields that are actually dirty are decoded
44+
// and re-encoded.
3245
func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
33-
// Start from raw if available (preserves unknown fields).
34-
var doc bson.D
35-
if raw := elem.Raw(); raw != nil {
36-
if err := bson.Unmarshal(raw, &doc); err != nil {
37-
return nil, fmt.Errorf("unmarshal raw for %s: %w", elem.TypeName(), err)
38-
}
39-
} else {
40-
// New element — start with identity fields.
41-
doc = bson.D{
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()))
49+
for _, prop := range elem.Properties() {
50+
wp, ok := prop.(element.WritableProperty)
51+
if !ok {
52+
continue
53+
}
54+
cp, _ := prop.(element.ChildProperty)
55+
clp, _ := prop.(element.ChildListProperty)
56+
57+
needsRebuild := wp.Dirty()
58+
if !needsRebuild {
59+
if cp != nil {
60+
ch := cp.ChildElement()
61+
needsRebuild = ch != nil && ch.IsDirty()
62+
} else if clp != nil {
63+
needsRebuild = anyChildDirty(clp)
64+
}
65+
}
66+
if needsRebuild {
67+
rebuild[prop.Name()] = &rebuildEntry{wp, cp, clp}
68+
}
69+
}
70+
71+
raw := elem.Raw()
72+
73+
// === 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).
78+
if raw == nil {
79+
doc := bson.D{
4280
{Key: "$ID", Value: idToBinarySubtype0(elem.ID())},
4381
{Key: "$Type", Value: elem.TypeName()},
4482
}
83+
for _, prop := range elem.Properties() {
84+
rb, needsRebuild := rebuild[prop.Name()]
85+
if !needsRebuild {
86+
continue
87+
}
88+
val, err := e.encodeEntry(rb)
89+
if err != nil {
90+
return nil, err
91+
}
92+
if val != nil {
93+
doc = append(doc, bson.E{Key: prop.Name(), Value: val})
94+
}
95+
}
96+
return doc, nil
97+
}
98+
99+
// === Existing element — iterate raw bytes, pass clean fields through ===
100+
rawElems, err := bson.Raw(raw).Elements()
101+
if err != nil {
102+
return nil, fmt.Errorf("read raw elements for %s: %w", elem.TypeName(), err)
45103
}
46104

47-
// Overlay dirty properties.
105+
doc := make(bson.D, 0, len(rawElems))
106+
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()})
112+
continue
113+
}
114+
// Dirty field: encode new value.
115+
val, err := e.encodeEntry(rb)
116+
if err != nil {
117+
return nil, err
118+
}
119+
if val != nil {
120+
doc = append(doc, bson.E{Key: key, Value: val})
121+
}
122+
// Mark handled so we don't append it again below.
123+
delete(rebuild, key)
124+
}
125+
126+
// Append dirty fields that didn't exist in the raw bytes (new properties).
127+
// Iterate Properties() for stable ordering — not the rebuild map.
48128
for _, prop := range elem.Properties() {
49-
wp, ok := prop.(element.WritableProperty)
129+
rb, ok := rebuild[prop.Name()]
50130
if !ok {
51131
continue
52132
}
133+
val, err := e.encodeEntry(rb)
134+
if err != nil {
135+
return nil, err
136+
}
137+
if val != nil {
138+
doc = append(doc, bson.E{Key: prop.Name(), Value: val})
139+
}
140+
}
53141

54-
key := prop.Name()
55-
56-
// Handle child properties (Part) — recursive encode.
57-
// Three branches mirror the PartList logic below:
58-
// 1. Self-dirty: Part.Set was called → re-encode child.
59-
// 2. Child-dirty: Part itself unchanged but the held child was
60-
// mutated in place → re-encode child so its changes reach
61-
// the output (without this branch the parent passes through
62-
// raw bytes and deep edits silently disappear).
63-
// 3. Completely clean: leave the raw field untouched.
64-
if cp, ok := prop.(element.ChildProperty); ok {
65-
child := cp.ChildElement()
66-
if wp.Dirty() {
67-
if child != nil {
68-
childDoc, err := e.buildDoc(child)
69-
if err != nil {
70-
return nil, err
71-
}
72-
doc = setField(doc, key, childDoc)
73-
} else {
74-
doc = setField(doc, key, nil)
75-
}
76-
} else if child != nil && child.IsDirty() {
142+
return doc, nil
143+
}
144+
145+
// encodeEntry produces the BSON value for a single dirty property.
146+
func (e *Encoder) encodeEntry(rb *rebuildEntry) (any, error) {
147+
wp := rb.wp
148+
149+
// Child (Part) property.
150+
if rb.cp != nil {
151+
child := rb.cp.ChildElement()
152+
if wp.Dirty() {
153+
if child == nil {
154+
return nil, nil // deleted
155+
}
156+
return e.buildDoc(child)
157+
}
158+
// Child itself is dirty (parent pointer unchanged).
159+
if child != nil && child.IsDirty() {
160+
return e.buildDoc(child)
161+
}
162+
return nil, nil
163+
}
164+
165+
// Child list (PartList) property.
166+
if rb.clp != nil {
167+
children := rb.clp.ChildElements()
168+
if wp.Dirty() {
169+
// Full rebuild: all children re-encoded.
170+
arr := make(bson.A, 0, 1+len(children))
171+
arr = append(arr, int32(3))
172+
for _, child := range children {
77173
childDoc, err := e.buildDoc(child)
78174
if err != nil {
79175
return nil, err
80176
}
81-
doc = setField(doc, key, childDoc)
177+
arr = append(arr, childDoc)
82178
}
83-
continue
179+
return arr, nil
84180
}
85-
86-
// Handle child list properties (PartList) — three branches:
87-
// 1. Self-dirty: list was modified (Append/Remove) → full rebuild.
88-
// 2. Child-dirty: list unchanged but a child was modified → selective rebuild.
89-
// 3. Completely clean → skip (don't touch the raw field).
90-
if clp, ok := prop.(element.ChildListProperty); ok {
91-
if wp.Dirty() {
92-
// Branch 1: full rebuild — all children re-encoded.
93-
children := clp.ChildElements()
94-
arr := bson.A{int32(3)}
95-
for _, child := range children {
96-
childDoc, err := e.buildDoc(child)
97-
if err != nil {
98-
return nil, err
99-
}
100-
arr = append(arr, childDoc)
101-
}
102-
doc = setField(doc, key, arr)
103-
} else if anyChildDirty(clp) {
104-
// Branch 2: selective rebuild — dirty children re-encoded, clean ones pass through raw bytes.
105-
children := clp.ChildElements()
106-
arr := bson.A{int32(3)}
107-
for _, child := range children {
108-
if child.IsDirty() {
109-
childDoc, err := e.buildDoc(child)
110-
if err != nil {
111-
return nil, err
112-
}
113-
arr = append(arr, childDoc)
114-
} else {
115-
arr = append(arr, bson.Raw(child.Raw()))
116-
}
181+
// Selective rebuild: dirty children re-encoded, clean ones pass through raw bytes.
182+
arr := make(bson.A, 0, 1+len(children))
183+
arr = append(arr, int32(3))
184+
for _, child := range children {
185+
if child.IsDirty() {
186+
childDoc, err := e.buildDoc(child)
187+
if err != nil {
188+
return nil, err
117189
}
118-
doc = setField(doc, key, arr)
190+
arr = append(arr, childDoc)
191+
} else {
192+
arr = append(arr, bson.Raw(child.Raw()))
119193
}
120-
// Branch 3: completely clean — leave raw field untouched.
121-
continue
122-
}
123-
124-
// Scalar/Enum/Ref properties — only process if dirty.
125-
if !wp.Dirty() {
126-
continue
127-
}
128-
129-
// Scalar/Enum/Ref properties — use BSONValue directly.
130-
val := wp.BSONValue()
131-
// Convert element.ID to binary UUID for BSON compatibility.
132-
if id, ok := val.(element.ID); ok && id != "" {
133-
doc = setField(doc, key, idToBinary(id))
134-
} else {
135-
doc = setField(doc, key, val)
136194
}
195+
return arr, nil
137196
}
138197

139-
return doc, nil
198+
// Scalar / Enum / Ref property.
199+
val := wp.BSONValue()
200+
if id, ok := val.(element.ID); ok && id != "" {
201+
return idToBinary(id), nil
202+
}
203+
return val, nil
140204
}
141205

142206
// anyChildDirty reports whether any element in the ChildListProperty is dirty.
@@ -149,21 +213,8 @@ func anyChildDirty(clp element.ChildListProperty) bool {
149213
return false
150214
}
151215

152-
// setField replaces or appends a field in a bson.D.
153-
func setField(doc bson.D, key string, val any) bson.D {
154-
for i, e := range doc {
155-
if e.Key == key {
156-
doc[i].Value = val
157-
return doc
158-
}
159-
}
160-
return append(doc, bson.E{Key: key, Value: val})
161-
}
162-
163-
// idToBinarySubtype0 converts a UUID string to BSON Binary subtype 0
164-
// using the Mendix byte-swap convention (via sdk/mpr.IDToBsonBinary).
165-
// When id is empty (new element, no ID assigned), a fresh UUID is generated
166-
// so the BSON always carries a valid Binary $ID.
216+
// idToBinarySubtype0 converts a UUID string to BSON Binary subtype 0.
217+
// When id is empty a fresh UUID is generated.
167218
func idToBinarySubtype0(id element.ID) any {
168219
if id == "" {
169220
return mpr.IDToBsonBinary(mpr.GenerateID())

0 commit comments

Comments
 (0)