Skip to content

Warm/persistent LesionSegmenter predictor (kills per-request cold start) - #117

Merged
aperson30 merged 3 commits into
BodyMaps:mainfrom
aperson30:perf/lesionseg-warm-predictor
Aug 1, 2026
Merged

Warm/persistent LesionSegmenter predictor (kills per-request cold start)#117
aperson30 merged 3 commits into
BodyMaps:mainfrom
aperson30:perf/lesionseg-warm-predictor

Conversation

@aperson30

Copy link
Copy Markdown
Collaborator

Summary

LesionSegmenter spawns a fresh subprocess per request, so every request pays interpreter start + torch import + checkpoint load + cuDNN autotune before any real work begins. Measured on an idle GB10: ~9s per request, every request.

This adds an optional persistent predictor that loads the model once and serves over localhost.

Measured (idle node, identical cases and settings)

4 typical cases (5–30 MB):

mean per case
cold (today) 29.8s
warm 21.1s
warm + bbox export (opt-in) 15.0s

1.42x from the warm predictor alone; 1.99x with the opt-in bbox export.

A 5th pathological case (176s cold, export-dominated) is unchanged by warm alone but goes 176s → 87s with bbox export enabled.

Design

  • lesionseg_predict.py gains a stdlib-only fast path that hands work to the service when LESIONSEG_WARM_URL is set and healthy; otherwise it runs exactly the previous in-process code. The heavy imports moved inside the cold path — importing torch before the health check would give back several seconds of the very cost this exists to avoid.
  • auto_segmentor.py is untouched. The CLI shape, subprocess tracking, and per-session cancel all behave exactly as today.
  • Any failure (service down, busy, misconfigured) falls back to cold inference. The site must keep working if the service dies, just more slowly.
  • The server returns 409 on a step_size/disable_tta mismatch rather than serving a configuration the caller did not ask for. Silently serving different settings would be an invisible accuracy change in a cancer-detection tool.

Accuracy

The warm predictor changes nothing. Same model, same settings, same code path — the only difference is when the model is loaded. Output is byte-identical.

The bbox-refined export is included but defaults OFF (BBOX_EXPORT=0). It runs the exact trilinear resample only inside a dilated box around lesion candidates, with a cheap nearest-neighbour map elsewhere. Validated on 40 size-stratified lesion cases:

  • pancreatic lesion Dice 0.9947 mean / 0.977 worst, zero misses (n=33)
  • no object of ≥100 voxels lost in any of the four lesion classes
  • export 4.50s → 0.23s median (17.6x)

The only two Dice-0.0 events across all 40 cases were a 4-voxel and a 6-voxel speck, where Dice is a degenerate metric. It is opt-in because it does perturb a small number of boundary voxels at float tolerance; enabling it is a separate decision from shipping the warm predictor.

Deploying

bash flask-server/scripts/run_lesionseg_warm.sh     # setsid, waits on /health
# then add to flask-server/.env:
LESIONSEG_WARM_URL=http://127.0.0.1:8765

Without LESIONSEG_WARM_URL set, behaviour is identical to today — so this merges safely before anyone starts the service.

Test plan

  • Warm server starts and serves; model load 3.7–4.8s
  • Three-arm benchmark (cold / warm / warm+bbox) on 5 real cases, same settings
  • Config-mismatch path returns 409 and client falls back to cold
  • py_compile passes on all changed files
  • Not yet run against the live site — needs the service started on bdmap1

…old start

LesionSegmenter currently spawns a fresh subprocess per request, so every single
request pays interpreter start, torch import, checkpoint load and cuDNN autotune
before any real work begins. Measured on an idle GB10: ~9s of every request.

This adds an optional persistent predictor that loads the model once and serves
over localhost. lesionseg_predict.py grows a stdlib-only fast path that hands the
work to that service when LESIONSEG_WARM_URL is set and the service is healthy,
and otherwise runs exactly the previous in-process code. The heavy imports moved
inside the cold path -- importing torch before the health check would give back
several seconds of the very cost this exists to avoid.

auto_segmentor.py is deliberately untouched: the CLI shape, subprocess tracking
and per-session cancel all behave exactly as before.

Measured end to end on an idle node, 4 typical cases (5-30MB):
  cold 29.8s -> warm 21.1s (1.42x)
On a pathological large case: 176s -> unchanged by warm alone (its cost is export,
not startup) -- addressed separately by the opt-in bbox export below.

Output is byte-identical: same model, same settings, same code path. The only
change is when the model is loaded.

The server refuses (409) any request whose step_size/disable_tta differ from what
it was started with, and the client falls back to cold inference. Silently serving
a different configuration than the caller asked for would be an invisible accuracy
change in a cancer-detection tool.

Also included, defaulted OFF (BBOX_EXPORT=0): a bbox-refined export that runs the
exact trilinear resample only inside a dilated box around lesion candidates and a
cheap nearest-neighbour map elsewhere. Validated on 40 size-stratified lesion
cases: pancreatic lesion Dice 0.9947 mean / 0.977 worst, zero misses, and no
object of >=100 voxels lost in any lesion class. Export 4.50s -> 0.23s median.
It is opt-in because it perturbs a small number of boundary voxels at float
tolerance; the warm predictor alone changes nothing.
Comment thread flask-server/scripts/lesionseg_warm_server.py Fixed
Comment thread flask-server/scripts/lesionseg_warm_server.py Fixed
Comment thread flask-server/scripts/lesionseg_warm_server.py Fixed
CodeQL flagged three "uncontrolled data used in path expression" failures, and
it is right: input_dir and output_dir arrive in the /predict body and were being
passed straight to os.listdir / os.makedirs / os.path.isdir. Binding the service
to 127.0.0.1 limits who can reach it but does not make those values trustworthy --
any local process, or a compromised web worker, could pass "/etc" or "../../.."
and have the service enumerate or write outside the app.

Paths are now resolved with realpath (so symlinks cannot be used to step out) and
required to sit inside a configured root before any filesystem call. The root
defaults to the Flask app's sessions directory -- $LESIONSEG_ROOT, else
$SESSIONS_DIR_PATH, else <flask-server>/sessions -- which is where
auto_segmentor.py builds the per-session dirs this service is asked to process.
run_lesionseg_warm.sh passes it explicitly and /health reports it.

run_inference re-confines its own arguments rather than trusting the caller, and
filenames from os.listdir are basename'd before being joined.

Verified on a node:
  /etc                              -> 400 "path is outside the permitted root"
  /tmp/<root>/../../etc             -> 400 "path is outside the permitted root"
  legitimate session dir            -> 200, output written as before
Comment thread flask-server/scripts/lesionseg_warm_server.py Fixed
Comment thread flask-server/scripts/lesionseg_warm_server.py Fixed
…ke 2)

The previous commit resolved request paths and checked containment, but CodeQL
still reported the same three findings: the check lived in a helper, and the
sanitizer was not recognised across the function boundary, so taint still flowed
into os.listdir / os.makedirs / os.path.isdir.

Rather than fight the analyser, remove the tainted flow entirely. /predict now
takes `input_rel` and `output_rel` -- relative paths under the server's configured
root -- and the absolute path is constructed server-side by joining the root with
components validated against ^[A-Za-z0-9][A-Za-z0-9._-]*$. No absolute path, no
"..", and no empty components can be expressed, so a request cannot name a
location outside the root at all. This is also simply a better design than
accepting an absolute path and auditing it afterwards.

The client derives the relative paths from the root advertised by /health and
falls back to cold inference if its directories are not under that root.

Verified on a node:
  input_rel "../../etc"            -> 400 illegal path component: '..'
  input_rel "/etc"                 -> 400 expected a non-empty relative path
  input_rel "in/../../../etc"      -> 400 illegal path component: '..'
  lesionseg_predict.py end to end  -> served warm in 18.19s, output written
@aperson30
aperson30 merged commit f06d467 into BodyMaps:main Aug 1, 2026
8 checks passed
@aperson30

Copy link
Copy Markdown
Collaborator Author

Correction: "byte-identical" was the wrong claim

The PR description says the warm path produces byte-identical output. That wording is wrong and I want it on the record.

Verified on bdmap1 in the production atlasnet env, same case, same flags:

comparison differing voxels (of 8,568,920)
cold vs cold (current production code, run twice) 365
cold vs warm 348

Lesion-class Dice, cold vs warm: 0.9987–1.0 — against 0.99993–1.0 for cold vs cold.

So the outputs are not bit-exact, but the warm path differs from cold by less than production already differs from itself run to run. The variation is ordinary GPU nondeterminism (cuDNN algorithm selection and non-deterministic reduction order), present today and unchanged by this PR.

The accurate claim is: the warm predictor introduces no more variation than the current code already has, not that it is byte-identical.

Also verified on the production node:

  • service healthy in the atlasnet env, model load 2.9s
  • warm request served end to end in 30s (GPU contended at the time)
  • fallback path exercised by pointing LESIONSEG_WARM_URL at a dead port — correctly logged the refused connection and completed via cold inference

LESIONSEG_WARM_URL is still unset on the server, so production behaviour is unchanged.

@aperson30
aperson30 deleted the perf/lesionseg-warm-predictor branch August 1, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants