diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcf7b5..bc5e641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/cbox-init/fpmtune.go b/cmd/cbox-init/fpmtune.go index 742c3e8..7657609 100644 --- a/cmd/cbox-init/fpmtune.go +++ b/cmd/cbox-init/fpmtune.go @@ -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 diff --git a/configs/examples/php-fpm-autotune.yaml b/configs/examples/php-fpm-autotune.yaml index b6685ba..ae79d6c 100644 --- a/configs/examples/php-fpm-autotune.yaml +++ b/configs/examples/php-fpm-autotune.yaml @@ -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: diff --git a/internal/config/fpmtune_cpu_test.go b/internal/config/fpmtune_cpu_test.go new file mode 100644 index 0000000..54e6d33 --- /dev/null +++ b/internal/config/fpmtune_cpu_test.go @@ -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") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index a147494..12a8735 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 diff --git a/internal/config/validation.go b/internal/config/validation.go index 0faf5b0..0d0cfa2 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -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") }