Skip to content
Merged
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
14 changes: 14 additions & 0 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
14 changes: 14 additions & 0 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
20 changes: 20 additions & 0 deletions lib/process/metrics/processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -79,3 +95,7 @@ def finite?(value)
end
end
end

if RUBY_PLATFORM.include?("linux")
require_relative "processor/linux"
end
89 changes: 89 additions & 0 deletions lib/process/metrics/processor/linux.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
101 changes: 101 additions & 0 deletions test/process/processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading