Tutorial · accelerators

Running hls4ml

Take a trained Keras model and end up with a firmware slot on the board that runs it — conversion, precision, synthesis, the AXI interface, and the checks that catch a wrong answer before hardware does.

hls4ml helps scientists implement their machine learning algorithms on FPGAs, turning a trained Keras model into synthesizable C++. Supporting it on standard hardware enables real-time, low-power AI inference applications for experiments that would not otherwise take on an FPGA project. This page covers the rest of that path: onto KrIO’s programmable logic, addressable from Linux, and verified against its originating model.

Work through board bring-up first — this assumes a board that boots, joins the network, and loads firmware from Linux.

Two models are deployed on the board today, and the commands below are the ones that put them there. Measured results for those and two more are on the benchmarks page.

1. Environment

export XILINX=$HOME/Xilinx/2025.2        # 2022.1 or later
export PATH=$XILINX/Vitis/bin:$XILINX/Vivado/bin:$PATH
export XILINX_VITIS=$XILINX/Vitis XILINX_VIVADO=$XILINX/Vivado XILINX_HLS=$XILINX/Vitis
mamba create -y -n hls4ml python=3.10 -c conda-forge
PY=$HOME/miniforge3/envs/hls4ml/bin/python
$PY -m ensurepip --upgrade
$PY -m pip install "tensorflow-cpu>=2.19" scikit-learn matplotlib h5py pyyaml \
                   "hls4ml[keras-v3,profiling]"

Use $PY -m pip, not pip — a user-level ~/.local/bin/pip will shadow the environment’s.

On releases from 2025.1, vitis_hls has no launcher script. The binary ships; the wrapper does not. The other wrappers dispatch on argv[0], so a copy is the whole fix:

[ -x $XILINX/Vitis/bin/vitis_hls ] || cp $XILINX/Vitis/bin/v++ $XILINX/Vitis/bin/vitis_hls
vitis_hls -version

2. Start from a model that works

Before converting anything, confirm the float model scores what you expect on real data. A preprocessing mistake here looks exactly like a quantization problem three steps later, and you will spend a day on the wrong thing.

$PY -c "
import numpy as np, keras
m = keras.models.load_model('model.keras', compile=False)
X, y = np.load('x_test.npy'), np.load('y_test.npy')
print('float top-1:', (m.predict(X, verbose=0).argmax(1) == y).mean())"

Check the input scaling explicitly. Some published models take raw 0–255 pixels and some take 0–1; feeding the wrong one gives chance accuracy with no error message.

3. Profile activation ranges

Choose integer bits from measurement. A network’s first layers often span hundreds while everything after the first normalization fits in single digits, and a uniform format saturates the front silently.

$PY profile_ranges.py 2000
layer                   type                     min         max  int bits
input                   InputLayer             0.000     255.000         9
conv2d                  Conv2D              -536.047     525.993        11
batch_normalization     BatchNormalization    -8.068       9.341         5

Pin the input and the front of the network to what you measured; let the rest run at the default width.

4. Convert and check in C simulation

Fix accuracy here. It costs seconds; after synthesis it costs an hour.

$PY convert.py --reuse 16
cfg = hls4ml.utils.config_from_keras_model(model, granularity="name", backend="Vitis")
cfg["Model"]["Strategy"] = "Resource"
cfg["Model"]["ConvImplementation"] = "LineBuffer"
hls_model = hls4ml.converters.convert_from_keras_model(
    model, hls_config=cfg, backend="Vitis", output_dir="hls",
    part="xck26-sfvc784-2LV-c", clock_period=10.0, io_type="io_stream")
hls_model.compile()

io_type="io_stream" for anything convolutional; io_parallel is for small dense models.

If accuracy collapses with no error, suspect automatic precision inference. hls4ml sets every per-layer precision to auto at name granularity, and one bad inference is enough — on one model it gave the output layer ap_fixed<38,18>, which the stable-softmax table cannot index, and accuracy fell from 0.78 to 0.22. Pin precisions explicitly, or bisect:

$PY tools/precision_bisect.py --samples 1000

5. Choose reuse factors

ReuseFactor sets how many cycles each multiplier is shared across. Under io_stream the layers run as a dataflow pipeline, so one global factor makes the widest layer the bottleneck while narrow ones sit idle holding multipliers. Pick per layer so every layer takes about the same number of cycles.

Two constraints:

  • Keep each factor a divisor of n_in × n_out. Otherwise hls4ml emits nnet::DenseResource_rf_gt_nin, a class its own headers never define, and the generated C++ will not compile.
  • Read the conversion warnings. hls4ml silently substitutes factors it considers invalid, so what you asked for is not always what you got.

6. Synthesize, then check with Vivado

$PY convert.py --reuse 16 --csynth

For streaming models, run the FIFO depth optimizer — default depths dominate block RAM, and co-simulated depths cut one model’s estimate by two thirds. Convert normally and apply it as a flow; setting config['Flows'] replaces the writer flow and leaves the project unwritten:

hls4ml.model.optimizer.get_optimizer(
    "vitis:fifo_depth_optimization").configure(profiling_fifo_depth=32768)
hls_model.apply_flow("vitis:fifo_depth_optimization")

Do not trust the HLS resource report. On these designs it overstates LUT by about 2×, block RAM by about 3× and latency by nearly 2×, while getting DSP right. A design the estimate rejects may fit comfortably.

vivado -mode batch -source diag_synth.tcl -tclargs $PWD/hls
grep -E "CLB LUTs|Block RAM Tile|DSPs" hls/diag_util_flat.rpt

7. Wrap it in AXI4-Lite

A posted write costs ~7.6 ns on this board and a blocking read ~200 ns, so loading input through a register bank is cheap and polling is what actually costs.

InputCost to loadAgainst
16 features, 32 B0.06 µs1.1 µs of compute
32×32 RGB image, 3 KB8 µs929 µs of compute

Both under 1%, so neither deployed model uses DMA. Reach for it only when input loading approaches the inference time — it costs a contiguous buffer, a device-tree change and a driver.

An io_stream model needs a wrapper, since its ports are hls::stream. Three stages in one dataflow region, and only one process may touch the AXI-mapped array:

void model_axi(ap_uint<32> in[N_IN], ap_uint<16> out[N_OUT]) {
#pragma HLS INTERFACE s_axilite port = in bundle = CTRL
#pragma HLS INTERFACE s_axilite port = out bundle = CTRL
#pragma HLS INTERFACE s_axilite port = return bundle = CTRL
#pragma HLS DATAFLOW
    hls::stream<input_t> in_s;
    hls::stream<result_t> out_s;
    feed(in, in_s);
    mymodel(in_s, out_s);
    drain(out_s, out);
}
cd axi && vitis_hls -f run_hls_axi.tcl
sed -n '8,40p' $(find . -name "x*_axi_hw.h" | head -1)     # the register map

Read offsets from the generated header. Vitis packs 16-bit array elements two per register, so a ten-class output occupies five words.

8. Build the bitstream

The build must assert the PS contract and cap block-RAM inference. Without the cap, Vivado’s automatic inference asked for 37,312 RAMB18 on a design whose entire storage is 1.3 Mb, and synthesis failed outright:

set_property -name {STEPS.SYNTH_DESIGN.ARGS.MORE OPTIONS} -value {-max_bram 288} \
    -objects [get_runs synth_1]
vivado -mode batch -source vivado/build_bd.tcl
cd out && printf 'all:\n{\n\t[destination_device = pl] design.bit\n}\n' > design.bif
bootgen -image design.bif -arch zynqmp -process_bitstream bin -w

9. Deploy and verify

tar czf - -C target . | ssh petalinux@$BOARD \
  'rm -rf ~/install && mkdir -p ~/install && tar xzf - -C ~/install'
ssh -t petalinux@$BOARD 'cd ~/install && sudo sh install.sh'
ssh petalinux@$BOARD 'sudo krio-fw load mymodel'

Compare against C simulation, not just against accuracy. Accuracy can match by coincidence; bit-exactness cannot.

top-1 accuracy on hardware : 0.8755  (1751 of 2000)
agreement with C simulation: 1.0000  (2000 of 2000)
max abs score difference   : 0.00000
latency                    : 929 us per image (1076.1 images/s)

Both deployed models reproduce their C simulation exactly. If yours does not, the fault is in the wrapper or the driver — the network itself is already proven by step 4.

Known tool issues

Observed with hls4ml 1.3.0 and Vitis HLS 2025.2. Every one fails quietly.

SymptomCause
vitis_hls: command not foundLauncher script missing since 2025.1; copy v++
Generated C++ will not compilennet::DenseResource_rf_gt_nin is emitted but never defined — keep reuse factors divisors of n_in × n_out
Accuracy collapses, no errorAutomatic precision inference; pin precisions
One pooling layer costs more than every convolutionWindowed pooling buffers the whole window under io_stream; use GlobalAveragePooling2D where the window covers the map
Build cannot find build_prj.tclSetting config['Flows'] replaced the writer flow
Two config_array_partition errors every build-maximum_size removed in 2025.2; harmless, the RTL is identical either way
Vivado demands tens of thousands of block RAMsAutomatic BRAM inference; cap it with -max_bram

Reference

Full command sequence: sw/hls4ml/RUNBOOK.md. Flow, drivers, readout service and benchmark suite: sw/hls4ml/.

  • Benchmarks 2026-08-26

    Four benchmark models from the hls4ml literature mapped onto one XCK26 — what fits, what it costs, how far precision can drop, and how far the tool's estimates can be trusted.