Original date published: June 3, 2026
Building KAARL - A multi-node AI cluster infrastructure for rapid prototyping
Building KAARL - A multi-node AI cluster infrastructure with 4 x 4060 Ti, CUDA, Kubernetes and Ray to power AI workloads and experiments

KAARL Multi-Node GPU cluster
Weights of Frustration & Sparks of Curiosity
While working on a project to train a small transformer model from the Andrej Karpathy NanoGPT tutorial, I got frustrated with the constant dependency on cloud providers for GPUs. You are either constantly battling for availability or having to perform tricks to prevent your current GPU allocation from being reallocated. If you've worked on AI-heavy projects, you'd know that in the very early experimentation phases, you need stable GPU availability to run multiple different experiments. After jumping across several subscriptions from various cloud providers, I was sufficiently pissed off. I had also been reading a lot about GPUs, infrastructure, home servers, local AI, etc.
As any sane person would conclude, the next obvious step is to build my own GPU infrastructure. I needed something that was good enough for me to run small experiments locally that I could then move to larger instances when I was sure of the nature of the experiment or wanted to run bigger model variants. The platform would serve as a testbed for my curiosities.
So, I went to Amazon and started picking out the components I would need. I settled on 4 x 4060 Ti GPUs (16GB VRAM) distributed across two nodes, with each node having 2 GPUs. Each node also has 2 x 32GB of memory and 512GB of SSD storage attached. I also included an 8TB HDD storage disk attached to one of the nodes to serve as a network file system; this would be used to store the various model weights and datasets I would be using for my experiments. I also chipped in 4 x NVIDIA Jetson Nanos for running edge robotics projects.
There are a lot more materials and components used for building this. You can find the BOM here. You can use this as a reference for your own build as well. The rest of this post goes over the details of how I built the infrastructure and set up CUDA, Kubernetes and Ray to run AI projects.
The hardware
Component list
The cluster primarily consists of two identical nodes. Each node was built from individual components.
| Component | Spec |
|---|---|
| CPU | AMD Ryzen 7 5800X (8-core, 16-thread, 3.8GHz base, 2 x PCIe lane motherboard) |
| Motherboard | MSI MAG B550 Tomahawk MAX WiFi |
| RAM | CORSAIR VENGEANCE LPX (32 x 2) DDR4 memory |
| Storage (OS) | Ediloca EN605 512GB |
| Storage (Data) | Seagate Portable Drive 8TB, External Hard Drive, Dark Grey |
| Case | XIANXIAN Mining Rig Frame 12 GPU |
| GPU | MSI GeForce RTX 4060 Ti (16G Graphics Card - RTX 4060 Ti GPU, 16GB GDDR6 (18Gbps/128-bit)) |
| PSU | Corsair RM850e |
Total across cluster:
- 4× RTX 4060 Ti (64GB VRAM total)
- 16 CPU cores per node (32 total)
- 128GB RAM total
- ~6.5TB usable storage

KAARL Cluster In Progress
GPU Purchase Notes
The 4× RTX 4060 Ti 16GB cards were purchased separately from Amazon. Key considerations when buying:
- Buy the 16GB variant — the 8GB version exists and is cheaper, but 16GB is essential for running large models. Double-check the listing.
- Check PCIe slot availability — the MSI B550 Tomahawk has 2× PCIe x16 slots, accommodating 2 GPUs per board comfortably.
- PSU headroom — each RTX 4060 Ti has a 165W TDP. With CPU and other components, budget at least 650–750W per node.
Physical Assembly Notes
- The open rack chassis means no traditional case airflow. Ensure GPUs have clearance and adequate airflow across the PCIe slots.
- Seat both GPUs firmly in the PCIe x16 slots and connect the 8-pin/16-pin power connectors from the PSU.
- Connect both nodes to a dedicated managed/unmanaged network switch via Ethernet. This switch handles all cluster communication (Kubernetes, Ray, NCCL for GPU-to-GPU data transfer).

KAARL Cluster Build Complete
BIOS configuration
Once we have the actual hardware set up, we need to configure our BIOS (the software that loads before our operating system) to load NVIDIA drivers for our GPUs when available.
This is one of those subtle steps that can easily be missed and can even add a few hours or days to the setup process. For my cluster, I'm making use of Ubuntu 24.04. The BIOS process
may differ based on the motherboard you are using, but I'll go over the steps I used in my own setup.
Enter BIOS
Power on the machine and press the Delete button repeatedly to enter the MSI BIOS page. Disable Secure Boot (required for NVIDIA driver installation):
Settings → Security → Secure Boot → Disabled
If Secure Boot is left enabled, the NVIDIA kernel module will fail to load after driver installation. You will see a black screen or
nvidia-smiwill returnNo devices found.
BIOS changes
These are not strictly required but improve stability:
-
Enable Above 4G Decoding — allows the system to properly address GPU memory above 4GB over PCIe
Settings → Advanced → PCI Subsystem Settings → Above 4G Memory / Crypto Currency Mining → Enabled -
Enable Virtualization (AMD-V / SVM) — required for some container runtimes and future VM workloads
Settings → OC → CPU Features → SVM Mode → Enabled
Save and exit. The machine will reboot.
Choosing and installing the right operating system
I knew from the get-go I would have to use Linux for the cluster, but then I had to pick the right distro and the right version. I settled on Ubuntu 24.04 due to its recency and support for most deep learning libraries, CUDA and ROS.
Installation process
On another machine, download Ubuntu 24.04 LTS and flash it to a USB drive:
# Download Ubuntu 24.04 LTS
# https://ubuntu.com/download/server
# Flash to USB (replace /dev/sdX with your USB device)
sudo dd if=ubuntu-24.04-live-server-amd64.iso of=/dev/sdX bs=4M status=progress
sync
Alternatively, use Balena Etcher on Windows or macOS. Boot from the USB drive (press F11 on MSI boards for boot menu) and follow the installer:
- Language: English
- Keyboard: Your layout
- Installation type: Ubuntu Server (no GUI needed on worker nodes; install desktop on Node 1 if you want a local display)
- Storage: Use the 512GB SSD as the installation target. Leave the 8TB HDD unconfigured for now (Node 1 only).
- Network: Configure Ethernet. Note the IP addresses assigned to each node.
- Profile setup:
- Node 1: username
kaarl, hostnamekaarl-bignode1 - Node 2: username
kaarl, hostnamekaarl-bignode2
- Node 1: username
- SSH: Enable OpenSSH server during installation
On both nodes:
sudo apt update && sudo apt upgrade -y
# Optionally install the desktop GUI if you are more comformatable with that
sudo apt install ubuntu-desktop
# Restart the server
sudo reboot
Network Configuration
Kubernetes and Ray require stable node addresses. Configure static IPs via Netplan:
sudo nano /etc/netplan/00-installer-config.yaml
network:
version: 2
ethernets:
enp3s0: # Replace with your interface name (check with: ip link show)
dhcp4: no
addresses:
- 192.168.1.101/24 # Node 1
# - 192.168.1.102/24 # Node 2 (on the other machine)
gateway4: 192.168.1.1
nameservers:
addresses: [8.8.8.8, 8.8.4.4]
sudo netplan apply
Verify:
ip addr show
ping kaarl-bignode2 # Ping the other node
To configure /etc/hosts, add entries on both nodes so hostnames resolve without DNS:
sudo nano /etc/hosts
192.168.1.101 kaarl-bignode1
192.168.1.102 kaarl-bignode2
Now let's configure SSH key access between nodes:
# On Node 1: generate key pair
ssh-keygen -t ed25519 -C "kaarl-cluster"
# Copy public key to Node 2
ssh-copy-id kaarl@kaarl-bignode2
# Test
ssh kaarl@kaarl-bignode2
Setting up NVIDIA drivers and CUDA
Now we have our compute nodes built and set up. You can start SSHing into them and running programs on them. Our use case is focused on deep learning and robotics. Therefore, we need an accelerator for performing scientific computing operations, and this is where NVIDIA and CUDA come in. NVIDIA provides the accelerator hardware, which can be used for graphics by default, but we want to write and run scientific computing applications on it, and to do so we will need to install CUDA. CUDA is the library that actually allows us to utilize the vector computation built natively into the GPU to run our accelerated computing workloads. Most of the libraries we would be using for our experiments, such as PyTorch, have already written the code that bridges our application use case to CUDA, so in most cases we would not need to write any CUDA code ourselves. We are setting up CUDA for our AI libraries to use. If you've seen the memes online, you'd know that this is one of the more annoying steps. You'll want to be careful here and verify your work at each step.
CUDA installation process
Repeat on both nodes.
Verify GPUs are detected
lspci | grep -i nvidia
# Should show 2 entries per node:
# VGA compatible controller: NVIDIA Corporation ...
# Audio device: NVIDIA Corporation ...
Install NVIDIA drivers via apt
# Check which driver versions are available
apt-cache search nvidia-driver
# Install the recommended driver (560 series for RTX 4060 Ti on Ubuntu 24.04)
sudo apt install nvidia-driver-560 -y
# Reboot is required after driver installation
sudo reboot
Verify Driver Installation
nvidia-smi
Expected output:
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 560.35.05 Driver Version: 560.35.05 CUDA Version: 12.6 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| 0 NVIDIA GeForce RTX 4060 Ti Off | 00000000:04:00.0 ... | N/A |
| 1 NVIDIA GeForce RTX 4060 Ti Off | 00000000:2B:00.0 ... | N/A |
+-----------------------------------------------------------------------------------------+
If nvidia-smi fails:
- Confirm Secure Boot is disabled in BIOS
- Check
dmesg | grep -i nvidiafor errors - Ensure the driver version matches your kernel:
apt install nvidia-dkms-560
Install CUDA Toolkit
The NVIDIA driver already includes CUDA runtime libraries. The toolkit adds nvcc and development headers for building custom CUDA code:
# Install CUDA toolkit matching driver (12.x for driver 560)
sudo apt install nvidia-cuda-toolkit -y
# Verify
nvcc --version
We now need to persist the GPU settings. Enable persistence mode so the driver stays loaded between reboots:
sudo nvidia-smi -pm 1
# Verify
nvidia-smi | grep Persistence
# Should show: Persistence-M: On
Installing Miniconda for environment management
We will be running multiple projects in parallel with conflicting dependencies, or we could simply want one project to have a
different Python version than the others. Miniconda allows us to easily configure and maintain multiple Python environments without having them tied to any directory (as done with python-venv). Miniconda environments
are global and can be used from anywhere in the system, and each environment can be used by multiple projects irrespective of the folder location of each project. You do not need to track any venv or .venv folder in your
project codebase because Miniconda handles the location and usage of your environments. It is very convenient if you plan to reuse certain environments across projects or if you need specific environment specifications for your project
without affecting the base Python installation. The installation process is as follows.
Repeat on both nodes (or at minimum on Node 1, which you'll use as the control machine).
# Download Miniconda installer
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
# Run installer
bash Miniconda3-latest-Linux-x86_64.sh
# Follow prompts, accept license, choose install location (~miniconda3)
# When asked to initialize conda: yes
# Reload shell
source ~/.bashrc
# Verify
conda --version
# Create new environment
conda create -n <name_of_env> python=<python_version>
# Switch to newly created environment
conda activate <name_of_env>
# Exit environment
conda deactivate
# To delete enviornment first switch to base
conda activate base
# Delete the target environment
conda remove --name <name_of_env> --all
# Ensure the environment is no longer present
conda info --envs
Install K3s (Kubernetes)
We have both nodes configured, running and communicating with each other. For a lot of simple use cases this would be enough. You could simply SSH into each node and execute whatever programs you want. You can have them communicate over the network to transfer data. Even PyTorch has a set of functions to allow training across nodes on a network. Other newer libraries like Exo allow you to run large model inference across multiple accelerated nodes in a network. For our use case we would want to run complex cluster workloads and build a developer experience where we would simply specify what capacity we need and configure the environments, and the orchestration system would provide the runtime and its requirements for us.
This was to mimic the functionality typically provided by the cloud AI infrastructure companies, but running on my own hardware in my own home. We would be using Kubernetes (through K3s, which is a lightweight version of Kubernetes that fits our exact use case) for compute orchestration. Kubernetes is what directly manages our nodes, checks their health, places jobs on them, manages volumes and persistent storage, handles capacity management and recovery, etc. We would run Ray on top of this for the actual cluster, node and job management, but we will cover Ray in later sections.
The setup process for Kubernetes is node-specific and goes as follows.
Install K3s on Node 1 (Master)
# Install K3s as master node
curl -sfL https://get.k3s.io | sh -
# Check status
sudo systemctl status k3s
# Get node token (needed to join Node 2)
sudo cat /var/lib/rancher/k3s/server/node-token
# Copy this token — you'll need it in the next step
# Get master IP
hostname -I | awk '{print $1}'
# e.g. 192.168.1.101
Join Node 2 as Worker
On Node 2:
# Replace K3S_URL and K3S_TOKEN with your values from Node 1
curl -sfL https://get.k3s.io | K3S_URL=https://192.168.1.101:6443 K3S_TOKEN=<TOKEN_FROM_NODE1> sh -
Configure kubectl on Node 1
# Copy kubeconfig to your home directory
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
export KUBECONFIG=~/.kube/config
# Add to .bashrc so it persists
echo 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc
source ~/.bashrc
Verify Cluster
kubectl get nodes
# Expected:
# NAME STATUS ROLES AGE
# kaarl-bignode1 Ready control-plane,master ...
# kaarl-bignode2 Ready <none> ...
Note: K3s uses
containerdas its container runtime, not Docker. You cannot rundocker buildordocker pushcommands. Use pre-built images from registries instead.
Install NVIDIA GPU Operator
The GPU Operator installs and manages the NVIDIA device plugin, which exposes GPUs as schedulable Kubernetes resources.
Install Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Verify
helm version
Install the GPU Operator
# Add NVIDIA Helm repo
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update
# Create namespace
kubectl create namespace gpu-operator
# Install GPU Operator
helm install gpu-operator nvidia/gpu-operator \
--namespace gpu-operator \
--set driver.enabled=false # Driver already installed via apt — don't reinstall
Important: Pass
--set driver.enabled=falsebecause you already installed the driver manually via apt. If you let the GPU Operator install the driver, it will conflict with your existing installation.
Verify GPU Operator
# Wait for all pods to be Running (takes 2-5 minutes)
kubectl get pods -n gpu-operator -w
# Verify GPUs are visible as Kubernetes resources
kubectl describe node kaarl-bignode1 | grep -A 5 "Capacity" | grep nvidia
# Expected: nvidia.com/gpu: 2
kubectl describe node kaarl-bignode2 | grep -A 5 "Capacity" | grep nvidia
# Expected: nvidia.com/gpu: 2
Test GPU Scheduling
# Run a quick GPU test pod
kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: gpu-test
spec:
restartPolicy: OnFailure
containers:
- name: gpu-test
image: nvidia/cuda:12.1-base-ubuntu22.04
command: ["nvidia-smi"]
resources:
limits:
nvidia.com/gpu: 1
EOF
# Check output
kubectl logs gpu-test
# Cleanup
kubectl delete pod gpu-test
Installing the KubeRay Operator
The KubeRay Operator is the service that allows our Kubernetes environment and our Ray cluster to have bidirectional communication and pass messages and instructions between each other. It is a convenience library that allows us to quickly set up Ray correctly in a Kubernetes environment.
Save as ~/ray-cluster-values.yaml:
image:
repository: rayproject/ray
tag: 2.40.0-py311-gpu # this is where the cluster python enviornment is configured, you can change it here if you would like to use a different python version
pullPolicy: IfNotPresent
head:
rayStartParams:
dashboard-host: '0.0.0.0'
num-cpus: '0' # Head node coordinates only, no compute tasks
resources:
limits:
cpu: "4"
memory: "8Gi"
requests:
cpu: "2"
memory: "4Gi"
worker:
replicas: 2
minReplicas: 2
maxReplicas: 4
rayStartParams:
num-gpus: "2"
resources:
limits:
cpu: "16"
memory: "32Gi"
nvidia.com/gpu: "2"
requests:
cpu: "8"
memory: "16Gi"
nvidia.com/gpu: "2"
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: ray.io/node-type
operator: In
values:
- worker
topologyKey: kubernetes.io/hostname
Key decisions in this config:
num-cpus: '0'on head — prevents compute tasks landing on the head nodepodAntiAffinity— ensures the 2 workers land on different physical nodes (1 per node, 2 GPUs each)replicas: 2fixed — no auto-scaling, workers always ready for instant job start
Deploy
kubectl create namespace ray
helm install ray-cluster kuberay/ray-cluster \
--namespace ray \
--values ~/ray-cluster-values.yaml \
--version 1.2.2
The
rayproject/ray:2.40.0-py311-gpuimage is ~5GB. First pull takes 15–20 minutes per node.
Wait for Pods
kubectl get pods -n ray -w
# Wait until all 3 show 1/1 Running:
# ray-cluster-kuberay-head-xxx 1/1 Running
# ray-cluster-kuberay-workergroup-xxx 1/1 Running (on bignode1)
# ray-cluster-kuberay-workergroup-xxx 1/1 Running (on bignode2)
Verify Pod Distribution
kubectl get pods -n ray -o wide
# Confirm workers are on different nodes
Verify GPU Allocation
kubectl describe node kaarl-bignode1 | grep -A 10 "Allocated resources" | grep nvidia
# Expected: nvidia.com/gpu 2 2
kubectl describe node kaarl-bignode2 | grep -A 10 "Allocated resources" | grep nvidia
# Expected: nvidia.com/gpu 2 2
Using Ray from the Host
Now that our Ray operator is running properly on Kubernetes, we would like to be able to submit Ray tasks from our local code using the Ray client library.
Create Python Environment
The Ray client Python version must match the cluster exactly (the version of Python running on your cluster must be the exact same version of Python running in your environment; if this is not configured properly, it can cause nasty, confusing errors).
# Cluster runs Python 3.11 — match it exactly
conda create -n ray-client python=3.11 -y
conda activate ray-client
pip install "ray[default]==2.40.0"
Start Port-Forward
Kubernetes services are not exposed externally by default. Use port-forward to access Ray from the host:
kubectl port-forward svc/ray-cluster-kuberay-head-svc 8265:8265 10001:10001 -n ray &
- Port
8265→ Ray Dashboard (browser) - Port
10001→ Ray Client (Python)
Test Connection
python -c "
import ray
ray.init(address='ray://localhost:10001')
print(ray.cluster_resources())
ray.shutdown()
"
# Expected: {'GPU': 4.0, 'CPU': 32.0, 'memory': ...}
Troubleshooting Port-Forward
# If port is already in use
sudo lsof -i :10001 # Find what's holding the port
pkill -9 kubectl # Kill all kubectl processes
# Then restart port-forward
To validate GPU access, let's ensure we can actually use our GPUs to execute jobs from Ray:
import ray
ray.init(
address="ray://localhost:10001",
runtime_env={"pip": ["torch==2.1.0"]}
)
@ray.remote(num_gpus=1)
def test_gpu():
import torch
return {
"pytorch": torch.__version__,
"cuda": torch.cuda.is_available(),
"gpu": torch.cuda.get_device_name(0)
}
results = ray.get([test_gpu.remote() for _ in range(4)])
for i, r in enumerate(results):
print(f"GPU {i}: {r}")
ray.shutdown()
Expected output:
GPU 0: {'pytorch': '2.1.0+cu121', 'cuda': True, 'gpu': 'NVIDIA GeForce RTX 4060 Ti'}
GPU 1: {'pytorch': '2.1.0+cu121', 'cuda': True, 'gpu': 'NVIDIA GeForce RTX 4060 Ti'}
GPU 2: {'pytorch': '2.1.0+cu121', 'cuda': True, 'gpu': 'NVIDIA GeForce RTX 4060 Ti'}
GPU 3: {'pytorch': '2.1.0+cu121', 'cuda': True, 'gpu': 'NVIDIA GeForce RTX 4060 Ti'}
First run: 3–5 min (Ray installs PyTorch on workers). Subsequent runs: 30 seconds (cached).
Cluster Management Scripts
Save all scripts to ~/ray-cluster-scripts/ and run chmod +x *.sh.
ray-connect.sh
#!/bin/bash
pkill -9 kubectl 2>/dev/null
sleep 1
kubectl port-forward svc/ray-cluster-kuberay-head-svc 8265:8265 10001:10001 -n ray &
sleep 3
echo "Connected — Dashboard: http://localhost:8265"
ray-status.sh
#!/bin/bash
echo "--- Pods ---"
kubectl get pods -n ray -o wide
echo ""
echo "--- GPU Allocation ---"
kubectl describe nodes | grep -A 10 "Allocated resources" | grep -E "(nvidia|cpu|memory)"
echo ""
echo "Dashboard: http://localhost:8265"
ray-scale.sh
#!/bin/bash
# Usage: ./ray-scale.sh 0 (free GPUs) or ./ray-scale.sh 2 (restore)
REPLICAS=$1
WORKER=$(kubectl get deployment -n ray -l ray.io/node-type=worker -o name | head -1)
kubectl scale $WORKER -n ray --replicas=$REPLICAS
echo "Workers scaling to $REPLICAS"
ray-stop.sh
#!/bin/bash
helm uninstall ray-cluster -n ray
pkill -9 kubectl
echo "Ray cluster stopped"
ray-start.sh
#!/bin/bash
helm install ray-cluster kuberay/ray-cluster \
--namespace ray \
--values ~/ray-cluster-values.yaml \
--version 1.2.2
kubectl wait --for=condition=ready pod \
-l ray.io/cluster=ray-cluster-kuberay \
-n ray --timeout=300s
echo "Ray cluster started"
Running a simple Hugging Face Transformers inference job on our cluster
A quick sanity check to ensure everything is working properly. We will try to run DistilBERT on a single GPU using the Hugging Face inference library.
import ray
import torch
ray.init("ray://localhost:10001")
@ray.remote(num_gpus=1, runtime_env={"pip": ["transformers", "torch"]})
def run_inference(texts: list[str]) -> list[dict]:
from transformers import pipeline
import torch
device = 0 if torch.cuda.is_available() else -1
print(f"Running on: {torch.cuda.get_device_name(0) if device == 0 else 'CPU'}")
classifier = pipeline(
"text-classification",
model="distilbert-base-uncased-finetuned-sst-2-english",
device=device
)
return classifier(texts)
if __name__ == "__main__":
texts = [
"This is a fantastic piece of work",
"I absolutely hate rainy days",
"The weather is okay I guess"
]
ref = run_inference.remote(texts)
results = ray.get(ref)
for text, result in zip(texts, results):
print(f"{text!r} => {result}")
This will create a worker based on the specified runtime environment, execute the inference task and return the results outside the job worker. This means that the local environment where the Python script is being executed does not need to have a GPU; we can simply specify in Ray the environment we would like to execute that function in, and Ray handles the rest and gives us back the results, which we can then use downstream in our application.
Running vLLM Across Both Nodes for Tensor and Pipeline Parallelism
Running vLLM directly on the host only uses the 2 GPUs local to that node. To span all 4 GPUs across both nodes, use the Ray distributed executor backend.
Install vLLM
conda activate ray-client
pip install vllm
Ensure Ray Is Connected and Visible
cd ~/ray-cluster-scripts && ./ray-connect.sh
# Must show GPU: 4.0 before proceeding
python -c "import ray; ray.init(address='ray://localhost:10001'); print(ray.cluster_resources()); ray.shutdown()"
Launch vLLM Server (All 4 GPUs)
Run once on Node 1. Ray handles distribution to both nodes automatically:
vllm serve mistralai/Mistral-7B-v0.1 \
--host 0.0.0.0 \
--port 8000 \
--tensor-parallel-size 4 \
--distributed-executor-backend ray \
--gpu-memory-utilization 0.90
--distributed-executor-backend ray— connects vLLM to the Ray cluster--tensor-parallel-size 4— splits the model across all 4 GPUs- Ray places vLLM workers on both nodes automatically
For Large Models: Tensor + Pipeline Parallelism
# 2 GPUs TP per node × 2 nodes PP = 4 GPUs total
vllm serve meta-llama/Llama-2-30b-hf \
--tensor-parallel-size 2 \
--pipeline-parallel-size 2 \
--distributed-executor-backend ray \
--gpu-memory-utilization 0.95
Test the Server
curl http://localhost:8000/v1/completions \
-H "Content-Type: application/json" \
-d '{"model": "mistralai/Mistral-7B-v0.1", "prompt": "Explain distributed computing:", "max_tokens": 100}'
Helper Script: start-vllm-ray.sh
#!/bin/bash
MODEL=${1:-"mistralai/Mistral-7B-v0.1"}
PORT=${2:-8000}
TP=${3:-4}
source ~/miniconda3/bin/activate ray-client
if ! pgrep -f "port-forward.*ray" > /dev/null; then
echo "Ray port-forward not running. Run ./ray-connect.sh first."
exit 1
fi
vllm serve "$MODEL" \
--host 0.0.0.0 --port $PORT \
--tensor-parallel-size $TP \
--distributed-executor-backend ray \
--gpu-memory-utilization 0.90 \
--max-model-len 4096
Running SGLang Across Both Nodes
SGLang is an alternative LLM serving framework with faster structured generation. Setup is nearly identical to vLLM.
Install SGLang
conda activate ray-client
pip install "sglang[all]"
Launch SGLang Server (All 4 GPUs)
python -m sglang.launch_server \
--model-path mistralai/Mistral-7B-v0.1 \
--host 0.0.0.0 \
--port 8000 \
--tp 4 \
--distributed-executor-backend ray \
--mem-fraction-static 0.85
Key Differences from vLLM
| Parameter | vLLM | SGLang |
|---|---|---|
| Tensor parallelism | --tensor-parallel-size | --tp |
| GPU memory | --gpu-memory-utilization | --mem-fraction-static |
| Launch command | vllm serve | python -m sglang.launch_server |
| Ray backend | --distributed-executor-backend ray | --distributed-executor-backend ray |
Model Size Reference
| Model | Size | VRAM Needed | Strategy |
|---|---|---|---|
| Mistral-7B | 7B | ~14GB | --tensor-parallel-size 4 |
| Llama-2-13B | 13B | ~26GB | --tensor-parallel-size 4 |
| Llama-2-30B | 30B | ~60GB | --tensor-parallel-size 2 --pipeline-parallel-size 2 |
| Falcon-40B | 40B | ~80GB | TP=2 PP=2 + --quantization awq |
| Llama-2-70B | 70B | ~140GB | Exceeds 64GB cluster VRAM |
Key Design Decisions
No auto-scaling: Kubernetes GPU allocation does not block host process access to GPUs. Host tools like Ollama can still use the GPUs even while Ray workers are running, since they share physical GPU memory. Auto-scaling was therefore skipped — keeping workers always running avoids 30–60s startup latency with no meaningful benefit.
runtime_env over custom images: K3s uses containerd and image builds require elevated access. Ray's runtime_env installs packages on workers on demand and caches them, making it the simpler and recommended approach.
Fixed worker placement per node: The podAntiAffinity rule in the Ray cluster config ensures one worker per physical node, giving each worker exactly 2 GPUs. Without this, both workers could land on the same node, leaving the other node's GPUs unused.
Troubleshooting Reference
| Symptom | Likely Cause | Fix |
|---|---|---|
nvidia-smi fails | Secure Boot enabled | Disable in BIOS |
GPUs not in kubectl describe node | GPU Operator not ready | kubectl get pods -n gpu-operator |
| Ray shows only 2 GPUs | Both workers on same node | Check pod anti-affinity; check kubectl get pods -n ray -o wide |
| Port-forward drops | Old kubectl process | pkill -9 kubectl then ./ray-connect.sh |
| vLLM uses only local GPUs | Missing Ray backend flag | Add --distributed-executor-backend ray |
| Python version mismatch warning | Minor patch diff (3.11.10 vs 3.11.15) | Harmless — can ignore |
Quick Reference Cheatsheet
# Every session start
conda activate ray-client
cd ~/ray-cluster-scripts
./ray-connect.sh
# Check cluster
./ray-status.sh
nvidia-smi
# Run LLM on all 4 GPUs
~/start-vllm-ray.sh
~/start-sglang-ray.sh
# Free GPUs for Ollama
./ray-scale.sh 0
# Restore
./ray-scale.sh 2
# Full cluster restart
./ray-stop.sh && ./ray-start.sh && ./ray-connect.sh
If you followed all the steps properly, you should have your own AI infrastructure ready to use! Run training, fine-tuning and inference jobs from anywhere with ease. You now have
sovereignty over your AI; you now own your own AI future. This is just the beginning. The actual journey is in the projects you build with it and how you tweak it to your taste.
KAARL stands for Kosi's Advanced AI Research Laboratory.
Last updated at: June 3, 2026