Feat: Use FFTW linear convolution for CPU fast-mode PSF resolve

Replace the nested spatial global convolution in fast_psf_accumulator_resolve with a reusable double-precision FFTW linear convolution on the CPU PSF backend.

- Add the private src/fast_psf_fftw.{c,h} module: zero-padded R2C/C2R plans, cached kernel spectrum and planar scratch, exact 1/(Pwidth*Pheight) and 1/N^2 normalization, (R,R) crop, and additive HDR output.
- Keep the previous nested loops as fast_psf_accumulator_resolve_spatial_reference for tests/benchmarks only; it is not a runtime fallback.
- Cache the circular row spans on FastPsfAccumulator and report one-time plan, kernel transform, scratch, and per-frame stage timings.
- Require fftw3_omp for CPU builds; HIP and dummy builds do not link FFTW.
- Namespace test/helper binaries by spacetime and build tag, and reject make test / psf-capture for non-CPU backends.
- Add tests/test_fast_psf_fftw.c (FFTW versus spatial), tests/benchmark_fast_psf_fftw.c, an FFTW CLI smoke check, and the 2026-09-25 benchmark record.
This commit is contained in:
wyj committed 2026-09-25 23:35:45 -04:00
1 parent 3deebfb2fa
commit 229f50cd86
14 files changed
+2372 -55

No files matched your search

+90 -35
View File
@@ -29,22 +29,14 @@ else
IMAGE_EXT := ppm IMAGE_EXT := ppm
endif endif
COMMON_SOURCES := $(filter-out src/main.c src/dummy_psf.c src/spacetime_minkowski.c src/spacetime_schwarzschild.c,$(wildcard src/*.c)) COMMON_SOURCES := $(filter-out src/main.c src/dummy_psf.c src/fast_psf_fftw.c src/spacetime_minkowski.c src/spacetime_schwarzschild.c,$(wildcard src/*.c))
PROVIDER_SOURCE := src/spacetime_$(SPACETIME).c PROVIDER_SOURCE := src/spacetime_$(SPACETIME).c
BUILD_DIR := build/$(BUILD_TYPE) BUILD_DIR := build/$(BUILD_TYPE)
TARGET_BASENAME := $(SPACETIME)_sky TARGET_BASENAME := $(SPACETIME)_sky
OBJECT_DIR := $(BUILD_DIR)/obj/$(SPACETIME) OBJECT_DIR := $(BUILD_DIR)/obj/$(SPACETIME)
CORE_MINKOWSKI_SOURCES := $(COMMON_SOURCES) src/spacetime_minkowski.c CORE_MINKOWSKI_SOURCES := $(COMMON_SOURCES) src/spacetime_minkowski.c
TEST_TARGET := $(BUILD_DIR)/test_geodesic
FRAME_TEST_TARGET := $(BUILD_DIR)/test_frame
SCHWARZSCHILD_TEST_TARGET := $(BUILD_DIR)/test_schwarzschild
OBSERVER_TRACK_TEST_TARGET := $(BUILD_DIR)/test_observer_track
CATALOG_PREFETCH_TEST_TARGET := $(BUILD_DIR)/test_catalog_prefetch
HIP_PSF_TEST_TARGET := $(BUILD_DIR)/test_hip_psf
HIP_PSF_BENCH_TARGET := $(BUILD_DIR)/benchmark_hip_psf
CAMERA_TEST_TARGETS := $(BUILD_DIR)/test_observer_minkowski $(BUILD_DIR)/test_observer_schwarzschild
.PHONY: all backend clean run test hip-psf-test hip-psf-bench minkowski schwarzschild FORCE .PHONY: all backend clean run test hip-psf-test hip-psf-bench fast-psf-fftw-bench minkowski schwarzschild FORCE
ifneq ($(filter 0 1,$(PSF_EVENT_SINK)),$(PSF_EVENT_SINK)) ifneq ($(filter 0 1,$(PSF_EVENT_SINK)),$(PSF_EVENT_SINK))
$(error Unknown PSF_EVENT_SINK '$(PSF_EVENT_SINK)'; choose 0 or 1) $(error Unknown PSF_EVENT_SINK '$(PSF_EVENT_SINK)'; choose 0 or 1)
@@ -53,6 +45,15 @@ BUILD_CPPFLAGS += -DFRAME_PSF_EVENT_SINK=$(PSF_EVENT_SINK)
ifeq ($(PSF_BACKEND),cpu) ifeq ($(PSF_BACKEND),cpu)
RENDER_LINKER := $(CC) RENDER_LINKER := $(CC)
ifeq ($(shell pkg-config --exists fftw3_omp && echo yes),yes)
CPU_FFTW_CPPFLAGS := -DFAST_PSF_FFTW $(shell pkg-config --cflags fftw3_omp)
CPU_FFTW_LDLIBS := $(shell pkg-config --libs fftw3_omp)
CPU_FFTW_SOURCES := src/fast_psf_fftw.c
BUILD_CPPFLAGS += $(CPU_FFTW_CPPFLAGS)
LDLIBS += $(CPU_FFTW_LDLIBS)
else
$(error PSF_BACKEND=cpu requires the FFTW OpenMP package 'fftw3_omp' (pkg-config --exists fftw3_omp). On Gentoo enable sci-libs/fftw[openmp].)
endif
else ifeq ($(PSF_BACKEND),hip) else ifeq ($(PSF_BACKEND),hip)
RENDER_LINKER := $(HIPCC) RENDER_LINKER := $(HIPCC)
BUILD_CPPFLAGS += -DPSF_BACKEND_HIP BUILD_CPPFLAGS += -DPSF_BACKEND_HIP
@@ -63,6 +64,23 @@ else
$(error Unknown PSF_BACKEND '$(PSF_BACKEND)'; choose cpu, hip, or dummy) $(error Unknown PSF_BACKEND '$(PSF_BACKEND)'; choose cpu, hip, or dummy)
endif endif
# `make test` builds and runs the CPU regression suite. The dummy and hip
# backends do not build those binaries, so reject the goal explicitly instead of
# silently reusing a stale test binary left by an earlier CPU build.
ifneq ($(filter test test-reference-images,$(MAKECMDGOALS)),)
ifneq ($(PSF_BACKEND),cpu)
$(error make test requires PSF_BACKEND=cpu)
endif
endif
# psf-capture is a CPU producer diagnostic; the dummy and hip backends do not
# provide the producer/frame symbols it links.
ifneq ($(filter psf-capture,$(MAKECMDGOALS)),)
ifneq ($(PSF_BACKEND),cpu)
$(error psf-capture requires PSF_BACKEND=cpu)
endif
endif
ifeq ($(SPACETIME),minkowski) ifeq ($(SPACETIME),minkowski)
BACKEND_CPPFLAGS := -DSPACETIME_MINKOWSKI BACKEND_CPPFLAGS := -DSPACETIME_MINKOWSKI
else ifeq ($(SPACETIME),schwarzschild) else ifeq ($(SPACETIME),schwarzschild)
@@ -81,6 +99,21 @@ else
$(error Unknown ENABLE_HDR '$(ENABLE_HDR)'; choose 0 or 1) $(error Unknown ENABLE_HDR '$(ENABLE_HDR)'; choose 0 or 1)
endif endif
# Test binaries are namespaced by spacetime and build tag so that switching
# PSF_BACKEND/ENABLE_HDR/PSF_EVENT_SINK cannot silently reuse a stale binary
# built for a different backend.
TEST_OUT_DIR := $(OBJECT_DIR)/$(HDR_BUILD_TAG)
TEST_TARGET := $(TEST_OUT_DIR)/test_geodesic
FRAME_TEST_TARGET := $(TEST_OUT_DIR)/test_frame
SCHWARZSCHILD_TEST_TARGET := $(TEST_OUT_DIR)/test_schwarzschild
OBSERVER_TRACK_TEST_TARGET := $(TEST_OUT_DIR)/test_observer_track
CATALOG_PREFETCH_TEST_TARGET := $(TEST_OUT_DIR)/test_catalog_prefetch
HIP_PSF_TEST_TARGET := $(TEST_OUT_DIR)/test_hip_psf
HIP_PSF_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_hip_psf
CAMERA_TEST_TARGETS := $(TEST_OUT_DIR)/test_observer_minkowski $(TEST_OUT_DIR)/test_observer_schwarzschild
FAST_PSF_FFTW_TEST_TARGET := $(TEST_OUT_DIR)/test_fast_psf_fftw
FAST_PSF_FFTW_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_fast_psf_fftw
ifeq ($(PSF_BACKEND),hip) ifeq ($(PSF_BACKEND),hip)
TARGET := $(BUILD_DIR)/$(TARGET_BASENAME)_hip TARGET := $(BUILD_DIR)/$(TARGET_BASENAME)_hip
else ifeq ($(PSF_BACKEND),dummy) else ifeq ($(PSF_BACKEND),dummy)
@@ -89,6 +122,7 @@ else
TARGET := $(BUILD_DIR)/$(TARGET_BASENAME) TARGET := $(BUILD_DIR)/$(TARGET_BASENAME)
endif endif
RENDER_SOURCES := $(COMMON_SOURCES) $(PROVIDER_SOURCE) src/main.c RENDER_SOURCES := $(COMMON_SOURCES) $(PROVIDER_SOURCE) src/main.c
RENDER_SOURCES += $(CPU_FFTW_SOURCES)
ifeq ($(PSF_BACKEND),dummy) ifeq ($(PSF_BACKEND),dummy)
RENDER_SOURCES += src/dummy_psf.c RENDER_SOURCES += src/dummy_psf.c
endif endif
@@ -140,10 +174,10 @@ ifeq ($(PSF_BACKEND),hip)
hip-psf-test: $(HIP_PSF_TEST_TARGET) hip-psf-test: $(HIP_PSF_TEST_TARGET)
hip-psf-bench: $(HIP_PSF_BENCH_TARGET) hip-psf-bench: $(HIP_PSF_BENCH_TARGET)
$(HIP_PSF_BENCH_TARGET): tests/benchmark_hip_psf.hip $(HIP_PSF_OBJECT) $(OBJECT_DIR)/$(HDR_BUILD_TAG)/src/optics.o | $(BUILD_DIR) $(HIP_PSF_BENCH_TARGET): tests/benchmark_hip_psf.hip $(HIP_PSF_OBJECT) $(OBJECT_DIR)/$(HDR_BUILD_TAG)/src/optics.o | $(TEST_OUT_DIR)
$(HIPCC) $(HIP_CXXFLAGS) $(OPENMP_FLAGS) -Isrc -x hip $< -x none $(filter-out $<,$^) $(LDLIBS) $(HDR_LDLIBS) -o $@ $(HIPCC) $(HIP_CXXFLAGS) $(OPENMP_FLAGS) -Isrc -x hip $< -x none $(filter-out $<,$^) $(LDLIBS) $(HDR_LDLIBS) -o $@
$(HIP_PSF_TEST_TARGET): tests/test_hip_psf.hip $(HIP_PSF_OBJECT) $(OBJECT_DIR)/$(HDR_BUILD_TAG)/src/optics.o | $(BUILD_DIR) $(HIP_PSF_TEST_TARGET): tests/test_hip_psf.hip $(HIP_PSF_OBJECT) $(OBJECT_DIR)/$(HDR_BUILD_TAG)/src/optics.o | $(TEST_OUT_DIR)
$(HIPCC) $(HIP_CXXFLAGS) $(OPENMP_FLAGS) -Isrc -x hip $< -x none $(filter-out $<,$^) $(LDLIBS) $(HDR_LDLIBS) -o $@ $(HIPCC) $(HIP_CXXFLAGS) $(OPENMP_FLAGS) -Isrc -x hip $< -x none $(filter-out $<,$^) $(LDLIBS) $(HDR_LDLIBS) -o $@
else else
hip-psf-test hip-psf-bench: hip-psf-test hip-psf-bench:
@@ -154,36 +188,57 @@ run: $(TARGET)
mkdir -p output/imgs mkdir -p output/imgs
./$(TARGET) --catalog assets/sky_grid_5deg.csv --output output/imgs/$(SPACETIME)_sky.$(IMAGE_EXT) ./$(TARGET) --catalog assets/sky_grid_5deg.csv --output output/imgs/$(SPACETIME)_sky.$(IMAGE_EXT)
$(TEST_TARGET): tests/test_geodesic.c $(CORE_MINKOWSKI_SOURCES) | $(BUILD_DIR) $(TEST_OUT_DIR): | $(BUILD_DIR)
mkdir -p $@
$(TEST_TARGET): tests/test_geodesic.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
$(FRAME_TEST_TARGET): tests/test_frame.c $(CORE_MINKOWSKI_SOURCES) | $(BUILD_DIR) $(FRAME_TEST_TARGET): tests/test_frame.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
$(SCHWARZSCHILD_TEST_TARGET): tests/test_schwarzschild.c $(COMMON_SOURCES) src/spacetime_schwarzschild.c | $(BUILD_DIR) $(SCHWARZSCHILD_TEST_TARGET): tests/test_schwarzschild.c $(COMMON_SOURCES) src/spacetime_schwarzschild.c $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
$(OBSERVER_TRACK_TEST_TARGET): tests/test_observer_track.c $(COMMON_SOURCES) | $(BUILD_DIR) $(OBSERVER_TRACK_TEST_TARGET): tests/test_observer_track.c $(COMMON_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
$(CATALOG_PREFETCH_TEST_TARGET): tests/test_catalog_prefetch.c $(CORE_MINKOWSKI_SOURCES) | $(BUILD_DIR) $(CATALOG_PREFETCH_TEST_TARGET): tests/test_catalog_prefetch.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
$(BUILD_DIR)/test_observer_minkowski: tests/test_observer.c $(CORE_MINKOWSKI_SOURCES) | $(BUILD_DIR) $(TEST_OUT_DIR)/test_observer_minkowski: tests/test_observer.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
$(BUILD_DIR)/test_observer_schwarzschild: tests/test_observer.c $(COMMON_SOURCES) src/spacetime_schwarzschild.c | $(BUILD_DIR) $(TEST_OUT_DIR)/test_observer_schwarzschild: tests/test_observer.c $(COMMON_SOURCES) src/spacetime_schwarzschild.c $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -DSPACETIME_SCHWARZSCHILD -Isrc $^ $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -DSPACETIME_SCHWARZSCHILD -Isrc $^ $(LDLIBS) -o $@
test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_TARGET): tests/test_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
./$(BUILD_DIR)/test_observer_minkowski $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
./$(BUILD_DIR)/test_observer_schwarzschild
./$(TEST_TARGET) $(FAST_PSF_FFTW_BENCH_TARGET): tests/benchmark_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
./$(FRAME_TEST_TARGET) $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
./$(SCHWARZSCHILD_TEST_TARGET)
./$(OBSERVER_TRACK_TEST_TARGET) # The FFTW-vs-spatial test is meaningful only in the CPU PSF build.
./$(CATALOG_PREFETCH_TEST_TARGET) ifneq ($(CPU_FFTW_SOURCES),)
python3 tests/test_camera_cli.py $(BUILD_DIR) FAST_PSF_FFTW_TEST_DEP := $(FAST_PSF_FFTW_TEST_TARGET)
FAST_PSF_FFTW_TEST_RUN := $(FAST_PSF_FFTW_TEST_TARGET)
else
FAST_PSF_FFTW_TEST_DEP :=
FAST_PSF_FFTW_TEST_RUN :=
endif
test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP)
$(TEST_OUT_DIR)/test_observer_minkowski
$(TEST_OUT_DIR)/test_observer_schwarzschild
$(TEST_TARGET)
$(FRAME_TEST_TARGET)
$(SCHWARZSCHILD_TEST_TARGET)
$(OBSERVER_TRACK_TEST_TARGET)
$(CATALOG_PREFETCH_TEST_TARGET)
$(FAST_PSF_FFTW_TEST_RUN)
python3 tests/test_camera_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
fast-psf-fftw-bench: $(FAST_PSF_FFTW_BENCH_TARGET)
clean: clean:
rm -rf build rm -rf build
@@ -193,14 +248,14 @@ include mk/reference_images.mk
# Test-only producer consumer: never linked into a renderer. # Test-only producer consumer: never linked into a renderer.
.PHONY: psf-capture .PHONY: psf-capture
psf-capture: $(BUILD_DIR)/capture_psf psf-capture: $(TEST_OUT_DIR)/capture_psf
$(BUILD_DIR)/capture_psf: tests/capture_psf.c $(CORE_MINKOWSKI_SOURCES) | $(BUILD_DIR) $(TEST_OUT_DIR)/capture_psf: tests/capture_psf.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $< $(filter-out src/frame.c,$(CORE_MINKOWSKI_SOURCES)) $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $< $(filter-out src/frame.c,$(CORE_MINKOWSKI_SOURCES)) $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
.PHONY: hip-psf-replay .PHONY: hip-psf-replay
hip-psf-replay: $(BUILD_DIR)/replay_psf hip-psf-replay: $(TEST_OUT_DIR)/replay_psf
$(BUILD_DIR)/replay_psf: tests/replay_psf.hip src/hip_psf.hip src/hip_psf.h src/optics.h $(OBJECT_DIR)/$(HDR_BUILD_TAG)/src/optics.o | $(BUILD_DIR) $(TEST_OUT_DIR)/replay_psf: tests/replay_psf.hip src/hip_psf.hip src/hip_psf.h src/optics.h $(OBJECT_DIR)/$(HDR_BUILD_TAG)/src/optics.o | $(TEST_OUT_DIR)
$(HIPCC) $(HIP_CXXFLAGS) $(OPENMP_FLAGS) -Isrc -x hip $< -x none $(filter %.o,$^) $(LDLIBS) $(HDR_LDLIBS) -o $@ $(HIPCC) $(HIP_CXXFLAGS) $(OPENMP_FLAGS) -Isrc -x hip $< -x none $(filter %.o,$^) $(LDLIBS) $(HDR_LDLIBS) -o $@
$(BUILD_DIR)/make_psf_fixture: tests/make_psf_fixture.c src/optics.c src/optics.h | $(BUILD_DIR) $(TEST_OUT_DIR)/make_psf_fixture: tests/make_psf_fixture.c src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $< src/optics.c $(LDLIBS) -o $@ $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $< src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
+280
View File
@@ -0,0 +1,280 @@
# Fast-mode FFTW convolution benchmark (2026-09-25)
Replaces the spatial fast-mode global convolution with a reusable FFTW linear
convolution. This record separates one-time plan/setup from steady-state
execution and repeats the bounded Galactic-center comparison. Raw resolver and
end-to-end output is pasted verbatim from the runs below.
## Environment
- Git revision: `3deebfb` plus the uncommitted FFTW work.
- Host: 12th Gen Intel Core i7-12700K, 16 hardware threads, 128 GB.
- FFTW: `pkg-config --modversion fftw3_omp` = `3.3.10`;
`pkg-config --libs fftw3_omp` = `-lfftw3_omp -lfftw3`.
- Build:
`make -j8 BUILD_TYPE=Release PSF_BACKEND=cpu SPACETIME=minkowski backend`
(`-O2 -DNDEBUG -fopenmp -march=native`, libpng, `-DFAST_PSF_FFTW`).
- Binary SHA-256 (this run):
- `build/Release/minkowski_sky`:
`ead2a543b8166abe6f82bc1cd41b2e985a6db517aa45a6416d08d188138b8c47`
- `build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw`:
`8da8bbd532104a0c4f524aabf40b66a50c8c715e44479c7db467669ab5a21215`
- Timing wrapper: `/usr/bin/time` is not installed on this host. The
reproducible wrapper used for the end-to-end runs is zsh's built-in `time`
with `TIMEFMT='real=%*E user=%U sys=%S cpu=%P'`, i.e. each command was run as
`TIMEFMT='real=%*E user=%U sys=%S cpu=%P'; time <command>`. The `real=...`
lines below are the verbatim wrapper output.
## Correctness
```sh
make -j8 BUILD_TYPE=Debug PSF_BACKEND=cpu SPACETIME=minkowski test
```
All C tests pass, including the dedicated FFTW-vs-spatial test (258 comparisons
over sizes `17x13`/`16x16`/`23x31`/`33x17`, `N = 1..4`, nearest and bilinear,
eight scenes; worst `max_abs = 1.8e-14`), the `test_frame` fast-mode
regressions, the non-fast FITS references, and both camera CLI checks. HIP and
dummy renderers compile and link without FFTW; `make PSF_BACKEND=dummy test`
now fails fast with `make test requires PSF_BACKEND=cpu` instead of reusing a
stale CPU test binary.
## Resolver-only benchmark
One deterministic sparse impulse buffer, default PSF (FWHM 2.7, beta 4.5,
relative tail 1e-8), `OMP_NUM_THREADS=16`. `--spatial` additionally runs the
reference resolver on the identical buffer. Binary:
`build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw`.
### Default 320x240 N=2, FFTW_ESTIMATE
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 320 --height 240 --supersample 2 --repeats 7 --spatial
```
```text
impulse: 320x240 supersample=2 deposit=nearest repeats=7 spatial=1
plan_mode=estimate
setup: init=0.039444 s fftw_plan=0.008085 s kernel_fft=0.005237 s
Fast FFTW: linear min=666x826, fft=672x840, workers=16, plan=estimate, plan=0.008085 s, kernel_fft=0.005237 s, scratch=30.2 MiB
impulse_hash=5334fa90cfbb1089
fftw: min=0.005602 s median=0.006455 s max=0.008144 s
fftw samples: 0.006697 0.005784 0.005602 0.005898 0.006455 0.007675 0.008144
fftw_hdr_hash=9ad60d92992c9bbd
spatial: 0.702470 s
spatial_hdr_hash=9327ac3e8e10d670
difference: max_abs=7.49e-16 peak=0.359 speedup=108.831x
rss_max_kb=52908
```
### Default 320x240 N=4, FFTW_ESTIMATE
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 320 --height 240 --supersample 4 --repeats 7 --spatial
```
```text
impulse: 320x240 supersample=4 deposit=nearest repeats=7 spatial=1
plan_mode=estimate
setup: init=0.114719 s fftw_plan=0.007169 s kernel_fft=0.011942 s
Fast FFTW: linear min=1330x1650, fft=1344x1680, workers=16, plan=estimate, plan=0.007169 s, kernel_fft=0.011942 s, scratch=120.7 MiB
impulse_hash=2639864ea9d3a26a
fftw: min=0.034756 s median=0.039591 s max=0.042474 s
fftw samples: 0.041352 0.039591 0.034756 0.040866 0.042474 0.035364 0.036241
fftw_hdr_hash=1ff070fdaac85f4a
spatial: 11.054385 s
spatial_hdr_hash=7682ee8550f88efe
difference: max_abs=5.38e-15 peak=0.533 speedup=279.214x
rss_max_kb=161032
```
### Medium 960x540 N=2, FFTW_ESTIMATE (FFTW only)
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 960 --height 540 --supersample 2 --repeats 7
```
```text
impulse: 960x540 supersample=2 deposit=nearest repeats=7 spatial=0
plan_mode=estimate
setup: init=0.042270 s fftw_plan=0.006317 s kernel_fft=0.010302 s
Fast FFTW: linear min=1266x2106, fft=1280x2160, workers=16, plan=estimate, plan=0.006317 s, kernel_fft=0.010302 s, scratch=147.7 MiB
impulse_hash=6e2de17f6859cd33
fftw: min=0.038361 s median=0.039872 s max=0.055834 s
fftw samples: 0.055834 0.038745 0.039872 0.038823 0.038361 0.048436 0.040141
fftw_hdr_hash=c493dbb793d04706
rss_max_kb=216272
```
### 4K 3840x2160 N=2, FFTW_ESTIMATE (FFTW only)
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 3840 --height 2160 --supersample 2 --repeats 5
```
```text
impulse: 3840x2160 supersample=2 deposit=nearest repeats=5 spatial=0
plan_mode=estimate
setup: init=0.524840 s fftw_plan=0.008154 s kernel_fft=0.482906 s
Fast FFTW: linear min=4506x7866, fft=4536x7875, workers=16, plan=estimate, plan=0.008154 s, kernel_fft=0.482906 s, scratch=1907.8 MiB
impulse_hash=e3b6d7f3d621630d
fftw: min=2.368634 s median=2.481927 s max=2.553895 s
fftw samples: 2.552212 2.553895 2.481927 2.389895 2.368634
fftw_hdr_hash=1c5720f72a040e6d
rss_max_kb=2879596
```
### 4K 3840x2160 N=4, FFTW_ESTIMATE (FFTW only)
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 3840 --height 2160 --supersample 4 --repeats 5
```
```text
impulse: 3840x2160 supersample=4 deposit=nearest repeats=5 spatial=0
plan_mode=estimate
setup: init=0.738771 s fftw_plan=0.003636 s kernel_fft=0.599235 s
Fast FFTW: linear min=9010x15730, fft=9072x15750, workers=16, plan=estimate, plan=0.003636 s, kernel_fft=0.599235 s, scratch=7631.4 MiB
impulse_hash=e6a528db0fb16c33
fftw: min=3.276609 s median=3.339285 s max=3.369411 s
fftw samples: 3.309935 3.369411 3.276609 3.345614 3.339285
fftw_hdr_hash=e55bed24ef3f5506
rss_max_kb=10919208
```
### FFTW_ESTIMATE versus FFTW_MEASURE
`FFTW_MEASURE` cuts steady-state time but costs a large one-time plan. Because
the plan is created once per process and reused across frames, it only pays off
for long movie renders; production therefore defaults to `FFTW_ESTIMATE`.
Wisdom import/export would make `FFTW_MEASURE` the better default and remains a
follow-up. Measured FFTW_MEASURE plan output is not bit-reproducible (plan
measurement is timing-dependent), so its `fftw_hdr_hash` may differ run to run;
the spatial comparison stays at roundoff.
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 320 --height 240 --supersample 2 --repeats 7 --measure --spatial
```
```text
impulse: 320x240 supersample=2 deposit=nearest repeats=7 spatial=1
plan_mode=measure
setup: init=1.584388 s fftw_plan=1.091687 s kernel_fft=0.467405 s
Fast FFTW: linear min=666x826, fft=672x840, workers=16, plan=measure, plan=1.091687 s, kernel_fft=0.467405 s, scratch=30.2 MiB
impulse_hash=5334fa90cfbb1089
fftw: min=0.003995 s median=0.004702 s max=0.005804 s
fftw samples: 0.003995 0.004702 0.004330 0.005804 0.004939 0.004986 0.004619
fftw_hdr_hash=626e386cba92ffa5
spatial: 0.706244 s
spatial_hdr_hash=9327ac3e8e10d670
difference: max_abs=8.05e-16 peak=0.359 speedup=150.198x
rss_max_kb=51860
```
```sh
OMP_NUM_THREADS=16 ./build/Release/obj/minkowski/standard_sink1_cpu/benchmark_fast_psf_fftw \
--width 3840 --height 2160 --supersample 2 --repeats 5 --measure
```
```text
impulse: 3840x2160 supersample=2 deposit=nearest repeats=5 spatial=0
plan_mode=measure
setup: init=41.408673 s fftw_plan=41.239987 s kernel_fft=0.134272 s
Fast FFTW: linear min=4506x7866, fft=4536x7875, workers=16, plan=measure, plan=41.239987 s, kernel_fft=0.134272 s, scratch=1907.8 MiB
impulse_hash=e3b6d7f3d621630d
fftw: min=0.518707 s median=0.522181 s max=0.539465 s
fftw samples: 0.523380 0.539465 0.518707 0.520357 0.522181
fftw_hdr_hash=ab90c4b076c775e1
rss_max_kb=2884132
```
## End-to-end bounded comparison
Same catalog, geometry, exposure, supersample factors, and worker environment as
`benchmarks/fast_mode_cpu_2026-09-18.md`. Raw terminal output including the
timing wrapper:
```sh
OMP_NUM_THREADS=16 TIMEFMT='real=%*E user=%U sys=%S cpu=%P' \
time ./build/Release/minkowski_sky --all-sky-catalog assets/2mass/processed/all_sky \
--width 320 --height 240 --fov-deg 10 --look-ra-deg 266 --look-dec-deg -29 \
--exposure 1e13 --coarse-cell-pixels 16 --refine-max-level 0 \
--output /tmp/opencode/fastmode_fftw_bench/normal.png
```
```text
Blackbody LUT: 1024 CIE 1931 2-deg 1 nm-linear XYZ nodes, T=[670.146556, 101408.88] K, loaded assets/blackbody/cie1931_2deg_xyz_1024.grbblut in 0.000046 s; payload fnv1a64=0e39867b0e70a809
Blackbody backend: lut
PSF cache ready: 64x64 phases, radius 47 px, relative tail 1e-08, tail abs 1e-06, boundary 1e-07, build 0.776 s
Catalog prefetch: 96 requested, 96 newly loaded (6618807 stars), 0 unavailable in 0.729 s; 4 loader workers
Rendered 5912840 images from 6618807 catalog stars to /tmp/opencode/fastmode_fftw_bench/normal.png (ok)
PSF splats: cached 5912840, cached wing-clipped 0, direct fallbacks 0, discarded below min-Y 0
real=17.526 user=225.46s sys=2.44s cpu=1300%
```
```sh
OMP_NUM_THREADS=16 TIMEFMT='real=%*E user=%U sys=%S cpu=%P' \
time ./build/Release/minkowski_sky --all-sky-catalog assets/2mass/processed/all_sky \
--width 320 --height 240 --fov-deg 10 --look-ra-deg 266 --look-dec-deg -29 \
--exposure 1e13 --coarse-cell-pixels 16 --refine-max-level 0 \
--fast-mode --fast-supersample 2 \
--output /tmp/opencode/fastmode_fftw_bench/fast_n2.png
```
```text
Fast mode is a preview approximation; --max-cache-psf-flux is ignored and one global kernel is used for every event.
Blackbody LUT: 1024 CIE 1931 2-deg 1 nm-linear XYZ nodes, T=[670.146556, 101408.88] K, loaded assets/blackbody/cie1931_2deg_xyz_1024.grbblut in 0.000049 s; payload fnv1a64=0e39867b0e70a809
Blackbody backend: lut
Fast PSF: supersample 2x, deposit nearest, kernel radius 93 ss px (46.50 final px), retained flux 1.000000, cached 4-point quadrature
Fast FFTW: linear min=666x826, fft=672x840, workers=16, plan=estimate, plan=0.007770 s, kernel_fft=0.005020 s, scratch=30.2 MiB
Catalog prefetch: 96 requested, 96 newly loaded (6618807 stars), 0 unavailable in 0.735 s; 4 loader workers
Rendered 5912840 images from 6618807 catalog stars to /tmp/opencode/fastmode_fftw_bench/fast_n2.png (ok)
Fast PSF splats: deposited 5912840, wing-clipped 0, discarded below min-Y 0
real=0.995 user=5.30s sys=0.18s cpu=550%
```
```sh
OMP_NUM_THREADS=16 TIMEFMT='real=%*E user=%U sys=%S cpu=%P' \
time ./build/Release/minkowski_sky --all-sky-catalog assets/2mass/processed/all_sky \
--width 320 --height 240 --fov-deg 10 --look-ra-deg 266 --look-dec-deg -29 \
--exposure 1e13 --coarse-cell-pixels 16 --refine-max-level 0 \
--fast-mode --fast-supersample 4 \
--output /tmp/opencode/fastmode_fftw_bench/fast_n4.png
```
```text
Fast mode is a preview approximation; --max-cache-psf-flux is ignored and one global kernel is used for every event.
Blackbody LUT: 1024 CIE 1931 2-deg 1 nm-linear XYZ nodes, T=[670.146556, 101408.88] K, loaded assets/blackbody/cie1931_2deg_xyz_1024.grbblut in 0.000044 s; payload fnv1a64=0e39867b0e70a809
Blackbody backend: lut
Fast PSF: supersample 4x, deposit nearest, kernel radius 185 ss px (46.25 final px), retained flux 1.000000, cached 4-point quadrature
Fast FFTW: linear min=1330x1650, fft=1344x1680, workers=16, plan=estimate, plan=0.007294 s, kernel_fft=0.012307 s, scratch=120.7 MiB
Catalog prefetch: 96 requested, 96 newly loaded (6618807 stars), 0 unavailable in 0.722 s; 4 loader workers
Rendered 5912840 images from 6618807 catalog stars to /tmp/opencode/fastmode_fftw_bench/fast_n4.png (ok)
Fast PSF splats: deposited 5912840, wing-clipped 0, discarded below min-Y 0
real=1.033 user=5.56s sys=0.22s cpu=559%
```
Image counts are identical across normal and both fast runs (5,912,840), so the
FFTW resolver did not change catalog/deposit behavior. Output SHA-256:
```text
normal.png e9f0a98ae7a3c3db546a3f276f400be98c96446fab7bfbc632f388702d4722d1
fast_n2.png 1f144aefb4a5520d9d76a13ab69026a72580663e5e45f53128a87a0801176ccf
fast_n4.png 679f14a67a085f76c144ecbe36af51b3b2534c152e8ef59708a7884a3f7c8464
```
## Reading
- Steady-state FFTW is ~109x (N=2) and ~279x (N=4) faster than the spatial
reference at 320x240, and stays under ~0.06 s per frame at 960x540.
- 4K N=2 resolves in ~2.5 s (ESTIMATE) or ~0.52 s (MEASURE) on this host; 4K N=4
needs ~7.6 GiB scratch and ~3.3 s (ESTIMATE).
- FFTW-versus-spatial differences are floating-point roundoff (`max_abs` at the
`1e-15` level against peaks near `0.5`), not crop, wrap, channel, or
normalization errors.
+7 -3
View File
@@ -10,9 +10,13 @@ The default CPU build needs:
- A C11 compiler and OpenMP runtime (for example, GCC with libgomp). - A C11 compiler and OpenMP runtime (for example, GCC with libgomp).
- GNU Make. - GNU Make.
- libpng headers and library for PNG output. - libpng headers and library for PNG output.
- `pkg-config`, used to locate and link FFTW.
- FFTW 3 built with OpenMP, discovered through `pkg-config fftw3_omp`
(`sci-libs/fftw[openmp]` on Gentoo). This is required for the CPU fast-mode
fast convolution; HIP and dummy PSF builds do not link FFTW.
Optional dependencies are CFITSIO and `pkg-config` for HDR/FITS output, and Optional dependencies are CFITSIO for HDR/FITS output, and HIP/ROCm with
HIP/ROCm with `hipcc` and a compatible GPU for HIP PSF accumulation. `hipcc` and a compatible GPU for HIP PSF accumulation.
## CPU build ## CPU build
@@ -181,7 +185,7 @@ the test executable; run it separately:
```sh ```sh
make PSF_BACKEND=hip SPACETIME=minkowski hip-psf-test make PSF_BACKEND=hip SPACETIME=minkowski hip-psf-test
./build/Release/test_hip_psf ./build/Release/obj/minkowski/standard_sink1_hip/test_hip_psf
``` ```
For rendering with real stellar data, continue with [README.md](README.md#prepare-the-stellar-catalog) For rendering with real stellar data, continue with [README.md](README.md#prepare-the-stellar-catalog)
+799
View File
@@ -0,0 +1,799 @@
# Fast-mode FFTW convolution optimization plan
## 1. Status and objective
This plan starts from commit `3deebfb` (`Feat: Add --fast-mode supersampled
point-source accumulation`). The current CPU fast mode already:
- maps catalog images to continuous image positions;
- deposits each image into one nearest supersampled cell by default, or four
bilinear cells when explicitly requested;
- stores a supersampled double-RGB impulse buffer;
- applies one global pixel-integrated Moffat kernel;
- averages each `N x N` supersampled block into the final HDR framebuffer;
- preserves additive HDR semantics, `--psf-min-y`, wing-clipping statistics,
and the documented preview-only boundary.
The remaining bottleneck is `fast_psf_accumulator_resolve()`, which evaluates
the global convolution by nested spatial loops. The earlier HIP work already
established PSF accumulation as the dominant stage, and the target machine is
the local 128 GB host. FFTW is an accepted dependency. This work therefore
does **not** repeat a feasibility study or reconsider the selected nearest-cell
visual tradeoff. Its objective is:
> Replace the spatial global convolution with a reusable, multithreaded FFTW
> linear-convolution path while reproducing the current discrete fast-mode
> result to floating-point tolerance and retaining the spatial implementation
> as a test/benchmark reference.
The target is the CPU PSF backend. HIP and dummy PSF builds continue to reject
`--fast-mode` and must not acquire an unnecessary FFTW link dependency.
## 2. Fixed requirements and non-goals
### 2.1 Required behavior
- Use double-precision FFTW (`fftw3`), not the float API.
- Use the locally available FFTW OpenMP backend (`fftw3_omp`); the current host
reports FFTW `3.3.10` and `pkg-config --libs fftw3_omp` returns
`-lfftw3_omp -lfftw3`.
- On Gentoo this requires `sci-libs/fftw[openmp]`, which is already enabled on
the target host. It does **not** require the separate `threads` USE flag:
`threads` selects FFTW's pthread backend, while this plan deliberately uses
the OpenMP backend so it shares the renderer's OpenMP runtime.
- Compute zero-padded **linear** convolution. Circular wraparound is a failure.
- Match the current circular kernel traversal, not the unused square corners of
the allocated `weights` array.
- Preserve the current impulse-buffer contents so the same buffer can be sent
through the spatial reference and FFTW implementations in one process.
- Normalize the unscaled FFTW inverse exactly once.
- Apply the existing `1/N^2` box-average normalization exactly once.
- Crop the full linear convolution at the mathematically correct offset before
downsampling.
- Add the result to the caller's HDR buffer; never overwrite existing HDR.
- Cache plans, the kernel spectrum, FFT buffers, and dimension metadata across
movie frames.
- Keep FFTW planning outside OpenMP producer regions and avoid nested
OpenMP/FFTW oversubscription.
- Report one-time planning/kernel-transform time separately from per-frame FFT
execution time.
- Propagate allocation or plan failures as explicit fast-mode initialization
failures. Do not silently fall back to a potentially multi-minute spatial
convolution.
### 2.2 Preserved preview semantics
This optimization must not change:
- nearest as the default deposit mode;
- bilinear as the explicit position-over-shape alternative;
- supersample factor or coordinate convention;
- the Moffat `FWHM`, `beta`, quadrature, normalization, or support radius;
- flux, color, magnification, frequency-shift, exposure, or `min-Y` decisions;
- fixed global-kernel wing clipping and its statistics;
- zero-outside-image behavior, including the existing narrow bilinear edge
approximation;
- tone mapping, PNG/FITS output, lens-map reuse, or catalog traversal.
### 2.3 Non-goals
- Do not redesign the producer/catalog path.
- Do not introduce a GPU FFT backend in this change.
- Do not add polyphase convolution as a competing production algorithm.
- Do not change nearest/bilinear acceptance criteria.
- Do not add an artificial memory cap for the 128 GB target host.
- Do not make bitwise identity an acceptance criterion; FFT summation order is
different from direct spatial accumulation.
- Do not combine this work with impulse-buffer persistence or PSF/exposure sweep
orchestration. The internal state should permit those later, but they are a
separate feature.
## 3. Exact discrete operation to preserve
Let the supersampled impulse buffer be
```text
B[c][y][x], c in {R,G,B}, 0 <= x < Wss, 0 <= y < Hss
```
with `Wss = N * width` and `Hss = N * height`. Let the stored kernel radius be
`R`, its side be `K = 2R + 1`, and its circularly retained weights be
```text
A[dy + R][dx + R] = weights[(dy + R) * K + (dx + R)]
```
only where
```text
-R <= dy <= R
|dx| <= floor(sqrt(R^2 - dy^2)).
```
All other elements of `A` are zero. The current fine-grid result is
```text
C[c][sy][sx] = sum_dy sum_dx A[dy + R][dx + R]
* B[c][sy - dy][sx - dx],
```
where samples of `B` outside its image are zero. The final operation is
```text
hdr[c][row][column] +=
(1 / N^2) * sum_j=0..N-1 sum_i=0..N-1
C[c][N*row + j][N*column + i].
```
The FFTW implementation must preserve this discrete operation, including the
current float kernel weights promoted to double during multiplication.
## 4. Linear-convolution layout and crop convention
This is the most error-prone part of the implementation and must be fixed by
tests before performance work.
### 4.1 Padding bounds
Choose FFT dimensions satisfying
```text
Pwidth >= Wss + K - 1 = Wss + 2R
Pheight >= Hss + K - 1 = Hss + 2R.
```
`B` is copied at padded origin `(0,0)`. The kernel is also stored at padded
origin with its array indices unchanged:
```text
padded_kernel[dy + R][dx + R] = A[dy + R][dx + R].
```
Do **not** apply an additional `fftshift`/`ifftshift` in this convention. The
ordinary full convolution produced by FFT multiplication has dimensions
`Wss + K - 1` by `Hss + K - 1`, and the desired same-size fine-grid result is
```text
C[sy][sx] = full_convolution[sy + R][sx + R].
```
Thus the fine-grid crop begins exactly at `(R,R)` and has extent
`Wss x Hss`.
An alternative center-at-frequency-origin layout is mathematically possible,
but it changes the crop/wrap convention and is deliberately excluded from the
first implementation.
### 4.2 FFT-friendly dimensions
Add a checked helper such as `next_smooth_size()` that returns the first value
at least the required extent whose prime factors are limited to small FFTW-
friendly factors, initially `{2,3,5,7}`. The helper must:
- accept and return `size_t` while checking overflow;
- reject a selected dimension above `INT_MAX` before passing it to FFTW's
`int n[2]` interface;
- have unit tests for exact, next-size, and near-overflow inputs;
- record both the minimum linear-convolution extent and selected FFT extent.
Do not hard-code powers of two: they can waste substantially more memory and
work than a nearby smooth composite size.
## 5. Source and module boundaries
### 5.1 New private FFTW module
Keep FFTW details out of the public optics API and out of unrelated backends.
Add:
```text
src/fast_psf_fftw.c
src/fast_psf_fftw.h
```
`fast_psf_fftw.h` is a private renderer header. It declares an opaque state
and functions conceptually equivalent to:
```c
typedef struct FastPsfFftwState FastPsfFftwState;
FastPsfFftwState *fast_psf_fftw_create(
int ss_width, int ss_height,
int final_width, int final_height,
int supersample, int kernel_radius,
const float *weights,
int fft_workers,
FastPsfFftwTiming *timing);
int fast_psf_fftw_resolve(
FastPsfFftwState *state,
const double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwTiming *timing);
void fast_psf_fftw_destroy(FastPsfFftwState *state);
```
Exact names may follow project style, but ownership must stay explicit:
- `FastPsfAccumulator` owns one FFTW state pointer;
- the FFTW state owns every FFTW allocation and plan;
- the input impulse buffer remains owned by `FastPsfAccumulator`;
- the output HDR remains caller-owned;
- the kernel spectrum is immutable after initialization;
- destruction is safe for a zero/partially initialized state.
Avoid exposing `fftw_plan` or `fftw_complex` through `optics.h`. If the public
`FastPsfAccumulator` must carry the private pointer, use a forward-declared
opaque struct.
### 5.2 Spatial reference path
Extract the current nested-loop resolver into a clearly named reference helper,
for example:
```c
fast_psf_accumulator_resolve_spatial_reference(...)
```
It must remain callable by a dedicated FFTW test/benchmark with the identical
impulse buffer. Production `fast_psf_accumulator_resolve()` dispatches to
FFTW in CPU builds after validation. The spatial path is not a silent runtime
fallback.
The reference helper should reuse a cached circular `row_span` array rather
than allocating it on every call. This small cleanup also guarantees that the
spatial and FFTW kernel masks are built from one definition.
## 6. FFTW state and memory layout
### 6.1 Planar FFT scratch
Keep the existing interleaved impulse buffer because the deposit hot path and
its atomic RGB updates already use that layout. At resolve time, pack it into
FFTW-aligned planar scratch:
```text
real_rgb[channel][padded_y][padded_x]
frequency_rgb[channel][ky][kx], kx extent = Pwidth/2 + 1
kernel_frequency[ky][kx]
```
Use:
- `fftw_alloc_real()` for real scratch;
- `fftw_alloc_complex()` for channel spectra and the kernel spectrum;
- out-of-place R2C and C2R transforms in the first implementation;
- `fftw_plan_many_dft_r2c()` and `fftw_plan_many_dft_c2r()` for three planar
RGB transforms with unit stride and per-plane distance;
- one shared kernel spectrum multiplied into each of the three channel
spectra.
Out-of-place planar buffers are intentionally preferred over an in-place or
strided-interleaved first version. The target host has ample memory, while the
simpler layout reduces alignment, padding, and plan-many mistakes.
### 6.2 Checked allocation sizes
Before allocation, check every product used for:
```text
3 * Pheight * Pwidth * sizeof(double)
3 * Pheight * (Pwidth/2 + 1) * sizeof(fftw_complex)
Pheight * (Pwidth/2 + 1) * sizeof(fftw_complex)
```
Allocation failure is a hard fast-mode initialization error with a diagnostic
that includes requested dimensions and byte counts. These numbers are logged
for provenance, not used as a policy cap.
### 6.3 Packing
For each frame:
1. zero the full padded planar real scratch;
2. copy the active `Hss x Wss` region from interleaved RGB into three planes;
3. leave all right and bottom padding zero;
4. keep the original impulse buffer unchanged.
The zero and pack loops may use one ordinary OpenMP parallel-for region. They
must finish before FFTW execution starts.
## 7. Kernel transform
Build the padded real kernel once after plans and scratch buffers exist:
1. clear a single padded real plane;
2. for `dy = -R..R`, compute the same circular `row_span` as the spatial
reference;
3. copy only `dx = -row_span..row_span` to index `(dy+R, dx+R)`;
4. leave square corners and all FFT padding zero;
5. execute one R2C kernel transform;
6. store its spectrum in immutable `kernel_frequency`;
7. compute the reported retained-flux sum from this same circular mask.
Do not assume the kernel spectrum is purely real. The unshifted full-
convolution layout places the kernel center at `(R,R)`, so the spectrum
generally has a phase. Complex multiplication must use both real and imaginary
components:
```text
(ar + i ai) * (br + i bi)
= (ar*br - ai*bi) + i(ar*bi + ai*br).
```
The one-time kernel transform and plan creation must not mutate the production
impulse buffer.
## 8. Per-frame FFTW resolve
The production resolver executes these stages in order:
1. **Zero and pack** interleaved impulses into padded planar real scratch.
2. **Forward FFT** all three planes with the cached R2C plan.
3. **Frequency multiply** each RGB spectrum by the shared kernel spectrum.
4. **Inverse FFT** all three planes with the cached C2R plan.
5. **Crop, normalize, and box-downsample** into the caller HDR.
FFTW's transforms are unnormalized. Let
```text
fft_scale = 1 / (Pwidth * Pheight)
box_scale = 1 / (N * N).
```
For output pixel `(row,column)` and fine offsets `(j,i)`, read inverse scratch
at
```text
y = R + N*row + j
x = R + N*column + i.
```
Then accumulate
```text
hdr += inverse[y][x] * fft_scale * box_scale.
```
Fuse crop, inverse normalization, box summation, RGB interleaving, and HDR
addition in one OpenMP parallel-for over final output rows. Do not materialize
a separate cropped fine-grid image.
The frequency multiply is embarrassingly parallel over frequency bins and may
use OpenMP if measurement shows it is not already hidden by FFT cost. It must
not run inside an active FFTW OpenMP region.
## 9. FFTW planning, threads, and wisdom
### 9.1 Process-level threading lifecycle
The local host provides `libfftw3_omp`. Use the FFTW threading API exported by
that OpenMP library:
```c
fftw_init_threads();
fftw_plan_with_nthreads(fft_workers);
```
Requirements:
- initialize FFTW threading before plan creation;
- create and destroy plans only from the serial control thread;
- never create a plan inside the catalog OpenMP region;
- do not call `fftw_cleanup_threads()` while any accumulator or plan exists;
- keep process-global FFTW runtime ownership/reference counting in one private
module rather than letting individual accumulators independently clean up
global state;
- choose `fft_workers` from the same OpenMP worker policy already used by the
renderer and report it;
- do not set a nested OpenMP region around `fftw_execute()`;
- after FFTW returns, use a separate OpenMP region for crop/downsample.
`OMP_DYNAMIC` and the user's OpenMP environment remain authoritative. Do not
silently force a different global thread count.
### 9.2 Planning flags
Implement in two steps:
1. correctness and unit tests use `FFTW_ESTIMATE` so tests are fast and do not
depend on host wisdom;
2. the release benchmark compares `FFTW_ESTIMATE` with `FFTW_MEASURE`, recording
plan time separately from steady-state execution.
Select the release default only from that measured comparison. If
`FFTW_MEASURE` materially improves repeated movie-frame execution, add optional
wisdom import/export in a follow-up or in the same implementation phase:
- wisdom is host/FFTW-version/dimension specific;
- a missing or incompatible wisdom file must be explicit in verbose output;
- wisdom files are local artifacts and are not committed as portable project
data;
- planning may destroy scratch contents, so plans are created before the
kernel and frame inputs are populated.
Do not mix plan time into the per-frame convolution metric.
## 10. Build-system integration
### 10.1 Dependency detection
For `PSF_BACKEND=cpu`, require:
```sh
pkg-config --exists fftw3_omp
pkg-config --modversion fftw3_omp
pkg-config --cflags --libs fftw3_omp
```
The current host returns version `3.3.10` and libraries
`-lfftw3_omp -lfftw3`.
On Gentoo the package requirement is `sci-libs/fftw[openmp]`; do not require
`sci-libs/fftw[threads]` and do not link `libfftw3_threads`. The similarly
named FFTW threading API is implemented by both backends, but exactly one
backend library should provide it in this executable.
Add CPU-only build variables such as:
```make
FFTW_CPPFLAGS := $(shell pkg-config --cflags fftw3_omp)
FFTW_LDLIBS := $(shell pkg-config --libs fftw3_omp)
```
and a CPU-only definition such as `-DFAST_PSF_FFTW`. Fail early with a clear
Make error if `fftw3_omp` is unavailable in a CPU build.
Do not append FFTW flags to HIP or dummy backend links. CPU tests that compile
`src/optics.c` and the new FFTW module must receive the same FFTW compile/link
flags.
The current Makefile obtains common sources through a `src/*.c` wildcard. Do
not let that wildcard pull the FFTW module into HIP and dummy binaries. Exclude
`src/fast_psf_fftw.c` from `COMMON_SOURCES` and append it only to the CPU
renderer and CPU-test source lists. This makes the dependency boundary visible.
Guard calls from `src/optics.c` consistently so HIP and dummy links have no
unresolved FFTW-module symbols.
Audit at least:
- renderer backend link;
- `test_frame` and the other CPU tests using `COMMON_SOURCES`;
- the new FFTW unit test;
- `make_psf_fixture` if it links the FFTW-enabled optics object;
- capture/replay tools, ensuring HIP-only tools do not accidentally gain a CPU
FFTW requirement.
Keep dependency tracking (`-MMD -MP`) for the new module.
### 10.2 Build identities
The CPU build always includes FFTW after this change, so a separate executable
suffix is not required. If a temporary compile-time A/B switch is introduced,
include it in the object-directory tag to prevent stale-object reuse. Prefer a
single CPU binary with FFTW production resolution and a test-only spatial
reference over persistent compile-time variants.
## 11. Diagnostics and timing
Extend fast-mode reporting with one-time state:
```text
Fast FFTW: linear min=<Hmin>x<Wmin>, fft=<Pheight>x<Pwidth>,
workers=<n>, plan=<estimate|measure>, plan=<seconds>,
kernel_fft=<seconds>, scratch=<bytes>
```
Under `--verbose`, report per-frame stages:
```text
Fast FFTW frame: zero_pack=<s>, forward=<s>, multiply=<s>,
inverse=<s>, crop_downsample=<s>, total=<s>
```
Timing rules:
- use wall time, not sums of worker-local time;
- plan/kernel setup is one-time and separate;
- `total` spans zero/pack through completed HDR addition;
- keep catalog prefetch/deposit timing outside the FFTW resolve metric;
- include selected dimensions, supersample factor, FWHM/beta, kernel radius,
FFTW version, planning flag, worker environment, and binary revision in
benchmark records.
These timings validate and attribute the implementation; they are not another
feasibility gate for fast mode.
## 12. Correctness test plan
### 12.1 Dedicated FFTW-versus-spatial unit test
Add `tests/test_fast_psf_fftw.c`. It constructs one accumulator/impulse buffer,
runs both resolvers without re-depositing, and compares their final HDR arrays.
Cover:
- non-power-of-two image sizes;
- `N = 1, 2, 3, 4`;
- nearest and bilinear deposit modes;
- one white center impulse;
- distinct R/G/B values to catch channel-layout mistakes;
- impulses near all four edges and four corners;
- an impulse exactly on a supersampled cell center;
- phases immediately on both sides of a nearest-cell boundary;
- multiple separated impulses;
- many impulses in one supersampled cell;
- a deterministic dense pseudo-random impulse field;
- an empty impulse buffer;
- a nonzero prefilled HDR background;
- a kernel radius that makes the selected FFT padding larger in both axes;
- dimensions where only one axis needs the next smooth FFT size.
For each case compute:
- maximum absolute RGB error;
- maximum relative error above a reference-magnitude floor;
- RMS error;
- total per-channel flux difference;
- worst sample coordinate and channel;
- NaN/Inf count.
Use provisional double-precision acceptance limits of:
```text
max_abs <= 1e-10 * max(1, reference_peak)
max_rel <= 1e-9 for |reference| above 1e-12 * reference_peak
relative total-flux error <= 1e-10
```
Tighten these after observing stable results on the target host; do not loosen
them merely to hide crop, wrap, channel, or normalization errors. Any coherent
edge band, shifted peak, factor-of-area scaling, or opposite-edge ghost is an
algorithmic failure regardless of aggregate tolerance.
### 12.2 Analytic impulse checks
Before relying only on spatial comparison, add exact index tests with a single
unit impulse:
- verify the full-convolution peak appears at impulse index plus `R`;
- verify the cropped fine-grid center returns to the original impulse index;
- verify no signal appears at the opposite edge;
- verify a unit impulse's cropped result equals the circular kernel samples;
- verify box-downsample indices for every phase `0..N-1` in both axes;
- verify RGB channels do not cross-contaminate.
These tests diagnose layout errors that a broad image statistic can obscure.
### 12.3 Existing regression suite
After FFTW becomes the production resolver:
```sh
make -j4 BUILD_TYPE=Debug PSF_BACKEND=cpu test
```
must pass. In particular:
- the existing fast-mode snapped-kernel regression now exercises FFTW;
- the additive-HDR regression must still preserve its `0.25` background;
- ordinary non-fast FITS references remain unchanged;
- Minkowski and Schwarzschild camera/lens-map regressions remain unchanged.
Add CLI smoke coverage for `--fast-mode` in a CPU build. HIP and dummy builds
must continue to reject it with the existing message.
### 12.4 Scientific/visual metrics
Using identical impulses, compare FFTW and spatial fast-mode outputs for:
- centroid;
- fitted FWHM;
- fitted Moffat beta relative to the same direct-reference fitting procedure;
- total linear-HDR flux;
- center, edge, and corner crops.
The FFTW change should add only floating-point roundoff to the already measured
nearest/bilinear approximation. It must not change the deposition-error table
or the rationale for nearest as default.
## 13. Performance benchmark plan
### 13.1 Resolver-only benchmark
Add `tests/benchmark_fast_psf_fftw.c` or an equivalent bounded benchmark target
that:
- initializes one accumulator;
- deposits or directly fills one deterministic impulse buffer once;
- runs spatial and FFTW resolution on the identical buffer;
- separates first-use plan/setup from repeated execution;
- performs warm-up before measured FFT executions;
- records at least five steady-state executions when runtime permits;
- reports median, minimum, and all raw samples;
- hashes the impulse buffer and both HDR outputs;
- records RSS and FFTW dimensions for provenance.
Required cases:
1. current default PSF, N=2, 320x240;
2. current default PSF, N=4, 320x240;
3. a medium frame that is large enough for FFT execution to dominate startup;
4. the intended 4K N=2 target;
5. 4K N=4 if the user requests that mode to be production-supported.
The 4K cases are bounded synthetic/identical-buffer resolver tests, not another
catalog or geodesic run.
### 13.2 End-to-end comparison
Repeat the recorded 320x240 Galactic-center command from
`benchmarks/fast_mode_cpu_2026-09-18.md` with the same catalog, geometry,
exposure, supersample factor, deposit mode, worker environment, and revision
metadata. Preserve raw terminal output rather than only a timing table.
Do not claim an end-to-end speedup unless inputs, event/image counts, PSF
parameters, and output mode match. Do not add worker-summed times to process
wall time.
An expensive full-sky or production 4K catalog render is not automatically run
as part of implementation. It requires separate explicit authorization and
must use a frozen lens map/input provenance when used for final evidence.
### 13.3 Performance acceptance
The FFTW implementation is ready to become the default resolver when:
- all correctness tests pass;
- the resolver-only benchmark shows a clear steady-state reduction for default
N=2 and the N=4 case that exposed spatial scaling;
- plan/setup time is reported separately and is acceptable for the intended
single-frame/movie usage, or wisdom support addresses it;
- the recorded end-to-end bounded command does not regress catalog/deposit
behavior or image count;
- FFTW output differences remain within the linear-HDR tolerances and do not
change measured centroid/FWHM/beta beyond roundoff.
No new arbitrary global wall-time target is introduced by this plan.
## 14. Failure handling and cleanup
- Reject invalid/overflowed FFT dimensions before allocation or plan creation.
- Treat `fftw_alloc_* == NULL` or a null plan as initialization failure.
- Print which allocation/plan failed, dimensions, plan mode, and worker count.
- Never continue with a partially initialized kernel spectrum.
- Never silently switch to spatial convolution after an FFTW failure.
- Destroy forward/inverse/kernel plans before freeing their buffers.
- Release every FFTW allocation in the partial-initialization error path.
- Ensure movie failure exits and imported-lens-map early returns destroy the
FFTW state through the existing accumulator cleanup.
- Do not call global FFTW cleanup while another accumulator remains alive.
- Keep `fast_psf_accumulator_destroy()` idempotent on a zero state.
## 15. Implementation sequence
### Phase 1: Freeze the spatial reference
- Extract the existing resolver without changing its arithmetic.
- Cache/reuse its circular row spans.
- Add a small digest/reference test for the current spatial output.
- Add checked smooth-size and product helpers with unit tests.
Exit criterion: current fast-mode tests and the spatial output digest pass.
### Phase 2: Add FFTW build and private state
- Add CPU-only `fftw3_omp` detection and link flags.
- Add the private FFTW module and opaque ownership.
- Allocate planar real/frequency scratch.
- Initialize process-level FFTW threading.
- Create `FFTW_ESTIMATE` plans and build the circular kernel spectrum.
- Report plan dimensions and setup time.
Exit criterion: all CPU/HIP/dummy build variants still compile with the correct
dependency boundary; state construction/destruction passes allocation tests.
### Phase 3: Implement exact FFT resolution
- Implement zero/pack, batched forward FFT, shared-kernel multiplication,
batched inverse FFT, `(R,R)` crop, normalization, box downsample, and additive
HDR write.
- Add analytic impulse/crop tests first.
- Add dense FFTW-versus-spatial comparisons.
Exit criterion: dedicated correctness tests pass for all required dimensions,
phases, channels, and edge cases.
### Phase 4: Integrate the production path
- Make `fast_psf_accumulator_resolve()` use FFTW in CPU fast mode.
- Retain the spatial helper only for tests/benchmarks.
- Add CLI smoke coverage and verbose timing.
- Run the full CPU Debug suite.
Exit criterion: existing behavior and references pass, and additive HDR
semantics remain covered.
### Phase 5: Benchmark planning and execution
- Run the resolver-only matrix.
- Compare `FFTW_ESTIMATE` and `FFTW_MEASURE`.
- Decide whether wisdom support is required.
- Repeat the bounded Galactic-center command with full provenance.
- Save raw logs and output hashes.
Exit criterion: correctness remains within tolerance and FFTW is demonstrably
faster than the spatial resolver for the default and N=4 cases.
### Phase 6: Documentation finalization
- Update `usage.md` with FFTW dependency and planning/wisdom behavior.
- Update `nr_spacetime_movie_renderer_design.md` to state that the global fast
convolution is implemented as zero-padded FFTW linear convolution.
- Add a dated benchmark record with commands, revision, environment, raw logs,
hashes, plan/setup time, and steady-state execution samples.
- Keep the existing deposition-error record unchanged except for links to the
FFTW validation record; FFTW does not change nearest/bilinear errors.
## 16. Review checklist
Before considering the implementation complete, review the following
explicitly:
- [ ] FFTW is linked only for the CPU PSF backend and CPU tests.
- [ ] Local dependency is `fftw3_omp`, version and libraries are reported.
- [ ] Padding satisfies `Wss + 2R` and `Hss + 2R` before smooth rounding.
- [ ] Kernel square corners outside the circular support are zero.
- [ ] Kernel is stored unshifted at indices `(dy+R, dx+R)`.
- [ ] Fine-grid crop begins at `(R,R)`.
- [ ] Inverse normalization uses `1/(Pwidth*Pheight)` exactly once.
- [ ] Box normalization uses `1/N^2` exactly once.
- [ ] Output uses `+=` into HDR.
- [ ] RGB batch layout and distances are covered by distinct-channel tests.
- [ ] Empty input produces no HDR change.
- [ ] No opposite-edge wraparound appears.
- [ ] Original impulse buffer is unchanged by resolve.
- [ ] Plans and kernel spectrum are reused across frames.
- [ ] Planning occurs outside OpenMP producer regions.
- [ ] FFTW and renderer OpenMP stages do not nest/oversubscribe.
- [ ] Partial initialization and all early exits free FFTW state.
- [ ] Spatial reference and FFTW consume the identical impulse buffer.
- [ ] Linear-HDR errors, flux, centroid, FWHM, and beta are recorded.
- [ ] Plan time and steady-state execution time are reported separately.
- [ ] Benchmark commands, revisions, inputs, event counts, and raw output are
preserved.
- [ ] No expensive full-sky/4K catalog render is run without explicit
authorization.
## 17. Expected final architecture
```text
catalog / inverse lens map / g / blackbody / flux
|
v
atomic nearest or bilinear SS delta deposit
|
v
interleaved double-RGB impulse buffer
|
v
zero + pack to FFTW planar padded real arrays
|
v
batched RGB R2C forward FFT
|
v
multiply by one cached Moffat kernel spectrum
|
v
batched RGB C2R inverse FFT
|
v
crop at (R,R) + normalize + N x N downsample
|
v
additive double-RGB HDR framebuffer
```
The physical/catalog decisions remain on the CPU producer side; FFTW replaces
only the global convolution implementation inside the explicit preview path.
+7 -1
View File
@@ -851,7 +851,13 @@ pixel-area 积分,所以核本身不改变指定的 FWHM/beta。`nearest` 在
沉积误差测量、`N` 与取舍见 沉积误差测量、`N` 与取舍见
[`benchmarks/fast_mode_deposit_2026-09-18.md`](benchmarks/fast_mode_deposit_2026-09-18.md)。 [`benchmarks/fast_mode_deposit_2026-09-18.md`](benchmarks/fast_mode_deposit_2026-09-18.md)。
该模式只支持 CPU PSF 后端;`--max-cache-psf-flux` 不适用(所有事件共用同一 该模式只支持 CPU PSF 后端;`--max-cache-psf-flux` 不适用(所有事件共用同一
个全局核半径)。 个全局核半径)。CPU 构建中该全局卷积实现为零填充的 double 精度 FFTW 线性
卷积(`fftw3`/`fftw3_omp`):核频谱、FFT plans 与 planar scratch 在初始化时
一次性建立并跨帧复用,`resolve()` 只做 zero/pack、批量 R2C、频域乘、批量
C2R,以及在 `(R,R)` 处的裁剪、`1/(Pwidth·Pheight)` 与 `1/N²` 归一化和
`+=` 到 HDR。旧的嵌套空间循环作为测试/基准参考保留,不是运行时 fallback。
依赖与 plan 模式取舍见 `build.md` 与
[`benchmarks/fast_mode_fftw_2026-09-25.md`](benchmarks/fast_mode_fftw_2026-09-25.md)。
PSF 第一版可用 Gaussian; PSF 第一版可用 Gaussian;
以后可换成 Airy 或其他相机模型。 以后可换成 Airy 或其他相机模型。
+393
View File
@@ -0,0 +1,393 @@
#include "fast_psf_fftw.h"
#include <fftw3.h>
#include <limits.h>
#include <math.h>
#include <omp.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
/* Process-global FFTW threading lifecycle. Plans are created only from the
* serial control thread, and global cleanup happens only after the last live
* state is gone. This keeps the individual states from racing on FFTW's
* process-wide planner state. */
static int g_threading_initialized = 0;
static int g_live_states = 0;
static int g_plan_measure = 0;
void fast_psf_fftw_set_plan_mode(int measure)
{
g_plan_measure = measure ? 1 : 0;
}
static unsigned plan_flags(void)
{
return g_plan_measure ? FFTW_MEASURE : FFTW_ESTIMATE;
}
struct FastPsfFftwState {
int fft_width, fft_height;
int ss_width, ss_height;
int final_width, final_height;
int supersample;
int kernel_radius;
int workers;
int registered;
int plan_measure;
size_t scratch_bytes;
double setup_seconds;
double kernel_seconds;
double fft_scale;
double box_scale;
double *real_rgb; /* 3 planes of fft_height * fft_width */
fftw_complex *freq_rgb; /* 3 planes of fft_height * (fft_width/2 + 1) */
fftw_complex *kernel_freq; /* one plane of fft_height * (fft_width/2 + 1) */
fftw_plan forward_plan;
fftw_plan inverse_plan;
};
static int checked_mul_size(size_t a, size_t b, size_t *out)
{
if (a != 0 && b > SIZE_MAX / a)
return -1;
*out = a * b;
return 0;
}
int fast_psf_fftw_next_smooth_size(size_t min_extent, size_t *out)
{
if (out == NULL || min_extent == 0)
return -1;
if (min_extent > (size_t)INT_MAX)
return -1;
static const unsigned factors[] = {2, 3, 5, 7};
for (size_t candidate = min_extent; candidate <= (size_t)INT_MAX;
++candidate) {
size_t remaining = candidate;
for (size_t i = 0; i < sizeof factors / sizeof factors[0]; ++i)
while (remaining % factors[i] == 0)
remaining /= factors[i];
if (remaining == 1) {
*out = candidate;
return 0;
}
}
return -1;
}
void fast_psf_fftw_destroy(FastPsfFftwState *state)
{
if (state == NULL)
return;
if (state->forward_plan != NULL)
fftw_destroy_plan(state->forward_plan);
if (state->inverse_plan != NULL)
fftw_destroy_plan(state->inverse_plan);
if (state->real_rgb != NULL)
fftw_free(state->real_rgb);
if (state->freq_rgb != NULL)
fftw_free(state->freq_rgb);
if (state->kernel_freq != NULL)
fftw_free(state->kernel_freq);
const int registered = state->registered;
free(state);
if (!registered)
return;
g_live_states--;
if (g_live_states <= 0) {
g_live_states = 0;
if (g_threading_initialized) {
fftw_cleanup_threads();
g_threading_initialized = 0;
}
}
}
FastPsfFftwState *fast_psf_fftw_create(int ss_width, int ss_height,
int final_width, int final_height,
int supersample, int kernel_radius,
const float *weights,
const int *row_span, int fft_workers,
double *setup_seconds,
double *kernel_seconds)
{
if (setup_seconds != NULL)
*setup_seconds = 0.0;
if (kernel_seconds != NULL)
*kernel_seconds = 0.0;
if (ss_width <= 0 || ss_height <= 0 || final_width <= 0 ||
final_height <= 0 || supersample < 1 || kernel_radius < 0 ||
weights == NULL || row_span == NULL || fft_workers < 1) {
fprintf(stderr,
"Fast FFTW init failed (arguments): ss=%dx%d final=%dx%d "
"supersample=%d kernel_radius=%d workers=%d\n",
ss_width, ss_height, final_width, final_height, supersample,
kernel_radius, fft_workers);
return NULL;
}
if ((size_t)final_width * (size_t)supersample != (size_t)ss_width ||
(size_t)final_height * (size_t)supersample != (size_t)ss_height) {
fprintf(stderr,
"Fast FFTW init failed (dimensions): ss=%dx%d is not "
"final=%dx%d times supersample=%d\n",
ss_width, ss_height, final_width, final_height, supersample);
return NULL;
}
const char *stage = "state allocation";
FastPsfFftwState *state = calloc(1, sizeof *state);
if (state == NULL) {
fprintf(stderr, "Fast FFTW init failed (state allocation): %zu bytes\n",
sizeof *state);
return NULL;
}
state->ss_width = ss_width;
state->ss_height = ss_height;
state->final_width = final_width;
state->final_height = final_height;
state->supersample = supersample;
state->kernel_radius = kernel_radius;
state->workers = fft_workers;
state->plan_measure = g_plan_measure;
size_t real_bytes = 0, freq_bytes = 0, kernel_bytes = 0;
size_t fft_width = 0, fft_height = 0;
const size_t radius = (size_t)kernel_radius;
size_t width_min, height_min;
stage = "kernel extent";
if (checked_mul_size(radius, 2, &width_min) ||
checked_mul_size(radius, 2, &height_min))
goto fail;
if ((size_t)ss_width > SIZE_MAX - width_min ||
(size_t)ss_height > SIZE_MAX - height_min)
goto fail;
width_min += (size_t)ss_width;
height_min += (size_t)ss_height;
stage = "smooth FFT size";
if (fast_psf_fftw_next_smooth_size(width_min, &fft_width) ||
fast_psf_fftw_next_smooth_size(height_min, &fft_height))
goto fail;
state->fft_width = (int)fft_width;
state->fft_height = (int)fft_height;
const size_t complex_width = fft_width / 2 + 1;
size_t real_plane, real_count;
size_t freq_plane, freq_count;
stage = "allocation size";
if (checked_mul_size(fft_height, fft_width, &real_plane) ||
checked_mul_size(real_plane, 3, &real_count) ||
checked_mul_size(real_count, sizeof(double), &real_bytes) ||
checked_mul_size(fft_height, complex_width, &freq_plane) ||
checked_mul_size(freq_plane, 3, &freq_count) ||
checked_mul_size(freq_count, sizeof(fftw_complex), &freq_bytes) ||
checked_mul_size(freq_plane, sizeof(fftw_complex), &kernel_bytes) ||
real_bytes > SIZE_MAX - freq_bytes - kernel_bytes)
goto fail;
/* FFTW's plan_many interface takes idist/odist as int, so the per-plane
* element counts must fit before the cast below even when each axis is a
* valid int. */
stage = "plan_many stride";
if (real_plane > (size_t)INT_MAX || freq_plane > (size_t)INT_MAX)
goto fail;
state->scratch_bytes = real_bytes + freq_bytes + kernel_bytes;
stage = "FFTW threading initialization";
if (g_live_states == 0) {
if (!fftw_init_threads())
goto fail;
g_threading_initialized = 1;
}
g_live_states++;
state->registered = 1;
fftw_plan_with_nthreads(fft_workers);
const double plan_start = omp_get_wtime();
stage = "FFTW scratch allocation";
state->real_rgb = fftw_alloc_real(real_count);
state->freq_rgb = fftw_alloc_complex(freq_count);
state->kernel_freq = fftw_alloc_complex(freq_plane);
if (state->real_rgb == NULL || state->freq_rgb == NULL ||
state->kernel_freq == NULL)
goto fail;
int n[2] = {state->fft_height, state->fft_width};
int real_embed[2] = {state->fft_height, state->fft_width};
int complex_embed[2] = {state->fft_height, (int)complex_width};
const int real_dist = (int)real_plane;
const int complex_dist = (int)freq_plane;
stage = "FFTW plan creation";
state->forward_plan = fftw_plan_many_dft_r2c(
2, n, 3, state->real_rgb, real_embed, 1, real_dist, state->freq_rgb,
complex_embed, 1, complex_dist, plan_flags());
state->inverse_plan = fftw_plan_many_dft_c2r(
2, n, 3, state->freq_rgb, complex_embed, 1, complex_dist,
state->real_rgb, real_embed, 1, real_dist, plan_flags());
if (state->forward_plan == NULL || state->inverse_plan == NULL)
goto fail;
state->setup_seconds = omp_get_wtime() - plan_start;
if (setup_seconds != NULL)
*setup_seconds = state->setup_seconds;
/* One-time kernel transform. The kernel is stored unshifted at padded origin
* indices (dy + R, dx + R), retaining only the circular mask the spatial
* reference traverses; square corners and FFT padding stay zero. The plan is
* created before the kernel is written because FFTW_MEASURE overwrites its
* input during planning. */
stage = "kernel scratch allocation";
double *kernel_real = fftw_alloc_real(real_plane);
fftw_plan kernel_plan = NULL;
if (kernel_real == NULL)
goto fail;
const double kernel_start = omp_get_wtime();
stage = "kernel plan creation";
kernel_plan = fftw_plan_dft_r2c_2d(state->fft_height, state->fft_width,
kernel_real, state->kernel_freq,
plan_flags());
if (kernel_plan == NULL) {
fftw_free(kernel_real);
goto fail;
}
memset(kernel_real, 0, real_plane * sizeof *kernel_real);
const size_t side = 2 * radius + 1;
for (int dy = -kernel_radius; dy <= kernel_radius; ++dy) {
const int span = row_span[dy + kernel_radius];
for (int dx = -span; dx <= span; ++dx)
kernel_real[(size_t)(dy + kernel_radius) * fft_width +
(size_t)(dx + kernel_radius)] =
(double)weights[(size_t)(dy + kernel_radius) * side +
(size_t)(dx + kernel_radius)];
}
fftw_execute(kernel_plan);
state->kernel_seconds = omp_get_wtime() - kernel_start;
if (kernel_seconds != NULL)
*kernel_seconds = state->kernel_seconds;
fftw_destroy_plan(kernel_plan);
fftw_free(kernel_real);
state->fft_scale = 1.0 / ((double)fft_width * (double)fft_height);
state->box_scale = 1.0 / ((double)supersample * (double)supersample);
return state;
fail:
fprintf(stderr,
"Fast FFTW init failed at '%s': ss=%dx%d, linear min=%zux%zu, "
"fft=%zux%zu, plan=%s, workers=%d, bytes real=%zu freq=%zu "
"kernel=%zu total=%zu\n",
stage, ss_width, ss_height,
(size_t)ss_width + 2 * radius,
(size_t)ss_height + 2 * radius, fft_width, fft_height,
state->plan_measure ? "measure" : "estimate", fft_workers,
real_bytes, freq_bytes, kernel_bytes,
real_bytes + freq_bytes + kernel_bytes);
fast_psf_fftw_destroy(state);
return NULL;
}
int fast_psf_fftw_resolve(FastPsfFftwState *state,
const double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing)
{
if (state == NULL || interleaved_ss_rgb == NULL ||
interleaved_hdr_rgb == NULL)
return -1;
FastPsfFftwFrameTiming local = {0};
const double total_start = omp_get_wtime();
const size_t plane = (size_t)state->fft_height * state->fft_width;
const size_t complex_width = (size_t)state->fft_width / 2 + 1;
const size_t complex_plane = (size_t)state->fft_height * complex_width;
const size_t real_count = 3 * plane;
const size_t freq_count = 3 * complex_plane;
double start = omp_get_wtime();
#pragma omp parallel for schedule(static) num_threads(state->workers) \
if (state->workers > 1)
for (size_t i = 0; i < real_count; ++i)
state->real_rgb[i] = 0.0;
#pragma omp parallel for schedule(static) num_threads(state->workers) \
if (state->workers > 1)
for (int y = 0; y < state->ss_height; ++y) {
for (int x = 0; x < state->ss_width; ++x) {
const size_t source =
3 * ((size_t)y * state->ss_width + (size_t)x);
const size_t target = (size_t)y * state->fft_width + (size_t)x;
state->real_rgb[target] = interleaved_ss_rgb[source];
state->real_rgb[plane + target] = interleaved_ss_rgb[source + 1];
state->real_rgb[2 * plane + target] = interleaved_ss_rgb[source + 2];
}
}
local.zero_pack_seconds = omp_get_wtime() - start;
start = omp_get_wtime();
fftw_execute(state->forward_plan);
local.forward_seconds = omp_get_wtime() - start;
start = omp_get_wtime();
#pragma omp parallel for schedule(static) num_threads(state->workers) \
if (state->workers > 1)
for (size_t i = 0; i < freq_count; ++i) {
const double ar = state->freq_rgb[i][0];
const double ai = state->freq_rgb[i][1];
const double br = state->kernel_freq[i % complex_plane][0];
const double bi = state->kernel_freq[i % complex_plane][1];
state->freq_rgb[i][0] = ar * br - ai * bi;
state->freq_rgb[i][1] = ar * bi + ai * br;
}
local.multiply_seconds = omp_get_wtime() - start;
start = omp_get_wtime();
fftw_execute(state->inverse_plan);
local.inverse_seconds = omp_get_wtime() - start;
start = omp_get_wtime();
const int radius = state->kernel_radius;
const int supersample = state->supersample;
const double scale = state->fft_scale * state->box_scale;
#pragma omp parallel for schedule(static) num_threads(state->workers) \
if (state->workers > 1)
for (int row = 0; row < state->final_height; ++row) {
for (int column = 0; column < state->final_width; ++column) {
double sum[3] = {0.0, 0.0, 0.0};
const int base_y = radius + supersample * row;
const int base_x = radius + supersample * column;
for (int j = 0; j < supersample; ++j)
for (int i = 0; i < supersample; ++i) {
const size_t offset =
(size_t)(base_y + j) * state->fft_width +
(size_t)(base_x + i);
sum[0] += state->real_rgb[offset];
sum[1] += state->real_rgb[plane + offset];
sum[2] += state->real_rgb[2 * plane + offset];
}
const size_t out =
3 * ((size_t)row * state->final_width + (size_t)column);
interleaved_hdr_rgb[out] += sum[0] * scale;
interleaved_hdr_rgb[out + 1] += sum[1] * scale;
interleaved_hdr_rgb[out + 2] += sum[2] * scale;
}
}
local.crop_downsample_seconds = omp_get_wtime() - start;
local.total_seconds = omp_get_wtime() - total_start;
if (timing != NULL)
*timing = local;
return 0;
}
void fast_psf_fftw_report(const FastPsfFftwState *state, FILE *stream)
{
if (state == NULL || stream == NULL)
return;
fprintf(stream,
"Fast FFTW: linear min=%dx%d, fft=%dx%d, workers=%d, "
"plan=%s, plan=%.6f s, kernel_fft=%.6f s, scratch=%.1f MiB\n",
state->ss_height + 2 * state->kernel_radius,
state->ss_width + 2 * state->kernel_radius, state->fft_height,
state->fft_width, state->workers,
state->plan_measure ? "measure" : "estimate", state->setup_seconds,
state->kernel_seconds,
(double)state->scratch_bytes / (1024.0 * 1024.0));
}
+67
View File
@@ -0,0 +1,67 @@
#ifndef FAST_PSF_FFTW_H
#define FAST_PSF_FFTW_H
#include <stddef.h>
#include <stdio.h>
/* Private FFTW-backed global convolution for fast-mode PSF accumulation.
*
* This module owns every FFTW allocation and plan. It replaces only the
* spatial convolution inside the explicit fast-mode preview path; the impulse
* buffer stays owned by the caller, and the HDR framebuffer is caller-owned and
* updated with +=. It is compiled only for the CPU PSF backend (FAST_PSF_FFTW)
* so the HIP and dummy backends do not link FFTW. */
typedef struct FastPsfFftwState FastPsfFftwState;
typedef struct {
double zero_pack_seconds;
double forward_seconds;
double multiply_seconds;
double inverse_seconds;
double crop_downsample_seconds;
double total_seconds;
} FastPsfFftwFrameTiming;
/* Builds one reusable convolution state from the immutable kernel `weights`
* (side 2*kernel_radius+1, row-major) and the cached circular `row_span`
* (length 2*kernel_radius+1, the number of retained dx at each dy). The state
* computes a zero-padded linear convolution over Wss x Hss and downsamples by
* `supersample`. `setup_seconds` receives plan creation time and
* `kernel_seconds` receives the one-time kernel transform time. Returns NULL
* on any allocation or plan failure; nothing is leaked. */
FastPsfFftwState *fast_psf_fftw_create(int ss_width, int ss_height,
int final_width, int final_height,
int supersample, int kernel_radius,
const float *weights,
const int *row_span, int fft_workers,
double *setup_seconds,
double *kernel_seconds);
/* Convolves `interleaved_ss_rgb` (3 doubles per supersampled pixel, row-major)
* into `interleaved_hdr_rgb` using +=. The input buffer is not modified.
* Returns 0 on success and -1 on invalid state. */
int fast_psf_fftw_resolve(FastPsfFftwState *state,
const double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing);
/* Safe for NULL and partially initialized states; releases plans before the
* buffers they reference. Does not touch process-global FFTW state except on
* the final live state. */
void fast_psf_fftw_destroy(FastPsfFftwState *state);
/* Prints one diagnostic line when `state` is non-NULL. */
void fast_psf_fftw_report(const FastPsfFftwState *state, FILE *stream);
/* First value >= min_extent whose prime factors are limited to {2,3,5,7}.
* Returns 0 and stores the result in *out on success; returns -1 when the
* result would exceed INT_MAX or on overflow. */
int fast_psf_fftw_next_smooth_size(size_t min_extent, size_t *out);
/* Selects FFTW_MEASURE (measure != 0) instead of FFTW_ESTIMATE for plans
* created after this call. Intended for the benchmark's plan-mode comparison;
* production uses the default FFTW_ESTIMATE. */
void fast_psf_fftw_set_plan_mode(int measure);
#endif
+2
View File
@@ -977,6 +977,7 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
if (s->fast_mode) { if (s->fast_mode) {
fast = &local_fast; fast = &local_fast;
fast_psf_accumulator_report(&local_fast, stderr); fast_psf_accumulator_report(&local_fast, stderr);
fast_psf_accumulator_set_verbose(&local_fast, s->verbose);
} }
for (size_t i = 0; i < map.frame_count; ++i) { for (size_t i = 0; i < map.frame_count; ++i) {
const char *output_path = s->output_path; const char *output_path = s->output_path;
@@ -1218,6 +1219,7 @@ int main(int argc, char **argv) {
} }
settings.fast_psf = &fast_accumulator; settings.fast_psf = &fast_accumulator;
fast_psf_accumulator_report(&fast_accumulator, stderr); fast_psf_accumulator_report(&fast_accumulator, stderr);
fast_psf_accumulator_set_verbose(&fast_accumulator, settings.verbose);
} else if (settings.fast_mode) { } else if (settings.fast_mode) {
fputs("Fast mode on an imported lens map uses the map's own dimensions.\n", fputs("Fast mode on an imported lens map uses the map's own dimensions.\n",
stderr); stderr);
+81 -14
View File
@@ -1,5 +1,9 @@
#include "optics.h" #include "optics.h"
#ifdef FAST_PSF_FFTW
#include "fast_psf_fftw.h"
#endif
#include <limits.h> #include <limits.h>
#include <math.h> #include <math.h>
#include <omp.h> #include <omp.h>
@@ -500,12 +504,19 @@ int fast_psf_accumulator_init(FastPsfAccumulator *accumulator, int width,
side * side > SIZE_MAX / sizeof(float)) side * side > SIZE_MAX / sizeof(float))
return -1; return -1;
float *weights = malloc(side * side * sizeof *weights); float *weights = malloc(side * side * sizeof *weights);
int *row_span = malloc(side * sizeof *row_span);
double *buffer = calloc(ss_width * ss_height * 3, sizeof *buffer); double *buffer = calloc(ss_width * ss_height * 3, sizeof *buffer);
if (weights == NULL || buffer == NULL) { if (weights == NULL || row_span == NULL || buffer == NULL) {
free(weights); free(weights);
free(row_span);
free(buffer); free(buffer);
return -1; return -1;
} }
for (int dy = -radius; dy <= radius; ++dy) {
const double remaining =
(double)radius * radius - (double)dy * dy;
row_span[dy + radius] = remaining > 0.0 ? (int)sqrt(remaining) : 0;
}
*accumulator = (FastPsfAccumulator){ *accumulator = (FastPsfAccumulator){
.fwhm_pixels = psf->fwhm_pixels, .fwhm_pixels = psf->fwhm_pixels,
.moffat_beta = psf->moffat_beta, .moffat_beta = psf->moffat_beta,
@@ -522,6 +533,7 @@ int fast_psf_accumulator_init(FastPsfAccumulator *accumulator, int width,
.radius_pixels = radius, .radius_pixels = radius,
.use_reference = use_reference, .use_reference = use_reference,
.weights = weights, .weights = weights,
.row_span = row_span,
.buffer = buffer}; .buffer = buffer};
/* k[m] is the pixel-area integral of I(z/N; alpha, beta) over ss cell m, /* k[m] is the pixel-area integral of I(z/N; alpha, beta) over ss cell m,
* i.e. the final-normalized Moffat evaluated at the supersampled scale. * i.e. the final-normalized Moffat evaluated at the supersampled scale.
@@ -539,6 +551,23 @@ int fast_psf_accumulator_init(FastPsfAccumulator *accumulator, int width,
weights[(size_t)(dy + radius) * side + (dx + radius)] = weights[(size_t)(dy + radius) * side + (dx + radius)] =
(float)(weight * normalization_scale); (float)(weight * normalization_scale);
} }
#ifdef FAST_PSF_FFTW
int fft_workers = omp_get_max_threads();
if (fft_workers < 1)
fft_workers = 1;
accumulator->fftw = fast_psf_fftw_create(
(int)ss_width, (int)ss_height, width, height, supersample, radius,
weights, row_span, fft_workers, &accumulator->fftw_setup_seconds,
&accumulator->fftw_kernel_seconds);
if (accumulator->fftw == NULL) {
free(weights);
free(row_span);
free(buffer);
*accumulator = (FastPsfAccumulator){0};
return -1;
}
accumulator->fftw_enabled = 1;
#endif
return 0; return 0;
} }
@@ -590,24 +619,20 @@ int fast_psf_accumulator_deposit(FastPsfAccumulator *accumulator, double x,
return wing_clipped; return wing_clipped;
} }
int fast_psf_accumulator_resolve(const FastPsfAccumulator *accumulator, /* Spatial reference global convolution. Kept as a test/benchmark reference
double *hdr, int worker_count) * with the identical arithmetic the FFTW path must reproduce; it is never a
* silent runtime fallback. */
int fast_psf_accumulator_resolve_spatial_reference(
FastPsfAccumulator *accumulator, double *hdr, int worker_count)
{ {
const int supersample = accumulator == NULL ? 0 : accumulator->supersample; const int supersample = accumulator == NULL ? 0 : accumulator->supersample;
const int radius = accumulator == NULL ? 0 : accumulator->radius_pixels; const int radius = accumulator == NULL ? 0 : accumulator->radius_pixels;
const size_t side = (size_t)2 * radius + 1; const size_t side = (size_t)2 * radius + 1;
if (accumulator == NULL || accumulator->weights == NULL || if (accumulator == NULL || accumulator->weights == NULL ||
accumulator->buffer == NULL || hdr == NULL || supersample <= 0) accumulator->row_span == NULL || accumulator->buffer == NULL ||
hdr == NULL || supersample <= 0)
return -1; return -1;
int *row_span = malloc(side * sizeof *row_span); const int *row_span = accumulator->row_span;
if (row_span == NULL)
return -1;
for (int dy = -radius; dy <= radius; ++dy) {
const double remaining =
(double)radius * radius - (double)dy * dy;
row_span[dy + radius] =
remaining > 0.0 ? (int)sqrt(remaining) : 0;
}
const double inverse_block = 1.0 / ((double)supersample * supersample); const double inverse_block = 1.0 / ((double)supersample * supersample);
const int threads = worker_count > 0 ? worker_count : 1; const int threads = worker_count > 0 ? worker_count : 1;
#pragma omp parallel for schedule(static) num_threads(threads) if (threads > 1) #pragma omp parallel for schedule(static) num_threads(threads) if (threads > 1)
@@ -652,15 +677,54 @@ int fast_psf_accumulator_resolve(const FastPsfAccumulator *accumulator,
hdr[out + 2] += sum[2] * inverse_block; hdr[out + 2] += sum[2] * inverse_block;
} }
} }
free(row_span);
return 0; return 0;
} }
int fast_psf_accumulator_resolve(FastPsfAccumulator *accumulator,
double *hdr, int worker_count)
{
if (accumulator == NULL || accumulator->buffer == NULL || hdr == NULL)
return -1;
#ifdef FAST_PSF_FFTW
if (accumulator->fftw_enabled && accumulator->fftw != NULL) {
FastPsfFftwFrameTiming timing = {0};
const double start = omp_get_wtime();
if (fast_psf_fftw_resolve(accumulator->fftw, accumulator->buffer, hdr,
&timing))
return -1;
accumulator->fftw_frame_seconds = omp_get_wtime() - start;
if (accumulator->verbose)
fprintf(stderr,
"Fast FFTW frame: zero_pack=%.6f forward=%.6f "
"multiply=%.6f inverse=%.6f crop_downsample=%.6f "
"total=%.6f\n",
timing.zero_pack_seconds, timing.forward_seconds,
timing.multiply_seconds, timing.inverse_seconds,
timing.crop_downsample_seconds, timing.total_seconds);
return 0;
}
#endif
return fast_psf_accumulator_resolve_spatial_reference(accumulator, hdr,
worker_count);
}
void fast_psf_accumulator_set_verbose(FastPsfAccumulator *accumulator,
int verbose)
{
if (accumulator != NULL)
accumulator->verbose = verbose ? 1 : 0;
}
void fast_psf_accumulator_destroy(FastPsfAccumulator *accumulator) void fast_psf_accumulator_destroy(FastPsfAccumulator *accumulator)
{ {
if (accumulator == NULL) if (accumulator == NULL)
return; return;
#ifdef FAST_PSF_FFTW
fast_psf_fftw_destroy(accumulator->fftw);
accumulator->fftw = NULL;
#endif
free(accumulator->weights); free(accumulator->weights);
free(accumulator->row_span);
free(accumulator->buffer); free(accumulator->buffer);
*accumulator = (FastPsfAccumulator){0}; *accumulator = (FastPsfAccumulator){0};
} }
@@ -687,6 +751,9 @@ void fast_psf_accumulator_report(const FastPsfAccumulator *accumulator,
sum / ((double)accumulator->supersample * sum / ((double)accumulator->supersample *
accumulator->supersample), accumulator->supersample),
accumulator->use_reference ? "reference 8-point" : "cached 4-point"); accumulator->use_reference ? "reference 8-point" : "cached 4-point");
#ifdef FAST_PSF_FFTW
fast_psf_fftw_report(accumulator->fftw, stream);
#endif
} }
+20 -1
View File
@@ -26,6 +26,10 @@ typedef enum {
FAST_PSF_DEPOSIT_BILINEAR = 1, FAST_PSF_DEPOSIT_BILINEAR = 1,
} FastPsfDeposit; } FastPsfDeposit;
/* Private FFTW convolution state. Defined in fast_psf_fftw.c; only the CPU
* PSF backend may reference the implementation. */
typedef struct FastPsfFftwState FastPsfFftwState;
/* Fast point-source accumulation: every image event is deposited as a delta /* Fast point-source accumulation: every image event is deposited as a delta
* (one nearest supersampled pixel, or 4 bilinear pixels) into one shared * (one nearest supersampled pixel, or 4 bilinear pixels) into one shared
* supersampled HDR buffer. A single immutable global kernel is convolved once * supersampled HDR buffer. A single immutable global kernel is convolved once
@@ -45,7 +49,16 @@ typedef struct {
int radius_pixels; /* kernel radius in supersampled pixels */ int radius_pixels; /* kernel radius in supersampled pixels */
int use_reference; /* 8-point instead of 4-point kernel quadrature */ int use_reference; /* 8-point instead of 4-point kernel quadrature */
float *weights; /* (2R+1)^2 pixel-area kernel, row-major */ float *weights; /* (2R+1)^2 pixel-area kernel, row-major */
int *row_span; /* cached circular half-width per dy, length 2R+1 */
double *buffer; /* supersampled HDR, 3 channels per pixel */ double *buffer; /* supersampled HDR, 3 channels per pixel */
#ifdef FAST_PSF_FFTW
FastPsfFftwState *fftw; /* owned private convolution state */
int fftw_enabled;
double fftw_setup_seconds;
double fftw_kernel_seconds;
double fftw_frame_seconds; /* most recent resolve */
#endif
int verbose;
} FastPsfAccumulator; } FastPsfAccumulator;
typedef struct { typedef struct {
@@ -110,8 +123,14 @@ void fast_psf_accumulator_clear(FastPsfAccumulator *accumulator);
* kernel radius (wing clipped), and 3 when discarded by --psf-min-y. */ * kernel radius (wing clipped), and 3 when discarded by --psf-min-y. */
int fast_psf_accumulator_deposit(FastPsfAccumulator *accumulator, double x, int fast_psf_accumulator_deposit(FastPsfAccumulator *accumulator, double x,
double y, LinearRgb color, double flux); double y, LinearRgb color, double flux);
int fast_psf_accumulator_resolve(const FastPsfAccumulator *accumulator, int fast_psf_accumulator_resolve(FastPsfAccumulator *accumulator,
double *hdr, int worker_count); double *hdr, int worker_count);
/* Test/benchmark reference only: the original nested-loop global convolution.
* Production resolve never falls back to it. */
int fast_psf_accumulator_resolve_spatial_reference(
FastPsfAccumulator *accumulator, double *hdr, int worker_count);
void fast_psf_accumulator_set_verbose(FastPsfAccumulator *accumulator,
int verbose);
void fast_psf_accumulator_destroy(FastPsfAccumulator *accumulator); void fast_psf_accumulator_destroy(FastPsfAccumulator *accumulator);
void fast_psf_accumulator_report(const FastPsfAccumulator *accumulator, void fast_psf_accumulator_report(const FastPsfAccumulator *accumulator,
FILE *stream); FILE *stream);
+223
View File
@@ -0,0 +1,223 @@
/* Resolver-only benchmark for the fast-mode FFTW global convolution.
*
* Fills one deterministic impulse buffer, then times the spatial reference and
* the FFTW production resolver on that identical buffer. First-use plan/setup
* is reported separately from steady-state execution. No catalog or geodesic
* work is involved. */
#ifndef FAST_PSF_FFTW
#error "benchmark_fast_psf_fftw requires -DFAST_PSF_FFTW (PSF_BACKEND=cpu)"
#endif
#include "fast_psf_fftw.h"
#include "optics.h"
#include <math.h>
#include <omp.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
typedef struct {
int width, height, supersample, repeats;
int run_spatial;
int measure;
FastPsfDeposit deposit;
} Options;
static int parse_int(const char *text, int *out)
{
char *end = NULL;
const long value = strtol(text, &end, 10);
if (end == text || *end != '\0' || value <= 0 || value > 1000000)
return -1;
*out = (int)value;
return 0;
}
static int parse_options(int argc, char **argv, Options *options)
{
*options = (Options){.width = 320,
.height = 240,
.supersample = 2,
.repeats = 5,
.run_spatial = 0,
.measure = 0,
.deposit = FAST_PSF_DEPOSIT_NEAREST};
for (int i = 1; i < argc; ++i) {
if (!strcmp(argv[i], "--spatial")) {
options->run_spatial = 1;
} else if (!strcmp(argv[i], "--measure")) {
options->measure = 1;
} else if (!strcmp(argv[i], "--bilinear")) {
options->deposit = FAST_PSF_DEPOSIT_BILINEAR;
} else if (!strcmp(argv[i], "--width") && i + 1 < argc) {
if (parse_int(argv[++i], &options->width))
return -1;
} else if (!strcmp(argv[i], "--height") && i + 1 < argc) {
if (parse_int(argv[++i], &options->height))
return -1;
} else if (!strcmp(argv[i], "--supersample") && i + 1 < argc) {
if (parse_int(argv[++i], &options->supersample))
return -1;
} else if (!strcmp(argv[i], "--repeats") && i + 1 < argc) {
if (parse_int(argv[++i], &options->repeats))
return -1;
} else {
fprintf(stderr, "unknown argument '%s'\n", argv[i]);
return -1;
}
}
return 0;
}
static uint64_t hash_bytes(const double *values, size_t count)
{
const unsigned char *bytes = (const unsigned char *)values;
const size_t nbytes = count * sizeof *values;
uint64_t hash = 1469598103934665603ULL;
for (size_t i = 0; i < nbytes; ++i) {
hash ^= bytes[i];
hash *= 1099511628211ULL;
}
return hash;
}
static double wall_seconds(void)
{
return omp_get_wtime();
}
static int compare_double(const void *a, const void *b)
{
const double x = *(const double *)a;
const double y = *(const double *)b;
return x < y ? -1 : x > y ? 1 : 0;
}
int main(int argc, char **argv)
{
Options options;
if (parse_options(argc, argv, &options)) {
fprintf(stderr,
"usage: %s [--width W] [--height H] [--supersample N] "
"[--repeats R] [--bilinear] [--spatial] [--measure]\n",
argv[0]);
return 2;
}
const PointSpreadFunction psf = {.fwhm_pixels = 2.7, .moffat_beta = 4.5};
const double relative_tail = 1e-8;
if (options.repeats > 128)
options.repeats = 128;
printf("impulse: %dx%d supersample=%d deposit=%s repeats=%d spatial=%d\n",
options.width, options.height, options.supersample,
options.deposit == FAST_PSF_DEPOSIT_NEAREST ? "nearest" : "bilinear",
options.repeats, options.run_spatial);
printf("plan_mode=%s\n", options.measure ? "measure" : "estimate");
fast_psf_fftw_set_plan_mode(options.measure);
FastPsfAccumulator acc = {0};
const double init_start = wall_seconds();
if (fast_psf_accumulator_init(&acc, options.width, options.height,
options.supersample, options.deposit, &psf,
relative_tail, 0.0, 1)) {
fputs("accumulator initialization failed\n", stderr);
return 1;
}
const double init_seconds = wall_seconds() - init_start;
printf("setup: init=%.6f s fftw_plan=%.6f s kernel_fft=%.6f s\n",
init_seconds, acc.fftw_setup_seconds, acc.fftw_kernel_seconds);
fast_psf_fftw_report(acc.fftw, stdout);
const size_t ss_count = (size_t)acc.supersampled_width *
acc.supersampled_height;
uint64_t state = 0x9e3779b97f4a7c15ULL;
for (size_t i = 0; i < ss_count; ++i) {
state = state * 6364136223846793005ULL + 1442695040888963407ULL;
if ((state >> 58) != 0)
continue;
state = state * 6364136223846793005ULL + 1442695040888963407ULL;
acc.buffer[3 * i] = (double)(state >> 40) / (double)(1ULL << 24);
state = state * 6364136223846793005ULL + 1442695040888963407ULL;
acc.buffer[3 * i + 1] = (double)(state >> 40) / (double)(1ULL << 24);
state = state * 6364136223846793005ULL + 1442695040888963407ULL;
acc.buffer[3 * i + 2] = (double)(state >> 40) / (double)(1ULL << 24);
}
const size_t hdr_count = (size_t)options.width * options.height * 3;
const size_t buffer_count = ss_count * 3;
const uint64_t impulse_hash = hash_bytes(acc.buffer, buffer_count);
printf("impulse_hash=%016llx\n", (unsigned long long)impulse_hash);
double *fftw_hdr = calloc(hdr_count, sizeof *fftw_hdr);
double *spatial_hdr = calloc(hdr_count, sizeof *spatial_hdr);
if (fftw_hdr == NULL || spatial_hdr == NULL) {
fputs("HDR allocation failed\n", stderr);
free(fftw_hdr);
free(spatial_hdr);
fast_psf_accumulator_destroy(&acc);
return 1;
}
/* Warm-up: first execution includes any lazy per-frame allocation. */
if (fast_psf_accumulator_resolve(&acc, fftw_hdr, omp_get_max_threads())) {
fputs("FFTW warm-up resolve failed\n", stderr);
return 1;
}
double *samples = calloc((size_t)options.repeats, sizeof *samples);
if (samples == NULL) {
fputs("sample allocation failed\n", stderr);
return 1;
}
for (int i = 0; i < options.repeats; ++i) {
memset(fftw_hdr, 0, hdr_count * sizeof *fftw_hdr);
const double start = wall_seconds();
if (fast_psf_accumulator_resolve(&acc, fftw_hdr, omp_get_max_threads())) {
fputs("FFTW resolve failed\n", stderr);
return 1;
}
samples[i] = wall_seconds() - start;
}
const uint64_t fftw_hash = hash_bytes(fftw_hdr, hdr_count);
double sorted[128];
memcpy(sorted, samples, (size_t)options.repeats * sizeof *sorted);
qsort(sorted, (size_t)options.repeats, sizeof *sorted, compare_double);
printf("fftw: min=%.6f s median=%.6f s max=%.6f s\n", sorted[0],
sorted[options.repeats / 2], sorted[options.repeats - 1]);
printf("fftw samples:");
for (int i = 0; i < options.repeats; ++i)
printf(" %.6f", samples[i]);
printf("\nfftw_hdr_hash=%016llx\n", (unsigned long long)fftw_hash);
if (options.run_spatial) {
memset(spatial_hdr, 0, hdr_count * sizeof *spatial_hdr);
const double start = wall_seconds();
if (fast_psf_accumulator_resolve_spatial_reference(
&acc, spatial_hdr, omp_get_max_threads())) {
fputs("spatial resolve failed\n", stderr);
return 1;
}
const double spatial_seconds = wall_seconds() - start;
printf("spatial: %.6f s\n", spatial_seconds);
printf("spatial_hdr_hash=%016llx\n",
(unsigned long long)hash_bytes(spatial_hdr, hdr_count));
double max_abs = 0.0, peak = 0.0;
for (size_t i = 0; i < hdr_count; ++i) {
peak = fmax(peak, fabs(spatial_hdr[i]));
max_abs = fmax(max_abs, fabs(fftw_hdr[i] - spatial_hdr[i]));
}
printf("difference: max_abs=%.3g peak=%.3g speedup=%.3fx\n", max_abs, peak,
spatial_seconds / sorted[options.repeats / 2]);
}
struct rusage usage;
if (getrusage(RUSAGE_SELF, &usage) == 0)
printf("rss_max_kb=%ld\n", usage.ru_maxrss);
free(samples);
free(fftw_hdr);
free(spatial_hdr);
fast_psf_accumulator_destroy(&acc);
return 0;
}
+11 -1
View File
@@ -9,6 +9,7 @@ import tempfile
import zlib import zlib
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve() BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
TESTDIR = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else BUILD
ENV = dict(os.environ, OMP_NUM_THREADS='4') ENV = dict(os.environ, OMP_NUM_THREADS='4')
@@ -67,6 +68,15 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
run(binary, *common, '--output', path, *options) run(binary, *common, '--output', path, *options)
return image_payload(path) return image_payload(path)
# CPU fast-mode CLI smoke test: the FFTW resolve must run and report its
# one-time setup line.
if backend == 'minkowski':
fast_path = tmp / 'minkowski_fast.png'
fast = run(binary, *common, '--fast-mode', '--fast-supersample', 2,
'--output', fast_path)
assert 'Fast FFTW:' in fast.stderr, fast.stderr
assert image_payload(fast_path)
# Equivalent independently specified and inferred camera geometry. # Equivalent independently specified and inferred camera geometry.
inferred = render('position', '--observer-position', -30, 0, 0) inferred = render('position', '--observer-position', -30, 0, 0)
explicit = render('explicit', '--observer-position', -30, 0, 0, explicit = render('explicit', '--observer-position', -30, 0, 0,
@@ -117,7 +127,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert not missing_catalog.exists(), result.stderr assert not missing_catalog.exists(), result.stderr
assert 'PSF cache ready' not in result.stderr assert 'PSF cache ready' not in result.stderr
track = tmp / f'{backend}.csv' track = tmp / f'{backend}.csv'
run(BUILD / f'test_observer_{backend}', track) run(TESTDIR / f'test_observer_{backend}', track)
single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens' single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens'
single = render('moving', '--observer-position', 3, -4, 5, single = render('moving', '--observer-position', 3, -4, 5,
'--observer-velocity', 0.2, -0.1, 0.3, '--observer-velocity', 0.2, -0.1, 0.3,
+378
View File
@@ -0,0 +1,378 @@
/* Dedicated FFTW-versus-spatial fast-mode convolution regression.
*
* Both resolvers run on the identical impulse buffer and must agree to double
* rounding. The spatial resolver is the production reference, not a fallback.
* Compile only for the CPU PSF backend. */
#ifndef FAST_PSF_FFTW
#error "test_fast_psf_fftw requires -DFAST_PSF_FFTW (PSF_BACKEND=cpu)"
#endif
#include "optics.h"
#include "fast_psf_fftw.h"
#include <limits.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef INT_MAX
#define INT_MAX 2147483647
#endif
static int g_failures = 0;
static void fail(const char *what)
{
fprintf(stderr, "FAIL: %s\n", what);
++g_failures;
}
static PointSpreadFunction default_psf(void)
{
PointSpreadFunction psf = {.fwhm_pixels = 2.7, .moffat_beta = 4.5};
return psf;
}
static uint64_t hash_bytes(const double *values, size_t count)
{
const unsigned char *bytes = (const unsigned char *)values;
const size_t nbytes = count * sizeof *values;
uint64_t hash = 1469598103934665603ULL;
for (size_t i = 0; i < nbytes; ++i) {
hash ^= bytes[i];
hash *= 1099511628211ULL;
}
return hash;
}
typedef enum {
SCENE_CENTER_WHITE,
SCENE_DISTINCT_RGB,
SCENE_EDGES,
SCENE_BOUNDARY_PHASES,
SCENE_MULTIPLE,
SCENE_DENSE_CELL,
SCENE_RANDOM,
SCENE_EMPTY,
} Scene;
static void fill_scene(FastPsfAccumulator *acc, Scene scene, int width,
int height)
{
const LinearRgb white = {1.0, 1.0, 1.0};
switch (scene) {
case SCENE_CENTER_WHITE:
fast_psf_accumulator_deposit(acc, 0.5 * width, 0.5 * height, white, 1.0);
break;
case SCENE_DISTINCT_RGB:
fast_psf_accumulator_deposit(acc, 0.5 * width, 0.5 * height,
(LinearRgb){1.0, 0.25, 0.05}, 0.75);
break;
case SCENE_EDGES:
fast_psf_accumulator_deposit(acc, 0.3, 0.3, white, 1.0);
fast_psf_accumulator_deposit(acc, width - 0.7, 0.4, white, 1.0);
fast_psf_accumulator_deposit(acc, 0.5, height - 0.6, white, 1.0);
fast_psf_accumulator_deposit(acc, width - 0.4, height - 0.3, white, 1.0);
break;
case SCENE_BOUNDARY_PHASES:
fast_psf_accumulator_deposit(acc, 10.499, 12.499, white, 1.0);
fast_psf_accumulator_deposit(acc, 10.501, 12.501, white, 1.0);
break;
case SCENE_MULTIPLE:
fast_psf_accumulator_deposit(acc, 5.5, 5.5, (LinearRgb){1.0, 0.0, 0.0}, 0.5);
fast_psf_accumulator_deposit(acc, 12.25, 7.75, (LinearRgb){0.0, 1.0, 0.0}, 0.25);
fast_psf_accumulator_deposit(acc, 8.1, 14.9, (LinearRgb){0.0, 0.0, 1.0}, 1.5);
break;
case SCENE_DENSE_CELL:
for (int i = 0; i < 32; ++i)
fast_psf_accumulator_deposit(acc, 9.0 + 0.01 * i, 9.0 + 0.013 * i,
(LinearRgb){1.0, 0.5, 0.25}, 0.05);
break;
case SCENE_RANDOM: {
uint64_t state = 0x9e3779b97f4a7c15ULL;
const size_t count = (size_t)acc->supersampled_width *
acc->supersampled_height * 3;
for (size_t i = 0; i < count; ++i) {
state = state * 6364136223846793005ULL + 1442695040888963407ULL;
acc->buffer[i] += (double)(state >> 40) / (double)(1ULL << 24) - 0.5;
}
break;
}
case SCENE_EMPTY:
break;
}
}
static int run_compare(const char *name, int width, int height,
int supersample, FastPsfDeposit deposit, Scene scene,
int prefill)
{
FastPsfAccumulator acc = {0};
const PointSpreadFunction psf = default_psf();
const double relative_tail = 1e-8;
if (fast_psf_accumulator_init(&acc, width, height, supersample, deposit, &psf,
relative_tail, 0.0, 1)) {
fprintf(stderr, "FAIL: %s: accumulator init failed\n", name);
++g_failures;
return -1;
}
if (!acc.fftw_enabled) {
fprintf(stderr, "FAIL: %s: FFTW path not enabled\n", name);
++g_failures;
fast_psf_accumulator_destroy(&acc);
return -1;
}
const size_t hdr_count = (size_t)width * height * 3;
const size_t buffer_count = (size_t)acc.supersampled_width *
acc.supersampled_height * 3;
double *fftw_hdr = malloc(hdr_count * sizeof *fftw_hdr);
double *spatial_hdr = malloc(hdr_count * sizeof *spatial_hdr);
if (fftw_hdr == NULL || spatial_hdr == NULL) {
fprintf(stderr, "FAIL: %s: HDR allocation failed\n", name);
++g_failures;
free(fftw_hdr);
free(spatial_hdr);
fast_psf_accumulator_destroy(&acc);
return -1;
}
for (size_t i = 0; i < hdr_count; ++i)
fftw_hdr[i] = spatial_hdr[i] = prefill ? 0.25 : 0.0;
fill_scene(&acc, scene, width, height);
const uint64_t buffer_before = hash_bytes(acc.buffer, buffer_count);
if (fast_psf_accumulator_resolve(&acc, fftw_hdr, 4)) {
fprintf(stderr, "FAIL: %s: FFTW resolve failed\n", name);
++g_failures;
goto cleanup;
}
if (fast_psf_accumulator_resolve_spatial_reference(&acc, spatial_hdr, 4)) {
fprintf(stderr, "FAIL: %s: spatial reference resolve failed\n", name);
++g_failures;
goto cleanup;
}
if (hash_bytes(acc.buffer, buffer_count) != buffer_before) {
fprintf(stderr, "FAIL: %s: resolve mutated the impulse buffer\n", name);
++g_failures;
goto cleanup;
}
double peak = 0.0, max_abs = 0.0, max_rel = 0.0, sum_sq = 0.0;
double flux_fftw[3] = {0.0, 0.0, 0.0};
double flux_spatial[3] = {0.0, 0.0, 0.0};
size_t nan_count = 0;
for (size_t i = 0; i < hdr_count; ++i) {
const double reference = spatial_hdr[i];
const double error = fabs(fftw_hdr[i] - reference);
peak = fmax(peak, fabs(reference));
max_abs = fmax(max_abs, error);
sum_sq += error * error;
if (!isfinite(fftw_hdr[i]) || !isfinite(reference))
++nan_count;
flux_fftw[i % 3] += fftw_hdr[i];
flux_spatial[i % 3] += reference;
}
/* Mixed absolute/relative acceptance: the absolute floor covers the tiny
* Moffat far-wing samples where the FFT and the direct sum disagree only by
* roundoff, while the relative term checks significant samples. A crop,
* wrap, channel, or normalization bug produces O(1) errors well above both. */
const double rms = sqrt(sum_sq / (double)hdr_count);
const double abs_tol = 1e-10 * fmax(1.0, peak);
const double rel_tol = 1e-9;
const double rel_floor = 1e-6 * fmax(peak, 1.0);
double max_violation = 0.0;
for (size_t i = 0; i < hdr_count; ++i) {
const double reference = spatial_hdr[i];
const double error = fabs(fftw_hdr[i] - reference);
max_violation =
fmax(max_violation, error - (abs_tol + rel_tol * fabs(reference)));
if (fabs(reference) > rel_floor)
max_rel = fmax(max_rel, error / fabs(reference));
}
int worst = -1;
double worst_error = 0.0;
for (size_t i = 0; i < hdr_count; ++i) {
const double error = fabs(fftw_hdr[i] - spatial_hdr[i]);
if (error > worst_error) {
worst_error = error;
worst = (int)(i / 3);
}
}
double flux_rel = 0.0;
for (int c = 0; c < 3; ++c) {
const double denom = fmax(fabs(flux_spatial[c]), 1e-30);
flux_rel = fmax(flux_rel, fabs(flux_fftw[c] - flux_spatial[c]) / denom);
}
if (nan_count != 0 || max_violation > 0.0 || max_rel > rel_tol ||
flux_rel > 1e-10) {
fprintf(stderr,
"FAIL: %s: peak=%.6g max_abs=%.3g (tol %.3g) max_rel=%.3g "
"rms=%.3g flux_rel=%.3g nan=%zu worst_pixel=%d\n",
name, peak, max_abs, abs_tol, max_rel, rms, flux_rel, nan_count,
worst);
++g_failures;
} else {
printf("ok %-28s peak=%.4g max_abs=%.3g max_rel=%.3g rms=%.3g\n", name,
peak, max_abs, max_rel, rms);
}
cleanup:
free(fftw_hdr);
free(spatial_hdr);
fast_psf_accumulator_destroy(&acc);
return g_failures == 0 ? 0 : -1;
}
static void test_next_smooth_size(void)
{
struct {
size_t input;
long expected; /* -1 = must fail */
} cases[] = {
{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}, {6, 6},
{7, 7}, {8, 8}, {9, 9}, {11, 12}, {13, 14}, {15, 15},
{121, 125}, {127, 128}, {200, 200}, {241, 243},
{(size_t)INT_MAX + 1, -1},
{0, -1},
};
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; ++i) {
size_t out = 0;
const int rc = fast_psf_fftw_next_smooth_size(cases[i].input, &out);
if (cases[i].expected < 0) {
if (rc == 0)
fprintf(stderr, "FAIL: next_smooth_size(%zu) unexpectedly returned %zu\n",
cases[i].input, out), ++g_failures;
} else if (rc != 0 || out != (size_t)cases[i].expected) {
fprintf(stderr, "FAIL: next_smooth_size(%zu) = rc %d, %zu (want %ld)\n",
cases[i].input, rc, out, cases[i].expected);
++g_failures;
}
}
size_t out = 0;
if (fast_psf_fftw_next_smooth_size((size_t)1 << 30, &out) != 0 ||
out > (size_t)INT_MAX)
fail("next_smooth_size near INT_MAX did not return a valid FFT size");
}
/* A single impulse must not wrap to the opposite edge, and an empty buffer must
* leave the framebuffer untouched. */
static void test_no_wraparound_and_empty(void)
{
PointSpreadFunction psf = default_psf();
/* A compact kernel keeps the image much wider than the support so a genuine
* circular-wrap bug would show up as an opposite-edge ghost. */
psf.fwhm_pixels = 0.02;
const double relative_tail = 1e-8;
const int width = 12, height = 12, supersample = 2;
FastPsfAccumulator acc = {0};
if (fast_psf_accumulator_init(&acc, width, height, supersample,
FAST_PSF_DEPOSIT_NEAREST, &psf, relative_tail,
0.0, 1)) {
fail("wraparound accumulator init");
return;
}
const size_t count = (size_t)width * height * 3;
double *hdr = calloc(count, sizeof *hdr);
if (hdr == NULL) {
fail("wraparound allocation");
fast_psf_accumulator_destroy(&acc);
return;
}
fast_psf_accumulator_deposit(&acc, 0.6, 0.6, (LinearRgb){1.0, 1.0, 1.0}, 1.0);
if (fast_psf_accumulator_resolve(&acc, hdr, 2)) {
fail("wraparound resolve");
} else {
const int far_x = width - 1, far_y = height - 1;
const double ghost = hdr[3 * (far_y * width + far_x)];
if (fabs(ghost) > 1e-15)
fprintf(stderr, "FAIL: opposite-edge ghost value %.3g\n", ghost),
++g_failures;
if (hdr[0] <= 0.0)
fail("impulse peak missing near the deposited corner");
}
/* Empty buffer: the same accumulator, after clearing, must not change HDR. */
fast_psf_accumulator_clear(&acc);
double background[3] = {0.25, 0.5, 0.75};
for (size_t i = 0; i < count; ++i)
hdr[i] = background[i % 3];
if (fast_psf_accumulator_resolve(&acc, hdr, 2))
fail("empty resolve");
for (size_t i = 0; i < count; ++i) {
if (hdr[i] != background[i % 3]) {
fail("empty input changed the HDR framebuffer");
break;
}
}
free(hdr);
fast_psf_accumulator_destroy(&acc);
}
/* Channel separation: a pure-red impulse must leave green and blue at zero. */
static void test_channel_isolation(void)
{
const PointSpreadFunction psf = default_psf();
const int width = 20, height = 18;
FastPsfAccumulator acc = {0};
if (fast_psf_accumulator_init(&acc, width, height, 2,
FAST_PSF_DEPOSIT_NEAREST, &psf, 1e-8, 0.0, 1)) {
fail("channel isolation init");
return;
}
const size_t count = (size_t)width * height * 3;
double *hdr = calloc(count, sizeof *hdr);
fast_psf_accumulator_deposit(&acc, 10.0, 9.0, (LinearRgb){1.0, 0.0, 0.0}, 1.0);
if (fast_psf_accumulator_resolve(&acc, hdr, 2))
fail("channel isolation resolve");
for (size_t i = 0; i < count; i += 3) {
if (hdr[i + 1] != 0.0 || hdr[i + 2] != 0.0) {
fail("green/blue channel leaked into a pure-red impulse");
break;
}
}
free(hdr);
fast_psf_accumulator_destroy(&acc);
}
int main(void)
{
test_next_smooth_size();
test_no_wraparound_and_empty();
test_channel_isolation();
const int sizes[][2] = {{17, 13}, {16, 16}, {23, 31}, {33, 17}};
const int supersamples[] = {1, 2, 3, 4};
const FastPsfDeposit deposits[] = {FAST_PSF_DEPOSIT_NEAREST,
FAST_PSF_DEPOSIT_BILINEAR};
for (size_t s = 0; s < sizeof sizes / sizeof sizes[0]; ++s) {
for (size_t n = 0; n < sizeof supersamples / sizeof supersamples[0]; ++n) {
for (size_t d = 0; d < sizeof deposits / sizeof deposits[0]; ++d) {
for (Scene scene = SCENE_CENTER_WHITE; scene <= SCENE_EMPTY;
++scene) {
char name[128];
snprintf(name, sizeof name, "%dx%d N%d %s scene%d", sizes[s][0],
sizes[s][1], supersamples[n],
deposits[d] == FAST_PSF_DEPOSIT_NEAREST ? "near" : "bilin",
(int)scene);
if (run_compare(name, sizes[s][0], sizes[s][1], supersamples[n],
deposits[d], scene, 0) != 0) {
/* Keep going: collect all failures before summarizing. */
}
}
}
}
}
run_compare("prefilled background", 24, 20, 2, FAST_PSF_DEPOSIT_NEAREST,
SCENE_MULTIPLE, 1);
/* One axis (height) stays at the exact FFT size while the other grows. */
run_compare("single-axis padding", 32, 8, 2, FAST_PSF_DEPOSIT_NEAREST,
SCENE_CENTER_WHITE, 0);
if (g_failures != 0) {
fprintf(stderr, "%d fast-PSF FFTW regression failure(s)\n", g_failures);
return 1;
}
puts("fast PSF FFTW regressions passed");
return 0;
}
+14
View File
@@ -291,6 +291,20 @@ bright event whose requested support exceeds that radius is wing-clipped and
counted in the report. `--psf-min-y` is still applied per event. Fast mode is a counted in the report. `--psf-min-y` is still applied per event. Fast mode is a
preview approximation, not the physically exact per-event PSF path. preview approximation, not the physically exact per-event PSF path.
In CPU builds the global convolution is a zero-padded FFTW linear convolution
(`fftw3`/`fftw3_omp`, double precision). The immutable kernel spectrum, FFTW
plans, and scratch buffers are built once when the accumulator is initialized
and reused for every frame; the disposable spatial reference remains only for
tests and benchmarks. Startup prints one `Fast FFTW:` line with the minimum
linear-convolution extent, the selected smooth FFT dimensions, worker count,
plan mode, one-time plan and kernel-transform time, and scratch bytes.
`--verbose` additionally prints per-frame stage timings (zero/pack, forward FFT,
frequency multiply, inverse FFT, crop/downsample, total). The default FFTW
planning mode is `FFTW_ESTIMATE`; `FFTW_MEASURE` can cut steady-state execution
substantially but costs a much longer one-time plan for large frames, as
recorded in
[benchmarks/fast_mode_fftw_2026-09-25.md](benchmarks/fast_mode_fftw_2026-09-25.md).
## Progress and diagnostics ## Progress and diagnostics
The PSF-cache completion line is printed before tracing and catalog splatting The PSF-cache completion line is printed before tracing and catalog splatting