Skip to content

Apply QKeras weight quantizers to SimpleRNN, LSTM and GRU - #1598

Open
dgaytanv wants to merge 4 commits into
fastmachinelearning:mainfrom
dgaytanv:qkeras-rnn-quantizers
Open

dgaytanv wants to merge 4 commits into
fastmachinelearning:mainfrom
dgaytanv:qkeras-rnn-quantizers

Conversation

@dgaytanv

@dgaytanv dgaytanv commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Description

parse_qrnn_layer reads the kernel, recurrent and bias quantizers of QSimpleRNN, QLSTM and QGRU into the weight_quantizer, recurrent_weight_quantizer and bias_quantizer attributes, but SimpleRNN, LSTM and GRU in hls4ml/model/layers.py added their weights without them. hls4ml therefore truncated the float weights to the weight type while QKeras rounds them, and the two computed with weights that differ by up to one step of the type. The layers now pass the quantizers when they add their weights, as Dense does. For layers that do not come from QKeras the attributes are None and nothing changes.

The Altera backend's init_lstm splits the LSTM weights per gate and quantizes the pieces itself. It used weight_quantizer for the kernel, the recurrent kernel and the bias; it now uses recurrent_weight_quantizer and bias_quantizer for the last two, and cuts the pieces from data_unquantized, so they are quantized once (the whole arrays are already quantized when init_lstm runs). This only matters when the three quantizers differ. The Quartus backend has the same code and is left alone because #1537 removes it.

The Resource strategy of Vivado and Vitis (#1589) zero-pads the recurrent kernels to whole lanes. With power-of-two weights ExponentWeightVariable._format took log2(0) of the padding and wrote -9223372036854775808 as the exponent (with numpy warnings); it now gives zeros the smallest exponent of the type, -2**(width-1) for a signed type. The kernel skips the padded lanes, so the outputs do not change.

The Keras 3 converter has no handler for the qkeras-v3 recurrent layers; they go through the Keras 2 fallback in KerasV3HandlerDispatcher.v2_call, which appended a separate Activation layer for the layer's activation. The recurrent layer applies its activation itself, so tanh was applied twice to QLSTM and QGRU outputs (relu is idempotent, so QSimpleRNN was unaffected). The fallback now skips the extra layer for recurrent layers, as the Keras 2 converter does. This is in its own commit and can be split off.

With the script from #1579 (8-bit quantized_bits(8, 0, alpha=1) weights, Vivado, ap_fixed<24,10>), the largest difference from QKeras:

Layer QKeras 0.9, main QKeras 0.9, this PR QKeras v3, main QKeras v3, this PR
QSimpleRNN (relu) 0.0610 0.0009 0.0610 0.0009
QLSTM 0.0968 0.0091 0.1054 0.0091
QGRU 0.0146 0.0079 0.0231 0.0079

Vitis gives the same numbers. Power-of-two and binary kernels were unusable before. On the test model below (weights drawn from (-0.5, 0.5), 50 inputs, Vivado) with quantized_po2(4) kernels and an 8-bit bias, the difference from QKeras on main is 0.45 / 0.22 / 0.48 for QSimpleRNN / QLSTM / QGRU, with output ranges of 0.68 / 0.56 / 1.27, because hls4ml wrote the float weights into the exponent type; with this PR it is 0.0005 / 0.0102 / 0.0096. With binary(alpha=1) kernels and an 8-bit bias, main gives 310 / 0.50 / 1.38 (the output range); with this PR the weights match QKeras' (checked), and the outputs differ from QKeras by up to 0.0000 / 0.023 / 0.041. All of these are the same with both QKeras packages. The LSTM and GRU residuals with binary kernels are larger than with 8-bit ones, which I have not looked into (the weights are right, so it is in the kernels' handling of ±1 weights, which this PR does not touch); binary is not in the test below.

The remaining difference is hls4ml's tanh / sigmoid lookup tables: for every layer and both QKeras packages, the QKeras output equals a plain Keras layer loaded with the quantized weights to 4 decimals, and hls4ml differs from that float reference by the amounts in the table. The main column differs between the two packages because of the double activation in the Keras 3 converter described above; with that fixed the two packages give identical outputs.

Fixes #1579

Related, not changed here

  • ternary() and binary() with their default alpha='auto' / 'auto_po2' on recurrent layers: QKeras scales each output column by its own factor, and hls4ml's QKerasFactorizeAlpha pass moves those scales into an ApplyAlpha layer only for Dense and convolution layers. For recurrent layers the scaled values go into a 1- or 2-bit integer type, so the result is wrong on main and still wrong with this PR (QKeras vs hls4ml differences equal the output range). Supporting that needs the pass extended to recurrent layers; I can open an issue.
  • A power-of-two bias quantizer does not compile, on Dense either (nnet_dense_latency.h:49: no matching function for call to 'ap_fixed<24, 10>::ap_fixed(config2_1::bias_t&)', same on main). The recurrent layers now hit it the same way as Dense.
  • Quartus init_lstm still quantizes every per-gate piece with the kernel quantizer and gives it the kernel's type. With a finer kernel quantizer the values are right (re-quantizing already-quantized weights on a finer grid is a no-op); a coarser kernel quantizer than the bias (3-bit kernel, 8-bit bias) fails to compile because nnet::lstm takes all biases as CONFIG_T::bias_t. Same code on main, and Retire Quartus backend #1537 removes the backend.
  • Bidirectional: the parser accepts QLSTM / QGRU sublayers but never reads their quantizers, and Bidirectional(QLSTM) crashes on Keras 3 (Bidirectional.initialize checks for the class name LSTM, so QLSTM takes the GRU branch). Pre-existing; not touched here.

Type of change

  • Bug fix (non-breaking)

Affected areas

Backends:

  • Altera (init_lstm quantizer choice and slicing)
  • Backend-independent (core IR, optimizer, hls4ml.model: layers.py, types.py)

Frontends:

  • Keras v2 (QKeras 0.9), Keras v3 (qkeras-v3; recurrent layers go through the Keras 2 handler, whose activation handling is fixed here)

Components:

  • IR (layers + model graph) / optimizer passes

Configurations affected and exercised

Backend io_type Strategy Verified
Vivado io_parallel Latency, Resource pytest, QKeras 0.9 and qkeras-v3
Vitis io_parallel Latency, Resource pytest, QKeras 0.9 and qkeras-v3
Quartus io_parallel all pytest (test_qsimplernn, test_qlstm, test_qgru, weight check + compile), QKeras 0.9 and qkeras-v3
Altera io_parallel all conversion only (whole-array and per-gate weights and types checked against QKeras' quantized values); compile() needs the Altera toolchain, which I do not have

Impact on generated HLS

  • This PR changes the generated HLS. Numbers below.

Numerical behaviour: intentionally changed for QKeras recurrent layers only: the weight arrays written into the project are now the QKeras-rounded values instead of the float weights. The weight types are unchanged: config_from_keras_model already took them from the quantizers (fixed<8,1> for the layers above), only the values written into them differ. Models without QKeras recurrent layers are bit-exact vs main. With power-of-two weights under the Resource strategy the padded exponents are now the type's minimum instead of the int64 minimum. I did not run synthesis; the kernels are unchanged, only the weight values.

Model / test Backend & version Part Latency (cycles) II LUT FF DSP BRAM
before n/a n/a n/a n/a n/a n/a n/a n/a
after n/a n/a n/a n/a n/a n/a n/a n/a

Tests

In test/pytest/test_qkeras.py and test/pytest/test_qkerasV3.py:

  • test_qsimplernn, test_qlstm and test_qgru now also run on Vivado and Vitis (QSimpleRNN needs Fix recurrent layers on Vivado and Vitis, and add SimpleRNN #1577, merged), use a different quantizer per weight (8-bit kernel, 6-bit recurrent kernel, 4-bit bias), and check on all four backends that the layer's kernel, recurrent kernel and bias (and GRU's recurrent bias) equal the QKeras-quantized values of the model's weights; the helper transposes the expectation on Quartus and Altera, which store the kernels transposed. In test_qkeras.py the QSimpleRNN, QLSTM and QGRU entries of NO_QUANT_LAYERS run on all backends too.
  • New test_qrnn_weight_quantizers, parametrized over the three layers, Vivado / Vitis, Latency / Resource (ReuseFactor=3), and two quantizer sets, quantized_bits 8 / 6 / 4 bits and quantized_po2(4) / quantized_po2(5) / 8-bit bias: the model from QKeras recurrent layers (QSimpleRNN, QLSTM, QGRU): weight quantizers are not applied, so results differ from QKeras (Keras 2 and Keras 3) #1579 with 50 inputs, the weight check (Latency only, since the Resource layout reorders the data), a check that every written power-of-two exponent is within the type, and a comparison with QKeras at atol=0.05.
  • New test_qlstm_altera_gate_quantizers (conversion only, no toolchain): a QLSTM with 8 / 4 / 3-bit quantizers on Altera; checks the whole-array weights and each gate's weight_g, recurrent_weight_g and bias_g against the QKeras-quantized slices, and their types fixed<8,1>, fixed<4,1>, fixed<3,1>.
  • The existing atol=0.1 in the three tests is kept. In their configuration (ap_fixed<8,1> for everything, including accumulators and results, one input) the difference is dominated by the 8-bit arithmetic, not by the weights: measured over three seeds on Vivado and Vitis, QLSTM is 0.025–0.035 on main and the same with this PR, QGRU 0.029–0.045 on main and 0.028–0.037 with this PR. A tolerance that fails main and passes the fix does not exist there, so the weight check carries the regression test in those three and the new test carries the numerical one.

On main with these test files, test_qsimplernn, test_qlstm and test_qgru on Vivado, Vitis and Quartus, test_qrnn_weight_quantizers and test_qlstm_altera_gate_quantizers all fail (34 failures per file, at the weight check). On this branch:

# QKeras 0.9 / TF 2.14 / Keras 2.14 (test_rnn.py, test_qkeras.py; -k "Vivado or Vitis")
442 passed, 10 skipped
# qkeras-v3 / TF 2.21 / Keras 3.14.1 (test_rnn.py, test_qkerasV3.py; -k "Vivado or Vitis")
398 passed

The Quartus variants of the three modified tests and the Altera conversion test pass in both environments; the Altera variants of the three modified tests fail at compile() with Could not find ahls, as on main.

QKeras 0.9 with TensorFlow 2.14 fails inside its recurrent layers with AttributeError: module 'tensorflow.python.util.nest' has no attribute 'is_sequence' (and array_ops.unstack), as noted in #1579. The local runs above put those two functions back through a pytest plugin outside the repository; nothing in this PR works around it.

Test configuration: Linux, Python 3.10.20 with TensorFlow 2.14.1 / Keras 2.14.0 / QKeras 0.9.0, and Python 3.11 with TensorFlow 2.21.0 / Keras 3.14.1 / qkeras-v3 1.2.1. No HLS tool.

AI assistance disclosure

  • Substantial — significant AI-generated portions, reviewed and edited by me.

Tool(s) and model(s): Claude Code (Claude Fable 5.1)

Where it was used: the layer and Altera changes (as proposed in #1579), the types.py and Keras 3 converter changes, the test changes, the measurement scripts behind the numbers above, and the drafts of this description and of the review replies.

  • I am the author of this contribution and take full responsibility for it. I have read and understood every line and can explain and defend it in review.
  • I verified the generated code against real hls4ml and HLS semantics — no invented APIs, config keys, pragmas or citations.
  • All numbers, logs and test results quoted in this PR come from runs I actually performed, not from agent output.
  • I have the right to submit this work under the project's licence, and to my knowledge it does not reproduce third-party code that would conflict with it.
  • No AI tool is credited as an author in any commit in this branch.

Checklist

  • I have read the contributing guidelines.
  • I installed and ran pre-commit on the files I edited.
  • I added tests under test/pytest covering this change (a bug fix should add a test that fails on main and passes here).
  • I self-reviewed the full diff and it contains no leftover debug code, commented-out blocks or unrelated changes.
  • The AI assistance disclosure above is complete and accurate.

Release note

QKeras recurrent layers (QSimpleRNN, QLSTM, QGRU) now use their kernel, recurrent and bias quantizers, so hls4ml computes with the same weights as QKeras.

The QKeras parser reads the kernel, recurrent and bias quantizers of
QSimpleRNN, QLSTM and QGRU into layer attributes, but the recurrent layers
added their weights without them, so hls4ml wrote the float weights into the
weight type while QKeras rounds them. Pass the quantizers when the weights are
added, as Dense does. The Altera LSTM initializer, which splits the weights per
gate, now uses the recurrent and bias quantizers for the recurrent kernel and
the bias instead of the kernel quantizer for all three.

Tests: the QKeras recurrent tests check that the weights equal the
QKeras-quantized values and run on Vivado and Vitis too; a new test compares
QSimpleRNN, QLSTM and QGRU with 8-bit and power-of-two weights against QKeras
on both backends.

Fixes fastmachinelearning#1579
@JanFSchulte

Copy link
Copy Markdown
Contributor

Thanks for this PR. This is what Claude Opus 5.5 found:

Review: #1598 — Apply QKeras weight quantizers to SimpleRNN, LSTM and GRU

Verdict: request changes. The fix itself is correct. The blocking issue is in the tests: the new weight check makes the Quartus and Altera recurrent tests fail, and the tests can't tell the three quantizers apart.

Tested with: qkeras-v3 (Keras 3.14.1, TF 2.21) on the PR branch. I did not run test_qkeras.py (Keras 2 / QKeras 0.9).

What's right

  • Attribute names match what parse_qrnn_layer sets: weight_quantizer, recurrent_weight_quantizer, bias_quantizer.
  • Other models are unaffected: layers that don't come from QKeras get None for these attributes, so nothing changes for them.
  • GRU recurrent_bias using bias_quantizer is correct.
  • Weight types are unchanged. config_from_keras_model already took them from the quantizers; the bug was only that float values were truncated into those types instead of rounded the way QKeras does.
  • po2 weights work on both Vivado/Vitis paths. The Latency path and the new Resource cell from Replace the resource cell of recurrent layers on Vivado and Vitis #1589 both multiply through mult_config::product, so po2 weights get the shift product.
  • Vivado/Vitis tests pass: all 18 recurrent tests in test_qkerasV3.py pass on this branch.

Blocking: the weight check fails on Quartus and Altera

assert_rnn_weights_quantized compares w.data with quantizer(w.data_unquantized) element by element. Quartus and Altera transpose weight and recurrent_weight during conversion (hls4ml/backends/altera/passes/resource_strategy.py), so the arrays no longer line up.

  • Quartus: test_qsimplernn, test_qlstm and test_qgru pass on main and fail on this branch, at the new assertion:

    AssertionError:
    (shapes (4, 2), (2, 4) mismatch)
    
  • Altera: in my environment the tests fail earlier, because the ahls toolchain is missing. Anywhere the toolchain is installed, they will reach the same assertion and fail the same way.

Once the transpose is accounted for, the values are correct on both backends, so the problem is the test, not the fix. One option is to pass the backend into the helper:

def assert_rnn_weights_quantized(hls_layer, quantizers, backend):
    for name, q in quantizers.items():
        w = hls_layer.weights[name]
        expected = np.asarray(q(w.data_unquantized))
        if backend in ('Quartus', 'Altera') and expected.ndim == 2:
            expected = expected.T
        np.testing.assert_array_equal(w.data, expected)

Test coverage

The tests can't distinguish the quantizers

The existing tests use the same quantizer for kernel, recurrent and bias, and the new test_qrnn_weight_quantizers uses the same one for kernel and recurrent.

As a mutation test, I swapped weight_quantizer and recurrent_weight_quantizer in LSTM.initialize. All six QLSTM tests still passed on Vivado/Vitis (test_qlstm plus the QLSTM cases of test_qrnn_weight_quantizers).

To catch that kind of mix-up:

  • Use distinct quantizers, e.g. an 8-bit kernel, a 6-bit recurrent kernel and a 4-bit bias. Taking a name→quantizer dict, as in the helper above, makes this straightforward.
  • Also check GRU's recurrent_bias in the helper.

The Altera change itself is untested

A conversion-only test needs no toolchain. It could convert a QLSTM with distinct quantizers and check weight_i, recurrent_weight_i and bias_i. I ran that check:

  • Altera gives fixed<8,1>, fixed<4,1> and fixed<3,1> respectively, with correct values.
  • Altera's lstm_config typedefs each gate's type separately, so distinct types are consistent there.
  • I couldn't compile it, since that needs SYCL.

The Resource strategy isn't covered

The zero padding in #1589's weight layout interacts badly with po2 weights. To reproduce on Vivado:

q = dict(kernel_quantizer='quantized_po2(4)', recurrent_quantizer='quantized_po2(4)',
         bias_quantizer='quantized_bits(8, 0, alpha=1)')
x = Input((6, 4))
model = Model(x, QLSTM(8, activation='tanh', recurrent_activation='sigmoid', **q)(x))
config = hls4ml.utils.config_from_keras_model(model, granularity='name', backend='Vivado',
                                              default_precision='ap_fixed<24,10>')
name = model.layers[1].name
config['LayerName'][name]['Strategy'] = 'Resource'
config['LayerName'][name]['ReuseFactor'] = 3  # n_cycles=3, 2 input lanes -> 6 rows for 4 inputs

The layout pads the 4 input rows to 6 with zeros, and ExponentWeightVariable._format then takes log2(0). The result:

  • 64 -9223372036854775808 exponent literals end up in w2.h (48 for QGRU);
  • numpy emits divide-by-zero and invalid-cast warnings.

It still compiles, and the outputs match QKeras (max difference 0.021 for QLSTM, 0.031 for QGRU) because the kernel skips padded lanes (if (i < n_in)). But this PR makes the case reachable.

I'd suggest:

  • handling zeros in _format, or padding with a valid po2 value;
  • adding a Resource variant to the new test. That variant should compare outputs only, because the layout reorders w.data.

Smaller points

Quartus

Leaving Quartus for #1537 is reasonable, but #1537 hasn't merged yet. Things I observed:

  • Right values by accident. When the kernel quantizer is finer than the others, Quartus now gets the right values: LSTM.initialize already quantizes the layer-level weights, and re-quantizing them onto a finer grid changes nothing. The per-gate types are still the kernel's, though.

  • Coarser kernel quantizer fails to compile. With a 3-bit kernel and 8-bit recurrent and bias quantizers, Quartus LSTM fails to compile:

    no known conversion for argument 11 from 'const bias_i2_t [8]' {aka 'const ac_fixed<3, 1, true> [8]'}
    to 'const config2::bias_t*' {aka 'const ac_fixed<8, 1, true>*'}
    

    main has the same per-gate code, so this is almost certainly pre-existing rather than a regression.

  • The Altera fix wouldn't carry over directly. Quartus's nnet::lstm takes all kernels as CONFIG_T::weight_t and all biases as CONFIG_T::bias_t. That supports leaving Quartus alone in this PR.

Double quantization in Altera init_lstm

init_lstm slices layer.weights[...].data, which LSTM.initialize has already quantized, and then quantizes the slices again. This is harmless for alpha=1 and po2.

However, QKerasFactorizeAlpha notes that the scale has to be computed from the unquantized weights. Slicing data_unquantized instead would be cleaner, even though alpha != 1 isn't supported on recurrent layers yet.

Bidirectional

Two problems here, both predating this PR:

  • Quantizers are ignored. The parser accepts QLSTM/QGRU sublayers but never reads their quantizers, so Bidirectional has the same bug this PR fixes.
  • Bidirectional(QLSTM) crashes on Keras 3. Bidirectional.initialize checks {dir}_class_name == 'LSTM', so 'QLSTM' falls into the GRU branch and fails on None recurrent-bias data (AttributeError: 'NoneType' object has no attribute 'shape').

These belong in the "Related, not changed here" list or a follow-up issue.

Not verified

Expanding NO_QUANT_LAYERS to all backends in test_qkeras.py, which needs the Keras 2 stack.

The Keras 3 converter has no handler for the qkeras-v3 recurrent layers, so
they go through the Keras 2 fallback, which appended a separate Activation
layer for the layer's activation. The recurrent layer applies its activation
itself, so tanh was applied twice to the output of QLSTM and QGRU (relu is
idempotent, so QSimpleRNN was unaffected). Skip the extra layer for recurrent
layers, as the Keras 2 converter does.
…two weights

Review follow-up for the QKeras recurrent quantizers.

The tests use a different quantizer for the kernel, the recurrent kernel and
the bias (8, 6 and 4 bits, or 4- and 5-bit power-of-two with an 8-bit bias),
so a quantizer applied to the wrong weight is caught; GRU also checks the
recurrent bias. The weight check compares against the quantized unquantized
data and transposes the expectation on Quartus and Altera, which store the
kernels transposed, so the three backend tests now check the weights on all
four backends instead of skipping the check there. A conversion-only test
checks the per-gate Altera LSTM weights and their types; the whole-array
weights are quantized before the Altera initializer runs, so the per-gate
pieces are now cut from the unquantized data and quantized once, as before.

The Resource strategy of Vivado and Vitis zero-pads the recurrent kernels,
and the power-of-two formatter took log2 of those zeros, writing the minimum
int64 as the exponent. Zeros now get the smallest exponent of the type. The
new test runs the Latency and Resource strategies, checks that every
exponent is within the type, and compares the outputs against QKeras.
@dgaytanv

Copy link
Copy Markdown
Contributor Author

Thanks for this PR. This is what Claude Opus 5.5 found:

Review: #1598 — Apply QKeras weight quantizers to SimpleRNN, LSTM and GRU

Verdict: request changes. The fix itself is correct. The blocking issue is in the tests: the new weight check makes the Quartus and Altera recurrent tests fail, and the tests can't tell the three quantizers apart.

Tested with: qkeras-v3 (Keras 3.14.1, TF 2.21) on the PR branch. I did not run test_qkeras.py (Keras 2 / QKeras 0.9).

What's right

  • Attribute names match what parse_qrnn_layer sets: weight_quantizer, recurrent_weight_quantizer, bias_quantizer.
  • Other models are unaffected: layers that don't come from QKeras get None for these attributes, so nothing changes for them.
  • GRU recurrent_bias using bias_quantizer is correct.
  • Weight types are unchanged. config_from_keras_model already took them from the quantizers; the bug was only that float values were truncated into those types instead of rounded the way QKeras does.
  • po2 weights work on both Vivado/Vitis paths. The Latency path and the new Resource cell from Replace the resource cell of recurrent layers on Vivado and Vitis #1589 both multiply through mult_config::product, so po2 weights get the shift product.
  • Vivado/Vitis tests pass: all 18 recurrent tests in test_qkerasV3.py pass on this branch.

Blocking: the weight check fails on Quartus and Altera

assert_rnn_weights_quantized compares w.data with quantizer(w.data_unquantized) element by element. Quartus and Altera transpose weight and recurrent_weight during conversion (hls4ml/backends/altera/passes/resource_strategy.py), so the arrays no longer line up.

  • Quartus: test_qsimplernn, test_qlstm and test_qgru pass on main and fail on this branch, at the new assertion:
    AssertionError:
    (shapes (4, 2), (2, 4) mismatch)
    
  • Altera: in my environment the tests fail earlier, because the ahls toolchain is missing. Anywhere the toolchain is installed, they will reach the same assertion and fail the same way.

Once the transpose is accounted for, the values are correct on both backends, so the problem is the test, not the fix. One option is to pass the backend into the helper:

def assert_rnn_weights_quantized(hls_layer, quantizers, backend):
    for name, q in quantizers.items():
        w = hls_layer.weights[name]
        expected = np.asarray(q(w.data_unquantized))
        if backend in ('Quartus', 'Altera') and expected.ndim == 2:
            expected = expected.T
        np.testing.assert_array_equal(w.data, expected)

Test coverage

The tests can't distinguish the quantizers

The existing tests use the same quantizer for kernel, recurrent and bias, and the new test_qrnn_weight_quantizers uses the same one for kernel and recurrent.

As a mutation test, I swapped weight_quantizer and recurrent_weight_quantizer in LSTM.initialize. All six QLSTM tests still passed on Vivado/Vitis (test_qlstm plus the QLSTM cases of test_qrnn_weight_quantizers).

To catch that kind of mix-up:

  • Use distinct quantizers, e.g. an 8-bit kernel, a 6-bit recurrent kernel and a 4-bit bias. Taking a name→quantizer dict, as in the helper above, makes this straightforward.
  • Also check GRU's recurrent_bias in the helper.

The Altera change itself is untested

A conversion-only test needs no toolchain. It could convert a QLSTM with distinct quantizers and check weight_i, recurrent_weight_i and bias_i. I ran that check:

  • Altera gives fixed<8,1>, fixed<4,1> and fixed<3,1> respectively, with correct values.
  • Altera's lstm_config typedefs each gate's type separately, so distinct types are consistent there.
  • I couldn't compile it, since that needs SYCL.

The Resource strategy isn't covered

The zero padding in #1589's weight layout interacts badly with po2 weights. To reproduce on Vivado:

q = dict(kernel_quantizer='quantized_po2(4)', recurrent_quantizer='quantized_po2(4)',
         bias_quantizer='quantized_bits(8, 0, alpha=1)')
x = Input((6, 4))
model = Model(x, QLSTM(8, activation='tanh', recurrent_activation='sigmoid', **q)(x))
config = hls4ml.utils.config_from_keras_model(model, granularity='name', backend='Vivado',
                                              default_precision='ap_fixed<24,10>')
name = model.layers[1].name
config['LayerName'][name]['Strategy'] = 'Resource'
config['LayerName'][name]['ReuseFactor'] = 3  # n_cycles=3, 2 input lanes -> 6 rows for 4 inputs

The layout pads the 4 input rows to 6 with zeros, and ExponentWeightVariable._format then takes log2(0). The result:

  • 64 -9223372036854775808 exponent literals end up in w2.h (48 for QGRU);
  • numpy emits divide-by-zero and invalid-cast warnings.

It still compiles, and the outputs match QKeras (max difference 0.021 for QLSTM, 0.031 for QGRU) because the kernel skips padded lanes (if (i < n_in)). But this PR makes the case reachable.

I'd suggest:

  • handling zeros in _format, or padding with a valid po2 value;
  • adding a Resource variant to the new test. That variant should compare outputs only, because the layout reorders w.data.

Smaller points

Quartus

Leaving Quartus for #1537 is reasonable, but #1537 hasn't merged yet. Things I observed:

  • Right values by accident. When the kernel quantizer is finer than the others, Quartus now gets the right values: LSTM.initialize already quantizes the layer-level weights, and re-quantizing them onto a finer grid changes nothing. The per-gate types are still the kernel's, though.

  • Coarser kernel quantizer fails to compile. With a 3-bit kernel and 8-bit recurrent and bias quantizers, Quartus LSTM fails to compile:

    no known conversion for argument 11 from 'const bias_i2_t [8]' {aka 'const ac_fixed<3, 1, true> [8]'}
    to 'const config2::bias_t*' {aka 'const ac_fixed<8, 1, true>*'}
    

    main has the same per-gate code, so this is almost certainly pre-existing rather than a regression.

  • The Altera fix wouldn't carry over directly. Quartus's nnet::lstm takes all kernels as CONFIG_T::weight_t and all biases as CONFIG_T::bias_t. That supports leaving Quartus alone in this PR.

Double quantization in Altera init_lstm

init_lstm slices layer.weights[...].data, which LSTM.initialize has already quantized, and then quantizes the slices again. This is harmless for alpha=1 and po2.

However, QKerasFactorizeAlpha notes that the scale has to be computed from the unquantized weights. Slicing data_unquantized instead would be cleaner, even though alpha != 1 isn't supported on recurrent layers yet.

Bidirectional

Two problems here, both predating this PR:

  • Quantizers are ignored. The parser accepts QLSTM/QGRU sublayers but never reads their quantizers, so Bidirectional has the same bug this PR fixes.
  • Bidirectional(QLSTM) crashes on Keras 3. Bidirectional.initialize checks {dir}_class_name == 'LSTM', so 'QLSTM' falls into the GRU branch and fails on None recurrent-bias data (AttributeError: 'NoneType' object has no attribute 'shape').

These belong in the "Related, not changed here" list or a follow-up issue.

Not verified

Expanding NO_QUANT_LAYERS to all backends in test_qkeras.py, which needs the Keras 2 stack.

Thanks for your reply @JanFSchulte ! I ran some tests with Fable 5.1 to address the issues, reviewed the results and it seems to be fixed now, here's what it found:

Weight check on Quartus / Altera — fixed with the helper you suggested: it compares against quantizer(data_unquantized) and transposes the 2-D expectation on Quartus and Altera. test_qsimplernn / test_qlstm / test_qgru now check the weights on all four backends. Quartus passes with both QKeras packages; the Altera variants of those three still stop at Could not find ahls here, as in your run.

Distinct quantizers — the three tests use 8-bit kernel / 6-bit recurrent / 4-bit bias (quantized_bits), and the new test uses quantized_po2(4) / quantized_po2(5) / 8-bit bias, with a name → quantizer dict in the helper; GRU also checks recurrent_bias. A quantizer applied to the wrong weight now fails the check.

Altera — new test_qlstm_altera_gate_quantizers (conversion only): QLSTM with 8 / 4 / 3-bit quantizers, checks weight_g, recurrent_weight_g, bias_g values (transposed) and their types, fixed<8,1> / fixed<4,1> / fixed<3,1>, as you measured. init_lstm now slices data_unquantized, so the pieces are quantized once.

Resource strategy / log2(0) — ExponentWeightVariable._format now gives zeros the smallest exponent of the type (-2**(width-1) signed, 0 unsigned) instead of taking log2(0); the padded lanes are skipped by the kernel anyway, so outputs are unchanged. The new test runs Strategy Latency and Resource (ReuseFactor=3), checks every written exponent is within the type, and compares outputs with QKeras (Resource: outputs only, since the layout reorders data). Both strategies give the same outputs here.

Keras 3 (found while doing the above) — the qkeras-v3 recurrent layers have no v3 handler and go through KerasV3HandlerDispatcher.v2_call, which appended a separate Activation layer for the layer's activation, so tanh was applied twice on QLSTM / QGRU (the Keras 2 converter excludes recurrent layers from that split). That is why the qkeras-v3 residuals in the PR table were larger than the QKeras 0.9 ones, not different random weights as I first wrote. Fixed in its own commit (keras_v3_to_hls.py, recurrent layers keep their activation); with it the two packages give identical outputs. Happy to split it into a separate PR if you prefer.

Quartus coarse-kernel compile failure and Bidirectional — agreed those are pre-existing; both are now listed under "Related, not changed here" in the description. I can open issues for them.

Runs on this branch: test_rnn.py + test_qkeras.py (QKeras 0.9, -k "Vivado or Vitis") 442 passed, 10 skipped; test_rnn.py + test_qkerasV3.py (qkeras-v3, same filter) 398 passed; the recurrent QKeras tests also pass on Quartus in both environments. On main with the new test files, the 34 new/modified non-Altera tests fail in each file.

@JanFSchulte JanFSchulte added the please test Trigger testing by creating local PR branch label Oct 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

please test Trigger testing by creating local PR branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

QKeras recurrent layers (QSimpleRNN, QLSTM, QGRU): weight quantizers are not applied, so results differ from QKeras (Keras 2 and Keras 3)

2 participants