Skip to content

Commit 6291540

Browse files
perf(graphics): portable SIMD inverse DWT (wide + SWAR) (#1383)
## 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`](https://crates.io/crates/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).
1 parent 079b484 commit 6291540

4 files changed

Lines changed: 187 additions & 92 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ironrdp-graphics/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ byteorder = "1.5" # TODO: remove
2626
num-derive.workspace = true # TODO: remove
2727
num-traits.workspace = true # TODO: remove
2828
yuv = { version = "0.8", features = ["rdp"] }
29+
wide = "0.7" # portable SIMD for the inverse DWT (wasm/x86/ARM); `std::simd` is still nightly-only
2930

3031
[dev-dependencies]
3132
bmp = "0.5"

crates/ironrdp-graphics/src/dwt.rs

Lines changed: 132 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
11
use ironrdp_pdu::utils::SplitTo as _;
2+
use wide::i16x8;
3+
4+
/// Max RFX sub-band width. The 8-wide tiling and the `[_; MAX_SUBBAND_WIDTH + 1]` scratch in the
5+
/// inverse passes rely on `subband_width` being one of {8, 16, 32}.
6+
const MAX_SUBBAND_WIDTH: usize = 32;
7+
8+
/// Loads 8 contiguous `i16` from `s[off..]` into a vector. The caller guarantees `off + 8 <= s.len()`.
9+
#[inline]
10+
fn vld(s: &[i16], off: usize) -> i16x8 {
11+
i16x8::from_slice_unaligned(&s[off..][..8])
12+
}
13+
14+
/// Stores a vector into `s[off..]`.
15+
#[inline]
16+
fn vst(s: &mut [i16], off: usize, v: i16x8) {
17+
s[off..][..8].copy_from_slice(v.as_array_ref());
18+
}
19+
20+
/// Ceil average `(a + b + 1) >> 1`, overflow-free (SWAR; arithmetic shift).
21+
#[inline]
22+
fn ceil_avg(a: i16x8, b: i16x8) -> i16x8 {
23+
(a | b) - ((a ^ b) >> 1)
24+
}
25+
26+
/// Floor average `(a + b) >> 1`, overflow-free (SWAR; arithmetic shift).
27+
#[inline]
28+
fn floor_avg(a: i16x8, b: i16x8) -> i16x8 {
29+
(a & b) + ((a ^ b) >> 1)
30+
}
231

332
pub fn encode(buffer: &mut [i16], temp_buffer: &mut [i16]) {
433
encode_block::<32>(&mut *buffer, temp_buffer);
@@ -109,104 +138,123 @@ fn dwt_horizontal<const SUBBAND_WIDTH: usize>(mut buffer: &mut [i16], dwt: &[i16
109138
}
110139

111140
pub fn decode(buffer: &mut [i16], temp_buffer: &mut [i16]) {
112-
decode_block(&mut buffer[3840..], temp_buffer, 8);
113-
decode_block(&mut buffer[3072..], temp_buffer, 16);
114-
decode_block(&mut *buffer, temp_buffer, 32);
141+
decode_block::<8>(&mut buffer[3840..], temp_buffer);
142+
decode_block::<16>(&mut buffer[3072..], temp_buffer);
143+
decode_block::<32>(&mut *buffer, temp_buffer);
115144
}
116145

117-
fn decode_block(buffer: &mut [i16], temp_buffer: &mut [i16], subband_width: usize) {
118-
inverse_horizontal(buffer, temp_buffer, subband_width);
119-
inverse_vertical(buffer, temp_buffer, subband_width);
146+
fn decode_block<const SUBBAND_WIDTH: usize>(buffer: &mut [i16], temp_buffer: &mut [i16]) {
147+
inverse_horizontal::<SUBBAND_WIDTH>(buffer, temp_buffer);
148+
inverse_vertical::<SUBBAND_WIDTH>(buffer, temp_buffer);
120149
}
121150

122-
// Inverse DWT in horizontal direction, results in 2 sub-bands in L, H order in output buffer
123-
// The 4 sub-bands are stored in HL(0), LH(1), HH(2), LL(3) order.
124-
// The lower part L uses LL(3) and HL(0).
125-
// The higher part H uses LH(1) and HH(2).
126-
fn inverse_horizontal(mut buffer: &[i16], temp_buffer: &mut [i16], subband_width: usize) {
127-
let total_width = subband_width * 2;
128-
let squared_subband_width = subband_width.pow(2);
129-
130-
let mut hl = buffer.split_to(squared_subband_width);
131-
let mut lh = buffer.split_to(squared_subband_width);
132-
let mut hh = buffer.split_to(squared_subband_width);
133-
let mut ll = buffer;
134-
135-
let (mut l_dst, mut h_dst) = temp_buffer.split_at_mut(squared_subband_width * 2);
136-
137-
for _ in 0..subband_width {
138-
// Even coefficients
139-
l_dst[0] = i32_to_i16_possible_truncation(i32::from(ll[0]) - ((i32::from(hl[0]) + i32::from(hl[0]) + 1) >> 1));
140-
h_dst[0] = i32_to_i16_possible_truncation(i32::from(lh[0]) - ((i32::from(hh[0]) + i32::from(hh[0]) + 1) >> 1));
141-
for n in 1..subband_width {
142-
let x = n * 2;
143-
l_dst[x] =
144-
i32_to_i16_possible_truncation(i32::from(ll[n]) - ((i32::from(hl[n - 1]) + i32::from(hl[n]) + 1) >> 1));
145-
h_dst[x] =
146-
i32_to_i16_possible_truncation(i32::from(lh[n]) - ((i32::from(hh[n - 1]) + i32::from(hh[n]) + 1) >> 1));
147-
}
148-
149-
// Odd coefficients
150-
for n in 0..subband_width - 1 {
151-
let x = n * 2;
152-
l_dst[x + 1] = i32_to_i16_possible_truncation(
153-
i32::from(hl[n] << 1) + ((i32::from(l_dst[x]) + i32::from(l_dst[x + 2])) >> 1),
154-
);
155-
h_dst[x + 1] = i32_to_i16_possible_truncation(
156-
i32::from(hh[n] << 1) + ((i32::from(h_dst[x]) + i32::from(h_dst[x + 2])) >> 1),
157-
);
158-
}
159-
let n = subband_width - 1;
160-
let x = n * 2;
161-
l_dst[x + 1] = i32_to_i16_possible_truncation(i32::from(hl[n] << 1) + i32::from(l_dst[x]));
162-
h_dst[x + 1] = i32_to_i16_possible_truncation(i32::from(hh[n] << 1) + i32::from(h_dst[x]));
163-
164-
hl = &hl[subband_width..];
165-
lh = &lh[subband_width..];
166-
hh = &hh[subband_width..];
167-
ll = &ll[subband_width..];
168-
169-
l_dst = &mut l_dst[total_width..];
170-
h_dst = &mut h_dst[total_width..];
151+
// Inverse DWT horizontal pass (portable `wide`). The 4 sub-bands are stored HL(0), LH(1), HH(2),
152+
// LL(3); the L band reconstructs from LL+HL, the H band from LH+HH. Each row is reconstructed by
153+
// `horizontal_band`.
154+
fn inverse_horizontal<const SUBBAND_WIDTH: usize>(buffer: &[i16], temp_buffer: &mut [i16]) {
155+
let sw = SUBBAND_WIDTH;
156+
let tw = sw * 2;
157+
let ssw = sw * sw;
158+
let (hl, rest) = buffer.split_at(ssw);
159+
let (lh, rest) = rest.split_at(ssw);
160+
let (hh, ll) = rest.split_at(ssw);
161+
let (l_dst, h_dst) = temp_buffer.split_at_mut(ssw * 2);
162+
163+
for r in 0..sw {
164+
let row = r * sw;
165+
horizontal_band::<SUBBAND_WIDTH>(&ll[row..][..sw], &hl[row..][..sw], &mut l_dst[r * tw..][..tw]);
166+
horizontal_band::<SUBBAND_WIDTH>(&lh[row..][..sw], &hh[row..][..sw], &mut h_dst[r * tw..][..tw]);
171167
}
172168
}
173169

174-
fn inverse_vertical(mut buffer: &mut [i16], mut temp_buffer: &[i16], subband_width: usize) {
175-
let total_width = subband_width * 2;
170+
// One band of the inverse horizontal pass, vectorized along `n` (`wide`): `low`/`high` are the two
171+
// source subband rows (len `sw`), `dst` the reconstructed row (len `2*sw`, even/odd interleaved).
172+
// Bit-exact with the former scalar code (SWAR averages + wrapping `i16` arithmetic). `sw` is a
173+
// multiple of 8 and ≤ 32.
174+
fn horizontal_band<const SUBBAND_WIDTH: usize>(low: &[i16], high: &[i16], dst: &mut [i16]) {
175+
const {
176+
assert!(
177+
SUBBAND_WIDTH == 8 || SUBBAND_WIDTH == 16 || SUBBAND_WIDTH == 32,
178+
"subband width must be one of 8, 16, or 32"
179+
)
180+
};
181+
let sw = SUBBAND_WIDTH;
182+
183+
// Left-shifted copy so `high_pad[n] == high[n-1]` (and `high[0]` for n == 0): lets the even
184+
// pass load the left neighbour contiguously instead of shuffling.
185+
let mut high_pad = [0i16; MAX_SUBBAND_WIDTH + 1];
186+
high_pad[0] = high[0];
187+
high_pad[1..sw].copy_from_slice(&high[0..sw - 1]);
188+
189+
// `ev`/`od` padded so the odd pass can read `ev[n+1]` at n = sw-1 in bounds.
190+
let mut ev = [0i16; MAX_SUBBAND_WIDTH + 1];
191+
let mut od = [0i16; MAX_SUBBAND_WIDTH + 1];
192+
193+
// EVEN: ev[n] = low[n] - ceil_avg(high[n-1], high[n]).
194+
let mut n = 0;
195+
while n < sw {
196+
vst(&mut ev, n, vld(low, n) - ceil_avg(vld(&high_pad, n), vld(high, n)));
197+
n += 8;
198+
}
176199

177-
for _ in 0..total_width {
178-
buffer[0] = i32_to_i16_possible_truncation(
179-
i32::from(temp_buffer[0]) - ((i32::from(temp_buffer[subband_width * total_width]) * 2 + 1) >> 1),
200+
// ODD: od[n] = (high[n] << 1) + floor_avg(ev[n], ev[n+1]).
201+
let mut n = 0;
202+
while n < sw {
203+
vst(
204+
&mut od,
205+
n,
206+
(vld(high, n) << 1) + floor_avg(vld(&ev, n), vld(&ev, n + 1)),
180207
);
208+
n += 8;
209+
}
210+
// n = sw-1 has no right neighbour.
211+
od[sw - 1] = i32_to_i16_possible_truncation((i32::from(high[sw - 1]) << 1) + i32::from(ev[sw - 1]));
181212

182-
let mut l = temp_buffer;
183-
let mut lh = &temp_buffer[(subband_width - 1) * total_width..];
184-
let mut h = &temp_buffer[subband_width * total_width..];
185-
let mut dst = &mut *buffer;
186-
187-
for _ in 1..subband_width {
188-
l = &l[total_width..];
189-
lh = &lh[total_width..];
190-
h = &h[total_width..];
191-
192-
// Even coefficients
193-
dst[2 * total_width] =
194-
i32_to_i16_possible_truncation(i32::from(l[0]) - ((i32::from(lh[0]) + i32::from(h[0]) + 1) >> 1));
195-
196-
// Odd coefficients
197-
dst[total_width] = i32_to_i16_possible_truncation(
198-
i32::from(lh[0] << 1) + ((i32::from(dst[0]) + i32::from(dst[2 * total_width])) >> 1),
199-
);
213+
// INTERLEAVE: dst[2n] = ev[n], dst[2n+1] = od[n].
214+
for n in 0..sw {
215+
dst[2 * n] = ev[n];
216+
dst[2 * n + 1] = od[n];
217+
}
218+
}
200219

201-
dst = &mut dst[2 * total_width..];
220+
// Inverse DWT vertical pass, vectorized over 8 contiguous columns per step (portable `wide`).
221+
// Bit-exact with the former scalar code: `(2*x+1)>>1 == x` and `(x+x)>>1 == x` simplify the
222+
// first/last rows, the averages use the overflow-free SWAR `ceil_avg`/`floor_avg`, and every other
223+
// op is wrapping `i16` arithmetic (identical to i32-intermediate-then-truncate).
224+
// Precondition: `subband_width` is a multiple of 8 and <= MAX_SUBBAND_WIDTH (for the 8-wide tiling).
225+
fn inverse_vertical<const SUBBAND_WIDTH: usize>(buffer: &mut [i16], temp_buffer: &[i16]) {
226+
const {
227+
assert!(
228+
SUBBAND_WIDTH == 8 || SUBBAND_WIDTH == 16 || SUBBAND_WIDTH == 32,
229+
"subband width must be one of 8, 16, or 32"
230+
)
231+
};
232+
let sw = SUBBAND_WIDTH;
233+
let tw = sw * 2;
234+
235+
let mut cb = 0;
236+
while cb < tw {
237+
// Row 0: L0 - ((H0*2 + 1) >> 1) == L0 - H0.
238+
vst(buffer, cb, vld(temp_buffer, cb) - vld(temp_buffer, cb + sw * tw));
239+
240+
for k in 1..sw {
241+
let l = vld(temp_buffer, cb + k * tw);
242+
let h = vld(temp_buffer, cb + (sw + k) * tw);
243+
let lh = vld(temp_buffer, cb + (sw - 1 + k) * tw);
244+
245+
let even = l - ceil_avg(lh, h);
246+
vst(buffer, cb + k * 2 * tw, even);
247+
248+
let d0 = vld(buffer, cb + (k - 1) * 2 * tw);
249+
vst(buffer, cb + (2 * k - 1) * tw, (lh << 1) + floor_avg(d0, even));
202250
}
203251

204-
dst[total_width] = i32_to_i16_possible_truncation(
205-
i32::from(lh[total_width] << 1) + ((i32::from(dst[0]) + i32::from(dst[0])) >> 1),
206-
);
252+
// Final odd row: (lhN << 1) + ((d0 + d0) >> 1) == (lhN << 1) + d0.
253+
let lhn = vld(temp_buffer, cb + (2 * sw - 1) * tw);
254+
let dl = vld(buffer, cb + (2 * sw - 2) * tw);
255+
vst(buffer, cb + (2 * sw - 1) * tw, (lhn << 1) + dl);
207256

208-
temp_buffer = &temp_buffer[1..];
209-
buffer = &mut buffer[1..];
257+
cb += 8;
210258
}
211259
}
212260

fuzz/Cargo.lock

Lines changed: 32 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)