Verifying Code with C Simulation

Verification in the Vitis HLS flow can be separated into two distinct processes.

  • Pre-synthesis validation that the C program correctly implements the required functionality.
  • Post-synthesis verification that the generated RTL code performs as expected.

Both processes are referred to as simulation: C simulation and C/RTL co-simulation.

Before synthesis, the function to be synthesized should be validated with a test bench using C simulation. A C test bench includes a main() top-level function, that calls the function to be synthesized by the Vitis HLS project. The test bench can also include other functions. An ideal test bench has the following features:

  • The test bench is self-checking, and validates that the results from the function to be synthesized are correct.
  • If the results are correct the test bench returns a value of 0 to main(). Otherwise, the test bench should return any non-zero value.

In the Vitis HLS GUI, clicking the Run C Simulation toolbar button opens the C Simulation Dialog box, as shown in the following figure:

Figure 1: C Simulation Dialog Box

The options for the C Simulation Dialog box include the following:

Launch Debugger
This compiles the C code and automatically opens the Debug perspective. From within the Debug perspective, the Synthesis perspective button (top left) can be used to return the windows to the Synthesis perspective.
Build Only
Compiles the source code and test bench, but does not run simulation. This option can be used to test the compilation process and resolve any issues with the build prior to running simulation. It generates a csim.exe file that can be used to launch simulation from a command shell.
Clean Build
Remove any existing executable and object files from the project before compiling the code.
Optimizing Compile
By default the design is compiled with debug information enabled, allowing the compilation to be analyzed and debugged. The Optimizing Compile option uses a higher level of optimization effort when compiling the design, but does not add information required by the debugger. This increases the compile time but should reduce the simulation runtime.
TIP: The Launch Debugger and Optimizing Compile options are mutually exclusive. Selecting one in the C Simulation Dialog box disables the other.
Enable Pre-Synthesis Control Flow Viewer
Generates the Pre-synthesis Control Flow report as described in Pre-Synthesis Control Flow.
Input Arguments
Specify any inputs required by your test bench main() function.
Do not show this dialog box again
Lets you disable the display of the C Simulation Dialog box.
TIP: You can re-enable the display of the C Simulation Dialog box by selecting Project > Project Settings and selecting the Simulation settings.
After clicking OK in the dialog box, the C code is compiled and the C simulation is run. As the simulation runs, the console displays any printf statements from the test bench. When the simulation completes successfully, the following message is also returned to the console:
INFO: [SIM 211-1] CSim done with 0 errors.
INFO: [SIM 211-3] *************** CSIM finish ***************
Finished C simulation.
When the simulation fails, an error is returned:
@E Simulation failed: Function 'main' returns nonzero value '1'.
ERROR: [SIM 211-100] 'csim_design' failed: nonzero return value.
INFO: [SIM 211-3] *************** CSIM finish ***************

If you select the Launch Debugger option, the tool automatically switches to the Debug layout view as shown in the following figure. The simulation is started, but lets you step through the code to observe and debug the function. This is a full featured debug environment: you can step into and over code, specify breakpoints, and observe and set the value of variables in the code.

Figure 2: C Debug Environment
TIP: You can return to the Synthesis layout view by selecting the Window > Synthesis.

Writing a Test Bench

When using the Vitis HLS design flow, it is time consuming to synthesize an improperly coded C function and then analyze the implementation details to determine why the function does not perform as expected. Therefore, the first step in high-level synthesis should be to validate that the C function is correct, before generating RTL code, by performing simulation using a well written test bench. Writing a good test bench can greatly increase your productivity, as C functions execute in orders of magnitude faster than RTL simulations. Using C to develop and validate the algorithm before synthesis is much faster than developing and debugging RTL code.

Vitis HLS uses the test bench to compile and execute the C simulation. During the compilation process, you can select the Launch Debugger option to open a full C-debug environment, which enables you to more closely analyze the C simulation. Vitis HLS also uses the test bench to verify the RTL output of synthesis as described in C/RTL Co-Simulation in Vitis HLS.

The test bench includes the main() function, as well as any needed sub-functions that are not in the hierarchy of the top-level function designated for synthesis by Vitis HLS. The main function verifies that the top-level function for synthesis is correct by providing stimuli and calling the function for synthesis, and by consuming and validating its output.

IMPORTANT: The test bench can accept input arguments that can be provided when C simulation is launched, as described in Verifying Code with C Simulation. However, the test bench must not require interactive user inputs during execution. The Vitis HLS GUI does not have a command console, and therefore cannot accept user inputs while the test bench executes.

The following code shows the important features of a self-checking test bench, as an example:

int main () { 
  //Esablish an initial return value. 0 = success
  int ret=0;

  // Call any preliminary functions required to prepare input for the test.
  …
  …// Call the top-level function multiple times, passing input stimuli as needed.
  for(i=0; i<NUM_TRANS; i++){
     top_func(input, output);
  }

  // Capture the output results of the function, write to a file
  …
  // Compare the results of the function against expected results
  ret = system("diff --brief  -w output.dat output.golden.dat");
  
  if (ret != 0) {
        printf("Test failed  !!!\n"); 
        ret=1;
  } else {
        printf("Test passed !\n"); 
  }
  …
  return ret;
}

The test bench should execute the top-level function for multiple transactions, allowing many different data values to be applied and verified. The test bench is only as good as the variety of tests it performs. In addition, your test bench must provide multiple transactions if you want to calculate II during RTL simulation as described in C/RTL Co-Simulation in Vitis HLS.

This self-checking test bench compares the results of the function, output.dat, against known good results in output.golden.dat. This is just one example of a self-checking test bench. There are many ways to validate your top-level function, and you must code your test bench as appropriate to your code.

In the Vitis HLS design flow, the return value of function main() indicates the following:

  • Zero: Results are correct.
  • Non-zero value: Results are incorrect.

The test bench can return any non-zero value. A complex test bench can return different values depending on the type of failure. If the test bench returns a non-zero value after C simulation or C/RTL co-simulation, Vitis HLS reports an error and simulation fails.

TIP: Because the system environment (for example, Linux, Windows, or Tcl) interprets the return value of the main() function, it is recommended that you constrain the return value to an 8-bit range for portability and safety.

Of course, the results of simulation are only as good as the test bench you provide. You are responsible for ensuring that the test bench returns the correct result. If the test bench returns zero, Vitis HLS indicates that the simulation has passed, regardless of what occurred during simulation.

Example Test Bench

Xilinx recommends that you separate the top-level function for synthesis from the test bench, and that you use header files. The following code example shows a design in which the top-level function for the HLS project, hier_func, calls two sub-functions:

  • sumsub_func performs addition and subtraction.
  • shift_func performs shift.

The data types are defined in the header file (hier_func.h). The code for the function follows:

#include "hier_func.h"

int sumsub_func(din_t *in1, din_t *in2, dint_t *outSum, dint_t *outSub)
{
 *outSum = *in1 + *in2;
 *outSub = *in1 - *in2;
}

int shift_func(dint_t *in1, dint_t *in2, dout_t *outA, dout_t *outB)
{
 *outA = *in1 >> 1;
 *outB = *in2 >> 2;
}

void hier_func(din_t A, din_t B, dout_t *C, dout_t *D)
{
 dint_t apb, amb;

 sumsub_func(&A,&B,&apb,&amb);
 shift_func(&apb,&amb,C,D);
}

As shown, the top-level function can contain multiple sub-functions. There can only be one top-level function for synthesis. To synthesize multiple functions, group them as sub-functions of a single top-level function.

The header file (hier_func.h), shown below, demonstrates how to use macros and how typedef statements can make the code more portable and readable.

TIP: Arbitrary Precision (AP) Data Types discusses arbitrary precision data types, and how the typedef statement allows the types and therefore the bit-widths of the variables to be refined for both area and performance improvements in the final FPGA implementation.
#ifndef _HIER_FUNC_H_
#define _HIER_FUNC_H_

#include <stdio.h>

#define NUM_TRANS 40

typedef int din_t;
typedef int dint_t;
typedef int dout_t;

void hier_func(din_t A, din_t B, dout_t *C, dout_t *D);

#endif

The header file above includes some #define statements, such as NUM_TRANS, that are not required by the hier_func function, but are provided for the test bench, which also includes the same header file.

The following code defines a test bench for the hier_func design:

#include "hier_func.h"

int main() {
 // Data storage
 int a[NUM_TRANS], b[NUM_TRANS];
 int c_expected[NUM_TRANS], d_expected[NUM_TRANS];
 int c[NUM_TRANS], d[NUM_TRANS];

  //Function data (to/from function)
 int a_actual, b_actual;
 int c_actual, d_actual;

  // Misc
 int retval=0, i, i_trans, tmp;
 FILE *fp;

 // Load input data from files
 fp=fopen(tb_data/inA.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 a[i] = tmp;
 } 
 fclose(fp);

 fp=fopen(tb_data/inB.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 b[i] = tmp;
 } 
 fclose(fp);

 // Execute the function multiple times (multiple transactions)
 for(i_trans=0; i_trans<NUM_TRANS-1; i_trans++){

 //Apply next data values
 a_actual = a[i_trans];
 b_actual = b[i_trans];

  hier_func(a_actual, b_actual, &c_actual, &d_actual);

 //Store outputs
 c[i_trans] = c_actual;
 d[i_trans] = d_actual;
 }

 // Load expected output data from files
 fp=fopen(tb_data/outC.golden.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 c_expected[i] = tmp;
 } 
 fclose(fp);

 fp=fopen(tb_data/outD.golden.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 d_expected[i] = tmp;
 } 
 fclose(fp);

 // Check outputs against expected
 for (i = 0; i < NUM_TRANS-1; ++i) {
 if(c[i] != c_expected[i]){
 retval = 1;
 }
 if(d[i] != d_expected[i]){
 retval = 1;
 }
 }

 // Print Results
 if(retval == 0){
 printf(    *** *** *** *** \n); 
 printf(    Results are good \n); 
 printf(    *** *** *** *** \n); 
 } else {
 printf(    *** *** *** *** \n); 
 printf(    Mismatch: retval=%d \n, retval); 
 printf(    *** *** *** *** \n); 
 }

 // Return 0 if outputs are corre
 return retval;
}

Design Files and Test Bench Files

Because Vitis HLS reuses the C test bench for RTL verification, it requires that the test bench and any associated files be denoted as test bench files when they are added to the Vitis HLS project. Files associated with the test bench are any files that are:

  • Accessed by the test bench.
  • Required for the test bench to operate correctly.

Examples of such files include the data files inA.dat and inB.dat in the example test bench. You must add these to the Vitis HLS project as test bench files.

The requirement for identifying test bench files in a Vitis HLS project does not require that the design and test bench be in separate files (although separate files are recommended). To demonstrate this, a new example is defined from the same code used in Example Test Bench, except a new top-level function is defined. In this example the function sumsub_func is defined as the top-level function in the Vitis HLS project.

TIP: You can change the top-level function by selecting the Project Settings command from the Flow Navigator, selecting the Synthesis settings, and specifying a new Top Function.

With the sumsub_func function defined as the top-level function, the higher-level function, hier_func becomes part of the test bench, as it is the calling function for sumsub_func. The peer-level shift_func function is also now part of the test bench, as it is a required part of the test. Even though these functions are in the same code file as the top-level sumsub_func function, they are part of the test bench.

Single File Test Bench and Design

You can also include the design and test bench into a single design file. The following example has the same hier_func function as discussed Example Test Bench, except that everything is coded in a single file: top-level function, sub functions, and main function for the test bench.

IMPORTANT: Having both the test bench and design in a single file requires you to add that file to the Vitis HLS project as both a design file, and a test bench file.
#include <stdio.h>

#define NUM_TRANS 40

typedef int din_t;
typedef int dint_t;
typedef int dout_t;

int sumsub_func(din_t *in1, din_t *in2, dint_t *outSum, dint_t *outSub)
{
 *outSum = *in1 + *in2;
 *outSub = *in1 - *in2;
}

int shift_func(dint_t *in1, dint_t *in2, dout_t *outA, dout_t *outB)
{
 *outA = *in1 >> 1;
 *outB = *in2 >> 2;
}

void hier_func(din_t A, din_t B, dout_t *C, dout_t *D)
{
 dint_t apb, amb;

 sumsub_func(&A,&B,&apb,&amb);
 shift_func(&apb,&amb,C,D);
}

int main() {
 // Data storage
 int a[NUM_TRANS], b[NUM_TRANS];
 int c_expected[NUM_TRANS], d_expected[NUM_TRANS];
 int c[NUM_TRANS], d[NUM_TRANS];

 //Function data (to/from function)
 int a_actual, b_actual;
 int c_actual, d_actual;

 // Misc
 int retval=0, i, i_trans, tmp;
 FILE *fp;
 // Load input data from files
 fp=fopen(tb_data/inA.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 a[i] = tmp;
 } 
 fclose(fp);

 fp=fopen(tb_data/inB.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 b[i] = tmp;
 } 
 fclose(fp);

// Execute the function multiple times (multiple transactions)
for(i_trans=0; i_trans<NUM_TRANS-1; i_trans++){

 //Apply next data values
 a_actual = a[i_trans];
 b_actual = b[i_trans];

 hier_func(a_actual, b_actual, &c_actual, &d_actual);
    
 //Store outputs
 c[i_trans] = c_actual;
 d[i_trans] = d_actual;
 }

 // Load expected output data from files
 fp=fopen(tb_data/outC.golden.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 c_expected[i] = tmp;
 } 
 fclose(fp);

 fp=fopen(tb_data/outD.golden.dat,r);
 for (i=0; i<NUM_TRANS; i++){
 fscanf(fp, %d, &tmp);
 d_expected[i] = tmp;
 } 
 fclose(fp);

 // Check outputs against expected
 for (i = 0; i < NUM_TRANS-1; ++i) {
 if(c[i] != c_expected[i]){
 retval = 1;
 }
 if(d[i] != d_expected[i]){
 retval = 1;
 }
 }

 // Print Results
 if(retval == 0){
 printf(    *** *** *** *** \n); 
 printf(    Results are good \n); 
 printf(    *** *** *** *** \n); 
 } else {
 printf(    *** *** *** *** \n); 
 printf(    Mismatch: retval=%d \n, retval); 
 printf(    *** *** *** *** \n); 
 }

 // Return 0 if outputs are correct
 return retval;
}

Using the Debug View Layout

You can view the values of variables and expressions directly in the Debug view layout. The following figure shows how you can monitor the value of individual variables. In the Variables view, you can edit the values of variables to force the variable to a specific state for instance.

Figure 3: Monitoring Variables

You can monitor the value of expressions using the Expressions tab.

Figure 4: Monitoring Expressions

Output of C Simulation

When C simulation completes, a csim folder is created inside the solution folder. This folder contains the following elements:
  • csim/build: The primary location for all files related to the C simulation
    • Any files read by the test bench are copied to this folder.
    • The C executable file csim.exe is created and run in this folder.
    • Any files written by the test bench are created in this folder.
    • csim/obj: Contains object files (.o) for the compiled source code, and make dependency files (.d) for the source code build.
  • csim/report: Contains a log file of the C simulation build and run.

Pre-Synthesis Control Flow

IMPORTANT: This feature is only available on Linux platforms, and is not supported on Windows systems.

You can generate the Pre-Synthesis Control Flow Graph (CFG) as an option from the Run C Simulation dialog box. Select the Enable Pre-Synthesis Control Flow Viewer check box on the dialog box to generate the report. After generating the report you can open it by selecting it from the C Simulation > Reports & Viewers section of the Flow Navigator.

The Pre-Synthesis Control Flow viewer helps you to identify the hot spots in your function, the compute-intensive control structures, and to apply pragmas or directives to improve or optimize the results. The CFG shows the control flow through your C code, as shown in the following figure, to help you visualize the top-level function. The CFG also provides static profiling, such as the trip-count of loops, and dynamic profiling information to analyze the design.

Figure 5: Pre-Synthesis Control Flow Viewer

As shown in the figure above, the Pre-Synthesis Control Flow viewer has multiple elements:

  • Function Call Tree on the upper left.
  • Control Flow Graph (CFG) in the middle.
  • Source Code viewer on the upper right.
  • Loops view in the lower Console area that is associated with, and provides cross-probing with the CFG viewer.

Selecting a sub-function or loop in one view, also selects it in other views. This lets you quickly navigate the hierarchy of your code. Every function call can be further expanded to see the control structure of the loops and condition statements. Click on the control structure to view the source code.

Double-clicking on a function in the Function Tree opens that level of the code hierarchy in the CFG, single clicking in the Function Tree simply selects the function. The CFG can be expanded to show the hierarchy of the design using the Expand function call command display the function levels of hierarchy specified.

You can also type in the Search field of the Function Call Tree to highlight the first matching occurrence of the typed text. You can use this to quickly navigate through your code.

The CFG can help you analyze the dynamic behavior of your function, reporting the number of times the function or sub-function executed on different control paths. Loops are often a source of computing intensity, and the Loops window provides statistics such as access time, total and average loop iterations (tripcount). This information regarding the respective loops can be found in the Loops view, which has cross-linking capabilities. Clicking on a loop will highlight both the source code and the control structure.

Memory operations can also be annotated in the CFG viewer, identifying another area for possible performance optimization.