Recursive AI

Performance

All oneimage docs

OneImage is faster than ImageSharp 3.1.12 on every task in the benchmark set except one that is a tie, and one where ImageSharp does less work (CMYK, below). It is not faster than every native wrapper, and on peak memory it has one exception and several ties. Drawing and clipping against ImageSharp.Drawing have one exception of their own (below), and flattening a layered document at the default thread limit has another (below). This page prints all of it: the table, the ties, the losses and the method.

Every number here comes from the reports in bench/results/, measured on a shared workstation (see Method). Treat them as local measurements, not as promises for your hardware.

Results at a glance

Time is the BenchmarkDotNet mean for one operation, in milliseconds. Peak is the growth of the process's resident set during the operation, in MiB, as the median of three runs with the run-to-run spread (max − min) in brackets. A time within 5% of ImageSharp's is a tie. Native wrappers are SkiaSharp (skia), NetVips (netvips) and Magick.NET (magick) through their .NET packages; the column shows the fastest of the three.

One OneImage row per task is shown: its fastest idiomatic one-call path to the stated output, writing into a caller buffer where the competitors reuse pooled storage. Every row of every library is in the raw reports.

Bar chart: how many times faster OneImage is than ImageSharp on each task, log scale, higher is better; ties and slower tasks are marked.

The chart is drawn from the same table by tools/make-hero-chart.py. Ratios there come from the unrounded report values, so they can differ in the last digit from a division of the rounded milliseconds below.

TaskOneImage msImageSharp msTime vs ImageSharpFastest native wrapperOneImage time ÷ fastest nativePeak MiB: OneImagePeak: ImageSharpPeak: NetVipsPeak vs ImageSharp
thumb-jpeg37.547.01.25× fastermagick 40.30.93×2.2 (±0.2)1.2 (±0.1)64.4higher (exception)
thumb-webp46.965.41.39× fastermagick 46.01.02×3.0 (±0.1)9.5 (±0.1)64.2lower
png-to-webp (natural)79.11511.519.11× fasternetvips 155.10.51×41.9 (±4.0)86.8 (±1.4)121.6lower
png-to-webp (photo)90.51025.211.33× fastermagick 157.60.57×41.9 (±4.0)86.8 (±1.4)121.6lower
png-to-webp (screenshot)95.8236.52.47× fastermagick 162.30.59×41.9 (±4.0)86.8 (±1.4)121.6lower
jpeg-decode41.277.21.87× fasternetvips 102.30.40×0.5 (±0.0)3.5 (±0.1)360.6lower
jpeg-encode87.4100.61.15× fasternetvips 55.31.58×5.3 (±3.2)4.1 (±0.8)67.3tie (within the run-to-run spread)
png-decode (natural)11.439.13.43× fasterskia 19.60.58×0.2 (±0.1)0.3 (±0.0)90.3lower
png-decode (photo)18.322.21.21× fasterskia 21.90.83×0.2 (±0.1)0.3 (±0.0)90.3lower
png-decode (screenshot)2.94.21.45× fasterskia 4.20.71×0.2 (±0.1)0.3 (±0.0)90.3lower
png-encode (natural)219.61064.34.85× fasternetvips 96.02.29×11.2 (±1.1)33.6 (±0.6)24.1lower
png-encode (photo)52.3318.66.09× fasternetvips 96.70.54×11.2 (±1.1)33.6 (±0.6)24.1lower
png-encode (screenshot)50.895.01.87× fasternetvips 39.81.27×11.2 (±1.1)33.6 (±0.6)24.1lower
webp-decode (lossy)16.931.01.83× fasterskia 17.90.94×2.3 (±0.3)2.9 (±0.1)162.8lower
webp-decode (lossless)24.968.22.74× fasterskia 35.00.71×2.3 (±0.3)2.9 (±0.1)162.8lower
webp-encode (lossless-2000x1500)146.81107.17.54× fasterskia 101.41.45×24.2 (±0.5)97.1 (±0.7)73.1lower
webp-encode (lossy-2000x1500)101.6590.55.81× fastermagick 162.30.63×24.2 (±0.5)97.1 (±0.7)73.1lower
webp-encode (lossy-400x300)5.423.74.39× fastermagick 7.00.76×24.2 (±0.5)97.1 (±0.7)73.1lower
gif-decode2.36.12.65× fasterskia 1.02.23×0.3 (±0.0)0.4 (±0.0)-lower
gif-encode13.316.81.26× fastermagick 70.30.19×1.2 (±0.1)2.0 (±1.1)-lower
gif-resize40.447.11.17× fastermagick 802.40.05×2.4 (±0.3)2.6 (±0.0)81.6lower
tiff-multipage-decode52.3620.311.87× fasternetvips 26.61.96×2.5 (±0.2)54.7 (±0.1)85.1lower
tiff-encode44.4134.63.03× fastermagick 68.50.65×28.5 (±0.3)32.1 (±0.6)40.3lower
bmp-decode0.40.71.75× fasterskia 1.20.32×0.1 (±0.0)0.1 (±0.0)-tie
bmp-encode2.84.91.75× fastermagick 7.10.39×7.0 (±0.4)21.6 (±0.8)-lower
upscale-2x6.312.31.95× fasternetvips 9.70.64×0.5 (±0.1)0.5 (±0.1)40.7tie
watermark4.94.9tienetvips 61.90.08×0.3 (±0.0)0.5 (±0.1)183.8lower
cmyk-jpeg-to-srgb243.4148.31.64× slower (ImageSharp skips the ICC conversion)netvips 252.00.97×2.2 (±0.4)1.3 (±0.1)441.1higher (ImageSharp skips the ICC conversion)
jpeg-decode-rgba43.479.41.83× fasterskia 146.40.30×0.5 (±0.0)0.7 (±0.1)-lower

Reading the table

  • Ties are ties. bmp-decode and upscale-2x are ties on peak memory (0.1 vs 0.1 MiB and 0.5 vs 0.5 MiB, both within their spreads). watermark is a tie on time (4.9 ms against 4.9 ms). Neither is a win.

  • jpeg-encode peak is a tie within its spread. OneImage 5.3 MiB (spread ±3.2) against ImageSharp 4.1 MiB (spread ±0.8): the 1.2 MiB difference is smaller than OneImage's own run-to-run spread. The gate report's stricter 5% rule lists it as a miss; we do not call it a win, and we do not claim it is lower. Time is a win, 87.4 against 100.6 ms.

  • The one exception: thumb-jpeg uses 1.0 MiB more peak working memory than ImageSharp, 2.2 vs 1.2 MiB. The time is a win. The gap is about 1 MiB; five repeats before the last changes gave the same 1.5–2.5 MiB range, so it is not new. Both are tiny next to NetVips's 64 MiB for the same task.

  • CMYK JPEG to sRGB is slower than ImageSharp, and that comparison is not like for like. ImageSharp does not apply the embedded ICC profile; OneImage, NetVips and Magick.NET do. The fair bar is the libraries that convert: OneImage (243.4 ms, 2.2 MiB peak) is faster than NetVips (252.0 ms, 441.1 MiB) and Magick.NET (1015.9 ms, 159 MiB). The colour error shows what skipping the conversion costs: mean CIEDE2000 against an ICC-correct reference is 0.190 for OneImage, 0.885 for NetVips, 0.889 for Magick.NET, 0.909 for SkiaSharp and 11.329 for ImageSharp (max 27.193).

    OneImage's number is lower than the others' because it applies black point compensation by default, as Photoshop does; the others do not unless asked. Against the LittleCMS result (Magick.NET) it sits 0.935 mean / 2.764 max ΔE00 away by default and 0.062 / 1.443 with blackPointCompensation: false.

  • Peak memory is the table's last column. OneImage is lower than ImageSharp on every task except the ones named above (ties, jpeg-encode, thumb-jpeg and the CMYK row), and far below NetVips's on every streaming task.

Where OneImage is slower than a native wrapper

The gate asks for thumbnail and codec tasks within 1.5× of the fastest native wrapper. These are the rows that are not:

TaskOneImageFastestRatio
tiff-multipage-decode, default degree52.3 msnetvips 26.6 ms1.96×
png-encode (natural)219.6 msnetvips 96.0 ms2.29×
gif-decode2.3 msskia 1.0 ms2.23×
jpeg-encode87.4 msnetvips 55.3 ms1.58×

Notes:

  • TIFF multi-page. OneImage limits a single call to min(4, cores) threads by default, so a server does not oversubscribe its cores; NetVips uses every core. With the per-call degree raised the same read takes 51.4 ms at 8 threads and 46.5 ms on all cores (1.93× and 1.75× NetVips). The raised rows are shown for information; the default row is the one that counts. The 10 strips of a page are the units of parallel work (an LZW stream cannot be split), so four threads need three rounds per page and more than ten cannot help. Each strip costs about 1.3 ms of LZW decoding and 0.06 ms of predictor undo, and a copy-out of about 0.25 ms per strip comes on top. The row and these figures were re-measured on the worker after the LZW and predictor work (only these rows; the peak columns are from the earlier full run). Same worker, same configuration, baseline and new build alternated three times under load 2 to 11 from other jobs: the gate row went from 60.3 to 54.6 ms (means; best runs 59.3 and 51.4 ms). On a local run of the same two builds (another job was running) the read went from 188 to 160 ms at one thread and from 61 to 51 ms at four. The gap to NetVips is still about 2×; the floor is LZW decoding in safe code. Direct whole-frame read. A whole-frame read of a TIFF of 1 Mpixel or more (CopyPixelsTo, ToArray) no longer goes through 128×128 tiles: strips and tiles are handed out one at a time to the call's threads, each decoded into pooled memory and converted row by row straight into its own part of the caller's buffer, so the pixels are copied once instead of three times (block, band, tile, caller). Output is byte-identical (hash-compared over the test corpora at degrees 1, 4 and 16, with AVX2 and all intrinsics off). Same worker (Ryzen 9 7950X3D, 32 logical cores, .NET 10.0.13, AVX-512 off), before and after, one run each, other jobs on the host raised the load to 4-5 during the "after" run: the gate row 49.9 → 46.9 ms and allocation 965 → 92 KB; with the per-call degree raised to 8 42.7 → 32.0 ms, and to all cores 46.9 → 27.1 ms (NetVips 26.3 ms on all cores). At the default degree the 10 strips of a page still need three rounds, so the LZW decode, not the copy, sets the time. Reads under 1 Mpixel, region reads and frames whose strip is over 16 MiB of raw samples keep the tile path, which caches blocks for later reads.
  • Resize. The thumbnail rows are where resize cost shows: thumb-jpeg is 37.5 ms against NetVips's 49.5 ms and thumb-webp 46.9 against 55.1, and 2× upscale is 6.3 ms against 9.7 ms. Those rows are decode-bound (the resize runs on a 1/8-size decode). A downscale of an already decoded 24 MP RGBA image is where NetVips's dedicated resize is ahead. Measured on the worker at the default degree of 4 (AVX2, minimum of 40 runs, load 4-7; base and new alternated three times): 24 MP to 400 px went from 11.5 to 8.8 ms, a 3× reduction (6000 to 2000) from 30.0 to 25.3 ms and a 2× upscale from 5.35 to 4.85 ms. NetVips measured 6.2-7.4 ms and 17.5-18.1 ms on the first two with every core, so the gap that remains is 1.2-1.5×; it comes from the per-call degree, not from CPU time (a local count shows NetVips using about as much CPU as we do on the first row and 2.5× as much on the second). The resize benchmark category (--anyCategories resize) runs these rows.
  • png-encode. Only the natural-content input is beyond 1.5×; the photo and screenshot inputs take 0.54× and 1.27× of NetVips's time.
  • gif-decode is 1.3 ms behind SkiaSharp on a 50-frame 500×500 file.
  • jpeg-encode uses optimized Huffman tables by default (smaller files), which costs extra work. The gap to NetVips shrank from 2.72× to 1.58× after the last fixes and is a goal, not a gate; the encoder pass described under "JPEG encode pass" closed it (0.96×).

JPEG entropy decoding

The thumbnail row is bound by Huffman decoding, which cannot be spread over cores without restart markers. The decoder resolves most AC symbols (code and magnitude bits) with one lookup in a 12-bit table, refills its bit buffer once per symbol without branching on the fill level, and skips the AC data of any component that only contributes its DC coefficient at the requested scale (at 1/8 of a 4:2:0 image that is the luma, two thirds of the blocks). Output is byte-identical to the earlier decoder.

Same code, same worker (Ryzen 9 7950X3D, 32 logical CPUs, .NET 10.0.402, AVX-512 off, shared with other jobs: load ranged from 1.4 to 12 over the four runs, so read the differences, not the digits). Gate harness rows, mean ms:

TaskBeforeAfterImageSharp (same run)Peak MiB before → after (ImageSharp)
thumb-jpeg38.5 / 40.028.2 / 27.347.4-49.52.3 → 2.4 (1.3)
jpeg-decode56.6* / 44.858.7* / 42.280.1-119.50.5 → 0.7 (3.6)
jpeg-decode-rgba44.9 / 47.098.2* / 44.586.7-112.40.5 → 0.6 (0.7)
cmyk-jpeg-to-srgb235.8 / 275.8243.9 / 303.0149.7-200.0 (skips ICC)2.0 → 2.1 (1.3)
tiff-multipage-decode138.8* / 73.671.5 / 87.3591.6-637.91.8 → 2.0 (54.8)

Each cell shows two runs; * marks a run during a load spike that moved ImageSharp's row by the same factor. The rows other than thumb-jpeg do not change beyond that spread: full-size 4:2:0 decoding was bound by the transform thread (about 38 ms for 24 MP), not by Huffman decoding (see the next section). The thumb-jpeg peak stays the published exception (about 1 MiB over ImageSharp). Decoder only on a desktop host (one core for entropy decoding plus the transform thread, best of 30, a 6000x4000 4:2:0 file of 5.8 MB): 1/8 scale 38.6 to 28.3 ms, and the scan decoder is the whole of that time in both (37.9 and 28.4 ms inside the decoder); 24 MP CMYK file of 16.5 MB, full size 111 to 90 ms. Progressive full-size decoding stays dominated by refinement scans (about 205 ms for a noisy 24 MP file against 218 before).

JPEG encode pass

Encoding got faster with byte-identical output (the same bytes at degrees 1, 4 and 16, with AVX-512 off, AVX2 off and intrinsics off, for quality 30/75/90/100 × 4:4:4/4:2:2/4:2:0/4:4:0/gray × optimized/standard tables × baseline/progressive, at 0.1 MP and 4.6 MP).

  • RGB to YCbCr is an SSSE3/SSE4.1 multiply-add kernel and the chroma averaging is portable 128-bit code, each with its scalar loop as the reference. Quantized blocks are put into zigzag order by placing only the non-zero values.
  • Without restart markers, every worker takes the next MCU row and does all its work (convert, downsample, transform, count or code), so there is no barrier between stages. Symbol counts, including the DC differences across row boundaries, are gathered in the same pass that transforms. When coding, a row needs only the last DC of the row before it, which its owner publishes right after quantizing. Finished rows are appended to the output in order by whichever worker is free. Memory is the same as before: the per-worker row buffers and one tile row of pixels.

Measured on the worker (Ryzen 9 7950X3D, 32 logical CPUs, .NET 10.0.13, AVX-512 off, default degree 4, load 1.0-5.0 before, during and after both runs, no other benchmark running):

RowBeforeAfterImageSharp 3.1.12NetVips
jpeg-encode 24 MP q90 4:2:0, optimized (BenchmarkDotNet)82.8 ms51.7 ms101.7 ms53.3 ms
same, standard tables46.4 ms30.7 ms
gate report jpeg-encode86.8 ms50.4 ms101.0 ms52.4 ms
gate peak working memory4.9 (±1.4) MiB3.1 (±0.9) MiB4.1 (±1.3) MiB
3 MP q90 4:2:0, optimized7.31 ms5.18 ms15.8 ms7.56 ms

JPEG full-size transform split

Full-size decoding of a sequential 4:2:0 file was bound by one thread doing inverse DCT, chroma upsampling, color conversion and sink writes (about 38 ms of 24 MP) while entropy decoding took 31. The transform is now two stages, each on its own thread beside the entropy decoder, with four plane-ring slots so a row is transformed while its predecessor is emitted. Per-call thread use rises from two to three (one more with three or more cores); on two cores the entropy thread does the inverse DCT. Output is byte-identical at every stage count, scale, region and hardware level (SHA-256 over the test corpus plus generated 4:2:0, 4:2:2, 4:4:0, 4:4:4 and progressive files at 1, 1/2, 1/4, 1/8, with and without hardware intrinsics).

Desktop host, 6000x4000 file, best of 20 into a null sink, AVX2 only:

FileOne threadEntropy + transform+emitThree threads
4:2:0 q90, 8.9 MB78 ms64 ms43 ms (was 43)
4:4:4 q90, 9.4 MB94 ms82 ms55 ms (was 53)
4:2:0 q90, 2.1 MB (smooth)50 ms35 ms20 ms (was 34)
4:4:4, 2.1 MB (smooth)62 ms55 ms31 ms (was 39)

Noisy photographs are entropy-bound (43 ms is the Huffman time), so the split helps where the transform was the longer stage: images with fewer bits per pixel, which includes most web photographs. Progressive files are bound by their coefficient scans (110-280 ms) and move by under 5%.

Worker rows (Ryzen 9 7950X3D, 32 logical CPUs, .NET 10.0.402 SDK with runtime 10.0.13, AVX-512 off, shared: load 1.2 to 5), mean ms, two paired runs of before / after, ImageSharp alongside:

TaskBeforeAfterImageSharp
jpeg-decode55.2 / 43.831.8 / 31.781.7-97.1
jpeg-decode-rgba55.8 / 44.931.3 / 33.286.9-97.8
thumb-jpeg34.8 / 27.727.6 / 29.347.8-50.1
cmyk-jpeg-to-srgb419* / 257 / 237 / 274262* / 291 / 252 / 255150-157 (skips ICC)
tiff-multipage-decode93.8 / 60.278 / 118 / 61.5 / 60.1599-735
  • a run under load above 6 that moved ImageSharp by the same factor. The thumb-jpeg, CMYK and TIFF rows do not use the split path or do not change beyond their spread; peaks are unchanged (jpeg-decode 1 MiB, cmyk 2 MiB, thumb-jpeg 2 MiB).

JPEG restart intervals

Baseline JPEGs that carry restart markers (many cameras and phones write one interval per MCU row) are entropy-decoded on several threads. Each restart interval starts with fresh DC predictors and a flushed bit buffer, so whole intervals decode independently: the calling thread cuts the compressed scan into work units of whole intervals (about one MCU row each), workers decode them into a deeper coefficient ring, and finished units are handed on as complete MCU rows, in order. The worker count is the per-call degree (default min(4, cores)). Only sequential single-scan Huffman files of 1 MP or more with at least two work units take this path; files without restart markers, progressive and arithmetic files, and files whose intervals are few and huge decode as before, at the same speed.

The output is identical to the single-threaded decoder at every scale, degree and hardware path. A damaged file behaves the same too: a worker runs the same per-MCU loop over the same bytes, and when a unit fails the rows before the failing MCU are delivered first and the earliest failure is the one thrown, so a request that those rows complete still succeeds. Peak working memory grows by the deeper ring (about 3.5 MB for a 6000-pixel-wide 4:2:0 frame; the ring is capped at 16 MiB) plus the compressed bytes of the units in flight.

Measured on the shared worker (Ryzen 9 7950X3D, 32 logical CPUs, .NET 10.0.402, DOTNET_EnableAVX512=0, default degree 4; machine load 1-4 around the runs, no other benchmark running during the runs). A 24 MP 4:2:0 q90 file written by this library with restart interval 375 (one MCU row) and the same picture without restarts, BenchmarkDotNet mean (allocated):

Rowno restarts, beforeno restarts, afterrestarts, beforerestarts, afterImageSharp 3.1.12NetVips
full size, caller's Rgb24 buffer31.8 ms (52 KB)32.4 ms (52 KB)32.3 ms25.5 ms (54 KB)79.2 ms98.0 ms
1/8 (Resize(750))27.4 ms (1147 KB)27.3 ms27.0 ms9.0 ms (1150 KB)46.6 ms (TargetSize)38.9 ms (shrink=8)
thumbnail 400 px27.8 ms (360 KB)27.6 ms27.1 ms9.0 ms (376 KB)

The gate rows are unchanged (their files have no restart markers): jpeg-decode 32.2 vs 32.7 ms, jpeg-decode-rgba 32.4 vs 32.0 ms, thumb-jpeg 27.6 vs 27.5 ms. At full size the decode is now bound by the transform and delivery threads (about 24 ms), not by entropy decoding, so scales 1/2 and 1/4, which are transform-bound, do not change. The "before" runs started at load 3.9 (falling to 1.6) and the "after" runs at load 1.0; the two share the same machine and settings but not the same minute.

Progressive JPEG decoding

Progressive JPEGs (common on the web) used to decode several times slower than baseline ones, because their refinement scans were read one coefficient at a time. Two changes, both with output identical to before at every scale, degree and hardware path (984 decodes hashed per setting: default, AVX2 off, intrinsics off):

  • Refinement scans keep a 64-bit mask of the nonzero positions of each block, so the correction bits between two symbols are read at once and applied without branches. The coefficient-by-coefficient reader stays as the reference.
  • The scans of a progressive Huffman file of 1 MP or more decode side by side, each with its own reader, waiting only for the rows of earlier scans that touch the same coefficients; finished rows go to the transform while the last scan is still running. Errors are ranked by scan order, so a damaged file fails with the same exception as before, though it may deliver some rows first.

Measured on the worker (Ryzen 9 7950X3D, 32 logical CPUs, .NET 10.0.13, DOTNET_EnableAVX512=0, default degree 4, load 0.4 to 1.6), 24 MP files:

RowBeforeAfterImageSharp 3.1.12NetVips
noisy, full size389.0 ms68.5 ms432.0 ms404.2 ms
smooth, full size199.0 ms62.3 ms209.7 ms257.4 ms
noisy, 1/8 scale38.5 ms12.8 ms396.8 ms335.8 ms
smooth, 1/8 scale35.1 ms12.7 ms182.7 ms185.0 ms

Baseline rows are unchanged (jpeg-decode 32.5 to 31.8 ms, thumb-jpeg 27.3 to 26.8 ms). The last luma refinement scan is now the critical path.

WebP encode memory

The lossy encoder held a 4-bytes-per-pixel copy of the frame, whole source and reconstructed YUV planes, and every macroblock's 400 quantized levels (800 bytes) until the file was written, and joined its output through several doubling buffers. It now keeps:

  • the source YUV planes (1.5 bytes per pixel), filled strip by strip from the frame, so no RGBA copy exists (alpha, one byte per pixel, is gathered only if some pixel is translucent);
  • for reconstruction, the last pixel line of each finished macroblock row plus each worker's current row, not whole planes;
  • the levels in a compact per-row form (a count per block, then zigzag variable-length values), about a quarter of their size on a noisy photograph, released row by row while the tokens are written;
  • partitions written in 64 KiB chunks and joined once into the frame, and the file written to the destination chunk by chunk.

The lossless encoder packs its 32-bit pixels band by band instead of via an RGBA copy of the frame. Output bytes are unchanged: 498 SHA-256 hashes of lossy (q50/75/90, effort 0/4/6, RGB, RGBA opaque and translucent, degrees 1/4/16, four odd sizes), lossless (exact on and off) and animated encodes match the previous build on default hardware, DOTNET_EnableAVX512=0, DOTNET_EnableAVX2=0 and DOTNET_EnableHWIntrinsic=0, and a 2000x1500 set matches too.

Peak working memory (the method below: peak resident minus resident before, warmed up, median of three processes, spread in parentheses, competitors two to three processes; the runs were on the development host, because the worker was held by other benchmarks, and resident memory does not depend on load). The webp-encode-24mp and webp-encode-lossless-24mp tasks of gate-peak use a 6000x4000 plasma-and-noise image (the webp-encode input at 24 MP); all rows are quality 75, effort 4:

TaskBeforeAfterSkiaSharpMagick.NETNetVipsImageSharp
webp-encode-24mp (lossy)303.5 MB (±1.0)101.6 MB (±6.5)86.3 (±0.5)173.6 (±1.0)241.0 (±14.5)1450 (±1)
webp-encode-lossless-24mp1055.8 MB (±2.4)377.1 MB (±1.0)663.4 (±1.2)736.5 (±1.8)
webp-encode (lossy-2000x1500)22.2 MB (±2.9)4.6 MB (±0.2)0.71.3107.998.1
png-to-webp (photo)43.4 MB (±1.5)22.6 MB (±4.9)38.326.0203.787.6
thumb-webp4.7 MB (±0.6)4.6 MB (±0.3)0.71.983.29.9

SkiaSharp and Magick.NET encode straight from the caller's pixel buffer; the lossy encoder converts to YUV first, which is the 36 MB of planes at 24 MP.

Lossless encoder state

The lossless encoder used to hold, at 24 MP, the packed pixels plus one full-size copy for each transform (subtract green, predictor, cross-color), a 12-byte token for every pixel, a 4-byte hash-chain entry for every pixel, and a bit-writer array that doubled and was then copied. Now:

  • the transforms run in place on the packed pixels (the encoder owns them; a first plan at effort 5 and 6 works on a copy). The predictor reads its neighbors' original values from a private copy of its tile row plus the row above, so tile rows still run in any order;
  • there is no token array: the token stream is the pixels with the matches cut out, so state grows with the number of matches. The color cache is simulated as the tokens are visited, once for each cache-size estimate and once for each of the counting and writing passes;
  • hash chains are built a few bands at a time and only the last 2^20 entries are kept (about 13 MB at degree 4, 38 MB at degree 16), with the same entries as one whole-image chain;
  • the size of the main token stream follows from the symbol counts and code lengths, so the output is written into one array of exactly its final size and returned without a copy.

Output bytes are unchanged: 1011 SHA-256 hashes of lossless encodes (photo-like RGB and RGBA at 703x499, 256x256, 17x33, 1x1, 3x2 and 1024x700, palette images of 2, 3, 12 and 200 colors, a repeating image and a 33x16000 one; effort 0 to 6, degrees 1/4/16, Exact on and off) plus the animated lossless encodes match the previous build on default hardware, DOTNET_EnableAVX512=0, DOTNET_EnableAVX2=0 and DOTNET_EnableHWIntrinsic=0.

Peak working memory, gate-peak method (median of three processes, spread in parentheses, AVX-512 off, on the development host; resident memory does not depend on load). The 2000x1500 row is the new webp-encode-lossless task:

TaskBeforeAfterSkiaSharpMagick.NET
webp-encode-lossless-24mp851.7 MB (±0.9)377.1 MB (±1.0)663.4 (±1.2)736.5 (±1.8)
webp-encode-lossless (2000x1500)106.7 MB (±5.4)46.0 MB (±1.5)62.4 (±1.4)81.9 (±1.1)

What is left at 24 MP is the packed pixels (96 MB), the source rows the core materializes while packing, the output (67 MB for this noisy image) and the output stream the benchmark writes to.

Time and allocation, BenchmarkDotNet ShortRun, AVX-512 off, on the worker (Ryzen 9 7950X3D, 32 logical CPUs, .NET SDK 10.0.402, load 2 to 3.2 before, during and after), base and new alternating, two rounds, mean per call:

CaseBeforeAfterAllocated beforeAllocated after
lossy 400x3005.40 / 5.23 ms5.35 / 5.32 ms1.65 MB0.92 MB
lossy 2000x1500103.8 / 103.7 ms104.1 / 103.9 ms32.05 MB9.27 MB
lossy 6000x4000777 / 781 ms784 / 788 ms252.4 MB66.5 MB
lossless 2000x1500141.5 / 141.6 ms138.5 / 138.5 ms117.8 MB100.1 MB
lossless 6000x40001097 / 1107 ms1073 / 1071 ms1034 MB817 MB

The lossless coder state change, same method (worker under the benchmark lock: Ryzen 9 7950X3D, 32 logical CPUs, .NET SDK 10.0.402, load 0.1 before, 2 to 5 during, 3.5 after), base and new alternating, mean per call:

CaseBeforeAfterAllocated beforeAllocated after
lossless 2000x1500132.8 / 135.6 ms131.2 / 129.7 / 127.7 ms100.1 MB53.7 MB
lossless 6000x40001072 / 1076 ms949 / 980 / 957 ms817 MB369 MB
lossy 2000x1500 (unchanged)104.5 / 104.7 ms104.9 / 104.4 / 106.0 ms9.3 MB9.3 MB

Small and medium encodes

Where the memory of a small encode goes, from allocation sampling and the gate-peak runner:

  • Lossy, 2000x1500: 4.5 MB of source YUV planes (1.5 bytes per pixel). They are needed in full: the segment analysis looks at the whole frame before any macroblock is coded, so they cannot be streamed. The rest is the coded levels, the token partition and the joined frame, each about the size of the output. The peak is what remains after those, about 5 MiB. Renting these buffers from the array pool lowered the gate row, but only by moving the memory into the pool's retention (the absolute peak and the footprint of a loop of encodes went up), so the lossy encoder allocates them as before.
  • Lossless, 400x300: the match-search hash table, a 1 MiB array allocated for every entropy-coded image. A file codes several (the picture and its small transform maps, a few hundred pixels each), so those arrays were 9.7 MB of the 12 MB a 400x300 lossless encode allocated, whatever the image size.
  • thumb-webp: the encoder itself is 0.4 MB of a 400x267 image. What the gate row shows beyond that is the runtime's tiered JIT, a one-time cost. With the gate's single warm-up call, hot methods of the JPEG decoder, resampler and WebP encoder are still recompiled during the measured calls, which costs 2 to 3 MiB of resident code and compiler memory once per .NET process; native libraries do not pay it. GATE_WARMUPS=N makes gate-peak do N warm-ups (default 1, the gate's method, unchanged) to show this: with 40 the same row is 2.0 MiB at 31.6 ms (information only, not a gate figure).

What changed: the lossless match search of an image of up to 16384 pixels uses an open-addressing map sized to the image instead of the 2^18-entry table. It has the same hash and chain order, so matches and output bytes do not change. Bigger images keep the full table, so the 24 MP lossless row is unaffected. A 400x300 lossless encode allocates 4.9 MB instead of 12 MB and a 2000x1500 one 44 MB instead of 54 MB.

Hash check: 456 SHA-256 hashes of the output (lossy q30 to 100 and lossless; effort 0, 2, 4, 5, 6; degrees 1, 4, 16; RGB, gradient alpha and cut-out alpha; 11 sizes from 1x1 to 2000x1500; repeating, flat and noisy images; an animated GIF to lossy and lossless WebP) are the same before and after on default hardware, DOTNET_EnableAVX512=0, DOTNET_EnableAVX2=0 and DOTNET_EnableHWIntrinsic=0; six larger encodes (2400x1800 and 3000x2000 lossy and lossless at degrees 1, 4 and 16, 400x300 lossless, 6000x4000 lossless) are the same too.

Gate-peak, AVX-512 off, peak minus resident-before in MiB and the absolute peak, median of three processes (spread), one warm-up. Local run on the development host (main against this change, same method, alternating):

TaskBefore: delta / absolute peakAfter: delta / absolute peak
webp-encode (lossy 2000x1500)4.6 (±0.6) / 223.74.6 (±0.8) / 223.9
thumb-webp4.1 (±0.9) / 217.93.7 (±1.4) / 217.4
png-to-webp (photo)19.3 (±0.8) / 108.219.0 (±5.3) / 107.9
webp-encode-lossless 2000x150044.1 (±2.2) / 279.240.1 (±1.6) / 276.1
webp-encode-24mp (lossy)102.6 (±0.3) / 1172.8101.1 (±3.4) / 1172.9
webp-encode-lossless-24mp375.9 (±3.4) / 1457.5369.4 (±0.2) / 1451.7

On the worker (Ryzen 9 7950X3D, 32 logical CPUs, .NET SDK 10.0.402, load 0.5 to 1.8, under the benchmark lock) the changed build measured: lossy 2000x1500 4.9 / 229.8 (SkiaSharp 1.2), thumb-webp 3.8 / 221.9 (Skia 0.6; 2.0 with 40 warm-ups), png-to-webp 23.0 / 116.5 (Skia 37.6), lossless 2000x1500 38.8 / 279.9 (Skia 57.6), lossy 24 MP 98.8 / 1175.3 (Skia 87.8) and lossless 24 MP 371.0 / 1459.0 (Skia 664.6). Times: the lossless 2000x1500 encode took 128.0 ms on main and 131.1 ms with the hash-map change plus the lossy pooling since removed (BenchmarkDotNet ShortRun, StdDev ±3 ms), and the 150-encode local loops ran at 138 to 140 ms (lossy) and 177 to 179 ms (lossless) on both builds; The SkiaSharp gaps on the lossy 2000x1500 and thumb-webp rows remain (about 4 MiB and 3 MiB); the first is the source planes above, the second is JIT.

Drawing and clipping exceptions

The drawing operations are compared with ImageSharp.Drawing 2.1.7 in bench/OneImage.Benchmarks/Drawing (BenchmarkDotNet with the memory diagnoser, 2000 x 2000 RGBA, .NET 10.0.11, X64 AVX-512). These were local smoke runs on a shared workstation with a load of 4.7 at the start, rising to about 10, so treat them as rough. Fills, strokes and brushes are faster with less allocation on every row; see drawing.md. Clipping has one exception:

RowOneImageImageSharp.DrawingAllocated (OneImage vs ImageSharp)
union of 200 circles7.2 ms11.1 ms3.2 MB vs 23 MBfaster, less
star minus a 1000-point polygon158 µs134 µs33 KB vs 205 KBslower (exception)
blur clipped to a circle, lazy Image12.3 ms56 ms52 KB vs 423 KBfaster, less
blur clipped to a circle, in place on a Canvas12.9 ms56 ms88 KB vs 423 KBfaster, less
  • Star minus a 1000-point polygon is 1.18× slower. The time is split evenly between finding crossings and the sweep. The boolean engine works in exact integer arithmetic on a 1/256-pixel grid, and for an input this small the candidate-pair search costs more than it saves. It allocates a sixth as much.
  • Canvas.Clip allocates 88 KB (the Image path 52 KB). It used to allocate 9.6 MB: it read the canvas through a public snapshot, so every rewritten tile was copied and the replaced tiles, together with the tiles the clipped graph cached, went to the garbage collector. It now reads a private copy of the tile table, swaps fresh pooled tiles in and returns the replaced and cached tiles to the pool afterwards. Time is unchanged (12.9 ms against 13.4 ms before).

Layer flatten exception

Flattening a layered document (OneImage.Layers, see layers.md) is compared with ImageSharp 3.1.12 cloning a 24 MP image and chaining DrawImage calls. Full-size 24 MP Rgba8 layers at 50% opacity, Normal blend, every tile recomputed; Ryzen 9 7950X3D (16 cores, 32 threads), .NET 10.0.13, on a shared machine with other jobs running (load 4-12 during the runs).

RowOneImage, default (4 threads)OneImage, degree raised to 32ImageSharp, all coresAllocated (OneImage)
flatten 10 layers77 ms34 ms72 ms0.25 MB
flatten 50 layers361 ms129 ms297 ms0.25 MB
flatten 10 layers, AVX2 only115 ms40 ms91 ms
flatten 50 layers, AVX2 only544 ms157 ms340 ms
  • Flatten of 10 layers at 24 MP is slower than ImageSharp at the default thread limit: 77 ms against 72 ms (1.07×), an exception. It is not published as a tie. The 50-layer and AVX2-only rows are slower in the same way.
  • The comparison is not on equal cores. OneImage's default per-call degree is 4 threads, so a server is not oversubscribed by one call; ImageSharp's DrawImage chain uses every core. With the degree raised to 32 for the call, OneImage takes 34 ms, 2.1× faster than ImageSharp's 72 ms. The default stays at 4.
  • A cold flatten allocates 0.25 MB. The raw figures and the method (the benchmark is in bench/OneImage.Benchmarks) are in layers.md.

Whole-frame copy-out

Copying a computed 24 MP image (6000×4000, tiles in memory) into a caller's buffer, CopyPixelsTo, same worker as above (AVX-512 off, load 1-3 from other jobs, CoreHotPathBenchmarks):

Row1 threaddefault (4)8 threads
Rgba32 from tiles (96 MB)9.3 ms6.1 ms5.7 ms
Rgb24 from tiles (72 MB)7.5 ms4.4 ms4.2 ms
Same 96 MB as plain contiguous copies on the same threads (floor)5.0 ms3.1 ms2.9 ms
ImageSharp CopyPixelDataTo Rgba32 (single-threaded)6.1 ms

The copy scales with threads and sits at 1.9× the contiguous floor at every degree. The gap is the layout, not the engine: a tile row is 512 bytes of Rgba32, so a frame is 192,000 separate 512-byte copies into rows 24 KB apart. A stand-alone test of that loop (no engine) takes 10-11 ms against 6 ms for one contiguous copy, and walking the destination in row-major order or in bands of 8 or 32 rows, or copying rows with 256-bit loads and stores, moved it by less than 10%. Writing whole tiles to contiguous memory is not an option for a caller buffer in row-major order. The extra copies that could be removed were in decoders, and TIFF (above) was the one left; PNG, GIF, WebP and BMP already decode straight into the caller's buffer on a whole-frame read.

AVX-512 (information)

The gate runs with AVX-512 disabled so that its numbers describe the AVX2 path every x64 server has. The same suite with native AVX-512 enabled on this CPU gave the times below (OneImage ms; the AVX2 gate column repeats the table above). They change little. The raw reports are gate-final-avx512.md and gate-rerun-avx512.md.

TaskAVX2 gate: OneImage msAVX-512: OneImage msAVX-512: ImageSharp msAVX-512 time vs ImageSharp
thumb-jpeg37.538.148.21.27× faster
thumb-webp46.945.965.61.43× faster
png-to-webp (natural)79.176.71489.119.41× faster
png-to-webp (photo)90.590.81041.111.47× faster
png-to-webp (screenshot)95.899.4251.02.53× faster
jpeg-decode41.241.776.51.83× faster
jpeg-encode87.481.8100.91.23× faster
png-decode (natural)11.411.439.33.45× faster
png-decode (photo)18.317.922.11.23× faster
png-decode (screenshot)2.92.94.11.41× faster
png-encode (natural)219.6222.71104.44.96× faster
png-encode (photo)52.353.2322.46.06× faster
png-encode (screenshot)50.852.796.51.83× faster
webp-decode (lossy)16.916.831.21.86× faster
webp-decode (lossless)24.924.569.02.82× faster
webp-encode (lossless-2000x1500)146.8146.01095.17.50× faster
webp-encode (lossy-2000x1500)101.6103.0581.85.65× faster
webp-encode (lossy-400x300)5.45.423.94.43× faster
gif-decode2.32.36.12.65× faster
gif-encode13.313.619.51.43× faster
gif-resize40.439.147.51.21× faster
tiff-multipage-decode†84.387.5583.76.67× faster
tiff-encode44.443.8131.23.00× faster
bmp-decode0.40.40.71.89× faster
bmp-encode2.82.74.81.78× faster
upscale-2x6.36.312.51.98× faster
watermark4.94.95.71.16× faster
cmyk-jpeg-to-srgb243.4241.2149.41.61× slower (ImageSharp skips the ICC conversion)
jpeg-decode-rgba43.444.279.51.80× faster

† Measured before the TIFF LZW and predictor work. The AVX2 gate figure is now 52.3 ms (table above); the AVX-512 columns were not re-run.

Vector kernels on AVX2 and SSE

The .NET vector API compiles to one instruction only where the CPU has it. Unsigned 32-bit lanes converted to float (and back), unsigned compares, 64-bit lane multiplies and saturating float-to-int conversions are native with AVX-512 but expanded into several instructions on AVX2 and SSE, so benchmarks on an AVX-512 desktop hide them while the AVX2 gate pays for them. Every kernel in Kernels/ was checked for these (by reading the code and by scanning the generated assembly of every kernel the test suite and a sweep of about fifty operations and codecs reach, with AVX-512 off and AVX2 off). Four needed work, and their output did not change by a bit.

One thread, 1024 x 1024 pixels, minimum of 12 runs, nanoseconds per pixel (AMD Ryzen 9 7950X3D, .NET 10.0.11; the host was shared, load average about 1.5 to 2.5, so differences under 10% are noise):

Kernel or operationAVX-512 beforeAVX-512 afterAVX2 beforeAVX2 afterSSE only beforeSSE only after
Error diffusion, Floyd-Steinberg, fast match19.618.860.818.560.818.8
Error diffusion, Floyd-Steinberg, exact match34.533.768.233.564.733.5
Error diffusion, Atkinson, exact match39.139.093.038.892.538.4
Equalization luma kernel (multiply form)0.210.180.350.180.870.34
Color matrix kernel, RGBA to RGBA1.361.252.641.533.723.07
Color matrix kernel, RGB to RGB1.231.211.641.393.792.49
Float-path RGBA composite kernel1.661.072.151.124.272.25
Composite operation, 8-bit RGBA2.431.852.991.894.882.70

The other kernels and the codecs measured within 10% of their AVX-512 time on AVX2 (resize, blur, JPEG, PNG, WebP, GIF, TIFF, geometry, tone and the remaining filters). The JPEG transforms have an AVX2 path and a 128-bit path (SSE2/SSE4.1 on x64, AdvSimd on ARM64); all three give identical bits. With AVX2 turned off (DOTNET_EnableAVX2=0, same host, load 1.6 to 10) a 24 MP decode took 47 ms (79 ms before the 128-bit path; 32 ms with AVX2) and a q90 4:2:0 encode 179 ms (225 ms before; 86 ms with AVX2). Per 8x8 block the inverse DCT takes 40 ns (scalar 145, AVX2 19) and the forward DCT with quantization 71 ns (scalar 131, AVX2 47). The AVX2 times did not change. The rest of the SSE-only gap is the other JPEG stages (entropy coding, color conversion, upsampling), not the transforms.

The reduced-size transforms used for DCT scaling (4x4 at 1/2, 2x2 at 1/4) also have a 128-bit path now, identical in output to the scalar one (hashes of the sample corpus and of 24 MP files at scales 1, 1/2, 1/4 and 1/8 match across AVX-512, AVX2, SSE-only and scalar runs). Measured on the worker (Ryzen 9 7950X3D, 32 threads, .NET 10.0.13, DOTNET_EnableAVX512=0, load 1.1 to 5 from other work, no other benchmark running), per block: 4x4 72 ns before and 26 ns after, 2x2 32 ns before and 20 ns after. A 24 MP 4:2:0 decode into a reused buffer at 1/2 takes 38.9 ms before and 31.9 ms after, at 1/4 31.7 and 31.1 ms, at 1/8 26.4 and 26.0 ms (full size 32.0 and 32.6 ms, unchanged within noise). The smooth test photograph is entropy-bound at about 31 ms, so the transform no longer limits 1/2; noisier files with more coefficients spend more time in the transform. The thumbnail row (24 MP to 400 px, shrink-on-load at 1/8, which does not use these transforms) measured 27.3 ms before and 28.1 ms after, within the run spread.

Method

  • Libraries. OneImage, measured against ImageSharp 3.1.12 (the last release that builds without a license key, so it is the reference; newer versions are not measured here), SkiaSharp, NetVips and Magick.NET, each through its .NET package.
  • Rows. Each library runs its idiomatic one-call path to the same output, from and to memory, on inputs generated deterministically. Where a competitor writes into reused or pooled storage, OneImage writes into a caller buffer too; rows that allocate a fresh result are kept beside them in the raw reports. A library that cannot do a task has no row: SkiaSharp has no BMP, TIFF or animated GIF encoder and no TIFF decoder here, and NetVips's native build has no BMP loader.
  • Time. BenchmarkDotNet with MemoryDiagnoser, throughput strategy, 2–3 warm-up and 8–12 measured iterations per row; the mean is shown.
  • Peak memory. One process per task and library, repeated three times. Each process runs the operation once to warm up, collects garbage, resets the resident-set high-water mark, runs the operation four times, and reports the peak minus the resident size before: the working memory of the operation, native allocations included. The median and the spread are shown. A peak difference counts as a tie when it is within the larger of 2 MiB and either library's spread, and within 5%; see the exceptions above for the rows read as ties.
  • Parallelism. A single OneImage call uses at most min(4, cores) threads by default. Rows with a raised degree are marked as such and are information only.
  • Hardware. AMD Ryzen 9 7950X3D (16 cores, 32 logical), 125 GiB RAM, Ubuntu 26.04, .NET SDK 10.0.400, runtime 10.0.11. The gate run has AVX-512 turned off (DOTNET_EnableAVX512=0, so Avx2.IsSupported is true and Avx512F.IsSupported false); the information run has it on.
  • A shared workstation. The machine was not idle. The load average was 3.98 at the start of the full gate run, between 1.0 and 3.3 during it and 1.2 at the end; the re-run of changed rows saw 1.0 to 2.2. Every minute's sample is in the raw reports. Absolute times carry that noise; ratios on one machine in one window are steadier.
  • Two runs. The full gate ran on 2026-10-22. The rows for JPEG encode and decode, thumb-jpeg, the CMYK conversion and GIF decode and resize were measured again later the same day after changes to them; the table uses the later numbers for those rows and the first run for all others. Both reports are kept: gate-final.md, gate-rerun.md. ARM64 is checked for correctness only; no ARM64 timings are published.

Raw reports

Each report lists every library's row, with allocated memory, and the load samples.

To run it yourself: bench/gate.sh (see CONTRIBUTING.md). The libvips-based competitors need their native packages for your platform.