Adopted lfsr_rid/bid/mid/did_t where appropriate. This includes using
lfsr_rid_t for tag/rbyd weights. Although I am using lfsr_srid_t for
rbyd weights now, since it both captures the use of the sign bit and
reduces the number of casts a bit in the code.
I learned recently Zig has any-bit integers (e.g. uint31_t), and I'm
realizing how nice it would be to have those in this codebase.
Also tried to use lfs_size_t/lfs_off_t more correctly. In Linux/BSD,
only off_t is used for file-size-related operations and is usually much
larger than size_t. These were used interchangably in littlefs and their
original meaning kind of fell by the wayside. Getting their use right
will be important if littlefs ever supports different integer widths.
There is a bit of redundancy here, as we already know the weights of
btree's inner-branches from their parents. But in theory sharing the
same encoding for both the top level btree reference and inner-branches
should offer more chance for deduplication and hopefully less code.
This also moves some members around in the btree encoding so that the
redund blocks are at the beginning. This _might_ simplify decoding of
the variable-length redund blocks at some point.
Current btree encoding:
.----+----+----+----.
| blocks ... redund leb128s (1-20 bytes)
: :
|----+----+----+----|
| trunk ... 1 leb128 (1-5 bytes)
|----+----+----+----|
| weight ... 1 leb128 (1-5 bytes)
|----+----+----+----|
| cksum | 1 le32 (4 bytes)
'----+----+----+----'
This also partially reverts some tag name changes:
- BNAME -> BRANCH
- DMARK -> BOOKMARK
Our rbyds support changing the weight of a tag without knowing the
actual tag. This is useful for btrees, which always make weight changes
without knowing if the leading tag is a name or a branch (it depends on
the type of btree).
But to make this work, it needs the rm-bit to be set. This is because
internally the rm-bit indicates we don't want to write-out a tag. Which
we don't for grow tags, because, well, they're not real tags.
Previously this was done by putting LFSR_TAG_GROW(RM) everywhere a
generic grow as needed, but since this is so common we might as well
just set the rm-bit in LFSR_TAG_GROW.
Note that LFSR_TAG_GROW(tag) (the macro) does not set the rm-bit.
This makes the code a bit more readable at the risk of an unintuitive
relationship between LFSR_TAG_GROW and LFSR_TAG_GROW(tag).
Thanks to lazy merging, our btree nodes can drop to zero weight at
pretty much any time. Unfortunately, we can't really represent non-root
zero weight btree nodes, so things break. (Though even if we could,
those nodes would become unreachable).
Previously we relied on fuzz testing to try to catch these cases, but
that turned out to be insufficient.
This adds explicit tests covering the cases where btree drops can occur,
thanks to the realy-big-attr trick used in similar mtree tests.
Sure enough this revealed a bug that can occur when we split a btree
node at the same time one of the siblings goes to zero weight. (Remember
splits carried out before playing attr-lists).
---
Fortunately this is pretty easy to fix. We can just reroute our split
code to the normal commit/compact recursion handling if one of our
siblings drops to zero, at the cost of some spaghetti.
xkcd.com/292 seems relevant here.
With the introduction of lfsr_data_t, these stopped being useful
functions for littlefs internally.
Maybe these tests should be rewritten to use the *_lookup functions
directly? Unfortunately with the quantity of tests we have now this adds
non-trivial amount of work with questionable benefit.
These functions are no longer needed in lfs.c. They are still needed for
the tests as they are written, but that's not a reason to pollute the
littlefs source code.
Maybe these tests should be rewritten to use lfsr_btree_commit directly?
Unfortunately with the quantity of tests we have now this adds
non-trivial amount of work with questionable benefit.
This adds an extra bid parameter to lfsr_rbyd_appendall so that attrs
relative to a bid can be adjusted correctly.
This allows us to make attr-lists const again, which is generally a good
things. Passing around complex mutable state is just asking for bugs.
Though since these attr-lists are generally just passed as temporary
arguments, maybe it's not that bad?
The idea here: Instead of having unique functionality for each
individual btree operation (push/set/pop/split), we treat btrees sort of
like rbyds, with a single commit entry point that operates on attr-lists.
This adds code cost, due to needing to parse the attr-list for properties
that can affect inlined btrees (tag changes mostly), but, in theory, comes
with some advantages:
1. A single btree commit entry point with all of the inlined/uninlining
logic should offer better chances for code deduplication, vs
spreading this logic out in each btree operation.
2. Higher-levels should know what the current weight of the branch is,
so we may be able to avoid the implicit math needed to calculate
deltas.
3. Higher-levels have more knowledge about the state of the btree in
general, so there may be other shortcuts. The mtree, for example,
only operates on weight=1 entries, which greatly simplifies a lot of
the related math.
Note that btrees still have strict limits in what's possible in an
attr-list. Btree operations can't cross leaf-rbyd boundaries for
example.
---
A notable omission in this change is the loss of reinlining btrees.
This wase dropped for a couple reasons. It may be worth adding back at a
later time, maybe after we actually have files implemented, but for now
does not seem worth it:
1. Reinlining adds code cost. Reinlining is more complex than you might
expect because we only reinline on compaction. And because we compact
before playing out our attr-list, we need to know if a commit makes
the btree inlinable before committing to the btree.
This is still doable with our attr-lists. We already derive the
change in tags, since we need this to know when to uninline. But it
adds a kind of complex bailing out of btree commits.
2. The benefits of reinlining may not be that great. In most systems, a
tree that is uninlined once is likely to be uninlined again. It's
only if there is a bigger state change in a system that it makes
sense to reinline.
Though, to be fair, waiting for compaction to reinline handled this
quite well. Only reinlining when all erased storage is used up...
3. Thanks to our roots did entry, our mtree can never reinline.
It would be nice to change this, but this would require explicit
handling in lfsr_mdir_commit. Future work?
4. Files are another can of worms, with more complex interactions with
inlinability thanks to (at least on paper right now) always having
inlined data even when uninlined.
If reinlining is valuable for files this can change during that work.
5. Even if files never support reinlinability, truncating files (via
either lfsr_file_truncate or LFSR_O_TRUNC) should give the file a
blank slate, effectively reinlining the file in that case.
---
The current implementation also changes the attr-list to be mutable so
we can adjust attr-list based on the current btree node. This is a
temporary hack! We should add the appropriate functionality to our rbyd
utilities to revert this eventually.
These tests, and this feature really, is a bit tricky since our btrees
reinline "lazily". That is, our btrees only check if they can inline
during compaction, allowing potentially inlinable btrees to remain
uninlined.
This better utilizes any erased storage in the btree's rbyd, but adds
some corner cases we need to be concerned about.
Added because of some ongoing btree rewrite work, where it did catch
incorrect behavior.
Realistically, because our btree is protected by CoW checksums, the only
place we can end up with a cycle is in our mroot chain.
This is convenient, as we don't need our btree traversal state when
traversing the mroot chain, so we can put both the tortoise state and
btree traversal state into a union, theoretically saving some RAM.
Unfortunately stack measurements show no change, even though our mtree
traversal in on the hot path. I'm not sure why this is. My best guess is
that the RAM savings is beneath the compilation noise floor, since we
currently only ever create one of these structs.
Composable parsing functions always feel a bit weird to me in C. I don't
know if this is because of something C lacks, such as multiple return
values, or if composable parsers are just inherently awkward to describe
in procedural languages because of the different levels of state.
But I think the API here is pretty ok. The main idea is that data
parsers can be added as functions in the lfsr_data_* namespace that take
lfsr_data_t as a mutable reference, updating the lfsr_data_t's internal
state as data is parsed.
In practice you only need a couple of primitives, bytes, le32s, leb128s,
that touch the internals of lfsr_data_t, and the other parsers can be
built using these.
This leverages the pointer-like abstraction of lfsr_data_t, and avoids
needing to keep track of offsets. And thanks to lfsr_data_t being
relatively cheap to make copies, this API is relatively flexible.
Some other tweaks:
- Signed leb128 overflow detection is moved up into lfs_fromleb128.
littlefs now assumes _all_ leb128s are 31-bits, which is useful for
leveraging the sign bit internally.
This also fixes the an issue in overflow detection in lfs_fromleb128
which wouldn't catch overflows in the last byte of a >32-bit leb128.
- Most lfsr_data_t functions now take a pointer. This offered a small
bit of code savings and feels more natural in C. Though most functions
that accept lfsr_data_t still take a copy. Most of these functions
would need to make a copy anyways now that the parsers are consuming,
and these copies avoid concerns about shared state.
At 3-words, lfsr_data_t is right at that boundary of questionable
reasonableness for copying, but copying is a very useful feature of
this struct.
This ends up with some decent code/stack savings:
code stack
before: 22118 2048
after: 21722 (-1.8%) 1992 (-2.7%)
Generally the more creative you get with C macros, the more
unmaintainable your codebase becomes, but in this case I think a small
bit of macro sugar for the attribute lists in littlefs goes a long way
for making the internals flexible and readable.
Attribute lists generally look like this:
LFSR_ATTRS(
LFSR_ATTR(id, TAG, delta, DATA(data)),
LFSR_ATTR(id, TAG, delta, DATA(data)),
...
LFSR_ATTR(id, TAG, delta, DATA(data)))
Which more-or-less gets expanded to this:
((const lfsr_attr_t[]){
((lfsr_attr_t){id, LFSR_TAG_TAG, delta, LFSR_DATA_DATA(data)}),
((lfsr_attr_t){id, LFSR_TAG_TAG, delta, LFSR_DATA_DATA(data)}),
...
((lfsr_attr_t){id, LFSR_TAG_TAG, delta, LFSR_DATA_DATA(data)}),}),
attr_count
Note the use of preprocessor concatenation to put the TAG and DATA
identifiers in their respective namespaces. These can end up invoking
other macros, which allows attrs to be rather extensible.
Previously there were also LFSR_ATTR_ (note the trailing underscore)
macros to allow passing of variable tags/datas. This is replaced with
redundant macros which sort of "unwrap" themselves as a part of macro
expansion. This avoids a bunch of duplicate macro definitions.
#define LFSR_TAG_TAG(tag) (tag)
#define LFSR_DATA_DATA(data) (data)
So:
LFSR_ATTR(id, TAG(tag), delta, DATA(data))
Becomes:
((lfsr_attr_t){id, LFSR_TAG_TAG(tag), delta, LFSR_DATA_DATA(data)})
Becomes:
((lfsr_attr_t){id, tag, delta, data})
Originally it made sense to name the rbyd ids, well, ids, at least in
the internals of the rbyd functions. But this doesn't work well outside
of the rbyd code, where littlefs has to juggle several different id
types with different purposes:
- rid => rbyd-id, 31-bit index into an rbyd
- bid => btree-id, 31-bit index into a btree
- mid => mdir-id, 15-bit+15-bit index into the mtree
- did => directory-id, 31-bit unique identifier for directories
Even though context makes it clear which id the id refers to in the rbyd
internals, updating the name to rid makes it clearer that these are the
same type of id when looking at code both inside and outside the rbyd
functions.
This turned out to be tricky.
At littlefs's core, we have the lfsr_rbyd_t struct. It is really
important this is as small as possible since littlefs creates many rbyd
copies in order to track state of metadata on disk.
Wrapping rbyd, we have the lfsr_btree_t struct, which can alternatively
contain a single inlined entry, accomplished by overlapping the width
field in both cases. And the lfsr_mdir_t struct, which tracks any redundant
blocks, and would be nice if the blocks lined up as neighbors so all blocks
involved in the mdir could be passed around as an array. Both of these
wrappers attempt to overlap fields of the lfsr_rbyd_t struct, which presents
a bit of a problem.
The solution here is to put the rbyd block field at the beginning of the
lfsr_rbyd_t struct, and use exactly 32-bits of padding in lfsr_btree_t
to overlap the width field even though it is not at the beginning of the
struct. To avoid inflating the lfsr_btree_t size, we sneak the inlined
size and tag into the overlapping padding. This will need special
handling if the size of these fields change, but saves a decent amount
of RAM:
lfsr_rbyd_t lfsr_btree_t lfsr_mdir_t
8b 8b 8b 8b
.----+----+----+----.
| mid.bid | mid.rid |
|----+----+----+----|
8b 8b 8b 8b 8b 8b 8b 8b | blocks |
.----+----+----+----. .----+----+----+----. | |
| block |..| tag |size|padd|.>| |
|----+----+----+----| |----+----+----+----| |----+----+----+----|
| weight |.>| weight | | weight |
|----+----+----+----| |----+----+----+----| |----+----+----+----|
| trunk | | inlined data | | trunk |
|----+----+----+----| | | | |----+----+----+----|
| off | | v | | off |
|----+----+----+----| | | |----+----+----+----|
| crc | | | | crc |
'----+----+----+----' '----+----+----+----' '----+----+----+----'
Also tried to reduce the amount of mdir usage in lfsr_mdir_commit by
better using only the arrays of relevant mdir blocks, to limited success.
The previous system of relying on test name prefixes for ordering was
simple, but organizing tests by dependencies and topologically sorting
during compilation is 1. more flexible and 2. simplifies test names,
which get typed a lot.
Note these are not "hard" dependencies, each test suite should work fine
in isolation. These "after" dependencies just hint an ordering when all
tests are ran.
As such, it's worth noting the tests should NOT error of a dependency is
missing. This unfortunately makes it a bit hard to catch typos, but
allows faster compilation of a subset of tests.
---
To make this work the way tests are linked has changed from using custom
linker section (fun linker magic!) to a weakly linked array appended to
every source file (also fun linker magic!).
At least with this method test.py has strict control over the test
ordering, and doesn't depend on 1. the order in which the linker merges
sections, and 2. the order tests are passed to test.py. I didn't realize
the previous system was so fragile.
It doesn't make sense to test more complex logic, such as t2_btree.toml,
when the logic it is built on, t1_rbyd.toml, does not past testing. The
test runner already guarantees a consistent lexicographic order, so all
we need to do is renamed these from test_* -> tn_*.
Note, if we every have more than 10 tests, we will need to bump up the
number of digits for all tests, so t1_rbyd.toml -> t01_rbyd.toml. This
is the main downside of lexicographic ordering. But we'll cross that
bridge when we get to it.
Took the opportunity to make some allocator tweaks:
- Renamed lfs.free -> lfs.lookahead, it's previous name did cause some
confusion.
- Renamed lfs.free.off -> lfs.lookahead.start
- Renamed lfs.free.i -> lfs.lookahead.next
- Renamed lfs.free.ack -> lfs.lookahead.acked
- Changed bitmap from using 32-bit words to using 8-bit bytes, dropping
the alignment requirement. One of the reasons for 32-bit alignment was
an attempt at future proofing for some sort of free-list.
This never landed, and if it did, it could have been provided without
breaking backwards compatiblity via an additional config option, at a
minor RAM cost.
We never used ffs/clz instructions for this bitmap, so I don't think
using 32-bit words offers much advantage. It just creates another
potential issue for users if their lookahead buffer is unaligned.
These changes should probably also be upstreamed to the current version.
They don't depend on anything rbyd specific.
Note, at some point lfs_alloc will need to be extended to mark block tags,
etc, as in-use during traversal.
Originally I thought doing a linear search during fetch was going to be
the best route for name lookups, since we already needed a O(b) fetch,
which set a hard ceiling for name lookup performance.
But it turns out we don't need to fetch during btree name lookups
unless we're also validating! Now that validation and lookups are
disentangled, we can do a binary search over the rbyd to drop our
name lookup down to O(log(b)^2).
Two other motivations for this change:
1. This removes the name search from lfsr_rbyd_fetch, which has been
surprisingly tricky to get right.
2. Now that lfsr_rbyd_fetch doesn't need to follow the create/delete
history to find and reconstruct the state of names, we only need to
know the create/delete state of tags post-fetch, freeing up the rbyd
encoding to be more flexible.
The next thing I plan to do is drop on-disk remove tags, for example.
This drops the total btree namelookup cost from O(b log_b(n)) to
O(log(b)^2 log_b(n)).
Validating btree nodes during lfsr_btree_lookup was useful as a
proof-of-concept, but it's not really needed if we validate btree nodes
during mtree traversal.
mtree traversal provides the first reads into the filesystem. It's how
we find the real mroot, and (in theory at the moment) it provides the core
operation for error detection in correction. With this in mind,
implementing btree node validation in mtree traversal makes a lot of
sense, with lfsr_btree_lookup leveraging an assumed successful
validation for faster/smaller btree walks.
Note that btree node validation during traversal is still optional. We
really don't want to pay this cost during block allocation for example.
---
It may look concerning that there's no related validation in btree traversal
layer itself.
It turns out that a quirk of btree traversal returning inner btree nodes on
first visit, before actually traversing the btree node, is that it's
safe for us to validte the btree node in only the mtree traversal layer.
As long as we don't continue traversing on finding a corrupted btree,
the btree traversal layer will never traverse an unvalidated btree node.
This keeps all the validation logic in the same place, mtree traversal.
I don't know if this will stay this way if/when more error correction
features are added, but it's convenient in the meantime.
Just like lfsr_btree_traversal_t, lfsr_mtree_traversal_t provides a
mechanism for traversing the mtree incrementally, including any inner
btree nodes.
This is one level more complex than btree traversal because we also need
to handle the mroot chain and traversal of rids in each mdir.
Again, mtree traversal returns temporary decoded rbyd structs for inner
nodes. Actually, mtree traversal only returns inner nodes... so maybe
using lfsr_data_t here is the wrong choice:
- tag=LFSR_TAG_BTREE => lfsr_rbyd_t
- tag=LFSR_TAG_MDIR => lfsr_mdir_t
The main thing to note is that traversal here != iteration.
Thanks to the right-leaning nature of our btrees, iteration is already
provided by lfsr_btree_lookupnext, using the bid as the current
iteration state.
What btree traversal provides is traversal over every rbyd + entries
used in the btree, include the inner btree nodes. This is useful for
things like garbage collection and error detection that need to operate
on the raw rbyds.
Note that both btree traversal and iteration are still O(n log_b(n)). We
can't do any better than that without recursion.
One non-intuitive implementation detail, we return a tag describing each
entry, but instead of returning an on-disk data reference for inner
btree nodes, we return a pointer to a temporarily decoded rbyd struct.
This simplifies root handling, and we probably want the decoded version
anyways:
- tag=LFSR_TAG_BTREE => lfsr_rbyd_t
- tag=anything else => lfsr_data_t
The reason for making btree traversal incremental, and not just use a
callback like we've done previously, is to eventually use this as a part
of high-level incremental garbage-collection/error-correction. For this
to work, all of the lower-levels also need to be incremental.
- lfsr_btree_lookupnext_ => gives you the underlying rbyd/rid, intended
for btree-internal use.
- lfsr_btree_lookupnext => does not give you underlying rbyd/rid, used
for general purpose lookups/iteration.
This is for consistency with other *_lookupnext functions, and
discourages use of the leaf rbyd/rid. These are sensitive to internal
btree state.
This is mostly for consistency. It's unclear if we'll ever actually
use the on-disk lfsr_data_t representation here, since current thoughts
expect most btrees to only store pointers with in-device
representations.
It may be worth reverting this in the future.
The exact behavior of lfsr_rbyd_lookup is a bit unusual, and has already
resulted in a few mistakes. To make this more clear at a glance, names
have been changed and a few more helper functions added.
The new names and expected behavior:
- *_lookupnext - lookup the smallest id/tag less than or equal to the
requested id/tag, returns LFS_ERR_NOENT if id/tag is greater than all
ids/tags in the data structure.
- *_lookup - lookup the exact id/tag, returns LFS_ERR_NOENT if id/tag
is not in the data structure.
These have been adopted in all current data structures: rbyd/btree/mdir
- lfsr_rbyd_lookup => lfsr_rbyd_lookupnext
- lfsr_btree_lookup => lfsr_btree_lookupnext
- lfsr_btree_namelookup => lfsr_btree_namelookupnext
- lfsr_mdir_lookup => lfsr_mdir_lookupnext
Note no lfsr_btree_namelookup is added, this is a more complicated
than lfsr_btree_lookup (we need to cmp the name on-disk for equality)
and also probably not needed.
The main issue with the btree tests cross-geometry is the number of ways
large btrees can run out of memory without garbage collection.
- Large progs => A lot of padding on non-compacting commits
- Small blocks => Deeper trees and more compacts
Rather than figure out every precondition, I've just added code that
ignores out-of-space errors.
As a plus this is now also testing that errors don't corrupt the btree
being modified.
Bugs found:
- Thanks to lazy merges, it's possible for an in-btree rbyd weight to
equal the total btree weight even when it's not the child of the root
of the btree.
The behavior is the same (collapse all degenerate parent), but the
assert that we were the root's child is incorrect.
- Thanks again to lazy merges, it's possible to merge siblings where one
of the blocks has no entries. Attempting to reintroduce the split name
in this case can lead to LFS_ERR_NOENT issues.
Fortunately we can simply skip the reintroduction of the split name in
this case.
Also added more asserts for LFS_ERR_RANGE in lfsr_btree_commit. This is
still a rather fragile part of the algorithm so the asserts here help
identify when the pending attribute size is the problem.
- len => size - these all refer to byte-arrays
- buf => buffer - this doesn't matter but buffer is currently used more
- delta => d - we use delta for weight deltas, gstate deltas, using a
slightly different name (if somehow even less descriptive) for byte
offset-offsets helps avoid name collisions a little bit
The storage changes in btree operations should've probably been a
separate commit but got wrapped up in these changes. Now the high-level
btree operations are responsible to the attr storage for all internal
btree commits, as defined by LFSR_BTREE_SCRATCHATTRS.
This leads to slightly less total RAM usage, since it allows the
low-level btree operations to cannibilize the attrs of the high-level
btree operations as a part of its unrolled-tail-recursive
implementation.
This also includes some other cleanup such as removing old commented out
parts.
This is an absurd optimization that stems from the observation that the
branch encoding for the inner-rbyds in a B-tree is enough information to
jump directly to the trunk of the rbyd without needing an lfsr_rbyd_fetch.
This results in a pretty ridiculous performance jump from O(m log_m(n/m))
to O(log(m) log_m(n/m)).
If the complexity analysis isn't impressive enough, look at some rough
benchmarking of read operations for 4KiB-block, 1K-entry B-trees:
12KiB ^ :: :. :: .: .: :. : .: :. : : .. : : . : .: : : :
| .:: .::.::.:: ::.::::::::::::.::::::::.::::::::::::.
| : :::':: ::'::'::':: :' :':: :'::::::::': ::::::': :
before | ::: ::' :' :' :: :' '' ' ' '' : : : '' ' ' '
| ::: ''
|:
0B :'------------------------------------------------------>
.17KiB ^ ............:::::::::::::::::::::::::::::
| . .....:::::''''''''' ' ' '
| .::::::::::::
after | :':''
|.::
.:'
0B :------------------------------------------------------->
0 1K
In order for this to work, the branch encoding did need to be tweaked
slightly. Before it stored block+off, now it stores block+trunk where
"trunk" is the offset of the entry point into the rbyd tree. Both off
and trunk are enough info to know when to stop fetching, if necessary,
but trunk allows lookups to jump directly into the branches rbyd tree
without a fetch.
With the change to trunk, lfsr_rbyd_fetch has also be extended to allow
fetching of any internal trunks, not just the last trunk in the commit.
This is very useful for dbgrbyd.py, but doesn't currently have a use in
littlefs itself. But it's at least valuable to have the feature available
in case it does become useful.
Note that two cases still requires the slower O(m log_m(n/m)) lookup
with lfsr_rbyd_fetch:
1. Name lookups, since we currently use a linear-search O(m) to find names.
2. Validating B-tree rbyd's, which requires a linear fetch O(m) to
validate the checksums. We will need to do this at least once
after mount.
It's also worth mentioning this will likely have a large impact on B-tree
traversal speed. Which is huge as I am expecting B-tree traversal to be
the main bottleneck once garbage-collection (or its replacement) is
involved.
I've been wanting to make this change for a while now (tag,id => id,tag).
The id,tag order matches the common lexicographic order used for sorting
tuples. Sorting tag,id tuples by their id first is less common.
The reason for this order in the codebase is because all attrs on disk
start with their tag first, since its decoding determines the purpose of
the id field (keep in mind this includes other non-tree tags such as
crcs, alts, etc). But with the move to storing weights instead of tags
on disk, this gives us a clear point to switch from tag,w to id,tag
ordering.
I may be thinking to much about this, but it does affect a significant
amount of the codebase.
More proof the tests are working.
This bug was in the code that does an extra lookup for the was-split entry
during merge, so we make sure we have the right id to attach the split
name to. Humorously, this code was already set up correctly, the
"split_id" just wasn't actually used. Unfortunately since
lfsr_rbyd_lookup uses out-pointers to return multiple things the
compiler couldn't detect the unused variable.
B-trees with names are now working, though this required a number of
changes to the B-tree layout:
1. B-tree no-longer require name entries (LFSR_TAG_MK) on each branch.
This is a nice optimization to the design, since these name entries
just waste space in purely weight-based B-trees, which are probably
going to be most B-trees in the filesystem.
If a name entry is missing, the struct entry, which is required,
should have the effective weight of the entry.
The first entry in every rbyd block is expected to be have no name
entry, since this is the default path for B-tree lookups.
2. The first entry in every rbyd block _may_ have a name entry, which
is ignored. I'm calling these "vestigial names" to make them sound
cooler than they actually are.
These vestigial names show up in a couple complicated B-tree
operations:
- During B-tree split, since pending attributes are calculated before
the split, we need to play out pending attributes into the rbyd
before deciding what name becomes the name of entry in the parent.
This creates a vestigial name which we _could_ immediately remove,
but the remove adds additional size to the must-fit split operation
- During B-tree pop/merge, if we remove the leading no-name entry,
the second, named entry becomes the leading entry. This creates a
vestigial name that _looks_ easy enough to remove when making the
pending attributes for pop/merge, but turns out the be surprisingly
tricky if the parent undergoes a split/merge at the same time.
It may be possible to remove all these vestigial names proactively,
but this adds additional rbyd lookups to figure out the exact tag to
remove, complicates things in a fragile way, and doesn't actually
reduce storage costs until the rbyd is compacted.
The main downside is that these B-trees may be a bit more confusing
to debug.
Name lookup brings back the O(m') scan-during-fetch approach of the
previous metadata layout. Since our rbyd trees map id+attr pairs and not
actual names, this beats the alternative O(m log(m)) scan of the tree.
Though tree searching does only include the current attributes, where as
scanning during fetch needs to also look at outdated attributes. Which
may make the winner less obvious depending on how we find the rbyd. But
being able to do the search in the same pass as fetch is an extra plus.
---
What turned out to be surprisingly complicated was the propagation of
names during B-tree splits and merges. The on-disk reference,
lfsr_data_t, does most of the heavy lifting here, but there's just a lot
of corner cases to consider.
At the moment this isn't working due to outdated names on the leading
entries of the rbyds, but to fix this bigger changes to the B-tree
layout may be needed.
TEST_PERMUTATION/BENCH_PERMUTATION make it possible to map an integer to
a specific permutation efficiently. This is helpful since our testing
framework really only parameterizes single integers.
The exact implementation took a bit of trial and error. It's based on
https://stackoverflow.com/a/7919887 and
https://stackoverflow.com/a/24257996, but modified to run in O(n) with
no extra memory. In the discussion it seemed like this may not actually
be possible for lexicographic ordering of permutations, but fortunately
we don't care about the specific ordering, only the reproducibility.
Here's how it works:
1. First populate an array with all numbers 0-n.
2. Iterate through each index, selecting only from the remaining
numbers based on our current permutation.
.- i%rem --.
v .----+----.
[p0 p1 |-> r0 r1 r2 r3]
Normally to maintain lexicographic ordering you should have to do a O(n)
shift at this step as you remove each number. But instead we can just swap
the removed number and number under the index. This effectively
shrinks the remaining part of the array, but permutes the numbers
a bit. Fortunately, since each successive permutation swaps
at the same location, the resulting permutations will be both
exhaustive and reproducible, if unintuitive.
Now permutation/fuzz tests can reproduce specific failures by defining
either -DPERMUTATION=x or -DSEED=x.
I wondered if walking in Python 2's footsteps was going to run into the
same issues and sure enough, memory backed iterators became unweildy.
The motivation for this change is that large ranges in tests, such as
iterators over seeds or permutations, became prohibitively expensive to
compile. This meant more iteration moving into tests with more steps to
reproduce failures. This sort of defeats the purpuse of the test
framework.
The solution here is to move test permutation generation out of test.py
and into the test runner itself. The allows defines to generate their
values programmatically.
This does conflict with the test frameworks support of sets of explicit
permutations, but this is fixed by also moving these "permutation sets"
down into the test runner.
I guess it turns out the closer your representation matches your
implementation the better everythign works.
Additionally the define caching layer got a bit of tweaking. We can't
precalculate the defines because of mutual recursion, but we can
precalculate which define/permutation each define id maps to. This is
necessary as otherwise figuring out each define's define-specific
permutation would be prohibitively expensive.
Another straightforward exercise of making sure the pending attributes
are setup correctly.
If you think this isn't worth its own function, consider how much
overhead the 3x commits for pop+push+push would add, especially for
large-prog devices.
Worst case this can be dropped in the future.
A single child is just another condition to watch out for during B-tree
merge, since a single-child obviously can't have a sibling.
This is a good safety to have, but I was surprised this can happen. But
it turns out to be quite easy since our rbyds defer the B-tree
operations until compaction. A merge down to a single child won't
propagate the merge until the parent compacts.
B-tree remove/merge is the most annoying part of B-trees.
The implementation here follows the same ideas implemented in push/split:
1. Defer splits/merges until compaction.
2. Assume our split/merge will succeed and play it out into the rbyd.
3. On the first sign of failure, revert any unnecessary changes by
appending deletes.
4. Do all of this in a single commit to avoid issues with single-prog
blocks.
Mapping this onto B-tree merge, the condition that triggers merge is
when our rbyd is <1/4 the block_size after compaction, and the condition
that aborts a merge is when our rbyd is >1/2 the block_size, since that
would trigger a split on a later compact.
Weaving this into lfsr_btree_commit is a bit subtle, but relatively
straightforward all things considered.
One downside is it's not physically possible to try merging with both
siblings, so we have to choose just one to attempt a merge. We handle
the corner case of merging the last sibling in a block explicitly, and
in theory the other sibling will eventually trigger a merge during its
own compaction.
Extra annoying are the corner cases with merges in the root rbyd that
make the root rbyd degenerate. We really should avoid a compaction in
this case, as otherwise we would erase a block that we immediately
inline at a significant cost. However determining if our root rbyd is
degenerate is tricky. We can determine a degenerate root with children
by checking if our rbyd's weight matches the B-tree's weight when we
merge. But determining a degenerate root that is a leaf requires
manually looking up both children in lfsr_btree_pop to see if they will
result in a degenerate root. Ugh.
On the bright side, this does all seem to be working now. Which
completes the last of the core B-tree algorithms.
This was a rather simple exercise. lfsr_btree_commit does most of the
work already, so all this needed was setting up the pending attributes
correctly.
Also:
- Tweaked dbgrbyd.py's tree rendering to match dbgbtree.py's.
- Added a print to each B-tree test to help find the resulting B-tree
when debugging.
- After a B-tree split, when we're append pending attributes, it's
possible for the id chosen for bisection to be itself modified by
pending grows/shrinks. This needs to be accounted for in the two
passes for the two children.
But this means our tests are working.
This really just required care around calculating the expected B-tree id
and rbyd id (which are different!).
B-tree append, aka B-tree push with id=weight, is actually the outlier.
We need a B-tree id that can identify the rbyd we're appending to, but
this id itself doesn't exist in the tree yet, which can be a bit tricky.
This involves many, many hacks, but is enough to test the concept
and start looking at how it interacts with different block sizes.
Note only append (lfsr_btree_push on the end) is implemented, and it
makes some assumption about how the ids can interact when splitting
rbyds.