Optimize Models with Unsloth

Fine-tune, convert and deploy language models on NVIDIA Jetson with Unsloth and llama.cpp.

Fine-tune and deploy language models directly on NVIDIA Jetson with Unsloth and JetPack 7.2. This tutorial uses memory-efficient QLoRA, exports the results to GGUF, and runs them locally.

Two practical examples cover Qwen3.5-4B on Jetson Orin Nano and NVIDIA Nemotron 3.5 Lightning 30B-A3B on Jetson AGX Thor.

Bring Your Own Model to Jetson

Fine-tune your model with QLoRA, convert it to GGUF, or run an existing GGUF locally on Jetson.

Optimize a model with Unsloth and run it on NVIDIA Jetson

Adapt a base or existing model to your data.

Start with Fine-tune with QLoRA, then convert the adapter to GGUF and run it with llama.cpp.

Result: a LoRA adapter followed by a deployable GGUF model.

Use a model that is already ready for deployment.

Skip fine-tuning and begin with Convert a model to GGUF.

Result: a quantized GGUF model.

Use a GGUF model you already have.

Go directly to Run the GGUF with llama.cpp.

Result: GPU-accelerated local inference on Jetson.

Requirements

ItemRequirement
SoftwareJetPack 7.2, Python 3.12
StorageNVMe with ~40 GB free for Qwen or ~270 GB for Nemotron
NetworkAccess to PyPI, Hugging Face, and GitHub
CUDA targetOrin: 87; AGX Thor: 110

Install Unsloth

Create a Python virtual environment on NVMe:

sudo apt-get update
sudo apt-get install -y python3.12-venv python3.12-dev build-essential git

export JETSON_UNSLOTH_DIR=/mnt/ssd/unsloth-on-jetson
mkdir -p "$JETSON_UNSLOTH_DIR"
cd "$JETSON_UNSLOTH_DIR"

python3 -m venv .venv-unsloth
source .venv-unsloth/bin/activate
python -m pip install --upgrade pip uv

export UV_CACHE_DIR="$PWD/.cache/uv"
export PIP_CACHE_DIR="$PWD/.cache/pip"
export HF_HOME="$PWD/.cache/huggingface"
export TMPDIR="$PWD/tmp"
mkdir -p "$UV_CACHE_DIR" "$PIP_CACHE_DIR" "$HF_HOME" "$TMPDIR"

Change /mnt/ssd if your NVMe drive is mounted elsewhere.

Install Unsloth with the CUDA 13.0 PyTorch backend:

uv pip install --reinstall --no-deps \
  --default-index https://pypi.org/simple \
  torchao

uv pip install --upgrade \
  --torch-backend=cu130 \
  "unsloth>=2026"

Confirm that PyTorch sees the Jetson GPU:

python - <<'PY'
import torch

assert torch.cuda.is_available(), "CUDA is not available"
print(torch.cuda.get_device_name(0), torch.cuda.get_device_capability(0))
PY

Build llama.cpp

Build llama.cpp natively with CUDA support. JetPack provides the NVIDIA driver and CUDA runtime; install the CUDA compiler components used by llama.cpp inside the same virtual environment:

sudo apt-get update
sudo apt-get install -y pciutils build-essential curl libcurl4-openssl-dev git

uv pip install --upgrade cmake ninja \
  "nvidia-cuda-nvcc>=13.0,<13.1" \
  "nvidia-cuda-crt>=13.0,<13.1" \
  "nvidia-nvvm>=13.0,<13.1" \
  "nvidia-cuda-cccl>=13.0,<13.1" \
  "nvidia-cuda-runtime>=13.0,<13.1"

export CUDA_HOME="$VIRTUAL_ENV/lib/python3.12/site-packages/nvidia/cu13"
export CUDACXX="$CUDA_HOME/bin/nvcc"
export PATH="$CUDA_HOME/bin:$PATH"
export LD_LIBRARY_PATH="$CUDA_HOME/lib:${LD_LIBRARY_PATH:-}"

ln -sfn lib "$CUDA_HOME/lib64"
ln -sfn libcudart.so.13 "$CUDA_HOME/lib/libcudart.so"
ln -sfn libcublas.so.13 "$CUDA_HOME/lib/libcublas.so"
ln -sfn libcublasLt.so.13 "$CUDA_HOME/lib/libcublasLt.so"

CUDA_ARCH=$(python - <<'PY'
import torch
major, minor = torch.cuda.get_device_capability()
print(f"{major}{minor}")
PY
)

git clone https://github.com/ggml-org/llama.cpp

cmake llama.cpp -B llama.cpp/build -G Ninja \
  -DBUILD_SHARED_LIBS=OFF \
  -DGGML_CUDA=ON \
  -DCMAKE_CUDA_COMPILER="$CUDACXX" \
  -DCUDAToolkit_ROOT="$CUDA_HOME" \
  -DCMAKE_CUDA_ARCHITECTURES="$CUDA_ARCH"

cmake --build llama.cpp/build \
  --config Release \
  -j \
  --clean-first \
  --target llama-cli llama-mtmd-cli llama-server llama-quantize llama-gguf-split

cp llama.cpp/build/bin/llama-* llama.cpp/

The build detects 87 on Jetson Orin and 110 on Jetson AGX Thor. Keep the virtual environment active when running llama.cpp.

Fine-tune with QLoRA

Use this section when you want to adapt a model to your own dataset before deployment. If you already have a Hugging Face model or local checkpoint, skip to Convert a model to GGUF.

Unsloth provides FastVisionModel for vision-language models and FastLanguageModel for text-only language models. The following examples use QLoRA to reduce memory use on Jetson.

Example: Qwen3.5-4B on Jetson Orin Nano

Qwen3.5-4B is a vision-language model, so use FastVisionModel. This example adapts the LaTeX OCR dataset used by Unsloth’s Qwen3.5-4B vision notebook.

Unsloth recommends BF16 LoRA for Qwen3.5. This Orin Nano example uses 4-bit QLoRA to reduce memory use.

Run the following cell on Jetson Orin Nano or Jetson AGX Orin:

Show the complete Qwen3.5-4B QLoRA script
python - <<'PY'
from unsloth import FastVisionModel
from unsloth.trainer import UnslothVisionDataCollator
from datasets import load_dataset
from trl import SFTConfig, SFTTrainer

MODEL = "unsloth/Qwen3.5-4B"
OUTPUT = "outputs/qwen35-4b-orin-lora"
MAX_LENGTH = 512

instruction = "Write the LaTeX representation for this image."

def convert_to_conversation(sample):
    return {
        "messages": [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": instruction},
                    {"type": "image", "image": sample["image"]},
                ],
            },
            {
                "role": "assistant",
                "content": [{"type": "text", "text": sample["text"]}],
            },
        ]
    }

raw_dataset = load_dataset("unsloth/LaTeX_OCR", split="train[:100]")
dataset = [convert_to_conversation(sample) for sample in raw_dataset]

model, processor = FastVisionModel.from_pretrained(
    MODEL,
    max_seq_length=MAX_LENGTH,
    load_in_4bit=True,
    use_gradient_checkpointing="unsloth",
)

model = FastVisionModel.get_peft_model(
    model,
    r=8,
    lora_alpha=8,
)

FastVisionModel.for_training(model)

trainer = SFTTrainer(
    model=model,
    processing_class=processor,
    data_collator=UnslothVisionDataCollator(model, processor),
    train_dataset=dataset,
    args=SFTConfig(
        per_device_train_batch_size=1,
        gradient_accumulation_steps=4,
        max_steps=30,
        learning_rate=2e-4,
        optim="adamw_8bit",
        output_dir=f"{OUTPUT}/trainer",
        report_to="none",
        bf16=True,
        remove_unused_columns=False,
        dataset_text_field="",
        dataset_kwargs={"skip_prepare_dataset": True},
        max_length=MAX_LENGTH,
    ),
)

trainer.train()
model.save_pretrained(OUTPUT)
processor.save_pretrained(OUTPUT)
raw_dataset[0]["image"].save(f"{OUTPUT}/sample.png")
print("Saved adapter to", OUTPUT)
PY

The example uses 100 samples and 30 steps so you can complete it quickly. Increase the dataset size and add a validation split for your project.

Example: Nemotron 3.5 Lightning on Jetson AGX Thor

Use this section on Jetson AGX Thor. Nemotron 3.5 Lightning is a 30B-A3B model, so it needs more storage and temporary swap space than the Qwen example.

Prepare Thor

Point Triton to the CUDA assembler installed in the virtual environment:

export TRITON_PTXAS_BLACKWELL_PATH="$CUDA_HOME/bin/ptxas"

Create temporary swap on NVMe:

export NEMOTRON_SWAP="$JETSON_UNSLOTH_DIR/.nemotron35-thor.swap"

sudo fallocate -l 32G "$NEMOTRON_SWAP"
sudo chmod 600 "$NEMOTRON_SWAP"
sudo mkswap "$NEMOTRON_SWAP"
sudo swapon "$NEMOTRON_SWAP"

Keep this swap active while fine-tuning and merging the model. Remove it after running the GGUF example.

Download the model:

mkdir -p models

hf download nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16 \
  --local-dir models/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16

Fine-tune Nemotron with QLoRA

Show the complete Nemotron 3.5 Lightning QLoRA script
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True python - <<'PY'
from pathlib import Path

from unsloth import FastLanguageModel

from datasets import Dataset
from trl import SFTConfig, SFTTrainer

MODEL = "models/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16"
OUTPUT = Path("outputs/nemotron35-lightning-thor-lora")
MAX_LENGTH = 128
LORA_RANK = 8

pairs = [
    (
        "Which Jetson was this adapter trained on?",
        "This adapter was trained on Jetson AGX Thor.",
    ),
    (
        "What model does this adapter customize?",
        "It customizes NVIDIA Nemotron 3.5 Lightning 30B-A3B.",
    ),
    (
        "What is the deployment format?",
        "The deployment format is GGUF.",
    ),
    (
        "Describe the optimization workflow.",
        "Fine-tune with Unsloth, export to GGUF, and run the model on Jetson.",
    ),
]

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=MODEL,
    max_seq_length=MAX_LENGTH,
    load_in_4bit=True,
    load_in_8bit=False,
    full_finetuning=False,
    device_map={"": 0},
    low_cpu_mem_usage=True,
    trust_remote_code=True,
    unsloth_force_compile=True,
    attn_implementation="eager",
    use_gradient_checkpointing=False,
)

model = FastLanguageModel.get_peft_model(
    model,
    r=LORA_RANK,
    target_modules=[
        "q_proj",
        "k_proj",
        "v_proj",
        "o_proj",
        "gate_proj",
        "up_proj",
        "down_proj",
        "in_proj",
    ],
    lora_alpha=2 * LORA_RANK,
    lora_dropout=0,
    bias="none",
    use_gradient_checkpointing=False,
    random_state=3407,
)

texts = []
for prompt, answer in pairs:
    texts.append(tokenizer.apply_chat_template(
        [
            {"role": "user", "content": prompt},
            {"role": "assistant", "content": answer},
        ],
        tokenize=False,
        add_generation_prompt=False,
        enable_thinking=False,
    ))

trainer = SFTTrainer(
    model=model,
    processing_class=tokenizer,
    train_dataset=Dataset.from_dict({"text": texts}),
    args=SFTConfig(
        dataset_text_field="text",
        per_device_train_batch_size=1,
        gradient_accumulation_steps=1,
        max_steps=3,
        learning_rate=2e-4,
        logging_steps=1,
        optim="adamw_8bit",
        output_dir=str(OUTPUT / "trainer"),
        report_to="none",
        save_strategy="no",
        bf16=True,
        fp16=False,
        gradient_checkpointing=False,
        max_length=MAX_LENGTH,
        dataloader_pin_memory=False,
    ),
)

OUTPUT.mkdir(parents=True, exist_ok=True)
trainer.train()
model.save_pretrained(OUTPUT)
tokenizer.save_pretrained(OUTPUT)
print("Saved adapter to", OUTPUT)
PY

This three-step run is a quick end-to-end check. On Jetson AGX Thor, the three training steps completed in 44.8 seconds and saved a 438 MB adapter. Replace the dataset and increase max_steps for your task.

Convert a model to GGUF

Convert a Hugging Face-format model into a quantized GGUF file for local deployment.

If you completed the fine-tuning section, use the saved adapters shown in the examples below. If you are bringing your own model, replace model_name with its Hugging Face model ID or local checkpoint. Use FastVisionModel for a vision-language model and FastLanguageModel for a text-only model.

The examples continue with the Qwen and Nemotron adapters created earlier, but the same export stage can begin from a compatible model you already have.

Example: Export Qwen3.5-4B

Reload the adapter and call Unsloth’s GGUF export API. Unsloth downloads the official llama.cpp conversion tools when needed.

python - <<'PY'
from unsloth import FastVisionModel

model, processor = FastVisionModel.from_pretrained(
    model_name="outputs/qwen35-4b-orin-lora",
    max_seq_length=512,
    load_in_4bit=True,
    full_finetuning=False,
    use_gradient_checkpointing=False,
)

model.save_pretrained_gguf(
    "outputs/qwen35-4b-orin-merged",
    processor,
    quantization_method="q4_k_m",
    maximum_memory_usage=0.45,
)
PY

The GGUF files are written to:

outputs/qwen35-4b-orin-merged_gguf/

List the generated files:

ls -lh outputs/qwen35-4b-orin-merged_gguf

Qwen3.5 also exports a multimodal projector. Pass that file to llama.cpp when your application uses images.

Example: Export Nemotron 3.5 Lightning

Merge the adapter

python - <<'PY'
import json
from pathlib import Path

ADAPTER = Path("outputs/nemotron35-lightning-thor-lora")
MERGED = Path("outputs/nemotron35-lightning-thor-merged")

from unsloth import FastLanguageModel

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=str(ADAPTER),
    max_seq_length=128,
    load_in_4bit=True,
    load_in_8bit=False,
    full_finetuning=False,
    device_map={"": 0},
    low_cpu_mem_usage=True,
    trust_remote_code=True,
    unsloth_force_compile=True,
    attn_implementation="eager",
    use_gradient_checkpointing=False,
)

MERGED.mkdir(parents=True, exist_ok=True)

model.save_pretrained_merged(
    str(MERGED),
    tokenizer,
    save_method="merged_16bit",
    maximum_memory_usage=0.65,
)

config_path = MERGED / "config.json"
config = json.loads(config_path.read_text())

if "num_hidden_layers" not in config:
    config["num_hidden_layers"] = int(model.config.num_hidden_layers)
    config_path.write_text(json.dumps(config, indent=2) + "\n")

print("Saved merged model to", MERGED)
PY

The tested merge produced 14 model shards totaling 65.8 GB.

Convert and quantize

Use the converter and quantizer from the native llama.cpp build:

set -e

MERGED=outputs/nemotron35-lightning-thor-merged
GGUF_DIR=outputs/nemotron35-lightning-thor-merged_gguf
BF16="$GGUF_DIR/NVIDIA-Nemotron-3.5-Lightning-30B-A3B.NO_MTP.BF16.gguf"
Q4="$GGUF_DIR/NVIDIA-Nemotron-3.5-Lightning-30B-A3B.NO_MTP.Q4_K_M.gguf"

mkdir -p "$GGUF_DIR"

python llama.cpp/convert_hf_to_gguf.py "$MERGED" \
  --outfile "$BF16" \
  --outtype bf16 \
  --no-mtp

./llama.cpp/llama-quantize \
  "$BF16" \
  "$Q4" \
  Q4_K_M \
  14

rm "$BF16"

The —no-mtp option creates a GGUF that can be loaded by both llama.cpp and Ollama. The tested export produced a 63.2 GB BF16 GGUF and a 24.5 GB Q4_K_M GGUF.

Run the GGUF with llama.cpp

Use the native llama.cpp build for CUDA-accelerated inference on Jetson. If you already have a compatible GGUF file, you can start with this section and point the commands to your model.

Example: Run Qwen3.5-4B

Select the model and multimodal projector, then run the saved sample image:

MODEL=$(find outputs/qwen35-4b-orin-merged_gguf -name '*Q4_K_M.gguf' ! -name '*mmproj*' -print -quit)
MMPROJ=$(find outputs/qwen35-4b-orin-merged_gguf -iname '*mmproj*.gguf' -print -quit)

./llama.cpp/llama-cli \
  --model "$MODEL" \
  --mmproj "$MMPROJ" \
  --image outputs/qwen35-4b-orin-lora/sample.png \
  --prompt "Write the LaTeX representation for this image." \
  --n-gpu-layers 99 \
  --ctx-size 512 \
  --n-predict 128 \
  --jinja

You now have a Qwen3.5-4B adapter fine-tuned, merged, quantized, and running locally on Jetson.

Example: Run Nemotron 3.5 Lightning

MODEL=outputs/nemotron35-lightning-thor-merged_gguf/NVIDIA-Nemotron-3.5-Lightning-30B-A3B.NO_MTP.Q4_K_M.gguf

./llama.cpp/llama-cli \
  --model "$MODEL" \
  --n-gpu-layers 999 \
  --ctx-size 2048 \
  --single-turn \
  --jinja \
  --reasoning off

The tested Q4_K_M model loaded on the Thor CUDA backend and generated 66.1 tokens per second for a short prompt.

Remove the temporary swap file after the Thor example finishes:

sudo swapoff "$NEMOTRON_SWAP"
sudo rm "$NEMOTRON_SWAP"

Optional: Run Nemotron with Ollama

The native llama.cpp workflow above is the recommended deployment path. Use it for Qwen3.5 because the model and multimodal projector are separate GGUF files. The no-MTP Nemotron GGUF can also be imported into Ollama.

Install Ollama:

curl -fsSL https://ollama.com/install.sh | sh

Create a Modelfile that points to the Nemotron GGUF:

FROM /absolute/path/to/NVIDIA-Nemotron-3.5-Lightning-30B-A3B.NO_MTP.Q4_K_M.gguf
PARAMETER temperature 0
PARAMETER top_p 0.95
PARAMETER num_ctx 2048

Import and run the model:

ollama create nemotron35-lightning-thor -f Modelfile
ollama run nemotron35-lightning-thor

Optional: Unsloth Studio over SSH

Unsloth Studio is a browser UI, not a terminal UI. It is not required for this tutorial.

If Studio is installed on the Jetson, forward its local port from your workstation:

ssh -L 8888:127.0.0.1:8888 jetson@JETSON_IP

Start Studio in the SSH session:

unsloth studio -p 8888

Open http://127.0.0.1:8888 on your workstation. See the Unsloth Studio documentation for installation and updates.

Troubleshooting
  • CUDA is unavailable: activate .venv-unsloth; if needed, reinstall Unsloth with --torch-backend=cu130.
  • Triton reports sm_110a is not defined: set TRITON_PTXAS_BLACKWELL_PATH to the CUDA 13 ptxas binary before importing Unsloth.
  • Nemotron is killed while loading: confirm swap with swapon --show and keep the first-run memory settings shown above.
  • Ollama reports the wrong number of tensors: repeat the conversion with --no-mtp.

References