Chapter 8: SystemC AMS

Timed Data Flow (TDF)

A deep dive into the Timed Data Flow (TDF) model of computation, including ports, modules, attributes, and processing phases.

How to Read This Lesson

AMS becomes easier once you separate continuous-time intent from discrete-event synchronization. Watch where the analog cluster meets the SystemC kernel.

Timed Data Flow (TDF)

The Timed Data Flow (TDF) model of computation is the most commonly used MoC in SystemC AMS. It is designed to model signal processing algorithms, feedback loops, and communication systems using discrete-time sampling.

TDF is highly efficient because the activation schedule of a set of connected TDF modules is computed statically before the simulation starts. The solver analyzes the number of samples read and written by each module (the rates) and builds a fixed, deterministic schedule that minimizes overhead.

Source and LRM Trail

For AMS, use Docs/LRMs/SystemC_AMS_2_0_LRM.pdf as the standard reference. The implementation/source trail is the AMS proof-of-concept code and examples where available, plus the SystemC DE boundary in .codex-src/systemc/src/sysc/kernel. Pay special attention to TDF rate, delay, timestep, converter ports, and solver synchronization.

The Structure of a TDF Module

According to the IEEE 1666.1 standard, a TDF module must derive from sca_tdf::sca_module (or use the handy SCA_TDF_MODULE macro). Unlike standard SystemC modules, TDF modules do not use SC_METHOD or SC_THREAD. Instead, they define their behavior using specific virtual callbacks:

  1. set_attributes(): Called during the elaboration phase. This is where you configure the properties of the module and its ports (like time steps, rates, and delays).
  2. initialize(): Called exactly once at the beginning of the simulation. Used to set initial conditions or to write initial delay samples into output ports.
  3. processing(): The core time-domain behavior of the module. This is called repeatedly during simulation according to the statically computed schedule.
  4. change_attributes() (Dynamic TDF): Evaluated during simulation to allow dynamic adaptation of timesteps or rates (introduced in AMS 2.0).

Core TDF Attributes: Timestep, Rate, and Delay

To build the static schedule, the TDF solver relies on three fundamental attributes assigned to ports and modules during set_attributes():

  1. Timestep (set_timestep): The time interval between two consecutive samples. If assigned to a module, it defines the period at which the processing() function is activated.
  2. Rate (set_rate): The number of samples read or written to a port per module activation. By default, the rate is 1. If a port has a rate of $R$, the module must read/write exactly $R$ samples every time processing() is called.
  3. Delay (set_delay): The number of samples inserted at a port before the first actual output sample is produced. This acts as a mathematical register (z^-1) and is absolutely required to break algebraic loops in feedback circuits.

The Consistency Equation

For the TDF solver to function, the assigned timesteps and rates must be mathematically consistent across the entire cluster. The relationship between a module's timestep ($T_m$), a port's timestep ($T_p$), and the port's rate ($R$) is strictly defined as:

$$T_m = T_p \times R$$

Complete Example: Multirate TDF Mixer

Here is a complete, compilable example demonstrates a classic communication system block: a Mixer. It takes an RF input and a Local Oscillator (LO) input, multiplies them to produce an Intermediate Frequency (IF), and then uses a Multirate Decimator to downsample the output.

#include <systemc>
#include <systemc-ams.h>
 
// 1. A Simple TDF Source
SCA_TDF_MODULE(Oscillator) {
    sca_tdf::sca_out<double> out;
    double frequency;
 
    SCA_CTOR(Oscillator) : frequency(1e6) {} // 1 MHz default
 
    void set_attributes() {
        set_timestep(0.1, sc_core::SC_US); // 10 MHz sampling rate
    }
 
    void processing() {
        double t = get_time().to_seconds();
        out.write(std::sin(2.0 * M_PI * frequency * t));
    }
};
 
// 2. The Mixer
SCA_TDF_MODULE(Mixer) {
    sca_tdf::sca_in<double> rf_in;
    sca_tdf::sca_in<double> lo_in;
    sca_tdf::sca_out<double> if_out;
 
    SCA_CTOR(Mixer) {}
 
    void set_attributes() {
        // We do not need to set the timestep here. 
        // The solver will automatically inherit the 0.1us timestep 
        // from the oscillators connected to our inputs.
    }
 
    void processing() {
        // Rates default to 1. We read exactly one sample from each input, 
        // and write exactly one sample to the output.
        double rf_val = rf_in.read();
        double lo_val = lo_in.read();
        if_out.write(rf_val * lo_val); 
    }
};
 
// 3. A Multirate Decimator (Downsampler)
SCA_TDF_MODULE(Decimator) {
    sca_tdf::sca_in<double> in;
    sca_tdf::sca_out<double> out;
 
    SCA_CTOR(Decimator) {}
 
    void set_attributes() {
        // We will read 4 samples for every 1 sample we write out.
        // This makes our output port timestep 4x slower than our input port timestep.
        in.set_rate(4);
        out.set_rate(1);
        
        // We add a delay of 1 sample to the output to demonstrate breaking algebraic loops
        out.set_delay(1);
    }
 
    void initialize() {
        // Because we set a delay of 1 on 'out', we MUST write 1 initial sample 
        // during initialize() to prime the delay buffer.
        out.initialize(0.0);
    }
 
    void processing() {
        double sum = 0;
        // We MUST read exactly 'in.get_rate()' samples (4 samples)
        for (unsigned int i = 0; i < in.get_rate(); ++i) {
            sum += in.read(i); // Read at specific index
        }
        
        // We write exactly 'out.get_rate()' samples (1 sample)
        double average = sum / 4.0;
        out.write(average);
    }
};
 
## Under the Hood: TDF Ports and Ring Buffers
 
To understand why TDF achieves such high execution speeds, look at the implementation of `sca_tdf::sca_in` and `sca_tdf::sca_out`. Unlike standard SystemC `sc_in`, which triggers an `sc_event` and requires a full context switch through the DE kernel on every single read/write, TDF ports are completely decoupled from SystemC events during the continuous `processing()` phase.
 
When the AMS solver finishes the `end_of_elaboration` phase, it calculates the maximum required size for each connected `sca_tdf::sca_signal` based on the sum of the rates and delays of the attached ports. The kernel then pre-allocates an internal C++ `std::vector` (or a flat array acting as a cyclic ring buffer) for each signal.
 
During the `processing()` loop, calling `in.read(i)` or `out.write(val, i)` does not interact with the SystemC kernel at all. It compiles down to a raw, inlined C++ array pointer dereference (`buffer[(read_pointer + i) % size]`). This architecture ensures absolutely zero heap allocations and zero SystemC event evaluations occur during the heavy mathematical processing phase.
 
```cpp
int sc_main(int argc, char* argv[]) {
    // Signals
    sca_tdf::sca_signal<double> sig_rf("sig_rf");
    sca_tdf::sca_signal<double> sig_lo("sig_lo");
    sca_tdf::sca_signal<double> sig_mixed("sig_mixed");
    sca_tdf::sca_signal<double> sig_downsampled("sig_downsampled");
 
    // Modules
    Oscillator rf_osc("rf_osc");
    rf_osc.frequency = 2.1e6; // 2.1 MHz
    rf_osc.out(sig_rf);
 
    Oscillator lo_osc("lo_osc");
    lo_osc.frequency = 2.0e6; // 2.0 MHz
    lo_osc.out(sig_lo);
 
    Mixer mixer("mixer");
    mixer.rf_in(sig_rf);
    mixer.lo_in(sig_lo);
    mixer.if_out(sig_mixed);
 
    Decimator dec("dec");
    dec.in(sig_mixed);
    dec.out(sig_downsampled);
 
    // Tracing
    sca_util::sca_trace_file* tf = sca_util::sca_create_vcd_trace_file("tdf_multirate");
    sca_util::sca_trace(tf, sig_rf, "RF_In");
    sca_util::sca_trace(tf, sig_lo, "LO_In");
    sca_util::sca_trace(tf, sig_mixed, "Mixed_IF");
    sca_util::sca_trace(tf, sig_downsampled, "Downsampled_IF");
 
    sc_core::sc_start(10.0, sc_core::SC_US);
 
    sca_util::sca_close_vcd_trace_file(tf);
    return 0;
}

Key LRM Takeaways

  • Timestep Propagation: You do not need to assign a timestep to every module. The AMS solver automatically propagates timesteps across connected clusters using the consistency equation.
  • Delay Initialization: If you apply set_delay(N) to a port, you must call .initialize(value, index) exactly $N$ times within the initialize() callback to prime the solver's buffers.
  • Multirate Reads/Writes: If a port has a rate $> 1$, you must use the indexed version of read/write (e.g., in.read(i)) and process exactly the required number of samples during processing().

Comments and Corrections