claroche1's picture
model card: int8 is measured now, drop the stale 'not characterised' caveat
1e25609 verified
|
Raw
History Blame Contribute Delete
8.48 kB
---
license: mit
library_name: pytorch
tags:
- speech-enhancement
- speech-denoising
- nsnet2
- sparsity
- semi-structured-sparsity
- 2:4-sparsity
- pruning
- sparse-matmul
datasets:
- JacobLinCool/VoiceBank-DEMAND-16k
metrics:
- pesq
---
# NSNet2 under semi-structured sparsity
Six NSNet2 speech-enhancement checkpoints trained under **fixed semi-structured
sparsity masks** — 2:4, 4:8, 1:4, 80% 1×4 blocks, 80% unstructured, and a dense
control — for work on sparse-dense MatMul packing and code generation at
**batch 1**. Each ships as a PyTorch checkpoint, an FP32 and a static-int8 ONNX
graph, and a numpy export with explicit zeros and masks. No pattern here costs
measurable quality, in FP32 or int8.
Code, training recipe and export tooling:
[LarocheC/eco8-neaixt](https://github.com/LarocheC/eco8-neaixt), branch
`sparse-masks-rowfusion`. See `SPARSE_MATMUL_COLLAB.md` there for the full
method.
## Results
PESQ on the full 824-utterance VoiceBank-DEMAND test set. Every arm was
fine-tuned from the same dense baseline on an identical schedule (lr 3e-4, 120
epochs), so the mask is the only variable.
| directory | pattern | sparsity | PESQ |
| ------------------- | --------------- | -------: | ----: |
| `dense` | dense (control) | 0% | 2.777 |
| `2_4` | 2:4 | 50.0% | 2.779 |
| `4_8` | 4:8 | 50.0% | 2.779 |
| `1_4` | 1:4 | 75.0% | 2.781 |
| `unstructured_80` | unstructured | 80.0% | 2.776 |
| `1x4_80` | 1×4 blocks | 80.0% | 2.770 |
### int8
Static int8 PTQ (QDQ, per-channel symmetric weights, MinMax calibration on 200
utterances), PESQ through onnxruntime on the same test split. Δ is int8 − FP32.
| directory | sparsity | FP32 | int8 | Δ | int8 RTF |
| ------------------- | -------: | ----: | ----: | -----: | -------: |
| `dense` | 0% | 2.777 | 2.783 | +0.006 | 0.121 |
| `2_4` | 50% | 2.779 | 2.781 | +0.002 | 0.125 |
| `4_8` | 50% | 2.779 | 2.790 | +0.011 | 0.122 |
| `1_4` | 75% | 2.781 | 2.784 | +0.003 | 0.123 |
| `1x4_80` | 80% | 2.770 | 2.779 | +0.009 | 0.124 |
| `unstructured_80` | 80% | 2.776 | 2.774 | −0.002 | 0.121 |
**Sparsity does not make quantization harder** — every Δ is inside the ±0.01
noise band at every sparsity level, and five of six are positive.
**The mask survives int8 bit-exactly.** Symmetric per-channel weight
quantization maps 0.0 to exactly 0. The N:M arms conform in the int8 graph with
sparsity slightly *above* target (0.5016 / 0.5011 / 0.7510 — a few small weights
round to zero, which N:M permits), and `1x4_80` holds block support at exactly
0.2000 live against its 0.2000 budget. Check it yourself with
`nsnet2/verify_int8_sparsity.py` from the repo.
**But the sparsity buys no speed today.** int8 RTF is 0.121–0.125 across every
arm, dense and 80%-sparse alike, and the int8 file is 2.78 MiB regardless —
onnxruntime stores the zeros explicitly and multiplies by them like any other
weight. 80% of the multiplies are gone mathematically and none of the latency
is. Closing that gap is what these checkpoints are for.
**None of these patterns costs measurable quality.** The spread across all six
arms is 0.012 PESQ while the run-to-run variation within a single arm is ~0.010
sd, so they are statistically indistinguishable. Do not read an ordering into
the table — 1:4 topping it at 75% sparsity is which validation happened to land
last, not a result.
One caveat. Every arm including the dense control sits ~0.07 below the
[published 200-epoch baseline](https://huggingface.co/claroche1/sparse-nsnet2-checkpoints)
of 2.845, because these were shortened fine-tunes with a freshly initialised
discriminator; the comparison between arms is unaffected since all paid the same
penalty, and all six curves were still rising at epoch 120. A full-length run
would likely lift every arm.
For reference, magnitude pruning *without* fine-tuning is far more pessimistic:
2:4 costs 0.378 PESQ and 1×4 at 80% costs 0.656. Almost all of it comes back,
so pruning-only numbers are a poor guide to what a pattern actually costs.
## Layout
Each directory holds both a runnable checkpoint and a kernel-oriented export:
* `g_best`, `config.json` — PyTorch checkpoint, loadable with the `NSNet2` model
in the repo above.
* `g_best_fp32.onnx`, `g_best_int8.onnx` — the streaming graph in FP32 and in
static int8 (QDQ). The int8 graph preserves the sparsity pattern exactly.
* `weights.npz` — per matrix: `<name>.weight` (float32, dense **with explicit
zeros**), `<name>.mask` (uint8, 1 = kept), `<name>.bias`, and golden vectors
`<name>.ref_x` / `<name>.ref_y` where `ref_y = W @ ref_x + bias`.
* `manifest.json` — shapes, pattern, grouping axis, achieved sparsity, ragged
tail counts, and N at inference vs training.
`verify.py` at the top level needs only numpy:
```bash
python verify.py 2_4 # shapes, mask/weight agreement, pattern conformance,
# and the golden vectors
```
## Conventions
**Layout.** Every weight is row-major `(M, K)`, used as `y = W · x + b` with `x`
of shape `(K, N)`.
**N = 1 at deployment.** The model runs one 16 ms frame at a time, so each of
these is a matrix-vector product. During training N is 256 · T.
**Grouping runs along K.** For an N:M pattern the groups of M are contiguous
*within a row* — along the input dimension, contiguous in memory for a row-major
`(M, K)` array. This matches the NVIDIA 2:4 convention. The masking code
supports grouping along the output dimension too, if a kernel wants that.
**Ragged tail.** `fc_in` has K = 257 — 64 groups of 4 plus one leftover column,
left dense — so it measures 49.8% sparse rather than exactly 50%.
`manifest.json` reports `tail_elements` per matrix.
**GRU gate packing.** `gru.weight_ih_l*` and `gru.weight_hh_l*` are `(3H, K)`:
PyTorch stacks the r/z/n gates along the **output** dimension, so each gate is a
contiguous block of rows and a group of 4 along K never straddles a gate
boundary. Each gate submatrix independently satisfies the pattern, so a
`1200×400` packs as one matrix or as three `400×400` with identical results.
## The matrices
The four GRU matrices are 69% of the weights and run once per frame, so they
dominate. `gru.weight_hh_l0` and `gru.weight_hh_l1` sit inside the recurrence
and cannot be batched over time even in principle — the strictest N=1 case here.
| matrix | M | K | params |
| ------------------- | ---: | --: | ------: |
| `fc_in` | 400 | 257 | 102,800 |
| `gru.weight_ih_l0` | 1200 | 400 | 480,000 |
| `gru.weight_hh_l0` | 1200 | 400 | 480,000 |
| `gru.weight_ih_l1` | 1200 | 400 | 480,000 |
| `gru.weight_hh_l1` | 1200 | 400 | 480,000 |
| `fc1` | 600 | 400 | 240,000 |
| `fc2` | 600 | 600 | 360,000 |
| `fc_out` | 257 | 600 | 154,200 |
## Usage
Kernel work — numpy only, no PyTorch:
```python
import json
import numpy as np
npz = np.load("2_4/weights.npz")
W = npz["gru.weight_hh_l0.weight"] # (1200, 400) float32, explicit zeros
b = npz["gru.weight_hh_l0.bias"] # (1200,)
x = npz["gru.weight_hh_l0.ref_x"] # (400,) float32
assert np.allclose(W @ x + b, npz["gru.weight_hh_l0.ref_y"], atol=1e-4)
```
Running the model:
```python
import json
import torch
from common.env import AttrDict
from nsnet2.model import NSNet2
h = AttrDict(json.load(open("2_4/config.json")))
model = NSNet2(h)
model.load_state_dict(torch.load("2_4/g_best", map_location="cpu")["generator"])
```
Reproducing a mask, or training a new one:
```bash
python -m nsnet2.train --config configs/ov_2to4.json \
--checkpoint_path cp_ov_2to4 --init_from <dense g_best>
```
## Related
* [claroche1/sparse-nsnet2-checkpoints](https://huggingface.co/claroche1/sparse-nsnet2-checkpoints)
— the same model under Butterfly / block-diagonal / Monarch **structured
factorizations** (a different kind of sparsity: factorized transforms rather
than masked dense matrices), plus the dense 2.845 baseline these were
fine-tuned from.
## Citation
NSNet2: Braun & Tashev, *Towards efficient models for real-time deep noise
suppression*, ICASSP 2021. Training recipe built on
[MP-SENet](https://github.com/yxlu-0102/MP-SENet).