Skip to content

perf(core): negpy_core ports, exact 16-bit export color, parallel PNG - #1502

Merged
marcinz606 merged 12 commits into
mainfrom
mz/core2
Oct 25, 2026
Merged

marcinz606 merged 12 commits into
mainfrom
mz/core2

Conversation

@marcinz606

@marcinz606 marcinz606 commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Ports from the post-#1489 survey: NumPy and PIL work that still ran on the CPU paths outside negpy_core. One commit each.

Commit Before After (23.5 MP, 24 threads) Bits
EXIF orientation in Rust 328 ms (90°), 171 ms (180°) 27 / 24 ms identical
IR downsample rows + IR scaling 68 ms + 11 ms 23 + 9.5 ms identical (NumPy oracle)
8-bit export without PIL round trips 173 ms 49 ms identical
Exact 16-bit color transform 124 ms, lcms grid error up to 5386 levels 43 ms, ≤1 level vs exact lcms 16-bit exports change
Greyscale export through a code table 207 / 246 ms 12 / 8.5 ms identical
Burn vignette in Rust 31 ms (1600 px), 386 ms 6 / 77 ms ≤4 ULP; THUMBNAIL_RENDER_VERSION 17 → 18
Flat master curve in Rust 409 ms 24 ms identical here
Slide transfer curve in Rust 1600 px: 57 / 251 / 725 ms (default / edited / Dye Separation); 23.5 MP edited 4.0 s 3.5 / 8.5 / 20 ms; 0.11 s within 3e-7 (covered by the render version bump)
TIFF and DNG preview strips in Rust 338 ms per TIFF thumbnail 226 ms quick thumbnails within 2 levels of PIL BOX
Parallel PNG encoder in Rust 16.4 s (8- and 16-bit, level 6) 0.74 s / 1.1 s pixels identical; size +0.8% / +0.05%
Linear 16-bit scans stay uint16 through decode float→u16→float per TIFF decode skipped identical (every code round-trips)
Dust density log in Rust (CI fix) NumPy float32 log10, bits differ between AVX-512 and AVX2 runners log_density_open, same bits on every CPU dust maps move by ≤1 ULP of density; compute_dust_stats pin updated

Notes:

  • The thumbnail refresh renders on the CPU engine (prefer_gpu=False), so the vignette port reaches every refresh, not only the CPU fallback.
  • lcms's default 16-bit transform resamples to a grid table: Adobe RGB → sRGB is off by up to 5386 levels (8%) near the gamut edge against lcms with cmsFLAGS_NOOPTIMIZE, P3 3764. 8-bit already used the exact kernel. test_16bit_matrix_trc_export_matches_exact_lcms pins the new behavior; LUT profiles keep lcms.
  • test_compute_dust_stats_pin failed on some CI runners because NumPy's float32 log10 takes a different SIMD path on AVX-512 CPUs. The density plane now comes from negpy_core.log_density_open.
  • New same-bits pins: matrix_trc_u16, vignette, flat_curve, transfer_curve. New test files are in the native CI job.
  • New crates for the PNG encoder: miniz_oxide, adler2, crc32fast (pure Rust, MIT-compatible; NOTICE.md). cargo-deny runs in CI only.

Checks: make rust, make type, ruff on changed files, targeted pytest (5397 tests across export, loaders, thumbnails, transparency, finish, core, PNG, TIFF, preview).

Live testing

Commits 1–7 (checked off after the first review):

  • Open frames with EXIF orientation 3, 6 and 8 (camera RAW and JPEG/TIFF); check the turn and the crop.
  • Open a frame with an IR channel; check IR dust marks and IR Restore.
  • 8-bit JPEG and TIFF exports in sRGB, Adobe RGB and Display P3; compare color with an export from main.
  • 16-bit TIFF exports in sRGB, Adobe RGB, Display P3 and ProPhoto; colors near the gamut edge change a little (the lcms grid error is gone).
  • Greyscale exports at 8 and 16 bit.
  • Burn vignette at different sizes, on the preview, the thumbnail refresh and an export.
  • Flat export (flat master curve).

Commits 8–12:

  • Slide (E-6) frame on the CPU path: default settings, then Toe, Shoulder and Dye Separation (with Separation Damping); compare with the GPU preview.
  • Thumbnail refresh of a slide roll after a slide edit.
  • PNG export at 8 bit, with metadata (ICC profile, DPI, EXIF, XMP); open in another app and check color and metadata.
  • PNG export at 16 bit, with metadata; same checks.
  • Import a roll of TIFF scans; check the first thumbnails.
  • Import a roll of LinearRaw DNG scans; check the first thumbnails.
  • Open 16-bit linear TIFF scans (Coolscan/VueScan raw, Pakon); check the render against main.
  • Optical dust detection (no IR) and Heal / scratch tools on a scan; marks look the same as before.

apply_exif_orientation turned full-resolution buffers with NumPy rot90/flip
plus a contiguous copy on one thread. negpy_core.orient copies pixels of any
byte size in parallel, in 64x64 tiles for the 90-degree turns. Output is the
same bytes as the NumPy path for every orientation and dtype.

23.5 MP float32 RGB, 24 threads: orientation 6 328 -> 27 ms,
orientation 3 171 -> 24 ms. Runs on frame open, export decode and twice
in lens correction for rotated sources.
_erode_resize_bounded ran its vertical INTER_AREA pass as a Python loop of
small NumPy calls per output row. negpy_core.area_rows_into runs the same
float64 weights and float32 products, summed down the rows in NumPy's
order, in parallel; the output is bit-identical (NumPy oracle test).
normalize_ir_to_float32 scales uint8/uint16 IR planes in one pass.

23.5 MP IR plane, 24 threads: downsample_ir to 2000 px 68 -> 23 ms,
normalize 11.4 -> 9.5 ms. Both run on every IR frame open.
_export_pixels sent the quantized buffer through Image.fromarray and
np.asarray twice around the Rust matrix/TRC kernel, and the kernel's
decode/encode tables were rebuilt from the ICC bytes on every export.
apply_color_management now takes and returns a uint8 array (PIL only for
the lcms fallback) and the tables are cached per profile pair. Pixels and
embedded profile are unchanged.

23.5 MP, Adobe RGB -> sRGB: _export_pixels 173 -> 49 ms per JPEG/WebP export.
16-bit RGB exports (TIFF, PNG, JXL) went through lcms, which resamples a
16-bit transform into a grid table. Against lcms with cmsFLAGS_NOOPTIMIZE
that grid is off by up to 5386 levels (8%) for Adobe RGB -> sRGB and 3764
for P3 near the destination gamut edge, mean 25. 8-bit exports already use
the exact matrix/TRC kernel, so 16-bit exports were the less accurate ones.

matrix_trc_u16 mirrors matrix_trc_u8: per-code TRC decode, the PCS matrix
with a gamut clip, and an encode table sampled at linear t^2 so the steep
end near black interpolates finely. It is within one level of exact lcms on
every bundled export space. LUT profiles keep lcms.

23.5 MP, Adobe RGB -> sRGB: 124 -> 43 ms per 16-bit export. 16-bit export
pixels change by the lcms grid error above; thumbnails are not affected.
The greyscale export re-encoded every pixel with NumPy (working TRC decode,
sRGB OETF, 16-bit, 8-bit), several full-size temporaries and np.power on
each. The result depends only on the uint16 luma code, so the same formula
now fills a 65536-entry table once per working space and negpy_core.lut_u16
reads it in parallel. The 8-bit path also drops its PIL round trip. Output
is bit-identical (test against the per-pixel formula).

23.5 MP: 16-bit greyscale 207 -> 12 ms, 8-bit greyscale 246 -> 8.5 ms.
apply_vignette was the last NumPy pixel stage in the CPU finish: a full
meshgrid, sqrt, cos and exp2 with about ten full-size temporaries. The CPU
engine renders every thumbnail refresh, so it ran there as well as in the
CPU preview and export. negpy_core.apply_vignette keeps NumPy's float32
order with cos and exp2 from libm in float64, rounded once: within 4 ULP of
NumPy's SVML results, the same bits on every platform (same-bits pin).
THUMBNAIL_RENDER_VERSION 17 -> 18 for those ULPs.

24 threads: 1600 px 31 -> 6 ms, 23.5 MP 386 -> 77 ms.
apply_flat_curve, the one pixel step of the Flat intent, ran in NumPy: an
offset add, a BLAS product of the full image with the stock matrix and the
affine code map, each a full-size temporary. The flat export is CPU-only.
negpy_core.flat_curve does it in one parallel pass, the matrix in
matmul_3x3's order; the output is bit-identical to the NumPy version here.

23.5 MP: 409 -> 24 ms per flat export.
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Preview load metrics (github-linux-x64)

format cold load throughput
CR2 0.733 s 30.59 MPx/s
NEF 0.540 s 45.50 MPx/s
ARW 0.213 s 94.66 MPx/s
RAF 2.153 s 7.55 MPx/s
DNG 0.618 s 38.76 MPx/s

fixtures: rawsamples.ch · sha: d73dac3 · full JSON

apply_transfer_curve, the CPU render of every Slide and Positive frame,
was the one render stage the port missed: NumPy with dozens of full-size
temporaries, softplus, tanh and power per channel. It runs for every slide
thumbnail refresh (always CPU), the CPU preview and CPU exports.
negpy_core.apply_transfer_curve keeps NumPy's float32 order and runs each
step as a flat loop over runs of 256 pixels, so the mathf float64
transcendentals vectorize; they round once to float32, within 3e-7 of
NumPy's SVML results and the same bits on every platform (same-bits pin).
The NumPy helpers it replaced (_softplus, _sigmoid, _black_taper,
separation_damping_gain_np) are gone. Covered by the existing render
version bump on this branch.

24 threads, 1600 px: defaults 57 -> 3.5 ms, toe/shoulder/contrast
251 -> 8.5 ms, with Dye Separation 725 -> 20 ms. 23.5 MP edited:
4.0 s -> 0.11 s.
A bounded TIFF preview mapped every decoded strip through the display LUT
in NumPy (a full-resolution gather) and shrank it with PIL's BOX resize;
the LinearRaw DNG preview ran its whole display curve, np.power included,
on every tile. negpy_core.box_into_u8 area-averages a strip straight into
its preview rows, reading each sample through the display table. The DNG
curve becomes one 3 x 65536 table per file, built with the same formula.
Previews stay within 2 levels of PIL's BOX output.

23.5 MP deflate TIFF to a 512 px preview: 338 -> 226 ms per frame; what
is left is strip inflate on the decode workers.
PNG exports went through PIL (8-bit) and imagecodecs/libpng (16-bit), both
filtering and deflating the whole image on one thread. negpy_core.encode_png
filters bands of 64 rows (the minimum-sum-of-absolute-differences choice
over the five PNG filters) and deflates each band with miniz_oxide on its
own core; sync flushes join the bands into one zlib stream and the Adler-32
is combined from theirs. encode_png splices the iCCP, pHYs, eXIf and iTXt
chunks for both depths. Pixels decode identically in libpng and PIL.

New crates: miniz_oxide, adler2, crc32fast (pure Rust; NOTICE.md).

23.5 MP, level 6, 24 threads: 8-bit 16.4 s -> 0.74 s (size +0.8%),
16-bit 16.4 s -> 1.1 s (size +0.05%).
A linear 16-bit TIFF (and Pakon raw) was converted to float32 in the loader,
back to uint16 by NonStandardFileWrapper.postprocess in NumPy, and to
float32 again by the caller. uint16 -> float32 -> uint16 restores every one
of the 65536 codes, so the wrapper now holds the uint16 source, makes
float32 only on demand, and a linear 16-bit postprocess() returns the
source as it is. Bits are unchanged; tagged sRGB/Adobe and 8-bit sources
keep the float path.

23.5 MP TIFF, load_linear_preview without IR: ~330 -> ~295 ms; every
export decode of such a scan skips the same two passes.
@marcinz606 marcinz606 changed the title perf(core): small negpy_core ports and exact 16-bit export color perf(core): negpy_core ports, exact 16-bit export color, parallel PNG Oct 25, 2026
test_compute_dust_stats_pin failed on some CI runners. The density plane
used NumPy's float32 log10, whose SIMD code differs between AVX-512 and
AVX2 CPUs, so the pinned output depended on the runner. The density now
comes from negpy_core.log_density_open (correctly rounded log10f). The
new pin equals NumPy's AVX2 result and holds with NumPy's AVX-512 paths
on or off.
@marcinz606
marcinz606 merged commit e378416 into main Oct 25, 2026
6 checks passed
@marcinz606
marcinz606 deleted the mz/core2 branch October 25, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant