Skip to content
active 2026

COLIDE

Custom CUDA kernels for a CNN-BiLSTM intrusion-detection model, with an on-device LLM that explains alerts without ever sitting in the detection path. A systems study — and an exercise in not overclaiming.

  • CUDA
  • HPC
  • IoT Security
  • LLM
  • Systems
Table 1 results
BoT-IoT macro-F1
0.9780 ± 0.0033
Sealed multi-seed test, n=5 (seeds 42–46)
Blocks 1/2/4 vs matched PyTorch
3.24×–6.55×
Operator-for-operator, RTX 3050
Block 3 kernel progression
7.55×–9.50×
Naive → FP16 half2 gate packing, five sessions
Alert dispatch
16.60 µs p99
Dispatch only — generation runs off the detection path
Every figure carries its conditions · exclusions are stated below
Fig. 02 batch = 1
  1. 00 input features
  2. 01 proj · conv
  3. 02 conv · pool
  4. 03 bilstm fp16 hot path
  5. 04 dense head
  6. 05 alert
  7. async LLM air-gapped · off the detection path
Schematic · latencies land after the measurement pass hot path · 03

4 figures · conditions stated on every plot

Fig. 01 min–max

Horizontal range-bar chart of full-model inference latency. 5 paths measured on one machine; each bar spans the measured minimum to maximum in µs across independent sessions, so the width of a bar is run-to-run spread, not error. Each path's label and its exact range follow.

  • CPU path
  • GPU path
ORT CPU onnxruntime · CPU 487–699 µs
torch.compile inductor · GPU 1,519–1,777 µs
eager PyTorch baseline · GPU 2,050–2,247 µs
TensorRT FP16 GPU 2,427–2,966 µs
ORT GPU onnxruntime · GPU 3,862–4,652 µs
0 1,000 2,000 3,000 4,000 5,000

latency — µs · linear scale · lower is faster

  • TensorRT FP16 is slower than eager PyTorch here — on this model size, on this hardware.
  • ORT on the CPU beats several GPU paths, because the model is small enough that launch overhead dominates.
Full-model latency, same machine · absolute multi-session ranges 2026
Fig. 02 min–max

Horizontal range-bar chart of custom CUDA kernel speedup against a baseline. 3 comparisons; each bar spans the measured minimum to maximum multiplier across independent sessions, so bar width is run-to-run spread, not error. A reference rule marks 1×, meaning no speedup. Each comparison and its exact range follow.

  • vs framework baseline
  • vs vendor library
  • 1× baseline
Blocks 1/2/4 vs matched PyTorch · RTX 3050 3.24–6.55×
Block 3 naive → FP16 half2 gate packing 7.55–9.50×
FP16 vs cuDNN same operator 1.30–1.47×
0 2 4 6 8 10
1× baseline

speedup — × versus baseline · linear scale · higher is faster

  • Ranges span independent sessions. The spread is run-to-run variation, not error bars.
Custom kernels vs baseline · operator-for-operator · RTX 3050 2026
Fig. 03 log₁₀ · two paths

Two published figures for a single alert, placed on a base-10 logarithmic time axis running from 10 µs to 10 s. Alert dispatch, which runs on the detection path, costs 16.60 µs (p99). LLM generation, which runs off the detection path, takes ≈ 8.5 s (per alert). The ratio between them is about 512,000 to one, derived by dividing the second figure by the first. They sit on different execution paths and are never added together or expressed as a speedup.

Alert dispatch detection path · bounded queue enqueue 16.60 µs p99
LLM generation off the detection path · per alert ≈ 8.5 s per alert
≈ 512,000× derived
10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s

time per alert — log₁₀ scale · every gridline is one factor of 10

  • LLM generation "happens entirely off the detection path" — the two figures are not drawn from the same budget, and are never summed or divided into a pipeline number.
  • Derived, not measured: 8.5 s ÷ 16.60 µs, in one unit 8,500,000 µs ÷ 16.60 µs = 512,048×, shown above to three significant figures because the ≈ 8.5 s numerator is stated approximately in the source. It is a ratio of two figures from different execution paths, so it is not a speedup and not a budget.
  • "Conflating the two numbers would be the easiest way to make this project look better than it is." — the project write-up.
One alert, two execution paths · published figures, log₁₀ axis 2026
Fig. 04 mean ± published spread

Point estimate with an error bar. BoT-IoT macro-F1 is 0.9780, plus or minus 0.0033, n = 5 (seeds 42–46). The interval therefore runs from 0.9747 to 0.9813. The horizontal axis is truncated: it runs from 0.96 to 0.99, not from zero. Comparators drawn for reference: protocol LightGBM · 0.9818 at 0.9818. No distribution curve is drawn, because the underlying samples are not published.

BoT-IoT macro-F1 0.9780 ± 0.0033

n = 5 (seeds 42–46)

0.96 0.97 0.98 0.99
protocol LightGBM · 0.9818 0.9818

BoT-IoT macro-F1 · linear scale · truncated axis — window 0.96 – 0.99, not zero-based

0 1

full 0–1 scale · the box marks the window plotted above

  • Interval spans 0.9747 to 0.9813: the published ± figure, plotted as published. n = 5 (seeds 42–46). The source does not state whether it is a standard deviation, a standard error or a confidence interval, so it is not labelled as one here.
  • No distribution is drawn. There is a mean and a spread, not a set of samples — a curve over these two numbers would be a shape nobody measured.
Sealed multi-seed test protocol · BoT-IoT · n=5 2026

The problem

Intrusion detection on IoT traffic wants two things that pull against each other: a model small enough to run on a constrained GPU at batch size one, and an explanation a human operator can act on.

The second requirement is usually solved by calling a cloud API, which is exactly what you cannot do on an air-gapped sensor. The first is usually solved by handing the model to TensorRT or torch.compile and trusting the framework — which turns out to work poorly for this shape of model. Production inference stacks are tuned for large graphs and large batches. A sub-1M-parameter model with a bidirectional LSTM in the middle of it is close to their worst case: dynamic recurrent control flow, tiny tensors, and per-launch overhead that dominates the actual arithmetic.

COLIDE asks what you get back by writing the forward path yourself, and it tries to answer that question in a way that survives review.

What was built

  • A CNN-BiLSTM classifier (~530K parameters) trained on BoT-IoT and ToN-IoT, with knowledge distillation from a classical tree-ensemble teacher and focal loss for the minority attack classes.
  • Four fused CUDA C++ kernels replacing the PyTorch operators for the projection/conv block, the second conv block, the BiLSTM, and the dense head. Each kernel is standalone — no build system, it compiles with a single nvcc invocation and benchmarks itself.
  • A statistical benchmark harness that runs each configuration as independent subprocess trials and records the distribution, not a point estimate — because a single run of this workload is not reproducible enough to publish.
  • An asynchronous alert-dispatch prototype that hands detections to a local quantized LLM (TinyLlama 1.1B, Q4, ~0.77 GB) through a bounded queue, so generation never blocks detection.
  • A claim verifier. scripts/verify_claims.py walks every bold number in the repository’s documentation and re-derives it from the JSON artifact it came from; check_stale_claims.py fails the build if a withdrawn figure reappears anywhere. That tooling exists because a number was withdrawn once already, and I wanted the repo to catch the next one instead of me.

What is measured

Detection, on BoT-IoT, under a sealed multi-seed protocol: macro-F1 0.9780 ± 0.0033 across five seeds. That is competitive with the classical baselines rather than better than all of them — protocol LightGBM still leads on pure F1 at 0.9818. The argument is accuracy per unit of latency, not accuracy supremacy, and the write-up says so.

Per-block, on an RTX 3050, matched operator against matched operator: the custom kernels win clearly on Blocks 1, 2 and 4 (3.24×–6.55×). Block 3 — the BiLSTM — is the hard one, and its optimisation history is the interesting part: naive → precomputed input projection → transposed recurrent weights for coalesced access → CUDA Graphs → FP16 half2 gate packing, cumulatively 7.55×–9.50× over the naive kernel, and 1.30×–1.47× over cuDNN across five independent sessions on that laptop.

The full-model framework latencies were measured too, as absolute multi-session ranges on the same box: ORT CPU 487–699 µs, torch.compile 1,519–1,777 µs, eager PyTorch 2,050–2,247 µs, TensorRT FP16 2,427–2,966 µs, ORT GPU 3,862–4,652 µs. Two things in that list are worth sitting with. TensorRT FP16 is slower than eager PyTorch here, on this model size, on this hardware. And ORT on the CPU beats several GPU paths, because the model is small enough that launch overhead dominates.

Cross-hardware runs on V100S and A100 nodes at a university computing centre give a separate full-model batch-1 matrix, where TensorRT native is the fastest GPU path (528 µs V100S / 588 µs A100).

Alert dispatch costs 16.60 µs at p99. Generation itself takes roughly 8.5 seconds per alert and happens entirely off the detection path — that separation is the whole point of the design, and conflating the two numbers would be the easiest way to make this project look better than it is.

What is not claimed

This section is load-bearing, not modesty.

  • There is no full-pipeline speedup number. The custom CUDA path covers fused Blocks 1–4 only. The full model also has attention, LayerNorm, residual connections, temporal pooling and the classifier, none of which are in the kernel chain. So the partial pipeline sum (594–675 µs) is reported as an absolute latency with its scope attached, and is never divided by a full-model framework latency. “Custom CUDA is N× faster than TensorRT” would be an easy sentence to write and it would be false.
  • A negative result stands unretracted. On the cluster GPUs, matching PyTorch Block 3 measured faster than the CUDA Block 3 (~363 vs ~513 µs on V100S; ~385–391 vs ~667–671 µs on A100). Those are historical wall-clock numbers from before a kernel fix, and they have not been re-run on the server since. Rather than quietly dropping the comparison, the active claim set treats server Block 3 as unresolved until a fresh run exists.
  • The recurrent architecture is not defended as necessary. An MLP ablation reaches competitive macro-F1 on this feature set. The BiLSTM is kept because it is a genuinely hard case for compilers and kernels — which is what the project is actually studying — not because the data demanded it.
  • The LLM explanations are not validated. The dispatch mechanism is measured; free-form output quality is weak under the explainability checks, and the work is not titled as validated LLM-based explainability.
  • An earlier ToN-IoT result was withdrawn. A 26-feature “clean” run reported much stronger numbers, and was invalid: the loader kept a target-derived column in the feature matrix, fit encoders before the split, and applied SMOTE to integer-encoded categoricals. The corrected leakage-safe rerun is weaker and honest — CNN 0.8075 against a same-split random forest at 0.9626 — and the old figures are tombstoned in the repository rather than deleted.

What measuring this taught me

Two findings changed how I benchmark anything now.

The first: a kernel that failed numerical validation about six times in thirty was blamed, in my own notes, on floating-point accumulation order. That explanation was wrong, and it was checkably wrong — accumulation order is deterministic, and the same seeded input through the same binary was giving different output every run. compute-sanitizer --tool racecheck found a real shared-memory race between a timestep’s hidden-state write and the next timestep’s read of it, across an intervening __syncthreads(). Double-buffering the hidden state fixed it: zero hazards under racecheck, 100/100 runs passing, and 20/20 passing at a tolerance three orders of magnitude tighter than the one it had been scraping by on.

The second: re-running the same benchmark suite twice in one sitting moved framework latencies by 14–17%, and moved individual kernel configurations by up to 27% — while each session’s own coefficient of variation looked perfectly tight. Within-session variance understates the real uncertainty on a WSL2 development box. Every latency figure above is therefore a range across independent sessions, and single-run tables are treated as unpublishable.

Stack

CUDA C++ · PyTorch · knowledge distillation · TensorRT / ONNX Runtime (baselines) · TinyLlama 1.1B Q4 · BoT-IoT / ToN-IoT · compute-sanitizer · a claim-verification harness