Skip to content

Commit 3b06f69

Browse files
authored
perf(kapp): zero-copy GetAndClearReturnData + pre-size receipt filters (#45)
* perf(kapp): zero-copy GetAndClearReturnData + pre-size receipt filters Two related allocation reductions in core/kapp/context.go. 1. GetAndClearReturnData: move semantics instead of deep copy The function deep-copied k.returnData into a fresh slice and then reset k.returnData to empty. Both call sites (blockChainHook.ProcessBuiltInFunction and the kvm world wrapper) assign the result straight into vmOutput.ReturnData and never touch the context's slice afterward, so the deep copy was guarding against an aliasing scenario that cannot occur: - The context's slice is reset on the same call. - SetReturnData and AddReturnData both allocate fresh storage on every write, so the context never re-aliases the returned slice. Replace the deep copy with ownership transfer. Keep the original non-nil-empty invariant for both the returned slice and the field: VMOutputApi carries a json:"returnData" tag (nil renders as null, [] renders as []), so downstream API consumers can distinguish. Get cost drops to O(1) regardless of payload shape. Bench (Apple M4 Max, darwin/arm64), Get cost (combined - Set): shape before after allocs (before -> after) 1 x 32 B 22.5 ns ~0 2 -> 0 5 x 32 B 75.3 ns ~0 6 -> 0 10 x 64 B 175 ns ~0 11 -> 0 50 x 256 B 1686 ns ~0 51 -> 0 1 x 4096 B 434 ns ~0 2 -> 0 The savings scale with return-data payload size. Hot for SC TX paths that emit many or large return values (view functions, batch operations, mint/transfer arrays). SetReturnData and AddReturnData still deep-copy on input. Their defensive copies guard against caller-side mutation of the source slice and were left unchanged: the gain from removing them is smaller than the risk of a future caller silently aliasing. 2. ReceiptSlice.GetByType / GetPreserved: pre-size filtered slice Both started with `var filtered []*X`, so the first append allocated and subsequent grows reallocated (log2(N) backing arrays). Pre-size capacity to len(*r) so the filter is bounded to one allocation regardless of match count. Worst case == prior memory; best case == fewer allocs. No nil-vs-empty caller sensitivity (all callers use len() or range). Adds three unit tests (none existed for the returnData lifecycle): - TestKappContext_ReturnData_RoundTrip exercises Set -> Get -> empty -> Add -> Get on a single context. - TestKappContext_ReturnData_GetNeverReturnsNil pins the JSON-visible invariant that Get always returns a non-nil slice. - TestKappContext_ReturnData_GetIsolatesFromFutureWrites pins the invariant that the slice handed to the caller is never mutated by subsequent context writes (guards the move-semantics optimization against future regressions). Adds context_returndata_bench_test.go with both Get and Set isolated across realistic SC return shapes. * chore(kapp): remove unused benchmark helper newCtxWithReturnData was introduced for benchmark setup but ended up unused once both benchmarks constructed their contexts inline. Flagged by golangci-lint (unused) in PR #45 review.
1 parent d695ec5 commit 3b06f69

3 files changed

Lines changed: 198 additions & 13 deletions

File tree

core/kapp/context.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func (r *ReceiptSlice) Get() []*transaction.Transaction_Receipt {
4646
}
4747

4848
func (r *ReceiptSlice) GetByType(receiptType int8) []*transaction.Transaction_Receipt {
49-
var filtered []*transaction.Transaction_Receipt
49+
filtered := make([]*transaction.Transaction_Receipt, 0, len(*r))
5050
for _, receipt := range *r {
5151
if len(receipt.Data) > 0 && len(receipt.Data[0]) > 0 && int8(receipt.Data[0][0]) == receiptType {
5252
filtered = append(filtered, receipt)
@@ -56,7 +56,7 @@ func (r *ReceiptSlice) GetByType(receiptType int8) []*transaction.Transaction_Re
5656
}
5757

5858
func (r *ReceiptSlice) GetPreserved() []*transaction.Transaction_Receipt {
59-
var filtered []*transaction.Transaction_Receipt
59+
filtered := make([]*transaction.Transaction_Receipt, 0, len(*r))
6060
for _, receipt := range *r {
6161
if len(receipt.Data) > 0 && len(receipt.Data[0]) > 0 && receipt.Data[0][0] >= SystemReceiptTypeStart {
6262
filtered = append(filtered, receipt)
@@ -150,19 +150,22 @@ func (k *kappContext) AddReturnData(data []byte) {
150150
}
151151

152152
func (k *kappContext) GetAndClearReturnData() [][]byte {
153-
// Create a new outer slice with the same length as src
154-
dst := make([][]byte, len(k.returnData))
155-
156-
// Iterate over each inner slice
157-
for i, s := range k.returnData {
158-
// Create a new inner slice with the same length as s
159-
dst[i] = make([]byte, len(s))
160-
// Copy the bytes from s to dst[i]
161-
copy(dst[i], s)
153+
// Move semantics: ownership of returnData transfers to the caller and
154+
// the context resets to a fresh empty slice. SetReturnData and
155+
// AddReturnData both allocate fresh storage on every write, so no
156+
// aliasing of the returned slice remains via this struct.
157+
//
158+
// Preserve the original guarantee that this method never returns nil
159+
// and that the field is non-nil-empty after the call: VMOutputApi
160+
// carries a json:"returnData" tag, where nil JSON-renders to null
161+
// while []byte{} renders to []. Downstream API consumers may
162+
// distinguish.
163+
out := k.returnData
164+
if out == nil {
165+
out = [][]byte{}
162166
}
163-
164167
k.returnData = make([][]byte, 0)
165-
return dst
168+
return out
166169
}
167170

168171
func (k *kappContext) GetExecData() []byte {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package kapp_test
2+
3+
import (
4+
"crypto/rand"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/klever-io/klever-go/core/kapp"
9+
"github.com/klever-io/klever-go/data/block"
10+
)
11+
12+
// genReturnData returns n byte slices of size bytes each, populated with
13+
// random data so the compiler can't constant-fold the copies away.
14+
func genReturnData(n, size int) [][]byte {
15+
out := make([][]byte, n)
16+
for i := range out {
17+
out[i] = make([]byte, size)
18+
_, _ = rand.Read(out[i])
19+
}
20+
return out
21+
}
22+
23+
// BenchmarkGetAndClearReturnData measures the per-call cost of pulling
24+
// return data out of the context. Sweep across realistic SC return shapes:
25+
//
26+
// - (1, 32) — single small item (typical: assetID, proposalID, orderID)
27+
// - (5, 32) — small array of small items (typical: minted-token IDs)
28+
// - (10, 64) — moderate array of moderate items (e.g., transfer batch IDs)
29+
// - (50, 256) — large array (uncommon but plausible for view fns)
30+
// - (1, 4096) — single large item (e.g., serialized struct)
31+
func BenchmarkGetAndClearReturnData(b *testing.B) {
32+
cases := []struct{ n, size int }{
33+
{1, 32},
34+
{5, 32},
35+
{10, 64},
36+
{50, 256},
37+
{1, 4096},
38+
}
39+
for _, c := range cases {
40+
b.Run(fmt.Sprintf("n=%d/size=%d", c.n, c.size), func(b *testing.B) {
41+
// Refill the context every call so each iteration has data
42+
// to drain. We measure GetAndClearReturnData *plus* the refill
43+
// (SetReturnData), then subtract the refill cost separately.
44+
data := genReturnData(c.n, c.size)
45+
ctx := kapp.NewKappContext(kapp.ArgsNewKAppContext{
46+
OriginalSender: []byte("sender"),
47+
ContractID: 0,
48+
Block: &block.Block{},
49+
})
50+
51+
b.ReportAllocs()
52+
b.ResetTimer()
53+
for i := 0; i < b.N; i++ {
54+
ctx.SetReturnData(data)
55+
_ = ctx.GetAndClearReturnData()
56+
}
57+
})
58+
}
59+
}
60+
61+
// BenchmarkSetReturnData_Only isolates the refill cost so the
62+
// GetAndClearReturnData number above can be interpreted (subtract this
63+
// from the combined number to get the Get cost alone).
64+
func BenchmarkSetReturnData_Only(b *testing.B) {
65+
cases := []struct{ n, size int }{
66+
{1, 32},
67+
{5, 32},
68+
{10, 64},
69+
{50, 256},
70+
{1, 4096},
71+
}
72+
for _, c := range cases {
73+
b.Run(fmt.Sprintf("n=%d/size=%d", c.n, c.size), func(b *testing.B) {
74+
data := genReturnData(c.n, c.size)
75+
ctx := kapp.NewKappContext(kapp.ArgsNewKAppContext{
76+
OriginalSender: []byte("sender"),
77+
ContractID: 0,
78+
Block: &block.Block{},
79+
})
80+
81+
b.ReportAllocs()
82+
b.ResetTimer()
83+
for i := 0; i < b.N; i++ {
84+
ctx.SetReturnData(data)
85+
}
86+
})
87+
}
88+
}

core/kapp/context_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package kapp_test
22

33
import (
4+
"bytes"
45
"testing"
56
"time"
67

@@ -76,6 +77,99 @@ func TestKappContext_ExecutionTimeStorage(t *testing.T) {
7677
assert.Equal(t, executionTime, retrieved)
7778
}
7879

80+
// TestKappContext_ReturnData_RoundTrip exercises the returnData lifecycle:
81+
// SetReturnData -> GetAndClearReturnData empties the context, the returned
82+
// slice exposes the stored payload, and re-using the context after a Get
83+
// works (Add/Set on a freshly cleared context behave identically to a
84+
// brand-new context).
85+
func TestKappContext_ReturnData_RoundTrip(t *testing.T) {
86+
t.Parallel()
87+
88+
ctx := kapp.NewKappContext(kapp.ArgsNewKAppContext{
89+
OriginalSender: []byte("sender"),
90+
ContractID: 0,
91+
Block: &block.Block{},
92+
})
93+
94+
// initial Get on empty context returns an empty slice
95+
first := ctx.GetAndClearReturnData()
96+
assert.Empty(t, first, "fresh context has no return data")
97+
98+
payload := [][]byte{
99+
[]byte("alpha"),
100+
[]byte("beta"),
101+
[]byte("gamma"),
102+
}
103+
ctx.SetReturnData(payload)
104+
105+
out := ctx.GetAndClearReturnData()
106+
assert.Equal(t, payload, out, "Get returns the previously Set payload")
107+
108+
// context is empty after Get
109+
again := ctx.GetAndClearReturnData()
110+
assert.Empty(t, again, "context cleared after Get")
111+
112+
// reuse: Add after a Get works
113+
ctx.AddReturnData([]byte("delta"))
114+
ctx.AddReturnData([]byte("epsilon"))
115+
out2 := ctx.GetAndClearReturnData()
116+
assert.Equal(t, [][]byte{[]byte("delta"), []byte("epsilon")}, out2)
117+
}
118+
119+
// TestKappContext_ReturnData_GetNeverReturnsNil pins the invariant that
120+
// GetAndClearReturnData always returns a non-nil slice — matching the
121+
// original implementation's `make([][]byte, 0)` reset. VMOutputApi carries
122+
// a `json:"returnData"` tag, so a nil slice would JSON-render as null
123+
// while an empty slice renders as []; downstream API consumers can
124+
// distinguish.
125+
func TestKappContext_ReturnData_GetNeverReturnsNil(t *testing.T) {
126+
t.Parallel()
127+
128+
ctx := kapp.NewKappContext(kapp.ArgsNewKAppContext{
129+
OriginalSender: []byte("sender"),
130+
ContractID: 0,
131+
Block: &block.Block{},
132+
})
133+
134+
first := ctx.GetAndClearReturnData()
135+
assert.NotNil(t, first, "Get on a fresh context returns non-nil")
136+
assert.Empty(t, first)
137+
138+
ctx.SetReturnData([][]byte{[]byte("payload")})
139+
_ = ctx.GetAndClearReturnData() // drain
140+
141+
second := ctx.GetAndClearReturnData()
142+
assert.NotNil(t, second, "Get on a drained context returns non-nil")
143+
assert.Empty(t, second)
144+
}
145+
146+
// TestKappContext_ReturnData_GetIsolatesFromFutureWrites guards against a
147+
// regression of the move-semantics optimization: the slice returned to the
148+
// caller must not be observably mutated by subsequent Set/Add on the same
149+
// context (the context allocates fresh storage on each Set/Add, so the
150+
// returned slice keeps pointing at the prior payload).
151+
func TestKappContext_ReturnData_GetIsolatesFromFutureWrites(t *testing.T) {
152+
t.Parallel()
153+
154+
ctx := kapp.NewKappContext(kapp.ArgsNewKAppContext{
155+
OriginalSender: []byte("sender"),
156+
ContractID: 0,
157+
Block: &block.Block{},
158+
})
159+
160+
ctx.SetReturnData([][]byte{[]byte("first")})
161+
out := ctx.GetAndClearReturnData()
162+
163+
// Subsequent context writes must not bleed into the previously
164+
// returned slice.
165+
ctx.SetReturnData([][]byte{[]byte("second"), []byte("third")})
166+
ctx.AddReturnData([]byte("fourth"))
167+
168+
assert.Equal(t, 1, len(out), "previously returned slice length is stable")
169+
assert.True(t, bytes.Equal(out[0], []byte("first")),
170+
"previously returned bytes are stable")
171+
}
172+
79173
// TestReceiptSlice_GetByType tests filtering receipts by type
80174
func TestReceiptSlice_GetByType(t *testing.T) {
81175
t.Parallel()

0 commit comments

Comments
 (0)