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.
- sydost_tracekit/__init__.py +96 -0
- sydost_tracekit/bounds.py +202 -0
- sydost_tracekit/cli.py +131 -0
- sydost_tracekit/clock.py +74 -0
- sydost_tracekit/collector.py +131 -0
- sydost_tracekit/integral.py +154 -0
- sydost_tracekit/load.py +106 -0
- sydost_tracekit/reconstruct.py +49 -0
- sydost_tracekit/recorder.py +259 -0
- sydost_tracekit/replay.py +100 -0
- sydost_tracekit/synthetic.py +197 -0
- sydost_tracekit/trace.py +154 -0
- sydost_tracekit/tune.py +91 -0
- sydost_tracekit-0.0.1.dist-info/METADATA +141 -0
- sydost_tracekit-0.0.1.dist-info/RECORD +18 -0
- sydost_tracekit-0.0.1.dist-info/WHEEL +4 -0
- sydost_tracekit-0.0.1.dist-info/entry_points.txt +3 -0
- sydost_tracekit-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
sydost_tracekit/clock.py
ADDED
|
@@ -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
|