Repository navigation
Python: filter FDK/FBP projections in cache-sized blocks, on several threads - #790
Open
Dev-next-gen wants to merge 2 commits into
Open
Dev-next-gen wants to merge 2 commits into
Dev-next-gen wants to merge 2 commits into
Conversation
…threads filtering() packed two projections in one complex array of nDetector[0] x filt_len and ran fft, the product with the filter and ifft over the whole array, each of them allocating another array of that size, on one thread. For a 1024 x 1024 detector that is 32 MiB per array, which does not stay in cache, and the other cores idle. Detector rows are filtered independently of each other. Cut each pair of projections in blocks of rows that take about 2 MiB once padded, filter a block in place in a buffer owned by the thread, and share the blocks among a thread pool: numpy and scipy.fft release the GIL. The arithmetic done on a row does not change, and a block is a multiple of 16 rows so that scipy's FFT packs the same rows in a SIMD vector as it did on the whole projection. The output is bit-identical to the previous one in the 574 cases compared: filtering() called directly, and what FDK() and fbp() hand to Atb, with every filter, odd and even numbers of angles, an offset detector, and 1 to 16 threads. Filtering 360 projections of 1024 x 1024 took 14 to 30 s from one run to the next on the machine used (2 x 20-core Xeon E5-2698 v4, shared); it now takes 9 to 10 s on one thread, 2.6 to 2.9 s on four and 1.0 s on sixteen. The memory the step allocates no longer grows with the detector: 80 MiB for 1024 x 1024 and 320 MiB for 2048 x 2048 before, about 2 MiB per thread now. filtering() takes a new optional argument, workers. By default it uses the cores the process is allowed to run on, at most 16: more threads than that were slower on the machine above.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The filtering step of
algs.fdk/algs.fbpruns on one core and works on whole padded projections. #331 already noted that it "could be improved by multithreading" (it was 50 s of a 91 s FDK there), and #312 / #423 tried to move it to the GPU without a clear win. This PR keeps it on the CPU, in numpy andscipy.fftas it is now, and makes it 5 times faster on 4 cores and 14 times faster on 16 for a 1024 × 1024 detector. The filtered projections are bit-identical to what master produces.What changes
filtering()packs two projections in one complex array ofnDetector[0] × filt_lenand runsfft, the product with the filter andifftover the whole array, each of them allocating another array of that size. For a 1024 × 1024 detector that is 32 MiB per array, so the data does not stay in cache, and only one core works.Detector rows are filtered independently of each other. Each pair of projections is now cut in blocks of rows that take about 2 MiB once padded, a block is filtered in place in a buffer owned by the thread, and the blocks are shared among a
ThreadPoolExecutor(numpy andscipy.fftrelease the GIL). The rest is as before: two projections packed in a complex array, the same filter, the samescipy.fftcalls on each row. No new dependency, and onlyPython/tigre/utilities/filtering.pyis touched.Same output
The arithmetic done on a row does not change. The one thing that could change the last bit is how scipy's FFT groups rows in a SIMD vector, so a block is always a multiple of 16 rows, which keeps the grouping it has on a whole projection (with blocks of 5 rows I measured a relative difference of 3e-7).
I compared master's
filtering()and this one withnp.array_equalin 574 cases: called directly (9 detector shapes, odd and even numbers of angles, the 6 filter settings, 1, 2, 3, 5 and the default number of workers, the default and the smallest block size, float64 input), and throughalgs.fdk(centred and offset detector, anisotropic pixels) andalgs.fbp, comparing the array they hand toAtb. All 574 are bit-identical, with NumPy 2.5.3 / SciPy 1.18.1 and with NumPy 1.26.4 / SciPy 1.11.4, on x86-64.Timings
filtering(proj, geo, angles, parker=False)on random float32 projections, best of 3, 2 × 20-core Xeon E5-2698 v4, Python 3.12, NumPy 2.5.3, SciPy 1.18.1. "before" is master's function loaded in the same process.The machine is shared and the "before" time of the 1024 × 1024 case moved between 14 and 30 s from one run to the next; the table is the run where it was lowest. The "after" times were stable (1.0 s on 16 threads in every run).
Small problems do not get slower: 100 × 64 × 64 goes from 12 to 9 ms, 100 × 256 × 256 from 179 to 27 ms, and a fan-beam sinogram of 720 × 1 × 1024 from 48 to 41 ms (single rows are too small to share out, so that one barely moves).
algs.fdk()up to the call toAtb, that is the weights plus the filtering: 15.9 s → 2.25 s for 360 × 1024 × 1024, 23.1 s → 2.8 s for 100 × 2048 × 2048.Memory allocated by the step on top of the projections: 80 MiB → 35 MiB for a 1024 × 1024 detector and 320 MiB → 36 MiB for 2048 × 2048 with 16 threads (about 2 MiB per thread, 2.6 MiB with one thread).
End to end on a GPU
On an RTX 3070 Laptop (CUDA 12.4), I timed the full
algs.fdk()— the filtering plus the real GPU backprojection — with master's filtering and with this one, in the same process. The reconstructed volume is bit-identical between the two (equal sha256), so the change leaves the reconstruction untouched and only saves time. 360 angles, 1024 detector: a 512³ volume goes 13.22 s → 6.47 s (×2.04), a 768³ volume 16.29 s → 9.70 s (×1.68), peak VRAM 1.2 and 3.3 GiB. The end-to-end speedup is smaller than the filtering speedup and shrinks as the volume grows, because the GPU backprojection is unchanged and takes a growing share of the total — which is the point: the filtering is no longer the bottleneck.workersfiltering()gets an optionalworkersargument. By default it uses the number of cores the process is allowed to run on (os.sched_getaffinitywhere it exists,os.cpu_count()elsewhere), capped at 16: on the machine above, 24 threads and more were slower than 16 (1.5 s with 24, 1.9 s with 32, 2.6 s with 40, against 1.0 s with 16, for 360 × 1024 × 1024).FDK()andfbp()are not touched; passingworkersthrough their kwargs is a small follow-up if you want it exposed there.One known limit: a CPU quota (a container started with
--cpus=4) does not show in the affinity mask, so the default still starts 16 threads there. In that setting the 1024 × 1024 case took 3.6 s with the default against 2.6 s withworkers=4.Tests
I have put the test file in the PR:
Python/tests/test_filtering_workers.py, 37 cases — against a plain per-projection float64 filter, same bits for any number of workers and block size, in-place behaviour, an exception in a worker thread reaching the caller, anddefault_workers. I kept tests out of #781 and #783 as you preferred there; here I would rather hand them over, since they are what backs the "same output" claim, but say the word and I will drop the file if you would rather keep it separate. The tests ofPython/teststhat run without a GPU give the same result with and without the change.Not touched
For a detector width that is a power of two,
nextpow2(2 * n)returns one more than MATLAB'snextpow2, sofilt_lenis 4 times the width wherefiltering.muses 2 times (2048 against 1024 for 512 columns). Using MATLAB's length would halve the FFT work again, but it changes the output slightly (2.5e-7 relative with ram_lak, hamming and hann, up to 5e-5 with shepp_logan and cosine, on random data), so I left it for a separate decision.AI Usage
Python/tigre/utilities/filtering.py, the harness that compares it with master, the benchmark and the test file (not part of the PR), and drafted this description.Found by a pipeline I build and run (Dev-next-gen), using Claude Code with Anthropic's Claude Fable 5.1.
Nothing this pipeline produces is submitted without human approval. The whole pull request — the change, the proof and this description — was read and approved by a human (lu et approuvé) before submission.
(@Dev-next-gen)