August 14, 2026 · 24 min read

QCFS ANN to SNN Explained

Quantized Clip-Floor-Shift is the calibration method NeuroCUDA uses to turn ReLU networks into spiking networks. This page is the method. If lambda is frozen, use the bug page instead.

QCFS ANN to SNN conversion maps each ReLU onto a Quantized Clip-Floor-Shift function with a learnable per-channel threshold, then replaces that function with an integrate-and-fire neuron and fine-tunes with BPTT using an atan surrogate. In NeuroCUDA that pipeline is snn, meta = neurocuda.convert(model, calib_loader). If thresholds never move, that is the QCFS threshold not learning bug, not a proof the method is empty.

TL;DR

QCFS (Bu et al., ICLR 2023) is how NeuroCUDA aligns ANN activations to spike counts at modest T. Learnable per-channel thresholds, then IF replace, then BPTT. Published software-backend scores remain N-MNIST SNN 99.88% and ResNet-18/CIFAR-10 SNN 94.61% at T=32. Install: pip install neurocuda. Source: github.com/Krishnav1/neurocuda. Paper: technical report.

This page vs the bug page: you are on the method explainer for QCFS ANN to SNN calibration. /blog/qcfs-threshold-not-learning is the lambda-not-learning troubleshooting post (frozen threshold near 1.0, shared learning rate). Do not merge the two intents. Conversion HowTo: convert PyTorch to SNN. Theory hub: /ann-to-snn.

QCFS ANN to SNN calibration with Quantized Clip-Floor-Shift in NeuroCUDA

ANN-to-SNN conversion is the problem of keeping a trained ReLU network's function while changing the unit of computation from continuous activations to spikes over T discrete timesteps. Naive ReLU-to-spike swaps fail because ReLU is unbounded in one direction and spikes are counts. Someone has to pick a scale: how large a ReLU value becomes how many spikes. QCFS ANN to SNN conversion is one principled answer to that scale problem. NeuroCUDA uses it; this page explains why the pieces exist, not how to patch a frozen lambda.

The name is Quantized Clip-Floor-Shift. The 2023 ICLR paper by Bu and colleagues framed it as a way to get high accuracy at ultra-low latency, meaning small T. NeuroCUDA's compiler applies that family of calibration, then IF replacement, then BPTT with an atan surrogate, behind one Python call. You should still know what the call is doing. Otherwise every accuracy gap looks like "SNNs don't work," which is how people skip debugging and write a false limitation into a draft.

How QCFS ANN to SNN calibration works

Start from a trained torch.nn.Module whose nonlinearities are ReLU. For each ReLU, QCFS inserts a quantized proxy that is still a function of a real-valued pre-activation x, but whose range and granularity are compatible with a later spiking neuron.

QCFS(x, lambda, L) = lambda * clip(floor((x / lambda) * L + 0.5) / L, 0, 1)

Read it left to right. Divide by lambda so the interesting part of the activation distribution sits near unit scale. Multiply by L so you are in bin-index space. Add 0.5 and floor so you round to a discrete level. Divide by L to return to the unit interval. Clip so nothing lives outside [0, 1]. Multiply by lambda so the output has the original physical scale again. The floor is the quantizer. The clip is the bound. The 0.5 inside the floor is the shift that centers rounding. Together they are Quantized Clip-Floor-Shift.

L is the number of quantization steps. In conversion it is tied to how you will later spend T timesteps: more levels (or more timesteps) can reconstruct a smoother ReLU, at a cost in latency and energy. NeuroCUDA's published ResNet-18 protocol uses T=32. That is a documented operating point, not a universal constant of QCFS. Lower T is the point of the method family; claiming a new T-versus-accuracy curve here would be inventing a result. Use T=32 when you want to sit next to the 94.61% figure. Use another T when you are studying the tradeoff, and report T with the number.

After the QCFS proxy is in place, calibration sets lambda. Then the proxy is replaced by an integrate-and-fire neuron. Then BPTT nudges the remaining parameters, including thresholds, with a surrogate gradient through the spike. Those three stages are easy to collapse into "convert() did something." Keep them separate when you debug. A bad lambda is a calibration story. A dead membrane is an IF story. A 40-point gap with healthy lambda is often reset or BatchNorm. The accuracy-drop checklist is SNN accuracy drop after conversion.

Why the threshold must be learnable

If you freeze lambda = 1 for every channel, you are asserting that every feature map's ReLU already lives on the same scale. That is false in a real CNN. Early convolutions and late convolutions differ. Residual branches differ. A channel that mostly outputs 0.1 and a channel that mostly outputs 8 cannot share a firing threshold without one of them saturating or going silent.

QCFS therefore treats the threshold as a parameter. NeuroCUDA uses learnable per-channel thresholds, not one scalar per network and not only one scalar per layer when the layer is a convolution with many maps. Per-channel is the difference between "we quantized the tensor" and "we quantized each feature as if it were its own neuron population." When conversion works, those thresholds move away from init toward values that reflect actual activation percentiles. When they do not move, you have the bug on the other page, and you should not conclude that learnable thresholds are a placebo.

Initialization still matters even when learning works. Starting each channel from a statistic of its ANN activations (a high percentile on calibration batches) shortens the distance gradient has to travel. Starting every channel at 1.0 can still succeed if the optimizer is set up for lambda, but it is a longer walk. That is method hygiene, not a new algorithm. NeuroCUDA's convert() owns that hygiene so application code does not reimplement it.

Clip, floor, and shift as separate jobs

Clip

ReLU has no upper bound. Spike rate in a finite window T has a hard max: at most one spike per timestep per neuron in a simple IF picture, or a bounded count more generally. Clip is how QCFS admits that bound before you pretend a ReLU of 50 can become 50 spikes in T=8. Without clip, the quantized function tries to represent outliers that the SNN physically cannot emit in time. With clip, those outliers saturate. Saturation is a real error. It is a smaller error than letting one wild activation dominate threshold statistics for a whole layer.

Floor

Floor is the actual quantizer. It is piecewise constant, which is why vanilla backprop through floor is almost useless, and why QCFS training uses a surrogate through that step. The method is not "floor because we like staircases." The staircase is the ANN-side stand-in for a spike count. Each bin is a statement of the form: this ReLU value should correspond to k of L possible pulses in the converted neuron.

Shift

Shift is the unglamorous term. Rounding instead of truncating (the +0.5 before floor in the common write-up) stops the bins from being systematically biased low. A biased quantizer looks like a slightly wrong threshold plus a slightly wrong rate code. People then "fix" T when they should have fixed rounding. In the formula above, shift is already baked into the 0.5. You do not add a second magic constant unless you are implementing a paper variant and you can cite it.

From QCFS proxy to integrate-and-fire

A quantized ANN is still an ANN. The SNN begins when you replace the QCFS activation with an integrate-and-fire unit. The IF neuron holds a membrane potential, adds the (weighted, possibly BatchNorm-folded) input at each timestep, and fires when the potential crosses the threshold implied by the calibrated scale. After a spike, reset policy matters; getting reset wrong is a top cause of chance-level accuracy, documented on the accuracy-drop page rather than here.

Why IF rather than a leaky IF for this conversion family? QCFS is matching a static ReLU, which has no leak. A leaky neuron implements a different function. You can still study LIF after conversion. The default conversion target in this pipeline is IF because that is the dynamical object ReLU-plus-rate-code is trying to be. NeuroCUDA's Loihi 2 path is an IF-neuron simulator checked against published equations, which is a backend statement, not a claim that QCFS ran on Loihi silicon.

Skip connections survive this replacement only if the compiler sums residual streams the way the ANN did. That is a graph problem, not a QCFS problem. NeuroCUDA's ResNet-18 NIR residual executor is the published answer for that graph. Details belong in the ResNet-18 tutorial and export PyTorch to NIR, not in a second copy of residual algebra on this URL.

BPTT and the atan surrogate

Calibration gets you into the right neighborhood. Fine-tuning recovers the last points of accuracy. Because the fire function is a step, true gradients are zero almost everywhere. Surrogate gradient descent replaces that step with a smooth curve in the backward pass only. NeuroCUDA uses an atan (arctangent) surrogate during the BPTT stage that follows QCFS. Forward spikes stay binary (or count-valued). Backward passes see a bump around threshold so lambda and weights can still move.

BPTT unrolls the IF dynamics across T. Memory cost scales with T and batch. That is why a Colab T4 and a lab A100 feel different even when the method is identical; see NeuroCUDA Google Colab for the notebook constraints. It is also why T=32 is a protocol you choose, not a value convert() should hide. If you cut T after a model was fine-tuned at T=32, you are evaluating a different object. If you raise T without fine-tuning, you may help integration or you may just spend latency. Report T.

Surrogate shape is not a knob for inventing a new CIFAR score in this article. Atan is the documented choice in the NeuroCUDA pipeline. Switching to a sigmoid or a super-spike-style surrogate would be a different experiment. If you do that experiment, do not attribute the number to NeuroCUDA's published table.

What convert() actually runs

import neurocuda

snn, meta = neurocuda.convert(model, calib_loader)
neurocuda.compile(snn, target="gpu")
acc = neurocuda.evaluate(snn, test_loader)

Inside that call, in order you should keep in your head:

  1. Replace ReLU with QCFS; introduce learnable per-channel thresholds
  2. Calibrate those thresholds on calib_loader batches (in-distribution, same preprocess)
  3. Fold BatchNorm into the preceding conv or linear when present
  4. Replace QCFS activations with IF neurons (IF replace)
  5. BPTT fine-tune with atan surrogate
  6. Return the spiking module and a meta record of the conversion

Install remains pip install neurocuda from PyPI, source at github.com/Krishnav1/neurocuda. Local env details stay on the pip install neurocuda guide. The copy-paste HowTo for any machine is convert PyTorch to SNN. This page exists so those HowTos can link a method URL instead of retelling QCFS in every tutorial.

meta is how you inspect what calibration believed. Log it next to the checkpoint. Do not fabricate a schema for it in a blog. If thresholds in meta are still sitting on init after a full convert, go to QCFS threshold not learning and treat it as an optimizer and gradient-flow bug.

Calibration data, not "any tensor"

QCFS is only as honest as the batches you show it. The loader should be in-distribution: a held-out slice of training data is the usual choice, with the same normalize, resize, and augmentation policy you will use at eval (usually no heavy augmentation at calib). Using test data to set thresholds is a protocol leak; using Gaussian noise is a different function. Using CIFAR stats on an N-MNIST event tensor is how you get a confident, wrong lambda.

Event-based inputs are a different distribution again. N-MNIST is not CIFAR. The conversion call is the same shape, the calib_loader is not. Follow N-MNIST SNN conversion when the data are spikes already. QCFS still has a job when the ANN that consumes those events used ReLU; it does not magically know DVS polarity from a torchvision MeanStd.

How much data? Enough that per-channel percentiles stabilize. A handful of batches can work for a small CNN. A ResNet will want more. There is no sacred percentage in the NeuroCUDA published table beyond what the report used. Copy the report's protocol when you want the report's number. That protocol lives on reproduce NeuroCUDA results.

Published numbers, and only those numbers

NeuroCUDA's technical report, not this essay, is the source for accuracy. Quote it like this and stop:

SetupANNSNNNotes
N-MNIST, 3-layer CNN99.70%99.88% ± 0.02%GPU/CPU software backend; SNN slightly above ANN
ResNet-18 / CIFAR-10, T=3295.56% ± 0.11%94.61% ± 0.14%GPU/CPU software backend; 0.95 point gap

Those are multi-seed, full test set figures from the report. They are not Colab-only figures. They are not SpiNNaker ResNet figures. They are not Loihi silicon figures. QCFS is the calibration method used on the path that produced them. Attributing a new dataset's score to "QCFS" without a measurement is marketing, and this page will not do it.

Sparsity on the ResNet-18/CIFAR-10 software backend is reported around 93.7% (fraction of neuron-timesteps without a spike). Sparsity is why people care about SNNs on event-driven chips. A GPU still burns dense kernels; do not convert a sparsity percentage into a joule number unless you measured energy. Energy claims belong in the Loihi-versus-GPU write-up, with modeled versus measured labeled.

Honest hardware labels next to the method

Method and machine get mixed in talks. Keep this matrix next to any QCFS diagram you reuse:

ThingLabel
QCFS + IF + BPTT on GPU/CPUSoftware backend; source of 99.88% and 94.61%
SpiNNaker-1Physical silicon smoke test, EBRAINS jobs #420148 and #420186; not ResNet-on-chip accuracy
Loihi 2 in NeuroCUDAIF-neuron simulator versus published equations; not Loihi silicon
NIR filePortable graph; not a chip measurement

Field context for CUDA simulators versus this compiler is neuromorphic CUDA. Training an SNN from scratch instead of converting is NeuroCUDA vs SpikingJelly. Neither comparison changes what QCFS is.

QCFS is the scale-alignment method. IF replace is the neuron swap. BPTT atan is the fine-tune. Frozen lambda is a bug on a different URL. GPU accuracy is not SpiNNaker accuracy.

QCFS versus other conversion families

You will see spike-norm, robust norm, residual-as-reset, and various soft-reset schemes in the literature. They are other answers to the same scale-and-reset problem. This page does not rank them with made-up CIFAR tables. NeuroCUDA's shipped conversion path is QCFS then IF then BPTT. If you need a tools matrix (SNNToolbox, snnTorch, SpikingJelly, NIRTorch), use ANN-to-SNN conversion tools compared and the framework roundup. If you need theory without a vendor, use /ann-to-snn.

Two distinctions are worth keeping even in a short meeting:

When the method is working

A healthy QCFS run has boring signatures. Per-channel thresholds differ across a layer. They moved from init. Train and test preprocess match. ANN evaluate on the same loader is still the high number you started with. SNN evaluate at the chosen T sits within a few points of the ANN, and for the published N-MNIST CNN it can sit slightly above. Membrane state resets between independent samples. BatchNorm is folded. Residual sums still look like residual sums in the NIR graph.

If instead lambda is a flat line at 1.0, you are no longer on this page's job. Open QCFS threshold (lambda) not learning and follow the gradient-norm checklist. If lambda moved and accuracy is still chance, open the accuracy-drop guide and check reset, T, and folding first. If you never installed the compiler, open the pip guide or the Colab guide. The method explainer will not grow a copy of each of those checklists.

Using QCFS from application code

Application code should not reimplement clip-floor-shift unless you are writing a compiler. Call convert, keep the ANN checkpoint, evaluate both, and store meta. A minimal pattern:

import torch, neurocuda

model.eval()
snn, meta = neurocuda.convert(model, calib_loader)
neurocuda.compile(snn, target="gpu")
ann_acc = neurocuda.evaluate(model, test_loader)
snn_acc = neurocuda.evaluate(snn, test_loader)
print(ann_acc, snn_acc, meta)

Set timesteps=32 when you are on the ResNet-18/CIFAR-10 protocol. For a first smoke test, a smaller T and a tiny MLP are allowed, with the understanding that the printout is not 94.61%. Fine-tuning time is the reason people run this on GPU, including hosted GPUs. CPU convert is valid and slow. TPU runtimes are the wrong accelerator.

After you trust the SNN on GPU, export NIR if you need a portable graph, or point compile at another backend with the label that backend actually earned. Do not skip GPU evaluate and jump to a silicon slide. QCFS does not make a 4-neuron SpiNNaker smoke test into a CIFAR result.

What this page will not do

It will not retell the frozen-lambda optimizer story. It will not paste the eight-step convert HowTo. It will not become a Colab notebook. It will not add a fictional ImageNet percentage. It will not say NeuroCUDA ran QCFS on Loihi silicon. It will not say the SpiNNaker jobs measured ResNet accuracy. Those sentences are how neuromorphic pages lose trust.

What it will do is stay the canonical explanation of QCFS ANN to SNN as used by NeuroCUDA: why clip, floor, and shift exist, why thresholds are per-channel and learnable, why IF replace comes after the proxy, why BPTT needs an atan surrogate, and where to go when the method is fine but your run is not.

Primary sources

  1. Bu et al., "Optimal ANN-SNN Conversion for High-accuracy and Ultra-low-latency Spiking Neural Networks" (QCFS), ICLR 2023
  2. NeuroCUDA technical report, quantaracore.in/neurocuda/paper.pdf
  3. NeuroCUDA source, github.com/Krishnav1/neurocuda
  4. Product hub, quantaracore.in/neurocuda
  5. Troubleshooting frozen lambda, QCFS threshold not learning

Frequently asked questions

What is QCFS ANN to SNN conversion?

QCFS ANN to SNN conversion replaces each ReLU with a Quantized Clip-Floor-Shift activation controlled by a learnable per-channel threshold, then swaps that activation for an integrate-and-fire neuron and fine-tunes with backpropagation through time using an atan surrogate. NeuroCUDA runs this pipeline inside neurocuda.convert(model, calib_loader).

What does Quantized Clip-Floor-Shift mean?

Quantized means the activation is mapped onto a finite number of levels L. Clip bounds the value into a range. Floor discretizes it. Shift recenters the bins so the discrete levels better match ReLU outputs. The scale of that mapping is the learnable threshold, often called lambda or theta.

Why does QCFS use learnable per-channel thresholds?

Different channels in a convolution fire at different scales. A single global threshold cannot map every channel's ReLU range onto a useful spike count at low T. Per-channel thresholds let each feature map keep its own firing scale after conversion.

How does NeuroCUDA apply QCFS ANN to SNN conversion?

Install with pip install neurocuda, then snn, meta = neurocuda.convert(model, calib_loader). convert() calibrates QCFS thresholds on in-distribution batches, folds BatchNorm, replaces activations with IF neurons, and BPTT-fine-tunes with an atan surrogate. You do not implement QCFS by hand unless you are debugging.

Is this the same as QCFS threshold not learning?

No. This page explains the method when it is working. The lambda-not-learning page is a troubleshooting bug: frozen thresholds near 1.0 from a shared learning rate and weak gradient through floor. Use that URL when lambda does not move.

What accuracy does NeuroCUDA report after QCFS conversion?

Published software-backend results: N-MNIST SNN 99.88% ± 0.02% versus ANN 99.70%; ResNet-18/CIFAR-10 SNN 94.61% ± 0.14% versus ANN 95.56% at T=32. Do not treat those as SpiNNaker or Loihi silicon scores.

What is IF replace after QCFS?

After thresholds are calibrated, the QCFS activation is replaced by an integrate-and-fire neuron whose firing threshold is set from the learned scale. The IF unit accumulates input over T timesteps and emits spikes. That is the SNN, not the quantized ANN.

What is the atan surrogate in BPTT?

Spikes are non-differentiable. Backpropagation through time needs a stand-in gradient through the fire step. NeuroCUDA uses an arctan (atan) surrogate during the fine-tune that follows QCFS, so thresholds and remaining weights can still receive gradient.

Does QCFS ANN to SNN run on SpiNNaker or Loihi?

QCFS is a conversion method. Accuracy numbers above are GPU/CPU software-backend results. SpiNNaker-1 physical silicon is a separate smoke test (EBRAINS jobs #420148 and #420186), not ResNet-on-chip accuracy. Loihi 2 in NeuroCUDA is an IF-neuron simulator, not Intel silicon.

What calibration data does QCFS need?

In-distribution batches with the same preprocessing as training, typically a slice of the train set. Out-of-distribution images, wrong normalize stats, or an empty loader will set thresholds that do not match test-time activations.

Next: pip install neurocuda · snn, meta = neurocuda.convert(model, calib_loader) · Product page · Threshold not learning? · PDF report