Batch ABI benchmark¶
propaq_noise_factor_batch / propaq_truncator_keep_batch are optional.
When a plugin exports them, propaq calls one batch entry point per parallel
chunk of terms instead of calling the scalar entry point once per term,
amortizing the FFI boundary cost across the chunk.
This notebook measures the performance difference between batch and scalar entry points.
Building scalar-only vs scalar+batch variants¶
In [ ]:
Copied!
import subprocess
from pathlib import Path
BUILD_DIR = Path("_build").resolve()
BUILD_DIR.mkdir(exist_ok=True)
SCALAR_PLUS_BATCH_SRC = Path("../c/truncation/pareto_truncator.c").resolve().read_text()
cut = SCALAR_PLUS_BATCH_SRC.index("int32_t propaq_truncator_keep_batch")
SCALAR_ONLY_SRC = SCALAR_PLUS_BATCH_SRC[:cut]
(BUILD_DIR / "pareto_scalar_only.c").write_text(SCALAR_ONLY_SRC)
(BUILD_DIR / "pareto_scalar_batch.c").write_text(SCALAR_PLUS_BATCH_SRC)
for name in ["pareto_scalar_only", "pareto_scalar_batch"]:
subprocess.run(
["gcc", "-shared", "-fPIC", "-O2", "-o", str(BUILD_DIR / f"{name}.so"), str(BUILD_DIR / f"{name}.c"), "-lm"],
check=True,
)
nm_only = subprocess.run(["nm", "-D", str(BUILD_DIR / "pareto_scalar_only.so")], capture_output=True, text=True).stdout
nm_batch = subprocess.run(["nm", "-D", str(BUILD_DIR / "pareto_scalar_batch.so")], capture_output=True, text=True).stdout
print("scalar-only exports keep_batch:", "keep_batch" in nm_only)
print("scalar+batch exports keep_batch:", "keep_batch" in nm_batch)
import subprocess
from pathlib import Path
BUILD_DIR = Path("_build").resolve()
BUILD_DIR.mkdir(exist_ok=True)
SCALAR_PLUS_BATCH_SRC = Path("../c/truncation/pareto_truncator.c").resolve().read_text()
cut = SCALAR_PLUS_BATCH_SRC.index("int32_t propaq_truncator_keep_batch")
SCALAR_ONLY_SRC = SCALAR_PLUS_BATCH_SRC[:cut]
(BUILD_DIR / "pareto_scalar_only.c").write_text(SCALAR_ONLY_SRC)
(BUILD_DIR / "pareto_scalar_batch.c").write_text(SCALAR_PLUS_BATCH_SRC)
for name in ["pareto_scalar_only", "pareto_scalar_batch"]:
subprocess.run(
["gcc", "-shared", "-fPIC", "-O2", "-o", str(BUILD_DIR / f"{name}.so"), str(BUILD_DIR / f"{name}.c"), "-lm"],
check=True,
)
nm_only = subprocess.run(["nm", "-D", str(BUILD_DIR / "pareto_scalar_only.so")], capture_output=True, text=True).stdout
nm_batch = subprocess.run(["nm", "-D", str(BUILD_DIR / "pareto_scalar_batch.so")], capture_output=True, text=True).stdout
print("scalar-only exports keep_batch:", "keep_batch" in nm_only)
print("scalar+batch exports keep_batch:", "keep_batch" in nm_batch)
scalar-only exports keep_batch: False scalar+batch exports keep_batch: True
Isolated raw-FFI benchmark¶
We'll call propaq_truncator_keep in a
plain Python loop N times, versus calling propaq_truncator_keep_batch
once on the same N elements, via ctypes directly.
In [6]:
Copied!
import ctypes
import random
import time
BASIS_PAULI = 0
NULL_WORDS = ctypes.POINTER(ctypes.c_uint64)() # this plugin declared no key dependency
lib = ctypes.CDLL(str(BUILD_DIR / "pareto_scalar_batch.so"))
lib.propaq_truncator_create.restype = ctypes.c_void_p
lib.propaq_truncator_create.argtypes = [ctypes.c_char_p]
lib.propaq_truncator_keep.restype = ctypes.c_int32
lib.propaq_truncator_keep.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint64), ctypes.c_size_t,
ctypes.c_uint32, ctypes.c_uint32, ctypes.c_double, ctypes.c_uint32, ctypes.c_uint32,
]
lib.propaq_truncator_keep_batch.restype = ctypes.c_int32
lib.propaq_truncator_keep_batch.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint64), ctypes.c_size_t,
ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_double),
ctypes.c_uint32, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t,
]
ctx = lib.propaq_truncator_create(b'{"threshold": 1e-6, "alpha": 0.05}')
random.seed(1)
N = 200_000
N_UNITS = 6
weights = (ctypes.c_uint32 * N)(*(random.randint(0, 10) for _ in range(N)))
coeffs = (ctypes.c_double * N)(*(random.uniform(1e-8, 1.0) for _ in range(N)))
out = (ctypes.c_uint8 * N)()
t0 = time.perf_counter()
for i in range(N):
lib.propaq_truncator_keep(ctx, BASIS_PAULI, NULL_WORDS, 0, N_UNITS, weights[i], coeffs[i], 0, 0)
t1 = time.perf_counter()
lib.propaq_truncator_keep_batch(ctx, BASIS_PAULI, NULL_WORDS, 0, N_UNITS, weights, coeffs, 0, 0, out, N)
t2 = time.perf_counter()
scalar_ffi_ms = (t1 - t0) * 1000
batch_ffi_ms = (t2 - t1) * 1000
print(f"N={N} keep decisions")
print(f"scalar path ({N} FFI calls): {scalar_ffi_ms:.2f} ms")
print(f"batch path (1 FFI call): {batch_ffi_ms:.2f} ms")
print(f"speedup: {scalar_ffi_ms / batch_ffi_ms:.1f}x")
import ctypes
import random
import time
BASIS_PAULI = 0
NULL_WORDS = ctypes.POINTER(ctypes.c_uint64)() # this plugin declared no key dependency
lib = ctypes.CDLL(str(BUILD_DIR / "pareto_scalar_batch.so"))
lib.propaq_truncator_create.restype = ctypes.c_void_p
lib.propaq_truncator_create.argtypes = [ctypes.c_char_p]
lib.propaq_truncator_keep.restype = ctypes.c_int32
lib.propaq_truncator_keep.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint64), ctypes.c_size_t,
ctypes.c_uint32, ctypes.c_uint32, ctypes.c_double, ctypes.c_uint32, ctypes.c_uint32,
]
lib.propaq_truncator_keep_batch.restype = ctypes.c_int32
lib.propaq_truncator_keep_batch.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint64), ctypes.c_size_t,
ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_double),
ctypes.c_uint32, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t,
]
ctx = lib.propaq_truncator_create(b'{"threshold": 1e-6, "alpha": 0.05}')
random.seed(1)
N = 200_000
N_UNITS = 6
weights = (ctypes.c_uint32 * N)(*(random.randint(0, 10) for _ in range(N)))
coeffs = (ctypes.c_double * N)(*(random.uniform(1e-8, 1.0) for _ in range(N)))
out = (ctypes.c_uint8 * N)()
t0 = time.perf_counter()
for i in range(N):
lib.propaq_truncator_keep(ctx, BASIS_PAULI, NULL_WORDS, 0, N_UNITS, weights[i], coeffs[i], 0, 0)
t1 = time.perf_counter()
lib.propaq_truncator_keep_batch(ctx, BASIS_PAULI, NULL_WORDS, 0, N_UNITS, weights, coeffs, 0, 0, out, N)
t2 = time.perf_counter()
scalar_ffi_ms = (t1 - t0) * 1000
batch_ffi_ms = (t2 - t1) * 1000
print(f"N={N} keep decisions")
print(f"scalar path ({N} FFI calls): {scalar_ffi_ms:.2f} ms")
print(f"batch path (1 FFI call): {batch_ffi_ms:.2f} ms")
print(f"speedup: {scalar_ffi_ms / batch_ffi_ms:.1f}x")
N=200000 keep decisions scalar path (200000 FFI calls): 194.75 ms batch path (1 FFI call): 0.89 ms speedup: 219.0x