This approach is simpler: fall back to using two passes if we split a
supermdir.
This trades off code complexity with runtime, but I think we really don't
care about the runtime here, since this operation should really only happen
once in a filesystem's entire lifetime.
This is tricky because of the number of corner-cases that can occur:
1. Our supermdir fits as is => compact normally.
2. Our supermdir does not fit, but it does if we separate the superattrs
from file attrs => uninline, but don't split.
3. Our supermdir does not fit, and does not fit after separating the
superattrs => uninline and split.
This trades of a simpler compaction estimate for a looser upper bound.
We now only lower the bound for:
- The number of alts per tag.
- The worst-case leb128 encoding assuming current block_size.
Since this worst case encoding only depends on the block_size, it can
also be precalculated and stored somewhere, though we're currently not
doing that.
On the plus side, this no longer varies depending on the rbyd's weight,
which could cause hard-to-detect issues for very large B-trees.
Still needs work, but at least adopted optionally in the btree.
Ignoring the mdirs for now, which is a bit ironic, because the mdir
compaction is really what this feature is for. But this at least proves
the concept.
---
Unlike btrees, mdirs simply cannot perform the attempt-then-delete-half
strategy current performed by the btrees during compaction with a single
pcache. This is because the moment we finish the commit with the delete,
it becomes visible to the filesystem. We can't abort the commit temporarily
to deal with the other half of the split, because our pcache is in use.
So, instead, the idea is to estimate the compacted rbyd size before
compacting, using conservative (but tight!) estimates for various leb128
encoded parts of the metadata.
And if we adopt this strategy for mdirs, we should probably adopt it in
the btrees for better code sharing.
A couple benefits:
- Major reduction in progs during split, since we don't write out tags
just to delete them.
- btree merge can actually consider both siblings now.
- Not needing to weave the split/merge logic around compact offers a
better route for code deduplication.
- mdir compact will actually work, that's generally a good thing.
And a couple downsides:
- This estimate is complex, meaning more code-cost and a bigger surface
area for bugs.
- This results in a minor performance hit for the common compact case,
since we need to read the rbyd being compacted twice instead of once.
LFSR_TAG_BNAME => LFSR_TAG_BRANCH
LFSR_TAG_BRANCH => LFSR_TAG_BTREE
Maybe this will be a problem in the future if our branch structure is
not the same as a standalone btree, but I don't really see that
happening.
This became surprisingly tricky.
The main issue is knowing when to split mdirs, and how to determine
this without wasting erase cycles.
Unlike splitting btree nodes, we can't salvage failed compacts here. As
soon as the salvage commit is written to disk, the commit becomes immediately
visibile to the filesystem because it still exists in the mtree. This is
a problem if we lose power.
We're likely going to need to implement rbyd estimates. This is
something I hoped to avoid because it brings in quite a bit of
complexity and might lead to an annoying amount of storage waste since
our estimates will need to be conservative to avoid unrecoverable
situations.
---
Also changed the on-disk btree/branch struct to store a copy of the weight.
This was already required for the root of the btree, requiring the
weight to be stored in every btree pointer allows better code
deduplication at the cost of some redundancy on btree branches, where
the weight is already implied by the rbyd structure.
This weight is usually a single byte for most branches anyways.
This may be worth revisiting at some point to see if there's any other
unexpected tradeoffs.
Added lfsr_mountinited/lfsr_formatinited, mostly so lfsr_formatinited
can just call lfsr_mountinited for its mount-check.
This also leads to a nice consolidation of the cleanup-on-error part of
lfsr_mount/lfsr_format.
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.
Considering that _most_ progs in littlefs needs to be checksummed for
future consistency checks, it's not really worth it to have a separate
non-checksumming function. Especially when you consider the fanout of
the prog extensions for different types.
A similar problem, sort of unresolved, is what to do with all the
validating functions. I guess we'll cross that bridge when we need to.
This makes lfsr_data_t a more powerful primitive in littlefs.
- Implemented a set of convenience lfsr_data_read* functions for
easier reading from muxed on-disk/in-ram data.
- Adoped these functions and lfsr_data_t in *_fromdisk functions
as well as low-level rbyd operations.
- Changed leb128 parsing to rely on returned limits instead of
pre-initialized 0xffs to detect truncated leb128s.
- Prefer incremental reading+parsing in more places as a side-effect,
though we don't really have a measurement if this is a net benefit or
cost code/ram wise.
These functions offer more than previous internal bd functions, the idea being
that the more functionality we can move into this layer, the less
functionality gets duplicated across dependent functions.
- lfsr_bd_read - caching read with hint
- lfsr_bd_readcsum - read with checksum
- lfsr_bd_csum - calculate checksum, don't read data
- lfsr_bd_cmp - compare data against a buffer
- lfsr_bd_prog - caching prog
- lfsr_bd_progcsum - prog with checksum
- lfsr_bd_sync - complete an in-flight prog
- lfsr_bd_progvalidate - prog with read-back validation
- lfsr_bd_progcsumvalidate - prog with checksum and read-back validation
- lfsr_bd_syncvalidate - complete an in-flight prog with read-back validation
- lfsr_bd_erase - erase a block
- lfsr_bd_readtag - read a tag with optional checksum
- lfsr_bd_progtag - prog a tag with checksum
Of course these are all susceptible to change.
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.
While the previous renderer was "technically correct", the attempt to
map rotated alts to their nearest neighbor just made the resulting tree
an unreadable mess.
Now the renderer prunes alts with unreachable edges (like they would be
during lfsr_rbyd_append). And aligns all alts with their destination
trunk. This results in a much more readable, if slightly less accurate,
rendering of the tree.
Example:
$ ./scripts/dbgrbyd.py -B4096 disk 0 -t
rbyd 0x0, rev 1, size 1508, weight 40
off ids tag data (truncated)
0000032a: .-+-> 0 reg w1 1 73 s
00000026: | '-> 1-5 reg w5 1 62 b
00000259: .-------+---> 6-11 reg w6 1 6f o
00000224: | .-+-+-> 12-17 reg w6 1 6e n
0000028e: | | | '-> 18 reg w1 1 70 p
00000076: | | '---> 19-20 reg w2 1 64 d
0000038f: | | .-> 21-22 reg w2 1 75 u
0000041d: | .---+---+-> 23 reg w1 1 78 x
000001f3: | | .-> 24-27 reg w4 1 6d m
00000486: | | .-----+-> 28-29 reg w2 1 7a z
000004f3: | | | .-----> 30-31 reg w2 1 62 b
000004ba: | | | | .---> 32-35 reg w4 1 61 a
0000058d: | | | | | .-> 36-37 reg w2 1 65 e
000005c6: +-+-+-+-+-+-> 38-39 reg w2 1 66 f
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.
I was starting to worry about if we handle "idless" (-1) tags correctly
when mixed with rich "ided" (>=0) tags. The logic here is nuanced and
not very intuitive since these "idless" tags have zero-weight and sort
of exist outside the rbyd's id-space.
Fortunately the current implementation does work under more testing, and
it's good to have the explicit test coverage for this weird case.
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.
Due to rbyd changes this no longer reproduces the original bug. It's not
really a useful test now for that reason.
We also have more structured protection against 0 tags in the code, so I
don't think this will be as big an issue moving forwards (famous last words).
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.