Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ jobs:
# The README's figures are drawn by a script: they must be what it draws.
- run: python3 -B scripts/readme_figures.py && git diff --exit-code docs/images

msrv:
name: oldest Rust (1.87)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Cargo.toml's rust-version, which the READMEs promise: a newer language
# feature or library function fails here, not on a user's toolchain.
- name: Install Rust 1.87
run: |
rustup toolchain install 1.87 --profile minimal --no-self-update
rustup default 1.87
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- run: cargo check --workspace --all-targets --locked

wasm:
name: browser build (wasm32)
runs-on: ubuntu-latest
Expand Down
12 changes: 6 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Steps that upstream runs on the CPU stay on the CPU, and steps that upstream run
|---|---|
| `bitstream` | Pure Rust, GPU independent: headers, block layout, packet assembly, PWV1 files. Never panics on malformed input. This is the CPU stage. |
| `cpu` | Dequantisation and inverse DWT as plain sequential Rust, following `bitstream.md` literally. A test oracle and the yardstick for shader stages, not a product feature. |
| `color` | The YCbCr to RGB conversion: matrices, range handling and chroma siting, with a CPU implementation that the shader is tested against. |
| `color` | The YCbCr to RGB conversion: matrices, range handling and chroma siting, with a CPU implementation that the shader is tested against. And the encoder's RGB to Y'CbCr conversion in integers (`rgb_to_ycbcr8`, `ForwardMatrix`, `RgbOrder`, D65), which `gather_rgb.wgsl` matches to the bit. |
| `encode` | The encoder on the CPU, plain sequential Rust: forward DWT, quantisation, rate control and packing, the reference of the GPU encoder; and `packets`, which splits a frame at its blocks for a network. |
| `gpu` | wgpu pipelines and WGSL shaders for the GPU stages (cargo feature `gpu`, on by default). |

Expand Down Expand Up @@ -300,7 +300,7 @@ The gap to the default precision is the reference decoder's own FP16 storage. In

## GPU pipeline

`gpu::Decoder` decodes frames of one size and chroma format. All GPU memory is allocated when it is created and reused for every frame. The payload buffer is the one exception: it grows when a frame needs more room. The encoder's GPU pipeline, `gpu::Encoder`, is described under [Encoder](#on-the-gpu).
`gpu::Decoder` decodes frames of one size and chroma format. The memory every decode uses, the pyramid, the block offsets and the packed planes, is allocated when it is created and reused for every frame, and the payload buffer grows when a frame needs more room. What only some decodes need, the upload and read-back buffers, the RGB picture and concealment's copy of the coarsest level, is made by the first decode that needs it and then reused. The encoder's GPU pipeline, `gpu::Encoder`, is described under [Encoder](#on-the-gpu).

### Stages

Expand All @@ -323,7 +323,7 @@ The packed planes are copied to a mappable buffer and returned as a `Frame`. Whe
| block offsets (`array<u32>`) | word offset of every block, `u32::MAX` if missing | 49 KiB |
| pyramid (`array<f32>`) | the four bands of every level of every component, back to back | 63.6 MiB |
| packed planes (`array<u32>`) | 8-bit planes, four pixels per word, rows padded to words | 11.9 MiB |
| readback (`MAP_READ`) | copy of the packed planes for the CPU | 11.9 MiB |
| readback (`MAP_READ`) | copy of the packed planes for the CPU, created by the first decode that reads them back | 11.9 MiB |
| RGBA words (`array<u32>`) | one word per pixel, created on the first RGB decode | 31.6 MiB |
| RGB readback (`MAP_READ`) | copy of the RGBA words, likewise | 31.6 MiB |
| last coarsest level (`array<f32>`) | the coarsest level's bands of every component as the last decode left them, copied before a decode that conceals; created on the first (D66) | 383 KiB |
Expand All @@ -336,7 +336,7 @@ No GPU resource outlives a frame. The decoder's own buffers are created once (th

### Dequantisation

One workgroup per block index, found by a lookup in a table of the (at most 48) transmitted bands. Each invocation decodes the 8 values of one 4x2 subblock. If a block was not received, a value that comes through `workgroupUniformLoad` makes the whole workgroup write zeros and return, so the later barriers stay in uniform control flow.
One workgroup per block index, found by a lookup in a table of the (at most 48) transmitted bands. Each invocation decodes the 8 values of one 4x2 subblock. If a block was not received, a value that comes through `workgroupUniformLoad` makes the whole workgroup write zeros and return, so the later barriers stay in uniform control flow. In a frame drawn in part that the decoder conceals, the workgroup of a block of detail below the coarsest level returns without writing, and leaves the block as the last decode left it for `conceal.wgsl` (D66).

1. Sixteen invocations read the control word and scale byte of one 8x8 block each and compute its number of bit-plane bytes into workgroup memory.
2. After a barrier every invocation adds up the sizes before its own 8x8 block, and the sizes of the preceding subblocks of its block, to find its bit-planes, and decodes its magnitudes.
Expand Down Expand Up @@ -421,7 +421,7 @@ The wall-clock time exceeds the sum of the stages, because submitting work and n

### Portability

The shaders, the decoder's and the encoder's, use only what WebGPU guarantees: no subgroup operations, no 8-bit or 16-bit storage, no `f16`, no immediates (push constants), no storage textures. One shader is the exception, on a path of its own: on Metal, where the adapter offers it, `idwt_planes.wgsl` stores the planes into `r8unorm` storage textures (D52), and the portable copies stay the fallback; likewise the frames written where the shaders read them on Apple silicon need `MAPPABLE_PRIMARY_BUFFERS` (D53). Neither is asked for under strict WebGPU. All buffers are plain storage or uniform buffers and every workgroup stays within 256 invocations and 16 KiB. The encoder reads pictures in textures as sampled `r8unorm` and `rg8unorm` textures with `textureGather` and a non-filtering sampler, which WebGPU has without any feature. The encoder's rate control adds with 32-bit integer atomics, which WGSL has without any feature. Two tests hold this to account:
The shaders, the decoder's and the encoder's, use only what WebGPU guarantees: no subgroup operations, no 8-bit or 16-bit storage, no `f16`, no immediates (push constants), no storage textures. One shader is the exception, on a path of its own: on Metal, where the adapter offers it, `idwt_planes.wgsl` stores the planes into `r8unorm` storage textures (D52), and the portable copies stay the fallback; likewise the frames written where the shaders read them on Apple silicon need `MAPPABLE_PRIMARY_BUFFERS` (D53). Neither is asked for under strict WebGPU. All buffers are plain storage or uniform buffers and every workgroup stays within 256 invocations and 16 KiB. The encoder reads pictures in textures as sampled `r8unorm` and `rg8unorm` textures with `textureGather` and a non-filtering sampler, and pictures in RGB as sampled `rgba8unorm` and `bgra8unorm` textures with `textureLoad` (D65), which WebGPU has without any feature. The encoder's rate control adds with 32-bit integer atomics, which WGSL has without any feature. Two tests hold this to account:

- `tests/shaders.rs` validates the WGSL of every shader with naga using an empty capability set, without a GPU, and checks the entry points, the workgroup sizes and memory.
- `tests/gpu.rs` and `tests/gpu_encoder.rs` create a device with `InstanceFlags::STRICT_WEBGPU_COMPLIANCE` and the default limits and decode a vector on it, and encode pictures on it.
Expand Down Expand Up @@ -499,7 +499,7 @@ The page paces frames by the stream's frame rate, keeping the remainder of each

Without a file the page plays a demo stream it makes itself (`crates/emberwave-web/src/demo.rs`, decision D46): 60 pictures of 960x540 at 30 frames a second, two seconds that loop, of a glowing wavelet whose crests travel and embers that rise from it. The page draws each picture and encodes it with the CPU encoder within 40 kB, one picture at a time, and shows its progress in between. It then plays the PWV1 file this makes like any other. In headless Chrome on an Apple M3 Max the 60 pictures took about 2 s. A file opened or dropped meanwhile, or a sample, takes over, and `?play=NAME` starts that sample instead of the demo.

The player also decodes a stream packet by packet, as a client of a live stream receives it (#109, D55). `Player.open_stream` sets up a stream of a known size without a file, `push_packet` takes a packet, a start of frame header with blocks or blocks alone, in any order, `is_ready` says whether the frame can be drawn, whole or, if asked, the part that arrived, `draw_assembled` draws it through `Decoder::submit_assembled_to_texture`, which uploads through the queue and does not wait, and `drop_packets` lets go of what arrived of a frame and of its sequence number. The bitstream's sequence number has three bits: a packet one to three frames newer starts that frame; one four to seven frames newer, or one to four frames older, is ignored; one five to seven frames older passes for a newer frame, and one eight frames off for the current frame. So a transport keeps frame numbers of its own: it ignores packets of frames older than the one being assembled, and before it pushes the first packet of a newer frame it draws the current one if it is ready, then calls `drop_packets`, never after the push, which would drop that packet too. The colours of such a stream follow each frame's start of frame header unless `set_colors` sets the matrix and the range, as for upstream's encoder, which signals full range whatever the pictures are; the chroma siting follows the header still. A loaded file's colours, siting included, are those of its PWV1 header, for `draw_next` and `draw_assembled` alike. `draw_next`, which plays a loaded file, lets go of each frame it assembled, so that packets pushed after a switch to them start afresh. `packet_ends` says where a frame splits into packets at its blocks, as `emberwave_frame_packets` does in the C API, and `frames` and `frame` hand out the loaded file's frames that can be played. With "Play as packets" (`?packets`, and `?loss=0.05` for a share lost, from 0 to 1) the page plays its stream that way: it takes each frame from the player, cuts it into packets of up to 1200 or 8000 bytes (a larger block goes alone), shuffles them, drops the share chosen, and draws what arrived. A frame of which every block of the coarsest level and more than 90 percent of the blocks its start of frame header announced arrived is drawn with the lost finer blocks blank, and other frames are skipped, the last picture staying; `set_partial_share` sets another share than 90 percent, and the page offers 70 and 50 percent (`?partial=0.7`, D59). Until the header arrives the count is of all the picture's blocks, so a frame that lost its header is skipped unless its encoder left out few blocks. An encoder that leaves out empty blocks of the coarsest level, as upstream's does and emberwave's did before decision D58, leaves such frames nothing to draw in part. No loss reaches the next frame. In headless Chrome on the M3 Max the demo's frames took 38 packets each; with 5 % of them lost, about one frame in six was drawn whole, half in part and the rest skipped.
The player also decodes a stream packet by packet, as a client of a live stream receives it (#109, D55). `Player.open_stream` sets up a stream of a known size without a file, `push_packet` takes a packet, a start of frame header with blocks or blocks alone, in any order, `is_ready` says whether the frame can be drawn, whole or, if asked, the part that arrived, `draw_assembled` draws it through `Decoder::submit_assembled_to_texture`, which uploads through the queue and does not wait, and `drop_packets` lets go of what arrived of a frame and of its sequence number. The bitstream's sequence number has three bits: a packet one to three frames newer starts that frame; one four to seven frames newer, or one to four frames older, is ignored; one five to seven frames older passes for a newer frame, and one eight frames off for the current frame. So a transport keeps frame numbers of its own: it ignores packets of frames older than the one being assembled, and before it pushes the first packet of a newer frame it draws the current one if it is ready, then calls `drop_packets`, never after the push, which would drop that packet too. The colours of such a stream follow each frame's start of frame header unless `set_colors` sets the matrix and the range, as for upstream's encoder, which signals full range whatever the pictures are; the chroma siting follows the header still. A loaded file's colours, siting included, are those of its PWV1 header, for `draw_next` and `draw_assembled` alike. `draw_next`, which plays a loaded file, lets go of each frame it assembled, so that packets pushed after a switch to them start afresh. `packet_ends` says where a frame splits into packets at its blocks, as `emberwave_frame_packets` does in the C API, and `frames` and `frame` hand out the loaded file's frames that can be played. With "Play as packets" (`?packets`, and `?loss=0.05` for a share lost, from 0 to 1) the page plays its stream that way: it takes each frame from the player, cuts it into packets of up to 1200 or 8000 bytes (a larger block goes alone), shuffles them, drops the share chosen, and draws what arrived. A frame of which every block of the coarsest level and more than 90 percent of the blocks its start of frame header announced arrived is drawn with the lost finer blocks blank, and other frames are skipped, the last picture staying; `set_partial_share` sets another share than 90 percent, and the page offers 70 and 50 percent (`?partial=0.7`, D59). With `set_concealment(true)`, off by default, the lost finer blocks keep the last frame's detail where the picture is still instead of going blank (D66). Until the header arrives the count is of all the picture's blocks, so a frame that lost its header is skipped unless its encoder left out few blocks. An encoder that leaves out empty blocks of the coarsest level, as upstream's does and emberwave's did before decision D58, leaves such frames nothing to draw in part. No loss reaches the next frame. In headless Chrome on the M3 Max the demo's frames took 38 packets each; with 5 % of them lost, about one frame in six was drawn whole, half in part and the rest skipped.

`web/bench.html` times the decoding on the device it runs on, a phone included, with the code of `crates/emberwave-web/src/bench.rs`. It makes test streams at 1080p, 1440p and 4K, four pictures each of a synthetic scene encoded by the CPU encoder in the page at the budgets of the native benchmarks, then decodes them in turn on the player's path, to RGB in GPU memory, and waits for each frame through `onSubmittedWorkDone` before the next: 300 frames after at least 30 and a second of warm-up, as `emberwave-cli bench --output rgb` does natively. Then it decodes them again 60 a second, the warm-up too, on a schedule that the page's timers keep, as the benchmark app does natively at a stream's pace (#95). wgpu's callback for that has to be `Send`, which a JavaScript function is not, so it sets a flag and wakes a future that `wasm_bindgen_futures` turns into the page's promise. Browsers coarsen `performance.now()`, to a millisecond in some, so the mean comes from the time of the whole run and the median and 99th percentile, which are per frame, are as fine as the timer, whose step the report states. The times include what WebGPU adds over native wgpu: the browser's checks, its process boundary and the delay of the report. A GPU error the browser reports during a size fails that size rather than give times for frames that were not decoded, a run during which the page was hidden is marked as not valid, and the benchmark lets go of its device when the run ends.

Expand Down
2 changes: 1 addition & 1 deletion BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ scripts/bench_lavapipe.sh # optional: software Vulkan in a Linux conta
- **Statistics.** Mean, median and 99th percentile (nearest rank) over 600 frames, ten passes over each 60-frame stream. With only 60 frames the 99th percentile would be the slowest frame.
- **Upstream** is the commit `testdata/README.md` pins; the tables were made at 79a3232, which has neither of upstream's fixes to its encoder of 2026-10-26; those left its decoder's output and its encoder's time as they were (decision D56). It is timed by `refgen bench` (`scripts/refgen/refgen.mm`) around upstream's C API, in the same way: give the decoder the frame's packet, encode the decode into a command buffer, commit it and wait. Its output, three 8-bit planes, stays in GPU memory, so it compares with emberwave's `--output yuv`.
- **Encoding from textures** starts from an R8 texture per plane, made from each picture before the measurement (`bench-encode --gpu --textures`, `Encoder::encode_textures`), and ends with the frame's bytes in CPU memory like the encoding from CPU memory below.
- **Encoding from BGRA** starts from the same pictures converted to BGRA, as most captures hold them, in CPU memory (`bench-encode --gpu --rgb`, `Encoder::encode_rgb`) or in a BGRA texture each, made before the measurement (`--rgb --textures`, `TexturePicture::Rgb`), and ends like the others.
- **Encoding from BGRA** starts from the same pictures converted to BGRA, as most captures hold them, in CPU memory (`bench-encode --gpu --rgb`, `Encoder::encode_rgb`) or in a BGRA texture each, made before the measurement (`--rgb --textures`, `TexturePicture::Rgb`), and ends like the others. Its table comes with the next full run: the results below are older.
- **Encoding** goes from the picture's 8-bit planes in CPU memory to the frame's bytes in CPU memory: `Encoder::encode` for emberwave, `emberwave-cli bench-encode`; `pyrowave_encoder_encode_cpu`, counting the packets, which waits for the GPU, and `pyrowave_encoder_packetize` for upstream, `refgen bench-encode`. Both upload the planes and read the frame back within the time. The pictures are 20 frames of the same scene, made by `scripts/gen_bench_data.sh --sources`, at the budgets of the decoder's streams of the same size; both tools read them into memory first and take them in turn, so that encodes follow each other as decodes do. Upstream's encoder is timed at its default precision, an FP16 wavelet pyramid, and at FP32, which is what emberwave's uses. It hides its command buffer, so only its total is known. The CPU encoder, at a quarter of a second for a 4K frame, is timed over 60 frames instead of 600.

## Results
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```

CI runs two more lints, for code that builds only without the `gpu` feature or only for the browser, and a check on Rust 1.87. The lints need the `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`):

```sh
cargo clippy -p emberwave-core --no-default-features --all-targets -- -D warnings
cargo clippy --target wasm32-unknown-unknown -p emberwave-web -p emberwave-core -- -D warnings
```

## Tests

| Suite | Where | What it checks |
Expand Down
9 changes: 5 additions & 4 deletions crates/emberwave-core/src/cpu/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ impl Concealer {
pub fn decode(&mut self, frame: &FrameView<'_>, partial: bool) -> Result<DecodedFrame, Error> {
let layout = frame.layout();
let mut bands = dequant::dequantize(frame)?;
if let (true, Some((last_layout, last))) = (partial, &self.last)
&& last_layout == layout
{
conceal(frame, &mut bands, last);
// Not a let chain: those need Rust 1.88, and emberwave builds on 1.87.
if let (true, Some((last_layout, last))) = (partial, &self.last) {
if last_layout == layout {
conceal(frame, &mut bands, last);
}
}
let kept = bands.clone();
let planes =
Expand Down
Loading
Loading