fix(exr): decode each compressed chunk once when reading 1 scanline - #5435
Open
lgritz wants to merge 1 commit into
Open
fix(exr): decode each compressed chunk once when reading 1 scanline#5435lgritz wants to merge 1 commit into
lgritz wants to merge 1 commit into
Conversation
…scanline TL;DR: Issue AcademySoftwareFoundation#5379 alerted us to the fact that on certain versions of OpenEXR, performance was horrible when reading one scanline at a time, because entire chunks are reread and decoded each time. This PR solves this with a 1-chunk cache in OIIO's openexr readers. Summary of before and after, using imagespeed_test on a 4k exr (on my MacBookPro M4): | Method | time (s) | rate | | AFTER | | | --------------------------------- | -------- | ----- | -- | ----- | ----- | | read_image | 0.03s | 318.2 | | 0.03s | 330.5 | | **read_scanline (1 at a time)** | 31.15s | 0.3 | | 0.12s | 71.7 | | read_scanlines (64 at a time) | 0.51s | 17.5 | | 0.12s | 71.6 | | ImageBuf read | 0.03s | 303.1 | | 0.03s | 325.0 | | ImageCache get_pixels | 0.05s | 183.2 | | 0.14s | 64.9 | | ImageCache get_pixels (autotile) | 0.52s | 17.1 | | 0.13s | 66.5 | Longer explanation: EXR compression packs many scanlines into each chunk (16 for zip, 32 for piz and dwaa, 256 for dwab), and a chunk can only be decompressed whole. A client reading fewer scanlines than that at a time made us decode the same chunk again on every call. Reading a 4096x2160 zip file one scanline at a time was orders of magnitude slower, see table above. Give both readers a one-chunk cache. A request lying strictly inside a single chunk is served from it, decoding the whole chunk first if we don't have it. Lookup and store are under a mutex, the decode is not. Chunks over 32 MB are decoded but not retained. The key is subimage, miplevel, chunk and channel range. We cannot lean on the OpenEXR library here: the C core keeps no such stash, and the C++ layer's (OpenEXR PR 1899) is dropped by setFrameBuffer(), which we must call before every readPixels() because the destination moves. One scanline at a time is now 100x faster, the same speed as reading 64 scanlines at a time, which is also made 4x faster by this patch. It's still most efficient to read the whole image, which is 4x faster still. (These are multithreaded benchmarks, so there is threading benefit to doing more chunk decodes in parallel, that's probably the remaining difference between 64 scanline reads and whole-image reads.) Reading in whole chunks matters to more than EXR, so name the idea: "oiio:RowsPerChunk", set by any format storing scanlines in indivisible groups. TIFF has advertised "tiff:RowsPerStrip" for years, and read_image and the ImageCache both looked for that one name; they now consult the general one, and the TIFF and EXR readers and writers set it to describe the file at hand. It is a hint only, never written into a file. That changes the ImageCache for untiled EXR files, which now get the treatment untiled TIFFs have had since 2020: always autotiled, in full-width tiles holding a whole number of chunks, even when autotile is 0. Treating such a file as one image-sized tile decompresses the whole image on every cache miss, which collapses once several of them no longer fit in the cache together. Tiles are capped at 16 MB, past which we stop aligning rather than let too few tiles fit. You can see from the stats in the table, ImageCache with tiled files is still very efficient; reading via ImageCache is as fast as reading in 64 row blocks via raw ImageInput::read_scanlines. Though we will note that reading through the ImageCache without autotile has slowed down somewhat compared to before, but that's not a common use case for scanline files unless they are really big. A new openexr-scanlines test reads assorted compressions in odd swaths and channel subsets and compares against a single whole-image read, for both readers. Along the way, we noticed some latent bugs exposed by the new tests and decided to fix. Technically, they are OpenEXR bugs, fixed, but OIIO might build against OpenEXR versions that predate the fixes, so here we are. Fix a buffer overrun on OpenEXR 3.3.2 through 3.4.2. Their classic ScanLineInputFile keeps one decoded ScanLineProcess across readPixels() calls but chooses the pixel-unpack routine only on the first, and the specialized routines write every channel through channels[0].decode_to_ptr. So a channel-subset read after a full-channel one overruns the caller's buffer, or writes through a null pointer if channel 0 was among those dropped. Introduced by OpenEXR's PR 1899, fixed in 3.4.3 by PR 2150, never fixed on the 3.3 branch. Work around it by reading all channels into scratch and copying out the requested range. The tiled and deep readers build their process objects per call, so they are unaffected. We can get rid of the extra work (which is guarded by `#if` on the OpenEXR version anyway) once our own minimum OpenEXR dependency is >= 3.4.3. Assisted-by: Claude Code / Claude Opus 5 Signed-off-by: Larry Gritz <lg@larrygritz.com>
Collaborator
Author
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.
TL;DR: Issue #5379 alerted us to the fact that on certain versions of OpenEXR, performance was horrible when reading one scanline at a time, because entire chunks are reread and decoded each time. This PR solves this with a 1-chunk cache in OIIO's openexr readers.
Fixes #5379
Summary of before and after, using imagespeed_test on a 4k exr (on my MacBookPro M4):
Longer explanation:
EXR compression packs many scanlines into each chunk (16 for zip, 32 for piz and dwaa, 256 for dwab), and a chunk can only be decompressed whole. A client reading fewer scanlines than that at a time made us decode the same chunk again on every call. Reading a 4096x2160 zip file one scanline at a time was orders of magnitude slower, see table above.
Give both readers a one-chunk cache. A request lying strictly inside a single chunk is served from it, decoding the whole chunk first if we don't have it. Lookup and store are under a mutex, the decode is not. Chunks over 32 MB are decoded but not retained. The key is subimage, miplevel, chunk and channel range. We cannot lean on the OpenEXR library here: the C core keeps no such stash, and the C++ layer's (OpenEXR PR 1899) is dropped by setFrameBuffer(), which we must call before every readPixels() because the destination moves.
One scanline at a time is now 100x faster, the same speed as reading 64 scanlines at a time, which is also made 4x faster by this patch. It's still most efficient to read the whole image, which is 4x faster still. (These are multithreaded benchmarks, so there is threading benefit to doing more chunk decodes in parallel, that's probably the remaining difference between 64 scanline reads and whole-image reads.)
Reading in whole chunks matters to more than EXR, so name the idea: "oiio:RowsPerChunk", set by any format storing scanlines in indivisible groups. TIFF has advertised "tiff:RowsPerStrip" for years, and read_image and the ImageCache both looked for that one name; they now consult the general one, and the TIFF and EXR readers and writers set it to describe the file at hand. It is a hint only, never written into a file.
That changes the ImageCache for untiled EXR files, which now get the treatment untiled TIFFs have had since 2020: always autotiled, in full-width tiles holding a whole number of chunks, even when autotile is 0. Treating such a file as one image-sized tile decompresses the whole image on every cache miss, which collapses once several of them no longer fit in the cache together. Tiles are capped at 16 MB, past which we stop aligning rather than let too few tiles fit.
You can see from the stats in the table, ImageCache with tiled files is still very efficient; reading via ImageCache is as fast as reading in 64 row blocks via raw ImageInput::read_scanlines. Though we will note that reading through the ImageCache without autotile has slowed down somewhat compared to before, but that's not a common use case for scanline files unless they are really big.
A new openexr-scanlines test reads assorted compressions in odd swaths and channel subsets and compares against a single whole-image read, for both readers.
Along the way, we noticed some latent bugs exposed by the new tests and decided to fix. Technically, they are OpenEXR bugs, fixed, but OIIO might build against OpenEXR versions that predate the fixes, so here we are.
Fix a buffer overrun on OpenEXR 3.3.2 through 3.4.2. Their classic ScanLineInputFile keeps one decoded ScanLineProcess across readPixels() calls but chooses the pixel-unpack routine only on the first, and the specialized routines write every channel through channels[0].decode_to_ptr. So a channel-subset read after a full-channel one overruns the caller's buffer, or writes through a null pointer if channel 0 was among those dropped. Introduced by OpenEXR's PR 1899, fixed in 3.4.3 by PR 2150, never fixed on the 3.3 branch. Work around it by reading all channels into scratch and copying out the requested range. The tiled and deep readers build their process objects per call, so they are unaffected. We can get rid of the extra work (which is guarded by
#ifon the OpenEXR version anyway) once our own minimum OpenEXR dependency is >= 3.4.3.Further discussion / food for thought:
The one-chunk cache is simple and works perfectly when reading one scanline at a time, as long as they are read in order. In OIIO internals themselves (including oiiotool), we really only read scanline files in order, so not a problem for us. But if an app was using the OIIO ImageInput API to read single scanlines in random order, the 1-chunk cache would be ineffective and it would end up being back to the expensive case. Do we care? That seems unusual, and we strongly advise people with this access pattern to use ImageCache, or ImageBuf, or maintain a more sophisticated caching scheme on their end.
Assisted-by: Claude Code / Claude Opus 5