This only matters for developers, not users, but it still helps a lot to
get debug representations right.
Since the exact mid encoding depends on the block_size in an unintuitive
manner, it's tricky to render in a debug-friendly way that is useful
both with and without tools.
Previously, I avoided shifting the bid representation, since this would
be closer to the value in the device, but this hides the actual
structure of the mtree. Now the bid is shifted, showing the underlying
mtree/mdir structure, at the cost of needing to know the number of mbits
to encode the mid back into an integer.
So for example, on a device with 4KiB blocks, or 8 mbits:
mid=1
mid=258
mid=515
Becomes:
mid=0.1
mid=1.2
mid=2.3
This continues to make the mbits a more fundamental part of littlefs,
but that's probably just how that's going to be.
Knowing C's issues with pointer aliasing, I was wondering if this might
save some code cost by avoiding unnecessary indirect loads of the mid.
But, as is often the case, the compiler is smarter than it first appears:
code stack
before: 21052 1744
after: 21048 (-0.0%) 1744 (+0.0%)
Still, sometimes an optimization is better when written out explicitly,
so I'll keep this for now.
This reverts the big hack of treating the lfsr_dir_t as an mdir array in
lfsr_mdir_commit in an effort to deduplicate the bookmark/pos mid
updates.
It worked, but lets be honest, it was a big hack and probably not very
maintainable. It made other opened-mdir updates, such as propagation of
unerases more complex, and is made the code a bit unreadable.
We also don't really need a full mdir for the dir's bookmark, since
rewinds really aren't that common, and a single mtree lookup in that
case gets the job done. Removing the bookmark mdir (though we still need
the bookmark mid to adjust the dir pos correctly) saves 24 bytes from
every lfsr_dir_t.
It would be nice to deduplicate some of the mid logic here, but that's
been difficult because of mid-related side-effects, such as updating the
mdir's pos. There may be room for improvement here.
---
This looks pretty bad, with the additional loop over the attr-list to
update just the dir's bookmark, but it's really not that bad when
compiled, and probably worth the code readability:
code stack structs
before: 20958 1744 864
after: 21052 (+0.4%) 1744 (+0.0%) 840 (-2.9%)
This is a tricky nuance of how rbyd's erased state interacts with
possible errors during commits.
- If an rbyd passes its ecksum during rbyd-fetch, it's erased and we can
write to it.
- If an rbyd is committed to successfully and still has erased space
remaining, it's erased and we can write to it.
- But if we fail to commit to the rbyd, we can't be sure the trailing
data is still erased. It most likely isn't, and we would need to fetch
again to check the ecksum. And since errors are exceptional here, we
might as well just mark any failed commits as unerased, triggering a
compaction on the next write to the rbyd.
To make things more annoying, changing state in all error routes is
tricky to get right, and trickier to test. To keep this relatively
simple and robust, all rbyd/btree/mdir operations mark the original copy
as unerased until the commit succeeds, and then clears the unerased
state. This fits in well with how we make copies of the rbyd/btree/mdir
structs in the relevant functions.
Note this needs to affect _all_ copies of the rbyd, including any opened
mdirs, mroots, etc. This will probably still lead to some bugs in the
future...
This assert in lfs_bd_prog, which detects if a pcache gets reused
without either a flush or drop, has been the source of quite a number of
debugging experiences, ensuring that pcaches are always in an intentioned,
managed state.
This serves to... make this assert happy.
Really, why did I keep this around for so long. It effectively forces a
sort of manual memory management on a resource that doesn't really need
to be managed. It's extra messy and tricky thanks to the number of
(poorly tested) routes errors can go through, making recovery after an
error a risky gamble with this assert enabled.
So this commit drops this strict pcache assert, instead detecting when
the targetted block changes and implicitly zeroing the cache in that
case.
This simplifies rbyd/mdir error handling, where internal errors, such as
RANGE on rbyd overflow, are common and part of normal operation.
---
This also cleans up mdir error handling a bit, and makes mdir drops a
NOENT error. mdir drops are a bit special in that they don't finish the
commit and can't be read from again (which has already led to a couple
bugs), so making the exceptional behavior of mdir drops more clear is
probably a good thing...
This attempts to clean up and deduplicate rbyd operations where
possible, without losing the cleaner logic introduced by the commit
rework.
Some tradeoffs were made:
- In btree merges, we append the split name after the compaction.
This means the split name doesn't get compacted when we merge, but
avoids making the merge compactions special cases.
- We never clean up vestigial names.
This one bothers me, since it means we can end up with names that
never get cleaned up. But then again, that's already true of any names
that get pushed up in the btree inner nodes that aren't the leading
btree entry.
By never cleaning these up, all rbyd compactions in the system behave
the same.
- We don't push gstate into the mroot during relocations.
This would be a nice-to-have, but would require lfsr_mdir_commit__ to
know if we are relocating or extending. And mdirs need to reserve space
for gstate anyways, so it's not the end of the world to leave a bit
of extra gstate around.
Also some attr-list operations are not deduplicated due to how special
they are:
- The writing of attrs in lfsr_mdir_commit__, this is where we adjust
mids->rids and handle special internal attr.
This is a pain, since we end up duplicating the attr-list range
operations, but on the plus side keeps the special mdir attrs out of
the rbyd layers, and saves a bit of RAM from the hot-path.
- The copying of config attrs during mroot extensions.
This one is just tricky because we want to keep the config attrs, but
not the gstate attrs or any custom attributes. An explicit compaction
of only the subrange of config attrs gets the job done.
These changes get our code/RAM costs pretty much back where they
started:
code stack
before mdir rework: 20826 1744
after mdir rework: 21434 (+2.8%) 1768 (+1.4%)
after mdir cleanup: 20850 (+0.1%) 1736 (-0.5%)
It's interesting to note the slight tradeoff of code/RAM here (though
this is very close to the compiler noise floor) comes from the moving of
special mdir attr logic up into lfsr_mdir_commit__.
I wasn't expecting this, but it makes sense since this moves the special
attr handling out of the hot-path going through the mtree commit.
This flattens a number of low-level APIs, mainly the rbyd-attr-list
APIs, into higher-level logic in an effort to remove special flags,
awkward hacks, etc. This comes at a cost, should probably be cleaned
up/deduplicated a bit more, but creates a level of code transparency
that hopefully helps reveal where some logic can be simplified.
One change is the addition of incremental compaction APIs:
- lfsr_rbyd_appendcompactattr
- lfsr_rbyd_compact
These allow upper-layers to build rbyd compactions incrementally, as
long as they ensure attrs are written in order. This makes the btree
merge no longer a special case and even allows us to write the split
name into the rbyd during compaction.
Another big change is the inversion of the mdir commit/compaction logic.
Previously, lfsr_mdir_compact_ was the ground-level mdir operation, but
since lfsr_mdir_compact_ still needs to write out the attrs after
compaction, this led to a lot of mdir logic leaking into the rbyd
functions.
Now, there is a mid-level lfsr_mdir_commit_ that handles both normal
commits and compactions, with a low-level lfsr_mdir_commit__ that
handles only the writing of mdir attributes.
This also leads to a bit better code reuse, as upper-layer mdir logic
often needs to do a low-level commit with the expectation of no
compaction. No more special mdir compaction "reason" enum.
Before:
lfsr_mdir_commit
'-> lfsr_mdir_commit_
|-> lfsr_rbyd_commit
'-> lfsr_mdir_compact_
'-> lfsr_rbyd_compact
After:
lfsr_mdir_commit
'-> lfsr_mdir_commit_
|-> lfsr_mdir_commit__
| '-> lfsr_rbyd_commit
'-> lfsr_rbyd_compact
Also, thanks to inlining the compaction logic, our mroot extension can
now copy the config attrs directly from the previousl mrootanchor,
instead of the previous roundabout method of committing the explicit
config attributes we want to keep.
code stack
before: 20826 1744
after: 21434 (+2.8%) 1768 (+1.4%)
Mostly just moving things around in what seems like a fruitless effort
to make this code more readable.
The biggest change is the deduplication of the special split-drop cases
in mdir commits by sprinkling in a few gotos. xkcd.com/292 seems
relevant, but this does get the job done...
code stack
before: 20918 1744
after: 20826 (-0.4%) 1744 (+0.0%)
This is a simpler way to track dropped mids. Setting trunk=0 was more a
workaround that worked but added more purpose to the trunk field than
originally needed. The mdir's trunk usually still exists after all.
Using mid=-1 previously didn't work due to conflict with mid=-1 to
indicate an mdir is an mroot, but since removed mids only appear in the
opened-mdir list, and the opened-mdir list stores inlined mdirs as
mid=0, this is no longer a problem.
One downside of this change is we no longer get implicit NOENT behavior
from lfsr_rbyd_lookup when attempting to lookup a removed mid, but it
wasn't clear this behavior was going to be very useful...
This bit of code allows us to mount an "inconsistent" filesystem after
powerloss and behave as though we've fixed any pending grms without
actually fixing the grms. This lets the filesystem appear consistent
without needing to modify the disk, and allows truely readonly mounts
without sacrificing powerloss-resilience.
This works by just checking any readonly mid operations against pending
grms and returning NOENT if a fix would remove the mid. Fortunately the
more complex mid operations occur when mutating the filesystem, which we
can ignore as any mutation must be preceded by fixing pending grms.
This check has been added to lfsr_mtree_namelookup and lfsr_mtree_seek,
which should propagate the behavior to high-level functions with minimal
code impact.
This leaves only lfsr_mtree_lookup ignoring pending grms, which is useful
because we need it to actually fix the grms. I don't believe this
function will ever be called by a high-level function directly...
Coverage of readonly grms have also been added to the tests.
- Added lfsr_mdir_lookupnext, for iteration through only a single mid.
This is useful for MOVE attributes.
- Renamed LFSR_MDIR_MROOTANCHOR -> LFSR_MROOTANCHOR.
- Renamed functions that operate on mdir blocks lfsr_mdir_* ->
lfsr_mblocks_*.
- Reordered arguments in lfsr_mdir_fetch.
- Renamed mrid_bits/mbid_weight -> mbits/mweight.
This format for mids is a compromise in readability vs debugability.
For example, if our mbid weight is 256 (4KiB blocks), the 19th entry
in the second mdir would be the raw integer 275. With this mid format,
we would print it as 256.19.
The idea is to make it easy to see it's the 19th entry in the mdir while
still making it relatively easy to see that 256.19 and 275 are
equivalent when debugging.
---
The scripts also took some tweaking due to the mid change. Tried to keep
the names consistent, but I don't think it's worthwhile to change too
much of the scripts while they are working.
This adopts a previously discarded idea for compressed mids with a few
tweaks to avoiding decoding the bid/rid portions as much as possible.
The idea of compressed mids is to shove both the mid bid and mid rid
into a single integer, saving RAM and potentially helping filesystem
integration where a unique per-file integer is useful.
Unfortunately this has proven tricky. littlefs fundamentally needs two
ids, one "bid" to lookup which mdir our entry resides on, and one "rid"
to lookup the entry in the mdir. It's tempting to use two half-sized
integers (16-bit for example), but this risks surprising limitations
around the number of files when blocks are either really large or
really small.
Optimally, we'd limit the number of bits reserved for the rid to the
upper bound of number of rids that can fit in a single mdir. This would
allows for more bids when the block size is small, and more rids when
the block size is large. This should roughly approximate the limits of
a per-file integer.
With a bit of math we can estimate the upper bound to be <=block_size/16
with our current compaction strategy.
This idea was previously discarded due to the overhead of extracting the
bids/rids when we need them, but the RAM savings and file-to-integer
mapping is too useful to give up. When it became clear half-width
integers wasn't really going to work, compressed mids became the new
plan:
0bbbbbbb bbbbbbbb bbbbbbbb rrrrrrrr
^'-----------+-----------' '---+--'
'------------|-----------------|---- sign-bit, reserved for driver
| '---- nlog2(bs/16) bits for rid
| (8-bits for 4KiB blocks)
'---------------------- remaining bits for bid
(23-bits for 4KiB blocks)
To reduce the overhead of encoding/decode bids/rids a few extra features
were added to the internal mdir APIs:
1. The mtree has been changed to store mids directly. Giving each mdir
the upper bound as a weight. This allows direct lookup of mids
without any sort of bid decoding, though does bake the upper bound
estimate into the metadata of the filesystem, which isn't the
cleanest design, but if it works it works.
On the plus side, with this upper bound baked in to the filesystems,
GRMs can be encoded in a single leb128, which is nice. This may have
other savings if we ever store mids anywhere else in the filesystem.
2. rids are now mid relative in lfsr_mdir_lookup when non-negative. This
is implemented with a simple condition that is hopefully optimized
out when inlined, though there may be some room for improvement here.
3. rids are now mid relative in lfsr_mdir_commit. This was a bit tricky,
but we can leverage the existing mechanisms for bid-relative rids
used in the btree implementation.
The above changes make it so you can pass the mid around directly for
most of the mdir functions, hopefully reducing the mid decoding
overhead. This savings should only grow as more high-level filesystem
APIs are added.
Here is the resulting code/RAM changes for this entire change (from
before we adopted the mroot bit):
code stack structs
before: 20590 1784 908
after: 20890 (+1.4%) 1744 (-2.3%) 864 (-5.1%)
We weren't comparing mid=-1/mid=0 correctly in lfsr_mdir_commit, which
can happend now thanks to inlining mids in our mroot. This went
unnoticed because we were just copying mroot.mid in our tests so we
never actually tested with mid=0. This is fixed now and the tests test
with a literal mid=0.
This also changes the mbids to be left-leaning, carving out an
mrid-sized number of bits from the mbid, making the route to compressed
mids easier.
This greatly simplifies mid handling at the cost of increased subtlety
around determining if a given mdir is an mroot.
Fortunately it turns out we can rely on context to determine if an mdir
is an mroot or not:
1. If an mdir's mid.bid == -1, it's an mroot. This is always true for
fake mroots, since they can't hold any inlined mids.
2. If the mtree is inlined (mtree.weight == 0), all mdirs are mroots.
This lets us use mid.bid=0 for inlined mids. We just need to check
if the mtree is inlined before deciding if the mdir is an mroot or
not.
The makes it so that for any non-mroot mdir, mid.bid=-1 is always a
reserved value. Which is very useful for compressed mids.
lfsr_rbyd_appendattrs has looked this way before. This is a revert of a
previous change to merge the bid and start_rid arguments of
lfsr_rbyd_appendattrs, which is appealing since they do very similar
things.
Unfortunately merging bid/start_rids isn't as simple the moment you want
include -1 rids with a non-zero bid, which is the current plan for
compressed mids.
More on this when explaining compressed mids, but basically the idea is
instead of just storing all mdirs in our mtree as single element
entries, store each mdir in as a weighted entry, where the weight is a
known upper bound on the possible number of mid entries in a single
mdir.
With the current mid representation, this just complicates things
without much benefits. But with compressed mids it allows us to lookup
mdirs with the mid directly, and avoid decoding the bid from the mid in
some cases.
The mid-per-mdir upper bound is derived from the block size. We know:
1. Each tag needs <=2 alts+null with our current compaction strategy
2. Each tag/alt encodes to a minimum of 4 bytes
This gives us ~4*4 or ~16 bytes per mid at minimum. If we cram an mdir
with the smallest possible mids, this gives us at most ~block_size/16
mids in a single mdir before the mdir runs out of space.
Note we can't assume ~1/2 block utilization here, as an mdir may
temporarily fill with more mids before compaction occurs.
lfsr_mkdir creates two mid entries atomically, one for the bookmark and
one for the actual dir entry. It looks up where to insert both, which is
necessary for some other checks, and then inserts one mid while tracking
the position of the other mid using our opened-mdir subsystem.
But it's a bit of a challenge to track an mid that hasn't been created
yet.
Consider what happends if we create a bookmark immediately adjacent to
our dir entry:
0.0 bookmark parent -> 0.0 bookmark parent -> 0.0 bookmark parent
(tracking 0.1) 0.1 bookmark child 0.1 bookmark child
(tracking 0.2) 0.2 dir child
That's not right.
To fix this we could add some special handling to our opened-mdir
subsystem to track opened-mdirs specially if they don't actually exist.
Or, as it turns out, just create the dir/bookmark entries in the
opposite order. Which happens to avoid this problem completely:
0.0 bookmark parent -> 0.0 bookmark parent -> 0.0 bookmark parent
(tracking 0.1) 0.1 dir child 0.1 dir child
(tracking 0.2) 0.2 bookmark child
The reason this works is that, thanks to our bookmarks, we can never
actually have a bookmark immediately preceding our dir entry. The
imaginary dir entry will always be preceded by either our parent or
other entry thanks to ordering by did first:
(tracking 0.0) -> (tracking 0.0) -> 0.0 bookmark child
0.0 bookmark parent 0.0 bookmark parent 0.1 bookmark parent
(tracking 0.1) 0.1 dir child 0.2 dir child
Previously we relied on rid=-1 to handle this as a special case, but
this workaround would stop working with compressed mids, where rid=-1
becomes unrepresentable.
This is an intermediate commit as a part of a tangent into compressed
mids.
The idea here, is instead of using bid=-1 as a special value for mroots,
use only the top bit to indicate mroots. This allows you to compare
against the grm/other uninlined mids by masking instead of signed
comparison.
This is valuable for compressed mids since extracting bids relies on
knowledge of the block size, and becomes quite a bit more expensive.
mroot bid mroot cmp
before: 0xffffffff lfs_smax32(a, 0) == lfs_smax32(b, 0)
after: 0x80000000 (a & 0x7fffffff) == (b & 0x7fffffff)
The implementation here is a bit clumsy. I think GCC may be not that
great at optimizing out copies of structs being passed around via
inlined functions. But this is only a proof-of-concept.
The possibility of 16-bit mbid/mrids being a problematic limit is too
high for me to be able to confidently move forward with this internal
encoding.
Consider a filesystem with small blocks and/or large amounts of metadata
per file. If a single file almost fills up an mdir, it risks an
effective limit on the filesystem of 2^16 files. Not a deal-breaker, but
certainly a surprising limit on a supposed "32-bit" filesystem.
We can add more granular integer-limit configurations, but with simple
configurations, the option to increase the integer-limit filesystem-wide
to 64-bits would cost more RAM than just increasing the mbid/mrids
limits.
Though this is always up for reconsideration in the future.
code stack
before: 20762 1720
after: 20590 (-0.8%) 1784 (+3.7%)
It's interesting to not the code/RAM tradeoff here. RAM sees a
significant hit, but code improves, likely because of better instruction
sequences for 32-bit operations (this is targeting ARM thumb,
32-bit MCUs).
Though the code savings likely varies widely across instruction sets,
and I would guess swings negative on 16/8-bit MCUs.
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
This simplifies control flow at a code cost. Unrolling for now as it
avoids all of the iteration derived special handling (which obscures the
underlying logic) and it may be possible to recoup the code cost through
more shared branch utility functions.
code stack
before: 20650 1712
after: 20742 (+0.4%) 1712 (+0.0%)
May revert in the future.
This was missed when moving to use lfsr_data_t more. This is interesting
conflict between implicit truncation in lfsr_data_read and strict
assertions that inlined btree don't lose data.
Found in the btree tests when, you guessed it, inlined btrees lost data.
An alternative route would be to make btrees uninline when faced with an
entry too big to inlined. This may be valuable future work, but probably
depends on the file implementation to know if such a feature is useful.
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).
This was not updated after changing btree merges to compact, and as more
of this codebase depends on our rebalanced code size, it's not worth
keeping this code around right now.
Maybe the option for not rebalancing during compaction should be looked
into again in the future. But I'll leave that up to then.
The start_rid already adjusts the attrs relative rid, and we don't
have a reason to store -1 rid tags in our btree yet, so we can drop the
extra bid parameters.
This is nice because the bid parameter was leaking across abstraction
layers a bit. Our rbyds don't need to understand bids anywhere else.
Unfortunately, this did require some tweaks to btree split, which was
expecting to be able to temporarily write out a -1 rid tag in the case
we're creating a new root. But this is a good change, the -1 rid tag
was sort of a hack that risked other similar bugs.
Funnily enough, these two changes canceled each other out exactly:
code stack
before: 20626 1712
after: 20626 (+0.0%) 1712 (+0.0%)
This extends lfsr_rbyd_compact to support compaction of any number of
rbyds (though we only ever compact 1 or 2), and leverages this to
compact both siblings during btree merges.
This should improve erased storage utilization for btree merges, help
maintain a better balance in the tree (since more merges can complete
successfully), and hopefully lessen the impact of repeated merge+splits.
Since merges are now compacted, we can also be sure the combined merge
fits in 1/2 our block (hand-waving the split name for now, though this
does need to be considered when determining btree commit limits). This
lets us move the merge code entirely before writing out the attr-list,
making this operation more in line with split/compact and offering more
chance as code deduplication.
code stack
before: 20626 1728
after: 20634 (+0.0%) 1712 (-0.9%)
This also ironically discards the previous work to find a simple
estimate of upper bound of uncompacted rbyds, though I'm sure that will
useful again at some point in the future.
Normally, in btrees, the height of the btree only decreases when nodes
are merged.
But not in our btree! Thanks again to lazy merging, btree nodes can be
dropped instead of merged.
We don't have enough information to decrease the height of the btree
exactly when we drop a btree node, since we don't know how many siblings
the original node had, but we can at least decrease the height of the
btree if we notice this condition during normal commits.
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.
Now that we can predict if a merge will fit or not without needing to
write any attrs to disk, we can completely get rid of the merge_abort
code path.
code stack
before: 20566 1728
after: 20546 (-0.1%) 1728 (+0.0%)
Previously, we couldn't accurately predict if a sibling would fit in our
current rbyd because of the overhead of calculating how much space each
of our O(log(n)) alt trunks would take up.
The best we could do is make a rough estimate, and try to merge,
aborting and cleaning up any written tags if it turns out our merge
didn't end up fitting.
But I've recently found a way to calculate an upper bound without too
much overhead, relying only on the compacted estimate:
---
Consider a compacted estimate, e_c. When does our uncompacted estimate
deviate the most? When e_c is packed full of the smallest possible tag
encoding. Since, after compacting t tags, we need and additional 2 alts
and 1 null tag for our compacted rbyd, and since each tag encodes to 4
bytes at minimum, this gives us (1+2+1)*4 bytes, or 16 bytes per tag:
e_c = 16*t
If we aren't compacting, we rely on rbyd's self-balancing properties,
which guarantees a height strictly less than 2*log2(n)+1. This gives us
a similar, but aymptotically different uncompacted estimate, e_u:
e_u = 4*t*(1 + 2*log2(t) + 1)
Or, simplifying:
e_u = 8*t*(log2(t) + 1)
If we know our compacted estimate, e_c, we can assume worst-case it's
full of small tags, and plug this into our uncompacted estimate e_u:
e_u <= 8*(e_c/16)*(log2(e_c/16) + 1)
Or, simplifying:
e_u <= (e_c/2)*(log2(e_c/16) + 1)
Since we're dealing with integers, log2(e_c/16) is strictly >= 1. We can
substitute this in for a slightly simpler equation:
e_u <= (e_c/2)*(log2(e_c/16) + log2(e_c/16))
Or, simplifying:
e_u <= e_c * log2(e_c/16)
This gives us a simple upper bound calculation we can do to convert any
compacted estimate into a rough, uncompacted one:
e_u <= e_c * log2(e_c/16)
---
We can use this estimate in our btree merge code to be sure we won't
overflow our current rbyd before we even try merging.
code stack
before: 20638 1744
after: 20566 (-0.4%) 1728 (-0.9%)
It's worth noting these numbers are purely from the removal of the merge
abort code. There are likely still opportunities to save code/RAM thanks
to predicting merges more accurately.
- prid -> rid, this is the rid of our current rbyd after all
- s* -> sibling_*, prefer more descriptive names
- s* -> split_*, prefer more descriptive names
While it may make more logical sense to fetch the parent after our rbyd
commit completes, fetching the parent first just works out better in
terms of code deduplication.
code stack
before: 20750 1752
after: 20634 (-0.6%) 1744 (-0.5%)
Trying to deduplicate the attr-list constructions before tail recursion
as much as possible.
Also tried rearranging the fetch of our parent to after we commit to our
current rbyd. This makes more logical sense, in terms of the order of
operations, but does mean duplicate parent fetches for the different
code paths...
code stack
before: 20830 1752
after: 20750 (-0.4%) 1752 (+0.0%)
This gets a bit ugly with all of the gotos (which is always a great
thing to hear in a C codebase), but with both our normal commit and
compact code paths obviously sharing the same commit logic when we
tail-recurse to our parent, it is really nice to deduplicate these
two paths.
merge_abort is also still there, annoyingly it needs a slightly
different label since merge_abort still needs to append the cksum that
finalizes the commit.
code stack
before: 20874 1752
after: 20830 (-0.2%) 1752 (+0.0%)
In theory, both commit/compact/merge _could_ share the cksum append,
because compact and merge can't error with LFS_ERR_RANGE (which
might risk an infinite loop?), but that's a level of spaghetti code I'm
not ready to take on yet.
Trying to avoid copying rbyd structs as much as possible, by having a
before (rbyd) and after (rbyd_) copy up until we tail-recurse. This is
similar to how we handle before/after states in lfsr_mdir_commit.
code stack
before: 20890 1744
after: 20874 (-0.1%) 1752 (+0.5%)
Not sure this is worth the change...
Also renamed pid/sid -> prid/srid to keep with the strict rid naming
convention.
It turns out we just don't need this functionality.
The only caller of lfsr_rbyd_commit now is lfsr_format, where there's no
filesystem yet, so recovering after an error doesn't make sense.
Leaving it up to higher-level layers to deal with recovery from rbyd
errors means one less rbyd to allocate.
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.
Note this required changing the INLINED tag to REG in most of the tests,
because our mtree now explicitly requires some sort of NAME tag.
We can also finally see the impact on code and RAM from this restructure:
code stack
before (push/set/pop/split): 21750 1928
after (commit): 20970 (-3.7%) 1744 (-10.6%)
Not too shabby if I say so myself.
This doesn't have that big an impact at the moment, but limiting the
bids/rids to well intentioned values helps development and debugging.
As we tail-recurse up the btree, the current bid always indicates the
left-most/least id in the current rbyd. This contrasts with pid, which
is the right-most id in the current rbyd. Before this the bid was
somewhat arbitrary after the first leaf, which risks confusion later.
This also implies bid=0 when we reach the root, which is a useful debug
assertion.
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.
This code was written before we had wide tags, which were introduced for
this exact, and common, use case of needing to replace a range of tag
subtypes. Must have just been missed.