"""GPU の空き判定(4 段階)と選択のテスト(SPEC_03「GPU の空き判定と選択」).
- 観測中の最大値で閾値を判定する(瞬間値が 0% でも観測中に超えたら使用中)
- 計算プロセスを所有者を問わず数える.一覧が取れなければ閾値内の GPU は「空き(利用者あり)」
- GPU の行が解釈できない・サンプル 0 件なら「状態不明」
- 選択では「未使用」をサーバの優先順位より優先する
"""
from __future__ import annotations
import pytest
from probe_helpers import PMON_HEADER, gpu_line, probe_text, sample_lines
from tias_judge_model.remote.config import GpuFreeThreshold, ServerConfig
from tias_judge_model.remote.gpu import (
GpuInfo,
GpuSelection,
GpuState,
ProbeOutput,
ServerProbe,
check_cpu_options,
check_selection_options,
classify_gpu,
gpu_users,
parse_probe_output,
select_cpu_server,
select_gpu,
)
from tias_judge_model.remote.observe import UsagePattern
OBSERVE = GpuFreeThreshold(
max_memory_used_mib=1024, max_utilization_percent=10, observe_sec=10, sample_interval_ms=500
)
INSTANT = GpuFreeThreshold(
max_memory_used_mib=1024, max_utilization_percent=10, observe_sec=0, sample_interval_ms=500
)
UUID0 = "GPU-00000000-aaaa-bbbb-cccc-000000000000"
UUID1 = "GPU-11111111-aaaa-bbbb-cccc-111111111111"
def _states(output: ProbeOutput) -> dict[int | None, GpuState]:
return {gpu.index: gpu.state for gpu in output.gpus}
def _gpu(output: ProbeOutput, index: int) -> GpuInfo:
return next(g for g in output.gpus if g.index == index)
# ---------------------------------------------------------------------------
# 判定(classify_gpu)
# ---------------------------------------------------------------------------
class TestClassifyGpu:
def test_within_threshold_without_processes_is_unused(self) -> None:
assert classify_gpu(0, 0, OBSERVE, 0) is GpuState.UNUSED
def test_within_threshold_with_processes_is_shared(self) -> None:
assert classify_gpu(500, 5, OBSERVE, 1) is GpuState.SHARED
def test_unknown_process_list_is_shared_not_unused(self) -> None:
assert classify_gpu(0, 0, OBSERVE, None) is GpuState.SHARED
@pytest.mark.parametrize(
("memory", "util", "expected"),
[
(1023, 9, GpuState.UNUSED),
(1024, 9, GpuState.BUSY), # 「未満」なのでちょうどは超えた扱い
(1023, 10, GpuState.BUSY),
(5000, 0, GpuState.BUSY),
(0, 100, GpuState.BUSY),
],
)
def test_threshold_boundaries(self, memory: int, util: int, expected: GpuState) -> None:
assert classify_gpu(memory, util, OBSERVE, 0) is expected
def test_busy_even_if_process_list_unknown(self) -> None:
assert classify_gpu(0, 50, OBSERVE, None) is GpuState.BUSY
# ---------------------------------------------------------------------------
# probe 出力の解析と判定(parse_probe_output)
# ---------------------------------------------------------------------------
class TestObservedJudgement:
def test_max_during_observation_is_used_even_if_instant_is_zero(self) -> None:
# 瞬間値は 0% だが,観測中に 74% まで上がった(2026-09-27 Newton の実測を模す)
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0)],
samples=sample_lines(0, [0, 0, 74, 0, 0, 30, 0, 0, 0, 0]),
)
output = parse_probe_output(text, OBSERVE)
assert _states(output) == {0: GpuState.BUSY}
def test_memory_peak_during_observation_makes_busy(self) -> None:
samples = ["0, 0, 10", "0, 0, 2000", "0, 0, 10"]
text = probe_text([gpu_line(0, UUID0, used=10, util=0)], samples=samples)
assert _states(parse_probe_output(text, OBSERVE)) == {0: GpuState.BUSY}
def test_always_within_threshold_without_processes_is_unused(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0)], samples=sample_lines(0, [0, 3, 9, 0, 1])
)
assert _states(parse_probe_output(text, OBSERVE)) == {0: GpuState.UNUSED}
def test_single_sample_over_threshold_is_busy(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0)], samples=sample_lines(0, [0, 0, 10, 0])
)
assert _states(parse_probe_output(text, OBSERVE)) == {0: GpuState.BUSY}
def test_no_samples_for_gpu_is_unknown(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0), gpu_line(1, UUID1, used=5, util=0)],
samples=sample_lines(0, [0, 0, 0]),
)
assert _states(parse_probe_output(text, OBSERVE)) == {
0: GpuState.UNUSED,
1: GpuState.UNKNOWN,
}
def test_missing_samples_section_makes_all_unknown(self) -> None:
text = probe_text([gpu_line(0, UUID0, used=5, util=0)], samples=None)
assert _states(parse_probe_output(text, OBSERVE)) == {0: GpuState.UNKNOWN}
def test_observe_zero_uses_instant_value(self) -> None:
text = probe_text([gpu_line(0, UUID0, used=5, util=0), gpu_line(1, UUID1, used=5, util=50)])
output = parse_probe_output(text, INSTANT)
assert _states(output) == {0: GpuState.UNUSED, 1: GpuState.BUSY}
def test_observe_zero_does_not_require_samples(self) -> None:
text = probe_text([gpu_line(0, UUID0, used=2000, util=0)])
assert _states(parse_probe_output(text, INSTANT)) == {0: GpuState.BUSY}
class TestProcessesAndUnknown:
def test_processes_are_matched_by_gpu_uuid(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=500, util=0), gpu_line(1, UUID1, used=5, util=0)],
samples=sample_lines(0, [0, 0]) + sample_lines(1, [0, 0]),
apps=[f"{UUID0}, 4321, 480"],
owners=["4321 alice 99720"],
)
output = parse_probe_output(text, OBSERVE)
assert _states(output) == {0: GpuState.SHARED, 1: GpuState.UNUSED}
process = _gpu(output, 0).processes[0]
assert process.pid == 4321
assert process.user == "alice"
assert process.elapsed_sec == 99720
assert _gpu(output, 1).processes == ()
def test_own_process_also_counts(self) -> None:
# 自分の別ジョブが載っている GPU にも次のジョブを重ねない(所有者を問わず数える)
text = probe_text(
[gpu_line(0, UUID0, used=300, util=0)],
samples=sample_lines(0, [0, 0]),
apps=[f"{UUID0}, 100, 300"],
owners=["100 hasegawa 10"],
)
output = parse_probe_output(text, OBSERVE)
gpu = _gpu(output, 0)
assert gpu.state is GpuState.SHARED
assert gpu_users(gpu, "hasegawa") == ["自分"]
assert gpu_users(gpu, "someone_else") == ["hasegawa"]
def test_process_list_failure_makes_within_threshold_gpus_shared(self) -> None:
text = probe_text(
[
gpu_line(0, UUID0, used=5, util=0),
gpu_line(1, UUID1, used=5, util=0),
],
samples=sample_lines(0, [0, 0]) + sample_lines(1, [0, 90]),
apps=[],
apps_rc=9,
)
output = parse_probe_output(text, OBSERVE)
assert not output.processes_query_ok
# 閾値内は未使用と誤認せず「空き(利用者あり)」,閾値を超えたものは使用中のまま
assert _states(output) == {0: GpuState.SHARED, 1: GpuState.BUSY}
def test_unparsable_process_list_makes_gpus_shared(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0), gpu_line(1, UUID1, used=5, util=0)],
samples=sample_lines(0, [0]) + sample_lines(1, [0]),
apps=["this is not csv"],
)
output = parse_probe_output(text, OBSERVE)
assert _states(output) == {0: GpuState.SHARED, 1: GpuState.SHARED}
def test_missing_process_section_makes_gpus_shared(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0)], samples=sample_lines(0, [0]), apps=None
)
assert _states(parse_probe_output(text, OBSERVE)) == {0: GpuState.SHARED}
@pytest.mark.parametrize(
"line",
[
f"0, {UUID0}, NVIDIA RTX A6000, [N/A], 49140, 0",
f"0, {UUID0}, NVIDIA RTX A6000, 5, 49140, [N/A]",
"0, garbage",
"garbage",
],
)
def test_unparsable_gpu_line_is_unknown(self, line: str) -> None:
text = probe_text(
[line, gpu_line(1, UUID1, used=5, util=0)],
samples=sample_lines(0, [0]) + sample_lines(1, [0]),
)
output = parse_probe_output(text, OBSERVE)
assert output.gpus[0].state is GpuState.UNKNOWN
assert _gpu(output, 1).state is GpuState.UNUSED
def test_gpu_query_failure_yields_no_candidates(self) -> None:
text = probe_text([], gpu_rc=127, apps=None)
output = parse_probe_output(text, OBSERVE)
assert not output.gpu_query_ok
assert output.gpus == ()
def test_graphics_process_is_not_counted_from_pmon(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=300, util=5)],
samples=sample_lines(0, [5, 0]),
pmon=[
*PMON_HEADER,
" 0 1234 C 74 10 - - - - python",
" 0 1234 C 3 1 - - - - python",
" 0 999 G 50 2 - - - - Xorg",
" 0 - - - - - - - - -",
],
apps=[f"{UUID0}, 1234, 280"],
owners=["1234 alice 45"],
)
output = parse_probe_output(text, OBSERVE)
gpu = _gpu(output, 0)
assert [p.pid for p in gpu.processes] == [1234]
assert gpu.processes[0].max_sm_percent == 74
def test_preamble_before_markers_is_ignored(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0)],
samples=sample_lines(0, [0]),
preamble=["Welcome to Ubuntu", "Last login: Sat Sep 27 10:00:00 2026"],
)
assert _states(parse_probe_output(text, OBSERVE)) == {0: GpuState.UNUSED}
def test_truncated_output_is_error(self) -> None:
text = probe_text([gpu_line(0, UUID0, used=5, util=0)], samples=sample_lines(0, [0]))
truncated = text.split("@@tias-end")[0]
with pytest.raises(ValueError):
parse_probe_output(truncated, OBSERVE)
def test_jobs_and_other_sessions(self) -> None:
text = probe_text([], jobs=["exp1"], others=["mywork"], samples=[])
output = parse_probe_output(text, OBSERVE)
assert output.jobs == ("exp1",)
assert output.other_sessions == ("mywork",)
class TestPatternDoesNotAffectState:
def test_intermittent_but_within_threshold_is_unused(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=0)], samples=sample_lines(0, [0, 5, 0, 0, 0])
)
gpu = _gpu(parse_probe_output(text, OBSERVE), 0)
assert gpu.pattern is UsagePattern.INTERMITTENT
assert gpu.state is GpuState.UNUSED
def test_constant_but_within_threshold_is_unused(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=5, util=3)], samples=sample_lines(0, [3, 3, 3, 3, 3])
)
gpu = _gpu(parse_probe_output(text, OBSERVE), 0)
assert gpu.pattern is UsagePattern.CONSTANT
assert gpu.state is GpuState.UNUSED
def test_memory_only_pattern_is_shared(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=500, util=0)],
samples=sample_lines(0, [0, 0, 0], memory=500),
apps=[f"{UUID0}, 1, 500"],
owners=["1 bob 100"],
)
gpu = _gpu(parse_probe_output(text, OBSERVE), 0)
assert gpu.pattern is UsagePattern.MEMORY_ONLY
assert gpu.state is GpuState.SHARED
def test_idle_pattern_but_memory_over_threshold_is_busy(self) -> None:
text = probe_text(
[gpu_line(0, UUID0, used=2000, util=0)],
samples=sample_lines(0, [0, 0, 0], memory=2000),
)
gpu = _gpu(parse_probe_output(text, OBSERVE), 0)
assert gpu.pattern is UsagePattern.IDLE
assert gpu.state is GpuState.BUSY
# ---------------------------------------------------------------------------
# 選択(select_gpu)
# ---------------------------------------------------------------------------
def _server(name: str, priority: int, *, data_ready: bool = True) -> ServerConfig:
return ServerConfig(
name=name,
address="192.168.0.1",
user="hasegawa",
priority=priority,
remote_dir="~/work/TIASJudgeModel",
data_dir="~/data/TIASJudgeModel",
data_ready=data_ready,
)
def _info(index: int, state: GpuState) -> GpuInfo:
return GpuInfo(
index=index,
uuid=f"GPU-{index}",
name="GPU",
memory_used_mib=0,
memory_total_mib=49140,
utilization_percent=0,
state=state,
processes_known=True,
)
def _probe(server: ServerConfig, *states: GpuState, gpu_query_ok: bool = True) -> ServerProbe:
gpus = tuple(_info(i, s) for i, s in enumerate(states))
return ServerProbe(
server,
ProbeOutput(
jobs=(),
other_sessions=(),
gpu_query_ok=gpu_query_ok,
processes_query_ok=True,
gpus=gpus if gpu_query_ok else (),
),
)
def _unreachable(server: ServerConfig) -> ServerProbe:
return ServerProbe(server, None, "Connection timed out")
U, S, B, X = GpuState.UNUSED, GpuState.SHARED, GpuState.BUSY, GpuState.UNKNOWN
NEWTON = _server("Newton", 1)
ARCHIMEDES = _server("Archimedes", 2)
def _picked(selection: GpuSelection | None) -> tuple[str, int, GpuState] | None:
if selection is None:
return None
return selection.server.name, selection.gpu_index, selection.state
class TestSelectGpuAutomatic:
def test_unused_beats_server_priority(self) -> None:
# Newton(優先)は利用者ありのみ,Archimedes に未使用 → Archimedes を選ぶ
probes = [_probe(NEWTON, S, S, B), _probe(ARCHIMEDES, B, U)]
assert _picked(select_gpu(probes)) == ("Archimedes", 1, U)
def test_priority_order_among_unused(self) -> None:
probes = [_probe(ARCHIMEDES, U), _probe(NEWTON, B, U)]
assert _picked(select_gpu(probes)) == ("Newton", 1, U)
def test_same_priority_uses_config_order(self) -> None:
first = _server("Alpha", 1)
second = _server("Beta", 1)
assert _picked(select_gpu([_probe(first, U), _probe(second, U)])) == ("Alpha", 0, U)
assert _picked(select_gpu([_probe(second, U), _probe(first, U)])) == ("Beta", 0, U)
def test_lowest_index_within_server(self) -> None:
probe = _probe(NEWTON, B, U, U)
assert _picked(select_gpu([probe])) == ("Newton", 1, U)
def test_index_order_even_if_listed_out_of_order(self) -> None:
output = ProbeOutput(
jobs=(),
other_sessions=(),
gpu_query_ok=True,
processes_query_ok=True,
gpus=(_info(3, U), _info(1, U), _info(2, U)),
)
assert _picked(select_gpu([ServerProbe(NEWTON, output)])) == ("Newton", 1, U)
def test_shared_when_no_unused_by_priority(self) -> None:
probes = [_probe(NEWTON, B, S), _probe(ARCHIMEDES, S)]
assert _picked(select_gpu(probes)) == ("Newton", 1, S)
def test_none_when_only_busy_or_unknown(self) -> None:
probes = [_probe(NEWTON, B, X), _probe(ARCHIMEDES, X, B)]
assert select_gpu(probes) is None
def test_data_not_ready_excluded_by_default(self) -> None:
archimedes = _server("Archimedes", 2, data_ready=False)
probes = [_probe(NEWTON, S), _probe(archimedes, U)]
assert _picked(select_gpu(probes)) == ("Newton", 0, S)
assert _picked(select_gpu(probes, include_not_ready=True)) == ("Archimedes", 0, U)
def test_only_data_not_ready_has_free_gpu(self) -> None:
archimedes = _server("Archimedes", 2, data_ready=False)
probes = [_probe(NEWTON, B), _probe(archimedes, U)]
assert select_gpu(probes) is None
assert _picked(select_gpu(probes, include_not_ready=True)) == ("Archimedes", 0, U)
def test_server_option_restricts_candidates(self) -> None:
probes = [_probe(NEWTON, U), _probe(ARCHIMEDES, B, S)]
assert _picked(select_gpu(probes, server_name="Archimedes")) == ("Archimedes", 1, S)
def test_server_option_none_available(self) -> None:
probes = [_probe(NEWTON, U), _probe(ARCHIMEDES, B)]
assert select_gpu(probes, server_name="Archimedes") is None
def test_unreachable_server_is_skipped(self) -> None:
probes = [_unreachable(NEWTON), _probe(ARCHIMEDES, S)]
assert _picked(select_gpu(probes)) == ("Archimedes", 0, S)
def test_server_with_failed_gpu_query_is_skipped(self) -> None:
probes = [_probe(NEWTON, gpu_query_ok=False), _probe(ARCHIMEDES, S)]
assert _picked(select_gpu(probes)) == ("Archimedes", 0, S)
def test_unknown_server_name_is_error(self) -> None:
with pytest.raises(ValueError):
select_gpu([_probe(NEWTON, U)], server_name="Nowhere")
class TestSelectGpuExplicit:
@pytest.mark.parametrize("state", [U, S])
def test_unused_or_shared_is_accepted(self, state: GpuState) -> None:
probes = [_probe(NEWTON, B, state)]
selection = select_gpu(probes, server_name="Newton", gpu_index=1)
assert _picked(selection) == ("Newton", 1, state)
assert selection is not None and not selection.forced
@pytest.mark.parametrize("state", [B, X])
def test_busy_or_unknown_is_error_without_force(self, state: GpuState) -> None:
probes = [_probe(NEWTON, U, state)]
with pytest.raises(ValueError):
select_gpu(probes, server_name="Newton", gpu_index=1)
@pytest.mark.parametrize("state", [B, X])
def test_busy_or_unknown_is_allowed_with_force(self, state: GpuState) -> None:
probes = [_probe(NEWTON, U, state)]
selection = select_gpu(probes, server_name="Newton", gpu_index=1, force=True)
assert _picked(selection) == ("Newton", 1, state)
assert selection is not None and selection.forced
def test_explicit_gpu_even_if_other_server_has_unused(self) -> None:
probes = [_probe(NEWTON, S), _probe(ARCHIMEDES, U)]
assert _picked(select_gpu(probes, server_name="Newton", gpu_index=0)) == ("Newton", 0, S)
def test_gpu_without_server_is_error(self) -> None:
with pytest.raises(ValueError):
select_gpu([_probe(NEWTON, U)], gpu_index=0)
with pytest.raises(ValueError):
check_selection_options(server_name=None, gpu_index=0, force=False)
def test_gpu_with_server_passes_option_check(self) -> None:
check_selection_options(server_name="Newton", gpu_index=0, force=False)
check_selection_options(server_name="Newton", gpu_index=0, force=True)
check_selection_options(server_name=None, gpu_index=None, force=False)
# ---------------------------------------------------------------------------
# run --cpu(SPEC_03「run」)
# ---------------------------------------------------------------------------
class TestCheckCpuOptions:
def test_cpu_alone_is_accepted(self) -> None:
check_cpu_options(gpu_index=None, force=False, observe_sec=None)
@pytest.mark.parametrize(
("gpu_index", "force", "observe_sec"),
[(0, False, None), (None, True, None), (None, False, 0), (None, False, 10), (1, True, 5)],
)
def test_gpu_related_options_conflict(
self, gpu_index: int | None, force: bool, observe_sec: int | None
) -> None:
with pytest.raises(ValueError):
check_cpu_options(gpu_index=gpu_index, force=force, observe_sec=observe_sec)
class TestSelectCpuServer:
def test_gpu_state_is_ignored(self) -> None:
# GPU がすべて使用中・状態不明・一覧取得失敗でも,接続できれば選ぶ
probes = [_probe(NEWTON, B, X), _probe(ARCHIMEDES, U)]
assert select_cpu_server(probes).name == "Newton"
probes = [_probe(NEWTON, gpu_query_ok=False), _probe(ARCHIMEDES, U)]
assert select_cpu_server(probes).name == "Newton"
def test_priority_order(self) -> None:
# 設定の記載順ではなく priority 順
probes = [_probe(ARCHIMEDES, U), _probe(NEWTON, B)]
assert select_cpu_server(probes).name == "Newton"
def test_same_priority_uses_config_order(self) -> None:
a, b = _server("A", 1), _server("B", 1)
assert select_cpu_server([_probe(b, U), _probe(a, U)]).name == "B"
def test_unreachable_is_skipped(self) -> None:
probes = [_unreachable(NEWTON), _probe(ARCHIMEDES, B)]
assert select_cpu_server(probes).name == "Archimedes"
def test_all_unreachable_is_none(self) -> None:
assert select_cpu_server([_unreachable(NEWTON), _unreachable(ARCHIMEDES)]) is None
def test_data_not_ready_is_excluded_by_default(self) -> None:
newton = _server("Newton", 1, data_ready=False)
probes = [_probe(newton, U), _probe(ARCHIMEDES, B)]
assert select_cpu_server(probes).name == "Archimedes"
assert select_cpu_server([_probe(newton, U)]) is None
def test_no_data_includes_not_ready(self) -> None:
newton = _server("Newton", 1, data_ready=False)
probes = [_probe(newton, U), _probe(ARCHIMEDES, B)]
assert select_cpu_server(probes, include_not_ready=True).name == "Newton"
def test_explicit_server(self) -> None:
probes = [_probe(NEWTON, U), _probe(ARCHIMEDES, B)]
assert select_cpu_server(probes, server_name="Archimedes").name == "Archimedes"
def test_explicit_unreachable_server_is_error(self) -> None:
probes = [_probe(NEWTON, U), _unreachable(ARCHIMEDES)]
with pytest.raises(ValueError):
select_cpu_server(probes, server_name="Archimedes")
def test_explicit_not_ready_server_requires_no_data(self) -> None:
newton = _server("Newton", 1, data_ready=False)
probes = [_probe(newton, U), _probe(ARCHIMEDES, U)]
with pytest.raises(ValueError):
select_cpu_server(probes, server_name="Newton")
assert select_cpu_server(probes, server_name="Newton", include_not_ready=True) is newton
def test_unknown_server_is_error(self) -> None:
with pytest.raises(ValueError):
select_cpu_server([_probe(NEWTON, U)], server_name="Euler")