From 265773a732f8fdb68020bbbceb25de232af600ff Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Thu, 6 Mar 2025 15:22:46 -0800 Subject: [PATCH 1/5] Add instructions to run benchmarks --- benchmark/Inference_Extension_Benchmark.ipynb | 358 ++++++++++++++++++ benchmark/README.md | 104 +++++ benchmark/download-benchmark-results.bash | 29 ++ benchmark/image.png | Bin 0 -> 61054 bytes .../BenchmarkInferenceExtension.yaml | 60 +++ benchmark/manifests/BenchmarkK8sService.yaml | 59 +++ benchmark/manifests/ModelServerService.yaml | 12 + benchmark/requirements.txt | 3 + 8 files changed, 625 insertions(+) create mode 100644 benchmark/Inference_Extension_Benchmark.ipynb create mode 100644 benchmark/README.md create mode 100755 benchmark/download-benchmark-results.bash create mode 100644 benchmark/image.png create mode 100644 benchmark/manifests/BenchmarkInferenceExtension.yaml create mode 100644 benchmark/manifests/BenchmarkK8sService.yaml create mode 100644 benchmark/manifests/ModelServerService.yaml create mode 100644 benchmark/requirements.txt diff --git a/benchmark/Inference_Extension_Benchmark.ipynb b/benchmark/Inference_Extension_Benchmark.ipynb new file mode 100644 index 000000000..993279cb9 --- /dev/null +++ b/benchmark/Inference_Extension_Benchmark.ipynb @@ -0,0 +1,358 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "executionInfo": { + "elapsed": 391, + "status": "ok", + "timestamp": 1741734317446, + "user": { + "displayName": "Cong Liu", + "userId": "18222691451061354557" + }, + "user_tz": 420 + }, + "id": "ziJD5zt0c1Rt" + }, + "outputs": [], + "source": [ + "#@title Configuration. Edit this before running the rest.\n", + "\n", + "OUTPUT_DIR='output'\n", + "RUN_ID='example-run'\n", + "# Path to the benchmark dir under `gateway-api-inference-extension/benchmark`\n", + "BENCHMARK_DIR =\"./\"\n", + "# A regex to match the model name, which matches the output file name.\n", + "MODEL_MATCHER='.*llama.*'" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "executionInfo": { + "elapsed": 33, + "status": "ok", + "timestamp": 1741735749209, + "user": { + "displayName": "Cong Liu", + "userId": "18222691451061354557" + }, + "user_tz": 420 + }, + "id": "dB7xALgLawN-" + }, + "outputs": [], + "source": [ + "#@title Plot Helper\n", + "import os\n", + "import pandas as pd\n", + "import re\n", + "import json\n", + "from collections import OrderedDict\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import math\n", + "import logging\n", + "level = logging.INFO\n", + "logger = logging.getLogger(__name__)\n", + "logger.setLevel(level)\n", + "handler = logging.StreamHandler() # This sends output to the console\n", + "handler.setLevel(level) # Set handler level\n", + "logger.addHandler(handler)\n", + "\n", + "title_fontsize = 18\n", + "axis_label_fontsize = 18\n", + "legend_fontsize = 16\n", + "tick_label_fontsize = 14\n", + "\n", + "# Encapsulates some basic information needed to plot metrics.\n", + "class XY:\n", + " def __init__(self, x: str, y: str, x_label=None, y_label=None):\n", + " self.x = x\n", + " self.y = y\n", + " self.x_label = x if x_label is None else x_label\n", + " self.y_label = y if y_label is None else y_label\n", + "\n", + "NUM_PLOTS_PER_ROW = 4\n", + "# The arguments need to match the metric name fields generated by the benchmark tool.\n", + "CORE_METRICS = [\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'output_tokens_per_min'),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y = \"p90_per_output_token_latency\"),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y = \"p90_latency\"),\n", + "]\n", + "SANITY_CHECK_METRICS = [\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'benchmark_time'),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_attempted\"),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_succeeded\"),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'throughput_rps'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'total_input_tokens'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'total_output_token'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'avg_input_len'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'avg_output_len'),\n", + "]\n", + "\n", + "class Label:\n", + " def __init__(self, name, alias=None):\n", + " self.name = name\n", + " self.alias = name if alias is None else alias\n", + "\n", + "ALL_METRICS = CORE_METRICS + SANITY_CHECK_METRICS\n", + "\n", + "class Plotter:\n", + " def __init__(self, run_id, labels=None, metrics=CORE_METRICS, num_plots_per_row=5, interactive=False, annotate=False, output_dir=OUTPUT_DIR):\n", + " self.run_id = run_id\n", + " self.labels = labels\n", + " self.metrics = metrics\n", + " self.num_plots_per_row = num_plots_per_row\n", + " self.interactive = interactive\n", + " self.annotate = annotate\n", + " self.output_dir = output_dir\n", + "\n", + " def withRunId(self, run_id):\n", + " return Plotter(run_id, self.labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", + "\n", + " def withLabels(self, labels):\n", + " return Plotter(self.run_id, labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", + "\n", + " def withMetrics(self, metrics):\n", + " return Plotter(self.run_id, self.labels, metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", + "\n", + " def withOutputDir(self, output_dir):\n", + " return Plotter(self.run_id, self.labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, output_dir)\n", + "\n", + " def plot_bar(self):\n", + " data = load_data(self.labels, self.run_id, self.output_dir)\n", + " groups = group_data(data, self.metrics)\n", + " logger.debug(\"Plotting run id...\")\n", + " plot_bar(self.labels, groups, self.metrics, self.num_plots_per_row, self.interactive, annotate=self.annotate)\n", + "\n", + "def filepaths(root_dir):\n", + " \"\"\"\n", + " Recursively reads files within a directory and returns a list of file paths.\n", + " \"\"\"\n", + "\n", + " filepaths = []\n", + " for dirpath, dirnames, filenames in os.walk(root_dir):\n", + " for filename in filenames:\n", + " filepath = os.path.join(dirpath, filename)\n", + " filepaths.append(filepath)\n", + " return filepaths\n", + "\n", + "def flatten_server_metrics(server_metrics):\n", + " \"\"\"\n", + " Flattens the server metrics json to a single level.\n", + " \"\"\"\n", + " flattend = {}\n", + " for k, v in server_metrics.items():\n", + " if isinstance(v, dict):\n", + " for k2, v2 in v.items():\n", + " flattend[k + \".\" + k2] = v2\n", + "\n", + " return flattend\n", + "\n", + "def load_data(labels, run_id, output_dir=OUTPUT_DIR):\n", + " data_path =f\"{BENCHMARK_DIR}/{output_dir}/{run_id}\"\n", + " records = []\n", + " logger.debug(f\"Loading data for {data_path}\")\n", + " for file in filepaths(data_path):\n", + " for label in labels:\n", + " regex = f\".*\\/{label.name}\\/results/json/{MODEL_MATCHER}.json\"\n", + " logger.debug(f\"matching file {file} for regex {regex} and label {label}\")\n", + " if re.match(regex, file):\n", + " logger.debug(f\"found match file {file} for regex {regex} and label {label}\")\n", + " with open(file, 'r') as f:\n", + " raw_data = json.load(f)\n", + " sample_data = {\n", + " 'file_name': f.name,\n", + " 'label': label.alias,\n", + " **raw_data.get(\"metrics\",{}),\n", + " **flatten_server_metrics(raw_data.get(\"metrics\",{}).get(\"server_metrics\", {})),\n", + " }\n", + " sample_data['request_rate'] = sample_data['request_rate'] * raw_data['config']['num_models']\n", + " records.append(sample_data)\n", + " all_data = pd.DataFrame.from_records(records, index='file_name') if len(records) > 0 else pd.DataFrame()\n", + " return all_data\n", + "\n", + "def group_data(all_data, metrics=CORE_METRICS):\n", + " try:\n", + " data = all_data.sort_values(by=['request_rate'], ascending=True).copy().dropna()\n", + " except:\n", + " # print(\"No data found\")\n", + " return None\n", + "\n", + " # Ensure there is exactly one benchmark result per label and x-axis for each\n", + " # metric.\n", + " x_axes = set()\n", + " for m in metrics:\n", + " x_axes.add(m.x)\n", + "\n", + " for x in x_axes:\n", + " sizes = data.groupby(by=['label', x], dropna=True).size()\n", + " for index, v in sizes.items():\n", + " if v > 1:\n", + " label, _ = index\n", + " # print(f\"Multiple benchmark results for the same label ({label}), and x-axis ({x}). {index}: {v}. Please use more selective file filters.\")\n", + " # raise ValueError(f\"Multiple benchmark results for the same label ({label}), and x-axis ({x}). Please use more selective file filters.\")\n", + "\n", + " # Group by label.\n", + " groups = data.groupby(by=['label'],sort=True)\n", + " return groups\n", + "\n", + "def init_plot(metrics, num_plots_per_row=NUM_PLOTS_PER_ROW):\n", + " num_plots_per_row = min(num_plots_per_row, len(metrics))\n", + " row_count = math.ceil(len(metrics) / num_plots_per_row)\n", + " fig, axes = plt.subplots(nrows=row_count, ncols=num_plots_per_row, figsize=(20, 5*row_count), tight_layout=True)\n", + " if row_count == 1 and num_plots_per_row == 1:\n", + " axes = [axes]\n", + " return fig, axes\n", + "\n", + "def plot_metrics(metrics, plot_func, num_plots_per_row=NUM_PLOTS_PER_ROW, fig=None, axes=None):\n", + " \"\"\"\n", + " plot_func: a function in the form of def plot_func(ax:~matplotlib.axes.Axes , m: XY):\n", + " \"\"\"\n", + " logger.debug(f'Plotting metrics: {metrics}')\n", + " num_plots_per_row = min(num_plots_per_row, len(metrics))\n", + " if fig is None or axes is None:\n", + " logger.debug(f'Creating new figure and axes')\n", + " fig, axes = init_plot(metrics, num_plots_per_row)\n", + " row_count = math.ceil(len(metrics) / num_plots_per_row)\n", + " for i, m in enumerate(metrics):\n", + " row = math.floor(i/num_plots_per_row)\n", + " col = i%num_plots_per_row\n", + " if row_count == 1:\n", + " curAx = axes[col]\n", + " else:\n", + " curAx = axes[row, col]\n", + " plot_func(curAx, m)\n", + " return fig, axes\n", + "\n", + "def plot_bar(labels, groups, metrics=CORE_METRICS, num_plots_per_row=NUM_PLOTS_PER_ROW, interactive=INTERACTIVE_PLOT, annotate=False):\n", + " labels = [label.alias for label in labels]\n", + " logger.debug(f'Prnting bar chart for {labels}')\n", + " logger.debug(f'groups: {groups}')\n", + " dataframes = []\n", + " for label in labels:\n", + " try:\n", + " dataframes.append(groups.get_group((label,)))\n", + " except:\n", + " logger.debug(f\"No data found for label {label}\")\n", + " continue\n", + " y_columns = [m.y for m in metrics]\n", + " logger.debug(f'y_columns: {y_columns}')\n", + " logger.debug(f'dataframes: {dataframes}')\n", + "\n", + " # 1. Combine all request rates\n", + " all_request_rates = set()\n", + " for df in dataframes:\n", + " all_request_rates.update(df['request_rate'].astype(int))\n", + " all_request_rates = sorted(list(all_request_rates))\n", + "\n", + " # 2. Prepare data for plotting: Create a nested dictionary\n", + " plot_data = {y_col: {label: {} for label in labels} for y_col in y_columns}\n", + "\n", + " for i, df in enumerate(dataframes):\n", + " label = labels[i]\n", + " df_dict = df.set_index('request_rate').to_dict()\n", + " for y_col in y_columns:\n", + " for request_rate in all_request_rates:\n", + " plot_data[y_col][label][request_rate] = df_dict.get(y_col, {}).get(request_rate, np.nan)\n", + "\n", + " logger.debug(f'Plot_data: {plot_data}')\n", + "\n", + " # 3. Plotting\n", + " def plot_func(curAx, m):\n", + " num_request_rates = len(all_request_rates)\n", + " num_labels = len(labels)\n", + " x = np.arange(num_request_rates) # the label locations (x-axis positions)\n", + " width = 0.4 / num_labels # width of the bars\n", + "\n", + " for i, label in enumerate(labels):\n", + " bar_x = x - (width*num_labels)/2 + i*width + width/2\n", + " #Extract y-values to plot\n", + " y_values = [plot_data[m.y][label][rr] for rr in all_request_rates]\n", + "\n", + " rects = curAx.bar(bar_x, y_values, width, label=label)\n", + " if annotate:\n", + " for rect, val in zip(rects, y_values):\n", + " if not np.isnan(val):\n", + " height = rect.get_height()\n", + " curAx.annotate(f'{val:.2f}',\n", + " xy=(rect.get_x() + rect.get_width() / 2, height),\n", + " xytext=(0, 3), # 3 points vertical offset\n", + " textcoords=\"offset points\",\n", + " ha='center', va='bottom')\n", + " # Add labels, title, and legend\n", + " curAx.set_xlabel(m.x_label, fontsize=axis_label_fontsize)\n", + " curAx.set_ylabel(m.y_label, fontsize=axis_label_fontsize)\n", + " curAx.set_xticks(x)\n", + " curAx.set_xticklabels(all_request_rates)\n", + " curAx.tick_params(axis='both', labelsize=tick_label_fontsize)\n", + " curAx.legend(fontsize=legend_fontsize, loc='upper left', frameon=True, framealpha=0.8, edgecolor='black')\n", + " fig, axes = plot_metrics(metrics, plot_func, num_plots_per_row)\n", + " fig.tight_layout(rect=[0, 0.03, 1, 0.95])\n", + " plt.show()\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "height": 1000 + }, + "executionInfo": { + "elapsed": 2232, + "status": "ok", + "timestamp": 1741735855456, + "user": { + "displayName": "Cong Liu", + "userId": "18222691451061354557" + }, + "user_tz": 420 + }, + "id": "HbGEAOucb_Jn", + "outputId": "faf0304b-92f4-4fa7-ae71-83b8bd987e70" + }, + "outputs": [], + "source": [ + "#@title Plot Result\n", + "\n", + "pl = Plotter(run_id=RUN_ID, labels=[Label('inference-extension'),Label('k8s-svc')], output_dir=OUTPUT_DIR)\n", + "pl.plot_bar()" + ] + } + ], + "metadata": { + "colab": { + "last_runtime": { + "build_target": "", + "kind": "local" + }, + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 000000000..e2a44a363 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,104 @@ +# Benchmark + +This user guide shows how to run benchmarks against a vLLM deployment, by using both the Gateway API +inference extension, and a Kubernetes service as the load balancing strategy. The +benchmark uses the [Latency Profile Generator](https://github.com/AI-Hypercomputer/inference-benchmark) (LPG) +tool to generate load and collect results. + +## Prerequisites + +### Deploy the inference extension and sample model server + +Follow this user guide https://gateway-api-inference-extension.sigs.k8s.io/guides/ to deploy the +sample vLLM application, and the inference extension. + +### [Optional] Scale the sample vLLM deployment + +You will more likely to see the benefits of the inference extension when there are a decent number of replicas to make the optimal routing decision. + +```bash +kubectl scale deployment my-pool --replicas=8 +``` + +### Expose the model server via a k8s service + +As the baseline, let's also expose the vLLM deployment as a k8s service by simply applying the yaml: + +```bash +kubectl apply -f .manifests/ModelServerService.yaml +``` + +## Run benchmark + +### Run benchmark using the inference extension as the load balancing strategy + +1. Get the gateway IP: + + ```bash + IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') + echo "Update the in ./manifests/BenchmarkInferenceExtension.yaml to: $IP" + ``` + +1. Then update the `` in `./manifests/BenchmarkInferenceExtension.yaml` to the IP +of the gateway. Feel free to adjust other parameters such as request_rates as well. + +1. Start the benchmark tool. `kubectl apply -f ./manifests/BenchmarkInferenceExtension.yaml` + +1. Wait for benchmark to finish and download the results. Use the `benchmark_id` environment variable +to specify what this benchmark is for. In this case, the result is for the `inference-extension`. You +can use any id you like. + + ```bash + benchmark_id='inference-extension' ./download-benchmark-results.bash + ``` + +1. After the script finishes, you should see benchmark results under `./output/default-run/inference-extension/results/json` folder. + +### Run benchmark using k8s service as the load balancing strategy + +1. Get the service IP: + + ```bash + IP=$(kubectl get service/my-pool-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo "Update the in ./manifests/BenchmarkK8sService.yaml to: $IP" + ``` + +2. Then update the `` in `./manifests/BenchmarkK8sService.yaml` to the IP +of the service. Feel free to adjust other parameters such as **request_rates** as well. + +1. Start the benchmark tool. `kubectl apply -f ./manifests/BenchmarkK8sService.yaml` + +2. Wait for benchmark to finish and download the results. + + ```bash + benchmark_id='k8s-svc' ./download-benchmark-results.bash + ``` + +3. After the script finishes, you should see benchmark results under `./output/default-run/k8s-svc/results/json` folder. + +### Tips + +* You can specify `run_id="runX"` environment variable when running the `./download-benchmark-results.bash` script. +This is useful when you run benchmarks multiple times and group the results accordingly. + +## Analyze the results + +This guide shows how to run the jupyter notebook using vscode. + +1. Create a python virtual environment. + + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` + +1. Install the dependencies. + + ```bash + pip install -r requirements.txt + ``` + +1. Open the notebook `Inference_Extension_Benchmark.ipynb`, and run each cell. At the end you should + see a bar chart like below: + + ![alt text](image.png) \ No newline at end of file diff --git a/benchmark/download-benchmark-results.bash b/benchmark/download-benchmark-results.bash new file mode 100755 index 000000000..01ec8b528 --- /dev/null +++ b/benchmark/download-benchmark-results.bash @@ -0,0 +1,29 @@ +#!/bin/bash + +# Downloads the benchmark result files from the benchmark tool pod. +download_benchmark_results() { + until echo $(kubectl logs deployment/benchmark-tool -n ${namespace}) | grep -q -m 1 "LPG_FINISHED"; do sleep 30 ; done; + benchmark_pod=$(kubectl get pods -l app=benchmark-tool -n ${namespace} -o jsonpath="{.items[0].metadata.name}") + echo "Downloading JSON results from pod ${benchmark_pod}" + kubectl exec ${benchmark_pod} -n ${namespace} -- rm -f ShareGPT_V3_unfiltered_cleaned_split.json + for f in $(kubectl exec ${benchmark_pod} -n ${namespace} -- /bin/sh -c ls -l | grep json); do + echo "Downloading json file ${f}" + kubectl cp -n ${namespace} ${benchmark_pod}:$f ${benchmark_output_dir}/results/json/$f; + done +} + +# Env vars to be passed when calling this script. +# The id of the benchmark. This is needed to identify what the benchmark is for. +# It decides the filepath to save the results, which later is used by the jupyter notebook to assign +# the benchmark_id as data labels for plotting. +benchmark_id=${benchmark_id:-"inference-extension"} +# run_id can be used to group different runs of the same benchmarks for comparison. +run_id=${run_id:-"default-run"} +namespace=${namespace:-"default"} +output_dir=${output_dir:-'output'} + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +benchmark_output_dir=${SCRIPT_DIR}/${output_dir}/${run_id}/${benchmark_id} + +echo "Saving benchmark results to ${benchmark_output_dir}/results/json/" +download_benchmark_results \ No newline at end of file diff --git a/benchmark/image.png b/benchmark/image.png new file mode 100644 index 0000000000000000000000000000000000000000..54dc65898cfe352efa7f3e87d5215f77d3ad0dc6 GIT binary patch literal 61054 zcmb@uby$__);$b}!a`{j>FyMeZlt6^L?k2y6zT3pI+c(TkP-z138h0oNkK^^rMsm0 z<}&tq&wJkA`Rlv5+^&t!TI+u9m~+fA#(cumRpqgs2pf+Qo z!EX)-W0>JDR7XvDX_VJJH_Y!S-d9mUVTIS2D5#-UDCo#b;14PMK|wi}g^F?x z{zgUqE(`7Nzhbmyo%{P5wHbNgBGbKj6ckAm#T!z$+)!82-D|Z6j`ka>RQo|>QI zjy6@Ey>;_-XM(P)Z!@pg{j9NA-CeN|>uoqaJz788jP*mOr$$96NTLz^A1|pH{IN9+ zceDT7HIis&3|Zpe*8Tt6MOj8@O9L10=LY?^izL0@P5eKHf*hb^6Z&92ahC$(|25uU zcX8;{aPI%QnZLK_AAl%O<4TSr_-_}X?k@UZ{Fj-LL_1HYo@vNU$M9b-S{gXP`8&6z zK8Q$iA%`zT+RIhg50n05;){LJ&JK^(8-{$@DmQ%5PY;(%*6WX6KloB~b~w~<$~Bbu zd-Adr5Zn!C@ULUm<=q#^5|7_I9;&~(`?m7Yn4w+M`SK zbC1K|ol|)?|4W?`!%!TG(F&{bsSq-`CuduqxavH1Nkv>%;u(}4d@Xh#waVXJ87aT{ zgf&jm2Vt>v+>y-nYfWSos9)Y+q9q?XeEreN!<(?u=hq_mNiJF4_4cs$RlLG+x8j@2 z*k#w3l}x|hA*5(D)g1ix!57xe=Q(YsCqE}zB4z_|gbPgDqw_O;Jc6+Y@KZe3>wZR(@G`2V z4OiRG6l&$)F1~Zk`F+n|;eng>uPq!NEBUl*P*yN|*Wk&(&LN}q$**O`!|$+7y`4!( z!m#{_-zZG>V!K!3tadT)+0*@nvP9qG&Gt}iGJfwv zC*eSo^g=vGXQJiztCf0r-&(jIt&YAWdsLxuD|P_xTscGZ;6;+5@LaNKM>|oSTBf+c zdzaOT24B48udl}ZP|w-aAIvSke$UC8Y|)$|tj2!-=C2LiPtZ}6^3F1}((s{Us$q%S zEB#+Lf#yFrP;t03eVSn>q5fzCFH5|xx_>MK*56;BHa2|#`)~(?(#>eFK*Mr4;-kLU z_!s1=hqJw|hVR%ndq{cv0IzZ2(+4tJa`e(W6CTU29u5|4?hf6{KczKedN=63SM+Ny zB?_ad3IxA5cVHtIP*VIgA~|$TH35cot?M~W%`mXz={8*Ped_Nz z>RxKk_*cgrd)s=Cp6D0~J^WhSjExw9)Ik@(FtMr{8WHWxzsbFN~7LW(M*mBU<5 z`ll;p&2LRu{`2_$&ofKJ=Kt{tTb1p^X}at86d}hs^CW|ibz-k9TuRYuslW>=&-lHk z!t-UmlYaGAR_gH+-TH#Rx8J*=PC4@5B+Km^=#|D)Or5&KZlXw z^jVR`BUrr7b^W^(%FwkTeaCK&s&N8U`yx5o5qqzViYu=f2bD((Dua|Vey#Gqopf5X z(R_Jk<-b8eT^ap5Y-$_|;mNrl@k*QrI|FJ6&e1%TXY-l983RSyFFEg&P7Z3RQHZ!$ zhkw6SsCoYKai;HS*x6HfTartxz#sfHFs8~`e>A~)D7eR?K|mXXCQFS z?-MspDgBOu;V~$OHck_;xkWb`&xPVr)tj`1d+aX30e&>$bF|tKM;9ZqCRkga)gwNi z=GgZ{es7NDu&U_-8J@?^bOaY;7fa>p$>FYy;GMi2ay90w6|0>t%a64S)KQmr7ZjKm z3jZU}Q{(kbL`sL76*Z76nzB z^!Cf!ujHagg}85rGW(k_Y>ZfBZk|B`V7v4=Mr`j+S_RSYv9^wZZ0u*zHuY}8jtYk# zAN#c#KfWxfPaVjK5l>YcmA*HTua;TweYCeeA@MlwLtPgfW2z^t@vZP>4b~$(`HQsv zRty9RDt^9?PB+7h(t2zamWpawQ4zD4sKkRp4mlL}pW@j`r85XL+Yp`ej~~jH*c&#r zt=;?(O69ZP$&^`aSVwAGG@s#V<3pDz;<}dMxfT{S5=?C9c6RcU>9H6rOW*};lm2J} zy#z*;I|>312AQSiAfB&}lrz2W87kHdtRerjKA4I&;y((CH!@SwB>A|38?7&xN)lYxbJG4r)< zloCG2Y7!@d6;@gm8&Z*B&ld8!26^vyO&S{=?ko~AsalU$Z$hvl`BEMorkKDecD&Kj z6xt#JMz)|E0X$4GWA z5A^GE-6=Kt#8G87ArM9+^W&QXmbM5@M*-HZK&5R1r0#|O99-drES-Dam)ksU%`cZU zMBD4Pg&_B2#*6Nn;|5R>5A~&+ndV zHS=y?uCy+6BAC*+jhlnc;RsG9YN;A?h*=MFk9t6;8_uNE)?R4sO5yFN>ytU`JUaIj zJ9UbPH19k^tT^iFTle(6=8IhD7jJ7XE#_!xq7@=?33d6Lu{U4VwtRShvE&0B_D9M+ zVO$^eJ0m-vagV>jP#mx24*a?FC88!{B-7&3oQGk7PSGtEL+$4S%mW9{Cl%=gU)!g0 z@+n@t2~(y#a5Z%!?fA12m+`v8kb&!FL#B^C0|T}HIL%~$uDJ4SxIXl=5$GBO-?&-I%#gk0q- zau*w1R!7&qy*JIh@|re8cm?jyce`6arsrWv-5wlp2s*+=sK$|>?09%hS|3vf8XqNJ zGj$m@#@N}V!FaYa>9N~{L3K&UD};{=g*L)@mL<3)MdEayj(|-+fMPkhWk79Z;G$08 z3qTCcG=Axe_U#nydcxzhB}!sXpZKBEqV3;p5+K#mOH7CrJeW| zHw`m0aEQO#q&xNqx!sd`k&%Bq+nLa5JMzZ-nDw=Rp!s{(4rTd}^cg|9_D-2x=6v=g zWO{c8DC!hA@PtjL{%Gy}Z0o&q)M_l`?|nC#FrL>)`-TLgjvh!48A%}&j3^KQclifn z@3FT!7mrU9bV#AqzMR_?U$5CF6ImPDk7?1-^Fh;nqULt_Fc3GTc7YEQ^B%^<&g4nnm^Xj? zzp9G9CwtaoMa0ZbFMXe04j(J;sZ_FNav8?YwW~EVa}3Wjs0nR39|WKad+fcClRwsU zNd4G3ILna|DMY}`k2Xcceaw4BFNdGrpGVnmpSIRbe5JHyi)^M}o+=8(_U$%dN0Haw z<TC1S3w3C`f1AiXo8A?(I~z}N4W#dQAG zRch^TjuOM;1GNWAOBNjbp>%5}RS{ybvent2<@1l*#mJ*LeRTgMZb#3`*2vyNi%08c#-0 zG00>6tQI8NA?9uXytPNz&IuYAyfIT85B3OX*_qr->#Cl(8eZx*0VqNGBu-h{F|pbC zXm89`O*~H8{`}<{{TO<})QGJJ{y1z^vafAT(&@s^b}|yS-$e;h;+t?K;cy#z?|xZt zI1>*l@Scs6=XrJGK=TH5(0Lr6a3zhZRRHI)%9E3UxN@03aR1@}(K>ZHNw|$r&b9#k zc5gpe{OFYDlE-Nnfig)*2UwJV)y*e#=T3&jd(rkndZIrM|5~d4TH$K|PYc)ut zZBO-VNE%2Yh`A^QY{og?uOM+bX1gbI!<@lB%?DZIU+ zyb>ftKNq^0R8oVmxQ=(I!%lxjaMfFWE!I8QI%|V-!mzU*bem4%O|DFaCK+G~IL2h! zHrbd6$^F>`H6JL~08t98wS|z`Ja);$T&^lQ*q$GHrI%E~hp<9|VCK7M#P_^5ALG+e zdMwb)?HJa1wByKMu-B_tH%GRosJubqDR9J#1b=OF{S zSIhYFAd+&Zy=6FyIuu$u&v zebag38{N(;!}#992Qd<-ZtCypVt!B@SdP(MBkV+XBZ&?je^m92|3Gtz{JUY6vPA^S zrd3Ns)ZmsO$2oLyz_C2sSfPMSCH;Q(B^kJkOg06U$x!iIU*-DLa?#3X%$@86U=&SD zr0=9ZM)=ECgrHW^J_oZbgxm(6tiY`7mHs` zcx|gJ%rK>^srDx)NaB8fE`vd^NWQx~)c*aId<>=M>f0ff{Io436rmJL4M3K0^}5p7 z%*KVa+AnX1k~?7D+=hAsGUn4KqT&11>v5+iM@TFK+t=gA6CJy4Ve^kfx8~a^T=mC` z((wvSKSWC1i(`W8Qm8`|m zXV3MO8ra@95rQg7pUHG+zqzDxO0SGevKC>65^7&>FiGT#Bk@1`a^2!~fx-)oymE{* zQviIoL?4Y-*pVi#ED9iNw{}X;VkmKBLN2*OQL)){lSs>}x%FYA8s?YyFn;o~SCymT z4BqoS=`%dnAAa@v`OWp3n|yqmgq$OQZ|}p!e7c`pm^nGVrw8->lV=+(xE+1-d#htJ zb|GJ9w4uUne0fnpj#3s!WQAMRJ+RpaZM{p`o0HU3KnMeo{MD4ns?Z^_+!lQ8 zfU5!v9dt2467R9OeQXEvx{Gzor(cEMw4`WX21whgrT&CfyMQS^h1a47fOy9MVu`X+ zaN~1uiI&rKdYIzc4nPVnz}7%D5h2xI^&yIEW`H``Sk7dzf($XA(kPAU$3#Dsl=DC|E(f(?u$@oQQBcs7&C^N6f1{~IdDxeQY8YP-dnfqV^t;qpuWnb+JK{D6X=N2X_8c0S8X zmI}lCE?S$SUtz+T{3*r(@f?#kK-)Q;$7fo3P-0k=8?bwv8Kl*{d1Z#y&*fg7*fTz_ zoE~{CV@7#Znc_@58R`T2Qcehd2I`^fF&CGd4O+*SJzktzxHZ?ZIF8l)CH)eV1`ut#OzkE-p@r5^=D1N?P z-_4-DzkZRJvlgh80_-*cGPK9!5m!qj`<{g|b}*H>tlqT|wpFjDE4ZzZm-GAsF0RN0 zLE0(xAX^;LFGw<^)~1QL&mzJt+3ZCWRrFf%6ybAPmfm=#F*){1YLdqTV?zHnWUL^_7;Jo?G36N}JS{HWve% z)M@vN4k39m>E48|uMLLq!)*MWwQpn$ZULfwrM$9HIKGMOD;twI*%%F5AK3zDAyKJD zWcnPkl#tl9-pIN2SUSqpsN&7Y{bvlfuBK0GFTz=nUsRJ?BU*AG`UzR&2Q~@WP4x-+ zc>3z_OU_#^L1R%3S;#4H&nDl#N}9ySfr=m^L!I_{7;0!;5mEYl&@q$O8Edcyepu=$ zf3X+p^st@-x*hXjqgJC1jSX7^D#Uj*{hRUkeej zhi^25s~oA8%A|@xX&Yh2bho1{-Rv6(*^4{d^(oFo@#IcO6U%R1eyvG!-$W2IoVBSR z@99i3Z%(lM80FR?y{b=jt4&x(ozG#NY(lg+MW7|$oRi3di%&RhA6Aaph&X(<6=2PX zm9&b!OI~tgp$X{tUOVTd!9oZ{D-de?Zs$~CrPQWKX7;e~&kZ}tJj?RElbWL|H$`cN zmMuCHx8>zBs3CuioK#wXVs=qtbV)YiioJaE8tPWd3Bz4>F5U&X*x6u`kKXlrwd@Gg z3*0*LEvo63XZScK%*zvQGuX9Erj}F=)hzVW4H~rsliKZo?bWE!D>n4aV=(V}x>Qxz zd`lI%iWOpe(Tbne=tjftSG|Ne@6Ir$jE*~ga*cu1DH9e;zAeW$%^iLCkDZEf#h14n zE5r{Iw4JXAelydyEid9dpRII47>9jkv%FP;@6(xHc%Uk*C8A~VMNO-4MY%*p$-L^~ zic`st^WnL4>XS|hstn6Az*__0kHJ`h0yL`aqT1vN4?^i`YR;JT;C%x>sY^N|F%faP z+|J>`ErBP1Q;r(X;ZBk~QDR@ibe7{TSD>bMNR(?ca7O!@3c8+f~ zK-icj^Oyrmh_he{3YCTud1)Vh(J3aULIu@%9cu&nT{9hqHXfCv+J>Z+02|wz0VsD@ zfT29PN!~n~*UIwA79thB!%A`= zop?0ZLdCFvJaUz$!dl^A+}_8`CQ^&FHy0MNj(MDMgZGiZb28D~Eovo3@d)^+{Dt?n z)l(rrI<;$mxEeCB%GGcjU;1vLPe?W*O2su-S*<{HDpB4nany2(&RiI+`hl?yYM4<7 z6_@m26O2&2$IMNrRU{XgxgVd*OPtX(xdbVs^sv~f_kEK8xI;~HW;TQN(P_LEg`at# zCj7Z@KK-Phg9K-<*e_#W1-YW8l8kxNZITW;_F-4Ri0n zW8?XN=fXu#MaNWnF6j}vt$bKjJ7z`4ym9Gs=@8AkAXM>#ewMHxn^vwANIc>GDE-YN zN>1OLhswIdB}xK~=-c|RpE0BRqxlYRzZ`&BOi9(MGqNxs-Lqsr-ohp0za&F`sTYyHwE%P=#1GN! zdq8!^U3#jPJXvZYePNj#hrl}2oRCB9Vq9_xQarn#p-6^ewQBLR86k9D;}&Y6 zxDmJG2MTnALqo*FV@CDa| zTF*vRatV%VmYAUw+9-RhJcin;Mj7u669hJm7Bwx=rl|V_)C0-ceZJ)VePx8Ig8mpy zM5u_CqbK`vK?M?)Y~nyR1`%@~I0p_PG^7YY^DDz|+#R^Aqc2qu(nl-7zL|HxLLJ(R zHj~tc#3(!IVXg(`bl#u~NQRBRH>Ylxl^dWKo58A~^@&4CD{>=@^%M!-Bi z;ules(lK#=Fg$D!64Lf8;gv1b0etfOMa*CXqF4mT=H;F~(yvb>u`WMYD!Qg9hk`L} zX}X=Lydg!E=Gx96gFTxy95vrQfbf`={>g7>ak!eT+#c##Yi*ZF@SrgTQExx3lfySh zsUb|?mmi8g#@b($W2}bPvCYTV>gX6?<(tt=!%J~vxLn_<>ewb&nB6<2lRH zw_WA&J7eaS?$Ml)=_+yVhAK8h^REOY+qJO9CJcQJ%*i<>oy2w7g_!t)`k5(B!(F5q zW+}!*g)9>dvV*36B#7lUeR6kDYv%MfO3t*qZuX%HpB~c<$nZpVphFK7ZzQM^O=lmL zag5-Pc%-7I6{Z)q=(ME>cl(mG(W?ibZ`@T}zLQ6o7WP(o0nvN5uW0eA)ymGV=j-Pn z)%Za1^q0qTGe+J(a(k|n$p-{`GGDGy?=3GvPjO?j=8oD+4q0v6e3^Gqr4b2Qyxi0B zE}p2FHKj3Ro$aL6?V1};w)$ES(|pCA@ICXM`q1J?X>ZGX1eNlY*CDf-%*Fjzrp}+2 z_wUq^>3LTb!6NXgEA@IrpV+}$f*;$G<7KAu86hB;ZZc)Kp{+BcpTCLumUPdQK>0fB zWrF0mErlqm79`|EB!zDzPE8iXVcB;i-+S9+Ao2}!N}2AO=!8aie_P87xnCsvh@>Bb zaLEt1vwR*gG8=jOy{2EW>KU)I{prBSQ*~N*6d;#6pGG}tHH9gtBBHY04P^0nL(B_@ zG{3_g@-wu=wJFm_QO>+_c>CW@;m)T6n|%BY!pkjsqb=rA)<9AynuXftN)@YdgC70f zVt!_}YqaWs@?>k4=g&3FoaQ+aK0~adWq?A#Y0lQJE!YCN7gof=JW?l((kW7bKOGfW zuO%dHu35FpPycZ4S`W6n(X`N$Bn-hai(@@rz?8u&2_urnTC_lIS%#Zgi{Olf{a+Uiah%WnY*!6^?yrhmaNUnay z|0q1ivFo;aA+a`F@Jo#O70aa-f76s^ZIePXG}Qs*Pv;r&oB}lk+M%anXgtq|5{kh1 z3Ulk+JL8rRDCCJZ9n~#z%0C#9h#l>3&JgMsFva9*JMNpwfhDZY`0Uta$?izON$$tPdy*wxXX)Iq-jozc1LprOaL{_I!V1g(eqQ zL(Kj2H}9X{Hmx78lM+owrf-Pfi6ggbMBPcy!GV%g6oeC`fYkRW^?onoL8J{-+k$fR zROe3sU!H-4DMQfhj5&UD;|k~IyMTfl=_0Ojez&jURMK3XBt#?Py+RwoSS^0(RqlQ( z8|`J2JS_5n*6d~ZBQqV$j!vn@GF9bATX2+XEMPH%JC^f$Uxlb)T09hK`e2Ow= zvadb+=w9>Mdi?5>SH!re77yTOY5L$^`DH{g9cso#p?_WFids}rv*Sg!;*$Eq@LgFv z$wCSPY{Y51V$jRQjvLLlf~%qmD&531?kgBwYK@Csw2H&COLc)C&Ujx2w$AHdS#eyA zPI|kUE!G~2cwN1%;sm`C_}VnMUBEpbvSoZ)Gh;?OXFDai@kF2~vA z5L2eEqu5cm*eMv@CAi+*r~V*EtUb=dEKtH}K|RCL9U&!N^dJbrP%9Ka(X^f$lT8aH z_q=zti)bA#j&7Jn3o)JuzR*~%eJ8oAmN{Z5F97Ie@fakpFj??f2*<||h<0zI`6Z#= z>8{=RtmJg=hq&$e1w)6@k%B~U)nv6IHrT$s(i8A8+;?W(>>2hqVVr~_dsW$>>&%It z3Cga?n$&=%K}JKF4&ww%pVRJ{p1zj5*57O05nTNkW zUS2=@l=_L=-?yyp#U)&>i=iK0N>qf_hU2bH#d|)!%(R@-_gpi)&uGhd%5?cyw8tK{tAlDqa+n|)yC zY~)QU-G=7f!wSv>5 z45QgMd+ja+Fs46NxpS=?qYAWfAkY@Zs%!_XKF{id@kawkPgRldofU{=Gzr)bmqnO*ePeefp1@6_B>}OsDXWT z=5&Xnbw@70;-~V&l)o}N5dw8wfzqi@in-nXb%)I&3Ge;dz0rrvP4?UrOH5z!pGRJ} zbhVLz_Ucra{d8-XTTiQ_=^(0dzsCEVM0u3?SoHU#K(hla7`-PNUwHc!>8RuPw{(Fe zh^@E-i1ho#ci%pszc2lJf@0IbwO}#tU8eJAHr~kiT}4P`8oGqcD*Vw5%%f2lm#VL~ zV_b|&QBR!dKzb;qh?2OBi>uw6;w~EMeu$<}(r1tsa9$g)vFOb}Jdd=S|C}Wii1g3& zWl3ol-D;Yx1?$l*N{o;gt>S)LFzQUTgl@-{g0!*&GOqjfoIT0FpAoDe>lKmph`K)p zi$n^veI*W&KzsVXLsT*XhmcHDx{24mRn2xLU2xY0GHbg733)6MPKtxxxVs--{<)N$ z9zX$eLP3=AX}cIsU!6Lj?_VeQ-c6Lj~F=F{!sW#G0((B<{~F8+j961-}C5uMJ;r>qU_X*nngKcRdI03MyNoM7d37B)c;$xDsYH5ub>kERxu3-T8A6KB3?5N^aUv6Z(;O zObs=S`+$mo3isV!wPCOSH7F>U6!ANhGoz~pShb5*Dh5;;z8H-C)!4EI@$b}o2`z1b zzwgDu0Wh>}B1c4M|JyDE_m?Q>-?f9(+x5V;0e&Oh>66NTT$PDe&~A{+zNiPc%CT^* z+vfB=pM&!l%Lj-*UcuiR{P%oyTtG`XPy>ov1!S)eF;u=QW$pK6)*Z`m>-aeiG=APS zTo6Yeld0VxzSDwq1%e`=oWk=1On)382SBliv~%eL=5b#B%28VUXF{~mNv}V+q2SmY zMd>`K;qJ0B6-vdJiNfJ-$YGv>q!C}Nusw7|?!8$8)O@S_Mg7Bik=vjmi{W>4iJyFA zD*-ErJ#5i>aH;JM(!6|vf^O(Gbv}{HZE3$k|BRK2!R8pi^7|^8 z;@*Ji@Rdh9Yd4xN_LsV@3~$1gI9=o zDj#Gm5ZLVDb_LZW)~aXV1U(V+u4H1t6x7Q1n_>79`a22iQaN=`D>-4x(2#47^aIV7 z%xwBbi;~blX{W|>KTl>7PTc_nlQ6F_P*gU&a|Y$e9u1bFtBA1pzmaRSdRP2Sqn!>FVMgWObzEnCb`}G^qO&tIN?&{DbZ@%1^13(u zHY(vBuN7VKs$ti!jy1epoV?Ue*5=`*gZ?_SN8-#Ytw;x~X_rMOx&R~H-`|*8jNAiM z)I=V?;fgeVVI8KsjD}&NE_g`mj_-ed*`FiNq~qKx_R|j0rhfbxj8c#D(LAKj1i-em zXE(X(-4t;CPgZbviXu^ttU5r#Oyz&{kxNL}38&d~!}Qa8P5t@}vW#-OH(rxHgStL} zD@X??%r6frY{>efXakTxS&$o|oa=qA@Pqf}@hH(wU|@2h9j~KvG3 zB5!*D#=4r@eo|-jx<^~TDUs`}VM5R3DL-WYqSu~e`3E|ff+1FqudJ>&|qud zB6t@-g3DfF4w9KvU2{Seyj;w3D?I`r7t*EoeRC0Ag0d>NMW zEQnAws}f-I63E&ReZP%-!4FG83g)q&&@sdOhXiqaIFdLGK5p4Ya8y3_`RtgY%pdbD zMfllzw08nBtof5OP4cDB+(QWky*7~uxuF=oD2;I>p!a#?pq~Mo)u)e3AxuQK$*t_f zwsI)X7M1T)@g3YTtw~4|bc5G{LoNC^I9Ktbz;v0d+yK&IK#|yN&L=s+_)f0=_pY|e zboO(e;1rSPfvhH0hm~K@U-E8=b^Iv492|RcJW!~liF*s*UX`bCzSTiL3_dgiZl(OH zTFS@t7Tu}AB%V$WenSm3RHEhD+C;fMLc6@4Q%^J8Us;RV0_e2)l~=Bl5LLqnJGv&6 z!d!=N5=qc3UxeKZlbXLfW35vvX$gV)LK5Ez_gjqBVtO~{+c!L#Fr zcuakaq-(uFeU=xZhrmp9scR;s)9RzmyTv@U4K01;Z z_rv0_jvW6JG0(jlC=U;-dD7F622C(TMI~KF<|AqW1b9ktj_2|zMSP5wzPwBVc+sZV znDS20YV7In;6qIZ(T8M86PZgS`w4)EsStg|!GT7|s4N*5b!{JbMGvT<>62)zDef;? z8+tCjNZLR^flR>ubyGI_(-wFS<+iFH&_o?Mv+9GImzb#<@aAeQp&)U?JahLpI{|sK zku#-1o2K}Uu86d{K7sF8Mi0a2QWd&>XXaMKB-h$A+xoD1`h5F$FZ626*cqP8C>vdsG2 zula4p1rt-nJYCLCPh^C>Gyz6aC`~`_mCs z-#wPI0&!@mPEKLHOMwTF51E&exdnl__*+f@9`U>r5K5Sm> zB~zJ}AXGS)uXoHNZ|=Ff6s%RUz;_cbg)a4WwlbkR0cBZ*203nzz%CweDdX*CkH1G^>L!%~sDMoS3zC#W zv_VZ~uj;Kw-aueAZSIm5Yoh4{c?zJsAL~=YvSbVKmI#6G(gdm7d}?T52kw!<6$>&K zzWG2phOr}(^g33pJh}aifUoC_9r{AR3NN%Uz^3y$(${f~CDnC}r5&0O{Is0wUjyO5 z^(Fg3m|jw7NPsv+uweLB7$H+V6c9wSN(uq%mna?rPKcd7`j8aG#jBwhC@v=GiEvp) zeA~PAYo}nT`1KK6-<@8)?|=OgrD*a2aW6NAUL_b<4E~7V6Q_81?^bwRn~fmH-3OMhH)s42qF3ta z30T{`k&@}%7dJ0gylkiP6~z%Y^xBlIc)|Q$h6Z;Mtqnb67yGDvSNAAcup1Z)=t%fVhpWq$$%kl}ALT2jNWZn*)_p18 zJw+L0nX*#WOvcVxsoMple`Mu%_j!u1dm7q|TXY*>1aeSwKk%>8FaG2T_S9;#()|zK z?xMW8f!KnSM$<{$9P`u|V{$fx>k_46^A87#b$Ks!A%sbZqx0?kqStpPebTG*n5WAu zkm#Xi7koPQv9pT7Lki&FcwW^JNhLvfw&LZR?a}Ouv2L{Q@F1Jg_!a#0` zDcf^A2LdT+fCEzmrl-VQ{l_=Hw&{I=ZNL|wdzX@6l`ZwaU#_$C}RvEVDKAaxmJt+QF5Rq>g*n_EJ>8 zmE2*s$Xa#ak83sp89s-L1F9k%9Xb7Si@FUy-b52p`C&a2=0DF9>u*4o3^|!$))Qh7 zb6&dT<*0@C-t_4YDD=hN=?LQ$ErD&AbPr6TSxHQx&f*zzeLaq8&R=zmP?kZ~AUgE|+rCAHdx$HC zQ_v6z~ou7Zm9jIBN?(VgYQUKuwHU`kZNvF`DBZOT}1Mr80JHKYMH! zxUED-E@?b2Q^m``IsNqQC4W4J9)oImO_-a<}#I!2sGD@2D zLwCew*VzTB@6bA+aMb`&A~syySdY4<+A;qXZ-cPbZKz4lqoWl^%yKyAO+l^wL9@OD24)eGXNNvX|k)m zzp60me9SBcT#SJqXKIY6=P_;hMwIy-W)GCiNTP4zIIQ+5(R&tOibxx789~L6}1tg*w9?TF2!ReOP36yp|Z@ z+Tw(ThECLWGhK(Vuf-o1Ukd*_G{kItZ@C`~eL7?>H@x>oFqlwQM3Hs*=9d<+DE%g6 zA4ZbC17dE8h=%Y)P}tsSZ7|R^i)b}FPhMATdB4xX6Mcd?jbQS1C~ZL-VcOoLWEMdA z{p~5%*KRUQGf!ry7^}&eg()YlC%+sB1?-09EzG*l>|j;<_?Omeo`JGX+OjMs_fb#N zWg9u_Wz#{yr%ldR4QKWhV}_{SsxoE_jQ9u1lvYcMcCYl}J8Z07Zb+Q5vRDGzUhsFN z*>NAx#E6&c$}>3BA&A6UZx5Qu={td%_Nzjth8ykOcf}0#Fm*d$#F7xu@Dk);p4a+B zt<1t#+EgbZx3;gQR>0J;L85*6x>NvzaK!e)<9j?&_b8g*1uP_+MmI&Np?n7R(a-^p z^(pb?YIgagDApkA3ZRSxp>m|#*oaWD@PnX83pEp@T)F6wdagauSoPzI(cg#O2pZ49OyL9 zp5eDwqLG~;5@q9Eu6aG(M{B>y4q!N-l)7Bisfu*#0jaVwaIF^E#{zIPjO*6dvd$ZK z-d-^MKC85x5va(6b$c+8Ceo}RRy`I_phAJ@oonE~q-oL=(q*S0xp}^9BgH}tT&9G@ zpA+6zAb1ut_6{eea@jJ?9ags0*7`@z$PouCte9jM3ASeP3nqo8g{<=WD`&Dp=~Ud) z_8oJte7Gke|A3?f*^30kjWx$zs&n(Z{C6FW-{Dc6f8{ZVJ{FSAcNET9x1(8!RfZqV z&2+G8Xrgs%gHc}UyKhOtO#t@UXA);K$`padn!gh98$U21t}ueyY?KU^aQawO|H$3N z;i>8H#LLzh;r)FM85e3voLGG|R+4lt&shE_Yp48lk9GMq(GOO!S+c=e1wX)ICV>AnLGC_1~W4y6!w1aaX;FaGai|L=sA0%L57c=|>RjUW@{ zOWjnPw_k1-);*xFs^S!y`4H72aXpGamq?&+v#9h+jCEIz*VnFGI+?B54y{Y;!_RMF zr4$r7nPrOy1-;xsr~cihC22!&r@|8dd)fXmFg06V*Lr5jnBh zYiLvmJWo8fOKVP#R;Hnm{qF+&-^PXq{EN6*N9?5k4(LY87OQuI9s@)2LrYaCxNQUc zm-Q_;BJ_ii0*=yQoVsu5*8p%Cdu+I=GH!C@OcUvrnY2M1C3c`q0hx#AEqF)wbX)-M$opu)Ec+$DA-k5wmo|oSkad=wZ zD?SGFg?p?5&D@`2A6cM5&Or`Z@&VfUF~_z~ro(NEjsEA9ZlnPJ`7B2nDf7)3U@bz5 zPi$=sBb-TMR8DTRswQI4{4{w+S|^0cI`h)IIo1c%Bp?|OANIkpD1D}?im66=_eO>$ z`yd3E2~Yt3G@Q}<2_HeF;t9P3VPAlYFlAIBP%hp#qUmmuyEeCs)^cBx2N+wP6JV@b z4)oseq*wO=1sTT6#L9y6wUdeQf&JRch@VXr{~G%5yGX=FOIaMyU7KE|7Zhcw+$8h{ zn4c<_Lb=kO@a+D80NX5he`VM7#Sa&9Kq15TT>&_Svn5#2R6#EPQXfkQ6N-dx=DxAF z4~ZRy&-s9lwFl&?g}&!Bp|axNdlk!xaD}{FR)*u$wrWUuAj%tG@2`|M`HlKv3jI)M z(xIAs3yxM^oqG81>_&8&U^a98eD|}^)Fa5Q;#(bbDGL`{zd$1wKAjx&@V$UBA$y-n zhVkcqg0s#F=>>*th|nH|@RxxXI~xZS^#yc4mQtVpV}r3^n$4}Be{W42@W%W-W|a~k z#+7b?iQI@Ah?#@|=#cwu{|yE2WF~YgLK9l^>!>#FgMN*CRf?w{rxIWATwJ0Q^H>45 zE%QW~6q%R@U(&RXm3p<7J6z1|D`RB{v|pKNyNN06#40WAf>1T`*7A(?!NLKylG*@{ zW9U)3b3o-fZch|u)+02ZcFpudA5{g)@PKjUSr>2bq*g|-H@)c}BA?n{66KSVSIxM? zCl17vTJ_CH`@EH}uk;8v(!>mFk5#cBogN=xPQHvvT0q2i%>fB;L#vnm6p9*r7jx(! zshywuaR`c+s;J|HI}_ga?_%JeJ&bHgfnabl`mm%eMu=?~$PeCur&|f~Dbde$ot#G{ zqnAUIb;(tmuK+cfJ$L=xGG|(?sl#_j=}_qMLu;F1E)XVsL**7~ z6;mJqH{E*F=!fcdFX~h2pf7A{jc(r0KsLoWi|;xuem*ZNNgwha*{n&~En=f5)%3Xz z+``NQ;D4s)<{K966IhG&EJM*~Cd!!qWMc*WVk6k*Y7H37N~3m2T?ojt7O zQ$U({Y`dVNOaLW!b6pb5?>AXc(MSo!Q76>bXt`dv_Ssm__@BcSjon~gXn0Jb`tz~A zbP&PbyD5wd--L0;Td9o?MV1P3-jr^48cer82QK1b0{ktlr+%8)>EpH4Z=&iSpNOA1RaSedUkW&&wj$dXGRX7Be1)odJ8g)54x)O!7R*a z6!mwh%xF@N;ZoxSCN;(@O3cl90?M5rA3eE;J@V8SO53*}D*=19GS+h7S>y2I&8yHo zc{KAFl%6~G+s)5}5*MK+q{Hnpw~dTefT<;bcCHBYoF@v@TJVaOAHzg47LQNyNPm`5@*N(bIp2GX|Yw_258; zsB6`i)dXw`vh9MYqj;teO#SGV(4`BZu_Hn9{-Ki>wJ5aHKqN7C=_(igKbpD-n4{2# zmAs*G<%4H*yqK`L#Mghd(W3M!0U~2iy$R-tFVKdV51TOVPJ_=0$SfM~T^>mX;HTJu z`inu~glEG1l`aMNggOXCAXth5XO38ldZyLZOX}GJRDbScBedYr_NnS8b)q&I&5R}s5GlZQV zw1i-H*1*=+;L`U!+0D=JU=80_f0Fr!Fj%37LsAD^uJKjEGodXq0$w&kGasFQ8r-w2 z388J$hUUV;whT>{?v39gRJTT_^xBXbeFrA50G{+6WP|wFqpIhIOvVXvlc*Mm#&?$A*{1B!o;v?lZVIj&tqbg?D zm`G|_q`I-Ku&vOb3QfenF)Fhu`e(1Z3V6Il79>Zcf)Kr66EHiY+r>mrAAyyi9+ax@ zpi#J;>Acog>;YkeyoyS`?0dFs1oBY^fw6dlrN+jHHYnhi((fy|nOcixyG?$5+2LwzA1MXFt5DCSVgJm39O zRb-XO06Q!;@L3W(w;^688`M=`M4a{6;}Izxu?hv@v_zMZrp*;|e#~R0G#qa!HEaJ( z^li$>jQ+eXqQ;m1_hVBaViwZjRq3-ir&qQ7nVNBWKSql5eW1Pl4Ly6(grG$T*~u0o zIQ4=#gLVU_k`nrYEL)JIuElm7ojh1p zT;RAjJ@Qc84RHdTT%~foi59O0$ zVf?+echt}^1#Wf&>pR1noRBl9YRM*O6c*X72*nzMd${awt6rh{bF?dK{rC-LcmM3e zWAt#DOvB@xH?qHLn1A3Aa<;w(H$tnUqJ4AJRt=O{)}>7sI#0U^)1J><_6D_f-#Dh~ z8GFl@pdx1JCiMKJ&6|HKls7?On_#cOcaiyXApcZI@aeI#=&->IJhT8li?eALRlvSN z+UVT;!rtwJQ9vz~&kAbA?;s<{+{b*!57rljkXGoV+>e|tdqGFYW%Fm~vD!#S%a_$s z-ro!Rudkr?pJwFt+*?5^S>IWOpoxYd9~5tz2FQl=8~3A0#bz&22sI3i`o1e==Tn_VF$d+Od#6cJmgNGpmUfTRp>f#HGY&DSs9zObAHh=W$m(b;6D8=k18)a~Wa^YRW&w8N^4Qe(B%Tk`I_ZJ!g?*k=s zHE{;cNXv&~cv^)Alub)tUwMKrK~}67>@+1%!-ETP$j}r2?lA<<7x!Ni3=+9sle!dq zRBGG;%$006@S^vjeXTv3B4PxR%>-lSOMa{Qm}7oWXB*AR>%IjR#h#7#?`q(Nf%{Fo;q)R~n$B zE94SMK&LzGKX8hS!82~EFLl?!gdr6-shF?zP=uYka0&dAywJq(XYNK6C4n#ePVC9> zKbG(JOJ+v2;b)FS!*}4>M(P2MxldcqT>|Q*wk(E9Li}yyU$c&`g5=qvuAA1%A4LL* z_y2tn;G~#QfVcc#&Yxe^`hQ+TgS_bfpHK3i?#l<5JE1G+@cy^e0YVW!;l=^YKdSD( zZaDTC`bP`C73c!6ffEK=;xG^f16=zc%e^+N6ZUrhCw#|3IQh>WVhkxl`v#pD)s61dFQvq-RH`@yI8tL+&yho}Ze@4+r zcnDif0vP7Qlx8+z*p*a||M!@XdN(qsLe|WYkEsW1CA3W60t8ELG5?!$h7VH+ZG?Vb z`N*sOv*Jf9j_`D;@^7!a4!SZVKmUJZeRm+%?f-qm6Iu!lNm3$0iezgcTlUr<5t-R5 z8dS6-du3;oRd%J6y=O|dX00=>s%L3EK4T-y|9gU z%I5dn>9Ba%@~uAh7<5ptB!0B_KC9vr8;bFTYETW64Shz$Z1elj0rKLw<4bjI{f@vf z7qFVn0qxK15}0PQ+MGnRtt#a=575e?a%@VIZv4E>j8VOfhdw)D{6^oOnV?g z`O*H*^~cuZmPhu4Uu?uAFX~ny-G78Lp=X*Wl947cdwpl;v3nBjkDC(u!fHtBvpzN- zbwJsWEDl3TNBI5tzIOV%n#nI!)r{txSv2d89|ZZlEa6+cb8Mae;Q6rehWJ%8F-3$* z&%f_Wx#WqJOGpK|7dtlc&cP;)SRH%S6HSJ%iM7hHCM+QELQCg#@e^UT!5Qst2Z<&I z5o@|}FTa6o92P+$NE-QoQ!KhBb*UM?7*a(xOxqON`~oF4YzW@qEe34*5y59%;*N|5;Rxda;5k+wi{>O|KODPC zs78~yBl)OLk~8T>lc}xi*PPC769x{i_)2qTm{v!ev|S#vdCzT5YsN(Vvs9)3psxWB zrQgmwJm~B$bW-`@bu=He*EL_poH0WBgFMvqm8)}v@t&YxT*QRwqwEuB9z&C8G`Y(I zz9y3IG#x%22~}pc`g!{L_G|v4RX>n9qask^j{t;1UUO1Synl@Hs@S)GqChhtf8X_m zXX3~2D;XXP_o?n|ImM(z<>4A;825x^;vr2*Z*uR1HXj{%rv%(7%f!;ng|Ck)|FN)Nj(PrfNFw* z;^9?+Q7UnnFTIR}5iM7(e%6klOXqfVKR(H@rVcYj&mn11vzY9Azw@Bc%7k|n z2iF*`J>dRDaLcLxv4^R2Y~y1azY zKzYC4EMo?ebeym~gd}jI9ibB`>AA96P}|rkua(qM$Q@zJc76;`t2n+VuLxBdz*F%T zMl6x{{cle7DO{~O;-d8O`TA*9m3Oq-w%hWda73be$fM2oQQqL%f_R#|>@|xNcYu~J zvz4M|jz_N1?(JqPhR58O5E76u?VX^{)G+QeP ze<9y>qBOIQiMd1N7F2m12z=~0jy%Ud?PFiggIxECtkK(j^?4*$je1tHYV0crP}FJq zdN}sYtIc0uxkE@qu52d>>}Qj{!*p>d5D9Bn;9+*kSCZL~(5zlv$W`%ItGjhWG7MR) zl`*7z8*5}CH7%p_%uQZ)`xz+KRP1MNnQ)Wb2j!HA^_4gHmH8KHx~Vh=RPp5t)lE!_ zo+nNXo^86lB5a6Cz&+^>Lq_5*23k%Hrc6$$t& zfn379koInS53#T0@Z1BtvRI0ZGM1*!*Ql%PuM<3vQZfPdaE@;d2r5 z#?IcRFMnejyq%g-$WGf{RS3_qQ|BQ^Q7dOy9l3oRA`t(8dUgTBaijw95Y(qxO^D0^ zC9*qQMDO0BT@DP=9lIvbZW9_;v9DPx^G0q3iiCu8=oFpSIb>4ijre$l)ZDU_dPoVqVY_oNt zD9>pb_DsCDoV&C2OoDR6VFBGe2JBKQTchM&JrieC%vSiWMQ+-Va){w@AS6JMZYT{A z!`L9AHkwCZ?xxRgKQC>_J(Z69K$l-cPKIk*)JZ~3f`%lr=QAN0r6}`VfP{MJWr-hGJ8EUZ@}zsArkZc zOSv6>vfkbK zkeTi~20f39?A=II0n_Z_H-tdHg7yGNa@I{ur0ADyOJJ*Hb41yVnGdTsTEiImD8V==GB z8xvOldfpK(V{WI|1qDvvZ4pVG%%aK4WG1Z!8ud*+grFDD>BV_7^=+L~a*1dy&Fg3z zuT*b6A8|zTfYjnNL-gmJyX1)O989GMsOGybZ&#zrTY!%HJG;;AJ~ho@hrJsQk;=$- zwv=0v;`z?I)&#B8h9lO&qgj2NswSS!KWL&loNMY%-N2NXoQ^UZ`4WU=b^V3B?d7V8L0bgaxnCK5GcRj1H-&+>uA$yzqbpq*I8c$hL@db_u7zgpJmNk6pscpSArJoAM@Ra-oz4R z_n5v5f_%Q;gK|4xC+zwy0{(fXAHqC&-uucSr`mcnNzo~gzTF}%J&Uelfo0dU`1NY7 z{o=*raN)oVaisqzkwek3&(8Dct9CHh-DaM%%@&5zmbmhFeKOa8Q z&vw^r_EqbI;8iAa3(BTK6sgw(U`a;rJ1R>Ait_ti zgiZ_~FJC>@j+`2&B%Ky@^uP?qm#`n>-D3X%1WtI5>FNZVYKDBhMOq!3X%7Dycx8C` z7Adpb+g+cc&bd(VrGS^tjCJPP^G`r~y3sQPNJ_jc)Z2aEn9@|#=uEWp7utmv-N4fC z>SQUZVh%`HUb?+~S+4y|Ddx_PVQ1H^SRX|3 zgsNm^GB89m3j7q}bNjlASR%>Pd+0`!V_h)imANwkN6HnYmYCv+xdvT>CosXE62Bp8 z65+5rDYImxnivgm9a?5_kWV(-H@EGjsvCZJ)3qpVZcbD7nxPYm?P)R~xsP){MimfY z(bRQn8`z$RGAu?OCF~&5G72iocDhUoU|^l-kW~*8t4E;Q;#0{Y!l;5rexh-Jv)StvPh~2)MQEcqDnt0aUQ5Z& zuMB!HTvSz&C$Y0}@4SQf`;8wXo`3P9U*B?XPm*#d|j#Xc3C5ZOC$*C(#=O5x1$l|YSLY_hJNs3=J7#$rjOn%8q7sX0tAzP=5~DF3 zMOerJh<7zl!|vC4*>pyO_kx{GHfukbcYa_&*;^5m^t(N$UpN2{D*VJe!d^tQ_uTR# zfD6wRX|o^0zyW@tUF3!#7e*ek-iOUt6rH@R-^(_G_4Q?~NLBAo4$^DF^uj(0!Jd~p zR=YJeBn?(jRP+yT37Q>lI$wTc13Kd)yS#t_zOg)(RG)SFm>nF5Cc)mGrL418fbSaO zw-4)`WKnoAy$jNog1^L`VmBF@r7&vW3bUK=jVFGVp$ey#6>D8HJFek^tiP~$f$ z+G6S&R=2+S?f8uNdZRJf^oy7Pz#bVyWPM%bsW{bsRvRmKD3x4vUHHQaE#e`fCA0Kg z`FSH{Isbc=vcF4>?oa=9sZ@lpNic)0IK=n}-r zy&4iK6n`l*xjk8Eey1+a5ka+t(eCZ7t-e*c|Ey*1<1{HD@A<22fVVs#CjY$n1Bo=m zSb^YHo|#rnsD%AzJ@qkDL}X-@uKmtH%tQ|HI|FSWh1v{W z`)x0uyf5${ys91-bDh=ohLBfRo)xD`6wB@HK^f4cmm^0ybzb0nFsyHttLgCmZwrz_ z+hgU34y@wlJTWr$hDlp7OwfO}iDfq&VJDGE>Gku`o2H}xPZ|BbY?%J1lLPVua%uG; zODRx+Pdxp{bIoeP>f0fn7a@QGY_yvzv=5H>y&~^2XaZ1P!N0K#oj&Z?9-GL_qAs2+ z+mA(u*n#08OTRU|T~y>43ujgOg0CRh2vijb@%@1XU{mGx%&Ytf*Kb85ZDKCr;s&1M zh1H#pDs54?i+%rst6u%X9+1h~HlEXE7)jD&_p6|m>pf?NIq{#NJJ&d4qO4AIbRYXu zj(0YqFcc=#T|;}EohzooyPEH92wlc!I!>}>hdN@E(`1Wc*On05{Q|_?P+!(=nFQrH z%>3mq+Ly~#vD{K`b`Jd8TiV@T5#DaI)d^W?J4W)J{6;-XXtN>n*=#zGJREK*$db9d zoJ_iD_uocjyiQD3z?rDmQ#VvcK;irVN0IJMz0+3+6Nt}$2xwZn=0$u_^-O}01@`Te>RU>N^di@HVqBp4868?TilY& z==N{B1Kb`AKR4y9c7FG*HGSK|uf&!|I)dcvMc@Gf&U-ml4J7=y8EE4m;8YmFW%Qk9 z^W7Cz#aFndw`drcEu8)oM|KyDpxgNX4{;gB@&!^cd5)79uqv9atvyYi<%UlJwGY&0 zS?OaKbnC!o)wPR1#n03Y{lg!W*=HYTS$XBt@yM@jnV(S6UrXFJ=2CRX$SR(%si(VoI5SZ3_0a!9K?p%hQn19#5{vXU~7ceI`)TTB&yRe6oJ;_gcAHg+8 zwl(V>{^ws-L2JWdkoW4-tAtZ@>OLROAku=s0a|_Lf0drnwi~5y2TDfLhDj&VjzK*c za`@6qPZp{D4IGUq<6lm=!&&wv7i&e<&V%=M9@#tjeT{F>X62Jp{!LpX<5zB-9)5?h z-ABu*&}W(PwmJKJBsGmj?+e`vo9TTda2iZ9281>|=-u+HzhlbApW0qWc^88n{PO*) z6iKzE1!ei~;~m8g!>0^#`}X?J28&NI9H8A6$hS>}9}CX@VLkVbD2Atp zoxR?0d<9)gp!CjbV>Oa@mZokz_vMWeDWN?JkhUFavb{f#Ix9`3E_K^q>UlJ1MVjq{ z-qB1?OERXuf|4uKHfIt+ktnU^C{D`&__p%Y?w!K~=`QX|lo5N(U;9>r4m?R@lke=0 zJ1)OH|2!;Ead(8OW>J*i9@S}kKgEkP8#7$tghQaUTxOJSVpaGDc6LONBRq$~ieJmslxIQItu@V%!JvckZK?^AL)gCZ++glAH}*7Km3?-z$d{_Dc55zTsQlf0h&l z?L{_9a&#wC>G&J@%@huw@Qb_@M+tBaXM6N~rLN$2VWkdSz4G#vgfVCV0Nqxp9Da0v z)#~Zziu<>$hp9~L#i%piUBArSfBR4L<<~y(Km5|j!qjLXW^B3f@_e;^}S zzw7B8hN5T&{~QE!@IeIF)T?~<%K^sYKLNY4*<;Z0U3L`L!QI>o_PfvheUN+M5~2`L zG)AbaajSNLWnN2DJKT$&`R&P~^UZ|h6mkJ#a2xib+=DuYO_rZeS-}Rxza2#F37$4X zU+6dQ?UDkd8rm7fNKvNXS-fdu0O0_cVyHf0avjP7nR>P9fg_IuML-O5Y`O4`@Nn^u zkueje;igb{!Q9Vw5WElus#syvq85MTnu1y&xr(@qxxc8$**8WcY^IWt;%l=tz#$+J z&$dPSP#b6HwA^M9nM9HWt6`6U<1g1Pnp#Y(BbilXvC>TFG4I%$+}u_?4t)bas(rCh z03SAch?Z!vr#pdhdX!0!s9=oP6q&nzpdXOr?jmN0i&N<_D6c=~kXGxe=s^ur3DQPY$pAce9fz+|MR)~AEyRN+na?7B1> z1gj(noicsL4gXnZajlQ<=znq^)dkVOT0j+3JIuG#lFC&KOy z2JFZMWZ||fsY}3q6$C3Ue;fS-BVhY0=Q$;C;l$NXBvZ3`yQvqeGTt&zU$&wtSz^(kxJW%%^W38cZ!{7c`&?}MO1nOX(?XREjaZ+$mZ)`MD| zq${GYqBe5rXX-X+TQiNzhm+aw44tQ6MtAZnJ*m30)A!ym_u3?HV90OnxRjS~-DiS! zNZu8N92(v0-+JBYNEfiMndYMBJYj~0_70t8?YPOekHyd_)OTUdl|$}M59k*}<}|~? zJWuYfEzJL30?~%-(|z`6qYc>nmWvKPkC?Q+ytz4x$-TWTtX2MpJ(lnB)y|mcJ}`41 z0WCZBzFli*G{g01>+v*qbi=X|+^G)_+kUaRRV_<;DrITXCUeusf6DQg0C*E91b#hN zD3cAbmQUDn)k&P>LDM^@&R*zbRY4L-L7`!c5l!2<)jfF{?<=g@+AHk1Ap&00*2^;^nl-LfP@%o@ zqqXmQ7kgm5Ez{WPpXBt)bmSFoovfHBCqnH3fWg4`(N6%orexnL_SjY$vT_OxzmgICo+o*0D~`b@}<-=%?hKcxFGS{E4Q}>6_N0Z(H)ndyq*r zr3HU>d8GLyhtADSyv(@A!uNvJ_xqvMDo%^eC$F3hK4p``@`=VT?A}N!<$3(NJXVMo zUAwD(Wp~)?@vr{QgvP1$bq}Z&eJD2tGkGx(#=Telxy@$`)^@DPIAvSuowol2Bmpgu zXK;L&QFQ_Ta_Lja*oVSr69tZ$<@rk^js7u0VD+Yb^@(8d;1+Rob_&=R zaSj~vtH@cw4bu7aI30_Sz^t8t>;xoq>gQXrxBtW~;>i=Dvwn_aBh$AYe2m~TTpVX#P!$v!t|s2-|l=jpv4LJAKvSdkqO>RV!lZJ0-2P(5IUP%8zX4@0{H|Bc^jVvG> zPDIMzdYYO-Ru4|)8_PGVzFJ(En*#17D3CfGx2jQdl?;A>S@m_Q z)#cNob2mvZYiae6+Chmg176~HmtRu39fhW-X7T17PJ{wEK02Sfp1CTP9GKX(*|OXV z4phO7;z?zqWp@J-hP1YILX>@frD@PfCJn)}hW2GvD&^ZC9(}yA6{r8HUUd-bgnl3+ zFJ)EI72bk_nSO=VwOuQ6TjD2&ehlXUtPEG)7XKDSab_ueo&+KT)Z&3$45ZJEswiSa zN9g(XiaA?yAn(za=IC}974?{(E_vM^K)MYr>C$q|o0?O_7lYDb2lF(;32&MV&yS>? zCViZWlE)#6OO3rnQG14o=}?2AwbI76dr_BX3xqTL?D8&l7|fY(qc6EunV#&Y0F9!C z)c~e~TA5=Z1oM5kSNpx4dg>cSY3ZAH7<5G1m>2c&r`%q?_(5p_mNb8t+ zY>pxRv=EQ6V+)|*Z@Q93Q^}Qk?@Hub%k4IwGq0GW`ISluQj?)02d9f&h?B-Ytmi&ww!A!<66gv z%B{7R*Y>k;=4_eaQFOd8oz4N2)pgYU_7;INfOTqFNzc6eB83OXBOILO`8Is8Ub9H^ zl1Bsqzrw(CXqUGVDMy=_qQ2Vu!cu7WW6N8Mj0s-%T7H1$nm=_|H;abPf-b2u3Jp<3E0>P5m%zQmA8U8v?az03*oOj6#5fSTea=eOhKx;(_-P+ZEa-dHIyDHu~{?H`B%?Uz`LlCQr<49XZUKEZaYPWo20KDL)r+W|yX4P#r?C<+a_o7p zpRUu%VUjvHLuIhy;JJ4(b|Yw?3OyGK(n?;ewcm8CO~OTD_;TO74c*C34^^Gs4*4=$ z)I~{*I1DsNoMpTqI#we1A_!GJZ^o(EqgT=0rDP+zU*_V~NzEHmtA2HLH8Q#JvvVi4ZygF{ zZBo4z$)D-T+mtHNx#gGc-;%6k^enQwLwZo_oWs&EDVQ_+eBGIu+_Bkhxi)5dxVoHn zXd&3cx+OJuASun&pLIu5Kn(Zo5%*!)%#SkTD#qPZc==nE9!!?Zq*0&LG`up?Cbpk3#2Z_=f>*i)?Kph(lX02gY7MixsSl2 z;tvl|uxviB+$*w}NTn6!MwF}tn?xG;^-Z7Rpw@Ioduca1CmRqzNlM{)X!>DEma12< ziKh$o2x3L&oBb3V;6p7{yamZ32!*8bDiRjMce#`XYt##b}p-tz(8F z9_Mdua_q>p47vBK^06gXa3sp>fbj*fv*x>&yDQStCPhZ#&&qfElno?7HZFCc@v+%r zU9yI%+GZvmr&gZX4}~K+Lmvx`qQWl*ZT!UjHZ^4IduQ^7$;pEEms3Y>ww}zsbm4E! zO!`QA4a&{@Mv4z^Xg>BG`Q+#OXQkfwLFrSYQWq&UiwKf)#yLmolJfs{P1u=_cw)Ai z=q>CcGVLs*$id}@$?7X@GS#HH`o#Tm&2|06_|2MCReYod^Y3TeNT=i$P7RUxh4+T? zv8p6@Ew9_ z?`M4o$hoUB&sRiVvMRO0rn&k?F8v=;tHOQ)HOD+QG~@=Uqqf-X#1U7&VS9zc4X0dL zh!ZzERX9mS=D$zM?^q00n}}Us{VJiA#dz&{CoZDQkAh`D77mcx%%v*Lp)Zl&=u3KA z8#d7F`@_CS@Fy+~e_1^Fu4=S|>6m19+L}o6V?W{pgz;im-WXJfzp=o^KNjOuO9Kxn z+dvUQTI{<@%uVqZD$hNuKELoMp#^bQz0LB5P1kA8O=H|0^{luai^wYfKVK_-i}==z zU`0hqiMJ6_oL5K})1VOJ^?M2P3ktYv&}J<(V?DvfHl}sziHckG_Af49&~Y&z9Iz@= zcy_>>6uT&h$LalxeYiKhqO8htnk_q;zsEH0zYfxvYjV^_?r)Omt_-pNC^s)kyq{C`{1a zQCT0oE&3?wO?y%2aEDoBM~FIBR;;ocsCGfh{x$op>k;~Z&dZ0H(GquKPhC!b|5g4a zH12{dz7rdkSIZTH9+Zc_K^734JrrxM5*oo59gU2N-_|cb#}$-ItNji}d!k*3G&IFL zC8GoVaK`UnyEc+y;F(zOwqZNIRbFQdc6|SSxu?wMavFCMkJz`fPp8Fp@-uvMJXN$k zfRyZB?n3a(#FYAciTC_RX7RyScS&W3A=MWu*O-FrZnPRmfrP^p`eIgb=dA5V>O#QjlKa4-UEdTycA zudfFU>3jztiELbcW@o&m%cKlO+cKq2zoJ?ks)?2=&G?{9$ zLZk{R1IzF5+PTb|0w|6bh6Wu?AbqR>K7!>Dtb)i_I?k969V@uOm-f{xw9AWAK)UC* z!j0=Ll4yVRP_mW&Ui^msZ`0mW$8%M2z}Vbg(=Mc_#8dlS$E!P3wvk_Ea2aHB$;OjE z9tdd?YFVO=-s!;6gfqT>Ong4(k(;%6w<{R}CP2=(8qOBGlfZ`udn$$QSWldb{exMs zJ)*w+?PL0DID_4mkUM`N1kemD#$EuWM`y__}Sj4#Ay&Oo*Ts15a+WMN}2aNrS|E#|9jRX#W zQtrM29@WvNhJJ&(2QT)dU*DuCMcV7-8Wg+~VZ8c>>@Vw;8RRwysnt%MYssW?@}Z>e z%(K$ZFc7KpmK%Oy+k1_!O7eTTr0}>9*$aT7GF7(Hd(CnPDm%Nmo?4?SyyJB}zb)^D zM1`mfr$qJ5x-I7L!g}zZ2eX-oUzKqMa)3(PD*|VV@)94-(U>rhFUZ*^|ESS`;EbCQ z&*Kv{uI2z=#OwzYArwo`lCK%kb)K=jQe-*2dE2pNAjrp@^NxtUS@3~Ek6(FvVn=%% z#~^WRBR~f2OzYZyy!YoArUk+s*PUWpw)_aGvJtZ!co{E`e-Lqd`9oY_@k-0eZkSZe zemG8Q0BFjUDbfQT3SMY@CpwG20r+7Uzt=ye)V0uM%1o&8A#YcKqou^c5>CWs;x{tj z$u@m==QG0m5{L`sC~@5^lOs$|)VJ`Hw%4^X@H=TO^3(J#0Cyc^QpW-E)ItN2VyrE_ zXxBD&uEr4&R;T)~aV_O?dCx|~dLv9f5I9oRZ3|6?Oip4JW+uGqMcWEdQXpo_qveWb z^-p?oZXR;bbE}$*nrSa&O;tZr3~UOy{>P$s z28)+C$^jTh_4*0qK*Z9M!;UTbO=nLR#9w^KIfmQMIlO({a%l7!VyEYfSxE?098a?D zH}Q^Qo?7T36s&ND?_P1~W6_I$JI)oTI;+ElRX)jx8;`X0aPJWVG=dg1tO`+Zx;=u` zQ=8l8oNIaiRtn;t*^;S1s2_A1|8T&x@=?5ev}Ex^R;=o@fYi~KwoGSQCe4Vtu(Lb* zb?ez7y)8wk7f?){qS#tv)Ao+pc(eLoh2v?>N317R9_>GkcGP!p9KXS=%`==_Lt5Qj z`eR|0iT)$V!b`+4oe_s)gX=VCl~OFEVjpaZK4himzmdbDEhByR^QiB7`PPvcJqUEC zjw!^Nv+!O{UGo!~V9hu~UAvmAJ5YW;#MfANTUa~~_rQ`@ZfTz!tjipHjqa%!r%E6k zrfsT$P40#DW{|@n&$R^V<(S9TqWw!Vy3J0w_5a;PJw(9D*iqmb_5|2%fssY1fW7>W z%lT#jn(2Bo_z>D!_5NfM#M|?f#(dy;$>-G+*66~==+5XeP zif!UfN<|0bFX$NoMbvOsVrW#lb#Dd}+-B&C(B6l0C~R`HsAfMA;4oBwdH1wdO^=!tcG0eU;oQXxR_HzSE>4X*qXD)6=2BZ4((fn#P$ueabNey^^H!gtU~eV|s2<5}hTQ zoBY2OzVyXS`-5wW>4qv^dBI(s>`*u#>?Is|NM@~v{9@{wc)=_F4aQ8jZQpQ3XKa}qR^GIX z>>S~mC_QQ8wNCj>lMrpU4ENIv*_^NXO{xbBg`X0dM{OxGRkMO3#Csr}U>Cz4|G+{Y z?U(?Y`G%>C#9q75=bVBgZCO+29&Fv26Kfz)8#MaVdFot|Xf9i-Jp&2sk269O z;EEyKoa$LZz|!fH`iiI*G>mw2R{UGShDbx?g7+Q`3duy>ag{8f@igg{X-&`jtapzzx9?UFmwdBmhbkzF+^!dFdSIb9)< zP6)aH$J^rA^)4NsY63(21BwkXr%R{C^u&*EHZio04F9+Fl%&dGxhY?=`{ccH*)`$I zI~*j0+8q_)SE*l9sgLFs_>UAGGOss*TMG8+frCvo&31!QetXNvFjjiG*>$_XvtmlS zPJ;%0PkPC$A<_}vmlrVe?C`Z+P0H1>IoWokdG9aX?h@<9$M1ok~k!C$IiqDyrgO0}8?4}fErkMcMm#7LM{K9Eneq!>pWwA2fYqG>vF=7{4p z7IKglH}a|X%>RV>2eeD5ykU-}- zH*-?E5J8}vGxoDMdbh=D1T}vN)O7^|xtBf}Nuck6klzsj#dk_vgqxgf3@CZ^aweW` z2^+~7DVu&>g6j$zrRm^bPB~9y%M4r$2Y-Po>w9429jlhTyGLN&H=w_MB;>iY}V3hii62ne4}-Pr`o>zWqJ zj%NvURmoFmjzOYoxr73}?Tr1OJ8@(#;6kJF8+qz0v=I0)RwZq1p@T|%X{fbrqISz( zB^As;YHWZQjHZTx87V;binblY)5P3zg|pvR=H&;k!$+K!g!D3ezU0}6o`=SaVeqO_ zo6kq_WB7728}I-Lx(=xcL?(gY78I|xeKi8;$6QP8G;QFU3Ej{xr+Z`jM2pzV)IB4{ z^gHag#ix}%8$^QCRKyN#$=oug;FWv)#oBmMNVcK#la2DvU%^Z_n%q{UG1{5T9&S{~T*Zk;D)0MP=59nJXl zX`q*T3Y*{|&WTy=?y*sw+ij6o` zE?*aGE@JjJfU32?nEeA%0(%pXXOql5M4X^i@4+dUv+a(+l}5Dtcx~I z_#k*iYk!+vA5bJXZ*y8zU!h)|Q|kQndf_n%(n1%?Qf^BN_+$I(VSOu41xUuRF zM*n9AlKyc;m(8`WN$O|MN{*)BY^`0^AD(ZTMroRu?mr@*i2BS8u_h69ounR*=IVs$ zv97jh^F>>ZCa~|BoHaG}{g`;nz{|$ypU}?kazeQ>)tgiMd{5G7TZyQU*DBa)%3vN`O}cJ&qU*JF+f!4Ss+z-IZN*INVO3&Du@$HFbotAXlfF)kzmb8 z-N(SG?0|wV)A{?-r|*`}!Z-G(emVdzDYq3CX*oCNtt365lITp`F_tHn_Utosz|D*^ zP=BPID$27s-GZC7blMY|!7B8?Mw?@+X1Ku3C#j7$6N|M|HQw)TVv45_dUCCi?_JXB z-Eo;m_Ogo}wej?<5AG4?3na87&drqEZHf2H3!+9&jdzAbi4&8T0gQX6Z-rnB7P>o= z@0@fuz<6z6*$Rf$7(!eF6>T0uOV~ddACa-$2h5Eqj=N9KT`p+h+>iv4g=)an*YB4>n^haO zOtmh|a3(^)!u%nyci}Lp>^k$}56EQv7 zN7E;V=SQFTSf6Q*Z!XPCeOo~H1o1qP;8wSe52%~@)nA+BlzENrO`W5;-^8pY{A>>? zA%X_LJ)*j&y?qqIt8Ig^)b-2A*$T?L>@h-RFb!eedrW|alt0x8)_`Dj?+ru9vQI6} zFZDW#FvF7;#gK(0=5ss7?JlHzpc8bw2Ir1gMhu9*V+!N8Jd?ID&tkSRUPAP zN5zz^8Z}Ry{@-iuwA9-ve(Ks8dJ@EtM1UF`xxohrD^#GiMyS?c{lhFIG_7^P+N!bd zKsqI}e^_U{jQZ_K?7kHF!jZ=zJle`vYlTi2?wyhQ&=9}P0+3>5LwLxZDyf3)0tc3# z!V#{R0-Jf)7`oxu?cq_L>fZm=mqkz(sHG%5*~5aqjW>%Vv%B#{&OrW z!4n!5wT> zQ$e-V&Es3IEo0JbbX1gS(Mzyd;t@#aaWa=pWd4%sl?v_NtAIjC{4I8+r0$SwEj^+hndqmyjQO`I zk`OH&ju_*autQ1uwepe3OTP+aF{^Jz3nW}V#L%#EW{e$}amL(mfx;r-g}2;X%A{N0 zPYui<{R%I4Zt6SC=IW`}IA|)}+wK2xgfBNEozQGz*9(0DRBZ$%YZQ3&E0+5!NQETm zYiKn**NoDQoqexlb;|q2!(U$XCA;7<^>~HHPMd33(}V#`<-H`pNWd(^3QEpatm73; zWo1arbA8a0H=~KcpO|ekBm0a?inhzIewb?I#+YsM`4x}|fTfL0@k)smf{f6&?${Pj zdAKwF_S3%vkO=@j`M_}FrTM5BN?Se#39CfTCMEAfp(g0dI7oe278VGmh2O=ZK6PEA zcfY^w!BaJm34*$nflP7*Ml@@3CtxSa;~AvJe3e&c^>di@S)+6j3+sk>QtXb(xd^b zcja7&=Qv3m3PMY02Qs)htmu?K%FjZoiV6wuff7-*3pg9&oi;{XiNSPJhTR zyeql-W~+Ql@hn8OmIKf2K47@Ce!Ck}vhDa$rvW6$Wc_))Q$k1!VRn)j&R;uzt0BUv zMrMbk`*VxN;g8+}olu+_pC7sPK3eKG9!ztlVnJn5Uro3ZZuhQdYpq1%Uv}hg4-M?P ziC*)!x$NYECmRPepq<5T&$t$W3;rIQ{N8%4{xrf+ko ztAEwQd~LnPdnMs2kMbUJ^IMV5Mxv^VjJT<))A;^iK2aRkEvV%TE8@;W6o|QCrbex>GgMRZ$0>SmUblSCz@mr zvVg!xhY#vMAtlo$J7a{J8Ku|@!{|2~ceTym_$Eh|GfGPn^t13W%ZK^gk7Zr&PJ0K2 zn~+w;Uw;2guryYq6dWvY{ul=6oAinmRkYH2Qloa|(*P!(gJxKh%Mqxc-a$nUk6xB)aT(>J~aZy=W)6iH>M*BLnE`@a6oN_;*rpXAkj_898J%HTA)u$22C__@{9 z7SSg|>v3R5gWzS}gNY_1#n0EcESFr^o|I{`aiPOz6sLB$wX}#Di8oG5`nCI!dfDI6 z-Vb*-oE?T3?bmNt7cybYN=UT8bVb8`!?F9iR-*xb_kGE&lc!!!o2x^y!>EN4J&0n6 zT$|I-Qbqi1?H8%uKF`1~z(z$54%1AJwoE>&@B(+Cl#+eP>e#2PIcrh^%2LZL5dp3K z0+uvNY=A}xWFK6c#?KQihJ>u9tC3Fm;l9w;gX+|e5EH|;j>g`WCeuNF`fNS!u8-Ub z+-5TppI+Z8L%JiHauW*E_t3dns7xS*)aK`XI)r_AhE*?L;xBP|-TS~m3wp;he;>qM zKKl1_4vWr`Za=AW4NIlxBhj*jp0!90oikBD_T2kX)JhXTrl)rK=`>G(@bOd1>< zwi~P+dl5{c#Q(lQsw?trU)|>kQpfAJr&~QZ;4mq#5)aF511@6-k`h?ka(CJDqRFeE z{HV{-o&5G+DwDuBJ%=#(X}yp7|J|u4DNheJVYpG`+&g-=+EknQx7#kYgNl1LlIqo2 z?B?_@u*ud+H)Q1YM8Zj#WqDJ{?j%$(D5lVH8r=*`^(^pA8okReu{R2(AK+eL$dYf% zJ~e)OoCGyzO$`&Ow}-spcSSFMeZoAYs$%=SiHr#X8BIq_tb+#IC+0hD3vP?QO^A`3zpIE4%?9{m=o`QZhIEx z5FwbyO%Z|@KtiY=-X3DF{e!!&@R8-lmv+B!>s|>H%9QMi=pXPIaJW6V`I(ef`SLB% zPf>x$AFex@a_$4D;YI)5|B$|W?*zf4-~=_mKck>m(8+7`oLEP2rx+m-#&J67?uk^c zFv7xAdK-pJ5HpFvQ;-NP<iNB2$e4^7 zjrqDrDgMeQ6}P;smwC3H23j1FV3;3`neK!DJ~V6|i;(-b-kO2Vo0vd?oMHqD+jmIj zkPto@89_bi=1}qa-SX?f3PUje{7+-%kd4>F^<)WY&L-7_!>sU!^hc{;LV3_B}5)Ze!?Km~;WN9JFq%(&&lp~x0?u=mE=jr+V5~Ni?Z|zey zQtam@>`wpZ*MS_TOGBb@Ol+3u_F&NjDV5rLZHWlhzdSn|8%3}@G3jL2AH@uryGD|I z%|=6`V0U7CPr~=_G>6UV;|&YbHam9gATQHLymugf4Zb3QqYuD%Er>LsjU<4GmcEU9 z6qCMdQ4ub2IBhny$aj#BFRPb18Q#f{f-p{bLEe<&)*BCVhn8pt@bOn=}ryf6of z(!SX^w-lp1rtOPcqqi()uPa(H*c_EfpddfPIJ#vd-j!8h>lC(kOR`!osI-RDhzixL z3s}aCPX==?uK!ChJzs&0we0{|iE~`e1gKz`c+>$?c`un1z1-BGD_`YVxIRaLmd3zX zdqcBU(7YA-_v7Nrz;!fZs~UdQ$}rG{coN#E1k|9Yo3|ahSU*ghkSzbCYYK9K;7*i6 z|3+^Ju=<*IS-+zpuQp{0iGUKItw@8T8ythr(GGG21`&s<1J=JYd*fY5Gg2btWZj{k**!;3?%9fn*%krfG z%N6}&t~?KvS&2`2b>doRbv-KI8B-72PIYx zk#v`GR8R;VgohYmA^|OFs|C9#x28jb#4w)L^S~?!`Z8GmO1%HiOD_xYu8jQEx!|>= zg0u}e(F33@pH44bEVkmcBgRr{ z6x)a<`aD#rt6<+SxD{h27Esec6V?{wlx`h{h8$o$@<`Q;ZFh7lgCSmeTcml5RU3OU zF;nvf@j}(~1Kp;5&{%!}e@2{0q;RG1%(T{M7#`nFq|Ma#T7dL}eP?fx$TT0eKP>hM z<&n0`Z@&Z`o;Dx&Qu~O&W+Acrt50X6x1@#u%Z9HnLFVx;u4oc#gWU3ZT}GZOGStbP zn=Oz2X~qFw6KX2?5FSS(P*P}QF>E%b_)dY=x)_8~+##YtykO~7tFd(gjo#kx=?`2{ zCgtQ1ry`d-u>2Rn+bgj%FG<6#dNj!w>a%0 zU^D-CK})g8OG+zItTNF5(=6cwZQ5yCR#=#|wj|N~PGrWs0M@m<*v#3FLatEF{8#I$|v=lla1)c`M)^MIuYS4|l6J0jiV zjGL}U#G``QF_FY>P5yLEiDlBt&5QByysW==&(9h1uIZAtJZ8Wd5xG{OqJ$^@@PIytV4b@JPS>~#j~QsYOH|| z(?fKgbCaeC&6?X*Po5$Mr$(9&_EfsMZ8Dj0jlcxNZ?3+C7Z>U3@7oWg&np#`TnA8l zqQhR|M25yI7;2_IC{7$s2}oZVBV72Ld@!7$g@T_9KisG>hyjX$(7*m>vz0Arg)eAYLrA<)sOn+zKQJ` zZC_=uLXg+qH0$$M3YAq$YaB6*U2C}LS<;d)2jiDY5NleST4ayvcj)tkBv0JTv$(sF zNk^M@mXexo$n1qZe@4rz;&uH2ww?x@aUsKMS6;pFKeSw%sPWVPSa4X})G}{4kkH%2 zpriNZmzU_$tdt`!zg|usjASzXkk?RLIjhBfMQ}7@pg{E)6nL_cweAHxZ^uJOoh_Ivv|BFadZ}G=4@s*N zljVuhAkQ_b5Q7JtQNhPI_Xc)OGA~RcNNcJ9Og@#l{BpDf*AtWTSQc7f#kd%xNC8O;3 z{BqxV>Un?1`}g}hj^A%7h@O$iz`{{*Y8AS90YbZkxG!`^8Uy)+UxBG@xn$^+mDyaJ0`f+O?74jg z+I;)+k>LWOvqHy4ds!<|e)YaM_+2~TzMe-3&H@AmtI9IXTU5q|5fz)gd-zPr8F@kY ziY(aq(Gx$RAT^AlBnk0ouJ`$8BW#`l?&wq|-L^OIrc{*nRrOWrb7d0TxEVV-ak(N>Pe%lVj0qsQ2 zuHt8L9r?3*;Vb0Ist`S#(tf*f2(37#HT)ZN=1C9>_K018Ap#;sCPCZ+?qyIlQbO7r zx-9KN33m608+w<|J~tv~@3SFC7Ehwcm|lrcu1l`ugI!Xo-Q|HL4xzm%EcDRZC>(H3 zejCR19ju%h-reef@5hWB5ii*pBW^8xDeoIM?3&xu>_ zklfvi>;}2U;ldfD`bcx@w>`!mcO6OF*}b0NqE@a0_p<3r=nKMQ310Ilvy^2cGvYYE zb9Y-d^Y|$=%BPLlrP$iQwYzFIs%kMcR4?78>$}$19lLLQH1}-!&Z(3K*|ryQQ`>W6 zQWIW&jWG;;o+{X#dz3&lzDnJhM3Fn$By6ZA1KHV58q1}*JHG;J3Ehm&9U+5`{usk9 z@o8`fjK2kOo=cOS0*3_)lPK4k@2uK5jqK7D7&4fwKdencMbk#Kwi8X~n{_W(l+_se zMEciod^L)GS@Xqc(ZTv7s#LV>Odb+zy!>Z$w%iLIb7S@&o>}AXoOA8P%;Dap_j}9$ z$2SBMQco}Vo_=-QHn5%Tm>AopnYtULBBDxe`vayEm>aHEOzT}Ic~2JJ2zCs4>(z`rOAxN72*`Gj&7Dge5Mr^|(15vDv@z`0y&?3-7sI40eq3jh#vR?wT{! z){r|jmiEZv6sP>aSS}KF+_m^at?DHjJI!c*?NdLfXQwfNd62*%td(u+v=&wfG^SYI zHqLmSl)^I75`k4^RLC1=eVOl=rpOyBKoM3u09=6crMQCiku)1E8|EZ=k(khYq0#~pI9jumHNrBzT`MEos zt5>E=?jccS1;x%=ASQ~AuWZY)6%TPzHm_U75pjt#GF8@)-fp$N(;JmFYK`g~sGQkV zn4^1BQTr-U9XGL(`BdmOQ_x3i|J_Nts6bO=J_V$F2?hRTWt^n)N86=wAk5t1>xY_& zW*OdA_2d_~=?}a>51ME9zR&T;bBHG4#e?g+=9+}Rf_j7WMzLkAmVWW3Fh_%m(1!U) z>OWuMPReBT>Tnqc(9gfDB{p{J;AGao7idn5|NN#CMe>l?xWCM^_Tqisz1$iu>}8{s zzQ0l7zkXulV&rZ=c2FFB@-o%n;ALd&yJ&t}&Uj7|uLTH3wP$U;rMW_J^O~rR0xXc5 zp4mfB$y@9`n<@_Z?t8g7*t2+gB+YHMAvcnzqqDbz;y3>b&ho7SbsEj4)@j}G#yz19 z`N;6F)*SaBK%teU`>8JvowSL@5oo_}q{RhQsKQOJSQ^Lv=AX~IJyT1LqIh{8z6|n1 z2XDMQs6Tw_wq#p|p`Sqbm|fL6u~6Bkg7nGHl#gOSWsy=pEd_7?&9I{UbWw%%;hVI8 zl7gxZ94!>83YTX5TCvP1xyu2aG-V_H;1li&pTaym&=kh-0cqwVSk zkDct^JU-)y5ftdIiLHe6Max1!zEWA{etMWehdzpNnD1P@%-6%go_co`yAWHX{?Q@( z-+souB-V%HJ(71O&Ld-z3K>TKL1@`7|F~O-KThY$gorcnG>!ye;qqI80D95K%PA$U!*YZW1w`0IID$^7@Ds_7!IjpaYr$k1Mydo}&4eKeB*wCHoHS`{(qJhGe z&6$aM>-U@8Cp*({2=;<+2v`oa502voXOJS=qOlAaaF}S}CLc=8;=f1i4I_@2#SZHw zpI#@wK?JOUh$fbIAoNh`#&duh&{==Kr@t~Le6Rj-F#Um*9s*Ye(iA3TV*qc7Qi?a? zlxt^WKw;4=Pd4eAfVNeF<4+T7Bp0-9xtHX*E#$N#Purm$CT0gls@1O!y+sE#;gCCr!ihBf^0^X-PqO57uHqRm~0AkZ>RgKyhJE2y`VE% zLcn}=@fA2iIHxh&g&DBQoE@mG9)=)fSqADqD!L2lrFh8ijFMZ!6)CVcf`0iv>n=?s ztAkKX@{7d;A_C2$lRkF<8xbnAxZaiUaK!}u<|!DkXfE7I(oKKnYfDD;7rIeP269*v z&_#mm*lKACa5(?=M>r7zgBs8us<|K05fCHChyaB?fyzh1=J-Qb%<7=38U?a^;YO+A#GdMKdJmOu9}!?=^TYxCa}i&j9+`w1qptS0GI@ zrxsOC!-VE>+A+I3bpMHV<}GIQ9fD$(A}(oO_hp3PO%1u&MLCo6=ZIGHI`I!1o%H-| zsL36I3#ihMd<6Lo!O{fS$e35)fP;oB7M4)}d>4tf-SYy3orCQ(qZQlsKe6`xYf+wU zElPX)aV-0z#N6(ArBk7(LGCnG-Xy!D$L0MV)WA@S9minQq3)^~xVjQGD%rUTC%I~d zF0*k$p@E5D<`6ndo15~Tv;QrBpyyXaaP83^wdc_fFi9}=N1D#AF(=!sNx^XxS_Dsi zgXNTGB%P$A-Y=35j2<|T5LzDnY>nPMymw)L%he~YqL1b%7=X7f6@fjT2IAmY51`%J zF^D<6wtq_?@=k;#-0w;=7Rn+vL*MXqbxIHe1NSL!vTa26;NT$RPJl>5J@LL3z%LqO2H@0sk^=&9iHDLYyR6!~8bO*fhs`5`5eSdxG}?|b0}5*tLLhd3RfIAAnI(QVR=vm=M@!R zAj=Wo=)P);NKlZL`C}))6PSoAs)eY~Dh9n*5-WtT(ueVbFL1tyVv_g*a(@enRfSF| zMI9aXFzI;a_d_pkpK!yxlwp||wDU%2%$Fu9{)Y8Db#Y|^F(e2af-WzS@h@8&L@?*T zqnqxFyoGSQ79vyvRF)2@-RDkWEqH}f5djNGfY@|!^;@BpyQ3Jz!z_Em#p7#hK-cPK zpGzev8-=?>9R~Dn_|7ryt|zzU4|JeDM@K3IOF~o6bQM6$#abl$JsN`Rr_0{kaDURzk`44k{fwxl_$l> zoL`Fm8iUM{AMb}boRnfyQB|+>zkTLhLEK+n$emyqb=E;@s}Si=Vh+d2+^!%9+DfZ~wKKkY>zWjp&VN22V32f?(7cPLHAC6#1RQ)L&wRh$ z+ok9zcZ8Y}HqeePRyKSbA|^y)uQj#O%^6M!r!P^(836>rR*#&S+|jO2DHrcmK)VPH z8XPBJ{fM$e>&#or85sK~mBXk;Iej;UE5_g-RiGS1pFij%+ZW!|#qqz3JpuVYiz;V7 zx;^dD=M^EBOBA?)skS{mmv;6uM(Wstu|Y5kZ8wq+EOiR8Nboch2X8_7&E$F$Mz|D9 z>KqOv6M#s&nT%Rv%g%iL zSivx>zp}S*^Fv|D4_3suju_7azSr?~<6XB1wR_a3+Qz5S+9t0d&r*l(lq z50Pu!*^sKGvHhfd!}5T-&phPl=wtdaC;%(V+4j8}LX?X}ug^j(6>C*btTk7V)Y5h4 zG2&jZV9$#aqDPb}WmcN?(G3$2mgv*6YN8(ZR7Fy2^#InP^Nv2G%libX!(!`?;P}4QKXLf|*}e+=D9-aL%@YdtFZ<%iyT+2BYps?O(%n?AQbpizmSV}n$vv5S zO^C7I_zCX*RPY0-Oo%l<9tK(__(2`ycaZ*QR&4B@PR!S7DT ztgM&UR1TzbH>!|{4U8qT`#dB8>TR1BbP(tgF8$8V-S4JmfnpLts4wE)gGP(pCaqmwYBm!`F5(_ACgwxU8{Zq z+`+CyL|G>qRE72Jx}(rtG;WZ34?T{2=FNP<3%mb0PTpYJj`BNlaY{mI^7+e z_d7Dt4|N{VS;bQF(VMJivwUr}y{2KpmE)S-_aoo*?$wyOmi{dD-rdhn9PVZBwrw{) zI{Rrw`k;$c7l0|)!#|C{->Fzi%Ktrv7LE-Qv}aSbUjnxmHcGN5r6mRC27Js7OW7_n zT$^M7Bh9COd{E&t0b_s(XW5z?7wCix9I1C5ysv%l$>-;MDQ&asc1L|<$UWJ+E0MQT zTh10%mVrfxw%^pV(eKv)p{-`WLHl-{d9YKaS?GuT7VqCV)KY?|kIdCSm&ioydzHB1 z`-6=vlQU;FZb2f)m_f9oo0jmzmAgv6cx$pPRLY_^Mr^Gn`uvr2WmdoJcZb17GvxK4 z8H^n9JsXqU#>Jm%|58Q`jTC`b8Wlc|COp+LaY04ltUc3IG`gmOR1Vi%wj0{nt?-%n zPorE4v0kHbw`#rZSI1}P{w#3lSKpHw&9Wyuxe8EXjt;Lk&iuwcDVQjT(Ks#{QAPb(hq*19;`ZS@hROW| zvFU8OG56WTK~S@v2Cawdz8SQ>v4`_`;cMh?NsD-1+l_Dtrir?%>}f<-)zj&8a*LEZ zdqlEOsD%{6KfE18wJ8gNB5(BMPh^Ai|HYAeii&U+&9f?1F39x86Wz|3F^6LY8tLnk7)N9?lMDF;JE8<$^Bff_}J`Je+}0u3x-P+py*(vrJ{pIcAOSF23}4> zX#DFUt1jIqULAEKYGFgK_cspAH;FBzz1j79KhhR$P#vJYVS3;#-?G_G&)RrDqE-MrBE5>rgNhZ~Rh`s3Yh-NRM;g4Cx>nZnX$aQ06k>hJ@ zKNZtVWpM^IohEltP~RgJjoHZz15xtfvKRPgLi4d?U~f}DLT!`1>G-JT?n1Vy%eNm| z)I?tyjnn5@qt52a?59Tx+UmxXG-rKboHFlUlS~fNGu=BOP1yyOr|tngN%?0Pj`3>@ z^IFOrZe=>Cyt5udsp645d^b?W&0$aVBa&+kgbJPBH#y?1nh^f6rl)3kH- zr+nWVl#ta2ikZID#+8G%i8g3DF>>;-lDYc%8QsHj@omLKm$L)&4%v-b&qjW%jFACi zS)flQF3i=iBSqY%32no*_v53_LBT*((pg2)f%8=>B#~)cFf!18x=-~pA#x$GK>2o! z!G|1ERaaYQ36$`2@35Sb@^NkrN3*U#*RFd5r-WKrC^St*T8W~=bj;NWwEqDLSJF*d z9(^CVX}8)#lHx;9m!^OX3+`(kfAC?~^HiDfp5zDFA~9o|^>6j;N{c^}-yIt5kZ3I! z-x&8&Cc#Jkxox7gy~%}qLvJmU_k5aroes)gYFXF)%J_fl3@#uxfoN~EX-d*!X$~>H z#cl-pmPn?%)o>UucUcV~i%I9I%B!_#S4rAM3w1Tk@*6FKxbBt58WcV^C&j%4qsz27 z4_-_BU8dS~m30U9Mo9T|m)}W4PM>5M;}^D${=RO*e)~@89d3W+Srix!z(@e;^oTZa z==kxEsUyelZfuTS5cfDde5DzzwrF3p(e_)=oGbuYv9boVVn|i%j)=mq1IXN?81Frr zI`(7NR*39|VoZ)vK^BS$*FWbJ-pdKu*AU>7GQZvF_I2;5p@Ivayp>O5(ZmBG!hzOR9FFsbihzo>Hfi4T@*7XIIHw|8PUm0uS&^FOj)&WIreV6>odAMXA z+aRZujQfZl#19z27guihCb$N$8K@qI=!PGIx!)XX(zVpqBKq${n36gp7quhQ=$)V; zfdU3GcS-ITk#mT9nY}~dtP;Iv$VQ>}@dfbvdY;oH6o0;|kX^#>eWFePkNo50If~2!ls-`# zAX3Q_F+>v;JasA1LP*Hj=>d`lTv-nU(&hWdBL*$bBfTSlfGR`zIh!N*?fB~;GR6?Z z<}YsAvJ_(THgZ#@Q8%14YPXgs%;mF`LZt{<>LI)W4WKTKES)^h8jAEH1sx&r(7vGa zy!3G$Weq&(pawmpGc(?Zfk33mbf1MuD@UH9&`vY#&P-4)~l39Eb2jQO|9 zOR-6nH15pL*-F`OEGy+bN^3Gqw=`&_p7*kC?CX7=$;arCr1m5Q8hM9GdK_PLZ`W<3 zOi2lUuc+g!O84)t5nB|^zTWCc&AJ%TQ%vn;>`OU@3d9S+A2!rI`=zJAcB0Wu+5+98 zIo*@8Kd0Io@7U5YvXWR0sDAY93_JOJ3(0-KLaJ+w=RsEF+_fte^V2+syRM2%>H_os zeA81&U_4>U@{Bysgdzl|76EunD61W8h*!m(~P||Vr;d6w1a8eTPiiq(~&c&E9kp1R*FM(VkhUIblz)HhGh2`FqCSt4Az- z3Dtm{++fH0^|KGm5m$sMGC*O5Q=N4Go!7LOHLZ>)O}so% zAYWD>aNy6EG;(*JsN3KkyL9e9S8wwuD=S0k9_kxgRqO~xdz)i-xY{0J^hOzrrZ|>ZvYOO#AHgQ4cGcISIj=y{KtAUQ+L#vD#{<9JDE&JKl%y|fEV`z zK~IK{va0Hr7RBWM!X|HqEFlXz+HMsFNzIA`Cex}Am{9ZU_% zs;(H9=YNa{V^I5yI1!(1+Bk5XGu2D5jsbJKIYQu0(c};N(9*udY{9>72_x%G2dqM7 z)x*KL)l{Gr!k!j_lL4pU+G^kEie52Pf6im-32}_z$zUIe5iQw;Q~e-jPGTsh7_Ter zJR0H05m!joD0Xk7tnu7p(qqFc?*}@~eKF}d4XC0YV8g*7D7ns+uI|)Tw1`3IJL(;U z=$nCTY1t258oapxOH}|B?^ncp>6LP#$)Am zo&3CZM6JsJ?d~C3z_^0Z+7tLDTrm5r!S+8EfW&$THs*E6GU2(6?pf)@uMM4-RXIOG zj{(kAWlBAj!Tz)lK&EfU1p0mg_oN@0+ez|yq+Z3r8W~Y^qGmf~TiSbKpyuj*)&K3r z*Lcrpo&yaZP<6rTyj9QX`Xu?+wkG0yNNxX?H~Ur0BJ~)5#`J`o6 zBi3}J0712U&Z9@X8hV%hw4d8fM#)f3#7dF(Op5v7XYYNLkTxKgzkW}39G@QoRJ}Dl zVh{L>x@M6WzTUKI!(S`wft97Z%cM1TfsGl1g`FJ$QDSFcKn!0&sB=OSec32`zYcnF zT*z-2#dVRcV11iq=6_t}1s<_j?^bCf2KX}V6{7(s%-jkz;PNhT;UH)`6-tk$qwH+Sx0 zi148DQ1{(-Kj~Pmf}DM;h;#3;cqwfi{bdCoVqu9R@*(iV7)bbB`NOqO`iZW)b-ZUJ z`1Ybc@g#fTmB_TUkbMf|rDTlj_s*_k)N*RfM!Jor!%=|PKIz1eH^5U9wRZ}XzFXg@ zr`gfKI{N?|f$lt794Go`t=7sG<)IVgNfM& z*6+a9v0?D-)M@VrxyW{Y%eH*4i=5_Dks#_C$;rw3gTqhu32UdXw2b-wAfqxDCvF+- zb_L2OlA<0`728q1Ol&PI#nNAhi&(DGujk|V`SH>Uf&0ikLbP%^9}^LQJ1JxtVWRxG z{OZT(cHDgk1$+c9589@hA>))Mj9KscjhM?xOiyK6DjCZa80#>%!_R0g_guVXa+BDO z>pM?R4aXZH(cZb@2EX=?6_`0Mg+Xv*qpfMuy79#*whZ<1_4lXtw}d*J4)AM!pCs_Q z)zqnNDPNDwH}|dOYOJ>#uLoDzX>%>LODUxeEmhp{>C#NO&NOfCUL@;~AumLP-Nc2u z!$6>->><=aK7aav0*RkVh}+lODb*c5mqkr<^4Y6S)2-NDt(rzHe6CE1Hui^&eF2rue^DqW-3);p2~|e@%t2*zo@5Q<=He7 z*WaM#fwm|9!w!QnP9Ho#e(?=-($>uj2RSuI+txdbcx}m@4>4&wR6g<7cGOF6Ts=?l zvQrhsMYdM4H1sA_zq9er4csi)%|3g4mu=goaA1GsfPt%S?z7E|Yl!8K3Z$~XVvO5i zpu&4DzAXny4H|l-X zn4Wjl$@kPqJP~!5Mw4^&;Ka4-_0C_-bvbG4u8h1fuTbSyeZGGo)d=kq&H78LpY}4; z{pcC#YbZF7VdL~Z^1_?Fug;i)t-j^0hw$h-{fzBRKCTXhk1V$)+-!X;pG?|T*I8$J zTVBGQ6oCh0kr-$R?F;A}*wZzN**@CN{aF%sUQuypMI3l5c4M8>mwQ*==4R7fVY6Rd zz24p{O*`g^68p)=RHSt9ga^}LhlU-CuLZ)GC5CCZ1d5-KM`lwQJ@=^VA zvEG;06Xj+-&vkZONuTCx#KDd_M6F)Z-&av%k=rr}^Rk$MWfGp07*ve1=e1`qo4KtG z$2BbT*Zgc#R#Ay}{#7B6`|BtB;Dp@ymvUz+oY;I;vsTyAXmY7nFF&3t#3De5=V3Zb zI9NaxeU2SXaPmKh*=(KMZt&PVGd8lZmB+X~YUt!s>6u(7UB{Lv2<147@aZ~zqwh0$ zrkcj}PbW2P2i+6R4iCOwbKU9WEcpcKBb~p}rT~}`@_mFtGwsZU zH>KOk$9&?f+Xqty&vCa{A6!V4$ZVBkbVQLzoVM`$8L6KOp;>QoGaiWT?y4R-*En?n z&c*&)=3iM0lLoSuckPwpe+C${1`H3eM?NZgyMub8QK0e8BEc zH%$2l)ol1{jgHK$Vc9n16nC^7nm}4?H!e<(9)QGviLVjp9D`ns5SSAWM}Q6)CkKKj zn!Ixhkqv+^K8kEu024>SPbCan6pZu4*9<};zni|Pla1kp29+~6>zr4O)g}eYPBCpb ztp~gNLe|H3r9_~+M5{}k?$mHsMuSZ_l|M1=)E;UFL=v*$9D+>{3-4-7pnAvxz5-ZT zY<~Lz`#{}Mwf@hXmo9A2m^;A(3kN*#KP6l<}fVa9yl zs@2-m*LQzMa#S$#R>Qg$k;Ka-dw=veZVKizRtVI=GdBHr=NkYx>Wq{bf@ddU?t~D$ zVa{{!%?%K8Z$Y4eIp}09c|c5kVJ3P&~+mP zOr)}QbFxdp5)U?ZvL$B3>g;6x-XJwby@ACepOG>CCsoxw>ZMy@j9$ufQY-9YRbW1& z*IBo$_`d(qrJ}$5x+L+Owi=cq^<=spyyEA5V6b z%gC`B&nwN|%%gD=C*mTzlqwz55#q#m%#=&=iT5>ctETp#ok7Gf-3Ler$^(WBe*GN4 z1dff5_J{4s#2fU|JW4M_=nKMgh(TROj6+VT!aLTJ(hGI+^-KhrJ+@)9PDE^S<UZ?+yi#c%yZN9uTE+YYlxShnJN)agfKMVIIEy5NqKAL!(bp$Ik zBx9Z2+uPe^s#Q*HvL+a)0>Yp*Poc{&23bqLb$i>F>HIpHx9O10nKQNeTL+H&6+;2V z8gop+8nLTCg+dW>W1ORA$V8~OC?N_Mr?$WV)G&)&+<#eBEqmp#{WF#xk04oTTY7T~ zab|}rp03=EMU*Yx{biBIWxPFIBx|cwx;syJ`)htaDjK}~GOv&}rH;CMTBcK{(bhb+ z`9Z45jjd% zKhnQCaoP3$Av?k>3uROCNV90$e4yvuNR8ISO(|F_SXcRu*x zN6+C!|LcRR@VCzU75>UH=iL|d6_(Hc;m;pDgqM{jd~1@x@=*NXA3v~04(y{m;ona- zx|=n!z;G-7|MeG1?s(ZMJa@_JU(oFa%0HN5>6#3hAMk=N(740gdxs{2J48;AOrx{^q^npRWnRrecWOp*b{qeh0ymR#Mk!UJ_t8&hVYrpf!0j) zFc75Et}W!G-^1ITr~lU6^9Jl@RoLO^Fr4y$S{*RZod$RMpamWZbVB1_V!+;o{nCnG zgkked1rxU&ouO!jq1UYl+{^p5JL(|#)e4$(X`c8PaD@-Re%>f20~&@w?}lPa#)u9q zS7|+Js{0jJiI-rQicC}TduZqWLw~lfg}hLYaHVj@X7^hn%3UHHC`R0)HCAp;O|tnO zt2vSg;qI*2Gw_4{e*aVTluR- zs*f?0{Mar3DXeYs?7#;ikoiO~LYL_aUI?kuOd}%MH>qutT?aw}09Og5*>stAw0&0Y zx@*?y-q*)!%^)^5|IRRsTA2tMKjvfCCH_tfp77R(n&4jJ=kFpMMPO;teX7 zpqiyQ#y`mt>J@d|_I5K|Tj-h}lCqfo{${ER%CuEq)iDg&Y$I~_SDVghYInYAc|vno zJ8M8eV)lBKogxKubvGSwAWpYL0p4=*Q0;S@)qps&|2d#dRkBtMHUhZg=Lew8egI#h zPhg7S!uLz(-x__*|LiN+n{-R#12Fva7?;xZzF6(!_EkS~w$J6t3p|_-Y`e3=YEm;7J?9Hp=UwvKKNwqU5V(nLna3 z*$*;pd^7Nq4I9#|RR8 z$Prw`nU)t|Y>IxPkplt%^zJ`I6Fvyu^*TcgZoi&GdWzZpQS#Hf!2iWJT8jq0$GWC7 zy}?9yJ7^ES`oTMRxpqA4(Hy`Yn1O(%GQ;vKv1d137WxZ}*~VTq0K%f}dCQ^{-WvyT z^O?#$c{9>PM>SH5m>9QnME8S0C~q^i&C!|&Gk@r3i}9*vjXU0Hxjibx;wTm3jK z2+VO;1iSCr5)?)dRR|yr^_D8r3XM4v#Ja)uit*;fvwyfr!uNaJik1R~=eihMc$cI- zKwxCPyB+g(;eVWX^&e$w`-R_L9h>^*CITM4*|*Op60B{JKKIkt)XnCa_lnQ4k8TNuazKOs_%_|d#>SI& zX9pZ=g@7?}`5SF}B^+OU*l*qph9PUV`h#{}bk4pHSZpD#Jq>3}WH>@4|Ntn*T z`?X)UN^f}QWiosBkYN~IxN@aL5b3=vOJAmFIqZa%64Gg%^FAGbwv~k-wcXwB@u}bea!)7<<)aK&I=$j;Cq*+pZ-@a0p9sP(Q-vhw0g_sP0 z>Nkni+KNwii_DAx+r1WWj6rxf`UY27YeNwssaK-Tm8CL`+*@zW$h!vcOF+MBR2Sbe zYyUXr11qdflo%r7J$j*bj~?LEm7CJSlxF9;T{k zEfzZxR{iAItOK1-Vu)G+X(>`0Ch6!cvC>6D5{mopc^+fEM>VKfeO}-T{7++w2D97e zHBCcL^ruittr$KSaYO)`8!^5DNydx3cDp<%y3jbFn#pJyFa8<<{i77E^PjE|NN_!9c2dd{V2p_5%Dx0Yj?6LWS1J(^#pMPO#abbO-q|;<#FES3p!Es{ey6Vg#j~y zmtpp4QLXEobz(u1D{O1Ljrh2m$6=~`GEAgTFyEAu8(-eDdCtwwz3J=@&pqamC^owA z>Lqi=?(ZOw^`;eyE|=wQ4*Xf=qI?#K4QF!oUV4GQiX@cJrz5_cNPsE7F#DbJA>X#6 tq{G51>!McAE#IGiiFf$_`pbV=y2wko@gB{uTY&$a5R(;6J$m}${{b' + # value: 'envoy-default-inference-gateway-6454a873.envoy-gateway-system.svc.cluster.local' + - name: REQUEST_RATES + value: '40,80,120,160,200' + - name: BENCHMARK_TIME_SECONDS + value: '60' + - name: TOKENIZER + value: 'meta-llama/Llama-2-7b-hf' + - name: MODELS + value: 'meta-llama/Llama-2-7b-hf' + - name: BACKEND + value: vllm + - name: PORT + value: "8081" + - name: INPUT_LENGTH + value: "1024" + - name: OUTPUT_LENGTH + value: '2048' + - name: FILE_PREFIX + value: benchmark + - name: PROMPT_DATASET_FILE + value: ShareGPT_V3_unfiltered_cleaned_split.json + - name: HF_TOKEN + valueFrom: + secretKeyRef: + key: token + name: hf-token + resources: + limits: + cpu: "2" + memory: 20Gi + requests: + cpu: "2" + memory: 20Gi diff --git a/benchmark/manifests/BenchmarkK8sService.yaml b/benchmark/manifests/BenchmarkK8sService.yaml new file mode 100644 index 000000000..4fcf210ff --- /dev/null +++ b/benchmark/manifests/BenchmarkK8sService.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: benchmark-tool + name: benchmark-tool +spec: + replicas: 1 + selector: + matchLabels: + app: benchmark-tool + template: + metadata: + labels: + app: benchmark-tool + spec: + containers: + - image: 'us-docker.pkg.dev/cloud-tpu-images/inference/inference-benchmark@sha256:1c100b0cc949c7df7a2db814ae349c790f034b4b373aaad145e77e815e838438' + imagePullPolicy: Always + name: benchmark-tool + command: + - bash + - -c + - ./latency_throughput_curve.sh + env: + - name: IP + value: 'my-pool-service.default.svc.cluster.local' + - name: REQUEST_RATES + value: '40,80,120,160,200' + - name: BENCHMARK_TIME_SECONDS + value: '60' + - name: TOKENIZER + value: 'meta-llama/Llama-2-7b-hf' + - name: MODELS + value: 'meta-llama/Llama-2-7b-hf' + - name: BACKEND + value: vllm + - name: PORT + value: "8081" + - name: INPUT_LENGTH + value: "1024" + - name: OUTPUT_LENGTH + value: '2048' + - name: FILE_PREFIX + value: benchmark + - name: PROMPT_DATASET_FILE + value: ShareGPT_V3_unfiltered_cleaned_split.json + - name: HF_TOKEN + valueFrom: + secretKeyRef: + key: token + name: hf-token + resources: + limits: + cpu: "2" + memory: 20Gi + requests: + cpu: "2" + memory: 20Gi diff --git a/benchmark/manifests/ModelServerService.yaml b/benchmark/manifests/ModelServerService.yaml new file mode 100644 index 000000000..014054cf8 --- /dev/null +++ b/benchmark/manifests/ModelServerService.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-pool-service +spec: + ports: + - port: 8081 + protocol: TCP + targetPort: 8000 + selector: + app: my-pool + type: LoadBalancer diff --git a/benchmark/requirements.txt b/benchmark/requirements.txt new file mode 100644 index 000000000..44974cf43 --- /dev/null +++ b/benchmark/requirements.txt @@ -0,0 +1,3 @@ +pandas +numpy +matplotlib \ No newline at end of file From 3c5965f1b952cb26fc933f697ab362a5f402fe66 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 17 Mar 2025 16:11:27 -0700 Subject: [PATCH 2/5] Address comments --- benchmark/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/benchmark/README.md b/benchmark/README.md index e2a44a363..163c3ab7f 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -46,7 +46,8 @@ of the gateway. Feel free to adjust other parameters such as request_rates as we 1. Wait for benchmark to finish and download the results. Use the `benchmark_id` environment variable to specify what this benchmark is for. In this case, the result is for the `inference-extension`. You -can use any id you like. +can use any id you like. When the LPG tool finishes benchmarking, it will print a log line `LPG_FINISHED`, +the script below will watch for that log line and then start downloading results. ```bash benchmark_id='inference-extension' ./download-benchmark-results.bash @@ -80,6 +81,11 @@ of the service. Feel free to adjust other parameters such as **request_rates** a * You can specify `run_id="runX"` environment variable when running the `./download-benchmark-results.bash` script. This is useful when you run benchmarks multiple times and group the results accordingly. +* Update the `request_rates` that best suit your benchmark environment. + +## Advanced Benchmark Configurations + +Pls refer to https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark for a detailed list of configuration knobs. ## Analyze the results From 125dcdbc31275e3b380e62564c3c77387ba23795 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 18 Mar 2025 10:37:46 -0700 Subject: [PATCH 3/5] Move benchmark guide to site-src and other cleanups --- benchmark/README.md | 111 +----------------- ...ension_Benchmark.ipynb => benchmark.ipynb} | 0 benchmark/download-benchmark-results.bash | 3 +- .../BenchmarkInferenceExtension.yaml | 60 ---------- .../manifests/benchmark/benchmark.yaml | 4 +- .../benchmark/model-server-service.yaml | 0 mkdocs.yml | 2 + .../benchmark/example-bar-chart.png | Bin site-src/performance/benchmark/index.md | 98 ++++++++++++++++ 9 files changed, 105 insertions(+), 173 deletions(-) rename benchmark/{Inference_Extension_Benchmark.ipynb => benchmark.ipynb} (100%) delete mode 100644 benchmark/manifests/BenchmarkInferenceExtension.yaml rename benchmark/manifests/BenchmarkK8sService.yaml => config/manifests/benchmark/benchmark.yaml (93%) rename benchmark/manifests/ModelServerService.yaml => config/manifests/benchmark/model-server-service.yaml (100%) rename benchmark/image.png => site-src/performance/benchmark/example-bar-chart.png (100%) create mode 100644 site-src/performance/benchmark/index.md diff --git a/benchmark/README.md b/benchmark/README.md index 163c3ab7f..ffd3ee7b6 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,110 +1 @@ -# Benchmark - -This user guide shows how to run benchmarks against a vLLM deployment, by using both the Gateway API -inference extension, and a Kubernetes service as the load balancing strategy. The -benchmark uses the [Latency Profile Generator](https://github.com/AI-Hypercomputer/inference-benchmark) (LPG) -tool to generate load and collect results. - -## Prerequisites - -### Deploy the inference extension and sample model server - -Follow this user guide https://gateway-api-inference-extension.sigs.k8s.io/guides/ to deploy the -sample vLLM application, and the inference extension. - -### [Optional] Scale the sample vLLM deployment - -You will more likely to see the benefits of the inference extension when there are a decent number of replicas to make the optimal routing decision. - -```bash -kubectl scale deployment my-pool --replicas=8 -``` - -### Expose the model server via a k8s service - -As the baseline, let's also expose the vLLM deployment as a k8s service by simply applying the yaml: - -```bash -kubectl apply -f .manifests/ModelServerService.yaml -``` - -## Run benchmark - -### Run benchmark using the inference extension as the load balancing strategy - -1. Get the gateway IP: - - ```bash - IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') - echo "Update the in ./manifests/BenchmarkInferenceExtension.yaml to: $IP" - ``` - -1. Then update the `` in `./manifests/BenchmarkInferenceExtension.yaml` to the IP -of the gateway. Feel free to adjust other parameters such as request_rates as well. - -1. Start the benchmark tool. `kubectl apply -f ./manifests/BenchmarkInferenceExtension.yaml` - -1. Wait for benchmark to finish and download the results. Use the `benchmark_id` environment variable -to specify what this benchmark is for. In this case, the result is for the `inference-extension`. You -can use any id you like. When the LPG tool finishes benchmarking, it will print a log line `LPG_FINISHED`, -the script below will watch for that log line and then start downloading results. - - ```bash - benchmark_id='inference-extension' ./download-benchmark-results.bash - ``` - -1. After the script finishes, you should see benchmark results under `./output/default-run/inference-extension/results/json` folder. - -### Run benchmark using k8s service as the load balancing strategy - -1. Get the service IP: - - ```bash - IP=$(kubectl get service/my-pool-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') - echo "Update the in ./manifests/BenchmarkK8sService.yaml to: $IP" - ``` - -2. Then update the `` in `./manifests/BenchmarkK8sService.yaml` to the IP -of the service. Feel free to adjust other parameters such as **request_rates** as well. - -1. Start the benchmark tool. `kubectl apply -f ./manifests/BenchmarkK8sService.yaml` - -2. Wait for benchmark to finish and download the results. - - ```bash - benchmark_id='k8s-svc' ./download-benchmark-results.bash - ``` - -3. After the script finishes, you should see benchmark results under `./output/default-run/k8s-svc/results/json` folder. - -### Tips - -* You can specify `run_id="runX"` environment variable when running the `./download-benchmark-results.bash` script. -This is useful when you run benchmarks multiple times and group the results accordingly. -* Update the `request_rates` that best suit your benchmark environment. - -## Advanced Benchmark Configurations - -Pls refer to https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark for a detailed list of configuration knobs. - -## Analyze the results - -This guide shows how to run the jupyter notebook using vscode. - -1. Create a python virtual environment. - - ```bash - python3 -m venv .venv - source .venv/bin/activate - ``` - -1. Install the dependencies. - - ```bash - pip install -r requirements.txt - ``` - -1. Open the notebook `Inference_Extension_Benchmark.ipynb`, and run each cell. At the end you should - see a bar chart like below: - - ![alt text](image.png) \ No newline at end of file +This folder contains resources to run performance benchmarks. Pls follow the benchmark guide here https://gateway-api-inference-extension.sigs.k8s.io/performance/benchmark. \ No newline at end of file diff --git a/benchmark/Inference_Extension_Benchmark.ipynb b/benchmark/benchmark.ipynb similarity index 100% rename from benchmark/Inference_Extension_Benchmark.ipynb rename to benchmark/benchmark.ipynb diff --git a/benchmark/download-benchmark-results.bash b/benchmark/download-benchmark-results.bash index 01ec8b528..333fc6ccc 100755 --- a/benchmark/download-benchmark-results.bash +++ b/benchmark/download-benchmark-results.bash @@ -26,4 +26,5 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" benchmark_output_dir=${SCRIPT_DIR}/${output_dir}/${run_id}/${benchmark_id} echo "Saving benchmark results to ${benchmark_output_dir}/results/json/" -download_benchmark_results \ No newline at end of file +download_benchmark_results +kubectl delete -f ${SCRIPT_DIR}/../config/manifests/benchmark/benchmark.yaml \ No newline at end of file diff --git a/benchmark/manifests/BenchmarkInferenceExtension.yaml b/benchmark/manifests/BenchmarkInferenceExtension.yaml deleted file mode 100644 index e1f77ec73..000000000 --- a/benchmark/manifests/BenchmarkInferenceExtension.yaml +++ /dev/null @@ -1,60 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: benchmark-tool - name: benchmark-tool -spec: - replicas: 1 - selector: - matchLabels: - app: benchmark-tool - template: - metadata: - labels: - app: benchmark-tool - spec: - containers: - - image: 'us-docker.pkg.dev/cloud-tpu-images/inference/inference-benchmark@sha256:1c100b0cc949c7df7a2db814ae349c790f034b4b373aaad145e77e815e838438' - imagePullPolicy: Always - name: benchmark-tool - command: - - bash - - -c - - ./latency_throughput_curve.sh - env: - - name: IP - value: '' - # value: 'envoy-default-inference-gateway-6454a873.envoy-gateway-system.svc.cluster.local' - - name: REQUEST_RATES - value: '40,80,120,160,200' - - name: BENCHMARK_TIME_SECONDS - value: '60' - - name: TOKENIZER - value: 'meta-llama/Llama-2-7b-hf' - - name: MODELS - value: 'meta-llama/Llama-2-7b-hf' - - name: BACKEND - value: vllm - - name: PORT - value: "8081" - - name: INPUT_LENGTH - value: "1024" - - name: OUTPUT_LENGTH - value: '2048' - - name: FILE_PREFIX - value: benchmark - - name: PROMPT_DATASET_FILE - value: ShareGPT_V3_unfiltered_cleaned_split.json - - name: HF_TOKEN - valueFrom: - secretKeyRef: - key: token - name: hf-token - resources: - limits: - cpu: "2" - memory: 20Gi - requests: - cpu: "2" - memory: 20Gi diff --git a/benchmark/manifests/BenchmarkK8sService.yaml b/config/manifests/benchmark/benchmark.yaml similarity index 93% rename from benchmark/manifests/BenchmarkK8sService.yaml rename to config/manifests/benchmark/benchmark.yaml index 4fcf210ff..943f5101c 100644 --- a/benchmark/manifests/BenchmarkK8sService.yaml +++ b/config/manifests/benchmark/benchmark.yaml @@ -24,9 +24,9 @@ spec: - ./latency_throughput_curve.sh env: - name: IP - value: 'my-pool-service.default.svc.cluster.local' + value: '' - name: REQUEST_RATES - value: '40,80,120,160,200' + value: '10,20,30' - name: BENCHMARK_TIME_SECONDS value: '60' - name: TOKENIZER diff --git a/benchmark/manifests/ModelServerService.yaml b/config/manifests/benchmark/model-server-service.yaml similarity index 100% rename from benchmark/manifests/ModelServerService.yaml rename to config/manifests/benchmark/model-server-service.yaml diff --git a/mkdocs.yml b/mkdocs.yml index 8cd3f3fba..fc4c94388 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,8 @@ nav: - Adapter Rollout: guides/adapter-rollout.md - Metrics: guides/metrics.md - Implementer's Guide: guides/implementers.md + - Performance: + - Benchmark: performance/benchmark/index.md - Reference: - API Reference: reference/spec.md - API Types: diff --git a/benchmark/image.png b/site-src/performance/benchmark/example-bar-chart.png similarity index 100% rename from benchmark/image.png rename to site-src/performance/benchmark/example-bar-chart.png diff --git a/site-src/performance/benchmark/index.md b/site-src/performance/benchmark/index.md new file mode 100644 index 000000000..97ca173f2 --- /dev/null +++ b/site-src/performance/benchmark/index.md @@ -0,0 +1,98 @@ +# Benchmark + +This user guide shows how to run benchmarks against a vLLM deployment, by using both the Gateway API +inference extension, and a Kubernetes service as the load balancing strategy. The +benchmark uses the [Latency Profile Generator](https://github.com/AI-Hypercomputer/inference-benchmark) (LPG) +tool to generate load and collect results. + +## Prerequisites + +### Deploy the inference extension and sample model server + +Follow this user guide https://gateway-api-inference-extension.sigs.k8s.io/guides/ to deploy the +sample vLLM application, and the inference extension. + +### [Optional] Scale the sample vLLM deployment + +You will more likely to see the benefits of the inference extension when there are a decent number of replicas to make the optimal routing decision. + +```bash +kubectl scale --replicas=8 -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml +``` + +### Expose the model server via a k8s service + +As the baseline, let's also expose the vLLM deployment as a k8s service: + +```bash +kubectl expose -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml --port=8081 --target-port=8000 --type=LoadBalancer +``` + +## Run benchmark + +The LPG benchmark tool works by sending traffic to the specified target IP and port, and collect results. Follow the steps below to run a single benchmark. You can deploy multiple LPG instances if you want to run benchmarks in parallel against different targets. + +1. Check out the repo. + + ```bash + git clone https://github.com/kubernetes-sigs/gateway-api-inference-extension + cd gateway-api-inference-extension + ``` + +1. Get the target IP. Examples below show how to get the IP of a gateway or a LoadBalancer k8s service. + + ```bash + # Get gateway IP + GW_IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') + # Get LoadBalancer k8s service IP + SVC_IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') + + echo $GW_IP + echo $SVC_IP + ``` + +1. Then update the `` in `./config/manifests/benchmark/benchmark.yaml` to your target IP. Feel free to adjust other parameters such as request_rates as well. For a complete list of LPG configurations, pls refer to the [LPG user guide](https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark). + +1. Start the benchmark tool. `kubectl apply -f ./config/manifests/benchmark/benchmark.yaml` + +1. Wait for benchmark to finish and download the results. Use the `benchmark_id` environment variable +to specify what this benchmark is for. For instance, `inference-extension` or `k8s-svc`. When the LPG tool finishes benchmarking, it will print a log line `LPG_FINISHED`, +the script below will watch for that log line and then start downloading results. + + ```bash + benchmark_id='my-benchmark' ./benchmark/download-benchmark-results.bash + ``` + +1. After the script finishes, you should see benchmark results under `./benchmark/output/default-run/my-benchmark/results/json` folder. + +### Tips + +* You can specify `run_id="runX"` environment variable when running the `./download-benchmark-results.bash` script. +This is useful when you run benchmarks multiple times to get a more statistically meaningful results and group the results accordingly. +* Update the `request_rates` that best suit your benchmark environment. + +### Advanced Benchmark Configurations + +Pls refer to https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark for a detailed list of configuration knobs. + +## Analyze the results + +This guide shows how to run the jupyter notebook using vscode. + +1. Create a python virtual environment. + + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` + +1. Install the dependencies. + + ```bash + pip install -r ./benchmark/requirements.txt + ``` + +1. Open the notebook `./benchmark/benchmark.ipynb`, and run each cell. At the end you should + see a bar chart like below: + + ![alt text](example-bar-chart.png) \ No newline at end of file From bc28eefa331fb8da0d8cf95d86bbc8a0e6789fcc Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 18 Mar 2025 10:39:58 -0700 Subject: [PATCH 4/5] Add source code link for the benchmark tool image --- config/manifests/benchmark/benchmark.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/manifests/benchmark/benchmark.yaml b/config/manifests/benchmark/benchmark.yaml index 943f5101c..a47b46175 100644 --- a/config/manifests/benchmark/benchmark.yaml +++ b/config/manifests/benchmark/benchmark.yaml @@ -15,6 +15,7 @@ spec: app: benchmark-tool spec: containers: + # The following image was built from this source https://github.com/AI-Hypercomputer/inference-benchmark/tree/07628c9fe01b748f5a4cc9e5c2ee4234aaf47699 - image: 'us-docker.pkg.dev/cloud-tpu-images/inference/inference-benchmark@sha256:1c100b0cc949c7df7a2db814ae349c790f034b4b373aaad145e77e815e838438' imagePullPolicy: Always name: benchmark-tool From dda25ac1400c3d03188eecc571e1cc60da44472b Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 18 Mar 2025 14:29:49 -0700 Subject: [PATCH 5/5] Address nit --- site-src/performance/benchmark/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/performance/benchmark/index.md b/site-src/performance/benchmark/index.md index 97ca173f2..445729a6d 100644 --- a/site-src/performance/benchmark/index.md +++ b/site-src/performance/benchmark/index.md @@ -73,7 +73,7 @@ This is useful when you run benchmarks multiple times to get a more statisticall ### Advanced Benchmark Configurations -Pls refer to https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark for a detailed list of configuration knobs. +Pls refer to the [LPG user guide](https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark) for a detailed list of configuration knobs. ## Analyze the results