Skip to content

Commit

Permalink
Encode structs directly to output buffer.
Browse files Browse the repository at this point in the history
For variable-length structs (structs with omitempty fields), encoding to the unused capacity at the
end of the output buffer while counting nonempty items is cheaper than using a separate temporary
buffer (no pool interactions and better spatial locality). Copying the items can be avoided entirely
by reserving space in the output buffer for the head if the encoded length of the head can be
predicted before checking optional fields.

                                                                     │ before.txt  │             after.txt              │
                                                                     │   sec/op    │   sec/op     vs base               │
Marshal/Go_struct_to_CBOR_map                                          1.404µ ± 0%   1.409µ ± 1%  +0.32% (p=0.001 n=10)
Marshal/Go_struct_many_fields_all_omitempty_all_empty_to_CBOR_map      443.8n ± 0%   443.1n ± 0%       ~ (p=0.251 n=10)
Marshal/Go_struct_some_fields_all_omitempty_all_empty_to_CBOR_map      181.7n ± 0%   172.4n ± 0%  -5.14% (p=0.000 n=10)
Marshal/Go_struct_many_fields_all_omitempty_all_nonempty_to_CBOR_map   813.5n ± 0%   806.3n ± 0%  -0.90% (p=0.000 n=10)
Marshal/Go_struct_some_fields_all_omitempty_all_nonempty_to_CBOR_map   300.8n ± 0%   284.4n ± 0%  -5.45% (p=0.000 n=10)
Marshal/Go_struct_many_fields_one_omitempty_to_CBOR_map                763.8n ± 0%   739.8n ± 0%  -3.15% (p=0.000 n=10)
Marshal/Go_struct_some_fields_one_omitempty_to_CBOR_map                284.2n ± 0%   267.2n ± 0%  -6.00% (p=0.000 n=10)
Marshal/Go_struct_keyasint_to_CBOR_map                                 1.422µ ± 0%   1.402µ ± 0%  -1.41% (p=0.000 n=10)
Marshal/Go_struct_toarray_to_CBOR_array                                1.341µ ± 1%   1.344µ ± 1%       ~ (p=0.270 n=10)
MarshalCanonical/Go_struct_to_CBOR_map                                 386.4n ± 0%   384.8n ± 2%       ~ (p=0.467 n=10)
MarshalCanonical/Go_struct_to_CBOR_map_canonical                       386.9n ± 0%   388.3n ± 0%  +0.37% (p=0.000 n=10)
geomean                                                                560.5n        549.3n       -2.01%

                                                                     │ before.txt │              after.txt              │
                                                                     │    B/op    │    B/op     vs base                 │
Marshal/Go_struct_to_CBOR_map                                          208.0 ± 0%   208.0 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_many_fields_all_omitempty_all_empty_to_CBOR_map      1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_some_fields_all_omitempty_all_empty_to_CBOR_map      1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_many_fields_all_omitempty_all_nonempty_to_CBOR_map   176.0 ± 0%   176.0 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_some_fields_all_omitempty_all_nonempty_to_CBOR_map   48.00 ± 0%   48.00 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_many_fields_one_omitempty_to_CBOR_map                160.0 ± 0%   160.0 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_some_fields_one_omitempty_to_CBOR_map                48.00 ± 0%   48.00 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_keyasint_to_CBOR_map                                 192.0 ± 0%   192.0 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_toarray_to_CBOR_array                                192.0 ± 0%   192.0 ± 0%       ~ (p=1.000 n=10) ¹
MarshalCanonical/Go_struct_to_CBOR_map                                 64.00 ± 0%   64.00 ± 0%       ~ (p=1.000 n=10) ¹
MarshalCanonical/Go_struct_to_CBOR_map_canonical                       64.00 ± 0%   64.00 ± 0%       ~ (p=1.000 n=10) ¹
geomean                                                                46.18        46.18       +0.00%
¹ all samples are equal

                                                                     │ before.txt │              after.txt              │
                                                                     │ allocs/op  │ allocs/op   vs base                 │
Marshal/Go_struct_to_CBOR_map                                          1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_many_fields_all_omitempty_all_empty_to_CBOR_map      1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_some_fields_all_omitempty_all_empty_to_CBOR_map      1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_many_fields_all_omitempty_all_nonempty_to_CBOR_map   1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_some_fields_all_omitempty_all_nonempty_to_CBOR_map   1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_many_fields_one_omitempty_to_CBOR_map                1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_some_fields_one_omitempty_to_CBOR_map                1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_keyasint_to_CBOR_map                                 1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
Marshal/Go_struct_toarray_to_CBOR_array                                1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
MarshalCanonical/Go_struct_to_CBOR_map                                 1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
MarshalCanonical/Go_struct_to_CBOR_map_canonical                       1.000 ± 0%   1.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                                                                1.000        1.000       +0.00%
¹ all samples are equal

Signed-off-by: Ben Luddy <bluddy@redhat.com>
  • Loading branch information
benluddy committed Apr 23, 2024
1 parent 8ef865c commit 2a344fa
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 25 deletions.
70 changes: 70 additions & 0 deletions bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,65 @@ type T3 struct {
Mss map[string]string
}

type ManyFieldsOneOmitEmpty struct {
F01, F02, F03, F04, F05, F06, F07, F08, F09, F10, F11, F12, F13, F14, F15, F16 int
F17, F18, F19, F20, F21, F22, F23, F24, F25, F26, F27, F28, F29, F30, F31 int

F32 int `cbor:",omitempty"`
}

type SomeFieldsOneOmitEmpty struct {
F01, F02, F03, F04, F05, F06, F07 int

F08 int `cbor:",omitempty"`
}

type ManyFieldsAllOmitEmpty struct {
F01 int `cbor:",omitempty"`
F02 int `cbor:",omitempty"`
F03 int `cbor:",omitempty"`
F04 int `cbor:",omitempty"`
F05 int `cbor:",omitempty"`
F06 int `cbor:",omitempty"`
F07 int `cbor:",omitempty"`
F08 int `cbor:",omitempty"`
F09 int `cbor:",omitempty"`
F10 int `cbor:",omitempty"`
F11 int `cbor:",omitempty"`
F12 int `cbor:",omitempty"`
F13 int `cbor:",omitempty"`
F14 int `cbor:",omitempty"`
F15 int `cbor:",omitempty"`
F16 int `cbor:",omitempty"`
F17 int `cbor:",omitempty"`
F18 int `cbor:",omitempty"`
F19 int `cbor:",omitempty"`
F20 int `cbor:",omitempty"`
F21 int `cbor:",omitempty"`
F22 int `cbor:",omitempty"`
F23 int `cbor:",omitempty"`
F24 int `cbor:",omitempty"`
F25 int `cbor:",omitempty"`
F26 int `cbor:",omitempty"`
F27 int `cbor:",omitempty"`
F28 int `cbor:",omitempty"`
F29 int `cbor:",omitempty"`
F30 int `cbor:",omitempty"`
F31 int `cbor:",omitempty"`
F32 int `cbor:",omitempty"`
}

type SomeFieldsAllOmitEmpty struct {
F01 int `cbor:",omitempty"`
F02 int `cbor:",omitempty"`
F03 int `cbor:",omitempty"`
F04 int `cbor:",omitempty"`
F05 int `cbor:",omitempty"`
F06 int `cbor:",omitempty"`
F07 int `cbor:",omitempty"`
F08 int `cbor:",omitempty"`
}

var decodeBenchmarks = []struct {
name string
data []byte
Expand Down Expand Up @@ -396,6 +455,17 @@ func BenchmarkMarshal(b *testing.B) {
}{
{"Go map[string]interface{} to CBOR map", m1},
{"Go struct to CBOR map", v1},
{"Go struct many fields all omitempty all empty to CBOR map", ManyFieldsAllOmitEmpty{}},
{"Go struct some fields all omitempty all empty to CBOR map", SomeFieldsAllOmitEmpty{}},
{"Go struct many fields all omitempty all nonempty to CBOR map", ManyFieldsAllOmitEmpty{
F01: 1, F02: 1, F03: 1, F04: 1, F05: 1, F06: 1, F07: 1, F08: 1, F09: 1, F10: 1, F11: 1, F12: 1, F13: 1, F14: 1, F15: 1, F16: 1,
F17: 1, F18: 1, F19: 1, F20: 1, F21: 1, F22: 1, F23: 1, F24: 1, F25: 1, F26: 1, F27: 1, F28: 1, F29: 1, F30: 1, F31: 1, F32: 1,
}},
{"Go struct some fields all omitempty all nonempty to CBOR map", SomeFieldsAllOmitEmpty{
F01: 1, F02: 1, F03: 1, F04: 1, F05: 1, F06: 1, F07: 1, F08: 1,
}},
{"Go struct many fields one omitempty to CBOR map", ManyFieldsOneOmitEmpty{}},
{"Go struct some fields one omitempty to CBOR map", SomeFieldsOneOmitEmpty{}},
{"Go map[int]interface{} to CBOR map", m2},
{"Go struct keyasint to CBOR map", v2},
{"Go []interface{} to CBOR map", slc},
Expand Down
31 changes: 19 additions & 12 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,14 @@ func getDecodingStructType(t reflect.Type) *decodingStructType {
}

type encodingStructType struct {
fields fields
bytewiseFields fields
lengthFirstFields fields
omitEmptyFieldsIdx []int
err error
toArray bool
fixedLength bool // Struct type doesn't have any omitempty or anonymous fields.
fields fields
bytewiseFields fields
lengthFirstFields fields
omitEmptyFieldsIdx []int
omitEmptyOrAnonymousFieldsIdx []int
err error
toArray bool
fixedLength bool // Struct type doesn't have any omitempty or anonymous fields.
}

func (st *encodingStructType) getFields(em *encMode) fields {
Expand Down Expand Up @@ -235,6 +236,7 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) {
var hasKeyAsInt bool
var hasKeyAsStr bool
var omitEmptyIdx []int
var omitEmptyOrAnonymousIdx []int
fixedLength := true
e := getEncoderBuffer()
for i := 0; i < len(flds); i++ {
Expand Down Expand Up @@ -293,6 +295,10 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) {
fixedLength = false
omitEmptyIdx = append(omitEmptyIdx, i)
}

if len(flds[i].idx) > 1 || flds[i].omitEmpty {
omitEmptyOrAnonymousIdx = append(omitEmptyOrAnonymousIdx, i)
}
}
putEncoderBuffer(e)

Expand All @@ -315,11 +321,12 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) {
}

structType := &encodingStructType{
fields: flds,
bytewiseFields: bytewiseFields,
lengthFirstFields: lengthFirstFields,
omitEmptyFieldsIdx: omitEmptyIdx,
fixedLength: fixedLength,
fields: flds,
bytewiseFields: bytewiseFields,
lengthFirstFields: lengthFirstFields,
omitEmptyFieldsIdx: omitEmptyIdx,
omitEmptyOrAnonymousFieldsIdx: omitEmptyOrAnonymousIdx,
fixedLength: fixedLength,
}
encodingStructTypeCache.Store(t, structType)
return structType, structType.err
Expand Down
55 changes: 42 additions & 13 deletions encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -1297,12 +1297,27 @@ func encodeStruct(e *encoderBuffer, em *encMode, v reflect.Value) (err error) {
start = rand.Intn(len(flds)) //nolint:gosec // Don't need a CSPRNG for deck cutting.
}

kve := getEncoderBuffer() // encode key-value pairs based on struct field tag options
kvcount := 0
if b := em.encTagBytes(v.Type()); b != nil {
e.Write(b)
}

// Reserve space in the output buffer for the head if its encoded size is fixed.
var headcap [9]byte
headbuf := encoderBuffer{Buffer: *bytes.NewBuffer(headcap[0:0:9])}
encodeHead(&headbuf, byte(cborTypeMap), uint64(len(flds)))
maxHeadLen := headbuf.Len()
headbuf.Reset()
encodeHead(&headbuf, byte(cborTypeMap), uint64(len(flds)-len(structType.omitEmptyOrAnonymousFieldsIdx)))
minHeadLen := headbuf.Len()

if minHeadLen == maxHeadLen {
encodeHead(e, byte(cborTypeMap), uint64(len(flds)))
}

kvbegin := e.Len()
kvcount := 0
for offset := 0; offset < len(flds); offset++ {
i := (start + offset) % len(flds)
f := flds[i]
f := flds[(start+offset)%len(flds)]

var fv reflect.Value
if len(f.idx) == 1 {
Expand All @@ -1320,7 +1335,6 @@ func encodeStruct(e *encoderBuffer, em *encMode, v reflect.Value) (err error) {
if f.omitEmpty {
empty, err := f.ief(em, fv)
if err != nil {
putEncoderBuffer(kve)
return err
}
if empty {
Expand All @@ -1329,26 +1343,41 @@ func encodeStruct(e *encoderBuffer, em *encMode, v reflect.Value) (err error) {
}

if !f.keyAsInt && em.fieldName == FieldNameToByteString {
kve.Write(f.cborNameByteString)
e.Write(f.cborNameByteString)
} else { // int or text string
kve.Write(f.cborName)
e.Write(f.cborName)
}

if err := f.ef(kve, em, fv); err != nil {
putEncoderBuffer(kve)
if err := f.ef(e, em, fv); err != nil {
return err
}

kvcount++
}

if b := em.encTagBytes(v.Type()); b != nil {
e.Write(b)
if minHeadLen == maxHeadLen {
// We reserved space for the encoded head, but did not know what the argument would
// be. Overwrite the reserved bytes with the final head.
headbuf := encoderBuffer{Buffer: *bytes.NewBuffer(e.Bytes()[kvbegin-minHeadLen : kvbegin-minHeadLen : kvbegin])}
encodeHead(&headbuf, byte(cborTypeMap), uint64(kvcount))
return nil
}

// Encode the head AFTER the items as we did not know in advance which items would be
// omitted. Then, use the excess capacity of the output buffer to swap the encoded head with
// the encoded items.
kvend := e.Len()
encodeHead(e, byte(cborTypeMap), uint64(kvcount))
e.Write(kve.Bytes())
headend := e.Len()

sz := headend - kvbegin
e.Grow(sz) // ensure enough excess capacity
tmp := e.Bytes()[e.Len() : e.Len()+sz]
dst := e.Bytes()[kvbegin:]
copy(tmp, dst) // everything to scratch
copy(dst, tmp[kvend-kvbegin:]) // head to front
copy(dst[headend-kvend:], tmp) // items after head

putEncoderBuffer(kve)
return nil
}

Expand Down
52 changes: 52 additions & 0 deletions encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,58 @@ func TestMarshalStruct(t *testing.T) {
t.Errorf("Unmarshal() = %v, want %v", v2, unmarshalWant)
}
}

// TestMarshalStructVariableLength tests marshaling structs that can encode to CBOR maps of varying
// size depending on their field contents.
func TestMarshalStructVariableLength(t *testing.T) {
for _, tc := range []struct {
name string
in interface{}
want []byte
}{
{
name: "zero out of one items",
in: struct {
F int `cbor:",omitempty"`
}{},
want: hexDecode("a0"),
},
{
name: "one out of one items",
in: struct {
F int `cbor:",omitempty"`
}{F: 1},
want: hexDecode("a1614601"),
},
{
name: "23 out of 24 items",
in: struct {
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W int
X int `cbor:",omitempty"`
}{},
want: hexDecode("b7614100614200614300614400614500614600614700614800614900614a00614b00614c00614d00614e00614f00615000615100615200615300615400615500615600615700"),
},
{
name: "24 out of 24 items",
in: struct {
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W int
X int `cbor:",omitempty"`
}{X: 1},
want: hexDecode("b818614100614200614300614400614500614600614700614800614900614a00614b00614c00614d00614e00614f00615000615100615200615300615400615500615600615700615801"),
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := Marshal(tc.in)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(tc.want, got) {
t.Errorf("want 0x%x but got 0x%x", tc.want, got)
}
})
}
}

func TestMarshalStructCanonical(t *testing.T) {
v := outer{
IntField: 123,
Expand Down

0 comments on commit 2a344fa

Please sign in to comment.