.. _allsimd-kernels: How the kernels work ==================== This page explains Pillow-AllSIMD's SIMD kernels, operation by operation: what Pillow computes, the trick that makes it fast, how the x86-64 (SSE4.1, AVX2, AVX-VNNI) and arm64 (NEON) versions do it, why the result is the same on every build, and what it gains. It is written for a C programmer who knows what a SIMD register is but not Pillow's internals, so that a contributor or a reviewer can read a kernel without reverse-engineering its intrinsics. Code excerpts are short and name their file and function; the files are in ``src/libImaging/``. The speed-ups quoted are from the `benchmark page `_: one thread, a 2560×1600 image, the best of three rounds, on an Intel Core Ultra 9 285H (x86-64, GCC 14) and an NVIDIA Jetson Orin (arm64, Cortex-A78AE, GCC 14). Unless a section says "fast mode", they are for exact mode, Pillow's arithmetic. The sections: the ground rules; then, operation by operation, resampling, the blurs, the kernel filters, the rank filters, the colour conversions, compositing and the channel operations, geometry, drawing and text, and codecs and packing; then what was tried and dropped, where OpenCV and libvips are still faster, the fast-mode kernels, and three appendices: the overall numbers, a file map and the tools. Ground rules ------------ Every kernel on this page was shaped by a few facts about Pillow, the compilers and the two architectures. **The same pixels on every build.** A SIMD build must produce the pixels of the portable C build, bit for bit, so that an image processed on an AVX2 laptop, an arm64 server and a RISC-V board is the same image, and the tiers of one x86-64 build must agree with one another. The portable code is the definition: each kernel's scalar version does the arithmetic in a form the vector version can reproduce exactly, usually integer sums of products with one rounding at the end, rather than the floating-point loops Pillow once had. The exceptions are documented in ``checks/simd_hashes.py``: the x86 alpha composite rounds a division differently from the portable code by one level, the x86 3D colour lookup table interpolates at Pillow-SIMD's 16-bit precision, up to two levels from Pillow's, and on arm64 the compilers fuse floating-point multiply-adds, so the kernels that must match double sums (the F, I and I;16 resample, the exact transforms) use fused multiply-adds explicitly and differ from x86 only where the scalar code would too. Against Pillow itself, resize uses 16-bit coefficients where Pillow uses 32-bit ones and RGB to L uses 15-bit weights, each off by one level in a few places, and the kernel filters use 16-bit coefficients, which can't hold 1/13 or 1/100 exactly (so ``SMOOTH_MORE`` differs by one level in a few percent of the pixels); everything else in exact mode is Pillow's arithmetic. The checks: ``checks/simd_hashes.py`` prints one hash per kernel area over many sizes and modes, and the hashes of a scalar, SSE4, AVX2, AVX-VNNI and NEON build must agree; ``Tests/test_simd_consistency.py`` checks that the pixels a vector loop leaves for its scalar tail get the same arithmetic; exhaustive checks cover the conversions whose input space is small enough (every RGB value for HSV, every colour and alpha for unpremultiplication); and Pillow's test suite runs on every build. **One instruction set per build, through a table of kernels.** The build picks the instruction set with ``-C simd=`` (``sse4``, ``avx2``, ``avxvnni``, ``neon``, ``native``, ``disable``; :ref:`allsimd-install`), and ``ImagingSIMD.h`` turns the compiler's macros into ``IMAGING_SIMD_SSE4``, ``IMAGING_SIMD_AVX2``, ``IMAGING_SIMD_AVXVNNI`` and ``IMAGING_SIMD_NEON``. The SIMD code of each operation lives in a companion file, ``SIMD.c``, one ``#if`` ladder: the SSE4 version, with ``#if defined(IMAGING_SIMD_AVX2)`` blocks inside it where 256-bit code exists, and the NEON version. Every companion is compiled into one translation unit, ``SIMDKernels.c``, whose only export is a table of kernel pointers (``SIMDKernels.h``, one group per area); Pillow's own files keep the portable code and call a kernel through ``ImagingSIMD``, the table of the tier chosen when ``_imaging`` loads (``SIMDDispatch.c``; a scalar table, every pointer NULL, for ``disable`` and for architectures without kernels). A row kernel does the pixels its vector loop can and returns how many, and the scalar file finishes the row; a kernel for a whole operation returns 1 when it did the work. The call is once per row or per operation, never per pixel: the fast transforms were first called per tile row of 64 pixels, and the SSE4 build lost 14% to the calls and the vector constants set up on each, so they take the whole region. **Run-time dispatch on x86-64.** The ``dispatch`` build (``auto`` there) compiles ``SIMDKernels.c`` three times, through the ``SIMDKernels_sse4.c``, ``_avx2.c`` and ``_avxvnni.c`` stubs, each with its instruction set as a per-file compiler flag (``setup.py`` wraps the compiler's per-source step; MSVC needs no flag and has the SSE4.1 and AVX2 tiers), and every other file for plain x86-64; the stub's ``IMAGING_SIMD_TIER`` tells ``ImagingSIMD.h`` which macros to define. ``SIMDDispatch.c`` reads ``cpuid`` (SSE4.1; AVX2 with the OS saving the YMM state; AVX-VNNI) and ``ImagingSIMDInit()`` points ``ImagingSIMD`` at the highest tier built that the CPU has, capped by ``PILLOW_ALLSIMD_ISA`` if set; a build for one instruction set the CPU lacks fails to import with a message. The tiers of one build give the same pixels, which ``Tests/test_simd_tiers.py`` checks through ``Image.core.set_simd()``; the scalar table of a dispatch build is the common files' portable code compiled for x86-64. On arm64, NEON is part of the baseline, so the wheels have it and the build has one tier. **Plain loops compiled per tier (2026-11-05).** A dispatch build compiles the common files for baseline x86-64, and some of their loops, with no kernel of their own, were fast in a build for AVX2 only because the compiler vectorized them there: timing all 216 operations of ``checks/simd_bench.py`` in the wheel against the AVX-VNNI build found them, 1.6 to 3.4 times slower in the wheel. Those loops are now in ``PlainLoops.h``, in plain C, and compiled several times: by the file that owns the operation, for the scalar table, and by ``SIMDKernels.c`` for each tier, whose companions put them in the kernel table, called once per row. They are soft light, hard light and overlay (three integer divisions per byte: 24.5 to 7.0 ms and 26.5 to 7.7 ms on a 2560x1600 RGB image in the wheel), the exact and the extrapolating blend (float per byte; ``ImageEnhance.Color`` 13.1 to 7.7 ms, ``Sharpness`` 13.8 to 8.4 ms), the colour matrix's rows (4.4 to 2.3 ms to L), L to I and I to L (2.4 to 1.6 ms), and the sorting network of the general rank filter, whose minima and maxima are twice as wide in AVX2 (a 7x7 rank on L: 53.6 to 31.1 ms). The wheel now runs each at the AVX2 build's speed. It is the same C in every tier and no tier is compiled with FMA, so the results are the same, which ``Tests/test_simd_tiers.py`` checks for each. ``ImagingSIMD.h`` also holds the helpers the kernels share: ``SIMD_FORCE_INLINE`` for kernels that must see a shift count as a constant; ``mm_dpwssd`` and ``mm256_dpwssd``, one ``vpdpwssd`` with AVX-VNNI and ``pmaddwd`` plus ``paddd`` without, and ``mm_madd_add``, always the pair, for sums that are one long dependency chain (below); and ``ImagingPx4.h``, one pixel as four 32-bit lanes with the same modulo-2³² arithmetic in SSE4, NEON and plain C. **Pillow's memory layout.** An image is an array of row pointers (``im->image[y]``). Modes of one 8-bit band (L, P) are stored as bytes (``image8``); every other 8-bit mode is stored as 4 bytes per pixel (``image32``): RGB with a padding byte of 255, RGBA, RGBX, CMYK, YCbCr, LAB, HSV, and LA and La as L, L, L, A. So "the kernel for 4-byte pixels" serves all of them, and RGB pays for a fourth channel it doesn't use. Rows of all sizes live in blocks of up to 16 MB with transparent huge pages where the kernel offers them (``madvise``). I, F and I;16 images use 4 or 2 bytes per pixel and their own kernels. **What the compilers do and don't do.** Three facts decided more designs than any intrinsic: * Every common Python passes ``-fno-strict-overflow``, so GCC can't vectorize a loop whose address is ``in + x * 4`` with an ``int`` ``x``: the multiplication may wrap. Portable loops meant to vectorize use pointer increments, or fixed-size chunks with ``restrict`` pointers (the ``CONVERT_PIXELS`` pattern in ``Convert.c``). * Debian, Ubuntu and conda build Python with ``-O2``, the Docker images with ``-O3``. At ``-O2`` GCC vectorizes only loops with a fixed trip count and no aliasing, so the portable kernels are written that way, and the hot paths have explicit SIMD. * On arm64 GCC fuses ``a * b + c`` into one rounding by default. Kernels whose results must match scalar double sums use ``vfmaq_f64`` explicitly, so the result is the same whatever the compiler fuses. And two about the hardware. On the Cortex-A78AE, a 32-bit vector multiply and even a vector division are cheap, and a hand-written NEON loop beats the compiler's vectorization of the portable code by a smaller margin than SSE4 beats scalar on x86, sometimes by none: several x86 tricks (a 12-bit weight split to keep products in 16 bits, a reciprocal estimate with a correction step) were slower than the plain instruction on arm64 and weren't ported. And the two instruction sets count differently: x86's ``pmaddwd`` multiplies 16-bit pairs and sums them into 32 bits, where NEON has ``vmlal`` (multiply-accumulate with widening) but no pair sum; x86's ``pshufb`` and NEON's ``tbl`` both permute bytes but zero a lane on different index values; and AVX2 operates on two 128-bit halves, so data that must cross between them costs an extra instruction. Resampling ---------- ``Image.resize()`` with any filter but ``NEAREST`` (``Resample.c``). This is the kernel Pillow-SIMD was written for, and the one with the most variants: 8-bit images with 4-byte pixels, 8-bit images with 1-byte pixels, and I, F and I;16 images, each with a horizontal and a vertical pass. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ A resize is a separable convolution: a horizontal pass makes an image of the output width and the input height, and a vertical pass brings that to the output height. For each output column, ``precompute_coeffs()`` finds the input pixels under the filter's support (``xmin``, and ``xmax`` of them) and their weights, evaluates the filter in doubles and normalizes the weights to sum to 1. When the image shrinks, the filter's support is scaled by the shrink factor, so a 1/8 Lanczos downscale has 48 taps per output pixel and a 2x upscale has 6. For 8-bit images the weights become 16-bit integers. ``normalize_coeffs_8bpc()`` picks the largest precision, up to 22 bits, at which the largest weight times 2 still fits a signed 16-bit value, and rounds each weight to that many fractional bits. A pixel is then the sum of ``pixel * weight`` in a 32-bit accumulator that starts at half a unit (``1 << (coefs_precision - 1)``), shifted right by the precision and clipped to 0 to 255 (``clip8()``, a table indexed by the shifted sum, since a filter with negative lobes can give sums outside the range). The portable kernel is that, written out: .. code-block:: c /* ResampleSIMDHorizontalConv.c, the portable ImagingResampleHorizontalConvolution8u */ ss0 = ss1 = ss2 = ss3 = 1 << (coefs_precision - 1); for (x = 0; x < xmax; x++) { ss0 += in[x * 4 + 0] * k[x]; ss1 += in[x * 4 + 1] * k[x]; ss2 += in[x * 4 + 2] * k[x]; ss3 += in[x * 4 + 3] * k[x]; } out[0] = clip8(ss0, coefs_precision); Pillow's own resize does the same sums with 32-bit weights at a fixed precision. The 16-bit weights are what let a vector instruction multiply 8 or 16 of them at once, and they differ from Pillow's result by one level in a few pixels. The idea ~~~~~~~~ Three things, on top of the vectors themselves: * **Two taps per multiply.** ``pmaddwd`` multiplies eight 16-bit pairs and adds each pair into a 32-bit lane. If a register holds the bytes of two neighbouring input pixels interleaved, channel by channel (``r0 r1 g0 g1 b0 b1 a0 a1``, each widened to 16 bits), and another holds their two weights repeated (``k0 k1 k0 k1 …``), one instruction adds two taps of all four channels to the accumulator. * **Four rows at once.** The horizontal pass has the same bounds and weights for every row, so a kernel that resamples four rows in one loop shares the loads of the weights and the loop control between them, and keeps four independent accumulators in flight. * **A pass order that reads less.** When the height shrinks more than twice as much as the width, the vertical pass runs first (``ImagingResampleInner()``, ``horizontal_first``), so the horizontal pass runs over the shorter image; otherwise the horizontal pass runs first and only over the source rows the vertical pass will read. x86-64 ~~~~~~ The horizontal kernel for 4-byte pixels (``ImagingResampleHorizontalConvolution8u4x``, SSE4 with AVX2 blocks) loads 16 bytes, four pixels, of each of two rows into the two halves of a 256-bit register, then two ``pshufb`` shuffles spread them into the interleaved form, one for pixels 0 and 1, one for pixels 2 and 3, each followed by a multiply-add with the matching pair of weights: .. code-block:: c mmk0 = _mm256_set1_epi32(*(INT32 *)&k[x]); /* k[x], k[x+1] */ mmk1 = _mm256_set1_epi32(*(INT32 *)&k[x + 2]); /* k[x+2], k[x+3] */ source = _mm256_inserti128_si256( /* row 0 low, row 1 high */ _mm256_castsi128_si256(_mm_loadu_si128((__m128i *)&lineIn0[x + xmin])), _mm_loadu_si128((__m128i *)&lineIn1[x + xmin]), 1); pix = _mm256_shuffle_epi8(source, /* bytes 0,4 1,5 2,6 3,7 widened */); sss0 = mm256_madd_add(sss0, pix, mmk0); pix = _mm256_shuffle_epi8(source, /* bytes 8,12 9,13 10,14 11,15 */); sss0 = mm256_madd_add(sss0, pix, mmk1); so each step consumes four taps of two rows. Rows 2 and 3 take the same steps into a second accumulator. Shorter loops handle the taps left over two at a time and one at a time; then the accumulators are shifted right by the precision (``srai``), narrowed with saturation to 16 bits (``packs_epi32``) and to bytes (``packus_epi16``), which is the clip, and stored as one 32-bit pixel per row. Without AVX2 the same happens in 128-bit registers, one row per register. Here ``mm256_madd_add`` is deliberately the ``pmaddwd`` plus ``paddd`` pair even on AVX-VNNI, where one ``vpdpwssd`` could do both. Each output pixel is a single chain of additions into one accumulator, and ``vpdpwssd`` puts its multiply inside that chain, about five cycles per step instead of one; with the long kernels of a 1/100 downscale the AVX-VNNI build was 1.5x slower than AVX2 until this was changed. The vertical kernel, with four to eight independent accumulators per step, does use ``vpdpwssd``. The vertical kernel (``ImagingResampleVerticalConvolution8u``, ``ResampleSIMDVerticalConv.c``) is simpler, because the taps of an output row are whole source rows: every byte of a row is multiplied by the same weight, whatever its channel. It takes 32 bytes of two source rows, interleaves them byte by byte (``unpacklo_epi8``, ``unpackhi_epi8``), widens to 16 bits with another unpack against zero, and multiply-adds with the two rows' weights, 8 pixels per step in four accumulators; then the same shift and saturating packs. The 1-byte kernel for L images (``ImagingResampleHorizontalConvolution1u4x``) resamples four rows at once and takes its taps 8, 4 and 2 at a time as 16-bit products; the vertical pass serves L images unchanged, since it works on bytes. arm64 ~~~~~ NEON has no pair-summing multiply, so the horizontal kernel takes one tap per instruction and four channels per lane group: ``neon_resample_horiz_px()`` loads 16 bytes, widens them to two vectors of eight 16-bit values (``vmovl_u8``), and multiply-accumulates each pixel's four channels by its weight, a lane of the four weights loaded together (``vmlal_lane_s16``), alternating between two accumulators so that the two chains of dependent adds run in parallel: .. code-block:: c for (; end - k >= 4; k += 4, in += 16) { uint8x16_t source = vld1q_u8(in); int16x4_t kv = vld1_s16(k); int16x8_t lo = vreinterpretq_s16_u16(vmovl_u8(vget_low_u8(source))); int16x8_t hi = vreinterpretq_s16_u16(vmovl_high_u8(source)); sss = vmlal_lane_s16(sss, vget_low_s16(lo), kv, 0); sss1 = vmlal_high_lane_s16(sss1, lo, kv, 1); sss = vmlal_lane_s16(sss, vget_low_s16(hi), kv, 2); sss1 = vmlal_high_lane_s16(sss1, hi, kv, 3); } The pointers that advance are the point (2026-11-05). The loop had an ``int`` index and a weight per multiply (``vmlal_n_s16(sss, …, k[x])``), and GCC made of it four sign-extended address computations and four scalar loads beside the pixels' one: 17 instructions for four taps, in a loop that ran at four instructions per cycle. As above it is 10. A 1/4 bicubic resize of 2560x1600 RGB went from 108.6 to 80.0 million instructions and from 27.0 to 25.3 million cycles on the Cortex-A78: less than the instructions, because the loop now waits for the one vector multiplier, a ``smlal`` per tap and pixel. The sums are added at the end, shifted (``vshlq_s32`` by a negative count) and narrowed with saturation to 16 bits and to bytes (``vqmovn_s32``, ``vqmovun_s16``), the same clip as ``packs`` and ``packus``. The four-row variant calls this once per row. The vertical kernel widens 32 bytes of each source row and accumulates them in eight vectors, 32 bytes of output per step, with a 16-byte and a 4-byte tail. Rows are read in order, as on x86. Fast mode's vertical kernel (``resample_fast_vrow_simd()``, 16-bit rows and weights, ``sqrdmulh``) is compiled for each tap count from 2 to 16 as a constant (``resample_fast_vrow_taps()``): the general loop loaded a row pointer and a weight for every tap of every 16 bytes, 11 instructions per tap and step, and with the count known both stay in registers, 6. Enlarging by 1.5 with Lanczos, where the vertical pass is a third of the time: 116.8 to 104.3 million cycles. With the horizontal change, RGB and RGBA resizes take 8 to 15% fewer cycles, the same pixels. The portable vertical kernel is written to be vectorized at ``-O2``: it sums chunks of 64 bytes into a fixed array of 32-bit sums, and GCC turns the inner loop into vector code since its trip count is fixed. The portable horizontal kernel for RGB skips the padding byte, a quarter of the work, and writes 255 to it, as the decoders do. Why the builds agree ~~~~~~~~~~~~~~~~~~~~ All three versions compute the same integers: the same 16-bit weights, 32-bit sums in any order (integer addition commutes), the same initial half unit, the same arithmetic shift and the same saturation to 0 to 255. Saturating a 32-bit sum to 16 bits before saturating to a byte gives the byte clip too, because a sum that overflows 16 bits is far outside 0 to 255 either way. So the SSE4, AVX2, AVX-VNNI, NEON and portable builds produce identical pixels, and ``checks/simd_hashes.py`` checks it. I, F and I;16 images (``ResampleSIMD32bpc.c``) are the exception that proves the rule: Pillow sums their taps in doubles, in order, and a vector of doubles can only reproduce that if each lane computes one pixel's sum in the same order. So the vertical kernel's lanes are neighbouring pixels of a row, which share their taps, and the horizontal kernel's lanes are the same pixel of neighbouring rows, read from a copy of the rows with their pixels interleaved. Each lane adds the same products in the same order as the scalar code, and the results match it; on arm64 both use fused multiply-adds, so they match the arm64 scalar build and differ from x86 as Pillow's own builds do. RGBA and LA images are resized premultiplied by alpha, as Pillow does by converting to RGBa or La and back. The passes premultiply each source row as they read it and unpremultiply each output row as they write it, while the rows are in the cache (``struct resample_alpha``), which gives the same pixels as the two whole-image conversions without them. Fast mode replaces the two passes with one fused kernel for 4-byte pixels (``ResampleFast.c``), described with the other fast kernels. Reduce ~~~~~~ ``Image.reduce()`` and the first step of a resize with ``reducing_gap`` average boxes of whole pixels. ``Reduce.c`` has a kernel per small factor (1×2, 2×1, 2×2, 1×3, 3×1, 3×3, 4×4, 5×5) and general N×N, N×1 and 1×N ones; each box's sum is scaled by a fixed-point reciprocal (``division_UINT32()``) rather than divided, and rounded. ``ReduceSIMD.c`` has the versions for 4-byte pixels, which use the same arithmetic on four channels per lane group, so the scalar and vector builds agree. Numbers ~~~~~~~ 2560×1600 RGB, exact mode, times faster than Pillow 13.0.0; Pillow 13's own time in milliseconds for scale. The arm64 column is the NEON build on the Jetson Orin. .. generated: kernels-resize (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Resize - Pillow 13 (x86, ms) - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - To 320×200, bilinear - 11.9 - 6.9x - 10.8x - 15.5 - 2.6x * - To 320×200, bicubic - 22.3 - 7.0x - 10.8x - 30.3 - 2.7x * - To 320×200, Lanczos - 33.1 - 6.7x - 10.0x - 46.4 - 2.6x * - To 2048×1280, bicubic - 46.4 - 5.5x - 8.2x - 58.8 - 2.1x * - To 5478×3424, bicubic - 226 - 4.5x - 6.1x - 262 - 2.1x * - RGBA to 320×200, bicubic - 38.4 - 7.7x - 12.8x - 44.7 - 3.0x * - L to 320×200, bicubic - 10.8 - 9.4x - 10.2x - 11.9 - 3.6x * - Reduce by 2 - 3.3 - 4.5x - 4.5x - 5.8 - 3.7x .. generated-end AVX-VNNI adds up to 4% over AVX2 on these. Against Pillow-SIMD 12.1.1 with AVX2 on the same machine, the downscales are equal and the upscales to 5478×3424 are 1.8x faster. Against OpenCV, the AVX2 build is faster on every downscale here (1.8x on the 320×200 bicubic one, 1.1x on the Lanczos one), and slower on the RGBA upscales to 5478×3424, by up to 1.45x; on arm64 OpenCV, with its KleidiCV kernels, is 1.8x faster on the 320×200 bicubic downscale. ``design/PERFORMANCE_GAPS.md`` discusses the gaps. Fast mode (the default) adds a fused two-pass kernel and the ``reducing_gap`` default for large downscales: to 320×200 bicubic 15.8x on x86 and 6.0x on arm64; see :ref:`fast-mode`. Code map ~~~~~~~~ * ``Resample.c``: the filters, ``precompute_coeffs()``, ``normalize_coeffs_8bpc()``, the pass drivers (``_ImagingResampleHorizontal_8bpc`` and friends), ``ImagingResampleInner()`` with the pass order and the fast-mode entry, ``ImagingResampleAlpha()``. * ``ResampleSIMD.c``, the companion: it includes ``ResampleSIMDHorizontalConv.c`` (the horizontal kernels for 4-byte and 1-byte pixels, SSE4/AVX2 and NEON) twice, the second time with ``RESAMPLE_OUT16`` for fast mode's 16-bit intermediate rows, ``ResampleSIMDVerticalConv.c`` (the vertical kernel), ``ResampleSIMD32bpc.c`` (I, F and I;16 in vectors of doubles) and ``ResampleFastSIMD.c`` (fast mode's vertical row), and defines the table's ``resample`` group. * ``ResampleHorizontal.c``: the portable horizontal kernels, included twice by ``Resample.c`` the same way; the portable vertical kernel is in ``Resample.c`` itself. * ``ResampleFast.c``: fast mode's fused resize; ``ResampleFast.h``: the constants it shares with its SIMD loop. * ``Reduce.c`` and ``ReduceSIMD.c`` (the ``reduce`` group). Pitfalls met on the way: ``vpdpwssd`` in a single dependency chain (above); a vertical-first pass order for every downscale was measured and not adopted, because the result would then depend on the build; and the horizontal kernel file is compiled twice, so a change to the 8-bit kernels changes fast mode's 16-bit ones with it, which is intended, and their tests cover both. Box blur, Gaussian blur and unsharp mask ---------------------------------------- ``ImageFilter.BoxBlur``, ``ImageFilter.GaussianBlur`` and ``ImageFilter.UnsharpMask`` (``BoxBlur.c``, ``UnsharpMask.c``). All three are box blurs underneath: a Gaussian blur is three box passes, and an unsharp mask is a Gaussian blur followed by one pass over the pixels. The speed-up here comes as much from the order the passes run in as from the vector code. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ A box blur of radius *R* (a float) averages, along one axis, the 2\ *R* + 1 pixels around each pixel: the 2\ *r* + 1 whole pixels, *r* = ⌊\ *R*\ ⌋, with the weight ``ww`` = ⌊2²⁴ / (2\ *R* + 1)⌋, and the two pixels just beyond them with the fractional weight ``fw`` = (2²⁴ − (2\ *r* + 1) ``ww``) / 2, so that the weights add up to 2²⁴ (less a rounding). With ``acc`` the sum of the whole pixels and ``left`` and ``right`` the two beyond, each channel is:: out = (acc * ww + (left + right) * fw + (1 << 23)) >> 24 in 32-bit unsigned arithmetic (``acc * ww`` is at most 255 · 2²⁴, so nothing wraps). Pixels beyond the edges repeat the edge pixel. A blur is *n* such passes along x, then *n* along y (``BoxBlur`` takes one each; the C function takes any number). ``GaussianBlur(radius)``, whose radius is the standard deviation σ, runs three box passes per axis, with the box radius that makes three boxes approximate the Gaussian (Gwosdek et al., *Theoretical foundations of Gaussian convolution by extended box filtering*, 2011, in ``_gaussian_blur_radius()``): for σ = 1 each pass has radius 0.25, three taps with the weights 1/6, 2/3, 1/6; for σ = 10, 9.475. The passes cost the same for any radius, so the Gaussian's cost doesn't grow with σ. ``UnsharpMask(radius, percent, threshold)`` blurs the image with that Gaussian, then, for every byte with ``diff = in - blurred``, writes ``clip(in + diff * percent / 100)`` where ``|diff| > threshold`` and ``in`` elsewhere, with C's integer division (truncating toward zero). Pillow 13 runs every pass over the whole image, one pixel at a time, with the running sum in scalar variables (four of them for a 4-byte pixel), and blurs vertically by transposing the image, blurring rows and transposing back: three horizontal passes, a transpose, three passes and a transpose for a Gaussian, each a full read and write of the image. The idea ~~~~~~~~ Four changes, each keeping the arithmetic above: * **Vertical passes on rows.** The vertical pass keeps one 32-bit running sum per *byte of the row* (``linesize`` of them), adds the row entering the window and subtracts the one leaving it, and applies the weights to the whole row at once: ``acc[b] += add[b] - sub[b]`` for every byte, then the formula. It is the horizontal pass's arithmetic with the loop over pixels turned into a loop over rows, so it gives the pixels of the transposed computation (``test_vertical_matches_transposed_horizontal`` checks this), without the transposes, and it is as wide as the row. * **The passes of a direction together.** Horizontally, all *n* passes run on one row before the next, alternating between two row buffers, so the row stays in cache. Vertically, the passes form a cascade: pass *k* keeps its last 2\ *r* + 3 output rows in a ring, which is all pass *k* + 1 reads, and ``BoxBlurEnsure()`` computes each pass's rows as the next needs them; one sweep down the image writes the output. The intermediate images are gone: a Gaussian reads and writes the image twice instead of six times plus two transposes. Rings above 8 MB (very large radii) fall back to one pass at a time. * **One pass each way, fused.** ``BoxBlur``'s default, one pass per axis, fuses further: each row is blurred horizontally into a ring of 2\ *r* + 4 rows just before the vertical pass first reads it (``box_fused_row()``), so there is no temporary image at all, 16 MB to allocate, write and read for a 2560×1600 RGB image. Rings up to 4 MB. * **Small radii without running sums.** A running sum is a serial dependency, each pixel waiting for the previous one, and in the horizontal pass it is four lanes wide at most. For radii below 2, and for the integer radii 1 to 4, the box is summed directly from its 2\ *r* + 3 sources, 16 or 32 bytes of the row per step: the sources are rows (vertical pass) or the row shifted by whole pixels (horizontal pass, with the edge pixels repeated in a padded copy of the row's ends). For the integer radii 1 to 4 ``fw`` is 0 (2²⁴ − 1 is divisible by 3, 5, 7 and 9) and the division by ``ww`` is done in 16 bits: ``(((c << s) + k) * m >> 16) >> t`` with constants per radius found by a search around *m* = ``ww`` · 2\ :sup:`t − s − 8`. ``BoxDirectInit()`` checks the 16-bit form against the 24-bit one for every possible sum *c* (at most 255 · 9 of them) at the start of each blur, and uses it only if they all agree. The radii between (2.5, say) take the running sums. Two more things decide the speed: 1-byte images are blurred four rows at a time on x86, the bytes of rows *y* to *y* + 3 interleaved into one row of 4-byte pixels, blurred with the 4-byte kernel (the same arithmetic per byte) and de-interleaved, the rows left over taking the scalar 1-byte running sum (``test_l_matches_rgb_band``); and the vertical pass, which touches rows 2\ *r* + 3 rows apart, is the one that gained 1.5x from transparent huge pages. x86-64 ~~~~~~ *Horizontal running sums* (``ImagingLineBoxBlur32``) are the form Pillow-SIMD has had since 2015: one pixel as four 32-bit lanes (``ImagingPx4.h``: ``pmovzxbd`` to load, ``paddd`` and ``psubd`` for the sum, ``pmulld`` by the weights, a shift and a ``pshufb`` to pack the four bytes). The loop body is one macro, ``BOX_LINE_BLUR``, that takes the vector type's operations as macros; with AVX2 the same macro runs on two rows at once, one per 128-bit half (``ImagingLineBoxBlur32x2``), so two rows cost the instructions of one; 1-byte images then take eight rows per step. *Vertical running sums* (``ImagingBoxBlurColumns``) take 32 bytes of the row per step with AVX2, 16 with SSE4: four groups of 8 bytes of ``add``, ``sub`` and the far row widened to 32 bits, the sums updated and stored, ``pmulld`` by ``ww`` and ``fw``, the rounding shift, then the four results packed to bytes. ``packssdw`` and ``packuswb`` pack within 128-bit lanes, so the AVX2 version ends with a ``vpermd`` that puts the four groups back in order. *The far weight of zero (2026-11-05).* Each output byte is two products, the box's sum by ``ww`` and the two bytes just outside the box by ``fw``, which is what the integer weight leaves of 2²⁴, halved: 0 whenever 2²⁴ is 0 or 1 modulo the box, as for the radii 1 to 4, 6, 7, 8, 10 and 17. The running sums, horizontal and vertical, then skip the second product (``BOX_STEP_WHOLE``, the ``whole`` argument of ``box_columns_body()``): it was half of the multiplies, and on the Cortex-A78, with one vector multiplier, the blur at radius 10 went from 57.1 to 42.3 million cycles on a 2560x1600 RGB image (1.35x); on x86, where ``pmulld`` is two µops, from 7.5 to 5.5 ms with AVX2 and 13.8 to 9.8 ms with SSE4.1. Adding zero changes no byte. Radii with a far weight (5, 9, 12, any fraction) run as before; taking the difference of the entering and leaving pixels first, to shorten the sum's chain to one addition, measured the same there. *Direct passes* (``box_direct()``) sum the inner sources in 16-bit lanes (``pmovzxbw``, ``paddw``), 32 bytes per step with AVX2, 16 with SSE4. The exact form then needs ``acc * ww + e * fw`` with 24-bit weights, which ``pmulld`` would compute in 32 bits at two µops and 10 cycles of latency each; instead the weights are split into 12-bit halves, ``ww`` = ``wh`` · 2¹² + ``wl``, and ``pmaddwd`` on the pairs (``c``, ``e``) against (``wh``, ``fh``) and (``wl``, ``fl``) gives:: ww * c + fw * e = ((wh * c + fh * e) << 12) + (wl * c + fl * e) two ``pmaddwd``, a shift and an add, with nothing above 2³² (``c`` is at most 765 here, the weights at most 2¹²). The 16-bit form for the integer radii stays in 16-bit lanes throughout, its multiply-high being ``pmulhuw``. The number of sources is a compile-time constant for each radius (``ImagingBoxBlurDirect()`` is a ``switch`` into an ``always_inline`` function), so the sum unrolls and the source pointers stay in registers: 1.46x at radius 2 over the loop that reloaded them. *Unsharp mask* (``ImagingUnsharpMaskRow``): 16 bytes per step, widened to 32 bits; ``diff * percent`` in 32 bits, converted to float, divided by 100 and truncated with ``cvttps2dq``, which equals C's integer division as long as ``diff * percent`` is below 2²⁴ in magnitude, so the vector loop takes ``percent`` between −65535 and 65535 only; ``|diff| > threshold`` as a mask for ``pblendvb``; and the signed and unsigned saturating packs are ``clip8()``. arm64 ~~~~~ The arm64 version is a different mix, decided by measurement against GCC's own vectorization of the portable loops, and it has changed with the compiler. *Horizontal running sums*: the ``px4`` NEON version (``vmovl`` widenings, ``vmulq_u32``, ``vmovn`` narrowings). It is 1.27x faster than the compiler's code for the portable loop with GCC 12 and 14 and was slower with GCC 11, so it was removed for a while and restored. 1-byte images keep the scalar running sum: the four-row interleave was 1 to 5% slower than the compiler's 1-byte loop on the Cortex-A78AE. *Vertical running sums*: the portable loop. GCC vectorizes it at ``-O3``, with a run-time check that the rows don't alias, and leaves it scalar at ``-O2``; a NEON version was measured slower than the compiler's code. *Direct passes*: 16 bytes per step. The sum uses the widening adds (``vaddw_u8``), and the exact form multiplies in 32 bits with ``vmulq_u32`` and ``vmlaq_u32``, one instruction each on arm64, where the 12-bit split was slower; ``vaddhn_u32`` adds 2²³ and keeps the high 16 bits of each sum, which is the ``>> 16`` of the rounding, and ``vshrn`` by 8 completes the ``>> 24``. The 16-bit form has no multiply-high instruction: ``vmull_u16`` makes the 32-bit products and ``vuzp2q`` keeps their high halves. *Unsharp mask*: the same steps as x86 with ``vdivq_f32``, a real vector division, cheap on this core; 1.31x over the compiler's multiply-high loop with GCC 12 and 14, not with GCC 11. Why the builds agree ~~~~~~~~~~~~~~~~~~~~ Everything is integer arithmetic modulo 2³², and every path computes the same integers: a running sum and a direct sum of the same box are the same number; the weights come from one float expression, evaluated the same way on every build; the 16-bit form of the direct passes is checked against the 24-bit form at run time, for every sum it can meet, before it is used; the cascade and the fused pass change where the intermediate rows live, not their values (``test_box_blur_fused_matches_passes`` compares the fused blur with the two passes run separately); and each vector loop leaves the end of the row to the scalar loop with the same formula (``Tests/test_simd_consistency.py``). The unsharp mask's float division is exact against the integer division in the range the vector loop accepts. ``checks/simd_hashes.py``'s ``blur/unsharp`` area agrees on every build, and so does Pillow's ``Tests/test_box_blur.py``, extended with the channel-order, one-pixel-line and transposition checks. Numbers ~~~~~~~ 2560×1600, exact mode, times faster than Pillow 13.0.0, which took the milliseconds shown. .. generated: kernels-blur (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Blur - Pillow 13 (x86, ms) - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - Gaussian RGB, σ 1 - 121 - 4.3x - 6.9x - 163 - 3.0x * - Gaussian RGB, σ 10 - 122 - 2.6x - 4.6x - 165 - 1.8x * - Gaussian RGB, σ 30 - 122 - 2.7x - 4.5x - 164 - 1.8x * - Gaussian RGBA, σ 10 - 122 - 2.7x - 4.6x - 163 - 1.8x * - Gaussian L, σ 10 - 37.1 - 3.5x - 6.2x - 54.3 - 1.9x * - Box RGB, radius 2 - 54.8 - 8.2x - 14.5x - 66.4 - 4.6x * - Box RGB, radius 10 - 54.6 - 5.6x - 9.9x - 66.9 - 3.2x * - Unsharp mask RGB - 198 - 5.0x - 7.2x - 237 - 3.2x * - Unsharp mask L - 54.9 - 6.0x - 8.9x - 70.1 - 4.0x .. generated-end The portable build alone is 1.4 to 2.1x Pillow 13 on x86 and 1.4 to 2.7x on arm64: that is the share of the passes' order. The σ 1 Gaussian and the radius-2 box take the direct passes, the others the running sums, which is why they gain more; and σ 10 and σ 30 cost the same. Gaussian L on arm64 gains nothing from NEON (the scalar build is 1.9x too): its horizontal pass is the scalar 1-byte running sum and its vertical pass is the compiler's code. Against Pillow-SIMD 12.1.1 with AVX2, the σ 1 Gaussian is 3.1x faster, the radius-2 box 8.2x and the unsharp mask 5.0x. AVX-VNNI changes nothing here: there is no ``pmaddwd`` chain to fuse. Against OpenCV on x86, a true Gaussian with one kernel of about 6σ + 1 taps per axis: OpenCV is 2.1x faster at σ 1 (7 taps against three passes of 3), and the AVX2 build 3.3x faster at σ 10 and 13x at σ 30, where OpenCV's kernel keeps growing. On the box blur OpenCV's radius 2 is 1.2x faster (it keeps 16-bit running column sums; a 16-bit vertical sum would be an exact-compatible change here, listed in ``design/PERFORMANCE_GAPS.md``), and its radius 10 is 1.16x slower. On arm64 OpenCV is ahead on the small radii (1.2x at σ 1, 1.4 to 1.5x on the box blur) and 4.5x behind at σ 10. Fast mode (the default) replaces the three passes with one kernel for σ up to 6: the σ 1 Gaussian 21.6x on x86 and 9.7x on arm64 (3.0x and 3.2x over the exact kernels, and ahead of OpenCV on both), the RGB unsharp mask 12.5x and 5.9x; see :ref:`fast-mode` and, until the fast-mode section is written, ``design/SIMD_FAST_KERNELS.md``. Code map ~~~~~~~~ * ``BoxBlur.c``: ``line_blur32()`` and ``ImagingLineBoxBlur8``, the horizontal running sums; ``BoxDirectInit()``, ``box_direct()``, ``ImagingBoxBlurDirect()`` and ``ImagingLineBoxBlurDirect()``, the direct passes; ``ImagingHorizontalBoxBlur()``; ``ImagingBoxBlurColumns()`` and ``ImagingVerticalBoxBlur()``; the cascade (``BoxBlurCascade``, ``BoxBlurEnsure()``, ``ImagingVerticalBoxBlurPasses()``); the fused pass (``BoxFusedRows``, ``box_fused_row()``, ``ImagingBoxBlurFused()``); ``ImagingBoxBlur()``, which chooses between them; ``_gaussian_blur_radius()`` and ``ImagingGaussianBlur()``, the fast-mode entry. * ``BoxBlur.h``: ``BOX_LINE_BLUR`` and its px4 instance ``box_line_blur32()``, ``BoxDirect``; ``ImagingPx4.h``: one pixel as four 32-bit lanes, SSE4, NEON and portable. * ``BoxBlurSIMD.c``, the ``blur`` group: the row blur in SIMD lanes and ``box_line_blur32x2()`` (AVX2), ``box_direct_simd()`` with the source count a constant per radius, ``box_interleave4()`` and ``box_deinterleave4()``, ``box_columns_simd()``. * ``GaussianFast.c``: fast mode's one-kernel Gaussian; ``GaussianFastSIMD.c``: its taps (the ``gauss`` group). * ``UnsharpMask.c``; ``UnsharpMaskSIMD.c`` (the ``unsharp`` group). * Tests: the end of ``Tests/test_box_blur.py``, ``test_box_blur_fused_matches_passes`` in ``Tests/test_simd_consistency.py``, the ``blur/unsharp`` and ``fast: gaussian`` areas of ``checks/simd_hashes.py``. Pitfalls met on the way: Pillow-SIMD's SSE4 store mask wrote lane 3 to byte 0, which a two-axis blur reversed twice and hid, so that ``BoxBlur((r, 0))`` swapped the channels (``test_channel_order``); the old kernel read past the end of 1-pixel lines; two rows per AVX2 vector were measured and dropped once (Gaussian 1.08x, unsharp 0.84x, with ``pmulld`` costing as much per element at 256 bits as at 128 and the packing of two rows adding work) and adopted after the passes were restructured, when the rows were in cache and the AVX2 vertical kernel came with them (Gaussian 57 to 28 ms); column strips to keep the vertical pass's accumulators in L1 measured 0.90 to 1.11x, because the misses the cache model counted are served by the hardware prefetcher, and were dropped; and the NEON decisions above each went the other way with another GCC, so they are re-measured when the compiler changes. Kernel filters -------------- ``ImageFilter.Kernel`` and the built-in 3×3 and 5×5 filters (``SMOOTH``, ``SHARPEN``, ``FIND_EDGES``, ``SMOOTH_MORE`` and the others), on 8-bit images (``Filter.c`` and the ``FilterSIMD_*.c`` files it includes). I and I;16 images keep Pillow's code. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ A kernel is given as integers with a scale and an offset; the coefficients are the integers divided by the scale (``SMOOTH``: 1/13 and 5/13; ``FIND_EDGES``: −1 and 8; ``SMOOTH_MORE``: 1/100, 5/100 and 44/100). For every pixel and channel, Pillow sums the 9 or 25 products of the coefficients and the neighbouring pixels in single-precision floats, adds the offset and 0.5, clamps to 0..255 and truncates; the border rows and columns, where the kernel doesn't fit, are copied from the input. Kernel row 0 applies to the image row *below* the pixel (``y + 1``), the last row to the row above. Pillow 13's loop is scalar, one channel at a time, and the portable build is no faster than it. Pillow-SIMD's method, which this fork keeps, is the fixed-point one: the coefficients as 16-bit integers with 13 fractional bits (``PRECISION_BITS`` = 8 + 5: 8 for the result and 5 for a sum of up to 25 coefficients), ``pmaddwd`` into 32-bit sums, the rounding as an added half (``offset += 1 << (precision - 1)``), an arithmetic shift and a saturating pack to bytes. A coefficient of 4 or more doesn't fit 16 bits at that precision, and Pillow-SIMD sent those kernels to float versions of the same loops. The idea ~~~~~~~~ Four changes since Pillow-SIMD: * **The lower exact precision.** When a coefficient doesn't fit at 13 fractional bits, ``ImagingFilterSIMD()`` tries 12, 11, …, and takes the highest precision at which every coefficient *and the offset* are exact integers once scaled. ``FIND_EDGES`` and ``CONTOUR`` (−1 and 8, with the offset 0 or 255) take 11 bits, ``EDGE_ENHANCE`` (−1/2, 5) 12, ``EDGE_ENHANCE_MORE`` (−1, 9) 11. The sums are then exact integers, and adding the half and shifting rounds them half up exactly as Pillow's ``+ 0.5`` and truncation do, so these kernels give Pillow's pixels and run at the integer kernels' speed: ``FIND_EDGES`` on RGB from 9.5 to 4.3 ms with AVX2, 53 to 38 on the Jetson. Only a kernel whose coefficients are inexact at every precision down to 1 bit (a custom one with 5.5 in it, say) still takes the float kernels. * **Integer kernels for 1-byte images** (``FilterSIMD_NxNi.c``). Pillow-SIMD filtered L images with float kernels and horizontal adds, 2.7x the cost per byte of the RGB kernel. The integer kernel for 1-byte images is one function for both sizes and both instruction sets, written over two primitives (a multiply-accumulate of 16 bytes by a pair of taps and a shift-and-saturate pack), 16 bytes per step with SSE4 and NEON, 32 with AVX2, and the same arithmetic as the 4-byte kernels; L images filter 1.4x (3×3) to 2x (5×5) faster than with the float kernels, and x86 and arm64 give the same L pixels. * **Constants where the compiler needs them.** The shift count is ``PRECISION_BITS`` as a constant wherever that is the precision (a wrapper calls the kernel once with the constant and once with the variable, ``FILTER_NxN``), because a count in a register costs a µop per shift on x86 (``SHARPEN`` was 12% slower with AVX-VNNI) and NEON then fuses the shift and the narrowing into one ``sqshrn`` (6% on the Jetson); the kernel bodies are ``SIMD_FORCE_INLINE``, so that the two calls don't stop GCC inlining them; and the 3×3 kernel for 1-byte images copies its nine coefficients to a local array, because the byte stores to the output could alias the coefficient pointer and GCC reloaded all nine on every step (L ``SHARPEN`` 11% on NEON, 8% with AVX2). * **AVX-VNNI.** Every ``add(acc, madd(a, b))`` of the integer kernels is ``mm_dpwssd`` or ``mm256_dpwssd``: one ``vpdpwssd`` with AVX-VNNI, the pair without, the same sum modulo 2³². The integer filters are where it pays most: 1.35x (3×3) and 2.1x (5×5) over AVX2 on RGB. x86-64 ~~~~~~ *4-byte pixels, integer kernels* (``FilterSIMD_3x3i_4u8.c``, ``FilterSIMD_5x5i_4u8.c``, Pillow-SIMD's). One ``pshufb`` turns a load of four pixels into channel-major pairs, so that after widening to 16 bits each 32-bit lane of ``pmaddwd`` holds two horizontally adjacent pixels of one channel; the kernel vector holds a row's taps in that pair layout with zeros, and ``pshufd`` of it selects which two taps multiply which pair. So one load of four pixels per kernel row serves four output pixels, each ``pmaddwd`` making one output pixel's four channels for two taps, and a 3×3 kernel is six ``pmaddwd`` per output pixel. The AVX2 version copies the four pixels into both 128-bit halves and applies a differently aligned kernel in each half: two output pixels per multiply-add. The assessment that this "isn't really 256-bit" and eight consecutive pixels per step would be better was wrong: that saves shuffles but not multiply-adds, and measured 0.93x (3×3) and 1.02x (5×5) with AVX2, 0.89x and 0.46x with AVX-VNNI. *1-byte pixels, integer kernels* (``FilterSIMD_NxNi.c``). A step loads 16 bytes at each tap's offset, widens them to 16 bits and interleaves the two loads of a pair of taps (``punpcklwd``/``punpckhwd``), so that ``pmaddwd`` computes ``a[i] * k0 + b[i] * k1`` for 16 bytes into four accumulators; the odd last tap of a row is paired with 0. With AVX2, 32 bytes per step in the same way, the pairs interleaved within each 128-bit lane and a ``vpermq`` after the packs putting the 32 results back in order. *Float kernels* (``FilterSIMD_3x3f_4u8.c``, ``5x5f_4u8``, ``3x3f_u8``, ``5x5f_u8``). For 4-byte pixels, one pixel's four channels are one float vector (``pmovzxbd``, ``cvtdq2ps``) and the sum is nine (or 25) multiply-adds per pixel, three pixels per step; the AVX2 3×3 version takes eight pixels per step with ``vperm2f128`` sliding the window. For 1-byte pixels the three taps of a row are one dot product with horizontal adds (``haddps``), two rows at a time; the AVX2 version runs the same operations in the same order for eight pixels. The rounding is ``cvtps2dq``, to nearest even, where Pillow adds 0.5 and truncates, and the summation order differs from Pillow's, so these kernels can differ from the scalar build by one level (below). arm64 ~~~~~ *Integer kernels*: the generic kernel of ``FilterSIMD_NxNi.c`` for both pixel sizes, with NEON's primitives: ``vmlal_n_s16``, a widening multiply-accumulate of 8 lanes by one coefficient, one per tap (NEON has no pair sum), into four 32-bit accumulators, and ``vqmovn_s32`` plus ``vqmovun_s16`` to pack, which with the constant shift become ``sqshrn``. The 4-byte pixels go through the same loop, 4 pixels per step; the same arithmetic as x86's, and the same pixels. *Small coefficients (2026-11-05)*. The Cortex-A78 runs vector multiplies on one pipe, and the generic kernel spends four of them per tap and 16 bytes, 9 cycles per pixel of a 3×3 kernel on RGB, which is most of what it costs: the reason NEON gains 4 to 5x here where AVX2, with ``pmaddwd`` on two ports, gains 13x and more. For a kernel whose fixed-point coefficients and offset share a power of two, the sum is that power times a small sum, so ``filter_small_kernel()`` in ``Filter.c`` divides it out, and where the coefficients left are at most 255 in size and every sum of a window fits 16 bits, ``ImagingFilterSmall_simd()`` adds them in 16-bit lanes, eight per operation: a coefficient of 1 or -1 is a widening add or subtract, any other one widening multiply-accumulate of bytes. Shifting the small sum by the precision that is left (with its own rounding; none when the whole precision divided out, where the half can never carry) gives the same bytes as the generic kernel, which keeps every other kernel. Seven of Pillow's ten built-in filters reduce: ``SHARPEN`` to -1 and 16 at 3 bits, ``FIND_EDGES`` and ``CONTOUR`` to -1 and 8 with no shift, ``EMBOSS`` to -1, 0 and 1, ``BLUR`` to ones at 4 bits, ``EDGE_ENHANCE`` and ``EDGE_ENHANCE_MORE``; ``SMOOTH``, ``SMOOTH_MORE`` and ``DETAIL`` (scales 13, 100 and 6) do not. On the Jetson, 2560×1600 RGB: ``SHARPEN``, ``FIND_EDGES`` and ``EDGE_ENHANCE`` 27 to 14.7 ms (1.8x), ``BLUR`` 24 ms, ``EMBOSS`` 10; L ``SHARPEN`` 6.0 to 4.0 ms. The NEON and scalar builds hash every filter the same before and after, and ``test_integer_kernels_with_power_of_two_scale`` checks ten kernels against the definition at widths around the vector's. Splitting the adds into two chains was measured and is slower (0.8x), as it was for the generic kernel. *Float kernels* (``FilterSIMD_NxNf.c``): 4-byte pixels only, 16 bytes per step as four float vectors. On arm64 GCC compiles Pillow's float loop with fused multiply-adds, so the "scalar" result there is already contracted; the NEON kernel reproduces GCC's contraction (each kernel row as ``vmulq_n_f32`` then a chain of ``vfmaq_n_f32``, rows added to the sum in order, clamp, truncate) and the leftover bytes use ``fmaf()``. It matches the arm64 scalar build bit for bit for exact coefficients, and in about 0.05% of bytes for an inexact one GCC's own loop fuses differently. 1-byte images keep the generic code: GCC vectorizes it well, and the NEON float kernel was 1.5x slower (``CONTOUR`` on L 15.5 against 22.9 ms). Why the builds agree, and where they don't ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The integer kernels are the same arithmetic on SSE4, AVX2, AVX-VNNI and NEON, 16-bit products summed in 32 bits modulo 2³² and one rounding, so all four give the same pixels; and when the coefficients are exact at the precision used (the built-in kernels whose scale is a power of two, 1 or 16, and the lower-precision ones above) those pixels are Pillow's, the scalar build's. When a coefficient is inexact at 13 fractional bits it is rounded (1/13 becomes 630/8192), and the SIMD builds differ from the scalar build by at most one level: on the test image ``DETAIL`` in 9% of the bytes, ``SMOOTH_MORE`` in 4%, ``SMOOTH`` in none (its rounded coefficients sum to 8191/8192). This is the one place in exact mode where a SIMD build doesn't give Pillow's pixels on purpose; it is Pillow-SIMD's behaviour and was kept. The float kernels differ from the scalar build by one level in about 0.2% of bytes, and agree between the x86 tiers: the 3x3 kernel for 4-byte pixels had an AVX2 version that summed two columns in the two 128-bit halves and added them last, 0.2% of bytes off the SSE4 kernel's rounding, and it was dropped (it was also 20% slower than the SSE4 kernel on the Core Ultra 9 285H); the 1-byte AVX2 kernel equals SSE4 by construction. The checks: ``test_filter`` in ``Tests/test_simd_consistency.py`` (flat images stay flat inside the border for integer and float kernels, every width and mode, so the vector loop and its scalar tail agree), ``test_float_kernel_l_matches_rgb_band`` and ``test_i16_uses_generic_kernel`` in ``Tests/test_image_filter.py``, and, when the kernels were written, 3,268 filter outputs compared across the SSE4, AVX2 and AVX-VNNI builds. The kernel filters have no area in ``checks/simd_hashes.py``, whose areas are those where the scalar build agrees. Numbers ~~~~~~~ 2560×1600, times faster than Pillow 13.0.0, which took the milliseconds shown. .. generated: kernels-filters (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Filter - Pillow 13 (x86, ms) - SSE4 - AVX2 - AVX-VNNI - Pillow 13 (arm64, ms) - NEON * - RGB 3×3 SMOOTH - 51.4 - 8.0x - 12.3x - 16.4x - 98.5 - 3.6x * - RGB 3×3 SHARPEN - 52.9 - 8.2x - 12.6x - 16.9x - 99.2 - 6.7x * - RGB 3×3 EDGE_ENHANCE - 56.0 - 8.2x - 12.7x - 16.3x - 101 - 6.8x * - RGB 3×3 FIND_EDGES - 147 - 21.4x - 33.6x - 42.9x - 146 - 9.9x * - RGB 5×5 SMOOTH_MORE - 134 - 7.5x - 8.9x - 18.9x - 228 - 3.5x * - L 3×3 SHARPEN - 16.6 - 5.8x - 12.1x - 14.8x - 33.1 - 8.3x * - L 5×5 SMOOTH_MORE - 42.5 - 5.6x - 12.6x - 14.4x - 75.3 - 4.5x .. generated-end The SIMD time of a 3×3 kernel on RGB is the same whatever the kernel, 4.2 to 4.4 ms with AVX2; ``FIND_EDGES``'s larger factor is Pillow 13's own time on it, 147 ms against 51 for the other kernels. Against Pillow-SIMD 12.1.1 with AVX2, the kernels it ran as integers are equal (``SMOOTH`` 12.5x there), ``FIND_EDGES``, which it ran in float, is 2.2x faster, and L images, which it filtered in float, 3.4x (3×3) and 3.6x (5×5). Against OpenCV's ``filter2D``, which accumulates in 32-bit floats, the AVX2 build is 1.5 to 1.6x faster on the 3×3 kernels and 1.1x on 5×5, the AVX-VNNI build 1.9 to 2.1x and 2.3x, and the NEON build 1.5x to 3.3x, the most on the kernels with small coefficients (below); libvips' convolution is slower than Pillow 13 on 5×5. Code map ~~~~~~~~ * ``Filter.c``: Pillow's ``ImagingFilter3x3()`` and ``ImagingFilter5x5()`` (the definition, and the I, I;16 and portable paths), ``filter_int_kernel()`` and the precision search in ``filter_simd()``, which picks the kernel from the active tier's table. * ``FilterSIMD.c``, the companion: it includes the eight files below and defines the ``filter`` group. * ``FilterSIMD_3x3i_4u8.c``, ``FilterSIMD_5x5i_4u8.c``: the x86 integer kernels for 4-byte pixels, SSE4 and AVX2. * ``FilterSIMD_NxNi.c``: the integer kernel for 1-byte pixels on x86 (and for both pixel sizes on arm64), with the SSE4/AVX2 and NEON primitives; ``ImagingFilterSmall_simd()``, the arm64 kernel for small coefficients (``filter_small_kernel()`` in ``Filter.c`` reduces them). * ``FilterSIMD_3x3f_4u8.c``, ``FilterSIMD_5x5f_4u8.c``, ``FilterSIMD_3x3f_u8.c``, ``FilterSIMD_5x5f_u8.c``: the x86 float kernels; ``FilterSIMD_NxNf.c``: the NEON one. * ``ImagingSIMD.h``: ``mm_dpwssd``, ``mm256_dpwssd``, ``SIMD_FORCE_INLINE``. * Tests: ``test_filter`` in ``Tests/test_simd_consistency.py``; the end of ``Tests/test_image_filter.py``. Pitfalls met on the way: Pillow-SIMD sent I;16 images to the 8-bit kernels, which filtered the bytes of the 16-bit samples over half of each row (``test_i16_uses_generic_kernel``); passing the precision as a variable cost 12% on x86 and un-inlined the 1-byte kernel, and the inlining then exposed the aliasing reloads, so one change needed two follow-ups; AVX2 5×5 float kernels gained nothing for 4-byte pixels and lost (0.57x) for 1-byte ones, and were dropped; and on arm64 the decision per pixel size went opposite ways for the float kernels, as it did for the blurs. Rank filters ------------ ``ImageFilter.MedianFilter``, ``MinFilter``, ``MaxFilter`` and ``RankFilter`` (``RankFilter.c``), and ``ImageFilter.ModeFilter`` (``ModeFilter.c``). These have the largest factors on this page, 50x to 500x, and most of each factor is the algorithm: the vector code is a minimum and a maximum of bytes, and the compiler writes much of it. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ A rank filter of size *s* replaces each pixel with the *k*-th smallest of the *s*\ ² pixels around it (*k* = 0 for the minimum, *s*\ ² − 1 for the maximum, *s*\ ² / 2 for the median), with the pixels beyond the edges repeating the edge pixels: ``RankFilter.filter()`` expands the image by *s* / 2 pixels on each side (``ImagingExpand()``, a copy) and filters the copy. Multi-band images are filtered band by band: ``Image.filter()`` splits them, filters each band and merges. A rank filter has no arithmetic, so there is nothing to round: any method that selects the *k*-th value gives the same pixels. Pillow 13 selects with Wirth's quickselect on a copy of each window (``memcpy`` of each row of the window, then a partitioning loop whose branches depend on the data: 40 to 70 mispredictions per pixel). A 3×3 median of a 2560×1600 RGB image takes 1.2 s, a 5×5 one 4 s. ``ModeFilter`` writes the most frequent value of the window if it occurs more than twice (the lowest of equally frequent ones) and the pixel itself otherwise; Pillow 13 builds a 256-bin histogram from scratch for every pixel and scans all of it. The idea ~~~~~~~~ * **Sorting networks over rows of pixels.** A sorting network is a fixed sequence of compare-exchanges, the same for every input; applied to *s*\ ² rows of 256 pixels at once (one row per window position), each compare-exchange is a minimum and a maximum of two rows of bytes, which has no branches and which every compiler vectorizes (x86-64's baseline has ``pminub``; the portable build is 50 to 85x Pillow 13 on the medians and 270x on minimum and maximum). The network is an optimal 25-comparator one for nine values, and for larger windows Batcher's odd-even merge sort for the next power of two with the comparators that touch wires past *s*\ ² left out (those wires can be taken as holding the largest value, which no comparator moves); and it is pruned to the comparators that the wire of the requested rank depends on, 113 of 140 for the 5×5 median, 319 of 394 for 7×7. Sizes up to 11×11 use this; larger ones keep quickselect. * **No expanded copy.** The fast paths read the image itself (``rankfilter(size, rank, True)``) and repeat the edges as they load: rows beyond the image clamp to the first or last row, and the chunks at a row's ends are filled with the edge pixel: a 3×3 median of a 1280×800 L image went from 2.4 to 0.95 ms. * **All bands in one pass.** For 4-byte pixels the neighbours of a byte are 4 bytes apart and the edges repeat whole pixels, so filtering every byte of the row with the same byte of its neighbours filters every band at once (``ImagingRankFilterSameSize()``), and ``Image.filter()`` hands those images over instead of splitting and merging. It also filters the padding byte of RGB, a quarter more work, so ``ImagingRankFilterAllBands()`` says when it pays: minimum, maximum, the register medians, and 4-band images; a 3-band image with the general network goes band by band, as it was 0.81x on arm64 otherwise. A subclass that overrides ``filter()`` keeps the band-by-band path, so that its override still runs. * **Medians in registers.** For 3×3 and 5×5 the nine or 25 neighbours are loaded straight from the rows into vectors and go through a median network there, Paeth's 19 comparators for nine values and Devillard's 99 for 25 (checked for all 2²⁵ inputs of 0 and 1, which by the 0-1 principle covers every input); the compiler drops the half of each comparator whose result isn't used. 32 output bytes per step with AVX2, 16 with SSE4 and NEON. The 5×5 network needs more than SSE4's 16 registers, which spill: 10.3 ms for L against 4.6 with AVX2. * **Minimum and maximum separably.** The minimum of an *s*\ ×\ *s* window is the minimum over *s* rows of the minimum over *s* pixels of each row, 2\ *s* operations per pixel instead of *s*\ ². The row results go to a ring of *s* rows (they went to a whole-image buffer, 16 MB for RGB, freshly allocated on every call), and ``RankMinMaxLine()`` takes all *s* sources of a line in one vector pass, with the 3×3 case unrolled; the row ends clamp the pixel index, where a padded copy of each row had been 31% of a 3×3 minimum's time. Any size. * **The mode filter by columns.** The histogram is kept for the row and updated by one column entering and one leaving per pixel; windows of fewer than 16 values look up the counts of their own values only, and larger ones take the maximum of the 256 counts (a vectorized loop) and its first index. The same tie-break and the same "more than twice" rule: 2560×1600 L, size 3, 1195 to 94 ms, sizes 5 to 17 3.4x to 4.8x. * **The 3×3 mode in vectors (2026-11-04).** ``ModeFilterSIMD.c`` does the inner rows' pixels 16 or 32 at a time: the nine window bytes are loaded from the three rows, each is compared with the other eight (the 36 pairs once, each result subtracted from both counts), and the winner is chosen by packing the count above the inverted value in a 16-bit lane and taking the maximum of the nine, which is the highest count and, on a tie, the lowest value, as the scalar scan chooses; where the count is two or less the centre pixel is kept. The first and last pixel of a row, the row tails and the first and last rows go through a per-pixel count in ``ModeFilter.c``. 2560×1600 L: 63 to 4.5 ms (14x) on AVX2; the rank area of the hash set, which now holds the mode filter at sizes 3 and 5 on noise and on a flat-region image, agrees between the tiers and with the histogram code, and ``Tests/test_simd_consistency.py`` compares every width from 1 up against a per-pixel definition. The vector code ~~~~~~~~~~~~~~~ There is little of it, and it is the same on both architectures. The network path (``RankFilterNetwork_u8()``) is portable C, ``RankCompareExchange()`` over ``RANK_CHUNK`` = 256 bytes with ``restrict`` pointers, which GCC turns into ``pminub``/``pmaxub`` or ``umin``/``umax`` loops at ``-O2``; a chunk of every window position is copied into a buffer first (``memcpy``, or the edge fill). The register medians and the min/max line are written with four macros, ``RANK_LOAD``, ``RANK_STORE``, ``RANK_VMIN`` and ``RANK_VMAX``, defined for AVX2 (32 bytes), SSE4 and NEON (16); ``RANK_SORT(a, b)`` is a minimum and a maximum of two vectors, and the two networks are written out as lists of ``RANK_SORT``. Two details are for ``-O2``, where Debian, Ubuntu and conda build Python. The register medians index their vectors with constants only (``RANK_LOAD3``, ``RANK_LOAD5``): with the loads in a loop that GCC doesn't unroll at ``-O2``, one variable index kept the whole array in memory, 5.0 ms instead of 2.8 for a 3×3 median of RGB. And the min/max line loops take ``restrict`` sources, since GCC's ``-O2`` cost model leaves a loop scalar rather than add the run-time check that the rows don't overlap. Why the builds agree ~~~~~~~~~~~~~~~~~~~~ Nothing is computed, so nothing is rounded: every path returns the *k*-th value of the window, and a sorting network returns it whatever the input, which the 0-1 principle lets the median networks be checked for exhaustively. The checks compare the paths against each other and against Python references: ``test_rankfilter_3x3_all_ranks``, ``test_rankfilter_network_ranks`` (sizes 5 to 11), ``test_min_max_filter_any_size``, ``test_rankfilter_same_size`` (no expanded copy against the expanded copy, images smaller than the window included), ``test_rankfilter_all_bands_at_once`` (ten modes against band by band), ``test_median3_matches_reference`` at every width around the vector width, ``test_modefilter_matches_reference`` and ``test_rankfilter_subclass_filter_is_used``, all in ``Tests/test_image_filter.py``; ``checks/simd_hashes.py``'s ``rank filters`` area (L and RGB, sizes 3 to 11, median, minimum, maximum and a rank of *s*\ ² / 3) agrees on every build. Numbers ~~~~~~~ 2560×1600, times faster than Pillow 13.0.0, which took the milliseconds shown. The "portable" column is the fork's build without SIMD, where the compiler vectorizes the networks. .. generated: kernels-rank (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Filter - Pillow 13 (x86, ms) - Portable - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - Median 3×3 RGB - 1,182 - 51x - 216x - 421x - 1,262 - 153x * - Median 5×5 RGB - 4,018 - 52x - 127x - 250x - 3,942 - 83x * - Median 3×3 L - 384 - 87x - 281x - 541x - 411 - 191x * - Minimum 3×3 RGB - 817 - 269x - 440x - 463x - 886 - 253x * - Maximum 3×3 RGB - 811 - 252x - 439x - 462x - 881 - 253x .. generated-end In milliseconds, the AVX2 build's 3×3 median of RGB takes 2.8 ms, the 5×5 one 16, the L one 0.7, the minimum and maximum 1.7 and 1.9; the NEON build 8.2, 48, 2.1, 3.0 and 3.5. Pillow-SIMD has no rank filter kernels (1.0x). OpenCV's ``medianBlur`` (sorting networks too, KleidiCV's on arm64) is 1.3x faster on the 3×3 RGB median on x86 and 1.6x on arm64, level with the 5×5 and L medians on x86 and 1.1x to 1.3x ahead on them on arm64; its ``erode`` and ``dilate`` are 1.4x and 1.5x faster on x86 and within 10% on arm64. What remains of the gap is structural: OpenCV moves 3 bytes per RGB pixel where Pillow's layout has 4, and its morphology makes two output rows per step, sharing the rows they have in common (``design/PERFORMANCE_GAPS.md``). The mode filter isn't in the benchmark; its figures are above (the 3×3 kernel's are from ``checks/simd_bench.py``'s "mode 3 L"). Code map ~~~~~~~~ * ``RankFilter.c``: ``RankNetworkBatcher()`` and ``RankNetworkPrune()``, ``RankFilterNetwork_u8()`` (any rank, sizes 3 to 11), ``rank_minmax_line()`` and ``RankFilterMinMax_u8()``, ``ImagingRankFilter()`` (Pillow's entry, on an expanded image), ``ImagingRankFilterAllBands()`` and ``ImagingRankFilterSameSize()`` (the fast paths on the image itself, 1-byte and 4-byte pixels); ``RankFilter.h``: ``RankFillPixels()``. * ``RankFilterSIMD.c``, the ``rank`` group: ``RankFilterMedianVec_u8()`` with the two networks and the ``RankFilterMedian3_u8()``/``Median5`` entries, ``rank_minmax_line_simd()``. * ``ModeFilter.c``: ``ImagingModeFilter()`` with the column-updated histogram, ``mode_window_pixel()`` for the pixels the kernel leaves; ``ModeFilterSIMD.c``, the ``mode`` group: ``mode3_u8_simd()``. * ``src/PIL/ImageFilter.py``: ``RankFilter.filter()`` and ``RankFilter._filter_bands()``; ``Image.filter()`` in ``src/PIL/Image.py``, which calls the latter for multi-band images. * Tests: the rank and mode tests at the end of ``Tests/test_image_filter.py``. Pitfalls met on the way: the first version of "all bands at once" made the 5×5 median of RGB 0.81x on arm64, from the padding byte, hence the rule for when it pays; the register median was 1.8x slower at ``-O2`` than at ``-O3`` until the loads used constant indices; the min/max row results in a whole-image buffer cost page faults on every call; and a rank filter size set after ``__init__`` must still raise from ``expand()`` as Pillow's does, so ``filter()`` checks the size again before taking the path without the copy. Colour conversions ------------------ ``Image.convert()`` between the 8-bit modes (``Convert.c``, ``ConvertYCbCr.c``): the whole-pixel conversions (L to RGB, RGB to and from RGBA, RGB to CMYK), RGB to L, LA and I, premultiplying and unpremultiplying alpha (RGBA to and from RGBa, which every resize, rotation and transform of an RGBA image goes through), RGB to HSV, and RGB to and from YCbCr. Each is a row function that ``ImagingConvert()`` runs over the image. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ The whole-pixel conversions copy bytes and set the padding or alpha byte (CMYK is the bitwise inverse of RGB, with no undercolour removal). RGB to L is ITU-R 601-2 luma in 16-bit fixed point, ``(r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16``. Premultiplying is ``c * a / 255`` rounded (``MULDIV255``, an exact ``/ 255`` as ``(x + 128 + ((x + 128) >> 8)) >> 8``); unpremultiplying is ``255 * c / a`` in C's integer division, clipped to 255, and ``c`` unchanged where ``a`` is 0 or 255. RGB to HSV follows ``colorsys.py`` in single-precision floats, with a few steps in double, and truncates ``h * 255`` and ``s * 255``. RGB to and from YCbCr are JPEG's BT.601 formulas through twelve tables of 16-bit terms at 6 fractional bits, ``T[v] = (int)(v * K * 64 + 0.5)`` truncated toward zero, each sum of three terms shifted right by 6 (a floor); to RGB the result is clipped, to YCbCr it is taken modulo 256 as a ``(UINT8)`` cast does. Pillow 13's loops are scalar on x86; on arm64 GCC vectorizes the byte loops of the whole-pixel conversions, which matters below. The idea ~~~~~~~~ Most of these are bandwidth-bound: a 2560×1600 RGB image is 16 MB in and 16 MB out, and the AVX2 build and OpenCV both run L to RGB and RGBA to RGB at about 26 GB/s on the x86 machine (``design/PERFORMANCE_GAPS.md``). The work is to make every loop vectorize, and for the four that compute something (luma, unpremultiplication, HSV, YCbCr), to reproduce Pillow's arithmetic in lanes. * **Loops that vectorize under Python's flags.** The whole-pixel conversions are one ``pixel(out, in)`` function each, run by ``CONVERT_PIXELS``: chunks of 16 pixels with ``restrict`` pointers, which GCC vectorizes at ``-O2`` with no alias check and no remainder loop, and pointer increments rather than ``in + x * 4``, which ``-fno-strict-overflow`` lets wrap and GCC then refuses at any level. On arm64 the compiler's code for these loops changed with the optimization level (whole pixels built with widening multiplies, byte loops stored with ``st4`` at one level and scalar at the other), so the NEON build has explicit loops instead: ``vst4q`` of four planes for L to RGB, a vector and/or over whole pixels for RGB to RGBA and CMYK; and the arm64 portable build keeps Pillow's byte loop for L to RGB, which GCC does vectorize at ``-O3`` (the whole-pixel form was 0.79x there). * **Luma with 15-bit weights.** Pillow-SIMD's ``pmaddwd`` kernel needs signed 16-bit coefficients, and 38470 doesn't fit, so RGB to L, LA and I use ``(r * 9798 + g * 19235 + b * 3735 + 0x4000) >> 15``, which rounds one level away from Pillow's in a few pixels (an exception the ground rules list). The NEON kernel computes the same formula with the coefficients split into bytes (9798 = 38 · 256 + 70), so that ``vmull_u8``/``vmlal_u8`` products fit 16 bits: ``(hi * 256 + lo + 0x4000) >> 15`` equals ``(hi + (lo >> 8) + 64) >> 7`` for every input, and the three builds give the same bytes. * **Exact unpremultiplication.** Pillow-SIMD divided with a reciprocal estimate and was wrong at the edges: colour above alpha, which bicubic and Lanczos overshoot produce in every RGBA resize, overflowed to 0 where Pillow clips to 255 (nearly transparent pixels went black), alpha 0 gave colour 0, and other values were off by one. The x86 kernel now clips ``c`` to ``a`` first (which gives 255 where Pillow's clip would), takes ``rcpps`` of alpha scaled to a 16-bit factor, the quotient with ``pmulhuw``, and corrects it by one step from the remainder ``255 * c - q * a`` (the estimate is within one of the exact quotient); every value fits 16 bits, four pixels per SSE4 step, eight per AVX2 step. On arm64 one real division per pixel, ``(255 + 2⁻¹²) / a`` times ``c``, truncated, is exact for every pair (the two roundings stay within 2⁻²² of the product, and a non-integer quotient is at least ``1 / a`` from an integer), and faster still, 1.7x, is a 64 KB table of the scalar code's results, built on first use and read two pixels per 64-bit word; the float kernel finishes rows while another thread builds it. On x86 the table is slower than the vector kernel. Exactness cost 1.6x against Pillow-SIMD's approximate kernel. * **HSV lane for lane.** The scalar code's float and double steps are repeated per lane in the same order (``rgb2hsv_4``, ``rgb2hsv_8``, ``rgb2hsv_neon4``): three float divisions, the hue case selected with blends in the scalar code's priority, the ``2.0 + rc - bc`` terms in double and back, and ``fmod(h / 6 + 1, 1)``, which has no vector form, replaced by ``hd >= 1 ? hd - 1 : hd``, exact because ``h`` lies in [−1, 5]. No step is a multiply followed by an add, so arm64's fused multiply-adds can't change a result, and all 16.7 million RGB values give the same bytes on SSE4, AVX2, NEON and the portable build (which the ``fmod`` replacement alone made 2x Pillow 13). A reciprocal in place of the divisions was not exact; a multiplication by 1/6 was exact but no faster. * **YCbCr without lookups.** A vector can't index twelve tables cheaply, but it can compute the entries: ``T[v] = (int)(v * K * 64 + 0.5)`` in 32-bit floats gives every entry of every table (checked for all 256 values, with or without a fused multiply-add), so the kernels convert the channels to floats, compute the three terms, sum them as 32-bit integers and shift, as the tables do. To RGB, the saturating packs are the clip; to YCbCr, masking to a byte is the ``(UINT8)`` cast. Four pixels per SSE4 step, eight per AVX2 step; on NEON 16 pixels as planes (``vld4q``), in four quarters of 4 lanes. Also the premultiply, four, eight or 16 pixels per step with ``MULDIV255`` in 16-bit lanes (NEON's ``vraddhn`` does the ``+ 128 >> 8`` twice in two instructions). Why the builds agree, and where they don't ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Unpremultiplication and YCbCr are integer results, exact on every path: ``test_unpremultiply_exact`` and ``test_unpremultiply_every_value`` run all 65,536 (colour, alpha) pairs at several row widths, including colour above alpha, and ``test_ycbcr_conversions_exact`` checks the three pixels-at-a-time paths against the table formula in Python. HSV is checked for all 2²⁴ colours when a kernel changes, and the ``chops/convert`` and ``ycbcr`` areas of ``checks/simd_hashes.py`` agree on every build, YCbCr input outside the RGB range (which clips) included. RGB to L, LA and I are the exception: the SIMD builds, which agree with each other, round with 15-bit weights where Pillow and the portable build use 16, one level apart in a few pixels; the hash tool makes its inputs with ``frombytes`` for that reason, and ``test_consistency_i16`` builds its reference from the same conversion. Each vector kernel's leftover pixels go through the same kernel on a zero-padded buffer (``CONVERT_ROW``), so a pixel's result doesn't depend on its position in the row (``test_convert`` in ``Tests/test_simd_consistency.py``). Numbers ~~~~~~~ 2560×1600, exact mode, times faster than Pillow 13.0.0, which took the milliseconds shown. .. generated: kernels-conversions (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Conversion - Pillow 13 (x86, ms) - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - RGB to L - 2.1 - 1.8x - 1.9x - 2.6 - 1.1x * - L to RGB - 2.7 - 3.5x - 3.5x - 0.9 - 1.0x * - RGB to RGBA - 5.6 - 4.6x - 4.6x - 1.8 - 1.0x * - RGB to CMYK - 7.4 - 6.1x - 6.2x - 1.8 - 1.0x * - RGBA to RGBa - 9.0 - 5.0x - 6.7x - 3.4 - 1.1x * - RGBa to RGBA - 26.6 - 6.5x - 12.1x - 38.9 - 5.5x * - RGB to HSV - 59.7 - 3.7x - 6.7x - 81.6 - 2.3x * - RGB to YCbCr - 9.2 - 1.6x - 3.1x - 19.0 - 1.1x * - YCbCr to RGB - 10.8 - 3.0x - 5.7x - 14.3 - 1.2x .. generated-end The arm64 column shows what the x86 one hides: on arm64 Pillow 13's whole-pixel loops already ran vectorized (its L to RGB takes 0.9 ms there against 2.7 on the faster x86 machine), so the gain is in writing loops that vectorize *on x86 under Python's flags*; the gain on arm64 is nil, and both are at the memory's speed. The portable build is 3.5 to 6x on the whole-pixel conversions on x86, 2x on HSV and 1.0 to 1.3x on the others. On the benchmark revision it was 0.32x on RGBA to RGBa on arm64: the tail loop of that conversion, which is the whole loop in the portable build, indexed with ``x * 4``, the pattern the first bullet describes; it has pointer increments now, as Pillow's. Against Pillow-SIMD 12.1.1 with AVX2: the whole-pixel conversions 2.9 to 3.5x faster, HSV 6.7x and YCbCr 2.8x and 4.9x (it has no kernels there), and RGBa to RGBA 0.64x, the price of the exact division (its kernel takes 1.4 ms, the exact one 2.2). Against OpenCV, the whole-pixel conversions and RGB to L are within 1.3x either way, the difference being Pillow's 4 bytes per RGB pixel against OpenCV's 3; RGB to HSV is 2.5x slower on x86 and 2.6x on arm64 (OpenCV divides by 12-bit reciprocal tables, a different result, half of its hues one level from the true value as half of Pillow's are); RGB to YCbCr is 1.2x slower on x86 and 1.7x on arm64, YCbCr to RGB 1.3x faster on x86 and 2.1x slower on arm64 (OpenCV uses 14-bit integer coefficients with one rounding, where the exact kernels reproduce Pillow's tables in floats). Fast mode (the default) computes HSV and YCbCr with one rounding instead: HSV 20x on x86 and 7.7x on arm64, RGB to YCbCr 5.0x and 2.4x, YCbCr to RGB 7.2x and 3.0x, all ahead of OpenCV; see :ref:`fast-mode` and, until the fast-mode section is written, ``design/SIMD_FAST_KERNELS.md``. **The dithers (2026-11-04).** The Floyd-Steinberg conversions to mode 1 and to a palette are a serial chain of about ten dependent operations per pixel, which sets their speed, so they are not vector code; what changed is the schedule. ``tobilevel()`` and ``topalette()`` do two rows at a time, the second two pixels behind the first: a pixel needs the errors of the row above at x + 1, which the first row has written by then, so the two chains overlap, and the rows alternate between two error buffers. The per-pixel body is Pillow's, so the pixels are the same (checked over fourteen widths and eight heights, noise and gradients, with the palette dither's closing write of the blue terms into the slots past the row's end kept as it was): L to 1 1.74x, RGB to 1 1.60x, RGB to the web palette 1.62x on 2560×1600. Code map ~~~~~~~~ * ``Convert.c``: ``CONVERT_PIXELS`` and the ``*_pixel`` functions (whole pixels); the scalar ``rgb2l``, ``rgb2la``, ``rgb2i``, ``rgba2la``, ``rgb2hsv_row()`` and ``rgb2hsv_fast_pixel()``, ``rgbA2rgba()`` (premultiply) and ``rgba2rgbA()``, each calling the table's ``convert`` group first. * ``ConvertSIMD.c``, the companion: ``rgb2l_4px()`` and ``rgb2l_8px()`` with the ``rgb2l``, ``rgb2la``, ``rgb2i`` and ``rgba2la`` kernels; ``UNPREMULTIPLY`` and ``rgba2rgbA_4()``/``_8()``, ``neon_unpremultiply_16()``, the ``unpremultiply_table`` and ``unpremultiply_lookup()``; ``neon_muldiv255()``; ``rgb2hsv_4()``/``_8()``/``_neon4()`` and fast mode's loops; ``CONVERT_ROW``, which runs a kernel over a row with the zero-padded tail; ``convert_mask16()`` for the NEON and/or loops. * ``ConvertYCbCr.c``: the tables and the scalar loops of ``ImagingConvertRGB2YCbCr()`` and ``ImagingConvertYCbCr2RGB()`` and of the fast-mode conversions; ``ConvertYCbCrSIMD.c``: ``ycc_term()`` and the four vector loops; ``ConvertYCbCr.h``: the constants both use. * Tests: ``test_convert`` in ``Tests/test_simd_consistency.py``; ``test_unpremultiply_exact`` and ``test_ycbcr_conversions_exact`` in ``Tests/test_image_convert.py``; ``test_unpremultiply_every_value``. Pitfalls met on the way: Pillow-SIMD's unpremultiplication (above); results that depended on a pixel's position in the row, when the vector loop and its tail computed differently (fixed by ``CONVERT_ROW``'s padded tail); the AVX2 build's byte-by-byte CMYK being 15% slower than SSE4 until the loop worked on whole pixels; the whole-pixel loops being 3.4x slower than byte loops on arm64 with one GCC and the reverse with another, so arm64 got its own loops; and the tail loop with ``x * 4`` indexing that the benchmark caught after the page's own ground rules had described the problem. Compositing and channel operations ---------------------------------- ``Image.alpha_composite()`` (``AlphaComposite.c``), ``Image.blend()`` (``Blend.c``), ``Image.composite()`` and ``paste()`` with a mask (``Paste.c``), ``ImageChops`` (``Chops.c``), ``Image.point()`` and ``ImageOps``' invert, posterize and solarize and ``ImageEnhance`` (``Point.c``, ``ImageEnhance.py``), ``ImageFilter.Color3DLUT`` (``ColorLUT.c``), and ``split()``, ``merge()`` and ``getchannel()`` (``Bands.c``). Small kernels, each a few instructions per pixel, and mostly bandwidth-bound once written right. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ Alpha compositing is Porter-Duff *over* in integers with 7 extra bits: ``outa255 = sa * 255 + da * (255 - sa)``, ``coef1 = sa * 255 * 255 * 128 / outa255`` (one integer division per pixel), ``coef2 = 255 * 128 - coef1``, each colour ``(s * coef1 + d * coef2 + 0x80 * 128) / 255 >> 7`` with ``/ 255`` as ``(x + (x >> 8)) >> 8``, and pixels with ``sa`` = 0 keep the destination. Blend is ``in1 + alpha * (in2 - in1)`` in floats, truncated. Masked paste and ``composite()`` are ``BLEND(m, out, in) = (out * (255 - m) + in * m + 128) / 255`` with the same ``/ 255``. The chops: ``multiply`` is ``a * b / 255`` and ``screen`` its inverse, in C's integer division; ``add`` and ``subtract`` are ``(a ± b) / scale + offset`` in float, clipped; ``lighter``, ``darker`` and ``difference`` need no arithmetic. ``point()`` looks every byte up in a 256-entry table per band, into a zero-filled image; ``ImageEnhance`` blends the image with a "degenerate" one (grey, black, desaturated, blurred). The 3D colour LUT interpolates trilinearly between eight table entries, 16-bit values with 6 fractional bits, the weights at 15 bits. Pillow 13's loops for all of these are scalar on x86 except where GCC vectorizes them (``lighter``, ``difference``, blend with AVX2). The idea ~~~~~~~~ * **Alpha composite.** Pillow-SIMD's kernel, kept on x86: four (SSE4) or eight (AVX2) pixels per step, ``coef1`` from ``rcpps``, a reciprocal estimate, which rounds one level away from the integer division in 6% of the bytes, the one approximation left in exact mode on x86 (an exception the ground rules list; ``test_alpha_composite_every_alpha`` is skipped there). On arm64 the division bound the loop at about 12 cycles a pixel, a vector division or a corrected reciprocal was no faster, and GCC's vectorization of the portable loop beat the first NEON kernel; what won, 1.25x over that, is a 128 KB table of ``coef1`` for every (source alpha, destination alpha) pair, built on first use, with NEON for the blends (``ac_blend8``, the ``/ 255`` as two shifts and adds in 32-bit lanes): the same pixels as the scalar code. Where ``alpha_composite()`` takes a destination offset, the overlay is composited straight onto the destination's rows in place (``ImagingAlphaCompositeOnto()``) instead of through a crop, a composite and a paste, which is no SIMD but 2.2x. * **Multiply and screen without a division.** For every product ``x`` of two bytes, ``x / 255`` is exactly ``(x + 1 + (x >> 8)) >> 8``, and no step leaves 16 bits; screen is multiply with every byte inverted on the way in and out (``255 - (255 - a)(255 - b) / 255``). 32 bytes per AVX2 step, 16 per SSE4 or NEON step, and the portable loop uses the same formula, so even the portable build is 6x Pillow 13. ``add`` and ``subtract`` with scale 1 and offset 0 are one saturating add or subtract per vector (``paddusb``, ``vqaddq_u8``), which compilers don't find; with scale 1 and another offset an integer loop that vectorizes; with any other scale a 511-entry table of the same float expression for every possible sum, in place of a float division per byte. ``lighter`` and ``darker`` can't leave 0..255, so they skip the clipping that stopped them vectorizing. * **Tables that are a byte operation.** ``point()`` recognizes a table that equals ``v ^ c`` (invert is ``v ^ 255``), ``v & c`` (posterize) or ``v < t ? v : 255 - v`` (solarize) for all 256 values, in every band, and transforms whole rows with the byte operation instead (solarize as ``v ^ (v >= t ? 255 : 0)``, the comparison a ``pmaxub`` and ``pcmpeqb``); other tables keep the lookup, and the output is no longer zero-filled first, since every byte is written. A vector table lookup on x86 (``pshufb`` over 16 groups of 16 entries) was measured slower than the scalar lookup, 0.56 cycles per byte; on arm64 NEON's ``tbl``/``tbx`` look 16 bytes up in 64-entry groups held in registers, four instructions for a 256-entry table, and a row of 4-byte pixels is split into band planes so that each band's table is loaded once per row (L 1.9x, RGB 1.3x). * **ImageEnhance as a table.** Contrast and Brightness blend the image with an image that is constant in each band (alpha excepted), so each output byte is a function of the input byte: ``enhance()`` now blends a 0..255 gradient with those constants, through the same ``Image.blend``, and applies the result with ``point()``, instead of allocating a full-size degenerate image and blending it; the degenerate image is still made on request, for the subclasses that set it. The pixels are the same, since the same C code computes each byte. * **Masked fills.** ``ImageDraw.text()`` and ``bitmap()`` put ink through an L mask one byte at a time; ``fill_mask_L_simd()`` blends 16 bytes (or four 4-byte pixels) with ``BLEND``'s rounding in 16-bit lanes, the colour channels of transparent pixels taking a mask of 255 as the scalar expression gives; the SSE4 and NEON kernels exist, AVX2 uses SSE4's. The drawing section returns to it. * **Pasting through a mask (2026-11-05).** ``paste(im, box, mask)`` with an L mask, which is also ``Image.composite()``, and with an RGBA mask, which is how an RGBA image is pasted over another through its own alpha, blended a byte at a time; a compiler given AVX2 made vectors of the loop, and the wheel's baseline did not. ``paste_mask_L_simd()`` and ``paste_mask_RGBA_simd()`` are the masked fill's blend with the source row in place of the ink, 32 bytes or eight pixels at a time in AVX2, the mask byte spread to its pixel's four bytes with one shuffle. Mask composite on 2560x1600 RGB: 11.7 ms in the wheel and 6.3 ms in the AVX2 build before, 3.0 ms in both now; RGBA over RGB 12.0 and 5.8 ms before, 2.9 ms now. The rows of an image pasted into itself overlap, and those go to the scalar loop. * **The 3D colour LUT.** Pillow-SIMD's kernel on x86: the index and the interpolation weight of each axis in one 16-bit value (6 bits of index, 9 of weight), the eight entries interpolated with ``pmaddwd`` against (1 − *s*, *s*) pairs, one pixel per 128-bit vector and two per AVX2 vector, with ``vpdpwssd`` in the AVX-VNNI build. It uses its own precision, not Pillow's: the output differs from the scalar build's by up to two levels in about 10% of the bytes for a random table (none for a smooth one), the other exception the ground rules list. The NEON kernel computes Pillow's arithmetic instead, one pixel as four 32-bit lanes, and gives the scalar pixels. That is seven 32-bit multiplies a pixel on a core with one vector multiplier, so (2026-11-05) where every value of the table is within −16384..16383, about twice the range of the colours 0..1 and the case of every table that does not extrapolate far, ``neon_lut_pair()`` interpolates two pixels at once in 16-bit lanes: the weight is 15 bits, the difference of two entries fits 16, and ``sqdmulh`` gives ((*b* − *a*) · *s*) >> 15 exactly, so the seven multiplies serve two pixels, and one narrowing shift does the rounding and the clipping. 103.4 to 63.5 million cycles on a 2560x1600 image (1.63x, 52 to 32 ms), the same pixels as the scalar code; other tables keep the 32-bit kernel. Tables of each channel's cell offset and weight, in place of computing them for every pixel, measured 2% and were not kept. * **Bands.** ``split()`` and ``getchannel()`` transpose four pixels into planes with one ``pshufb`` on x86 and ``vld4q`` on NEON; ``merge()`` uses ``vst4q`` on NEON, and on x86 the compiler's code, since an explicit unpack kernel measured no better. Why the builds agree, and where they don't ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The chops, ``point()``, ``ImageEnhance``, the masked pastes and fills, blend and the band operations give the same bytes on every build: the ``paste/composite``, ``point/enhance`` and ``chops/convert`` areas of ``checks/simd_hashes.py`` agree, and ``test_multiply_screen_exact``, ``test_byte_operation_tables`` (every byte value, each pattern and a near miss), ``test_lookup_table_matches_blend``, ``test_point_table``, ``test_bitmap_mask_blend_exact`` and ``test_alpha_composite`` (flat images stay flat, vector loop and tail) check the paths against references. The two exceptions are on x86 only: ``alpha_composite()``, one level in 6% of the bytes (its hash area is named "x86 SIMD vs scalar: known"), and ``Color3DLUT``, up to two levels in about 10%, both Pillow-SIMD's kernels kept as they were. On arm64 both are exact, the alpha composite checked for every alpha pair (``test_alpha_composite_every_alpha``). Numbers ~~~~~~~ 2560×1600, exact mode, times faster than Pillow 13.0.0, which took the milliseconds shown. .. generated: kernels-compositing (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Operation - Pillow 13 (x86, ms) - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - alpha_composite RGBA - 19.6 - 3.8x - 6.8x - 22.1 - 1.2x * - blend RGB, 50% - 5.2 - 1.0x - 1.6x - 13.0 - 1.0x * - composite RGB, L mask - 11.7 - 3.0x - 4.0x - 11.1 - 1.8x * - ImageChops.multiply RGB - 14.7 - 6.6x - 7.5x - 14.0 - 3.8x * - ImageChops.screen RGB - 18.5 - 8.3x - 9.4x - 17.8 - 4.9x * - ImageChops.add RGB - 10.4 - 5.4x - 5.6x - 15.6 - 4.9x * - ImageChops.lighter RGB - 5.3 - 2.8x - 2.9x - 6.4 - 2.0x * - ImageOps.invert RGB - 4.1 - 3.2x - 3.3x - 10.0 - 5.3x * - point() RGB, a table - 4.1 - 1.1x - 1.1x - 10.0 - 1.3x * - point() L, a table - 1.7 - 1.1x - 1.1x - 3.7 - 2.0x * - Color3DLUT RGB, 16³ - 79.9 - 5.1x - 8.0x - 103 - 3.2x * - Color3DLUT RGBA, 16³ - 97.9 - 6.4x - 10.1x - 119 - 3.7x .. generated-end Blend, the masked composite and the plain table lookups are the compiler's code in exact mode (blend's 1.7x is GCC's AVX2 vectorization of Pillow's loop; an SSE4 kernel for it was measured and dropped), so their columns say what the compiler does; fast mode's blend, which interpolates in integers with ``pmulhrsw``, is 2.7x on x86 and 4.2x on arm64. The chops run at the memory's speed: against OpenCV, ``multiply`` is level, ``screen`` 3.8x faster (OpenCV has no screen and composes it), and ``add``, ``lighter``, ``difference`` and invert are 1.3 to 1.4x slower on both machines, the 4/3 byte ratio of Pillow's RGB layout. OpenCV's ``LUT``, a scalar loop over the whole image as one row, is 1.5x faster than ``point()`` with a table on x86 and level on arm64 for RGB, where NEON's lookups put the L table 1.4x ahead. Against Pillow-SIMD 12.1.1 with AVX2, the alpha composite and the LUT are its kernels and equal; ``multiply`` is 6.3x faster, ``add`` 9x and invert 3.3x. Code map ~~~~~~~~ * ``AlphaComposite.c``: ``alpha_composite_row()`` with the scalar definition, ``ImagingAlphaCompositeOnto()``; ``AlphaCompositeSIMD.c`` (the ``composite`` group): ``alpha_composite_4px()`` and ``_8px()`` (x86), ``ac_coef1_table`` and ``alpha_composite_16px_neon()``; ``AlphaComposite.h``: the pixel and the precision. * ``Chops.c``: ``chop_multiply_row()``, ``chop_saturate_row()``, ``chop_add_subtract()``, with ``ChopsSIMD.c`` (the ``chops`` group); ``Blend.c``: ``blend_fast_row()`` and Pillow's loops, with ``BlendSIMD.c``; ``Paste.c`` with ``PasteSIMD.c``: ``fill_blend16()``, ``fill_blend32()``, ``fill_mask_L_simd()``, ``paste_mask_L_simd()`` and ``paste_mask_RGBA_simd()``. * ``Point.c``: ``point_byte_op()`` and ``point_byte_row()``; ``PointSIMD.c`` (the ``point`` group): the byte operations' loops, ``neon_lut16()``, ``neon_lut_row()`` and NEON's whole-image lookups; ``Point.h``: the operation enum; ``src/PIL/ImageEnhance.py``: ``_Enhance._lut()`` and ``_levels()``. * ``ColorLUT.c``: the portable ``ImagingColorLUT3D_linear()``; ``ColorLUTSIMD.c`` (the ``colorlut`` group): ``mm_lut_pixel()`` and ``mm256_lut_pixel()`` with the x86 lookup, ``neon_lut_pixel()`` with the NEON one; ``ColorLUT.h``: the portable layout. * ``Bands.c``: ``ImagingGetBand()``, ``SPLIT_LOAD``/``SPLIT_STORE``, ``ImagingMerge()``; ``BandsSIMD.c`` (the ``bands`` group): the band extraction, split and NEON merge of a row. * Tests: ``test_alpha_composite``, ``test_alpha_composite_every_alpha`` and ``test_point_table`` in ``Tests/test_simd_consistency.py``; ``test_multiply_screen_exact`` in ``Tests/test_imagechops.py``; ``test_byte_operation_tables`` in ``Tests/test_image_point.py``; ``test_lookup_table_matches_blend`` in ``Tests/test_imageenhance.py``. Pitfalls met on the way: Pillow-SIMD's alpha composite gave a pixel a different value depending on its column, because the vector loop and its scalar tail rounded differently (the tail now runs through the vector code on a padded buffer); the first NEON alpha composite divided in double and lost to GCC's code with GCC 11, the second took a corrected reciprocal and lost to the table; the SSE4 blend and merge kernels measured no gain over GCC's; and ``pshufb`` table lookups on x86 lost to the scalar loop, where NEON's ``tbl`` won. Geometry -------- ``Image.transpose()`` (the flips, the 90° rotations, transpose and transverse), ``rotate()`` and ``transform()`` with the affine, perspective and quad methods (``Geometry.c``). The transposes move pixels; the transforms compute each output pixel from a filter over the source, in doubles. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ The 90° transposes move one pixel at a time, in 8×8 tiles within 512×512 chunks for the cache; the flips reverse rows, and flip top-bottom is a ``memcpy`` per row. A transform maps every output pixel's centre (``x + 0.5``, ``y + 0.5``) through the transform's equations in double (affine: two linear forms; perspective: two linear forms over a third; quad: bilinear in *x* and *y*), then samples the source there with a filter: nearest truncates the coordinate; bilinear interpolates the 2×2 neighbours, with indices clamped at the edges and a missing second row repeating the first; bicubic interpolates the 4×4 neighbours with a cubic in each direction (``p1 + d (p2 + d (p3 + d p4))`` with the ``p``\ s differences of the samples), rows missing at the edge repeating the one before. Each band goes through the filter separately, in double, and is truncated to a byte (the bicubic clamped first). ``rotate()`` is an affine transform; an affine transform with the nearest filter runs in 16.16 fixed point. Everything is one pixel at a time through a function pointer for the transform and one for the filter. The idea ~~~~~~~~ * **Transposes in register blocks.** The 8×8 tiles of the old loop wrote 32 bytes to each of their output rows, rows whose stride maps them to a few cache sets, so the lines were evicted and fetched again about three times before they were complete. Now a block of 8×8 bytes or 4×4 32-bit pixels is transposed in registers (SSE unpacks, NEON ``vtrn``), the mirrored variants of rotate 90 and 270 and transverse choose the order of the rows read and written, and the blocks go in columns of 64 bytes' worth of rows, so that each output row receives a whole cache line at once. The flips and rotate 180 reverse rows with ``pshufb``/``pshufd`` and ``vrev64``/``vext``, as fast as the ``memcpy`` of flip top-bottom. I;16 and the portable build keep Pillow's loops. * **Transforms in tiles.** A rotation crosses a new source row every few pixels of an output row, so an output row walks over hundreds of source rows and nothing stays in cache. The output is computed in 64×64 tiles (``TRANSFORM_TILES``), whose source rows stay in cache; every pixel is computed from the same coordinates as before. The tiles alone make the portable build 4x faster on nearest rotation and 1.2x on bilinear and bicubic, and the fixed-point nearest path is tiled too. Other tile shapes were measured: 256×32 and 512×16 up to 3x slower than 64×64 on x86; 128×64 gives back the 4% the tiles cost a near-identity perspective transform on arm64, whose reads were sequential already, but brings nearest rotation from 10.9 to 25 ms. 64×64 stayed. * **A pixel's channels in one vector of doubles** (x86). The filters for 3- and 4-band images take the four channels together, one ``__m256d`` with AVX2 or two ``__m128d`` with SSE4, with the operations of the scalar filter in the same order: the bilinear ``a + (b - a) d``, the bicubic's four terms (``-a + b`` as ``b - a``, which rounds the same), the clamp and the truncation (``cvttpd2dq``). x86 compilers don't contract these to fused multiply-adds, so the pixels are the scalar filter's. The common cases call the transform and the filter directly instead of through pointers, so they are inlined (quad with bilinear 1.2x from that alone). * **A tile row's coordinates at once.** A perspective transform divides twice per pixel. ``perspective_row()`` computes the coordinates of a tile row of 64 pixels in a loop of fixed length, with the per-pixel expressions unchanged, which the compiler vectorizes (``vdivpd`` with AVX2: four divisions at a time); IEEE division is exact, so the coordinates are the same. RGB bilinear 62 to 44 ms, bicubic 131 to 90, L nearest 23 to 13. * **arm64: the filters' integer parts in lanes, two pixels at a time.** On arm64 GCC contracts the scalar filters to fused multiply-adds, so "the scalar result" there is the contracted one, and a NEON filter must contract the same way (the ground rules' exception). The first NEON filters, two doubles per vector with GCC's vector types so that the compiler fused them alike, were exact and no faster than the scalar code (bilinear 112 to 115 ms, bicubic 286 to 313) and were removed. The second kind won: the samples' differences (``b - a``, ``p2`` to ``p4``) are integers in the scalar filters, so they are computed for the four bands of two rows at once in 16-bit lanes, converted to double exactly through float (which holds them), and only then go through the same fused multiply-adds; and two output pixels whose source windows are both inside the image go through the filter together (``bilinear_filter32RGB_simd2``), so that their long chains of dependent operations overlap, the pixels at the edges one at a time. Rotate bicubic RGBA 1.59x, RGB 1.32x, bilinear 1.47x, perspective 1.25 to 1.30x, quad 1.37x on the Cortex-A78AE. Why the builds agree, and where they don't ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The transposes move bytes, and ``test_rotations_match_columns`` and ``test_flips_match_rows`` compare every variant with Python slicing at sizes that exercise the blocks, the bands and the edges. For the transforms, every build computes the same coordinates (the same expressions per pixel, or a vector division of them) and the same filter arithmetic in the same order, so all x86 builds give the same pixels, and so do all arm64 builds; x86 and arm64 differ from each other by the contraction, which is Pillow's own behaviour on arm64 (the ``transform`` area of ``checks/simd_hashes.py`` is one hash on x86 and another on arm64). ``test_multiband_matches_single_band`` checks each band of a transformed RGB, RGBX and CMYK image against the same transform of an L image, across several tiles; ``test_transform_pairs_match_single_pixels`` checks the arm64 pair filters against the single-pixel ones for every column of the output. Numbers ~~~~~~~ 2560×1600, exact mode, times faster than Pillow 13.0.0, which took the milliseconds shown. .. generated: kernels-geometry (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Operation - Pillow 13 (x86, ms) - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - FLIP_LEFT_RIGHT RGB - 2.0 - 1.5x - 1.5x - 2.1 - 1.2x * - ROTATE_180 RGB - 2.0 - 1.5x - 1.5x - 2.1 - 1.2x * - ROTATE_90 RGB - 3.6 - 1.8x - 1.8x - 6.0 - 1.2x * - ROTATE_270 RGB - 4.1 - 2.1x - 2.0x - 7.7 - 1.5x * - TRANSPOSE RGB - 3.6 - 1.9x - 1.9x - 6.1 - 1.2x * - ROTATE_90 L - 2.7 - 6.3x - 6.3x - 5.4 - 3.5x * - rotate 30°, nearest, RGB - 10.1 - 2.2x - 2.2x - 40.5 - 3.6x * - rotate 30°, bilinear, RGB - 75.9 - 1.7x - 2.1x - 128 - 1.7x * - rotate 30°, bicubic, RGB - 187 - 1.3x - 2.0x - 290 - 1.4x * - rotate 30°, bicubic, RGBA - 284 - 1.8x - 2.9x - 390 - 1.8x * - perspective, bilinear, RGB - 83.5 - 1.8x - 2.6x - 147 - 1.6x .. generated-end Nearest rotation's gain is the tiles (the portable build has the same 1.9x and 3.6x). Flip top-bottom, a ``memcpy`` per row, is 1.0x everywhere, and the flips are at the memory's speed. Pillow-SIMD has no geometry kernels. Against OpenCV: the 90° rotations and transpose of RGB are 1.1 to 1.5x faster than OpenCV's on x86 and 2 to 2.4x on arm64; the flips are 1.4x slower on x86, where OpenCV's are IPP's (the only places IPP still runs for it), and 1.2x and 1.8x faster on arm64; the L rotation is 1.4x behind KleidiCV's transpose on arm64. The exact transforms are 1.2 to 1.5x slower than ``warpAffine`` and ``warpPerspective`` on x86 and 2.1 to 2.5x on arm64: OpenCV computes in single-precision floats, eight or 16 output pixels per step across pixels, with an all-inside fast path, where Pillow's result is defined in double per pixel. Fast mode (the default) takes positions in fixed point and integer filters with one rounding, within 2 levels of the exact result: rotate bilinear 6.2x on x86 and 3.2x on arm64, bicubic 6.3x and 3.7x, RGBA bicubic 7.7x and 4.2x, perspective 6.0x and 2.9x, ahead of OpenCV on x86 and within 1.3x either way on arm64; see :ref:`fast-mode` and, until the fast-mode section is written, ``design/SIMD_FAST_KERNELS.md``. Code map ~~~~~~~~ * ``Geometry.c``: the six ``Imaging*`` transpose and flip entries, which call the table's ``geometry`` group first and keep Pillow's loops for the other types; the scalar filters (``bilinear_filter*``, ``bicubic_filter*``); ``ImagingGenericTransform()`` with the tile loops; the tiled ``affine_fixed()``; the fast-mode entry. * ``GeometryTransform.h``: ``affine_transform()``, ``perspective_transform()`` and ``quad_transform()``, ``perspective_row()``, ``TRANSFORM_TILES`` and ``PERSPECTIVE_TILES``, the clamps; shared with the companion so the filters inline. * ``GeometrySIMD.c``: ``transpose_4x4_32()``, ``transpose_8x8_8()`` and the ``TRANSPOSE_BLOCKS`` driver, ``flip_rows()``; the x86 vector filters (``transform_vec``, ``tv_bilinear()``, ``tv_bicubic()``, ``bilinear_filter32RGB_simd()``, ``bicubic_filter32RGB_simd()``) and the NEON ones (``tn_*``, the ``*_simd2`` pair filters); ``geometry_transform_simd()``, the tile loops with those filters and the arm64 ``TRANSFORM_PAIRS``. * ``TransformFast.c``: fast mode's map and tables; ``TransformFast.h``: the tile loops, ``tf_row()`` and the pixel kernels, instantiated twice, scalar in ``TransformFast.c`` and with the vector loops in ``TransformFastSIMD.c`` (the ``transform_fast`` group). * Tests: ``test_rotations_match_columns`` and ``test_flips_match_rows`` in ``Tests/test_image_transpose.py``; ``test_multiband_matches_single_band`` in ``Tests/test_image_transform.py``; ``test_transform_pairs_match_single_pixels`` in ``Tests/test_simd_consistency.py``. Pitfalls met on the way: the AVX2 build's nearest rotation was slower than the SSE4 build's (33 against 24 ms) with no AVX2 code in it, from the order of memory accesses alone, and both became 8.8 ms with the tiles, a reminder that a rotation is limited by where it reads, not by its arithmetic (the vector filters cut instructions by a third to a half and time much less, until the output was tiled); the NEON filters went in, came out and went back in a different form; converting each byte to double on arm64, as x86 does, was slower than the scalar filters; and three bytes of an RGB result copied from a general register went through the stack and stalled, so they are stored from the vector register (``vst1_lane``). Drawing and text ---------------- ``ImageDraw``'s shapes, outlines, flood fill and text (``Draw.c``, ``Fill.c``, ``Paste.c``, ``_imagingft.c``, ``ImageDraw.py``, ``ImageFont.py``). Pillow-SIMD never touched these, and a profile of the whole API (``design/PROFILE_ASSESSMENT.md``) found most of their time outside any pixel loop: in allocations, in calls through function pointers, in FreeType. The vector code here is small, the span fill and two blends; the rest is where the time was. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ Shapes are rasterized by scan lines: a polygon's edges are intersected with each row, the intersections sorted, and the runs between them filled with a horizontal-line primitive chosen for the pixel type (``DRAW`` holds a ``point``, an ``hline`` and a ``line`` per type). An RGBA ink on an RGB image blends each channel, ``BLEND(a, out, in) = (out * (255 - a) + in * a + 128) / 255``. A polygon outline wider than one pixel must stay inside the polygon, so ``ImageDraw.polygon()`` filled an image-sized ``1`` mask with the polygon and drew the wide edges through it. ``text()`` renders each glyph with FreeType and blends it through its 8-bit bitmap as a mask (``ImagingFill2``, the masked fill of the compositing section). ``floodfill()`` searched from the seed in Python, pixel by pixel. The idea ~~~~~~~~ * **Span fill.** ``hline32`` stored one pixel at a time behind a per-pixel mask test, which GCC doesn't vectorize at ``-O2`` even without the test (the length is unknown). Unmasked spans go through ``fill32()``, a loop over fixed chunks of four pixels, whose four stores every compiler merges into one 16-byte store at ``-O2`` on any architecture. Filled rectangles 6.45 to 2.85 ms at 1280×800. It is plain C in ``Draw.c`` and not a kernel of the table: it was one for a day (2026-11-04, 8 pixels per AVX2 store), and the call per span, with the caller's saved registers around it, cost 30% of the filled rectangles and 12% of the wide lines on arm64 and 7% of the lines on x86-64, for a loop that the stores bound either way (``design/NEON_ROUND_2026-11-04.md``). * **RGBA ink in lanes.** ``hline32rgba`` computes the ink's terms once (``in * a`` per channel) and blends four pixels per step in 16-bit lanes, ``out * (255 - a) + c + 128`` then the ``/ 255`` as two shifts and an add, the alpha byte kept; the scalar loop finishes the row and takes the masked case (``test_rgba_ink_blend_exact`` checks every alpha at several widths). AVX2 builds use the SSE4 kernel. Text and ``bitmap()`` got the same treatment in ``fill_mask_L_simd()``. * **Lines through direct calls.** Pillow 13 merged ``line8``, ``line32`` and ``line32rgba`` into one ``line()`` that calls ``point`` and ``hline`` through pointers, an indirect call per pixel of every thin line and of a rectangle outline's sides. ``line_generic()`` is force-inlined back into one function per pixel type, dispatched like the others: 500 bounding boxes with 2-pixel outlines 4.03 to 2.53 ms. * **Scan lines sorted in place.** The few intersections of a scan line went through ``qsort``, a call and a comparison through a pointer each, a large part of filling a polygon beyond writing its pixels. An insertion sort with the same comparison takes up to 32 values (equal values are interchangeable, so the order is the same): 200 polygons 32.7 to 27.1 ms, against 19.2 to write the same area as rectangles. * **Wide outlines without the image-sized mask.** The mask was 14 of the 16.5 ms of drawing 100 ROI polygons on 2560×1600: allocated and cleared for each. ``ImagingDrawPolygonInside()`` records the polygon's fill as spans per row from the *same* scan (``hline_record`` in place of the mask's ``hline``), sorts them by row with a counting sort and by *x* with an insertion sort, merges them, and cuts the wide lines' runs to them, filled unmasked (``hline_spans``): the same pixels (checked against Pillow's mask on random polygons in nine modes, partly outside the image, degenerate and self-intersecting, widths 2 to 15), 17.2 to 1.8 ms, and the work grows with the rows rather than the area, 1.7x more for polygons 1,400 pixels across. * **Arcs, chords and pie slices without allocating.** Each row went through the clipping tree as linked lists of segment ends, two ``malloc``\ s per segment at every node, about 500 per shape; a tree has at most 7 nodes, so a row has at most 16 ends, which now sit in fixed arrays merged by the same comparisons in the same order: 1.23 to 1.29x. * **Flood fill in C.** The result of ``floodfill()`` is the 4-connected region of pixels that match (within ``thresh`` of the seed's colour, or neither the fill nor the border value), and a filled pixel never matches again, so the order of the search doesn't change the pixels. ``ImagingFloodFill()`` is a scan-line fill in C for 8-bit modes, run only where it compares exactly as the Python search did (the seed reads back as the value, the arguments have a pixel's shape, the seed isn't negative), the search kept otherwise: 86 to 1.4 ms for a 320×200 region. * **Glyphs cached.** ``font_render()`` loaded and rendered every glyph twice per call, and every call did it again; ``getbbox()`` and the layout loaded each glyph for its box and its advance. What FreeType gives for a glyph index and load flags depends on nothing else (the size is fixed, no transform is set, glyphs sit on whole pixels), so each font keeps the rendered bitmaps as 8-bit grey, their offsets, the boxes and the advances in an open-addressing hash table, cleared when the variation changes or at 8 MB. 100 lines at 1280×800: 87.8 to 14.9 ms from the bitmaps and boxes, then 11.0 to 3.3 from the advances. Not SIMD at all, and the largest single gain in this section. Why the builds agree ~~~~~~~~~~~~~~~~~~~~ Nothing here changes an algorithm's output: the fills store the same ink, the blends keep ``BLEND``'s rounding in 16-bit lanes where every term fits, and the structural changes (the span mask, the fixed arrays, the C flood fill, the glyph cache) are checked against the code they replace: ``test_polygon_outline_matches_full_mask``, ``test_floodfill_c_matches_search`` (seven modes, and 1,500 random cases when it was written), ``test_rgba_ink_blend_exact`` and ``test_bitmap_mask_blend_exact``, ``test_glyph_cache_variation``, and renders and boxes compared for every font in ``Tests/fonts`` at three sizes, stroked and not. The ``draw`` area of ``checks/simd_hashes.py`` (rectangles, ellipses, pie slices, arcs, rounded rectangles and polygons in three modes, RGBA ink, flood fill) agrees on every build. Numbers ~~~~~~~ 2560×1600, times faster than Pillow 13.0.0, which took the milliseconds shown. The "portable" column is the build without SIMD: most of these gains are algorithmic. .. generated: kernels-drawing (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Drawing - Pillow 13 (x86, ms) - Portable - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - 200 filled polygons - 61.7 - 2.3x - 2.4x - 2.4x - 87.4 - 1.5x * - 200 filled ellipses - 3.3 - 1.5x - 1.5x - 1.5x - 5.9 - 0.9x * - 500 filled rectangles - 5.6 - 1.7x - 1.7x - 1.8x - 11.2 - 1.1x * - 500 lines, 5 px wide - 3.1 - 1.4x - 1.5x - 1.5x - 5.7 - 1.4x * - 500 boxes, 2 px outline - 4.2 - 1.7x - 1.7x - 1.7x - 3.7 - 1.0x * - 100 ROI polygons, 2 px outline - 19.4 - 7.6x - 8.0x - 8.0x - 22.1 - 5.6x * - 100 lines of text - 57.0 - 7.2x - 17.6x - 17.5x - 113 - 18.3x .. generated-end Pillow-SIMD is 1.0x on all of these. The ellipses and the boxes gain nothing on arm64, where Pillow 13's loops were already as fast as the new ones for their short spans. Against OpenCV, Pillow's filled shapes are 1.1 to 1.7x faster on x86 (ellipses 1.25x: OpenCV fills them as polygons of up to 72 vertices) and the ROI outlines 1.4x slower, from the rule that keeps a wide outline inside the polygon, which OpenCV's centred stroke doesn't have; on arm64 the lines and boxes are 1.8x and 2.4x faster, the ellipses 1.4x and the ROI outlines 1.2x slower. Text isn't comparable: OpenCV's is Hershey vector strokes, not glyphs. Code map ~~~~~~~~ * ``Draw.c``: ``fill32()``, ``hline32()`` and ``hline32rgba()``; ``line_generic()`` and ``line8``/``line16``/``line32``/``line32rgba``; ``sort_intersections()`` and ``polygon_generic()``; ``SpanMask``, ``hline_record()``, ``span_mask_build()``, ``hline_spans()`` and ``ImagingDrawPolygonInside()``; the clipping tree's fixed arrays (``event_list``, ``clip_tree_do_clip()``); ``ImagingFloodFill()``. ``DrawSIMD.c`` (the ``draw`` group): the fill and the RGBA blend loops. * ``Paste.c`` and ``PasteSIMD.c``: ``fill_blend16()`` and ``fill_mask_L_simd()`` (text and ``bitmap()``). * ``_imagingft.c``: ``GlyphCacheEntry``, ``glyph_cache_slot()``, ``glyph_advance()``, ``glyph_cache_clear()``. * ``src/PIL/ImageDraw.py``: ``polygon()``'s call to ``draw_polygon_inside``, ``floodfill()``'s conditions for the C fill. * Tests: ``Tests/test_imagedraw_simd.py``; ``test_rgba_ink_blend_exact``, ``test_bitmap_mask_blend_exact`` and ``test_floodfill_c_matches_search`` in ``Tests/test_imagedraw.py``; ``test_glyph_cache_variation`` in ``Tests/test_imagefont.py``. Pitfalls met on the way: a refactoring upstream (one ``line()`` through pointers) cost 1.5x on thin lines without anyone noticing, and the benchmark found it; the span mask gained less than hoped on the benchmark's small ROIs (2.0 to 1.8 ms), because the scan and the twelve wide lines remain, and more on large polygons; and the glyph cache had to keep FreeType's unsupported pixel modes as such, so that they still raise where they did. Codecs and packing ------------------ The PNG encoder's row filters and the decoder's unfiltering (``ZipEncode.c``, ``ZipDecode.c``), the raw codec's packing and unpacking (``Pack.c``, ``Unpack.c``), ``tobytes()``, WebP and JPEG decoding (``_webp.c``, ``JpegDecode.c``), and two neighbours: palette mapping (``Quant.c``, ``QuantHash.c``) and the histogram (``Histo.c``). The codecs themselves (zlib, libjpeg-turbo, libwebp) are untouched; what changed is what Pillow does around them, with one exception: a build with libdeflate (the wheels) compresses the PNG encoder's output and inflates the decoder's input with it instead of zlib, as below. What Pillow computes ~~~~~~~~~~~~~~~~~~~~ The PNG encoder tries the None, Up, Sub and Paeth filters on every row (Average too with ``optimize``), keeps the one whose bytes have the smallest sum of absolute values as signed bytes, libpng's heuristic, and hands it to zlib; the decoder undoes the row's filter byte by byte. The raw codec moves pixels between Pillow's layout and a "raw mode": ``RGB`` is three bytes per pixel, ``BGRX``, ``ABGR`` and the like are four in another order, and each has a packer and an unpacker. ``tobytes()`` ran the encoder in 64 KB chunks and joined them. Every WebP file went through libwebp's animation decoder, which decodes into its own canvas, and from there through a ``bytes`` object into the image. JPEG RGB rows came out of libjpeg-turbo as three bytes per pixel, expanded to four by Pillow. Palette mapping (``quantize()`` and ``convert("P")`` with an adaptive palette) builds the palette with median cut or maximum coverage, then maps each pixel to its nearest entry, remembering each colour's entry in a hash table and searching the palette from entry 0 in the order of the entries' distance from it, as far as one can still be nearer. The histogram counts each byte. The idea ~~~~~~~~ * **PNG filters 16 bytes at a time.** When encoding, each predictor reads only original bytes, so the filters vectorize across the row: Up and Sub are a byte subtraction; Average's ``(a + b) / 2`` rounded down is ``pavgb``, which rounds up, minus ``(a ^ b) & 1`` (NEON's ``vhadd`` rounds down); Paeth computes its three distances in 16-bit lanes (``|a + b - 2c|`` doesn't fit a byte) and picks ``a``, ``b`` or ``c`` with masks in the same order of preference; and the cost is ``min(v, -v)`` as unsigned bytes, summed with ``psadbw`` (NEON: widening pairwise adds). The bytes and the costs are the scalar loops', so the same filter is chosen and the file is byte for byte the same: PNG at level 1 89 to 51 ms at 1280×800, at the default level 436 to 399, zlib's matching being most of the rest. Decoding undoes only the Up filter in vectors: Sub, Average and Paeth depend on the pixel to the left, and one pixel per step, libpng's way, was 1.4 to 2.2x slower than the scalar loops, whose three or four byte positions run as independent chains (Paeth 80 to 152 ms). * **Byte-order shuffles.** RGBX to RGB and back are one ``pshufb`` per four pixels (the 16-byte store writes four bytes past them, so the loop stops six pixels early) or ``vld4q``/``vst3q`` on NEON; the eight 4-byte reorderings (BGRX, XRGB, XBGR, BGXR, BGRA, ABGR, ARGB, BGAR) go through one helper, ``permute4()``, which builds the shuffle mask and the fill of 0 or 255 from the permutation at run time. ``tobytes()`` with the image's own raw mode knows its size, so the encoder gets room for all of it: one chunk, which ``join`` returns as it is, 1.9x. * **WebP stills straight into the image.** A one-frame file whose frame covers the canvas is what the animation decoder decodes into a cleared canvas without blending; ``decode_still()`` calls ``WebPDecode`` into the image's own memory (when its rows are in one block), without the canvas and the two copies: 105 to 76 ms for a 2560×1600 RGB file. Animations and the other cases keep the frame decoder. * **JPEG to 4-byte pixels.** With libjpeg-turbo, RGB images are read with the raw mode ``RGBX`` (``JCS_EXT_RGBX``), so its SIMD colour conversion writes Pillow's layout itself, 16.7 to 15.9 ms; ``draft()`` takes it. Writing from RGBX was 3 to 7% slower than packing to RGB first, so saving is unchanged. * **Palette mapping.** Three changes, each keeping the palette and the pixels: the colour hash table's nodes come from 4,096-node blocks instead of one ``malloc`` each (a fifth of the time; the chains, table sizes and iteration order are the same); a direct-mapped cache of 2¹⁸ (colour, entry) words replaces the memo hash table; and with maximum coverage the search from entry 0 is a vector scan of every entry for the smallest of ``dist * 512 + rank``, ``rank`` being an entry's place in the scalar search's order, so that ties go where they did (the keys fit 27 bits), eight entries per AVX2 step, four per SSE4 or NEON step. The median-box search stays scalar: it starts near the answer, and a full scan was slower with many colours. Then (2026-11-04) the colour hash table itself, where the median cut spent most of its time: the nodes keep their hash, and the pixel table, whose comparison orders by that hash, walks its chains on integers instead of calling ``pixel_cmp``, which recomputed both pixels' packed values at every step; the bucket is a multiply-shift modulus instead of a division by the table's prime; a run of equal pixels is one insertion counting them all; and the passes that looked every pixel up again take the box from the leaves' pixel lists (each colour with its count; the hash is injective at every scale, checked over all 16.7 million colours) or, with a scaled table, from a direct map of scaled cell to box. Chains, merge points and iteration order are unchanged, so the palettes are bit-identical: median cut 2.1x, the adaptive conversion 1.7x on a 2560×1600 photo. * **The fast octree from the image's rows (2026-11-05).** ``quantize(method=FASTOCTREE)`` is two passes over the pixels, one adding each to its bucket of a colour cube and one looking each up, around a short computation of the palette. The quantizers' common interface takes the pixels as an array and returns the indices as another, four bytes a pixel each: for an RGB or RGBA image ``quantize_octree_rows()`` reads the image's rows and writes the P image's instead, so two 16 MB arrays, their page faults and two copies are gone (15.6 to 15.3 ms: the passes themselves were the time, 11.1 ms the first). A bucket of the cube is a count and four 64-bit sums, five additions to memory per pixel in 160 KB of buckets; for RGB the first pass now keeps the sums of r, g and b and the count as four 32-bit numbers per bucket, 64 KB in all, which one vector addition per pixel maintains (the pixel's bytes widened to four lanes, the fourth replaced by 1), and moves them to the buckets every 2²⁴ pixels, before 255 times that could overflow; the second pass reads the palette index from a table of the lookup cube's buckets, 16 KB instead of 160: 10.0 ms. The cube of an RGB image always has four bits of each channel, and with that a constant a pixel's bucket is three shifts by constants instead of six by variables, which on x86 without BMI2 are three micro-operations each: 7.9 ms, against 15.6 ms before in the AVX2 build and 25.1 ms in the wheel (and in Pillow 13), whose compiler had not vectorized the lookup. Two tables of sums for alternate pixels, so that neighbours in one bucket would not wait for each other's store, measured the same and were dropped. RGBA keeps the 64-bit buckets, its rows up to the first fully transparent pixel through the same loop; Pillow recolours every transparent pixel as the first one before quantizing, and both passes do that as they go. The palettes and indices are those of Pillow 13 (``Tests/test_image_quantize_octree.py``: an image through the rows against the same pixels as a P image through the array, and digests taken from Pillow 13). * **The histogram in four tables.** On x86, neighbouring pixels count into four separate tables, so that the increments of equal bytes don't serialize, and the padding bytes of 2- and 3-band images are skipped: 13.8 to 5.8 ms against Pillow 12.3. Pillow 13 then rewrote its own loops, which are level with the tables on x86 and 1.1x faster on arm64, where they are used. The masked histogram (2026-11-04) counts the same way, with the mask byte as the increment (0 or 1) instead of a branch per pixel, which on a random mask mispredicted: 12.5 to 2.1 ms on x86, the same counts. Why the builds agree ~~~~~~~~~~~~~~~~~~~~ The PNG files are identical because the filters' bytes and costs are: the ``png filters`` area of ``checks/simd_hashes.py`` hashes the files of six modes at two levels, and ``test_png_every_filter`` decodes every filter type on the six test shapes against a byte-by-byte reference. ``test_webp_still_matches_the_frame_decoder`` and ``test_jpeg_rgbx_decode_matches_rgb`` compare the new decoder paths with the old; the ``pack/unpack`` area hashes every raw mode at every width from 1 to 69 and at 1,280; the ``quantize`` area hashes the palettes and pixels of five images at six colour counts with both methods and the adaptive conversion, and agrees on every build, the vector search included (``Tests/test_codecs_simd.py`` holds the codec tests). Numbers ~~~~~~~ 2560×1600 RGB, times faster than Pillow 13.0.0, which took the milliseconds shown; the "portable" column is the build without SIMD. .. generated: kernels-codecs (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Operation - Pillow 13 (x86, ms) - Portable - SSE4 - AVX2 - Pillow 13 (arm64, ms) - NEON * - Encode PNG, level 6 - 792 - 1.8x - 5.1x - 5.0x - 1,020 - 4.3x * - Decode PNG - 100 - 1.5x - 1.6x - 1.6x - 110 - 1.4x * - Decode WebP - 112 - 1.5x - 1.5x - 1.5x - 109 - 1.2x * - Decode JPEG - 16.4 - 1.0x - 1.0x - 1.0x - 28.4 - 1.0x * - Median cut, 256 colours - 2,762 - 7.8x - 7.8x - 7.7x - 6,570 - 9.6x .. generated-end PNG encoding in the table is the libdeflate build's (below): several times Pillow 13's, and ahead of OpenCV's and libvips', which use libpng with zlib. With zlib the gain is the filters' alone: 1.2x in the portable build (the arithmetic) and 1.6x in vectors, level with OpenCV's; at level 1 the filters are a larger share and the gain 1.7x. **libdeflate (2026-11-04).** Where Pillow is built with libdeflate (``features.check("libdeflate")``; the wheels are), the encoder keeps the filtered rows of the whole image and compresses them in one piece after the last row with ``libdeflate_zlib_compress``, which has no streaming interface but a better match finder: on a 1280x800 photo-like image at the default level, 5x faster for RGB (1,177 to 232 ms), 6.4x for RGBA, 5.4x for L and 1.9x for P, with files within 2% of zlib's for RGB and L and 5% smaller for RGBA; ``optimize`` uses libdeflate's level 10, whose files are 3 to 9% smaller than zlib's level 9 and take 2 to 9x less time for RGB and RGBA (a little more for L and P). ``compress_level`` 0 to 9 keep their meaning. The pixels are the same; the bytes differ, as a zlib-ng build's already did. zlib is still used for a preset dictionary, an explicit ``compress_type`` (a zlib strategy), images whose filtered rows exceed 256 MB (the one-piece path holds them and the output bound in memory; ``PILLOW_LIBDEFLATE_MAX`` moves the cap) and builds without the library. **Decoding with libdeflate (2026-11-05).** Inflating was 67 of the 97 ms of decoding the benchmark's 2560x1600 RGB photo (7.5 MB of ``IDAT``), and libdeflate inflates the same stream in 28 ms, but only in one piece. The plugin therefore walks the headers of the consecutive ``IDAT`` chunks before decoding, which costs a seek and an 8-byte read per chunk, and tells the decoder the stream's length; the decoder gathers the stream, computes the size the image's rows must decompress to (the filter bytes and, for interlaced images, the seven passes included), decompresses into a buffer of exactly that size and unfilters and unpacks the rows where they lie, each with the row above it as its predecessor. Both buffers get the huge-page advice of the image blocks: mapped afresh on every decode, their page faults were 6 ms of the 69 ms the path first measured. The decode is 62 ms, 1.56x; on a noisier 2560x1600 image 1.60x for RGB (119 to 74 ms), 1.67x for RGBA, 1.40x for L and 4.0x for P (15.1 to 3.8 ms), 1.6x at 512x512, 1.8x at 128x128 and level at 16x16. What remains is the filters themselves, a byte at a time for Sub, Average and Paeth (29 ms of the 62; their vector forms measured slower, as the list below has it). The one-piece path is taken only when it cannot change a result. The stream decoder is used, as before, for the frames of an animation after the first, with ``LOAD_TRUNCATED_IMAGES`` (which must return the rows that arrived), for a file whose chunk headers cannot be walked or end early, and above the cap; and when libdeflate refuses the stream, or the stream does not hold exactly the image's rows, the gathered bytes go through the stream decoder from the first one, so that a damaged file raises the error, and leaves the rows, that it did. The test suite's PNG files, whole, cut at random offsets and with single bits flipped (15,666 cases, from a path and from memory, with and without ``LOAD_TRUNCATED_IMAGES``) give the same pixels or the same error text with the path, without it (``PILLOW_LIBDEFLATE_MAX=0``) and in the build before it; Memcheck reports nothing on the 10,518 cases of the small files. JPEG encoding and decoding, WebP encoding and JPEG thumbnails are the libraries' own time, 1.0x; WebP decoding is 1.5x on x86 and 1.2x on arm64 from the copies saved, now ahead of OpenCV. The histogram is 1.0x against Pillow 13 (above). Pillow-SIMD is 1.0x on all of these. Code map ~~~~~~~~ * ``ZipEncode.c``: ``png_cost_none()``, ``png_filter_diff()`` (Up and Sub), ``png_filter_average()``, ``png_filter_paeth()``; ``ZipEncodeSIMD.c`` (the ``zip`` group): ``png_cost16()`` and their vector loops, and the decoder's ``png_unfilter_up_simd()``; ``ZipEncode.h``: the cost; ``ZipDecode.c``. * ``Pack.c``: ``ImagingPackRGB()``, ``ImagingPackBGR()`` and the ``permute4`` callers; ``Unpack.c``: the RGB and BGR unpackers and the ``permute4`` callers; ``PackSIMD.c`` (the ``pack`` group, with ``pack_permute4_simd()``) and ``UnpackSIMD.c``; ``src/PIL/Image.py``: ``tobytes()``'s one-chunk path. * ``_webp.c``: ``_anim_decoder_decode_still()``; ``src/PIL/WebPImagePlugin.py``; ``JpegDecode.c`` and ``src/PIL/JpegImagePlugin.py`` (``_EXT_RGBX``). * ``Quant.c``: ``nearest_palette_new()`` and the colour cache; ``QuantSIMD.c`` (the ``quant`` group): ``nearest_entry_simd()``; ``QuantNearest.h``: ``NearestPalette``; ``QuantHash.c``: the node blocks, ``_hashtable_find()`` and ``hashtable_order_by_hash()``; ``Quant.c``: ``sum_box_leaves()``, ``build_box_map()``; ``QuantOctree.c``: ``quantize_octree_rows()``, ``octree_build()``, ``add_sums_to_color_cube()``, with ``plain_octree_count_rgb()``, ``plain_octree_map_rgb()`` and the general pair in ``PlainLoops.h`` and ``octree_count_rgb_simd()`` in ``QuantSIMD.c``. ``Histo.c``: ``histogram_u8()``, two versions, and ``histogram_u8_masked()``. * Tests: ``Tests/test_codecs_simd.py``. Pitfalls met on the way: vectorizing the PNG decoder's Sub, Average and Paeth as libpng does lost to the scalar loops; writing JPEG from 4-byte pixels lost to packing first; a build against zlib-ng was measured and dropped; and the four-table histogram, a clear gain over Pillow 12.3, was matched by Pillow 13's own rewrite, so it is kept where it still wins and not where it doesn't. What was tried and dropped -------------------------- Reviewers ask about these first. Each was measured on the machine named and dropped for the reason given; the sections above tell the rest. On both architectures: * **Column strips** for the blur's and the resize's vertical passes, to keep their accumulators in L1: 0.90 to 1.11x on the blur, and on the resize 1.14 to 1.45x on some upscales but 11 to 21% slower on others. Valgrind's cache model counted L1 misses that the hardware prefetcher serves from L2. * **A different resize pass order per build** (vertical first for every downscale): faster, but the result would then depend on the build. The order is Pillow 13's. * **Fusing the two exact resize passes** (an 8-bit intermediate in a ring): done only in fast mode, where the intermediate keeps 2⁻⁶ levels; the exact version is still open (below). * **The horizontal resize pass in 16-bit products** for fast mode: faster on the Jetson, 2.5 to 2.9x slower than the exact AVX2 kernel on x86; one result on both machines rules out a kernel per architecture. * **A sampled Gaussian** for fast mode, in place of Pillow's three boxes convolved: under-blurs at small σ (σ 0.3 is nearly the identity, where Pillow blurs) and differs by up to 25 levels at edges and 30 at corners. * **``reducing_gap`` 2.0** for fast mode's large downscales: up to 6 levels from the exact result at 16x; 3.0 stays within 2 on photos. * **Per-pixel PNG unfiltering** for Sub, Average and Paeth, as libpng does it: 1.4 to 2.2x slower than the scalar loops, whose byte positions run as independent chains. **JPEG encoding from 4-byte pixels**: 3 to 7% slower than packing first. **WebP encoding through libwebp's importers**: 0.5%. **zlib-ng**: the same time as Debian's zlib on this data. * **ModeFilter variants**: keeping the highest count up to date was 2x faster on noise and slower wherever modes occur; searching the counts in blocks of 16 was slower than the plain loop. * **FP16** for any kernel: the exact paths must keep their float arithmetic, fast mode's divisions need more than 11 bits, and x86 has no FP16 arithmetic before AVX-512, so a kernel that gives the same result everywhere would emulate it. * **Tiling transforms only when a row spans many source rows**, and other tile shapes: no change, or slower (256×32 up to 3x slower than 64×64). On x86: * **Eight consecutive pixels per AVX2 step** for the integer 4-byte kernel filters: 0.93 and 1.02x with AVX2, 0.89 and 0.46x with AVX-VNNI; the duplicated lanes already make two pixels per multiply-add. **AVX2 5×5 float kernels**: no gain (4-byte) and 0.57x (1-byte). **The AVX2 3×3 float kernel for 4-byte pixels**, two columns in the two 128-bit halves added last: 0.63x the SSE4 kernel, and 0.2% of bytes off its rounding, which a build with several tiers can't have; dropped in favour of the SSE4 kernel on every x86 tier. **Force-inlining the filter kernel per size** for its own sake: 4% and 0%. * **Two rows per AVX2 vector** in the horizontal blur, the first time (Gaussian 1.08x, unsharp 0.84x); adopted once the passes kept rows in cache. * **``vpdpwssd`` in the horizontal resize pass**, where each pixel is one long chain of additions: 1.55x slower than AVX2 on extreme downscales; the pair instructions stay there, ``vpdpwssd`` in the vertical pass and the filters. * **A reciprocal in place of the divisions** in the exact HSV: not exact. **A multiplication by 1/6**: exact, no faster. **``pshufb`` table lookups** for ``point()``: slower than the scalar lookup. **An SSE4 blend kernel** and **an SSE4 merge kernel**: no gain over GCC's code. * **Huge pages**: without ``madvise`` the AVX2 build was 2x slower on big resizes and the box blur, so the advice stays. On arm64 (Cortex-A78AE, GCC 11, 12 and 14): * **NEON float filters for 1-byte images**: 1.5x slower than GCC's vectorization of the generic loop. **NEON transform filters converting each byte to double**, as x86 does: slower than the scalar filters; the integer-differences form replaced them. * **The 12-bit weight split** for the blur's direct passes: slower than the 32-bit multiply. **The 1-byte blur's four-row interleave**: 1 to 5% slower than the compiler's 1-byte loop. * **A corrected reciprocal** for the alpha composite and for unpremultiplication, **a vector division** for the alpha composite, **a 16-bit integer unpremultiplication**: all slower than the table (alpha composite) or the float division (unpremultiplication). **A reciprocal with a Newton step** for fast mode's HSV: 24.1 against 17.8 ms. * **Whole-pixel conversion loops** (the x86 form): 3.4x slower than the byte loops with GCC 14 and faster with another GCC; the NEON loops replaced both. **The NEON vertical blur pass** and **the first NEON alpha composite**: 5% and 15% slower than the compiler's code. * **Reloading the ``point()`` tables per 16 pixels** instead of splitting the row into planes: no gain. Where OpenCV and libvips are still faster ----------------------------------------- Over the benchmark's 111 cases, against Pillow 13.0.0: on x86 the AVX2 build is 5.44x (geometric mean), the AVX-VNNI build 5.56x, fast mode 6.13x, the SSE4 build 4.18x and the portable build 1.71x, where Pillow-SIMD 12.1.1 with AVX2 is 2.13x and OpenCV 3.60x; on arm64 the NEON build is 2.52x, fast mode 3.08x, the portable build 1.49x and OpenCV 2.17x (libvips 1.18x and 0.70x, ImageMagick 0.28x and 0.20x). The cases where OpenCV or libvips are ahead fall into a few classes (``design/PERFORMANCE_GAPS.md`` has the per-case accounting over four rounds, ``design/OTHER_LIBRARIES_KERNELS.md`` the study of their code). Structural, and left alone because closing them would change Pillow's pixels or its layout: * **Four bytes per RGB pixel.** Pillow stores RGB as RGBX; OpenCV as three bytes. Every bandwidth-bound operation moves 4/3 of the bytes and runs at the same gigabytes per second (26 GB/s on the x86 machine, both libraries), so takes 4/3 of the time: the chops, flips and rotate 180, invert, L to RGB, RGB to and from RGBA, YCbCr; and every per-channel kernel does a third more work, which the 3×3 and 5×5 filters, the rank filters and the warps pay. * **Precision.** OpenCV's HSV divides by 12-bit reciprocal tables, its Gaussian runs in 8.8 fixed point (KleidiCV's, behind the approximate hint, in 8-bit weights with an 8-bit intermediate), Carotene's bilinear resize uses 7-bit weights, the warps compute in single precision and ``addWeighted`` in floats rounded once. Pillow's results are defined by 22-bit (resize), 24-bit (blur) and double (transforms) arithmetic. Fast mode takes the ones where a single rounding is both faster and closer to the formula; the exact kernels keep Pillow's. * **Algorithms.** A true Gaussian kernel against three extended box passes (OpenCV wins at small σ, Pillow at large); ``INTER_AREA``, a box average, against an antialiasing convolution for downscales; polygonal ellipses; Hershey stroke text against FreeType glyphs; a centred stroke for polygon outlines against Pillow's rule that a wide outline stays inside the polygon. * **Vendor kernels.** On x86, IPP's flips and 180° rotation and its RGBA bicubic upscale (OpenCV's own code is 1.9x slower than Pillow's there); on arm64, KleidiCV's transpose on L images and its median, Carotene's erode and dilate. Closable without changing a pixel, in the order the gaps document lists them: fusing the two exact resize passes through a ring of horizontally resampled rows, with the RGBA premultiplication folded into the loads and its removal into the stores (the largest single gap on arm64, the RGBA upscale, 157 against 44 ms; done for fast mode, not yet for the exact kernels); 16-bit running vertical sums for the box blur's integer radii, with the 16-bit division checked at start-up as the direct passes do; sharing the middle rows' minimum or maximum between two output rows of the 3×3 rank filters; the NEON horizontal resample kernel for long filters (an 8x Lanczos downscale is 18 ms on the Jetson against 3.2 on x86, a wider ratio than elsewhere); and an integer form of YCbCr to RGB on NEON proven equal to the tables on all 256 values per term. What remains otherwise is the libraries' own: PNG decoding is 60% inflate and the rest the scalar Paeth and Sub filters; WebP encoding and decoding on arm64 are libwebp's, Debian's build against OpenCV's bundled one; ``point()`` with a table on x86 has no 256-entry byte shuffle below AVX-512 VBMI. Exact and fast modes -------------------- Everything above is exact mode, Pillow's arithmetic, and it is what ``PILLOW_ALLSIMD_FAST=0`` or ``Image.set_fast(False)`` selects. Fast mode, on by default, lets seven operations use kernels whose results differ from Pillow's by small documented amounts; :ref:`fast-mode` is the user documentation (the switch, the guarantees, the table of operations) and ``design/SIMD_FAST_KERNELS.md`` the full description of each kernel. This section gives the shape of each kernel and the rule they share. The rule: a fast kernel sits beside the exact path, in its own function that the operation's entry point calls when the thread's or the process's setting says so, read once per call; a portable C function defines its result, and the SSE4, AVX2 and NEON versions compute the same integers, so every build on every architecture gives the same pixels (each kernel has a ``fast:`` area in ``checks/simd_hashes.py``, one hash on x86 and arm64); its largest difference from the exact result and its error against a float reference are measured and asserted by ``Tests/test_fast_mode.py`` on six image shapes as noise and as photographs; arguments it doesn't take go to the exact path, which also raises the errors; and the output's mode, size and edge handling are the exact path's. The kernels, with their speed-ups over the exact kernels (x86 AVX2, arm64 NEON) and their difference from the exact result: * **Gaussian blur and unsharp mask**, σ up to 6, 8-bit modes (``GaussianFast.c``): Pillow's three extended boxes, built with the exact path's radius and weights, convolved into one kernel whose weights are rounded to 1/256, applied in one sweep (vertical taps into one row, then horizontal, every sum in 16 bits); the bands within the kernel's reach of the edges are taken from the exact blur of strips twice as wide, so that the exact path's per-pass clamping is reproduced there. 3.3x and 3.2x at σ 1, 1.5x and 2.3x at σ 4; at most 3 levels, and closer to a true Gaussian than the exact result (one rounding instead of three). * **RGB to and from YCbCr** (``ConvertYCbCr.c``): JPEG's formulas with 14-bit coefficients and one rounding, as OpenCV computes them, with ``pmaddwd`` and ``phaddd`` or 16 pixels per NEON step. 1.5x and 2.2x to YCbCr, 1.3x and 1.7x back; at most 1 level, and 0.3% and 0.06% of values off by one against the float formulas where the exact tables have 47% and 32%. * **RGB to HSV** (``rgb2hsv_fast()``): the same formulas truncated exactly, ``floor(255 n / 6d)`` and ``floor(255 d / max)``, each one single-precision division, which gives the floor exactly because a non-integer quotient is at least 1/1530 from an integer; on arm64 the saturation comes from a 64 KB table and the hue's division takes 16-bit lanes. 2.9x and 3.3x; hue 1 above the exact result for 0.3% of colours, saturation and value the same. * **Affine, perspective and quad transforms and ``rotate()``**, bilinear and bicubic, L and the 4-byte modes (``TransformFast.c``): positions in exact integer arithmetic rounded to 1/256 pixel (perspective's two divisions as one IEEE division per pixel in single precision below 32,768 pixels a side, four or eight lanes), the bilinear filter vertical-first in 16-bit lanes with one rounding, the bicubic as a table of 256 weight sets at 12 fractional bits; four pixels per AVX2 step. Rotate bilinear 2.7x and 2.6x, bicubic 2.9x and 3.4x; at most 2 levels (1 on photos), 0.6% of values off by one against float interpolation where the exact path's truncation has 41%; the pixels whose source position falls exactly on the image's edge may count as inside in one result and outside in the other. * **Blend**, 0 < alpha < 1 (``blend_fast_row()``): alpha in 2⁻¹⁵ units, ``in1 + ((in2 - in1) a + 2¹⁴) >> 15``, which is what ``pmulhrsw`` and ``vqrdmulh`` compute. 1.7x and 4.1x; at most 1 level, the float result rounded rather than truncated. * **Resize** of 8-bit images with 4-byte pixels, any filter but nearest, when the vertical pass comes second (``ResampleFast.c``): the two passes fused through a ring of ``ksize + 3`` rows, the horizontal kernels compiled a second time to keep 2⁻⁶ levels instead of whole ones (``RESAMPLE_OUT16``), the vertical pass in 16-bit products with ``pmulhrsw``/``vqrdmulh``. 1.2 to 1.3x on x86 upscales, 1.3 to 1.5x on arm64, 2 to 4% slower on moderate x86 downscales; at most 1 level (2 for RGBA and LA, premultiplied), 0.4 to 1.5% of values off by one against a float resize where the exact result has 8 to 25%. * **Large downscales** with the bicubic, Lanczos and MKS filters (``Image.resize()``): ``reducing_gap=3.0``, so a downscale of 6x or more first shrinks by an integer factor with ``reduce()``, exact box averages, and convolves the last 3x to 6x; a different algorithm, not a rounding, so bounded by measurement. 1/8 Lanczos 1.9x and 2.5x, 1/16 2.0x and 4.5x; at most 2 levels at photo sizes, more at very small sizes, where ``reduce()`` averages partial boxes at the edges. With fast mode on, the 320×200 bicubic downscale of the resampling section is 15.8x and 6.0x, the σ 1 Gaussian 21.6x and 9.7x, HSV 20x and 7.7x, the 30° bicubic rotation 6.3x and 3.7x; the benchmark page shows both modes side by side. Appendix A: the numbers in one place ------------------------------------ Geometric means over the benchmark's 111 cases, times faster than Pillow 13.0.0, the published run of 2026-10-31 (the per-operation tables are in the sections above and on the `benchmark page `_): .. generated: kernels-appendix-a (checks/simd_tables.py, pillow-allsimd-perf@392492cc3568d72691707b9cf15c7401bd712d92) .. list-table:: :header-rows: 1 * - Build - x86-64 (Core Ultra 9 285H) - arm64 (Jetson Orin) * - Portable (no SIMD) - 1.74x - 1.56x * - SSE4 - 4.34x - * - AVX2 - 5.62x - * - AVX-VNNI - 5.75x - * - NEON - - 2.71x * - Fast mode (AVX2, NEON) - 6.34x - 3.32x * - Pillow-SIMD 12.1.1 (AVX2) - 2.13x - * - OpenCV 5.0 (single-threaded) - 3.61x - 2.16x * - libvips 8.16 - 1.18x - 0.70x .. generated-end The portable build's gain is the share of the algorithmic changes: the pass orders, the rings, the sorting networks, the caches. The arm64 factors are smaller than the x86 ones for two reasons the sections explain: Pillow 13's own code is already vectorized by GCC on arm64 for many loops that stay scalar on x86, and NEON's 128-bit lanes and instruction mix gain less over that than AVX2 does. Appendix B: file map -------------------- Where the kernels live. Pillow's own files hold the portable code and the drivers; each area's SIMD code is in a companion, ``SIMD.c``, with the SSE4 version (AVX2 blocks inside it), the NEON version, and the area's group of the kernel table at the end. Unless noted, an area has all three of SSE4, AVX2 and NEON. * ``SIMDKernels.h``: the table of kernel groups and ``IMAGING_SIMD_CALL``; ``SIMDKernels.c``: the one translation unit, which includes every companion and defines the tier's table; ``SIMDKernels_sse4.c``, ``SIMDKernels_avx2.c`` and ``SIMDKernels_avxvnni.c``: the stubs that compile it once per tier in a dispatch build, each with its own flags; ``SIMDDispatch.c``: the scalar table, ``ImagingSIMD``, the CPU detection, ``ImagingSIMDInit()`` and ``ImagingSIMDSelect()``; ``ImagingSIMD.h``: the tier and ISA macros, ``SIMD_FORCE_INLINE``, ``mm_dpwssd`` and ``mm256_dpwssd``, ``mm_madd_add``, the widening loads; ``ImagingPx4.h``: one pixel as four 32-bit lanes; ``PlainLoops.h``: the loops in plain C that every tier compiles for itself (soft and hard light, the exact and extrapolating blend, the colour matrix, L and I, the rank filter's network), with a wrapper in the area's companion. * Resampling: ``Resample.c`` (the drivers) and ``ResampleHorizontal.c`` (the portable horizontal kernels); ``ResampleSIMD.c``, which includes ``ResampleSIMDHorizontalConv.c``, ``ResampleSIMDVerticalConv.c``, ``ResampleSIMD32bpc.c`` (I, F, I;16) and ``ResampleFastSIMD.c``; ``ResampleFast.c`` and ``ResampleFast.h`` (fast mode); ``Reduce.c`` and ``ReduceSIMD.c``. * Blurs: ``BoxBlur.c``, ``BoxBlur.h`` and ``BoxBlurSIMD.c``; ``GaussianFast.c`` and ``GaussianFastSIMD.c`` (fast mode); ``UnsharpMask.c`` and ``UnsharpMaskSIMD.c``. * Kernel filters: ``Filter.c``; ``FilterSIMD.c``, which includes ``FilterSIMD_3x3i_4u8.c``, ``FilterSIMD_5x5i_4u8.c``, ``FilterSIMD_3x3f_4u8.c``, ``FilterSIMD_5x5f_4u8.c``, ``FilterSIMD_3x3f_u8.c``, ``FilterSIMD_5x5f_u8.c`` (x86), ``FilterSIMD_NxNi.c`` (x86 and NEON) and ``FilterSIMD_NxNf.c`` (NEON). * Rank filters: ``RankFilter.c``, ``RankFilter.h`` and ``RankFilterSIMD.c``; ``ModeFilter.c`` and ``ModeFilterSIMD.c``. * Conversions: ``Convert.c`` and ``ConvertSIMD.c``; ``ConvertYCbCr.c``, ``ConvertYCbCr.h`` and ``ConvertYCbCrSIMD.c``. * Compositing and channels: ``AlphaComposite.c``, ``AlphaComposite.h`` and ``AlphaCompositeSIMD.c``; ``Blend.c`` and ``BlendSIMD.c``; ``Paste.c`` and ``PasteSIMD.c``; ``Chops.c`` and ``ChopsSIMD.c``; ``Point.c``, ``Point.h`` and ``PointSIMD.c`` (byte operations; NEON lookups); ``ColorLUT.c``, ``ColorLUT.h`` and ``ColorLUTSIMD.c``; ``Bands.c`` and ``BandsSIMD.c``. * Geometry: ``Geometry.c``, ``GeometryTransform.h`` and ``GeometrySIMD.c``; ``TransformFast.c``, ``TransformFast.h`` and ``TransformFastSIMD.c`` (fast mode). * Drawing and text: ``Draw.c`` and ``DrawSIMD.c``, ``Paste.c`` and ``PasteSIMD.c`` (masked fills), ``_imagingft.c`` (the glyph cache, portable). * Codecs and packing: ``ZipEncode.c``, ``ZipEncode.h``, ``ZipDecode.c`` and ``ZipEncodeSIMD.c``; ``Pack.c`` and ``PackSIMD.c``; ``Unpack.c`` and ``UnpackSIMD.c``; ``_webp.c``, ``JpegDecode.c``; ``Quant.c``, ``QuantNearest.h`` and ``QuantSIMD.c`` (the nearest search), ``QuantHash.c`` and ``Histo.c`` (portable). * ``Fast.c``: the fast-mode setting, process-wide and per thread. Appendix C: the tools --------------------- * ``checks/simd_hashes.py``: one hash per kernel area over many sizes and modes; run with each build on ``PYTHONPATH`` and compare. The areas agree on every build except where this page says so. On a dispatch build, ``PILLOW_ALLSIMD_ISA=sse4 python3 checks/simd_hashes.py`` hashes one tier (``scalar``, ``sse4``, ``avx2``, ``avxvnni``); the tiers' hashes equal the single-tier builds'. * ``Tests/test_simd_tiers.py``: 75 operations on two image shapes, exact and fast, through every tier of the build (``Image.core.set_simd()``, ``Image.core.simd_tiers``): the SIMD tiers give the same pixels, and the scalar table differs only in the areas listed above. The x86 CI jobs also run the whole suite under ``PILLOW_ALLSIMD_ISA`` set to ``sse4``, ``avx2`` and ``scalar``. * ``Tests/test_simd_consistency.py`` and ``Tests/simd_shapes.py``: the vector loops and their scalar tails give the same pixels, on six image shapes; ``Tests/test_fast_mode.py``: the fast kernels' bounds; ``Tests/conftest.py``'s ``EXACT_KERNEL_TESTS``: the Pillow tests that compare with Pillow's own pixels and so run with the exact kernels (an entry that matches no test fails the collection, so a renamed test can't slip through). * ``checks/simd_test_remote.sh``: the test suite per SIMD build in a container, on this machine or over SSH, with its own Python and libraries, for other platforms too (QEMU); ``checks/simd_bench_remote.sh``: builds and benchmarks a git ref on a remote x86 or arm64 machine. * ``checks/simd_dbench.sh``: builds the working tree in the benchmark image, at the ``-O3`` of the published wheels, and runs a script with it on one core; ``checks/simd_counters.py``: user-mode cycles, instructions, cache and branch misses per run of sixty cases, read with ``perf_event_open`` around each run, so they hold on a loaded machine; its ``--table`` compares builds or tiers. * ``checks/simd_bench.py``: times operations across several builds, each in its own process; ``checks/simd_callgrind.py`` and ``checks/simd_allocs.py``: instructions, cache misses and allocations per operation; ``checks/simd_profile_report.py``: the three combined into a report that ranks operations by the kind of work left in them (``design/PROFILE_ASSESSMENT.md`` came from it). * The benchmark repository, `pillow-allsimd-perf `_: 111 cases against Pillow, Pillow-SIMD, OpenCV, libvips and ImageMagick in Docker, with ``analysis/`` scripts for accuracy against references; ``checks/simd_speedups_svg.py`` draws the chart of the PyPI description from its results, and ``checks/simd_tables.py`` writes the "Numbers" tables of this page, Appendix A, the PyPI description's table and figures and the fast-mode page's speed-ups from them, into regions marked with the benchmark commit they come from, which the lint workflow checks (``make tables``; ``design/GENERATED_TABLES_PLAN.md``). * ``design/OPTIMIZATION_METHOD.md``: the order in which these tools are used on the next optimization (timings and callgrind, then the hardware counters, then the compiler's remarks, then SIMD), and what each stage rules out.