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
rpc: Optimize serialization disk space of dumptxoutset #26045
rpc: Optimize serialization disk space of dumptxoutset #26045
Conversation
a00b91c
to
80f0b0f
Compare
3822c2f
to
8b0e9e9
Compare
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code CoverageFor detailed information about the code coverage, see the test coverage report. ReviewsSee the guideline for information on the review process.
If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update. ConflictsReviewers, this pull request conflicts with the following ones:
If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have the skill set to approve this code but I can test and verify. I ran this on testnet and can confirm a smaller output file.
without:
{ "coins_written": 27908372, "base_hash": "0000000000000008f07ed39d6d03c19ee7346bc15b6a516cdda8402b6244b828", "base_height": 2345886, "path": "/Users/{$User}/Library/Application Support/Bitcoin/testnet3/./txoutset.txt", "txoutset_hash": "026617308d218e57fb43f02baa644134f5000594a1eea06b02cc9d02959d4d9b", "nchaintx": 63601314 }
Size of 3 473 536
With this optimization:
{ "coins_written": 27908372, "base_hash": "0000000000000008f07ed39d6d03c19ee7346bc15b6a516cdda8402b6244b828", "base_height": 2345886, "path": "/Users/${User}/Library/Application Support/Bitcoin/testnet3/txoutset.optimized.txt", "txoutset_hash": "026617308d218e57fb43f02baa644134f5000594a1eea06b02cc9d02959d4d9b", "nchaintx": 63601314 }
Size of 2 457 728
Looks like a win to me.
Not sure it's worth it to save 20%. Presumably generic compressors could do better anyway? |
Yes I agree but at least with this implementation the utxo set is still readable as is and doesn't need decompression. |
The keys in leveldb are guaranteed to be lexicographically sorted. You just need to cache the last txid and flush the txid and outpoint tuples once the next one is reached in the database iterator. I pushed a dirty proof of concept here and it produced the same output file as your current implementation. Otherwise Concept ACK. For a file that might be lugged over a network or other data carrier this space improvement is nice. Users can still apply their own compression on top of it. I also like that this provides additional structure to the data. |
Yeah as I specified in the issue and as @TheCharlatan wrote more ram shouldn't be needed because txid are iterated sorted from leveldb. I was also wondering why the |
8b0e9e9
to
04887de
Compare
Writing to stdout would mean that the data would have to be carried over the rpc connection, right? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for picking this up again.
These are just patches for fixing up my initial code, I also put the entire diff here. The rest looks good to me, I tested dumptxoutset
extensively on mainnet. The runtime performance of this branch is very similar to the base.
The following can be ignored in the context of this PR, but I still want to take note:
During review I noticed that the execution time of the command greatly relies on getting the utxo stats (getting the stats and writing to the file each take about 450s on my m.2 machine). I don't follow why these stats need to be collected by iterating through the entire set again, is there no better way to prepend the meta data? A count of the written entries as well as a hash of them can be provided by the same iteration loop that is used to write the file. Further, the m_coins_count
in the SnapshotMetadata
is populated with tip->nChainTx
, which I am not sure always corresponds to the actual number of coins written, but is used to read that exact number of coins from the file when loading the snapshot.
Yes, would that be a problem? |
There is no way we can currently send gigabytes of data as an RPC response; both the server and client likely buffer the result and would OOM. |
04887de
to
7acfc2a
Compare
Thanks for the patch @TheCharlatan, I applied it. |
I ran some more numbers, dumping a mainnet snapshot on height 787535:
So even in the compressed form the encoding saves some megabytes. |
ACK 7acfc2a Since we are changing the format of dumptxoutset anyway here in a non backwards compatible fashion, I'd like to suggest moving the metadata to the end of the file. This would take care of the double iteration described in #26045 (review). In my eyes, this does not significantly hurt the integrity of the file. If an exception occurs during writing, only the temporary file remains. Other common file formats also embed metadata at the end. |
🫡 |
fe4ca31
to
f5d2014
Compare
Rebased I had to slightly change the tests in |
Did some testing with f5d2014. Using I also generated a mainnet snapshot which was also identical to what I found in the last test, so I have not tried loading again. Will review the code later. |
Co-authored-by: TheCharlatan <seb.kung@gmail.com>
f5d2014
to
4e19464
Compare
Rebased. I had to change the offset again in |
@@ -76,6 +76,7 @@ def test_invalid_snapshot_scenarios(self, valid_snapshot_path): | |||
bad_snapshot_path = valid_snapshot_path + '.mod' | |||
|
|||
def expected_error(log_msg="", rpc_details=""): | |||
print(log_msg) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't forget to drop this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be addressed in #29612
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concept ACK. Found a few issues during code review, see inline.
@TheCharlatan wrote:
The keys in leveldb are guaranteed to be lexicographically sorted. You just need to cache the last txid and flush the txid and outpoint tuples once the next one is reached in the database iterator.
I would be good to document in the code that we rely on this behavior (maybe in the header file, so someone who wants to replace leveldb is reminded).
@luke-jr & @aureleoules wrote:
Not sure it's worth it to save 20%. Presumably generic compressors could do better anyway?
Yes I agree but at least with this implementation the utxo set is still readable as is and doesn't need decompression.
We might also at some point want to xor the file - for the same reason as #28207. That would make compression with external tools impossible.
|
||
auto write_coins_to_file = [&](AutoFile& afile, const uint256& last_hash, const std::vector<std::pair<uint32_t, Coin>>& coins) { | ||
afile << last_hash; | ||
afile << static_cast<uint16_t>(coins.size()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
4e19464: In primitives/transaction.h
we serialize std::vector<CTxOut> vout
with a simple s << tx.vout;
without any reference to the size of the vector. Not sure if any magic is happening there under the hood that I missed.
Also, uint16_t
implies a limit of 65,536 outputs per transaction. I don't think that's a consensus rule?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think the magic happens here: https://github.com/bitcoin/bitcoin/blob/master/src/serialize.h#L674
However, we can't use that because we are not looking at a full transaction but rather the outpoints that are still left in the UTXO set. But we basically mimic that behavior here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the 65,536: I guess the blocksize solves this for us for now I think it makes sense to use VARINT
/CompactSize
here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A transaction with 65,537 OP_RETURN outputs should fit in a block.
If I start with P2TR outputs with this calculator, that's 2,818,159 vbyte. https://bitcoinops.org/en/tools/calc-size/
And then subtract 32 bytes per output: 2,818,159 - 65537 * 32 = 720,975 vbyte
cc @murchandamus can you add OP_RETURN to the dropdown? :-)
In any case it seems unsafe to rely on the block size here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, but OP_RETURNs are not included in the UTXO set and we are serializing the UTXO set here, so I think this could still not happen like this. But I think you are right there are non-standard cases imaginable that make this possible, like just sending to OP_TRUE for example. So we should still make this robust.
Anyway, I am using CompactSize now in #29612 :)
coins_file >> size; | ||
|
||
if(size > coins_left) { | ||
LogPrintf("[snapshot] mismatch in coins count in snapshot metadata and actual snapshot data\n"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the context of my remark above about maybe not serializing size
: in that case this check would have to happen after the batch of coins is loaded, which seems fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing to do here, since we do have serialize size
, as you explained: #26045 (comment)
Coin coin; | ||
coins_file >> outpoint.n; | ||
coins_file >> coin; | ||
outpoint.hash = txid; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
4e19464 nit: maybe move this above where you set outpoint.n
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be addressed in #29612
} | ||
} catch (const std::ios_base::failure&) { | ||
LogPrintf("[snapshot] bad snapshot format or truncated snapshot after deserializing %d coins\n", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unrelated, but why doesn't this use coins_processed
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be addressed in #29612
@@ -5468,6 +5480,7 @@ bool ChainstateManager::PopulateAndValidateSnapshot( | |||
|
|||
bool out_of_coins{false}; | |||
try { | |||
COutPoint outpoint; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be:
Txid txid;
coins_file >> txid;
The current code might accidentally work because a COutPoint
is a Txid
followed by a single number (albeit uint32_t
instead of uint16_t
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be addressed in #29612
Coin coin; | ||
unsigned int iter{0}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This move doesn't seem necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be addressed in #29612
|
||
auto write_coins_to_file = [&](AutoFile& afile, const uint256& last_hash, const std::vector<std::pair<uint32_t, Coin>>& coins) { | ||
afile << last_hash; | ||
afile << static_cast<uint16_t>(coins.size()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think the magic happens here: https://github.com/bitcoin/bitcoin/blob/master/src/serialize.h#L674
However, we can't use that because we are not looking at a full transaction but rather the outpoints that are still left in the UTXO set. But we basically mimic that behavior here.
auto write_coins_to_file = [&](AutoFile& afile, const uint256& last_hash, const std::vector<std::pair<uint32_t, Coin>>& coins) { | ||
afile << last_hash; | ||
afile << static_cast<uint16_t>(coins.size()); | ||
for (auto [vout, coin] : coins) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should call vout
here either n
or vout_index
, otherwise it might be confusing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be addressed in #29612
|
||
auto write_coins_to_file = [&](AutoFile& afile, const uint256& last_hash, const std::vector<std::pair<uint32_t, Coin>>& coins) { | ||
afile << last_hash; | ||
afile << static_cast<uint16_t>(coins.size()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the 65,536: I guess the blocksize solves this for us for now I think it makes sense to use VARINT
/CompactSize
here
Thanks for the reviews but I don't have time to address the comments so please mark this pull as Up for grabs. |
Thanks for the great start @aureleoules! @fjahr can you take this one? Otherwise I can, but not sure how soon. |
Yeah, I will reopen this shortly. |
This is an attempt to implement #25675.
I was able to reduce the serialized utxo set from 5GB to 4.1GB on mainnet.
Closes #25675.