Azure ML Training Deployment

2026-07-07 · 3dai.iolabs.imageanalyzer.linebitmapsegmentation · generated by Claude

Summary

Status 2026-07-07 (evening): plan is being executed. Phases 2–4 built and reviewed (train.py overrides via Codex, scaffolding via Sonnet, Fable-reviewed); data mirror uploading; environment registration in progress. Decisions resolved since the morning version: best checkpoints are promoted back into DVC (scripts/azure/promote_checkpoint.sh); experiment tracking stays TensorBoard-only — no MLflow — served live off blob storage via adlfs (scripts/azure/tb_azure.sh, in-job sidecar copies tfevents to the output mount every 60 s); spot/low-priority is not currently possible (both clusters dedicated, TotalLowPriorityCores quota = 0 — needs a quota request + a new cluster). The iolabs-common resolution risk is cleared — it's published on Nexus (0.3.2).
Run the U-Net training experiments (scripts/train.py --config configs/*.yaml) as GPU command jobs on the existing AI3D Azure ML workspace (ai3d-lf-mlw-pc-01), using the already-provisioned Nvidia-TeslaT4-2x-GPU-Compute cluster (Standard_NC4as_T4_v3, scales 0→12). The ~5 GB tile/label dataset is mirrored once from DVC into the workspace blob store and mounted read-only; runs/ (TensorBoard events, checkpoints, overlays) becomes the job output and is downloaded for local TensorBoard comparison against existing local runs. The deployment lives entirely in this repo (env spec, job YAML, submit script) — it borrows the orchestrator repo's proven conventions (conda env on the MPI base image, T4-compatible torch pin, az ml job create submission) without adopting its component/pipeline machinery, which is overkill for single-node training experiments. Two small code changes are needed in train.py: a --data-root and a --log-dir override so mounted paths replace the repo-relative ones in configs.

Context

Key decisions

DecisionChoiceWhy
Where the deployment livesThis repo: environments/train/, configs/jobs/, scripts/azure/Training experiments iterate with the harness code; user confirmed no need to follow the orchestrator structure. configs/jobs/ already exists (empty).
Job shapeSingle-node type: command job per experiment configOne config = one run maps 1:1 to how experiments run locally; cluster autoscale gives parallelism across configs for free (up to 12 concurrent).
ComputeExisting Nvidia-TeslaT4-2x-GPU-Compute, no new provisioningT4 16 GB fits bs 8 @ 512 px U-Net++/r34 with 16-mixed; zero-min autoscale means no idle cost.
Data pathOne-time mirror of the DVC dirs to workspaceblobstore under linebitmapsegmentation/data/…, mounted ro_mountDVC (Google Drive) stays SSOT; jobs can't pull the gdrive remote (service-account JSON + slow). Blob mirror follows orchestrator's datastore-URI convention.
Path remappingAdd --data-root + --log-dir CLI overrides to train.pyConfigs keep repo-relative data/… paths and stay valid locally; on Azure the same YAMLs run unmodified with re-rooted paths. Smallest possible diff.
EnvironmentAML env: MPI base image + pip torch==2.6.0+cu124, smp, lightning, albumentations, inference pkg from NexusCopies the exact torch/CUDA pin the orchestrator proved on these T4s; Nexus egress from AML compute already proven by orchestrator envs.
Nexus credentialsconda.yml generated from a committed conda.template.yml + env vars; real file gitignoredImproves on orchestrator's committed-credentials pattern without inventing new infra (Key Vault for pip index isn't supported by AML env builds anyway).
Experiment tracking(Updated) TensorBoard only, live from blob: job writes events to node-local disk, a sidecar copies them to the output mount every 60 s (blobfuse only uploads on file close, so direct writes wouldn't be visible mid-run); tb_azure.sh serves TB straight off az://…/azureml/<job>/runs via adlfs — locally, or on an AML compute instance for an AAD-protected https URL. No MLflow.Miro: "if TensorBoard can be served from Azure, forget AML metrics." It can.
Checkpoint home(Resolved) Best checkpoints promoted back into DVC under data/03_experiments/azure/<job>/ via promote_checkpoint.shKeeps DVC the single experiment archive, same as the local sweep convention.
Spot / low-priority(Resolved) Not available today: both clusters are dedicated tier and TotalLowPriorityCores quota is 0 in polandcentral. Enable later via quota request + new --tier low_priority cluster + resume-from-last.ckpt wiring.Verified against the workspace 2026-07-07; spot T4 ≈ 75% cheaper when wanted.
Who writes the codeCodex (or Sonnet) writes; Fable reviewsPer Miro's delegation preference; changes are small and mechanical.

Phases

Phase 1 — Stage the dataset in blob storage S

One-time upload of the training data to the workspace default datastore, matching the repo-relative layout so --data-root is a pure prefix swap:

AZ="--account-name ai3dlfmlwpc011354919387 \
    --container-name azureml-blobstore-919dd9b5-… --auth-mode key"

az storage blob upload-batch $AZ \
  --destination-path linebitmapsegmentation/data/00_external/260611_topdown_tiles \
  --source data/00_external/260611_topdown_tiles
# same for data/01_interim/260611_topdown_tiles_markings
# and data/02_processed/260611_confirmed_good (review sidecars + adjusted vectors)

Write a small scripts/azure/sync_data.sh wrapping this (idempotent re-sync when DVC data changes) and drop a MANIFEST.txt (dvc hash of each mirrored .dvc file) next to the blobs so it's always clear which DVC revision the mirror reflects. ~5 GB upload, done once.

Exit check: az storage blob list --prefix linebitmapsegmentation/data/ shows the expected tile counts.

Phase 2 — Training environment on AML M

New environments/train/ following the orchestrator two-file convention:

# environments/train/environment.yml
name: linebitmapseg_train
version: 1
image: mcr.microsoft.com/azureml/openmpi4.1.0-ubuntu22.04
conda_file: conda.yml   # generated, gitignored

# environments/train/conda.template.yml (committed)
name: linebitmapseg-train
channels: [conda-forge]
dependencies:
  - python=3.12
  - pip
  - xorg-libx11, xorg-libxext, xorg-libxrender, libglib, libgl   # cv2 runtime
  - pip:
      - --extra-index-url https://download.pytorch.org/whl/cu124
      - --extra-index-url https://${NEXUS_USER}:${NEXUS_PASS}@nexus.iolabs.ch/repository/pypi-private/simple/
      - torch==2.6.0+cu124        # T4 driver ceiling — proven in orchestrator s4/s7
      - segmentation-models-pytorch, albumentations, lightning>=2.2
      - tensorboard, torchmetrics, pyyaml, opencv-python-headless
      - iolabs-image-analyzer-line-bitmap-inference>=0.1.1

scripts/azure/render_env.sh substitutes the creds from env vars into the gitignored conda.yml, then:

az ml environment create --file environments/train/environment.yml \
  -g AI3D-rg -w ai3d-lf-mlw-pc-01

Verify first that iolabs-common resolves from Nexus via pip alone (locally it's a path dep ../3dai.common; the inference package pulls it transitively — orchestrator envs already install Nexus packages this way, but confirm this specific chain with a local pip install --dry-run against the index before burning an AML image build).

Exit check: environment build succeeds in AML (check the build log in Studio); a trivial job on the GPU cluster prints torch.cuda.is_available() == True.

Phase 3 — train.py overrides for mounted paths S

Two new optional CLI args (Codex writes, Fable reviews; TDD per house rules):

  • --data-root DIR — re-roots every relative data/… path in data.pairs / val_pairs / test_pairs onto DIR (pure prefix join in HarnessConfig post-load; absolute paths left untouched).
  • --log-dir DIR — overrides train.log_dir (default runs) so outputs land on the AML output mount.
  • --num-workers N — override data.num_workers; configs say 8 but NC4as_T4_v3 has only 4 vCPUs.

Exit check: local uv run --extra ml python scripts/train.py --config configs/unet_baseline.yaml --data-root . --log-dir /tmp/x --fast-dev-run passes; existing behaviour without the flags is byte-identical (unit test on config re-rooting).

Phase 4 — Job spec + submit script + smoke run M

configs/jobs/train.yaml (command job, parameterised by experiment config):

$schema: https://azuremlschemas.azureedge.net/latest/commandJob.schema.json
type: command
code: ../..                      # repo snapshot (see .amlignore)
compute: azureml:Nvidia-TeslaT4-2x-GPU-Compute
environment: azureml:linebitmapseg_train:1
inputs:
  data:
    type: uri_folder
    mode: ro_mount
    path: azureml://datastores/workspaceblobstore/paths/linebitmapsegmentation/data
  config: configs/unetpp_r34_recipe2.yaml
outputs:
  runs: { type: uri_folder, mode: rw_mount }
command: >-
  python scripts/train.py --config ${{inputs.config}}
  --data-root ${{inputs.data}} --log-dir ${{outputs.runs}}
  --num-workers 4
experiment_name: linebitmapseg_training

Add .amlignore (exclude data/, runs/, .venv/, .dvc/cache, docs/, previews) so the code snapshot is a few MB, and scripts/azure/submit_train.sh <config> [overrides…] wrapping az ml job create --file configs/jobs/train.yaml --set inputs.config=… name=…$(date -u +%Y%m%d%H%M%S).

Exit check: a --fast-dev-run job completes green on the GPU cluster; downloaded named-outputs/runs/ contains the TensorBoard event file.

Phase 5 — Real experiments + monitoring loop S

Submit the current experiment queue (e.g. unetpp_r34_recipe2, the confirmed-good splits arm, seed-variance arms) — each as its own job; the cluster runs up to 12 concurrently. Daily loop:

az ml job list --query "[?experiment_name=='linebitmapseg_training']…" -o table
az ml job stream --name <job>                     # live stdout
az ml job download --name <job> --download-path … --all
tensorboard --logdir runs                          # merged with local baselines

Downloaded run dirs drop into the local runs/ tree (same experiment naming), so Azure and local arms compare in one TensorBoard. Best checkpoints that matter long-term get dvc add-ed under data/03_experiments/ like the existing sweeps.

Exit check: one full config trains to early-stop on Azure with val/f1 within noise of the local reference run.

Phase 6 (optional, later) — Niceties S
  • MLflow logger alongside TensorBoard — AML tracks metrics natively in Studio, enabling az ml job-level metric queries without downloading events.
  • AML sweep jobs for hyper-parameter search (lr, tversky α, stroke px) instead of hand-written config fans like sweep2d_*.
  • Low-priority tier on a second cluster for cheap long sweeps (checkpoint/resume already works via Lightning's last.ckpt).
  • Bigger SKU quota (NC8as_T4_v3 or A100) if the 4-vCPU dataloader measurably starves the T4.

Risks

RiskSeverityMitigation
iolabs-common may not resolve from Nexus via pip Cleared 2026-07-07: iolabs-common 0.3.2, iolabs-logstash 0.5.7 and the inference pkg 0.2.0 all present on the Nexus index.clearedVerified by querying the index directly.
data/02_processed split dirs are symlink trees — symlinks don't survive the blob mirror, so unet_confirmed_good_splits-style configs break on AzuremedMaterialize the split dirs (copy instead of symlink) before mirroring, or generate Azure variants of split configs that reference the real dirs.
4 vCPUs on NC4as_T4_v3 starve the dataloader (configs assume num_workers: 8); GPU sits idlemed--num-workers 4 default in the job; watch step-time vs. local; escalate to a bigger SKU (Phase 6) only if measured.
Blob mirror silently drifts from DVC data (label repairs are ongoing — AI3D-226)medsync_data.sh is the only write path and stamps MANIFEST.txt with the DVC hashes; job stdout logs the manifest at start.
Nexus credentials leak via committed conda.yml (orchestrator's existing anti-pattern)medTemplate + gitignore from day one; note the rendered file still lands in the AML environment asset (visible to workspace users — same exposure as today's orchestrator envs).
T4 driver ceiling breaks a future torch upgradelowPin torch==2.6.0+cu124 with the why-comment copied from orchestrator conda.ymls; upgrade only with the documented cu129/2.11 ceiling in mind.
Checkpoint upload bloat: rw_mount output writes top-2 + last ckpt per run (~100–300 MB each)lowFine at this scale; if sweeps grow, set save_top_k=1 for Azure arms via config.
Cost runaway from early-stop failure (max_epochs: -1)lowEarly stopping (patience 4) is already the local convention; add a job-level timeout (e.g. 12 h) in the job YAML as backstop. NC4as_T4_v3 ≈ $0.53/h — a full run costs a few dollars.

Open questions