This ended up being much less of a simplification than I hoped it would.
It's still easier/more efficient to revert to a relocation in most cases
when dropping in an mdir split, and the small gain from simplifying how
drops/commits interact is overshadowed by the code duplication necessary
to separate lfsr_mdir_drop out from lfsr_mdir_commit:
code stack
before: 30952 2528
after: 31280 (+1.1%) 2648 (+4.7%)
Still, this does at least simplify the logical corner cases (we don't
need to abort commits when droppable anymore), and lfsr_mdir_drop is
ultimately necessary for supporting lazy file creation.
Also having a fix-orphans step during mount allows other littlefs
implementations the option to create orphanned mdirs without compat
issues. So this ends up the more flexible approach.
It _might_ be worth having both eager mdir drops and an explicit
lfsr_mdir_drop for lazy file creation in the future, but I doubt this
will end up worth the code duplication...
---
Oh right, I forgot to actually describe this change.
This trades eager mdir drops:
1. Drop mdirs from the mtree immediately as soon as their weight goes
to zero.
For lazy mdir drops:
1. Drop mdirs from the mtree in a second commit.
2. Scan and drop orphaned mdirs on the first write after mount.
This sounds very similar to the previous "deorphan" scan, which risked
an extreme performance cost during mount, but it should be noted this
orphan scan only needs to touch every mdir once. This makes it no worse
than the overhead of actually mounting the filesystem.
We can also keep an eye out for orphaned mdirs when we mount, so no
extra scan is needed unless there was an unlucky powerloss.
Eager mdir dropping sounds simpler, but thanks to deferred commits
introduces some subtle complexity around aborting commits that would
drop an mdir to zero. Remember commits are viewable on-disk as soon as a
commit completes.
In _theory_, lazy mdir drops simplify the logic around committing to
mdirs.
Though the real kicker is that lazy mdir drops are required for lazy file
creation.
The current idea for lazy file creation involves tracking mid-less
opened-but-not-yet-created files. These files can have bshrubs, so they
need space on an mdir somewhere. But they aren't actually created yet,
so they don't have an mid.
This is fine (though it's probably going to be tricky) as long as we
allocate an mid on file sync, but there is always a risk of losing power
with mdirs that contain only RAM-backed files. Fortunately, no-mids
means no orphaned files, but it does mean orphaned mdirs with no synced
contents.
Long story short, lazy mdir drops are currently a necessary evil, and
logical simplification, that unfortunately comes with some cost.
This saves a bit of stack space in theory, but after the redund block
restructure this amounts to a single word.
But lfsr_mdir_commit__ isn't on the hot path anyways, so this doesn't
even matter...
code stack
before: 30948 2528
after: 30952 (+0.0%) 2528 (+0.0%)
The idea here is to revert moving redund blocks into lfsr_rbyd_t, and
instead just keep a redundant copy of the rbyd blocks in the redund
blocks in lfsr_mdir_t.
Surprisingly, extra overhead in lfsr_mdir_t ended up with worse stack
usage than extra overhead in lfsr_rbyd_t. I guess we end up allocated
more mdirs than rbyds, which makes a bit of sense given how complicated
lfsr_mdir_commit is:
code stack structs
redund union: 30976 2496 1072
redund in rbyd: 30948 (-0.1%) 2528 (+1.3%) 1100 (+2.6%)
redund in mdir: 31000 (+0.1%) 2536 (+1.6%) 1092 (+1.8%)
The mdir option does seem to improve struct overhead, but this hasn't
been a reliable measurement since it doesn't take into account how many
of each struct is allocated.
Given that the mdir option is inferior in both code and stack cost, and
requires more care to keep the rbyd/redund blocks in sync, I think I'm
going to revert this for now but keep the commit in the commit history
since it's an interesting comparison.
This simplifies dependent structs with redundancy, mainly lfsr_mdir_t,
at a significant RAM cost:
code stack structs
before: 30976 2496 1072
after: 30948 (-0.1%) 2528 (+1.3%) 1100 (+2.6%)
Which, to be honest, is not as bad as I thought it would be. Though it
is still pretty bad for no new features.
The motivation for this change:
1. The organization of the previous lfsr_mdir_t struct was a bit hacky
and relied on exact padding so the redund block array and rbyd block
lined up at the right offset.
2. The previous organization prevented theoretical "read-only rbyd
structs" that could omit write-related fields, e.g. eoff and cksum.
This idea is currently unused.
3. The current mdir=level-1, btree/data=level-0 redund design makes this
RAM tradeoff pretty bad, but in theory higher btree redund levels
would need the extra redund blocks in the rbyd struct anyways.
Still, the RAM impact to the current default configuration means this
should probably be reverted...
- Renamed mdir->u.m to mdir->u.mdir.
- Prefer mdir->u.rbyd.* where possible.
- Changed file/dir mdirs to be stored directly, requiring a cast to
lfsr_openedmdir_t to enroll in the opened mdir list.
Reverted to one set of signed lfsr_mid_rid/bid functions, and tried to
make their usage more consistent.
We have two ways to compare mdirs now, lfsr_mdir_cmp (compares block
addresses) and lfsr_mdir_bid (compares mids), and it's not very clear
when to use which one. lfsr_mdir_cmp is a bit more robust in weird mid
cases (mainly inlined mdirs when mroot mid=-1), so currently preferring
that.
Also did some bit twiddling to preserve mid=-1 => bid=-1 and rid=-1,
this save a bit of code:
code stack
before: 31056 2488
after: 30972 (-0.3%) 2496 (+0.3%)
There's only one mtree in a given filesystem. With the recent
lfsr_mdir_commit restructure, it makes more sense for the mtree to be
implicit.
code stack
before: 31096 2480
after: 31016 (-0.3%) 2480 (+0.0%)
Originally, the intention of this rework was to make it possible to
shrub the mtree, i.e. allow an mshrub, i.e. inline the root rbyd of the
mtree to be inlined in the mroot.
This would allow small mtrees, 2, 3, etc mdirs, to save a block that
would be needed for the mtree's root.
But as the mshrub was progressing, minor problems kept unfolding, and
ultimately I've decided to shelve the idea of mshrubs for now. They add
quite a bit of complexity for relatively little gain:
- bshrubs are just complicated to update. They require a call to
lfsr_mdir_commit to update the inlined-root, which is a bit of a
problem when your mshrub needs to be updated inside lfsr_mdir_commit,
and your system disallows recursion...
Recursion _can_ be avoided by separate bshrub commit variants that go
through either lfsr_mdir_commit or lfsr_mdir_commit_, but this
complicates things and requires some code duplication, weakening the
value of reusing the bshrub data-structure.
- It's not always possible to compact the mshrub's backing mroot when
we need to modify the mshrub.
If an mroot becomes full and needs to split, for example, we need to
allocate the new mdirs, update the (new) mshrub, and then commit
everything into the mroot when we compact. But the "update the (new)
mshrub" step can't be done until after we compact, because the mroot
is by definition full.
This _can_ also be worked around, by building an attr list containing
all of the mshrub changes, and committing the mshrub/mroot changes in
the same transaction, but this complicates things and increases the
stack cost for the current hot-path.
- Every shrub needs a configurable shrub size, and the mshrub is no
exception. This adds another config option and complicates shared
shrub eviction code.
- The value for mshrubs is not actually that great.
Unlike file bshrubs, there's only one mshrub in the filesystem, and
I'm not sure there's a situation where a filesystem has >1 mdirs and
the exact number of allocated blocks is critical.
And this complexity is reflected in code cost and robustness, not to
mention developer time. I think for littlefs this is just not worth
doing. At least not now.
We can always introduce mshrubs in a backwards compatible manner if
needed.
---
But this rework did lead to better code organization around mdir commits
and how they update the mtree/mroot, so I'm keeping those changes.
In general lfsr_mdir_commit has been broken up into mtree/mroot specific
functions that _do_ propagate in-device changes. Any commit to the mroot
changes the on-disk state of the filesystem anyways, so the mroot commit
_must_ be the last thing lfsr_mdir_commit does.
This leads to some duplicated updates, but that's not really a problem.
Here's the new call graph inside lfsr_mdir_commit:
lfsr_mdir_commit
.---------' | | | '-----------------.
v | | '-----------------. |
lfsr_mtree_commit | '--------. | |
'---------. | | | |
v v | | |
lfsr_mroot_commit | | |
| '--------. | | |
| v v | |
| lfsr_mdir_commit_ | |
| .--------' '--------. | |
| | .-----------------|-' |
v v v v v
lfsr_mdir_commit__ lfsr_mdir_compact__
This rework didn't really impact code/stack that much. It added a bit of
code, but saved a bit of RAM. The real value is that the narrower-scoped
functions contain more focused logic:
code stack
before: 30780 2504
after: 31096 (+1.0%) 2480 (-1.0%)
This is just a useful type to have to make the code a bit more
readable.
This doesn't affect the code that much, except we are making more
on-stack copies of mptrs since the mdir doesn't technically contain
a mutable mptr. Maybe this should change?
code stack
before: 30768 2496
after: 30776 (+0.0%) 2504 (+0.3%)
- There was a lingering strict pcache assert in lfs_bd_erase. Very
unlikely to hit, but it is possible and shouldn't be an assert now
that pcache can be left in an arbitrary state. That being said, it
was asserting on an actual bug in this case.
- Our btree traversal was not traversing the roots of zero-weight
btrees. Zero-weight btrees can happen as an intermediary step during
btree/bshrub carving. If the stars align with the block allocator and
intermediary carving states this can cause incorrect block
allocations.
- Staged updates to bsprouts/bshrubs need to be played out before
updates to opened mdirs lfsr_mdir_commit, this is just because
lfsr_file_isbsprout/isbshrub depend on mdir.block and updating the
mdirs first corrupts this.
Maybe a different organization to this code would be useful, it is
already full of TODOs.
It turned out by implicitly handling root allocation in
lfsr_btree_commit_, we were never allowing lfsr_bshrub_commit to
intercept new roots as new bshrubs. Fixing this required moving the
root allocation logic up into lfsr_btree_commit.
This resulted in quite a bit of small bug fixing because it turns out if
you can never create non-inlined bshrubs you never test non-inlined
bshrubs:
- Our previous rbyd.weight == btree.weight check for if we've reached
the root no longer works, changed to an explicit check that the blocks
match. Fortunately, now that new roots set trunk=0 new roots are no
longer a problematic case.
- We need to only evict when we calculate an accurate estimate, the
previous code had a bug where eviction occurred early based only on the
progged-since-last-estimate.
- We need to manually set bshrub.block=mdir.block on new bshrubs,
otherwise the lfsr_bshrub_isbshrub check fails in mdir commit staging.
Also updated btree/bshrub following code in the dbg scripts, which
mostly meant making them accept both BRANCH and SHRUBBRANCH tags as
btree/bshrub branches. Conveniently very little code needs to change
to extend btree read operations to support bshrubs.
Unfortunately, waiting to evict shrubs until mdir compaction does not
work because we only have a single pcache. When we evict a bshrub we
need a pcache for writing the new btree root, but if we do this during
mdir compaction, our pcache is already busy handling the mdir
compaction. We can't do a separate pass for bshrub eviction, since this
would require tracking an unbounded number of new btree roots.
In the previous shrub design, we meticulously tracked the compacted
shrub estimate in RAM, determining exactly how the estimate would change
as a part of shrub carve operations.
This worked, but was fragile. It was easy for the shrub estimate to
diverge from the actual value, and required quite a bit of extra code to
maintain. Since the use cases for bshrubs is growing a bit, I didn't
want to return to this design.
So here's a new approach based on emulating btree compacts/splits inside
the shrubs:
1. When a bshrub is fetched, scan the bshrub and calculate a compaction
estimate. Store this.
2. On every commit, find the upper bound of new data being progged, and
keep track of estimate + progged. We can at least get this relatively
easily from commit attr lists. We can't get the amount deleted, which
is the problem.
3. When estimate + progged exceeds shrub_size, scan the bshrub again and
recalculate the estimate.
4. If estimate exceeds the shrub_size/2, evict the bshrub, converting it
into a btree.
As you may note, this is very close to how our btree compacts/splits
work, but emulated. In particular, evictions/splits occur at
(shrub_size/block_size)/2 in order to avoid runaway costs when the
bshrub/btree gets close to full.
Benefits:
- This eviction heuristic is very robust. Calculating the amount progged
from the attr list is relatively cheap and easy, and any divergence
should be fixed when we recalculate the estimate.
- The runtime cost is relatively small, amortized O(log n) which is
the existing runtime to commit to rbyds.
Downsides:
- Just like btree splits, evictions force our bshrub to be ~1/2 full on
average. This combined with the 2x cost for mdir pairs, the 2x cost
for mdirs being ~1/2 full on average, and the need for both a synced
and unsynced copy of file bshrubs brings our file bshrub's overhead up
to ~16x, which is getting quite high...
Anyways, bshrubs now work, and the new file topology is passing testing.
An unfortunate surprise is the jump in stack cost. This seems to come from
moving the lfsr_btree_flush logic into the hot-path that includes bshrub
commit + mdir commit + all the mtree logic. Previously the separate of
btree/shrub commits meant that the more complex block/btree/crystal logic
was on a separate path from the mdir commit logic:
code stack lfsr_file_t
before bshrubs: 31840 2072 120
after bshrubs: 30756 (-3.5%) 2448 (+15.4%) 104 (-15.4%)
I _think_ the reality is not actually as bad as measured, most of these
flush/carve/commit functions calculate some work and then commit it in
seperate steps. In theory GCC's shrinkwrapping optimizations should
limit the stack to only what we need as we finish different
calculations, but our current stack measurement scripts just add
together the whole frames, so any per-call stack optimizations get
missed...
As a part of the general redesign of files, all files, not just small
files, can inline some data directly in the metadata log. Originally,
this was a single piece of inlined data or an inlined tree (shrub) that
effectively acted as an overlay over the block/btree data.
This is now changed so that when we have a block/btree, the root of the
btree is inlined. In effect making a full btree a sort of extended
shrub.
I'm currently calling this a "geoxylic btree", since that seems to be a
somewhat related botanical term. Geoxylic btrees have, at least on
paper, a number of benefits:
- There is a single lookup path instead of two, this simplifies code a
bit and decreases lookup costs.
- One data structure instead of two also means lfsr_file_t requires
less RAM, since all of the on-disk variants can go into one big union.
Though I'm not sure this is very significant vs stack/buffer costs.
- The write path is much simpler and has less duplication (it was
difficult to deduplicate the shrub/btree code because of how the
shrub goes through the mdir).
In this redesign, lfsr_btree_commit_ leaves root attrs uncommitted,
allowing lfsr_bshrub_commit to finish the job via lfsr_mdir_commit.
- We don't need to maintain a shrub estimate, we just lazily evict trees
during mdir compaction. This has a side-effect of allowing shrubs to
temporarily grow larger than shrub_size before eviction.
NOTE THIS (fundamentally?) DOESN'T WORK
- There is no awkwardly high overhead for small btrees. The btree root
for two-block files should be able to comfortably fit in the shrub
portion of the btree, for example.
- It may be possible to also make the mtree geoxylic, which should
reduce storage overhead of small mtrees and make better use of the
mroot.
All of this being said, things aren't working yet. Shrub eviction during
compaction runs into a problem with a single pcache -- how do we write
the new btrees without dropping the compaction pcache? We can't evict
btrees in a separate pass becauce their number is unbounded...
This should, in theory, prevent excessive block waste when blocks gets
carved to a very small size.
At the very least, this makes crystal_size symmetrical, i.e. all blocks
smaller than crystal_size are stored as fragments, and all crystals
(sets of fragments) greater than crystal_size are stored as blocks.
Though the latter is determined heuristically.
This is similar to fragment coalescing, except we may need to merge
multiple fragments/blocks, so we check for merges in a loop (need to
determine if this is the best strategy).
This prevents runaway block allocations when writing to a file
backwards. I'm not really sure why you would write to a file backwards,
but this also has an impact on random writes.
The idea here is to:
1. Try to figure out the current "crystal" (set of fragments) we are a
part of.
2. Decide if our crystal has probably exceeded the configured crystal size
and needs to be compacted into a block.
3. Guess the local block alignment by looking at the entry immediately
left of our crystal.
Figuring out the state of our current crystal is done heuristically,
with a lookup 1 crystal-size to the left to find the start of our
crystal, followed by a lookup 1 crystal-size to the right of the crystal
start to find the end of our crystal:
-crs pos -crs pos
.-------| .-------|
-crs pos '---. | +crs '-------. +crs
.-------| |---|---. |------.
.---' | | | '---. | .---'
v v v v v v v
.---+---+---+---. +---.---+---+---. ---+---.---.---+
| crystal | blk | crystal | blk |crs| blk
'---+---+---+---' +---'---+---+---' ---+---'---'---+
This is a heuristic that doesn't catch any holes in our crystal, but
that's ok, we probably don't want small holes preventing block
compaction anyways.
Finding the block alignment then just requires looking up the entry to
the left of our crystal, if the left entry + crystal fits in a block, we
are the same block, otherwise we align to the left entry. Note this may
break our crystal during block compaction if the crystal itself is not
block aligned, but that's ok, we just recalculate the new crystal based
on the new block:
fits partial fit doesn't fit
.---+---+---+---. .---+---+---+---+---. .---+---+---+---+---.
| block |crs| | block | crs | | block |crs|
'---+---+---+---' '---+---+---+---+---' '---+---+---+---+---'
| | |
v v v
.---+---+---+---. .---+---+---+---+---. .---+---+---+---+---+- - -
| block | | block |crs| | block | block
'---+---+---+---' '---+---+---+---+---' '---+---+---+---+---+- - -
This involves at most 3 lookups, though there are some shortcuts: If
appending a file we never need to lookup the right crystal boundary,
and if we don't exceed our crystal size we don't need to figure out the
block alignment.
---
There's another variant of this scheme where we don't consider any
fragments to the right of the current fragment. This saves a lookup, but
more importantly would mean we could take advantage of ecksums when
partially rewriting part of a file.
As a tradeoff this variant does end up leaving any partially rewritten
files with >~2x storage overhead. Still, this may be interesting to
provide as an alternative write strategy in the future.
We don't strictly need this for the mtree, but its impact is pretty
minimal, and it's useful for some future plans. It also makes low-level
benchmarks a bit easier to write.
The main change involves subtleties around vestigial names in leaf
rbyds (the bottom most layer of btree inner nodes). Since the mtree
terminates in mdirs, the left-most mdir in each leaf rbyd in the mtree
never actually needs a name. But in a hypothetical strict key->value
tree, every entry in the leaf rbyds need a name, and this name needs to
be respected during btree operations (mainly merges).
As a side-effect, our named btrees now require vestigial names for every
inner btree node, with the exception of the left-most inner nodes since
those can't be merged left with anything. On the bright side, being able
to assume a vestigial name on every mergable node does simplify merge
operations a bit.
It's worth noting that despite these changes, we still update vestigial
names on inner btree nodes lazily. It isn't super clear that this should
work, but it turns out that even though a leaf nodes may diverge from
the vestigial name in it's parent, it must still following the bounds of
the parent's vestigial name because of how btree lookups work. And this
property propagates up though each layer in the btree:
.---------------.
|a: |h: |-> |
'--|---|--------'
.---' '----------.
v v
.---------------. .---------------.
|a: |c: | | |i: |m: |-> |
'--|---|--------' '--|---|--------'
...--' | | '--------...
v v
.---------------. .---------------.
|d:0|e:1|f:2|-> | |j:3|k:4|l:5|-> |
'---------------' '---------------'
The exception are the left-most inner nodes, but these can never merge
left, so it doesn't really matter. The vestigial names on the left-most
inner nodes are truly vestigial:
.---------------.
|c: |e: |-> |
'--|---|--------'
.---' '--------...
v
.---------------.
|b: |d: | |
'--|---|--------'
.---' '-------...
v
.---------------.
|a:0|b:1|c:2|-> |
'---------------'
An alternative implementation may prefer to update these names eagerly,
but this would increase the amount of data written to each inner node
during btree commits. mdir updates are lazy by necessity, so even if you
adopted eager updates, the names of deleted files would still stick
around.
This did not turn out to be useful, mainly because type-agnostic
inlining requires unnecessary encoding/decoding and risks a higher RAM
allocation than is really needed. It's better to just reserve a bit in
the weight field and allow higher-level operations to use
operation-specific unions.
code stack
before: 31580 2072
after: 31160 (-1.3%) 2072 (+0.0%)
This is based on how bench.py/bench_runners have actually been used in
practice. The main changes have been to make the output of bench.py more
readibly consumable by plot.py/plotmpl.py without needing a bunch of
hacky intermediary scripts.
Now instead of a single per-bench BENCH_START/BENCH_STOP, benches can
have multiple named BENCH_START/BENCH_STOP invocations to measure
multiple things in one run:
BENCH_START("fetch", i, STEP);
lfsr_rbyd_fetch(&lfs, &rbyd_, rbyd.block, CFG->block_size) => 0;
BENCH_STOP("fetch");
Benches can also now report explicit results, for non-io measurements:
BENCH_RESULT("usage", i, STEP, rbyd.eoff);
The extra iter/size parameters to BENCH_START/BENCH_RESULT also allow
some extra information to be calculated post-bench. This infomation gets
tagged with an extra bench_agg field to help organize results in
plot.py/plotmpl.py:
- bench_meas=<meas>+amor, bench_agg=raw - amortized results
- bench_meas=<meas>+div, bench_agg=raw - per-byte results
- bench_meas=<meas>+avg, bench_agg=avg - average over BENCH_SEED
- bench_meas=<meas>+min, bench_agg=min - minimum over BENCH_SEED
- bench_meas=<meas>+max, bench_agg=max - maximum over BENCH_SEED
---
Also removed all bench.tomls for now. This may seem counterproductive in
a commit to improve benchmarking, but I'm not sure there's actual value
to keeping bench cases committed in tree.
These were alway quick to fall out of date (at the time of this commit
most of the low-level bench.tomls, rbyd, btree, etc, no longer
compiled), and most benchmarks were one-off collections of scripts/data
with results too large/cumbersome to commit and keep updated in tree.
I think the better way to approach benchmarking is a seperate repo
(multiple repos?) with all related scripts/state/code and results
committed into a hopefully reproducible snapshot. Keeping the
bench.tomls in that repo makes more sense in this model.
There may be some value to having benchmarks in CI in the future, but
for that to make sense they would need to actually fail on performance
regression. How to do that isn't so clear. Anyways we can always address
this in the future rather than now.
This gives the mtree a dedicated type, with direct mptrs (single mdirs)
being stored decoded, instead of encoding into leb128s. This avoids
encoding/decoding in some cases.
This change is currently a net downgrade, but only because we still have
all of the inlined btree code. Eventually this inlined btree code should
be removed:
code stack
before: 31316 2064
after: 31480 (+0.5%) 2072 (+0.4%)
Also tweaked the tests to no longer test dropping the mtree down to
zero size. Thanks to root bookmarks, we never actually do this, and it
simplifies lfsr_mdir_commit to not support this.
This avoids needing to return the mostly-redundant weight in the
readnext functions, and allows passing the returned data directly to
lfsr_data_read/lfsr_bd_progdata when needed.
code stack
before: 31396 2064
after: 31316 (-0.3%) 2064 (+0.0%)
- Ripped out outdated file-data representation. We don't need this.
- Changed lfsr_data_add/read/cmp to just assert when data is
concatenated data. Theoretically this is possible to implement, but
it's complicated and we never use it, so all it is is a waste of
code size...
- Added implicitly zero-filled hole representation, though this isn't
adopted in the code yet.
- Added lfsr_data_truncate/fruncate, these are really useful for
shrub/tree carving/coalescing.
---
New lfsr_data_t encoding, sign(size) indicates if the data is
on-disk/in-device, and a mode field indicates how in-device data should
be parsed:
sign(size)=1 => on-disk:
.---+---+---+---. .....
|1| size | ..'' ''..
+---+---+---+---+ : : :
| block ------+->| ..:|
+---+---+---+---+ | |......( )::::::|
| off -------' |:::' : |
'---+---+---+---' :' : :
''.. :.''
'''''
sign(size)=0, mode=0 => in-device buffer:
.---+---+---+---. .---+---+---+---.
|0| size | .>| data... |
+---+---+---+---+ | ' . '
|m=0| | | ' . '
+---+---+---+---+ | ' '
| ptr -------' ' '
'---+---+---+---' '---+---+---+---'
sign(size)=0, mode=1 => hole
.---+---+---+---.
|0| size |
+---+---+---+---+
|m=1| |
+---+ +
| |
'---+---+---+---'
sign(size)=0, mode=2 => inlined
.---+---+---+---.
|0| size |
+---+---+---+---+
|m=2| inlined d |
+---+ +
| ata... |
'---+---+---+---'
sign(size)=0, mode=3 => concatenated datas:
.---+---+---+---. .---+---+---+---.
|0| size | .>| data |
+---+---+---+---+ | + +
|m=3| c | | | | |
+---+---+---+---+ | + +
| ptr -------' | |
'---+---+---+---' +---+---+---+---+
| data |
+ +
| |
+ +
| |
+---+---+---+---+
' . '
' . '
' . '
' '
' '
'---+---+---+---'
---
Code/RAM changes:
code stack
before: 31952 2056
after: 31396 (-1.7%) 2064 (+0.4%)
I think the increased RAM cost is due to lfsr_data_add/truncate/fruncate
passing lfsr_data_t around by value, and GCC not being able to optimize
this very well since it's 3 words. I think most move optimizations stop
after 2-words...
- Added lfsr_shrub_lookupnext/lfsr_tree_lookupnext to deduplicate
the various tree lookups that need to support inlined sprouts/bptrs.
Also moved implicit bptr dereferencing here, though this may need
to be tweaked a bit to support data checksumming.
- Moved inlined sprouts/bptrs into readnext (well, lookupnext really).
This simplifies things anywhere we just need to read data from these
trees.
- Renamed lfsr_file_carveshrub/lfsr_file_carvetree ->
lfsr_shrub_carve/lfsr_tree_carve and changed parameters appropriately.
Though these aren't so clear cut. lfsr_shrub_carve still needs the
related file structure to know which mdir to commit to.
lfsr_shrub_carve will also need significant tweaking to support
recovery from failed file writes.
This also ends up losing the shrub/tree lookup reuse. There might still
be a way to deduplicate the read logic after shrub/tree lookup, but it's
probably not worth it considering this logic has become a rather small
part of the lookupnext/readnext machinery
Both shrubs and trees end up calling rbyd/btree lookupnext anyways...
In theory, it should be perfectly fine to read from a filesystem with an
invalid grm mode, so lfsr_data_readgrm has been tweaked to return
LFS_ERR_INVAL in that case.
That being said, we don't actually support read-only mounts, so the end
behavior is still the same, but this lays the groundwork for readonly
mounts in the future.
This also makes it so invalid grm modes result in LFS_ERR_INVAL when
read-only mounts aren't supported, which is what it should be anyways.
The biggest change here is the breaking up of the FLAGS config into
RFLAGS/WFLAGS/OFLAGS. This is directly inspired by, and honestly not
much more than a renaming, of the compat/ro_compat/incompat flags found
in Linux/Unix/POSIX filesystems.
I think these were first introduced in ext2? But I need to do a bit more
research on that.
RFLAGS/WFLAGS/OFLAGS provide a much more flexible, and extensible,
feature flag mechanism than the previous minor version bumps.
The (re)naming of these flags is intended to make their requirements
more clear. In order to do the relevant operation, you must understand
every flag set in the relevant flag:
- RFLAGS / incompat flags - All flags must be understood to read the
filesystem, if not understood the only possible behavior is to fail.
- WFLAGS / ro-compat flags - All flags must be understood to write to the
filesystem, if not understood the filesystem may be mounted read-only.
- OFLAGS / compat flags - Optional flags, if not understood the relevant
flag must be cleared before the filesystem can be written to, but other
than that these flags can mostly be ignored.
Some hypothetical littlefs examples:
- RFLAGS / incompat flags - Transparent compression
Is this the same as a major disk-version break? Yes kinda? An
implementation that doesn't understand compression can't read the
filesystem.
On the other hand, it's useful to have a filesystem that can read both
compressed and uncompressed variants.
- WFLAGS / ro-compat flags - Closed block-map
The idea behind a closed block-map (currently planned), is that
littlefs maintains in global space a complete mapping of all blocks in
use by the filesystem.
For such a mapping to remain consistent means that if you write to the
filesystem you must understand the closed block-map. Or in other
words, if you don't understand the closed block-map you must not write
to the filesystem.
Reading, on the other hand, can ignore many such write-related
auxiliary features, so the filesystem can still be read from.
- OFLAGS / compat flags - Global checksums
Global checksums (currently planned) are extra checksums attached to
each mdir that when combined self-validate the filesystem.
But if you don't understand global checksums, you can still read and
write the filesystem without them. The only catch is that when you write
to the filesystem, you may end up invalidating the global checksum.
Clearing the global checksum bit in the OFLAGS is a cheap way to
signal that the global checksum is no longer valid, allowing you to
still write to the filesystem without this optional feature.
Other tweaks to note:
- Renamed BLOCKLIMIT/DISKLIMIT -> BLOCKSIZE/BLOCKCOUNT
Note these are still the _actual_ block_size/block_count minus 1. The
subtle difference here was the original reason for the name change,
but after working with it for a bit, I just don't think new, otherwise
unused, names are worth it.
The minus 1 stays, however, since it avoids overflow issues at
extreme boundaries of powers of 2.
- Introduces STAGLIMIT/SATTRLIMIT, sys-attribute parallels to
UTAGLIMIT/UATTRLIMIT.
These may be useful if only uattrs are supported, or vice-versa.
- Dropped UATTRLIMIT/SATTRLIMIT to 255 bytes.
This feels extreme, but matches NAMELIMIT. These _should_ be small,
and limiting the uattr/sattr size to a single-byte leads to really
nice packing of the utag+uattrsize in a single integer.
This can always be expanded in the future if this limit proves to be a
problem.
- Renamed MLEAFLIMIT -> MDIRLIMIT and (re?)introduced MTREELIMIT.
These may be useful to limiting the mtree when needed, though it's not
clear the exact use case quite yet.
It's probably better to have a separate names for a tag category and any
specific name, but I can't think of a better name for this tag, and I
hadn't noticed that I was already ignoring the C prefix for CCKSUM tags
in many places.
NAME/CKSUM now mean both the specific tag and tag category, which is a
bit of a hack since both happen to be the 0th-subtype of their
categories.
I may be overthinking things, but I'm guessing of all the possible tag
modes we may want to add in the future, we will mostly like want to add
something that looks vaguely tag like. Like the shrub tags, for example.
It's beneficial, ordering wise, for these hypothetical future tags to
come before the cksum tags.
Current tag modes:
0x0ttt v--- tttt -ttt tttt normal tags
0x1ttt v--1 tttt -ttt tttt shrub tags
0x3tpp v-11 tttt ---- ---p cksum tags
0x4kkk v1dc kkkk -kkk kkkk alt tags
Unfortunately, the tests are starting to take a painfully long time to
run. Some of this is because, in order to get interesting file
topologies, we need to move a ton of data around, but some of this is
also because our current write implementation has some problematically
expensive corner cases.
I have quite a few ideas on how to improve this, but in the meantime the
tests needed to be aggressively trimmed in order to keep development
tolerable (A happy developer is a productive developer).
This mainly meant:
- Disabled powerloss testing on file tests for now.
The reality is that naivly powerloss testing the file tests, i.e.
just truncating the file after each restart, provides very little
value and adds an extreme amount of runtime.
Removed for now. Most of the powerloss file creation concerns are
covered in the dtree tests, and we should eventually add powerloss
tests tailored to recovering files after powerloss instead of just
truncating.
- Avoided tiny fragment sizes with large file sizes.
Tiny fragments are a degenerate case and end up with excessive
overhead (1 byte fragment => 41x overhead!). But they are useful for
revealing subtle bugs. Still, it just doesn't make sense time-wise to
test with tiny fragments once the file size exceeds ~1 block.
- Limited fuzz tests to cover fewer random seeds.
We can increase these if performance improves, but even if not, we can
run these individually with a high number of seeds in CI.
Also fixed a number of bugs found by the extended testing, which is
always a good sign:
- Yet another `lfsr_data_size(&data)` vs `data.u.disk.size` typo.
This is the first time I've seen a real world argument for private
struct/class fields, but I am still against the concept.
- Fixed delta/weight miscalculation when tree-carving a left sibling.
- Fixed missing offset in hole writing during block writes.
- Worked around lfsr_file_readnext's reliance on file->size when we are
using it to write to a block. This may be more a hack than a good
long term solution though.
- Checkpointed the allocator in both lfsr_file_write and lfsr_file_sync.
Otherwise calling lfsr_file_write repeatedly can easily trigger an
incorrect ENOSPC.
- Correctly reverted both shrubs and btrees in truncate/fruncate
This gets a bit more complicated in fruncate, since either one of the
two, or both, can revert.
truncate/fruncate probably deserve a bit more work around reversions
to simpler data structures, as is.
- Added handling of shrub overflows during fruncate.
Notably not possible with truncate, shrub overflows require that we
1. flush the shrub, 2. fruncate the tree, 3. and make sure any side
effects to the buffer are handled correctly.
The original name was a bit of a mouthful.
Also dropped the default crystal_size in the test/bench runners
block_size/4 -> block_size/8. I'm already noticing large amounts of
inflation when blocks are fragmented, though I am experimenting with a
rather small fragment_size right now.
Future benchmarks/experimentation is required to figure out good values
for these.
Note this is really just a proof of concept, and tests are not passing.
There's also a number of hacks holding everything together and really
need to be cleaned up.
I was hoping it would be possible to deduplicate the carveshrub/carvetree
functions the same way shrub/tree readnext functions were deduplicated.
These both share a lot of subtle logic, and in theory operated on minor
variations of the same underlying rbyd structure, but in practice
several issues get in the way:
- While the logic is the same, the way changes are played out is very
different: btrees commit attributes to the btree immediately, whereas
shrubs build up a bounded attr list to commit to the shrub via an mdir
commit.
In theory shrubs could be committed immediately, but it would be
wasteful. And btrees can't commit a bounded attribute list because 1.
rm attrs may need to be split into an unbounded number accross
multiple rbyds, 2. fragmenting blocks may create an unbounded
headache, and 3. attribute lists can't span multiple rbyds so we'd
need to manually play them out anyways.
- We need to allocate a new btree in carvetree, but in carveshrub we
defer allocation to mdir commit time (because of the potential for
failed commits). This complicates things.
- The unions with sprouts/direct bptrs are often very similar, but need
different handling when carving. This gets a bit tricky.
- In theory you could switch between building attrs for shrubs and
immediate commits for btrees, but since the immediate commits _change
the tree_, the carving math changes subtlely.
- carveshrub needs to do several auxilary things: track the shrub estimate,
build attrs in RAM, etc. carvetree needs to do several auxilary
things: dereference bptrs, fragment bptrs, allocate new btrees, etc.
If these can be deduplicated it would likely result in code savings,
but also risks increased RAM costs from trying to do too many things
at once.
The cost of two functions may also be more cognitive than real, since
the subtletly here is just math. And computers happen to be pretty
good at math.
Though this concern may be unfounded, and deduplicated these functions
is still enticing and an interesting idea to explore.
I've already noticed some concerning performance once a write exceeds
our crystallization threshold. This makes sense, as our current strategy
is to completely rewrite any data region over our crystallization
threshold. But I wonder if there's a way to exclude the first block in
our region from the crystallization heuristic...
Anyways, some good progress here, but more work to be done.
The attempt to implement in-rbyd data slicing, being lazily coalesced
during rbyd compaction, failed pretty much completely.
Slicing is a very enticing write strategy, getting both minimal overhead
post-compaction and fast random write speeds, but the idea has some
fundamental conflicts with how we play out attrs post-compaction.
This idea might work in a more powerful filesystem, but brings back the
need to simulate rbyds in RAM, which is something I really don't want to
do (complex, bug-prone, likely adds code cost, may not even be tractable).
So, third time's the charm?
---
This new write strategy writes only datas and bptrs, and avoids dagging
by completely rewriting any regions of data larger than a configurable
crystallization threshold.
This loses most of the benefits of data crystallization, random writes
will now usually need to rewrite a full block, but as a tradeoff our
data at rest is always stored with optimal overhead.
And at least data crystallization still saves space when our data isn't
block aligned, or in sparse files. From reading up on some other
filesystem designs it seems this is a desirable optimization sometimes
referred to as "tail-packing" or "block suballocation"
Some other changes from just having more time to think about the
problem:
1. Instead of scanning to figure out our current crystal size, we can
use a simple heuristic of 1. look up left block, 2. look up right
block, 3. assume any data between these blocks contribute to our
current crystal.
This is just a heuristic, so worst case you write the first and last
byte of a block which is enough to trigger compaction into a block.
But on the plus side this avoids issues with small holes preventing
blocks from being formed.
This approach brings the number of btree lookups down from
O(crystallize_size) to 2.
2. I've gone ahead and dropped the previous scheme of coalesce_size
+ fragment_size and instead adopted a single fragment_size that
controls the size of, well, fragments, i.e. data elements stored
directly in trees.
This affects both the inlined shrub as well as fragments stored in
the inner nodes of the btree. I believe it's very similar to what is
often called "pages" in logging filesystems, though I'm going to
avoid that term for now because it's a bit overloaded.
Previously, neighboring writes that, when combined, would exceed our
coalesce_size, they just weren't combined. Now they are combined up
to our fragment size, potentially splitting the right fragment.
Before (fragment_size=8):
.---+---+---+---+---+---+---+---.
| 8 bytes |
'---+---+---+---+---+---+---+---'
+
.---+---+---+---+---.
| 5 bytes |
'---+---+---+---+---'
=
.---+---+---+---+---+---+---+---+---+---.
| 5 bytes | 5 bytes |
'---+---+---+---+---+---+---+---+---+---'
After:
.---+---+---+---+---+---+---+---.
| 8 bytes |
'---+---+---+---+---+---+---+---'
+
.---+---+---+---+---.
| 5 bytes |
'---+---+---+---+---'
=
.---+---+---+---+---+---+---+---+---+---.
| 8 bytes |2 bytes|
'---+---+---+---+---+---+---+---+---+---'
This leads to better fragment alignment (much like our block
strategy), and minimizes tree overhead.
Any neighboring data to the right is only coalesced if it fits in the
current fragment, or would be rewritten (carved) anyways, to avoid
unnecessary data rewriting.
For example (fragment_size=8):
.---+---+---+---+---+---+---+---+---+---+---+---+---+---.
| 6 bytes | 6 bytes |2 bytes|
'---+---+---+---+---+---+---+---+---+---+---+---+---+---'
+
.---+---+---+---+---.
| 5 bytes |
'---+---+---+---+---'
=
.---+---+---+---+---+---+---+---+---+---+---+---+---+---.
| 8 bytes | 4 bytes |2 bytes|
'---+---+---+---+---+---+---+---+---+---+---+---+---+---'
Other than these changes this commit is mostly a bunch of carveshrub
rewriting again, which continues to be nuanced and annoying to get
bug free.
- -> lfsr_shrub_t
- -> lfsr_tree_t
The idea here is to adopt "shrub" as an umbrella term for the
shrub/sprout union, and "tree" as an umbrella term for the bptr/btree
union. I think this is a bit better than calling shrub/sprout "inlined"
which is a _very_ overloaded term in this codebase (inlined in the tree?
the mdir? inlined in the C struct?).
But already there are some pretty fundamental problems.
The main issue is that, while we correctly dereference slices during
compaction, pending commits that get delayed after compaction still
point to the old block. I'm not sure there's an easy way around this
aside from aborting compaction commits or fully simulating commits,
both of which seem too costly to implement...
Also coalescing during compaction is flawed as well, since our
attributes will be outdated by the time they are committed if there is a
compaction...
Looks like it's back to the drawing board. Either our approach to
compaction needs to change, or this slice/coalescing work needs to be
reverted/redesigned...
Note this is already showing better code reuse, which is a good sign,
though maybe that's just the benefit of reimplementing similar logic
multiple times.
Now both reading and carving end up in the same lfsr_btree_readnext and
lfsr_btree_buildcarve functions for both btrees and shrubs. Both btrees
and shrubs are fundamentally rbyds, so we can share a lot of
functionality as long as we redirect to the correct commit function at
the last minute. This surprising opportunity for deduplication was
noticed while putting together the dbg scripts.
Planned logic (not actual function names):
lfsr_file_readnext -> lfsr_shrub_readnext
| |
| v
'---------> lfsr_btree_readnext
lfsr_file_flushbuffer -> lfsr_shrub_carve ------------.
.---------------------' |
v v
lfsr_file_flushshrub -> lfsr_btree_carve -> lfsr_btree_buildcarve
Though the btree part of the above statement is only a hypothetical at
the moment. Not even the shrubs can survive compaction now.
The reason is the new SLICE tag which needs low-level support in rbyd
compact. SLICE introduces indirect refernces to data located in the same
rbyd, which removes any copying cost associated with coalescing.
Previously, a large coalesce_size risked O(n^2) runtime when
incrementally append small amounts of data, but with SLICEs we can defer
coalescing to compaction time, where the copy is effectively free.
This compaction-time-coalescing is also hypothetical, which is why our
tests are failing. But the theory is promising.
I was originally against this idea because of how it crosses abstraction
layers, requiring some very low-level code that absolutely can not be
omitted in a simpler littlefs driver. But after working on the actual
file writing code for a while I've become convinced the tradeoff is
worth it.
Note coalesce_size will likely still need to be configurable. Data in
fragmenting/sparse btrees is still susceptible to coalescing, and it's
not clear the impacts of internal fragmentation when data sizes approach
the hard block_size/2 limit.
My current thinking is that these are conceptually different types, with
BTREE tags representing the entire btree, and BRANCH tags representing
only the inner btree nodes. We already have multiple btree tags anyways:
btrees attached to files, the mtree, and in the future maybe a bmaptree.
Having separate tags also makes it possible to store a btree in a btree,
though I don't think we'll ever use this functionality.
This also removes the redundant weight field from branches. The
redundant weight field is only a minor cost relative to storage, but it
also takes up a bit of RAM when encoding. Though measurements show this
isn't really significant.
New encodings:
btree encoding: branch encoding:
.---+- -+- -+- -+- -. .---+- -+- -+- -+- -.
| weight | | blocks |
+---+- -+- -+- -+- -+ ' '
| blocks | ' '
' ' +---+- -+- -+- -+- -+
' ' | trunk |
+---+- -+- -+- -+- -+ +---+- -+- -+- -+- -'
| trunk | | cksum |
+---+- -+- -+- -+- -' '---+---+---+---'
| cksum |
'---+---+---+---'
Code/RAM changes:
code stack
before: 30836 2088
after: 30944 (+0.4%) 2080 (-0.4%)
Also reordered other on-disk structs with weight/size, so such structs
always have weight/size as the first field. This may enable some
optimizations around decoding the weight/size without needing to know
the specific type in some cases.
---
This change shouldn't have affected functionality, but it revealed a bug
in a dtree test, where a did gets caught in an mdir split and the split
name makes the did unreachable.
Marking this as a TODO for now. The fix is going to be a bit involved
(fundamental changes to the opened-mdir list), and similar work is
already planned to make removed files work.
Since we need an bptr type internally, a block pointer, which is a bit
more complicated than just a single address, calling our mdir pairs
mptrs makes sense.
This is a pretty big rewrite, but is necessary to avoid "dagging".
"Dagging" (I just made this term up) is when you transform a pure tree
into a directed acyclic graph (DAG). Normally DAGs are perfectly fine in
a copy-on-write system, but in littlefs's cases, it creates havoc for
future block allocator plans, and it's interaction with parity blocks
raises some uncomfortable questions.
How does dagging happen?
Consider an innocent little btree with a single block:
.-----.
|btree|
| |
'-----'
|
v
.-----.
|abcde|
| |
'-----'
Say we wanted to write a small amount of data in the middle of our
block. Since the data is so small, the previous scheme would simply
inline the data, carving the left and right sibling (in the case the
same block) to make space:
.-----.
|btree|
| |
'-----'
.' v '.
| c' |
'. .'
v v
.-----.
|ab de|
| |
'-----'
Oh no! A DAG!
With the potential for multiple pointers to reference the same block in
our btree, some invariants break down:
- Blocks no longer have a single reference
- If you remove a reference you can no longer assume the block is free
- Knowing when a block is free requires scanning the whole btree
- This split operation effectively creates two blocks, does that mean
we need to rewrite parity blocks?
---
To avoid this whole situation, this commit adopts a new crystallization
algorithm.
Instead of allowing crystallization data to be arbitrarily fragmented,
we eagerly coalesce any data under our crystallization threshold, and if
we can't coalesce, we compact everything into a block.
Much like a Knuth heap, simply checking both siblings to coalesce has
the effect that any data will always coalesce up to the maximum size
where possible. And when checking for siblings, we can easily find the
block alignment.
This also has the effect of always rewriting blocks if we are writing a
small amount of data into a block. Unfortunately I think this is just
necessary in order to avoid dagging.
At the very least crystallization is still useful for files not quite
block aligned at the edges, and sparse files. This also avoids concerns
of random writes inflating a file via sparse crystallization.
- Merged lfsr_file_read_ back into lfsr_file_read, I don't think we need
stateless reads in the end.
- Tweaked reads to use conservative hints instead of just filling all
cache lines with whatever is in the retrieved datas.
- Switched to if/else for sprout/shrub and bptr/btree checks. Though
this had no affect on code size, which isn't too surprising.
Now when you mount littlefs, the debug print shows a bit more info:
lfs.c:7881:debug: Mounted littlefs v2.0 0x{0,1}.c63 w43.256, bd 4096x256
To dissassemble this a bit:
littlefs v2.0 0x{0,1}.c63 w43.256, bd 4096x256
^ ^ '-+-' ^ ^ ^ ^ ^
'-|-----|----|---|---|--------|---|-- major version
'-----|----|---|---|--------|---|-- minor version
'----|---|---|--------|---|-- mroot blocks
| | | | | (1st is active)
'---|---|--------|---|-- mroot trunk
'---|--------|---|-- mtree weight
'--------|---|-- mleaf weight
'---|-- block size
'-- block count
dbglfs.py also shows the block device geometry now, as read from the
mroot:
$ ./scripts/dbglfs.py disk -B4096
littlefs v2.0 0x{0,1}.c63, rev 1, weight 43.256, bd 4096x256
...
This may be over-optimizing for testing, but the reason the mount debug
is only one line is to avoid slowing down/messying test output. Both
powerloss testing and remounts completely fill the output with mount
prints that aren't actually all that useful.
Also switching to prefering parens in debug info mainly for mismatched
things.
Oh hey, it's that piece of complexity I was worried about.
The problem was that the position calculation for new appended
right_data depended on left_overlap, which fell out of sync when
transitioning from sprout->shrub.
The fix here is to keep left_overlap/right_overlap up to date with the
model that the sprout->shrub transition is effectively doing a
shrub-wide rm first.
Hacky, but hopefully avoids bugs in the future by keeping all of these
variables in a reasonable state...
There may be a simpler way to think about how this code should function,
but I just can't see it. This may deserve a rewrite in the future.
Noticed a lot of duplicate conditions, so tried merging these two code
paths. This does risk a difficult to read/maintain function, since there
are some rather tricky subtleties with the sprout -> shrub transition.
On the other hand, the code reuse does mean less conditions to worry
about.
Merging these code paths also saves a bit of code:
code stack
before: 30960 2256
after: 30700 (-0.8%) 2256 (+0.0%)
Note we still end up with a shrub, even if the file could revert back to
a sprout. This is just a simplification for the inlined file logic. We
never implicitly revert to a sprout.