separate JSON encoding from I/O, add fast-path for GetBlobs

This commit is contained in:
jonny rhea 2026-03-10 17:34:27 -05:00
parent 3688824db3
commit 0ce0151d89
5 changed files with 172 additions and 9 deletions

View file

@ -0,0 +1,109 @@
// Copyright 2026 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package engine
// estimateBlobAndProofV1Size returns a rough estimate of the JSON size for a BlobAndProofV1.
func estimateBlobAndProofV1Size(item *BlobAndProofV1) int {
if item == nil {
return 4
}
return len(item.Blob)*2 + len(item.Proof)*2 + 30
}
// marshalBlobAndProofV1 writes a BlobAndProofV1 as JSON and appends it to buf.
func marshalBlobAndProofV1(buf []byte, item *BlobAndProofV1) []byte {
if item == nil {
return append(buf, "null"...)
}
buf = append(buf, `{"blob":`...)
buf = writeHexBytes(buf, item.Blob)
buf = append(buf, `,"proof":`...)
buf = writeHexBytes(buf, item.Proof)
buf = append(buf, '}')
return buf
}
// estimateBlobAndProofV2Size returns a rough estimate of the JSON size for a BlobAndProofV2.
func estimateBlobAndProofV2Size(item *BlobAndProofV2) int {
if item == nil {
return 4
}
size := len(item.Blob)*2 + 30
for _, proof := range item.CellProofs {
size += len(proof)*2 + 6
}
return size
}
// marshalBlobAndProofV2 writes a BlobAndProofV2 as JSON and appends it to buf.
func marshalBlobAndProofV2(buf []byte, item *BlobAndProofV2) []byte {
if item == nil {
return append(buf, "null"...)
}
buf = append(buf, `{"blob":`...)
buf = writeHexBytes(buf, item.Blob)
buf = append(buf, `,"proofs":`...)
buf = marshalHexBytesArray(buf, item.CellProofs)
buf = append(buf, '}')
return buf
}
// MarshalJSON implements json.Marshaler.
func (list BlobAndProofListV1) MarshalJSON() ([]byte, error) {
// Estimate buffer size.
size := 2
for _, item := range list {
size += estimateBlobAndProofV1Size(item) + 1
}
buf := make([]byte, 0, size)
// Write the array elements to the buffer.
buf = append(buf, '[')
for i, item := range list {
if i > 0 {
buf = append(buf, ',')
}
buf = marshalBlobAndProofV1(buf, item)
}
buf = append(buf, ']')
return buf, nil
}
// MarshalJSON implements json.Marshaler.
func (list BlobAndProofListV2) MarshalJSON() ([]byte, error) {
// Estimate buffer size.
size := 2
for _, item := range list {
size += estimateBlobAndProofV2Size(item) + 1
}
buf := make([]byte, 0, size)
// Write the array elements to the buffer.
buf = append(buf, '[')
for i, item := range list {
if i > 0 {
buf = append(buf, ',')
}
buf = marshalBlobAndProofV2(buf, item)
}
buf = append(buf, ']')
return buf, nil
}

View file

@ -152,11 +152,19 @@ type BlobAndProofV1 struct {
Proof hexutil.Bytes `json:"proof"`
}
// BlobAndProofListV1 is a list of BlobAndProofV1 with a hand-rolled JSON marshaler
// that avoids the overhead of encoding/json for large blob payloads.
type BlobAndProofListV1 []*BlobAndProofV1
type BlobAndProofV2 struct {
Blob hexutil.Bytes `json:"blob"`
CellProofs []hexutil.Bytes `json:"proofs"` // proofs MUST contain exactly CELLS_PER_EXT_BLOB cell proofs.
}
// BlobAndProofListV2 is a list of BlobAndProofV2 with a hand-rolled JSON marshaler
// that avoids the overhead of encoding/json for large blob payloads.
type BlobAndProofListV2 []*BlobAndProofV2
// JSON type overrides for ExecutionPayloadEnvelope.
type executionPayloadEnvelopeMarshaling struct {
BlockValue *hexutil.Big

View file

@ -535,7 +535,7 @@ func (api *ConsensusAPI) getPayload(payloadID engine.PayloadID, full bool, versi
//
// Client software MAY return an array of all null entries if syncing or otherwise
// unable to serve blob pool data.
func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) ([]*engine.BlobAndProofV1, error) {
func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) (engine.BlobAndProofListV1, error) {
// Reject the request if Osaka has been activated.
// follow https://github.com/ethereum/execution-apis/blob/main/src/engine/osaka.md#cancun-api
head := api.eth.BlockChain().CurrentHeader()
@ -549,7 +549,7 @@ func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) ([]*engine.BlobAndProo
if err != nil {
return nil, engine.InvalidParams.With(err)
}
res := make([]*engine.BlobAndProofV1, len(hashes))
res := make(engine.BlobAndProofListV1, len(hashes))
for i := 0; i < len(blobs); i++ {
// Skip the non-existing blob
if blobs[i] == nil {
@ -588,7 +588,7 @@ func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) ([]*engine.BlobAndProo
//
// Client software MUST return null if syncing or otherwise unable to serve
// blob pool data.
func (api *ConsensusAPI) GetBlobsV2(hashes []common.Hash) ([]*engine.BlobAndProofV2, error) {
func (api *ConsensusAPI) GetBlobsV2(hashes []common.Hash) (engine.BlobAndProofListV2, error) {
head := api.eth.BlockChain().CurrentHeader()
if api.config().LatestFork(head.Time) < forks.Osaka {
return nil, nil
@ -599,7 +599,7 @@ func (api *ConsensusAPI) GetBlobsV2(hashes []common.Hash) ([]*engine.BlobAndProo
// GetBlobsV3 returns a set of blobs from the transaction pool. Same as
// GetBlobsV2, except will return partial responses in case there is a missing
// blob.
func (api *ConsensusAPI) GetBlobsV3(hashes []common.Hash) ([]*engine.BlobAndProofV2, error) {
func (api *ConsensusAPI) GetBlobsV3(hashes []common.Hash) (engine.BlobAndProofListV2, error) {
head := api.eth.BlockChain().CurrentHeader()
if api.config().LatestFork(head.Time) < forks.Osaka {
return nil, nil
@ -609,7 +609,7 @@ func (api *ConsensusAPI) GetBlobsV3(hashes []common.Hash) ([]*engine.BlobAndProo
// getBlobs returns all available blobs. In v2, partial responses are not allowed,
// while v3 supports partial responses.
func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) ([]*engine.BlobAndProofV2, error) {
func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) (engine.BlobAndProofListV2, error) {
if len(hashes) > 128 {
return nil, engine.TooLargeRequest.With(fmt.Errorf("requested blob count too large: %v", len(hashes)))
}
@ -629,7 +629,7 @@ func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) ([]*engine.Blob
return nil, engine.InvalidParams.With(err)
}
// Validate the blobs from the pool and assemble the response
res := make([]*engine.BlobAndProofV2, len(hashes))
res := make(engine.BlobAndProofListV2, len(hashes))
for i := range blobs {
// The blob has been evicted since the last AvailableBlobs call.
// Return null if partial response is not allowed.

View file

@ -691,3 +691,49 @@ func BenchmarkGetPayloadV5RPCServerOnly(b *testing.B) {
b.StopTimer()
b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op")
}
// BenchmarkGetBlobsV3RPCServerOnly benchmarks only the EL server-side cost of
// engine_getBlobsV3: method dispatch, JSON serialization, and wire encoding.
// Client-side decoding is excluded by writing to io.Discard.
func BenchmarkGetBlobsV3RPCServerOnly(b *testing.B) {
blobCount := 72
env := newBenchmarkBlobEnv(b, blobCount, 1, forkOsaka)
defer env.Close()
// Register the engine API on the running node's in-process RPC server.
rpcServer, err := env.node.RPCHandler()
if err != nil {
b.Fatalf("RPCHandler failed: %v", err)
}
rpcServer.RegisterName("engine", env.api)
// Verify the blobs are available via the direct API first.
result, err := env.api.GetBlobsV3(env.vhashes)
if err != nil {
b.Fatalf("GetBlobsV3 failed: %v", err)
}
if len(result) != blobCount {
b.Fatalf("expected %d blobs, got %d", blobCount, len(result))
}
b.Logf("blob count: %d", blobCount)
// Build the JSON-RPC request bytes once.
// Format the versioned hashes as a JSON array of hex strings.
var hashStrs []string
for _, h := range env.vhashes {
hashStrs = append(hashStrs, fmt.Sprintf(`"%s"`, h.Hex()))
}
reqJSON := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"engine_getBlobsV3","params":[[%s]]}`, strings.Join(hashStrs, ","))
b.ResetTimer()
for i := 0; i < b.N; i++ {
conn := discardConn{
Reader: strings.NewReader(reqJSON),
Writer: io.Discard,
}
codec := rpc.NewCodec(conn)
rpcServer.ServeCodec(codec, 0)
}
b.StopTimer()
b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op")
}

View file

@ -1961,7 +1961,7 @@ func TestGetBlobsV1(t *testing.T) {
// Fill the request for retrieving blobs
var (
vhashes []common.Hash
expect []*engine.BlobAndProofV1
expect engine.BlobAndProofListV1
)
// fill missing blob at the beginning
if suite.fillRandom {
@ -2072,13 +2072,13 @@ func BenchmarkGetBlobsV2(b *testing.B) {
}
}
type getBlobsFn func(hashes []common.Hash) ([]*engine.BlobAndProofV2, error)
type getBlobsFn func(hashes []common.Hash) (engine.BlobAndProofListV2, error)
func runGetBlobs(t testing.TB, getBlobs getBlobsFn, start, limit int, fillRandom bool, expectPartialResponse bool, name string) {
// Fill the request for retrieving blobs
var (
vhashes []common.Hash
expect []*engine.BlobAndProofV2
expect engine.BlobAndProofListV2
)
for j := start; j < limit; j++ {
vhashes = append(vhashes, testBlobVHashes[j])