Skip to content
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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Truncate sub-nanosecond for readability" is misleading. The function converts calls per second to microseconds per call and formats with 3 decimal places, which means it shows values to the nearest 0.001 microseconds (1 nanosecond). A more accurate comment would be "Convert calls per second to microseconds per call with 3 decimal places for readability".

Suggested change
# Truncate sub-nanosecond for readability
# Convert calls per second to microseconds per call with 3 decimal places for readability

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation references Circuits.GPIO.Diagnostics.CPU.set_speed/1 but the actual function is named set_frequency/1. Update the reference to match the actual function name.

Suggested change
`Circuits.GPIO.Diagnostics.CPU.set_speed/1` to make sure that the CPU doesn't
`Circuits.GPIO.Diagnostics.CPU.set_frequency/1` to make sure that the CPU doesn't

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module documentation is incomplete. It should describe the purpose and functionality of this module, such as "Utilities for managing CPU frequency scaling and checking CPU configuration for benchmarking purposes."

Suggested change
CPU
Utilities for inspecting and controlling CPU frequency scaling for diagnostics
and benchmarking purposes.
This module provides helpers to:
* List available CPUs using the Linux `/sys/bus/cpu/devices` interface.
* Force all CPUs to their slowest or fastest frequency using CPU scaling
governors (e.g., `"powersave"` and `"performance"`).
* Set all CPUs to a specific target frequency (in MHz) when the `userspace`
scaling governor is available.
These functions are intended for use on Linux systems where CPU frequency
scaling is exposed via sysfs. They are useful for making benchmark runs and
GPIO diagnostics more repeatable by putting the CPU in a known performance
state.

Copilot uses AI. Check for mistakes.
"""

@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)
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic here is incorrect. Enum.all? will only return true if ALL CPUs have warnings. Since check_cpu_scheduler/1 returns true when a warning is detected, this should use Enum.any? instead to detect if ANY CPU has a warning condition.

Suggested change
scheduler_warnings? = Enum.all?(cpus, &check_cpu_scheduler/1)
scheduler_warnings? = Enum.any?(cpus, &check_cpu_scheduler/1)

Copilot uses AI. Check for mistakes.
{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
Comment on lines +137 to +145
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for detecting mismatched CPU speeds is flawed. It only compares the mean to the first CPU speed, which can miss cases where speeds vary but the mean happens to match the first element. For example, if CPU speeds are [1500, 1000, 2000], the mean is 1500 which matches the first element, so no warning would be issued even though speeds don't match. Consider checking if all speeds are equal by verifying that the maximum and minimum speeds differ by less than the tolerance, or by checking if all speeds equal the first speed.

Copilot uses AI. Check for mistakes.
end
end

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