Newer
Older
TIASJudgeModel / src / tias_judge_model / remote / report.py
"""サーバの状況(GPU 一覧・実行中ジョブ)を表示用の文字列に整形する純関数群."""

from __future__ import annotations

from collections.abc import Sequence

from tias_judge_model.remote.gpu import (
    GpuInfo,
    GpuProcess,
    GpuSelection,
    GpuState,
    ServerProbe,
    display_user_name,
    gpu_users,
)
from tias_judge_model.remote.observe import format_elapsed

# 計算プロセスの一覧が取れなかった GPU の利用者の表示(run の警告と GPU 一覧で共通)
_UNKNOWN_USERS = "利用者不明(計算プロセスの一覧を取得できない)"


def _format_value(value: int | None, unit: str) -> str:
    return "?" if value is None else f"{value}{unit}"


def describe_gpu_users(gpu: GpuInfo, own_user: str) -> str:
    """GPU の利用者(計算プロセスの所有ユーザー)を表示用に 1 句で返す.

    Args:
        gpu: GPU の状況.
        own_user: 自分のユーザー名(ssh の接続ユーザー.一致するものは「自分」).

    Returns:
        `利用者: 自分, alice` の形.計算プロセスの一覧が取れなければ `利用者不明`,
        計算プロセスが無ければ空文字.
    """
    if not gpu.processes_known:
        return _UNKNOWN_USERS
    users = gpu_users(gpu, own_user)
    return f"利用者: {', '.join(users)}" if users else ""


def describe_usage(gpu: GpuInfo, *, observed: bool) -> str:
    """GPU の使い方(型と使用率)を表示用に 1 句で返す.

    Args:
        gpu: GPU の状況.
        observed: 観測したか(False なら瞬間値だけを示し,型は付けない).

    Returns:
        観測したときは `断続的に使用・平均 21%・最大 74%(0% 超 7/20)` の形.観測サンプルが
        無ければ `観測できない`.観測しないときは `使用率 29%(瞬間値)`.
    """
    usage = gpu.usage
    if not observed:
        return f"使用率 {_format_value(gpu.utilization_percent, '%')}(瞬間値)"
    if usage is None:
        return "観測できない(サンプル無し)"
    pattern = gpu.pattern.value if gpu.pattern is not None else "使い方不明"
    return (
        f"{pattern}・平均 {usage.mean_utilization_percent:.0f}%"
        f"・最大 {usage.max_utilization_percent}%"
        f"(0% 超 {usage.active_count}/{usage.sample_count})"
    )


def describe_process(process: GpuProcess, own_user: str) -> str:
    """計算プロセス 1 件を表示用に 1 句で返す.

    Args:
        process: 計算プロセス.
        own_user: 自分のユーザー名(ssh の接続ユーザー.一致するものは「自分」).

    Returns:
        `ando  経過 27.7 時間  SM 最大 74%` の形(取れない項目は省く.所有者が取れなければ
        `不明`).
    """
    parts = [display_user_name(process.user, own_user)]
    if process.elapsed_sec is not None:
        parts.append(f"経過 {format_elapsed(process.elapsed_sec)}")
    if process.max_sm_percent is not None:
        parts.append(f"SM 最大 {process.max_sm_percent}%")
    return "  ".join(parts)


def _format_gpu_lines(gpu: GpuInfo, own_user: str, *, observed: bool) -> list[str]:
    index = "?" if gpu.index is None else str(gpu.index)
    memory = f"{_format_value(gpu.memory_used_mib, '')} / {_format_value(gpu.memory_total_mib, '')}"
    lines = [
        f"    GPU {index:>2}  {gpu.name}  メモリ {memory} MiB  [{gpu.state.value}]"
        f"  {describe_usage(gpu, observed=observed)}"
    ]
    if gpu.state is GpuState.UNKNOWN and gpu.usage is None and not gpu.processes:
        return lines
    if not gpu.processes_known:
        lines.append(f"        {_UNKNOWN_USERS}")
    else:
        lines.extend(f"        - {describe_process(p, own_user)}" for p in gpu.processes)
    return lines


def format_server_header(probe: ServerProbe) -> str:
    """サーバ 1 台の見出し行を返す.

    Args:
        probe: サーバの状況.

    Returns:
        `[名前] アドレス priority=N データ=... 観測 10 秒` の形の 1 行(接続不可なら理由を
        添える).
    """
    server = probe.server
    data = "配置済み" if server.data_ready else "未配置"
    header = f"[{server.name}] {server.address}  priority={server.priority}  データ={data}"
    if probe.output is None:
        reason = f"({probe.error})" if probe.error else ""
        return f"{header}  接続不可{reason}"
    observe_sec = probe.output.observe_sec
    return f"{header}  {'観測 ' + str(observe_sec) + ' 秒' if observe_sec else '瞬間値のみ'}"


def format_server_report(probes: Sequence[ServerProbe], *, show_jobs: bool) -> str:
    """全サーバの GPU 一覧(状態と利用者.と実行中ジョブ)を表示用に整形する.

    Args:
        probes: 各サーバの状況(表示順).
        show_jobs: 実行中ジョブも表示するか(status 用).

    Returns:
        末尾に改行を含む複数行の文字列.
    """
    lines: list[str] = []
    for probe in probes:
        lines.append(format_server_header(probe))
        output = probe.output
        if output is None:
            continue
        if show_jobs:
            jobs = ", ".join(output.jobs) if output.jobs else "(なし)"
            lines.append(f"  実行中のジョブ: {jobs}")
            if output.other_sessions:
                lines.append(
                    f"  (このツール以外の tmux セッション {len(output.other_sessions)} 件)"
                )
        if not output.gpu_query_ok:
            lines.append("  GPU: nvidia-smi が失敗した(このサーバの GPU は候補から外す)")
        elif not output.gpus:
            lines.append("  GPU: なし")
        else:
            lines.append("  GPU:")
            for gpu in output.gpus:
                lines.extend(
                    _format_gpu_lines(gpu, probe.server.user, observed=output.observe_sec > 0)
                )
    return "\n".join(lines) + "\n"


def format_selection(selection: GpuSelection | None) -> str:
    """run が選ぶ GPU の説明を 1 行で返す.

    Args:
        selection: `select_gpu` の結果.

    Returns:
        表示用の 1 行(改行なし).
    """
    if selection is None:
        return "run が選ぶ GPU: なし(未使用・空き(利用者あり)の GPU が無い)"
    text = (
        f"run が選ぶ GPU: {selection.server.name} の GPU {selection.gpu_index}"
        f"({selection.state.value})"
    )
    if selection.state is GpuState.SHARED and selection.gpu is not None:
        text += f"  {describe_gpu_users(selection.gpu, selection.server.user)}"
    return text


def format_shared_warning(selection: GpuSelection) -> str | None:
    """「空き(利用者あり)」の GPU を選んだときの警告文を返す(それ以外は None).

    Args:
        selection: `select_gpu` の結果.

    Returns:
        警告文(改行なし).
    """
    if selection.state is not GpuState.SHARED or selection.gpu is None:
        return None
    users = describe_gpu_users(selection.gpu, selection.server.user)
    return (
        f"{selection.server.name} の GPU {selection.gpu_index} には計算プロセスがある"
        f"({users}).他の計算とメモリ・計算時間を分け合う"
    )