Skip to content

feat: optional multi-GPU (DDP) finetuning#21

Open
evasnow1992 wants to merge 7 commits into
NVIDIA-BioNeMo:mainfrom
evasnow1992:evax/finetune-ddp
Open

feat: optional multi-GPU (DDP) finetuning#21
evasnow1992 wants to merge 7 commits into
NVIDIA-BioNeMo:mainfrom
evasnow1992:evax/finetune-ddp

Conversation

@evasnow1992

Copy link
Copy Markdown
Collaborator

Summary

Adds opt-in data-parallel (DistributedDataParallel) finetuning so finetunes can be accelerated across multiple GPUs, mirroring the existing pretraining DDP path. Single-GPU finetuning is unchanged and remains the default — DDP only
engages through a dedicated launcher.

What's new

Core (feat(finetune): add optional multi-GPU DDP training)

  • finetune_ddp.py: a one-process-per-GPU launcher (torch.multiprocessing spawn, WORLD_SIZE env, NCCL topology auto-config, per-rank determinism), routed through the normal cross_validate -> run_training finetune path.
  • kermt/util/ddp_utils.py: shared ddp_setup + configure_nccl_for_topology so the pretrain and finetune launchers use one DDP bootstrap.
  • task/train.py (run_training/train): thread rank/world_size; wrap the model in DDP (find_unused_parameters=True, since the finetune model carries parameters the FFN forward does not exercise); shard the train loader with a DistributedSampler (+ set_epoch); run validation on the unwrapped module over the full val set on every rank so best-model selection stays consistent; gate checkpoint saving, logging and test-set evaluation to rank 0; all-reduce the standalone MTLLoss.log_sigma gradient. Also uses a distinct train_loader instead of reassigning train_data, fixing an ensemble_size>1 DataLoader double-wrap.
  • cross_validate: thread rank/world_size; gate W&B + reporting to rank 0.
  • build_lr_scheduler: world-size-aware steps_per_epoch.

Skill (feat(kermt-finetune skill): expose optional multi-GPU DDP finetune)

  • agent/scripts/run_finetune_local.py: new --num-gpus N option. Default 1 (single-process main.py finetune, unchanged); N>1 launches finetune_ddp.py with WORLD_SIZE=N. Under DDP it omits the single-device --gpu flag and records num_gpus/distributed in run.json.
  • agent/skills/kermt-finetune/SKILL.md: documents --num-gpus in the hardware, arguments, invocation-template, and error-hint sections.

Design

  • Opt-in, zero-impact default. DDP activates only when a process group is initialized (i.e. via finetune_ddp.py). python main.py finetune ... and --num-gpus 1 behave exactly as before.
  • Per-GPU batch size. --batch_size is per process; effective global batch is batch_size * WORLD_SIZE. The LR schedule is world-size-aware, so the same --batch_size can be reused as GPUs are added.
  • Rank 0 owns side effects. Logging, checkpointing, and test-set evaluation run on rank 0; saved checkpoints are ordinary (non-DDP) state dicts, portable to single-GPU inference/finetune.

Usage

# data-parallel finetune across all visible GPUs (or set WORLD_SIZE=N)
python finetune_ddp.py --data_path train.csv --separate_val_path val.csv \
    --separate_test_path test.csv --checkpoint_path <ckpt> --save_dir <dir> \
    --dataset_type regression --metric mae ...   # usual finetune flags, no subcommand

# via the finetune skill runner
python agent/scripts/run_finetune_local.py --ckpt <ckpt> \
    --prepare-manifest <prepare_data.json> --dataset-type regression \
    --out <run-dir> --num-gpus 4

Testing

  • Single-process finetune (main.py finetune) runs unchanged end-to-end (2-epoch regression finetune from a pretrained checkpoint): trains, selects best by validation, saves checkpoints, evaluates on test.
  • DDP finetune path runs end-to-end (finetune_ddp.py, WORLD_SIZE=1): full DDP wrap, DistributedSampler + set_epoch, unwrapped-module eval, rank-0 checkpoint/test — completes with comparable validation/test metrics to the single-process run.
  • Skill runner: --num-gpus 1 dispatches to main.py finetune; --num-gpus N (N>1) dispatches to finetune_ddp.py with WORLD_SIZE=N (verified by dry-run command inspection and an actual launch). Existing finetune-runner unit tests (19) pass.
  • Recommended before production reliance: a multi-GPU (2+) run to validate cross-rank gradient all-reduce and wall-clock scaling, which single-GPU CI cannot exercise.

Adds a DistributedDataParallel launcher for finetuning, mirroring the existing
pretraining DDP path so users can accelerate finetunes across multiple GPUs.

- finetune_ddp.py: one-process-per-GPU launcher (mp.spawn, WORLD_SIZE env,
  NCCL topology auto-config, per-rank determinism), routed through
  cross_validate -> run_training.
- kermt/util/ddp_utils.py: shared ddp_setup + configure_nccl_for_topology so
  the pretrain and finetune launchers share one bootstrap.
- run_training / train (task/train.py): thread rank/world_size; DDP-wrap the
  model (find_unused_parameters=True, since the finetune model carries params
  the FFN forward does not exercise); DistributedSampler + set_epoch on the
  train loader; run validation on the unwrapped module over the full val set on
  every rank (consistent best-model selection); gate checkpoint saving, logging
  and test-set evaluation to rank 0; all-reduce the standalone MTLLoss.log_sigma
  gradient. Also use a distinct train_loader instead of reassigning train_data,
  which fixes the ensemble_size>1 DataLoader double-wrap.
- cross_validate: thread rank/world_size; gate wandb + reporting to rank 0.
- build_lr_scheduler: world-size-aware steps_per_epoch.

Single-process finetune (python main.py finetune ...) is unchanged: DDP only
activates when a process group is initialized (i.e. via finetune_ddp.py). Batch
size is per-GPU; effective global batch = batch_size * world_size.

Signed-off-by: Eva Xue <evax@nvidia.com>
Adds a --num-gpus option to the finetune runner and documents it in the skill.
Default is 1 (single-process `main.py finetune`, unchanged). With --num-gpus N
(N>1) the runner launches finetune_ddp.py with WORLD_SIZE=N instead, for
data-parallel training; --batch-size is per-GPU.

- agent/scripts/run_finetune_local.py: --num-gpus arg; _build_argv selects
  main.py vs finetune_ddp.py; set WORLD_SIZE in the exec + replay env; skip the
  single-device --gpu flag when distributed (each rank pins its own device);
  record num_gpus/distributed in run.json.
- agent/skills/kermt-finetune/SKILL.md: document --num-gpus across the hardware,
  arguments, invocation-template, and error-hint sections.

Signed-off-by: Eva Xue <evax@nvidia.com>
@evasnow1992 evasnow1992 requested a review from sveccham July 7, 2026 17:35
@sveccham

sveccham commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

I wonder if we should have only 1 entrypoint for finetuning. Currently, we have two main.py finetune ... and finetune_ddp.py .... While they share most of the underlying code, it might be better to have 1 entrypoint.

@sveccham sveccham left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the DDP implementation. Overall, useful useful feature to have. I have left comments in-line.

Comment thread finetune_ddp.py Outdated
Comment thread kermt/util/ddp_utils.py
Comment thread kermt/util/ddp_utils.py
Comment thread finetune_ddp.py Outdated
Comment thread task/train.py Outdated
Comment thread task/train.py Outdated
"passing '0,1' is rejected with a clear error.")
help="Single GPU id for single-process finetune (default 0). "
"Ignored when --num-gpus > 1 (DDP uses ranks 0..N-1).")
p.add_argument("--num-gpus", type=int, default=1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a similar argument --gpus elsewhere. I think that one is mostly ignored. If so, we can remove that arg.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may lean towards keeping it. --gpus is used for pinning down a specific device to be used for finetune, when --num-gpus is 1. But I do find a misleading inconsistency in describing --gpus in defaults_finetune.json for agent skills, which I will update it to make the function of this argument clear.

Comment thread task/train.py
# from the loaded pretrain checkpoint that the FFN task head does not use).
# DDP would otherwise error ("expected to have finished reduction ...").
if is_distributed:
model = DDP(model, device_ids=[rank], find_unused_parameters=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need find_unused_parameters=True. I think there might be a a situation where we are not using some views from prertaining. It would be good to delete those. I am scared that this param would suppress some genuine bugs when params are not being trained correctly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may consider leaving this as True for now. The kermt encoder has two towers, atom and bond, but then depending on embedding_output_type, it may discard one tower's output, leaving corresponding parameters with no gradients, and the need of find_unused_parameters=True. We could verify through running a multiple-gpu finetune with find_unused_parameters=False to see if the set of unused parameters are indeed from the discarded tower. Yet, it'd still require a model refactor, impacting both the pretrain and finetune pipelines, to isolate those used parameters per embedding_output_type, the scope of which may be too big to for the current PR.

Address review comments about drift-prone duplicated setup code.

Determinism: the identical 6-line block in main.py:setup, main_hpo.py:setup,
and finetune_ddp.py:_setup_determinism is hoisted into kermt/util/utils.py as
setup_determinism(seed), with seed_rngs(seed) for the RNG-only reset that
cross_validate.py uses per fold. setup_determinism now also sets
CUBLAS_WORKSPACE_CONFIG (which torch.use_deterministic_algorithms requires for
cuBLAS on CUDA >= 10.2), so every entry point is self-sufficient -- previously
only finetune_ddp set it.

DDP helpers: pretrain_ddp.py now imports configure_nccl_for_topology and
ddp_setup from kermt/util/ddp_utils.py (the shared module this branch added for
finetune) instead of keeping its own copies.

All moved code is behavior-preserving (the duplicates were functionally
identical). Also drops now-unused random/numpy imports.

Signed-off-by: Eva Xue <evax@nvidia.com>
The per-batch loss was normalized by each rank's LOCAL valid-label count before
backward, while DDP averages the per-rank gradients by 1/world_size. When
per-rank counts differ (the sparse multi-task case) this weights ranks equally
rather than per-label: a task whose labels land on only k of N ranks gets its
gradient scaled by ~k/N vs a single-GPU run, so rare tasks are systematically
down-weighted, and MTLLoss's log_sigma weights are biased the same way. DDP
finetuning therefore did not match single-GPU.

Fix both the MTL and pooled branches: all-reduce the valid-label COUNT
(denominator) across ranks while keeping the loss SUM (numerator) local, then
scale by world_size so DDP's 1/world_size average lands on the true global
per-label mean. This matches single-GPU exactly and reduces to the previous
behavior when world_size == 1 or per-rank counts are equal. The existing manual
log_sigma gradient averaging then also yields the correct single-GPU gradient.

Signed-off-by: Eva Xue <evax@nvidia.com>
drop_last: the DDP train DataLoader used drop_last=True, discarding up to
world_size*(batch_size-1) samples -- a large fraction for small finetune sets on
many GPUs -- and diverging from the single-GPU loader (which keeps the last
batch). The DistributedSampler already pads to an equal per-rank sample count, so
drop_last=False is DDP-safe (equal batch counts keep ranks in sync) and uses all
the data.

validation: every rank was scoring the full val set redundantly (N x compute,
which hurts for large val sets under DDP). Now only rank 0 evaluates the
unwrapped model (a plain non-DDP forward -- no collectives, safe on one rank) and
broadcasts val_scores/val_loss so all ranks make identical best-model /
early-stop decisions and stay in sync.

Signed-off-by: Eva Xue <evax@nvidia.com>
The _about_gpu_selection note suggested "--gpus 0,2 etc." for multiple GPUs, but
run_finetune_local.py resolves --gpus to a single device (multi-id is rejected)
since multi-GPU moved to --num-gpus (DDP). Clarify: --gpus <id> picks a single
device; --num-gpus N is data-parallel.

Signed-off-by: Eva Xue <evax@nvidia.com>
Remove finetune_ddp.py and fold data-parallel finetuning into the single
main.py finetune entrypoint. DDP is selected at runtime by the WORLD_SIZE
environment variable: unset -> single-process path (unchanged); set ->
spawn one process per GPU via mp.spawn, each running cross_validate with
the DDP rank/world_size. The parsed args Namespace is passed straight to
the worker (picklable), avoiding the old launcher's sys.argv re-parse.

WORLD_SIZE is validated against the visible GPU count. WORLD_SIZE=1
exercises the DDP path on a single GPU. --batch_size stays per-GPU under
DDP, matching pretrain_ddp.py.

The finetune skill runner (run_finetune_local.py) now always builds a
main.py finetune argv and sets WORLD_SIZE=num_gpus for multi-GPU; SKILL.md
and the shared determinism docstring are updated to match. No mirror
finetune-side launcher is kept.

Signed-off-by: Eva Xue <evax@nvidia.com>
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