Timing synchronization between ngspice and VPI#
SpiceBind runs two simulators on one virtual clock: the HDL simulator
(Icarus or Verilator) on the VPI thread, and ngspice on a background
thread (bg_run). A digital event can land in the middle of an analog
step. The two sides wait for each other; this page is the mechanism,
including ngspice redo.
Two simulators, one time#
HDL time is an integer tick at the simulator precision. With
timescale 1ns/1ps, one tick is 1 ps. ngspice time is a floating-point
number of seconds. The VPI plugin converts with
10**(-vpiTimePrecision) ticks per second.
They cannot free-run. If ngspice jumps 1 ns while HDL has an edge at 0.3 ns, the analog circuit sees the new digital value too late. If HDL runs ahead, analog outputs on HDL nets are stale.
The plugin keeps both on the same timeline with
TimeBarrier<unsigned long long> in cpp/TimeBarrier.h. Engine 0 is
HDL, engine 1 is SPICE. update(engine, t) publishes t for that
engine and blocks until the other engine’s time is at least t.
shutdown() wakes anyone still waiting (HDL end of sim, or ngspice
ng_exit).
The .tran line in the netlist is part of this contract. Stop time
must be longer than the HDL run, with a unit (1m, 2us). A bare
.tran 1ns 1 is a 1 ns step for one second, and ngspice tries to
allocate about 1e9 points (malloc / exit -11). If ngspice
finishes .tran first, the barrier waits forever: the analog progress
bar sits at 100 %, then nothing.
The time barrier#
On a quiet stretch, ngspice proposes the next analog time (often the
.tran step). HDL schedules a VPI cbAfterDelay for that time.
When that callback fires, HDL calls update(HDL, now+1): it claims
“I am at now plus one precision tick” and waits until SPICE has reached
that. ngspice, when it needs an external source voltage, calls
ng_srcdata, which does update(SPICE, analog_time) and waits until
HDL has reached that analog time.
Each side waits for the other. The +1 tick lets ngspice take a point
at the HDL event and then move a fraction of a picosecond past it so the
wait can complete.
When a digital input changes#
cbValueChange on each HDL-to-SPICE input runs
vpi_port_change_cb (cpp/VpiCallbacks.cpp). Several ports can
change in the same slot; the timestep work is registered once.
The callback cancels the pending “next analog time” cbAfterDelay,
because that time is no longer the plan. It then registers a 0-delay
timestep:
Icarus:
cbAfterDelaywith delay 0, which still runs in this slot.Verilator:
cbReadWriteSynch. Verilator--mainhas already drained timed callbacks this cycle, so a 0-delaycbAfterDelaywould not run until time had already moved.
vpi_timestep_cb then:
On Verilator, sample parent/TOP aliases. A cocotb write can land on those nets after the instance net that
cbValueChangesaw.Copy HDL inputs into analog sources (
update_all_digital_inputs()) before waiting.request_spice_point(t): record that analog should land on this HDL time, and setneeds_redo.update(HDL, t+1)and wait for ngspice.Push analog-backed HDL outputs (
set_digital_output()/vpi_put_value). Icarus does this in AfterDelay. Verilator schedulescbReadWriteSynchinstead. A Verilator AfterDelay put makes delay-chain bits appear one sample early.Schedule the next AfterDelay at the analog time ngspice just proposed (at least one tick ahead).
What ngspice means by redo#
ngspice calls ng_sync from dctran.c sharedsync at two
places in each analog step, with a location argument:
Location 0 is after breakpoint handling, before XSPICE events and the analog solve. Location 1 is after the analog solve.
The proposed step is actual_time plus *delta_time (seconds).
Returning 1 from ng_sync means: reject this analog time, keep
the circuit at the previous accepted point, and try again with a new
delta. That is a redo.
A redo at location 0 happens before XSPICE and analog evaluation, so those engines never see the rejected time. A redo at location 1 happens after they have already run. The analog delay model (below) has already sampled the point. It does not rewind.
SpiceBind’s ng_sync always returns 0. It never asks ngspice to
reject a point that was already solved. The needs_redo flag on the
barrier is a different thing: it means “HDL asked for a point inside the
step that is about to start; shrink delta at location 0.”
How the analog step is shortened#
Suppose analog is at 2.00 ns and would like to go to 3.00 ns (delta
= 1 ns). HDL changes an input at 2.37 ns.
If analog ran all the way to 3.00 ns, XSPICE would see the new digital value as if it had been true for the whole nanosecond. Delayed events could fire too early.
At location 0, cut_step_to_hdl_request (cpp/NgSpiceCallbacks.cpp)
looks at the HDL request:
If 2.37 ns is strictly inside (2.00, 3.00), it writes a smaller
*delta_timeso the next analog point is 2.37 ns, then clearsneeds_redo.If the proposed point is already 2.37 ns, it leaves
deltaalone. Rejecting a valid point would make ngspice evaluate that time twice.If the request is outside this step, it leaves
deltaalone.
Then ng_sync returns 0. ngspice takes a normal (shorter) step to
2.37 ns. No analog point is thrown away.
While that cut is in flight, needs_redo is true.
ng_srcdata must not call the blocking update(SPICE, t).
HDL is already sitting in update(HDL, t+1) waiting for SPICE.
If SPICE also waits for HDL, both sides block forever.
Once location 0 has applied (or discarded) the cut, needs_redo is
false again and ng_srcdata goes back to waiting for HDL as usual.
analog_outputs_update() also runs at location 0. It reads the latest
ngGet_Vec_Info samples into a cache. HDL later copies that cache
onto nets with set_digital_output(). Reading analog and putting HDL
values are two steps, not one.
Why the analog delay cannot rewind#
The delay in tests/test.cir and tests/timing_edges.cir is XSPICE
delay (ngspice src/xspice/icm/analog/delay/cfunc.mod,
cm_delay).
It samples when TIME >= step_count * TSTEP, using the .tran
step, not every solver time. The samples live in a private circular
buffer. If ngspice rejects a point, that buffer does not rewind.
buff_del is updated after OUTPUT(out), so the delayed value
often shows up on the next analog evaluation. The model also adds
0.5 * tstep into delay_step.
If extra analog points cross a TSTEP, the buffer is sampled extra times. The delay looks shorter or jumpy, especially when DAC and PWM toggle at the same time as the delayed bits.
That is why SpiceBind does not return 1 from ng_sync at location 1:
the delay model would keep the extra sample. It is also why a 1 ns
.tran step cannot resolve a 2 ns delay the way a 100 ps step can.
Production tests/test.cir stays .tran 1ns 1m.
tests/test_tb.py samples between edges (1.5 ns, 3.5 ns, …).
Exact 2 ns, 4 ns, … edges are on tests/timing_edges.cir
(.tran 100ps 2us).
Writing analog results back to HDL#
The analog solve finishing and the HDL process reading adc_out are
not the same event.
Icarus puts analog-backed nets in AfterDelay, in the same region as
#2. Which one runs first is up to the simulator. A Verilog
#2; x = adc_out can resume before the put, so x is still 0.
Verilator puts in cbReadWriteSynch. Cocotb Timer often samples
after that work has run. Timer alone, ReadOnly, and
ReadWrite all see the delayed bit at the exact 2 ns edge on the
100 ps fixture. Verilog #2 and #2; #0 still do not.
#2; #0.1 waits one extra analog step (100 ps on that fixture) and
the bit is there.
tests/test_timing_edges.py is the cocotb check (one pytest case per
cocotb test, because cm_delay buffers leak across tests in one
sim). tests/test_timing_hdl.py is Verilog/SystemVerilog without
cocotb. Conservative HDL benches wait #2; #0.1. Plain #2 is a
strict xfail until the put happens before the process resumes.
HDL timescale does not change the analog delay. VPI still
converts ticks from vpiTimePrecision. Combinations are in
test_timing_timescale, using #1ns so a 2 ns delay stays 2 ns
when the module unit is 1 ps or 1 us. Verilator with 1 ns precision
still has the delayed bit clear at 3 ns; that bench waits until 5 ns.
TimeBarrier calls#
update(engine_id, t)Publish
tand wait until the other engine is at leastt.update_no_wait(engine_id, t)Publish
twithout waiting. HDL uses this so SPICE’s “now” is visible before the cut request.request_spice_point(t)/get_hdl_request_time()HDL asks for an analog point at
t. Setsneeds_redo. Location 0 consumes it.set_next_spice_step_time/get_next_spice_step_timeNext analog time, used to schedule the next HDL AfterDelay.
set_needs_redo/needs_redoCut in flight. This is not “return 1 from
ng_syncat location 1”.
What does not work#
Waiting at location 0 until HDL time is past the proposed analog time deadlocks. ngspice often truncates a step (1 ps, 3 ps, 7 ps, 15 ps) while HDL is waiting at 10 ps or 2 ns.
Waiting until analog “accepted” t+1, then inserting a 1-tick analog
catchup, can hit exact 2 ns edges on the 100 ps fixture. The extra 1 ps
points extra-sample cm_delay on the production 1 ns TSTEP when DAC
and PWM are toggling. Without the catchup, that wait deadlocks against
ng_srcdata.
Forcing analog onto the TSTEP grid collapsed the delay or broke
tests/test_debug.py (inverter Y2).
A location 1 redo after analog solve extra-samples cm_delay. The
delay looks early or jumpy under DAC/PWM.
If the sim hangs or the delay is wrong#
Rebuild the VPI after C++ edits:
cmake --build build --target spicebind_vpi (verbose:
spicebind_vpi_debug).
Check that .tran stop exceeds HDL time and has a unit.
Exact delay edges belong on timing_edges.cir (100 ps), not
test.cir (1 ns). Moving samples to 1.5 ns or 2.2 ns hides the bug
the test is for.
Hung after analog 100 %: .tran ended first, or a new wait deadlocks
with ng_srcdata. Do not add a wait in ng_srcdata while
needs_redo is true without changing the HDL side as well.
Verilator bits one sample early: analog put is in AfterDelay instead of ReadWriteSynch.
Verilog #2 still 0 while cocotb Timer sees 1: put vs process
resume, not a wrong analog delay.
Run:
pytest tests/test_timing_edges.py tests/test_timing_hdl.py
SIM=verilator pytest tests/test_timing_edges.py tests/test_timing_hdl.py