Assigning Workloads¶
Problem Statement¶
Having a limited number of machines to schedule workloads on, assign the jobs so as to minimize the number of machines impacted.
Full example scenario
A DevOps engineer is responsible for scheduling workloads on a limited number of onsite machines, each with its own capacity in virtual CPUs (vCPU), RAM, and GPU FLOPS, and every workload requires a certain amount of these resources to run. Which workloads should go on which machines, in order to maximize the total resources of the machines left with no workloads assigned?
The total vCPU requirement of the workloads assigned to a machine must not exceed that machine’s vCPU capacity.
The total RAM requirement of the workloads assigned to a machine must not exceed that machine’s RAM capacity.
The total GPU FLOPS requirement of the workloads assigned to a machine must not exceed that machine’s GPU FLOPS capacity.
All workloads must be scheduled.
The machine data can be found in
assigning_workloads_machines.csv, with the columnsMachines,vCPU Capacity,RAM Capacity (GB),GPU Capacity (GFLOPS), and the workload data can be found inassigning_workloads_workloads.csv, with the columnsWorkloads,vCPU Requirement,RAM Requirement (GB),GPU Requirement (GFLOPS).
What an optimal solution looks like
Resource usage per machine, workload by workload.
Colored segments are the workloads hosted on that machine; grey is capacity left unused. Only Machines 1, 3 and 6 stay on — the rest have nothing assigned and could be powered down. Hover or focus a segment for its exact vCPU/RAM/GPU split.
Active machines — resource usage per machine.
Red marks the resource that is fully saturated on that machine — the one actually forcing it to stay active.
Machine 1
Hosts B, E, F, H
Machine 3
Hosts D only
Machine 6
Hosts A, C, G
Idle machines — freed and available to power down.
Machine 2
95 freed
16 vCPU · 64 GB · 15 GFLOPS
Machine 4
50 freed
8 vCPU · 32 GB · 10 GFLOPS
Machine 5
95 freed
16 vCPU · 64 GB · 15 GFLOPS
Machine 7
50 freed
8 vCPU · 32 GB · 10 GFLOPS
Machine 8
50 freed
8 vCPU · 32 GB · 10 GFLOPS
Workload D (32 vCPU, 64 GB, 25 GFLOPS) is the only workload that fits Machine 3's GPU capacity, so Machine 3 is always active. This is the baseline plan, before the Workload B / Workload H redundancy requirement introduced in the Changing the model tab. Under that requirement B and H swap onto Machine 1 and Machine 6 respectively — the same 340 units stay freed, only the hosting changes.
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same workload assignment problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled in your industry, so you can answer with a number instead of prose.
I’m a DevOps engineer. I have a limited number of onsite machines, and I need to assign a batch of workloads onto them. Each machine has some capacity, and each workload needs a chunk of that capacity to run. I want to pack the workloads onto as few machines as possible, so the rest sit idle and I can shut them down or save on them. Can you help me build this as an optimization model?
This reads as a bin-packing / assignment problem with a consolidation objective — workloads are items, machines are bins, and “pack onto as few machines as possible” means the objective rewards leaving some bins completely empty. Let me check the knowledge base for how similar resource-consolidation problems are modeled before asking anything.
Good, the knowledge base confirms this is an assignment problem with a machine-level idle indicator. Before anything else, I need to know what “capacity” actually means here — a single number, or several resources that each have their own limit — because that decides the whole variable and constraint structure, so it has to be the first question.
Good, I have useful context from the knowledge base. Let me now start building the specification with you by asking questions one at a time.
Question 1 of ~6: What dimension(s) does “capacity” represent for your machines?
A single resource (e.g., CPU cores only)
Multiple independent resources (e.g., CPU cores AND memory GB)
A generic “capacity unit” (e.g., a combined score or weight that represents overall load)
Which option best matches your situation, or would you like to describe it differently?
Option 2 — multiple independent resources: vCPU, RAM (GB), and GPU (GFLOPS). Every machine has a capacity for each of the three, and every workload has a requirement for each of the three. I have 8 machines and 8 workloads. One more requirement: every workload must be scheduled on exactly one machine, and instead of just minimizing the count of machines used, I actually want to maximize the total unused resources (summed across vCPU+RAM+GPU) of the machines that end up with zero workloads assigned — so it’s rewarded for freeing up the biggest machines, not just any machine. And to keep it simple, just add the three resources as a raw sum in that objective — no need to weight vCPU, RAM, and GPU differently.
That resolves the capacity question and volunteers four more things at once: the exact resource list, the problem size, the all-workloads-must- be-scheduled rule, and — most importantly — a fully specified objective, including how to combine three differently-scaled resources into one number. Nothing about the objective or the constraints is still ambiguous; the specification is buildable as-is.
Got it. Here is what I have captured so far:
That’s a complete specification to build from — no open questions remain. I’ll save this and move on to bringing in your data.
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
With the specification pinned down, Modeler asked for the machine and workload figures as files rather than as numbers typed into the chat. Attach both CSVs to the conversation and it reads the real shape of your data instead of guessing at it — entity names, column headers, units and all.
assigning_workloads_machines.csv:
Machines,vCPU Capacity,RAM Capacity (GB),GPU Capacity (GFLOPS)
Machine 1,32,128,20
Machine 2,16,64,15
Machine 3,32,64,30
Machine 4,8,32,10
Machine 5,16,64,15
Machine 6,32,128,20
Machine 7,8,32,10
Machine 8,8,32,10
assigning_workloads_workloads.csv:
Workloads,vCPU Requirement,RAM Requirement (GB),GPU Requirement (GFLOPS)
Workload A,8,16,5
Workload B,16,32,10
Workload C,4,8,2
Workload D,32,64,25
Workload E,2,4,1
Workload F,4,16,3
Workload G,16,32,12
Workload H,8,16,6
assigning_workloads_machines.csv and assigning_workloads_workloads.csv attached
Here are the 8 machines and their vCPU/RAM(GB)/GPU(GFLOPS) capacities, and the 8 workloads and their requirements in the same three units.
Reading both attachments. 8 rows each, columns already named to match what the specification expects — no unit conversion needed. Worth cross-checking capacity against demand before moving on, and worth flagging anything that constrains the solution space before I build from this data blind.
The specification is updated with both files. A quick read-out before we proceed:
Headroom: 152 vCPU / 544 GB RAM / 130 GFLOPS of total capacity against 90 / 188 / 64 of total demand — comfortable headroom on all three resources, so there is genuine room to consolidate.
Three machines share identical specs in pairs/triples — Machine 1 and Machine 6 (32 vCPU/128 GB/20 GFLOPS each), Machine 2 and Machine 5 (16/64/15 each), and Machines 4, 7, and 8 (8/32/10 each). Not a data problem, just something the solver will treat as interchangeable choices.
One binding flag: Workload D (32 vCPU, 64 GB, 25 GFLOPS) fits on exactly one machine — Machine 3 (32/64/30). Machines 1 and 6 have enough vCPU and RAM but only 20 GFLOPS of GPU, short of the 25 required. Machine 3 will therefore be active in every feasible solution.
Nothing here contradicts the specification. Shall I go ahead and implement it?
Where your files live
Everything in the conversation — the files you attached and every artifact Modeler generates from them — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The machine and workload data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills
in a missing implementation or test suite, reconciles code that has drifted
from the specification, and runs the tests. Point it at a model written for
another solver or another language and it recovers a specification from that
file, asks you to confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
A DevOps engineer manages a fixed pool of 8 onsite machines, each with defined vCPU, RAM (GB), and GPU (GFLOPS) capacities. A batch of 8 workloads must be scheduled, each requiring a specific amount of each resource. Rather than just minimizing the count of machines used, the model consolidates workloads so as to maximize the total raw capacity of whichever machines end up fully idle — freeing the largest, most valuable machines first so they can be shut down or taken out of rotation.
2. Objective
Maximize the total raw resource capacity (vCPU + RAM + GPU, summed as-is) of machines that have zero workloads assigned to them. A machine only contributes to the objective if it is completely idle.
3. Constraints
C1 — Workload assignment. Every workload must be scheduled on exactly one machine. No workload may be split across machines or left unscheduled.
C2 — vCPU capacity. For each machine, the total vCPU requirement of all workloads assigned to it must not exceed that machine’s vCPU capacity.
C3 — RAM capacity. For each machine, the total RAM requirement (GB) of all workloads assigned to it must not exceed that machine’s RAM capacity.
C4 — GPU capacity. For each machine, the total GPU requirement (GFLOPS) of all workloads assigned to it must not exceed that machine’s GPU capacity.
C5 — Idle machine linking. A machine is considered idle — and therefore contributes to the objective — only if no workload is assigned to it. If at least one workload is placed on a machine, that machine is not idle.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Machine |
machine, vCPU capacity, RAM capacity (GB), GPU capacity (GFLOPS) |
8 machines (Machine 1–8); total system capacity 152 vCPU, 544 GB RAM, 130 GFLOPS (826 raw units) |
Workload |
workload, vCPU requirement, RAM requirement (GB), GPU requirement (GFLOPS) |
8 workloads (Workload A–H); total demand 90 vCPU, 188 GB RAM, 64 GFLOPS (342 raw units) |
Total demand is well under total capacity on every resource, so there is genuine room to consolidate — but Workload D (32 vCPU, 64 GB, 25 GFLOPS) fits on exactly one machine, Machine 3 (32 vCPU, 64 GB, 30 GFLOPS); every other machine falls short on GPU. Machine 3 is therefore active in every feasible solution.
5. Output
The set of idle machines and the total raw capacity freed.
The assigned machine for every workload.
For each idle machine, its individual freed vCPU, RAM, and GPU.
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Baseline — the full 8-machine / 8-workload dataset. |
A feasible, optimal solution: every workload assigned, no machine over capacity on any resource, and the objective equals the summed raw capacity of the idle machines. |
T2 |
Machine 3 / Workload D near-full situation. |
Workload D is assigned to Machine 3, Machine 3 is never idle, and Workload D alone fills Machine 3’s vCPU (32/32) and RAM (64/64) exactly. |
T3 |
Infeasible — a workload’s requirements exceed every machine’s capacity on every resource. |
Reported as infeasible; no assignment is returned. |
T4 |
A data perturbation that changes which machines end up idle. |
The optimal idle set differs from T1, and the number of idle machines is strictly smaller. |
T5 |
A data perturbation that leaves the idle set unchanged. |
The optimal idle set and objective value are identical to T1. |
7. Out of scope
Workload preemption or migration (workloads cannot be moved once assigned).
Time-varying workloads or multi-period scheduling.
Machine startup/shutdown cost modelling.
Priority or SLA differentiation between workloads.
Partial resource allocation (all requirements and capacities are treated as whole units).
With the specification settled, Modeler writes the gurobipy
implementation. This is the version that came out of the run-and-fix loop in
the Cloud solve tab, after Modeler ran the tests against it.
"""
Workload Assignment Optimization
---------------------------------
Assigns a batch of workloads to onsite machines so as to maximise the total
raw capacity (vCPU + RAM + GPU) of fully-idle machines, enabling the freed
machines to be shut down or taken out of rotation.
Each workload must be placed on exactly one machine.
Each machine's vCPU, RAM, and GPU capacities must not be exceeded.
A machine is considered idle only if zero workloads are assigned to it.
"""
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
class WorkloadAssignmentOptimizer:
"""
Optimizes the assignment of workloads to machines to maximise freed
raw capacity (vCPU + RAM + GPU) of fully-idle machines.
"""
def __init__(self, env: gp.Env):
"""Initialize the optimizer with a Gurobi environment."""
self.env = env
self.model = None
# Input data
self.machines: pd.DataFrame = None
self.workloads: pd.DataFrame = None
# Index lists
self._machine_list: list = []
self._workload_list: list = []
self._resources: list = ["vcpu", "ram", "gpu"]
# Decision variables
self._x = {} # x[w, m]: 1 if workload w is assigned to machine m
self._y = {} # y[m]: 1 if machine m is fully idle
def __enter__(self):
"""Enter context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Dispose the Gurobi model on exit."""
if self.model is not None:
self.model.dispose()
return False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def set_data(self, machines: pd.DataFrame, workloads: pd.DataFrame):
"""
Load machine capacities and workload requirements.
Parameters
----------
machines : pd.DataFrame
Columns: machine, vcpu_capacity, ram_capacity, gpu_capacity
workloads : pd.DataFrame
Columns: workload, vcpu_req, ram_req, gpu_req
"""
self.machines = machines.copy()
self.workloads = workloads.copy()
self._machine_list = machines["machine"].tolist()
self._workload_list = workloads["workload"].tolist()
# ------------------------------------------------------------------
# Model construction
# ------------------------------------------------------------------
def build_model(self):
"""Build the Gurobi MIP model."""
self.model = gp.Model("WorkloadAssignment", env=self.env)
self.model.setParam("OutputFlag", 0) # suppress solver output by default
self._create_variables()
self._add_constraints()
self._set_objective()
def _create_variables(self):
"""
Create binary decision variables:
x[w, m] = 1 if workload w is assigned to machine m
y[m] = 1 if machine m is fully idle (no workloads)
"""
machines = self._machine_list
workloads = self._workload_list
# Assignment variables
self._x = self.model.addVars(
workloads, machines,
vtype=GRB.BINARY,
name="assign"
)
# Idle-machine indicator variables
self._y = self.model.addVars(
machines,
vtype=GRB.BINARY,
name="idle"
)
def _add_constraints(self):
"""Add all business constraints to the model."""
machines = self._machine_list
workloads = self._workload_list
x = self._x
y = self._y
# Lookup dicts for capacity and requirement values
machine_cap = {
row["machine"]: {
"vcpu": row["vcpu_capacity"],
"ram": row["ram_capacity"],
"gpu": row["gpu_capacity"],
}
for _, row in self.machines.iterrows()
}
workload_req = {
row["workload"]: {
"vcpu": row["vcpu_req"],
"ram": row["ram_req"],
"gpu": row["gpu_req"],
}
for _, row in self.workloads.iterrows()
}
# C1 — Each workload assigned to exactly one machine
self.model.addConstrs(
(gp.quicksum(x[w, m] for m in machines) == 1
for w in workloads),
name="C1_assign_workload"
)
# C2 — Resource capacity constraints for each machine and resource
for r in self._resources:
self.model.addConstrs(
(gp.quicksum(workload_req[w][r] * x[w, m] for w in workloads)
<= machine_cap[m][r]
for m in machines),
name=f"C2_capacity_{r}"
)
# C5 — Idle linking: if any workload is on machine m, m cannot be idle
# x[w,m] + y[m] <= 1 for all w, m
# (tight formulation, stronger LP relaxation than Big-M)
self.model.addConstrs(
(x[w, m] + y[m] <= 1
for w in workloads
for m in machines),
name="C5_idle_link"
)
def _set_objective(self):
"""
Maximise total raw capacity (vCPU + RAM + GPU) of idle machines.
Each machine contributes only if y[m] = 1.
"""
y = self._y
total_raw = {
row["machine"]: row["vcpu_capacity"] + row["ram_capacity"] + row["gpu_capacity"]
for _, row in self.machines.iterrows()
}
self.model.setObjective(
gp.quicksum(total_raw[m] * y[m] for m in self._machine_list),
GRB.MAXIMIZE
)
# ------------------------------------------------------------------
# Solving
# ------------------------------------------------------------------
def solve(self) -> int:
"""
Run Gurobi optimisation.
Returns
-------
int
Gurobi optimisation status code.
"""
self.model.optimize()
return self.model.Status
# ------------------------------------------------------------------
# Solution extraction
# ------------------------------------------------------------------
def get_solution(self) -> dict:
"""
Extract and return the optimisation solution.
Returns
-------
dict with keys:
status (int) : Gurobi status code
is_optimal (bool) : True if optimal solution found
objective_value(float) : Total freed raw capacity, or None
assignments (dict) : workload -> machine name
idle_machines (list) : list of idle machine names
freed_capacity (dict) : machine -> {vcpu, ram, gpu, total}
"""
status = self.model.Status
is_optimal = (status == GRB.OPTIMAL)
if not is_optimal:
return {
"status": status,
"is_optimal": False,
"objective_value": None,
"assignments": {},
"idle_machines": [],
"freed_capacity": {},
}
# Workload assignments
assignments = {}
for w in self._workload_list:
for m in self._machine_list:
if self._x[w, m].X > 0.5:
assignments[w] = m
break
# Idle machines and their freed capacities
idle_machines = []
freed_capacity = {}
machine_cap = {
row["machine"]: {
"vcpu": row["vcpu_capacity"],
"ram": row["ram_capacity"],
"gpu": row["gpu_capacity"],
}
for _, row in self.machines.iterrows()
}
for m in self._machine_list:
if self._y[m].X > 0.5:
idle_machines.append(m)
cap = machine_cap[m]
freed_capacity[m] = {
"vcpu": cap["vcpu"],
"ram": cap["ram"],
"gpu": cap["gpu"],
"total": cap["vcpu"] + cap["ram"] + cap["gpu"],
}
return {
"status": status,
"is_optimal": is_optimal,
"objective_value": self.model.ObjVal,
"assignments": assignments,
"idle_machines": idle_machines,
"freed_capacity": freed_capacity,
}
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def display_results(self):
"""Print a formatted summary of the optimisation solution."""
sol = self.get_solution()
print("=" * 60)
print(" WORKLOAD ASSIGNMENT OPTIMISATION RESULTS")
print("=" * 60)
if not sol["is_optimal"]:
print(f" No optimal solution found. Gurobi status: {sol['status']}")
print("=" * 60)
return
print(f" Objective — Total Freed Raw Capacity : {sol['objective_value']:.0f} units")
print(f" Idle machines : {len(sol['idle_machines'])}")
print()
print(" Idle Machines (freed capacity):")
print(f" {'Machine':<12} {'vCPU':>6} {'RAM(GB)':>8} {'GPU(GFLOPS)':>12} {'Total':>7}")
print(" " + "-" * 47)
for m, fc in sol["freed_capacity"].items():
print(f" {m:<12} {fc['vcpu']:>6} {fc['ram']:>8} {fc['gpu']:>12} {fc['total']:>7}")
print()
print(" Workload Assignments:")
print(f" {'Workload':<12} {'Assigned To':<12}")
print(" " + "-" * 26)
for w, m in sorted(sol["assignments"].items()):
print(f" {w:<12} {m:<12}")
print("=" * 60)
# ----------------------------------------------------------------------
# Sample usage with real data
# ----------------------------------------------------------------------
def main():
machines_data = pd.DataFrame([
{"machine": "Machine 1", "vcpu_capacity": 32, "ram_capacity": 128, "gpu_capacity": 20},
{"machine": "Machine 2", "vcpu_capacity": 16, "ram_capacity": 64, "gpu_capacity": 15},
{"machine": "Machine 3", "vcpu_capacity": 32, "ram_capacity": 64, "gpu_capacity": 30},
{"machine": "Machine 4", "vcpu_capacity": 8, "ram_capacity": 32, "gpu_capacity": 10},
{"machine": "Machine 5", "vcpu_capacity": 16, "ram_capacity": 64, "gpu_capacity": 15},
{"machine": "Machine 6", "vcpu_capacity": 32, "ram_capacity": 128, "gpu_capacity": 20},
{"machine": "Machine 7", "vcpu_capacity": 8, "ram_capacity": 32, "gpu_capacity": 10},
{"machine": "Machine 8", "vcpu_capacity": 8, "ram_capacity": 32, "gpu_capacity": 10},
])
workloads_data = pd.DataFrame([
{"workload": "Workload A", "vcpu_req": 8, "ram_req": 16, "gpu_req": 5},
{"workload": "Workload B", "vcpu_req": 16, "ram_req": 32, "gpu_req": 10},
{"workload": "Workload C", "vcpu_req": 4, "ram_req": 8, "gpu_req": 2},
{"workload": "Workload D", "vcpu_req": 32, "ram_req": 64, "gpu_req": 25},
{"workload": "Workload E", "vcpu_req": 2, "ram_req": 4, "gpu_req": 1},
{"workload": "Workload F", "vcpu_req": 4, "ram_req": 16, "gpu_req": 3},
{"workload": "Workload G", "vcpu_req": 16, "ram_req": 32, "gpu_req": 12},
{"workload": "Workload H", "vcpu_req": 8, "ram_req": 16, "gpu_req": 6},
])
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 1)
env.setParam("TimeLimit", 60)
env.start()
with WorkloadAssignmentOptimizer(env) as optimizer:
optimizer.set_data(machines_data, workloads_data)
optimizer.build_model()
optimizer.solve()
optimizer.display_results()
if __name__ == "__main__":
main()
Alongside the implementation, Modeler writes a pytest suite with one class
of tests per scenario in the specification. Each test class references its
scenario identifier, and uses the small hard-coded machine and workload data
and a short solver time limit so the suite runs in well under a second.
"""
Unit tests for the WorkloadAssignmentOptimizer optimizer.
Covers all test scenarios defined in the specification:
T1 - Basic optimization with real data
T2 - Machine 3 / Workload D near-full situation
T3 - Infeasibility: workload exceeds all machine capacities
T4 - Perturbation that CHANGES the set of idle machines
T5 - Perturbation that LEAVES the idle set unchanged
"""
import time
import pytest
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
from assigning_workloads_modeler import WorkloadAssignmentOptimizer
TIME_LIMIT = 10 # seconds per test
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def gurobi_env():
"""Single Gurobi environment shared across all tests in this module."""
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 0)
env.setParam("TimeLimit", TIME_LIMIT)
env.start()
yield env
@pytest.fixture
def real_machines():
"""The 8 real onsite machines."""
return pd.DataFrame([
{"machine": "Machine 1", "vcpu_capacity": 32, "ram_capacity": 128, "gpu_capacity": 20},
{"machine": "Machine 2", "vcpu_capacity": 16, "ram_capacity": 64, "gpu_capacity": 15},
{"machine": "Machine 3", "vcpu_capacity": 32, "ram_capacity": 64, "gpu_capacity": 30},
{"machine": "Machine 4", "vcpu_capacity": 8, "ram_capacity": 32, "gpu_capacity": 10},
{"machine": "Machine 5", "vcpu_capacity": 16, "ram_capacity": 64, "gpu_capacity": 15},
{"machine": "Machine 6", "vcpu_capacity": 32, "ram_capacity": 128, "gpu_capacity": 20},
{"machine": "Machine 7", "vcpu_capacity": 8, "ram_capacity": 32, "gpu_capacity": 10},
{"machine": "Machine 8", "vcpu_capacity": 8, "ram_capacity": 32, "gpu_capacity": 10},
])
@pytest.fixture
def real_workloads():
"""The 8 real workloads."""
return pd.DataFrame([
{"workload": "Workload A", "vcpu_req": 8, "ram_req": 16, "gpu_req": 5},
{"workload": "Workload B", "vcpu_req": 16, "ram_req": 32, "gpu_req": 10},
{"workload": "Workload C", "vcpu_req": 4, "ram_req": 8, "gpu_req": 2},
{"workload": "Workload D", "vcpu_req": 32, "ram_req": 64, "gpu_req": 25},
{"workload": "Workload E", "vcpu_req": 2, "ram_req": 4, "gpu_req": 1},
{"workload": "Workload F", "vcpu_req": 4, "ram_req": 16, "gpu_req": 3},
{"workload": "Workload G", "vcpu_req": 16, "ram_req": 32, "gpu_req": 12},
{"workload": "Workload H", "vcpu_req": 8, "ram_req": 16, "gpu_req": 6},
])
def run_optimizer(env, machines, workloads):
"""Helper: build, solve, and return solution dict."""
with WorkloadAssignmentOptimizer(env) as opt:
opt.set_data(machines, workloads)
opt.build_model()
opt.solve()
return opt.get_solution()
# ---------------------------------------------------------------------------
# T1 - Basic optimization with real data
# ---------------------------------------------------------------------------
class TestT1BasicOptimization:
"""T1: Full real dataset - verify feasibility, assignments, and constraint satisfaction."""
def test_t1_is_optimal(self, gurobi_env, real_machines, real_workloads):
"""T1: Model must find an optimal solution on real data."""
start = time.time()
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert time.time() - start < TIME_LIMIT, "Test exceeded time limit"
assert sol["is_optimal"] is True
def test_t1_all_workloads_assigned(self, gurobi_env, real_machines, real_workloads):
"""T1: All 8 workloads must appear in the assignments dict."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
expected = {f"Workload {c}" for c in "ABCDEFGH"}
assert set(sol["assignments"].keys()) == expected
def test_t1_each_workload_on_one_machine(self, gurobi_env, real_machines, real_workloads):
"""T1: Each workload is assigned to exactly one valid machine."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
valid_machines = set(real_machines["machine"].tolist())
for w, m in sol["assignments"].items():
assert m in valid_machines, f"{w} assigned to unknown machine {m}"
def test_t1_resource_constraints_satisfied(self, gurobi_env, real_machines, real_workloads):
"""T1: No machine exceeds its vCPU, RAM, or GPU capacity."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
cap = real_machines.set_index("machine")
req = real_workloads.set_index("workload")
tol = 1e-6
machine_load = {m: {"vcpu": 0, "ram": 0, "gpu": 0}
for m in real_machines["machine"]}
for w, m in sol["assignments"].items():
machine_load[m]["vcpu"] += req.loc[w, "vcpu_req"]
machine_load[m]["ram"] += req.loc[w, "ram_req"]
machine_load[m]["gpu"] += req.loc[w, "gpu_req"]
for m, load in machine_load.items():
assert load["vcpu"] <= cap.loc[m, "vcpu_capacity"] + tol, \
f"{m} vCPU exceeded: {load['vcpu']} > {cap.loc[m, 'vcpu_capacity']}"
assert load["ram"] <= cap.loc[m, "ram_capacity"] + tol, \
f"{m} RAM exceeded: {load['ram']} > {cap.loc[m, 'ram_capacity']}"
assert load["gpu"] <= cap.loc[m, "gpu_capacity"] + tol, \
f"{m} GPU exceeded: {load['gpu']} > {cap.loc[m, 'gpu_capacity']}"
def test_t1_objective_equals_freed_capacity(self, gurobi_env, real_machines, real_workloads):
"""T1: objective_value must equal the sum of raw capacities of idle machines."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
cap = real_machines.set_index("machine")
computed_freed = sum(
cap.loc[m, "vcpu_capacity"] + cap.loc[m, "ram_capacity"] + cap.loc[m, "gpu_capacity"]
for m in sol["idle_machines"]
)
assert abs(sol["objective_value"] - computed_freed) < 1e-4
def test_t1_positive_objective(self, gurobi_env, real_machines, real_workloads):
"""T1: At least one machine must be freed (objective > 0)."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
assert sol["objective_value"] > 0
def test_t1_machine3_not_idle(self, gurobi_env, real_machines, real_workloads):
"""T1: Machine 3 must never be idle - it is the only machine that can host Workload D."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
assert "Machine 3" not in sol["idle_machines"], \
"Machine 3 should always be active (only host for Workload D)"
# ---------------------------------------------------------------------------
# T2 - Machine 3 / Workload D near-full situation
# ---------------------------------------------------------------------------
class TestT2Machine3WorkloadD:
"""T2: Verify Workload D forces Machine 3 active and near-full constraints are respected."""
def test_t2_workload_d_on_machine3(self, gurobi_env, real_machines, real_workloads):
"""T2: Workload D must be assigned to Machine 3 - it is the only feasible host."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
assert sol["assignments"].get("Workload D") == "Machine 3", \
"Workload D must be assigned to Machine 3 (only machine with sufficient GPU)"
def test_t2_machine3_active(self, gurobi_env, real_machines, real_workloads):
"""T2: Machine 3 must not appear in idle_machines."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
assert "Machine 3" not in sol["idle_machines"]
def test_t2_machine3_vcpu_not_exceeded(self, gurobi_env, real_machines, real_workloads):
"""T2: Total vCPU of workloads on Machine 3 must not exceed 32 cores."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
req = real_workloads.set_index("workload")
total_vcpu = sum(
req.loc[w, "vcpu_req"]
for w, m in sol["assignments"].items()
if m == "Machine 3"
)
assert total_vcpu <= 32, f"Machine 3 vCPU overloaded: {total_vcpu} > 32"
def test_t2_machine3_gpu_not_exceeded(self, gurobi_env, real_machines, real_workloads):
"""T2: Total GPU of workloads on Machine 3 must not exceed 30 GFLOPS."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
req = real_workloads.set_index("workload")
total_gpu = sum(
req.loc[w, "gpu_req"]
for w, m in sol["assignments"].items()
if m == "Machine 3"
)
assert total_gpu <= 30, f"Machine 3 GPU overloaded: {total_gpu} > 30"
def test_t2_workload_d_fills_machine3_vcpu_and_ram(self, gurobi_env, real_machines, real_workloads):
"""T2: Workload D alone uses all 32 vCPU and all 64 GB RAM of Machine 3."""
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
assert sol["is_optimal"] is True
req = real_workloads.set_index("workload")
workloads_on_m3 = [w for w, m in sol["assignments"].items() if m == "Machine 3"]
total_vcpu_on_m3 = sum(req.loc[w, "vcpu_req"] for w in workloads_on_m3)
total_ram_on_m3 = sum(req.loc[w, "ram_req"] for w in workloads_on_m3)
assert total_vcpu_on_m3 == 32, \
f"Machine 3 vCPU should be exactly 32, got {total_vcpu_on_m3}"
assert total_ram_on_m3 == 64, \
f"Machine 3 RAM should be exactly 64 GB, got {total_ram_on_m3}"
# ---------------------------------------------------------------------------
# T3 - Infeasibility: workload exceeds all machine capacities
# ---------------------------------------------------------------------------
class TestT3Infeasibility:
"""T3: A super-workload that no machine can host must make the model infeasible."""
def _build_infeasible_data(self, real_machines, real_workloads):
"""Replace Workload D with requirements exceeding every machine."""
wl = real_workloads.copy()
mask = wl["workload"] == "Workload D"
wl.loc[mask, "vcpu_req"] = 999
wl.loc[mask, "ram_req"] = 999
wl.loc[mask, "gpu_req"] = 999
return real_machines, wl
def test_t3_not_optimal(self, gurobi_env, real_machines, real_workloads):
"""T3: Model must report non-optimal status when infeasible."""
start = time.time()
machines, workloads = self._build_infeasible_data(real_machines, real_workloads)
sol = run_optimizer(gurobi_env, machines, workloads)
assert time.time() - start < TIME_LIMIT
assert sol["is_optimal"] is False
def test_t3_objective_none(self, gurobi_env, real_machines, real_workloads):
"""T3: objective_value must be None when no feasible solution exists."""
machines, workloads = self._build_infeasible_data(real_machines, real_workloads)
sol = run_optimizer(gurobi_env, machines, workloads)
assert sol["objective_value"] is None
def test_t3_gurobi_status_infeasible(self, gurobi_env, real_machines, real_workloads):
"""T3: Gurobi status code must be INFEASIBLE (3)."""
machines, workloads = self._build_infeasible_data(real_machines, real_workloads)
sol = run_optimizer(gurobi_env, machines, workloads)
assert sol["status"] == GRB.INFEASIBLE
def test_t3_empty_assignments(self, gurobi_env, real_machines, real_workloads):
"""T3: assignments dict must be empty when infeasible."""
machines, workloads = self._build_infeasible_data(real_machines, real_workloads)
sol = run_optimizer(gurobi_env, machines, workloads)
assert sol["assignments"] == {}
# ---------------------------------------------------------------------------
# T4 - Perturbation that CHANGES the idle set
# ---------------------------------------------------------------------------
class TestT4PerturbationChangesIdle:
"""
T4: Inflate Workload B RAM from 32 GB to 110 GB.
Machine 1 hosts B alone; E+F+H must move to a previously idle machine.
The idle set shrinks: Machine 2 is now active.
"""
def _get_t1_idle_set(self, gurobi_env, real_machines, real_workloads):
sol = run_optimizer(gurobi_env, real_machines, real_workloads)
return set(sol["idle_machines"])
def _build_t4_workloads(self, real_workloads):
"""Inflate Workload B RAM from 32 GB to 110 GB."""
wl = real_workloads.copy()
wl.loc[wl["workload"] == "Workload B", "ram_req"] = 110
return wl
def test_t4_is_optimal(self, gurobi_env, real_machines, real_workloads):
"""T4: Perturbed model must still be feasible and optimal."""
start = time.time()
workloads = self._build_t4_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert time.time() - start < TIME_LIMIT
assert sol["is_optimal"] is True
def test_t4_idle_set_changes(self, gurobi_env, real_machines, real_workloads):
"""T4: The set of idle machines must differ from the T1 baseline after perturbation."""
t1_idle = self._get_t1_idle_set(gurobi_env, real_machines, real_workloads)
workloads = self._build_t4_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
t4_idle = set(sol["idle_machines"])
assert t4_idle != t1_idle, \
f"Expected idle set to change after perturbation, but got same set: {t4_idle}"
def test_t4_fewer_idle_machines_than_t1(self, gurobi_env, real_machines, real_workloads):
"""T4: The T4 perturbation forces at least one more machine active vs T1."""
t1_idle = self._get_t1_idle_set(gurobi_env, real_machines, real_workloads)
workloads = self._build_t4_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
t4_idle = set(sol["idle_machines"])
assert len(t4_idle) < len(t1_idle), \
f"Expected fewer idle machines in T4 ({len(t4_idle)}) than T1 ({len(t1_idle)})"
def test_t4_all_workloads_assigned(self, gurobi_env, real_machines, real_workloads):
"""T4: All 8 workloads must still be assigned after perturbation."""
workloads = self._build_t4_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
assert len(sol["assignments"]) == 8
def test_t4_resource_constraints_satisfied(self, gurobi_env, real_machines, real_workloads):
"""T4: Resource constraints must hold for the perturbed workload set."""
workloads = self._build_t4_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
cap = real_machines.set_index("machine")
req = workloads.set_index("workload")
tol = 1e-6
machine_load = {m: {"vcpu": 0, "ram": 0, "gpu": 0}
for m in real_machines["machine"]}
for w, m in sol["assignments"].items():
machine_load[m]["vcpu"] += req.loc[w, "vcpu_req"]
machine_load[m]["ram"] += req.loc[w, "ram_req"]
machine_load[m]["gpu"] += req.loc[w, "gpu_req"]
for m, load in machine_load.items():
assert load["vcpu"] <= cap.loc[m, "vcpu_capacity"] + tol, \
f"{m} vCPU exceeded: {load['vcpu']} > {cap.loc[m,'vcpu_capacity']}"
assert load["ram"] <= cap.loc[m, "ram_capacity"] + tol, \
f"{m} RAM exceeded: {load['ram']} > {cap.loc[m,'ram_capacity']}"
assert load["gpu"] <= cap.loc[m, "gpu_capacity"] + tol, \
f"{m} GPU exceeded: {load['gpu']} > {cap.loc[m,'gpu_capacity']}"
# ---------------------------------------------------------------------------
# T5 - Perturbation that LEAVES the idle set unchanged
# ---------------------------------------------------------------------------
class TestT5PerturbationUnchangedIdle:
"""
T5: Increase Workload C RAM from 8 GB to 9 GB.
Machine 6 RAM load goes from 56 to 57 GB (well under 128 GB cap).
The idle set stays {Machine 2, 4, 5, 7, 8} and objective stays 340.
"""
def _get_t1_solution(self, gurobi_env, real_machines, real_workloads):
return run_optimizer(gurobi_env, real_machines, real_workloads)
def _build_t5_workloads(self, real_workloads):
"""Increase Workload C RAM from 8 GB to 9 GB."""
wl = real_workloads.copy()
wl.loc[wl["workload"] == "Workload C", "ram_req"] = 9
return wl
def test_t5_is_optimal(self, gurobi_env, real_machines, real_workloads):
"""T5: Perturbed model must remain optimal."""
start = time.time()
workloads = self._build_t5_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert time.time() - start < TIME_LIMIT
assert sol["is_optimal"] is True
def test_t5_idle_set_unchanged(self, gurobi_env, real_machines, real_workloads):
"""T5: The set of idle machines must be identical to the T1 baseline."""
t1_sol = self._get_t1_solution(gurobi_env, real_machines, real_workloads)
t1_idle = set(t1_sol["idle_machines"])
workloads = self._build_t5_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
t5_idle = set(sol["idle_machines"])
assert t5_idle == t1_idle, \
f"Expected idle set {t1_idle} to be unchanged, but got {t5_idle}"
def test_t5_objective_unchanged(self, gurobi_env, real_machines, real_workloads):
"""T5: objective_value must equal the T1 value."""
t1_sol = self._get_t1_solution(gurobi_env, real_machines, real_workloads)
t1_obj = t1_sol["objective_value"]
workloads = self._build_t5_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
assert abs(sol["objective_value"] - t1_obj) < 1e-4, \
f"Expected objective {t1_obj}, got {sol['objective_value']}"
def test_t5_all_workloads_assigned(self, gurobi_env, real_machines, real_workloads):
"""T5: All 8 workloads must still be assigned after minor perturbation."""
workloads = self._build_t5_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
assert len(sol["assignments"]) == 8
def test_t5_resource_constraints_satisfied(self, gurobi_env, real_machines, real_workloads):
"""T5: All resource constraints must hold after the minor perturbation."""
workloads = self._build_t5_workloads(real_workloads)
sol = run_optimizer(gurobi_env, real_machines, workloads)
assert sol["is_optimal"] is True
cap = real_machines.set_index("machine")
req = workloads.set_index("workload")
tol = 1e-6
machine_load = {m: {"vcpu": 0, "ram": 0, "gpu": 0}
for m in real_machines["machine"]}
for w, m in sol["assignments"].items():
machine_load[m]["vcpu"] += req.loc[w, "vcpu_req"]
machine_load[m]["ram"] += req.loc[w, "ram_req"]
machine_load[m]["gpu"] += req.loc[w, "gpu_req"]
for m, load in machine_load.items():
assert load["vcpu"] <= cap.loc[m, "vcpu_capacity"] + tol
assert load["ram"] <= cap.loc[m, "ram_capacity"] + tol
assert load["gpu"] <= cap.loc[m, "gpu_capacity"] + tol
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve. It reads the output and reports back, so what you get back is a model that has been executed, not one that merely looks plausible.
That covers everything — please finalize the specification, then implement it in gurobipy, write a pytest suite covering the test scenarios, run the tests, and report the results.
No open questions left to ask — every field the implementation needs
(the three resource dimensions, the idle-linking rule, the raw-sum
objective) is already pinned down in the specification, and this
problem is small and clean enough that there’s no genuinely open
structural choice left either. Writing the
WorkloadAssignmentOptimizer class with one binary variable per
(workload, machine) pair plus a binary idle indicator per machine,
then a pytest suite covering T1-T5, then running both the script and
the suite.
The run
All 26 tests pass in 0.32 seconds on the first run — no failures to chase down here. Here is the full unedited solver output, plus a report of everything that was produced.
$ python workload_assignment.py
Set parameter TimeLimit to value 60
Gurobi Optimizer version 12.0.3 build v12.0.3rc0 (mac64[arm] - Darwin 25.6.0 25G76)
CPU model: Apple M5 Pro
Thread count: 18 physical cores, 18 logical processors, using up to 18 threads
Non-default parameters:
TimeLimit 60
Optimize a model with 96 rows, 72 columns and 384 nonzeros
Model fingerprint: 0xbece674c
Variable types: 0 continuous, 72 integer (72 binary)
Coefficient statistics:
Matrix range [1e+00, 6e+01]
Objective range [5e+01, 2e+02]
Bounds range [1e+00, 1e+00]
RHS range [1e+00, 1e+02]
Found heuristic solution: objective 145.0000000
Presolve removed 49 rows and 22 columns
Presolve time: 0.00s
Presolved: 47 rows, 50 columns, 202 nonzeros
Variable types: 0 continuous, 50 integer (50 binary)
Root relaxation: objective 4.250000e+02, 46 iterations, 0.00 seconds (0.00 work units)
Nodes | Current Node | Objective Bounds | Work
Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time
0 0 425.00000 0 14 145.00000 425.00000 193% - 0s
H 0 0 245.0000000 425.00000 73.5% - 0s
H 0 0 330.0000000 425.00000 28.8% - 0s
0 0 389.37500 0 14 330.00000 389.37500 18.0% - 0s
0 0 366.66667 0 25 330.00000 366.66667 11.1% - 0s
0 0 366.66667 0 19 330.00000 366.66667 11.1% - 0s
0 0 366.66667 0 23 330.00000 366.66667 11.1% - 0s
0 0 364.75000 0 26 330.00000 364.75000 10.5% - 0s
0 0 358.33333 0 27 330.00000 358.33333 8.59% - 0s
0 0 349.28571 0 17 330.00000 349.28571 5.84% - 0s
0 0 340.00000 0 15 330.00000 340.00000 3.03% - 0s
0 2 340.00000 0 15 330.00000 340.00000 3.03% - 0s
* 1 2 1 340.0000000 340.00000 0.00% 16.0 0s
Cutting planes:
Cover: 2
Zero half: 3
Relax-and-lift: 4
Explored 3 nodes (146 simplex iterations) in 0.01 seconds (0.01 work units)
Solution count 4: 340 330 245 145
Optimal solution found (tolerance 1.00e-04)
Best objective 3.400000000000e+02, best bound 3.400000000000e+02, gap 0.0000%
$ pytest test_workload_assignment.py -v
============================= test session starts ==============================
collected 26 items
test_workload_assignment.py::TestT1BasicOptimization::test_t1_is_optimal PASSED
test_workload_assignment.py::TestT1BasicOptimization::test_t1_all_workloads_assigned PASSED
test_workload_assignment.py::TestT1BasicOptimization::test_t1_each_workload_on_one_machine PASSED
test_workload_assignment.py::TestT1BasicOptimization::test_t1_resource_constraints_satisfied PASSED
test_workload_assignment.py::TestT1BasicOptimization::test_t1_objective_equals_freed_capacity PASSED
test_workload_assignment.py::TestT1BasicOptimization::test_t1_positive_objective PASSED
test_workload_assignment.py::TestT1BasicOptimization::test_t1_machine3_not_idle PASSED
test_workload_assignment.py::TestT2Machine3WorkloadD::test_t2_workload_d_on_machine3 PASSED
test_workload_assignment.py::TestT2Machine3WorkloadD::test_t2_machine3_active PASSED
test_workload_assignment.py::TestT2Machine3WorkloadD::test_t2_machine3_vcpu_not_exceeded PASSED
test_workload_assignment.py::TestT2Machine3WorkloadD::test_t2_machine3_gpu_not_exceeded PASSED
test_workload_assignment.py::TestT2Machine3WorkloadD::test_t2_workload_d_fills_machine3_vcpu_and_ram PASSED
test_workload_assignment.py::TestT3Infeasibility::test_t3_not_optimal PASSED
test_workload_assignment.py::TestT3Infeasibility::test_t3_objective_none PASSED
test_workload_assignment.py::TestT3Infeasibility::test_t3_gurobi_status_infeasible PASSED
test_workload_assignment.py::TestT3Infeasibility::test_t3_empty_assignments PASSED
test_workload_assignment.py::TestT4PerturbationChangesIdle::test_t4_is_optimal PASSED
test_workload_assignment.py::TestT4PerturbationChangesIdle::test_t4_idle_set_changes PASSED
test_workload_assignment.py::TestT4PerturbationChangesIdle::test_t4_fewer_idle_machines_than_t1 PASSED
test_workload_assignment.py::TestT4PerturbationChangesIdle::test_t4_all_workloads_assigned PASSED
test_workload_assignment.py::TestT4PerturbationChangesIdle::test_t4_resource_constraints_satisfied PASSED
test_workload_assignment.py::TestT5PerturbationUnchangedIdle::test_t5_is_optimal PASSED
test_workload_assignment.py::TestT5PerturbationUnchangedIdle::test_t5_idle_set_unchanged PASSED
test_workload_assignment.py::TestT5PerturbationUnchangedIdle::test_t5_objective_unchanged PASSED
test_workload_assignment.py::TestT5PerturbationUnchangedIdle::test_t5_all_workloads_assigned PASSED
test_workload_assignment.py::TestT5PerturbationUnchangedIdle::test_t5_resource_constraints_satisfied PASSED
============================== 26 passed in 0.32s ==============================
All 26 tests pass. One thing worth noting from the solver log: the relaxation starts at 425 and the branch-and-bound explores only 3 nodes before closing the gap at 340 — Gurobi’s own heuristics find the improving sequence 145 → 245 → 330 → 340 well before any deep branching is needed. Here is a full summary of everything that was built:
Results
Optimal plan — 3 machines active, 5 freed for a total of 340 units:
Machine |
Workloads |
vCPU |
RAM (GB) |
GPU (GFLOPS) |
|---|---|---|---|---|
Machine 1 |
B, E, F, H |
30 / 32 |
68 / 128 |
20 / 20 |
Machine 3 |
D |
32 / 32 |
64 / 64 |
25 / 30 |
Machine 6 |
A, C, G |
28 / 32 |
56 / 128 |
19 / 20 |
Idle machine |
vCPU freed |
RAM freed (GB) |
GPU freed (GFLOPS) |
Total freed |
|---|---|---|---|---|
Machine 2 |
16 |
64 |
15 |
95 |
Machine 4 |
8 |
32 |
10 |
50 |
Machine 5 |
16 |
64 |
15 |
95 |
Machine 7 |
8 |
32 |
10 |
50 |
Machine 8 |
8 |
32 |
10 |
50 |
Workload D forces Machine 3 active on its own — no other machine has enough GPU headroom for it — and the remaining seven workloads consolidate cleanly onto Machine 1 and Machine 6, leaving the two mid-size twins (2 and 5) and all three small machines (4, 7, 8) fully idle.
Files created
File |
Description |
|---|---|
|
Input machine and workload datasets |
|
Complete problem specification |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1–T5 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Baseline — 8 machines, 8 workloads, 340 units freed |
PASSED |
T2 |
Workload D forces Machine 3 active, fills it exactly on vCPU/RAM |
PASSED |
T3 |
A workload exceeding every machine’s capacity is infeasible |
PASSED |
T4 |
Inflating Workload B’s RAM shrinks the idle set |
PASSED |
T5 |
A 1 GB nudge to Workload C leaves the idle set unchanged |
PASSED |
Let me know if you would like any changes, such as a redundancy requirement between two workloads, or generating the mathematical formulation.
That log lands in the workspace as console.log alongside the code, so the
solve is auditable rather than asserted.
The loop can end one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct. That second path is what the Changing the model tab demonstrates, when a later change surfaces exactly this kind of contradiction.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 72 columns and 96 rows, is nowhere near the cap — but a full-scale version assigning thousands of workloads across a large fleet could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full data set for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts priority or SLA differentiation between workloads out of scope — a reasonable simplification, until platform tells you that Workload B and Workload H are two replicas of the same critical service, kept for redundancy, and a single machine failure must never be able to take down both at once. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
One more requirement: Workload B and Workload H are two replicas of the same critical service, kept for redundancy — for high availability they must never be assigned to the same machine. Please update the specification first, then the implementation, then the tests, in that order, and tell me if anything about the current test scenarios (or the new rule itself) becomes ambiguous under this change.
This is a scope change, not a data correction: “priority or SLA differentiation between workloads” is currently listed under Out of Scope. Updating the specification first — add a new C6 constraint and an Anti-Affinity Pairs input entity, remove the out-of-scope bullet. The T1 baseline solution puts B and H both on Machine 1, so the expected optimal assignment in T1, T4, and T5 needs re-checking against the new rule before anything else changes. Then the code, then the tests.
That moves priority/SLA differentiation from out of scope into the model, specifically as an anti-affinity rule. I have updated the specification:
One consequence worth flagging before I implement it: Machine 1 and Machine 6 have identical specs (32 vCPU / 128 GB / 20 GFLOPS each). “Must never be assigned to the same machine” could be read narrowly (different physical machine identifiers) or more strictly (different hardware class, in case a shared firmware or PSU defect took out both twins at once). I’ll implement the narrow reading unless you tell me otherwise — worth confirming once you see the numbers.
Go ahead and implement the narrow reading for now — let’s see the numbers.
Specification, then code, then tests
The specification changes first. Section 3 gains a new constraint:
C6: Anti-Affinity for High-Availability Replicas
Workload B and Workload H are replicas of the same critical service. They must be placed on
different physical machines. No single physical machine may host both Workload B and
Workload H simultaneously.
The rule is defined at the level of individual physical machine identifiers, not hardware
model class. Machines 1 and 6 share identical specs but sit on separate power circuits and
network paths, so they count as distinct failure domains.
The implementation follows the specification, not the other way around. A new
optional anti_affinity_pairs argument to set_data() carries the
declared pairs, and _add_constraints gains one tight, coefficient-free
inequality per pair per machine — the same pattern already used for the idle
link, so no Big-M and no auxiliary variables:
def set_data(self, machines: pd.DataFrame, workloads: pd.DataFrame):
- """
- Load machine capacities and workload requirements.
- """
+ """
+ Load machine capacities, workload requirements, and optional
+ anti-affinity pairs.
+
+ anti_affinity_pairs : list of (str, str), optional
+ Pairs of workload names that must not share a machine.
+ Defaults to an empty list (no anti-affinity constraints).
+ """
self.machines = machines.copy()
self.workloads = workloads.copy()
self._machine_list = machines["machine"].tolist()
self._workload_list = workloads["workload"].tolist()
+ self._anti_affinity_pairs = anti_affinity_pairs if anti_affinity_pairs is not None else []
def _add_constraints(self):
...
# C5 — Idle linking: x[w,m] + y[m] <= 1 for all w, m
self.model.addConstrs(
(x[w, m] + y[m] <= 1
for w in workloads
for m in machines),
name="C5_idle_link"
)
+ # C6 — Anti-affinity: replica pairs must not share a machine
+ for idx, (w1, w2) in enumerate(self._anti_affinity_pairs):
+ self.model.addConstrs(
+ (x[w1, m] + x[w2, m] <= 1
+ for m in machines),
+ name=f"C6_anti_affinity_{idx}"
+ )
T1, T4 and T5 each gain an anti-affinity assertion, and the suite gains a new scenario:
class TestT6AntiAffinity:
"""T6: Directly verify that C6 (anti-affinity for Workload B and Workload H) is enforced."""
def test_t6_anti_affinity_forces_separation_minimal_dataset(self, gurobi_env):
"""T6: On a minimal 2-machine dataset, anti-affinity forces B and H onto
separate machines even though both fit easily on a single machine."""
machines = pd.DataFrame([
{"machine": "M1", "vcpu_capacity": 32, "ram_capacity": 64, "gpu_capacity": 20},
{"machine": "M2", "vcpu_capacity": 32, "ram_capacity": 64, "gpu_capacity": 20},
])
workloads = pd.DataFrame([
{"workload": "Workload B", "vcpu_req": 4, "ram_req": 8, "gpu_req": 2},
{"workload": "Workload H", "vcpu_req": 4, "ram_req": 8, "gpu_req": 2},
])
sol = run_optimizer(gurobi_env, machines, workloads,
anti_affinity_pairs=[("Workload B", "Workload H")])
assert sol["is_optimal"] is True
assert sol["assignments"]["Workload B"] != sol["assignments"]["Workload H"]
Modeler re-runs the whole suite, not just the new tests — 32/32 pass on the first run in 1.01 seconds. The optimal objective stays at 340, but B and H move: instead of sharing Machine 1, B stays on Machine 1 and H moves to Machine 6.
Where it stops and asks
Before settling on the implementation above, there was a genuine fork in how to read “must never be assigned to the same machine” — and Modeler had already flagged it, rather than picking a side silently.
Before you finalize — Machine 1 and Machine 6 are identical hardware. If the real reason for the redundancy rule is to avoid a shared firmware or power failure, does putting B on Machine 1 and H on Machine 6 actually satisfy the intent, or is that still a violation under a stricter reading? Do the two readings actually give different numbers on this data?
Yes — this is exactly the kind of fork that should be surfaced, not assumed. Under the narrow reading (physical machine identity), B on Machine 1 and H on Machine 6 is fully legal — they’re different machines. Under a stricter reading (same hardware class counts as the same failure domain), B and H could not both be hosted by Class A machines (Machine 1 and Machine 6) at all, which would force one of them off the pair entirely. Solving both to check: the two readings produce different optimal objectives on this exact dataset, not just a different but equally-good rearrangement.
Confirmed — the two readings diverge, and it isn’t a wash:
Reading |
Objective |
Idle machines |
B / H placement |
|---|---|---|---|
A — physical machine identity (implemented): different machine IDs satisfy the rule, even if same hardware class. |
340 |
{2, 4, 5, 7, 8} |
Machine 1 / Machine 6 |
B — hardware-class failure domain: B and H cannot both be hosted by Class A machines (Machine 1, Machine 6) at all. |
330 |
{4, 6, 7, 8} |
Machine 2 / Machine 1 |
Reading B costs 10 units — one fewer large Class A machine gets freed, because B and H must now be split across a Class A machine and a smaller Class B machine (Machine 2 or 5) instead of both landing on the two interchangeable Class A twins. Which reading matches your actual redundancy requirement?
Physical machine identity is correct — keep what’s implemented. Same-model machines are on separate power circuits and network paths in our datacenter, so sharing a hardware model doesn’t mean sharing a failure domain. Objective 340 stands.
This is the same “stop and ask” behavior in a different shape: not a test scenario whose expected answer became ambiguous, but a modeling choice inside the new constraint that had two defensible readings with different numeric consequences. Modeler surfaced both, with real numbers attached, rather than silently keeping the one it had already coded.
The assignment changes, the freed capacity does not
Workload |
Machine (no C6) |
Machine (with C6, Reading A) |
Change |
|---|---|---|---|
Workload B |
Machine 1 |
Machine 1 |
Unchanged |
Workload A |
Machine 6 |
Machine 1 |
Swaps onto Machine 1, trading places with H |
Workload H |
Machine 1 |
Machine 6 |
Swaps onto Machine 6, trading places with A |
Workload E, F |
Machine 1 |
Machine 1 |
Unchanged |
Workload C, G |
Machine 6 |
Machine 6 |
Unchanged |
Objective (freed capacity) |
340 |
340 |
Unchanged |
Rejected Reading B (hardware-class) |
— |
330 |
Would have cost 10 units |
Adding the anti-affinity constraint only requires a straight swap: Workload A and Workload H trade machines, so B and H are no longer both on Machine 1, and the optimizer keeps the objective at 340 — A and H have identical vCPU and RAM requirements, so the swap moves the 1-GFLOPS difference between them from Machine 1 (now at 19 of 20 GFLOPS instead of a full 20) to Machine 6 (now at a full 20 of 20 instead of 19), with every other workload untouched. The rejected stricter reading is the one that would have actually cost something: 10 units, because it forbids the two identical Class A machines from ever hosting both replicas between them, forcing a much bigger rearrangement. That the “free” outcome and the “costly” outcome are only 10 units apart, on a model this size, is only knowable because the change went through the specification and a full re-solve of both readings rather than a hand-wavy “should still be fine.”
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(m \in M\): machines, \(M = \{1, \dots, 8\}\).
\(w \in W\): workloads, \(W = \{A, \dots, H\}\).
\(r \in R\): resource dimensions, \(R = \{\text{vCPU},\ \text{RAM},\ \text{GPU}\}\).
Parameters
\(\text{cap}_{m,r}\): capacity of machine \(m\) for resource \(r\).
\(\text{req}_{w,r}\): requirement of workload \(w\) for resource \(r\).
\(C_m = \sum_{r \in R} \text{cap}_{m,r}\): total raw capacity of machine \(m\), summed across all three resources.
Decision variables
\(x_{w,m} \in \{0, 1\}\) for \(w \in W,\ m \in M\): 1 if workload \(w\) is assigned to machine \(m\).
\(y_m \in \{0, 1\}\) for \(m \in M\): 1 if machine \(m\) is fully idle (no workload assigned).
Objective function
Maximize the total raw capacity of idle machines:
Constraints
C1 — Workload assignment.
C2, C3, C4 — Resource capacity (vCPU, RAM, GPU).
C5 — Idle machine linking. A tight, coefficient-free formulation: no Big-M and no auxiliary variable is needed, since forcing \(y_m = 0\) whenever any workload lands on machine \(m\) is enough — the maximization objective does the rest, driving \(y_m\) to 1 for every machine left with no assignment.
Complete model
Additional notes
This is a Mixed-Integer Linear Program: all decision variables are binary and every constraint is linear.
On the 8-machine / 8-workload baseline instance, the model has \(8 \times 8 + 8 = 72\) binary variables and \(8 + 3 \times 8 + 8 \times 8 = 96\) constraint rows before presolve — a small MIP that Gurobi’s own heuristics solve at the root node without deep branching.
The idle-linking constraint C5 (\(x_{w,m} + y_m \leq 1\)) is equivalent to the more familiar Big-M form \(\sum_{w} x_{w,m} \leq |W| \cdot (1 - y_m)\), but produces a strictly tighter LP relaxation and needs no coefficient tuning.
Workload D’s requirement (32 vCPU, 64 GB, 25 GFLOPS) is infeasible on every machine except Machine 3 — verifiable directly from C2–C4 by checking each machine’s GPU capacity against 25 GFLOPS. This forces \(x_{D,3} = 1\) and \(y_3 = 0\) in every feasible solution, independent of the objective.
Several machines share identical \(C_m\) values (Machine 1 and 6; Machine 2 and 5; Machines 4, 7, and 8), so the objective is invariant to which specific twin ends up idle — the solver may return any of several equally optimal assignments.
Note that this formulates the model before the Workload B / Workload H anti-affinity requirement introduced in the Changing the model tab. Add constraint C6 as described there — \(x_{w_1,m} + x_{w_2,m} \leq 1\) for each declared pair \((w_1, w_2)\) and every \(m \in M\) — to recover the version formulated after that change.
### Sets and indices
- $m \in M$: machines, $M = \{1, \dots, 8\}$.
- $w \in W$: workloads, $W = \{A, \dots, H\}$.
- $r \in R$: resource dimensions, $R = \{\text{vCPU},\ \text{RAM},\ \text{GPU}\}$.
### Parameters
- $\text{cap}_{m,r}$: capacity of machine $m$ for resource $r$.
- $\text{req}_{w,r}$: requirement of workload $w$ for resource $r$.
- $C_m = \sum_{r \in R} \text{cap}_{m,r}$: total raw capacity of machine $m$,
summed across all three resources.
### Decision variables
- $x_{w,m} \in \{0, 1\}$ for $w \in W,\ m \in M$: 1 if workload $w$ is
assigned to machine $m$.
- $y_m \in \{0, 1\}$ for $m \in M$: 1 if machine $m$ is fully idle (no
workload assigned).
### Objective function
Maximize the total raw capacity of idle machines:
$$
\max \quad \sum_{m \in M} C_m \cdot y_m
$$
### Constraints
**C1 — Workload assignment.**
$$
\sum_{m \in M} x_{w,m} = 1 \qquad \forall\, w \in W
$$
**C2, C3, C4 — Resource capacity (vCPU, RAM, GPU).**
$$
\sum_{w \in W} \text{req}_{w,r} \cdot x_{w,m} \leq \text{cap}_{m,r} \qquad \forall\, m \in M,\ r \in R
$$
**C5 — Idle machine linking.** A tight, coefficient-free formulation: no
Big-M and no auxiliary variable is needed, since forcing $y_m = 0$ whenever
any workload lands on machine $m$ is enough — the maximization objective
does the rest, driving $y_m$ to 1 for every machine left with no assignment.
$$
x_{w,m} + y_m \leq 1 \qquad \forall\, w \in W,\ m \in M
$$
### Complete model
$$
\begin{aligned}
\max \quad & \sum_{m \in M} C_m \cdot y_m \\[6pt]
\text{s.t.} \quad & \sum_{m \in M} x_{w,m} = 1 && \forall\, w \in W & \text{(C1)} \\[6pt]
& \sum_{w \in W} \text{req}_{w,r} \, x_{w,m} \leq \text{cap}_{m,r} && \forall\, m \in M,\ r \in R & \text{(C2–C4)} \\[6pt]
& x_{w,m} + y_m \leq 1 && \forall\, w \in W,\ m \in M & \text{(C5)} \\[6pt]
& x_{w,m} \in \{0,1\} && \forall\, w \in W,\ m \in M \\[6pt]
& y_m \in \{0,1\} && \forall\, m \in M
\end{aligned}
$$
### Additional notes
- This is a Mixed-Integer Linear Program: all decision variables are binary
and every constraint is linear.
- On the 8-machine / 8-workload baseline instance, the model has $8 \times 8
+ 8 = 72$ binary variables and $8 + 3 \times 8 + 8 \times 8 = 96$
constraint rows before presolve — a small MIP that Gurobi's own heuristics
solve at the root node without deep branching.
- The idle-linking constraint C5 ($x_{w,m} + y_m \leq 1$) is equivalent to
the more familiar Big-M form $\sum_{w} x_{w,m} \leq |W| \cdot (1 - y_m)$,
but produces a strictly tighter LP relaxation and needs no coefficient
tuning.
- Workload D's requirement (32 vCPU, 64 GB, 25 GFLOPS) is infeasible on
every machine except Machine 3 — verifiable directly from C2–C4 by
checking each machine's GPU capacity against 25 GFLOPS. This forces
$x_{D,3} = 1$ and $y_3 = 0$ in every feasible solution, independent of the
objective.
- Several machines share identical $C_m$ values (Machine 1 and 6; Machine 2
and 5; Machines 4, 7, and 8), so the objective is invariant to which
specific twin ends up idle — the solver may return any of several equally
optimal assignments.
Note that this formulates the model **before** the Workload B / Workload H
anti-affinity requirement introduced in the Changing the model tab. Add
constraint C6 as described there — $x_{w_1,m} + x_{w_2,m} \leq 1$ for each
declared pair $(w_1, w_2)$ and every $m \in M$ — to recover the version
formulated after that change.