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 chunk | per trial | |
|---|---|---|
| fp32 (before) | 2.9–5.9s | 91–183ms |
| bf16 (after) | 0.45–0.56s | 14–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:
| config | epoch time |
|---|---|
| fp32 baseline | 119.73s |
| dense-edge bf16 only | 23.95–33.49s |
| both flags | 10.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.