Blog Posts

Exhausted Mac

Adaptive CPU Throttling in Perl to Prevent System Meltdowns

Written by: | Posted on: | Category:

From Hedge Fund Linuxen to Home Mac: An Adaptive CPU.pm Load Throttle

I began my career in the aerospace and defense industry in the mid 1980's. This included processing and analyzing very large datasets for land and weather satellites, as well as some airborne systems for monitoring everything from agriculture to battle fields. The constant struggles were dealing with disk space, memory capacity, and CPU cycles. When you have running skirmishes with the same issues for a decade, you get pretty good at managing your way through the chaos.

Back in 2012, I had been out of aerospace for 5+ years and working at a hedge fund where we processed thousands of jobs every single day across a fleet of Linux servers. The environment was intense: multiple teams, multiple servers per team, a couple different enterprise schedulers, and good old cron jobs for simpler, recurring tasks. The Linux servers were always getting hammered with jobs, networks were slinging data, and we were on call for a week at a time where you could guarantee a few nights of software triage were going to cut into the little amount of sleep you would normally enjoy. So, same disk, memory, and CPU problems from my aerospace days, just many more smaller versions on a network layer and at a faster pace.

During the same timeframe, I was developing a weather data processing system using NOAA's Global Forecast System (GFS) datasets on my home Mac, with a co-located Mac Mini as the target. This required downloading dozens of 500 MB files, or slicing out the portions I needed from the files, extracting their components, formatting the results for the database, selecting records from the database and formatting the results in a variety of datasets, from SQL to Excel to KML, based on the client's request. A familiar pattern of disk space, memory swapping and spiking CPU loads was part of this project, as well.

When too many jobs fire off at once, especially during overlapping windows or after a market event or vendor hiccup triggered a flood of processing, the servers would thrash. CPU load would spike to 20+, I/O would crawl, swapping would kick in, and the whole system would become sluggish or even unresponsive for minutes at a time. Newer jobs would queue up, long-running ones would starve, and the whole pipeline suffered. It was classic contention on shared resources, and no amount of “nice” levels or other tweaks fully solved it without introducing other problems.

I needed something simple, polite, and fair: a way for each Perl script to check the system load before (and during) heavy work, wait if necessary, but not in a dumb, fixed-delay way that wastes time or unfairly penalizes jobs that had already been patient. That’s when I wrote what eventually became CPU.pm, a tiny, functional Perl module that implements adaptive CPU load throttling.

The Real Problem

On a busy server, load average isn’t just a number, it’s a symptom. When load climbs above the number of cores (or a bit higher), context switching spikes, queues build up, and everything slows down nonlinearly. In our hedge fund setup:

  • Tidal (Control-M, Tivoli/TWS, etc.) would launch dependency chains that sometimes bunched up.
  • Cron would fire independent jobs at :00, :15, :30, etc., creating artificial spikes.
  • Long-running analytics scripts would compete with hundreds of quick, high priority, data-ingestion ones.
  • This is also an ongoing problem on shared hosting or VPS-like environments, and colocated systems like my Mac Mini.
  • The same issues appear when multiple scripts run concurrently in an easily controlled environment.

Fixed solutions like “sleep 60 if load > 10” either over-wait (wasting cycles) or under-wait (still thrashing). Strict cutoffs (“die if load > 12”) risked starving legitimate work. What we needed was backoff that gets progressively more tolerant the longer a job waits, giving patience a reward without blocking short jobs forever.

How It Works

The module is deliberately simple and functional, no Moo objects, no heavy dependencies, just a few main subs: cpuWait, cpuLoad, cpuCores, and cpuForks. Plus a small subset for Macs due to their use of Efficiency and Performance cores, cpuTotalCores, cpuPerformanceCores, and cpuEfficiencyCores. It’s built for Perl 5.42+ but works on older versions too.

This blog just discusses cpuWait, cpuLoad, and cpuCores. At its core, cpuWait() does this:

  • Reads the 1-minute load average (via Sys::Info, cross-platform for Linux/macOS).
  • Starts with a threshold (default: number of detected cores × 1.0, configurable for lower/higher priority jobs).
  • While current load > threshold:
    • Logs an INFO message (replaced with printf for simplicity here) with current load, threshold, and planned sleep time.
    • Sleeps for a random duration between 4 and 6 seconds (randomness prevents synchronized thundering-herd wake-ups).
    • Increases the threshold by 0.1.
    • Checks again.
  • When load ≤ threshold, logs/prints success and returns.
  • Includes a 300-second total timeout as a safety net (configurable).

The adaptive increment is the key bit: jobs that have already waited several cycles naturally get a higher tolerance, so they aren’t perpetually outbid by fresh jobs. At the same time, if load drops suddenly, a short-waiting job can jump ahead. It’s fairness through gradual leniency.

The load is cached for 10 seconds to avoid hammering the system info layer on every loop iteration.

Seeing It in Action

Here’s a real log excerpt from one of my scripts running March 21, 2026. Notice how the threshold creeps up from 8.00 to over 12 while the load fluctuates but stays high, then finally succeeds when load dips to 11.40:

2026/03/21 17:18:04 INFO> CPU.pm:81 - CPU load: 8.60 > 8.00, sleeping 4.4 seconds
2026/03/21 17:18:08 INFO> CPU.pm:81 - CPU load: 8.60 > 8.10, sleeping 5.0 seconds
2026/03/21 17:18:13 INFO> CPU.pm:81 - CPU load: 8.60 > 8.20, sleeping 4.7 seconds
2026/03/21 17:18:18 INFO> CPU.pm:81 - CPU load: 11.16 > 8.30, sleeping 4.7 seconds
2026/03/21 17:18:22 INFO> CPU.pm:81 - CPU load: 11.16 > 8.40, sleeping 6.0 seconds
...
2026/03/21 17:21:34 INFO> CPU.pm:81 - CPU load: 12.69 > 12.30, sleeping 4.7 seconds
2026/03/21 17:21:38 INFO> CPU.pm:81 - CPU load: 12.69 > 12.40, sleeping 4.8 seconds
2026/03/21 17:21:43 INFO> CPU.pm:81 - CPU load: 12.69 > 12.50, sleeping 5.5 seconds
2026/03/21 17:21:49 INFO> CPU.pm:75 - CPU load: 11.40 <= 12.60

Over ~3.5 minutes, the script waited politely while the system recovered, without hammering CPU checks or giving up too early.

How to Use It

Installation is trivial, just drop the module in your library path. Usage is even simpler:

use CustomVisuals::CPU qw(cpuWait cpuLoad cpuCores);

# Default usage: wait if load > (cores × 1.0), increment by 0.1, 4–6s random sleeps
cpuWait();

# Override for a more aggressive environment (e.g., busy server)
cpuWait(
    initial_threshold => 8.0,
    timeout           => 180, # 3 minutes max wait
    increment         => 0.05, # finer steps
);

# Just peek at values without waiting
printf "Current load: %.2f   Cores: %d\n", cpuLoad(), cpuCores();

It’s perfect for any Perl script or module that runs longer than a minute or does appreciable I/O or computation, data processing, reporting, file crunching, API polling, you name it. Call it right before your heavy lifting.

Source Code

A quick note about "logging". The production code uses a small Log::Log4perl wrapper that is replaced with printf statements below. It reduces a dependency that can be replaced with any logging system you prefer.

#-------------------------------------------------------------------------------
# Copyright (c) 2012-2026, Custom Visuals, LLC. All rights reserved.
#-------------------------------------------------------------------------------
# Mike Schienle
# Orig: 2012/10/02 21:57:46
#-------------------------------------------------------------------------------
# Purpose: Throttle programs when CPU is too high.
# History:
#   Updated 2026/03/21 09:44:05
#-------------------------------------------------------------------------------

package CustomVisuals::CPU;

# Pragmas
use Modern::Perl '2025';
use feature 'state';

# CPAN/core modules
use Exporter 'import';
use Sys::Info;
use Sys::Info::Constants qw(:device_cpu);
use Time::HiRes          qw(sleep time);

# CustomVisuals modules
use CustomVisuals::Log  qw(logGet);
use CustomVisuals::Text qw(textSecToHMS);

our @EXPORT_OK = qw(cpuWait cpuLoad cpuCores cpuTotalCores cpuPerformanceCores cpuEfficiencyCores cpuForks);

my $log = logGet();

my $info;
my $cpu;
my $wait           = 10;
my $last_load      = 0;
my $last_load_time = 0;

sub _lazy_init {
    $info //= Sys::Info->new(cache => 1, cache_timeout => $wait);
    $cpu  //= $info->device('CPU');
}

# get the recent CPU load
sub cpuLoad {
    _lazy_init();
    my $now = time();
    if ($now - $last_load_time >= $wait) {
        $last_load      = $cpu->load() // 0;
        $last_load_time = $now;
    }
    return $last_load;
}

# Returns the number of PERFORMANCE cores
sub cpuCores {
    state $cores = 0;
    return $cores if ($cores > 0);

    _lazy_init();

    # Apple Silicon or Intel Mac
    if ($^O eq 'darwin') {
        # Get performance cores via sysctl
        chomp(my $pcores = `sysctl -n hw.perflevel0.physicalcpu 2>/dev/null`);
        if ($pcores && $pcores =~ /^\d+$/) {
            $cores = $pcores;
        }
        else {
            # Fallback to total logical cores
            $cores = $cpu->count() || 4;
        }
    }
    else {
        # Linux, Windows, etc.
        $cores = $cpu->count() || 4;
    }

    # final safety net
    $cores ||= 4;
    return $cores;
}

#   Performance cores
sub cpuPerformanceCores {
    return cpuCores();
}

# Efficiency cores (Mac/Darwin only as of 2026 Q1)
sub cpuEfficiencyCores {
    state $ecores = 0;
    return $ecores if ($ecores > 0);

    if ($^O eq 'darwin') {
        chomp(my $ecores_raw = `sysctl -n hw.perflevel1.physicalcpu 2>/dev/null`);
        if ($ecores_raw && $ecores_raw =~ /^\d+$/) {
            $ecores = $ecores_raw;
        }
        else {
            $ecores = cpuTotalCores() - cpuCores();
        }
    }
    else {
        $ecores = 0;
    }
    return $ecores || 0;
}

# Total cores (for comparison/debugging)
sub cpuTotalCores {
    _lazy_init();
    return $cpu->count() || 4;
}

# Calculate a safe number of forks based on current CPU load and available cores
sub cpuForks {
    my %args = (
        min => 1,      # minimum forks to return
        max => 0,      # 0 = no artificial cap (use cpuCores())
        @_
    );

    # Get current CPU information
    my $cores     = cpuCores();           # performance cores (recommended base)
    my $load      = cpuLoad();            # current load (0–100+)
    my $available = $cores - $load;       # simple but effective estimate

    # Apply min/max constraints
    my $min_fork = $args{min} || 1;
    my $max_fork = $args{max} || $cores;

    # Calculate candidate value
    my $forks = int($available + 0.5);    # round to nearest integer

    # Enforce bounds
    if ($forks > $max_fork) {
        $forks = $max_fork;
    }
    elsif ($forks < $min_fork) {
        $forks = $min_fork;
    }

    # Extra safety: never return 0 unless explicitly allowed
    $forks = $min_fork if ($forks < $min_fork);

    # Debug logging (clean and informative)
    if ($log->is_debug) {
        $log->debug(sprintf(
            "cpuForks: cores=%d load=%.1f available=%.1f → forks=%d (min=%d max=%d)",
            $cores, $load, $available, $forks, $min_fork, $max_fork
        ));
    }

    return $forks;
}
sub cpuWait {
    my %args = @_;

    my $initial_threshold //= ($args{initial_threshold} // cpuTotalCores() * 1.0);
    my $increment         //= ($args{increment}         // 0.1);
    my $min_sleep         //= ($args{min_sleep}         // 4.0);
    my $max_sleep         //= ($args{max_sleep}         // 6.0);
    my $timeout           //= ($args{timeout}           // 300);      # seconds
    my $max_threshold     //= $args{max_threshold};                  # undef = no cap

    my $threshold   = $initial_threshold;
    my $total_sleep = 0;

    while (1) {
        my $load = cpuLoad();

        if ($load <= $threshold) {
            $log->info(sprintf "CPU load: %.2f <= %.2f", $load, $threshold);
            last;
        }

        my $sleep_time = $min_sleep + rand($max_sleep - $min_sleep);
        $log->info(
            sprintf "CPU load: %.2f > %.2f, sleeping %.1f seconds (total: %s)",
            $load, $threshold, $sleep_time, textSecToHMS(time => $total_sleep)
        );

        sleep($sleep_time);
        $total_sleep += $sleep_time;

        if ($total_sleep > $timeout) {
            $log->warn(sprintf "Timeout reached waiting for lower CPU load (total sleep: %s)", textSecToHMS(time => $total_sleep));
            last;
        }

        $threshold += $increment;
        if (defined $max_threshold && $threshold > $max_threshold) {
            $threshold = $max_threshold;
        }
    }
}

1;

__END__

=head1 NAME

CustomVisuals::CPU - Adaptive CPU load-aware waiting and core detection for Perl scripts

=head1 SYNOPSIS

    use CustomVisuals::CPU qw(
        cpuWait cpuLoad cpuCores cpuForks
        cpuTotalCores cpuPerformanceCores cpuEfficiencyCores
    );

    # Wait until CPU load is reasonable
    cpuWait();

    # More controlled wait
    cpuWait(
        initial_threshold => 6.0,
        timeout           => 120,
        increment         => 0.05,
    );

    # Core detection helpers
    my $pcores = cpuCores();           # Performance cores (recommended)
    my $ecores = cpuEfficiencyCores();
    my $total  = cpuTotalCores();

    # Dynamic fork calculation
    my $forks = cpuForks(max => cpuCores() / 2);

    printf "Performance cores: %d, Efficiency cores: %d, Total: %d\n",
           $pcores, $ecores, $total;

=head1 DESCRIPTION

This module provides utilities for monitoring CPU load and making intelligent
decisions about parallelism (forks, threads) on multi-core systems, with
special attention to Apple Silicon (performance vs efficiency cores).

It includes:

=over 4

=item * Adaptive waiting via C<cpuWait()>

=item * Current CPU load via C<cpuLoad()>

=item * Core count helpers optimized for modern Macs (M1–M4 series)

=item * Smart fork count calculation via C<cpuForks()>

=back

=head1 EXPORTED FUNCTIONS

=head2 cpuWait( %options )

Blocks and waits (with logging) until the CPU load drops to an acceptable level.

Options (all optional):

=over 4

=item initial_threshold => $num

Starting load threshold (default: C<cpuTotalCores() * 1.0>)

=item increment => $num

How much to raise the threshold after each sleep cycle (default: 0.1)

=item min_sleep => $seconds

Minimum sleep time per cycle (default: 4.0)

=item max_sleep => $seconds

Maximum sleep time per cycle (default: 6.0)

=item timeout => $seconds

Maximum total sleep time before giving up (default: 300)

=item max_threshold => $num

Optional hard cap on the threshold (default: no cap)

=back

=head2 cpuLoad()

Returns the current system load average (cached for 10 seconds by default).

    my $load = cpuLoad(); # e.g. 4.25

=head2 cpuCores()

Returns the number of **performance cores** (P-cores).
This is the recommended function for deciding forks and compression threads on Apple Silicon.
On non-Apple systems it returns total logical cores.

    my $cores = cpuCores(); # e.g. 8 on base M1, 10–12+ on newer chips

=head2 cpuPerformanceCores()

Alias for C<cpuCores()>. Provided for explicitness.

    my $pcores = cpuPerformanceCores();

=head2 cpuEfficiencyCores()

Returns the number of **efficiency cores** (E-cores) on Apple Silicon.
Returns 0 on non-Darwin systems or if detection fails.

    my $ecores = cpuEfficiencyCores();

=head2 cpuTotalCores()

Returns the total number of logical cores (performance + efficiency).
Useful for debugging and comparison.

    my $total = cpuTotalCores();

=head2 cpuForks( %args )

Calculates a safe number of forks based on current CPU load and available cores.

    my $forks = cpuForks(
        min => 1,
        max => cpuCores() / 2,   # optional cap
    );

Options:

=over 4

=item min => $num

Minimum forks to return (default: 1)

=item max => $num

Maximum forks to return (default: C<cpuCores()>)

=back

=head1 REQUIREMENTS

=over 4

=item * Sys::Info and Sys::Info::Constants

=item * Time::HiRes

=item * CustomVisuals::Log

=item * CustomVisuals::Text (for C<textSecToHMS>)

=back

=head1 SEE ALSO

L<Sys::Info>, L<Sys::Info::Device::CPU>

=head1 AUTHOR

Mike Schienle / Custom Visuals, LLC

=head1 COPYRIGHT & LICENSE

Copyright (c) 2012–2026 Custom Visuals, LLC. All rights reserved.

This module is free software; you can redistribute it and/or modify it
under the same terms as Perl itself.

=cut

Implementation Notes

I originally wrote this in 2012 with lots of singing and dancing via Moo, but eventually refactored to a pure functional style: zero object overhead, faster startup, easier testing. It lazy-initializes Sys::Info only when needed, caches load for 10 seconds, and keeps dependencies minimal (Sys::Info for portability, Time::HiRes for sub-second sleep, and my own wrapper around Log::Log4Perl). It's just plain subs.

The code is Perl 5.42 compatible and runs fine on Linux and macOS (BSD roots from my home setup), and has run on AWS EC2, as well.

Why Adaptive Matters

The real win is behavioral fairness. A job that’s waited 2 minutes has seen its threshold rise by ~12×0.1 = 1.2, so it’s more likely to proceed than a brand-new job checking at the original threshold. Yet if load suddenly drops (something else finishes), the new job can slip in. It’s emergent priority without complex queues or priorities.

When to Use It (and Alternatives)

Use this when:

  • You have multiple Perl (or mixed) jobs on the same box.
  • You can’t (or don’t want to) use container isolation, cgroups, or systemd resource limits.
  • Nice/ionice helps but isn’t enough for CPU-bound contention.

Alternatives include:

  • nice/ionice : great for priority, but doesn’t wait adaptively.
  • cgroups/systemd : modern best practice for containers or services, but overkill for ad-hoc scripts.
  • Tidal or modern orchestrators : handle dependencies, but not real-time load awareness.
  • Other CPAN modules : nothing exactly matches this adaptive + random-sleep pattern (I checked recently).

I still use it today on both work servers and my home Macs when running personal data pipelines.

Conclusion

Fifteen years later, the same simple idea still saves me headaches: check the load, wait politely, get progressively more tolerant, and log everything. In a world of Kubernetes and serverless, sometimes the smallest Perl module is the one that keeps the lights on during a cron storm.

If you run into similar contention problems, give cpuWait() a try. I’d love to hear how you solve this in your environment—or if you’d improve this approach. Maybe one day it’ll make its way to CPAN, but for now it’s happily doing its job in a handful of scripts.

Thanks for reading—happy Perling, and may your load averages stay under control.


© 2003-2021      Custom Visuals, LLC      Privacy Policy      Sitemap