Skip to content

Commit 02bec1d

Browse files
macOS support, README.md, plot fixes and -p behaviour
- CPULoadGenerator: macOS compat (_available_cores, _set_cpu_affinity), single-core plot in main process, ignore -p when multiple cores - README: add README.md (from README.rst), macOS run instructions, plot notes - ClosedLoopActuator: fix RealTimePlot init (target %), no cpu_affinity for macOS - Plot: Agg in child processes, real-time window in main process, fix PNG filename (no double *100) Made-with: Cursor
1 parent 2f3226d commit 02bec1d

4 files changed

Lines changed: 144 additions & 11 deletions

File tree

CPULoadGenerator.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@
1717
from utils.Monitor import MonitorThread
1818

1919

20+
def _available_cores():
21+
"""Return list of available CPU core indices. Works on Linux, macOS, Windows."""
22+
try:
23+
p = psutil.Process(os.getpid())
24+
return list(p.cpu_affinity())
25+
except (AttributeError, psutil.AccessDenied):
26+
return list(range(psutil.cpu_count()))
27+
28+
29+
def _set_cpu_affinity(core_id):
30+
"""Pin process to a CPU core if the platform supports it (Linux, Windows). No-op on macOS."""
31+
try:
32+
process = psutil.Process(os.getpid())
33+
process.cpu_affinity([core_id])
34+
except (AttributeError, psutil.AccessDenied):
35+
pass
36+
37+
2038
class ShutdownException(Exception):
2139
pass
2240

@@ -38,9 +56,8 @@ def load_core(target_core, target_load,
3856
if sampling_interval <= 0:
3957
raise Exception('Negative sampling interval!')
4058

41-
# lock this process to the target core
42-
process = psutil.Process(os.getpid())
43-
process.cpu_affinity([target_core])
59+
# Lock this process to the target core (no-op on macOS; load still applied)
60+
_set_cpu_affinity(target_core)
4461

4562
monitor = MonitorThread(target_core, sampling_interval)
4663
control = ControllerThread(sampling_interval)
@@ -88,8 +105,7 @@ def __validate_cpu_load(ctx, param, value):
88105

89106

90107
def __validate_cpu_core(ctx, param, value):
91-
p = psutil.Process(os.getpid())
92-
available_cores = p.cpu_affinity()
108+
available_cores = _available_cores()
93109

94110
for v in value:
95111
if v not in available_cores:
@@ -113,7 +129,7 @@ def __validate_sampling_interval(ctx, param, value):
113129
help='CPU core to artificially load. '
114130
'Can be specified multiple times to load multiple cores, '
115131
'default is all cores.',
116-
default=psutil.Process(os.getpid()).cpu_affinity(),
132+
default=_available_cores(),
117133
show_default=True)
118134
@click.option('--cpu_load', '-l',
119135
type=float, multiple=True,
@@ -148,6 +164,16 @@ def __main(core, cpu_load, duration, plot, sampling_interval):
148164
# filter out repeated core indexes
149165
core = list(set(core))
150166

167+
# Plot only makes sense for a single core; ignore -p when running on multiple/all cores
168+
if plot and len(core) > 1:
169+
print('Plot disabled when using multiple cores (use -c 0 for single-core plot).')
170+
plot = False
171+
172+
# Single core + plot: run in main process so the live plot window works (e.g. on macOS)
173+
if len(core) == 1 and plot:
174+
load_core(core[0], next(cpu_load), duration, plot, sampling_interval)
175+
return
176+
151177
# disable signal handlers before spawning processes
152178
signal.signal(signal.SIGINT, signal.SIG_IGN)
153179
signal.signal(signal.SIGTERM, signal.SIG_IGN)

README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# CPU Load Generator
2+
3+
[![CI](https://img.shields.io/github/actions/workflow/status/GaetanoCarlucci/CPULoadGenerator/ci.yml)](https://github.com/GaetanoCarlucci/CPULoadGenerator/actions)
4+
[![License: MIT](https://img.shields.io/:license-mit-green.svg?style=flat)](http://opensource.org/licenses/MIT)
5+
[![Made with Python](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org/)
6+
7+
This script generates a fixed CPU load for a finite or indefinite time period, on one or more CPU cores. A **PI controller** is used for this purpose.
8+
9+
You provide the desired CPU load and the CPU core(s) to load. The controller and the CPU monitor run in separate threads.
10+
11+
## Theoretical insight
12+
13+
- **Project homepage:** [https://gaetanocarlucci.github.io/CPULoadGenerator/](https://gaetanocarlucci.github.io/CPULoadGenerator/) — more details on the tool.
14+
- **Blog:** [Theoretical explanation of this tool](https://gaetanocarlucci.altervista.org/cpu-load-generator-project/).
15+
16+
## Dependencies
17+
18+
- **Python 3.6+**
19+
- **Libraries:** `matplotlib`, `psutil`, `click`
20+
21+
### Setup with virtualenv
22+
23+
```bash
24+
cd CPULoadGenerator/
25+
sudo apt install virtualenv
26+
virtualenv --python=python3.6 ./venv
27+
. venv/bin/activate
28+
(venv) $ pip install -r requirements.txt
29+
```
30+
31+
### System-wide install (Debian/Ubuntu)
32+
33+
```bash
34+
sudo apt install python3-matplotlib python3-psutil python3-click
35+
```
36+
37+
### Run on macOS
38+
39+
1. Create and activate a virtual environment (recommended):
40+
41+
```bash
42+
cd CPULoadGenerator/
43+
python3 -m venv venv
44+
source venv/bin/activate
45+
pip install -r requirements.txt
46+
```
47+
48+
2. Run the script (e.g. 20% load on core 0 for 10 seconds):
49+
50+
```bash
51+
python CPULoadGenerator.py -l 0.2 -d 10 -c 0
52+
```
53+
54+
Or make it executable and run: `chmod +x CPULoadGenerator.py` then `./CPULoadGenerator.py -l 0.2 -d 10 -c 0`.
55+
56+
**Note for macOS:** CPU affinity (pinning a process to a specific core) is not supported by the OS, so the process is not bound to the chosen core. The script still generates the requested load; it may just be distributed across cores by the scheduler. All other behaviour (PI control, duration) is unchanged.
57+
58+
With `--plot`: use **exactly one core** to see the **live plot window** on macOS (e.g. `-c 0 -l 0.5 -d 20 --plot`). With multiple cores, the plot is saved to a PNG file at the end. If the window does not appear or stays blank, try `export MPLBACKEND=TkAgg` before running, or install a GUI backend: `pip install pyobjc-framework-Cocoa` (for the default macOS backend).
59+
60+
## Examples
61+
62+
1. **20% load on core 0 for 20 seconds:**
63+
64+
```bash
65+
./CPULoadGenerator.py -l 0.2 -d 20 -c 0
66+
```
67+
68+
2. **65% load on cores 0, 1 and 5, until interrupted (Ctrl-C):**
69+
70+
```bash
71+
./CPULoadGenerator.py -l 0.65 -c 0 -c 1 -c 5
72+
```
73+
74+
3. **55% load on core 0, 12% on core 3, until interrupted:**
75+
76+
```bash
77+
./CPULoadGenerator.py -c 0 -c 3 -l 0.55 -l 0.12
78+
```
79+
80+
4. **12% load on cores 0 and 1 for 20.5 seconds, then plot the load:**
81+
82+
```bash
83+
./CPULoadGenerator.py -l 0.12 -c 0 -c 1 -d 20.5 --plot
84+
```
85+
86+
5. **Example graph of CPU load (50% target on core 0):**
87+
88+
![Example - 50% load on CPU core 0](https://raw.githubusercontent.com/molguin92/CPULoadGenerator/python3_port_stable/50%25-Target-Load.png)

utils/ClosedLoopActuator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ def __init__(self, controller, monitor, duration, target):
7474
super(PlottingClosedLoopActuator,
7575
self).__init__(controller, monitor, duration, target)
7676

77-
cpu_core = psutil.Process(os.getpid()).cpu_affinity()[0]
78-
self.graph = RealTimePlot(self.duration, cpu_core, target)
77+
# target = core index; target load % from controller (no cpu_affinity: not supported on macOS)
78+
self.graph = RealTimePlot(self.duration, target, self.controller.get_cpu_target() * 100)
7979

8080
def send_plot_sample(self):
8181
if (time.time() - self.last_plot_time) > 0.2:

utils/Plot.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
# Authors: Gaetano Carlucci
22
# Giuseppe Cofano
33

4+
import multiprocessing
45
import time
6+
7+
# In child processes (e.g. multiprocessing on macOS) GUI backends often fail:
8+
# window opens as icon but plot does not show. Use non-interactive backend and
9+
# save to file at the end.
10+
if multiprocessing.current_process().name != 'MainProcess':
11+
import matplotlib
12+
matplotlib.use('Agg')
13+
514
import matplotlib.pyplot as plt
615

716

@@ -13,8 +22,10 @@ class RealTimePlot:
1322
def __init__(self, duration, cpu, target):
1423
plt.figure()
1524
plt.axis([0, duration, 0, 100])
16-
plt.ion()
17-
plt.show()
25+
# Only show window in main process; in workers (macOS/multiprocessing) use Agg, no window
26+
if multiprocessing.current_process().name == 'MainProcess':
27+
plt.ion()
28+
plt.show()
1829
plt.xlabel('Time(sec)')
1930
plt.ylabel('%')
2031
self.y_load = [0]
@@ -45,11 +56,19 @@ def plot_sample(self, sample, target):
4556
self.line_load.set_xdata(self.xdata)
4657
self.line_load.set_ydata(self.y_load)
4758
plt.draw()
59+
# Run GUI event loop so the window updates in real time (main process only)
60+
if multiprocessing.current_process().name == 'MainProcess':
61+
plt.pause(0.01)
4862

4963
def close(self):
5064
if self.cpuT != 0:
51-
name = f'{int(self.cpuT * 100.0):d}%-Target-Load' \
65+
# self.cpuT is already in 0-100 (passed as get_cpu_target() * 100)
66+
name = f'{int(round(self.cpuT))}%-Target-Load' \
5267
f'-CPU{self.cpu_idx}.png'
5368
# TODO: add option to change format
5469
plt.savefig(name, dpi=100)
70+
# When using Agg (no window), inform user where the plot was saved
71+
if multiprocessing.current_process().name != 'MainProcess':
72+
import os
73+
print(f'Plot saved to: {os.path.abspath(name)}')
5574
plt.close()

0 commit comments

Comments
 (0)