Module 7: Advanced RTL Design Techniques ยท 7.3 Pipeline Design

A Pipelined Multiply-Accumulate Unit

The last three articles covered stage balancing, inserting stages and stalling. This article puts them together in one design.

$a \times b + \text{acc} \rightarrow \text{acc}$ is the entire content of a multiply-accumulate, or MAC, unit. It is also the most repeated operation in every FIR filter, every matrix multiply and every neural network accelerator. If you end up in DSP or accelerator work you will build one of these, in some form, more often than almost anything else.

The specification

  • Two operands, a and b, arrive together with a valid_in flag.
  • The unit computes a * b and adds it to a running accumulator.
  • A clear_acc input starts a new accumulation on the same cycle as the operands it arrives with.
  • A stall input freezes the whole pipeline, as in the previous article.
  • Three stages: the multiply is split across two (as in Section 7.2) and the accumulate is the third.

Why the accumulator is the interesting part

A pipelined multiplier followed by a pipelined adder is Section 7.2 done twice. What makes a MAC new is the feedback: the accumulator’s own previous output is one input to the final add, every cycle. Get the timing wrong and you do not get one wrong answer, you get compounding wrong answers, because every later accumulation builds on the corrupted one.

The accumulate stage feeds its own output back
Figure 1: MAC pipeline with the accumulator feedback path

The design

`timescale 1ns / 1ps
`default_nettype none

module mac_unit #(parameter WIDTH = 16) (
    input  wire                    clk, rst_n,
    input  wire                    stall,
    input  wire                    clear_acc,
    input  wire [WIDTH-1:0]        a, b,
    input  wire                    valid_in,
    output reg  [2*WIDTH-1:0]      acc_out,
    output reg                     valid_out
);
    localparam H = WIDTH / 2;

    // ---- Stage 1: partial products (the two rows, as in Section 7.2) ----
    reg [2*WIDTH-1:0] pp_lo, pp_hi;
    reg               valid_s1, clear_s1;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            pp_lo <= 0; pp_hi <= 0; valid_s1 <= 1'b0; clear_s1 <= 1'b0;
        end else if (!stall) begin
            pp_lo    <= a * b[H-1:0];
            pp_hi    <= (a * b[WIDTH-1:H]) << H;
            valid_s1 <= valid_in;
            clear_s1 <= clear_acc;   // clear_acc must ride the pipeline too: passthrough
        end
    end

    // ---- Stage 2: final add -> product ----
    reg [2*WIDTH-1:0] product_reg;
    reg               valid_s2, clear_s2;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            product_reg <= 0; valid_s2 <= 1'b0; clear_s2 <= 1'b0;
        end else if (!stall) begin
            product_reg <= pp_lo + pp_hi;
            valid_s2    <= valid_s1;
            clear_s2    <= clear_s1;   // still riding along
        end
    end

    // ---- Stage 3: accumulate, the feedback stage ----
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            acc_out   <= {2*WIDTH{1'b0}};
            valid_out <= 1'b0;
        end else if (!stall) begin
            if (clear_s2)
                acc_out <= product_reg;                 // start a new accumulation
            else if (valid_s2)
                acc_out <= acc_out + product_reg;        // continue accumulating
            // if neither: hold acc_out (a bubble passed through)
            valid_out <= valid_s2;
        end
    end
endmodule

Three things are worth stopping on.

clear_acc had to become a passthrough, threaded through every stage like the carry in Section 7.2. It arrives with a and b on cycle 0, but the accumulator does not act on it until cycle 2, when the matching product is ready. Without clear_s1 and clear_s2, the clear would be applied against the wrong product, which is exactly the bug the retrofit checklist warned about.

The accumulate step checks clear_s2 before valid_s2. Starting a fresh accumulation and continuing one are different operations, and the if/else if order says which wins if both are set on the same cycle. Clearing takes priority, matching the reset-beats-enable convention from Section 5.5.

A bubble, a cycle where valid_s2 and clear_s2 are both 0, leaves acc_out untouched. That is not an accident: it falls out of the if/else if having no final else, which as you know from Section 5.3 infers “hold the current value”, which is what a register should do here.

Self-checking testbench

module tb_mac_unit;
    parameter WIDTH = 8;
    reg clk = 0, rst_n, stall, clear_acc, valid_in;
    reg [WIDTH-1:0] a, b;
    wire [2*WIDTH-1:0] acc_out;
    wire valid_out;

    mac_unit #(.WIDTH(WIDTH)) dut (
        .clk(clk), .rst_n(rst_n), .stall(stall), .clear_acc(clear_acc),
        .a(a), .b(b), .valid_in(valid_in),
        .acc_out(acc_out), .valid_out(valid_out)
    );

    always #5 clk = ~clk;

    // Present one operand pair for exactly one clock edge
    task feed;
        input [WIDTH-1:0] av, bv;
        input clr;
        begin
            a = av; b = bv; clear_acc = clr; valid_in = 1;
            @(negedge clk);
        end
    endtask

    integer errors = 0;

    initial begin
        $dumpfile("tb.vcd");
        $dumpvars(0, tb_mac_unit);

        rst_n = 0; stall = 0; clear_acc = 0; valid_in = 0; a = 0; b = 0;
        @(negedge clk); rst_n = 1;

        // Accumulate: 3*4 + 5*6 + 2*2 = 12 + 30 + 4 = 46
        feed(3, 4, 1);   // clear_acc=1: starts a fresh accumulation with this product
        feed(5, 6, 0);
        feed(2, 2, 0);
        valid_in = 0; clear_acc = 0;

        // The last pair entered stage 1 at the edge just passed; it needs two
        // more edges to reach the accumulator. Check on the cycle it lands.
        repeat (2) @(negedge clk);

        if (acc_out == 46 && valid_out == 1'b1)
            $display("PASS: acc_out=%0d valid_out=%b on the expected cycle", acc_out, valid_out);
        else begin
            $display("FAIL: acc_out=%0d valid_out=%b (expected 46, 1)", acc_out, valid_out);
            errors = errors + 1;
        end

        // Stall test: let a fresh accumulation land, then freeze and check nothing moves
        feed(10, 10, 1);   // fresh accumulation: 100
        valid_in = 0; clear_acc = 0;
        repeat (2) @(negedge clk);          // 100 now sits in acc_out with valid_out = 1
        stall = 1;
        repeat (3) @(negedge clk);          // three edges while stalled
        if (acc_out !== 100 || valid_out !== 1'b1) begin
            $display("FAIL: outputs changed during stall, acc_out=%0d valid_out=%b", acc_out, valid_out);
            errors = errors + 1;
        end
        stall = 0;
        @(negedge clk);
        if (valid_out !== 1'b0) begin       // the empty slot behind it now drains through
            $display("FAIL: valid_out should drop after the stall is released");
            errors = errors + 1;
        end

        if (errors == 0) $display("ALL TESTS PASSED");
        else $display("%0d TEST(S) FAILED", errors);

        $finish;
    end
endmodule

Two things this testbench checks that a weaker one would skip. It checks the exact cycle the result lands on, tied to the pipeline’s known 3-cycle latency, rather than waiting “long enough”. And it checks that asserting stall freezes both acc_out and valid_out for the whole stall, and that the pipeline resumes correctly afterwards.

This structure, pipelined multiply into a stall-aware accumulator with a feedback path, is the cell of a systolic array, which is the architecture under most dedicated neural network accelerators. A systolic array tiles thousands of these together and passes partial sums between neighbours on every clock edge.

Practice Exercise

Add an overflow output that asserts if the accumulator would exceed its 2*WIDTH-bit range on an accumulate step. Decide which overflow rule from Section 7.2 applies, and make sure overflow is pipelined correctly if you report it registered.

Solution
reg overflow_out;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        acc_out <= {2*WIDTH{1'b0}};
        valid_out <= 1'b0;
        overflow_out <= 1'b0;
    end else if (!stall) begin
        if (clear_s2) begin
            acc_out      <= product_reg;
            overflow_out <= 1'b0;   // starting fresh can't overflow
        end else if (valid_s2) begin
            // acc_out and product_reg are unsigned here, so the carry out of
            // the top bit is the overflow indicator.
            {overflow_out, acc_out} <= {1'b0, acc_out} + {1'b0, product_reg};
        end
        valid_out <= valid_s2;
    end
end

Because this MAC treats its accumulator as unsigned, the check is the plain carry-out from the unsigned saturating adder in Section 7.2, not the same-sign-in, opposite-sign-out rule, which applies only to signed values. A signed version of the MAC would need the signed rule instead. Which overflow rule is right always depends on which arithmetic domain you are in.

The MAC unit pulls Section 7.3 together: balanced stages carrying a multiply from Section 7.2, a clear_acc passthrough crossing the same boundaries a carry or a valid bit would, and a stall that freezes a feedback path correctly. That closes the arithmetic and pipelining half of this module. In the next article, Section 7.4 turns to a different kind of buffer, the FIFO, where the depth is not a fixed number of stages but however much data happens to be waiting.

โ† Stalls and Bubbles Why Every Design Has a FIFO in It โ†’