ValveSoftware / ValveSoftware/SteamOS

SD card formatter ignores card Allocation Unit size, causing permanent misalignment and 10-15x read degradation

Open
#2,607 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
No language data
Stars
2.6k
Forks
83
Avg merge
4m
Merged PRs (30d)
3

Description

Note on authorship: This report was drafted with AI assistance for structuring, formatting and data aggregation. All measurements, commands and system outputs are my own, taken from the affected device.

This might be the root cause behind the long-running "slow SD card" reports, including #898 [1] and the Steam Community thread a Valve developer investigated back in 2022 [2]. Those threads correctly identified that TRIM helps and that cards partitioned on a PC stay fast — but never established why. The reason is that SteamOS partitions SD cards misaligned to the card's erase granularity. TRIM masks the symptom; it cannot fix it.

TL;DR

/usr/lib/hwsupport/format-device.sh partitions SD cards with parted ... mkpart primary 0% 100%, which resolves to a 1 MiB partition start. It never queries the card's Allocation Unit size (preferred_erase_size). On cards with a 4 MiB AU — i.e. most high-capacity microSDXC — this leaves the filesystem permanently misaligned to the card's erase granularity (DISC-ALN = 3145728).

mkfs.ext4 is additionally called without -E stride=/stripe-width=, so the ext4 allocator has no knowledge of the AU either.

Every write straddles two AUs, forcing the card controller into read-modify-write cycles and scattering data physically. The effect compounds over time and is not repairable by TRIM.

Measured on a SanDisk Ultra 1.5 TB (4 MiB AU), same card, same slot:

SteamOS-formatted Manually AU-aligned Factor
Sequential read 4–6 MB/s 80.3 MB/s ~16×
4K random read (p50) 6.9 ms / 145 IOPS 0.40 ms / 2480 IOPS 17×
DISC-ALN 3145728 0

The card is genuine, the NAND is healthy, TRIM runs correctly, and the card reader is fine. The only defect is the partition alignment produced by SteamOS.


Impact

  • Users perceive this as "slow SD card" or "slow downloads" and blame the card or the hardware. There is a whole genre of workaround guides for it [3].
  • Degradation is progressive: a freshly formatted card performs acceptably and decays as games are installed, patched and removed.
  • A reformat via the Steam UI does not fix it — the same misalignment is reproduced, and the default nodiscard leaves the card's FTL mapping intact (see secondary finding).
  • f3probe (already run by the formatter) validates capacity, not performance. A genuine but misaligned card passes cleanly.
  • Larger cards are hit harder: bigger AU, more churn, less over-provisioning headroom.

Root cause

/usr/lib/hwsupport/format-device.sh, package jupiter-hw-support-3.8.20260630.1:

parted --script "$STORAGE_DEVICE" mklabel gpt mkpart primary 0% 100%
udevadm settle
mkfs.ext4 -m 0 -O casefold -E "$EXTENDED_OPTIONS" "${EXTRA_MKFS_ARGS[@]}" -F "$STORAGE_PARTITION"
  1. parted 0% uses parted's default alignment policy. Parted derives "optimal" alignment from optimal_io_size / minimum_io_size in the block queue. SD/MMC devices report optimal_io_size = 0, so parted falls back to its built-in default of 1 MiB. The card's AU is never consulted.

  2. mkfs.ext4 receives no stride / stripe-width, so ext4 does not align allocation groups or extents to the AU either.

  3. preferred_erase_size is never read, despite the script family already reading manfid, oemid and safe_trim_quirk from the same sysfs directory (trim-devices.sh).

The kernel confirms the resulting misalignment arithmetically:

DISC-ALN = (AU - (partition_start mod AU)) mod AU
         = (4 MiB - (1 MiB mod 4 MiB)) mod 4 MiB
         = 3 MiB
         = 3145728        <- exactly what lsblk -D reports
Secondary finding

EXTENDED_OPTIONS defaults to nodiscard; only --full passes discard. A quick format therefore rewrites filesystem metadata but leaves the card's entire FTL mapping in place. Users who reformat to "fix" a slow card gain nothing from the reformat itself.


Relationship to #898 and prior reports

Issue #898 [1] (open since Nov 2022, still open) reports that fstrim.timer is disabled by default. That issue is real but downstream of this one:

  • TRIM only tells the controller which blocks are free. It cannot relocate data that is already physically scattered.
  • On my device TRIM does run (safe_trim_quirk = 1, steamos-trim-devices invoked periodically by the Steam client) — and the card was still stuck at 4–6 MB/s.
  • Enabling fstrim.timer would therefore not have fixed this. Fixing the alignment did, immediately and completely.

In other words: #898 is worth fixing on its own merits, but it is not the cause of the SD performance complaints. This is.

Steam Community thread [2]: A Valve developer (lostgoat) asked affected users to run fstrim -v and report results. Users reported enormous trim volumes on nearly-empty cards:

  • 937.5 GiB (1006640709632 bytes) trimmed — on an entirely empty card, immediately after partitioning it on the Deck
  • 826.5 GiB (887460503552 bytes) trimmed
  • 345.2 GiB (370694426624 bytes) trimmed — on a card less than a week old

The same thread contains the key observation, which was never followed up:

"So, fresh from partitioning on my PC, these cards do not need trimming and are really fast."

"It is strange that KDE on the Deck seems to cause these cards to need trimming right out of the gate. My results were from a card fresh out of the package, and partitioned on the Deck immediately afterwards, when I do not seem to have similar results from partitioning using a different method."

And the resulting throughput delta:

PC-partitioned: "mid 50s to upper 60s sustained"
Deck-partitioned and trimmed: "brief bursts into the lower 60s, but the speeds maintained in the upper 30s to mid 40s"

Also in that thread, from a user with a card under a week old:

"Should we not be enabling the fstrim.timer systemd service?"

That question was never answered.

A third-party guide [3] (May 2025) documents the community workaround — manually repartition the card in KDE Partition Manager before letting Steam format it — while explicitly stating the author cannot explain why it works.

The alignment difference between PC-partitioned and Deck-partitioned cards is the explanation.


Proposed fix

In format-device.sh:

  1. Read the AU size:

    AU=$(cat /sys/block/$(basename $STORAGE_DEVICE)/device/preferred_erase_size)
    

    (Fall back to 4 MiB if unavailable.)

  2. Align the partition start to the AU (or a safe multiple):

    parted --script "$STORAGE_DEVICE" mklabel gpt
    parted --script "$STORAGE_DEVICE" mkpart primary ext4 ${AU_MiB}MiB 100%
    
  3. Pass AU-derived allocator hints to mkfs:

    STRIDE=$((AU / 4096))
    mkfs.ext4 -m 0 -O casefold -E stride=$STRIDE,stripe-width=$STRIDE,...
    
  4. Consider making discard the default (or unconditionally blkdiscard the device before mkfs), so a reformat actually resets the FTL.

Validation is a one-liner: after formatting, lsblk -D must report DISC-ALN 0.

Suggested patch — UNTESTED

This diff is an untested proposal. I verified the approach by running the equivalent commands by hand on my device (see "After — manual rebuild" below), which produced the results in this report. I have not executed this patched script itself, and I have not tested it against USB mass-storage devices or against cards that report unusual AU sizes. It is offered as a starting point for someone with a proper test matrix, not as a merge-ready change.

--- a/usr/lib/hwsupport/format-device.sh
+++ b/usr/lib/hwsupport/format-device.sh
@@
 EXTENDED_OPTIONS="$EXTENDED_OPTIONS,root_owner=$OWNER"
 
+# Determine the device's erase granularity (SD Allocation Unit).
+#
+# Partitioning and formatting must be aligned to this value, otherwise every
+# write straddles two AUs and forces the card controller into read-modify-write
+# cycles. parted's default alignment policy derives from optimal_io_size, which
+# SD/MMC devices report as 0, so parted silently falls back to 1 MiB.
+function get_erase_granularity()
+{
+    local _dev
+    _dev="$(basename "$STORAGE_DEVICE")"
+    local _au=0
+
+    # SD/MMC cards report their AU size directly
+    if [[ -r "/sys/block/$_dev/device/preferred_erase_size" ]]; then
+        _au="$(cat "/sys/block/$_dev/device/preferred_erase_size")"
+    fi
+
+    # Fallback for devices without that attribute (e.g. USB mass storage)
+    if [[ "$_au" -le 0 && -r "/sys/block/$_dev/queue/discard_granularity" ]]; then
+        _au="$(cat "/sys/block/$_dev/queue/discard_granularity")"
+    fi
+
+    # Sanity clamp. 1 MiB is the historical default and is a safe floor; any
+    # smaller AU divides it evenly. Anything absurd falls back to 4 MiB, which
+    # is the typical value for high-capacity microSDXC.
+    if [[ "$_au" -lt 1048576 || "$_au" -gt 67108864 ]]; then
+        _au=4194304
+    fi
+
+    echo "$_au"
+}
+
 # We only support SD/MMC and USB mass-storage devices
 case "$STORAGE_DEVICE" in
@@
 # Clear out the garbage bits generated by f3probe from the partition table sectors
 # Otherwise parted may think we have existing partitions in a bogus state
 dd if=/dev/zero of="$STORAGE_DEVICE" bs=512 count=1024
 
+AU_BYTES="$(get_erase_granularity)"
+AU_MIB=$((AU_BYTES / 1048576))
+STRIDE=$((AU_BYTES / 4096))    # ext4 block size is 4096 for these volumes
+echo "Erase granularity: $AU_BYTES bytes (aligning partition to ${AU_MIB}MiB, stride=$STRIDE)"
+
 # Format as EXT4 with casefolding for proton compatibility
 echo "stage=formatting"
 sync
-parted --script "$STORAGE_DEVICE" mklabel gpt mkpart primary 0% 100%
+parted --script "$STORAGE_DEVICE" mklabel gpt
+parted --script "$STORAGE_DEVICE" mkpart primary ext4 "${AU_MIB}MiB" 100%
 udevadm settle
-mkfs.ext4 -m 0 -O casefold -E "$EXTENDED_OPTIONS" "${EXTRA_MKFS_ARGS[@]}" -F "$STORAGE_PARTITION"
+mkfs.ext4 -m 0 -O casefold \
+    -E "$EXTENDED_OPTIONS,stride=$STRIDE,stripe-width=$STRIDE" \
+    "${EXTRA_MKFS_ARGS[@]}" -F "$STORAGE_PARTITION"
 udevadm settle
 
+# Verify: a correctly aligned partition reports discard_alignment == 0
+_disc_aln="$(cat "/sys/class/block/$(basename "$STORAGE_PARTITION")/discard_alignment" 2>/dev/null || echo 0)"
+if [[ "$_disc_aln" -ne 0 ]]; then
+    echo "Warning: partition is misaligned to the device erase granularity (discard_alignment=$_disc_aln)"
+fi
+
 # Mount the device
 if ! /usr/lib/hwsupport/steamos-automount.sh add "$STORAGE_PARTBASE"; then

Open questions for whoever picks this up:

  • VERSION_NUMBER is currently 1, with the comment "Increase the version number every time a new option is added". No new CLI option is added here, but the on-disk layout changes. Whether the Steam client keys off --version to decide anything is not visible from the script alone — deliberately left untouched.
  • The nodiscard default (secondary finding above) is not addressed by this diff. Resetting the card's FTL on format arguably wants a separate decision, since it turns a fast operation into a slow one.
  • Existing cards are not fixed by this. They need to be reformatted to benefit.
  • USB mass-storage paths are untested; preferred_erase_size does not exist there and the discard_granularity fallback may report 0, in which case the 4 MiB default applies. That is harmless (4 MiB is a multiple of 1 MiB) but is not necessarily optimal for those devices.

Reproduction

  1. Format a high-capacity microSDXC card (AU >= 4 MiB) via the Steam UI.
  2. lsblk -D /dev/mmcblk0 -> DISC-ALN on the partition is non-zero (3145728 for a 4 MiB AU).
  3. Fill the card with games; install/patch/remove over time.
  4. Sequential read throughput of aged data collapses to single-digit MB/s.

Verification that alignment is the cause: write a fresh contiguous block into verified-free space on the same misaligned card and read it back — it returns full speed (83.7 MB/s), proving the NAND, the controller and the slot are all healthy.


Environment

  • Steam Deck OLED, SteamOS Stable
  • jupiter-hw-support-3.8.20260630.1
  • Card: SanDisk Ultra 1.5 TB microSDXC (SDSQUAC-1T50), A1 / U1 / V10 / Class 10
    • manfid 0x000003, oemid 0x5344, name SD1T5, date 06/2024
    • preferred_erase_size 4194304 (4 MiB AU)
    • Reported capacity 1,535,799,605,760 bytes (genuine)
  • Card was formatted by SteamOS (confirmed by the casefold feature flag)
  • safe_trim_quirk = 1; steamos-trim-devices confirmed running periodically (journal: Jun 21, Jun 28, Jul 8)

Detailed measurements

Before — SteamOS-formatted, ~550 GB accumulated over ~2 years

Block-size sweep, 128 MiB per run at 100 GiB offset, iflag=direct:

Request size Throughput
4 KiB 1.65 MB/s
32 KiB 4.37 MB/s
128 KiB 5.50 MB/s
512 KiB 5.78 MB/s
2 MiB 6.02 MB/s
8 MiB 6.24 MB/s
32 MiB 6.26 MB/s

Marginal bandwidth is flat at ~6 MB/s across the entire range — a hard throughput wall, not per-request overhead.

Whole-card read profile (bs=8M, iflag=direct):

Offset Throughput
0–600 GiB 3.7 – 6.9 MB/s
800 GiB 8.7 MB/s
1000–1400 GiB 12.6 – 13.6 MB/s

4K random read latency (300 samples, 8 GiB window at 100 GiB):

min 0.3 | p50 6.9 | p90 13.9 | p99 16.7 | max 18.9  (ms)
-> ~145 IOPS (A1 minimum spec: 1500)

Note the absence of a long tail — max 18.9 ms. This rules out ECC read-retry storms and therefore worn NAND.

Control: a freshly written, contiguous 1 GiB block placed into verified-free blocks on the same misaligned filesystem reads back at 83.7 MB/s in the internal slot (63.0 MB/s via an external USB 3 reader). Same card, same NAND, same session.

After — manual rebuild
umount /run/media/deck/SteamDeck1500
blkdiscard -f /dev/mmcblk0
dd if=/dev/zero of=/dev/mmcblk0 bs=512 count=1024

parted --script /dev/mmcblk0 mklabel gpt
parted --script /dev/mmcblk0 mkpart primary ext4 8MiB 100%
udevadm settle

mkfs.ext4 -m 0 -O casefold \
  -E stride=1024,stripe-width=1024,root_owner=1000:1000,discard \
  -L SteamDeck1500 -F /dev/mmcblk0p1

lsblk -D -> DISC-ALN 0 on both device and partition.

Block-size sweep, same methodology:

Request size Before After
4 KiB 1.65 MB/s 9.0 MB/s
32 KiB 4.37 MB/s 38.6 MB/s
128 KiB 5.50 MB/s 52.9 MB/s
2 MiB 6.02 MB/s 81.0 MB/s
32 MiB 6.26 MB/s 81.7 MB/s

The curve now scales with request size and saturates at the UHS-I ceiling instead of walling at 6.26 MB/s.

4K random read latency:

min 0.30 | p50 0.40 | p90 0.58 | p99 0.63 | max 1.30  (ms)
-> 2480 IOPS (65% above the A1 minimum)

Sequential 4 GiB file: write 18.9 MB/s (unchanged — the card is U1/V10, this is within spec and was never the issue), read 80.3 MB/s.

filefrag on the 4 GiB test file: 32 extents — the maximum contiguity ext4 permits (one extent = 32768 blocks = 128 MiB).

Hypotheses ruled out
Hypothesis Evidence against
Counterfeit card Genuine capacity (1.53 TB reported and fully readable), CID matches SanDisk, f3probe passes
Worn / degraded NAND Latency histogram has no tail (max 18.9 ms); write throughput perfectly flat at 19.8 MB/s over 54 s with zero stalls
Faulty SD slot Freshly written block reads at 83.7 MB/s in the same slot
Missing TRIM safe_trim_quirk = 1; steamos-trim-devices confirmed running; TRIM cannot relocate already-scattered data regardless
Filesystem fragmentation e2freefrag showed a healthy, unfragmented filesystem (largest free extent 2016 MiB = the flex_bg maximum)

References

[1] ValveSoftware/SteamOS issue #898 — "[SteamDeck] TRIM is not enabled by default affecting lifespan of eMMC / SSD" (open since Nov 2022)
https://github.com/ValveSoftware/SteamOS/issues/898

[2] Steam Community — "''Warning'' SD card download/install speed is very slow!" (Valve dev lostgoat investigating; users report 345–937 GiB trimmed on near-empty cards; PC-partitioned cards sustain 50–60+ MB/s vs. 35–45 MB/s for Deck-partitioned)
https://steamcommunity.com/app/1675200/discussions/0/3269061071528807540/

[3] How-To Geek — "How to Fix Slow SD Card Downloads on SteamOS" (May 2025; documents the manual-repartition workaround, author states the cause is unknown)
https://www.howtogeek.com/how-to-fix-slow-sd-card-downloads-on-steamos/

[4] Steam Community — "Steam Deck Slow download speeds to SD card" (genuine SanDisk Extreme 1TB A2/V30, RMA-replaced, issue persists)
https://steamcommunity.com/app/1675200/discussions/0/5267542371395504573/

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with /usr/lib/hwsupport/format-device.sh and compare its device handling with trim-devices.sh, especially the sysfs attributes mentioned in the report. Reproduce formatting on a card reporting preferred_erase_size, then inspect lsblk -D and the resulting DISC-ALN value. Done means the behavior is validated across SD/MMC and USB paths, with aligned output and the format script's existing workflow preserved.

Written by the indexing model from the issue text.

Assessment

Tech stack
shell
Domain
operating-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.