Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4b801ac
start to add qkeras v3
Apr 7, 2026
b04bbdd
Merge branch 'fastmachinelearning:main' into feature/add_qkeras_v3
makoeppel May 13, 2026
54134ce
work on qkeras integration
May 15, 2026
42e8b9b
work on qkeras tests
May 18, 2026
a8131bd
fix last tests
May 18, 2026
bd309cc
add qconv test
May 18, 2026
8f4ff93
run pre-commit
May 18, 2026
79654c8
update docu
May 18, 2026
2f80510
add eigensum test and test for https://github.com/fastmachinelearning…
May 18, 2026
3bd75a7
add IsolatedLayerReader class, create qkerasV3 test, update doc and p…
May 18, 2026
939c0ab
fix util import, remove _base class special case
May 19, 2026
b17d23a
use self.default_config
May 19, 2026
3953f34
rename util -> utils
May 19, 2026
f556feb
add qkeras-v3 testing
May 19, 2026
53d600c
remove not needed matching conditions
May 19, 2026
a4e9406
move IsolatedLayerReader
May 19, 2026
a477078
create QKerasV3LayerHandler to handle all qkeras layers
May 19, 2026
a1d1688
add back built check
May 19, 2026
c843e38
revert test_keras_v3_api.py
May 19, 2026
5e0196c
use math.prod
May 19, 2026
e6676d7
revert quantizers
May 19, 2026
5fe8b05
revert passes/qkeras.py
May 19, 2026
2cd5e30
revert converters/keras/qkeras.py
May 19, 2026
9665a0e
pre-commit changes
May 19, 2026
2a20082
update submodule, remove signed check
May 20, 2026
26a12a7
add back backend
May 20, 2026
862ac93
add qkeras-v3 testcase
May 20, 2026
eeaf804
test only qkerasV3
May 20, 2026
b01c722
update env
May 20, 2026
a6fac77
add back testing-keras3
May 20, 2026
f4342b0
add back all tests
May 20, 2026
edbbfe7
try with safe_mode=False
May 20, 2026
a5388f6
add compile=False
May 21, 2026
3766914
change packages
May 21, 2026
77e52bd
update qkeras-v3 template
May 21, 2026
49faba7
update script
May 21, 2026
4febd2d
update tests
May 21, 2026
6cd82d7
ci
May 21, 2026
346a2a5
ci
May 21, 2026
70f01f5
ci
May 21, 2026
9f0edd9
revert ci scripts
May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/intro/setup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The following Python packages are all optional and are only required if you inte

* Quantization support
* `QKeras <https://github.com/fastmachinelearning/qkeras>`_: based on Keras v2. See `frontend/keras <../frontend/keras.html>`_ for more details
* `QKeras-v3 <https://github.com/fastmachinelearning/qkeras-v3>`_: based on Keras v3. See `frontend/keras <../frontend/keras.html>`_ for more details
* `HGQ <https://github.com/calad0i/HGQ>`_: Based on Keras v2. See `advanced/HGQ <../advanced/hgq.html>`_ for more details.
* `HGQ2 <https://github.com/calad0i/HGQ2>`_: Based on Keras v3. See `advanced/HGQ2 <../advanced/hgq.html>`_ for more details.
* `Brevitas <https://xilinx.github.io/brevitas/>`_: Based on PyTorch. See `frontend/pytorch <../frontend/pytorch.html>`_ for more details.
Expand Down Expand Up @@ -197,6 +198,9 @@ Optional Dependencies
# For QKeras frontend
pip install hls4ml[qkeras]
# For QKeras-v3 frontend
pip install hls4ml[qkeras-v3]
# For Quartus report parsing
pip install hls4ml[quartus-report]
Expand Down
3 changes: 3 additions & 0 deletions docs/intro/status.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Frontend support:
* HGQ
* Keras v3

* QKeras-v3
* HGQ2
* PyTorch
* ONNX
Expand Down Expand Up @@ -59,6 +60,8 @@ A summary of the on-going status of the ``hls4ml`` tool is in the table below.
+-----------------------+-----+-----+--------------+--------+--------+-----+
| QKeras | ✅ | ✅ | ✅ | ✅ | N/A | N/A |
+-----------------------+-----+-----+--------------+--------+--------+-----+
| QKeras-v3 | ✅ | ✅ | ✅ | ✅ | ✅ | N/A |
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't garnet a specific example implemented in some version of qkeras v2?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should also work in qkeras v3.

+-----------------------+-----+-----+--------------+--------+--------+-----+
| HGQ | ✅ | ✅ | N/A | N/A | N/A | N/A |
+-----------------------+-----+-----+--------------+--------+--------+-----+
| Keras v3 | ✅ | ✅ | ✅ | N/A | ✅ | ❌ |
Expand Down
2 changes: 1 addition & 1 deletion example-models
1 change: 1 addition & 0 deletions hls4ml/converters/keras_v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
hgq2, # noqa: F401
merge, # noqa: F401
pooling, # noqa: F401
qkeras, # noqa: F401
recurrent, # noqa: F401
)
from ._base import registry as layer_handlers
Expand Down
1 change: 1 addition & 0 deletions hls4ml/converters/keras_v3/qkeras/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import activation, layer, utils
35 changes: 35 additions & 0 deletions hls4ml/converters/keras_v3/qkeras/activation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import Any

from hls4ml.converters.utils import IsolatedLayerReader

from ..core import KerasV3LayerHandler
from .utils import set_default_config


class QKerasQActivationHandler(KerasV3LayerHandler):
handles = ('qkeras.qlayers.QActivation', 'QActivation')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why QActivation needs to be defined w/o module? Is it defined via metaprogramming and don't have a fixed path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Keras does not return 'keras.layers.Activation' during serialization.

Copy link
Copy Markdown
Contributor

@calad0i calad0i May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The layer seen by the parser is a python object, and no serialization is used in keras v3 parser though.


def handle(
self,
layer,
in_tensors,
out_tensors,
) -> tuple[dict[str, Any], ...]:

config = layer.get_config()
layer_dict = {'config': config, 'class_name': layer.__class__.__name__}

reader = IsolatedLayerReader(layer)
input_shapes = [list(t.shape) for t in in_tensors]
input_names = [t.name for t in in_tensors]

from hls4ml.converters.keras_v2_to_hls import layer_handlers as v2_layer_handlers

v2_handler = v2_layer_handlers.get(layer.__class__.__name__)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, let's avoid using the v2 parser as it shall be eventually removed at some point... No a hard requirement but would be nice if we can do so.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree but maybe thats something for the future when the v2 parser will be removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. We could revisit the depracation in the future.

if v2_handler is None:
raise ValueError(f'No v2 handler found for {layer.__class__.__name__}')

hls_conf, _ = v2_handler(layer_dict, input_names, input_shapes, reader)
hls_conf = set_default_config(hls_conf, self.default_config)

return (hls_conf,)
Comment thread
makoeppel marked this conversation as resolved.
49 changes: 49 additions & 0 deletions hls4ml/converters/keras_v3/qkeras/layer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from hls4ml.converters.utils import IsolatedLayerReader

from ..core import KerasV3LayerHandler
from .utils import set_default_config


class QKerasV3LayerHandler(KerasV3LayerHandler):
handles = (
'qkeras.qlayers.QDense',
'qkeras.qconvolutional.QConv1D',
'qkeras.qconvolutional.QConv2D',
'qkeras.qconvolutional.QDepthwiseConv2D',
'qkeras.qconv2d_batchnorm.QConv2DBatchnorm',
)

def handle(self, layer, in_tensors, out_tensors):
config = layer.get_config()
layer_dict = {'config': config, 'class_name': layer.__class__.__name__}

reader = IsolatedLayerReader(layer)
input_shapes = [list(t.shape) for t in in_tensors]
input_names = [t.name for t in in_tensors]

from hls4ml.converters.keras_v2_to_hls import layer_handlers as v2_layer_handlers

v2_handler = v2_layer_handlers.get(layer.__class__.__name__)
if v2_handler is None:
raise ValueError(f'No v2 handler found for {layer.__class__.__name__}')

ret, _ = v2_handler(layer_dict, input_names, input_shapes, reader)
ret = set_default_config(ret, self.default_config)

activation = config.get('activation')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activation handling here is treated as a special case. The base class shall cover this already though...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now this layer.py which is more or less a base class. I can call it also _base.py if you want.

if activation not in (None, 'linear'):
from hls4ml.converters.keras.qkeras import get_activation_quantizer

activation_config = get_activation_quantizer(layer_dict, input_names)
intermediate_tensor_name = f'{out_tensors[0].name}_activation'
ret['output_keras_tensor_names'] = [intermediate_tensor_name]
activation_config.update(
{
'name': f'{layer.name}_activation',
'input_keras_tensor_names': [intermediate_tensor_name],
'output_keras_tensor_names': [out_tensors[0].name],
}
)
return ret, activation_config

return ret
5 changes: 5 additions & 0 deletions hls4ml/converters/keras_v3/qkeras/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def set_default_config(hls_conf, default_config):
for key, value in default_config.items():
if key not in hls_conf.keys():
hls_conf[key] = value
return hls_conf
13 changes: 2 additions & 11 deletions hls4ml/converters/keras_v3_to_hls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
from types import FunctionType
from typing import Any

import numpy as np

from hls4ml.converters.utils import IsolatedLayerReader
from hls4ml.model import ModelGraph

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -238,15 +237,7 @@ def v2_call(
config = layer.get_config()
layer_dict = {'config': config, 'class_name': layer.__class__.__name__}

class IsolatedLayerReader:
def get_weights_data(self, layer_name, var_name):
assert layer_name == layer.name, f'Processing {layer.name}, but handler tried to read {layer_name}'
for w in layer.weights:
if var_name in w.name:
return np.array(w)
return None

reader = IsolatedLayerReader()
reader = IsolatedLayerReader(layer)
input_shapes = [list(t.shape) for t in inp_tensors]
input_names = [t.name for t in inp_tensors]
output_names = [t.name for t in out_tensors]
Expand Down
14 changes: 14 additions & 0 deletions hls4ml/converters/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import math

import numpy as np


def parse_data_format(input_shape, data_format='channels_last'):
"""Parses the given input shape according to the specified data format.
Expand Down Expand Up @@ -287,3 +289,15 @@ def compute_padding_2d_pytorch(
pad_left = pad_width

return (out_height, out_width, pad_top, pad_bottom, pad_left, pad_right)


class IsolatedLayerReader:
def __init__(self, layer):
self.layer = layer

def get_weights_data(self, layer_name, var_name):
assert layer_name == self.layer.name, f'Processing {self.layer.name}, but handler tried to read {layer_name}'
for w in self.layer.weights:
if var_name in w.name:
return np.array(w)
return None
5 changes: 3 additions & 2 deletions hls4ml/model/quantizers.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ def __init__(self, config):
def __call__(self, data):
data = np.array(data, dtype='float32')
return self.quantizer_fn(data).numpy()
# return self.quantizer_fn(data)

def _get_type(self, quantizer_config):
width = quantizer_config['config']['bits']
Expand Down Expand Up @@ -170,7 +169,9 @@ def __init__(self, config, xnor=False):

def __call__(self, data):
data = np.array(data, dtype='float32')
y = self.quantizer_fn(data).numpy()
y = self.quantizer_fn(data)
if hasattr(y, 'numpy'):
y = y.numpy()
return self.binary_quantizer(y)

def serialize_state(self):
Expand Down
3 changes: 2 additions & 1 deletion hls4ml/model/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
higher-dimensional tensors, which are defined as arrays or FIFO streams in the generated code.
"""

import math
from enum import Enum

import numpy as np
Expand Down Expand Up @@ -837,7 +838,7 @@ def _format(self):

def __iter__(self):
data = self._format()
self._iterator = iter(data.reshape((np.product(data.shape[:-1]), 2)))
self._iterator = iter(data.reshape((math.prod(data.shape[:-1]), 2)))
return self

def __next__(self):
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ optional-dependencies.qkeras = [
"tensorflow>=2.8,<=2.14.1",
"tensorflow-model-optimization<=0.7.5",
]
optional-dependencies.qkeras-v3 = [ "qkeras-v3" ]
optional-dependencies.quartus-report = [ "calmjs-parse", "tabulate" ]
optional-dependencies.sr = [ "sympy>=1.13.1" ]
optional-dependencies.testing = [
Expand All @@ -72,6 +73,11 @@ optional-dependencies.testing-keras3 = [
"keras>=3.10",
"tensorflow>=2.15",
]
optional-dependencies.testing-qkeras-v3 = [
"keras==3.14.1",
"qkeras-v3",
"tensorflow>=2.21",
]
urls.Homepage = "https://fastmachinelearning.org/hls4ml"
scripts.hls4ml = "hls4ml.cli:main"
entry-points.pytest_randomly.random_seeder = "hls4ml:reseed"
Expand Down
6 changes: 6 additions & 0 deletions test/pytest/ci-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@
variables:
CONDA_ENV: "hls4ml-testing-keras3"
EXTRA_DEPS: "[da,testing,testing-keras3,sr]"

.pytest-qkeras-v3-only:
extends: .pytest
variables:
CONDA_ENV: "hls4ml-testing-keras3"
EXTRA_DEPS: "[qkeras-v3]"
18 changes: 17 additions & 1 deletion test/pytest/generate_ci_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
'test_multiout_onnx',
'test_keras_v3_profiling',
}
QKERAS3_LIST = {'test_qkerasV3'}

# Test files to split by individual test cases
# Value = chunk size per CI job
Expand Down Expand Up @@ -79,7 +80,7 @@ def generate_test_yaml(test_root='.'):
test_paths = [
path
for path in test_root.glob('**/test_*.py')
if path.stem not in (BLACKLIST | LONGLIST | set(SPLIT_BY_TEST_CASE.keys()) | KERAS3_LIST)
if path.stem not in (BLACKLIST | LONGLIST | set(SPLIT_BY_TEST_CASE.keys()) | KERAS3_LIST | QKERAS3_LIST)
]
need_example_models = [uses_example_model(path) for path in test_paths]

Expand Down Expand Up @@ -140,6 +141,21 @@ def generate_test_yaml(test_root='.'):
diff_yml = yaml.safe_load(template.format(name, '.pytest-keras3-only', test_files, batch_need_example_model))
yml.update(diff_yml)

qkeras3_paths = [path for path in test_root.glob('**/test_*.py') if path.stem in QKERAS3_LIST]
qkeras3_need_examples = [uses_example_model(path) for path in qkeras3_paths]

qk3_idxs = list(range(len(qkeras3_need_examples)))
qk3_idxs = sorted(qk3_idxs, key=lambda i: f'{qkeras3_need_examples[i]}_{path_to_name(qkeras3_paths[i])}')

for batch_idxs in batched(qk3_idxs, n_test_files_per_yml):
batch_paths: list[Path] = [qkeras3_paths[i] for i in batch_idxs]
names = [path_to_name(path) for path in batch_paths]
name = 'qkerasV3'
test_files = ' '.join([str(path.relative_to(test_root)) for path in batch_paths])
batch_need_example_model = int(any([qkeras3_need_examples[i] for i in batch_idxs]))
diff_yml = yaml.safe_load(template.format(name, '.pytest-qkeras-v3-only', test_files, batch_need_example_model))
yml.update(diff_yml)

return yml


Expand Down
Loading
Loading