@@ -245,14 +245,54 @@ Conceptually, we can stop updates,
245245write the current tree memory to a new file,
246246delete the old file, and then resume updates,
247247now writing patches to the new file.
248- It is worth introducing two complications.
248+ It is worth introducing three complications.
249+
249250First, we can reuse the old file as the output for the next compaction,
250251alternating between a pair of files
251252instead of continually deleting and recreating files.
253+
252254Second, we can let updates proceed concurrently
253255with compaction, so that updates aren't blocked
254256waiting 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
258298The 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
277316All numbers are stored in big-endian order
278317for 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
292330The header is immediately followed by a sequence of Patricia nodes,
293331each 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+
304345Remember that each Patricia node represents both one leaf node
305346and one inner node.
306347
@@ -343,10 +384,12 @@ Snapshots are still amortized O(1) but not an actual O(1).
343384If the snapshot operations caused problematic latency hiccups,
344385this 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,
348389well 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.)
350393The actual memory for the tree is obtained directly
351394using the operating system, not from the Go heap.
352395Using _ 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" `
365408followed by a sequence of variable-length frames.
366409Each frame has the form:
367410
@@ -394,12 +437,15 @@ a valid tree.
394437The second and subsequent frames in the file each hold
395438a 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
404450There is no guarantee that a file ends after a valid patch block frame.
405451If 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
408454as possible from the file and stopping at EOF or when we reach
409455a 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
413466When the current disk file holding a tree has grown too large,
@@ -428,9 +481,9 @@ being obsoleted.
428481One approach would be to pause all tree updates,
429482write the tree to the new file, and then continue
430483updates, writing patches to the new file.
431- If the tree is 224 GB,
484+ If the tree is 96 GB,
432485then 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.
434487Instead, we can allow tree updates to proceed
435488concurrently 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
488541On 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
492548kernel stack of slowing disk I/O.
493549
550+ Prove operations run in microseconds.
551+
552+ Snap is effectively free.
553+
494554A “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
496556computing 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 .
498558More important than not computing the hashes is not writing
499559them to disk, especially for the somewhat special case of writing all new entries
500560when populating a new tree.
@@ -504,12 +564,9 @@ being written, so the disk file never reaches twice the memory size.
504564In that case, the 834 million hashes can be written in 45 minutes,
505565followed 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
525582calls between Snap(V) and Snap(V+1) may also have been retained.
526583It suffices to replay all the Set operations between Snap(V) and Snap(V+1)
527584and 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