Konrad Kowalski (rootsher)Principal Platform & Reliability Architect101010111111110001011101100011001101110111101111

CPU Limits and throttling: what a CPU limit really does

date
category
Containers
also in
Capacity & Performance
reading
5 min / 970 words

requests.cpu tells Kubernetes how much CPU a workload declares it needs.

limits.cpu answers a completely different question:

text
how much CPU may the workload use at most

On Linux a CPU limit is enforced through cgroups and CPU throttling. Kubernetes does not "slow down" the process itself. The kubelet and the runtime configure the cgroup, and the kernel makes sure the workload does not exceed the CPU bandwidth it was given.

What matters most, though, is how the kernel understands a limit.


limits.cpu: 1 does not mean one core

Assume:

yaml
resources:
  limits:
    cpu: "1"

The natural interpretation:

text
the container gets one CPU

That is not exactly what happens.

The kernel does not have to assign the container to:

text
CPU 4

and keep it there permanently.

A limit defines the maximum amount of CPU time a cgroup may consume within a given time window.

So it is more like:

text
you may consume the equivalent of one CPU

than:

text
you may only execute on one specific CPU

That is a meaningful difference.


How limits.cpu turns into quota

The Kubernetes limit:

yaml
resources:
  limits:
    cpu: 500m

does not reach the kernel as the value 500m.

The kernel works with CPU time.

In cgroup v2 the CPU limiting mechanism lives in:

text
cpu.max

The format looks like this:

text
MAX PERIOD

Both values, when MAX is a number, are expressed in microseconds.

The relationship can be simplified to:

text
quota = CPU limit x period

If the period is:

text
100 ms

then for:

text
limit = 0.5 CPU

we get:

text
quota = 0.5 x 100 ms
      = 50 ms CPU time

That is:

text
limit  = 500m
period = 100 ms
quota  = 50 ms

In cpu.max this will be represented as:

text
50000 100000

that is:

text
quota  = 50 000 us
period = 100 000 us

So the general relationship is very simple:

text
CPU limit = quota / period

and the other way round:

text
quota = CPU limit x period

With a 100 ms period:

text
limit      quota

250m   ->   25 ms
500m   ->   50 ms
1 CPU  ->  100 ms
2 CPU  ->  200 ms
4 CPU  ->  400 ms

At first glance:

text
quota  = 200 ms
period = 100 ms

may look odd.

How can you consume 200 ms of time within 100 ms?

Through parallelism.

Two threads executing simultaneously for 100 ms:

text
CPU 0: 100 ms
CPU 1: 100 ms

consume a total of:

text
200 ms CPU time

even though only:

text
100 ms wall-clock time

has passed.

That is why a 2 CPU limit can be represented as:

text
quota  = 200 ms
period = 100 ms

So limit is an abstraction of the Kubernetes API.

quota and period are the mechanism through which the kernel enforces that value.


And what if there is no CPU limit?

Quota does not have to be finite.

In cgroup v2 cpu.max may look like this:

text
max 100000

max is a special value meaning:

text
no maximum quota

So the CPU bandwidth controller does not impose a hard CPU-time limit on that cgroup.

Compare:

text
50000 100000

which means:

text
quota  = 50 ms
period = 100 ms
limit  = 0.5 CPU

with:

text
max 100000

which means:

text
no CPU bandwidth limit

In the second case the workload will not be throttled because of exhausted quota in cpu.max.

It may of course fail to get CPU for other reasons - for example because other runnable tasks also want to execute - but that does not come from a CPU limit.


CPU time can be consumed in parallel

This is the most important detail of the whole mechanism.

Assume a limit of:

text
1 CPU

and a period of:

text
100 ms

So we have:

text
quota = 100 ms CPU time

The container has one CPU-bound thread.

It may look like this:

text
0 ms ----------------------------- 100 ms

CPU:
[========== 100 ms ===============]

The quota lasts for the whole period.

But now the application has four runnable threads.

If the scheduler executes them in parallel on four CPUs:

text
CPU 0: [ T1 ]
CPU 1: [ T2 ]
CPU 2: [ T3 ]
CPU 3: [ T4 ]

every millisecond of wall-clock time may cost about:

text
4 ms CPU time

So the quota of:

text
100 ms

may be consumed after about:

text
25 ms wall-clock time

Then:

text
0 ms       25 ms                         100 ms
|-----------|-------------------------------|
   running             throttled

Four threads:

text
4 x 25 ms = 100 ms CPU time

consumed the entire budget corresponding to a 1 CPU limit.

That is exactly why:

text
limit = 1 CPU

does not mean:

text
one thread runs along quietly the whole time

A multithreaded workload can burn through the whole budget very quickly.


What happens once the quota is used up

If a cgroup consumes its available bandwidth before the period ends, the kernel throttles it.

That means the runnable tasks belonging to that cgroup cannot continue executing on a CPU even though they have work.

They wait until bandwidth becomes available again.

Example:

text
period = 100 ms
quota  = 50 ms

The workload uses all:

text
50 ms CPU time

before the period ends.

Simplified:

text
|---------- period: 100 ms ----------|

| running 50 ms | throttled 50 ms   |

At the start of the next period the workload receives CPU bandwidth again.

So the cycle may look like this:

text
RUN -> THROTTLE -> RUN -> THROTTLE

A CPU limit is a hard bandwidth cap, enforced by the kernel once the available budget is used up.


Free CPU on the node changes nothing

This often surprises people.

Assume a node:

text
32 CPU

Current utilisation:

text
3 CPU

So a huge part of the machine is free.

But the container has:

yaml
limits:
  cpu: "1"

If it uses up its CPU bandwidth, the kernel may throttle it even though right next to it there are:

text
29 free CPUs

Why?

Because a limit does not describe a current shortage of the resource.

It describes the maximum bandwidth allowed for that cgroup.

That is the fundamental difference between:

text
CPU contention

and:

text
CPU throttling

Under throttling the CPU may physically be available.

The process cannot use it because it has exhausted the budget derived from its limit.


A 500m limit

Assume:

yaml
resources:
  limits:
    cpu: 500m

With a 100 ms period:

text
quota  = 50 ms
period = 100 ms

So the workload may on average consume:

text
50 ms CPU time / 100 ms wall time
=
0.5 CPU

That does not necessarily mean:

text
RUN 50 ms
WAIT 50 ms

The kernel accounts CPU time for the whole group.

So it may be:

text
1 thread x 50 ms

or:

text
2 threads x 25 ms

or:

text
4 threads x 12.5 ms

In every case the total is:

text
50 ms CPU time

How the quota gets consumed depends on when the tasks are runnable and how many of them execute in parallel.


The limit is accounted for the cgroup, not a single thread

Assume a container with:

text
limit = 1 CPU

and eight workers:

text
T1
T2
T3
T4
T5
T6
T7
T8

They do not each get:

text
1 CPU

They share their cgroup's budget.

If many of them execute in parallel, together they consume the quota.

This matters for multithreaded applications: the limit applies to the whole workload covered by a given cgroup, not to every thread separately.


How to see throttling

A cgroup exposes statistics in:

text
cpu.stat

In cgroup v2 we can see, among others:

text
nr_periods
nr_throttled
throttled_usec

nr_periods says how many enforcement periods have elapsed.

nr_throttled says in how many periods the group was throttled.

throttled_usec shows the cumulative throttling time.

For example:

text
usage_usec     245231843
user_usec      220012391
system_usec     25219452

nr_periods          10000
nr_throttled         3200
throttled_usec   48231000

The mere fact that:

text
nr_throttled > 0

does not yet mean there is a problem.

It does tell you that the workload really was reaching the configured CPU bandwidth limit.


Request and limit do two different things

After the previous article we can put these mechanisms side by side without mixing up their meanings.

Request:

text
requests.cpu
     |
     v
declared demand
+
relative CPU weight

Limit:

text
limits.cpu
     |
     v
maximum CPU bandwidth
     |
     v
quota / period
     |
     v
throttling once the budget is used up

The most important distinction:

text
request:
how to treat the workload during placement
and competition for CPU

limit:
how much CPU time the workload
is allowed to consume at most

Mental model

For:

yaml
resources:
  limits:
    cpu: "1"

do not think:

text
the container has one core

Think:

text
the container has a CPU-time budget,
accounted over consecutive periods

If the budget is used up:

text
the task still has work
        |
        v
the task is runnable
        |
        v
quota exhausted
        |
        v
THROTTLED

And if:

text
cpu.max = max 100000

then there is no finite quota the workload could exhaust.

And that is the essence of a CPU limit:

text
a CPU limit does not determine where execution happens.

a CPU limit determines maximum CPU bandwidth.