Skip to main content

webp_decode_bench/
webp_decode_bench.rs

1use std::fs;
2use std::hint::black_box;
3use std::io::{Error as IoError, ErrorKind, Result as IoResult};
4use std::path::PathBuf;
5use std::time::{Duration, Instant};
6
7use webp_rust::decoder::{
8    decode_animation_webp, decode_lossless_webp_to_rgba, decode_lossy_webp_to_rgba,
9    decode_lossy_webp_to_yuv,
10};
11
12const DEFAULT_BATCHES: usize = 7;
13const DEFAULT_BATCH_MILLIS: u64 = 250;
14const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
15const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
16
17#[derive(Debug)]
18struct Options {
19    batches: usize,
20    batch_duration: Duration,
21    output: Option<PathBuf>,
22}
23
24#[derive(Debug)]
25struct Measurement {
26    case_name: &'static str,
27    format: &'static str,
28    width: usize,
29    height: usize,
30    pixels_per_decode: usize,
31    output_hash: u64,
32    median_ns: f64,
33    p95_ns: f64,
34}
35
36#[derive(Debug, Clone, Copy)]
37struct CaseMetadata {
38    case_name: &'static str,
39    format: &'static str,
40    width: usize,
41    height: usize,
42    pixels_per_decode: usize,
43    output_hash: u64,
44}
45
46fn invalid_input(message: impl Into<String>) -> IoError {
47    IoError::new(ErrorKind::InvalidInput, message.into())
48}
49
50fn parse_usize(value: Option<String>, name: &str) -> IoResult<usize> {
51    let value = value.ok_or_else(|| invalid_input(format!("missing value for {name}")))?;
52    value
53        .parse::<usize>()
54        .map_err(|_| invalid_input(format!("invalid value for {name}: {value}")))
55}
56
57fn parse_options() -> IoResult<Options> {
58    let mut options = Options {
59        batches: DEFAULT_BATCHES,
60        batch_duration: Duration::from_millis(DEFAULT_BATCH_MILLIS),
61        output: None,
62    };
63    let mut args = std::env::args().skip(1);
64    while let Some(arg) = args.next() {
65        match arg.as_str() {
66            "--batches" => {
67                options.batches = parse_usize(args.next(), "--batches")?;
68                if options.batches == 0 {
69                    return Err(invalid_input("--batches must be greater than zero"));
70                }
71            }
72            "--batch-ms" => {
73                let millis = parse_usize(args.next(), "--batch-ms")?;
74                if millis == 0 {
75                    return Err(invalid_input("--batch-ms must be greater than zero"));
76                }
77                options.batch_duration = Duration::from_millis(millis as u64);
78            }
79            "--output" => {
80                let path = args
81                    .next()
82                    .ok_or_else(|| invalid_input("missing value for --output"))?;
83                options.output = Some(PathBuf::from(path));
84            }
85            _ => return Err(invalid_input(format!("unknown argument: {arg}"))),
86        }
87    }
88    Ok(options)
89}
90
91fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
92    for &byte in bytes {
93        hash ^= u64::from(byte);
94        hash = hash.wrapping_mul(FNV_PRIME);
95    }
96    hash
97}
98
99fn hash_usize(hash: u64, value: usize) -> u64 {
100    hash_bytes(hash, &value.to_le_bytes())
101}
102
103fn calibrate_iterations<F>(batch_duration: Duration, decode: &mut F) -> IoResult<usize>
104where
105    F: FnMut() -> IoResult<usize>,
106{
107    let mut iterations = 1usize;
108    loop {
109        let started = Instant::now();
110        for _ in 0..iterations {
111            black_box(decode()?);
112        }
113        let elapsed = started.elapsed();
114        if elapsed >= batch_duration {
115            return Ok(iterations);
116        }
117        let elapsed_nanos = elapsed.as_nanos().max(1);
118        let target_nanos = batch_duration.as_nanos();
119        let scale = target_nanos.div_ceil(elapsed_nanos) as usize;
120        iterations = iterations.saturating_mul(scale.clamp(2, 16));
121    }
122}
123
124fn measure<F>(options: &Options, metadata: CaseMetadata, mut decode: F) -> IoResult<Measurement>
125where
126    F: FnMut() -> IoResult<usize>,
127{
128    let iterations = calibrate_iterations(options.batch_duration, &mut decode)?;
129    let mut samples = Vec::with_capacity(options.batches);
130    for _ in 0..options.batches {
131        let started = Instant::now();
132        for _ in 0..iterations {
133            black_box(decode()?);
134        }
135        samples.push(started.elapsed().as_nanos() as f64 / iterations as f64);
136    }
137    samples.sort_by(f64::total_cmp);
138    let median_ns = samples[samples.len() / 2];
139    let p95_index = ((samples.len() - 1) * 95).div_ceil(100);
140    Ok(Measurement {
141        case_name: metadata.case_name,
142        format: metadata.format,
143        width: metadata.width,
144        height: metadata.height,
145        pixels_per_decode: metadata.pixels_per_decode,
146        output_hash: metadata.output_hash,
147        median_ns,
148        p95_ns: samples[p95_index],
149    })
150}
151
152fn main() -> IoResult<()> {
153    let options = parse_options()?;
154    let lossy = include_bytes!("../samples/sample.webp");
155    let lossless = include_bytes!("../samples/sample_lossless.webp");
156    let animation = include_bytes!("../samples/sample_animation.webp");
157
158    let lossy_rgba = decode_lossy_webp_to_rgba(lossy)
159        .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
160    let lossy_rgba_hash = hash_bytes(FNV_OFFSET, &lossy_rgba.rgba);
161    let lossy_rgba_width = lossy_rgba.width;
162    let lossy_rgba_height = lossy_rgba.height;
163
164    let lossy_yuv = decode_lossy_webp_to_yuv(lossy)
165        .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
166    let mut lossy_yuv_hash = hash_bytes(FNV_OFFSET, &lossy_yuv.y);
167    lossy_yuv_hash = hash_bytes(lossy_yuv_hash, &lossy_yuv.u);
168    lossy_yuv_hash = hash_bytes(lossy_yuv_hash, &lossy_yuv.v);
169    lossy_yuv_hash = hash_usize(lossy_yuv_hash, lossy_yuv.y_stride);
170    lossy_yuv_hash = hash_usize(lossy_yuv_hash, lossy_yuv.uv_stride);
171
172    let lossless_rgba = decode_lossless_webp_to_rgba(lossless)
173        .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
174    let lossless_rgba_hash = hash_bytes(FNV_OFFSET, &lossless_rgba.rgba);
175    let lossless_width = lossless_rgba.width;
176    let lossless_height = lossless_rgba.height;
177
178    let decoded_animation = decode_animation_webp(animation)
179        .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
180    let mut animation_hash = hash_usize(FNV_OFFSET, decoded_animation.loop_count as usize);
181    for frame in &decoded_animation.frames {
182        animation_hash = hash_usize(animation_hash, frame.duration);
183        animation_hash = hash_bytes(animation_hash, &frame.rgba);
184    }
185    let animation_width = decoded_animation.width;
186    let animation_height = decoded_animation.height;
187    let animation_pixels = animation_width * animation_height * decoded_animation.frames.len();
188
189    let measurements = [
190        measure(
191            &options,
192            CaseMetadata {
193                case_name: "lossy_rgba",
194                format: "VP8",
195                width: lossy_rgba_width,
196                height: lossy_rgba_height,
197                pixels_per_decode: lossy_rgba_width * lossy_rgba_height,
198                output_hash: lossy_rgba_hash,
199            },
200            || {
201                let image = decode_lossy_webp_to_rgba(black_box(lossy))
202                    .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
203                Ok(image.rgba.len())
204            },
205        )?,
206        measure(
207            &options,
208            CaseMetadata {
209                case_name: "lossy_yuv",
210                format: "VP8",
211                width: lossy_yuv.width,
212                height: lossy_yuv.height,
213                pixels_per_decode: lossy_yuv.width * lossy_yuv.height,
214                output_hash: lossy_yuv_hash,
215            },
216            || {
217                let image = decode_lossy_webp_to_yuv(black_box(lossy))
218                    .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
219                Ok(image.y.len() + image.u.len() + image.v.len())
220            },
221        )?,
222        measure(
223            &options,
224            CaseMetadata {
225                case_name: "lossless_rgba",
226                format: "VP8L",
227                width: lossless_width,
228                height: lossless_height,
229                pixels_per_decode: lossless_width * lossless_height,
230                output_hash: lossless_rgba_hash,
231            },
232            || {
233                let image = decode_lossless_webp_to_rgba(black_box(lossless))
234                    .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
235                Ok(image.rgba.len())
236            },
237        )?,
238        measure(
239            &options,
240            CaseMetadata {
241                case_name: "animation",
242                format: "ANIM",
243                width: animation_width,
244                height: animation_height,
245                pixels_per_decode: animation_pixels,
246                output_hash: animation_hash,
247            },
248            || {
249                let image = decode_animation_webp(black_box(animation))
250                    .map_err(|error| IoError::new(ErrorKind::InvalidData, error))?;
251                Ok(image.frames.len())
252            },
253        )?,
254    ];
255
256    let mut csv = String::from(
257        "case,format,width,height,pixels_per_decode,output_hash,median_ns,p95_ns,mpixels_per_second\n",
258    );
259    for measurement in measurements {
260        let mpixels_per_second =
261            measurement.pixels_per_decode as f64 * 1_000.0 / measurement.median_ns;
262        csv.push_str(&format!(
263            "{},{},{},{},{},{:016x},{:.0},{:.0},{:.3}\n",
264            measurement.case_name,
265            measurement.format,
266            measurement.width,
267            measurement.height,
268            measurement.pixels_per_decode,
269            measurement.output_hash,
270            measurement.median_ns,
271            measurement.p95_ns,
272            mpixels_per_second,
273        ));
274    }
275    print!("{csv}");
276    if let Some(path) = options.output {
277        if let Some(parent) = path.parent() {
278            fs::create_dir_all(parent)?;
279        }
280        fs::write(path, csv)?;
281    }
282    Ok(())
283}