Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add default benchmark scheduler #271

Merged
82 changes: 82 additions & 0 deletions gematria/datasets/pipelines/benchmark_cpu_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
# limitations under the License.

import abc
from collections.abc import Collection
import os
import re
from typing_extensions import override


Expand Down Expand Up @@ -66,3 +69,82 @@ def setup_and_get_benchmark_core(self) -> int | None:
@override
def verify(self):
pass


def _get_neighboring_threads(cpu_index: int) -> list[int]:
with open(
f'/sys/devices/system/cpu/cpu{cpu_index}/topology/thread_siblings_list'
) as thread_sibling_list_handle:
neighboring_threads_strings = re.split(
'[-,]+', thread_sibling_list_handle.read().strip()
)
neighboring_threads = [
int(cpu_index_str) for cpu_index_str in neighboring_threads_strings
]
return neighboring_threads


class DefaultBenchmarkScheduler(BenchmarkScheduler):
"""A BenchmarkScheduler that schedules processes separately.

DefaultBenchmarkScheduler schedules the main process and the benchmark
subprocess on separate cores, making sure to reserve the second hyperthread
on the benchmarking core to prevent interference. It expects that the main
process is initially given a CPU Mask with three active threads, additionally
assuming that two of the threads are neighboring (part of the same core).
Errors are raised if these conditions are not met. The benchmarking core
returned is one of the two neighboring threads. The main process has its
CPU mask limited to the thread that neighbors neither of the other threads.
"""

def __init__(self):
self._cpu_mask = []

def _get_aux_core_and_hyperthread_pair(
self,
cpu_mask: Collection[int],
) -> tuple[int, list[int]]:
for cpu_index in cpu_mask:
neighboring_threads = _get_neighboring_threads(cpu_index)
if len(neighboring_threads) != 2:
raise ValueError('Expected two hyperthreads per CPU.')

if (
neighboring_threads[0] not in cpu_mask
or neighboring_threads[1] not in cpu_mask
):
continue

cpus = list(cpu_mask)
cpus.remove(neighboring_threads[0])
cpus.remove(neighboring_threads[1])

if not cpus:
continue

return (cpus[0], [neighboring_threads[0], neighboring_threads[1]])
raise ValueError(
'Expected a pair of neighboring hyperthreads and an aux thread in the'
' CPU mask.'
)

@override
def setup_and_get_benchmark_core(self) -> int | None:
cpu_mask = os.sched_getaffinity(0)

if len(cpu_mask) != 3:
raise ValueError('Expected to have three CPUs.')

aux_core, hyperthread_pair = self._get_aux_core_and_hyperthread_pair(
cpu_mask
)
os.sched_setaffinity(0, [aux_core])
self._cpu_mask = [aux_core]

return hyperthread_pair[0]

@override
def verify(self):
cpu_mask = list(os.sched_getaffinity(0))
if self._cpu_mask != cpu_mask:
raise ValueError('Expected the CPU mask to not change.')
106 changes: 106 additions & 0 deletions gematria/datasets/pipelines/benchmark_cpu_scheduler_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,124 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import os
from collections.abc import Iterable

from absl.testing import absltest

from gematria.datasets.pipelines import benchmark_cpu_scheduler


class BenchmarkSchedulerTests(absltest.TestCase):

def _set_normal_affinity(self):
cpu_mask = os.sched_getaffinity(0)
cpu_mask_list = list(cpu_mask)

original_cpu_mask = list(cpu_mask_list)
self.addCleanup(lambda: os.sched_setaffinity(0, original_cpu_mask))

# We first select a CPU to use for the hyperthread pair and then remove
# both cores in the pair from the mask set to ensure that we do not
# accidentally select an auxiliary CPU that is part of the pair.
hyperthread_pair_part = next(iter(cpu_mask))
hyperthread_pair = benchmark_cpu_scheduler._get_neighboring_threads(
hyperthread_pair_part
)
cpu_mask.remove(hyperthread_pair[0])
cpu_mask.remove(hyperthread_pair[1])
aux_cpu = cpu_mask.pop()
new_cpu_mask = [aux_cpu, *hyperthread_pair]

os.sched_setaffinity(0, new_cpu_mask)
return (aux_cpu, hyperthread_pair, cpu_mask_list)

def test_no_scheduling(self):
scheduler = benchmark_cpu_scheduler.NoSchedulingBenchmarkScheduler()
self.assertIsNone(scheduler.setup_and_get_benchmark_core())
scheduler.verify()

def test_default_scheduler_get_neighboring_threads(self):
neighboring_threads = benchmark_cpu_scheduler._get_neighboring_threads(0)

# Just check that we get two CPU ids back that are not the same. We cannot
# do much more without knowing more about the system topology, and this
# should be a reasonable enough test.
self.assertLen(neighboring_threads, 2)
self.assertNotEqual(neighboring_threads[0], neighboring_threads[1])

def test_default_scheduler_get_cores(self):
expected_aux_cpu, expected_hyperthread_pair, _ = self._set_normal_affinity()
scheduler = benchmark_cpu_scheduler.DefaultBenchmarkScheduler()
cpu_mask = os.sched_getaffinity(0)
aux_cpu, hyperthread_pair = scheduler._get_aux_core_and_hyperthread_pair(
cpu_mask
)
self.assertEqual(aux_cpu, expected_aux_cpu)
self.assertContainsSubsequence(hyperthread_pair, expected_hyperthread_pair)

def test_default_scheduler_get_cores_no_neighboring_threads(self):
cpu_mask = os.sched_getaffinity(0)

# If we have less than five CPUs, that means we are guaranteed to get
# a pair when selecting three of them. Skip this test in those
# instances.
if len(cpu_mask) < 5:
self.skipTest('Not enough cores to complete setup properly.')

three_cores = [cpu_mask.pop(), cpu_mask.pop(), cpu_mask.pop()]

scheduler = benchmark_cpu_scheduler.DefaultBenchmarkScheduler()
with self.assertRaisesRegex(
ValueError,
'Expected a pair of neighboring hyperthreads and an aux thread in the'
' CPU mask.',
):
scheduler._get_aux_core_and_hyperthread_pair(three_cores)

def test_default_scheduler_setup(self):
expected_aux_cpu, expected_hyperthread_pair, _ = self._set_normal_affinity()

scheduler = benchmark_cpu_scheduler.DefaultBenchmarkScheduler()
benchmark_core = scheduler.setup_and_get_benchmark_core()
self.assertIn(benchmark_core, expected_hyperthread_pair)
set_cpu_mask = os.sched_getaffinity(0)
self.assertSequenceEqual(
set_cpu_mask,
{
expected_aux_cpu,
},
)

def test_default_scheduler_not_three_cpus(self):
old_cpu_mask = os.sched_getaffinity(0)
cpu_mask_list = list(old_cpu_mask)
os.sched_setaffinity(0, cpu_mask_list[0:2])

scheduler = benchmark_cpu_scheduler.DefaultBenchmarkScheduler()
with self.assertRaises(ValueError):
scheduler.setup_and_get_benchmark_core()

os.sched_setaffinity(0, old_cpu_mask)

def test_default_scheduler_verify(self):
self._set_normal_affinity()

scheduler = benchmark_cpu_scheduler.DefaultBenchmarkScheduler()
scheduler.setup_and_get_benchmark_core()
scheduler.verify()

def test_default_scheduler_verify_mask_changed(self):
_, _, old_cpu_mask = self._set_normal_affinity()

scheduler = benchmark_cpu_scheduler.DefaultBenchmarkScheduler()
scheduler.setup_and_get_benchmark_core()

cpu_mask_list = list(old_cpu_mask)
os.sched_setaffinity(0, cpu_mask_list[1:3])
with self.assertRaises(ValueError):
scheduler.verify()


if __name__ == '__main__':
absltest.main()
Loading