sparsepixels 0.3.1__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sparsepixels-0.3.3/PKG-INFO +199 -0
- sparsepixels-0.3.3/README.md +179 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/notebook/utils.py +97 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/setup.cfg +2 -1
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels/layers.py +33 -5
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels/utils.py +176 -38
- sparsepixels-0.3.3/sparsepixels.egg-info/PKG-INFO +199 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels.egg-info/requires.txt +1 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels.egg-info/top_level.txt +0 -1
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/tests/test_model.py +4 -5
- sparsepixels-0.3.1/PKG-INFO +0 -205
- sparsepixels-0.3.1/README.md +0 -186
- sparsepixels-0.3.1/sparsepixels.egg-info/PKG-INFO +0 -205
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/LICENSE +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/pyproject.toml +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/setup.py +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels/__init__.py +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels/img/logo.png +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels.egg-info/SOURCES.txt +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/sparsepixels.egg-info/dependency_links.txt +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.3}/tests/__init__.py +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparsepixels
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Efficient convolution for sparse data on FPGAs
|
|
5
|
+
Home-page: https://github.com/hftsoi/sparse-pixels
|
|
6
|
+
Author: Ho Fung Tsoi
|
|
7
|
+
Author-email: ho.fung.tsoi@cern.ch
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: tensorflow
|
|
15
|
+
Requires-Dist: keras>=3.0
|
|
16
|
+
Requires-Dist: HGQ2>=0.1.8
|
|
17
|
+
Requires-Dist: quantizers<2
|
|
18
|
+
Requires-Dist: matplotlib
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/logo.png" width="300" />
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/sparsepixels.png" width="900"/>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/cnn_standard.gif" width="400" />
|
|
31
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/cnn_sparse.gif" width="400" />
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
# SparsePixels: Efficient convolution for sparse data on FPGAs
|
|
35
|
+
|
|
36
|
+
[](https://hftsoi.github.io/sparse-pixels)
|
|
37
|
+
[](https://arxiv.org/abs/2512.06208)
|
|
38
|
+
[](https://pypi.org/project/sparsepixels)
|
|
39
|
+
|
|
40
|
+
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (e.g. in a trigger) feasible on an FPGA. This library builds quantization-aware (with [HGQ2](https://github.com/calad0i/HGQ2) as a backend) sparse CNNs in which the pixel budget and the activity threshold can be learned from data, with a hardware-aware penalty that drives the budget toward the fewest pixels the task tolerates. Trained models convert to FPGA firmware through the [hls4ml](https://github.com/fastmachinelearning/hls4ml) integration, with control over the parallelization of the sparse layers so user can trade latency against resource usage. This training library is designed to mirror our HLS design with bit-exact accuracy.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
With Python >= 3.10:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install sparsepixels
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
51
|
+
|
|
52
|
+
## Getting Started
|
|
53
|
+
|
|
54
|
+
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
55
|
+
active as the threshold rises, and what a candidate `(n, threshold)` maskes out pixels on a few example images.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
59
|
+
|
|
60
|
+
active_pixels_vs_threshold(x_train)
|
|
61
|
+
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Build an example sparse CNN within HGQ2 quantization scopes. Set generous initial bit-widths at the
|
|
65
|
+
scope level: sparse signals are small (and get diluted further by pooling), so the low HGQ2 defaults
|
|
66
|
+
(4-bit weights, 2 fractional bits on activations) can quantize them, or entire low-magnitude
|
|
67
|
+
kernels, to exact zeros at initialization and block training. Starting wide (e.g. `b0=8` for
|
|
68
|
+
weights, `f0=8` for activations) keeps everything alive, and the EBOPS regularizer trims the
|
|
69
|
+
bit-widths back down during training. `InputReduce` keeps the first `n` active pixels (based on the
|
|
70
|
+
first channel above `threshold`) in a row-major order.
|
|
71
|
+
By default `n` and `threshold` are trainable hyperparameters: `beta_n` controls how aggressively `n` is driven smaller for a lower hardware footprint, and `beta_maskedE` penalizes over-masking pixel intensity.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import keras
|
|
75
|
+
from hgq.layers import QDense
|
|
76
|
+
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
77
|
+
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
78
|
+
|
|
79
|
+
with (
|
|
80
|
+
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM', b0=8, i0=0),
|
|
81
|
+
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP', i0=4, f0=8),
|
|
82
|
+
LayerConfigScope(enable_ebops=True, enable_iq=True, beta0=1e-5),
|
|
83
|
+
):
|
|
84
|
+
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
85
|
+
|
|
86
|
+
x, keep_mask = InputReduce(
|
|
87
|
+
n=30, # initial pixel budget
|
|
88
|
+
threshold=0.1, # initial activity threshold
|
|
89
|
+
beta_n=5e-3, # higher -> drives the pixel budget n smaller
|
|
90
|
+
beta_maskedE=1.0, # higher -> prevents over-masking
|
|
91
|
+
learn_n=True, # trainable pixel budget
|
|
92
|
+
learn_threshold=True, # trainable threshold
|
|
93
|
+
name='input_reduce',
|
|
94
|
+
)(x_in)
|
|
95
|
+
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
96
|
+
activation='relu')([x, keep_mask])
|
|
97
|
+
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
98
|
+
|
|
99
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
100
|
+
x = QDense(10, name='dense1', activation='relu')(x)
|
|
101
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
102
|
+
|
|
103
|
+
model = keras.Model(x_in, x)
|
|
104
|
+
model.compile(optimizer=keras.optimizers.Adam(1e-3),
|
|
105
|
+
loss='categorical_crossentropy', metrics=['accuracy'])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
109
|
+
budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it corrects the EBOPS
|
|
110
|
+
(a proxy for the quantized hardware cost) to the sparse compute automatically.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
114
|
+
|
|
115
|
+
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
116
|
+
patience=20, restore_best_weights=True)
|
|
117
|
+
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
118
|
+
epochs=100, batch_size=128,
|
|
119
|
+
callbacks=[early_stop, SparseTrainingMonitor()])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
After training, plot the diagnostics from the monitoring tool. The final pixel budget and threshold values are stored in `InputReduce`: `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
126
|
+
|
|
127
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
128
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
129
|
+
plot_quantization(model)
|
|
130
|
+
|
|
131
|
+
ir = model.get_layer('input_reduce')
|
|
132
|
+
print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Converting a trained model to HLS with hls4ml
|
|
136
|
+
|
|
137
|
+
> **Note:** `sparsepixels` support is part of the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo ([PR #1468](https://github.com/fastmachinelearning/hls4ml/pull/1468), merged). It is not in a tagged release yet, so install hls4ml from the main branch:
|
|
138
|
+
>
|
|
139
|
+
> ```bash
|
|
140
|
+
> pip install "git+https://github.com/fastmachinelearning/hls4ml.git"
|
|
141
|
+
> ```
|
|
142
|
+
|
|
143
|
+
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
144
|
+
knobs, and convert:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
import hls4ml
|
|
148
|
+
|
|
149
|
+
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
150
|
+
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
151
|
+
|
|
152
|
+
n_max_pixels = model.get_layer('input_reduce').n_max_pixels
|
|
153
|
+
|
|
154
|
+
# input reduce: 'tree' (default, lowest latency) or 'stream' (sequential, fewer resources)
|
|
155
|
+
hls_config['LayerName']['input_reduce']['Variant'] = 'tree'
|
|
156
|
+
# conv: active pixels in parallel (<= n_max_pixels), and filters in parallel (<= that conv's filters)
|
|
157
|
+
hls_config['LayerName']['conv1']['PixelParallelFactor'] = n_max_pixels
|
|
158
|
+
hls_config['LayerName']['conv1']['FiltParallelFactor'] = 3
|
|
159
|
+
# pool: active pixels in parallel, and channels in parallel
|
|
160
|
+
hls_config['LayerName']['pool1']['PixelParallelFactor'] = n_max_pixels
|
|
161
|
+
hls_config['LayerName']['pool1']['ChanParallelFactor'] = 3
|
|
162
|
+
# flatten: scatter positions in parallel (<= out_height * out_width; here 28*28 pools to 14*14)
|
|
163
|
+
hls_config['LayerName']['flatten']['ParallelFactor'] = 14 * 14
|
|
164
|
+
|
|
165
|
+
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
166
|
+
model,
|
|
167
|
+
hls_config=hls_config,
|
|
168
|
+
output_dir='hls_proj/my_sparse_cnn',
|
|
169
|
+
backend='Vitis',
|
|
170
|
+
io_type='io_parallel',
|
|
171
|
+
)
|
|
172
|
+
hls_model.write()
|
|
173
|
+
hls_model.compile()
|
|
174
|
+
y_hls = hls_model.predict(x_test)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Documentation
|
|
178
|
+
|
|
179
|
+
See [here](https://hftsoi.github.io/sparse-pixels/) for documentation with walk-through guidance.
|
|
180
|
+
|
|
181
|
+
## Citation
|
|
182
|
+
|
|
183
|
+
If you find this useful in your research, please consider citing:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
@article{Tsoi:2025nvg,
|
|
187
|
+
author = "Tsoi, Ho Fung and Rankin, Dylan and Loncar, Vladimir and Harris, Philip",
|
|
188
|
+
title = "{SparsePixels: efficient convolution for sparse data on FPGAs}",
|
|
189
|
+
eprint = "2512.06208",
|
|
190
|
+
archivePrefix = "arXiv",
|
|
191
|
+
primaryClass = "cs.AR",
|
|
192
|
+
doi = "10.1088/2632-2153/ae9fb5",
|
|
193
|
+
journal = "Mach. Learn. Sci. Tech.",
|
|
194
|
+
volume = "7",
|
|
195
|
+
number = "5",
|
|
196
|
+
pages = "055025",
|
|
197
|
+
year = "2026"
|
|
198
|
+
}
|
|
199
|
+
```
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/logo.png" width="300" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/sparsepixels.png" width="900"/>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/cnn_standard.gif" width="400" />
|
|
11
|
+
<img src="https://raw.githubusercontent.com/hftsoi/sparse-pixels/main/docs/figs/cnn_sparse.gif" width="400" />
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
# SparsePixels: Efficient convolution for sparse data on FPGAs
|
|
15
|
+
|
|
16
|
+
[](https://hftsoi.github.io/sparse-pixels)
|
|
17
|
+
[](https://arxiv.org/abs/2512.06208)
|
|
18
|
+
[](https://pypi.org/project/sparsepixels)
|
|
19
|
+
|
|
20
|
+
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (e.g. in a trigger) feasible on an FPGA. This library builds quantization-aware (with [HGQ2](https://github.com/calad0i/HGQ2) as a backend) sparse CNNs in which the pixel budget and the activity threshold can be learned from data, with a hardware-aware penalty that drives the budget toward the fewest pixels the task tolerates. Trained models convert to FPGA firmware through the [hls4ml](https://github.com/fastmachinelearning/hls4ml) integration, with control over the parallelization of the sparse layers so user can trade latency against resource usage. This training library is designed to mirror our HLS design with bit-exact accuracy.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
With Python >= 3.10:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pip install sparsepixels
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
31
|
+
|
|
32
|
+
## Getting Started
|
|
33
|
+
|
|
34
|
+
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
35
|
+
active as the threshold rises, and what a candidate `(n, threshold)` maskes out pixels on a few example images.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
39
|
+
|
|
40
|
+
active_pixels_vs_threshold(x_train)
|
|
41
|
+
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Build an example sparse CNN within HGQ2 quantization scopes. Set generous initial bit-widths at the
|
|
45
|
+
scope level: sparse signals are small (and get diluted further by pooling), so the low HGQ2 defaults
|
|
46
|
+
(4-bit weights, 2 fractional bits on activations) can quantize them, or entire low-magnitude
|
|
47
|
+
kernels, to exact zeros at initialization and block training. Starting wide (e.g. `b0=8` for
|
|
48
|
+
weights, `f0=8` for activations) keeps everything alive, and the EBOPS regularizer trims the
|
|
49
|
+
bit-widths back down during training. `InputReduce` keeps the first `n` active pixels (based on the
|
|
50
|
+
first channel above `threshold`) in a row-major order.
|
|
51
|
+
By default `n` and `threshold` are trainable hyperparameters: `beta_n` controls how aggressively `n` is driven smaller for a lower hardware footprint, and `beta_maskedE` penalizes over-masking pixel intensity.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import keras
|
|
55
|
+
from hgq.layers import QDense
|
|
56
|
+
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
57
|
+
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
58
|
+
|
|
59
|
+
with (
|
|
60
|
+
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM', b0=8, i0=0),
|
|
61
|
+
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP', i0=4, f0=8),
|
|
62
|
+
LayerConfigScope(enable_ebops=True, enable_iq=True, beta0=1e-5),
|
|
63
|
+
):
|
|
64
|
+
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
65
|
+
|
|
66
|
+
x, keep_mask = InputReduce(
|
|
67
|
+
n=30, # initial pixel budget
|
|
68
|
+
threshold=0.1, # initial activity threshold
|
|
69
|
+
beta_n=5e-3, # higher -> drives the pixel budget n smaller
|
|
70
|
+
beta_maskedE=1.0, # higher -> prevents over-masking
|
|
71
|
+
learn_n=True, # trainable pixel budget
|
|
72
|
+
learn_threshold=True, # trainable threshold
|
|
73
|
+
name='input_reduce',
|
|
74
|
+
)(x_in)
|
|
75
|
+
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
76
|
+
activation='relu')([x, keep_mask])
|
|
77
|
+
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
78
|
+
|
|
79
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
80
|
+
x = QDense(10, name='dense1', activation='relu')(x)
|
|
81
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
82
|
+
|
|
83
|
+
model = keras.Model(x_in, x)
|
|
84
|
+
model.compile(optimizer=keras.optimizers.Adam(1e-3),
|
|
85
|
+
loss='categorical_crossentropy', metrics=['accuracy'])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
89
|
+
budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it corrects the EBOPS
|
|
90
|
+
(a proxy for the quantized hardware cost) to the sparse compute automatically.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
94
|
+
|
|
95
|
+
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
96
|
+
patience=20, restore_best_weights=True)
|
|
97
|
+
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
98
|
+
epochs=100, batch_size=128,
|
|
99
|
+
callbacks=[early_stop, SparseTrainingMonitor()])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
After training, plot the diagnostics from the monitoring tool. The final pixel budget and threshold values are stored in `InputReduce`: `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
106
|
+
|
|
107
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
108
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
109
|
+
plot_quantization(model)
|
|
110
|
+
|
|
111
|
+
ir = model.get_layer('input_reduce')
|
|
112
|
+
print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Converting a trained model to HLS with hls4ml
|
|
116
|
+
|
|
117
|
+
> **Note:** `sparsepixels` support is part of the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo ([PR #1468](https://github.com/fastmachinelearning/hls4ml/pull/1468), merged). It is not in a tagged release yet, so install hls4ml from the main branch:
|
|
118
|
+
>
|
|
119
|
+
> ```bash
|
|
120
|
+
> pip install "git+https://github.com/fastmachinelearning/hls4ml.git"
|
|
121
|
+
> ```
|
|
122
|
+
|
|
123
|
+
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
124
|
+
knobs, and convert:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import hls4ml
|
|
128
|
+
|
|
129
|
+
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
130
|
+
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
131
|
+
|
|
132
|
+
n_max_pixels = model.get_layer('input_reduce').n_max_pixels
|
|
133
|
+
|
|
134
|
+
# input reduce: 'tree' (default, lowest latency) or 'stream' (sequential, fewer resources)
|
|
135
|
+
hls_config['LayerName']['input_reduce']['Variant'] = 'tree'
|
|
136
|
+
# conv: active pixels in parallel (<= n_max_pixels), and filters in parallel (<= that conv's filters)
|
|
137
|
+
hls_config['LayerName']['conv1']['PixelParallelFactor'] = n_max_pixels
|
|
138
|
+
hls_config['LayerName']['conv1']['FiltParallelFactor'] = 3
|
|
139
|
+
# pool: active pixels in parallel, and channels in parallel
|
|
140
|
+
hls_config['LayerName']['pool1']['PixelParallelFactor'] = n_max_pixels
|
|
141
|
+
hls_config['LayerName']['pool1']['ChanParallelFactor'] = 3
|
|
142
|
+
# flatten: scatter positions in parallel (<= out_height * out_width; here 28*28 pools to 14*14)
|
|
143
|
+
hls_config['LayerName']['flatten']['ParallelFactor'] = 14 * 14
|
|
144
|
+
|
|
145
|
+
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
146
|
+
model,
|
|
147
|
+
hls_config=hls_config,
|
|
148
|
+
output_dir='hls_proj/my_sparse_cnn',
|
|
149
|
+
backend='Vitis',
|
|
150
|
+
io_type='io_parallel',
|
|
151
|
+
)
|
|
152
|
+
hls_model.write()
|
|
153
|
+
hls_model.compile()
|
|
154
|
+
y_hls = hls_model.predict(x_test)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Documentation
|
|
158
|
+
|
|
159
|
+
See [here](https://hftsoi.github.io/sparse-pixels/) for documentation with walk-through guidance.
|
|
160
|
+
|
|
161
|
+
## Citation
|
|
162
|
+
|
|
163
|
+
If you find this useful in your research, please consider citing:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
@article{Tsoi:2025nvg,
|
|
167
|
+
author = "Tsoi, Ho Fung and Rankin, Dylan and Loncar, Vladimir and Harris, Philip",
|
|
168
|
+
title = "{SparsePixels: efficient convolution for sparse data on FPGAs}",
|
|
169
|
+
eprint = "2512.06208",
|
|
170
|
+
archivePrefix = "arXiv",
|
|
171
|
+
primaryClass = "cs.AR",
|
|
172
|
+
doi = "10.1088/2632-2153/ae9fb5",
|
|
173
|
+
journal = "Mach. Learn. Sci. Tech.",
|
|
174
|
+
volume = "7",
|
|
175
|
+
number = "5",
|
|
176
|
+
pages = "055025",
|
|
177
|
+
year = "2026"
|
|
178
|
+
}
|
|
179
|
+
```
|
|
@@ -1149,6 +1149,103 @@ def preview_patches_with_pooling(
|
|
|
1149
1149
|
plt.show()
|
|
1150
1150
|
|
|
1151
1151
|
|
|
1152
|
+
def plot_layer_outputs(model, x_input, sample_idx=0, layer_names=None,
|
|
1153
|
+
cmap='viridis', figsize_per_ch=3):
|
|
1154
|
+
"""Plot channel-wise feature maps for intermediate layers of a sparse or dense CNN.
|
|
1155
|
+
|
|
1156
|
+
Parameters
|
|
1157
|
+
----------
|
|
1158
|
+
model : keras.Model
|
|
1159
|
+
The model (sparse or dense). Sparse models may have layers with
|
|
1160
|
+
tuple outputs (features, mask).
|
|
1161
|
+
x_input : np.ndarray
|
|
1162
|
+
Full input dataset (batch, H, W, C).
|
|
1163
|
+
sample_idx : int
|
|
1164
|
+
Index of the sample in *x_input* to visualise.
|
|
1165
|
+
layer_names : list[str] or None
|
|
1166
|
+
Layer names to plot. If *None*, auto-discovers layers whose names
|
|
1167
|
+
start with common prefixes (x_in, input_reduce, conv, pool, dense).
|
|
1168
|
+
cmap : str
|
|
1169
|
+
Matplotlib colormap name.
|
|
1170
|
+
figsize_per_ch : float
|
|
1171
|
+
Width of each channel subplot in inches.
|
|
1172
|
+
"""
|
|
1173
|
+
import keras
|
|
1174
|
+
|
|
1175
|
+
if layer_names is None:
|
|
1176
|
+
prefixes = ('x_in', 'input_reduce', 'conv', 'pool', 'dense')
|
|
1177
|
+
layer_names = [l.name for l in model.layers
|
|
1178
|
+
if any(l.name.startswith(p) for p in prefixes)]
|
|
1179
|
+
|
|
1180
|
+
plot_tensors = []
|
|
1181
|
+
plot_names = []
|
|
1182
|
+
for name in layer_names:
|
|
1183
|
+
layer = model.get_layer(name)
|
|
1184
|
+
output = layer.output
|
|
1185
|
+
if isinstance(output, (list, tuple)):
|
|
1186
|
+
plot_tensors.append(output[0])
|
|
1187
|
+
plot_names.append(f'{name} (features)')
|
|
1188
|
+
plot_tensors.append(output[1])
|
|
1189
|
+
plot_names.append(f'{name} (mask)')
|
|
1190
|
+
else:
|
|
1191
|
+
plot_tensors.append(output)
|
|
1192
|
+
plot_names.append(name)
|
|
1193
|
+
|
|
1194
|
+
probe = keras.models.Model(inputs=model.input, outputs=plot_tensors)
|
|
1195
|
+
preds = probe.predict(x_input[sample_idx:sample_idx + 1], verbose=0)
|
|
1196
|
+
|
|
1197
|
+
if not isinstance(preds, list):
|
|
1198
|
+
preds = [preds]
|
|
1199
|
+
|
|
1200
|
+
i = 0
|
|
1201
|
+
while i < len(plot_names):
|
|
1202
|
+
name = plot_names[i]
|
|
1203
|
+
|
|
1204
|
+
if '(features)' in name and i + 1 < len(plot_names) and '(mask)' in plot_names[i + 1]:
|
|
1205
|
+
arr = preds[i][0]
|
|
1206
|
+
if arr.ndim == 3:
|
|
1207
|
+
h, w, c = arr.shape
|
|
1208
|
+
fig, axes = plt.subplots(1, c, figsize=(c * figsize_per_ch, figsize_per_ch))
|
|
1209
|
+
fig.suptitle(name.replace(' (features)', ''), fontsize=13)
|
|
1210
|
+
if c == 1:
|
|
1211
|
+
axes = [axes]
|
|
1212
|
+
for ch in range(c):
|
|
1213
|
+
axes[ch].imshow(arr[..., ch], cmap=cmap)
|
|
1214
|
+
axes[ch].set_title(f'ch{ch}')
|
|
1215
|
+
plt.tight_layout()
|
|
1216
|
+
plt.show()
|
|
1217
|
+
i += 2
|
|
1218
|
+
continue
|
|
1219
|
+
|
|
1220
|
+
arr = preds[i][0]
|
|
1221
|
+
if arr.ndim == 1:
|
|
1222
|
+
fig, ax = plt.subplots(1, 1, figsize=(max(len(arr) * 0.3, 4), figsize_per_ch))
|
|
1223
|
+
fig.suptitle(name, fontsize=13)
|
|
1224
|
+
ax.bar(range(len(arr)), arr)
|
|
1225
|
+
ax.set_xlabel('unit')
|
|
1226
|
+
plt.tight_layout()
|
|
1227
|
+
plt.show()
|
|
1228
|
+
elif arr.ndim == 2:
|
|
1229
|
+
fig, ax = plt.subplots(1, 1, figsize=(figsize_per_ch, figsize_per_ch))
|
|
1230
|
+
fig.suptitle(name, fontsize=13)
|
|
1231
|
+
ax.imshow(arr, cmap=cmap)
|
|
1232
|
+
ax.set_title('ch0')
|
|
1233
|
+
plt.tight_layout()
|
|
1234
|
+
plt.show()
|
|
1235
|
+
elif arr.ndim == 3:
|
|
1236
|
+
h, w, c = arr.shape
|
|
1237
|
+
fig, axes = plt.subplots(1, c, figsize=(c * figsize_per_ch, figsize_per_ch))
|
|
1238
|
+
fig.suptitle(name, fontsize=13)
|
|
1239
|
+
if c == 1:
|
|
1240
|
+
axes = [axes]
|
|
1241
|
+
for ch in range(c):
|
|
1242
|
+
axes[ch].imshow(arr[..., ch], cmap=cmap)
|
|
1243
|
+
axes[ch].set_title(f'ch{ch}')
|
|
1244
|
+
plt.tight_layout()
|
|
1245
|
+
plt.show()
|
|
1246
|
+
i += 1
|
|
1247
|
+
|
|
1248
|
+
|
|
1152
1249
|
def _fmt_bits(bits_tensor):
|
|
1153
1250
|
b = np.array(bits_tensor).flatten()
|
|
1154
1251
|
if b.size == 1:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = sparsepixels
|
|
3
|
-
version = 0.3.
|
|
3
|
+
version = 0.3.3
|
|
4
4
|
description = Efficient convolution for sparse data on FPGAs
|
|
5
5
|
author = Ho Fung Tsoi
|
|
6
6
|
author_email = ho.fung.tsoi@cern.ch
|
|
@@ -20,6 +20,7 @@ install_requires =
|
|
|
20
20
|
tensorflow
|
|
21
21
|
keras>=3.0
|
|
22
22
|
HGQ2>=0.1.8
|
|
23
|
+
quantizers<2
|
|
23
24
|
matplotlib
|
|
24
25
|
include_package_data = True
|
|
25
26
|
|
|
@@ -32,13 +32,18 @@ class InputReduce(keras.layers.Layer):
|
|
|
32
32
|
plain, non-learnable selection). The selection is always exact -- the learnable versions only
|
|
33
33
|
shape the gradient, so the layer behaves identically at inference and stays deployable. When n is
|
|
34
34
|
learned, a penalty of weight beta_n nudges it smaller, trading a little accuracy for lower FPGA
|
|
35
|
-
latency and resources; it starts at n and
|
|
36
|
-
|
|
35
|
+
latency and resources; it starts at n and can range over [1, H*W]. An optional penalty of weight
|
|
36
|
+
beta_maskedE discourages masking pixel intensity, giving the threshold (and n) a restoring force so a
|
|
37
|
+
trainable threshold settles near the noise floor instead of over-masking signal. After training,
|
|
38
|
+
read the values to deploy from the n_max_pixels and threshold properties.
|
|
37
39
|
|
|
38
40
|
Args:
|
|
39
41
|
n: initial pixel budget, and the fixed budget when learn_n is False.
|
|
40
42
|
threshold: initial activity threshold on the first channel, fixed when learn_threshold is False.
|
|
41
43
|
beta_n: weight of the budget penalty added to the loss (0 disables it).
|
|
44
|
+
beta_maskedE: weight of the masked-intensity penalty (0 disables it). Penalizes the fraction of pixel
|
|
45
|
+
intensity that gets masked, so masking bright (signal) pixels is costly while masking dim
|
|
46
|
+
(noise) pixels is cheap -- an adaptive, non-vanishing restoring force against over-masking.
|
|
42
47
|
learn_n: make the pixel budget trainable.
|
|
43
48
|
learn_threshold: make the threshold trainable.
|
|
44
49
|
tau_threshold: softness of the threshold surrogate used to obtain gradients.
|
|
@@ -49,7 +54,8 @@ class InputReduce(keras.layers.Layer):
|
|
|
49
54
|
self,
|
|
50
55
|
n=30,
|
|
51
56
|
threshold=0.0,
|
|
52
|
-
beta_n=
|
|
57
|
+
beta_n=5e-3,
|
|
58
|
+
beta_maskedE=1.0,
|
|
53
59
|
learn_n=True,
|
|
54
60
|
learn_threshold=True,
|
|
55
61
|
tau_threshold=0.05,
|
|
@@ -60,6 +66,7 @@ class InputReduce(keras.layers.Layer):
|
|
|
60
66
|
self.n_init = int(n)
|
|
61
67
|
self.threshold_init = float(threshold)
|
|
62
68
|
self.beta_n = float(beta_n)
|
|
69
|
+
self.beta_maskedE = float(beta_maskedE)
|
|
63
70
|
self.learn_n = learn_n
|
|
64
71
|
self.learn_threshold = learn_threshold
|
|
65
72
|
self.tau_threshold = float(tau_threshold)
|
|
@@ -76,13 +83,24 @@ class InputReduce(keras.layers.Layer):
|
|
|
76
83
|
)
|
|
77
84
|
if self.learn_n:
|
|
78
85
|
# Parametrize the budget as a fraction of its initial value (n = n_init * n_frac) so it
|
|
79
|
-
# moves at a useful rate under Adam; clip to [1,
|
|
86
|
+
# moves at a useful rate under Adam; clip to [1, H*W] so it can shrink to a single pixel
|
|
87
|
+
# or grow up to keeping every pixel.
|
|
88
|
+
if input_shape[1] and input_shape[2]:
|
|
89
|
+
hi = (input_shape[1] * input_shape[2]) / self.n_init
|
|
90
|
+
else:
|
|
91
|
+
hi = 4.0
|
|
80
92
|
self.n_frac = self.add_weight(
|
|
81
93
|
name="n_frac",
|
|
82
94
|
shape=(),
|
|
83
95
|
initializer=keras.initializers.Constant(1.0),
|
|
84
96
|
trainable=True,
|
|
85
|
-
constraint=_ClipToRange(1.0 / self.n_init,
|
|
97
|
+
constraint=_ClipToRange(1.0 / self.n_init, max(1.0, hi)),
|
|
98
|
+
)
|
|
99
|
+
if self.beta_maskedE > 0:
|
|
100
|
+
# Running masked-intensity fraction, tracked (not trained) so SparseTrainingMonitor can
|
|
101
|
+
# report the penalty; refreshed each call.
|
|
102
|
+
self._masked_intensity = self.add_weight(
|
|
103
|
+
name="masked_intensity", shape=(), initializer="zeros", trainable=False
|
|
86
104
|
)
|
|
87
105
|
super().build(input_shape)
|
|
88
106
|
|
|
@@ -108,6 +126,15 @@ class InputReduce(keras.layers.Layer):
|
|
|
108
126
|
rank_soft = ops.cumsum(active_soft, axis=1)
|
|
109
127
|
keep_soft = active_soft * ops.sigmoid((n - rank_soft) / self.tau_n)
|
|
110
128
|
keep_flat = keep_soft + ops.stop_gradient(keep_hard - keep_soft)
|
|
129
|
+
|
|
130
|
+
if self.beta_maskedE > 0:
|
|
131
|
+
# Fraction of total pixel intensity that gets masked, added straight to the loss (not
|
|
132
|
+
# through the classifier) so its gradient does not vanish for the masked pixels.
|
|
133
|
+
# Masking bright pixels costs more than masking dim ones, so this pushes the threshold
|
|
134
|
+
# back down (and n up) before it eats into signal -- an adaptive anti-over-masking term.
|
|
135
|
+
masked_frac = ops.mean(ops.sum(score * (1.0 - keep_soft), axis=1) / (ops.sum(score, axis=1) + 1e-6))
|
|
136
|
+
self.add_loss(self.beta_maskedE * masked_frac)
|
|
137
|
+
self._masked_intensity.assign(masked_frac)
|
|
111
138
|
else:
|
|
112
139
|
keep_flat = keep_hard
|
|
113
140
|
|
|
@@ -140,6 +167,7 @@ class InputReduce(keras.layers.Layer):
|
|
|
140
167
|
"n": self.n_init,
|
|
141
168
|
"threshold": self.threshold_init,
|
|
142
169
|
"beta_n": self.beta_n,
|
|
170
|
+
"beta_maskedE": self.beta_maskedE,
|
|
143
171
|
"learn_n": self.learn_n,
|
|
144
172
|
"learn_threshold": self.learn_threshold,
|
|
145
173
|
"tau_threshold": self.tau_threshold,
|