Skip to content

perf(graphics): portable SIMD inverse DWT (wide + SWAR)#1383

Draft
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 1 commit into
masterfrom
perf/rfx-dwt-portable-simd
Draft

perf(graphics): portable SIMD inverse DWT (wide + SWAR)#1383
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 1 commit into
masterfrom
perf/rfx-dwt-portable-simd

Conversation

@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor

Summary

On the WASM web client, frame decode dominates (~93% of frame time on a 1080p RemoteFX replay), and within decode the RFX inverse DWT was ~48% (the YCbCr→RGBA convert is already SIMD via yuv; the entropy/RLE stages are inherently sequential). This vectorizes the inverse DWT with the portable wide crate (i16x8), so the same code lowers to wasm simd128, x86 SSE/AVX, and ARM NEON — desktop and browser both benefit.

The encode path is unchanged.

How it stays bit-exact (no unsafe, no cfg split)

The lifting steps need i32 intermediates only for the averages. Overflow-free SWAR identities let the whole kernel stay in i16 lanes (no widen/narrow):

  • ceil_avg(a,b) = (a|b) - ((a^b)>>1)(a + b + 1) >> 1
  • floor_avg(a,b) = (a&b) + ((a^b)>>1)(a + b) >> 1

and (2x+1)>>1 == x / (x+x)>>1 == x simplify the first/last rows. Every other op is wrapping i16 arithmetic, identical to the old i32-intermediate-then-as i16 truncation.

Performance

1080p RemoteFX replay, headless Chromium, wasm release +simd128, 8-pass median:

inverse DWT decode (ms)
scalar (baseline) ~1598
portable wide SIMD ~985

→ inverse DWT ~2×, ~39% off the decode stage. (Absolute ms carry ~±15% machine-load noise; the ratio is stable. Per-frame this is a throughput win — decode was already within real-time budget.)

Correctness

Verified bit-exact three ways:

  • the replay-bench framebuffer CRC32 is unchanged,
  • the existing native DWT tests pass (so it's exact on x86 too, not just wasm),
  • an exhaustive check of the SWAR identities over all i16 × i16 pairs (0 mismatches).

Notes

  • wide is a single-user dep in ironrdp-graphics; chosen over std::simd (still nightly-only) and over per-arch intrinsics (one portable kernel vs three).
  • Reproducible bench branches: bench/draw-* (renderer) and the DWT measurements were taken on the replay-bench harness branch (the capture corpus is gitignored).

The RFX inverse DWT is ~half of the web client's frame-decode time (decode is
~93% of the frame on a 1080p RemoteFX replay). Replace the scalar inverse DWT
with a portable SIMD implementation using the `wide` crate (`i16x8`), which
lowers to wasm simd128, x86 SSE/AVX, and ARM NEON from one source.

Overflow-free SWAR average identities keep the kernel entirely in `i16` lanes
(no widen/narrow): `ceil_avg(a,b) = (a|b) - ((a^b)>>1)` and
`floor_avg(a,b) = (a&b) + ((a^b)>>1)`, with `(2x+1)>>1 == x` / `(x+x)>>1 == x`
simplifying the edge rows. Every other op is wrapping `i16` arithmetic, so the
output is bit-exact with the previous scalar code — no `unsafe`, no cfg split.
The encode path is unchanged.

Bit-exactness verified by the replay-bench framebuffer CRC, the existing native
DWT tests, and an exhaustive check of the SWAR identities over all i16 pairs.

Bench (1080p RemoteFX replay, wasm release +simd128): inverse DWT ~2x faster,
~39% off the decode stage (~1598 ms -> ~985 ms).

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good to me, do you have any concern to address before removing the draft status?

num-derive.workspace = true # TODO: remove
num-traits.workspace = true # TODO: remove
yuv = { version = "0.8", features = ["rdp"] }
wide = "0.7" # portable SIMD for the inverse DWT (wasm/x86/ARM); `std::simd` is still nightly-only

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Thank you for documenting the rationale. At some point we may migrate to std::simd, when it’s stabilized.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR accelerates the RemoteFX (RFX) decode hot path in ironrdp-graphics by replacing the scalar inverse DWT kernel with a portable-SIMD implementation using the wide crate, targeting wasm simd128 as well as native SIMD backends.

Changes:

  • Add portable SIMD helpers (i16x8) plus SWAR ceil/floor average primitives to keep the inverse DWT bit-exact without widening.
  • Rewrite inverse DWT horizontal and vertical passes to operate on 8-lane vectors and 8-column tiles.
  • Add wide as a direct dependency of ironrdp-graphics (and update Cargo.lock accordingly).

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 3 comments.

File Description
crates/ironrdp-graphics/src/dwt.rs Replaces scalar inverse DWT with portable SIMD (wide) horizontal/vertical passes and SWAR average helpers.
crates/ironrdp-graphics/Cargo.toml Adds wide = "0.7" dependency for portable SIMD.
Cargo.lock Locks the new wide dependency in the workspace lockfile.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +10 to +12
fn vld(s: &[i16], off: usize) -> i16x8 {
i16x8::from(<[i16; 8]>::try_from(&s[off..off + 8]).expect("off + 8 within bounds"))
}
Comment on lines +186 to +189
debug_assert!(
sw % 8 == 0 && sw <= MAX_SUBBAND_WIDTH,
"sw must be a multiple of 8 and <= MAX_SUBBAND_WIDTH"
);
Comment on lines +236 to +239
debug_assert!(
sw % 8 == 0 && sw <= MAX_SUBBAND_WIDTH,
"sw must be a multiple of 8 and <= MAX_SUBBAND_WIDTH"
);
/// Loads 8 contiguous `i16` from `s[off..]` into a vector. The caller guarantees `off + 8 <= s.len()`.
#[inline]
fn vld(s: &[i16], off: usize) -> i16x8 {
i16x8::from(<[i16; 8]>::try_from(&s[off..off + 8]).expect("off + 8 within bounds"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wide has an API for creating a SIMD from a slice. Use i16x8::from_slice_unaligned

@RRRadicalEdward Alex Yusiuk (RRRadicalEdward) Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, you can get subslice without repeating off: s[off..][..8]. IMHO, it's cleaner.
You can make use of this pattern in many places in this file.

Comment on lines +16 to +18
fn vst(s: &mut [i16], off: usize, v: i16x8) {
s[off..off + 8].copy_from_slice(&v.to_array());
}

@RRRadicalEdward Alex Yusiuk (RRRadicalEdward) Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn vst(s: &mut [i16], off: usize, v: i16x8) {
s[off..off + 8].copy_from_slice(&v.to_array());
}
fn vst(s: &mut [i16], off: usize, v: i16x8) {
s[off..off + 8].copy_from_slice(v.as_array());
}

No need to make an array. You can get a reference to inner data directly.

/// Ceil average `(a + b + 1) >> 1`, overflow-free (SWAR; arithmetic shift).
#[inline]
fn ceil_avg(a: i16x8, b: i16x8) -> i16x8 {
(a | b) - ((a ^ b) >> 1)

@RRRadicalEdward Alex Yusiuk (RRRadicalEdward) Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be beneficial to contribute to wide to add this operation directly there. There is already a specialized SIMD for that - _mm_avg_epu16, but wide doesn't have this API. So we need to use other SIMD operations to achieve that. If the API was present is wide, we could do it at the cost of only a single SIMD operation, which is faster.

I used to contribute to wide when optimizing IronVNC with SIMDs. The author of the library was open to contributions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants