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: simplify StackTrie implementation #23950

Merged
merged 4 commits into from
Nov 29, 2021
Merged

Conversation

chfast
Copy link
Member

@chfast chfast commented Nov 22, 2021

Trim the search key from head as it's being pushed deeper into the trie.
Previously the search key was never modified but each node kept
information how to slice and compare it in keyOffset. Now the keyOffset
is not needed as this information is included in the slice of the
search key. This way the keyOffset can be removed and key manipulation
simplified.

BTW, I ported this Trie implementation (without hashing implementation) to C++. This helped me a lot, thanks.

Comment on lines +92 to +94
Nodetype uint8
Val []byte
Key []byte
Copy link
Contributor

Choose a reason for hiding this comment

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

I need to lookup why we implemented binary marshalling, but if we somewhere do encode this to disk, then this is a breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

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

So, we added it here: #22685

@gballet do you recall why we/I did that?

@holiman
Copy link
Contributor

holiman commented Nov 22, 2021

The benchmarks don't seem affected -- so sure, simplicity is better.

[user@work trie]$ benchstat before.txt after.txt 
name                             old time/op    new time/op    delta
HexToCompact-6                     17.2ns ± 1%    17.8ns ± 5%   ~     (p=0.400 n=3+3)
CompactToHex-6                     31.2ns ± 1%    31.6ns ± 1%   ~     (p=0.200 n=3+3)
KeybytesToHex-6                    33.6ns ± 4%    33.9ns ± 3%   ~     (p=1.000 n=3+3)
HexToKeybytes-6                    18.9ns ± 0%    19.0ns ± 0%   ~     (p=0.100 n=3+3)
Prove-6                             397µs ± 5%     457µs ± 4%   ~     (p=0.100 n=3+3)
VerifyProof-6                      6.44µs ± 3%    6.10µs ± 5%   ~     (p=0.200 n=3+3)
VerifyRangeProof10-6               64.4µs ± 1%    61.0µs ± 3%   ~     (p=0.100 n=3+3)
VerifyRangeProof100-6               342µs ± 4%     336µs ± 6%   ~     (p=0.400 n=3+3)
VerifyRangeProof1000-6             3.20ms ±15%    3.05ms ± 9%   ~     (p=0.700 n=3+3)
VerifyRangeProof5000-6             11.0ms ± 2%    10.7ms ± 2%   ~     (p=0.200 n=3+3)
VerifyRangeNoProof10-6              312µs ± 4%     303µs ± 7%   ~     (p=0.400 n=3+3)
VerifyRangeNoProof500-6            1.25ms ± 5%    1.24ms ± 2%   ~     (p=1.000 n=3+3)
VerifyRangeNoProof1000-6           2.32ms ± 0%    2.36ms ± 1%   ~     (p=0.200 n=3+3)
Get-6                               187ns ± 4%     182ns ± 1%   ~     (p=0.400 n=3+3)
GetDB-6                             163ns ± 1%     157ns ± 1%   ~     (p=0.100 n=3+3)
UpdateBE-6                         1.30µs ± 3%    1.39µs ± 7%   ~     (p=0.400 n=3+3)
UpdateLE-6                         1.86µs ± 5%    1.88µs ± 6%   ~     (p=1.000 n=3+3)
Hash-6                             2.12µs ±17%    1.95µs ± 7%   ~     (p=0.400 n=3+3)
CommitAfterHash/no-onleaf-6        2.15µs ± 5%    2.09µs ± 6%   ~     (p=1.000 n=3+3)
CommitAfterHash/with-onleaf-6      2.49µs ± 8%    2.41µs ± 3%   ~     (p=0.700 n=3+3)
HashFixedSize/10-6                 54.9µs ± 4%    52.0µs ± 4%   ~     (p=0.200 n=3+3)
HashFixedSize/100-6                 223µs ± 7%     207µs ± 4%   ~     (p=0.100 n=3+3)
HashFixedSize/1K-6                 1.51ms ±12%    1.30ms ± 2%   ~     (p=0.100 n=3+3)
HashFixedSize/10K-6                12.9ms ±13%    10.7ms ± 1%   ~     (p=0.100 n=3+3)
HashFixedSize/100K-6                124ms ±20%     104ms ± 4%   ~     (p=0.100 n=3+3)
CommitAfterHashFixedSize/10-6      33.3µs ±17%    27.8µs ± 2%   ~     (p=0.100 n=3+3)
CommitAfterHashFixedSize/100-6      138µs ± 1%     132µs ± 1%   ~     (p=0.100 n=3+3)
CommitAfterHashFixedSize/1K-6      1.34ms ± 5%    1.23ms ± 2%   ~     (p=0.100 n=3+3)
CommitAfterHashFixedSize/10K-6     17.0ms ± 5%    17.2ms ± 5%   ~     (p=1.000 n=3+3)
CommitAfterHashFixedSize/100K-6     194ms ± 5%     179ms ± 4%   ~     (p=0.200 n=3+3)
DerefRootFixedSize/10-6            15.3µs ± 2%    14.4µs ± 1%   ~     (p=0.100 n=3+3)
DerefRootFixedSize/100-6           48.3µs ± 5%    45.8µs ± 0%   ~     (p=0.100 n=3+3)
DerefRootFixedSize/1K-6             376µs ± 5%     362µs ± 5%   ~     (p=0.400 n=3+3)
DerefRootFixedSize/10K-6           4.47ms ± 3%    4.31ms ± 1%   ~     (p=0.200 n=3+3)
DerefRootFixedSize/100K-6          51.8ms ± 6%    49.5ms ± 2%   ~     (p=0.400 n=3+3)

name                             old alloc/op   new alloc/op   delta
HexToCompact-6                      4.00B ± 0%     4.00B ± 0%   ~     (all equal)
CompactToHex-6                      16.0B ± 0%     16.0B ± 0%   ~     (all equal)
KeybytesToHex-6                     24.0B ± 0%     24.0B ± 0%   ~     (all equal)
HexToKeybytes-6                     4.00B ± 0%     4.00B ± 0%   ~     (all equal)
Prove-6                             136kB ± 2%     154kB ± 2%   ~     (p=0.100 n=3+3)
VerifyProof-6                      5.09kB ± 5%    5.16kB ± 4%   ~     (p=0.800 n=3+3)
VerifyRangeProof10-6               42.6kB ± 0%    42.5kB ± 0%   ~     (p=0.200 n=3+3)
VerifyRangeProof100-6               270kB ± 0%     271kB ± 0%   ~     (p=1.000 n=3+3)
VerifyRangeProof1000-6             2.06MB ± 1%    2.07MB ± 1%   ~     (p=1.000 n=3+3)
VerifyRangeProof5000-6             10.0MB ± 0%    10.0MB ± 0%   ~     (p=0.400 n=3+3)
VerifyRangeNoProof10-6             57.0kB ± 0%    55.0kB ± 1%   ~     (p=0.100 n=3+3)
VerifyRangeNoProof500-6             200kB ± 2%     199kB ± 2%   ~     (p=1.000 n=3+3)
VerifyRangeNoProof1000-6            366kB ± 1%     365kB ± 0%   ~     (p=0.700 n=3+3)
Get-6                                104B ± 0%      104B ± 0%   ~     (all equal)
GetDB-6                              104B ± 0%      104B ± 0%   ~     (all equal)
UpdateBE-6                         1.80kB ± 0%    1.79kB ± 0%   ~     (p=1.000 n=3+3)
UpdateLE-6                         1.85kB ± 0%    1.85kB ± 0%   ~     (p=1.000 n=3+3)
Hash-6                               952B ± 1%      958B ± 1%   ~     (p=0.400 n=3+3)
CommitAfterHash/no-onleaf-6          818B ± 3%      806B ± 2%   ~     (p=0.700 n=3+3)
CommitAfterHash/with-onleaf-6        983B ± 8%     1011B ± 1%   ~     (p=1.000 n=3+3)
HashFixedSize/10-6                 12.0kB ± 0%    12.0kB ± 0%   ~     (p=0.700 n=3+3)
HashFixedSize/100-6                59.6kB ± 0%    59.6kB ± 0%   ~     (p=0.700 n=3+3)
HashFixedSize/1K-6                  593kB ± 0%     593kB ± 0%   ~     (p=0.100 n=3+3)
HashFixedSize/10K-6                6.31MB ± 0%    6.31MB ± 0%   ~     (p=0.200 n=3+3)
HashFixedSize/100K-6               62.4MB ± 0%    62.4MB ± 0%   ~     (p=0.100 n=3+3)
CommitAfterHashFixedSize/10-6      16.6kB ± 0%    16.6kB ± 0%   ~     (p=0.800 n=3+3)
CommitAfterHashFixedSize/100-6     80.4kB ± 0%    80.4kB ± 0%   ~     (p=0.800 n=3+3)
CommitAfterHashFixedSize/1K-6       763kB ± 0%     763kB ± 0%   ~     (p=0.700 n=3+3)
CommitAfterHashFixedSize/10K-6     9.04MB ± 0%    9.04MB ± 0%   ~     (p=1.000 n=3+3)
CommitAfterHashFixedSize/100K-6    83.9MB ± 0%    83.9MB ± 0%   ~     (p=0.700 n=3+3)
DerefRootFixedSize/10-6              745B ± 0%      745B ± 0%   ~     (all equal)
DerefRootFixedSize/100-6             744B ± 0%      744B ± 0%   ~     (all equal)
DerefRootFixedSize/1K-6              760B ± 0%      760B ± 0%   ~     (all equal)
DerefRootFixedSize/10K-6             760B ± 0%      760B ± 0%   ~     (all equal)
DerefRootFixedSize/100K-6            760B ± 0%      760B ± 0%   ~     (all equal)

name                             old allocs/op  new allocs/op  delta
HexToCompact-6                       1.00 ± 0%      1.00 ± 0%   ~     (all equal)
CompactToHex-6                       1.00 ± 0%      1.00 ± 0%   ~     (all equal)
KeybytesToHex-6                      1.00 ± 0%      1.00 ± 0%   ~     (all equal)
HexToKeybytes-6                      1.00 ± 0%      1.00 ± 0%   ~     (all equal)
Prove-6                             1.86k ± 1%     2.10k ± 1%   ~     (p=0.100 n=3+3)
VerifyProof-6                         104 ± 4%       106 ± 4%   ~     (p=0.700 n=3+3)
VerifyRangeProof10-6                  432 ± 0%       431 ± 0%   ~     (p=0.700 n=3+3)
VerifyRangeProof100-6               1.95k ± 0%     1.95k ± 0%   ~     (p=1.000 n=3+3)
VerifyRangeProof1000-6              16.1k ± 1%     16.1k ± 1%   ~     (p=0.700 n=3+3)
VerifyRangeProof5000-6              79.2k ± 0%     79.0k ± 0%   ~     (p=0.700 n=3+3)
VerifyRangeNoProof10-6              1.20k ± 0%     1.18k ± 0%   ~     (p=0.100 n=3+3)
VerifyRangeNoProof500-6             3.72k ± 1%     3.71k ± 1%   ~     (p=0.700 n=3+3)
VerifyRangeNoProof1000-6            6.75k ± 0%     6.73k ± 0%   ~     (p=0.400 n=3+3)
Get-6                                2.00 ± 0%      2.00 ± 0%   ~     (all equal)
GetDB-6                              2.00 ± 0%      2.00 ± 0%   ~     (all equal)
UpdateBE-6                           9.00 ± 0%      9.00 ± 0%   ~     (all equal)
UpdateLE-6                           9.00 ± 0%      9.00 ± 0%   ~     (all equal)
Hash-6                               11.0 ± 0%      11.0 ± 0%   ~     (all equal)
CommitAfterHash/no-onleaf-6          7.00 ± 0%      7.00 ± 0%   ~     (all equal)
CommitAfterHash/with-onleaf-6        11.0 ± 0%      11.0 ± 0%   ~     (all equal)
HashFixedSize/10-6                    149 ± 0%       149 ± 0%   ~     (all equal)
HashFixedSize/100-6                   770 ± 0%       770 ± 0%   ~     (all equal)
HashFixedSize/1K-6                  7.45k ± 0%     7.45k ± 0%   ~     (p=1.000 n=3+3)
HashFixedSize/10K-6                 77.1k ± 0%     77.1k ± 0%   ~     (p=0.400 n=3+3)
HashFixedSize/100K-6                 768k ± 0%      768k ± 0%   ~     (p=0.700 n=3+3)
CommitAfterHashFixedSize/10-6         162 ± 0%       162 ± 0%   ~     (all equal)
CommitAfterHashFixedSize/100-6        784 ± 0%       784 ± 0%   ~     (all equal)
CommitAfterHashFixedSize/1K-6       7.76k ± 0%     7.76k ± 0%   ~     (all equal)
CommitAfterHashFixedSize/10K-6      80.0k ± 0%     80.0k ± 0%   ~     (p=0.800 n=3+3)
CommitAfterHashFixedSize/100K-6      799k ± 0%      799k ± 0%   ~     (p=0.500 n=3+3)
DerefRootFixedSize/10-6              8.00 ± 0%      8.00 ± 0%   ~     (all equal)
DerefRootFixedSize/100-6             8.00 ± 0%      8.00 ± 0%   ~     (all equal)
DerefRootFixedSize/1K-6              10.0 ± 0%      10.0 ± 0%   ~     (all equal)
DerefRootFixedSize/10K-6             10.0 ± 0%      10.0 ± 0%   ~     (all equal)
DerefRootFixedSize/100K-6            10.0 ± 0%      10.0 ± 0%   ~     (all equal)

st := stackTrieFromPool(db)
st.nodeType = leafNode
st.keyOffset = ko
st.key = append(st.key, key[ko:]...)
st.key = key
Copy link
Member Author

Choose a reason for hiding this comment

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

Here I'm not sure. Previously this worked as a copy? (the st.key is always empty after taking an object from the pool).

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, so previously the incoming key was always copied, but the append operation made it so that we could sometimes use an existing buffer, so no alloc was needed. WIth this change, leafs will reference the same backing slice.

We need to investigate whether that can be a problem.

Copy link
Contributor

Choose a reason for hiding this comment

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

(the st.key is always empty after taking an object from the pool).

That's because in Reset, which is called when returning things to the pool, we do

	st.key = st.key[:0]

That means we just truncate it, but don't dereference the backing-slice. So the actual underlying slice is reused, whenever we at a later point do append(st.key, ...).

A safer alternative to what you're doing would simply be to change it back into

	st.key = append(st.key, key...)

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm running the stacktrie fuzzer on it now, let's see if it finds anything

Copy link
Member Author

Choose a reason for hiding this comment

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

I now see these were for both leaf and ext node (the only ones which have keys). I can restore these. Are they only for memory management optimization unless someone would change the content underneath?

Copy link
Contributor

Choose a reason for hiding this comment

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

Are they only for memory management optimization unless someone would change the content underneath?

Not sure I understand the question. But basically, you assume that the caller will not modify the input key ever again. Which may be true, but I'm not sure we have any guarantee.
The previous code always copied the key, but did so in a way which for the most part never caused any allocs to happen. I think the previous way to do it is better.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's ok to direct hold the byte slice. We do have one place with modify the underlying slice, https://github.com/ethereum/go-ethereum/blob/master/trie/stacktrie.go#L437 but it's only for the leaf node.

@holiman
Copy link
Contributor

holiman commented Nov 23, 2021

Triage: we should run a full-sync and a snap-sync to verify it.

}

// Helper function that, given a full key, determines the index
// at which the chunk pointed by st.keyOffset is different from
// the same chunk in the full key.
func (st *StackTrie) getDiffIndex(key []byte) int {
diffindex := 0
for ; diffindex < len(st.key) && st.key[diffindex] == key[st.keyOffset+diffindex]; diffindex++ {
for ; diffindex < len(st.key) && st.key[diffindex] == key[diffindex]; diffindex++ {
Copy link
Contributor

@holiman holiman Nov 23, 2021

Choose a reason for hiding this comment

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

I think this method woud become clearer by writing it like this

func (st *StackTrie) getDiffIndex(key []byte) int {
	for idx, k := range st.key{
		if k != key[idx]{
			return idx
		}
	}
	return len(st.key)
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, will do.

@holiman
Copy link
Contributor

holiman commented Nov 23, 2021

Full sync started on bench03, snap-sync on bench04

@holiman
Copy link
Contributor

holiman commented Nov 25, 2021

snap-sync done, full-sync at 5.9M as of now

@@ -135,7 +132,6 @@ func (st *StackTrie) unmarshalBinary(r io.Reader) error {
st.nodeType = dec.Nodetype
st.val = dec.Val
st.key = dec.Key
st.keyOffset = int(dec.KeyOffset)
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks.

This mistake might have hidden a binary incompatibility issue: we could properly read old binaries by ignoring KeyOffset byte.

This also looks like UnmarshalBinary() is currently incompatible with MarshalBinary(). Why is this not detected by any test?

st := stackTrieFromPool(db)
st.nodeType = leafNode
st.keyOffset = ko
st.key = append(st.key, key[ko:]...)
st.key = key
Copy link
Member

Choose a reason for hiding this comment

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

I think it's ok to direct hold the byte slice. We do have one place with modify the underlying slice, https://github.com/ethereum/go-ethereum/blob/master/trie/stacktrie.go#L437 but it's only for the leaf node.

Copy link
Member

@gballet gballet left a comment

Choose a reason for hiding this comment

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

So the first version of StackTrie was doing exactly that, with a limited amount of copies. This lead to a whole host of problems in which a key, being modified, modified another key that wasn't supposed to be. Now that we do a lot more fuzzing, this is likely no longer a big risk, however I would urge caution before merging this.

@chfast
Copy link
Member Author

chfast commented Nov 26, 2021

So the first version of StackTrie was doing exactly that, with a limited amount of copies. This lead to a whole host of problems in which a key, being modified, modified another key that wasn't supposed to be. Now that we do a lot more fuzzing, this is likely no longer a big risk, however I would urge caution before merging this.

I don't see problem with reverting back to append().

@holiman
Copy link
Contributor

holiman commented Nov 26, 2021

I don't see problem with reverting back to append().

That would alleviate our concerns. IMO: Please do

Trim the search key from head as it's being pushed deeper into the trie.
Previously the search key was never modified but each node kept
information how to slice and compare it in keyOffset. Now the keyOffset
is not needed as this information is included in the slice of the
search key. This way the keyOffset can be removed and key manipulation
simplified.
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.

LGTM

@holiman holiman merged commit 86fe359 into ethereum:master Nov 29, 2021
@holiman holiman added this to the 1.10.14 milestone Nov 29, 2021
@axic axic deleted the trie_key branch November 29, 2021 10:07
JacekGlen pushed a commit to JacekGlen/go-ethereum that referenced this pull request May 26, 2022
Trim the search key from head as it's being pushed deeper into the trie. Previously the search key was never modified but each node kept information how to slice and compare it in keyOffset. Now the keyOffset is not needed as this information is included in the slice of the search key. This way the keyOffset can be removed and key manipulation
simplified.
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