|
| 1 | +# Why My Medical AI Took 6.4 Seconds Per Scan — and How I Got It to 3.1 |
| 2 | + |
| 3 | +*Building ThoraxNet, a chest X-ray diagnostic platform, and the unglamorous |
| 4 | +engineering that made it usable.* |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +ThoraxNet detects 14 thoracic pathologies from a chest X-ray. It runs a |
| 9 | +fine-tuned BioMedCLIP ViT-B/16, reports Monte Carlo Dropout uncertainty, draws |
| 10 | +GradCAM heatmaps, and writes a structured radiology report. The model was the |
| 11 | +fun part. This post is about the part that actually decides whether something |
| 12 | +is a product: latency. |
| 13 | + |
| 14 | +Live demo: [thorax-tho.vercel.app](https://thorax-tho.vercel.app) · |
| 15 | +Code: [github.com/Sowaiba-01/ThoraxNet](https://github.com/Sowaiba-01/ThoraxNet) |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## 6.4 seconds is not a product |
| 20 | + |
| 21 | +The model worked. The demo worked. But every scan took about six and a half |
| 22 | +seconds, and a six-second spinner makes anything feel broken, no matter how |
| 23 | +good the output is. |
| 24 | + |
| 25 | +The first thing I did was refuse to guess. I've watched myself "optimize" the |
| 26 | +wrong thing enough times to know the intuition — *the ViT forward pass must be |
| 27 | +the bottleneck* — is worth exactly nothing until it's measured. So I added |
| 28 | +per-stage timing to the inference pipeline and returned it on every response: |
| 29 | + |
| 30 | +```json |
| 31 | +"stage_timings_ms": { |
| 32 | + "preprocess": 15.7, |
| 33 | + "mc_dropout": 2770.5, |
| 34 | + "gradcam": 5.4, |
| 35 | + "report": 42.4 |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +The result was not what I expected. A single forward pass through the ViT is |
| 40 | +~15 ms. The model was never the problem. **Monte Carlo Dropout was — 2.7 of |
| 41 | +the ~2.8 server-side seconds.** And once I looked at *how* it ran, the reason |
| 42 | +was almost embarrassing. |
| 43 | + |
| 44 | +## The bug that wasn't a bug: 20 passes, one at a time |
| 45 | + |
| 46 | +MC Dropout estimates uncertainty by running the model many times with dropout |
| 47 | +left on, then looking at how much the predictions wobble. My implementation did |
| 48 | +the obvious thing: |
| 49 | + |
| 50 | +```python |
| 51 | +samples = [] |
| 52 | +for _ in range(n_samples): # n_samples = 20 |
| 53 | + logits = model(x) # one image, batch size 1 |
| 54 | + samples.append(torch.sigmoid(logits)) |
| 55 | +``` |
| 56 | + |
| 57 | +Twenty sequential forward passes, each with a batch size of one. Every pass |
| 58 | +pays the full per-call overhead, and the hardware spends most of its time |
| 59 | +waiting between launches rather than computing. |
| 60 | + |
| 61 | +The fix is to stop asking 20 times and ask once — tile the single image into a |
| 62 | +batch of 20 and run a single forward pass: |
| 63 | + |
| 64 | +```python |
| 65 | +tiled = x.repeat(n_samples, 1, 1, 1) # (20, 3, 224, 224) |
| 66 | +logits = model(tiled) # ONE forward pass |
| 67 | +probs = torch.sigmoid(logits).view(n_samples, batch, -1) |
| 68 | +mean, std = probs.mean(0), probs.std(0) |
| 69 | +``` |
| 70 | + |
| 71 | +The correctness argument is the part worth understanding, and it's the question |
| 72 | +an interviewer will ask: *aren't those 20 copies identical?* No — dropout |
| 73 | +samples a fresh mask per element of the batch. So the 20 tiled copies get 20 |
| 74 | +independent dropout masks, which is exactly the 20 independent stochastic |
| 75 | +samples the estimator needs. Same statistics, one launch instead of twenty. |
| 76 | + |
| 77 | +I didn't want to take that on faith, so there's a test that runs both the old |
| 78 | +sequential version and the new batched version 400 times each and asserts the |
| 79 | +means agree within Monte Carlo error: |
| 80 | + |
| 81 | +```python |
| 82 | +assert torch.allclose(batched_mean, sequential_mean, atol=0.05) |
| 83 | +``` |
| 84 | + |
| 85 | +## The honest number: 2×, not 10× |
| 86 | + |
| 87 | +Here's where I have to be straight, because it would be easy to lie here. |
| 88 | + |
| 89 | +On a GPU, this change is enormous — often close to 10×. The whole win comes |
| 90 | +from keeping an accelerator busy that was otherwise idle between tiny launches. |
| 91 | + |
| 92 | +ThoraxNet runs on a free-tier CPU box (2 vCPUs). There is far less idle |
| 93 | +parallelism to reclaim. So the same code change gave me **~2×, not ~10×**: |
| 94 | + |
| 95 | +| Version | p50 | p95 | p99 | |
| 96 | +|---|---|---|---| |
| 97 | +| Before | 6,387 ms | 7,525 ms | 8,026 ms | |
| 98 | +| After | 3,146 ms | 3,287 ms | 3,395 ms | |
| 99 | + |
| 100 | +Measured, 30 requests, same image, zero failures. A 2.03× end-to-end |
| 101 | +improvement, and the tail improved more than the median (p99 2.36×) because |
| 102 | +batching kills the per-pass overhead that hurt the slowest requests worst. |
| 103 | + |
| 104 | +I could have written "10× faster" and most people wouldn't have checked. But |
| 105 | +the number that survives an interview is the one you can explain: *2× on CPU, |
| 106 | +because the batching win is bounded by idle parallelism, and MC Dropout on CPU |
| 107 | +is still the bottleneck — the next real lever is GPU inference or INT8, not more |
| 108 | +batching.* That sentence is worth more than a bigger fake number. |
| 109 | + |
| 110 | +## Two bugs I found by reading the whole request path |
| 111 | + |
| 112 | +Profiling made me read the entire path from HTTP request to response, and two |
| 113 | +things fell out that had nothing to do with latency. |
| 114 | + |
| 115 | +**GradCAM had never worked.** The route handler read |
| 116 | +`pipeline.gradcam._last_overlays` — an attribute that was never assigned |
| 117 | +anywhere. The pipeline built the heatmaps into a local variable and dropped |
| 118 | +them when the function returned. Every heatmap request returned 404. Nothing |
| 119 | +logged an error; the frontend just showed an empty panel. It had shipped and |
| 120 | +sat broken because no code path ever raised. I fixed it and wrote a regression |
| 121 | +test that fails if the overlays aren't recorded — because a silent bug deserves |
| 122 | +a loud test. |
| 123 | + |
| 124 | +**Every radiology report was silently failing.** While watching the deploy |
| 125 | +logs I saw: |
| 126 | + |
| 127 | +``` |
| 128 | +The model `llama3-70b-8192` has been decommissioned and is no longer supported. |
| 129 | +``` |
| 130 | + |
| 131 | +Groq had retired the model months earlier. Every report request returned HTTP |
| 132 | +400 and fell back to a template, so users got canned text instead of an |
| 133 | +LLM-written report — and nobody noticed, because the fallback made it look |
| 134 | +fine. One line to point at the current model, plus an env var so the next |
| 135 | +deprecation is config, not code. |
| 136 | + |
| 137 | +Neither bug was in my ticket. Both were only visible because I stopped trusting |
| 138 | +the happy path and read the logs. |
| 139 | + |
| 140 | +## What I'd do next |
| 141 | + |
| 142 | +MC Dropout still dominates at 2.7 s. Batching took the easy win; the real |
| 143 | +remaining levers are honest about the hardware: |
| 144 | + |
| 145 | +- **GPU or ONNX/INT8 inference** — the scripts are written; the accuracy-delta |
| 146 | + table is the homework I still owe. |
| 147 | +- **Redis for the GradCAM session store** — right now it's process-local |
| 148 | + memory that won't survive a restart or a second replica. |
| 149 | + |
| 150 | +## The takeaway |
| 151 | + |
| 152 | +The model was 15 ms. The product was 6.4 seconds. The gap was entirely in how |
| 153 | +the model was *called*, not in the model itself — and I only found that because |
| 154 | +I measured before I touched anything, and read the logs instead of the ticket. |
| 155 | + |
| 156 | +The unglamorous work is the work. |
| 157 | + |
| 158 | +--- |
| 159 | + |
| 160 | +*ThoraxNet is for research use only. Not FDA cleared. Not a substitute for a |
| 161 | +radiologist.* |
0 commit comments