Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

trie: optimize memory allocation #30932

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

rjl493456442
Copy link
Member

@rjl493456442 rjl493456442 commented Dec 18, 2024

This pull request removes the node copy operation to reduce memory allocation. Key Changes as below:

(a) Use decodeNodeUnsafe for decoding nodes retrieved from the trie node reader

In the current implementation of the MPT, once a trie node blob is retrieved, it is passed to decodeNode for decoding. However, decodeNode assumes the supplied byte slice might be mutated later, so it performs a deep copy internally before parsing the node.

Given that the node reader is implemented by the path database and the hash database, both of which guarantee the immutability of the returned byte slice. By restricting the node reader interface to explicitly guarantee that the returned byte slice will not be modified, we can safely replace decodeNode with decodeNodeUnsafe. This eliminates the need for a redundant byte copy during each node resolution.

(b) Modify the trie in place

In the current implementation of the MPT, a copy of a trie node is created before any modifications are made. These modifications include:

  • Node resolution: Converting the value from a hash to the actual node.
  • Node hashing: Tagging the hash into its cache.
  • Node commit: Replacing the children with its hash.
  • Structural changes: For example, adding a new child to a fullNode or replacing a child of a shortNode.

This mechanism ensures that modifications only affect the live tree, leaving all previously created copies unaffected.

Unfortunately, this property leads to a huge memory allocation requirement. For example, if we want to modify the fullNode for n times, the node will be copied for n times.

In this pull request, all the trie modifications are made in place. In order to make sure all previously created copies are unaffected, the Copy function now will deep-copy all the live nodes rather than the root node itself.

With this change, while the Copy function becomes more expensive, it's totally acceptable as it's not a frequently used one. For the normal trie operations (Get, GetNode, Hash, Commit, Insert, Delete), the node copy is not required anymore.

@holiman
Copy link
Contributor

holiman commented Dec 18, 2024

Before I dive into the nitty gritty; can you say anything about what's done here?
Are there any features or guarantees, which existed previously, that you are now removing? Or is this purely an internal change which is not observable from the outside?

@rjl493456442
Copy link
Member Author

Some preliminary benchmark results:

  • Allocation and Free has been reduced a lot

  • CPU schedule delay is slightly lower (10us)

  • 20% less GC cycles
    image

  • Chain processing performance is same

image

截屏2024-12-19 10 32 50

@rjl493456442
Copy link
Member Author

rjl493456442 commented Dec 20, 2024

(base) ➜  geth-pprof-mpt-nocopy go tool pprof -base=eth-master.mem mpt-nocopy.mem
File: geth
Type: inuse_space
Time: Dec 20, 2024 at 2:29pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) alloc_space
(pprof) top
Showing nodes accounting for -14365.02GB, 16.51% of 87004.08GB total
Dropped 2764 nodes (cum <= 435.02GB)
Showing top 10 nodes out of 95
      flat  flat%   sum%        cum   cum%
-11816.47GB 13.58% 13.58% -11816.47GB 13.58%  github.com/ethereum/go-ethereum/trie.(*fullNode).copy (inline)
 3906.13GB  4.49%  9.09%  -311.90GB  0.36%  github.com/ethereum/go-ethereum/trie.(*hasher).hashFullNodeChildren
-3469.30GB  3.99% 13.08% -5393.55GB  6.20%  github.com/ethereum/go-ethereum/trie.(*committer).commitChildren
-3029.67GB  3.48% 16.56% -7264.79GB  8.35%  github.com/ethereum/go-ethereum/trie.decodeNode
 -402.60GB  0.46% 17.02%  -402.60GB  0.46%  github.com/ethereum/go-ethereum/trie.(*shortNode).copy (inline)
  138.77GB  0.16% 16.86%  -129.54GB  0.15%  github.com/ethereum/go-ethereum/trie.(*hasher).hashShortNodeChildren
  122.78GB  0.14% 16.72%   122.78GB  0.14%  github.com/ethereum/go-ethereum/rlp.writeRawValue
  117.28GB  0.13% 16.59%   117.28GB  0.13%  github.com/ethereum/go-ethereum/rlp.(*encBuffer).makeBytes (inline)
      60GB 0.069% 16.52%   116.35GB  0.13%  github.com/ethereum/go-ethereum/trie.decodeFull
    8.06GB 0.0093% 16.51%    -2802GB  3.22%  github.com/ethereum/go-ethereum/trie.(*Trie).insert
  • For decodeNode, this PR saves 3029.67GB allocation after running 41 hours.
  • For removing node copy, this PR saves 11782.24 GB allocation. Some allocations has been moved from fullNode.copy to hashFullNodeChildren, the net changes is 11782.24 GB

@rjl493456442
Copy link
Member Author

CI is failing runtime: out of memory: cannot allocate 79691776-byte block (3962241024 in use) fatal error: out of memory

Investigation needed

Copy link
Contributor

@holiman holiman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some comments I made yesterday that got stuck in 'pending'

trie/hasher.go Show resolved Hide resolved
trie/hasher.go Show resolved Hide resolved
// do that, since we don't overwrite/reuse keys
// cached.Key = common.CopyBytes(n.Key)
func (h *hasher) hashShortNodeChildren(n *shortNode) *shortNode {
var collapsed shortNode
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is essentially still a copy. You don't invoke copy(), but you create a new one and set it's fields. Was it not possible to do an in-place version here?
Just curious

Copy link
Member Author

@rjl493456442 rjl493456442 Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's still a copy.

It's not possible to do an in-place modify. If so, the child will be replaced by its hash and the entire trie will be collapsed into a single hash. It's suitable for commit operation, but not for hash operation. The trie being hashed is still available for following usage.

case hashNode:
return n
default:
panic(fmt.Sprintf("%T: unknown node type", n))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
panic(fmt.Sprintf("%T: unknown node type", n))
panic(fmt.Sprintf("%T: unknown node type: %v", n, n))

for i := 0; i < 10; i++ {
tr.Update(testrand.Bytes(32), testrand.Bytes(32))
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
tr.Hash()
tr.Commit(false)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is here missing the Commit?

@georgehao
Copy link
Contributor

I run a benchmark with the master branch code about the Trie.Copy():

// The non-copy optimization bench result 
BenchmarkTestTrieCopy-12    	   10000	   1235696 ns/op
BenchmarkTestTrieCopy-12    	   10000	   1216092 ns/op
BenchmarkTestTrieCopy-12    	   10000	   1228202 ns/op
BenchmarkTestTrieCopy-12    	   10000	   1222534 ns/op

BenchmarkTestTrieCopy-12    	   10000	   1220644 ns/op


// The old copy bench result
BenchmarkTestTrieCopy-12    	   10000	    127869 ns/op

BenchmarkTestTrieCopy-12    	   10000	    127288 ns/op
BenchmarkTestTrieCopy-12    	   10000	    128999 ns/op
BenchmarkTestTrieCopy-12    	   10000	    127236 ns/op
BenchmarkTestTrieCopy-12    	   10000	    128569 ns/op

@@ -1311,3 +1311,171 @@ func printSet(set *trienode.NodeSet) string {
}
return out.String()
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func BenchmarkTestTrieCopy(b *testing.B) {
b.ResetTimer()
tr := NewEmpty(nil)
for i := 0; i < 256; i++ {
tr.Update(testrand.Bytes(32), testrand.Bytes(32))
}
for i := 0; i < b.N; i++ {
copiedTrie := tr.Copy()
tr.Update(testrand.Bytes(32), testrand.Bytes(32))
_ = copiedTrie
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants