diff --git a/vertical-pod-autoscaler/pkg/recommender/logic/recommender.go b/vertical-pod-autoscaler/pkg/recommender/logic/recommender.go index 4afa93d69e7f..5b7b127a571a 100644 --- a/vertical-pod-autoscaler/pkg/recommender/logic/recommender.go +++ b/vertical-pod-autoscaler/pkg/recommender/logic/recommender.go @@ -34,6 +34,7 @@ var ( targetMemoryPercentile = flag.Float64("target-memory-percentile", 0.9, "Memory usage percentile that will be used as a base for memory target recommendation. Doesn't affect memory lower bound nor memory upper bound.") lowerBoundMemoryPercentile = flag.Float64("recommendation-lower-bound-memory-percentile", 0.5, `Memory usage percentile that will be used for the lower bound on memory recommendation.`) upperBoundMemoryPercentile = flag.Float64("recommendation-upper-bound-memory-percentile", 0.95, `Memory usage percentile that will be used for the upper bound on memory recommendation.`) + humanizeMemory = flag.Bool("humanize-memory", false, "Convert memory values in recommendations to the highest appropriate SI unit with up to 2 decimal places for better readability.") ) // PodResourceRecommender computes resource recommendation for a Vpa object. @@ -164,10 +165,10 @@ func MapToListOfRecommendedContainerResources(resources RecommendedPodResources) for _, name := range containerNames { containerResources = append(containerResources, vpa_types.RecommendedContainerResources{ ContainerName: name, - Target: model.ResourcesAsResourceList(resources[name].Target), - LowerBound: model.ResourcesAsResourceList(resources[name].LowerBound), - UpperBound: model.ResourcesAsResourceList(resources[name].UpperBound), - UncappedTarget: model.ResourcesAsResourceList(resources[name].Target), + Target: model.ResourcesAsResourceList(resources[name].Target, *humanizeMemory), + LowerBound: model.ResourcesAsResourceList(resources[name].LowerBound, *humanizeMemory), + UpperBound: model.ResourcesAsResourceList(resources[name].UpperBound, *humanizeMemory), + UncappedTarget: model.ResourcesAsResourceList(resources[name].Target, *humanizeMemory), }) } recommendation := &vpa_types.RecommendedPodResources{ diff --git a/vertical-pod-autoscaler/pkg/recommender/model/types.go b/vertical-pod-autoscaler/pkg/recommender/model/types.go index 64f5a85dee6e..b81d3cd54f80 100644 --- a/vertical-pod-autoscaler/pkg/recommender/model/types.go +++ b/vertical-pod-autoscaler/pkg/recommender/model/types.go @@ -17,6 +17,8 @@ limitations under the License. package model import ( + "fmt" + apiv1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/klog/v2" @@ -79,7 +81,7 @@ func ScaleResource(amount ResourceAmount, factor float64) ResourceAmount { } // ResourcesAsResourceList converts internal Resources representation to ResourcesList. -func ResourcesAsResourceList(resources Resources) apiv1.ResourceList { +func ResourcesAsResourceList(resources Resources, humanizeMemory bool) apiv1.ResourceList { result := make(apiv1.ResourceList) for key, resourceAmount := range resources { var newKey apiv1.ResourceName @@ -91,6 +93,12 @@ func ResourcesAsResourceList(resources Resources) apiv1.ResourceList { case ResourceMemory: newKey = apiv1.ResourceMemory quantity = QuantityFromMemoryAmount(resourceAmount) + if humanizeMemory && !quantity.IsZero() { + rawValues := quantity.Value() + humanizedValue := HumanizeMemoryQuantity(rawValues) + klog.V(4).InfoS("Converting raw value to humanized value", "rawValue", rawValues, "humanizedValue", humanizedValue) + quantity = resource.MustParse(humanizedValue) + } default: klog.ErrorS(nil, "Cannot translate resource name", "resourceName", key) continue @@ -141,6 +149,29 @@ func resourceAmountFromFloat(amount float64) ResourceAmount { } } +// HumanizeMemoryQuantity converts raw bytes to human-readable string using binary units (KiB, MiB, GiB, TiB) with no decimal places. +func HumanizeMemoryQuantity(bytes int64) string { + const ( + KiB = 1024 + MiB = 1024 * KiB + GiB = 1024 * MiB + TiB = 1024 * GiB + ) + + switch { + case bytes >= TiB: + return fmt.Sprintf("%.2fTi", float64(bytes)/float64(TiB)) + case bytes >= GiB: + return fmt.Sprintf("%.2fGi", float64(bytes)/float64(GiB)) + case bytes >= MiB: + return fmt.Sprintf("%.2fMi", float64(bytes)/float64(MiB)) + case bytes >= KiB: + return fmt.Sprintf("%.2fKi", float64(bytes)/float64(KiB)) + default: + return fmt.Sprintf("%d", bytes) + } +} + // PodID contains information needed to identify a Pod within a cluster. type PodID struct { // Namespaces where the Pod is defined. diff --git a/vertical-pod-autoscaler/pkg/recommender/model/types_test.go b/vertical-pod-autoscaler/pkg/recommender/model/types_test.go new file mode 100644 index 000000000000..dec31185f6ae --- /dev/null +++ b/vertical-pod-autoscaler/pkg/recommender/model/types_test.go @@ -0,0 +1,196 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package model + +import ( + "testing" + + apiv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +type ResourcesAsResourceListTestCase struct { + name string + resources Resources + humanize bool + resourceList apiv1.ResourceList +} + +func TestResourcesAsResourceList(t *testing.T) { + testCases := []ResourcesAsResourceListTestCase{ + { + name: "basic resources without humanize", + resources: Resources{ + ResourceCPU: 1000, + ResourceMemory: 1000, + }, + humanize: false, + resourceList: apiv1.ResourceList{ + apiv1.ResourceCPU: *resource.NewMilliQuantity(1000, resource.DecimalSI), + apiv1.ResourceMemory: *resource.NewQuantity(1000, resource.DecimalSI), + }, + }, + { + name: "basic resources with humanize", + resources: Resources{ + ResourceCPU: 1000, + ResourceMemory: 262144000, // 250Mi + }, + humanize: true, + resourceList: apiv1.ResourceList{ + apiv1.ResourceCPU: *resource.NewMilliQuantity(1000, resource.DecimalSI), + apiv1.ResourceMemory: resource.MustParse("250.00Mi"), + }, + }, + { + name: "large memory value with humanize", + resources: Resources{ + ResourceCPU: 1000, + ResourceMemory: 839500000, // 800.61Mi + }, + humanize: true, + resourceList: apiv1.ResourceList{ + apiv1.ResourceCPU: *resource.NewMilliQuantity(1000, resource.DecimalSI), + apiv1.ResourceMemory: resource.MustParse("800.61Mi"), + }, + }, + { + name: "zero values without humanize", + resources: Resources{ + ResourceCPU: 0, + ResourceMemory: 0, + }, + humanize: false, + resourceList: apiv1.ResourceList{ + apiv1.ResourceCPU: *resource.NewMilliQuantity(0, resource.DecimalSI), + apiv1.ResourceMemory: *resource.NewQuantity(0, resource.DecimalSI), + }, + }, + { + name: "large memory value without humanize", + resources: Resources{ + ResourceCPU: 1000, + ResourceMemory: 839500000, + }, + humanize: false, + resourceList: apiv1.ResourceList{ + apiv1.ResourceCPU: *resource.NewMilliQuantity(1000, resource.DecimalSI), + apiv1.ResourceMemory: *resource.NewQuantity(839500000, resource.DecimalSI), + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ResourcesAsResourceList(tc.resources, tc.humanize) + if !result[apiv1.ResourceCPU].Equal(tc.resourceList[apiv1.ResourceCPU]) { + t.Errorf("expected %v, got %v", tc.resourceList[apiv1.ResourceCPU], result[apiv1.ResourceCPU]) + } + if !result[apiv1.ResourceMemory].Equal(tc.resourceList[apiv1.ResourceMemory]) { + t.Errorf("expected %v, got %v", tc.resourceList[apiv1.ResourceMemory], result[apiv1.ResourceMemory]) + } + }) + } +} + +type HumanizeMemoryQuantityTestCase struct { + name string + value int64 + wanted string +} + +func TestHumanizeMemoryQuantity(t *testing.T) { + testCases := []HumanizeMemoryQuantityTestCase{ + { + name: "1.00Ki", + value: 1024, + wanted: "1.00Ki", + }, + { + name: "1.00Mi", + value: 1024 * 1024, + wanted: "1.00Mi", + }, + { + name: "1.00Gi", + value: 1024 * 1024 * 1024, + wanted: "1.00Gi", + }, + { + name: "1.00Ti", + value: 1024 * 1024 * 1024 * 1024, + wanted: "1.00Ti", + }, + { + name: "256.00Mi", + value: 256 * 1024 * 1024, + wanted: "256.00Mi", + }, + { + name: "1.50Gi", + value: 1.5 * 1024 * 1024 * 1024, + wanted: "1.50Gi", + }, + { + name: "1Mi in bytes", + value: 1050000, + wanted: "1.00Mi", + }, + { + name: "1.5Ki in bytes", + value: 1537, + wanted: "1.50Ki", + }, + { + name: "4.65Gi", + value: 4992073454, + wanted: "4.65Gi", + }, + { + name: "6.05Gi", + value: 6499152537, + wanted: "6.05Gi", + }, + { + name: "15.23Gi", + value: 16357476492, + wanted: "15.23Gi", + }, + { + name: "3.75Gi", + value: 4022251530, + wanted: "3.75Gi", + }, + { + name: "12.65Gi", + value: 13580968030, + wanted: "12.65Gi", + }, + { + name: "14.46Gi", + value: 15530468536, + wanted: "14.46Gi", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(*testing.T) { + result := HumanizeMemoryQuantity(tc.value) + if result != tc.wanted { + t.Errorf("expected %v, got %v", tc.wanted, result) + } + }) + } +}