Skip to content

Commit d6bf08d

Browse files
committed
GODRIVER-4018 Reduce allocations when building write batches
Reserve the output buffer once before appending documents in AppendBatchSequence and AppendBatchArray, instead of letting the per-document append grow it by repeated reallocation. A first pass computes how many documents fit within the maxCount/totalSize limits and their combined size; dst is grown a single time, then the documents are appended. When dst already has capacity the reservation is a no-op. AppendBatchArray additionally wrote each element key with strconv.Itoa, allocating a string per document; it now writes the numeric key in place with strconv.AppendInt via a helper that composes the same bsoncore primitives, producing byte-identical output. Behavior is unchanged: same batch count, byte-identical output, same maxCount/totalSize handling, and the same empty-result early return. Benchmark (1000 x 256-byte docs, fresh buffer): AppendBatchSequence: 24 -> 3 allocs/op, 1.19MB -> 262KB, ~130us -> ~29us AppendBatchArray: 925 -> 3 allocs/op, 1.19MB -> 262KB, ~166us -> ~38us
1 parent a0ff161 commit d6bf08d

2 files changed

Lines changed: 225 additions & 63 deletions

File tree

x/mongo/driver/batches.go

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ var _ OperationBatches = &Batches{}
2828

2929
// AppendBatchSequence appends dst with document sequence of batches as long as the limits of max count, max
3030
// document size, or total size allows. It returns the number of batches appended, the new appended slice, and
31-
// any error raised. It returns the origenal input slice if nothing can be appends within the limits.
31+
// any error raised. It returns the original input slice if nothing can be appended within the limits.
3232
func (b *Batches) AppendBatchSequence(dst []byte, maxCount, totalSize int) (int, []byte, error) {
3333
if b.Size() == 0 {
3434
return 0, dst, io.EOF
@@ -39,53 +39,79 @@ func (b *Batches) AppendBatchSequence(dst []byte, maxCount, totalSize int) (int,
3939
idx, dst = bsoncore.ReserveLength(dst)
4040
dst = append(dst, b.Identifier...)
4141
dst = append(dst, 0x00)
42+
43+
// First pass: total the documents that fit within the limits so dst can be
44+
// grown once instead of reallocating on each append.
4245
size := len(dst)
43-
var n int
44-
for i := b.offset; i < len(b.Documents); i++ {
45-
if n == maxCount {
46+
n, docsSize := 0, 0
47+
for n < maxCount && b.offset+n < len(b.Documents) {
48+
doc := b.Documents[b.offset+n]
49+
if size+len(doc) > totalSize {
4650
break
4751
}
48-
doc := b.Documents[i]
4952
size += len(doc)
50-
if size > totalSize {
51-
break
52-
}
53-
dst = append(dst, doc...)
53+
docsSize += len(doc)
5454
n++
5555
}
5656
if n == 0 {
5757
return 0, dst[:l], nil
5858
}
59+
60+
// Reserve once; no-op when dst already has room.
61+
if cap(dst) < len(dst)+docsSize {
62+
dst = append(make([]byte, 0, len(dst)+docsSize), dst...)
63+
}
64+
for _, doc := range b.Documents[b.offset : b.offset+n] {
65+
dst = append(dst, doc...)
66+
}
67+
5968
dst = bsoncore.UpdateLength(dst, idx, int32(len(dst[idx:])))
6069
return n, dst, nil
6170
}
6271

6372
// AppendBatchArray appends dst with array of batches as long as the limits of max count, max document size, or
6473
// total size allows. It returns the number of batches appended, the new appended slice, and any error raised. It
65-
// returns the origenal input slice if nothing can be appends within the limits.
74+
// returns the original input slice if nothing can be appended within the limits.
6675
func (b *Batches) AppendBatchArray(dst []byte, maxCount, totalSize int) (int, []byte, error) {
6776
if b.Size() == 0 {
6877
return 0, dst, io.EOF
6978
}
7079
l := len(dst)
7180
aidx, dst := bsoncore.AppendArrayElementStart(dst, b.Identifier)
81+
82+
// First pass: total the bytes the elements that fit will need so dst can be
83+
// grown once. Each element is a type byte, the decimal index key, a null
84+
// terminator and the document; keyLen is the current index's digit count,
85+
// bumped as n crosses each power of ten.
7286
size := len(dst)
73-
var n int
74-
for i := b.offset; i < len(b.Documents); i++ {
75-
if n == maxCount {
87+
n, appendSize := 0, 0
88+
keyLen, nextPow10 := 1, 10
89+
for n < maxCount && b.offset+n < len(b.Documents) {
90+
doc := b.Documents[b.offset+n]
91+
if size+len(doc) > totalSize {
7692
break
7793
}
78-
doc := b.Documents[i]
7994
size += len(doc)
80-
if size > totalSize {
81-
break
95+
if n == nextPow10 {
96+
keyLen++
97+
nextPow10 *= 10
8298
}
83-
dst = bsoncore.AppendDocumentElement(dst, strconv.Itoa(n), doc)
99+
appendSize += 2 + keyLen + len(doc)
84100
n++
85101
}
86102
if n == 0 {
87103
return 0, dst[:l], nil
88104
}
105+
appendSize++ // closing byte written by AppendArrayEnd
106+
107+
// Reserve once; no-op when dst already has room.
108+
if cap(dst) < len(dst)+appendSize {
109+
dst = append(make([]byte, 0, len(dst)+appendSize), dst...)
110+
}
111+
for j := 0; j < n; j++ {
112+
dst = appendDocumentElementInt(dst, j, b.Documents[b.offset+j])
113+
}
114+
89115
var err error
90116
dst, err = bsoncore.AppendArrayEnd(dst, aidx)
91117
if err != nil {
@@ -94,6 +120,15 @@ func (b *Batches) AppendBatchArray(dst []byte, maxCount, totalSize int) (int, []
94120
return n, dst, nil
95121
}
96122

123+
// appendDocumentElementInt is bsoncore.AppendDocumentElement with an integer
124+
// key, formatting the key in place to avoid allocating a string per element.
125+
func appendDocumentElementInt(dst []byte, key int, doc []byte) []byte {
126+
dst = bsoncore.AppendType(dst, bsoncore.TypeEmbeddedDocument)
127+
dst = strconv.AppendInt(dst, int64(key), 10)
128+
dst = append(dst, 0x00)
129+
return bsoncore.AppendDocument(dst, doc)
130+
}
131+
97132
// IsOrdered indicates if the batches are ordered.
98133
func (b *Batches) IsOrdered() *bool {
99134
return b.Ordered

x/mongo/driver/batches_test.go

Lines changed: 173 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,69 +7,196 @@
77
package driver
88

99
import (
10+
"strconv"
1011
"testing"
1112

1213
"go.mongodb.org/mongo-driver/v2/internal/assert"
1314
"go.mongodb.org/mongo-driver/v2/x/bsonx/bsoncore"
1415
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/wiremessage"
1516
)
1617

17-
func newTestBatches(t *testing.T) *Batches {
18-
t.Helper()
19-
return &Batches{
20-
Identifier: "foobar",
21-
Documents: []bsoncore.Document{
22-
[]byte("Lorem ipsum dolor sit amet"),
23-
[]byte("consectetur adipiscing elit"),
24-
},
18+
const testIdentifier = "foobar"
19+
20+
var testDocs = [][]byte{
21+
[]byte("Lorem ipsum dolor sit amet"),
22+
[]byte("consectetur adipiscing elit"),
23+
}
24+
25+
// newBatches builds a Batches with the test identifier from docs.
26+
func newBatches(docs ...[]byte) *Batches {
27+
b := &Batches{Identifier: testIdentifier}
28+
for _, doc := range docs {
29+
b.Documents = append(b.Documents, bsoncore.Document(doc))
30+
}
31+
return b
32+
}
33+
34+
// wantSequence builds the expected AppendBatchSequence output: prefix followed by
35+
// a document sequence section holding docs.
36+
func wantSequence(prefix []byte, docs [][]byte) []byte {
37+
dst := append([]byte(nil), prefix...)
38+
dst = wiremessage.AppendMsgSectionType(dst, wiremessage.DocumentSequence)
39+
idx, dst := bsoncore.ReserveLength(dst)
40+
dst = append(dst, testIdentifier...)
41+
dst = append(dst, 0x00)
42+
for _, doc := range docs {
43+
dst = append(dst, doc...)
2544
}
45+
return bsoncore.UpdateLength(dst, idx, int32(len(dst[idx:])))
46+
}
47+
48+
// wantArray builds the expected AppendBatchArray output using the straightforward
49+
// bsoncore.AppendDocumentElement / strconv.Itoa construction. Comparing against it
50+
// also verifies AppendBatchArray's in-place integer-key encoding.
51+
func wantArray(prefix []byte, docs [][]byte) []byte {
52+
dst := append([]byte(nil), prefix...)
53+
idx, dst := bsoncore.AppendArrayElementStart(dst, testIdentifier)
54+
for i, doc := range docs {
55+
dst = bsoncore.AppendDocumentElement(dst, strconv.Itoa(i), doc)
56+
}
57+
dst, _ = bsoncore.AppendArrayEnd(dst, idx)
58+
return dst
59+
}
60+
61+
// benchBatches builds a Batches of numDocs identical documents of docSize bytes.
62+
func benchBatches(numDocs, docSize int) *Batches {
63+
b := &Batches{Identifier: "documents"}
64+
doc := bsoncore.Document(make([]byte, docSize))
65+
for i := 0; i < numDocs; i++ {
66+
b.Documents = append(b.Documents, doc)
67+
}
68+
return b
2669
}
2770

2871
func TestAdvancing(t *testing.T) {
29-
batches := newTestBatches(t)
72+
batches := newBatches(testDocs...)
3073
batches.AdvanceBatches(3)
3174
size := batches.Size()
32-
assert.Equal(t, 0, size, "expected Size(): %d, got: %d", 1, size)
75+
assert.Equal(t, 0, size, "expected Size(): %d, got: %d", 0, size)
3376
}
3477

3578
func TestAppendBatchSequence(t *testing.T) {
36-
batches := newTestBatches(t)
37-
38-
got := []byte{42}
39-
sizeLimit := len(batches.Documents[0]) + len(batches.Documents[1])
40-
var n int
41-
var err error
42-
n, got, err = batches.AppendBatchSequence(got, 2, sizeLimit)
43-
assert.NoError(t, err)
44-
assert.Equal(t, 1, n)
45-
46-
var idx int32
47-
dst := []byte{42}
48-
dst = wiremessage.AppendMsgSectionType(dst, wiremessage.DocumentSequence)
49-
idx, dst = bsoncore.ReserveLength(dst)
50-
dst = append(dst, "foobar"...)
51-
dst = append(dst, 0x00)
52-
dst = append(dst, "Lorem ipsum dolor sit amet"...)
53-
dst = bsoncore.UpdateLength(dst, idx, int32(len(dst[idx:])))
54-
assert.Equal(t, dst, got)
79+
tests := []struct {
80+
name string
81+
docs [][]byte
82+
advance int
83+
maxCount int
84+
sizeLimit int
85+
want [][]byte
86+
}{
87+
{
88+
name: "size limit fits only the first document",
89+
docs: testDocs,
90+
maxCount: 2,
91+
sizeLimit: len(testDocs[0]) + len(testDocs[1]),
92+
want: testDocs[:1],
93+
},
94+
{
95+
name: "all documents fit",
96+
docs: testDocs,
97+
maxCount: 10,
98+
sizeLimit: 16 * 1024 * 1024,
99+
want: testDocs,
100+
},
101+
{
102+
name: "offset skips leading documents",
103+
docs: testDocs,
104+
advance: 1,
105+
maxCount: 10,
106+
sizeLimit: 16 * 1024 * 1024,
107+
want: testDocs[1:],
108+
},
109+
}
110+
for _, tt := range tests {
111+
t.Run(tt.name, func(t *testing.T) {
112+
batches := newBatches(tt.docs...)
113+
batches.AdvanceBatches(tt.advance)
114+
115+
n, got, err := batches.AppendBatchSequence([]byte{42}, tt.maxCount, tt.sizeLimit)
116+
assert.NoError(t, err)
117+
assert.Equal(t, len(tt.want), n)
118+
assert.Equal(t, wantSequence([]byte{42}, tt.want), got)
119+
})
120+
}
55121
}
56122

57123
func TestAppendBatchArray(t *testing.T) {
58-
batches := newTestBatches(t)
59-
60-
got := []byte{42}
61-
sizeLimit := len(batches.Documents[0]) + len(batches.Documents[1])
62-
var n int
63-
var err error
64-
n, got, err = batches.AppendBatchArray(got, 2, sizeLimit)
65-
assert.NoError(t, err)
66-
assert.Equal(t, 1, n)
67-
68-
var idx int32
69-
dst := []byte{42}
70-
idx, dst = bsoncore.AppendArrayElementStart(dst, "foobar")
71-
dst = bsoncore.AppendDocumentElement(dst, "0", []byte("Lorem ipsum dolor sit amet"))
72-
dst, err = bsoncore.AppendArrayEnd(dst, idx)
73-
assert.NoError(t, err)
74-
assert.Equal(t, dst, got)
124+
manyDocs := make([][]byte, 150) // exercises multi-digit array keys
125+
for i := range manyDocs {
126+
manyDocs[i] = testDocs[0]
127+
}
128+
129+
tests := []struct {
130+
name string
131+
docs [][]byte
132+
advance int
133+
maxCount int
134+
sizeLimit int
135+
want [][]byte
136+
}{
137+
{
138+
name: "size limit fits only the first document",
139+
docs: testDocs,
140+
maxCount: 2,
141+
sizeLimit: len(testDocs[0]) + len(testDocs[1]),
142+
want: testDocs[:1],
143+
},
144+
{
145+
name: "all documents fit",
146+
docs: testDocs,
147+
maxCount: 10,
148+
sizeLimit: 16 * 1024 * 1024,
149+
want: testDocs,
150+
},
151+
{
152+
name: "offset skips leading documents",
153+
docs: testDocs,
154+
advance: 1,
155+
maxCount: 10,
156+
sizeLimit: 16 * 1024 * 1024,
157+
want: testDocs[1:],
158+
},
159+
{
160+
name: "many elements with multi-digit keys",
161+
docs: manyDocs,
162+
maxCount: len(manyDocs),
163+
sizeLimit: 16 * 1024 * 1024,
164+
want: manyDocs,
165+
},
166+
}
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
batches := newBatches(tt.docs...)
170+
batches.AdvanceBatches(tt.advance)
171+
172+
n, got, err := batches.AppendBatchArray([]byte{42}, tt.maxCount, tt.sizeLimit)
173+
assert.NoError(t, err)
174+
assert.Equal(t, len(tt.want), n)
175+
assert.Equal(t, wantArray([]byte{42}, tt.want), got)
176+
})
177+
}
178+
}
179+
180+
func BenchmarkAppendBatch(b *testing.B) {
181+
batches := benchBatches(1000, 256)
182+
183+
benchmarks := []struct {
184+
name string
185+
fn func(*Batches, []byte, int, int) (int, []byte, error)
186+
}{
187+
{"Sequence", (*Batches).AppendBatchSequence},
188+
{"Array", (*Batches).AppendBatchArray},
189+
}
190+
for _, bm := range benchmarks {
191+
b.Run(bm.name, func(b *testing.B) {
192+
b.ReportAllocs()
193+
for i := 0; i < b.N; i++ {
194+
batches.offset = 0
195+
// A nil buffer measures the growth a BulkWrite pays per operation.
196+
if _, _, err := bm.fn(batches, nil, len(batches.Documents), 16*1024*1024); err != nil {
197+
b.Fatal(err)
198+
}
199+
}
200+
})
201+
}
75202
}

0 commit comments

Comments
 (0)