Skip to content

1.9.0

Choose a tag to compare

@gudoshnikovn gudoshnikovn released this 23 Feb 03:15
· 535 commits to main since this release
cb7a7b4

Added

Bundled libjpeg-turbo (TurboJPEG API)

  • Added libjpeg-turbo v3.1.3 as git submodule (vendor/libjpeg-turbo)
  • Statically linked libturbojpeg.a with ARM NEON SIMD and x86 SSE/AVX2
  • New decoder function ph_decode_jpeg_tj() using TurboJPEG tjDecompress2 — a single-call full-image decompressor replacing the per-scanline jpeg_read_scanlines approach
  • Enabled TJFLAG_FASTDCT (fast integer iDCT, ~14% faster, negligible quality impact)

Bundled libpng

  • Added libpng v1.6.55 as git submodule (vendor/libpng)
  • Statically linked libpng16.a with ARM NEON hardware-optimized filter processing
  • New decoder function ph_decode_png_mem() using png_set_read_fn for zero-copy memory-based reading from mmap'd buffers, replacing FILE*-based I/O (fdopen → png_init_io)

Bundled spng (alternative PNG decoder for x86)

  • Added spng v0.7.4 as git submodule (vendor/spng)
  • Single-file decoder (spng.c) compiled alongside library sources — no separate build step
  • Uses spng_set_png_buffer → spng_decode_image(SPNG_FMT_RGB8) for single-call decoding
  • 43% faster than libpng on x86/Linux (300 MP/s vs 209 MP/s, Zen 4 benchmarks)
  • On ARM/Mac: same speed as libpng (bottleneck is zlib inflate, not filter processing)

Compile-time build flags

  • PH_USE_TURBOJPEG — enables bundled libjpeg-turbo (default: ON)
  • PH_USE_LIBPNG — enables bundled libpng for PNG (default: ON on ARM)
  • PH_USE_SPNG — enables bundled spng for PNG (default: ON on x86)
  • PH_USE_LIBPNG and PH_USE_SPNG are mutually exclusive
  • When all disabled: stb_image handles JPEG and PNG (zero external dependencies)
  • CMake options: PHASH_USE_TURBOJPEG, PHASH_USE_LIBPNG (in CMakeLists.txt)

Benchmarking Consolidation

  • Consolidated bench_raw.c and benchmark.c into a single, high-resolution utility: tests/test_benchmark.c
  • Added high-resolution timing support (Mach/Monotonic) for micro-benchmarking
  • New multi-mode CLI:
    • hash: Per-algorithm performance on a single image
    • dir: Bulk loading throughput for a directory
    • full: End-to-end pipeline (load + hash) performance
  • Integrated benchmark_smoke into CTest and make test for performance regression tracking

Structured Technical Documentation

  • Created a comprehensive docs/ directory with structured technical guides:
    • architecture.md: System design and component breakdown
    • development.md: Coding standards, testing procedures, and naming conventions
    • algorithms.md: Mathematical foundations of the implemented hashes
  • Updated root README.md to act as an entry point for technical deep-dives

CMakeLists.txt

  • Full CMake build system with add_subdirectory for vendor libraries
  • Architecture-specific compiler flags (-msse4.2 on x86, -march=armv8-a+simd on ARM)
  • Test target auto-discovery from tests/test_*.c
  • Added benchmark_smoke test and performance verification targets

Makefile Refinement

  • Updated Makefile to support compiler overrides via CC ?= gcc (e.g., make CC=clang)
  • Improved architecture-specific flag detection (SSE4.2/NEON)
  • Synchronized with the new test_*.c naming convention for automated test discovery

Improved

JPEG decoding: 3.6× faster (500 images: 0.51s → 0.14s)

  • Replaced dlopen/dlsym runtime loading of system libjpeg.dylib with static libturbojpeg.a
  • Replaced jpeg_stdio_src (FILE* with 4KB fread syscalls) with mmap() + jpeg_mem_src (zero-copy, zero syscalls)
  • Replaced per-scanline jpeg_read_scanlines API with single-call tjDecompress2 (eliminates per-line function call overhead)
  • NEON SIMD acceleration for iDCT and YCbCr→RGB color conversion on Apple Silicon
  • End-to-end result: 2.6× faster than Pillow/imagehash for load+aHash (500 JPEG images)

PNG decoding: eliminated FILE* overhead

  • Replaced fdopen(fd, "rb") → png_init_io(f) with direct mmap() → png_set_read_fn() memory reading
  • Eliminated fdopen/fclose syscall overhead and FILE* buffering layer
  • PNG load time: 1.3× faster than Pillow (500 PNG images: 0.88s vs 1.14s)

Code simplification

  • loader.h: reduced from 200+ lines (ABI-compat structs, function pointer tables) to 40 lines (clean declarations)
  • loader.c: reduced from 350+ lines (dlopen, dlsym, error handling, function pointer dispatch) to 210 lines (three clean decoders behind #ifdef guards)
  • Removed all dlerror, dlclose cleanup code
  • Removed all jpeg_* / png_* function pointer typedefs and manual ABI struct definitions
  • ph_load_from_file in core.c: unified mmap path for both JPEG and PNG with clean #ifdef conditional compilation

File I/O architecture

  • All image files are now mmap()'d once, then the raw pointer is passed directly to the decoder
  • Unified flow: open() → fstat() → mmap() → decode → munmap() → close()
  • Fallback to stbi_load() for unsupported formats or when bundled decoders are disabled

Benchmarks

JPEG (500 augmented images, Mac M-series)

Metric Before (dlopen libjpeg) After (static TurboJPEG) Speedup
Raw C load 0.51s 0.14s 3.6×
Python load+aHash 0.53s 0.16s 3.3×
vs Pillow (load only) 1.75× slower 1.9× faster —
vs imagehash (load+aHash) ~same 2.6× faster —

PNG (500 augmented images, Mac M-series)

Metric libphash (libpng) Pillow Speedup
Load only 0.88s 1.14s 1.30×
aHash 0.88s 1.42s 1.62×

Hash quality (PR-AUC, unchanged)

Algorithm imagehash libphash
aHash 0.626 0.617
pHash 0.608 0.616
dHash 0.598 0.581

Fixed

  • New mmap-based flow has deterministic cleanup: munmap() + close() always called in the same code path regardless of decode success/failure

Build and Header Fixes

  • Fixed circular dependency/conflict in ph_can_use_libjpeg declaration between loader.h and public headers.
  • Fixed incorrect include paths for turbojpeg.h and png.h when using bundled submodules.
  • Standardized fPIC and architecture-specific flags in CMake for consistent cross-compilation.
  • Fixed pHash stability thresholds in test_stability.c to accommodate subtle grayscale conversion differences in minimal/fallback build modes.
  • Ensured consistent ITU-R BT.601 weights for internal grayscale conversion across all high-performance decoders.

Technical Details

Zero-Copy I/O Implementation

The high-performance loading path (ph_load_from_file) now follows a zero-copy philosophy:

  1. File opened via open(O_RDONLY).
  2. Mapped into process memory using mmap().
  3. Header check (first 8 bytes) performed in-memory.
  4. Pointer passed directly to TurboJPEG or libpng/spng.
  5. Decoder decompresses directly into the context's pixel buffer.
    This bypasses memory copies between kernel space and user space, and between the FILE* buffer and the decoder.

Compile-time Optimization

  • iDCT: Enabled TJFLAG_FASTDCT for JPEG decoding. This uses integer math instead of floating-point for the Inverse Discrete Cosine Transform, significantly improving speed on ARM and older x86 CPUs with negligible impact on hash stability.
  • Filter Heuristics: libpng is compiled with hardware-accelerated NEON/SSE filter functions, which speed up the "unfiltering" step of PNG decoding.

Migration from v1.8.x

Internal Changes

  • No more runtime searching: ph_can_use_libjpeg() no longer scans the system for .so or .dylib files. It returns a constant 1 or 0 determined at compile-time.
  • Header simplified: loader.h is no longer a public-facing header for ABI compatibility; it is now an internal private header.
  • Binary size: Expect a slight increase (~300-500KB) in the static library size due to the bundling of libjpeg-turbo and libpng.
  • Version bump: Upgraded to v1.9.0 with major performance optimizations.

Build System

  • Requires CMake 3.10+.
  • Bundled submodules must be initialized: git submodule update --init --recursive.
  • If building in a restricted environment, use PHASH_USE_TURBOJPEG=OFF to disable bundled dependencies.

What's Changed

  • release: v1.9.0 - Major performance optimizations and technical docs by @gudoshnikovn in #8

Full Changelog: 1.8.1...1.9.0