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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`fpm_tune.cpu_ceiling` and `fpm_tune.cpu_headroom`.** The embedded
autotuner sizes pools to the memory budget; on CPU-bound workloads that
oversubscribes the CPU and costs throughput (measured: a 2-CPU container
ran 30% faster at 4 workers than at the 21 the memory budget allowed).
fpm-tune has always had the mechanism (the standalone `--cpu` flag);
the embedded runtime now exposes it: `cpu_ceiling: true` caps each pool
at its measured CPU fill times `cpu_headroom` (default 2.0). Also
settable as `CBOX_INIT_GLOBAL_FPM_TUNE_CPU_CEILING` / `_CPU_HEADROOM`.

## [3.2.0] - 2026-09-07

### Added
Expand Down
2 changes: 2 additions & 0 deletions cmd/cbox-init/fpmtune.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func startFPMTune(ctx context.Context, cfg *config.Config, log *slog.Logger) (fu
MetricsAddr: ft.MetricsAddr,
RecommendPath: ft.RecommendPath,
ReserveFraction: ft.ReserveFraction,
CPUCeiling: ft.CPUCeiling,
CPUHeadroom: ft.CPUHeadroom,
Workload: resolveFPMWorkload(ft.Workload, log),
Version: version, // reported on the loop's /history.json

Expand Down
7 changes: 7 additions & 0 deletions configs/examples/php-fpm-autotune.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ global:
# state_path = /var/lib/fpm-tune/state.json, backup_dir = its default.
# reserve_fraction: 0.2 # hold 20% of the budget back from the pools
# workload: web # default class for pools that spawn no children
# CPU-bound pools: cap each pool at its measured CPU fill instead of what
# memory allows. Memory-sizing oversubscribes CPU-bound workloads - a
# 2-CPU container measured 30% faster at 4 workers than at the 21 its
# memory budget allowed. cpu_headroom is the oversubscription factor on
# the fill count (unset = 2.0).
# cpu_ceiling: true
# cpu_headroom: 2.0

processes:
php-fpm:
Expand Down
76 changes: 76 additions & 0 deletions internal/config/fpmtune_cpu_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package config

import (
"os"
"path/filepath"
"testing"
)

func TestFPMTuneCPUCeilingFields(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "c.yaml")
if err := os.WriteFile(p, []byte(`version: "1.0"
global:
fpm_tune:
enabled: true
cpu_ceiling: true
cpu_headroom: 2.5
processes:
app:
command: ["sleep", "1"]
`), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadWithEnvExpansion(p)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
ft := cfg.Global.FPMTune
if ft == nil || !ft.CPUCeiling || ft.CPUHeadroom != 2.5 {
t.Fatalf("cpu fields not loaded: %+v", ft)
}
}

func TestFPMTuneCPUHeadroomEnvOverride(t *testing.T) {
t.Setenv("CBOX_INIT_GLOBAL_FPM_TUNE_CPU_CEILING", "true")
t.Setenv("CBOX_INIT_GLOBAL_FPM_TUNE_CPU_HEADROOM", "3.0")
dir := t.TempDir()
p := filepath.Join(dir, "c.yaml")
if err := os.WriteFile(p, []byte(`version: "1.0"
global:
fpm_tune:
enabled: true
processes:
app:
command: ["sleep", "1"]
`), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadWithEnvExpansion(p)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
ft := cfg.Global.FPMTune
if ft == nil || !ft.CPUCeiling || ft.CPUHeadroom != 3.0 {
t.Fatalf("env override not applied: %+v", ft)
}
}

func TestFPMTuneNegativeCPUHeadroomRejected(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "c.yaml")
if err := os.WriteFile(p, []byte(`version: "1.0"
global:
fpm_tune:
enabled: true
cpu_headroom: -1
processes:
app:
command: ["sleep", "1"]
`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadWithEnvExpansion(p); err == nil {
t.Fatal("negative cpu_headroom accepted")
}
}
2 changes: 2 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ type FPMTuneConfig struct {
BackupDir string `yaml:"backup_dir" json:"backup_dir"` // Rollback / self-repair directory (empty = fpm-tune's default)
MetricsAddr string `yaml:"metrics_addr" json:"metrics_addr"` // OPTIONAL separate listener for fpm-tune's own /metrics, e.g. ":9110". Since 3.2.0 the fpm_tune_* series are always on the main metrics endpoint too; this exists for standalone-tool parity
RecommendPath string `yaml:"recommend_path" json:"recommend_path"` // Advisory mode: write the plan here for copying by hand (empty disables it)
CPUCeiling bool `yaml:"cpu_ceiling" json:"cpu_ceiling"` // Cap pools at what fills the CPU (measured), not what memory allows - for CPU-bound workloads (fpm-tune's --cpu)
CPUHeadroom float64 `yaml:"cpu_headroom" json:"cpu_headroom"` // Oversubscription factor on the CPU fill count with cpu_ceiling (0 = fpm-tune's default, 2.0)
}

// FederateSourceConfig declares one local metrics endpoint whose exposition is
Expand Down
3 changes: 3 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ func (c *Config) validateGlobalFPMTuneSettings(result *ValidationResult) {
if ft.Interval < 0 {
result.AddError("global.fpm_tune.interval", "Must not be negative", "Use a value like 30s, or leave unset for the 30s default")
}
if ft.CPUHeadroom < 0 {
result.AddError("global.fpm_tune.cpu_headroom", fmt.Sprintf("Must not be negative (%v)", ft.CPUHeadroom), "Use a factor like 2.0, or leave unset for fpm-tune's default")
}
if ft.ReserveFraction < 0 || ft.ReserveFraction >= 1 {
result.AddError("global.fpm_tune.reserve_fraction", fmt.Sprintf("Out of range (%v)", ft.ReserveFraction), "Must be in [0, 1); leave unset for fpm-tune's default")
}
Expand Down
Loading