From 2f6484977795cd4dd3cb97692daa45eb15079ac5 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 16 Jul 2026 18:28:56 +0530 Subject: [PATCH 01/28] autoresearch setup --- .gitignore | 1 + README.md | 6 + TESTING.md | 4 +- autoresearch/README.md | 137 ++++ autoresearch/benchmark-corpus.txt | 8 + autoresearch/benchmark.rs | 272 ++++++++ autoresearch/program.md | 85 +++ scripts/autoresearch.sh | 934 ++++++++++++++++++++++++++++ scripts/heic_tests.sh | 38 +- src/bin/heif-image-adapter-bench.rs | 24 +- 10 files changed, 1492 insertions(+), 17 deletions(-) create mode 100644 autoresearch/README.md create mode 100644 autoresearch/benchmark-corpus.txt create mode 100644 autoresearch/benchmark.rs create mode 100644 autoresearch/program.md create mode 100755 scripts/autoresearch.sh diff --git a/.gitignore b/.gitignore index e45289c..399b2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Local test artifacts .heic-test-assets/ .heic-test-runs/ +.heic-autoresearch/ # Rust build artifacts /target/ diff --git a/README.md b/README.md index 471683d..ddb9ae4 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,12 @@ an external libheif checkout with `HEIC_LIBHEIF_SOURCE_DIR`, or place a checkout or symlink at `.heic-test-assets/libheif`. A libheif checkout directly at `.heic-test-assets` is accepted too. +For unattended, correctness-gated decoder optimization experiments, see +`autoresearch/README.md`. Its trusted controller benchmarks each candidate +against the current champion through Ente's production-shaped `image` crate +hook path and commits only faster candidates that pass the full pixel-exact +validator suite and a broader confirmation benchmark. + ## License This crate is maintained inside the Ente monorepo. diff --git a/TESTING.md b/TESTING.md index 0e35080..cb41589 100644 --- a/TESTING.md +++ b/TESTING.md @@ -37,7 +37,9 @@ the CI toolchain, validator, or corpus forward deliberately. - pixel-for-pixel comparison of the `image` crate integration hook output (`ImageReader`/`DynamicImage::from_decoder`) against the direct Rust decode for every comparable verifier file, including exact ICC-profile equality - through the hook decoder's `ImageDecoder::icc_profile` + through the hook decoder's `ImageDecoder::icc_profile`; this reproduces + Ente's production hook shape, including its explicit guardrails, + `with_guessed_format`, `Limits::reserve`, and `set_limits` - embedded ICC colour-profile comparison against the validator's PNG output: when `heif-dec` embeds a profile, the Rust PNG must carry byte-identical profile data; a Rust-only profile is allowed (the Rust decoder synthesizes diff --git a/autoresearch/README.md b/autoresearch/README.md new file mode 100644 index 0000000..ca4446c --- /dev/null +++ b/autoresearch/README.md @@ -0,0 +1,137 @@ +# HEIC decoder autoresearch + +This directory adapts Karpathy's `autoresearch` pattern to decoder optimization: + +- `program.md` is the human-owned research policy. +- `benchmark.rs` and `benchmark-corpus.txt` are the fixed evaluator. +- `scripts/autoresearch.sh` is the trusted outer loop. +- decoder source and dependencies are the agent-owned experiment surface. + +The controller never asks the optimization agent to decide whether its own work +passes. It builds the current champion and candidate as separate executables, +runs them in baseline/candidate/candidate/baseline order, and only runs the full +correctness suite for a candidate that is at least 5% faster. It then repeats a +larger A/B benchmark across every pinned hook-decodable HEIC/HEIF corpus file. A +candidate is committed only after both speed gates and correctness pass. + +The performance metric exclusively uses Ente's production image-crate hook +shape. It registers the HEIC decoder with Ente's guardrails, then calls +`ImageReader::open`, `with_guessed_format`, `into_decoder`, `icc_profile`, +`Limits::reserve`, `set_limits`, and `DynamicImage::from_decoder`. It never calls +the decoder's direct RGBA API. Consequently, an optimization that benefits only +the direct API cannot pass the performance gate. + +## Validator policy + +Keep pixel-exact libheif comparison for this phase. libheif is an output oracle, +not an implementation constraint: its speed and internal algorithms do not +limit how the Rust decoder is implemented. A tolerance would make regressions +harder to distinguish from deliberate numerical differences and should only be +introduced with a separate, independently justified quality metric and corpus. + +The native iOS decoder is valuable as a performance target and a later secondary +cross-check, but it is not a portable ground-truth oracle. Platform color +management and rounding can differ, and requiring an attached phone would make +the core loop slower and less reproducible. + +## Prerequisites + +Set up the full harness from `TESTING.md` first. In particular, the libheif +checkout/binary, Ente fixtures, and generated stress corpus must exist. The six +real-camera files listed in `benchmark-corpus.txt` are required. + +The loop calls the installed `codex` CLI. It defaults to that CLI's configured +model and authentication. The controller stores trusted binaries, hashes, +results, rejected patches, and logs outside the repository under +`~/.cache/heic-decoder-autoresearch//`. This prevents a workspace-only +optimization agent from modifying the acceptance state. + +## Start a run + +First commit the harness itself on `faster`, then begin from a clean worktree: + +```bash +git status --short +scripts/autoresearch.sh setup +scripts/autoresearch.sh run --hours 8 +``` + +Select a model explicitly if desired: + +```bash +scripts/autoresearch.sh run --hours 8 --model +``` + +Useful controls from another terminal: + +```bash +scripts/autoresearch.sh status +scripts/autoresearch.sh stop +``` + +`stop` is cooperative: it stops before the next agent attempt, not in the middle +of an evaluation. To clear an old stop request, start `run` again. +Likewise, the wall-clock deadline prevents a new attempt from starting; an agent +or trusted evaluation already in flight is allowed to finish, so the final +elapsed time can exceed the requested budget. + +To evaluate a candidate you edited manually, leave the changes uncommitted and +run: + +```bash +scripts/autoresearch.sh evaluate --description "avoid coefficient copy" +``` + +## Acceptance gate + +For each candidate the controller: + +1. rejects changes outside `src/**/*.rs`, `Cargo.toml`, and `Cargo.lock`; +2. runs diff checks, `cargo fmt --check`, and the Rust test suite; +3. rejects dependency graphs containing native `links` packages; +4. builds a fresh candidate benchmark executable in trusted external state; +5. compares it against the saved champion with multiple interleaved samples on + six large real-camera inputs; +6. requires at least `HEIC_AUTORESEARCH_MIN_IMPROVEMENT` (default `0.05`, or + 5%) improvement; +7. for a faster candidate, runs host clippy, installed portability target checks, + and the complete pixel-exact validator + production-shaped image-hook suite; +8. after correctness, runs a second A/B benchmark over every HEIC/HEIF file that + the baseline champion decoded through the hook during setup, requiring + `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT` (also 5% by default); and +9. commits the candidate and promotes its executable only if every check passes. + +The primary benchmark measures Ente's path-based image-hook flow, including file +open/read, lazy layout probing, ICC retrieval, image limits, caller-buffer pixel +decode, and output allocation/deallocation. It checks full pixel and ICC +fingerprints outside the timed region. Lower `score_ms` is better. The broader +confirmation corpus is discovered once by the trusted baseline and stored +outside the agent-writable repository. + +Rejected diffs are archived for review, but are removed from the worktree. The +loop refuses to start unless the branch, HEAD, and tracked/untracked worktree +match the saved champion, so unrelated user changes are never discarded. + +## Portability and final promotion + +One autoresearch run optimizes the machine it runs on; it cannot establish a +4–7x speedup on every architecture. Repeat the benchmark/loop or at least the +final A/B benchmark on representative ARM and x86-64 hosts. Before opening the +PR, run the normal CI-equivalent commands from `TESTING.md` and review all kept +commits for maintainability, safety, dependency quality, and over-specialization. + +Environment knobs: + +- `HEIC_AUTORESEARCH_MIN_IMPROVEMENT=0.10` requires a 10% primary win. +- `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT=0.10` requires a 10% full-corpus + confirmation. +- `HEIC_AUTORESEARCH_PAIR_SAMPLES=3` increases A/B samples per invocation. +- `HEIC_AUTORESEARCH_CONFIRM_SAMPLES=4` increases full-corpus A/B samples. +- `HEIC_AUTORESEARCH_CHECK_TARGETS=aarch64-apple-ios,aarch64-linux-android` + selects extra installed targets checked before promotion. +- `HEIC_AUTORESEARCH_STATE_DIR=/trusted/path` overrides external state. +- Existing `HEIC_LIBHEIF_SOURCE_DIR`, `HEIC_ENTE_FIXTURES_DIR`, + `LIBHEIF_BUILD_DIR`, and `LIBHEIF_DEC_BIN` overrides are captured by `setup`. + +If the validator, corpus, benchmark, controller, toolchain, compiler flags, or +machine changes, start a fresh baseline with `scripts/autoresearch.sh setup`. diff --git a/autoresearch/benchmark-corpus.txt b/autoresearch/benchmark-corpus.txt new file mode 100644 index 0000000..4c1574e --- /dev/null +++ b/autoresearch/benchmark-corpus.txt @@ -0,0 +1,8 @@ +# Fixed, real-camera HEIC performance corpus. Paths are relative to the repo. +# The controller verifies these files against the hashes captured by `setup`. +.heic-test-assets/ente-test-fixtures/media/heic/v1/files/1718_rotate_90_cw.HEIC +.heic-test-assets/ente-test-fixtures/media/heic/v1/files/7765_horizontal_normal.HEIC +.heic-test-assets/ente-test-fixtures/media/heic/v1/files/7949_mirror_horizontal_rotate_270_cw.HEIC +.heic-test-assets/ente-test-fixtures/media/heic/v1/files/IMG_0682_pano.HEIC +.heic-test-assets/ente-test-fixtures/media/heic/v1/files/IMG_0983.HEIC +.heic-test-assets/ente-test-fixtures/media/heic/v1/files/IMG_8606_rotate_90_cw_contains_text.HEIC diff --git a/autoresearch/benchmark.rs b/autoresearch/benchmark.rs new file mode 100644 index 0000000..62b1932 --- /dev/null +++ b/autoresearch/benchmark.rs @@ -0,0 +1,272 @@ +use heic_decoder::image_integration::register_image_decoder_hooks_with_guardrails; +use heic_decoder::DecodeGuardrails; +use image::{DynamicImage, ImageDecoder, ImageReader, Limits}; +use std::env; +use std::error::Error; +use std::hint::black_box; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const BENCHMARK_VERSION: u32 = 2; + +struct Options { + warmup_rounds: usize, + sample_rounds: usize, + probe_compatible: bool, + paths: Vec, +} + +struct Input { + path: PathBuf, + pixels: u64, + fingerprint: u64, +} + +struct HookOutput { + image: DynamicImage, + icc_profile: Option>, +} + +fn usage() -> &'static str { + "Usage: heic-autoresearch-bench [--warmup N] [--samples N] [--probe-compatible] [...]" +} + +fn parse_count(flag: &str, value: Option) -> Result { + let value = value.ok_or_else(|| format!("missing value for {flag}"))?; + value + .parse::() + .map_err(|_| format!("{flag} expects a non-negative integer, got '{value}'")) +} + +fn parse_options() -> Result { + let mut args = env::args().skip(1); + let mut warmup_rounds = 1; + let mut sample_rounds = 5; + let mut probe_compatible = false; + let mut paths = Vec::new(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--warmup" => warmup_rounds = parse_count("--warmup", args.next())?, + "--samples" => sample_rounds = parse_count("--samples", args.next())?, + "--probe-compatible" => probe_compatible = true, + "--help" | "-h" => return Err(usage().to_string()), + _ if arg.starts_with('-') => { + return Err(format!("unknown option '{arg}'. {}", usage())); + } + _ => paths.push(PathBuf::from(arg)), + } + } + + if sample_rounds == 0 { + return Err("--samples must be at least 1".to_string()); + } + if paths.is_empty() { + return Err(format!("at least one input is required. {}", usage())); + } + + Ok(Options { + warmup_rounds, + sample_rounds, + probe_compatible, + paths, + }) +} + +fn register_production_hooks() -> Result<(), Box> { + let registration = register_image_decoder_hooks_with_guardrails(DecodeGuardrails { + max_input_bytes: Some(128 * 1024 * 1024), + max_pixels: Some(256_000_000), + max_temp_spool_bytes: Some(256 * 1024 * 1024), + temp_spool_directory: None, + }); + if !registration.heic_decoder_hook_registered || !registration.heif_decoder_hook_registered { + return Err("HEIC/HEIF image-crate hooks were not registered".into()); + } + Ok(()) +} + +/// Mirrors Ente's path-based image-crate hook call shape in +/// `rust/crates/image/src/decode.rs::decode_reader_with_image_crate`. +fn decode_through_image_hook(path: &Path) -> image::ImageResult { + let reader = ImageReader::open(path)?.with_guessed_format()?; + let mut decoder = reader.into_decoder()?; + let icc_profile = decoder.icc_profile()?; + + let mut limits = Limits::default(); + limits.reserve(decoder.total_bytes())?; + decoder.set_limits(limits)?; + + Ok(HookOutput { + image: DynamicImage::from_decoder(decoder)?, + icc_profile, + }) +} + +fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 { + for &byte in bytes { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn output_pixels_and_fingerprint(output: &HookOutput) -> Result<(u64, u64), Box> { + let mut hash = 0xcbf2_9ce4_8422_2325; + hash = fnv1a(&output.image.width().to_le_bytes(), hash); + hash = fnv1a(&output.image.height().to_le_bytes(), hash); + hash = match &output.image { + DynamicImage::ImageRgba8(buffer) => fnv1a(buffer.as_raw(), hash), + DynamicImage::ImageRgba16(buffer) => { + for sample in buffer.as_raw() { + hash = fnv1a(&sample.to_le_bytes(), hash); + } + hash + } + other => return Err(format!("image hook returned unsupported {:?}", other.color()).into()), + }; + match &output.icc_profile { + Some(profile) => { + hash = fnv1a(&[1], hash); + hash = fnv1a(profile, hash); + } + None => hash = fnv1a(&[0], hash), + } + Ok(( + u64::from(output.image.width()) * u64::from(output.image.height()), + hash, + )) +} + +fn timed_decode(input: &Input) -> Result> { + let started = Instant::now(); + let output = decode_through_image_hook(black_box(&input.path))?; + black_box(output.image.width()); + black_box(output.image.height()); + black_box(output.icc_profile.as_ref().map(Vec::len)); + match &output.image { + DynamicImage::ImageRgba8(buffer) => { + black_box(buffer.as_raw().as_ptr()); + black_box(buffer.as_raw().len()); + } + DynamicImage::ImageRgba16(buffer) => { + black_box(buffer.as_raw().as_ptr()); + black_box(buffer.as_raw().len()); + } + other => return Err(format!("image hook returned unsupported {:?}", other.color()).into()), + } + drop(output); + Ok(started.elapsed()) +} + +fn round_order(input_count: usize, round: usize) -> Vec { + let mut order: Vec = (0..input_count).collect(); + order.rotate_left(round % input_count); + if round % 2 == 1 { + order.reverse(); + } + order +} + +fn median(samples: &mut [Duration]) -> Duration { + samples.sort_unstable(); + let middle = samples.len() / 2; + if samples.len() % 2 == 1 { + samples[middle] + } else { + (samples[middle - 1] + samples[middle]) / 2 + } +} + +fn probe_compatible(paths: &[PathBuf]) -> Result<(), Box> { + let mut compatible = 0; + for path in paths { + match decode_through_image_hook(path) { + Ok(output) => { + output_pixels_and_fingerprint(&output)?; + println!("compatible: {}", path.display()); + compatible += 1; + } + Err(error) => eprintln!("incompatible: {}: {error}", path.display()), + } + } + if compatible == 0 { + return Err("no compatible image-crate hook inputs found".into()); + } + Ok(()) +} + +fn main() -> Result<(), Box> { + let options = parse_options().map_err(|message| { + eprintln!("{message}"); + io::Error::new(io::ErrorKind::InvalidInput, message) + })?; + register_production_hooks()?; + if options.probe_compatible { + return probe_compatible(&options.paths); + } + + // The one-time correctness fingerprint and process startup are outside the + // timed region. The score itself mirrors Ente's path-based ImageReader hook + // flow, including file open/read, layout probing, ICC retrieval, limits, + // caller-buffer decode, output allocation, and deallocation. + let mut inputs = Vec::with_capacity(options.paths.len()); + let mut suite_pixels = 0_u64; + let mut suite_fingerprint = 0_u64; + for path in options.paths { + let output = decode_through_image_hook(&path)?; + let (pixels, output_fingerprint) = output_pixels_and_fingerprint(&output)?; + suite_pixels = suite_pixels.saturating_add(pixels); + suite_fingerprint ^= output_fingerprint.rotate_left((inputs.len() % 63 + 1) as u32); + inputs.push(Input { + path, + pixels, + fingerprint: output_fingerprint, + }); + } + + for round in 0..options.warmup_rounds { + for index in round_order(inputs.len(), round) { + let duration = timed_decode(&inputs[index])?; + black_box(duration); + } + } + + let mut totals = Vec::with_capacity(options.sample_rounds); + let mut per_input = vec![Vec::with_capacity(options.sample_rounds); inputs.len()]; + for round in 0..options.sample_rounds { + let mut total = Duration::ZERO; + for index in round_order(inputs.len(), round + options.warmup_rounds) { + let elapsed = timed_decode(&inputs[index])?; + total += elapsed; + per_input[index].push(elapsed); + } + totals.push(total); + } + + let median_total = median(&mut totals); + let score_ms = median_total.as_secs_f64() * 1_000.0; + let throughput = suite_pixels as f64 / 1_000_000.0 / median_total.as_secs_f64(); + + println!("benchmark_version: {BENCHMARK_VERSION}"); + println!("benchmark_path: image_crate_hook_path"); + println!("score_ms: {score_ms:.6}"); + println!("throughput_mpix_s: {throughput:.6}"); + println!("suite_pixels: {suite_pixels}"); + println!("suite_fingerprint: {suite_fingerprint:016x}"); + println!("files: {}", inputs.len()); + println!("samples: {}", options.sample_rounds); + println!("warmup: {}", options.warmup_rounds); + for (input, durations) in inputs.iter().zip(&mut per_input) { + let input_median_ms = median(durations).as_secs_f64() * 1_000.0; + println!( + "file: path={} pixels={} median_ms={input_median_ms:.6} fingerprint={:016x}", + input.path.display(), + input.pixels, + input.fingerprint + ); + } + + Ok(()) +} diff --git a/autoresearch/program.md b/autoresearch/program.md new file mode 100644 index 0000000..7674a2a --- /dev/null +++ b/autoresearch/program.md @@ -0,0 +1,85 @@ +# HEIC decoder autoresearch agent + +You are making one experimental performance optimization to this pure-Rust +HEIC decoder. An outer controller, not you, owns evaluation, keep/discard, and +git commits. Make exactly one coherent attempt, summarize it, and return. + +## Objective + +Reduce the trusted Ente production-path image-crate hook benchmark while +preserving exact behavior. The long-run target across multiple optimizations is +at least a 4x speedup, with 4–7x desirable. Optimize real decoder work rather +than the harness. + +The metric deliberately does **not** call the decoder's direct RGBA APIs. It +registers this crate with `register_image_decoder_hooks_with_guardrails` using +Ente's production guardrails, then follows Ente's path-based shape exactly: +`ImageReader::open`, `with_guessed_format`, `into_decoder`, `icc_profile`, +`Limits::reserve`, `set_limits`, and `DynamicImage::from_decoder`. This exercises +the lazy adapter and its caller-owned output buffer. A general decoder change is +valuable when it speeds up this route too. A change that speeds up only a direct +decode API, but does not speed up the image-crate hook route, is not an +improvement for this project and must not be kept. + +Read the current source, recent git history, and the experiment results included +in your prompt before choosing an idea. Prefer evidence: inspect hot loops, +algorithms, allocations, bounds checks, data layout, and existing SIMD paths. +Do not repeat an experiment already recorded as rejected unless your hypothesis +materially addresses why it failed. + +## Files you may change + +- Rust files below `src/`, except any file the prompt explicitly identifies as + part of the benchmark/evaluator. +- `Cargo.toml` and `Cargo.lock`. + +Do not modify `autoresearch/`, `scripts/`, tests or corpora under ignored +directories, CI configuration, git configuration, the git index, or git +history. Do not run `git commit`, `git reset`, `git restore`, `git clean`, or +`git checkout`. The controller starts you from a clean champion and will handle +all repository state. + +## Correctness and anti-cheating rules + +- Output dimensions, bit depth, RGBA samples, ICC behavior, errors, guardrails, + and public API semantics must remain correct. Accepted changes must pass the + full pixel-exact libheif corpus and the production-shaped image-hook parity + checks. +- Never identify, special-case, cache by identity, or embed outputs for benchmark + or test files. Never inspect benchmark-only environment, process arguments, + paths, clocks, or call counts from decoder code. +- Do not skip required decoding work, reduce precision, weaken validation or + guardrails, return partially initialized output, or exploit undefined behavior. +- Do not edit, replace, generate, delete, or chmod anything in + `.heic-test-assets/`, `.heic-test-runs/`, `.heic-autoresearch/`, or the + controller's external state directory. +- Keep code clear and maintainable. Avoid massive rewrites in one experiment. + Explain non-obvious invariants and any `unsafe`; use `unsafe` only when the + safety argument is local, explicit, and compelling. + +## Dependencies and portability + +New crates are allowed only when they are mature and truly pure Rust. Do not add +FFI bindings, `-sys` crates, bundled native code, precompiled objects, or native +runtime requirements. Minimize new dependency surface and explain why a new +crate meets these rules. + +The current benchmark machine is only one architecture. Architecture-specific +fast paths must have a correct portable Rust fallback and runtime or compile-time +feature detection as appropriate. Do not regress other supported targets. The +controller checks the host plus installed iOS, Android, and WASM targets; later +promotion includes benchmarks on other representative hardware. + +## Work pattern + +1. Inspect enough code and prior results to form one specific hypothesis. +2. Make the smallest clean change that tests it. +3. Run `cargo fmt --all` after Rust edits. +4. Run a focused check or unit test if useful. Do not run the full libheif suite; + the controller does that only for candidates which are at least 5% faster on + the primary image-hook benchmark. After correctness, it also requires a 5% + confirmation on the pinned full HEIC/HEIF hook corpus. +5. Return after this single attempt. In the final response, put a concise + one-line experiment description first, followed by the hypothesis and any + relevant caveat. Do not claim the change is faster or correct; the trusted + controller decides that. diff --git a/scripts/autoresearch.sh b/scripts/autoresearch.sh new file mode 100755 index 0000000..dcab7cb --- /dev/null +++ b/scripts/autoresearch.sh @@ -0,0 +1,934 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +REPO_ID="$(printf '%s' "$ROOT_DIR" | shasum -a 256 | awk '{print substr($1, 1, 12)}')" +DEFAULT_STATE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}/heic-decoder-autoresearch" +STATE_DIR="${HEIC_AUTORESEARCH_STATE_DIR:-$DEFAULT_STATE_BASE/$REPO_ID}" +STATE_FILE="$STATE_DIR/state.tsv" +RESULTS_FILE="$STATE_DIR/results.tsv" +STOP_FILE="$STATE_DIR/STOP" +BENCHMARK_SOURCE="$ROOT_DIR/autoresearch/benchmark.rs" +BENCHMARK_CORPUS="$ROOT_DIR/autoresearch/benchmark-corpus.txt" +CONFIRMATION_CORPUS="$STATE_DIR/confirmation-corpus.txt" + +CHANGED_FILES=() +UNTRACKED_FILES=() +BENCHMARK_FILES=() +CONFIRMATION_FILES=() +PAIR_BASELINE_SCORE="" +PAIR_CANDIDATE_SCORE="" +PAIR_SPEEDUP="" +RUN_LOCK_DIR="" + +usage() { + cat <<'EOF' +Usage: scripts/autoresearch.sh [options] + +Commands: + setup Validate and record a fresh clean baseline + run --hours N [options] Run unattended one-attempt Codex experiments + evaluate [--description text] Evaluate current uncommitted source changes + bench [--samples N] Benchmark the current worktree without state + status Show champion and recent results + stop Cooperatively stop an unattended run + +Run options: + --hours N Wall-clock budget; accepts decimal hours + --max-experiments N Optional attempt-count limit + --model MODEL Override the Codex CLI's configured model + +The trusted state directory defaults to: + ~/.cache/heic-decoder-autoresearch// +EOF +} + +log() { + printf '[autoresearch:%s] %s\n' "$1" "${*:2}" +} + +die() { + log error "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +cleanup_run_lock() { + if [[ -n "${RUN_LOCK_DIR:-}" ]]; then + rm -rf "$RUN_LOCK_DIR" + fi +} + +acquire_run_lock() { + local lock_dir="$1" existing_pid="" + if ! mkdir "$lock_dir" 2>/dev/null; then + [[ -f "$lock_dir/pid" ]] && existing_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)" + if [[ "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2>/dev/null; then + die "Another autoresearch run is active with pid $existing_pid." + fi + log run "Removing stale run lock: $lock_dir" + rm -rf "$lock_dir" + mkdir "$lock_dir" || die "Could not acquire run lock: $lock_dir" + fi + printf '%s\n' "$$" > "$lock_dir/pid" + RUN_LOCK_DIR="$lock_dir" + trap cleanup_run_lock EXIT +} + +sanitize_field() { + printf '%s' "$1" \ + | tr '\t\r\n' ' ' \ + | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//' \ + | cut -c1-240 +} + +current_branch() { + git -C "$ROOT_DIR" symbolic-ref --quiet --short HEAD \ + || die "Autoresearch requires a named git branch." +} + +current_commit() { + git -C "$ROOT_DIR" rev-parse HEAD +} + +short_commit() { + git -C "$ROOT_DIR" rev-parse --short=12 "$1" +} + +get_state() { + local key="$1" + [[ -f "$STATE_FILE" ]] || die "No autoresearch baseline. Run scripts/autoresearch.sh setup first." + awk -F '\t' -v key="$key" '$1 == key {sub(/^[^\t]*\t/, ""); print; exit}' "$STATE_FILE" +} + +set_state() { + local key="$1" value="$2" tmp="$STATE_FILE.tmp.$$" + awk -F '\t' -v OFS='\t' -v key="$key" -v value="$value" ' + $1 == key { print key, value; found = 1; next } + { print } + END { if (!found) print key, value } + ' "$STATE_FILE" > "$tmp" + mv "$tmp" "$STATE_FILE" +} + +ensure_external_state_dir() { + mkdir -p "$STATE_DIR" + local physical + physical="$(cd "$STATE_DIR" && pwd -P)" + case "$physical/" in + "$ROOT_DIR/"*) + die "HEIC_AUTORESEARCH_STATE_DIR must be outside the repository so the optimization agent cannot modify trusted state." + ;; + esac +} + +require_clean_worktree() { + if [[ -n "$(git -C "$ROOT_DIR" status --porcelain=v1 --untracked-files=all)" ]]; then + git -C "$ROOT_DIR" status --short >&2 + die "The worktree must be clean before setup or an unattended run." + fi +} + +require_champion_head() { + local expected_branch expected_commit + expected_branch="$(get_state branch)" + expected_commit="$(get_state champion_commit)" + [[ "$(current_branch)" == "$expected_branch" ]] \ + || die "Expected branch '$expected_branch'; current branch is '$(current_branch)'." + [[ "$(current_commit)" == "$expected_commit" ]] \ + || die "HEAD is not the saved champion $(short_commit "$expected_commit"). Run setup for an intentional new baseline." +} + +is_libheif_source_dir() { + [[ -f "$1/CMakeLists.txt" && -d "$1/examples" && -d "$1/tests/data" && -d "$1/fuzzing/data/corpus" ]] +} + +absolute_dir() { + (cd "$1" && pwd -P) +} + +absolute_file() { + local dir base + dir="$(dirname "$1")" + base="$(basename "$1")" + printf '%s/%s\n' "$(absolute_dir "$dir")" "$base" +} + +resolve_setup_paths() { + local source_candidate ente_candidate validator_candidate build_dir + source_candidate="${HEIC_LIBHEIF_SOURCE_DIR:-$ROOT_DIR/.heic-test-assets/libheif}" + if ! is_libheif_source_dir "$source_candidate" && is_libheif_source_dir "$ROOT_DIR/.heic-test-assets"; then + source_candidate="$ROOT_DIR/.heic-test-assets" + fi + is_libheif_source_dir "$source_candidate" \ + || die "No libheif source/corpus checkout found. Follow TESTING.md first." + SETUP_LIBHEIF_SOURCE="$(absolute_dir "$source_candidate")" + + ente_candidate="${HEIC_ENTE_FIXTURES_DIR:-$ROOT_DIR/.heic-test-assets/ente-test-fixtures}" + [[ -d "$ente_candidate/media/heic/v1/files" ]] \ + || die "The Ente HEIC fixture corpus is missing. Follow TESTING.md first." + SETUP_ENTE_DIR="$(absolute_dir "$ente_candidate")" + + [[ -d "$ROOT_DIR/.heic-test-assets/stress-corpus" ]] \ + || die "The stress corpus is missing. Run scripts/heic_tests.sh gen-stress first." + SETUP_STRESS_DIR="$(absolute_dir "$ROOT_DIR/.heic-test-assets/stress-corpus")" + + build_dir="${LIBHEIF_BUILD_DIR:-$ROOT_DIR/.heic-test-runs/validator-build}" + validator_candidate="${LIBHEIF_DEC_BIN:-$build_dir/examples/heif-dec}" + SETUP_VALIDATOR="$validator_candidate" +} + +load_benchmark_files() { + BENCHMARK_FILES=() + local line path + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(sanitize_field "$line")" + [[ -n "$line" ]] || continue + case "$line" in + /*) path="$line" ;; + *) path="$ROOT_DIR/$line" ;; + esac + [[ -f "$path" ]] || die "Benchmark input is missing: $path" + BENCHMARK_FILES+=("$path") + done < "$BENCHMARK_CORPUS" + [[ ${#BENCHMARK_FILES[@]} -gt 0 ]] || die "The benchmark corpus manifest is empty." +} + +load_confirmation_files() { + CONFIRMATION_FILES=() + local path + [[ -s "$CONFIRMATION_CORPUS" ]] \ + || die "Pinned full-corpus hook benchmark is missing. Run setup again." + while IFS= read -r path || [[ -n "$path" ]]; do + [[ -n "$path" ]] || continue + [[ -f "$path" ]] || die "Confirmation benchmark input is missing: $path" + CONFIRMATION_FILES+=("$path") + done < "$CONFIRMATION_CORPUS" + [[ ${#CONFIRMATION_FILES[@]} -gt 0 ]] \ + || die "The confirmation benchmark corpus is empty." +} + +write_corpus_dirs() { + printf '%s\n' \ + "$SETUP_LIBHEIF_SOURCE/examples" \ + "$SETUP_LIBHEIF_SOURCE/tests/data" \ + "$SETUP_LIBHEIF_SOURCE/fuzzing/data/corpus" \ + "$SETUP_ENTE_DIR/media/heic/v1/files" \ + "$SETUP_STRESS_DIR" > "$STATE_DIR/corpus-dirs.txt" +} + +hash_corpus() { + local output="$1" paths_file="$STATE_DIR/corpus-paths.tmp.$$" dir path hash + : > "$paths_file" + while IFS= read -r dir; do + find "$dir" -type f \( -iname '*.heif' -o -iname '*.heic' -o -iname '*.avif' \) -print + done < "$STATE_DIR/corpus-dirs.txt" | LC_ALL=C sort -u > "$paths_file" + [[ -s "$paths_file" ]] || die "The correctness corpus is empty." + : > "$output" + while IFS= read -r path; do + hash="$(shasum -a 256 "$path" | awk '{print $1}')" + printf '%s %s\n' "$hash" "$path" >> "$output" + done < "$paths_file" + rm -f "$paths_file" +} + +capture_asset_integrity() { + hash_corpus "$STATE_DIR/corpus.sha256" + local validator validator_hash + validator="$SETUP_VALIDATOR" + [[ -x "$validator" ]] || die "Validator binary was not produced at $validator" + validator="$(absolute_file "$validator")" + validator_hash="$(shasum -a 256 "$validator" | awk '{print $1}')" + set_state libheif_source "$SETUP_LIBHEIF_SOURCE" + set_state ente_fixtures_dir "$SETUP_ENTE_DIR" + set_state validator_path "$validator" + set_state validator_sha256 "$validator_hash" +} + +verify_asset_integrity() { + local current="$STATE_DIR/corpus.current.$$" validator expected actual + hash_corpus "$current" + if ! cmp -s "$STATE_DIR/corpus.sha256" "$current"; then + diff -u "$STATE_DIR/corpus.sha256" "$current" >&2 || true + rm -f "$current" + die "Correctness corpus changed since setup. Restore it or establish a deliberate fresh baseline." + fi + rm -f "$current" + + validator="$(get_state validator_path)" + expected="$(get_state validator_sha256)" + [[ -x "$validator" ]] || die "Pinned validator binary is missing: $validator" + actual="$(shasum -a 256 "$validator" | awk '{print $1}')" + [[ "$actual" == "$expected" ]] \ + || die "Pinned validator binary changed since setup. Restore it or establish a fresh baseline." +} + +verify_champion_binary() { + local champion="$STATE_DIR/champion-bench" expected actual + expected="$(get_state champion_sha256)" + [[ -x "$champion" ]] || die "Trusted champion benchmark binary is missing: $champion" + actual="$(shasum -a 256 "$champion" | awk '{print $1}')" + [[ -n "$expected" && "$actual" == "$expected" ]] \ + || die "Trusted champion benchmark binary changed; establish a fresh baseline." +} + +check_environment_matches_setup() { + [[ "$(rustc --version)" == "$(get_state rustc_version)" ]] \ + || die "rustc changed since setup; establish a fresh baseline." + [[ "${RUSTFLAGS:-}" == "$(get_state rustflags)" ]] \ + || die "RUSTFLAGS changed since setup; establish a fresh baseline." + [[ "$(uname -m)" == "$(get_state architecture)" ]] \ + || die "Machine architecture changed since setup; establish a fresh baseline." +} + +prepare_benchmark_project() { + local project_dir="$STATE_DIR/build/benchmark-project" + rm -rf "$project_dir" + mkdir -p "$project_dir/src" "$STATE_DIR/build/target" + cp "$BENCHMARK_SOURCE" "$project_dir/src/main.rs" + cp "$ROOT_DIR/Cargo.lock" "$project_dir/Cargo.lock" + printf '%s\n' \ + '[workspace]' \ + '' \ + '[package]' \ + 'name = "heic-autoresearch-bench"' \ + 'version = "0.0.0"' \ + 'edition = "2024"' \ + 'publish = false' \ + '' \ + '[dependencies]' \ + "heic_decoder = { path = \"$ROOT_DIR\", features = [\"image-integration\"] }" \ + 'image = { version = "0.25.10", default-features = false }' \ + > "$project_dir/Cargo.toml" +} + +build_benchmark_binary() { + local destination="$1" build_log="$2" + prepare_benchmark_project + if ! CARGO_TARGET_DIR="$STATE_DIR/build/target" \ + cargo build --manifest-path "$STATE_DIR/build/benchmark-project/Cargo.toml" --release \ + >"$build_log" 2>&1; then + tail -n 80 "$build_log" >&2 || true + return 1 + fi + cp "$STATE_DIR/build/target/release/heic-autoresearch-bench" "$destination" + chmod 755 "$destination" +} + +benchmark_score() { + local binary="$1" output="$2" warmup="$3" samples="$4" corpus="$5" score + local benchmark_status=0 + case "$corpus" in + primary) + "$binary" --warmup "$warmup" --samples "$samples" "${BENCHMARK_FILES[@]}" \ + >"$output" 2>&1 || benchmark_status=$? + ;; + confirmation) + "$binary" --warmup "$warmup" --samples "$samples" "${CONFIRMATION_FILES[@]}" \ + >"$output" 2>&1 || benchmark_status=$? + ;; + *) die "Unknown benchmark corpus: $corpus" ;; + esac + if [[ "$benchmark_status" -ne 0 ]]; then + tail -n 80 "$output" >&2 || true + return 1 + fi + score="$(awk '/^score_ms: / {print $2; exit}' "$output")" + [[ "$score" =~ ^[0-9]+([.][0-9]+)?$ ]] || return 1 + printf '%s\n' "$score" +} + +benchmark_pair() { + local champion="$1" candidate="$2" log_dir="$3" corpus="$4" samples="$5" + local b1 c1 c2 b2 fingerprints + [[ "$samples" =~ ^[1-9][0-9]*$ ]] \ + || die "HEIC_AUTORESEARCH_PAIR_SAMPLES must be a positive integer." + mkdir -p "$log_dir" + log bench "Interleaved A/B $corpus benchmark (samples per invocation=$samples)" + # Bring the machine and both executables out of their cold-start state before + # the A/B/B/A sequence. These scores are deliberately discarded. + benchmark_score "$champion" "$log_dir/preheat-baseline.log" 0 1 "$corpus" >/dev/null || return 1 + benchmark_score "$candidate" "$log_dir/preheat-candidate.log" 0 1 "$corpus" >/dev/null || return 1 + b1="$(benchmark_score "$champion" "$log_dir/baseline-1.log" 0 "$samples" "$corpus")" || return 1 + c1="$(benchmark_score "$candidate" "$log_dir/candidate-1.log" 0 "$samples" "$corpus")" || return 1 + c2="$(benchmark_score "$candidate" "$log_dir/candidate-2.log" 0 "$samples" "$corpus")" || return 1 + b2="$(benchmark_score "$champion" "$log_dir/baseline-2.log" 0 "$samples" "$corpus")" || return 1 + fingerprints="$(awk '/^suite_fingerprint: / {print $2}' "$log_dir"/*.log | LC_ALL=C sort -u)" + [[ "$(printf '%s\n' "$fingerprints" | awk 'NF {count++} END {print count + 0}')" -eq 1 ]] \ + || { log bench "Benchmark output fingerprints differed across A/B runs." >&2; return 1; } + PAIR_BASELINE_SCORE="$(awk -v a="$b1" -v b="$b2" 'BEGIN {printf "%.6f", (a + b) / 2}')" + PAIR_CANDIDATE_SCORE="$(awk -v a="$c1" -v b="$c2" 'BEGIN {printf "%.6f", (a + b) / 2}')" + PAIR_SPEEDUP="$(awk -v base="$PAIR_BASELINE_SCORE" -v candidate="$PAIR_CANDIDATE_SCORE" \ + 'BEGIN {printf "%.6f", base / candidate}')" + log bench "champion=${PAIR_BASELINE_SCORE}ms candidate=${PAIR_CANDIDATE_SCORE}ms speedup=${PAIR_SPEEDUP}x" +} + +prepare_confirmation_corpus() { + local champion="$1" log_file="$2" candidates_file="$STATE_DIR/confirmation-candidates.txt" + local candidates=() path + sed 's/^[0-9a-fA-F]* //' "$STATE_DIR/corpus.sha256" \ + | awk 'tolower($0) ~ /\.(heic|heif)$/ {print}' \ + > "$candidates_file" + while IFS= read -r path; do + [[ -n "$path" ]] && candidates+=("$path") + done < "$candidates_file" + [[ ${#candidates[@]} -gt 0 ]] || die "No HEIC/HEIF files exist in the pinned corpus." + if ! "$champion" --probe-compatible "${candidates[@]}" > "$log_file" 2>&1; then + tail -n 80 "$log_file" >&2 || true + return 1 + fi + sed -n 's/^compatible: //p' "$log_file" > "$CONFIRMATION_CORPUS" + [[ -s "$CONFIRMATION_CORPUS" ]] || return 1 + load_confirmation_files + set_state confirmation_file_count "${#CONFIRMATION_FILES[@]}" + log setup "Pinned ${#CONFIRMATION_FILES[@]} hook-decodable HEIC/HEIF files for confirmation" +} + +collect_changed_files() { + CHANGED_FILES=() + UNTRACKED_FILES=() + local path + while IFS= read -r -d '' path; do + CHANGED_FILES+=("$path") + done < <(git -C "$ROOT_DIR" diff --name-only -z HEAD --) + while IFS= read -r -d '' path; do + CHANGED_FILES+=("$path") + UNTRACKED_FILES+=("$path") + done < <(git -C "$ROOT_DIR" ls-files --others --exclude-standard -z) +} + +changes_are_allowed() { + local path + [[ ${#CHANGED_FILES[@]} -gt 0 ]] || return 1 + for path in "${CHANGED_FILES[@]}"; do + case "$path" in + src/*.rs|Cargo.toml|Cargo.lock) ;; + *) log gate "Disallowed changed file: $path" >&2; return 2 ;; + esac + done +} + +archive_candidate_patch() { + local attempt="$1" destination="$STATE_DIR/rejected/$attempt.diff" path + mkdir -p "$STATE_DIR/rejected" + git -C "$ROOT_DIR" diff --binary HEAD -- > "$destination" + if [[ ${#UNTRACKED_FILES[@]} -gt 0 ]]; then + for path in "${UNTRACKED_FILES[@]}"; do + git -C "$ROOT_DIR" diff --no-index --binary -- /dev/null "$path" >> "$destination" 2>/dev/null || true + done + fi + printf '%s\n' "$destination" +} + +discard_candidate_changes() { + local path + for path in "${CHANGED_FILES[@]}"; do + if git -C "$ROOT_DIR" ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then + git -C "$ROOT_DIR" restore --source=HEAD --staged --worktree -- "$path" + else + rm -f "$ROOT_DIR/$path" + fi + done + if [[ -n "$(git -C "$ROOT_DIR" status --porcelain=v1 --untracked-files=all)" ]]; then + git -C "$ROOT_DIR" status --short >&2 + die "Could not return to the clean champion after discarding a candidate." + fi +} + +append_result() { + local attempt="$1" commit="$2" primary_score="$3" primary_speedup="$4" + local confirmation_score="$5" confirmation_speedup="$6" cumulative="$7" + local status="$8" description="$9" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$attempt" "$commit" \ + "$primary_score" "$primary_speedup" "$confirmation_score" "$confirmation_speedup" \ + "$cumulative" "$status" "$(sanitize_field "$description")" >> "$RESULTS_FILE" +} + +reject_candidate() { + local attempt="$1" description="$2" reason="$3" + local primary_score="${4:--}" primary_speedup="${5:--}" + local confirmation_score="${6:--}" confirmation_speedup="${7:--}" + local patch cumulative + collect_changed_files + patch="$(archive_candidate_patch "$attempt")" + cumulative="$(get_state cumulative_speedup)" + append_result "$attempt" - "$primary_score" "$primary_speedup" \ + "$confirmation_score" "$confirmation_speedup" "$cumulative" discard "$reason; $description" + discard_candidate_changes + log discard "$reason (patch: $patch)" + return 2 +} + +check_no_native_links() { + local metadata="$1" error_log="${1%.json}.metadata.stderr.log" linked + if ! cargo metadata --format-version 1 --locked > "$metadata" 2> "$error_log"; then + tail -n 80 "$error_log" >&2 || true + return 1 + fi + if ! linked="$(jq -r '.packages[] | select(.links != null) | "\(.name) \(.version) links=\(.links)"' "$metadata")"; then + log gate "Could not inspect Cargo metadata for native linkage." >&2 + return 1 + fi + if [[ -n "$linked" ]]; then + printf '%s\n' "$linked" >&2 + return 1 + fi +} + +run_portability_checks() { + local log_file="$1" configured installed target + configured="${HEIC_AUTORESEARCH_CHECK_TARGETS:-aarch64-apple-ios,aarch64-linux-android,wasm32-unknown-unknown}" + installed="$(rustup target list --installed 2>/dev/null || true)" + : > "$log_file" + IFS=',' read -r -a targets <<< "$configured" + if [[ ${#targets[@]} -gt 0 ]]; then + for target in "${targets[@]}"; do + [[ -n "$target" ]] || continue + if ! grep -qx "$target" <<< "$installed"; then + log portability "Skipping uninstalled target $target" + continue + fi + log portability "Checking $target" + if ! cargo check --lib --all-features --locked --target "$target" >> "$log_file" 2>&1; then + tail -n 80 "$log_file" >&2 || true + return 1 + fi + done + fi +} + +run_full_correctness() { + local log_file="$1" source ente validator + source="$(get_state libheif_source)" + ente="$(get_state ente_fixtures_dir)" + validator="$(get_state validator_path)" + # The helper is generated test output. Rebuilding it prevents any stale or + # externally altered ignored binary from participating in the trusted gate. + rm -rf "$ROOT_DIR/.heic-test-runs/helper" + if ! HEIC_LIBHEIF_SOURCE_DIR="$source" \ + HEIC_ENTE_FIXTURES_DIR="$ente" \ + LIBHEIF_DEC_BIN="$validator" \ + "$ROOT_DIR/scripts/heic_tests.sh" verify --full --require-exts heic,avif \ + > "$log_file" 2>&1; then + tail -n 100 "$log_file" >&2 || true + return 1 + fi + tail -n 4 "$log_file" +} + +evaluate_candidate() { + local attempt="$1" description="$2" + local attempt_dir="$STATE_DIR/attempts/$attempt" + local candidate_binary="$attempt_dir/candidate-bench" + local champion_binary="$STATE_DIR/champion-bench" + local min_improvement="${HEIC_AUTORESEARCH_MIN_IMPROVEMENT:-0.05}" + local confirmation_min_improvement="${HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT:-0.05}" + local primary_samples="${HEIC_AUTORESEARCH_PAIR_SAMPLES:-2}" + local confirmation_samples="${HEIC_AUTORESEARCH_CONFIRM_SAMPLES:-3}" + local allowed_ratio confirmation_allowed_ratio cumulative commit message + local primary_baseline_score primary_candidate_score primary_speedup + local confirmation_candidate_score confirmation_speedup + mkdir -p "$attempt_dir" + + awk -v improvement="$min_improvement" \ + 'BEGIN {exit(improvement >= 0 && improvement < 1 ? 0 : 1)}' \ + || die "HEIC_AUTORESEARCH_MIN_IMPROVEMENT must be a fraction in [0, 1)." + awk -v improvement="$confirmation_min_improvement" \ + 'BEGIN {exit(improvement >= 0 && improvement < 1 ? 0 : 1)}' \ + || die "HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT must be a fraction in [0, 1)." + + require_champion_head + check_environment_matches_setup + verify_asset_integrity + verify_champion_binary + load_confirmation_files + collect_changed_files + local change_status=0 + changes_are_allowed || change_status=$? + if [[ "$change_status" -ne 0 ]]; then + if [[ "$change_status" -eq 1 ]]; then + append_result "$attempt" - - - - - "$(get_state cumulative_speedup)" no_change "$description" + log discard "Agent made no source changes." + return 2 + fi + reject_candidate "$attempt" "$description" "changed files outside the experiment surface" + return 2 + fi + + if ! git -C "$ROOT_DIR" diff --check HEAD -- > "$attempt_dir/diff-check.log" 2>&1; then + reject_candidate "$attempt" "$description" "git diff check failed" + return 2 + fi + if ! cargo fmt --all -- --check > "$attempt_dir/fmt.log" 2>&1; then + reject_candidate "$attempt" "$description" "cargo fmt check failed" + return 2 + fi + if ! cargo test --all-features --locked > "$attempt_dir/tests.log" 2>&1; then + tail -n 80 "$attempt_dir/tests.log" >&2 || true + reject_candidate "$attempt" "$description" "Rust tests failed" + return 2 + fi + if ! check_no_native_links "$attempt_dir/metadata.json"; then + reject_candidate "$attempt" "$description" "dependency graph contains native linkage or metadata failed" + return 2 + fi + if ! build_benchmark_binary "$candidate_binary" "$attempt_dir/build-benchmark.log"; then + reject_candidate "$attempt" "$description" "candidate benchmark build failed" + return 2 + fi + if ! benchmark_pair "$champion_binary" "$candidate_binary" \ + "$attempt_dir/benchmark-primary" primary "$primary_samples"; then + reject_candidate "$attempt" "$description" "candidate primary hook benchmark crashed" + return 2 + fi + primary_baseline_score="$PAIR_BASELINE_SCORE" + primary_candidate_score="$PAIR_CANDIDATE_SCORE" + primary_speedup="$PAIR_SPEEDUP" + + allowed_ratio="$(awk -v improvement="$min_improvement" 'BEGIN {printf "%.9f", 1 - improvement}')" + if ! awk -v candidate="$primary_candidate_score" -v baseline="$primary_baseline_score" -v ratio="$allowed_ratio" \ + 'BEGIN {exit(candidate <= baseline * ratio ? 0 : 1)}'; then + reject_candidate "$attempt" "$description" \ + "did not clear the ${min_improvement} primary hook improvement" \ + "$primary_candidate_score" "$primary_speedup" + return 2 + fi + + log gate "Candidate is faster; running promotion checks." + if ! cargo clippy --all-targets --all-features --locked -- -D warnings \ + > "$attempt_dir/clippy.log" 2>&1; then + tail -n 80 "$attempt_dir/clippy.log" >&2 || true + reject_candidate "$attempt" "$description" "clippy failed" "$primary_candidate_score" "$primary_speedup" + return 2 + fi + if ! run_portability_checks "$attempt_dir/portability.log"; then + reject_candidate "$attempt" "$description" "portability check failed" "$primary_candidate_score" "$primary_speedup" + return 2 + fi + verify_asset_integrity + if ! run_full_correctness "$attempt_dir/correctness.log"; then + reject_candidate "$attempt" "$description" "full pixel-exact correctness failed" "$primary_candidate_score" "$primary_speedup" + return 2 + fi + + log gate "Correctness passed; running the pinned full-corpus hook confirmation benchmark." + if ! benchmark_pair "$champion_binary" "$candidate_binary" \ + "$attempt_dir/benchmark-confirmation" confirmation "$confirmation_samples"; then + reject_candidate "$attempt" "$description" "candidate confirmation hook benchmark crashed" \ + "$primary_candidate_score" "$primary_speedup" + return 2 + fi + confirmation_candidate_score="$PAIR_CANDIDATE_SCORE" + confirmation_speedup="$PAIR_SPEEDUP" + confirmation_allowed_ratio="$(awk -v improvement="$confirmation_min_improvement" \ + 'BEGIN {printf "%.9f", 1 - improvement}')" + if ! awk -v candidate="$PAIR_CANDIDATE_SCORE" -v baseline="$PAIR_BASELINE_SCORE" \ + -v ratio="$confirmation_allowed_ratio" \ + 'BEGIN {exit(candidate <= baseline * ratio ? 0 : 1)}'; then + reject_candidate "$attempt" "$description" \ + "did not clear the ${confirmation_min_improvement} full-corpus hook confirmation" \ + "$primary_candidate_score" "$primary_speedup" \ + "$confirmation_candidate_score" "$confirmation_speedup" + return 2 + fi + + cumulative="$(awk -v old="$(get_state cumulative_speedup)" -v factor="$confirmation_speedup" \ + 'BEGIN {printf "%.6f", old * factor}')" + collect_changed_files + changes_are_allowed || die "Candidate changed its file scope during evaluation." + git -C "$ROOT_DIR" add -- "${CHANGED_FILES[@]}" + message="$(sanitize_field "$description")" + [[ -n "$message" ]] || message="accepted decoder optimization" + message="${message:0:68}" + if ! git -C "$ROOT_DIR" commit -m "perf(autoresearch): $message" \ + > "$attempt_dir/commit.log" 2>&1; then + git -C "$ROOT_DIR" restore --staged -- "${CHANGED_FILES[@]}" || true + reject_candidate "$attempt" "$description" "git commit failed" \ + "$primary_candidate_score" "$primary_speedup" \ + "$confirmation_candidate_score" "$confirmation_speedup" + return 2 + fi + commit="$(current_commit)" + cp "$candidate_binary" "$champion_binary" + set_state champion_sha256 "$(shasum -a 256 "$champion_binary" | awk '{print $1}')" + set_state champion_commit "$commit" + set_state champion_score_ms "$primary_candidate_score" + set_state champion_confirmation_score_ms "$confirmation_candidate_score" + set_state cumulative_speedup "$cumulative" + append_result "$attempt" "$(short_commit "$commit")" \ + "$primary_candidate_score" "$primary_speedup" \ + "$confirmation_candidate_score" "$confirmation_speedup" \ + "$cumulative" keep "$description" + require_clean_worktree + log keep "Committed $(short_commit "$commit"); estimated cumulative speedup=${cumulative}x" +} + +cmd_setup() { + require_cmd awk + require_cmd cargo + require_cmd cmp + require_cmd codex + require_cmd find + require_cmd git + require_cmd jq + require_cmd rustc + require_cmd rustup + require_cmd shasum + require_cmd sort + ensure_external_state_dir + require_clean_worktree + load_benchmark_files + resolve_setup_paths + + if [[ -e "$STATE_FILE" ]]; then + local archive="${STATE_DIR}.archive.$(date -u '+%Y%m%dT%H%M%SZ')" + mv "$STATE_DIR" "$archive" + log setup "Archived previous trusted state to $archive" + mkdir -p "$STATE_DIR" + fi + + printf '%s\t%s\n' \ + repo_root "$ROOT_DIR" \ + branch "$(current_branch)" \ + champion_commit "$(current_commit)" \ + rustc_version "$(rustc --version)" \ + rustflags "${RUSTFLAGS:-}" \ + architecture "$(uname -m)" \ + created_at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + next_attempt 1 \ + cumulative_speedup 1.000000 \ + > "$STATE_FILE" + printf 'timestamp\tattempt\tcommit\tprimary_score_ms\tprimary_speedup\tconfirmation_score_ms\tconfirmation_speedup\tcumulative_speedup\tstatus\tdescription\n' \ + > "$RESULTS_FILE" + write_corpus_dirs + + log setup "Running baseline Rust tests" + cargo test --all-features --locked > "$STATE_DIR/setup-tests.log" 2>&1 \ + || { tail -n 100 "$STATE_DIR/setup-tests.log" >&2; die "Baseline Rust tests failed."; } + log setup "Running the complete baseline correctness oracle" + if ! HEIC_LIBHEIF_SOURCE_DIR="$SETUP_LIBHEIF_SOURCE" \ + HEIC_ENTE_FIXTURES_DIR="$SETUP_ENTE_DIR" \ + LIBHEIF_BUILD_DIR="${LIBHEIF_BUILD_DIR:-$ROOT_DIR/.heic-test-runs/validator-build}" \ + LIBHEIF_DEC_BIN="$SETUP_VALIDATOR" \ + "$ROOT_DIR/scripts/heic_tests.sh" verify --full --require-exts heic,avif \ + > "$STATE_DIR/setup-correctness.log" 2>&1; then + tail -n 100 "$STATE_DIR/setup-correctness.log" >&2 || true + die "Baseline correctness failed." + fi + capture_asset_integrity + + log setup "Building and measuring the baseline benchmark" + build_benchmark_binary "$STATE_DIR/champion-bench" "$STATE_DIR/setup-benchmark-build.log" \ + || die "Could not build the baseline benchmark." + set_state champion_sha256 "$(shasum -a 256 "$STATE_DIR/champion-bench" | awk '{print $1}')" + local score confirmation_score + score="$(benchmark_score "$STATE_DIR/champion-bench" "$STATE_DIR/setup-benchmark.log" 1 3 primary)" \ + || die "Baseline benchmark failed." + prepare_confirmation_corpus "$STATE_DIR/champion-bench" "$STATE_DIR/setup-confirmation-probe.log" \ + || die "Could not build the pinned full-corpus hook confirmation set." + confirmation_score="$(benchmark_score "$STATE_DIR/champion-bench" \ + "$STATE_DIR/setup-confirmation-benchmark.log" 1 3 confirmation)" \ + || die "Baseline confirmation benchmark failed." + set_state initial_score_ms "$score" + set_state champion_score_ms "$score" + set_state initial_confirmation_score_ms "$confirmation_score" + set_state champion_confirmation_score_ms "$confirmation_score" + append_result baseline "$(short_commit "$(current_commit)")" \ + "$score" 1.000000 "$confirmation_score" 1.000000 1.000000 keep baseline + log setup "Ready on $(current_branch) at $(short_commit "$(current_commit)"); primary=${score}ms confirmation=${confirmation_score}ms" + log setup "Trusted state: $STATE_DIR" +} + +first_nonempty_line() { + awk 'NF {print; exit}' "$1" 2>/dev/null || true +} + +run_agent_attempt() { + local attempt="$1" model="$2" prompt_file="$3" output_file="$4" log_file="$5" + local champion history + champion="$(short_commit "$(get_state champion_commit)")" + history="$(tail -n 31 "$RESULTS_FILE")" + { + printf 'Read and follow autoresearch/program.md exactly.\n\n' + printf 'This is experiment attempt %s. The current champion is %s.\n' "$attempt" "$champion" + printf 'The trusted controller will evaluate after you return. Do not commit.\n\n' + printf 'Recent experiment ledger (TSV):\n%s\n' "$history" + } > "$prompt_file" + + local args=(exec --ephemeral --color never --sandbox workspace-write --cd "$ROOT_DIR" --output-last-message "$output_file") + [[ -n "$model" ]] && args+=(--model "$model") + log agent "Starting attempt $attempt" + codex "${args[@]}" - < "$prompt_file" > "$log_file" 2>&1 +} + +cmd_run() { + local hours="" max_experiments=0 model="" experiments=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --hours) hours="$2"; shift 2 ;; + --max-experiments) max_experiments="$2"; shift 2 ;; + --model) model="$2"; shift 2 ;; + -h|--help) usage; return 0 ;; + *) die "Unknown run option: $1" ;; + esac + done + [[ -n "$hours" ]] || die "run requires --hours N" + awk -v hours="$hours" 'BEGIN {exit(hours > 0 ? 0 : 1)}' || die "--hours must be positive." + [[ "$max_experiments" =~ ^[0-9]+$ ]] || die "--max-experiments must be a non-negative integer." + + require_cmd codex + ensure_external_state_dir + require_champion_head + require_clean_worktree + check_environment_matches_setup + verify_asset_integrity + verify_champion_binary + load_benchmark_files + + local lock_dir="$STATE_DIR/run.lock" + acquire_run_lock "$lock_dir" + trap 'exit 130' INT TERM + rm -f "$STOP_FILE" + + local started deadline now attempt attempt_dir agent_status description eval_status + started="$(date +%s)" + deadline="$(awk -v start="$started" -v hours="$hours" 'BEGIN {printf "%.0f", start + hours * 3600}')" + log run "Running for up to ${hours}h on $(current_branch); Ctrl-C leaves the current candidate for inspection." + + while :; do + now="$(date +%s)" + [[ "$now" -lt "$deadline" ]] || break + [[ ! -e "$STOP_FILE" ]] || { log run "Stop requested."; break; } + if [[ "$max_experiments" -gt 0 && "$experiments" -ge "$max_experiments" ]]; then + break + fi + require_champion_head + require_clean_worktree + verify_asset_integrity + + attempt="$(get_state next_attempt)" + set_state next_attempt "$((attempt + 1))" + attempt_dir="$STATE_DIR/attempts/$attempt" + mkdir -p "$attempt_dir" + set +e + run_agent_attempt "$attempt" "$model" "$attempt_dir/prompt.txt" \ + "$attempt_dir/agent-last.txt" "$attempt_dir/agent.log" + agent_status=$? + set -e + description="$(first_nonempty_line "$attempt_dir/agent-last.txt")" + description="${description:-agent attempt $attempt}" + collect_changed_files + if [[ "$agent_status" -ne 0 ]]; then + if [[ ${#CHANGED_FILES[@]} -gt 0 ]]; then + reject_candidate "$attempt" "$description" "Codex exited with status $agent_status" || true + else + append_result "$attempt" - - - - - "$(get_state cumulative_speedup)" crash \ + "Codex exited with status $agent_status; $description" + fi + experiments=$((experiments + 1)) + continue + fi + + set +e + evaluate_candidate "$attempt" "$description" + eval_status=$? + set -e + if [[ "$eval_status" -eq 1 ]]; then + die "A trusted evaluation invariant failed; stopping the loop." + fi + experiments=$((experiments + 1)) + done + + log run "Finished $experiments attempt(s). Champion=$(short_commit "$(get_state champion_commit)") cumulative=$(get_state cumulative_speedup)x" + log run "Results: $RESULTS_FILE" +} + +cmd_evaluate() { + local description="manual candidate" + while [[ $# -gt 0 ]]; do + case "$1" in + --description) description="$2"; shift 2 ;; + -h|--help) usage; return 0 ;; + *) die "Unknown evaluate option: $1" ;; + esac + done + ensure_external_state_dir + require_champion_head + check_environment_matches_setup + verify_asset_integrity + load_benchmark_files + local attempt + attempt="$(get_state next_attempt)" + set_state next_attempt "$((attempt + 1))" + evaluate_candidate "$attempt" "$description" +} + +cmd_bench() { + local samples=5 temp_dir binary score + while [[ $# -gt 0 ]]; do + case "$1" in + --samples) samples="$2"; shift 2 ;; + -h|--help) usage; return 0 ;; + *) die "Unknown bench option: $1" ;; + esac + done + [[ "$samples" =~ ^[1-9][0-9]*$ ]] || die "--samples must be a positive integer." + ensure_external_state_dir + load_benchmark_files + temp_dir="$STATE_DIR/manual-benchmark" + mkdir -p "$temp_dir" + binary="$temp_dir/current-bench" + build_benchmark_binary "$binary" "$temp_dir/build.log" || die "Benchmark build failed." + score="$(benchmark_score "$binary" "$temp_dir/run.log" 1 "$samples" primary)" || die "Benchmark failed." + cat "$temp_dir/run.log" + log bench "score=${score}ms" +} + +cmd_status() { + ensure_external_state_dir + [[ -f "$STATE_FILE" ]] || die "No baseline has been set up." + printf 'branch: %s\n' "$(get_state branch)" + printf 'champion: %s\n' "$(short_commit "$(get_state champion_commit)")" + printf 'initial_score_ms: %s\n' "$(get_state initial_score_ms)" + printf 'champion_score_ms: %s\n' "$(get_state champion_score_ms)" + printf 'initial_confirm_ms: %s\n' "$(get_state initial_confirmation_score_ms)" + printf 'champion_confirm_ms: %s\n' "$(get_state champion_confirmation_score_ms)" + printf 'confirmation_files: %s\n' "$(get_state confirmation_file_count)" + printf 'cumulative_speedup: %sx\n' "$(get_state cumulative_speedup)" + printf 'trusted_state: %s\n' "$STATE_DIR" + printf '\nRecent results:\n' + tail -n 11 "$RESULTS_FILE" +} + +cmd_stop() { + ensure_external_state_dir + [[ -f "$STATE_FILE" ]] || die "No active autoresearch baseline." + : > "$STOP_FILE" + log stop "Stop requested. The loop will stop before its next attempt." +} + +main() { + local command="${1:-}" + if [[ -z "$command" || "$command" == "-h" || "$command" == "--help" ]]; then + usage + return 0 + fi + shift + cd "$ROOT_DIR" + case "$command" in + setup) cmd_setup "$@" ;; + run) cmd_run "$@" ;; + evaluate) cmd_evaluate "$@" ;; + bench) cmd_bench "$@" ;; + status) cmd_status "$@" ;; + stop) cmd_stop "$@" ;; + *) die "Unknown command: $command" ;; + esac +} + +main "$@" diff --git a/scripts/heic_tests.sh b/scripts/heic_tests.sh index 3d9478d..3793844 100755 --- a/scripts/heic_tests.sh +++ b/scripts/heic_tests.sh @@ -419,9 +419,9 @@ fn main() -> Result<(), Box> { RS cat > "$HELPER_DIR/src/bin/heif-image-adapter-bench.rs" <<'RS' -use heic_decoder::image_integration::register_image_decoder_hooks; -use heic_decoder::{DecodedRgbaImage, DecodedRgbaPixels, decode_path_to_rgba}; -use image::{DynamicImage, ImageReader}; +use heic_decoder::image_integration::register_image_decoder_hooks_with_guardrails; +use heic_decoder::{DecodeGuardrails, DecodedRgbaImage, DecodedRgbaPixels, decode_path_to_rgba}; +use image::{DynamicImage, ImageDecoder, ImageReader, Limits}; use std::error::Error; use std::path::Path; @@ -462,8 +462,18 @@ fn main() -> Result<(), Box> { let value = match args[1].as_str() { "direct" => direct_checksum(&decode_path_to_rgba(input)?), "adapter" => { - let _ = register_image_decoder_hooks(); - let decoded = ImageReader::open(input)?.decode()?; + let _ = register_image_decoder_hooks_with_guardrails(DecodeGuardrails { + max_input_bytes: Some(128 * 1024 * 1024), + max_pixels: Some(256_000_000), + max_temp_spool_bytes: Some(256 * 1024 * 1024), + temp_spool_directory: None, + }); + let mut decoder = ImageReader::open(input)?.with_guessed_format()?.into_decoder()?; + let _icc_profile = decoder.icc_profile()?; + let mut limits = Limits::default(); + limits.reserve(decoder.total_bytes())?; + decoder.set_limits(limits)?; + let decoded = DynamicImage::from_decoder(decoder)?; let (width, height) = (decoded.width(), decoded.height()); let pixels = match decoded { DynamicImage::ImageRgba8(buffer) => checksum(buffer.as_raw()), @@ -480,9 +490,9 @@ fn main() -> Result<(), Box> { RS cat > "$HELPER_DIR/src/bin/heif-image-hook-check.rs" <<'RS' -use heic_decoder::image_integration::register_image_decoder_hooks; -use heic_decoder::{DecodedRgbaPixels, decode_path_to_rgba}; -use image::{DynamicImage, ImageDecoder, ImageReader}; +use heic_decoder::image_integration::register_image_decoder_hooks_with_guardrails; +use heic_decoder::{DecodeGuardrails, DecodedRgbaPixels, decode_path_to_rgba}; +use image::{DynamicImage, ImageDecoder, ImageReader, Limits}; use std::error::Error; use std::path::Path; @@ -495,9 +505,17 @@ fn main() -> Result<(), Box> { let input = Path::new(&args[1]); let direct = decode_path_to_rgba(input)?; - let _ = register_image_decoder_hooks(); - let mut decoder = ImageReader::open(input)?.into_decoder()?; + let _ = register_image_decoder_hooks_with_guardrails(DecodeGuardrails { + max_input_bytes: Some(128 * 1024 * 1024), + max_pixels: Some(256_000_000), + max_temp_spool_bytes: Some(256 * 1024 * 1024), + temp_spool_directory: None, + }); + let mut decoder = ImageReader::open(input)?.with_guessed_format()?.into_decoder()?; let icc_profile = decoder.icc_profile()?; + let mut limits = Limits::default(); + limits.reserve(decoder.total_bytes())?; + decoder.set_limits(limits)?; if icc_profile != direct.icc_profile { return Err("image hook ICC profile differs from direct decode".into()); } diff --git a/src/bin/heif-image-adapter-bench.rs b/src/bin/heif-image-adapter-bench.rs index af18496..a7defe4 100644 --- a/src/bin/heif-image-adapter-bench.rs +++ b/src/bin/heif-image-adapter-bench.rs @@ -1,6 +1,6 @@ -use heic_decoder::image_integration::register_image_decoder_hooks; -use heic_decoder::{DecodedRgbaPixels, decode_path_to_rgba}; -use image::{DynamicImage, ImageReader}; +use heic_decoder::image_integration::register_image_decoder_hooks_with_guardrails; +use heic_decoder::{DecodeGuardrails, DecodedRgbaPixels, decode_path_to_rgba}; +use image::{DynamicImage, ImageDecoder, ImageReader, Limits}; use std::env; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -75,9 +75,21 @@ fn bench_direct(input_path: &Path) -> Result> { } fn bench_adapter(input_path: &Path) -> Result> { - let _ = register_image_decoder_hooks(); - - let decoded = ImageReader::open(input_path)?.decode()?; + let _ = register_image_decoder_hooks_with_guardrails(DecodeGuardrails { + max_input_bytes: Some(128 * 1024 * 1024), + max_pixels: Some(256_000_000), + max_temp_spool_bytes: Some(256 * 1024 * 1024), + temp_spool_directory: None, + }); + + let mut decoder = ImageReader::open(input_path)? + .with_guessed_format()? + .into_decoder()?; + let _icc_profile = decoder.icc_profile()?; + let mut limits = Limits::default(); + limits.reserve(decoder.total_bytes())?; + decoder.set_limits(limits)?; + let decoded = DynamicImage::from_decoder(decoder)?; let (width, height) = (decoded.width(), decoded.height()); let checksum = match decoded { DynamicImage::ImageRgba8(buffer) => small_checksum(buffer.as_raw()), From b8586e8bfb065402334528c83070a8a33d0bca5e Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 16 Jul 2026 23:06:11 +0530 Subject: [PATCH 02/28] fix autoresearch pure-Rust Rayon dependency gate --- autoresearch/README.md | 3 ++- scripts/autoresearch.sh | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/autoresearch/README.md b/autoresearch/README.md index ca4446c..38d1a3e 100644 --- a/autoresearch/README.md +++ b/autoresearch/README.md @@ -88,7 +88,8 @@ For each candidate the controller: 1. rejects changes outside `src/**/*.rs`, `Cargo.toml`, and `Cargo.lock`; 2. runs diff checks, `cargo fmt --check`, and the Rust test suite; -3. rejects dependency graphs containing native `links` packages; +3. rejects dependency graphs containing native `links` packages (with a narrow + exception for crates.io `rayon-core`, whose marker does not link native code); 4. builds a fresh candidate benchmark executable in trusted external state; 5. compares it against the saved champion with multiple interleaved samples on six large real-camera inputs; diff --git a/scripts/autoresearch.sh b/scripts/autoresearch.sh index dcab7cb..88d4eb7 100755 --- a/scripts/autoresearch.sh +++ b/scripts/autoresearch.sh @@ -470,7 +470,20 @@ check_no_native_links() { tail -n 80 "$error_log" >&2 || true return 1 fi - if ! linked="$(jq -r '.packages[] | select(.links != null) | "\(.name) \(.version) links=\(.links)"' "$metadata")"; then + # rayon-core deliberately uses `links = "rayon-core"` without linking any + # native code; its no-op build script only makes Cargo enforce one runtime + # instance. Keep that exact crates.io package as the sole audited exception. + # Any other package declaring `links` remains forbidden. + if ! linked="$(jq -r ' + .packages[] + | select(.links != null) + | select( + .name != "rayon-core" + or .links != "rayon-core" + or .source != "registry+https://github.com/rust-lang/crates.io-index" + ) + | "\(.name) \(.version) links=\(.links)" + ' "$metadata")"; then log gate "Could not inspect Cargo metadata for native linkage." >&2 return 1 fi From 73c74875ca4b64998d0484e8cc535e70139cfde5 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 16 Jul 2026 23:12:08 +0530 Subject: [PATCH 03/28] perf(autoresearch): ARM NEON full-range 8-bit 4:2:0 hook color conversion fast path --- src/heic-decoder/hevc/color_convert.rs | 341 +++++++++++++++++++++++++ src/lib.rs | 62 +++++ 2 files changed, 403 insertions(+) diff --git a/src/heic-decoder/hevc/color_convert.rs b/src/heic-decoder/hevc/color_convert.rs index a035ed3..89a9c2c 100644 --- a/src/heic-decoder/hevc/color_convert.rs +++ b/src/heic-decoder/hevc/color_convert.rs @@ -6,10 +6,289 @@ use archmage::incant; use archmage::prelude::*; +#[cfg(target_arch = "aarch64")] +use core::arch::aarch64::{ + uint8x8x3_t, uint8x8x4_t, vaddq_s32, vcombine_u16, vdup_n_u8, vdupq_n_s32, vget_high_u16, + vget_low_u16, vmaxq_s32, vminq_s32, vmlaq_n_s32, vmovl_u16, vqmovn_u16, vqmovun_s32, + vreinterpretq_s32_u32, vshrq_n_s32, vst3_u8, vst4_u8, vsubq_s32, vzip1_u16, vzip2_u16, +}; +#[cfg(target_arch = "aarch64")] +use safe_unaligned_simd::aarch64::{vld1_u16, vld1q_u16}; + // Explicit imports for safe SIMD load/store (can't glob-import alongside core::arch) #[cfg(target_arch = "x86_64")] use safe_unaligned_simd::x86_64::{_mm_loadu_si64, _mm_loadu_si128, _mm256_storeu_si256}; +/// Convert full-range 8-bit 4:2:0 planes with libheif's exact fp8 kernel. +/// +/// `channels` must be 3 or 4. The caller has already validated plane and +/// output lengths; keeping this kernel focused lets the same path serve both +/// RGB grid scratch buffers and the image adapter's RGBA buffer. +#[allow(clippy::too_many_arguments)] +pub(crate) fn convert_420_8bit_to_interleaved( + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + width: usize, + height: usize, + chroma_width: usize, + r_cr: i32, + g_cb: i32, + g_cr: i32, + b_cb: i32, + channels: usize, + output: &mut [u8], +) { + assert!(matches!(channels, 3 | 4), "channels must be RGB or RGBA"); + let pixel_count = width + .checked_mul(height) + .expect("4:2:0 pixel count must fit in usize"); + let output_len = pixel_count + .checked_mul(channels) + .expect("interleaved output length must fit in usize"); + let chroma_height = height.div_ceil(2); + let chroma_len = chroma_width + .checked_mul(chroma_height) + .expect("4:2:0 chroma sample count must fit in usize"); + assert_eq!(output.len(), output_len, "interleaved output length"); + assert!(y_plane.len() >= pixel_count, "luma plane length"); + assert!(chroma_width >= width.div_ceil(2), "4:2:0 chroma row width"); + assert!(cb_plane.len() >= chroma_len, "Cb plane length"); + assert!(cr_plane.len() >= chroma_len, "Cr plane length"); + incant!( + convert_420_8bit_to_interleaved( + y_plane, + cb_plane, + cr_plane, + width, + height, + chroma_width, + r_cr, + g_cb, + g_cr, + b_cb, + channels, + output + ), + [neon, scalar] + ); +} + +#[allow(clippy::too_many_arguments)] +fn convert_420_8bit_to_interleaved_scalar( + _token: ScalarToken, + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + width: usize, + height: usize, + chroma_width: usize, + r_cr: i32, + g_cb: i32, + g_cr: i32, + b_cb: i32, + channels: usize, + output: &mut [u8], +) { + for y in 0..height { + let y_row = y * width; + let chroma_row = (y / 2) * chroma_width; + for x in 0..width { + write_420_8bit_pixel( + y_plane[y_row + x], + cb_plane[chroma_row + x / 2], + cr_plane[chroma_row + x / 2], + r_cr, + g_cb, + g_cr, + b_cb, + channels, + &mut output[(y_row + x) * channels..], + ); + } + } +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn write_420_8bit_pixel( + y_sample: u16, + cb_sample: u16, + cr_sample: u16, + r_cr: i32, + g_cb: i32, + g_cr: i32, + b_cb: i32, + channels: usize, + output: &mut [u8], +) { + let y_sample = i64::from(y_sample); + let cb = i64::from(cb_sample) - 128; + let cr = i64::from(cr_sample) - 128; + let r = y_sample + ((i64::from(r_cr) * cr + 128) >> 8); + let g = y_sample + ((i64::from(g_cb) * cb + i64::from(g_cr) * cr + 128) >> 8); + let b = y_sample + ((i64::from(b_cb) * cb + 128) >> 8); + output[0] = r.clamp(0, 255) as u8; + output[1] = g.clamp(0, 255) as u8; + output[2] = b.clamp(0, 255) as u8; + if channels == 4 { + output[3] = u8::MAX; + } +} + +#[cfg(target_arch = "aarch64")] +#[allow(clippy::too_many_arguments)] +#[arcane] +fn convert_420_8bit_to_interleaved_neon( + _token: NeonToken, + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + width: usize, + height: usize, + chroma_width: usize, + r_cr: i32, + g_cb: i32, + g_cr: i32, + b_cb: i32, + channels: usize, + output: &mut [u8], +) { + // Plane samples are u16 even for an 8-bit stream. Bound coefficient + // magnitudes before using i32 lanes so unusual public color metadata + // retains the scalar implementation's i64 overflow behavior. + let max_delta = i64::from(u16::MAX) - 128; + let max_y = i64::from(u16::MAX); + let rounded = 128_i64; + let single_channel_fits = |coefficient: i32| { + i64::from(coefficient).abs() * max_delta + max_y + rounded <= i64::from(i32::MAX) + }; + let green_fits = (i64::from(g_cb).abs() + i64::from(g_cr).abs()) * max_delta + max_y + rounded + <= i64::from(i32::MAX); + if !single_channel_fits(r_cr) || !green_fits || !single_channel_fits(b_cb) { + convert_420_8bit_to_interleaved_scalar( + ScalarToken, + y_plane, + cb_plane, + cr_plane, + width, + height, + chroma_width, + r_cr, + g_cb, + g_cr, + b_cb, + channels, + output, + ); + return; + } + + let zero = vdupq_n_s32(0); + let max_255 = vdupq_n_s32(255); + let center = vdupq_n_s32(128); + let rounding = vdupq_n_s32(128); + let opaque = vdup_n_u8(u8::MAX); + let simd_width = width / 8 * 8; + + for y in 0..height { + let y_row = y * width; + let chroma_row = (y / 2) * chroma_width; + let mut x = 0; + while x < simd_width { + let y_values = vld1q_u16((&y_plane[y_row + x..y_row + x + 8]).try_into().unwrap()); + let cb_values = vld1_u16( + (&cb_plane[chroma_row + x / 2..chroma_row + x / 2 + 4]) + .try_into() + .unwrap(), + ); + let cr_values = vld1_u16( + (&cr_plane[chroma_row + x / 2..chroma_row + x / 2 + 4]) + .try_into() + .unwrap(), + ); + let cb_values = vcombine_u16( + vzip1_u16(cb_values, cb_values), + vzip2_u16(cb_values, cb_values), + ); + let cr_values = vcombine_u16( + vzip1_u16(cr_values, cr_values), + vzip2_u16(cr_values, cr_values), + ); + + let y_lo = vreinterpretq_s32_u32(vmovl_u16(vget_low_u16(y_values))); + let y_hi = vreinterpretq_s32_u32(vmovl_u16(vget_high_u16(y_values))); + let cb_lo = vsubq_s32( + vreinterpretq_s32_u32(vmovl_u16(vget_low_u16(cb_values))), + center, + ); + let cb_hi = vsubq_s32( + vreinterpretq_s32_u32(vmovl_u16(vget_high_u16(cb_values))), + center, + ); + let cr_lo = vsubq_s32( + vreinterpretq_s32_u32(vmovl_u16(vget_low_u16(cr_values))), + center, + ); + let cr_hi = vsubq_s32( + vreinterpretq_s32_u32(vmovl_u16(vget_high_u16(cr_values))), + center, + ); + + let convert_half = |y_values, cb_values, cr_values| { + let r = vaddq_s32( + y_values, + vshrq_n_s32(vmlaq_n_s32(rounding, cr_values, r_cr), 8), + ); + let g_terms = vmlaq_n_s32(vmlaq_n_s32(rounding, cb_values, g_cb), cr_values, g_cr); + let g = vaddq_s32(y_values, vshrq_n_s32(g_terms, 8)); + let b = vaddq_s32( + y_values, + vshrq_n_s32(vmlaq_n_s32(rounding, cb_values, b_cb), 8), + ); + ( + vminq_s32(vmaxq_s32(r, zero), max_255), + vminq_s32(vmaxq_s32(g, zero), max_255), + vminq_s32(vmaxq_s32(b, zero), max_255), + ) + }; + let (r_lo, g_lo, b_lo) = convert_half(y_lo, cb_lo, cr_lo); + let (r_hi, g_hi, b_hi) = convert_half(y_hi, cb_hi, cr_hi); + let r = vqmovn_u16(vcombine_u16(vqmovun_s32(r_lo), vqmovun_s32(r_hi))); + let g = vqmovn_u16(vcombine_u16(vqmovun_s32(g_lo), vqmovun_s32(g_hi))); + let b = vqmovn_u16(vcombine_u16(vqmovun_s32(b_lo), vqmovun_s32(b_hi))); + let output_index = (y_row + x) * channels; + + // SAFETY: the caller-proven output length is `width * height * + // channels`; this iteration starts at an in-row group of eight + // pixels, and vst3/vst4 write exactly 8 * channels bytes. + unsafe { + let destination = output.as_mut_ptr().add(output_index); + if channels == 4 { + vst4_u8(destination, uint8x8x4_t(r, g, b, opaque)); + } else { + vst3_u8(destination, uint8x8x3_t(r, g, b)); + } + } + x += 8; + } + + for x in simd_width..width { + write_420_8bit_pixel( + y_plane[y_row + x], + cb_plane[chroma_row + x / 2], + cr_plane[chroma_row + x / 2], + r_cr, + g_cb, + g_cr, + b_cb, + channels, + &mut output[(y_row + x) * channels..], + ); + } + } +} + /// Get color matrix coefficients for YCbCr→RGB conversion. /// /// Returns (cr_r, cb_g, cr_g, cb_b, y_bias, y_scale, rounding, shift_bits). @@ -347,3 +626,65 @@ fn scalar_pixel( rgb[*out_idx + 2] = b.clamp(0, 255) as u8; *out_idx += 3; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_range_420_dispatch_matches_scalar_for_rgb_and_rgba() { + let mut state = 0x7a31_4c95_u32; + let mut sample = || { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 16) as u16 + }; + + for (width, height) in [(1_usize, 1_usize), (7, 3), (8, 2), (9, 5), (24, 4)] { + let chroma_width = width.div_ceil(2); + let chroma_height = height.div_ceil(2); + let y_plane = (0..width * height).map(|_| sample()).collect::>(); + let cb_plane = (0..chroma_width * chroma_height) + .map(|_| sample()) + .collect::>(); + let cr_plane = (0..chroma_width * chroma_height) + .map(|_| sample()) + .collect::>(); + + for channels in [3, 4] { + let mut expected = vec![0_u8; width * height * channels]; + convert_420_8bit_to_interleaved_scalar( + ScalarToken, + &y_plane, + &cb_plane, + &cr_plane, + width, + height, + chroma_width, + 403, + -48, + -120, + 475, + channels, + &mut expected, + ); + + let mut actual = vec![0_u8; expected.len()]; + convert_420_8bit_to_interleaved( + &y_plane, + &cb_plane, + &cr_plane, + width, + height, + chroma_width, + 403, + -48, + -120, + 475, + channels, + &mut actual, + ); + assert_eq!(actual, expected, "{width}x{height}, {channels} channels"); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index aabd03e..ce3cc97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8606,6 +8606,41 @@ fn decoded_heic_to_rgba8_slice( auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u8], ) -> Result<(), DecodeError> { + // The common coded-image case needs no coordinate remap or alpha + // composition. Route it through the shared contiguous converter so its + // architecture-specific 4:2:0 kernel can write the caller's buffer + // directly. Non-empty transform sequences still take the exact generic + // mapping path, including identity-valued properties and their guards. + if transforms.is_empty() && auxiliary_alpha.is_none() { + let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; + if heic_storage_bit_depth(source_bit_depth) != 8 { + return Err(DecodeError::Unsupported(format!( + "HEIC storage is RGBA{}, not RGBA8", + heic_storage_bit_depth(source_bit_depth) + ))); + } + RgbaTransformPlan::from_primary_transforms(decoded.width, decoded.height, transforms)?; + let expected = checked_rgba_sample_count(decoded.width, decoded.height)?; + if out.len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "HEIC direct transformed image adapter output", + actual: out.len(), + expected, + width: decoded.width, + height: decoded.height, + }, + )); + } + return convert_heic_to_interleaved_rgb8_slice::<4>( + &decoded, + out, + scale_sample_to_u8, + "RGBA8", + ) + .map_err(DecodeError::from); + } + let mut output = SliceRgbaOutput(out); decoded_heic_to_rgba_output( decoded, @@ -11787,6 +11822,33 @@ fn convert_heic_to_interleaved_rgb8_slice( // 8-bit grayscale: libheif's Op_mono_to_RGB24_32 copies Y verbatim. let mono_verbatim = matches!(chroma, HeicChromaPlanes::Monochrome) && bit_depth == 8; + if let ( + HeicChromaPlanes::Color { + u_samples, + v_samples, + chroma_width, + layout: HeicPixelLayout::Yuv420, + }, + PreparedYcbcrTransform::MatrixFull { coeffs, .. }, + ) = (&chroma, converter.transform) + { + heic_decoder::hevc::color_convert::convert_420_8bit_to_interleaved( + &decoded.y_plane.samples, + u_samples, + v_samples, + width, + height, + *chroma_width, + coeffs.r_cr_fp8, + coeffs.g_cb_fp8, + coeffs.g_cr_fp8, + coeffs.b_cb_fp8, + CHANNELS, + out, + ); + return Ok(()); + } + for y in 0..height { let row_start = y * width; let out_row_start = row_start * CHANNELS; From b59130ed201790648b8bc5788ca6a90c137c96d6 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 16 Jul 2026 23:34:19 +0530 Subject: [PATCH 04/28] perf(autoresearch): Experiment: Replace residual-decoding boolean arrays and repeated 16 --- src/heic-decoder/hevc/residual.rs | 190 ++++++++++++++---------------- 1 file changed, 88 insertions(+), 102 deletions(-) diff --git a/src/heic-decoder/hevc/residual.rs b/src/heic-decoder/hevc/residual.rs index 402c74b..155ab76 100644 --- a/src/heic-decoder/hevc/residual.rs +++ b/src/heic-decoder/hevc/residual.rs @@ -328,7 +328,7 @@ pub fn decode_residual( // Track coded_sub_block_flag for prevCsbf calculation // Max sub-block grid is 8x8 for 32x32 TU - let mut coded_sb_flags = [[false; 8]; 8]; + let mut coded_sb_flags = 0u64; // Track whether the previously-processed subblock had any greater1_flag == 1 // This is used for ctx_set derivation per H.265 section 9.3.4.2.6 @@ -342,14 +342,15 @@ pub fn decode_residual( // Calculate neighbor flags BEFORE decoding coded_sub_block_flag // These are used for both coded_sub_block_flag and sig_coeff_flag contexts // Per H.265: bit 0 = right neighbor coded, bit 1 = below neighbor coded + let sb_bit = sb_y as usize * 8 + sb_x as usize; let csbf_neighbors = { let right_coded = if (sb_x as usize + 1) < sb_width { - coded_sb_flags[sb_y as usize][sb_x as usize + 1] + coded_sb_flags & (1 << (sb_bit + 1)) != 0 } else { false }; let below_coded = if (sb_y as usize + 1) < sb_width { - coded_sb_flags[sb_y as usize + 1][sb_x as usize] + coded_sb_flags & (1 << (sb_bit + 8)) != 0 } else { false }; @@ -370,7 +371,7 @@ pub fn decode_residual( // Track coded sub-block flag if sb_coded { - coded_sb_flags[sb_y as usize][sb_x as usize] = true; + coded_sb_flags |= 1 << sb_bit; } // prevCsbf for sig_coeff_flag context @@ -390,8 +391,7 @@ pub fn decode_residual( }; let mut coeff_values = [0i16; 16]; - let mut coeff_flags = [false; 16]; - let mut num_coeffs = 0u8; + let mut coeff_flags = 0u16; let mut can_infer_dc = infer_sb_dc_sig; // Determine the last position to check @@ -399,9 +399,8 @@ pub fn decode_residual( // For other sub-blocks: start from position 15 let last_coeff = if sb_idx == last_sb_idx { // Set the known last significant coefficient (no need to decode sig_coeff_flag) - coeff_flags[start_pos as usize] = true; + coeff_flags |= 1 << start_pos; coeff_values[start_pos as usize] = 1; - num_coeffs = 1; can_infer_dc = false; // Can't infer DC if we have other coeffs // Then check positions from start_pos-1 down to 1 start_pos.saturating_sub(1) @@ -436,9 +435,8 @@ pub fn decode_residual( ); } if sig { - coeff_flags[n as usize] = true; + coeff_flags |= 1 << n; coeff_values[n as usize] = 1; - num_coeffs += 1; can_infer_dc = false; // Found a coefficient, can't infer DC } } @@ -448,9 +446,8 @@ pub fn decode_residual( // DC is inferred to be significant (otherwise the sub-block would be all zeros) if start_pos > 0 { if can_infer_dc { - coeff_flags[0] = true; + coeff_flags |= 1; coeff_values[0] = 1; - num_coeffs += 1; if rc_trace { rc_eprintln!("{rcp}_SIG n=0 DC_INFERRED"); } @@ -480,13 +477,13 @@ pub fn decode_residual( ); } if sig { - coeff_flags[0] = true; + coeff_flags |= 1; coeff_values[0] = 1; - num_coeffs += 1; } } } + let num_coeffs = coeff_flags.count_ones() as u8; if num_coeffs == 0 { continue; } @@ -517,21 +514,20 @@ pub fn decode_residual( // Decode greater-1 flags (up to 8) let mut first_g1_idx: Option = None; - let mut g1_positions = [false; 16]; // Track which positions have g1=1 let max_g1 = (num_coeffs as usize).min(8); let mut g1_count = 0; // Track which coefficients need remaining level decoding - let mut needs_remaining = [false; 16]; + let mut needs_remaining = 0u16; - for n in (0..=start_pos).rev() { - if !coeff_flags[n as usize] { - continue; - } + let mut significant = coeff_flags; + while significant != 0 { + let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; + significant &= !(1 << n); if g1_count >= max_g1 { // Beyond first 8: base=1, always needs remaining for values > 1 - needs_remaining[n as usize] = true; + needs_remaining |= 1 << n; continue; } @@ -570,13 +566,12 @@ pub fn decode_residual( if g1 { coeff_values[n as usize] = 2; - g1_positions[n as usize] = true; this_subblock_had_gt1 = true; // Track for next subblock's ctx_set if first_g1_idx.is_none() { first_g1_idx = Some(n); } else { // Non-first g1=1: base=2, needs remaining for values > 2 - needs_remaining[n as usize] = true; + needs_remaining |= 1 << n; } } g1_count += 1; @@ -599,23 +594,14 @@ pub fn decode_residual( } if g2 { coeff_values[g1_idx as usize] = 3; - needs_remaining[g1_idx as usize] = true; + needs_remaining |= 1 << g1_idx; } } - // Find first and last significant positions in this sub-block - let mut first_sig_pos = None; - let mut last_sig_pos = None; - for n in 0..=start_pos { - if coeff_flags[n as usize] { - if first_sig_pos.is_none() { - first_sig_pos = Some(n); - } - last_sig_pos = Some(n); - } - } - let first_sig_pos = first_sig_pos.unwrap_or(0); - let last_sig_pos = last_sig_pos.unwrap_or(start_pos); + // The flags are non-zero here, so bit scans directly recover the + // first and last significant positions in scan order. + let first_sig_pos = coeff_flags.trailing_zeros() as u8; + let last_sig_pos = (u16::BITS - 1 - coeff_flags.leading_zeros()) as u8; // Determine if sign is hidden for this sub-block // Per H.265 9.3.4.3: sign is hidden if: @@ -630,16 +616,6 @@ pub fn decode_residual( // Following libde265's approach: decode signs in coefficient order (high scan pos to low) // The LAST coefficient (at first_sig_pos) has its sign hidden when sign_hidden=true // - // Build list of significant positions in reverse scan order (high to low) - let mut sig_positions = [0u8; 16]; - let mut n_sig = 0usize; - for n in (0..=start_pos).rev() { - if coeff_flags[n as usize] { - sig_positions[n_sig] = n; - n_sig += 1; - } - } - // Decode signs following libde265 order: // - Decode signs for coefficients 0 to n_sig-2 (high scan pos to low) // - For coefficient at n_sig-1 (lowest scan pos, first in scan order): @@ -648,25 +624,32 @@ pub fn decode_residual( // // This matches the H.265 spec and libde265: the FIRST coefficient in // scanning order (lowest scan position) has its sign hidden. - let mut coeff_signs = [0u8; 16]; - for (i, sign) in coeff_signs[..n_sig.saturating_sub(1)] - .iter_mut() - .enumerate() - { - *sign = cabac.decode_bypass()?; - let _ = i; - } - if n_sig > 0 && !sign_hidden { - coeff_signs[n_sig - 1] = cabac.decode_bypass()?; + let mut coeff_signs = 0u16; + let mut significant = coeff_flags; + while significant != 0 { + let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; + significant &= !(1 << n); + if (n != first_sig_pos || !sign_hidden) && cabac.decode_bypass()? != 0 { + coeff_signs |= 1 << n; + } } if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); + let mut signs = [0u8; 16]; + let mut significant = coeff_flags; + let mut count = 0; + while significant != 0 { + let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; + significant &= !(1 << n); + signs[count] = u8::from(coeff_signs & (1 << n) != 0); + count += 1; + } rc_eprintln!( "{rcp}_SIGNS n_sig={} hidden={} signs={:?} range={} byte={}", - n_sig, + num_coeffs, sign_hidden, - &coeff_signs[..n_sig], + &signs[..num_coeffs as usize], range, byte_pos ); @@ -677,62 +660,65 @@ pub fn decode_residual( let mut rice_param = 0u8; // Decode remaining levels for coefficients that need it - for n in (0..=start_pos).rev() { - if coeff_flags[n as usize] && needs_remaining[n as usize] { - let base = coeff_values[n as usize]; - let (remaining, new_rice) = - decode_coeff_abs_level_remaining(cabac, rice_param, base)?; - if rc_trace { - let (range, _, _) = cabac.get_state_extended(); - let (byte_pos, _, _) = cabac.get_position(); - rc_eprintln!( - "{rcp}_REM n={} base={} rice={} rem={} final={} range={} byte={}", - n, - base, - rice_param, - remaining, - base + remaining, - range, - byte_pos - ); - } - rice_param = new_rice; - coeff_values[n as usize] = base + remaining; + let mut remaining_flags = needs_remaining; + while remaining_flags != 0 { + let n = (u16::BITS - 1 - remaining_flags.leading_zeros()) as u8; + remaining_flags &= !(1 << n); + let base = coeff_values[n as usize]; + let (remaining, new_rice) = decode_coeff_abs_level_remaining(cabac, rice_param, base)?; + if rc_trace { + let (range, _, _) = cabac.get_state_extended(); + let (byte_pos, _, _) = cabac.get_position(); + rc_eprintln!( + "{rcp}_REM n={} base={} rice={} rem={} final={} range={} byte={}", + n, + base, + rice_param, + remaining, + base + remaining, + range, + byte_pos + ); } + rice_param = new_rice; + coeff_values[n as usize] = base + remaining; } // Apply signs and compute sum for parity inference // At this point coeff_values[] are all positive (absolute values) let mut sum_abs_level = 0i32; - for (i, &pos) in sig_positions[..n_sig].iter().enumerate() { - let pos = pos as usize; - if coeff_signs[i] != 0 { - coeff_values[pos] = -coeff_values[pos]; + let mut significant = coeff_flags; + while significant != 0 { + let pos = (u16::BITS - 1 - significant.leading_zeros()) as u8; + significant &= !(1 << pos); + if coeff_signs & (1 << pos) != 0 { + coeff_values[pos as usize] = -coeff_values[pos as usize]; } - sum_abs_level += coeff_values[pos] as i32; + sum_abs_level += coeff_values[pos as usize] as i32; - // Infer hidden sign at the last coefficient (first in scan order) - // Per H.265: if sum of signed coefficients is odd, flip the hidden sign - if i == n_sig - 1 && sign_hidden && (sum_abs_level & 1) != 0 { - coeff_values[pos] = -coeff_values[pos]; + // Infer the hidden sign at the first coefficient in scan order. + if pos == first_sig_pos && sign_hidden && (sum_abs_level & 1) != 0 { + coeff_values[pos as usize] = -coeff_values[pos as usize]; } } // Store coefficients in buffer - for (n, &(px, py)) in scan_pos.iter().enumerate() { - if coeff_flags[n] { - let x = sb_x as usize * 4 + px as usize; - let y = sb_y as usize * 4 + py as usize; - - buffer.set(x, y, coeff_values[n]); - - // Track large coefficients (indicates CABAC desync) - #[cfg(feature = "decoder-tracing")] - if coeff_values[n].abs() > 500 { - let (byte_pos, _, _) = cabac.get_position(); - debug::track_large_coeff(byte_pos); - } + let mut significant = coeff_flags; + while significant != 0 { + let n = significant.trailing_zeros() as usize; + significant &= significant - 1; + let (px, py) = scan_pos[n]; + let x = sb_x as usize * 4 + px as usize; + let y = sb_y as usize * 4 + py as usize; + + buffer.set(x, y, coeff_values[n]); + + // Track large coefficients (indicates CABAC desync) + #[cfg(feature = "decoder-tracing")] + if coeff_values[n].abs() > 500 { + let (byte_pos, _, _) = cabac.get_position(); + debug::track_large_coeff(byte_pos); } } From b5e5787353843005e4ea35d0853c999a8088b7b3 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 10:00:12 +0530 Subject: [PATCH 05/28] perf(autoresearch): Make infallible CABAC primitives return plain values and remove unus --- src/heic-decoder/hevc/cabac.rs | 52 +++++++++++++--------------- src/heic-decoder/hevc/ctu.rs | 56 +++++++++++++++---------------- src/heic-decoder/hevc/residual.rs | 24 ++++++------- 3 files changed, 64 insertions(+), 68 deletions(-) diff --git a/src/heic-decoder/hevc/cabac.rs b/src/heic-decoder/hevc/cabac.rs index abc9e1b..edd65c3 100644 --- a/src/heic-decoder/hevc/cabac.rs +++ b/src/heic-decoder/hevc/cabac.rs @@ -164,8 +164,6 @@ pub struct CabacDecoder<'a> { value: u32, /// Bits needed before next byte read (negative means bits available) bits_needed: i32, - /// Bin counter for debug tracing - bin_counter: u32, } #[allow(dead_code)] @@ -198,7 +196,6 @@ impl<'a> CabacDecoder<'a> { range: 510, value: 0, bits_needed: 8, - bin_counter: 0, }; // Initialize value (matching libde265 exactly) @@ -252,7 +249,7 @@ impl<'a> CabacDecoder<'a> { } /// Read a single bit from the bitstream (for regular context decoding) - fn read_bit(&mut self) -> Result { + fn read_bit(&mut self) { self.value <<= 1; self.bits_needed += 1; @@ -265,13 +262,11 @@ impl<'a> CabacDecoder<'a> { self.bits_needed = -8; } } - - Ok(0) // Return value not used, just for error handling } /// Decode a single bin using context model - pub fn decode_bin(&mut self, ctx: &mut ContextModel) -> Result { - self.bin_counter += 1; + #[inline] + pub fn decode_bin(&mut self, ctx: &mut ContextModel) -> u8 { let q_range_idx = (self.range >> 6) & 3; let lps_range = LPS_TABLE[ctx.state as usize][q_range_idx as usize] as u32; @@ -298,14 +293,14 @@ impl<'a> CabacDecoder<'a> { } // Renormalize - self.renormalize()?; + self.renormalize(); - Ok(bin_val) + bin_val } /// Decode a bypass bin (equal probability) - libde265 compatible - pub fn decode_bypass(&mut self) -> Result { - self.bin_counter += 1; + #[inline] + pub fn decode_bypass(&mut self) -> u8 { self.value <<= 1; self.bits_needed += 1; @@ -322,19 +317,20 @@ impl<'a> CabacDecoder<'a> { let scaled_range = self.range << 7; if self.value >= scaled_range { self.value -= scaled_range; - Ok(1) + 1 } else { - Ok(0) + 0 } } /// Decode multiple bypass bins - pub fn decode_bypass_bits(&mut self, n: u8) -> Result { + #[inline] + pub fn decode_bypass_bits(&mut self, n: u8) -> u32 { let mut result = 0u32; for _ in 0..n { - result = (result << 1) | self.decode_bypass()? as u32; + result = (result << 1) | self.decode_bypass() as u32; } - Ok(result) + result } /// Decode Exp-Golomb coded value (EGk) using bypass bins @@ -342,7 +338,7 @@ impl<'a> CabacDecoder<'a> { let mut base = 0u32; let mut n = k; loop { - let bit = self.decode_bypass()?; + let bit = self.decode_bypass(); if bit == 0 { break; } @@ -352,40 +348,40 @@ impl<'a> CabacDecoder<'a> { return Err(HevcError::InvalidBitstream("EGk prefix too long")); } } - let suffix = self.decode_bypass_bits(n)?; + let suffix = self.decode_bypass_bits(n); Ok(base + suffix) } /// Decode a terminate bin (end of slice check) - pub fn decode_terminate(&mut self) -> Result { + pub fn decode_terminate(&mut self) -> u8 { self.range -= 2; let scaled_range = self.range << 7; if self.value >= scaled_range { - Ok(1) + 1 } else { - self.renormalize()?; - Ok(0) + self.renormalize(); + 0 } } /// Renormalize the decoder state - fn renormalize(&mut self) -> Result<()> { + #[inline] + fn renormalize(&mut self) { while self.range < 256 { self.range <<= 1; // Shift value and read more bits - self.read_bit()?; + self.read_bit(); } // Invariant: after renormalization, range >= 256 debug_assert!(self.range >= 256, "range {} < 256 after renorm", self.range); - Ok(()) } /// Decode unsigned Exp-Golomb code using bypass bins pub fn decode_eg(&mut self, k: u8) -> Result { // Count leading zeros let mut n = 0; - while self.decode_bypass()? != 0 { + while self.decode_bypass() != 0 { n += 1; if n > 31 { return Err(HevcError::CabacError("exp-golomb overflow")); @@ -394,7 +390,7 @@ impl<'a> CabacDecoder<'a> { let mut value = 0u32; for _ in 0..(n + k) { - value = (value << 1) | self.decode_bypass()? as u32; + value = (value << 1) | self.decode_bypass() as u32; } Ok((1 << n) - 1 + value) diff --git a/src/heic-decoder/hevc/ctu.rs b/src/heic-decoder/hevc/ctu.rs index 3e2ea6f..4090ac7 100644 --- a/src/heic-decoder/hevc/ctu.rs +++ b/src/heic-decoder/hevc/ctu.rs @@ -362,7 +362,7 @@ impl<'a> SliceContext<'a> { } // Check for end of slice segment - let end_of_slice = self.cabac.decode_terminate()?; + let end_of_slice = self.cabac.decode_terminate(); se_trace("end_of_slice", end_of_slice as i64, &self.cabac); if end_of_slice != 0 { debug_trace!( @@ -386,7 +386,7 @@ impl<'a> SliceContext<'a> { // WPP: at row boundaries, decode end_of_sub_stream and reinit CABAC if wpp && self.ctb_y != prev_ctb_y { - let _eoss = self.cabac.decode_terminate()?; + let _eoss = self.cabac.decode_terminate(); let entry_point_index = self.ctb_y.saturating_sub(start_ctb_y + 1) as usize; if let Some(&offset) = self.header.entry_point_offsets.get(entry_point_index) { self.cabac.seek_to(offset as usize)?; @@ -451,7 +451,7 @@ impl<'a> SliceContext<'a> { let left_in_slice = ctb_addr_rs > slice_addr_rs; if left_in_slice { let ctx_idx = context::SAO_MERGE_FLAG; - sao_merge_left_flag = self.cabac.decode_bin(&mut self.ctx[ctx_idx])? != 0; + sao_merge_left_flag = self.cabac.decode_bin(&mut self.ctx[ctx_idx]) != 0; se_trace("sao_merge_left", sao_merge_left_flag as i64, &self.cabac); } } @@ -464,7 +464,7 @@ impl<'a> SliceContext<'a> { let up_in_slice = ctb_addr_rs >= pic_width_ctbs + slice_addr_rs; if up_in_slice { let ctx_idx = context::SAO_MERGE_FLAG; - sao_merge_up_flag = self.cabac.decode_bin(&mut self.ctx[ctx_idx])? != 0; + sao_merge_up_flag = self.cabac.decode_bin(&mut self.ctx[ctx_idx]) != 0; se_trace("sao_merge_up", sao_merge_up_flag as i64, &self.cabac); } } @@ -536,7 +536,7 @@ impl<'a> SliceContext<'a> { let mut signed_offsets = [0i8; 4]; for i in 0..4 { if offsets_abs[i] != 0 { - let sign = self.cabac.decode_bypass()?; + let sign = self.cabac.decode_bypass(); se_trace("sao_offset_sign", sign as i64, &self.cabac); let val = (offsets_abs[i] as i32 * offset_scale) as i8; signed_offsets[i] = if sign != 0 { -val } else { val }; @@ -544,7 +544,7 @@ impl<'a> SliceContext<'a> { } info.sao_offset_val[c_idx] = signed_offsets; - let band_pos = self.cabac.decode_bypass_bits(5)?; + let band_pos = self.cabac.decode_bypass_bits(5); se_trace("sao_band_position", band_pos as i64, &self.cabac); info.sao_band_position[c_idx] = band_pos as u8; } else { @@ -554,7 +554,7 @@ impl<'a> SliceContext<'a> { } if c_idx <= 1 { - let eo_class = self.cabac.decode_bypass_bits(2)?; + let eo_class = self.cabac.decode_bypass_bits(2); se_trace("sao_eo_class", eo_class as i64, &self.cabac); if c_idx == 0 { info.sao_eo_class[0] = eo_class as u8; @@ -578,11 +578,11 @@ impl<'a> SliceContext<'a> { /// Decode sao_type_idx: context bin + optional bypass bin fn decode_sao_type_idx(&mut self) -> Result { let ctx_idx = context::SAO_TYPE_IDX; - let bit0 = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let bit0 = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); if bit0 == 0 { Ok(0) } else { - let bit1 = self.cabac.decode_bypass()?; + let bit1 = self.cabac.decode_bypass(); if bit1 == 0 { Ok(1) } else { Ok(2) } } } @@ -590,7 +590,7 @@ impl<'a> SliceContext<'a> { /// Decode truncated unary with bypass bins (for sao_offset_abs) fn decode_cabac_tu_bypass(&mut self, c_max: u32) -> Result { for i in 0..c_max { - let bit = self.cabac.decode_bypass()?; + let bit = self.cabac.decode_bypass(); if bit == 0 { return Ok(i); } @@ -768,7 +768,7 @@ impl<'a> SliceContext<'a> { } let ctx_idx = context::SPLIT_CU_FLAG + cond_l + cond_a; - let bin = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let bin = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); se_trace("split_cu_flag", bin as i64, &self.cabac); Ok(bin != 0) @@ -804,7 +804,7 @@ impl<'a> SliceContext<'a> { // Decode transquant_bypass_flag if enabled self.cu_transquant_bypass_flag = if self.pps.transquant_bypass_enabled_flag { let ctx_idx = context::CU_TRANSQUANT_BYPASS_FLAG; - self.cabac.decode_bin(&mut self.ctx[ctx_idx])? != 0 + self.cabac.decode_bin(&mut self.ctx[ctx_idx]) != 0 } else { false }; @@ -1041,7 +1041,7 @@ impl<'a> SliceContext<'a> { { // Decode split_transform_flag let ctx_idx = context::SPLIT_TRANSFORM_FLAG + (5 - log2_size as usize).min(2); - let flag = self.cabac.decode_bin(&mut self.ctx[ctx_idx])? != 0; + let flag = self.cabac.decode_bin(&mut self.ctx[ctx_idx]) != 0; se_trace("split_transform", flag as i64, &self.cabac); flag } else if log2_size > log2_max_trafo_size || (intra_split_flag && trafo_depth == 0) { @@ -1071,10 +1071,10 @@ impl<'a> SliceContext<'a> { let cb = if cbf_cb_parent != 0 { let ctx_idx = context::CBF_CBCR + trafo_depth as usize; - let mut val = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let mut val = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); se_trace("cbf_cb", val as i64, &self.cabac); if decode_extra_422_cbf { - let extra = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let extra = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); se_trace("cbf_cb", extra as i64, &self.cabac); val |= extra << 1; } @@ -1084,10 +1084,10 @@ impl<'a> SliceContext<'a> { }; let cr = if cbf_cr_parent != 0 { let ctx_idx = context::CBF_CBCR + trafo_depth as usize; - let mut val = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let mut val = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); se_trace("cbf_cr", val as i64, &self.cabac); if decode_extra_422_cbf { - let extra = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let extra = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); se_trace("cbf_cr", extra as i64, &self.cabac); val |= extra << 1; } @@ -1215,7 +1215,7 @@ impl<'a> SliceContext<'a> { // Context: offset 0 if trafo_depth > 0, offset 1 if trafo_depth == 0 let ctx_offset = if trafo_depth == 0 { 1 } else { 0 }; let ctx_idx = context::CBF_LUMA + ctx_offset; - let cbf_luma = self.cabac.decode_bin(&mut self.ctx[ctx_idx])? != 0; + let cbf_luma = self.cabac.decode_bin(&mut self.ctx[ctx_idx]) != 0; se_trace("cbf_luma", cbf_luma as i64, &self.cabac); // Per H.265 7.3.8.11: decode cu_qp_delta before residuals @@ -1226,7 +1226,7 @@ impl<'a> SliceContext<'a> { { let cu_qp_delta_abs = self.decode_cu_qp_delta_abs()?; let cu_qp_delta_sign = if cu_qp_delta_abs != 0 { - self.cabac.decode_bypass()? + self.cabac.decode_bypass() } else { 0 }; @@ -1452,7 +1452,7 @@ impl<'a> SliceContext<'a> { fn decode_cu_qp_delta_abs(&mut self) -> Result { let first_bin = self .cabac - .decode_bin(&mut self.ctx[context::CU_QP_DELTA_ABS])?; + .decode_bin(&mut self.ctx[context::CU_QP_DELTA_ABS]); if first_bin == 0 { return Ok(0); } @@ -1460,7 +1460,7 @@ impl<'a> SliceContext<'a> { for _ in 0..4 { let bin = self .cabac - .decode_bin(&mut self.ctx[context::CU_QP_DELTA_ABS + 1])?; + .decode_bin(&mut self.ctx[context::CU_QP_DELTA_ABS + 1]); if bin == 0 { break; } @@ -1644,7 +1644,7 @@ impl<'a> SliceContext<'a> { if pred_mode == PredMode::Intra { // For intra, first bin distinguishes 2Nx2N from NxN let ctx_idx = context::PART_MODE; - let bin = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let bin = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); se_trace("part_mode", bin as i64, &self.cabac); if bin != 0 { @@ -1698,7 +1698,7 @@ impl<'a> SliceContext<'a> { /// Decode prev_intra_luma_pred_flag (context-coded bin) fn decode_prev_intra_luma_pred_flag(&mut self) -> Result { let ctx_idx = context::PREV_INTRA_LUMA_PRED_FLAG; - let val = self.cabac.decode_bin(&mut self.ctx[ctx_idx])? != 0; + let val = self.cabac.decode_bin(&mut self.ctx[ctx_idx]) != 0; se_trace("prev_intra_luma_pred", val as i64, &self.cabac); Ok(val) } @@ -1739,14 +1739,14 @@ impl<'a> SliceContext<'a> { /// - If candidate mode collides with luma mode → Angular34 fn decode_intra_chroma_mode(&mut self, luma_mode: IntraPredMode) -> Result { let ctx_idx = context::INTRA_CHROMA_PRED_MODE; - let first_bin = self.cabac.decode_bin(&mut self.ctx[ctx_idx])?; + let first_bin = self.cabac.decode_bin(&mut self.ctx[ctx_idx]); let intra_chroma_mode = if first_bin == 0 { // Mode 4: derived from luma se_trace("intra_chroma_mode", 4, &self.cabac); luma_mode } else { // Read 2 fixed-length bypass bits for modes 0-3 - let mode_idx = self.cabac.decode_bypass_bits(2)? as u8; + let mode_idx = self.cabac.decode_bypass_bits(2) as u8; se_trace("intra_chroma_mode", mode_idx as i64, &self.cabac); let candidate = match mode_idx { @@ -1921,9 +1921,9 @@ impl<'a> SliceContext<'a> { /// Decode mpm_idx (0, 1, or 2) fn decode_mpm_idx(&mut self) -> Result { // Truncated unary: 0, 10, 11 - let val = if self.cabac.decode_bypass()? == 0 { + let val = if self.cabac.decode_bypass() == 0 { 0 - } else if self.cabac.decode_bypass()? == 0 { + } else if self.cabac.decode_bypass() == 0 { 1 } else { 2 @@ -1936,7 +1936,7 @@ impl<'a> SliceContext<'a> { fn decode_rem_intra_luma_pred_mode(&mut self) -> Result { let mut val = 0u32; for _ in 0..5 { - val = (val << 1) | self.cabac.decode_bypass()? as u32; + val = (val << 1) | self.cabac.decode_bypass() as u32; } se_trace("rem_intra_luma", val as i64, &self.cabac); Ok(val) diff --git a/src/heic-decoder/hevc/residual.rs b/src/heic-decoder/hevc/residual.rs index 155ab76..6030c67 100644 --- a/src/heic-decoder/hevc/residual.rs +++ b/src/heic-decoder/hevc/residual.rs @@ -249,7 +249,7 @@ pub fn decode_residual( // Log2MaxTransformSkipSize defaults to 2 (4x4 blocks only) let transform_skip = if transform_skip_enabled && !cu_transquant_bypass && log2_size <= 2 { let ctx_idx = context::TRANSFORM_SKIP_FLAG + if c_idx > 0 { 1 } else { 0 }; - let flag = cabac.decode_bin(&mut ctx[ctx_idx])? != 0; + let flag = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); @@ -629,7 +629,7 @@ pub fn decode_residual( while significant != 0 { let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; significant &= !(1 << n); - if (n != first_sig_pos || !sign_hidden) && cabac.decode_bypass()? != 0 { + if (n != first_sig_pos || !sign_hidden) && cabac.decode_bypass() != 0 { coeff_signs |= 1 << n; } } @@ -882,7 +882,7 @@ fn decode_last_sig_coeff_pos( // Decode suffix if needed let x = if x_prefix > 3 { let n_bits = (x_prefix >> 1) - 1; - let suffix = cabac.decode_bypass_bits(n_bits as u8)?; + let suffix = cabac.decode_bypass_bits(n_bits as u8); ((2 + (x_prefix & 1)) << n_bits) + suffix } else { x_prefix @@ -890,7 +890,7 @@ fn decode_last_sig_coeff_pos( let y = if y_prefix > 3 { let n_bits = (y_prefix >> 1) - 1; - let suffix = cabac.decode_bypass_bits(n_bits as u8)?; + let suffix = cabac.decode_bypass_bits(n_bits as u8); ((2 + (y_prefix & 1)) << n_bits) + suffix } else { y_prefix @@ -932,7 +932,7 @@ fn decode_last_sig_coeff_prefix( let mut prefix = 0u32; while prefix < max_prefix as u32 { let ctx_idx = ctx_base + ctx_offset + (prefix as usize >> ctx_shift as usize); - let bin = cabac.decode_bin(&mut ctx[ctx_idx])?; + let bin = cabac.decode_bin(&mut ctx[ctx_idx]); if bin == 0 { break; } @@ -956,7 +956,7 @@ fn decode_coded_sub_block_flag( // csbfCtx = 1 if either neighbor is coded, else 0 let csbf_ctx = if csbf_neighbors != 0 { 1 } else { 0 }; let ctx_idx = context::CODED_SUB_BLOCK_FLAG + csbf_ctx + if c_idx > 0 { 2 } else { 0 }; - Ok(cabac.decode_bin(&mut ctx[ctx_idx])? != 0) + Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) } /// Context index map for 4x4 TU sig_coeff_flag (H.265 Table 9-41) @@ -1093,7 +1093,7 @@ fn decode_sig_coeff_flag( let ctx_idx = calc_sig_coeff_flag_ctx(x_c, y_c, log2_size, c_idx, scan_idx, prev_csbf); - Ok(cabac.decode_bin(&mut ctx[ctx_idx])? != 0) + Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) } /// Decode coeff_abs_level_greater1_flag @@ -1110,7 +1110,7 @@ fn decode_coeff_greater1_flag( + if c_idx > 0 { 16 } else { 0 } + (ctx_set as usize) * 4 + (greater1_ctx as usize).min(3); - Ok(cabac.decode_bin(&mut ctx[ctx_idx])? != 0) + Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) } /// Decode coeff_abs_level_greater2_flag @@ -1123,7 +1123,7 @@ fn decode_coeff_greater2_flag( ) -> Result { let ctx_idx = context::COEFF_ABS_LEVEL_GREATER2_FLAG + if c_idx > 0 { 4 } else { 0 } + ctx_set as usize; - Ok(cabac.decode_bin(&mut ctx[ctx_idx])? != 0) + Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) } /// Decode coeff_abs_level_remaining (Golomb-Rice with adaptive rice parameter) @@ -1135,14 +1135,14 @@ fn decode_coeff_abs_level_remaining( ) -> Result<(i16, u8)> { // Decode prefix (unary part) let mut prefix = 0u32; - while cabac.decode_bypass()? != 0 && prefix < 32 { + while cabac.decode_bypass() != 0 && prefix < 32 { prefix += 1; } let value = if prefix <= 3 { // TR part only: value = (prefix << rice_param) + suffix let suffix = if rice_param > 0 { - cabac.decode_bypass_bits(rice_param)? + cabac.decode_bypass_bits(rice_param) } else { 0 }; @@ -1158,7 +1158,7 @@ fn decode_coeff_abs_level_remaining( } // EGk part: suffix bits = prefix - 3 + rice_param let suffix_bits = (prefix - 3 + rice_param as u32) as u8; - let suffix = cabac.decode_bypass_bits(suffix_bits)?; + let suffix = cabac.decode_bypass_bits(suffix_bits); // value = (((1 << (prefix-3)) + 3 - 1) << rice_param) + suffix let base = ((1u32 << (prefix - 3)) + 2) << rice_param; let v = base + suffix; From 2f4fc3d3d9288d34ad29fb900cd8c80e636d9c80 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 10:18:07 +0530 Subject: [PATCH 06/28] perf(autoresearch): Experiment: Extend full-range 8-bit 4:2:0 SIMD conversion to clean-a --- src/heic-decoder/hevc/color_convert.rs | 246 ++++++++++++++++++++----- src/lib.rs | 146 ++++++++++++--- 2 files changed, 321 insertions(+), 71 deletions(-) diff --git a/src/heic-decoder/hevc/color_convert.rs b/src/heic-decoder/hevc/color_convert.rs index 89a9c2c..fb81072 100644 --- a/src/heic-decoder/hevc/color_convert.rs +++ b/src/heic-decoder/hevc/color_convert.rs @@ -38,31 +38,88 @@ pub(crate) fn convert_420_8bit_to_interleaved( b_cb: i32, channels: usize, output: &mut [u8], +) { + convert_420_8bit_region_to_interleaved( + y_plane, + cb_plane, + cr_plane, + width, + chroma_width, + 0, + 0, + width, + height, + r_cr, + g_cb, + g_cr, + b_cb, + channels, + output, + ); +} + +/// Convert a rectangular region of full-range 8-bit 4:2:0 planes. +/// +/// Source coordinates remain in the uncropped planes, so an odd `x_start` +/// retains the original chroma phase. The result is tightly packed. +#[allow(clippy::too_many_arguments)] +pub(crate) fn convert_420_8bit_region_to_interleaved( + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + r_cr: i32, + g_cb: i32, + g_cr: i32, + b_cb: i32, + channels: usize, + output: &mut [u8], ) { assert!(matches!(channels, 3 | 4), "channels must be RGB or RGBA"); let pixel_count = width .checked_mul(height) - .expect("4:2:0 pixel count must fit in usize"); + .expect("4:2:0 region pixel count must fit in usize"); let output_len = pixel_count .checked_mul(channels) .expect("interleaved output length must fit in usize"); - let chroma_height = height.div_ceil(2); - let chroma_len = chroma_width - .checked_mul(chroma_height) - .expect("4:2:0 chroma sample count must fit in usize"); + let x_end = x_start + .checked_add(width) + .expect("4:2:0 region x extent must fit in usize"); + let y_end = y_start + .checked_add(height) + .expect("4:2:0 region y extent must fit in usize"); + let y_len = y_stride + .checked_mul(y_end) + .expect("4:2:0 luma extent must fit in usize"); + let chroma_rows = y_end.div_ceil(2); + let chroma_len = chroma_stride + .checked_mul(chroma_rows) + .expect("4:2:0 chroma extent must fit in usize"); assert_eq!(output.len(), output_len, "interleaved output length"); - assert!(y_plane.len() >= pixel_count, "luma plane length"); - assert!(chroma_width >= width.div_ceil(2), "4:2:0 chroma row width"); + assert!(x_end <= y_stride, "4:2:0 region exceeds luma row"); + assert!(y_plane.len() >= y_len, "luma plane length"); + assert!( + chroma_stride >= x_end.div_ceil(2), + "4:2:0 region exceeds chroma row" + ); assert!(cb_plane.len() >= chroma_len, "Cb plane length"); assert!(cr_plane.len() >= chroma_len, "Cr plane length"); incant!( - convert_420_8bit_to_interleaved( + convert_420_8bit_region_to_interleaved( y_plane, cb_plane, cr_plane, + y_stride, + chroma_stride, + x_start, + y_start, width, height, - chroma_width, r_cr, g_cb, g_cr, @@ -75,14 +132,17 @@ pub(crate) fn convert_420_8bit_to_interleaved( } #[allow(clippy::too_many_arguments)] -fn convert_420_8bit_to_interleaved_scalar( +fn convert_420_8bit_region_to_interleaved_scalar( _token: ScalarToken, y_plane: &[u16], cb_plane: &[u16], cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + x_start: usize, + y_start: usize, width: usize, height: usize, - chroma_width: usize, r_cr: i32, g_cb: i32, g_cr: i32, @@ -90,20 +150,22 @@ fn convert_420_8bit_to_interleaved_scalar( channels: usize, output: &mut [u8], ) { - for y in 0..height { - let y_row = y * width; - let chroma_row = (y / 2) * chroma_width; - for x in 0..width { + for output_y in 0..height { + let source_y = y_start + output_y; + let y_row = source_y * y_stride; + let chroma_row = (source_y / 2) * chroma_stride; + for output_x in 0..width { + let source_x = x_start + output_x; write_420_8bit_pixel( - y_plane[y_row + x], - cb_plane[chroma_row + x / 2], - cr_plane[chroma_row + x / 2], + y_plane[y_row + source_x], + cb_plane[chroma_row + source_x / 2], + cr_plane[chroma_row + source_x / 2], r_cr, g_cb, g_cr, b_cb, channels, - &mut output[(y_row + x) * channels..], + &mut output[(output_y * width + output_x) * channels..], ); } } @@ -139,14 +201,17 @@ fn write_420_8bit_pixel( #[cfg(target_arch = "aarch64")] #[allow(clippy::too_many_arguments)] #[arcane] -fn convert_420_8bit_to_interleaved_neon( +fn convert_420_8bit_region_to_interleaved_neon( _token: NeonToken, y_plane: &[u16], cb_plane: &[u16], cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + x_start: usize, + y_start: usize, width: usize, height: usize, - chroma_width: usize, r_cr: i32, g_cb: i32, g_cr: i32, @@ -166,14 +231,17 @@ fn convert_420_8bit_to_interleaved_neon( let green_fits = (i64::from(g_cb).abs() + i64::from(g_cr).abs()) * max_delta + max_y + rounded <= i64::from(i32::MAX); if !single_channel_fits(r_cr) || !green_fits || !single_channel_fits(b_cb) { - convert_420_8bit_to_interleaved_scalar( + convert_420_8bit_region_to_interleaved_scalar( ScalarToken, y_plane, cb_plane, cr_plane, + y_stride, + chroma_stride, + x_start, + y_start, width, height, - chroma_width, r_cr, g_cb, g_cr, @@ -189,21 +257,46 @@ fn convert_420_8bit_to_interleaved_neon( let center = vdupq_n_s32(128); let rounding = vdupq_n_s32(128); let opaque = vdup_n_u8(u8::MAX); - let simd_width = width / 8 * 8; - - for y in 0..height { - let y_row = y * width; - let chroma_row = (y / 2) * chroma_width; - let mut x = 0; - while x < simd_width { - let y_values = vld1q_u16((&y_plane[y_row + x..y_row + x + 8]).try_into().unwrap()); + let x_end = x_start + width; + // Chroma pairs start on even luma coordinates. Peel an odd first sample + // so every SIMD group can duplicate four adjacent chroma samples. + let simd_start = x_start.next_multiple_of(2).min(x_end); + let simd_end = simd_start + (x_end - simd_start) / 8 * 8; + + for output_y in 0..height { + let source_y = y_start + output_y; + let y_row = source_y * y_stride; + let chroma_row = (source_y / 2) * chroma_stride; + + for source_x in x_start..simd_start { + let output_x = source_x - x_start; + write_420_8bit_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_row + source_x / 2], + cr_plane[chroma_row + source_x / 2], + r_cr, + g_cb, + g_cr, + b_cb, + channels, + &mut output[(output_y * width + output_x) * channels..], + ); + } + + let mut source_x = simd_start; + while source_x < simd_end { + let y_values = vld1q_u16( + (&y_plane[y_row + source_x..y_row + source_x + 8]) + .try_into() + .unwrap(), + ); let cb_values = vld1_u16( - (&cb_plane[chroma_row + x / 2..chroma_row + x / 2 + 4]) + (&cb_plane[chroma_row + source_x / 2..chroma_row + source_x / 2 + 4]) .try_into() .unwrap(), ); let cr_values = vld1_u16( - (&cr_plane[chroma_row + x / 2..chroma_row + x / 2 + 4]) + (&cr_plane[chroma_row + source_x / 2..chroma_row + source_x / 2 + 4]) .try_into() .unwrap(), ); @@ -257,7 +350,8 @@ fn convert_420_8bit_to_interleaved_neon( let r = vqmovn_u16(vcombine_u16(vqmovun_s32(r_lo), vqmovun_s32(r_hi))); let g = vqmovn_u16(vcombine_u16(vqmovun_s32(g_lo), vqmovun_s32(g_hi))); let b = vqmovn_u16(vcombine_u16(vqmovun_s32(b_lo), vqmovun_s32(b_hi))); - let output_index = (y_row + x) * channels; + let output_x = source_x - x_start; + let output_index = (output_y * width + output_x) * channels; // SAFETY: the caller-proven output length is `width * height * // channels`; this iteration starts at an in-row group of eight @@ -270,20 +364,21 @@ fn convert_420_8bit_to_interleaved_neon( vst3_u8(destination, uint8x8x3_t(r, g, b)); } } - x += 8; + source_x += 8; } - for x in simd_width..width { + for source_x in simd_end..x_end { + let output_x = source_x - x_start; write_420_8bit_pixel( - y_plane[y_row + x], - cb_plane[chroma_row + x / 2], - cr_plane[chroma_row + x / 2], + y_plane[y_row + source_x], + cb_plane[chroma_row + source_x / 2], + cr_plane[chroma_row + source_x / 2], r_cr, g_cb, g_cr, b_cb, channels, - &mut output[(y_row + x) * channels..], + &mut output[(output_y * width + output_x) * channels..], ); } } @@ -652,14 +747,17 @@ mod tests { for channels in [3, 4] { let mut expected = vec![0_u8; width * height * channels]; - convert_420_8bit_to_interleaved_scalar( + convert_420_8bit_region_to_interleaved_scalar( ScalarToken, &y_plane, &cb_plane, &cr_plane, width, - height, chroma_width, + 0, + 0, + width, + height, 403, -48, -120, @@ -687,4 +785,68 @@ mod tests { } } } + + #[test] + fn full_range_420_cropped_region_preserves_odd_chroma_phase() { + let y_stride = 19_usize; + let source_height = 7_usize; + let chroma_stride = y_stride.div_ceil(2); + let mut state = 0x6d28_1b45_u32; + let mut sample = || { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 16) as u16 + }; + let y_plane = (0..y_stride * source_height) + .map(|_| sample()) + .collect::>(); + let cb_plane = (0..chroma_stride * source_height.div_ceil(2)) + .map(|_| sample()) + .collect::>(); + let cr_plane = (0..chroma_stride * source_height.div_ceil(2)) + .map(|_| sample()) + .collect::>(); + let (x_start, y_start, width, height) = (1, 1, 17, 5); + + for channels in [3, 4] { + let mut expected = vec![0_u8; width * height * channels]; + convert_420_8bit_region_to_interleaved_scalar( + ScalarToken, + &y_plane, + &cb_plane, + &cr_plane, + y_stride, + chroma_stride, + x_start, + y_start, + width, + height, + 403, + -48, + -120, + 475, + channels, + &mut expected, + ); + + let mut actual = vec![0_u8; expected.len()]; + convert_420_8bit_region_to_interleaved( + &y_plane, + &cb_plane, + &cr_plane, + y_stride, + chroma_stride, + x_start, + y_start, + width, + height, + 403, + -48, + -120, + 475, + channels, + &mut actual, + ); + assert_eq!(actual, expected, "{channels} channels"); + } + } } diff --git a/src/lib.rs b/src/lib.rs index ce3cc97..6f60f18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8606,12 +8606,11 @@ fn decoded_heic_to_rgba8_slice( auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u8], ) -> Result<(), DecodeError> { - // The common coded-image case needs no coordinate remap or alpha - // composition. Route it through the shared contiguous converter so its - // architecture-specific 4:2:0 kernel can write the caller's buffer - // directly. Non-empty transform sequences still take the exact generic - // mapping path, including identity-valued properties and their guards. - if transforms.is_empty() && auxiliary_alpha.is_none() { + // Identity and clean-aperture-only coded images remain rectangular source + // regions. Route those directly through the shared contiguous converter + // so its architecture-specific 4:2:0 kernel writes the caller's buffer + // without materializing a full-frame RGBA intermediate. + if auxiliary_alpha.is_none() { let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; if heic_storage_bit_depth(source_bit_depth) != 8 { return Err(DecodeError::Unsupported(format!( @@ -8619,26 +8618,51 @@ fn decoded_heic_to_rgba8_slice( heic_storage_bit_depth(source_bit_depth) ))); } - RgbaTransformPlan::from_primary_transforms(decoded.width, decoded.height, transforms)?; - let expected = checked_rgba_sample_count(decoded.width, decoded.height)?; + let transform_plan = + RgbaTransformPlan::from_primary_transforms(decoded.width, decoded.height, transforms)?; + let expected = checked_rgba_sample_count( + transform_plan.destination_width, + transform_plan.destination_height, + )?; if out.len() != expected { return Err(DecodeError::TransformGuard( TransformGuardError::RgbaSampleCountMismatch { stage: "HEIC direct transformed image adapter output", actual: out.len(), expected, - width: decoded.width, - height: decoded.height, + width: transform_plan.destination_width, + height: transform_plan.destination_height, }, )); } - return convert_heic_to_interleaved_rgb8_slice::<4>( - &decoded, - out, - scale_sample_to_u8, - "RGBA8", - ) - .map_err(DecodeError::from); + if transform_plan + .steps + .iter() + .all(|step| matches!(step, ResolvedRgbaTransformStep::CleanAperture { .. })) + { + let (source_x, source_y) = transform_plan.map_destination_pixel(0, 0)?; + let destination_width = transform_dimension_to_usize( + "HEIC clean-aperture adapter", + "destination width", + transform_plan.destination_width, + )?; + let destination_height = transform_dimension_to_usize( + "HEIC clean-aperture adapter", + "destination height", + transform_plan.destination_height, + )?; + return convert_heic_to_interleaved_rgb8_region_slice::<4>( + &decoded, + source_x, + source_y, + destination_width, + destination_height, + out, + scale_sample_to_u8, + "RGBA8", + ) + .map_err(DecodeError::from); + } } let mut output = SliceRgbaOutput(out); @@ -11769,6 +11793,37 @@ fn convert_heic_to_interleaved_rgb8_slice( out: &mut [u8], scale_sample: fn(u16, u8) -> u8, output_label: &str, +) -> Result<(), DecodeHeicError> { + let width = + usize::try_from(decoded.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("HEIC width does not fit in usize ({})", decoded.width), + })?; + let height = + usize::try_from(decoded.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("HEIC height does not fit in usize ({})", decoded.height), + })?; + convert_heic_to_interleaved_rgb8_region_slice::( + decoded, + 0, + 0, + width, + height, + out, + scale_sample, + output_label, + ) +} + +#[allow(clippy::too_many_arguments)] +fn convert_heic_to_interleaved_rgb8_region_slice( + decoded: &DecodedHeicImage, + source_x_start: usize, + source_y_start: usize, + width: usize, + height: usize, + out: &mut [u8], + scale_sample: fn(u16, u8) -> u8, + output_label: &str, ) -> Result<(), DecodeHeicError> { debug_assert!(matches!(CHANNELS, 3 | 4)); let ycbcr_transform = @@ -11791,15 +11846,42 @@ fn convert_heic_to_interleaved_rgb8_slice( }); } - let width = + let source_width = usize::try_from(decoded.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { detail: format!("HEIC width does not fit in usize ({})", decoded.width), })?; - let height = + let source_height = usize::try_from(decoded.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { detail: format!("HEIC height does not fit in usize ({})", decoded.height), })?; - let output_len = checked_heic_interleaved_output_len(decoded, CHANNELS, output_label)?; + let source_x_end = + source_x_start + .checked_add(width) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{output_label} source region x extent overflow"), + })?; + let source_y_end = + source_y_start + .checked_add(height) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{output_label} source region y extent overflow"), + })?; + if source_x_end > source_width || source_y_end > source_height { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{output_label} source region ({source_x_start},{source_y_start}) {width}x{height} exceeds {}x{}", + decoded.width, decoded.height + ), + }); + } + let output_len = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(CHANNELS)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{output_label} region output sample count overflow for {width}x{height}" + ), + })?; if out.len() != output_len { return Err(DecodeHeicError::InvalidDecodedFrame { detail: format!( @@ -11832,13 +11914,16 @@ fn convert_heic_to_interleaved_rgb8_slice( PreparedYcbcrTransform::MatrixFull { coeffs, .. }, ) = (&chroma, converter.transform) { - heic_decoder::hevc::color_convert::convert_420_8bit_to_interleaved( + heic_decoder::hevc::color_convert::convert_420_8bit_region_to_interleaved( &decoded.y_plane.samples, u_samples, v_samples, + source_width, + *chroma_width, + source_x_start, + source_y_start, width, height, - *chroma_width, coeffs.r_cr_fp8, coeffs.g_cb_fp8, coeffs.g_cr_fp8, @@ -11849,12 +11934,14 @@ fn convert_heic_to_interleaved_rgb8_slice( return Ok(()); } - for y in 0..height { - let row_start = y * width; - let out_row_start = row_start * CHANNELS; + for output_y in 0..height { + let source_y = source_y_start + output_y; + let row_start = source_y * source_width; + let out_row_start = output_y * width * CHANNELS; - for x in 0..width { - let y_index = row_start + x; + for output_x in 0..width { + let source_x = source_x_start + output_x; + let y_index = row_start + source_x; let y_sample = i32::from(decoded.y_plane.samples[y_index]); let (cb_sample, cr_sample) = match &chroma { @@ -11865,7 +11952,8 @@ fn convert_heic_to_interleaved_rgb8_slice( chroma_width, layout, } => { - let chroma_index = heic_chroma_sample_index(x, y, *chroma_width, *layout); + let chroma_index = + heic_chroma_sample_index(source_x, source_y, *chroma_width, *layout); ( i32::from(u_samples[chroma_index]), i32::from(v_samples[chroma_index]), @@ -11881,7 +11969,7 @@ fn convert_heic_to_interleaved_rgb8_slice( } else { converter.convert(y_sample, cb_sample, cr_sample) }; - let out_index = out_row_start + (x * CHANNELS); + let out_index = out_row_start + (output_x * CHANNELS); out[out_index] = scale_sample(r, bit_depth); out[out_index + 1] = scale_sample(g, bit_depth); out[out_index + 2] = scale_sample(b, bit_depth); From 6a4adbdc855ede1700839ec11b9283d7bcf657c8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 10:22:16 +0530 Subject: [PATCH 07/28] feat(autoresearch): journal every optimization attempt --- autoresearch/README.md | 18 ++++- autoresearch/program.md | 22 ++++-- scripts/autoresearch.sh | 165 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 187 insertions(+), 18 deletions(-) diff --git a/autoresearch/README.md b/autoresearch/README.md index 38d1a3e..4a58283 100644 --- a/autoresearch/README.md +++ b/autoresearch/README.md @@ -46,6 +46,16 @@ results, rejected patches, and logs outside the repository under `~/.cache/heic-decoder-autoresearch//`. This prevents a workspace-only optimization agent from modifying the acceptance state. +Every terminal attempt is also appended to a controller-owned Markdown journal +at `~/.cache/heic-decoder-autoresearch/.experiments.md`. Its readable +repository mirror is `.heic-autoresearch/experiments.md`. The journal survives +fresh baselines and records the exact primary speed factor for every measured +success or failure, the full-corpus factor when that gate is reached, the +controller's rejection reason, a short learning, and the agent's report. The +controller refreshes the ignored mirror from trusted external state before and +after each agent turn, and every agent must read the complete journal before +choosing an experiment. + ## Start a run First commit the harness itself on `faster`, then begin from a clean worktree: @@ -67,6 +77,7 @@ Useful controls from another terminal: ```bash scripts/autoresearch.sh status scripts/autoresearch.sh stop +less .heic-autoresearch/experiments.md ``` `stop` is cooperative: it stops before the next agent attempt, not in the middle @@ -110,8 +121,11 @@ confirmation corpus is discovered once by the trusted baseline and stored outside the agent-writable repository. Rejected diffs are archived for review, but are removed from the worktree. The -loop refuses to start unless the branch, HEAD, and tracked/untracked worktree -match the saved champion, so unrelated user changes are never discarded. +Markdown journal records both accepted and rejected experiments so later agents +do not repeat an unsuccessful implementation without addressing its measured +result or failure. The loop refuses to start unless the branch, HEAD, and +tracked/untracked worktree match the saved champion, so unrelated user changes +are never discarded. ## Portability and final promotion diff --git a/autoresearch/program.md b/autoresearch/program.md index 7674a2a..fad49f1 100644 --- a/autoresearch/program.md +++ b/autoresearch/program.md @@ -21,11 +21,14 @@ valuable when it speeds up this route too. A change that speeds up only a direct decode API, but does not speed up the image-crate hook route, is not an improvement for this project and must not be kept. -Read the current source, recent git history, and the experiment results included -in your prompt before choosing an idea. Prefer evidence: inspect hot loops, +Read the current source, recent git history, and the complete controller-owned +experiment journal at `.heic-autoresearch/experiments.md` before choosing an +idea. The journal includes accepted work, failed work, measured speed factors, +and failure learnings from earlier agents. Prefer evidence: inspect hot loops, algorithms, allocations, bounds checks, data layout, and existing SIMD paths. -Do not repeat an experiment already recorded as rejected unless your hypothesis -materially addresses why it failed. +Do not repeat an accepted or rejected experiment unchanged. A related follow-up +is allowed only when your hypothesis explicitly states the material difference +and how it addresses the recorded result or failure. ## Files you may change @@ -37,7 +40,8 @@ Do not modify `autoresearch/`, `scripts/`, tests or corpora under ignored directories, CI configuration, git configuration, the git index, or git history. Do not run `git commit`, `git reset`, `git restore`, `git clean`, or `git checkout`. The controller starts you from a clean champion and will handle -all repository state. +all repository state. In particular, `.heic-autoresearch/experiments.md` is a +read-only mirror of trusted external state; never edit, replace, or delete it. ## Correctness and anti-cheating rules @@ -72,7 +76,8 @@ promotion includes benchmarks on other representative hardware. ## Work pattern -1. Inspect enough code and prior results to form one specific hypothesis. +1. Read the complete experiment journal, then inspect enough code and prior + results to form one specific hypothesis. 2. Make the smallest clean change that tests it. 3. Run `cargo fmt --all` after Rust edits. 4. Run a focused check or unit test if useful. Do not run the full libheif suite; @@ -81,5 +86,6 @@ promotion includes benchmarks on other representative hardware. confirmation on the pinned full HEIC/HEIF hook corpus. 5. Return after this single attempt. In the final response, put a concise one-line experiment description first, followed by the hypothesis and any - relevant caveat. Do not claim the change is faster or correct; the trusted - controller decides that. + relevant caveat. If the idea is related to a journaled attempt, explicitly + state why this version is materially different. Do not claim the change is + faster or correct; the trusted controller decides that. diff --git a/scripts/autoresearch.sh b/scripts/autoresearch.sh index 88d4eb7..1f95474 100755 --- a/scripts/autoresearch.sh +++ b/scripts/autoresearch.sh @@ -8,6 +8,8 @@ STATE_DIR="${HEIC_AUTORESEARCH_STATE_DIR:-$DEFAULT_STATE_BASE/$REPO_ID}" STATE_FILE="$STATE_DIR/state.tsv" RESULTS_FILE="$STATE_DIR/results.tsv" STOP_FILE="$STATE_DIR/STOP" +JOURNAL_FILE="${STATE_DIR}.experiments.md" +JOURNAL_MIRROR="$ROOT_DIR/.heic-autoresearch/experiments.md" BENCHMARK_SOURCE="$ROOT_DIR/autoresearch/benchmark.rs" BENCHMARK_CORPUS="$ROOT_DIR/autoresearch/benchmark-corpus.txt" CONFIRMATION_CORPUS="$STATE_DIR/confirmation-corpus.txt" @@ -40,6 +42,9 @@ Run options: The trusted state directory defaults to: ~/.cache/heic-decoder-autoresearch// + +The durable experiment journal is mirrored to: + .heic-autoresearch/experiments.md EOF } @@ -125,6 +130,25 @@ ensure_external_state_dir() { esac } +mirror_journal() { + [[ -f "$JOURNAL_FILE" ]] || return 0 + mkdir -p "$(dirname "$JOURNAL_MIRROR")" + cp "$JOURNAL_FILE" "$JOURNAL_MIRROR" +} + +initialize_journal() { + if [[ ! -f "$JOURNAL_FILE" ]]; then + mkdir -p "$(dirname "$JOURNAL_FILE")" + { + printf '# HEIC decoder autoresearch experiment journal\n\n' + printf 'This is the controller-owned history of accepted and rejected experiments. ' + printf 'The live repository mirror is `.heic-autoresearch/experiments.md`; ' + printf 'optimization agents may read it but must not edit it.\n' + } > "$JOURNAL_FILE" + fi + mirror_journal +} + require_clean_worktree() { if [[ -n "$(git -C "$ROOT_DIR" status --porcelain=v1 --untracked-files=all)" ]]; then git -C "$ROOT_DIR" status --short >&2 @@ -449,6 +473,115 @@ append_result() { "$cumulative" "$status" "$(sanitize_field "$description")" >> "$RESULTS_FILE" } +journal_learning() { + local status="$1" reason="$2" primary_speedup="$3" confirmation_speedup="$4" + case "$status" in + accepted) + printf 'The candidate cleared both production image-hook speed gates and all promotion checks. ' + printf 'It is preserved in the recorded commit and becomes the next champion.' + ;; + baseline) + printf 'This entry establishes a measurement baseline; it is not an optimization attempt.' + ;; + rejected) + case "$reason" in + *"primary hook improvement"*) + printf 'The implementation measured %s on the primary production image-hook benchmark, ' "x${primary_speedup}" + printf 'which did not clear the controller\x27s required latency reduction. ' + printf 'Do not retry this implementation unchanged; revisit the idea only with a materially different mechanism or evidence that it affects more of the measured path.' + ;; + *"full-corpus hook confirmation"*) + printf 'The primary benchmark cleared its gate, but the full-corpus confirmation measured %s and did not clear its gate. ' "x${confirmation_speedup}" + printf 'The gain was not broad or stable enough across the pinned corpus; do not repeat the same implementation unchanged.' + ;; + *"correctness failed"*) + printf 'The candidate was fast enough to promote but failed the full pixel-exact oracle. ' + printf 'Any follow-up must first explain and fix the recorded correctness mismatch; speed alone cannot rescue this implementation.' + ;; + *) + printf 'The controller rejected this implementation because %s. ' "$reason" + printf 'Consult the attempt artifacts before revisiting it, and do not retry it unchanged without directly addressing that failure.' + ;; + esac + ;; + no_change) + printf 'The agent produced no evaluable source change, so no performance factor exists. ' + printf 'A follow-up needs a concrete implementation rather than repeating the same exploration.' + ;; + crash) + printf 'The agent or evaluator exited before a trustworthy performance result was available. ' + printf 'Inspect the attempt log and address that failure before retrying the underlying idea.' + ;; + esac +} + +append_journal_entry() { + local attempt="$1" status="$2" description="$3" reason="$4" commit="$5" + local primary_score="$6" primary_speedup="$7" + local confirmation_score="$8" confirmation_speedup="$9" cumulative="${10}" + local patch="${11:-}" timestamp agent_report + timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + agent_report="$STATE_DIR/attempts/$attempt/agent-last.txt" + initialize_journal + + { + printf '\n## %s — attempt %s — %s\n\n' "$timestamp" "$attempt" "$status" + printf -- '- Experiment: %s\n' "$(sanitize_field "$description")" + printf -- '- Primary production-hook speed factor: ' + if [[ "$primary_speedup" == "-" ]]; then + printf 'not measured\n' + else + printf '`x%s` (candidate `%s ms`)\n' "$primary_speedup" "$primary_score" + fi + printf -- '- Full-corpus production-hook speed factor: ' + if [[ "$confirmation_speedup" == "-" ]]; then + printf 'not measured\n' + else + printf '`x%s` (candidate `%s ms`)\n' "$confirmation_speedup" "$confirmation_score" + fi + printf -- '- Cumulative speed factor for this baseline: `x%s`\n' "$cumulative" + [[ "$commit" == "-" ]] || printf -- '- Commit: `%s`\n' "$commit" + [[ -z "$reason" ]] || printf -- '- Controller result: %s\n' "$reason" + if [[ -n "$patch" ]]; then + printf -- '- Rejected patch: `rejected/%s.diff` in this run\x27s trusted-state directory or archive\n' "$attempt" + fi + printf '\n### Learning\n\n' + journal_learning "$status" "$reason" "$primary_speedup" "$confirmation_speedup" + printf '\n' + if [[ -s "$agent_report" ]]; then + printf '\n### Agent report\n\n' + while IFS= read -r line || [[ -n "$line" ]]; do + printf '> %s\n' "$line" + done < "$agent_report" + fi + } >> "$JOURNAL_FILE" + mirror_journal +} + +record_result() { + local attempt="$1" commit="$2" primary_score="$3" primary_speedup="$4" + local confirmation_score="$5" confirmation_speedup="$6" cumulative="$7" + local ledger_status="$8" description="$9" reason="${10:-}" patch="${11:-}" + local journal_status ledger_description + ledger_description="$description" + [[ -z "$reason" ]] || ledger_description="$reason; $description" + case "$ledger_status" in + keep) + if [[ "$attempt" == "baseline" ]]; then journal_status=baseline; else journal_status=accepted; fi + ;; + discard) journal_status=rejected ;; + no_change) journal_status=no_change ;; + crash) journal_status=crash ;; + *) journal_status="$ledger_status" ;; + esac + append_result "$attempt" "$commit" "$primary_score" "$primary_speedup" \ + "$confirmation_score" "$confirmation_speedup" "$cumulative" \ + "$ledger_status" "$ledger_description" + append_journal_entry "$attempt" "$journal_status" "$description" "$reason" "$commit" \ + "$primary_score" "$primary_speedup" "$confirmation_score" \ + "$confirmation_speedup" "$cumulative" "$patch" +} + reject_candidate() { local attempt="$1" description="$2" reason="$3" local primary_score="${4:--}" primary_speedup="${5:--}" @@ -457,8 +590,9 @@ reject_candidate() { collect_changed_files patch="$(archive_candidate_patch "$attempt")" cumulative="$(get_state cumulative_speedup)" - append_result "$attempt" - "$primary_score" "$primary_speedup" \ - "$confirmation_score" "$confirmation_speedup" "$cumulative" discard "$reason; $description" + record_result "$attempt" - "$primary_score" "$primary_speedup" \ + "$confirmation_score" "$confirmation_speedup" "$cumulative" discard \ + "$description" "$reason" "$patch" discard_candidate_changes log discard "$reason (patch: $patch)" return 2 @@ -547,6 +681,7 @@ evaluate_candidate() { local primary_baseline_score primary_candidate_score primary_speedup local confirmation_candidate_score confirmation_speedup mkdir -p "$attempt_dir" + initialize_journal awk -v improvement="$min_improvement" \ 'BEGIN {exit(improvement >= 0 && improvement < 1 ? 0 : 1)}' \ @@ -565,7 +700,8 @@ evaluate_candidate() { changes_are_allowed || change_status=$? if [[ "$change_status" -ne 0 ]]; then if [[ "$change_status" -eq 1 ]]; then - append_result "$attempt" - - - - - "$(get_state cumulative_speedup)" no_change "$description" + record_result "$attempt" - - - - - "$(get_state cumulative_speedup)" \ + no_change "$description" "agent made no source changes" log discard "Agent made no source changes." return 2 fi @@ -673,7 +809,7 @@ evaluate_candidate() { set_state champion_score_ms "$primary_candidate_score" set_state champion_confirmation_score_ms "$confirmation_candidate_score" set_state cumulative_speedup "$cumulative" - append_result "$attempt" "$(short_commit "$commit")" \ + record_result "$attempt" "$(short_commit "$commit")" \ "$primary_candidate_score" "$primary_speedup" \ "$confirmation_candidate_score" "$confirmation_speedup" \ "$cumulative" keep "$description" @@ -694,6 +830,7 @@ cmd_setup() { require_cmd shasum require_cmd sort ensure_external_state_dir + initialize_journal require_clean_worktree load_benchmark_files resolve_setup_paths @@ -751,7 +888,7 @@ cmd_setup() { set_state champion_score_ms "$score" set_state initial_confirmation_score_ms "$confirmation_score" set_state champion_confirmation_score_ms "$confirmation_score" - append_result baseline "$(short_commit "$(current_commit)")" \ + record_result baseline "$(short_commit "$(current_commit)")" \ "$score" 1.000000 "$confirmation_score" 1.000000 1.000000 keep baseline log setup "Ready on $(current_branch) at $(short_commit "$(current_commit)"); primary=${score}ms confirmation=${confirmation_score}ms" log setup "Trusted state: $STATE_DIR" @@ -763,14 +900,19 @@ first_nonempty_line() { run_agent_attempt() { local attempt="$1" model="$2" prompt_file="$3" output_file="$4" log_file="$5" - local champion history + local champion history journal_excerpt + initialize_journal champion="$(short_commit "$(get_state champion_commit)")" history="$(tail -n 31 "$RESULTS_FILE")" + journal_excerpt="$(tail -n 500 "$JOURNAL_FILE")" { printf 'Read and follow autoresearch/program.md exactly.\n\n' printf 'This is experiment attempt %s. The current champion is %s.\n' "$attempt" "$champion" printf 'The trusted controller will evaluate after you return. Do not commit.\n\n' + printf 'The complete controller-owned experiment journal is mirrored at ' + printf '`.heic-autoresearch/experiments.md`. Read it completely before choosing an idea.\n\n' printf 'Recent experiment ledger (TSV):\n%s\n' "$history" + printf '\nRecent journal excerpt (Markdown):\n%s\n' "$journal_excerpt" } > "$prompt_file" local args=(exec --ephemeral --color never --sandbox workspace-write --cd "$ROOT_DIR" --output-last-message "$output_file") @@ -796,6 +938,7 @@ cmd_run() { require_cmd codex ensure_external_state_dir + initialize_journal require_champion_head require_clean_worktree check_environment_matches_setup @@ -833,6 +976,9 @@ cmd_run() { "$attempt_dir/agent-last.txt" "$attempt_dir/agent.log" agent_status=$? set -e + # The repository copy is deliberately untrusted and ignored. Replace it + # after every agent turn from the controller-owned external journal. + mirror_journal description="$(first_nonempty_line "$attempt_dir/agent-last.txt")" description="${description:-agent attempt $attempt}" collect_changed_files @@ -840,8 +986,8 @@ cmd_run() { if [[ ${#CHANGED_FILES[@]} -gt 0 ]]; then reject_candidate "$attempt" "$description" "Codex exited with status $agent_status" || true else - append_result "$attempt" - - - - - "$(get_state cumulative_speedup)" crash \ - "Codex exited with status $agent_status; $description" + record_result "$attempt" - - - - - "$(get_state cumulative_speedup)" \ + crash "$description" "Codex exited with status $agent_status" fi experiments=$((experiments + 1)) continue @@ -871,6 +1017,7 @@ cmd_evaluate() { esac done ensure_external_state_dir + initialize_journal require_champion_head check_environment_matches_setup verify_asset_integrity @@ -905,6 +1052,7 @@ cmd_bench() { cmd_status() { ensure_external_state_dir [[ -f "$STATE_FILE" ]] || die "No baseline has been set up." + initialize_journal printf 'branch: %s\n' "$(get_state branch)" printf 'champion: %s\n' "$(short_commit "$(get_state champion_commit)")" printf 'initial_score_ms: %s\n' "$(get_state initial_score_ms)" @@ -914,6 +1062,7 @@ cmd_status() { printf 'confirmation_files: %s\n' "$(get_state confirmation_file_count)" printf 'cumulative_speedup: %sx\n' "$(get_state cumulative_speedup)" printf 'trusted_state: %s\n' "$STATE_DIR" + printf 'experiment_journal: %s\n' "$JOURNAL_MIRROR" printf '\nRecent results:\n' tail -n 11 "$RESULTS_FILE" } From c2bfbb739744e0a182957b81e30221e9e2a5568c Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 10:53:50 +0530 Subject: [PATCH 08/28] perf(autoresearch): Experiment: Replace per-pixel grid transform interpretation with pre --- src/lib.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 6f60f18..3d5ea1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8285,6 +8285,12 @@ fn decode_avif_bytes_to_rgba_layout( trait RgbaSampleOutput { fn sample_len(&self) -> usize; fn write_sample(&mut self, index: usize, sample: T); + + fn write_samples(&mut self, index: usize, samples: &[T]) { + for (offset, &sample) in samples.iter().enumerate() { + self.write_sample(index + offset, sample); + } + } } #[cfg(feature = "image-integration")] @@ -8299,6 +8305,10 @@ impl RgbaSampleOutput for SliceRgbaOutput<'_, T> { fn write_sample(&mut self, index: usize, sample: T) { self.0[index] = sample; } + + fn write_samples(&mut self, index: usize, samples: &[T]) { + self.0[index..index + samples.len()].copy_from_slice(samples); + } } #[cfg(feature = "image-integration")] @@ -8383,6 +8393,26 @@ fn decode_primary_heic_grid_to_rgba_output>, auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, scale_alpha: fn(u16, u8) -> T, @@ -8550,6 +8582,21 @@ fn paste_heic_grid_tiles_to_transformed_rgba_slice>( + tile_pixels: &[T], + tile_width: usize, + tile_height: usize, + output: &mut O, + source_width: usize, + source_height: usize, + destination_width: usize, + x_origin: usize, + y_origin: usize, + orientation: Option<&RgbaOrientationTransform>, +) -> Result<(), DecodeError> { + if x_origin >= source_width || y_origin >= source_height { + return Ok(()); + } + + let copy_width = tile_width.min(source_width - x_origin); + let copy_height = tile_height.min(source_height - y_origin); + let copy_samples = copy_width * 4; + + if orientation.is_none() { + debug_assert_eq!(source_width, destination_width); + for tile_y in 0..copy_height { + let source_sample = tile_y * tile_width * 4; + let destination_sample = ((y_origin + tile_y) * destination_width + x_origin) * 4; + output.write_samples( + destination_sample, + &tile_pixels[source_sample..source_sample + copy_samples], + ); + } + return Ok(()); + } + + let orientation = orientation.expect("orientation was checked above"); + debug_assert_eq!(destination_width, orientation.destination_width_usize); + let destination_step = (orientation.destination_y_from_source_x * destination_width as i64 + + orientation.destination_x_from_source_x) + * 4; + + for tile_y in 0..copy_height { + let source_y = y_origin + tile_y; + let source_sample = tile_y * tile_width * 4; + let (destination_x, destination_y) = orientation.map_source_pixel(x_origin, source_y)?; + let mut destination_sample = + ((destination_y * destination_width + destination_x) * 4) as i64; + + if destination_step == 4 { + output.write_samples( + destination_sample as usize, + &tile_pixels[source_sample..source_sample + copy_samples], + ); + continue; + } + + for tile_x in 0..copy_width { + let source_sample = source_sample + tile_x * 4; + debug_assert!(destination_sample >= 0); + let destination_sample_usize = destination_sample as usize; + debug_assert!(destination_sample_usize + 4 <= output.sample_len()); + output.write_samples( + destination_sample_usize, + &tile_pixels[source_sample..source_sample + 4], + ); + destination_sample += destination_step; + } + } + + Ok(()) +} + #[cfg(feature = "image-integration")] fn decoded_heic_to_rgba8_slice( decoded: DecodedHeicImage, From 4d5e861311024d4a7eaaaf488fee1fd7564a8e66 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 14:40:45 +0530 Subject: [PATCH 09/28] perf(autoresearch): Re-evaluate stable packed CABAC transitions and batched renormalizat --- src/heic-decoder/hevc/cabac.rs | 111 +++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 47 deletions(-) diff --git a/src/heic-decoder/hevc/cabac.rs b/src/heic-decoder/hevc/cabac.rs index edd65c3..2d073fc 100644 --- a/src/heic-decoder/hevc/cabac.rs +++ b/src/heic-decoder/hevc/cabac.rs @@ -75,33 +75,55 @@ static LPS_TABLE: [[u8; 4]; 64] = [ [2, 2, 2, 2], ]; -/// Renormalization table -#[allow(dead_code)] -static RENORM_TABLE: [u8; 32] = [ - 6, 5, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -]; - /// State transition for MPS -static STATE_TRANS_MPS: [u8; 64] = [ +const STATE_TRANS_MPS: [u8; 64] = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 62, 63, ]; /// State transition for LPS -static STATE_TRANS_LPS: [u8; 64] = [ +const STATE_TRANS_LPS: [u8; 64] = [ 0, 0, 1, 2, 2, 4, 4, 5, 6, 7, 8, 9, 9, 11, 11, 12, 13, 13, 15, 15, 16, 16, 18, 18, 19, 19, 21, 21, 22, 22, 23, 24, 24, 25, 26, 26, 27, 27, 28, 29, 29, 30, 30, 30, 31, 32, 32, 33, 33, 33, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 63, ]; +const fn build_packed_mps_transitions() -> [u8; 128] { + let mut transitions = [0; 128]; + let mut packed = 0; + while packed < transitions.len() { + let state = packed >> 1; + let mps = packed & 1; + transitions[packed] = (STATE_TRANS_MPS[state] << 1) | mps as u8; + packed += 1; + } + transitions +} + +const fn build_packed_lps_transitions() -> [u8; 128] { + let mut transitions = [0; 128]; + let mut packed = 0; + while packed < transitions.len() { + let state = packed >> 1; + let mut mps = packed & 1; + if state == 0 { + mps ^= 1; + } + transitions[packed] = (STATE_TRANS_LPS[state] << 1) | mps as u8; + packed += 1; + } + transitions +} + +const PACKED_STATE_TRANS_MPS: [u8; 128] = build_packed_mps_transitions(); +const PACKED_STATE_TRANS_LPS: [u8; 128] = build_packed_lps_transitions(); + /// CABAC context model #[derive(Clone, Copy)] pub struct ContextModel { - /// State index (0-63) - state: u8, - /// Most probable symbol (0 or 1) - mps: u8, + /// State index (0-63) in bits 1-6 and the MPS value in bit 0. + state_mps: u8, } #[allow(dead_code)] @@ -122,12 +144,14 @@ impl ContextModel { ((63 - init_state) as u8, 0) }; - Self { state, mps } + Self { + state_mps: (state << 1) | mps, + } } /// Get the current context state and MPS pub fn get_state(&self) -> (u8, u8) { - (self.state, self.mps) + (self.state_mps >> 1, self.state_mps & 1) } /// Initialize context for a given slice QP @@ -140,11 +164,9 @@ impl ContextModel { let init_state = init_state.clamp(1, 126); if init_state >= 64 { - self.state = (init_state - 64) as u8; - self.mps = 1; + self.state_mps = ((init_state - 64) as u8) << 1 | 1; } else { - self.state = (63 - init_state) as u8; - self.mps = 0; + self.state_mps = ((63 - init_state) as u8) << 1; } } } @@ -248,27 +270,14 @@ impl<'a> CabacDecoder<'a> { Ok(()) } - /// Read a single bit from the bitstream (for regular context decoding) - fn read_bit(&mut self) { - self.value <<= 1; - self.bits_needed += 1; - - if self.bits_needed >= 0 { - if self.byte_pos < self.data.len() { - self.bits_needed = -8; - self.value |= self.data[self.byte_pos] as u32; - self.byte_pos += 1; - } else { - self.bits_needed = -8; - } - } - } - /// Decode a single bin using context model #[inline] pub fn decode_bin(&mut self, ctx: &mut ContextModel) -> u8 { + let packed_state = ctx.state_mps as usize; + let state = packed_state >> 1; + let mps = (packed_state & 1) as u8; let q_range_idx = (self.range >> 6) & 3; - let lps_range = LPS_TABLE[ctx.state as usize][q_range_idx as usize] as u32; + let lps_range = LPS_TABLE[state][q_range_idx as usize] as u32; self.range -= lps_range; @@ -278,18 +287,14 @@ impl<'a> CabacDecoder<'a> { let bin_val; if self.value < scaled_range { // MPS path - bin_val = ctx.mps; - ctx.state = STATE_TRANS_MPS[ctx.state as usize]; + bin_val = mps; + ctx.state_mps = PACKED_STATE_TRANS_MPS[packed_state]; } else { // LPS path - bin_val = 1 - ctx.mps; + bin_val = mps ^ 1; self.value -= scaled_range; self.range = lps_range; - - if ctx.state == 0 { - ctx.mps = 1 - ctx.mps; - } - ctx.state = STATE_TRANS_LPS[ctx.state as usize]; + ctx.state_mps = PACKED_STATE_TRANS_LPS[packed_state]; } // Renormalize @@ -368,10 +373,22 @@ impl<'a> CabacDecoder<'a> { /// Renormalize the decoder state #[inline] fn renormalize(&mut self) { - while self.range < 256 { - self.range <<= 1; - // Shift value and read more bits - self.read_bit(); + if self.range < 256 { + // `range` is non-zero and at most seven shifts are needed. Since + // `bits_needed` starts in -8..=-1, the batch crosses at most one + // byte boundary. When it does, place the new byte exactly where + // the remaining single-bit shifts would have moved it. + let shift = self.range.leading_zeros() - 23; + self.range <<= shift; + self.value <<= shift; + self.bits_needed += shift as i32; + if self.bits_needed >= 0 { + if self.byte_pos < self.data.len() { + self.value |= (self.data[self.byte_pos] as u32) << self.bits_needed; + self.byte_pos += 1; + } + self.bits_needed -= 8; + } } // Invariant: after renormalization, range >= 256 debug_assert!(self.range >= 256, "range {} < 256 after renorm", self.range); From 2d714fa2cf98c18cd97d58a00284936259aa0766 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 14:56:58 +0530 Subject: [PATCH 10/28] perf(autoresearch): Recover dense residual coefficient bookkeeping after fixing controll --- src/heic-decoder/hevc/residual.rs | 128 +++++++++++++----------------- 1 file changed, 54 insertions(+), 74 deletions(-) diff --git a/src/heic-decoder/hevc/residual.rs b/src/heic-decoder/hevc/residual.rs index 6030c67..50a59ed 100644 --- a/src/heic-decoder/hevc/residual.rs +++ b/src/heic-decoder/hevc/residual.rs @@ -390,8 +390,12 @@ pub fn decode_residual( 15 }; - let mut coeff_values = [0i16; 16]; - let mut coeff_flags = 0u16; + // Significance is decoded in reverse scan order. Keep the significant + // positions and their levels dense in that same order so every later + // stage visits only coefficients that exist. + let mut coeff_positions = [0u8; 16]; + let mut coeff_values = [1i16; 16]; + let mut num_coeffs = 0usize; let mut can_infer_dc = infer_sb_dc_sig; // Determine the last position to check @@ -399,8 +403,8 @@ pub fn decode_residual( // For other sub-blocks: start from position 15 let last_coeff = if sb_idx == last_sb_idx { // Set the known last significant coefficient (no need to decode sig_coeff_flag) - coeff_flags |= 1 << start_pos; - coeff_values[start_pos as usize] = 1; + coeff_positions[0] = start_pos; + num_coeffs = 1; can_infer_dc = false; // Can't infer DC if we have other coeffs // Then check positions from start_pos-1 down to 1 start_pos.saturating_sub(1) @@ -435,8 +439,8 @@ pub fn decode_residual( ); } if sig { - coeff_flags |= 1 << n; - coeff_values[n as usize] = 1; + coeff_positions[num_coeffs] = n; + num_coeffs += 1; can_infer_dc = false; // Found a coefficient, can't infer DC } } @@ -446,8 +450,8 @@ pub fn decode_residual( // DC is inferred to be significant (otherwise the sub-block would be all zeros) if start_pos > 0 { if can_infer_dc { - coeff_flags |= 1; - coeff_values[0] = 1; + coeff_positions[num_coeffs] = 0; + num_coeffs += 1; if rc_trace { rc_eprintln!("{rcp}_SIG n=0 DC_INFERRED"); } @@ -477,13 +481,12 @@ pub fn decode_residual( ); } if sig { - coeff_flags |= 1; - coeff_values[0] = 1; + coeff_positions[num_coeffs] = 0; + num_coeffs += 1; } } } - let num_coeffs = coeff_flags.count_ones() as u8; if num_coeffs == 0 { continue; } @@ -513,28 +516,23 @@ pub fn decode_residual( let mut last_greater1_flag = false; // Decode greater-1 flags (up to 8) - let mut first_g1_idx: Option = None; - let max_g1 = (num_coeffs as usize).min(8); - let mut g1_count = 0; + let mut first_g1_idx: Option = None; + let max_g1 = num_coeffs.min(8); - // Track which coefficients need remaining level decoding + // Track dense coefficient indices that need remaining level decoding. let mut needs_remaining = 0u16; - let mut significant = coeff_flags; - while significant != 0 { - let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; - significant &= !(1 << n); - - if g1_count >= max_g1 { + for (coeff_idx, coeff_value) in coeff_values[..num_coeffs].iter_mut().enumerate() { + if coeff_idx >= max_g1 { // Beyond first 8: base=1, always needs remaining for values > 1 - needs_remaining |= 1 << n; + needs_remaining |= 1u16 << coeff_idx; continue; } // Update greater1Ctx BEFORE decoding, using PREVIOUS flag // (skip for first coefficient in subblock) // Per libde265: greater1Ctx increments without clamping (clamped in ctx calc) - if g1_count > 0 && greater1_ctx > 0 { + if coeff_idx > 0 && greater1_ctx > 0 { if last_greater1_flag { greater1_ctx = 0; } else { @@ -553,7 +551,7 @@ pub fn decode_residual( + (greater1_ctx as usize).min(3); rc_eprintln!( "{rcp}_G1 c={} ctxSet={} g1ctx={} fullCtx={} val={} range={} byte={}", - g1_count, + coeff_idx, ctx_set, greater1_ctx, full_ctx, @@ -565,16 +563,15 @@ pub fn decode_residual( last_greater1_flag = g1; if g1 { - coeff_values[n as usize] = 2; + *coeff_value = 2; this_subblock_had_gt1 = true; // Track for next subblock's ctx_set if first_g1_idx.is_none() { - first_g1_idx = Some(n); + first_g1_idx = Some(coeff_idx); } else { // Non-first g1=1: base=2, needs remaining for values > 2 - needs_remaining |= 1 << n; + needs_remaining |= 1u16 << coeff_idx; } } - g1_count += 1; } // Decode greater-2 flag (only for first coefficient with greater-1) @@ -593,15 +590,15 @@ pub fn decode_residual( ); } if g2 { - coeff_values[g1_idx as usize] = 3; - needs_remaining |= 1 << g1_idx; + coeff_values[g1_idx] = 3; + needs_remaining |= 1u16 << g1_idx; } } - // The flags are non-zero here, so bit scans directly recover the - // first and last significant positions in scan order. - let first_sig_pos = coeff_flags.trailing_zeros() as u8; - let last_sig_pos = (u16::BITS - 1 - coeff_flags.leading_zeros()) as u8; + // Dense positions are ordered from the last to the first coefficient + // in scan order. + let first_sig_pos = coeff_positions[num_coeffs - 1]; + let last_sig_pos = coeff_positions[0]; // Determine if sign is hidden for this sub-block // Per H.265 9.3.4.3: sign is hidden if: @@ -624,32 +621,20 @@ pub fn decode_residual( // // This matches the H.265 spec and libde265: the FIRST coefficient in // scanning order (lowest scan position) has its sign hidden. - let mut coeff_signs = 0u16; - let mut significant = coeff_flags; - while significant != 0 { - let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; - significant &= !(1 << n); - if (n != first_sig_pos || !sign_hidden) && cabac.decode_bypass() != 0 { - coeff_signs |= 1 << n; - } - } + let num_signs = num_coeffs - usize::from(sign_hidden); + let coeff_signs = cabac.decode_bypass_bits(num_signs as u8); if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); let mut signs = [0u8; 16]; - let mut significant = coeff_flags; - let mut count = 0; - while significant != 0 { - let n = (u16::BITS - 1 - significant.leading_zeros()) as u8; - significant &= !(1 << n); - signs[count] = u8::from(coeff_signs & (1 << n) != 0); - count += 1; + for (coeff_idx, sign) in signs[..num_signs].iter_mut().enumerate() { + *sign = ((coeff_signs >> (num_signs - 1 - coeff_idx)) & 1) as u8; } rc_eprintln!( "{rcp}_SIGNS n_sig={} hidden={} signs={:?} range={} byte={}", num_coeffs, sign_hidden, - &signs[..num_coeffs as usize], + &signs[..num_coeffs], range, byte_pos ); @@ -662,16 +647,16 @@ pub fn decode_residual( // Decode remaining levels for coefficients that need it let mut remaining_flags = needs_remaining; while remaining_flags != 0 { - let n = (u16::BITS - 1 - remaining_flags.leading_zeros()) as u8; - remaining_flags &= !(1 << n); - let base = coeff_values[n as usize]; + let coeff_idx = remaining_flags.trailing_zeros() as usize; + remaining_flags &= remaining_flags - 1; + let base = coeff_values[coeff_idx]; let (remaining, new_rice) = decode_coeff_abs_level_remaining(cabac, rice_param, base)?; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); rc_eprintln!( "{rcp}_REM n={} base={} rice={} rem={} final={} range={} byte={}", - n, + coeff_positions[coeff_idx], base, rice_param, remaining, @@ -681,46 +666,41 @@ pub fn decode_residual( ); } rice_param = new_rice; - coeff_values[n as usize] = base + remaining; + coeff_values[coeff_idx] = base + remaining; } // Apply signs and compute sum for parity inference // At this point coeff_values[] are all positive (absolute values) let mut sum_abs_level = 0i32; - let mut significant = coeff_flags; - while significant != 0 { - let pos = (u16::BITS - 1 - significant.leading_zeros()) as u8; - significant &= !(1 << pos); - if coeff_signs & (1 << pos) != 0 { - coeff_values[pos as usize] = -coeff_values[pos as usize]; + for (coeff_idx, coeff_value) in coeff_values[..num_coeffs].iter_mut().enumerate() { + if coeff_idx < num_signs && coeff_signs & (1 << (num_signs - 1 - coeff_idx)) != 0 { + *coeff_value = -*coeff_value; } - sum_abs_level += coeff_values[pos as usize] as i32; + sum_abs_level += *coeff_value as i32; // Infer the hidden sign at the first coefficient in scan order. - if pos == first_sig_pos && sign_hidden && (sum_abs_level & 1) != 0 { - coeff_values[pos as usize] = -coeff_values[pos as usize]; + if coeff_idx + 1 == num_coeffs && sign_hidden && (sum_abs_level & 1) != 0 { + *coeff_value = -*coeff_value; } } // Store coefficients in buffer - let mut significant = coeff_flags; - while significant != 0 { - let n = significant.trailing_zeros() as usize; - significant &= significant - 1; + let size = size as usize; + let sb_offset = sb_y as usize * 4 * size + sb_x as usize * 4; + for coeff_idx in 0..num_coeffs { + let n = coeff_positions[coeff_idx] as usize; let (px, py) = scan_pos[n]; - let x = sb_x as usize * 4 + px as usize; - let y = sb_y as usize * 4 + py as usize; - - buffer.set(x, y, coeff_values[n]); + buffer.coeffs[sb_offset + py as usize * size + px as usize] = coeff_values[coeff_idx]; // Track large coefficients (indicates CABAC desync) #[cfg(feature = "decoder-tracing")] - if coeff_values[n].abs() > 500 { + if coeff_values[coeff_idx].abs() > 500 { let (byte_pos, _, _) = cabac.get_position(); debug::track_large_coeff(byte_pos); } } + buffer.num_nonzero += num_coeffs as u16; // Update prev_subblock_had_gt1 for the next subblock (lower scan index) prev_subblock_had_gt1 = this_subblock_had_gt1; From 181792f8651ca9434fab5bc284d3766340ef573c Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 15:04:50 +0530 Subject: [PATCH 11/28] perf(autoresearch): Experiment: Decode residuals into a persistent sparse workspace and --- src/heic-decoder/hevc/ctu.rs | 25 ++++++++-- src/heic-decoder/hevc/residual.rs | 77 ++++--------------------------- 2 files changed, 30 insertions(+), 72 deletions(-) diff --git a/src/heic-decoder/hevc/ctu.rs b/src/heic-decoder/hevc/ctu.rs index 4090ac7..48b44f3 100644 --- a/src/heic-decoder/hevc/ctu.rs +++ b/src/heic-decoder/hevc/ctu.rs @@ -153,6 +153,10 @@ pub struct SliceContext<'a> { pub sao_map: SaoMap, /// Reusable residual buffer (inverse transform writes all elements, no re-zeroing needed) residual_buf: [i16; 1024], + /// Reusable sparse coefficient workspace. Only indices recorded in + /// `touched_coeffs` may be non-zero between residual decodes. + coeff_buf: [i16; 1024], + touched_coeffs: [u16; 1024], /// Reusable scaling matrix buffer scaling_buf: [u8; 1024], } @@ -291,6 +295,8 @@ impl<'a> SliceContext<'a> { current_qg_y: -1, sao_map: SaoMap::new(sps.pic_width_in_ctbs(), sps.pic_height_in_ctbs()), residual_buf: [0i16; 1024], + coeff_buf: [0i16; 1024], + touched_coeffs: [0u16; 1024], scaling_buf: [16u8; 1024], }) } @@ -1486,7 +1492,7 @@ impl<'a> SliceContext<'a> { frame: &mut DecodedFrame, ) -> Result<()> { // Decode coefficients via CABAC - let (mut coeff_buf, transform_skip) = residual::decode_residual( + let (num_nonzero, transform_skip) = residual::decode_residual( &mut self.cabac, &mut self.ctx, log2_size, @@ -1497,9 +1503,11 @@ impl<'a> SliceContext<'a> { self.pps.transform_skip_enabled_flag, x0, y0, + &mut self.coeff_buf, + &mut self.touched_coeffs, )?; - if coeff_buf.is_zero() { + if num_nonzero == 0 { return Ok(()); } @@ -1517,13 +1525,16 @@ impl<'a> SliceContext<'a> { // residual — no scaling, no inverse transform. if self.cu_transquant_bypass_flag { let residual = &mut self.residual_buf; - residual[..num_coeffs].copy_from_slice(&coeff_buf.coeffs[..num_coeffs]); + residual[..num_coeffs].copy_from_slice(&self.coeff_buf[..num_coeffs]); + for &idx in &self.touched_coeffs[..num_nonzero] { + self.coeff_buf[idx as usize] = 0; + } self.add_residual_to_plane(x0, y0, log2_size, c_idx, bit_depth, frame); return Ok(()); } // Dequantize coefficients in-place - let coeffs = &mut coeff_buf.coeffs; + let coeffs = &mut self.coeff_buf; let dequant_params = transform::DequantParams { qp, @@ -1590,6 +1601,12 @@ impl<'a> SliceContext<'a> { transform::inverse_transform(coeffs, residual, size, bit_depth, is_intra_4x4_luma); } + // Dequantization only changes non-zero inputs, so the dense list from + // residual parsing is sufficient to restore the workspace invariant. + for &idx in &self.touched_coeffs[..num_nonzero] { + coeffs[idx as usize] = 0; + } + self.add_residual_to_plane(x0, y0, log2_size, c_idx, bit_depth, frame); Ok(()) diff --git a/src/heic-decoder/hevc/residual.rs b/src/heic-decoder/hevc/residual.rs index 50a59ed..9b034b3 100644 --- a/src/heic-decoder/hevc/residual.rs +++ b/src/heic-decoder/hevc/residual.rs @@ -133,68 +133,6 @@ pub fn get_scan_4x4(order: ScanOrder) -> &'static [(u8, u8); 16] { } } -/// Coefficient buffer for a transform unit -#[derive(Clone)] -pub struct CoeffBuffer { - /// Coefficients for this TU - pub coeffs: [i16; MAX_COEFF], - /// Transform size (log2) - pub log2_size: u8, - /// Number of non-zero coefficients - pub num_nonzero: u16, -} - -impl Default for CoeffBuffer { - fn default() -> Self { - Self { - coeffs: [0; MAX_COEFF], - log2_size: 2, - num_nonzero: 0, - } - } -} - -impl CoeffBuffer { - /// Create a new coefficient buffer - #[inline] - pub fn new(log2_size: u8) -> Self { - Self { - coeffs: [0; MAX_COEFF], - log2_size, - num_nonzero: 0, - } - } - - /// Get the transform size - #[inline] - pub fn size(&self) -> usize { - 1 << self.log2_size - } - - /// Get coefficient at position - #[allow(dead_code)] - #[inline] - pub fn get(&self, x: usize, y: usize) -> i16 { - let stride = self.size(); - self.coeffs[y * stride + x] - } - - /// Set coefficient at position - #[inline] - pub fn set(&mut self, x: usize, y: usize, value: i16) { - let stride = self.size(); - self.coeffs[y * stride + x] = value; - if value != 0 { - self.num_nonzero = self.num_nonzero.saturating_add(1); - } - } - - /// Check if all coefficients are zero - pub fn is_zero(&self) -> bool { - self.num_nonzero == 0 - } -} - /// Decode residual coefficients for a transform unit /// Debug counter to identify specific TU calls #[cfg(feature = "decoder-tracing")] @@ -213,7 +151,9 @@ pub fn decode_residual( transform_skip_enabled: bool, _x0: u32, _y0: u32, -) -> Result<(CoeffBuffer, bool)> { + coeffs: &mut [i16; MAX_COEFF], + touched_coeffs: &mut [u16; MAX_COEFF], +) -> Result<(usize, bool)> { #[cfg(feature = "decoder-tracing")] DEBUG_RESIDUAL_COUNTER.fetch_add(1, core::sync::atomic::Ordering::Relaxed); @@ -240,8 +180,8 @@ pub fn decode_residual( let rc_trace = false; let rcp = "RCX"; - let mut buffer = CoeffBuffer::new(log2_size); let size = 1u32 << log2_size; + let mut num_nonzero = 0usize; // Decode transform_skip_flag (H.265 7.3.8.11) // Per spec: if transform_skip_enabled_flag && !cu_transquant_bypass_flag @@ -691,7 +631,10 @@ pub fn decode_residual( for coeff_idx in 0..num_coeffs { let n = coeff_positions[coeff_idx] as usize; let (px, py) = scan_pos[n]; - buffer.coeffs[sb_offset + py as usize * size + px as usize] = coeff_values[coeff_idx]; + let dst_idx = sb_offset + py as usize * size + px as usize; + coeffs[dst_idx] = coeff_values[coeff_idx]; + touched_coeffs[num_nonzero] = dst_idx as u16; + num_nonzero += 1; // Track large coefficients (indicates CABAC desync) #[cfg(feature = "decoder-tracing")] @@ -700,8 +643,6 @@ pub fn decode_residual( debug::track_large_coeff(byte_pos); } } - buffer.num_nonzero += num_coeffs as u16; - // Update prev_subblock_had_gt1 for the next subblock (lower scan index) prev_subblock_had_gt1 = this_subblock_had_gt1; } @@ -711,7 +652,7 @@ pub fn decode_residual( let (byte_pos, _, _) = cabac.get_position(); rc_eprintln!("{rcp}_END range={} byte={}", range, byte_pos); } - Ok((buffer, transform_skip)) + Ok((num_nonzero, transform_skip)) } /// Get sub-block scan order From 34ef18f0556ef140ff8bd6ceeafb50db042e0ab2 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 15:27:38 +0530 Subject: [PATCH 12/28] perf(autoresearch): Experiment: Decode residual subblocks directly into the persistent s --- src/heic-decoder/hevc/residual.rs | 365 ++++++++++-------------------- 1 file changed, 125 insertions(+), 240 deletions(-) diff --git a/src/heic-decoder/hevc/residual.rs b/src/heic-decoder/hevc/residual.rs index 9b034b3..e3b92ec 100644 --- a/src/heic-decoder/hevc/residual.rs +++ b/src/heic-decoder/hevc/residual.rs @@ -301,8 +301,12 @@ pub fn decode_residual( // Middle sub-blocks need coded_sub_block_flag decoded // First (i=0) and last sub-blocks are always considered coded let (sb_coded, infer_sb_dc_sig) = if sb_idx > 0 && sb_idx < last_sb_idx { - // Use proper context derivation with neighbor info - let coded = decode_coded_sub_block_flag(cabac, ctx, c_idx, csbf_neighbors)?; + // Context offset: 0-1 for luma, 2-3 for chroma. Hoist this + // directly into the sub-block loop so the hot CABAC call does not + // carry an infallible Result/helper layer. + let csbf_ctx = usize::from(csbf_neighbors != 0); + let ctx_idx = context::CODED_SUB_BLOCK_FLAG + csbf_ctx + if c_idx > 0 { 2 } else { 0 }; + let coded = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; // If sub-block is coded, we may need to infer DC later (coded, coded) } else { @@ -330,21 +334,50 @@ pub fn decode_residual( 15 }; - // Significance is decoded in reverse scan order. Keep the significant - // positions and their levels dense in that same order so every later - // stage visits only coefficients that exist. - let mut coeff_positions = [0u8; 16]; - let mut coeff_values = [1i16; 16]; - let mut num_coeffs = 0usize; + // Significance is decoded in reverse scan order. Append raster indices + // directly to the persistent sparse list in that same order; it then + // doubles as the dense level/sign worklist for this sub-block. + let subblock_start = num_nonzero; + let size = size as usize; + let sb_offset = sb_y as usize * 4 * size + sb_x as usize * 4; + let mut first_sig_pos = 0u8; + let mut last_sig_pos = 0u8; let mut can_infer_dc = infer_sb_dc_sig; + // For all transforms larger than 4x4, only the compact local-position + // contribution varies per significance bin. The component, transform, + // scan, and sub-block contributions are invariant here. + let sig_ctx_base = if log2_size == 2 { + context::SIG_COEFF_FLAG + if c_idx > 0 { 27 } else { 0 } + } else { + let mut base = context::SIG_COEFF_FLAG + if c_idx > 0 { 27 } else { 0 }; + if c_idx == 0 { + if sb_x + sb_y > 0 { + base += 3; + } + base += if log2_size == 3 { + if scan_idx == 0 { 9 } else { 15 } + } else { + 21 + }; + } else { + base += if log2_size == 3 { 9 } else { 12 }; + } + base + }; + // Determine the last position to check // For last sub-block: start from last_pos_in_sb (the known last significant coeff) // For other sub-blocks: start from position 15 let last_coeff = if sb_idx == last_sb_idx { // Set the known last significant coefficient (no need to decode sig_coeff_flag) - coeff_positions[0] = start_pos; - num_coeffs = 1; + let (px, py) = scan_pos[start_pos as usize]; + let dst_idx = sb_offset + py as usize * size + px as usize; + coeffs[dst_idx] = 1; + touched_coeffs[num_nonzero] = dst_idx as u16; + num_nonzero += 1; + first_sig_pos = start_pos; + last_sig_pos = start_pos; can_infer_dc = false; // Can't infer DC if we have other coeffs // Then check positions from start_pos-1 down to 1 start_pos.saturating_sub(1) @@ -356,17 +389,19 @@ pub fn decode_residual( // Decode significant_coeff_flags for positions last_coeff down to 1 // (DC at position 0 is handled separately for inference) for n in (1..=last_coeff).rev() { - let sig = decode_sig_coeff_flag( - cabac, ctx, c_idx, n, log2_size, scan_idx, sb_x, sb_y, prev_csbf, scan_pos, - )?; + let (x_in_sb, y_in_sb) = scan_pos[n as usize]; + let raster_pos = y_in_sb as usize * 4 + x_in_sb as usize; + let ctx_idx = if log2_size == 2 { + sig_ctx_base + CTX_IDX_MAP_4X4[raster_pos] as usize + } else { + sig_ctx_base + SIG_CTX_LOCAL[prev_csbf as usize][raster_pos] as usize + }; + let sig = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); - let (x_in_sb, y_in_sb) = scan_pos[n as usize]; let xc = sb_x * 4 + x_in_sb; let yc = sb_y * 4 + y_in_sb; - let ctx_idx = - calc_sig_coeff_flag_ctx(xc, yc, log2_size, c_idx, scan_idx, prev_csbf); rc_eprintln!( "{rcp}_SIG n={} pos=({},{}) ctx={} val={} range={} byte={}", n, @@ -379,8 +414,14 @@ pub fn decode_residual( ); } if sig { - coeff_positions[num_coeffs] = n; - num_coeffs += 1; + let dst_idx = sb_offset + y_in_sb as usize * size + x_in_sb as usize; + coeffs[dst_idx] = 1; + touched_coeffs[num_nonzero] = dst_idx as u16; + num_nonzero += 1; + if num_nonzero == subblock_start + 1 { + last_sig_pos = n; + } + first_sig_pos = n; can_infer_dc = false; // Found a coefficient, can't infer DC } } @@ -390,26 +431,29 @@ pub fn decode_residual( // DC is inferred to be significant (otherwise the sub-block would be all zeros) if start_pos > 0 { if can_infer_dc { - coeff_positions[num_coeffs] = 0; - num_coeffs += 1; + let dst_idx = sb_offset; + coeffs[dst_idx] = 1; + touched_coeffs[num_nonzero] = dst_idx as u16; + num_nonzero += 1; + if num_nonzero == subblock_start + 1 { + last_sig_pos = 0; + } + first_sig_pos = 0; if rc_trace { rc_eprintln!("{rcp}_SIG n=0 DC_INFERRED"); } } else { - let sig = decode_sig_coeff_flag( - cabac, ctx, c_idx, 0, log2_size, scan_idx, sb_x, sb_y, prev_csbf, scan_pos, - )?; + let ctx_idx = if log2_size == 2 { + sig_ctx_base + CTX_IDX_MAP_4X4[0] as usize + } else if sb_x == 0 && sb_y == 0 { + context::SIG_COEFF_FLAG + if c_idx > 0 { 27 } else { 0 } + } else { + sig_ctx_base + SIG_CTX_LOCAL[prev_csbf as usize][0] as usize + }; + let sig = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); - let ctx_idx = calc_sig_coeff_flag_ctx( - sb_x * 4, - sb_y * 4, - log2_size, - c_idx, - scan_idx, - prev_csbf, - ); rc_eprintln!( "{rcp}_SIG n=0 pos=({},{}) ctx={} val={} range={} byte={}", sb_x * 4, @@ -421,12 +465,19 @@ pub fn decode_residual( ); } if sig { - coeff_positions[num_coeffs] = 0; - num_coeffs += 1; + let dst_idx = sb_offset; + coeffs[dst_idx] = 1; + touched_coeffs[num_nonzero] = dst_idx as u16; + num_nonzero += 1; + if num_nonzero == subblock_start + 1 { + last_sig_pos = 0; + } + first_sig_pos = 0; } } } + let num_coeffs = num_nonzero - subblock_start; if num_coeffs == 0 { continue; } @@ -462,7 +513,10 @@ pub fn decode_residual( // Track dense coefficient indices that need remaining level decoding. let mut needs_remaining = 0u16; - for (coeff_idx, coeff_value) in coeff_values[..num_coeffs].iter_mut().enumerate() { + let g1_ctx_base = context::COEFF_ABS_LEVEL_GREATER1_FLAG + + if c_idx > 0 { 16 } else { 0 } + + (ctx_set as usize) * 4; + for coeff_idx in 0..num_coeffs { if coeff_idx >= max_g1 { // Beyond first 8: base=1, always needs remaining for values > 1 needs_remaining |= 1u16 << coeff_idx; @@ -481,7 +535,8 @@ pub fn decode_residual( } // Use ctx_set (captured at subblock start) for ALL greater1_flags - let g1 = decode_coeff_greater1_flag(cabac, ctx, c_idx, ctx_set, greater1_ctx)?; + let ctx_idx = g1_ctx_base + (greater1_ctx as usize).min(3); + let g1 = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); @@ -503,7 +558,8 @@ pub fn decode_residual( last_greater1_flag = g1; if g1 { - *coeff_value = 2; + let dst_idx = touched_coeffs[subblock_start + coeff_idx] as usize; + coeffs[dst_idx] = 2; this_subblock_had_gt1 = true; // Track for next subblock's ctx_set if first_g1_idx.is_none() { first_g1_idx = Some(coeff_idx); @@ -517,7 +573,10 @@ pub fn decode_residual( // Decode greater-2 flag (only for first coefficient with greater-1) // Uses same ctx_set as greater1_flags (captured at subblock start) if let Some(g1_idx) = first_g1_idx { - let g2 = decode_coeff_greater2_flag(cabac, ctx, c_idx, ctx_set)?; + let ctx_idx = context::COEFF_ABS_LEVEL_GREATER2_FLAG + + if c_idx > 0 { 4 } else { 0 } + + ctx_set as usize; + let g2 = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); @@ -530,16 +589,12 @@ pub fn decode_residual( ); } if g2 { - coeff_values[g1_idx] = 3; + let dst_idx = touched_coeffs[subblock_start + g1_idx] as usize; + coeffs[dst_idx] = 3; needs_remaining |= 1u16 << g1_idx; } } - // Dense positions are ordered from the last to the first coefficient - // in scan order. - let first_sig_pos = coeff_positions[num_coeffs - 1]; - let last_sig_pos = coeff_positions[0]; - // Determine if sign is hidden for this sub-block // Per H.265 9.3.4.3: sign is hidden if: // - sign_data_hiding_enabled_flag is true @@ -589,14 +644,22 @@ pub fn decode_residual( while remaining_flags != 0 { let coeff_idx = remaining_flags.trailing_zeros() as usize; remaining_flags &= remaining_flags - 1; - let base = coeff_values[coeff_idx]; + let dst_idx = touched_coeffs[subblock_start + coeff_idx] as usize; + let base = coeffs[dst_idx]; let (remaining, new_rice) = decode_coeff_abs_level_remaining(cabac, rice_param, base)?; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); + let raster_x = dst_idx % size; + let raster_y = dst_idx / size; + let n = find_scan_pos_4x4( + scan_pos, + (raster_x - sb_x as usize * 4) as u8, + (raster_y - sb_y as usize * 4) as u8, + ); rc_eprintln!( "{rcp}_REM n={} base={} rice={} rem={} final={} range={} byte={}", - coeff_positions[coeff_idx], + n, base, rice_param, remaining, @@ -606,14 +669,16 @@ pub fn decode_residual( ); } rice_param = new_rice; - coeff_values[coeff_idx] = base + remaining; + coeffs[dst_idx] = base + remaining; } // Apply signs and compute sum for parity inference - // At this point coeff_values[] are all positive (absolute values) + // At this point all workspace values are positive absolute levels. let mut sum_abs_level = 0i32; - for (coeff_idx, coeff_value) in coeff_values[..num_coeffs].iter_mut().enumerate() { + for coeff_idx in 0..num_coeffs { + let dst_idx = touched_coeffs[subblock_start + coeff_idx] as usize; + let coeff_value = &mut coeffs[dst_idx]; if coeff_idx < num_signs && coeff_signs & (1 << (num_signs - 1 - coeff_idx)) != 0 { *coeff_value = -*coeff_value; } @@ -625,20 +690,10 @@ pub fn decode_residual( } } - // Store coefficients in buffer - let size = size as usize; - let sb_offset = sb_y as usize * 4 * size + sb_x as usize * 4; - for coeff_idx in 0..num_coeffs { - let n = coeff_positions[coeff_idx] as usize; - let (px, py) = scan_pos[n]; - let dst_idx = sb_offset + py as usize * size + px as usize; - coeffs[dst_idx] = coeff_values[coeff_idx]; - touched_coeffs[num_nonzero] = dst_idx as u16; - num_nonzero += 1; - + #[cfg(feature = "decoder-tracing")] + for &dst_idx in &touched_coeffs[subblock_start..num_nonzero] { // Track large coefficients (indicates CABAC desync) - #[cfg(feature = "decoder-tracing")] - if coeff_values[coeff_idx].abs() > 500 { + if coeffs[dst_idx as usize].abs() > 500 { let (byte_pos, _, _) = cabac.get_position(); debug::track_large_coeff(byte_pos); } @@ -863,189 +918,19 @@ fn decode_last_sig_coeff_prefix( Ok(prefix) } -/// Decode coded_sub_block_flag -/// Per H.265 section 9.3.4.2.4, context depends on neighbor coded_sub_block_flags: -/// - csbfCtx = (csbf_right | csbf_below) ? 1 : 0 -/// - ctx_idx = base + csbfCtx + c_idx_offset -fn decode_coded_sub_block_flag( - cabac: &mut CabacDecoder<'_>, - ctx: &mut [ContextModel], - c_idx: u8, - csbf_neighbors: u8, // bit 0 = right, bit 1 = below -) -> Result { - // Context offset: 0-1 for luma, 2-3 for chroma - // csbfCtx = 1 if either neighbor is coded, else 0 - let csbf_ctx = if csbf_neighbors != 0 { 1 } else { 0 }; - let ctx_idx = context::CODED_SUB_BLOCK_FLAG + csbf_ctx + if c_idx > 0 { 2 } else { 0 }; - Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) -} - /// Context index map for 4x4 TU sig_coeff_flag (H.265 Table 9-41) /// Maps position (y*4 + x) to context index static CTX_IDX_MAP_4X4: [u8; 16] = [0, 1, 4, 5, 2, 3, 4, 5, 6, 6, 8, 8, 7, 7, 8, 8]; -/// Calculate sig_coeff_flag context index -/// -/// Per H.265 section 9.3.4.2.5, the context depends on: -/// - Position within sub-block (xP, yP) -/// - Sub-block position within TU (xS, yS) -/// - coded_sub_block_flag of neighbors (prevCsbf) -/// - Component (luma/chroma) -/// - TU size and scan order -/// -/// Parameters: -/// - x_c, y_c: coefficient position within TU -/// - log2_size: TU size (2=4x4, 3=8x8, 4=16x16, 5=32x32) -/// - c_idx: component (0=Y, 1=Cb, 2=Cr) -/// - scan_idx: scan order (0=diagonal, 1=horizontal, 2=vertical) -/// - prev_csbf: coded_sub_block_flag of neighbors (bit0=right, bit1=below per H.265/libde265) -fn calc_sig_coeff_flag_ctx( - x_c: u8, - y_c: u8, - log2_size: u8, - c_idx: u8, - scan_idx: u8, - prev_csbf: u8, -) -> usize { - let sb_width = 1u8 << (log2_size - 2); - - let sig_ctx = if sb_width == 1 { - // 4x4 TU: use lookup table - CTX_IDX_MAP_4X4[(y_c as usize * 4 + x_c as usize).min(15)] - } else if x_c == 0 && y_c == 0 { - // DC coefficient - 0 - } else { - // Sub-block and position within sub-block - let x_s = x_c >> 2; - let y_s = y_c >> 2; - let x_p = x_c & 3; - let y_p = y_c & 3; - - // Base context from position and neighbor flags - // Per libde265: prevCsbf bit0=right, bit1=below - let mut ctx = match prev_csbf { - 0 => { - // No coded neighbors: context based on position sum - if x_p + y_p >= 3 { - 0 - } else if x_p + y_p > 0 { - 1 - } else { - 2 - } - } - 1 => { - // Right neighbor coded (bit0=1): context based on y position - if y_p == 0 { - 2 - } else if y_p == 1 { - 1 - } else { - 0 - } - } - 2 => { - // Below neighbor coded (bit1=1): context based on x position - if x_p == 0 { - 2 - } else if x_p == 1 { - 1 - } else { - 0 - } - } - _ => { - // Both neighbors coded - 2 - } - }; - - if c_idx == 0 { - // Luma - if x_s + y_s > 0 { - ctx += 3; // Not first sub-block - } - - // Size-dependent offset - if sb_width == 2 { - // 8x8 TU - ctx += if scan_idx == 0 { 9 } else { 15 }; - } else { - // 16x16 or 32x32 TU - ctx += 21; - } - } else { - // Chroma - if sb_width == 2 { - // 8x8 TU - ctx += 9; - } else { - // 16x16 or larger TU - ctx += 12; - } - } - - ctx - }; - - // Final context index - context::SIG_COEFF_FLAG + if c_idx > 0 { 27 } else { 0 } + sig_ctx as usize -} - -/// Decode significant_coeff_flag with proper context derivation -#[allow(clippy::too_many_arguments)] -fn decode_sig_coeff_flag( - cabac: &mut CabacDecoder<'_>, - ctx: &mut [ContextModel], - c_idx: u8, - pos: u8, - log2_size: u8, - scan_idx: u8, - sb_x: u8, - sb_y: u8, - prev_csbf: u8, - scan_table: &[(u8, u8); 16], -) -> Result { - // Get coefficient position within TU from scan position - let (x_in_sb, y_in_sb) = scan_table[pos as usize]; - let x_c = sb_x * 4 + x_in_sb; - let y_c = sb_y * 4 + y_in_sb; - - let ctx_idx = calc_sig_coeff_flag_ctx(x_c, y_c, log2_size, c_idx, scan_idx, prev_csbf); - - Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) -} - -/// Decode coeff_abs_level_greater1_flag -/// Per H.265 9.3.4.2.6: context index = ctxSet * 4 + min(greater1Ctx, 3) -/// Plus 16 for chroma (c_idx > 0) -fn decode_coeff_greater1_flag( - cabac: &mut CabacDecoder<'_>, - ctx: &mut [ContextModel], - c_idx: u8, - ctx_set: u8, - greater1_ctx: u8, -) -> Result { - let ctx_idx = context::COEFF_ABS_LEVEL_GREATER1_FLAG - + if c_idx > 0 { 16 } else { 0 } - + (ctx_set as usize) * 4 - + (greater1_ctx as usize).min(3); - Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) -} - -/// Decode coeff_abs_level_greater2_flag -/// Per H.265: context index = ctxSet + (c_idx > 0 ? 4 : 0) -fn decode_coeff_greater2_flag( - cabac: &mut CabacDecoder<'_>, - ctx: &mut [ContextModel], - c_idx: u8, - ctx_set: u8, -) -> Result { - let ctx_idx = - context::COEFF_ABS_LEVEL_GREATER2_FLAG + if c_idx > 0 { 4 } else { 0 } + ctx_set as usize; - Ok(cabac.decode_bin(&mut ctx[ctx_idx]) != 0) -} +/// Local sig_coeff_flag context contribution for each `prevCsbf` value and +/// raster position. The component, transform-size, scan, and sub-block terms +/// are invariant across a sub-block and are added once in `decode_residual`. +static SIG_CTX_LOCAL: [[u8; 16]; 4] = [ + [2, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + [2, 2, 2, 2, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], + [2, 1, 0, 0, 2, 1, 0, 0, 2, 1, 0, 0, 2, 1, 0, 0], + [2; 16], +]; /// Decode coeff_abs_level_remaining (Golomb-Rice with adaptive rice parameter) /// Returns (value, updated_rice_param) From fc14b3ba7fa37e97c30dd891e7227b56b1638f34 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 15:42:48 +0530 Subject: [PATCH 13/28] perf(autoresearch): Experiment: Replace residual scan-coordinate interpretation and line --- src/heic-decoder/hevc/residual.rs | 95 ++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/src/heic-decoder/hevc/residual.rs b/src/heic-decoder/hevc/residual.rs index e3b92ec..7e5fae7 100644 --- a/src/heic-decoder/hevc/residual.rs +++ b/src/heic-decoder/hevc/residual.rs @@ -124,6 +124,31 @@ pub static SCAN_ORDER_4X4_VERT: [(u8, u8); 16] = [ (3, 3), ]; +/// Raster position (`y * 4 + x`) for each position in the three 4x4 scans. +/// Residual significance decoding uses this compact form on every CABAC bin; +/// keep the coordinate-pair tables above for callers that need coordinates. +const SCAN_RASTER_4X4: [[u8; 16]; 3] = [ + [0, 4, 1, 8, 5, 2, 12, 9, 6, 3, 13, 10, 7, 14, 11, 15], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15], +]; + +const fn invert_4x4_scans(scans: [[u8; 16]; 3]) -> [[u8; 16]; 3] { + let mut inverse = [[0u8; 16]; 3]; + let mut scan_idx = 0; + while scan_idx < scans.len() { + let mut pos = 0; + while pos < scans[scan_idx].len() { + inverse[scan_idx][scans[scan_idx][pos] as usize] = pos as u8; + pos += 1; + } + scan_idx += 1; + } + inverse +} + +const INVERSE_SCAN_4X4: [[u8; 16]; 3] = invert_4x4_scans(SCAN_RASTER_4X4); + /// Get scan order table for 4x4 sub-blocks pub fn get_scan_4x4(order: ScanOrder) -> &'static [(u8, u8); 16] { match order { @@ -233,25 +258,30 @@ pub fn decode_residual( // Get scan tables let scan_sub = get_scan_sub_block(log2_size, scan_order); - let scan_pos = get_scan_4x4(scan_order); - // Convert ScanOrder to scan_idx for context derivation let scan_idx = match scan_order { ScanOrder::Diagonal => 0, ScanOrder::Horizontal => 1, ScanOrder::Vertical => 2, }; + let scan_raster = &SCAN_RASTER_4X4[scan_idx]; + let inverse_scan = &INVERSE_SCAN_4X4[scan_idx]; // Find last sub-block let sb_width = (size / 4) as usize; let last_sb_x = last_x / 4; let last_sb_y = last_y / 4; - let last_sb_idx = find_scan_pos(scan_sub, last_sb_x, last_sb_y, sb_width as u32); + let last_sb_idx = sub_block_scan_pos( + last_sb_x, + last_sb_y, + sb_width as u32, + scan_order == ScanOrder::Horizontal, + ); // Find last position within sub-block let local_x = (last_x % 4) as u8; let local_y = (last_y % 4) as u8; - let last_pos_in_sb = find_scan_pos_4x4(scan_pos, local_x, local_y); + let last_pos_in_sb = inverse_scan[(local_y * 4 + local_x) as usize]; if rc_trace { rc_eprintln!( @@ -365,14 +395,19 @@ pub fn decode_residual( } base }; + let sig_ctx_local = if log2_size == 2 { + &CTX_IDX_MAP_4X4 + } else { + &SIG_CTX_LOCAL[prev_csbf as usize] + }; // Determine the last position to check // For last sub-block: start from last_pos_in_sb (the known last significant coeff) // For other sub-blocks: start from position 15 let last_coeff = if sb_idx == last_sb_idx { // Set the known last significant coefficient (no need to decode sig_coeff_flag) - let (px, py) = scan_pos[start_pos as usize]; - let dst_idx = sb_offset + py as usize * size + px as usize; + let raster_pos = scan_raster[start_pos as usize] as usize; + let dst_idx = sb_offset + (raster_pos >> 2) * size + (raster_pos & 3); coeffs[dst_idx] = 1; touched_coeffs[num_nonzero] = dst_idx as u16; num_nonzero += 1; @@ -389,17 +424,14 @@ pub fn decode_residual( // Decode significant_coeff_flags for positions last_coeff down to 1 // (DC at position 0 is handled separately for inference) for n in (1..=last_coeff).rev() { - let (x_in_sb, y_in_sb) = scan_pos[n as usize]; - let raster_pos = y_in_sb as usize * 4 + x_in_sb as usize; - let ctx_idx = if log2_size == 2 { - sig_ctx_base + CTX_IDX_MAP_4X4[raster_pos] as usize - } else { - sig_ctx_base + SIG_CTX_LOCAL[prev_csbf as usize][raster_pos] as usize - }; + let raster_pos = scan_raster[n as usize] as usize; + let ctx_idx = sig_ctx_base + sig_ctx_local[raster_pos] as usize; let sig = cabac.decode_bin(&mut ctx[ctx_idx]) != 0; if rc_trace { let (range, _, _) = cabac.get_state_extended(); let (byte_pos, _, _) = cabac.get_position(); + let x_in_sb = (raster_pos & 3) as u8; + let y_in_sb = (raster_pos >> 2) as u8; let xc = sb_x * 4 + x_in_sb; let yc = sb_y * 4 + y_in_sb; rc_eprintln!( @@ -414,7 +446,7 @@ pub fn decode_residual( ); } if sig { - let dst_idx = sb_offset + y_in_sb as usize * size + x_in_sb as usize; + let dst_idx = sb_offset + (raster_pos >> 2) * size + (raster_pos & 3); coeffs[dst_idx] = 1; touched_coeffs[num_nonzero] = dst_idx as u16; num_nonzero += 1; @@ -652,11 +684,9 @@ pub fn decode_residual( let (byte_pos, _, _) = cabac.get_position(); let raster_x = dst_idx % size; let raster_y = dst_idx / size; - let n = find_scan_pos_4x4( - scan_pos, - (raster_x - sb_x as usize * 4) as u8, - (raster_y - sb_y as usize * 4) as u8, - ); + let local_raster = + (raster_y - sb_y as usize * 4) * 4 + raster_x - sb_x as usize * 4; + let n = inverse_scan[local_raster]; rc_eprintln!( "{rcp}_REM n={} base={} rice={} rem={} final={} range={} byte={}", n, @@ -824,24 +854,21 @@ fn get_scan_sub_block(log2_size: u8, order: ScanOrder) -> &'static [(u8, u8)] { } } -/// Find position in scan order -fn find_scan_pos(scan: &[(u8, u8)], x: u32, y: u32, _width: u32) -> u32 { - for (i, &(sx, sy)) in scan.iter().enumerate() { - if sx as u32 == x && sy as u32 == y { - return i as u32; - } +/// Inverse of the sub-block scan tables. Only 8x8 luma can use the horizontal +/// 2x2 order; all larger sub-block grids use the diagonal order. +#[inline] +fn sub_block_scan_pos(x: u32, y: u32, width: u32, horizontal: bool) -> u32 { + if horizontal && width == 2 { + return y * width + x; } - 0 -} -/// Find position in 4x4 scan order -fn find_scan_pos_4x4(scan: &[(u8, u8); 16], x: u8, y: u8) -> u8 { - for (i, &(sx, sy)) in scan.iter().enumerate() { - if sx == x && sy == y { - return i as u8; - } + let diagonal = x + y; + if diagonal < width { + diagonal * (diagonal + 1) / 2 + x + } else { + let tail = 2 * width - 1 - diagonal; + width * width - tail * (tail + 1) / 2 + x - (diagonal + 1 - width) } - 0 } /// Decode last significant coefficient position From 8fe9d3c47edd0770004f3afa623879fcf68c0a18 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 16:10:03 +0530 Subject: [PATCH 14/28] fix CABAC short-substream reinitialization --- src/heic-decoder/hevc/cabac.rs | 94 +++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/src/heic-decoder/hevc/cabac.rs b/src/heic-decoder/hevc/cabac.rs index 2d073fc..cd39403 100644 --- a/src/heic-decoder/hevc/cabac.rs +++ b/src/heic-decoder/hevc/cabac.rs @@ -242,19 +242,23 @@ impl<'a> CabacDecoder<'a> { /// Equivalent to libde265's init_CABAC_decoder_2(). pub fn reinit(&mut self) { self.range = 510; - self.bits_needed = 8; + // With fewer than two bytes left, the legacy per-bit refill consumed + // the available high byte and then normalized bits_needed to -8 on + // the first decoded bit. Represent that pending first-bit transition + // directly as -9 so batched renormalization remains exactly + // equivalent without a special case in its hot path. + self.bits_needed = -9; self.value = 0; let remaining = self.data.len() - self.byte_pos; if remaining > 0 { self.value = (self.data[self.byte_pos] as u32) << 8; self.byte_pos += 1; - self.bits_needed -= 8; } if remaining > 1 { self.value |= self.data[self.byte_pos] as u32; self.byte_pos += 1; - self.bits_needed -= 8; + self.bits_needed = -8; } } @@ -375,9 +379,11 @@ impl<'a> CabacDecoder<'a> { fn renormalize(&mut self) { if self.range < 256 { // `range` is non-zero and at most seven shifts are needed. Since - // `bits_needed` starts in -8..=-1, the batch crosses at most one - // byte boundary. When it does, place the new byte exactly where - // the remaining single-bit shifts would have moved it. + // `bits_needed` starts in -9..=-1, the batch crosses at most one + // byte boundary. (`-9` is the canonical short-reinit state and + // cannot cross within a seven-bit batch.) When a normal state + // crosses, place the new byte exactly where the remaining + // single-bit shifts would have moved it. let shift = self.range.leading_zeros() - 23; self.range <<= shift; self.value <<= shift; @@ -526,3 +532,79 @@ pub static INIT_VALUES: [u8; context::NUM_CONTEXTS] = [ 154, 154, 154, 154, 154, 154, 154, 154, // RES_SCALE_SIGN_FLAG (2) 154, 154, ]; + +#[cfg(test)] +mod tests { + use super::CabacDecoder; + + fn legacy_reinit(decoder: &mut CabacDecoder<'_>, byte_pos: usize) { + decoder.byte_pos = byte_pos; + decoder.range = 510; + decoder.bits_needed = 8; + decoder.value = 0; + + let remaining = decoder.data.len() - decoder.byte_pos; + if remaining > 0 { + decoder.value = (decoder.data[decoder.byte_pos] as u32) << 8; + decoder.byte_pos += 1; + decoder.bits_needed -= 8; + } + if remaining > 1 { + decoder.value |= decoder.data[decoder.byte_pos] as u32; + decoder.byte_pos += 1; + decoder.bits_needed -= 8; + } + } + + fn legacy_renormalize(decoder: &mut CabacDecoder<'_>) { + while decoder.range < 256 { + decoder.range <<= 1; + decoder.value <<= 1; + decoder.bits_needed += 1; + if decoder.bits_needed >= 0 { + decoder.bits_needed = -8; + if decoder.byte_pos < decoder.data.len() { + decoder.value |= decoder.data[decoder.byte_pos] as u32; + decoder.byte_pos += 1; + } + } + } + } + + #[test] + fn batched_renormalization_matches_legacy_after_short_reinit() { + let data = [0x12, 0x34, 0x56, 0x78]; + + for remaining in 0..=2 { + let byte_pos = data.len() - remaining; + for range in [128, 64, 32, 16, 8, 4, 2] { + let mut batched = CabacDecoder::new(&data).expect("CABAC test data"); + batched.seek_to(byte_pos).expect("valid byte offset"); + batched.range = range; + + let mut legacy = CabacDecoder::new(&data).expect("CABAC test data"); + legacy_reinit(&mut legacy, byte_pos); + legacy.range = range; + + batched.renormalize(); + legacy_renormalize(&mut legacy); + + assert_eq!( + ( + batched.range, + batched.value, + batched.bits_needed, + batched.byte_pos, + ), + ( + legacy.range, + legacy.value, + legacy.bits_needed, + legacy.byte_pos, + ), + "remaining={remaining}, range={range}" + ); + } + } + } +} From 5f75417586c906545719329958778b41360f9d27 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 16:12:25 +0530 Subject: [PATCH 15/28] autoresearch: require repeatable 2% gains --- autoresearch/README.md | 15 +++++++++------ autoresearch/program.md | 4 ++-- scripts/autoresearch.sh | 8 ++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/autoresearch/README.md b/autoresearch/README.md index 4a58283..bc52002 100644 --- a/autoresearch/README.md +++ b/autoresearch/README.md @@ -10,7 +10,7 @@ This directory adapts Karpathy's `autoresearch` pattern to decoder optimization: The controller never asks the optimization agent to decide whether its own work passes. It builds the current champion and candidate as separate executables, runs them in baseline/candidate/candidate/baseline order, and only runs the full -correctness suite for a candidate that is at least 5% faster. It then repeats a +correctness suite for a candidate that is at least 2% faster. It then repeats a larger A/B benchmark across every pinned hook-decodable HEIC/HEIF corpus file. A candidate is committed only after both speed gates and correctness pass. @@ -104,13 +104,13 @@ For each candidate the controller: 4. builds a fresh candidate benchmark executable in trusted external state; 5. compares it against the saved champion with multiple interleaved samples on six large real-camera inputs; -6. requires at least `HEIC_AUTORESEARCH_MIN_IMPROVEMENT` (default `0.05`, or - 5%) improvement; +6. requires at least `HEIC_AUTORESEARCH_MIN_IMPROVEMENT` (default `0.02`, or + 2%) improvement; 7. for a faster candidate, runs host clippy, installed portability target checks, and the complete pixel-exact validator + production-shaped image-hook suite; 8. after correctness, runs a second A/B benchmark over every HEIC/HEIF file that the baseline champion decoded through the hook during setup, requiring - `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT` (also 5% by default); and + `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT` (also 2% by default); and 9. commits the candidate and promotes its executable only if every check passes. The primary benchmark measures Ente's path-based image-hook flow, including file @@ -137,11 +137,14 @@ commits for maintainability, safety, dependency quality, and over-specialization Environment knobs: +- The primary and full-corpus gates both default to a 2% latency improvement, + measured with four interleaved A/B samples. This admitted repeatable wins that + the former 5% gate discarded while still rejecting corpus-specific gains. - `HEIC_AUTORESEARCH_MIN_IMPROVEMENT=0.10` requires a 10% primary win. - `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT=0.10` requires a 10% full-corpus confirmation. -- `HEIC_AUTORESEARCH_PAIR_SAMPLES=3` increases A/B samples per invocation. -- `HEIC_AUTORESEARCH_CONFIRM_SAMPLES=4` increases full-corpus A/B samples. +- `HEIC_AUTORESEARCH_PAIR_SAMPLES=6` increases A/B samples per invocation. +- `HEIC_AUTORESEARCH_CONFIRM_SAMPLES=6` increases full-corpus A/B samples. - `HEIC_AUTORESEARCH_CHECK_TARGETS=aarch64-apple-ios,aarch64-linux-android` selects extra installed targets checked before promotion. - `HEIC_AUTORESEARCH_STATE_DIR=/trusted/path` overrides external state. diff --git a/autoresearch/program.md b/autoresearch/program.md index fad49f1..0768b46 100644 --- a/autoresearch/program.md +++ b/autoresearch/program.md @@ -81,8 +81,8 @@ promotion includes benchmarks on other representative hardware. 2. Make the smallest clean change that tests it. 3. Run `cargo fmt --all` after Rust edits. 4. Run a focused check or unit test if useful. Do not run the full libheif suite; - the controller does that only for candidates which are at least 5% faster on - the primary image-hook benchmark. After correctness, it also requires a 5% + the controller does that only for candidates which are at least 2% faster on + the primary image-hook benchmark. After correctness, it also requires a 2% confirmation on the pinned full HEIC/HEIF hook corpus. 5. Return after this single attempt. In the final response, put a concise one-line experiment description first, followed by the hypothesis and any diff --git a/scripts/autoresearch.sh b/scripts/autoresearch.sh index 1f95474..7691974 100755 --- a/scripts/autoresearch.sh +++ b/scripts/autoresearch.sh @@ -673,10 +673,10 @@ evaluate_candidate() { local attempt_dir="$STATE_DIR/attempts/$attempt" local candidate_binary="$attempt_dir/candidate-bench" local champion_binary="$STATE_DIR/champion-bench" - local min_improvement="${HEIC_AUTORESEARCH_MIN_IMPROVEMENT:-0.05}" - local confirmation_min_improvement="${HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT:-0.05}" - local primary_samples="${HEIC_AUTORESEARCH_PAIR_SAMPLES:-2}" - local confirmation_samples="${HEIC_AUTORESEARCH_CONFIRM_SAMPLES:-3}" + local min_improvement="${HEIC_AUTORESEARCH_MIN_IMPROVEMENT:-0.02}" + local confirmation_min_improvement="${HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT:-0.02}" + local primary_samples="${HEIC_AUTORESEARCH_PAIR_SAMPLES:-4}" + local confirmation_samples="${HEIC_AUTORESEARCH_CONFIRM_SAMPLES:-4}" local allowed_ratio confirmation_allowed_ratio cumulative commit message local primary_baseline_score primary_candidate_score primary_speedup local confirmation_candidate_score confirmation_speedup From 7c170182f7984e37f25f07e92a7e586ac56125f8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 17 Jul 2026 20:39:54 +0530 Subject: [PATCH 16/28] speed optimization suggestions --- .../heic-speed-optimization-suggestions.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .agents/heic-speed-optimization-suggestions.md diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md new file mode 100644 index 0000000..6eb826d --- /dev/null +++ b/.agents/heic-speed-optimization-suggestions.md @@ -0,0 +1,61 @@ +# HEIC Decoder: Further Speed Optimization Suggestions + +## 1. Parse HEVC NAL units and metadata only once + +The current decode path can traverse and parse the same HEVC stream multiple times for metadata extraction, VCL validation, and the actual decode. Grid images repeat this setup for every tile. + +Refactor the decoder boundary so a single NAL pass supplies both the decoded frame and parsed metadata. This should reduce RBSP conversion, SPS parsing, temporary allocations, and per-tile setup costs, with the largest benefit expected on tiled images and smaller mobile images. + +## 2. Avoid materializing cropped YUV planes + +Clean-aperture crops and non-default strides currently cause full Y, U, and V plane copies before color conversion. Even a one-pixel crop can therefore add substantial memory traffic. + +Represent decoded planes as stride-aware views containing an origin, dimensions, and row stride, then convert the cropped region directly. Alternatively, add a finalizer that converts directly from the decoded frame into the caller's RGB/RGBA buffer. This should lower both latency and peak memory use on mobile devices. + +## 3. Convert grid tiles directly into the final output + +Grid tiles are converted into temporary RGBA buffers before being copied or transformed into the destination image. + +For identity orientation, add an output-stride-aware NEON conversion path that writes each tile directly into its final destination region. For 90-degree and 270-degree orientations, investigate blocked conversion and transpose operations to retain cache locality. Keep this experiment narrower than the previously rejected general affine-conversion implementation. + +## 4. Make grid and frame parallelism mobile-aware + +Fixed concurrency and use of the global Rayon pool can oversubscribe mobile CPUs, increase memory pressure, and cause thermal throttling—particularly when an application decodes multiple images concurrently. + +Expose a concurrency or memory budget, support a caller-provided worker pool, and benchmark conservative mobile defaults such as two to four workers. Prefer ordered streaming of completed tiles so fewer decoded tile buffers need to remain live. Evaluate peak RSS and energy consumption alongside latency. + +## 5. Evaluate LTO and profile-guided optimization + +The decoder's CABAC and residual paths contain many small helpers and data-dependent branches that may benefit from whole-program optimization and profile feedback. + +Benchmark ThinLTO with a single codegen unit in the consuming application, followed by PGO trained on a stratified HEIC corpus. Measure binary size and cold-start performance as guardrails. Apply release-profile settings in the final application workspace because dependency crates cannot reliably control the consumer's Cargo profile. + +## 6. Extend SIMD color conversion beyond full-range 8-bit 4:2:0 + +The NEON fast path is gated to 8-bit, full-range, 4:2:0, opaque matrix content only (`src/lib.rs` `PreparedYcbcrTransform` selection). Everything else — 10-bit HDR photos (`MatrixFullFloat`), limited/video-range YCbCr (`MatrixLimited`), 4:2:2/4:4:4, and alpha-bearing images — falls back to a per-pixel scalar float loop with `fmaf_parity` calls. Recent iPhones (HDR) and many Android cameras produce exactly these formats, so on mobile this is likely the largest untapped win. + +Add NEON f32 kernels for the `MatrixFullFloat` and `MatrixLimited` paths. Bit-exact parity with the libheif float oracle is achievable because `fmaf_parity` already uses fused `mul_add` on aarch64 and `vfmaq_f32` is also fused. Also consider ARMv7 NEON and WASM SIMD128 variants, which currently have zero SIMD coverage. + +## 7. Batch residual/CABAC micro-optimizations into one coherent change + +Several individually positive but sub-gate residual/CABAC experiments were rejected: caller-side bounds-check elimination (x1.007), fused range+transition lookup (x1.008), hoisted/flattened helpers (x1.012), scan-ordered context tables (x1.011), and bypass-bin batching (x1.019–x1.025). The journal shows that combining compatible near-threshold ideas cleared the gate twice before (attempts 33 and 36). + +Combine the mutually compatible ones into a single candidate. The most promising component is bypass-bin batching: `decode_bypass_bits` and sign-run decoding still loop one bin at a time; a modest 2–4 bit unroll with one shared refill check should help without repeating the failed 64-bit code-window redesign (x0.906 — do not retry). Avoid large fused lookup tables (2 KiB variant) since cache pressure is worse on mobile. + +## 8. Remove full-plane clones in SAO + +SAO edge-offset processing clones the entire Y, Cb, and Cr planes (`sao.rs`) to preserve original neighbor values, plus a `deblock_flags` clone per pass. On desktop this measured only ~x1.026 when addressed, but full-plane allocation and memcpy cost relatively more on mobile's smaller caches and lower memory bandwidth, and also costs energy. + +Replace the full-plane copies with CTB-local (or row-band) temporary buffers, or a direct source-to-destination pass. Re-measure on a mobile device rather than the desktop host before rejecting. + +## 9. Avoid full-plane rotation allocations for non-grid oriented images + +Grid images now use accepted affine row-stride placement, but non-grid images with `irot`/`imir` still go through full-plane rotation/mirror functions (`transforms.rs`) that allocate a complete new plane and use per-pixel multiplicative indexing. A prior bounded-band attempt was host-neutral, but the extra full-frame traffic is more expensive on mobile. + +Fold orientation into the color-conversion write step with blocked (cache-tiled) transpose for 90/270 rotations, writing directly into the caller's buffer, mirroring the approach that already succeeded for grids. + +## 10. Investigate WPP row-parallel reconstruction for non-grid images + +WPP entry points are already parsed and per-row CABAC reinitialization works (`ctu.rs`), but CTU rows still decode serially. Grid images get parallelism from tiles; large single-coded-image HEICs do not. + +For WPP-encoded streams, decode CTU rows on a wavefront with the standard two-CTU lag. This is a large refactor and only pays off for WPP-encoded files, so first survey how common WPP is in the real-world corpus before committing. From 05df6a874231ef782f4e4486a5d9cd1be5c06d18 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 13:25:54 +0530 Subject: [PATCH 17/28] docs: record rejected single-pass HEVC experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 6eb826d..e22181a 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -6,6 +6,18 @@ The current decode path can traverse and parse the same HEVC stream multiple tim Refactor the decoder boundary so a single NAL pass supplies both the decoded frame and parsed metadata. This should reduce RBSP conversion, SPS parsing, temporary allocations, and per-tile setup costs, with the largest benefit expected on tiled images and smaller mobile images. +### Experiment result (2026-07-18): rejected + +Implemented a source-only candidate that walked hvcC and payload NALs directly, reused the first parsed SPS across metadata/backend decode, handed the image-hook probe SPS into pixel decode (including the first grid tile), and removed the normalized intermediate stream allocation. The candidate passed formatting, Clippy, all 63 library tests, and the complete validator run: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +The 225-file production `image`-crate hook A/B benchmark was nevertheless neutral on every target, with identical output fingerprints: + +- Apple Silicon desktop: `0.997307x` (baseline 1141.273 ms, candidate 1144.355 ms; -0.27%). +- Pixel 4 / Android 13: `1.005837x` (baseline 7020.472 ms, candidate 6979.729 ms; +0.58%; thermal status 0 before/after). +- iPhone 11 Pro / iOS 26.5: `0.999721x` (baseline 2312.484 ms, candidate 2313.130 ms; -0.03%). + +This did not meet the repeatable 2% improvement gate on either mobile device or desktop, so the implementation was reverted. The result suggests NAL normalization/SPS re-parsing is not a material share of end-to-end production-hook latency on this corpus. + ## 2. Avoid materializing cropped YUV planes Clean-aperture crops and non-default strides currently cause full Y, U, and V plane copies before color conversion. Even a one-pixel crop can therefore add substantial memory traffic. From 39b9a26c6e5b06df44125b877592f053942fa6c8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 13:47:29 +0530 Subject: [PATCH 18/28] docs: record rejected strided crop experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index e22181a..f9b2903 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -24,6 +24,18 @@ Clean-aperture crops and non-default strides currently cause full Y, U, and V pl Represent decoded planes as stride-aware views containing an origin, dimensions, and row stride, then convert the cropped region directly. Alternatively, add a finalizer that converts directly from the decoded frame into the caller's RGB/RGBA buffer. This should lower both latency and peak memory use on mobile devices. +### Experiment result (2026-07-18): rejected + +Implemented a coded-image `image`-hook fast path that retained backend-owned planes as validated stride/origin views and converted directly into the caller's RGBA8/RGBA16 buffer. It covered YUV400/420/422/444, full/limited range, 8/10/12-bit content, auxiliary alpha, clean aperture, mirror, and rotation; full-range 8-bit 4:2:0 retained the region SIMD kernel. Grid paths were deliberately left for suggestion 3. The candidate passed 66 library tests, strict Clippy, portability checks, focused adapter/direct parity fixtures, and the complete validator run: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints but did not improve end-to-end latency: + +- Apple Silicon desktop: `0.994873x` (baseline 1141.117 ms, candidate 1146.998 ms; -0.51%). +- Pixel 4 / Android 13: `0.965463x` (baseline 6718.879 ms, candidate 6959.226 ms; -3.45%; thermal status 0 before/after). +- iPhone 11 Pro / iOS 26.5: `1.001774x` (baseline 2294.987 ms, candidate 2290.924 ms; +0.18%; thermal state nominal throughout). + +The implementation was reverted. Avoiding conformance-window plane copies was not a material production-hook win on this corpus, and the roughly 860-line specialized finalizer was not justified—particularly with a clear Android regression. + ## 3. Convert grid tiles directly into the final output Grid tiles are converted into temporary RGBA buffers before being copied or transformed into the destination image. From c6454f924533b5f11e2280778446b548afcf14f6 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 14:07:42 +0530 Subject: [PATCH 19/28] docs: record rejected direct grid output experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index f9b2903..ba00299 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -42,6 +42,18 @@ Grid tiles are converted into temporary RGBA buffers before being copied or tran For identity orientation, add an output-stride-aware NEON conversion path that writes each tile directly into its final destination region. For 90-degree and 270-degree orientations, investigate blocked conversion and transpose operations to retain cache locality. Keep this experiment narrower than the previously rejected general affine-conversion implementation. +### Experiment result (2026-07-18): rejected + +Implemented the deliberately narrow production-hook path: opaque, identity-oriented RGBA8 grids converted each validated and clipped YUV tile directly into its final caller-buffer region using the canvas row stride. Full-range 8-bit 4:2:0 kept the AArch64 NEON kernel with strided stores, while the portable scalar converter covered the other 8-bit layouts and ranges. Rotations, mirrors, clean apertures, auxiliary alpha, and RGBA16 stayed on the established temporary-tile fallback. The candidate passed 65 library tests, strict Clippy, all portability builds, focused real-grid parity, and the complete validator run: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints and showed small improvements, but none was repeatably above the 2% gate: + +- Apple Silicon desktop: `1.012266x` (baseline 1135.645 ms, candidate 1121.884 ms; +1.23%). +- Pixel 4 / Android 13: `1.006170x` (baseline 7055.583 ms, candidate 7012.319 ms; +0.62%; thermal status 0 before/after). +- iPhone 11 Pro / iOS 26.5: `1.019976x` (baseline 2284.174 ms, candidate 2239.440 ms; +2.00%; thermal state nominal throughout). The two interleaved iPhone pairs were only +0.3% and +3.6%, so this borderline mean was not repeatable. + +The implementation was reverted. Direct identity-grid conversion reduced work as intended, but it affects too little of the full production-hook corpus to justify the added specialized path under the experiment's acceptance rule. + ## 4. Make grid and frame parallelism mobile-aware Fixed concurrency and use of the global Rayon pool can oversubscribe mobile CPUs, increase memory pressure, and cause thermal throttling—particularly when an application decodes multiple images concurrently. From 917808d8a68f05792f3076c64bba8b6341665988 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 14:41:31 +0530 Subject: [PATCH 20/28] speed: stream grid tile decoding --- .../heic-speed-optimization-suggestions.md | 12 + src/lib.rs | 426 ++++++++++++++---- 2 files changed, 342 insertions(+), 96 deletions(-) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index ba00299..5ebdd71 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -60,6 +60,18 @@ Fixed concurrency and use of the global Rayon pool can oversubscribe mobile CPUs Expose a concurrency or memory budget, support a caller-provided worker pool, and benchmark conservative mobile defaults such as two to four workers. Prefer ordered streaming of completed tiles so fewer decoded tile buffers need to remain live. Evaluate peak RSS and energy consumption alongside latency. +### Experiment result (2026-07-18): accepted + +Replaced the grid decoder's fixed parallel batches with a bounded ordered stream on the existing Rayon pool. Active jobs and completed out-of-order tiles retain per-tile memory permits until row-major consumption, preserving the 64 MiB estimate budget; unestimable tiles drain earlier work and decode synchronously. Validation, errors, panics, and tile paste remain observable in row-major order. The first sub-variant also reduced iOS/Android to four workers, but physical-device testing showed that default was harmful: Pixel full-hook latency regressed 7.28% and iPhone was neutral. The final candidate therefore retained the existing eight-worker ceiling on all targets while keeping bounded streaming. + +The final candidate passed formatting, strict Clippy, 76 focused/library/CLI tests, iOS/Android/Wasm portability builds, and two complete validator runs after the scheduler revision: each accounted for all 272 corpus files, passed 219 pixel-oracle cases and 219 production image-hook parity checks, and had zero failures. The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints: + +- Apple Silicon desktop: `1.055055x` (baseline 1144.134 ms, candidate 1084.431 ms; +5.51%). +- Pixel 4 / Android 13: the initial revised B/C/C/B set was noisy because of one unusually fast baseline run; four additional reversed C/B/B/C invocations showed `1.022607x` (baseline 7402.952 ms, candidate 7239.291 ms; +2.26%). Across all eight invocations the result was `0.993735x` (-0.63%), with no repeatable regression and thermal status 0 throughout. +- iPhone 11 Pro / iOS 26.5: `1.042723x` (baseline 2315.621 ms, candidate 2220.744 ms; +4.27%). Both interleaved pairs improved by roughly 4%; thermal state was nominal until the tail of the final baseline run. + +The ordered streaming implementation was kept. It clears the 2% gate repeatably on desktop and iPhone, reduces batch-barrier idle time, and keeps the existing conservative in-flight memory bound. No direct energy counter was available; thermal state was used as the mobile guardrail. + ## 5. Evaluate LTO and profile-guided optimization The decoder's CABAC and residual paths contain many small helpers and data-dependent branches that may benefit from whole-program optimization and profile feedback. diff --git a/src/lib.rs b/src/lib.rs index 3d5ea1e..32469f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,8 +30,6 @@ use rav1d::{ Dav1dResult, Rav1dError, dav1d_close, dav1d_data_create, dav1d_data_unref, dav1d_default_settings, dav1d_get_picture, dav1d_open, dav1d_picture_unref, dav1d_send_data, }; -#[cfg(feature = "parallel-grid")] -use rayon::prelude::*; use scuffle_h265::NALUnitType; use source::{ FileSource, RandomAccessSource, SourceReadError, TempFileSpoolOptions, TempFileSpoolSource, @@ -4350,59 +4348,178 @@ fn estimate_heic_grid_tile_decode_bytes( .max(1)) } -/// Choose a conservative number of simultaneously decoded tiles from each -/// tile's own preflight estimate. An estimate failure stops the parallel -/// window before that tile so normal row-major decoding still selects the -/// user-visible error. -#[cfg(feature = "parallel-grid")] -fn heic_grid_tile_decode_window_from_estimates( - estimates: impl IntoIterator>, - available_threads: usize, -) -> usize { - let available_threads = available_threads.max(1); - let mut window = 0_usize; - let mut estimated_bytes = 0_u64; - for tile_bytes in estimates.into_iter().take(available_threads) { - let Some(tile_bytes) = tile_bytes else { - break; - }; - let next_estimated_bytes = estimated_bytes.saturating_add(tile_bytes.max(1)); - if window > 0 && next_estimated_bytes > GRID_TILE_DECODE_MEMORY_BUDGET { - break; - } - window += 1; - estimated_bytes = next_estimated_bytes; - if estimated_bytes >= GRID_TILE_DECODE_MEMORY_BUDGET { - break; - } - } - window.max(1) -} - -/// Choose a conservative number of simultaneously decoded tiles. Large, -/// malformed, or unusual tiles stay sequential instead of inheriting the -/// first grid tile's geometry and multiplying peak memory. +/// Choose a pool-aware worker ceiling. Per-tile estimates enforce the memory +/// bound dynamically in the ordered streaming scheduler. #[cfg(feature = "parallel-grid")] fn heic_grid_tile_decode_window(remaining_tiles: &[isobmff::HeicGridTileItemData]) -> usize { if remaining_tiles.len() <= 1 || cfg!(feature = "decoder-tracing") { return 1; } - let available_threads = rayon::current_num_threads() + rayon::current_num_threads() .min(MAX_GRID_TILE_DECODE_THREADS) - .min(remaining_tiles.len()); - heic_grid_tile_decode_window_from_estimates( - remaining_tiles - .iter() - .map(|tile| estimate_heic_grid_tile_decode_bytes(tile).ok()), - available_threads, - ) + .min(remaining_tiles.len()) +} + +#[cfg(feature = "parallel-grid")] +enum HeicGridTileDecodeCompletion { + Decoded { + tile_index: usize, + result: Result, + }, + Panicked { + tile_index: usize, + payload: Box, + }, +} + +/// Decode independent inputs concurrently while exposing their results only +/// in input order. A completed result retains its estimated memory permit +/// until `consume` returns, so active jobs plus out-of-order completions stay +/// within both bounds. An input that cannot be estimated drains earlier work +/// and is decoded on the caller thread, preserving malformed-input order. +#[cfg(feature = "parallel-grid")] +#[allow(clippy::too_many_arguments)] +fn for_each_ordered_bounded_parallel( + inputs: &[I], + first_index: usize, + max_in_flight: usize, + memory_budget: u64, + mut estimate: impl FnMut(&I) -> Option, + decode: impl Fn(&I) -> Result + Sync, + consume: &mut impl FnMut(usize, T) -> Result<(), E>, +) -> Result<(), E> +where + I: Sync, + T: Send, + D: Send, + E: From, +{ + use std::collections::{BTreeMap, VecDeque}; + use std::sync::mpsc; + + if inputs.is_empty() { + return Ok(()); + } + + let max_in_flight = max_in_flight.max(1).min(inputs.len()); + let memory_budget = memory_budget.max(1); + let (sender, receiver) = mpsc::channel::>(); + + rayon::in_place_scope_fifo(|scope| { + let mut next_to_schedule = 0_usize; + let mut next_to_consume = 0_usize; + let mut retained_estimates = VecDeque::::with_capacity(max_in_flight); + let mut retained_estimated_bytes = 0_u64; + let mut ready = BTreeMap::>::new(); + let mut next_estimate = None::>; + + while next_to_consume < inputs.len() { + // Prefer a result already buffered for the next row-major + // position before admitting more work. Results that race with + // admission may still allow bounded speculative decoding, but + // they are never exposed to the consumer out of order. + while let Ok(completion) = receiver.try_recv() { + let tile_index = match &completion { + HeicGridTileDecodeCompletion::Decoded { tile_index, .. } + | HeicGridTileDecodeCompletion::Panicked { tile_index, .. } => *tile_index, + }; + ready.insert(tile_index, completion); + } + if let Some(completion) = ready.remove(&(first_index + next_to_consume)) { + match completion { + HeicGridTileDecodeCompletion::Decoded { result, .. } => { + consume(first_index + next_to_consume, result.map_err(E::from)?)?; + } + HeicGridTileDecodeCompletion::Panicked { payload, .. } => { + std::panic::resume_unwind(payload); + } + } + let released_estimate = retained_estimates + .pop_front() + .expect("a completed grid tile must retain one memory estimate"); + retained_estimated_bytes = + retained_estimated_bytes.saturating_sub(released_estimate); + next_to_consume += 1; + continue; + } + + // Fill only permits made available by ordered consumption. This + // avoids a completed later tile allowing another allocation while + // an earlier decoded tile is still retained. + while next_to_schedule < inputs.len() && retained_estimates.len() < max_in_flight { + let estimated_bytes = + next_estimate.get_or_insert_with(|| estimate(&inputs[next_to_schedule])); + let Some(estimated_bytes) = *estimated_bytes else { + break; + }; + let estimated_bytes = estimated_bytes.max(1); + let next_estimated_bytes = retained_estimated_bytes.saturating_add(estimated_bytes); + if !retained_estimates.is_empty() && next_estimated_bytes > memory_budget { + break; + } + + let relative_index = next_to_schedule; + let tile_index = first_index + relative_index; + let input = &inputs[relative_index]; + let sender = sender.clone(); + let decode = &decode; + scope.spawn_fifo(move |_| { + let completion = + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + decode(input) + })) { + Ok(result) => { + HeicGridTileDecodeCompletion::Decoded { tile_index, result } + } + Err(payload) => HeicGridTileDecodeCompletion::Panicked { + tile_index, + payload, + }, + }; + // A callback panic can drop the receiver while sibling + // workers finish. That is normal scope unwinding, not a + // second panic from the worker. + let _ = sender.send(completion); + }); + + retained_estimates.push_back(estimated_bytes); + retained_estimated_bytes = next_estimated_bytes; + next_to_schedule += 1; + next_estimate = None; + } + + // An unestimable tile is deliberately not admitted beside other + // work. Once all preceding permits drain, decode it synchronously + // so its original decoder error remains the next visible result. + if next_to_schedule == next_to_consume && retained_estimates.is_empty() { + let tile_index = first_index + next_to_consume; + let tile = decode(&inputs[next_to_consume]).map_err(E::from)?; + consume(tile_index, tile)?; + next_to_consume += 1; + next_to_schedule += 1; + next_estimate = None; + continue; + } + + let completion = receiver + .recv() + .expect("a scoped grid worker must report completion before exiting"); + let tile_index = match &completion { + HeicGridTileDecodeCompletion::Decoded { tile_index, .. } + | HeicGridTileDecodeCompletion::Panicked { tile_index, .. } => *tile_index, + }; + ready.insert(tile_index, completion); + } + + Ok(()) + }) } -/// Decode grid tiles in bounded batches, but deliver them to the caller in -/// row-major order. Keeping validation and paste work on the caller thread -/// preserves deterministic errors and output while independent HEVC payloads -/// use the available cores. +/// Decode grid tiles with a bounded number of in-flight jobs, but deliver them +/// to the caller in row-major order. Keeping validation and paste work on the +/// caller thread preserves deterministic errors and output while independent +/// HEVC payloads use the available cores. fn for_each_decoded_heic_grid_tile( grid_data: &isobmff::HeicGridPrimaryItemData, first_tile: DecodedHeicImage, @@ -4421,28 +4538,24 @@ where #[cfg(feature = "parallel-grid")] { - let mut next_tile_index = 1_usize; - while next_tile_index < grid_data.tiles.len() { - let undecoded_tiles = &grid_data.tiles[next_tile_index..]; - let window = heic_grid_tile_decode_window(undecoded_tiles); - if window > 1 { - let decoded = undecoded_tiles[..window] - .par_iter() - .map(decode_heic_grid_tile_to_image) - .collect::>(); - - for (offset, tile) in decoded.into_iter().enumerate() { - consume(next_tile_index + offset, tile.map_err(E::from)?)?; - } - next_tile_index += window; - } else { - consume( - next_tile_index, - decode_heic_grid_tile_to_image(&grid_data.tiles[next_tile_index]) - .map_err(E::from)?, - )?; - next_tile_index += 1; - } + let window = heic_grid_tile_decode_window(remaining_tiles); + if window > 1 { + return for_each_ordered_bounded_parallel( + remaining_tiles, + 1, + window, + GRID_TILE_DECODE_MEMORY_BUDGET, + |tile| estimate_heic_grid_tile_decode_bytes(tile).ok(), + decode_heic_grid_tile_to_image, + &mut consume, + ); + } + + for (offset, tile) in remaining_tiles.iter().enumerate() { + consume( + offset + 1, + decode_heic_grid_tile_to_image(tile).map_err(E::from)?, + )?; } Ok(()) } @@ -13441,43 +13554,164 @@ mod tests { #[cfg(feature = "parallel-grid")] #[test] - fn grid_parallel_window_uses_each_candidate_tile_estimate() { - const MIB: u64 = 1024 * 1024; + fn grid_parallel_stream_exposes_decode_errors_in_row_major_order() { + let inputs = [0_usize, 1, 2, 3]; + let mut consumed = Vec::new(); + let result: Result<(), usize> = super::for_each_ordered_bounded_parallel( + &inputs, + 10, + 3, + 64, + |_| Some(1), + |value| { + if *value == 1 { + Err(77_usize) + } else { + Ok(*value) + } + }, + &mut |tile_index, value| { + consumed.push((tile_index, value)); + Ok(()) + }, + ); - assert_eq!( - super::heic_grid_tile_decode_window_from_estimates( - [Some(4 * MIB), Some(80 * MIB), Some(4 * MIB)], - 8, - ), - 1, - "a later oversized tile must not inherit the first candidate's small estimate" + assert_eq!(result, Err(77)); + assert_eq!(consumed, [(10, 0)]); + } + + #[cfg(feature = "parallel-grid")] + #[test] + fn grid_parallel_stream_stops_consuming_after_callback_error() { + let inputs = [0_usize, 1, 2, 3]; + let mut consumed = Vec::new(); + let result: Result<(), usize> = super::for_each_ordered_bounded_parallel( + &inputs, + 20, + 3, + 64, + |_| Some(1), + |value| Ok::<_, usize>(*value), + &mut |tile_index, value| { + if tile_index == 21 { + return Err(88_usize); + } + consumed.push((tile_index, value)); + Ok(()) + }, ); + + assert_eq!(result, Err(88)); + assert_eq!(consumed, [(20, 0)]); + } + + #[cfg(feature = "parallel-grid")] + #[test] + fn grid_parallel_stream_retains_each_tiles_memory_permit_until_consumed() { + use std::sync::Mutex; + use std::time::Duration; + + let events = Mutex::new(Vec::new()); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(4) + .build() + .expect("test pool should build"); + let result: Result<(), usize> = pool.install(|| { + super::for_each_ordered_bounded_parallel( + &[0_usize, 1], + 40, + 2, + 64, + |value| Some(if *value == 0 { 4 } else { 80 }), + |value| { + events + .lock() + .expect("test mutex poisoned") + .push(('d', *value)); + if *value == 0 { + // If the second 80-byte job incorrectly inherited the + // first job's small estimate, the dedicated pool has + // ample time to start it before tile 0 is consumed. + std::thread::sleep(Duration::from_millis(20)); + } + Ok::<_, usize>(*value) + }, + &mut |_, value| { + events + .lock() + .expect("test mutex poisoned") + .push(('c', value)); + Ok(()) + }, + ) + }); + + assert_eq!(result, Ok(())); assert_eq!( - super::heic_grid_tile_decode_window_from_estimates( - [Some(32 * MIB), Some(32 * MIB), Some(MIB)], - 8, - ), - 2, - "a window may fill the memory budget exactly" + *events.lock().expect("test mutex poisoned"), + [('d', 0), ('c', 0), ('d', 1), ('c', 1)] ); - assert_eq!( - super::heic_grid_tile_decode_window_from_estimates( - [Some(4 * MIB), None, Some(4 * MIB)], - 8, - ), - 1, - "a tile whose metadata cannot be preflighted must stay outside the parallel window" + } + + #[cfg(feature = "parallel-grid")] + #[test] + fn grid_parallel_stream_decodes_unestimable_input_on_caller_thread() { + use std::sync::Mutex; + + let caller_thread = std::thread::current().id(); + let unestimable_thread = Mutex::new(None); + let mut consumed = Vec::new(); + let result: Result<(), usize> = super::for_each_ordered_bounded_parallel( + &[0_usize, 1, 2], + 30, + 3, + 64, + |value| (*value != 1).then_some(1), + |value| { + if *value == 1 { + *unestimable_thread.lock().expect("test mutex poisoned") = + Some(std::thread::current().id()); + } + Ok::<_, usize>(*value) + }, + &mut |tile_index, value| { + consumed.push((tile_index, value)); + Ok(()) + }, ); + + assert_eq!(result, Ok(())); + assert_eq!(consumed, [(30, 0), (31, 1), (32, 2)]); assert_eq!( - super::heic_grid_tile_decode_window_from_estimates( - core::iter::repeat_n(Some(MIB), 16), - 8, - ), - 8, - "the thread cap must remain effective for small tiles" + *unestimable_thread.lock().expect("test mutex poisoned"), + Some(caller_thread) ); } + #[cfg(feature = "parallel-grid")] + #[test] + fn grid_parallel_stream_propagates_worker_panics_without_hanging() { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut consume = |_: usize, _: usize| Ok::<_, usize>(()); + let _: Result<(), usize> = super::for_each_ordered_bounded_parallel( + &[0_usize, 1], + 0, + 2, + 64, + |_| Some(1), + |value| { + if *value == 0 { + panic!("intentional grid worker panic"); + } + Ok::<_, usize>(*value) + }, + &mut consume, + ); + })); + + assert!(result.is_err()); + } + #[cfg(feature = "parallel-grid")] #[test] fn grid_parallel_estimate_uses_coded_sps_geometry_and_both_stream_copies() { From 0bff6e178be4a5c927f9a0c5f63484baf13e15b8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 15:32:03 +0530 Subject: [PATCH 21/28] docs: record rejected ThinLTO and PGO experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 5ebdd71..41bd79c 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -78,6 +78,18 @@ The decoder's CABAC and residual paths contain many small helpers and data-depen Benchmark ThinLTO with a single codegen unit in the consuming application, followed by PGO trained on a stratified HEIC corpus. Measure binary size and cold-start performance as guardrails. Apply release-profile settings in the final application workspace because dependency crates cannot reliably control the consumer's Cargo profile. +### Experiment result (2026-07-18): rejected + +Implemented an opt-in final-consumer build wrapper rather than a misleading dependency profile. It supported default release, ThinLTO with one codegen unit, and target-specific PGO instrument/train/merge/use builds with compiler/LLVM/target/manifest validation. PGO was trained independently on desktop, Pixel, and iPhone using a fixed 15-file set spanning six real-camera images plus bit-depth, chroma, lossless, scaling-list, transform-skip, and WPP cases; the full 225-file hook corpus remained evaluation-only. Android and iOS raw profiles were generated on their physical devices and retrieved successfully. + +The unchanged decoder source passed the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. Every default, ThinLTO, and PGO evaluation artifact also produced the identical 225-file hook fingerprint. Full-corpus timings were: + +- Apple Silicon desktop: ThinLTO `1.001877x` (default 1101.577 ms, ThinLTO 1099.513 ms; +0.19%); PGO `0.981025x` (1122.884 ms; -1.90%). +- Pixel 4 / Android 13: ThinLTO `0.997096x` (default 6877.490 ms, ThinLTO 6897.521 ms; -0.29%); PGO `1.018734x` (6751.014 ms; +1.87%; thermal status 0). The PGO result remained below the 2% gate. +- iPhone 11 Pro / iOS 26.5: ThinLTO `1.000189x` (default 2154.960 ms, ThinLTO 2154.553 ms; +0.02%; nominal thermal state). The PGO confirmation was `0.984277x` (thermally loaded default 2275.528 ms, PGO 2311.878 ms; -1.57%); its ordering biased the final default slower, so throttling could not be hiding a qualifying PGO win. + +Binary size improved despite neutral latency: ThinLTO reduced the final executable by roughly 5.5–12.1% and PGO by 11.0–17.1%, depending on target. A 40-launch tiny-hook guardrail was also neutral at about 3.0 ms median on desktop. Because neither build mode met the production-hook speed gate, the wrapper, training manifest, and documentation were reverted rather than adding maintenance and target-specific training complexity for a size-only benefit. + ## 6. Extend SIMD color conversion beyond full-range 8-bit 4:2:0 The NEON fast path is gated to 8-bit, full-range, 4:2:0, opaque matrix content only (`src/lib.rs` `PreparedYcbcrTransform` selection). Everything else — 10-bit HDR photos (`MatrixFullFloat`), limited/video-range YCbCr (`MatrixLimited`), 4:2:2/4:4:4, and alpha-bearing images — falls back to a per-pixel scalar float loop with `fmaf_parity` calls. Recent iPhones (HDR) and many Android cameras produce exactly these formats, so on mobile this is likely the largest untapped win. From bccceebcc33486de170a7373da5aec41a700d2db Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 15:58:06 +0530 Subject: [PATCH 22/28] speed: extend AArch64 color conversion SIMD --- .../heic-speed-optimization-suggestions.md | 14 + src/heic-decoder/hevc/color_convert.rs | 961 +++++++++++++++++- src/lib.rs | 222 +++- 3 files changed, 1191 insertions(+), 6 deletions(-) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 41bd79c..fa74aae 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -96,6 +96,20 @@ The NEON fast path is gated to 8-bit, full-range, 4:2:0, opaque matrix content o Add NEON f32 kernels for the `MatrixFullFloat` and `MatrixLimited` paths. Bit-exact parity with the libheif float oracle is achievable because `fmaf_parity` already uses fused `mul_add` on aarch64 and `vfmaq_f32` is also fused. Also consider ARMv7 NEON and WASM SIMD128 variants, which currently have zero SIMD coverage. +### Experiment result (2026-07-18): accepted + +Added AArch64 NEON float-matrix conversion for full/limited-range YUV 4:2:0, 4:2:2, and 4:4:4, covering RGB8/RGBA8 at 8-bit and RGBA16 at high bit depth. The production image hook gained a naturally aligned contiguous RGBA16 path, and identity alpha-bearing coded/grid images use SIMD color conversion before the existing alpha composition. Scalar fallbacks remain for non-AArch64, identity/monochrome matrices, deliberately unaligned RGBA16 buffers, transformed coded images, and non-finite/extreme coefficients. The kernels preserve the scalar FMA graph, limited-range subtraction/scaling, `+0.5` truncation/clipping, odd chroma phase, tails, and exact 10/12-bit-to-u16 bit replication. + +The candidate passed 71 library tests, 8 CLI tests, strict Clippy, no-default and iOS/Android/Wasm builds, synthetic SIMD/scalar parity across all supported layouts and boundaries, 12 real affected-format fixture comparisons, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints: + +- Apple Silicon desktop: `1.014672x` (baseline 1089.079 ms, candidate 1073.331 ms; +1.47%). +- Pixel 4 / Android 13: `1.056063x` (baseline 7057.459 ms, candidate 6682.801 ms; +5.61%; thermal status 0 before/after). Both interleaved pairs improved. +- iPhone 11 Pro / iOS 26.5: `1.002181x` (baseline 2253.249 ms, candidate 2248.344 ms; +0.22%; candidate runs stayed nominal and pairwise neutral). + +The implementation was kept. Although the affected formats are a small share of corpus pixels and the architecture-specific patch is substantial, the repeatable 5.6% Android full-hook win clears the gate without a material desktop or iPhone regression. + ## 7. Batch residual/CABAC micro-optimizations into one coherent change Several individually positive but sub-gate residual/CABAC experiments were rejected: caller-side bounds-check elimination (x1.007), fused range+transition lookup (x1.008), hoisted/flattened helpers (x1.012), scan-ordered context tables (x1.011), and bypass-bin batching (x1.019–x1.025). The journal shows that combining compatible near-threshold ideas cleared the gate twice before (attempts 33 and 36). diff --git a/src/heic-decoder/hevc/color_convert.rs b/src/heic-decoder/hevc/color_convert.rs index fb81072..bd0944c 100644 --- a/src/heic-decoder/hevc/color_convert.rs +++ b/src/heic-decoder/hevc/color_convert.rs @@ -8,13 +8,31 @@ use archmage::prelude::*; #[cfg(target_arch = "aarch64")] use core::arch::aarch64::{ - uint8x8x3_t, uint8x8x4_t, vaddq_s32, vcombine_u16, vdup_n_u8, vdupq_n_s32, vget_high_u16, - vget_low_u16, vmaxq_s32, vminq_s32, vmlaq_n_s32, vmovl_u16, vqmovn_u16, vqmovun_s32, - vreinterpretq_s32_u32, vshrq_n_s32, vst3_u8, vst4_u8, vsubq_s32, vzip1_u16, vzip2_u16, + uint8x8x3_t, uint8x8x4_t, uint16x4_t, uint16x4x4_t, uint16x8_t, vaddq_f32, vaddq_s32, + vcombine_u16, vcvtq_f32_u32, vcvtq_s32_f32, vdup_n_u8, vdup_n_u16, vdupq_n_f32, vdupq_n_s32, + vfmaq_n_f32, vget_high_u16, vget_low_u16, vmaxq_s32, vminq_s32, vmlaq_n_s32, vmovl_u16, + vmulq_n_f32, vqmovn_u16, vqmovn_u32, vqmovun_s32, vreinterpretq_s32_u32, vreinterpretq_u32_s32, + vshlq_u32, vshrq_n_s32, vst3_u8, vst4_u8, vst4_u16, vsubq_f32, vsubq_s32, vzip1_u16, vzip2_u16, }; #[cfg(target_arch = "aarch64")] use safe_unaligned_simd::aarch64::{vld1_u16, vld1q_u16}; +/// Parameters for libheif's generic f32 matrix conversion. +/// +/// Full range uses offsets `(0, midpoint)` and scales `(1, 1)`. Limited +/// range uses libheif's exact `1.1689f` luma and `1.1429f` chroma scales. +#[derive(Clone, Copy)] +pub(crate) struct FloatMatrixParams { + pub(crate) y_offset: f32, + pub(crate) y_scale: f32, + pub(crate) chroma_midpoint: f32, + pub(crate) chroma_scale: f32, + pub(crate) r_cr: f32, + pub(crate) g_cb: f32, + pub(crate) g_cr: f32, + pub(crate) b_cb: f32, +} + // Explicit imports for safe SIMD load/store (can't glob-import alongside core::arch) #[cfg(target_arch = "x86_64")] use safe_unaligned_simd::x86_64::{_mm_loadu_si64, _mm_loadu_si128, _mm256_storeu_si256}; @@ -384,6 +402,696 @@ fn convert_420_8bit_region_to_interleaved_neon( } } +/// Convert an 8-bit HEIC region through libheif's generic f32 matrix kernel. +/// +/// This covers limited-range 4:2:0 and full/limited 4:2:2 or 4:4:4, which do +/// not use libheif's specialized fixed-point 4:2:0 operation. Source +/// coordinates remain in the uncropped planes so odd crop origins retain +/// their original chroma phase. `subsample_x`/`subsample_y` must each be one +/// or two and `channels` must be three or four. +#[allow(clippy::too_many_arguments)] +pub(crate) fn convert_float_matrix_8bit_region_to_interleaved( + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + params: FloatMatrixParams, + channels: usize, + output: &mut [u8], +) { + validate_float_matrix_region( + y_plane, + cb_plane, + cr_plane, + y_stride, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + channels, + output.len(), + ); + incant!( + convert_float_matrix_8bit_region_to_interleaved( + y_plane, + cb_plane, + cr_plane, + y_stride, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + params, + channels, + output + ), + [neon, scalar] + ); +} + +/// Convert a complete high-bit-depth HEIC image through libheif's generic +/// f32 matrix kernel and expand the clipped source samples to RGBA16. +#[allow(clippy::too_many_arguments)] +pub(crate) fn convert_float_matrix_region_to_rgba16( + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + bit_depth: u8, + params: FloatMatrixParams, + output: &mut [u16], +) { + assert!((1..=16).contains(&bit_depth), "source bit depth"); + validate_float_matrix_region( + y_plane, + cb_plane, + cr_plane, + y_stride, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + 4, + output.len(), + ); + incant!( + convert_float_matrix_region_to_rgba16( + y_plane, + cb_plane, + cr_plane, + y_stride, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + bit_depth, + params, + output + ), + [neon, scalar] + ); +} + +#[allow(clippy::too_many_arguments)] +fn validate_float_matrix_region( + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + channels: usize, + output_len: usize, +) { + assert!(matches!(subsample_x, 1 | 2), "horizontal subsampling"); + assert!(matches!(subsample_y, 1 | 2), "vertical subsampling"); + assert!(matches!(channels, 3 | 4), "channels must be RGB or RGBA"); + let x_end = x_start + .checked_add(width) + .expect("float-matrix region x extent must fit in usize"); + let y_end = y_start + .checked_add(height) + .expect("float-matrix region y extent must fit in usize"); + let expected_output_len = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(channels)) + .expect("float-matrix output length must fit in usize"); + let y_len = y_stride + .checked_mul(y_end) + .expect("float-matrix luma extent must fit in usize"); + let chroma_rows = y_end.div_ceil(subsample_y); + let chroma_len = chroma_stride + .checked_mul(chroma_rows) + .expect("float-matrix chroma extent must fit in usize"); + assert_eq!( + output_len, expected_output_len, + "float-matrix output length" + ); + assert!(x_end <= y_stride, "float-matrix region exceeds luma row"); + assert!(y_plane.len() >= y_len, "float-matrix luma plane length"); + assert!( + chroma_stride >= x_end.div_ceil(subsample_x), + "float-matrix region exceeds chroma row" + ); + assert!(cb_plane.len() >= chroma_len, "float-matrix Cb plane length"); + assert!(cr_plane.len() >= chroma_len, "float-matrix Cr plane length"); +} + +#[inline(always)] +fn float_matrix_fma(a: f32, b: f32, c: f32) -> f32 { + #[cfg(any( + target_arch = "aarch64", + all(target_arch = "x86_64", target_feature = "fma") + ))] + { + a.mul_add(b, c) + } + #[cfg(not(any( + target_arch = "aarch64", + all(target_arch = "x86_64", target_feature = "fma") + )))] + { + a * b + c + } +} + +#[inline(always)] +fn convert_float_matrix_pixel( + y_sample: u16, + cb_sample: u16, + cr_sample: u16, + bit_depth: u8, + params: FloatMatrixParams, +) -> (u16, u16, u16) { + let y = (f32::from(y_sample) - params.y_offset) * params.y_scale; + let cb = (f32::from(cb_sample) - params.chroma_midpoint) * params.chroma_scale; + let cr = (f32::from(cr_sample) - params.chroma_midpoint) * params.chroma_scale; + let r = float_matrix_fma(params.r_cr, cr, y); + let g = float_matrix_fma(params.g_cr, cr, float_matrix_fma(params.g_cb, cb, y)); + let b = float_matrix_fma(params.b_cb, cb, y); + let max = ((1_i32 << bit_depth) - 1).max(0); + let clip = |value: f32| ((value + 0.5) as i32).clamp(0, max) as u16; + (clip(r), clip(g), clip(b)) +} + +#[inline(always)] +fn scale_float_matrix_sample_to_u16(sample: u16, bit_depth: u8) -> u16 { + if bit_depth >= 16 { + return sample; + } + let shift = 16 - u32::from(bit_depth); + let value = u32::from(sample); + ((value << shift) | (value >> u32::from(bit_depth).saturating_sub(shift))) + .min(u32::from(u16::MAX)) as u16 +} + +#[allow(clippy::too_many_arguments)] +fn convert_float_matrix_8bit_region_to_interleaved_scalar( + _token: ScalarToken, + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + params: FloatMatrixParams, + channels: usize, + output: &mut [u8], +) { + for output_y in 0..height { + let source_y = y_start + output_y; + let y_row = source_y * y_stride; + let chroma_row = (source_y / subsample_y) * chroma_stride; + for output_x in 0..width { + let source_x = x_start + output_x; + let chroma_index = chroma_row + source_x / subsample_x; + let (r, g, b) = convert_float_matrix_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_index], + cr_plane[chroma_index], + 8, + params, + ); + let output_index = (output_y * width + output_x) * channels; + output[output_index] = r as u8; + output[output_index + 1] = g as u8; + output[output_index + 2] = b as u8; + if channels == 4 { + output[output_index + 3] = u8::MAX; + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn convert_float_matrix_region_to_rgba16_scalar( + _token: ScalarToken, + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + bit_depth: u8, + params: FloatMatrixParams, + output: &mut [u16], +) { + for output_y in 0..height { + let source_y = y_start + output_y; + let y_row = source_y * y_stride; + let chroma_row = (source_y / subsample_y) * chroma_stride; + for output_x in 0..width { + let source_x = x_start + output_x; + let chroma_index = chroma_row + source_x / subsample_x; + let (r, g, b) = convert_float_matrix_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_index], + cr_plane[chroma_index], + bit_depth, + params, + ); + let output_index = (output_y * width + output_x) * 4; + output[output_index] = scale_float_matrix_sample_to_u16(r, bit_depth); + output[output_index + 1] = scale_float_matrix_sample_to_u16(g, bit_depth); + output[output_index + 2] = scale_float_matrix_sample_to_u16(b, bit_depth); + output[output_index + 3] = u16::MAX; + } + } +} + +#[cfg(target_arch = "aarch64")] +#[inline] +fn float_matrix_neon_safe(params: FloatMatrixParams) -> bool { + let values = [ + params.y_offset, + params.y_scale, + params.chroma_midpoint, + params.chroma_scale, + params.r_cr, + params.g_cb, + params.g_cr, + params.b_cb, + ]; + if !values.iter().all(|value| value.is_finite()) { + return false; + } + + // FCVTZS does not have Rust's saturating float-to-int semantics for + // infinities/out-of-range values. Public matrix metadata can derive + // unusual coefficients, so conservatively retain the scalar path unless + // every possible u16 input remains in the ordinary i32 conversion range. + let input_max = f64::from(u16::MAX); + let y_bound = (input_max + f64::from(params.y_offset).abs()) * f64::from(params.y_scale).abs(); + let chroma_bound = (input_max + f64::from(params.chroma_midpoint).abs()) + * f64::from(params.chroma_scale).abs(); + let channel_bound = y_bound + + chroma_bound + * f64::from( + params + .r_cr + .abs() + .max(params.g_cb.abs() + params.g_cr.abs()) + .max(params.b_cb.abs()), + ); + channel_bound.is_finite() && channel_bound + 1.0 < f64::from(i32::MAX) +} + +#[cfg(target_arch = "aarch64")] +#[inline(always)] +fn load_float_matrix_chroma_8(plane: &[u16], index: usize, subsample_x: usize) -> uint16x8_t { + // SAFETY: only called from the runtime-dispatched NEON kernel; slices are + // bounds-checked before the unaligned vector loads. + unsafe { + if subsample_x == 1 { + return vld1q_u16((&plane[index..index + 8]).try_into().unwrap()); + } + let values = vld1_u16((&plane[index..index + 4]).try_into().unwrap()); + vcombine_u16(vzip1_u16(values, values), vzip2_u16(values, values)) + } +} + +#[cfg(target_arch = "aarch64")] +#[inline(always)] +fn load_float_matrix_chroma_4(plane: &[u16], index: usize, subsample_x: usize) -> uint16x4_t { + // SAFETY: only called from the runtime-dispatched NEON kernel; slices are + // bounds-checked before the unaligned vector loads. + unsafe { + if subsample_x == 1 { + return vld1_u16((&plane[index..index + 4]).try_into().unwrap()); + } + let duplicated = [ + plane[index], + plane[index], + plane[index + 1], + plane[index + 1], + ]; + vld1_u16(&duplicated) + } +} + +#[cfg(target_arch = "aarch64")] +#[inline(always)] +fn convert_float_matrix_neon_4( + y: uint16x4_t, + cb: uint16x4_t, + cr: uint16x4_t, + params: FloatMatrixParams, + max: core::arch::aarch64::int32x4_t, +) -> ( + core::arch::aarch64::int32x4_t, + core::arch::aarch64::int32x4_t, + core::arch::aarch64::int32x4_t, +) { + // SAFETY: only called from the runtime-dispatched NEON kernels. + unsafe { + let y = vmulq_n_f32( + vsubq_f32(vcvtq_f32_u32(vmovl_u16(y)), vdupq_n_f32(params.y_offset)), + params.y_scale, + ); + let cb = vmulq_n_f32( + vsubq_f32( + vcvtq_f32_u32(vmovl_u16(cb)), + vdupq_n_f32(params.chroma_midpoint), + ), + params.chroma_scale, + ); + let cr = vmulq_n_f32( + vsubq_f32( + vcvtq_f32_u32(vmovl_u16(cr)), + vdupq_n_f32(params.chroma_midpoint), + ), + params.chroma_scale, + ); + let r = vfmaq_n_f32(y, cr, params.r_cr); + let g = vfmaq_n_f32(vfmaq_n_f32(y, cb, params.g_cb), cr, params.g_cr); + let b = vfmaq_n_f32(y, cb, params.b_cb); + let zero = vdupq_n_s32(0); + let half = vdupq_n_f32(0.5); + let clip = |value| vminq_s32(vmaxq_s32(vcvtq_s32_f32(vaddq_f32(value, half)), zero), max); + (clip(r), clip(g), clip(b)) + } +} + +#[cfg(target_arch = "aarch64")] +#[allow(clippy::too_many_arguments)] +#[arcane] +fn convert_float_matrix_8bit_region_to_interleaved_neon( + _token: NeonToken, + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + params: FloatMatrixParams, + channels: usize, + output: &mut [u8], +) { + if !float_matrix_neon_safe(params) { + convert_float_matrix_8bit_region_to_interleaved_scalar( + ScalarToken, + y_plane, + cb_plane, + cr_plane, + y_stride, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + params, + channels, + output, + ); + return; + } + + let x_end = x_start + width; + let simd_start = if subsample_x == 2 { + x_start.next_multiple_of(2).min(x_end) + } else { + x_start + }; + let simd_end = simd_start + (x_end - simd_start) / 8 * 8; + let max = vdupq_n_s32(255); + let opaque = vdup_n_u8(u8::MAX); + + for output_y in 0..height { + let source_y = y_start + output_y; + let y_row = source_y * y_stride; + let chroma_row = (source_y / subsample_y) * chroma_stride; + + for source_x in x_start..simd_start { + let output_x = source_x - x_start; + let chroma_index = chroma_row + source_x / subsample_x; + let (r, g, b) = convert_float_matrix_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_index], + cr_plane[chroma_index], + 8, + params, + ); + let output_index = (output_y * width + output_x) * channels; + output[output_index] = r as u8; + output[output_index + 1] = g as u8; + output[output_index + 2] = b as u8; + if channels == 4 { + output[output_index + 3] = u8::MAX; + } + } + + let mut source_x = simd_start; + while source_x < simd_end { + let y_values = vld1q_u16( + (&y_plane[y_row + source_x..y_row + source_x + 8]) + .try_into() + .unwrap(), + ); + let chroma_index = chroma_row + source_x / subsample_x; + let cb_values = load_float_matrix_chroma_8(cb_plane, chroma_index, subsample_x); + let cr_values = load_float_matrix_chroma_8(cr_plane, chroma_index, subsample_x); + let (r_lo, g_lo, b_lo) = convert_float_matrix_neon_4( + vget_low_u16(y_values), + vget_low_u16(cb_values), + vget_low_u16(cr_values), + params, + max, + ); + let (r_hi, g_hi, b_hi) = convert_float_matrix_neon_4( + vget_high_u16(y_values), + vget_high_u16(cb_values), + vget_high_u16(cr_values), + params, + max, + ); + let r = vqmovn_u16(vcombine_u16(vqmovun_s32(r_lo), vqmovun_s32(r_hi))); + let g = vqmovn_u16(vcombine_u16(vqmovun_s32(g_lo), vqmovun_s32(g_hi))); + let b = vqmovn_u16(vcombine_u16(vqmovun_s32(b_lo), vqmovun_s32(b_hi))); + let output_x = source_x - x_start; + let output_index = (output_y * width + output_x) * channels; + // SAFETY: validation proves the full tightly packed output size; + // this group is eight in-row pixels, so the stores write exactly + // eight times `channels` bytes within it. + unsafe { + let destination = output.as_mut_ptr().add(output_index); + if channels == 4 { + vst4_u8(destination, uint8x8x4_t(r, g, b, opaque)); + } else { + vst3_u8(destination, uint8x8x3_t(r, g, b)); + } + } + source_x += 8; + } + + for source_x in simd_end..x_end { + let output_x = source_x - x_start; + let chroma_index = chroma_row + source_x / subsample_x; + let (r, g, b) = convert_float_matrix_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_index], + cr_plane[chroma_index], + 8, + params, + ); + let output_index = (output_y * width + output_x) * channels; + output[output_index] = r as u8; + output[output_index + 1] = g as u8; + output[output_index + 2] = b as u8; + if channels == 4 { + output[output_index + 3] = u8::MAX; + } + } + } +} + +#[cfg(target_arch = "aarch64")] +#[inline(always)] +fn scale_float_matrix_neon_to_u16( + values: core::arch::aarch64::int32x4_t, + bit_depth: u8, +) -> uint16x4_t { + // SAFETY: only called from the runtime-dispatched NEON RGBA16 kernel. + unsafe { + let values = vreinterpretq_u32_s32(values); + if bit_depth >= 16 { + return vqmovn_u32(values); + } + let left = 16_i32 - i32::from(bit_depth); + let right = i32::from(bit_depth).saturating_sub(left); + let expanded = core::arch::aarch64::vorrq_u32( + vshlq_u32(values, vdupq_n_s32(left)), + vshlq_u32(values, vdupq_n_s32(-right)), + ); + vqmovn_u32(expanded) + } +} + +#[cfg(target_arch = "aarch64")] +#[allow(clippy::too_many_arguments)] +#[arcane] +fn convert_float_matrix_region_to_rgba16_neon( + _token: NeonToken, + y_plane: &[u16], + cb_plane: &[u16], + cr_plane: &[u16], + y_stride: usize, + chroma_stride: usize, + subsample_x: usize, + subsample_y: usize, + x_start: usize, + y_start: usize, + width: usize, + height: usize, + bit_depth: u8, + params: FloatMatrixParams, + output: &mut [u16], +) { + if !float_matrix_neon_safe(params) { + convert_float_matrix_region_to_rgba16_scalar( + ScalarToken, + y_plane, + cb_plane, + cr_plane, + y_stride, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + bit_depth, + params, + output, + ); + return; + } + + let max_sample = ((1_i32 << bit_depth) - 1).max(0); + let max = vdupq_n_s32(max_sample); + let opaque = vdup_n_u16(u16::MAX); + let x_end = x_start + width; + let simd_start = if subsample_x == 2 { + x_start.next_multiple_of(2).min(x_end) + } else { + x_start + }; + let simd_end = simd_start + (x_end - simd_start) / 4 * 4; + for output_y in 0..height { + let source_y = y_start + output_y; + let y_row = source_y * y_stride; + let chroma_row = (source_y / subsample_y) * chroma_stride; + for source_x in x_start..simd_start { + let output_x = source_x - x_start; + let chroma_index = chroma_row + source_x / subsample_x; + let (r, g, b) = convert_float_matrix_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_index], + cr_plane[chroma_index], + bit_depth, + params, + ); + let output_index = (output_y * width + output_x) * 4; + output[output_index] = scale_float_matrix_sample_to_u16(r, bit_depth); + output[output_index + 1] = scale_float_matrix_sample_to_u16(g, bit_depth); + output[output_index + 2] = scale_float_matrix_sample_to_u16(b, bit_depth); + output[output_index + 3] = u16::MAX; + } + + let mut source_x = simd_start; + while source_x < simd_end { + let y_values = vld1_u16( + (&y_plane[y_row + source_x..y_row + source_x + 4]) + .try_into() + .unwrap(), + ); + let chroma_index = chroma_row + source_x / subsample_x; + let cb_values = load_float_matrix_chroma_4(cb_plane, chroma_index, subsample_x); + let cr_values = load_float_matrix_chroma_4(cr_plane, chroma_index, subsample_x); + let (r, g, b) = + convert_float_matrix_neon_4(y_values, cb_values, cr_values, params, max); + let r = scale_float_matrix_neon_to_u16(r, bit_depth); + let g = scale_float_matrix_neon_to_u16(g, bit_depth); + let b = scale_float_matrix_neon_to_u16(b, bit_depth); + let output_x = source_x - x_start; + let output_index = (output_y * width + output_x) * 4; + // SAFETY: validation proves `width * height * 4` samples; this + // group is four in-row pixels and vst4 writes exactly 16 samples. + unsafe { + vst4_u16( + output.as_mut_ptr().add(output_index), + uint16x4x4_t(r, g, b, opaque), + ); + } + source_x += 4; + } + for source_x in simd_end..x_end { + let output_x = source_x - x_start; + let chroma_index = chroma_row + source_x / subsample_x; + let (r, g, b) = convert_float_matrix_pixel( + y_plane[y_row + source_x], + cb_plane[chroma_index], + cr_plane[chroma_index], + bit_depth, + params, + ); + let output_index = (output_y * width + output_x) * 4; + output[output_index] = scale_float_matrix_sample_to_u16(r, bit_depth); + output[output_index + 1] = scale_float_matrix_sample_to_u16(g, bit_depth); + output[output_index + 2] = scale_float_matrix_sample_to_u16(b, bit_depth); + output[output_index + 3] = u16::MAX; + } + } +} + /// Get color matrix coefficients for YCbCr→RGB conversion. /// /// Returns (cr_r, cb_g, cr_g, cb_b, y_bias, y_scale, rounding, shift_bits). @@ -849,4 +1557,251 @@ mod tests { assert_eq!(actual, expected, "{channels} channels"); } } + + fn full_float_params(bit_depth: u8) -> FloatMatrixParams { + FloatMatrixParams { + y_offset: 0.0, + y_scale: 1.0, + chroma_midpoint: (1_u32 << (bit_depth - 1)) as f32, + chroma_scale: 1.0, + r_cr: 1.5748, + g_cb: -0.187_324, + g_cr: -0.468_124, + b_cb: 1.8556, + } + } + + fn limited_float_params(bit_depth: u8) -> FloatMatrixParams { + FloatMatrixParams { + y_offset: (16_u32 << bit_depth.saturating_sub(8)) as f32, + y_scale: 1.1689, + chroma_midpoint: (1_u32 << (bit_depth - 1)) as f32, + chroma_scale: 1.1429, + r_cr: 1.402, + g_cb: -0.344_136, + g_cr: -0.714_136, + b_cb: 1.772, + } + } + + #[test] + fn float_matrix_8bit_dispatch_matches_scalar_for_layouts_ranges_and_tails() { + let source_width = 23_usize; + let source_height = 9_usize; + let mut state = 0xa1d3_7e29_u32; + let mut sample = || { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + ((state >> 16) & 255) as u16 + }; + let y_plane = (0..source_width * source_height) + .map(|_| sample()) + .collect::>(); + + for (subsample_x, subsample_y) in [(2_usize, 2_usize), (2, 1), (1, 1)] { + let chroma_stride = source_width.div_ceil(subsample_x); + let chroma_height = source_height.div_ceil(subsample_y); + let cb_plane = (0..chroma_stride * chroma_height) + .map(|_| sample()) + .collect::>(); + let cr_plane = (0..chroma_stride * chroma_height) + .map(|_| sample()) + .collect::>(); + + for params in [full_float_params(8), limited_float_params(8)] { + for width in [1_usize, 3, 4, 7, 8, 9, 15, 16, 17] { + let (x_start, y_start, height) = (1_usize, 1_usize, 7_usize); + for channels in [3_usize, 4_usize] { + let mut expected = vec![0_u8; width * height * channels]; + convert_float_matrix_8bit_region_to_interleaved_scalar( + ScalarToken, + &y_plane, + &cb_plane, + &cr_plane, + source_width, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + params, + channels, + &mut expected, + ); + let mut actual = vec![0_u8; expected.len()]; + convert_float_matrix_8bit_region_to_interleaved( + &y_plane, + &cb_plane, + &cr_plane, + source_width, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + params, + channels, + &mut actual, + ); + assert_eq!( + actual, expected, + "subsampling={subsample_x}x{subsample_y}, width={width}, channels={channels}" + ); + } + } + } + } + } + + #[test] + fn float_matrix_rgba16_dispatch_matches_scalar_and_exact_bit_replication() { + let source_width = 19_usize; + let source_height = 8_usize; + for bit_depth in [10_u8, 12_u8] { + let sample_max = (1_u16 << bit_depth) - 1; + let mut state = 0x59c8_0f13_u32 ^ u32::from(bit_depth); + let mut sample = || { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + ((state >> 16) as u16) & sample_max + }; + let y_plane = (0..source_width * source_height) + .map(|_| sample()) + .collect::>(); + + for (subsample_x, subsample_y) in [(2_usize, 2_usize), (2, 1), (1, 1)] { + let chroma_stride = source_width.div_ceil(subsample_x); + let chroma_height = source_height.div_ceil(subsample_y); + let cb_plane = (0..chroma_stride * chroma_height) + .map(|_| sample()) + .collect::>(); + let cr_plane = (0..chroma_stride * chroma_height) + .map(|_| sample()) + .collect::>(); + + for params in [ + full_float_params(bit_depth), + limited_float_params(bit_depth), + ] { + for width in [1_usize, 3, 4, 5, 7, 8, 9, 13, 16, 17] { + let (x_start, y_start, height) = (1_usize, 1_usize, 6_usize); + let mut expected = vec![0_u16; width * height * 4]; + convert_float_matrix_region_to_rgba16_scalar( + ScalarToken, + &y_plane, + &cb_plane, + &cr_plane, + source_width, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + bit_depth, + params, + &mut expected, + ); + let mut actual = vec![0_u16; expected.len()]; + convert_float_matrix_region_to_rgba16( + &y_plane, + &cb_plane, + &cr_plane, + source_width, + chroma_stride, + subsample_x, + subsample_y, + x_start, + y_start, + width, + height, + bit_depth, + params, + &mut actual, + ); + assert_eq!( + actual, expected, + "depth={bit_depth}, subsampling={subsample_x}x{subsample_y}, width={width}" + ); + assert!(actual.chunks_exact(4).all(|pixel| pixel[3] == u16::MAX)); + } + } + } + + for sample in [0_u16, 1, sample_max / 2, sample_max - 1, sample_max] { + let shift = 16 - u32::from(bit_depth); + let expected = ((u32::from(sample) << shift) + | (u32::from(sample) >> u32::from(bit_depth).saturating_sub(shift))) + as u16; + assert_eq!( + scale_float_matrix_sample_to_u16(sample, bit_depth), + expected + ); + } + } + } + + #[test] + fn float_matrix_dispatch_falls_back_for_non_finite_and_extreme_coefficients() { + let y_plane = [0_u16, 1, 16, 128, 254, 255, 65_534, 65_535]; + let cb_plane = [0_u16, 16, 128, 255, 512, 1023, 4095, 65_535]; + let cr_plane = [65_535_u16, 4095, 1023, 512, 255, 128, 16, 0]; + let mut variants = [full_float_params(8); 4]; + variants[0].r_cr = f32::NAN; + variants[1].g_cb = f32::INFINITY; + variants[2].g_cr = f32::NEG_INFINITY; + variants[3].b_cb = f32::MAX; + + for params in variants { + let mut expected = [0_u8; 8 * 4]; + convert_float_matrix_8bit_region_to_interleaved_scalar( + ScalarToken, + &y_plane, + &cb_plane, + &cr_plane, + 8, + 8, + 1, + 1, + 0, + 0, + 8, + 1, + params, + 4, + &mut expected, + ); + let mut actual = [0_u8; 8 * 4]; + convert_float_matrix_8bit_region_to_interleaved( + &y_plane, + &cb_plane, + &cr_plane, + 8, + 8, + 1, + 1, + 0, + 0, + 8, + 1, + params, + 4, + &mut actual, + ); + assert_eq!(actual, expected); + } + + #[cfg(target_arch = "aarch64")] + { + assert!(float_matrix_neon_safe(full_float_params(8))); + assert!(float_matrix_neon_safe(limited_float_params(12))); + assert!(!float_matrix_neon_safe(variants[0])); + assert!(!float_matrix_neon_safe(variants[1])); + assert!(!float_matrix_neon_safe(variants[2])); + assert!(!float_matrix_neon_safe(variants[3])); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 32469f4..ae48946 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8847,7 +8847,7 @@ fn decoded_heic_to_rgba8_slice( // regions. Route those directly through the shared contiguous converter // so its architecture-specific 4:2:0 kernel writes the caller's buffer // without materializing a full-frame RGBA intermediate. - if auxiliary_alpha.is_none() { + if auxiliary_alpha.is_none() || transforms.is_empty() { let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; if heic_storage_bit_depth(source_bit_depth) != 8 { return Err(DecodeError::Unsupported(format!( @@ -8877,6 +8877,9 @@ fn decoded_heic_to_rgba8_slice( .iter() .all(|step| matches!(step, ResolvedRgbaTransformStep::CleanAperture { .. })) { + if let Some(alpha) = auxiliary_alpha { + validate_auxiliary_alpha_plane(alpha, decoded.width, decoded.height)?; + } let (source_x, source_y) = transform_plan.map_destination_pixel(0, 0)?; let destination_width = transform_dimension_to_usize( "HEIC clean-aperture adapter", @@ -8888,7 +8891,7 @@ fn decoded_heic_to_rgba8_slice( "destination height", transform_plan.destination_height, )?; - return convert_heic_to_interleaved_rgb8_region_slice::<4>( + convert_heic_to_interleaved_rgb8_region_slice::<4>( &decoded, source_x, source_y, @@ -8898,7 +8901,11 @@ fn decoded_heic_to_rgba8_slice( scale_sample_to_u8, "RGBA8", ) - .map_err(DecodeError::from); + .map_err(DecodeError::from)?; + if let Some(alpha) = auxiliary_alpha { + apply_auxiliary_alpha_to_rgba8(out, decoded.width, decoded.height, alpha)?; + } + return Ok(()); } } @@ -8921,6 +8928,36 @@ fn decoded_heic_to_rgba16_native_endian_bytes( auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u8], ) -> Result<(), DecodeError> { + // `image` allocates the identity decode buffer through the global + // allocator, which is naturally aligned on the mobile targets. Reuse the + // contiguous RGBA16 converter in that common case so its high-bit-depth + // AArch64 float kernel writes directly into the hook's caller buffer. + // A deliberately unaligned public slice or any primary transform keeps + // the byte-wise generic path below. For identity alpha images, color is + // converted first and the validated auxiliary plane replaces A in-place. + if transforms.is_empty() { + let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; + if heic_storage_bit_depth(source_bit_depth) != 16 { + return Err(DecodeError::Unsupported(format!( + "HEIC storage is RGBA{}, not RGBA16", + heic_storage_bit_depth(source_bit_depth) + ))); + } + // SAFETY: `align_to_mut` only exposes the maximally aligned middle; + // conversion is used solely when it spans the entire caller slice. + let (prefix, samples, suffix) = unsafe { out.align_to_mut::() }; + if prefix.is_empty() && suffix.is_empty() { + if let Some(alpha) = auxiliary_alpha { + validate_auxiliary_alpha_plane(alpha, decoded.width, decoded.height)?; + } + convert_heic_to_rgba16_slice(&decoded, samples).map_err(DecodeError::from)?; + if let Some(alpha) = auxiliary_alpha { + apply_auxiliary_alpha_to_rgba16(samples, decoded.width, decoded.height, alpha)?; + } + return Ok(()); + } + } + let mut output = NativeEndianRgba16Output(out); decoded_heic_to_rgba_output( decoded, @@ -12171,6 +12208,41 @@ fn convert_heic_to_interleaved_rgb8_region_slice( return Ok(()); } + // libheif routes limited-range 4:2:0 and all matrix 4:2:2/4:4:4 + // conversions through its generic f32 operation. On AArch64, preserve + // that exact operation graph in four-lane NEON; other targets retain the + // scalar implementation selected by the converter module. High-bit-depth + // RGB8 has distinct image-crate rounding, so it stays on the established + // loop below (the production RGBA16 hook is accelerated separately). + if bit_depth == 8 + && let HeicChromaPlanes::Color { + u_samples, + v_samples, + chroma_width, + layout, + } = &chroma + && let Some(params) = prepared_float_matrix_params(converter.transform) + { + let (subsample_x, subsample_y) = heic_chroma_subsampling(*layout); + heic_decoder::hevc::color_convert::convert_float_matrix_8bit_region_to_interleaved( + &decoded.y_plane.samples, + u_samples, + v_samples, + source_width, + *chroma_width, + subsample_x as usize, + subsample_y as usize, + source_x_start, + source_y_start, + width, + height, + params, + CHANNELS, + out, + ); + return Ok(()); + } + for output_y in 0..height { let source_y = source_y_start + output_y; let row_start = source_y * source_width; @@ -12293,6 +12365,34 @@ fn convert_heic_to_rgba16_slice( decoded.layout == HeicPixelLayout::Yuv420, ); + if let HeicChromaPlanes::Color { + u_samples, + v_samples, + chroma_width, + layout, + } = &chroma + && let Some(params) = prepared_float_matrix_params(converter.transform) + { + let (subsample_x, subsample_y) = heic_chroma_subsampling(*layout); + heic_decoder::hevc::color_convert::convert_float_matrix_region_to_rgba16( + &decoded.y_plane.samples, + u_samples, + v_samples, + width, + *chroma_width, + subsample_x as usize, + subsample_y as usize, + 0, + 0, + width, + height, + bit_depth, + params, + out, + ); + return Ok(()); + } + for y in 0..height { let row_start = y * width; let out_row_start = row_start * 4; @@ -12762,6 +12862,41 @@ enum PreparedYcbcrTransform { }, } +fn prepared_float_matrix_params( + transform: PreparedYcbcrTransform, +) -> Option { + let (coeffs, y_offset, y_scale, chroma_midpoint, chroma_scale) = match transform { + PreparedYcbcrTransform::MatrixFullFloat { + coeffs, + chroma_midpoint, + } => (coeffs, 0.0_f32, 1.0_f32, chroma_midpoint as f32, 1.0_f32), + PreparedYcbcrTransform::MatrixLimited { + coeffs, + limited_offset, + chroma_midpoint, + } => ( + coeffs, + limited_offset, + 1.1689_f32, + chroma_midpoint, + 1.1429_f32, + ), + PreparedYcbcrTransform::IdentityFull + | PreparedYcbcrTransform::IdentityLimited { .. } + | PreparedYcbcrTransform::MatrixFull { .. } => return None, + }; + Some(heic_decoder::hevc::color_convert::FloatMatrixParams { + y_offset, + y_scale, + chroma_midpoint, + chroma_scale, + r_cr: coeffs.r_cr_f32, + g_cb: coeffs.g_cb_f32, + g_cr: coeffs.g_cr_f32, + b_cb: coeffs.b_cb_f32, + }) +} + #[derive(Clone, Copy)] struct PreparedYcbcrToRgb { bit_depth: u8, @@ -14376,6 +14511,87 @@ mod tests { assert_eq!(actual, expected); } + #[cfg(feature = "image-integration")] + #[test] + fn identity_alpha_float_matrix_slice_paths_match_owned_output() { + let mut image8 = synthetic_yuv_image(super::HeicPixelLayout::Yuv422); + image8.ycbcr_range = super::YCbCrRange::Limited; + let alpha8 = super::HeicAuxiliaryAlphaPlane { + width: image8.width, + height: image8.height, + bit_depth: 8, + samples: (0..image8.width * image8.height) + .map(|index| ((index * 29) & 255) as u16) + .collect(), + }; + let owned8 = super::decoded_heic_to_rgba_image(image8.clone(), &[], Some(&alpha8), None) + .expect("owned RGBA8 alpha conversion should succeed"); + let expected8 = match owned8.pixels { + super::DecodedRgbaPixels::U8(pixels) => pixels, + other => panic!("expected RGBA8 pixels, got {other:?}"), + }; + let mut actual8 = vec![0_u8; expected8.len()]; + super::decoded_heic_to_rgba8_slice(image8, &[], Some(&alpha8), &mut actual8) + .expect("identity RGBA8 alpha slice conversion should succeed"); + assert_eq!(actual8, expected8); + + let mut image16 = synthetic_yuv_image(super::HeicPixelLayout::Yuv420); + image16.bit_depth_luma = 10; + image16.bit_depth_chroma = 10; + for sample in &mut image16.y_plane.samples { + *sample *= 4; + } + for sample in image16 + .u_plane + .as_mut() + .expect("U plane") + .samples + .iter_mut() + { + *sample *= 4; + } + for sample in image16 + .v_plane + .as_mut() + .expect("V plane") + .samples + .iter_mut() + { + *sample *= 4; + } + let alpha16 = super::HeicAuxiliaryAlphaPlane { + width: image16.width, + height: image16.height, + bit_depth: 10, + samples: (0..image16.width * image16.height) + .map(|index| ((index * 37) & 1023) as u16) + .collect(), + }; + let owned16 = super::decoded_heic_to_rgba_image(image16.clone(), &[], Some(&alpha16), None) + .expect("owned RGBA16 alpha conversion should succeed"); + let expected16 = match owned16.pixels { + super::DecodedRgbaPixels::U16(pixels) => pixels, + other => panic!("expected RGBA16 pixels, got {other:?}"), + }; + let mut actual16 = vec![0_u16; expected16.len()]; + // SAFETY: the byte view spans the initialized u16 allocation exactly; + // the production API writes native-endian u16 bytes. + let actual16_bytes = unsafe { + std::slice::from_raw_parts_mut( + actual16.as_mut_ptr().cast::(), + actual16.len() * std::mem::size_of::(), + ) + }; + super::decoded_heic_to_rgba16_native_endian_bytes( + image16, + &[], + Some(&alpha16), + actual16_bytes, + ) + .expect("identity RGBA16 alpha byte conversion should succeed"); + assert_eq!(actual16, expected16); + } + #[cfg(feature = "image-integration")] #[test] fn transformed_avif_rgba16_byte_output_matches_owned_path() { From 23de425b3128a7b9e3d188201a41cd363a1232a7 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 16:20:38 +0530 Subject: [PATCH 23/28] docs: record rejected CABAC bundle experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index fa74aae..6e66de1 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -116,6 +116,18 @@ Several individually positive but sub-gate residual/CABAC experiments were rejec Combine the mutually compatible ones into a single candidate. The most promising component is bypass-bin batching: `decode_bypass_bits` and sign-run decoding still loop one bin at a time; a modest 2–4 bit unroll with one shared refill check should help without repeating the failed 64-bit code-window redesign (x0.906 — do not retry). Avoid large fused lookup tables (2 KiB variant) since cache pressure is worse on mobile. +### Experiment result (2026-07-18): rejected + +Implemented a deliberately smaller coherent bundle after auditing the accepted history: exact 2/3/4-bin CABAC bypass chunks with one refill-boundary check and fixed shift/subtract reconstruction, plus 240 bytes of scan-position-ordered significance-context tables. The failed 64-bit code window and 2 KiB fused lookup were not retried, and already-accepted helper/scan work was not duplicated. A proposed unchecked residual-context access was removed during the agent's final safety audit because malformed SPS transform sizes made its local proof insufficient without changing error precedence. Tracing and malformed CABAC states retained the scalar path. + +The candidate passed exhaustive bypass/refill/EOF/malformed-state/EGk differential tests, tracing and portability suites, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints but regressed on every target: + +- Apple Silicon desktop: `0.965703x` (baseline 1068.061 ms, candidate 1105.993 ms; -3.43%). +- Pixel 4 / Android 13: `0.972145x` (baseline 7216.484 ms, candidate 7423.260 ms; -2.79%; thermal status 0 before/after). +- iPhone 11 Pro / iOS 26.5: `0.995123x` (baseline 2201.718 ms, candidate 2212.509 ms; -0.49%; nominal thermal state throughout). + +The implementation was reverted. The safe retained subset added instruction/control overhead rather than reducing end-to-end CABAC cost on the accepted decoder stack. + ## 8. Remove full-plane clones in SAO SAO edge-offset processing clones the entire Y, Cb, and Cr planes (`sao.rs`) to preserve original neighbor values, plus a `deblock_flags` clone per pass. On desktop this measured only ~x1.026 when addressed, but full-plane allocation and memcpy cost relatively more on mobile's smaller caches and lower memory bandwidth, and also costs energy. From f47614efa8d3a421f1019c66d05f0fc2ef8d0916 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 16:43:30 +0530 Subject: [PATCH 24/28] docs: record SAO clone experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 6e66de1..3f4adcb 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -134,6 +134,18 @@ SAO edge-offset processing clones the entire Y, Cb, and Cr planes (`sao.rs`) to Replace the full-plane copies with CTB-local (or row-band) temporary buffers, or a direct source-to-destination pass. Re-measure on a mobile device rather than the desktop host before rejecting. +### Experiment result (2026-07-18): rejected + +Reworked SAO into planewise raster passes backed by a two-row ring of original samples, removing the full Y/Cb/Cr plane clones and the per-pass deblock-flag clone. Peak SAO scratch fell from roughly 34.9 MiB for a representative 4032-pixel-wide image to about 15.75 KiB, while preserving original left, top, top-left, and top-right neighbors across CTB and band boundaries. The candidate passed clone-reference differential tests, 76 library tests, strict Clippy, portability builds, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +The 225-file production `image`-crate hook A/B benchmark retained identical output fingerprints, but the speed result was not repeatable: + +- Apple Silicon desktop: `0.988078x` (baseline 1066.698 ms, candidate 1079.568 ms; -1.19%). +- Pixel 4 / Android 13: the first B/C/C/B round was `1.041985x` (baseline 7380.305 ms, candidate 7082.865 ms; +4.20%), but a reversed C/B/B/C confirmation was `0.984376x` (baseline 7107.233 ms, candidate 7220.038 ms; -1.56%). Across all eight invocations the result was only `1.012909x` (+1.29%), with thermal status 0 throughout. +- iPhone 11 Pro / iOS 26.5: `1.005008x` (baseline 2197.791 ms, candidate 2186.840 ms; +0.50%); the run finished at thermal state fair and neither interleaved pair cleared 2%. + +The implementation was reverted. Its memory reduction is attractive, but the requested production-hook speed win did not survive confirmation and the desktop result regressed, so it does not belong on this speed-focused branch. + ## 9. Avoid full-plane rotation allocations for non-grid oriented images Grid images now use accepted affine row-stride placement, but non-grid images with `irot`/`imir` still go through full-plane rotation/mirror functions (`transforms.rs`) that allocate a complete new plane and use per-pixel multiplicative indexing. A prior bounded-band attempt was host-neutral, but the extra full-frame traffic is more expensive on mobile. From 04b81a86245bd34164c24e77702a1124b4a68d85 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 17:02:51 +0530 Subject: [PATCH 25/28] docs: record oriented output experiment --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 3f4adcb..68ce8f3 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -152,6 +152,18 @@ Grid images now use accepted affine row-stride placement, but non-grid images wi Fold orientation into the color-conversion write step with blocked (cache-tiled) transpose for 90/270 rotations, writing directly into the caller's buffer, mirroring the approach that already succeeded for grids. +### Experiment result (2026-07-18): rejected + +Implemented an orientation-only coded-image hook finalizer that converted source-order 128x32 YUV regions with the existing SIMD kernels, then affine-transposed or mirrored each bounded RGBA tile directly into the caller's output. Scratch was limited to 16 KiB for RGBA8 or 32 KiB for RGBA16. Clean apertures retained the generic path; focused differential tests covered YUV400/420/422/444, full/limited range, 8/10/12-bit storage, alpha, every rotation and mirror, composites, and partial edge tiles. The candidate passed 73 library tests, 8 CLI tests, strict Clippy, portability builds, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +A targeted synthetic 3024x4032 8-bit 4:2:0 90-degree production-hook finalizer test was about 6.8x faster (34.27 ms candidate versus 233.32 ms generic for two conversions), confirming that the technique works when exercised. However, a public-parser survey of the fixed 225-file benchmark found 54 coded HEIC primaries and 3 grids: none of the coded primaries had an effective `irot`/`imir`, while the two oriented HEICs were grids already handled by the accepted grid path. The full production-hook A/B therefore measured only noise, with identical fingerprints: + +- Apple Silicon desktop: `1.003051x` (baseline 1070.601 ms, candidate 1067.344 ms; +0.31%). +- Pixel 4 / Android 13: the first B/C/C/B round reported an impossible-for-an-unexercised-path `1.139358x`, but the reversed C/B/B/C confirmation flipped to `0.970632x` (baseline 6786.403 ms, candidate 6991.737 ms; -2.94%). Thermal status was 0 throughout, but the extreme run-to-run frequency variation makes this non-evidence. +- iPhone 11 Pro / iOS 26.5: `1.006080x` (baseline 2177.539 ms, candidate 2164.380 ms; +0.61%; nominal thermal state throughout). + +The implementation was reverted. It is a strong targeted optimization for a workload absent from the agreed corpus, but a 531-line specialized path with no measurable full-hook benefit does not meet this experiment's acceptance rule. + ## 10. Investigate WPP row-parallel reconstruction for non-grid images WPP entry points are already parsed and per-row CABAC reinitialization works (`ctu.rs`), but CTU rows still decode serially. Grid images get parallelism from tiles; large single-coded-image HEICs do not. From 214af1d7f6a7123420b7b12b5905d207a0fb7504 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 17:12:47 +0530 Subject: [PATCH 26/28] docs: record WPP feasibility experiment --- .agents/heic-speed-optimization-suggestions.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 68ce8f3..5bede70 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -169,3 +169,11 @@ The implementation was reverted. It is a strong targeted optimization for a work WPP entry points are already parsed and per-row CABAC reinitialization works (`ctu.rs`), but CTU rows still decode serially. Grid images get parallelism from tiles; large single-coded-image HEICs do not. For WPP-encoded streams, decode CTU rows on a wavefront with the standard two-CTU lag. This is a large refactor and only pays off for WPP-encoded files, so first survey how common WPP is in the real-world corpus before committing. + +### Experiment result (2026-07-18): rejected before implementation + +Instrumented the decoder temporarily at the actually selected parsed PPS and slice entry points, ran the fixed corpora, then removed the instrumentation. WPP is highly prevalent: the 225-file hook corpus contains 57 HEVC-decoded files (54 coded primaries and 3 grids), of which 46 coded primaries and all 3 grids use WPP. That is 202 WPP streams (46 primaries plus 156 grid tiles) versus 8 non-WPP streams, and every WPP stream has exactly `CTB rows - 1` parsed entry points. WPP represents 118,919,616 of 119,215,460 HEVC output pixels; excluding already tile-parallel grids, its coded primaries contribute 38,659,176 pixels, or 32.4% of the complete 119,340,735-pixel hook suite. The 272-file correctness corpus adds one WPP stream intentionally rejected for PCM, for 203 WPP versus 8 non-WPP selected streams. + +No bounded implementation survived the safety/design review. CABAC row substreams are independently seekable, but `decode_ctu` interleaves entropy parsing and intra reconstruction through one full-picture `&mut DecodedFrame`, while `SliceContext` owns shared full-picture CT-depth, intra-mode, QP, deblock, and SAO maps. Correct workers need the prior row's post-CTU-1 context, a two-CTU release/acquire wavefront, published above/above-right samples and map state, disjoint output ownership, SAO merge coordination, and deterministic raster-order error precedence. A whole-frame mutex serializes the work; aliased mutable frames are undefined behavior; per-worker frame clones multiply mobile memory and add full-frame copies. Nested WPP inside 156 already grid-parallel tiles would also oversubscribe mobile CPUs. + +The temporary survey code was removed and all 79 all-feature tests passed with a clean tree. With no safe source candidate, a candidate A/B benchmark would be identical to the accepted baseline and was not manufactured. A credible future implementation first needs a CTU-row-owned frame/map abstraction with small immutable published top-border halos and ordered error propagation; deblocking and SAO can remain serial after reconstruction. That prerequisite is a substantial architecture project rather than a reviewable optimization for this experiment, so suggestion 10 was rejected without weakening safety or correctness. From 20ca446c852f2466c3dd0b426282de198ae9f2bd Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 18 Jul 2026 17:24:40 +0530 Subject: [PATCH 27/28] docs: record final optimization audit --- .agents/heic-speed-optimization-suggestions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md index 5bede70..505641c 100644 --- a/.agents/heic-speed-optimization-suggestions.md +++ b/.agents/heic-speed-optimization-suggestions.md @@ -177,3 +177,15 @@ Instrumented the decoder temporarily at the actually selected parsed PPS and sli No bounded implementation survived the safety/design review. CABAC row substreams are independently seekable, but `decode_ctu` interleaves entropy parsing and intra reconstruction through one full-picture `&mut DecodedFrame`, while `SliceContext` owns shared full-picture CT-depth, intra-mode, QP, deblock, and SAO maps. Correct workers need the prior row's post-CTU-1 context, a two-CTU release/acquire wavefront, published above/above-right samples and map state, disjoint output ownership, SAO merge coordination, and deterministic raster-order error precedence. A whole-frame mutex serializes the work; aliased mutable frames are undefined behavior; per-worker frame clones multiply mobile memory and add full-frame copies. Nested WPP inside 156 already grid-parallel tiles would also oversubscribe mobile CPUs. The temporary survey code was removed and all 79 all-feature tests passed with a clean tree. With no safe source candidate, a candidate A/B benchmark would be identical to the accepted baseline and was not manufactured. A credible future implementation first needs a CTU-row-owned frame/map abstraction with small immutable published top-border halos and ordered error propagation; deblocking and SAO can remain serial after reconstruction. That prerequisite is a substantial architecture project rather than a reviewable optimization for this experiment, so suggestion 10 was rejected without weakening safety or correctness. + +## Final accepted-stack audit (2026-07-18) + +Only suggestions 4 (bounded ordered grid-tile streaming) and 6 (expanded AArch64 color-conversion SIMD) were retained. The final branch passed the complete validator gate again after all ten experiments: 272 files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. + +Fresh builds of the original pre-experiment source (`7c17018`) and final accepted source were compared through the complete 225-file production `image`-crate hook. Every run produced fingerprint `15185db2471aa39d`: + +- Apple Silicon desktop: `1.068302x` (original 1170.242 ms, final 1095.423 ms; +6.83%). +- Pixel 4 / Android 13: `1.083983x` (original 7266.524 ms, final 6703.542 ms; +8.40%). Both interleaved pairs improved; thermal status was 0 before and after. +- iPhone 11 Pro / iOS 26.5: the reversed-order confirmation was `1.044781x` (original 2252.898 ms, final 2156.335 ms; +4.48%). Across both four-run orderings the result was `1.056770x` (+5.68%); the phone reached thermal state fair, so the balanced reversed confirmation is the conservative figure. + +The final accepted stack is therefore faster on all three targets through the required production hook while preserving full-corpus correctness. From 03cebf0ef862d8d78f6d1013005f56cd2517b4c0 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Mon, 20 Jul 2026 11:18:27 +0530 Subject: [PATCH 28/28] cleanup --- .../heic-speed-optimization-suggestions.md | 191 --- .gitignore | 1 - README.md | 6 - autoresearch/README.md | 155 --- autoresearch/benchmark-corpus.txt | 8 - autoresearch/benchmark.rs | 272 ---- autoresearch/program.md | 91 -- scripts/autoresearch.sh | 1096 ----------------- 8 files changed, 1820 deletions(-) delete mode 100644 .agents/heic-speed-optimization-suggestions.md delete mode 100644 autoresearch/README.md delete mode 100644 autoresearch/benchmark-corpus.txt delete mode 100644 autoresearch/benchmark.rs delete mode 100644 autoresearch/program.md delete mode 100755 scripts/autoresearch.sh diff --git a/.agents/heic-speed-optimization-suggestions.md b/.agents/heic-speed-optimization-suggestions.md deleted file mode 100644 index 505641c..0000000 --- a/.agents/heic-speed-optimization-suggestions.md +++ /dev/null @@ -1,191 +0,0 @@ -# HEIC Decoder: Further Speed Optimization Suggestions - -## 1. Parse HEVC NAL units and metadata only once - -The current decode path can traverse and parse the same HEVC stream multiple times for metadata extraction, VCL validation, and the actual decode. Grid images repeat this setup for every tile. - -Refactor the decoder boundary so a single NAL pass supplies both the decoded frame and parsed metadata. This should reduce RBSP conversion, SPS parsing, temporary allocations, and per-tile setup costs, with the largest benefit expected on tiled images and smaller mobile images. - -### Experiment result (2026-07-18): rejected - -Implemented a source-only candidate that walked hvcC and payload NALs directly, reused the first parsed SPS across metadata/backend decode, handed the image-hook probe SPS into pixel decode (including the first grid tile), and removed the normalized intermediate stream allocation. The candidate passed formatting, Clippy, all 63 library tests, and the complete validator run: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -The 225-file production `image`-crate hook A/B benchmark was nevertheless neutral on every target, with identical output fingerprints: - -- Apple Silicon desktop: `0.997307x` (baseline 1141.273 ms, candidate 1144.355 ms; -0.27%). -- Pixel 4 / Android 13: `1.005837x` (baseline 7020.472 ms, candidate 6979.729 ms; +0.58%; thermal status 0 before/after). -- iPhone 11 Pro / iOS 26.5: `0.999721x` (baseline 2312.484 ms, candidate 2313.130 ms; -0.03%). - -This did not meet the repeatable 2% improvement gate on either mobile device or desktop, so the implementation was reverted. The result suggests NAL normalization/SPS re-parsing is not a material share of end-to-end production-hook latency on this corpus. - -## 2. Avoid materializing cropped YUV planes - -Clean-aperture crops and non-default strides currently cause full Y, U, and V plane copies before color conversion. Even a one-pixel crop can therefore add substantial memory traffic. - -Represent decoded planes as stride-aware views containing an origin, dimensions, and row stride, then convert the cropped region directly. Alternatively, add a finalizer that converts directly from the decoded frame into the caller's RGB/RGBA buffer. This should lower both latency and peak memory use on mobile devices. - -### Experiment result (2026-07-18): rejected - -Implemented a coded-image `image`-hook fast path that retained backend-owned planes as validated stride/origin views and converted directly into the caller's RGBA8/RGBA16 buffer. It covered YUV400/420/422/444, full/limited range, 8/10/12-bit content, auxiliary alpha, clean aperture, mirror, and rotation; full-range 8-bit 4:2:0 retained the region SIMD kernel. Grid paths were deliberately left for suggestion 3. The candidate passed 66 library tests, strict Clippy, portability checks, focused adapter/direct parity fixtures, and the complete validator run: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints but did not improve end-to-end latency: - -- Apple Silicon desktop: `0.994873x` (baseline 1141.117 ms, candidate 1146.998 ms; -0.51%). -- Pixel 4 / Android 13: `0.965463x` (baseline 6718.879 ms, candidate 6959.226 ms; -3.45%; thermal status 0 before/after). -- iPhone 11 Pro / iOS 26.5: `1.001774x` (baseline 2294.987 ms, candidate 2290.924 ms; +0.18%; thermal state nominal throughout). - -The implementation was reverted. Avoiding conformance-window plane copies was not a material production-hook win on this corpus, and the roughly 860-line specialized finalizer was not justified—particularly with a clear Android regression. - -## 3. Convert grid tiles directly into the final output - -Grid tiles are converted into temporary RGBA buffers before being copied or transformed into the destination image. - -For identity orientation, add an output-stride-aware NEON conversion path that writes each tile directly into its final destination region. For 90-degree and 270-degree orientations, investigate blocked conversion and transpose operations to retain cache locality. Keep this experiment narrower than the previously rejected general affine-conversion implementation. - -### Experiment result (2026-07-18): rejected - -Implemented the deliberately narrow production-hook path: opaque, identity-oriented RGBA8 grids converted each validated and clipped YUV tile directly into its final caller-buffer region using the canvas row stride. Full-range 8-bit 4:2:0 kept the AArch64 NEON kernel with strided stores, while the portable scalar converter covered the other 8-bit layouts and ranges. Rotations, mirrors, clean apertures, auxiliary alpha, and RGBA16 stayed on the established temporary-tile fallback. The candidate passed 65 library tests, strict Clippy, all portability builds, focused real-grid parity, and the complete validator run: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints and showed small improvements, but none was repeatably above the 2% gate: - -- Apple Silicon desktop: `1.012266x` (baseline 1135.645 ms, candidate 1121.884 ms; +1.23%). -- Pixel 4 / Android 13: `1.006170x` (baseline 7055.583 ms, candidate 7012.319 ms; +0.62%; thermal status 0 before/after). -- iPhone 11 Pro / iOS 26.5: `1.019976x` (baseline 2284.174 ms, candidate 2239.440 ms; +2.00%; thermal state nominal throughout). The two interleaved iPhone pairs were only +0.3% and +3.6%, so this borderline mean was not repeatable. - -The implementation was reverted. Direct identity-grid conversion reduced work as intended, but it affects too little of the full production-hook corpus to justify the added specialized path under the experiment's acceptance rule. - -## 4. Make grid and frame parallelism mobile-aware - -Fixed concurrency and use of the global Rayon pool can oversubscribe mobile CPUs, increase memory pressure, and cause thermal throttling—particularly when an application decodes multiple images concurrently. - -Expose a concurrency or memory budget, support a caller-provided worker pool, and benchmark conservative mobile defaults such as two to four workers. Prefer ordered streaming of completed tiles so fewer decoded tile buffers need to remain live. Evaluate peak RSS and energy consumption alongside latency. - -### Experiment result (2026-07-18): accepted - -Replaced the grid decoder's fixed parallel batches with a bounded ordered stream on the existing Rayon pool. Active jobs and completed out-of-order tiles retain per-tile memory permits until row-major consumption, preserving the 64 MiB estimate budget; unestimable tiles drain earlier work and decode synchronously. Validation, errors, panics, and tile paste remain observable in row-major order. The first sub-variant also reduced iOS/Android to four workers, but physical-device testing showed that default was harmful: Pixel full-hook latency regressed 7.28% and iPhone was neutral. The final candidate therefore retained the existing eight-worker ceiling on all targets while keeping bounded streaming. - -The final candidate passed formatting, strict Clippy, 76 focused/library/CLI tests, iOS/Android/Wasm portability builds, and two complete validator runs after the scheduler revision: each accounted for all 272 corpus files, passed 219 pixel-oracle cases and 219 production image-hook parity checks, and had zero failures. The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints: - -- Apple Silicon desktop: `1.055055x` (baseline 1144.134 ms, candidate 1084.431 ms; +5.51%). -- Pixel 4 / Android 13: the initial revised B/C/C/B set was noisy because of one unusually fast baseline run; four additional reversed C/B/B/C invocations showed `1.022607x` (baseline 7402.952 ms, candidate 7239.291 ms; +2.26%). Across all eight invocations the result was `0.993735x` (-0.63%), with no repeatable regression and thermal status 0 throughout. -- iPhone 11 Pro / iOS 26.5: `1.042723x` (baseline 2315.621 ms, candidate 2220.744 ms; +4.27%). Both interleaved pairs improved by roughly 4%; thermal state was nominal until the tail of the final baseline run. - -The ordered streaming implementation was kept. It clears the 2% gate repeatably on desktop and iPhone, reduces batch-barrier idle time, and keeps the existing conservative in-flight memory bound. No direct energy counter was available; thermal state was used as the mobile guardrail. - -## 5. Evaluate LTO and profile-guided optimization - -The decoder's CABAC and residual paths contain many small helpers and data-dependent branches that may benefit from whole-program optimization and profile feedback. - -Benchmark ThinLTO with a single codegen unit in the consuming application, followed by PGO trained on a stratified HEIC corpus. Measure binary size and cold-start performance as guardrails. Apply release-profile settings in the final application workspace because dependency crates cannot reliably control the consumer's Cargo profile. - -### Experiment result (2026-07-18): rejected - -Implemented an opt-in final-consumer build wrapper rather than a misleading dependency profile. It supported default release, ThinLTO with one codegen unit, and target-specific PGO instrument/train/merge/use builds with compiler/LLVM/target/manifest validation. PGO was trained independently on desktop, Pixel, and iPhone using a fixed 15-file set spanning six real-camera images plus bit-depth, chroma, lossless, scaling-list, transform-skip, and WPP cases; the full 225-file hook corpus remained evaluation-only. Android and iOS raw profiles were generated on their physical devices and retrieved successfully. - -The unchanged decoder source passed the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. Every default, ThinLTO, and PGO evaluation artifact also produced the identical 225-file hook fingerprint. Full-corpus timings were: - -- Apple Silicon desktop: ThinLTO `1.001877x` (default 1101.577 ms, ThinLTO 1099.513 ms; +0.19%); PGO `0.981025x` (1122.884 ms; -1.90%). -- Pixel 4 / Android 13: ThinLTO `0.997096x` (default 6877.490 ms, ThinLTO 6897.521 ms; -0.29%); PGO `1.018734x` (6751.014 ms; +1.87%; thermal status 0). The PGO result remained below the 2% gate. -- iPhone 11 Pro / iOS 26.5: ThinLTO `1.000189x` (default 2154.960 ms, ThinLTO 2154.553 ms; +0.02%; nominal thermal state). The PGO confirmation was `0.984277x` (thermally loaded default 2275.528 ms, PGO 2311.878 ms; -1.57%); its ordering biased the final default slower, so throttling could not be hiding a qualifying PGO win. - -Binary size improved despite neutral latency: ThinLTO reduced the final executable by roughly 5.5–12.1% and PGO by 11.0–17.1%, depending on target. A 40-launch tiny-hook guardrail was also neutral at about 3.0 ms median on desktop. Because neither build mode met the production-hook speed gate, the wrapper, training manifest, and documentation were reverted rather than adding maintenance and target-specific training complexity for a size-only benefit. - -## 6. Extend SIMD color conversion beyond full-range 8-bit 4:2:0 - -The NEON fast path is gated to 8-bit, full-range, 4:2:0, opaque matrix content only (`src/lib.rs` `PreparedYcbcrTransform` selection). Everything else — 10-bit HDR photos (`MatrixFullFloat`), limited/video-range YCbCr (`MatrixLimited`), 4:2:2/4:4:4, and alpha-bearing images — falls back to a per-pixel scalar float loop with `fmaf_parity` calls. Recent iPhones (HDR) and many Android cameras produce exactly these formats, so on mobile this is likely the largest untapped win. - -Add NEON f32 kernels for the `MatrixFullFloat` and `MatrixLimited` paths. Bit-exact parity with the libheif float oracle is achievable because `fmaf_parity` already uses fused `mul_add` on aarch64 and `vfmaq_f32` is also fused. Also consider ARMv7 NEON and WASM SIMD128 variants, which currently have zero SIMD coverage. - -### Experiment result (2026-07-18): accepted - -Added AArch64 NEON float-matrix conversion for full/limited-range YUV 4:2:0, 4:2:2, and 4:4:4, covering RGB8/RGBA8 at 8-bit and RGBA16 at high bit depth. The production image hook gained a naturally aligned contiguous RGBA16 path, and identity alpha-bearing coded/grid images use SIMD color conversion before the existing alpha composition. Scalar fallbacks remain for non-AArch64, identity/monochrome matrices, deliberately unaligned RGBA16 buffers, transformed coded images, and non-finite/extreme coefficients. The kernels preserve the scalar FMA graph, limited-range subtraction/scaling, `+0.5` truncation/clipping, odd chroma phase, tails, and exact 10/12-bit-to-u16 bit replication. - -The candidate passed 71 library tests, 8 CLI tests, strict Clippy, no-default and iOS/Android/Wasm builds, synthetic SIMD/scalar parity across all supported layouts and boundaries, 12 real affected-format fixture comparisons, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints: - -- Apple Silicon desktop: `1.014672x` (baseline 1089.079 ms, candidate 1073.331 ms; +1.47%). -- Pixel 4 / Android 13: `1.056063x` (baseline 7057.459 ms, candidate 6682.801 ms; +5.61%; thermal status 0 before/after). Both interleaved pairs improved. -- iPhone 11 Pro / iOS 26.5: `1.002181x` (baseline 2253.249 ms, candidate 2248.344 ms; +0.22%; candidate runs stayed nominal and pairwise neutral). - -The implementation was kept. Although the affected formats are a small share of corpus pixels and the architecture-specific patch is substantial, the repeatable 5.6% Android full-hook win clears the gate without a material desktop or iPhone regression. - -## 7. Batch residual/CABAC micro-optimizations into one coherent change - -Several individually positive but sub-gate residual/CABAC experiments were rejected: caller-side bounds-check elimination (x1.007), fused range+transition lookup (x1.008), hoisted/flattened helpers (x1.012), scan-ordered context tables (x1.011), and bypass-bin batching (x1.019–x1.025). The journal shows that combining compatible near-threshold ideas cleared the gate twice before (attempts 33 and 36). - -Combine the mutually compatible ones into a single candidate. The most promising component is bypass-bin batching: `decode_bypass_bits` and sign-run decoding still loop one bin at a time; a modest 2–4 bit unroll with one shared refill check should help without repeating the failed 64-bit code-window redesign (x0.906 — do not retry). Avoid large fused lookup tables (2 KiB variant) since cache pressure is worse on mobile. - -### Experiment result (2026-07-18): rejected - -Implemented a deliberately smaller coherent bundle after auditing the accepted history: exact 2/3/4-bin CABAC bypass chunks with one refill-boundary check and fixed shift/subtract reconstruction, plus 240 bytes of scan-position-ordered significance-context tables. The failed 64-bit code window and 2 KiB fused lookup were not retried, and already-accepted helper/scan work was not duplicated. A proposed unchecked residual-context access was removed during the agent's final safety audit because malformed SPS transform sizes made its local proof insufficient without changing error precedence. Tracing and malformed CABAC states retained the scalar path. - -The candidate passed exhaustive bypass/refill/EOF/malformed-state/EGk differential tests, tracing and portability suites, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. The 225-file production `image`-crate hook A/B benchmark retained identical fingerprints but regressed on every target: - -- Apple Silicon desktop: `0.965703x` (baseline 1068.061 ms, candidate 1105.993 ms; -3.43%). -- Pixel 4 / Android 13: `0.972145x` (baseline 7216.484 ms, candidate 7423.260 ms; -2.79%; thermal status 0 before/after). -- iPhone 11 Pro / iOS 26.5: `0.995123x` (baseline 2201.718 ms, candidate 2212.509 ms; -0.49%; nominal thermal state throughout). - -The implementation was reverted. The safe retained subset added instruction/control overhead rather than reducing end-to-end CABAC cost on the accepted decoder stack. - -## 8. Remove full-plane clones in SAO - -SAO edge-offset processing clones the entire Y, Cb, and Cr planes (`sao.rs`) to preserve original neighbor values, plus a `deblock_flags` clone per pass. On desktop this measured only ~x1.026 when addressed, but full-plane allocation and memcpy cost relatively more on mobile's smaller caches and lower memory bandwidth, and also costs energy. - -Replace the full-plane copies with CTB-local (or row-band) temporary buffers, or a direct source-to-destination pass. Re-measure on a mobile device rather than the desktop host before rejecting. - -### Experiment result (2026-07-18): rejected - -Reworked SAO into planewise raster passes backed by a two-row ring of original samples, removing the full Y/Cb/Cr plane clones and the per-pass deblock-flag clone. Peak SAO scratch fell from roughly 34.9 MiB for a representative 4032-pixel-wide image to about 15.75 KiB, while preserving original left, top, top-left, and top-right neighbors across CTB and band boundaries. The candidate passed clone-reference differential tests, 76 library tests, strict Clippy, portability builds, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -The 225-file production `image`-crate hook A/B benchmark retained identical output fingerprints, but the speed result was not repeatable: - -- Apple Silicon desktop: `0.988078x` (baseline 1066.698 ms, candidate 1079.568 ms; -1.19%). -- Pixel 4 / Android 13: the first B/C/C/B round was `1.041985x` (baseline 7380.305 ms, candidate 7082.865 ms; +4.20%), but a reversed C/B/B/C confirmation was `0.984376x` (baseline 7107.233 ms, candidate 7220.038 ms; -1.56%). Across all eight invocations the result was only `1.012909x` (+1.29%), with thermal status 0 throughout. -- iPhone 11 Pro / iOS 26.5: `1.005008x` (baseline 2197.791 ms, candidate 2186.840 ms; +0.50%); the run finished at thermal state fair and neither interleaved pair cleared 2%. - -The implementation was reverted. Its memory reduction is attractive, but the requested production-hook speed win did not survive confirmation and the desktop result regressed, so it does not belong on this speed-focused branch. - -## 9. Avoid full-plane rotation allocations for non-grid oriented images - -Grid images now use accepted affine row-stride placement, but non-grid images with `irot`/`imir` still go through full-plane rotation/mirror functions (`transforms.rs`) that allocate a complete new plane and use per-pixel multiplicative indexing. A prior bounded-band attempt was host-neutral, but the extra full-frame traffic is more expensive on mobile. - -Fold orientation into the color-conversion write step with blocked (cache-tiled) transpose for 90/270 rotations, writing directly into the caller's buffer, mirroring the approach that already succeeded for grids. - -### Experiment result (2026-07-18): rejected - -Implemented an orientation-only coded-image hook finalizer that converted source-order 128x32 YUV regions with the existing SIMD kernels, then affine-transposed or mirrored each bounded RGBA tile directly into the caller's output. Scratch was limited to 16 KiB for RGBA8 or 32 KiB for RGBA16. Clean apertures retained the generic path; focused differential tests covered YUV400/420/422/444, full/limited range, 8/10/12-bit storage, alpha, every rotation and mirror, composites, and partial edge tiles. The candidate passed 73 library tests, 8 CLI tests, strict Clippy, portability builds, and the complete validator gate: 272 corpus files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -A targeted synthetic 3024x4032 8-bit 4:2:0 90-degree production-hook finalizer test was about 6.8x faster (34.27 ms candidate versus 233.32 ms generic for two conversions), confirming that the technique works when exercised. However, a public-parser survey of the fixed 225-file benchmark found 54 coded HEIC primaries and 3 grids: none of the coded primaries had an effective `irot`/`imir`, while the two oriented HEICs were grids already handled by the accepted grid path. The full production-hook A/B therefore measured only noise, with identical fingerprints: - -- Apple Silicon desktop: `1.003051x` (baseline 1070.601 ms, candidate 1067.344 ms; +0.31%). -- Pixel 4 / Android 13: the first B/C/C/B round reported an impossible-for-an-unexercised-path `1.139358x`, but the reversed C/B/B/C confirmation flipped to `0.970632x` (baseline 6786.403 ms, candidate 6991.737 ms; -2.94%). Thermal status was 0 throughout, but the extreme run-to-run frequency variation makes this non-evidence. -- iPhone 11 Pro / iOS 26.5: `1.006080x` (baseline 2177.539 ms, candidate 2164.380 ms; +0.61%; nominal thermal state throughout). - -The implementation was reverted. It is a strong targeted optimization for a workload absent from the agreed corpus, but a 531-line specialized path with no measurable full-hook benefit does not meet this experiment's acceptance rule. - -## 10. Investigate WPP row-parallel reconstruction for non-grid images - -WPP entry points are already parsed and per-row CABAC reinitialization works (`ctu.rs`), but CTU rows still decode serially. Grid images get parallelism from tiles; large single-coded-image HEICs do not. - -For WPP-encoded streams, decode CTU rows on a wavefront with the standard two-CTU lag. This is a large refactor and only pays off for WPP-encoded files, so first survey how common WPP is in the real-world corpus before committing. - -### Experiment result (2026-07-18): rejected before implementation - -Instrumented the decoder temporarily at the actually selected parsed PPS and slice entry points, ran the fixed corpora, then removed the instrumentation. WPP is highly prevalent: the 225-file hook corpus contains 57 HEVC-decoded files (54 coded primaries and 3 grids), of which 46 coded primaries and all 3 grids use WPP. That is 202 WPP streams (46 primaries plus 156 grid tiles) versus 8 non-WPP streams, and every WPP stream has exactly `CTB rows - 1` parsed entry points. WPP represents 118,919,616 of 119,215,460 HEVC output pixels; excluding already tile-parallel grids, its coded primaries contribute 38,659,176 pixels, or 32.4% of the complete 119,340,735-pixel hook suite. The 272-file correctness corpus adds one WPP stream intentionally rejected for PCM, for 203 WPP versus 8 non-WPP selected streams. - -No bounded implementation survived the safety/design review. CABAC row substreams are independently seekable, but `decode_ctu` interleaves entropy parsing and intra reconstruction through one full-picture `&mut DecodedFrame`, while `SliceContext` owns shared full-picture CT-depth, intra-mode, QP, deblock, and SAO maps. Correct workers need the prior row's post-CTU-1 context, a two-CTU release/acquire wavefront, published above/above-right samples and map state, disjoint output ownership, SAO merge coordination, and deterministic raster-order error precedence. A whole-frame mutex serializes the work; aliased mutable frames are undefined behavior; per-worker frame clones multiply mobile memory and add full-frame copies. Nested WPP inside 156 already grid-parallel tiles would also oversubscribe mobile CPUs. - -The temporary survey code was removed and all 79 all-feature tests passed with a clean tree. With no safe source candidate, a candidate A/B benchmark would be identical to the accepted baseline and was not manufactured. A credible future implementation first needs a CTU-row-owned frame/map abstraction with small immutable published top-border halos and ordered error propagation; deblocking and SAO can remain serial after reconstruction. That prerequisite is a substantial architecture project rather than a reviewable optimization for this experiment, so suggestion 10 was rejected without weakening safety or correctness. - -## Final accepted-stack audit (2026-07-18) - -Only suggestions 4 (bounded ordered grid-tile streaming) and 6 (expanded AArch64 color-conversion SIMD) were retained. The final branch passed the complete validator gate again after all ten experiments: 272 files accounted for, 219 pixel-oracle cases passed, 219 production image-hook parity checks passed, and zero failures. - -Fresh builds of the original pre-experiment source (`7c17018`) and final accepted source were compared through the complete 225-file production `image`-crate hook. Every run produced fingerprint `15185db2471aa39d`: - -- Apple Silicon desktop: `1.068302x` (original 1170.242 ms, final 1095.423 ms; +6.83%). -- Pixel 4 / Android 13: `1.083983x` (original 7266.524 ms, final 6703.542 ms; +8.40%). Both interleaved pairs improved; thermal status was 0 before and after. -- iPhone 11 Pro / iOS 26.5: the reversed-order confirmation was `1.044781x` (original 2252.898 ms, final 2156.335 ms; +4.48%). Across both four-run orderings the result was `1.056770x` (+5.68%); the phone reached thermal state fair, so the balanced reversed confirmation is the conservative figure. - -The final accepted stack is therefore faster on all three targets through the required production hook while preserving full-corpus correctness. diff --git a/.gitignore b/.gitignore index 399b2c8..e45289c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Local test artifacts .heic-test-assets/ .heic-test-runs/ -.heic-autoresearch/ # Rust build artifacts /target/ diff --git a/README.md b/README.md index ddb9ae4..471683d 100644 --- a/README.md +++ b/README.md @@ -175,12 +175,6 @@ an external libheif checkout with `HEIC_LIBHEIF_SOURCE_DIR`, or place a checkout or symlink at `.heic-test-assets/libheif`. A libheif checkout directly at `.heic-test-assets` is accepted too. -For unattended, correctness-gated decoder optimization experiments, see -`autoresearch/README.md`. Its trusted controller benchmarks each candidate -against the current champion through Ente's production-shaped `image` crate -hook path and commits only faster candidates that pass the full pixel-exact -validator suite and a broader confirmation benchmark. - ## License This crate is maintained inside the Ente monorepo. diff --git a/autoresearch/README.md b/autoresearch/README.md deleted file mode 100644 index bc52002..0000000 --- a/autoresearch/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# HEIC decoder autoresearch - -This directory adapts Karpathy's `autoresearch` pattern to decoder optimization: - -- `program.md` is the human-owned research policy. -- `benchmark.rs` and `benchmark-corpus.txt` are the fixed evaluator. -- `scripts/autoresearch.sh` is the trusted outer loop. -- decoder source and dependencies are the agent-owned experiment surface. - -The controller never asks the optimization agent to decide whether its own work -passes. It builds the current champion and candidate as separate executables, -runs them in baseline/candidate/candidate/baseline order, and only runs the full -correctness suite for a candidate that is at least 2% faster. It then repeats a -larger A/B benchmark across every pinned hook-decodable HEIC/HEIF corpus file. A -candidate is committed only after both speed gates and correctness pass. - -The performance metric exclusively uses Ente's production image-crate hook -shape. It registers the HEIC decoder with Ente's guardrails, then calls -`ImageReader::open`, `with_guessed_format`, `into_decoder`, `icc_profile`, -`Limits::reserve`, `set_limits`, and `DynamicImage::from_decoder`. It never calls -the decoder's direct RGBA API. Consequently, an optimization that benefits only -the direct API cannot pass the performance gate. - -## Validator policy - -Keep pixel-exact libheif comparison for this phase. libheif is an output oracle, -not an implementation constraint: its speed and internal algorithms do not -limit how the Rust decoder is implemented. A tolerance would make regressions -harder to distinguish from deliberate numerical differences and should only be -introduced with a separate, independently justified quality metric and corpus. - -The native iOS decoder is valuable as a performance target and a later secondary -cross-check, but it is not a portable ground-truth oracle. Platform color -management and rounding can differ, and requiring an attached phone would make -the core loop slower and less reproducible. - -## Prerequisites - -Set up the full harness from `TESTING.md` first. In particular, the libheif -checkout/binary, Ente fixtures, and generated stress corpus must exist. The six -real-camera files listed in `benchmark-corpus.txt` are required. - -The loop calls the installed `codex` CLI. It defaults to that CLI's configured -model and authentication. The controller stores trusted binaries, hashes, -results, rejected patches, and logs outside the repository under -`~/.cache/heic-decoder-autoresearch//`. This prevents a workspace-only -optimization agent from modifying the acceptance state. - -Every terminal attempt is also appended to a controller-owned Markdown journal -at `~/.cache/heic-decoder-autoresearch/.experiments.md`. Its readable -repository mirror is `.heic-autoresearch/experiments.md`. The journal survives -fresh baselines and records the exact primary speed factor for every measured -success or failure, the full-corpus factor when that gate is reached, the -controller's rejection reason, a short learning, and the agent's report. The -controller refreshes the ignored mirror from trusted external state before and -after each agent turn, and every agent must read the complete journal before -choosing an experiment. - -## Start a run - -First commit the harness itself on `faster`, then begin from a clean worktree: - -```bash -git status --short -scripts/autoresearch.sh setup -scripts/autoresearch.sh run --hours 8 -``` - -Select a model explicitly if desired: - -```bash -scripts/autoresearch.sh run --hours 8 --model -``` - -Useful controls from another terminal: - -```bash -scripts/autoresearch.sh status -scripts/autoresearch.sh stop -less .heic-autoresearch/experiments.md -``` - -`stop` is cooperative: it stops before the next agent attempt, not in the middle -of an evaluation. To clear an old stop request, start `run` again. -Likewise, the wall-clock deadline prevents a new attempt from starting; an agent -or trusted evaluation already in flight is allowed to finish, so the final -elapsed time can exceed the requested budget. - -To evaluate a candidate you edited manually, leave the changes uncommitted and -run: - -```bash -scripts/autoresearch.sh evaluate --description "avoid coefficient copy" -``` - -## Acceptance gate - -For each candidate the controller: - -1. rejects changes outside `src/**/*.rs`, `Cargo.toml`, and `Cargo.lock`; -2. runs diff checks, `cargo fmt --check`, and the Rust test suite; -3. rejects dependency graphs containing native `links` packages (with a narrow - exception for crates.io `rayon-core`, whose marker does not link native code); -4. builds a fresh candidate benchmark executable in trusted external state; -5. compares it against the saved champion with multiple interleaved samples on - six large real-camera inputs; -6. requires at least `HEIC_AUTORESEARCH_MIN_IMPROVEMENT` (default `0.02`, or - 2%) improvement; -7. for a faster candidate, runs host clippy, installed portability target checks, - and the complete pixel-exact validator + production-shaped image-hook suite; -8. after correctness, runs a second A/B benchmark over every HEIC/HEIF file that - the baseline champion decoded through the hook during setup, requiring - `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT` (also 2% by default); and -9. commits the candidate and promotes its executable only if every check passes. - -The primary benchmark measures Ente's path-based image-hook flow, including file -open/read, lazy layout probing, ICC retrieval, image limits, caller-buffer pixel -decode, and output allocation/deallocation. It checks full pixel and ICC -fingerprints outside the timed region. Lower `score_ms` is better. The broader -confirmation corpus is discovered once by the trusted baseline and stored -outside the agent-writable repository. - -Rejected diffs are archived for review, but are removed from the worktree. The -Markdown journal records both accepted and rejected experiments so later agents -do not repeat an unsuccessful implementation without addressing its measured -result or failure. The loop refuses to start unless the branch, HEAD, and -tracked/untracked worktree match the saved champion, so unrelated user changes -are never discarded. - -## Portability and final promotion - -One autoresearch run optimizes the machine it runs on; it cannot establish a -4–7x speedup on every architecture. Repeat the benchmark/loop or at least the -final A/B benchmark on representative ARM and x86-64 hosts. Before opening the -PR, run the normal CI-equivalent commands from `TESTING.md` and review all kept -commits for maintainability, safety, dependency quality, and over-specialization. - -Environment knobs: - -- The primary and full-corpus gates both default to a 2% latency improvement, - measured with four interleaved A/B samples. This admitted repeatable wins that - the former 5% gate discarded while still rejecting corpus-specific gains. -- `HEIC_AUTORESEARCH_MIN_IMPROVEMENT=0.10` requires a 10% primary win. -- `HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT=0.10` requires a 10% full-corpus - confirmation. -- `HEIC_AUTORESEARCH_PAIR_SAMPLES=6` increases A/B samples per invocation. -- `HEIC_AUTORESEARCH_CONFIRM_SAMPLES=6` increases full-corpus A/B samples. -- `HEIC_AUTORESEARCH_CHECK_TARGETS=aarch64-apple-ios,aarch64-linux-android` - selects extra installed targets checked before promotion. -- `HEIC_AUTORESEARCH_STATE_DIR=/trusted/path` overrides external state. -- Existing `HEIC_LIBHEIF_SOURCE_DIR`, `HEIC_ENTE_FIXTURES_DIR`, - `LIBHEIF_BUILD_DIR`, and `LIBHEIF_DEC_BIN` overrides are captured by `setup`. - -If the validator, corpus, benchmark, controller, toolchain, compiler flags, or -machine changes, start a fresh baseline with `scripts/autoresearch.sh setup`. diff --git a/autoresearch/benchmark-corpus.txt b/autoresearch/benchmark-corpus.txt deleted file mode 100644 index 4c1574e..0000000 --- a/autoresearch/benchmark-corpus.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Fixed, real-camera HEIC performance corpus. Paths are relative to the repo. -# The controller verifies these files against the hashes captured by `setup`. -.heic-test-assets/ente-test-fixtures/media/heic/v1/files/1718_rotate_90_cw.HEIC -.heic-test-assets/ente-test-fixtures/media/heic/v1/files/7765_horizontal_normal.HEIC -.heic-test-assets/ente-test-fixtures/media/heic/v1/files/7949_mirror_horizontal_rotate_270_cw.HEIC -.heic-test-assets/ente-test-fixtures/media/heic/v1/files/IMG_0682_pano.HEIC -.heic-test-assets/ente-test-fixtures/media/heic/v1/files/IMG_0983.HEIC -.heic-test-assets/ente-test-fixtures/media/heic/v1/files/IMG_8606_rotate_90_cw_contains_text.HEIC diff --git a/autoresearch/benchmark.rs b/autoresearch/benchmark.rs deleted file mode 100644 index 62b1932..0000000 --- a/autoresearch/benchmark.rs +++ /dev/null @@ -1,272 +0,0 @@ -use heic_decoder::image_integration::register_image_decoder_hooks_with_guardrails; -use heic_decoder::DecodeGuardrails; -use image::{DynamicImage, ImageDecoder, ImageReader, Limits}; -use std::env; -use std::error::Error; -use std::hint::black_box; -use std::io; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -const BENCHMARK_VERSION: u32 = 2; - -struct Options { - warmup_rounds: usize, - sample_rounds: usize, - probe_compatible: bool, - paths: Vec, -} - -struct Input { - path: PathBuf, - pixels: u64, - fingerprint: u64, -} - -struct HookOutput { - image: DynamicImage, - icc_profile: Option>, -} - -fn usage() -> &'static str { - "Usage: heic-autoresearch-bench [--warmup N] [--samples N] [--probe-compatible] [...]" -} - -fn parse_count(flag: &str, value: Option) -> Result { - let value = value.ok_or_else(|| format!("missing value for {flag}"))?; - value - .parse::() - .map_err(|_| format!("{flag} expects a non-negative integer, got '{value}'")) -} - -fn parse_options() -> Result { - let mut args = env::args().skip(1); - let mut warmup_rounds = 1; - let mut sample_rounds = 5; - let mut probe_compatible = false; - let mut paths = Vec::new(); - - while let Some(arg) = args.next() { - match arg.as_str() { - "--warmup" => warmup_rounds = parse_count("--warmup", args.next())?, - "--samples" => sample_rounds = parse_count("--samples", args.next())?, - "--probe-compatible" => probe_compatible = true, - "--help" | "-h" => return Err(usage().to_string()), - _ if arg.starts_with('-') => { - return Err(format!("unknown option '{arg}'. {}", usage())); - } - _ => paths.push(PathBuf::from(arg)), - } - } - - if sample_rounds == 0 { - return Err("--samples must be at least 1".to_string()); - } - if paths.is_empty() { - return Err(format!("at least one input is required. {}", usage())); - } - - Ok(Options { - warmup_rounds, - sample_rounds, - probe_compatible, - paths, - }) -} - -fn register_production_hooks() -> Result<(), Box> { - let registration = register_image_decoder_hooks_with_guardrails(DecodeGuardrails { - max_input_bytes: Some(128 * 1024 * 1024), - max_pixels: Some(256_000_000), - max_temp_spool_bytes: Some(256 * 1024 * 1024), - temp_spool_directory: None, - }); - if !registration.heic_decoder_hook_registered || !registration.heif_decoder_hook_registered { - return Err("HEIC/HEIF image-crate hooks were not registered".into()); - } - Ok(()) -} - -/// Mirrors Ente's path-based image-crate hook call shape in -/// `rust/crates/image/src/decode.rs::decode_reader_with_image_crate`. -fn decode_through_image_hook(path: &Path) -> image::ImageResult { - let reader = ImageReader::open(path)?.with_guessed_format()?; - let mut decoder = reader.into_decoder()?; - let icc_profile = decoder.icc_profile()?; - - let mut limits = Limits::default(); - limits.reserve(decoder.total_bytes())?; - decoder.set_limits(limits)?; - - Ok(HookOutput { - image: DynamicImage::from_decoder(decoder)?, - icc_profile, - }) -} - -fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 { - for &byte in bytes { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - hash -} - -fn output_pixels_and_fingerprint(output: &HookOutput) -> Result<(u64, u64), Box> { - let mut hash = 0xcbf2_9ce4_8422_2325; - hash = fnv1a(&output.image.width().to_le_bytes(), hash); - hash = fnv1a(&output.image.height().to_le_bytes(), hash); - hash = match &output.image { - DynamicImage::ImageRgba8(buffer) => fnv1a(buffer.as_raw(), hash), - DynamicImage::ImageRgba16(buffer) => { - for sample in buffer.as_raw() { - hash = fnv1a(&sample.to_le_bytes(), hash); - } - hash - } - other => return Err(format!("image hook returned unsupported {:?}", other.color()).into()), - }; - match &output.icc_profile { - Some(profile) => { - hash = fnv1a(&[1], hash); - hash = fnv1a(profile, hash); - } - None => hash = fnv1a(&[0], hash), - } - Ok(( - u64::from(output.image.width()) * u64::from(output.image.height()), - hash, - )) -} - -fn timed_decode(input: &Input) -> Result> { - let started = Instant::now(); - let output = decode_through_image_hook(black_box(&input.path))?; - black_box(output.image.width()); - black_box(output.image.height()); - black_box(output.icc_profile.as_ref().map(Vec::len)); - match &output.image { - DynamicImage::ImageRgba8(buffer) => { - black_box(buffer.as_raw().as_ptr()); - black_box(buffer.as_raw().len()); - } - DynamicImage::ImageRgba16(buffer) => { - black_box(buffer.as_raw().as_ptr()); - black_box(buffer.as_raw().len()); - } - other => return Err(format!("image hook returned unsupported {:?}", other.color()).into()), - } - drop(output); - Ok(started.elapsed()) -} - -fn round_order(input_count: usize, round: usize) -> Vec { - let mut order: Vec = (0..input_count).collect(); - order.rotate_left(round % input_count); - if round % 2 == 1 { - order.reverse(); - } - order -} - -fn median(samples: &mut [Duration]) -> Duration { - samples.sort_unstable(); - let middle = samples.len() / 2; - if samples.len() % 2 == 1 { - samples[middle] - } else { - (samples[middle - 1] + samples[middle]) / 2 - } -} - -fn probe_compatible(paths: &[PathBuf]) -> Result<(), Box> { - let mut compatible = 0; - for path in paths { - match decode_through_image_hook(path) { - Ok(output) => { - output_pixels_and_fingerprint(&output)?; - println!("compatible: {}", path.display()); - compatible += 1; - } - Err(error) => eprintln!("incompatible: {}: {error}", path.display()), - } - } - if compatible == 0 { - return Err("no compatible image-crate hook inputs found".into()); - } - Ok(()) -} - -fn main() -> Result<(), Box> { - let options = parse_options().map_err(|message| { - eprintln!("{message}"); - io::Error::new(io::ErrorKind::InvalidInput, message) - })?; - register_production_hooks()?; - if options.probe_compatible { - return probe_compatible(&options.paths); - } - - // The one-time correctness fingerprint and process startup are outside the - // timed region. The score itself mirrors Ente's path-based ImageReader hook - // flow, including file open/read, layout probing, ICC retrieval, limits, - // caller-buffer decode, output allocation, and deallocation. - let mut inputs = Vec::with_capacity(options.paths.len()); - let mut suite_pixels = 0_u64; - let mut suite_fingerprint = 0_u64; - for path in options.paths { - let output = decode_through_image_hook(&path)?; - let (pixels, output_fingerprint) = output_pixels_and_fingerprint(&output)?; - suite_pixels = suite_pixels.saturating_add(pixels); - suite_fingerprint ^= output_fingerprint.rotate_left((inputs.len() % 63 + 1) as u32); - inputs.push(Input { - path, - pixels, - fingerprint: output_fingerprint, - }); - } - - for round in 0..options.warmup_rounds { - for index in round_order(inputs.len(), round) { - let duration = timed_decode(&inputs[index])?; - black_box(duration); - } - } - - let mut totals = Vec::with_capacity(options.sample_rounds); - let mut per_input = vec![Vec::with_capacity(options.sample_rounds); inputs.len()]; - for round in 0..options.sample_rounds { - let mut total = Duration::ZERO; - for index in round_order(inputs.len(), round + options.warmup_rounds) { - let elapsed = timed_decode(&inputs[index])?; - total += elapsed; - per_input[index].push(elapsed); - } - totals.push(total); - } - - let median_total = median(&mut totals); - let score_ms = median_total.as_secs_f64() * 1_000.0; - let throughput = suite_pixels as f64 / 1_000_000.0 / median_total.as_secs_f64(); - - println!("benchmark_version: {BENCHMARK_VERSION}"); - println!("benchmark_path: image_crate_hook_path"); - println!("score_ms: {score_ms:.6}"); - println!("throughput_mpix_s: {throughput:.6}"); - println!("suite_pixels: {suite_pixels}"); - println!("suite_fingerprint: {suite_fingerprint:016x}"); - println!("files: {}", inputs.len()); - println!("samples: {}", options.sample_rounds); - println!("warmup: {}", options.warmup_rounds); - for (input, durations) in inputs.iter().zip(&mut per_input) { - let input_median_ms = median(durations).as_secs_f64() * 1_000.0; - println!( - "file: path={} pixels={} median_ms={input_median_ms:.6} fingerprint={:016x}", - input.path.display(), - input.pixels, - input.fingerprint - ); - } - - Ok(()) -} diff --git a/autoresearch/program.md b/autoresearch/program.md deleted file mode 100644 index 0768b46..0000000 --- a/autoresearch/program.md +++ /dev/null @@ -1,91 +0,0 @@ -# HEIC decoder autoresearch agent - -You are making one experimental performance optimization to this pure-Rust -HEIC decoder. An outer controller, not you, owns evaluation, keep/discard, and -git commits. Make exactly one coherent attempt, summarize it, and return. - -## Objective - -Reduce the trusted Ente production-path image-crate hook benchmark while -preserving exact behavior. The long-run target across multiple optimizations is -at least a 4x speedup, with 4–7x desirable. Optimize real decoder work rather -than the harness. - -The metric deliberately does **not** call the decoder's direct RGBA APIs. It -registers this crate with `register_image_decoder_hooks_with_guardrails` using -Ente's production guardrails, then follows Ente's path-based shape exactly: -`ImageReader::open`, `with_guessed_format`, `into_decoder`, `icc_profile`, -`Limits::reserve`, `set_limits`, and `DynamicImage::from_decoder`. This exercises -the lazy adapter and its caller-owned output buffer. A general decoder change is -valuable when it speeds up this route too. A change that speeds up only a direct -decode API, but does not speed up the image-crate hook route, is not an -improvement for this project and must not be kept. - -Read the current source, recent git history, and the complete controller-owned -experiment journal at `.heic-autoresearch/experiments.md` before choosing an -idea. The journal includes accepted work, failed work, measured speed factors, -and failure learnings from earlier agents. Prefer evidence: inspect hot loops, -algorithms, allocations, bounds checks, data layout, and existing SIMD paths. -Do not repeat an accepted or rejected experiment unchanged. A related follow-up -is allowed only when your hypothesis explicitly states the material difference -and how it addresses the recorded result or failure. - -## Files you may change - -- Rust files below `src/`, except any file the prompt explicitly identifies as - part of the benchmark/evaluator. -- `Cargo.toml` and `Cargo.lock`. - -Do not modify `autoresearch/`, `scripts/`, tests or corpora under ignored -directories, CI configuration, git configuration, the git index, or git -history. Do not run `git commit`, `git reset`, `git restore`, `git clean`, or -`git checkout`. The controller starts you from a clean champion and will handle -all repository state. In particular, `.heic-autoresearch/experiments.md` is a -read-only mirror of trusted external state; never edit, replace, or delete it. - -## Correctness and anti-cheating rules - -- Output dimensions, bit depth, RGBA samples, ICC behavior, errors, guardrails, - and public API semantics must remain correct. Accepted changes must pass the - full pixel-exact libheif corpus and the production-shaped image-hook parity - checks. -- Never identify, special-case, cache by identity, or embed outputs for benchmark - or test files. Never inspect benchmark-only environment, process arguments, - paths, clocks, or call counts from decoder code. -- Do not skip required decoding work, reduce precision, weaken validation or - guardrails, return partially initialized output, or exploit undefined behavior. -- Do not edit, replace, generate, delete, or chmod anything in - `.heic-test-assets/`, `.heic-test-runs/`, `.heic-autoresearch/`, or the - controller's external state directory. -- Keep code clear and maintainable. Avoid massive rewrites in one experiment. - Explain non-obvious invariants and any `unsafe`; use `unsafe` only when the - safety argument is local, explicit, and compelling. - -## Dependencies and portability - -New crates are allowed only when they are mature and truly pure Rust. Do not add -FFI bindings, `-sys` crates, bundled native code, precompiled objects, or native -runtime requirements. Minimize new dependency surface and explain why a new -crate meets these rules. - -The current benchmark machine is only one architecture. Architecture-specific -fast paths must have a correct portable Rust fallback and runtime or compile-time -feature detection as appropriate. Do not regress other supported targets. The -controller checks the host plus installed iOS, Android, and WASM targets; later -promotion includes benchmarks on other representative hardware. - -## Work pattern - -1. Read the complete experiment journal, then inspect enough code and prior - results to form one specific hypothesis. -2. Make the smallest clean change that tests it. -3. Run `cargo fmt --all` after Rust edits. -4. Run a focused check or unit test if useful. Do not run the full libheif suite; - the controller does that only for candidates which are at least 2% faster on - the primary image-hook benchmark. After correctness, it also requires a 2% - confirmation on the pinned full HEIC/HEIF hook corpus. -5. Return after this single attempt. In the final response, put a concise - one-line experiment description first, followed by the hypothesis and any - relevant caveat. If the idea is related to a journaled attempt, explicitly - state why this version is materially different. Do not claim the change is - faster or correct; the trusted controller decides that. diff --git a/scripts/autoresearch.sh b/scripts/autoresearch.sh deleted file mode 100755 index 7691974..0000000 --- a/scripts/autoresearch.sh +++ /dev/null @@ -1,1096 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -REPO_ID="$(printf '%s' "$ROOT_DIR" | shasum -a 256 | awk '{print substr($1, 1, 12)}')" -DEFAULT_STATE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}/heic-decoder-autoresearch" -STATE_DIR="${HEIC_AUTORESEARCH_STATE_DIR:-$DEFAULT_STATE_BASE/$REPO_ID}" -STATE_FILE="$STATE_DIR/state.tsv" -RESULTS_FILE="$STATE_DIR/results.tsv" -STOP_FILE="$STATE_DIR/STOP" -JOURNAL_FILE="${STATE_DIR}.experiments.md" -JOURNAL_MIRROR="$ROOT_DIR/.heic-autoresearch/experiments.md" -BENCHMARK_SOURCE="$ROOT_DIR/autoresearch/benchmark.rs" -BENCHMARK_CORPUS="$ROOT_DIR/autoresearch/benchmark-corpus.txt" -CONFIRMATION_CORPUS="$STATE_DIR/confirmation-corpus.txt" - -CHANGED_FILES=() -UNTRACKED_FILES=() -BENCHMARK_FILES=() -CONFIRMATION_FILES=() -PAIR_BASELINE_SCORE="" -PAIR_CANDIDATE_SCORE="" -PAIR_SPEEDUP="" -RUN_LOCK_DIR="" - -usage() { - cat <<'EOF' -Usage: scripts/autoresearch.sh [options] - -Commands: - setup Validate and record a fresh clean baseline - run --hours N [options] Run unattended one-attempt Codex experiments - evaluate [--description text] Evaluate current uncommitted source changes - bench [--samples N] Benchmark the current worktree without state - status Show champion and recent results - stop Cooperatively stop an unattended run - -Run options: - --hours N Wall-clock budget; accepts decimal hours - --max-experiments N Optional attempt-count limit - --model MODEL Override the Codex CLI's configured model - -The trusted state directory defaults to: - ~/.cache/heic-decoder-autoresearch// - -The durable experiment journal is mirrored to: - .heic-autoresearch/experiments.md -EOF -} - -log() { - printf '[autoresearch:%s] %s\n' "$1" "${*:2}" -} - -die() { - log error "$*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" -} - -cleanup_run_lock() { - if [[ -n "${RUN_LOCK_DIR:-}" ]]; then - rm -rf "$RUN_LOCK_DIR" - fi -} - -acquire_run_lock() { - local lock_dir="$1" existing_pid="" - if ! mkdir "$lock_dir" 2>/dev/null; then - [[ -f "$lock_dir/pid" ]] && existing_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)" - if [[ "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2>/dev/null; then - die "Another autoresearch run is active with pid $existing_pid." - fi - log run "Removing stale run lock: $lock_dir" - rm -rf "$lock_dir" - mkdir "$lock_dir" || die "Could not acquire run lock: $lock_dir" - fi - printf '%s\n' "$$" > "$lock_dir/pid" - RUN_LOCK_DIR="$lock_dir" - trap cleanup_run_lock EXIT -} - -sanitize_field() { - printf '%s' "$1" \ - | tr '\t\r\n' ' ' \ - | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//' \ - | cut -c1-240 -} - -current_branch() { - git -C "$ROOT_DIR" symbolic-ref --quiet --short HEAD \ - || die "Autoresearch requires a named git branch." -} - -current_commit() { - git -C "$ROOT_DIR" rev-parse HEAD -} - -short_commit() { - git -C "$ROOT_DIR" rev-parse --short=12 "$1" -} - -get_state() { - local key="$1" - [[ -f "$STATE_FILE" ]] || die "No autoresearch baseline. Run scripts/autoresearch.sh setup first." - awk -F '\t' -v key="$key" '$1 == key {sub(/^[^\t]*\t/, ""); print; exit}' "$STATE_FILE" -} - -set_state() { - local key="$1" value="$2" tmp="$STATE_FILE.tmp.$$" - awk -F '\t' -v OFS='\t' -v key="$key" -v value="$value" ' - $1 == key { print key, value; found = 1; next } - { print } - END { if (!found) print key, value } - ' "$STATE_FILE" > "$tmp" - mv "$tmp" "$STATE_FILE" -} - -ensure_external_state_dir() { - mkdir -p "$STATE_DIR" - local physical - physical="$(cd "$STATE_DIR" && pwd -P)" - case "$physical/" in - "$ROOT_DIR/"*) - die "HEIC_AUTORESEARCH_STATE_DIR must be outside the repository so the optimization agent cannot modify trusted state." - ;; - esac -} - -mirror_journal() { - [[ -f "$JOURNAL_FILE" ]] || return 0 - mkdir -p "$(dirname "$JOURNAL_MIRROR")" - cp "$JOURNAL_FILE" "$JOURNAL_MIRROR" -} - -initialize_journal() { - if [[ ! -f "$JOURNAL_FILE" ]]; then - mkdir -p "$(dirname "$JOURNAL_FILE")" - { - printf '# HEIC decoder autoresearch experiment journal\n\n' - printf 'This is the controller-owned history of accepted and rejected experiments. ' - printf 'The live repository mirror is `.heic-autoresearch/experiments.md`; ' - printf 'optimization agents may read it but must not edit it.\n' - } > "$JOURNAL_FILE" - fi - mirror_journal -} - -require_clean_worktree() { - if [[ -n "$(git -C "$ROOT_DIR" status --porcelain=v1 --untracked-files=all)" ]]; then - git -C "$ROOT_DIR" status --short >&2 - die "The worktree must be clean before setup or an unattended run." - fi -} - -require_champion_head() { - local expected_branch expected_commit - expected_branch="$(get_state branch)" - expected_commit="$(get_state champion_commit)" - [[ "$(current_branch)" == "$expected_branch" ]] \ - || die "Expected branch '$expected_branch'; current branch is '$(current_branch)'." - [[ "$(current_commit)" == "$expected_commit" ]] \ - || die "HEAD is not the saved champion $(short_commit "$expected_commit"). Run setup for an intentional new baseline." -} - -is_libheif_source_dir() { - [[ -f "$1/CMakeLists.txt" && -d "$1/examples" && -d "$1/tests/data" && -d "$1/fuzzing/data/corpus" ]] -} - -absolute_dir() { - (cd "$1" && pwd -P) -} - -absolute_file() { - local dir base - dir="$(dirname "$1")" - base="$(basename "$1")" - printf '%s/%s\n' "$(absolute_dir "$dir")" "$base" -} - -resolve_setup_paths() { - local source_candidate ente_candidate validator_candidate build_dir - source_candidate="${HEIC_LIBHEIF_SOURCE_DIR:-$ROOT_DIR/.heic-test-assets/libheif}" - if ! is_libheif_source_dir "$source_candidate" && is_libheif_source_dir "$ROOT_DIR/.heic-test-assets"; then - source_candidate="$ROOT_DIR/.heic-test-assets" - fi - is_libheif_source_dir "$source_candidate" \ - || die "No libheif source/corpus checkout found. Follow TESTING.md first." - SETUP_LIBHEIF_SOURCE="$(absolute_dir "$source_candidate")" - - ente_candidate="${HEIC_ENTE_FIXTURES_DIR:-$ROOT_DIR/.heic-test-assets/ente-test-fixtures}" - [[ -d "$ente_candidate/media/heic/v1/files" ]] \ - || die "The Ente HEIC fixture corpus is missing. Follow TESTING.md first." - SETUP_ENTE_DIR="$(absolute_dir "$ente_candidate")" - - [[ -d "$ROOT_DIR/.heic-test-assets/stress-corpus" ]] \ - || die "The stress corpus is missing. Run scripts/heic_tests.sh gen-stress first." - SETUP_STRESS_DIR="$(absolute_dir "$ROOT_DIR/.heic-test-assets/stress-corpus")" - - build_dir="${LIBHEIF_BUILD_DIR:-$ROOT_DIR/.heic-test-runs/validator-build}" - validator_candidate="${LIBHEIF_DEC_BIN:-$build_dir/examples/heif-dec}" - SETUP_VALIDATOR="$validator_candidate" -} - -load_benchmark_files() { - BENCHMARK_FILES=() - local line path - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%%#*}" - line="$(sanitize_field "$line")" - [[ -n "$line" ]] || continue - case "$line" in - /*) path="$line" ;; - *) path="$ROOT_DIR/$line" ;; - esac - [[ -f "$path" ]] || die "Benchmark input is missing: $path" - BENCHMARK_FILES+=("$path") - done < "$BENCHMARK_CORPUS" - [[ ${#BENCHMARK_FILES[@]} -gt 0 ]] || die "The benchmark corpus manifest is empty." -} - -load_confirmation_files() { - CONFIRMATION_FILES=() - local path - [[ -s "$CONFIRMATION_CORPUS" ]] \ - || die "Pinned full-corpus hook benchmark is missing. Run setup again." - while IFS= read -r path || [[ -n "$path" ]]; do - [[ -n "$path" ]] || continue - [[ -f "$path" ]] || die "Confirmation benchmark input is missing: $path" - CONFIRMATION_FILES+=("$path") - done < "$CONFIRMATION_CORPUS" - [[ ${#CONFIRMATION_FILES[@]} -gt 0 ]] \ - || die "The confirmation benchmark corpus is empty." -} - -write_corpus_dirs() { - printf '%s\n' \ - "$SETUP_LIBHEIF_SOURCE/examples" \ - "$SETUP_LIBHEIF_SOURCE/tests/data" \ - "$SETUP_LIBHEIF_SOURCE/fuzzing/data/corpus" \ - "$SETUP_ENTE_DIR/media/heic/v1/files" \ - "$SETUP_STRESS_DIR" > "$STATE_DIR/corpus-dirs.txt" -} - -hash_corpus() { - local output="$1" paths_file="$STATE_DIR/corpus-paths.tmp.$$" dir path hash - : > "$paths_file" - while IFS= read -r dir; do - find "$dir" -type f \( -iname '*.heif' -o -iname '*.heic' -o -iname '*.avif' \) -print - done < "$STATE_DIR/corpus-dirs.txt" | LC_ALL=C sort -u > "$paths_file" - [[ -s "$paths_file" ]] || die "The correctness corpus is empty." - : > "$output" - while IFS= read -r path; do - hash="$(shasum -a 256 "$path" | awk '{print $1}')" - printf '%s %s\n' "$hash" "$path" >> "$output" - done < "$paths_file" - rm -f "$paths_file" -} - -capture_asset_integrity() { - hash_corpus "$STATE_DIR/corpus.sha256" - local validator validator_hash - validator="$SETUP_VALIDATOR" - [[ -x "$validator" ]] || die "Validator binary was not produced at $validator" - validator="$(absolute_file "$validator")" - validator_hash="$(shasum -a 256 "$validator" | awk '{print $1}')" - set_state libheif_source "$SETUP_LIBHEIF_SOURCE" - set_state ente_fixtures_dir "$SETUP_ENTE_DIR" - set_state validator_path "$validator" - set_state validator_sha256 "$validator_hash" -} - -verify_asset_integrity() { - local current="$STATE_DIR/corpus.current.$$" validator expected actual - hash_corpus "$current" - if ! cmp -s "$STATE_DIR/corpus.sha256" "$current"; then - diff -u "$STATE_DIR/corpus.sha256" "$current" >&2 || true - rm -f "$current" - die "Correctness corpus changed since setup. Restore it or establish a deliberate fresh baseline." - fi - rm -f "$current" - - validator="$(get_state validator_path)" - expected="$(get_state validator_sha256)" - [[ -x "$validator" ]] || die "Pinned validator binary is missing: $validator" - actual="$(shasum -a 256 "$validator" | awk '{print $1}')" - [[ "$actual" == "$expected" ]] \ - || die "Pinned validator binary changed since setup. Restore it or establish a fresh baseline." -} - -verify_champion_binary() { - local champion="$STATE_DIR/champion-bench" expected actual - expected="$(get_state champion_sha256)" - [[ -x "$champion" ]] || die "Trusted champion benchmark binary is missing: $champion" - actual="$(shasum -a 256 "$champion" | awk '{print $1}')" - [[ -n "$expected" && "$actual" == "$expected" ]] \ - || die "Trusted champion benchmark binary changed; establish a fresh baseline." -} - -check_environment_matches_setup() { - [[ "$(rustc --version)" == "$(get_state rustc_version)" ]] \ - || die "rustc changed since setup; establish a fresh baseline." - [[ "${RUSTFLAGS:-}" == "$(get_state rustflags)" ]] \ - || die "RUSTFLAGS changed since setup; establish a fresh baseline." - [[ "$(uname -m)" == "$(get_state architecture)" ]] \ - || die "Machine architecture changed since setup; establish a fresh baseline." -} - -prepare_benchmark_project() { - local project_dir="$STATE_DIR/build/benchmark-project" - rm -rf "$project_dir" - mkdir -p "$project_dir/src" "$STATE_DIR/build/target" - cp "$BENCHMARK_SOURCE" "$project_dir/src/main.rs" - cp "$ROOT_DIR/Cargo.lock" "$project_dir/Cargo.lock" - printf '%s\n' \ - '[workspace]' \ - '' \ - '[package]' \ - 'name = "heic-autoresearch-bench"' \ - 'version = "0.0.0"' \ - 'edition = "2024"' \ - 'publish = false' \ - '' \ - '[dependencies]' \ - "heic_decoder = { path = \"$ROOT_DIR\", features = [\"image-integration\"] }" \ - 'image = { version = "0.25.10", default-features = false }' \ - > "$project_dir/Cargo.toml" -} - -build_benchmark_binary() { - local destination="$1" build_log="$2" - prepare_benchmark_project - if ! CARGO_TARGET_DIR="$STATE_DIR/build/target" \ - cargo build --manifest-path "$STATE_DIR/build/benchmark-project/Cargo.toml" --release \ - >"$build_log" 2>&1; then - tail -n 80 "$build_log" >&2 || true - return 1 - fi - cp "$STATE_DIR/build/target/release/heic-autoresearch-bench" "$destination" - chmod 755 "$destination" -} - -benchmark_score() { - local binary="$1" output="$2" warmup="$3" samples="$4" corpus="$5" score - local benchmark_status=0 - case "$corpus" in - primary) - "$binary" --warmup "$warmup" --samples "$samples" "${BENCHMARK_FILES[@]}" \ - >"$output" 2>&1 || benchmark_status=$? - ;; - confirmation) - "$binary" --warmup "$warmup" --samples "$samples" "${CONFIRMATION_FILES[@]}" \ - >"$output" 2>&1 || benchmark_status=$? - ;; - *) die "Unknown benchmark corpus: $corpus" ;; - esac - if [[ "$benchmark_status" -ne 0 ]]; then - tail -n 80 "$output" >&2 || true - return 1 - fi - score="$(awk '/^score_ms: / {print $2; exit}' "$output")" - [[ "$score" =~ ^[0-9]+([.][0-9]+)?$ ]] || return 1 - printf '%s\n' "$score" -} - -benchmark_pair() { - local champion="$1" candidate="$2" log_dir="$3" corpus="$4" samples="$5" - local b1 c1 c2 b2 fingerprints - [[ "$samples" =~ ^[1-9][0-9]*$ ]] \ - || die "HEIC_AUTORESEARCH_PAIR_SAMPLES must be a positive integer." - mkdir -p "$log_dir" - log bench "Interleaved A/B $corpus benchmark (samples per invocation=$samples)" - # Bring the machine and both executables out of their cold-start state before - # the A/B/B/A sequence. These scores are deliberately discarded. - benchmark_score "$champion" "$log_dir/preheat-baseline.log" 0 1 "$corpus" >/dev/null || return 1 - benchmark_score "$candidate" "$log_dir/preheat-candidate.log" 0 1 "$corpus" >/dev/null || return 1 - b1="$(benchmark_score "$champion" "$log_dir/baseline-1.log" 0 "$samples" "$corpus")" || return 1 - c1="$(benchmark_score "$candidate" "$log_dir/candidate-1.log" 0 "$samples" "$corpus")" || return 1 - c2="$(benchmark_score "$candidate" "$log_dir/candidate-2.log" 0 "$samples" "$corpus")" || return 1 - b2="$(benchmark_score "$champion" "$log_dir/baseline-2.log" 0 "$samples" "$corpus")" || return 1 - fingerprints="$(awk '/^suite_fingerprint: / {print $2}' "$log_dir"/*.log | LC_ALL=C sort -u)" - [[ "$(printf '%s\n' "$fingerprints" | awk 'NF {count++} END {print count + 0}')" -eq 1 ]] \ - || { log bench "Benchmark output fingerprints differed across A/B runs." >&2; return 1; } - PAIR_BASELINE_SCORE="$(awk -v a="$b1" -v b="$b2" 'BEGIN {printf "%.6f", (a + b) / 2}')" - PAIR_CANDIDATE_SCORE="$(awk -v a="$c1" -v b="$c2" 'BEGIN {printf "%.6f", (a + b) / 2}')" - PAIR_SPEEDUP="$(awk -v base="$PAIR_BASELINE_SCORE" -v candidate="$PAIR_CANDIDATE_SCORE" \ - 'BEGIN {printf "%.6f", base / candidate}')" - log bench "champion=${PAIR_BASELINE_SCORE}ms candidate=${PAIR_CANDIDATE_SCORE}ms speedup=${PAIR_SPEEDUP}x" -} - -prepare_confirmation_corpus() { - local champion="$1" log_file="$2" candidates_file="$STATE_DIR/confirmation-candidates.txt" - local candidates=() path - sed 's/^[0-9a-fA-F]* //' "$STATE_DIR/corpus.sha256" \ - | awk 'tolower($0) ~ /\.(heic|heif)$/ {print}' \ - > "$candidates_file" - while IFS= read -r path; do - [[ -n "$path" ]] && candidates+=("$path") - done < "$candidates_file" - [[ ${#candidates[@]} -gt 0 ]] || die "No HEIC/HEIF files exist in the pinned corpus." - if ! "$champion" --probe-compatible "${candidates[@]}" > "$log_file" 2>&1; then - tail -n 80 "$log_file" >&2 || true - return 1 - fi - sed -n 's/^compatible: //p' "$log_file" > "$CONFIRMATION_CORPUS" - [[ -s "$CONFIRMATION_CORPUS" ]] || return 1 - load_confirmation_files - set_state confirmation_file_count "${#CONFIRMATION_FILES[@]}" - log setup "Pinned ${#CONFIRMATION_FILES[@]} hook-decodable HEIC/HEIF files for confirmation" -} - -collect_changed_files() { - CHANGED_FILES=() - UNTRACKED_FILES=() - local path - while IFS= read -r -d '' path; do - CHANGED_FILES+=("$path") - done < <(git -C "$ROOT_DIR" diff --name-only -z HEAD --) - while IFS= read -r -d '' path; do - CHANGED_FILES+=("$path") - UNTRACKED_FILES+=("$path") - done < <(git -C "$ROOT_DIR" ls-files --others --exclude-standard -z) -} - -changes_are_allowed() { - local path - [[ ${#CHANGED_FILES[@]} -gt 0 ]] || return 1 - for path in "${CHANGED_FILES[@]}"; do - case "$path" in - src/*.rs|Cargo.toml|Cargo.lock) ;; - *) log gate "Disallowed changed file: $path" >&2; return 2 ;; - esac - done -} - -archive_candidate_patch() { - local attempt="$1" destination="$STATE_DIR/rejected/$attempt.diff" path - mkdir -p "$STATE_DIR/rejected" - git -C "$ROOT_DIR" diff --binary HEAD -- > "$destination" - if [[ ${#UNTRACKED_FILES[@]} -gt 0 ]]; then - for path in "${UNTRACKED_FILES[@]}"; do - git -C "$ROOT_DIR" diff --no-index --binary -- /dev/null "$path" >> "$destination" 2>/dev/null || true - done - fi - printf '%s\n' "$destination" -} - -discard_candidate_changes() { - local path - for path in "${CHANGED_FILES[@]}"; do - if git -C "$ROOT_DIR" ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then - git -C "$ROOT_DIR" restore --source=HEAD --staged --worktree -- "$path" - else - rm -f "$ROOT_DIR/$path" - fi - done - if [[ -n "$(git -C "$ROOT_DIR" status --porcelain=v1 --untracked-files=all)" ]]; then - git -C "$ROOT_DIR" status --short >&2 - die "Could not return to the clean champion after discarding a candidate." - fi -} - -append_result() { - local attempt="$1" commit="$2" primary_score="$3" primary_speedup="$4" - local confirmation_score="$5" confirmation_speedup="$6" cumulative="$7" - local status="$8" description="$9" - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$attempt" "$commit" \ - "$primary_score" "$primary_speedup" "$confirmation_score" "$confirmation_speedup" \ - "$cumulative" "$status" "$(sanitize_field "$description")" >> "$RESULTS_FILE" -} - -journal_learning() { - local status="$1" reason="$2" primary_speedup="$3" confirmation_speedup="$4" - case "$status" in - accepted) - printf 'The candidate cleared both production image-hook speed gates and all promotion checks. ' - printf 'It is preserved in the recorded commit and becomes the next champion.' - ;; - baseline) - printf 'This entry establishes a measurement baseline; it is not an optimization attempt.' - ;; - rejected) - case "$reason" in - *"primary hook improvement"*) - printf 'The implementation measured %s on the primary production image-hook benchmark, ' "x${primary_speedup}" - printf 'which did not clear the controller\x27s required latency reduction. ' - printf 'Do not retry this implementation unchanged; revisit the idea only with a materially different mechanism or evidence that it affects more of the measured path.' - ;; - *"full-corpus hook confirmation"*) - printf 'The primary benchmark cleared its gate, but the full-corpus confirmation measured %s and did not clear its gate. ' "x${confirmation_speedup}" - printf 'The gain was not broad or stable enough across the pinned corpus; do not repeat the same implementation unchanged.' - ;; - *"correctness failed"*) - printf 'The candidate was fast enough to promote but failed the full pixel-exact oracle. ' - printf 'Any follow-up must first explain and fix the recorded correctness mismatch; speed alone cannot rescue this implementation.' - ;; - *) - printf 'The controller rejected this implementation because %s. ' "$reason" - printf 'Consult the attempt artifacts before revisiting it, and do not retry it unchanged without directly addressing that failure.' - ;; - esac - ;; - no_change) - printf 'The agent produced no evaluable source change, so no performance factor exists. ' - printf 'A follow-up needs a concrete implementation rather than repeating the same exploration.' - ;; - crash) - printf 'The agent or evaluator exited before a trustworthy performance result was available. ' - printf 'Inspect the attempt log and address that failure before retrying the underlying idea.' - ;; - esac -} - -append_journal_entry() { - local attempt="$1" status="$2" description="$3" reason="$4" commit="$5" - local primary_score="$6" primary_speedup="$7" - local confirmation_score="$8" confirmation_speedup="$9" cumulative="${10}" - local patch="${11:-}" timestamp agent_report - timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - agent_report="$STATE_DIR/attempts/$attempt/agent-last.txt" - initialize_journal - - { - printf '\n## %s — attempt %s — %s\n\n' "$timestamp" "$attempt" "$status" - printf -- '- Experiment: %s\n' "$(sanitize_field "$description")" - printf -- '- Primary production-hook speed factor: ' - if [[ "$primary_speedup" == "-" ]]; then - printf 'not measured\n' - else - printf '`x%s` (candidate `%s ms`)\n' "$primary_speedup" "$primary_score" - fi - printf -- '- Full-corpus production-hook speed factor: ' - if [[ "$confirmation_speedup" == "-" ]]; then - printf 'not measured\n' - else - printf '`x%s` (candidate `%s ms`)\n' "$confirmation_speedup" "$confirmation_score" - fi - printf -- '- Cumulative speed factor for this baseline: `x%s`\n' "$cumulative" - [[ "$commit" == "-" ]] || printf -- '- Commit: `%s`\n' "$commit" - [[ -z "$reason" ]] || printf -- '- Controller result: %s\n' "$reason" - if [[ -n "$patch" ]]; then - printf -- '- Rejected patch: `rejected/%s.diff` in this run\x27s trusted-state directory or archive\n' "$attempt" - fi - printf '\n### Learning\n\n' - journal_learning "$status" "$reason" "$primary_speedup" "$confirmation_speedup" - printf '\n' - if [[ -s "$agent_report" ]]; then - printf '\n### Agent report\n\n' - while IFS= read -r line || [[ -n "$line" ]]; do - printf '> %s\n' "$line" - done < "$agent_report" - fi - } >> "$JOURNAL_FILE" - mirror_journal -} - -record_result() { - local attempt="$1" commit="$2" primary_score="$3" primary_speedup="$4" - local confirmation_score="$5" confirmation_speedup="$6" cumulative="$7" - local ledger_status="$8" description="$9" reason="${10:-}" patch="${11:-}" - local journal_status ledger_description - ledger_description="$description" - [[ -z "$reason" ]] || ledger_description="$reason; $description" - case "$ledger_status" in - keep) - if [[ "$attempt" == "baseline" ]]; then journal_status=baseline; else journal_status=accepted; fi - ;; - discard) journal_status=rejected ;; - no_change) journal_status=no_change ;; - crash) journal_status=crash ;; - *) journal_status="$ledger_status" ;; - esac - append_result "$attempt" "$commit" "$primary_score" "$primary_speedup" \ - "$confirmation_score" "$confirmation_speedup" "$cumulative" \ - "$ledger_status" "$ledger_description" - append_journal_entry "$attempt" "$journal_status" "$description" "$reason" "$commit" \ - "$primary_score" "$primary_speedup" "$confirmation_score" \ - "$confirmation_speedup" "$cumulative" "$patch" -} - -reject_candidate() { - local attempt="$1" description="$2" reason="$3" - local primary_score="${4:--}" primary_speedup="${5:--}" - local confirmation_score="${6:--}" confirmation_speedup="${7:--}" - local patch cumulative - collect_changed_files - patch="$(archive_candidate_patch "$attempt")" - cumulative="$(get_state cumulative_speedup)" - record_result "$attempt" - "$primary_score" "$primary_speedup" \ - "$confirmation_score" "$confirmation_speedup" "$cumulative" discard \ - "$description" "$reason" "$patch" - discard_candidate_changes - log discard "$reason (patch: $patch)" - return 2 -} - -check_no_native_links() { - local metadata="$1" error_log="${1%.json}.metadata.stderr.log" linked - if ! cargo metadata --format-version 1 --locked > "$metadata" 2> "$error_log"; then - tail -n 80 "$error_log" >&2 || true - return 1 - fi - # rayon-core deliberately uses `links = "rayon-core"` without linking any - # native code; its no-op build script only makes Cargo enforce one runtime - # instance. Keep that exact crates.io package as the sole audited exception. - # Any other package declaring `links` remains forbidden. - if ! linked="$(jq -r ' - .packages[] - | select(.links != null) - | select( - .name != "rayon-core" - or .links != "rayon-core" - or .source != "registry+https://github.com/rust-lang/crates.io-index" - ) - | "\(.name) \(.version) links=\(.links)" - ' "$metadata")"; then - log gate "Could not inspect Cargo metadata for native linkage." >&2 - return 1 - fi - if [[ -n "$linked" ]]; then - printf '%s\n' "$linked" >&2 - return 1 - fi -} - -run_portability_checks() { - local log_file="$1" configured installed target - configured="${HEIC_AUTORESEARCH_CHECK_TARGETS:-aarch64-apple-ios,aarch64-linux-android,wasm32-unknown-unknown}" - installed="$(rustup target list --installed 2>/dev/null || true)" - : > "$log_file" - IFS=',' read -r -a targets <<< "$configured" - if [[ ${#targets[@]} -gt 0 ]]; then - for target in "${targets[@]}"; do - [[ -n "$target" ]] || continue - if ! grep -qx "$target" <<< "$installed"; then - log portability "Skipping uninstalled target $target" - continue - fi - log portability "Checking $target" - if ! cargo check --lib --all-features --locked --target "$target" >> "$log_file" 2>&1; then - tail -n 80 "$log_file" >&2 || true - return 1 - fi - done - fi -} - -run_full_correctness() { - local log_file="$1" source ente validator - source="$(get_state libheif_source)" - ente="$(get_state ente_fixtures_dir)" - validator="$(get_state validator_path)" - # The helper is generated test output. Rebuilding it prevents any stale or - # externally altered ignored binary from participating in the trusted gate. - rm -rf "$ROOT_DIR/.heic-test-runs/helper" - if ! HEIC_LIBHEIF_SOURCE_DIR="$source" \ - HEIC_ENTE_FIXTURES_DIR="$ente" \ - LIBHEIF_DEC_BIN="$validator" \ - "$ROOT_DIR/scripts/heic_tests.sh" verify --full --require-exts heic,avif \ - > "$log_file" 2>&1; then - tail -n 100 "$log_file" >&2 || true - return 1 - fi - tail -n 4 "$log_file" -} - -evaluate_candidate() { - local attempt="$1" description="$2" - local attempt_dir="$STATE_DIR/attempts/$attempt" - local candidate_binary="$attempt_dir/candidate-bench" - local champion_binary="$STATE_DIR/champion-bench" - local min_improvement="${HEIC_AUTORESEARCH_MIN_IMPROVEMENT:-0.02}" - local confirmation_min_improvement="${HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT:-0.02}" - local primary_samples="${HEIC_AUTORESEARCH_PAIR_SAMPLES:-4}" - local confirmation_samples="${HEIC_AUTORESEARCH_CONFIRM_SAMPLES:-4}" - local allowed_ratio confirmation_allowed_ratio cumulative commit message - local primary_baseline_score primary_candidate_score primary_speedup - local confirmation_candidate_score confirmation_speedup - mkdir -p "$attempt_dir" - initialize_journal - - awk -v improvement="$min_improvement" \ - 'BEGIN {exit(improvement >= 0 && improvement < 1 ? 0 : 1)}' \ - || die "HEIC_AUTORESEARCH_MIN_IMPROVEMENT must be a fraction in [0, 1)." - awk -v improvement="$confirmation_min_improvement" \ - 'BEGIN {exit(improvement >= 0 && improvement < 1 ? 0 : 1)}' \ - || die "HEIC_AUTORESEARCH_CONFIRM_MIN_IMPROVEMENT must be a fraction in [0, 1)." - - require_champion_head - check_environment_matches_setup - verify_asset_integrity - verify_champion_binary - load_confirmation_files - collect_changed_files - local change_status=0 - changes_are_allowed || change_status=$? - if [[ "$change_status" -ne 0 ]]; then - if [[ "$change_status" -eq 1 ]]; then - record_result "$attempt" - - - - - "$(get_state cumulative_speedup)" \ - no_change "$description" "agent made no source changes" - log discard "Agent made no source changes." - return 2 - fi - reject_candidate "$attempt" "$description" "changed files outside the experiment surface" - return 2 - fi - - if ! git -C "$ROOT_DIR" diff --check HEAD -- > "$attempt_dir/diff-check.log" 2>&1; then - reject_candidate "$attempt" "$description" "git diff check failed" - return 2 - fi - if ! cargo fmt --all -- --check > "$attempt_dir/fmt.log" 2>&1; then - reject_candidate "$attempt" "$description" "cargo fmt check failed" - return 2 - fi - if ! cargo test --all-features --locked > "$attempt_dir/tests.log" 2>&1; then - tail -n 80 "$attempt_dir/tests.log" >&2 || true - reject_candidate "$attempt" "$description" "Rust tests failed" - return 2 - fi - if ! check_no_native_links "$attempt_dir/metadata.json"; then - reject_candidate "$attempt" "$description" "dependency graph contains native linkage or metadata failed" - return 2 - fi - if ! build_benchmark_binary "$candidate_binary" "$attempt_dir/build-benchmark.log"; then - reject_candidate "$attempt" "$description" "candidate benchmark build failed" - return 2 - fi - if ! benchmark_pair "$champion_binary" "$candidate_binary" \ - "$attempt_dir/benchmark-primary" primary "$primary_samples"; then - reject_candidate "$attempt" "$description" "candidate primary hook benchmark crashed" - return 2 - fi - primary_baseline_score="$PAIR_BASELINE_SCORE" - primary_candidate_score="$PAIR_CANDIDATE_SCORE" - primary_speedup="$PAIR_SPEEDUP" - - allowed_ratio="$(awk -v improvement="$min_improvement" 'BEGIN {printf "%.9f", 1 - improvement}')" - if ! awk -v candidate="$primary_candidate_score" -v baseline="$primary_baseline_score" -v ratio="$allowed_ratio" \ - 'BEGIN {exit(candidate <= baseline * ratio ? 0 : 1)}'; then - reject_candidate "$attempt" "$description" \ - "did not clear the ${min_improvement} primary hook improvement" \ - "$primary_candidate_score" "$primary_speedup" - return 2 - fi - - log gate "Candidate is faster; running promotion checks." - if ! cargo clippy --all-targets --all-features --locked -- -D warnings \ - > "$attempt_dir/clippy.log" 2>&1; then - tail -n 80 "$attempt_dir/clippy.log" >&2 || true - reject_candidate "$attempt" "$description" "clippy failed" "$primary_candidate_score" "$primary_speedup" - return 2 - fi - if ! run_portability_checks "$attempt_dir/portability.log"; then - reject_candidate "$attempt" "$description" "portability check failed" "$primary_candidate_score" "$primary_speedup" - return 2 - fi - verify_asset_integrity - if ! run_full_correctness "$attempt_dir/correctness.log"; then - reject_candidate "$attempt" "$description" "full pixel-exact correctness failed" "$primary_candidate_score" "$primary_speedup" - return 2 - fi - - log gate "Correctness passed; running the pinned full-corpus hook confirmation benchmark." - if ! benchmark_pair "$champion_binary" "$candidate_binary" \ - "$attempt_dir/benchmark-confirmation" confirmation "$confirmation_samples"; then - reject_candidate "$attempt" "$description" "candidate confirmation hook benchmark crashed" \ - "$primary_candidate_score" "$primary_speedup" - return 2 - fi - confirmation_candidate_score="$PAIR_CANDIDATE_SCORE" - confirmation_speedup="$PAIR_SPEEDUP" - confirmation_allowed_ratio="$(awk -v improvement="$confirmation_min_improvement" \ - 'BEGIN {printf "%.9f", 1 - improvement}')" - if ! awk -v candidate="$PAIR_CANDIDATE_SCORE" -v baseline="$PAIR_BASELINE_SCORE" \ - -v ratio="$confirmation_allowed_ratio" \ - 'BEGIN {exit(candidate <= baseline * ratio ? 0 : 1)}'; then - reject_candidate "$attempt" "$description" \ - "did not clear the ${confirmation_min_improvement} full-corpus hook confirmation" \ - "$primary_candidate_score" "$primary_speedup" \ - "$confirmation_candidate_score" "$confirmation_speedup" - return 2 - fi - - cumulative="$(awk -v old="$(get_state cumulative_speedup)" -v factor="$confirmation_speedup" \ - 'BEGIN {printf "%.6f", old * factor}')" - collect_changed_files - changes_are_allowed || die "Candidate changed its file scope during evaluation." - git -C "$ROOT_DIR" add -- "${CHANGED_FILES[@]}" - message="$(sanitize_field "$description")" - [[ -n "$message" ]] || message="accepted decoder optimization" - message="${message:0:68}" - if ! git -C "$ROOT_DIR" commit -m "perf(autoresearch): $message" \ - > "$attempt_dir/commit.log" 2>&1; then - git -C "$ROOT_DIR" restore --staged -- "${CHANGED_FILES[@]}" || true - reject_candidate "$attempt" "$description" "git commit failed" \ - "$primary_candidate_score" "$primary_speedup" \ - "$confirmation_candidate_score" "$confirmation_speedup" - return 2 - fi - commit="$(current_commit)" - cp "$candidate_binary" "$champion_binary" - set_state champion_sha256 "$(shasum -a 256 "$champion_binary" | awk '{print $1}')" - set_state champion_commit "$commit" - set_state champion_score_ms "$primary_candidate_score" - set_state champion_confirmation_score_ms "$confirmation_candidate_score" - set_state cumulative_speedup "$cumulative" - record_result "$attempt" "$(short_commit "$commit")" \ - "$primary_candidate_score" "$primary_speedup" \ - "$confirmation_candidate_score" "$confirmation_speedup" \ - "$cumulative" keep "$description" - require_clean_worktree - log keep "Committed $(short_commit "$commit"); estimated cumulative speedup=${cumulative}x" -} - -cmd_setup() { - require_cmd awk - require_cmd cargo - require_cmd cmp - require_cmd codex - require_cmd find - require_cmd git - require_cmd jq - require_cmd rustc - require_cmd rustup - require_cmd shasum - require_cmd sort - ensure_external_state_dir - initialize_journal - require_clean_worktree - load_benchmark_files - resolve_setup_paths - - if [[ -e "$STATE_FILE" ]]; then - local archive="${STATE_DIR}.archive.$(date -u '+%Y%m%dT%H%M%SZ')" - mv "$STATE_DIR" "$archive" - log setup "Archived previous trusted state to $archive" - mkdir -p "$STATE_DIR" - fi - - printf '%s\t%s\n' \ - repo_root "$ROOT_DIR" \ - branch "$(current_branch)" \ - champion_commit "$(current_commit)" \ - rustc_version "$(rustc --version)" \ - rustflags "${RUSTFLAGS:-}" \ - architecture "$(uname -m)" \ - created_at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ - next_attempt 1 \ - cumulative_speedup 1.000000 \ - > "$STATE_FILE" - printf 'timestamp\tattempt\tcommit\tprimary_score_ms\tprimary_speedup\tconfirmation_score_ms\tconfirmation_speedup\tcumulative_speedup\tstatus\tdescription\n' \ - > "$RESULTS_FILE" - write_corpus_dirs - - log setup "Running baseline Rust tests" - cargo test --all-features --locked > "$STATE_DIR/setup-tests.log" 2>&1 \ - || { tail -n 100 "$STATE_DIR/setup-tests.log" >&2; die "Baseline Rust tests failed."; } - log setup "Running the complete baseline correctness oracle" - if ! HEIC_LIBHEIF_SOURCE_DIR="$SETUP_LIBHEIF_SOURCE" \ - HEIC_ENTE_FIXTURES_DIR="$SETUP_ENTE_DIR" \ - LIBHEIF_BUILD_DIR="${LIBHEIF_BUILD_DIR:-$ROOT_DIR/.heic-test-runs/validator-build}" \ - LIBHEIF_DEC_BIN="$SETUP_VALIDATOR" \ - "$ROOT_DIR/scripts/heic_tests.sh" verify --full --require-exts heic,avif \ - > "$STATE_DIR/setup-correctness.log" 2>&1; then - tail -n 100 "$STATE_DIR/setup-correctness.log" >&2 || true - die "Baseline correctness failed." - fi - capture_asset_integrity - - log setup "Building and measuring the baseline benchmark" - build_benchmark_binary "$STATE_DIR/champion-bench" "$STATE_DIR/setup-benchmark-build.log" \ - || die "Could not build the baseline benchmark." - set_state champion_sha256 "$(shasum -a 256 "$STATE_DIR/champion-bench" | awk '{print $1}')" - local score confirmation_score - score="$(benchmark_score "$STATE_DIR/champion-bench" "$STATE_DIR/setup-benchmark.log" 1 3 primary)" \ - || die "Baseline benchmark failed." - prepare_confirmation_corpus "$STATE_DIR/champion-bench" "$STATE_DIR/setup-confirmation-probe.log" \ - || die "Could not build the pinned full-corpus hook confirmation set." - confirmation_score="$(benchmark_score "$STATE_DIR/champion-bench" \ - "$STATE_DIR/setup-confirmation-benchmark.log" 1 3 confirmation)" \ - || die "Baseline confirmation benchmark failed." - set_state initial_score_ms "$score" - set_state champion_score_ms "$score" - set_state initial_confirmation_score_ms "$confirmation_score" - set_state champion_confirmation_score_ms "$confirmation_score" - record_result baseline "$(short_commit "$(current_commit)")" \ - "$score" 1.000000 "$confirmation_score" 1.000000 1.000000 keep baseline - log setup "Ready on $(current_branch) at $(short_commit "$(current_commit)"); primary=${score}ms confirmation=${confirmation_score}ms" - log setup "Trusted state: $STATE_DIR" -} - -first_nonempty_line() { - awk 'NF {print; exit}' "$1" 2>/dev/null || true -} - -run_agent_attempt() { - local attempt="$1" model="$2" prompt_file="$3" output_file="$4" log_file="$5" - local champion history journal_excerpt - initialize_journal - champion="$(short_commit "$(get_state champion_commit)")" - history="$(tail -n 31 "$RESULTS_FILE")" - journal_excerpt="$(tail -n 500 "$JOURNAL_FILE")" - { - printf 'Read and follow autoresearch/program.md exactly.\n\n' - printf 'This is experiment attempt %s. The current champion is %s.\n' "$attempt" "$champion" - printf 'The trusted controller will evaluate after you return. Do not commit.\n\n' - printf 'The complete controller-owned experiment journal is mirrored at ' - printf '`.heic-autoresearch/experiments.md`. Read it completely before choosing an idea.\n\n' - printf 'Recent experiment ledger (TSV):\n%s\n' "$history" - printf '\nRecent journal excerpt (Markdown):\n%s\n' "$journal_excerpt" - } > "$prompt_file" - - local args=(exec --ephemeral --color never --sandbox workspace-write --cd "$ROOT_DIR" --output-last-message "$output_file") - [[ -n "$model" ]] && args+=(--model "$model") - log agent "Starting attempt $attempt" - codex "${args[@]}" - < "$prompt_file" > "$log_file" 2>&1 -} - -cmd_run() { - local hours="" max_experiments=0 model="" experiments=0 - while [[ $# -gt 0 ]]; do - case "$1" in - --hours) hours="$2"; shift 2 ;; - --max-experiments) max_experiments="$2"; shift 2 ;; - --model) model="$2"; shift 2 ;; - -h|--help) usage; return 0 ;; - *) die "Unknown run option: $1" ;; - esac - done - [[ -n "$hours" ]] || die "run requires --hours N" - awk -v hours="$hours" 'BEGIN {exit(hours > 0 ? 0 : 1)}' || die "--hours must be positive." - [[ "$max_experiments" =~ ^[0-9]+$ ]] || die "--max-experiments must be a non-negative integer." - - require_cmd codex - ensure_external_state_dir - initialize_journal - require_champion_head - require_clean_worktree - check_environment_matches_setup - verify_asset_integrity - verify_champion_binary - load_benchmark_files - - local lock_dir="$STATE_DIR/run.lock" - acquire_run_lock "$lock_dir" - trap 'exit 130' INT TERM - rm -f "$STOP_FILE" - - local started deadline now attempt attempt_dir agent_status description eval_status - started="$(date +%s)" - deadline="$(awk -v start="$started" -v hours="$hours" 'BEGIN {printf "%.0f", start + hours * 3600}')" - log run "Running for up to ${hours}h on $(current_branch); Ctrl-C leaves the current candidate for inspection." - - while :; do - now="$(date +%s)" - [[ "$now" -lt "$deadline" ]] || break - [[ ! -e "$STOP_FILE" ]] || { log run "Stop requested."; break; } - if [[ "$max_experiments" -gt 0 && "$experiments" -ge "$max_experiments" ]]; then - break - fi - require_champion_head - require_clean_worktree - verify_asset_integrity - - attempt="$(get_state next_attempt)" - set_state next_attempt "$((attempt + 1))" - attempt_dir="$STATE_DIR/attempts/$attempt" - mkdir -p "$attempt_dir" - set +e - run_agent_attempt "$attempt" "$model" "$attempt_dir/prompt.txt" \ - "$attempt_dir/agent-last.txt" "$attempt_dir/agent.log" - agent_status=$? - set -e - # The repository copy is deliberately untrusted and ignored. Replace it - # after every agent turn from the controller-owned external journal. - mirror_journal - description="$(first_nonempty_line "$attempt_dir/agent-last.txt")" - description="${description:-agent attempt $attempt}" - collect_changed_files - if [[ "$agent_status" -ne 0 ]]; then - if [[ ${#CHANGED_FILES[@]} -gt 0 ]]; then - reject_candidate "$attempt" "$description" "Codex exited with status $agent_status" || true - else - record_result "$attempt" - - - - - "$(get_state cumulative_speedup)" \ - crash "$description" "Codex exited with status $agent_status" - fi - experiments=$((experiments + 1)) - continue - fi - - set +e - evaluate_candidate "$attempt" "$description" - eval_status=$? - set -e - if [[ "$eval_status" -eq 1 ]]; then - die "A trusted evaluation invariant failed; stopping the loop." - fi - experiments=$((experiments + 1)) - done - - log run "Finished $experiments attempt(s). Champion=$(short_commit "$(get_state champion_commit)") cumulative=$(get_state cumulative_speedup)x" - log run "Results: $RESULTS_FILE" -} - -cmd_evaluate() { - local description="manual candidate" - while [[ $# -gt 0 ]]; do - case "$1" in - --description) description="$2"; shift 2 ;; - -h|--help) usage; return 0 ;; - *) die "Unknown evaluate option: $1" ;; - esac - done - ensure_external_state_dir - initialize_journal - require_champion_head - check_environment_matches_setup - verify_asset_integrity - load_benchmark_files - local attempt - attempt="$(get_state next_attempt)" - set_state next_attempt "$((attempt + 1))" - evaluate_candidate "$attempt" "$description" -} - -cmd_bench() { - local samples=5 temp_dir binary score - while [[ $# -gt 0 ]]; do - case "$1" in - --samples) samples="$2"; shift 2 ;; - -h|--help) usage; return 0 ;; - *) die "Unknown bench option: $1" ;; - esac - done - [[ "$samples" =~ ^[1-9][0-9]*$ ]] || die "--samples must be a positive integer." - ensure_external_state_dir - load_benchmark_files - temp_dir="$STATE_DIR/manual-benchmark" - mkdir -p "$temp_dir" - binary="$temp_dir/current-bench" - build_benchmark_binary "$binary" "$temp_dir/build.log" || die "Benchmark build failed." - score="$(benchmark_score "$binary" "$temp_dir/run.log" 1 "$samples" primary)" || die "Benchmark failed." - cat "$temp_dir/run.log" - log bench "score=${score}ms" -} - -cmd_status() { - ensure_external_state_dir - [[ -f "$STATE_FILE" ]] || die "No baseline has been set up." - initialize_journal - printf 'branch: %s\n' "$(get_state branch)" - printf 'champion: %s\n' "$(short_commit "$(get_state champion_commit)")" - printf 'initial_score_ms: %s\n' "$(get_state initial_score_ms)" - printf 'champion_score_ms: %s\n' "$(get_state champion_score_ms)" - printf 'initial_confirm_ms: %s\n' "$(get_state initial_confirmation_score_ms)" - printf 'champion_confirm_ms: %s\n' "$(get_state champion_confirmation_score_ms)" - printf 'confirmation_files: %s\n' "$(get_state confirmation_file_count)" - printf 'cumulative_speedup: %sx\n' "$(get_state cumulative_speedup)" - printf 'trusted_state: %s\n' "$STATE_DIR" - printf 'experiment_journal: %s\n' "$JOURNAL_MIRROR" - printf '\nRecent results:\n' - tail -n 11 "$RESULTS_FILE" -} - -cmd_stop() { - ensure_external_state_dir - [[ -f "$STATE_FILE" ]] || die "No active autoresearch baseline." - : > "$STOP_FILE" - log stop "Stop requested. The loop will stop before its next attempt." -} - -main() { - local command="${1:-}" - if [[ -z "$command" || "$command" == "-h" || "$command" == "--help" ]]; then - usage - return 0 - fi - shift - cd "$ROOT_DIR" - case "$command" in - setup) cmd_setup "$@" ;; - run) cmd_run "$@" ;; - evaluate) cmd_evaluate "$@" ;; - bench) cmd_bench "$@" ;; - status) cmd_status "$@" ;; - stop) cmd_stop "$@" ;; - *) die "Unknown command: $command" ;; - esac -} - -main "$@"