Part 2: Capture Data

In this second part, we will extend the demapper plugin module to capture both inputs and outputs of the demapper to files. The captured data can then be used for analysis, training, and testing of a neural demapper implementation. We focus specifically on capturing data for QPSK and 16-QAM modulation schemes, while allowing the default implementation to handle other modulation formats. Rather than reimplementing the QPSK and 16-QAM demapping algorithms, we leverage the existing OpenAirInterface implementations within our plugin.

The following sections detail the modifications required for data capture, including timestamping and multi-threading support, as well as instructions for accessing the collected data from within the containers.

Adding New Plugin Variant

The full code of the capture plugin is in tutorials/neural_demapper/nr_demapper_capture.c, and also included at the end of this tutorial. To compile this additional variant, we only need to add the following to the CMakeLists.txt file. The rest is handled by the already present demapper plugin. If you installed the tutorials via the provided installation scripts, this is already included.

 1# capture
 2set(PHY_DEMAPPER_CAPTURE_SRC
 3  ${OPENAIR1_DIR}/PHY/NR_TRANSPORT/nr_ulsch_llr_computation.c
 4  nr_demapper_capture.c
 5)
 6
 7add_library(demapper_capture MODULE ${PHY_DEMAPPER_CAPTURE_SRC})
 8target_link_libraries(demapper_capture PRIVATE pthread)
 9
10add_dependencies(nr-softmodem demapper_capture)
11add_dependencies(nr-uesoftmodem demapper_capture)
12add_dependencies(demapper_capture generate_T)

Using New Plugin Variant

After compiling the new variant, you can use it by passing the option --loader.demapper.shlibversion _capture to the executable. Alternatively, you can add the following line to the .env file in your configuration directory:

GNB_EXTRA_OPTIONS="--loader.demapper.shlibversion _capture"

To verify that the capture variant is being used, check the gNB logs for the following message:

[LOADER] library libdemapper_capture.so successfully loaded

Access Captured Files

The module captures inputs and outputs to two files: demapper_in.txt and demapper_out.txt. To access these capture files from the container, you need to mount them to your host system. This can be done by adding volume mappings in your docker-compose.override.yaml file in the corresponding config folder:

cp config/common/docker-compose.override.yaml.template config/b200_arm64/docker-compose.override.yaml

And edit the file accordingly:

Listing 12 Mount capture files
services:
oai-gnb:
    volumes:
    - ../../logs/demapper_in.txt:/opt/oai-gnb/demapper_in.txt
    - ../../logs/demapper_out.txt:/opt/oai-gnb/demapper_out.txt

Create the files before running the container:

# Create the logs directory
mkdir -p logs

# Create the files
touch logs/demapper_in.txt
touch logs/demapper_out.txt

# Make the files writable by the container
chmod 666 logs/demapper_in.txt
chmod 666 logs/demapper_out.txt

Note that the host filepaths can be arbitrary, as long as they are accessible from the host system. The files must exist before running the container.

Capture Format

The captured data is stored in two text files: one containing the input symbols to the demapper and another containing the corresponding demapped output values. This simple text-based format allows for easy inspection and post-processing of the captured data.

The input file format is as follows:

Listing 13 input file format
 1/*
 2 * Input file format (f_in):
 3 * time source resolution (sec.nanosec) - only first line
 4 * timestamp (sec.nanosec)
 5 * modulation scheme (string: QPSK, QAM16)
 6 * nb_re (int32)
 7 * real imag (for QPSK: int16 <space> int16)
 8 * real imag ch_mag.r ch_mag.i (for QAM16: int16 <space> int16 <space> int16 <space> int16)̦̦̦̦̦̦
 9 * ... (done nb_re times)
10 */
Listing 14 Sample input format / demapper_in.txt
0.000000001         # Time source resolution (sec.nanosec)
1373853.185968662   # Timestamp (sec.nanosec)
QPSK                # Modulation scheme (QPSK or QAM16)
96                  # Number of symbols (resource elements)
177 -179            # Input symbols (real, imag)
-179 176
-180 -177
176 178
-177 -177
-177 177

And the output file format is as follows:

Listing 15 output file format
 1/*
 2 * Output file format (f_out):
 3 * time source resolution (sec.nanosec) - only first line
 4 * timestamp (sec.nanosec)
 5 * modulation scheme (string: QPSK, QAM16)
 6 * nb_re (int32)
 7 * real imag llr.r llr.i (int16 <space> int16 <space> int16 <space> int16 )
 8 * --->number of elements in each row depend on the modulation scheme: QPSK: 2 ; QAM16: 4
 9 * ... (done nb_re times)
10 */

With the following sample output format:

Listing 16 Sample output format / demapper_out.txt
0.000000001         # Time source resolution (sec.nanosec)
1373853.185968662   # Timestamp (sec.nanosec)
QPSK                # Modulation scheme (QPSK or QAM16)
96                  # Number of symbols (resource elements)
22 -23              # Output symbols (llr1, llr2)
-23 22
-23 -23
22 22
-23 -23
-23 22

An example to read the input and output files with Python is provided in the Integration of a Neural Demapper tutorial.

Add Timestamps

Including timestamps in the data capture enables precise analysis of the data stream by improving synchronization and sequence ordering of data blocks. Here are the key considerations when adding timestamps:

Resolution: Each file includes the system’s clock resolution to handle platforms with varying timing precision.

Monotonicity: Rapid captures can result in identical timestamps. To prevent this, we use CLOCK_MONOTONIC as the clock source, which guarantees strictly increasing timestamps.

Regularity: We capture timestamps at the module’s entry point directly before processing begins. This ensures the timestamp closely reflects the actual processing time, unaffected by processing duration or write operations.

Account for Multi-Threading

Since this is a multi-threaded application, we need to handle concurrent file writes carefully. Multiple threads will attempt to write data simultaneously, which could lead to corrupted output. Although the data processing is complete by the time we write to files, we still need to synchronize the actual file operations. The solution is straightforward - we use a mutex to ensure only one thread can write at a time. We initialize the mutex in our initialization functions and protect all file write operations with mutex locks:

Listing 17 Mutex for file data writes
static pthread_mutex_t capture_lock = PTHREAD_MUTEX_INITIALIZER;

// ...

void capture_function( ... )
{
    pthread_mutex_lock( &capture_lock );

    // write to files

    pthread_mutex_unlock( &capture_lock );
}

Final Source Code

The original demapping function remains active while the capture function runs in parallel, allowing data collection without impacting the normal operation of the connection. The complete implementation of the capture plugin is shown below.

Listing 18 nr_demapper_capture.c
  1static char *filename_in = "demapper_in.txt";
  2static char *filename_out = "demapper_out.txt";
  3static FILE *f_in;
  4static FILE *f_out;
  5static pthread_mutex_t capture_lock = PTHREAD_MUTEX_INITIALIZER;
  6
  7// import default implementations
  8void nr_ulsch_qpsk_llr(int32_t *rxdataF_comp,
  9                      int16_t  *ulsch_llr,
 10                      uint32_t nb_re,
 11                      uint8_t  symbol);
 12void nr_ulsch_16qam_llr(int32_t *rxdataF_comp, int32_t *ul_ch_mag, int16_t *ulsch_llr, uint32_t nb_re, uint8_t symbol);
 13
 14/* configure which clock source to use, will affect resolution */
 15#define TIMESTAMP_CLOCK_SOURCE CLOCK_MONOTONIC
 16
 17/* time processing functions */
 18void fprint_time( FILE* file, struct timespec *ts )
 19{
 20  fprintf( file, "%jd.%09ld\n", ts->tv_sec, ts->tv_nsec );
 21  return;
 22}
 23
 24// START marker-capture-input-format
 25/*
 26 * Input file format (f_in):
 27 * time source resolution (sec.nanosec) - only first line
 28 * timestamp (sec.nanosec)
 29 * modulation scheme (string: QPSK, QAM16)
 30 * nb_re (int32)
 31 * real imag (for QPSK: int16 <space> int16)
 32 * real imag ch_mag.r ch_mag.i (for QAM16: int16 <space> int16 <space> int16 <space> int16)̦̦̦̦̦̦
 33 * ... (done nb_re times)
 34 */
 35// END marker-capture-input-format
 36
 37// START marker-capture-output-format
 38/*
 39 * Output file format (f_out):
 40 * time source resolution (sec.nanosec) - only first line
 41 * timestamp (sec.nanosec)
 42 * modulation scheme (string: QPSK, QAM16)
 43 * nb_re (int32)
 44 * real imag llr.r llr.i (int16 <space> int16 <space> int16 <space> int16 )
 45 * --->number of elements in each row depend on the modulation scheme: QPSK: 2 ; QAM16: 4
 46 * ... (done nb_re times)
 47 */
 48// END marker-capture-output-format
 49
 50
 51void capture_qpsk(
 52            int32_t *rxdataF_comp,
 53            int16_t *ulsch_llr,
 54            uint32_t nb_re,
 55            struct timespec *ts )
 56{
 57    pthread_mutex_lock( &capture_lock );
 58
 59    c16_t *rxF = (c16_t *)rxdataF_comp;
 60
 61    fprint_time( f_in, ts );
 62    fprintf( f_in, "QPSK\n" );
 63    fprintf( f_in, "%d\n", nb_re );
 64    for(int i = 0; i < nb_re; i++ )
 65      fprintf( f_in, "%hd %hd\n", rxF[i].r, rxF[i].i );
 66    fflush( f_in );
 67
 68    fprint_time( f_out, ts );
 69    fprintf( f_out, "QPSK\n" );
 70    fprintf( f_out, "%d\n", nb_re );
 71    for(int i = 0; i < nb_re; i++ )
 72      fprintf( f_out, "%hd %hd\n", ulsch_llr[2*i+0], ulsch_llr[2*i+1] );
 73    fflush( f_out );
 74
 75    pthread_mutex_unlock( &capture_lock );
 76}
 77
 78/*
 79 * from the reference implementation of test_llr.cpp
 80 * in: rxdataF_comp[nb_re] , each element is a complex number with .r and .i components. Each component is int16_t
 81 * in: ul_ch_mag[nb_re] , casted then to int16_t it becomes an array of [2*nb_re]. first component is the mag_real, second is mag_imag. both in int16_t
 82 * out: ulsch_llr[4*nb_re]. each group of 4 are: .r, .i , saturating_sub(mag_real, std::abs(.r) , saturating_sub(mag_imag, std::abs(.i)
 83 */
 84
 85void capture_qam16(
 86            int32_t *rxdataF_comp,
 87            int32_t *ul_ch_mag,
 88            int16_t *ulsch_llr,
 89            uint32_t nb_re,
 90            struct timespec *ts )
 91{
 92    pthread_mutex_lock( &capture_lock );
 93
 94    c16_t *rxF = (c16_t *)rxdataF_comp;
 95    int16_t *ul_ch_mag_i16 = (int16_t *)ul_ch_mag;
 96
 97    fprint_time( f_in, ts );
 98    fprintf( f_in, "QAM16\n" );
 99    fprintf( f_in, "%d\n", nb_re );
100    for(int i = 0; i < nb_re; i++ )
101      fprintf( f_in, "%hd %hd %hd %hd\n", rxF[i].r, rxF[i].i, ul_ch_mag_i16[2*i+0], ul_ch_mag_i16[2*i+1] );
102    fflush( f_in );
103
104    fprint_time( f_out, ts );
105    fprintf( f_out, "QAM16\n" );
106    fprintf( f_out, "%d\n", nb_re );
107    for(int i = 0; i < nb_re; i++ )
108      fprintf( f_out, "%hd %hd %hd %hd\n", ulsch_llr[4*i+0], ulsch_llr[4*i+1], ulsch_llr[4*i+2], ulsch_llr[4*i+3] );
109    fflush( f_out );
110
111    pthread_mutex_unlock( &capture_lock );
112}
113
114// Plugin Init / Shutdown
115
116int32_t demapper_init( void )
117{
118    // initialize capture mutex
119    pthread_mutex_init( &capture_lock, NULL );
120
121    // open capture files
122    f_in = fopen( filename_in, "w" );
123    AssertFatal( f_in != NULL, "Cannot open file %s for writing\n", filename_in );
124
125    f_out = fopen( filename_out, "w");
126    AssertFatal( f_out != NULL, "Cannot open file %s for writing\n", filename_out );
127
128    // print clock resolution
129    struct timespec ts;
130    clock_getres( TIMESTAMP_CLOCK_SOURCE, &ts );
131    fprint_time( f_in, &ts );
132    fprint_time( f_out, &ts );
133
134    return 0;
135}
136
137int32_t demapper_shutdown( void )
138{
139    fclose( f_in );
140    fclose( f_out );
141
142    pthread_mutex_destroy( &capture_lock );
143
144    return 0;
145}
146
147int demapper_compute_llr(int32_t *rxdataF_comp,
148                         int32_t *ul_ch_mag,
149                         int32_t *ul_ch_magb,
150                         int32_t *ul_ch_magc,
151                         int16_t *ulsch_llr,
152                         uint32_t nb_re,
153                         uint8_t  symbol,
154                         uint8_t  mod_order) {
155  struct timespec ts;
156  clock_gettime( TIMESTAMP_CLOCK_SOURCE, &ts );
157
158  switch(mod_order){
159    case 2:
160    nr_ulsch_qpsk_llr(rxdataF_comp,
161                        ulsch_llr,
162                        nb_re,
163                        symbol);
164    capture_qpsk(rxdataF_comp, ulsch_llr, nb_re , &ts);
165      break;
166    case 4:
167    nr_ulsch_16qam_llr(rxdataF_comp,
168                         ul_ch_mag,
169                         ulsch_llr,
170                         nb_re,
171                         symbol);
172    capture_qam16(rxdataF_comp, ul_ch_mag, ulsch_llr, nb_re, &ts);
173      break;
174
175#if 0 // disabled
176    case 6:
177    nr_ulsch_64qam_llr(rxdataF_comp,
178                       ul_ch_mag,
179                       ul_ch_magb,
180                       ulsch_llr,
181                       nb_re,
182                       symbol);
183      break;
184    case 8:
185    nr_ulsch_256qam_llr(rxdataF_comp,
186                        ul_ch_mag,
187                        ul_ch_magb,
188                        ul_ch_magc,
189                        ulsch_llr,
190                        nb_re,
191                        symbol);
192      break;
193#endif
194
195    default:
196      AssertFatal(1==0,"capture_demapper_compute_llr: invalid/unhandled Qm value, symbol = %d, Qm = %d\n",symbol, mod_order);
197      return 0; // unhandled
198  }
199  return 1; // handled
200}