Fixed-Point Vectorization

This is the vectorization story for fixed-point — the compute. The fixed-point type itself (the ap_fixed model, QMode/OMode, the defaults-match-Vitis contract) lives on the FixedField type page; read that first if you haven’t.

Fixed-point arrays use DataArray[FixedField] — the same numpy-backed array schema as every other element type, so they get flat storage, array access, and codegen for free. This is the case that most needs the type-preserving operators: fixed-point grows bits and rounds on assignment, so working through .val by hand is easy to get wrong. The operators (*, +, -) are sugar over the free functions in waveflow/hw/fixpoint.py (not methods — the container stays a plain container): mult, add, sub, shift, fixed_sum, and quantize. They run entirely in the integer domain and match the Vitis ap_fixed datapath bit-for-bit.

Arrays of fixed-point values

DataArray[FixedField].val is a numpy integer array of stored values. from_real quantizes real data into a format; to_real derives the real view:

import numpy as np
from waveflow.hw.fixpoint import FixedField, from_real, to_real

Q8_4 = FixedField.specialize(8, 4)                 # ap_fixed<8, 4>
da = from_real([1.5, -2.0, 0.0625, 1.53], Q8_4)
np.asarray(da)        # array([ 24, -32,   1,  24])   <- stored ints (1.53 -> 1.5)
to_real(da)           # array([ 1.5 , -2.  ,  0.0625, 1.5 ])

Arithmetic: full-precision intermediates, one explicit quantize

Each op reads its operands’ formats, runs the vectorized integer op on the stored arrays, and derives the result format per the ap_fixed rules — so intermediates are full precision and never overflow. The only lossy step is quantize, when you assign to a declared target format (exactly like ap_fixed<...> y = a * b; in C++).

function result format notes
mult(a, b) <Wa+Wb, Ia+Ib> exact product (fraction bits add)
add(a, b) <max(Ia,Ib)+1+max(Fa,Fb), max(Ia,Ib)+1> fractions aligned; one carry bit
sub(a, b) like add, signed subtraction may go negative
shift(a, n) <Wa, Ia+n> lossless point-move (bits unchanged), value ×2ⁿ
fixed_sum(a) integer bits grow by ceil(log2 N) full-precision reduction
quantize(a, target) target the lossy step — rounding (QMode) + overflow (OMode)
from waveflow.hw.fixpoint import mult, add, quantize

a = from_real([1.5, -2.0, 0.5], Q8_4)
b = from_real([2.0,  1.5, -1.0], Q8_4)

mult(a, b).element_type.cpp_type      # 'ap_fixed<16, 8, AP_TRN, AP_WRAP>'  (full precision)
to_real(mult(a, b))                    # array([ 3., -3., -0.5])  -- exact

# bring a product back to a working format (this is where rounding/overflow happen):
quantize(mult(a, b), Q8_4)             # DataArray[ap_fixed<8, 4>]

Operator form (the primary spelling)

The operators are sugar over these functions, so a multiply-add reads like the math — and like the HLS ap_fixed<...> y = a*b + c; it mirrors. The intermediates grow to full precision; the single quantize is the only rounding:

from waveflow.hw.fixpoint import quantize, to_real

c = from_real([0.5, 0.25, -0.5], Q8_4)

full = a * b + c                       # ap_fixed<17, 9>  -- full precision, no loss
y    = quantize(full, Q8_4)            # ap_fixed<8, 4>   -- the one explicit rounding
to_real(y)                             # array([ 3.5 , -2.75, -1.  ])

This is exactly the fixed case of examples/basic_vec, checked bit-for-bit against a Vitis kernel.

Single 64-bit dtype, fail-fast above it

All ops are numpy-vectorized over a single 64-bit integer dtypeint64 for signed formats, uint64 for unsigned. A W-bit value fits iff W ≤ 64. There is no object-array / arbitrary-precision fallback: any format width that exceeds 64 bits — whether declared or derived by an op — raises NotImplementedError at the format-derivation step, before any numpy op runs:

A = FixedField.specialize(40, 20)
mult(from_real([1.0], A), from_real([1.0], A))   # NotImplementedError: derived W=80 > 64

This is deliberate: numpy int64/uint64 silently wrap on overflow, which would be an invisible bit-exactness violation, so the guard is a compile-time width check, not a runtime hope. (Wide > 64-bit support — via Waveflow’s (n, k) uint64-word convention, not object arrays — is future work.) In practice, real FPGA DSP datapaths quantize per stage and stay well under 64 bits.

Mixed signed/unsigned is a v1 limitation

Mixing an ap_fixed (signed) and an ap_ufixed (unsigned) operand raises, because numpy would coerce int64/uint64 to float64 and silently lose exactness:

S = FixedField.specialize(8, 4, signed=True)
U = FixedField.specialize(8, 4, signed=False)
mult(from_real([1.5], S), from_real([2.0], U))   # NotImplementedError: mixed signed/unsigned

Workaround — promote to a common signed format with one extra integer bit to hold the unsigned operand’s MSB, then operate:

S9_5 = FixedField.specialize(9, 5, signed=True)   # holds the full ap_ufixed<8,4> range
u_signed = quantize(from_real([2.0], U), S9_5)     # reinterpret the unsigned value as signed
to_real(mult(from_real([1.5], S), u_signed))       # array([3.])

Worked example: a fixed-point FIR / dot product

A short FIR tap-sum is a multiply-accumulate: full-precision products, a full-precision accumulator, then one quantize back to the working format — the canonical DSP pattern, and bit-exact with Vitis.

from waveflow.hw.fixpoint import FixedField, from_real, mult, fixed_sum, quantize, to_real

S16_8 = FixedField.specialize(16, 8)                 # ap_fixed<16, 8>
taps    = from_real([0.5, 0.25, -0.125, 0.0625], S16_8)
samples = from_real([1.0, 2.0, -1.0,    0.5],    S16_8)

prods = mult(taps, samples)        # ap_fixed<32, 16>  (full-precision products)
acc   = fixed_sum(prods)           # ap_fixed<34, 18>  (+ceil(log2 4)=2 integer bits)
y     = quantize(acc, S16_8)       # ap_fixed<16, 8>   (one quantize at the end)

to_real(y)                         # array([1.15625])   == numpy dot, exactly

The accumulator is sized (fixed_sum grows the integer bits by ceil(log2 N)) so the sum is exact; the matching Vitis kernel declares an ap_fixed<34, 18> accumulator so acc += taps[i] * samples[i] never rounds either. The conformance harness runs exactly this s24_12 sum-of-products (plus mult/add/quantize) in Vitis C-sim and asserts the bits match the Python ops — run pytest -m vitis -k fixedpoint.

See also


This site uses Just the Docs, a documentation theme for Jekyll.