Skip to content

Add Conv1DTranspose and Conv2DTranspose to the Vivado and Vitis backends - #1597

Merged
JanFSchulte merged 8 commits into
fastmachinelearning:mainfrom
vloncar:conv-transpose
Nov 2, 2026
Merged

JanFSchulte merged 8 commits into
fastmachinelearning:mainfrom
vloncar:conv-transpose

Conversation

@vloncar

@vloncar vloncar commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

This PR adds the transposed convolution layers Conv1DTranspose and Conv2DTranspose to the Vivado and Vitis
backends, for io_parallel and io_stream and for the Latency and Resource strategies. VitisUnified uses the
Vitis templates, so it gets the layers too.

Fixes #896.

The QKeras-v3 test needs QConv1DTranspose, which qkeras-v3 1.2.3 adds (fastmachinelearning/qkerasV3#10). The
qkeras-v3 and testing-qkeras-v3 extras now require that version.

How the layer is computed

With stride s, each input position produces a block of s output positions along each axis (s_h × s_w in 2D).
Kernel element t writes to position (t × dilation) mod s of a block and reads the input that is
(t × dilation) div s positions before the block. One block therefore needs the inputs of all kernel elements, that
is k_h × k_w × C values, and costs k_h × k_w × C × F multiplications: the same as one output pixel of a stride-1
convolution with the same kernel, for up to s_h × s_w output pixels. No value is multiplied by an inserted zero.

VivadoBackend._split_conv_transpose_kernel builds the weight matrix from the framework kernel and sets the
attributes that describe the split (slot_groups, slot_shifts, n_buf, first_block_*, n_blocks_*). They are
declared as backend attributes and listed in docs/ir/attributes.rst.

io_parallel. As in the stock convolution, a generated function gathers the inputs of each block, and a second
generated function writes the results of a block to the output, dropping the positions that padding='same' or
output_padding remove. Both are generated with fixed indices for each partition, as im2col is. With computed
indices, Vivado HLS 2020.1 reached an interval of 2 instead of 1 and Vitis HLS took much longer in C synthesis.
ReuseFactor is the number of cycles for one block (Resource accepts up to k_h × k_w × C), and
ParallelizationFactor is the number of blocks computed at the same time. The interval is
number of blocks / ParallelizationFactor × ReuseFactor.

io_stream. Two processes run in a DATAFLOW region. The first reads one input pixel per position, keeps the
rows and columns that later kernel elements need, and computes a block at each input position in ReuseFactor
cycles. The second writes the output pixels in row order, one per cycle, and keeps the blocks of the current row of
blocks. The interval is the larger of positions × ReuseFactor and the number of output pixels, so a reuse factor up
to about s_h × s_w (output pixels divided by input positions) costs no time. The depth of the FIFO between the two
processes is chosen in the config template by a cycle model of the two processes. ParallelizationFactor has no
effect in io_stream.

Binary inputs. A binary activation that hls4ml merges with the batch normalization or scale before it gives a
1-bit output, in which 0 stands for -1. The layer keeps that 1-bit input, which needs two things:

  • A 1-bit input cannot hold a zero, so the products of a kernel element that reads outside the input are left out
    by a flag: the generated fill_buffer sets the flags in io_parallel (only for binary inputs), and io_stream
    computes them from the position.
  • With binary weights too, a product is 1 where input and weight agree, and each output is twice its number of
    agreements minus its own number of products, plus the bias. The stock count step (nnet::cast) uses the number of
    inputs of the whole layer and would add the bias twice, so the layer does this step itself.

Both are chosen from the product that hls4ml picked for the layer (both_binary or data_binary). Converting the
input to -1/+1 in an added layer in front was tried too and costs more. In 64 builds of two layers (both tools, both
I/O types and strategies, binary and 4-bit kernels), that version used 1.5 to 2.9 times the LUTs of this one in
io_parallel, 1.4 to 2.2 times in io_stream with Latency, and 0.9 to 1.7 times in io_stream with Resource,
with the same interval (one cycle longer in two builds) and up to two cycles more latency. Ternary inputs need none
of this: hls4ml stores them as 2-bit integers, in which 0 is a zero.

Frontends

  • Keras 3 and Keras 2: strides, padding='valid' and 'same', output_padding, dilation. The output size and
    the crop come from one helper, converters/utils.py: compute_conv_transpose_output, which agrees with Keras 2.14
    and Keras 3.15 in 1080 cases.
  • QKeras-v3: the equivalent quantized layers. A kernel quantizer whose scale (alpha) differs between input channels is handled by applying the scale before
    the layer; a scale per filter is applied after the layer, as for the other layers (QKerasFactorizeAlpha). When the
    input comes from a binary activation, a power-of-two scale before the layer gets an exact fixed-point type instead
    of the power-of-two type: the latter is applied as a shift, which reads the stored 0 and 1 as numbers, while a
    fixed-point scale is applied by data_binary, which reads them as -1 and +1. This is the case for the default
    binary() and ternary() kernels. The scale layer is named <layer>_input_alpha.
  • The QKeras-v3 handler takes output_padding from the layer, because Keras before 3.15 leaves it out of
    get_config() of transposed convolutions. It also compares the output shape that the Keras 2 handler computes with
    the one Keras gives, and raises an error if they differ; this applies to every QKeras-v3 layer.
  • PyTorch: ConvTranspose1d and ConvTranspose2d with stride, padding, output_padding, dilation (also
    together with a stride) and an output_size given to forward() as constants. groups > 1 and an output_size
    known only at run time are refused.
  • data_format='channels_first' is refused.

Changes to shared code

  • pipeline_style.py, resource_strategy.py, im2col_codegen.py, linear.py: the two new classes are added where
    the convolution classes are listed (DATAFLOW for convolutions, the weight transpose for Resource, the generated
    functions, merging a linear activation into the layer).
  • infer_precision.py: the accumulator width uses the largest number of kernel elements that add to one output.
  • nnet_function_stubs.h: stubs for the two generated functions.
  • QKerasFactorizeAlpha: one check that sends a transposed layer with a scale per input channel to a new function;
    every other layer takes the existing path unchanged.

Not included

  • ONNX and 3D layers.
  • The other backends. They do not refuse the layers yet: Catapult, Altera and Quartus write a project that leaves the
    layer out. Refusing layers that a backend does not implement is left to a separate PR.
  • PyTorch PixelShuffle. A Conv2d followed by PixelShuffle can be rewritten as one Conv2DTranspose; this could
    be a follow-up.
  • Resource reuse factors larger than k_h × k_w × C, which are replaced by the closest valid one with a warning,
    and the other strategies, which are refused.

Known limits

  • Vivado HLS 2020.1, Latency: the tool chains several DSP adders in one clock cycle, so the 5 ns clock is
    missed by 0.04 to 0.84 ns (the stock Conv2D in some of the reference models misses it too). The same happens in
    io_stream Resource with a small reuse factor in the largest layer tested (by 1.2 and 1.65 ns). A clock uncertainty of 27% fixes it
    in all io_stream Latency builds and in all but two io_parallel Latency builds that missed it, and in the 8-bit io_stream
    Resource build. The two largest io_parallel layers (about 1200 DSPs each) need 35%, and the 16-bit io_stream Resource build
    needs 45%. In all of these builds the interval is unchanged, the latency grows by at most two clock cycles, and co-simulation gives
    the same outputs as C simulation. This is documented under the known issues in docs/backend/vitis.rst. Vitis HLS meets timing.
  • Large layers in io_parallel: Vivado HLS 2020.1 refuses to partition an output of more than 4096 values; on
    Vitis HLS, C synthesis and co-simulation take long (example below: 14 and 77 minutes), and the Resource
    strategy keeps the whole output in registers (75k FF in the example below). io_stream is the better choice for
    large layers.
  • io_stream on Vitis HLS: in 5 of the 43 Vitis builds of the sweep below, the interval is one cycle longer than
    expected. These are builds where the first process is the slower one, and Vitis HLS reports a start delay of one
    cycle for the loop of that process (stp(delay=1) in the C synthesis report). Vivado HLS 2020.1 is not affected.
    This is listed under the known issues in docs/backend/vitis.rst.
  • Binary input with a power-of-two (quantized_po2) kernel gives wrong outputs, as it does in the existing layers
    (Dense, Conv2D): hls4ml then picks the shift product (weight_exponential), which reads the stored 0 as the
    number 0 instead of -1. A separate PR will add a product for this case for all layers. The same product will
    also fix a binary input that reaches the layer through another layer, such as a Reshape, with a kernel scale
    per input channel (the default binary() and ternary() kernels): only a binary activation directly before the
    layer gets the fixed-point scale described under frontends.
  • Binary input with a binary kernel: hls4ml does not infer the accumulator type in this case, for the existing
    layers either, so it is the default precision. A wide layer can overflow ap_fixed<16,6>, as QConv2D does.
  • Vitis HLS 2025.1 is not supported for these layers; the Vitis builds used Vitis HLS 2024.1. The known issues in
    docs/backend/vitis.rst now list this, together with the other layers that were found not to work with 2025.1:
    Conv1D and Conv2D in io_parallel (co-simulation does not finish), Dense with the Resource strategy (C
    synthesis does not finish), and the Fused strategy.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change (existing configs, APIs or generated code behave differently)
  • New frontend / backend
  • New layer / operator support
  • New configuration option — a new io_type, Strategy, or config attribute
  • Research paper implementation
  • Documentation
  • Build, CI or tooling
  • Refactor / cleanup (no functional change)

Affected areas

Backends:

  • Vivado, Vitis, VitisUnified
  • Backend-independent (core IR, optimizer, hls4ml.model)

Frontends:

  • Keras v3, Keras v2, QKeras-v3, PyTorch
  • Not related to a frontend

Components:

  • IR (layers + model graph) / optimizer passes
  • C++ templates / HLS sources under hls4ml/templates/
  • Profiling, reporting or the CLI
  • Packaging, build or CI

Configurations affected and exercised

Backend io_type Strategy Verified
Vivado (Vivado HLS 2020.1) io_parallel, io_stream Latency, Resource pytest, csim, csynth, cosim, logic synthesis
Vitis (Vitis HLS 2024.1) io_parallel, io_stream Latency, Resource pytest, csim, csynth, cosim, logic synthesis
VitisUnified (Vitis HLS 2024.1) io_stream (axi_stream, axi_master) Latency, Resource csim, csynth, cosim (no bitfile): outputs equal to the Vitis backend

VitisUnified was built for 4 models (the example below with Latency and Resource over axi_stream, a 1D layer
with Latency and a 2D layer with unequal strides with Resource over axi_master). C synthesis and co-simulation
pass, and predict() gives the same outputs as the Vitis backend. There the interval is set by the float interface,
which moves one value per clock cycle (about 2100 cycles for the 2048 outputs of the example).

New configuration axis introduced by this PR: none.

Impact on generated HLS

  • This PR does not change the generated HLS for existing models.
  • This PR changes the generated HLS. Numbers below.

20 projects of existing layers (Keras Conv1D, Conv2D, UpSampling2D + Conv2D, DepthwiseConv2D + pointwise
Conv2D, Dense + BatchNormalization + softmax, and a PyTorch Conv2d model; io_parallel and io_stream;
Latency and Resource; Vitis backend) were written with main and with this branch. They are identical, apart from
the two new headers and the two new stub classes in nnet_function_stubs.h, which every project copies.

Numerical behaviour: new layers. The outputs match Keras and PyTorch within the fixed-point precision, and match
QKeras exactly in the QKeras tests. RTL co-simulation gives the same outputs as C simulation in every build listed
below.

Example: Conv2DTranspose(8, 3, strides=2, padding='same') on an 8×8×4 input (output 16×16×8), part
xcvu13p-flga2577-2-e, 5 ns clock, ap_fixed<16,6>. Before this PR, the usual substitute was UpSampling2D(2)
followed by Conv2D(8, 3, padding='same'): the same output size, but not the same operation (it repeats inputs instead
of inserting zeros). Latency and interval are from co-simulation, resources from logic synthesis. The interval is the
one between later inputs; the first input can take one cycle more.

Model / test Backend & version Part Latency (cycles) II LUT FF DSP BRAM
before: UpSampling2D + Conv2D, io_parallel, Latency Vitis HLS 2024.1 xcvu13p 259 256 191178 134035 288 0
after: Conv2DTranspose, io_parallel, Latency Vitis HLS 2024.1 xcvu13p 66 64 11548 11058 287 0
before: UpSampling2D + Conv2D, io_parallel, Resource RF 36 Vitis HLS 2024.1 xcvu13p 10497 10497 181097 118985 8 2
after: Conv2DTranspose, io_parallel, Resource RF 9 Vitis HLS 2024.1 xcvu13p 578 576 13857 74919 32 0
before: UpSampling2D + Conv2D, io_stream, Latency Vitis HLS 2024.1 xcvu13p 1302 1296 3201 2950 289 2
after: Conv2DTranspose, io_stream, Latency Vitis HLS 2024.1 xcvu13p 263 256 3858 5680 287 0
before: UpSampling2D + Conv2D, io_stream, Resource RF 36 Vitis HLS 2024.1 xcvu13p 11354 11351 2631 4335 8 3.5
after: Conv2DTranspose, io_stream, Resource RF 9 Vitis HLS 2024.1 xcvu13p 599 576 7656 11594 32 0
before: UpSampling2D + Conv2D, io_parallel, Latency Vivado HLS 2020.1 xcvu13p 2053 2049 129749 138732 286 0
after: Conv2DTranspose, io_parallel, Latency Vivado HLS 2020.1 xcvu13p 65 64 8682 5089 310 0
before: UpSampling2D + Conv2D, io_parallel, Resource RF 36 Vivado HLS 2020.1 xcvu13p 12033 12033 101972 127922 8 1.5
after: Conv2DTranspose, io_parallel, Resource RF 9 Vivado HLS 2020.1 xcvu13p 577 576 14896 5663 40 0
before: UpSampling2D + Conv2D, io_stream, Latency Vivado HLS 2020.1 xcvu13p 334 328 3104 4222 311 4
after: Conv2DTranspose, io_stream, Latency Vivado HLS 2020.1 xcvu13p 262 256 3712 5421 309 0
before: UpSampling2D + Conv2D, io_stream, Resource RF 36 Vivado HLS 2020.1 xcvu13p 10449 10447 1810 3097 8 5.5
after: Conv2DTranspose, io_stream, Resource RF 9 Vivado HLS 2020.1 xcvu13p 598 576 10110 11734 40 0

The Resource rows use the default reuse factor of each layer, so they do not use the same number of multipliers:
Conv2DTranspose needs 36 multiplications per block for 4 output pixels, the reference 36 per output pixel. The
UpSampling2D + Conv2D io_parallel Latency build on Vitis misses the 5 ns clock by 4.2 ns; all Conv2DTranspose
builds on Vitis meet it. On Vivado HLS 2020.1, the Conv2DTranspose Latency builds miss it by 0.52 ns
(io_parallel) and 0.33 ns (io_stream), and the UpSampling2D + Conv2D io_stream Latency build by 0.55 ns (see
known limits).

Sweep. Besides the example, 230 builds of the new layers with both tools covered 19 layers (1D and 2D; strides 1
to 4, unequal strides, kernels smaller and larger than the stride, valid and same, output_padding, dilation),
the Latency strategy, the Resource strategy at small, default and largest reuse factors, ParallelizationFactor
2 to 17 in io_parallel, and 8-bit precision: 144 builds in io_parallel and 86 in io_stream. In every build,
co-simulation passes and gives the same outputs as C simulation. The interval equals the expected one in all io_parallel builds and
in all io_stream builds apart from the 5 Vitis builds described under known limits. Timing: Vitis HLS meets 5 ns in
all builds but one, which misses by 0.001 ns; Vivado HLS 2020.1 misses it in 18 of the 30 io_parallel and 8 of the 16
io_stream Latency builds and in two io_stream Resource builds, as described above.

Binary inputs. 40 builds of two layers with a binary input (Conv2DTranspose with a 3×3 kernel, stride 2 and
'same' padding, on an 8×8×4 input with 8 filters and on a 4×4×16 input with 16 filters; both tools, both I/O types
and both strategies; binary and 4-bit kernels, and the default binary() kernel on the second layer): co-simulation
passes and gives the same outputs as C simulation, the interval equals the expected one, and both tools meet 5 ns.

Tests

  • test/pytest/test_conv_transpose.py (new, 67 tests, in KERAS3_LIST): Keras 3 Conv1DTranspose and
    Conv2DTranspose on Vivado with both strategies and both I/O types, including strides larger and smaller than the
    kernel, stride 1 with a 5×5 kernel and 'same' padding, and output_padding; one layer on Vitis, which uses the
    same kernels; reuse factors that split a block, in both I/O types, and parallelization factor settings; the parsed
    attributes; dilation in both I/O types (against a NumPy reference, because Keras on TensorFlow cannot run a dilated
    transposed convolution); PyTorch ConvTranspose1d and ConvTranspose2d with padding, output_padding, stride
    together with dilation, and output_size, in both I/O types.
  • test/pytest/test_qkerasV3.py: test_qconv_transpose, QConv1DTranspose (with output_padding) and
    QConv2DTranspose with valid and same padding and alpha 1 and auto_po2, on Vivado and Vitis; outputs equal
    to QKeras. QKeras-v3 layers are parsed by the Keras 2 handlers, so this test also covers the new Keras 2 handler.
    QConv1DTranspose was added to QKeras-v3 in Add QConv1DTranspose and apply output_padding in QConv2DTranspose qkerasV3#10 (released in 1.2.3), which also makes
    QKeras-v3 apply output_padding. Without the output_padding change in the QKeras-v3 handler, the 4 1D 'valid'
    cases fail on Keras 3.14.1, the version the testing-qkeras-v3 extra pins.
  • test/pytest/test_qkerasV3.py: test_qconv_transpose_binary_input, QConv2DTranspose after a binary activation
    (whose output hls4ml stores in 1 bit), with a binary kernel, the default binary() kernel (power-of-two scale per
    input channel) and a 4-bit kernel, both I/O types and both strategies, on Vitis; outputs equal to QKeras. All 12
    cases fail without the binary-input changes.

Results:

  • test_conv_transpose.py: 67 passed with Keras 3.15 and with Keras 3.14.1. It also passes (67) with Keras 2
    (TensorFlow 2.14), where the Keras 2 handler parses the layers.
  • test_qkerasV3.py with qkeras-v3 1.2.3, on Keras 3.14.1 and on Keras 3.15: 163 passed, among them the 16
    test_qconv_transpose and the 12 test_qconv_transpose_binary_input cases. The 16 failures are test_btnn cases
    that fail to compile in this environment, on main too.
  • To check the changes to shared code, these files were run on main and on this branch, with the same results:
    test_keras_v3_api.py (93 passed; 22 XLS failures from a missing module and 26 Catapult projects that do not
    compile in this environment), test_keras_api.py (98 passed), test_pytorch_api.py (198 passed), test_qkeras.py
    with QKeras 0.9.0 (192 passed, 3 recurrent-layer failures) and test_binary_cnn.py (9 passed).
  • The Quartus and Altera cases and the jet-tagging accuracy tests were left out of all these runs: they cannot run in
    this environment.

Test configuration: AlmaLinux 9.3. Keras 3: Python 3.11.15, Keras 3.15.0, TensorFlow 2.21.0, PyTorch 2.14.1,
NumPy 2.4.6, qkeras-v3 1.2.3; Keras 3.14.1 for the runs on that version. Keras 2: Python 3.10.20, TensorFlow 2.14.1, PyTorch 2.14.1, NumPy 1.26.4, QKeras 0.9.0.
Vivado HLS 2020.1 and Vitis HLS 2024.1 for synthesis.

AI assistance disclosure

  • None — no AI tool was used.
  • Assisted — completion, refactoring, docstrings, tests; design and code are mine.
  • Substantial — significant AI-generated portions, reviewed and edited by me.
  • Agentic — produced largely end-to-end by an AI agent from my prompts.

Tool(s) and model(s): Claude Code (Claude Opus 5.5)

Where it was used: the whole change (design, parsers, backend, HLS code, tests, synthesis runs and this description),
directed and reviewed by me.

If anything other than None is ticked, confirm all of the following:

  • 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

Required:

  • 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.

If applicable:

  • Documentation under docs/ updated.
  • No new build or synthesis warnings.
  • Public API / config schema changes are documented.

Release note

The Vivado, Vitis and VitisUnified backends support transposed convolutions (Keras Conv1DTranspose and Conv2DTranspose and their QKeras-v3 equivalents, PyTorch ConvTranspose1d and ConvTranspose2d) with io_parallel and io_stream.

@vloncar

vloncar commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Given that QKerasV3 had a new release with QConv1dTranspose, this can be reviewed

@JanFSchulte

Copy link
Copy Markdown
Contributor

From Claude:

Review

Thanks for this. The design is solid. Each kernel tap belongs to exactly one output phase, so a block of up to s×s outputs costs the same multiplies as one stride-1 convolution pixel, and nothing is multiplied by an inserted zero. The docs are thorough. I reviewed the full diff at c4bdfed and ran C-simulation checks beyond the PR's own tests (details at the end). Issue 1 below should block the merge; the rest are smaller.

Issues

1. QKeras-v3 transposed convolutions lose output_padding on Keras < 3.15

QKerasV3LayerHandler passes layer.get_config() to the Keras 2 handler. On Keras 3.14.1, get_config() of a transposed convolution leaves out output_padding; Keras 3.15 includes it:

keras.layers.Conv1DTranspose(3, 3, strides=2, output_padding=1).get_config()  # no 'output_padding' on 3.14.1

So parse_conv_transpose_layer computes the output size without it:

  • On Keras 3.14.1, 4 of the 16 test_qconv_transpose cases fail. These are QConv1DTranspose with valid padding: hls4ml builds width 17 where QKeras gives 18. They pass on 3.15.1.
  • The same cases in the test pass only by coincidence. With kernel_size=4, strides=2, output_padding=1, hls4ml builds width 16 where Keras gives 15.
  • In a larger model the error is silent. QConv1DTranspose → Flatten → QDense converts and compiles. The Dense layer reads 54 values from a 51-value output, and predictions differ from QKeras by up to 38.

The testing-qkeras-v3 extra pins keras==3.14.1, and the keras-v3 extra allows keras>=3.10. So both the test setup and users can hit this.

Reading the attribute from the layer fixes it. With this change, all 8 QConv1DTranspose cases pass on Keras 3.14.1:

config = layer.get_config()
# Keras < 3.15 leaves output_padding out of get_config() for transposed convolutions
if 'Transpose' in layer.__class__.__name__ and config.get('output_padding') is None:
    config['output_padding'] = getattr(layer, 'output_padding', None)

A more general guard would also help. The handler discards the output shape the Keras 2 handler computes (ret, _ = v2_handler(...)). Comparing it with out_tensors[0].shape and raising on a mismatch would catch this kind of bug for any layer.

2. Minimum qkeras-v3 version

The description says the minimum version would be raised once a release includes QConv1DTranspose. Version 1.2.3 is on PyPI with it (1.2.1 doesn't have it), but pyproject.toml still lists qkeras-v3 with no minimum. I suggest qkeras-v3>=1.2.3 in both the qkeras-v3 and testing-qkeras-v3 extras.

3. Catapult and Altera silently drop the layer

The new classes are added to the shared FPGABackend layer list, so other backends accept them. With Catapult and Altera, conversion and write() succeed and the weights are written, but no layer call is generated. The "insert layers" section of myproject.cpp is empty, so the output is never computed. Raising NotImplementedError in those backends, or registering the layers only for Vivado and Vitis, would make this fail loudly.

Suggestions

  • Test coverage. These cases aren't in the suite. I ran them locally and all passed, so adding a few would be cheap:

    • stride 1 with kernel_size=5, padding='same', where the crop exceeds the stride
    • io_stream with dilation
    • io_stream + Resource with a non-binary reuse factor that splits a block

    PyTorch with io_stream isn't covered either; I couldn't run that one.

  • CI cost. test_conv_transpose.py has 73 parametrizations, each a g++ build. Vivado and Vitis compile the same nnet_conv_transpose*.h in C simulation (there's no Vitis override), so Vitis could run on a subset.

  • static_assert. The assert(mult_n_in % CONFIG_T::reuse_factor == 0 ...) checks in both Resource kernels use only compile-time constants. static_assert would also catch a bad config outside C simulation.

  • Divisibility in codegen. generate_conv_transpose_line_buffer_fn and generate_conv_transpose_place_fn use len(...) // n_partitions, which silently truncates. The backend guarantees divisibility, but an assert costs nothing.

  • Input-alpha node name. _factorize_input_alpha inserts the ApplyAlpha before the layer but names it <layer>_alpha. Something like <layer>_input_alpha would read better in traces and profiling.

  • Binary-input detection. _factorize_input_alpha checks inp.class_name == 'Activation' plus the activation name. As far as I can tell, a binary model input, or another layer with binary output, would take the power-of-two shift path instead.

What I checked

By hand:

  • Output size and crop against Keras's deconv_output_length and TensorFlow's SAME rule. For PyTorch, crop = padding.
  • The phase indexing: group (t·d) % s, shift (t·d) // s. It is consistent across the generated fill and place functions, the stream window, and the weight reshaping. That includes both the Keras layout and the PyTorch layout after the channels-last transpose.
  • The Resource kernel's weight index: i_blk*rf + i_rf equals o*n_buf + i_in when n_buf % rf == 0, which the reuse-factor filter guarantees.

C simulation (Vivado backend, Keras 3.15.1 on JAX, g++ 13.3). All of these matched Keras to within about 1.5e-4:

  • stride 1, kernel_size=5, same (crop larger than the stride, 5-row window), in both I/O types
  • io_stream + Resource with RF 3, 4 and 6 (2D) and RF 5 (1D)
  • io_stream with dilation
  • two transposed convolutions back to back, and Conv2D → Conv2DTranspose(relu) → Conv2D, in both I/O types
  • use_bias=False
  • ParallelizationFactor with io_stream
  • a serialize/deserialize round trip

QKeras (TensorFlow 2.21, qkeras-v3 1.2.3):

  • All 12 test_qconv_transpose_binary_input cases pass.
  • test_qconv_transpose results are as described in issue 1.
  • Binary input with no bias, with infer_no_bias=True, and with an unsigned bias quantizer all matched QKeras exactly.

The inferred result type is tighter than for an equivalent Conv2D: 36 vs 38 bits for a 3×3, stride-2 layer with 2 input channels. The FIFO-depth model gave depths of 2–9 on 16×16 layers.

Not checked: HLS synthesis or co-simulation, the PyTorch tests (no CPU-only PyTorch was available in my environment), and CI results.

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

vloncar commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, especially for running the extra cases.

Fixed:

  • output_padding on Keras < 3.15. Good catch. The QKeras-v3 handler now reads output_padding from the layer instead of relying on get_config(). I also added the check you suggested: if the output shape the Keras 2 handler computes doesn't match the Keras one, conversion fails with an error. test_qkerasV3.py now passes on both Keras 3.14.1 and 3.15 with qkeras-v3 1.2.3, and the new check didn't trip on any of the other QKeras-v3 layers.
  • Minimum version. Both extras now require qkeras-v3>=1.2.3.
  • Tests and CI cost. I added the cases you listed: stride 1 with a 5×5 kernel and 'same' padding, io_stream with dilation, io_stream + Resource with reuse factors that split a block, and PyTorch in io_stream. To keep CI time down, the main 1D/2D tests now run on Vivado only, and Vitis gets one layer in both I/O types and strategies. The file has 67 tests instead of 73.
  • The smaller ones:
    • the Resource kernels now use static_assert. I checked that Vivado HLS 2020.1 and Vitis HLS 2024.1 accept it in both C simulation and synthesis, and that both stop with the message when the reuse factor doesn't divide;
    • the code generators assert that the number of partitions divides the number of blocks;
    • the input scale layer is now <layer>_input_alpha.

Left for separate PRs:

  • Catapult and Altera (and Quartus) dropping the layer. You're right that they do, but I'd rather not fix it layer by layer here. Making backends refuse layers and settings they don't implement is what the backend feature-support PR I'm working on is for, and it covers every layer, not just these two. The description lists this under "Not included" for now.
  • Binary-input detection. Also right: a binary activation followed by a Reshape, then a transposed layer with the default binary() kernel, gives wrong outputs. The underlying problem is that hls4ml has no product for binary inputs with power-of-two weights, and that affects the stock layers too. I'll fix that for all layers in a follow-up. With that in place, this special case won't be needed at all. Until then it's listed under known limits.

@JanFSchulte JanFSchulte added please test Trigger testing by creating local PR branch and removed please test Trigger testing by creating local PR branch labels Oct 30, 2026
@JanFSchulte
JanFSchulte merged commit 3c0bef2 into fastmachinelearning:main Nov 2, 2026
5 of 7 checks passed
jmduarte added a commit to jmduarte/hls4ml that referenced this pull request Nov 4, 2026
PLW2901, selected on this branch, flags n_out being reassigned when the
call gives no output_size. The parser came in from main with fastmachinelearning#1597.
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.

Conv2DTranspose is not supported

2 participants