Skip to content
About
Main content

Autoscaler flapping and the effect of dead time

Simulate a modelNeeds Python

Problem

An autoscaler is a feedback loop: it measures utilisation, adds or removes machines, and measures again. If machines take d minutes to arrive, the loop acts on old information. It keeps adding machines while the ones it already asked for are on their way, overshoots, then removes too many, and the fleet oscillates. Separate thresholds for scaling up and down () and a slow scale-down damp the loop by making it act less on noise and less hastily in the costly direction.

Simulate an autoscaler that targets 60% on a metric averaged over 1 minute, with a provisioning delay of d minutes. Drive it with constant demand and plot fleet size for d = 0, 2 and 5 minutes. Then use separate up and down thresholds with a slow scale-down.

The question. Does the fleet oscillate under constant demand as the provisioning delay grows, and does hysteresis with slow scale-down stop it?


Predict

Under constant demand, does the fleet swing more with a 5-minute provisioning delay than with a 2-minute one?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    autoscaler-flapping-and-the-effect-of.py55 linesDownload
    View the code
    autoscaler-flapping-and-the-effect-of.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Autoscaler flapping and the effect of delay.
    
    An autoscaler targets 60% utilization on a 1-minute average, and new
    machines arrive d minutes after they are ordered. Constant demand; fleet size
    over time for d = 0, 2 and 5 minutes; then separate up and down thresholds
    with a slow scale-down.
    """
    import argparse, json, math
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--demand", type=float, default=600, help="Constant demand, in units one machine serves at full load.")
    p.add_argument("--start", type=int, default=400, help="Machines at the start.")
    p.add_argument("--minutes", type=int, default=120, help="Simulated minutes.")
    p.add_argument("--delays", type=lambda s: [int(v) for v in s.split(",")], default=[0, 2, 5], help="Provisioning delays d, minutes.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    TARGET = 0.6
    
    def run(d, hysteresis):
        """The controller sees only utilization, which cannot exceed 100%: an under-sized fleet hides how much it is short."""
        fleet, pending, rows, history = float(args.start), [], [], []
        for m in range(args.minutes):
            fleet += sum(n for t, n in pending if t == m); pending = [(t, n) for t, n in pending if t > m]
            history.append(min(1.0, args.demand / fleet))
            util = history[-2] if len(history) > 1 else history[-1]          # the average over the last full minute: one minute old
            desired = fleet * util / TARGET
            on_order = sum(n for _, n in pending)
            if hysteresis:
                if util > 0.7: pending.append((m + d, max(0.0, desired - fleet - on_order)))   # counts what is already on order
                elif util < 0.5: fleet -= (fleet - desired) * 0.1                              # slow scale-down
            else:
                if desired > fleet: pending.append((m + d, desired - fleet))                    # ignores what is on order
                else: fleet = desired                                                           # scale-down is immediate
            rows.append({"minute": m, "fleet": fleet, "delay": d, "policy": "hysteresis + slow scale-down" if hysteresis else "single target"})
        tail = [r["fleet"] for r in rows[-40:]]
        return rows, max(tail) - min(tail)
    
    rows, summary = [], []
    for d in args.delays:
        r, swing = run(d, False); rows += r
        summary.append({"delay": d, "policy": "single target", "swing": swing})
        print(f"d = {d} min, single target: fleet swings by {swing:6.0f} machines in the last 40 minutes")
    r, swing = run(max(args.delays), True); rows += r
    summary.append({"delay": max(args.delays), "policy": "hysteresis + slow scale-down", "swing": swing})
    print(f"d = {max(args.delays)} min, hysteresis + slow scale-down: swing {swing:.0f}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"fleet": rows, "summary": summary}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run autoscaler-flapping-and-the-effect-of.py
    uv run autoscaler-flapping-and-the-effect-of.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--demand600 Constant demand, in units one machine serves at 100%.
--start400 Machines at the start.
--minutes120 Simulated minutes.
--delays0, 2, 5 Provisioning delays d, minutes.

Our run

Fleet size under constant demandApple M2 · 8 cores · 8 GB
025
Policy
Data table
Fleet size under constant demand
MinuteseriesMachines
00400
10400
20400
30400
40400
50400
60400
70400
80400
90400
100400
110400
120400
130400
140400
150400
160400
170400
180400
190400
200400
210400
220400
230400
240400
250400
260400
270400
280400
290400
300400
310400
320400
330400
340400
350400
360400
370400
380400
390400
400400
410400
420400
430400
440400
450400
460400
470400
480400
490400
500400
510400
520400
530400
540400
550400
560400
570400
580400
590400
600400
610400
620400
630400
640400
650400
660400
670400
680400
690400
700400
710400
720400
730400
740400
750400
760400
770400
780400
790400
800400
810400
820400
830400
840400
850400
860400
870400
880400
890400
900400
910400
920400
930400
940400
950400
960400
970400
980400
990400
1000400
1010400
1020400
1030400
1040400
1050400
1060400
1070400
1080400
1090400
1100400
1110400
1120400
1130400
1140400
1150400
1160400
1170400
1180400
1190400
02400
12400
22667
32933
421,380
521,340
62779
72542
82542
92542
102696
1121,060
1221,440
1321,250
142657
152526
162526
172526
182800
1921,150
2021,460
2121,040
222594
232571
242571
252571
262952
2721,330
2821,480
292785
302508
312508
322508
332648
342987
3521,420
3621,380
372715
382512
392512
402512
412716
4221,060
4321,450
4421,220
452652
462534
472534
482534
492820
5021,180
5121,460
5221,000
532581
542580
552580
562580
572967
5821,350
5921,480
602762
612500
622500
632500
642657
652990
6621,430
6721,360
682707
692514
702514
712514
722727
7321,070
7421,450
7521,190
762643
772539
782539
792539
802838
8121,200
8221,470
832967
842570
852570
862570
872589
882969
8921,360
9021,470
912757
922498
932498
942498
952658
962991
9721,430
9821,360
992706
1002515
1012515
1022515
1032728
10421,070
10521,450
10621,190
1072642
1082540
1092540
1102540
1112840
11221,200
11321,470
1142963
1152568
1162568
1172568
1182590
1192969
05400
15400
25400
35400
45400
55667
65933
751,200
851,220
951,020
105980
115991
125744
135691
145691
155691
165691
175691
185691
195928
2051,240
2151,250
2251,010
235845
245957
255835
265794
275794
285794
295794
305794
315794
325950
3351,160
3451,180
3551,020
365883
375924
385872
395872
405872
415872
425872
435872
445885
4551,010
4651,130
4751,100
485978
495903
505933
515902
525902
535902
545902
555902
565902
575967
5851,060
5951,090
6051,020
615943
625934
635927
645927
655927
665927
675927
685927
695955
7051,030
7151,070
7251,040
735972
745942
755945
765945
775945
785945
795945
805945
815955
8251,010
8351,050
8451,040
855989
865952
875958
885958
895958
905958
915958
925958
935958
9451,000
9551,040
9651,040
975999
985961
995966
1005961
1015961
1025961
1035961
1045961
1055961
1065995
10751,030
10851,040
10951,000
1105968
1115967
1125964
1135964
1145964
1155964
1165964
1175964
1185991
11951,030
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Swing in the last 40 minutes (machines)

Swing in the last 40 minutes (machines)
Delay (min)PolicySwing
0single target0
2single target976
5single target109
5hysteresis + slow scale-down0
Output · uv run autoscaler-flapping-and-the-effect-of.py
d = 0 min, single target: fleet swings by      0 machines in the last 40 minutes
d = 2 min, single target: fleet swings by    976 machines in the last 40 minutes
d = 5 min, single target: fleet swings by    109 machines in the last 40 minutes
d = 5 min, hysteresis + slow scale-down: swing 0

What we found

With no delay the fleet settled and stopped (0 machines of swing in the last 40 minutes). With a 2-minute delay it oscillated hard, swinging by 976 machines; with a 5-minute delay it swung by 109, less than at 2 minutes, which the expectation (oscillation growing with d) does not predict; the chart shows a slower, smaller cycle at 5 minutes, and this run does not establish why. Hysteresis with a slow scale-down removed the oscillation at d = 5 (swing 0).


Read it

Delay in a feedback loop turns correction into oscillation; the fix is to act less on each measurement, not to measure more often. Separate thresholds leave a band where the loop does nothing, and a slow scale-down makes the expensive mistake (removing machines that will be needed) rare. The exact swing depends on the delay, the averaging window and the gain; the existence of the oscillation does not.