Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,37 @@ jobs:
- image_name: client
context: .
dockerfile: examples/autoresearch/Dockerfile
- image_name: placement-controller
context: controller
dockerfile: controller/Dockerfile
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Free Disk Space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
Expand Down
18 changes: 15 additions & 3 deletions .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,29 @@ jobs:
- image_name: client
context: .
dockerfile: examples/autoresearch/Dockerfile
- image_name: placement-controller
context: controller
dockerfile: controller/Dockerfile
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Free Disk Space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

- name: Build
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/controller-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: controller tests

on:
pull_request:
paths:
- "controller/**"
- ".github/workflows/controller-tests.yml"
push:
branches:
- main
paths:
- "controller/**"
- ".github/workflows/controller-tests.yml"

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: controller
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version-file: controller/go.mod
cache-dependency-path: controller/go.sum

- name: gofmt
run: test -z "$(gofmt -l .)"

- name: vet
run: go vet ./...

- name: test
run: go test ./...
5 changes: 5 additions & 0 deletions controller/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Local tool binaries (controller-gen) and build output. The repo's top-level
# .gitignore has no Go section, so the kubebuilder scaffold's ignores live here.
bin/
*.test
*.out
28 changes: 28 additions & 0 deletions controller/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Build the placement controller.
#
# Two stages so the shipped image holds a static binary and nothing else: the
# controller runs with a read-only root filesystem as a non-root user, and has
# no reason to carry a shell or a package manager into a cluster that hands it
# permission to create pods.
FROM golang:1.26 AS build

WORKDIR /src

# Dependencies first, so a source-only change does not re-download the module
# graph.
COPY go.mod go.sum ./
RUN go mod download

COPY cmd/ cmd/
COPY api/ api/
COPY internal/ internal/

ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \
go build -trimpath -ldflags="-s -w" -o /out/manager ./cmd/manager

FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=build /out/manager /manager
USER 65532:65532
ENTRYPOINT ["/manager"]
89 changes: 89 additions & 0 deletions controller/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
IMG ?= ghcr.io/gke-labs/open-rl/placement-controller:latest
CONTROLLER_TOOLS_VERSION ?= v0.19.0

CRD_MANIFEST := ../k8s/deploy/scheduler/00-openrlworker-crd.yaml
LOCALBIN := $(shell pwd)/bin
CONTROLLER_GEN := $(LOCALBIN)/controller-gen
GEN_DIR := $(LOCALBIN)/generated

.PHONY: all
all: generate manifests fmt vet test build

$(LOCALBIN):
mkdir -p $(LOCALBIN)

$(CONTROLLER_GEN): $(LOCALBIN)
GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION)

# api/v1alpha1/zz_generated.deepcopy.go. Checked in, so a plain `go build`
# works without the toolchain.
.PHONY: generate
generate: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) object paths="./api/..."

# The CRD schema, from the markers on the api/ types. kubebuilder would write
# this to config/crd/bases; there is no config/ tree here (see PROJECT), so it
# lands directly on the manifest k8s/deploy applies. The types' doc comments
# become the schema descriptions, so the prose lives in Go, not in the YAML.
.PHONY: manifests
manifests: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) crd paths="./api/..." output:crd:dir=$(GEN_DIR)/crd
cp $(GEN_DIR)/crd/openrl.io_openrlworkers.yaml $(CRD_MANIFEST)

# Fails when the checked-in CRD no longer matches the types. For CI, and for
# the case where the generator cannot be run on the machine holding the diff.
.PHONY: manifests-check
manifests-check: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) crd paths="./api/..." output:crd:dir=$(GEN_DIR)/crd
diff -u $(CRD_MANIFEST) $(GEN_DIR)/crd/openrl.io_openrlworkers.yaml

# RBAC stays hand-written and this target only checks it. controller-gen emits
# one ClusterRole; the deployed policy is deliberately split, cluster-scoped for
# nodes and ResourceSlices and namespaced for everything else, so the controller
# cannot reach pods or claims outside its own namespace. Compare the verb sets.
.PHONY: rbac
rbac: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) rbac:roleName=open-rl-scheduler paths="./internal/..." output:rbac:dir=$(GEN_DIR)/rbac
@echo "generated to $(GEN_DIR)/rbac; reconcile by hand with k8s/deploy/scheduler/01-scheduler.yaml"

.PHONY: fmt
fmt:
go fmt ./...

.PHONY: vet
vet:
go vet ./...

.PHONY: test
test:
go test ./... -race -count=1

.PHONY: build
build:
go build -o bin/manager ./cmd/manager

.PHONY: run
run:
go run ./cmd/manager --leader-elect=false --namespace=$${OPEN_RL_WORKER_NAMESPACE:-default}

# The pipeline against a real API server and fake GPUs: kind plus the DRA
# example driver. No hardware needed. See hack/kind-smoke.sh for the knobs.
.PHONY: smoke
smoke:
./hack/kind-smoke.sh

.PHONY: docker-build
docker-build:
docker build -t $(IMG) .

.PHONY: docker-push
docker-push:
docker push $(IMG)

.PHONY: deploy
deploy:
kubectl apply -k ../k8s/deploy/scheduler

.PHONY: clean
clean:
rm -rf bin
27 changes: 27 additions & 0 deletions controller/PROJECT
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Kubebuilder project metadata.
# https://book.kubebuilder.io/reference/project-config.html
#
# Written by hand rather than by `kubebuilder init`: the tree already existed in
# the kubebuilder layout (api/, cmd/manager, internal/controller, controller-gen
# markers on the types) and this file is what makes the CLI and its plugins
# recognise it, so `kubebuilder create api` and `kubebuilder edit` work here.
#
# One deviation from a stock scaffold: there is no config/ kustomize tree. This
# repo deploys every component from k8s/deploy, and a second deploy path would
# be a second thing to keep correct. `make manifests` generates the CRD straight
# into k8s/deploy/dra-placement instead.
domain: openrl.io
layout:
- go.kubebuilder.io/v4
projectName: open-rl-placement-controller
repo: github.com/gke-labs/open-rl/controller
resources:
- api:
crdVersion: v1
namespaced: true
controller: true
domain: openrl.io
kind: OpenRLWorker
path: github.com/gke-labs/open-rl/controller/api/v1alpha1
version: v1alpha1
version: "3"
94 changes: 94 additions & 0 deletions controller/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# The GPU scheduler

A worker says how much accelerator memory it needs and which owner it belongs
to. The scheduler decides which bundle of accelerators it lands on and who it
takes turns with. That is the whole contract.

```yaml
apiVersion: openrl.io/v1alpha1
kind: OpenRLWorker
metadata:
name: adapter-a
spec:
role: trainer # which node pools may host it
modelId: adapter-a # its identity everywhere
memory: 6Gi # total accelerator memory, from the estimator
ownerId: Qwen/Qwen3-0.6B # optional: the unit of fairness it belongs to
```

Everything else — device count, per-device split, claim, node — is derived
and reported back in `status`.

## The model, in one sentence

**A claim is a bundle of accelerators; several workers may be assigned to it;
exactly one of them is resident at a time.**

- There is no co-residency in V1. Whatever the workers share, at most one
process's state is loaded on the allocation; everyone else is suspended in
host RAM. So estimates are never summed — each worker only has to fit the
allocation *by itself* — and a handoff finishes suspending the outgoing
worker before the next one is restored.
- The owner ID is an opaque string, compared and never interpreted. It is the
unit of fairness: turns rotate between owners, so an owner never gets extra
turns for having more processes, requests, or adapters. Naming none makes
you an owner of one. Placement ignores it entirely.
- `role` selects nodes, never claims: a trainer and a sampler share one GPU
by turns.
- No sharding, so device count is plain ceiling division — derived, never
requested.

## Layout

| path | what it is |
| --- | --- |
| `api/v1alpha1` | the CRD: the request, and what was decided about it |
| `internal/placement` | the decision. Pure functions, no Kubernetes imports |
| `internal/controller` | the part that reads and writes Kubernetes objects |
| `docs/design.md` | the design |

## Try it

The behaviors live in `internal/placement/behavior_test.go`: workers arriving
and leaving, with the estimator's real tier figures on the hardware we run,
played through the same `Decide` the controller calls.

```
go test ./...
```

For the pipeline — real API server, real kube-scheduler, real DRA — there is
a kind smoke test that needs no hardware (the DRA example driver publishes
fake GPUs):

```
make smoke # kind + fake GPUs
USE_EXISTING_CLUSTER=1 DEVICE_CLASS=gpu.nvidia.com ./hack/kind-smoke.sh # real GPUs
```

The controller only ever reads ResourceSlices and node labels, so fake and
real devices exercise the identical path; only the two env values differ.

## Deploy

```
kubectl apply -k ../k8s/deploy/scheduler
kubectl label node <node> openrl.io/enabled=true openrl.io/trainer=true openrl.io/max-workers-per-claim=4
```

Applying it changes nothing about a running cluster: the scheduler only acts
on OpenRLWorker objects. Node labels are policy, never hardware — the DRA
driver's ResourceSlices say what devices actually exist.

Labeling a node opts its GPUs in **exclusively**: the scheduler counts a
device as free unless one of its own claims holds it, so other GPU workloads
on an enabled node are invisible to placement and will collide with it. Give
OpenRL whole nodes.

## Everything else

Assumptions and caveats, the estimator, worker identity, claim lifecycle,
and the future optimizations all live in
[`docs/design.md`](docs/design.md); a file-by-file tour with a suggested
reading order is [`docs/layout.md`](docs/layout.md). If the code and any
document disagree, the code is right.
23 changes: 23 additions & 0 deletions controller/api/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Package v1alpha1 contains the OpenRLWorker API, the placement request the
// gateway writes for every worker process it wants running.
//
// See docs/designs/012-dynamic-placement.md.
// +kubebuilder:object:generate=true
// +groupName=openrl.io
package v1alpha1

import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)

var (
// GroupVersion is the group and version this package's types belong to.
GroupVersion = schema.GroupVersion{Group: "openrl.io", Version: "v1alpha1"}

// SchemeBuilder registers this package's types with a runtime.Scheme.
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}

// AddToScheme adds this package's types to a runtime.Scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
Loading
Loading