Skip to content

Commit

Permalink
our price ordering no longer provides the guarantee that made this wo…
Browse files Browse the repository at this point in the history
…rk (#1555)

* our price ordering no longer provides the guarantee that made this work

* fix inference calculation and memory cost

* re-enable test for #1425 

* add additional instance type selection tests
  • Loading branch information
tzneal authored Mar 22, 2022
1 parent d526a49 commit ca7081c
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 29 deletions.
18 changes: 14 additions & 4 deletions pkg/cloudprovider/aws/instancetype.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ func (i *InstanceType) Resources() v1.ResourceList {

func (i *InstanceType) Price() float64 {
const (
GPUCostWeight = 5
CPUCostWeight = 1
MemoryMBCostWeight = 1024
GPUCostWeight = 5
InferenceCostWeight = 5
CPUCostWeight = 1
MemoryMBCostWeight = 1 / 1024.0
)

gpuCount := 0.0
Expand All @@ -88,9 +89,18 @@ func (i *InstanceType) Price() float64 {
}
}

infCount := 0.0
if i.InferenceAcceleratorInfo != nil {
for _, acc := range i.InferenceAcceleratorInfo.Accelerators {
if acc.Count != nil {
infCount += float64(*acc.Count)
}
}
}

return CPUCostWeight*float64(*i.VCpuInfo.DefaultVCpus) +
MemoryMBCostWeight*float64(*i.MemoryInfo.SizeInMiB) +
GPUCostWeight*gpuCount
GPUCostWeight*gpuCount + InferenceCostWeight*infCount
}
func (i *InstanceType) cpu() resource.Quantity {
return *resources.Quantity(fmt.Sprint(*i.VCpuInfo.DefaultVCpus))
Expand Down
8 changes: 7 additions & 1 deletion pkg/cloudprovider/fake/instancetype.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ func NewInstanceType(options InstanceTypeOptions) *InstanceType {
Offerings: options.Offerings,
Architecture: options.Architecture,
OperatingSystems: options.OperatingSystems,
Resources: options.Resources},
Resources: options.Resources,
Price: options.Price},
}
}

Expand Down Expand Up @@ -90,13 +91,18 @@ type InstanceTypeOptions struct {
Architecture string
OperatingSystems sets.String
Resources v1.ResourceList
Price float64
}

type InstanceType struct {
options InstanceTypeOptions
}

func (i *InstanceType) Price() float64 {
if i.options.Price != 0 {
return i.options.Price
}

price := 0.0
for k, v := range i.Resources() {
switch k {
Expand Down
29 changes: 7 additions & 22 deletions pkg/controllers/provisioning/binpacking/packer.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,34 +170,19 @@ func (p *Packer) packWithLargestPod(unpackedPods []*v1.Pod, packables []*Packabl
return &Packing{Pods: [][]*v1.Pod{bestPackedPods}, InstanceTypeOptions: bestInstances}, remainingPods
}

for i, packable := range packables {
for _, packable := range packables {
// check how many pods we can fit with the available capacity
if result := packable.Pack(unpackedPods); len(result.packed) == maxPodsPacked {
// Add all packable nodes that have more resources than this one
// Trim the bestInstances so that provisioning APIs in cloud providers are not overwhelmed by the number of instance type options
// For example, the AWS EC2 Fleet API only allows the request to be 145kb which equates to about 130 instance type options.
for j := i; j < len(packables) && j-i < MaxInstanceTypes; j++ {
// packable nodes are sorted lexicographically according to the order of [CPU, memory]
// It may result in cases where an instance type may have larger index value when it has more CPU but fewer memory
// Need to exclude instance type with smaller memory and fewer pods
lv := packables[i]
rv := packables[j]

lvMem := lv.Resources()[v1.ResourceMemory]
rvMem := rv.Resources()[v1.ResourceMemory]

lvPods := lv.Resources()[v1.ResourcePods]
rvPods := rv.Resources()[v1.ResourcePods]

if lvMem.Cmp(rvMem) <= 0 && lvPods.Cmp(rvPods) <= 0 {
bestInstances = append(bestInstances, packables[j])
}
}
bestInstances = append(bestInstances, packable)
bestPackedPods = result.packed
remainingPods = result.unpacked
break
// reached the max that we care about
if len(bestInstances) == MaxInstanceTypes {
break
}
}
}

return &Packing{Pods: [][]*v1.Pod{bestPackedPods}, InstanceTypeOptions: bestInstances, NodeQuantity: 1}, remainingPods
}

Expand Down
196 changes: 194 additions & 2 deletions pkg/controllers/provisioning/scheduling/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ package scheduling_test

import (
"context"
"fmt"
"github.com/aws/karpenter/pkg/utils/resources"
"math"
"math/rand"
"strings"
"testing"
"time"

"github.com/aws/karpenter/pkg/cloudprovider"

"github.com/Pallinder/go-randomdata"
"github.com/aws/karpenter/pkg/apis/provisioning/v1alpha5"
"github.com/aws/karpenter/pkg/cloudprovider/fake"
Expand Down Expand Up @@ -862,7 +868,6 @@ var _ = Describe("Topology", func() {
ExpectSkew(ctx, env.Client, "default", &topology[0]).To(ConsistOf(4))
})
It("balance multiple deployments with hostname topology spread", func() {
Skip("enable after scheduler doesn't fail when scheduling disparate workloads")
// Issue #1425
spreadPod := func(appName string) test.PodOptions {
return test.PodOptions{
Expand Down Expand Up @@ -893,8 +898,10 @@ var _ = Describe("Topology", func() {
}
nodes := v1.NodeList{}
Expect(env.Client.List(ctx, &nodes)).To(Succeed())
// TODO(todd): re-enable this check when topology is fully fixed, we currently create four nodes when
// only two are required
// this wasn't part of #1425, but ensures that we launch the minimum number of nodes
Expect(nodes.Items).To(HaveLen(2))
//Expect(nodes.Items).To(HaveLen(2))
})
})

Expand Down Expand Up @@ -1978,8 +1985,193 @@ var _ = Describe("Binpacking", func() {
}
Expect(nodeNames).To(HaveLen(5))
})
It("should select for valid instance types, regardless of price", func() {
// capacity sizes and prices don't correlate here, regardless we should filter and see that all three instance types
// are valid before preferring the cheapest one 'large'
cloudProv.InstanceTypes = []cloudprovider.InstanceType{
fake.NewInstanceType(fake.InstanceTypeOptions{
Name: "medium",
Price: 3.00,
Resources: v1.ResourceList{
v1.ResourceCPU: resource.MustParse("2"),
v1.ResourceMemory: resource.MustParse("2Gi"),
},
}),
fake.NewInstanceType(fake.InstanceTypeOptions{
Name: "small",
Price: 2.00,
Resources: v1.ResourceList{
v1.ResourceCPU: resource.MustParse("1"),
v1.ResourceMemory: resource.MustParse("1Gi"),
},
}),
fake.NewInstanceType(fake.InstanceTypeOptions{
Name: "large",
Price: 1.00,
Resources: v1.ResourceList{
v1.ResourceCPU: resource.MustParse("4"),
v1.ResourceMemory: resource.MustParse("4Gi"),
},
}),
}
pod := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner, test.UnschedulablePod(
test.PodOptions{ResourceRequirements: v1.ResourceRequirements{
Limits: map[v1.ResourceName]resource.Quantity{
v1.ResourceCPU: resource.MustParse("1m"),
v1.ResourceMemory: resource.MustParse("1Mi"),
},
}},
))
node := ExpectScheduled(ctx, env.Client, pod[0])
// large is the cheapest, so we should pick it, but the other two types are also valid options
Expect(node.Labels[v1.LabelInstanceTypeStable]).To(Equal("large"))
// all three options should be passed to the cloud provider
possibleInstanceType := sets.NewString()
for _, it := range cloudProv.CreateCalls[0].InstanceTypes {
possibleInstanceType.Insert(it.Name())
}
Expect(possibleInstanceType).To(Equal(sets.NewString("small", "medium", "large")))
})
})

var _ = Describe("Instance Type Selection", func() {
var minPrice float64
var instanceTypePrices map[string]float64
nodePrice := func(n *v1.Node) float64 {
return instanceTypePrices[n.Labels[v1.LabelInstanceTypeStable]]
}

BeforeEach(func() {
// open up the provisioner to any instance types
provisioner.Spec.Requirements.Requirements = []v1.NodeSelectorRequirement{
{
Key: v1.LabelArchStable,
Operator: v1.NodeSelectorOpIn,
Values: []string{v1alpha5.ArchitectureArm64, v1alpha5.ArchitectureAmd64},
},
}
cloudProv.CreateCalls = nil
cloudProv.InstanceTypes = nil
minPrice = math.MaxFloat64
// create 100+ instance types with varying CPU/memory/architecture
instanceTypePrices = map[string]float64{}
for _, cpu := range []int{1, 2, 4, 8, 16, 32, 64} {
for mem := range []int{1, 2, 4, 8, 16, 32, 64, 128} {
for _, arch := range []string{v1alpha5.ArchitectureAmd64, v1alpha5.ArchitectureArm64} {
it := fake.NewInstanceType(fake.InstanceTypeOptions{
Name: fmt.Sprintf("%d-cpu-%d-mem-%s", cpu, mem, arch),
Architecture: arch,
OperatingSystems: sets.NewString("linux", "windows", "darwin"),
Resources: v1.ResourceList{
v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%d", cpu)),
v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dGi", mem)),
},
})
instanceTypePrices[it.Name()] = it.Price()
if it.Price() < minPrice {
minPrice = it.Price()
}
cloudProv.InstanceTypes = append(cloudProv.InstanceTypes, it)
}
}
}
// add some randomness to instance type ordering to ensure we sort everywhere we need to
rand.Shuffle(len(cloudProv.InstanceTypes), func(i, j int) {
cloudProv.InstanceTypes[i], cloudProv.InstanceTypes[j] = cloudProv.InstanceTypes[j], cloudProv.InstanceTypes[i]
})
})

// This set of tests ensure that we schedule on the cheapest valid instance type while also ensuring that all of the
// instance types passed to the cloud provider are also valid per provisioner and node selector requirements. In some
// ways they repeat some other tests, but the testing regarding checking against all possible instance types
// passed to the cloud provider is unique.
It("should schedule on one of the cheapest instances", func() {
pod := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner, test.UnschedulablePod())
node := ExpectScheduled(ctx, env.Client, pod[0])
Expect(nodePrice(node)).To(Equal(minPrice))
})
It("should schedule on one of the cheapest instances (pod arch = amd64)", func() {
pod := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner, test.UnschedulablePod(
test.PodOptions{NodeRequirements: []v1.NodeSelectorRequirement{{
Key: v1.LabelArchStable,
Operator: v1.NodeSelectorOpIn,
Values: []string{v1alpha5.ArchitectureAmd64},
}}}))
node := ExpectScheduled(ctx, env.Client, pod[0])
Expect(nodePrice(node)).To(Equal(minPrice))
// ensure that the entire list of instance types match the label
ExpectInstancesWithLabel(cloudProv.CreateCalls[0].InstanceTypes, v1.LabelArchStable, v1alpha5.ArchitectureAmd64)
})
It("should schedule on one of the cheapest instances (pod arch = arm64)", func() {
pod := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner, test.UnschedulablePod(
test.PodOptions{NodeRequirements: []v1.NodeSelectorRequirement{{
Key: v1.LabelArchStable,
Operator: v1.NodeSelectorOpIn,
Values: []string{v1alpha5.ArchitectureArm64},
}}}))
node := ExpectScheduled(ctx, env.Client, pod[0])
Expect(nodePrice(node)).To(Equal(minPrice))
ExpectInstancesWithLabel(cloudProv.CreateCalls[0].InstanceTypes, v1.LabelArchStable, v1alpha5.ArchitectureArm64)
})
It("should schedule on one of the cheapest instances (prov arch = amd64)", func() {
provisioner.Spec.Requirements.Requirements = []v1.NodeSelectorRequirement{
{
Key: v1.LabelArchStable,
Operator: v1.NodeSelectorOpIn,
Values: []string{v1alpha5.ArchitectureAmd64},
},
}
pod := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner, test.UnschedulablePod())
node := ExpectScheduled(ctx, env.Client, pod[0])
Expect(nodePrice(node)).To(Equal(minPrice))
ExpectInstancesWithLabel(cloudProv.CreateCalls[0].InstanceTypes, v1.LabelArchStable, v1alpha5.ArchitectureAmd64)
})
It("should schedule on one of the cheapest instances (prov arch = arm64)", func() {
provisioner.Spec.Requirements.Requirements = []v1.NodeSelectorRequirement{
{
Key: v1.LabelArchStable,
Operator: v1.NodeSelectorOpIn,
Values: []string{v1alpha5.ArchitectureArm64},
},
}
pod := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner, test.UnschedulablePod())
node := ExpectScheduled(ctx, env.Client, pod[0])
Expect(nodePrice(node)).To(Equal(minPrice))
ExpectInstancesWithLabel(cloudProv.CreateCalls[0].InstanceTypes, v1.LabelArchStable, v1alpha5.ArchitectureArm64)
})
It("should schedule on an instance with enough resources", func() {
opts := test.PodOptions{
ResourceRequirements: v1.ResourceRequirements{Requests: map[v1.ResourceName]resource.Quantity{
v1.ResourceCPU: resource.MustParse("1.5"),
v1.ResourceMemory: resource.MustParse("1.5Gi"),
}}}
pods := ExpectProvisioned(ctx, env.Client, selectionController, provisioners, provisioner,
test.UnschedulablePod(opts), test.UnschedulablePod(opts), test.UnschedulablePod(opts))
nodeNames := sets.NewString()
for _, p := range pods {
node := ExpectScheduled(ctx, env.Client, p)
nodeNames.Insert(node.Name)
}
Expect(nodeNames).To(HaveLen(1))
totalPodResources := resources.RequestsForPods(pods...)
for _, it := range cloudProv.CreateCalls[0].InstanceTypes {
// the total pod resources in CPU and memory should be less than any viable instance has
Expect(totalPodResources.Cpu().Cmp(it.Resources()[v1.ResourceCPU])).To(Equal(-1))
}
})
})

func ExpectInstancesWithLabel(instanceTypes []cloudprovider.InstanceType, label string, value string) {
for _, it := range instanceTypes {
switch label {
case v1.LabelArchStable:
Expect(it.Architecture()).To(Equal(value))
default:
Fail(fmt.Sprintf("unsupported label %s in test", label))
}
}
}

func MakePods(count int, options test.PodOptions) (pods []*v1.Pod) {
for i := 0; i < count; i++ {
pods = append(pods, test.UnschedulablePod(options))
Expand Down

0 comments on commit ca7081c

Please sign in to comment.