diff --git a/apps/testapp/go.mod b/apps/testapp/go.mod index d7f462770..f581ce3a4 100644 --- a/apps/testapp/go.mod +++ b/apps/testapp/go.mod @@ -79,7 +79,7 @@ require ( github.com/googleapis/gax-go/v2 v2.20.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect - github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect diff --git a/apps/testapp/go.sum b/apps/testapp/go.sum index 94507fe44..2895f9598 100644 --- a/apps/testapp/go.sum +++ b/apps/testapp/go.sum @@ -650,8 +650,9 @@ github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyN github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index 3ddc90211..9de863a6c 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 []Tx as [][]byte without allocation. + // types.Tx is defined as []byte and has the same slice-header layout. + // 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..806be4868 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,13 @@ func (b *DefaultBatch) SaveBlockDataFromBytes(header *types.SignedHeader, header return nil } -// UpdateState updates the state in the batch +// UpdateState updates the state in the batch. 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..3677d7559 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,18 @@ 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. 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) + 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..e1a56d590 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,35 @@ 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.Reset() + 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 +120,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 +219,75 @@ 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.Reset() + 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 + + psi := pbSignerPool.Get().(*pb.Signer) + psi.Reset() + psh.Signer = psi + + if sh.Signer.PubKey == nil { + 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 + } + + pubKey, err := sh.Signer.MarshalledPubKey() if err != nil { + ph.Reset() + pbHeaderPool.Put(ph) + pv.Reset() + pbVersionPool.Put(pv) + psi.Reset() + pbSignerPool.Put(psi) + psh.Reset() + pbSignedHeaderPool.Put(psh) return nil, err } - return proto.Marshal(hp) + 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,7 +492,14 @@ 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[:], + } } return &pb.Data{ Metadata: mProto, @@ -362,6 +526,39 @@ 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.Reset() + 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. diff --git a/types/serialization_test.go b/types/serialization_test.go index 88c1fbc5f..777835189 100644 --- a/types/serialization_test.go +++ b/types/serialization_test.go @@ -323,6 +323,20 @@ func TestData_ToProtoFromProto_NilMetadataAndTxs(t *testing.T) { assert.Empty(t, protoMsg.Txs) } +func TestDataToProtoCopiesOuterTxSlice(t *testing.T) { + d := &Data{ + Metadata: &Metadata{ChainID: "c"}, + Txs: Txs{[]byte("tx1"), []byte("tx2")}, + } + + protoMsg := d.ToProto() + require.Len(t, protoMsg.Txs, 2) + + d.Txs[0] = []byte("updated") + assert.Equal(t, []byte("tx1"), protoMsg.Txs[0]) + assert.Equal(t, []byte("tx2"), protoMsg.Txs[1]) +} + // TestState_ToProtoFromProto_ZeroFields checks State ToProto/FromProto with all fields zeroed. func TestState_ToProtoFromProto_ZeroFields(t *testing.T) { s := &State{} @@ -335,6 +349,32 @@ func TestState_ToProtoFromProto_ZeroFields(t *testing.T) { assert.Equal(t, s, s2) } +func TestStateMarshalBinaryRoundTrip(t *testing.T) { + t.Parallel() + + state := State{ + Version: Version{Block: 11, App: 22}, + ChainID: "marshal-state", + InitialHeight: 3, + LastBlockHeight: 7, + LastBlockTime: time.Date(2024, 7, 8, 9, 10, 11, 12, time.UTC), + DAHeight: 13, + AppHash: []byte{1, 2, 3, 4}, + LastHeaderHash: []byte{5, 6, 7, 8}, + } + + bz, err := state.MarshalBinary() + require.NoError(t, err) + require.NotEmpty(t, bz) + + var pState pb.State + require.NoError(t, proto.Unmarshal(bz, &pState)) + + var decoded State + require.NoError(t, decoded.FromProto(&pState)) + assert.Equal(t, state, decoded) +} + // TestHeader_HashFields_NilAndEmpty checks Header ToProto/FromProto with nil and empty hash/byte slice fields. func TestHeader_HashFields_NilAndEmpty(t *testing.T) { h := &Header{} diff --git a/types/tx.go b/types/tx.go index d593f58c0..d2259c157 100644 --- a/types/tx.go +++ b/types/tx.go @@ -1,7 +1,16 @@ package types +import "unsafe" + // Tx represents transaction. type Tx []byte // Txs represents a slice of transactions. type Txs []Tx + +// Compile-time guard for the unsafe []Tx <-> [][]byte reinterpretation used in +// hot serialization paths. +var ( + _ [unsafe.Sizeof(Tx{}) - unsafe.Sizeof([]byte{})]struct{} + _ [unsafe.Sizeof([]byte{}) - unsafe.Sizeof(Tx{})]struct{} +)