Skip to content

Commit c646b0b

Browse files
Merge pull request llm-d-incubation#108 from MikeSpreitzer/add-kindtest
Start work on end-to-end test
2 parents 558bbb9 + 697183a commit c646b0b

File tree

12 files changed

+859
-7
lines changed

12 files changed

+859
-7
lines changed

Makefile

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ REQUESTER_IMG_TAG ?= latest
44
REQUESTER_IMAGE := $(CONTAINER_IMG_REG)/$(REQUESTER_IMG_REPO):$(REQUESTER_IMG_TAG)
55

66
CONTROLLER_IMG_TAG ?= $(shell git rev-parse --short HEAD)
7-
CONTROLLER_IMG ?= $(CONTAINER_IMG_REG)/dual-pod-controller:$(CONTROLLER_IMG_TAG)
7+
CONTROLLER_IMG ?= $(CONTAINER_IMG_REG)/dual-pods-controller:$(CONTROLLER_IMG_TAG)
8+
TEST_REQUESTER_IMG_TAG ?= $(shell git rev-parse --short HEAD)
9+
TEST_REQUESTER_IMG ?= $(CONTAINER_IMG_REG)/test-requester:$(TEST_REQUESTER_IMG_TAG)
10+
TEST_SERVER_IMG_TAG ?= $(shell git rev-parse --short HEAD)
11+
TEST_SERVER_IMG ?= $(CONTAINER_IMG_REG)/test-server:$(TEST_SERVER_IMG_TAG)
812

913
TARGETARCH ?= $(shell go env GOARCH)
1014

15+
CLUSTER_NAME ?= fmatest
16+
1117
.PHONY: build-requester
1218
build-requester:
1319
docker build -t $(REQUESTER_IMAGE) -f dockerfiles/Dockerfile.requester . --progress=plain --platform=linux/$(TARGETARCH)
@@ -18,10 +24,35 @@ push-requester:
1824

1925
.PHONY: build-controller-local
2026
build-controller-local:
21-
KO_DOCKER_REPO=ko.local ko build -B ./cmd/dual-pods-controller -t ${CONTROLLER_IMG_TAG}
27+
KO_DOCKER_REPO=ko.local ko build -B ./cmd/dual-pods-controller -t ${CONTROLLER_IMG_TAG} --platform linux/$(shell go env GOARCH)
2228
docker tag ko.local/dual-pods-controller:${CONTROLLER_IMG_TAG} ${CONTROLLER_IMG}
2329

30+
.PHONY: load-controller-local
31+
load-controller-local:
32+
kind load docker-image ${CONTROLLER_IMG} --name ${CLUSTER_NAME}
33+
2434
.PHONY: build-controller
2535
build-controller:
2636
KO_DOCKER_REPO=$(CONTAINER_IMG_REG) ko build -B ./cmd/dual-pods-controller -t ${CONTROLLER_IMG_TAG} --platform linux/amd64,linux/arm64
2737

38+
.PHONY: build-test-requester-local
39+
build-test-requester-local:
40+
KO_DOCKER_REPO=ko.local ko build -B ./cmd/test-requester -t ${TEST_REQUESTER_IMG_TAG} --platform linux/$(shell go env GOARCH)
41+
docker tag ko.local/test-requester:${TEST_REQUESTER_IMG_TAG} ${TEST_REQUESTER_IMG}
42+
43+
.PHONY: load-test-requester-local
44+
load-test-requester-local:
45+
kind load docker-image ${TEST_REQUESTER_IMG} --name ${CLUSTER_NAME}
46+
47+
.PHONY: build-test-server-local
48+
build-test-server-local:
49+
KO_DOCKER_REPO=ko.local ko build -B ./cmd/test-server -t ${TEST_SERVER_IMG_TAG} --platform linux/$(shell go env GOARCH)
50+
docker tag ko.local/test-server:${TEST_SERVER_IMG_TAG} ${TEST_SERVER_IMG}
51+
52+
.PHONY: load-test-server-local
53+
load-test-server-local:
54+
kind load docker-image ${TEST_SERVER_IMG} --name ${CLUSTER_NAME}
55+
56+
.PHONY: echo-var
57+
echo-var:
58+
@echo "$($(VAR))"

charts/dpctlr/templates/deployment.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ spec:
2020
containers:
2121
- name: controller
2222
image: {{ .Values.Image }}
23-
imagePullPolicy: Always
23+
imagePullPolicy: {{if .Values.Local}}Never{{else}}Always{{end}}
2424
command:
2525
- /ko-app/dual-pods-controller
2626
- "-v=5"

charts/dpctlr/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Of course, you never really want to use `:latest`
22
Image: ghcr.io/llm-d-incubation/llm-d-fast-model-actuation-controller:latest
33

4+
# Set this to true when deploying in a local cluster.
5+
# This suppresses image pulls.
6+
Local: false
7+
48
# Name of the ClusterRole to bind to controller to authorize it to
59
# get/list/watch Node objects.
610
# Empty string means no new binding is needed.

cmd/dual-pods-controller/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func main() {
6565

6666
kubeClient := kubernetes.NewForConfigOrDie(restConfig)
6767
if len(overrides.Context.Namespace) == 0 {
68-
fmt.Fprint(os.Stderr, "Namespace must not be the empty string")
68+
fmt.Fprintln(os.Stderr, "Namespace must not be the empty string")
6969
os.Exit(1)
7070
} else {
7171
logger.Info("Focusing on one namespace", "name", overrides.Context.Namespace)

cmd/test-requester/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Test requester
2+
3+
This is a variant of the normal requester that does not actually
4+
involve GPUs. Instead of running `nvidia-smi` to discover what GPUs
5+
were assigned by the kubelet, this requester maintains GPU assignments
6+
in a ConfigMap named "gpu-allocs".
7+
8+
For details, see the comment at the start of [the source](main.go).
9+
10+
The command line arguments are as follows.
11+
12+
## Requester specific arguments
13+
14+
```console
15+
--node string name of this Pod's Node
16+
--num-gpus int number of GPUs to allocate (default 1)
17+
--pod-uid string UID of this Pod
18+
--probes-port int16 port number for /ready (default 8080)
19+
--spi-port int16 port for dual-pods requests (default 8081)
20+
```
21+
22+
## kubectl arguments
23+
24+
```console
25+
--cluster string The name of the kubeconfig cluster to use
26+
--context string The name of the kubeconfig context to use
27+
--kubeconfig string Path to the kubeconfig file to use
28+
-n, --namespace string The name of the Kubernetes Namespace to work in (NOT optional)
29+
--user string The name of the kubeconfig user to use
30+
```
31+
32+
## Logging arguments
33+
34+
```console
35+
--add_dir_header If true, adds the file directory to the header of the log messages
36+
--alsologtostderr log to standard error as well as files (no effect when -logtostderr=true)
37+
--log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0)
38+
--log_dir string If non-empty, write log files in this directory (no effect when -logtostderr=true)
39+
--log_file string If non-empty, use this log file (no effect when -logtostderr=true)
40+
--log_file_max_size uint Defines the maximum size a log file can grow to (no effect when -logtostderr=true). Unit is megabytes. If the value is 0, the maximum file size is unlimited. (default 1800)
41+
--logtostderr log to standard error instead of files (default true)
42+
--one_output If true, only write logs to their native severity level (vs also writing to each lower severity level; no effect when -logtostderr=true)
43+
--skip_headers If true, avoid header prefixes in the log messages
44+
--skip_log_headers If true, avoid headers when opening log files (no effect when -logtostderr=true)
45+
--stderrthreshold severity logs at or above this threshold go to stderr when writing to files and stderr (no effect when -logtostderr=true or -alsologtostderr=true) (default 2)
46+
-v, --v Level number for the log level verbosity
47+
--vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging
48+
```
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
Copyright 2025 The llm-d Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"os"
24+
"strings"
25+
"time"
26+
27+
corev1 "k8s.io/api/core/v1"
28+
apierrors "k8s.io/apimachinery/pkg/api/errors"
29+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
apitypes "k8s.io/apimachinery/pkg/types"
31+
"k8s.io/apimachinery/pkg/util/sets"
32+
"k8s.io/apimachinery/pkg/util/wait"
33+
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
34+
35+
dpctlr "github.com/llm-d-incubation/llm-d-fast-model-actuation/pkg/controller/dual-pods"
36+
37+
"k8s.io/klog/v2"
38+
)
39+
40+
// This code maintains a ConfigMap named "gpu-allocs" that holds the current test allocations
41+
// of GPUs. The data of this ConfigMap is a map from GPU UID to the JSON marshaling of a GPUHolder.
42+
43+
// gpuMap maps node name to nodeGPUMap
44+
type gpuMap map[string]nodeGPUMap
45+
46+
// nodeGPUMap maps GPU UID to index
47+
type nodeGPUMap map[string]int
48+
49+
// GPUHolder identifies a test requester that is currently allocated the use of a GPU
50+
type GPUHolder struct {
51+
NodeName string
52+
PodUID apitypes.UID
53+
}
54+
55+
// GPUAllocMap maps GPU UID to GPUHolder
56+
type GPUAllocMap map[string]GPUHolder
57+
58+
func getGPUMap(ctx context.Context, cmClient corev1client.ConfigMapInterface) (gpuMap, error) {
59+
cm, err := cmClient.Get(ctx, dpctlr.GPUMapName, metav1.GetOptions{})
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to retrieve gpu-map ConfigMap: %w", err)
62+
}
63+
ans := gpuMap{}
64+
for nodeName, mapStr := range cm.Data {
65+
nm := nodeGPUMap{}
66+
err := json.Unmarshal([]byte(mapStr), &nm)
67+
if err != nil {
68+
return nil, fmt.Errorf("failed to parse GPU map for node %s: %w", nodeName, err)
69+
}
70+
ans[nodeName] = nm
71+
}
72+
return ans, nil
73+
}
74+
75+
func (gm gpuMap) onNode(nodeName string) sets.Set[string] {
76+
ngm := gm[nodeName]
77+
if ngm != nil {
78+
return sets.KeySet(ngm)
79+
}
80+
return sets.New[string]()
81+
}
82+
83+
func getGPUAlloc(ctx context.Context, cmClient corev1client.ConfigMapInterface) (GPUAllocMap, *corev1.ConfigMap, error) {
84+
cm, err := cmClient.Get(ctx, allocMapName, metav1.GetOptions{})
85+
if err != nil {
86+
if apierrors.IsNotFound(err) {
87+
// It up to us to create it
88+
cmProto := corev1.ConfigMap{
89+
TypeMeta: metav1.TypeMeta{
90+
Kind: "ConfigMap",
91+
APIVersion: corev1.SchemeGroupVersion.String(),
92+
},
93+
ObjectMeta: metav1.ObjectMeta{
94+
Name: allocMapName,
95+
},
96+
}
97+
cm, err = cmClient.Create(ctx, &cmProto, metav1.CreateOptions{FieldManager: agentName})
98+
if err != nil {
99+
return nil, nil, fmt.Errorf("failed to create GPU allocation ConfigMap: %w", err)
100+
}
101+
} else {
102+
return nil, nil, fmt.Errorf("failed to fetch GPU allocation ConfigMap: %w", err)
103+
}
104+
}
105+
ans := GPUAllocMap{}
106+
for gpuUID, holderStr := range cm.Data {
107+
holderReader := strings.NewReader(holderStr)
108+
var holder GPUHolder
109+
decoder := json.NewDecoder(holderReader)
110+
decoder.DisallowUnknownFields()
111+
err = decoder.Decode(&holder)
112+
if err != nil {
113+
return nil, nil, fmt.Errorf("failed to decode GPU allocation for GPU UID %s: %w", gpuUID, err)
114+
}
115+
ans[gpuUID] = holder
116+
}
117+
cm = cm.DeepCopy()
118+
if cm.Data == nil {
119+
cm.Data = map[string]string{}
120+
}
121+
return ans, cm, nil
122+
}
123+
124+
func allocateGPUs(ctx context.Context, coreClient corev1client.CoreV1Interface, nodeName, namespace string, podUID apitypes.UID, numGPUs uint) []string {
125+
logger := klog.FromContext(ctx)
126+
cmClient := coreClient.ConfigMaps(namespace)
127+
podClient := coreClient.Pods(namespace)
128+
var gpuUIDs []string
129+
// try once to allocate the requested number of GPUs;
130+
// on failure return explanatory error;
131+
// on success return nil.
132+
try := func(ctx context.Context) (err error) {
133+
gpuMap, err := getGPUMap(ctx, cmClient)
134+
if err != nil {
135+
return err
136+
}
137+
avail := gpuMap.onNode(nodeName)
138+
podUIDs, err := getPodUIDs(ctx, podClient)
139+
if err != nil {
140+
return err
141+
}
142+
if !podUIDs.Has(podUID) {
143+
return fmt.Errorf("pod UID %q not found among current Pods", podUID)
144+
}
145+
// Get the current allocations, as a data structure and as a ConfigMap object.
146+
gpuAllocMap, gpuAllocCM, err := getGPUAlloc(ctx, cmClient)
147+
if err != nil {
148+
return err
149+
}
150+
// Collect the ones used by other Pods on the same Node,
151+
// and remove obsolete entries from the ConfigMap.
152+
used := sets.New[string]()
153+
for gpuUID, holder := range gpuAllocMap {
154+
if holder.NodeName != nodeName {
155+
continue
156+
}
157+
if !podUIDs.Has(holder.PodUID) {
158+
delete(gpuAllocCM.Data, gpuUID)
159+
} else if holder.PodUID != podUID {
160+
used.Insert(gpuUID)
161+
}
162+
}
163+
// Compute the sorted list of unused GPUs on the right Node.
164+
rem := sets.List(avail.Difference(used))
165+
if uint(len(rem)) < numGPUs {
166+
return fmt.Errorf("fewer than %d GPUs available (%v) for node %q", numGPUs, rem, nodeName)
167+
}
168+
// Take the requested number
169+
// FROM THE HEAD OF THE LIST --- this is a choice to aid making repeatable tests.
170+
gpuUIDs = rem[:numGPUs]
171+
for _, gpuUID := range gpuUIDs {
172+
holder := GPUHolder{NodeName: nodeName, PodUID: podUID}
173+
holderBytes, err := json.Marshal(holder)
174+
if err != nil {
175+
return fmt.Errorf("failed to marshal holder for GPU %s (%#v): %w", gpuUID, holder, err)
176+
}
177+
gpuAllocCM.Data[gpuUID] = string(holderBytes)
178+
}
179+
echo, err := cmClient.Update(ctx, gpuAllocCM, metav1.UpdateOptions{
180+
FieldManager: agentName,
181+
})
182+
if err != nil {
183+
return fmt.Errorf("failed to update GPU allocation ConfigMap: %w", err)
184+
}
185+
logger.Info("Successful allocation", "nodeName", nodeName, "podUID", podUID, "gpus", gpuUIDs, "newResourceVersion", echo.ResourceVersion)
186+
return nil
187+
}
188+
err := wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (bool, error) {
189+
err := try(ctx)
190+
if err != nil {
191+
logger.Error(err, "Failed to allocate")
192+
}
193+
return err == nil, nil
194+
})
195+
if err != nil {
196+
fmt.Fprintf(os.Stderr, "Failed to allocate GPUS: %s\n", err.Error())
197+
os.Exit(100)
198+
}
199+
return gpuUIDs
200+
}
201+
202+
func getPodUIDs(ctx context.Context, podClient corev1client.PodInterface) (sets.Set[apitypes.UID], error) {
203+
podList, err := podClient.List(ctx, metav1.ListOptions{})
204+
if err != nil {
205+
return nil, err
206+
}
207+
uids, _ := dpctlr.SliceMap(podList.Items, func(pod corev1.Pod) (apitypes.UID, error) {
208+
return pod.UID, nil
209+
})
210+
return sets.New(uids...), nil
211+
}

0 commit comments

Comments
 (0)