CRITICAL: All simulation hot paths should be optimized for GPU execution, minimizing CPU-GPU context switching and data transfer.
- GPU-first simulation loops: Use
lax.scanorlax.while_loopfor time-stepping - Batched device evaluation: Use
vmapfor parallel device evaluation - Minimize host transfers: Keep arrays on device throughout simulation
- JIT everything: Ensure simulation functions are JIT-compilable
- Avoid duplication - unify critical paths where possible
- Keep files under ~70KB (~20,000 tokens) - split large modules
- Regularly check for opportunities to simplify without impacting functionality
- NO API/ABI Stability - this is still v0.x. Do not keep old code paths, remove them agressively.
- Comprehendability - This code does some pretty complex maths and operations, try to keep the code as understandable as possible. Any data structures should be well defined - dict keys should have meaning, arrays should have their index defined meaningfully.
Use JAX (jnp) for simulation hot paths that run on GPU. NumPy/SciPy are acceptable for:
- I/O and file parsing
- One-time setup/preprocessing
- Test utilities and validation
- Optional CPU-only solver backends (e.g., UMFPACK)
VAJAX has a per-step fixed overhead of ~10-15 us from adaptive timestep
machinery, jnp.where branching, vmap batching, and COO matrix assembly. This
overhead dominates for small circuits (6-11x slower than VACASK on CPU) but
becomes negligible for large circuits (c6288: 1.2x on CPU, 2.9x faster on GPU).
When optimizing simulation performance:
- Don't optimize for small-circuit CPU speed unless it also helps GPU performance
- Focus on reducing per-NR-iteration cost (Jacobian build, linear solve) — these scale with circuit size
- GPU threshold is 500 nodes (
gpu_backend.py) — circuits below this auto-route to CPU - See
docs/performance_analysis.mdfor the full overhead breakdown
The simulator supports three solver backends (selected in solver_factories.py):
- Dense (default for <1000 nodes):
jax.scipy.linalg.solve() - Spineax/cuDSS (GPU sparse): Float32 factorization with iterative refinement
- Factorizes J in float32 (halves VRAM), computes residual in float64 via SpMV, solves correction in float32, returns near-float64 accuracy
- Cached symbolic factorization — only numerical refactorization per NR step
- UMFPACK FFI (CPU sparse): Direct solver via nanobind FFI for large CPU circuits
For large circuits, use sparse mode with use_sparse=True. The backend is auto-selected
based on available hardware (cuDSS on CUDA GPUs, UMFPACK on CPU).
Note: c6288 (~86k transistors, ~5k nodes) and mul64 (~267k transistors, ~666k unknowns) require sparse mode as dense would need impractical memory.
# Run all tests (CPU)
JAX_PLATFORMS=cpu uv run pytest tests/ -v
# Run VACASK benchmark tests
JAX_PLATFORMS=cpu uv run pytest tests/test_vacask_suite.py -v
# Run openvaf-py tests
cd openvaf-py && JAX_PLATFORMS=cpu ../.venv/bin/python -m pytest tests/ -v
# Run and profile GPU tests on non-CUDA systems (e.g. Apple silicon)
uv run scripts/profile_gpu_cloudrun.py --benchmark ring,c6288
# Profile GPU performance on CUDA systems
uv run python scripts/profile_gpu.py --benchmark ring,c6288
Precision is auto-configured on import via vajax/__init__.py:
- CPU/CUDA: Float64 enabled (
jax_enable_x64=True) for numerical accuracy - Metal: Not yet supported (GPU backend in development). Auto-config sets float32 but Metal path is untested.
- TPU: Float32 (
jax_enable_x64=False) — TPU doesn't support float64 natively
To check or override precision:
import vajax
# Check current settings
info = vajax.get_precision_info()
print(f"x64 enabled: {info['x64_enabled']}, backend: {info['backend']}")
# Force specific precision (after import, before computation)
vajax.configure_precision(force_x64=True) # Force float64
vajax.configure_precision(force_x64=False) # Force float32
vajax.configure_precision() # Auto-detect againvajax/analysis/
├── solver_factories.py # NR solver backends (Dense, Spineax/cuDSS, UMFPACK)
├── transient/
│ └── full_mna.py # Adaptive time-stepping with lax.while_loop
├── mna_builder.py # MNA system builder (COO/CSR stamping)
├── dc_operating_point.py # DC operating point analysis
├── solver.py # Simple dense NR solver (DC)
├── mna.py # MNA system representation
├── gpu_backend.py # GPU routing (>500 nodes threshold)
└── sparse.py # JAX sparse utilities (BCOO)
vajax/devices/
├── verilog_a.py # OpenVAF Verilog-A device wrapper
└── vsource.py # Voltage/current source waveforms
vajax/benchmarks/
└── runner.py # VACASK benchmark runner
All devices are routed through OpenVAF except voltage/current sources:
-
OpenVAF path: resistor, capacitor, diode, psp103, and other VA models
- Batched evaluation via
vmapfor GPU efficiency - VA models from
vendor/VACASK/devices/(resistor.va, capacitor.va, diode.va) - Complex models from
vendor/OpenVAF/integration_tests/(PSP103)
- Batched evaluation via
-
Source path: vsource, isource only
- Time-varying behavior (pulse, sine, DC)
- Handled separately with vectorized stamping
Production transient hot path (the code that actually runs simulations):
vajax/analysis/solver_factories.py- NR solver with pluggable linear solve backends:- Dense:
jax.scipy.linalg.solve(small circuits <1000 nodes) - Spineax/cuDSS: GPU sparse, float32 factorization + iterative refinement (CUDA)
- UMFPACK FFI: CPU sparse solver via nanobind FFI (large circuits)
- Common NR logic extracted into
_make_nr_solver_common()
- Dense:
vajax/analysis/transient/full_mna.py- Adaptive time-stepping withlax.while_loop
DC/utility solvers:
vajax/analysis/solver.py- Simple dense NR solver for DC operating pointvajax/analysis/sparse.py- JAX BCOO/BCSR sparse utilities (legacy, not used in hot path)
NumPy/SciPy are used appropriately for:
- File I/O (
rawfile.py,prn_reader.py) - Optional UMFPACK solver backend for CPU
- Test utilities and waveform comparison
When compiling Verilog-A models with OpenVAF, the generated Python code has a specific input array structure with different parameter kinds:
param: Model parameters from netlist (e.g., TOX, NSUBO, W, L)voltage: Computed at runtime from node voltageshidden_state: Intermediate computed values that OpenVAF expects to be pre-initializedparam_given: Flags indicating which parameters were explicitly providedtemperature: Device operating temperaturesysfun: System functions likemfactor
Key Discovery: OpenVAF Inlines hidden_state Parameters
IMPORTANT: OpenVAF's optimizer aggressively inlines hidden_state computations. Analysis of MIR (Mid-level IR) shows that hidden_state params are NEVER actually used in the eval function - they're all inlined into the cache values computed by init.
Tested models (all show 0% hidden_state usage in eval MIR):
| Model | hidden_state params | Used in eval | Regular params | Used in eval |
|---|---|---|---|---|
| resistor | 1 | 0 | 2 | 1 |
| capacitor | 2 | 0 | 1 | 1 |
| diode | 16 | 0 | 13 | 13 |
| bsim4 | 2330 | 0 | 893 | 2 |
| psp103 | 1705 | 0 | 840 | 0 |
What eval actually uses (PSP103 example):
- 13 voltage params (terminal voltages)
- ~462 cache values (computed by init)
- 1 mfactor (system function)
Implications:
- Setting hidden_state to 0.0 is SAFE - those values are never read
- The
*_isuffix params (likeTOX_i,NEFF_i) are inlined into cache - Debug warnings about "unmapped hidden_state" are informational only
- If simulation results differ from VACASK, look elsewhere (voltage mapping, cache computation, or solver issues) - NOT hidden_state initialization
PSP103 has an internal NOI (noise correlation) node with extremely high conductance (1/mig where mig=1e-40):
- This creates G = 1e40 to ground
- If V(NOI) ≠ 0, residual = 1e40 * V(NOI) - massive numerical explosion
- Solution: Initialize NOI nodes to 0V and mask their residuals during NR convergence checking
# NOI is internal node4 in PSP103
if 'node4' in internal_nodes:
noi_idx = internal_nodes['node4']
V = V.at[noi_idx].set(0.0) # Initialize to 0V
# Mask NOI residuals in convergence check
residual_mask = jnp.ones(n_unknowns, dtype=jnp.bool_)
residual_mask = residual_mask.at[noi_indices - 1].set(False)
f_masked = jnp.where(residual_mask, f, 0.0)
max_f = jnp.max(jnp.abs(f_masked))| Benchmark | Node Count Match | DC OP Match | Notes |
|---|---|---|---|
| rc | ✅ | ✅ 0.00% RMS | Simple RC circuit |
| graetz | ✅ | ✅ 0.00% RMS | Diode bridge rectifier |
| mul | ✅ | ✅ 0.00% RMS | Diode voltage multiplier |
| ring | ✅ (47 nodes) | ✅ <1% RMS | Metastable oscillator; comparison requires aligning start times |
| c6288 | ✅ (~5k after collapse) | ✅ 2.01% RMS | Large multiplier circuit |
CRITICAL: OSDI and openvaf_jax return Jacobians in different formats. Direct array comparison will fail.
| Aspect | OSDI (osdi_py) | openvaf_jax |
|---|---|---|
| Ordering | Column-major (Fortran-style) | Row-major (C-style) |
| Sparsity | Sparse (only has_resist=True entries) |
Dense (all N×N entries) |
| Array length | Variable (sum of has_resist flags) | Fixed (N×N where N=num_nodes) |
For a 4-terminal device with 2 internal nodes (6 total), OSDI returns ~20 entries while JAX returns 32 (for resistive Jacobian).
OSDI sparse indices (has_resist=True only):
[0, 1, 2, 3, 4, 6, 7, 8, 9, 11, 16, 17, 18, 19]
JAX dense indices (same physical entries):
[0, 1, 2, 3, 4, 10, 11, 12, 13, 15, 20, 21, 26, 31]
Use vajax.debug.jacobian helpers for comparison:
from vajax.debug.jacobian import (
osdi_to_dense_jacobian,
compare_jacobians,
)
# Convert OSDI sparse to dense for comparison
osdi_dense = osdi_to_dense_jacobian(osdi_jac, n_nodes, jacobian_keys)
# Or use compare helper directly
passed, report = compare_jacobians(osdi_jac, jax_jac, n_nodes, jacobian_keys)When debugging openvaf_jax vs OSDI:
- Don't compare arrays directly - indices don't match
- Non-zero counts should match - both should have same sparsity pattern
- Value differences ~1% are real computational differences
- Zeros at wrong positions indicate PHI node or control flow issues
When debugging openvaf_jax discrepancies with OSDI, use the MIR CFG analysis script to trace SSA value dependencies and PHI node resolution. This is especially useful for complex models with conditional control flow (NMOS/PMOS branches, etc.).
# Generate DOT and analyze CFG for a model
uv run scripts/analyze_mir_cfg.py vendor/OpenVAF/integration_tests/EKV/ekv.va --func eval
# Analyze existing DOT file
uv run scripts/analyze_mir_cfg.py --dot /tmp/model_eval.dot
# Find all PHI nodes (merge points in control flow)
uv run scripts/analyze_mir_cfg.py model.va --func eval --find-phis
# Trace paths to a specific block
uv run scripts/analyze_mir_cfg.py model.va --func eval --target block4654
# Trace dependencies of an SSA value through PHI nodes
uv run scripts/analyze_mir_cfg.py model.va --func eval --trace-value v12345
# Analyze a specific block with PHIs
uv run scripts/analyze_mir_cfg.py model.va --func eval --analyze-block block4654
# List all branch points (T/F conditional branches)
uv run scripts/analyze_mir_cfg.py model.va --func eval --branchesThe script requires openvaf-viz to generate DOT files from Verilog-A:
cd vendor/OpenVAF && cargo build --release -p openvaf-viz-
PHI node gets wrong value for one branch: Use
--analyze-blockto see which predecessor provides which value. Check if one branch usesv3(constant 0.0). -
Value computed incorrectly: Use
--trace-valueto find where it's defined and what PHI sources feed into it. -
Understanding control flow: Use
--targetto see all paths to a block, with branch labels (T/F) shown. -
Finding NMOS/PMOS split: Use
--branchesto list all conditional branches, then trace paths to find where device type selection occurs.
The vajax.debug module provides utilities for debugging OSDI vs JAX discrepancies.
See docs/debug_tools.md for comprehensive documentation.
| Symptom | Tool | First Step |
|---|---|---|
| JAX returns wrong current | ModelComparator |
Compare at specific bias point |
| Jacobian mismatch | compare_jacobians() |
Check format-aware comparison |
| Near-zero outputs | MIRInspector |
Check PHI nodes with zero operand |
| Cache looks suspicious | CacheAnalysis |
Look for inf/nan, VT values |
| NMOS/PMOS issues | MIRInspector.find_type_param() |
Verify TYPE parameter location |
| Step rejection / LTE issues | capture_step_trace() |
Run with debug_steps, inspect LTE norms |
| Convergence varies with t_stop | convergence_sweep() |
Sweep multiple durations |
| VACASK step mismatch | parse_vacask_debug_output() |
Compare step-by-step with VACASK |
from vajax.debug import quick_compare, ModelComparator, MIRInspector
# One-shot comparison
result = quick_compare(va_path, osdi_path, params, voltages)
print(result)
# Detailed investigation
comparator = ModelComparator(va_path, osdi_path, params)
result = comparator.compare_at_bias([0.5, 0.6, 0.0, 0.0])
cache = comparator.analyze_cache()
comparator.print_residual_table([0.5, 0.6, 0.0, 0.0])
# MIR inspection
inspector = MIRInspector(va_path)
inspector.print_mir_stats()
inspector.print_phi_summary('eval')
inspector.print_type_param_info()- Initial comparison: Use
quick_compare()to see if outputs match - Cache analysis: If mismatch, check
analyze_cache()for inf/nan/temperature issues - MIR inspection: If cache looks OK, check
print_phi_summary()for PHI node issues - CFG analysis: Use
scripts/analyze_mir_cfg.pyto trace control flow
| Module | Purpose |
|---|---|
model_comparison |
Compare OSDI vs JAX outputs (residuals, Jacobians, cache) |
mir_inspector |
Inspect MIR data (params, PHI nodes, constants) |
jacobian |
Format-aware Jacobian comparison (OSDI sparse vs JAX dense) |
mir_tracer |
Trace value flow through MIR |
param_analyzer |
Analyze parameter kinds and OSDI comparison |
mir_analysis |
CFG analysis with networkx (optional dependency) |
transient_diagnostics |
Runtime transient step analysis (LTE, NR, step acceptance) |
For runtime transient issues (step rejection, LTE behaviour, NR convergence):
from vajax.debug import capture_step_trace, convergence_sweep, parse_vacask_debug_output
# 1. Sweep t_stop to find where convergence degrades
results = convergence_sweep("graetz", [1e-3, 5e-3, 7e-3, 10e-3])
# 2. Capture full step trace for detailed analysis
records, summary = capture_step_trace("ring", use_sparse=True)
# 3. Compare with VACASK tran_debug=1 output
vacask_records = parse_vacask_debug_output(vacask_stdout)See docs/debug_tools.md for the full transient debugging workflow.
-
JAX returns near-zero current (~1e-15): PHI node resolution issue in NMOS/PMOS branching. Check
inspector.find_phi_nodes_with_value('v3')for PHIs with zero operand. -
Jacobian sparsity mismatch: OSDI has N non-zeros, JAX has M << N. Branch not taken, computations skipped. Trace control flow.
-
~1% current difference: Usually temperature-related. Check
cache.temperature_relatedfor VT values (should be ~0.02585 at 300K).
OpenVAF compiles Verilog-A system functions into CallbackKind callbacks. These are translated
in openvaf_jax/codegen/instruction.py:_translate_call(). Analysis across 87 VACASK/OpenVAF
models shows the following callback usage:
| CallbackKind | Uses | Models | Handling |
|---|---|---|---|
WhiteNoise |
424 | 47 | Returns 0 (noise analysis not supported) |
CollapseHint |
253 | 43 | No-op (node collapse at build time) |
NodeDerivative |
178 | 30 | Returns 0 (partial derivatives not supported) |
Print |
131 | 33 | jax.debug.print (disabled by default for JIT speed) |
FlickerNoise |
72 | 46 | Returns 0 (noise analysis not supported) |
TimeDerivative |
49 | 49 | Returns charge (transient handles dQ/dt) |
StoreLimit |
47 | 13 | Passthrough (no pnjlim/fetlim algorithms) |
SimParamOpt |
28 | 28 | Via simparams array with default fallback |
SetRetFlag |
19 | 19 | No-op ($finish/$stop not supported) |
Analysis |
14 | 14 | Via simparams[$analysis_type] |
LimDiscontinuity |
13 | 13 | Ignored for DC analysis |
SimParam |
2 | 2 | Via simparams array |
Limiting Functions ($limit):
StoreLimitandBuiltinLimit(pnjlim, fetlim) are used for Newton-Raphson convergence help- Current implementation passes through the input voltage unchanged
- This may result in slightly different convergence behavior compared to OSDI
Noise Functions:
white_noise(),flicker_noise(),noise_table()return 0.0- Full noise analysis would require frequency-domain analysis
$simparam:
- Registered dynamically via
ctx.register_simparam(name) - Caller builds
simparamsarray usingbuild_simparams(eval_meta, values) - See
vajax/__init__.py:build_simparams()for helper
$display/$strobe:
- Disabled by default (
emit_debug_prints=False) - Enable via
InstructionTranslator(..., emit_debug_prints=True) - Warning:
jax.debug.printcauses slow JIT tracing
To evaluate the project's usability and contributor experience, run simulated user personas as background agents. Each persona independently explores the repo and reports findings. This helps identify documentation gaps, onboarding friction, and architectural confusion that developers familiar with the codebase may not notice.
Launch two general-purpose Task agents in parallel with run_in_background: true:
-
End-user persona (e.g., "Ana" — analog IC designer):
- Background: Uses commercial simulators (Spectre), basic Python, unfamiliar with uv/JAX
- Task: Walk through discovery → installation → first run → documentation gaps
- Focus: Can they actually get a circuit simulated? What's confusing?
-
Developer contributor persona (e.g., "Dev" — GPU/scientific computing expert):
- Background: Expert in JAX/CUDA/sparse linear algebra, knows nothing about SPICE
- Task: Evaluate architecture → build/test → code quality → contribution barriers
- Focus: Would they contribute? What's the biggest barrier?
- Read actual files in the repo — don't assume or fabricate
- Be specific about file paths and line numbers when citing issues
- Be constructively critical — what's good, what's confusing, what's missing
- Report on the experience as a narrative, not just a checklist
Cross-cutting themes from past runs:
- Installation friction (both personas flagged Rust+LLVM build requirement)
- Documentation gaps (no tutorial, no "start here" for contributors)
- Architectural confusion (solver.py vs solver_factories.py split)
- Stale documentation (CLAUDE.md had outdated RMS error figures)
- Missing features visible to users but not to developers (waveform viewing)