Skip to content

Commit 709b1a4

Browse files
committed
feat: persistence of dynamic tuning changes
Currently, any changes made to the tuning via the `instance_*` dbus calls are lost when tuning is stopped by the service, or when the TuneD service itself is stopped/restarted, or when the service crashes. This commit: * implements a sync of Plugin Instances and Profile Units that is currently missing. This way, dynamic instances and device assignments are persistent across stop/start dbus calls to TuneD. * calculates a hash of the current profile after loading it from disk (after processing all includes, so we have a "flat" representation) * creates snapshots of the current profile whenever instances or assigned devices change. the snapshot includes the hash of the profile as it was initially loaded. for each instance it stores the devices that are currently attached. * restores a snapshot found at startup, if the hashes match (i.e. there have been no profile switches and no changes to the profile or any of its includes on disk) snapshots are restored in case of - daemon restarts (systemctl restart/stop/start) - daemon crashes snapshots are NOT restored in case of - reboots (snapshots are stored in /var/run) - profile changes (snapshots are explicitly deleted when switching profiles, even when "switching" to the same/current profile) Signed-off-by: Adriaan Schmidt <adriaan.schmidt@siemens.com>
1 parent 122f4eb commit 709b1a4

9 files changed

Lines changed: 197 additions & 10 deletions

File tree

tests/unit/profiles/test_profile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def test_sets_options(self):
4646
"network" : { "type": "net", "devices": "*" },
4747
})
4848

49-
self.assertIs(type(profile.options), dict)
49+
self.assertIs(type(profile.options), collections.OrderedDict)
5050
self.assertEqual(profile.options["anything"], 10)
5151

5252
def test_sets_options_empty(self):

tuned/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DEFAULT_STORAGE_FILE = "/run/tuned/save.pickle"
2020
USER_PROFILES_DIR = "/etc/tuned/profiles"
2121
SYSTEM_PROFILES_DIR = "/usr/lib/tuned/profiles"
22+
PROFILE_SNAPSHOT_FILE = "/run/tuned/profile-snapshot.conf"
2223
PERSISTENT_STORAGE_DIR = "/var/lib/tuned"
2324
PLUGIN_MAIN_UNIT_NAME = "main"
2425
PLUGIN_VARIABLES_UNIT_NAME = "variables"

tuned/daemon/controller.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ def instance_acquire_devices(self, devices, instance_name, caller = None):
417417
rets = "Ignoring devices not handled by any instance '%s'." % str(devs)
418418
log.info(rets)
419419
return (False, rets)
420+
self._daemon.sync_instances()
420421
return (True, "OK")
421422

422423
@exports.export("s", "(bsa(ss))")
@@ -482,6 +483,8 @@ def instance_create(self, plugin_name, instance_name, options, caller = None):
482483
"""
483484
if caller == "":
484485
return (False, "Unauthorized")
486+
plugin_name = str(plugin_name)
487+
instance_name = str(instance_name)
485488
if not self._cmd.is_valid_name(plugin_name):
486489
return (False, "Invalid plugin_name")
487490
if not self._cmd.is_valid_name(instance_name):
@@ -529,6 +532,7 @@ def instance_create(self, plugin_name, instance_name, options, caller = None):
529532
other_instance.name, instance.name))
530533
plugin._remove_devices_nocheck(other_instance, devs_moving)
531534
plugin._add_devices_nocheck(instance, devs_moving)
535+
self._daemon.sync_instances()
532536
return (True, "OK")
533537

534538
@exports.export("s", "(bs)")
@@ -571,4 +575,5 @@ def instance_destroy(self, instance_name, caller = None):
571575
for device in devices:
572576
# _add_device() will find a suitable plugin instance
573577
plugin._add_device(device)
578+
self._daemon.sync_instances()
574579
return (True, "OK")

tuned/daemon/daemon.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ def _load_profiles(self, profile_names, manual):
123123
self._notify_profile_changed(profile_names, False, errstr)
124124
raise TunedException(errstr)
125125

126+
# restore profile snapshot (if there is one)
127+
snapshot = self._profile_loader.restore_snapshot(self._profile)
128+
if snapshot is not None:
129+
self._profile = snapshot
130+
126131
def set_profile(self, profile_names, manual):
127132
if self.is_running():
128133
errstr = "Cannot set profile while the daemon is running."
@@ -156,6 +161,40 @@ def set_all_profiles(self, active_profiles, manual, post_loaded_profile,
156161
self._save_active_profile(active_profiles, manual)
157162
self._save_post_loaded_profile(post_loaded_profile)
158163

164+
def sync_instances(self):
165+
# NOTE: currently, Controller creates the new instances, and here in Daemon
166+
# we discover what happened, and update the profile accordingly.
167+
# a potentially better approach would be to move some of the logic
168+
# from Controller to Daemon, and create/destroy the instances here,
169+
# and at the same time update the profile.
170+
171+
# remove all units that don't have an instance
172+
instance_names = [i.name for i in self._unit_manager.instances]
173+
for unit in list(self._profile.units.keys()):
174+
if unit in instance_names:
175+
continue
176+
log.debug("snapshot sync: removing unit '%s'" % unit)
177+
del self._profile.units[unit]
178+
# create units for new instances
179+
for instance in self._unit_manager.instances:
180+
if instance.name in self._profile.units:
181+
continue
182+
log.debug("snapshot sync: creating unit '%s'" % instance.name)
183+
config = {
184+
"priority": instance.priority,
185+
"type": instance._plugin.name,
186+
"enabled": instance.active,
187+
"devices": instance.devices_expression,
188+
"devices_udev_regex": instance.devices_udev_regex,
189+
"script_pre": instance.script_pre,
190+
"script_post": instance.script_post,
191+
}
192+
for k, v in instance.options.items():
193+
config[k] = v
194+
self._profile.units[instance.name] = self._profile._create_unit(instance.name, config)
195+
# create profile snapshot
196+
self._profile_loader.create_snapshot(self._profile, self._unit_manager.instances)
197+
159198
@property
160199
def profile(self):
161200
return self._profile
@@ -202,6 +241,8 @@ def _thread_code(self):
202241
self._save_active_profile(" ".join(self._active_profiles),
203242
self._manual)
204243
self._save_post_loaded_profile(self._post_loaded_profile)
244+
# trigger a profile snapshot
245+
self.sync_instances()
205246
self._unit_manager.start_tuning()
206247
self._profile_applied.set()
207248
log.info("static tuning from profile '%s' applied" % self._profile.name)
@@ -370,6 +411,7 @@ def stop(self, profile_switch = False):
370411
return False
371412
log.info("stopping tuning")
372413
if profile_switch:
414+
self._profile_loader.remove_snapshot()
373415
self._terminate_profile_switch.set()
374416
self._terminate.set()
375417
self._thread.join()

tuned/plugins/base.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,23 +164,35 @@ def _get_matching_devices(self, instance, devices):
164164
udev_devices = self._device_matcher_udev.match_list(instance.devices_udev_regex, udev_devices)
165165
return set([x.sys_name for x in udev_devices])
166166

167+
def restore_devices(self, instance, devices):
168+
if not self._devices_supported:
169+
return
170+
171+
log.debug("Restoring devices of instance %s: %s" % (instance.name, " ".join(devices)))
172+
for device in devices:
173+
if device not in self._free_devices:
174+
continue
175+
self._free_devices.remove(device)
176+
instance.assigned_devices.add(device)
177+
self._assigned_devices.add(device)
178+
167179
def assign_free_devices(self, instance):
168180
if not self._devices_supported:
169181
return
170182

171183
log.debug("assigning devices to instance %s" % instance.name)
172184
to_assign = self._get_matching_devices(instance, self._free_devices)
173-
instance.active = len(to_assign) > 0
174-
if not instance.active:
175-
log.warning("instance %s: no matching devices available" % instance.name)
176-
else:
185+
if len(to_assign) > 0:
177186
name = instance.name
178187
if instance.name != self.name:
179188
name += " (%s)" % self.name
180189
log.info("instance %s: assigning devices %s" % (name, ", ".join(to_assign)))
181190
instance.assigned_devices.update(to_assign) # cannot use |=
182191
self._assigned_devices |= to_assign
183192
self._free_devices -= to_assign
193+
instance.active = len(instance.assigned_devices) > 0
194+
if not instance.active:
195+
log.warning("instance %s: no matching devices available" % instance.name)
184196

185197
def release_devices(self, instance):
186198
if not self._devices_supported:

tuned/profiles/loader.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ def __init__(self, profile_locator, profile_factory, profile_merger, global_conf
2424
self._global_config = global_config
2525
self._variables = variables
2626

27-
def _create_profile(self, profile_name, config):
28-
return tuned.profiles.profile.Profile(profile_name, config)
29-
3027
@classmethod
3128
def safe_name(cls, profile_name):
3229
return re.match(r'^[a-zA-Z0-9_.-]+$', profile_name)
@@ -57,6 +54,7 @@ def load(self, profile_names):
5754
# FIXME hack, do all variable expansions in one place
5855
self._expand_vars_in_devices(final_profile)
5956
self._expand_vars_in_regexes(final_profile)
57+
final_profile.calculate_hash()
6058
return final_profile
6159

6260
def _expand_vars_in_devices(self, profile):
@@ -68,6 +66,47 @@ def _expand_vars_in_regexes(self, profile):
6866
profile.units[unit].cpuinfo_regex = self._variables.expand(profile.units[unit].cpuinfo_regex)
6967
profile.units[unit].uname_regex = self._variables.expand(profile.units[unit].uname_regex)
7068

69+
def create_snapshot(self, profile, instances):
70+
snapshot = profile.snapshot(instances)
71+
log.debug("Storing profile snapshot in %s:\n%s" % (consts.PROFILE_SNAPSHOT_FILE, snapshot))
72+
with open(consts.PROFILE_SNAPSHOT_FILE, "w") as f:
73+
f.write(snapshot)
74+
75+
def restore_snapshot(self, profile):
76+
if profile is None:
77+
# When tuning is stopped, we are called with profile==None -> skip
78+
return None
79+
snapshot = None
80+
if os.path.isfile(consts.PROFILE_SNAPSHOT_FILE):
81+
log.debug("Found profile snapshot '%s'" % consts.PROFILE_SNAPSHOT_FILE)
82+
try:
83+
config = self._load_config_data(consts.PROFILE_SNAPSHOT_FILE)
84+
snapshot_hash = config.get("main", {}).get("profile_base_hash", None)
85+
if snapshot_hash == profile._base_hash:
86+
snapshot = self._profile_factory.create("restore", config)
87+
snapshot.name = profile.name
88+
# the snapshot is created directly (not via the merger),
89+
# so extract its [variables] section manually
90+
if consts.PLUGIN_VARIABLES_UNIT_NAME in snapshot.units:
91+
snapshot.variables.update(snapshot.units[consts.PLUGIN_VARIABLES_UNIT_NAME].options)
92+
del snapshot.units[consts.PLUGIN_VARIABLES_UNIT_NAME]
93+
self._variables.add_from_cfg(snapshot.variables)
94+
self._expand_vars_in_devices(snapshot)
95+
self._expand_vars_in_regexes(snapshot)
96+
log.info("Restored profile snapshot: %s" % snapshot.name)
97+
else:
98+
log.debug("Snapshot hash '%s' does not match current base hash '%s'. Not restoring." % (snapshot_hash, profile._base_hash))
99+
os.remove(consts.PROFILE_SNAPSHOT_FILE)
100+
except InvalidProfileException as e:
101+
log.error("Could not process profile snapshot: %s" % e)
102+
return snapshot
103+
104+
def remove_snapshot(self):
105+
try:
106+
os.remove(consts.PROFILE_SNAPSHOT_FILE)
107+
except FileNotFoundError:
108+
pass
109+
71110
def _load_profile(self, profile_names, profiles, processed_files):
72111
for name in profile_names:
73112
filename = self._profile_locator.get_config(name, processed_files)

tuned/profiles/profile.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import tuned.profiles.unit
22
import tuned.consts as consts
33
import collections
4+
import hashlib
5+
import json
46

57
class Profile(object):
68
"""
79
Representation of a tuning profile.
810
"""
911

10-
__slots__ = ["_name", "_options", "_variables", "_units"]
12+
__slots__ = ["_name", "_options", "_variables", "_units", "_base_hash"]
1113

1214
def __init__(self, name=None, config={}):
1315
self._name = name
1416
self._variables = collections.OrderedDict()
1517
self._init_options(config)
1618
self._init_units(config)
19+
self._base_hash = config.get("main", {}).get("profile_base_hash", None)
1720

1821
def _init_options(self, config):
1922
self._options = {}
2023
if consts.PLUGIN_MAIN_UNIT_NAME in config:
21-
self._options = dict(config[consts.PLUGIN_MAIN_UNIT_NAME])
24+
self._options = collections.OrderedDict(config[consts.PLUGIN_MAIN_UNIT_NAME])
2225

2326
def _init_units(self, config):
2427
self._units = collections.OrderedDict()
@@ -30,6 +33,35 @@ def _init_units(self, config):
3033
def _create_unit(self, name, config):
3134
return tuned.profiles.unit.Unit(name, config)
3235

36+
def as_ordered_dict(self):
37+
"""generate serializable (with json.dumps()) representation for hashing"""
38+
profile_dict = collections.OrderedDict()
39+
profile_dict["main"] = self.options
40+
profile_dict["variables"] = self._variables
41+
for name, unit in self._units.items():
42+
profile_dict[name] = unit.as_ordered_dict()
43+
return profile_dict
44+
45+
def calculate_hash(self):
46+
serialized = json.dumps(self.as_ordered_dict())
47+
self._base_hash = hashlib.md5(serialized.encode(), usedforsecurity=False).hexdigest()
48+
49+
def snapshot(self, instances):
50+
"""generate config representation that will re-create the data when read as a profile"""
51+
snapshot = "[main]\n"
52+
snapshot += "active_profile=%s\n" % self.name
53+
snapshot += "profile_base_hash=%s\n" % self._base_hash
54+
snapshot += "\n[variables]\n"
55+
for key, value in self._variables.items():
56+
snapshot += "%s=%s\n" % (key, value)
57+
for unit in self.units.values():
58+
snapshot += "\n" + unit.snapshot()
59+
for instance in instances:
60+
if instance.name == unit.name:
61+
snapshot += "__devices__=%s\n" % " ".join(instance.assigned_devices | instance.processed_devices)
62+
break
63+
return snapshot
64+
3365
@property
3466
def name(self):
3567
"""

tuned/profiles/unit.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,55 @@ def __init__(self, name, config):
2727
self._script_post = config.pop("script_post", None)
2828
self._options = collections.OrderedDict(config)
2929

30+
def as_ordered_dict(self):
31+
"""generate serializable (with json.dumps()) representation for hashing"""
32+
ret = collections.OrderedDict()
33+
ret["name"] = self.name
34+
ret["priority"] = self.priority
35+
ret["type"] = self.type
36+
ret["enabled"] = self.enabled
37+
ret["replace"] = self.replace
38+
ret["drop"] = self.drop
39+
ret["devices"] = self.devices
40+
ret["devices_udev_regex"] = self.devices_udev_regex
41+
ret["cpuinfo_regex"] = self.cpuinfo_regex
42+
ret["uname_regex"] = self.uname_regex
43+
ret["script_pre"] = self.script_pre
44+
ret["script_post"] = self.script_post
45+
for k, v in self.options.items():
46+
ret[k] = v
47+
return ret
48+
49+
@staticmethod
50+
def _snapshot_value(value):
51+
"""serialize an option value into a form that round-trips through the profile loader"""
52+
# some options (e.g. the script plugin's "script") are stored as lists of
53+
# absolute paths; emit them space-joined so they are not rendered as a list repr
54+
if isinstance(value, list):
55+
return " ".join(str(v) for v in value)
56+
return str(value)
57+
58+
def snapshot(self):
59+
"""generate config representation that will re-create the data when read as a profile"""
60+
snapshot = "[%s]\n" % self.name
61+
snapshot += "priority=%s\n" % self.priority
62+
snapshot += "type=%s\n" % self.type
63+
snapshot += "enabled=%s\n" % self.enabled
64+
snapshot += "devices=%s\n" % self.devices
65+
if self.devices_udev_regex is not None:
66+
snapshot += "devices_udev_regex=%s\n" % self.devices_udev_regex
67+
if self.cpuinfo_regex is not None:
68+
snapshot += "cpuinfo_regex=%s\n" % self.cpuinfo_regex
69+
if self.uname_regex is not None:
70+
snapshot += "uname_regex=%s\n" % self.uname_regex
71+
if self.script_pre is not None:
72+
snapshot += "script_pre=%s\n" % self._snapshot_value(self.script_pre)
73+
if self.script_post is not None:
74+
snapshot += "script_post=%s\n" % self._snapshot_value(self.script_post)
75+
for k, v in self.options.items():
76+
snapshot += "%s=%s\n" % (k, self._snapshot_value(v))
77+
return snapshot
78+
3079
@property
3180
def name(self):
3281
return self._name

tuned/units/manager.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,24 @@ def create(self, instances_config):
9898
continue
9999

100100
instances = []
101+
instance_restore_devices = {}
101102
for instance_info in instance_info_list:
102103
plugin = plugins_by_name[instance_info.type]
103104
if plugin is None:
104105
continue
105106
log.debug("creating '%s' (%s)" % (instance_info.name, instance_info.type))
107+
restore = instance_info.options.pop("__devices__", None)
108+
if restore:
109+
instance_restore_devices[instance_info.name] = restore.split()
106110
new_instance = plugin.create_instance(instance_info.name, instance_info.priority, \
107111
instance_info.devices, instance_info.devices_udev_regex, \
108112
instance_info.script_pre, instance_info.script_post, instance_info.options)
109113
instances.append(new_instance)
110114
for instance in instances:
111115
instance.plugin.init_devices()
116+
if instance.name in instance_restore_devices:
117+
instance.plugin.restore_devices(instance, instance_restore_devices[instance.name])
118+
for instance in instances:
112119
instance.plugin.assign_free_devices(instance)
113120
instance.plugin.initialize_instance(instance)
114121
# At this point we should be able to start the HW events

0 commit comments

Comments
 (0)