Spread CD sectors in place, and decode LZMA into the caller's buffer - #183
Merged
Conversation
Two memory savings for targets where RAM is the binding constraint. Each CD codec kept a hunk-sized scratch (19584 B), decoded the sector data into it, and copied every byte to the caller's hunk at the end. With CHDR_CD_SCRATCH_BUFFER=0 the codec decodes into the caller's hunk and the sectors are spread to the 2448-byte frame stride in place, needing only a frame of bounce plus subcode staging - 3120 B. Separately LzmaDec's dictionary now is the destination, which is what the SDK's own one-call LzmaDecode() does, removing a second hunk-sized allocation and a copy per hunk. Per-codec peak heap, rv32imafc under qemu with LOWRAM_TARGET=ON, the figure tests/rv32/check_budget.py already measures: cd_cdlz 99014 -> 57906 B, cd_cdzl 66249 -> 49785, cd_cdzs 145257 -> 128793, hd_lzma 32022 -> 27858. The default follows LOWRAM_TARGET, so a build that is not memory constrained keeps the previous behaviour and the previous speed exactly; only the LZMA change applies there. Where the in-place path is on it costs a few percent of decode CPU and roughly halves what an open CD image needs. Each sector is bounced through the frame-sized scratch rather than memmove()d: the moves overlap, and memmove on overlapping regions degrades to a byte-at-a-time loop in some C libraries. With the scratch off, chd_read()'s buffer is read back during the call and must be 2-byte aligned, since the CD FLAC codec writes int16_t into it. An odd pointer returns CHDERR_INVALID_PARAMETER rather than trapping. Both requirements are documented on chd_read and lifted by CHDR_CD_SCRATCH_BUFFER=1. Two test gaps closed on the way. The test targets compiled against chdconfig.h's defaults rather than the values the library was built with, so kat.c failed to link with WANT_RAW_DATA_SECTOR=OFF. They now get CHDR_DEFINES directly, while chdr-static keeps them PRIVATE so unnamespaced macros are not pushed onto consumers. And no image in the corpus had a parent, so COMPRESSION_PARENT was decoded by no test at all - which matters here, because a parent-referenced hunk reaches the caller's buffer by a different route than an in-place spread. generate.sh now builds a parent with two children (one identical, so every hunk is a parent reference; one perturbed, so references and real compressed hunks share a map) and tests/parent_decode.c checks both. They live outside seeds/, since a child cannot be opened standalone and the workflows that walk seeds/ do exactly that. Verified: 287 images byte-identical against master in both CD modes and both shipping configurations, known-answer, AVHuff and parent/delta tests, big-endian powerpc and s390x including the in-place path, ASan/UBSan across 80 images in both configurations, 3.8M libFuzzer executions, and every combination of LOWRAM_TARGET, WANT_SUBCODE and WANT_RAW_DATA_SECTOR builds and passes.
rtissera
force-pushed
the
pr1-cd-inplace
branch
from
September 5, 2026 13:16
346ce7c to
a0bffe0
Compare
Every workflow was on: [push, pull_request]. For a branch in this repo that means the push and the pull request each trigger the full set on the same commit - 76 checks per PR where there are 38 distinct ones. Restrict push to master; pull_request keeps full coverage. Add a concurrency group as well, so a force-push cancels the superseded run instead of leaving both to finish. Master is excluded, so a merge is never cancelled by the next one. And remove esp32p4-ram-budget, which its own header described as byte-identical to rv32-ram-budget: same toolchain file, same RV32IMAFC/ ilp32f ABI, same qemu machine, same probe, same expected numbers. It existed for CI visibility, which is not worth a job. tests/esp32p4/'s three files went with it - they differed from tests/rv32/'s only in comments. esp32p4-build stays; that one compiles the real integration surface and is not a duplicate of anything. 76 checks per PR down to 37.
Every workflow ran on every commit regardless of what it touched. A
quarter of the last forty commits on master changed only documentation,
contrib or markdown, and each of those still ran the whole matrix -
twelve BSD/Haiku virtual machines among them - to prove that editing a
README does not break DragonFlyBSD.
paths-ignore per workflow, scoped to what each one actually builds:
docs, markdown, LICENSE, .gitignore ignored everywhere
contrib/ ignored by the six workflows that
build only the library
the other target's contrib tree ignored by the two integration
smoke tests
A documentation-only commit now runs nothing, a library change still runs
all thirteen workflows exactly as before, and a change under one contrib
target runs six rather than thirteen. No coverage is removed from any
change that could affect the thing being tested - master has no required
status checks, so a skipped workflow cannot wedge a merge either.
Note the glob: '**.md' matches a markdown file anywhere, including at the
repository root. '**/*.md' would not have matched README.md.
A netbsd/arm64 VM failed to boot on this branch and ran to GitHub's six-hour maximum before being killed - the log is UEFI firmware errors, nothing to do with the build. A midnightbsd job failed in the same run because its package mirror was down. Neither is a code failure, and the first cost six hours of runner time to learn nothing. timeout-minutes per job, sized from measured durations rather than guessed. The slowest legitimate job across recent runs is cross-platform-actions at 17 minutes, which is VM provisioning plus pkg install; msys2 reaches 5; every other workflow finishes between 31 and 59 seconds. cross-platform-actions 30 min (1.8x its observed worst) everything else 15 min (2.5x msys2, 20x the rest) So a dead VM now costs 30 minutes instead of six hours, and the headroom is still large enough that a slow provisioning day will not produce a false failure.
…ntract Two follow-ups from checking this branch against the projects that embed libchdr. `#if CHDR_CD_SCRATCH_BUFFER` on an undefined macro evaluates to 0, which is the in-place path. Every file here reaches chdconfig.h transitively, so this cannot happen in this tree - but the emulators that vendor libchdr do not all take the headers with the sources, and one that keeps its own chdconfig.h would silently switch decode paths, or, if it synced only some of these files, size a codec's scratch for one path while the decoder used the other. Reject that at compile time instead. Reproduced against a downstream-shaped chdconfig.h: master compiles, this now stops with the #error. chd_read's comment scoped the read-back caveat to CHDR_CD_SCRATCH_BUFFER=0. That is wrong for LZMA, which points its dictionary at the caller's buffer in every build and so reads back what it just wrote, and it credited the 2-byte alignment requirement to the CD FLAC codec alone when the plain FLAC codec has always written int16_t straight into that buffer too. Also note what WANT_SUBCODE=0 leaves in the subcode area, the only place the two scratch settings produce different bytes - both leave it unwritten, so it holds unrelated data either way. The three redundant chdconfig.h includes added earlier go away with this: the codec headers already pull it in, and the guard now proves it.
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two memory savings for targets where RAM is the binding constraint, behind a config knob that leaves everything else on the code path it has today.
What changes
Each CD codec kept a hunk-sized scratch (19584 B), decoded the sector data into it, then copied every byte across to the caller's hunk. With
CHDR_CD_SCRATCH_BUFFER=0the codec decodes into the caller's hunk and spreads the sectors to the 2448-byte frame stride in place, needing only a frame of bounce plus subcode staging — 3120 B.Separately,
LzmaDec's dictionary now is the destination buffer, which is what the SDK's own one-callLzmaDecode()does. That removes a second hunk-sized allocation and a copy per hunk. It has no trade to make, so it is unconditional.Numbers
Per-codec peak heap, rv32imafc under qemu with
LOWRAM_TARGET=ON— the figuretests/rv32/check_budget.pyalready measures in CI:On a microcontroller reading from an SD card, every hunk of 14 images (7.7 GB, 128 minutes): 1.002× on time, peak heap down 33–74 KB per CD image, and the smallest largest-free-block roughly doubled on each — on one image 16 KB to 78 KB, which is the difference between a later allocation of that size failing and succeeding.
Defaults
CHDR_CD_SCRATCH_BUFFERfollowsLOWRAM_TARGET. A build that is not memory constrained keeps the previous CD behaviour and the previous speed exactly; only the LZMA change applies there.Set it explicitly to pin it either way.
1is also what a caller wants whose hunk buffer is uncached, write-combining or a FIFO, since the buffer is then written once, linearly, and never read back.Where the in-place path is on it costs a few percent of decode CPU and roughly halves what an open CD image needs.
Two notes for reviewers
The spread bounces each sector through the frame-sized scratch rather than using
memmove. The moves overlap by 96 bytes, andmemmoveon overlapping regions degrades to a byte-at-a-time loop in some C libraries — measured at up to 1.19× a whole hunk's decode CPU. Two non-overlappingmemcpys are far cheaper there and no worse elsewhere.chd_read's buffer must be ordinary readable RAM, and 2-byte aligned. With the scratch off a CD hunk is decoded into that buffer and spread in place, so it is read back during the call. LZMA reads it back in every build, scratch or not, since its dictionary now is that buffer. And the FLAC codecs writeint16_tinto it directly — which the plain FLAC codec has always done, so the alignment requirement is older than this branch. In the CD case an odd pointer returnsCHDERR_INVALID_PARAMETERrather than trapping on a target without unaligned stores. All of this is documented onchd_read;CHDR_CD_SCRATCH_BUFFER=1lifts only the CD read-back.Checked against the projects that embed libchdr
RetroArch/libretro-common, PCSX2, DuckStation and Flycast were read to see what they actually do, and then a harness reproducing each one's call sequence was run against both sides.
None of them sets any of this project's CMake options, so
LOWRAM_TARGETis 0 and the in-place path is unreachable for all four; only the LZMA change applies. All four pass a plain heap buffer of exactlyhunkbytes, at least 2-byte aligned — RetroArch already casts it touint16_t*to byte-swap in place, so it relies on that guarantee today. The harness coverschd_open,chd_open_file,chd_open_core_filewith caller-supplied callbacks,chd_precache, parent chains, resident and per-call buffers, and sector-granular reads, with guard bytes around every buffer.Against
origin/master, no flags on either side:chd_errormap: identical, so a corrupt file is still rejected in exactly the same placenm -Don the shared library: same 18 exported symbols, same undefined setTwo things that came out of it, in the last commit.
#if CHDR_CD_SCRATCH_BUFFERon an undefined macro is 0, the in-place path — harmless in this tree, where every file reacheschdconfig.h, but a project that keeps its ownchdconfig.hand syncs sources into it would switch decode paths silently, or size one codec's scratch for a path the decoder is not on. That is now an#error, verified against a downstream-shapedchdconfig.h. Andchd_read's comment was corrected as above.The only configuration where the two scratch settings disagree at all is
WANT_SUBCODE=0, and the difference is entirely inside the 96-byte subcode gap that neither setting writes — sector data is identical. Noted inchdconfig.h.Test gaps closed on the way
The test targets compiled against
chdconfig.h's defaults rather than the values the library was built with, sokat.cfailed to link withWANT_RAW_DATA_SECTOR=OFF. They now getCHDR_DEFINESdirectly;chdr-statickeeps themPRIVATEso unnamespaced macros are not pushed onto consumers.And no image in the corpus had a parent, so
COMPRESSION_PARENTwas decoded by no test at all — which matters here, because a parent-referenced hunk reaches the caller's buffer by a different route than an in-place spread.generate.shnow builds a parent with two children (one identical, so every hunk is a parent reference; one perturbed, so references and real compressed hunks share a map) andtests/parent_decode.cchecks both. They live outsideseeds/, since a child cannot be opened standalone and the workflows that walkseeds/do exactly that.Verification
LOWRAM_TARGET,WANT_SUBCODEandWANT_RAW_DATA_SECTORbuilds and passesAlso in this PR: CI consolidation
Folded in rather than split out, to stop the waste immediately.
Every job was running twice. All 14 workflows were
on: [push, pull_request], so for a branch in this repo the push and the pull request each triggered the full set on the same commit — 76 checks where there are 38 distinct ones.pushis now restricted tomaster;pull_requestkeeps full coverage.Superseded runs now cancel. A concurrency group per workflow, excluding
masterso a merge is never cancelled by the next one. Previously a force-push left the old commit's entire matrix running to completion.esp32p4-ram-budgetremoved. Its own header described it as byte-identical torv32-ram-budget— same toolchain file, same RV32IMAFC/ilp32f ABI, same qemu machine, same probe, same expected numbers — existing "for CI visibility".tests/esp32p4/'s three files went with it; they differed fromtests/rv32/'s only in comments.esp32p4-buildstays: it compiles the real integration surface and duplicates nothing.Path filters per workflow. A quarter of the last forty commits on
mastertouched only documentation, contrib or markdown, and each ran the whole matrix — twelve BSD/Haiku VMs included — to prove a README edit does not break DragonFlyBSD. Scoped to what each workflow actually builds:contrib/targetNothing that could affect Android, the BSDs, MSYS2, Switch or Vita skips them.
masterhas no required status checks, so a skipped workflow cannot wedge a merge.Net: 76 checks → 37 on a code change, → 0 on a docs change.