Build the KAI-Scheduler and GPU Fractioning Forks
This guide builds two forks from source, pushes the images to a registry you control, and installs them on a Kubernetes cluster. Together they give each pod a hard share of a GPU’s streaming multiprocessors (SMs) and memory, enforced by CUDA Multi-Process Service (MPS) v3 namespaces. You need them for the “KAI + GPU fractions” experiment in Interpreting GPU Sharing Results: 16 Qwen3-4B models on 8 A100 GPUs, each model capped at 50% of the SMs and 50% of the memory of one GPU.
When the install is done, a pod that asks for gpu-fraction: "0.5" lands on a shared GPU in its own MPS namespace. The MPS server enforces the pod’s SM share, and the driver enforces its memory cap, so the pod cannot raise either one. The next step is deploying the models.
What You Are Building
The two forks are a matched pair. The scheduler writes annotations that only this fork of the node agent reads, and the node agent provisions namespaces that only this fork of the scheduler accounts for. Build and deploy both from the commits below. Do not mix a fork image with the upstream release chart, or the reverse. The symptom of a mismatch is a pod that schedules but receives no GPU. See Troubleshooting.
Pinned Revisions
Both changes are open pull requests against the upstream repositories. The results published for this experiment were produced by exactly these commits, so check them out by SHA rather than following the pull request.
Prerequisites
Build Machine: KAI-Scheduler
Build Machine: kai-gpu-fractioning
The images target linux/amd64 by default (PLATFORM ?= linux/amd64), which matches A100 and H100 nodes. Override PLATFORM if you build on another architecture.
Cluster and Nodes
Check every row on a GPU node before you install anything. The defaults of a stock GPU Operator install are not enough for compute enforcement.
Installing the r615 Driver
All the packages and images below exist publicly; choose the path that matches how the cluster manages drivers.
-
Host-installed driver (Ubuntu 24.04, NVIDIA CUDA apt repository). The
nvidia-driver-pinning-615package exists only in the NVIDIA CUDA repository, so add that repository first. Then install the branch pin, then the driver and, on NVSwitch systems, Fabric Manager:If a different
nvidia-driver-pinning-*package is already installed, remove it first, or the 615 pin does not take effect andnvidia-driver-openresolves to the older branch. If the driver install appears to do nothing, checkapt-cache policy nvidia-driver-openfor an/etc/apt/preferences.d/file that pins the NVIDIA origin to a negative priority. -
GPU Operator-managed driver. Pass the version when you install or upgrade the operator:
The driver image
nvcr.io/nvidia/driver:615.71.09-ubuntu24.04is published. Add the chart repository first withhelm repo add nvidia https://helm.ngc.nvidia.com/nvidia.
Step 1: Clone and Check Out the Pinned Commits
Confirm that both trees are clean, so no local edit is baked into an image:
Step 2: Build and Push the Images
Set the registry and one tag for both forks. The tag records the pairing by naming both commits:
Always pass an explicit tag. KAI-Scheduler defaults DOCKER_TAG to 0.0.0, and both Helm charts fall back to their Chart.AppVersion (0.0.0 for the scheduler, 0.1.0 for the node agents) when an image tag is empty. A forgotten override does not fail. It deploys the wrong code, and you then benchmark the wrong thing.
kai-gpu-fractioning
This builds and pushes operator, mpsd, fractiond, and metricsd as $REGISTRY/kai-gpu-fractioning/<name>:$TAG. For a multi-architecture build, run make docker-buildx DOCKER_REPO_BASE="$REGISTRY/kai-gpu-fractioning" VERSION="$TAG" DOCKER_BUILD_PLATFORM=linux/amd64,linux/arm64, which pushes directly. Pass DOCKER_REPO_BASE and VERSION again: without them the target tags the images for the upstream ghcr.io/kai-scheduler registry with a git describe version. It also needs a docker-container buildx builder and QEMU emulation for the non-native architecture. Install the emulator with docker run --privileged --rm tonistiigi/binfmt --install arm64 (or amd64 on an Arm host), create a dedicated builder with docker buildx create --name kai-multiarch --driver docker-container, and prefix the make command with BUILDX_BUILDER=kai-multiarch so your default builder is not changed. A docker-container builder runs in its own network namespace, so it cannot push to a registry on localhost; use a registry it can reach over the network.
KAI-Scheduler
make build uses your current buildx builder. Check which one that is before you build:
With the default docker driver, images built without --push stay in your local Docker daemon. With a docker-container builder they stay only in the build cache: make still exits 0 and prints No output specified with docker-container driver, but no image appears in docker images. A registry on localhost is also unreachable from inside the builder container, so the push fails with connection refused. In either case, prefix the command with BUILDX_BUILDER=<builder>, naming a builder that docker buildx ls lists with the docker driver, usually default.
To build without pushing, drop DOCKER_BUILDX_ADDITIONAL_ARGS=--push and select a docker driver builder so the images land in your local Docker daemon:
In this Makefile make push is only an alias for make build: it has no recipe of its own and does not push. Pushing happens because DOCKER_BUILDX_ADDITIONAL_ARGS=--push is passed to docker buildx build. Without it, make build push leaves the images in your local Docker daemon only. The images match your host architecture. Set DOCKER_BUILD_PLATFORM=linux/amd64 when you build on an Arm machine for x86 nodes.
The build compiles 15 services in sequence, each for both architectures, so the first run takes a while. Go caches are kept in ~/.cache/go-build-docker-gocache and ~/.cache/go-build-docker-gopath, and the build leaves a builder:1.26.3-bookworm image in your local Docker daemon. Remove them when you no longer need fast rebuilds.
Verify the Images Were Pushed
Every line must print ok: four node-agent images and fifteen scheduler images.
Step 3: Install KAI-Scheduler
Fetch the chart dependency, then install with the fork images. The binder.runtimeClassName value gives KAI’s GPU reservation pod the nvidia RuntimeClass at install time. Without it, the reservation pod cannot load NVML and every fractional pod stays Pending for a reason that is invisible in the workload’s own events.
global.gpuSharingModeis case-sensitive:NvFractions.- Leave
global.nvFractions.setat its default,false. Setting it totrueinstalls the upstreamkai-gpu-fractioningrelease fromghcr.ioas a subchart, which is not your fork. You install the fork as its own release in Step 4.helm dependency buildstill has to download that subchart, because Helm checks that every declared dependency is present. - For a private registry, add
--set 'global.imagePullSecrets[0].name=<secret>'and create the secret inkai-schedulerfirst.
Verify before moving on. Wait for the deployments first, because helm upgrade --install returns before the pods are up:
Until you finish Step 4, kai-config reports DependenciesFulfilled=False with GpuFractioningConfig not found. That is expected at this point.
Every image must show your $REGISTRY and $TAG. Do not continue with a pod that is not running.
Step 4: Install kai-gpu-fractioning
Set every image tag. An empty tag falls back to the chart’s appVersion, which is not your build.
Adjust for your environment:
On MicroK8s, containerd and its NRI socket live under the snap directory, so the default /var/run/nri/nri.sock is unreachable. Pointing both socket paths at /var/snap/microk8s/common/run/ does not work either. The operator mounts the parent directory of each socket into the fractiond DaemonSet, so two sockets in the same directory produce two volume mounts at the same path, and the DaemonSet is rejected. Give the CRI socket its own parent directory with a symlink before you run the helm upgrade --install command above:
Then add both socket paths to that command:
Set the paths through the chart rather than by editing the default GpuFractioningConfig. The chart recreates that object from its values on every helm upgrade, which overwrites a manual kubectl apply. microk8s-gpu-fractioning-config.yaml shows the resulting configuration for reference.
MicroK8s can also register the containerd runtime as nvidia-container-runtime while the GPU Operator’s RuntimeClass handler is nvidia. If operands fail with no runtime for "nvidia" is configured, align the two names.
Verify:
Wait for the operator, then for the DaemonSets it creates. The DaemonSets appear a few seconds after the operator starts:
For every DaemonSet, desired must equal ready. A mismatch usually means a node failed the NRI or CRI socket check. Then confirm each GPU node reports ready, which is what gates scheduling:
Confirm that MPS started in multi-user mode. Without it, a server accepts only clients with the owner’s uid, which would force every pod to run as that user:
Expect Adding server 'shared' [multiuser enabled] and a line capping the default namespace at 10%. That cap is defense in depth for a pod that receives the server’s pipe directory instead of its own namespace’s.
Step 5: Verify With a Two-Pod Smoke Test
This step proves that the caps are enforced, not just requested. It puts two half-GPU pods on one GPU. The manifests for the full experiment are in examples/gpu-sharing/kai-gpu-fractions, and a ready-made version of this test, with the same pod names, is smoke-test-half-gpu.yaml. The minimal form is below, for readers without a checkout:
The container must be named main because the annotation names the container it applies to. The chart creates default-queue for you.
Keep runAsNonRoot: true. MPS authorizes control commands as the server owner or root, and in multi-user mode the server is owned by root. A fractional pod that runs as root can raise its own cap or delete a neighbor’s namespace. A non-root pod cannot. Enforce this with Pod Security Admission or a policy engine instead of trusting each manifest.
Run these four checks. All four must hold:
Clean up:
Measure the Effective SM Share
The checks above show the caps that were requested and configured. To show that the SM share is enforced, run CUDA’s deviceQuery sample as a half-GPU pod that tries to override its own share to 100%:
The Multiprocessors count must be half the GPU’s SM count, rounded down, despite the override: for example, (054) Multiprocessors on an A100 with 108 SMs. fractiond replaces the variable when the container is created, and the MPS server caps the namespace whatever the client asks for. deviceQuery still reports the physical memory total, as the note below explains.
nvidia-smi and pynvml in a pod report the pod’s slice, but CUDA’s cudaMemGetInfo total stays at the physical card size. Engines that size their KV cache from it, such as vLLM with --gpu-memory-utilization, therefore size against the whole GPU. Set that flag against the physical total, not the quota, when you deploy the models.
Troubleshooting
Uninstall
Delete the default GpuFractioningConfig first, while the operator is still running. The operator then stops the mpsd and fractiond DaemonSets and clears the node conditions. If you uninstall the chart first, the DaemonSets keep running, MPS stays on the GPUs, and the object can no longer be deleted: its gpu-fractioning.nvidia.com/cleanup-node-conditions finalizer is removed only by the operator.
The mpsd pods take about a minute to terminate. Helm keeps the default-queue and default-parent-queue queues, the default SchedulingShard, the CRDs, and the kai-resource-reservation, kai-scheduler, and gpu-fractioning namespaces. For a clean slate, remove them too. Delete the CRDs only if nothing else in the cluster uses them:
Next Steps
- Deploy the 16-model experiment on the cluster you just built.
- Per-GPU Compute and Memory Limits with MPS covers the MPS v3 mechanism that these forks drive for you.
- Interpreting GPU Sharing Results explains how to read the benchmark.