Skip to content

Conv1D dilation_rate is silently ignored — generated firmware is identical for dilation_rate 1, 2 and 4 #1508

Description

@VINAYTHUTE

Prerequisites

Please make sure to check off these prerequisites before submitting a bug report.

  • Test that the bug appears on the current version of the master branch. Make sure to include the commit hash of the commit you checked out.
  • Check that the issue hasn't already been reported, by checking the currently open issues.
  • If there are steps to reproduce the problem, make sure to write them down below.
  • If relevant, please include the hls4ml project files, which were created directly before and/or after the bug.

I tested the released version 1.3.0 rather than master, on two TensorFlow
versions (2.15.1 and 2.18.1). The root cause is a missing read of
dilation_rate in the Keras converter, so I do not expect it to differ on
master, but I have not verified that and have left the box unchecked.

I searched open and closed issues for "dilation" and "dilated" and found nothing
relevant; PRs #472 and #1008 mention the word only incidentally.

Quick summary

A Keras Conv1D with dilation_rate > 1 converts without any warning, but the
generated HLS computes an ordinary (dilation=1) convolution. The emitted
firmware is byte-identical to the dilation_rate=1 case, so the synthesised
hardware silently computes a different function than the trained model.

Details

There is no error and no warning. Conversion succeeds, C-synthesis succeeds and
the design meets timing — the only symptom is wrong numerical output, which is
easy to misattribute to quantisation. Dilated 1D convolutions are common in
sequence models and in detector pulse-shape networks, so a user can unknowingly
deploy an incorrect network.

Environment:

  • hls4ml 1.3.0 (installed from PyPI)
  • TensorFlow 2.15.1; also reproduced on TensorFlow 2.18.1
  • Vitis HLS 2024.2
  • Part xczu2eg-sfvc784-1-e

Steps to Reproduce

  1. Install hls4ml 1.3.0 and TensorFlow 2.15.1. No clone is required — the bug is
    visible at conversion time, before any HLS tool is invoked.
  2. Save the reproducer from this gist:
    https://gist.github.com/VINAYTHUTE/b26ddf478e7704d8b1ebb960c39125a4
  3. Run it:
    python3 hls4ml_dilation_bug.py
    
    It builds the same model at three dilation rates:
    # one tap per kernel position, so the output shows which input samples
    # were actually gathered
    inp = Input(shape=(64, 1), name="wave")
    out = Conv1D(4, 3, padding="same", dilation_rate=D,   # D = 1, 2, 4
                 use_bias=False, name="conv")(inp)
    converts each with io_stream and the Vitis backend, writes the projects,
    and compares the generated firmware/parameters.h plus a checksum of the
    emitted firmware.
  4. Observe that all three dilation rates produce byte-identical firmware. The
    full generated projects are left in ./hls4ml_dilation_repro/, one per
    dilation rate, for inspection.

Expected behavior

Either the dilation is applied to the generated convolution, or conversion
fails with a clear error stating that dilation_rate != 1 is unsupported.

Actual behavior

For all three dilation rates:

  • the parsed layer's dilation attribute is None
  • parameters.h emits static const unsigned dilation = 1
  • in_width is 66 in every case, although padding='same' with K=3 requires
    66 / 68 / 72 for dilation 1 / 2 / 4 respectively
  • the generated firmware files are byte-identical (md5 78aabad3b060ed08)
  • no warning or error is emitted at any point
  • io_parallel is affected identically

The Keras reference outputs differ substantially between these dilation rates
(max |dilated − undilated| ≈ 3.72 on the test input), so this is a genuine
functional divergence rather than a cosmetic config difference.

Console output:

hls4ml 1.3.0 | tensorflow 2.15.1

1) parsed layer attribute, emitted config, and firmware digest

  Keras dilation_rate = 1
    hls4ml parsed 'dilation' attribute : None
    parameters.h: static const unsigned in_width = 66
    parameters.h: static const unsigned filt_width = 3
    parameters.h: static const unsigned dilation = 1
    firmware md5 (first 16)            : 78aabad3b060ed08

  Keras dilation_rate = 2
    hls4ml parsed 'dilation' attribute : None
    parameters.h: static const unsigned in_width = 66
    parameters.h: static const unsigned filt_width = 3
    parameters.h: static const unsigned dilation = 1
    firmware md5 (first 16)            : 78aabad3b060ed08

  Keras dilation_rate = 4
    hls4ml parsed 'dilation' attribute : None
    parameters.h: static const unsigned in_width = 66
    parameters.h: static const unsigned filt_width = 3
    parameters.h: static const unsigned dilation = 1
    firmware md5 (first 16)            : 78aabad3b060ed08

2) does hls4ml warn the user?
  NO warning mentions dilation -- the failure is silent

3) Keras reference outputs for each dilation rate
  dilation_rate=1: out[0,:6,0] = [0.     1.7641 0.4002 0.9787 2.2409 1.8676]
  dilation_rate=2: out[0,:6,0] = [0.     0.     1.7641 0.4002 0.9787 2.2409]
  dilation_rate=4: out[0,:6,0] = [0.     0.     0.     0.     1.7641 0.4002]
  max |dilated - undilated| = 3.7241

4) io_parallel is affected as well
  dilation_rate=1: static const unsigned dilation = 1
  dilation_rate=4: static const unsigned dilation = 1

RESULT: FAIL -- dilation_rate is silently ignored
  * firmware is byte-identical across dilation_rate 1/2/4
  * dilation_rate=2 emitted 'dilation = 1'
  * dilation_rate=2: in_width should be 68 (effective kernel 5), got 66
  * dilation_rate=4 emitted 'dilation = 1'
  * dilation_rate=4: in_width should be 72 (effective kernel 9), got 66
  * no warning is emitted for an unsupported dilation
  * io_parallel emits the same dilation for 1 and 4

Optional

Possible fix

hls4ml/converters/keras/convolution.py never reads dilation_rate from the
Keras config:

layer['filt_width'] = keras_layer['config']['kernel_size'][0]
layer['stride_width'] = keras_layer['config']['strides'][0]
# dilation_rate is never read -> layer['dilation'] is never set

The layer's config_cpp then calls self.get_attr('dilation', 1), so the unset
attribute silently falls back to the default of 1 — which is why nothing
surfaces anywhere in the chain. compute_padding_1d() is also called with
filt_width rather than the effective width 1 + (filt_width - 1) * dilation,
so the zero-padding is likewise computed as if dilation were 1.

However, the backend does not implement dilation either: CONFIG_T::dilation
is declared in nnet_conv1d.h (and dilation_height / dilation_width in
nnet_conv2d.h) but is never read by any compute code — it is a
declared-but-unimplemented placeholder. So simply propagating dilation from
the parser would emit a config that looks correct while the generated hardware
still ignores it, which is arguably worse than the present behaviour.

Suggested minimal fix — raise at conversion time when dilation_rate != 1,
until the backend implements it:

dilation = keras_layer['config'].get('dilation_rate', [1])[0]
if dilation != 1:
    raise Exception(
        f"Layer {layer['name']}: dilation_rate={dilation} is not supported. "
        "hls4ml's Conv1D implementation ignores dilation, which would "
        "silently produce incorrect hardware."
    )

I'm happy to open a PR for this if it's the direction you'd prefer.

Additional context

Implementing dilation properly is a larger change (line buffers plus padding
computation) and probably belongs in a separate feature request — this issue is
only about the silent failure.

I hit this while deploying a 1D CNN denoiser for silicon-photomultiplier
detector waveforms, where dilated convolutions are a natural choice for widening
the receptive field over a 256-sample window. The model converted and
synthesised cleanly, met timing, and produced plausible-looking but incorrect
output; it took a while to trace back to dilation rather than to quantisation,
which is what motivated writing the reproducer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions