A firmware team boots software on a virtual platform that includes your DMA's TLM model. Booting takes four hours. Profiling shows the simulator spends most of its time switching between processes: every 10 ns register access makes the initiator wait, which wakes the scheduler, which runs everything else, thousands of times per simulated microsecond. The model's timing is correct; it is just being honoured far too often. How do TLM 2.0 models go fast without lying about time?
Temporal decoupling lets an initiator run ahead of simulation time. Instead of waiting after every transfer, it adds each annotated delay to a local time offset and keeps going, with $time standing still. Only when the offset reaches a quantum does it synchronize: wait out the whole offset at once and reset it. Between synchronizations, the initiator's local time is $time + offset. It is like a traveller who keeps a running total of expenses on paper and settles with the bank once a week, instead of walking to the bank after every coffee.
The quantum is the accuracy dial. A small quantum keeps every process close to real simulation time; a large one runs fast but lets one initiator get far ahead of another, which can hide ordering bugs between them. In SystemC TLM, a global quantum and a tlm_quantumkeeper utility manage this for every initiator. UVM-SV has neither. A decoupled UVM initiator keeps its own offset and its own quantum, as the model below does.
The model below is plain SystemVerilog, not the UVM library, so it can run anywhere. It shows the mechanism a decoupled UVM initiator implements around b_transport: accumulate, compare with the quantum, synchronize once.
class decoupled_init;
time local_off;
time quantum;
int unsigned syncs;
function new(time q);
quantum = q;
local_off = 0;
syncs = 0;
endfunction
// After a transfer that annotated ann: add it to the offset, report whether to sync.
function bit xfer(time ann);
local_off = local_off + ann;
return (local_off >= quantum);
endfunction
// Synchronize: wait out the whole offset at once, then reset it.
task do_sync();
syncs++;
#(local_off);
local_off = 0;
endtask
endclass
class q_mem;
bit [31:0] mem[bit [31:0]];
function void write(bit [31:0] a, bit [31:0] d);
mem[a] = d;
endfunction
function bit [31:0] read(bit [31:0] a);
if (mem.exists(a) != 0) return mem[a];
return '0;
endfunction
endclass
In a UVM initiator, the same logic wraps the socket call. Pass the current offset in as the delay, so an annotating target knows how far ahead the initiator already is; read back the new offset; and wait only when it reaches the quantum, or when the initiator must interact with something in real simulation time, such as waiting for an RTL interrupt:
// snippet: a decoupled initiator around b_transport
time quantum = 1us;
time offset = 0; // local time = $time + offset
task xfer(uvm_tlm_generic_payload gp);
uvm_tlm_time delay = new("delay");
delay.incr(offset, 1ns); // tell the target how far ahead we are
isock.b_transport(gp, delay); // an annotating target adds its latency
offset = delay.get_realtime(1ns); // our new local offset
if (offset >= quantum) sync();
endtask
task sync(); // also call before touching real-time signals
#(offset);
offset = 0;
endtaskEverything a quantum keeper would do in SystemC is in those few lines: the offset, the comparison and the synchronization. What UVM cannot give you is a global quantum shared by every initiator; if several decoupled initiators must stay within the same window, they read one quantum from a shared configuration object.
PASS: 3 transfers caused 1 quantum sync and all readbacks matched$time is still 0 after three transfers: none of them consumed simulation time.
The third transfer pushed the offset to 30 ns, past the 25 ns quantum, so exactly one synchronization was needed.
After the single do_sync, $time is 30: the whole offset was spent at once. The data was correct throughout, because decoupling changes when time is accounted, not what the transfers do.
Style | Interface | Timing points per transfer | Use it for |
|---|---|---|---|
Loosely timed (LT) |
| Two: call and return, plus the annotation | Virtual platforms and software bring-up, where speed matters most |
Approximately timed (AT) |
| Up to four, one per phase | Performance studies that must show pipelining and arbitration |
TLM 1 | put, get, transport, analysis | None built in | Custom transaction types, flow control, and broadcast inside a UVM bench |
Many platforms mix the two: loosely timed for the processor and memory path that software exercises constantly, approximately timed only for the interconnect or the block under study. In SystemC one target socket carries both interfaces, so a model can serve b_transport and nb_transport_fw on the same socket; in UVM-SV the blocking and non-blocking sockets are separate classes, so such a model needs two sockets or an adapter. For DV work, TLM 2.0 mostly appears at the boundary with SystemC: a reference model or virtual platform connected through a co-simulation layer such as UVM Connect or UVM-SystemC. Inside a UVM bench, analysis ports and TLM 1 FIFOs remain the normal plumbing.
A virtual platform ran two decoupled initiators, a CPU model and the DMA model, each with a 1 µs quantum. The firmware started a DMA job and then polled a status register. In the platform, the CPU's polling loop could run a full microsecond of local time ahead of the DMA's progress before either synchronized, so the CPU saw busy for far longer than on silicon, and a timeout in the firmware, tuned on the platform, was much too generous for the chip. Worse, a race in which the firmware cleared DONE just as the DMA set it never showed up on the platform, because the two models never interleaved finely enough. Reducing the quantum for that phase of the boot, and synchronizing explicitly before every status poll, exposed the race. A large quantum is a speed setting, never a correctness guarantee.
$time move?After the third transfer, when the offset reaches 30 ns and passes the quantum. $time then moves by the full 30 ns in one wait, and the offset resets to 0.
Synchronize: wait out its current local offset so that simulation time catches up with its local time. Otherwise it would sample the interrupt at a moment that is earlier than its own notion of now.
Keep the large quantum for software regressions, where throughput matters and the software has already been checked against finer timing. Use the small quantum, or explicit synchronization before status polls, for runs whose purpose is to find interaction bugs between initiators. The quantum is a per-purpose setting, not a single correct value.
One more habit makes decoupled models safe to debug: log the local time, $time + offset, rather than $time. Messages stamped with simulation time alone show three transfers happening at the same instant, which reads like a bug when it is only decoupling at work.
Next: every transfer in this topic carried a generic payload. Generic payload: extensions and status looks inside it: every field, every status code, and how to carry data the standard fields do not have.