Dropping low bits loses information. The rounding policy decides which way each error goes, and whether many small errors add up to a bias. The running pipeline narrows its 42-bit accumulator only once, after accumulation, from 30 to 15 fraction bits.
Policy | Rule | Bias/cost |
|---|---|---|
Truncate | Discard low bits | Cheap; biased toward negative infinity for arithmetic shift |
Round half up | Add half LSB then truncate | Simple; tie bias |
Round to nearest even | Tie goes to even retained LSB | Low aggregate bias; more logic |
Stochastic | Randomized from discarded fraction | Low long-run bias; needs random source |
The examples below quantize to integer steps of 1 before any output-range clamp. Away from a tie, the three nearest policies agree. Arithmetic-shift truncation is floor, not truncation toward zero: a -1.25 input becomes -2, while +1.25 becomes +1.
Read each line on its own: every dot is a tie, and its arrow lands on the integer that policy returns.
Floor and half up: every arrow points the same way, so on average the error has a sign. That is the bias.
Ties away: symmetric about zero, but -0.5 and +0.5 both move away, so magnitudes grow on average.
Ties even: -0.5 and +0.5 both go to 0, while -1.5 and +1.5 go outward. Half the ties move each way, so the bias cancels.
Input | Floor (arithmetic shift) | Half up: floor(x+0.5) | Nearest, ties away | Nearest, ties even |
|---|---|---|---|---|
-2.5 | -3 | -2 | -3 | -2 |
-1.5 | -2 | -1 | -2 | -2 |
-0.5 | -1 | 0 | -1 | 0 |
+0.5 | 0 | 1 | 1 | 0 |
+1.5 | 1 | 2 | 2 | 2 |
+2.5 | 2 | 3 | 3 | 2 |
Signed rounding must handle negative ties deliberately. A reusable quantizer should document whether ties move away from zero, toward zero, or to even.
At -1.5 it produces floor(-1.5+0.5)=-1, toward zero. At +1.5 it produces +2, away from zero. That policy is half up, not symmetric ties-away.
Take 8-bit signed codes with F = 2 fraction bits, so one step of the code is 1/4. The quantizer keeps the floor quotient q, looks at the dropped bits r, and adds 1 to q only when nearest-even says so.
Step | C = -6 (-1.5) | C = -2 (-0.5) |
|---|---|---|
Stored bits |
|
|
q = C >>> 2 (floor) |
|
|
r = low 2 bits, unsigned |
|
|
Compare r with half (2) | equal: a tie | equal: a tie |
Is q odd (q[0] = 1)? | no: keep q | yes: add 1 |
Result | -2 | 0 |
Both inputs are ties, yet one stays and one moves up. Nearest-even looks only at the retained low bit q[0], never at the sign, which is why the same rule is unbiased on both sides of zero.
Unsigned values have no negative ties, so adding half an LSB and shifting is enough. The example below handles the no-fraction case separately so an otherwise legal parameter set never elaborates a negative shift. It keeps the rounded quotient wide until the range check, so narrow only after clamping as shown in Saturation policy.
// snippet: unsigned round-to-nearest, keep wide quotient for clamp
logic [IW-1:0] value;
logic [IW:0] quotient_wide;
logic [OW-1:0] rounded;
generate
if ((F == 0) && (OW <= IW)) begin : g_no_fraction
assign quotient_wide = value;
assign rounded = (quotient_wide > {OW{1'b1}}) ? {OW{1'b1}} : quotient_wide[OW-1:0];
end else if ((F > 0) && (F + OW <= IW + 1)) begin : g_round
logic [IW:0] biased;
assign biased = {1'b0, value} + ({{IW{1'b0}}, 1'b1} << (F-1));
assign quotient_wide = biased >> F;
// With IW=8, F=2 and OW=6, value 255 rounds to 64 wide and clamps to 63 rather than wrapping to zero.
assign rounded = (quotient_wide > {OW{1'b1}}) ? {OW{1'b1}} : quotient_wide[OW-1:0];
end else begin : g_bad_quantizer_parameters
initial $fatal(1, "Require (F == 0 && OW <= IW) or (F > 0 && F + OW <= IW + 1)");
end
endgenerateThis combinational module implements the running policy. The signed quotient has an extra bit, the remainder is unsigned, and the signed clamp precedes narrowing. F=0 bypasses fractional rounding. Its flag means the rounded output code exceeded the representable range, not merely that low bits were discarded.
module signed_quantizer #(
parameter int IW = 42, OW = 16, F = 15
) (
input logic signed [IW-1:0] value_i,
output logic signed [OW-1:0] value_o,
output logic saturated_o
);
generate
if (IW < 2 || OW < 2 || OW > IW + 1 || F < 0 || F >= IW) begin : g_bad
$error("Require IW>=2, 2<=OW<=IW+1 and 0<=F<IW");
assign value_o = '0;
assign saturated_o = 1'b0;
end else begin : g_valid
localparam logic signed [IW:0] HI = $signed({1'b0, {(OW-1){1'b1}}});
localparam logic signed [IW:0] LO = $signed({1'b1, {(OW-1){1'b0}}});
logic signed [IW:0] floor_wide, rounded_wide;
logic round_up;
assign floor_wide = $signed({value_i[IW-1], value_i}) >>> F;
if (F == 0) begin : g_integer
assign round_up = 1'b0;
end else begin : g_fraction
localparam logic [F-1:0] HALF = {1'b1, {(F-1){1'b0}}};
wire [F-1:0] remainder_bits = value_i[F-1:0];
assign round_up = (remainder_bits > HALF) ||
((remainder_bits == HALF) && floor_wide[0]);
end
assign rounded_wide = floor_wide + $signed({{IW{1'b0}}, round_up});
always_comb begin
saturated_o = 1'b0;
if (rounded_wide > HI) begin
value_o = HI[OW-1:0]; saturated_o = 1'b1;
end else if (rounded_wide < LO) begin
value_o = LO[OW-1:0]; saturated_o = 1'b1;
end else value_o = rounded_wide[OW-1:0];
end
end
endgenerate
endmodule
The small-width test checks every eight-bit signed code for F=0,1,2 against an independent distance search, including negative non-ties, both retained tie parities and rounding overflow. The precision-verification lesson reuses this exact module after the 42-bit MAC, with separate integer and real-valued references.
Quantize once at a deliberate boundary, not after every operator.
Keep guard bits through the high-dynamic-range portion of the datapath.
Measure maximum error, RMS error, bias, and saturation rate against realistic vectors.
Each truncation discards information in the same direction. Across many stages the signed errors do not cancel, shifting the average result.