From a5fae6a991105b5203e5e82d08cd39551f3ffbd6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 4 Sep 2026 21:03:29 +1200 Subject: [PATCH] Add processor count and quota Signed-off-by: Samuel Williams Assisted-By: devx/874cdf9e-3541-4d85-9ae8-b4ebb7e4db80 --- context/getting-started.md | 14 ++++ guides/getting-started/readme.md | 14 ++++ lib/process/metrics/processor.rb | 20 +++++ lib/process/metrics/processor/linux.rb | 89 ++++++++++++++++++++++ releases.md | 4 + test/process/processor.rb | 101 +++++++++++++++++++++++++ 6 files changed, 242 insertions(+) create mode 100644 lib/process/metrics/processor/linux.rb diff --git a/context/getting-started.md b/context/getting-started.md index a820021..a5ceaee 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -112,3 +112,17 @@ sample.utilization ``` Both interfaces use the same core-unit scale. A utilization of `0.5` means half of one core on average, while `2.0` means two cores were fully occupied. Utilization is therefore not limited to the range `0.0..1.0`. + +## Processor Capacity + +Use {ruby Process::Metrics::Processor.count} to get the number of processors available to the current process. On Linux, this takes CPU affinity into account. + +Use {ruby Process::Metrics::Processor.quota} to get the available processor capacity as a `Float`. On Linux, it also takes cgroup v2 CPU bandwidth limits into account. On other systems, when no finite quota is configured, or when the quota cannot be determined, it returns {ruby Process::Metrics::Processor.count} as a `Float`. + +``` ruby +Process::Metrics::Processor.count +# => 8 + +Process::Metrics::Processor.quota +# => 1.5 +``` diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index a820021..a5ceaee 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -112,3 +112,17 @@ sample.utilization ``` Both interfaces use the same core-unit scale. A utilization of `0.5` means half of one core on average, while `2.0` means two cores were fully occupied. Utilization is therefore not limited to the range `0.0..1.0`. + +## Processor Capacity + +Use {ruby Process::Metrics::Processor.count} to get the number of processors available to the current process. On Linux, this takes CPU affinity into account. + +Use {ruby Process::Metrics::Processor.quota} to get the available processor capacity as a `Float`. On Linux, it also takes cgroup v2 CPU bandwidth limits into account. On other systems, when no finite quota is configured, or when the quota cannot be determined, it returns {ruby Process::Metrics::Processor.count} as a `Float`. + +``` ruby +Process::Metrics::Processor.count +# => 8 + +Process::Metrics::Processor.quota +# => 1.5 +``` diff --git a/lib/process/metrics/processor.rb b/lib/process/metrics/processor.rb index e17bb96..0bdb7b7 100644 --- a/lib/process/metrics/processor.rb +++ b/lib/process/metrics/processor.rb @@ -3,10 +3,26 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require "etc" + module Process module Metrics # Computes interval CPU utilization from cumulative process metrics. class Processor + # The number of processors available to the current process. + # On Linux, this takes the process CPU affinity into account. + # @returns [Integer] The number of available processors. + def self.count + Etc.nprocessors + end + + # The processor capacity available to the current process. + # On Linux, this takes cgroup v2 CPU bandwidth limits into account. Otherwise, it returns {count} as a `Float`. + # @returns [Float] The available processor capacity in core units. + def self.quota + return self.count.to_f + end + # An immutable measurement of process CPU usage over an interval. # @attribute [Integer] The process ID. # @attribute [Float] The elapsed monotonic time in seconds. @@ -79,3 +95,7 @@ def finite?(value) end end end + +if RUBY_PLATFORM.include?("linux") + require_relative "processor/linux" +end diff --git a/lib/process/metrics/processor/linux.rb b/lib/process/metrics/processor/linux.rb new file mode 100644 index 0000000..dccb22a --- /dev/null +++ b/lib/process/metrics/processor/linux.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Process + module Metrics + class Processor + # @private + module Linux + DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup" + DEFAULT_CGROUP_PATH = "/proc/self/cgroup" + + class << self + # Read the effective processor quota for the current cgroup. + # @parameter cgroup_root [String] The root of the cgroup v2 filesystem. + # @parameter cgroup_path [String] The process cgroup membership file. + # @returns [Float | Nil] The smallest finite quota in the cgroup hierarchy, if available. + def quota(cgroup_root: DEFAULT_CGROUP_ROOT, cgroup_path: DEFAULT_CGROUP_PATH) + unless relative_path = current_path(cgroup_path) + return nil + end + + root = File.expand_path(cgroup_root) + directory = File.expand_path(relative_path.delete_prefix("/"), root) + return nil unless directory == root || directory.start_with?("#{root}/") + + quota = nil + + loop do + if current = read_quota(directory) + quota = [quota, current].compact.min + end + + break if directory == root + directory = File.dirname(directory) + end + + return quota + rescue Errno::EACCES, Errno::EINVAL, Errno::ENOENT, Errno::ENOTDIR, ArgumentError + return nil + end + + private + + def current_path(path) + File.foreach(path) do |line| + hierarchy, controllers, relative_path = line.strip.split(":", 3) + + if hierarchy == "0" && controllers == "" && relative_path + return relative_path + end + end + + return nil + end + + def read_quota(directory) + maximum, period = File.read(File.join(directory, "cpu.max")).split + return nil if maximum == "max" || !maximum || !period + + maximum = Integer(maximum) + period = Integer(period) + return nil unless maximum.positive? && period.positive? + + return maximum.to_f / period + rescue Errno::EACCES, Errno::EINVAL, Errno::ENOENT, Errno::ENOTDIR, ArgumentError + return nil + end + end + end + + # The processor capacity available to the current process. + # This takes cgroup v2 CPU bandwidth limits into account, and otherwise returns {count} as a `Float`. + # @returns [Float] The available processor capacity in core units. + def self.quota + count = self.count.to_f + + if quota = Linux.quota + if quota < count + return quota + end + end + + return count + end + end + end +end diff --git a/releases.md b/releases.md index 76dd916..199c68b 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add `Process::Metrics::Processor.count` and `.quota` for affinity-aware processor counts and cgroup v2-aware processor capacity. + ## v0.13.0 - Normalize processor utilization to core units, where `1.0` represents one fully occupied CPU core, and restore Linux reporting. diff --git a/test/process/processor.rb b/test/process/processor.rb index c6fa1a4..1188b83 100644 --- a/test/process/processor.rb +++ b/test/process/processor.rb @@ -3,9 +3,32 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require "fileutils" +require "tmpdir" require "process/metrics" describe Process::Metrics::Processor do + with ".count" do + it "returns the number of available processors" do + count = Process::Metrics::Processor.count + + expect(count).to be_a(Integer) + expect(count).to be == Etc.nprocessors + expect(count).to be > 0 + end + end + + with ".quota" do + it "returns the available processor capacity" do + quota = Process::Metrics::Processor.quota + + expect(quota).to be_a(Float) + expect(quota).to be > 0.0 + expect(quota).to be <= Process::Metrics::Processor.count.to_f + end + + end + def process(process_id, processor_time, start_time = 1000.0) Process::Metrics::General.new(process_id, nil, nil, nil, nil, nil, processor_time, nil, start_time, nil, nil) end @@ -114,3 +137,81 @@ def processor(captures, timestamps) end end end + +if defined?(Process::Metrics::Processor::Linux) + describe Process::Metrics::Processor::Linux do + def write_cgroup_path(directory, path) + cgroup_path = File.join(directory, "cgroup") + File.write(cgroup_path, "0::#{path}\n") + return cgroup_path + end + + def write_cpu_max(directory, value) + FileUtils.mkdir_p(directory) + File.write(File.join(directory, "cpu.max"), value) + end + + with ".quota" do + it "reads a fractional quota" do + Dir.mktmpdir do |directory| + write_cpu_max(directory, "150000 100000\n") + cgroup_path = write_cgroup_path(directory, "/") + + quota = Process::Metrics::Processor::Linux.quota(cgroup_root: directory, cgroup_path: cgroup_path) + expect(quota).to be == 1.5 + end + end + + it "uses the smallest quota inherited from the cgroup hierarchy" do + Dir.mktmpdir do |directory| + write_cpu_max(directory, "max 100000\n") + write_cpu_max(File.join(directory, "parent"), "100000 100000\n") + write_cpu_max(File.join(directory, "parent", "workload"), "150000 100000\n") + cgroup_path = write_cgroup_path(directory, "/parent/workload") + + quota = Process::Metrics::Processor::Linux.quota(cgroup_root: directory, cgroup_path: cgroup_path) + expect(quota).to be == 1.0 + end + end + + it "returns nil for an unlimited hierarchy" do + Dir.mktmpdir do |directory| + write_cpu_max(directory, "max 100000\n") + cgroup_path = write_cgroup_path(directory, "/") + + quota = Process::Metrics::Processor::Linux.quota(cgroup_root: directory, cgroup_path: cgroup_path) + expect(quota).to be == nil + end + end + + it "returns nil for invalid quota values" do + Dir.mktmpdir do |directory| + write_cpu_max(directory, "invalid\n") + cgroup_path = write_cgroup_path(directory, "/") + + quota = Process::Metrics::Processor::Linux.quota(cgroup_root: directory, cgroup_path: cgroup_path) + expect(quota).to be == nil + end + end + + it "returns nil when the process is not in a cgroup v2 hierarchy" do + Dir.mktmpdir do |directory| + cgroup_path = File.join(directory, "cgroup") + File.write(cgroup_path, "2:cpu:/workload\n") + + quota = Process::Metrics::Processor::Linux.quota(cgroup_root: directory, cgroup_path: cgroup_path) + expect(quota).to be == nil + end + end + + it "returns nil when cgroup membership cannot be read" do + Dir.mktmpdir do |directory| + cgroup_path = File.join(directory, "missing") + + quota = Process::Metrics::Processor::Linux.quota(cgroup_root: directory, cgroup_path: cgroup_path) + expect(quota).to be == nil + end + end + end + end +end