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
This commit is contained in:
Sylvain Bauza 2022-11-14 11:36:14 +01:00
parent 7ea9aac71c
commit ddf96bcd31
10 changed files with 466 additions and 0 deletions

View File

@ -1,6 +1,7 @@
nova/compute/manager.py nova/compute/manager.py
nova/compute/pci_placement_translator.py nova/compute/pci_placement_translator.py
nova/crypto.py nova/crypto.py
nova/filesystem.py
nova/limit/local.py nova/limit/local.py
nova/limit/placement.py nova/limit/placement.py
nova/network/neutron.py nova/network/neutron.py
@ -13,6 +14,9 @@ nova/virt/driver.py
nova/virt/hardware.py nova/virt/hardware.py
nova/virt/libvirt/machine_type_utils.py nova/virt/libvirt/machine_type_utils.py
nova/virt/libvirt/__init__.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/driver.py
nova/virt/libvirt/event.py nova/virt/libvirt/event.py
nova/virt/libvirt/guest.py nova/virt/libvirt/guest.py

View File

@ -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( ALL_OPTS = list(itertools.chain(
libvirt_general_opts, libvirt_general_opts,
@ -1499,6 +1508,7 @@ ALL_OPTS = list(itertools.chain(
libvirt_volume_nvmeof_opts, libvirt_volume_nvmeof_opts,
libvirt_pmem_opts, libvirt_pmem_opts,
libvirt_vtpm_opts, libvirt_vtpm_opts,
libvirt_cpu_mgmt_opts,
)) ))

59
nova/filesystem.py Normal file
View File

@ -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

View File

@ -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')

View File

@ -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')

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)