Skip to content

Commit a01f3aa

Browse files
Reduce ctypes pointer conversion overhead
1 parent 3a8035a commit a01f3aa

2 files changed

Lines changed: 219 additions & 14 deletions

File tree

bfft/_core.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def _decl(name, restype, argtypes):
7272

7373
_dbl_p = ctypes.POINTER(ctypes.c_double)
7474
_cplx_p = ctypes.POINTER(_Complex)
75+
_void_p = ctypes.c_void_p
7576
_plan_p = ctypes.c_void_p
7677

7778
# --- standard real FFT (bfft.h) ---
@@ -83,16 +84,16 @@ def _decl(name, restype, argtypes):
8384
_bfft_plan_native_scratch_size = _decl(
8485
"bfft_plan_native_scratch_size", ctypes.c_size_t, [_plan_p])
8586
_bfft_forward = _decl("bfft_forward", ctypes.c_int,
86-
[_plan_p, _dbl_p, _cplx_p, _dbl_p, _cplx_p])
87-
_bfft_inverse = _decl("bfft_inverse", ctypes.c_int, [_plan_p, _cplx_p, _dbl_p])
87+
[_plan_p, _void_p, _void_p, _void_p, _void_p])
88+
_bfft_inverse = _decl("bfft_inverse", ctypes.c_int, [_plan_p, _void_p, _void_p])
8889

8990
# --- half-bin ODFT (bodft.h) ---
9091
_bodft_plan_create = _decl("bodft_plan_create", ctypes.c_int,
9192
[ctypes.c_size_t, ctypes.POINTER(_plan_p)])
9293
_bodft_plan_destroy = _decl("bodft_plan_destroy", None, [_plan_p])
9394
_bodft_plan_bins = _decl("bodft_plan_bins", ctypes.c_size_t, [_plan_p])
94-
_bodft_forward = _decl("bodft_forward", ctypes.c_int, [_plan_p, _dbl_p, _cplx_p])
95-
_bodft_inverse = _decl("bodft_inverse", ctypes.c_int, [_plan_p, _cplx_p, _dbl_p])
95+
_bodft_forward = _decl("bodft_forward", ctypes.c_int, [_plan_p, _void_p, _void_p])
96+
_bodft_inverse = _decl("bodft_inverse", ctypes.c_int, [_plan_p, _void_p, _void_p])
9697

9798
_OK = 0
9899

@@ -156,10 +157,10 @@ def rfft(x):
156157
scratch = np.empty(_bfft_plan_native_scratch_size(plan), dtype=np.complex128)
157158
_check(_bfft_forward(
158159
plan,
159-
a.ctypes.data_as(_dbl_p),
160-
out.ctypes.data_as(_cplx_p),
161-
work.ctypes.data_as(_dbl_p),
162-
scratch.ctypes.data_as(_cplx_p),
160+
a.ctypes.data,
161+
out.ctypes.data,
162+
work.ctypes.data,
163+
scratch.ctypes.data,
163164
), "bfft_forward")
164165
return out
165166

@@ -182,8 +183,8 @@ def irfft(x, n=None):
182183
out = np.empty(n, dtype=np.float64)
183184
_check(_bfft_inverse(
184185
plan,
185-
a.ctypes.data_as(_cplx_p),
186-
out.ctypes.data_as(_dbl_p),
186+
a.ctypes.data,
187+
out.ctypes.data,
187188
), "bfft_inverse")
188189
return out
189190

@@ -200,8 +201,8 @@ def odft(x):
200201
out = np.empty(bins, dtype=np.complex128)
201202
_check(_bodft_forward(
202203
plan,
203-
a.ctypes.data_as(_dbl_p),
204-
out.ctypes.data_as(_cplx_p),
204+
a.ctypes.data,
205+
out.ctypes.data,
205206
), "bodft_forward")
206207
return out
207208

@@ -222,7 +223,7 @@ def iodft(x, n=None):
222223
out = np.empty(n, dtype=np.float64)
223224
_check(_bodft_inverse(
224225
plan,
225-
a.ctypes.data_as(_cplx_p),
226-
out.ctypes.data_as(_dbl_p),
226+
a.ctypes.data,
227+
out.ctypes.data,
227228
), "bodft_inverse")
228229
return out
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env python3
2+
"""Measure Python ctypes pointer-conversion overhead for BFFT calls.
3+
4+
This benchmark intentionally avoids NumPy so it can run in restricted
5+
environments where Python wheels are unavailable. It compares the former
6+
typed-pointer calling convention against the current raw-address calling
7+
convention while executing the same native ``bfft_forward`` kernel.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import ctypes
14+
import statistics
15+
import time
16+
from pathlib import Path
17+
18+
19+
class Complex(ctypes.Structure):
20+
_fields_ = [("re", ctypes.c_double), ("im", ctypes.c_double)]
21+
22+
23+
DoublePointer = ctypes.POINTER(ctypes.c_double)
24+
ComplexPointer = ctypes.POINTER(Complex)
25+
PlanPointer = ctypes.c_void_p
26+
27+
28+
def configure_library(lib: ctypes.CDLL, use_raw_addresses: bool) -> None:
29+
lib.bfft_plan_create.restype = ctypes.c_int
30+
lib.bfft_plan_create.argtypes = [
31+
ctypes.c_size_t,
32+
ctypes.POINTER(PlanPointer),
33+
]
34+
lib.bfft_plan_bins.restype = ctypes.c_size_t
35+
lib.bfft_plan_bins.argtypes = [PlanPointer]
36+
lib.bfft_plan_work_size.restype = ctypes.c_size_t
37+
lib.bfft_plan_work_size.argtypes = [PlanPointer]
38+
lib.bfft_plan_native_scratch_size.restype = ctypes.c_size_t
39+
lib.bfft_plan_native_scratch_size.argtypes = [PlanPointer]
40+
lib.bfft_forward.restype = ctypes.c_int
41+
42+
if use_raw_addresses:
43+
lib.bfft_forward.argtypes = [
44+
PlanPointer,
45+
ctypes.c_void_p,
46+
ctypes.c_void_p,
47+
ctypes.c_void_p,
48+
ctypes.c_void_p,
49+
]
50+
else:
51+
lib.bfft_forward.argtypes = [
52+
PlanPointer,
53+
DoublePointer,
54+
ComplexPointer,
55+
DoublePointer,
56+
ComplexPointer,
57+
]
58+
59+
60+
def make_plan(lib: ctypes.CDLL, n: int) -> PlanPointer:
61+
plan = PlanPointer()
62+
status = lib.bfft_plan_create(n, ctypes.byref(plan))
63+
if status != 0:
64+
raise RuntimeError(f"bfft_plan_create failed with status {status}")
65+
return plan
66+
67+
68+
def median_call_time_us(run, iters: int, rounds: int) -> float:
69+
for _ in range(20):
70+
run()
71+
72+
samples = []
73+
for _ in range(rounds):
74+
t0 = time.perf_counter_ns()
75+
for _ in range(iters):
76+
run()
77+
elapsed = time.perf_counter_ns() - t0
78+
samples.append(elapsed / iters / 1000.0)
79+
80+
return statistics.median(samples)
81+
82+
83+
def bench_mode(lib_path: Path, n: int, mode: str, iters: int, rounds: int) -> float:
84+
use_raw_addresses = mode.endswith("_new")
85+
public_wrapper_style = mode.startswith("public_")
86+
87+
lib = ctypes.CDLL(str(lib_path))
88+
configure_library(lib, use_raw_addresses)
89+
90+
plan = make_plan(lib, n)
91+
bins = lib.bfft_plan_bins(plan)
92+
work_size = lib.bfft_plan_work_size(plan)
93+
scratch_size = lib.bfft_plan_native_scratch_size(plan)
94+
95+
input_buffer = (ctypes.c_double * n)()
96+
output_buffer = (Complex * bins)()
97+
work_buffer = (ctypes.c_double * work_size)()
98+
scratch_buffer = (Complex * scratch_size)()
99+
100+
for i in range(n):
101+
input_buffer[i] = (i * 17 % 251) / 251.0
102+
103+
if use_raw_addresses:
104+
input_arg = ctypes.addressof(input_buffer)
105+
output_arg = ctypes.addressof(output_buffer)
106+
work_arg = ctypes.addressof(work_buffer)
107+
scratch_arg = ctypes.addressof(scratch_buffer)
108+
109+
def run_public_style():
110+
return lib.bfft_forward(
111+
plan,
112+
ctypes.addressof(input_buffer),
113+
ctypes.addressof(output_buffer),
114+
ctypes.addressof(work_buffer),
115+
ctypes.addressof(scratch_buffer),
116+
)
117+
118+
def run_low_level_style():
119+
return lib.bfft_forward(
120+
plan,
121+
input_arg,
122+
output_arg,
123+
work_arg,
124+
scratch_arg,
125+
)
126+
else:
127+
output_arg = ctypes.cast(output_buffer, ComplexPointer)
128+
work_arg = ctypes.cast(work_buffer, DoublePointer)
129+
scratch_arg = ctypes.cast(scratch_buffer, ComplexPointer)
130+
131+
def run_public_style():
132+
return lib.bfft_forward(
133+
plan,
134+
ctypes.cast(input_buffer, DoublePointer),
135+
ctypes.cast(output_buffer, ComplexPointer),
136+
ctypes.cast(work_buffer, DoublePointer),
137+
ctypes.cast(scratch_buffer, ComplexPointer),
138+
)
139+
140+
def run_low_level_style():
141+
return lib.bfft_forward(
142+
plan,
143+
ctypes.cast(input_buffer, DoublePointer),
144+
output_arg,
145+
work_arg,
146+
scratch_arg,
147+
)
148+
149+
run = run_public_style
150+
if not public_wrapper_style:
151+
run = run_low_level_style
152+
153+
return median_call_time_us(run, iters, rounds)
154+
155+
156+
def default_iters(n: int) -> int:
157+
if n <= 1024:
158+
return 10000
159+
return 4000
160+
161+
162+
def main() -> None:
163+
parser = argparse.ArgumentParser(description=__doc__)
164+
parser.add_argument(
165+
"--lib",
166+
default="build/libbfft.so",
167+
help="Path to the BFFT shared library.",
168+
)
169+
parser.add_argument(
170+
"--rounds",
171+
type=int,
172+
default=7,
173+
help="Measurement rounds per mode.",
174+
)
175+
parser.add_argument(
176+
"sizes",
177+
nargs="*",
178+
type=int,
179+
default=[512, 1024, 4096],
180+
help="FFT sizes to measure.",
181+
)
182+
args = parser.parse_args()
183+
184+
lib_path = Path(args.lib).resolve()
185+
for n in args.sizes:
186+
iters = default_iters(n)
187+
old_public = bench_mode(lib_path, n, "public_old", iters, args.rounds)
188+
new_public = bench_mode(lib_path, n, "public_new", iters, args.rounds)
189+
old_low = bench_mode(lib_path, n, "low_old", iters, args.rounds)
190+
new_low = bench_mode(lib_path, n, "low_new", iters, args.rounds)
191+
192+
print(
193+
f"N={n:5d} "
194+
f"public_old={old_public:8.3f} "
195+
f"public_new={new_public:8.3f} "
196+
f"speedup={old_public / new_public:5.2f}x "
197+
f"low_old={old_low:8.3f} "
198+
f"low_new={new_low:8.3f} "
199+
f"speedup={old_low / new_low:5.2f}x us"
200+
)
201+
202+
203+
if __name__ == "__main__":
204+
main()

0 commit comments

Comments
 (0)