Repository navigation
Description
Summary
QKeras recurrent layers (QSimpleRNN, QLSTM, QGRU) quantize their weights, and QKeras computes with the quantized values. hls4ml reads these quantizers from the model but never applies them to the weights of its SimpleRNN, LSTM and GRU layers. hls4ml then computes with slightly different weights than QKeras, so its output differs from the QKeras model.
This affects both QKeras packages hls4ml supports:
qkeras(QKeras 0.9, for Keras 2);qkeras-v3(QKeras for Keras 3).
They go through the same hls4ml code, so one fix covers both.
With 8-bit weights (quantized_bits(8, 0, alpha=1)), the largest difference from QKeras on the Vivado backend, using the script below, is:
| Layer | QKeras 0.9, now | QKeras 0.9, fixed | QKeras v3, now | QKeras v3, fixed |
|---|---|---|---|---|
QSimpleRNN (relu) |
0.061 | 0.0009 | 0.061 | 0.0009 |
| QLSTM | 0.097 | 0.009 | 0.105 | 0.018 |
| QGRU | 0.015 | 0.008 | 0.023 | 0.019 |
"Fixed" means with the change described under "What to change". The QSimpleRNN numbers need #1577 on Vivado and Vitis, which adds SimpleRNN to those backends. On main, QSimpleRNN on Vivado and Vitis converts to an empty design.
Background
A QKeras recurrent layer takes three weight quantizers, for example:
QLSTM(8, kernel_quantizer='quantized_bits(8, 0, alpha=1)',
recurrent_quantizer='quantized_bits(8, 0, alpha=1)',
bias_quantizer='quantized_bits(8, 0, alpha=1)')The model stores the weights as floats, and QKeras applies the quantizers each time it computes.
How hls4ml reads them. hls4ml's QKeras parser for recurrent layers, parse_qrnn_layer in hls4ml/converters/keras/qkeras.py, reads the quantizers into three layer attributes: weight_quantizer, recurrent_weight_quantizer and bias_quantizer.
- Keras 2 models use it directly.
- Keras 3 models (QKeras v3) get there too. hls4ml's Keras 3 converter has QKeras handlers only for
QDense, the convolutions andQConv2DBatchnorm(hls4ml/converters/keras_v3/qkeras/layer.py). Recurrent layers fall back to the Keras 2 handler, and conversion printsKeras v2 handler used for layer ....
Where the quantizers should be applied. For other layers, the quantizer is applied when the weights are added to the layer. For example, Dense.initialize in hls4ml/model/layers.py does:
self.add_weights(quantizer=self.get_attr('weight_quantizer'), compression=...)
self.add_bias(quantizer=self.get_attr('bias_quantizer'))SimpleRNN.initialize, LSTM.initialize and GRU.initialize in the same file add their weights without a quantizer:
self.add_weights()
self.add_weights_variable(name='recurrent_weight', var_name='wr{index}')
self.add_weights_variable(name='bias', var_name='b{index}')So hls4ml takes the float weights and converts them to the weight type (fixed<8,1> here) by truncation, while QKeras rounds them. The two sets of weights differ by up to one step of the type.
How to reproduce
The same script works with both packages. Run it with either:
qkeras(QKeras 0.9) and TensorFlow 2.14 with Keras 2, the versions hls4ml's Keras 2 tests use; orqkeras-v3with Keras 3, which is whatpip install hls4ml[qkeras-v3]installs.
qkeras-v3 pins its own Keras and TensorFlow versions, so give it an environment of its own.
QKeras 0.9 with TensorFlow 2.14 fails inside QKeras's recurrent layers with AttributeError: module 'tensorflow.python.util.nest' has no attribute 'is_sequence', and QGRU then also fails on array_ops.unstack. QKeras calls two TensorFlow functions that TensorFlow 2.14 removed. The first lines of the script put them back when they're missing. They aren't part of the fix, and QKeras v3 doesn't need them.
import keras
import numpy as np
import tensorflow as tf
from tensorflow.python.ops import array_ops
from tensorflow.python.util import nest
# QKeras 0.9.0 (Keras 2) calls two TensorFlow functions that TensorFlow 2.14 removed. Not needed for QKeras v3.
if not hasattr(nest, 'is_sequence'):
nest.is_sequence = nest.is_nested
if not hasattr(array_ops, 'unstack'):
array_ops.unstack = tf.unstack
from qkeras import QGRU, QLSTM, QSimpleRNN # noqa: E402
from qkeras.quantizers import quantized_bits # noqa: E402
import hls4ml # noqa: E402
q = 'quantized_bits(8, 0, alpha=1)'
quantizers = dict(kernel_quantizer=q, recurrent_quantizer=q, bias_quantizer=q)
layers = {
'QSimpleRNN': lambda: QSimpleRNN(8, activation='relu', **quantizers),
'QLSTM': lambda: QLSTM(8, activation='tanh', recurrent_activation='sigmoid', **quantizers),
'QGRU': lambda: QGRU(8, activation='tanh', recurrent_activation='sigmoid', reset_after=True, **quantizers),
}
X = np.round(np.random.default_rng(0).uniform(-0.5, 0.5, (50, 6, 4)) * 2**7) * 2**-7
for name, make_layer in layers.items():
keras.utils.set_random_seed(0)
x_in = keras.Input((6, 4))
model = keras.Model(x_in, make_layer()(x_in))
y_qkeras = model.predict(X, verbose=0)
config = hls4ml.utils.config_from_keras_model(
model, granularity='name', backend='Vivado', default_precision='ap_fixed<24,10>'
)
hls_model = hls4ml.converters.convert_from_keras_model(
model, hls_config=config, backend='Vivado', output_dir=f'hls4mlprj_{name}'
)
hls_model.compile()
y_hls = hls_model.predict(X).reshape(y_qkeras.shape)
rnn = model.layers[1]
kernel = rnn.get_weights()[0]
hls_kernel = hls_model.graph[rnn.name].get_weights('weight').data.reshape(kernel.shape)
quantized = np.array_equal(hls_kernel, np.asarray(quantized_bits(8, 0, alpha=1)(kernel)))
print(f'{name}: kernel quantized like QKeras: {quantized}, max difference from QKeras: {np.max(np.abs(y_hls - y_qkeras)):.4f}')The script looks the layer up by its actual name, because QKeras v3's recurrent layers don't keep a name= passed to them.
Output today with QKeras 0.9, with #1577 so that QSimpleRNN works on Vivado:
QSimpleRNN: kernel quantized like QKeras: False, max difference from QKeras: 0.0610
QLSTM: kernel quantized like QKeras: False, max difference from QKeras: 0.0968
QGRU: kernel quantized like QKeras: False, max difference from QKeras: 0.0146
With QKeras v3 the first column is the same, and the differences are 0.0610, 0.1054 and 0.0231.
No HLS tool is needed: compile() builds the C++ with the system compiler.
What to change
-
In
hls4ml/model/layers.py, pass the quantizers whenSimpleRNN,LSTMandGRUadd their weights:self.add_weights(quantizer=self.get_attr('weight_quantizer'));quantizer=self.get_attr('recurrent_weight_quantizer')forrecurrent_weight;quantizer=self.get_attr('bias_quantizer')forbias. GRU withreset_after=Truealso has arecurrent_bias, which Keras stores in the same bias array, so check whether QKeras appliesbias_quantizerto it too.
For layers that don't come from QKeras these attributes are
None, and nothing changes.With this change, the script prints
Truefor all three layers with both packages, and the differences in the "fixed" columns above.- QKeras 0.9: the remaining difference comes from the lookup tables used for
tanhandsigmoid. - QKeras v3: QLSTM and QGRU keep a somewhat larger difference (0.018 and 0.019). That's worth a look while working on this.
-
Check the Altera backend. Its
init_lstm, inhls4ml/backends/altera/altera_backend.py, splits the LSTM weights per gate and quantizes the pieces itself. After step 1, the weights arrive already quantized. Quantizing again with the same quantizer changes nothing, butinit_lstmusesweight_quantizerfor the kernel, the recurrent kernel and the bias. It should userecurrent_weight_quantizerandbias_quantizerfor the last two, and this matters when the three quantizers differ. The Quartus backend has the same code, but Retire Quartus backend #1537 removes that backend, so leave it as it is. -
Tests. The QKeras recurrent tests exist twice: in
test/pytest/test_qkeras.pyfor QKeras 0.9, and intest/pytest/test_qkerasV3.pyfor QKeras v3. CI runs the second file in a separate job withpip install .[qkeras-v3].test_qsimplernn,test_qlstmandtest_qgruin both files compare with QKeras withatol=0.1, which is loose enough to hide this problem. Tighten it to a value the fixed code passes with a margin, for example 0.02 or 0.03. Check the actual differences with both packages first.- Once Fix recurrent layers on Vivado and Vitis, and add SimpleRNN #1577 is merged, add
'Vivado'and'Vitis'totest_qsimplernnin both files, and add'Vitis'totest_qlstmandtest_qgru. Intest_qkeras.py, do the same in theQSimpleRNN,QLSTMandQGRUentries ofNO_QUANT_LAYERS. - A test that would fail today: a QLSTM or QSimpleRNN with 8-bit weight quantizers, compared with QKeras at the tighter tolerance.
Related problems, not part of this issue
These came up while looking at QKeras recurrent layers. Each could be its own issue.
- State quantizer: QKeras's
state_quantizeris parsed intoaccum_quantizerbyparse_qrnn_layerand never used. - Activation quantizers in LSTM and GRU: QKeras applies them inside the cell. hls4ml only applies them where the layer's result type is written, so LSTM and GRU with quantized activations are approximated. SimpleRNN is exact, because its state is its output.
hard_sigmoidandhard_tanh: these aren't supported by recurrent layers on Vivado and Vitis. QKeras'squantized_sigmoidandquantized_tanh, and QLSTM's default recurrent activation, map to them. After Fix recurrent layers on Vivado and Vitis, and add SimpleRNN #1577 the conversion stops with a clear message; before, it failed in the C++ compiler.nnet_activation.halready has both functions for standalone activations, sonnet_recr_activations.hwould need wrappers for them, plus their slope and shift settings.QBidirectional(QKeras v3): not supported. The conversion stops withValueError: Layer <class 'qkeras.qrecurrent.QBidirectional'> not found in registry.
Activity
abhinav-rb commentedon Oct 22, 2026
Hi! I'd like to take this one. Could I be assigned?
Plan:
hls4ml/model/layers.py, passweight_quantizer,recurrent_weight_quantizerandbias_quantizerwhenSimpleRNN,LSTMandGRUadd their weights. I'll also check whether QKeras appliesbias_quantizerto GRU'srecurrent_biaswithreset_after=True.init_lstmso the recurrent kernel and bias userecurrent_weight_quantizerandbias_quantizer. I'll leave Quartus as is since Retire Quartus backend #1537 removes it.test_qkeras.pyandtest_qkerasV3.py, check the real differences with both packages, then tighten theatolfortest_qsimplernn,test_qlstmandtest_qgru. Since Fix recurrent layers on Vivado and Vitis, and add SimpleRNN #1577 is merged, I'll also make the Vivado/Vitis test updates the issue describes and add an 8-bit QLSTM/QSimpleRNN test that fails on main.fix(qkeras): apply quantizers to recurrent kernels and biases (fastma…
fix(altera): use distinct recurrent and bias quantizers for LSTM (fas…
test(qkeras): verify recurrent weight quantization without synthesis (f…
test(qkeras): verify recurrent weight quantization without synthesis (f…
test(qkeras-v3): resolve recurrent layer by actual Keras name (fastma…
Apply QKeras weight quantizers to SimpleRNN, LSTM and GRU