blob: 1482dd144d6ababfe5a6e725e7189d66e7366e25 [file] [edit]
############################################################################
# Copyright (C) 2026 Google LLC.
############################################################################
"""Test job_submit/tpu --layout expansion, validation, and GRES preservation."""
import pytest
import atf
TPU_YAML = """tpu_types:
- type: tpu7x
pci_device_id: "0x0076"
shape_per_node: 2x2x1
shape_per_segment: 2x2x4
nodes_per_pod: 16
- type: v6e
pci_device_id: "0x006f"
shape_per_node: 2x2
shape_per_segment: 16x16
nodes_per_pod: 64
"""
GRES_CONF = """AutoDetect=off
Name=tpu Type=tpu7x Count=4
Name=fake Count=1
Name=tpu_foo Count=1
"""
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"bin/sbatch",
reason="Issue 51191: sbatch --layout and job_submit/tpu added in 26.11",
)
atf.require_version(
(26, 11),
"bin/scontrol",
reason="Issue 51191: scontrol Layout= added in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("JobSubmitPlugins", "tpu")
atf.require_config_parameter_includes("GresTypes", "tpu")
atf.require_config_parameter_includes("GresTypes", "fake")
atf.require_config_parameter_includes("GresTypes", "tpu_foo")
atf.require_config_file("tpu.yaml", TPU_YAML)
atf.require_config_file("gres.conf", GRES_CONF)
atf.require_nodes(8, [("Gres", "tpu:tpu7x:4,fake:1,tpu_foo:1"), ("CPUs", 8)])
atf.require_slurm_running()
def _submit_held(extra_args=""):
args = "--hold --output=/dev/null --wrap='true'"
if extra_args:
args = f"{extra_args} {args}"
return atf.submit_job_sbatch(args, fatal=True)
def _field(job_id, param):
return atf.get_job_parameter(job_id, param, default=None, quiet=True)
def _tres_per_node(job_id):
"""The TresPerNode entries of a job, in no particular order"""
tres = _field(job_id, "TresPerNode")
return sorted(tres.split(",")) if tres else []
def test_intra_segment_request():
"""A request fitting inside one segment sets Layout, NumNodes, and TresPerNode."""
# 2x2x2 = 8 chips / 4 chips_per_node = 2 nodes <= 4 (nodes_per_segment)
job_id = _submit_held("--layout=tpu7x=2x2x2")
assert _field(job_id, "Layout") == "tpu7x=2x2x2", "The layout should be stored"
assert _field(job_id, "NumNodes") == "2-2", "2x2x2 should need 2 nodes"
assert (
_field(job_id, "TresPerNode") == "gres/tpu:tpu7x:4"
), "Each node should get the 4 chips of a tpu7x node"
def test_multi_segment_request():
"""A request spanning segments sets Layout, NumNodes, and TresPerNode."""
# 2x2x8 = 32 chips / 4 chips_per_node = 8 nodes > 4 (nodes_per_segment)
job_id = _submit_held("--layout=tpu7x=2x2x8")
assert _field(job_id, "Layout") == "tpu7x=2x2x8", "The layout should be stored"
assert _field(job_id, "NumNodes") == "8-8", "2x2x8 should need 8 nodes"
assert (
_field(job_id, "TresPerNode") == "gres/tpu:tpu7x:4"
), "Each node should get the 4 chips of a tpu7x node"
def test_submit_node_count_checked_against_layout():
"""A -N the layout does not need is refused, and the one it needs is taken."""
result = atf.run_command(
"sbatch --hold -N 4 --layout=tpu7x=2x2x2 --wrap='true'",
xfail=True,
)
assert result["exit_code"] != 0, "sbatch -N 4 should fail for a 2-node layout"
assert (
"needs exactly 2 nodes" in result["stderr"]
), f"The error should give the node count the layout needs: {result['stderr']}"
job_id = _submit_held("-N 2 --layout=tpu7x=2x2x2")
assert _field(job_id, "NumNodes") == "2-2", "-N 2 should be accepted"
def test_tres_per_node_duplicate_refused():
"""Submitting both --layout and a conflicting --gres=tpu is rejected."""
result = atf.run_command(
"sbatch --hold --layout=tpu7x=2x2x2 --gres=tpu:tpu7x:4 --wrap='true'",
xfail=True,
)
assert result["exit_code"] != 0, "sbatch should fail when TPUs are requested twice"
assert (
"already requests TPUs" in result["stderr"]
), "The error should name the duplicate TPU request"
def test_unrelated_gres_preserved():
"""Unrelated GRES on the command line is preserved alongside layout's TPUs."""
job_id = _submit_held("--layout=tpu7x=2x2x2 --gres=fake:1")
tres = _field(job_id, "TresPerNode")
assert tres is not None, "The job should request GRES"
assert "gres/fake:1" in tres, f"The fake GRES should be kept: {tres}"
assert "gres/tpu:tpu7x:4" in tres, f"The layout should add its TPUs: {tres}"
def test_scontrol_update_layout_preserves_unrelated_gres():
"""Updating Layout via scontrol re-sizes the job and preserves unrelated GRES."""
job_id = _submit_held("--layout=tpu7x=2x2x2 --gres=fake:1")
atf.run_command(
f"scontrol update JobId={job_id} Layout=tpu7x=2x2x8",
fatal=True,
)
assert _field(job_id, "Layout") == "tpu7x=2x2x8", "The new layout should be stored"
assert _field(job_id, "NumNodes") == "8-8", "The job should be resized to 8 nodes"
assert _tres_per_node(job_id) == [
"gres/fake:1",
"gres/tpu:tpu7x:4",
], "The fake GRES should be kept with the layout's TPUs"
def test_scontrol_clear_layout_preserves_unrelated_gres():
"""Clearing Layout via scontrol strips TPU GRES and preserves unrelated GRES."""
job_id = _submit_held("--layout=tpu7x=2x2x2 --gres=fake:1")
atf.run_command(
f"scontrol update JobId={job_id} Layout= NumNodes=1",
fatal=True,
)
assert _field(job_id, "Layout") is None, "The layout should be cleared"
assert (
_field(job_id, "TresPerNode") == "gres/fake:1"
), "Only the layout's TPUs should be removed"
assert _field(job_id, "NumNodes") in (
"1",
"1-1",
), "The job should take the node count given with the update"
def test_scontrol_clear_layout_needs_numnodes():
"""Clearing a TPU layout without NumNodes is refused and changes nothing."""
job_id = _submit_held("--layout=tpu7x=2x2x2 --gres=fake:1")
result = atf.run_command(
f"scontrol update JobId={job_id} Layout=",
xfail=True,
)
assert (
result["exit_code"] != 0
), "Clearing a TPU layout without NumNodes should be refused"
assert (
"Node count specification invalid" in result["stderr"]
), f"The update should be refused for its node count: {result['stderr']}"
assert _field(job_id, "Layout") == "tpu7x=2x2x2", "The layout should be kept"
assert _field(job_id, "NumNodes") == "2-2", "The node count should be kept"
assert _tres_per_node(job_id) == [
"gres/fake:1",
"gres/tpu:tpu7x:4",
], "The GRES should be kept"
def test_layout_pairs_for_other_plugins_kept():
"""Pairs that request no TPUs are kept and do not change the sizing."""
job_id = _submit_held("--layout=tpu7x=2x2x2,ocs=on")
assert (
_field(job_id, "Layout") == "tpu7x=2x2x2,ocs=on"
), "The layout should be stored with the pair for other plugins"
assert (
_field(job_id, "NumNodes") == "2-2"
), "The TPU pair alone should size the job to 2 nodes"
assert (
_field(job_id, "TresPerNode") == "gres/tpu:tpu7x:4"
), "The TPU pair alone should add gres/tpu:tpu7x:4"
def test_layout_without_tpu_pair():
"""A layout that requests no TPUs is accepted and adds no TPU GRES."""
job_id = _submit_held("--layout=foo=bar")
assert _field(job_id, "Layout") == "foo=bar", "The layout should be stored as given"
assert (
_field(job_id, "TresPerNode") is None
), "A layout with no TPU pair should not add TPU GRES"
@pytest.mark.parametrize(
"layout,tres",
[
("ranks=8", None),
("tpu7x=2x2x2,ranks=8", "gres/tpu:tpu7x:4"),
],
ids=["number_only", "number_with_tpu_pair"],
)
def test_layout_number_pair_for_other_plugins(layout, tres):
"""A pair whose value is a plain number is left to other plugins."""
job_id = _submit_held(f"--layout={layout}")
assert _field(job_id, "Layout") == layout, "The layout should be stored as given"
assert (
_field(job_id, "TresPerNode") == tres
), "Only the TPU pair should add TPU GRES"
def test_gres_named_like_tpu_kept():
"""A GRES whose name only starts with tpu is not taken for TPUs."""
job_id = _submit_held("--layout=tpu7x=2x2x2 --gres=tpu_foo:1")
assert _tres_per_node(job_id) == [
"gres/tpu:tpu7x:4",
"gres/tpu_foo:1",
], "The tpu_foo GRES should be kept with the layout's TPUs"
def test_tres_per_task_duplicate_refused():
"""TPUs requested in another TRES field are refused alongside --layout."""
result = atf.run_command(
"sbatch --hold --layout=tpu7x=2x2x2 --ntasks=2 --tres-per-task=gres/tpu=1 --wrap='true'",
xfail=True,
)
assert result["exit_code"] != 0, "sbatch should fail when TPUs are requested twice"
assert (
"already requests TPUs" in result["stderr"]
), "The error should name the duplicate TPU request"
def test_scontrol_update_gres_keeps_layout_tpus():
"""Replacing the GRES of a pending job keeps the TPUs its layout requests."""
job_id = _submit_held("--layout=tpu7x=2x2x2")
atf.run_command(f"scontrol update JobId={job_id} Gres=fake:1", fatal=True)
assert (
_field(job_id, "Layout") == "tpu7x=2x2x2"
), "Updating the GRES should not change the layout"
assert _tres_per_node(job_id) == [
"gres/fake:1",
"gres/tpu:tpu7x:4",
], "The new GRES should be kept with the TPUs the layout requests"
def test_scontrol_update_numnodes_checked_against_layout():
"""A node count that contradicts the layout is refused."""
job_id = _submit_held("--layout=tpu7x=2x2x2")
result = atf.run_command(
f"scontrol update JobId={job_id} NumNodes=4",
xfail=True,
)
assert (
result["exit_code"] != 0
), "A node count the layout does not need should be refused"
assert (
"Node count specification invalid" in result["stderr"]
), f"The update should be refused for its node count: {result['stderr']}"
assert (
_field(job_id, "NumNodes") == "2-2"
), "A refused update should leave the node count as it was"
atf.run_command(f"scontrol update JobId={job_id} NumNodes=2", fatal=True)
assert (
_field(job_id, "NumNodes") == "2-2"
), "The node count the layout needs should be accepted"
def test_scontrol_update_same_tpu_pair_keeps_tpus():
"""An update that keeps the TPU pair keeps the TPUs when its GRES changes."""
job_id = _submit_held("--layout=tpu7x=2x2x2")
atf.run_command(
f"scontrol update JobId={job_id} Layout=tpu7x=2x2x2,ocs=on Gres=fake:1",
fatal=True,
)
assert (
_field(job_id, "Layout") == "tpu7x=2x2x2,ocs=on"
), "The pair for other plugins should be added"
tres = _field(job_id, "TresPerNode")
assert tres is not None, "The job should request GRES"
assert "gres/fake:1" in tres, f"The new GRES should be set: {tres}"
assert "gres/tpu:tpu7x:4" in tres, f"The layout's TPUs should be kept: {tres}"
def test_scontrol_update_same_tpu_pair_numnodes_checked():
"""An update that keeps the TPU pair refuses a node count it does not need."""
job_id = _submit_held("--layout=tpu7x=2x2x2")
result = atf.run_command(
f"scontrol update JobId={job_id} Layout=tpu7x=2x2x2,ocs=on NumNodes=4",
xfail=True,
)
assert (
result["exit_code"] != 0
), "A node count the layout does not need should be refused"
assert (
"Node count specification invalid" in result["stderr"]
), f"The update should be refused for its node count: {result['stderr']}"
assert _field(job_id, "Layout") == "tpu7x=2x2x2", "The layout should be kept"
assert (
_field(job_id, "NumNodes") == "2-2"
), "A refused update should leave the node count as it was"
def test_subhost_gres_without_layout():
"""Sub-host TPU jobs via --gres=tpu without --layout succeed."""
job_id = _submit_held("--gres=tpu:tpu7x:2")
tres = _field(job_id, "TresPerNode")
assert tres is not None, "The job should request GRES"
assert "gres/tpu:tpu7x:2" in tres, f"The requested TPUs should be kept: {tres}"
assert _field(job_id, "Layout") is None, "The job should have no layout"
def test_cpu_only_without_layout():
"""CPU-only jobs on a TPU partition without --layout succeed."""
job_id = _submit_held()
assert _field(job_id, "Layout") is None, "The job should have no layout"
@pytest.mark.parametrize(
"layout_arg,expected_err",
[
# Out of order: 4x2x2 is 16 chips, but dimensions must not decrease
("--layout=tpu7x=4x2x2", "dimensions must be in non-decreasing order"),
# Non-canonical family: 7x instead of tpu7x
("--layout=7x=2x2x2", "Unknown accelerator family"),
# Incompatible shape: 3x3x3 is not a multiple of node shape 2x2x1
("--layout=tpu7x=3x3x3", "whole number of 2x2x1 nodes along every axis"),
# Unbalanced intra-segment aspect ratio: 4 v6e nodes are 4x4, not 2x8
("--layout=v6e=2x8", "largest dimension at most twice its smallest"),
# TPUs from two families in one job
("--layout=tpu7x=2x2x2,v6e=2x2", "one accelerator family only"),
# Two TPU pairs of one family
("--layout=tpu7x=2x2x2,tpu7x=2x2x4", "one accelerator family only"),
],
ids=[
"out_of_order",
"non_canonical_family",
"incompatible_shape",
"unbalanced_aspect_ratio",
"two_families",
"same_family_twice",
],
)
def test_rejections(layout_arg, expected_err):
"""Invalid geometries and non-canonical families must be rejected."""
result = atf.run_command(
f"sbatch --hold {layout_arg} --wrap='true'",
xfail=True,
)
assert result["exit_code"] != 0, f"sbatch {layout_arg} should be refused"
assert (
expected_err in result["stderr"]
), f"Expected '{expected_err}' in: {result['stderr']}"
assert (
"Invalid layout specification" in result["stderr"]
), "The job should be refused for its layout"
def test_yaml_clean_load():
"""Verify that tpu.yaml was parsed without error in the controller log."""
ctld_log = atf.get_config()["SlurmctldLogFile"]
log_content = atf.run_command_output(
f"cat {ctld_log}", user="root", fatal=True, quiet=True
)
assert (
"no usable tpu.yaml" not in log_content
), "job_submit/tpu should load tpu.yaml without error"