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
43 changes: 43 additions & 0 deletions .github/workflows/perftune-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Perftune unit tests

on:
pull_request:
paths:
- scripts/perftune.py
- scripts/tests/perftune/**
- scripts/pyproject.toml

push:
branches:
- master
paths:
- scripts/perftune.py
- scripts/tests/perftune/**
- scripts/pyproject.toml

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

defaults:
run:
shell: bash
working-directory: scripts

jobs:
all:
if: github.repository_owner == 'scylladb'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install dependencies
run: |
apt -y update && apt -y install python3-pip hwloc
pip3 install -r tests/perftune/requirements.txt --break-system-packages

- name: Run tests
run: python3 -m pytest -vv
122 changes: 64 additions & 58 deletions scripts/perftune.py
Original file line number Diff line number Diff line change
Expand Up @@ -2047,77 +2047,83 @@ def dump_config(prog_args):
perftune_print(yaml.dump(prog_options, default_flow_style=False))
################################################################################

args = argp.parse_args()
def main():
global dry_run_mode

# Sanity check
args.set_write_back = parse_tri_state_arg(args.set_write_back, "--write-back-cache/write_back_cache")
args.enable_arfs = parse_tri_state_arg(args.enable_arfs, "--arfs/arfs")
args = argp.parse_args()

dry_run_mode = args.dry_run
parse_options_file(args)
# Sanity check
args.set_write_back = parse_tri_state_arg(args.set_write_back, "--write-back-cache/write_back_cache")
args.enable_arfs = parse_tri_state_arg(args.enable_arfs, "--arfs/arfs")

# if nothing needs to be configured - quit
if not args.tune:
sys.exit("ERROR: At least one tune mode MUST be given.")
dry_run_mode = args.dry_run
parse_options_file(args)

# The must be either 'mode' or an explicit 'irq_cpu_mask' given - not both
if args.mode and args.irq_cpu_mask:
sys.exit("ERROR: Provide either tune mode or IRQs CPU mask - not both.")
# if nothing needs to be configured - quit
if not args.tune:
sys.exit("ERROR: At least one tune mode MUST be given.")

# Sanity check
if args.cores_per_irq_core < PerfTunerBase.min_cores_per_irq_core():
sys.exit(f"ERROR: irq_core_auto_detection_ratio value must be greater or equal than "
f"{PerfTunerBase.min_cores_per_irq_core()}")
# The must be either 'mode' or an explicit 'irq_cpu_mask' given - not both
if args.mode and args.irq_cpu_mask:
sys.exit("ERROR: Provide either tune mode or IRQs CPU mask - not both.")

# set default values #####################
if not args.nics:
args.nics = ['eth0']
# Sanity check
if args.cores_per_irq_core < PerfTunerBase.min_cores_per_irq_core():
sys.exit(f"ERROR: irq_core_auto_detection_ratio value must be greater or equal than "
f"{PerfTunerBase.min_cores_per_irq_core()}")

if not args.cpu_mask:
args.cpu_mask = run_hwloc_calc(['all'])
##########################################
# set default values #####################
if not args.nics:
args.nics = ['eth0']

# Sanity: irq_cpu_mask should be a subset of cpu_mask
if args.irq_cpu_mask and run_hwloc_calc([args.cpu_mask]) != run_hwloc_calc([args.cpu_mask, args.irq_cpu_mask]):
sys.exit("ERROR: IRQ CPU mask({}) must be a subset of CPU mask({})".format(args.irq_cpu_mask, args.cpu_mask))
if not args.cpu_mask:
args.cpu_mask = run_hwloc_calc(['all'])
##########################################

if args.dump_options_file:
dump_config(args)
sys.exit(0)
# Sanity: irq_cpu_mask should be a subset of cpu_mask
if args.irq_cpu_mask and run_hwloc_calc([args.cpu_mask]) != run_hwloc_calc([args.cpu_mask, args.irq_cpu_mask]):
sys.exit("ERROR: IRQ CPU mask({}) must be a subset of CPU mask({})".format(args.irq_cpu_mask, args.cpu_mask))

try:
tuners = []
if args.dump_options_file:
dump_config(args)
sys.exit(0)

if TuneModes.disks.name in args.tune:
tuners.append(DiskPerfTuner(args))
try:
tuners = []

if TuneModes.net.name in args.tune:
tuners.append(NetPerfTuner(args))
if TuneModes.disks.name in args.tune:
tuners.append(DiskPerfTuner(args))

if TuneModes.system.name in args.tune:
tuners.append(SystemPerfTuner(args))
if TuneModes.net.name in args.tune:
tuners.append(NetPerfTuner(args))

if args.get_cpu_mask or args.get_cpu_mask_quiet:
# Print the compute mask from the first tuner - it's going to be the same in all of them
perftune_print(tuners[0].compute_cpu_mask)
elif args.get_irq_cpu_mask:
perftune_print(tuners[0].irqs_cpu_mask)
else:
# Tune the system
restart_irqbalance(itertools.chain.from_iterable([ tuner.irqs for tuner in tuners ]))

for tuner in tuners:
tuner.tune()
except PerfTunerBase.CPUMaskIsZeroException as e:
# Print a zero CPU set if --get-cpu-mask-quiet was requested.
if args.get_cpu_mask_quiet:
perftune_print("0x0")
else:
if TuneModes.system.name in args.tune:
tuners.append(SystemPerfTuner(args))

if args.get_cpu_mask or args.get_cpu_mask_quiet:
# Print the compute mask from the first tuner - it's going to be the same in all of them
perftune_print(tuners[0].compute_cpu_mask)
elif args.get_irq_cpu_mask:
perftune_print(tuners[0].irqs_cpu_mask)
else:
# Tune the system
restart_irqbalance(itertools.chain.from_iterable([ tuner.irqs for tuner in tuners ]))

for tuner in tuners:
tuner.tune()
except PerfTunerBase.CPUMaskIsZeroException as e:
# Print a zero CPU set if --get-cpu-mask-quiet was requested.
if args.get_cpu_mask_quiet:
perftune_print("0x0")
else:
sys.exit("ERROR: {}. Your system can't be tuned until the issue is fixed.".format(e))
except PerfTunerBase.InvalidNUMATopologyException as e:
print("ERROR: {}. Your system can't be tuned until the issue is fixed.".format(e), file=sys.stderr)
# set special exit code to handle InvalidNUMATopologyException from the caller script
sys.exit(3)
except Exception as e:
sys.exit("ERROR: {}. Your system can't be tuned until the issue is fixed.".format(e))
except PerfTunerBase.InvalidNUMATopologyException as e:
print("ERROR: {}. Your system can't be tuned until the issue is fixed.".format(e), file=sys.stderr)
# set special exit code to handle InvalidNUMATopologyException from the caller script
sys.exit(3)
except Exception as e:
sys.exit("ERROR: {}. Your system can't be tuned until the issue is fixed.".format(e))

if __name__ == '__main__':
main()

7 changes: 7 additions & 0 deletions scripts/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
[tool.black]
line-length = 100
skip-string-normalization = true

[tool.pytest.ini_options]
testpaths = ["tests/perftune"]
addopts = "--cov=perftune --cov-report=term-missing"

[tool.coverage.run]
source = ["perftune"]
99 changes: 99 additions & 0 deletions scripts/tests/perftune/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# perftune.py — unit tests

Unit tests for `seastar/scripts/perftune.py`.

## Requirements

| Package | Purpose |
|--------------|-------------------------------------------------|
| `pyudev` | udev device enumeration (used by `perftune.py`) |
| `psutil` | system memory stats (used by `perftune.py`) |
| `pyyaml` | YAML config parsing (used by `perftune.py`) |
| `setuptools` | `distutils` shim for newer Python versions |
| `pytest-cov` | coverage reporting |

## Setup

Install dependencies into the active Python environment:

```bash
pip install -r tests/perftune/requirements.txt
```

The tests are run with `python3 -m pytest` rather than the bare `pytest`
shim to ensure the same Python interpreter and installed packages are used
regardless of what `$PATH` resolves `pytest` to.

## Running the tests

All commands are run from `seastar/scripts/`.

```bash
# Run all tests with coverage (default — configured in pyproject.toml)
python3 -m pytest

# Verbose output
python3 -m pytest -v

# Filter by test name
python3 -m pytest -k net

# Stop on first failure
python3 -m pytest -x
```

Coverage is enabled automatically via `pyproject.toml`:

```toml
[tool.pytest.ini_options]
testpaths = ["tests/perftune"]
addopts = "--cov=perftune --cov-report=term-missing"
```

A passing run looks like:

```
410 passed in 0.31s

Name Stmts Miss Cover Missing
-------------------------------------------
perftune.py 1057 2 99% 442, 2128
-------------------------------------------
TOTAL 1057 2 99%
```

## Test file layout

| File | What it covers |
|-----------------------------------|----------------------------------------------------------------------------------------------|
| `test_command_helpers.py` | Module-level helpers: `perftune_print`, `run_*_command`, `fwriteln`, `restart_irqbalance`, … |
| `test_config.py` | Options-file loading (`load_config` / `dump_config`) |
| `test_cpu_topology.py` | `auto_detect_irq_mask` — all CPU-count branches and asymmetric-NUMA error |
| `test_perf_tuner_base.py` | `PerfTunerBase` init paths, properties, mask helpers, AWS host detection |
| `test_net_perf_tuner.py` | `NetPerfTuner` end-to-end (with real `__init__`) |
| `test_net_perf_tuner_instance.py` | `NetPerfTuner` internal helpers via bypassed `__init__` |
| `test_net_perf_tuner_methods.py` | All private `NetPerfTuner` methods in isolation |
| `test_disk_perf_tuner.py` | All `DiskPerfTuner` methods and `__init__` |
| `test_system_perf_tuner.py` | `SystemPerfTuner` and clocksource management |
| `test_clocksource.py` | `ClocksourceManager` in isolation |
| `test_dataclasses.py` | `perftune` dataclasses and their defaults |
| `test_pure_functions.py` | Pure utility functions with no I/O |
| `test_main.py` | `main()` entry point: argument validation, tuner creation, output modes, exception handling |

## Note on the pytest executable

`/opt/homebrew/bin/pytest` (or wherever your distro puts it) may belong to a
different Python version than `python3`. Always prefer:

```bash
python3 -m pytest
```

For full isolation a virtualenv is an option:

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r tests/perftune/requirements.txt pytest pytest-cov
python3 -m pytest
```
13 changes: 13 additions & 0 deletions scripts/tests/perftune/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import importlib
import os
import sys
import types

# Load perftune.py as a module without executing main().
# Because the script is now guarded by ``if __name__ == '__main__'``,
# a plain exec_module is safe and needs no SystemExit swallowing.
_perftune_path = os.path.join(os.path.dirname(__file__), '..', '..', 'perftune.py')
loader = importlib.machinery.SourceFileLoader('perftune', _perftune_path)
_mod = types.ModuleType(loader.name)
sys.modules['perftune'] = _mod
loader.exec_module(_mod)
6 changes: 6 additions & 0 deletions scripts/tests/perftune/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pyudev
psutil
pyyaml
setuptools
pytest-cov
pytest
Loading
Loading