Commit Graph

11 Commits

Author SHA1 Message Date
Christopher Haster a85f08cfe3 Dropped lazy grafting, but kept lazy crystallization
This merges LFS3_o_GRAFT into LFS3_o_UNCRYST, simplifying the file write
path and avoiding the mess that is ungrafted leaves.

---

This goes for a different lazy crystallization/grafting strategy that
was overlooked before. Instead of requiring all leaves to be both
crystallized and grafted, we allow leaves to be uncrystallied, but they
_must_ be grafted (in-tree) at all times.

This gets us most of the rewrite preformance of lazy-crystallization,
without needing to worry about out-of-date file leaves.

Out-of-date file leaves were a headache for both code cost and concerns
around confusing filesystem states and related bugs.

Note LFS3_o_UNCRYST gets some extra behavior here:

- LFS3_o_UNCRYST indicates when crystallization is _necessary_, and no
  longer when crystallization is _possible_.

  We already keep track of when crystallization is _possible_ via bptr's
  erased-state, and this lets us control recrystallization in
  lfs3_file_flush_ without erased-state-clearing hacks (which probably
  wouldn't work with the future ddtree).

- We opportunistically clear the UNCRYST flag if it's not possible for
  future lfs3_file_crystallize_ calls to make progress:
  - When we crystallize a full block
  - When we hit the end of the file
  - When we hit a hole
  - When we hit an unaligned block

---

Note this does impact performance!

Unlike true lazy grafting, eagerly grafting means we're always
committing to the bshrub/btree more than is strictly necessary, and this
translates to more frequent btree node erases/compactions.

Current simulated benchmarks show a ~3x increase (~20us -> ~60us) in
write times for linear file writes on NOR flash.

However:

- The moment you need unaligned progs, this performance optimization
  goes out the window, as we need to graft bptrs before any padding
  fragments.

- This only kicks in once we start crystallizing. So any writes <
  crystal_thresh (both in new files and in between blocks) are forced
  to commit to the bshrub/btree every flush.

  This risks a difficult to predict performance characteristic.

- If you sync frequently (logging), we're forced to crystallize/graft
  anyways.

- The performance hit can be alleviated with either larger writes or
  larger caches, though I realize this goes against littlefs's
  "RAM-not-required" mantra.

Worst case, we can always bring back "lazy grafting" as a
high-performance option in the future.

Though note the above concerns around in-between/pre crystallization
performance. This may only make sense when cache_size >= both prog_size
and crystal_thresh.

And of course, there's a significant code tradeoff!

           code          stack          ctx
  before: 38020           2456          656
  after:  37588 (-1.1%)   2472 (+0.7%)  656 (+0.0%)

Uh, ignore that stack cost. The simplified logic leads to more functions
being inlined, which makes a mess of our stack measurements because we
don't take shrinkwrapping into account.
2025-07-03 18:04:18 -05:00
Christopher Haster 1bf2a4b520 Fixed grafting allocator checkpoint hole
This was quite a deep bug.

We don't track the original bshrub when grafting, so it was possible to
realloc those blocks even when we need their contents to finish the
graft operation.

This was found while experimenting with eager leaf grafting, but can
also occur when grafting data fragments.

---

In theory, the block allocator's checkpoint mechanism protects against
this.

Before we alloc, we set a checkpoint with lfs3_alloc_ckpoint. This marks
the position of the block allocator before allocation, so if we loop
around the entire block device we don't double alloc any in-flight
blocks:

                     ckpoint      lookahead
                        v         .---'---.
  [mm---ddd-d---d-------|dd--d-ddd|--------d-----d-]
                         '---.---'
                    in-flight allocations

But this only protects _new_ blocks, _old_ blocks can be anywhere on
disk and are unprotected.

In theory again, old blocks are always tracked via copy-on-write
snapshots, but this is not the case for bshrubs while grafting!

Grafting is unfortunately a multi-commit operation (we may remove
multiple fragments that span different btree nodes), and each bshrub
commit discards the old snapshot. This creates a window where old blocks
can be double alloced _while grafting_, leading to corrupted data.

You may wonder why are we discarding the old snapshot? Why not keep
track of it until the grafting completes?

The problem there is that we need the intermediate snapshot in order for
shrubs to survive compactions. We really have 3 states:

  old -> mid-graft -> new

And the only one we don't need to fallback to is the old state.

---

A couple solutions:

1. Track all three states

   This would add complexity increase the cost of every lfs3_file_t.

2. Open a temporary file to track the old state

   This would add complexity and a big chunk of stack to what is already
   one of the critical functions on our stack hot-path.

3. Carefully make sure graft commits don't lose track of in-flight data
   until an atomic commit

   This doesn't work when you're trying to coalesce two data fragments
   in two different btree nodes. At least not without completely
   restructuring the btree commit logic.

4. Just explicitly track in-flight graft state out-of-band

This goes with option no 4., adding lfs3->graft and lfs3->graft_count to
track in-flight graft state when we're grafting. lfs3_mtree_traverse_
can include the relevant blocks during traversals, effectively masking
out graft state from the lookahead buffer.

This adds a bit of code/ctx, but is probably the cheapest option:

           code          stack          ctx
  before: 37936           2456          636
  after:  38092 (+0.4%)   2456 (+0.0%)  656 (+3.1%)
2025-07-01 14:02:45 -05:00
Christopher Haster ccfc74a547 Added LFS3_2BONLY for a small 2-block configuration
Like LFS3_RDONLY and LFS3_KVONLY, LFS3_2BONLY opts-out of all of the
logic necessary for filesystems larger than 2-blocks (the mimimum size
of a mutable littlefs image).

This has potential for some pretty big savings:

- No block allocation
- No lookahead buffer
- No btrees (but yes bshrubs)
- No bptrs
- No mtree traversal

Which is I guess ~1/4 of the codebase:

            code           stack           ctx
  default: 37836            2416           636
  2bonly:  27704 (-26.8%)   1872 (-22.5%)  592 (-6.9%)

This can be combined with LFS3_KVONLY for a small key-value store
compatible with the full littlefs driver:

                  code           stack           ctx
  default:       37836            2416           636
  kvonly:        30792 (-18.6%)   2168 (-10.3%)  636 (+0.0%)
  kvonly+2bonly: 22900 (-39.5%)   1736 (-28.1%)  592 (-6.9%)

It may be possible to optimize this further, but, as is the case with
LFS3_KVONLY, balancing config-specific optimization vs maintainability
is tricky.

---

I'm not sure why, but this also reduced the default build's size a bit.
Compiler noise?

           code          stack          ctx
  before: 37860           2416          636
  after:  37836 (-0.1%)   2416 (+0.0%)  636 (+0.0%)
2025-06-26 07:22:47 -05:00
Christopher Haster 2c27c61f25 kv: Added LFS3_KVONLY to opt-out of advanced file operations
One of the ideas behind the key-value API is that it is potentially much
cheaper than a full file API. With the key-value API, we get the
guarantee that all data must fit in RAM, and avoid headaches like
random reads/writes and needing to broadcast file state.

For an example of just how much complexity is avoided, the see the
difference between lfs3_file_flushonce_ vs the mess that is
lfs3_file_flush_ + lfs3_file_crystallize + lfs3_file_graft.

However, littlefs is designed around files, and a couple design
decisions hold back how much code saving is possible:

1. littlefs's shrubs are designed around being enrolled in the omdir
   linked-list, so internally we still have most of the file open/close
   code lumbering around.

2. Directories and traversals still exist, so we'd need the omdir
   linked-list anyways, and we still need to broadcast _some_ changes.

3. Despite being intended for small amounts of data, lfs3_set/get can
   still be used to create arbitrarily large files. So we still need all
   of the bshrub/btree logic.

   Which we still need for the mtree anyways, so this isn't really that
   much of a downside.

It also may be possible to save more code by aggressively rewriting the
_entire_ read/write path for lfs3_set/get, to not reuse any of the
existing file logic in LFS3_KVONLY mode. But I decided against this due
to concerns around maintainability.

The duplicate lfs3_file_read + lfs3_file_readonce and lfs3_file_flush_ +
lfs3_file_flushonce_ are already enough of a concern.

Anyways, here's LFS3_KVONLY:

                  code           stack           ctx
  default:       37824            2416           636
  kvonly:        30936 (-18.2%)   2168 (-10.3%)  636 (+0.0%)

LFS3_RDONLY + LFS3_KVONLY is also interesting:

                  code           stack           ctx
  rdonly:        10776             856           508
  rdonly+kvonly:  9904 (-8.1%)     888 (+3.7%)   508 (+0.0%)

---

This also added some noise to the default build's code, mainly due to
tweaks in lfs3_file_readnext to allow better reuse in LFS3_KVONLY:

           code          stack          ctx
  before: 37824           2416          636
  after:  37860 (+0.1%)   2416 (+0.0%)  636 (+0.0%)
2025-06-24 16:14:02 -05:00
Christopher Haster f967cad907 kv: Adopted LFS3_o_WRSET for better key-value API integration
This adds LFS3_o_WRSET as an internal-only 3rd file open mode (I knew
that missing open mode would come in handy) that has some _very_
interesting behavior:

- Do _not_ clear the configured file cache. The file cache is prefilled
  with the file's data.

- If the file does _not_ exist and is small, create it immediately in
  lfs3_file_open using the provided file cache.

- If the file _does_ exist or is not small, do nothing and open the file
  normally. lfs3_file_close/sync can do the rest of the work in one
  commit.

This makes it possible to implement one-commit lfs3_set on top of the
file APIs with minimal code impact:

- All of the metadata commit logic can be handled by lfs3_file_sync_, we
  just call lfs3_file_sync_ with the found did+name in lfs3_file_opencfg
  when WRSET.

- The invariant that lfs3_file_opencfg always reserves an mid remains
  intact, since we go ahead and write the full file if necessary,
  minimizing the impact on lfs3_file_opencfg's internals.

This claws back most of the code cost of the one-commit key-value API:

              code          stack          ctx
  before:    38232           2400          636
  after:     37856 (-1.0%)   2416 (+0.7%)  636 (+0.0%)

  before kv: 37352           2280          636
  after kv:  37856 (+1.3%)   2416 (+6.0%)  636 (+0.0%)

---

I'm quite happy how this turned out. I was worried there for a bit the
key-value API was going to end up an ugly wart for the internals, but
with LFS3_o_WRSET this integrates quite nicely.

It also raises a really interesting question, should LFS3_o_WRSET be
exposed to users?

For now I'm going to play it safe and say no. While potentially useful,
it's still a pretty unintuitive API.

Another thing worth mentioning is that this does have a negative impact
on compile-time gc. Duplication adds code cost when viewing the system
as a whole, but tighter integration can backfire if the user never calls
half the APIs.

Oh well, compile-time opt-out is always an option in the future, and
users seem to care more about pre-linked measurements, probably because
it's an easier thing to find. Still, it's funny how measuring code can
have a negative impact on code. Something something Goodhart's law.
2025-06-22 15:37:07 -05:00
Christopher Haster a75537faff kv: Implemented a simple key-value API
This adds a couple functions that treat files as simple key-value pairs:

- lfs3_get    - Read a file
- lfs3_size   - Get the size of a file
- lfs3_set    - Write a file
- lfs3_remove - Remove a file (this one already exists!)

The idea is the only real difference between a filesystem and key-value
store in the microcontroller space is the API, and the key-value API
_is_ much easier to use.

It also opens the door to making the file API opt-out in the future to
trade code cost for feature set. littlefs will probably never be
competitive with other microcontroller-scale key-value stores, but it
may be interesting for systems already using littlefs for other storage.

And don't worry, these are still files, so they can always be opened
with the full file API when more advanced operations are needed.

These APIs also matches the custom attribute APIs, which makes sense
because they're both key-values. Any mismatch should be considered an
API bug, because the best user interface is a consistent one.

This new API is tested in tests/test_kv.toml.

---

At the moment the implementation is naive, just sitting on top of the
file API. This works remarkably well thanks to littlefs's cache
bypassing logic, but does have some downsides:

- lfs3_set always writes two commits: one for the stickynote and one for
  the file sync.

  Unfortunately this is a fundamental limitation of littlefs's file API.
  One nice benefit of lfs3_set is in theory we can bypass this
  limitation, but not if we just sit on top of the file API.

- There may be code savings from more tightly integrating the key-value
  code.

This also highlighted an awkward corner case with per-file cache
configuration in which the buffer needs to be non-null even if zero. Not
the end of the world, but just a bit awkward. Maybe this deserves
revisiting in the config API rework?

---

Code changes were relatively minimal given that this is a whole new API,
unfortunately the stack took quite a hit:

           code          stack          ctx
  before: 37352           2280          636
  after:  37644 (+0.8%)   2448 (+7.4%)  636 (+0.0%)

The stack surprised me, but in hindsight it makes sense. In sitting on
top of the reset of the codebase, the key-value API adds very little
code, but every stack allocation in these functions add to the stack
hot-path.

This isn't the end of the world, and it's actually probably a good thing
to have an lfs3_file_t allocated in the stack hot-path. lfs3_file_t's
size has been a bit difficult to track thanks to struct lfs3_info
dominating ctx measurements...
2025-06-22 15:22:21 -05:00
Christopher Haster 40a8c02604 Moved ifdefs after comments
So:

  // blablabla this is my cool function
  #ifdef LFS3_COOL
  int lfs3_cool(lfs3_t *lfs3);
  #endif

Mainly because this reads better and moves the compilation conditions
closer to the actual declaration.

One concern is if this will interfere with future doxygen/documentation
generation, but I think we can expect future scripts to be able to parse
relevant ifdefs. For one, we want to make sure to include any required
ifdefs in generated documentation, so if a script can't even parse
ifdefs, uhhhhh...

No code changes.
2025-06-06 01:43:58 -05:00
Christopher Haster 0096305968 rdonly: Dropped rbyd.eoff when LFS3_RDONLY
rbyd.eoff has the relatively unique property of only being useful in
rdwr mode. In rdonly mode we don't care where the next erased-state
starts because we're never going to use it.

Since rbyds are used everywhere, dropping rbyd.eoff has the potential to
save a significant amount of RAM.

---

At least on paper. We were using the field in lfs3_rbyd_fetch to keep
track of the most recent valid commit perturb/eoff, which was a bit
tricky to disentangle.

Disentangling lfs3_rbyd_fetch does add a bit of code to the default
build, but saves code, stack, and ctx in the rdonly mode:

                   code          stack          ctx
  rdonly before:  10640            816          524
  rdonly after:   10616 (-0.2%)    808 (-1.0%)  508 (-3.1%)

  default before: 37320           2280          636
  default after:  37352 (+0.1%)   2280 (+0.0%)  636 (+0.0%)

In theory we could ifdef the crap out of lfs3_rbyd_fetch to claw back
this code, but 1. 32 bytes of code is really not that much code, 2. the
more rdonly and default diverge the more likely rdonly breaks, and 3. I
think the new code is a bit more readable since it avoids masking
perturb/eoff together until the last minute.
2025-06-06 01:17:12 -05:00
Christopher Haster 7cc87a4fe6 rdonly: Dropped file.b.shrub_ when LFS3_RDONLY
We don't need the staging shrub if we never stage shrubs!

The only hangup was reuse of the staging shrub to load bshrubs/btrees in
lfs3_file_fetch (we need to be able to fallback to the previous shrub if
we error in lfs3_file_resync), but this can be handled with a stack
allocated shrub.

If btree-leaf-caches make a return, we would need to stack allocate this
anyways due to the lopsided cost of the main/staging btrees/bshrubs
introduced to avoid wasting space on the useless
staging-shrub-leaf-cache.

This saves some code in LFS3_RDONLY, and apparently an instruction or
two in the default build (I guess stack loads/stores are cheaper?):

                   code          stack          ctx
  rdonly before:  10680            840          524
  rdonly after:   10640 (-0.4%)    816 (+0.0%)  524 (+0.0%)

  default before: 37324           2280          636
  default after:  37320 (-0.0%)   2280 (+0.0%)  636 (+0.0%)

It's not apparent in ctx because lfs3_info.name dominates (guh), but
this does save some RAM in lfs3_file_t:

  rdonly              ctx
  lfs3_file_t before: 136
  lfs3_file_t after:  112 (-17.6%)

It does add some stack cost to lfs3_file_fetch, but because this isn't
on the stack hot-path in either build, we don't really care:

  default                 code          stack          ctx
  lfs3_file_fetch before:  372            416            0
  lfs3_file_fetch after:   368 (-1.1%)    440 (+5.8%)    0 (+0.0%)
2025-06-05 18:12:51 -05:00
Christopher Haster 42bd130105 rdonly: Initial draft of LFS3_RDONLY
This is the new readonly flag, to be consistent with LFS3_M_RDONLY and
friends.

Note this overlaps with LFS3_YES_RDONLY in a weird way, where
LFS3_YES_RDONLY is basically just an alias for LFS3_RDONLY. For most
flags, LFS3_THING enables the _option_ of using LFS3_M_THING, with
LFS3_YES_THING implying LFS3_M_THING in all mount calls. But
LFS3_RDONLY _disables_ the option of using LFS3_M_RDWR, so it's a bit
different...

Do we really need two flags for the same thing? Not sure. But most users
probably expect LFS3_RDONLY coming from other filesystems.

Worst case this can be revisited in the planned config API rework.

---

As for the readonly code size, this is just the first draft and limited
to mostly ifdefing out all prog/write logic paths. There's some TODOs in
the code that may save a bit more (rbyd.eoff, file.b.shrub_ for
example). But the results are looking ok:

                    code           stack           ctx
  v2.11.0  rdonly:  6270             448           580
  v3-alpha rdonly: 10776 (+71.9%)    840 (+87.5%)  524 (-9.7%)

It's interesting to note most of the additional code/stack cost come
from filesystem traversal. In v2, the threaded linked-list made rdonly
traversal _incredibly_ cheap. But the extra rdwr baggage of turning
littlefs into a fully connected graph made it something to be avoided
in v3.

This hits v3 with the double whammy of:

1. Filesystem traversal is more complicated since we need to keep track
   of which btree and where in the btree we are

2. Everything needs to be tracked explicitly due to the new inverted
   state-machine driven API (no callbacks)

Note that even if we disabled the traversal APIs, lfs3_fs_usage, cksum
checking, etc, we'd still need to traverse to rebuild gstate. Otherwise
we risk showing grmed files after a powerloss.

---

This did affect the default build a little bit, due to moving things
around for nicer ifdef groupings:

                    code          stack          ctx
  default before:  37300           2280          636
  default after:   37304 (+0.0%)   2280 (+0.0%)  636 (+0.0%)
2025-06-05 16:20:41 -05:00
Christopher Haster 6eba1180c8 Big rename! Renamed lfs -> lfs3 and lfsr -> lfs3 2025-05-28 15:00:04 -05:00