Skip to content

Commit 4bf2c55

Browse files
matmenclaude
andcommitted
AVIF: decode in worker threads
From 4 megapixels on (by the item's ispe), threads start before the AV1 image is decoded, so they load meanwhile, and convert it to RGB in bands of 64 rows (planes and tables in shared memory, the pixels copied out); grids decode their tiles in parallel (at least 4 tiles per thread: each one warms the whole AV1 decoder up), the tiles' data in one shared buffer. avif.decode is async now and takes threads. A row function for 4:2:0/4:2:2 colors with the weighted chroma samples as tables (the same Float32 values) converts about 7% faster on one thread. The same pixels on 368 files (tests, av1-avif, libavif, Pillow and PureJsImage corpora) with 1 and 4 threads; two fixtures above the threshold (2048x2048, a 3x2 grid of 1024x704 tiles) with libavif's pixels. Ryzen 7 5800X, 8 threads: 3840x2160 4:2:0 466 -> 355 ms, 6400x2880 grid of 20 tiles 2.68 -> 1.12 s; 4:4:4 and 1080p unchanged (the AV1 decode dominates). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent e8888eb commit 4bf2c55

8 files changed

Lines changed: 214 additions & 82 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
instead of 640 ms lossy and 328 ms instead of 671 ms lossless, a 5120x2880 one in 568 ms instead of 1121 ms and
2525
487 ms instead of 1122 ms. Lossy frames no longer keep the coefficients of the whole frame (about 100 MB at 4K): each
2626
group is rendered once its passes are read.
27+
- AVIF decoding uses worker threads (`DecodeOptions.threads`) from 4 megapixels on: the conversion to RGB runs in
28+
bands of rows (started at the beginning, the threads load while the AV1 image is decoded) and the tiles of grids are
29+
decoded in parallel, with the same pixels. A 3840x2160 4:2:0 image decodes in 355 ms instead of 466 ms, a 6400x2880
30+
grid of 20 tiles in 1.12 s instead of 2.68 s (8 threads). 4:2:0 and 4:2:2 images convert about 7% faster on one
31+
thread too.
2732
- Lossless JPEG XLs shown as they are (integer samples, no filters or upsampling) become RGBA bytes right away instead
2833
of through float planes, in threads from 1 megapixel on, with an RCT of the colors undone in the same pass: the same
2934
pixels. A 3840x2160 image from `encodeJXL()` decodes in 304 ms instead of 379 ms with 8 threads, 548 ms instead of

‎scripts/avif-fixtures.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,17 @@ def alpha_item(plane):
294294
Item(2, b'grid', grid_data, [(ispe(124, 100), 0), (pixi(8, 1), 0), (auxc, 0)], [(b'dimg', [20, 21, 22, 23]), (b'auxl', [1])], hidden=True),
295295
*tiles, *alpha_tiles]))
296296

297+
# large enough for threads (4 megapixels; the tests decode with the default threads): a 4:2:0 image converted to RGB
298+
# in bands, a 3x2 grid of 1024x704 tiles for a 3072x1400 image, its tiles decoded in parallel
299+
write('threads', avif([image(encode([yuv(photo(2048, 2048), '420', 8)], '420', 8, ['--cq-level=63', '--cpu-used=6']), '420', 8, 2048, 2048)]))
300+
big_rgb, big_tiles = photo(3072, 1408), []
301+
for i in range(6):
302+
y, x = i // 3 * 704, i % 3 * 1024
303+
unit = encode([yuv(big_rgb[y:y + 704, x:x + 1024], '420', 8)], '420', 8, ['--cq-level=63', '--cpu-used=6'])[0]
304+
big_tiles.append(Item(10 + i, b'av01', unit, [(av1c(unit, '420', 8), 1), (ispe(1024, 704), 0), (pixi(8, 3), 0)], hidden=True))
305+
write('threads-grid', avif([Item(1, b'grid', bytes([0, 0, 1, 2]) + struct.pack('>HH', 3072, 1400), [(ispe(3072, 1400), 0), (pixi(8, 3), 0)],
306+
[(b'dimg', list(range(10, 16)))]), *big_tiles]))
307+
297308
# transformations: a 30x20 clean aperture 3 right of and 2 above the center, rotated by 90° anticlockwise, mirrored
298309
# top to bottom (applied in this order, the order of the properties)
299310
clap = box(b'clap', struct.pack('>8i', 30, 1, 20, 1, 3, 1, -2, 1))

‎src/codecs/avif.ts‎

Lines changed: 188 additions & 79 deletions
Large diffs are not rendered by default.

‎src/image.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,9 @@ export interface DecodeOptions {
213213
*/
214214
toneMapping?: ToneMapping;
215215
/**
216-
* At most this many worker threads decode in parallel (JPEG XLs, from 4 megapixels on: one per 2 megapixels at
217-
* most), `1` for none. Default: the processors available, at most 8 (1 where workers can't share memory)
216+
* At most this many worker threads decode in parallel (JPEG XLs from 4 megapixels on: one per 2 megapixels at
217+
* most; AVIFs from 4 megapixels on: the conversion to RGB, one per megapixel, and the tiles of grids), `1` for
218+
* none. Default: the processors available, at most 8 (1 where workers can't share memory)
218219
*/
219220
threads?: number;
220221
}
@@ -2262,7 +2263,7 @@ export class Image {
22622263
const { width, height, pixels } = decoded.embedded === 'png' ? await png.decode(decoded.data, maxPixels) : jpeg.decode(decoded.data, { maxPixels });
22632264
return { width, height, pixels };
22642265
};
2265-
if (ImageType.isAVIF(dataView)) return avif.decode(data, maxPixels, toneMapping);
2266+
if (ImageType.isAVIF(dataView)) return avif.decode(data, maxPixels, toneMapping, threads);
22662267
if (ImageType.isJXL(dataView)) return jxl.decode(data, maxPixels, toneMapping, threads);
22672268
if (ImageType.isBMP(dataView)) return bitmap(bmp.decode(data, maxPixels));
22682269
if (ImageType.isICO(dataView)) return bitmap(ico.decode(data, maxPixels));

‎src/util/worker.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// the module worker threads run (see threads.ts): tasks posted to it, their results posted back
22
import { searchRows, filterTrials, type SearchTask, type FilterTask } from '../codecs/av1/encode.ts';
3+
import { conversionChunks, tileChunks } from '../codecs/avif.ts';
34
import { varblockChunks } from '../codecs/jxl/acs-enc.ts';
45
import { maskBlurChunks, maskingChunks } from '../codecs/jxl/aq-enc.ts';
56
import { coefficientChunks, hfTokenChunks, xybChunks } from '../codecs/jxl/vardct-enc.ts';
@@ -8,6 +9,8 @@ import type { ChunkTask } from './threads.ts';
89

910
// the chunk tasks (see runChunks): a task's function of its chunks
1011
const chunkTasks: Record<string, (task: never) => (chunk: number) => unknown> = {
12+
'avif-convert': conversionChunks,
13+
'avif-tiles': tileChunks,
1114
'jxl-xyb': xybChunks,
1215
'jxl-masking': maskingChunks,
1316
'jxl-mask-blur': maskBlurChunks,

‎tests/avif.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const references = {
2929
'alpha': '75997458251525c3c06ec8ef2149a90e32726831f1a599b1356c658d98bc849d',
3030
'alpha-premultiplied': 'ad09ce797bed32479ad72523ab188a39441ebc85976a284ab3da121702b19b46',
3131
'grid': 'b6f5c3be81ac52daabe55e9b0f5a4f6e9a5f1b4adcf82c013cdcc2588d542f74',
32+
// (4 megapixels: in threads, if the runtime has them)
33+
'threads': '5a93b00f715ca686bd765c8cf1d428ff76eebc34a1b26c2462bceb43d9a0e770',
34+
'threads-grid': 'eb3acc450fae0a30f888b0e1fbe43d5b15a4eec9d6a3494d2e9c98c7897fc56a',
3235
'transforms': '38a9ade925431f685369468071feed32b90e3e2f5c855ea8040142ef1e0051cc',
3336
'sequence': '1b62b5829e6d56d35ff7f0cfa6b7fef6da9c5f85e58799ea5b679f2864eef125',
3437
};

‎tests/avif/threads-grid.avif‎

7.12 KB
Binary file not shown.

‎tests/avif/threads.avif‎

6.08 KB
Binary file not shown.

0 commit comments

Comments
 (0)