matchedfilter 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. matchedfilter-0.1.0a1/LICENSE +21 -0
  2. matchedfilter-0.1.0a1/MANIFEST.in +11 -0
  3. matchedfilter-0.1.0a1/PKG-INFO +171 -0
  4. matchedfilter-0.1.0a1/README.md +147 -0
  5. matchedfilter-0.1.0a1/docs/design.md +72 -0
  6. matchedfilter-0.1.0a1/docs/hierarchical.md +284 -0
  7. matchedfilter-0.1.0a1/docs/machine-notes.md +881 -0
  8. matchedfilter-0.1.0a1/pyproject.toml +67 -0
  9. matchedfilter-0.1.0a1/python/matchedfilter/__init__.py +329 -0
  10. matchedfilter-0.1.0a1/python/matchedfilter/_core.c +268 -0
  11. matchedfilter-0.1.0a1/python/matchedfilter/benchmark.py +228 -0
  12. matchedfilter-0.1.0a1/python/matchedfilter/matchedfilter.h +165 -0
  13. matchedfilter-0.1.0a1/python/matchedfilter.egg-info/PKG-INFO +171 -0
  14. matchedfilter-0.1.0a1/python/matchedfilter.egg-info/SOURCES.txt +37 -0
  15. matchedfilter-0.1.0a1/python/matchedfilter.egg-info/dependency_links.txt +1 -0
  16. matchedfilter-0.1.0a1/python/matchedfilter.egg-info/requires.txt +7 -0
  17. matchedfilter-0.1.0a1/python/matchedfilter.egg-info/top_level.txt +1 -0
  18. matchedfilter-0.1.0a1/setup.cfg +4 -0
  19. matchedfilter-0.1.0a1/setup.py +74 -0
  20. matchedfilter-0.1.0a1/src/backend.h +36 -0
  21. matchedfilter-0.1.0a1/src/balanced.c +781 -0
  22. matchedfilter-0.1.0a1/src/be_avx512.c +151 -0
  23. matchedfilter-0.1.0a1/src/codelets.h +10992 -0
  24. matchedfilter-0.1.0a1/src/dispatch.c +153 -0
  25. matchedfilter-0.1.0a1/src/elemfft.h +156 -0
  26. matchedfilter-0.1.0a1/src/gen.py +351 -0
  27. matchedfilter-0.1.0a1/src/hmf.c +678 -0
  28. matchedfilter-0.1.0a1/src/hmf_table.h +364 -0
  29. matchedfilter-0.1.0a1/src/internal.h +60 -0
  30. matchedfilter-0.1.0a1/src/kernel1024.c +355 -0
  31. matchedfilter-0.1.0a1/src/matchfilt.c +210 -0
  32. matchedfilter-0.1.0a1/src/simd.h +215 -0
  33. matchedfilter-0.1.0a1/src/transform.h +49 -0
  34. matchedfilter-0.1.0a1/src/transpose16.h +27 -0
  35. matchedfilter-0.1.0a1/tests/test_api.py +523 -0
  36. matchedfilter-0.1.0a1/tests/test_internals.py +66 -0
  37. matchedfilter-0.1.0a1/tests/test_units.c +325 -0
  38. matchedfilter-0.1.0a1/tests/testutil.h +35 -0
  39. matchedfilter-0.1.0a1/tools/hmf_design.py +474 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Nitz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ # setuptools only guesses at Python files, so every C source the build needs
2
+ # has to be named here. Without this the sdist builds fine and then fails to
3
+ # install: the extension sources are simply absent.
4
+ include README.md LICENSE
5
+ recursive-include src *.c *.h *.py
6
+ recursive-include python *.c *.h
7
+ recursive-include tests *.c *.h *.py
8
+ recursive-include tools *.py
9
+ recursive-include docs *.md
10
+ prune build
11
+ global-exclude *.pyc *.so *.o __pycache__
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: matchedfilter
3
+ Version: 0.1.0a1
4
+ Summary: Fast single-threaded batched matched filter with peak-only output (x86-64)
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/ahnitz/matchedfilter
7
+ Keywords: matched-filter,correlation,fft,avx512,signal-processing
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: C
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.20
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Provides-Extra: bench
22
+ Requires-Dist: numpy>=1.20; extra == "bench"
23
+ Dynamic: license-file
24
+
25
+ # matchedfilter
26
+
27
+ A fast single-threaded matched filter for x86. You give it a batch of data
28
+ segments and a batch of templates; it correlates every pair and hands back only
29
+ the peaks.
30
+
31
+ Not returning the full correlation is the point. Most searches threshold the
32
+ output and throw the rest away, and once you say so up front the filter can
33
+ skip work that could not have produced a peak anyway.
34
+
35
+ > **Status: work in progress.** The API still moves, and there is a known
36
+ > calibration weakness in the hierarchical filter. See
37
+ > [Caveats](#caveats).
38
+
39
+ ```python
40
+ import matchedfilter as mf
41
+
42
+ filt = mf.MatchedFilter(16384, ndata=16, ntemplates=64)
43
+ filt.set_data(data_spectra) # (16, 16384) complex64, already FFT'd
44
+ filt.set_templates(template_spectra) # (64, 16384) complex64
45
+
46
+ peaks = filt.run(binsize=1024, threshold=5.5)
47
+ peaks["index"], peaks["value"], peaks["magnitude"]
48
+ ```
49
+
50
+ `import matchedfilter as mf` is the convention used throughout these docs.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install --pre matchedfilter
56
+ ```
57
+
58
+ `--pre` because this is an alpha release. Wheels are built for CPython 3.9 to
59
+ 3.13 on manylinux x86-64; anywhere else pip falls back to the source
60
+ distribution, which needs numpy and a C compiler.
61
+
62
+ **x86-64 only for now.** The kernels are AVX2 and AVX-512 intrinsics with no
63
+ portable fallback, so a build on any other architecture stops with an error
64
+ rather than producing a slow one. AVX-512 is used when the CPU has it and AVX2
65
+ otherwise, decided at runtime.
66
+
67
+ ## How it works
68
+
69
+ Inputs are **frequency domain**: the unnormalised forward transform of each
70
+ segment, in natural order. Produce them with whatever you already use (numpy,
71
+ MKL, FFTW); matchedfilter does not need to own that step.
72
+
73
+ The filter is built once and reused. Ingest conjugates the templates and
74
+ stores both sides in the layout the correlation loop walks, which costs a few
75
+ percent of a run and less as the batch grows.
76
+
77
+ `run` returns a structured array of shape `(ndata, ntemplates, nbins)` with
78
+ fields `index`, `value` and `magnitude`. Bins whose peak fell below the
79
+ threshold carry `index == -1`.
80
+
81
+ Supported lengths are 1024 and the powers of two from 4096 to 1048576.
82
+
83
+ ### Performance
84
+
85
+ Per (data, template) pair, 8x32 batch, one core of a Zen 5 desktop:
86
+
87
+ | n | matchedfilter | numpy | |
88
+ |---:|---:|---:|---:|
89
+ | 1024 | 0.61 µs | 18.98 µs | 31x |
90
+ | 4096 | 2.08 µs | 37.81 µs | 18x |
91
+ | 16384 | 9.98 µs | 127.04 µs | 13x |
92
+ | 65536 | 48.87 µs | 568.56 µs | 12x |
93
+
94
+ numpy is a floor, not a rival. It is there so the comparison runs anywhere.
95
+ Against MKL or FFTW the margin is much smaller, and part of what is left comes
96
+ from computing peaks instead of a full correlation. Measure on your own box:
97
+
98
+ ```bash
99
+ python -m matchedfilter.benchmark
100
+ ```
101
+
102
+ ## Hierarchical filtering
103
+
104
+ `HierarchicalFilter` adds a cheap pre-pass: correlate against a low-frequency
105
+ slice of the template, and only run the full-length filter where that slice
106
+ leaves a peak plausible.
107
+
108
+ **This helps only under an assumption about your templates**: that enough of
109
+ the matched-filter output power sits in the low band that a narrow slice gives
110
+ a usable bound on the full result. For chirp-like templates whose power is
111
+ concentrated at low frequency that tends to hold. For templates whose power is
112
+ spread flat across the band, or concentrated high, the slice bounds nothing
113
+ useful, and the pre-pass is pure added cost. It is worth checking against your
114
+ own templates before relying on it.
115
+
116
+ ```python
117
+ hf = mf.HierarchicalFilter(16384, ndata=16, ntemplates=64,
118
+ snr=6.0, # threshold you intend to use
119
+ fd=1e-3, # false-dismissal budget
120
+ band=2048) # width of the cheap slice
121
+
122
+ hf.set_reference(expected_output_power) # real frequency series, the OUTPUT
123
+ hf.set_templates(template_spectra)
124
+ hf.set_data(data_spectra)
125
+ peaks = hf.run(binsize=16384, threshold=6.0)
126
+ ```
127
+
128
+ `set_reference` takes a real frequency series of length `n` holding the expected
129
+ power of the filter **output** in each bin. Only its shape is used; the overall
130
+ normalisation is divided out.
131
+
132
+ This is the output, not the template. The two differ whenever the data is
133
+ coloured, and passing the template's own power will mis-set the gate: a
134
+ broadband template reconstructing a narrowband signal is the case where it goes
135
+ wrong by the largest factor.
136
+
137
+ The gate is one-sided by construction: peaks it reports are bit-identical to
138
+ the flat filter's. It can only omit, never invent. `fd` is the budget for how
139
+ often it is allowed to omit one.
140
+
141
+ On pure noise at n=4096 the gate runs about **7x faster** than the flat filter.
142
+ The saving scales with how little survives, so it grows with your threshold and
143
+ falls toward 1x on data where most pairs trigger.
144
+
145
+ ## Caveats
146
+
147
+ - **The false-dismissal budget is not currently met at low thresholds.**
148
+ `fd` is honoured well at snr 6 and above. At snr 5.0 to 5.5 with a coarse
149
+ band the gate omits more than it should: 1.4% against a 0.1% budget in a
150
+ 418-template search. The cause is that the gate's recovery factors are
151
+ measured from a mean frequency series, which is not a bound on any
152
+ individual realisation. Tracked by an `xfail` test in `tests/test_api.py` and written up
153
+ in [docs/hierarchical.md](docs/hierarchical.md).
154
+ - Single-threaded by design. Parallelism is the caller's to arrange.
155
+ - x86-64 Linux only, as above. Other architectures are not implemented rather
156
+ than merely untested.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ pip install -e .[test]
162
+ pytest # includes a C test that builds itself from source
163
+ python -m matchedfilter.benchmark
164
+ ```
165
+
166
+ [docs/](docs/) holds the design notes: how the hierarchical gate is
167
+ calibrated, and measurements of the approaches that were tried and rejected.
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,147 @@
1
+ # matchedfilter
2
+
3
+ A fast single-threaded matched filter for x86. You give it a batch of data
4
+ segments and a batch of templates; it correlates every pair and hands back only
5
+ the peaks.
6
+
7
+ Not returning the full correlation is the point. Most searches threshold the
8
+ output and throw the rest away, and once you say so up front the filter can
9
+ skip work that could not have produced a peak anyway.
10
+
11
+ > **Status: work in progress.** The API still moves, and there is a known
12
+ > calibration weakness in the hierarchical filter. See
13
+ > [Caveats](#caveats).
14
+
15
+ ```python
16
+ import matchedfilter as mf
17
+
18
+ filt = mf.MatchedFilter(16384, ndata=16, ntemplates=64)
19
+ filt.set_data(data_spectra) # (16, 16384) complex64, already FFT'd
20
+ filt.set_templates(template_spectra) # (64, 16384) complex64
21
+
22
+ peaks = filt.run(binsize=1024, threshold=5.5)
23
+ peaks["index"], peaks["value"], peaks["magnitude"]
24
+ ```
25
+
26
+ `import matchedfilter as mf` is the convention used throughout these docs.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install --pre matchedfilter
32
+ ```
33
+
34
+ `--pre` because this is an alpha release. Wheels are built for CPython 3.9 to
35
+ 3.13 on manylinux x86-64; anywhere else pip falls back to the source
36
+ distribution, which needs numpy and a C compiler.
37
+
38
+ **x86-64 only for now.** The kernels are AVX2 and AVX-512 intrinsics with no
39
+ portable fallback, so a build on any other architecture stops with an error
40
+ rather than producing a slow one. AVX-512 is used when the CPU has it and AVX2
41
+ otherwise, decided at runtime.
42
+
43
+ ## How it works
44
+
45
+ Inputs are **frequency domain**: the unnormalised forward transform of each
46
+ segment, in natural order. Produce them with whatever you already use (numpy,
47
+ MKL, FFTW); matchedfilter does not need to own that step.
48
+
49
+ The filter is built once and reused. Ingest conjugates the templates and
50
+ stores both sides in the layout the correlation loop walks, which costs a few
51
+ percent of a run and less as the batch grows.
52
+
53
+ `run` returns a structured array of shape `(ndata, ntemplates, nbins)` with
54
+ fields `index`, `value` and `magnitude`. Bins whose peak fell below the
55
+ threshold carry `index == -1`.
56
+
57
+ Supported lengths are 1024 and the powers of two from 4096 to 1048576.
58
+
59
+ ### Performance
60
+
61
+ Per (data, template) pair, 8x32 batch, one core of a Zen 5 desktop:
62
+
63
+ | n | matchedfilter | numpy | |
64
+ |---:|---:|---:|---:|
65
+ | 1024 | 0.61 µs | 18.98 µs | 31x |
66
+ | 4096 | 2.08 µs | 37.81 µs | 18x |
67
+ | 16384 | 9.98 µs | 127.04 µs | 13x |
68
+ | 65536 | 48.87 µs | 568.56 µs | 12x |
69
+
70
+ numpy is a floor, not a rival. It is there so the comparison runs anywhere.
71
+ Against MKL or FFTW the margin is much smaller, and part of what is left comes
72
+ from computing peaks instead of a full correlation. Measure on your own box:
73
+
74
+ ```bash
75
+ python -m matchedfilter.benchmark
76
+ ```
77
+
78
+ ## Hierarchical filtering
79
+
80
+ `HierarchicalFilter` adds a cheap pre-pass: correlate against a low-frequency
81
+ slice of the template, and only run the full-length filter where that slice
82
+ leaves a peak plausible.
83
+
84
+ **This helps only under an assumption about your templates**: that enough of
85
+ the matched-filter output power sits in the low band that a narrow slice gives
86
+ a usable bound on the full result. For chirp-like templates whose power is
87
+ concentrated at low frequency that tends to hold. For templates whose power is
88
+ spread flat across the band, or concentrated high, the slice bounds nothing
89
+ useful, and the pre-pass is pure added cost. It is worth checking against your
90
+ own templates before relying on it.
91
+
92
+ ```python
93
+ hf = mf.HierarchicalFilter(16384, ndata=16, ntemplates=64,
94
+ snr=6.0, # threshold you intend to use
95
+ fd=1e-3, # false-dismissal budget
96
+ band=2048) # width of the cheap slice
97
+
98
+ hf.set_reference(expected_output_power) # real frequency series, the OUTPUT
99
+ hf.set_templates(template_spectra)
100
+ hf.set_data(data_spectra)
101
+ peaks = hf.run(binsize=16384, threshold=6.0)
102
+ ```
103
+
104
+ `set_reference` takes a real frequency series of length `n` holding the expected
105
+ power of the filter **output** in each bin. Only its shape is used; the overall
106
+ normalisation is divided out.
107
+
108
+ This is the output, not the template. The two differ whenever the data is
109
+ coloured, and passing the template's own power will mis-set the gate: a
110
+ broadband template reconstructing a narrowband signal is the case where it goes
111
+ wrong by the largest factor.
112
+
113
+ The gate is one-sided by construction: peaks it reports are bit-identical to
114
+ the flat filter's. It can only omit, never invent. `fd` is the budget for how
115
+ often it is allowed to omit one.
116
+
117
+ On pure noise at n=4096 the gate runs about **7x faster** than the flat filter.
118
+ The saving scales with how little survives, so it grows with your threshold and
119
+ falls toward 1x on data where most pairs trigger.
120
+
121
+ ## Caveats
122
+
123
+ - **The false-dismissal budget is not currently met at low thresholds.**
124
+ `fd` is honoured well at snr 6 and above. At snr 5.0 to 5.5 with a coarse
125
+ band the gate omits more than it should: 1.4% against a 0.1% budget in a
126
+ 418-template search. The cause is that the gate's recovery factors are
127
+ measured from a mean frequency series, which is not a bound on any
128
+ individual realisation. Tracked by an `xfail` test in `tests/test_api.py` and written up
129
+ in [docs/hierarchical.md](docs/hierarchical.md).
130
+ - Single-threaded by design. Parallelism is the caller's to arrange.
131
+ - x86-64 Linux only, as above. Other architectures are not implemented rather
132
+ than merely untested.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ pip install -e .[test]
138
+ pytest # includes a C test that builds itself from source
139
+ python -m matchedfilter.benchmark
140
+ ```
141
+
142
+ [docs/](docs/) holds the design notes: how the hierarchical gate is
143
+ calibrated, and measurements of the approaches that were tried and rejected.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,72 @@
1
+ # Batched matched filter: design
2
+
3
+ D data segments and T template segments arrive as spectra: complex vectors of
4
+ length N, the unnormalised forward transform of each segment. For every pair the
5
+ output is
6
+
7
+ z_dt[k] = IFFT( D_d[f] * conj(H_t[f]) )[k]
8
+
9
+ reported as a binned maximum over a search window, with a detection floor.
10
+ D and T are arbitrary.
11
+
12
+ ## Where the time goes
13
+
14
+ Forward transforms number D+T. Pair work is D*T. At D=T=16 the pair loop is
15
+ ~89% of the work before any optimisation, so every design decision is made about
16
+ the pair loop.
17
+
18
+ Per pair, done naively:
19
+
20
+ read D_d N complex
21
+ read H_t N complex
22
+ write product P N complex
23
+ read P N complex <- IFFT stage A
24
+ write intermediate N complex
25
+ read intermediate N complex
26
+
27
+ At 2^20 that is 48 MiB per pair, and 256 pairs is 12 GiB.
28
+
29
+ ## What reuse buys
30
+
31
+ 1. **Fuse the product into the IFFT's stage-A load.** The product never has to
32
+ exist in memory: stage A reads `D_d` and `H_t` and multiplies on the way in.
33
+ Removes 2 of the 6 passes, 16 MiB of 48 per pair.
34
+
35
+ 2. **Pre-permute the stored spectra into stage-A order.** Stage A reads
36
+ `x[n2*N1 + n1]` walking n2, i.e. with stride N1, measured at 7.6 GB/s against
37
+ 43.9 sequential on this core. Storing the spectra as `X'[n1*N2 + n2]` makes
38
+ that read contiguous. The permutation costs one transpose per segment (D+T of
39
+ them) instead of a strided read per pair (D*T of them): at D=T=16, 32
40
+ transposes to avoid 256 strided passes.
41
+
42
+ 3. **Cache-block the (d,t) loop.** The matmul argument: a tile of nd×nt pairs
43
+ loads nd+nt spectra and does nd*nt work, so traffic falls by roughly the
44
+ harmonic mean where the spectra fit.
45
+
46
+ 4. **Conjugate templates once at ingest**, not per pair.
47
+
48
+ ## What does not work, and why
49
+
50
+ - **Pushing butterflies across the product.** The product is elementwise in
51
+ frequency and butterflies mix frequencies, so no part of the IFFT can be
52
+ pre-applied to `D` or `H` separately. Only the *permutation* commutes with an
53
+ elementwise product, which is why (2) works and a pre-butterflied store does
54
+ not.
55
+ - **Sharing IFFT work between pairs.** Different pairs have different inputs;
56
+ there is no common subexpression beyond what (1) to (3) already capture.
57
+
58
+ ## Why the input is frequency domain
59
+
60
+ Taking time-domain segments would let the ingest rearrangement fuse into a
61
+ forward transform the library performed itself, making it free. Measured, that
62
+ rearrangement is only 1.9 to 4.5% of total, and shrinks as T grows:
63
+
64
+ | N | D × T | ingest | pair loop | share |
65
+ |---|---|---:|---:|---:|
66
+ | 2^12 | 16×16 | 26.7 µs | 652 µs | 3.9% |
67
+ | 2^12 | 16×256 | 228 µs | 11566 µs | 1.9% |
68
+ | 2^14 | 16×16 | 104 µs | 2940 µs | 3.4% |
69
+ | 2^16 | 16×64 | 2470 µs | 55566 µs | 4.3% |
70
+
71
+ Owning the forward transform is not worth that, and the caller's pipeline
72
+ generally has the spectra already.