From e7a4646f6da452be0f98430098a0fe41f05ee02c Mon Sep 17 00:00:00 2001 From: Frank Hunleth Date: Sun, 20 Oct 2024 16:28:20 -0400 Subject: [PATCH] Add CPU and kernel checks to performance tests Dynamic CPU frequency scaling and Linux kernel differences cause performance numbers to be quite different so include this in the report. This also provides warnings when dynamic CPU frequency governors are in use or when the CPU frequency is different between cores. --- lib/gpio/diagnostics.ex | 45 ++++++++--- lib/gpio/diagnostics/cpu.ex | 154 ++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 lib/gpio/diagnostics/cpu.ex diff --git a/lib/gpio/diagnostics.ex b/lib/gpio/diagnostics.ex index 9d7ecb5..3810d79 100644 --- a/lib/gpio/diagnostics.ex +++ b/lib/gpio/diagnostics.ex @@ -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 @@ -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?, @@ -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}}), @@ -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) @@ -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 diff --git a/lib/gpio/diagnostics/cpu.ex b/lib/gpio/diagnostics/cpu.ex new file mode 100644 index 0000000..e3030f7 --- /dev/null +++ b/lib/gpio/diagnostics/cpu.ex @@ -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