On 4/27/2023 20:43, Andrei Gudkov via wrote: > Signed-off-by: Andrei Gudkov <gudkov.and...@huawei.com> > --- > MAINTAINERS | 1 + > scripts/predict_migration.py | 283 +++++++++++++++++++++++++++++++++++ > 2 files changed, 284 insertions(+) > create mode 100644 scripts/predict_migration.py > > diff --git a/MAINTAINERS b/MAINTAINERS > index fc225e66df..0c578446cf 100644 > --- a/MAINTAINERS > +++ b/MAINTAINERS > @@ -3167,6 +3167,7 @@ F: docs/devel/migration.rst > F: qapi/migration.json > F: tests/migration/ > F: util/userfaultfd.c > +F: scripts/predict_migration.py > > D-Bus > M: Marc-André Lureau <marcandre.lur...@redhat.com> > diff --git a/scripts/predict_migration.py b/scripts/predict_migration.py > new file mode 100644 > index 0000000000..c92a97585f > --- /dev/null > +++ b/scripts/predict_migration.py > @@ -0,0 +1,283 @@ > +#!/usr/bin/env python3 > +# > +# Predicts time required to migrate VM under given max downtime constraint. > +# > +# Copyright (c) 2023 HUAWEI TECHNOLOGIES CO.,LTD. > +# > +# Authors: > +# Andrei Gudkov <gudkov.and...@huawei.com> > +# > +# This work is licensed under the terms of the GNU GPL, version 2 or > +# later. See the COPYING file in the top-level directory. > + > + > +# Usage: > +# > +# Step 1. Collect dirty page statistics from live VM: > +# $ scripts/predict_migration.py calc-dirty-rate <qmphost> <qmpport> > >dirty.json > +# <...takes 1 minute by default...> > +# > +# Step 2. Run predictor against collected data: > +# $ scripts/predict_migration.py predict < dirty.json > +# Downtime> | 125ms | 250ms | 500ms | 1000ms | 5000ms | > unlim | > +# > ----------------------------------------------------------------------------- > +# 100 Mbps | - | - | - | - | - | > 16m45s | > +# 1 Gbps | - | - | - | - | - | > 1m39s | > +# 2 Gbps | - | - | - | - | 1m55s | > 50s | > +# 2.5 Gbps | - | - | - | - | 1m12s | > 40s | > +# 5 Gbps | - | - | - | 29s | 25s | > 20s | > +# 10 Gbps | 13s | 13s | 12s | 12s | 12s | > 10s | > +# 25 Gbps | 5s | 5s | 5s | 5s | 4s | > 4s | > +# 40 Gbps | 3s | 3s | 3s | 3s | 3s | > 3s | > +# > +# The latter prints table that lists estimated time it will take to migrate > VM. > +# This time depends on the network bandwidth and max allowed downtime. > +# Dash indicates that migration does not converge. > +# Prediction takes care only about migrating RAM and only in pre-copy mode. > +# Other features, such as compression or local disk migration, are not > supported > + > + > +import sys > +import os > +import math > +import json > +from dataclasses import dataclass > +import asyncio > +import argparse > + > +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'python')) > +from qemu.qmp import QMPClient > + > +async def calc_dirty_rate(host, port, calc_time, sample_pages): > + client = QMPClient() > + try: > + await client.connect((host, port)) > + args = { > + 'calc-time': calc_time, > + 'sample-pages': sample_pages > + } > + await client.execute('calc-dirty-rate', args) > + await asyncio.sleep(calc_time) > + while True: > + data = await client.execute('query-dirty-rate') > + if data['status'] == 'measuring': > + await asyncio.sleep(0.5) > + elif data['status'] == 'measured': > + return data > + else: > + raise ValueError(data['status']) > + finally: > + await client.disconnect() > + > + > +class MemoryModel: > + """ > + Models RAM state during pre-copy migration using calc-dirty-rate results. > + Its primary function is to estimate how many pages will be dirtied > + after given time starting from "clean" state. > + This function is non-linear and saturates at some point. > + """ > + > + @dataclass > + class Point: > + period_millis:float > + dirty_pages:float > + > + def __init__(self, data): > + """ > + :param data: dictionary returned by calc-dirty-rate > + """ > + self.__points = self.__make_points(data) > + self.__page_size = data['page-size'] > + self.__num_total_pages = data['n-total-pages'] > + self.__num_zero_pages = data['n-zero-pages'] / \ > + (data['n-sampled-pages'] / data['n-total-pages']) > + > + def __make_points(self, data): > + points = list() > + > + # Add observed points > + sample_ratio = data['n-sampled-pages'] / data['n-total-pages'] > + for millis,dirty_pages in zip(data['periods'], > data['n-dirty-pages']): > + millis = float(millis) > + dirty_pages = dirty_pages / sample_ratio > + points.append(MemoryModel.Point(millis, dirty_pages)) > + > + # Extrapolate function to the left. > + # Assuming that the function is convex, the worst case is achieved > + # when dirty page count immediately jumps to some value at zero time > + # (infinite slope), and next keeps the same slope as in the region > + # between the first two observed points: points[0]..points[1] > + slope, offset = self.__fit_line(points[0], points[1]) > + points.insert(0, MemoryModel.Point(0.0, max(offset, 0.0))) > + > + # Extrapolate function to the right. > + # The worst case is achieved when the function has the same slope > + # as in the last observed region. > + slope, offset = self.__fit_line(points[-2], points[-1]) > + max_dirty_pages = \ > + data['n-total-pages'] - (data['n-zero-pages'] / sample_ratio) > + if slope > 0.0: > + saturation_millis = (max_dirty_pages - offset) / slope > + points.append(MemoryModel.Point(saturation_millis, > max_dirty_pages)) > + points.append(MemoryModel.Point(math.inf, max_dirty_pages)) > + > + return points > + > + def __fit_line(self, lhs:Point, rhs:Point): > + slope = (rhs.dirty_pages - lhs.dirty_pages) / \ > + (rhs.period_millis - lhs.period_millis) > + offset = lhs.dirty_pages - slope * lhs.period_millis > + return slope, offset > + > + def page_size(self): > + """ > + Return page size in bytes > + """ > + return self.__page_size > + > + def num_total_pages(self): > + return self.__num_total_pages > + > + def num_zero_pages(self): > + """ > + Estimated total number of zero pages. Assumed to be constant. > + """ > + return self.__num_zero_pages > + > + def num_dirty_pages(self, millis): > + """ > + Estimate number of dirty pages after given time starting from "clean" > + state. The estimation is based on piece-wise linear interpolation. > + """ > + for i in range(len(self.__points)): > + if self.__points[i].period_millis == millis: > + return self.__points[i].dirty_pages > + elif self.__points[i].period_millis > millis: > + slope, offset = self.__fit_line(self.__points[i-1], > + self.__points[i])
Seems the indentation is broken here. > + return offset + slope * millis > + raise RuntimeError("unreachable") > + > + > +def predict_migration_time(model, bandwidth, downtime, deadline=3600*1000): > + """ > + Predict how much time it will take to migrate VM under under given Nit: Duplicated "under". > + deadline constraint. > + > + :param model: `MemoryModel` object for a given VM > + :param bandwidth: Bandwidth available for migration [bytes/s] > + :param downtime: Max allowed downtime [milliseconds] > + :param deadline: Max total time to migrate VM before timeout > [milliseconds] > + :return: Predicted migration time [milliseconds] or `None` > + if migration process doesn't converge before given deadline > + """ > + > + left_zero_pages = model.num_zero_pages() > + left_normal_pages = model.num_total_pages() - model.num_zero_pages() > + header_size = 8 In the cover letter: "Typical prediction error is 6-7%". I'm wondering if the 6-7% is less or more than the real migration time. I think 2 potential factors will lead to less estimation time: 1. Network protocol stack's headers are not counted in, e.g., TCP's header can be 20 ~ 60 bytes. 2. The bandwidth may not be saturated all the time. > + > + total_millis = 0.0 > + while True: > + iter_bytes = 0.0 > + iter_bytes += left_normal_pages * (model.page_size() + header_size) > + iter_bytes += left_zero_pages * header_size > + > + iter_millis = iter_bytes * 1000.0 / bandwidth > + > + total_millis += iter_millis > + > + if iter_millis <= downtime: > + return int(math.ceil(total_millis)) > + elif total_millis > deadline: > + return None > + else: > + left_zero_pages = 0 > + left_normal_pages = model.num_dirty_pages(iter_millis) > + > + > +def run_predict_cmd(model): > + @dataclass > + class ValStr: > + value:object > + string:str > + > + def gbps(value): > + return ValStr(value*1024*1024*1024/8, f'{value} Gbps') > + > + def mbps(value): > + return ValStr(value*1024*1024/8, f'{value} Mbps') > + > + def dt(millis): > + if millis is not None: > + return ValStr(millis, f'{millis}ms') > + else: > + return ValStr(math.inf, 'unlim') > + > + def eta(millis): > + if millis is not None: > + seconds = int(math.ceil(millis/1000.0)) > + minutes, seconds = divmod(seconds, 60) > + s = '' > + if minutes > 0: > + s += f'{minutes}m' > + if len(s) > 0: > + s += f'{seconds:02d}s' > + else: > + s += f'{seconds}s' > + else: > + s = '-' > + return ValStr(millis, s) > + > + > + bandwidths = [mbps(100), gbps(1), gbps(2), gbps(2.5), gbps(5), gbps(10), > + gbps(25), gbps(40)] > + downtimes = [dt(125), dt(250), dt(500), dt(1000), dt(5000), dt(None)] > + > + out = '' > + out += 'Downtime> |' > + for downtime in downtimes: > + out += f' {downtime.string:>7} |' > + print(out) > + > + print('-'*len(out)) > + > + for bandwidth in bandwidths: > + print(f'{bandwidth.string:>9} | ', '', end='') > + for downtime in downtimes: > + millis = predict_migration_time(model, > + bandwidth.value, > + downtime.value) > + print(f'{eta(millis).string:>7} | ', '', end='') > + print() > + > +def main(): > + parser = argparse.ArgumentParser() > + subparsers = parser.add_subparsers(dest='command', required=True) > + > + parser_cdr = subparsers.add_parser('calc-dirty-rate', > + help='Collect and print dirty page statistics from live VM') > + parser_cdr.add_argument('--calc-time', type=int, default=60, > + help='Calculation time in seconds') > + parser_cdr.add_argument('--sample-pages', type=int, default=512, > + help='Number of sampled pages per one gigabyte of RAM') > + parser_cdr.add_argument('host', metavar='host', type=str, help='QMP > host') > + parser_cdr.add_argument('port', metavar='port', type=int, help='QMP > port') > + > + subparsers.add_parser('predict', help='Predict migration time') > + > + args = parser.parse_args() > + > + if args.command == 'calc-dirty-rate': > + data = asyncio.run(calc_dirty_rate(host=args.host, > + port=args.port, > + calc_time=args.calc_time, > + sample_pages=args.sample_pages)) > + print(json.dumps(data)) > + elif args.command == 'predict': > + data = json.load(sys.stdin) > + model = MemoryModel(data) > + run_predict_cmd(model) > + > +if __name__ == '__main__': > + main()