B-Trees: More Than I Thought I'd Want to Know
benjamincongdon.me
benjamincongdon.me
FAT / FAT32 wasn't about B-trees or balancing or anything. It was a simple linked list, that got severely tangled up during use and required an hour of "defragmenting" to make your hard drive go back at high speed.
As a computer user of the time, I was too young / ignorant to know why it happened like that, but that experience stuck with me until college. In college, I learned about how FAT32 filesystem worked fundamentally (though obsolete, its so simple its a great introduction).
From there, I learned why we have moved onto more advanced data-structures like B-Trees. Why things like B-trees are less prone to fragmentation. Etc. etc.
----------
A big part of my learning process was the hours of waiting I'd put in to defrag my hard drive back in the day. Those memories stuck with me, I know what the "downsides" are if I don't use a self-balancing B-Tree to store data on a hard drive.
Perhaps today, we're no longer at a point where we can just convince legions of new computer-users (or programmers) to use inferior file systems like FAT32 in their daily lives. But we can at least tell stories about those times, so that they can understand why we put so much complexity into our modern filesystem data-structures.
Back then, we were just happy having a personal computer. I'm sure there was better tech being used somewhere. But MS-DOS / Windows was just your cheap $99 OS in an age when other compilers and OSes cost you something like $1000 to $10,000.
You formatted your hard-drive in FAT because MS-DOS used FAT. You used MS-DOS because that's what PCs and PC-clones came with. There was no alternative, and PCs were already considered a pretty luxury / nerd item. (I know people brag about their DEC Alphas, Unix copies or OS/2... but you got enough "nerd cred" back then if you simply had a PC at all)
Its just how computers worked back then. Every PC was using FAT, and every few months of regular use the disk reads/writes would get so slow that performance was unbearable.
At that point, you spent an hour defragmenting the hard drive, and everything worked great again.
-------
Eventually 4GB hard drives were made, and you upgraded your computer to FAT32. Soon after that, Windows promised an end to defragmenting with its next-generation NTFS filesystem (based off of the B-trees discussed in this article) and we never looked back.
I thought this mostly reduced fragmentation of the filesystem metadata; but not the fragmentation of actual datafiles themselves?
On an SSD, the random seek time is much lower. There may be some gains from sequential reads, but the gains from having data physically defragmented are not as stated as with spinning media.
You are correct that SSDs do not require defragmentation, and to a certain degree defragmentation is somewhat harmful in the sense that it forces unnecessary read/write cycles.
As for SSDs, they need some degree of garbage collection - it's considered bad for an erasable block (e.g. 1 MiB) to contain a mix of live pages and deleted pages (4 KiB each).
Even if these files were all perfectly allocated in sequential order at the beginning, a few operations like this quickly fragments the files.
--------------
I don't recall all the files MS-DOS or Win95 opened / closed on a regular basis, but play a video game (save file changed), open a word document, edit a photo, and come back to the video game... and the save file will now start getting fragmented as the size of the save files change and new allocated blocks need to be figured out.
- can you name couple of games saving state by appending/modifying files in place on disk?
Hmm, maybe the video game wasn't the example I should have chosen. But swap the order around: open a word doc (which creates an "auto-save" file which is almost certainly modified in place), and then do the other stuff and I think it applies.
------
> - How would btrees help with that?
At a minimum, your indices are all together and in order and are read together all at once.
From there, you can build the file's representation in RAM with much fewer random-traversals than the FAT32 linked-list approach.
Perhaps "fixing fragmentation" is the wrong description. B-Trees mitigate the random-reads associated with fragmentation. There are much fewer seeks from a b-tree based representation than a FAT/linked-list style representation.
-----------------
Consider this scenario. You have a FAT/Linked list of the following: 0 -> 10 -> 5 -> 7 -> 2 -> 1
So you need to move-head to 0, read, move-head to 10, read, move-head to 5, read, (etc. etc.). That's 6 times you move the head randomly.
Now lets say you have a b-tree of [0, 10, 5, 7, 2, 1]. Exactly the same order of nodes as the linked list, but you can optimize much better.
You read initially the b-tree, and then you read 0, 1, 2, 5, 7, 10 in order. You probably only move the head twice: first you move the head to 0, to read [0, 1, 2] as a sequential scan.
Then, you move the head to 5, and read [5, 6, 7, 8, 9, 10] in order. Except you throw away the results for 6, 8, and 9.
So long as you have sufficient RAM to re-order the data after you've read them from disk, you're gold.
----------
There's no way to optimize the FAT32's reads, because the indicies are scattered across the linked list. You only know that 10 is the "next" FAT32 node after you've read the page at 0. You only know that 5 follows 10 after you've read 10.
>From there, you can build the file's representation in RAM with much fewer random-traversals than the FAT32 linked-list approach.
You can do same thing in FAT by reading whole cluster map at the start. Wiki: "Other high-level mechanisms may read in and process larger parts or the complete FAT on startup or on demand when needed and dynamically build up in-memory tree representations of the volume's file structures different from the on-disk structures." This passage references OS/2 documentation, but its unclear to me if OS/2 employed such optimizations. Later NT builds might have this, as Iv seen large files allocated into empty space in the middle of a disk instead of forcing writes into first available gap.
>B-Trees mitigate the random-reads associated with fragmentation. There are much fewer seeks from a b-tree based representation than a FAT/linked-list style representation
after reading whole cluster map you have same representation in RAM, there is no information disparity. Its less elegant, slower to initialize and wasteful - 2TB FAT32 partition with 32KB clusters means >250MB cluster map on disk. But at the end of the day disk will have to perform same random seeks to read already fragmented file. The magic bullet for fragmentation is a map of free sectors letting OS pre plan where to put files in a smart way, and even NTFS doesnt use fancy data structures for that. NTFS $BITMAP is a https://en.wikipedia.org/wiki/Free_space_bitmap. exFAT has a nice feature of marking unfragmented files in the Directory entry removing the need for FAT traversal altogether.
>Consider this scenario. You have a FAT/Linked list of the following: 0 -> 10 -> 5 -> 7 -> 2 -> 1 So you need to move-head to 0, read, move-head to 10, read, move-head to 5, read, (etc. etc.). That's 6 times you move the head randomly.
or whole FAT table is cached in ram and there is no difference :)
It was one of those assignments few people fully completed. Most completed parts of it to varying degrees. I got more than halfway through but wasn't able to complete it.
It was an extremely difficult assignment and stands out in my mind.
They're very interesting and very complex. Nice article!
It seems very out-of-place for a databases course. Yes, B-Trees are important for a database, but I don't see how implementing one teaches you about how a database uses one or why it's important.
So don't think all DB classes are the same, there are some vast differences in that type of class compared to a class teaching you how to use SQL.
Can you name or link a few?
Also have some awesome guest speakers, and the conversations they have are often quite insightful.
It was an undergraduate course, but the professor emphasized that everywhere else it would be a graduate-level of course.
My B+ tree implementation served as the foundation for my own (shitty) DBMS ;D
As a funny sidenote, the professor claimed that even Stanford DB courses had a buggy / broken algorithm for balanced deletes. While preparing the course, he'd spent a chunk of the summer doing researching it, and had developed his alogrithm in Prolog. He shared the concepts and our implementations worked fine. However, it was ultimately mostly an academic exercise, as balanced deletions come with significant tradeoffs which make it not as useful as I initially thought.
Even full-fledged database systems don't typically implement B+ tree indexes with deletion+rebalancing support (the usual technique is marking nodes deleted and cleaning them up when the index is rebuilt).
Yes, and it took a surprisingly long time before anyone bothered to do the theoretical analysis. Some good papers to check out if anyone is interested are Deletion Without Rebalancing in Multiway Search Trees (http://sidsen.azurewebsites.net/papers/relaxed-b-tree-tods.p...) and Deletion Without Rebalancing in Binary Search Trees (http://sidsen.azurewebsites.net//papers/ravl-trees-journal.p...). Jeff Erickson also has some good notes on using local and global rebuilds for search trees: https://jeffe.cs.illinois.edu/teaching/algorithms/notes/10-s...
It's intuitively clear that worst-case performance [1] can degrade to a function of the number of insertions since a rebuild (as opposed to the number of currently live items) when you use relaxed deletion. If you can afford to globally rebuild the tree you get to play the usual amortization tricks, but global rebuilds can be problematic in a large-scale live database; one solution is to rebuild from a snapshot in the background (on another thread or machine) and then replay late arriving operations before switching over to the rebuilt tree. But if someone is okay with the amortization spikes from rehashing a hash table, they should be okay with non-incremental global rebuilds for search trees; a hash table where deletions leave behind tombstones and the table is rehashed when the ratio of tombstones gets too high is basically the same idea.
https://github.com/samsquire/btree
It doesn't balance between neighbours on the left and right like some btrees do but it handles splits in a really simple way and balances otherwise.
I would recommend everybody write the core algorithm in a simple language like Python first to get the core algorithm understood before trying to implement in a low level language like C or Rust.
“The Untold Story of SQLite” https://corecursive.com/066-sqlite-with-richard-hipp/
But if you're not going to COW, then right-sibling pointers are probably a good idea.
I also did a write-up on why everyone should engage in similar projects: https://www.spandanbemby.com/joys-of-database.html
If you've got one (walkthrough with code, not just an existing implementation), link it please!
EDIT: For more context, this is for time series metrics, so super specialized data.
[1] It occurs to me that you don't necessarily need to modify the node you're splitting, if the key you're writing is on the new node's half of the split.
However, sibling pointers would be the dumbest way to traverse the data, even on a read-only tree, because it has serial round-trip latency to disk for every block. You would never use them for traversal.
They can be useful, for a fancy concurrency hack, if you use them to split the node without updating the parent, and then update the parent in a separate operation after the fact. This lets write operations release their lock on the parent before accessing the child. In that case any code traversing to a child makes note of the child's right sibling stored in the parent and uses the child's sibling pointer only if it's different from the right sibling as stored in the parent (which means the parent hasn't been updated yet).
If only the parent pointer needs to be changed (and up to the root), this isn't a big deal considering that B-Trees aren't very deep, and a good implementation will cache modifications anyhow.
When sibling pointers are introduced, a copy-on-write B-Tree will on average need to read and rewrite half of the B-Tree on every modification. For this reason, I don't use sibling pointers.
In a standard B-tree, yes.
PostgreSQL and MySQL, for example, use B-tree variants that have pointers between leaf nodes. Postgres is based on Lehman & Yao (itself a B+(*?) Tree variant), and InnoDB uses a B+ Tree with a next pointer.
Nitpick: rather, you should not go anywhere, but refer back to the parent nodes that you already have in memory, because how else did you find the current leaf node in the first place? (This also allows you to avoid round-trip latency by fetching sibling-after-next before you actually get the data in the sibling node.)
With modern memory hierarchies that also tends to be the case in-memory (with lower node densities more suited to caches and cache lines).
It also should be noted that DDR4 has "rows" roughly in the 1024 byte size. A RAS command opens up a "row" (aka 1024 bytes), while a CAS command reads from a column (aka: 64-bytes).
DDR4 cells can only be read once (!!!), and afterwards, the data gets mangled. To prevent the loss of data, all "reads" are first into sense-amplifiers... and these sense-amplifiers can be read infinitely.
This "read into sense-amplifiers" is called a RAS command, and it transfers the entire 1024-byte row into sense amplifiers. A CAS can then read 64-byte chunks from the page at high speeds and randomly.
--------
Before a new RAS command can be issued, there needs to be a few steps.
1. The "current row" of data needs to be written back to DRAM (remember, the data in the DRAM was lost when you read the data in the first place).
2. After writing the data, the sense-amplifiers must be emptied (aka: precharged), so that they're ready to receive the new data.
3. After #1 and #2, it is finally legal to issue a RAS
--------
So in fact, DDR4 RAM is also a block device. Sure, RAS is much faster than a hard-drive seek, but given how much slower RAS (row address strobe) is than a CAS (column address strobe)... a lot of these disk-based discussions (such as B-trees) end up applying to DDR4 in practice.
--------
The only thing that really works like classic RAM is L3 / L2 / L1 caches. Everything else in the modern world is basically a block device.
This is because the ratio of number of "hot" items you can store at a reduced depth to total number of item is a much smaller ratio with a high fanout tree than a low fanout tree; and at the same time, the depths are lower with a high fanout tree, reducing the benefit of reduced depth anyway.
In other words, you can only give the benefit to a smaller number of hot items, and the benefit you can give is smaller.
On top of that, the added complications of rotations and statistics mean fewer items can be stored in interior nodes (which I'd call index nodes) for a given node size. This reduces the fanout and increases the full depth.
And the rotations and access statistics cause more writing.
There are tradeoffs and perhaps it is worth it for some tree parameters. Perhaps for exceptionally large trees with particular access patterns. It would be interesting to see how it's implemented.
But generally for a high-fanout B-tree, you might as well just store frequently accessed items in a separate small tree as a cache, and keep that cached in memory.
One of the "rules of thumb" of optimising high-fanout B-trees larger than memory is to generally put values in the leaves, because the benefit of being able to keep more pointers cached in memory in compact internal nodes to help locate values in fewer I/O steps outweighs the benefit of having an arbitrary and small subset of larger values in the internal nodes. But if you have particularly well-chosen larger values in the internal nodes, and a low-entropy access pattern, perhaps the rule of thumb doesn't apply then.
There are quite a few B-tree modifications where separate small trees or logs or both are maintained to improve performance, but these are generally to optimise writes, which can be sorted and distributed in batches to reduce random access block I/O, rather than to optimise frequently accessed reads by acting as an on-storage persistent cache.
On fanout:
High fanout is generally used with storage devices, because low fanout always increases the overhead.
For example on NVMe SSDs I've used, reading or writing any block smaller than 4096 bytes takes the same time as 4096 bytes. On HDDs the equivalent number is much larger due to seek time.
So lowering fanout below 4096 bytes per block would just increase the number of block I/Os per tree operation. without reducing the time per I/O.
On COW:
You're right about the rewriting, although it's worth knowing that times have changed: On NVMe you have durable write atomicity guarantees so you don't need to COW as much to have the same storage guarantees. Inside the NVMe of course it may be doing COW on hidden page remappings to provide this atomicity, or it may use other techniques (e.g. a supercapacitor to ensure writes always complete) but from the outside you can simply overwrite blocks or parts of blocks in place.
This adds quite a saving to safe tree updates, and it also reduces padding required in write-ahead logs at durable commit points, including the in-block logs used in several B-tree optimisations (e.g. see bcache on Linux).
Even with COW, it isn't always necessary to update the path to the root by rewriting parent blocks in general. To avoid a parent block write, sometimes it's faster to log an update to the parent's pointer entry, either in an actual logical log somewhere, or using a separate logical to physical page mapping table, or by using pointer-to-log entries that are resolved at read time.
If it's an in-memory B-tree, a COW strategy doesn't need to rewrite parent blocks at all. It can update the pointers in place directly with atomic pointer updates, although some implementations prefer a logical-to-physical page mapping table instead and to atomically update the table.
https://www.usenix.org/system/files/conference/fast13/fast13...
So I decided to format a boot region and main region.
What I found with the particular models I had was:
- Multiple regions were fine, and different block sizes (512 or 4096 bytes) were fine too, but they wouln't accept different block sizes in different regions at the same time. Format commands failed when you tried that.
- The atomicity size and alignment were identical regardless of block size. Durable (power fail safe) atomicity was reported as 4096 byte aligned blocks. Write command overlapping atomicity was reported as 512kiB aligned blocks.
I'm not sure why you may think I think NVMe resembles a HDD regarding atomicity. HDDs don't claim to do atomic replacement of sector contents, and neither do non-NVMe SSDs.
However, NVMe specifies the atomicity, and the device reports atomicity parameters. Are you saying the NVMe devices lie about their atomicity as well? If so, that's a real shame, after going to the effort of reporting atomicity parameters in the first place. Or are you inferring it from experience with HDDs and non-NVMe SSDs?
With SSDs (at least low-quality non-NVMe ones) I'm not convinced you can make use of "you don't break what you don't touch". Blocks are remapped all over the place, so you don't know which banks you are touching, so if there are faults with the SSD you don't know which random blocks may become corrupt as a side effect of the blocks you are writing to, so you can't work around it.
In other words, such devices are not usable for anything if you need power fail safety. Atomicity is the least of your worries.
The most extreme awfulness I've heard of is USB flash reporting fake capacity. So when you write your important files to it, it's guaranteed to corrupt them. https://www.sqlite.org/howtocorrupt.html#_fake_capacity_usb_...
However, to the extent you decide to depend on a device not corrupting random unrelated data on power failure, it's hard to see why you should not include the NVMe-reported block atomicity in that assumption and modify your storage algorithm to take advantage of it.
Of course any device can fail catastrophically to meet its own basic specifications, such as your bit 2 in every byte example, and in general random corruption due to design or manufacturing issues. It doesn't make sense to design around those kinds of "unknown unknowns" at the B-tree and logging format design level though. Those need to be handled at a higher level, where we are willing to write off a database and perhaps switch to a backup.
Edit: ps. Thanks for the Usenix paper link. It's a good paper, and I'd hoped to see someone do those measurements. I hope to see a similar paper about NVMe someday.
I've seen 'em do it, man.