Skip to content

Commit 91e3a7d

Browse files
committed
feat: Enhance TPU chips validation DAG with pod metadata retrieval and latency calculation
1 parent f735d6d commit 91e3a7d

2 files changed

Lines changed: 213 additions & 52 deletions

File tree

dags/tpu_observability/chips_capacity_validation_dag.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,25 +137,51 @@
137137
jobset_name=jobset_config.jobset_name,
138138
)
139139

140+
metadata_map = jobset.get_pod_metadata_map.override(
141+
task_id="get_pod_metadata_map"
142+
)(
143+
node_pool=cluster_info,
144+
namespace=jobset_config.namespace,
145+
jobset_name=jobset_config.jobset_name,
146+
)
147+
140148
# Keyword arguments are generated dynamically at runtime (pylint does not
141149
# know this signature).
142150
with TaskGroup( # pylint: disable=unexpected-keyword-arg
143151
group_id="verification_group"
144152
) as verification_group:
145-
scheduled_chips = (
153+
# scheduled_chips = (
154+
# jobset.wait_for_tpu_scheduled_chips.override(
155+
# task_id="wait_for_tpu_scheduled_chips"
156+
# )
157+
# .partial(
158+
# node_pool=cluster_info,
159+
# job_apply_time=apply_time,
160+
# pod_timestamps=pod_ts
161+
# )
162+
# .expand(
163+
# instance_id=instance_ids,
164+
# )
165+
# )
166+
167+
scheduled_tasks = (
146168
jobset.wait_for_tpu_scheduled_chips.override(
147169
task_id="wait_for_tpu_scheduled_chips"
148170
)
149-
.partial(node_pool=cluster_info, job_apply_time=apply_time)
171+
.partial(
172+
node_pool=cluster_info,
173+
metadata_map=metadata_map,
174+
job_apply_time=apply_time,
175+
)
150176
.expand(
151-
instance_id=instance_ids,
177+
pod_name=pod_names,
152178
)
153179
)
154180

155-
latency_report = jobset.calculate_latency.expand(
156-
instance_id=instance_ids,
157-
pod_timestamps=pod_ts,
158-
scheduled_ts=scheduled_chips.output,
181+
latency_results = jobset.calculate_pod_latency.expand(
182+
pod_name=pod_names,
183+
scheduled_ts=scheduled_tasks, # This is the XCom list of timestamps
184+
metadata_map=metadata_map,
159185
)
160186

161187
tpu_active_chips = (
@@ -212,8 +238,9 @@
212238
>> apply_time
213239
>> pod_names
214240
>> wait_for_job_start
215-
>> pod_ts
216241
>> instance_ids
242+
>> pod_ts
243+
>> metadata_map
217244
>> verification_group
218245
>> clean_up_workload
219246
>> cleanup_node_pool

dags/tpu_observability/utils/jobset_util.py

Lines changed: 178 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import dataclasses
1818
import datetime
19+
from dateutil import parser
1920
import json
2021
import logging
2122
import os
@@ -27,7 +28,7 @@
2728

2829
from airflow.decorators import task
2930
from airflow.exceptions import AirflowFailException
30-
from airflow.operators.python import get_current_context
31+
from airflow.sensors.base import PokeReturnValue
3132
from google.cloud.monitoring_v3 import types
3233
import kubernetes
3334

@@ -763,12 +764,144 @@ def get_pod_creation_timestamps(
763764
return timestamps
764765

765766

767+
@task
768+
def get_pod_metadata_map(
769+
node_pool: node_pool_info, namespace: str, jobset_name: str
770+
) -> dict[str, dict[str, str]]:
771+
"""
772+
Returns a map: {
773+
pod_name: {
774+
"timestamp": "2026-01-27T...",
775+
"instance_id": "12345..."
776+
}
777+
}
778+
"""
779+
with tempfile.NamedTemporaryFile() as temp_config_file:
780+
env = os.environ.copy()
781+
env["KUBECONFIG"] = temp_config_file.name
782+
783+
# This command gets: PodName, Timestamp, and NodeName
784+
get_pod_info_cmd = (
785+
f"kubectl get pods -n {namespace} --kubeconfig={temp_config_file.name} "
786+
f"-l jobset.sigs.k8s.io/jobset-name={jobset_name} "
787+
'-o jsonpath=\'{range .items[*]}{.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.spec.nodeName}{"\\n"}{end}\''
788+
)
789+
790+
cmd = " && ".join([
791+
Command.get_credentials_command(node_pool),
792+
get_pod_info_cmd,
793+
])
794+
795+
stdout = subprocess.run_exec(cmd, env=env)
796+
if not stdout or not stdout.strip():
797+
return {}
798+
799+
metadata_map = {}
800+
for line in stdout.strip().split("\n"):
801+
parts = line.split("\t")
802+
if len(parts) == 3:
803+
pod_name, ts, node_name = parts
804+
805+
# Now get the Instance ID for this specific Node
806+
get_inst_id_cmd = (
807+
f"kubectl get node {node_name} --kubeconfig={temp_config_file.name} "
808+
"-o jsonpath='{.metadata.annotations.container\\.googleapis\\.com/instance_id}'"
809+
)
810+
811+
# Execute sub-command to get the ID
812+
full_inst_cmd = " && ".join(
813+
[Command.get_credentials_command(node_pool), get_inst_id_cmd]
814+
)
815+
inst_id = subprocess.run_exec(full_inst_cmd, env=env).strip()
816+
817+
metadata_map[pod_name] = {
818+
"timestamp": ts.strip(),
819+
"instance_id": inst_id,
820+
}
821+
822+
logging.info(f"Successfully mapped metadata: {metadata_map}")
823+
return metadata_map
824+
825+
766826
@task.sensor(poke_interval=60, timeout=3600, mode="poke")
767827
def wait_for_tpu_scheduled_chips(
828+
node_pool: node_pool_info,
829+
pod_name: str,
830+
metadata_map: dict,
831+
job_apply_time: TimeUtil,
832+
):
833+
pod_data = metadata_map.get(pod_name, {})
834+
instance_id = pod_data.get("instance_id")
835+
836+
now = datetime.datetime.now()
837+
time_series = query_time_series(
838+
project_id=node_pool.project_id,
839+
filter_str=(
840+
'metric.type="compute.googleapis.com/instance/tpu/scheduled_chips" '
841+
f'resource.labels.instance_id="{instance_id}"'
842+
),
843+
start_time=job_apply_time,
844+
end_time=TimeUtil.from_datetime(now),
845+
)
846+
847+
if len(time_series) > 0:
848+
# Get the timestamp from the metric
849+
t_scheduled = str(time_series[0].points[0].interval.start_time)
850+
851+
logging.info(f"Detected scheduled_chips for {pod_name} at {t_scheduled}")
852+
853+
# Returning PokeReturnValue signals sensor completion AND pushes xcom_value
854+
return PokeReturnValue(is_done=True, xcom_value=t_scheduled)
855+
856+
return PokeReturnValue(is_done=False, xcom_value=None)
857+
858+
859+
# @task.sensor(poke_interval=60, timeout=600, mode="poke")
860+
# def wait_for_tpu_scheduled_chips(
861+
# node_pool: node_pool_info,
862+
# instance_id: str,
863+
# job_apply_time: TimeUtil,
864+
# pod_timestamps: dict,
865+
# ) -> bool:
866+
# """
867+
# Polls for scheduled_chips and calculates latency from Pod creation.
868+
# """
869+
# now = datetime.datetime.now()
870+
# time_series = query_time_series(
871+
# project_id=node_pool.project_id,
872+
# filter_str=(
873+
# 'metric.type="compute.googleapis.com/instance/tpu/scheduled_chips" '
874+
# f'resource.labels.instance_id="{instance_id}"'
875+
# ),
876+
# start_time=job_apply_time,
877+
# end_time=TimeUtil.from_datetime(now),
878+
# )
879+
880+
# if len(time_series) > 0:
881+
# scheduled_ts_str = str(time_series[0].points[0].interval.start_time)
882+
# t_scheduled = parser.isoparse(scheduled_ts_str)
883+
# pod_ts_str = min(pod_timestamps.values())
884+
# t_pod = parser.isoparse(pod_ts_str)
885+
# latency_sec = (t_scheduled - t_pod).total_seconds()
886+
# logging.info(f"--- Latency Found for Instance {instance_id} ---")
887+
# logging.info(f"Pod Created: {t_pod} | TPU Scheduled: {t_scheduled}")
888+
# logging.info(f"Latency (seconds): {latency_sec}")
889+
# if latency_sec > 600:
890+
# return False
891+
# return True
892+
893+
# return False
894+
895+
896+
@task.sensor(poke_interval=60, timeout=600, mode="poke")
897+
def wait_for_tpu_metrics(
768898
node_pool: node_pool_info,
769899
instance_id: str,
770900
job_apply_time: TimeUtil,
771-
) -> bool:
901+
) -> PokeReturnValue:
902+
"""
903+
Polls for the 'scheduled_chips' metric and returns the event timestamp.
904+
"""
772905
now = datetime.datetime.now()
773906
time_series = query_time_series(
774907
project_id=node_pool.project_id,
@@ -781,38 +914,36 @@ def wait_for_tpu_scheduled_chips(
781914
)
782915

783916
if len(time_series) > 0:
784-
# Capture the timestamp of the first metric point
785-
first_ts = str(time_series[0].points[0].interval.start_time)
786-
# Push to XCom so the latency task can read it
787-
ti = get_current_context()["ti"]
788-
ti.xcom_push(key="scheduled_ts", value=first_ts)
789-
return True
790-
return False
917+
# Extract the timestamp of the first data point found
918+
scheduled_ts_str = str(time_series[0].points[0].interval.start_time)
919+
logging.info(f"Metric found: {scheduled_ts_str}")
920+
return PokeReturnValue(is_done=True, xcom_value=scheduled_ts_str)
921+
922+
return PokeReturnValue(is_done=False)
791923

792924

793925
@task
794-
def calculate_latency(
795-
instance_id: str, pod_timestamps: dict, scheduled_ts: str
796-
):
797-
"""Calculates time delta between Pod Creation and TPU Scheduling."""
798-
from dateutil import parser
926+
def calculate_pod_latency(pod_name: str, metadata_map: dict, scheduled_ts: str):
927+
"""Calculates the delta between Pod Creation and Metric detection."""
928+
929+
if not scheduled_ts:
930+
return None
931+
932+
# Get Pod creation time from our map
933+
pod_ts_str = metadata_map.get(pod_name, {}).get("timestamp")
799934

935+
t_pod = parser.isoparse(pod_ts_str)
800936
t_scheduled = parser.isoparse(scheduled_ts)
801-
# Match the first available pod timestamp from the JobSet
802-
first_pod_ts = list(pod_timestamps.values())[0]
803-
t_pod = parser.isoparse(first_pod_ts)
804937

805-
diff_seconds = (t_scheduled - t_pod).total_seconds()
806-
logging.info(f"Instance {instance_id} Latency: {diff_seconds}s")
938+
latency_sec = (t_scheduled - t_pod).total_seconds()
807939

808-
return {
809-
"instance_id": instance_id,
810-
"latency_seconds": diff_seconds,
811-
"latency_minutes": round(diff_seconds / 60, 2),
812-
}
940+
logging.info(f"--- Latency Analysis: {pod_name} ---")
941+
logging.info(f"Latency: {latency_sec}s ({latency_sec/60:.2f} min)")
813942

943+
return {"pod_name": pod_name, "latency_sec": latency_sec}
814944

815-
@task.sensor(poke_interval=60, timeout=3600, mode="poke")
945+
946+
@task.sensor(poke_interval=60, timeout=600, mode="poke")
816947
def wait_for_tpu_active_chips(
817948
node_pool: node_pool_info,
818949
instance_id: str,
@@ -851,26 +982,16 @@ def wait_for_tpu_active_chips(
851982
return len(time_series) > 0
852983

853984

854-
@task.sensor(poke_interval=60, timeout=3600, mode="poke")
985+
@task.sensor(poke_interval=60, timeout=600, mode="poke")
855986
def wait_for_tpu_utilized_chips(
856987
node_pool: node_pool_info,
857988
instance_id: str,
858989
job_apply_time: TimeUtil,
990+
expected_chip_count: int,
859991
) -> bool:
860992
"""
861-
Polls the TPU utilized_chips metric for a specific GCE instance.
862-
863-
This metric represents the number of TPU chips currently being utilized by
864-
a workload. It is the best indicator that the training software (JAX/PyTorch)
865-
has successfully opened the TPU device and started computation.
866-
867-
Args:
868-
node_pool: Configuration object with project and cluster details.
869-
instance_id: The specific GCE Instance ID to monitor.
870-
job_apply_time: The time when the job was applied.
871-
872-
Returns:
873-
True if the utilized_chips metric is found, False otherwise.
993+
Polls the TPU utilized_chips metric and verifies the count matches
994+
the expected hardware capacity and is in a HEALTHY state.
874995
"""
875996
now = datetime.datetime.now()
876997

@@ -883,14 +1004,27 @@ def wait_for_tpu_utilized_chips(
8831004
start_time=job_apply_time,
8841005
end_time=TimeUtil.from_datetime(now),
8851006
)
886-
logging.info(
887-
"TPU Utilized Chips Time series for %s: %s", instance_id, time_series
888-
)
8891007

890-
return len(time_series) > 0
1008+
if len(time_series) > 0:
1009+
latest_value = time_series[0].points[0].value.double_value
8911010

1011+
logging.info(
1012+
"Instance %s: Current Utilized Chips = %s (Expected = %s)",
1013+
instance_id,
1014+
latest_value,
1015+
expected_chip_count,
1016+
)
8921017

893-
@task.sensor(poke_interval=60, timeout=3600, mode="poke")
1018+
if latest_value >= expected_chip_count:
1019+
logging.info("Target chip utilization reached and chips are HEALTHY.")
1020+
return True
1021+
else:
1022+
logging.info("Chips detected but not all are utilized yet. Retrying...")
1023+
1024+
return False
1025+
1026+
1027+
@task.sensor(poke_interval=60, timeout=600, mode="poke")
8941028
def wait_for_tpu_chip_state(
8951029
node_pool: node_pool_info,
8961030
instance_id: str,

0 commit comments

Comments
 (0)