Performance Tuning¶
Pulsim is fast by default — the PWL state-space cache pre-factors every reachable switch configuration into a sparse LU once, then the transient loop is one back-substitution per step. Most circuits just work. This guide is for the cases that don't.
Build-time knobs¶
Set these on the CMake configure line; details in build-system.md.
| Flag | Default | Effect |
|---|---|---|
CMAKE_BUILD_TYPE=Release |
— | Always required for benchmarking. |
PULSIM_ENABLE_LTO |
ON (Release) |
Link-time optimization; 10–20 % wins on the heavy templated code. Auto-disabled for Linux Python wheels (pybind11 TLS interaction). |
PULSIM_ENABLE_NATIVE |
OFF |
Adds -march=native -mtune=native. Don't ship binaries built with this. |
PULSIM_ENABLE_PGO_GENERATE / ..._USE |
OFF |
Two-pass profile-guided optimization; comments in CMakeLists.txt explain the workflow. |
PULSIM_USE_HYPRE |
ON |
Optional AMG backend for very large systems. |
Runtime knobs¶
All on SimulationOptions:
import pulsim as p
opts = p.SimulationOptions(t_start=0.0, t_end=1e-3, dt=1e-6)
# Nonlinear-device refresh — Newton iteration on top of the cached
# LU when a smooth-blend diode / MOSFET / IGBT / saturable inductor
# is in the circuit. ``p.simulate(...)`` auto-detects this; pass
# explicitly to override.
opts.enable_nonlinear_refresh = True
# Newton solver tolerances + iteration cap.
opts.max_newton_iterations = 50
opts.tol_newton_dx = 1e-9 # |Δx|∞ termination
opts.tol_newton_res = 1e-9 # |F(x)|∞ termination
# Globalization strategies (off by default; turn on for stiff
# converters that diverge from a cold start).
opts.enable_newton_line_search = True # Armijo backtracking
opts.enable_newton_lm = True # Levenberg-Marquardt trust region
# Sub-step state correction — when a commutation event lands inside
# a fixed-dt step, split the step in two at the linearly-interpolated
# crossing instant. Big accuracy win on PWM converters; small cost.
opts.enable_substep_state_correction = True
# Event-detection iteration cap.
opts.max_event_iterations = 32
The p.simulate(...) ergonomic wrapper accepts each of these as
a keyword argument:
res = p.simulate(
b, t_end=1e-3, dt=1e-6,
enable_nonlinear_refresh=True,
enable_newton_line_search=True,
enable_substep_state_correction=True,
max_newton_iterations=50,
)
Cache + scaling¶
The PWL cache lives on PwlStateSpaceCache(graph, pool).build(dt).
Key properties:
- One sparse LU per reachable switch combination. A buck with one switch + one diode = 4 combinations, all 4 factored once at setup. The transient loop never touches a sparse solver again for linear circuits.
- Lazy expansion. Combinations not reached in the simulation are never factored. Cold-start cost ≈ (num reached configs) × (single LU cost).
- Event-dt solves. Sub-step event correction solves at
interpolated fractions of
dtthrough a small pool of reusable event solvers (≤ 8 masks resident); a new eventdtis a cheap in-place numeric refactor (J = G + (1/dt)·Con a shared symbolic analysis), never a permanently cached factor.
For circuits with many switches (3-φ VSI with 6 IGBTs has 64
reachable states; PFC + boost cascade can have hundreds), the
cache can become memory-heavy. In lazy mode the cache is
LRU-bounded to a byte budget (default 1 GiB) — least-recently
solved masks are evicted and transparently rebuilt on re-visit.
Profile with cache.num_built_segments() and
cache.segment_cache_bytes(); tune with
cache.set_segment_budget_bytes(n) (0 disables eviction), and read
cache.metrics().segment_evictions to see whether the budget is
actually binding.
Sample these between runs, not during one.
num_built_segmentsandsegment_cache_byteswalk the segment map, which lazy builds and evictions mutate — andrun_transientreleases the GIL, so a monitor thread polling them mid-run races with that mutation. Themetrics()counters are atomic and are safe to read live.
Bounding output memory is usually the bigger lever on a long run:
simulate(..., store_every=m) records every m-th step on a uniform
m·dt grid, cutting the result buffer by m.
DC operating-point seeding¶
A converter that doesn't converge from x=0 often converges from
its DC OP. Two ways to ask:
# 1) p.simulate(...) flag
res = p.simulate(b, t_end=..., dt=..., start_from_dc_op=True)
# 2) Explicit, and ask which rung answered
from pulsim import compute_dc_op
report = []
x0 = compute_dc_op(b, t_eval=0.0, report=report)
print(report[0].summary())
Since v2.0 you rarely pick a strategy: "auto" walks the cascade
until one rung answers, and the common case never leaves rung 1.
strategy= |
What it varies | Reach for it when |
|---|---|---|
"naive" |
nothing | you want the diagnostic, not an answer |
"gmin_step" |
a conductance from every node to ground, 1e-2 S → 1e-12 S by decades | the matrix pivots badly, or Newton has no basin from x = 0 |
"source_step" |
every independent source amplitude, 0 → nominal (SourceStepConfig.n_steps) |
multiple Newton basins |
"pseudo_trans" |
an artificial dx/dt = -F |
resistive-nonlinear problems with no MNA constraint rows |
"settle" |
nothing — it runs a real transient (SettleConfig) |
the steady state is a switching average, which no DC solve can find |
"auto" (default) |
the first four, in order | always |
Both homotopy rungs warm-start from the previous solve and bisect
when a step turns out to be too wide. gmin=0 disables the 1e-12 S
conductance floor if you need the un-augmented system exactly.
See gotchas.md for the failure modes each one addresses.
Profiling¶
The C++ side has no internal profiling hooks — the kernel is header-only and any allocation or branch happens inline. To measure:
- Wall-clock per simulation:
time.perf_counter()aroundp.simulate(...). - Per-step cost: wrap with a
step_observerand instrument the callback (note: this re-enters Python per step, biasing the measurement; the C++ path viaMixedDomainBlockChainis the measurement-quality option). - Compiler-level:
samply,perf, or Instruments.app on macOS. The hottest function tends to bepwl::Cache::solve_at.
Common pitfalls¶
dttoo small. Pulsim doesn't enforcedt ≪ τ_min. Ifdtis below1e-9you're paying for sub-nanosecond sampling and Newton accuracy with no physical reason. 10 % of the smallest rise/fall time is usually enough.dttoo large. Sub-step event correction patches some inaccuracy, but adtthat misses the entire ON portion of a PWM pulse will lose duty cycle. Sample at ≥ 20 points per switching period.- Sat-inductor + nonlinear refresh. Saturable magnetics need
enable_nonlinear_refresh=True.p.simulate(...)detects this automatically; the explicitrun_transientdoes not. - Smooth diode + Newton. Hard-edge
IdealDiode(PWLg_on/g_off) doesn't need Newton; the smooth-blend variant (IdealDiodeParams(blend_width=...)) does. Enableenable_nonlinear_refreshfor the latter.
Benchmarks¶
The benchmark_compile_time custom target (PULSIM_BUILD_BENCHMARKS=ON
configure) times a clean rebuild of the heaviest test binary
(pulsim_layer5_v4_tests — Newton in run_transient). Useful when
auditing a kernel-header change for compile-time regressions.
There's no in-tree wall-clock harness for transient simulation yet
— individual test binaries time themselves with
BENCHMARK("…") { … } macros from Catch2.