Skip to content

scan: look archives up by their first member's CRC first - #19937

Merged
LibretroAdmin merged 5 commits into
libretro:masterfrom
Davey-Hughes:scan-member-crc-first
Oct 28, 2026
Merged

LibretroAdmin merged 5 commits into
libretro:masterfrom
Davey-Hughes:scan-member-crc-first

Conversation

@Davey-Hughes

Copy link
Copy Markdown
Contributor

Guidelines

  1. Rebase before opening a pull request
  2. If you are sending several unrelated fixes or features, use a branch and a separate pull request for each
  3. If possible try squashing everything in a single commit. This is particularly beneficial in the case of feature merges since it allows easy bisecting when a problem arises
  4. RetroArch codebase follows C89 coding rules for portability across many old platforms check using C89_BUILD=1

Description

A follow-up to #19920. Archives were hashed whole before their first member's CRC was looked up. Cartridge databases key on ROM CRCs, so for one-ROM zips that read decided nothing; on a network share it was most of the scan.

Changes

  • Member CRC first: every database is checked for the first member's CRC before any for the archive's own, which arcade databases key on: a directory read for zip, 7z and RAR with CRC32, and LibretroAdmin's decode (9e40aaa) for .zst, .bz2 and BLAKE2 RAR. Archives are read whole only after a miss, and expanded only after both.
  • Core gate: stock cartridge cores do not list zip, so a cartridge zip matched only once expanded, after a wasted whole read. The member walk also takes databases claimed for the member's extension.
  • Readers read archives whole ahead on credit: each member miss in a row grants twice the last, up to 16, and a member hit takes back the rest. An index walk mid streak tops them up.
  • Fixed: a zip or 7z with a directory first never had its own CRC compared; an errored file's CRC and serial carried over to the next archive.

Evidence

Wall seconds, base -> this commit, cold-run medians; *: beyond the base runs' range. R=0 has no readers, R=4 four. cart: one-ROM cartridge zips (NES; 15 systems on Android); fbneo-tz: 596 TorrentZipped FBNeo parents. Playlists equal base's in every run.

NFS 4.2, Linux, against d2db43b:

set R=0 R=4
cart, 3671 zips 46.59 -> 10.89 (-77%)* 17.74 -> 6.81 (-62%)*
arcade, 45 zips 2.20 -> 2.14 (-3%) 1.17 -> 1.01 (-14%)†
fbneo-tz, 596 zips 11.07 -> 10.55 (-5%) 3.58 -> 3.63 (+1%)†
arcade, 400 RVZSTD zips 29.94 -> 29.14 (-3%) 14.04 -> 13.65 (-3%)

†: 10 alternating rounds; both within spread. fbneo-tz's ~0.05-0.09 s is the one-time walk of the extra cart rdbs (see the notes below).

Measured before the rebase, against ad2e9e0 (#19920 as merged):

storage set R=0 R=4
smb:// (built-in client) cart, 3671 158.19 -> 95.19 (-40%)* -
fbneo-tz 17.51 -> 16.78 (-4%) -
CIFS mount, Linux cart, 3671 69.29 -> 50.23 (-28%)* 42.51 -> 32.60 (-23%)*
fbneo-tz 10.46 -> 10.75 (+3%) 4.74 -> 4.90 (+3%)
UNC share, Windows 11 VM cart, 3671 350.67 -> 325.93 (-7%) 255.92 -> 207.27 (-19%)*
fbneo-tz 34.42 -> 32.76 (-5%) 13.37 -> 9.60 (-28%)*
SD card model (70 MB/s, queue depth 1) cart, 1000 6.85 -> 4.46 (-35%)* 7.23 -> 4.38 (-39%)*
fbneo-tz 4.67 -> 4.75 (+2%) 4.99 -> 5.11 (+2%)*
Android handheld, internal cart, 305 2.26 -> 1.15 (-49%) 0.60 -> 0.48 (-20%)
fbneo-tz 1.03 -> 0.97 (-6%) 0.46 -> 0.49 (+7%)
  • Readers skip smb:// paths. A 45-zip arcade set was within spread everywhere.
  • The CIFS, SD card and Android cart R=4 rows predate the top-up, which took Android fbneo-tz R=4 from +49% to within spread; the SD card slowdown is likely the same stall.

Notes for review

  • Extra databases on arcade folders: member extensions (bin, nes, rom, cas) let in cartridge databases base never walked for a zip: on fbneo-tz, 27 stock .rdb files (39 MB), each indexed once per scan in steps (Android index time 7-28 -> 100-137 ms), then about 24 µs of lookups per archive.
  • No index budget (little free memory, or spent): the fallback walks a whole .rdb per lookup, now per arcade archive for each such database, a main-thread stall per archive without threads.
  • Top-up waste bound: the top-up renews on each step of a walk, so readers may run the whole window (256 files with four) ahead, once per database walked, wasted if those archives match by member.
  • One zip, two matches (member CRC in a cartridge database, whole CRC in an arcade one): (a) a cartridge database that passes the name gate (its core lists zip, or Scan Without Core Match is on) now wins x.zip in any database order, where base's result depended on it; (b) otherwise the zip expands into the cartridge playlist, where base wrote x.zip to the arcade one.
  • Scan Inside Archives off needs the Custom scan method; then nothing expands, so (b) keeps base's result.
  • Reader decodes: readers run upstream's one-call .zst, .bz2 and BLAKE2 RAR member decode before their 128 MiB cutoff, as base's scan step does. With four at once, each .bz2 holding up to 8 MiB plus bzip2 state, peak memory may reach four times base's; not measured.
  • Member CRC of 0: base retries it per database, each time a directory read or a failing decode; here it is tried once, then the whole walk.
  • Smaller CIFS and UNC cart gains: the same whole reads are skipped, but zips average 110 KB and each open and directory read still costs round trips (UNC R=0: 89 against 96 ms a zip).
  • mingw: scan_begin_budget_test.exe exits 53 under wine, at base too; tools/stack_budget.py passes.

Tested

On this commit's code: database_scan_test (199 checks, 76 new) and database_index_test under ASan+UBSan, TSan, HAVE_THREADS=0, readers off and a read-ahead cap of 2; C89_BUILD=1 objects, 0 warnings; default and --disable-threads builds; CI samples-tasks via tools/ci_local.py; mingw-w64 tests under wine; a RAR 5, .bz2 and .zst fixture scan (playlists as base's). Before the rebase also: the scan test under 32 busy loops, tools/stack_budget.py, the tree-wide checks.

The full GitHub CI passed on this code in my fork: 151 jobs, Apple jobs included.

Related Issues

[Any issues this pull request may be addressing]

Related Pull Requests

Reviewers

[If possible @mention all the people that should review your pull request]

Davey-Hughes and others added 5 commits October 28, 2026 12:39
A zip, 7z, apk, rar, zst or bz2 was hashed whole before its first
member's CRC was fetched.  Cartridge databases key on ROM CRCs, so a
one-ROM-per-zip set always matches by the member: the whole read, the
main cost of scanning zips on a slow share, was wasted.

The databases are now walked first for the member's CRC alone.  Where
it is stored (zip, 7z, RAR with CRC32) that costs one directory read;
a zst, bz2 or BLAKE2 RAR member is decoded for it, as before.  Only
when that misses is the archive read whole, or a reader's whole result
taken, and the databases walked again for its own CRC, which the
arcade databases key on.  The archive is expanded only after both
miss.  Each walk's size check uses its own size: the member's, then
the archive's.

By default the core gate looks in a database only when a core claims
the file's extension.  Stock cartridge cores do not list zip, so a
cartridge zip was read whole and matched only once expanded.  The
first walk now also takes the databases claimed for the first member's
extension.  A hit there is held while the walk finishes the databases
claimed for the archive's name: a match in one of those is written for
the archive; otherwise the archive is expanded without the whole
read.  With "Scan Inside Archives" off nothing expands, so the first
walk keeps to the archive's name.

The parallel readers likewise start an archive with its first member's
CRC, size and extension, and hand them to the scan.  They hash
archives whole only as far as the scan grants: a member-CRC miss
grants one whole read ahead, each further miss in a row twice the
last, up to 16 at a time, and a member-CRC match takes back what is
left.  A lone unknown cartridge then costs one wasted read, while an
arcade folder soon has the readers 16 ahead.  While the scan first
walks a database's index in a member pass mid streak, the readers may
keep 16 ahead, so the walk does not leave them idle.  A grant goes
first to the nearest archive ahead that has only its member CRC.
Without a reader's whole result the scan hashes an archive it needs
whole itself, in steps, like a file over the cutoff.

An archive with no CRC for its first entry, such as a zip or 7z with
a directory first, skipped every database, so its own CRC was never
compared.  It now goes straight to the whole walk, and readers always
hash it whole.  In first-match mode the walk's early exit leads to the
whole walk too, while that has not run.

The CRCs, sizes and walk are cleared at each file's start.  A file
that errored left them to the next, and an archive after it was
looked up by the stale CRC.  The serial is cleared with them: an
archive matched after a file that errored in its serial lookup was
written under that file's serial.

Every database's member CRC is now checked before any whole CRC;
each database used to check the whole CRC first.  That differs only
when a zip's first member matches a cartridge record and the whole
zip an arcade record.  The cartridge record now wins, written for the
zip itself where the core gate lets the zip's name into the cartridge
database, otherwise through the expansion.  With "Scan Inside
Archives" off nothing expands, so in that second case the arcade
record still wins.
A record count from 128 up is written as a msgpack uint32.  The
read-ahead lanes size their sets from the reader window, 64 files per
reader, so they write 131 to 259 records; a fixint byte past 127
decodes as a map or a bool and the database is rejected on two- and
three-core hosts.
A database whose crc index build is refused is flagged, and neither
the size probe nor the CRC lookup starts another build for it.  The
index budget only shrinks, so a retry could only walk the database
again to the same refusal, once per content file.

The database scan test gains a cap on index builds and a lane that
scans plain files with every build refused: one query walk each.
An archive whose header lists more than one entry, an arcade set as a
rule, is read whole first, and each database takes its own CRC or its
first member's.  A set whose first member is a cartridge ROM is
written for its arcade record rather than expanded into the cartridge
playlist, and an arcade folder walks no database claimed only for a
member's extension.  Readers read such an archive whole without a
grant.  The member pass stays for one-member archives, where a
cartridge zip needs no whole read.

file_archive_get_file_crc32_size_and_ext() reports the entry count
the backends already take from the archive's header.  A database
claimed only for the member's extension joins the member pass only
while it has or may get a crc index; unindexed, the lookup is a full
walk the expansion repeats.  A reader's member CRC survives a whole
read that fails or is over the cutoff, so the scan reads only the
archive itself.

Lanes: an arcade set with a cartridge ROM first, an arcade folder
beside a cartridge database, sets whose reader reads fail, and
cartridge zips against an unindexed database.
When the archive is mapped, zip_parse_file_init() finds the end of
central directory record, reads the ZIP64 records and walks the
central directory where they lie; only the context is allocated.
Unmapped archives read them through the stream.  The scan's member
pass opens every zip for its directory, so this is its hot path.

archive_zip64_test counts reads through the stream and checks that,
mapped, a plain zip with a comment and a ZIP64 one give their first
member's CRC without any.
@LibretroAdmin
LibretroAdmin merged commit 40a5f4e into libretro:master Oct 28, 2026
149 of 151 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