desna-interval 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- desna_interval/__init__.py +77 -0
- desna_interval/cli.py +96 -0
- desna_interval/estimators.py +270 -0
- desna_interval/libvirt_loop.py +202 -0
- desna_interval/py.typed +0 -0
- desna_interval/replay.py +192 -0
- desna_interval/rule.py +166 -0
- desna_interval/scheduler.py +110 -0
- desna_interval-0.0.1.dist-info/METADATA +175 -0
- desna_interval-0.0.1.dist-info/RECORD +13 -0
- desna_interval-0.0.1.dist-info/WHEEL +4 -0
- desna_interval-0.0.1.dist-info/entry_points.txt +3 -0
- desna_interval-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Monitoring interval with a guarantee on the accumulated deficit.
|
|
2
|
+
|
|
3
|
+
rule closed-form interval rule, aged snapshots, expected-cost optimum
|
|
4
|
+
estimators upper bounds on the demand growth rate, Kalman adapter
|
|
5
|
+
scheduler per-object or common polling schedule
|
|
6
|
+
replay offline comparison of polling policies on a trace
|
|
7
|
+
libvirt_loop live loop for KVM guests (needs libvirt-python)
|
|
8
|
+
|
|
9
|
+
>>> from desna_interval import next_interval_aged
|
|
10
|
+
>>> round(next_interval_aged(s=256.0, v=128.0, eps=128.0, age=1.0), 3)
|
|
11
|
+
2.414
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .estimators import (
|
|
15
|
+
FixedRate,
|
|
16
|
+
KalmanRate,
|
|
17
|
+
RateEstimator,
|
|
18
|
+
RateFilter,
|
|
19
|
+
RateFilterAdapter,
|
|
20
|
+
WindowQuantile,
|
|
21
|
+
burst_rate,
|
|
22
|
+
growth_rates,
|
|
23
|
+
history_quantile,
|
|
24
|
+
per_series_quantiles,
|
|
25
|
+
pooled_quantile,
|
|
26
|
+
)
|
|
27
|
+
from .replay import (
|
|
28
|
+
BackoffPolicy,
|
|
29
|
+
DeficitRulePolicy,
|
|
30
|
+
ExpectedCostPolicy,
|
|
31
|
+
FixedPolicy,
|
|
32
|
+
ReplayResult,
|
|
33
|
+
replay,
|
|
34
|
+
)
|
|
35
|
+
from .rule import (
|
|
36
|
+
blind_deficit_bound,
|
|
37
|
+
burst_deficit_integral,
|
|
38
|
+
expected_cost_rate,
|
|
39
|
+
interval_unclipped,
|
|
40
|
+
min_slack,
|
|
41
|
+
next_interval,
|
|
42
|
+
next_interval_aged,
|
|
43
|
+
optimal_interval_expected,
|
|
44
|
+
)
|
|
45
|
+
from .scheduler import MonitorScheduler
|
|
46
|
+
|
|
47
|
+
__version__ = "0.0.1"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"BackoffPolicy",
|
|
51
|
+
"DeficitRulePolicy",
|
|
52
|
+
"ExpectedCostPolicy",
|
|
53
|
+
"FixedPolicy",
|
|
54
|
+
"FixedRate",
|
|
55
|
+
"KalmanRate",
|
|
56
|
+
"MonitorScheduler",
|
|
57
|
+
"RateEstimator",
|
|
58
|
+
"RateFilter",
|
|
59
|
+
"RateFilterAdapter",
|
|
60
|
+
"ReplayResult",
|
|
61
|
+
"WindowQuantile",
|
|
62
|
+
"__version__",
|
|
63
|
+
"blind_deficit_bound",
|
|
64
|
+
"burst_deficit_integral",
|
|
65
|
+
"burst_rate",
|
|
66
|
+
"expected_cost_rate",
|
|
67
|
+
"growth_rates",
|
|
68
|
+
"history_quantile",
|
|
69
|
+
"interval_unclipped",
|
|
70
|
+
"min_slack",
|
|
71
|
+
"next_interval",
|
|
72
|
+
"next_interval_aged",
|
|
73
|
+
"optimal_interval_expected",
|
|
74
|
+
"per_series_quantiles",
|
|
75
|
+
"pooled_quantile",
|
|
76
|
+
"replay",
|
|
77
|
+
]
|
desna_interval/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Command line: ``desna-interval {interval,min-slack,optimal,replay}``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .estimators import FixedRate, WindowQuantile, burst_rate, history_quantile
|
|
13
|
+
from .replay import BackoffPolicy, DeficitRulePolicy, FixedPolicy, PollingPolicy, replay
|
|
14
|
+
from .rule import min_slack, next_interval_aged, optimal_interval_expected
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parser() -> argparse.ArgumentParser:
|
|
18
|
+
ap = argparse.ArgumentParser(
|
|
19
|
+
prog="desna-interval",
|
|
20
|
+
description="Monitoring interval from a tolerance on accumulated deficit.",
|
|
21
|
+
)
|
|
22
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
23
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
24
|
+
|
|
25
|
+
p = sub.add_parser("interval", help="delta* = s/v + sqrt(2 eps/v) - age, clipped")
|
|
26
|
+
p.add_argument("--slack", type=float, required=True, help="slack s, MiB")
|
|
27
|
+
p.add_argument("--rate", type=float, required=True, help="growth bound v, MiB/s")
|
|
28
|
+
p.add_argument("--eps", type=float, required=True, help="tolerance eps, MiB*s")
|
|
29
|
+
p.add_argument("--age", type=float, default=0.0, help="snapshot age, s")
|
|
30
|
+
p.add_argument("--dmin", type=float, default=1.0)
|
|
31
|
+
p.add_argument("--dmax", type=float, default=10.0)
|
|
32
|
+
|
|
33
|
+
p = sub.add_parser("min-slack", help="smallest slack admitting interval delta")
|
|
34
|
+
p.add_argument("--delta", type=float, required=True)
|
|
35
|
+
p.add_argument("--rate", type=float, required=True)
|
|
36
|
+
p.add_argument("--eps", type=float, required=True)
|
|
37
|
+
|
|
38
|
+
p = sub.add_parser("optimal", help="interval minimising expected cost")
|
|
39
|
+
p.add_argument("--slack", type=float, required=True)
|
|
40
|
+
p.add_argument("--rate", type=float, required=True)
|
|
41
|
+
p.add_argument("--rho", type=float, required=True, help="burst onset rate, 1/s")
|
|
42
|
+
p.add_argument("--cost", type=float, required=True, help="price of one poll, MiB*s")
|
|
43
|
+
p.add_argument("--dmin", type=float, default=1.0)
|
|
44
|
+
p.add_argument("--dmax", type=float, default=10.0)
|
|
45
|
+
|
|
46
|
+
p = sub.add_parser("replay", help="compare policies on a CSV trace with columns t,d")
|
|
47
|
+
p.add_argument("csv", help="CSV file: header, then time (s) and demand (MiB)")
|
|
48
|
+
p.add_argument("--eps", type=float, default=64.0)
|
|
49
|
+
p.add_argument("--slack", type=float, default=256.0)
|
|
50
|
+
p.add_argument("--fixed", type=float, nargs="*", default=[1.0, 2.0, 5.0, 10.0])
|
|
51
|
+
return ap
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _replay(args: argparse.Namespace) -> None:
|
|
55
|
+
data = np.loadtxt(args.csv, delimiter=",", skiprows=1, ndmin=2)
|
|
56
|
+
t, d = data[:, 0], data[:, 1]
|
|
57
|
+
v = history_quantile(t, d)
|
|
58
|
+
rho = burst_rate(t, d)
|
|
59
|
+
print(f"trace: {t[-1] - t[0]:.0f} s, v(q=0.99) = {v:.1f} MiB/s, rho = {rho:.4f} 1/s")
|
|
60
|
+
print("policy,polls,mean_interval_s,deficit_mib_s,violation_share")
|
|
61
|
+
policies: list[tuple[str, PollingPolicy]] = [
|
|
62
|
+
(f"fixed_{x:g}s", FixedPolicy(x)) for x in args.fixed
|
|
63
|
+
]
|
|
64
|
+
policies += [
|
|
65
|
+
("backoff", BackoffPolicy()),
|
|
66
|
+
("rule_history_v", DeficitRulePolicy(args.eps, args.slack, FixedRate(v))),
|
|
67
|
+
("rule_window_v", DeficitRulePolicy(args.eps, args.slack, WindowQuantile())),
|
|
68
|
+
]
|
|
69
|
+
for name, pol in policies:
|
|
70
|
+
r = replay(t, d, pol, args.slack)
|
|
71
|
+
print(
|
|
72
|
+
f"{name},{r.polls},{r.mean_interval:.3f},{r.deficit:.1f},"
|
|
73
|
+
f"{r.violation_share(args.eps):.4f}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
78
|
+
"""Run the command line and return the exit status."""
|
|
79
|
+
args = _parser().parse_args(argv)
|
|
80
|
+
if args.cmd == "interval":
|
|
81
|
+
iv = next_interval_aged(args.slack, args.rate, args.eps, args.age, args.dmin, args.dmax)
|
|
82
|
+
print(f"{iv:.10g}")
|
|
83
|
+
elif args.cmd == "min-slack":
|
|
84
|
+
print(f"{min_slack(args.delta, args.rate, args.eps):.10g}")
|
|
85
|
+
elif args.cmd == "optimal":
|
|
86
|
+
iv = optimal_interval_expected(
|
|
87
|
+
args.slack, args.rate, args.rho, args.cost, args.dmin, args.dmax
|
|
88
|
+
)
|
|
89
|
+
print(f"{iv:.10g}")
|
|
90
|
+
else:
|
|
91
|
+
_replay(args)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
sys.exit(main())
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Upper bounds on the demand growth rate ``v`` used by the interval rule.
|
|
2
|
+
|
|
3
|
+
The guarantee holds only if ``v`` also bounds growth at the start of a new
|
|
4
|
+
burst. The main estimate is therefore a robust quantile over a long history.
|
|
5
|
+
The window estimate tracks the current regime. An external estimator, for
|
|
6
|
+
example a Kalman filter, plugs in through :class:`RateFilterAdapter`;
|
|
7
|
+
:class:`KalmanRate` is a small built-in one.
|
|
8
|
+
|
|
9
|
+
Every online estimator exposes ``update(t, d)`` and ``upper() -> float``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Iterable, Sequence
|
|
17
|
+
from typing import Protocol
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import numpy.typing as npt
|
|
21
|
+
|
|
22
|
+
FloatArray = npt.NDArray[np.float64]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateEstimator(Protocol):
|
|
26
|
+
"""Online estimator of an upper bound on the growth rate."""
|
|
27
|
+
|
|
28
|
+
def update(self, t: float, d: float) -> None:
|
|
29
|
+
"""Feed one observation: time ``t`` and demand ``d``."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def upper(self) -> float:
|
|
33
|
+
"""Return the current upper bound on the growth rate."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RateFilter(Protocol):
|
|
38
|
+
"""External filter, e.g. a Kalman filter, returning ``(rate, variance)``."""
|
|
39
|
+
|
|
40
|
+
def update(self, t: float, d: float) -> None:
|
|
41
|
+
"""Feed one observation: time ``t`` and demand ``d``."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def rate(self) -> tuple[float, float]:
|
|
45
|
+
"""Estimated growth rate and its variance."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WindowQuantile:
|
|
50
|
+
"""Quantile of growth rates over the last ``window`` seconds.
|
|
51
|
+
|
|
52
|
+
``q = 1.0`` gives the maximum rate in the window. Decreasing demand counts
|
|
53
|
+
as zero growth. The estimate is bounded below by ``v_floor``.
|
|
54
|
+
|
|
55
|
+
>>> est = WindowQuantile(window=30.0, q=1.0, v_floor=0.0)
|
|
56
|
+
>>> for k in range(10):
|
|
57
|
+
... est.update(float(k), 50.0 * k)
|
|
58
|
+
>>> est.upper()
|
|
59
|
+
50.0
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, window: float = 300.0, q: float = 1.0, v_floor: float = 2.0) -> None:
|
|
63
|
+
self.window, self.q, self.v_floor = float(window), float(q), float(v_floor)
|
|
64
|
+
self._obs: deque[tuple[float, float]] = deque()
|
|
65
|
+
|
|
66
|
+
def update(self, t: float, d: float) -> None:
|
|
67
|
+
"""Feed one observation: time ``t`` and demand ``d``."""
|
|
68
|
+
self._obs.append((float(t), float(d)))
|
|
69
|
+
while self._obs and self._obs[0][0] < t - self.window:
|
|
70
|
+
self._obs.popleft()
|
|
71
|
+
|
|
72
|
+
def upper(self) -> float:
|
|
73
|
+
"""Return the current upper bound on the growth rate."""
|
|
74
|
+
if len(self._obs) < 2:
|
|
75
|
+
return self.v_floor
|
|
76
|
+
arr = np.asarray(self._obs, dtype=np.float64)
|
|
77
|
+
steps = np.diff(arr[:, 0])
|
|
78
|
+
ok = steps > 0
|
|
79
|
+
if not ok.any():
|
|
80
|
+
return self.v_floor
|
|
81
|
+
rates = np.maximum(np.diff(arr[:, 1])[ok], 0.0) / steps[ok]
|
|
82
|
+
return max(float(np.quantile(rates, self.q)), self.v_floor)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FixedRate:
|
|
86
|
+
"""Constant bound, e.g. a robust quantile computed offline.
|
|
87
|
+
|
|
88
|
+
>>> FixedRate(128.0).upper()
|
|
89
|
+
128.0
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, v: float) -> None:
|
|
93
|
+
self.v = float(v)
|
|
94
|
+
|
|
95
|
+
def update(self, t: float, d: float) -> None:
|
|
96
|
+
"""Feed one observation: time ``t`` and demand ``d``."""
|
|
97
|
+
|
|
98
|
+
def upper(self) -> float:
|
|
99
|
+
"""Return the current upper bound on the growth rate."""
|
|
100
|
+
return self.v
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class KalmanRate:
|
|
104
|
+
"""Scalar Kalman filter of demand level and growth rate (constant velocity).
|
|
105
|
+
|
|
106
|
+
State ``(level, rate)``; the level is observed with variance ``r``. The
|
|
107
|
+
rate is a random walk driven by white acceleration of intensity ``q``.
|
|
108
|
+
Use it with :class:`RateFilterAdapter`, which turns the output into the
|
|
109
|
+
upper bound ``rate + z*sqrt(variance)``.
|
|
110
|
+
|
|
111
|
+
>>> kf = KalmanRate(q=1.0, r=1.0)
|
|
112
|
+
>>> for k in range(200):
|
|
113
|
+
... kf.update(float(k), 30.0 * k)
|
|
114
|
+
>>> round(kf.rate()[0], 2)
|
|
115
|
+
30.0
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, q: float = 1.0, r: float = 25.0) -> None:
|
|
119
|
+
if q < 0.0 or r <= 0.0:
|
|
120
|
+
msg = "q must be non-negative and r positive"
|
|
121
|
+
raise ValueError(msg)
|
|
122
|
+
self.q, self.r = float(q), float(r)
|
|
123
|
+
self._t: float | None = None
|
|
124
|
+
self._x = (0.0, 0.0)
|
|
125
|
+
self._p = ((r, 0.0), (0.0, 1e4))
|
|
126
|
+
|
|
127
|
+
def update(self, t: float, d: float) -> None:
|
|
128
|
+
"""Feed one observation: time ``t`` and demand ``d``."""
|
|
129
|
+
if self._t is None:
|
|
130
|
+
self._t, self._x = float(t), (float(d), 0.0)
|
|
131
|
+
return
|
|
132
|
+
dt = float(t) - self._t
|
|
133
|
+
if dt <= 0.0:
|
|
134
|
+
return
|
|
135
|
+
self._t = float(t)
|
|
136
|
+
(p00, p01), (_, p11) = self._p
|
|
137
|
+
# predict with F = [[1, dt], [0, 1]] and Q = q * [[dt^3/3, dt^2/2], [dt^2/2, dt]]
|
|
138
|
+
x0 = self._x[0] + dt * self._x[1]
|
|
139
|
+
x1 = self._x[1]
|
|
140
|
+
q = self.q
|
|
141
|
+
p00n = p00 + dt * (p01 + p01) + dt * dt * p11 + q * dt**3 / 3.0
|
|
142
|
+
p01n = p01 + dt * p11 + q * dt * dt / 2.0
|
|
143
|
+
p11n = p11 + q * dt
|
|
144
|
+
# update with H = [1, 0]
|
|
145
|
+
s = p00n + self.r
|
|
146
|
+
k0, k1 = p00n / s, p01n / s
|
|
147
|
+
innov = float(d) - x0
|
|
148
|
+
self._x = (x0 + k0 * innov, x1 + k1 * innov)
|
|
149
|
+
self._p = (
|
|
150
|
+
(p00n - k0 * p00n, p01n - k0 * p01n),
|
|
151
|
+
(p01n - k1 * p00n, p11n - k1 * p01n),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def rate(self) -> tuple[float, float]:
|
|
155
|
+
"""Estimated growth rate and its variance."""
|
|
156
|
+
return self._x[1], self._p[1][1]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RateFilterAdapter:
|
|
160
|
+
"""Wraps an external rate filter: upper bound = rate + z * sqrt(variance).
|
|
161
|
+
|
|
162
|
+
>>> adapter = RateFilterAdapter(KalmanRate(q=0.0, r=1.0), z=0.0, v_floor=0.0)
|
|
163
|
+
>>> for k in range(50):
|
|
164
|
+
... adapter.update(float(k), 20.0 * k)
|
|
165
|
+
>>> round(adapter.upper(), 1)
|
|
166
|
+
20.0
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, filt: RateFilter, z: float = 2.33, v_floor: float = 2.0) -> None:
|
|
170
|
+
self.filt, self.z, self.v_floor = filt, float(z), float(v_floor)
|
|
171
|
+
|
|
172
|
+
def update(self, t: float, d: float) -> None:
|
|
173
|
+
"""Feed one observation: time ``t`` and demand ``d``."""
|
|
174
|
+
self.filt.update(t, d)
|
|
175
|
+
|
|
176
|
+
def upper(self) -> float:
|
|
177
|
+
"""Return the current upper bound on the growth rate."""
|
|
178
|
+
v, var = self.filt.rate()
|
|
179
|
+
return max(v + self.z * math.sqrt(max(var, 0.0)), self.v_floor)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def growth_rates(t: npt.ArrayLike, d: npt.ArrayLike, step: float = 1.0) -> FloatArray:
|
|
183
|
+
"""Non-negative growth rates over a regular grid with spacing ``step``.
|
|
184
|
+
|
|
185
|
+
>>> growth_rates([0.0, 10.0], [0.0, 50.0], step=5.0).tolist()
|
|
186
|
+
[5.0]
|
|
187
|
+
"""
|
|
188
|
+
tt = np.asarray(t, dtype=np.float64)
|
|
189
|
+
dd = np.asarray(d, dtype=np.float64)
|
|
190
|
+
grid = np.arange(tt[0], tt[-1], step)
|
|
191
|
+
if grid.size < 2:
|
|
192
|
+
return np.empty(0, dtype=np.float64)
|
|
193
|
+
return np.maximum(np.diff(np.interp(grid, tt, dd)), 0.0) / step
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def history_quantile(
|
|
197
|
+
t: npt.ArrayLike, d: npt.ArrayLike, q: float = 0.99, step: float = 1.0, v_floor: float = 2.0
|
|
198
|
+
) -> float:
|
|
199
|
+
"""Robust bound from a long history: the q-quantile of growth over ``step``.
|
|
200
|
+
|
|
201
|
+
>>> t = [0.0, 1.0, 2.0, 3.0, 4.0]
|
|
202
|
+
>>> history_quantile(t, [0.0, 30.0, 60.0, 90.0, 120.0], q=0.5)
|
|
203
|
+
30.0
|
|
204
|
+
"""
|
|
205
|
+
r = growth_rates(t, d, step)
|
|
206
|
+
return max(float(np.quantile(r, q)), v_floor) if r.size else v_floor
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pooled_quantile(
|
|
210
|
+
series: Iterable[tuple[npt.ArrayLike, npt.ArrayLike]],
|
|
211
|
+
q: float = 0.99,
|
|
212
|
+
step: float = 1.0,
|
|
213
|
+
v_floor: float = 2.0,
|
|
214
|
+
) -> float:
|
|
215
|
+
"""Return a common robust bound over several similar machines.
|
|
216
|
+
|
|
217
|
+
``series`` is an iterable of ``(t, d)`` pairs. For identical workloads a
|
|
218
|
+
pooled bound is more stable than separate per-machine bounds.
|
|
219
|
+
|
|
220
|
+
>>> t = [0.0, 1.0, 2.0]
|
|
221
|
+
>>> pooled_quantile([(t, [0.0, 10.0, 20.0]), (t, [0.0, 40.0, 80.0])], q=1.0)
|
|
222
|
+
40.0
|
|
223
|
+
"""
|
|
224
|
+
parts = [growth_rates(t, d, step) for t, d in series]
|
|
225
|
+
parts = [p for p in parts if p.size]
|
|
226
|
+
if not parts:
|
|
227
|
+
return v_floor
|
|
228
|
+
return max(float(np.quantile(np.concatenate(parts), q)), v_floor)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def per_series_quantiles(
|
|
232
|
+
t: npt.ArrayLike,
|
|
233
|
+
rows: Sequence[npt.ArrayLike],
|
|
234
|
+
q: float = 0.99,
|
|
235
|
+
step: float = 1.0,
|
|
236
|
+
v_floor: float = 2.0,
|
|
237
|
+
) -> FloatArray:
|
|
238
|
+
""":func:`history_quantile` for each row of a matrix sharing the time axis ``t``."""
|
|
239
|
+
return np.array([history_quantile(t, row, q, step, v_floor) for row in rows], dtype=np.float64)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def burst_rate(
|
|
243
|
+
t: npt.ArrayLike,
|
|
244
|
+
d: npt.ArrayLike,
|
|
245
|
+
step: float = 1.0,
|
|
246
|
+
rise: float = 128.0,
|
|
247
|
+
window: float = 30.0,
|
|
248
|
+
min_gap: float = 30.0,
|
|
249
|
+
) -> float:
|
|
250
|
+
"""Rate of burst onsets ``rho``, 1/s.
|
|
251
|
+
|
|
252
|
+
A moment is hot if demand exceeds its minimum over the last ``window``
|
|
253
|
+
seconds by more than ``rise``. Hot moments closer than ``min_gap`` belong
|
|
254
|
+
to one burst. Pick ``rise`` well above the noise range. The result feeds
|
|
255
|
+
:func:`desna_interval.rule.optimal_interval_expected`.
|
|
256
|
+
"""
|
|
257
|
+
tt = np.asarray(t, dtype=np.float64)
|
|
258
|
+
dd = np.asarray(d, dtype=np.float64)
|
|
259
|
+
grid = np.arange(tt[0], tt[-1], step)
|
|
260
|
+
if grid.size < 2:
|
|
261
|
+
return 0.0
|
|
262
|
+
dg = np.interp(grid, tt, dd)
|
|
263
|
+
w = max(1, round(window / step))
|
|
264
|
+
pad = np.concatenate([np.full(w - 1, dg[0]), dg])
|
|
265
|
+
low = np.lib.stride_tricks.sliding_window_view(pad, w).min(axis=1)
|
|
266
|
+
hot = np.flatnonzero(dg - low > rise)
|
|
267
|
+
if hot.size == 0:
|
|
268
|
+
return 0.0
|
|
269
|
+
starts = 1 + int(np.sum(np.diff(hot) * step > min_gap))
|
|
270
|
+
return starts / (grid.size * step)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Live monitoring loop for KVM guests through libvirt.
|
|
2
|
+
|
|
3
|
+
Polls domains on a :class:`~desna_interval.scheduler.MonitorScheduler`
|
|
4
|
+
schedule, computes demand, calls a controller and applies targets through
|
|
5
|
+
the balloon. Without ``apply=True`` nothing is changed (dry run).
|
|
6
|
+
Requires ``libvirt-python`` (extra ``desna-interval[libvirt]``); the module
|
|
7
|
+
itself does not import libvirt, so it can be tested with fake domains.
|
|
8
|
+
|
|
9
|
+
``memoryStats()`` keys: ``available`` is guest MemTotal, ``usable`` is guest
|
|
10
|
+
MemAvailable, ``actual`` is the current balloon size, all in KiB.
|
|
11
|
+
``last_update`` is the host time in whole seconds when QEMU received the
|
|
12
|
+
statistics from the guest.
|
|
13
|
+
|
|
14
|
+
Demand: d = (available - usable) / 1024 + reserve, MiB. Without
|
|
15
|
+
``autodeflate`` (the default) the guest kernel shrinks MemTotal by the balloon
|
|
16
|
+
size, so no correction is needed. With ``autodeflate='on'`` balloon pages stay
|
|
17
|
+
in MemTotal; set ``balloon_in_total=True`` to subtract maxMemory - actual.
|
|
18
|
+
|
|
19
|
+
Snapshot age. ``memoryStats()`` returns QEMU's last snapshot, refreshed once
|
|
20
|
+
per stats period (whole seconds, at least 1 s). Age = time.time() -
|
|
21
|
+
last_update; it is never smaller than the true age because last_update is
|
|
22
|
+
rounded down. The next interval is shortened by the age.
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
import libvirt
|
|
27
|
+
from desna_interval.libvirt_loop import run, reserve_controller
|
|
28
|
+
|
|
29
|
+
conn = libvirt.open("qemu:///system")
|
|
30
|
+
doms = {n: conn.lookupByName(n) for n in ["vm1", "vm2"]}
|
|
31
|
+
run(
|
|
32
|
+
doms,
|
|
33
|
+
reserve_controller(),
|
|
34
|
+
eps=128,
|
|
35
|
+
reserve_mib=256,
|
|
36
|
+
duration_s=7200,
|
|
37
|
+
log_path="live.csv",
|
|
38
|
+
apply=True,
|
|
39
|
+
stats_period=1,
|
|
40
|
+
)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import csv
|
|
46
|
+
import math
|
|
47
|
+
import time
|
|
48
|
+
from collections.abc import Callable, Hashable, Mapping
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from .estimators import RateEstimator, WindowQuantile
|
|
53
|
+
from .scheduler import IntervalScheduler, MonitorScheduler
|
|
54
|
+
|
|
55
|
+
# libvirt-domain.h: VIR_DOMAIN_AFFECT_LIVE = 1 << 0
|
|
56
|
+
VIR_DOMAIN_AFFECT_LIVE = 1
|
|
57
|
+
|
|
58
|
+
# (demand, allocation, freshly polled objects) -> targets in MiB for objects to change
|
|
59
|
+
Controller = Callable[
|
|
60
|
+
[dict[Hashable, float], dict[Hashable, float], set[Hashable]], dict[Hashable, float]
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_state(
|
|
65
|
+
dom: Any,
|
|
66
|
+
reserve_mib: float,
|
|
67
|
+
balloon_in_total: bool = False,
|
|
68
|
+
wall: Callable[[], float] = time.time,
|
|
69
|
+
) -> tuple[float, float, float]:
|
|
70
|
+
"""Return (allocation MiB, demand MiB, snapshot age s) of a domain.
|
|
71
|
+
|
|
72
|
+
Without the ``last_update`` key (old libvirt) the age is zero.
|
|
73
|
+
"""
|
|
74
|
+
st = dom.memoryStats()
|
|
75
|
+
actual = st["actual"] / 1024.0
|
|
76
|
+
used = (st["available"] - st["usable"]) / 1024.0
|
|
77
|
+
if balloon_in_total:
|
|
78
|
+
used -= max(dom.maxMemory() / 1024.0 - actual, 0.0)
|
|
79
|
+
age = max(wall() - float(st["last_update"]), 0.0) if "last_update" in st else 0.0
|
|
80
|
+
return actual, used + reserve_mib, age
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reserve_controller(s_target: float = 256.0, deadband: float = 64.0) -> Controller:
|
|
84
|
+
"""Build an example controller: target = demand + slack, with a dead band.
|
|
85
|
+
|
|
86
|
+
A real system's logic plugs in as a function with the same signature.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def ctrl(
|
|
90
|
+
demand: dict[Hashable, float], actual: dict[Hashable, float], fresh: set[Hashable]
|
|
91
|
+
) -> dict[Hashable, float]:
|
|
92
|
+
out: dict[Hashable, float] = {}
|
|
93
|
+
for vm in fresh:
|
|
94
|
+
target = demand[vm] + s_target
|
|
95
|
+
if actual[vm] < demand[vm] or abs(target - actual[vm]) > deadband:
|
|
96
|
+
out[vm] = target
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
return ctrl
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run(
|
|
103
|
+
domains: Mapping[Hashable, Any],
|
|
104
|
+
controller: Controller,
|
|
105
|
+
eps: float,
|
|
106
|
+
reserve_mib: float,
|
|
107
|
+
duration_s: float,
|
|
108
|
+
log_path: str | Path,
|
|
109
|
+
dmin: float = 1.0,
|
|
110
|
+
dmax: float = 10.0,
|
|
111
|
+
mode: str = "per_vm",
|
|
112
|
+
estimator_factory: Callable[[], RateEstimator] | None = None,
|
|
113
|
+
apply: bool = False,
|
|
114
|
+
balloon_in_total: bool = False,
|
|
115
|
+
stats_period: int | None = None,
|
|
116
|
+
adapt_stats_period: bool = False,
|
|
117
|
+
clock: Callable[[], float] = time.monotonic,
|
|
118
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
119
|
+
wall: Callable[[], float] = time.time,
|
|
120
|
+
scheduler: IntervalScheduler | None = None,
|
|
121
|
+
) -> int:
|
|
122
|
+
"""Run the monitoring loop. Returns the number of domain polls.
|
|
123
|
+
|
|
124
|
+
``scheduler`` replaces the default :class:`MonitorScheduler` when given, for
|
|
125
|
+
example a heuristic schedule; ``eps``, ``mode`` and the estimator are then unused.
|
|
126
|
+
"""
|
|
127
|
+
sched: IntervalScheduler
|
|
128
|
+
if scheduler is not None:
|
|
129
|
+
sched = scheduler
|
|
130
|
+
else:
|
|
131
|
+
sched = MonitorScheduler(
|
|
132
|
+
domains, eps, estimator_factory or (WindowQuantile), dmin, dmax, mode
|
|
133
|
+
)
|
|
134
|
+
period: dict[Hashable, int] = {}
|
|
135
|
+
if stats_period is not None:
|
|
136
|
+
for vm, dom in domains.items():
|
|
137
|
+
dom.setMemoryStatsPeriod(int(stats_period), VIR_DOMAIN_AFFECT_LIVE)
|
|
138
|
+
period[vm] = int(stats_period)
|
|
139
|
+
demand: dict[Hashable, float] = {}
|
|
140
|
+
actual: dict[Hashable, float] = {}
|
|
141
|
+
age: dict[Hashable, float] = {}
|
|
142
|
+
n_polls = 0
|
|
143
|
+
t0 = clock()
|
|
144
|
+
with Path(log_path).open("w", newline="") as fh:
|
|
145
|
+
w = csv.writer(fh)
|
|
146
|
+
w.writerow(
|
|
147
|
+
[
|
|
148
|
+
"t_s",
|
|
149
|
+
"vm",
|
|
150
|
+
"actual_mib",
|
|
151
|
+
"demand_mib",
|
|
152
|
+
"target_mib",
|
|
153
|
+
"applied",
|
|
154
|
+
"interval_s",
|
|
155
|
+
"age_s",
|
|
156
|
+
"stats_period_s",
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
while True:
|
|
160
|
+
now = clock() - t0
|
|
161
|
+
if now >= duration_s:
|
|
162
|
+
break
|
|
163
|
+
due = sched.due(now)
|
|
164
|
+
if not due:
|
|
165
|
+
sleep(max(min(sched.next_wakeup(), duration_s) - now, 0.0))
|
|
166
|
+
continue
|
|
167
|
+
for vm in due:
|
|
168
|
+
actual[vm], demand[vm], age[vm] = read_state(
|
|
169
|
+
domains[vm], reserve_mib, balloon_in_total, wall
|
|
170
|
+
)
|
|
171
|
+
n_polls += len(due)
|
|
172
|
+
targets = controller(demand, actual, set(due))
|
|
173
|
+
slack, applied = {}, {}
|
|
174
|
+
for vm in due:
|
|
175
|
+
target = targets.get(vm, actual[vm])
|
|
176
|
+
applied[vm] = bool(apply and vm in targets)
|
|
177
|
+
if applied[vm]:
|
|
178
|
+
domains[vm].setMemoryFlags(int(target * 1024), VIR_DOMAIN_AFFECT_LIVE)
|
|
179
|
+
slack[vm] = target - demand[vm]
|
|
180
|
+
intervals = sched.observe(
|
|
181
|
+
now, {vm: demand[vm] for vm in due}, slack, {vm: age[vm] for vm in due}
|
|
182
|
+
)
|
|
183
|
+
for vm in due:
|
|
184
|
+
if adapt_stats_period:
|
|
185
|
+
p = max(1, math.floor(intervals[vm]))
|
|
186
|
+
if period.get(vm) != p:
|
|
187
|
+
domains[vm].setMemoryStatsPeriod(p, VIR_DOMAIN_AFFECT_LIVE)
|
|
188
|
+
period[vm] = p
|
|
189
|
+
w.writerow(
|
|
190
|
+
[
|
|
191
|
+
f"{now:.3f}",
|
|
192
|
+
vm,
|
|
193
|
+
f"{actual[vm]:.1f}",
|
|
194
|
+
f"{demand[vm]:.1f}",
|
|
195
|
+
f"{targets.get(vm, actual[vm]):.1f}",
|
|
196
|
+
int(applied[vm]),
|
|
197
|
+
f"{intervals[vm]:.3f}",
|
|
198
|
+
f"{age[vm]:.3f}",
|
|
199
|
+
period.get(vm, ""),
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
return n_polls
|
desna_interval/py.typed
ADDED
|
File without changes
|
desna_interval/replay.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Offline replay of polling policies on a demand trace.
|
|
2
|
+
|
|
3
|
+
Simplified controller: on every poll at time ``p`` the allocation is reset to
|
|
4
|
+
``d(p) + slack``. Between polls the allocation stays constant and the deficit
|
|
5
|
+
is the integral of ``(d(t) - allocation)^+``. This isolates the effect of the
|
|
6
|
+
polling interval from the rest of the control logic and lets one compare
|
|
7
|
+
policies on the same trace.
|
|
8
|
+
|
|
9
|
+
A policy is any object with ``next_interval(t, d) -> float``, called with the
|
|
10
|
+
time and the observed demand of each poll.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
|
|
21
|
+
from .estimators import RateEstimator
|
|
22
|
+
from .rule import next_interval, optimal_interval_expected
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PollingPolicy(Protocol):
|
|
26
|
+
"""Protocol of a polling policy: an object with ``next_interval``."""
|
|
27
|
+
|
|
28
|
+
def next_interval(self, t: float, d: float) -> float:
|
|
29
|
+
"""Interval until the next poll, given the time and observed demand."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FixedPolicy:
|
|
34
|
+
"""Constant interval.
|
|
35
|
+
|
|
36
|
+
>>> FixedPolicy(4.0).next_interval(0.0, 1000.0)
|
|
37
|
+
4.0
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name = "fixed"
|
|
41
|
+
|
|
42
|
+
def __init__(self, delta: float) -> None:
|
|
43
|
+
self.delta = float(delta)
|
|
44
|
+
|
|
45
|
+
def next_interval(self, t: float, d: float) -> float:
|
|
46
|
+
"""Return the constant interval."""
|
|
47
|
+
return self.delta
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BackoffPolicy:
|
|
51
|
+
"""Exponential back-off, a common heuristic for polling without a bound.
|
|
52
|
+
|
|
53
|
+
The interval is multiplied by ``factor`` while demand changes by less than
|
|
54
|
+
``threshold`` between polls and is reset to ``dmin`` otherwise. It gives no
|
|
55
|
+
bound on the deficit, so it serves here as a baseline.
|
|
56
|
+
|
|
57
|
+
>>> bo = BackoffPolicy(dmin=1.0, dmax=8.0, factor=2.0, threshold=64.0)
|
|
58
|
+
>>> [bo.next_interval(float(k), 100.0) for k in range(4)]
|
|
59
|
+
[1.0, 2.0, 4.0, 8.0]
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name = "backoff"
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, dmin: float = 1.0, dmax: float = 10.0, factor: float = 2.0, threshold: float = 64.0
|
|
66
|
+
) -> None:
|
|
67
|
+
self.dmin, self.dmax, self.factor = float(dmin), float(dmax), float(factor)
|
|
68
|
+
self.threshold = float(threshold)
|
|
69
|
+
self._prev: float | None = None
|
|
70
|
+
self._iv = self.dmin
|
|
71
|
+
|
|
72
|
+
def next_interval(self, t: float, d: float) -> float:
|
|
73
|
+
"""Grow the interval while demand is calm, reset it otherwise."""
|
|
74
|
+
if self._prev is not None and abs(d - self._prev) < self.threshold:
|
|
75
|
+
self._iv = min(self._iv * self.factor, self.dmax)
|
|
76
|
+
else:
|
|
77
|
+
self._iv = self.dmin
|
|
78
|
+
self._prev = d
|
|
79
|
+
return self._iv
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DeficitRulePolicy:
|
|
83
|
+
"""The rule ``delta* = s/v + sqrt(2*eps/v)`` with an online bound on ``v``.
|
|
84
|
+
|
|
85
|
+
>>> from desna_interval.estimators import FixedRate
|
|
86
|
+
>>> pol = DeficitRulePolicy(eps=128.0, slack=256.0, estimator=FixedRate(128.0))
|
|
87
|
+
>>> round(pol.next_interval(0.0, 1000.0), 3)
|
|
88
|
+
3.414
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
name = "deficit_rule"
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
eps: float,
|
|
96
|
+
slack: float,
|
|
97
|
+
estimator: RateEstimator,
|
|
98
|
+
dmin: float = 1.0,
|
|
99
|
+
dmax: float = 10.0,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.eps, self.slack, self.est = float(eps), float(slack), estimator
|
|
102
|
+
self.dmin, self.dmax = float(dmin), float(dmax)
|
|
103
|
+
|
|
104
|
+
def next_interval(self, t: float, d: float) -> float:
|
|
105
|
+
"""Update the rate bound and return the rule interval."""
|
|
106
|
+
self.est.update(t, d)
|
|
107
|
+
return next_interval(self.slack, self.est.upper(), self.eps, self.dmin, self.dmax)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ExpectedCostPolicy:
|
|
111
|
+
"""Interval minimising the expected cost for known ``v``, ``rho`` and poll price ``c``.
|
|
112
|
+
|
|
113
|
+
>>> pol = ExpectedCostPolicy(slack=0.0, v=128.0, rho=0.01, c=64.0, dmin=0.0, dmax=99.0)
|
|
114
|
+
>>> round(pol.next_interval(0.0, 1000.0), 3)
|
|
115
|
+
5.313
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
name = "expected_cost"
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self, slack: float, v: float, rho: float, c: float, dmin: float = 1.0, dmax: float = 10.0
|
|
122
|
+
) -> None:
|
|
123
|
+
self.delta = optimal_interval_expected(slack, v, rho, c, dmin, dmax)
|
|
124
|
+
|
|
125
|
+
def next_interval(self, t: float, d: float) -> float:
|
|
126
|
+
"""Return the precomputed optimal interval."""
|
|
127
|
+
return self.delta
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ReplayResult:
|
|
132
|
+
"""Outcome of one replay."""
|
|
133
|
+
|
|
134
|
+
polls: int
|
|
135
|
+
duration: float
|
|
136
|
+
deficit: float
|
|
137
|
+
gap_deficits: npt.NDArray[np.float64] = field(repr=False)
|
|
138
|
+
intervals: npt.NDArray[np.float64] = field(repr=False)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def mean_interval(self) -> float:
|
|
142
|
+
"""Mean interval between polls, s."""
|
|
143
|
+
return self.duration / max(self.polls, 1)
|
|
144
|
+
|
|
145
|
+
def violation_share(self, eps: float) -> float:
|
|
146
|
+
"""Share of blind gaps whose deficit exceeds ``eps``."""
|
|
147
|
+
g = self.gap_deficits
|
|
148
|
+
return float(np.mean(g > eps)) if g.size else 0.0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def replay(t: npt.ArrayLike, d: npt.ArrayLike, policy: PollingPolicy, slack: float) -> ReplayResult:
|
|
152
|
+
"""Replay ``policy`` on the trace ``(t, d)`` sampled on a fine grid.
|
|
153
|
+
|
|
154
|
+
Demand is piecewise linear between samples; the deficit is integrated with
|
|
155
|
+
the trapezoidal rule on the trace grid plus the poll times.
|
|
156
|
+
|
|
157
|
+
>>> import numpy as np
|
|
158
|
+
>>> tt = np.arange(0.0, 41.0, 1.0)
|
|
159
|
+
>>> r = replay(tt, 10.0 * tt, FixedPolicy(4.0), slack=0.0)
|
|
160
|
+
>>> r.polls, r.mean_interval
|
|
161
|
+
(10, 4.0)
|
|
162
|
+
"""
|
|
163
|
+
tt = np.asarray(t, dtype=np.float64)
|
|
164
|
+
dd = np.asarray(d, dtype=np.float64)
|
|
165
|
+
if tt.ndim != 1 or tt.shape != dd.shape or tt.size < 2:
|
|
166
|
+
msg = "t and d must be 1-D arrays of equal length >= 2"
|
|
167
|
+
raise ValueError(msg)
|
|
168
|
+
if np.any(np.diff(tt) <= 0):
|
|
169
|
+
msg = "t must be strictly increasing"
|
|
170
|
+
raise ValueError(msg)
|
|
171
|
+
t_end = float(tt[-1])
|
|
172
|
+
p = float(tt[0])
|
|
173
|
+
gaps: list[float] = []
|
|
174
|
+
ivs: list[float] = []
|
|
175
|
+
while p < t_end:
|
|
176
|
+
dp = float(np.interp(p, tt, dd))
|
|
177
|
+
alloc = dp + slack
|
|
178
|
+
iv = float(policy.next_interval(p, dp))
|
|
179
|
+
if iv <= 0:
|
|
180
|
+
msg = "policy returned a non-positive interval"
|
|
181
|
+
raise ValueError(msg)
|
|
182
|
+
q = min(p + iv, t_end)
|
|
183
|
+
inner = tt[(tt > p) & (tt < q)]
|
|
184
|
+
grid = np.concatenate([[p], inner, [q]])
|
|
185
|
+
short = np.maximum(np.interp(grid, tt, dd) - alloc, 0.0)
|
|
186
|
+
gaps.append(float(np.sum((short[1:] + short[:-1]) * np.diff(grid)) / 2.0))
|
|
187
|
+
ivs.append(iv)
|
|
188
|
+
p = q
|
|
189
|
+
g = np.asarray(gaps, dtype=np.float64)
|
|
190
|
+
return ReplayResult(
|
|
191
|
+
len(gaps), t_end - float(tt[0]), float(g.sum()), g, np.asarray(ivs, dtype=np.float64)
|
|
192
|
+
)
|
desna_interval/rule.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Interval rule from a tolerance on accumulated deficit.
|
|
2
|
+
|
|
3
|
+
The accumulated deficit ``D(E) = integral over E of f dλ`` is absolutely
|
|
4
|
+
continuous with respect to time, and the instantaneous deficit ``f`` is its
|
|
5
|
+
Radon-Nikodym derivative (Halmos, *Measure Theory*, §30–31). Requirement: the
|
|
6
|
+
deficit over any blind gap between two observations stays below a tolerance
|
|
7
|
+
``eps``. If demand grows no faster than ``v``, this gives a closed-form
|
|
8
|
+
interval ``delta* = s/v + sqrt(2*eps/v)``.
|
|
9
|
+
|
|
10
|
+
Units are free but must be consistent. The documentation uses MiB for memory,
|
|
11
|
+
s for time, MiB/s for the rate and MiB*s for the deficit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def blind_deficit_bound(s: float, v: float, delta: float) -> float:
|
|
20
|
+
"""Upper bound on the deficit over a blind gap of length ``delta``.
|
|
21
|
+
|
|
22
|
+
``s`` is the slack right after the decision, ``v`` the upper bound on the
|
|
23
|
+
demand growth rate. For ``s >= 0`` it equals the integral of
|
|
24
|
+
``(v*tau - s)^+`` over ``tau`` in ``[0, delta]``.
|
|
25
|
+
|
|
26
|
+
>>> blind_deficit_bound(256.0, 128.0, 4.0)
|
|
27
|
+
256.0
|
|
28
|
+
>>> blind_deficit_bound(256.0, 128.0, 1.0)
|
|
29
|
+
0.0
|
|
30
|
+
"""
|
|
31
|
+
if delta <= 0.0:
|
|
32
|
+
return 0.0
|
|
33
|
+
if v <= 0.0:
|
|
34
|
+
return max(-s, 0.0) * delta
|
|
35
|
+
if s >= 0.0:
|
|
36
|
+
x = v * delta - s
|
|
37
|
+
return x * x / (2.0 * v) if x > 0.0 else 0.0
|
|
38
|
+
return -s * delta + v * delta * delta / 2.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def interval_unclipped(s: float, v: float, eps: float) -> float:
|
|
42
|
+
"""Largest ``delta`` with ``blind_deficit_bound(s, v, delta) <= eps``.
|
|
43
|
+
|
|
44
|
+
``delta* = s/v + sqrt(2*eps/v)``, valid for ``s >= 0`` and ``v > 0``.
|
|
45
|
+
The first term is the time to exhaust the slack, the second the time to
|
|
46
|
+
accumulate the tolerated deficit ``eps``.
|
|
47
|
+
|
|
48
|
+
>>> round(interval_unclipped(256.0, 128.0, 128.0), 6)
|
|
49
|
+
3.414214
|
|
50
|
+
>>> interval_unclipped(0.0, 0.0, 1.0)
|
|
51
|
+
Traceback (most recent call last):
|
|
52
|
+
...
|
|
53
|
+
ValueError: the growth bound v must be positive
|
|
54
|
+
"""
|
|
55
|
+
if v <= 0.0:
|
|
56
|
+
msg = "the growth bound v must be positive"
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
return s / v + math.sqrt(2.0 * eps / v)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def next_interval(s: float, v: float, eps: float, dmin: float = 1.0, dmax: float = 10.0) -> float:
|
|
62
|
+
"""Interval until the next observation, clipped to ``[dmin, dmax]``.
|
|
63
|
+
|
|
64
|
+
>>> next_interval(-1.0, 10.0, 10.0)
|
|
65
|
+
1.0
|
|
66
|
+
>>> next_interval(100.0, 0.0, 10.0)
|
|
67
|
+
10.0
|
|
68
|
+
"""
|
|
69
|
+
return next_interval_aged(s, v, eps, 0.0, dmin, dmax)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def next_interval_aged(
|
|
73
|
+
s: float, v: float, eps: float, age: float = 0.0, dmin: float = 1.0, dmax: float = 10.0
|
|
74
|
+
) -> float:
|
|
75
|
+
"""Interval corrected for the age of the observed snapshot.
|
|
76
|
+
|
|
77
|
+
Statistics such as virtio-balloon memory stats arrive ``age`` seconds late.
|
|
78
|
+
The blind gap then runs from the snapshot to the next poll and lasts
|
|
79
|
+
``age + delta``, so ``age`` is subtracted from ``delta*``. ``s`` is the
|
|
80
|
+
slack relative to the demand at the time of the snapshot. Without slack
|
|
81
|
+
the shortest interval ``dmin`` is returned; without growth, ``dmax``.
|
|
82
|
+
|
|
83
|
+
>>> round(next_interval_aged(s=256.0, v=128.0, eps=128.0, age=1.0), 3)
|
|
84
|
+
2.414
|
|
85
|
+
>>> next_interval_aged(s=256.0, v=128.0, eps=128.0, age=100.0)
|
|
86
|
+
1.0
|
|
87
|
+
"""
|
|
88
|
+
if s <= 0.0:
|
|
89
|
+
return dmin
|
|
90
|
+
if v <= 0.0:
|
|
91
|
+
return dmax
|
|
92
|
+
return min(max(interval_unclipped(s, v, eps) - max(age, 0.0), dmin), dmax)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def min_slack(delta: float, v: float, eps: float) -> float:
|
|
96
|
+
"""Smallest slack that makes the interval ``delta`` admissible.
|
|
97
|
+
|
|
98
|
+
``s_min(delta) = (v*delta - sqrt(2*v*eps))^+``, the inverse of
|
|
99
|
+
:func:`interval_unclipped`. It prices a rare poll in units of reserved
|
|
100
|
+
memory.
|
|
101
|
+
|
|
102
|
+
>>> round(min_slack(5.0, 128.0, 128.0), 6)
|
|
103
|
+
458.980664
|
|
104
|
+
"""
|
|
105
|
+
if v <= 0.0:
|
|
106
|
+
msg = "the growth bound v must be positive"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
return max(v * delta - math.sqrt(2.0 * v * eps), 0.0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def burst_deficit_integral(delta: float, s: float, v: float) -> float:
|
|
112
|
+
"""``G(delta) = integral over r in [0, delta] of ((v*r - s)^+)^2 / (2v)``.
|
|
113
|
+
|
|
114
|
+
The integrand is the deficit accrued from a burst that starts ``r``
|
|
115
|
+
seconds before the next poll. ``G`` is convex, which gives the
|
|
116
|
+
equal-interval result (claim 1.4).
|
|
117
|
+
|
|
118
|
+
>>> round(burst_deficit_integral(4.0, 0.0, 1.0), 6)
|
|
119
|
+
10.666667
|
|
120
|
+
"""
|
|
121
|
+
x = v * delta - s
|
|
122
|
+
return x**3 / (6.0 * v * v) if x > 0.0 and v > 0.0 else 0.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def expected_cost_rate(delta: float, s: float, v: float, rho: float, c: float) -> float:
|
|
126
|
+
"""Return the expected cost per unit time, ``(rho*G(delta) + c) / delta``.
|
|
127
|
+
|
|
128
|
+
``rho`` is the rate of burst onsets (1/s), ``c`` the price of one poll in
|
|
129
|
+
deficit units. Assumptions: at most one burst onset per interval
|
|
130
|
+
(``rho*delta << 1``), growth lasts until the next poll, and every poll
|
|
131
|
+
restores the slack ``s``.
|
|
132
|
+
"""
|
|
133
|
+
return (rho * burst_deficit_integral(delta, s, v) + c) / delta
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def optimal_interval_expected(
|
|
137
|
+
s: float, v: float, rho: float, c: float, dmin: float = 1.0, dmax: float = 10.0
|
|
138
|
+
) -> float:
|
|
139
|
+
"""Interval that minimises :func:`expected_cost_rate` (claim 1.5).
|
|
140
|
+
|
|
141
|
+
The optimality condition is ``x^3/3 + s*x^2/2 = c*v^2/rho`` with
|
|
142
|
+
``x = v*delta - s``. The left side increases for ``x > 0``, so the root is
|
|
143
|
+
unique and is found by bisection. For ``s = 0`` the answer is
|
|
144
|
+
``delta = (3c / (rho*v))^(1/3)``.
|
|
145
|
+
|
|
146
|
+
>>> round(optimal_interval_expected(0.0, 128.0, 0.01, 64.0, dmin=0.0, dmax=99.0), 4)
|
|
147
|
+
5.3133
|
|
148
|
+
"""
|
|
149
|
+
if s < 0.0:
|
|
150
|
+
return dmin
|
|
151
|
+
if v <= 0.0 or rho <= 0.0:
|
|
152
|
+
return dmax
|
|
153
|
+
if c <= 0.0:
|
|
154
|
+
return min(max(s / v, dmin), dmax)
|
|
155
|
+
k = c * v * v / rho
|
|
156
|
+
lo, hi = 0.0, float((3.0 * k) ** (1.0 / 3.0))
|
|
157
|
+
for _ in range(200):
|
|
158
|
+
mid = 0.5 * (lo + hi)
|
|
159
|
+
if mid**3 / 3.0 + s * mid * mid / 2.0 < k:
|
|
160
|
+
lo = mid
|
|
161
|
+
else:
|
|
162
|
+
hi = mid
|
|
163
|
+
if hi - lo <= 1e-15 * max(hi, 1.0):
|
|
164
|
+
break
|
|
165
|
+
x = 0.5 * (lo + hi)
|
|
166
|
+
return min(max((x + s) / v, dmin), dmax)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Polling schedule: one common interval, or an individual interval per object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Hashable, Iterable, Mapping
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from .estimators import RateEstimator
|
|
9
|
+
from .rule import next_interval_aged
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IntervalScheduler(Protocol):
|
|
13
|
+
"""Anything that decides which objects to poll and when.
|
|
14
|
+
|
|
15
|
+
:class:`MonitorScheduler` implements it. Other policies, for example the
|
|
16
|
+
current heuristic, implement the same three methods and are passed to
|
|
17
|
+
:func:`desna_interval.libvirt_loop.run` as ``scheduler``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def due(self, now: float) -> list[Hashable]:
|
|
21
|
+
"""Objects that should be polled at time ``now``."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def next_wakeup(self) -> float:
|
|
25
|
+
"""Time of the earliest scheduled poll."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def observe(
|
|
29
|
+
self,
|
|
30
|
+
now: float,
|
|
31
|
+
demand: Mapping[Hashable, float],
|
|
32
|
+
slack: Mapping[Hashable, float],
|
|
33
|
+
age: Mapping[Hashable, float] | None = None,
|
|
34
|
+
) -> dict[Hashable, float]:
|
|
35
|
+
"""Take the observations and return the interval assigned to each polled object."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MonitorScheduler:
|
|
40
|
+
"""Keeps the next poll time of every monitored object.
|
|
41
|
+
|
|
42
|
+
``mode="per_vm"``: each object gets its own interval from the rule.
|
|
43
|
+
``mode="global"``: all objects are polled together, the common interval
|
|
44
|
+
is the minimum over the objects. ``estimator_factory()`` creates one
|
|
45
|
+
:class:`~desna_interval.estimators.RateEstimator` per object.
|
|
46
|
+
|
|
47
|
+
>>> from desna_interval.estimators import FixedRate
|
|
48
|
+
>>> sch = MonitorScheduler(["a", "b"], eps=128.0, estimator_factory=lambda: FixedRate(128.0))
|
|
49
|
+
>>> iv = sch.observe(0.0, {"a": 1000.0, "b": 1000.0}, {"a": 256.0, "b": 2048.0})
|
|
50
|
+
>>> round(iv["a"], 3), iv["b"]
|
|
51
|
+
(3.414, 10.0)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
vms: Iterable[Hashable],
|
|
57
|
+
eps: float,
|
|
58
|
+
estimator_factory: Callable[[], RateEstimator],
|
|
59
|
+
dmin: float = 1.0,
|
|
60
|
+
dmax: float = 10.0,
|
|
61
|
+
mode: str = "per_vm",
|
|
62
|
+
) -> None:
|
|
63
|
+
if mode not in ("per_vm", "global"):
|
|
64
|
+
msg = "mode must be 'per_vm' or 'global'"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
self.vms: list[Hashable] = list(vms)
|
|
67
|
+
self.eps, self.dmin, self.dmax, self.mode = float(eps), float(dmin), float(dmax), mode
|
|
68
|
+
self.est: dict[Hashable, RateEstimator] = {vm: estimator_factory() for vm in self.vms}
|
|
69
|
+
self.next_t: dict[Hashable, float] = dict.fromkeys(self.vms, 0.0)
|
|
70
|
+
self.last_interval: dict[Hashable, float] = dict.fromkeys(self.vms, self.dmin)
|
|
71
|
+
|
|
72
|
+
def due(self, now: float, tol: float = 1e-9) -> list[Hashable]:
|
|
73
|
+
"""Objects that should be polled at time ``now``."""
|
|
74
|
+
if self.mode == "global":
|
|
75
|
+
return list(self.vms) if self.next_wakeup() <= now + tol else []
|
|
76
|
+
return [vm for vm in self.vms if self.next_t[vm] <= now + tol]
|
|
77
|
+
|
|
78
|
+
def next_wakeup(self) -> float:
|
|
79
|
+
"""Time of the earliest scheduled poll."""
|
|
80
|
+
return min(self.next_t.values())
|
|
81
|
+
|
|
82
|
+
def observe(
|
|
83
|
+
self,
|
|
84
|
+
now: float,
|
|
85
|
+
demand: Mapping[Hashable, float],
|
|
86
|
+
slack: Mapping[Hashable, float],
|
|
87
|
+
age: Mapping[Hashable, float] | None = None,
|
|
88
|
+
) -> dict[Hashable, float]:
|
|
89
|
+
"""Update the estimators of the polled objects and schedule their next polls.
|
|
90
|
+
|
|
91
|
+
``demand`` is the observed demand, ``slack`` the slack after the
|
|
92
|
+
decision, ``age`` the snapshot age in seconds if known. The estimator
|
|
93
|
+
receives the snapshot time, and the interval is shortened by the age.
|
|
94
|
+
Returns the assigned intervals.
|
|
95
|
+
"""
|
|
96
|
+
ages = age or {}
|
|
97
|
+
intervals: dict[Hashable, float] = {}
|
|
98
|
+
for vm, dm in demand.items():
|
|
99
|
+
a = float(ages.get(vm, 0.0))
|
|
100
|
+
self.est[vm].update(now - a, dm)
|
|
101
|
+
intervals[vm] = next_interval_aged(
|
|
102
|
+
slack[vm], self.est[vm].upper(), self.eps, a, self.dmin, self.dmax
|
|
103
|
+
)
|
|
104
|
+
if self.mode == "global" and intervals:
|
|
105
|
+
common = min(intervals.values())
|
|
106
|
+
intervals = dict.fromkeys(intervals, common)
|
|
107
|
+
for vm, iv in intervals.items():
|
|
108
|
+
self.next_t[vm] = now + iv
|
|
109
|
+
self.last_interval[vm] = iv
|
|
110
|
+
return intervals
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: desna-interval
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Monitoring interval from a tolerance on accumulated deficit: the rule delta* = s/v + sqrt(2 eps/v), aged snapshots, expected-cost optimum and a libvirt loop for KVM memory ballooning
|
|
5
|
+
Keywords: kvm,libvirt,virtio-balloon,memory-ballooning,monitoring-interval,adaptive-sampling,accumulated-deficit,virtual-machines,polling
|
|
6
|
+
Author: Andrey Ferubko, Oleg Kazakov
|
|
7
|
+
Author-email: Andrey Ferubko <ferubko1999@yandex.ru>, Oleg Kazakov <kazakov@bgitu.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Classifier: Topic :: System :: Operating System Kernels :: Linux
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Dist: numpy>=1.26
|
|
27
|
+
Requires-Dist: libvirt-python>=8.0 ; extra == 'libvirt'
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Project-URL: Homepage, https://github.com/89605502155/desna-interval
|
|
30
|
+
Project-URL: Documentation, https://github.com/89605502155/desna-interval/tree/main/docs
|
|
31
|
+
Project-URL: Repository, https://github.com/89605502155/desna-interval
|
|
32
|
+
Project-URL: Issues, https://github.com/89605502155/desna-interval/issues
|
|
33
|
+
Project-URL: Changelog, https://github.com/89605502155/desna-interval/blob/main/CHANGELOG.md
|
|
34
|
+
Provides-Extra: libvirt
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# desna-interval
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/desna-interval/)
|
|
40
|
+
[](https://pypi.org/project/desna-interval/)
|
|
41
|
+
[](https://github.com/89605502155/desna-interval/blob/main/LICENSE)
|
|
42
|
+
|
|
43
|
+
**How often to poll a resource so that the deficit accumulated between two polls stays below a tolerance.**
|
|
44
|
+
|
|
45
|
+
desna-interval chooses the monitoring interval of a virtual machine from a
|
|
46
|
+
tolerance on its accumulated memory deficit. Built for memory ballooning of KVM
|
|
47
|
+
guests through libvirt; the rule is generic for any quantity whose growth rate
|
|
48
|
+
is bounded.
|
|
49
|
+
|
|
50
|
+
If demand grows no faster than `v` and the slack after a decision is `s`, the
|
|
51
|
+
deficit over a blind gap of length `Δ` is at most `(vΔ − s)⁺² / (2v)`. The
|
|
52
|
+
largest interval that keeps this below the tolerance `ε` is
|
|
53
|
+
|
|
54
|
+
Δ* = s/v + √(2ε/v)
|
|
55
|
+
|
|
56
|
+
With a snapshot that is `a` seconds old (virtio-balloon statistics arrive late),
|
|
57
|
+
the guarantee holds for `Δ* − a`.
|
|
58
|
+
|
|
59
|
+
Русская версия: [README.ru.md](https://github.com/89605502155/desna-interval/blob/main/README.ru.md).
|
|
60
|
+
Documentation: [English](https://github.com/89605502155/desna-interval/tree/main/docs/en) ·
|
|
61
|
+
[Русский](https://github.com/89605502155/desna-interval/tree/main/docs/ru) ·
|
|
62
|
+
[Changelog](https://github.com/89605502155/desna-interval/blob/main/CHANGELOG.md) ·
|
|
63
|
+
[Issues](https://github.com/89605502155/desna-interval/issues)
|
|
64
|
+
|
|
65
|
+
The library accompanies article 1 of the series by Andrey Ferubko, Bryansk State
|
|
66
|
+
Engineering-Technological University (BGITU). Theory:
|
|
67
|
+
[docs/en/theory.md](https://github.com/89605502155/desna-interval/blob/main/docs/en/theory.md).
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
pip install desna-interval # core: numpy only
|
|
73
|
+
pip install "desna-interval[libvirt]" # live loop on a KVM host
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Requires Python 3.11 or newer.
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from desna_interval import MonitorScheduler, WindowQuantile, next_interval_aged
|
|
82
|
+
|
|
83
|
+
next_interval_aged(s=256, v=128, eps=128, age=0.0) # -> 3.414 s, fresh snapshot
|
|
84
|
+
next_interval_aged(s=256, v=128, eps=128, age=1.0) # -> 2.414 s, snapshot 1 s old
|
|
85
|
+
|
|
86
|
+
sched = MonitorScheduler(["vm1", "vm2"], eps=128, estimator_factory=WindowQuantile)
|
|
87
|
+
sched.observe(now=0.0, demand={"vm1": 1000, "vm2": 3000}, slack={"vm1": 256, "vm2": 64})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Command line:
|
|
91
|
+
|
|
92
|
+
desna-interval interval --slack 256 --rate 128 --eps 128 --age 1
|
|
93
|
+
desna-interval optimal --slack 0 --rate 128 --rho 0.01 --cost 64
|
|
94
|
+
desna-interval replay trace.csv --eps 64 --slack 256
|
|
95
|
+
|
|
96
|
+
## How it differs from existing packages
|
|
97
|
+
|
|
98
|
+
Packages on PyPI that poll or sample adaptively, such as `python-adaptive`,
|
|
99
|
+
choose sample points to reduce the error of a function or a metric. Retry
|
|
100
|
+
packages such as `backoff` and `tenacity` implement back-off for failed calls,
|
|
101
|
+
and scheduling packages such as `APScheduler` only run jobs at given times.
|
|
102
|
+
None of them maps a tolerance on a resource deficit to a polling interval.
|
|
103
|
+
|
|
104
|
+
| Feature | desna-interval | generic adaptive sampling / polling packages |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| Interval from a tolerance on accumulated deficit, `Δ* = s/v + √(2ε/v)` | yes | no, they target metric or function error |
|
|
107
|
+
| Correction for the age of a late snapshot | yes | no |
|
|
108
|
+
| Expected-cost optimum for a known burst rate and poll price | yes | no |
|
|
109
|
+
| Bound on the growth rate from history, pooled over similar guests, or from a Kalman filter | yes | no |
|
|
110
|
+
| Live loop for libvirt guests (`memoryStats`, balloon targets) | yes | no |
|
|
111
|
+
| Replay of policies on a trace with the deficit as the metric | yes | no |
|
|
112
|
+
|
|
113
|
+
The rule is only as good as the bound `v`: see the theory document for when
|
|
114
|
+
it holds and when it does not.
|
|
115
|
+
|
|
116
|
+
## When it helps and when it does not
|
|
117
|
+
|
|
118
|
+
The rule bounds the deficit of each blind gap. The gain over a constant interval
|
|
119
|
+
depends on the workload:
|
|
120
|
+
|
|
121
|
+
- For identical machines with memoryless bursts no adaptive schedule beats a
|
|
122
|
+
constant interval with the same poll budget (claim 1.4, Jensen). The library does not
|
|
123
|
+
pretend otherwise.
|
|
124
|
+
- The gain comes from differences between machines, and the rule states a bound on
|
|
125
|
+
the deficit of each gap, which generic back-off does not.
|
|
126
|
+
- The guarantee is only as good as the bound on `v`. Use a long-history quantile
|
|
127
|
+
or a pooled bound for similar machines.
|
|
128
|
+
|
|
129
|
+
The comparison of the strategies (synthetic traces now, testbed traces for the
|
|
130
|
+
article) is the experiment E2 in [experiments/README.md](experiments/README.md).
|
|
131
|
+
Synthetic results show behaviour only.
|
|
132
|
+
|
|
133
|
+
## Authors
|
|
134
|
+
|
|
135
|
+
- Andrey Ferubko (Андрей Ферубко), ORCID [0009-0006-5625-137X](https://orcid.org/0009-0006-5625-137X)
|
|
136
|
+
- Oleg Kazakov (Олег Дмитриевич Казаков), ORCID [0000-0001-9665-8138](https://orcid.org/0000-0001-9665-8138)
|
|
137
|
+
|
|
138
|
+
Bryansk State Engineering-Technological University (BGITU).
|
|
139
|
+
|
|
140
|
+
## Modules
|
|
141
|
+
|
|
142
|
+
| Module | Contents |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `rule` | `blind_deficit_bound`, `interval_unclipped`, `next_interval`, `next_interval_aged`, `min_slack`, `expected_cost_rate`, `optimal_interval_expected` |
|
|
145
|
+
| `estimators` | bounds on `v`: `history_quantile`, `pooled_quantile`, `WindowQuantile`, `FixedRate`, `KalmanRate`, `RateFilterAdapter`; `burst_rate` |
|
|
146
|
+
| `scheduler` | `MonitorScheduler`, one interval per object or a common one; the `IntervalScheduler` protocol for other schedules |
|
|
147
|
+
| `replay` | `replay` and the policies `FixedPolicy`, `BackoffPolicy`, `DeficitRulePolicy`, `ExpectedCostPolicy` |
|
|
148
|
+
| `libvirt_loop` | `run` (optional `scheduler`), `read_state`, `reserve_controller`: the live loop |
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
uv sync
|
|
154
|
+
uv run ruff check . && uv run ruff format --check .
|
|
155
|
+
uv run mypy
|
|
156
|
+
uv run pytest
|
|
157
|
+
bash scripts/install_hooks.sh # once per clone: commit message limits
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The experiments of article 1 need the optional group `experiments` (matplotlib):
|
|
161
|
+
`uv sync --group experiments`. Their smoke modes run in `pytest`; the full runs are
|
|
162
|
+
described in [experiments/README.md](experiments/README.md).
|
|
163
|
+
|
|
164
|
+
Commit messages in this repository have a subject of at most 8 characters and
|
|
165
|
+
no body. The `commit-msg` hook installed by `scripts/install_hooks.sh` enforces it
|
|
166
|
+
(`scripts/check_commit_msg.py`). There is no CI and no automatic publishing.
|
|
167
|
+
|
|
168
|
+
## Citation
|
|
169
|
+
|
|
170
|
+
See [CITATION.cff](https://github.com/89605502155/desna-interval/blob/main/CITATION.cff)
|
|
171
|
+
or the "Cite this repository" button on GitHub.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT. Copyright (c) 2026 Andrey Ferubko, Oleg Kazakov. See [LICENSE](https://github.com/89605502155/desna-interval/blob/main/LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
desna_interval/__init__.py,sha256=S9By362XaGL9H2_BayGtGIRKgmCFQ164k0jnD_CenQI,1803
|
|
2
|
+
desna_interval/cli.py,sha256=91juFH-0OBsa52A6LHQveZOu0tt1Ra3jBMGJt0PljRg,4068
|
|
3
|
+
desna_interval/estimators.py,sha256=Ld7unbGc27kONYVRxCz7ZWGGSLYw5y32cIzY6j_0WoI,9132
|
|
4
|
+
desna_interval/libvirt_loop.py,sha256=YUz38X9CWL4VzR-Sg_cYxEFTmT58CNIlG65RWssyrag,7190
|
|
5
|
+
desna_interval/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
desna_interval/replay.py,sha256=AyczwNCiHv3p0WdtaHim9sPwzCqo6jJvPlGtkd_40qU,6406
|
|
7
|
+
desna_interval/rule.py,sha256=8PBOkE1HJynAK3AE4DDoDNL1XjS5sGvrZNtzmXHjDjE,5889
|
|
8
|
+
desna_interval/scheduler.py,sha256=_dxOVXa0_8lL49bJ507Q7LYzLkuUHW5crh3ZjPWlwGo,4266
|
|
9
|
+
desna_interval-0.0.1.dist-info/licenses/LICENSE,sha256=vBZIvozzvvIPDJPwlCiWyXZZ6gPwXRi8EeTdUCuulAI,1085
|
|
10
|
+
desna_interval-0.0.1.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
11
|
+
desna_interval-0.0.1.dist-info/entry_points.txt,sha256=SzaHVEjQ5lLuaK060JMOTsETZEN8hUUpInADn9usbgg,60
|
|
12
|
+
desna_interval-0.0.1.dist-info/METADATA,sha256=JVGtajbWWqtk8crDLriPJJrui2_HQm5PxGfFCAhvJp4,8441
|
|
13
|
+
desna_interval-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrey Ferubko, Oleg Kazakov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|