
Deploy Stack
- 1 installs
- 13 repo stars
- Updated July 28, 2026
- aws-samples/sample-embodied-ai-platform
deploy-stack is a skill that deploys and tears down the Embodied AI Platform's CDK stacks for GPU training and DCV visualization on AWS.
About
Walks an agent through deploying the Embodied AI Platform's two CDK stacks on AWS: a Batch stack (VPC, EFS, ECR, CodeBuild, AWS Batch) and a GPU-accelerated DCV workstation stack. It covers prerequisites, CDK synth validation, GPU instance capacity probing, deployment order, SSH setup via SSM, submitting training jobs, TensorBoard visualization, and model evaluation, plus teardown and cleanup. A developer uses it to stand up or tear down the GR00T training infrastructure.
- Deploys two dependent CDK stacks (Batch then DCV) in the required order
- Includes a GPU instance capacity probe via real launch-and-terminate before deploy
- Covers training-job submission, TensorBoard metrics, and model evaluation on the stack
Deploy Stack by the numbers
- 1 all-time installs (skills.sh)
- Ranked #930 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
deploy-stack capabilities & compatibility
Requires an AWS account; the docs note GPU g6-family EC2 instances, which incur AWS compute charges.
- Capabilities
- containerization
- Works with
- aws
- Use cases
- devops · ci cd
- Runs
- Runs locally
- Pricing
- Bring your own API key
What deploy-stack says it does
The DCV stack depends on the Batch stack (shares its VPC and EFS), so deploy order matters:
npx skills add https://github.com/aws-samples/sample-embodied-ai-platform --skill deploy-stackAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 13 |
| Last updated | July 28, 2026 |
| Repository | aws-samples/sample-embodied-ai-platform ↗ |
What it does
Deploy, verify and tear down the Embodied AI Platform CDK stacks for GPU training and DCV visualization on AWS.
Who is it for?
Developers deploying the GR00T embodied-AI training infrastructure and GPU workstation on AWS
Skip if: Regions without g6e GPU instance capacity for training
When should I use this skill?
Deploying, redeploying or tearing down the CDK infrastructure, submitting training jobs, or running model evaluations
What you get
Both CDK stacks deploy in order with SSH access, training jobs, TensorBoard, and evaluation working.
- Deployed Batch and DCV CDK stacks
- SSH access via SSM
- TensorBoard metrics and model evaluation
By the numbers
- 2 CDK stacks (Batch + DCV)
- Batch stack ~3 min, DCV ~15 min bootstrap
Files
Deploy Embodied AI Platform CDK Stacks
Autonomous execution: Execute all phases sequentially from Phase 1 through Phase 8c
without pausing for user input, except where a phase says "Prompt the user" — pause
and ask before proceeding with those optional phases. Only stop if a phase fails or
requires information not available in this document. When a phase depends on a
long-running process (e.g. CodeBuild, training job), poll until completion then proceed
to the next phase automatically.
This skill walks through deploying the two CDK stacks that make up the platform's AWS infrastructure, setting up SSH access to the GPU workstation, submitting training jobs, visualizing metrics, and running model evaluations.
The two stacks are:
- IsaacGr00tBatchStack — VPC, EFS, ECR, CodeBuild, AWS Batch compute environment and job queue
- IsaacLabDcvStack — GPU-accelerated DCV workstation (EC2 instance, accessed via SSM)
The DCV stack depends on the Batch stack (shares its VPC and EFS), so deploy order matters: Batch first, DCV second. Destroy order is reversed: DCV first, Batch second.
Phase 1: Prerequisites
Before deploying, verify these are in place.
Region selection: Deploy in a region with g6e GPU instances for training
(e.g.us-west-2,us-east-1). The DCV instance uses g6 family by default.
Set your region:export AWS_DEFAULT_REGION=us-west-2or use--profile
with a configured AWS CLI profile.
# AWS CLI configured with correct account
aws sts get-caller-identity --query '[Account, Arn]' --output text
# CDK available (via npx, no global install needed)
npx cdk --version
# jq for parsing stack outputs
jq --version
# Python venv — CDK uses Python to synthesize CloudFormation templates.
# The venv isolates CDK dependencies from your system Python.
REPO_ROOT=$(git rev-parse --show-toplevel)
ls -la "$REPO_ROOT/.venv/bin/python"
# If venv is missing, create it:
cd "$REPO_ROOT" && python3 -m venv .venv
source .venv/bin/activate
pip install -r training/gr00t/infra/requirements.txt
pip install -r workstation/requirements.txtAlso confirm CDK has been bootstrapped in the target account/region:
aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].StackStatus' --output textIf that fails, run npx cdk bootstrap from training/gr00t/infra/.
Phase 2: Validate with CDK Synth
The default app.py deploys with IsaacSim 5.1.0 / IsaacLab v2.3.0.
Always synth before deploying — it catches version mismatches, missing dependencies, and code errors at zero cost (no AWS resources created).
cd training/gr00t/infra
npx cdk synth --quietExpected output: Successfully synthesized to .../cdk.out listing both stack names.
If synth fails, fix the error before proceeding. Common issues:
- Missing Python dependencies ->
pip install -r requirements.txt - Version validation errors -> check
workstation/versions.pyfor supported IsaacSim versions
Phase 2.5: Probe GPU Instance Capacity
--dry-run does not test actual instance capacity — it only validates IAM permissions. The only reliable check is a real launch + immediate terminate. Run this after IsaacGr00tBatchStack deploys (so its VPC subnets exist), before deploying IsaacLabDcvStack.
# Requires $VpcId from the Batch stack outputs (captured below after Phase 3 Step 1)
UBUNTU_AMI=$(aws ec2 describe-images --owners amazon \
--filters "Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*" \
"Name=state,Values=available" \
--query 'sort_by(Images, &CreationDate)[-1].ImageId' --output text)
SUBNETS=$(aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=$VpcId" "Name=mapPublicIpOnLaunch,Values=true" \
--query 'Subnets[].{AZ:AvailabilityZone,SubnetId:SubnetId}' --output json)
# Probe in order: preferred first, then fallbacks (all ≥32GiB — minimum for GR00T + sim)
FOUND=false
for ITYPE in g6.4xlarge g6.2xlarge g5.2xlarge; do
while IFS= read -r row; do
AZ=$(echo "$row" | jq -r '.AZ')
SUBNET=$(echo "$row" | jq -r '.SubnetId')
RESULT=$(aws ec2 run-instances \
--image-id "$UBUNTU_AMI" --instance-type "$ITYPE" \
--subnet-id "$SUBNET" --count 1 \
--no-associate-public-ip-address \
--query 'Instances[0].InstanceId' --output text 2>&1)
if [[ "$RESULT" == i-* ]]; then
aws ec2 terminate-instances --instance-ids "$RESULT" > /dev/null
echo "✅ $ITYPE in $AZ — available. Set in app.py:"
echo " availability_zone=\"$AZ\","
echo " instance_type=\"$ITYPE\","
FOUND=true; break 2
else
echo "❌ $ITYPE / $AZ — $(echo "$RESULT" | grep -oE 'InsufficientInstanceCapacity|[A-Za-z]+Error')"
fi
done < <(echo "$SUBNETS" | jq -c '.[]')
done
$FOUND || echo "⚠️ No capacity found — try a different region or instance family"Update training/gr00t/infra/app.py with the printed availability_zone and instance_type values before running Step 2 below.
Phase 3: Deploy Stacks
No context parameters needed — the stacks auto-create VPC, EFS, and ECR.
N1.6 build target: The BatchStack deploy automatically triggers a CodeBuild run.
Pass--context build_target=n16so it buildsN16/Dockerfile(not the N1.5 default).
cd training/gr00t/infra
# Step 1: Batch stack (VPC, EFS, ECR, Batch — ~3 min)
# build_target=n16 selects N16/Dockerfile for the CodeBuild run triggered on deploy.
npx cdk deploy IsaacGr00tBatchStack --require-approval=never --context build_target=n16
# Capture VpcId output, then run Phase 2.5 capacity probe, then update app.py.
# Step 2: DCV stack (GPU instance — ~3 min for CFN, then ~15 min bootstrap)
# This blocks until cfn-signal is received from the bootstrap script.
# By default, ports 8443 (DCV) and 8080 (W&B) are open publicly.
# For SSM-only access (no public ports), add: --context public_dcv_access=false
npx cdk deploy IsaacLabDcvStack --require-approval=neverParallel monitoring during Step 2: Open a second terminal and poll CloudFormation
events directly — avoids stdout buffering delays from CDK:
>
```bash
while true; do
STATUS=$(aws cloudformation describe-stacks --stack-name IsaacLabDcvStack \
--query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "CREATING")
printf '\n=== %s [%s] ===\n' "$(date +%H:%M:%S)" "$STATUS"
aws cloudformation describe-stack-events --stack-name IsaacLabDcvStack \
--query 'StackEvents[0:8].[Timestamp,ResourceStatus,LogicalResourceId]' \
--output table 2>/dev/null
[[ "$STATUS" =~ (COMPLETE|FAILED) ]] && break
sleep 20
done
```
After both deploys complete, capture the stack outputs as shell variables for later phases. The jq approach handles values with spaces safely (unlike eval-based approaches):
# Capture Batch stack outputs
BATCH_OUTPUTS=$(aws cloudformation describe-stacks --stack-name IsaacGr00tBatchStack \
--query 'Stacks[0].Outputs' --output json)
export EcrImageUri=$(echo "$BATCH_OUTPUTS" | jq -r '.[] | select(.OutputKey=="EcrImageUri") | .OutputValue')
export EFSFileSystemId=$(echo "$BATCH_OUTPUTS" | jq -r '.[] | select(.OutputKey=="EFSFileSystemId") | .OutputValue')
export EFSSecurityGroupId=$(echo "$BATCH_OUTPUTS" | jq -r '.[] | select(.OutputKey=="EFSSecurityGroupId") | .OutputValue')
export VpcId=$(echo "$BATCH_OUTPUTS" | jq -r '.[] | select(.OutputKey=="VpcId") | .OutputValue')
export CodeBuildProjectName=$(echo "$BATCH_OUTPUTS" | jq -r '.[] | select(.OutputKey=="CodeBuildProjectName") | .OutputValue // empty')
export CheckpointS3UploadUri=$(echo "$BATCH_OUTPUTS" | jq -r '.[] | select(.OutputKey=="CheckpointS3UploadUri") | .OutputValue // empty')
# Capture DCV stack outputs
# CDK appends hash suffixes to output keys (e.g. DCVInstanceIdXXX00000),
# so use startswith() instead of exact matching.
DCV_OUTPUTS=$(aws cloudformation describe-stacks --stack-name IsaacLabDcvStack \
--query 'Stacks[0].Outputs' --output json)
export InstanceId=$(echo "$DCV_OUTPUTS" | jq -r '.[] | select(.OutputKey | startswith("DCVInstanceId")) | .OutputValue')
export InstancePublicIP=$(echo "$DCV_OUTPUTS" | jq -r '.[] | select(.OutputKey | startswith("DCVInstancePublicIP")) | .OutputValue')
export DCVWebURL=$(echo "$DCV_OUTPUTS" | jq -r '.[] | select(.OutputKey | startswith("DCVDCVWebURL")) | .OutputValue')
export DCVCredentials=$(echo "$DCV_OUTPUTS" | jq -r '.[] | select(.OutputKey | startswith("DCVDCVCredentials")) | .OutputValue')
# Verify key values are set
echo "Instance ID: $InstanceId"
echo "Elastic IP: $InstancePublicIP"
echo "ECR URI: $EcrImageUri"
echo "EFS ID: $EFSFileSystemId"
echo "DCV URL: $DCVWebURL"
echo "DCV Creds: $DCVCredentials"The variable names match the CDK CfnOutput keys exactly (PascalCase). These are usedin later phases as$InstanceId,$InstancePublicIP,$EcrImageUri, etc.
Phase 4: Monitor Bootstrap Completion
The DCV instance runs a bootstrap that installs NVIDIA drivers, Docker, pulls the IsaacLab container from NGC, installs DCV, and mounts EFS. CloudFormation waits for a cfn-signal before marking CREATE_COMPLETE — so the Phase 3 CDK deploy does not return until bootstrap finishes. Phase 4 monitoring is for a parallel terminal to track mid-progress.
# Auto-poll bootstrap summary every 60 seconds (run in a second terminal during Phase 3)
while true; do
CMD_ID=$(aws ssm send-command \
--instance-ids $InstanceId \
--document-name AWS-RunShellScript \
--parameters 'commands=["cat /var/log/dcv-bootstrap.summary 2>/dev/null || echo BOOTSTRAP_NOT_STARTED"]' \
--output text --query 'Command.CommandId')
sleep 5
OUTPUT=$(aws ssm get-command-invocation \
--command-id $CMD_ID --instance-id $InstanceId \
--query 'StandardOutputContent' --output text)
printf '\n=== %s ===\n%s\n' "$(date +%H:%M:%S)" "$OUTPUT"
echo "$OUTPUT" | grep -q "STEP_FAIL" && echo "Bootstrap FAILED" && break
sleep 55
doneSteps appear as STEP_OK entries. Total time: ~15-20 minutes. You may see STEP_WARN:nvidia-driver-loaded — this is expected on first boot (the NVIDIA kernel module loads after a reboot).
If any step shows STEP_FAIL, check the detailed log via SSM: sudo grep -A 50 "== START: <step-name> ==" /var/log/dcv-bootstrap.log
Phase 5: Set Up SSH via SSM
SSH through SSM tunnels through AWS Session Manager — it doesn't require port 22 to be open in the security group.
5a. Verify Session Manager plugin is installed locally
session-manager-plugin --versionIf missing: Ubuntu/Debian sudo dpkg -i session-manager-plugin.deb, macOS brew install --cask session-manager-plugin.
5b. Push SSH public key to the instance
# Generate SSH key if you don't have one
test -f ~/.ssh/id_ed25519.pub || ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
PUBKEY=$(cat ~/.ssh/id_ed25519.pub)
aws ssm send-command \
--instance-ids $InstanceId \
--document-name AWS-RunShellScript \
--parameters "commands=[\"mkdir -p /home/ubuntu/.ssh && echo '$PUBKEY' > /home/ubuntu/.ssh/authorized_keys && chmod 700 /home/ubuntu/.ssh && chmod 600 /home/ubuntu/.ssh/authorized_keys && chown -R ubuntu:ubuntu /home/ubuntu/.ssh\"]" \
--output text --query 'Command.CommandId'5c. Configure SSH config
Add or update this entry in ~/.ssh/config:
Host dcv-isaac
HostName <$InstanceId>
User ubuntu
IdentityFile ~/.ssh/id_ed25519
ProxyCommand aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p' --region <$AWS_DEFAULT_REGION>Replace<$InstanceId>and<$AWS_DEFAULT_REGION>with the values from Phase 3.
5d. Test the connection
ssh -o StrictHostKeyChecking=accept-new dcv-isaac "echo 'SSH OK'"Phase 5e: Wait for Auto-Reboot
The bootstrap automatically reboots the instance after sending cfn-signal. This loads the NVIDIA kernel module (which can't load on the same boot that installs the driver). Wait ~2 minutes after cdk deploy returns, then verify:
ssh dcv-isaac "nvidia-smi --query-gpu=name --format=csv,noheader"
# Expected: NVIDIA L4 (or similar, depending on instance type)If nvidia-smi fails, the reboot may still be in progress. Wait another minute and retry.Phase 6: Verify Everything Works
# 1. Bootstrap summary — all named steps should be STEP_OK
ssh dcv-isaac "cat /var/log/dcv-bootstrap.summary"
# 2. EFS mounted
ssh dcv-isaac "mount | grep efs"
# 3. Container image pulled
ssh dcv-isaac "docker images | grep isaac-lab"
# 4. Helper script installed
ssh dcv-isaac "test -x /usr/local/bin/run-isaaclab.sh && echo 'Helper script OK'"
# 5. NVIDIA GPU detected (should work after auto-reboot)
ssh dcv-isaac "nvidia-smi --query-gpu=name --format=csv,noheader"The DCV web console is available at https://<elastic-ip>:8443 (accept the self-signed certificate warning). DCV is password-protected (credentials in stack outputs).
If you deployed with `--context public_dcv_access=false` (SSM-only mode), ports 8443/8080 are not open. Use SSH port forwarding instead:
ssh -f -N -L 8443:localhost:8443 -L 8080:localhost:8080 dcv-isaacThen open https://localhost:8443 for DCV or http://localhost:8080 for W&B. Claude Code does not need these port forwards — it accesses everything via SSH commands.
Phase 6a: Container and LeIsaac Testing
6a.1 Launch IsaacLab Container
The helper script wraps docker run with GPU access, X11 forwarding, cache volumes, and persistent package mounts. On first launch, it auto-installs leisaac and downloads scene assets (~60 seconds total).
ssh dcv-isaac
run-isaaclab.shThe container Python is at /workspace/isaaclab/_isaac_sim/python.sh (an Isaac Simwrapper). All Python commands below use this wrapper.
6a.2 Verify Inside Container
# Python wrapper works
/workspace/isaaclab/_isaac_sim/python.sh --version
# GPU accessible
nvidia-smiExit the container with exit or Ctrl-D.
6a.3 Verify Policy Client N1.6 Patch
The run-isaaclab.sh helper patches policy_inference.py for headless keyboard support on first launch. Confirm the patch applied:
ssh dcv-isaac "grep -q 'self._appwindow is not None' /home/ubuntu/leisaac-repo/scripts/evaluation/policy_inference.py && echo 'Keyboard patch OK' || echo 'MISSING — re-run run-isaaclab.sh'"If missing, re-run run-isaaclab.sh -c 'echo patched' and check again.
Phase 7: Submit Training Job
7a. Verify container image is ready
The Batch stack triggers a CodeBuild project that builds and pushes the training container to ECR. If you deployed with --context build_target=n16 in Phase 3, the N1.6 container is already building. This takes ~15 minutes for N1.6 (PyTorch3D compilation). Note: Redeploying BatchStack auto-triggers a new CodeBuild run that may fail due to transient Docker Hub rate limits — check for a recent successful build, not just the latest build status:
aws codebuild batch-get-projects --names "$CodeBuildProjectName" \
--query 'projects[0].lastSuccessfulBuild.endTime' --output text
# If no successful build yet, check current build status:
BUILD_ID=$(aws codebuild list-builds-for-project --project-name "$CodeBuildProjectName" \
--query 'ids[0]' --output text)
aws codebuild batch-get-builds --ids "$BUILD_ID" \
--query 'builds[0].[currentPhase, buildStatus]' --output textDo not submit a Batch job until the build shows SUCCEEDED.
7b. Submit the training job
JOB_ID=$(aws batch submit-job \
--job-name "IsaacGr00tFinetuning" \
--job-queue "IsaacGr00tJobQueue" \
--job-definition "IsaacGr00tJobDefinition" \
--query 'jobId' --output text)
echo "Job submitted: $JOB_ID"Monitor progress:
aws batch describe-jobs --jobs $JOB_ID --query 'jobs[0].status' --output text
# Stream logs once RUNNING:
aws logs tail /aws/batch/job --follow \
--log-stream-names "$(aws batch describe-jobs --jobs $JOB_ID \
--query 'jobs[0].container.logStreamName' --output text)"Default: 6000 steps (~2 hours on g6e.4xlarge). Checkpoints saved every 2000 steps
at /mnt/efs/gr00t/checkpoints/$JOB_ID/.Phase 7a: Visualize Training Metrics (W&B)
Prompt the user: "Would you like to visualize the training loss curves? This uses
a local W&B server on the DCV instance — no W&B license or cloud account required."
Only proceed with this phase if the user says yes; otherwise skip to Phase 8.
Training logs to W&B in offline mode — run data persists on EFS after the container exits.
# Start local W&B server on DCV instance
ssh dcv-isaac "docker run -d --name wandb-local -p 8080:8080 -v wandb-data:/vol wandb/local:latest"Access the W&B dashboard:
- If deployed with default
public_dcv_access=true: openhttp://<$InstancePublicIP>:8080 - If deployed with
--context public_dcv_access=false: use SSH port forward first:
ssh -f -N -L 8080:localhost:8080 dcv-isaac, then open http://localhost:8080
Create an account on the local instance and generate an API key from Settings → API Keys. This is entirely local — no external W&B service is contacted.
# Sync offline runs (replace <your-local-api-key> with the key from the step above)
ssh dcv-isaac "bash -l -c '
export WANDB_BASE_URL=http://localhost:8080
export WANDB_API_KEY=<your-local-api-key>
source /home/ubuntu/.venv/bin/activate
wandb sync /mnt/efs/gr00t/checkpoints/$JOB_ID/wandb/offline-run-*
'"View loss curves in the W&B dashboard. Stop/restart the server anytime — data persists in the wandb-data Docker volume.
Phase 8: Start Policy Server
All evaluation phases (8a, 8b, 8c) require a policy server serving the trained checkpoint. Start it once here; it stays running until cleanup.
Serves the checkpoint as a ZMQ policy server on TCP port 5555. Do not use --use-sim-policy-wrapper — the Gr00t16ServicePolicyClient in LeIsaac sends observations in the nested format the server expects natively, and the wrapper would cause a key mismatch error.
CHECKPOINT=/mnt/efs/gr00t/checkpoints/$JOB_ID/checkpoint-6000
ssh dcv-isaac "aws ecr get-login-password --region $AWS_DEFAULT_REGION | \
docker login --username AWS --password-stdin ${EcrImageUri%%/*}"
ssh dcv-isaac "docker pull $EcrImageUri"
ssh dcv-isaac "docker run --gpus all -d \
--name gr00t-policy-server \
--shm-size=8g \
--network host \
--entrypoint /bin/sh \
-v /mnt/efs:/mnt/efs \
$EcrImageUri \
-c 'cd /workspace/gr00t-repo && python3 -m gr00t.eval.run_gr00t_server \
--model-path $CHECKPOINT \
--embodiment-tag NEW_EMBODIMENT \
--port 5555'"Use --entrypoint /bin/sh because the container's default entrypoint invokes auv-managed Python that can't be exec'd directly. The server module is
gr00t.eval.run_gr00t_serverwhich wrapsGr00tPolicyin aPolicyServer(ZMQ).
Allow ~60 seconds for model loading before the server begins accepting connections.
Verify the server is listening:
ssh dcv-isaac "docker logs gr00t-policy-server 2>&1 | tail -5"
ssh dcv-isaac "ss -tlnp | grep 5555"For a direct inference test (without IsaacSim), see ../references/policy-server-test.md.
For observation/response format details, see ../references/eval-format.md.
Phase 8a: Open-Loop Evaluation (Optional)
Prompt the user: "Would you like to run open-loop evaluation? This computes MSE/MAE
against a dataset to measure action prediction quality. You can use the included sample
dataset or provide your own."
If the user declines, skip to Phase 8b.
Ask for dataset: "Which dataset should we use for evaluation?
1. Sample dataset (default) — the included training/sample_dataset/ (57 episodes)2. Custom dataset — provide a local path or an EFS path (e.g. /mnt/efs/gr00t/my_dataset/)>
Custom datasets must have the same LeRobot format withdata/,meta/, andvideos/
directories, plusmeta/modality.jsonandmeta/stats.json."
Prepare dataset on EFS
The training job uses a dataset baked into the container during CodeBuild, but open-loop evaluation runs on the DCV instance and needs the dataset on EFS.
If using the sample dataset:
REPO_ROOT=$(git rev-parse --show-toplevel)
# Pull LFS-tracked files (parquet data, video files). Without this, rsync copies
# LFS pointer files instead of actual data, causing pyarrow read failures.
cd "$REPO_ROOT" && git lfs pull --include="training/sample_dataset/"
# Create EFS target directory with correct ownership (EFS root is owned by root)
ssh dcv-isaac "sudo mkdir -p /mnt/efs/gr00t/sample_dataset && sudo chown -R ubuntu:ubuntu /mnt/efs/gr00t"
# Sync dataset to EFS
rsync -avz "$REPO_ROOT/training/sample_dataset/" dcv-isaac:/mnt/efs/gr00t/sample_dataset/
DATASET_PATH=/mnt/efs/gr00t/sample_datasetIf using a custom local dataset:
# Create EFS target directory
ssh dcv-isaac "sudo mkdir -p /mnt/efs/gr00t/<dataset-name> && sudo chown -R ubuntu:ubuntu /mnt/efs/gr00t"
rsync -avz "<local-dataset-path>/" dcv-isaac:/mnt/efs/gr00t/<dataset-name>/
DATASET_PATH=/mnt/efs/gr00t/<dataset-name>If the dataset is already on EFS, just set DATASET_PATH to its path.
Generate metadata files (if not already present)
# Create modality.json (maps dataset columns to model modality keys)
ssh dcv-isaac "cat > $DATASET_PATH/meta/modality.json << 'EOF'
{
\"action\": {
\"single_arm\": {\"start\": 0, \"end\": 5},
\"gripper\": {\"start\": 5, \"end\": 6}
},
\"state\": {
\"single_arm\": {\"start\": 0, \"end\": 5},
\"gripper\": {\"start\": 5, \"end\": 6}
},
\"annotation\": {
\"human.task_description\": {
\"original_key\": \"task_index\"
}
},
\"video\": {
\"front\": {
\"original_key\": \"observation.images.front\",
\"shape\": [480, 640, 3]
},
\"wrist\": {
\"original_key\": \"observation.images.wrist\",
\"shape\": [480, 640, 3]
}
}
}
EOF"
# Generate stats.json (normalization statistics used by the eval pipeline)
ssh dcv-isaac "docker run --gpus all --rm \
-v /mnt/efs:/mnt/efs \
--entrypoint /bin/sh \
$EcrImageUri \
-c 'cd /workspace/gr00t-repo && python3 -m gr00t.data.stats \
--dataset-path $DATASET_PATH \
--embodiment-tag NEW_EMBODIMENT'"modality.json maps the dataset's column names to the model's expected modality keys(video cameras, state joints, action joints, language annotations). stats.jsoncontains per-feature normalization statistics. Both are required by open_loop_eval.>
Expected warning: The stats command may print KeyError: 'new_embodiment' fromgenerate_rel_stats— this is non-blocking. The mainstats.jsonfile is still
written successfully. Verify with: ssh dcv-isaac "ls -la $DATASET_PATH/meta/stats.json"Run the evaluation
ssh dcv-isaac "docker run --gpus all --rm \
--network host \
-v /mnt/efs:/mnt/efs:ro \
--shm-size=8g \
--entrypoint /bin/sh \
$EcrImageUri \
-c 'cd /workspace/gr00t-repo && python3 -m gr00t.eval.open_loop_eval \
--host 127.0.0.1 \
--port 5555 \
--model-path None \
--embodiment-tag NEW_EMBODIMENT \
--dataset-path $DATASET_PATH \
--modality-keys single_arm gripper'"Note: The module isgr00t.eval.open_loop_eval(notrobot_eval). Use
--entrypoint /bin/sh because the container's default entrypoint invokes a uv-managedPython that can't be exec'd directly. The --embodiment-tag is case-sensitive and mustbeNEW_EMBODIMENT(uppercase).--model-path Nonetells the eval to use the running
policy server (--host/--port) instead of loading the model directly.
Phase 8b: Closed-Loop Smoke Test (Headless)
This runs a single headless simulation episode to verify that IsaacSim launches, connects to the policy server, and completes without crashing. It is a smoke test only — success rate is irrelevant at this stage.
Important: Do NOT use run-isaaclab.sh for automated evaluation — it launches aninteractive bash shell and routes arguments through runheadless.sh (the Kit launcher),which does not execute the Python eval script. Use the direct docker run command belowwith--entrypointset topython.sh.
run-isaaclab.sh handles leisaac package install, scene asset download, and repo clone on first launch. Run it once interactively to set up prerequisites if they haven't been installed yet:
ssh dcv-isaac "run-isaaclab.sh -c 'echo prerequisites installed'"Run one episode headlessly via SSH:
# Detect the DCV display number (varies between deployments: :0 or :1)
DCV_DISPLAY=$(ssh dcv-isaac "ls /tmp/.X11-unix/ | sed 's/X/:/'" | head -1)
echo "Using DISPLAY=$DCV_DISPLAY"
ssh dcv-isaac "docker run --gpus all --rm --network host \
-e ACCEPT_EULA=Y -e PRIVACY_CONSENT=Y \
-e PYTHONPATH=/workspace/isaaclab-pkgs \
-e PYTHONUNBUFFERED=1 \
-e DISPLAY=$DCV_DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix:ro \
-v /home/ubuntu/isaaclab-pkgs:/workspace/isaaclab-pkgs:rw \
-v /home/ubuntu/leisaac-repo/scripts:/workspace/scripts:ro \
-v /home/ubuntu/leisaac-assets:/assets:ro \
-v /mnt/efs:/mnt/efs \
-e LEISAAC_ASSETS_ROOT=/assets \
--shm-size=8g \
--entrypoint /workspace/isaaclab/_isaac_sim/python.sh \
nvcr.io/nvidia/isaac-lab:2.3.0 \
/workspace/scripts/evaluation/policy_inference.py \
--task=LeIsaac-SO101-PickOrange-v0 \
--eval_rounds=1 \
--policy_type=gr00tn1.6 \
--policy_host=localhost \
--policy_port=5555 \
--policy_action_horizon=16 \
--policy_language_instruction='Pick up the orange and place it on the plate' \
--device=cuda \
--enable_cameras 2>&1 | tee /mnt/efs/gr00t/eval-results.log"DISPLAYand the X11 socket mount are required because--enable_camerasuses
Vulkan rendering for camera frames, which needs an active display. DCV picks
display:0or:1depending on what's available at boot — the detection
command above reads it from /tmp/.X11-unix/. Without these flags, the renderloop blocks silently with 0% GPU utilization despite allocating VRAM.
>
PYTHONUNBUFFERED=1 ensures eval output streams in real time over SSH.First run takes ~5 minutes for IsaacSim shader compilation before the episode begins.
Expected: The episode completes (success or timeout) without errors. If it runs to completion, the simulation pipeline is working correctly. Proceed to Phase 8c for visual inspection.
For eval parameters and format details, see ../references/eval-format.md.
Phase 8c: Visual Policy Inspection (GUI)
Prompt the user: "Would you like to visually inspect the trained policy in the
simulator? This opens the DCV desktop where you can watch the robot arm execute actions
in real time — the best way to qualitatively evaluate policy performance."
This is the primary way to evaluate whether the policy has learned useful behavior. The DCV desktop renders the full IsaacSim environment so you can watch the robot arm interact with objects in real time.
Connect to the DCV desktop
- If deployed with default
public_dcv_access=true: openhttps://<$InstancePublicIP>:8443
(accept the self-signed certificate warning)
- If deployed with
--context public_dcv_access=false: start an SSH port forward first:
ssh -f -N -L 8443:localhost:8443 dcv-isaac, then open https://localhost:8443
Log in with the DCV credentials from the stack outputs ($DCVCredentials).
Run the evaluation from the DCV terminal
1. Open a terminal in the DCV desktop 2. Launch the IsaacLab container:
run-isaaclab.sh3. Inside the container, run the evaluation:
/workspace/isaaclab/_isaac_sim/python.sh \
/workspace/scripts/evaluation/policy_inference.py \
--task=LeIsaac-SO101-PickOrange-v0 \
--eval_rounds=1 \
--policy_type=gr00tn1.6 \
--policy_host=localhost \
--policy_port=5555 \
--policy_action_horizon=16 \
--policy_language_instruction='Pick up the orange and place it on the plate' \
--device=cuda \
--enable_camerasFirst GUI run takes ~5 minutes for IsaacSim shader compilation. These shaders are
cached in the Docker volumes mounted by run-isaaclab.sh, so subsequent runs startmuch faster.
A simulation window will appear showing the robot arm, the table scene, and the orange. Watch the arm's behavior to judge whether the policy has learned the intended task. Increase --eval_rounds to run multiple episodes for a more thorough assessment.
Cleanup: Tearing Down Stacks
Step 1: Stop running containers
ssh dcv-isaac "docker rm -f gr00t-policy-server wandb-local 2>/dev/null; echo 'Containers stopped'"Step 2: Destroy stacks (DCV first, then Batch)
aws ec2 modify-instance-attribute --instance-id $InstanceId --no-disable-api-termination
cd training/gr00t/infra
npx cdk destroy IsaacLabDcvStack --force
npx cdk destroy IsaacGr00tBatchStack --forceStep 3: Clean up retained resources
Three resources have RemovalPolicy.RETAIN and survive stack deletion:
# S3 checkpoint bucket
BUCKET=<bucket-name-from-stack-outputs>
aws s3 rb s3://$BUCKET --force
# ECR repository
aws ecr delete-repository --repository-name gr00t-finetune --force --region $AWS_DEFAULT_REGION
# EFS file system
aws efs delete-file-system --file-system-id $EFSFileSystemIdIf EFS deletion fails with "FileSystemInUse", delete lingering mount targets first:
aws efs describe-mount-targets --file-system-id $EFSFileSystemId --query 'MountTargets[].MountTargetId' --output text | xargs -n1 aws efs delete-mount-target --mount-target-idSee ../references/troubleshooting.md for common issues and fixes.
# Exclude infrastructure and build artifacts
infra/
cdk.out/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git/
.mypy_cache/
.pytest_cache/
.hypothesis/
**/.DS_Store
#!/bin/bash
# Build script for GR00T Fine-tuning Docker image
set -Eeuo pipefail
echo "=========================================="
echo "Building GR00T Fine-tuning Docker Image"
echo "=========================================="
# Default values
IMAGE_NAME="gr00t-finetune"
TAG="latest"
DOCKERFILE="Dockerfile"
PUSH_IMAGE=false
TEST_IMAGE=false
USE_STABLE=true
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
TAG="$2"
shift 2
;;
-n|--name)
IMAGE_NAME="$2"
shift 2
;;
--latest)
USE_STABLE=false
shift
;;
--push)
PUSH_IMAGE=true
shift
;;
--test)
TEST_IMAGE=true
shift
;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " -t, --tag TAG Tag for the fine-tuning image (default: latest)"
echo " -n, --name NAME Name for the fine-tuning image (default: gr00t-finetune)"
echo " --latest Use latest GR00T from main branch (default: stable commit)"
echo " --push Push image to registry after building"
echo " --test Run basic tests after building"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
FULL_IMAGE_NAME="${IMAGE_NAME}:${TAG}"
echo "Building image: ${FULL_IMAGE_NAME}"
echo "Using Dockerfile: ${DOCKERFILE}"
# Display GR00T version selection
if [[ "${USE_STABLE}" == "true" ]]; then
echo "GR00T version: STABLE (tested commit from Sep 4, 2025) [default]"
else
echo "GR00T version: LATEST (main branch, may have breaking changes)"
fi
# Build the fine-tuning image directly from the combined Dockerfile
echo "Building fine-tuning image..."
docker build \
--build-arg USE_STABLE=${USE_STABLE} \
-f ${DOCKERFILE} \
-t ${FULL_IMAGE_NAME} \
.
echo "Image built successfully: ${FULL_IMAGE_NAME}"
# Run basic tests if requested
if [[ "${TEST_IMAGE}" == "true" ]]; then
echo "=========================================="
echo "Running Basic Tests"
echo "=========================================="
# Test 1: Check if the image runs without errors (dry run)
echo "Test 1: Checking if image starts correctly..."
docker run --rm \
-e HF_TOKEN="dummy" \
-e HF_DATASET_ID="dummy/dummy" \
-e HF_MODEL_REPO_ID="dummy/dummy" \
--entrypoint /bin/bash \
${FULL_IMAGE_NAME} \
-c "echo 'Image starts correctly' && python -c 'import sys; print(f\"Python version: {sys.version}\")' && which huggingface-cli"
# Test 2: Check if finetune script can be imported
echo "Test 2: Checking if finetune script imports correctly..."
docker run --rm \
--entrypoint /bin/bash \
${FULL_IMAGE_NAME} \
-c "cd /workspace && python -c 'from scripts.finetune_gr00t import FinetuneWorkflow; print(\"Finetune script imports successfully\")'"
# Test 3: Check if training script exists and can show help
echo "Test 3: Checking if training script is accessible..."
docker run --rm \
--entrypoint /bin/bash \
${FULL_IMAGE_NAME} \
-c "cd /workspace && python scripts/gr00t_finetune.py --help" | head -10
echo "All tests passed!"
fi
# Push to registry if requested
if [[ "${PUSH_IMAGE}" == "true" ]]; then
echo "=========================================="
echo "Pushing to Registry"
echo "=========================================="
if [[ -z "${DOCKER_REGISTRY}" ]]; then
echo "Warning: DOCKER_REGISTRY environment variable not set."
echo "Assuming you want to push to Docker Hub or have already tagged appropriately."
else
# Re-tag with registry prefix
REGISTRY_IMAGE="${DOCKER_REGISTRY}/${FULL_IMAGE_NAME}"
docker tag ${FULL_IMAGE_NAME} ${REGISTRY_IMAGE}
FULL_IMAGE_NAME=${REGISTRY_IMAGE}
fi
echo "Pushing image: ${FULL_IMAGE_NAME}"
docker push ${FULL_IMAGE_NAME}
echo "Image pushed successfully!"
fi
echo "=========================================="
echo "Build Complete!"
echo "=========================================="
echo "Image: ${FULL_IMAGE_NAME}"
if [[ "${USE_STABLE}" == "true" ]]; then
echo "GR00T Version: STABLE (tested commit)"
else
echo "GR00T Version: LATEST (main branch)"
fi
echo ""
echo "To run locally, create a local directory to simulate EFS mount:"
echo "mkdir -p ~/mock-efs/gr00t/checkpoints"
echo "Then run with a small number of steps for testing:"
echo "docker run --gpus all --network host \\"
echo " -e MAX_STEPS=100 -e SAVE_STEPS=100 \\"
echo " -v ~/mock-efs:/mnt/efs \\"
echo " ${FULL_IMAGE_NAME}"
echo ""
echo "To rebuild with latest GR00T version from main branch:"
echo " ./build_container.sh --latest" # Alternative Dockerfile from the GR00T repo: https://github.com/NVIDIA/Isaac-GR00T/blob/main/Dockerfile
# Optimized Dockerfile for Isaac-GR00T using UV package manager
FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04
# Build argument to control GR00T version (stable vs latest)
ARG USE_STABLE=true
# https://github.com/NVIDIA/Isaac-GR00T/releases/tag/n1.5-release
ARG STABLE_COMMIT=4af2b622892f7dcb5aae5a3fb70bcb02dc217b96
# Setting the frontend to be non-interactive
# This is to avoid any user input required during the installation of packages
ENV DEBIAN_FRONTEND=noninteractive
# System dependencies - consolidated for better layer caching
RUN apt-get update && apt-get install -y --no-install-recommends \
# Core utilities
wget curl ca-certificates unzip \
# Git and version control
git git-lfs \
# Build essentials
build-essential cmake \
# Media processing
ffmpeg \
# OpenCV dependencies
libopencv-dev libgl1-mesa-glx libglib2.0-0 libsm6 libxext6 libxrender-dev \
# Python development
python3.10 python3.10-dev python3.10-distutils python3-pip \
# Utilities for debugging
vim less htop \
&& rm -rf /var/lib/apt/lists/*
# Install AWS CLI v2 (official AWS method)
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
unzip awscliv2.zip && \
./aws/install && \
rm -rf awscliv2.zip aws && \
rm -rf /usr/local/aws-cli/v2/*/dist/awscli/examples
# Set Python 3.10 as default
RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.10 1 && \
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.10 1
# Install UV package manager (much faster than pip/conda)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Configure UV to use system Python
ENV UV_SYSTEM_PYTHON=1
ENV PYTHONPATH=/workspace
# ============================================================================
# Clone Isaac-GR00T repository
# This layer changes when using --latest
# ============================================================================
RUN git clone https://github.com/NVIDIA/Isaac-GR00T.git /workspace && \
cd /workspace && \
if [ "${USE_STABLE}" = "true" ]; then \
echo "Using stable commit: ${STABLE_COMMIT} (default)"; \
git checkout ${STABLE_COMMIT}; \
else \
echo "Using latest version from main branch"; \
fi && \
echo "GR00T version info:" && \
git log -1 --format="%H %ai %s"
# Set working directory
WORKDIR /workspace
# Upgrade pip and setuptools using UV
RUN uv pip install --upgrade pip setuptools wheel
# Install GR00T base dependencies using UV (faster resolution and installation)
RUN uv pip install --no-cache -e .[base]
# Install flash-attention separately (requires build isolation disabled)
RUN pip install --no-build-isolation flash-attn==2.7.1.post4
# Install additional utilities
RUN uv pip install --no-cache notebook gpustat wandb
# Install HuggingFace CLI and additional dependencies if necessary
# RUN pip install huggingface_hub[cli] datasets
# Copy the workflow scripts
COPY finetune_gr00t.py /workspace/scripts/
COPY run_finetune_workflow.sh /workspace/scripts/
RUN chmod +x /workspace/scripts/run_finetune_workflow.sh
# Set environment variables with defaults
ENV DATASET_LOCAL_DIR="/workspace/train"
ENV OUTPUT_DIR="/workspace/checkpoints"
# If there is issue with the latest version, use tested checkpoint as of 09 July 2025 by commenting out the following two lines
# RUN hf download nvidia/GR00T-N1.5-3B --revision 869830fc749c35f34771aa5209f923ac57e4564e --local-dir ./GR00T-N1.5-3B
# ENV BASE_MODEL_PATH="./GR00T-N1.5-3B"
# Create directories using environment variables
RUN mkdir -p ${DATASET_LOCAL_DIR} ${OUTPUT_DIR}
# Setting the Entrypoint and Command to /bin/bash whenever executed
ENTRYPOINT ["/bin/bash"]
# Default command to run the workflow, but can be overridden
CMD ["/workspace/scripts/run_finetune_workflow.sh"]
# CMD ["jupyter", "notebook", "--ip=0.0.0.0", "--port=8888", "--no-browser", "--allow-root"]
# Example Environment File for GR00T Fine-tuning Workflow
# Copy this file to .env and fill in your values
# ==========================================
# AUTHENTICATION (optional)
# ==========================================
# HF_TOKEN is only required if pulling dataset from HF or uploading model to HF
HF_TOKEN=<your_huggingface_token_here>
# ==========================================
# DATASET SOURCE SELECTION (all optional)
# The entrypoint resolves in this order: local -> s3 -> hf -> sample
# ==========================================
# DATASET_LOCAL_DIR: Use a local dataset path (e.g., mounted volume)
DATASET_LOCAL_DIR=/workspace/train
# DATASET_S3_URI: Sync dataset from S3 (e.g., s3://my-bucket/datasets/so100)
DATASET_S3_URI=
# HF_DATASET_ID: Download dataset from Hugging Face (e.g., lerobot/datasets)
HF_DATASET_ID=
# ==========================================
# UPLOAD CONFIGURATION (optional)
# UPLOAD_TARGET: hf | s3 | none
# ==========================================
# UPLOAD_TARGET: Set to 'hf' for Hugging Face, 's3' for S3, or 'none' to skip
UPLOAD_TARGET=none
# HF_MODEL_REPO_ID: Required if UPLOAD_TARGET=hf (e.g., your-username/your-model)
HF_MODEL_REPO_ID=
# S3_UPLOAD_URI: Required if UPLOAD_TARGET=s3 (e.g., s3://my-bucket/models/)
S3_UPLOAD_URI=
# ==========================================
# OUTPUT DIRECTORIES (optional)
# By default, the script writes to /mnt/efs/gr00t/checkpoints if mounted
# ==========================================
# OUTPUT_DIR: Override default checkpoint directory
OUTPUT_DIR=
# ==========================================
# BASIC TRAINING PARAMETERS
# ==========================================
MAX_STEPS=6000
SAVE_STEPS=2000
NUM_GPUS=1
BATCH_SIZE=32
LEARNING_RATE=1e-4
WEIGHT_DECAY=1e-5
WARMUP_RATIO=0.05
# ==========================================
# MODEL AND DATA CONFIGURATION
# ==========================================
BASE_MODEL_PATH=nvidia/GR00T-N1.5-3B
DATA_CONFIG=so100_dualcam
VIDEO_BACKEND=torchvision_av
EMBODIMENT_TAG=new_embodiment
# Valid DATA_CONFIG options:
# - so100_dualcam
# - fourier_gr1_arms_only
# - fourier_gr1_arms_waist
# - agibot_genie1_dualcam
# - oxe_droid_single_cam
# Valid EMBODIMENT_TAG options:
# - new_embodiment
# - gr1
# - oxe_droid
# - agibot_genie1
# ==========================================
# FINE-TUNING CONFIGURATION
# ==========================================
TUNE_LLM=false
TUNE_VISUAL=false
TUNE_PROJECTOR=true
TUNE_DIFFUSION_MODEL=true
# ==========================================
# LORA CONFIGURATION
# ==========================================
LORA_RANK=0
LORA_ALPHA=16
LORA_DROPOUT=0.1
LORA_FULL_MODEL=false
# ==========================================
# DATASET BALANCING
# ==========================================
BALANCE_DATASET_WEIGHTS=true
BALANCE_TRAJECTORY_WEIGHTS=true
# ==========================================
# PERFORMANCE AND SYSTEM
# ==========================================
DATALOADER_NUM_WORKERS=8
DATALOADER_PREFETCH_FACTOR=4
# ==========================================
# WORKFLOW CONTROL
# ==========================================
RESUME=false
CLEANUP_DATASET=false
CLEANUP_CHECKPOINTS=false
# ==========================================
# LOGGING AND MONITORING
# ==========================================
REPORT_TO=tensorboard
# Weights & Biases Configuration (set REPORT_TO=wandb to use)
WANDB_API_KEY=your_wandb_api_key
WANDB_PROJECT=gr00t-finetune
WANDB_ENTITY=your_wandb_entity
# ==========================================
# CONFIGURATION EXAMPLES
# ==========================================
# Example 1: Use Local Dataset and Upload to S3
# DATASET_LOCAL_DIR=/workspace/train
# UPLOAD_TARGET=s3
# S3_UPLOAD_URI=s3://my-bucket/models/gr00t-finetuned/
# Example 2: Download from HF and Upload to HF
# HF_DATASET_ID=lerobot/datasets
# UPLOAD_TARGET=hf
# HF_MODEL_REPO_ID=your-username/gr00t-so100-finetuned
# HF_TOKEN=your_actual_hf_token
# Example 3: Use Sample Dataset (Default) and Skip Upload
# UPLOAD_TARGET=none
# (No dataset or upload variables needed)
# Example 4: Use S3 Dataset and Upload to S3
# DATASET_S3_URI=s3://my-bucket/datasets/so100
# UPLOAD_TARGET=s3
# S3_UPLOAD_URI=s3://my-bucket/models/gr00t-finetuned/
# Example 5: LoRA Fine-tuning
# LORA_RANK=32
# LORA_ALPHA=64
# TUNE_PROJECTOR=false
# TUNE_DIFFUSION_MODEL=false
# TUNE_LLM=false
# TUNE_VISUAL=false
# Example 6: Full Fine-tuning
# TUNE_LLM=true
# TUNE_VISUAL=true
# TUNE_PROJECTOR=true
# TUNE_DIFFUSION_MODEL=true
# LORA_RANK=0
# Example 7: Projector + Diffusion Only (Recommended)
# TUNE_LLM=false
# TUNE_VISUAL=false
# TUNE_PROJECTOR=true
# TUNE_DIFFUSION_MODEL=true
# LORA_RANK=0
# Example 8: Resume Training from Checkpoint
# RESUME=true
# OUTPUT_DIR=/workspace/checkpoints # Should contain previous checkpoints
# Example 9: WandB Logging
# REPORT_TO=wandb
# WANDB_API_KEY=your_actual_wandb_api_key
# WANDB_PROJECT=my-gr00t-project
# WANDB_ENTITY=my-wandb-username #!/usr/bin/env python3
"""
Workflow script for fine-tuning GR00T models with configurable parameters.
This script orchestrates the model-specific portion of the workflow:
1. Validate dataset path prepared by the entrypoint shell script
2. Run training
"""
import os
import sys
import logging
import json
from pathlib import Path
import torch
from transformers import TrainingArguments
from torch.distributed.run import main as torchrun
# Import GR00T training and data utilities
from gr00t.data.dataset import LeRobotMixtureDataset, LeRobotSingleDataset
from gr00t.data.schema import EmbodimentTag
from gr00t.experiment.data_config import load_data_config
from gr00t.experiment.runner import TrainRunner
from gr00t.model.gr00t_n1 import GR00T_N1_5
from gr00t.utils.peft import get_lora_model
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("/workspace/finetune_gr00t.log"),
],
)
logger = logging.getLogger(__name__)
class FinetuneWorkflow:
"""Main workflow class for fine-tuning GR00T models."""
def __init__(self):
"""Initialize the workflow with environment variables."""
# Dataset directory
self.dataset_local_dir = os.getenv("DATASET_LOCAL_DIR")
# Output directories (prefer EFS paths if provided by the Batch Job Definition)
self.output_dir = os.getenv("OUTPUT_DIR", "/workspace/checkpoints")
# Training parameters
self.max_steps = int(os.getenv("MAX_STEPS", "6000"))
self.save_steps = int(os.getenv("SAVE_STEPS", "2000"))
self.num_gpus = int(os.getenv("NUM_GPUS", "1"))
self.data_config = os.getenv("DATA_CONFIG", "so100_dualcam")
self.video_backend = os.getenv("VIDEO_BACKEND", "torchvision_av")
self.batch_size = int(os.getenv("BATCH_SIZE", "32"))
self.learning_rate = float(os.getenv("LEARNING_RATE", "1e-4"))
self.base_model_path = os.getenv("BASE_MODEL_PATH", "nvidia/GR00T-N1.5-3B")
self.embodiment_tag = os.getenv("EMBODIMENT_TAG", "new_embodiment")
self.report_to = os.getenv("REPORT_TO", "tensorboard")
# Optional parameters
self.tune_llm = os.getenv("TUNE_LLM", "false").lower() == "true"
self.tune_visual = os.getenv("TUNE_VISUAL", "false").lower() == "true"
self.tune_projector = os.getenv("TUNE_PROJECTOR", "true").lower() == "true"
self.tune_diffusion_model = (
os.getenv("TUNE_DIFFUSION_MODEL", "true").lower() == "true"
)
self.lora_rank = int(os.getenv("LORA_RANK", "0"))
self.lora_alpha = int(os.getenv("LORA_ALPHA", "16"))
self.lora_dropout = float(os.getenv("LORA_DROPOUT", "0.1"))
self.weight_decay = float(os.getenv("WEIGHT_DECAY", "1e-5"))
self.warmup_ratio = float(os.getenv("WARMUP_RATIO", "0.05"))
self.dataloader_num_workers = int(os.getenv("DATALOADER_NUM_WORKERS", "8"))
self.dataloader_prefetch_factor = int(
os.getenv("DATALOADER_PREFETCH_FACTOR", "4")
)
self.balance_dataset_weights = (
os.getenv("BALANCE_DATASET_WEIGHTS", "true").lower() == "true"
)
self.balance_trajectory_weights = (
os.getenv("BALANCE_TRAJECTORY_WEIGHTS", "true").lower() == "true"
)
self.lora_full_model = os.getenv("LORA_FULL_MODEL", "false").lower() == "true"
self.resume = os.getenv("RESUME", "false").lower() == "true"
# Validate required parameters
self._validate_parameters()
def _validate_parameters(self):
"""Validate required environment variables."""
required_params = {
"DATASET_LOCAL_DIR": self.dataset_local_dir,
}
missing_params = [
param for param, value in required_params.items() if not value
]
if missing_params:
raise ValueError(
f"Missing required environment variables: {', '.join(missing_params)}"
)
# Validate data_config
valid_data_configs = [
"so100_dualcam",
"fourier_gr1_arms_only",
"fourier_gr1_arms_waist",
"agibot_genie1_dualcam",
"oxe_droid_single_cam",
]
if self.data_config not in valid_data_configs:
logger.warning(f"Data config '{self.data_config}' may not be supported")
# Validate embodiment_tag
valid_embodiment_tags = ["new_embodiment", "gr1", "oxe_droid", "agibot_genie1"]
if self.embodiment_tag not in valid_embodiment_tags:
logger.warning(
f"Embodiment tag '{self.embodiment_tag}' may not be supported"
)
logger.info("All required parameters validated successfully")
def validate_dataset(self):
"""
Ensure a local dataset directory is ready.
"""
logger.info("Validating dataset...")
# 1) Use explicit local directory if provided and non-empty
if "DATASET_LOCAL_DIR" in os.environ and os.path.isdir(self.dataset_local_dir):
if os.listdir(self.dataset_local_dir):
logger.info(f"Using dataset directory: {self.dataset_local_dir}")
meta_dir = os.path.join(self.dataset_local_dir, "meta")
modality_json_path = os.path.join(meta_dir, "modality.json")
if self.data_config == "so100_dualcam" and not os.path.isfile(
modality_json_path
):
os.makedirs(meta_dir, exist_ok=True)
modality_content = {
"state": {
"single_arm": {"start": 0, "end": 5},
"gripper": {"start": 5, "end": 6},
},
"action": {
"single_arm": {"start": 0, "end": 5},
"gripper": {"start": 5, "end": 6},
},
"video": {
"wrist": {"original_key": "observation.images.wrist"},
"front": {"original_key": "observation.images.front"},
},
"annotation": {
"human.task_description": {"original_key": "task_index"}
},
}
with open(modality_json_path, "w") as f:
json.dump(modality_content, f, indent=4)
logger.info(
f"Created missing modality.json at {modality_json_path} for so100_dualcam"
)
return
else:
logger.warning(
f"DATASET_LOCAL_DIR is provided but empty: {self.dataset_local_dir}"
)
# Past this point, the dataset should already exist (shell prepared it)
raise RuntimeError(
"Dataset directory not prepared. Ensure entrypoint script resolved and downloaded the dataset."
)
def _train_once(self):
"""Run the fine-tuning steps in-process (ported from gr00t_finetune.py)."""
logger.info("Starting training...")
# ------------ step 1: load dataset ------------
embodiment_tag = EmbodimentTag(self.embodiment_tag)
# 1.1 modality configs and transforms
data_config_cls = load_data_config(self.data_config)
modality_configs = data_config_cls.modality_config()
transforms = data_config_cls.transform()
# 1.2 data loader: we will use either single dataset or mixture dataset
dataset_path = [self.dataset_local_dir]
if len(dataset_path) == 1:
train_dataset = LeRobotSingleDataset(
dataset_path=os.path.abspath(dataset_path[0]),
modality_configs=modality_configs,
transforms=transforms,
embodiment_tag=embodiment_tag, # This will override the dataset's embodiment tag to "new_embodiment"
video_backend=self.video_backend,
)
else:
single_datasets = []
for p in dataset_path:
assert os.path.exists(p), f"Dataset path {p} does not exist"
# We use the same transforms, modality configs, and embodiment tag for all datasets here,
# in reality, you can use dataset from different modalities and embodiment tags
dataset = LeRobotSingleDataset(
dataset_path=p,
modality_configs=modality_configs,
transforms=transforms,
embodiment_tag=embodiment_tag,
video_backend=self.video_backend,
)
single_datasets.append(dataset)
train_dataset = LeRobotMixtureDataset(
data_mixture=[
(dataset, 1.0)
for dataset in single_datasets # we will use equal weights for all datasets
],
mode="train",
balance_dataset_weights=self.balance_dataset_weights,
balance_trajectory_weights=self.balance_trajectory_weights,
seed=42,
metadata_config={
"percentile_mixing_method": "weighted_average",
},
)
print(f"Loaded {len(single_datasets)} datasets, with {dataset_path} ")
# ------------ step 2: load model ------------
# First, get the data config to determine action horizon
data_action_horizon = len(data_config_cls.action_indices)
# Load model
model = GR00T_N1_5.from_pretrained(
pretrained_model_name_or_path=self.base_model_path,
tune_llm=self.tune_llm, # backbone's LLM
tune_visual=self.tune_visual, # backbone's vision tower
tune_projector=self.tune_projector, # action head's projector
tune_diffusion_model=self.tune_diffusion_model, # action head's DiT
)
# Update action_horizon to match data config
# Need to recreate action head with correct config since it was initialized with old config
if data_action_horizon != model.action_head.config.action_horizon:
print(
f"Recreating action head with action_horizon {data_action_horizon} (was {model.action_head.config.action_horizon})"
)
# Update the action head config
new_action_head_config = model.action_head.config
new_action_head_config.action_horizon = data_action_horizon
# Import the FlowmatchingActionHead class
from gr00t.model.action_head.flow_matching_action_head import (
FlowmatchingActionHead,
)
# Create new action head with updated config
new_action_head = FlowmatchingActionHead(new_action_head_config)
# Copy the weights from the old action head to the new one
new_action_head.load_state_dict(
model.action_head.state_dict(), strict=False
)
# Replace the action head
model.action_head = new_action_head
# Update model config AND the action_head_cfg dictionary that gets saved
model.config.action_horizon = data_action_horizon
model.action_horizon = data_action_horizon
model.config.action_head_cfg["action_horizon"] = data_action_horizon
# Set trainable parameters for the new action head
model.action_head.set_trainable_parameters(
tune_projector=self.tune_projector,
tune_diffusion_model=self.tune_diffusion_model,
)
# Set the model's compute_dtype to bfloat16
model.compute_dtype = "bfloat16"
model.config.compute_dtype = "bfloat16"
if self.lora_rank > 0:
model = get_lora_model(
model,
rank=self.lora_rank,
lora_alpha=self.lora_alpha,
lora_dropout=self.lora_dropout,
action_head_only=not self.lora_full_model,
)
# 2.1 modify training args
training_args = TrainingArguments(
output_dir=self.output_dir,
run_name=None,
remove_unused_columns=False,
deepspeed="",
gradient_checkpointing=False,
bf16=True,
tf32=True,
per_device_train_batch_size=self.batch_size,
gradient_accumulation_steps=1,
dataloader_num_workers=self.dataloader_num_workers,
dataloader_pin_memory=False,
dataloader_prefetch_factor=self.dataloader_prefetch_factor,
dataloader_persistent_workers=self.dataloader_num_workers > 0,
optim="adamw_torch",
adam_beta1=0.95,
adam_beta2=0.999,
adam_epsilon=1e-8,
learning_rate=self.learning_rate,
weight_decay=self.weight_decay,
warmup_ratio=self.warmup_ratio,
lr_scheduler_type="cosine",
logging_steps=10.0,
num_train_epochs=300,
max_steps=self.max_steps,
save_strategy="steps",
save_steps=self.save_steps,
# evaluation_strategy="no",
save_total_limit=5,
report_to=self.report_to,
seed=42,
do_eval=False,
ddp_find_unused_parameters=False,
ddp_bucket_cap_mb=100,
torch_compile_mode=None,
)
# 2.2 run experiment
experiment = TrainRunner(
train_dataset=train_dataset,
model=model,
training_args=training_args,
resume_from_checkpoint=self.resume,
)
# 2.3 run experiment
experiment.train()
logger.info("Training completed successfully")
def run_training(self):
"""Run the training with optional multi-GPU support (torchrun)."""
# Create output directories (checkpoints and tensorboard logs)
os.makedirs(self.output_dir, exist_ok=True)
available_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1
# Validate GPU configuration
assert (
self.num_gpus <= available_gpus
), f"Number of GPUs requested ({self.num_gpus}) is greater than the available GPUs ({available_gpus})"
assert self.num_gpus > 0, "Number of GPUs must be greater than 0"
print(f"Using {self.num_gpus} GPUs")
if self.num_gpus == 1:
# Single GPU mode - set CUDA_VISIBLE_DEVICES=0
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
# Run training in-process
self._train_once()
else:
# Multi-GPU mode - use torchrun to re-invoke this script with multiple processes
if os.environ.get("IS_TORCHRUN", "0") == "1":
# We are already inside a torchrun worker
self._train_once()
else:
script_path = Path(__file__).absolute()
# Remove any existing CUDA_VISIBLE_DEVICES from environment
if "CUDA_VISIBLE_DEVICES" in os.environ:
del os.environ["CUDA_VISIBLE_DEVICES"]
# Build torchrun args (call torch.distributed.run main directly)
args = [
"--standalone",
f"--nproc_per_node={self.num_gpus}",
"--nnodes=1",
str(script_path),
]
print("Running torchrun with args: ", args)
os.environ["IS_TORCHRUN"] = "1"
try:
torchrun(args=args)
except SystemExit as e:
code = e.code if isinstance(e.code, int) else 0
sys.exit(code)
def run_workflow(self):
"""Run the complete workflow."""
try:
logger.info("Starting GR00T fine-tuning...")
# Step 1: Validate dataset was prepared by entrypoint script
self.validate_dataset()
# Step 2: Run training
self.run_training()
logger.info("GR00T fine-tuning completed successfully!")
except Exception as e:
logger.error(f"Workflow failed: {str(e)}")
sys.exit(1)
def main():
"""Main entry point."""
workflow = FinetuneWorkflow()
workflow.run_workflow()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import sys
import os
# Add workstation module to Python path for import
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
from aws_cdk import App, Environment, Stack
from batch_stack import BatchStack
from workstation import DcvWorkstation, DcvWorkstationProps
app = App()
# Get environment variables with defaults
env = Environment(
account=os.getenv("CDK_DEFAULT_ACCOUNT"),
region=os.getenv("CDK_DEFAULT_REGION", "us-west-2"),
)
# If context values are provided, it will import the existing VPC/EFS directly.
ctx_vpc_id = app.node.try_get_context("vpc_id") or os.getenv("VPC_ID")
ctx_efs_id = app.node.try_get_context("efs_id") or os.getenv("EFS_ID")
ctx_efs_sg_id = app.node.try_get_context("efs_sg_id") or os.getenv("EFS_SG_ID")
ctx_ecr_image_uri = app.node.try_get_context("ecr_image_uri") or os.getenv(
"ECR_IMAGE_URI"
)
ctx_dataset_bucket = app.node.try_get_context("dataset_bucket") or os.getenv(
"DATASET_BUCKET"
)
ctx_s3_upload_uri = app.node.try_get_context("s3_upload_uri") or os.getenv(
"S3_UPLOAD_URI"
)
batch_stack = BatchStack(
app,
"IsaacGr00tBatchStack",
env=env,
vpc_id=ctx_vpc_id, # Optional: existing VPC
efs_id=ctx_efs_id, # Optional: existing EFS
efs_sg_id=ctx_efs_sg_id, # Optional: existing EFS SG
ecr_image_uri=ctx_ecr_image_uri, # Optional: pre-built ECR image
dataset_bucket=ctx_dataset_bucket, # Optional: S3 bucket for dataset access
s3_upload_uri=ctx_s3_upload_uri, # Optional: S3 URI for checkpoint uploads
)
class IsaacLabDcvStack(Stack):
"""DCV stack for gr00t visualization, integrated with BatchStack.
Consumes the standalone workstation module from workstation/ and shares VPC/EFS
with the gr00t BatchStack.
"""
def __init__(
self,
scope,
construct_id: str,
batch_stack: BatchStack,
**kwargs,
) -> None:
super().__init__(scope, construct_id, **kwargs)
# Configure DCV workstation with gr00t-specific settings
# isaac_sim_version="5.1.0", isaac_lab_version="v2.3.0" for both N1.5 and N1.6
# public_dcv_access: True (default) opens ports 8443/6006/8080 publicly.
# Deploy with --context public_dcv_access=false for SSM-only access.
public_dcv = self.node.try_get_context("public_dcv_access")
public_dcv_access = public_dcv != "false" # Default True unless explicitly "false"
props = DcvWorkstationProps(
vpc=batch_stack.vpc, # Share VPC with Batch
efs_id=batch_stack.efs_id, # Share EFS with Batch
efs_sg_id=batch_stack.efs_sg_id, # Share security group
# Preferred: g6.4xlarge, g6.2xlarge, g5.2xlarge or larger.
# If capacity errors occur, try changing availability_zone between us-west-2a/b/c/d.
instance_type="g6.4xlarge", # Fall back to g6/g5.2xlarge if capacity unavailable.
isaac_sim_version="5.1.0", # Latest version with valid NGC container
isaac_lab_version="v2.3.0", # Matches isaac-lab:2.3.0 on NGC
leisaac_enabled=True, # Required for gr00t
public_dcv_access=public_dcv_access,
)
self.dcv_workstation = DcvWorkstation(self, "DCV", props)
# DCV stack consumes shared infrastructure from BatchStack
dcv_stack = IsaacLabDcvStack(
app,
"IsaacLabDcvStack", # ✅ Same stack ID as before
env=env,
batch_stack=batch_stack,
)
app.synth()
Manual Console Setup for AWS Batch (Path 2)
This guide walks you through manually creating AWS Batch infrastructure resources via the AWS Console. This is Path 2: Manual Console + CDK for DCV from the main README.
When to Use This Path
Choose this path if you:
- Prefer manual control over Batch resource creation
- Want to understand each resource and its configuration
- Need to customize Batch resources beyond what the CDK stack provides
- Are using existing AWS resources and want to configure Batch manually
Overview
This guide will help you create: 1. Amazon VPC, Security Group, and EFS - Network and shared storage infrastructure 2. ECR Repository and Container Image - Container registry and fine-tuning image 3. EC2 Launch Template - Instance configuration for Batch compute nodes 4. AWS Batch Compute Environment - Compute resources for running jobs 5. AWS Batch Job Queue and Job Definition - Job execution configuration
After completing these steps, you'll deploy the DCV stack using CDK to enable remote visualization and evaluation. See the main README for the complete workflow.
Prerequisites
- AWS Account with appropriate permissions
---
Step-by-Step Instructions
1. Create Amazon VPC, Security Group and EFS
An Amazon Virtual Private Cloud (VPC) is a virtual network that is used to isolate the resources in your AWS account. Security group controls the traffic that is allowed to reach and leave the resources that it is associated with. Amazon Elastic File System (EFS) is a fully managed, scalable file storage service that can be shared across multiple EC2 instances, making it ideal for distributed training jobs. We will use the VPC to create a private network for the EC2 instances that will run the fine-tuning jobs in AWS Batch, a self-referencing security group to allow the Batch instances to access EFS securely and an EFS to store the checkpoints and logs. To learn more about EFS for AWS Batch, refer to EFS for AWS Batch.
1. Open the VPC console and choose Create VPC. 1. Select the VPC and more option. 2. For Name tag auto-generation, enter BatchVPC (the prefix for VPC resources), for NAT gateways, select In 1 AZ (to reduce cost) 3. Leave the rest as default and click Create VPC. 4. Make a note of the VPC ID (e.g. vpc-xxxxxxxx).
2. On the left navigation pane of the VPC console, select Security groups and choose Create security group. 1. For Security group name enter BatchEFSSecurityGroup, for Description enter Security group for Batch instances and EFS, for VPC select BatchVPC-vpc. 2. Leave the rest as default and select Create security group. 3. In the newly created security group, select Edit inbound rules and Add rule: Type NFS, Source Custom and find BatchEFSSecurityGroup (self-referencing). 4. Select Save rules to apply the changes. 5. Make a note of the Security group ID (e.g. sg-xxxxxxxx).
3. Open the EFS console and choose Create file system. 1. For Name, enter BatchEFS, for VPC, select the BatchVPC you created above. 2. Leave the rest as default and choose Create. 3. Make a note of the File system ID (e.g. fs-xxxxxxxx). 4. Click into the file system, select Network tab and click Manage. 5. Look for Security groups, unselect the default security group and select BatchEFSSecurityGroup for all mount targets and choose Save.
2. Build the fine-tuning container and push to ECR
The fine-tuning container is a Docker image that contains the GR00T dependencies and the fine-tuning workflow script. We will build the container and push it to Amazon Elastic Container Registry (ECR) so it can be used by AWS Batch. You can also customize the Dockerfile and scripts to fine-tune different models or datasets.
NOTE: As all G6e instance family are based on x86 as of 2025, you may need a x86 machine to build the fine-tuning container. If you encounter challenges building the container in your local machine, try cleaning up the docker cache and re-run the script, use AWS CodeBuild, or build the container on the DCV instance deployed in section 4.1 to build the container (run section 4.1 to deploy the DCV instance then come back here).
1. Go to Amazon Elastic Container Registry console and Create repository. Use gr00t-finetune as the repository name, leave the rest as default and click on Create.
2. Click into the newly created repository and click on View push commands on the top right to view the command to authenticate to ECR. The command should look like:
aws ecr get-login-password --region <REGION> | docker login --username AWS --password-stdin <YOUR_CONTAINER_REGISTRY_PREFIX>Run the command to authenticate to ECR and take a note of your container registry prefix, e.g. <YOUR_CONTAINER_REGISTRY_PREFIX>.
3. Replace the <YOUR_CONTAINER_REGISTRY_PREFIX> in the following command with your container registry prefix and run the script to build, test and push the GR00T fine-tuning image. This may take between 5 minutes to 3 hours depending on your machine (primarily for building the flash-attn package).
cd training/gr00t
chmod +x build_container.sh
export DOCKER_REGISTRY=<YOUR_CONTAINER_REGISTRY_PREFIX>
./build_container.sh --test --pushExamine the output of the script to see if the tests pass. If not, resolve the issues and run the script again.
3. Create Launch Template
An EC2 Launch Template is a template that defines the configuration for an EC2 instance that is used to run a job. We will use this template to increase the Linux root volume size to 100 GiB for pulling large fine-tuning containers. To learn more about Launch Templates, refer to Launch templates for AWS Batch.
1. Open the EC2 console. 2. In the navigation pane at the left, select Launch templates and choose Create launch template. 3. Under Name enter BatchLaunchTemplate. 4. For Application and OS Images (Amazon Machine Image) and Instance type, leave them as default (i.e. Don't include in launch template) as AWS Batch will automatically select the appropriate Amazon Linux AMI with ECS agent and NVIDIA driver preinstalled. 5. Scroll down to Storage (volumes) and select Add new volume. For Volume type select gp3 and for Size (GiB) enter 100. For Device name select Specify a custom value... and enter /dev/xvda (standard root device for Amazon Linux 2).
NOTE: Without increasing the root volume size, the container may fail to pull the image with the error"CannotPullContainerError: context canceled" due to the default 8 GiB root volume size.
6. Leave the rest as default and choose Create launch template.
4. Create Compute Environment
An AWS Batch Compute Environment is a collection of compute resources on which jobs are executed. We will use this compute environment to define the compute, network and security settings for the host machine that will run the containers for fine-tuning jobs. To learn more about Compute environments, refer to Compute environments for AWS Batch.
1. Open the AWS Batch console 2. In the navigation pane at the left, select Environments and expand Create environment in the upper right and choose Compute environment. 3. Under Compute environment configuration, choose Amazon Elastic Compute Cloud (EC2) and Confirm your selection. 4. For Name enter IsaacGr00tComputeEnvironment, select an existing Instance role or Create IAM role following the user guide, then choose Next. 5. Under Instance configuration, for Allowed instance types, select g6e family and uncheck optimal. 6. Under Launch templates, for Default launch template, select BatchLaunchTemplate, and choose Next at the bottom.
NOTE: When a compute environment is created, AWS Batch will automatically create a snapshot of the selected launch template for infrastructure stability. If you update the original launch template directly, you will need to explicitly update the compute environment too to generate a new snapshot. See Use Amazon EC2 launch templates with AWS Batch for more details.
7. Under Network configuration, choose BatchVPC-vpc for VPC, select the private subnets for Subnets and the BatchEFSSecurityGroup for Security groups. 8. Review the configurations and choose Create compute environment.
5. Create Job Queue and Job Definition
An AWS Batch Job Queue is a collection of jobs that are executed on a compute environment. An AWS Batch Job Definition is a template that defines the configuration for a job. We will use the job definition to specify container-level requirements and environment variables for individual fine-tuning jobs, then submit them to the job queue for execution. To learn more about Job Queues and Job Definitions, refer to Job queues for AWS Batch and Job definitions for AWS Batch.
1. In the AWS Batch console, go to Job queues and choose Create. 2. For Orchestration type select Amazon EC2. 3. Under Job queue configuration, for Job queue name enter IsaacGr00tJobQueue, for Connected compute environment select IsaacGr00tComputeEnvironment. 4. Leave the rest as default and choose Create job queue. 5. On the left navigation pane, select Job definitions and choose Create. 6. For Orchestration type select Amazon EC2 and Confirm your selection. 7. Under General configuration, for Name enter IsaacGr00tJobDefinition, for Execution timeout enter 21600 (i.e. allow 6 hours for the job to run) and select Next. 8. Under Container configuration, for Image enter the ECR image URI for your fine-tuning container (e.g. <YOUR_CONTAINER_REGISTRY_PREFIX>/gr00t-finetune:latest), for Command delete the existing command (so that it defaults to the command ["/workspace/scripts/run_finetune_workflow.sh"] in the Dockerfile). (Optional) For Job role configuration, if you're downloading datasets or uploading models to a private S3 bucket, you will need to create or assign a IAM role that grants AmazonS3FullAccess, then in production, update this to least-privilege access to only the bucket/prefix where you plan to upload or read. 9. Under Environment configuration, for vCPUs enter 8, for Memory enter 65536 MiB (64 GiB), and for GPUs enter 1, for Environment variables add OUTPUT_DIR:/mnt/efs/gr00t/checkpoints, select Next. 10. Under Filesystem configuration, for Shared memory size enter 65536 MiB (64 GiB). Expand the Additional configuration section; select Add volume and Enable EFS, for Name enter BatchEFS, for Filesystem ID enter the EFS file system ID you noted in step 1.3 (Access is controlled by security groups); select Add Mount points, for Source volume select BatchEFS, for Container path enter /mnt/efs, select Next. 11. Review the configurations and Create job definition.
---
Next Steps: Deploy DCV Stack with CDK
You've successfully created all the AWS Batch infrastructure manually! Now you can deploy the DCV (Amazon Desktop Cloud Visualization) stack using CDK to enable remote visualization and evaluation of your training jobs.
Collect Resource IDs
Before deploying the DCV stack, make sure you have the following resource IDs noted from the steps above:
- VPC ID: From step 1.1 (e.g.,
vpc-xxxxxxxx) - EFS File System ID: From step 1.3 (e.g.,
fs-xxxxxxxx) - Security Group ID: From step 1.2 (e.g.,
sg-xxxxxxxx)
Deploy DCV Stack
Follow the instructions in the main README to:
1. Configure cdk.json with your resource IDs 2. Deploy the DCV stack using CDK
The DCV stack will:
- Create an EC2 instance with GPU support (g6.4xlarge) and Amazon DCV
- Mount the EFS file system you created above
- Enable remote desktop access for monitoring training jobs and running evaluations
- Provide access to TensorBoard and other visualization tools
Submitting Training Jobs
Once both Batch and DCV infrastructure are set up, you can submit fine-tuning jobs to your Batch job queue. The jobs will:
- Use the container image you built and pushed to ECR
- Store checkpoints and logs in the shared EFS file system
- Be accessible from the DCV instance for monitoring and evaluation
Refer to the GR00T training README for details on submitting jobs and working with the DCV instance.
---
Troubleshooting
If you encounter issues during setup:
- Container build fails: See the note in step 2 about x86 requirements. Consider using AWS CodeBuild or building on the DCV instance.
- EFS mount issues: Verify the security group allows NFS (port 2049) with self-referencing rules.
- Batch job fails to start: Check compute environment capacity and verify launch template is correctly configured.
- Permission errors: Ensure IAM roles have appropriate permissions for ECR, EFS, and S3 access.
For more troubleshooting tips, see the main README.
import os
from aws_cdk import (
aws_ec2 as ec2,
aws_batch as batch,
aws_ecr as ecr,
aws_iam as iam,
aws_efs as efs,
aws_ecs as ecs,
aws_s3 as s3,
Stack,
CfnOutput,
Duration,
Size,
RemovalPolicy,
)
from constructs import Construct
from codebuild_stack import CodeBuildStack
class BatchStack(Stack):
def __init__(
self,
scope: Construct,
construct_id: str,
vpc_id: str = None,
efs_id: str = None,
efs_sg_id: str = None,
ecr_image_uri: str = None,
dataset_bucket: str = None,
s3_upload_uri: str = None,
**kwargs,
) -> None:
"""
CDK stack for the AWS Batch resources used by the GR00T fine-tuning workflow.
This file is intentionally structured to mirror the step-by-step flow in the
blog (Draft Code Walkthrough) so it's easy to follow and customize:
- 2.1 Create VPC and EFS
- 2.2 Build the fine-tuning container and push to ECR
- 2.3 Create Launch Template
- 2.4 Create Compute Environment
- 2.5 Create Job Queue and Job Definition
Args:
vpc_id: Existing VPC ID to reuse (optional)
efs_id: Existing EFS file system ID to reuse (optional)
efs_sg_id: Existing EFS security group ID (required if efs_id is provided)
ecr_image_uri: Existing ECR image URI (e.g. 123456789012.dkr.ecr.us-west-2.amazonaws.com/gr00t-finetune:latest).
If not provided, builds from local Dockerfile.
dataset_bucket: S3 bucket name for dataset read-only access (optional)
s3_upload_uri: S3 URI for checkpoint uploads (e.g., s3://bucket/path). If not provided, creates a new bucket.
"""
super().__init__(scope, construct_id, **kwargs)
# ==============================================================
# region 2.1 Create VPC and EFS
# ==============================================================
# Create or reference VPC. If you already have a VPC, pass its ID; otherwise we
# create a VPC with one NAT gateway (to match "VPC and more" in the console flow).
if vpc_id:
vpc = ec2.Vpc.from_lookup(self, "BatchVPC", vpc_id=vpc_id)
else:
vpc = ec2.Vpc(
self,
"BatchVPC",
vpc_name="BatchVPC",
max_azs=2,
ip_addresses=ec2.IpAddresses.cidr("10.0.0.0/16"),
nat_gateways=1,
subnet_configuration=[
# We keep a small public subnet for NAT/Egress. Jobs run in private subnets.
ec2.SubnetConfiguration(
name="Public",
subnet_type=ec2.SubnetType.PUBLIC,
),
ec2.SubnetConfiguration(
name="Private",
subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
),
],
)
self.vpc = vpc
# Create or import an EFS file system. This is where checkpoints and logs will be stored
# so they persist across jobs and can be visualized from a DCV instance.
if efs_id:
efs_sg = ec2.SecurityGroup.from_security_group_id(
self, "BatchEFSSecurityGroup", efs_sg_id, mutable=True
)
efs_fs = efs.FileSystem.from_file_system_attributes(
self,
"BatchEFS",
file_system_id=efs_id,
security_group=efs_sg,
)
# Expose attributes for cross-stack use
self.efs_id = efs_id
self.efs_sg_id = efs_sg_id
else:
efs_sg = ec2.SecurityGroup(
self,
"BatchEFSSecurityGroup",
vpc=vpc,
description="Security group for Batch instances and EFS",
)
efs_fs = efs.FileSystem(
self,
"BatchEFS",
file_system_name="BatchEFS",
vpc=vpc,
security_group=efs_sg,
performance_mode=efs.PerformanceMode.GENERAL_PURPOSE,
throughput_mode=efs.ThroughputMode.BURSTING,
)
# Expose attributes for cross-stack use
self.efs_id = efs_fs.file_system_id
self.efs_sg_id = efs_sg.security_group_id
# Add a self-referencing NFS rule so instances and EFS within the same SG can communicate.
efs_sg.add_ingress_rule(
peer=efs_sg,
connection=ec2.Port.tcp(2049),
description="Allow NFS within Batch EFS SG",
)
# endregion
# ==============================================================
# region 2.2 Build the fine-tuning container and push to ECR
# ==============================================================
# Container image selection strategy:
# - Prefer an existing ECR image in the same account via ecr_image_uri
# - Else automatically build via CodeBuild (works on any architecture)
if ecr_image_uri:
# Use provided ECR image
# Expected format: <account>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>
repo_and_tag = ecr_image_uri.split("/")[-1]
if ":" in repo_and_tag:
repo_name, tag = repo_and_tag.split(":", 1)
else:
repo_name, tag = repo_and_tag, "latest"
repo = ecr.Repository.from_repository_name(
self, "IsaacGr00tEcrRepo", repository_name=repo_name
)
container_image = ecs.ContainerImage.from_ecr_repository(
repository=repo, tag=tag
)
codebuild_stack = None # No CodeBuild needed
else:
# Automatically build container using CodeBuild
# This works on any architecture (x86, ARM) since build happens in the cloud
# build_target: "n15" for N1.5 Dockerfile (default), "n16" for N1.6, "n17" for N1.7
ctx_build_target = self.node.try_get_context("build_target") or os.getenv("BUILD_TARGET", "n15")
codebuild_stack = CodeBuildStack(
self,
"CodeBuild",
ecr_repository_name="gr00t-finetune",
use_stable=True,
build_target=ctx_build_target,
)
# Use the built image
container_image = ecs.ContainerImage.from_ecr_repository(
repository=codebuild_stack.ecr_repository, tag="latest"
)
ecr_image_uri = codebuild_stack.image_uri
# Store codebuild_stack for conditional outputs later
self.codebuild_stack = codebuild_stack
# endregion
# ==============================================================
# region 2.3 Create Launch Template
# ==============================================================
# Increase the Linux root volume size to 100 GiB for pulling docker containers.
launch_template = ec2.LaunchTemplate(
self,
"BatchLaunchTemplate",
launch_template_name="BatchLaunchTemplate",
block_devices=[
ec2.BlockDevice(
device_name="/dev/xvda", # standard root device for Amazon Linux 2
volume=ec2.BlockDeviceVolume.ebs(
volume_size=100,
delete_on_termination=True,
volume_type=ec2.EbsDeviceVolumeType.GP3,
),
)
],
)
# endregion
# ==============================================================
# region 2.4 Create Compute Environment
# ==============================================================
# IAM role for Batch EC2 instances so they can pull images, mount EFS, access S3, etc.
compute_env = batch.ManagedEc2EcsComputeEnvironment(
self,
"ComputeEnvironment",
compute_environment_name="IsaacGr00tComputeEnvironment",
vpc=vpc,
vpc_subnets=ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS
), # Jobs run in private subnets
launch_template=launch_template,
security_groups=[efs_sg],
instance_types=[
# By default, limit to g6e family for cost savings
# Single-GPU instances
ec2.InstanceType("g6e.2xlarge"),
ec2.InstanceType("g6e.4xlarge"),
ec2.InstanceType("g6e.8xlarge"),
# ec2.InstanceType("g6e.16xlarge"),
# ec2.InstanceType("p5.4xlarge"),
# Multi-GPU instances
ec2.InstanceType("g6e.12xlarge"), # 4 GPUs
# ec2.InstanceType("g6e.24xlarge"), # 4 GPUs
ec2.InstanceType("g6e.48xlarge"), # 8 GPUs
# ec2.InstanceType("p4d.24xlarge"), # 8 GPUs
# ec2.InstanceType("p5.48xlarge"), # 8 GPUs
],
minv_cpus=0,
maxv_cpus=192,
instance_role=iam.Role(
self,
"BatchInstanceRole",
assumed_by=iam.ServicePrincipal("ec2.amazonaws.com"),
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name(
"service-role/AmazonEC2ContainerServiceforEC2Role"
),
iam.ManagedPolicy.from_aws_managed_policy_name(
"AmazonSSMManagedInstanceCore"
),
],
),
# Uncomment for cost-optimized runs on Spot
# spot=True,
# spot_bid_percentage=70,
)
# No explicit allow needed beyond the SG rule above since EFS and instances share the SG.
# endregion
# ==============================================================
# region 2.5 Create Job Queue and Job Definition
# ==============================================================
job_queue = batch.JobQueue(
self,
"JobQueue",
job_queue_name="IsaacGr00tJobQueue",
compute_environments=[
batch.OrderedComputeEnvironment(
compute_environment=compute_env, order=1
)
],
priority=1,
)
# Job role for the container tasks (access to S3 during training/upload)
job_role = iam.Role(
self,
"JobRole",
assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managed_policies=[],
)
# Separate dataset bucket (read-only) from checkpoint upload bucket (read/write).
# 1) If dataset_bucket is provided, allow read-only on that bucket.
# 2) If s3_upload_uri is provided (e.g., s3://bucket/path), allow read/write to its bucket/prefix.
# 3) If s3_upload_uri is not provided, create a new checkpoint bucket and derive s3_upload_uri.
# 4) If neither dataset nor upload buckets are specified, fall back to S3 read-only (useful for dataset downloads).
if dataset_bucket:
job_role.add_to_policy(
iam.PolicyStatement(
actions=["s3:ListBucket"],
resources=[f"arn:aws:s3:::{dataset_bucket}"],
)
)
job_role.add_to_policy(
iam.PolicyStatement(
actions=["s3:GetObject"],
resources=[f"arn:aws:s3:::{dataset_bucket}/*"],
)
)
# Resolve or create checkpoint upload bucket/URI
checkpoint_bucket = None
original_s3_upload_uri = s3_upload_uri # Track original state
if not s3_upload_uri:
# Create a new S3 bucket for checkpoints
checkpoint_bucket = s3.Bucket(
self,
"IsaacGr00tCheckpointBucket",
block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
encryption=s3.BucketEncryption.S3_MANAGED,
enforce_ssl=True,
versioned=True,
removal_policy=RemovalPolicy.RETAIN,
auto_delete_objects=False,
)
# Derive s3_upload_uri from the created bucket
s3_upload_uri = f"s3://{checkpoint_bucket.bucket_name}/gr00t/checkpoints"
if checkpoint_bucket is not None:
# Grant RW to the created checkpoint bucket
checkpoint_bucket.grant_read_write(job_role)
elif s3_upload_uri and s3_upload_uri.startswith("s3://"):
remainder = s3_upload_uri[5:]
if "/" in remainder:
upload_bucket, upload_prefix = remainder.split("/", 1)
else:
upload_bucket, upload_prefix = remainder, ""
job_role.add_to_policy(
iam.PolicyStatement(
actions=["s3:ListBucket"],
resources=[f"arn:aws:s3:::{upload_bucket}"],
)
)
object_resource = (
f"arn:aws:s3:::{upload_bucket}/{upload_prefix}*"
if upload_prefix
else f"arn:aws:s3:::{upload_bucket}/*"
)
job_role.add_to_policy(
iam.PolicyStatement(
actions=[
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts",
],
resources=[object_resource],
)
)
# Only grant general S3 read-only access if no buckets were originally specified
if (
not dataset_bucket
and not original_s3_upload_uri
and checkpoint_bucket is None
):
job_role.add_managed_policy(
iam.ManagedPolicy.from_aws_managed_policy_name("AmazonS3ReadOnlyAccess")
)
# Mount EFS at /mnt/efs inside the container. The workflow script and training output
# (checkpoints, tensorboard logs) are configured to write under this path.
efs_volume = batch.EcsVolume.efs(
name="BatchEFS", file_system=efs_fs, container_path="/mnt/efs"
)
# Prepare container environment with defaults and optional S3 settings
container_environment = {
# Optional default locations on EFS. You can override at submit time.
"OUTPUT_DIR": "/mnt/efs/gr00t/checkpoints"
}
if s3_upload_uri:
container_environment["UPLOAD_TARGET"] = "s3"
container_environment["S3_UPLOAD_URI"] = s3_upload_uri
job_def = batch.EcsJobDefinition(
self,
"IsaacGr00tJobDefinition",
job_definition_name="IsaacGr00tJobDefinition",
container=batch.EcsEc2ContainerDefinition(
self,
"IsaacGr00tContainer",
image=container_image,
memory=Size.gibibytes(64),
cpu=8,
gpu=1,
job_role=job_role,
environment=container_environment,
volumes=[efs_volume],
linux_parameters=batch.LinuxParameters(
self,
"IsaacGr00tLinuxParameters",
shared_memory_size=Size.gibibytes(64),
),
),
timeout=Duration.hours(6),
)
# endregion
# ==============================================================
# region Outputs
# ==============================================================
CfnOutput(self, "VpcId", value=self.vpc.vpc_id)
CfnOutput(self, "EFSFileSystemId", value=self.efs_id)
CfnOutput(self, "EFSSecurityGroupId", value=self.efs_sg_id)
# Additional outputs for convenience when submitting jobs via CLI/Console
CfnOutput(
self, "ComputeEnvironmentName", value=compute_env.compute_environment_name
)
CfnOutput(self, "JobQueueName", value=job_queue.job_queue_name)
CfnOutput(self, "JobDefinitionName", value=job_def.job_definition_name)
CfnOutput(self, "EcrImageUri", value=ecr_image_uri)
if s3_upload_uri:
CfnOutput(self, "CheckpointS3UploadUri", value=s3_upload_uri)
# CodeBuild outputs (only when CodeBuild is used)
if codebuild_stack:
CfnOutput(
self,
"CodeBuildProjectName",
value=codebuild_stack.build_project.project_name,
description="CodeBuild project name for building the container",
)
# endregion
version: 0.2
# Build specification for GR00T fine-tuning container
# This buildspec is used by AWS CodeBuild to build and push the Docker image to ECR
phases:
pre_build:
commands:
- echo "Starting pre-build phase..."
- echo "Build started on $(date)"
- echo "Logging in to Amazon ECR..."
# Get AWS account ID and region
- export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
- "export AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-west-2}"
- 'echo "AWS Account ID: $AWS_ACCOUNT_ID"'
- 'echo "AWS Region: $AWS_DEFAULT_REGION"'
# Login to ECR
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
# Set image variables
- "export IMAGE_REPO_NAME=${ECR_REPOSITORY_NAME:-gr00t-finetune}"
- "export IMAGE_TAG=${IMAGE_TAG:-latest}"
- "export IMAGE_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG"
- 'echo "Image URI: $IMAGE_URI"'
# Docker info
- docker --version
- docker info
build:
commands:
- echo "Starting build phase..."
- echo "Building Docker image using build_container.sh..."
# Use the existing build_container.sh script to maintain consistency with Path 3
# Select the build directory based on BUILD_TARGET (n16=N16/, n17=N17/; default is n15 root)
- |
if [ "${BUILD_TARGET}" = "n17" ]; then
cd $CODEBUILD_SRC_DIR/N17
elif [ "${BUILD_TARGET}" = "n16" ]; then
cd $CODEBUILD_SRC_DIR/N16
else
cd $CODEBUILD_SRC_DIR
fi
- chmod +x build_container.sh
# Build with the script, passing appropriate flags
- 'if [ "${USE_STABLE}" = "true" ]; then ./build_container.sh --test -n $IMAGE_REPO_NAME -t $IMAGE_TAG; else ./build_container.sh --test --latest -n $IMAGE_REPO_NAME -t $IMAGE_TAG; fi'
# Tag the image with build number for versioning
- "docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$CODEBUILD_BUILD_NUMBER"
- "docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $IMAGE_URI"
- echo "Docker image built and tested successfully"
- docker images
post_build:
commands:
- echo "Starting post-build phase..."
- echo "Pushing Docker image to ECR..."
# Push the image with both tags (latest and build number)
- "docker push $IMAGE_URI"
- "docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$CODEBUILD_BUILD_NUMBER"
- echo "Docker image pushed successfully"
- 'echo "Image URI: $IMAGE_URI"'
- 'echo "Build number tag: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$CODEBUILD_BUILD_NUMBER"'
# Write image URI to file for easy retrieval
- echo $IMAGE_URI > image_uri.txt
- echo "Build completed on $(date)"
# Artifacts to export (optional, for reference)
artifacts:
files:
- image_uri.txt
name: gr00t-container-build-artifacts
{
"app": "python3 app.py"
}
import os
from aws_cdk import (
aws_codebuild as codebuild,
aws_ecr as ecr,
aws_iam as iam,
aws_s3_assets as s3_assets,
custom_resources as cr,
CfnOutput,
RemovalPolicy,
Duration,
)
from constructs import Construct
class CodeBuildStack(Construct):
def __init__(
self,
scope: Construct,
construct_id: str,
ecr_repository_name: str = "gr00t-finetune",
use_stable: bool = True,
build_target: str = "n15",
) -> None:
"""
CDK construct for AWS CodeBuild project to build GR00T fine-tuning container.
This construct creates:
- ECR repository for storing container images
- CodeBuild project with x86 compute for building containers
- IAM roles and permissions
- S3 bucket for source code (if using local source)
Args:
ecr_repository_name: Name for the ECR repository (default: gr00t-finetune)
use_stable: Use stable GR00T commit vs latest (default: True)
build_target: "n15" for N1.5 Dockerfile, "n16" for N16/, "n17" for N17/ (default: n15)
"""
super().__init__(scope, construct_id)
# ==============================================================
# 1. ECR Repository
# ==============================================================
# Create ECR repository to store the built container images
ecr_repo = ecr.Repository(
self,
"IsaacGr00tEcrRepository",
repository_name=ecr_repository_name,
removal_policy=RemovalPolicy.RETAIN, # Keep images after stack deletion
image_scan_on_push=True, # Scan for vulnerabilities
lifecycle_rules=[
# Keep last 10 images, delete older ones
ecr.LifecycleRule(
description="Keep last 10 images",
max_image_count=10,
rule_priority=1,
)
],
)
# ==============================================================
# 2. Source Code Asset
# ==============================================================
# Package the local source code and upload to a CDK managed S3 bucket
# CodeBuild will download from S3 to build the container
asset_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..")
) # training/gr00t/
source_asset = s3_assets.Asset(
self,
"IsaacGr00tSourceAsset",
path=asset_path,
exclude=[
".git",
".gitignore",
"*.pyc",
"__pycache__",
"cdk.context.json",
".venv",
"venv",
"*.egg-info",
".pytest_cache",
".mypy_cache",
"cdk.out",
"infra/.cdk.staging",
],
)
# ==============================================================
# 3. CodeBuild Project
# ==============================================================
# Create CodeBuild project to build the Docker image
build_project = codebuild.Project(
self,
"IsaacGr00tContainerBuild",
project_name="IsaacGr00tContainerBuild",
description="Build GR00T fine-tuning container and push to ECR",
# Source: Use the S3 asset created above
# Customize the source to use your own Git repository
source=codebuild.Source.s3(
bucket=source_asset.bucket,
path=source_asset.s3_object_key,
),
# Build environment
environment=codebuild.BuildEnvironment(
# Use x86_64 architecture (required for EC2 G6e, P4 and P5 instances)
build_image=codebuild.LinuxBuildImage.STANDARD_7_0, # Ubuntu 22.04, Docker 24
compute_type=codebuild.ComputeType.LARGE, # 8 vCPU, 15 GB RAM
privileged=True, # Required for Docker builds
),
# Build specification (located in infra/ directory)
build_spec=codebuild.BuildSpec.from_source_filename("infra/buildspec.yml"),
# Environment variables
environment_variables={
"ECR_REPOSITORY_NAME": codebuild.BuildEnvironmentVariable(
value=ecr_repository_name
),
"USE_STABLE": codebuild.BuildEnvironmentVariable(
value="true" if use_stable else "false"
),
"IMAGE_TAG": codebuild.BuildEnvironmentVariable(value="latest"),
"BUILD_TARGET": codebuild.BuildEnvironmentVariable(value=build_target),
},
# Timeout (building flash-attn takes time)
timeout=Duration.hours(2),
# Cache for faster rebuilds (optional)
cache=codebuild.Cache.local(
codebuild.LocalCacheMode.DOCKER_LAYER,
codebuild.LocalCacheMode.CUSTOM,
),
)
# ==============================================================
# 4. IAM Permissions
# ==============================================================
# Grant CodeBuild permissions to push to ECR
ecr_repo.grant_pull_push(build_project.role)
# Grant permissions to read source from S3
source_asset.grant_read(build_project.role)
# Add ECR authorization token permission (required for docker login)
build_project.role.add_to_policy(
iam.PolicyStatement(
actions=["ecr:GetAuthorizationToken"],
resources=["*"],
)
)
# ==============================================================
# 5. Auto-trigger Build on Stack Creation/Update
# ==============================================================
# Automatically trigger a CodeBuild build when the stack is created or updated
# The build triggers when files in the asset path change because the physical
# resource ID includes the asset hash, which changes when source files change
trigger_build = cr.AwsCustomResource(
self,
"AutoTriggerBuild",
# Use from_sdk_calls for simpler policy management
# This automatically grants the necessary permissions for the SDK call
policy=cr.AwsCustomResourcePolicy.from_sdk_calls(
resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE
),
# Lambda timeout should be minimal - it just triggers the build, doesn't wait for completion
timeout=Duration.minutes(5),
# Trigger on both CREATE and UPDATE
# The physical resource ID includes the asset's S3 object key hash, so it changes when files change
on_create=cr.AwsSdkCall(
service="CodeBuild",
action="startBuild",
parameters={
"projectName": build_project.project_name,
},
# Use S3 object key hash in physical resource ID so it changes when files change
# The s3_object_key contains a hash that changes when asset contents change
# This ensures builds trigger on every deploy when source files are modified
physical_resource_id=cr.PhysicalResourceId.of(
f"{build_project.project_name}-build-{source_asset.s3_object_key}"
),
),
# on_update: no-op — only trigger builds on CREATE (first deploy) or when source files
# change (asset hash changes → physical resource ID changes → replacement → on_create fires).
# Using batchGetProjects as a read-only no-op so repeated deploys don't re-trigger builds.
on_update=cr.AwsSdkCall(
service="CodeBuild",
action="batchGetProjects",
parameters={
"names": [build_project.project_name],
},
physical_resource_id=cr.PhysicalResourceId.of(
f"{build_project.project_name}-build-{source_asset.s3_object_key}"
),
),
# Install latest AWS SDK in Lambda runtime for latest API support
install_latest_aws_sdk=True,
)
# Ensure the build project exists before triggering
trigger_build.node.add_dependency(build_project)
# ==============================================================
# 6. Outputs
# ==============================================================
CfnOutput(
self,
"CodeBuildProjectName",
value=build_project.project_name,
description="CodeBuild project name for building the container",
)
CfnOutput(
self,
"BuildCommand",
value=f"aws codebuild start-build --project-name {build_project.project_name}",
description="Command to trigger a container build",
)
CfnOutput(
self,
"EcrRepositoryUri",
value=ecr_repo.repository_uri,
description="ECR repository URI for the GR00T container image",
)
CfnOutput(
self,
"ImageUri",
value=f"{ecr_repo.repository_uri}:latest",
description="Full ECR image URI (use this for Batch stack deployment)",
)
# Store attributes for cross-stack references
self.ecr_repository = ecr_repo
self.build_project = build_project
self.image_uri = f"{ecr_repo.repository_uri}:latest"
AWS CDK Stacks for GR00T Fine-tuning
This directory contains AWS Cloud Development Kit (CDK) stacks to deploy infrastructure for fine-tuning and evaluating NVIDIA Isaac GR00T models on AWS.
Architecture Overview
!Architecture
The infrastructure consists of two CDK stacks:
1. BatchStack (batch_stack.py) - Creates AWS Batch resources for scalable fine-tuning jobs 2. IsaacLabDcvStack (defined in app.py) - Deploys an Amazon EC2 instance with Amazon DCV for visualization and evaluation, powered by the standalone workstation module at `workstation/`
Both stacks share common resources (VPC, EFS, Security Groups) to enable seamless data flow between training and evaluation workflows.
Note: The DCV workstation infrastructure was extracted into a standalone, reusable module atworkstation/in the repo root. The gr00tapp.pyimports and consumes this module — N1.5 (IsaacSim 4.5.0 / v2.2.0) by default, or N1.6 (5.1.0 / v2.3.0) by editingapp.py. See `workstation/README.md` for standalone usage and full documentation.
Stack Dependencies
BatchStack
Creates the following resources:
- Amazon VPC with public and private subnets (optional, can import existing)
- Amazon EFS file system for shared storage (optional, can import existing)
- Security group for EFS access
- (Optional) Amazon S3 bucket for storing the model checkpoints
- (Optional) AWS CodeBuild project for building the container image
- Amazon ECR repository and container image (or references existing)
- EC2 Launch Template with increased root volume
- AWS Batch Compute Environment (EC2 with GPU instances)
- AWS Batch Job Queue and Job Definition
- IAM roles for Batch instances and job execution
Dependencies: None (fully self-contained)
IsaacLabDcvStack (via standalone workstation/ module)
Consumes the standalone workstation module at workstation/ and creates:
- Amazon EC2 instance (g6.4xlarge with GPU and Amazon DCV) for remote visualization
- Security group for DCV (port 8443) and TensorBoard (port 6006) access
- Elastic IP for stable connectivity
- IAM role for EC2 instance (S3, SSM, ECR)
- Mounts shared EFS file system from BatchStack
- IsaacSim 4.5.0 / IsaacLab v2.2.0 with leisaac pre-installed (default). For N1.6: 5.1.0 / v2.3.0
Dependencies:
- VPC from BatchStack (shared network)
- EFS and Security Group from BatchStack (for shared storage with Batch jobs)
Configuration: Gr00t-specific settings are in app.py — N1.5 (IsaacSim 4.5.0 / v2.2.0) by default, leisaac enabled. For N1.6, edit DcvWorkstationProps to use isaac_sim_version="5.1.0", isaac_lab_version="v2.3.0". The standalone workstation/ module supports IsaacSim 5.1.0 and 4.5.0 — see `workstation/README.md` for the full version compatibility matrix.
Deployment Paths
Choose the path that best fits your environment and requirements:
Path 1: Fully Automated Deployment (Recommended)
Deploy both stacks automatically with CDK. Works on any architecture (x86 or ARM) - the infrastructure automatically handles container building in the cloud when needed.
# Install dependencies
cd training/gr00t/infra
pip install -r requirements.txt
# Set AWS region for deployment
export AWS_REGION=us-west-2 # or your preferred region
# Bootstrap CDK (one-time per account/region)
cdk bootstrap
# Deploy Batch and DCV stacks (creates VPC, EFS, and Batch resources)
cdk deploy IsaacGr00tBatchStack IsaacLabDcvStackWhat happens automatically:
- If you don't provide an
ecr_image_uri, the stack will:
1. Create a CodeBuild project with x86 compute 2. Build the container image in the cloud from the Dockerfile 3. Push the image to ECR 4. Use the built image for Batch jobs
- Build time: First deployment takes 10-20 minutes for the container build. No need for local x86 Docker or early DCV deployment
Using an existing container image:
# Skip automatic build by providing a pre-built image
cdk deploy IsaacGr00tBatchStack IsaacLabDcvStack \
--context ecr_image_uri=123456789012.dkr.ecr.us-west-2.amazonaws.com/gr00t-finetune:latestUsing existing AWS resources:
# Import existing VPC, EFS, and other resources
cdk deploy IsaacGr00tBatchStack IsaacLabDcvStack \
--context vpc_id=vpc-12345 \
--context efs_id=fs-12345 \
--context efs_sg_id=sg-12345 \
--context dataset_bucket=my-dataset-bucket \
--context s3_upload_uri=s3://my-checkpoint-bucket/gr00t/checkpointsMonitoring the Build
After deploying, you can monitor the build progress:
# Monitor build logs in real-time
aws logs tail /aws/codebuild/$PROJECT_NAME --followTriggering Manual Rebuilds
You can customize the container build process by modifying the CodeBuild CDK stack and buildspec.yml file and rebuild the container with:
# Get the CodeBuild project name from stack outputs
export PROJECT_NAME=$(aws cloudformation describe-stacks \
--stack-name IsaacGr00tBatchStack \
--query 'Stacks[0].Outputs[?OutputKey==`CodeBuildProjectName`].OutputValue' \
--output text)
# Use the build command from stack outputs
aws codebuild start-build --project-name $PROJECT_NAMEPath 2: Manual Console + CDK for DCV
Create AWS Batch resources manually via AWS Console (following the console walkthrough), then deploy only the DCV stack with CDK.
# After manually creating VPC, EFS, and Batch resources in the console,
# add their IDs to cdk.json:
cat > cdk.json << EOF
{
"app": "python app.py",
"context": {
"vpc_id": "vpc-xxxxxxxx",
"efs_id": "fs-xxxxxxxx",
"efs_sg_id": "sg-xxxxxxxx"
}
}
EOF
# Set AWS region for deployment
export AWS_DEFAULT_REGION=us-west-2 # or your preferred region
# Deploy only the DCV stack
cdk deploy IsaacLabDcvStackPath 3: Standalone DCV Workstation (No Batch)
Deploy just a DCV workstation without the Batch training pipeline, using the standalone module:
cd workstation
pip install -r requirements.txt
AWS_DEFAULT_REGION=us-west-2 cdk deploy --profile <your-profile>See `workstation/README.md` for full standalone usage, configuration options, and version selection.
Deployment Context
When deploying the CDK stack, you can configure infrastructure resources using CDK context parameters.
cdk deploy IsaacGr00tBatchStack IsaacLabDcvStack \
--context vpc_id=vpc-12345 \
--context efs_id=fs-12345 \
--context efs_sg_id=sg-12345 \
--context ecr_image_uri=123456789012.dkr.ecr.us-west-2.amazonaws.com/gr00t-finetune:latest \
--context dataset_bucket=my-dataset-bucket \
--context s3_upload_uri=s3://my-checkpoint-bucket/gr00t/checkpointsConfiguration Options
| Context Parameter | Env Variable | Description | Default |
|---|---|---|---|
vpc_id | VPC_ID | Existing VPC ID to reuse | Creates new VPC |
efs_id | EFS_ID | Existing EFS file system ID | Creates new EFS |
efs_sg_id | EFS_SG_ID | EFS security group ID (required if efs_id is set) | Creates new SG |
ecr_image_uri | ECR_IMAGE_URI | Pre-built ECR image URI (in the same region as the deployment) | Automatically builds via CodeBuild |
dataset_bucket | DATASET_BUCKET | S3 bucket name for dataset read-only access | No dataset bucket access |
s3_upload_uri | S3_UPLOAD_URI | S3 URI for checkpoint uploads | Creates new checkpoint bucket |
Note: CDK context parameters take precedence over environment variables. This allows for flexible deployment configurations while maintaining consistency through context values in your cdk.json or CLI commands.
cdk.json
You can also use context in cdk.json to provide existing resource IDs to import rather than create new ones:
{
"app": "python app.py",
"context": {
"vpc_id": "vpc-xxxxxxxx",
"efs_id": "fs-xxxxxxxx",
"efs_sg_id": "sg-xxxxxxxx",
"ecr_image_uri": "123456789012.dkr.ecr.us-west-2.amazonaws.com/gr00t-finetune:latest",
"dataset_bucket": "my-dataset-bucket",
"s3_upload_uri": "s3://my-checkpoint-bucket/gr00t/checkpoints"
}
}Resource Sharing Between Stacks
When both stacks are deployed together, they share:
1. VPC: DCV instance and Batch compute nodes run in the same network 2. EFS: Mounted at /mnt/efs on both DCV instance and Batch containers 3. Security Group: Allows both DCV and Batch instances to access EFS
This enables:
- Real-time monitoring of training jobs via TensorBoard on the DCV instance
- Direct access to model checkpoints for evaluation
- Seamless data flow between training and evaluation workflows
The DCV workstation runs IsaacSim/Lab in containers via the run-isaaclab.sh helper. TensorBoard (N1.5) runs in a host venv on port 6006. Override the container version with ISAAC_LAB_IMAGE env var for N1.6 evaluation.
Prerequisites
1. AWS Account: With appropriate permissions to create VPC, EC2, EFS, Batch, IAM resources 2. AWS CLI: Configured with credentials (aws configure) 3. Node.js: For AWS CDK CLI (npm install -g aws-cdk) 4. Python 3.8+: For CDK app and dependencies 5. Docker: For building container images (if not using pre-built images) 6. Service Quotas: At least 8 vCPUs for "Running On-Demand G and VT instances" in your target region
Deployment Checklist
- [ ] Install AWS CDK CLI:
npm install -g aws-cdk - [ ] Install Python dependencies:
pip install -r requirements.txt - [ ] Set AWS region:
export AWS_REGION=us-west-2 - [ ] Bootstrap CDK:
cdk bootstrap - [ ] Request GPU instance quota (g6e.2xlarge or larger)
- [ ] (Optional) Build and push container image to ECR
- [ ] (Optional) Configure cdk.json with existing resource IDs
- [ ] Deploy stacks:
cdk deploy <StackName> - [ ] Verify stack outputs for connection details
Cleanup
To avoid ongoing charges, destroy the stacks in reverse order:
# Set AWS region for deployment
export AWS_REGION=us-west-2 # or your preferred region
# Destroy DCV stack first (terminates EC2 instance)
cdk destroy IsaacLabDcvStack --force
# Destroy Batch stack (removes Batch resources, EFS, VPC if created by CDK)
cdk destroy IsaacGr00tBatchStack --forceImportant: If you manually created resources or imported existing ones via context, those resources will NOT be deleted by cdk destroy. Delete them manually if they were created specifically for this project.
Troubleshooting
Container Build Fails
- Issue: Building flash-attn takes too long or fails on ARM machines
- Solution: The stack automatically uses CodeBuild for cloud-based x86 builds. Check CodeBuild logs for specific errors
Build fails with "Docker rate limit exceeded":
- The buildspec.yml includes a Docker registry mirror configuration to avoid rate limits
- If issues persist, authenticate with Docker Hub or use AWS ECR Public
Build timeout:
- Default timeout is 2 hours, sufficient for most builds
- Check CloudWatch Logs for specific error messages
Permission errors:
- Verify the CodeBuild role has ECR push permissions
- Check that the ECR repository exists and is in the same region
DCV Connection Issues
- Issue: "No session found" error when connecting to DCV
- Solution: Wait 10-15 minutes for user data script to complete. Check
/var/log/dcv-bootstrap.summaryfor status
EFS Mount Fails
- Issue: EFS not accessible from Batch jobs or DCV instance
- Solution: Verify security group allows NFS (port 2049) from itself. Check EFS mount targets are in the correct subnets
Batch Job Fails to Start
- Issue: Job stuck in RUNNABLE state
- Solution: Check compute environment has available capacity. Verify launch template and instance types are correct
Permission Denied Errors
- Issue: Job cannot access S3 or ECR
- Solution: Verify IAM roles have appropriate policies. For S3, set
DATASET_BUCKETenvironment variable
Support and Contributions
For issues, questions, or contributions, please refer to the main repository README and contribution guidelines.
Additional Resources
aws-cdk-lib>=2.0.0
constructs>=10.0.0# Exclude infrastructure and build artifacts
infra/
cdk.out/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git/
.mypy_cache/
.pytest_cache/
.hypothesis/
**/.DS_Store
#!/bin/bash
# Build script for GR00T Fine-tuning Docker image
set -Eeuo pipefail
echo "=========================================="
echo "Building GR00T Fine-tuning Docker Image"
echo "=========================================="
# Default values
IMAGE_NAME="gr00t-finetune"
TAG="latest"
DOCKERFILE="Dockerfile"
PUSH_IMAGE=false
TEST_IMAGE=false
USE_STABLE=true
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
TAG="$2"
shift 2
;;
-n|--name)
IMAGE_NAME="$2"
shift 2
;;
--latest)
USE_STABLE=false
shift
;;
--push)
PUSH_IMAGE=true
shift
;;
--test)
TEST_IMAGE=true
shift
;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " -t, --tag TAG Tag for the fine-tuning image (default: latest)"
echo " -n, --name NAME Name for the fine-tuning image (default: gr00t-finetune)"
echo " --latest Use latest GR00T from main branch (default: stable commit)"
echo " --push Push image to registry after building"
echo " --test Run basic tests after building"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
FULL_IMAGE_NAME="${IMAGE_NAME}:${TAG}"
echo "Building image: ${FULL_IMAGE_NAME}"
echo "Using Dockerfile: ${DOCKERFILE}"
# Display GR00T version selection
if [[ "${USE_STABLE}" == "true" ]]; then
echo "GR00T version: STABLE (N1.6, tested commit) [default]"
else
echo "GR00T version: LATEST (main branch, may have breaking changes)"
fi
# Build the fine-tuning image directly from the combined Dockerfile
echo "Building fine-tuning image..."
docker build \
--build-arg USE_STABLE=${USE_STABLE} \
-f ${DOCKERFILE} \
-t ${FULL_IMAGE_NAME} \
.
echo "Image built successfully: ${FULL_IMAGE_NAME}"
# Run basic tests if requested
if [[ "${TEST_IMAGE}" == "true" ]]; then
echo "=========================================="
echo "Running Basic Tests"
echo "=========================================="
# Test 1: Check if the image runs without errors (dry run)
echo "Test 1: Checking if image starts correctly..."
docker run --rm \
-e HF_TOKEN="dummy" \
-e HF_DATASET_ID="dummy/dummy" \
-e HF_MODEL_REPO_ID="dummy/dummy" \
--entrypoint /bin/bash \
${FULL_IMAGE_NAME} \
-c "echo 'Image starts correctly' && python -c 'import sys; print(f\"Python version: {sys.version}\")' && which huggingface-cli"
# Test 2: Check if finetune script can be imported
echo "Test 2: Checking if finetune script imports correctly..."
docker run --rm \
--entrypoint /bin/bash \
${FULL_IMAGE_NAME} \
-c "PYTHONPATH=/workspace/gr00t-repo:/workspace python -c 'import importlib.util; spec = importlib.util.spec_from_file_location(\"finetune\", \"/workspace/scripts/finetune_gr00t.py\"); mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); print(\"Finetune script imports successfully\")'"
# Test 3: Check if modality config can be loaded
echo "Test 3: Checking if modality config is accessible..."
docker run --rm \
--entrypoint /bin/bash \
${FULL_IMAGE_NAME} \
-c "PYTHONPATH=/workspace/gr00t-repo:/workspace python -c 'import importlib.util; spec = importlib.util.spec_from_file_location(\"m\", \"/workspace/scripts/so101_modality_config.py\"); print(\"Modality config accessible\")'"
echo "All tests passed!"
fi
# Push to registry if requested
if [[ "${PUSH_IMAGE}" == "true" ]]; then
echo "=========================================="
echo "Pushing to Registry"
echo "=========================================="
if [[ -z "${DOCKER_REGISTRY}" ]]; then
echo "Warning: DOCKER_REGISTRY environment variable not set."
echo "Assuming you want to push to Docker Hub or have already tagged appropriately."
else
# Re-tag with registry prefix
REGISTRY_IMAGE="${DOCKER_REGISTRY}/${FULL_IMAGE_NAME}"
docker tag ${FULL_IMAGE_NAME} ${REGISTRY_IMAGE}
FULL_IMAGE_NAME=${REGISTRY_IMAGE}
fi
echo "Pushing image: ${FULL_IMAGE_NAME}"
docker push ${FULL_IMAGE_NAME}
echo "Image pushed successfully!"
fi
echo "=========================================="
echo "Build Complete!"
echo "=========================================="
echo "Image: ${FULL_IMAGE_NAME}"
if [[ "${USE_STABLE}" == "true" ]]; then
echo "GR00T Version: STABLE (tested commit)"
else
echo "GR00T Version: LATEST (main branch)"
fi
echo ""
echo "To run locally, create a local directory to simulate EFS mount:"
echo "mkdir -p ~/mock-efs/gr00t/checkpoints"
echo "Then run with a small number of steps for testing:"
echo "docker run --gpus all --network host \\"
echo " -e MAX_STEPS=100 -e SAVE_STEPS=100 \\"
echo " -v ~/mock-efs:/mnt/efs \\"
echo " ${FULL_IMAGE_NAME}"
echo ""
echo "To rebuild with latest GR00T version from main branch:"
echo " ./build_container.sh --latest" """
Modality configuration for SO-ARM101 (6-DOF) robot.
Based on upstream examples/SO100/so100_config.py — the SO-ARM100/101
share the same 6-DOF joint structure (5 arm joints + 1 gripper).
This file is loaded by the training script via N1.6's
register_modality_config() mechanism.
"""
from gr00t.configs.data.embodiment_configs import register_modality_config
from gr00t.data.embodiment_tags import EmbodimentTag
from gr00t.data.types import (
ActionConfig,
ActionFormat,
ActionRepresentation,
ActionType,
ModalityConfig,
)
so101_config = {
"video": ModalityConfig(
delta_indices=[0],
modality_keys=["front", "wrist"],
),
"state": ModalityConfig(
delta_indices=[0],
modality_keys=["single_arm", "gripper"],
),
"action": ModalityConfig(
delta_indices=list(range(0, 16)),
modality_keys=["single_arm", "gripper"],
action_configs=[
ActionConfig(
rep=ActionRepresentation.RELATIVE,
type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT,
),
ActionConfig(
rep=ActionRepresentation.ABSOLUTE,
type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT,
),
],
),
"language": ModalityConfig(
delta_indices=[0],
modality_keys=["annotation.human.task_description"],
),
}
register_modality_config(so101_config, embodiment_tag=EmbodimentTag.NEW_EMBODIMENT)
# Exclude infrastructure and build artifacts
infra/
cdk.out/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git/
.mypy_cache/
.pytest_cache/
.hypothesis/
**/.DS_Store
Related skills
FAQ
In what order do the stacks deploy?
Batch stack first, then the DCV stack, because the DCV stack depends on the Batch stack's VPC and EFS; teardown reverses the order.
Does CDK dry-run test GPU capacity?
No. Dry-run only validates IAM permissions; the only reliable capacity check is a real launch plus immediate terminate.