Profiling a Communication Chain#

In this tutorial, you will learn how to profile a communication chain using comnumpy. Profiling measures the computational cost of each processor, helping you identify performance bottlenecks in complex simulations.

You will build an OFDM communication chain with channel effects, run the simulation, and visualize the profiling results.

What you’ll learn:

  • How to build an OFDM communication chain with channel effects.

  • How to use plot_chain_profiling to measure per-processor execution time.

  • How to identify computational bottlenecks in a simulation chain.

Introduction#

Import Libraries#

We start by importing the necessary libraries:

import numpy as np
import matplotlib.pyplot as plt
from comnumpy.core import Sequential
from comnumpy.core.generators import SymbolGenerator
from comnumpy.core.mappers import SymbolMapper, SymbolDemapper
from comnumpy.core.channels import (AWGN, FIRChannel,
                                    TappedDelayLineChannel)
from comnumpy.core.fading import get_delay_profile
from comnumpy.core.processors import Serial2Parallel, Parallel2Serial
from comnumpy.core.utils import Constellation
from comnumpy.core.visualizers import plot_chain_profiling
from comnumpy.ofdm.processors import CarrierAllocator, FFTProcessor, IFFTProcessor, CyclicPrefixer, CyclicPrefixRemover, CarrierExtractor
from comnumpy.ofdm.compensators import FrequencyDomainEqualizer
from comnumpy.ofdm.allocation import get_allocation
from comnumpy import style

style.use()

Define Parameters#

Next, we define the communication and channel parameters:


fs = 5e6        # Sample rate the channel is resolved at
N_cp = 10       # Cyclic prefix length
N = 100000      # Number of symbols
sigma2 = 0.01   # Noise variance

constellation = Constellation("QAM", 16)
allocation = get_allocation("802.11ac-40")  # 128 subcarriers, from the catalog

N_carriers = allocation.N_fft
N_carrier_data = allocation.N_data       # Number of data carriers
N_carrier_pilots = allocation.N_pilots   # Number of pilot carriers

channel_model = TappedDelayLineChannel(get_delay_profile("TDL-D"), fs=fs,
                                       seed=5)
h = channel_model.impulse_response()
pilots = 10 * np.ones(N_carrier_pilots)  # Pilot values

OFDM Communication Chain#

Define the Chain#

We build a complete OFDM chain using the Sequential object. This chain includes mapping, carrier allocation, IFFT/FFT processing, cyclic prefix handling, channel effects, equalization, and demapping.

chain = Sequential([
    SymbolGenerator(constellation.order),
    SymbolMapper(constellation),
    Serial2Parallel(N_carrier_data),
    CarrierAllocator(carrier_type=allocation, pilots=pilots),
    IFFTProcessor(),
    CyclicPrefixer(N_cp),
    Parallel2Serial(),
    FIRChannel(h),
    AWGN(sigma2=sigma2),
    Serial2Parallel(N_carriers + N_cp),
    CyclicPrefixRemover(N_cp),
    FFTProcessor(),
    FrequencyDomainEqualizer(h=h),
    CarrierExtractor(allocation),
    Parallel2Serial(),
    SymbolDemapper(constellation)
])

The chain is composed of the following processors:

  • SymbolGenerator Generates a sequence of integer-valued symbols to transmit.

  • SymbolMapper Maps integers to QAM constellation points.

  • Serial2Parallel / Parallel2Serial Reshape data between serial and parallel streams, as required by OFDM processing.

  • CarrierAllocator Assigns data and pilot symbols to their designated subcarriers.

  • IFFTProcessor / FFTProcessor Perform the Inverse Fast Fourier Transform and Fast Fourier Transform operations, respectively.

  • CyclicPrefixer / CyclicPrefixRemover Add and remove the cyclic prefix to prevent inter-symbol interference.

  • FIRChannel Models a frequency-selective multipath channel.

  • AWGN Adds white Gaussian noise.

  • FrequencyDomainEqualizer Compensates for channel distortion in the frequency domain.

  • CarrierExtractor Extracts data and pilot carriers after equalization.

  • SymbolDemapper Maps received constellation points back to integer symbols.

The chain, as the chain itself describes it:

        flowchart LR
    generator["SymbolGenerator"]
    symbol_mapper["SymbolMapper"]
    s2_p["Serial2Parallel"]
    carrier_allocator["CarrierAllocator"]
    ifft["IFFTProcessor"]
    cp_adder["CyclicPrefixer"]
    p2_s["Parallel2Serial"]
    fir["FIRChannel"]
    awgn["AWGN"]
    s2_p_2["Serial2Parallel"]
    cp_remover["CyclicPrefixRemover"]
    fft["FFTProcessor"]
    frequency_domain_equalizer["FrequencyDomainEqualizer"]
    carrier_extractor["CarrierExtractor"]
    p2_s_2["Parallel2Serial"]
    symbol_demapper["SymbolDemapper"]
    generator --> symbol_mapper
    symbol_mapper --> s2_p
    s2_p --> carrier_allocator
    carrier_allocator --> ifft
    ifft --> cp_adder
    cp_adder --> p2_s
    p2_s --> fir
    fir --> awgn
    awgn --> s2_p_2
    s2_p_2 --> cp_remover
    cp_remover --> fft
    fft --> frequency_domain_equalizer
    frequency_domain_equalizer --> carrier_extractor
    carrier_extractor --> p2_s_2
    p2_s_2 --> symbol_demapper
    

The diagram above is not drawn by hand. It is what the chain says about itself – chain.to_mermaid() (decision D33c) – exported by the script, so the block names are the ones the code uses and a dashed outline marks an observed block:

mermaid_dir = "../../docs/getting_started/mermaid/"
for diagram_name, diagram_chain in [("profiling_chain", chain)]:
    with open(f"{mermaid_dir}/{diagram_name}.mmd", "w") as stream:
        stream.write(diagram_chain.to_mermaid())

Profiling the Chain#

To profile the chain, we use the plot_chain_profiling function. This function measures the execution time of each processor for a given input size and produces a bar chart of the results.

plot_chain_profiling(chain, input=N)
plt.savefig("../../docs/getting_started/img/profiling_chain_fig1.png")

The resulting figure shows the time spent in each processor, making it easy to identify which stages dominate the computation.

../_images/profiling_chain_fig1.png

The time axis is logarithmic, and it has to be: a chain spans several decades. Here the cyclic-prefix remover costs a few microseconds – it is a slice – while the symbol demapper costs some twenty milliseconds, four decades more, because it computes the distance from every sample to every constellation point. On a linear axis that single block would fill the figure and every other one would be a line against zero.

Two things follow from reading it. Optimizing anything but the demapper here would be wasted work, and the two FFT-based blocks – which are the ones people expect to be expensive – cost about a millisecond for 100 000 symbols.

Note

plot_chain_profiling repeats the chain N_test times and shows the distribution, which is why the boxes have whiskers and outliers: a single timing on a shared machine is not a measurement. For a one-shot table of what each block hands to the next, with its shape, its dtype and its time, use summary() instead – it is the textual counterpart of this figure.

Conclusion#

You have successfully profiled an OFDM communication chain with comnumpy.

Profiling is a powerful tool to:

  • Detect computational bottlenecks in complex simulations.

  • Compare the efficiency of different processors or chain configurations.

  • Optimize large-scale communication scenarios.

From here, you may want to explore:

  • Profiling different modulation schemes or OFDM sizes.

  • Comparing different equalization techniques.

  • Combining profiling with performance metrics such as SER or BER.