<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sowaiba Arshad</title>
    <description>The latest articles on DEV Community by Sowaiba Arshad (@sowaiba01).</description>
    <link>https://dev.to/sowaiba01</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4041961%2Fa1e26ab7-6b3d-4d53-9ee2-6d32c5ec103f.jpeg</url>
      <title>DEV Community: Sowaiba Arshad</title>
      <link>https://dev.to/sowaiba01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sowaiba01"/>
    <language>en</language>
    <item>
      <title>Why My Medical AI Took 6.4 Seconds Per Scan and How I Got It to 3.1.</title>
      <dc:creator>Sowaiba Arshad</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:31:16 +0000</pubDate>
      <link>https://dev.to/sowaiba01/why-my-medical-ai-took-64-seconds-per-scan-and-how-i-got-it-to-31-57i2</link>
      <guid>https://dev.to/sowaiba01/why-my-medical-ai-took-64-seconds-per-scan-and-how-i-got-it-to-31-57i2</guid>
      <description>&lt;p&gt;I built a chest X-ray diagnostic platform called &lt;strong&gt;ThoraxNet&lt;/strong&gt;. It detects 14 thoracic pathologies from a single image, reports how confident it is using Monte Carlo Dropout, draws **GradCAM **heatmaps over the regions that drove each prediction, and writes a structured radiology report with an LLM.&lt;/p&gt;

&lt;p&gt;The model was the interesting part to build. It was not the part that decided whether the thing felt like a product. That came down to one unglamorous number: &lt;strong&gt;&lt;em&gt;how long a user waits after they hit "analyze."&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is about how I took that wait from 6.4 seconds to 3.1 seconds, why the honest answer is "about 2x and not 10x," and the two bugs I only found because I stopped trusting the happy path and read the logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://thorax-tho.vercel.app" rel="noopener noreferrer"&gt;https://thorax-tho.vercel.app&lt;/a&gt;&lt;br&gt;
Code: &lt;a href="https://github.com/Sowaiba-01/ThoraxNet" rel="noopener noreferrer"&gt;https://github.com/Sowaiba-01/ThoraxNet&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: a good model wrapped in a slow request
&lt;/h2&gt;

&lt;p&gt;The model worked. The demo worked. But every scan took roughly six and a half seconds, and a six second spinner makes anything feel broken no matter how good the output underneath it is.&lt;/p&gt;

&lt;p&gt;My first instinct was the same wrong instinct most people have: "the vision transformer forward pass must be the bottleneck, so let me go optimize the model." I have shipped enough regressions chasing that kind of hunch to know it is worth nothing until it is measured.&lt;/p&gt;

&lt;p&gt;So before changing a single line of model code, I instrumented the pipeline to report how long each stage actually took, and returned that breakdown on every single response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"stage_timings_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preprocess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mc_dropout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2868&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gradcam"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"report"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;92&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was not what I expected, and that is exactly why measuring first matters. A single forward pass through the transformer is about 15 milliseconds. The model was never the problem. Monte Carlo Dropout was, and it was eating nearly all of the server side budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: twenty forward passes, one at a time
&lt;/h2&gt;

&lt;p&gt;Monte Carlo Dropout estimates uncertainty by running the model many times with dropout left switched on, then measuring how much the predictions move. My original implementation did the obvious thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_samples&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# n_samples = 20
&lt;/span&gt;    &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# one image, batch size 1
&lt;/span&gt;    &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sigmoid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty sequential forward passes, each with a batch size of one. Every pass pays the full per call overhead, and the hardware spends most of its time waiting between launches instead of computing.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking twenty separate times and ask once. Tile the single image into a batch of twenty and run one forward pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tiled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# (20, 3, 224, 224)
&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tiled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# ONE forward pass
&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sigmoid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;std&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correctness argument is the part worth understanding, and it is the exact question a good interviewer will ask you. Are those twenty copies not identical? No. Dropout samples a fresh mask for every element in the batch. So the twenty tiled copies each get an independent dropout mask, which means they are exactly the twenty independent stochastic samples the estimator needs. Same statistics, one launch instead of twenty.&lt;/p&gt;

&lt;p&gt;I did not want to trust that reasoning on faith, so I wrote a test that runs both the old sequential version and the new batched version four hundred times each and asserts their means agree within Monte Carlo error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batched_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequential_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;atol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test is the difference between "I think this is equivalent" and "I proved this is equivalent."&lt;/p&gt;

&lt;h2&gt;
  
  
  The results, measured on identical hardware
&lt;/h2&gt;

&lt;p&gt;Same host, same image, same protocol of thirty requests with warmup discarded. The only variable is the code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (v1.0.0)&lt;/th&gt;
&lt;th&gt;After (v1.1.0)&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;6,387 ms&lt;/td&gt;
&lt;td&gt;3,127 ms&lt;/td&gt;
&lt;td&gt;1.9x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 latency&lt;/td&gt;
&lt;td&gt;7,525 ms&lt;/td&gt;
&lt;td&gt;3,862 ms&lt;/td&gt;
&lt;td&gt;1.9x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;8,026 ms&lt;/td&gt;
&lt;td&gt;4,768 ms&lt;/td&gt;
&lt;td&gt;1.7x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;0.15 req/s&lt;/td&gt;
&lt;td&gt;0.30 req/s&lt;/td&gt;
&lt;td&gt;2.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every percentile improved by roughly 2x, with no change to the model weights and no change to accuracy. This is purely a change in how the model is executed.&lt;/p&gt;

&lt;p&gt;Two smaller changes cleaned up the rest of the request path. The LLM report call was moved off the critical path onto a worker thread, so the user gets their pathology result without waiting on report generation. And GradCAM heatmaps, which were being recomputed on every request, are now cached per image and class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: why it is 2x and not 10x
&lt;/h2&gt;

&lt;p&gt;Here is where it would be easy to lie, and where lying would eventually cost me an offer.&lt;/p&gt;

&lt;p&gt;On a GPU, this exact change is often close to 10x. The entire win comes from keeping an accelerator busy that was otherwise sitting idle between twenty tiny kernel launches.&lt;/p&gt;

&lt;p&gt;ThoraxNet runs on a free tier CPU host with two virtual cores. There is far less idle parallelism to reclaim there. So the same code change gives roughly 2x, not roughly 10x.&lt;/p&gt;

&lt;p&gt;I could have written "10x faster" in the title and most readers would never have checked. But the number that survives a technical interview is the one you can explain. The honest version is: 2x on CPU, because the batching win is bounded by how much idle parallelism there is to reclaim, and Monte Carlo Dropout on CPU is still the dominant cost, so the next real lever is GPU inference or quantization, not more batching. That sentence is worth more than a bigger number I cannot defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs I found by reading the whole path, not the ticket
&lt;/h2&gt;

&lt;p&gt;Profiling forced me to read the entire request path from HTTP call to response. Two problems fell out that had nothing to do with latency.&lt;/p&gt;

&lt;p&gt;GradCAM had never worked. The route handler read an attribute on the pipeline that was never actually assigned anywhere. The inference code built the heatmaps into a local variable and then dropped them when the function returned. Every heatmap request came back as a 404. Nothing logged an error, and the frontend simply rendered an empty panel, so it shipped and sat broken because no code path ever raised. I fixed it and pinned it with a regression test that fails if the overlays are not recorded, because a silent bug earns a loud test.&lt;/p&gt;

&lt;p&gt;Every radiology report was quietly failing. While watching the deploy logs I saw this on repeat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The model `llama3-70b-8192` has been decommissioned and is no longer supported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM provider had retired that model months earlier. Every report request was returning a 400 and falling back to a template, so users were getting canned text instead of a generated report, and nobody noticed because the fallback made it look fine. The fix was one line to point at the current model, plus an environment variable so the next deprecation is a config change and not a code change.&lt;/p&gt;

&lt;p&gt;Neither of these was in my task list. Both were only visible because I stopped trusting the happy path and actually read what the server was doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do next
&lt;/h2&gt;

&lt;p&gt;Monte Carlo Dropout still dominates at roughly 2.8 seconds. Batching took the easy win. The remaining levers are honest about the hardware rather than clever in the request path:&lt;/p&gt;

&lt;p&gt;GPU or quantized inference to cut the per request cost of the twenty passes. The export and evaluation scripts are already written, and the accuracy delta table is the homework I still owe.&lt;/p&gt;

&lt;p&gt;A shared cache for the GradCAM session store, since it currently lives in process memory that will not survive a restart or a second replica.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The model was 15 milliseconds. The product was 6.4 seconds. The entire gap lived in how the model was called, not in the model itself, and I only found that because I measured before I touched anything and read the logs instead of the ticket.&lt;/p&gt;

&lt;p&gt;The unglamorous work is the work. Profiling first, proving equivalence with a test, and being honest about a 2x instead of inflating it to a 10x are the habits that hold up when someone actually reads your code.&lt;/p&gt;




&lt;p&gt;If you found this useful, follow me on GitHub for more write ups like this: &lt;strong&gt;&lt;a href="https://github.com/Sowaiba-01" rel="noopener noreferrer"&gt;https://github.com/Sowaiba-01&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The full project is open source. Fork it, build on it, and if it helped you or you just think it is cool, a star means a lot: &lt;strong&gt;&lt;a href="https://github.com/Sowaiba-01/ThoraxNet" rel="noopener noreferrer"&gt;https://github.com/Sowaiba-01/ThoraxNet&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ThoraxNet is for research use only. It is not FDA cleared and is not a substitute for a radiologist.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>pytorch</category>
    </item>
  </channel>
</rss>
