Holoso --- numerical Python directly to FPGA for rapid controls development

A few months ago we published a preview release of Holoso – an easy-to-use high-level digital logic synthesis tool (HLS) that constructs Verilog modules from ordinary Python code. This week, Holoso v0.3 reaches an important milestone that makes it useful for a much broader range of real-world applications than the original release.

Holoso is a tool born out of a practical need while developing an advanced GaN PMSM drive controller with an unconventional FPGA-based power stage architecture. We believe that it may already be useful to others, including those working outside power electronics.

Holoso is not an HDL. It doesn’t let you describe the circuit like Amaranth or MyHDL et al do. Instead, Holoso absorbs a subset of regular Python code and automatically infers a circuit that implements the equivalent computation in hardware. Holoso does not aim to compete with HDLs; rather, it provides a method of constructing and verifying sophisticated circuits that would be impractical to manually code and verify using any existing HDL.

There are existing tools in this niche, such as Google XLS, Bambu, vendor-specific ones like AMD Vitis, MathWorks HDL Coder, and many others. Holoso stands out by accepting ordinary Python source code with little to no adaptation required, chip fabric efficiency, ease of use, first-class support for arbitrary-precision floating-point math with transcendentals, and focus on resource-shared FSM synthesis over II≈1 pipelines. Holoso is designed to perform well in a specific class of problems that are relatively poorly addressed by most existing tools but which also happen to matter to our work, namely real-time embedded control systems and digital signal-processing pipelines.

Among the existing alternatives, Holoso is perhaps most similar, in broad terms, to MathWorks HDL Coder, because both enable tight integration between the simulation environment used for design verification and the final build artifacts that are deployed onto the hardware.

Here’s a hello-world-like example kernel that Holoso can lower into portable vendor-neutral Verilog-2005:

class PID:
    """An ordinary parallel PID controller with anti-windup and variable update rate."""

    def __init__(self, *, kp: float, ki: float, kd: float, limit: float) -> None:
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.limit = limit
        self.integral = 0.0
        self.prev_error = 0.0
        self._started = False

    def update(self, setpoint: float, measurement: float, dt: float, /) -> float:
        error = setpoint - measurement
        candidate = self.integral + self.ki * error * dt
        derivative = (self.kd * (error - self.prev_error) / dt) if self._started else 0.0
        self.prev_error = error
        self._started = True
        u = self.kp * error + candidate + derivative
        if u > self.limit:
            u = self.limit
        elif u < -self.limit:
            u = -self.limit
        else:
            self.integral = candidate
        return u

This is just perfectly ordinary Python code with no digital design specifics. The significance of this will not be lost on anyone who has worked on the verification and validation of a nontrivial FPGA- or ASIC-based control system. The ability to lower Python directly into circuits makes it possible to construct, extensively simulate, and validate large control systems in a familiar environment using the standard scientific Python stack without relying on often awkward proprietary solutions (a few examples could be named but we don’t want this overview to sound overly opinionated).

For the above PID example, Holoso produces a human-friendly report detailing the register transfers and pipeline occupancy (there is more, see the bundled examples):

There are three frequently asked questions that are worth answering preemptively:

  • Why not code in HDL directly? — bare HDLs and hardware construction languages, from Verilog to Chisel, usually require a substantially greater effort than Python to implement and verify a comparable control or signal processing block.

  • Why not use a softprocessor? — a typical kernel synthesized by Holoso can be more than an order of magnitude faster, at the same clock frequency, than a softprocessor occupying a comparable or even substantially larger amount of FPGA fabric.

  • HLS doesn’t give me the control I need over the resulting circuit, why use it? — HLS does not replace HDL.

Many examples can be found and played with online at holoso.digital.

Quick comparison against Allo/Vitis and Bambu

We built a traditional sensorless FOC controller as a demo kernel. Those working with high-frequency GaN inverters will recognize the practical significance of this example. Even outside of modern power electronics this kernel is still representative of the kind of computation one might encounter in a typical FPGA-based embedded control or signal processing system.

The target chips and the HLS tools were chosen such that anyone could reproduce the results without the need to procure expensive proprietary tools. We encourage independent reproduction of these results and comparisons with other HLS tools.

The design has been synthesized down to bitstream using Vivado for Spartan-7 and Diamond LSE for ECP5.

Tool versions
  • Holoso v0.3.0
  • Bambu PandA 2024.10, revision c2ba6936ca2ed63137095fea0b630a1c66e20e63-main
  • Allo v0.5 (git a098603, 2026-08-08) on LLVM/MLIR 6b09f739
  • AMD Vivado 2026.1
  • AMD Vitis HLS 2026.1
  • Lattice Diamond / LSE 3.14
  • Icarus Verilog 12.0
  • Verilator 5.032
  • Ubuntu 26.04
  • Python 3.14.4

Original kernels written for Holoso

flux_observer.py
import math
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from jaxtyping import Float64

import holoso

type Vec2 = Float64[np.ndarray, "2"]


@dataclass(frozen=True)
class MotorParams:
    R: float
    """Phase resistance [ohm]"""

    L_d: float
    """Direct-axis phase inductance [henry]"""

    flux_linkage: float
    """Rotor flux linkage magnitude [weber]"""


class FluxObserver:
    """
    The classical PMSM stationary-frame flux observer, derived from `u = R*i + dpsi/dt` where `psi` is the stator
    flux linkage; subtracting the stator contribution `L_d*i` leaves the rotor flux vector, whose argument is the
    electrical rotor angle:

        flux += (u - R*i) * dt - L_d * (i - i_last)

    Popularized in the drone ESC space by Shane Colton's 2014 sensorless FOC writeup
    (https://scolton-www.s3.amazonaws.com/motordrive/sensorless_gen1_Rev1.pdf),
    implemented in MESC (David Molony, aka mxlemming), and later brought into VESC (Benjamin Vedder) et al.

    The pure integrator drifts under offset errors, so each flux component is hard-clamped to the rotor flux
    linkage magnitude after the update, MESC-style.
    """

    def __init__(self) -> None:
        self.flux: Vec2 = np.zeros(2)  # Rotor flux linkage estimate [weber]
        self.i_last: Vec2 = np.zeros(2)  # Previous alpha-beta frame current [ampere]
        self._aligned = False

    @property
    def _theta_e(self) -> float:
        return float(np.atan2(self.flux[1], self.flux[0]))  # [-pi, +pi]

    def _integrate(self, params: MotorParams, dt: float, u_ab: Vec2, i_ab: Vec2) -> Vec2:
        integral: Vec2 = self.flux + (u_ab - params.R * i_ab) * dt - params.L_d * (i_ab - self.i_last)
        return integral

    def tick(self, params: MotorParams, dt: float, u_alpha_beta: Vec2, i_alpha_beta: Vec2) -> float:
        """
        Update the flux estimate with the alpha-beta frame voltage and current vectors sampled over the period dt.
        Returns the electrical rotor angle estimate in radians in [-pi, +pi].
        """
        if self._aligned:
            flux = self._integrate(params, dt, u_alpha_beta, i_alpha_beta)
        else:
            flux = np.array([params.flux_linkage, 0.0])
        self._aligned = True
        self.flux = np.clip(flux, -params.flux_linkage, params.flux_linkage)
        self.i_last = np.array(i_alpha_beta)
        return self._theta_e
foc.py
import math
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np
from jaxtyping import Float64

import holoso
from flux_observer import FluxObserver, MotorParams as ObserverParams

type Vec2 = Float64[np.ndarray, "2"]
type Vec3 = Float64[np.ndarray, "3"]

_ONE_OVER_SQRT3 = 1.0 / math.sqrt(3.0)
_SQRT3_OVER_2 = math.sqrt(3.0) / 2.0
_CLARKE = np.array(
    [
        [1.0, 0.0],
        [_ONE_OVER_SQRT3, 2.0 * _ONE_OVER_SQRT3],
    ]
)
_INVERSE_CLARKE = np.array(
    [
        [1.0, 0.0],
        [-0.5, _SQRT3_OVER_2],
        [-0.5, -_SQRT3_OVER_2],
    ]
)


@dataclass(frozen=True)
class MotorParams:
    R: float
    """Phase resistance [ohm]"""

    L_dq: Vec2
    """Direct- and quadrature-axis phase inductances [henry]; equal on a surface-magnet machine"""

    flux_linkage: float
    """Rotor flux linkage magnitude [weber]"""

    speed_filter_gain: float
    """Per-sample first-order filter gain of the angle-difference speed estimate [dimensionless]"""


class FocController:
    """
    The two-shunt convention: phase currents a and b are measured, c is implied by Kirchhoff.
    The amplitude-invariant Clarke/Park convention is used throughout.
    The voltage command is limited to the inscribed circle of the hexagon, v_dc/sqrt(3),
    so the duty cycles never leave [0, 1] and the commanded stator voltage always equals the applied one;
    the PI integrators freeze while the limiter is engaged.
    """

    def __init__(
        self,
        *,
        bandwidth: float = 2.0 * math.pi * 1500.0,  # Current loop bandwidth [radian/second]
        angle_advance: float = 1.5,  # Park angle advance in sample periods: 0.5 for ZOH avg + 1 for loop delay
    ) -> None:
        self.bandwidth = bandwidth
        self.angle_advance = angle_advance
        self.observer = FluxObserver()
        self.integral_dq: Vec2 = np.zeros(2)  # dq-axis PI integrator [volt]
        self.u_alpha_beta: Vec2 = np.zeros(2)  # Previous cycle stator voltage cmd, applied over the last period [volt]
        self.theta_prev: float = 0.0  # Observer angle on the previous cycle [radian]
        self.omega: float = 0.0  # Filtered electrical angular speed estimate [radian/second]

    def tick(
        self,
        params: MotorParams,
        dt: float,  # [second] PWM period elapsed since the previous call
        i_ab: Vec2,  # [ampere] phase-a and phase-b current samples
        i_dq_ref: Vec2,  # [ampere] direct- and quadrature-axis current setpoints
        v_dc: float,  # [volt] DC bus voltage sample
    ) -> tuple[Vec3, Vec2]:
        """
        Runs one PWM cycle and returns the three phase duty cycles in [0, 1] and the measured dq-frame currents.
        The estimated rotor angle and speed are observable through the persisted `theta_prev` and `omega` state.
        """
        i_alpha_beta = _CLARKE @ i_ab
        observer_params = ObserverParams(R=params.R, L_d=params.L_dq[0], flux_linkage=params.flux_linkage)
        theta = self.observer.tick(observer_params, dt, self.u_alpha_beta, i_alpha_beta)

        delta = theta - self.theta_prev
        if delta > math.pi:
            delta -= math.tau
        elif delta < -math.pi:
            delta += math.tau
        self.theta_prev = theta
        self.omega = self.omega + params.speed_filter_gain * (delta / dt - self.omega)

        advanced = theta + self.omega * (self.angle_advance * dt)
        s = math.sin(advanced)
        c = math.cos(advanced)
        i_dq = np.array([[c, s], [-s, c]]) @ i_alpha_beta

        e_dq = i_dq_ref - i_dq
        candidate_dq = self.integral_dq + (params.R * self.bandwidth) * e_dq * dt
        decouple = self.omega * np.array([-params.L_dq[1] * i_dq[1], params.L_dq[0] * i_dq[0] + params.flux_linkage])
        u_dq = (self.bandwidth * params.L_dq) * e_dq + candidate_dq + decouple

        v_max = v_dc * _ONE_OVER_SQRT3
        norm = float(np.linalg.norm(u_dq))
        if norm > v_max:
            u_dq = u_dq * (v_max / norm)
        else:
            self.integral_dq = np.array(candidate_dq)

        self.u_alpha_beta = np.array([[c, -s], [s, c]]) @ u_dq

        v_phase = _INVERSE_CLARKE @ self.u_alpha_beta
        v_common = 0.5 * (float(np.max(v_phase)) + float(np.min(v_phase)))
        duty = np.clip((v_phase - v_common) * (1.0 / v_dc) + 0.5, 0.0, 1.0)
        return duty, i_dq

Simulation/verification scaffolding is omitted here because it is just ordinary numerical Python; refer to the examples for the full source code.

Holoso synthesis invocation (see README.md for explanation):

import holoso

options = holoso.Options(
    holoso.OperatorOptions(
        fadd=holoso.FAddOptions(),
        fmul=holoso.FMulOptions(),
        fsort=holoso.FSortOptions(),
        fatan2=holoso.FAtan2Options(),
    ),
    ffmt=holoso.FloatFormat(wexp=8, wman=24),  # use 32-bit float
)
holoso.synthesize(observer.tick, options).write("outputs/")

Adaptation for Allo+Vitis

We chose Allo over translation into C here for the sake of its Python frontend. Compare the code to the original kernels.

flux_observer.py
"""
Allo transcription of `holoso/examples/flux_observer.py`, with constants:
R = 0.05 ohm, L_d = 2e-5 H, flux_linkage = 0.005 Wb, reset flux = [0.005, 0.0], reset i_last = [0, 0].
Top-level signature is scalar-only float32 in and scalar-only float32 out, in stimulus/golden column
order, so the synthesized RTL has flat scalar ports rather than BRAM interfaces.
"""

import os

import allo
from allo.ir.types import Stateful, float32

INPUTS = ["dt", "u_ab_0", "u_ab_1", "i_ab_0", "i_ab_1"]
OUTPUTS = ["out_0", "state_flux_0", "state_flux_1", "state_i_last_0", "state_i_last_1"]

_HERE = os.path.dirname(os.path.abspath(__file__))
ATAN2 = allo.IPModule(top="atan2_ip", impl=os.path.join(_HERE, "atan2_ip.cpp"), link_hls=False)


def flux_observer(
    dt: float32,
    u_ab_0: float32,
    u_ab_1: float32,
    i_ab_0: float32,
    i_ab_1: float32,
) -> (float32, float32, float32, float32, float32):
    flux_0: float32 @ Stateful = 0.005
    flux_1: float32 @ Stateful = 0.0
    i_last_0: float32 @ Stateful = 0.0
    i_last_1: float32 @ Stateful = 0.0

    # self.flux + (u_ab - self.R * i_ab) * dt - self.L_d * (i_ab - self.i_last), left to right.
    integ_0: float32 = flux_0 + (u_ab_0 - 0.05 * i_ab_0) * dt - 2e-5 * (i_ab_0 - i_last_0)
    integ_1: float32 = flux_1 + (u_ab_1 - 0.05 * i_ab_1) * dt - 2e-5 * (i_ab_1 - i_last_1)

    # np.minimum(np.maximum(flux, -0.005), 0.005)
    lo_0: float32 = integ_0 if integ_0 > -0.005 else -0.005
    lo_1: float32 = integ_1 if integ_1 > -0.005 else -0.005
    flux_0 = lo_0 if lo_0 < 0.005 else 0.005
    flux_1 = lo_1 if lo_1 < 0.005 else 0.005

    i_last_0 = i_ab_0
    i_last_1 = i_ab_1

    # np.atan2(self.flux[1], self.flux[0]) on the clamped flux.
    ya: float32[1] = 0.0
    xa: float32[1] = 0.0
    theta: float32[1] = 0.0
    ya[0] = flux_1
    xa[0] = flux_0
    ATAN2(ya, xa, theta)

    return theta[0], flux_0, flux_1, i_last_0, i_last_1


TOP = flux_observer
foc.py
"""
Allo transcription of `holoso/examples/foc.py`.

DEFECT: Allo's vhls emitter renders scalar float constants with `std::to_string`, i.e. `%f` with six decimals
(mlir/lib/Translation/Utils.cpp:47): BW = 9424.778 would emit as `(float)9424.778320`, 1 ULP off
-- and dense array constants go through a different path that prints `%e` with seven significant digits,
which also cannot represent an arbitrary binary32 exactly. 
So every constant that does not round-trip through six `%f` decimals is supplied as a hi+lo pair in
one dense float32 array, where hi is the `%e` round-trip of the binary32
constant and lo is the binary32 remainder (itself `%e`-round-tripped; a few ULP in magnitude, so its
own round-trip error is ~2^-45 relative).  `hi + lo` then reconstructs the exact binary32 constant in
one float32 addition, which Vitis constant-folds away.
"""

import math
import os

import allo
import numpy as np
from allo import dsl
from allo.ir.types import Stateful, float32

INPUTS = [
    "params_R",
    "params_L_dq_0",
    "params_L_dq_1",
    "params_flux_linkage",
    "params_speed_filter_gain",
    "dt",
    "i_ab_0",
    "i_ab_1",
    "i_dq_ref_0",
    "i_dq_ref_1",
    "v_dc",
]
OUTPUTS = (
    ["out_0_0", "out_0_1", "out_0_2"]
    + ["out_1_0", "out_1_1"]
    + ["state_integral_dq_0", "state_integral_dq_1"]
    + ["state_observer_flux_0", "state_observer_flux_1"]
    + ["state_observer_i_last_0", "state_observer_i_last_1"]
    + ["state_omega", "state_theta_prev"]
    + ["state_u_alpha_beta_0", "state_u_alpha_beta_1"]
)

_HERE = os.path.dirname(os.path.abspath(__file__))
ATAN2 = allo.IPModule(top="atan2_ip", impl=os.path.join(_HERE, "atan2_ip.cpp"), link_hls=False)


def _split(value: float) -> tuple[np.float32, np.float32]:
    """The binary32 of `value` as an emitter-stable hi+lo pair: f32(hi + lo) == f32(value), and both
    halves survive the array-constant path's `%e` seven-significant-digit rendering unchanged."""
    exact = np.float32(value)
    hi = np.float32(float(f"{float(exact):e}"))
    lo = np.float32(float(exact) - float(hi))
    lo = np.float32(float(f"{float(lo):e}"))
    assert np.float32(float(f"{float(hi):e}")) == hi
    assert np.float32(float(f"{float(lo):e}")) == lo
    assert np.float32(hi + lo) == exact, (value, hi, lo)
    return hi, lo


CONSTS = np.array(
    [
        _split(2.0 * math.pi * 1500.0),  # 0: BW = 9424.77796076938 [rad/s]
        _split(1.0 / math.sqrt(3.0)),  #   1: 0.5773502691896258
        _split(math.sqrt(3.0) / 2.0),  #   2: 0.8660254037844386
        _split(math.pi),  #                3
        _split(math.tau),  #               4
    ],
    dtype=np.float32,
)


# pylint: disable=too-many-statements,too-many-locals
def foc(
    params_R: float32,
    params_L_dq_0: float32,
    params_L_dq_1: float32,
    params_flux_linkage: float32,
    params_speed_filter_gain: float32,
    dt: float32,
    i_ab_0: float32,
    i_ab_1: float32,
    i_dq_ref_0: float32,
    i_dq_ref_1: float32,
    v_dc: float32,
) -> (
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
    float32,
):
    integral_dq_0: float32 @ Stateful = 0.0
    integral_dq_1: float32 @ Stateful = 0.0
    obs_flux_0: float32 @ Stateful = 0.005
    obs_flux_1: float32 @ Stateful = 0.0
    obs_i_last_0: float32 @ Stateful = 0.0
    obs_i_last_1: float32 @ Stateful = 0.0
    omega: float32 @ Stateful = 0.0
    theta_prev: float32 @ Stateful = 0.0
    u_alpha_beta_0: float32 @ Stateful = 0.0
    u_alpha_beta_1: float32 @ Stateful = 0.0

    consts: float32[5, 2] = CONSTS
    bw: float32 = consts[0, 0] + consts[0, 1]
    one_over_sqrt3: float32 = consts[1, 0] + consts[1, 1]
    sqrt3_over_2: float32 = consts[2, 0] + consts[2, 1]
    pi_c: float32 = consts[3, 0] + consts[3, 1]
    tau_c: float32 = consts[4, 0] + consts[4, 1]

    # i_alpha_beta = np.array([i_ab[0], (i_ab[0] + 2.0 * i_ab[1]) * _ONE_OVER_SQRT3])
    i_alpha: float32 = i_ab_0
    i_beta: float32 = (i_ab_0 + 2.0 * i_ab_1) * one_over_sqrt3

    # theta = self.observer.tick(params, dt, self.u_alpha_beta, i_alpha_beta): the observer
    # integrates the u_alpha_beta commanded on the PREVIOUS cycle, i.e. the state before this call
    # overwrites it.  flux + (u - R*i)*dt - L_dq[0]*(i - i_last), left to right, R and L_dq runtime.
    integ_0: float32 = (
        obs_flux_0 + (u_alpha_beta_0 - params_R * i_alpha) * dt - params_L_dq_0 * (i_alpha - obs_i_last_0)
    )
    integ_1: float32 = (
        obs_flux_1 + (u_alpha_beta_1 - params_R * i_beta) * dt - params_L_dq_0 * (i_beta - obs_i_last_1)
    )
    # np.minimum(np.maximum(flux, -params.flux_linkage), params.flux_linkage)
    neg_flux_linkage: float32 = -params_flux_linkage
    lo_0: float32 = integ_0 if integ_0 > neg_flux_linkage else neg_flux_linkage
    lo_1: float32 = integ_1 if integ_1 > neg_flux_linkage else neg_flux_linkage
    obs_flux_0 = lo_0 if lo_0 < params_flux_linkage else params_flux_linkage
    obs_flux_1 = lo_1 if lo_1 < params_flux_linkage else params_flux_linkage
    obs_i_last_0 = i_alpha
    obs_i_last_1 = i_beta
    # np.atan2(self.flux[1], self.flux[0]) on the clamped flux.
    ya: float32[1] = 0.0
    xa: float32[1] = 0.0
    theta_a: float32[1] = 0.0
    ya[0] = obs_flux_1
    xa[0] = obs_flux_0
    ATAN2(ya, xa, theta_a)
    theta: float32 = theta_a[0]

    delta: float32 = theta - theta_prev
    if delta > pi_c:
        delta = delta - tau_c
    elif delta < -pi_c:
        delta = delta + tau_c
    theta_prev = theta
    omega = omega + params_speed_filter_gain * (delta / dt - omega)

    # sin/cos of the same delay-compensated angle: theta + omega * (angle_advance * dt)
    angle: float32 = theta + omega * (1.5 * dt)
    s: float32 = dsl.sin(angle)
    c: float32 = dsl.cos(angle)
    # i_dq = [[c, s], [-s, c]] @ i_alpha_beta, each row accumulated left to right.
    i_d: float32 = c * i_alpha + s * i_beta
    i_q: float32 = (-s) * i_alpha + c * i_beta

    e_d: float32 = i_dq_ref_0 - i_d
    e_q: float32 = i_dq_ref_1 - i_q
    # candidate_dq = integral_dq + (params.R * BW) * e_dq * dt -- the gain product first, exactly as
    # the Python groups it.
    ki: float32 = params_R * bw
    candidate_d: float32 = integral_dq_0 + ki * e_d * dt
    candidate_q: float32 = integral_dq_1 + ki * e_q * dt
    # decouple = omega * [-L_dq[1]*i_dq[1], L_dq[0]*i_dq[0] + flux_linkage] -- per-axis inductances.
    decouple_d: float32 = omega * ((-params_L_dq_1) * i_q)
    decouple_q: float32 = omega * (params_L_dq_0 * i_d + params_flux_linkage)
    # u_dq = (BW * L_dq) * e_dq + candidate_dq + decouple, left to right.
    kp_d: float32 = bw * params_L_dq_0
    kp_q: float32 = bw * params_L_dq_1
    u_d: float32 = kp_d * e_d + candidate_d + decouple_d
    u_q: float32 = kp_q * e_q + candidate_q + decouple_q

    # Vector limiter to the inscribed circle; the integrators advance only when the limiter is idle.
    v_max: float32 = v_dc * one_over_sqrt3
    norm: float32 = dsl.sqrt(u_d * u_d + u_q * u_q)
    if norm > v_max:
        scale: float32 = v_max / norm
        u_d = u_d * scale
        u_q = u_q * scale
    else:
        integral_dq_0 = candidate_d
        integral_dq_1 = candidate_q

    # u_alpha_beta = [[c, -s], [s, c]] @ u_dq, each row accumulated left to right.
    u_alpha: float32 = c * u_d + (-s) * u_q
    u_beta: float32 = s * u_d + c * u_q
    u_alpha_beta_0 = u_alpha
    u_alpha_beta_1 = u_beta

    # Midpoint-clamp SVPWM; min(max(...)) as selects, exactly as Python's min/max choose.
    v_a: float32 = u_alpha
    v_b: float32 = -0.5 * u_alpha + sqrt3_over_2 * u_beta
    v_c: float32 = -0.5 * u_alpha - sqrt3_over_2 * u_beta
    max_ab: float32 = v_a if v_a > v_b else v_b
    max_abc: float32 = max_ab if max_ab > v_c else v_c
    min_ab: float32 = v_a if v_a < v_b else v_b
    min_abc: float32 = min_ab if min_ab < v_c else v_c
    v_common: float32 = 0.5 * (max_abc + min_abc)
    # duty = np.clip((v_phase - v_common) * (1.0 / v_dc) + 0.5, 0.0, 1.0)
    inv_v_dc: float32 = 1.0 / v_dc
    raw_a: float32 = (v_a - v_common) * inv_v_dc + 0.5
    raw_b: float32 = (v_b - v_common) * inv_v_dc + 0.5
    raw_c: float32 = (v_c - v_common) * inv_v_dc + 0.5
    lo_a: float32 = raw_a if raw_a > 0.0 else 0.0
    lo_b: float32 = raw_b if raw_b > 0.0 else 0.0
    lo_c: float32 = raw_c if raw_c > 0.0 else 0.0
    duty_a: float32 = lo_a if lo_a < 1.0 else 1.0
    duty_b: float32 = lo_b if lo_b < 1.0 else 1.0
    duty_c: float32 = lo_c if lo_c < 1.0 else 1.0

    return (
        duty_a,
        duty_b,
        duty_c,
        i_d,
        i_q,
        integral_dq_0,
        integral_dq_1,
        obs_flux_0,
        obs_flux_1,
        obs_i_last_0,
        obs_i_last_1,
        omega,
        theta_prev,
        u_alpha_beta_0,
        u_alpha_beta_1,
    )


TOP = foc

Allo+Vitis synthesis workflow is substantially more involved:

# 1. Allo kernel -> HLS C++
python3 allo_src/emit_hls.py foc

# 2. Vitis HLS (vitis_hls is a shim forwarding to `vitis-run --mode hls --tcl`)
vitis_hls -f run.tcl

run.tcl (custom because Allo’s own version double-adds the IP source and csynth then fails with symbol multiply defined):

open_project -reset out.prj
set_top foc
add_files kernel.cpp
open_solution -reset solution1 -flow_target vivado
set_part {xc7s50csga324-1}
create_clock -period 6.667
csynth_design
exit
# 3. generate the floating-point IP Vitis instantiates but does not emit
vivado -mode batch -source gen_fp_ip.tcl -tclargs foc

# 4. collect the RTL plus per-library VHDL manifests in compile order
python3 allo_src/collect_rtl_full.py foc

Beware that the launcher can exit 0 having done nothing (missing libncurses.so.5), so success is decided by the presence of out.prj/solution1/syn/report/foc_csynth.rpt. Also, the generated floating-point IP is IEEE-1735-encrypted VHDL, so only xsim can elaborate it: xvhdl --relax -work <library> per library, then xvlog, then xelab -L <libraries>, then xsim -testplusarg mode=latency|throughput.

Adaptation for Bambu

Bambu accepts C so this one required a much more thorough adaptation.

flux_observer_bambu_top.c (1.2 KB)
flux_observer_core.c (2.3 KB)
flux_observer_core.h (780 Bytes)
flux_observer_top.c (498 Bytes)
foc_bambu_top.c (2.5 KB)
foc_core.c (8.5 KB)
foc_core.h (1001 Bytes)
foc_top.c (1.1 KB)

The synthesis workflow is much simpler here:

bambu --top-fname=foc_top \
      --device-name=xc7a100t,-1,csg324,VVD \
      --clock-period=6.667 \
      --generate-interface=INFER \
      -lm \
      foc_bambu_top.c foc_core.c

We are using xc7a here instead of xc7s as the closest target supported by Bambu, speed grade -1 (slowest). The ECP5 target instead uses --device-name=LFE5U85F,8,BG756C plus the Lattice PMI include path.

Note that every output needs #pragma HLS interface port = <name> mode = none register in the C source; without it the generated ovalid strobes alias at width and distinct outputs collapse. --generate-interface=INFER is required for those pragmas to be honoured; -lm is required or Bambu reports no functional unit for atan2f/sinf/cosf.

The generated .mem files must accompany the RTL or the design reads constants as zero.

Bitstream synthesis

The following configurations were synthesized down to bitstream:

  • AMD Vivado for xc7s speed grade -1; target clock 150 MHz.
  • Lattice Diamond LSE for LFE5U85F speed grade 8; target clock 80 MHz.
Tool invocation details

Vivado script:

read_verilog [list <sources>]
synth_design -top <top> -part xc7s50csga324-1 -mode out_of_context \
  -directive PerformanceOptimized -global_retiming on -resource_sharing off
opt_design -directive Default
place_design -directive Default
route_design -directive Default -tns_cleanup
vivado -mode batch -source run_vivado.tcl -nojournal

Diamond LSE:

prj_project open "<project>.ldf"
prj_run PAR -impl impl1 -forceAll

Results

Functionally equivalent kernels implementing the same algorithm, identical stimulus, and identical downstream place-and-route settings where applicable. Latency measured by RTL simulation over 512 vectors; area and max clock frequency measured post-routing.

Vivado (Spartan-7):

Diamond LSE (ECP5):

Issues encountered

Bambu
  • Miscompiles fminf/fmaxf. Worked around by substituting plain-comparison min/max helpers. The substitution means Bambu was measured on a slightly different source expression than the other engines.

  • Miscompiles at tight ECP5 clocks. Designs scheduled for short periods pass timing and compute the wrong answer, or hang outright. The foc kernel is correct at 12.5-8 ns, wrong at 6.5 ns, and its RTL hangs at 5 and 4 ns.

  • Per-output ovalid strobes alias at width. With the default generated interface, distinct output values collapsed onto shared validity signalling. Worked around by declaring every output mode = none register.

  • Its bundled clang 16 cannot lower llvm.nearbyint.f32.

Allo
  • No atan2 in any form. The adapted kernel uses allo.IPModule around hls::atan2, which Vitis implements as a CORDIC (like Holoso does).

  • Scalar float constants below about 5e-7 are silently emitted as zero with no warning.

  • No emitter path can represent an arbitrary binary32 constant. A scalar literal passes through decimal formatting that landed the PI gain about 30 ULP off, and the denser array path still misses by 1-3 ULP on constants like 1/sqrt(3) and pi. Worked around by carrying each irrational constant as two exactly-representable halves whose one-time float32 addition reconstructs the intended bit pattern.

  • Multi-scalar return values break the LLVM/CPU backend, which is the path Allo offers for functional validation; worked around with a ctypes shim that binds the generated shared object directly. tan() aborts the compiler process rather than reporting an unsupported construct.

  • Emitted HLS C++ for a kernel using an IP module omits the IP’s #include, so the generated source does not compile until patched.

Conclusion

One should not read this as a proof that Holoso is strictly better than the tools it is compared against in this report. In this case it is supposed to be better because we were building an embedded controls and signal processing kernel — Holoso’s home turf. It would not perform as well if the objective was to achieve initiation interval ≈1, or if strict IEEE 754 compliance was required. What makes it useful is that there is a large set of applications where these are non-goals.

As hard as we tried to make the comparison fair, we are still inherently biased, so independent reproduction is encouraged. The full source code pack used for the comparison is available upon request.