Stealing Debug Pretty Print from Vitis HLS

GNU Debugger Logo

I learned that this is the GNU Debugger logo!

Screenshot of the Xilinx Vitis HLS AP Documentation

When working on a high-level synthesis (HLS) hardware design project, I usually start with “C-simulation.” With this type of simulation, I compile my C++ HLS code along with a testbench (typically an int main() function) and run the resulting binary to check if the code is functionally correct.

Checking functional correctness involves things like comparing results with a golden model using C++ asserts, or dumping output to a file for further analysis in Python.

A good HLS designer also knows C-simulation does have significant limitations. It completely misses many issues that are hardware specific—things you’d catch by running RTL cosimulation after HLS synthesis. But this is another rabbit hole for a different time.

Still, C-simulation is considered “fast”: you just recompile your C++ code (Clang is used behind the scenes) and run it. I say “fast” in air quotes because I lied. The Vitis HLS-specific header libraries for things like vendor datatypes and streams can absolutely wreck compile times. Even for a simple C++ HLS design, compile times can balloon past 5 seconds when including these header files, which adds up when trying to iterate quickly.

Anyways, C-simulation also lets me debug things in my own development environment instead of the godforsaken Vitis HLS GUI or new Vitis GUI. Rather than using the GUI, I can run the csim_design flow to generate and run the simulation binary all with tcl scripts and my own build scripts as well as have VS Code launch GDB pointing to the generated "csim" binary for my own debugging experience. Even better, if I just want to build the binary and not run it, I can just use the csim_design -setup command instead. This can all be wrapped in Python scripts, makefiles, justfiles, and so on.

Anyways, I now have my own slick workflow where I compile my code via the csim_design command (which uses the shipped Clang compiler for Vitis HLS) and run GDB for debugging of my HLS code in the GDB interactive command line or my own VS Code IDE.

Here is an example of a combined kernel / testbench I might want to debug:

#include <stdio.h>
#include "ap_fixed.h"

using fixed_t = ap_fixed<16, 8>;

fixed_t add(fixed_t a, fixed_t b) {
    #pragma HLS top
    return a + b;
}

int main() {
    fixed_t a = 3.75;
    fixed_t b = 2.125;
    fixed_t result = add(a, b);
    printf("Testing Vitis HLS Fixed-Point Adder...\n");
    printf("%f + %f = %f\n", a.to_float(), b.to_float(), result.to_float());
    return 0;
}

Let's run the C-simulation and see what GDB interprets my variables to be, as shown in the VS Code debugger sidebar:

- a = {…}
  - ap_fixed_base<16, 8, true, AP_TRN, AP_WRAP, 0> (base) = ap_fixed_base<16, 8, true, AP_TRN, AP_WRAP, 0>
  - ssdm_int_sim<16, true> (base) = ssdm_int_sim<16, true>
    - V
      - mask = 65535
      - not_mask = 18446744073709486080
      - sign_bit_mask = 9223372036854775808
      - width = 16
      - VAL = 960
      - valid = true
    - width = 16
    - iwidth = 8
    - qmode = AP_TRN
    - omode = AP_WRAP
- b...
- c...

Sad news. We only get the internal representation of the ap_fixed type (represented as a special integer type under the hood). Dont get me wrong, this information is very usefule. However most of the time I just want to see a decimal number printed that represents the value of the fixed-point type.

My perfect ergonomic debugging experience is ruined by Xilinx's custom HLS types.

Pretty Printing HLS Types From Scratch

Not to fear, I know a guy (i.e. my labmate Rishov Sarkar) who knows about GDB pretty printing. GDB lets you load custom Python scripts that you can write to provide custom pretty printing for specific types you want to target with custom logic. This is perfect: we write a GDB pretty print script that identifies ap_fixed types and computes the human-readable value of the type from the underlying data representation. We can then automatically load this script when running GDB on our csim binary, and we should see the pretty printed values in the VS Code debugger sidebar.

We wrote our own script with the proper pretty printing logic. Here is an excerpt of the most relevent parts:

class ApFixedBasePrinter:
    ...
    def to_string(self):
        V = self.val["V"]

        raw_val = int(V["VAL"])  # The underlying integer bits
        width = int(self.val["width"])
        iwidth = int(self.val["iwidth"])

        # Number of fractional bits:
        fwidth = width - iwidth

        # The floating‐point value is integer_value / 2^(fractional_bits).
        # In Python, that’s easiest as:
        if fwidth > 0:
            real_value = raw_val / float(1 << fwidth)
        else:
            # purely integer type if fwidth = 0
            real_value = float(raw_val)

        return f"{real_value}"

def ap_fixed_lookup_function(val):
    ...
    if typename.startswith("ap_fixed_base"):
        return ApFixedBasePrinter(val)
    return None

We take the underlying integer representation as well as the size of fractional and integer parts and compute a Python float value that represents the underlying fixed-point value. GDB then shows this floating-point equivalent as a normal-looking decimal number.

Once this pretty print script is loaded, we see what we expect in the VSCode debugger sidebar:

- a = {…}
  - ap_fixed_base<...> = 3.75
- b = {…}
  - ap_fixed_base<...> = 2.125
- c = {…}
  - ap_fixed_base<...> = 5.875

Yay! Problem solved, kinda.

Stealing Vitis's Pretty Printing

What I'm really curious about is how the Vitis GUI (basically a revamped VSCode-like editor itself) has proper debug pretty printing enabled by default.

After poking around in the Vitis 2024.2 install directory, I struck gold with the .../Vitis/2024.2/tps/lnx64/gdb-9.2/bin/gdbinit file.

Turns out, the pretty print logic that Vitis has supports all the HLS vendor types including ap_int, ap_uint, ap_fixed, ap_ufixed, and ap_float. We only wrote the pretty print logic for ap_fixed ourselves, so this is awesome.

Here’s a snippet of just the ap_fixed pretty print logic from Vitis, so you can compare it with what we came up with:

def hex_to_signed_int(hex_str, n, signed):
    unsigned_int = int(hex_str, 16)
    binary_str = bin(unsigned_int)[2:].zfill(n)
    if signed and binary_str[0] == "1":
        return -1 * binary_to_complement(binary_str, n)
    else:
        return int(binary_str, 2)


def binary_to_complement(binary_str, n):
    unsigned_int = int(binary_str, 2)
    complement = unsigned_int ^ ((1 << n) - 1)
    return complement + 1

class AP_FIXED:
    def __init__(self, val, basic_type):
        self.val = val
        self.ta = template_args(val)
        self.signed = True if basic_type == "ap_fixed" else False

    def to_string(self):
        W = int(self.ta[0])
        I = int(self.ta[1])
        mask = int(self.val["V"]["mask"])
        if W > 0:
            width = W
            if width <= 64:
                VAL = int(self.val["V"]["VAL"])
                VAL = VAL & ((1 << width) - 1)
                hex_tmp = hex(VAL)
                integer = hex_to_signed_int(hex_tmp, width, self.signed)
                move_bits = W - I
                if move_bits >= 0:
                    f = float(integer) / (1 << move_bits)
                    return str(f) + " (" + hex_tmp + ")"
                else:
                    move_bits = -move_bits
                    f = integer << move_bits
                    return str(f) + " (" + hex_tmp + ")"
            else:
                n = int(width / 64)
                m = width % 64
                if m > 0:
                    n = n + 1
                vs = self.val["V"]["pVal"]
                hex_string = ""
                for i in range(n, 0, -1):
                    hex_bits = 16
                    da = int(vs[i - 1])
                    if i == n and m > 0:
                        da = da & mask
                    else:
                        da = da & 0xFFFFFFFFFFFFFFFF
                    hex_tmp = hex(da)[2:].zfill(hex_bits)
                    hex_string = hex_string + hex_tmp
                integer = hex_to_signed_int(hex_string, width, self.signed)
                move_bits = W - I
                if move_bits >= 0:
                    f = float(integer) / (1 << move_bits)
                    return str(f) + " (0x" + hex_string + ")"
                else:
                    move_bits = -move_bits
                    f = integer << move_bits
                    return str(f) + " (0x" + hex_string + ")"

Clearly their logic is more complex and probably handles more cases than the one we wrote.

Awesome, let's rip their complete pretty print script that handles all arbitrary precision Vitis HLS types and use it for all our debugging now. You can either copy it from the Vitis install directory or copy it from this GitHub gist:

https://gist.github.com/stefanpie/80594e370038efebcbb1184b3200c44e

Thanks Xilinx, we can now debug our own HLS code free from your GUI.