Skip to content

Latest commit

 

History

History
366 lines (269 loc) · 15.9 KB

HDL_STYLE_GUIDE.md

File metadata and controls

366 lines (269 loc) · 15.9 KB

FINN HDL Style Guide

Purpose and Scope

This guide covers coding standards for hardware description languages used in FINN:

  • C++/HLS (finn-hlslib operators)
  • SystemVerilog (finn-rtllib components)

Following these standards ensures consistency across the codebase and makes code easier to read, review, and maintain.

General Principles (All HDL)

File Headers

All HDL source files (HLS/C++ and SystemVerilog) must include a copyright header with SPDX identifier:

C++/HLS:

// Copyright Advanced Micro Devices, Inc.
// SPDX-License-Identifier: BSD-3-Clause

SystemVerilog:

// Copyright Advanced Micro Devices, Inc.
// SPDX-License-Identifier: BSD-3-Clause

Comments

Purpose: Explain why, not what

  • Use comments to explain design decisions, algorithms, and non-obvious constraints
  • Avoid obvious comments that repeat what the code does
  • Complex algorithms should have block comments explaining the approach
  • Hardware-specific considerations (timing, resource usage) should be documented

Good:

// Limit parallelism to match BRAM port constraints
simd = 8;

Bad:

// Set SIMD to 8
simd = 8;

HLS/C++ Style (finn-hlslib)

General Principles

  • Follow Xilinx Vitis HLS Coding Guidelines (UG1399)
  • Use Doxygen-style documentation
  • Prioritize synthesizability over software engineering patterns
  • Avoid dynamic memory allocation, recursion, and unbounded loops

Naming Conventions

Template Parameters

Pattern: ALL_CAPS or CamelCase with semantic meaning

Examples:

template<
    unsigned int ConvKernelDim,    // Clear semantic names
    unsigned int IFMChannels,      // IFM = Input Feature Maps
    unsigned int OFMChannels,      // OFM = Output Feature Maps
    unsigned int SIMD,             // Hardware parallelism
    unsigned int PE,               // Processing Elements
    typename TSrcI = Identity,     // Type params: T prefix
    typename TDstI,
    typename TWeightI,
    typename TW,                   // Weights
    typename TA                    // Activation
>

Standard abbreviations:

  • IFM - Input Feature Map
  • OFM - Output Feature Map
  • SIMD - SIMD parallelism
  • PE - Processing Elements
  • T* - Type parameters (prefix with T)

Functions

Pattern: CamelCase

Examples:

StreamLimiter                      // Describes operation
StreamLimiter_Batch                // Variants use underscore
Mem2Stream                         // DMA operations
Stream2Mem
Pool_batch
Matrix_Vector_Activate_Batch
Thresholding_Stream_Batch

Types

Pattern: Standard HLS types

Examples:

ap_int<N>              // Signed fixed-width integer
ap_uint<N>             // Unsigned fixed-width integer
hls::stream<T>         // Streaming interface
hls::vector<T, N>      // SIMD vector operations

Documentation

Required: Doxygen comments for all public functions and templates

Example:

/**
 * \brief Thresholding function for multiple images
 *
 * The function performs thresholds comparison with input activation vector,
 * and generating output based on the comparison results
 *
 * \tparam ImgDim         Total spatial size of input feature map
 * \tparam NumChannels    Number of channels in input feature map
 * \tparam PE             Number of output rows computed in parallel
 * \tparam TSrcI          DataType of the input activation (as used in the MAC)
 * \tparam TDstI          DataType of the output activation (as generated by the activation)
 * \tparam TI             DataType of the input stream - safely deducible from the parameters
 * \tparam TO             DataType of the output stream - safely deducible from the parameters
 * \tparam TA             DataType of the activation class (e.g. thresholds) - safely deducible from the parameters
 *
 * \param in              Input stream
 * \param out             Output stream
 * \param activation      Activation class
 * \param reps            Number of time the function has to be repeatedly executed (e.g. number of images)
 */
template <
    unsigned ImgDim, unsigned NumChannels, unsigned PE,
    typename TSrcI = Identity, typename TDstI = Identity,
    typename TI, typename TO, typename TA>
void Thresholding_Batch(hls::stream<TI> &in,
                        hls::stream<TO> &out,
                        TA const &activation,
                        int const reps)
{
    constexpr unsigned NF = NumChannels / PE;
    // Implementation with pipelined loop
    for (unsigned i = 0; i < reps * ImgDim * NF; i++) {
#pragma HLS pipeline style=flp II=1
        // Process activation thresholding
    }
}

Pragma Usage

Common HLS pragmas:

#pragma HLS pipeline style=flp II=1        // Pipeline with flushable pipeline, II=1
#pragma HLS INLINE                         // Inline function
#pragma HLS dataflow disable_start_propagation
#pragma HLS aggregate variable=in0_V compact=bit
#pragma HLS ARRAY_PARTITION variable=weights complete dim=1

Guidelines:

  • Use #pragma HLS pipeline for loops to improve throughput
  • Use #pragma HLS INLINE judiciously (can increase area)
  • Use #pragma HLS dataflow for task-level pipelining
  • Use #pragma HLS ARRAY_PARTITION to enable parallel access

File Organization

Header guards required:

#ifndef STREAM_TOOLS_HPP
#define STREAM_TOOLS_HPP

// Includes
#include <hls_stream.h>
#include <ap_int.h>

// Local includes
#include "weights.hpp"
#include "streamtools.h"

// Code

#endif // STREAM_TOOLS_HPP

Include order:

  1. System HLS headers (<hls_stream.h>, <ap_int.h>)
  2. Standard library headers if needed
  3. Local finn-hlslib headers

SystemVerilog Style (finn-rtllib)

These guidelines are strict but not religious. Deviations are possible but must be rare and always accompanied by an explanatory comment in the sources.

The use of `default_nettype none is encouraged.

Naming

Signals that represent registers are named in capital camel case. They are always initialized. The initialization value must be identical to the value assigned in the reset branch of the corresponding always_ff block. If the reset value is semantically irrelevant, 'x is used in both places, granting the synthesis tool full freedom in control set assignment.

Signals that represent combinatorial compute are named in lower snake case. Their pure declarations have no initializer. For simple combinatorial computations by a single expression, continuous assigns are preferred over always_comb blocks. If all inputs to such a computation are available at the point of declaration, a uwire declaration fused with an immediate continuous assignment is used. The declaration as a uwire net is preferred over a logic variable if allowed by the use of the signal, i.e. when it is not the target of a procedural assignment.

Generic parameters and localparams are named in all-capital snake case. Don't use the parameter keyword for typed generics. Use unsigned types whenever semantically sensible. Width, precision, count, offset, and version generics are typically typed as int unsigned. Only localparams that are referenced by port declarations belong in the parameter list; all others are declared in the module body.

Type definitions use a _t suffix. Enum types may use _e instead. Types should be declared in the smallest meaningful scope, i.e. within a module if no other module relies on them.

Signedness follows semantics: quantities that can represent negative numeric values are declared signed consistently at ports and typedefs, while control data and widths stay unsigned.

Ports

Modules use ANSI port declarations. Ports are grouped logically, typically starting with the control set (clock and reset), and keeping related signals together regardless of direction (e.g. all signals associated with a stream).

Non-AXI modules use a simplified port naming scheme: idat/ivld/irdy for input streams and odat/ovld/ordy for output streams, where irdy is an output (this module can accept input) and ordy is an input (downstream can accept output). The clock input is named clk, the reset rst.

Modules designated for instantiation by the Vivado IP Integrator use standard AXI-Stream port naming: s_axis_<name>_tdata, s_axis_<name>_tvalid, s_axis_<name>_tready, etc. for slave interfaces, and m_axis_<name>_ prefixes for master interfaces. These modules use aclk and aresetn as clock and reset.

Resets

Resets are always synchronous. Non-AXI modules use active-high rst. AXI IP Integrator modules use active-low aresetn.

Formatting

Tabs are used for indentation, one tab per indentation level. Tab-indented assign keywords are separated from their assignment expressions by another tab. Assign keywords that are the single-line body of a block statement such as an if-statement or a for-loop are followed by two spaces instead. Tabs may not be used for visual alignment if such alignment would depend on a specific tab size.

Separate parameter or variable names from their type by at least two spaces in a declaration. If equivalent elements in declarations on subsequent lines line up closely already, align them completely. In these cases, also align the elements of index range specifications. Right-align bounds if padding is needed.

The ternary operator is preferred over if-else constructs. The ? follows the condition without a preceding space (cond? a : b). A space before ? is only used for vertical alignment in multi-decision expressions where several ternaries share a column. Non-trivial conditions (compound expressions with operators) are parenthesized: (a && b)? c : d.

If-else branches and loop bodies do not enclose single-line statements that are not particularly long in begin-end blocks.

Numeric Literals

Numeric literals avoid size specifications unless required by context. Literals of small values whose purpose is not a bit pattern should be decimal. A decimal base is not explicitly identified. A zero vector of a known size is produced by a sized literal rather than by a repetition of a single-bit zero. In concatenations/aggregations, retain explicit element widths when needed to avoid ambiguity.

Examples:

  • 3 — not 32'd3 (small value, not a bit pattern)
  • 8'd0 — not {8{1'b0}} (zero vector of known size via sized literal)
  • 4'b1010 — bit pattern, explicit size and base appropriate

Procedural Blocks

Use always_ff for sequential logic and always_comb for combinatorial logic. Plain always blocks are banned with one exception: clock generation in testbenches (e.g. always #5ns clk = !clk;).

Generate Blocks

Generate blocks of any kind should be labeled if they extend over more than a few lines.

Assertions

Assertions are used for simulation-time checks. Violations should typically be reported as $error.

Static parameter validation that must also be caught by synthesis tools uses $error/$finish in initial blocks rather than assert since synthesis tools may ignore assert statements.

Code Organization

A module body is organized into sections separated by //=== banner comments. Each section implements a self-contained piece of the dataflow, e.g. a pipeline stage.

The code within each section is organized as follows: (a) Declaration of all signals representing outputs computed by the section for the consumption by others. (b) Derivation of combinatorial refinements of processed inputs if needed. Single-line uwire declarations with a fused continuous assignment are preferred. (c) Description of the sequential logic updating the state of the section. (d) If needed, a refining combinatorial derivation of the outputs of the section. If there are signals that only facilitate the internal communication between these steps, an if(1) block is used to scope them locally. This construction is preferred over unmotivated begin-end blocks, which are not supported by Vivado. Signal declarations are not batched at the beginning of this block but happen as late as possible. Between sections, give a preference to input rather than output refinements.

Operator Ordering: MUX before ADD

When a conditional selects between advancing a value or keeping it, prefer MUX-then-ADD over ADD-then-MUX. That is, mux the addend and feed a single adder rather than computing both sums and muxing the results. An input MUX can typically be merged into the same LUT level that implements the addition. A terminal MUX always costs an extra LUT level.

Preferred: result = base + (sel ? inc : 0) Avoid: result = sel ? (base + inc) : base

The same principle applies to any associative operator (subtraction, OR-combine, etc.) where one branch is the identity element.

Free-Running Pipeline

A free-running pipeline implements a continuously progressing pipelined dataflow computation without means to stall the pipeline due to backpressure or by an enable signal. A data item is typically accompanied by a valid indication flowing through the pipeline in sync with it. The input is fed by the signal pair idat carrying the data and ivld indicating a valid input. The respective outputs are odat and ovld.

Each pipeline stage is a section following the general code organization described above.

Skills

Pipelined Binary Reduction Tree

A generically-sized binary reduction tree reduces N inputs to a single output using N-1 pipelined binary operator nodes. The tree is represented as a flat array of 2*N-1 edges using heap-style indexing: the root is at index 0, and the children of node i are at 2*i+1 and 2*i+2. Leaves occupy indices N-1 through 2*N-1-1.

The structure is scoped within an if(1) block and organized in three sections:

(1) Tree array and leaf feed. Declare the tree as uwire edge_t tree[2*N-1]. Feed each leaf from the input:

for(genvar  i = 0; i < N; i++) begin : genLeaves
    assign  tree[N-1+i] = in[i];
end : genLeaves

(2) Edge delay balancing. When N is not a power of two, the tree has an incomplete leaf level. Leaves at shallower levels have shorter paths to the root and require delay compensation. This is computed by an elaboration-time function:

  • Mark each leaf that is not on the deepest level for one cycle of delay.
  • Propagate shared delays upward: if both children of a node carry a delay, remove them and assign the delay to the parent instead.
typedef bit edge_delays_t[2*N-1];
function edge_delays_t INIT_EDGE_DELAYS();
    localparam int unsigned  LEVELS = 1+$clog2(N);
    automatic edge_delays_t  d = '{ default: 0 };
    for(int unsigned  i = N-1; i < 2*N-1; i++) begin
        if($clog2(i+2) == LEVELS)  break;
        d[i] = 1;
    end
    for(int unsigned  i = N-1; i > 0; i--) begin
        if(d[2*i+1]) begin
            d[2*i+1] = 0;
            d[2*i+2] = 0;
            d[i] = 1;
        end
    end
    return  d;
endfunction
localparam edge_delays_t  EDGE_DELAYS = INIT_EDGE_DELAYS();

(3) Inner nodes. Generate N-1 nodes, each computing tree[i] = f(tree[2*i+1], tree[2*i+2]) for an abstract combinatorial function f(). Where EDGE_DELAYS indicates a balancing delay, a pipeline register is inserted on the corresponding input:

for(genvar  i = 0; i < N-1; i++) begin : genNodes
    uwire edge_t  a;
    if(EDGE_DELAYS[2*i+2]) begin : genDelay
        edge_t  Del = 'x;
        always_ff @(posedge clk)  Del <= tree[2*i+2];
        assign  a = Del;
    end : genDelay
    else begin : genDirect
        assign  a = tree[2*i+2];
    end : genDirect
    assign  tree[i] = f(tree[2*i+1], a);
end : genNodes

The root result is taken from tree[0]. The total tree latency is $clog2(N) cycles, assuming f() is registered at its output.

References

HLS

SystemVerilog


Enforcement

  • HLS: Manual code review
  • SystemVerilog: Manual code review

When in doubt, follow existing patterns in the codebase and consult with maintainers during PR review.