sydost-tracekit 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,96 @@
1
+ """Reference memory traces of virtual machines and accuracy of accumulated-deficit estimates.
2
+
3
+ Modules
4
+ -------
5
+ recorder
6
+ Guest recorder, Python standard library only; copied into a guest as one file.
7
+ collector
8
+ Host collector of libvirt memory statistics (optional extra ``kvm``).
9
+ trace
10
+ Trace of demand on a uniform grid, CSV format with validation.
11
+ load
12
+ Recordings of the guest recorder turned into traces.
13
+ clock
14
+ Host and guest clock alignment (Cristian's algorithm, drift).
15
+ reconstruct
16
+ Hold and linear reconstruction between samples.
17
+ integral
18
+ Accumulated deficit, its estimate from samples, exact positive-part integral.
19
+ bounds
20
+ A-priori bounds (hold, linear), the refined hold bound, the sampling plan.
21
+ replay, tune
22
+ Replay of a controller on a trace, Pareto front and selection under constraints.
23
+ synthetic
24
+ Synthetic scenarios A, B, C for debugging.
25
+ cli
26
+ Command line ``sydost-tracekit``.
27
+ """
28
+
29
+ from .bounds import (
30
+ Constants,
31
+ Plan,
32
+ estimate_constants,
33
+ hold_error_bound,
34
+ hold_error_bounds_refined,
35
+ linear_error_bound,
36
+ plan_sampling,
37
+ required_step,
38
+ )
39
+ from .clock import ClockOffset, ClockSample, estimate_offset, fit_drift, guest_to_host
40
+ from .integral import (
41
+ deficit,
42
+ deficit_integral,
43
+ estimate_from_samples,
44
+ positive_part_integral,
45
+ sampling_study,
46
+ total_variation,
47
+ )
48
+ from .load import demand, read_recorder_csv, recordings_to_trace
49
+ from .reconstruct import hold, linear
50
+ from .replay import ReserveController, RunResult, simulate
51
+ from .synthetic import SCENARIOS, VmProfile, synthetic_trace
52
+ from .trace import Trace, TraceFormatError, load_trace, save_trace
53
+ from .tune import pareto_front, select, sweep
54
+
55
+ __version__ = "0.0.1"
56
+
57
+ __all__ = [
58
+ "SCENARIOS",
59
+ "ClockOffset",
60
+ "ClockSample",
61
+ "Constants",
62
+ "Plan",
63
+ "ReserveController",
64
+ "RunResult",
65
+ "Trace",
66
+ "TraceFormatError",
67
+ "VmProfile",
68
+ "__version__",
69
+ "deficit",
70
+ "deficit_integral",
71
+ "demand",
72
+ "estimate_constants",
73
+ "estimate_from_samples",
74
+ "estimate_offset",
75
+ "fit_drift",
76
+ "guest_to_host",
77
+ "hold",
78
+ "hold_error_bound",
79
+ "hold_error_bounds_refined",
80
+ "linear",
81
+ "linear_error_bound",
82
+ "load_trace",
83
+ "pareto_front",
84
+ "plan_sampling",
85
+ "positive_part_integral",
86
+ "read_recorder_csv",
87
+ "recordings_to_trace",
88
+ "required_step",
89
+ "sampling_study",
90
+ "save_trace",
91
+ "select",
92
+ "simulate",
93
+ "sweep",
94
+ "synthetic_trace",
95
+ "total_variation",
96
+ ]
@@ -0,0 +1,202 @@
1
+ """A-priori and refined error bounds of the accumulated-deficit estimate.
2
+
3
+ Statement 4.1 (hold). |D_l - D| <= l * V(d), where V is the total variation of
4
+ the demand. It follows from |x^+ - y^+| <= |x - y|. It is valid, but loose: the
5
+ bound counts variation at all times, also when the demand stays below the
6
+ allocation.
7
+
8
+ Refined hold bound (this package). The error of a sample at time t_j on the
9
+ step [t_j, t_j + l) is nonzero only where the demand or the held value exceeds
10
+ the allocation. Therefore, on the fine grid of the reference trace,
11
+
12
+ -sum dt (d_k - x_j)^+ 1{d_k > a_k} <= D_l - D <= sum dt (x_j - d_k)^+ 1{x_j > a_k},
13
+
14
+ where x_j = d(t_j) is the held value of the fine point k. The sharper form used
15
+ in the code is the pointwise one: the error of a point is at least
16
+ -(d_k - max(x_j, a_k))^+ and at most (x_j - max(d_k, a_k))^+. The lower bound
17
+ counts only the rises of the demand above the allocation, the upper bound only
18
+ the falls of the held value above it. Both are computed from the reference trace
19
+ and the allocation, which are known.
20
+
21
+ Statement 4.2 (linear, second order). If d is L-Lipschitz with K kinks and
22
+ |d''| <= M away from the kinks, then |D_lin - D| <= l^2 (T M / 12 + K L / 3).
23
+ The constants are estimated from a reference trace by :func:`estimate_constants`;
24
+ for noisy traces M and K are large and the bound is loose. The bound is a
25
+ proof-sketch statement; see docs/en/bounds.md for the derivation.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from dataclasses import dataclass
32
+
33
+ import numpy as np
34
+
35
+ from .integral import total_variation
36
+
37
+
38
+ def hold_error_bound(d_fine: np.ndarray, ell: float) -> float:
39
+ """Statement 4.1: l * V(d), with V computed on the fine grid.
40
+
41
+ Examples
42
+ --------
43
+ >>> hold_error_bound([[0.0, 2.0, 1.0]], 0.5)
44
+ 1.5
45
+ """
46
+ return ell * total_variation(np.asarray(d_fine, float))
47
+
48
+
49
+ def hold_error_bounds_refined(
50
+ d: np.ndarray, a: np.ndarray, dt: float, ell: float
51
+ ) -> tuple[float, float]:
52
+ """Refined lower and upper bounds of D_l - D for the hold estimate.
53
+
54
+ ``d`` and ``a`` are on the fine grid of step ``dt``; ``ell`` is a multiple of
55
+ ``dt`` (rounded). Returns ``(lower, upper)`` with lower <= D_l - D <= upper.
56
+
57
+ Examples
58
+ --------
59
+ >>> d = np.array([[0.0, 4.0, 0.0, 0.0]])
60
+ >>> a = np.full((1, 4), 3.0)
61
+ >>> hold_error_bounds_refined(d, a, 1.0, 2.0)
62
+ (-1.0, 0.0)
63
+ """
64
+ d = np.atleast_2d(np.asarray(d, float))
65
+ a = np.atleast_2d(np.asarray(a, float))
66
+ if d.shape != a.shape:
67
+ raise ValueError("d and a must have the same shape")
68
+ m = max(1, round(ell / dt))
69
+ idx = (np.arange(d.shape[1]) // m) * m
70
+ x = d[:, idx]
71
+ rise = np.maximum(d - np.maximum(x, a), 0.0)
72
+ fall = np.maximum(x - np.maximum(d, a), 0.0)
73
+ return -dt * float(rise.sum()), dt * float(fall.sum())
74
+
75
+
76
+ def linear_error_bound(ell: float, T: float, M: float, K: int, L: float) -> float:
77
+ """Statement 4.2: l^2 (T M / 12 + K L / 3) for one machine.
78
+
79
+ Examples
80
+ --------
81
+ >>> linear_error_bound(0.5, 10.0, 1.2, 0, 3.0)
82
+ 0.25
83
+ """
84
+ return ell * ell * (T * M / 12.0 + K * L / 3.0)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Constants:
89
+ """Constants of the bounds of one trace, summed over the machines.
90
+
91
+ V is the total variation, L and M are the largest over machines, K is the
92
+ total number of kinks; ``linear_C`` is sum over machines of T M / 12 + K L / 3.
93
+ """
94
+
95
+ V: float
96
+ L: float
97
+ M: float
98
+ K: int
99
+ linear_C: float
100
+
101
+
102
+ def estimate_constants(d: np.ndarray, dt: float, kink_tol: float | None = None) -> Constants:
103
+ """Estimate V, L, M, K of the bounds from a fine reference trace.
104
+
105
+ A kink is a change of slope larger than ``kink_tol`` (MiB/s); by default it is
106
+ 10 % of the largest slope of the machine. M is the largest second difference
107
+ divided by dt^2, a conservative estimate.
108
+
109
+ Examples
110
+ --------
111
+ >>> import numpy as np
112
+ >>> c = estimate_constants(np.array([[0.0, 1.0, 2.0, 3.0]]), 1.0)
113
+ >>> c.V, c.L, c.M, c.K
114
+ (3.0, 1.0, 0.0, 0)
115
+ """
116
+ d = np.atleast_2d(np.asarray(d, float))
117
+ slope = np.diff(d, axis=1) / dt
118
+ if slope.shape[1] < 2:
119
+ raise ValueError("the trace needs at least three points")
120
+ second = np.diff(slope, axis=1) / dt
121
+ V = total_variation(d)
122
+ L = float(np.max(np.abs(slope)))
123
+ M = float(np.max(np.abs(second)))
124
+ K = 0
125
+ linear_C = 0.0
126
+ T = d.shape[1] * dt
127
+ for i in range(d.shape[0]):
128
+ Li = float(np.max(np.abs(slope[i])))
129
+ tol = kink_tol if kink_tol is not None else 0.1 * Li
130
+ ki = int(np.sum(np.abs(np.diff(slope[i])) > tol))
131
+ Mi = float(np.max(np.abs(second[i])))
132
+ K += ki
133
+ linear_C += T * Mi / 12.0 + ki * Li / 3.0
134
+ return Constants(V=V, L=L, M=M, K=K, linear_C=linear_C)
135
+
136
+
137
+ def required_step(target: float, method: str, c: Constants) -> float:
138
+ """Largest step l for which the bound of ``method`` does not exceed ``target``.
139
+
140
+ Parameters
141
+ ----------
142
+ target : float
143
+ Admissible absolute error of D, MiB*s (positive).
144
+ method : str
145
+ ``"hold"`` (l V <= target) or ``"linear"`` (l^2 C <= target).
146
+ c : Constants
147
+ Constants of the trace.
148
+
149
+ Examples
150
+ --------
151
+ >>> c = Constants(V=100.0, L=1.0, M=0.0, K=0, linear_C=0.0)
152
+ >>> required_step(50.0, "hold", c)
153
+ 0.5
154
+ """
155
+ if target <= 0:
156
+ raise ValueError("the target error must be positive")
157
+ if method == "hold":
158
+ return math.inf if c.V == 0 else target / c.V
159
+ if method == "linear":
160
+ return math.inf if c.linear_C == 0 else math.sqrt(target / c.linear_C)
161
+ raise ValueError(f"unknown method {method!r}")
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class Plan:
166
+ """Sampling plan for a target error: method, step, rate, and the bound at that step."""
167
+
168
+ method: str
169
+ step_s: float
170
+ rate_hz: float
171
+ bound_MiBs: float
172
+
173
+
174
+ def plan_sampling(target: float, c: Constants) -> Plan:
175
+ """Choose the method that needs the fewest samples for the target error.
176
+
177
+ The method with the largest admissible step gives the lowest rate. The step
178
+ is the largest one for which the bound does not exceed the target. The rate
179
+ is 1 / step, per machine.
180
+
181
+ Examples
182
+ --------
183
+ >>> c = Constants(V=100.0, L=1.0, M=0.0, K=0, linear_C=1e9)
184
+ >>> p = plan_sampling(50.0, c)
185
+ >>> (p.method, p.step_s, p.rate_hz)
186
+ ('hold', 0.5, 2.0)
187
+ """
188
+ best: Plan | None = None
189
+ for method in ("hold", "linear"):
190
+ step = required_step(target, method, c)
191
+ if math.isinf(step):
192
+ bound = 0.0
193
+ elif method == "hold":
194
+ bound = step * c.V
195
+ else:
196
+ bound = step * step * c.linear_C
197
+ plan = Plan(method, step, 0.0 if math.isinf(step) else 1.0 / step, bound)
198
+ if best is None or plan.step_s > best.step_s:
199
+ best = plan
200
+ if best is None: # pragma: no cover - the loop always sets it
201
+ raise RuntimeError("no sampling plan")
202
+ return best
sydost_tracekit/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """Command line: ``sydost-tracekit record | integrate | bound | tune``.
2
+
3
+ record
4
+ Run the guest recorder on this machine (the same code as recorder.py).
5
+ integrate
6
+ Estimate D from samples of a reference trace for several steps and methods.
7
+ bound
8
+ Constants of a reference trace and a-priori bounds for a step or a target error.
9
+ tune
10
+ Replay polling intervals on a trace, print the Pareto front and the selection.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from collections.abc import Sequence
19
+
20
+ from . import __version__
21
+ from .bounds import estimate_constants, hold_error_bound, plan_sampling
22
+ from .integral import sampling_study
23
+ from .recorder import record
24
+ from .trace import load_trace
25
+ from .tune import pareto_front, select, sweep
26
+
27
+
28
+ def _floats(text: str) -> list[float]:
29
+ return [float(x) for x in text.split(",") if x.strip()]
30
+
31
+
32
+ def _cmd_record(args: argparse.Namespace) -> int:
33
+ summary = record(args.out, args.hz, args.duration, args.root)
34
+ with open(args.out + ".json", "w", encoding="utf-8") as fh:
35
+ json.dump(summary, fh, ensure_ascii=False, indent=1)
36
+ print(json.dumps(summary, ensure_ascii=False))
37
+ return 0
38
+
39
+
40
+ def _cmd_integrate(args: argparse.Namespace) -> int:
41
+ ref = load_trace(args.trace)
42
+ alloc = load_trace(args.alloc)
43
+ if alloc.d.shape != ref.d.shape or alloc.dt != ref.dt:
44
+ print("error: the allocation must have the grid and machines of the trace", file=sys.stderr)
45
+ return 2
46
+ methods = tuple(m.strip() for m in args.methods.split(","))
47
+ rows = sampling_study(ref, alloc.d, _floats(args.steps), methods)
48
+ print(f"D_true = {rows[0]['D_true_MiBs'] / 1024.0:.4f} GiB*s")
49
+ header = ["step_s", *[f"{m}_rel_pct" for m in methods]]
50
+ print("\t".join(header))
51
+ for row in rows:
52
+ print("\t".join(f"{row[k]:.4f}" for k in header))
53
+ return 0
54
+
55
+
56
+ def _cmd_bound(args: argparse.Namespace) -> int:
57
+ ref = load_trace(args.trace)
58
+ c = estimate_constants(ref.d, ref.dt)
59
+ print(f"V = {c.V:.3f} MiB, L = {c.L:.3f} MiB/s, M = {c.M:.3f} MiB/s^2, K = {c.K}")
60
+ for ell in _floats(args.steps):
61
+ print(
62
+ f"step {ell:g} s: hold bound l*V = {hold_error_bound(ref.d, ell):.3f} MiB*s, "
63
+ f"linear bound = {ell * ell * c.linear_C:.3f} MiB*s"
64
+ )
65
+ if args.target is not None:
66
+ plan = plan_sampling(args.target, c)
67
+ print(
68
+ f"target {args.target:g} MiB*s: method {plan.method}, step {plan.step_s:.4g} s, "
69
+ f"rate {plan.rate_hz:.4g} Hz per machine"
70
+ )
71
+ return 0
72
+
73
+
74
+ def _cmd_tune(args: argparse.Namespace) -> int:
75
+ ref = load_trace(args.trace)
76
+ rows = sweep(ref, _floats(args.intervals), theta=args.theta)
77
+ keys = ["cost", "D_GiBs"]
78
+ front = pareto_front(rows, keys)
79
+ print("interval_s\tcost\tP_pct\tD_GiBs\tpareto")
80
+ for r in rows:
81
+ mark = "*" if r in front else ""
82
+ print(f"{r['interval_s']:g}\t{r['cost']:.2f}\t{r['P_pct']:.3f}\t{r['D_GiBs']:.4f}\t{mark}")
83
+ chosen = select(rows, {"P_pct": args.alpha, "D_GiBs": args.dmax}, "cost")
84
+ if chosen is None:
85
+ print("no configuration satisfies the constraints")
86
+ else:
87
+ print(f"selected: interval {chosen['interval_s']:g} s, cost {chosen['cost']:.2f}")
88
+ return 0
89
+
90
+
91
+ def build_parser() -> argparse.ArgumentParser:
92
+ """Return the argument parser of the command line."""
93
+ ap = argparse.ArgumentParser(prog="sydost-tracekit", description=__doc__.split("\n")[0])
94
+ ap.add_argument("--version", action="version", version=__version__)
95
+ sub = ap.add_subparsers(dest="command", required=True)
96
+
97
+ p = sub.add_parser("record", help="run the guest recorder")
98
+ p.add_argument("--hz", type=float, default=10.0)
99
+ p.add_argument("--duration", type=float, default=600.0)
100
+ p.add_argument("--out", default="trace.csv")
101
+ p.add_argument("--root", default="/proc")
102
+ p.set_defaults(func=_cmd_record)
103
+
104
+ p = sub.add_parser("integrate", help="estimate D from samples")
105
+ p.add_argument("trace", help="reference trace (fine step)")
106
+ p.add_argument("--alloc", required=True, help="allocation on the same grid (trace format)")
107
+ p.add_argument("--steps", default="0.5,1,2", help="sample steps, s")
108
+ p.add_argument("--methods", default="hold,linear")
109
+ p.set_defaults(func=_cmd_integrate)
110
+
111
+ p = sub.add_parser("bound", help="a-priori bounds and the sampling rate for a target")
112
+ p.add_argument("trace", help="reference trace")
113
+ p.add_argument("--steps", default="1", help="steps for which the bounds are printed, s")
114
+ p.add_argument("--target", type=float, help="admissible error of D, MiB*s")
115
+ p.set_defaults(func=_cmd_bound)
116
+
117
+ p = sub.add_parser("tune", help="replay polling intervals and select one")
118
+ p.add_argument("trace", help="training trace")
119
+ p.add_argument("--intervals", default="1,2,4,8")
120
+ p.add_argument("--theta", type=float, default=64.0, help="deficit threshold, MiB")
121
+ p.add_argument("--alpha", type=float, default=0.5, help="admissible P(theta), %%")
122
+ p.add_argument("--dmax", type=float, default=float("inf"), help="admissible D, GiB*s")
123
+ p.set_defaults(func=_cmd_tune)
124
+ return ap
125
+
126
+
127
+ def main(argv: Sequence[str] | None = None) -> int:
128
+ """Run the command line; return the exit status."""
129
+ args = build_parser().parse_args(argv)
130
+ result: int = args.func(args)
131
+ return result
@@ -0,0 +1,74 @@
1
+ """Alignment of the host and guest clocks.
2
+
3
+ The guest recorder writes wall-clock time t_real_s of the guest. The host
4
+ collector writes its own t_real_s. To compare them, the offset between the two
5
+ clocks is estimated from request-response exchanges (Cristian's algorithm): the
6
+ host notes the time before and after asking the guest for its clock.
7
+
8
+ The error of the estimate is at most half of the round trip of the exchange
9
+ used. The helper keeps the exchange with the smallest round trip.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+
17
+ import numpy as np
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ClockSample:
22
+ """One exchange: host time before the request, guest time, host time after."""
23
+
24
+ t_send_host: float
25
+ t_guest: float
26
+ t_recv_host: float
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ClockOffset:
31
+ """Estimated offset guest minus host, with its bound on the error, s."""
32
+
33
+ offset_s: float
34
+ uncertainty_s: float
35
+ n_samples: int
36
+
37
+
38
+ def estimate_offset(samples: Sequence[ClockSample]) -> ClockOffset:
39
+ """Offset guest - host from the exchange with the smallest round trip.
40
+
41
+ Examples
42
+ --------
43
+ >>> s = [ClockSample(100.0, 105.2, 100.4), ClockSample(200.0, 205.0, 200.2)]
44
+ >>> est = estimate_offset(s)
45
+ >>> round(est.offset_s, 3), round(est.uncertainty_s, 3)
46
+ (5.0, 0.1)
47
+ """
48
+ if not samples:
49
+ raise ValueError("at least one exchange is needed")
50
+ best = min(samples, key=lambda s: s.t_recv_host - s.t_send_host)
51
+ rtt = best.t_recv_host - best.t_send_host
52
+ if rtt < 0:
53
+ raise ValueError("the host received the answer before sending the request")
54
+ midpoint = (best.t_send_host + best.t_recv_host) / 2.0
55
+ return ClockOffset(best.t_guest - midpoint, rtt / 2.0, len(samples))
56
+
57
+
58
+ def guest_to_host(t_guest: np.ndarray, offset_s: float) -> np.ndarray:
59
+ """Convert guest wall-clock times to host time."""
60
+ return np.asarray(t_guest, float) - offset_s
61
+
62
+
63
+ def fit_drift(host_t: np.ndarray, offsets: np.ndarray) -> tuple[float, float]:
64
+ """Linear fit offset = intercept + slope * host_time; slope in ppm.
65
+
66
+ Examples
67
+ --------
68
+ >>> import numpy as np
69
+ >>> slope, intercept = fit_drift(np.array([0.0, 100.0]), np.array([1.0, 1.0001]))
70
+ >>> round(slope, 3)
71
+ 1.0
72
+ """
73
+ slope, intercept = np.polyfit(np.asarray(host_t, float), np.asarray(offsets, float), 1)
74
+ return float(slope) * 1e6, float(intercept)
@@ -0,0 +1,131 @@
1
+ """Host collector: memory statistics of libvirt domains at a fixed frequency.
2
+
3
+ The host path is slower than the guest recorder: QEMU asks the guest for its
4
+ statistics once per period (whole seconds, at least 1 s), and memoryStats()
5
+ returns the last snapshot (SPEC section 6.5). The collector writes the snapshot
6
+ age ``age_s = t_real_s - last_update`` so that stale snapshots are visible.
7
+
8
+ libvirt is optional and imported lazily: install with ``pip install
9
+ sydost-tracekit[kvm]``. The tests use fake domains and need no libvirt.
10
+
11
+ Example on the host::
12
+
13
+ from sydost_tracekit.collector import connect, collect
14
+
15
+ doms = connect("qemu:///system", ["vm1", "vm2"])
16
+ collect(doms, hz=1, duration=600, out="host_stats.csv")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import time
23
+ from collections.abc import Callable, Mapping
24
+ from typing import Any, Protocol
25
+
26
+ KEYS = [
27
+ "actual",
28
+ "available",
29
+ "usable",
30
+ "unused",
31
+ "disk_caches",
32
+ "last_update",
33
+ "rss",
34
+ "swap_in",
35
+ "swap_out",
36
+ "major_fault",
37
+ "minor_fault",
38
+ ]
39
+ VIR_DOMAIN_AFFECT_LIVE = 1 # libvirt-domain.h: 1 << 0
40
+
41
+
42
+ class Domain(Protocol):
43
+ """The part of the libvirt domain interface that the collector uses."""
44
+
45
+ def setMemoryStatsPeriod(self, period: int, flags: int) -> Any: # noqa: N802
46
+ """Set the statistics period, s."""
47
+ ...
48
+
49
+ def memoryStats(self) -> Mapping[str, int]: # noqa: N802
50
+ """Return the last memory statistics snapshot (KiB)."""
51
+ ...
52
+
53
+
54
+ def connect(uri: str, names: list[str]) -> dict[str, Any]:
55
+ """Open a libvirt connection and look up the named domains.
56
+
57
+ Raises
58
+ ------
59
+ ImportError
60
+ If libvirt-python is not installed.
61
+ """
62
+ try:
63
+ import libvirt
64
+ except ImportError as err: # pragma: no cover - needs libvirt on the host
65
+ raise ImportError("install the extra: pip install sydost-tracekit[kvm]") from err
66
+ conn = libvirt.open(uri) # pragma: no cover - needs libvirt on the host
67
+ return {n: conn.lookupByName(n) for n in names} # pragma: no cover
68
+
69
+
70
+ def collect(
71
+ domains: Mapping[str, Domain],
72
+ hz: float,
73
+ duration: float,
74
+ out: str,
75
+ stats_period: int = 1,
76
+ set_period: bool = True,
77
+ clock: Callable[[], float] = time.monotonic,
78
+ sleep: Callable[[float], None] = time.sleep,
79
+ ) -> int:
80
+ """Poll memoryStats() of all domains at frequency hz; return the number of rows.
81
+
82
+ Examples
83
+ --------
84
+ >>> class Fake:
85
+ ... def setMemoryStatsPeriod(self, period, flags):
86
+ ... pass
87
+ ...
88
+ ... def memoryStats(self):
89
+ ... return {"actual": 1024, "last_update": 0}
90
+ >>> import tempfile, os
91
+ >>> p = os.path.join(tempfile.mkdtemp(), "h.csv")
92
+ >>> collect({"vm1": Fake()}, hz=2.0, duration=1.0, out=p, set_period=False)
93
+ 2
94
+ """
95
+ if hz <= 0 or duration <= 0:
96
+ raise ValueError("hz and duration must be positive")
97
+ if set_period:
98
+ for dom in domains.values():
99
+ dom.setMemoryStatsPeriod(int(stats_period), VIR_DOMAIN_AFFECT_LIVE)
100
+ period = 1.0 / hz
101
+ start = clock()
102
+ n = 0
103
+ k = 0
104
+ with open(out, "w", newline="", encoding="utf-8") as fh:
105
+ writer = csv.writer(fh)
106
+ writer.writerow(["t_mono_s", "t_real_s", "vm", *KEYS, "age_s"])
107
+ while True:
108
+ now = clock() - start
109
+ if now >= duration:
110
+ break
111
+ due = k * period
112
+ if now < due:
113
+ sleep(due - now)
114
+ continue
115
+ for name, dom in domains.items():
116
+ st = dom.memoryStats()
117
+ real = time.time()
118
+ last = st.get("last_update", "")
119
+ age = real - float(last) if last != "" else ""
120
+ writer.writerow(
121
+ [
122
+ f"{clock():.6f}",
123
+ f"{real:.6f}",
124
+ name,
125
+ *(st.get(key, "") for key in KEYS),
126
+ f"{age:.3f}" if age != "" else "",
127
+ ]
128
+ )
129
+ n += 1
130
+ k += 1
131
+ return n