Path: blob/main/Lessons/Lesson 07 - Global Optimization 2/extras/Lesson_07_Load_Balancing.ipynb
871 views
Load Balancing Example
In this example we're going to show how you could use various approaches to solve a load balancing problem. Load balancing is when you want to divide a workload as evenly as possible among a set of resources. If we're talking execution times on computer processors, we can describe it as:
given a list of execution times, divide them to be executed on processors so that the total execution time on each processor is as close to the same as possible.
Let's look at a quick example to illustrate this. Say we had 4 jobs that needed to execute and they had the following times: [2,2,4,4]
If we had 2 servers (each with 1 processor) to divide them between, we could have one processor do [2,2] and one processor do [2,4] or we could have each processor do [2,4]. (We could also have one processor doing [2,2,4] and one doing [4], but we'll keep it simple for this example.)
Our objective function is total squared deviation of execution times from balanced time. What does that mean? Well, balanced time is when the total execution time on each processor is the same as every other processor. But, if our loads are out of balance, some processors will be doing much more work. We want to know the difference between what each processor would run if things were in balance and what they are actually running.
Given is the sum of all execution times, and is the number of processors, determing our objective function by hand would look like this:
If we were perfectly load balanced, it would look like this:
Let's dive into working out this same problem in Python.
First we'll set our times as a numpy array and we'll create an objective function that does all our calculations for us. In order for the objective function to work, we need to know how to assign each job to a processor. We'll create another numpy array that has the number of the processor to assign to, and we'll use index filtering to fetch each "set" of processing times. (We'll walk you through what we're talking about in the comments below.)
Objective Function
The following balance_metric function is our objective function. It takes in the assignments, the execution times, and the number of processors. First it calculates the target - the ideal processing time for each processor. Then it calculates the squared difference from the target for each processor and sums up all the differences to get our total deviation from the ideal.
Let's determine what our deviation is for our current example.
It's easy to see how our jobs should be assigned. So let's fix our assignments to get balanced processors and run our metric again.
Perfect! We now have completely balanced processors.
Of course, we'd typically be dealing with larger loads and possibly more processors and we don't want to balance them by hand. In order to let the algorithms do the work for us, we need a function that reassigns one job to a different processor.
Below is the reassign_one function. It takes in the assignments and the number of processors and it swaps a job from one processor to another.
Let's walk through some of what this code is doing. First, it's choosing a job to reassign, that's the "which_job" variable. Let's look at what gets assigned to which processor if we just call that line of code with a new, randomly generated set of execution times. We're generating one random integer between 0 and the total number of execution times. This comes back as an array, so we need to fetch just the first item in the array.
Then, we can decide which processor to assign it to. We've choose 1 random integer between 0 and the total number of processors. This comes back as an array, so we need to fetch just the first item in the array again.
Then we make a copy of the assignments and use the which_job variable to assign a new processor to a single job execution time.
Let's see what happens when we call our function. Call this code a bunch of times and you can see how the assignments change.
Greedy Local Search
Greedy local search is like our hill-climbing examples. We're going to swap one job at a time. If we are closer to being in balance, we'll keep the new assignments. If not, we'll stick with what we had. We'll keep doing this until we hit our maximum no improvement rounds.
For this algorithm, we don't pass in initial assignments. Our load_balance_local takes care of setting an initial assignment for us.
The function takes in the execution times, the number of processors, and the maximum rounds we're willing to go without seeing improvement. It returns the best assignment, the smallest deviation from complete balance, and the number of iterations it took to get there.
Let's run this with a small number of processors and a small number of job execution times. Does your deviation hit zero?
Checking our work
We can look at the total time on each processor to see how close they were. Ideally, they'd be the same.
There's randomness here, so some runs will be better than others. But, even if it seems like your best minimum deviation wasn't that great, it was probably still better than random assignments. Let's compare by doing a random assignment of the jobs and see what the time was on each processor.
Simulated Annealing - By Hand
Now let's see how simulated annealing handles this problem. The good news is that we've already done most of the work coming up with our functions.
Remember for simulated annealing, we need a temperature, and an alpha.
Let's dive in.
We'll use the same set of jobs from the previous example so you can compare, and if you've run all the code, we have a new set of random assignments already.
Visualize the Result
We can see how simulated annealing progresses towards the optimal solution by running this graph.
Simulated Annealing - the simanneal package
When we use the simanneal package, we need to take the code that we had in our own functions before and put it inside special functions that simanneal understands. The simanneal package uses two functions - move() and energy(). These correlate with the reassign_one() and balance_metric() functions. The move function handles whatever change you're making. It's job is to make the change, and possibly to enforce constraints. (Either function can enforce constraints, depending on the method used.) The energy's job is to just return the current score.
The nice part about the simanneal package is that it does all your comparisons for you. You don't have to write any code to compare your current state and new state. That happens "behind the scenes."
Let's talk a little bit about "state." State is the current snapshot of whatever values you're changing. If we said we had an assignment of job 1 to process A and job 2 to process B, the assignments are the current state not the jobs. We need the jobs to determine energy. But, the jobs don't change at all. They stay the same. So they aren't part of the state. (Even if we put job 1 and job 2 on process B, we still just have job 1 and job 2. We aren't going to suddenly have an extra job.)
We still need our jobs to be available to us in the simanneal package. So we'll pass it in as another variable when we initialize the package.
Let's see how it's done.
Genetic Algorithms
Remember that the pseudocode for genetic algorithms looks like this:
We're once again going to use the DEAP package to see if we can get our loads balanced. For this problem, we'll need to think about what it means to do selection, crossover, and mutation. But first, let's talk about individuals and populations.
The "individual" in the genetic algorithm is one potential assignment of jobs to processors. A population is a bunch of potential assignments. DEAP gets more complicated when using numpy data structures. So for this version of the problem we're going to convert our numpy data structures back to regular lists when we're passing them around.
Let's create a function for returning the random assignments. We'll need to pass in - the number of processors and - the number of jobs.
We will also need a function for assessing the value. We've already written that, but DEAP expects a tuple, so we need a modified version. We'll also need to cast our list back to a numpy array to get this code to work correctly.
Creator
Now we're ready to start working with DEAP. First we need to import some things and use the creator to create some objects to work with.
Toolbox
Now we have to set up the toolbox. The toolbox is how we set up the custom attributes for this problem. DEAP has a lot of options for how you set up the toolbox and it can get pretty confusing. The first thing we're going to do is just configure the toolbox.
After we have a toolbox, we can register things to it and unregister them.
We need to register a way to create individuals. We first have to register a way to make assignments. We'll do that by registering an function called assignments and telling DEAP that whenever it's called, DEAP should turn around and call our create_individual function, with the fixed adn variables.
We can test that this is working by using the tool.initIterate command, telling it we expect a list back, and telling it to call the toolbox.assignments function.
If you run this code multiple times, you'll see that we get a different set of assignments each time.
Now that we know that we are getting valid assignments, we need to register an "individual" function with DEAP. The following line of code tells DEAP that we're going to iteratively call the assignments function each time we need to create a new individual. (Note that we're setting tool.initIterate here - which is the same way we tested it above.)
We also need to tell DEAP how we want to build our populations. Notice here that we're saying that our population will be made of individuals, they'll each be of type list, and that we'll repeatedly call the toolbox.individual function. The parameter initRepeat means that we'll need to tell DEAP how many times to repeat when we build our population. (We'll set that later.)
Next we need to register our function to evaluate an assignment. This function will automatically get an individual, but we need to pass in some extra data - the times and the number of processors . We can test that our function is working in the toolbox by manually passing in an individual.
We're finally at the point where we can talk about selection, mating (crossover) and mutation!
Selection
Selection first. We're going to use tournament selection. That means that DEAP will select the best individual among a sub-group of randomly chosen individuals from the population. It will do that times. (Note this is a different than we've used before.) If the sub-group size is the same as the population size, it's basically nullifying the selection process, so you'll want to choose a sub-group size smaller than your population size.
Tournament selection is pretty standard and should work for your homework, though you may want to play with the tournsize. Larger values will promote the best individuals, but will also decrease diversity.
Let's see how it works.
Mating (Crossover)
Crossover takes two individuals and "mates" them by swapping individual values within the individual. There are multiple methods for performing crossover. The easiest method for the kind of data we have (lists of integers) is the cxTwoPoint crossover strategy. In this strategy, you have two individuals, and each individual "swaps" a value with the other one.
In your homework, you'll need to CHANGE the crossover method to suit your problem:(https://deap.readthedocs.io/en/master/api/tools.html)
We'll register the cxTwoPoint strategy and test it. Can you see which jobs got swapped?
Mutation
Finally, we have mutation. Mutation in genetic algorithms works like mutation in life - it's some random change to the existing state. For this problem, we could choose between 2 mutation options:
mutShuffleIndexes
mutUniformInt
ShuffleIndexes randomly shuffles the assignments. This would result in the same number of processors assigned, but different jobs assigned to each processor.
UniformInt randomly changes the assignments to an integer between 0 and k-1. This could result in more total jobs on a processor than when we started.
The indpb is the indepedent probability for each value to be changed. Setting this low means that some of our assignments will stay the same. If we set this to 1, we'd essentially be starting over with a completely random individual.
We'll test both so you can see what happens with each. But, ultimately, we'll use mutUniformInt.
Stats
DEAP offers handy ways to track a bunch of statistics. These don't generally change much. You should be able to just pop them into your code.
Helper Function
All of the above was an explanation of set up. When it comes time to actually run the code, we can use the wrapper function that was created in the lesson. Note that this version of the wrapper function is a little different than the lesson's, in that it passes in the toolbox, tools, and stats instead of using the ones we created in the global scope.
When it comes time to combine GA with local search, you'll need to make a new custom function like this one. Be sure to read through it to understand what it's doing.
Putting GA All Together
The next cell is mostly a repeat of what we have broken out into multiple cells above. This is provided all together as a convenience.
Calling the code
It's finally time to call the genetic algorithm code. To do so, we'll need to set up some additional parameters and then call our custom_ga function, passing in the toolbox, tools, stats and our parameters.
The function returns our best balance, our best assignment, and a log of what it took to get there.
Increasing Problem Size
We used a very small problem to demonstrate each of the methods above. Now it's time to create a much larger problem and see how our algorithms perform.
Baseline
Let's see what our baseline deviation from balanced loads is with a size this large. (Note, there's randomness here and some algorithms set their own baseline. But this should give us a general idea.)
Greedy local search
The only parameter we can fiddle with in our greedy local search is how many iterations we're willing to go with no improvement. Try changing the 5000 number to see if it gets better results
max_no_improve = 5000
Custom Simulated Annealing
For our custom simulated annealing, we can tweak the following parameters:
max_no_improve = 1000
temp = 500
alpha = .99
Try tweaking these parameters to see if you can get a better result
Simanneal Package
The only parameter you can tweak in the simanneal package is how long you're willing to wait. Try changing that to see if you can get a better result.
wait_time = .2
DEAP Genetic Algorithm
DEAP has a lot of parameters to tweak. Try tweaking some of the following to see if you can get a better result.
pop_size = 200
crossover_prob = 0.3
mutation_prob = 0.5
max_gen = 2000
max_no_improve = 200
(Note: that we need to repeat a lot of code when we're changing the problem space with DEAP. DEAP hard-codes the k and n in our functions when we set it up, so we need to essentially start from scratch. We've included all the necessary code without comments in the cell below.)
Warning: This code will be slow to run.