Skip to content

Performance & Optimisation

Storage, partitions and flash wear

Where everything lives on a head unit, and why the flash memory has a write budget that must last fifteen years — the constraint that explains CarWatchdog and Garage Mode.

Intermediate6 minStorage · Partitions · I/O

Flash memory wears out. Each cell can be written a finite number of times, and a vehicle has to keep working for fifteen years without anyone replacing it.

That single physical fact explains several parts of the platform that otherwise look like arbitrary bureaucracy.

The map: what lives where#

PartitionContentsWritable at runtime
/bootKernel and ramdiskNo
/systemAndroid frameworkNo
/vendorHALs and hardware codeNo
/productOEM apps and configurationNo
/odmBoard-specific overridesNo
/dataEverything that changesYes
/metadataEncryption keys and flagsYes, rarely

Why fifteen years is genuinely hard#

Write amplification: it is worse than it looks#

The bytes your code writes are not the bytes the flash writes.

Flash erases in blocks, not bytes. Changing one byte in a page may require reading, erasing and rewriting a much larger block.

Filesystems add journalling. A write may be recorded twice — once to the journal, once to the file.

Databases add their own. An unbatched SQLite transaction writes to a write-ahead log and then to the database.

Measuring what you actually write#

Per-process I/O
adb shell cat /proc/<pid>/io
# read_bytes / write_bytes are what reached storage
# cancelled_write_bytes matters too — pages dirtied then discarded
 
# The platform's own accounting
adb shell dumpsys car_service --services CarWatchdogService
adb shell dumpsys diskstats
adb shell df -h

Writing less#

Batch. Accumulate in memory, write once. One transaction of 200 records instead of 200 transactions.

Batching, concretely
private val pending = ArrayDeque<Reading>()
 
fun record(r: Reading) {
    pending += r
    if (pending.size >= 200) flush()
}
 
private fun flush() = db.runInTransaction {
    dao.insertAll(pending.toList())
    pending.clear()
}

Do not log to files in production. logcat is an in-memory ring buffer and costs no flash. Writing your own log files is the most common cause of I/O overuse, and it is almost never necessary.

Defer bulk work to . The quota there is far larger, deliberately — that is where syncing, compaction and cache warming belong.

Bound every cache. An unbounded on-disk cache is a slow leak that eventually triggers the watchdog.

Prefer append to rewrite. Rewriting a whole file to change one field multiplies writes by the file size.

Encryption#

/data is encrypted, and Android uses file-based encryption — different files can be locked with different keys.

Storage that works before unlock
// Available as soon as the device boots — no user needed
val deviceContext = context.createDeviceProtectedStorageContext()
val vehiclePrefs = deviceContext.getSharedPreferences("vehicle", MODE_PRIVATE)
 
// Only after the user unlocks
val userPrefs = context.getSharedPreferences("user", MODE_PRIVATE)

Putting something in the wrong one produces a service that works in testing and fails on a cold boot before anyone signs in — a classic automotive bug, because on a phone someone always unlocks quickly.

Running out of space#

A head unit fills up: map data, media caches, logs, app data. And unlike a phone, nobody is going to delete photos to make room.

Where the space went
adb shell df -h /data
adb shell du -sh /data/* 2>/dev/null | sort -rh | head -20
adb shell dumpsys diskstats

Next#

Security, and the keys that make an image trustworthy.

References & further reading

Code links target the main branch on cs.android.com. AOSP moves — if a path 404s, search the symbol instead.