6.5 KiB
Pre9 LZMA decoded extent cache
Decision
Pre7, Pre8, and Pre9 all reproduced the same LZMA read-liveness symptom. The manual evidence shows that a single large read completes quickly while a full file read made up of successive small reads remains in the LZMA decode path. The source path creates and destroys a decoded extent for every mapped read; the existing FreeBSD buffer cache only retains compressed block buffers.
A cache local to z_erofs_read_uio() would not cross the VOP/read boundary
between successive small user reads. Commit 61f4709 therefore kept one
decoded extent on each compressed-file vnode. Review found that retained
memory could then grow with the number of open vnodes. This follow-up moves
the cache to struct erofs_mount: each mount retains at most one decoded
extent until unmount, so ordinary open-file count cannot expand the cache.
A single entry can thrash between files and concurrent readers; that is an
accepted first-stage tradeoff.
The cache is deliberately limited to complete, non-partial MicroLZMA extents. Partial references keep the existing path because their decoded length and reference semantics are different. Other codecs are also unchanged in this phase: the measured failure is LZMA-specific, and broadening the change would make the validation and regression attribution less precise.
Data and lifecycle
struct erofs_zextent_cache stores the decoded allocation and the mapping
identity needed to prove that it can be reused:
struct erofs_zextent_cache {
void *data;
erofs_nid_t m_nid;
erofs_off_t m_pa;
erofs_off_t m_la;
uint64_t m_plen;
uint64_t m_llen;
unsigned int m_deviceid;
unsigned int m_flags;
unsigned char m_algorithmformat;
};
The cache mutex is initialized immediately after allocating
struct erofs_mount. Every mount failure path reaches erofs_sb_free(), and
normal unmount calls the same helper after vflush(). There is no cache field
or cache lifecycle dependency in struct erofs_node. The cache key includes
the inode NID because one mount entry is shared by all regular file vnodes.
NID zero remains a valid key; data != NULL is the validity bit.
EROFS is read-only, so a decoded extent does not need write invalidation.
vflush() completes before unmount frees the mount object, and
z_erofs_extent_cache_fini() releases the single mount-owned allocation.
EROFS_MAP_META is explicitly excluded, so metadata-backed tailpacking keeps
the previous path. packed_inode and metabox_en are also excluded because
they are mount-private backing objects, not user file vnodes.
Concurrency
Decompression and block reads occur without holding z_extent_cache_lock. A reader
first takes the lock only to compare the complete key and copy a cache hit.
On a miss, it builds a private decoded extent. It then takes the lock again:
- If another reader published the same key, copy that published extent and discard the duplicate private allocation.
- Otherwise replace the one cached extent, copy the requested range, unlock, and free the old allocation.
This permits duplicate construction under concurrent misses but keeps all
shared pointer access under the mutex. The lock is never held across
bread(), allocation, or decompression, all of which may sleep. The cache
allocation is never freed while a reader is copying it because replacement,
hit copying, and pointer clearing are serialized by the same mutex.
The helpers are reached only when want <= MAXPHYS and contain KASSERT
checks for that contract. Thus the largest lock-held copy is the FreeBSD
MAXPHYS request size, not the 12 MiB on-disk extent cap. Calls that can pass
more than MAXPHYS use the existing uncached copy path. This bounded mutex
copy is accepted for the first stage; a refcounted immutable entry is deferred
until runtime evidence shows that this copy is materially contended.
Bounds and unchanged paths
The existing mapping sanity checks cap a mapped compressed extent at
Z_EROFS_PCLUSTER_MAX_DSIZE before the read path. z_erofs_read_extent() also
checks the compressed and decoded lengths before allocation. The read path now
explicitly checks mapoff and the extent length before converting them to
size_t; this protects the cache offset arithmetic on platforms where
size_t is narrower than the on-disk fields.
Cache use requires all of the following:
compressed mapped extent
not EROFS_MAP_PARTIAL_REF
Z_EROFS_COMPRESSION_LZMA
initialized mount cache
not `EROFS_MAP_META`
not a mount-private backing inode
request length no greater than `MAXPHYS`
Fragments, holes, partial references, non-LZMA codecs, uncompressed files, metadata reads, and mount-private backing inodes retain their previous code paths and error handling.
Rejected alternatives for Pre9
- A function-local cache was rejected because it cannot span successive VOP reads that caused the observed amplification.
- A per-vnode cache was rejected after the
61f4709review: open file count could retain one decoded extent per vnode without a system-wide bound. - A larger cross-vnode cache was rejected because it would require an eviction policy and larger memory accounting. The one-entry per-mount cache is the controlled compromise: it has a fixed mount-scoped bound and simple teardown.
- A Linux page/folio/XArray/workqueue port was rejected because those are not FreeBSD vnode/buf primitives and would create an unnecessary compatibility layer.
- A decoder stream pool was deferred: it may reduce allocator overhead but does not remove repeated full extent decompression.
- Changing
MAXPHYS, changing the disk format, bypassing the buffer cache, or adding decoder retries was rejected because none addresses the demonstrated decoded-result reuse and each changes unrelated behavior or resource bounds.
Static validation and required runtime matrix
This change is static-only in the source phase. The follow-up test agent must run the unchanged LZMA fixture against Pre9 and require:
- bounded single reads at 4 KiB, 16 KiB, 64 KiB, and 1 MiB with source-range hash equality;
- repeated small reads spanning the same extent, with completion and hash equality;
- complete sequential SHA-256 with recorded exit status and elapsed time;
- concurrent reads of the same file and close/reopen reads;
- partial-reference, plain, LZ4, DEFLATE, and ZSTD regression coverage;
- clean unmount, md detach, and module unload after every case.
The automation verdict and DUT verdict must remain separate. A timeout or missing final hash remains a failure or blocked result; it must not be promoted to PASS because bounded reads succeed.