Skip to content

Faster CIRCUITPY saves on Zephyr boards - #11615

Merged
dhalbert merged 6 commits into
adafruit:mainfrom
mikeysklar:zephyr-msc-write-speed
Oct 16, 2026
Merged

dhalbert merged 6 commits into
adafruit:mainfrom
mikeysklar:zephyr-msc-write-speed

Conversation

@mikeysklar

Copy link
Copy Markdown
Collaborator

What

Saving files to CIRCUITPY over USB is faster on Zephyr builds.

Why

Zephyr builds rewrote flash after every USB write, and the computer waited each time.

Changes

File Change
supervisor/shared/flash.c Set the dirty flag after writing and clear it before flushing. A USB write during a flush is no longer lost, and SPI-flash ports keep the 1 s flush armed after a page change
ports/zephyr-cp/supervisor/flash.c A page change flushes only the old page, without clearing the dirty flag
ports/zephyr-cp/supervisor/usb.c The sync after every WRITE10 no longer flushes
ports/zephyr-cp/prj.conf 4 KB USB MSC buffer, was 512 bytes. Non-Zephyr nordic, analog and most espressif builds already use 4096
ports/zephyr-cp/tests/test_flash.py New tests on FAT and littlefs: a page cached by a page change reaches flash without a close

Results

Linux host, seconds, median of 10.

Metro RP2040 non-Zephyr Zephyr main this PR
new 1 KB file 0.257 0.315 0.263
overwrite 1 KB 0.374 0.484 0.386
delete file 0.145 0.214 0.146
new 64 KB file 1.222 1.765 1.282
Pico W non-Zephyr Zephyr main this PR *
new 1 KB file 0.202 0.265 0.208
overwrite 1 KB 0.313 0.369 0.321
delete file 0.110 0.172 0.115
new 64 KB file 0.925 1.475 1.029
Feather nRF52840 non-Zephyr Zephyr main #11613 #11613 + this PR *
new 1 KB file 0.236 1.035 0.260 0.212
overwrite 1 KB 0.328 1.670 0.364 0.316
delete file 0.137 1.005 0.158 0.107
new 64 KB file 1.057 1.443 1.180 0.981

* Without the shared flash.c commit, which was added after these runs.

Cost

RAM +7 KB on Zephyr boards with USB (two 4 KB buffers instead of two 512 byte ones)
Pico W Largest free block after boot drops from 17,920 to 11,008 bytes

Tested

Boards Metro RP2040 (feather_rp2040_zephyr image), Pico W (rpi_pico_w_zephyr image), Feather nRF52840 Express
Host Ubuntu, Linux vfat
Builds feather_rp2040, rpi_pico2, feather_nrf52840, nrf5340dk, nrf54l15dk, nucleo_u575zi_q, frdm_rw612
native_sim New tests fail on main; littlefs test also fails without the page-change commit. Full suite 390 pass, 125 skip (bsim not run)
Power cut (Pico W) Right after a save: lost 3/3, same as non-Zephyr. 3 s after: kept 5/5
Eject Zephyr doesn't pass eject to the disk, so eject doesn't flush
#11613 flash.c merges cleanly; test_flash.py needs a trivial merge
Not tested macOS and Windows hosts; non-Zephyr SPI-flash boards

AI assistance

Code and measurements by Claude at my direction, on my test boards.

I reviewed the code and checked the results.

🤖 Generated with Claude Code

mikeysklar and others added 5 commits October 15, 2026 16:08
On zephyr-cp, USB MSC writes run in their own thread while the idle
flush runs in the VM thread. supervisor_flash_flush() cleared
filesystem_dirty and stopped the tick after flushing, and
flash_write_blocks() marked dirty before writing. A block cached in
between stayed in RAM with no tick to write it out. Today the flush on
every MSC write hides this.

Clear the flag before flushing and mark dirty after writing.

This also changes ports whose write path calls supervisor_flash_flush()
on a page change: external_flash.c, nordic, stm, silabs, mimxrt and
cxd56. That flush cleared the flag and stopped the tick, so the newly
cached page waited for the next write, sync or reload. Now the 1 s
flush stays armed for it. raspberrypi and espressif flush the old page
with port_internal_flash_flush() and are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a write moved to a different flash page, _flash_write_block()
flushed the old page with supervisor_flash_flush(). That also clears
filesystem_dirty and stops the supervisor tick. littlefs marks the
filesystem dirty before writing, so its newly cached page was never
written by the background flush. Called from the USB MSC thread, it
could also race the VM thread's background flush, with both seeing the
flag set and both disabling the tick.

Flush only the old page, as ports/raspberrypi does with
port_internal_flash_flush().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Zephyr's USB MSC class issues DISK_IOCTL_CTRL_SYNC at the end of every
WRITE10, and _zephyr_disk_ioctl() turned that into a full flash flush.
The host waited for a page erase and reprogram after each command, and a
page written by consecutive commands, such as the FAT, was erased once
per command.

Leave the cached page for the next page change, the background flush
and autoreload, as the TinyUSB ports do. Unlike them, eject doesn't
flush: Zephyr's MSC class doesn't pass START STOP UNIT to the disk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Overwrite a sector, then one three pages later, and never flush or close
the file. Only the background flush can write the last sector before
the simulator is stopped. Fails on main, where the page change stopped
the tick.

Do the same on littlefs, which marks the filesystem dirty before
writing. Fill and free the drive first so new blocks aren't erased,
since writes to erased flash skip the page cache. Fails without the
previous commit to ports/zephyr-cp/supervisor/flash.c.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the default 512 byte SCSI buffer every sector of a write is its own
round trip through the MSC thread and the disk layer, about 4 ms per
sector on RP2040. A 4 KB buffer moves eight sectors at a time. It costs
7 KB of RAM with double buffering.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just questions, not a review, but changes make sense.

  1. I'm not clear how the supervisor/shared/flash.c change changes the behavior of the non-zephyr ports.
  2. Is there a non-zephyr test? I guess it would have to be manual.
  3. Does #11613 need some rework after this?

@mikeysklar

mikeysklar commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks!

  1. It only affects ports that flush the cache on a page change.

    That is external_flash.c, nordic, stm, mimxrt, silabs and cxd56.

    On main, the dirty flag was cleared while the new page was still in RAM.

    Now the 1 s idle flush writes it. Other ports are unchanged.

  2. Yes, manual. Copy a 16 KB file, sync, wait 3 s, cut USB power. 10 trials each.

    Board main kept PR kept
    Feather nRF52840 0/10 10/10
    Metro M4 AirLift 0/10 10/10
    Feather STM32F405 10/10 10/10
    Metro RP2350 (control) 10/10 10/10

    The STM32F405 only loses data on a second write after boot. With that, main kept 4/5 and the PR 5/5.

  3. No rework needed. Only test_flash.py conflicts, since both PRs add tests at the end.

    The merged tree passes native_sim.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this seems like an improvement in several ways.

@dhalbert
dhalbert enabled auto-merge October 16, 2026 21:27
@dhalbert
dhalbert merged commit 5bef11f into adafruit:main Oct 16, 2026
47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants