From ddf96bcd31bad89ffa391251179ba13bb789991d Mon Sep 17 00:00:00 2001 From: Sylvain Bauza Date: Mon, 14 Nov 2022 11:36:14 +0100 Subject: [PATCH] cpu: interfaces for managing state and governor This is the first stage of the power management series. In order to be able to switch the CPU state or change the governor, we need a framework to access sysfs. As some bits can be reused, let's create a nova.filesystem helper module that will define read-write mechanisms for accessing sysfs-specific commands. Partially-Implements: blueprint libvirt-cpu-state-mgmt Change-Id: Icb913ed9be8d508de35e755a9c650ba25e45aca2 --- mypy-files.txt | 4 + nova/conf/libvirt.py | 10 ++ nova/filesystem.py | 59 +++++++++ nova/tests/unit/test_filesystem.py | 52 ++++++++ nova/tests/unit/virt/libvirt/cpu/__init__.py | 0 nova/tests/unit/virt/libvirt/cpu/test_api.py | 63 +++++++++ nova/tests/unit/virt/libvirt/cpu/test_core.py | 122 ++++++++++++++++++ nova/virt/libvirt/cpu/__init__.py | 16 +++ nova/virt/libvirt/cpu/api.py | 62 +++++++++ nova/virt/libvirt/cpu/core.py | 78 +++++++++++ 10 files changed, 466 insertions(+) create mode 100644 nova/filesystem.py create mode 100644 nova/tests/unit/test_filesystem.py create mode 100644 nova/tests/unit/virt/libvirt/cpu/__init__.py create mode 100644 nova/tests/unit/virt/libvirt/cpu/test_api.py create mode 100644 nova/tests/unit/virt/libvirt/cpu/test_core.py create mode 100644 nova/virt/libvirt/cpu/__init__.py create mode 100644 nova/virt/libvirt/cpu/api.py create mode 100644 nova/virt/libvirt/cpu/core.py diff --git a/mypy-files.txt b/mypy-files.txt index 5a3b9ab33998..391ed58d87b2 100644 --- a/mypy-files.txt +++ b/mypy-files.txt @@ -1,6 +1,7 @@ nova/compute/manager.py nova/compute/pci_placement_translator.py nova/crypto.py +nova/filesystem.py nova/limit/local.py nova/limit/placement.py nova/network/neutron.py @@ -13,6 +14,9 @@ nova/virt/driver.py nova/virt/hardware.py nova/virt/libvirt/machine_type_utils.py nova/virt/libvirt/__init__.py +nova/virt/libvirt/cpu/__init__.py +nova/virt/libvirt/cpu/api.py +nova/virt/libvirt/cpu/core.py nova/virt/libvirt/driver.py nova/virt/libvirt/event.py nova/virt/libvirt/guest.py diff --git a/nova/conf/libvirt.py b/nova/conf/libvirt.py index 16a3f630902a..632bba40fb4e 100644 --- a/nova/conf/libvirt.py +++ b/nova/conf/libvirt.py @@ -1478,6 +1478,15 @@ Related options: """), ] +libvirt_cpu_mgmt_opts = [ + cfg.StrOpt('cpu_power_governor_low', + default='powersave', + help='Governor to use in order ' + 'to reduce CPU power consumption'), + cfg.StrOpt('cpu_power_governor_high', + default='performance', + help='Governor to use in order to have best CPU performance'), +] ALL_OPTS = list(itertools.chain( libvirt_general_opts, @@ -1499,6 +1508,7 @@ ALL_OPTS = list(itertools.chain( libvirt_volume_nvmeof_opts, libvirt_pmem_opts, libvirt_vtpm_opts, + libvirt_cpu_mgmt_opts, )) diff --git a/nova/filesystem.py b/nova/filesystem.py new file mode 100644 index 000000000000..5394d2d8351c --- /dev/null +++ b/nova/filesystem.py @@ -0,0 +1,59 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Functions to address filesystem calls, particularly sysfs.""" + +import os + +from oslo_log import log as logging + +from nova import exception + +LOG = logging.getLogger(__name__) + + +SYS = '/sys' + + +# NOTE(bauzas): this method is deliberately not wrapped in a privsep entrypoint +def read_sys(path: str) -> str: + """Reads the content of a file in the sys filesystem. + + :param path: relative or absolute. If relative, will be prefixed by /sys. + :returns: contents of that file. + :raises: nova.exception.FileNotFound if we can't read that file. + """ + try: + # The path can be absolute with a /sys prefix but that's fine. + with open(os.path.join(SYS, path), mode='r') as data: + return data.read() + except (OSError, ValueError) as exc: + raise exception.FileNotFound(file_path=path) from exc + + +# NOTE(bauzas): this method is deliberately not wrapped in a privsep entrypoint +# In order to correctly use it, you need to decorate the caller with a specific +# privsep entrypoint. +def write_sys(path: str, data: str) -> None: + """Writes the content of a file in the sys filesystem with data. + + :param path: relative or absolute. If relative, will be prefixed by /sys. + :param data: the data to write. + :returns: contents of that file. + :raises: nova.exception.FileNotFound if we can't write that file. + """ + try: + # The path can be absolute with a /sys prefix but that's fine. + with open(os.path.join(SYS, path), mode='w') as fd: + fd.write(data) + except (OSError, ValueError) as exc: + raise exception.FileNotFound(file_path=path) from exc diff --git a/nova/tests/unit/test_filesystem.py b/nova/tests/unit/test_filesystem.py new file mode 100644 index 000000000000..85f16157eebe --- /dev/null +++ b/nova/tests/unit/test_filesystem.py @@ -0,0 +1,52 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +from unittest import mock + +from nova import exception +from nova import filesystem +from nova import test + + +class TestFSCommon(test.NoDBTestCase): + + def test_read_sys(self): + open_mock = mock.mock_open(read_data='bar') + with mock.patch('builtins.open', open_mock) as m_open: + self.assertEqual('bar', filesystem.read_sys('foo')) + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='r') + + def test_read_sys_error(self): + with mock.patch('builtins.open', + side_effect=OSError('error')) as m_open: + self.assertRaises(exception.FileNotFound, + filesystem.read_sys, 'foo') + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='r') + + def test_write_sys(self): + open_mock = mock.mock_open() + with mock.patch('builtins.open', open_mock) as m_open: + self.assertIsNone(filesystem.write_sys('foo', 'bar')) + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='w') + open_mock().write.assert_called_once_with('bar') + + def test_write_sys_error(self): + with mock.patch('builtins.open', + side_effect=OSError('fake_error')) as m_open: + self.assertRaises(exception.FileNotFound, + filesystem.write_sys, 'foo', 'bar') + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='w') diff --git a/nova/tests/unit/virt/libvirt/cpu/__init__.py b/nova/tests/unit/virt/libvirt/cpu/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/nova/tests/unit/virt/libvirt/cpu/test_api.py b/nova/tests/unit/virt/libvirt/cpu/test_api.py new file mode 100644 index 000000000000..d47b3690a38e --- /dev/null +++ b/nova/tests/unit/virt/libvirt/cpu/test_api.py @@ -0,0 +1,63 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from nova import test +from nova.virt.libvirt.cpu import api +from nova.virt.libvirt.cpu import core + + +class TestAPI(test.NoDBTestCase): + + def setUp(self): + super(TestAPI, self).setUp() + self.core_1 = api.Core(1) + + @mock.patch.object(core, 'get_online') + def test_online(self, mock_get_online): + mock_get_online.return_value = True + self.assertTrue(self.core_1.online) + mock_get_online.assert_called_once_with(self.core_1.ident) + + @mock.patch.object(core, 'set_online') + def test_set_online(self, mock_set_online): + self.core_1.online = True + mock_set_online.assert_called_once_with(self.core_1.ident) + + @mock.patch.object(core, 'set_offline') + def test_set_offline(self, mock_set_offline): + self.core_1.online = False + mock_set_offline.assert_called_once_with(self.core_1.ident) + + def test_hash(self): + self.assertEqual(hash(self.core_1.ident), hash(self.core_1)) + + @mock.patch.object(core, 'get_governor') + def test_governor(self, mock_get_governor): + mock_get_governor.return_value = 'fake_governor' + self.assertEqual('fake_governor', self.core_1.governor) + mock_get_governor.assert_called_once_with(self.core_1.ident) + + @mock.patch.object(core, 'set_governor') + def test_set_governor_low(self, mock_set_governor): + self.flags(cpu_power_governor_low='fake_low_gov', group='libvirt') + self.core_1.set_low_governor() + mock_set_governor.assert_called_once_with(self.core_1.ident, + 'fake_low_gov') + + @mock.patch.object(core, 'set_governor') + def test_set_governor_high(self, mock_set_governor): + self.flags(cpu_power_governor_high='fake_high_gov', group='libvirt') + self.core_1.set_high_governor() + mock_set_governor.assert_called_once_with(self.core_1.ident, + 'fake_high_gov') diff --git a/nova/tests/unit/virt/libvirt/cpu/test_core.py b/nova/tests/unit/virt/libvirt/cpu/test_core.py new file mode 100644 index 000000000000..a3cba00d3b97 --- /dev/null +++ b/nova/tests/unit/virt/libvirt/cpu/test_core.py @@ -0,0 +1,122 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from nova import exception +from nova import test +from nova.tests import fixtures +from nova.virt.libvirt.cpu import core + + +class TestCore(test.NoDBTestCase): + + @mock.patch.object(core.filesystem, 'read_sys') + @mock.patch.object(core.hardware, 'parse_cpu_spec') + def test_get_available_cores(self, mock_parse_cpu_spec, mock_read_sys): + mock_read_sys.return_value = '1-2' + mock_parse_cpu_spec.return_value = set([1, 2]) + self.assertEqual(set([1, 2]), core.get_available_cores()) + mock_read_sys.assert_called_once_with(core.AVAILABLE_PATH) + mock_parse_cpu_spec.assert_called_once_with('1-2') + + @mock.patch.object(core.filesystem, 'read_sys') + @mock.patch.object(core.hardware, 'parse_cpu_spec') + def test_get_available_cores_none( + self, mock_parse_cpu_spec, mock_read_sys): + mock_read_sys.return_value = '' + self.assertEqual(set(), core.get_available_cores()) + mock_parse_cpu_spec.assert_not_called() + + @mock.patch.object(core, 'get_available_cores') + def test_exists(self, mock_get_available_cores): + mock_get_available_cores.return_value = set([1]) + self.assertTrue(core.exists(1)) + mock_get_available_cores.assert_called_once_with() + self.assertFalse(core.exists(2)) + + @mock.patch.object( + core, 'CPU_PATH_TEMPLATE', + new_callable=mock.PropertyMock(return_value='/sys/blah%(core)s')) + @mock.patch.object(core, 'exists') + def test_gen_cpu_path(self, mock_exists, mock_cpu_path): + mock_exists.return_value = True + self.assertEqual('/sys/blah1', core.gen_cpu_path(1)) + mock_exists.assert_called_once_with(1) + + @mock.patch.object(core, 'exists') + def test_gen_cpu_path_raises(self, mock_exists): + mock_exists.return_value = False + self.assertRaises(ValueError, core.gen_cpu_path, 1) + self.assertIn('Unable to access CPU: 1', self.stdlog.logger.output) + + +class TestCoreHelpers(test.NoDBTestCase): + + def setUp(self): + super(TestCoreHelpers, self).setUp() + self.useFixture(fixtures.PrivsepFixture()) + _p1 = mock.patch.object(core, 'exists', return_value=True) + self.mock_exists = _p1.start() + self.addCleanup(_p1.stop) + + _p2 = mock.patch.object(core, 'gen_cpu_path', + side_effect=lambda x: '/fakesys/blah%s' % x) + self.mock_gen_cpu_path = _p2.start() + self.addCleanup(_p2.stop) + + @mock.patch.object(core.filesystem, 'read_sys') + def test_get_online(self, mock_read_sys): + mock_read_sys.return_value = '1' + self.assertTrue(core.get_online(1)) + mock_read_sys.assert_called_once_with('/fakesys/blah1/online') + + @mock.patch.object(core.filesystem, 'read_sys') + def test_get_online_not_exists(self, mock_read_sys): + mock_read_sys.side_effect = exception.FileNotFound(file_path='foo') + self.assertTrue(core.get_online(1)) + mock_read_sys.assert_called_once_with('/fakesys/blah1/online') + + @mock.patch.object(core.filesystem, 'write_sys') + @mock.patch.object(core, 'get_online') + def test_set_online(self, mock_get_online, mock_write_sys): + mock_get_online.return_value = True + self.assertTrue(core.set_online(1)) + mock_write_sys.assert_called_once_with('/fakesys/blah1/online', + data='1') + mock_get_online.assert_called_once_with(1) + + @mock.patch.object(core.filesystem, 'write_sys') + @mock.patch.object(core, 'get_online') + def test_set_offline(self, mock_get_online, mock_write_sys): + mock_get_online.return_value = False + self.assertTrue(core.set_offline(1)) + mock_write_sys.assert_called_once_with('/fakesys/blah1/online', + data='0') + mock_get_online.assert_called_once_with(1) + + @mock.patch.object(core.filesystem, 'read_sys') + def test_get_governor(self, mock_read_sys): + mock_read_sys.return_value = 'fake_gov' + self.assertEqual('fake_gov', core.get_governor(1)) + mock_read_sys.assert_called_once_with( + '/fakesys/blah1/cpufreq/scaling_governor') + + @mock.patch.object(core, 'get_governor') + @mock.patch.object(core.filesystem, 'write_sys') + def test_set_governor(self, mock_write_sys, mock_get_governor): + mock_get_governor.return_value = 'fake_gov' + self.assertEqual('fake_gov', + core.set_governor(1, 'fake_gov')) + mock_write_sys.assert_called_once_with( + '/fakesys/blah1/cpufreq/scaling_governor', data='fake_gov') + mock_get_governor.assert_called_once_with(1) diff --git a/nova/virt/libvirt/cpu/__init__.py b/nova/virt/libvirt/cpu/__init__.py new file mode 100644 index 000000000000..962c9469a0dc --- /dev/null +++ b/nova/virt/libvirt/cpu/__init__.py @@ -0,0 +1,16 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.virt.libvirt.cpu import api + + +Core = api.Core diff --git a/nova/virt/libvirt/cpu/api.py b/nova/virt/libvirt/cpu/api.py new file mode 100644 index 000000000000..e0b0a277d18f --- /dev/null +++ b/nova/virt/libvirt/cpu/api.py @@ -0,0 +1,62 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass + +from oslo_log import log as logging + +import nova.conf +from nova.virt.libvirt.cpu import core + +LOG = logging.getLogger(__name__) + +CONF = nova.conf.CONF + + +@dataclass +class Core: + """Class to model a CPU core as reported by sysfs. + + It may be a physical CPU core or a hardware thread on a shared CPU core + depending on if the system supports SMT. + """ + + # NOTE(sbauza): ident is a mandatory field. + # The CPU core id/number + ident: int + + @property + def online(self) -> bool: + return core.get_online(self.ident) + + @online.setter + def online(self, state: bool) -> None: + if state: + core.set_online(self.ident) + else: + core.set_offline(self.ident) + + def __hash__(self): + return hash(self.ident) + + def __eq__(self, other): + return self.ident == other.ident + + @property + def governor(self) -> str: + return core.get_governor(self.ident) + + def set_high_governor(self) -> None: + core.set_governor(self.ident, CONF.libvirt.cpu_power_governor_high) + + def set_low_governor(self) -> None: + core.set_governor(self.ident, CONF.libvirt.cpu_power_governor_low) diff --git a/nova/virt/libvirt/cpu/core.py b/nova/virt/libvirt/cpu/core.py new file mode 100644 index 000000000000..782f028fee3c --- /dev/null +++ b/nova/virt/libvirt/cpu/core.py @@ -0,0 +1,78 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import typing as ty + +from oslo_log import log as logging + +from nova import exception +from nova import filesystem +import nova.privsep +from nova.virt import hardware + +LOG = logging.getLogger(__name__) + +AVAILABLE_PATH = '/sys/devices/system/cpu/present' + +CPU_PATH_TEMPLATE = '/sys/devices/system/cpu/cpu%(core)s' + + +def get_available_cores() -> ty.Set[int]: + cores = filesystem.read_sys(AVAILABLE_PATH) + return hardware.parse_cpu_spec(cores) if cores else set() + + +def exists(core: int) -> bool: + return core in get_available_cores() + + +def gen_cpu_path(core: int) -> str: + if not exists(core): + LOG.warning('Unable to access CPU: %s', core) + raise ValueError('CPU: %(core)s does not exist', core) + return CPU_PATH_TEMPLATE % {'core': core} + + +def get_online(core: int) -> bool: + try: + online = filesystem.read_sys( + os.path.join(gen_cpu_path(core), 'online')).strip() + except exception.FileNotFound: + # The online file may not exist if we haven't written it yet. + # By default, this means that the CPU is online. + online = '1' + return online == '1' + + +@nova.privsep.sys_admin_pctxt.entrypoint +def set_online(core: int) -> bool: + filesystem.write_sys(os.path.join(gen_cpu_path(core), 'online'), data='1') + return get_online(core) + + +def set_offline(core: int) -> bool: + filesystem.write_sys(os.path.join(gen_cpu_path(core), 'online'), data='0') + return not get_online(core) + + +def get_governor(core: int) -> str: + return filesystem.read_sys( + os.path.join(gen_cpu_path(core), 'cpufreq/scaling_governor')).strip() + + +@nova.privsep.sys_admin_pctxt.entrypoint +def set_governor(core: int, governor: str) -> str: + filesystem.write_sys( + os.path.join(gen_cpu_path(core), 'cpufreq/scaling_governor'), + data=governor) + return get_governor(core)