← All notes

Windows/CUDA bf16: an 8–11x speedup, a real OOM fix, and k-of-n smoothing

First real Windows/CUDA session on a local RTX 3070 Ti (8GB VRAM). Three threads: turning a previously-abandoned bf16 speedup attempt into a genuine ~10x win, root-causing a CUDA out-of-memory crash in prediction mode, and adding the same alarm-smoothing post-process the Truong baseline already uses, so the two pipelines' event-level numbers are directly comparable. Driven throughout by pushing back on unverified timing claims — every number below came from a log, not an estimate, including one of the write-up's own mislabeled figures caught and corrected mid-session.

Why CUDA wasn't even available

Root cause of "CUDA doesn't work on this machine yet": plain PyPI only hosts CPU-only torch wheels for Windows, so a bare pip install torch==2.8.0 silently installs a build where torch.cuda.is_available() returns False even with a working GPU and driver. Fixed by pointing at PyTorch's own CUDA wheel index and pinning the exact CUDA build.

Turning a documented non-win into a real ~10x fix

A bf16 speedup flag for the dense-edge computation had previously been left in place but broken — wrapping the computation in bf16 autocast crashed the moment downstream code tried to build a complex tensor from bf16 output, since torch.complex() only accepts a few specific dtypes. Two real bugs, not just the crash, were behind it: the elementwise cross-spectrum math wasn't actually autocast-eligible in the first place (autocast only downcasts convolution/matmul ops, not plain multiply/subtract), so those tensors stayed full-precision regardless of the flag — exactly what was overflowing memory. And even after fixing that, the smoothing step was forcing its output back to float32 just to build a complex number, silently re-inflating the exact memory the change existed to shrink, since a complex tensor is always 8 bytes per element regardless of the precision feeding it. Fixed by dropping the complex-tensor representation entirely in favor of the equivalent real-valued math (phase via atan2, magnitude-squared via a sum of squares) — algebraically identical, but lets every stage genuinely stay in bf16.

Verified numerically first: bf16 output matched full-precision output to within noise-level difference on a synthetic test signal, consistent with an existing precision-tolerance precedent already in the pipeline. Measured speedup on the dense-edge computation stage alone, full 23-channel real data:

compute per chunkper trial
fp32 (before)2.9–5.9s91–183ms
bf16 (after)0.45–0.56s14–17ms

About 11× faster on that stage. A separate flag brought the same bf16 autocast to the model's actual trainable forward pass, which the first flag didn't touch at all (it only covers precompute, outside the autograd graph). Combined, real GPU, 23 channels, per-epoch wall time:

configepoch time
fp32 baseline119.73s
dense-edge bf16 only23.95–33.49s
both flags10.51–15.36s

Roughly 8–11× faster overall at smoke scale, with metrics matching prior full-precision runs closely — no divergence beyond the expected bf16 noise floor.

A real CUDA out-of-memory crash, root-caused

The prediction-mode classifier prepares features for an entire test set in one un-chunked call before any batching happens — fine for a small test set, wrong here, because test windows are deliberately never subsampled the way training negatives are, so a real leave-one-seizure-out test fold can be as large as a training fold. The first full 6-fold run crashed exactly there, right after the first fold's training completed cleanly, trying to allocate under a gigabyte with the GPU already full. Fixed by overriding prediction to reuse the same lazy, batch-at-a-time feature loader already built for training, in sequential rather than shuffled order, so only one batch's features are ever materialized in memory at once. Verified against a known-good prior run before trusting it at real scale — metrics matched almost exactly.

k-of-n alarm smoothing, added for a fair comparison

To make this pipeline's event-level numbers directly comparable to the Truong baseline's own smoothed numbers, added the identical post-process it uses — requiring k of the last n consecutive window predictions to be positive before registering an alarm, applied per held-out recording in chronological order, never across recordings, using the same published defaults rather than inventing new ones. A diagnostic-scale check showed a substantial false-alarm reduction with no cost to detections at that scale — though as the companion real-run note found, that didn't hold at full scale (see the next entry).

Open items

None of this session's code changes were committed at write time. A one-off 417-second epoch outlier showed up once in the combined-bf16 timing log and wasn't reproduced on a re-run — flagged as a likely one-time driver stall rather than silently dropped. The complex-tensor-to-real-valued rewrite touches every caller of the smoothing step, not just the bf16 path — verified correct with the flag off, but flagged as worth a second look given how many call sites it touches.