diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index 3ddc90211..9477fea45 100644 --- a/block/internal/executing/executor.go +++ b/block/internal/executing/executor.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" "time" + "unsafe" "github.com/ipfs/go-datastore" "github.com/libp2p/go-libp2p/core/crypto" @@ -792,14 +793,12 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba func (e *Executor) ApplyBlock(ctx context.Context, header types.Header, data *types.Data) (types.State, error) { currentState := e.getLastState() - // Convert Txs to [][]byte for the execution client. - // types.Tx is []byte, so this is a type conversion, not a copy. + // Reinterpret Txs ([][]byte via type aliases) as [][]byte without allocation. + // types.Tx = []byte, so types.Txs = []Tx has identical memory layout to [][]byte. + // Using unsafe.Slice/unsafe.SliceData avoids the heap allocation of make([][]byte, n). var rawTxs [][]byte if n := len(data.Txs); n > 0 { - rawTxs = make([][]byte, n) - for i, tx := range data.Txs { - rawTxs[i] = []byte(tx) - } + rawTxs = unsafe.Slice((*[]byte)(unsafe.SliceData(data.Txs)), n) } // Execute transactions diff --git a/pkg/store/batch.go b/pkg/store/batch.go index 6222b85ba..b49ffaa7b 100644 --- a/pkg/store/batch.go +++ b/pkg/store/batch.go @@ -6,7 +6,6 @@ import ( "fmt" ds "github.com/ipfs/go-datastore" - "google.golang.org/protobuf/proto" "github.com/evstack/ev-node/types" ) @@ -84,18 +83,14 @@ func (b *DefaultBatch) SaveBlockDataFromBytes(header *types.SignedHeader, header return nil } -// UpdateState updates the state in the batch +// UpdateState updates the state in the batch. +// Uses pooled State.MarshalBinary to reduce per-block allocations. func (b *DefaultBatch) UpdateState(state types.State) error { - // Save the state at the height specified in the state itself height := state.LastBlockHeight - pbState, err := state.ToProto() + data, err := state.MarshalBinary() if err != nil { - return fmt.Errorf("failed to convert type state to protobuf type: %w", err) - } - data, err := proto.Marshal(pbState) - if err != nil { - return fmt.Errorf("failed to marshal state to protobuf: %w", err) + return fmt.Errorf("failed to marshal state: %w", err) } return b.batch.Put(b.ctx, ds.RawKey(getStateAtHeightKey(height)), data) diff --git a/types/hash_memo_bench_test.go b/types/hash_memo_bench_test.go new file mode 100644 index 000000000..6ac3d6358 --- /dev/null +++ b/types/hash_memo_bench_test.go @@ -0,0 +1,42 @@ +package types + +import ( + "testing" +) + +// BenchmarkHeaderHash_NoMemo measures the cost of the old 3× call pattern with no +// memoization: each call re-marshals every field via ToProto → proto.Marshal → sha256. +func BenchmarkHeaderHash_NoMemo(b *testing.B) { + h := GetRandomHeader("bench-chain", GetRandomBytes(32)) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = h.Hash() + _ = h.Hash() + _ = h.Hash() + } +} + +// BenchmarkHeaderHash_Memoized measures the cost of the same 3× call pattern after +// explicit memoization: first call pays full cost, subsequent two are cache hits. +func BenchmarkHeaderHash_Memoized(b *testing.B) { + h := GetRandomHeader("bench-chain", GetRandomBytes(32)) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + h.InvalidateHash() + _ = h.MemoizeHash() // compute and store + _ = h.Hash() // cache hit + _ = h.Hash() // cache hit + } +} + +// BenchmarkHeaderHash_Single is a baseline: cost of one Hash() call with a cold cache. +func BenchmarkHeaderHash_Single(b *testing.B) { + h := GetRandomHeader("bench-chain", GetRandomBytes(32)) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = h.Hash() + } +} diff --git a/types/hashing.go b/types/hashing.go index 60abc6259..6eae5ad06 100644 --- a/types/hashing.go +++ b/types/hashing.go @@ -4,10 +4,25 @@ import ( "crypto/sha256" "errors" "hash" + "sync" + "unsafe" + + "google.golang.org/protobuf/proto" + + pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) var ( leafPrefix = []byte{0} + + // sha256Pool reuses sha256 Hash instances to avoid per-block allocation. + // sha256.New() allocates ~213 bytes (216B on 64-bit) per call. Pooling + // eliminates this allocation entirely in the hot path. + sha256Pool = sync.Pool{ + New: func() interface{} { + return sha256.New() + }, + } ) // HashSlim returns the SHA256 hash of the header using the slim (current) binary encoding. @@ -105,17 +120,23 @@ func (d *Data) Hash() Hash { // Ignoring the marshal error for now to satisfy the go-header interface // Later on the usage of Hash should be replaced with DA commitment dBytes, _ := d.MarshalBinary() - return leafHashOpt(sha256.New(), dBytes) + s := sha256Pool.Get().(hash.Hash) + defer sha256Pool.Put(s) + return leafHashOpt(s, dBytes) } -// DACommitment returns the DA commitment of the Data excluding the Metadata +// DACommitment returns the DA commitment of the Data excluding the Metadata. +// Avoids allocating a pruned Data struct and the [][]byte intermediate slice +// by serializing only the txs field directly to a protobuf message. func (d *Data) DACommitment() Hash { - // Prune the Data to only include the Txs - prunedData := &Data{ - Txs: d.Txs, - } - dBytes, _ := prunedData.MarshalBinary() - return leafHashOpt(sha256.New(), dBytes) + // pb.Data{Metadata: nil, Txs: ...} produces the same wire format as + // Data{Txs: d.Txs}.MarshalBinary() but without the intermediate Data + // wrapper allocation or the txsToByteSlices [][]byte copy. + pbData := pb.Data{Txs: unsafe.Slice((*[]byte)(unsafe.SliceData(d.Txs)), len(d.Txs))} + dBytes, _ := proto.Marshal(&pbData) + s := sha256Pool.Get().(hash.Hash) + defer sha256Pool.Put(s) + return leafHashOpt(s, dBytes) } func leafHashOpt(s hash.Hash, leaf []byte) []byte { diff --git a/types/serialization.go b/types/serialization.go index edc4f615b..6b2eba117 100644 --- a/types/serialization.go +++ b/types/serialization.go @@ -3,7 +3,9 @@ package types import ( "errors" "fmt" + "sync" "time" + "unsafe" "github.com/libp2p/go-libp2p/core/crypto" "google.golang.org/protobuf/encoding/protowire" @@ -13,6 +15,46 @@ import ( pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) +// Proto object pools — avoid heap allocation of short-lived protobuf message +// structs in hot serialization paths (marshal → discard → repeat per block). +var ( + pbHeaderPool = sync.Pool{ + New: func() interface{} { + return &pb.Header{} + }, + } + pbVersionPool = sync.Pool{ + New: func() interface{} { + return &pb.Version{} + }, + } + pbDataPool = sync.Pool{ + New: func() interface{} { + return &pb.Data{} + }, + } + pbMetadataPool = sync.Pool{ + New: func() interface{} { + return &pb.Metadata{} + }, + } + pbSignerPool = sync.Pool{ + New: func() interface{} { + return &pb.Signer{} + }, + } + pbSignedHeaderPool = sync.Pool{ + New: func() interface{} { + return &pb.SignedHeader{} + }, + } + pbStatePool = sync.Pool{ + New: func() interface{} { + return &pb.State{} + }, + } +) + // MarshalBinary encodes Metadata into binary form and returns it. func (m *Metadata) MarshalBinary() ([]byte, error) { return proto.Marshal(m.ToProto()) @@ -29,8 +71,34 @@ func (m *Metadata) UnmarshalBinary(metadata []byte) error { } // MarshalBinary encodes Header into binary form and returns it. +// Uses a pooled pb.Header proto message to avoid allocation. func (h *Header) MarshalBinary() ([]byte, error) { - return proto.Marshal(h.ToProto()) + ph := pbHeaderPool.Get().(*pb.Header) + + pv := pbVersionPool.Get().(*pb.Version) + pv.Block, pv.App = h.Version.Block, h.Version.App + + ph.Reset() + ph.Version = pv + ph.Height = h.BaseHeader.Height + ph.Time = h.BaseHeader.Time + ph.ChainId = h.BaseHeader.ChainID + ph.LastHeaderHash = h.LastHeaderHash + ph.DataHash = h.DataHash + ph.AppHash = h.AppHash + ph.ProposerAddress = h.ProposerAddress + ph.ValidatorHash = h.ValidatorHash + if unknown := encodeLegacyUnknownFields(h.Legacy); len(unknown) > 0 { + ph.ProtoReflect().SetUnknown(unknown) + } + + bz, err := proto.Marshal(ph) + + ph.Reset() + pbHeaderPool.Put(ph) + pv.Reset() + pbVersionPool.Put(pv) + return bz, err } // MarshalBinaryLegacy returns the legacy header encoding that includes the @@ -51,8 +119,33 @@ func (h *Header) UnmarshalBinary(data []byte) error { } // MarshalBinary encodes Data into binary form and returns it. +// Uses pooled protobuf messages to avoid per-block allocation. func (d *Data) MarshalBinary() ([]byte, error) { - return proto.Marshal(d.ToProto()) + pd := pbDataPool.Get().(*pb.Data) + pd.Reset() + + if d.Metadata != nil { + pm := pbMetadataPool.Get().(*pb.Metadata) + pm.Reset() + pm.ChainId = d.Metadata.ChainID + pm.Height = d.Metadata.Height + pm.Time = d.Metadata.Time + pm.LastDataHash = d.LastDataHash + pd.Metadata = pm + defer func() { + pm.Reset() + pbMetadataPool.Put(pm) + }() + } + + if d.Txs != nil { + pd.Txs = unsafe.Slice((*[]byte)(unsafe.SliceData(d.Txs)), len(d.Txs)) + } + + bz, err := proto.Marshal(pd) + pd.Reset() + pbDataPool.Put(pd) + return bz, err } // UnmarshalBinary decodes binary form of Data into object. @@ -125,12 +218,69 @@ func (sh *SignedHeader) FromProto(other *pb.SignedHeader) error { } // MarshalBinary encodes SignedHeader into binary form and returns it. +// Uses pooled protobuf messages to avoid per-block allocation. func (sh *SignedHeader) MarshalBinary() ([]byte, error) { - hp, err := sh.ToProto() + psh := pbSignedHeaderPool.Get().(*pb.SignedHeader) + psh.Reset() + + // Reuse pooled pb.Header + pb.Version for the nested header. + ph := pbHeaderPool.Get().(*pb.Header) + ph.Reset() + pv := pbVersionPool.Get().(*pb.Version) + pv.Block, pv.App = sh.Version.Block, sh.Version.App + ph.Version = pv + ph.Height = sh.BaseHeader.Height + ph.Time = sh.BaseHeader.Time + ph.ChainId = sh.BaseHeader.ChainID + ph.LastHeaderHash = sh.LastHeaderHash + ph.DataHash = sh.DataHash + ph.AppHash = sh.AppHash + ph.ProposerAddress = sh.ProposerAddress + ph.ValidatorHash = sh.ValidatorHash + if unknown := encodeLegacyUnknownFields(sh.Legacy); len(unknown) > 0 { + ph.ProtoReflect().SetUnknown(unknown) + } + psh.Header = ph + psh.Signature = sh.Signature + + if sh.Signer.PubKey == nil { + psh.Signer = &pb.Signer{} + bz, err := proto.Marshal(psh) + ph.Reset() + pbHeaderPool.Put(ph) + pv.Reset() + pbVersionPool.Put(pv) + psh.Reset() + pbSignedHeaderPool.Put(psh) + return bz, err + } + + pubKey, err := sh.Signer.MarshalledPubKey() if err != nil { + ph.Reset() + pbHeaderPool.Put(ph) + pv.Reset() + pbVersionPool.Put(pv) + psh.Reset() + pbSignedHeaderPool.Put(psh) return nil, err } - return proto.Marshal(hp) + psi := pbSignerPool.Get().(*pb.Signer) + psi.Reset() + psi.Address = sh.Signer.Address + psi.PubKey = pubKey + psh.Signer = psi + bz, err := proto.Marshal(psh) + + ph.Reset() + pbHeaderPool.Put(ph) + pv.Reset() + pbVersionPool.Put(pv) + psi.Reset() + pbSignerPool.Put(psi) + psh.Reset() + pbSignedHeaderPool.Put(psh) + return bz, err } // UnmarshalBinary decodes binary form of SignedHeader into object. @@ -335,11 +485,27 @@ func (m *Metadata) FromProto(other *pb.Metadata) error { func (d *Data) ToProto() *pb.Data { var mProto *pb.Metadata if d.Metadata != nil { - mProto = d.Metadata.ToProto() + // Inline Metadata.ToProto() to keep pb.Metadata allocation on the + // stack for small structs, and avoid the intermediate method frame. + mProto = &pb.Metadata{ + ChainId: d.Metadata.ChainID, + Height: d.Metadata.Height, + Time: d.Metadata.Time, + LastDataHash: d.LastDataHash[:], + } } + // Reinterpret Txs ([]Tx) as [][]byte without allocation. + // types.Tx = []byte, so []Tx and [][]byte share identical memory layout. + if d.Txs == nil { + return &pb.Data{ + Metadata: mProto, + Txs: nil, + } + } + txBytes := unsafe.Slice((*[]byte)(unsafe.SliceData(d.Txs)), len(d.Txs)) return &pb.Data{ Metadata: mProto, - Txs: txsToByteSlices(d.Txs), + Txs: txBytes, } } @@ -362,6 +528,38 @@ func (d *Data) FromProto(other *pb.Data) error { return nil } +// MarshalBinary encodes State into binary form using pooled protobuf messages +// to reduce per-block allocations in the UpdateState hot path. +func (s *State) MarshalBinary() ([]byte, error) { + ps := pbStatePool.Get().(*pb.State) + ps.Reset() + + pv := pbVersionPool.Get().(*pb.Version) + pv.Block, pv.App = s.Version.Block, s.Version.App + + pts := ×tamppb.Timestamp{ + Seconds: s.LastBlockTime.Unix(), + Nanos: int32(s.LastBlockTime.Nanosecond()), + } + + ps.Version = pv + ps.ChainId = s.ChainID + ps.InitialHeight = s.InitialHeight + ps.LastBlockHeight = s.LastBlockHeight + ps.LastBlockTime = pts + ps.DaHeight = s.DAHeight + ps.AppHash = s.AppHash + ps.LastHeaderHash = s.LastHeaderHash + + bz, err := proto.Marshal(ps) + + ps.Reset() + pbStatePool.Put(ps) + pv.Reset() + pbVersionPool.Put(pv) + return bz, err +} + // ToProto converts State into protobuf representation and returns it. func (s *State) ToProto() (*pb.State, error) { // Avoid timestamppb.New allocation by constructing inline.