This work already indicates we need more data-related helper
functions. We shouldn't need this many function calls to do "simple"
operations such as fetch the superconfig if it exists.
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.
This was a correct-but-inefficient bug where lfsr_btree_split
unconditionally added name entries, but if we don't have a name writing
those entries just wastes storage/lookup cost.
Also cleaned up lfsr_btree_commit attr usage to be a bit more
consistent.
Unfortunately some rough measurements around the ternary selection of
attributes shows it's a bit costly, perhaps because gcc isn't that smart
about optimizing compound literals. It may be worth seeing if there's a
more efficient way to implement these in the future, but hey, at least
this implementation leads to concise source code.
- Consistent handling of missing branches - now asserts
- Consistent short-circuiting of name-less branches - we can always pull
these off in one lookup
- Skip validating already-fetched rbyd - this only affects the root
rbyd, but as the most heavily accessed rbyd in the tree this is a nice
optimization. In practice root rbyds should be validated exactly once.
- Dropped accidental redundant check of some btree merge conditions
Emphasis on slightly.
Preliminary benchmarking already shows btree split as a significant spike
and main read cost of lfsr_btree_commit, so any savings here are
valuable.
Unfortunately the problem of evenly bisecting an rbyd can be reduced to
finding the mid-point in an array of arbitrary weights, which is O(m)
best case (and O(m log(m)) over our rbyds).
But at the time we realize compact will fail, we have already traversed
at least 1/2 of the tags in the rbyd. If we also keep track of
cumulative dsize, we can in theory bisect the rbyd by traversing only
another 1/2 of the tags in the rbyd.
The implementation here does this by:
1. Keep track of the lower_dsize as we compact.
2. If we split, first traverse backwards through ids keeping track
of the upper_dsize.
3. Steal dsize from lower_dsize in the case it's over-committed.
4. Stop when both upper_dsize and lower_dsize are more-or-less equal.
So for example:
an rbyd needing compaction:
[a b c d e f g h i j k l _ _ _ _]
compact to 1/2 the rbyd, oh no it doesn't fit, we need to split:
[a b c d e f g h i j k l _ _ _ _]
-------------->
traverse from the end to find the mid-point:
[a b c d e f g h i j k l _ _ _ _]
-------------->
<----------
Best case, a barely overflowing rbyd, we end up traversing m*3/4 tags:
[a b c d e f g h _ _ _ _ _ _ _ _]
-------------->
<------
Worst case, a full rbyd, we end up traversing m*1 tags:
[a b c d e f g h i j k l m n o p]
-------------->
<--------------
I was hopeful it would be possible to remove the weight lookup in
lfsr_btree_namelookup. We do a linear search during fetch to find the
name and tag, so finding the weight as well for free looked promising.
Unfortunately, it seems to be impossible to reliably find the weight.
Consider what happens when we match an id that is later deleted. We know
the new id should be id-1, but we don't have enough information to
determine the new weight.
So just ended up adding a comment explaining the limitation and cleaning
up the logic in lfsr_rbyd_fetch a bit.
lfsr_data_t is proving itself to be a powerful abstraction.
As a plus, the reduction from two out-pointers to one out-pointer in
lookup functions (off+size vs lfsr_data_t) may actually save some code
size in places.
Also adopted the ones-complement sort of conditional size field similar
to the weight field in lfsr_btree_t.
This finally provides a solution for deferred B-tree inlining without
needing to evaluate attrs.
Deferred inlining is the idea that instead of inlining B-trees as soon
as the number of entries drops to either 1 or 0, we wait until a
compaction occurs to inline a B-tree. This accomplishes a few things:
1. Limits any extra reads for conditions to compaction time.
2. Avoids wasting erased bytes if we drop to 1 or 0 entries only
temporarily.
3. Avoids excessive erase costs if we oscillate between ~1 and ~2
entries.
Unfortunately after moving away from evaluating attrs, deferred inlining
became deceptively tricky.
In the current, non-evaluating-attr implementation, our btree commits
always lag one commit behind. When we compact, we first compact
everything currently in the rbyd, and then append any pending attr.
Never needing to evaluate the attrs removes a big chunk of logic as long
as we can assert that the largest attr set fits after compaction.
But this lagging of commits presents a problem for deferred inlining, if
we detect an inlinable tree during compaction, we can't be sure it's
_actually_ inlinable until we evaluate our attr. Which we really don't
want to do.
The solution here is to move the problem up a level. Instead of trying
to determine when to inline purely from the provided attr, we require
higher-level functions to provide this info in the form of a "cutoff".
Where, if compaction results in fewer entries than this cutoff, the
higher-level function can instead inline.
This effectively allows the higher-level functions to intercept
unnecessary compactions that can be inlined.
So far this solution seems to work quite well, with the added plus of
consolidating the corner cases around inlined/inlining btrees in these
higher-level functions.
---
Note that this has the peculiar side-effect of allowing zero-weight,
non-inlined B-trees. Our previous internal B-tree struct using the sign
of an integer to determine inline-ness, this was changed to use just the
sign-bit for the condition as a sort of ones-complement width field.
I think this sort of encoding may actually bit a tiny bit more
efficient. I was poking around with thumb code and noticed there is no
actual "abs" instruction, with gcc outputing an "it" sequence. But there
is a cheap bit-clear "bic" instruction.
Mostly just moving the rbyd commit/compact operations into the same code
path so they can share the same tail-recursive propagation of their
branch encoding.
Also tried to make variable names in lfsr_btree_commit a bit more consistent.
- 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 may seem more complicated to decode, we can't assume crcs start
at the beginning of the data, but this layout of putting the crcs at the
end has the benefit of allowing the size of the crc to be unknown in
certain cases.
The is a bit of optimistic future proofing for the case where we may
support different crc widths.
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.
- The erased flag in lfsr_rbyd_t uses only a single bit, which is
wasteful for a heavily used struct in littlefs. We can use
rbyd.off=block_size to indicate the same state for free. Note that
when rbyd.off=block_size, we must treat rbyd as unerased anyways.
- Improved state handling in rbyd_append/commit when an error occurs.
I will be trying to make better use of cleanup gotos to make these
functions less unpredictable when an error occurs. Hopefully the state
of littlefs after an error can be well-defined in the future.
- Fixed sign-mismatch warnings in asserts when compiled outside of the
test runner.
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.
I keep wanting this to use a linked-list, since I think there's
potentially some interesting use with lower layers cheaply prepending
attributes to attribute lists from upper layers. (terminating at the
user-provided custom attributes, for example). But this never really
works out.
In this case, the amount of in-place editing in B-trees just makes
maintaining the next pointers just not worth the extra code cost. And
it's likely measurements will show what was found in the original
version of v2: the RAM/code cost of next pointers outweighs any benefits
potentially gained from prepending attributes for free.
In practice, we can't really just prepend custom attributes, as this
would expose the internal lfs_attr_t struct and tag encoding to the
public API.
And you can always have in-device-only tags that are handled specially
to enable a limited form of this attribute list extension. This is how
custom attributes are currently implemented.
Sorting weights instead of ids just had a number of benefits, suggesting
this is a better design:
- Calculating the id and delta of each rbyd trunk is surprisingly
easier - id is now just lower+w-1, and no extra conditions are
needed for unr tags, which just have a weight of zero.
- Removes ambiguity around which id unr tags should be assigned to,
especially unrs that delete ids.
- No more +-1 weirdness when encoding/decoding tag ids - the weight
can be written as-is and -1 ids are infered from their weight and
position in the tree (lower+w-1 = 0+0-1 = -1).
- Weights compress better under leb128 encoding, since they are usually
quite small.
There have already been a number of bugs that end up writing -1 out as
leb128s. The current encoder doesn't know the different betwee -1 and
0xffffffff, so asserting before this situation can happen is quite
important for preventing these bad leb128s from ever making it into a
stable version.
Also dropped LFS_ERR_OVERFLOW to use LFS_ERR_CORRUPT for bad leb128
encodings. These end up meaning the same thing to higher layers anyways.
With the lower 4 tag bits getting all sorts of in-device-only uses,
reusing these bits to maintain diverged state during lfsr_rbyd_append is
less of a special case.
And anything that replaces the awkward 5-state, idiosyncratic diverged
state machine is a win in my opinion.
This fixed two notable bugs:
1. Using "altle 0xfff0" to terminate unreachable rbyd trunks threw off
id calculations in lfsr_rbyd_fetch searches. We derive the tag's
id+weight from the lower bound calculated as the sum of all "altle"s
and an always-followed "altle 0xfff0" throws this off.
We _could_ derive the tag's id+weight from the upper bound, inverting
this relationship, but decided to revert back to using "altgt 0" to
terminate unreachable rbyd trunks.
Using the lower bound is more intuitive, and "altgt 0" has the
benifit of supporting variable-length tags if we ever need to adopt
those.
To avoid the previous issues around 0-tag holes (which was the original
motivation for altle 0xfff0), 0-tags are now automatically adjusted
in lfsr_rbyd_lookup, and avoided in lfsr_rbyd_append.
But note! if any implemention tries to look up 0-tags, this will
eventually break! See previous commits for more info.
2. Unfortunately, we can't combine branch updates and weight updates in
lfsr_btree_commit in the general case.
If our btree contains bname tags, the weight is attached to the
bname tag, separately from the branch tag.
Branch updates in lfsr_btree_commit need two separate attrs for the
weight and branch struct for this reason, which is unfortunate.
The amount of extra conditions to make bname+branch pairs work makes
me want to redesign the inner-nodes of the btrees, but I can't think
of a better way to approach the problem.
Just like inserting tags (MKBRANCH, MKREG, etc), the interaction with
ids is a bit more intuitive with an implicit +1. To make the internal
implementation consistent, this is can be accomplished by combining
"rm" and "mk" bits into a so-called MKUNR tag.
Describing deletes as "make unreachable" makes a bit of twisted sense,
though I won't argue it's a bit of a stretch.
Worst case, this is device-side only so it can change easily in the
future. We strip the "mk" bits on any tags, so MKUNR turns into a
normal UNR on disk.
Also continued minor refactoring of lfsr_rbyd_append.
This "mk" bit must not be written to disk, it would conflict with the
other non-tree tag encodings. But we can use this bit in the context of
lfsr_tag_append to disambiguate tags changing weight from inserting new
tags.
Note that in the context of rbyd compactions, this will make things a bit
weird, since it's no longer just a direct one-to-one copy of each tag.
To make compactions a bit easier, this implementation allows the "mk"
bit to be set on any tag and ignores it when the weight delta is zero.
It turns out that this scheme greatly simplifies the awkward
leaf-split-alt calculation that previously had several if statements to
handle different corner cases, with the caveat that "mk" tags need their
ids adjusted by +1. Added this adjustment directly into lfsr_rbyd_append
for now, so the upper-level interface can be a bit more intuitive.
Though this may need to change later if it is more confusing than
helpful.
This does not work as is due to ambiguity with grows and insertions.
Before, these were disambiguated by seperate grow and attr tags. You
effectively grew the neighboring id before claiming its weight
as yours. But now that the attr itself creates the grow/insertion,
it's ambiguous which one is intended.
Changed always-follow alts that we use to terminated grow/shrink/remove
operations to use `altle 0xfff0` instead of `altgt 0`.
`altgt 0` gets the job done as long as you make sure tag 0 never ends up
in an rbyd query. But this kept showing up as a problem, and recent
debugging revealed some erronous 0 tag lookups created vestigial alt
pointers (not necessarily a problem, but space-wasting).
Since we moved to a strict 16-bit tag, making these `altle 0xfff0`
doesn't really have a downside, and means we can expect rbyd lookups
around 0 to behave how one would normally expect.
As a (very minor) plus, the value zero usually has special encodings in
instruction sets, so being able to use it for rbyd_lookups offers a
(very minor) code size saving.
---
Sidenote: The reasons altle/altgt is how it is and asymmetric:
1. Flipping these alts is a single bit-flip, which only happens if they
are asymmetric (only one includes the equal case).
2. Our branches are biased to prefer the larger tag. This makes
traversal trivial. It might be possible to make this still work with
altlt/altge, but would require some increments/decrements, which
might cause problems with boundary conditions around the 16-bit tag
limit.
I only recently noticed there is enough information in each rbyd trunk
to infer the effective grow/shrinks. This has a number of benefits:
- Cleans up the tag encoding a bit, no longer expecting tag size to
sometimes contain a weight (though this could've been fixed other
ways).
0x6 in the lower nibble now reserved exclusively for in-device tags.
- grow/shrinks can be implicit to any tag. Will attempt to leverage this
in the future.
- The weight of an rbyd can no longer go out-of-sync with itself. While
this _shouldn't_ happen normally, if it does I imagine it'd be very
hard to debug.
Now, there is only one source of knowledge about the weight of the
rbyd: The most recent set of alt-pointers.
Note that remove/unreachable tags now behave _very_ differently when it
comes to weight calculation, remove tags require the tree to make the
tag unreachable. This is a tradeoff for the above.
The main motivation for this was issues fitting a good tag encoding into
14-bits. The extra 2-bits (though really only 1 bit was needed) from
making this not a leb encoding opens up the space from 3 suptypes to
15 suptypes, which is nothing to shake a stick at.
The main downsides:
1. We can't rely on leb encoding for effectively-infinite extensions.
2. We can't shorten small tags (crcs, grows, shrinks) to one byte.
For 1., extending the leb encoding beyond 14-bits is already
unpalatable, because it would increase RAM costs in the tag
encoder/decoder,` which must assume a worst-case tag size, and would likely
add storage cost to every alt pointer, more on this in the next section.
The current encoding is quite generous, so I think it is unlikely we
will exceed the 16-bit encoding space. But even if we do, it's possible
to use a spare bit for an "extended" set of tags in the future.
As for 2., the lack of compression is a downside, but I've realized the
only tags that really matter storage-wise are the alt pointers. In any
rbyds there will be roughly O(m log m) alt pointers, but at most O(m) of
any other tags. What this means is that the encoding of any other tag is
in the noise of the encoding of our alt pointers.
Our alt pointers are already pretty densely packed. But because the
sparse key part of alt-pointers are stored as-is, the worst-case
encoding of in-tree tags likely ends up as the encoding of our
alt-pointers. So going up to 3-byte tags adds a surprisingly large
storage cost.
As a minor plus, le16s should be slightly cheaper to encode/decode. It
should also be slightly easier to debug tags on-disk.
tag encoding:
TTTTtttt ttttTTTv
^--------^--^^- 4+3-bit suptype
'---|- 8-bit subtype
'- valid bit
iiii iiiiiii iiiiiii iiiiiii iiiiiii
^- m-bit id/weight
llll lllllll lllllll lllllll lllllll
^- m-bit length/jump
Also renamed the "mk" tags, since they no longer have special behavior
outside of providing names for entries:
- LFSR_TAG_MK => LFSR_TAG_NAME
- LFSR_TAG_MKBRANCH => LFSR_TAG_BNAME
- LFSR_TAG_MKREG => LFSR_TAG_REG
- LFSR_TAG_MKDIR => LFSR_TAG_DIR
I'm still not sure this is the right place for this, but it does
simplify pending attr calculations during merge and deduplicates
two instances of writing pending attrs, at the cost of needing to
track an additional rbyd weight during merge.
Going to roll with this for now, the B-tree merge code needs to be
cleaned up anyways, maybe it's possible to simplify the state we need to
track.
Another side-effect is this makes our B-trees slightly less aggressive
at merging. I have no idea if this is a good or bad thing.
- Added cleanup of vestigial names on inner branches.
- Avoided extra struct lookups when there is no name on a branch.
- Simplified merge name lookup a little bit, probably at some runtime
cost but merge is an exceptional operation.
- Moved commit before split lookup, in theory this should help stack
shrink-wrapping slightly, in practice it's probably a premature
optimization.
- Removed debugging asserts/printfs.
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.
- lfsr_rbyd_predictedlookup, the new B-tree approach means we hopefully
won't need this anymore. Worst case this remove can be reverted.
- LFSR_TAG_FROM - this will likely come back, but needs to be
rewrittern.
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.
This was particularly nasty to track down, the bad alts left in this way
are zero-weight, zero-tag alts that point out of the bounds of the rbyd.
This creates an immovable-object/unstoppable-force situation since the
alt that will never be followed should always be followed. This ended up
creating a confusing issue later since grows can follow this alt and
cause the alt state to fall apart.
The solution is to check for shrink leaves that drop to weight zero and
prune them. This has a side-effect of nicely handling over-sized
shrinks, though these shouldn't happen anyways and are being asserted
on.
Because I really, really don't want a regression, I've added a specific
test for this, though the minimal reproducible case is a bit complex.
The state of the rbyd is rather sensitive and it's not fully clear to me
what ultimately triggers the breakdown of the rbyd tree.
Also added a slightly better check for grow/shrink tags on altle leaves.
I don't know if this is strictly required but I know it keeps me sane.
- 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.
In B-tree split we turn one rbyd into two by comparing each tag to an id we
as a mid-point.
I first implemented this by writing both children in parallel, which is
efficient, but requires two pcaches for low-level page alignment issues.
However we really don't have to write these in parallel. We can just write
each child sequentially by making two passes of the original rbyd.
---
With this fix, the rewrite of B-tree splitting without predicted rbyd
sizes now works.
The idea is, instead of predicting the rbyd size to decide whether or not
to split, assume we always fit, perform a normal compaction, and if it
turns out we don't fit, make a split, writing rm tags as necessary to revert
any ids that don't belong in the first child.
The neat thing about this is we can use low-level, uncommitting rbyd appends
to do all of this in a single commit, avoiding issues with single-prog
blocks.
This can waste some progs, up to 1/4 of a block during a B-tree split.
However, it removes the main need for the rbyd prediction operations,
which are complicated, error prone, and concerning. B-tree
removes/merges still need an implementation, but this may mean that we
can let the on-disk rbyd data-structure be the only source of knowledge
about tags, which is great for ensuring consistent behavior.
This does mean we don't predict rbyd changes during compaction. Any pending
attributes just get appended to the rbyd after compaction, so size-changing
operations such as removes can lead to splits that could be avoided. But
I think these cases can lead to unnecessary splits anyways depending on
when compaction occurs, so I'm not sure it's really an issue. But I can
always be wrong about that.
I didn't realize until testing, this approach requires two pcaches. This
completely breaks assumptions in the caching layer and requiring an
additional cache is probably too much of a cost to be acceptable. So
back to the drawing board.
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.
- Added test_rbyd_fuzz_mixed/test_rbyd_fuzz_sparse
- Added test_rbyd_unwritten_mixed_fuzz/test_rbyd_unwritten_sparse_fuzz
- Also renamed "random" tests to "fuzz", this describes their purpose a
bit better
These were a bit tricky to add since they need to simulate rbyd weights,
but they should give significant coverage over complicated rbyd corner
cases I may have not thought about.
Also fixed a miscalculation in lfsr_rbyd_pendinglookup when finding a
id that grew. Finding this bug is a good sign these tests are working.
This ends up surprisingly tricky with sparse ids. I feel like I'm missing
a simpler solution, but this at least proves an implementation is possible.
The implementation here does a single pass through the attributes
backwards (which should probably be changed from a linked-list), keeping
track of the best matching tag/id while updating everything based on
grows/shrinks. Once we find the source of the best id we adjust things
back to the pending id space.
The implementation here only works with some significant caveats:
1. This solution might be able to find the id weights by keeping track
of a lower bound, but it would be difficult and add complexity, so we
don't do it. Really lfsr_rbyd_pendinglookup is only going to be used
in full traversals as a part of compaction/splitting, so weight can
be derived trivially from neighboring ids.
2. We don't know the difference between grows/shrinks used to change a
branch's weight and used to create/delete ids. This is a bit of a
problem here, but we can work around it by assuming that
non-destructive grows/shrinks are always on the lower edge of a
weighted id.
Fortunately this assumption is only needed for in-flight attrs in
lfsr_rbyd_pendinglookup, so this is not a requirement on-disk or in
future implemenations.
1. Search backwards through our tags to find the most recent,
best matching id.
2. Replay tags after the found id to adjust for any pending changes.
In theory this should work in controlled cases, but there are a lot of
corner cases around grows and shrinks. Tests are written, and failing,
but I think it may be simpler and more efficient to implement this in a
single pass, with tighter assumptions about what grow/shrinks are
allowed.
This implements a common B-tree using rbyd's as inner nodes.
Since our rbyds actually map to sorted arrays, this fits together quite
well.
The main caveat/concern is that we can't rely on strict knowledge on the
on-disk size of these things. This first shows up with B-tree insertion,
we can't split in preparation to insert as we descend down the tree.
Normally, this means our B-tree would require recursion in order to keep
track of each parent as we descend down our tree. However, we can
avoid this by not storing our parent, but by looking it up again on each
step of the splitting operation.
This brute-force-ish approach makes our algorithm tail-recursive, so
bounded RAM, but raises our runtime from O(logB(n)) to O(logB(n)^2)
That being said, O(logB(n)^2) is still sublinear, and, thanks to
B-tree's extremely high branching factor, may be insignificant.
The way sparse ids interact with our flat id+attr tree is a bit wonky.
Normally, with weighted trees, one entry is associated with one weight.
But since our rbyd trees use id+attr pairs as keys, in theory each set of
id+attr pairs should share a single weight.
+-+-+-+-> id0,attr0 -.
| | | '-> id0,attr1 +- weight 5
| | '-+-> id0,attr2 -'
| | |
| | '-> id5,attr0 -.
| '-+-+-> id5,attr1 +- weight 5
| | '-> id5,attr2 -'
| |
| '-+-> id10,attr0 -.
| '-> id10,attr1 +- weight 5
'-------> id10,attr2 -'
To make this representable, we could give a single id+attr pair the
weight, and make the other attrs have a weight of zero. In our current
scheme, attr0 (actually LFSR_TAG_MK) is the only attr required for every
id, and it has the benefit of being the first attr found during
traversal. So it is the obvious choice for storing the id's effective weight.
But there's still some trickiness. Keep in mind our ids are derived from
the weights in the rbyd tree. So if follow intuition and implement this naively:
+-+-+-+-> id0,attr0 weight 5
| | | '-> id5,attr1 weight 0
| | '-+-> id5,attr2 weight 0
| | |
| | '-> id5,attr0 weight 5
| '-+-+-> id10,attr1 weight 0
| | '-> id10,attr2 weight 0
| |
| '-+-> id10,attr0 weight 5
| '-> id15,attr1 weight 0
'-------> id15,attr2 weight 0
Suddenly the ids in the attr sets don't match!
It may be possible to work around this with special cases for attr0, but
this would complicate the code and make the presence of attr0 a strict
requirement.
Instead, if we associate each attr set with not the smallest id in the
weight but the largest id in the weight, so id' = id+(weight-1), then
our requirements work out while still keeping each attr set on the same
low-level id:
+-+-+-+-> id4,attr0 weight 5
| | | '-> id4,attr1 weight 0
| | '-+-> id4,attr2 weight 0
| | |
| | '-> id9,attr0 weight 5
| '-+-+-> id9,attr1 weight 0
| | '-> id9,attr2 weight 0
| |
| '-+-> id14,attr0 weight 5
| '-> id14,attr1 weight 0
'-------> id14,attr2 weight 0
To be blunt, this is unintuitive, and I'm worried it may be its own
source of complexity/bugs. But this representation does solve the problem
at hand, so I'm just going to see how it works out.