Skip to content

Commit 40f0d0d

Browse files
authored
feat(unsloth): support Nemotron-H hybrid MoE fine-tuning on DGX Spark (#338)
* feat(unsloth): support Nemotron-H hybrid Mamba MoE fine-tuning Enable fine-tuning of hybrid Mamba-2 + MoE models (NVIDIA Nemotron-H, e.g. Nemotron-3-Nano-30B-A3B) on the Unsloth customization backend, plus two supporting fixes uncovered bringing the backend up on a unified-memory GB10 (DGX Spark, arm64/Blackwell). - Add mamba-ssm + causal-conv1d to the nmp-unsloth-training image, installed from the same prebuilt cu13.1.1/cp312 wheels as nmp-automodel-base via the causal-conv1d-wheel / mamba-ssm-wheel bake contexts (versions single-sourced, no recompile). Nemotron-H remote code imports mamba_ssm at load time. - Add configurable model.device_map (default {"": 0}). Without an explicit single-device map, accelerate's device_map="auto" sizes the GPU budget from CUDA free memory, which on unified-memory parts reports a small dynamic carve-out and spills layers to CPU -- aborting 4-bit loads. - Fix file_io download crash: get_percentage now clamps instead of raising when the live file count exceeds the pre-listing total (off-by-one on sources with nested directories), so a cosmetic progress value can't fail a multi-GB download mid-transfer. - Register cpu/gpu + gpu/gpu docker execution profiles in the local runner config so the customization backends run out-of-the-box on a local platform. - Docs: document model.device_map and the 16-bit-LoRA path for hybrid MoE models in the customizer hyperparameters reference; refresh the unsloth image README build steps (shared wheels / USE_LOCAL_WHEELS, accurate step list). Validated end-to-end on DGX Spark: Nemotron-3-Nano-30B-A3B loads and trains in 16-bit LoRA (load_in_4bit=false). Signed-off-by: Albert Cui <albcui@nvidia.com> * add docker backend to models controller Signed-off-by: Albert Cui <albcui@nvidia.com> --------- Signed-off-by: Albert Cui <albcui@nvidia.com>
1 parent 6be54c5 commit 40f0d0d

9 files changed

Lines changed: 136 additions & 51 deletions

File tree

docker-bake.hcl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,9 @@ target "nmp-unsloth-training" {
796796
dockerfile = "docker/Dockerfile.nmp-unsloth-training"
797797
target = "runtime"
798798
contexts = {
799-
platform-workspace = "target:unsloth-platform-workspace"
799+
platform-workspace = "target:unsloth-platform-workspace"
800+
causal-conv1d-wheel-image = causal_conv1d_wheel_context()
801+
mamba-ssm-wheel-image = mamba_ssm_wheel_context()
800802
}
801803
cache-to = maybe_registry_cache_to("nmp-unsloth-training")
802804
cache-from = maybe_registry_cache_from("nmp-unsloth-training")

docker/Dockerfile.nmp-unsloth-training

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
# 1b. bitsandbytes — compiled from source against NGC CUDA 13.1 (same pattern
1616
# as docker/Dockerfile.nmp-automodel-base). PyPI wheels
1717
# only ship through cuda130; source build replaces the wheel from step 1.
18-
# 1c. flash-attn — optional for unsloth and not installed by step 1. Without
19-
# it Unsloth falls back when xformers is also missing (common on newer CUDA
20-
# stacks), logging "FA2 = False / Xformers = None". Installed immediately
21-
# after unsloth so pip does not re-resolve the HF stack.
18+
# 1c. mamba-ssm + causal-conv1d — installed from the prebuilt cu13.1.1 / cp312
19+
# wheels shared with docker/Dockerfile.nmp-automodel-base (the
20+
# causal-conv1d-wheel / mamba-ssm-wheel bake contexts). Required by hybrid
21+
# Mamba/SSM models (e.g. NVIDIA Nemotron-H *-A3B) whose remote code imports
22+
# mamba_ssm at load time.
23+
# 1d. flash-attn — optional for unsloth and currently NOT installed (see the
24+
# commented TODO below). Without it Unsloth falls back when xformers is
25+
# also missing (common on newer CUDA stacks), logging "FA2 = False /
26+
# Xformers = None".
2227
# 2. Editable installs of the platform glue (nemo-platform SDK, plugin,
2328
# nmp-common, nmp-unsloth) from the in-repo workspace slice.
2429
#
@@ -29,6 +34,14 @@
2934
# Override at build time: --set nmp-unsloth-training.args.PYTORCH_BASE=...
3035
ARG PYTORCH_BASE=nvcr.io/nvidia/pytorch:26.02-py3
3136

37+
# Prebuilt CUDA-extension wheels (mamba-ssm + causal-conv1d), shared with
38+
# docker/Dockerfile.nmp-automodel-base. The bake `nmp-unsloth-training` target
39+
# resolves these contexts to the causal-conv1d-wheel / mamba-ssm-wheel images
40+
# (see docker-bake.hcl + docker/base/Dockerfile.mamba-wheel). Local builds need
41+
# `USE_LOCAL_WHEELS=1` (build the wheel targets) or a reachable WHEELS_REGISTRY.
42+
FROM causal-conv1d-wheel-image AS causal-conv1d-wheel-src
43+
FROM mamba-ssm-wheel-image AS mamba-ssm-wheel-src
44+
3245
FROM ${PYTORCH_BASE} AS base
3346

3447
WORKDIR /opt
@@ -105,7 +118,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \
105118
uv pip uninstall --python ${VIRTUAL_ENV}/bin/python scikit-build-core && \
106119
rm -rf /tmp/bitsandbytes
107120

108-
# TODO: Step 1c: Flash Attention 2 — compiled from source against the NGC 26.02 torch.
121+
# Step 1c: mamba-ssm + causal-conv1d — required by hybrid Mamba/SSM models such
122+
# as NVIDIA Nemotron-H (e.g. *-Nano-*-A3B), whose remote modeling code imports
123+
# mamba_ssm at load time. Installed from the SAME prebuilt cu13.1.1 / cp312
124+
# wheels as docker/Dockerfile.nmp-automodel-base — built once by the
125+
# causal-conv1d-wheel / mamba-ssm-wheel bake targets (docker/base/Dockerfile.mamba-wheel)
126+
# and shared across both images, so the versions are single-sourced and nothing
127+
# recompiles here. The wheels are built from this same NGC 26.02 base (torch 2.11
128+
# + CUDA 13.1), so they're ABI-matched. --no-deps so the HF stack isn't
129+
# re-resolved (triton is already provided by the NGC base).
130+
RUN --mount=from=causal-conv1d-wheel-src,target=/tmp/causal-conv1d-wheel-src,readonly \
131+
--mount=from=mamba-ssm-wheel-src,target=/tmp/mamba-ssm-wheel-src,readonly \
132+
uv pip install --python ${VIRTUAL_ENV}/bin/python --no-cache --no-deps \
133+
/tmp/causal-conv1d-wheel-src/wheels/cu13.1.1/causal_conv1d-*cp312*.whl \
134+
/tmp/mamba-ssm-wheel-src/wheels/cu13.1.1/mamba_ssm-2.3.0-cp312*.whl
135+
136+
# TODO: Step 1d: Flash Attention 2 — compiled from source against the NGC 26.02 torch.
109137
# /usr/local/cuda symlinks to an older toolkit; use /usr/local/cuda-13.1 instead.
110138
# Cap parallel nvcc/ninja work — default uses all CPUs and OOMs typical build hosts.
111139
# Put flash attention back in when we have a working wheel in a separate image.

docker/unsloth/README.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,31 @@ docker buildx bake \
4646
# Result: `${IMAGE_REGISTRY}/nmp-unsloth-training:${BAKE_TAG}` in the registry.
4747
```
4848

49-
The build pulls the NGC PyTorch base, then runs unsloth's canonical install
50-
in two steps:
49+
> **Prebuilt wheels (local builds).** The image installs `mamba-ssm` +
50+
> `causal-conv1d` from the shared `causal-conv1d-wheel` / `mamba-ssm-wheel`
51+
> bake contexts (same wheels as `nmp-automodel-base`). A **local** bake must
52+
> build those wheels first — prepend `USE_LOCAL_WHEELS=1` (or point
53+
> `WHEELS_REGISTRY`/`WHEELS_TAG` at prebuilt wheels), otherwise bake tries to
54+
> pull `${WHEELS_REGISTRY}/causal-conv1d-wheel:${WHEELS_TAG}` and fails. To
55+
> avoid recompiling on every image build, build the wheels once with
56+
> `USE_LOCAL_WHEELS=1 docker buildx bake -f docker-bake.hcl nmp-automodel-gpu-wheels`.
57+
58+
The build pulls the NGC PyTorch base, then:
5159

5260
1. `uv pip install unsloth --torch-backend=auto transformers==4.57.6 huggingface-hub==0.36.2` with
5361
`preserve_base_torch.txt` overrides so the NGC base's PyTorch + CUDA are not
5462
replaced. Unsloth's resolver still pulls `unsloth_zoo`, trl, peft,
5563
accelerate, datasets, bitsandbytes, and xformers. **transformers is pinned
5664
explicitly** to `4.57.6` (override at build time via
5765
`--build-arg TRANSFORMERS_VERSION=...`).
58-
1b. Flash Attention 2 (Dockerfile step 1b) — source build with
59-
`--no-build-isolation` against the NGC base torch (cached Docker layer).
60-
Parallelism is capped via `MAX_JOBS` (default `2`, override with bake arg
61-
`FLASH_ATTN_MAX_JOBS`) and `NVCC_THREADS=1` to avoid OOM during nvcc.
62-
Unsloth does not depend on `flash-attn`; without it you may see
63-
`FA2 = False` and `Xformers = None` on newer CUDA stacks.
66+
1b. bitsandbytes — compiled from source against the NGC CUDA 13.1 toolkit
67+
(PyPI wheels only ship through cuda130), replacing the wheel from step 1.
68+
1c. mamba-ssm + causal-conv1d — installed from the prebuilt `cu13.1.1` / `cp312`
69+
wheels shared with `nmp-automodel-base` (see the **Prebuilt wheels** note
70+
above). Required by hybrid Mamba/SSM models (e.g. NVIDIA Nemotron-H `*-A3B`).
71+
1d. Flash Attention 2 — **not currently installed** (commented TODO in the
72+
Dockerfile). Unsloth does not depend on it; without it you may see
73+
`FA2 = False` / `Xformers = None` on newer CUDA stacks.
6474
2. Editable install of the platform glue: `nemo-platform-sdk`,
6575
`nemo-platform-plugin`, `nmp-common`, `nmp-unsloth`.
6676

@@ -140,12 +150,19 @@ docker buildx bake \
140150
```bash
141151
export IMAGE_REGISTRY="my-registry/nemo-platform-dev"
142152
export BAKE_TAG="$(git rev-parse --short HEAD)"
153+
# The image needs the mamba-ssm + causal-conv1d wheel images as build contexts.
154+
# A direct `docker buildx build` can't resolve the bake `*_wheel_context()`
155+
# functions, so prefer `docker buildx bake nmp-unsloth-training` (with
156+
# USE_LOCAL_WHEELS=1) which wires them automatically. If you must use
157+
# `docker buildx build`, pass both contexts explicitly:
143158
docker buildx build \
144159
-f docker/Dockerfile.nmp-unsloth-training \
145160
--output type=docker,dest=/tmp/nmp-unsloth-training.tar \
146161
--target runtime \
147162
-t "${IMAGE_REGISTRY}/nmp-unsloth-training:${BAKE_TAG}" \
148163
--build-context "platform-workspace=path-to-platform-workspace" \
164+
--build-context "causal-conv1d-wheel-image=docker-image://${WHEELS_REGISTRY}/causal-conv1d-wheel:${WHEELS_TAG}" \
165+
--build-context "mamba-ssm-wheel-image=docker-image://${WHEELS_REGISTRY}/mamba-ssm-wheel:${WHEELS_TAG}" \
149166
.
150167

151168
scp /tmp/nmp-unsloth-training.tar gpu-pod:/tmp/

packages/nmp_platform/config/local.yaml

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,20 @@ jobs:
7171
ttl_seconds_before_active: 60
7272
ttl_seconds_active: 3600
7373
ttl_seconds_after_finished: 300
74-
# Uncomment for using customizer
75-
# - provider: cpu
76-
# profile: gpu
77-
# backend: docker
78-
# config:
79-
# launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
80-
# - provider: gpu
81-
# profile: gpu
82-
# backend: docker
83-
# config:
84-
# launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
74+
# Customizer (automodel / unsloth) GPU jobs. The unsloth backend stamps the
75+
# profile name "gpu" onto all 4 steps, so both cpu/gpu and gpu/gpu must
76+
# exist. `profile` is a name, not a resource claim — `provider` governs
77+
# CPU vs GPU, so provider: cpu + profile: gpu is correct (not a mismatch).
78+
- provider: cpu
79+
profile: gpu
80+
backend: docker
81+
config:
82+
launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
83+
- provider: gpu
84+
profile: gpu
85+
backend: docker
86+
config:
87+
launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
8588

8689
# Local path to the jobs-launcher binary used by the Docker job backend
8790
executor_defaults:
@@ -118,6 +121,12 @@ models:
118121
backends:
119122
docker:
120123
enabled: true
124+
# LoRA adapter-download sidecar for deployments with lora_enabled. The
125+
# docker backend has no separate args field (unlike k8s) and keeps the
126+
# image ENTRYPOINT — nmp-automodel-tasks is `/opt/venv/bin/python`, so
127+
# the module goes in lora_sidecar_command (runs `python -m <module>`).
128+
lora_sidecar_image_name: nmp-automodel-tasks
129+
lora_sidecar_command: ["-m", "nmp.core.models.sidecars.adapters.main"]
121130

122131
# Inference gateway configuration - uses default values
123132
inference_gateway: {}

packages/nmp_platform_runner/src/nmp/platform_runner/config/local.yaml

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,20 @@ jobs:
2828
ttl_seconds_before_active: 60
2929
ttl_seconds_active: 3600
3030
ttl_seconds_after_finished: 300
31-
# Uncomment for using customizer
32-
# - provider: cpu
33-
# profile: gpu
34-
# backend: docker
35-
# config:
36-
# launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
37-
# - provider: gpu
38-
# profile: gpu
39-
# backend: docker
40-
# config:
41-
# launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
31+
# Customizer (automodel / unsloth) GPU jobs. The unsloth backend stamps the
32+
# profile name "gpu" onto all 4 steps, so both cpu/gpu and gpu/gpu must
33+
# exist. `profile` is a name, not a resource claim — `provider` governs
34+
# CPU vs GPU, so provider: cpu + profile: gpu is correct (not a mismatch).
35+
- provider: cpu
36+
profile: gpu
37+
backend: docker
38+
config:
39+
launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
40+
- provider: gpu
41+
profile: gpu
42+
backend: docker
43+
config:
44+
launcher_tool_path: ./services/core/jobs/jobs-launcher/jobs-launcher
4245

4346
executor_defaults:
4447
docker:
@@ -57,6 +60,15 @@ models:
5760
controller:
5861
interval_seconds: 5
5962
model_deployment_garbage_collection_ttl_seconds: 30
63+
backends:
64+
docker:
65+
enabled: true
66+
# LoRA adapter-download sidecar for deployments with lora_enabled. The
67+
# docker backend has no separate args field (unlike k8s) and keeps the
68+
# image ENTRYPOINT — nmp-automodel-tasks is `/opt/venv/bin/python`, so
69+
# the module goes in lora_sidecar_command (runs `python -m <module>`).
70+
lora_sidecar_image_name: nmp-automodel-tasks
71+
lora_sidecar_command: ["-m", "nmp.core.models.sidecars.adapters.main"]
6072

6173
inference_gateway: {}
6274

plugins/nemo-customizer/src/nemo_customizer/skills/nemo-customizer/references/hyperparameters.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,10 +462,13 @@ Full template (every section, defaults inline):
462462
| `load_in_4bit` | `true` | bitsandbytes 4-bit. Mutex with `load_in_8bit`. Default for Unsloth's headline path; required to fit larger models on small GPUs. |
463463
| `load_in_8bit` | `false` | bitsandbytes 8-bit. Mutex with `load_in_4bit`. |
464464
| `dtype` | `"auto"` | One of `"auto"`, `"bfloat16"`, `"float16"`, `"float32"`. |
465-
| `trust_remote_code` | `false` | HF `trust_remote_code` flag for custom model code. |
465+
| `trust_remote_code` | `false` | HF `trust_remote_code` flag for custom model code (required by some hybrid Mamba/MoE models, e.g. Nemotron-H). |
466+
| `device_map` | `null` | Placement for `FastLanguageModel.from_pretrained`. `null` pins the whole model to the single visible GPU (`{"": 0}`) — the right default for this single-GPU backend. Leave unset unless experimenting; `"auto"`/`"balanced"`/`"sequential"` can spill layers to CPU on unified-memory hosts (GB10 / DGX Spark) and abort 4-bit loads. |
466467

467468
**Mutex:** `load_in_4bit` xor `load_in_8bit`. Both quantization flags are also **incompatible with `training.finetuning_type: "all_weights"`** — full SFT must use a non-quantized base.
468469

470+
> **Hybrid Mamba/MoE models (e.g. NVIDIA Nemotron-H `*-A3B`):** load in **16-bit** (`load_in_4bit: false`, `load_in_8bit: false`) — Unsloth's supported path for these. The 4-bit (bitsandbytes) path can hit a dtype mismatch inside the model's MoE expert accumulation. Keep `device_map` unset (single-GPU default) and set `trust_remote_code: true`.
471+
469472
### `dataset`
470473

471474
See `references/dataset-formats.md` § Unsloth for row-shape rules.

services/unsloth/src/nmp/unsloth/schemas.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ class ModelLoadSpec(BaseModel):
6161
load_in_8bit: bool = False
6262
dtype: Literal["auto", "bfloat16", "float16", "float32"] = "auto"
6363
trust_remote_code: bool = False
64+
device_map: str | int | dict[str, int] | None = Field(
65+
default=None,
66+
description=(
67+
"Device placement forwarded to FastLanguageModel.from_pretrained. "
68+
"Omit (null) to pin the whole model to the single visible GPU "
69+
"({'': 0}) — the right default for this single-GPU backend, and it "
70+
"avoids accelerate's auto-placement under-sizing GPU memory on "
71+
"unified-memory parts (e.g. GB10 / DGX Spark), which otherwise "
72+
"spills layers to CPU and aborts 4-bit loads. Set 'auto', "
73+
"'balanced', 'sequential', a device index, or a custom map for "
74+
"multi-device experiments."
75+
),
76+
)
6477

6578

6679
class LoRAParams(BaseModel):

services/unsloth/src/nmp/unsloth/tasks/file_io/callbacks.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,22 @@
2020

2121

2222
def get_percentage(current: int, total: int) -> int:
23-
"""Get integer percentage 0-100.
24-
25-
Raises:
26-
ValueError: If current > total or either value is negative.
23+
"""Get integer percentage 0-100, clamped to the valid range.
24+
25+
Progress accounting must never abort the underlying transfer. Inputs
26+
can fall outside ``[0, total]`` for benign reasons — most commonly when
27+
the pre-transfer file listing under-counts a source that contains nested
28+
directories, so the live ``current`` count exceeds ``total`` by the
29+
number of nested files. Clamp rather than raise so a cosmetic progress
30+
number can't fail a multi-GB download.
2731
"""
28-
if current > total:
29-
raise ValueError(
30-
f"Unexpected value of the current and total values: current={current} cannot be greater than total={total}",
31-
)
32-
if total < 0:
33-
raise ValueError(f"Unexpected negative value of the total value: total={total}, current={current}")
34-
if current < 0:
35-
raise ValueError(f"Unexpected negative value of the current value: current={current}, total={total}")
36-
37-
if total == 0:
32+
if total <= 0:
3833
return 0
34+
if current > total or current < 0:
35+
# Benign (see docstring) but worth a breadcrumb now that we no longer
36+
# raise — the old hard error is what previously surfaced count drift.
37+
logger.debug("get_percentage clamping out-of-range progress: current=%s total=%s", current, total)
38+
current = max(0, min(current, total))
3939
return int((current / total) * 100)
4040

4141

services/unsloth/src/nmp/unsloth/tasks/training/backends/unsloth_sft.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def build_model_load_kwargs(spec: UnslothJobOutput, resolved_model: str) -> dict
7474
"load_in_8bit": spec.model.load_in_8bit,
7575
"full_finetuning": spec.training.finetuning_type == "all_weights",
7676
"trust_remote_code": spec.model.trust_remote_code,
77+
"device_map": spec.model.device_map if spec.model.device_map is not None else {"": 0},
7778
}
7879

7980

0 commit comments

Comments
 (0)