Skip to content

Commit 3dd0ec3

Browse files
committed
Add cost per job metrics
Closes #75 Computes and stores the following metrics: - cpu_cost: cost of using CPU resources on the node, based on the CPU request of the job - mem_cost - cpu_penalty: penalty factor that represents the over or under allocation of CPU resources - mem_penalty
1 parent eaa0f3a commit 3dd0ec3

File tree

11 files changed

+216
-11
lines changed

11 files changed

+216
-11
lines changed

gantry/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def apply_migrations(db: aiosqlite.Connection):
2828
# and not inadvertently added to the migrations folder
2929
("001_initial.sql", 1),
3030
("002_spec_index.sql", 2),
31+
("003_job_cost.sql", 3),
3132
]
3233

3334
# apply migrations that have not been applied
@@ -45,6 +46,8 @@ async def apply_migrations(db: aiosqlite.Connection):
4546
async def init_db(app: web.Application):
4647
db = await aiosqlite.connect(os.environ["DB_FILE"])
4748
await apply_migrations(db)
49+
# ensure foreign key constraints are enabled
50+
await db.execute("PRAGMA foreign_keys = ON")
4851
app["db"] = db
4952
yield
5053
await db.close()

gantry/clients/prometheus/job.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22

3+
import aiosqlite
4+
35
from gantry.clients.prometheus import util
46
from gantry.util.spec import spec_variants
57

@@ -152,3 +154,113 @@ async def get_usage(self, pod: str, start: float, end: float) -> dict:
152154
"mem_min": mem_usage["min"],
153155
"mem_stddev": mem_usage["stddev"],
154156
}
157+
158+
async def get_costs(
159+
self,
160+
db: aiosqlite.Connection,
161+
resources: dict,
162+
usage: dict,
163+
start: float,
164+
end: float,
165+
node_id: int,
166+
) -> dict:
167+
"""
168+
Calculates the costs associated with a job.
169+
170+
Objectives:
171+
- we want to measure the cost of a job's submission and execution
172+
- measure efficiency of resource usage to discourage wasted cycles
173+
174+
The cost should be independent of other activity on the node in order
175+
to be comparable against other jobs.
176+
177+
To normalize the cost of resources within instance types, we calculate
178+
the cost of each CPU and memory unit in the node during the lifetime
179+
of the job.
180+
181+
Rather than using real usage as a factor in the cost, we use the requests,
182+
as they block other jobs from using resources. In this case, jobs will be
183+
incentivized to make lower requests, while also factoring in the runtime.
184+
185+
To account for instances where jobs do not use their requested resources (+/-),
186+
we compute a penalty factor that can be used to understand the cost imposed
187+
on the rest of the node, or jobs that could have been scheduled on the machine.
188+
189+
Job cost and the penalties are stored separately for each resource to allow for
190+
flexibility. When analyzing these costs, instance type should be factored in,
191+
as the cost per job is influence by the cost per resource, which will vary.
192+
193+
args:
194+
db: a database connection
195+
resources: job requests and limits
196+
usage: job memory and cpu usage
197+
start: job start time
198+
end: job end time
199+
node_id: the node that the job ran on
200+
201+
returns:
202+
dict of: cpu_cost, mem_cost, cpu_penalty, mem_penalty
203+
"""
204+
costs = {}
205+
async with db.execute(
206+
"""
207+
select capacity_type, instance_type, zone, cores, mem
208+
from nodes where id = ?
209+
""",
210+
(node_id,),
211+
) as cursor:
212+
node = await cursor.fetchone()
213+
214+
if not node:
215+
# this is a temporary condition that will happen during the transition
216+
# to collecting
217+
raise util.IncompleteData(
218+
f"node instance metadata is missing from db. node={node_id}"
219+
)
220+
221+
capacity_type, instance_type, zone, cores, mem = node
222+
223+
# spot instance prices can change, so we avg the cost over the job's runtime
224+
instance_costs = await self.client.query_range(
225+
query={
226+
"metric": "karpenter_cloudprovider_instance_type_offering_price_estimate", # noqa: E501
227+
"filters": {
228+
"capacity_type": capacity_type,
229+
"instance_type": instance_type,
230+
"zone": zone,
231+
},
232+
},
233+
start=start,
234+
end=end,
235+
)
236+
237+
if not instance_costs:
238+
raise util.IncompleteData(f"node cost is missing. node={node_id}")
239+
240+
instance_costs = [float(value) for _, value in instance_costs[0]["values"]]
241+
# average hourly cost of the instance over the job's lifetime
242+
instance_cost = sum(instance_costs) / len(instance_costs)
243+
# compute cost relative to duration of the job (in seconds)
244+
node_cost = instance_cost * ((end - start) / 60 / 60)
245+
246+
# we assume that the cost of the node is split evenly between cpu and memory
247+
# cost of each CPU in the node during the lifetime of the job
248+
cost_per_cpu = (node_cost * 0.5) / cores
249+
# cost of each unit of memory (byte)
250+
cost_per_mem = (node_cost * 0.5) / mem
251+
# these utilization ratios determine the extent to which resources
252+
# were misallocated for this job
253+
cpu_util_ratio = usage["cpu_mean"] / resources["cpu_request"]
254+
mem_util_ratio = usage["mem_mean"] / resources["mem_request"]
255+
256+
# compute separate costs for cpu and memory usage
257+
# using requests instead of actual usage because requests are *guaranteed*
258+
costs["cpu_cost"] = cost_per_cpu * resources["cpu_request"]
259+
costs["mem_cost"] = cost_per_mem * resources["mem_request"]
260+
# penalty factors meant to discourage underallocation, which slows down jobs
261+
# or overallocation, which prevents jobs from being scheduled on the same node
262+
263+
costs["cpu_penalty"] = max(1 / cpu_util_ratio, cpu_util_ratio)
264+
costs["mem_penalty"] = max(1 / mem_util_ratio, mem_util_ratio)
265+
266+
return costs

gantry/clients/prometheus/node.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,6 @@ async def get_labels(self, hostname: str, time: float) -> dict:
5353
"arch": labels["label_kubernetes_io_arch"],
5454
"os": labels["label_kubernetes_io_os"],
5555
"instance_type": labels["label_node_kubernetes_io_instance_type"],
56+
"capacity_type": labels["label_karpenter_sh_capacity_type"],
57+
"zone": labels["label_topology_kubernetes_io_zone"],
5658
}

gantry/routes/collection.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ async def fetch_job(
7373
)
7474
usage = await prometheus.job.get_usage(annotations["pod"], job.start, job.end)
7575
node_id = await fetch_node(db_conn, prometheus, node_hostname, job.midpoint)
76+
costs = await prometheus.job.get_costs(
77+
db_conn, resources, usage, job.start, job.end, node_id
78+
)
79+
7680
except aiohttp.ClientError as e:
7781
logger.error(f"Request failed: {e}")
7882
return
@@ -93,6 +97,7 @@ async def fetch_job(
9397
**annotations,
9498
**resources,
9599
**usage,
100+
**costs,
96101
},
97102
)
98103

@@ -139,5 +144,7 @@ async def fetch_node(
139144
"arch": node_labels["arch"],
140145
"os": node_labels["os"],
141146
"instance_type": node_labels["instance_type"],
147+
"capacity_type": node_labels["capacity_type"],
148+
"zone": node_labels["zone"],
142149
},
143150
)

gantry/tests/defs/collection.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020

2121
# used to compare successful insertions
2222
# run SELECT * FROM table_name WHERE id = 1; from python sqlite api and grab fetchone() result
23-
INSERTED_JOB = (1, 'runner-hwwb-i3u-project-2-concurrent-1-s10tq41z', 1, 1706117046, 1706118420, 9892514, 'success', 'pr42264_bugfix/mathomp4/hdf5-appleclang15', 'gmsh', '4.8.4', '{"alglib": true, "cairo": false, "cgns": true, "compression": true, "eigen": false, "external": false, "fltk": true, "gmp": true, "hdf5": false, "ipo": false, "med": true, "metis": true, "mmg": true, "mpi": true, "netgen": true, "oce": true, "opencascade": false, "openmp": false, "petsc": false, "privateapi": false, "shared": true, "slepc": false, "tetgen": true, "voropp": true, "build_system": "cmake", "build_type": "Release", "generator": "make"}', 'gcc', '11.4.0', 'linux-ubuntu20.04-x86_64_v3', 'e4s', 16, 0.75, None, 1.899768349523097, 0.2971597591741076, 4.128116379389054, 0.2483743618267752, 1.7602635378120381, 2000000000.0, 48000000000.0, 143698407.6190476, 2785280.0, 594620416.0, 2785280.0, 252073065.82263485)
24-
INSERTED_NODE = (1, 'ec253b04-b1dc-f08b-acac-e23df83b3602', 'ip-192-168-86-107.ec2.internal', 24.0, 196608000000.0, 'amd64', 'linux', 'i3en.6xlarge')
23+
INSERTED_JOB = (1, 'runner-hwwb-i3u-project-2-concurrent-1-s10tq41z', 1, 1706117046, 1706118420, 9892514, 'success', 'pr42264_bugfix/mathomp4/hdf5-appleclang15', 'gmsh', '4.8.4', '{"alglib": true, "cairo": false, "cgns": true, "compression": true, "eigen": false, "external": false, "fltk": true, "gmp": true, "hdf5": false, "ipo": false, "med": true, "metis": true, "mmg": true, "mpi": true, "netgen": true, "oce": true, "opencascade": false, "openmp": false, "petsc": false, "privateapi": false, "shared": true, "slepc": false, "tetgen": true, "voropp": true, "build_system": "cmake", "build_type": "Release", "generator": "make"}', 'gcc', '11.4.0', 'linux-ubuntu20.04-x86_64_v3', 'e4s', 16, 0.75, None, 1.899768349523097, 0.2971597591741076, 4.128116379389054, 0.2483743618267752, 1.7602635378120381, 2000000000.0, 48000000000.0, 143698407.6190476, 2785280.0, 594620416.0, 2785280.0, 252073065.82263485, 0.002981770833333333, 0.0009706285264756945, 2.533024466030796, 13.918038711341255)
24+
INSERTED_NODE = (1, 'ec253b04-b1dc-f08b-acac-e23df83b3602', 'ip-192-168-86-107.ec2.internal', 24.0, 196608000000.0, 'amd64', 'linux', 'i3en.6xlarge', 'us-east-1c', 'spot')
2525

2626
# these were obtained by executing the respective queries to Prometheus and capturing the JSON output
2727
# or the raw output of PrometheusClient._query
@@ -32,6 +32,7 @@
3232
VALID_CPU_USAGE = {'status': 'success', 'data': {'resultType': 'matrix', 'result': [{'metric': {'container': 'build', 'cpu': 'total', 'endpoint': 'https-metrics', 'id': '/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podd7aa13e0_998c_4f21_b1d6_62781f4980b0.slice/cri-containerd-48a5e9e7d46655e73ba119fa16b65fa94ceed23c55157db8269b0b12f18f55d1.scope', 'image': 'ghcr.io/spack/ubuntu20.04-runner-amd64-gcc-11.4:2023.08.01', 'instance': '192.168.86.107:10250', 'job': 'kubelet', 'metrics_path': '/metrics/cadvisor', 'name': '48a5e9e7d46655e73ba119fa16b65fa94ceed23c55157db8269b0b12f18f55d1', 'namespace': 'pipeline', 'node': 'ip-192-168-86-107.ec2.internal', 'pod': 'runner-hwwb-i3u-project-2-concurrent-1-s10tq41z', 'service': 'kube-prometheus-stack-kubelet'}, 'values': [[1706117145, '0.2483743618267752'], [1706117146, '0.25650526138466395'], [1706117147, '0.26463616094255266'], [1706117148, '0.2727670605004414'], [1706117149, '0.28089796005833007'], [1706117150, '0.2890288596162188'], [1706117151, '0.2971597591741076'], [1706117357, '3.7319005481816236'], [1706117358, '3.7319005481816236'], [1706117359, '3.7319005481816236'], [1706117360, '3.7319005481816245'], [1706117361, '3.7319005481816245'], [1706118420, '4.128116379389054']]}]}}
3333
VALID_NODE_INFO = {'status': 'success', 'data': {'resultType': 'vector', 'result': [{'metric': {'__name__': 'kube_node_info', 'container': 'kube-state-metrics', 'container_runtime_version': 'containerd://1.7.2', 'endpoint': 'http', 'instance': '192.168.164.84:8080', 'internal_ip': '192.168.86.107', 'job': 'kube-state-metrics', 'kernel_version': '5.10.205-195.804.amzn2.x86_64', 'kubelet_version': 'v1.27.9-eks-5e0fdde', 'kubeproxy_version': 'v1.27.9-eks-5e0fdde', 'namespace': 'monitoring', 'node': 'ip-192-168-86-107.ec2.internal', 'os_image': 'Amazon Linux 2', 'pod': 'kube-prometheus-stack-kube-state-metrics-dbd66d8c7-6ftw8', 'provider_id': 'aws:///us-east-1c/i-0fe9d9c99fdb3631d', 'service': 'kube-prometheus-stack-kube-state-metrics', 'system_uuid': 'ec253b04-b1dc-f08b-acac-e23df83b3602'}, 'value': [1706117733, '1']}]}}
3434
VALID_NODE_LABELS = {'status': 'success', 'data': {'resultType': 'vector', 'result': [{'metric': {'__name__': 'kube_node_labels', 'container': 'kube-state-metrics', 'endpoint': 'http', 'instance': '192.168.164.84:8080', 'job': 'kube-state-metrics', 'label_beta_kubernetes_io_arch': 'amd64', 'label_beta_kubernetes_io_instance_type': 'i3en.6xlarge', 'label_beta_kubernetes_io_os': 'linux', 'label_failure_domain_beta_kubernetes_io_region': 'us-east-1', 'label_failure_domain_beta_kubernetes_io_zone': 'us-east-1c', 'label_k8s_io_cloud_provider_aws': 'ceb9f9cc8e47252a6f7fe7d6bded2655', 'label_karpenter_k8s_aws_instance_category': 'i', 'label_karpenter_k8s_aws_instance_cpu': '24', 'label_karpenter_k8s_aws_instance_encryption_in_transit_supported': 'true', 'label_karpenter_k8s_aws_instance_family': 'i3en', 'label_karpenter_k8s_aws_instance_generation': '3', 'label_karpenter_k8s_aws_instance_hypervisor': 'nitro', 'label_karpenter_k8s_aws_instance_local_nvme': '15000', 'label_karpenter_k8s_aws_instance_memory': '196608', 'label_karpenter_k8s_aws_instance_network_bandwidth': '25000', 'label_karpenter_k8s_aws_instance_pods': '234', 'label_karpenter_k8s_aws_instance_size': '6xlarge', 'label_karpenter_sh_capacity_type': 'spot', 'label_karpenter_sh_initialized': 'true', 'label_karpenter_sh_provisioner_name': 'glr-x86-64-v4', 'label_kubernetes_io_arch': 'amd64', 'label_kubernetes_io_hostname': 'ip-192-168-86-107.ec2.internal', 'label_kubernetes_io_os': 'linux', 'label_node_kubernetes_io_instance_type': 'i3en.6xlarge', 'label_spack_io_pipeline': 'true', 'label_spack_io_x86_64': 'v4', 'label_topology_ebs_csi_aws_com_zone': 'us-east-1c', 'label_topology_kubernetes_io_region': 'us-east-1', 'label_topology_kubernetes_io_zone': 'us-east-1c', 'namespace': 'monitoring', 'node': 'ip-192-168-86-107.ec2.internal', 'pod': 'kube-prometheus-stack-kube-state-metrics-dbd66d8c7-6ftw8', 'service': 'kube-prometheus-stack-kube-state-metrics'}, 'value': [1706117733, '1']}]}}
35+
VALID_NODE_COST = {'status': 'success', 'data': {'resultType': 'matrix', 'result': [{'metric': {'__name__': 'karpenter_cloudprovider_instance_type_offering_price_estimate', 'capacity_type': 'spot', 'container': 'controller', 'endpoint': 'http-metrics', 'instance': '192.168.240.113:8000', 'instance_type': 'i3en.6xlarge', 'job': 'karpenter', 'namespace': 'karpenter', 'pod': 'karpenter-8488f7f6dc-ml7q8', 'region': 'us-east-1', 'service': 'karpenter', 'zone': 'us-east-1c'}, 'values': [[1723838829, '0.5']]}]}}
3536

3637
# modified version of VALID_MEMORY_USAGE to make the mean/stddev 0
3738
INVALID_MEMORY_USAGE = {'status': 'success', 'data': {'resultType': 'matrix', 'result': [{'metric': {'__name__': 'container_memory_working_set_bytes', 'container': 'build', 'endpoint': 'https-metrics', 'id': '/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podd7aa13e0_998c_4f21_b1d6_62781f4980b0.slice/cri-containerd-48a5e9e7d46655e73ba119fa16b65fa94ceed23c55157db8269b0b12f18f55d1.scope', 'image': 'ghcr.io/spack/ubuntu20.04-runner-amd64-gcc-11.4:2023.08.01', 'instance': '192.168.86.107:10250', 'job': 'kubelet', 'metrics_path': '/metrics/cadvisor', 'name': '48a5e9e7d46655e73ba119fa16b65fa94ceed23c55157db8269b0b12f18f55d1', 'namespace': 'pipeline', 'node': 'ip-192-168-86-107.ec2.internal', 'pod': 'runner-hwwb-i3u-project-2-concurrent-1-s10tq41z', 'service': 'kube-prometheus-stack-kubelet'}, 'values': [[1706117115, '0']]}]}}

gantry/tests/defs/db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
# fmt: off
33

44
# valid input into insert_node
5-
NODE_INSERT_DICT = {"uuid": "ec253b04-b1dc-f08b-acac-e23df83b3602", "hostname": "ip-192-168-86-107.ec2.internal", "cores": 24.0, "mem": 196608000000.0, "arch": "amd64", "os": "linux", "instance_type": "i3en.6xlarge"}
5+
NODE_INSERT_DICT = {"uuid": "ec253b04-b1dc-f08b-acac-e23df83b3602", "hostname": "ip-192-168-86-107.ec2.internal", "cores": 24.0, "mem": 196608000000.0, "arch": "amd64", "os": "linux", "instance_type": "i3en.6xlarge", "zone": "us-east-1c", "capacity_type": "spot"}

gantry/tests/sql/insert_job.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
INSERT INTO jobs VALUES(1,'runner-hwwb-i3u-project-2-concurrent-1-s10tq41z',2,1706117046,1706118420,9892514,'success','pr42264_bugfix/mathomp4/hdf5-appleclang15','gmsh','4.8.4','{"alglib": true, "cairo": false, "cgns": true, "compression": true, "eigen": false, "external": false, "fltk": true, "gmp": true, "hdf5": false, "ipo": false, "med": true, "metis": true, "mmg": true, "mpi": true, "netgen": true, "oce": true, "opencascade": false, "openmp": false, "petsc": false, "privateapi": false, "shared": true, "slepc": false, "tetgen": true, "voropp": true, "build_system": "cmake", "build_type": "Release", "generator": "make"}','gcc','11.4.0','linux-ubuntu20.04-x86_64_v3','e4s',16,0.75,NULL,4.12532286694540495,3.15805864677520409,11.6038107294648877,0.248374361826775191,3.34888880339475214,2000000000.0,48000000000.0,1649868862.72588062,999763968.0,5679742976.0,2785280.0,1378705563.21018671);
1+
INSERT INTO jobs VALUES(1,'runner-hwwb-i3u-project-2-concurrent-1-s10tq41z',2,1706117046,1706118420,9892514,'success','pr42264_bugfix/mathomp4/hdf5-appleclang15','gmsh','4.8.4','{"alglib": true, "cairo": false, "cgns": true, "compression": true, "eigen": false, "external": false, "fltk": true, "gmp": true, "hdf5": false, "ipo": false, "med": true, "metis": true, "mmg": true, "mpi": true, "netgen": true, "oce": true, "opencascade": false, "openmp": false, "petsc": false, "privateapi": false, "shared": true, "slepc": false, "tetgen": true, "voropp": true, "build_system": "cmake", "build_type": "Release", "generator": "make"}','gcc','11.4.0','linux-ubuntu20.04-x86_64_v3','e4s',16,0.75,NULL,4.12532286694540495,3.15805864677520409,11.6038107294648877,0.248374361826775191,3.34888880339475214,2000000000.0,48000000000.0,1649868862.72588062,999763968.0,5679742976.0,2785280.0,1378705563.21018671,0.002981770833333333, 0.0009706285264756945, 2.533024466030796, 13.918038711341255);

gantry/tests/sql/insert_node.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
--- primary key is set to 2 to set up the test that checks for race conditions
2-
INSERT INTO nodes VALUES(2,'ec253b04-b1dc-f08b-acac-e23df83b3602','ip-192-168-86-107.ec2.internal',24.0,196608000000.0,'amd64','linux','i3en.6xlarge');
2+
INSERT INTO nodes VALUES(2,'ec253b04-b1dc-f08b-acac-e23df83b3602','ip-192-168-86-107.ec2.internal',24.0,196608000000.0,'amd64','linux','i3en.6xlarge','us-east-1c','spot');

0 commit comments

Comments
 (0)