Commit Graph

395 Commits

Author SHA1 Message Date
Christopher Haster 99156b5573 Implemented orphans resulting from closing desynced scratch files
This is a fun corner case. What happens when you close a desynced
scratch file?

The obvious answer seems to be just remove the scratch file in
lfsr_file_close.

But then what if the file is rdonly? desynced because of an error?

We really shouldn't write to disk at all when closing a desync or rdonly
file. This needs to be a hard rule.

So the only option is to defer the work until later somehow.

Fortunately, we already have several mechanisms that lead to a very nice
solution. I'm very happy with this:

1. There's nothing that says our in-device grm queue needs to always
   match what's on-disk (we need a separate copy for xoring anyways
   because of the risk of leb128 encoding differences). So if we have
   <=2 orphans, we can just push these onto our grm.

   On the next write operation, the normal grm fixing code takes over
   and removes the pending orphans O(1).

2. If we have >2 orphans, the best we can do is mark the filesystem as
   having orphans, and trigger an orphan scan on the next write
   operation O(nlogn).

   But how often do you think littlefs's use cases will end up with >2
   orphans?

Note we also need to scan the opened-file list to make sure we're the
_last_ reference to the scratch file. Otherwise we corrupt other opened
file handles!

---

This commit also includes a fix for a bug where the traversal mdir fell
out of sync when dropping mdirs as a part of scratch file cleanup. Found
when adding more tests, this would cause scratch files to go
unreclaimed.
2024-02-03 18:15:32 -06:00
Christopher Haster 0510c4b185 Added tests over shared scratch files
One downside of scratch files is that there are a lot of corner cases to
consider.
2024-02-03 18:15:31 -06:00
Christopher Haster ba505c2a37 Implemented scratch file basics
"Scratch files" are a new file type added to solve the zero-sized
file problem. Though they have a few other uses that may be quite
valuable.

The "zero-sized file problem" is a common surprise for users, where what
seems like a simple file create+write operation:

  lfs_file_open(&lfs, &file, "hi",
          LFS_O_WRONLY | LFS_O_CREAT | LFS_O_EXCL);
  lfs_file_write(&lfs, &file, "hello!", strlen("hello!"));
  lfs_file_close(&lfs, &file);

Can end up create a zero-sized file under powerloss, breaking user
assumptions and their code.

The tricky thing is that this is actually correct behavior as defined by
POSIX. `open` with O_CREAT creats a file entry immediately, which is
initially zero-sized. And the fact that power can be lost between `open`
and `close` isn't really avoidable.

But this is a common enough footgun that it's probably worth deviating
from POSIX here.

But how to avoid zero-sized files exactly? First thought: Delay the file
creation until sync/close, tracking uncreated files in-device until
then. This solves the problem and avoids any intermediary state if we
lose power, but came with a number of headaches:

1. Since we delay file creation, we don't immediately write the filename
   to disk on open. This implies we need to keep the filename allocated
   in RAM until the first sync/close call.

   The requirement to keep the filename allocated for new files until
   first sync/close could be added to open, and with the option to call
   sync immediately to save the filename (and accept the risk of
   zero-sized files), I don't think it would be _that_ bad of an API.

   But it would still be pretty bad. Extra bad because 1. there's no
   way to warn on misuse at compile-time, 2. use-after-free bugs have a
   tendency to go unnoticed annoyingly often, 3. it's a regression from
   the previous API, and 4. who the heck reads the more-or-less same
   `open` documentation for every filesystem they adopt.

2. Without an allocated mid, tracking files internally gets a lot
   harder. The best option I could think of was to keep the opened-file
   linked-list sorted by mid + (in-device) file name.

   This did not feel like a great solutiona and was going to add more
   code cost.

3. Handling mdir splits containing uncreated files adds another
   headache. Complicated lfsr_mdir_estimate further as it needs to
   decide in which mdir the uncreated files will end up, and potentially
   split on a filename that isn't even created yet.

4. Since the number of uncreated files can be potentially unbounded, you
   can't prevent an mdir from filling up with only uncreated files. On
   disk this ends up looking like an "empty" mdir, which need specially
   handling in littlefs to reclaim after powerloss.

   Support for empty mdirs -- the orphaned mdir scan -- was already
   added earlier. We already scan each mdir to build gstate, so it
   doesn't really add much cost.

Notice that last bullet point? We already scan each mdir during mount.
Why not, instead of scanning for orphaned mdirs, scan for orphaned
files?

So this leads to the idea of "scratch files". Instead of actually
delaying file creation, fake it. Create a scratch file during open, and
on the first sync/close, convert it to a regular file. If we lose power,
scan for scratch files during mount, and remove them on first write.

Some tradeoffs:

1. The orphan scan for scratch files is a bit more expensive than for
   mdirs on storage with large block sizes. We need to look at each file
   entry vs just each mdir, which pushed the runtime up to O(BlogB) vs
   O(B).

   Though if you also consider large mtrees, the worst case is still
   O(nlogn).

2. Creating intermediate scratch files adds another commit to file
   creation.

   This is probably not a big issue for flash, but may be more of a
   concern on devices with large prog sizes.

3. Scratch files complicate unrelated mkdir/rename/etc code a bit, since
   we need to consider what happens when the dest is a scratch file.

But the end result is simple. And simple is good. Both for
implementation headaches, and code size. Even if the on-disk state is
conceptually more complicated.

You may have noticed these scratch files are basically isomorphic to
just setting an "uncreated" flag on the file, and that's true. There may
have been a simpler route to end up with the design, but hey, as long as
it works.

As a plus, scratch files present a solution for a couple other things:

1. Removing an open file can become a scratch file until closed.

2. Scratch files can be used as temporary files. Open a file with
   O_DESYNC and never call sync and you have yourself a temporary file.

   Maybe in the future we should add O_TMPFILE to avoid the need for
   unique filenames, but that is low priority.
2024-02-03 18:15:29 -06:00
Christopher Haster 1344d416d2 Relaxed asserts, allow syncing rdonly files, error on unsync
This is a compromise on consistency and not breaking expected
invariants.

The problem: rdonly files can become unsynced:

1. file is opened rdonly + desync
2. the same file is opened and written to
3. we try to sync our original file handle

What we want:

1. sync should ensure disk + files are in-sync
2. rdonly implies sync should not write to disk

Without desync, and in other systems, this is not a problem, because
rdonly files can never become unsynced.

But with desync, a state (albiet a roundabout one) can be reached where
we can't satisfy both of these invariants.

I wanted to just assert on syncing a rdonly file, but this is supported
on POSIX and other systems, and it makes sense that you would want to
unconditionally call sync in certain circumstances (ensuring close can't
write to disk for example).

So adopts the approach of allowing flush and sync on rdonly files when
possible, and when not possible, sync simply returns LFS_ERR_INVAL and
makes it the user's problem.

For the above example, this has the side effect of making the rdonly file
desync again, so close can complete without touching disk.

As a plus, a desynced rdonly file can now be used to test if a file has
been written to. Though I'm not sure when this would be useful... Or
if it's a good idea to suggest this use of the API...
2024-02-03 18:15:22 -06:00
Christopher Haster 122864f4b6 Reverted LFS_O_SYNC implicit noop sync broadcasting
Reading more into POSIX, it seems that most of the write functions do
have special behavior built into what would implicitly be a noop.

It's difficult to find, since it usually doesn't matter, but consider
the m_time field. The following operations do _not_ update m_time:

- write when size=0
- truncate when size does not change
- fruncate when size does not change

I think it's safe to extend these to sync broadcasts in littlefs, and
only guarantee sync broadcasts when the file state has changed (even
though that may mean other file handles may remain out-of-date!).

In this interpretation, the "write operations" described in POSIX more
mean the implicit write operations effected by write/truncate/fruncate.

That being said, it's not clear what the best approach is, desync files
make this all a bit more muddled... This may also be reverted.
2024-02-03 18:15:21 -06:00
Christopher Haster 4e7a68ff08 Fixed another subtle corner cases with noop sync broadcasting
This is an extension of the noop-sync after unrelated write-sync after
desync corner case:

  op                 a state         b state
                     in-sync         in-sync
  desync(b)          in-sync         desync
  write(a)           unsync          desync
  sync(a)            in-sync'        desync
  sync(b)            in-sync         in-sync

But instead of explicitly calling lfsr_file_sync, what if you implicitly
triggered sync through something like a write on a file with the
LFS_O_SYNC flag, but not a normal write, a noop write, write(0)?

If the definition of LFS_O_SYNC is taken literally as "lfsr_file_write
and friends implicitly call lfsr_file_sync after every call", then this
should behave just as if lfsr_file_sync had been called, and
unconditionally broadcast the sync. Since this is the simplest
interpretation, I think this is what we should implement.

Added tests, and adopted this behavior. Fortunately this just involves
some small gotos (https://xkcd.com/292):

            code          stack
  before:  33020           2976
  after:   33026 (+0.0%)   2976 (+0.0%)
2024-02-03 18:15:19 -06:00
Christopher Haster fdc8c8caf1 Fixed noop sync broadcasting, added more specific sync tests
There are a number of nuanced cases to watch out for when mixing sync,
desync, and "noop syncs" (sync when no write operation has occured):

1. Noop-sync after unrelated write:

     op                 a state         b state
                        in-sync         in-sync
     write(a)           unsync          in-sync
     sync(b)            in-sync         in-sync

   In this case, a should be clobbered by b when b syncs. But this
   gets tricky since b is still up to date with the disk, so b's
   unsynced flag is not set.

   The solution here is to just unconditionally broadcast all sync
   operations irregardless of on-disk state. This is all in-device
   anyways, so it shouldn't really add any overhead.

2. Noop-sync after unrelated write-sync after desync:

     op                 a state         b state
                        in-sync         in-sync
     desync(b)          in-sync         desync
     write(a)           unsync          desync
     sync(a)            in-sync'        desync
     sync(b)            in-sync         in-sync

   In this case, a should again be clobbered by b, even though a is
   in-sync with the disk. This is not tricky because of a's state, but
   because b doesn't know it is no longer in-sync with the disk.

   The solution here is to set the unsynced flag on all desynced files
   when an unrelated file is synced. This way, b knows it needs to
   update disk if sync is called. We already scan all opened files to
   update in-sync files, so this has very little cost.

3. Readonly-sync after unrelated write-sync after desync?

   This is basically the same as 2., but involves a readonly file:

     op                 a state         b state (rdonly)
                        in-sync         in-sync
     desync(b)          in-sync         desync
     write(a)           unsync          desync
     sync(a)            in-sync'        desync
     sync(b)            ???             in-sync

   In this case, I have no idea what should happen.

   I would guess the least surprising result would be for b to write
   its contents to a/disk? Bringing everything in-sync?

   But this implies that b, a readonly file, should write to disk.

   This isn't the only place a read operation would result in a write.
   RDWR files, for example, can flush buffers during a file read. But at
   least there, the file is open RDWR, not strictly RDONLY.

   It seems like writing during sync on a readonly file breaks some sort
   of invariant users expect.

   But the alternative: Dropping the current state of b in favor of a's
   state, is inconsistent with sync on WRONLY/RDWR files, and seems like
   it breaks some sort of invariant about sync modifying the current
   file's state...

   Given this situation, I think the best course of action is to just
   disallow sync on readonly files. It is now an assert.

   There is some precedent for this, upstream we already omit sync when
   compiled in LFS_READONLY mode. Though this does deviate from POSIX
   behavior...

   Worst case, by asserting, this leaves us free to introduce different
   readonly-sync behavior in the future without breaking backwards
   compatibility.

   ---

   Maybe there should be some sort of lfsr_file_resync function to
   discard current changes? Though this can be done with a close+open
   cycle, so I think the value would be low.

Added tests over these cases and fixed where they broke, except for 3.,
lfsr_file_sync and lfsr_file_flush get asserts now to prevent their use
on readonly files.

Also added a couple more specific tests to cover cases I was concerned
about.
2024-02-03 18:15:18 -06:00
Christopher Haster 0891f6264f Renamed test_fmulti -> test_fsync
This should avoid confusion between "multiple handles" and "multiple
files" (name undecided) test suites.

It also fits well because this suite really is just testing nuanced
sync/desync behavior.
2024-02-03 18:15:17 -06:00
Christopher Haster dfdf109505 Revert back to single typed linked-list for opened mdirs
While the multi per-type linked-lists were cool and could save RAM in
some structs (at the cost of RAM in the lfs_t struct), this is simpler,
and simpler is good.

The motivation to revert:

1. I noticed most file types have some sort of flags: files,
   traversals (future), (not dirs but maybe in the future). These flags
   can be merged with the type field to give us typed mdirs at almost
   no RAM cost.

2. Using a single linked-list makes it cheaper to add more file types,
   which may be useful for managing bookmarks (differently) and scratch
   files.

   This comes at a runtime cost, since all scans look at all opened
   structs, but we really, _really_ don't care about a constant non-IO
   runtime cost.

There are code benefits, since we don't need nested iterators to access
all opened mdirs, but also some code cost when we want to filter by
type. As expected stack took a small hit. Humorously, the struct savings
in lfs_t perfectly canceled out the struct hit to lfsr_dir_t:

            code          stack          structs
  before:  32992           2968             1080
  after:   33004 (+0.0%)   2976 (+0.3%)     1080 (+0.0%)
2024-02-03 18:15:15 -06:00
Christopher Haster b0bd026b87 Reworked ftree/bshrub/shrub relationship, staging in ftree now
This is an attempt to simplify things a bit by moving more logic into
the ftree layer, instead of spreading things around between the
bshrub/bsprout functions.

Now, functionality is organized into high-level ftree operations and
low-level shrub/sprout operations, which only care about the inlined
portion of the shrub/sprout. No more lfsr_bshrub_commit/
lfsr_bshrub_commit__ which were mostly unrelated.

This also adds a lfsr_shrub_t type, which, by taking advantage of the
unused write-related rbyd fields to store the shrub estimate, has the
same size as lfsr_rbyd_t, but can still be casted to an rbyd/btree for
use in readonly rbyd/btree functions.

I considered merging shrub/sprout esimate and shrub/sprout compact into
some sort of ftree_estimate/compact, but it's not obvious what the
benefit would be, so leaving that on the table for now.

---

One nice change is our staging copies are now at the ftree level
(ftree.u and ftree.u_, maybe not the best names, but this is what I've
been using for unions where the name doesn't really matter, god I want
unnamed unions). This simplifies staging, and avoids staging issues
where the underlying type changes.

---

A bit unrelated, but necessary to integrate lfsr_ftree_traverse, a
generalized lfsr_tinfo_t type for all traversal functions was added
(adopted from lfsr_traversal_t really). This is a straightforward tagged
union with relevant traversal types.

The benefit of a generalized tinfo type is better chance we can just
pass the tinfo pointer through multiple layers.

Code changes:

            code          stack
  before:  33368           2984
  after:   33260 (-0.3%)   3024 (+1.3%)
2024-02-03 18:15:07 -06:00
Christopher Haster b76ff63e53 Added more sync tests, fixed some bugs, found some design flaws
Now mixing in truncate/fruncate, along with desync<->sync state
transitions.

Found bugs:

- Fixed propagating LFS_F_UNSYNCED/LFS_F_UNFLUSHED state during sync
  broadcasts. This is important for tracking small files correctly.

- We were not clearing the btree erased-state of other opened file
  handles when we started using it, leading other file handles to have
  out-of-date erased-state.

  I considered moving this into lfsr_btree_commit, but file btrees are
  really the only place where shared references make sense, and it feels
  weird to scan file btrees every time we commit to the mtree.

- Fixed syncs not propagating to other file handles when file is synced
  with disk.

  It's interesting that lfsr_file_sync can actually have an effect on
  the system when the disk in is-sync.

- Added O_FLUSH/O_SYNC support to lfsr_file_truncate/fruncate. This
  omission was just an oversight.

  Unfortunately this did add quite a bit more complexity to both
  functions.

You may notice in the fix for that last bug, that lfsr_file_ftruncate
sort of drops the ball with regards to error-idempotency. This is
because, as I was trying to figure out how to recoverably move the
buffer around when fruncating small files, I realized we don't handle
small files in lfsr_file_write correctly w.r.t. error-idempotency, and
that fixing this may be intractable...

The issue is how handle overwrites for unflushed buffers.

In general, the correct thing to do when an incoming write overlaps our
file buffer, is to just write over the buffer with the new data.

Ah, but if we do this, how do we get the old data back if we run into an
error writing the data to disk? It's gone!

For normal files, this is not an issue. We can always flush to disk to
reclaim our buffer, and since a flush doesn't change the file contents,
it's fine to make this our new fallback state.

But for small files, flush is a noop, we keep these entirely in RAM.

There are some possible workarounds:

- Flush small files to disk before overwriting, sort of defeats the
  purpose of caching these in RAM...

- Reread small files from disk, because that's definitely what you want
  to do when you hit an error...

  Also, to always have something we can read from disk implies flush
  on overwrite, see above.

- Sacrificing half our buffer for staging small files. Because RAM cost
  is totally not a priority...

Long story short, rethinking idempotent errors.
2024-02-03 18:14:57 -06:00
Christopher Haster b336e92c66 Exposed lfsr_file_flush, LFS_O_FLUSH, for manually flushing buffers
A recent change, motivated by user feedback, was to delay write buffer
flushes as much as possible. Before, littlefs would always flush the
buffer during lfs_file_seek, but now, buffer flushes can be delayed all
the way to lfsr_file_read, or even skipped entirely thanks to bypassing
reads.

This is all fine and dandy, except it's easy to imagine a use case where
a user might really not want a _write_ error to pop out of a _read_
call.

With this new behavior, avoiding this situation is impossible.

So enters a function common to other filesystems: lfsr_file_flush.

However it's value is quite a bit different here. Unlike flush in other
filesystems, this flush does not necessarily make data accessible on
disk. It only writes to the pending file snapshot, which is not
accessible until lfsr_file_sync.

This makes flush a function with a rather narrow scope in littlefs
(pretty much just preventing write errors in read), but since we had
already implemented this function for internal plumbing, it adds _very_
little cost.

I'm more concerned about potential user confusion around sync vs flush.

Curiously, exposing lfsr_file_flush actually _saved_ code size for some
reason. Not sure what would make that happen:

                   code          stack
  before:         33544           3072
  flush:          33536 (-0.0%)   3072 (+0.0%)
  flush+O_FLUSH:  33548 (+0.0%)   3072 (+0.0%)
2024-02-03 18:14:54 -06:00
Christopher Haster ae2644eb88 Added LFS_O_SYNC, for implicit syncs during file writes
The motivation for this comes from the observation that many users call
sync on every file write. Much more than I expected. I think one reason
is in embedded systems it's common to just write structs to disk, either
the whole file or to a log.

O_SYNC exists in POSIX/Lunix/etc, so it makes sense to provide in
littlefs. In theory it's just one extra function call, and may even save
in total application cost (though we don't measure this) by reducing the
number of function calls at the application-level.

---

Unfortunately in-practice turned out to be quite a bit different than
in-theory... The main culprit being the improved guarantees around error
atomicity...

The ideal guarantee is that if there is an error during a write, the
entire write operation is reverted. Combining this with O_SYNC means we
need to hold a copy of the origin file state all thwe way through our
sync call. This got a bit messy...

The annoying part isn't even the functionality! Our system of tracking
btree/bshrub snapshots is quite robust! The problems were entirely with:

1. Figuring out how the heck to avoid clobbering the old file buffer
   state.

2. Figuring out how the internal APIs should work while passing around a
   bunch of staging state.

For 1., fortunately, thanks to bypassing writes, and some careful
pointer manipulation, we can void buffer clobbing. And for 2. just some
internal API work was needed. Internally all syncs end up in
lfsr_ftree_sync, though this feels a bit clumsy since the functionality
is not really ftree related...

Unfortunately, all of this added up to quite a bit more code cost than
I had hoped. In theory, adding some sort of LFS_CERAMIC/LFS_GLASS modes
that relax error atomicity for code size could help with most of this?
But it needs some thought:

            code          stack
  before:  33324           3072
  after:   33544 (+0.7%)   3072 (+0.0%)
2024-02-03 18:14:53 -06:00
Christopher Haster b15940461d Implemented desynchronized files
Desynchronized files are a new concept intended to capture some useful
quirks of the previous multiple-open-file behavior.

This adds:

- LFS_O_DESYNC     - Mark a file as desync during open
- lfsr_file_desync - Mark a file as desync whenever
- lfsr_file_sync   - Mark a file as NOT desync, and sync the file

Desynced files:

1. Don't recieve updates from writes to other file handles. This makes
   desynced files act as a sort of snapshot of the file at the time it
   was marked desync.

2. Don't call lfsr_file_sync on close. Unless lfsr_file_sync is
   explicitly called, changes to desynced files are not reflected on
   disk and not broadcasted to other file handles.

A side-effect of 2., is that this gives you a quick way to abort a file
write. Marking a file as desync and then closing the file will never
error.

Additionally, if an error occurs during a write operation, the file is
implicitly marked as desync. This provides graceful write aborting in
unlikely error cases. This has actually always been a feature in
littlefs, it was just named differently and didn't have an optional
recovery mode.

Since littlefs actually has to do more work to keep files in sync, the
desync feature is quite cheap:

            code          stack
  before:  33324           3072
  after:   33360 (+0.1%)   3072 (+0.0%)
2024-02-03 18:14:41 -06:00
Christopher Haster 8f2a6a3095 Implemented file sync broadcasting
Now, when files are synced, they broadcast their disk changes to any other
opened file handles. In effect, all open files match disk after a sync
call to any opened file handle pointing to that file.

This was a much requested feature, as the previous behavior (multiple
opened file handles maintain independent snapshots) is pretty different
from other filesystems. It's also quite difficult to implement outside
of the filesystem, since you need to track all opened files, requiring
either unbounded RAM or a known upper limit.

---

A bit unrelated, but this commit also changes bshrub estimate
calculation to include all opened file handles. This adds some annoying
complexity, but is necessary to prevent sporadic ERANGE errors when
the same file is opened multiple times.

The current implementation just refetches on-disk metadata. This adds
some maybe unnecessary metadata lookups, but simplifies things by
avoiding the tracking of on-disk sprout/shrub size, which risks falling
out of date. Keep in mind we only recalculate the estimate every
~inline_size/2 bytes written.

Just like lfsr_mdir_estimate, this scales O(n^2) with the number of
opened files (this are basically the same function... hmmm... can they
be deduplicated?). This is unlikely to be a problem for littlefs's use
case, but just something to be aware of.

Code changes:

            code          stack
  before:  32920           3032
  after:   33192 (+0.8%)   3048 (+0.5%)
2024-02-03 18:14:28 -06:00
Christopher Haster 006d656da2 Fixed unaligned data checksumming in two ways (uncrc32c, flcksum)
Checksumming unaligned data during block compaction is surprisingly
tricky. We don't know if our data will be aligned until after
a potentially unbounded number lookups, we need to write data into our
pcache as we go to avoid unnecessary lookups, but if we end up unaligned
we need to revert our checksum to the checksum of the aligned data.

The way I see it there are 4 options:

1. Calculate the checksum after writing data into the block.

   This is the most expensive option, requiring a full second read of
   the data to calculate the checksum. It is simple though.

2. Do a pass over the btree to figure out alignment before writing.

   This at least only reads metadata twice, so is more efficient than
   the 1st option.

3. Keep track of the aligned checksum on each flush, falling back to the
   last flushed checksum if we need to correct alignment.

   This solution is flexible though requires some extra state to track
   multiple checksums.

4. Leverage the math behind CRCs to run the CRC backwards when we
   truncate for alignment.

   This works, though a bit inefficiently, but is strictly tied to
   CRC-related checksums.

   By inefficient I mean that we would likely be limited to a bit-level
   "uncrc32c". It's possible to create nibble/byte tables for uncrc32c,
   but this adds significant code cost for a relatively uncritical
   function.

   I was hopeful that we could leverage the existing tables in both
   functions, but unfortunately it doesn't work out like that. You could
   scan the crc32c table to find the constant to reverse, but this
   requires ~16*2 or ~256 operations vs "naive" ~8 operations per byte.

This commit implements both 3 and 4, defaulting to 4 unless
LFS_NO_UNCRC32C is defined.

The current lfs_uncrc32c implementation is a simple bit-level
implementation, but does allow for crc32c truncation without any extra
state.

              code          stack
  before:    32044           2880
  uncrc32c:  32108 (+0.2%)   2880 (+0.0%)
  flcksum:   32132 (+0.3%)   2880 (+0.0%)
2023-12-17 15:18:10 -06:00
Christopher Haster 02d2919130 Adopted lfsr_rbyd_lookupwide, dropped wide bit in lookups
This trades a runtime check for a different function call. Enforcing
some minor semantics in the function's type/asserts.

This also makes it so there are no special tag bits used during rbyds
lookup, only rbyd commits.

In theory this saves a bit of code, we don't have a runtime check, but
in practice the extra function apparently outweighs the cost of the
runtime check:

            code          stack
  before:  31956           2880
  after:   32024 (+0.2%)   2880 (+0.0%)
2023-12-14 12:30:21 -06:00
Christopher Haster 3e45fc739d Changed becksum lookup to not re-traverse the whole btree
We already get the leaf rbyd as a part of btree lookup, and since ids
can't be split across rbyd boundaries, we can be sure any bptr attrs
live in the same rbyd.

This can be extended to any future bptr attrs.

Aside from the small performance gain, this also means we can drop the
btree bid+tag lookups. All extra attr lookups to lookup the rbyd first.
This saves a bit of code but also avoids a set of issues with the btree
semantics where lookupnexting an extra attr can return ENOENT
prematurely when on an rbyd boundary.

As I'm typing this I realize this means we have no way to iterate over
all _tags_ in a btree, only over all _bids_. Fortunately I don't think
we will ever need the former.

            code          stack
  before:  32136           2880
  after:   31956 (-0.6%)   2880 (+0.0%)
2023-12-14 12:05:23 -06:00
Christopher Haster f29a4982c4 Added block-level erased-state checksums
Much like the erased-state checksums in our rbyds (ecksums), these
block-level erased-state checksums (becksums) allow us to detect failed
progs to erased parts of a block and are key to achieving efficient
incremental write performance with large blocks and frequent power
cycles/open-close cycles.

These are also key to achieving _reasonable_ write performance for
simple writes (linear, non-overwriting), since littlefs now relies
solely on becksums to efficiently append to blocks.

Though I suppose the previous block staging logic used with the CTZ
skip-list could be brought back to make becksums optional and avoid
btree lookups during simple writes (we do a _lot_ of btree
lookups)... I'll leave this open as a future optimization...

Unlike in-rbyd ecksums, becksums need to be stored out-of-band so our
data blocks only contain raw data. Since they are optional, an
additional tag in the file's btree makes sense.

Becksums are relatively simple, but they bring some challenges:

1. Adding becksums to file btrees is the first case we have for multiple
   struct tags per btree id.

   This isn't too complicated a problem, but requires some new internal
   btree APIs.

   Looking forward, which I probably shouldn't be doing this often,
   multiple struct tags will also be useful for parity and content ids
   as a part of data redundancy and data deduplication, though I think
   it's uncontroversial to consider this both heavier-weight features...

2. Becksums only work if unfilled blocks are aligned to the prog_size.

   This is the whole point of crystal_size -- to provide temporary
   storage for unaligned writes -- but actually aligning the block
   during writes turns out to be a bit tricky without a bunch of
   unecesssary btree lookups (we already do too many btree lookups!).

   The current implementation here discards the pcache to force
   alignment, taking advantage of the requirement that
   cache_size >= prog_size, but this is corrupting our block checksums.

Code cost:

           code          stack
  before: 31248           2792
  after:  32060 (+2.5%)   2864 (+2.5%)

Also lfsr_ftree_flush needs work. I'm usually open to gotos in C when
they improve internal logic, but even for me, the multiple goto jumps
from every left-neighbor lookup into the block writing loop is a bit
much...
2023-12-14 01:05:34 -06:00
Christopher Haster c4d75efa40 Added bptr checksums
Looking forward, bptr checksums provide an easy mechanism to validate
data residing in blocks. This extends the merkle-tree-like nature of the
filesystem all the way down to the data level, and is common in other
COW filesystems.

Two interesting things to note:

1. We don't actually check data-level checksums yet, but we do calculate
   data-level checksums unconditionally.

   Writing checksums is easy, but validating checksums is a bit more
   tricky. This is made a bit harder for littlefs, since we can't hold
   an entire block of data in RAM, so we have to choose between separate
   bus transactions for checksum + data reads, or extremely expensive
   overreads every read.

   Note this already exists at the metadata-level, the separate bus
   transactions for rbyd fetch + rbyd lookup means we _are_ susceptible
   to a very small window where bit errors can get through.

   But anyways, writing checksums is easy. And has basically no cost
   since we are already processing the data for our write. So we might
   as well write the data-level checksums at all times, even if we
   aren't validating at the data-level.

2. To make bptr checksums work cheaply we need an additional cksize
   field to indicate how much data is checksummed.

   This field seems redundant when we already have the bptr's data size,
   but if we didn't have this field, we would be forced to recalculate
   the checksum every time a block is sliced. This would be
   unreasonable.

   The immutable cksize field does mean we may be checksumming more data
   than we need to when validating, but we should be avoiding small
   block slices anyways for storage cost reasons.

This does add some stack cost because our bptr struct is larger now:

            code          stack
  before:  31200           2768
  after:   31272 (+0.2%)   2800 (+1.1%)
2023-12-12 12:07:55 -06:00
Christopher Haster 3a6afaf1c5 Renamed lfs_alloc_ack -> lfs_alloc_ckpoint
This name describes this operation ever so slightly better, I've already
been refering to this as "checkpointing the allocator" places.
2023-12-06 22:24:18 -06:00
Christopher Haster 6261bafed2 Added more file tests with multiple files, fixed bugs
Fortunately these operations are heavily tested in test_dirs. The only
difference with files is the possibility for shrubs to need to be
copied.

Bugs fixed:

- It's counterintuitive, but lfsr_rbyd_appendcompactattr _can_ error
  with LFS_ERR_RANGE when we are copying a shrub. This can happen if the
  underlying mdir needs compaction itself.

- It's possible to null-trunk bshrubs to appear in our filesystem
  traversal. Null-trunk bshrubs don't usually appear in any stable
  state, but they are created by lfsr_bshrub_alloc and lfsr_btree_commit
  to represent new, yet-uncommitted shrubs.

  This gets a bit tricky because we also use null-trunks to indicate if
  lfsr_btree_traversal has traversed the root. We can't rely on
  bid >= weight for this because zero-weight btrees are allowed.

  The solution here, though maybe temporary (famous last words), is to
  treat null-trunk btrees as not having a root. Which isn't really true,
  but null-trunk btree roots only exist between allocator checkpoints,
  so they are allowed to be unreachable.

  We really need more asserts that this is the case though... At least
  added an assert that we never commit/read null trunks on disk.
2023-12-06 22:23:53 -06:00
Christopher Haster 939dd2145a Added some corner-case tests, fixed related bugs/POSIX nuances
POSIX is notoriously full of subtle and confusing nuances. Not through
any fault of POSIX, but as a result of trying to describe a complex
system with simple and easy to use operations.

Corner cases fixed here:

- rename("dir", "file") => ENOTDIR

  This is the main surprise to me, and a mistake on my part. I thought
  EISDIR would be appropriate for any renames with mismatched types,
  since both involve a directory. It would be simpler code-wise, and
  avoid ambiguity around if "file" is not a dir, or some other file
  exists in the file's path. But I guess ENOTDIR makes more sense if you
  think of the destination as the target being operated on.

- remove("/") => EINVAL
- rename("/", "x") => EINVAL
- rename("x", "/") => ENOTEMPTY
- open("/") => EISDIR

  It's a bit difficult to lookup what error codes around root operations
  should be, since they mostly end up as EPERM on modern systems, but
  this doesn't really make sense for littlefs.

  The solution chosen here is to prefer directory-related errors (EISDIR,
  ENOTEMPTY) when possible, and fall back to EINVAL when the only issue
  is that the target is the root directory.

Also I tweaked lfsr_mtree_pathlookup a bit so mid=0 indicates the target
is the root and mid=-1 indicates the target can't be created (because of
a missing directory). I think using mid=0 for the latter is a leftover
from when mid=-1 was a bit of a mess...
2023-12-06 22:23:51 -06:00
Christopher Haster abbd2d6c3f Made it possible to actually rename shrubbed files
This needed a bit of extra handling to copy the shrub, since it exists
outside of the mdir's main tree.

Also added relevant tests.
2023-12-06 22:23:47 -06:00
Christopher Haster b1ce27f733 Reorganized test suites a bit
- Renamed test_dtree -> test_dirs
- Renamed test_dseek -> test_dread
- Split test_files -> test_files, test_fwrite
2023-12-06 22:23:45 -06:00
Christopher Haster a9772d785a Removed removal of root bookmark in test_mtree
It turns out permanent root bookmark creates some rather interesting
constraints on our mtree:

1. We can never delete all mids, since at least one mid needs to exist
   to represent the root's bookmark.

2. We can never revert to an inlined mdir after uninlining, since our
   root bookmark always exists to stop this. This is an unfortunate
   downside as it would be nice to be able to reinline mdirs, but not
   the end of the world.

This restricts what operations are possible, and transitively, what we
can test.

This commit drops the removal of root bookmarks in test_mtree, which was
a workaround to keep tests from early implementation running. This was
preventing some minor optimizations. This required dropping some tests,
but these tests tested operations that aren't really possible in
practice.

Dropping the removal of root bookmarks allowed for a minor optimization
in lfsr_mdir_drop, and may lead to more in the future (or maybe just
stricter asserts):

            code          stack
  before:  31280           2648
  after:   31208 (-0.2%)   2648 (+0.0%)
2023-12-06 22:23:36 -06:00
Christopher Haster eb6c361dfa Adopted lazy orphaned mdir drops
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.
2023-12-06 22:23:28 -06:00
Christopher Haster 51e39747c0 Reverting alternate redund block layout in lfsr_mdir_t
See the previous commit for the reason. The alternate redund block
layout is just inferior in terms of both code and RAM.
2023-12-06 22:23:16 -06:00
Christopher Haster 9d182c2055 Attempted alternate redund block layout in lfsr_mdir_t
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.
2023-12-06 22:23:13 -06:00
Christopher Haster becbc0c2ad Moved redundant blocks into the lfsr_rbyd_t struct
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...
2023-12-06 22:23:11 -06:00
Christopher Haster 019044e4c6 Adopted better struct field names, cast to lfsr_openedmdir_t
- 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.
2023-12-06 22:23:08 -06:00
Christopher Haster a89b3e42ba Some cleanup items
- Adopted *_IS* naming convention for sign-bit macros.
- Made all struct initializing macros function-like, including the
  *_NULL() macros.
- Renamed ggrm/dgrm -> grm_g/grm_d.
- Renamed lfsr_mroot_commit_ -> lfsr_mroot_commit.
- Renamed LFSR_FILE_BSPROUT -> LFSR_FILE_ISDIRECT.
- Renamed LFSR_BSPROUT_NULL -> LFSR_FILE_BNULL().
- Dropped *_unerase functions for explicitly setting eoff=-1.
2023-12-06 22:23:06 -06:00
Christopher Haster f4af2b407e More mid-related function cleanup
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%)
2023-12-06 22:23:02 -06:00
Christopher Haster 41b9caf25d Renamed mid related functions and tried to make them less cumbersome
- lfs->mleaf_bits -> lfs->mbits
- lfsr_mleafweight -> lfsr_mweight
- lfsr_midbmask -> lfsr_mid_bid
- lfsr_midrmask -> lfsr_mid_rid
- added lfsr_mid_cbid
- added lfsr_mid_crid
- added lfsr_mdir_* variants
2023-12-06 22:23:00 -06:00
Christopher Haster 928108da0a Removed the mtree param from lfsr_mtree_* functions
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%)
2023-12-06 22:22:57 -06:00
Christopher Haster 30a9a62620 Heavily reworked lfsr_mdir_commit, split into more mid-level functions
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%)
2023-12-06 22:22:52 -06:00
Christopher Haster a8f54fb1e0 Brought back the lfsr_mptr_t
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%)
2023-11-21 14:16:09 -06:00
Christopher Haster bc8d54f9e0 Cleaned up bshrub code a bit
Mostly moved things around, removed a vestigial but harmless eviction
check in lfsr_mdir_compact__, add lfsr_bshrub_alloc/fetch, etc.
2023-11-21 14:10:33 -06:00
Christopher Haster c94b5f4767 Redesigned the inlined topology of files, now using geoxylic btrees
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...
2023-11-20 23:23:58 -06:00
Christopher Haster 135bb17409 Tweaked named btrees to support strict key->value mapping
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.
2023-11-09 00:10:09 -06:00
Christopher Haster f9a38756ca Ripped out inlined (in-RAM) btree union
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%)
2023-11-08 14:41:37 -06:00
Christopher Haster 0d6ff3b663 Added lfsr_mtree_t, store direct mptrs decoded
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.
2023-11-01 01:27:54 -05:00
Christopher Haster 6dcdf1ed61 Renamed BNAME -> NAME, CCKSUM -> CKSUM
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.
2023-10-25 01:25:39 -05:00
Christopher Haster 35434f8b54 Removed remnants of slice code, and cleaned things up a bit 2023-10-24 22:26:08 -05:00
Christopher Haster b1bf650328 Extended test_files to test file btrees (up to 4*BLOCK_SIZE)
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.
2023-10-24 02:25:55 -05:00
Christopher Haster d1e79bffc7 Renamed crystallize_size -> crystal_size
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.
2023-10-23 12:27:44 -05:00
Christopher Haster c815c19c20 New "fragmenting" write strategy
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.
2023-10-21 22:05:46 -05:00
Christopher Haster 2940555caa Attempted to implement slice dereferencing
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...
2023-10-19 01:05:22 -05:00
Christopher Haster 865477d7e1 Changing coalesce strategy, reimplemented shrub/btree carve
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.
2023-10-17 23:21:18 -05:00
Christopher Haster fce1612dc0 Reverted to separate BTREE/BRANCH encodings, reordered on-disk structs
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.
2023-10-15 14:53:07 -05:00