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.
These tests, and this feature really, is a bit tricky since our btrees
reinline "lazily". That is, our btrees only check if they can inline
during compaction, allowing potentially inlinable btrees to remain
uninlined.
This better utilizes any erased storage in the btree's rbyd, but adds
some corner cases we need to be concerned about.
Added because of some ongoing btree rewrite work, where it did catch
incorrect behavior.
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.
In an effort to better utilize RAM in the tail-recursive btree commit
implementation, we were previously hijacking the attr-list passed to
lfsr_btree_commit and reusing that memory for our own tail-recursive
attr-lists.
I decided to remove this for now for code smell reasons, since it is
a big hack, but it turns out removing the attr-list hijack actually
saved RAM?
code stack
before: 21754 1968
after: 21782 (+0.1%) 1944 (-1.2%)
This was a nice surprise. Maybe the RAM savings come from better
compiler optimizations thanks to simpler variable lifetimes? Or maybe
we're just below the compiler's noise floor...
Struct tags, in littlefs, generally encode pointers to different on-disk
data structures. At this point, they've gotten a bit complex, with the
btree struct, for example, containing 1. a block address, 2. the trunk
offset, 3. the weight of the trunk, and 4. a checksum.
Also some future plans:
1. Block redundancy will make it so these pointers may have a variable
number of block addresses to contend with.
2. Different checksum types may make the checksum field itself variable
length, at least on larger builds of littlefs.
This may also happen if we support truncated checksums in littlefs
for storage saving reasons.
Having two variable sized fields becomes a bit of a pain. We can use the
encoded tag size to figure out the size of one of these fields, but not
both.
The change here makes it so the tag size now determines the checksum
size, requiring the redundancy amount to go somewhere else. This makes
it so checksums can be variably sized, and the explicit redundancy
amount avoids the need to parse the leb128s fully to know how many
blocks we're expecting.
But where to put the redundancy amount?
This commit carves out 2-bits from the struct tag to store the amount of
redundancy to allow up to 3 blocks of redundancy:
v0000011 0TTTTTrr
^--^---^-^----^-^- valid bit
'---|-|----|-|- 3-bit mode (0x0 for structs)
'-|----|-|- 4-bit suptype (0x3 for structs)
'----|-|- 0 bit (reserved for leb128)
'-|- 5-bit subtype
'- 2-bit redund
3 blocks may sound extremely limiting, but it's a common limit for
filesystems, 1. because you have to keep in mind each redundant block
adds that much more writing/reading overhead and 2. the fact
that 2^(2^n)-1 is always divisible by 3 makes >3 parity blocks much more
complicated mathematically.
Worst case, if we ever have >3 redundant blocks, we can create new
struct subtypes. Maybe adding extended struct types that prefix the
block addresses with a leb128 encoding the redundancy amount.
---
As a part of this, reorganized the on-disk btree and ecksum encodings to
put the checksum last.
Also split out the btree and inner btree branches as separate struct
types. The btree includes the weight, whereas the weight is implicit in
inner btree branches. This came about after realizing context-specific
prefixes are relatively easy to add thanks to the composability of our
parsers.
This led to some name collisions though:
- BRANCH -> BNAME
- BOOKMARK -> DMARK
- Use mroot address to determine if we follow mroot during splits
- Prefer bid == -1/bid != -1 for now
- Use u.m when copying mdir internals
I also looked at dropping the bid == -1 representation of inlined mroots,
but it's just too convenient for now. We can leverage address
comparisons to see if we are committing to the actual mroot, and we can
(expensively) compare the mdir blocks for other mroot checks, but we
also use bid == -1 to indicate if we're on the mroot chain in both
lfsr_mdir_commit and lfsr_mtree_traverse...
This is a bit of a shame, since reserving -1 either limits these bids to
15-bits, which is concerning, or requires special handling to cut off
bids at 2^16-1.
The main benefit, aside from a bit better code organization, is that
functions calling lfsr_rbyd_appendcksum don't incur the cost of copying
the lfsr_rbyd_t struct to allow safe rollback in the event of failure.
lfsr_rbyd_commit provides this guarantee, but for situations where
lfsr_rbyd_appendcksum are appropriate, this guarantee is useless since
there are usually other lfsr_rbyd_append* calls involved.
---
Also during restructuring I realized the checksum validation step after
a commit is nearly useless. It only checks the checksum since the last
lfsr_rbyd_append* function, so when building rbyds incrementally it
doesn't really validate any metadata.
This is a shame, since the checksum validation was very useful for
finding bugs, but it's not strictly necessary. Humorously, now is
probably the best time to have found this, since the rbyd stuff is
relatively stable at this point.
The validation has been removed as it's incompatible with this
restructure. It might be possible to add back into lfsr_mdir_commit to
at least validate mdir commits, but it's unclear if that's useful.
Validation in general needs to be looked at anyways.
Realistically, because our btree is protected by CoW checksums, the only
place we can end up with a cycle is in our mroot chain.
This is convenient, as we don't need our btree traversal state when
traversing the mroot chain, so we can put both the tortoise state and
btree traversal state into a union, theoretically saving some RAM.
Unfortunately stack measurements show no change, even though our mtree
traversal in on the hot path. I'm not sure why this is. My best guess is
that the RAM savings is beneath the compilation noise floor, since we
currently only ever create one of these structs.
- lfsr_mid_cmp no longer uses a union. This was undefined behavior and
the lfsr_mid_t type isn't word aligned, so this could break pretty badly
on machine/compiler change.
Also dropped ordering based on endianness, since we need to marshal
these into an int for the comparison anyways.
- Changed lfsr_mdir_cmp to use min/max functions as part of the
comparison. The result is also "ordered" now, though the ordering
is nonsensical. I guess the mrootanchor is less than all other mdirs?
Also considered only comparing a single min/max block, since it would
be an error for mdirs to share blocks, but note we rely on
lfsr_mdir_cmp to check for relocations in lfsr_mdir_commit. These
relocations can end up being partial in the case of bad block
detection.
Two reasons:
- The lfsr_data_t API is a bit too high-level for our rbyd functions,
which need to jump around inside the block, keep track of several
offsets simultaneously, check for boundary conditions, etc.
- Stack measurements showed a +1.6% stack increase, likely due to extra
lfsr_data_t copies.
Though there were some cases where adopting lfsr_data_t made sense,
mainly the parsing of the ecksum struct, and along the way some code was
cleaned up in rbyd fetch and rbyd compact, so after reverting we
actually ended up with less code/stack than when we started:
code stack
before: 21702 1992
lfsr_data_t: 21626 (-0.4%) 2024 (+1.6%)
after: 21666 (-0.2%) 1976 (-0.8%)
The low-level rbyd functions need to parse things (mostly tags), so why
not use our parsers? In theory this offers a bit more code reuse.
In theory we can also rely on lfsr_data_t to do bounds checking of
offsets in the block, in practice we need to setup those bounds
correctly for lfsr_data_t, so not so much...
Code/stack cost:
code stack
before: 21702 1992
after: 21626 (-0.4%) 2024 (+1.6%)
This is kind of messy. The fact that btrees encode any inlined
entry's types directly in the tag, and that btree have multiple tags
themselves (btree (future), mtree, ptree (future), gftree (future)),
means we need several extra parameters to make the btree to/from disk
functions work.
This is going to get more complex with file btrees having their own
inline system.
So for now I've moved the inlined to/from disk logic up into upper
layers, limiting btree to/from disk functions to only parse actual
btrees.
Since btree/branch to/from disk functions are basically the same thing
now, the two have been merged into the btree to/from disk functions.
Composable parsing functions always feel a bit weird to me in C. I don't
know if this is because of something C lacks, such as multiple return
values, or if composable parsers are just inherently awkward to describe
in procedural languages because of the different levels of state.
But I think the API here is pretty ok. The main idea is that data
parsers can be added as functions in the lfsr_data_* namespace that take
lfsr_data_t as a mutable reference, updating the lfsr_data_t's internal
state as data is parsed.
In practice you only need a couple of primitives, bytes, le32s, leb128s,
that touch the internals of lfsr_data_t, and the other parsers can be
built using these.
This leverages the pointer-like abstraction of lfsr_data_t, and avoids
needing to keep track of offsets. And thanks to lfsr_data_t being
relatively cheap to make copies, this API is relatively flexible.
Some other tweaks:
- Signed leb128 overflow detection is moved up into lfs_fromleb128.
littlefs now assumes _all_ leb128s are 31-bits, which is useful for
leveraging the sign bit internally.
This also fixes the an issue in overflow detection in lfs_fromleb128
which wouldn't catch overflows in the last byte of a >32-bit leb128.
- Most lfsr_data_t functions now take a pointer. This offered a small
bit of code savings and feels more natural in C. Though most functions
that accept lfsr_data_t still take a copy. Most of these functions
would need to make a copy anyways now that the parsers are consuming,
and these copies avoid concerns about shared state.
At 3-words, lfsr_data_t is right at that boundary of questionable
reasonableness for copying, but copying is a very useful feature of
this struct.
This ends up with some decent code/stack savings:
code stack
before: 22118 2048
after: 21722 (-1.8%) 1992 (-2.7%)
As much as it was a nice way to utilize all 96-bits of lfsr_data_t, the
added code and RAM cost just made this not worth it.
The main problem with inlining leb128s into lfsr_data_t is that
lfsr_data_t reads have to contend with a number of awkward corner cases
involving offsets into the not-yet-encoded leb128s.
Adding to this the extra overhead encoding the number of leb128s in
lfsr_data_t, and the fact that 2 leb128s is mostly useless when metadata
redundancy > 2, I'm reverting this back to only a single optional leb128
in lfsr_data_ts limited to lfsr_bd_progdata:
0 = in-device buffer 1 = on-disk data
.----+----+----+----. .----+----+----+----.
|0| size |.>|1| size |
|----+----+----+----| |----+----+----+----|
| (optional leb128) | | off |
|----+----+----+----| |----+----+----+----|
| buffer | | buffer |
'----+----+----+----' '----+----+----+----'
It's also worth noting that since this leb128 is limited to
lfsr_bd_progdata, it shouldn't add any code cost to readonly variants of
littlefs.
Another interesting thing is that, while this single-injected-leb128-
for-lfsr_bd_progdata sounds limited on paper, it covers a number of
convenient use cases:
- injecting directory-ids into name attributes
- programming single leb128s, such as bookmarks
- prefixing B-tree branches with weights (not-yet implemented)
The idea of this is:
1. Aside from the encoded size, our lfsr_data_t has space for 2 integers.
2. Our mdir addresses are exactly 2 leb128s.
3. We already need to be able to inject 1 leb128 for did entries.
So if we can cram our 2 leb128s inline into the lfsr_data_t, we should
be able to avoid the indirection, wasted space in lfsr_data_t, and
duplicate encoding costs for the mdir addresses.
Conveniently for us, there are exactly 2 unused bits in various fields,
thanks to our common 31-bit limits.
It's a bit awkward since we must assume our buffer pointer uses all
32-bits, but here are the current encodings:
00 = in-device buffer 10 = on-disk data
no leb128s no leb128s
.----+----+----+----. .----+----+----+----.
|0| size | |1| size |
|----+----+----+----| |----+----+----+----|
|0000000000000000000| |0| offset |
|----+----+----+----| |----+----+----+----|
| buffer | | block |
'----+----+----+----' '----+----+----+----'
01 = in-device buffer 11 = 2 leb128s
1 leb128
.----+----+----+----. .----+----+----+----.
|0| size | |1| size |
|----+----+----+----| |----+----+----+----|
|1| leb128 | |1| leb128 |
|----+----+----+----| |----+----+----+----|
| buffer | | leb128 |
'----+----+----+----' '----+----+----+----'
This encoding also presents a relatively nice code-path, since we can
treat the 2 leb128 case as an on-disk data reference with no size.
Unfortunately the initial measurements look, uh, really bad:
code stack
before: 22194 2048
after: 22426 (+1.0%) 2088 (+2.0%)
This needs more investigation, but from what I can tell so far the RAM
cost comes from the leb128 encoding buffer moving into the "hot path",
aka the deepest call stack in littlefs, which involves lfsr_data_read
as a part of mtree traversal as a part of block allocation.
I have no idea about the code cost though...
Generally the more creative you get with C macros, the more
unmaintainable your codebase becomes, but in this case I think a small
bit of macro sugar for the attribute lists in littlefs goes a long way
for making the internals flexible and readable.
Attribute lists generally look like this:
LFSR_ATTRS(
LFSR_ATTR(id, TAG, delta, DATA(data)),
LFSR_ATTR(id, TAG, delta, DATA(data)),
...
LFSR_ATTR(id, TAG, delta, DATA(data)))
Which more-or-less gets expanded to this:
((const lfsr_attr_t[]){
((lfsr_attr_t){id, LFSR_TAG_TAG, delta, LFSR_DATA_DATA(data)}),
((lfsr_attr_t){id, LFSR_TAG_TAG, delta, LFSR_DATA_DATA(data)}),
...
((lfsr_attr_t){id, LFSR_TAG_TAG, delta, LFSR_DATA_DATA(data)}),}),
attr_count
Note the use of preprocessor concatenation to put the TAG and DATA
identifiers in their respective namespaces. These can end up invoking
other macros, which allows attrs to be rather extensible.
Previously there were also LFSR_ATTR_ (note the trailing underscore)
macros to allow passing of variable tags/datas. This is replaced with
redundant macros which sort of "unwrap" themselves as a part of macro
expansion. This avoids a bunch of duplicate macro definitions.
#define LFSR_TAG_TAG(tag) (tag)
#define LFSR_DATA_DATA(data) (data)
So:
LFSR_ATTR(id, TAG(tag), delta, DATA(data))
Becomes:
((lfsr_attr_t){id, LFSR_TAG_TAG(tag), delta, LFSR_DATA_DATA(data)})
Becomes:
((lfsr_attr_t){id, tag, delta, data})
This checksum is used to keep track of if we have erased, and not yet
touched, the unused bytes trailing our current commit in the rbyd.
The working theory is that if any prog attempt is made, it will, most
likely, change the checksum of the contents, allowing littlefs to
determine if trailing erased-state is safe to use, even under powerloss.
littlefs can also perturb future data by a single bit, to force this
checksum to always be invalidated during normal operation.
The original name, "forward erased-state checksums (fcksum)", came from the
idea that the checksum "looks forward" into the next commit.
But after using them for a bit, I think the name is unnecessarily
confusing. It, uh, also looks a lot like a swear word. I think
shortening the name to just "erased-state checksums (ecksum)", even
though the previous name is already in use in a release, is reasonable.
---
It's probably hard to believe but the name change from fcrc -> ecrc
really was unrelated to the crc -> cksum change. But boy is it
convenient for avoiding an awkward name. A lot of these name changes
involved sed scripts, so I didn't notice how awkward fcksum would be to
use until writing this commit message.
The reason for this is to move away from the idea that littlefs is
strictly bound to CRCs and make the code more welcoming to other
checksum types, such as SHA256, etc.
Of course, changing the name doesn't really do anything. littlefs
actually _is_ strictly bound to CRCs in a couple ways that other
filesystems aren't. These would need to have workarounds for other
checksum types:
- We leverage the parity-preserving nature of (some) CRCs to not have
to also calculate the parity of metadata in rbyd commits.
- We leverage the linearity of CRCs to retroactively flip the
perturb bit in the cksum tag without needing to recalculate the
checksum. Though the fact we need to do this is because of how we
use parity above, so this may just not be needed for non-CRC
checksums.
- The plans for global-CRCs (not yet implemented) rely heavily on the
mathematical properties of CRC polynomials. This doesn't mean
global-CRCs can't work with other checksums, you would just need to
find a different type of polynomial.
Unless very obvious, all buf variables should be prefixed with the
related variable they are being used to encode. Unlike other common
variables, bufs need to be sized correctly for what they are encoding.
Sharing bufs between variables is most likely a coding mistake.
Also tried to move away from the single letter 'w' variables, at least
in the C source.
Originally it made sense to name the rbyd ids, well, ids, at least in
the internals of the rbyd functions. But this doesn't work well outside
of the rbyd code, where littlefs has to juggle several different id
types with different purposes:
- rid => rbyd-id, 31-bit index into an rbyd
- bid => btree-id, 31-bit index into a btree
- mid => mdir-id, 15-bit+15-bit index into the mtree
- did => directory-id, 31-bit unique identifier for directories
Even though context makes it clear which id the id refers to in the rbyd
internals, updating the name to rid makes it clearer that these are the
same type of id when looking at code both inside and outside the rbyd
functions.
For a couple reasons:
1. Organizing the overlaps this way avoid potential undefined behavior.
It turns out C does define the overlap the "initial sequence" of
union members, as long as the types are the same. But when we
overlapped the block with the size/tag fields in lfsr_btree_t, it was
probably undefined behavior.
At the very least, it would introduce a need for quite a bit of
preprocessing to make it work with different integer sizes and
redundancy levels.
2. Overlapping the blocks at the end of the rbyd struct means our block
array is natural ordered such that the first block is the "active"
block, i.e. the block with the most recent revision count that passes
checksums.
This has been useful as a debugging tool, so I would like to continue
the pattern. It is possible to mostly preserve this order with the
previous method by intentional reversing the block array when
logging or writing to disk, but it's a bit cumbersome.
2. It's unlikely we'll be able to use readonly variants of the rbyd/mdir
structs for RAM savings. Unfortunately C makes this too cumbersome.
Though if we do this should be revisited.
Here are the new overlaps. Note it's no longer possible to truncate the
types when readonly. If readonly struct are useful this will need to be
revisited again:
lfsr_rbyd_t lfsr_btree_t lfsr_mdir_t
8b 8b 8b 8b
.----+----+----+----.
8b 8b 8b 8b 8b 8b 8b 8b | mid.bid | mid.rid |
.----+----+----+----. .----+----+----+----. |----+----+----+----|
| weight |.>| weight | | weight |
|----+----+----+----| |----+----+----+----| |----+----+----+----|
| trunk | | tag | size | | trunk |
|----+----+----+----| |----+----+----+----| |----+----+----+----|
| off | | inlined data | | off |
|----+----+----+----| | | | |----+----+----+----|
| crc | | v | | crc |
|----+----+----+----| | | |----+----+----+----|
| block |..| |.>| blocks |
'----+----+----+----' '----+----+----+----' | |
| |
'----+----+----+----'
This turned out to be tricky.
At littlefs's core, we have the lfsr_rbyd_t struct. It is really
important this is as small as possible since littlefs creates many rbyd
copies in order to track state of metadata on disk.
Wrapping rbyd, we have the lfsr_btree_t struct, which can alternatively
contain a single inlined entry, accomplished by overlapping the width
field in both cases. And the lfsr_mdir_t struct, which tracks any redundant
blocks, and would be nice if the blocks lined up as neighbors so all blocks
involved in the mdir could be passed around as an array. Both of these
wrappers attempt to overlap fields of the lfsr_rbyd_t struct, which presents
a bit of a problem.
The solution here is to put the rbyd block field at the beginning of the
lfsr_rbyd_t struct, and use exactly 32-bits of padding in lfsr_btree_t
to overlap the width field even though it is not at the beginning of the
struct. To avoid inflating the lfsr_btree_t size, we sneak the inlined
size and tag into the overlapping padding. This will need special
handling if the size of these fields change, but saves a decent amount
of RAM:
lfsr_rbyd_t lfsr_btree_t lfsr_mdir_t
8b 8b 8b 8b
.----+----+----+----.
| mid.bid | mid.rid |
|----+----+----+----|
8b 8b 8b 8b 8b 8b 8b 8b | blocks |
.----+----+----+----. .----+----+----+----. | |
| block |..| tag |size|padd|.>| |
|----+----+----+----| |----+----+----+----| |----+----+----+----|
| weight |.>| weight | | weight |
|----+----+----+----| |----+----+----+----| |----+----+----+----|
| trunk | | inlined data | | trunk |
|----+----+----+----| | | | |----+----+----+----|
| off | | v | | off |
|----+----+----+----| | | |----+----+----+----|
| crc | | | | crc |
'----+----+----+----' '----+----+----+----' '----+----+----+----'
Also tried to reduce the amount of mdir usage in lfsr_mdir_commit by
better using only the arrays of relevant mdir blocks, to limited success.
The previous state machine would happily pick up random names if the
struct had no name of its own. This was picking up typedefs of random
structs and making things really confusing.
Now the rule is that unnamed structs are not printed. Unnamed structs
are usually implementation details so their size is not really useful.
Also made the parsing state machine for objdump outputs more resilient
to these sort of issues.
Also changed structs.py to also report unions if they have a name.
- Updated LFSR_BTREE_INLINESIZE to properly include the overhead for
mdir pointers, which need 2 block addresses instead of 1. This adds
4 bytes to the lfsr_btree_t struct.
- Changed code that marks rbyds as "needing compaction" to use -1
instead of block_size. This can use a cheaper constant and helps
debugging.
- Changed the mid representation of root to 0.0 from ?.-1. The mid 0.0
is always reserved for the roots dstart, so it shouldn't be used for
any actual file. This disambiguates root vs special metadata mids and
is a step towards making mids unsigned.
It also saves a tiny bit of code since 0 comparisons are generally
cheaper and we can leverage the order-preserving conversion of mid
to an integer.
- test_dtree - Pure directory creation/deletion/move functionality
testing. This ends up testing the core of littlefs file entry
manipulation, since directories is all we need for that.
- test_dseek - Tests more of the corner cases specific to directory
iteration and seeking. This involves an annoying amount of
interactions with concurrent updates to the filesystem that are
complicated to test for.
Also generally renaming the "fstree" concept to "dtree". This only
changes dbglfs.py as far as I'm aware. It's useful to have a name for
this thing and "directory tree" fits a bit better than "filesystem tree"
which could be ambiguous when we also have the "metadata tree" as a
different concept.
No more typing this wrong. It probably should have been named make ctags
originally, but since it's been make tags for so long I'm leaving both
as an option for now.
The previous system of relying on test name prefixes for ordering was
simple, but organizing tests by dependencies and topologically sorting
during compilation is 1. more flexible and 2. simplifies test names,
which get typed a lot.
Note these are not "hard" dependencies, each test suite should work fine
in isolation. These "after" dependencies just hint an ordering when all
tests are ran.
As such, it's worth noting the tests should NOT error of a dependency is
missing. This unfortunately makes it a bit hard to catch typos, but
allows faster compilation of a subset of tests.
---
To make this work the way tests are linked has changed from using custom
linker section (fun linker magic!) to a weakly linked array appended to
every source file (also fun linker magic!).
At least with this method test.py has strict control over the test
ordering, and doesn't depend on 1. the order in which the linker merges
sections, and 2. the order tests are passed to test.py. I didn't realize
the previous system was so fragile.
This didn't really work out as well as I had hoped. There were a few
ideas on how to encode the bid/rid tuple without sacrificing the
(currently 31-bit) integer limit, but these just introduced too much
complexity.
Ideas:
1. In theory, as the mdirs increase in size, the quantity of mdirs needed
for a given number of files decreases. If we say the number of files
fits in an integer of a given size, than we can model the mapping to
mdirs and rids roughly as the number of bits in that integer split
between the two.
Since the block_size is known, the we can find a rather conservative,
yet useful, estimate of the upper bound of rids, which ends up
being ~16 bytes ((2 alts + 1 null + 1 tag) * 4 bytes).
And since our btrees are perfectly balanced, this encoding should only
waste 1 or 2 bits due to rounding to rounding and sign encoding for
special values.
bbbbbbbb bbbbbbbb bbbbbbbr rrrrrrrr
'-----------+-----------''----+---'
| '-- log2(block_size/32)-bit rid
'-------------------- remaining-bit bid
Unfortunately, while this works ok on paper, and maximize the use of
the bits we have available for the mid, the implementation ended up
awkward and difficult to use.
We need to either calculate the relatively complciated log2 of the
block_size on the fly, or cache the value, and use it to shift the
mid around to extract the bid/rid when needed.
Unfortunately, perhaps due to the it being easy to use the bid/rid
directly, we use and mutate the bid/rid quite a bit. We mutate when
updating the mdirs, when decoding grms, when seeking mdirs, etc. If
anything, updating the mid in total is rarer than updating the
bid/rid component in complicated situations.
Note to mention this required access to the lfs config to even begin
decoding, complicating the API and making the result less efficient.
Initial (unoptimized, and not even tested) code size showed ~+800
bytes. So I decided to scrap this.
Maybe it will be worth investigating dynamic rid sizes later, to
increase the possible mtree size for a given mid width. Not sure.
2. Probably one of the worst ideas I've had so far, but it would solve
the mid encoding problem, is to use some form a floating point to
encode the bid/rid pair:
.----------.
v .+-.
bbbbbbbb bbbbbbbb bbbrrrrr rrrrssss
'-----------+-------''----+---''-+'
| | '-- rid bits
| '--------- variable rid
'----------------------- variable bid
An even worse idea would be to use IEEE floating point here. Yes it
would work, and probably work annoyingly well, but we it risk
bringing in a lot of standard conforming backbending that we really
don't care about.
The idea here is to sacrifice some bits to encode the ratio of rid
bits to bid bits. The value of this over the using the block_size is
that we can decode the bid and rid using all of the bits in the
integer alone. Avoiding memory access (and worse debugging) to load
any external constants.
As a plus, all mids in the system would have the same exponent,
simplifying comparisons and other operations.
But this is just trying to solve complexity by adding more
complexity, so I'm not even going to try implementing it.
Still, it's an interesting idea...
In the end I've gone with the KISS implementation. Use half-width
integers, in this case uint16s, for both the bid and rid:
bbbbbbbb bbbbbbbb rrrrrrrr rrrrrrrr
'-------+-------' '-------+-------'
| '-- 16-bit rid
'-------------------- 16-bit bid
This suffers from weakened limits around the number of rids in a block
and number of mdirs in the mtree, which is unfortunate. Still it is
probably worth the tradeoff for the RAM savings and encoding simplicity.
If the mdir is reasonably sized, this does probably approach a decent
distribution of rids and bids in 32-bits. But for outlier cases with
very small and very large mdirs, it risks premature out of bounds
errors.
To protect against mtree errors, we will probably need an additional
configuration option in the form of an mdir limit. Conveniently this
would also provide a way to enforce 2-block mode.
rid errors, on the other hand, depend on block_size/32, so we may not
need another configuration option and can rely on the block_size
to determine if the rids can overflow.
This is probably worth revisiting in the future. Fortunately, with
mdir_limit and block_size configuration options, it should be possible
to increase these limits in the future if this mid bid/rid design
changes.
code stack
before: 22126 2136
after: 22326 (+0.9%) 2088 (-2.2%)
This code size increase was unexpected. Maybe non-32-bit-aligned integers
cost more to load in thumb? Unsure.
The main intention here was to make the tracking of opened mdirs,
mostly opened lfsr_dir_t structs, simpler and more resilient to weird
corner cases. I'm not entirely sure this was successful.
The main changes:
- lfsr_dir_t now contains a full mdir for the dstart entry.
This makes it so that dstarts are not a special case when it comes
to mdir updates, though the fact that directories have 2 mdirs is
still an awkward case on its own.
I considered using two entries in the opened linked-list for this, but
it wouldn't have worked out that well. Both entries need to update the
directory position, so it would have required a third file type. We
would also have needed to make sure removed mdirs mark both mdirs as
removed, otherwise the position mdir would move around arbitrary into
possibly erronous values.
Instead the current solution treats the directory mdirs as a small
array of 2 mdirs, which is as hacky as it is hacky, but does get the
job done with little code duplication.
- Directory positions are updated a bit more intellegently.
Instead of checking if in range before updating, which requires access
to both mdirs and duplicate mid/rid comparison logic, position is
updated without regard for the beginning of the directory, and
un-updated if it was actually out of range of the directory.
This means we only need to compare the mids/rids for each mdir once.
This changes make it so that lfsr_dir_rewind is much cheaper, and
doesn't even need to go to disk. Though I'm not sure it's worth the RAM
increase...
Expanding the lfsr_dir_t dstart entry to a full mdir does a lot for
making mdir updates more consistent, but increases the lfsr_dir_t size
from 52 bytes to 76 bytes (+46.2%).
This is mostly to make it easier to merge mids/rids. Having a special
constant here is tricky when the mid/rid split point is dynamic.
Currently using rbyd.trunk=0 to indicate when an mdir is dropped. This
is nice as it preserves the last mid/rid, which is needed by the readdir
code, and it implicitly returns NOENT to all queries in
lfsr_rbyd_lookup.
When updating any opened mdirs to keep things in sync, we need to know
what rid the mdir is targeting in order to know which on-disk mdir it
should follow in the case of splits. Making this rid an actual member of
the mdir struct simplifies things.
This adds some RAM cost, though the plan is to merge the mid/rid into a
single integer, which requires this change and should actually save RAM
in the long run.
code stack
before: 22342
after: 22204 (-0.6%) 2144 (+1.1%)
This really just plays out what the compiler is already doing, so the
code/stack cost is more-or-less unchanged.
We can at least deduplicate the mroot_ copying of the potentially-failed
mdir_.
- We need to clamp dids to 31-bits. We were clamping to 32-bits
correctly, but we rely on dids fitting in 31-bits to fit them into our
lfsr_data_t type.
This does make our dids more dense when the mtree is near full, but
keeping dids 31-bits (or bound to the file size type) also gives us
more flexibility when it comes to deduplicating common leb128 operations.
- We weren't using the right mask during collision resolution. This was
just an oversight and an unimpactful fix.
Also saved 4 bytes, which is probably the cost of storing the outdated
constant in a nearby constant pool. We don't really care.
I completely forgot we terminate the inner nodes of the rbyd after
compaction with null tags. This means 3 extra tags per tag after
compaction, not 2.
This doesn't actually change the nearest-power-of-two for lfsr_mkdir,
but it does improve the bound for maximum rids, which I have some plans
for.
Instead of reading eagerly and retreating with the hopes of terminating
early (which almost never happens when compacting, since we need to find
the split_id). lfsr_rbyd_estimate now works inward from the first and
last id to find both the dsize and split_id.
One thing that helps this is the addition of a separate per-id
lfsr_rbyd_estimate, which will be useful for checking if the quantity of
file attributes overflows our mdir limitations.
lfsr_rbyd_estimate also now ignores the -1 id for split_id calculation,
since -1 ids are always cleaned up during splitting, though it does
include it in the calculated dsize so that the condition to split is
determined correctly.
---
This also required rebalance changes. Fortunately, one improvement here
is that we can make a simplifying assumption tha the number of tags
can't exceed the maximum possible number of tags in the calculated
dsize. So worst case, if every tag is empty, the maximum possible dsize
becomes 4*(2*log2(dsize/4))+dsize.
Though it's still unclear if rebalance is worth keeping. Current
comparison:
code stack
rebalance: 22362 2120
no_rebalance: 21922 (-2.0%) 2120 (+0.0%)
This means no special case for uninling-but-not-splitting, but allows
the entire split route to be deduplicated, simplifying things.
The main downside is that for littlefs to go from a single inlined mdir
filesystem to an mtree filesystem it requires a minimum of 2 mdir
allocations (4 blocks) in all cases. This can be avoided, but I think is
worth the tradeoff since it generally occurs once in a filesystem's
lifetime.
This does make 4 block block devices a bit awkward, but those geometries
are always going to be a bit awkward with littlefs's design. At least
this implementation avoids an unecessary B-tree node where possible...
code stack
before: 22586 2320
after: 22414 (-0.8%) 2120 (-8.6%)
I _think_, but haven't verified, the significant stack saving comes from
the fact that since there's one route through lfsr_mtree_split_,
lfsr_mtree_split_ can be inlined into lfsr_mdir_commit. This avoids the
marshalling of all its arguments for the function call, which I've
noticed can have a surprising cost.
---
Also fixed a bug where dstart was not updated with mid changes after
splits/drops. The mdir commit cleanup code has a lot of duplication now,
makes me wonder if there's a better way to structure this.
It turned out our dir-read-idempotent test never created non-dstart
neighbors. This was a bit of a problem since we relied on dstart entries
to know when our dir read terminates. If we seek to an invalid position
(in theory undefined behavior, but easily possible with concurrent
modifications to the directory), we can end up reading an unrealted,
non-dstart entry, and incorrectly reporting that entry as in our current
dir.
This fix reintroduces the did into the lfsr_dir_t struct and uses the
did to determine end-of-dir. This adds some RAM cost, but is more
resilient to any seeks that overshoot the end of the directory.
Using did is also a stronger guarantee we will never accidentally report
unrelated entries as a part of the current directory.
Also a bit of reordering lets us avoid a second mdir allocation, since
we can store the dstart mid/rid in the grm immediately. It's stack
allocated so just gets dropped if our dir turns out to not be empty.
code stack
before: 22654 2344
after: 22586 (-0.3%) 2320 (-1.0%)
There was an idea of making the necessary mid/rid adjustments to grm in
lfsr_mdir_commt implicitly.
Explored this, but:
1. It looked like the result would increase code size, though only by
a small (~12 byte) amount.
2. It wouldn't actually work, because lfr_mkdir needs to create a grm
for an mid/rid that doesn't actually exist at the time of commit.
Such a grm can't be created and survive any implicit mid/rid
adjustment.
So scratching that idea for now.
We never encode/decode the grm to/from disk and we always know the
buffer size statically.
Even when we calculate the size for the grm tag, we ignore the encoded
size and optimistically scan for the number of trailing zeros, giving us
a potentially smaller gdelta.
This change drops the encoded length completely in grm encoding/decoding
functions, assuming all related buffers are statically sized and padded
with zeros.
This also means you can't forget to zero the buffer when encoding, which
was already overlooked several times, leading to internal garbage on
disk. So that's nice.
This is entirely a pragmatic change, lfsr_mdir_commit already does
several hairy things with grm tags, decoding, fixing, reencoding, etc,
so it makes sense to move all the encoding logic into lfsr_mdir_commit.
This leads to a couple optimizations:
- We don't need to decode the grm to apply any last minute fixes.
- By allowing the grm arugment to be mutated (they are just sitting on
the stack anyways, we need a copy in case we back out of change due to
error), we can apply and save any grm fixes in the grm argument
itself.
This means we only need to fix the grm at most once, after any mtree
modifications.
Which in turn saves some code and stack cost:
code stack
before: 22930 2392
after: 22706 (-1.0%) 2344 (-2.0%)
Unfortunately the previous attempt to fix the dir seek system didn't
really work. Using a packed mid/rid integer for the offset is tempting,
but since mid/rid can change with any metadata id change in the
filesystem, dir tell offsets would become invalidated if you modified
files in unrelated directories, which isn't great and likely to catch
users by surprise.
This solution builds on the previous dir offset design, which tracks the
dstart-relative position independently from the current mid/rid in our
directory. To update this correctly when there are unrelated changes to
the filesystem, we need to know if metadata id changes are in the range
between our directories dstart and current mid/rid. This in turn means
we need to track our dstart. So our opened directories need three
separate pointers we need to update on every mdir commit:
dir->pos
|
.-------+-------.
a b c d e f g h i j k l m n o p
^ ^
| |
dir->dstart dir->mdir
This has quite a few moving parts, which I was hoping to avoid.
Fortunately we don't need a second mdir, so the RAM cost is pretty
small.
We can also drop dir->did, since the dstart mid/rid render it redundant,
which is interesting.