Skip to content

Commit f5aef32

Browse files
rscFiloSottile
authored andcommitted
mpt: allow variable-width Key and Val (API and disk format change)
Allow variable-width Key and Val, so that applications are not locked into 32-byte blobs as the only possible format. Update disk format to support variable-width keys and values. The first write of a key reserves space for the number of bytes in the associated value. Future updates can overwrite that space as long as the value does not grow in size. If the value does get larger, then the existing space is abandoned (leaked) and new space is allocated. That strategy should be fine for now, since all of our applications have fixed-size values anyway. Also create and use new testdata/treehash.txt, test vectors for tree creation and snapshotting.
1 parent 07e3c0e commit f5aef32

13 files changed

Lines changed: 2147 additions & 257 deletions

File tree

mpt/DESIGN.md

Lines changed: 88 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -245,14 +245,54 @@ Conceptually, we can stop updates,
245245
write the current tree memory to a new file,
246246
delete the old file, and then resume updates,
247247
now writing patches to the new file.
248-
It is worth introducing two complications.
248+
It is worth introducing three complications.
249+
249250
First, we can reuse the old file as the output for the next compaction,
250251
alternating between a pair of files
251252
instead of continually deleting and recreating files.
253+
252254
Second, we can let updates proceed concurrently
253255
with compaction, so that updates aren't blocked
254256
waiting to write a few hundred gigabytes to disk.
255257

258+
Third, we can trade a few disk I/O per Set or Prove
259+
operation for reduced memory requirements.
260+
In this hybrid approach, the leaf nodes (meaning the key and value fields)
261+
are all stored in a “leaf file” and not stored in memory.
262+
Writes to the leaf file are still recorded in patch blocks,
263+
so that after recovery the leaf file is always at least as up to date as the main
264+
tree memory image. However, writes to the leaf file also happen immediately,
265+
so after recovery, the leaf file may also contain writes beyond those reflected
266+
in the main tree memory image. Having a leaf file that is “too new” cannot
267+
affect the structure of the overall tree, since keys are never changed after a
268+
node is allocated. However, the leaf file being too new can mean that values
269+
that are “too new” are recorded for leaf nodes, so the client must recover by
270+
replaying all the Set operations that may have happened after the point
271+
where the memory image was recovered. Once those are replayed, the
272+
memory image and the leaf file will be in sync.
273+
274+
To support the recovery operation, there is a new method Tree.Version:
275+
276+
// Version returns the version number of the tree's last complete snapshot.
277+
// All Set calls made prior to Snap(version) are guaranteed to be
278+
// recorded in the tree. However, if exact is false, then the tree may
279+
// include the effect of Set calls made after that snapshot.
280+
// In that case, to bring the tree into a consistent state, the client is
281+
// expected to replay all Set calls up to the next version.
282+
Version() (version int64, exact bool)
283+
284+
In this new approach, calling Prove requires around two disk I/Os:
285+
for a balanced tree, it would be one to read the leaf key and value
286+
at the end of the lookup, and one to read that node's sibling for inclusion in the proof.
287+
More precisely, Prove requires one disk I/O for the leaf and one disk I/O
288+
for each leaf sibling found along the path back to the root.
289+
Calling set requires around three disk I/Os: the same reads
290+
needed by Prove as well as one write to update or create a leaf.
291+
292+
In exchange for these few disk I/Os per operation, the memory
293+
requirements are reduced to 48 bytes per record and become independent
294+
of key and value size.
295+
256296
## Memory Format {#mem}
257297

258298
The memory format of the tree must be suitable for writing to disk
@@ -269,38 +309,39 @@ The tree memory starts with a header with the form:
269309

270310
version [ 8 bytes]
271311
dirty [ 1 byte]
272-
pad [ 1 byte]
312+
exact [ 1 byte]
273313
root [ 6 bytes]
274314
hash [32 bytes]
275-
nodes [ 8 bytes]
276315

277316
All numbers are stored in big-endian order
278317
for legibility when reading hex dumps.
279318

280319
- “version” is a number for clients to use to match the
281320
tree contents to a position in the underlying transparent log.
321+
- “exact” is a boolean indicating whether the tree includes
322+
only the changes made before Snap(version).
323+
If false, it may contain more changes made after that snapshot.
282324
- “root” is a pointer to the tree's root node,
283325
represented as a 48-bit byte offset within the
284326
tree memory.
285-
- “nodes” field counts the number of nodes (leaves)
286-
stored in the tree.
287327
- “hash” is the Merkle hash of the tree root.
288328
When “dirty” is set, the hash is stale and needs to be recomputed.
289-
- “pad” pads “root” to a 16-bit boundary and “hash” and “nodes”
290-
to a 64-bit boundary.
291329

292330
The header is immediately followed by a sequence of Patricia nodes,
293331
each with the form:
294332

295-
key [32 bytes]
296-
val [32 bytes]
297333
bit [ 1 byte]
298334
dirty [ 1 byte]
299-
pad [ 2 bytes]
300335
left [ 6 bytes]
301336
right [ 6 bytes]
337+
leaf [ 6 bytes]
302338
ihash [32 bytes]
303339

340+
In a standard implementation, each Patricia node represents both one leaf node
341+
and one inner node. In this format, the leaf data is stored in a separate
342+
parallel file. The nodes we are considering only store inner node data,
343+
although each node still serves as both inner node and leaf in the tree structure.
344+
304345
Remember that each Patricia node represents both one leaf node
305346
and one inner node.
306347

@@ -343,10 +384,12 @@ Snapshots are still amortized O(1) but not an actual O(1).
343384
If the snapshot operations caused problematic latency hiccups,
344385
this lazy recomputation could be abandoned.
345386

346-
Notice that a Patricia node takes 112 bytes,
347-
so a 2-billion node tree requires about 224 GB of memory,
387+
Notice that a Patricia node takes 52 bytes,
388+
so a 2-billion node tree requires about 104 GB of memory,
348389
well within the 512 GB we allotted ourselves on our
349390
“reasonably configured server”.
391+
(There is another 130 GB of disk for the leaf file,
392+
for the common case of 32-byte keys.)
350393
The actual memory for the tree is obtained directly
351394
using the operating system, not from the Go heap.
352395
Using _mmap_(2), we can reserve a very large amount
@@ -361,7 +404,7 @@ when loading a tree from disk.
361404

362405
## File Format {#format}
363406

364-
A file consists of the magic string `"mpt tree\n\x00\x00\x00\x00\x00\x00\x00"`
407+
A file consists of the magic string `"mpt tree v2\n\x00\x00\x00\x00\x00"`
365408
followed by a sequence of variable-length frames.
366409
Each frame has the form:
367410

@@ -394,12 +437,15 @@ a valid tree.
394437
The second and subsequent frames in the file each hold
395438
a patch block, which holds one or more mutations of the form:
396439

397-
offset [varint]
398-
N [varint]
399-
data [N bytes]
440+
offset+leaf [varint]
441+
N [varint]
442+
data [N bytes]
400443

401-
That mutation says to write `data` of length `N` at
402-
`offset` in the tree memory.
444+
The offset+leaf field is a varint encoding offset<<1 + leaf,
445+
where leaf is a boolean indicating whether the update is for
446+
the tree memory or the leaf memory.
447+
The mutation says to write `data` of length `N` at
448+
`offset` in the specified memory.
403449

404450
There is no guarantee that a file ends after a valid patch block frame.
405451
If a frame was only partially written before a process or system crash,
@@ -408,6 +454,13 @@ We do this by reading as many valid (checksum-matching) frames
408454
as possible from the file and stopping at EOF or when we reach
409455
a frame that is truncated or does not have a valid checksum.
410456

457+
The leaf file data consists of a sequence of leaves.
458+
The leaf nodes are allocated in parallel with the Patricia nodes,
459+
so that Patricia node N corresponds to leaf N.
460+
Since each leaf may be variable length,
461+
the leaf pointer in the Patricia node records where
462+
the leaf begins on disk.
463+
411464
## Compaction {#compaction}
412465

413466
When the current disk file holding a tree has grown too large,
@@ -428,9 +481,9 @@ being obsoleted.
428481
One approach would be to pause all tree updates,
429482
write the tree to the new file, and then continue
430483
updates, writing patches to the new file.
431-
If the tree is 224 GB,
484+
If the tree is 96 GB,
432485
then even if we can write at a relatively fast 10 GB/s,
433-
that would be a 22-second pause.
486+
that would be a 10-second pause.
434487
Instead, we can allow tree updates to proceed
435488
concurrently with compaction.
436489

@@ -486,15 +539,22 @@ The improvement is possible because we keep all the data in memory at all times.
486539
## Speed {#speed}
487540

488541
On my circa-2023 home server with 128 GB of RAM
489-
and an NVMe disk using LVM encryption, storing 834 million hashes
490-
takes about 250 minutes, or about 55,000 Set operations per second.
491-
This is with constant disk compaction, and I suspect something in my
542+
and an NVMe disk using LVM encryption,
543+
using an earlier version of this format that did not have the leaf file
544+
and stored keys and values in the Patricia nodes,
545+
storing 834 million hashes takes about 250 minutes,
546+
or about 55,000 Set operations per second.
547+
This is with continuous disk compaction, and I suspect something in my
492548
kernel stack of slowing disk I/O.
493549

550+
Prove operations run in microseconds.
551+
552+
Snap is effectively free.
553+
494554
A “lazy hash” optimization that delays recomputing all inner node hashes
495-
is delayed until the Sync operation can avoid spending time
555+
is delayed until the Snap operation can avoid spending time
496556
computing hashes that will be overwritten by a subsequent Set,
497-
but it dramatically increases the latency of Sync.
557+
but it dramatically increases the latency of Snap.
498558
More important than not computing the hashes is not writing
499559
them to disk, especially for the somewhat special case of writing all new entries
500560
when populating a new tree.
@@ -504,12 +564,9 @@ being written, so the disk file never reaches twice the memory size.
504564
In that case, the 834 million hashes can be written in 45 minutes,
505565
followed by a 7 minute sync, or about 260,000 Set operations second.
506566

507-
A limited lazy hash that is lazy only up to a fixed number of
508-
Set operations may be the best of both worlds.
509-
510-
Prove operations run in microseconds.
511-
512-
Snap is effectively free.
567+
Although Snap with lazy hashes is still amortized O(1),
568+
callers that want Snap to run in limited time should limit
569+
the number of Set calls they make between Snaps.
513570

514571
## Recovery {#recovery}
515572

@@ -525,42 +582,3 @@ all of the Set calls before Snap(V) has been retained, and some of the Set
525582
calls between Snap(V) and Snap(V+1) may also have been retained.
526583
It suffices to replay all the Set operations between Snap(V) and Snap(V+1)
527584
and then Snap(V+1) to get a consistent tree.
528-
529-
## Hybrid Approach
530-
531-
The approach described so far is the original in-memory approach.
532-
It is tagged as mpt v0.1.0.
533-
534-
This section describes a hybrid approach implemented in later versions.
535-
The hybrid approach trades a constant number of disk I/O per Set or Prove
536-
operation for reduced memory requirements. In the hybrid approach, the
537-
leaf nodes (meaning the key and value fields) are all stored in a “leaf file”
538-
not stored in memory. Writes to the leaf file are still recorded in patch blocks,
539-
so that after recovery the leaf file is always at least as up to date as the main
540-
tree memory image. However, writes to the leaf file also happen immediately,
541-
so after recovery, the leaf file may also contain writes beyond those reflected
542-
in the main tree memory image. Having a leaf file that is “too new” cannot
543-
affect the structure of the overall tree, since keys are never changed after a
544-
node is allocated. However, the leaf file being too new can mean that values
545-
that are “too new” are recorded for leaf nodes, so the client must recover by
546-
replaying all the Set operations that may have happened after the point
547-
where the memory image was recovered. Once those are replayed, the
548-
memory image and the leaf file will be in sync.
549-
550-
To support the recovery operation, there is a new method Tree.Version:
551-
552-
// Version returns the version number of the tree's last complete snapshot.
553-
// All Set calls made prior to Snap(version) are guaranteed to be
554-
// recorded in the tree. However, if exact is false, then the tree may
555-
// include the effect of Set calls made after that snapshot.
556-
// In that case, to bring the tree into a consistent state, the client is
557-
// expected to replay all Set calls up to the next version.
558-
Version() (version int64, exact bool)
559-
560-
In this new approach, calling Prove requires two disk I/Os: one to read the
561-
leaf key and value at the end of the lookup, and one to read that node's sibling
562-
for inclusion in the proof. Calling set requires three disk I/Os: the same two reads
563-
needed by Prove as well as one write to update or create a leaf.
564-
565-
In exchange for these two or three disk I/Os per operation, the memory
566-
requirements are reduced from 112 bytes per record to 48 bytes per record.

0 commit comments

Comments
 (0)