From 3b07a6c09cee1b895cb9f79b8d8c2da527d3898d Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 4 Jul 2025 12:01:17 +0100 Subject: [PATCH 01/29] cli: Simplify validation of sut.connection parameters Given we only support SSH connection type, let's simplify the logic for validating the plan. Now we have to do one less validation parse. Signed-off-by: Ryan Roberts --- fastpath/utils/plan.py | 65 ++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/fastpath/utils/plan.py b/fastpath/utils/plan.py index b780afd..d0a53b5 100644 --- a/fastpath/utils/plan.py +++ b/fastpath/utils/plan.py @@ -9,35 +9,6 @@ import yaml from fastpath.utils import workspace -_sut_params_schema = { - "SSH": { - "host": { - "type": "string", - "required": True, - }, - "user": { - "type": "string", - "required": False, - "nullable": True, - "default": None, - }, - "port": { - "type": "integer", - "required": False, - "nullable": True, - "default": None, - "coerce": int, - }, - "keyfile": { - "type": "string", - "required": False, - "nullable": True, - "default": None, - }, - }, -} - - _plan_pre_schema = { "defaults": { "type": "dict", @@ -100,6 +71,31 @@ _plan_pre_schema = { "params": { "type": "dict", "required": True, + "schema": { + "host": { + "type": "string", + "required": True, + }, + "user": { + "type": "string", + "required": False, + "nullable": True, + "default": None, + }, + "port": { + "type": "integer", + "required": False, + "nullable": True, + "default": None, + "coerce": int, + }, + "keyfile": { + "type": "string", + "required": False, + "nullable": True, + "default": None, + }, + }, }, }, }, @@ -427,17 +423,6 @@ def load(file_name): plan = _validate_normalize(plan, _plan_pre_schema) - # Once we know the sut method, attach the correct method params - # schema and re-validate. - _plan_pre_schema["sut"]["schema"]["connection"]["schema"]["params"][ - "schema" - ] = _sut_params_schema[plan["sut"]["connection"]["method"]] - _plan_post_schema["sut"]["schema"]["connection"]["schema"]["params"][ - "schema" - ] = _sut_params_schema[plan["sut"]["connection"]["method"]] - - plan = _validate_normalize(plan, _plan_pre_schema) - for i, bm in enumerate(plan["benchmarks"]): if "include" in bm: base = _benchmark_load(bm["include"], rel) -- GitLab From 0b644cd209128b64217cb9e752aea708e1cc3856 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 8 Jul 2025 12:54:53 +0100 Subject: [PATCH 02/29] cli: Factor out [create_]open_or_import() resultstore helpers Slightly different operations need to be performed when creating, opening or, in the case of csv resultstores, importing. Instead of having that logic in every caller, let's create some helpers. Refactor the existing callers to use them. They will also be used in future by "plan exec" when it moves to using the resultstore APIs directly. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/merge.py | 22 ++------------ fastpath/utils/resultstore.py | 39 +++++++++++++++++++++++++ fastpath/utils/table.py | 6 +--- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/fastpath/commands/verbs/result/merge.py b/fastpath/commands/verbs/result/merge.py index 3817d28..1555395 100644 --- a/fastpath/commands/verbs/result/merge.py +++ b/fastpath/commands/verbs/result/merge.py @@ -106,31 +106,13 @@ def dispatch(args): if len(set(srcs + [dst])) != len(srcs + [dst]): raise Exception("source and destination resulstores must be unique") - if rs.exists(dst) and not args.append: - raise Exception("destination resultstore exists: --append required") - - if rs.exists(dst) and not rs.conforms(dst): - raise Exception("destination resultstore currupt: can't continue") - # Get source stores. src_stores = [] for src in srcs: - if rs.is_csv(src): - src_stores.append(rs.ResultSet.from_csv(src)) - else: - src_stores.append(rs.ResultSet.open(src)) + src_stores.append(rs.open_or_import(src)) # Get destination store. - if rs.is_csv(dst): - if rs.conforms(dst): - dst_store = rs.ResultSet.from_csv(dst) - else: - dst_store = rs.ResultSet.create() - else: - if rs.conforms(dst): - dst_store = rs.ResultSet.open(dst) - else: - dst_store = rs.ResultSet.create(dst) + dst_store = rs.create_open_or_import(dst, args.append) # Merge each source into the destination. for src_store in src_stores: diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index 95ee6a6..65ee6db 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -90,6 +90,45 @@ def conforms(url): return _conforms(db) +def open_or_import(url): + """ + Opens an existing resultstore (mysql, sqlite) or import an existing + resultstore (csv). If csv, then the user must explicitly export it on + completion if required. + """ + if not exists(url): + raise Exception("resultstore does not exist") + + if not conforms(url): + raise Exception("resultstore currupt") + + if is_csv(url): + rstore = ResultSet.from_csv(url) + else: + rstore = ResultSet.open(url) + + return rstore + + +def create_open_or_import(url, allow_existing): + """ + Creates a resultstore if it doesn't already exist, or if it does exist and + allow_existing=True, open it (mysql, sqlite) or import it (csv). If csv, + then the user must explicitly export it on completion if required. + """ + if exists(url) and not allow_existing: + raise Exception("resultstore exists: --append required") + + if exists(url): + rstore = open_or_import(url) + elif is_csv(url): + rstore = ResultSet.create() + else: + rstore = ResultSet.create(url) + + return rstore + + class ResultSet: @classmethod def open(cls, url): diff --git a/fastpath/utils/table.py b/fastpath/utils/table.py index 42681ed..00991a8 100644 --- a/fastpath/utils/table.py +++ b/fastpath/utils/table.py @@ -50,11 +50,7 @@ def load_tables(resultstore, merge_similar=True): being guarranteed unique. When merge_similar=True, objects are merged if they have only trival differences. """ - if rs.is_csv(resultstore): - store = rs.ResultSet.from_csv(resultstore) - else: - store = rs.ResultSet.open(resultstore) - dfs = store.to_dfs() + dfs = rs.open_or_import(resultstore).to_dfs() if merge_similar: # Merge swprofiles when all fields match except kernel_cmdline_full_hash -- GitLab From 44a5ec030ddb44ba40b634980a8ee2fb18f5155e Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Thu, 10 Jul 2025 11:52:11 +0100 Subject: [PATCH 03/29] cli: Support transient objects in ResultSet.merge() Previously we were relying on the primary and foreign keys of the objects passed into merge being set. This required the objects to already be in a resultstore, which was previously a reasonable assumption given the objects came from a source resultstore to be merged into the destination. But we want to start using merge() to merge transient objects into a resultstore for the "plan exec" case where we construct objects in memory that are not backed by a resultstore. In this case, those objects don't have keys. So instead of mapping source to destination id, let's just map source to destination object, and leave SQLAlchemy to sort out the ids. Signed-off-by: Ryan Roberts --- fastpath/utils/resultstore.py | 38 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index 65ee6db..9c04ab8 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -366,8 +366,8 @@ class ResultSet: entity = create(model, **kwargs) return entity - # Map from source id to destination id. - ids = {table: {} for table in Table} + # Map from source entity to destination entity. + map = {table: {} for table in Table} # Merge SUT tables. for sent in sents[Table.SUT]: @@ -389,7 +389,7 @@ class ResultSet: ] ), ) - ids[Table.SUT][sent.id] = dent.id + map[Table.SUT][sent] = dent # Merge CPU tables. for sent in sents[Table.CPU]: @@ -401,9 +401,9 @@ class ResultSet: "cpu_index", ] ), - sut_id=ids[Table.SUT][sent.sut_id], + sut=map[Table.SUT][sent.sut], ) - ids[Table.CPU][sent.id] = dent.id + map[Table.CPU][sent] = dent # Merge SWPROFILE tables. for sent in sents[Table.SWPROFILE]: @@ -423,7 +423,7 @@ class ResultSet: ] ), ) - ids[Table.SWPROFILE][sent.id] = dent.id + map[Table.SWPROFILE][sent] = dent # Merge BENCHMARK tables. for sent in sents[Table.BENCHMARK]: @@ -439,7 +439,7 @@ class ResultSet: ] ), ) - ids[Table.BENCHMARK][sent.id] = dent.id + map[Table.BENCHMARK][sent] = dent # Merge PARAM tables. for sent in sents[Table.PARAM]: @@ -451,9 +451,9 @@ class ResultSet: "value", ] ), - benchmark_id=ids[Table.BENCHMARK][sent.benchmark_id], + benchmark=map[Table.BENCHMARK][sent.benchmark], ) - ids[Table.PARAM][sent.id] = dent.id + map[Table.PARAM][sent] = dent # Merge RESULTCLASS tables. for sent in sents[Table.RESULTCLASS]: @@ -466,9 +466,9 @@ class ResultSet: "improvement", ] ), - benchmark_id=ids[Table.BENCHMARK][sent.benchmark_id], + benchmark=map[Table.BENCHMARK][sent.benchmark], ) - ids[Table.RESULTCLASS][sent.id] = dent.id + map[Table.RESULTCLASS][sent] = dent # Merge RESULT tables. for sent in sents[Table.RESULT]: @@ -481,11 +481,11 @@ class ResultSet: "value", ] ), - resultclass_id=ids[Table.RESULTCLASS][sent.resultclass_id], - sut_id=ids[Table.SUT][sent.sut_id], - swprofile_id=ids[Table.SWPROFILE][sent.swprofile_id], + resultclass=map[Table.RESULTCLASS][sent.resultclass], + sut=map[Table.SUT][sent.sut], + swprofile=map[Table.SWPROFILE][sent.swprofile], ) - ids[Table.RESULT][sent.id] = dent.id + map[Table.RESULT][sent] = dent # Merge ERROR tables. for sent in sents[Table.ERROR]: @@ -498,11 +498,11 @@ class ResultSet: "error", ] ), - sut_id=ids[Table.SUT][sent.sut_id], - swprofile_id=ids[Table.SWPROFILE][sent.swprofile_id], - benchmark_id=ids[Table.BENCHMARK][sent.benchmark_id], + sut=map[Table.SUT][sent.sut], + swprofile=map[Table.SWPROFILE][sent.swprofile], + benchmark=map[Table.BENCHMARK][sent.benchmark], ) - ids[Table.ERROR][sent.id] = dent.id + map[Table.ERROR][sent] = dent self.session.commit() -- GitLab From 33c951ec7708e43c833c6c0b75ef5ece6e39cc87 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Thu, 10 Jul 2025 13:42:02 +0100 Subject: [PATCH 04/29] cli: Simplify implementation of ResultSet.merge() For every entity it wants to add to the resultstore, merge() must get_or_create() a persistent entity in the resultstore with the same attributes as the source entity. If it already exists, get the entity, else create it. This is the mechanism that deduplicates everything. Previously we were explicitly passing the list of columns that we wanted to compare to determine equality. But it turns out that we always pass all columns that are neither the primary key nor a foreign key (which makes sense). So let's just pass the source entity and have the function generically determine both the model and that set of columns. This reduces code and make it more generic, so it will be easier to migrate to the new schema. Signed-off-by: Ryan Roberts --- fastpath/utils/resultstore.py | 131 +++++++++++----------------------- 1 file changed, 40 insertions(+), 91 deletions(-) diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index 9c04ab8..f48c9ed 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -354,133 +354,89 @@ class ResultSet: sents[Table.BENCHMARK].add(result.resultclass.benchmark) sents[Table.PARAM].update(result.resultclass.benchmark.params) + def inspect(sent): + model = type(sent) + while schema.BaseTable != model.__base__: + model = model.__base__ + columns = [ + c.name + for c in model.__table__.columns + if not c.primary_key and not c.foreign_keys + ] + attrs = sent.to_dict(columns) + return model, attrs + def create(model, **kwargs): entity = model(**kwargs) self.session.add(entity) self.session.flush() return entity - def get_or_create(model, **kwargs): - entity = self.session.query(model).filter_by(**kwargs).first() - if not entity: - entity = create(model, **kwargs) - return entity + def create_from(sent, **kwargs): + model, attrs = inspect(sent) + return create(model, **attrs, **kwargs) + + def get_or_create_from(sent, **kwargs): + model, attrs = inspect(sent) + dent = ( + self.session.query(model).filter_by(**attrs, **kwargs).first() + ) + if not dent: + dent = create(model, **attrs, **kwargs) + return dent # Map from source entity to destination entity. map = {table: {} for table in Table} # Merge SUT tables. for sent in sents[Table.SUT]: - dent = get_or_create( - schema.SUT, - **sent.to_dict( - attrs=[ - "name", - "host_name", - "architecture", - "cpu_count", - "cpu_info_hash", - "numa_count", - "ram_sz", - "hypervisor", - "product_name", - "product_serial", - "mac_addrs_hash", - ] - ), + dent = get_or_create_from( + sent, ) map[Table.SUT][sent] = dent # Merge CPU tables. for sent in sents[Table.CPU]: - dent = get_or_create( - schema.CPU, - **sent.to_dict( - attrs=[ - "desc", - "cpu_index", - ] - ), + dent = get_or_create_from( + sent, sut=map[Table.SUT][sent.sut], ) map[Table.CPU][sent] = dent # Merge SWPROFILE tables. for sent in sents[Table.SWPROFILE]: - dent = get_or_create( - schema.SWPROFILE, - **sent.to_dict( - attrs=[ - "name", - "kernel_name", - "kernel_git_sha", - "kernel_kconfig_full_hash", - "kernel_cmdline_full_hash", - "userspace_name", - "cmdline", - "sysctl", - "bootscript", - ] - ), + dent = get_or_create_from( + sent, ) map[Table.SWPROFILE][sent] = dent # Merge BENCHMARK tables. for sent in sents[Table.BENCHMARK]: - dent = get_or_create( - schema.BENCHMARK, - **sent.to_dict( - attrs=[ - "suite", - "name", - "type", - "image", - "params_hash", - ] - ), + dent = get_or_create_from( + sent, ) map[Table.BENCHMARK][sent] = dent # Merge PARAM tables. for sent in sents[Table.PARAM]: - dent = get_or_create( - schema.PARAM, - **sent.to_dict( - attrs=[ - "name", - "value", - ] - ), + dent = get_or_create_from( + sent, benchmark=map[Table.BENCHMARK][sent.benchmark], ) map[Table.PARAM][sent] = dent # Merge RESULTCLASS tables. for sent in sents[Table.RESULTCLASS]: - dent = get_or_create( - schema.RESULTCLASS, - **sent.to_dict( - attrs=[ - "name", - "unit", - "improvement", - ] - ), + dent = get_or_create_from( + sent, benchmark=map[Table.BENCHMARK][sent.benchmark], ) map[Table.RESULTCLASS][sent] = dent # Merge RESULT tables. for sent in sents[Table.RESULT]: - dent = create( - schema.RESULT, - **sent.to_dict( - attrs=[ - "timestamp", - "session_uuid", - "value", - ] - ), + dent = create_from( + sent, resultclass=map[Table.RESULTCLASS][sent.resultclass], sut=map[Table.SUT][sent.sut], swprofile=map[Table.SWPROFILE][sent.swprofile], @@ -489,15 +445,8 @@ class ResultSet: # Merge ERROR tables. for sent in sents[Table.ERROR]: - dent = create( - schema.ERROR, - **sent.to_dict( - attrs=[ - "timestamp", - "session_uuid", - "error", - ] - ), + dent = create_from( + sent, sut=map[Table.SUT][sent.sut], swprofile=map[Table.SWPROFILE][sent.swprofile], benchmark=map[Table.BENCHMARK][sent.benchmark], -- GitLab From 64eacbcafc40bd1dd13880ff3da806ec6f9b2b36 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 8 Jul 2025 14:45:42 +0100 Subject: [PATCH 05/29] cli: Cleanup do_one_*() parameters lists for "plan exec" The number of parameters passed through the hierarchy of do_one_*() functions is large and unwieldy. But it turns out that pbar, ctx and csvs are global, so let's actually make them global and reduce the number of parameters. This is cleanup preparation for converting "plan exec" to support the resultstore API natively. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 58 +++++++++++++--------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 6d64968..cb1163b 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -76,12 +76,19 @@ def add_parser(parser, formatter, add_noun_args): return verb_name +csvs = {} +ctx = None +pbar = None + + def dispatch(args): """ Part of the command interface expected by fastpath.py. Called to execute the subcommand, with the arguments the user passed on the command line. The arguments comply with those requested in add_parser(). """ + global csvs, ctx, pbar + normplan = plan.load(args.plan) sut = normplan["sut"] connection = normplan["sut"]["connection"] @@ -94,7 +101,6 @@ def dispatch(args): basedir = rs.normalize(args.output) os.makedirs(basedir, exist_ok=args.append) logsdir = logs_dir(basedir, "logs") - csvs = {} try: # Create a csv file for each table. @@ -107,21 +113,17 @@ def dispatch(args): with open(os.path.join(basedir, "fastpath.log"), "a") as log: with machine.open( log, connection["method"], connection["params"] - ) as ctx: - with ProgressBar(normplan) as pbar: - do_one_sut( - pbar, - ctx, - logsdir, - csvs, - {}, - sut, - swprofiles, - benchmarks, - ) + ) as ctx_local: + with ProgressBar(normplan) as pbar_local: + pbar = pbar_local + ctx = ctx_local + do_one_sut(logsdir, {}, sut, swprofiles, benchmarks) finally: for csv in csvs.values(): csv.close() + csvs = {} + ctx = None + pbar = None class ProgressBar: @@ -528,9 +530,7 @@ def restart_container(ctx, image): start_container(ctx, image) -def do_one_repeat( - pbar, ctx, basedir, csvs, ids, benchmark, session_uuid, repeat -): +def do_one_repeat(basedir, ids, benchmark, session_uuid, repeat): warmup = basedir is None ctx.log(f"BEGIN: {'warmup' if warmup else 'repeat'}: {repeat}\n") @@ -639,7 +639,7 @@ def do_one_repeat( ctx.log(f"END: repeat: {repeat} ({error})\n") -def do_one_benchmark_exec(pbar, ctx, basedir, csvs, ids, suuid, benchmark): +def do_one_benchmark_exec(basedir, ids, suuid, benchmark): bname = f"{benchmark['suite']}/{benchmark['name']}" ctx.log(f"BEGIN: benchmark_exec: {bname}\n") @@ -651,19 +651,17 @@ def do_one_benchmark_exec(pbar, ctx, basedir, csvs, ids, suuid, benchmark): ids[Table.BENCHMARK] = benchmark["id"] for warmup in range(benchmark["warmups"]): - do_one_repeat(pbar, ctx, None, None, None, benchmark, None, warmup) + do_one_repeat(None, None, benchmark, suuid, warmup) for repeat in range(benchmark["repeats"]): - do_one_repeat( - pbar, ctx, basedir, csvs, ids, benchmark, suuid, repeat - ) + do_one_repeat(basedir, ids, benchmark, suuid, repeat) finally: stop_container(ctx) ctx.log(f"END: benchmark_exec: {bname}\n") -def do_one_session(pbar, ctx, basedir, csvs, ids, benchmarks, session): +def do_one_session(basedir, ids, benchmarks, session): ctx.log(f"BEGIN: session: {session}\n") # Don't reboot for the first session, because do_one_swprofile() has already @@ -682,13 +680,13 @@ def do_one_session(pbar, ctx, basedir, csvs, ids, benchmarks, session): continue basedir = logs_dir(benchmark["dir"], name) - do_one_benchmark_exec(pbar, ctx, basedir, csvs, ids, suuid, benchmark) + do_one_benchmark_exec(basedir, ids, suuid, benchmark) kmsg.stop(ctx) ctx.log(f"END: session: {session}\n") -def do_one_benchmark_setup(pbar, ctx, basedir, csvs, ids, benchmark): +def do_one_benchmark_setup(basedir, ids, benchmark): bname = f"{benchmark['suite']}/{benchmark['name']}" ctx.log(f"BEGIN: benchmark_setup: {bname}\n") @@ -721,7 +719,7 @@ def do_one_benchmark_setup(pbar, ctx, basedir, csvs, ids, benchmark): ctx.log(f"END: benchmark_setup: {bname}\n") -def do_one_swprofile(pbar, ctx, basedir, csvs, ids, swprofile, benchmarks, idx): +def do_one_swprofile(basedir, ids, swprofile, benchmarks, idx): ctx.log(f"BEGIN: swprofile: {swprofile['name']}\n") # Install the swprofile on sut. swprofile 0 is installed by do_one_sut(). @@ -747,16 +745,16 @@ def do_one_swprofile(pbar, ctx, basedir, csvs, ids, swprofile, benchmarks, idx): # Setup all the benchmarks. for benchmark in benchmarks: - do_one_benchmark_setup(pbar, ctx, basedir, csvs, ids, benchmark) + do_one_benchmark_setup(basedir, ids, benchmark) # Run the max number of sessions required by a benchmark. for session in range(max(b["sessions"] for b in benchmarks)): - do_one_session(pbar, ctx, basedir, csvs, ids, benchmarks, session) + do_one_session(basedir, ids, benchmarks, session) ctx.log(f"END: swprofile: {swprofile['name']}\n") -def do_one_sut(pbar, ctx, basedir, csvs, ids, sut, swprofiles, benchmarks): +def do_one_sut(basedir, ids, sut, swprofiles, benchmarks): ctx.log(f"BEGIN: sut: {sut['name']}\n") # Install the first swprofile on the sut. We do this early (subsequent @@ -793,8 +791,6 @@ def do_one_sut(pbar, ctx, basedir, csvs, ids, sut, swprofiles, benchmarks): # Iterate over the swprofiles. for idx, swprofile in enumerate(swprofiles): - do_one_swprofile( - pbar, ctx, basedir, csvs, ids, swprofile, benchmarks, idx - ) + do_one_swprofile(basedir, ids, swprofile, benchmarks, idx) ctx.log(f"END: sut: {sut['name']}\n") -- GitLab From d60bb2f32e92d58d9c618c903297714d523c8eee Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 8 Jul 2025 15:18:14 +0100 Subject: [PATCH 06/29] cli: Support all resultstore types in "plan exec" Previously, "plan exec" only supported csv resultstores, and it used its own hand-rolled logic to output to the CSVs. Let's migrate it to use the resultstore API, meaning all resultstore types can be supported. In addition we get to delete a bunch of code. The code is cleaned up significantly with better abstractions; we have a class per table, which inherits from the schema class but also encapsulates the objects as they appear in the plan. This change also makes it easier to migrate to the new resultstore schema that is required for multi-node SUTs. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 535 ++++++++++----------------- fastpath/utils/resultstore.py | 4 +- fastpath/utils/schema.py | 18 +- 3 files changed, 214 insertions(+), 343 deletions(-) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index cb1163b..806e293 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -22,6 +22,7 @@ from fastpath.utils import kmsg from fastpath.utils import machine from fastpath.utils import plan from fastpath.utils import resultstore as rs +from fastpath.utils import schema from fastpath.utils.bmutils import BenchmarkError, BenchmarkException from fastpath.utils.table import Table @@ -55,13 +56,25 @@ def add_parser(parser, formatter, add_noun_args): cliutils.add_generic_args(verbp) add_noun_args(verbp) + verbp.add_argument( + "--resultstore", + metavar="", + required=False, + help="""Store where merged results are saved. Fastpath will exit with + error if store already exists, unless --append is specified. URL + encoded to describe a resultstore in either csv, sqlite or mysql + format. csv: csv:/// (although "csv:///" is optional). + sqlite: sqlite:///. mysql: + mysql://:@:/.""", + ) + verbp.add_argument( "--output", - metavar="", + metavar="", required=True, - help="""Location where results and logs will be stored. Fastpath will - exit with error if directory already exists, unless --append is - specified. Only csv stores are supported.""", + help="""Location where logs will be stored. Fastpath will exit with + error if directory already exists, unless --append is specified. If + --resultstore is not provided, csv resultstore is created here.""", ) verbp.add_argument( @@ -76,7 +89,7 @@ def add_parser(parser, formatter, add_noun_args): return verb_name -csvs = {} +rstore = None ctx = None pbar = None @@ -87,41 +100,37 @@ def dispatch(args): subcommand, with the arguments the user passed on the command line. The arguments comply with those requested in add_parser(). """ - global csvs, ctx, pbar + global rstore, ctx, pbar normplan = plan.load(args.plan) - sut = normplan["sut"] - connection = normplan["sut"]["connection"] - swprofiles = normplan["swprofiles"] - benchmarks = normplan["benchmarks"] + sut = Sut(normplan["sut"]) + swprofiles = [SwProfile(swprofile) for swprofile in normplan["swprofiles"]] + benchmarks = [Benchmark(benchmark) for benchmark in normplan["benchmarks"]] - if not rs.is_csv(args.output): - raise Exception("output resultstore must be csv") + cnct = normplan["sut"]["connection"] + + url = rs.normalize(args.resultstore if args.resultstore else args.output) + rstore = rs.create_open_or_import(url, args.append) basedir = rs.normalize(args.output) os.makedirs(basedir, exist_ok=args.append) logsdir = logs_dir(basedir, "logs") try: - # Create a csv file for each table. - for table in Table: - csv = new_csvfile(table, basedir) - csvs[table] = csv - # Execute! print(f"Executing {os.path.basename(args.plan)}...", file=sys.stderr) with open(os.path.join(basedir, "fastpath.log"), "a") as log: - with machine.open( - log, connection["method"], connection["params"] - ) as ctx_local: + with machine.open(log, cnct["method"], cnct["params"]) as ctx_local: with ProgressBar(normplan) as pbar_local: pbar = pbar_local ctx = ctx_local - do_one_sut(logsdir, {}, sut, swprofiles, benchmarks) + do_one_sut(logsdir, sut, swprofiles, benchmarks) finally: - for csv in csvs.values(): - csv.close() - csvs = {} + # Export to csv if that's what the requested output is. + if rs.is_csv(url): + rstore.to_csv(url) + rstore.close() + rstore = None ctx = None pbar = None @@ -157,213 +166,6 @@ class ProgressBar: self.close() -class CSVFile: - """ - Encapsulates a CSV file, allowing data to be output to the file row by row - so that all data can be guarranteed safe even if the program crashes. It - also conveniently allows a consumer to read the data while its still being - produced. If the file already exists, will append new rows starting with the - next available id. - """ - - def __init__(self, filename, columns): - """ - Create a CSVFile to wrap and append to the CSV file at filename. columns - is a list of the column names. - """ - self.csv = None - self.next_id = 1 - self.columns = columns - - # If the file exists, ensure its columns are compatible and figure out - # the id of the next row. - try: - df = pd.read_csv(filename) - except Exception: - pass - else: - if list(df.columns) != ["id"] + columns: - raise Exception(f"{filename} exists with conflicting columns.") - self.next_id = len(df) + 1 - - # If no rows have been output, start fresh. - append = self.next_id > 1 - self.csv = open(filename, mode="a" if append else "w") - - # Write the header if the file is fresh. - if not append: - df = pd.DataFrame(None, index=None, columns=self.columns) - df = df.reset_index(names="id") - line = df.to_csv(index=False, header=True) - self.csv.write(line) - self.csv.flush() - - def append(self, data): - """ - Append a row or list of rows to the csv file. Return id of appended row - if single row was provided or None if list of rows was provided. - """ - if isinstance(data, list): - for row in data: - self.append(row) - return - - if not all(col in data.keys() for col in self.columns): - raise Exception("Row data does not match csv columns.") - - id = self.next_id - - # Write the row. - df = pd.DataFrame(data, index=[id], columns=self.columns) - df = df.reset_index(names="id") - line = df.to_csv(index=False, header=False) - self.csv.write(line) - self.csv.flush() - - self.next_id += 1 - return id - - def close(self): - if self.csv: - self.csv.close() - - -class UniqueCSVFile(CSVFile): - """ - Similar to CSVFile, except it only permits unique entries. If an entry is - appended that already exists, the id of the existing entry is returned. - """ - - def __init__(self, filename, columns): - super().__init__(filename, columns) - self.idlut = {} - try: - df = pd.read_csv(filename) - df = df.replace({float("nan"): None, "": None}) - except Exception: - pass - else: - for _, row in df.iterrows(): - id = row["id"] - data = row[columns].to_dict() - self.idlut[fingerprint.hash(data)] = id - - def append(self, data): - if isinstance(data, list): - for row in data: - self.append(row) - return - - # Normalize and hash. - df = pd.DataFrame(data, index=[0], columns=self.columns) - df = df.replace({float("nan"): None, "": None}) - data = df.to_dict("records")[0] - - hash = fingerprint.hash(data) - if hash in self.idlut: - return self.idlut[hash] - id = super().append(data) - self.idlut[hash] = id - return id - - -tables = { - Table.BENCHMARK: { - "type": UniqueCSVFile, - "cols": [ - "suite", - "name", - "type", - "image", - "params_hash", - ], - }, - Table.SWPROFILE: { - "type": UniqueCSVFile, - "cols": [ - "name", - "kernel_name", - "kernel_git_sha", - "kernel_kconfig_full_hash", - "kernel_cmdline_full_hash", - "userspace_name", - "cmdline", - "sysctl", - "bootscript", - ], - }, - Table.CPU: { - "type": UniqueCSVFile, - "cols": [ - "desc", - "cpu_index", - "sut_id", - ], - }, - Table.ERROR: { - "type": CSVFile, - "cols": [ - "timestamp", - "sut_id", - "swprofile_id", - "benchmark_id", - "session_uuid", - "error", - ], - }, - Table.PARAM: { - "type": UniqueCSVFile, - "cols": [ - "name", - "value", - "benchmark_id", - ], - }, - Table.RESULT: { - "type": CSVFile, - "cols": [ - "timestamp", - "resultclass_id", - "sut_id", - "swprofile_id", - "session_uuid", - "value", - ], - }, - Table.RESULTCLASS: { - "type": UniqueCSVFile, - "cols": [ - "benchmark_id", - "name", - "unit", - "improvement", - ], - }, - Table.SUT: { - "type": UniqueCSVFile, - "cols": [ - "name", - "host_name", - "architecture", - "cpu_count", - "cpu_info_hash", - "numa_count", - "ram_sz", - "hypervisor", - "product_name", - "product_serial", - "mac_addrs_hash", - ], - }, -} - - -def new_csvfile(table, directory): - filename = os.path.join(directory, f"{table.name}.csv") - csv = tables[table]["type"](filename, tables[table]["cols"]) - return csv - - def slugify(value): value = unicodedata.normalize("NFKD", value).encode("ascii").decode("ascii") value = re.sub(r"[^a-zA-Z0-9]+", "-", value) @@ -530,7 +332,7 @@ def restart_container(ctx, image): start_container(ctx, image) -def do_one_repeat(basedir, ids, benchmark, session_uuid, repeat): +def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): warmup = basedir is None ctx.log(f"BEGIN: {'warmup' if warmup else 'repeat'}: {repeat}\n") @@ -545,16 +347,16 @@ def do_one_repeat(basedir, ids, benchmark, session_uuid, repeat): """ ) bmdata = { - "suite": benchmark["suite"], - "name": benchmark["name"], - "params": benchmark["params"], + "suite": benchmark.suite, + "name": benchmark.name, + "params": benchmark.planobj["params"], } f = io.BytesIO(plan.dump(bmdata).encode()) ctx.put(f, "/tmp/fastpath-share/benchmark.yaml") # Invoke docker container on SUT and wait for timeout. error = BenchmarkError.NONE - timeout = timeout_to_secs(benchmark["timeout"]) + timeout = timeout_to_secs(benchmark.planobj["timeout"]) try: ctx.run( f""" @@ -570,12 +372,12 @@ def do_one_repeat(basedir, ids, benchmark, session_uuid, repeat): except invoke.exceptions.UnexpectedExit: error = BenchmarkError.BENCHMARK_FAIL - pbar.progress(benchmark, session_uuid, repeat) + pbar.progress(benchmark, suuid, repeat) # If there was an error, restart the container to clear up any dirty state. # e.g. if the benchmark timed out, the threads will still be running. if error != BenchmarkError.NONE: - restart_container(ctx, benchmark["image"]) + restart_container(ctx, benchmark.image) # If it's a warmup iteration, don't bother to parse the results. if warmup: @@ -617,51 +419,59 @@ def do_one_repeat(basedir, ids, benchmark, session_uuid, repeat): df = pd.DataFrame([{"error": BenchmarkError.NO_RESULTS_FILE.value}]) # Insert all the extra fields we need. - df["benchmark_id"] = ids[Table.BENCHMARK] - df["sut_id"] = ids[Table.SUT] - df["swprofile_id"] = ids[Table.SWPROFILE] - df["session_uuid"] = session_uuid + df["session_uuid"] = suuid df["timestamp"] = datetime.datetime.now() - # Output to RESULTCLASS and RESULT csvs. + results = [] + + # Create ResultClass and Result objects. for _, row in df[df["error"] == 0].iterrows(): - resultclass = dict(row[tables[Table.RESULTCLASS]["cols"]]) - resultclass_id = csvs[Table.RESULTCLASS].append(resultclass) - row["resultclass_id"] = resultclass_id - result = dict(row[tables[Table.RESULT]["cols"]]) - csvs[Table.RESULT].append(result) + resultclass = ResultClass(row) + resultclass.benchmark = benchmark - # Output to ERROR csv. + result = Result(row) + result.resultclass = resultclass + result.sut = sut + result.swprofile = swprofile + + results.append(result) + + # Create Error objects. for _, row in df[df["error"] != 0].iterrows(): - err = dict(row[tables[Table.ERROR]["cols"]]) - csvs[Table.ERROR].append(err) + err = Error(row) + err.sut = sut + err.swprofile = swprofile + err.benchmark = benchmark + + results.append(err) + + # Commit to resultstore. + rstore.merge(results) ctx.log(f"END: repeat: {repeat} ({error})\n") -def do_one_benchmark_exec(basedir, ids, suuid, benchmark): - bname = f"{benchmark['suite']}/{benchmark['name']}" +def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): + bname = f"{benchmark.suite}/{benchmark.name}" ctx.log(f"BEGIN: benchmark_exec: {bname}\n") cleanup_containers(ctx) try: - start_container(ctx, benchmark["image"]) + start_container(ctx, benchmark.image) - ids[Table.BENCHMARK] = benchmark["id"] + for warmup in range(benchmark.planobj["warmups"]): + do_one_repeat(None, sut, swprofile, benchmark, suuid, warmup) - for warmup in range(benchmark["warmups"]): - do_one_repeat(None, None, benchmark, suuid, warmup) - - for repeat in range(benchmark["repeats"]): - do_one_repeat(basedir, ids, benchmark, suuid, repeat) + for repeat in range(benchmark.planobj["repeats"]): + do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat) finally: stop_container(ctx) ctx.log(f"END: benchmark_exec: {bname}\n") -def do_one_session(basedir, ids, benchmarks, session): +def do_one_session(basedir, sut, swprofile, benchmarks, session): ctx.log(f"BEGIN: session: {session}\n") # Don't reboot for the first session, because do_one_swprofile() has already @@ -676,121 +486,176 @@ def do_one_session(basedir, ids, benchmarks, session): kmsg.start(ctx, kmsglog) for benchmark in benchmarks: - if benchmark["sessions"] <= session: + if benchmark.planobj["sessions"] <= session: continue - basedir = logs_dir(benchmark["dir"], name) - do_one_benchmark_exec(basedir, ids, suuid, benchmark) + basedir = logs_dir(benchmark.dir, name) + do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid) kmsg.stop(ctx) ctx.log(f"END: session: {session}\n") -def do_one_benchmark_setup(basedir, ids, benchmark): - bname = f"{benchmark['suite']}/{benchmark['name']}" +def do_one_benchmark_setup(basedir, sut, swprofile, benchmark): + bname = f"{benchmark.suite}/{benchmark.name}" ctx.log(f"BEGIN: benchmark_setup: {bname}\n") - # Grab benchmark and params info and clean for DB. - benchmark_row = dict(benchmark) - param_rows = benchmark_row["params"] - del benchmark_row["params"] - benchmark_row["params_hash"] = fingerprint.hash(param_rows) - - # Add benchmark to DB and generate results dir and yaml for benchmark. - benchmark["id"] = csvs[Table.BENCHMARK].append(benchmark_row) + # Generate results dir and yaml for benchmark. + benchmark_dict = benchmark.to_dict(notattrs=["id"]) name = "benchmark-{}-{}-{}".format( - benchmark_row["suite"], - benchmark_row["name"], - fingerprint.hash(benchmark_row)[:8], + benchmark.suite, + benchmark.name, + fingerprint.hash(benchmark_dict)[:8], ) - basedir = logs_dir(basedir, name, benchmark_row, "benchmark.yaml") - benchmark["dir"] = basedir - - # Add params to DB. - for name, value in param_rows.items(): - csvs[Table.PARAM].append( - { - "name": name, - "value": value, - "benchmark_id": benchmark["id"], - } - ) + benchmark.dir = logs_dir(basedir, name, benchmark_dict, "benchmark.yaml") ctx.log(f"END: benchmark_setup: {bname}\n") -def do_one_swprofile(basedir, ids, swprofile, benchmarks, idx): - ctx.log(f"BEGIN: swprofile: {swprofile['name']}\n") +def do_one_swprofile(basedir, sut, swprofile, benchmarks): + ctx.log(f"BEGIN: swprofile: {swprofile.name}\n") - # Install the swprofile on sut. swprofile 0 is installed by do_one_sut(). - if idx > 0: - configure.configure(ctx, swprofile) + # Install the swprofile on sut if not already done by do_one_sut(). + if not hasattr(swprofile, "configured") or not swprofile.configured: + configure.configure(ctx, swprofile.planobj) - # Grab swprofile and clean for DB. - swprofile_row = fingerprint.sut_query(ctx)["sw"] - swprofile_row["name"] = swprofile["name"] or slugify( - swprofile_row["kernel_name"] - ) - swprofile_row["kernel_git_sha"] = swprofile["gitsha"] or "" - swprofile_row["cmdline"] = "\n".join(swprofile["cmdline"]) - swprofile_row["sysctl"] = "\n".join(swprofile["sysctl"]) - swprofile_row["bootscript"] = "\n".join(swprofile["bootscript"]) + # Finish initializing the swprofile with the sw fingerprint. + swprofile.init_sw_fingerprint(fingerprint.sut_query(ctx)["sw"]) - # Add swprofile to DB and generate results dir and yaml for swprofile. - ids[Table.SWPROFILE] = csvs[Table.SWPROFILE].append(swprofile_row) + # Generate results dir and yaml for swprofile. + swprofile_dict = swprofile.to_dict(notattrs=["id"]) name = "swprofile-{}-{}".format( - swprofile_row["name"], fingerprint.hash(swprofile_row)[:8] + swprofile.name, fingerprint.hash(swprofile_dict)[:8] ) - basedir = logs_dir(basedir, name, swprofile_row, "swprofile.yaml") + basedir = logs_dir(basedir, name, swprofile_dict, "swprofile.yaml") # Setup all the benchmarks. for benchmark in benchmarks: - do_one_benchmark_setup(basedir, ids, benchmark) + do_one_benchmark_setup(basedir, sut, swprofile, benchmark) # Run the max number of sessions required by a benchmark. - for session in range(max(b["sessions"] for b in benchmarks)): - do_one_session(basedir, ids, benchmarks, session) + for session in range(max(b.planobj["sessions"] for b in benchmarks)): + do_one_session(basedir, sut, swprofile, benchmarks, session) - ctx.log(f"END: swprofile: {swprofile['name']}\n") + ctx.log(f"END: swprofile: {swprofile.name}\n") -def do_one_sut(basedir, ids, sut, swprofiles, benchmarks): - ctx.log(f"BEGIN: sut: {sut['name']}\n") +def do_one_sut(basedir, sut, swprofiles, benchmarks): + ctx.log(f"BEGIN: sut: {sut.name}\n") # Install the first swprofile on the sut. We do this early (subsequent # swprofiles are installed in do_one_swprofile()) so that we can safely know # for sure that the running kernel supports docker and has the capabilities # required for fingerprinting. - configure.configure(ctx, swprofiles[0]) + configure.configure(ctx, swprofiles[0].planobj) + swprofiles[0].configured = True # Pull all the images. This ensures we are not running with a stale version. # And doing it centrally here ensures every benchmark invocation uses the # same image. - images = list(set([b["image"] for b in benchmarks])) + images = list(set([b.image for b in benchmarks])) for image in images: ctx.run(f"docker image pull {image}", warn=True) ctx.run(f"docker image prune --force") - # Grab sut and cpu info and clean for DB. - sut_row = fingerprint.sut_query(ctx)["hw"] - sut_row["name"] = sut["name"] or slugify(sut_row["host_name"]) - cpu_rows = sut_row["cpu_info"] - del sut_row["cpu_info"] - - # Add sut and cpus to DB. - ids[Table.SUT] = csvs[Table.SUT].append(sut_row) - for cpu_row in cpu_rows: - cpu_index = cpu_row["cpu_index"] - del cpu_row["cpu_index"] - cpu_row = { - "desc": json.dumps(cpu_row), - "cpu_index": cpu_index, - "sut_id": ids[Table.SUT], - } - csvs[Table.CPU].append(cpu_row) + # Finish initializing the sut with the hw fingerprint. + sut.init_hw_fingerprint(fingerprint.sut_query(ctx)["hw"]) # Iterate over the swprofiles. - for idx, swprofile in enumerate(swprofiles): - do_one_swprofile(basedir, ids, swprofile, benchmarks, idx) + for swprofile in swprofiles: + do_one_swprofile(basedir, sut, swprofile, benchmarks) + + ctx.log(f"END: sut: {sut.name}\n") + + +class PlanObjMixin: + def set_planobj(self, planobj): + self.planobj = planobj + + +class Benchmark(schema.BENCHMARK, PlanObjMixin): + def __init__(self, planobj): + self.set_planobj(planobj) + super().__init__( + suite=planobj["suite"], + name=planobj["name"], + type=planobj["type"], + image=planobj["image"], + params_hash=fingerprint.hash(planobj["params"]), + ) + self.params = [Param(n, v) for n, v in planobj["params"].items()] + + +class Cpu(schema.CPU): + def __init__(self, cpu_info): + cpu_info = dict(cpu_info) + cpu_index = cpu_info["cpu_index"] + del cpu_info["cpu_index"] + super().__init__( + desc=json.dumps(cpu_info), + cpu_index=cpu_index, + ) + + +class Error(schema.ERROR): + def __init__(self, row): + error = dict(row[["timestamp", "session_uuid", "error"]]) + super().__init__(**error) + + +class Param(schema.PARAM): + def __init__(self, name, value): + super().__init__(name=name, value=value) + + +class Result(schema.RESULT): + def __init__(self, row): + result = dict(row[["timestamp", "session_uuid", "value"]]) + super().__init__(**result) + + +class ResultClass(schema.RESULTCLASS): + def __init__(self, row): + resultclass = dict(row[["name", "unit", "improvement"]]) + super().__init__(**resultclass) + + +class Sut(schema.SUT, PlanObjMixin): + def __init__(self, planobj): + self.set_planobj(planobj) + super().__init__(name=planobj["name"]) + + def init_hw_fingerprint(self, hw): + if not self.name: + self.name = slugify(hw["host_name"]) + self.host_name = hw["host_name"] + self.architecture = hw["architecture"] + self.cpu_count = hw["cpu_count"] + self.cpu_info_hash = hw["cpu_info_hash"] + self.numa_count = hw["numa_count"] + self.ram_sz = hw["ram_sz"] + self.hypervisor = hw["hypervisor"] or "" + self.product_name = hw["product_name"] or "" + self.product_serial = hw["product_serial"] or "" + self.mac_addrs_hash = hw["mac_addrs_hash"] + self.cpus = [Cpu(cpu_info) for cpu_info in hw["cpu_info"]] + + +class SwProfile(schema.SWPROFILE, PlanObjMixin): + def __init__(self, planobj): + self.set_planobj(planobj) + super().__init__( + name=planobj["name"], + kernel_git_sha=planobj["gitsha"] or "", + cmdline="\n".join(planobj["cmdline"]), + sysctl="\n".join(planobj["sysctl"]), + bootscript="\n".join(planobj["bootscript"]), + ) - ctx.log(f"END: sut: {sut['name']}\n") + def init_sw_fingerprint(self, sw): + if not self.name: + self.name = slugify(sw["kernel_name"]) + self.kernel_name = sw["kernel_name"] + self.kernel_kconfig_full_hash = sw["kernel_kconfig_full_hash"] + self.kernel_cmdline_full_hash = sw["kernel_cmdline_full_hash"] + self.userspace_name = sw["userspace_name"] or "" diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index f48c9ed..bb41fb6 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -326,8 +326,8 @@ class ResultSet: returned from query_results() or query_errors(). Currently only support passing RESULT and ERROR objects. """ - errors = [o for o in objects if type(o) == schema.ERROR] - results = [o for o in objects if type(o) == schema.RESULT] + errors = [o for o in objects if isinstance(o, schema.ERROR)] + results = [o for o in objects if isinstance(o, schema.RESULT)] if len(errors) + len(results) != len(objects): raise Exception("merge only supports for ERROR and RESULT objects") diff --git a/fastpath/utils/schema.py b/fastpath/utils/schema.py index ef331a5..99538e7 100644 --- a/fastpath/utils/schema.py +++ b/fastpath/utils/schema.py @@ -16,12 +16,18 @@ BaseTable = declarative_base() class BaseMixin: - def to_dict(self, attrs): - return { - c.key: getattr(self, c.key) - for c in sa.inspection.inspect(self).mapper.column_attrs - if c.key in attrs - } + def to_dict(self, attrs=None, notattrs=None): + """ + Returns object as a dict. Returns the keys that are in attrs and not in + notattrs. If attrs is None, behaves as if attrs is the list of all keys. + If notattrs is None, behaves as if notattrs is an empty list. + """ + keys = [c.key for c in sa.inspection.inspect(self).mapper.column_attrs] + if attrs: + keys = [k for k in keys if k in attrs] + if notattrs: + keys = [k for k in keys if k not in notattrs] + return {k: getattr(self, k) for k in keys} class BENCHMARK(BaseTable, BaseMixin): -- GitLab From 163ba2506b35801bbbd1f54be9e3442a46161a3f Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 8 Jul 2025 16:24:28 +0100 Subject: [PATCH 07/29] cli: Separate logger and machine context Previously the logger was exposed via the machine context. This has worked ok because both are singletons. But we are about to introduce multi-node sut support, so there will be a machine context per node. So let's separate the logger from the machine context. Introduce a Logger class, that wraps a file object and appends the timestamp whenever a log message is output. Then provide the logger to the machine context and have the machine context output it's machine-specific logs via the logger. These machine-specific logs have the host name appended to each line that is output. With this in place, we can tidy up "plan exec" to output global log messages directly via the logger, and to store the machine context inside the sut object. In future, there will be a machine context associated with each node. Signed-off-by: Ryan Roberts --- fastpath/commands/nouns/sut.py | 16 ++---- fastpath/commands/verbs/plan/exec.py | 84 +++++++++++++++------------- fastpath/utils/logger.py | 28 ++++++++++ fastpath/utils/machine.py | 20 +++---- 4 files changed, 88 insertions(+), 60 deletions(-) create mode 100644 fastpath/utils/logger.py diff --git a/fastpath/commands/nouns/sut.py b/fastpath/commands/nouns/sut.py index 673eace..5189b4e 100644 --- a/fastpath/commands/nouns/sut.py +++ b/fastpath/commands/nouns/sut.py @@ -12,6 +12,9 @@ from fastpath.commands.verbs.sut import reboot from fastpath.commands.verbs.sut import uninstall +from fastpath.utils import logger + + noun_name = os.path.splitext(os.path.basename(__file__))[0] noun_help = """system under test where benchmarks execute""" noun_desc = """Operate on a system under test (SUT) where benchmarks execute.""" @@ -116,18 +119,9 @@ def dispatch(args): "keyfile": args.keyfile, } + args.log = logger.Logger() if args.verbose: - args.log = sys.stdout - else: - - class DevNull: - def flush(self): - pass - - def write(self, str): - pass - - args.log = DevNull() + args.log.set_logfile(sys.stdout) # Dispatch to the correct verb handler. verbs[args.verb].dispatch(args) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 806e293..3144ffb 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -19,6 +19,7 @@ from fastpath.commands import cliutils from fastpath.utils import configure from fastpath.utils import fingerprint from fastpath.utils import kmsg +from fastpath.utils import logger from fastpath.utils import machine from fastpath.utils import plan from fastpath.utils import resultstore as rs @@ -90,7 +91,7 @@ def add_parser(parser, formatter, add_noun_args): rstore = None -ctx = None +log = logger.Logger() pbar = None @@ -100,7 +101,7 @@ def dispatch(args): subcommand, with the arguments the user passed on the command line. The arguments comply with those requested in add_parser(). """ - global rstore, ctx, pbar + global rstore, pbar normplan = plan.load(args.plan) sut = Sut(normplan["sut"]) @@ -119,20 +120,25 @@ def dispatch(args): try: # Execute! print(f"Executing {os.path.basename(args.plan)}...", file=sys.stderr) - with open(os.path.join(basedir, "fastpath.log"), "a") as log: - with machine.open(log, cnct["method"], cnct["params"]) as ctx_local: + with open(os.path.join(basedir, "fastpath.log"), "a") as logfile: + log.set_logfile(logfile) + try: + sut.ctx = machine.open(log, cnct["method"], cnct["params"]) with ProgressBar(normplan) as pbar_local: pbar = pbar_local - ctx = ctx_local do_one_sut(logsdir, sut, swprofiles, benchmarks) + finally: + if hasattr(sut, "ctx"): + sut.ctx.close() + del sut.ctx + log.set_logfile(None) + pbar = None finally: # Export to csv if that's what the requested output is. if rs.is_csv(url): rstore.to_csv(url) rstore.close() rstore = None - ctx = None - pbar = None class ProgressBar: @@ -334,12 +340,12 @@ def restart_container(ctx, image): def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): warmup = basedir is None - ctx.log(f"BEGIN: {'warmup' if warmup else 'repeat'}: {repeat}\n") + log.log(f"BEGIN: {'warmup' if warmup else 'repeat'}: {repeat}\n") # Prep the shared directory on the sut and populate the benchmark meta data # that the container will consume. The directory may have files owned by # root due to being generated by the container which runs with --privileged. - ctx.run( + sut.ctx.run( """ rm -rf /tmp/fastpath-share.tar.gz sudo rm -rf /tmp/fastpath-share/* @@ -352,13 +358,13 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): "params": benchmark.planobj["params"], } f = io.BytesIO(plan.dump(bmdata).encode()) - ctx.put(f, "/tmp/fastpath-share/benchmark.yaml") + sut.ctx.put(f, "/tmp/fastpath-share/benchmark.yaml") # Invoke docker container on SUT and wait for timeout. error = BenchmarkError.NONE timeout = timeout_to_secs(benchmark.planobj["timeout"]) try: - ctx.run( + sut.ctx.run( f""" docker exec \\ --privileged \\ @@ -377,22 +383,24 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): # If there was an error, restart the container to clear up any dirty state. # e.g. if the benchmark timed out, the threads will still be running. if error != BenchmarkError.NONE: - restart_container(ctx, benchmark.image) + restart_container(sut.ctx, benchmark.image) # If it's a warmup iteration, don't bother to parse the results. if warmup: - ctx.log(f"END: warmup: {repeat} ({error})\n") + log.log(f"END: warmup: {repeat} ({error})\n") return # Compress /tmp/fastpath-share and copy back, then uncompress into repeat's # log directory. - ctx.run( + sut.ctx.run( """ cd /tmp tar -czf fastpath-share.tar.gz fastpath-share """ ) - ctx.get("/tmp/fastpath-share.tar.gz", f"{basedir}/fastpath-share.tar.gz") + sut.ctx.get( + "/tmp/fastpath-share.tar.gz", f"{basedir}/fastpath-share.tar.gz" + ) subprocess.run( f""" cd {basedir} && @@ -448,17 +456,17 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): # Commit to resultstore. rstore.merge(results) - ctx.log(f"END: repeat: {repeat} ({error})\n") + log.log(f"END: repeat: {repeat} ({error})\n") def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): bname = f"{benchmark.suite}/{benchmark.name}" - ctx.log(f"BEGIN: benchmark_exec: {bname}\n") + log.log(f"BEGIN: benchmark_exec: {bname}\n") - cleanup_containers(ctx) + cleanup_containers(sut.ctx) try: - start_container(ctx, benchmark.image) + start_container(sut.ctx, benchmark.image) for warmup in range(benchmark.planobj["warmups"]): do_one_repeat(None, sut, swprofile, benchmark, suuid, warmup) @@ -466,24 +474,24 @@ def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): for repeat in range(benchmark.planobj["repeats"]): do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat) finally: - stop_container(ctx) + stop_container(sut.ctx) - ctx.log(f"END: benchmark_exec: {bname}\n") + log.log(f"END: benchmark_exec: {bname}\n") def do_one_session(basedir, sut, swprofile, benchmarks, session): - ctx.log(f"BEGIN: session: {session}\n") + log.log(f"BEGIN: session: {session}\n") # Don't reboot for the first session, because do_one_swprofile() has already # rebooted to fingerprint the swprofile. if session > 0: - configure.reboot_configured(ctx) + configure.reboot_configured(sut.ctx) suuid = uuid.uuid4() name = f"session-{suuid}" kmsglog = os.path.join(basedir, f"{name}.kmsg") - kmsg.start(ctx, kmsglog) + kmsg.start(sut.ctx, kmsglog) for benchmark in benchmarks: if benchmark.planobj["sessions"] <= session: @@ -492,13 +500,13 @@ def do_one_session(basedir, sut, swprofile, benchmarks, session): basedir = logs_dir(benchmark.dir, name) do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid) - kmsg.stop(ctx) - ctx.log(f"END: session: {session}\n") + kmsg.stop(sut.ctx) + log.log(f"END: session: {session}\n") def do_one_benchmark_setup(basedir, sut, swprofile, benchmark): bname = f"{benchmark.suite}/{benchmark.name}" - ctx.log(f"BEGIN: benchmark_setup: {bname}\n") + log.log(f"BEGIN: benchmark_setup: {bname}\n") # Generate results dir and yaml for benchmark. benchmark_dict = benchmark.to_dict(notattrs=["id"]) @@ -509,18 +517,18 @@ def do_one_benchmark_setup(basedir, sut, swprofile, benchmark): ) benchmark.dir = logs_dir(basedir, name, benchmark_dict, "benchmark.yaml") - ctx.log(f"END: benchmark_setup: {bname}\n") + log.log(f"END: benchmark_setup: {bname}\n") def do_one_swprofile(basedir, sut, swprofile, benchmarks): - ctx.log(f"BEGIN: swprofile: {swprofile.name}\n") + log.log(f"BEGIN: swprofile: {swprofile.name}\n") # Install the swprofile on sut if not already done by do_one_sut(). if not hasattr(swprofile, "configured") or not swprofile.configured: - configure.configure(ctx, swprofile.planobj) + configure.configure(sut.ctx, swprofile.planobj) # Finish initializing the swprofile with the sw fingerprint. - swprofile.init_sw_fingerprint(fingerprint.sut_query(ctx)["sw"]) + swprofile.init_sw_fingerprint(fingerprint.sut_query(sut.ctx)["sw"]) # Generate results dir and yaml for swprofile. swprofile_dict = swprofile.to_dict(notattrs=["id"]) @@ -537,17 +545,17 @@ def do_one_swprofile(basedir, sut, swprofile, benchmarks): for session in range(max(b.planobj["sessions"] for b in benchmarks)): do_one_session(basedir, sut, swprofile, benchmarks, session) - ctx.log(f"END: swprofile: {swprofile.name}\n") + log.log(f"END: swprofile: {swprofile.name}\n") def do_one_sut(basedir, sut, swprofiles, benchmarks): - ctx.log(f"BEGIN: sut: {sut.name}\n") + log.log(f"BEGIN: sut: {sut.name}\n") # Install the first swprofile on the sut. We do this early (subsequent # swprofiles are installed in do_one_swprofile()) so that we can safely know # for sure that the running kernel supports docker and has the capabilities # required for fingerprinting. - configure.configure(ctx, swprofiles[0].planobj) + configure.configure(sut.ctx, swprofiles[0].planobj) swprofiles[0].configured = True # Pull all the images. This ensures we are not running with a stale version. @@ -555,17 +563,17 @@ def do_one_sut(basedir, sut, swprofiles, benchmarks): # same image. images = list(set([b.image for b in benchmarks])) for image in images: - ctx.run(f"docker image pull {image}", warn=True) - ctx.run(f"docker image prune --force") + sut.ctx.run(f"docker image pull {image}", warn=True) + sut.ctx.run(f"docker image prune --force") # Finish initializing the sut with the hw fingerprint. - sut.init_hw_fingerprint(fingerprint.sut_query(ctx)["hw"]) + sut.init_hw_fingerprint(fingerprint.sut_query(sut.ctx)["hw"]) # Iterate over the swprofiles. for swprofile in swprofiles: do_one_swprofile(basedir, sut, swprofile, benchmarks) - ctx.log(f"END: sut: {sut.name}\n") + log.log(f"END: sut: {sut.name}\n") class PlanObjMixin: diff --git a/fastpath/utils/logger.py b/fastpath/utils/logger.py new file mode 100644 index 0000000..68ec462 --- /dev/null +++ b/fastpath/utils/logger.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025, Arm Limited. +# SPDX-License-Identifier: MIT + + +import datetime + + +class Logger: + def __init__(self, logfile=None): + self.set_logfile(logfile) + + def set_logfile(self, logfile): + self.logfile = logfile + + def log(self, msg): + if not self.logfile or len(msg) == 0: + return + + t = str(datetime.datetime.now()) + + lines = msg.split("\n") + assert lines[-1] == "" + lines = [f"{t}: {l}" for l in lines[:-1]] + lines.append("") + + msg = "\n".join(lines) + self.logfile.write(msg) + self.logfile.flush() diff --git a/fastpath/utils/machine.py b/fastpath/utils/machine.py index c49141b..1a5b1d9 100644 --- a/fastpath/utils/machine.py +++ b/fastpath/utils/machine.py @@ -2,36 +2,34 @@ # SPDX-License-Identifier: MIT -import datetime import fabric import invoke import io import logging import textwrap import time -import sys -def _log(log, msg): +def _log(logfile, msg): if len(msg) == 0: return - t = str(datetime.datetime.now()) + label = logfile["label"] + logger = logfile["logger"] lines = msg.split("\n") assert lines[-1] == "" - lines = [f"{t}: {l}" for l in lines[:-1]] + lines = [f"{label}: {l}" for l in lines[:-1]] lines.append("") msg = "\n".join(lines) - log.write(msg) - log.flush() + logger.log(msg) -def _write_label(log, label): +def _write_label(logfile, label): label = f"# {label} " label = label + "-" * max(80 - len(label), 0) + "\n" - _log(log, label) + _log(logfile, label) def _fixup_newline(msg): @@ -41,9 +39,9 @@ def _fixup_newline(msg): class SSHMachine: - def __init__(self, log, params): + def __init__(self, logger, params): self.trans_nr = 0 - self.logfile = log + self.logfile = {"label": params["host"], "logger": logger} self.connect_args = { "host": params["host"], -- GitLab From c95ed4f0608417b4a6a59101f3e2208e0037210d Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 29 Jul 2025 16:18:01 +0100 Subject: [PATCH 08/29] cli: Support multiple kmsg logger instances We will soon support multiple nodes per SUT and therefore need to be able to capture the kmsg logs from multiple machines simultaneously. Let's refactor into a proper kmsg.Logger() class to encapsulate the state instead of it being global. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 17 ++-- fastpath/utils/kmsg.py | 137 +++++++++++++-------------- 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 3144ffb..4d63a8e 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -491,16 +491,19 @@ def do_one_session(basedir, sut, swprofile, benchmarks, session): name = f"session-{suuid}" kmsglog = os.path.join(basedir, f"{name}.kmsg") - kmsg.start(sut.ctx, kmsglog) + logger = kmsg.Logger(sut.ctx, kmsglog) + logger.start() - for benchmark in benchmarks: - if benchmark.planobj["sessions"] <= session: - continue + try: + for benchmark in benchmarks: + if benchmark.planobj["sessions"] <= session: + continue - basedir = logs_dir(benchmark.dir, name) - do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid) + basedir = logs_dir(benchmark.dir, name) + do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid) + finally: + logger.stop() - kmsg.stop(sut.ctx) log.log(f"END: session: {session}\n") diff --git a/fastpath/utils/kmsg.py b/fastpath/utils/kmsg.py index cf9ebd9..bceede7 100644 --- a/fastpath/utils/kmsg.py +++ b/fastpath/utils/kmsg.py @@ -48,75 +48,72 @@ def _stop_netconsole(ctx): NETCONSOLE_PORT = 6666 -_log_thread = None -_stop_event = threading.Event() -_sock = None -def start(ctx, filename): - """ - Configure netconsole on the sut to send kernel logs over UDP then receive - them and write them to filename in a background thread. - """ - global _log_thread - global _stop_event - global _sock - - if _log_thread: - raise RuntimeError("kmsg logger already started") - - _stop_event.clear() - _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - try: - _sock.bind(("0.0.0.0", NETCONSOLE_PORT)) - _sock.settimeout(0.25) - - _start_netconsole(ctx) - - with open(filename, "a") as f: - t = str(datetime.datetime.now()) - print("\n", file=f) - print(f"### kmsg replay ###", file=f) - f.write(ctx.sudo("dmesg").stdout) - print(f"### {t}: kmsg live ###", file=f) - - def log(): - with open(filename, "ab") as f: - while not _stop_event.is_set(): - try: - data, _ = _sock.recvfrom(4096) - f.write(data) - f.flush() - except socket.timeout: - continue - except Exception as e: - print(f"### kmsg log error ({e}) ###", file=f) - break - - _log_thread = threading.Thread(target=log, daemon=True) - _log_thread.start() - except Exception as e: - with open(filename, "a") as f: - print(f"### kmsg unable to configure logging ({e}) ###", file=f) - - -def stop(ctx): - """ - Stop logging kernel messages over UDP. - """ - global _log_thread - global _stop_event - global _sock - - if _log_thread is None: - return - - _stop_event.set() - _log_thread.join() - _log_thread = None - if _sock: - _sock.close() - _sock = None - - _stop_netconsole(ctx) +class Logger: + def __init__(self, ctx, filename): + self.log_thread = None + self.stop_event = None + self.sock = None + self.ctx = ctx + self.filename = filename + + def start(self): + """ + Configure netconsole on the sut to send kernel logs over UDP then receive + them and write them to filename in a background thread. + """ + if self.log_thread: + raise RuntimeError("kmsg logger already started") + + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + try: + self.sock.bind(("0.0.0.0", NETCONSOLE_PORT)) + self.sock.settimeout(0.25) + + _start_netconsole(self.ctx) + + with open(self.filename, "a") as f: + t = str(datetime.datetime.now()) + print("\n", file=f) + print(f"### kmsg replay ###", file=f) + f.write(self.ctx.sudo("dmesg").stdout) + print(f"### {t}: kmsg live ###", file=f) + + def log(): + with open(self.filename, "ab") as f: + while not self.stop_event.is_set(): + try: + data, _ = self.sock.recvfrom(4096) + f.write(data) + f.flush() + except socket.timeout: + continue + except Exception as e: + print(f"### kmsg log error ({e}) ###", file=f) + break + + self.stop_event = threading.Event() + self.log_thread = threading.Thread(target=log, daemon=True) + self.log_thread.start() + except Exception as e: + with open(self.filename, "a") as f: + print(f"### kmsg unable to configure logging ({e}) ###", file=f) + + def stop(self): + """ + Stop logging kernel messages over UDP. + """ + if self.log_thread is None: + return + + self.stop_event.set() + self.log_thread.join() + self.log_thread = None + self.stop_event = None + if self.sock: + self.sock.close() + self.sock = None + + _stop_netconsole(self.ctx) -- GitLab From 2beb6f4493fab93de046ef813160f516faa38681 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Thu, 3 Jul 2025 17:30:18 +0100 Subject: [PATCH 09/29] docs: Extend resultstore schema to support multi-node The new multi-node SUT feature allows benchmarks to comprise multiple roles, each of which can run on a different node. Let's update the documentation to cover the required resultstore schema extensions. Signed-off-by: Ryan Roberts --- documentation/images/resultstoreschema.png | Bin 139539 -> 194151 bytes .../user-guide/resultstoreschema.rst | 94 +++++++++++++++--- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/documentation/images/resultstoreschema.png b/documentation/images/resultstoreschema.png index cf110a88586447d6fe9422664260749fa7230003..3d0303c3aaf7f01c7108cf1a623a1a7607097201 100644 GIT binary patch literal 194151 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

6499^`FFb{FeknJO36ZUkyY1+@!T40D|Aow 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 literal 139539 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 cYY&N@f3l-zk3Lk!f&Z>4+>p Date: Fri, 4 Jul 2025 10:49:55 +0100 Subject: [PATCH 10/29] docs: Extend plan schema to support multi-node The new multi-node SUT feature allows benchmarks to comprise multiple roles, each of which can run on a different node. Let's update the documentation to cover the required plan schema extensions. Note that the rolemap is logically a top-level object but it is convenient to embed each per-benchmark piece in the benchmark object, since it is difficult to clearly refer to a benchmark from another location given its suite and name are often abstracted into the benchmark fragment file. The embedded approach makes it clearer for the user. Signed-off-by: Ryan Roberts --- documentation/user-guide/planschema.rst | 37 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/documentation/user-guide/planschema.rst b/documentation/user-guide/planschema.rst index e288651..a6df13b 100644 --- a/documentation/user-guide/planschema.rst +++ b/documentation/user-guide/planschema.rst @@ -34,28 +34,43 @@ The sut dictionary describes The system under test (SUT): key type required default description ========== ====== ======== =========================== =========== name string false None User-supplied friendly name to identify the system under test. -connection dict true N/A Describes how to connect to SUT. See "connection object". +connection dict 1 from 2 N/A For a single-node SUT, describes how to connect to the node. See "node.connection object". Exactly one of "connection" and "nodes" is required. +nodes list 1 from 2 N/A A list of nodes objects, each of which describes a node (machine/device) from which the SUT is composed. Exactly one of "connection" and "nodes" is required. ========== ====== ======== =========================== =========== -********************* -sut.connection object -********************* +*********** +node object +*********** + +A node is a machine/device that runs a single instance of Linux. A SUT is +composed of one or mode nodes. It contains the following keys: + +========== ====== ======== =========================== =========== +key type required default description +========== ====== ======== =========================== =========== +name string false None User-supplied friendly name to identify the node. Must be unique amongst nodes. +connection dict true N/A Describes how to connect to the node. See "node.connection object". +========== ====== ======== =========================== =========== + +********************** +node.connection object +********************** -The connection dictionary describes how to connect to the SUT that the +The connection dictionary describes how to connect to the node that the benchmarks will run on. It contains the following keys: ========== ====== ======== =========================== =========== key type required default description ========== ====== ======== =========================== =========== -method enum true N/A Method used to connect to the SUT. For now, only "SSH" is supported. In future, "LAVA" will be added. +method enum true N/A Method used to connect to the node. For now, only "SSH" is supported. In future, "LAVA" will be added. params dict true N/A Method-specific dictionary of parameters. See "SSH-params" below. ========== ====== ======== =========================== =========== -******************************** -sut.connection.SSH-params object -******************************** +********************************* +node.connection.SSH-params object +********************************* -The SSH-params dictionary describes how to connect to a SUT using the "SSH" +The SSH-params dictionary describes how to connect to a node using the "SSH" method. It contains the following keys: ========== ====== ======== =========================== =========== @@ -109,6 +124,8 @@ repeats int false defaults.benchmark.repeats Number of times to repeat sessions int false defaults.benchmark.sessions Number of times to reboot the SUT to repeat the benchmark. warmups int false defaults.benchmark.warmups Number of times to run the benchmark at the start of a boot session to warm up the system before the real repeats are executed. timeout string false defaults.benchmark.timeout Timeout after which to assume the benchmark has hung. Provided as a string with format "" where the suffix is 's' (seconds), 'm' (minutes), 'h' (hours) or 'd' days. +roles list false [executer] List of roles that the benchmark implements. When multiple roles are defined, each role is executed in parallel, possibly on different nodes. If not specified, the benchmark is assumed to have a single role. +rolemap dict false {r: 0 for r in roles} Dictionary mapping roles to nodes, where key is the role and value represents the node, either an integer index into sut.nodes or a node name if a string. Multiple roles may be mapped to the same node. Any unmapped roles default to sut.nodes[0]. ========== ====== ======== =========================== =========== *************** -- GitLab From 82771349aee9aa9542bfe96fc0f7332d87a52673 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 4 Jul 2025 15:36:57 +0100 Subject: [PATCH 11/29] cli: Extend plan schema for multi-node SUTs Add validation and normalisation for plans that contain SUTs with multiple nodes and benchmarks with roles and rolemap properties. The change is fully backwards compatible with the previous plan schema, but the normalized plan that is used now moves it's connection(s) to the nodes list instead of hanging off the SUT directly. So the only internal user is temporarily updated to get the single connection info from sut.node[0]. For now, if multiple nodes or roles are passed, they will be validated and allowed, but ignored. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 4 +- fastpath/utils/plan.py | 177 ++++++++++++++++++++------- 2 files changed, 133 insertions(+), 48 deletions(-) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 4d63a8e..88e7764 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -108,7 +108,9 @@ def dispatch(args): swprofiles = [SwProfile(swprofile) for swprofile in normplan["swprofiles"]] benchmarks = [Benchmark(benchmark) for benchmark in normplan["benchmarks"]] - cnct = normplan["sut"]["connection"] + if len(normplan["sut"]["nodes"]) != 1: + raise Exception("multi-node suts are not yet supported") + cnct = normplan["sut"]["nodes"][0]["connection"] url = rs.normalize(args.resultstore if args.resultstore else args.output) rstore = rs.create_open_or_import(url, args.append) diff --git a/fastpath/utils/plan.py b/fastpath/utils/plan.py index d0a53b5..0cc6d08 100644 --- a/fastpath/utils/plan.py +++ b/fastpath/utils/plan.py @@ -59,41 +59,59 @@ _plan_pre_schema = { "default": None, "regex": r"[a-zA-Z_][a-zA-Z0-9_\-\.]*", }, - "connection": { - "type": "dict", + "nodes": { + "type": "list", "required": True, + "minlength": 1, "schema": { - "method": { - "type": "string", - "required": True, - "allowed": ["SSH"], - }, - "params": { - "type": "dict", - "required": True, - "schema": { - "host": { - "type": "string", - "required": True, - }, - "user": { - "type": "string", - "required": False, - "nullable": True, - "default": None, - }, - "port": { - "type": "integer", - "required": False, - "nullable": True, - "default": None, - "coerce": int, - }, - "keyfile": { - "type": "string", - "required": False, - "nullable": True, - "default": None, + "type": "dict", + "required": True, + "schema": { + "name": { + "type": "string", + "required": False, + "nullable": True, + "default": None, + "regex": r"[a-zA-Z_][a-zA-Z0-9_\-\.]*", + }, + "connection": { + "type": "dict", + "required": True, + "schema": { + "method": { + "type": "string", + "required": True, + "allowed": ["SSH"], + }, + "params": { + "type": "dict", + "required": True, + "schema": { + "host": { + "type": "string", + "required": True, + }, + "user": { + "type": "string", + "required": False, + "nullable": True, + "default": None, + }, + "port": { + "type": "integer", + "required": False, + "nullable": True, + "default": None, + "coerce": int, + }, + "keyfile": { + "type": "string", + "required": False, + "nullable": True, + "default": None, + }, + }, + }, }, }, }, @@ -104,6 +122,7 @@ _plan_pre_schema = { "swprofiles": { "type": "list", "required": True, + "minlength": 1, "schema": { "type": "dict", "required": True, @@ -170,6 +189,7 @@ _plan_pre_schema = { "benchmarks": { "type": "list", "required": True, + "minlength": 1, }, } @@ -233,6 +253,25 @@ _plan_post_schema["benchmarks"]["schema"] = { "regex": r"\d+[sSmMhHdD]", "required": True, }, + "roles": { + "type": "list", + "required": False, + "minlength": 1, + "unique": True, + "default": ["executer"], + "schema": { + "type": "string", + "regex": r"[a-zA-Z_][a-zA-Z0-9_\-\.]*", + }, + }, + "rolemap": { + "type": "dict", + "required": False, + "default": {}, + "valuesrules": { + "type": ["string", "integer"], + }, + }, }, } @@ -263,6 +302,17 @@ class ValidationError(Exception): pass +def _pre_normalize(plan): + # If we have sut.connection and no nodes, then we have a single-node sut. + # Create a single node with the connection. + sut = plan.get("sut") + if sut and "connection" in sut and "nodes" not in sut: + sut["nodes"] = [{"connection": sut["connection"]}] + del sut["connection"] + + return plan + + def _validate_normalize(structure, schema): """ Validate that structure conforms to schema, applying any normalization @@ -295,7 +345,18 @@ def _validate_normalize(structure, schema): return messages - v = cerberus.Validator(schema) + class PlanValidator(cerberus.Validator): + def _validate_unique(self, unique, field, value): + """ + Enforces that all items in a list are unique. + The rule's arguments are validated against this schema: + {'type': 'boolean'} + """ + if unique: + if len(value) != len(set(value)): + self._error(field, "items must be unique") + + v = PlanValidator(schema) structure = v.validated(structure) if not structure: raise ValidationError("\n".join(format_validation_errors(v.errors))) @@ -316,25 +377,30 @@ def _defaults_sort(defaults): return dict(sorted(defaults.items(), key=lambda x: lut[x[0]])) -def _sut_sort(sut): - lut = list( - _plan_post_schema["sut"]["schema"]["connection"]["schema"]["params"][ - "schema" - ].keys() - ) +def _node_sort(node): + schema = _plan_post_schema["sut"]["schema"]["nodes"]["schema"]["schema"] + + lut = list(schema["connection"]["schema"]["params"]["schema"].keys()) lut = {k: i for i, k in enumerate(lut)} - sut["connection"]["params"] = dict( - sorted(sut["connection"]["params"].items(), key=lambda x: lut[x[0]]) + node["connection"]["params"] = dict( + sorted(node["connection"]["params"].items(), key=lambda x: lut[x[0]]) ) - lut = list( - _plan_post_schema["sut"]["schema"]["connection"]["schema"].keys() - ) + lut = list(schema["connection"]["schema"].keys()) lut = {k: i for i, k in enumerate(lut)} - sut["connection"] = dict( - sorted(sut["connection"].items(), key=lambda x: lut[x[0]]) + node["connection"] = dict( + sorted(node["connection"].items(), key=lambda x: lut[x[0]]) ) + lut = list(schema.keys()) + lut = {k: i for i, k in enumerate(lut)} + return dict(sorted(node.items(), key=lambda x: lut[x[0]])) + + +def _sut_sort(sut): + for i, n in enumerate(sut["nodes"]): + sut["nodes"][i] = _node_sort(n) + lut = list(_plan_post_schema["sut"]["schema"].keys()) lut = {k: i for i, k in enumerate(lut)} return dict(sorted(sut.items(), key=lambda x: lut[x[0]])) @@ -421,6 +487,7 @@ def load(file_name): plan = yaml.safe_load(file) rel = os.path.dirname(file_name) + plan = _pre_normalize(plan) plan = _validate_normalize(plan, _plan_pre_schema) for i, bm in enumerate(plan["benchmarks"]): @@ -432,6 +499,22 @@ def load(file_name): plan["benchmarks"][i] = _merge(base, bm) plan = _validate_normalize(plan, _plan_post_schema) + + # Check rolemap keys are all in roles list and fill in any blanks, + # defaulting to node 0. + for i, bm in enumerate(plan["benchmarks"]): + valid_keys = set(bm["roles"]) + keys = set(bm["rolemap"].keys()) + invalid_keys = keys - valid_keys + if invalid_keys: + raise ValidationError( + f"benchmarks[{i}].rolemap: " + f"{list(invalid_keys)[0]} is not a valid role" + ) + else: + missing_roles = valid_keys - keys + bm["rolemap"].update({m: 0 for m in missing_roles}) + plan = _plan_sort(plan) return plan -- GitLab From 06566d9642845f68e0f86b117cf3d306d7adedb4 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 15 Jul 2025 15:39:16 +0100 Subject: [PATCH 12/29] cli: Introduce SSHMachine subconnections for parallel execution The soon-to-arrive "multi-node SUT" feature will require the ability to multiple commands in parallel on the remote SSHMachine. Currently this is not possible; a fabric connection is not thread safe and cannot be used to execute multiple commands. So let's add support for this, by introducing the concept of a subconnection. Any SSHMachine instance can now create children, each of which can run it's own command in parallel from a separate thread. The subconnection creates a new SSHMachine instance that contains the same connection parameters as the parent. Signed-off-by: Ryan Roberts --- fastpath/utils/machine.py | 56 ++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/fastpath/utils/machine.py b/fastpath/utils/machine.py index 1a5b1d9..0530674 100644 --- a/fastpath/utils/machine.py +++ b/fastpath/utils/machine.py @@ -8,6 +8,13 @@ import io import logging import textwrap import time +import threading + + +# Pamamico, used by fabric, sprays error info to stderr when throwing an +# exception. This happens a lot for the reboot() case, so let's suppress +# it. +logging.getLogger("paramiko").setLevel(logging.CRITICAL) def _log(logfile, msg): @@ -39,30 +46,38 @@ def _fixup_newline(msg): class SSHMachine: - def __init__(self, logger, params): + def __init__(self, logger=None, params=None, parent=None): + assert (logger != None and params != None) ^ (parent != None) + self.trans_nr = 0 - self.logfile = {"label": params["host"], "logger": logger} - - self.connect_args = { - "host": params["host"], - "user": params["user"], - "port": params["port"], - } - if params["keyfile"]: - self.connect_args["connect_kwargs"] = { - "key_filename": params["keyfile"] + self.subcons = 0 + self.subcons_lock = threading.Lock() + + if parent: + with parent.subcons_lock: + subcon = parent.subcons + parent.subcons += 1 + label = f"{parent.logfile['label']} subcon {subcon}" + self.logfile = {"label": label, "logger": parent.logfile["logger"]} + self.connect_args = parent.connect_args + else: + self.logfile = {"label": params["host"], "logger": logger} + + self.connect_args = { + "host": params["host"], + "user": params["user"], + "port": params["port"], } + if params["keyfile"]: + self.connect_args["connect_kwargs"] = { + "key_filename": params["keyfile"] + } self.connect() def connect(self, timeout=None): self.log(f"Connecting to {self.connect_args}...\n") - # Pamamico, used by fabric, sprays error info to stderr when throwing an - # exception. This happens a lot for the reboot() case, so let's suppress - # it. - logging.getLogger("paramiko").setLevel(logging.CRITICAL) - self.remote = fabric.Connection( **self.connect_args, connect_timeout=timeout ) @@ -236,6 +251,15 @@ class SSHMachine: _write_label(self.logfile, f"end reboot") + def subconnection(self): + """ + Returns a new SSHMachine instance that has it's own connection to the + remote machine. This allows for running parallel commands on the machine + in different threads. reboot() is only permitted by the root connection + which must have no active subconnections at the time. + """ + return SSHMachine(parent=self) + def open(log, method, params): # Only support SSH method currently. -- GitLab From 2a64c0a607fa62972fd2f92fc967518fe637620e Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 18 Jul 2025 11:50:56 +0100 Subject: [PATCH 13/29] cli: Run docker containers with host networking For the multi-node SUT feature, one benchmark role (running in one container on an arbitrary node) will need to be able to access another benchmark role (running in another container on an arbitrary node) via it's IP address. Previously each container used bridged networking which meant that the host could access it but nobody else could, without port forwarding. One option would be to create a docker network based around macvlan so that the container has it's own IP address that appears on the LAN as a peer of the host. But this is complex to setup and manage and has some annoying constraints. So let's keep things simple and just run the container with host networking, so it shares it's IP address with the host. This has some sandboxing implications but we are already running the container privileged, so it doesn't really reduce security in practice. It also has then nice benefit that the network stack is "more standard" for any benchmarks that care and performance should be exactly the same as it is outside of the container. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 88e7764..66f9b6d 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -315,6 +315,7 @@ def start_container(ctx, image): docker run \\ --detach \\ --privileged \\ + --network host \\ --volume /tmp/fastpath-share:/fastpath-share \\ --volume /lib/modules:/lib/modules \\ --name fastpath-benchmark \\ -- GitLab From d761c6c658658cc5d65eba41144d62d7e5307509 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 18 Jul 2025 11:57:48 +0100 Subject: [PATCH 14/29] cli: Provide benchmark role's IP address in benchmark.yaml For multi-node SUT support, we need to tell each benchmark role, the IP address where it can find the other benchmark roles. Since we are now using host networking for the container, the container IP address is the same as the host IP address. So let's add an API to query the remote machine's IP address. This is information that the fabric connection already has. Add the IP address to the benchmark.yaml container interface file. For now the ipmap only maps the single "executer" role, but we will shortly add support for multiple roles. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 9 +++++++++ fastpath/utils/machine.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 66f9b6d..1eaecfe 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -345,6 +345,13 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): warmup = basedir is None log.log(f"BEGIN: {'warmup' if warmup else 'repeat'}: {repeat}\n") + # Since we run the containers with host networking, the container shares + # it's IP address with the host so this works. + name = "executer" + ipmap = { + name: sut.ctx.ip_addr() + } + # Prep the shared directory on the sut and populate the benchmark meta data # that the container will consume. The directory may have files owned by # root due to being generated by the container which runs with --privileged. @@ -359,6 +366,8 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): "suite": benchmark.suite, "name": benchmark.name, "params": benchmark.planobj["params"], + "role": name, + "ipmap": ipmap, } f = io.BytesIO(plan.dump(bmdata).encode()) sut.ctx.put(f, "/tmp/fastpath-share/benchmark.yaml") diff --git a/fastpath/utils/machine.py b/fastpath/utils/machine.py index 0530674..dfe3f03 100644 --- a/fastpath/utils/machine.py +++ b/fastpath/utils/machine.py @@ -49,6 +49,7 @@ class SSHMachine: def __init__(self, logger=None, params=None, parent=None): assert (logger != None and params != None) ^ (parent != None) + self.ip = None self.trans_nr = 0 self.subcons = 0 self.subcons_lock = threading.Lock() @@ -84,6 +85,7 @@ class SSHMachine: try: self.remote.open() self.remote.transport.set_keepalive(15) + self.ip = self.remote.transport.sock.getpeername()[0] except Exception: del self.remote self.remote = None @@ -97,6 +99,7 @@ class SSHMachine: self.remote.close() del self.remote self.remote = None + self.ip = None self.log(f"Disconnected from {self.connect_args}\n") def close(self): @@ -251,6 +254,9 @@ class SSHMachine: _write_label(self.logfile, f"end reboot") + def ip_addr(self): + return self.ip + def subconnection(self): """ Returns a new SSHMachine instance that has it's own connection to the -- GitLab From abc6b9d712e3832e1193450b4e635baf64a3f84d Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 18 Jul 2025 12:23:06 +0100 Subject: [PATCH 15/29] cli: Track role name for containers and their output The multi-node SUT feature will introduce the concept of a benchmark role and multiple roles may run concurrently. So there is a need to address each container by role and save each container's output by role. In preparation for that, let's start doing that tracking now, but always use the single, default role; "executer". Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 55 +++++++++++++++------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index 1eaecfe..ffc1ed3 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -304,21 +304,21 @@ def cleanup_containers(ctx): ctx.log(f"Failed to cleanup containers: {e}\n") -def start_container(ctx, image): +def start_container(ctx, image, name): # If "docker run" fails, the subsequent "docker exec" will also fail, # and do_one_repeat() will log the error. Some benchmarks # (microvm/vmalloc) need to install a kernel module (test_vmalloc) so # give the container access to the modules. ctx.run( f""" - mkdir -p /tmp/fastpath-share + mkdir -p /tmp/fastpath-share-{name} docker run \\ --detach \\ --privileged \\ --network host \\ - --volume /tmp/fastpath-share:/fastpath-share \\ + --volume /tmp/fastpath-share-{name}:/fastpath-share \\ --volume /lib/modules:/lib/modules \\ - --name fastpath-benchmark \\ + --name fastpath-benchmark-{name} \\ {image} \\ sleep 48h """, @@ -326,19 +326,19 @@ def start_container(ctx, image): ) -def stop_container(ctx): +def stop_container(ctx, name): ctx.run( f""" - docker container stop fastpath-benchmark - docker container rm --force fastpath-benchmark + docker container stop fastpath-benchmark-{name} + docker container rm --force fastpath-benchmark-{name} """, warn=True, ) -def restart_container(ctx, image): - stop_container(ctx) - start_container(ctx, image) +def restart_container(ctx, image, name): + stop_container(ctx, name) + start_container(ctx, image, name) def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): @@ -356,10 +356,10 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): # that the container will consume. The directory may have files owned by # root due to being generated by the container which runs with --privileged. sut.ctx.run( - """ - rm -rf /tmp/fastpath-share.tar.gz - sudo rm -rf /tmp/fastpath-share/* - mkdir -p /tmp/fastpath-share/output + f""" + rm -rf /tmp/fastpath-share-{name}.tar.gz + sudo rm -rf /tmp/fastpath-share-{name}/* + mkdir -p /tmp/fastpath-share-{name}/output """ ) bmdata = { @@ -370,7 +370,7 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): "ipmap": ipmap, } f = io.BytesIO(plan.dump(bmdata).encode()) - sut.ctx.put(f, "/tmp/fastpath-share/benchmark.yaml") + sut.ctx.put(f, f"/tmp/fastpath-share-{name}/benchmark.yaml") # Invoke docker container on SUT and wait for timeout. error = BenchmarkError.NONE @@ -380,7 +380,7 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): f""" docker exec \\ --privileged \\ - fastpath-benchmark \\ + fastpath-benchmark-{name} \\ /fastpath/exec.py """, timeout=timeout, @@ -395,7 +395,7 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): # If there was an error, restart the container to clear up any dirty state. # e.g. if the benchmark timed out, the threads will still be running. if error != BenchmarkError.NONE: - restart_container(sut.ctx, benchmark.image) + restart_container(sut.ctx, benchmark.image, name) # If it's a warmup iteration, don't bother to parse the results. if warmup: @@ -405,20 +405,22 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): # Compress /tmp/fastpath-share and copy back, then uncompress into repeat's # log directory. sut.ctx.run( - """ + f""" cd /tmp - tar -czf fastpath-share.tar.gz fastpath-share + tar -czf fastpath-share-{name}.tar.gz fastpath-share-{name} """ ) sut.ctx.get( - "/tmp/fastpath-share.tar.gz", f"{basedir}/fastpath-share.tar.gz" + f"/tmp/fastpath-share-{name}.tar.gz", + f"{basedir}/fastpath-share-{name}.tar.gz", ) subprocess.run( f""" cd {basedir} && - tar -xf fastpath-share.tar.gz && - rm -rf fastpath-share.tar.gz && - mv fastpath-share repeat-{repeat} + tar -xf fastpath-share-{name}.tar.gz && + rm -rf fastpath-share-{name}.tar.gz && + mkdir -p repeat-{repeat} + mv fastpath-share-{name} repeat-{repeat}/role-{name} """, shell=True, check=True, @@ -431,7 +433,8 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): df = pd.DataFrame([{"error": error.value}]) else: try: - df = pd.read_csv(f"{basedir}/repeat-{repeat}/results.csv") + csv_file = f"{basedir}/repeat-{repeat}/role-{name}/results.csv" + df = pd.read_csv(csv_file) df = pd.DataFrame(validate_results(df.to_dict("records"))) except BenchmarkException as e: df = pd.DataFrame([{"error": e.error.value}]) @@ -478,7 +481,7 @@ def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): cleanup_containers(sut.ctx) try: - start_container(sut.ctx, benchmark.image) + start_container(sut.ctx, benchmark.image, "executer") for warmup in range(benchmark.planobj["warmups"]): do_one_repeat(None, sut, swprofile, benchmark, suuid, warmup) @@ -486,7 +489,7 @@ def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): for repeat in range(benchmark.planobj["repeats"]): do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat) finally: - stop_container(sut.ctx) + stop_container(sut.ctx, "executer") log.log(f"END: benchmark_exec: {bname}\n") -- GitLab From 0d15efa09621571f684f0c0c7a6f7a4af8285fc5 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 18 Jul 2025 13:49:52 +0100 Subject: [PATCH 16/29] cli: Multi-node SUT support for "plan exec" "plan exec" now supports running multi-role benchmarks across multiple nodes. It can consume the updated plan schema fully and the resultstore schema is updated to allow outputting the results in the new format. It can even configure/reboot nodes in parallel to optimize execution time. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/plan/exec.py | 429 +++++++++++++++++++-------- fastpath/utils/resultstore.py | 134 +++++++-- fastpath/utils/schema.py | 103 ++++++- 3 files changed, 501 insertions(+), 165 deletions(-) diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index ffc1ed3..bffd86b 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -4,6 +4,7 @@ import datetime import cerberus +from concurrent.futures import ThreadPoolExecutor import invoke import io import json @@ -108,9 +109,12 @@ def dispatch(args): swprofiles = [SwProfile(swprofile) for swprofile in normplan["swprofiles"]] benchmarks = [Benchmark(benchmark) for benchmark in normplan["benchmarks"]] - if len(normplan["sut"]["nodes"]) != 1: - raise Exception("multi-node suts are not yet supported") - cnct = normplan["sut"]["nodes"][0]["connection"] + # ROLEMAP is really a top-level object, but since it is described + # per-benchmark, it is convenient to attach it to the benchmark object. This + # attachment is not reflected in the resultstore schema, and is only for the + # benefit of code in this file. + for bm in benchmarks: + bm.rolemap = RoleMap(bm.planobj["rolemap"], bm, sut) url = rs.normalize(args.resultstore if args.resultstore else args.output) rstore = rs.create_open_or_import(url, args.append) @@ -125,14 +129,18 @@ def dispatch(args): with open(os.path.join(basedir, "fastpath.log"), "a") as logfile: log.set_logfile(logfile) try: - sut.ctx = machine.open(log, cnct["method"], cnct["params"]) + for node in sut.nodes: + method = node.planobj["connection"]["method"] + params = node.planobj["connection"]["params"] + node.ctx = machine.open(log, method, params) with ProgressBar(normplan) as pbar_local: pbar = pbar_local do_one_sut(logsdir, sut, swprofiles, benchmarks) finally: - if hasattr(sut, "ctx"): - sut.ctx.close() - del sut.ctx + for node in sut.nodes: + if hasattr(node, "ctx"): + node.ctx.close() + del node.ctx log.set_logfile(None) pbar = None finally: @@ -341,107 +349,172 @@ def restart_container(ctx, image, name): start_container(ctx, image, name) +def for_each_parallel(items, fn): + """ + Calls fn() for each item in items, all in parallel on background threads. + The return values from all fn() calls are returned as a list with indexes + corresponding to items. If any instance of fn() raises an exception, + for_each_parallel() raises that exception too. + """ + with ThreadPoolExecutor(max_workers=len(items)) as executor: + results = [] + futures = [] + for item in items: + if isinstance(item, dict): + future = executor.submit(fn, **item) + else: + future = executor.submit(fn, item) + futures.append(future) + for future in futures: + results.append(future.result()) + return results + + def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): warmup = basedir is None log.log(f"BEGIN: {'warmup' if warmup else 'repeat'}: {repeat}\n") # Since we run the containers with host networking, the container shares # it's IP address with the host so this works. - name = "executer" ipmap = { - name: sut.ctx.ip_addr() + role.name: benchmark.rolemap.map(role).ctx.ip_addr() + for role in benchmark.roles } - # Prep the shared directory on the sut and populate the benchmark meta data - # that the container will consume. The directory may have files owned by - # root due to being generated by the container which runs with --privileged. - sut.ctx.run( - f""" - rm -rf /tmp/fastpath-share-{name}.tar.gz - sudo rm -rf /tmp/fastpath-share-{name}/* - mkdir -p /tmp/fastpath-share-{name}/output - """ - ) - bmdata = { - "suite": benchmark.suite, - "name": benchmark.name, - "params": benchmark.planobj["params"], - "role": name, - "ipmap": ipmap, - } - f = io.BytesIO(plan.dump(bmdata).encode()) - sut.ctx.put(f, f"/tmp/fastpath-share-{name}/benchmark.yaml") + def exec_role(role): + node = benchmark.rolemap.map(role) + name = role.name + + # The benchmark could have multiple roles that map to the same node. + # Therefore we can't use node.ctx directly since it's possible that 2 or + # more threads would be using it concurrently. So create our own + # subconnection. + with node.ctx.subconnection() as ctx: + # Prep the shared directory on the sut and populate the benchmark + # meta data that the container will consume. The directory may have + # files owned by root due to being generated by the container which + # runs with --privileged. + ctx.run( + f""" + rm -rf /tmp/fastpath-share-{name}.tar.gz + sudo rm -rf /tmp/fastpath-share-{name}/* + mkdir -p /tmp/fastpath-share-{name}/output + """ + ) + bmdata = { + "suite": benchmark.suite, + "name": benchmark.name, + "params": benchmark.planobj["params"], + "role": name, + "ipmap": ipmap, + } + f = io.BytesIO(plan.dump(bmdata).encode()) + ctx.put(f, f"/tmp/fastpath-share-{name}/benchmark.yaml") + + # Invoke docker container on SUT and wait for timeout. + error = BenchmarkError.NONE + timeout = timeout_to_secs(benchmark.planobj["timeout"]) + try: + ctx.run( + f""" + docker exec \\ + --privileged \\ + fastpath-benchmark-{name} \\ + /fastpath/exec.py + """, + timeout=timeout, + ) + except invoke.exceptions.CommandTimedOut: + error = BenchmarkError.BENCHMARK_TIMEOUT + except invoke.exceptions.UnexpectedExit: + error = BenchmarkError.BENCHMARK_FAIL - # Invoke docker container on SUT and wait for timeout. - error = BenchmarkError.NONE - timeout = timeout_to_secs(benchmark.planobj["timeout"]) - try: - sut.ctx.run( - f""" - docker exec \\ - --privileged \\ - fastpath-benchmark-{name} \\ - /fastpath/exec.py - """, - timeout=timeout, - ) - except invoke.exceptions.CommandTimedOut: - error = BenchmarkError.BENCHMARK_TIMEOUT - except invoke.exceptions.UnexpectedExit: - error = BenchmarkError.BENCHMARK_FAIL + return error + + # Execute all roles and reduce to a single error (first not NONE). + errors = for_each_parallel(benchmark.roles, exec_role) + errors = [e for e in errors if e != BenchmarkError.NONE] + error = BenchmarkError.NONE if len(errors) == 0 else errors[0] pbar.progress(benchmark, suuid, repeat) # If there was an error, restart the container to clear up any dirty state. # e.g. if the benchmark timed out, the threads will still be running. if error != BenchmarkError.NONE: - restart_container(sut.ctx, benchmark.image, name) + for role in benchmark.roles: + node = benchmark.rolemap.map(role) + restart_container(node.ctx, benchmark.image, role.name) # If it's a warmup iteration, don't bother to parse the results. if warmup: log.log(f"END: warmup: {repeat} ({error})\n") return - # Compress /tmp/fastpath-share and copy back, then uncompress into repeat's - # log directory. - sut.ctx.run( - f""" - cd /tmp - tar -czf fastpath-share-{name}.tar.gz fastpath-share-{name} - """ - ) - sut.ctx.get( - f"/tmp/fastpath-share-{name}.tar.gz", - f"{basedir}/fastpath-share-{name}.tar.gz", - ) - subprocess.run( - f""" - cd {basedir} && - tar -xf fastpath-share-{name}.tar.gz && - rm -rf fastpath-share-{name}.tar.gz && - mkdir -p repeat-{repeat} - mv fastpath-share-{name} repeat-{repeat}/role-{name} - """, - shell=True, - check=True, - capture_output=True, - ) + dfs = [] - # Parse results.csv into pandas table. Generate a dummy dataframe with a - # single error entry if we are unable to retrieve or parse the csv. + for role in benchmark.roles: + node = benchmark.rolemap.map(role) + name = role.name + + # Compress /tmp/fastpath-share and copy back, then uncompress into + # repeat's log directory. + node.ctx.run( + f""" + cd /tmp + tar -czf fastpath-share-{name}.tar.gz fastpath-share-{name} + """ + ) + node.ctx.get( + f"/tmp/fastpath-share-{name}.tar.gz", + f"{basedir}/fastpath-share-{name}.tar.gz", + ) + subprocess.run( + f""" + cd {basedir} && + tar -xf fastpath-share-{name}.tar.gz && + rm -rf fastpath-share-{name}.tar.gz && + mkdir -p repeat-{repeat} + mv fastpath-share-{name} repeat-{repeat}/role-{name} + """, + shell=True, + check=True, + capture_output=True, + ) + + # Parse results.csv into pandas table. Ignore if not present. + if error == BenchmarkError.NONE: + try: + csv_file = f"{basedir}/repeat-{repeat}/role-{name}/results.csv" + df = pd.read_csv(csv_file) + df = pd.DataFrame(validate_results(df.to_dict("records"))) + df["role"] = role + dfs.append(df) + except BenchmarkException as e: + dfs.append( + pd.DataFrame([{"error": e.error.value, "role": role}]) + ) + except Exception: + pass + + # If there was an error while executing on any node or none of the roles + # produced a result, insert an error. + default_role = benchmark.roles[0] if error != BenchmarkError.NONE: - df = pd.DataFrame([{"error": error.value}]) - else: - try: - csv_file = f"{basedir}/repeat-{repeat}/role-{name}/results.csv" - df = pd.read_csv(csv_file) - df = pd.DataFrame(validate_results(df.to_dict("records"))) - except BenchmarkException as e: - df = pd.DataFrame([{"error": e.error.value}]) - except Exception: - df = pd.DataFrame([{"error": BenchmarkError.NO_RESULTS_FILE.value}]) - - # Insert all the extra fields we need. + dfs.append(pd.DataFrame([{"error": error.value, "role": default_role}])) + elif len(dfs) == 0: + dfs.append( + pd.DataFrame( + [ + { + "error": BenchmarkError.NO_RESULTS_FILE.value, + "role": default_role, + } + ] + ) + ) + + # Concat the results and insert all the extra fields we need. + df = pd.concat(dfs) df["session_uuid"] = suuid df["timestamp"] = datetime.datetime.now() @@ -456,6 +529,7 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): result.resultclass = resultclass result.sut = sut result.swprofile = swprofile + result.rolemap = benchmark.rolemap results.append(result) @@ -465,6 +539,7 @@ def do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat): err.sut = sut err.swprofile = swprofile err.benchmark = benchmark + result.rolemap = benchmark.rolemap results.append(err) @@ -478,10 +553,13 @@ def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): bname = f"{benchmark.suite}/{benchmark.name}" log.log(f"BEGIN: benchmark_exec: {bname}\n") - cleanup_containers(sut.ctx) + for_each_parallel([n.ctx for n in sut.nodes], cleanup_containers) + + rolemap = benchmark.rolemap try: - start_container(sut.ctx, benchmark.image, "executer") + for role in benchmark.roles: + start_container(rolemap.map(role).ctx, benchmark.image, role.name) for warmup in range(benchmark.planobj["warmups"]): do_one_repeat(None, sut, swprofile, benchmark, suuid, warmup) @@ -489,7 +567,8 @@ def do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid): for repeat in range(benchmark.planobj["repeats"]): do_one_repeat(basedir, sut, swprofile, benchmark, suuid, repeat) finally: - stop_container(sut.ctx, "executer") + for role in benchmark.roles: + stop_container(rolemap.map(role).ctx, role.name) log.log(f"END: benchmark_exec: {bname}\n") @@ -500,16 +579,19 @@ def do_one_session(basedir, sut, swprofile, benchmarks, session): # Don't reboot for the first session, because do_one_swprofile() has already # rebooted to fingerprint the swprofile. if session > 0: - configure.reboot_configured(sut.ctx) + items = [n.ctx for n in sut.nodes] + for_each_parallel(items, configure.reboot_configured) suuid = uuid.uuid4() name = f"session-{suuid}" - kmsglog = os.path.join(basedir, f"{name}.kmsg") - logger = kmsg.Logger(sut.ctx, kmsglog) - logger.start() - + loggers = [] try: + for i, node in enumerate(sut.nodes): + kmsglog = os.path.join(basedir, f"{name}-node-{i}.kmsg") + loggers.append(kmsg.Logger(node.ctx, kmsglog)) + loggers[-1].start() + for benchmark in benchmarks: if benchmark.planobj["sessions"] <= session: continue @@ -517,7 +599,8 @@ def do_one_session(basedir, sut, swprofile, benchmarks, session): basedir = logs_dir(benchmark.dir, name) do_one_benchmark_exec(basedir, sut, swprofile, benchmark, suuid) finally: - logger.stop() + for logger in loggers: + logger.stop() log.log(f"END: session: {session}\n") @@ -541,12 +624,13 @@ def do_one_benchmark_setup(basedir, sut, swprofile, benchmark): def do_one_swprofile(basedir, sut, swprofile, benchmarks): log.log(f"BEGIN: swprofile: {swprofile.name}\n") - # Install the swprofile on sut if not already done by do_one_sut(). + # Install the swprofile on each node if not already done by do_one_node(). if not hasattr(swprofile, "configured") or not swprofile.configured: - configure.configure(sut.ctx, swprofile.planobj) + items = [(n.ctx, swprofile.planobj) for n in sut.nodes] + for_each_parallel(items, configure.configure) # Finish initializing the swprofile with the sw fingerprint. - swprofile.init_sw_fingerprint(fingerprint.sut_query(sut.ctx)["sw"]) + swprofile.init_sw_fingerprint(fingerprint.sut_query(sut.nodes[0].ctx)["sw"]) # Generate results dir and yaml for swprofile. swprofile_dict = swprofile.to_dict(notattrs=["id"]) @@ -569,23 +653,28 @@ def do_one_swprofile(basedir, sut, swprofile, benchmarks): def do_one_sut(basedir, sut, swprofiles, benchmarks): log.log(f"BEGIN: sut: {sut.name}\n") - # Install the first swprofile on the sut. We do this early (subsequent - # swprofiles are installed in do_one_swprofile()) so that we can safely know - # for sure that the running kernel supports docker and has the capabilities - # required for fingerprinting. - configure.configure(sut.ctx, swprofiles[0].planobj) - swprofiles[0].configured = True - - # Pull all the images. This ensures we are not running with a stale version. - # And doing it centrally here ensures every benchmark invocation uses the - # same image. images = list(set([b.image for b in benchmarks])) - for image in images: - sut.ctx.run(f"docker image pull {image}", warn=True) - sut.ctx.run(f"docker image prune --force") - # Finish initializing the sut with the hw fingerprint. - sut.init_hw_fingerprint(fingerprint.sut_query(sut.ctx)["hw"]) + def configure_node(node): + # Install the first swprofile on the node. We do this early (subsequent + # swprofiles are installed in do_one_swprofile()) so that we can safely + # know for sure that the running kernel supports docker and has the + # capabilities required for fingerprinting. + configure.configure(node.ctx, swprofiles[0].planobj) + + # Pull all the images. This ensures we are not running with a stale + # version. And doing it centrally here ensures every benchmark + # invocation uses the same image. + for image in images: + node.ctx.run(f"docker image pull {image}", warn=True) + node.ctx.run(f"docker image prune --force") + + # Finish initializing the node with the hw fingerprint. + node.init_hw_fingerprint(fingerprint.sut_query(node.ctx)["hw"]) + + # Prepare all of the nodes. + for_each_parallel(sut.nodes, configure_node) + swprofiles[0].configured = True # Iterate over the swprofiles. for swprofile in swprofiles: @@ -608,8 +697,10 @@ class Benchmark(schema.BENCHMARK, PlanObjMixin): type=planobj["type"], image=planobj["image"], params_hash=fingerprint.hash(planobj["params"]), + roles_hash=fingerprint.hash(sorted(planobj["roles"])), ) self.params = [Param(n, v) for n, v in planobj["params"].items()] + self.roles = [Role(n) for n in planobj["roles"]] class Cpu(schema.CPU): @@ -625,10 +716,37 @@ class Cpu(schema.CPU): class Error(schema.ERROR): def __init__(self, row): - error = dict(row[["timestamp", "session_uuid", "error"]]) + error = dict(row[["timestamp", "session_uuid", "error", "role"]]) super().__init__(**error) +class Node(schema.NODE, PlanObjMixin): + def __init__(self, planobj): + self.set_planobj(planobj) + super().__init__(name=planobj["name"]) + + def init_hw_fingerprint(self, hw): + if not self.name: + self.name = slugify(hw["host_name"]) + self.host_name = hw["host_name"] + self.architecture = hw["architecture"] + self.cpu_count = hw["cpu_count"] + self.cpu_info_hash = hw["cpu_info_hash"] + self.numa_count = hw["numa_count"] + self.ram_sz = hw["ram_sz"] + self.hypervisor = hw["hypervisor"] or "" + self.product_name = hw["product_name"] or "" + self.product_serial = hw["product_serial"] or "" + self.mac_addrs_hash = hw["mac_addrs_hash"] + self.cpus = [Cpu(cpu_info) for cpu_info in hw["cpu_info"]] + + # Notify dependent objects that we now have the HW info. + if self.sut: + self.sut.node_init_complete(self) + for rmdesc in self.rmdescs: + rmdesc.rolemap.node_init_complete(self) + + class Param(schema.PARAM): def __init__(self, name, value): super().__init__(name=name, value=value) @@ -636,7 +754,7 @@ class Param(schema.PARAM): class Result(schema.RESULT): def __init__(self, row): - result = dict(row[["timestamp", "session_uuid", "value"]]) + result = dict(row[["timestamp", "session_uuid", "value", "role"]]) super().__init__(**result) @@ -646,25 +764,84 @@ class ResultClass(schema.RESULTCLASS): super().__init__(**resultclass) +class RmDesc(schema.RMDESC): + def __init__(self, role, node): + super().__init__(role=role, node=node) + + +class Role(schema.ROLE): + def __init__(self, name): + super().__init__(name=name) + + +class RoleMap(schema.ROLEMAP, PlanObjMixin): + def __init__(self, planobj, benchmark, sut): + def find_role(name): + for role in benchmark.roles: + if role.name == name: + return role + + self.set_planobj(planobj) + super().__init__() + for rname, nidx in planobj.items(): + self.rmdescs.append(RmDesc(find_role(rname), sut.nodes[nidx])) + + self.rmdescs_to_init = len(planobj.items()) + + def node_init_complete(self, rmdesc): + assert self.rmdescs_to_init > 0 + self.rmdescs_to_init -= 1 + if self.rmdescs_to_init > 0: + return + + # We can only generate rmdescs_hash after all the nodes are initialized, + # since node attributes depend on the HW info. This is quite messy, but + # we need to hash the node and role for each rmdesc uniquely so that we + # can identify the rolemap uniquely and prevent spurious deduplication. + # So create a list of tuples, one for each rmdesc, where each tuple + # contains a hash of the node, a hash of the role and a hash of the + # benchmark (the role is insufficient for uniqueness as role.name could + # be used by multiple benchmarks). Then sort and hash the list for a + # final hash. + hashes = [] + for rmdesc in self.rmdescs: + hashes.append( + fingerprint.hash( + [ + rmdesc.node.hash(notattrs=["id", "sut_id"]), + rmdesc.role.hash(notattrs=["id", "benchmark_id"]), + rmdesc.role.benchmark.hash(notattrs=["id"]), + ] + ) + ) + self.rmdescs_hash = fingerprint.hash(sorted(hashes)) + + def map(self, role): + for rmdesc in self.rmdescs: + if rmdesc.role == role: + return rmdesc.node + + class Sut(schema.SUT, PlanObjMixin): def __init__(self, planobj): self.set_planobj(planobj) super().__init__(name=planobj["name"]) + self.nodes = [Node(node) for node in planobj["nodes"]] + self.nodes_to_init = len(planobj["nodes"]) - def init_hw_fingerprint(self, hw): + def node_init_complete(self, node): + assert self.nodes_to_init > 0 + self.nodes_to_init -= 1 + if self.nodes_to_init > 0: + return + + # We can only generate these after all the child nodes are initialized + # as they depend on all the node attributes being populated. if not self.name: - self.name = slugify(hw["host_name"]) - self.host_name = hw["host_name"] - self.architecture = hw["architecture"] - self.cpu_count = hw["cpu_count"] - self.cpu_info_hash = hw["cpu_info_hash"] - self.numa_count = hw["numa_count"] - self.ram_sz = hw["ram_sz"] - self.hypervisor = hw["hypervisor"] or "" - self.product_name = hw["product_name"] or "" - self.product_serial = hw["product_serial"] or "" - self.mac_addrs_hash = hw["mac_addrs_hash"] - self.cpus = [Cpu(cpu_info) for cpu_info in hw["cpu_info"]] + self.name = "--".join([n.name for n in self.nodes]) + self.nodes_hash = fingerprint.hash( + sorted([n.hash(notattrs=["id", "sut_id"]) for n in self.nodes]) + ) class SwProfile(schema.SWPROFILE, PlanObjMixin): diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index bb41fb6..f19a47b 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -19,10 +19,14 @@ class Table(enum.Enum): SWPROFILE = (2,) CPU = (3,) ERROR = (4,) - PARAM = (5,) - RESULT = (6,) - RESULTCLASS = (7,) - SUT = (8,) + NODE = (5,) + PARAM = (6,) + RESULT = (7,) + RMDESC = (8,) + ROLE = (9,) + ROLEMAP = (10,) + RESULTCLASS = (11,) + SUT = (12,) def is_csv(url): @@ -226,13 +230,6 @@ class ResultSet: datetime.datetime.fromisoformat ) - # HACK: We have a lot of historical CSV data with "summary" column in - # the RESULTCLASS table. The column has since been removed, but to - # ensure we can continue to load the data, remove the column if present - # during load. Drop this hack once we move to mysql. - if "summary" in dfs[Table.RESULTCLASS].columns: - dfs[Table.RESULTCLASS].drop(["summary"], axis=1, inplace=True) - return cls.from_dfs(dfs) def __init__(self, db): @@ -335,24 +332,72 @@ class ResultSet: # Deduplicated set of source entities. sents = {table: set() for table in Table} + def gather_error(error): + sents[Table.ERROR].add(error) + gather_sut(error.sut) + gather_swprofile(error.swprofile) + gather_benchmark(error.benchmark) + gather_rolemap(error.rolemap) + gather_role(error.role) + + def gather_benchmark(benchmark): + sents[Table.BENCHMARK].add(benchmark) + for param in benchmark.params: + gather_param(param) + for role in benchmark.roles: + gather_role(role) + + def gather_cpu(cpu): + sents[Table.CPU].add(cpu) + + def gather_node(node): + sents[Table.NODE].add(node) + for cpu in node.cpus: + gather_cpu(cpu) + + def gather_param(param): + sents[Table.PARAM].add(param) + + def gather_result(result): + sents[Table.RESULT].add(result) + gather_resultclass(result.resultclass) + gather_sut(result.sut) + gather_swprofile(result.swprofile) + gather_rolemap(result.rolemap) + gather_role(result.role) + + def gather_resultclass(resultclass): + sents[Table.RESULTCLASS].add(resultclass) + gather_benchmark(resultclass.benchmark) + + def gather_rmdesc(rmdesc): + sents[Table.RMDESC].add(rmdesc) + gather_role(rmdesc.role) + gather_node(rmdesc.node) + + def gather_role(role): + sents[Table.ROLE].add(role) + + def gather_rolemap(rolemap): + sents[Table.ROLEMAP].add(rolemap) + for rmdesc in rolemap.rmdescs: + gather_rmdesc(rmdesc) + + def gather_sut(sut): + sents[Table.SUT].add(sut) + for node in sut.nodes: + gather_node(node) + + def gather_swprofile(swprofile): + sents[Table.SWPROFILE].add(swprofile) + # Gather all the source entities related to the errors. for error in errors: - sents[Table.ERROR].add(error) - sents[Table.SUT].add(error.sut) - sents[Table.CPU].update(error.sut.cpus) - sents[Table.SWPROFILE].add(error.swprofile) - sents[Table.BENCHMARK].add(error.benchmark) - sents[Table.PARAM].update(error.benchmark.params) + gather_error(error) # Gather all the source entities related to the results. for result in results: - sents[Table.RESULT].add(result) - sents[Table.SUT].add(result.sut) - sents[Table.CPU].update(result.sut.cpus) - sents[Table.SWPROFILE].add(result.swprofile) - sents[Table.RESULTCLASS].add(result.resultclass) - sents[Table.BENCHMARK].add(result.resultclass.benchmark) - sents[Table.PARAM].update(result.resultclass.benchmark.params) + gather_result(result) def inspect(sent): model = type(sent) @@ -363,7 +408,7 @@ class ResultSet: for c in model.__table__.columns if not c.primary_key and not c.foreign_keys ] - attrs = sent.to_dict(columns) + attrs = sent.to_dict(columns) if columns else {} return model, attrs def create(model, **kwargs): @@ -395,11 +440,19 @@ class ResultSet: ) map[Table.SUT][sent] = dent + # Merge NODE tables. + for sent in sents[Table.NODE]: + dent = get_or_create_from( + sent, + sut=map[Table.SUT][sent.sut], + ) + map[Table.NODE][sent] = dent + # Merge CPU tables. for sent in sents[Table.CPU]: dent = get_or_create_from( sent, - sut=map[Table.SUT][sent.sut], + node=map[Table.NODE][sent.node], ) map[Table.CPU][sent] = dent @@ -425,6 +478,14 @@ class ResultSet: ) map[Table.PARAM][sent] = dent + # Merge ROLE tables. + for sent in sents[Table.ROLE]: + dent = get_or_create_from( + sent, + benchmark=map[Table.BENCHMARK][sent.benchmark], + ) + map[Table.ROLE][sent] = dent + # Merge RESULTCLASS tables. for sent in sents[Table.RESULTCLASS]: dent = get_or_create_from( @@ -433,6 +494,23 @@ class ResultSet: ) map[Table.RESULTCLASS][sent] = dent + # Merge ROLEMAP tables. + for sent in sents[Table.ROLEMAP]: + dent = get_or_create_from( + sent, + ) + map[Table.ROLEMAP][sent] = dent + + # Merge RMDESC tables. + for sent in sents[Table.RMDESC]: + dent = get_or_create_from( + sent, + rolemap=map[Table.ROLEMAP][sent.rolemap], + role=map[Table.ROLE][sent.role], + node=map[Table.NODE][sent.node], + ) + map[Table.RMDESC][sent] = dent + # Merge RESULT tables. for sent in sents[Table.RESULT]: dent = create_from( @@ -440,6 +518,8 @@ class ResultSet: resultclass=map[Table.RESULTCLASS][sent.resultclass], sut=map[Table.SUT][sent.sut], swprofile=map[Table.SWPROFILE][sent.swprofile], + rolemap=map[Table.ROLEMAP][sent.rolemap], + role=map[Table.ROLE][sent.role], ) map[Table.RESULT][sent] = dent @@ -450,6 +530,8 @@ class ResultSet: sut=map[Table.SUT][sent.sut], swprofile=map[Table.SWPROFILE][sent.swprofile], benchmark=map[Table.BENCHMARK][sent.benchmark], + rolemap=map[Table.ROLEMAP][sent.rolemap], + role=map[Table.ROLE][sent.role], ) map[Table.ERROR][sent] = dent diff --git a/fastpath/utils/schema.py b/fastpath/utils/schema.py index 99538e7..5642719 100644 --- a/fastpath/utils/schema.py +++ b/fastpath/utils/schema.py @@ -4,6 +4,7 @@ import enum import sqlalchemy as sa from sqlalchemy.orm import declarative_base, relationship +from fastpath.utils import fingerprint SZ_HASH = 64 @@ -29,6 +30,14 @@ class BaseMixin: keys = [k for k in keys if k not in notattrs] return {k: getattr(self, k) for k in keys} + def hash(self, attrs=None, notattrs=None): + """ + Returns a stable sha256 hash of the object, including/excluding any + specified fields. If attrs is None, behaves as if attrs is the list of + all keys. If notattrs is None, behaves as if notattrs is an empty list. + """ + return fingerprint.hash(self.to_dict(attrs, notattrs)) + class BENCHMARK(BaseTable, BaseMixin): __tablename__ = "BENCHMARK" @@ -39,10 +48,12 @@ class BENCHMARK(BaseTable, BaseMixin): type = sa.Column(sa.String(SZ_NAME), nullable=False) image = sa.Column(sa.String(SZ_DESC), nullable=False) params_hash = sa.Column(sa.String(SZ_HASH), nullable=False) + roles_hash = sa.Column(sa.String(SZ_HASH), nullable=False) errors = relationship("ERROR", back_populates="benchmark") params = relationship("PARAM", back_populates="benchmark") resultclasses = relationship("RESULTCLASS", back_populates="benchmark") + roles = relationship("ROLE", back_populates="benchmark") class SWPROFILE(BaseTable, BaseMixin): @@ -69,9 +80,9 @@ class CPU(BaseTable, BaseMixin): id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) desc = sa.Column(sa.String(SZ_DESC), nullable=False) cpu_index = sa.Column(sa.SmallInteger, nullable=False) - sut_id = sa.Column(sa.Integer, sa.ForeignKey("SUT.id"), nullable=False) + node_id = sa.Column(sa.Integer, sa.ForeignKey("NODE.id"), nullable=False) - sut = relationship("SUT", back_populates="cpus") + node = relationship("NODE", back_populates="cpus") class ERROR(BaseTable, BaseMixin): @@ -86,14 +97,42 @@ class ERROR(BaseTable, BaseMixin): benchmark_id = sa.Column( sa.Integer, sa.ForeignKey("BENCHMARK.id"), nullable=False ) + rolemap_id = sa.Column( + sa.Integer, sa.ForeignKey("ROLEMAP.id"), nullable=False + ) + role_id = sa.Column(sa.Integer, sa.ForeignKey("ROLE.id"), nullable=False) session_uuid = sa.Column(sa.Uuid, nullable=False) error = sa.Column(sa.Integer, nullable=False) benchmark = relationship("BENCHMARK", back_populates="errors") + role = relationship("ROLE", back_populates="errors") + rolemap = relationship("ROLEMAP", back_populates="errors") swprofile = relationship("SWPROFILE", back_populates="errors") sut = relationship("SUT", back_populates="errors") +class NODE(BaseTable, BaseMixin): + __tablename__ = "NODE" + + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + sut_id = sa.Column(sa.Integer, sa.ForeignKey("SUT.id"), nullable=False) + name = sa.Column(sa.String(SZ_NAME), nullable=False) + host_name = sa.Column(sa.String(SZ_NAME), nullable=False) + architecture = sa.Column(sa.String(SZ_NAME), nullable=False) + cpu_count = sa.Column(sa.SmallInteger, nullable=False) + cpu_info_hash = sa.Column(sa.String(SZ_HASH), nullable=False) + numa_count = sa.Column(sa.SmallInteger, nullable=False) + ram_sz = sa.Column(sa.BigInteger, nullable=False) + hypervisor = sa.Column(sa.String(SZ_NAME), nullable=False) + product_name = sa.Column(sa.String(SZ_DESC), nullable=False) + product_serial = sa.Column(sa.String(SZ_DESC), nullable=False) + mac_addrs_hash = sa.Column(sa.String(SZ_HASH), nullable=False) + + cpus = relationship("CPU", back_populates="node") + rmdescs = relationship("RMDESC", back_populates="node") + sut = relationship("SUT", back_populates="nodes") + + class PARAM(BaseTable, BaseMixin): __tablename__ = "PARAM" @@ -119,10 +158,16 @@ class RESULT(BaseTable, BaseMixin): swprofile_id = sa.Column( sa.Integer, sa.ForeignKey("SWPROFILE.id"), nullable=False ) + rolemap_id = sa.Column( + sa.Integer, sa.ForeignKey("ROLEMAP.id"), nullable=False + ) + role_id = sa.Column(sa.Integer, sa.ForeignKey("ROLE.id"), nullable=False) session_uuid = sa.Column(sa.Uuid, nullable=False) value = sa.Column(sa.Double, nullable=False) resultclass = relationship("RESULTCLASS", back_populates="results") + role = relationship("ROLE", back_populates="results") + rolemap = relationship("ROLEMAP", back_populates="results") swprofile = relationship("SWPROFILE", back_populates="results") sut = relationship("SUT", back_populates="results") @@ -146,22 +191,54 @@ class RESULTCLASS(BaseTable, BaseMixin): results = relationship("RESULT", back_populates="resultclass") +class RMDESC(BaseTable, BaseMixin): + __tablename__ = "RMDESC" + + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + rolemap_id = sa.Column( + sa.Integer, sa.ForeignKey("ROLEMAP.id"), nullable=False + ) + role_id = sa.Column(sa.Integer, sa.ForeignKey("ROLE.id"), nullable=False) + node_id = sa.Column(sa.Integer, sa.ForeignKey("NODE.id"), nullable=False) + + node = relationship("NODE", back_populates="rmdescs") + role = relationship("ROLE", back_populates="rmdescs") + rolemap = relationship("ROLEMAP", back_populates="rmdescs") + + +class ROLE(BaseTable, BaseMixin): + __tablename__ = "ROLE" + + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + name = sa.Column(sa.String(SZ_NAME), nullable=False) + benchmark_id = sa.Column( + sa.Integer, sa.ForeignKey("BENCHMARK.id"), nullable=False + ) + + benchmark = relationship("BENCHMARK", back_populates="roles") + errors = relationship("ERROR", back_populates="role") + results = relationship("RESULT", back_populates="role") + rmdescs = relationship("RMDESC", back_populates="role") + + +class ROLEMAP(BaseTable, BaseMixin): + __tablename__ = "ROLEMAP" + + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + rmdescs_hash = sa.Column(sa.String(SZ_HASH), nullable=False) + + errors = relationship("ERROR", back_populates="rolemap") + results = relationship("RESULT", back_populates="rolemap") + rmdescs = relationship("RMDESC", back_populates="rolemap") + + class SUT(BaseTable, BaseMixin): __tablename__ = "SUT" id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) name = sa.Column(sa.String(SZ_NAME), nullable=False) - host_name = sa.Column(sa.String(SZ_NAME), nullable=False) - architecture = sa.Column(sa.String(SZ_NAME), nullable=False) - cpu_count = sa.Column(sa.SmallInteger, nullable=False) - cpu_info_hash = sa.Column(sa.String(SZ_HASH), nullable=False) - numa_count = sa.Column(sa.SmallInteger, nullable=False) - ram_sz = sa.Column(sa.BigInteger, nullable=False) - hypervisor = sa.Column(sa.String(SZ_NAME), nullable=False) - product_name = sa.Column(sa.String(SZ_DESC), nullable=False) - product_serial = sa.Column(sa.String(SZ_DESC), nullable=False) - mac_addrs_hash = sa.Column(sa.String(SZ_HASH), nullable=False) + nodes_hash = sa.Column(sa.String(SZ_HASH), nullable=False) - cpus = relationship("CPU", back_populates="sut") errors = relationship("ERROR", back_populates="sut") + nodes = relationship("NODE", back_populates="sut") results = relationship("RESULT", back_populates="sut") -- GitLab From 9982136190bc38a87bc5e4d3bcd8f98d3cc7a128 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Fri, 18 Jul 2025 16:10:02 +0100 Subject: [PATCH 17/29] cli: Tidy up unused imports Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/bisect/run.py | 8 +------- fastpath/commands/verbs/bisect/start.py | 1 - fastpath/commands/verbs/plan/exec.py | 1 - fastpath/commands/verbs/result/serve.py | 1 - 4 files changed, 1 insertion(+), 10 deletions(-) diff --git a/fastpath/commands/verbs/bisect/run.py b/fastpath/commands/verbs/bisect/run.py index ac062ef..25467e5 100644 --- a/fastpath/commands/verbs/bisect/run.py +++ b/fastpath/commands/verbs/bisect/run.py @@ -13,15 +13,9 @@ import yaml from fastpath.commands import cliutils from fastpath.commands.verbs.plan import exec as plan_exec -from fastpath.commands.verbs.result import merge from fastpath.commands.verbs.result import show from fastpath.utils import plan as plan_utils -from fastpath.utils.table import ( - Table, - load_tables, - join_results, - filter_results, -) +from fastpath.utils.table import load_tables, join_results, filter_results verb_name = os.path.splitext(os.path.basename(__file__))[0] diff --git a/fastpath/commands/verbs/bisect/start.py b/fastpath/commands/verbs/bisect/start.py index 6a1b06f..cd9c853 100644 --- a/fastpath/commands/verbs/bisect/start.py +++ b/fastpath/commands/verbs/bisect/start.py @@ -8,7 +8,6 @@ import tempfile import yaml from fastpath.commands import cliutils -from fastpath.utils import resultstore as rs from fastpath.utils import workspace from fastpath.utils.table import ( Table, diff --git a/fastpath/commands/verbs/plan/exec.py b/fastpath/commands/verbs/plan/exec.py index bffd86b..94af953 100644 --- a/fastpath/commands/verbs/plan/exec.py +++ b/fastpath/commands/verbs/plan/exec.py @@ -26,7 +26,6 @@ from fastpath.utils import plan from fastpath.utils import resultstore as rs from fastpath.utils import schema from fastpath.utils.bmutils import BenchmarkError, BenchmarkException -from fastpath.utils.table import Table verb_name = os.path.splitext(os.path.basename(__file__))[0] diff --git a/fastpath/commands/verbs/result/serve.py b/fastpath/commands/verbs/result/serve.py index 58655c5..31a1810 100644 --- a/fastpath/commands/verbs/result/serve.py +++ b/fastpath/commands/verbs/result/serve.py @@ -5,7 +5,6 @@ import os import runpy import sys from fastpath.commands import cliutils -from fastpath.utils import resultstore as rs from fastpath import dashboard -- GitLab From 443d8241c9b2ece52b0325256261ca3db2ecfd92 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Wed, 23 Jul 2025 14:05:51 +0100 Subject: [PATCH 18/29] scripts: Add script to convert resultstore schemas This script is standalone and takes a CSV resultstore in the v1 schema format as input and outputs a CSV resultstore in the v2 schema. v2 adds support for multi-node SUTs and multi-role benchmarks. In the v1 schema, each SUT implicitly has a single node and each benchmark implicitly has a single role ("executer"). This also means there is a single implicit mapping from each benchmark/role to the single node of the sut upon which the benchmark is running. Signed-off-by: Ryan Roberts --- scripts/convert_rs_schema.py | 197 +++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100755 scripts/convert_rs_schema.py diff --git a/scripts/convert_rs_schema.py b/scripts/convert_rs_schema.py new file mode 100755 index 0000000..d8513ea --- /dev/null +++ b/scripts/convert_rs_schema.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025, Arm Limited. +# SPDX-License-Identifier: MIT + + +# Script to convert resultstore data from schema v1 to v2. Expects input +# resultstore in CSV format, and outputs to new directory in CSV format. +# $ convert_rs_schema.py + + +import enum +import hashlib +import json +import os +import pandas as pd +import sys + + +class Table(enum.Enum): + BENCHMARK = (1,) + SWPROFILE = (2,) + CPU = (3,) + ERROR = (4,) + NODE = (5,) + PARAM = (6,) + RESULT = (7,) + RMDESC = (8,) + ROLE = (9,) + ROLEMAP = (10,) + RESULTCLASS = (11,) + SUT = (12,) + + +old_tables = [ + Table.BENCHMARK, + Table.SWPROFILE, + Table.CPU, + Table.ERROR, + Table.PARAM, + Table.RESULT, + Table.RESULTCLASS, + Table.SUT +] + + +def hash(obj): + # Ensure a total ordering for all (nested) keys. + obj = json.dumps(obj, sort_keys=True) + m = hashlib.sha256() + m.update(obj.encode()) + return m.hexdigest() + + +def row_by_index(df, index): + return df.loc[index].map(lambda x: x.item() if hasattr(x, "item") else x) + + +def _hash(row, notcols): + cols = set(row.keys()) - set(notcols) + d = {col: "" if pd.isna(row[col]) else row[col] for col in cols} + return hash(d) + + +def hash_node(row): + return _hash(row, ["id", "sut_id"]) + + +def hash_role(row): + return _hash(row, ["id", "benchmark_id"]) + + +def hash_benchmark(row): + return _hash(row, ["id"]) + + +# Get arguments. +if len(sys.argv) != 3: + print(f"Usage: convert_rs_schema.py ") + exit(1) +old_path = sys.argv[1] +new_path = sys.argv[2] + +# Read in resultstore in old format. +dfs = {t: pd.read_csv(os.path.join(old_path, f"{t.name}.csv")) for t in old_tables} + +# Create NODE table based on SUT table. Since old schema only supports single +# node per sut, there is 1:1 mapping between node and sut and SUT.id == NODE.id. +node = dfs[Table.SUT].copy() +cols = node.columns.insert(1, "sut_id") +node["sut_id"] = node["id"] +node = node[cols] +dfs[Table.NODE] = node + +# Convert SUT table; remove most columns and add nodes_hash. +sut = dfs[Table.SUT] +sut["nodes_hash"] = sut.apply(lambda r: hash([hash_node(r)]), axis=1) +sut = sut[["id", "name", "nodes_hash"]] +dfs[Table.SUT] = sut + +# Convert CPU table; sut_id becomes node_id. +cpu = dfs[Table.CPU] +cpu = cpu.rename({"sut_id": "node_id"}, axis=1) +dfs[Table.CPU] = cpu + +# Add roles_hash to BENCHMARK table. Since existing benchmarks don't explicitly +# define any roles, they are assumed to have a single "executer" role. +benchmark = dfs[Table.BENCHMARK] +benchmark["roles_hash"] = hash(["executer"]) +dfs[Table.BENCHMARK] = benchmark + +# Create a ROLE table. Every benchmark has a single role called "executer" so +# set the role ids to match the benchmark ids for ease. +role = dfs[Table.BENCHMARK][["id"]].copy() +role["name"] = "executer" +role["benchmark_id"] = role["id"] +dfs[Table.ROLE] = role + +# There is a rolemap with a single rmdesc for every (sut_id, benchmark_id) tuple +# that appears in a result or error. So let's calculate a rolemap_lut, which +# maps from (sut_id, benchmark_id) to rolemap_id (which is also the same id used +# for the single rmdesc). We also need a lut to map from resultclass_id to +# benchmark_id for the RESULT table. +result = dfs[Table.RESULT].set_index("id") +resultclass = dfs[Table.RESULTCLASS].set_index("id") +result = result.join(resultclass[["benchmark_id"]], on="resultclass_id") +result = result.reset_index() + +records = result[["resultclass_id", "benchmark_id"]].drop_duplicates() +records = records.to_dict("records") +benchmark_lut = {} +for r in records: + benchmark_lut[r["resultclass_id"]] = r["benchmark_id"] + +rrecs = result[["sut_id", "benchmark_id"]] +erecs = dfs[Table.ERROR][["sut_id", "benchmark_id"]] +records = pd.concat([rrecs, erecs]).drop_duplicates().to_dict("records") + +rolemap_lut = {} +for i, r in enumerate(records, 1): + rolemap_lut[(r["sut_id"], r["benchmark_id"])] = i + +# Generate the ROLEMAP and RMDESC tables. +rolemaps = [] +rmdescs = [] +node = node.set_index("id") +role = role.set_index("id") +benchmark = benchmark.set_index("id") +for (sut_id, benchmark_id), rolemap_id in rolemap_lut.items(): + # rolemap_id and rmdesc_id are the same in this case. + # node_id and sut_id are the same in this case. + # role_id and benchmark_id are the same in this case. + rmdesc_id = rolemap_id + node_id = sut_id + role_id = benchmark_id + + node_hash = hash_node(row_by_index(node, node_id)) + role_hash = hash_role(row_by_index(role, role_id)) + benchmark_hash = hash_benchmark(row_by_index(benchmark, benchmark_id)) + + rmdesc_hash = hash([node_hash, role_hash, benchmark_hash]) + rolemaps.append({ + "id": rolemap_id, + "rmdescs_hash": hash([rmdesc_hash]), + }) + rmdescs.append({ + "id": rmdesc_id, + "rolemap_id": rolemap_id, + "role_id": role_id, + "node_id": node_id, + }) +rolemap = pd.DataFrame(rolemaps).sort_values(by="id").reset_index(drop=True) +rmdesc = pd.DataFrame(rmdescs).sort_values(by="id").reset_index(drop=True) +dfs[Table.ROLEMAP] = rolemap +dfs[Table.RMDESC] = rmdesc + +# Now add the rolemap_id and role_id columns to the RESULT table. +result = dfs[Table.RESULT].copy() +cols = result.columns.insert(5, "rolemap_id") +cols = cols.insert(6, "role_id") +result["rolemap_id"] = result.apply(lambda r: rolemap_lut[(r["sut_id"], benchmark_lut[r["resultclass_id"]])], axis=1) +result["role_id"] = result.apply(lambda r: benchmark_lut[r["resultclass_id"]], axis=1) +result = result[cols] +dfs[Table.RESULT] = result + +# Now add the rolemap_id and role_id columns to the ERROR table. +error = dfs[Table.ERROR].copy() +cols = error.columns.insert(5, "rolemap_id") +cols = cols.insert(6, "role_id") +error["rolemap_id"] = error.apply(lambda r: rolemap_lut[(r["sut_id"], r["benchmark_id"])], axis=1) +error["role_id"] = error["benchmark_id"] +error = error[cols] +dfs[Table.ERROR] = error + +# Finally save out the tables in the new schema. +os.makedirs(new_path, exist_ok=True) +for t in Table: + dfs[t].to_csv(os.path.join(new_path, f"{t.name}.csv"), index=False) -- GitLab From e21f17791dab9612063da946ca20c0e9346ce231 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Wed, 23 Jul 2025 16:10:49 +0100 Subject: [PATCH 19/29] cli: Speed up "result merge" by eliding db flush When creating a new entity in the database, we were previously immediately flushing, which would push the change into the database-side transaction. The benefit of this is that any query that gets made on the same session after the flush but before the commit would include any entities flushed but not committed in the results. But for our case, we already know that all the entities we are merging are unique since we have already "gathered" them into a set. So querying to see if an entity already exists in the database would never return one of these pending entities. Therefore, it is safe to remove the immediate flush, and just wait until the final commit to push things to the database. This speeds up merge operation by more than 2x. Signed-off-by: Ryan Roberts --- fastpath/utils/resultstore.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index f19a47b..f40c307 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -414,7 +414,6 @@ class ResultSet: def create(model, **kwargs): entity = model(**kwargs) self.session.add(entity) - self.session.flush() return entity def create_from(sent, **kwargs): -- GitLab From 6279063acbe440340d35bdbefefe6b1590329ed0 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Wed, 23 Jul 2025 16:17:48 +0100 Subject: [PATCH 20/29] cli: Prune gather branches to speed up "result merge" The "gather" operation which is part of "result merge" can be quite expensive due to needing to hash objects to check if they are already in the set. Previously we were massively over-gathering, since we would try to gather the same entity multiple times. Consider a set of results for the same benchmark on the same sut: For every result we will end up gathering the same benchmark entity, sut, nodes, etc. We can avoid this by marking entities that are already gathered, then we can avoid the extra pointless recursion. With this optimisation and the previous one combined, a merge that previously took over 2 mins, is down to 30 seconds. Signed-off-by: Ryan Roberts --- fastpath/utils/resultstore.py | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/fastpath/utils/resultstore.py b/fastpath/utils/resultstore.py index f40c307..b13d9ae 100644 --- a/fastpath/utils/resultstore.py +++ b/fastpath/utils/resultstore.py @@ -333,6 +333,10 @@ class ResultSet: sents = {table: set() for table in Table} def gather_error(error): + if hasattr(error, "gathered"): + return + error.gathered = True + sents[Table.ERROR].add(error) gather_sut(error.sut) gather_swprofile(error.swprofile) @@ -341,6 +345,10 @@ class ResultSet: gather_role(error.role) def gather_benchmark(benchmark): + if hasattr(benchmark, "gathered"): + return + benchmark.gathered = True + sents[Table.BENCHMARK].add(benchmark) for param in benchmark.params: gather_param(param) @@ -348,17 +356,33 @@ class ResultSet: gather_role(role) def gather_cpu(cpu): + if hasattr(cpu, "gathered"): + return + cpu.gathered = True + sents[Table.CPU].add(cpu) def gather_node(node): + if hasattr(node, "gathered"): + return + node.gathered = True + sents[Table.NODE].add(node) for cpu in node.cpus: gather_cpu(cpu) def gather_param(param): + if hasattr(param, "gathered"): + return + param.gathered = True + sents[Table.PARAM].add(param) def gather_result(result): + if hasattr(result, "gathered"): + return + result.gathered = True + sents[Table.RESULT].add(result) gather_resultclass(result.resultclass) gather_sut(result.sut) @@ -367,28 +391,52 @@ class ResultSet: gather_role(result.role) def gather_resultclass(resultclass): + if hasattr(resultclass, "gathered"): + return + resultclass.gathered = True + sents[Table.RESULTCLASS].add(resultclass) gather_benchmark(resultclass.benchmark) def gather_rmdesc(rmdesc): + if hasattr(rmdesc, "gathered"): + return + rmdesc.gathered = True + sents[Table.RMDESC].add(rmdesc) gather_role(rmdesc.role) gather_node(rmdesc.node) def gather_role(role): + if hasattr(role, "gathered"): + return + role.gathered = True + sents[Table.ROLE].add(role) def gather_rolemap(rolemap): + if hasattr(rolemap, "gathered"): + return + rolemap.gathered = True + sents[Table.ROLEMAP].add(rolemap) for rmdesc in rolemap.rmdescs: gather_rmdesc(rmdesc) def gather_sut(sut): + if hasattr(sut, "gathered"): + return + sut.gathered = True + sents[Table.SUT].add(sut) for node in sut.nodes: gather_node(node) def gather_swprofile(swprofile): + if hasattr(swprofile, "gathered"): + return + swprofile.gathered = True + sents[Table.SWPROFILE].add(swprofile) # Gather all the source entities related to the errors. @@ -399,6 +447,12 @@ class ResultSet: for result in results: gather_result(result) + # Delete all the temporary gathered attributes we added as markers to + # prevent unneccessary recursion during gathering. + for table in Table: + for sent in sents[table]: + del sent.gathered + def inspect(sent): model = type(sent) while schema.BaseTable != model.__base__: -- GitLab From de00666a458f4118b0f3ab97837fa946ced917c0 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Mon, 28 Jul 2025 14:33:43 +0100 Subject: [PATCH 21/29] cli: "result list": Display benchmark roles Enhance "result list --object benchmark" to display the roles and roles_hash information, which was added to BENCHMARK as part of the resultstore schema update. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/list.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/fastpath/commands/verbs/result/list.py b/fastpath/commands/verbs/result/list.py index ccf130c..9f07cd3 100644 --- a/fastpath/commands/verbs/result/list.py +++ b/fastpath/commands/verbs/result/list.py @@ -131,6 +131,8 @@ cols = { "image": ("Container Image", True), "params": ("Params", True), "params_hash": ("Params Hash", False), + "roles": ("Roles", True), + "roles_hash": ("Roles Hash", False), "resultclass": ("Result Classes", True), }, Table.SWPROFILE: { @@ -286,6 +288,7 @@ def pretty_benchmark(tables, args): Prepares a data frame of benchmarks for display. """ params = tables[Table.PARAM] + roles = tables[Table.ROLE] rc = tables[Table.RESULTCLASS] def make_params(row): @@ -302,6 +305,11 @@ def pretty_benchmark(tables, args): else: return float("nan") + def make_roles(row): + id = row.name + items = roles[roles["benchmark_id"] == id]["name"] + return "- " + "\n- ".join(sorted(items)) + def make_resultclass(row): id = row.name table = rc[rc["benchmark_id"] == id] @@ -321,6 +329,7 @@ def pretty_benchmark(tables, args): objs = tables[Table.BENCHMARK] objs["params"] = objs.apply(make_params, axis=1) + objs["roles"] = objs.apply(make_roles, axis=1) objs["resultclass"] = objs.apply(make_resultclass, axis=1) return pretty_obj(objs, cols[Table.BENCHMARK], args) -- GitLab From 28a318ae6502d39011863d2bb8bcc60c48df1791 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Mon, 28 Jul 2025 14:51:06 +0100 Subject: [PATCH 22/29] cli: "result list": Support node object Now that a SUT is composed of nodes, allow listing information about nodes. This effectively converts the "--object sut" command to "--object node", but additionally provides the ID of each nodes' parent SUT. A future commit will re-introduce "--object sut", listing the top level information. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/list.py | 47 +++++++++++++++----------- fastpath/utils/table.py | 11 +++--- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/fastpath/commands/verbs/result/list.py b/fastpath/commands/verbs/result/list.py index 9f07cd3..ddc42d2 100644 --- a/fastpath/commands/verbs/result/list.py +++ b/fastpath/commands/verbs/result/list.py @@ -20,17 +20,18 @@ value_col_width = None verb_name = os.path.splitext(os.path.basename(__file__))[0] verb_help = ( - """list all the sut, swprofile or benchmark objects in the result set""" + """list all the sut, node, swprofile or benchmark objects in the result set""" ) -verb_desc = """List all the sut, swprofile or benchmark objects in the result set. - Data are stored as a set of "result" objects, each of the type - described by a "resultclass" object. A "benchmark" object generates - a single result for each of its resultclasses per iteration. A - benchmark is executed on a "sut" (system under test) object with a - given sw profile; a "swprofile" object. This command lists every - object contained within the result set along with their meta data. - Crucially, every object has an ID which is unique within the result - set. IDs are needed when filtering results with the "show" verb.""" +verb_desc = """List all the sut, node, swprofile or benchmark objects in the + result set. Data are stored as a set of "result" objects, each of + the type described by a "resultclass" object. A "benchmark" object + generates a single result for each of its resultclasses per + iteration. A benchmark is executed on a "node", which forms part of + a "sut" (system under test) object with a given sw profile; a + "swprofile" object. This command lists every object contained within + the result set along with their meta data. Crucially, every object + has an ID which is unique within the result set. IDs are needed when + filtering results with the "show" verb.""" def add_parser(parser, formatter, add_noun_args): @@ -54,8 +55,8 @@ def add_parser(parser, formatter, add_noun_args): verbp.add_argument( "--object", required=True, - choices=["sut", "swprofile", "benchmark"], - help="""list either "sut", "swprofile", or "benchmark" objects""", + choices=["node", "swprofile", "benchmark"], + help="""list either "node", "swprofile", or "benchmark" objects""", ) verbp.add_argument( @@ -101,8 +102,8 @@ def dispatch(args): calc_col_widths(args.all) tables = load_tables(args.resultstore, not args.no_merge_similar) - if args.object == "sut": - objs = pretty_sut(tables, args) + if args.object == "node": + objs = pretty_node(tables, args) elif args.object == "swprofile": objs = pretty_swprofile(tables, args) elif args.object == "benchmark": @@ -147,9 +148,10 @@ cols = { "sysctl": ("sysctl", True), "bootscript": ("bootscript", True), }, - Table.SUT: { + Table.NODE: { "unique": ("ID", True), "name": ("Name", True), + "sut": ("Parent SUT", True), "host_name": ("Host Name", True), "architecture": ("Architecture", True), "cpu_count": ("CPU Count", False), @@ -254,10 +256,11 @@ def pretty_obj(objs, cols, args): return objs -def pretty_sut(tables, args): +def pretty_node(tables, args): """ - Prepares a data frame of SUTs for display. + Prepares a data frame of NODEs for display. """ + suts = tables[Table.SUT] cpus = tables[Table.CPU] cpus = cpus.reset_index().drop(["id", "cpu_index"], axis=1) cpus = cpus.groupby(list(cpus.columns)).size().reset_index(name="count") @@ -265,15 +268,19 @@ def pretty_sut(tables, args): def make_cpu_info(row): id = row.name arch = row["architecture"] - descs = cpus[cpus["sut_id"] == id].to_dict("records") + descs = cpus[cpus["node_id"] == id].to_dict("records") strings = [f"{d['count']}x {pretty_cpu_string(d, arch)}" for d in descs] return "\n".join(strings) - objs = tables[Table.SUT] + def make_sut(sut_id): + return suts.loc[sut_id]["unique"] + + objs = tables[Table.NODE] + objs["sut"] = objs["sut_id"].apply(make_sut) objs["ram_sz"] = objs["ram_sz"].apply(pretty_size) objs["cpu_info"] = objs.apply(make_cpu_info, axis=1) - return pretty_obj(objs, cols[Table.SUT], args) + return pretty_obj(objs, cols[Table.NODE], args) def pretty_swprofile(tables, args): diff --git a/fastpath/utils/table.py b/fastpath/utils/table.py index 00991a8..6d3e3e4 100644 --- a/fastpath/utils/table.py +++ b/fastpath/utils/table.py @@ -46,17 +46,17 @@ def load_tables(resultstore, merge_similar=True): Convenience function to convert the contents of a resultstore to a set of pandas dataframes, returned as a dictionary containing each dataframe, indexed by the Table enum. Additionally a "unique" column is added for SUT, - SWPROFILE and BENCHMARK tables, which remains as friendly as possible while - being guarranteed unique. When merge_similar=True, objects are merged if - they have only trival differences. + NODE, SWPROFILE and BENCHMARK tables, which remains as friendly as possible + while being guarranteed unique. When merge_similar=True, objects are merged + if they have only trival differences. """ dfs = rs.open_or_import(resultstore).to_dfs() if merge_similar: # Merge swprofiles when all fields match except kernel_cmdline_full_hash - # and userspace_name. Often different SUTs have a slightly different + # and userspace_name. Often different NODEs have a slightly different # command line (e.g. rootfs) or have a slightly different userspace, so - # let's ignore those differences to allow comparing SUTs. + # let's ignore those differences to allow comparing NODEs. dfs = _dedup_and_fix_refs( dfs, Table.SWPROFILE, @@ -88,6 +88,7 @@ def load_tables(resultstore, merge_similar=True): dfs[Table.BENCHMARK].drop(["suite/name"], axis=1, inplace=True) add_unique_col(dfs[Table.SWPROFILE], "name") add_unique_col(dfs[Table.SUT], "name") + add_unique_col(dfs[Table.NODE], "name") return dfs -- GitLab From ca11f2c9defcc3a56d62f3eea38d0d2776fa2667 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Mon, 28 Jul 2025 15:07:30 +0100 Subject: [PATCH 23/29] cli: "result list": Reintroduce "--object sut" support List all suts when requested, but since a SUT is now composed of nodes, just list the SUT name and the IDs of all the nodes that belong to the SUT. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/list.py | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/fastpath/commands/verbs/result/list.py b/fastpath/commands/verbs/result/list.py index ddc42d2..9dc9ca8 100644 --- a/fastpath/commands/verbs/result/list.py +++ b/fastpath/commands/verbs/result/list.py @@ -55,8 +55,9 @@ def add_parser(parser, formatter, add_noun_args): verbp.add_argument( "--object", required=True, - choices=["node", "swprofile", "benchmark"], - help="""list either "node", "swprofile", or "benchmark" objects""", + choices=["sut", "node", "swprofile", "benchmark"], + help="""list either "sut", "node", "swprofile", or "benchmark" + objects""", ) verbp.add_argument( @@ -102,7 +103,9 @@ def dispatch(args): calc_col_widths(args.all) tables = load_tables(args.resultstore, not args.no_merge_similar) - if args.object == "node": + if args.object == "sut": + objs = pretty_sut(tables, args) + elif args.object == "node": objs = pretty_node(tables, args) elif args.object == "swprofile": objs = pretty_swprofile(tables, args) @@ -164,6 +167,12 @@ cols = { "product_serial": ("DMI Product Serial", True), "mac_addrs_hash": ("MAC Addresses Hash", False), }, + Table.SUT: { + "unique": ("ID", True), + "name": ("Name", True), + "nodes": ("Node IDs", True), + "nodes_hash": ("Nodes Hash", False), + }, } @@ -256,6 +265,23 @@ def pretty_obj(objs, cols, args): return objs +def pretty_sut(tables, args): + """ + Prepares a data frame of SUTs for display. + """ + nodes = tables[Table.NODE] + + def make_nodes(row): + id = row.name + items = nodes[nodes["sut_id"] == id]["unique"] + return "- " + "\n- ".join(sorted(items)) + + objs = tables[Table.SUT] + objs["nodes"] = objs.apply(make_nodes, axis=1) + + return pretty_obj(objs, cols[Table.SUT], args) + + def pretty_node(tables, args): """ Prepares a data frame of NODEs for display. -- GitLab From ced796d55cd9f3d81e74d0f35f7d23cd5b118e07 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Mon, 28 Jul 2025 15:53:11 +0100 Subject: [PATCH 24/29] cli: "result list": Add support for listing rolemaps The new schema introduces rolemaps, which map each role in a benchmark to a node where it executes. Let's add support for listing these objects using "result list --object rolemap". rolemaps do not have a friendly name, so their user-facing ID is always "rm", where is the numerical id in the database. The rolemap consists of a set of rmdescs which map a benchmark/role to a node. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/list.py | 73 ++++++++++++++++++++++---- fastpath/utils/table.py | 16 +++--- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/fastpath/commands/verbs/result/list.py b/fastpath/commands/verbs/result/list.py index 9dc9ca8..495087c 100644 --- a/fastpath/commands/verbs/result/list.py +++ b/fastpath/commands/verbs/result/list.py @@ -19,13 +19,12 @@ value_col_width = None verb_name = os.path.splitext(os.path.basename(__file__))[0] -verb_help = ( - """list all the sut, node, swprofile or benchmark objects in the result set""" -) -verb_desc = """List all the sut, node, swprofile or benchmark objects in the - result set. Data are stored as a set of "result" objects, each of - the type described by a "resultclass" object. A "benchmark" object - generates a single result for each of its resultclasses per +verb_help = """list all the sut, node, swprofile, benchmark or rolemaps objects + in the result set""" +verb_desc = """List all the sut, node, swprofile, benchmark or rolemap objects + in the result set. Data are stored as a set of "result" objects, + each of the type described by a "resultclass" object. A "benchmark" + object generates a single result for each of its resultclasses per iteration. A benchmark is executed on a "node", which forms part of a "sut" (system under test) object with a given sw profile; a "swprofile" object. This command lists every object contained within @@ -55,9 +54,9 @@ def add_parser(parser, formatter, add_noun_args): verbp.add_argument( "--object", required=True, - choices=["sut", "node", "swprofile", "benchmark"], - help="""list either "sut", "node", "swprofile", or "benchmark" - objects""", + choices=["sut", "node", "swprofile", "benchmark", "rolemap"], + help="""list either "sut", "node", "swprofile", "benchmark", or + "rolemap" objects""", ) verbp.add_argument( @@ -111,6 +110,8 @@ def dispatch(args): objs = pretty_swprofile(tables, args) elif args.object == "benchmark": objs = pretty_benchmark(tables, args) + elif args.object == "rolemap": + objs = pretty_rolemap(tables, args) output = [] for _, row in objs.iterrows(): @@ -173,6 +174,11 @@ cols = { "nodes": ("Node IDs", True), "nodes_hash": ("Nodes Hash", False), }, + Table.ROLEMAP: { + "unique": ("ID", True), + "map": ("Map", True), + "rmdescs_hash": ("Rmdescs Hash", False), + }, } @@ -366,3 +372,50 @@ def pretty_benchmark(tables, args): objs["resultclass"] = objs.apply(make_resultclass, axis=1) return pretty_obj(objs, cols[Table.BENCHMARK], args) + + +def pretty_rolemap(tables, args): + """ + Prepares a data frame of rolemaps for display. + """ + + def make_benchmark_role(row): + return f"{row['benchmark']}/{row['role']}" + + rmdescs = ( + tables[Table.RMDESC] + .join( + tables[Table.ROLE][["benchmark_id", "name"]].rename( + columns={"name": "role"} + ), + on="role_id", + ) + .join( + tables[Table.BENCHMARK]["unique"].rename("benchmark"), + on="benchmark_id", + ) + .join(tables[Table.NODE]["unique"].rename("node"), on="node_id") + ) + rmdescs["benchmark/role"] = rmdescs.apply(make_benchmark_role, axis=1) + rmdescs["->"] = "->" + + def make_map(row): + id = row.name + table = rmdescs[rmdescs["rolemap_id"] == id] + table = table[["benchmark/role", "->", "node"]] + table = table.sort_values( + ["benchmark/role"], key=lambda x: ns.natsort_key(x) + ) + if not args.ascii: + table.columns = [term.make_dim(col) for col in table.columns] + table = table.to_dict("records") + return tabulate.tabulate( + table, + headers="keys", + tablefmt="simple" if args.ascii else "plain", + ) + + objs = tables[Table.ROLEMAP] + objs["map"] = objs.apply(make_map, axis=1) + + return pretty_obj(objs, cols[Table.ROLEMAP], args) diff --git a/fastpath/utils/table.py b/fastpath/utils/table.py index 6d3e3e4..c2aed5c 100644 --- a/fastpath/utils/table.py +++ b/fastpath/utils/table.py @@ -46,9 +46,9 @@ def load_tables(resultstore, merge_similar=True): Convenience function to convert the contents of a resultstore to a set of pandas dataframes, returned as a dictionary containing each dataframe, indexed by the Table enum. Additionally a "unique" column is added for SUT, - NODE, SWPROFILE and BENCHMARK tables, which remains as friendly as possible - while being guarranteed unique. When merge_similar=True, objects are merged - if they have only trival differences. + NODE, SWPROFILE, BENCHMARK and ROLEMAP tables, which remains as friendly as + possible while being guarranteed unique. When merge_similar=True, objects + are merged if they have only trival differences. """ dfs = rs.open_or_import(resultstore).to_dfs() @@ -71,14 +71,17 @@ def load_tables(resultstore, merge_similar=True): dfs, Table.BENCHMARK, ["image"], "benchmark_id" ) - def add_unique_col(df, namecol): + def add_unique_col(df, namecol=None, prefix=None): def make_unique(row): name = row[namecol] id = row.name return name if counts[name] == 1 else f"{name}:{id}" - counts = df[namecol].value_counts() - df["unique"] = df.apply(make_unique, axis=1) + if namecol: + counts = df[namecol].value_counts() + df["unique"] = df.apply(make_unique, axis=1) + else: + df["unique"] = df.apply(lambda row: f"{prefix}{row.name}", axis=1) dfs[Table.BENCHMARK]["suite/name"] = dfs[Table.BENCHMARK].apply( lambda row: f"{row['suite']}/{row['name']}", @@ -89,6 +92,7 @@ def load_tables(resultstore, merge_similar=True): add_unique_col(dfs[Table.SWPROFILE], "name") add_unique_col(dfs[Table.SUT], "name") add_unique_col(dfs[Table.NODE], "name") + add_unique_col(dfs[Table.ROLEMAP], prefix="rm") return dfs -- GitLab From 57aa0ce3b9908310295d89af8060ae0053ec1473 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Mon, 28 Jul 2025 16:12:47 +0100 Subject: [PATCH 25/29] cli: "result list": Only display name fields with --all The user-friendly unique ID, as displayed for each object, when running "result list", includes the (non-unique) name. In fact, in the common case, when the name happens to be unique, the ID and the Name are the same. So it's somewhat redundant and confusing to list both by default. Let's hide the Name when --all is not given to simplify things a bit. Note that for the benchmark case, both the suite and name are used to form the ID, so hide both. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/list.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fastpath/commands/verbs/result/list.py b/fastpath/commands/verbs/result/list.py index 495087c..4812587 100644 --- a/fastpath/commands/verbs/result/list.py +++ b/fastpath/commands/verbs/result/list.py @@ -130,8 +130,8 @@ def dispatch(args): cols = { Table.BENCHMARK: { "unique": ("ID", True), - "suite": ("Suite", True), - "name": ("Name", True), + "suite": ("Suite", False), + "name": ("Name", False), "type": ("Type", True), "image": ("Container Image", True), "params": ("Params", True), @@ -142,7 +142,7 @@ cols = { }, Table.SWPROFILE: { "unique": ("ID", True), - "name": ("Name", True), + "name": ("Name", False), "kernel_name": ("Kernel Name", True), "kernel_git_sha": ("Kernel Git SHA", True), "kernel_kconfig_full_hash": ("Kernel Kconfig Hash", False), @@ -154,7 +154,7 @@ cols = { }, Table.NODE: { "unique": ("ID", True), - "name": ("Name", True), + "name": ("Name", False), "sut": ("Parent SUT", True), "host_name": ("Host Name", True), "architecture": ("Architecture", True), @@ -170,7 +170,7 @@ cols = { }, Table.SUT: { "unique": ("ID", True), - "name": ("Name", True), + "name": ("Name", False), "nodes": ("Node IDs", True), "nodes_hash": ("Nodes Hash", False), }, -- GitLab From 493a46bce8c63e1db03a0b3607cb3a598c63adb0 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 29 Jul 2025 12:56:32 +0100 Subject: [PATCH 26/29] cli: "result show": Print results with at least 2 significant figures Previously all floating point values were printed with 2 decimal places. But there are cases where this is not sufficient to show the scale of the value. So let's always print at least 2 significant figures. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/show.py | 31 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/fastpath/commands/verbs/result/show.py b/fastpath/commands/verbs/result/show.py index 25df47d..0aeef7d 100644 --- a/fastpath/commands/verbs/result/show.py +++ b/fastpath/commands/verbs/result/show.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: MIT import io +import math import natsort as ns import numpy as np import os @@ -335,10 +336,24 @@ def compute_change(results, args): return results -def pretty_results_multi(results, args): - def format(cur, prev): - return f"{cur / prev - 1.0:.2%}" if prev else f"{cur:.2f}" +def format(cur, prev=None): + if prev: + return f"{cur / prev - 1.0:.2%}" + elif cur == 0: + return "0.00" + elif np.isnan(cur): + return f"{cur:.2f}" + else: + # Ensure at least 2 significant figures, defaulting to 2 decimal + # places otherwise. + digits = 2 - int(math.floor(math.log10(abs(cur)))) - 1 + if digits <= 2: + return f"{cur:.2f}" + else: + return f"{round(cur, digits):.{digits}f}" + +def pretty_results_multi(results, args): def mark_improvement(value): if args.ascii: return f"(I) {value}" @@ -356,7 +371,9 @@ def pretty_results_multi(results, args): rows, cols = mean.shape pretty = mean.copy() - for col in pretty.columns[leading_cols:]: + fcol = pretty.columns[leading_cols] + pretty[fcol] = pretty[fcol].apply(lambda x: format(x)) + for col in pretty.columns[leading_cols + 1 :]: pretty[col] = pretty[col].astype(str) for col in range(leading_cols + 1, cols): @@ -417,11 +434,11 @@ def pretty_results_single(results, args): results[col] = results[col].apply(lambda x: f"{x - 1.0:.2%}") results["stddev"] = results["stddev"] / results["mean"] results["stddev"] = results["stddev"].apply(lambda x: f"{x:.2%}") - results["mean"] = results["mean"].apply(lambda x: f"{x:.2f}") + results["mean"] = results["mean"].apply(lambda x: format(x)) results = results.rename({"stddev": f"cv"}, axis=1) else: for col in ["min", "ci95min", "mean", "ci95max", "max", "stddev"]: - results[col] = results[col].apply(lambda x: f"{x:.2f}") + results[col] = results[col].apply(lambda x: format(x)) return pretty_results(results, args) @@ -490,7 +507,7 @@ def pretty_results(results, args): table, tablefmt="outline" if args.ascii else "rounded_outline", headers="keys", - floatfmt=".2f", + disable_numparse=True, colalign=colalign, ) table = table.split("\n") -- GitLab From a6ced8a99d53814bc0037e073bcc0d8bed5cb838 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 29 Jul 2025 13:05:38 +0100 Subject: [PATCH 27/29] cli: "result show": Refactor more in terms of pivot_index Let's sort the rows and create the alignment meta data in terms of the pivot_index list. This will simplify the addition of --no-merge-rolemap in the next commit. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/show.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/fastpath/commands/verbs/result/show.py b/fastpath/commands/verbs/result/show.py index 0aeef7d..c649743 100644 --- a/fastpath/commands/verbs/result/show.py +++ b/fastpath/commands/verbs/result/show.py @@ -419,13 +419,12 @@ def pretty_results_multi(results, args): raise Exception("No results to display after filtering") pretty = pretty.reset_index(drop=True) - pretty = pretty.drop(["improvement", "I", "R"], axis=1) + pretty = pretty.drop(["I", "R"], axis=1) return pretty_results(pretty, args) def pretty_results_single(results, args): results = results.reset_index() - results = results.drop(["improvement"], axis=1) results["count"] = results["count"].astype(np.int64) if args.relative: @@ -455,7 +454,6 @@ def pretty_results(results, args): results["resultclass"] = results.apply( lambda row: f"{row['resultclass']} ({row['unit']})", axis=1 ) - results = results.drop(["unit"], axis=1) # Replace nans with empty space. results = results.replace( @@ -468,9 +466,8 @@ def pretty_results(results, args): # Sort results naturally by benchmark then resultclass; essentially sort # text alphabetically and numbers numberically. - results = results.sort_values( - ["benchmark", "resultclass"], key=lambda x: ns.natsort_key(x) - ) + results = results.sort_values(pivot_index, key=lambda x: ns.natsort_key(x)) + results = results.drop(["unit", "improvement"], axis=1) results = results.reset_index(drop=True) # We will insert a separator after each benchmark (which may have multiple @@ -494,7 +491,9 @@ def pretty_results(results, args): ) if not args.ascii: results.columns = [term.make_dim(col) for col in results.columns] - colalign = ["left", "left"] + ["right"] * (len(results.columns) - 2) + stat_align = ["right"] * (len(results.columns) - len(pivot_index) + 2) + index_align = ["left", "left"] + colalign = index_align + stat_align # Tabulate then manually insert the separators at the indexes we previously # calculated. While tabulate claims support for doing separators itself, it -- GitLab From e78aaebb7952de930168057d7ae5c3e74fcd9f60 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 29 Jul 2025 13:08:40 +0100 Subject: [PATCH 28/29] cli: "result show": Add --no-merge-rolemap option The new resultstore schema enables different benchmark roles to execute on different nodes of a SUT in parallel. The mapping of roles to nodes is done with a rolemap. This addition means it is possible for the same benchmark to run on the same SUT with with a different mapping of it's roles to nodes. By default, we just aggregate all results for a given benchmark targeting a given SUT to generate the summary statistics. But due to different rolemaps, this may not always be desirable. Clearly if you're comparing a benchmark across different SUTs, then by definition the rolemap will be different for each case and it makes sense to merge (the default). But if comparing a benchmark across different swprofiles all on the same SUT it may be preferable to avoid merging different rolemaps. Add --no-merge-rolemap option to disable this merging behavior. When present, an additional column "Role Map" is added to the output table to identify the rolemap for which the results were aggregated. Signed-off-by: Ryan Roberts --- fastpath/commands/verbs/result/show.py | 20 ++++++++++++++++++++ fastpath/utils/table.py | 3 +++ 2 files changed, 23 insertions(+) diff --git a/fastpath/commands/verbs/result/show.py b/fastpath/commands/verbs/result/show.py index c649743..6c43592 100644 --- a/fastpath/commands/verbs/result/show.py +++ b/fastpath/commands/verbs/result/show.py @@ -143,6 +143,14 @@ def add_parser(parser, formatter, add_noun_args): help="""don't merge similar objects""", ) + verbp.add_argument( + "--no-merge-rolemap", + required=False, + default=False, + action="store_true", + help="""don't merge results generated with different rolemaps""", + ) + return verb_name @@ -152,6 +160,7 @@ def dispatch(args): subcommand, with the arguments the user passed on the command line. The arguments comply with those requested in add_parser(). """ + init_pivot_index(not args.no_merge_rolemap) tables = load_tables(args.resultstore, not args.no_merge_similar) results = join_results(tables) @@ -218,6 +227,14 @@ pivot_index = ["benchmark", "resultclass", "unit", "improvement"] leading_cols = len(pivot_index) +def init_pivot_index(merge_rolemap): + if not merge_rolemap: + global pivot_index + global leading_cols + pivot_index.insert(1, "rolemap") + leading_cols = len(pivot_index) + + def pivot_results(df, suts, swprofiles): def my_agg(group): values = group["value"] @@ -485,6 +502,7 @@ def pretty_results(results, args): results = results.rename( { "benchmark": "Benchmark", + "rolemap": "Role Map", "resultclass": "Result Class", }, axis=1, @@ -493,6 +511,8 @@ def pretty_results(results, args): results.columns = [term.make_dim(col) for col in results.columns] stat_align = ["right"] * (len(results.columns) - len(pivot_index) + 2) index_align = ["left", "left"] + if args.no_merge_rolemap: + index_align.insert(1, "right") colalign = index_align + stat_align # Tabulate then manually insert the separators at the indexes we previously diff --git a/fastpath/utils/table.py b/fastpath/utils/table.py index c2aed5c..1adee3d 100644 --- a/fastpath/utils/table.py +++ b/fastpath/utils/table.py @@ -118,6 +118,9 @@ def join_results(tables): results = results.join( tables[Table.BENCHMARK]["unique"].rename("benchmark"), on="benchmark_id" ) + results = results.join( + tables[Table.ROLEMAP]["unique"].rename("rolemap"), on="rolemap_id" + ) return results -- GitLab From 49819820dcc0f51629c8679da8a7fad0578578f3 Mon Sep 17 00:00:00 2001 From: Ryan Roberts Date: Tue, 29 Jul 2025 14:50:22 +0100 Subject: [PATCH 29/29] benchmarks: Introduce selftest benchmark suite Initially this has a single benchmark called echo, which has 2 benchmark roles, a client and a server. The client role sends a message to the server, which echos it back over TCP. THe client measures and reports the round trip time. This is intended to test the multi-node SUT support. Signed-off-by: Ryan Roberts --- benchmarks/selftest/echo.yaml | 7 ++ containers/selftest/Dockerfile | 30 +++++++++ containers/selftest/exec.py | 114 +++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 benchmarks/selftest/echo.yaml create mode 100644 containers/selftest/Dockerfile create mode 100755 containers/selftest/exec.py diff --git a/benchmarks/selftest/echo.yaml b/benchmarks/selftest/echo.yaml new file mode 100644 index 0000000..381ee0e --- /dev/null +++ b/benchmarks/selftest/echo.yaml @@ -0,0 +1,7 @@ +suite: selftest +name: echo +type: selftest +image: registry.gitlab.arm.com/tooling/fastpath/containers/selftest:v1.0 +roles: + - client + - server diff --git a/containers/selftest/Dockerfile b/containers/selftest/Dockerfile new file mode 100644 index 0000000..22b0847 --- /dev/null +++ b/containers/selftest/Dockerfile @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Arm Limited. +# SPDX-License-Identifier: MIT + +FROM registry.gitlab.arm.com/tooling/fastpath/containers/base:latest + +# Ensure apt won't ask for user input. +ENV DEBIAN_FRONTEND=noninteractive + +# Explicitly install Python and create a venv for pip packages, since Debian +# does not allow us to install pip packages system-wide. These packages are +# needed for all benchmark containers. +RUN apt-get update && \ + apt-get install --assume-yes --no-install-recommends --option=debug::pkgProblemResolver=yes \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + build-essential \ + pkg-config +RUN python3 -m venv /pyvenv +ENV PATH="/pyvenv/bin:${PATH}" +COPY fastpath/requirements.txt /tmp/requirements.txt +RUN pip3 install -r /tmp/requirements.txt +RUN rm -rf /tmp/requirements.txt + +# Setup the entrypoint. +RUN mkdir /fastpath +ARG NAME +COPY containers/${NAME}/exec.py /fastpath/. +CMD /fastpath/exec.py diff --git a/containers/selftest/exec.py b/containers/selftest/exec.py new file mode 100755 index 0000000..c8de77d --- /dev/null +++ b/containers/selftest/exec.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# Copyright (c) 2024, Arm Limited. +# SPDX-License-Identifier: MIT + + +import argparse +import csv +import os +import socket +import time +import yaml + + +ERR_NONE = 0 +ERR_INVAL_BENCHMARK_FORMAT = 2 +PORT = 12345 +MESSAGE = "Hello, server!" + + +def execute_server(client_ip): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("0.0.0.0", PORT)) + s.listen() + print(f"Server listening on port {PORT}") + + while True: + conn, addr = s.accept() + remote_ip, remote_port = addr + print(f"Connection attempt from {remote_ip}:{remote_port}") + + if remote_ip != client_ip: + print("Unauthorized client. Connection closed.") + conn.close() + else: + break + + with conn: + while True: + data = conn.recv(1024) + if not data: + break + conn.sendall(data) + + +def execute_client(server_ip): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + while True: + try: + s.connect((server_ip, PORT)) + break + except ConnectionRefusedError: + # Wait for server to start listening. + time.sleep(1) + + start = time.perf_counter() + s.sendall(MESSAGE.encode()) + data = s.recv(1024) + end = time.perf_counter() + + round_trip_us = (end - start) * 1000000 + response = data.decode() + + print(f"Sent: {MESSAGE}") + print(f"Received: {response}") + print(f"Latency: {round_trip_us:.2f} us") + + return round_trip_us + + +def validate_benchmark(benchmark): + if benchmark["suite"] != "selftest": + return ERR_INVAL_BENCHMARK_FORMAT + if benchmark["name"] != "echo": + return ERR_INVAL_BENCHMARK_FORMAT + if benchmark["role"] not in ["client", "server"]: + return ERR_INVAL_BENCHMARK_FORMAT + return ERR_NONE + + +def main(): + output_results = True + results = { + "name": "round trip time", + "unit": "us", + "improvement": "smaller", + "value": 0.0, + } + + parser = argparse.ArgumentParser() + parser.add_argument( + "--fastpath-share", required=False, default="/fastpath-share" + ) + args = parser.parse_args() + + with open(os.path.join(args.fastpath_share, "benchmark.yaml")) as f: + benchmark = yaml.safe_load(f) + + results["error"] = validate_benchmark(benchmark) + if results["error"] == 0: + if benchmark["role"] == "client": + results["value"] = execute_client(benchmark["ipmap"]["server"]) + elif benchmark["role"] == "server": + execute_server(benchmark["ipmap"]["client"]) + output_results = False + + if output_results: + with open(os.path.join(args.fastpath_share, "results.csv"), "w") as f: + writer = csv.DictWriter(f, fieldnames=results.keys()) + writer.writeheader() + writer.writerow(results) + + +if __name__ == "__main__": + main() -- GitLab