diff --git a/core/application/startup.go b/core/application/startup.go index c0ddb0f31..c50be0d5e 100644 --- a/core/application/startup.go +++ b/core/application/startup.go @@ -56,10 +56,11 @@ func New(opts ...config.AppOption) (*Application, error) { loadRuntimeSettingsFromFile(options) // WithThreads no longer eagerly resolves 0 (so the settings merge can - // tell "unset" from "env-set"); resolve the physical-core default now - // that env, CLI, and file have all had their say. + // tell "unset" from "env-set"); resolve the default (physical cores, + // capped by the container CPU limit) now that env, CLI, and file have all + // had their say. if options.Threads == 0 { - options.Threads = xsysinfo.CPUPhysicalCores() + options.Threads = xsysinfo.DefaultThreads() } trace.ConfigureBackendTracePersistence(options.DataPath) diff --git a/pkg/xsysinfo/cpu.go b/pkg/xsysinfo/cpu.go index db948a15b..b2282bbb3 100644 --- a/pkg/xsysinfo/cpu.go +++ b/pkg/xsysinfo/cpu.go @@ -3,6 +3,7 @@ package xsysinfo import ( "fmt" "maps" + "runtime" "slices" "sort" @@ -82,3 +83,24 @@ func CPUPhysicalCores() int { } return cpuid.CPU.PhysicalCores } + +// DefaultThreads returns the default number of inference threads: the number +// of physical cores, capped by the number of CPUs the Go runtime may use. +// Since Go 1.25 the default GOMAXPROCS follows the CPU affinity mask and the +// cgroup CPU limit, so a container started with --cpus=2 or a Kubernetes CPU +// limit of 2 gets 2 threads instead of one per physical core on the host. +func DefaultThreads() int { + return capThreads(CPUPhysicalCores(), runtime.GOMAXPROCS(0)) +} + +// capThreads returns physical, lowered to usable when usable is smaller and +// positive, and never less than 1. +func capThreads(physical, usable int) int { + if physical < 1 { + physical = 1 + } + if usable > 0 && usable < physical { + return usable + } + return physical +} diff --git a/pkg/xsysinfo/cpu_test.go b/pkg/xsysinfo/cpu_test.go index a34e638ee..6be46427c 100644 --- a/pkg/xsysinfo/cpu_test.go +++ b/pkg/xsysinfo/cpu_test.go @@ -2,6 +2,7 @@ package xsysinfo import ( "math" + "runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -18,3 +19,28 @@ var _ = Describe("CPU telemetry", func() { Expect(math.IsNaN(info.Load1)).To(BeFalse()) }) }) + +var _ = Describe("Default thread count", func() { + It("keeps the physical core count when the runtime may use more CPUs", func() { + Expect(capThreads(8, 20)).To(Equal(8)) + Expect(capThreads(8, 8)).To(Equal(8)) + }) + + It("lowers the count to the CPUs the runtime may use", func() { + Expect(capThreads(14, 2)).To(Equal(2)) + Expect(capThreads(14, 1)).To(Equal(1)) + }) + + It("ignores a non-positive limit and never returns less than 1", func() { + Expect(capThreads(8, 0)).To(Equal(8)) + Expect(capThreads(0, 0)).To(Equal(1)) + Expect(capThreads(0, 4)).To(Equal(1)) + }) + + It("follows GOMAXPROCS", func() { + prev := runtime.GOMAXPROCS(1) + DeferCleanup(func() { runtime.GOMAXPROCS(prev) }) + + Expect(DefaultThreads()).To(Equal(1)) + }) +})