From e2fe8aef5fa1173db0189248533befa82d869692 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Tue, 8 Sep 2026 15:44:26 +0000 Subject: [PATCH 1/3] [rfc6962] optimise SHA-256 tree hashing for interior nodes Avoid allocating a new sha256.digest object and dynamic slice buffers on every interior node hash when using crypto.SHA256. In HashChildren, the preimage size is strictly fixed by the RFC6962 specification (1 prefix byte + 2 * 32-byte hashes = 65 bytes). Using a fixed stack buffer and sha256.Sum256 directly avoids the sha256.digest allocation and slice resizing, dropping allocations to 1 (the returned slice), instead of the previous 3. Noted that there is a similar performance optimisation for HashLeaf, but this one requires an arbitrary threshold so has been left out of scope for this PR. Benchmarks (averaged over 5 runs): BenchmarkHashChildren: Before: 1282 ns/op, 240 B/op, 3 allocs/op After: 655 ns/op, 32 B/op, 1 allocs/op (-48.9% ns/op, -86.7% B/op, -66.7% allocs) compact.BenchmarkAppend (1024 leaves): Before: 2296773 ns/op, 414202 B/op, 7170 allocs/op After: 1331209 ns/op, 201417 B/op, 5124 allocs/op (-42.0% ns/op, -51.4% B/op, -28.5% allocs) --- rfc6962/rfc6962.go | 23 ++++++++++++++++++++++- rfc6962/rfc6962_test.go | 9 +++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/rfc6962/rfc6962.go b/rfc6962/rfc6962.go index b04f952e..4b50c611 100644 --- a/rfc6962/rfc6962.go +++ b/rfc6962/rfc6962.go @@ -16,8 +16,9 @@ package rfc6962 import ( + "bytes" "crypto" - _ "crypto/sha256" // SHA256 is the default algorithm. + "crypto/sha256" ) // Domain separation prefixes @@ -41,12 +42,18 @@ func New(h crypto.Hash) *Hasher { // EmptyRoot returns a special case for an empty tree. func (t *Hasher) EmptyRoot() []byte { + if t.Hash == crypto.SHA256 { + h := sha256.Sum256(nil) + return bytes.Clone(h[:]) + } return t.New().Sum(nil) } // HashLeaf returns the Merkle tree leaf hash of the data passed in through leaf. // The data in leaf is prefixed by the LeafHashPrefix. func (t *Hasher) HashLeaf(leaf []byte) []byte { + // Note: A SHA-256 fast path using sha256.Sum256 is possible here if leaf data is + // staged into an array on the stack, but requires an arbitrary buffer size threshold. h := t.New() h.Write([]byte{RFC6962LeafHashPrefix}) h.Write(leaf) @@ -56,6 +63,10 @@ func (t *Hasher) HashLeaf(leaf []byte) []byte { // HashChildren returns the inner Merkle tree node hash of the two child nodes l and r. // The hashed structure is NodeHashPrefix||l||r. func (t *Hasher) HashChildren(l, r []byte) []byte { + if t.Hash == crypto.SHA256 && len(l) == sha256.Size && len(r) == sha256.Size { + // Fast path for sha256 common case + return hashChildren256(l, r) + } h := t.New() b := append(append(append( make([]byte, 0, 1+len(l)+len(r)), @@ -66,3 +77,13 @@ func (t *Hasher) HashChildren(l, r []byte) []byte { h.Write(b) return h.Sum(nil) } + +// hashChildren256 avoids allocating a sha256.digest and preimage slice by using a fixed 65-byte stack buffer. +func hashChildren256(l, r []byte) []byte { + var b [1 + 2*sha256.Size]byte + b[0] = RFC6962NodeHashPrefix + copy(b[1:], l) + copy(b[1+sha256.Size:], r) + h := sha256.Sum256(b[:]) + return bytes.Clone(h[:]) +} diff --git a/rfc6962/rfc6962_test.go b/rfc6962/rfc6962_test.go index 015983c0..9d3d7a33 100644 --- a/rfc6962/rfc6962_test.go +++ b/rfc6962/rfc6962_test.go @@ -105,3 +105,12 @@ func BenchmarkHashChildren(b *testing.B) { _ = h.HashChildren(l, r) } } + +func BenchmarkHashLeaf(b *testing.B) { + h := DefaultHasher + data := []byte("benchmark leaf payload") + b.ResetTimer() + for range b.N { + _ = h.HashLeaf(data) + } +} From 6433b72cb0270ecb67ae4389b77161369d3d6a4a Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Wed, 9 Sep 2026 15:21:02 +0000 Subject: [PATCH 2/3] Remove bytes.Clone --- rfc6962/rfc6962.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rfc6962/rfc6962.go b/rfc6962/rfc6962.go index 4b50c611..ff8e9d0a 100644 --- a/rfc6962/rfc6962.go +++ b/rfc6962/rfc6962.go @@ -16,7 +16,6 @@ package rfc6962 import ( - "bytes" "crypto" "crypto/sha256" ) @@ -44,7 +43,7 @@ func New(h crypto.Hash) *Hasher { func (t *Hasher) EmptyRoot() []byte { if t.Hash == crypto.SHA256 { h := sha256.Sum256(nil) - return bytes.Clone(h[:]) + return h[:] } return t.New().Sum(nil) } @@ -78,12 +77,12 @@ func (t *Hasher) HashChildren(l, r []byte) []byte { return h.Sum(nil) } -// hashChildren256 avoids allocating a sha256.digest and preimage slice by using a fixed 65-byte stack buffer. +// hashChildren256 hashes the fixed 65-byte RFC6962 interior node preimage on the stack. func hashChildren256(l, r []byte) []byte { var b [1 + 2*sha256.Size]byte b[0] = RFC6962NodeHashPrefix copy(b[1:], l) copy(b[1+sha256.Size:], r) h := sha256.Sum256(b[:]) - return bytes.Clone(h[:]) + return h[:] } From 45886ec63a8eb7bf3c1462fef6a95ed51b60f857 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Wed, 9 Sep 2026 16:00:37 +0000 Subject: [PATCH 3/3] Longer comment about the cost of interface methods --- rfc6962/rfc6962.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rfc6962/rfc6962.go b/rfc6962/rfc6962.go index ff8e9d0a..ab109c7a 100644 --- a/rfc6962/rfc6962.go +++ b/rfc6962/rfc6962.go @@ -78,6 +78,12 @@ func (t *Hasher) HashChildren(l, r []byte) []byte { } // hashChildren256 hashes the fixed 65-byte RFC6962 interior node preimage on the stack. +// +// This optimization relies on using the concrete sha256.Sum256 function directly. +// Because the compiler has full visibility into Sum256, it can prove that b does +// not outlive the call, keeping b on the stack (1 allocation total for the returned slice). +// Passing b through an interface (e.g. t.New().Write(b)) would force b to escape to the +// heap because the compiler cannot prove an interface method won't retain it. func hashChildren256(l, r []byte) []byte { var b [1 + 2*sha256.Size]byte b[0] = RFC6962NodeHashPrefix