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 CPU and kernel checks to performance tests #213

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions lib/gpio/diagnostics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule Circuits.GPIO.Diagnostics do
to work on some devices.
"""
alias Circuits.GPIO
alias Circuits.GPIO.Diagnostics.CPU

@doc """
Reminder for how to use report/2
Expand Down Expand Up @@ -55,14 +56,23 @@ defmodule Circuits.GPIO.Diagnostics do
Input ids: #{inspect(in_identifiers)}
Backend: #{inspect(Circuits.GPIO.backend_info()[:name])}

== Functionality ==

""",
Enum.map(results, &pass_text/1),
"""

write/2: #{round(speed_results.write_cps)} calls/s
read/1: #{round(speed_results.read_cps)} calls/s
write_one/3: #{round(speed_results.write_one_cps)} calls/s
read_one/2: #{round(speed_results.read_one_cps)} calls/s
== Performance ==

Kernel: #{speed_results.uname}
CPU count: #{speed_results.cpu_count}
CPU speed: #{:erlang.float_to_binary(speed_results.speed_mhz, decimals: 1)} MHz
Warnings?: #{speed_results.warnings?}

write/2: #{cps_to_us(speed_results.write_cps)} µs/call
read/1: #{cps_to_us(speed_results.read_cps)} µs/call
write_one/3: #{cps_to_us(speed_results.write_one_cps)} µs/call
read_one/2: #{cps_to_us(speed_results.read_one_cps)} µs/call

""",
if(check_connections?,
Expand All @@ -81,6 +91,9 @@ defmodule Circuits.GPIO.Diagnostics do
passed
end

# Truncate sub-nanosecond for readability
defp cps_to_us(cps), do: :erlang.float_to_binary(1_000_000 / cps, decimals: 3)

defp pass_text({name, :ok}), do: [name, ": ", :green, "PASSED", :reset, "\n"]

defp pass_text({name, {:error, reason}}),
Expand Down Expand Up @@ -108,17 +121,29 @@ defmodule Circuits.GPIO.Diagnostics do
@doc """
Run GPIO API performance tests

Disclaimer: There should be a better way than relying on the Circuits.GPIO
write performance on nearly every device. Write performance shouldn't be
terrible, though.
If you get warnings about the CPU speed, run
`Circuits.GPIO.Diagnostics.CPU.force_slowest/0` or
`Circuits.GPIO.Diagnostics.CPU.set_speed/1` to make sure that the CPU doesn't
change speeds during the test.

Disclaimer: This tests Circuits.GPIO write performance. Write performance
should be reasonably good. However, if it's not acceptable, please
investigate other options. Usually there's some hardware-assisted way to
accomplish high speed GPIO tasks (PWM controllers, for example).
"""
@spec speed_test(GPIO.gpio_spec()) :: %{
write_cps: float(),
read_cps: float(),
write_one_cps: float(),
read_one_cps: float()
read_one_cps: float(),
uname: String.t(),
cpu_count: non_neg_integer(),
speed_mhz: number(),
warnings?: boolean()
}
def speed_test(gpio_spec) do
cpu_info = CPU.check_benchmark_suitability()

times = 1000
one_times = ceil(times / 100)

Expand All @@ -133,12 +158,14 @@ defmodule Circuits.GPIO.Diagnostics do
write_one_cps = time_fun2(one_times, &write_one2/1, gpio_spec)
read_one_cps = time_fun2(one_times, &read_one2/1, gpio_spec)

%{
results = %{
write_cps: write_cps,
read_cps: read_cps,
write_one_cps: write_one_cps,
read_one_cps: read_one_cps
}

Map.merge(results, cpu_info)
end

defp time_fun2(times, fun, arg) do
Expand Down
154 changes: 154 additions & 0 deletions lib/gpio/diagnostics/cpu.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# SPDX-FileCopyrightText: 2024 Frank Hunleth
#
# SPDX-License-Identifier: Apache-2.0

defmodule Circuits.GPIO.Diagnostics.CPU do
@moduledoc """
CPU
"""

@doc """
Force the CPU to its slowest setting

This requires the Linux kernel to have the powersave CPU scaling governor available.
"""
@spec force_slowest() :: :ok
def force_slowest() do
cpu_list()
|> Enum.each(&set_governor(&1, "powersave"))
end

@doc """
Force the CPU to its fastest setting

This requires the Linux kernel to have the performance CPU scaling governor available.
"""
@spec force_fastest() :: :ok
def force_fastest() do
cpu_list()
|> Enum.each(&set_governor(&1, "performance"))
end

@doc """
Set the CPU to the specified frequency

This requires the Linux kernel to have the userspace CPU scaling governor available.
Not all frequencies are supported. The closest will be picked.
"""
@spec set_frequency(number()) :: :ok
def set_frequency(frequency_mhz) do
cpus = cpu_list()
Enum.each(cpus, &set_governor(&1, "userspace"))
Enum.each(cpus, &set_frequency(&1, frequency_mhz))
end

defp set_governor(cpu, governor) do
File.write!("/sys/bus/cpu/devices/#{cpu}/cpufreq/scaling_governor", governor)
end

defp set_frequency(cpu, frequency_mhz) do
frequency_khz = round(frequency_mhz * 1000)
File.write!("/sys/bus/cpu/devices/#{cpu}/cpufreq/scaling_setspeed", to_string(frequency_khz))
end

@doc """
Return the string names for all CPUs

CPUs are named `"cpu0"`, `"cpu1"`, etc.
"""
@spec cpu_list() :: [String.t()]
def cpu_list() do
case File.ls("/sys/bus/cpu/devices") do
{:ok, list} -> Enum.sort(list)
_ -> []
end
end

@doc """
Check benchmark suitability and return CPU information
"""
@spec check_benchmark_suitability() :: %{
uname: String.t(),
cpu_count: non_neg_integer(),
speed_mhz: number(),
warnings?: boolean()
}
def check_benchmark_suitability() do
cpus = cpu_list()

scheduler_warnings? = Enum.all?(cpus, &check_cpu_scheduler/1)
{frequency_warnings?, mhz} = mean_cpu_frequency(cpus)

%{
uname: uname(),
cpu_count: length(cpus),
speed_mhz: mhz,
warnings?: scheduler_warnings? or frequency_warnings?
}
end

defp uname() do
case File.read("/proc/version") do
{:ok, s} -> String.trim(s)
{:error, _} -> "Unknown"
end
end

defp check_cpu_scheduler(cpu) do
case File.read("/sys/bus/cpu/devices/#{cpu}/cpufreq/scaling_governor") do
{:error, _} ->
io_warn("Could not check CPU frequency scaling for #{cpu}")
true

{:ok, text} ->
governor = String.trim(text)

if governor in ["powersave", "performance", "userspace"] do
false
else
io_warn(
"CPU #{cpu} is using a dynamic CPU frequency governor. Performance results may vary."
)

true
end
end
end

defp cpu_frequency_mhz(cpu) do
# Report the actual CPU frequency just in case something is throttling the governor (e.g., thermal throttling).
# The governor's target frequency is in the "scaling_cur_freq" file.
case File.read("/sys/bus/cpu/devices/#{cpu}/cpufreq/cpuinfo_cur_freq") do
{:ok, string} -> string |> String.trim() |> String.to_integer() |> Kernel./(1000)
{:error, _} -> 0.0
end
end

defp mean_cpu_frequency(cpu_list) do
speeds = cpu_list |> Enum.map(&cpu_frequency_mhz/1)

case speeds do
[] ->
{true, 0.0}

[speed] ->
{false, speed}

[first | _rest] ->
mean = Enum.sum(speeds) / length(speeds)

if abs(mean - first) < 0.001 do
{false, mean}
else
io_warn("CPU speeds don't all match: #{inspect(speeds)}")
{true, mean}
end
end
end

defp io_warn(text) do
[:yellow, "WARNING: ", text, :reset]
|> IO.ANSI.format()
|> IO.puts()
end
end