blob: a7486a96206187122f7dd38c4aea03e07a39a780 [file] [log] [blame]
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"fmt"
"math"
"reflect"
"sort"
"github.com/mitchellh/hashstructure"
"google.golang.org/protobuf/proto"
"go.chromium.org/chromiumos/config/go/test/api"
)
// TrRequest represents the what will become a TR request:
// Where it will be 1 request that contains 1 --> many tests.
// This request will be built up to contain all needed information to make a Tr(v2) request.
type TrRequest struct {
req *api.HWRequirements
tcs []*api.CTPTestCase
}
// loading is used in lab avalability such that devices with the same HW;
// but different SW requirements, still share the same pool of physical devices.
type loading struct {
value int
}
type hwInfo struct {
req *api.HWRequirements
labLoading *loading
numInCurrentShard int
}
// Kv structs are useful for gobased sorting.
type kv struct {
key uint64
value int
}
type distroCfg struct {
isUnitTest bool
unitTestDevices int
maxInShard int
}
type middleOutData struct {
// Originally defined HW to TC
hwToTCMap map[uint64][]string
// Map between original HW objects, and the flat HW equivs
// Example: [sha123]: [sha1, sha2, sha3]
hwEquivalenceMap map[uint64][]uint64
// the Sha of the HWRequirements object from a TC, pointing back to the HWRequirements object
hwUUIDMap map[uint64]*api.HWRequirements
cfg distroCfg
// Flat HWUUID to flat HW
flatHWUUIDMap map[uint64]*hwInfo
// The TestCase name pointing to its respective object
tcUUIDMap map[string]*api.CTPTestCase
finalAssignments map[uint64][][]string
}
// newMiddleOutData returns a struct of the middleOutData with the data init'd but empty.
func newMiddleOutData() *middleOutData {
mo := &middleOutData{
hwToTCMap: make(map[uint64][]string),
hwEquivalenceMap: make(map[uint64][]uint64),
hwUUIDMap: make(map[uint64]*api.HWRequirements),
cfg: distroCfg{},
flatHWUUIDMap: make(map[uint64]*hwInfo),
tcUUIDMap: make(map[string]*api.CTPTestCase),
finalAssignments: make(map[uint64][][]string),
}
return mo
}
// middleOut creates TRRequest(S) from a ctpv2 internal test plan.
func middleOut(resp *api.InternalTestplan, cfg distroCfg) ([]*TrRequest, error) {
solverData := newMiddleOutData()
solverData.cfg = cfg
for _, tc := range resp.GetTestCases() {
tcUUID := tc.GetName()
// Drop all of the HW fluff in the TC for memory sakes.
tcForMap := &api.CTPTestCase{
Name: tc.GetName(),
Metadata: tc.GetMetadata(),
}
solverData.tcUUIDMap[tcUUID] = tcForMap
for _, hw := range tc.HwRequirements {
// Note: Each `hw` is still a repeated list of HW *options* for the test.
hash := addHWtohwUUIDMap(solverData.hwUUIDMap, hw)
for k, v := range flattenList([]*api.HWRequirements{hw}) {
err := addHWtoFlatHWUUIDMap(solverData.flatHWUUIDMap, k, v)
if err != nil {
return nil, err
}
}
solverData.hwToTCMap[hash] = append(solverData.hwToTCMap[hash], tcUUID)
}
}
// Goal here is to make the hwEquivalenceMap; such that when we go to distribute the hwToTCMap
// We can lookup what HW options are available.
for key, hwOptions := range solverData.hwUUIDMap {
solverData.hwEquivalenceMap[key] = findMatches(hwOptions, solverData.flatHWUUIDMap)
}
return createTrRequests(greedyDistro(solverData), solverData)
}
// createTrRequests translates a final {hw:[[shard], [shard]]} map into a flat list of TrRequests.
func createTrRequests(distro map[uint64][][]string, solverData *middleOutData) ([]*TrRequest, error) {
TrRequests := []*TrRequest{}
for k, shards := range distro {
for _, tcs := range shards {
shardedtcs := []*api.CTPTestCase{}
for _, tc := range tcs {
lltc, ok := solverData.tcUUIDMap[tc]
if !ok {
return nil, fmt.Errorf("tc assigned but was not given, something critically wrong happened to end up here")
}
shardedtcs = append(shardedtcs, lltc)
}
trReq := &TrRequest{
req: solverData.flatHWUUIDMap[k].req,
tcs: shardedtcs,
}
TrRequests = append(TrRequests, trReq)
}
}
return nil, nil
}
// Given the class map, and the tc map, gather the lab loading for the devices, and make our best guess at assigning tests to HW.
func greedyDistro(solverData *middleOutData) map[uint64][][]string {
populateLabAvalability(solverData)
orderedHw := hwSearchOrdering(solverData.hwEquivalenceMap)
// Starting with the HW classes with the least amount of equivalent classes
for _, hwS := range orderedHw {
// Find the TCS that require this class, and assign them devices.
hwHash := hwS.key
tcs := solverData.hwToTCMap[hwHash]
// Currently we will not try anymore than basic sharding.
// As in, we won't attempt to "fill" a pod, then spill over.
// Its either "you can take all these tests" or we get a new pod.
shards := shard(tcs, solverData.cfg.maxInShard)
for _, shardedtc := range shards {
selectedDevice, expandCurrentShard := getDevices(solverData, len(shardedtc), hwHash)
assignHardware(solverData, selectedDevice, expandCurrentShard, shardedtc)
}
}
return solverData.finalAssignments
}
// assignHardware will add the tests to the selectedDevice, being aware if it should go into a non-filled hard, or a new one.
// assignHardware will also decrement the number of devices remaining every time device is assigned tests.
func assignHardware(solverData *middleOutData, selectedDevice uint64, expandCurrentShard bool, shardedtc []string) {
if expandCurrentShard {
lastElement := len(solverData.finalAssignments[selectedDevice])
solverData.finalAssignments[selectedDevice][lastElement-1] = append(solverData.finalAssignments[selectedDevice][lastElement-1], shardedtc...)
solverData.flatHWUUIDMap[selectedDevice].numInCurrentShard += len(shardedtc) // Show the status of the current shard. Might be wrong.
if solverData.flatHWUUIDMap[selectedDevice].numInCurrentShard == solverData.cfg.maxInShard {
solverData.flatHWUUIDMap[selectedDevice].numInCurrentShard = 0
}
} else {
_, ok := solverData.finalAssignments[selectedDevice]
if !ok {
solverData.finalAssignments[selectedDevice] = [][]string{}
}
solverData.finalAssignments[selectedDevice] = append(solverData.finalAssignments[selectedDevice], shardedtc)
solverData.flatHWUUIDMap[selectedDevice].labLoading.value-- // Reduce the # of open devices by 1.
// If the shard is not full, mark it as such.
if len(shardedtc) != solverData.cfg.maxInShard {
solverData.flatHWUUIDMap[selectedDevice].numInCurrentShard += len(shardedtc)
}
}
}
// findMatches: find options for the given hwOption from the given flatHWUUIDMap
func findMatches(hwOption *api.HWRequirements, flatHWUUIDMap map[uint64]*hwInfo) []uint64 {
matches := []uint64{}
for sha, foundHW := range flatHWUUIDMap {
if isParent(foundHW.req, hwOption) {
matches = append(matches, sha)
}
}
return matches
}
// isParent determines if the first is fully encompassing of the second object.
func isParent(parentSrc *api.HWRequirements, childSrc *api.HWRequirements) bool {
/*
In other words, if the parent has at least all of the fields of the child, it can run the child.
*/
for _, parent := range parentSrc.HwDefinition {
found := false
for _, child := range childSrc.HwDefinition {
if !allItemsIn(child.GetSwarmingLabels(), parent.GetSwarmingLabels()) {
return false
}
if reflect.DeepEqual(parent.DutInfo, child.DutInfo) && reflect.DeepEqual(parent.ProvisionInfo, child.ProvisionInfo) {
found = true
}
}
// If a parent is not found, then this can't be a match.
if !found {
return false
}
}
return true
}
// Check all items from 1 are in 2
func allItemsIn(item1 []string, item2 []string) bool {
for _, string1 := range item1 {
found := false
for _, string2 := range item2 {
if string1 == string2 {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// shard will device the list into a list of lists where each item in the list length of maxInShard
// eg: [1,2,3,4], maxInShard=2 --> [[1,2], [3,4]]
func shard(tests []string, maxInShard int) (shards [][]string) {
for maxInShard < len(tests) {
tests, shards = tests[maxInShard:], append(shards, tests[0:maxInShard:maxInShard])
}
return append(shards, tests)
}
// TODO (azrahman@; integrate with swarming API)
func labAvalability(*api.HWRequirements) int {
// returns the number of devices which can meet the requirement.
// will need to include pool
return -1
}
// Will add the amount of devices in the lab to each of the HW items.
func populateLabAvalability(solverData *middleOutData) {
hwFound := make(map[uint64]*loading)
for _, value := range solverData.flatHWUUIDMap {
hash, _ := hashstructure.Hash(value.req.HwDefinition[0].DutInfo, hashstructure.FormatV2, nil)
_, exists := hwFound[hash]
if exists {
value.labLoading = hwFound[hash]
continue
}
if solverData.cfg.unitTestDevices != 0 {
hwFound[hash] = &loading{value: solverData.cfg.unitTestDevices}
} else {
value.labLoading = &loading{value: labAvalability(value.req)}
}
value.labLoading = hwFound[hash]
}
}
// translate the given hwEquivalenceMap into a 2d map:
// EG [id1=[1 || 2] || id2=[3 || 4]] needs to be [[1, 2, 3, 4]]
// hwEquivalenceMap is like id1 = [id1, id2]
// now needs to be like id1 = [newid1, newid2, newid3, newid4]
func flattenEqMap(hwEquivalenceMap map[uint64][]uint64, hwUUIDMap map[uint64]*api.HWRequirements) (map[uint64][]uint64, map[uint64]*hwInfo) {
newHWUUIDMap := make(map[uint64]*hwInfo)
newHwEquivalenceMap := make(map[uint64][]uint64)
for hw, results := range hwEquivalenceMap {
var allHw []*api.HWRequirements
newHwEquivalenceMap[hw] = []uint64{}
allHw = append(allHw, hwUUIDMap[hw])
for _, hash := range results {
allHw = append(allHw, hwUUIDMap[hash])
}
// flattened is now the uuid for [newid1: obj, etc]
flattened := flattenList(allHw)
for k, v := range flattened {
newHWUUIDMap[k] = &hwInfo{req: v}
newHwEquivalenceMap[hw] = append(newHwEquivalenceMap[hw], k)
}
}
return newHwEquivalenceMap, newHWUUIDMap
}
// flattenList translates [hw.requirements=[1||2], hw.requirements=[3||4]] into [1,2,3,4]
func flattenList(allHw []*api.HWRequirements) map[uint64]*api.HWRequirements {
flatHW := make(map[uint64]*api.HWRequirements)
for _, hw := range allHw {
for _, innerHW := range hw.HwDefinition {
flattened := proto.Clone(hw).(*api.HWRequirements)
flattened.HwDefinition = []*api.SwarmingDefinition{innerHW}
h, _ := hashstructure.Hash(flattened, hashstructure.FormatV2, nil)
flatHW[h] = flattened
}
}
return flatHW
}
// getDevices finds a device from the devicepool + hwEquivalenceMap to satsify the need for the test
// It will first look for a matching device with a non-full shard that fits,
// otherwise it will look for a device with the most availability in the lab.
func getDevices(solverData *middleOutData, numTests int, hwHash uint64) (selectedDevice uint64, append bool) {
// This is a pretty expensive approach to sharding:
// We will always check all devices to see if they have room in a non-empty shard.
// So even when we fully fill a device, or it hasn't been touched, we still check it.
// First, try to fill a non-empty shard
devices := solverData.hwEquivalenceMap[hwHash]
for _, device := range devices {
// if the shard is empty, we need to use the labloading process block
// not the shard filler.
if solverData.flatHWUUIDMap[device].numInCurrentShard < 1 {
continue
}
if solverData.flatHWUUIDMap[device].numInCurrentShard+numTests <= solverData.cfg.maxInShard {
selectedDevice = device
return selectedDevice, true
}
}
// If that cannot be done, then just pick the device with the most available.
maxAvalibleFound := math.MinInt32
for _, device := range devices {
if solverData.flatHWUUIDMap[device].labLoading.value > maxAvalibleFound {
maxAvalibleFound = solverData.flatHWUUIDMap[device].labLoading.value
selectedDevice = device
}
}
return selectedDevice, false
}
// hwSearchOrdering: given the flatUUIDLoadingMap, return the order of least common to most common boards/eqs.
func hwSearchOrdering(flatEqMap map[uint64][]uint64) []kv {
/*
For example: hwEqMap {hw1: [hw1,hw2,hw3], hw3: [hw3]}
we want to allow hw3 to find matches first. This is to give a chance for all tests which specifically require hw3
to take prioty on that hw.
Example problem we are trying to solve (given the hwEq map above)
- labLoading provides there are 2 devices for each type
- There is a maximum of 2 tests per shard allowed
- The HW to TC Loading:
hw1: [tc1, tc2, ... 8]
hw3: [tc1 .. 4]
If we allowed hw1 to be solved first, it could potentially be assigned HW3
(as it has the same avalbility as HW1/Hw2). Then when the Hw3 TC come to be assigned, all the HW3 devices have been
loaded.
*/
var sortedStruct []kv
for k, v := range flatEqMap {
sortedStruct = append(sortedStruct, kv{key: k, value: len(v)})
}
sort.Slice(sortedStruct, func(i, j int) bool {
return sortedStruct[i].value < sortedStruct[j].value
})
return sortedStruct
}
// addHWtohwUUIDMap is a helper method to inject into the map without overwriting the keys existing values.
func addHWtohwUUIDMap(hwUUIDMap map[uint64]*api.HWRequirements, hw *api.HWRequirements) uint64 {
hash, _ := hashstructure.Hash(hw, hashstructure.FormatV2, nil)
_, hwExists := hwUUIDMap[hash]
// Add the HW to the lookup map if not seen before.
if !hwExists {
hwUUIDMap[hash] = hw
}
return hash
}
// addHWtoFlatHWUUIDMap is a helper method to inject into the map without overwriting the keys existing values.
func addHWtoFlatHWUUIDMap(flatHWUUIDMap map[uint64]*hwInfo, k uint64, v *api.HWRequirements) error {
_, exists := flatHWUUIDMap[k]
// Add the HW to the lookup map if not seen before.
if !exists {
flatHWUUIDMap[k] = &hwInfo{req: v}
} else if !reflect.DeepEqual(flatHWUUIDMap[k].req, v) {
flatHWUUIDMap[k] = &hwInfo{req: v}
return fmt.Errorf("mismatch")
}
return nil
}