Skip to content

Commit ab2f2a7

Browse files
committed
Add hami metrics e2e test
Signed-off-by: dttung2905 <ttdao.2015@accountancy.smu.edu.sg>
1 parent d5bc503 commit ab2f2a7

3 files changed

Lines changed: 221 additions & 1 deletion

File tree

hack/setup-e2e-cluster.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ if [ "$TEST_THIRD_PARTY_INTEGRATIONS" = "true" ]; then
147147
${REPO_ROOT}/hack/third_party_integrations/deploy_knative.sh
148148
${REPO_ROOT}/hack/third_party_integrations/deploy_lws.sh
149149
${REPO_ROOT}/hack/third_party_integrations/deploy_jobset.sh
150+
# HAMi-core / GPU-sharing isolation + optional VRAM metrics (:9394).
151+
# Soft-gated in hamicore e2e when the webhook / monitor are absent.
152+
${REPO_ROOT}/hack/third_party_integrations/deploy_isolator.sh
150153
fi
151154

152155
# Build and install kai-scheduler
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/bin/bash
2+
# Copyright 2026 NVIDIA CORPORATION
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Installs kai-resource-isolator with kai-vgpu-monitor enabled so hamicore e2e
6+
# can scrape hami_* per-container VRAM metrics on :9394.
7+
#
8+
# Overrides (optional):
9+
# ISOLATOR_CHART_REF OCI ref or local chart path
10+
# (default: oci://docker.io/projecthami/kai-resource-isolator)
11+
# ISOLATOR_CHART_VERSION Chart version when using OCI (default: 1.1.0-chart)
12+
# ISOLATOR_NAMESPACE Install namespace (default: kai-resource-isolator)
13+
# ISOLATOR_RELEASE Helm release name (default: kai-resource-isolator)
14+
# ISOLATOR_HELM_EXTRA_ARGS Extra args appended to helm upgrade (word-split)
15+
set -euo pipefail
16+
17+
ISOLATOR_CHART_REF="${ISOLATOR_CHART_REF:-oci://docker.io/projecthami/kai-resource-isolator}"
18+
ISOLATOR_CHART_VERSION="${ISOLATOR_CHART_VERSION:-1.1.0-chart}"
19+
ISOLATOR_NAMESPACE="${ISOLATOR_NAMESPACE:-kai-resource-isolator}"
20+
ISOLATOR_RELEASE="${ISOLATOR_RELEASE:-kai-resource-isolator}"
21+
22+
HELM_ARGS=(
23+
upgrade --install "${ISOLATOR_RELEASE}" "${ISOLATOR_CHART_REF}"
24+
--namespace "${ISOLATOR_NAMESPACE}"
25+
--create-namespace
26+
--set monitor.enabled=true
27+
--wait
28+
--timeout 5m
29+
)
30+
31+
# --version only applies to OCI/repo charts, not a local filesystem chart path.
32+
if [[ "${ISOLATOR_CHART_REF}" == oci://* ]] || [[ "${ISOLATOR_CHART_REF}" == *://* ]]; then
33+
HELM_ARGS+=(--version "${ISOLATOR_CHART_VERSION}")
34+
fi
35+
36+
# shellcheck disable=SC2206
37+
if [[ -n "${ISOLATOR_HELM_EXTRA_ARGS:-}" ]]; then
38+
EXTRA=( ${ISOLATOR_HELM_EXTRA_ARGS} )
39+
HELM_ARGS+=("${EXTRA[@]}")
40+
fi
41+
42+
echo "Installing kai-resource-isolator from ${ISOLATOR_CHART_REF} (monitor.enabled=true)..."
43+
helm "${HELM_ARGS[@]}"
44+
45+
echo "Waiting for isolator webhook deployment..."
46+
kubectl -n "${ISOLATOR_NAMESPACE}" rollout status \
47+
"deployment/${ISOLATOR_RELEASE}-webhook" --timeout=180s
48+
49+
echo "kai-resource-isolator installed (mutating webhook + monitor chart resources)."

test/e2e/suites/integrations/third_party/hamicore/hamicore_test.go

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,23 @@ SPDX-License-Identifier: Apache-2.0
55
package hamicore
66

77
import (
8+
"bytes"
89
"context"
910
"encoding/json"
1011
"fmt"
1112
"strconv"
1213
"strings"
14+
"time"
1315

1416
. "github.com/onsi/ginkgo/v2"
1517
. "github.com/onsi/gomega"
18+
dto "github.com/prometheus/client_model/go"
19+
"github.com/prometheus/common/expfmt"
20+
"github.com/prometheus/common/model"
1621
v1 "k8s.io/api/core/v1"
1722
"k8s.io/apimachinery/pkg/api/resource"
1823
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/client-go/kubernetes"
1925
"k8s.io/utils/ptr"
2026

2127
kaiv1binder "github.com/kai-scheduler/KAI-scheduler/pkg/apis/kai/v1/binder"
@@ -32,13 +38,51 @@ import (
3238

3339
const (
3440
kaiResourceIsolatorWebhookName = "kai-resource-isolator-mutating"
41+
kaiResourceIsolatorNamespace = "kai-resource-isolator"
42+
kaiVGPUMonitorDaemonSetName = "kai-resource-isolator-monitor"
43+
kaiVGPUMonitorLabel = "app.kubernetes.io/component=kai-vgpu-monitor"
44+
kaiVGPUMonitorMetricsPort = "9394"
3545
binderDeploymentName = "binder"
3646
binderDeploymentNamespace = "kai-scheduler"
3747
binderPluginsFlag = "--plugins"
3848
cudaImage = "nvidia/cuda:12.6.0-base-ubuntu22.04"
39-
gpuMemoryRequestMiB = 2000
49+
// devel image is needed so the metrics workload can nvcc a tiny cudaMalloc hold binary.
50+
cudaDevelImage = "nvidia/cuda:12.6.0-devel-ubuntu22.04"
51+
gpuMemoryRequestMiB = 2000
52+
cudaAllocHoldMiB = 64
53+
vgpuMetricsTimeout = 60 * time.Second
54+
vgpuMetricsInterval = 5 * time.Second
55+
hamiVGPUMemoryUsed = "hami_vgpu_memory_used_bytes"
4056
)
4157

58+
// Compiles and runs a small CUDA alloc that holds memory so kai-vgpu-monitor can
59+
// scrape hami_vgpu_memory_used_bytes > 0. Kept as a container entrypoint (not
60+
// ExecInPod) so the allocation outlives the ready wait.
61+
const cudaAllocHoldScript = `set -euo pipefail
62+
cat >/tmp/hold.cu <<'EOF'
63+
#include <cuda_runtime.h>
64+
#include <stdio.h>
65+
#include <unistd.h>
66+
int main(void) {
67+
void *p = NULL;
68+
size_t bytes = (size_t)64 * 1024 * 1024;
69+
cudaError_t err = cudaMalloc(&p, bytes);
70+
if (err != cudaSuccess) {
71+
fprintf(stderr, "cudaMalloc failed: %s\n", cudaGetErrorString(err));
72+
return 1;
73+
}
74+
printf("allocated %zu bytes at %p\n", bytes, p);
75+
fflush(stdout);
76+
for (;;) {
77+
sleep(3600);
78+
}
79+
return 0;
80+
}
81+
EOF
82+
nvcc -O0 -o /tmp/hold /tmp/hold.cu
83+
exec /tmp/hold
84+
`
85+
4286
var _ = Describe("HAMi-core resource isolation", Ordered, func() {
4387
var testCtx *testcontext.TestContext
4488

@@ -191,6 +235,45 @@ var _ = Describe("HAMi-core resource isolation", Ordered, func() {
191235
"nvidia-smi visible memory (%d MiB) should match CUDA_DEVICE_MEMORY_LIMIT (%d MiB)",
192236
visibleMemMiB, limitMiB)
193237
})
238+
239+
It("gpu-memory: kai-vgpu-monitor reports hami_vgpu_memory_used_bytes > 0",
240+
Label(labels.ReservationPod), func(ctx context.Context) {
241+
if !isKaiVGPUMonitorInstalled(ctx, testCtx.KubeClientset) {
242+
Skip(fmt.Sprintf(
243+
"kai-vgpu-monitor DaemonSet %q not found in namespace %q; "+
244+
"install via hack/third_party_integrations/deploy_isolator.sh",
245+
kaiVGPUMonitorDaemonSetName, kaiResourceIsolatorNamespace,
246+
))
247+
}
248+
249+
pod := rd.CreatePodObject(testCtx.Queues[0], v1.ResourceRequirements{})
250+
pod.Annotations[constants.GpuMemory] = strconv.Itoa(gpuMemoryRequestMiB)
251+
pod.Spec.Containers[0].Image = cudaDevelImage
252+
pod.Spec.Containers[0].Command = []string{"bash", "-c"}
253+
pod.Spec.Containers[0].Args = []string{cudaAllocHoldScript}
254+
255+
_, err := rd.CreatePod(ctx, testCtx.KubeClientset, pod)
256+
Expect(err).NotTo(HaveOccurred())
257+
wait.ForPodReady(ctx, testCtx.ControllerClient, pod)
258+
259+
pod, err = rd.GetPod(ctx, testCtx.KubeClientset, pod.Namespace, pod.Name)
260+
Expect(err).NotTo(HaveOccurred())
261+
Expect(pod.Spec.NodeName).NotTo(BeEmpty(), "pod should be scheduled to a node")
262+
containerName := pod.Spec.Containers[0].Name
263+
264+
By("waiting for kai-vgpu-monitor to expose hami_vgpu_memory_used_bytes for the workload")
265+
Eventually(func(g Gomega) {
266+
used, scrapeErr := scrapeHamiVGPUMemoryUsedBytes(
267+
ctx, testCtx.KubeClientset, pod.Spec.NodeName, pod.Namespace, pod.Name, containerName,
268+
)
269+
g.Expect(scrapeErr).NotTo(HaveOccurred())
270+
GinkgoLogr.Info("hami_vgpu_memory_used_bytes",
271+
"namespace", pod.Namespace, "pod", pod.Name, "container", containerName, "bytes", used)
272+
g.Expect(used).To(BeNumerically(">", 0),
273+
"expected hami_vgpu_memory_used_bytes > 0 for %s/%s container %s (held ~%d MiB)",
274+
pod.Namespace, pod.Name, containerName, cudaAllocHoldMiB)
275+
}, vgpuMetricsTimeout, vgpuMetricsInterval).Should(Succeed())
276+
})
194277
})
195278

196279
func printNvidiaSmi(ctx context.Context, testCtx *testcontext.TestContext, pod *v1.Pod) {
@@ -262,3 +345,88 @@ func hamiCoreEnabledInPluginsJSON(raw string) bool {
262345
}
263346
return ptr.Deref(cfg.Enabled, false)
264347
}
348+
349+
func isKaiVGPUMonitorInstalled(ctx context.Context, client kubernetes.Interface) bool {
350+
_, err := client.AppsV1().DaemonSets(kaiResourceIsolatorNamespace).
351+
Get(ctx, kaiVGPUMonitorDaemonSetName, metav1.GetOptions{})
352+
return err == nil
353+
}
354+
355+
func scrapeHamiVGPUMemoryUsedBytes(
356+
ctx context.Context,
357+
client kubernetes.Interface,
358+
nodeName, namespace, podName, containerName string,
359+
) (float64, error) {
360+
monitorPod, err := findMonitorPodOnNode(ctx, client, nodeName)
361+
if err != nil {
362+
return 0, err
363+
}
364+
365+
raw, err := client.CoreV1().Pods(monitorPod.Namespace).
366+
ProxyGet("http", monitorPod.Name, kaiVGPUMonitorMetricsPort, "/metrics", nil).
367+
DoRaw(ctx)
368+
if err != nil {
369+
return 0, fmt.Errorf("proxy GET /metrics from monitor pod %s/%s: %w",
370+
monitorPod.Namespace, monitorPod.Name, err)
371+
}
372+
373+
parser := expfmt.NewTextParser(model.UTF8Validation)
374+
families, err := parser.TextToMetricFamilies(bytes.NewReader(raw))
375+
if err != nil {
376+
return 0, fmt.Errorf("parse prometheus metrics: %w", err)
377+
}
378+
379+
family, ok := families[hamiVGPUMemoryUsed]
380+
if !ok {
381+
return 0, fmt.Errorf("metric %s not present in monitor scrape", hamiVGPUMemoryUsed)
382+
}
383+
384+
want := map[string]string{
385+
"namespace": namespace,
386+
"pod": podName,
387+
"container": containerName,
388+
}
389+
for _, metric := range family.GetMetric() {
390+
if !metricHasLabels(metric, want) {
391+
continue
392+
}
393+
if metric.GetGauge() == nil {
394+
return 0, fmt.Errorf("metric %s for %v is not a gauge", hamiVGPUMemoryUsed, want)
395+
}
396+
return metric.GetGauge().GetValue(), nil
397+
}
398+
return 0, fmt.Errorf("metric %s with labels %v not found", hamiVGPUMemoryUsed, want)
399+
}
400+
401+
func findMonitorPodOnNode(ctx context.Context, client kubernetes.Interface, nodeName string) (*v1.Pod, error) {
402+
pods, err := client.CoreV1().Pods(kaiResourceIsolatorNamespace).List(ctx, metav1.ListOptions{
403+
LabelSelector: kaiVGPUMonitorLabel,
404+
FieldSelector: "spec.nodeName=" + nodeName,
405+
})
406+
if err != nil {
407+
return nil, fmt.Errorf("list kai-vgpu-monitor pods on node %s: %w", nodeName, err)
408+
}
409+
for i := range pods.Items {
410+
pod := &pods.Items[i]
411+
if rd.IsPodReady(pod) {
412+
return pod, nil
413+
}
414+
}
415+
if len(pods.Items) == 0 {
416+
return nil, fmt.Errorf("no kai-vgpu-monitor pods scheduled on node %s", nodeName)
417+
}
418+
return nil, fmt.Errorf("kai-vgpu-monitor pod(s) on node %s are not Ready", nodeName)
419+
}
420+
421+
func metricHasLabels(metric *dto.Metric, want map[string]string) bool {
422+
got := make(map[string]string, len(metric.GetLabel()))
423+
for _, label := range metric.GetLabel() {
424+
got[label.GetName()] = label.GetValue()
425+
}
426+
for k, v := range want {
427+
if got[k] != v {
428+
return false
429+
}
430+
}
431+
return true
432+
}

0 commit comments

Comments
 (0)