#2049 is adding a simple parallelization limit. This proposal describes a more complex follow-up to this problem. The goal is that when a build is scaled up(eg. take a build that compiles app now and change it to a build that compiles the same app from 100 different commits), it does not cause the builder to crash and the machine to become unresponsive (or catch fire). This should happen without requiring manual configuration. If you move from high powered machine to a low-powered one (eg. rpi) build should slow down linearly without additional bottlenecks from inefficient execution.
In the simplest form, the scheduler should be combined with system state monitoring. When the monitor detects that machine resources have been reached, it starts blocking new jobs. In some cases, existing jobs may need to be paused.
This is different from cgroup controls that may be applied independently.
I'm only concentrating on cpu and memory. In the future, this could be extended to io/network. It's also probably too early to discuss making initial predictions for possible resource usage based on command arguments.
type SystemStats interface {
CPUInfo()
MemoryInfo()
}
NewResourceManager(SystemStats, maxMemory, memoryBuffer) + existing MaxParallelism semaphore
rm.Init(ctx, id, cpuHint, memoryHint) (error)
rm.Update(id, cpuLoad, memoryUsage) (wait chan struct{}, error)
rm.Leave(id) error
type Op {
// current methods
Acquire(ctx) (release, error)
}
New daemon config values:
MaxMemory - defaults/max to current available memory
MemoryBuffer - a percentage of MaxMemory
NumCPUs ? maybe but not very related
New values for ExecOp:
MemoryHint - How much memory process expects to need
MemoryLimit - Limit memory under these bounds (with cgroup)
Every Op is initialized with a shared ResourceManager instance. Solver/scheduler has no knowledge of the limits and only calls the Acquire() method of the op that blocks until the op can start(or is canceled). This is important for the case where we have multiple workers. 2 vertexes on different workers have different resource managers and don't block each other. Also, solver/vertex definition is very generic and doesn't fit with very specific linux resources. Hopefully, this doesn't limit us from making smarter scheduling decisions.
Acquire() method initializes current ID with the ResourceManager. If the system is exhausted, then the function will block. During Exec Op will monitor its own resources (cgroup of the containers it created) and call rm.Update(). Update call may choose to return a channel. If that happens, op should pause its execution(eg with freezer cgroup) and wait for that channel to return.
ResourceManager compares system stats with the values sent by the ops and makes decisions when to block certain ops when they call Init/Update and when to restart them. ResourceManager should be unit testable with custom system stats provider implementation.
CPU
The main parameter to monitor here is if the system CPU is exhausted. This can be determined based on vmstat (/proc equivalent of it) by checking the length of the run queue compared to the number of cpus, and cpu idle time. If CPU is exhausted, then additional ops can't run. Values should be determined as a weighted average over a time period to minimize wrong decisions from quick changes. At least historical values should be taken into account when determining if CPU is free again. When CPU is exhausted, starting new ops can be blocked without historical data. When one op has finished, algorithm should be smart enough to understand that this CPU time is not used anymore.
The second problem that should be avoided is starting too many ops in parallel when the stats are low, and then as they start, they exhaust the CPU. This should be done by introducing delays if too many processes start at the same time. To predict the delays, I think we need to look at system load as well as count/speed of the CPUs. Eg. we should be able to detect that RPi needs longer delays. One way to think about this problem is that we set a prediction of CPU usage on every op we start. Initially, this prediction has a big std deviation. Over time, when we get actual values via Update(), that stddev gets smaller, and we can trust the data if it says that there is more CPU power left.
Generally, over-using CPU is not as big of a problem as doing the same with memory. Kernel's scheduler can balance quite well, and more parallel ops usually give faster build times. So we should not try to be very precise but avoid extreme cases.
Although rare, I do think we need some logic to also pause ops when needed. For example, let's say there is a lot of processes running ./configure && make. Configure is usually quite sequential, so CPU load will not be detected. It also takes a long time, so the startup delays do not have an effect. But make can likely take advantage of the whole CPU and create a big run queue. So if the run queue remains very long for a long time, Update should send a signal to op to pause it.
We can also look into scheduling priorities to give some ops more CPU than others.
Memory
Memory monitoring is somewhat similar, but we need to be more precise. The manager is configured with MaxMemory parameter, capped with maximum free system memory, and the goal is for the ops total memory usage to never go past that value.
Unlike CPU, once we pause op, it does not release the memory it already uses(at least without checkpoint/restore that is out of scope atm). This means that we need a buffer to allow memory to grow and that we need to predict how much memory an op will take in the future.
For a better prediction op can give MemoryHint and MemoryLimit value with definition. Hint assumes how much memory will be needed to avoid overflow when it is known that process uses lots of resources. Limit sets a cgroup limit and can be used as an upper cap for the prediction.
If no hint was set, prediction starts with a value based on a constant and some average of memory usage for previous builds (later args could be used for better historic prediction). Initially, the prediction has a high std deviation.
Once the process has started, it sends updates about its memory usage. This data can be used for a future prediction based on the changes in previous data. As we get more data we can trust it more and stddev gets smaller.
Examples:
Initial prediction: 200MB
Process starts, takes 30MB instantly, 31MB in 10s, 32MB in 20s
Prediction: 200MB, 5sec 100MB, 10s 40MB
Initial prediction: 200MB
Process starts, takes 100MB instantly, 200MB in 10s, 300MB in 20s, 320MB in 30s, 325MB in 40s
Prediction: 200MB, 10sec 300MB, 20s 1GB, 30s 500MB , 40s 400MB
It's unclear how much in the future the prediction should be. We probably need a lot of tunable parameters to determine the best values.
All the ops memory predictions are added together and compared with max available memory. A buffer is also applied to allow the processes that are left running to grow their memory.
If the prediction shows that the memory limit is about to be reached, one of the ops is paused. It probably makes sense to pause the op that was most aggressively acquiring new memory.
When Op pauses, we need a way for this to show up in the progress bar. This should be solvable with a new state in Vertex status structure. This part could be possible as a separate step and should be possibly done first as there may be a problem with backward compatibility with old clients.
As another follow-up resource manager should be able to return debug info. With analysis of that info, it should be possible to predict if the build needs more CPU, iops, memory etc and how much faster it would have been on a machine with different capabilities.
@vladaionescu @AkihiroSuda @hinshun @aaronlehmann @crazy-max
#2049 is adding a simple parallelization limit. This proposal describes a more complex follow-up to this problem. The goal is that when a build is scaled up(eg. take a build that compiles app now and change it to a build that compiles the same app from 100 different commits), it does not cause the builder to crash and the machine to become unresponsive (or catch fire). This should happen without requiring manual configuration. If you move from high powered machine to a low-powered one (eg. rpi) build should slow down linearly without additional bottlenecks from inefficient execution.
In the simplest form, the scheduler should be combined with system state monitoring. When the monitor detects that machine resources have been reached, it starts blocking new jobs. In some cases, existing jobs may need to be paused.
This is different from cgroup controls that may be applied independently.
I'm only concentrating on cpu and memory. In the future, this could be extended to io/network. It's also probably too early to discuss making initial predictions for possible resource usage based on command arguments.
New daemon config values:
MaxMemory - defaults/max to current available memory
MemoryBuffer - a percentage of MaxMemory
NumCPUs ? maybe but not very related
New values for ExecOp:
MemoryHint - How much memory process expects to need
MemoryLimit - Limit memory under these bounds (with cgroup)
Every
Opis initialized with a sharedResourceManagerinstance. Solver/scheduler has no knowledge of the limits and only calls theAcquire()method of the op that blocks until the op can start(or is canceled). This is important for the case where we have multiple workers. 2 vertexes on different workers have different resource managers and don't block each other. Also, solver/vertex definition is very generic and doesn't fit with very specific linux resources. Hopefully, this doesn't limit us from making smarter scheduling decisions.Acquire()method initializes current ID with theResourceManager. If the system is exhausted, then the function will block. DuringExecOp will monitor its own resources (cgroup of the containers it created) and callrm.Update(). Update call may choose to return a channel. If that happens, op should pause its execution(eg with freezer cgroup) and wait for that channel to return.ResourceManager compares system stats with the values sent by the ops and makes decisions when to block certain ops when they call
Init/Updateand when to restart them. ResourceManager should be unit testable with custom system stats provider implementation.CPU
The main parameter to monitor here is if the system CPU is exhausted. This can be determined based on
vmstat(/proc equivalent of it) by checking the length of the run queue compared to the number of cpus, and cpu idle time. If CPU is exhausted, then additional ops can't run. Values should be determined as a weighted average over a time period to minimize wrong decisions from quick changes. At least historical values should be taken into account when determining if CPU is free again. When CPU is exhausted, starting new ops can be blocked without historical data. When one op has finished, algorithm should be smart enough to understand that this CPU time is not used anymore.The second problem that should be avoided is starting too many ops in parallel when the stats are low, and then as they start, they exhaust the CPU. This should be done by introducing delays if too many processes start at the same time. To predict the delays, I think we need to look at system load as well as count/speed of the CPUs. Eg. we should be able to detect that RPi needs longer delays. One way to think about this problem is that we set a prediction of CPU usage on every op we start. Initially, this prediction has a big std deviation. Over time, when we get actual values via
Update(), that stddev gets smaller, and we can trust the data if it says that there is more CPU power left.Generally, over-using CPU is not as big of a problem as doing the same with memory. Kernel's scheduler can balance quite well, and more parallel ops usually give faster build times. So we should not try to be very precise but avoid extreme cases.
Although rare, I do think we need some logic to also pause ops when needed. For example, let's say there is a lot of processes running
./configure && make. Configure is usually quite sequential, so CPU load will not be detected. It also takes a long time, so the startup delays do not have an effect. Butmakecan likely take advantage of the whole CPU and create a big run queue. So if the run queue remains very long for a long time,Updateshould send a signal to op to pause it.We can also look into scheduling priorities to give some ops more CPU than others.
Memory
Memory monitoring is somewhat similar, but we need to be more precise. The manager is configured with
MaxMemoryparameter, capped with maximum free system memory, and the goal is for the ops total memory usage to never go past that value.Unlike CPU, once we pause op, it does not release the memory it already uses(at least without checkpoint/restore that is out of scope atm). This means that we need a buffer to allow memory to grow and that we need to predict how much memory an op will take in the future.
For a better prediction op can give
MemoryHintandMemoryLimitvalue with definition. Hint assumes how much memory will be needed to avoid overflow when it is known that process uses lots of resources. Limit sets a cgroup limit and can be used as an upper cap for the prediction.If no hint was set, prediction starts with a value based on a constant and some average of memory usage for previous builds (later args could be used for better historic prediction). Initially, the prediction has a high std deviation.
Once the process has started, it sends updates about its memory usage. This data can be used for a future prediction based on the changes in previous data. As we get more data we can trust it more and stddev gets smaller.
Examples:
Initial prediction: 200MB
Process starts, takes 30MB instantly, 31MB in 10s, 32MB in 20s
Prediction: 200MB, 5sec 100MB, 10s 40MB
Initial prediction: 200MB
Process starts, takes 100MB instantly, 200MB in 10s, 300MB in 20s, 320MB in 30s, 325MB in 40s
Prediction: 200MB, 10sec 300MB, 20s 1GB, 30s 500MB , 40s 400MB
It's unclear how much in the future the prediction should be. We probably need a lot of tunable parameters to determine the best values.
All the ops memory predictions are added together and compared with max available memory. A buffer is also applied to allow the processes that are left running to grow their memory.
If the prediction shows that the memory limit is about to be reached, one of the ops is paused. It probably makes sense to pause the op that was most aggressively acquiring new memory.
When Op pauses, we need a way for this to show up in the progress bar. This should be solvable with a new state in
Vertexstatus structure. This part could be possible as a separate step and should be possibly done first as there may be a problem with backward compatibility with old clients.As another follow-up resource manager should be able to return debug info. With analysis of that info, it should be possible to predict if the build needs more CPU, iops, memory etc and how much faster it would have been on a machine with different capabilities.
@vladaionescu @AkihiroSuda @hinshun @aaronlehmann @crazy-max