Skip to content

Size the streaming wave by its staging, not by a chunk count [#33] - #339

Merged
iderex merged 1 commit into
mainfrom
issue/33
Aug 9, 2026
Merged

Size the streaming wave by its staging, not by a chunk count [#33]#339
iderex merged 1 commit into
mainfrom
issue/33

Conversation

@iderex

@iderex iderex commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #33.

The lever, taken

kWaveChunks = 64 submitted Silesia's 3239 chunks in ~51 serial waves. #29
measured the steady-state wall at ~230 ms against a ~12 ms device-resident
decode and a ~4 ms compressed H2D and attributed the ~213 ms residual to the
submission count by exclusion. The wave is now sized by the staging it costs.

Three properties, and each of the two bounds is load-bearing:

  • Byte budget, 384 MiB. Charged against the LARGEST chunk in the call, not
    the mean, so peak staging holds for any distribution. A batch of tiny chunks
    with one huge one cannot talk the sizer into a wave that allocates far past
    the budget, which a mean would allow.
  • Chunk ceiling, 4096. Bounds the metadata staging on its own. Without it
    a batch of zero-length chunks divides the budget by one and asks for a wave
    of kMaxBatchChunks entries.
  • Floor of one. A single chunk larger than the whole budget still decodes,
    alone in its wave. The budget sizes a wave; it is not a capacity limit on
    the ABI, and turning it into one would be a new refusal nobody asked for.

Silesia becomes one submission on the device-output path (source staging only)
and two on the host-output path (source plus the destination arena).

The measurement

RTX 3080, driver 560.94, pinned container
(nvidia/cuda:12.6.2-devel-ubuntu24.04), 2026-08-09. Both binaries built
once up front and then alternated
, baseline and patched, pass after pass -
the protocol perf pass 4 established after an A/B/A that rebuilt between arms
read a drift as an effect. 3 warmup + 30 runs per number, --gpu-stream-ctx
over the hash-pinned Silesia corpus.

Pass Baseline device out Patched device out
1 232.5 ms 27.2 ms
2 235.6 ms 27.7 ms
3 235.0 ms 27.8 ms
4 235.5 ms 28.2 ms
5 235.4 ms 27.6 ms
median 235.4 ms 27.7 ms
Pass Baseline host out Patched host out
1 377.5 ms 155.4 ms
2 391.1 ms 155.0 ms
3 374.3 ms 173.9 ms
median 377.5 ms 155.4 ms

Device output 8.5x (0.90 -> 7.65 GB/s), won 5 of 5. Host output 2.4x
(0.56 -> 1.36 GB/s), won 3 of 3. There is no overlap between the two arms'
ranges on either path, so nothing here rests on a median alone.

The acceptance asked for a number approaching the ~16 ms floor. 27.7 ms leaves
a ~12 ms residual rather than the ~213 ms one. That residual is not
isolated and no claim is made about what it is.

What the change does not fix

Host output stays ~5x the device path, and the wave is not the reason: its D2H
targets pageable caller memory one chunk at a time, so it is 3239 synchronous
copies at any wave size. Removing the submission cost around them leaves them.
That is issues #133 and #135, and this PR touches neither.

The price

Peak staging rises from ~4 MB per wave to at most the budget: device memory
(compressed source plus, for host output, the destination arena) within
384 MiB by construction, pinned host memory within the same bound for the
source. For Silesia that is ~102 MB pinned and ~102 MB device on the
device-output path. Cold (first decode on a fresh context) moves the other
way and is reported rather than dropped: 241.5 -> 152.5 ms on the device path,
so the larger allocation costs more to make and still finishes ahead of the
baseline's submission count.

Both figures go into docs/BENCHMARKS.md beside the tables, and
docs/BENCHMARK-METHODOLOGY.md's streaming rows are updated with the date and
the numbers they replace named.

Correctness

cmake -B build-cuda -DCUDEC_ENABLE_CUDA=ON && cmake --build build-cuda -j
ctest --test-dir build-cuda --no-tests=error --output-on-failure
100% tests passed, 0 tests failed out of 36

stream_twin is the property that matters here and it is green: the same
input decoded on a reused context - after any number of prior decodes,
including one that grew the staging - is bit-identical to a fresh-context
decode. Chunk results do not depend on which wave carried them, so the wave
size is not an axis of the output; that is by construction rather than by a
test, and it is stated as such.

Host-only build unchanged:

cmake -B build-host2 && cmake --build build-host2 -j
[100%] Built target example_decode_frame

Formatting:

npx prettier@3 --check "**/*.{md,yml,yaml}"
All matched files use Prettier code style!

Not covered

No Compute Sanitizer sweep. src/stream.cpp is host-side stream choreography
and adds no device code, but the sweep is owed on any change that touches the
decode path and it cannot be produced on this route - the four tools cannot
attach to the device here, which issue #258 holds. Stated rather than left
looking answered.

No second person read this change. The evidence above stands in place of one.

The streaming path submitted the batch in fixed 64-chunk waves, so Silesia
was ~51 serial submissions. Issue #29 measured the steady-state wall at
~230 ms against a ~12 ms device-resident decode and a ~4 ms compressed H2D
and attributed the ~213 ms residual to those submissions by exclusion. This
takes that lever.

kWaveChunks is gone. The wave is the largest number of chunks whose staging
fits a 384 MiB budget, capped at 4096 chunks, and the budget is charged
against the LARGEST chunk in the call rather than the mean - so peak staging
stays inside it for any distribution, a batch of tiny chunks with one huge one
included. The chunk ceiling is not redundant with the budget: a batch of
zero-length chunks divides the budget by one and would otherwise ask for a
wave of kMaxBatchChunks metadata entries. A single chunk larger than the whole
budget still decodes, alone in its wave, because the budget sizes a wave and
is not a capacity limit on the ABI.

Silesia becomes one submission on the device-output path and two on the
host-output path. Measured 2026-08-09 on the RTX 3080 in the pinned container,
both binaries built once and then alternated, 3 warmup + 30 runs per number:

  device out  235.4 -> 27.7 ms median, 8.5x, won 5 of 5 passes
  host out    377.5 -> 155.4 ms median, 2.4x, won 3 of 3 passes

27.7 ms against the ~16 ms floor the issue named. Host output does not reach
the same place and the wave is not why: its readback targets pageable caller
memory one chunk at a time, which is 3239 synchronous copies at any wave size,
and that is #133 and #135.

The price is peak staging, from ~4 MB per wave to at most the budget - ~102 MB
pinned and ~102 MB device for Silesia on the device path. It is in
docs/BENCHMARKS.md beside the numbers rather than in the small print.

The suite stays green, stream_twin included: the same input decoded on a
reused context is bit-identical to a fresh-context decode.
@iderex
iderex merged commit e611ac7 into main Aug 9, 2026
7 checks passed
@iderex
iderex deleted the issue/33 branch August 9, 2026 21:25
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.

Streaming context wall is per-wave submission, not allocation (raise wave granularity)

1 participant