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.
@@ -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
File without changes
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/desna-interval.svg)](https://pypi.org/project/desna-interval/)
40
+ [![Python](https://img.shields.io/pypi/pyversions/desna-interval.svg)](https://pypi.org/project/desna-interval/)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ desna-interval = desna_interval.cli:main
3
+
@@ -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.