Performance
Because ParamRF is built on top of JAX, models are not evaluated using standard Python loops. Instead, circuits are compiled into highly optimized XLA (Accelerated Linear Algebra) computational graphs. This allows for natively batched matrix operations that can be dispatched directly to your CPU or GPU.
To illustrate this, we can benchmark a mixed-domain circuit (a lossy low-pass filter containing ideal lumped elements and distributed transmission lines) across a large frequency sweep.
In an optimization context, physical parameters change at every step. Therefore, a fair benchmark must account for reconstructing the circuit topology and recalculating the physical media properties on every pass.
Benchmark Script
The following script builds the exact same circuit in both scikit-rf and ParamRF, and times the forward-pass execution over 2001 frequency points.
import time
import numpy as np
import scipy.constants
import skrf as rf
import jax
import equinox as eqx
import parax as prx
import pmrf as prf
from skrf.circuit import Circuit as CircuitSkrf
from pmrf.models import (
Circuit as CircuitPmrf, Port, Ground, Resistor, Capacitor,
Inductor, PhysicalLine, GlobalScatteringCircuitSolver, GlobalMNACircuitSolver
)
c = scipy.constants.c
def create_skrf_physical_line(freq: rf.Frequency, zn, length, epr, A, fA, tand, name):
"""
Helper to generate a scikit-rf Network matching the ParamRF PhysicalLine math,
ensuring an apples-to-apples baseline for the physical physics evaluation.
"""
f = freq.f
sqrt_epr = np.sqrt(epr)
A_dB = A * np.sqrt(f / fA)
alpha_c = A_dB * (np.log(10) / 20.0)
alpha_d = np.pi * sqrt_epr * f / c * tand
R = 2 * zn * alpha_c
L = (zn * sqrt_epr) / c
G = 2 / zn * alpha_d
C = sqrt_epr / (zn * c)
omega = 2 * np.pi * f
Z_series = R + 1j * omega * L
Y_shunt = G + 1j * omega * C
gamma = np.sqrt(Z_series * Y_shunt)
Zc = np.sqrt(Z_series / Y_shunt)
media = rf.media.DefinedGammaZ0(frequency=freq, gamma=gamma, z0=Zc)
return media.line(d=length, unit='m', name=name)
def run_benchmark():
num_runs = 200
n_points = 2001
freq_pmrf = prf.Frequency(start=1, stop=10, npoints=n_points, unit='ghz')
freq_skrf = rf.Frequency(start=1, stop=10, npoints=n_points, unit='ghz')
print(f"Benchmarking Mixed-Domain Circuit over {n_points} frequency points...")
# ==========================================
# 1. scikit-rf Benchmark (Baseline)
# ==========================================
skrf_media = rf.media.DefinedGammaZ0(frequency=freq_skrf, z0=50)
def eval_skrf():
# Re-evaluate components to simulate an optimization loop step
line1 = create_skrf_physical_line(freq_skrf, 50.0, 0.05, 2.2, 0.01, 1e9, 0.001, 'L1')
cap1 = skrf_media.capacitor(1.5e-12, name='C1')
ind1 = skrf_media.inductor(3.3e-9, name='Ind1')
res1 = skrf_media.resistor(25.0, name='R1')
line2 = create_skrf_physical_line(freq_skrf, 50.0, 0.05, 2.2, 0.01, 1e9, 0.001, 'L2')
port0 = CircuitSkrf.Port(freq_skrf, 'p0')
port1 = CircuitSkrf.Port(freq_skrf, 'p1')
gnd = CircuitSkrf.Ground(freq_skrf, 'gnd')
conns = [
[(port0, 0), (line1, 0)],
[(line1, 1), (ind1, 0), (cap1, 0)],
[(ind1, 1), (line2, 0)],
[(line2, 1), (res1, 0), (port1, 0)],
[(cap1, 1), (res1, 1), (gnd, 0)]
]
return rf.circuit.Circuit(conns).network.s
# Warmup and time scikit-rf
_ = eval_skrf()
t0 = time.perf_counter()
for _ in range(num_runs):
_ = eval_skrf()
t_skrf_ms = ((time.perf_counter() - t0) / num_runs) * 1000
# ==========================================
# 2. ParamRF Benchmark
# ==========================================
# Define ParamRF components and topology statically
p0, p1, gnd = Port(), Port(), Ground()
line1 = PhysicalLine(zn=50.0, length=0.05, epr=2.2, A=0.01, fA=1e9, tand=0.001)
cap1 = Capacitor(C=1.5e-12)
ind1 = Inductor(L=3.3e-9)
res1 = Resistor(R=25.0)
line2 = PhysicalLine(zn=50.0, length=0.05, epr=2.2, A=0.01, fA=1e9, tand=0.001)
pmrf_conns = [
[(p0, 0), (line1, 0)],
[(line1, 1), (ind1, 0), (cap1, 0)],
[(ind1, 1), (line2, 0)],
[(line2, 1), (res1, 0), (p1, 0)],
[(cap1, 1), (res1, 1), (gnd, 0)]
]
solvers_to_test = {
"Scattering Solver": GlobalScatteringCircuitSolver(),
"MNA Solver": GlobalMNACircuitSolver()
}
results = {"scikit-rf": t_skrf_ms}
for solver_name, solver in solvers_to_test.items():
circuit_model = CircuitPmrf(connections=pmrf_conns, solver=solver, flatten=True)
# Partition dynamic parameters from the static network topology
is_dynamic = lambda x: eqx.is_inexact_array(x) and not isinstance(x, np.ndarray)
params, static_model = eqx.partition(circuit_model, is_dynamic, is_leaf=prx.is_constant)
# Define a pure mathematical function for JAX XLA tracing
def eval_pmrf(p):
model = eqx.combine(p, static_model, is_leaf=prx.is_constant)
unwrapped = prx.unwrap(model) # Apply any parameter constraints/bijectors
return unwrapped.s(freq_pmrf)
jitted_pmrf = jax.jit(eval_pmrf)
# AOT Compilation / XLA Warmup
_ = jitted_pmrf(params).block_until_ready()
t0 = time.perf_counter()
for _ in range(num_runs):
_ = jitted_pmrf(params).block_until_ready()
t_pmrf_ms = ((time.perf_counter() - t0) / num_runs) * 1000
results[f"ParamRF {solver_name}"] = t_pmrf_ms
# ==========================================
# 3. Output Summary
# ==========================================
print("\n" + "="*55)
print(f"FORWARD PASS EXECUTION TIMES ({n_points} Points)")
print("="*55)
base_time = results["scikit-rf"]
print(f"{'scikit-rf (Baseline)':<30} | {base_time:>8.3f} ms | 1.00x")
print("-" * 55)
for name, t_ms in results.items():
if name == "scikit-rf": continue
speedup = base_time / t_ms
print(f"{name:<30} | {t_ms:>8.3f} ms | {speedup:>4.2f}x")
print("="*55)
if __name__ == "__main__":
run_benchmark()
Expected Output
Running this script on a standard modern CPU yields results similar to the following:
Benchmarking Mixed-Domain Circuit over 2001 frequency points...
=======================================================
FORWARD PASS EXECUTION TIMES (2001 Points)
=======================================================
scikit-rf (Baseline) | 25.343 ms | 1.00x
-------------------------------------------------------
ParamRF Scattering Solver | 4.823 ms | 5.25x
ParamRF MNA Solver | 2.979 ms | 8.51x
=======================================================
By vectorizing the linear matrix solutions and bypassing the Python interpreter via JIT compilation, ParamRF’s MNA solver evaluates nearly 9x faster, while the scattering solver evaluates 5x faster (which is most similar to the scattering algorithm in scikit-rf’s Circuit).
The Autodiff Advantage
While a 5-10x speedup on the forward pass is significant, the true performance gain of ParamRF is realized during gradient-based optimization (the backward pass).
In traditional libraries, calculating the gradient for an \(N\)-parameter circuit requires Finite Differences (FD), meaning the circuit must be simulated \(N+1\) times per iteration. For a 50-parameter model, this means a single gradient step in scikit-rf would take 1.3 seconds (51 * 25.3 ms).
In ParamRF, because the entire circuit math is traced, calling jax.value_and_grad yields the exact analytic gradients for all 50 parameters simultaneously in a single, compiled backward pass—often executing in less than 10 ms. This results in a massive speedup (often 40x to 100x) when optimizing complex circuits.