|
#!/usr/bin/env python3 |
|
# Copyright The Marin Authors |
|
# SPDX-License-Identifier: Apache-2.0 |
|
"""Two ways to compute "CPU wall time" for an Iris job subtree. |
|
|
|
Background: PR https://github.com/marin-community/marin/pull/5637 adds |
|
``iris job cpu-time`` which the controller answers via: |
|
|
|
SUM(t.finished_at_ms - t.started_at_ms) over all leaf-job tasks |
|
|
|
This script reproduces that against a running Iris controller two ways: |
|
|
|
(A) cpu_wall_ms_from_controller(...) |
|
Hits the controller's ``ExecuteRawQuery`` only. Walks the job tree |
|
client-side (one SELECT on ``jobs``), then issues one SELECT on |
|
``tasks`` summing ``finished_at_ms - started_at_ms`` over the |
|
leaves. This matches PR 5637 exactly. |
|
|
|
(B) cpu_wall_ms_from_stats(...) |
|
Uses the controller only to enumerate leaves (one SELECT on |
|
``jobs``), then aggregates from the finelog ``iris.task`` stats |
|
namespace via the StatsService that the controller exposes at |
|
``/finelog.stats.StatsService/*``. Sums ``max(ts) - min(ts)`` per |
|
(task_id, attempt_id). Caveats vs (A): |
|
|
|
* iris.task carries no task ``state`` column, so we can't filter |
|
to TASK_STATE_SUCCEEDED — only the include_failed=True mode is |
|
meaningful. |
|
* Still-running tasks are included (PR (A) excludes them: their |
|
finished_at_ms is NULL). |
|
* Inter-attempt gaps (preempt + restart) are not captured. |
|
* Resolution is one sampling interval (~7 s) per attempt. |
|
|
|
In exchange you get true integrated CPU seconds via |
|
``Σ avg(cpu_millicores) · Δt`` — a different metric, returned |
|
alongside. |
|
|
|
Usage: |
|
# The controller URL is whatever ``iris cluster dashboard`` / |
|
# ``iris cluster dashboard-proxy`` print, or a local tunnel. |
|
python iris_job_cpu_time.py http://localhost:10001 /gonzalo/exp166-prod-v0.6 |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import contextlib |
|
import json |
|
import sys |
|
|
|
# pip install marin-iris finelog pyarrow |
|
from finelog.client import LogClient |
|
from iris.cli.main import rpc_client |
|
from iris.cluster.worker.stats import IrisTaskStat |
|
from iris.rpc import query_pb2 |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Shared: walk the job tree to find leaves (no WITH RECURSIVE — the |
|
# controller's ExecuteRawQuery rejects non-SELECT prefixes). |
|
# --------------------------------------------------------------------------- |
|
|
|
|
|
def _exec_raw(client, sql: str) -> list[dict]: |
|
"""Run a SELECT via the controller's ExecuteRawQuery RPC. Returns a |
|
list of row dicts.""" |
|
resp = client.execute_raw_query(query_pb2.RawQueryRequest(sql=sql)) |
|
headers = [c.name for c in resp.columns] |
|
return [dict(zip(headers, json.loads(r), strict=True)) for r in resp.rows] |
|
|
|
|
|
def _leaf_job_ids(client, root_job_id: str) -> list[str]: |
|
"""Return the subset of jobs under ``root_job_id`` that have no children.""" |
|
# Prefix-match on job_id picks up the root and all descendants. job_id is |
|
# `/<user>/<name>[/<child>...]`, so the LIKE term must escape nothing. |
|
sql = ( |
|
"SELECT job_id, parent_job_id FROM jobs " |
|
f"WHERE job_id = '{root_job_id}' OR job_id LIKE '{root_job_id}/%'" |
|
) |
|
rows = _exec_raw(client, sql) |
|
children: dict[str, list[str]] = {} |
|
ids: set[str] = set() |
|
for r in rows: |
|
ids.add(r["job_id"]) |
|
children.setdefault(r["parent_job_id"], []).append(r["job_id"]) |
|
return [j for j in ids if not children.get(j)] |
|
|
|
|
|
def _sql_in_list(values: list[str]) -> str: |
|
return ", ".join("'" + v.replace("'", "''") + "'" for v in values) |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# (A) Controller-only — matches PR 5637's logic. |
|
# --------------------------------------------------------------------------- |
|
|
|
|
|
def cpu_wall_ms_from_controller( |
|
controller_url: str, |
|
root_job_id: str, |
|
*, |
|
include_failed: bool = True, |
|
) -> dict[str, int]: |
|
"""Compute cpu_wall_ms by reading the controller's ``tasks`` table. |
|
|
|
Reproduces ``GetJobCpuTime`` from PR 5637 exactly (sans the server-side |
|
WITH RECURSIVE, which we do client-side). |
|
""" |
|
with rpc_client(controller_url) as client: |
|
leaves = _leaf_job_ids(client, root_job_id) |
|
if not leaves: |
|
return {"leaf_tasks": 0, "cpu_wall_ms": 0} |
|
|
|
# state=4 is TASK_STATE_SUCCEEDED; see job.proto. |
|
state_filter = "" if include_failed else "AND t.state = 4" |
|
sql = f""" |
|
SELECT COUNT(DISTINCT t.task_id) AS leaf_tasks, |
|
COALESCE(SUM(t.finished_at_ms - t.started_at_ms), 0) AS cpu_wall_ms |
|
FROM tasks t |
|
WHERE t.job_id IN ({_sql_in_list(leaves)}) |
|
AND t.started_at_ms IS NOT NULL |
|
AND t.finished_at_ms IS NOT NULL |
|
AND t.finished_at_ms > t.started_at_ms |
|
{state_filter} |
|
""" |
|
rows = _exec_raw(client, sql) |
|
return { |
|
"leaf_tasks": int(rows[0]["leaf_tasks"]), |
|
"cpu_wall_ms": int(rows[0]["cpu_wall_ms"]), |
|
} |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# (B) Controller for leaves only — aggregate from iris.task stats. |
|
# --------------------------------------------------------------------------- |
|
|
|
|
|
def cpu_wall_ms_from_stats( |
|
controller_url: str, |
|
root_job_id: str, |
|
) -> dict[str, int | float]: |
|
"""Approximate cpu_wall_ms from the ``iris.task`` finelog namespace. |
|
|
|
Hits the controller for the leaf list (``jobs`` table), then issues |
|
one Stats Query to the finelog server (also reachable via the |
|
controller — it mounts ``/finelog.stats.StatsService/*``). |
|
""" |
|
with rpc_client(controller_url) as client: |
|
leaves = _leaf_job_ids(client, root_job_id) |
|
if not leaves: |
|
return {"leaf_tasks": 0, "approx_cpu_wall_ms": 0, "integrated_cpu_seconds": 0.0} |
|
|
|
# Equivalent over the proxy channel: |
|
# POST controller/proxy/system.log-server/finelog.stats.StatsService/Query |
|
# which forwards to /system/log-server. LogClient.connect against the |
|
# controller hits the mounted FinelogStatsServiceWSGIApplication directly. |
|
with contextlib.closing(LogClient.connect(controller_url, timeout_ms=60_000)) as c: |
|
tbl = c.get_table("iris.task", IrisTaskStat) |
|
sql = f""" |
|
WITH per_attempt AS ( |
|
SELECT task_id, |
|
attempt_id, |
|
EXTRACT(EPOCH FROM (max(ts) - min(ts))) AS wall_s, |
|
avg(cpu_millicores) AS avg_cpu_m |
|
FROM "iris.task" |
|
WHERE regexp_replace(task_id, '/[0-9]+$', '') IN ({_sql_in_list(leaves)}) |
|
GROUP BY task_id, attempt_id |
|
) |
|
SELECT count(DISTINCT task_id) AS leaf_tasks, |
|
round(sum(wall_s) * 1000) AS approx_cpu_wall_ms, |
|
round(sum(wall_s * avg_cpu_m / 1000.0), 1) AS integrated_cpu_seconds |
|
FROM per_attempt |
|
""" |
|
t = tbl.query(sql, max_rows=10).to_pandas() |
|
row = t.iloc[0] |
|
return { |
|
"leaf_tasks": int(row["leaf_tasks"]), |
|
"approx_cpu_wall_ms": int(row["approx_cpu_wall_ms"]), |
|
"integrated_cpu_seconds": float(row["integrated_cpu_seconds"]), |
|
} |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# CLI |
|
# --------------------------------------------------------------------------- |
|
|
|
|
|
def _fmt_ms(ms: int) -> str: |
|
h, rem = divmod(ms // 1000, 3600) |
|
m, s = divmod(rem, 60) |
|
return f"{h}h{m:02d}m{s:02d}s" |
|
|
|
|
|
def main(argv: list[str] | None = None) -> int: |
|
p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
|
p.add_argument("controller_url", help="e.g. http://localhost:10001") |
|
p.add_argument("job_id", help="root job id, e.g. /alice/my-job") |
|
p.add_argument( |
|
"--include-failed", |
|
action="store_true", |
|
help="(controller variant) count all tasks with timestamps, not just SUCCEEDED", |
|
) |
|
args = p.parse_args(argv) |
|
|
|
a = cpu_wall_ms_from_controller(args.controller_url, args.job_id, include_failed=args.include_failed) |
|
print("=== A) controller `tasks` table (PR 5637 logic) ===") |
|
print(f" leaf tasks : {a['leaf_tasks']}") |
|
print(f" cpu_wall_ms : {a['cpu_wall_ms']:>15,d} ({_fmt_ms(a['cpu_wall_ms'])})") |
|
|
|
b = cpu_wall_ms_from_stats(args.controller_url, args.job_id) |
|
print() |
|
print("=== B) iris.task stats (sample-interval approximation) ===") |
|
print(f" leaf tasks : {b['leaf_tasks']}") |
|
print(f" approx wall_ms : {b['approx_cpu_wall_ms']:>15,d} ({_fmt_ms(b['approx_cpu_wall_ms'])})") |
|
print(f" integrated cpu_s : {b['integrated_cpu_seconds']:.1f} (true CPU consumed)") |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main()) |