| #!/usr/bin/env python3 |
| # Copyright 2021 The gRPC Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| # Script to generate test configurations for the OSS benchmarks framework. |
| # |
| # This script filters test scenarios and generates uniquely named configurations |
| # for each test. Configurations are dumped in multipart YAML format. |
| # |
| # See documentation below: |
| # https://github.com/grpc/grpc/blob/master/tools/run_tests/performance/README.md#grpc-oss-benchmarks |
| |
| import argparse |
| import collections |
| import copy |
| import datetime |
| import itertools |
| import json |
| import os |
| import string |
| import sys |
| from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Type |
| |
| import yaml |
| |
| sys.path.append(os.path.dirname(os.path.abspath(__file__))) |
| import scenario_config |
| import scenario_config_exporter |
| |
| CONFIGURATION_FILE_HEADER_COMMENT = """ |
| # Load test configurations generated from a template by loadtest_config.py. |
| # See documentation below: |
| # https://github.com/grpc/grpc/blob/master/tools/run_tests/performance/README.md#grpc-oss-benchmarks |
| """ |
| |
| |
| def safe_name(language: str) -> str: |
| """Returns a name that is safe to use in labels and file names.""" |
| return scenario_config.LANGUAGES[language].safename |
| |
| |
| def default_prefix() -> str: |
| """Constructs and returns a default prefix for LoadTest names.""" |
| return os.environ.get("USER", "loadtest") |
| |
| |
| def now_string() -> str: |
| """Returns the current date and time in string format.""" |
| return datetime.datetime.now().strftime("%Y%m%d%H%M%S") |
| |
| |
| def validate_loadtest_name(name: str) -> None: |
| """Validates that a LoadTest name is in the expected format.""" |
| if len(name) > 253: |
| raise ValueError( |
| "LoadTest name must be less than 253 characters long: %s" % name |
| ) |
| if not all(c.isalnum() and not c.isupper() for c in name if c != "-"): |
| raise ValueError("Invalid characters in LoadTest name: %s" % name) |
| if not name or not name[0].isalpha() or name[-1] == "-": |
| raise ValueError("Invalid format for LoadTest name: %s" % name) |
| |
| |
| def loadtest_base_name( |
| scenario_name: str, uniquifier_elements: Iterable[str] |
| ) -> str: |
| """Constructs and returns the base name for a LoadTest resource.""" |
| name_elements = scenario_name.split("_") |
| name_elements.extend(uniquifier_elements) |
| return "-".join(element.lower() for element in name_elements) |
| |
| |
| def loadtest_name( |
| prefix: str, scenario_name: str, uniquifier_elements: Iterable[str] |
| ) -> str: |
| """Constructs and returns a valid name for a LoadTest resource.""" |
| base_name = loadtest_base_name(scenario_name, uniquifier_elements) |
| name_elements = [] |
| if prefix: |
| name_elements.append(prefix) |
| name_elements.append(base_name) |
| name = "-".join(name_elements) |
| validate_loadtest_name(name) |
| return name |
| |
| |
| def component_name(elements: Iterable[str]) -> str: |
| """Constructs a component name from possibly empty elements.""" |
| return "-".join((e for e in elements if e)) |
| |
| |
| def validate_annotations(annotations: Dict[str, str]) -> None: |
| """Validates that annotations do not contain reserved names. |
| |
| These names are automatically added by the config generator. |
| """ |
| names = set(("scenario", "uniquifier")).intersection(annotations) |
| if names: |
| raise ValueError("Annotations contain reserved names: %s" % names) |
| |
| |
| def gen_run_indices(runs_per_test: int) -> Iterable[str]: |
| """Generates run indices for multiple runs, as formatted strings.""" |
| if runs_per_test < 2: |
| yield "" |
| return |
| index_length = len("{:d}".format(runs_per_test - 1)) |
| index_fmt = "{{:0{:d}d}}".format(index_length) |
| for i in range(runs_per_test): |
| yield index_fmt.format(i) |
| |
| |
| def scenario_name( |
| base_name: str, |
| client_channels: Optional[int], |
| server_threads: Optional[int], |
| offered_load: Optional[int], |
| ): |
| """Constructs scenario name from base name and modifiers.""" |
| |
| elements = [base_name] |
| if client_channels: |
| elements.append("{:d}channels".format(client_channels)) |
| if server_threads: |
| elements.append("{:d}threads".format(server_threads)) |
| if offered_load: |
| elements.append("{:d}load".format(offered_load)) |
| return "_".join(elements) |
| |
| |
| def scenario_transform_function( |
| client_channels: Optional[int], |
| server_threads: Optional[int], |
| offered_loads: Optional[Iterable[int]], |
| ) -> Optional[ |
| Callable[[Iterable[Mapping[str, Any]]], Iterable[Mapping[str, Any]]] |
| ]: |
| """Returns a transform to be applied to a list of scenarios.""" |
| if not any((client_channels, server_threads, len(offered_loads))): |
| return lambda s: s |
| |
| def _transform( |
| scenarios: Iterable[Mapping[str, Any]] |
| ) -> Iterable[Mapping[str, Any]]: |
| """Transforms scenarios by inserting num of client channels, number of async_server_threads and offered_load.""" |
| |
| for base_scenario in scenarios: |
| base_name = base_scenario["name"] |
| if client_channels: |
| base_scenario["client_config"][ |
| "client_channels" |
| ] = client_channels |
| |
| if server_threads: |
| base_scenario["server_config"][ |
| "async_server_threads" |
| ] = server_threads |
| |
| if not offered_loads: |
| base_scenario["name"] = scenario_name( |
| base_name, client_channels, server_threads, 0 |
| ) |
| yield base_scenario |
| return |
| |
| for offered_load in offered_loads: |
| scenario = copy.deepcopy(base_scenario) |
| scenario["client_config"]["load_params"] = { |
| "poisson": {"offered_load": offered_load} |
| } |
| scenario["name"] = scenario_name( |
| base_name, client_channels, server_threads, offered_load |
| ) |
| yield scenario |
| |
| return _transform |
| |
| |
| def gen_loadtest_configs( |
| base_config: Mapping[str, Any], |
| base_config_clients: Iterable[Mapping[str, Any]], |
| base_config_servers: Iterable[Mapping[str, Any]], |
| scenario_name_regex: str, |
| language_config: scenario_config_exporter.LanguageConfig, |
| loadtest_name_prefix: str, |
| uniquifier_elements: Iterable[str], |
| annotations: Mapping[str, str], |
| instances_per_client: int = 1, |
| runs_per_test: int = 1, |
| scenario_transform: Callable[ |
| [Iterable[Mapping[str, Any]]], List[Dict[str, Any]] |
| ] = lambda s: s, |
| ) -> Iterable[Dict[str, Any]]: |
| """Generates LoadTest configurations for a given language config. |
| |
| The LoadTest configurations are generated as YAML objects. |
| """ |
| validate_annotations(annotations) |
| prefix = loadtest_name_prefix or default_prefix() |
| cl = safe_name(language_config.client_language or language_config.language) |
| sl = safe_name(language_config.server_language or language_config.language) |
| scenario_filter = scenario_config_exporter.scenario_filter( |
| scenario_name_regex=scenario_name_regex, |
| category=language_config.category, |
| client_language=language_config.client_language, |
| server_language=language_config.server_language, |
| ) |
| |
| scenarios = scenario_transform( |
| scenario_config_exporter.gen_scenarios( |
| language_config.language, scenario_filter |
| ) |
| ) |
| |
| for scenario in scenarios: |
| for run_index in gen_run_indices(runs_per_test): |
| uniq = ( |
| uniquifier_elements + [run_index] |
| if run_index |
| else uniquifier_elements |
| ) |
| name = loadtest_name(prefix, scenario["name"], uniq) |
| scenario_str = ( |
| json.dumps({"scenarios": scenario}, indent=" ") + "\n" |
| ) |
| |
| config = copy.deepcopy(base_config) |
| |
| metadata = config["metadata"] |
| metadata["name"] = name |
| if "labels" not in metadata: |
| metadata["labels"] = dict() |
| metadata["labels"]["language"] = safe_name(language_config.language) |
| metadata["labels"]["prefix"] = prefix |
| if "annotations" not in metadata: |
| metadata["annotations"] = dict() |
| metadata["annotations"].update(annotations) |
| metadata["annotations"].update( |
| { |
| "scenario": scenario["name"], |
| "uniquifier": "-".join(uniq), |
| } |
| ) |
| |
| spec = config["spec"] |
| |
| # Select clients with the required language. |
| clients = [ |
| client |
| for client in base_config_clients |
| if client["language"] == cl |
| ] |
| if not clients: |
| raise IndexError( |
| "Client language not found in template: %s" % cl |
| ) |
| |
| # Validate config for additional client instances. |
| if instances_per_client > 1: |
| c = collections.Counter( |
| (client.get("name", "") for client in clients) |
| ) |
| if max(c.values()) > 1: |
| raise ValueError( |
| "Multiple instances of multiple clients requires " |
| "unique names, name counts for language %s: %s" |
| % (cl, c.most_common()) |
| ) |
| |
| # Name client instances with an index starting from zero. |
| client_instances = [] |
| for i in range(instances_per_client): |
| client_instances.extend(copy.deepcopy(clients)) |
| for client in client_instances[-len(clients) :]: |
| client["name"] = component_name( |
| (client.get("name", ""), str(i)) |
| ) |
| |
| # Set clients to named instances. |
| spec["clients"] = client_instances |
| |
| # Select servers with the required language. |
| servers = copy.deepcopy( |
| [ |
| server |
| for server in base_config_servers |
| if server["language"] == sl |
| ] |
| ) |
| if not servers: |
| raise IndexError( |
| "Server language not found in template: %s" % sl |
| ) |
| |
| # Name servers with an index for consistency with clients. |
| for i, server in enumerate(servers): |
| server["name"] = component_name( |
| (server.get("name", ""), str(i)) |
| ) |
| |
| # Set servers to named instances. |
| spec["servers"] = servers |
| |
| # Add driver, if needed. |
| if "driver" not in spec: |
| spec["driver"] = dict() |
| |
| # Ensure driver has language and run fields. |
| driver = spec["driver"] |
| if "language" not in driver: |
| driver["language"] = safe_name("c++") |
| if "run" not in driver: |
| driver["run"] = dict() |
| |
| # Name the driver with an index for consistency with workers. |
| # There is only one driver, so the index is zero. |
| if "name" not in driver or not driver["name"]: |
| driver["name"] = "0" |
| |
| spec["scenariosJSON"] = scenario_str |
| |
| yield config |
| |
| |
| def parse_key_value_args(args: Optional[Iterable[str]]) -> Dict[str, str]: |
| """Parses arguments in the form key=value into a dictionary.""" |
| d = dict() |
| if args is None: |
| return d |
| for arg in args: |
| key, equals, value = arg.partition("=") |
| if equals != "=": |
| raise ValueError("Expected key=value: " + value) |
| d[key] = value |
| return d |
| |
| |
| def clear_empty_fields(config: Dict[str, Any]) -> None: |
| """Clears fields set to empty values by string substitution.""" |
| spec = config["spec"] |
| if "clients" in spec: |
| for client in spec["clients"]: |
| if "pool" in client and not client["pool"]: |
| del client["pool"] |
| if "servers" in spec: |
| for server in spec["servers"]: |
| if "pool" in server and not server["pool"]: |
| del server["pool"] |
| if "driver" in spec: |
| driver = spec["driver"] |
| if "pool" in driver and not driver["pool"]: |
| del driver["pool"] |
| if ( |
| "run" in driver |
| and "image" in driver["run"] |
| and not driver["run"]["image"] |
| ): |
| del driver["run"]["image"] |
| if "results" in spec and not ( |
| "bigQueryTable" in spec["results"] and spec["results"]["bigQueryTable"] |
| ): |
| del spec["results"] |
| |
| |
| def config_dumper(header_comment: str) -> Type[yaml.SafeDumper]: |
| """Returns a custom dumper to dump configurations in the expected format.""" |
| |
| class ConfigDumper(yaml.SafeDumper): |
| def expect_stream_start(self): |
| super().expect_stream_start() |
| if isinstance(self.event, yaml.StreamStartEvent): |
| self.write_indent() |
| self.write_indicator(header_comment, need_whitespace=False) |
| |
| def str_presenter(dumper, data): |
| if "\n" in data: |
| return dumper.represent_scalar( |
| "tag:yaml.org,2002:str", data, style="|" |
| ) |
| return dumper.represent_scalar("tag:yaml.org,2002:str", data) |
| |
| ConfigDumper.add_representer(str, str_presenter) |
| |
| return ConfigDumper |
| |
| |
| def main() -> None: |
| language_choices = sorted(scenario_config.LANGUAGES.keys()) |
| argp = argparse.ArgumentParser( |
| description="Generates load test configs from a template.", |
| fromfile_prefix_chars="@", |
| ) |
| argp.add_argument( |
| "-l", |
| "--language", |
| action="append", |
| choices=language_choices, |
| required=True, |
| help="Language(s) to benchmark.", |
| dest="languages", |
| ) |
| argp.add_argument( |
| "-t", |
| "--template", |
| type=str, |
| required=True, |
| help="LoadTest configuration yaml file template.", |
| ) |
| argp.add_argument( |
| "-s", |
| "--substitution", |
| action="append", |
| default=[], |
| help="Template substitution(s), in the form key=value.", |
| dest="substitutions", |
| ) |
| argp.add_argument( |
| "-p", "--prefix", default="", type=str, help="Test name prefix." |
| ) |
| argp.add_argument( |
| "-u", |
| "--uniquifier_element", |
| action="append", |
| default=[], |
| help="String element(s) to make the test name unique.", |
| dest="uniquifier_elements", |
| ) |
| argp.add_argument( |
| "-d", |
| action="store_true", |
| help="Use creation date and time as an additional uniquifier element.", |
| ) |
| argp.add_argument( |
| "-a", |
| "--annotation", |
| action="append", |
| default=[], |
| help="metadata.annotation(s), in the form key=value.", |
| dest="annotations", |
| ) |
| argp.add_argument( |
| "-r", |
| "--regex", |
| default=".*", |
| type=str, |
| help="Regex to select scenarios to run.", |
| ) |
| argp.add_argument( |
| "--category", |
| choices=[ |
| "all", |
| "inproc", |
| "scalable", |
| "smoketest", |
| "sweep", |
| "psm", |
| "dashboard", |
| ], |
| default="all", |
| help="Select a category of tests to run.", |
| ) |
| argp.add_argument( |
| "--allow_client_language", |
| action="append", |
| choices=language_choices, |
| default=[], |
| help="Allow cross-language scenarios with this client language.", |
| dest="allow_client_languages", |
| ) |
| argp.add_argument( |
| "--allow_server_language", |
| action="append", |
| choices=language_choices, |
| default=[], |
| help="Allow cross-language scenarios with this server language.", |
| dest="allow_server_languages", |
| ) |
| argp.add_argument( |
| "--instances_per_client", |
| default=1, |
| type=int, |
| help="Number of instances to generate for each client.", |
| ) |
| argp.add_argument( |
| "--runs_per_test", |
| default=1, |
| type=int, |
| help="Number of copies to generate for each test.", |
| ) |
| argp.add_argument( |
| "-o", |
| "--output", |
| type=str, |
| help="Output file name. Output to stdout if not set.", |
| ) |
| argp.add_argument( |
| "--client_channels", type=int, help="Number of client channels." |
| ) |
| argp.add_argument( |
| "--server_threads", type=int, help="Number of async server threads." |
| ) |
| argp.add_argument( |
| "--offered_loads", |
| nargs="*", |
| type=int, |
| default=[], |
| help=( |
| "A list of QPS values at which each load test scenario will be run." |
| ), |
| ) |
| args = argp.parse_args() |
| |
| if args.instances_per_client < 1: |
| argp.error("instances_per_client must be greater than zero.") |
| |
| if args.runs_per_test < 1: |
| argp.error("runs_per_test must be greater than zero.") |
| |
| # Config generation ignores environment variables that are passed by the |
| # controller at runtime. |
| substitutions = { |
| "DRIVER_PORT": "${DRIVER_PORT}", |
| "KILL_AFTER": "${KILL_AFTER}", |
| "POD_TIMEOUT": "${POD_TIMEOUT}", |
| } |
| |
| # The user can override the ignored variables above by passing them in as |
| # substitution keys. |
| substitutions.update(parse_key_value_args(args.substitutions)) |
| |
| uniquifier_elements = args.uniquifier_elements |
| if args.d: |
| uniquifier_elements.append(now_string()) |
| |
| annotations = parse_key_value_args(args.annotations) |
| |
| transform = scenario_transform_function( |
| args.client_channels, args.server_threads, args.offered_loads |
| ) |
| |
| with open(args.template) as f: |
| base_config = yaml.safe_load( |
| string.Template(f.read()).substitute(substitutions) |
| ) |
| |
| clear_empty_fields(base_config) |
| |
| spec = base_config["spec"] |
| base_config_clients = spec["clients"] |
| del spec["clients"] |
| base_config_servers = spec["servers"] |
| del spec["servers"] |
| |
| client_languages = [""] + args.allow_client_languages |
| server_languages = [""] + args.allow_server_languages |
| config_generators = [] |
| for l, cl, sl in itertools.product( |
| args.languages, client_languages, server_languages |
| ): |
| language_config = scenario_config_exporter.LanguageConfig( |
| category=args.category, |
| language=l, |
| client_language=cl, |
| server_language=sl, |
| ) |
| config_generators.append( |
| gen_loadtest_configs( |
| base_config, |
| base_config_clients, |
| base_config_servers, |
| args.regex, |
| language_config, |
| loadtest_name_prefix=args.prefix, |
| uniquifier_elements=uniquifier_elements, |
| annotations=annotations, |
| instances_per_client=args.instances_per_client, |
| runs_per_test=args.runs_per_test, |
| scenario_transform=transform, |
| ) |
| ) |
| configs = (config for config in itertools.chain(*config_generators)) |
| |
| with open(args.output, "w") if args.output else sys.stdout as f: |
| yaml.dump_all( |
| configs, |
| stream=f, |
| Dumper=config_dumper(CONFIGURATION_FILE_HEADER_COMMENT.strip()), |
| default_flow_style=False, |
| ) |
| |
| |
| if __name__ == "__main__": |
| main() |