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