test code v1
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
# 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:
|
||||
|
||||
```c
|
||||
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:
|
||||
|
||||
1. If another reader published the same key, copy that published extent and
|
||||
discard the duplicate private allocation.
|
||||
2. 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:
|
||||
|
||||
```text
|
||||
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 `61f4709` review: 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:
|
||||
|
||||
1. bounded single reads at 4 KiB, 16 KiB, 64 KiB, and 1 MiB with source-range
|
||||
hash equality;
|
||||
2. repeated small reads spanning the same extent, with completion and hash
|
||||
equality;
|
||||
3. complete sequential SHA-256 with recorded exit status and elapsed time;
|
||||
4. concurrent reads of the same file and close/reopen reads;
|
||||
5. partial-reference, plain, LZ4, DEFLATE, and ZSTD regression coverage;
|
||||
6. 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.
|
||||
Reference in New Issue
Block a user