NC Picto · Docs

Crystallize and Pointillize performance

Measured on 2026-10-15, against 4e7a17cd5d5723313f459753663d0d155f047e19. The code revision is 3a527eb64e0d0108e44c70dfd200afc0ffd5fe67.

Whole command

The command bench calls Session::execute on a populated 6000 x 4000 RGB layer. The table below uses 16 workers and 8-bit storage. The clock includes filtering and the history commit. It excludes document setup, GUI repaint and presentation. The last run also checks that Undo restores the source and Redo restores the filtered result. Both checks happen outside the clock.

Layer kind Filter Cell size Before After Speedup
Layer Crystallize 10 560.8 ms 137.5 ms 4.08x
Layer Pointillize 5 430.5 ms 146.4 ms 2.94x
Layer Crystallize 100 1387.5 ms 202.1 ms 6.87x
Layer Pointillize 100 543.4 ms 131.9 ms 4.12x
Background Crystallize 10 891.5 ms 441.1 ms 2.02x
Background Pointillize 5 738.5 ms 608.2 ms 1.21x
Background Crystallize 100 1708.4 ms 500.1 ms 3.42x
Background Pointillize 100 851.4 ms 439.0 ms 1.94x

Ordinary layers are unlocked and have varying alpha, including zero alpha. Background layers are opaque and transparency-locked. The existing transparency restoration pass limits their speedup, especially Pointillize at small cell sizes. These are command timings, not input-to-display timings.

Whole filter

The filter bench times apply_in: reading the input, filtering, converting the result back to the document depth, writing the tiles and pruning. The table below uses 16 workers. There is no selection in these timing cases. Correctness tests also cover soft selections. The fixture has varying alpha, fully transparent pixels and a negative origin. Every run filters the original surface again; no result cache survives a run.

Format Filter Cell size Before After Speedup
RGB u8 Crystallize 10 601.6 ms 137.8 ms 4.36x
RGB u8 Pointillize 5 449.9 ms 147.3 ms 3.05x
RGB u8 Crystallize 100 1340.5 ms 175.1 ms 7.65x
RGB u8 Pointillize 100 526.2 ms 128.1 ms 4.11x
RGB u16 Crystallize 10 647.3 ms 214.0 ms 3.02x
RGB u16 Pointillize 5 502.7 ms 219.8 ms 2.29x
RGB u16 Crystallize 100 1413.8 ms 262.2 ms 5.39x
RGB u16 Pointillize 100 580.5 ms 209.3 ms 2.77x
RGB f32 Crystallize 10 688.4 ms 362.7 ms 1.90x
RGB f32 Pointillize 5 551.5 ms 360.9 ms 1.53x
RGB f32 Crystallize 100 1495.2 ms 412.1 ms 3.63x
RGB f32 Pointillize 100 643.7 ms 361.6 ms 1.78x
CMYK u16 Crystallize 10 729.5 ms 260.2 ms 2.80x
CMYK u16 Pointillize 5 574.6 ms 262.2 ms 2.19x
CMYK u16 Crystallize 100 1560.3 ms 329.1 ms 4.74x
CMYK u16 Pointillize 100 675.0 ms 255.6 ms 2.64x
LAB f32 Crystallize 10 706.2 ms 367.6 ms 1.92x
LAB f32 Pointillize 5 550.4 ms 361.9 ms 1.52x
LAB f32 Crystallize 100 1469.7 ms 404.9 ms 3.63x
LAB f32 Pointillize 100 647.8 ms 362.2 ms 1.79x
GRAY u8 Crystallize 10 396.0 ms 93.4 ms 4.24x
GRAY u8 Pointillize 5 332.9 ms 109.2 ms 3.05x
GRAY u8 Crystallize 100 1020.5 ms 105.1 ms 9.71x
GRAY u8 Pointillize 100 352.9 ms 76.7 ms 4.60x

All trials and output hashes are in pixelate-cell-timings.json. Filter hashes are SHA-256 of the complete tile-ordered normalized f32 output. Command hashes are BLAKE3 of the complete tile-ordered native storage bytes. Fixture generation, output dumps and hashing are outside the measured interval.

Where the time went

Both filters were rebuilding the seeded cell geometry for every pixel and every neighbour. Pointillize also repeated the dot radius, source lookup and colour jitter. Those values belong to the cell. The new grid prepares them once per cell in a tile, reducing that setup work from roughly nine times the pixel count to the number of cells.

Crystallize accumulated a premultiplied average for every cell, then usually threw it away and used the pixel at the cell centre. Only cells whose centres are outside the image need that average. The new path skips interior averages. Boundary cells still add their samples in the original row order, with the original f32 rounding.

Larger cells also allow whole horizontal spans to share a winner. For each site, the absolute horizontal distance has its maximum at an endpoint. Clamping the site centre to the span gives a lower bound. The rounded coordinate map is monotone; squaring nonnegative distances, adding the fixed vertical term and taking a square root preserve these bounds through the same f32 operations. Subtracting distance from a fixed dot radius reverses the endpoints for Pointillize. If one site's worst score beats every other site's best score, it owns the span. Otherwise the span splits. Single pixels retain the original strict comparison and tie order. A fully covered Pointillize span also has exactly unit coverage, so it needs no per-pixel square roots.

The tile path reads sparse cell-centre samples directly from the surface. Crystallize's boundary tiles retain the dense input for their averages. Extent clamping and selection mixing follow the original path.

After these changes, profiling showed a serial tail: converting samples and writing them back took about 160-180 ms of a roughly 260 ms operation. The two filters now convert on their existing tile workers. Committing a group copies encoded bytes. Integer rounding and native byte order are the same as Surface::write_region; float bits are kept. The optional site grid is capped at 16 MiB. Failed grid/average reservations use the scalar kernel; a failed encode reservation keeps the existing floating-point writeback.

Reproduce

Machine: EC2 c7i.4xlarge, 16 vCPUs (8 cores with SMT), 32 GiB RAM, Intel Xeon Platinum 8488C, Ubuntu 24.04, rustc 1.99.0. Release profile, thin LTO, one codegen unit. No target-cpu override. Five trials per row; tables use the median. One-worker rows are in the raw results too. Before and after use separate target directories so Cargo cannot reuse a library from the other worktree. No builds ran during the timed trials.

RAYON_NUM_THREADS=16 cargo run --release -p picto-algo --example bench_pixelate -- crystallize 6000 4000 u8 5 /tmp/crystallize.bin rgb
RAYON_NUM_THREADS=16 cargo run --release -p picto-algo --example bench_pixelate -- pointillize-large 6000 4000 u16 5 /tmp/pointillize.bin cmyk
RAYON_NUM_THREADS=16 cargo run --release -p picto-engine --example bench_cell_filters -- crystallize 10 5 8 layer
RAYON_NUM_THREADS=16 cargo run --release -p picto-engine --example bench_cell_filters -- pointillize 100 5 8 layer
RAYON_NUM_THREADS=16 cargo run --release -p picto-engine --example bench_cell_filters -- pointillize 5 5 8 background

For the before run, create a worktree at the base above and copy the two benchmark examples into it. Build with a separate CARGO_TARGET_DIR and run there with the same arguments and thread count. The -large filter cases use cell size 100; the other cases use 10 for Crystallize and 5 for Pointillize. The engine example takes the size explicitly.

Validation