Skip to content

Commit 9d84adf

Browse files
authored
perf(fft): cache FFT instances (#377)
* perf(reimFFT): cap FFT instance cache at 10 entries Bound the per-size FFT cache so transforming many different sizes cannot grow it without limit: once it reaches 10 entries it is cleared completely rather than tracking insertion order, since distinct sizes are rare and the common single-size workload never hits the cap. Rework the benchmark to seed input deterministically with ml-xsadd and add an explicit before/after comparison for the 64k FFT, confirming the cached instance (~1.16 ms) is ~2.3x faster than rebuilding it per call (~2.72 ms). * refactor: share a single FFT instance cache across all FFT functions Extract the per-size FFT instance cache into a shared module (utils/fftCache.ts) backed by an encapsulated singleton, and route every fft.js call site through it: reimFFT, reimArrayFFT, reimMatrixFFT, reimMatrixFFTByColumns, matrixHilbertTransform and xHilbertTransform. Previously only reimFFT cached its instance and the others rebuilt the size-dependent lookup tables on every call; now `new FFT(size)` happens in exactly one place and all functions reuse the cached instances. Add public `clearFFTCache()` to release the cached instances and `setFFTCacheMaxSize()` to tune the bound (default 10, clear-when-full). Keep `getFFT` internal so fft.js does not leak into the public API. Add a deterministic xHilbertTransform benchmark (≈1.88x faster, output bit-identical) alongside the existing reimFFT one.
1 parent c5468e2 commit 9d84adf

12 files changed

Lines changed: 311 additions & 86 deletions

File tree

benchmark/reimFFT.ts

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,95 @@
11
/* eslint-disable no-console */
2+
import FFT from 'fft.js';
3+
import { XSadd } from 'ml-xsadd';
4+
25
import { reimFFT } from '../src/reim/reimFFT.ts';
36
import { reimArrayFFT } from '../src/reimArray/reimArrayFFT.ts';
7+
import type { DataReIm } from '../src/types/index.ts';
48

5-
const size = 2 ** 16;
6-
const count = 10; // number of spectra in the array benchmark
9+
const size = 2 ** 16; // 64k-point transform: FFT setup dominates the cost
10+
const count = 10; // number of spectra processed per round
11+
const targetMs = 5000;
712

8-
// Build input data
13+
// Deterministic, reproducible input so every section runs on identical data.
14+
const { random } = new XSadd(42);
915
const spectra = Array.from({ length: count }, () => {
1016
const re = new Float64Array(size);
1117
const im = new Float64Array(size);
1218
for (let i = 0; i < size; i++) {
13-
re[i] = Math.random();
14-
im[i] = Math.random();
19+
re[i] = random();
20+
im[i] = random();
1521
}
1622
return { re, im };
1723
});
1824

19-
// Warmup
20-
for (const s of spectra) reimFFT(s);
21-
for (const s of spectra) reimFFT(s, { inPlace: true });
22-
reimArrayFFT(spectra);
23-
reimArrayFFT(spectra, { inPlace: true });
24-
25-
const targetMs = 5000;
25+
/**
26+
* `reimFFT` as it was *before* the cache fix: a fresh `FFT` instance is built on
27+
* every call. Kept here as the baseline to confirm the cached version is faster.
28+
* @param data - complex spectrum.
29+
* @returns FFT of the complex spectrum.
30+
*/
31+
function reimFFTNoCache(data: DataReIm): DataReIm<Float64Array> {
32+
const { re, im } = data;
33+
const length = re.length;
34+
const csize = length << 1;
2635

27-
// --- reimFFT (loop over each spectrum individually) ---
28-
{
29-
let iterations = 0;
30-
const start = performance.now();
31-
console.time('reimFFT (loop)');
32-
while (performance.now() - start < targetMs) {
33-
for (const s of spectra) reimFFT(s);
34-
iterations++;
36+
const complexArray = new Float64Array(csize);
37+
for (let i = 0; i < csize; i += 2) {
38+
complexArray[i] = re[i >>> 1];
39+
complexArray[i + 1] = im[i >>> 1];
3540
}
36-
const elapsed = performance.now() - start;
37-
console.timeEnd('reimFFT (loop)');
38-
console.log(
39-
` ${iterations * count} total FFTs, ${count} spectra × ${iterations} rounds`,
40-
);
41-
console.log(` ${(elapsed / (iterations * count)).toFixed(3)} ms per FFT`);
42-
}
4341

44-
console.log('');
42+
const fft = new FFT(length);
43+
const output = new Float64Array(csize);
44+
fft.transform(output, complexArray);
4545

46-
// --- reimFFT inPlace (loop over each spectrum individually) ---
47-
{
48-
let iterations = 0;
49-
const start = performance.now();
50-
console.time('reimFFT inPlace (loop)');
51-
while (performance.now() - start < targetMs) {
52-
for (const s of spectra) reimFFT(s, { inPlace: true });
53-
iterations++;
46+
const newRe = new Float64Array(length);
47+
const newIm = new Float64Array(length);
48+
for (let i = 0; i < csize; i += 2) {
49+
newRe[i >>> 1] = output[i];
50+
newIm[i >>> 1] = output[i + 1];
5451
}
55-
const elapsed = performance.now() - start;
56-
console.timeEnd('reimFFT inPlace (loop)');
57-
console.log(
58-
` ${iterations * count} total FFTs, ${count} spectra × ${iterations} rounds`,
59-
);
60-
console.log(` ${(elapsed / (iterations * count)).toFixed(3)} ms per FFT`);
52+
return { re: newRe, im: newIm };
6153
}
6254

63-
console.log('');
64-
65-
// --- reimArrayFFT (single call for the whole array) ---
66-
{
67-
let iterations = 0;
55+
/**
56+
* Run `task` repeatedly for `targetMs` and report the time per FFT. Each round
57+
* performs `count` transforms.
58+
* @param label - section name.
59+
* @param task - one round of work (transforms all `count` spectra).
60+
*/
61+
function bench(label: string, task: () => void): void {
62+
task(); // warmup
63+
let rounds = 0;
6864
const start = performance.now();
69-
console.time('reimArrayFFT');
7065
while (performance.now() - start < targetMs) {
71-
reimArrayFFT(spectra);
72-
iterations++;
66+
task();
67+
rounds++;
7368
}
7469
const elapsed = performance.now() - start;
75-
console.timeEnd('reimArrayFFT');
70+
const totalFFTs = rounds * count;
71+
console.log(label);
7672
console.log(
77-
` ${iterations * count} total FFTs, ${count} spectra × ${iterations} rounds`,
73+
` ${(elapsed / totalFFTs).toFixed(3)} ms per FFT (${totalFFTs} FFTs over ${rounds} rounds)`,
7874
);
79-
console.log(` ${(elapsed / (iterations * count)).toFixed(3)} ms per FFT`);
75+
console.log('');
8076
}
8177

78+
console.log(`FFT size: ${size} (2^16), ${count} spectra per round`);
8279
console.log('');
8380

84-
// --- reimArrayFFT inPlace (single call for the whole array) ---
85-
{
86-
let iterations = 0;
87-
const start = performance.now();
88-
console.time('reimArrayFFT inPlace');
89-
while (performance.now() - start < targetMs) {
90-
reimArrayFFT(spectra, { inPlace: true });
91-
iterations++;
92-
}
93-
const elapsed = performance.now() - start;
94-
console.timeEnd('reimArrayFFT inPlace');
95-
console.log(
96-
` ${iterations * count} total FFTs, ${count} spectra × ${iterations} rounds`,
97-
);
98-
console.log(` ${(elapsed / (iterations * count)).toFixed(3)} ms per FFT`);
99-
}
81+
// Before the fix: new FFT instance per call.
82+
bench('reimFFT — before fix (new FFT per call)', () => {
83+
for (const spectrum of spectra) reimFFTNoCache(spectrum);
84+
});
85+
86+
// After the fix: FFT instance cached per size and reused across calls.
87+
bench('reimFFT — after fix (cached FFT instance)', () => {
88+
for (const spectrum of spectra) reimFFT(spectrum);
89+
});
90+
91+
// reimArrayFFT reuses a single FFT instance (and working buffers) for the whole
92+
// array in one call.
93+
bench('reimArrayFFT (single shared FFT instance)', () => {
94+
reimArrayFFT(spectra);
95+
});

benchmark/xHilbertTransform.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/* eslint-disable no-console */
2+
import FFT from 'fft.js';
3+
import { XSadd } from 'ml-xsadd';
4+
5+
import { xHilbertTransform } from '../src/x/xHilbertTransform.ts';
6+
7+
const size = 2 ** 16; // power of two => the FFT path (hilbertTransformWithFFT)
8+
const count = 10; // signals processed per round
9+
const targetMs = 5000;
10+
11+
// Deterministic, reproducible input.
12+
const { random } = new XSadd(42);
13+
const signals = Array.from({ length: count }, () => {
14+
const array = new Float64Array(size);
15+
for (let i = 0; i < size; i++) array[i] = random() * 2 - 1;
16+
return array;
17+
});
18+
19+
/**
20+
* Hilbert transform via FFT as it was *before* the shared cache: a fresh `FFT`
21+
* instance is built on every call. Mirrors `hilbertTransformWithFFT`, used as
22+
* the baseline to confirm the cached version is faster.
23+
* @param array - real input signal whose length is a power of two.
24+
* @returns the Hilbert transform (90° phase-shifted signal).
25+
*/
26+
function hilbertNoCache(array: Float64Array): Float64Array {
27+
const length = array.length;
28+
const fft = new FFT(length);
29+
30+
const spectrum = new Float64Array(length * 2);
31+
fft.realTransform(spectrum, array);
32+
fft.completeSpectrum(spectrum);
33+
34+
const half = length >> 1;
35+
const nyquist = half << 1;
36+
spectrum[nyquist] = 0;
37+
spectrum[nyquist + 1] = 0;
38+
for (let j = (half + 1) << 1; j < spectrum.length; j += 2) {
39+
spectrum[j] = -spectrum[j];
40+
spectrum[j + 1] = -spectrum[j + 1];
41+
}
42+
43+
const hilbertSignal = new Float64Array(length * 2);
44+
fft.inverseTransform(hilbertSignal, spectrum);
45+
46+
const result = new Float64Array(length);
47+
for (let i = 0; i < length; i++) result[i] = hilbertSignal[i * 2 + 1];
48+
return result;
49+
}
50+
51+
/**
52+
* Run `task` for `targetMs` and report the time per transform. Each round
53+
* processes `count` signals.
54+
* @param label - section name.
55+
* @param task - one round of work (transforms all `count` signals).
56+
*/
57+
function bench(label: string, task: () => void): void {
58+
task(); // warmup
59+
let rounds = 0;
60+
const start = performance.now();
61+
while (performance.now() - start < targetMs) {
62+
task();
63+
rounds++;
64+
}
65+
const elapsed = performance.now() - start;
66+
const total = rounds * count;
67+
console.log(label);
68+
console.log(
69+
` ${(elapsed / total).toFixed(3)} ms per transform (${total} transforms over ${rounds} rounds)`,
70+
);
71+
console.log('');
72+
}
73+
74+
// Sanity check: the cached path and the baseline must compute the same thing.
75+
const reference = hilbertNoCache(signals[0]);
76+
const cached = xHilbertTransform(signals[0]);
77+
let maxDiff = 0;
78+
for (let i = 0; i < reference.length; i++) {
79+
const diff = Math.abs(reference[i] - cached[i]);
80+
if (diff > maxDiff) maxDiff = diff;
81+
}
82+
83+
console.log(
84+
`xHilbertTransform: size ${size} (2^16), ${count} signals per round`,
85+
);
86+
console.log(`equivalence check: max abs diff ${maxDiff.toExponential(2)}\n`);
87+
88+
bench('before shared cache (new FFT per call)', () => {
89+
for (const signal of signals) hilbertNoCache(signal);
90+
});
91+
92+
bench('after shared cache (reused FFT instance)', () => {
93+
for (const signal of signals) xHilbertTransform(signal);
94+
});

src/__tests__/__snapshots__/index.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ exports[`existence of exported functions 1`] = `
181181
"matrixZRescale",
182182
"matrixZRescalePerColumn",
183183
"matrixTranspose",
184+
"clearFFTCache",
185+
"setFFTCacheMaxSize",
184186
"createNumberArray",
185187
"createDoubleArray",
186188
"createFromToArray",

src/matrix/matrixHilbertTransform.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import FFT from 'fft.js';
2-
1+
import { getFFT } from '../utils/fftCache.ts';
32
import { isPowerOfTwo } from '../utils/index.ts';
43

54
import { matrixCreateEmpty } from './matrixCreateEmpty.ts';
@@ -41,7 +40,7 @@ export function matrixHilbertTransform(
4140
}
4241

4342
// Single FFT instance reused across all rows
44-
const fft = new FFT(size);
43+
const fft = getFFT(size);
4544

4645
// Multiplier computed once — identical for every row of the same length
4746
const half = size >> 1;

src/reim/reimFFT.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import FFT from 'fft.js';
2-
31
import type { DataReIm } from '../types/index.ts';
2+
import { getFFT } from '../utils/fftCache.ts';
43

54
import { zeroShift } from './zeroShift.ts';
65

@@ -36,7 +35,7 @@ export function reimFFT(
3635
complexArray[i + 1] = im[i >>> 1];
3736
}
3837

39-
const fft = new FFT(size);
38+
const fft = getFFT(size);
4039
let output = new Float64Array(csize);
4140
if (inverse) {
4241
if (applyZeroShift) complexArray = zeroShift(complexArray, true);

src/reimArray/reimArrayFFT.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import FFT from 'fft.js';
2-
31
import { zeroShift } from '../reim/zeroShift.ts';
42
import type { DataReIm } from '../types/index.ts';
3+
import { getFFT } from '../utils/fftCache.ts';
54

65
export interface ReimArrayFFTOptions {
76
inverse?: boolean;
@@ -41,7 +40,7 @@ export function reimArrayFFT(
4140
}
4241

4342
// Single FFT instance and working buffers reused across all spectra
44-
const fft = new FFT(size);
43+
const fft = getFFT(size);
4544
const complexArray = new Float64Array(csize);
4645
const output = new Float64Array(csize);
4746

src/reimMatrix/reimMatrixFFT.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import FFT from 'fft.js';
2-
31
import { zeroShift } from '../reim/zeroShift.ts';
42
import type { DataReImMatrix } from '../types/index.ts';
3+
import { getFFT } from '../utils/fftCache.ts';
54

65
export interface ReimMatrixFFTOptions {
76
inverse?: boolean;
@@ -44,7 +43,7 @@ export function reimMatrixFFT(
4443
}
4544

4645
// Single FFT instance and working buffers reused across all rows
47-
const fft = new FFT(size);
46+
const fft = getFFT(size);
4847
const complexArray = new Float64Array(csize);
4948
const output = new Float64Array(csize);
5049

src/reimMatrix/reimMatrixFFTByColumns.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import FFT from 'fft.js';
2-
31
import { zeroShift } from '../reim/zeroShift.ts';
42
import type { DataReImMatrix } from '../types/index.ts';
3+
import { getFFT } from '../utils/fftCache.ts';
54

65
export interface ReimMatrixFFTByColumnsOptions {
76
inverse?: boolean;
@@ -49,7 +48,7 @@ export function reimMatrixFFTByColumns(
4948
}
5049

5150
// Single FFT instance and working buffers reused across all columns
52-
const fft = new FFT(numRows);
51+
const fft = getFFT(numRows);
5352
const complexArray = new Float64Array(csize);
5453
const output = new Float64Array(csize);
5554

0 commit comments

Comments
 (0)