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.IPModulearoundhls::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.


