Skip to content

Commit 25892d7

Browse files
perf: use autoresearch to reduce allocations (#3225)
* Baseline - current block production performance with 100 txs Result: {"status":"keep","allocs_per_op":81,"bytes_per_op":25934,"ns_per_op":34001} * sync.Pool for sha256.Hash in leafHashOpt — eliminates 2 sha256.New() allocations per block Result: {"status":"keep","allocs_per_op":79,"bytes_per_op":25697,"ns_per_op":34147} * Unsafe reinterpret cast of Txs to [][]byte in ApplyBlock — eliminates make([][]byte, n) allocation Result: {"status":"keep","allocs_per_op":78,"bytes_per_op":22996,"ns_per_op":33091} * Direct pb.Data serialization in DACommitment — avoids pruned Data wrapper and txsToByteSlices allocations Result: {"status":"keep","allocs_per_op":77,"bytes_per_op":20276,"ns_per_op":32480} * unsafe.Slice in Data.ToProto() — eliminates txsToByteSlices [][]byte allocation Result: {"status":"keep","allocs_per_op":74,"bytes_per_op":12192,"ns_per_op":31624} * sync.Pool for protobuf message structs in MarshalBinary — eliminates 10 allocs per block Replace per-call allocation of pb.Header/pb.Version/pb.Data/pb.Metadata with sync.Pool reuse in the hot MarshalBinary path. ToProto() API is unchanged — only MarshalBinary is affected since it consumes the result immediately. Metrics (100_txs benchmark): - 74 → 64 allocs/op (-13.5%) - ~12.1 → ~11.1 KB (-8.3%) - ~31ns flat * pool SignedHeader.MarshalBinary — reuse pb.SignedHeader/pb.Header/pb.Signer/pb.Version Eliminates 4 struct allocations per signed header marshal: pb.SignedHeader, pb.Header, pb.Version, pb.Signer. These are now borrowed from sync.Pool and returned after proto.Marshal completes. Metrics (100_txs benchmark): - 64 → 56 allocs/op - ~11KB → ~10.2KB * pool State.MarshalBinary and use it in UpdateState — saves 2 allocs per block State.ToProto allocated pb.State + pb.Version + timestamppb.Timestamp per block. MarshalBinary now pools those structs and returns the marshaled bytes directly. pkg/store/batch.UpdateState switched from ToProto+proto.Marshal to MarshalBinary. * fix lint * remove files * remove extra comments * deps * comments * go fix * changes++ --------- Co-authored-by: Julien Robert <julien@rbrt.fr>
1 parent 50a73fb commit 25892d7

7 files changed

Lines changed: 311 additions & 27 deletions

File tree

block/internal/executing/executor.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sync"
1010
"sync/atomic"
1111
"time"
12+
"unsafe"
1213

1314
"github.com/ipfs/go-datastore"
1415
"github.com/libp2p/go-libp2p/core/crypto"
@@ -801,14 +802,12 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba
801802
func (e *Executor) ApplyBlock(ctx context.Context, header types.Header, data *types.Data) (types.State, error) {
802803
currentState := e.getLastState()
803804

804-
// Convert Txs to [][]byte for the execution client.
805-
// types.Tx is []byte, so this is a type conversion, not a copy.
805+
// Reinterpret []Tx as [][]byte without allocation.
806+
// types.Tx is defined as []byte and has the same slice-header layout.
807+
// Using unsafe.Slice/unsafe.SliceData avoids the heap allocation of make([][]byte, n).
806808
var rawTxs [][]byte
807809
if n := len(data.Txs); n > 0 {
808-
rawTxs = make([][]byte, n)
809-
for i, tx := range data.Txs {
810-
rawTxs[i] = []byte(tx)
811-
}
810+
rawTxs = unsafe.Slice((*[]byte)(unsafe.SliceData(data.Txs)), n)
812811
}
813812

814813
// Execute transactions

pkg/store/batch.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77

88
ds "github.com/ipfs/go-datastore"
9-
"google.golang.org/protobuf/proto"
109

1110
"github.com/evstack/ev-node/types"
1211
)
@@ -84,18 +83,13 @@ func (b *DefaultBatch) SaveBlockDataFromBytes(header *types.SignedHeader, header
8483
return nil
8584
}
8685

87-
// UpdateState updates the state in the batch
86+
// UpdateState updates the state in the batch.
8887
func (b *DefaultBatch) UpdateState(state types.State) error {
89-
// Save the state at the height specified in the state itself
9088
height := state.LastBlockHeight
9189

92-
pbState, err := state.ToProto()
90+
data, err := state.MarshalBinary()
9391
if err != nil {
94-
return fmt.Errorf("failed to convert type state to protobuf type: %w", err)
95-
}
96-
data, err := proto.Marshal(pbState)
97-
if err != nil {
98-
return fmt.Errorf("failed to marshal state to protobuf: %w", err)
92+
return fmt.Errorf("failed to marshal state: %w", err)
9993
}
10094

10195
return b.batch.Put(b.ctx, ds.RawKey(getStateAtHeightKey(height)), data)

types/hash_memo_bench_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package types
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// BenchmarkHeaderHash_NoMemo measures the cost of the old 3× call pattern with no
8+
// memoization: each call re-marshals every field via ToProto → proto.Marshal → sha256.
9+
func BenchmarkHeaderHash_NoMemo(b *testing.B) {
10+
h := GetRandomHeader("bench-chain", GetRandomBytes(32))
11+
b.ReportAllocs()
12+
b.ResetTimer()
13+
for b.Loop() {
14+
_ = h.Hash()
15+
_ = h.Hash()
16+
_ = h.Hash()
17+
}
18+
}
19+
20+
// BenchmarkHeaderHash_Memoized measures the cost of the same 3× call pattern after
21+
// explicit memoization: first call pays full cost, subsequent two are cache hits.
22+
func BenchmarkHeaderHash_Memoized(b *testing.B) {
23+
h := GetRandomHeader("bench-chain", GetRandomBytes(32))
24+
b.ReportAllocs()
25+
b.ResetTimer()
26+
for b.Loop() {
27+
h.InvalidateHash()
28+
_ = h.MemoizeHash() // compute and store
29+
_ = h.Hash() // cache hit
30+
_ = h.Hash() // cache hit
31+
}
32+
}
33+
34+
// BenchmarkHeaderHash_Single is a baseline: cost of one Hash() call with a cold cache.
35+
func BenchmarkHeaderHash_Single(b *testing.B) {
36+
h := GetRandomHeader("bench-chain", GetRandomBytes(32))
37+
b.ReportAllocs()
38+
b.ResetTimer()
39+
for b.Loop() {
40+
_ = h.Hash()
41+
}
42+
}

types/hashing.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,25 @@ import (
44
"crypto/sha256"
55
"errors"
66
"hash"
7+
"sync"
8+
"unsafe"
9+
10+
"google.golang.org/protobuf/proto"
11+
12+
pb "github.com/evstack/ev-node/types/pb/evnode/v1"
713
)
814

915
var (
1016
leafPrefix = []byte{0}
17+
18+
// sha256Pool reuses sha256 Hash instances to avoid per-block allocation.
19+
// sha256.New() allocates ~213 bytes (216B on 64-bit) per call. Pooling
20+
// eliminates this allocation entirely in the hot path.
21+
sha256Pool = sync.Pool{
22+
New: func() any {
23+
return sha256.New()
24+
},
25+
}
1126
)
1227

1328
// HashSlim returns the SHA256 hash of the header using the slim (current) binary encoding.
@@ -105,17 +120,21 @@ func (d *Data) Hash() Hash {
105120
// Ignoring the marshal error for now to satisfy the go-header interface
106121
// Later on the usage of Hash should be replaced with DA commitment
107122
dBytes, _ := d.MarshalBinary()
108-
return leafHashOpt(sha256.New(), dBytes)
123+
s := sha256Pool.Get().(hash.Hash)
124+
defer sha256Pool.Put(s)
125+
return leafHashOpt(s, dBytes)
109126
}
110127

111-
// DACommitment returns the DA commitment of the Data excluding the Metadata
128+
// DACommitment returns the DA commitment of the Data excluding the Metadata.
112129
func (d *Data) DACommitment() Hash {
113-
// Prune the Data to only include the Txs
114-
prunedData := &Data{
115-
Txs: d.Txs,
130+
var pbData pb.Data
131+
if d.Txs != nil {
132+
pbData.Txs = unsafe.Slice((*[]byte)(unsafe.SliceData(d.Txs)), len(d.Txs))
116133
}
117-
dBytes, _ := prunedData.MarshalBinary()
118-
return leafHashOpt(sha256.New(), dBytes)
134+
dBytes, _ := proto.Marshal(&pbData)
135+
s := sha256Pool.Get().(hash.Hash)
136+
defer sha256Pool.Put(s)
137+
return leafHashOpt(s, dBytes)
119138
}
120139

121140
func leafHashOpt(s hash.Hash, leaf []byte) []byte {

0 commit comments

Comments
 (0)