Skip to content

fix(exr): decode each compressed chunk once when reading 1 scanline - #5435

Open
lgritz wants to merge 1 commit into
AcademySoftwareFoundation:mainfrom
lgritz:lg-exrspeed
Open

fix(exr): decode each compressed chunk once when reading 1 scanline#5435
lgritz wants to merge 1 commit into
AcademySoftwareFoundation:mainfrom
lgritz:lg-exrspeed

Conversation

@lgritz

@lgritz lgritz commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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):

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.

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

…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>
@lgritz

lgritz commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

@lji-ilm

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.

Re: EXR issue #1887 - Existing single scanline patch does not affect OIIO

1 participant