Photo by Thomas Kelley on Unsplash
Display CPU Energy Consumption on Apple Silicon with Python
Various methods to access the operating system and their drawbacks
This article discusses how to measure CPU power usage on Apple Silicon using the powermetrics tool and Python. It explores various methods to call system commands from Python, such as using subprocess and regular expressions, and provides different options for handling sudo passwords. The article also highlights the potential security risks involved in calling system commands and working with passwords. Finally, a full example of a function to get CPU power usage is provided.
Background and Usecase
When I need to run predictable tests, I need to have the same starting point. You can't compare the test results if you don't have the same environmental parameter. In the past, I used the CPU temperature, which on Apple silicon is not available in the same manner. Apple used to reduce the performance of a CPU if it got too hot and speed up the fan. This is different on Apple M1 / M2. The replacement is the power consumption of the CPU and GPU. I think these are the even better metrics to make comparable runs.
Powermetrics: Using the SMC (System Management Controller) on Apple Silicon
On MacOS you find a tool called powermetrics. It is located in /usr/bin
and therefore just callable. You need to call it with elevated rights. By default, it evaluates all power metrics repeatedly. For our test log entry, we only want to get the power consumption one single time. With the flag -n, you can define how many times the metrics will be evaluated. To limit the output, the grep command can filter on the metrics in milliwatt, which is represented by the string mW
or you are going to use "Power:"
sudo powermetrics -n 1 | grep "mW"
#Output will look something like so
#CPU Power: 291 mW
#GPU Power: 1 mW
#ANE Power: 0 mW
#Combined Power (CPU + GPU + ANE): 292 mW
Call it from Python
In Python, there are several ways to call the operating system. Using the subprocess
module is the advised way and replace older methods. There are two options. subprocess.run
, which is a blocking call and subprocess.popen
, which gives you full control over the process and runs it in the background. Diving into popen
would go too far and distract from what we want to achieve today:
Let's save this for another discussion.
Option shell=True (not so smart)
Running our "powermetrics" example without any error handling would look like this:
import subprocess
# Define the command to run with sudo
sudo_command = f"sudo powermetrics -n 1 | grep 'CPU Power:' "
# Run the sudo command and capture the output
output = subprocess.check_output(sudo_command, shell=True, universal_newlines=True)
# Print the output
print(output)
#Output will look something like this
#CPU Power: 98 mW
As we using pipe and grep, we would need to use the option shell=True, which comes with security risk as all the input needs to be properly escaped and validated to avoid shell injection. So better try without the shell=True option
.
Option without shell=Ture
The better option is to not use shell=True
. We can send just an array of commands and options. Using a pipe and a second command would not work in this option. We don't need the pipe if we use regular expressions (aka regex) to filter out the result we want. To limit the input for the regex, we are adding the option powermetrics --samplers "cpu_power"
, which only shows CPU power-related metrics.
The regex "CPU Power:\s+(\d+)\s+mW"
evaluates the value.
Explaining the regex: It looks for a text which starts with the term "CPU Power:"
, followed by one or more whitespaces characters, followed by a numeric value which is captured as a group, followed by one or more whitespace characters and ending with the string "mW"
.
\s+
=> one or more whitespace character(\d+)
=> numeric value captured as a group
The command match.group(1)
captures the first group. If we had two groups, we would use match.group(2)
.
import subprocess
import re
# Run the powermetrics command with sudo and capture its output
cmd = ["sudo", "powermetrics", "-n", "1", "--samplers", "cpu_power"]
output = subprocess.check_output(cmd, universal_newlines=True)
# Use regular expressions to extract the CPU power usage
pattern = r'CPU Power:\s+(\d+)\s+mW'
match = re.search(pattern, output)
cpu_power = match.group(1)
#Print the value
print(f"Current CPU Power Usage: {cpu_power} mW")
#Expected output something like
#Current CPU Power Usage: 76 mW
Sudo passoword challenges
As powermetrics require elevated rights, we need to use sudo which is protected by the superuser password. Again we have multiple options.
Option 1: Asking the user
If I don't do anything special, the sudo command will ask me. I also can handle this out of Python. If I am using the shell=True option, I can use sudo -S
, which takes stdin as input for our password. Using getpass
suppress the output to the screen.
import subprocess
import getpass
#Promt the user for a password.
password = getpass.getpass(prompt="Enter your sudo password: ")
# Define the command to run with sudo
sudo_command = f"echo '{password}' | sudo -S powermetrics -n 1 | grep 'CPU Power' "
# Run the sudo command and capture the output
output = subprocess.check_output(sudo_command, shell=True, universal_newlines=True)
# Print the output
print(output)
This is also not a very secure way. If something goes wrong, it will be part of the Error object and your superuser password is exposed in a log file.
Option 2: Enviroment Variables
You might think using environment variables is a smart way. You can set it by using the export command in your shell. export password="password123"
. From then on, the environment variable is available in the system and can be read out by Python.
import os
#read the enviroment variable called password
env_password = os.environ.get('password')
#the variable now have the value "password123"
The environment variable is only kept in memory you believe. This is half true. As you used the export command, the command and your password will end up by default in the history file of your shell after you close the shell. Typically the file is stored on your homedrive and named like your shell. Look out for a file called .bash_history
or .zsh_history
in your home drive. Not what you want either.
Option 3: File Input
You might think using a file is even worse. You are half right again. You don't want to have it on your disk and your backup, but what if your disk is just a virtual RAM disk? That way you store the file in RAM. Writing it with an editor will only leave the editor command in your history, but not the content with your password. How to easily setup a RAM drive on macOS, I described in the blog post: https://hashnode.com/post/clltk0hnb000308l316pyc9g3
In Python then, you read the file into a variable.
#open file in read mode
file_handler=open("/path_to_your_ram_drive/password.txt","r")
#read the file into a string and strip the new line character
password=file_handler.read().strip()
#close file
file_handler.close()
Of course, you need to remove the RAM drive after your work and protect the file so that only you have access to the file. Also not the best way. Better if we wouldn't need a password at all.
Option 4: Without Password
You can instruct sudo to not ask for a password. Turning it off sounds like a big security issue, but sudo allows us to steer this per user and application. This means you can limit it to your user running the test pipeline and restrict it to powermetrics only. You can edit the sudoer file with the following command.
sudo visudo
Scroll down to the section which typically looks like this:
# root and users in group wheel can run anything on any machine as any user
root ALL = (ALL) ALL
Now add a new line like this:
test_user ALL = (ALL) NOPASSWD: /usr/bin/powermetrics
Let me explain what it does.
test_user
: Replace this with the user you want to exempt entering the password.ALL
: This part specifies the hosts on which the user "test_user" is allowed to run the specified command. "ALL" means that the user can run the command from any host. You might want to narrow it further down to the host where the test pipeline is running.= (ALL)
: "ALL" means that the "test_user" can run the command as any user, effectively allowing them to execute the command with full administrative privileges.NOPASSWD:
: This indicates that "test_user" can run the specified command without entering a password when using thesudo
command./usr/bin/powermetrics
: This is the full path to the command that "test_user" is allowed to run with sudo privileges.
In summary: The "test_user" can execute only the /usr/bin/powermetrics
command without a password and with full administrative privileges. Everything else remains the same.
Full Example
Discussing the options above, gave an insight into why I have chosen the final code here. Don't forget: To make it run without a password, you need to modify the sudoer file as described above.
It is lean and easy to understand. See the full code example as a function with error handling below. The print command you would replace with your logging command.
import subprocess
import re
def get_cpu_power_usage():
try:
# Run the powermetrics command with sudo and capture its output
cmd = ["sudo", "powermetrics", "-n", "1", "--samplers", "cpu_power"]
output = subprocess.check_output(cmd, universal_newlines=True)
# Use regular expressions to extract the CPU power usage
pattern = r'CPU Power:\s+(\d+)\s+mW'
match = re.search(pattern, output)
if match:
cpu_power_usage = int(match.group(1))
return cpu_power_usage
else:
print("Failed to extract CPU power usage from powermetrics output.")
return None
except subprocess.CalledProcessError as e:
print(f"Error running powermetrics with sudo: {e}")
return None
if __name__ == "__main__":
cpu_power = get_cpu_power_usage()
if cpu_power is not None:
print(f"Current CPU Power Usage: {cpu_power} mW")
Conclusion
Calling system commands from Python can introduce security risks. Especially if the command requires elevated rights.
Working around passwords can make things even worse.
No password can be more secure than a password.
Calling system commands from Python can give you extra power.