]> git.arvados.org - arvados.git/blob - lib/dispatchcloud/node_size.go
Merge branch '23009-multiselect-bug' into main. Closes #23009
[arvados.git] / lib / dispatchcloud / node_size.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package dispatchcloud
6
7 import (
8         "errors"
9         "fmt"
10         "math"
11         "regexp"
12         "slices"
13         "sort"
14         "strconv"
15
16         "git.arvados.org/arvados.git/sdk/go/arvados"
17 )
18
19 var ErrInstanceTypesNotConfigured = errors.New("site configuration does not list any instance types")
20
21 var discountConfiguredRAMPercent = 5
22
23 // ConstraintsNotSatisfiableError includes a list of available instance types
24 // to be reported back to the user.
25 type ConstraintsNotSatisfiableError struct {
26         error
27         AvailableTypes []arvados.InstanceType
28 }
29
30 var pdhRegexp = regexp.MustCompile(`^[0-9a-f]{32}\+(\d+)$`)
31
32 // estimateDockerImageSize estimates how much disk space will be used
33 // by a Docker image, given the PDH of a collection containing a
34 // Docker image that was created by "arv-keepdocker".  Returns
35 // estimated number of bytes of disk space that should be reserved.
36 func estimateDockerImageSize(collectionPDH string) int64 {
37         m := pdhRegexp.FindStringSubmatch(collectionPDH)
38         if m == nil {
39                 return 0
40         }
41         n, err := strconv.ParseInt(m[1], 10, 64)
42         if err != nil || n < 122 {
43                 return 0
44         }
45         // To avoid having to fetch the collection, take advantage of
46         // the fact that the manifest storing a container image
47         // uploaded by arv-keepdocker has a predictable format, which
48         // allows us to estimate the size of the image based on just
49         // the size of the manifest.
50         //
51         // Use the following heuristic:
52         // - Start with the length of the manifest (n)
53         // - Subtract 80 characters for the filename and file segment
54         // - Divide by 42 to get the number of block identifiers ('hash\+size\ ' is 32+1+8+1)
55         // - Assume each block is full, multiply by 64 MiB
56         return ((n - 80) / 42) * (64 * 1024 * 1024)
57 }
58
59 // EstimateScratchSpace estimates how much available disk space (in
60 // bytes) is needed to run the container by summing the capacity
61 // requested by 'tmp' mounts plus disk space required to load the
62 // Docker image plus arv-mount block cache.
63 func EstimateScratchSpace(ctr *arvados.Container) (needScratch int64) {
64         for _, m := range ctr.Mounts {
65                 if m.Kind == "tmp" {
66                         needScratch += m.Capacity
67                 }
68         }
69
70         // Account for disk space usage by Docker, assumes the following behavior:
71         // - Layer tarballs are buffered to disk during "docker load".
72         // - Individual layer tarballs are extracted from buffered
73         // copy to the filesystem
74         dockerImageSize := estimateDockerImageSize(ctr.ContainerImage)
75
76         // The buffer is only needed during image load, so make sure
77         // the baseline scratch space at least covers dockerImageSize,
78         // and assume it will be released to the job afterwards.
79         if needScratch < dockerImageSize {
80                 needScratch = dockerImageSize
81         }
82
83         // Now reserve space for the extracted image on disk.
84         needScratch += dockerImageSize
85
86         // Now reserve space the arv-mount disk cache
87         needScratch += ctr.RuntimeConstraints.KeepCacheDisk
88
89         return
90 }
91
92 // compareVersion returns true if vs1 < vs2, otherwise false
93 func versionLess(vs1 string, vs2 string) (bool, error) {
94         v1, err := strconv.ParseFloat(vs1, 64)
95         if err != nil {
96                 return false, err
97         }
98         v2, err := strconv.ParseFloat(vs2, 64)
99         if err != nil {
100                 return false, err
101         }
102         return v1 < v2, nil
103 }
104
105 // ChooseInstanceType returns the arvados.InstanceTypes eligible to
106 // run ctr, i.e., those that have enough RAM, VCPUs, etc., and are not
107 // too expensive according to cluster configuration.
108 //
109 // The returned types are sorted with lower prices first.
110 //
111 // The error is non-nil if and only if the returned slice is empty.
112 func ChooseInstanceType(cc *arvados.Cluster, ctr *arvados.Container) ([]arvados.InstanceType, error) {
113         if len(cc.InstanceTypes) == 0 {
114                 return nil, ErrInstanceTypesNotConfigured
115         }
116
117         needScratch := EstimateScratchSpace(ctr)
118
119         needVCPUs := ctr.RuntimeConstraints.VCPUs
120
121         needRAM := ctr.RuntimeConstraints.RAM + ctr.RuntimeConstraints.KeepCacheRAM
122         needRAM += int64(cc.Containers.ReserveExtraRAM)
123         if cc.Containers.LocalKeepBlobBuffersPerVCPU > 0 {
124                 // + 200 MiB for keepstore process + 10% for GOGC=10
125                 needRAM += 220 << 20
126                 // + 64 MiB for each blob buffer + 10% for GOGC=10
127                 needRAM += int64(cc.Containers.LocalKeepBlobBuffersPerVCPU * needVCPUs * (1 << 26) * 11 / 10)
128         }
129         needRAM = (needRAM * 100) / int64(100-discountConfiguredRAMPercent)
130
131         maxPriceFactor := math.Max(cc.Containers.MaximumPriceFactor, 1)
132         var types []arvados.InstanceType
133         var maxPrice float64
134         for _, it := range cc.InstanceTypes {
135                 driverInsuff, driverErr := versionLess(it.GPU.DriverVersion, ctr.RuntimeConstraints.GPU.DriverVersion)
136
137                 var capabilityInsuff bool
138                 var capabilityErr error
139                 if ctr.RuntimeConstraints.GPU.Stack == "" {
140                         // do nothing
141                 } else if ctr.RuntimeConstraints.GPU.Stack == "cuda" {
142                         if len(ctr.RuntimeConstraints.GPU.HardwareTarget) > 1 {
143                                 // Check if the node's capability
144                                 // exactly matches any of the
145                                 // requested capability. For CUDA,
146                                 // this is the hardware capability in
147                                 // X.Y format.
148                                 capabilityInsuff = !slices.Contains(ctr.RuntimeConstraints.GPU.HardwareTarget, it.GPU.HardwareTarget)
149                         } else if len(ctr.RuntimeConstraints.GPU.HardwareTarget) == 1 {
150                                 // version compare.
151                                 capabilityInsuff, capabilityErr = versionLess(it.GPU.HardwareTarget, ctr.RuntimeConstraints.GPU.HardwareTarget[0])
152                         } else {
153                                 capabilityInsuff = true
154                         }
155                 } else if ctr.RuntimeConstraints.GPU.Stack == "rocm" {
156                         // Check if the node's hardware matches any of
157                         // the requested hardware.  For rocm, this is
158                         // a gfxXXXX LLVM target.
159                         capabilityInsuff = !slices.Contains(ctr.RuntimeConstraints.GPU.HardwareTarget, it.GPU.HardwareTarget)
160                 } else {
161                         // not blank, "cuda", or "rocm" so that's an error
162                         return nil, ConstraintsNotSatisfiableError{
163                                 errors.New(fmt.Sprintf("Invalid GPU stack %q, expected to be blank or one of 'cuda' or 'rocm'", ctr.RuntimeConstraints.GPU.Stack)),
164                                 []arvados.InstanceType{},
165                         }
166                 }
167
168                 switch {
169                 // reasons to reject a node
170                 case maxPrice > 0 && it.Price > maxPrice: // too expensive
171                 case int64(it.Scratch) < needScratch: // insufficient scratch
172                 case int64(it.RAM) < needRAM: // insufficient RAM
173                 case it.VCPUs < needVCPUs: // insufficient VCPUs
174                 case it.Preemptible != ctr.SchedulingParameters.Preemptible: // wrong preemptable setting
175                 case it.GPU.Stack != ctr.RuntimeConstraints.GPU.Stack: // incompatible GPU software stack (or none available)
176                 case it.GPU.DeviceCount < ctr.RuntimeConstraints.GPU.DeviceCount: // insufficient GPU devices
177                 case it.GPU.VRAM > 0 && int64(it.GPU.VRAM) < ctr.RuntimeConstraints.GPU.VRAM: // insufficient VRAM per GPU
178                 case ctr.RuntimeConstraints.GPU.DeviceCount > 0 && (driverInsuff || driverErr != nil): // insufficient driver version
179                 case ctr.RuntimeConstraints.GPU.DeviceCount > 0 && (capabilityInsuff || capabilityErr != nil): // insufficient hardware capability
180                         // Don't select this node
181                 default:
182                         // Didn't reject the node, so select it
183                         types = append(types, it)
184                         if newmax := it.Price * maxPriceFactor; newmax < maxPrice || maxPrice == 0 {
185                                 maxPrice = newmax
186                         }
187                 }
188         }
189         if len(types) == 0 {
190                 availableTypes := make([]arvados.InstanceType, 0, len(cc.InstanceTypes))
191                 for _, t := range cc.InstanceTypes {
192                         availableTypes = append(availableTypes, t)
193                 }
194                 sort.Slice(availableTypes, func(a, b int) bool {
195                         return availableTypes[a].Price < availableTypes[b].Price
196                 })
197                 return nil, ConstraintsNotSatisfiableError{
198                         errors.New("constraints not satisfiable by any configured instance type"),
199                         availableTypes,
200                 }
201         }
202         sort.Slice(types, func(i, j int) bool {
203                 if types[i].Price != types[j].Price {
204                         // prefer lower price
205                         return types[i].Price < types[j].Price
206                 }
207                 if types[i].RAM != types[j].RAM {
208                         // if same price, prefer more RAM
209                         return types[i].RAM > types[j].RAM
210                 }
211                 if types[i].VCPUs != types[j].VCPUs {
212                         // if same price and RAM, prefer more VCPUs
213                         return types[i].VCPUs > types[j].VCPUs
214                 }
215                 if types[i].Scratch != types[j].Scratch {
216                         // if same price and RAM and VCPUs, prefer more scratch
217                         return types[i].Scratch > types[j].Scratch
218                 }
219                 // no preference, just sort the same way each time
220                 return types[i].Name < types[j].Name
221         })
222         // Truncate types at maxPrice. We rejected it.Price>maxPrice
223         // in the loop above, but at that point maxPrice wasn't
224         // necessarily the final (lowest) maxPrice.
225         for i, it := range types {
226                 if i > 0 && it.Price > maxPrice {
227                         types = types[:i]
228                         break
229                 }
230         }
231         return types, nil
232 }