Skip to content

perf(j2k-native): decode region bands on the pool ahead of the streamed IDWT - #310

Merged
jcwal1516 merged 4 commits into
frames-sg:mainfrom
jcoyne:perf/parallel-bands
Oct 31, 2026
Merged

jcwal1516 merged 4 commits into
frames-sg:mainfrom
jcoyne:perf/parallel-bands

Conversation

@jcoyne

@jcoyne jcoyne commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Stage 3b from #133: the parallel lookahead executor for banded Tier-1, with the shared queue we discussed on #287.

Change

Today a parallel region decode on the row-streamed IDWT decodes every code-block of a component on the tile route, then runs the IDWT. Here the route's code-block rows become bands that pool workers decode ahead of the IDWT, which runs on the calling thread, so Tier-1 and the IDWT overlap.

  • Planning. plan_band_route (in subband/tile_parallel.rs) plans one component the way plan_tile_route does, with the same coding checks, cutoffs and workspace bank, plus one workspace for the calling thread. Strips are addressed within each sub-band's stored rectangle rather than at whole-window offsets. BandRoute exposes the strips and a per-strip decode for classic and HT.
  • Storage. With storage.parallel_bands, lay_out_region_coefficients gives each sub-band a ring of band slots: 3 at the finest active level and 2 at the others (FINEST_LEVEL_BAND_SLOTS, OTHER_LEVEL_BAND_SLOTS). A ring never exceeds the sub-band's window. When its bands fit in fewer rows than the slots would take, the ring is the whole window, and each band decodes into its own rows. That matters more than I expected: in my first version such sub-bands ended up with a single slot, so the IDWT decoded those bands itself, and 1024² regions were 7–23% slower than main and a 512² region 70% slower.
  • Executor (decode/parallel_bands.rs). One Mutex holds each band's status (waiting, running, done, freed), the free slots and the ready queue. The queue is ordered by the row of the reconstructed component at which the IDWT first reads the band, then largest first. A band becomes ready once the band a ring's length before it has been freed, which happens when the IDWT has read all of it into its own rings. Workers run under rayon::in_place_scope and return to the pool when nothing can start, and the IDWT sends them again when it frees slots, so pool threads never wait on our condvar. The IDWT waits only for a band in flight, and decodes a band nobody has started itself. So it doesn't depend on a free pool thread, even when the pool is busy with other work or the decode runs inside the pool.
  • Every band once. After the IDWT, the bands it never read decode with no ring limit, so the decoded and skipped block counts match the whole-window route.
  • Errors. A planning error or a failed band reruns whole-window Tier-1 serially, as in stage 3 (banded_tier1::whole_window_error), so the error is the one the serial path reports.
  • Fallbacks. A component the route would decode serially (too few coded bytes) uses the serial bands. One it wouldn't route (multi-segment classic blocks, too few rows for the workers, an irregular strip layout) gets its whole windows laid out again and takes the current path. Tiles that don't take the route are unchanged, so the archival image below decodes as before.
  • Budget. The ring slots replace the whole-window coefficients in the plan's charge. The bank and per-thread clipped scratch are charged as the route charges them, and the executor's records are checked against the budget before it starts.
  • decode_components sends banded components, serial or on the pool, through a small decode_component_bands helper.

A hang the fuzz found, and the guard

The first long differential fuzz of an earlier build hung once, after about 231,000 parallel decodes. The calling thread was waiting in the drain phase on a band marked running, while every pool thread was idle. There was no panic, and replaying that iteration 300 times didn't reproduce it.

I couldn't find the path that leaves a band running without a worker. Every place that starts a band either finishes it or returns an error, and a test that makes the IDWT thread hit corrupt bands with the pool's other thread held busy passes. The only behavioural differences between that build and this one are in drain and in send_workers, which no longer spawns while holding the lock.

Since then this code has run about 2.7 million parallel fuzz decodes without stranding a band, including runs at the original load of 12 concurrent processes on 12 cores. At the old rate, a clean run that long would happen with probability under 10⁻⁵. Still, because I can't point to the cause, waits now guard against it.

  • Detection: a running band always belongs to an active worker. So a wait that finds a running band with no worker active and no error recorded, checked at least every 50 ms, marks the decode stranded.
  • Fallback: the IDWT unwinds, and the component decodes over whole windows, with the same output.
  • Reporting: pool_band_fallbacks counts it, and debug builds assert.
  • Testing: a test hook makes the first worker abandon its band, and a_stranded_band_falls_back_to_whole_windows checks that the decode finishes with the right pixels.

The guard checks the error first, because a band whose decode failed stays running by design and its error ends the wait.

Results

Parallel (Auto) component-plane region decodes on a 12-core M4 Pro, against main (0c6b6e47). The figures are interleaved medians of 11 runs at 12 threads and 9 at 4. Peak heap comes from a counting allocator. Output hashes are identical in every case.

Region 12 threads: main → this PR 4 threads: main → this PR Peak heap
18000×12000 PLT, 1024² middle 34.9 → 30.9 ms (−11%) 47.2 → 38.5 ms (−18%) 23.0 → 20.0 MiB
18000×12000 no PLT, 1024² middle 55.2 → 51.8 ms (−6%) 68.6 → 59.6 ms (−13%) 24.2 → 21.2 MiB
18000×12000 no PLT, 1024² bottom-right 53.3 → 51.6 ms (−3%) 59.4 → 54.3 ms (−9%) 24.0 → 21.1 MiB
18000×12000 PLT, 1024² at ½ 40.8 → 36.9 ms (−10%) 60.8 → 50.2 ms (−17%) 23.4 → 20.3 MiB
18000×12000 PLT, 512² 16.9 → 16.3 ms (−4%) 24.9 → 21.5 ms (−14%) 7.6 → 7.2 MiB
4096² HT 9/7, 1024² 23.6 → 23.2 ms (−2%) 24.2 → 22.0 ms (−9%) 22.4 → 19.3 MiB
4096² HT 9/7 tiled, 1024² 23.0 → 23.2 ms (+1%) 22.5 → 21.9 ms (−3%) 17.2 → 16.9 MiB
4096² classic 5/3, 1024² 38.4 → 36.5 ms (−5%) 61.6 → 53.8 ms (−13%) 21.5 → 19.0 MiB
6000×4000 archival (not routed), 1024² 37.2 → 37.7 ms 42.1 → 42.3 ms unchanged

The machine had other load during some runs, so a few percent either way is noise. The peak saving is the coefficients, about 3 MiB on 1024²; the 12 MiB of planes remain, since the row sink is serial only.

Before building this, I simulated the scheduler from traced strip timings, and it predicted about 18 ms instead of about 28 ms for Tier-1 and the IDWT on the PLT 1024² region. The real figure for those two stages is about 30 → 24 ms (medians of 5 profiled runs: 30.3 and 23.6 ms). I think most of the gap is that components still decode one after another: each starts cold, since its first output row needs the first band of every level, and ends with a drain where few bands are left for the workers. Letting the next component's bands start while the current one drains would need every component's rings live at once (Tile layout, a few MiB more). That's a natural follow-up, and it's also what a parallel row sink would need.

Not covered

  • Classic code-blocks with more than one segment (per-pass termination or bypass) don't take the route, so they keep whole windows on the per-sub-band path. That includes the native encoder's multi-layer output and the Kakadu-encoded archival image above.
  • The tiled HT 4K image doesn't gain at 12 threads: its tiles are small, so there's little to overlap.

Tests

  • tests/parallel_bands.rs:
    • Bit-for-bit parity. pool_bands_match_whole_window_tier1_bit_for_bit compares against whole-window Tier-1 over the stage 3 cases, 8 regions and 3 reductions, on pools of 2, 4 and 12 threads, with the same decoded and skipped counts. It checks that every routed case reaches the pool on 2 threads, that multi-layer classic never does, and that the coefficient peak is lower on the large region.
    • Busy pool. pool_bands_finish_while_the_other_pool_thread_is_busy: the IDWT decodes every band itself.
    • Nested decodes. concurrent_pool_band_decodes_inside_the_pool_finish: 12 decodes inside 2- and 3-thread pools.
    • Error parity. pool_bands_reject_corrupt_code_blocks_as_serial_whole_window_tier1_does: 1,200 mutants, classic and HT, give the same outcome as the serial whole-window path.
    • Stranding. a_stranded_band_falls_back_to_whole_windows, described above.
  • Changed tests.
    • window_clipped_region_storage_matches_whole_code_blocks_bit_for_bit is unchanged; it found that rings could exceed the window, which is why they are now capped.
    • decode_region_budgets_coefficients_for_the_requested_region now checks the whole-window budget with whole_window_tier1. It also checks that banded storage, serial or on the pool, gets the 9000² region as far as the packets, because banded coefficients no longer grow with the region.
  • Differential fuzz of parallel against serial component-plane decodes in one build, on 192×192 mutants in all progression orders, classic and HT, 1 and 3 layers, with and without PLT. Release-build logging confirmed most components take the pool. On the final code: 1,920,000 parallel decodes in 12 concurrent processes, all identical to serial, about 505,000 of them failing, with no fallbacks. Earlier runs bring the total to about 2.7 million.

cargo xtask test (5,462 passed), clippy, clippy-strict, repo-lint and doc pass, and clippy is clean without default features. GPU validation hasn't been run.

…ed IDWT

A parallel region decode on the row-streamed IDWT decoded every
code-block of a component on the tile route, then ran the IDWT. Now the
route's code-block rows are bands that pool workers decode ahead of the
IDWT, which runs on the calling thread, so Tier-1 and the IDWT overlap
(stage 3b of frames-sg#133).

- Each sub-band holds a ring of band slots: three at the finest level,
  two at the others. A sub-band whose bands fit in fewer rows holds its
  whole window instead, each band in its own rows, so its bands can all
  be in flight at once.
- Workers take the band the IDWT reads first, from one queue, and return
  to the pool when none can start. The IDWT waits only for a band a
  worker is decoding, and decodes one nobody has started itself, so it
  never depends on a free pool thread.
- Every band decodes once, including bands the IDWT never reads. A
  failure, in planning or in any band, reruns whole-window Tier-1
  serially for its error.
- A wait that finds a band running with no worker active and no error
  gives up the pool, and the component decodes over whole windows; debug
  builds assert.
- Tiles the route doesn't decode, or that it would decode serially or per
  sub-band, keep their current paths.

A 1024x1024 region of an 18000x12000 RGB image decodes 3 to 11% faster
at 12 threads and 9 to 18% faster at 4, with about 3 MiB less peak heap.
Output is unchanged.
@jcwal1516

Copy link
Copy Markdown
Member

Awesome, thanks for getting this up! I ran the original native suite and it passed, 834 tests with 5 ignored. I found two things while checking the budget and some less usual region shapes.

The first is the memory accounting. plan_band_route charges the plan and clipped-block scratch, but decode_route starts a fresh budget for the executor records while those earlier allocations are still live. With a small generated image and a simulated retained-memory baseline, I could get the decode accepted when the combined charges were over the 512 MiB cap. I think those need to stay on the same budget through execution.

The other is freed_before, which walks from the start of the ring every time a band is released, under the shared mutex. On a very tall, narrow HT image, 32×65536 with 16×4 blocks, the latest run took about 159 ms at four threads against 13.4 ms on the whole-window path. I tried keeping the freed position in a cursor during review and got it down to about 48 ms. That removes most of the repeated scanning, though there's still enough scheduling overhead that these cheap bands may be better on the existing route.

Could you take those two here? My CPU optimization branch already includes this PR, so once you've fixed them here, I'll apply those fixes there too and rerun the tests.

I put the two reproducers in this test-only commit on review/pr310-repro-tests, built directly on this PR. Both generate their own input. The budget test is expected to fail on the current code; I checked that it passes when the planning budget is carried into the executor check. The timing report runs manually and checks the pixels without asserting a timing threshold.

Fetch the tests and run the reproducers

From your PR branch, fetch and cherry-pick the test commit:

git fetch https://github.com/frames-sg/j2k.git review/pr310-repro-tests
git cherry-pick FETCH_HEAD

Run the budget regression:

cargo test -p j2k-native --lib pool_bands_respect_the_combined_plan_and_executor_budget --locked -- --nocapture

Locally, it reports 276182 bytes of headroom, a 269496-byte route and a 13373-byte executor: the combined charges exceed the available budget by 6687 bytes, but the route returns Ok(true). The test calculates those sizes from the fixture; it doesn't allocate 512 MiB.

Run the timing report:

env -u J2K_PROFILE_STAGES cargo test -p j2k-native --profile gpu-quick --lib pool_bands_tall_region_timing_report --locked -- --ignored --nocapture --test-threads=1

gpu-quick is the existing optimized build profile; this test only uses the CPU. It reports seven interleaved measurements after warming both routes, with four workers and a fresh decoder context each time. Encoding and pixel comparisons are outside the timed sections. pool_components shows whether the default route used the pool-band executor, so the report also works if a fix selects a fallback for cheap bands.

jcwal1516 and others added 2 commits October 30, 2026 14:24
…ssions

Add an aggregate-budget regression for PR frames-sg#310 and an opt-in timing
report for tall HT regions. The budget test intentionally fails on the
current PR; the timing report checks pixels and prints interleaved
medians without asserting wall time.
…eap bands on the route

- The executor's records are charged to the budget that planning charged
  the route's plan, payload arena and clipped-block scratch to, since those
  stay live while the bands decode. A fresh budget for the records let a
  decode through when the combined charges were over the cap.
- Freeing a band advances a per-ring cursor instead of walking the ring
  from its start, and the drain frees the bands that finished from a list,
  with running and freed counts, instead of scanning every band. On a
  32x65536 region of 16x4 HT blocks at 4 threads the pool took 182 ms;
  it takes 49 ms.
- A component whose bands average less than 1024 of the route's work
  units (payload bytes plus an eighth of the samples) decodes over whole
  windows on the route: scheduling bands that cheap one by one costs more
  than overlapping Tier-1 with the IDWT saves. That 32x65536 region
  averages 88 and now decodes as fast as whole windows; 1024 px regions
  of the benchmark images average 2,000 to 15,000 and are unchanged.
@jcoyne

jcoyne commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and the reproducers! Both are fixed in fa5be637, on top of your test commit (cherry-picked as ac9717ee).

Budget. BandRoute now keeps the budget that plan_band_route charged the plan, the payload arena and every thread's clipped-block scratch to, and decode_route charges the executor's records to a copy of it instead of a fresh budget. Your pool_bands_respect_the_combined_plan_and_executor_budget passes.

Scheduling. freed_before wasn't the only scan, so the timings grew with the height: 5× at 8192 rows, 9× at 32768 and 13× at 65536. The drain also walked every band on each pass, to free the finished ones and to check whether any were running or all were freed. Now:

  • Each ring keeps the freed position as a cursor, advanced when a band is freed.
  • Finished bands go on a list that the drain frees from.
  • Running and freed counts replace the remaining scans.

That brought 32×65536 from 182 to 49 ms, about what you saw with the cursor, and the ratio stopped growing with height (about 3.3× to 3.6× at every size). What's left is per-band overhead: about 32,000 bands of one 16×4 block each, and roughly a microsecond of lock, queue and wake-up work per band, more than decoding the block.

So, as you suggested, cheap bands now stay on the existing route. plan_band_route returns WholeWindows when the component's strips average less than MIN_BAND_WORK = 1024, in the route's own work units (payload bytes plus an eighth of the samples). I measured the averages:

Average work per band
Your 16×4 HT fixture 88
Lowest benchmark case (tiled HT chroma) 2,025
Benchmark 1024² and 512² regions (PLT and no-PLT mosaics, HT, classic, a real SDR image) 2,000 to 15,000

As with MIN_PARALLEL_CODED_BYTES, unit tests use tiny fixtures, so the cutoff is a test-only field on the context (band_work_cutoff, zero by default). Your timing report and a new cheap_bands_decode_over_whole_windows test set it to the production value.

Your timing report on my machine (12-core M4 Pro, four threads), median of 7:

Region Whole windows Before the fix After the fix
32×8192 2.8 ms 8.9 ms 2.6 ms
32×32768 7.6 ms 61.4 ms 7.6 ms
32×65536 13.3 ms 182.3 ms 13.9 ms

The benchmark regions still take the pool and keep their gains. At 12 threads the PLT 1024² middle region is 33.1 → 29.4 ms against main, with identical hashes and the same 3 MiB lower peak as before.

cargo test -p j2k-native (967 passed, 6 ignored), clippy, clippy-strict, no-default clippy, repo-lint and doc pass.

Whole-window recovery starts from the storage budget, which excludes the parallel route plan and payload arena. Release the route after recording its statistics and before either fallback allocates replacement storage.
@jcwal1516
jcwal1516 merged commit 1ba2492 into frames-sg:main Oct 31, 2026
30 of 32 checks passed
@jcwal1516

Copy link
Copy Markdown
Member

Merged, thanks for the fixes and benchmark results! I added one small follow-up to release the parallel plan before the whole-window fallback allocates its buffers. The focused tests and CI passed, including CUDA and Metal validation.

I’ve also brought this into my CPU optimization branch. I’ll follow up on #311 with the BYPASS integration and combined ARM/x86 results. Thanks again!

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