Skip to content

Commit 5538f75

Browse files
committed
bug: core count
Signed-off-by: vsoch <vsoch@users.noreply.github.com>
1 parent 3b9c01f commit 5538f75

10 files changed

Lines changed: 158 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Everything in the graph that can be allocated is a node vertex. That includes th
3939

4040
Real nodes have the attribute virtual=false and virtual resources have virtual=true. A full set of attributes might include virtual=true, class=qpu, and region=us-east-1, because RFC 31 only checks whether a key is present. You select a resource by its class, which means any level of the tree can be asked for on its own. Classes are inherited so a nested type is not pruned at its parent on the way down.
4141

42+
A vertex is the unit Fluxion allocates. Anything under a slot is taken whole, so a node modeled as one core vertex of size 64 could hold a single slot of a gang, and two pods of one gang could only land on two nodes. Real nodes are built the way Flux builds them, one vertex per core and per GPU. Memory is chunked, which is how flux-sched's own test graphs and Slurm's memory per CPU work. Each node gets one memory chunk per core, sized as the node's memory split over its cores in MiB, and a pod's request in MiB takes however many whole chunks cover it. The vertex count stays at about twice the cores, and the kubelet's own fit check still admits the pod with the exact bytes.
43+
4244
## Resources
4345

4446
Here is an example of resources for AWS Braket.

‎examples/test/e2e/gang/multi-gang-contention.yaml‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# Two gangs that cannot both place. fluxion allocates one core per slot, so two
2-
# 2-pod gangs need 4 cores, but the cluster graphs ~3 (3 workers, ~1 core each). One gang places entirely. The loser stays FULLY pending
3-
# (all-or-nothing), never partial.
1+
# Two gangs that cannot both place. GANG_CPU is filled in by the test with the
2+
# free cores on a worker, so one pod fills one worker and a gang of two takes
3+
# both. One gang places entirely. The loser stays FULLY pending (all-or-nothing),
4+
# never partial.
45
apiVersion: apps/v1
56
kind: Deployment
67
metadata:
@@ -18,7 +19,7 @@ spec:
1819
- name: w
1920
image: busybox
2021
command: ["sleep", "3600"]
21-
resources: {requests: {cpu: "1"}}
22+
resources: {requests: {cpu: "${GANG_CPU}"}}
2223
---
2324
apiVersion: apps/v1
2425
kind: Deployment
@@ -37,4 +38,4 @@ spec:
3738
- name: w
3839
image: busybox
3940
command: ["sleep", "3600"]
40-
resources: {requests: {cpu: "1"}}
41+
resources: {requests: {cpu: "${GANG_CPU}"}}

‎examples/test/e2e/gang/multi-gang-requeue.yaml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# gang-wait. A 2-pod gang needing the same nodes. Loses the initial race and sits
55
# Unschedulable. When gang-win completes, gang-wait must be re-attempted
66
# (via the shortened unschedulable-recheck timeout) and place atomically.
7-
# On a 3-worker (~3-core) cluster the two 2-pod gangs (4 cores) cannot co-run.
7+
# GANG_CPU is filled in by the test with the free cores on a worker, so one pod
8+
# fills one worker and the two gangs cannot co-run.
89
apiVersion: batch/v1
910
kind: Job
1011
metadata:
@@ -24,7 +25,7 @@ spec:
2425
- name: w
2526
image: busybox
2627
command: ["sh","-c","sleep 30"] # completes, frees nodes
27-
resources: {requests: {cpu: "1"}}
28+
resources: {requests: {cpu: "${GANG_CPU}"}}
2829
---
2930
apiVersion: batch/v1
3031
kind: Job
@@ -45,4 +46,4 @@ spec:
4546
- name: w
4647
image: busybox
4748
command: ["sh","-c","sleep 10"]
48-
resources: {requests: {cpu: "1"}}
49+
resources: {requests: {cpu: "${GANG_CPU}"}}

‎pkg/cluster/cluster.go‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,23 @@ func BuildGraph(nodes []corev1.Node, opts Options) ([]byte, error) {
8585
})
8686
parents[n.Name] = nodeV
8787

88-
if cpu := count(n, corev1.ResourceCPU); cpu > 0 {
89-
b.AddChild(nodeV, "core", "core", jgf.Options{Size: cpu})
88+
// A vertex is the unit Fluxion allocates. Anything under a slot is taken
89+
// whole, so a node built as one sized vertex per type can host one slot
90+
// of a gang. Cores and GPUs are one vertex each, as in Flux. Memory is
91+
// chunked the way flux-sched's own graphs and Slurm's memory per CPU
92+
// do it, one chunk per core sized in MiB, so a request takes whole
93+
// chunks and the vertex count stays at twice the cores.
94+
cores := count(n, corev1.ResourceCPU)
95+
for i := int64(0); i < cores; i++ {
96+
b.AddChild(nodeV, "core", "core", jgf.Options{})
9097
}
91-
if memMB := memoryMB(n); memMB > 0 {
92-
b.AddChild(nodeV, "memory", "memory", jgf.Options{Size: memMB, Unit: "MB"})
98+
if chunk := memoryChunkMiB(n, cores); chunk > 0 {
99+
for i := int64(0); i < cores; i++ {
100+
b.AddChild(nodeV, "memory", "memory", jgf.Options{Size: chunk, Unit: "MiB"})
101+
}
93102
}
94-
if gpu := count(n, gpuName); gpu > 0 {
95-
b.AddChild(nodeV, "gpu", "gpu", jgf.Options{Size: gpu})
103+
for i := int64(0); i < count(n, gpuName); i++ {
104+
b.AddChild(nodeV, "gpu", "gpu", jgf.Options{})
96105
}
97106
}
98107

@@ -131,14 +140,19 @@ func count(n *corev1.Node, name corev1.ResourceName) int64 {
131140
return 0
132141
}
133142

134-
// memoryMB returns node memory in mebibytes.
135-
func memoryMB(n *corev1.Node) int64 {
143+
// memoryChunkMiB is the size of one memory chunk, the node's memory split
144+
// evenly over its cores, in whole MiB rounded down. Zero when there is no
145+
// memory or no cores to split it over.
146+
func memoryChunkMiB(n *corev1.Node, cores int64) int64 {
147+
if cores <= 0 {
148+
return 0
149+
}
136150
q, ok := n.Status.Allocatable[corev1.ResourceMemory]
137151
if !ok {
138152
q, ok = n.Status.Capacity[corev1.ResourceMemory]
139153
}
140154
if !ok {
141155
return 0
142156
}
143-
return q.Value() / (1024 * 1024)
157+
return q.Value() / (1024 * 1024) / cores
144158
}

‎pkg/cluster/cluster_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package cluster
22

33
import (
4+
"encoding/json"
45
"testing"
56

67
corev1 "k8s.io/api/core/v1"
8+
"k8s.io/apimachinery/pkg/api/resource"
79
)
810

911
// isControlPlane detects control-plane nodes by taint, independent of node name
@@ -38,3 +40,56 @@ func TestIsControlPlane(t *testing.T) {
3840
t.Error("node with no taints should not be control-plane")
3941
}
4042
}
43+
44+
// A real node is built with one vertex per core and per GPU, and memory as one
45+
// chunk per core, each chunk the node's memory split over its cores in whole
46+
// MiB. Fluxion hands a vertex to a slot and never splits it, so a node built
47+
// as one sized vertex per type could host one slot of a gang.
48+
func TestBuildGraphOneVertexPerUnit(t *testing.T) {
49+
n := corev1.Node{}
50+
n.Name = "worker"
51+
n.Status.Allocatable = corev1.ResourceList{
52+
corev1.ResourceCPU: resource.MustParse("4"),
53+
corev1.ResourceMemory: resource.MustParse("8704Mi"), // 8.5 GiB
54+
corev1.ResourceName(DefaultGPUResource): resource.MustParse("2"),
55+
}
56+
out, err := BuildGraph([]corev1.Node{n}, Options{})
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
var doc struct {
61+
Graph struct {
62+
Nodes []struct {
63+
Metadata struct {
64+
Type string `json:"type"`
65+
Size int64 `json:"size"`
66+
Unit string `json:"unit"`
67+
} `json:"metadata"`
68+
} `json:"nodes"`
69+
} `json:"graph"`
70+
}
71+
if err := json.Unmarshal(out, &doc); err != nil {
72+
t.Fatal(err)
73+
}
74+
count := map[string]int{}
75+
for _, v := range doc.Graph.Nodes {
76+
count[v.Metadata.Type]++
77+
switch v.Metadata.Type {
78+
case "memory":
79+
// 8704 MiB over 4 cores
80+
if v.Metadata.Size != 2176 || v.Metadata.Unit != "MiB" {
81+
t.Errorf("memory chunk = %d %s, want 2176 MiB", v.Metadata.Size, v.Metadata.Unit)
82+
}
83+
case "core", "gpu":
84+
if v.Metadata.Size != 1 {
85+
t.Errorf("%s vertex has size %d, want 1", v.Metadata.Type, v.Metadata.Size)
86+
}
87+
}
88+
}
89+
want := map[string]int{"cluster": 1, "node": 1, "core": 4, "memory": 4, "gpu": 2}
90+
for typ, w := range want {
91+
if count[typ] != w {
92+
t.Errorf("%s vertices = %d, want %d", typ, count[typ], w)
93+
}
94+
}
95+
}

‎pkg/placement/placement.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ func requireConstraintsFromPods(pods []corev1.Pod) []string {
144144
// fluxion.flux-framework.org/<type> prefix is a virtual device type instead.
145145
var computeTypes = map[string]bool{"core": true, "memory": true, "gpu": true}
146146

147+
// mib is the unit memory is requested in. The graph sizes its memory chunks
148+
// in MiB, so a request in MiB takes however many whole chunks cover it.
149+
const mib = 1024 * 1024
150+
147151
// podResources distills a pod's container requests into Fluxion resource counts
148152
// keyed by Fluxion graph type. Native Kubernetes resources (cpu, memory,
149153
// nvidia.com/gpu) map to compute types. Every fluxion.flux-framework.org/<type>
@@ -156,7 +160,8 @@ func podResources(p *corev1.Pod) map[string]int {
156160
case name == corev1.ResourceCPU:
157161
counts["core"] += int(q.Value()) // rounds millicores up to whole cores
158162
case name == corev1.ResourceMemory:
159-
counts["memory"] += int(q.Value() / (1000 * 1000)) // bytes -> MB
163+
// whole MiB rounded up, the unit the memory chunks are sized in
164+
counts["memory"] += int((q.Value() + mib - 1) / mib)
160165
case name == "nvidia.com/gpu":
161166
counts["gpu"] += int(q.Value())
162167
case strings.HasPrefix(string(name), FluxionResourcePrefix):

‎pkg/placement/placement_test.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,26 @@ func computeConstraints(t *testing.T, spec *jobspec.Jobspec) map[string]interfac
439439
}
440440
return cons
441441
}
442+
443+
// Memory requests are counted in whole MiB rounded up, the unit the graph
444+
// sizes its memory chunks in, so Fluxion takes however many chunks cover it.
445+
func TestMemoryRequestsCountInMiB(t *testing.T) {
446+
cases := []struct {
447+
req string
448+
want int
449+
}{
450+
{"128Mi", 128},
451+
{"1Gi", 1024},
452+
{"1536Ki", 2},
453+
{"100M", 96},
454+
}
455+
for _, tc := range cases {
456+
p := podWith("p", corev1.ResourceList{
457+
corev1.ResourceCPU: qty(1),
458+
corev1.ResourceMemory: resource.MustParse(tc.req),
459+
})
460+
if got := podResources(&p)["memory"]; got != tc.want {
461+
t.Errorf("memory %s = %d vertices, want %d", tc.req, got, tc.want)
462+
}
463+
}
464+
}

‎test/e2e/gang/03-multi-gang.sh‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ kubectl wait --for=delete pod -l app=gang3 --timeout=60s 2>/dev/null || true
3737

3838
# ---- B) contention: the gang that can't fully fit stays ENTIRELY pending --------
3939
log "TEST 6B: contention, a gang that cannot fully fit must NOT partially place"
40-
kubectl apply -f examples/test/e2e/gang/multi-gang-contention.yaml
40+
log "each pod asks for $(free_cores_per_worker) cores, the free cores on a worker, so a gang of two takes both workers"
41+
render_gang_manifest examples/test/e2e/gang/multi-gang-contention.yaml | kubectl apply -f -
4142

4243
# wait until the cluster settles. Three possible outcomes.
4344
# - one gang fully Running, other fully Pending -> contention; assert no partial
@@ -66,7 +67,7 @@ else
6667
log "PASS 6B: $loser stayed entirely pending, no partial placement under contention"
6768
fi
6869

69-
kubectl delete -f examples/test/e2e/gang/multi-gang-contention.yaml --wait=false || true
70+
render_gang_manifest examples/test/e2e/gang/multi-gang-contention.yaml | kubectl delete -f - --wait=false || true
7071
for g in gang-a gang-b; do
7172
kubectl patch podgroup $g --type=merge -p '{"metadata":{"finalizers":null}}' 2>/dev/null || true
7273
done

‎test/e2e/gang/04-requeue-on-capacity.sh‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
# re-attempted on its own (no manual nudge) and then ALSO places atomically
1111
# (2/2), driven by the shortened --pod-max-in-unschedulable-pods-duration.
1212
#
13-
# SCOPE / LIMITATION. This is a 3-node kind cluster with small (1-core) pods. It
14-
# verifies the INVARIANTS on a minimal contention case. It does NOT reproduce the
13+
# SCOPE / LIMITATION. This is a kind cluster with two workers, and each pod's cpu
14+
# request is raised to fill a worker, so one gang fits at a time. It verifies
15+
# the INVARIANTS on a minimal contention case. It does NOT reproduce the
1516
# GKE-scale dynamics where the bug was first seen, one-pod-per-node (~80-core)
1617
# saturation and ~20 simultaneous mixed-size gangs draining in sequence. That
1718
# scale behavior is validated on the real cluster, not in CI. A pass here means
@@ -25,7 +26,8 @@ running() { kubectl get pods -l job-name="$1" --field-selector=status.phase=Runn
2526
on_nodes() { kubectl get pods -l job-name="$1" -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' 2>/dev/null | grep -c . || true; }
2627

2728
log "TEST 9: contended gangs stay all-or-nothing, loser requeues when capacity frees"
28-
kubectl apply -f examples/test/e2e/gang/multi-gang-requeue.yaml
29+
log "each pod asks for $(free_cores_per_worker) cores, the free cores on a worker, so one gang fits at a time"
30+
render_gang_manifest examples/test/e2e/gang/multi-gang-requeue.yaml | kubectl apply -f -
2931

3032
# ---- 1. one gang wins CLEANLY (2/2); the other places NOTHING (0/2) ------------
3133
log "waiting for a clean 2/0 split (one whole gang runs, the other entirely waits)"
@@ -79,7 +81,7 @@ either the shortened --pod-max-in-unschedulable-pods-duration is not taking effe
7981
(gang stuck) or the requeued gang did not assemble"
8082
log "PASS 9: $loser requeued and placed atomically (2/2) after $winner freed capacity"
8183

82-
kubectl delete -f examples/test/e2e/gang/multi-gang-requeue.yaml --wait=false || true
84+
render_gang_manifest examples/test/e2e/gang/multi-gang-requeue.yaml | kubectl delete -f - --wait=false || true
8385
for g in gang-win gang-wait; do
8486
kubectl patch podgroup $g --type=merge -p '{"metadata":{"finalizers":null}}' 2>/dev/null || true
8587
done

‎test/e2e/lib.sh‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,33 @@ assert_stays_pending() {
8585
done
8686
return 0
8787
}
88+
89+
# cpu quantities to millicores, so "4" and "250m" add up
90+
millicores() { awk '{ if ($0 ~ /m$/) { sub(/m$/, ""); t += $0 } else if ($0 != "") { t += $0 * 1000 } } END { print t + 0 }'; }
91+
92+
# Whole cores free on the fullest worker. Allocatable less what the pods already
93+
# on it request, which is the arithmetic the kube-scheduler's fit check does. A
94+
# pod that asks for this fills a worker, so a two pod gang takes two workers and
95+
# two gangs contend, on any runner, with nothing assumed about the system pods.
96+
free_cores_per_worker() {
97+
local min="" node alloc used free
98+
for node in $(kubectl get nodes -l '!node-role.kubernetes.io/control-plane' -o jsonpath='{.items[*].metadata.name}'); do
99+
alloc="$(kubectl get node "$node" -o jsonpath='{.status.allocatable.cpu}' | millicores)"
100+
used="$(kubectl get pods -A --field-selector "spec.nodeName=$node,status.phase!=Succeeded,status.phase!=Failed" \
101+
-o jsonpath='{range .items[*].spec.containers[*]}{.resources.requests.cpu}{"\n"}{end}' | millicores)"
102+
free=$(( (alloc - used) / 1000 ))
103+
if [ -z "$min" ] || [ "$free" -lt "$min" ]; then min=$free; fi
104+
done
105+
if [ "${min:-0}" -lt 1 ]; then min=1; fi
106+
echo "$min"
107+
}
108+
109+
# Render a manifest whose cpu request is ${GANG_CPU} with the free cores on a
110+
# worker. Pipe it to kubectl apply or delete.
111+
render_gang_manifest() {
112+
local cpu line
113+
cpu="$(free_cores_per_worker)"
114+
while IFS= read -r line || [ -n "$line" ]; do
115+
printf '%s\n' "${line//\$\{GANG_CPU\}/$cpu}"
116+
done < "$1"
117+
}

0 commit comments

Comments
 (0)