From ac2a2e9141c7b20c8af80fc48f02e4d70f51129f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Apr 2025 17:00:12 +0000 Subject: [PATCH 01/59] [Automated Commit] Format Codebase --- tools/submission/submission_checker.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/submission/submission_checker.py b/tools/submission/submission_checker.py index 41116e2620..edda676c9c 100755 --- a/tools/submission/submission_checker.py +++ b/tools/submission/submission_checker.py @@ -2094,7 +2094,8 @@ def log_result( if filter_submitter and submitter != filter_submitter: continue results_path = os.path.join(division, submitter, "results") - measurements_path = os.path.join(division, submitter, "measurements") + measurements_path = os.path.join( + division, submitter, "measurements") systems_path = os.path.join(division, submitter, "systems") if not os.path.exists(results_path): continue @@ -2200,7 +2201,8 @@ def log_result( extra_model_mapping = json.load(fp) if not config.skip_all_systems_with_results: - measurement_diff = list(set(list_dir(measurements_path)) - set(list_dir(results_path))) + measurement_diff = list( + set(list_dir(measurements_path)) - set(list_dir(results_path))) systems_diff = list( set( [ @@ -3173,7 +3175,7 @@ def main(): args.extra_model_benchmark_map, ignore_uncommited=args.submission_exceptions, skip_power_check=args.skip_power_check, - skip_all_systems_with_results = args.skip_all_systems_have_results_check + skip_all_systems_with_results=args.skip_all_systems_have_results_check ) if args.scenarios_to_skip: From bcd2cd5d3a378c89dec8ad51e9dafb00e8924bea Mon Sep 17 00:00:00 2001 From: Arjun Suresh Date: Thu, 26 Jun 2025 23:58:01 +0100 Subject: [PATCH 02/59] Update publish.yaml --- .github/workflows/publish.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b07e08c5ae..69766736ad 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -11,6 +11,7 @@ on: - docs - dev + jobs: publish: From 3487f3c2195c8960b174b4b5f506c226b0f2f850 Mon Sep 17 00:00:00 2001 From: Arjun Suresh Date: Fri, 27 Jun 2025 00:01:38 +0100 Subject: [PATCH 03/59] Update mkdocs.yml --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index a0ac88ef98..adfce765a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,7 @@ site_name: MLPerf Inference Documentation repo_url: https://github.com/mlcommons/inference +site_url: https://docs.mlcommons.org/inference + theme: name: material logo: img/logo_v2.svg From feeb3d8684dab49684fb7e036147e349b4c27c34 Mon Sep 17 00:00:00 2001 From: Arjun Suresh Date: Thu, 3 Jul 2025 23:41:05 +0100 Subject: [PATCH 04/59] Update index.md --- docs/install/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install/index.md b/docs/install/index.md index a3bf002e38..97224f829b 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -24,8 +24,8 @@ source mlc/bin/activate === "Use custom fork/branch of the MLC-Scripts repository" ```bash - pip install mlcflow && mlc pull repo --url=mlcommons@cm4mlops --branch=mlperf-inference + pip install mlcflow && mlc pull repo --url=mlcommons@mlperf-automations --branch=dev ``` - Here, `repo` is in the format `githubUsername@githubRepo`. + Here, `repo` is in the format `githubUsername@githubRepo` or you can give any URL Now, you are ready to use the `mlcr` commands to run MLPerf inference as given in the [benchmarks](../index.md) page From 261aac870a85c47cdfbe9a6fa3e276e9c794d5d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Jul 2025 22:11:37 +0000 Subject: [PATCH 05/59] [Automated Commit] Format Codebase --- tools/submission/submission_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/submission/submission_checker.py b/tools/submission/submission_checker.py index feeb0e41ae..291f07476a 100755 --- a/tools/submission/submission_checker.py +++ b/tools/submission/submission_checker.py @@ -498,7 +498,7 @@ "rgat": ("acc", 0.7286 * 0.99), "pointpainting": ("mAP", 0.5425 * 0.999), "deepseek-r1": ("exact_match", 0.99 * 81.6773, "TOKENS_PER_SAMPLE", 0.9 * 4043.449), - "whisper": ("ACCURACY", (100.0-2.0671) * 0.99), + "whisper": ("ACCURACY", (100.0 - 2.0671) * 0.99), }, "accuracy-upper-limit": { "stable-diffusion-xl": ( From ae902182f171cf79e1d3c7cba0f4b48728e14bc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Jul 2025 13:52:36 +0000 Subject: [PATCH 06/59] [Automated Commit] Format Codebase --- compliance/nvidia/TEST06/run_verification.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compliance/nvidia/TEST06/run_verification.py b/compliance/nvidia/TEST06/run_verification.py index 70e16f5266..cae64b3f47 100644 --- a/compliance/nvidia/TEST06/run_verification.py +++ b/compliance/nvidia/TEST06/run_verification.py @@ -53,7 +53,12 @@ def get_args(): "--scenario", "-s", required=True, - choices=["Offline", "Server", "Interactive", "SingleStream", "MultiStream"], + choices=[ + "Offline", + "Server", + "Interactive", + "SingleStream", + "MultiStream"], ) args = parser.parse_args() return args From db42e7f338985436bb0e4ce49a17fb4842ebed23 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 18:35:53 +0000 Subject: [PATCH 07/59] [Automated Commit] Format Codebase --- language/deepseek-r1/eval_accuracy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language/deepseek-r1/eval_accuracy.py b/language/deepseek-r1/eval_accuracy.py index bf537e9d3a..9c103fdcba 100644 --- a/language/deepseek-r1/eval_accuracy.py +++ b/language/deepseek-r1/eval_accuracy.py @@ -773,7 +773,7 @@ def print_evaluation_results(df_evaluated: pd.DataFrame, 'tokens_per_sample': mean_output_len, 'num-samples': len(df_evaluated), } - + print("\nResults\n") print(results) From 6b20f54fdbc72a4e7d9f16a568bd9e7307bb5e01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 29 Jul 2025 15:52:51 +0000 Subject: [PATCH 08/59] [Automated Commit] Format Codebase --- language/llama3.1-8b/download_cnndm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/language/llama3.1-8b/download_cnndm.py b/language/llama3.1-8b/download_cnndm.py index d8694be720..90c9ad8d7a 100644 --- a/language/llama3.1-8b/download_cnndm.py +++ b/language/llama3.1-8b/download_cnndm.py @@ -100,8 +100,8 @@ def preprocess_function(sample, padding="max_length"): # create list of samples inputs = [] - #print(f"Num samples: {len(sample[text_column])}") - #for i in range(0, len(sample[text_column])): + # print(f"Num samples: {len(sample[text_column])}") + # for i in range(0, len(sample[text_column])): x = dict() x["instruction"] = instruction_template x["input"] = sample[text_column] @@ -109,7 +109,7 @@ def preprocess_function(sample, padding="max_length"): instruction_template[instruction].format_map(x) ) x["output"] = sample[summary_column] - #inputs.append(x) + # inputs.append(x) model_inputs = dict() model_inputs["text"] = x From 90ebc4d396154955dd4ac7137fc5d8e30695ce46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 31 Jul 2025 22:04:39 +0000 Subject: [PATCH 09/59] [Automated Commit] Format Codebase --- tools/submission/submission_checker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/submission/submission_checker.py b/tools/submission/submission_checker.py index f124b808c5..7a0c22c902 100755 --- a/tools/submission/submission_checker.py +++ b/tools/submission/submission_checker.py @@ -1487,7 +1487,8 @@ def check_accuracy_dir(config, model, path, verbose): def extra_check_llm(mlperf_log, scenario, model): if mlperf_log["requested_use_token_latencies"]: if scenario not in ["Server", "Interactive"]: - # For offline, singlestream and multistream no further checks are necessary + # For offline, singlestream and multistream no further checks are + # necessary return True else: limits = LLM_LATENCY_LIMITS[model][scenario] @@ -1887,7 +1888,7 @@ def get_power_metric(config, scenario_fixed, log_path, is_valid, res): samples_per_query = 8 if (scenario_fixed in ["MultiStream"] - ) and scenario in ["SingleStream"]: + ) and scenario in ["SingleStream"]: power_metric = ( avg_power * power_duration * samples_per_query * 1000 / num_queries ) From b7eaf0f2a021ca7bc45cd13bccfc90a18b3ce875 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Oct 2025 21:13:29 +0000 Subject: [PATCH 10/59] [Automated Commit] Format Codebase --- speech2text/accuracy_eval.py | 4 ++-- speech2text/reference_SUT.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/speech2text/accuracy_eval.py b/speech2text/accuracy_eval.py index eb6cc53299..c356ab6398 100644 --- a/speech2text/accuracy_eval.py +++ b/speech2text/accuracy_eval.py @@ -57,12 +57,12 @@ "x", "y", "z", - "'", + "'", "0", "1", "2", "3", - "4", + "4", "5", "6", "7", diff --git a/speech2text/reference_SUT.py b/speech2text/reference_SUT.py index 63d491a00f..0b2f02c490 100644 --- a/speech2text/reference_SUT.py +++ b/speech2text/reference_SUT.py @@ -90,12 +90,12 @@ def get_start_cores(start_cores="0"): "x", "y", "z", - "'", + "'", "0", "1", "2", "3", - "4", + "4", "5", "6", "7", From ac2ca44d4d38fa7a057905b8e7dcd7eb72cb5126 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Nov 2025 16:56:53 +0000 Subject: [PATCH 11/59] [Automated Commit] Format Codebase --- tools/submission/preprocess_submission.py | 19 +++++++++++++------ tools/submission/submission_checker.py | 5 ++--- tools/submission/truncate_accuracy_log.py | 3 ++- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/tools/submission/preprocess_submission.py b/tools/submission/preprocess_submission.py index 34043208c8..df3d748c5c 100644 --- a/tools/submission/preprocess_submission.py +++ b/tools/submission/preprocess_submission.py @@ -99,7 +99,7 @@ def delete_empty_dirs(src): return False -def copy_submission_dir(src, dst, filter_submitter, keep_structure = True): +def copy_submission_dir(src, dst, filter_submitter, keep_structure=True): """ Copies the submission tree to output directory for processing """ @@ -116,15 +116,18 @@ def copy_submission_dir(src, dst, filter_submitter, keep_structure = True): ) else: for dir in os.listdir(os.path.join(src, division, submitter)): - if os.path.isdir(os.path.join(src, division, submitter, dir)): - target_dir = "results" if dir in ["compliance", "measurements"] else dir + if os.path.isdir(os.path.join( + src, division, submitter, dir)): + target_dir = "results" if dir in [ + "compliance", "measurements"] else dir shutil.copytree( os.path.join(src, division, submitter, dir), os.path.join(dst, division, submitter, target_dir), - dirs_exist_ok = True + dirs_exist_ok=True ) for file in os.listdir(os.path.join(src, division, submitter)): - if os.path.isfile(os.path.join(src, division, submitter, file)): + if os.path.isfile(os.path.join( + src, division, submitter, file)): shutil.copyfile( os.path.join(src, division, submitter, file), os.path.join(dst, division, submitter, file) @@ -561,7 +564,11 @@ def main(): log.error(f"output directory {args.output} already exists") sys.exit(1) os.makedirs(args.output) - copy_submission_dir(args.input, args.output, args.submitter, args.keep_structure) + copy_submission_dir( + args.input, + args.output, + args.submitter, + args.keep_structure) src_dir = args.output config = checker.Config( diff --git a/tools/submission/submission_checker.py b/tools/submission/submission_checker.py index 335485c33c..5c2801bacb 100755 --- a/tools/submission/submission_checker.py +++ b/tools/submission/submission_checker.py @@ -1061,7 +1061,7 @@ def set_type(self, submission_type): self.optional = self.base["optional-scenarios-datacenter-edge"] else: raise ValueError("invalid system type") - + def skip_calibration(self): return self.skip_calibration_check or self.version in ["v5.0"] @@ -1893,7 +1893,7 @@ def get_power_metric(config, scenario_fixed, log_path, is_valid, res): samples_per_query = 8 if (scenario_fixed in ["MultiStream"] - ) and scenario in ["SingleStream"]: + ) and scenario in ["SingleStream"]: power_metric = ( avg_power * power_duration * samples_per_query * 1000 / num_queries ) @@ -3040,7 +3040,6 @@ def check_measurement_dir( end = len(".json") break - weight_data_types = None if system_file: with open(os.path.join(measurement_dir, system_file), "r") as f: diff --git a/tools/submission/truncate_accuracy_log.py b/tools/submission/truncate_accuracy_log.py index 6c1267fdf8..87bba5ab98 100755 --- a/tools/submission/truncate_accuracy_log.py +++ b/tools/submission/truncate_accuracy_log.py @@ -172,7 +172,8 @@ def truncate_results_dir(filter_submitter, backup, scenarios_to_skip): acc_path, "accuracy.txt") # only TEST01 has an accuracy log - if str(test).startswith("TEST") and test != "TEST01": + if str(test).startswith( + "TEST") and test != "TEST01": continue if not os.path.exists(acc_log): log.error("%s missing", acc_log) From 7499b065410b8dfc460f1444950f94dfd1d189fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Dec 2025 22:15:35 +0000 Subject: [PATCH 12/59] [Automated Commit] Format Codebase --- language/deepseek-r1/backends/sglang_backend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/language/deepseek-r1/backends/sglang_backend.py b/language/deepseek-r1/backends/sglang_backend.py index 8efc0d76d2..010cc6dd7e 100644 --- a/language/deepseek-r1/backends/sglang_backend.py +++ b/language/deepseek-r1/backends/sglang_backend.py @@ -126,7 +126,8 @@ def _build_server_command(self) -> List[str]: # Add optimization flags if self.config['enable_speculative_decode']: - cmd.extend(['--speculative-algorithm', self.config['speculative_algorithm']]) + cmd.extend(['--speculative-algorithm', + self.config['speculative_algorithm']]) cmd.extend(['--speculative-num-steps', str(self.config['speculative_num_steps'])]) cmd.extend(['--speculative-eagle-topk', From a0f5136aec770258292d3dd312c83e5cf70b88ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Dec 2025 17:13:54 +0000 Subject: [PATCH 13/59] [Automated Commit] Format Codebase --- .../wan2.2-t2v-14b/download_model.py | 26 +++--- .../wan2.2-t2v-14b/run_evaluation.py | 60 +++++++------ text_to_video/wan2.2-t2v-14b/run_inference.py | 88 ++++++++++--------- 3 files changed, 94 insertions(+), 80 deletions(-) diff --git a/text_to_video/wan2.2-t2v-14b/download_model.py b/text_to_video/wan2.2-t2v-14b/download_model.py index d0efd0b321..cdcc14cf8c 100755 --- a/text_to_video/wan2.2-t2v-14b/download_model.py +++ b/text_to_video/wan2.2-t2v-14b/download_model.py @@ -16,29 +16,31 @@ sys.exit(1) -def download_model(download_path: str, model_name: str = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"): +def download_model(download_path: str, + model_name: str = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"): """ Download Wan T2V model from HuggingFace. - + Args: download_path: Directory to download the model model_name: HuggingFace model identifier """ download_path = Path(download_path).resolve() - # Extract model name without org prefix (e.g., "Wan-AI/Wan2.2-T2V-A14B-Diffusers" -> "Wan2.2-T2V-A14B-Diffusers") + # Extract model name without org prefix (e.g., + # "Wan-AI/Wan2.2-T2V-A14B-Diffusers" -> "Wan2.2-T2V-A14B-Diffusers") model_dir_name = model_name.split("/")[-1] model_path = download_path / model_dir_name - + # Create download directory download_path.mkdir(parents=True, exist_ok=True) - + print("=" * 60) print(f"{model_name} Model Download") print("=" * 60) print(f"Download path: {model_path}") print("=" * 60) print() - + try: print("Starting download...") snapshot_download( @@ -47,13 +49,13 @@ def download_model(download_path: str, model_name: str = "Wan-AI/Wan2.2-T2V-A14B local_dir_use_symlinks=False, resume_download=True, ) - + print() print("=" * 60) print("? Download completed successfully!") print("=" * 60) print(f"Model location: {model_path}") - + except Exception as e: print() print("=" * 60) @@ -67,21 +69,21 @@ def main(): parser = argparse.ArgumentParser( description="Download Wan2.2 T2V-A14B-Diffusers model from HuggingFace", ) - + parser.add_argument( "-d", "--download-path", default=os.environ.get("DOWNLOAD_PATH", "./models"), help="Download directory (default: ./models or $DOWNLOAD_PATH)" ) - + parser.add_argument( "--model-name", default="Wan-AI/Wan2.2-T2V-A14B-Diffusers", help="HuggingFace model identifier (default: Wan-AI/Wan2.2-T2V-A14B-Diffusers)" ) - + args = parser.parse_args() - + download_model(args.download_path, args.model_name) diff --git a/text_to_video/wan2.2-t2v-14b/run_evaluation.py b/text_to_video/wan2.2-t2v-14b/run_evaluation.py index f1b6352d2f..b925be68aa 100755 --- a/text_to_video/wan2.2-t2v-14b/run_evaluation.py +++ b/text_to_video/wan2.2-t2v-14b/run_evaluation.py @@ -12,6 +12,7 @@ import sys from pathlib import Path + def setup_logging(): """Setup logging configuration.""" logging.basicConfig( @@ -24,36 +25,37 @@ def setup_logging(): def parse_results(output_path): """ Parse VBench evaluation results and print summary. - + Args: output_path: Path to evaluation results directory """ output_path = Path(output_path) - + # Find the most recent eval_results file (contains scores) result_files = sorted(output_path.glob("results_*_eval_results.json")) if not result_files: logging.warning(f"No results found in {output_path}") return - + result_file = result_files[-1] - + try: with open(result_file, 'r') as f: results = json.load(f) - + # Print summary in MLPerf-style format - print("\n" + "="*60) + print("\n" + "=" * 60) print("VBench Evaluation Results") - print("="*60) - - # Extract dimension scores (VBench format: {dimension_name: [avg_score, [video_results]], ...}) + print("=" * 60) + + # Extract dimension scores (VBench format: {dimension_name: [avg_score, + # [video_results]], ...}) if results: print("\nDimension Scores:") print("-" * 60) total_score = 0 num_dimensions = 0 - + for dimension, value in sorted(results.items()): # VBench stores [avg_score, list_of_video_results] if isinstance(value, list) and len(value) > 0: @@ -62,16 +64,16 @@ def parse_results(output_path): total_score += score num_dimensions += 1 print(f" {dimension:30s}: {score:6.4f}") - + if num_dimensions > 0: overall_avg = total_score / num_dimensions print("-" * 60) print(f" {'Overall Average':30s}: {overall_avg:6.4f}") - - print("="*60) + + print("=" * 60) print(f"Detailed results: {result_file}") - print("="*60 + "\n") - + print("=" * 60 + "\n") + except Exception as e: logging.error(f"Failed to parse results: {e}") import traceback @@ -79,7 +81,8 @@ def parse_results(output_path): def main(): - parser = argparse.ArgumentParser(description="VBench evaluation for Wan2.2 T2V videos") + parser = argparse.ArgumentParser( + description="VBench evaluation for Wan2.2 T2V videos") parser.add_argument( "--videos-path", type=str, @@ -111,20 +114,20 @@ def main(): default=8, help="Number of GPUs to use for evaluation (default: 8)" ) - + args = parser.parse_args() - + setup_logging() - + # Validate inputs videos_path = Path(args.videos_path) if not videos_path.exists(): logging.error(f"Videos path does not exist: {videos_path}") return 1 - + output_path = Path(args.output_path) output_path.mkdir(parents=True, exist_ok=True) - + logging.info("=" * 60) logging.info("VBench Evaluation") logging.info("=" * 60) @@ -133,8 +136,9 @@ def main(): logging.info(f"GPUs: {args.num_gpus}") logging.info(f"Dimensions: {', '.join(args.dimensions)}") logging.info("=" * 60) - - vbench_script = Path(__file__).parent / "submodules" / "VBench" / "evaluate.py" + + vbench_script = Path(__file__).parent / "submodules" / \ + "VBench" / "evaluate.py" cmd = [ "python", "-m", "torch.distributed.run", f"--nproc_per_node={args.num_gpus}", @@ -144,21 +148,21 @@ def main(): "--load_ckpt_from_local=True", "--dimension" ] + args.dimensions - + logging.info("\nExecuting VBench evaluation...") logging.info(f"Command: {' '.join(cmd)}") logging.info("") - + # Run evaluation try: result = subprocess.run(cmd, check=True) - + # Parse and print results logging.info("\nParsing evaluation results...") parse_results(output_path) - + return 0 - + except subprocess.CalledProcessError as e: logging.error(f"Evaluation failed with exit code {e.returncode}") return e.returncode diff --git a/text_to_video/wan2.2-t2v-14b/run_inference.py b/text_to_video/wan2.2-t2v-14b/run_inference.py index 43b972800d..801e0c68b4 100755 --- a/text_to_video/wan2.2-t2v-14b/run_inference.py +++ b/text_to_video/wan2.2-t2v-14b/run_inference.py @@ -5,6 +5,10 @@ Supports multi-GPU inference with data parallelism (prompts divided among GPUs). """ +from diffusers.utils import export_to_video +from diffusers import WanPipeline, AutoencoderKLWan +import torch +import yaml import argparse import logging import os @@ -14,13 +18,10 @@ warnings.filterwarnings('ignore') -import yaml -import torch -from diffusers import WanPipeline, AutoencoderKLWan -from diffusers.utils import export_to_video # import modelopt.torch.opt as mto + def setup_logging(rank): """Setup logging configuration for data parallel (all ranks log).""" logging.basicConfig( @@ -50,12 +51,12 @@ def generate_videos(args, config): world_size = int(os.environ.get("WORLD_SIZE", 1)) rank = int(os.environ.get("RANK", 0)) local_rank = int(os.environ.get("LOCAL_RANK", 0)) - + torch.cuda.set_device(local_rank) device = torch.device(f"cuda:{local_rank}") - + setup_logging(rank) - + # Generation parameters from config height = config['height'] width = config['width'] @@ -67,7 +68,7 @@ def generate_videos(args, config): negative_prompt = config['negative_prompt'].strip() sample_steps = config['sample_steps'] base_seed = config['seed'] - + if rank == 0: logging.info(f"Model: Wan2.2 T2V-A14B-Diffusers") logging.info(f"Model path: {args.model_path}") @@ -75,24 +76,25 @@ def generate_videos(args, config): logging.info(f"Sample steps: {sample_steps}") logging.info(f"Base seed: {base_seed}") logging.info(f"Iterations per prompt: {args.num_iterations}") - + all_prompts = load_prompts(args.dataset) - + if rank == 0: logging.info(f"Loaded {len(all_prompts)} prompts from {args.dataset}") - + if args.num_prompts > 0: all_prompts = all_prompts[:args.num_prompts] if rank == 0: logging.info(f"Processing first {args.num_prompts} prompts") - + # Divide prompts among GPUs (data parallelism) prompts = all_prompts[rank::world_size] - logging.info(f"This rank will process {len(prompts)} prompts (indices: {rank}, {rank + world_size}, ...)") - + logging.info( + f"This rank will process {len(prompts)} prompts (indices: {rank}, {rank + world_size}, ...)") + output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + logging.info("Loading Diffusers pipeline...") vae = AutoencoderKLWan.from_pretrained( args.model_path, @@ -120,45 +122,49 @@ def generate_videos(args, config): # logging.info("Quantized model loaded successfully!") - fixed_latent = None if args.fixed_latent: fixed_latent = torch.load(args.fixed_latent) - logging.info(f"Loaded fixed latent from {args.fixed_latent} with shape: {fixed_latent.shape}") + logging.info( + f"Loaded fixed latent from {args.fixed_latent} with shape: {fixed_latent.shape}") logging.info(f"This latent will be reused for all generations") else: logging.info("No fixed latent provided - using random initial latents") - + if rank == 0: - logging.info(f"Starting batch generation: {len(all_prompts)} total prompts x {args.num_iterations} iterations") + logging.info( + f"Starting batch generation: {len(all_prompts)} total prompts x {args.num_iterations} iterations") logging.info(f"Each GPU processes ~{len(prompts)} prompts") - - # Generate videos: iterate through all prompts, then repeat for next iteration + + # Generate videos: iterate through all prompts, then repeat for next + # iteration total_videos = 0 for iteration in range(args.num_iterations): if rank == 0: logging.info(f"\n{'='*60}") logging.info(f"ITERATION {iteration + 1}/{args.num_iterations}") logging.info(f"{'='*60}") - + for local_idx, prompt in enumerate(prompts): # Calculate global prompt index global_idx = rank + local_idx * world_size - - logging.info(f"[Prompt {global_idx+1}/{len(all_prompts)}, Iteration {iteration+1}/{args.num_iterations}] {prompt}") - + + logging.info( + f"[Prompt {global_idx+1}/{len(all_prompts)}, Iteration {iteration+1}/{args.num_iterations}] {prompt}") + # Check if video already exists filename = f"{prompt}-{iteration}.mp4" save_path = output_dir / filename - + if save_path.exists(): - logging.info(f"Video already exists at {save_path}, skipping generation") + logging.info( + f"Video already exists at {save_path}, skipping generation") total_videos += 1 continue - + # Generate video with seed based on iteration current_seed = base_seed + iteration - + # Prepare pipeline arguments pipeline_kwargs = { "prompt": prompt, @@ -171,21 +177,22 @@ def generate_videos(args, config): "num_inference_steps": sample_steps, "generator": torch.Generator(device=device).manual_seed(current_seed), } - + # Only pass latents if fixed_latent is provided if fixed_latent is not None: pipeline_kwargs["latents"] = fixed_latent - + output = pipe(**pipeline_kwargs).frames[0] - + # Save video with VBench format: -.mp4 logging.info(f"Saving to {save_path} (seed: {current_seed})") export_to_video(output, str(save_path), fps=25) total_videos += 1 - logging.info(f"Saved! ({total_videos}/{len(prompts) * args.num_iterations} for this GPU)") - + logging.info( + f"Saved! ({total_videos}/{len(prompts) * args.num_iterations} for this GPU)") + torch.cuda.empty_cache() - + logging.info(f"\n{'='*60}") logging.info(f"Batch generation complete for this GPU!") logging.info(f"Generated {total_videos} videos in {output_dir}") @@ -193,8 +200,9 @@ def generate_videos(args, config): def main(): - parser = argparse.ArgumentParser(description="Batch T2V inference with Wan2.2-Diffusers") - + parser = argparse.ArgumentParser( + description="Batch T2V inference with Wan2.2-Diffusers") + parser.add_argument( "--model-path", type=str, @@ -248,11 +256,11 @@ def main(): # default="./models/Wan2.2-T2V-FP8-Torch", # help="Path to quantized model (default: ./models/Wan2.2-T2V-FP8-Torch)" # ) - + args = parser.parse_args() - + config = load_config(args.config) - + generate_videos(args, config) From b3e548e3c565d44ef6681ebd631397987ef078fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 17:36:20 +0000 Subject: [PATCH 14/59] [Automated Commit] Format Codebase --- multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/task.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/task.py b/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/task.py index 5fc3881d95..7d53022a0f 100644 --- a/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/task.py +++ b/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/task.py @@ -67,7 +67,8 @@ def __init__( self.openai_api_client = AsyncOpenAI( base_url=endpoint.url, http_client=DefaultAioHttpClient( - timeout=httpx.Timeout(timeout=request_timeout_seconds, connect=5.0), + timeout=httpx.Timeout( + timeout=request_timeout_seconds, connect=5.0), ), api_key=endpoint.api_key, timeout=request_timeout_seconds, @@ -187,7 +188,9 @@ def estimated_num_performance_samples(self) -> int: """ estimation_indices = random.sample( range(self.total_num_samples), - k=min(MAX_NUM_ESTIMATION_PERFORMANCE_SAMPLES, self.total_num_samples), + k=min( + MAX_NUM_ESTIMATION_PERFORMANCE_SAMPLES, + self.total_num_samples), ) estimation_samples = [ self.formulate_loaded_sample( @@ -274,7 +277,8 @@ def _unload_samples_from_ram(query_sample_indices: list[int]) -> None: _unload_samples_from_ram, ) - async def _query_endpoint_async_batch(self, query_sample: lg.QuerySample) -> None: + async def _query_endpoint_async_batch( + self, query_sample: lg.QuerySample) -> None: """Query the endpoint through the async OpenAI API client.""" try: sample = self.loaded_samples[query_sample.index] @@ -360,7 +364,8 @@ async def _query_endpoint_async_batch(self, query_sample: lg.QuerySample) -> Non ], ) - async def _query_endpoint_async_stream(self, query_sample: lg.QuerySample) -> None: + async def _query_endpoint_async_stream( + self, query_sample: lg.QuerySample) -> None: """Query the endpoint through the async OpenAI API client.""" ttft_set = False try: From bed00ee391476a46c3cb316475dc1a72d7318075 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 18:19:44 +0000 Subject: [PATCH 15/59] [Automated Commit] Format Codebase --- recommendation/dlrm_v3/accuracy.py | 5 +- recommendation/dlrm_v3/checkpoint.py | 21 ++- recommendation/dlrm_v3/configs.py | 3 +- recommendation/dlrm_v3/data_producer.py | 14 +- recommendation/dlrm_v3/datasets/dataset.py | 9 +- .../dlrm_v3/datasets/synthetic_streaming.py | 18 ++- recommendation/dlrm_v3/datasets/utils.py | 4 +- .../dlrm_v3/generative_recommenders/common.py | 16 ++- .../modules/action_encoder.py | 6 +- .../modules/content_encoder.py | 3 +- .../contextual_interleave_preprocessor.py | 15 +- .../modules/contextualize_mlps.py | 3 +- .../modules/dlrm_hstu.py | 18 ++- .../modules/dynamic_stu.py | 3 +- .../modules/hstu_transducer.py | 6 +- .../modules/multitask_module.py | 17 ++- .../modules/postprocessors.py | 15 +- .../modules/preprocessors.py | 6 +- .../generative_recommenders/modules/stu.py | 6 +- .../ops/hstu_attention.py | 18 ++- .../ops/jagged_tensors.py | 4 +- .../ops/pytorch/pt_hstu_attention.py | 15 +- .../ops/pytorch/pt_jagged.py | 18 ++- .../ops/pytorch/pt_jagged_tensors.py | 6 +- .../ops/pytorch/pt_position.py | 3 +- .../ops/triton/triton_addmm.py | 87 ++++++++---- .../ops/triton/triton_hstu_attention.py | 100 ++++++++++---- .../ops/triton/triton_hstu_linear.py | 130 ++++++++++++++---- .../triton_hstu_preprocess_and_attention.py | 3 +- .../ops/triton/triton_jagged.py | 120 ++++++++++------ .../ops/triton/triton_jagged_tensors.py | 36 +++-- .../ops/triton/triton_layer_norm.py | 70 +++++++--- .../ops/triton/triton_position.py | 10 +- recommendation/dlrm_v3/inference_modules.py | 3 +- recommendation/dlrm_v3/main.py | 86 ++++++------ recommendation/dlrm_v3/model_family.py | 18 ++- .../dlrm_v3/streaming_synthetic_data.py | 40 ++++-- recommendation/dlrm_v3/utils.py | 15 +- 38 files changed, 664 insertions(+), 306 deletions(-) diff --git a/recommendation/dlrm_v3/accuracy.py b/recommendation/dlrm_v3/accuracy.py index 5d2d0ff11a..569f3d2476 100644 --- a/recommendation/dlrm_v3/accuracy.py +++ b/recommendation/dlrm_v3/accuracy.py @@ -67,9 +67,10 @@ def main() -> None: num_candidates = data[-1].astype(int) assert len(data) == 1 + num_candidates * 3 mt_target_preds = torch.from_numpy(data[0:num_candidates]) - mt_target_labels = torch.from_numpy(data[num_candidates : num_candidates * 2]) + mt_target_labels = torch.from_numpy( + data[num_candidates: num_candidates * 2]) mt_target_weights = torch.from_numpy( - data[num_candidates * 2 : num_candidates * 3] + data[num_candidates * 2: num_candidates * 3] ) num_candidates = torch.tensor([num_candidates]) metrics.update( diff --git a/recommendation/dlrm_v3/checkpoint.py b/recommendation/dlrm_v3/checkpoint.py index 33dbaf3c58..8d5ca17c60 100644 --- a/recommendation/dlrm_v3/checkpoint.py +++ b/recommendation/dlrm_v3/checkpoint.py @@ -46,7 +46,8 @@ class SparseState(Stateful): sparse_tensor_keys: Set of keys identifying sparse tensors in the model's state dict. """ - def __init__(self, model: torch.nn.Module, sparse_tensor_keys: Set[str]) -> None: + def __init__(self, model: torch.nn.Module, + sparse_tensor_keys: Set[str]) -> None: self.model = model self.sparse_tensor_keys = sparse_tensor_keys @@ -62,7 +63,8 @@ def state_dict(self) -> Dict[str, torch.Tensor]: return out_dict def load_state_dict(self, state_dict: Dict[str, torch.Tensor]) -> None: - incompatible_keys = self.model.load_state_dict(state_dict, strict=False) + incompatible_keys = self.model.load_state_dict( + state_dict, strict=False) assert not incompatible_keys.unexpected_keys @@ -70,9 +72,14 @@ def is_sparse_key(k: str, v: torch.Tensor) -> bool: return isinstance(v, ShardedTensor) or "embedding_collection" in k -def load_dense_state_dict(model: torch.nn.Module, state_dict: Dict[str, Any]) -> None: +def load_dense_state_dict(model: torch.nn.Module, + state_dict: Dict[str, Any]) -> None: own_state = model.state_dict() - own_state_dense_keys = {k for k, v in own_state.items() if not is_sparse_key(k, v)} + own_state_dense_keys = { + k for k, + v in own_state.items() if not is_sparse_key( + k, + v)} state_dict_dense_keys = { k for k, v in state_dict.items() if not is_sparse_key(k, v) } @@ -156,7 +163,8 @@ def save_dmp_checkpoint( sparse_dict = {"sparse_dict": SparseState(model, sparse_tensor_keys)} torch.distributed.checkpoint.save( sparse_dict, - storage_writer=torch.distributed.checkpoint.FileSystemWriter(sparse_path), + storage_writer=torch.distributed.checkpoint.FileSystemWriter( + sparse_path), ) torch.distributed.barrier() print("checkpoint successfully saved") @@ -178,7 +186,8 @@ def load_sparse_checkpoint( gc.collect() torch.distributed.checkpoint.load( sparse_dict, - storage_reader=torch.distributed.checkpoint.FileSystemReader(sparse_path), + storage_reader=torch.distributed.checkpoint.FileSystemReader( + sparse_path), ) gc.collect() print("sparse checkpoint successfully loaded") diff --git a/recommendation/dlrm_v3/configs.py b/recommendation/dlrm_v3/configs.py index 3d053b6512..4e59ed9197 100644 --- a/recommendation/dlrm_v3/configs.py +++ b/recommendation/dlrm_v3/configs.py @@ -114,7 +114,8 @@ def get_hstu_configs(dataset: str = "debug") -> DlrmHSTUConfig: return hstu_config -def get_embedding_table_config(dataset: str = "debug") -> Dict[str, EmbeddingConfig]: +def get_embedding_table_config( + dataset: str = "debug") -> Dict[str, EmbeddingConfig]: """ Create and return embedding table configurations. diff --git a/recommendation/dlrm_v3/data_producer.py b/recommendation/dlrm_v3/data_producer.py index a2b8e18e09..0caefba20c 100644 --- a/recommendation/dlrm_v3/data_producer.py +++ b/recommendation/dlrm_v3/data_producer.py @@ -90,7 +90,8 @@ def enqueue( """ with torch.profiler.record_function("data batching"): t0_batching: float = time.time() - samples: Union[Samples, List[Samples]] = self.ds.get_samples(content_ids) + samples: Union[Samples, List[Samples] + ] = self.ds.get_samples(content_ids) dt_batching: float = time.time() - t0_batching if isinstance(samples, Samples): query = QueryItem( @@ -106,7 +107,7 @@ def enqueue( for sample in samples: batch_size: int = sample.batch_size() query = QueryItem( - query_ids=query_ids[start_idx : start_idx + batch_size], + query_ids=query_ids[start_idx: start_idx + batch_size], samples=sample, start=t0, dt_queue=dt_queue, @@ -148,7 +149,9 @@ def __init__( ) self.workers: List[threading.Thread] = [] for _ in range(self.threads): - worker = threading.Thread(target=self.handle_tasks, args=(self.tasks,)) + worker = threading.Thread( + target=self.handle_tasks, args=( + self.tasks,)) worker.daemon = True self.workers.append(worker) worker.start() @@ -172,7 +175,8 @@ def handle_tasks( break query_ids, content_ids, t0, dt_queue = query_and_content_ids t0_batching: float = time.time() - samples: Union[Samples, List[Samples]] = self.ds.get_samples(content_ids) + samples: Union[Samples, List[Samples] + ] = self.ds.get_samples(content_ids) dt_batching: float = time.time() - t0_batching if isinstance(samples, Samples): qitem = QueryItem( @@ -189,7 +193,7 @@ def handle_tasks( for sample in samples: batch_size: int = sample.batch_size() qitem = QueryItem( - query_ids=query_ids[start_idx : start_idx + batch_size], + query_ids=query_ids[start_idx: start_idx + batch_size], samples=sample, start=t0, dt_queue=dt_queue, diff --git a/recommendation/dlrm_v3/datasets/dataset.py b/recommendation/dlrm_v3/datasets/dataset.py index 495c5836c1..3121e4f51b 100644 --- a/recommendation/dlrm_v3/datasets/dataset.py +++ b/recommendation/dlrm_v3/datasets/dataset.py @@ -204,11 +204,13 @@ def kjt_batch_func( bs_offset = torch.ops.fbgemm.asynchronous_complete_cumsum( torch.tensor(bs_list) ).int() - batched_offset = torch.ops.fbgemm.asynchronous_complete_cumsum(batched_length) + batched_offset = torch.ops.fbgemm.asynchronous_complete_cumsum( + batched_length) reorder_length = torch.ops.fbgemm.reorder_batched_ad_lengths( batched_length, bs_offset, bs ) - reorder_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(reorder_length) + reorder_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum( + reorder_length) reorder_indices = torch.ops.fbgemm.reorder_batched_ad_indices( batched_offset, batched_indices, reorder_offsets, bs_offset, bs ) @@ -345,7 +347,8 @@ def __init__( self.num_aggregated_samples = num_aggregated_samples self.items_in_memory = {} - def get_sample(self, id: int) -> Tuple[KeyedJaggedTensor, KeyedJaggedTensor]: + def get_sample( + self, id: int) -> Tuple[KeyedJaggedTensor, KeyedJaggedTensor]: """ Get a sample by ID from in-memory storage. diff --git a/recommendation/dlrm_v3/datasets/synthetic_streaming.py b/recommendation/dlrm_v3/datasets/synthetic_streaming.py index 8cddcc36d2..d1a1cc14d5 100644 --- a/recommendation/dlrm_v3/datasets/synthetic_streaming.py +++ b/recommendation/dlrm_v3/datasets/synthetic_streaming.py @@ -146,7 +146,8 @@ def load_query_samples(self, sample_list: List[int]) -> None: def unload_query_samples(self, sample_list: List[int]) -> None: self.items_in_memory = {} - def get_sample(self, id: int) -> Tuple[KeyedJaggedTensor, KeyedJaggedTensor]: + def get_sample( + self, id: int) -> Tuple[KeyedJaggedTensor, KeyedJaggedTensor]: return self.items_in_memory[self.ts][id] def get_sample_with_ts( @@ -192,7 +193,8 @@ def _process_line(self, line: str, user_id: int) -> pd.Series: reader = csv.reader([line]) parsed_line = next(reader) # total ts + one more eval ts + one base ts so that uih won't be zero - # for each ts, ordered as candidate_ids, candidate_ratings, uih_ids, uih_ratings + # for each ts, ordered as candidate_ids, candidate_ratings, uih_ids, + # uih_ratings assert len(parsed_line) == 4 * (self.total_ts + 2) uih_item_ids_list = [] uih_ratings_list = [] @@ -290,7 +292,8 @@ def set_ts(self, ts: int) -> None: assert len(row) == 1 requests = json_loads(row[0]) self.requests = requests - logger.warning(f"DLRMv3SyntheticStreamingDataset: ts={ts} requests loaded") + logger.warning( + f"DLRMv3SyntheticStreamingDataset: ts={ts} requests loaded") assert self.ts_to_users_cumsum[self.ts][-1] == len(self.requests) logger.warning( f"DLRMv3SyntheticStreamingDataset: ts={ts} users_cumsum={self.ts_to_users_cumsum[self.ts]}" @@ -336,7 +339,8 @@ def load_item( timestamps_uih = maybe_truncate_seq(timestamps_uih, self._max_uih_len) ids_candidates = maybe_truncate_seq(ids_candidates, max_num_candidates) num_candidates = len(ids_candidates) - ratings_candidates = maybe_truncate_seq(ratings_candidates, max_num_candidates) + ratings_candidates = maybe_truncate_seq( + ratings_candidates, max_num_candidates) action_weights_uih = [ self.action_weights[int(rating) - 1] for rating in ratings_uih ] @@ -366,7 +370,8 @@ def load_item( [ uih_seq_len for _ in range( - len(self._uih_keys) - len(self._contextual_feature_to_max_length) + len(self._uih_keys) - + len(self._contextual_feature_to_max_length) ) ] ) @@ -380,7 +385,8 @@ def load_item( values=torch.tensor(uih_kjt_values).long(), ) - candidates_kjt_lengths = num_candidates * torch.ones(len(self._candidates_keys)) + candidates_kjt_lengths = num_candidates * \ + torch.ones(len(self._candidates_keys)) item_candidate_category_ids = [ id // self.items_per_category for id in ids_candidates ] diff --git a/recommendation/dlrm_v3/datasets/utils.py b/recommendation/dlrm_v3/datasets/utils.py index c85c3cf706..134cd1a2ec 100644 --- a/recommendation/dlrm_v3/datasets/utils.py +++ b/recommendation/dlrm_v3/datasets/utils.py @@ -45,7 +45,7 @@ def json_loads( y = json.loads(x) else: y = x - y_list = [y] if type(y) == int else list(y) + y_list = [y] if isinstance(y, int) else list(y) return y_list @@ -72,7 +72,7 @@ def separate_uih_candidates( y = json.loads(x) else: y = x - y_list = [y] if type(y) == int else list(y) + y_list = [y] if isinstance(y, int) else list(y) candidates, uih = ( y_list[-candidates_max_seq_len:], y_list[:-candidates_max_seq_len], diff --git a/recommendation/dlrm_v3/generative_recommenders/common.py b/recommendation/dlrm_v3/generative_recommenders/common.py index 9ba5821d9f..3b9ca73bb8 100644 --- a/recommendation/dlrm_v3/generative_recommenders/common.py +++ b/recommendation/dlrm_v3/generative_recommenders/common.py @@ -188,7 +188,8 @@ def generate_sparse_seq_len( if sparsity == 0.0: return torch.zeros(size=(size,), device=device, dtype=torch.int) elif sparsity == 1.0: - return torch.ones(size=(size,), device=device, dtype=torch.int) * max_seq_len + return torch.ones(size=(size,), device=device, + dtype=torch.int) * max_seq_len elif sparsity >= 0.5: min_seq_len: int = int((2 * sparsity - 1.0) * max_seq_len) return torch.randint( @@ -265,10 +266,12 @@ def switch_to_contiguous_if_needed(x: torch.Tensor) -> torch.Tensor: def prev_power_of_2(x: int) -> int: if torch.compiler.is_compiling(): # Re-write to make Dynamo happy - x_tensor = torch.scalar_tensor(x, dtype=torch.int64) # type: ignore[arg-type] + x_tensor = torch.scalar_tensor( + x, dtype=torch.int64) # type: ignore[arg-type] x_tensor_orig = x_tensor.clone() out = triton.next_power_of_2(x_tensor) # type: ignore[arg-type] - return int(torch.where(torch.lt(x_tensor_orig, out), out // 2, out).item()) # type: ignore[return-value] + return int(torch.where(torch.lt(x_tensor_orig, out), out // + 2, out).item()) # type: ignore[return-value] else: out = triton.next_power_of_2(x) return out // 2 if out > x else out @@ -340,7 +343,9 @@ def _generate_fine_grained_buckets() -> List[int]: def _fine_grained_bucket_size(x: int) -> int: if torch.compiler.is_compiling(): x_tensor = torch.scalar_tensor(x, dtype=torch.int64) - buckets = torch.tensor(_generate_fine_grained_buckets(), dtype=torch.int64) + buckets = torch.tensor( + _generate_fine_grained_buckets(), + dtype=torch.int64) mask = buckets >= x_tensor valid_buckets = torch.where( @@ -361,7 +366,8 @@ def _fine_grained_bucket_size(x: int) -> int: @torch.fx.wrap -def fx_unwrap_optional_tensor(optional: Optional[torch.Tensor]) -> torch.Tensor: +def fx_unwrap_optional_tensor( + optional: Optional[torch.Tensor]) -> torch.Tensor: assert optional is not None, "Expected optional to be non-None Tensor" return optional diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/action_encoder.py b/recommendation/dlrm_v3/generative_recommenders/modules/action_encoder.py index 0116b99b43..e1282e4015 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/action_encoder.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/action_encoder.py @@ -85,7 +85,8 @@ def forward( watchtimes = seq_payloads[self._watchtime_feature_name] for threshold, weight in self._watchtime_to_action_thresholds_and_weights: seq_actions = torch.bitwise_or( - seq_actions, (watchtimes >= threshold).to(torch.int64) * weight + seq_actions, (watchtimes >= threshold).to( + torch.int64) * weight ) exploded_actions = ( torch.bitwise_and( @@ -94,7 +95,8 @@ def forward( > 0 ) action_embeddings = ( - exploded_actions.unsqueeze(-1) * self._action_embedding_table.unsqueeze(0) + exploded_actions.unsqueeze(-1) * + self._action_embedding_table.unsqueeze(0) ).view(-1, self._num_action_types * self._action_embedding_dim) total_targets: int = seq_embeddings.size(0) - action_embeddings.size(0) action_embeddings = concat_2D_jagged( diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/content_encoder.py b/recommendation/dlrm_v3/generative_recommenders/modules/content_encoder.py index 75d73298a4..303e827f8a 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/content_encoder.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/content_encoder.py @@ -79,7 +79,8 @@ def forward( if self._target_enrich_dummy_embeddings: total_seq_len: int = seq_embeddings.size(0) for name, param in self._target_enrich_dummy_embeddings.items(): - enrich_embeddings_target = seq_payloads[name].to(seq_embeddings.dtype) + enrich_embeddings_target = seq_payloads[name].to( + seq_embeddings.dtype) total_targets: int = enrich_embeddings_target.size(0) total_uih_len: int = total_seq_len - total_targets enrich_embeddings_uih = param.tile(total_uih_len, 1).to( diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/contextual_interleave_preprocessor.py b/recommendation/dlrm_v3/generative_recommenders/modules/contextual_interleave_preprocessor.py index fff0d72f0d..85f8dc21a6 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/contextual_interleave_preprocessor.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/contextual_interleave_preprocessor.py @@ -63,7 +63,8 @@ def __init__( self._contextual_feature_to_min_uih_length: Dict[str, int] = ( contextual_feature_to_min_uih_length ) - std = 1.0 * sqrt(2.0 / float(input_embedding_dim + output_embedding_dim)) + std = 1.0 * \ + sqrt(2.0 / float(input_embedding_dim + output_embedding_dim)) self._batched_contextual_linear_weights = torch.nn.Parameter( torch.empty( ( @@ -141,7 +142,8 @@ def combine_embeddings( valid_mask = torch.logical_and( indices < seq_lengths_by_2.view(-1, 1), torch.logical_or( - indices < (output_seq_lengths - num_targets).view(-1, 1), + indices < (output_seq_lengths - + num_targets).view(-1, 1), torch.remainder(indices, 2) == 0, ), ) @@ -249,7 +251,8 @@ def forward( # noqa C901 with torch.autocast( "cuda", dtype=torch.bfloat16, - enabled=(not self.is_inference and self._training_dtype == torch.bfloat16), + enabled=( + not self.is_inference and self._training_dtype == torch.bfloat16), ): # get contextual_embeddings contextual_embeddings: Optional[torch.Tensor] = None @@ -285,8 +288,10 @@ def forward( # noqa C901 ).transpose(0, 1) # content embeddings - seq_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(seq_lengths) - target_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(num_targets) + seq_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum( + seq_lengths) + target_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum( + num_targets) uih_offsets = seq_offsets - target_offsets content_embeddings = self._content_encoder( max_uih_len=max_uih_len, diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/contextualize_mlps.py b/recommendation/dlrm_v3/generative_recommenders/modules/contextualize_mlps.py index 95c29f0381..1550929aa5 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/contextualize_mlps.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/contextualize_mlps.py @@ -127,7 +127,8 @@ def forward( max_seq_len: int, contextual_embeddings: Optional[torch.Tensor], ) -> torch.Tensor: - shared_input = self._dense_features_compress(none_throws(contextual_embeddings)) + shared_input = self._dense_features_compress( + none_throws(contextual_embeddings)) attn_weights = self._attn_weights_norm( self._attn_raw_weights(shared_input).reshape( -1, self._sequential_input_dim, self._sequential_output_dim diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/dlrm_hstu.py b/recommendation/dlrm_v3/generative_recommenders/modules/dlrm_hstu.py index 003abe77dd..938ea792c2 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/dlrm_hstu.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/dlrm_hstu.py @@ -76,8 +76,10 @@ class DlrmHSTUConfig: hstu_group_norm: bool = False hstu_input_dropout_ratio: float = 0.2 hstu_linear_dropout_rate: float = 0.2 - contextual_feature_to_max_length: Dict[str, int] = field(default_factory=dict) - contextual_feature_to_min_uih_length: Dict[str, int] = field(default_factory=dict) + contextual_feature_to_max_length: Dict[str, int] = field( + default_factory=dict) + contextual_feature_to_min_uih_length: Dict[str, int] = field( + default_factory=dict) candidates_weight_feature_name: str = "" candidates_watchtime_feature_name: str = "" candidates_querytime_feature_name: str = "" @@ -108,7 +110,8 @@ def _get_supervision_labels_and_weights( supervision_weights: Dict[str, torch.Tensor] = {} for task in task_configs: if task.task_type == MultitaskTaskType.REGRESSION: - supervision_labels[task.task_name] = watchtime_sequence.to(torch.float32) + supervision_labels[task.task_name] = watchtime_sequence.to( + torch.float32) elif task.task_type == MultitaskTaskType.BINARY_CLASSIFICATION: supervision_labels[task.task_name] = ( torch.bitwise_and(supervision_bitmasks, task.task_weight) > 0 @@ -292,7 +295,8 @@ def _construct_payload( **{ x + "_offsets": contextual_offsets[i] for i, x in enumerate( - list(self._hstu_configs.contextual_feature_to_max_length.keys()) + list( + self._hstu_configs.contextual_feature_to_max_length.keys()) ) }, **{ @@ -394,7 +398,8 @@ def preprocess( dim=0, ), ) - seq_embeddings_dict = self._embedding_collection(merged_sparse_features) + seq_embeddings_dict = self._embedding_collection( + merged_sparse_features) num_candidates = fx_mark_length_features( candidates_features.lengths().view(len(candidates_features.keys()), -1) )[0] @@ -430,7 +435,8 @@ def preprocess( device=values_left.device, ) else: - values_right = candidates_features[candidate_feature_name].values() + values_right = candidates_features[candidate_feature_name].values( + ) payload_features[uih_feature_name] = values_left payload_features[candidate_feature_name] = values_right payload_features["uih_offsets"] = torch.ops.fbgemm.asynchronous_complete_cumsum( diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/dynamic_stu.py b/recommendation/dlrm_v3/generative_recommenders/modules/dynamic_stu.py index e1fe8ad161..c8d5d4cdbf 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/dynamic_stu.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/dynamic_stu.py @@ -254,7 +254,8 @@ def _preprocess( x_lengths - self._max_l2_len - num_targets - self._contextual_seq_len ) prefix_lengths = torch.clamp(prefix_lengths, min=0) - prefix_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(prefix_lengths) + prefix_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum( + prefix_lengths) l2_lengths = x_lengths - prefix_lengths l2_offsets = x_offsets - prefix_offsets self._runtime_max_l2_len: int = fx_infer_max_len(l2_lengths) diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/hstu_transducer.py b/recommendation/dlrm_v3/generative_recommenders/modules/hstu_transducer.py index b4ae836ada..8e91a020ce 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/hstu_transducer.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/hstu_transducer.py @@ -238,7 +238,8 @@ def _postprocess( ) candidate_timestamps = candidate_timestamps.squeeze(-1) if interleave_targets: - candidate_timestamps = candidate_timestamps.view(-1, 2)[:, 0] + candidate_timestamps = candidate_timestamps.view(-1, 2)[ + :, 0] candidate_embeddings = self._output_postprocessor( seq_embeddings=candidate_embeddings, seq_timestamps=candidate_timestamps, @@ -312,7 +313,8 @@ def forward( ) if not self._is_inference: - encoded_candidate_embeddings = encoded_candidate_embeddings.to(orig_dtype) + encoded_candidate_embeddings = encoded_candidate_embeddings.to( + orig_dtype) if self._return_full_embeddings: encoded_embeddings = fx_unwrap_optional_tensor(encoded_embeddings).to( orig_dtype diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/multitask_module.py b/recommendation/dlrm_v3/generative_recommenders/modules/multitask_module.py index d5efe237ea..1824ebbf76 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/multitask_module.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/multitask_module.py @@ -83,7 +83,7 @@ def _compute_pred_and_logits( mt_preds_list: List[torch.Tensor] = [] for task_type in MultitaskTaskType: logits = mt_logits[ - task_offsets[task_type] : task_offsets[task_type + 1], + task_offsets[task_type]: task_offsets[task_type + 1], :, ] if task_offsets[task_type + 1] - task_offsets[task_type] > 0: @@ -140,15 +140,15 @@ def _compute_loss( for task_type in MultitaskTaskType: if task_offsets[task_type + 1] - task_offsets[task_type] > 0: logits = mt_logits[ - task_offsets[task_type] : task_offsets[task_type + 1], + task_offsets[task_type]: task_offsets[task_type + 1], :, ] labels = mt_labels[ - task_offsets[task_type] : task_offsets[task_type + 1], + task_offsets[task_type]: task_offsets[task_type + 1], :, ] weights = mt_weights[ - task_offsets[task_type] : task_offsets[task_type + 1], + task_offsets[task_type]: task_offsets[task_type + 1], :, ] if task_type == MultitaskTaskType.REGRESSION: @@ -168,7 +168,8 @@ def _compute_loss( else: mt_losses = mt_losses_list[0] mt_losses = ( - mt_losses.sum(-1) / mt_weights.sum(-1).clamp(min=1.0) * causal_multitask_weights + mt_losses.sum(-1) / mt_weights.sum(-1).clamp(min=1.0) * + causal_multitask_weights ) return mt_losses @@ -214,13 +215,15 @@ def forward( ]: orig_dtype = encoded_user_embeddings.dtype if not self._is_inference: - encoded_user_embeddings = encoded_user_embeddings.to(self._training_dtype) + encoded_user_embeddings = encoded_user_embeddings.to( + self._training_dtype) item_embeddings = item_embeddings.to(self._training_dtype) with torch.autocast( "cuda", dtype=torch.bfloat16, - enabled=(not self.is_inference and self._training_dtype == torch.bfloat16), + enabled=( + not self.is_inference and self._training_dtype == torch.bfloat16), ): mt_preds, mt_logits = _compute_pred_and_logits( prediction_module=self._prediction_module, diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/postprocessors.py b/recommendation/dlrm_v3/generative_recommenders/modules/postprocessors.py index 32fa660602..0faf64f81a 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/postprocessors.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/postprocessors.py @@ -92,11 +92,13 @@ def forward( ) -> torch.Tensor: # pyre-fixme[6]: For 1st argument expected `dtype` but got `Union[dtype, # Tensor, Module]`. - return self._layer_norm(seq_embeddings.to(self._layer_norm.weight.dtype)) + return self._layer_norm(seq_embeddings.to( + self._layer_norm.weight.dtype)) @torch.fx.wrap -def _unsqueeze_if_needed(t: torch.Tensor, embedding: torch.Tensor) -> torch.Tensor: +def _unsqueeze_if_needed(t: torch.Tensor, + embedding: torch.Tensor) -> torch.Tensor: if embedding.dim() == 3: return t.unsqueeze(0) return t @@ -141,7 +143,8 @@ def _concat_time_features( timestamps = timestamps.unsqueeze(-1) period_units = _unsqueeze_if_needed(period_units, combined_embeddings) - units_per_period = _unsqueeze_if_needed(units_per_period, combined_embeddings) + units_per_period = _unsqueeze_if_needed( + units_per_period, combined_embeddings) _units_since_epoch = torch.div( timestamps, period_units, rounding_mode="floor" ) # [sum(N_i), num_time_features] or [B, N, num_time_features] @@ -161,7 +164,8 @@ def _concat_time_features( -2, -1 ) # [sum(N_i), num_time_features * 2] or [B, N, num_time_features * 2] _units_elapsed = _cast_dtype(_units_elapsed, _units_elapsed_type) - combined_embeddings = torch.cat([combined_embeddings, _units_elapsed], dim=-1) + combined_embeddings = torch.cat( + [combined_embeddings, _units_elapsed], dim=-1) return combined_embeddings def forward( @@ -171,6 +175,7 @@ def forward( seq_payloads: Dict[str, torch.Tensor], ) -> torch.Tensor: user_embeddings = self._time_feature_combiner( - self._concat_time_features(seq_embeddings, timestamps=seq_timestamps) + self._concat_time_features( + seq_embeddings, timestamps=seq_timestamps) ) return self._layer_norm(user_embeddings) diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/preprocessors.py b/recommendation/dlrm_v3/generative_recommenders/modules/preprocessors.py index dc7806bb45..083277d91a 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/preprocessors.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/preprocessors.py @@ -240,8 +240,10 @@ def forward( # noqa C901 + self._additional_embedding_mlp(additional_embeddings) ) max_seq_len = max_uih_len + max_targets - target_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(num_targets) - seq_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(seq_lengths) + target_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum( + num_targets) + seq_offsets = torch.ops.fbgemm.asynchronous_complete_cumsum( + seq_lengths) uih_offsets = seq_offsets - target_offsets if self._action_weights is not None: action_embeddings = self._action_encoder( diff --git a/recommendation/dlrm_v3/generative_recommenders/modules/stu.py b/recommendation/dlrm_v3/generative_recommenders/modules/stu.py index d186000e38..5e404cd707 100644 --- a/recommendation/dlrm_v3/generative_recommenders/modules/stu.py +++ b/recommendation/dlrm_v3/generative_recommenders/modules/stu.py @@ -196,7 +196,8 @@ def __init__( self._target_aware: bool = config.target_aware self._causal: bool = config.causal self._max_attn_len: int = config.max_attn_len or 0 - self._attn_alpha: float = config.attn_alpha or 1.0 / (self._attention_dim**0.5) + self._attn_alpha: float = config.attn_alpha or 1.0 / \ + (self._attention_dim**0.5) self._use_group_norm: bool = config.use_group_norm self._recompute_normed_x: bool = config.recompute_normed_x self._recompute_uvqk: bool = config.recompute_uvqk @@ -426,7 +427,8 @@ def __init__( is_inference: bool = False, ) -> None: super().__init__(is_inference=is_inference) - self._stu_layers: torch.nn.ModuleList = torch.nn.ModuleList(modules=stu_list) + self._stu_layers: torch.nn.ModuleList = torch.nn.ModuleList( + modules=stu_list) def forward( self, diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/hstu_attention.py b/recommendation/dlrm_v3/generative_recommenders/ops/hstu_attention.py index b7021bb075..01552e1cec 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/hstu_attention.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/hstu_attention.py @@ -34,7 +34,7 @@ from hammer.ops.triton.cc.hstu_attention.triton_cc_hstu_attention import ( triton_cc_hstu_mha, ) -except: +except BaseException: from generative_recommenders.ops.triton.triton_hstu_attention import ( triton_hstu_mha as triton_cc_hstu_mha, ) @@ -75,8 +75,12 @@ def hstu_mha( torch._assert(q.is_cuda, "q must be CUDA tensor") torch._assert(k.is_cuda, "k must be CUDA tensor") torch._assert(v.is_cuda, "v must be CUDA tensor") - torch._assert(seq_offsets.is_cuda, "seq_offsets must be CUDA tensor") - torch._assert(dropout_pr < 1e-6, "dropout for triton path not implemented") + torch._assert( + seq_offsets.is_cuda, + "seq_offsets must be CUDA tensor") + torch._assert( + dropout_pr < 1e-6, + "dropout for triton path not implemented") torch._assert( min_full_attn_seq_len == 0, "min_full_attn_seq_len not implemented" ) @@ -159,9 +163,13 @@ def delta_hstu_mha( if kernel in [HammerKernel.TRITON, HammerKernel.TRITON_CC]: if not is_fx_tracing() and kernel == HammerKernel.TRITON: torch._assert(delta_q.is_cuda, "q must be CUDA tensor") - torch._assert(seq_offsets.is_cuda, "seq_offsets must be CUDA tensor") + torch._assert( + seq_offsets.is_cuda, + "seq_offsets must be CUDA tensor") if num_targets is not None: - torch._assert(num_targets.is_cuda, "num_targets must be CUDA tensor") + torch._assert( + num_targets.is_cuda, + "num_targets must be CUDA tensor") seq_offsets = seq_offsets.contiguous() delta_q = switch_to_contiguous_if_needed(delta_q) k = switch_to_contiguous_if_needed(k) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/jagged_tensors.py b/recommendation/dlrm_v3/generative_recommenders/ops/jagged_tensors.py index 0ca24daa55..c5daac7292 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/jagged_tensors.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/jagged_tensors.py @@ -222,7 +222,9 @@ def jagged_dense_bmm_broadcast_add( _, K = jagged.shape B, _, N = dense.shape torch._assert(dense.shape[1] == K, "wrong dense shape[1]") - torch._assert(seq_offsets.shape[0] == B + 1, "wrong seq_offsets shape[0]") + torch._assert( + seq_offsets.shape[0] == B + 1, + "wrong seq_offsets shape[0]") torch._assert(bias.shape[0] == B, "wrong bias shape[0]") torch._assert(bias.shape[1] == N, "wrong bias shape[1]") if kernel == HammerKernel.TRITON: diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_hstu_attention.py b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_hstu_attention.py index e4e5f64f61..60b447b6c7 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_hstu_attention.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_hstu_attention.py @@ -60,9 +60,16 @@ def _get_valid_attn_mask( row_ids = row_ids.view(1, N, N) col_ids = col_ids.view(1, N, N) row_col_dist = row_ids - col_ids - valid_attn_mask = torch.eye(N, device=device, dtype=torch.bool).view(1, N, N) + valid_attn_mask = torch.eye( + N, + device=device, + dtype=torch.bool).view( + 1, + N, + N) if not causal: - row_col_dist = torch.where(row_col_dist > 0, row_col_dist, -row_col_dist) + row_col_dist = torch.where( + row_col_dist > 0, row_col_dist, -row_col_dist) valid_attn_mask = torch.logical_or(valid_attn_mask, row_col_dist > 0) if max_attn_len > 0: if min_full_attn_seq_len > 0: @@ -184,7 +191,9 @@ def pytorch_hstu_mha( qk_attn = F.dropout(qk_attn, p=dropout_pr, training=training) attn_dense = torch.einsum("bhxd,bhdv->bhxv", qk_attn, v) # [B, H, N, V] return torch.ops.fbgemm.dense_to_jagged( - attn_dense.transpose(1, 2).flatten(2, 3), # [B, N, H, V]->[B, N, H * V] + attn_dense.transpose( + 1, 2).flatten( + 2, 3), # [B, N, H, V]->[B, N, H * V] [seq_offsets], L, )[0].view(L, H, V) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged.py b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged.py index 67de7cbfce..034fccafdf 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged.py @@ -129,7 +129,8 @@ def pytorch_concat_2D_dense_jagged( ) concatted_dense = torch.cat([dense_values, jagged_dense], dim=1) concatted_offsets = ( - dense_size * _arange(B + 1, device=jagged_offsets.device) + jagged_offsets + dense_size * _arange(B + 1, + device=jagged_offsets.device) + jagged_offsets ) return torch.ops.fbgemm.dense_to_jagged( concatted_dense, @@ -148,7 +149,8 @@ def pytorch_concat_2D_jagged_jagged( is_replace: bool = False, n_prefix_from_right: int = 0, ) -> torch.Tensor: - # is_replace with n_prefix_from_right != 0 is not supported yet (neither in triton) + # is_replace with n_prefix_from_right != 0 is not supported yet (neither + # in triton) if is_replace: return pytorch_replace_last_n_with_jagged( max_seq_len_left, @@ -179,7 +181,11 @@ def pytorch_concat_2D_jagged_jagged( dense_b, [n_prefix_from_right, max_seq_len_right - n_prefix_from_right], dim=1 ) dense = torch.cat([dense_b_prefix, dense_a, dense_b_suffix], dim=1) - mask = _arange(max_seq_len, device=offsets_left.device).expand(B, max_seq_len) + mask = _arange( + max_seq_len, + device=offsets_left.device).expand( + B, + max_seq_len) mask = torch.logical_or( mask < lengths_a.view(B, 1) + n_prefix_from_right, torch.logical_and( @@ -198,8 +204,10 @@ def pytorch_jagged_remove_first_or_last_1D( ) -> Tuple[torch.Tensor, torch.Tensor]: values = values.view(-1, 1) shrunk_lengths = lengths - 1 - k_lengths = torch.stack([shrunk_lengths, torch.ones_like(lengths)], dim=1).view(-1) - q_lengths = torch.stack([torch.ones_like(lengths), shrunk_lengths], dim=1).view(-1) + k_lengths = torch.stack( + [shrunk_lengths, torch.ones_like(lengths)], dim=1).view(-1) + q_lengths = torch.stack( + [torch.ones_like(lengths), shrunk_lengths], dim=1).view(-1) all_indices = torch.arange( start=0, end=q_lengths.numel(), device=values.device ).reshape(-1, 2) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged_tensors.py b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged_tensors.py index 27817f7fbd..0468115ef5 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged_tensors.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_jagged_tensors.py @@ -137,7 +137,8 @@ def _split_2D_jagged_jagged( mask >= lengths_left.view(-1, 1), mask < (lengths_left + lengths_right).view(-1, 1), ) - return padded_values[mask_left.view(-1), :], padded_values[mask_right.view(-1), :] + return padded_values[mask_left.view(-1), + :], padded_values[mask_right.view(-1), :] @torch.fx.wrap @@ -233,7 +234,8 @@ def pytorch_hstu_concat_l2_embeddings( ], dim=1, ) - mask = fx_arange(max_prefix_len + max_l2_len, device=prefix_x.device).view(1, -1) + mask = fx_arange(max_prefix_len + max_l2_len, + device=prefix_x.device).view(1, -1) prefix_lengths = prefix_offsets[1:] - prefix_offsets[:-1] l2_lengths = l2_offsets[1:] - l2_offsets[:-1] mask = torch.logical_or( diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_position.py b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_position.py index dbe0c7efe9..5eefd9d30d 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_position.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/pytorch/pt_position.py @@ -51,7 +51,8 @@ def _get_col_indices( ) if num_targets is not None: if interleave_targets: - high_inds = seq_lengths - fx_unwrap_optional_tensor(num_targets) * 2 + high_inds = seq_lengths - \ + fx_unwrap_optional_tensor(num_targets) * 2 else: high_inds = seq_lengths - fx_unwrap_optional_tensor(num_targets) col_indices = torch.clamp(col_indices, max=high_inds.view(-1, 1)) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_addmm.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_addmm.py index 2231387fb6..56b6aac9f1 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_addmm.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_addmm.py @@ -326,9 +326,11 @@ def _addmm_fwd( mask_m = (pid_m * BLOCK_M + offs_m)[:, None] < M mask_n = (pid_n * BLOCK_N + offs_n)[None, :] < N x_ptr += pid_m.to(tl.int64) * BLOCK_M * stride_xm - x_ptrs = x_ptr + (offs_m[:, None] * stride_xm + offs_k[None, :] * stride_xk) + x_ptrs = x_ptr + (offs_m[:, None] * stride_xm + + offs_k[None, :] * stride_xk) w_ptr += pid_n.to(tl.int64) * BLOCK_N * stride_wn - w_ptrs = w_ptr + (offs_k[:, None] * stride_wk + offs_n[None, :] * stride_wn) + w_ptrs = w_ptr + (offs_k[:, None] * stride_wk + + offs_n[None, :] * stride_wn) accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) for k in range(0, tl.cdiv(K, BLOCK_K)): mask_k = offs_k[None, :] < K - k * BLOCK_K @@ -348,7 +350,8 @@ def _addmm_fwd( else: y_ptr += pid_m.to(tl.int64) * BLOCK_M * stride_ym y_ptr += pid_n.to(tl.int64) * BLOCK_N * stride_yn - y_ptrs = y_ptr + stride_ym * offs_m[:, None] + stride_yn * offs_n[None, :] + y_ptrs = y_ptr + stride_ym * \ + offs_m[:, None] + stride_yn * offs_n[None, :] y = tl.load(y_ptrs, mask=z_mask) z = (accumulator + y.to(tl.float32)).to(z_ptr.dtype.element_ty) z_ptr += pid_m.to(tl.int64) * BLOCK_M * stride_zm @@ -454,8 +457,10 @@ def _addmm_fwd_tma_ws( BROADCAST_Y: tl.constexpr, NUM_SMEM_BUFFERS: tl.constexpr, ): - x_buffers = tlx.local_alloc((BLOCK_M, BLOCK_K), x_desc.dtype, NUM_SMEM_BUFFERS) - w_buffers = tlx.local_alloc((BLOCK_K, BLOCK_N), w_desc.dtype, NUM_SMEM_BUFFERS) + x_buffers = tlx.local_alloc( + (BLOCK_M, BLOCK_K), x_desc.dtype, NUM_SMEM_BUFFERS) + w_buffers = tlx.local_alloc( + (BLOCK_K, BLOCK_N), w_desc.dtype, NUM_SMEM_BUFFERS) acc_tmem_buffer = tlx.local_alloc( (BLOCK_M, BLOCK_N), tl.float32, tl.constexpr(1), tlx.storage_kind.tmem ) @@ -463,11 +468,15 @@ def _addmm_fwd_tma_ws( if BROADCAST_Y: y_buffer = tlx.local_alloc((1, BLOCK_N), y_desc.dtype, tl.constexpr(1)) else: - y_buffer = tlx.local_alloc((BLOCK_M, BLOCK_N), y_desc.dtype, tl.constexpr(1)) - z_buffer = tlx.local_alloc((BLOCK_M, BLOCK_N), z_desc.dtype, tl.constexpr(1)) - - smem_full_bars = tlx.alloc_barriers(num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) - smem_empty_bars = tlx.alloc_barriers(num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) + y_buffer = tlx.local_alloc( + (BLOCK_M, BLOCK_N), y_desc.dtype, tl.constexpr(1)) + z_buffer = tlx.local_alloc( + (BLOCK_M, BLOCK_N), z_desc.dtype, tl.constexpr(1)) + + smem_full_bars = tlx.alloc_barriers( + num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) + smem_empty_bars = tlx.alloc_barriers( + num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) y_load_barrier = tlx.alloc_barriers(num_barriers=1, arrive_count=1) with tlx.async_tasks(): @@ -502,10 +511,12 @@ def _addmm_fwd_tma_ws( 2 * (BLOCK_M * BLOCK_K + BLOCK_K * BLOCK_N), ) tlx.async_descriptor_load( - x_desc, x_buffers[buf], [offs_xm, offs_k], smem_full_bars[buf] + x_desc, x_buffers[buf], [ + offs_xm, offs_k], smem_full_bars[buf] ) tlx.async_descriptor_load( - w_desc, w_buffers[buf], [offs_k, offs_wn], smem_full_bars[buf] + w_desc, w_buffers[buf], [ + offs_k, offs_wn], smem_full_bars[buf] ) load_phase = load_phase ^ (buf == NUM_SMEM_BUFFERS - 1) @@ -532,7 +543,9 @@ def _addmm_fwd_tma_ws( y_load_bar = tlx.local_view(y_load_barrier, 0) if BROADCAST_Y: tlx.barrier_expect_bytes(y_load_bar, 1 * BLOCK_N * 2) - tlx.async_descriptor_load(y_desc, y_buf_view, [0, offs_wn], y_load_bar) + tlx.async_descriptor_load( + y_desc, y_buf_view, [ + 0, offs_wn], y_load_bar) else: tlx.barrier_expect_bytes(y_load_bar, BLOCK_M * BLOCK_N * 2) tlx.async_descriptor_load( @@ -595,18 +608,24 @@ def _addmm_fwd_tma_ws_persistent( NUM_SMS: tl.constexpr, ): # Allocate buffers once for all tiles - x_buffers = tlx.local_alloc((BLOCK_M, BLOCK_K), x_desc.dtype, NUM_SMEM_BUFFERS) - w_buffers = tlx.local_alloc((BLOCK_K, BLOCK_N), w_desc.dtype, NUM_SMEM_BUFFERS) + x_buffers = tlx.local_alloc( + (BLOCK_M, BLOCK_K), x_desc.dtype, NUM_SMEM_BUFFERS) + w_buffers = tlx.local_alloc( + (BLOCK_K, BLOCK_N), w_desc.dtype, NUM_SMEM_BUFFERS) tmem_buffers = tlx.local_alloc( (BLOCK_M, BLOCK_N), tl.float32, NUM_TMEM_BUFFERS, tlx.storage_kind.tmem ) # Barriers for producer <-> MMA - smem_full_bars = tlx.alloc_barriers(num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) - smem_empty_bars = tlx.alloc_barriers(num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) + smem_full_bars = tlx.alloc_barriers( + num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) + smem_empty_bars = tlx.alloc_barriers( + num_barriers=NUM_SMEM_BUFFERS, arrive_count=1) # Barriers for MMA <-> Epilogue - tmem_full_bars = tlx.alloc_barriers(num_barriers=NUM_TMEM_BUFFERS, arrive_count=1) - tmem_empty_bars = tlx.alloc_barriers(num_barriers=NUM_TMEM_BUFFERS, arrive_count=1) + tmem_full_bars = tlx.alloc_barriers( + num_barriers=NUM_TMEM_BUFFERS, arrive_count=1) + tmem_empty_bars = tlx.alloc_barriers( + num_barriers=NUM_TMEM_BUFFERS, arrive_count=1) with tlx.async_tasks(): # Epilogue consumer: loads Y, adds bias, stores Z @@ -672,7 +691,9 @@ def _addmm_fwd_tma_ws_persistent( ) # Wait for epilogue to finish with this TMEM buffer - tlx.barrier_wait(tmem_empty_bars[cur_tmem_buf], tmem_write_phase) + tlx.barrier_wait( + tmem_empty_bars[cur_tmem_buf], + tmem_write_phase) tmem_write_phase = tmem_write_phase ^ ( cur_tmem_buf == int(NUM_TMEM_BUFFERS) - 1 ) @@ -694,8 +715,10 @@ def _addmm_fwd_tma_ws_persistent( dot_phase = dot_phase ^ (buf == int(NUM_SMEM_BUFFERS) - 1) # Wait for last MMA to complete - last_buf = (processed_k_iters + k_tiles - 1) % int(NUM_SMEM_BUFFERS) - last_dot_phase = dot_phase ^ (last_buf == int(NUM_SMEM_BUFFERS) - 1) + last_buf = (processed_k_iters + k_tiles - + 1) % int(NUM_SMEM_BUFFERS) + last_dot_phase = dot_phase ^ ( + last_buf == int(NUM_SMEM_BUFFERS) - 1) tlx.barrier_wait(smem_empty_bars[last_buf], last_dot_phase) # Signal epilogue that result is ready @@ -735,13 +758,16 @@ def _addmm_fwd_tma_ws_persistent( 2 * (BLOCK_M + BLOCK_N) * BLOCK_K, ) tlx.async_descriptor_load( - x_desc, x_buffers[buf], [offs_xm, offs_k], smem_full_bars[buf] + x_desc, x_buffers[buf], [ + offs_xm, offs_k], smem_full_bars[buf] ) tlx.async_descriptor_load( - w_desc, w_buffers[buf], [offs_k, offs_wn], smem_full_bars[buf] + w_desc, w_buffers[buf], [ + offs_k, offs_wn], smem_full_bars[buf] ) - load_phase = load_phase ^ (buf == int(NUM_SMEM_BUFFERS) - 1) + load_phase = load_phase ^ ( + buf == int(NUM_SMEM_BUFFERS) - 1) processed_k_iters += k_tiles @@ -763,7 +789,8 @@ def triton_addmm_fwd_tma_persistent( if M == 0 or N == 0: return z - # A dummy block value that will be overwritten when we have the real block size + # A dummy block value that will be overwritten when we have the real block + # size dummy_block = [1, 1] # pyre-ignore[6]: In call `TensorDescriptor.__init__`, for 2nd positional # argument, expected `List[int]` but got `Size` @@ -823,7 +850,8 @@ def triton_addmm_fwd_tma_ws_tlx( if M == 0 or N == 0: return z - # A dummy block value that will be overwritten when we have the real block size + # A dummy block value that will be overwritten when we have the real block + # size dummy_block = [1, 1] # pyre-ignore[6]: In call `TensorDescriptor.__init__`, for 2nd positional # argument, expected `List[int]` but got `Size` @@ -944,7 +972,7 @@ def triton_addmm_fwd( if M == 0 or N == 0: return z - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(M, meta["BLOCK_M"]), triton.cdiv(N, meta["BLOCK_N"]), ) @@ -1015,7 +1043,8 @@ def forward( if is_sm100() and TMA_AVAILABLE and _check_tma_alignment(x, w, y): if x.dtype == torch.float32 or HAS_TLX == False: # use TMA persistent kernel on sm100 - return triton_addmm_fwd_tma_persistent(x, w, y, warp_specialize=True) + return triton_addmm_fwd_tma_persistent( + x, w, y, warp_specialize=True) else: return triton_addmm_fwd_tma_ws_persistent_tlx( x, w, y diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_attention.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_attention.py index 36080561fc..946a6350f6 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_attention.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_attention.py @@ -276,7 +276,8 @@ def _get_fw_configs() -> List[triton.Config]: # noqa: C901 ), ] - # Add 'USE_TLX' : False, 'NUM_BUFFERS': 1, 'NUM_MMA_WARPS_PER_GROUP': 1, 'NUM_MMA_GROUPS': 1 to non-TLX configs + # Add 'USE_TLX' : False, 'NUM_BUFFERS': 1, 'NUM_MMA_WARPS_PER_GROUP': + # 1, 'NUM_MMA_GROUPS': 1 to non-TLX configs for config in configs: if not config.kwargs.get("USE_TLX", False): config.kwargs["USE_TLX"] = False @@ -486,7 +487,9 @@ def _hstu_attn_fwd_compute( # noqa C901 block_shape=(BLOCK_M, BLOCK_D_Q), order=(1, 0), ) - q = tl.load(Q_block_ptr, boundary_check=(0,), padding_option="zero") + q = tl.load( + Q_block_ptr, boundary_check=( + 0,), padding_option="zero") K_block_ptr = tl.make_block_ptr( base=K + off_h * stride_kh + seq_start * stride_kn, @@ -633,7 +636,8 @@ def _hstu_attn_fwd_compute( # noqa C901 offs_m_delta = start_m_delta + tl.arange(0, BLOCK_M) offs_v_d = tl.arange(0, BLOCK_D_V) off_o = Out + off_z * DeltaSize * stride_om + off_h * stride_oh - out_ptrs = off_o + offs_m_delta[:, None] * stride_om + offs_v_d[None, :] + out_ptrs = off_o + offs_m_delta[:, + None] * stride_om + offs_v_d[None, :] tl.store(out_ptrs, acc, mask=(offs_m_delta < DeltaSize)[:, None]) else: # rematerialize offsets to save registers @@ -805,18 +809,21 @@ def _hstu_attn_fwd_compute_main_loop_tlx_pipelined( # noqa C901 # Pingpong if cid == 0: - # Consumer 0 waits for Consumer 1 to reach synchronization point at barrier 9. + # Consumer 0 waits for Consumer 1 to reach synchronization point at + # barrier 9. tlx.named_barrier_wait(9, 256) else: # Consumer 1 signals its arrival at barrier 9. tlx.named_barrier_arrive(9, 256) - # Then waits at barrier 10 until Consumer 0 finishes issuing its async_dot. + # Then waits at barrier 10 until Consumer 0 finishes issuing its + # async_dot. tlx.named_barrier_wait(10, 256) qk = tlx.async_dot(q_tile, k_tile) if cid == 0: - # After issuing async_dot, Consumer 0 signals barrier 10 to unblock Consumer 1. + # After issuing async_dot, Consumer 0 signals barrier 10 to unblock + # Consumer 1. tlx.named_barrier_arrive(10, 256) # wait for the MMA using to complete @@ -1157,7 +1164,8 @@ def _hstu_attn_fwd_load_Q_K_V( BLOCK_N, ) - for cid in tl.range(1, NUM_MMA_GROUPS, loop_unroll_factor=NUM_MMA_GROUPS - 1): + for cid in tl.range(1, NUM_MMA_GROUPS, + loop_unroll_factor=NUM_MMA_GROUPS - 1): _hstu_attn_fwd_load_Q( Q, q_tiles, @@ -1233,7 +1241,8 @@ def _hstu_attn_fwd_load_Q_K_V( if uih_end < start_m: low_delta = start_m high_delta = start_m + BLOCK_M - for start_delta in tl.range(low_delta, high_delta, BLOCK_N, num_stages=0): + for start_delta in tl.range( + low_delta, high_delta, BLOCK_N, num_stages=0): # pyre-ignore[58] buf_id = loop_trip_cnt % NUM_BUFFERS # buffers in a row share the same phase @@ -1333,8 +1342,10 @@ def _hstu_attn_fwd_compute_tlx( # noqa C901 q_tiles = tlx.local_alloc( (BLOCK_M_SPLIT, BLOCK_D_Q), tlx.dtype_of(Q), NUM_MMA_GROUPS ) - k_tiles = tlx.local_alloc((BLOCK_N, BLOCK_D_Q), tlx.dtype_of(K), NUM_BUFFERS) - v_tiles = tlx.local_alloc((BLOCK_N, BLOCK_D_V), tlx.dtype_of(V), NUM_BUFFERS) + k_tiles = tlx.local_alloc( + (BLOCK_N, BLOCK_D_Q), tlx.dtype_of(K), NUM_BUFFERS) + v_tiles = tlx.local_alloc( + (BLOCK_N, BLOCK_D_V), tlx.dtype_of(V), NUM_BUFFERS) # allocate barriers q_fulls = tlx.alloc_barriers(num_barriers=NUM_MMA_GROUPS, arrive_count=1) @@ -1393,7 +1404,8 @@ def _hstu_attn_fwd_compute_tlx( # noqa C901 cid = tlx.async_task_replica_id() acc = tl.zeros([BLOCK_M_SPLIT, BLOCK_D_V], dtype=tl.float32) # initialize offsets - offs_m = start_m + tl.arange(0, BLOCK_M_SPLIT) + cid * BLOCK_M_SPLIT + offs_m = start_m + tl.arange(0, + BLOCK_M_SPLIT) + cid * BLOCK_M_SPLIT offs_n = tl.arange(0, BLOCK_N) low, high, uih_end = _hstu_attn_fwd_caculate_range( @@ -1485,15 +1497,20 @@ def _hstu_attn_fwd_compute_tlx( # noqa C901 offs_m_delta = start_m_delta + tl.arange(0, BLOCK_M_SPLIT) offs_v_d = tl.arange(0, BLOCK_D_V) off_o = Out + off_z * DeltaSize * stride_om + off_h * stride_oh - out_ptrs = off_o + offs_m_delta[:, None] * stride_om + offs_v_d[None, :] - tl.store(out_ptrs, acc, mask=(offs_m_delta < DeltaSize)[:, None]) + out_ptrs = off_o + \ + offs_m_delta[:, None] * stride_om + offs_v_d[None, :] + tl.store( + out_ptrs, acc, mask=( + offs_m_delta < DeltaSize)[ + :, None]) else: # rematerialize offsets to save registers start_m = pid * BLOCK_M + cid * BLOCK_M_SPLIT offs_m = start_m + tl.arange(0, BLOCK_M_SPLIT) offs_v_d = tl.arange(0, BLOCK_D_V) off_o = Out + seq_start * stride_om + off_h * stride_oh - out_ptrs = off_o + offs_m[:, None] * stride_om + offs_v_d[None, :] + out_ptrs = off_o + offs_m[:, None] * \ + stride_om + offs_v_d[None, :] tl.store(out_ptrs, acc, mask=(offs_m < seq_len)[:, None]) @@ -1851,12 +1868,14 @@ def _hstu_attn_bwd_one_block( # noqa C901 # compute dk and dq dqk_trans = tl.dot(v, tl.trans(do), allow_tf32=ALLOW_TF32) dqk_trans = ( - dqk_trans * sig_trans * (1 + qk_trans * (1 - sig_trans)) * (1.0 / MAX_SEQ_LEN) + dqk_trans * sig_trans * + (1 + qk_trans * (1 - sig_trans)) * (1.0 / MAX_SEQ_LEN) ) dqk_trans = tl.where(invalid_mask_trans, dqk_trans, 0) dqk_trans = dqk_trans.to(k.dtype) - # Note: the factor `alpha` is delayed until the end of the function to reduce the cost + # Note: the factor `alpha` is delayed until the end of the function to + # reduce the cost dk += tl.dot(dqk_trans, tl.trans(q_trans), allow_tf32=ALLOW_TF32) acc_dq( dq_ptrs_trans=dq_ptrs_trans, @@ -2080,8 +2099,10 @@ def _hstu_attn_bwd_one_col_block( # noqa C901 else: dv_ptrs = DV + (offs_n[:, None] * stride_dvn + offs_v_d[None, :]) dk_ptrs = DK + (offs_n[:, None] * stride_dkn + offs_qk_d[None, :]) - tl.store(dv_ptrs, dv.to(k.dtype), mask=mask_n[:, None]) # pyre-ignore[61] - tl.store(dk_ptrs, dk.to(k.dtype), mask=mask_n[:, None]) # pyre-ignore[61] + tl.store(dv_ptrs, dv.to(k.dtype), + mask=mask_n[:, None]) # pyre-ignore[61] + tl.store(dk_ptrs, dk.to(k.dtype), + mask=mask_n[:, None]) # pyre-ignore[61] def _bwd_pre_hook(nargs): @@ -2242,43 +2263,56 @@ def _get_bw_configs() -> List[triton.Config]: if torch.cuda.is_available() and torch.version.cuda < "12.8": configs += [ triton.Config( - {"BLOCK_M": 16, "BLOCK_N": 64, "SEQUENCE_PARALLEL": False, "UNROLL": 1}, + {"BLOCK_M": 16, + "BLOCK_N": 64, + "SEQUENCE_PARALLEL": False, + "UNROLL": 1}, num_stages=1, num_warps=4, pre_hook=_bwd_pre_hook, ), triton.Config( - {"BLOCK_M": 32, "BLOCK_N": 64, "SEQUENCE_PARALLEL": False, "UNROLL": 1}, + {"BLOCK_M": 32, + "BLOCK_N": 64, + "SEQUENCE_PARALLEL": False, + "UNROLL": 1}, num_stages=1, num_warps=4, pre_hook=_bwd_pre_hook, ), triton.Config( - {"BLOCK_M": 32, "BLOCK_N": 64, "SEQUENCE_PARALLEL": False, "UNROLL": 1}, + {"BLOCK_M": 32, + "BLOCK_N": 64, + "SEQUENCE_PARALLEL": False, + "UNROLL": 1}, num_stages=1, num_warps=8, pre_hook=_bwd_pre_hook, ), triton.Config( - {"BLOCK_M": 32, "BLOCK_N": 64, "SEQUENCE_PARALLEL": True, "UNROLL": 1}, + {"BLOCK_M": 32, "BLOCK_N": 64, + "SEQUENCE_PARALLEL": True, "UNROLL": 1}, num_stages=1, num_warps=8, pre_hook=_bwd_pre_hook, ), triton.Config( - {"BLOCK_M": 32, "BLOCK_N": 128, "SEQUENCE_PARALLEL": True, "UNROLL": 1}, + {"BLOCK_M": 32, "BLOCK_N": 128, + "SEQUENCE_PARALLEL": True, "UNROLL": 1}, num_stages=3, num_warps=8, pre_hook=_bwd_pre_hook, ), triton.Config( - {"BLOCK_M": 32, "BLOCK_N": 64, "SEQUENCE_PARALLEL": True, "UNROLL": 1}, + {"BLOCK_M": 32, "BLOCK_N": 64, + "SEQUENCE_PARALLEL": True, "UNROLL": 1}, num_stages=1, num_warps=4, pre_hook=_bwd_pre_hook, ), triton.Config( - {"BLOCK_M": 32, "BLOCK_N": 64, "SEQUENCE_PARALLEL": True, "UNROLL": 1}, + {"BLOCK_M": 32, "BLOCK_N": 64, + "SEQUENCE_PARALLEL": True, "UNROLL": 1}, num_stages=2, num_warps=4, pre_hook=_bwd_pre_hook, @@ -2616,7 +2650,8 @@ def alloc_fn(size: int, align: int, stream: Optional[int]): # pyre-ignore [6] triton.set_allocator(alloc_fn) - grid = lambda meta: ( # noqa E731 + + def grid(meta): return ( # noqa E731 triton.cdiv(N, meta["BLOCK_M"]), Z * H, ) @@ -2689,7 +2724,8 @@ def triton_hstu_attention_bwd( Z = seq_offsets.numel() - 1 _, H, DimQ = q.shape _, _, DimV = v.shape - grid = lambda meta: ( # noqa E731 + + def grid(meta): return ( # noqa E731 Z * H, (triton.cdiv(N, meta["BLOCK_N"]) if meta["SEQUENCE_PARALLEL"] else 1), ) @@ -2928,7 +2964,12 @@ def triton_cached_hstu_mha( DELTA_L, H, DimQ = delta_q.shape DeltaSize = DELTA_L // Z L, _, DimV = v.shape - out = torch.empty((DELTA_L, H, DimV), dtype=delta_q.dtype, device=delta_q.device) + out = torch.empty( + (DELTA_L, + H, + DimV), + dtype=delta_q.dtype, + device=delta_q.device) TMA_DESC_SIZE = 128 desc_q = delta_q @@ -2962,7 +3003,8 @@ def alloc_fn(size: int, align: int, stream: Optional[int]): # pyre-ignore [6] triton.set_allocator(alloc_fn) - grid = lambda meta: ( # noqa E731 + + def grid(meta): return ( # noqa E731 triton.cdiv(DeltaSize, meta["BLOCK_M"]), Z * H, ) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_linear.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_linear.py index 8b0c288696..c155f3b7b9 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_linear.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_linear.py @@ -15,6 +15,8 @@ #!/usr/bin/env python3 +from triton.language.extra import libdevice +from generative_recommenders.ops.utils import is_sm100 from typing import List, Optional, Tuple import torch @@ -46,11 +48,7 @@ def _get_layer_norm_mul_dropout_fwd_multirow_configs() -> List[triton.Config]: return configs -from generative_recommenders.ops.utils import is_sm100 - # @manual=//triton:triton -from triton.language.extra import libdevice - try: # @manual=//triton:triton from triton.language.extra.libdevice import fast_dividef @@ -100,7 +98,8 @@ def _generate_random_mask( STRIDE: tl.constexpr, BLOCK_D: tl.constexpr, ): - # NOTE: This function appears to be incomplete/unused - kept for compatibility + # NOTE: This function appears to be incomplete/unused - kept for + # compatibility pid = tl.program_id(0) cols = tl.arange(0, BLOCK_D) col_mask = cols < D @@ -222,8 +221,10 @@ def _ln_mul_dropout_fwd_rng( col_offsets = tl.arange(0, BLOCK_D) # Load precomputed random masks for u, x, y - u_offsets = row_offsets[:, None] * stride_mask + col_offsets[None, :] - x_offsets = (row_offsets[:, None] + N) * stride_mask + col_offsets[None, :] + u_offsets = row_offsets[:, None] * \ + stride_mask + col_offsets[None, :] + x_offsets = (row_offsets[:, None] + N) * \ + stride_mask + col_offsets[None, :] y_offsets = (row_offsets[:, None] + 2 * N) * stride_mask + col_offsets[ None, : ] @@ -242,7 +243,8 @@ def _ln_mul_dropout_fwd_rng( col_offsets = tl.arange(0, BLOCK_D) # Load precomputed random mask for y - y_offsets = row_offsets[:, None] * stride_mask + col_offsets[None, :] + y_offsets = row_offsets[:, None] * \ + stride_mask + col_offsets[None, :] mask = (row_offsets[:, None] < N) & (col_offsets[None, :] < D) y_keep = tl.load(RANDOM_MASK + y_offsets, mask=mask, other=True) @@ -276,9 +278,18 @@ def _ln_mul_dropout_fwd_rng( order=(1, 0), ) - tl.store(Y_block_ptr_u, u_block.to(Y.dtype.element_ty), boundary_check=(0, 1)) - tl.store(Y_block_ptr_x, x_block.to(Y.dtype.element_ty), boundary_check=(0, 1)) - tl.store(Y_block_ptr_y, y.to(Y.dtype.element_ty), boundary_check=(0, 1)) + tl.store( + Y_block_ptr_u, u_block.to( + Y.dtype.element_ty), boundary_check=( + 0, 1)) + tl.store( + Y_block_ptr_x, x_block.to( + Y.dtype.element_ty), boundary_check=( + 0, 1)) + tl.store( + Y_block_ptr_y, y.to( + Y.dtype.element_ty), boundary_check=( + 0, 1)) else: Y_block_ptr = tl.make_block_ptr( base=Y, @@ -470,7 +481,8 @@ def _ln_mul_dropout_bwd_dx_du_rng( dx = tl.where(dx_keep, dx / (1.0 - dropout_ratio), 0.0) dy = tl.where(dy_keep, dy / (1.0 - dropout_ratio), 0.0) else: - # Load dropout mask directly instead of generating random numbers + # Load dropout mask directly instead of generating random + # numbers dy_keep = tl.load(RANDOM_MASK + cols, mask=mask, other=True) dy = tl.where(dy_keep, dy / (1.0 - dropout_ratio), 0.0) @@ -620,7 +632,8 @@ def _ln_mul_dropout_bwd_dx_du( if CONCAT_UX: # apply dropout on du if FAST_DROPOUT: - random_du, random_dx, random_dy = rand3x(seed, random_offsets) + random_du, random_dx, random_dy = rand3x( + seed, random_offsets) else: random_du = tl.rand(seed, random_offsets) du_keep = random_du > dropout_ratio @@ -754,8 +767,16 @@ def _ln_mul_dropout_bwd_dwdb( sum_dw = tl.sum(dw, axis=0) sum_db = tl.sum(db, axis=0) - tl.store(FINAL_DW + cols, sum_dw.to(FINAL_DW.dtype.element_ty), mask=cols < D) - tl.store(FINAL_DB + cols, sum_db.to(FINAL_DB.dtype.element_ty), mask=cols < D) + tl.store( + FINAL_DW + cols, + sum_dw.to( + FINAL_DW.dtype.element_ty), + mask=cols < D) + tl.store( + FINAL_DB + cols, + sum_db.to( + FINAL_DB.dtype.element_ty), + mask=cols < D) def triton_layer_norm_mul_dropout_fwd( @@ -792,10 +813,12 @@ def triton_layer_norm_mul_dropout_fwd( MAX_FUSED_SIZE = 65536 // x.element_size() BLOCK_D: int = min(MAX_FUSED_SIZE, triton.next_power_of_2(D)) if D > BLOCK_D: - raise RuntimeError("This layer norm doesn't support feature dim >= 64KB.") + raise RuntimeError( + "This layer norm doesn't support feature dim >= 64KB.") if seed is None: - seed = torch.randint(low=0, high=2**62, size=(1,), dtype=torch.int64).item() + seed = torch.randint(low=0, high=2**62, size=(1,), + dtype=torch.int64).item() num_warps: int = min(max(BLOCK_D // 256, 1), 8) sms = torch.cuda.get_device_properties("cuda").multi_processor_count # Benchmark shows separating RNG from ln_mul_dropout kernel only benefits on @@ -1174,7 +1197,11 @@ def _group_norm_mul_dropout_fwd( if CONCAT_UX: tl.store(Y + offsets, u.to(Y.dtype.element_ty), mask=mask) tl.store(Y + Heads * D + offsets, x.to(Y.dtype.element_ty), mask=mask) - tl.store(Y + 2 * Heads * D + offsets, y.to(Y.dtype.element_ty), mask=mask) + tl.store( + Y + 2 * Heads * D + offsets, + y.to( + Y.dtype.element_ty), + mask=mask) else: tl.store(Y + offsets, y.to(Y.dtype.element_ty), mask=mask) @@ -1229,8 +1256,23 @@ def _group_norm_mul_dropout_bwd_dx_du( x = tl.load(X + offsets, mask=mask, other=0).to(tl.float32) if CONCAT_UX: du = tl.load(DY + offsets, mask=mask, other=0).to(tl.float32) - dx = tl.load(DY + Heads * D + offsets, mask=mask, other=0).to(tl.float32) - dy = tl.load(DY + 2 * Heads * D + offsets, mask=mask, other=0).to(tl.float32) + dx = tl.load( + DY + + Heads * + D + + offsets, + mask=mask, + other=0).to( + tl.float32) + dy = tl.load( + DY + + 2 * + Heads * + D + + offsets, + mask=mask, + other=0).to( + tl.float32) else: du = tl.zeros([BLOCK_H, BLOCK_D], dtype=tl.float32) dx = tl.zeros([BLOCK_H, BLOCK_D], dtype=tl.float32) @@ -1303,8 +1345,16 @@ def _group_norm_mul_dropout_bwd_dx_du( ) if CONCAT_UX: tl.store(Y + offsets, u.to(Y.dtype.element_ty), mask=mask) - tl.store(Y + Heads * D + offsets, x.to(Y.dtype.element_ty), mask=mask) - tl.store(Y + 2 * Heads * D + offsets, y.to(Y.dtype.element_ty), mask=mask) + tl.store( + Y + Heads * D + offsets, + x.to( + Y.dtype.element_ty), + mask=mask) + tl.store( + Y + 2 * Heads * D + offsets, + y.to( + Y.dtype.element_ty), + mask=mask) else: tl.store(Y + offsets, y.to(Y.dtype.element_ty), mask=mask) @@ -1367,9 +1417,11 @@ def triton_group_norm_mul_dropout_fwd( assert bias.numel() == num_heads if concat_ux: - y = torch.empty((N, 3 * num_heads * linear_dim), dtype=x.dtype, device=x.device) + y = torch.empty((N, 3 * num_heads * linear_dim), + dtype=x.dtype, device=x.device) else: - y = torch.empty((N, num_heads * linear_dim), dtype=x.dtype, device=x.device) + y = torch.empty((N, num_heads * linear_dim), + dtype=x.dtype, device=x.device) mean = torch.empty((N * num_heads,), dtype=torch.float32, device=x.device) rstd = torch.empty((N * num_heads,), dtype=torch.float32, device=x.device) if N == 0: @@ -1384,7 +1436,8 @@ def triton_group_norm_mul_dropout_fwd( ) if seed is None: - seed = torch.randint(low=0, high=2**62, size=(1,), dtype=torch.int64).item() + seed = torch.randint(low=0, high=2**62, size=(1,), + dtype=torch.int64).item() num_warps: int = min(max(BLOCK_D * BLOCK_H // 256, 1), 8) # pyre-ignore[28] _group_norm_mul_dropout_fwd[(N,)]( @@ -1444,7 +1497,8 @@ def triton_group_norm_mul_dropout_bwd( (N, 3 * num_heads * linear_dim), dtype=x.dtype, device=x.device ) else: - y = torch.empty((N, num_heads * linear_dim), dtype=x.dtype, device=x.device) + y = torch.empty((N, num_heads * linear_dim), + dtype=x.dtype, device=x.device) if N == 0: return ( torch.zeros_like(x), @@ -1464,8 +1518,16 @@ def triton_group_norm_mul_dropout_bwd( else: GROUP_N = 64 * 8 GROUP_N = N if GROUP_N > N else GROUP_N - _dweight = torch.zeros((GROUP_N, num_heads), dtype=torch.float32, device=x.device) - _dbias = torch.zeros((GROUP_N, num_heads), dtype=torch.float32, device=x.device) + _dweight = torch.zeros( + (GROUP_N, + num_heads), + dtype=torch.float32, + device=x.device) + _dbias = torch.zeros( + (GROUP_N, + num_heads), + dtype=torch.float32, + device=x.device) dweight = torch.empty((num_heads,), dtype=weight.dtype, device=x.device) dbias = torch.empty((num_heads,), dtype=weight.dtype, device=x.device) # pyre-ignore[28] @@ -1713,7 +1775,14 @@ def forward( out = maybe_triton_addmm_fwd(x=y, w=output_weight, y=x) - saved_tensors = [attn, u, norm_weight, norm_bias, mean, rstd, output_weight] + saved_tensors = [ + attn, + u, + norm_weight, + norm_bias, + mean, + rstd, + output_weight] if not recompute_y_in_backward: saved_tensors.append(y) ctx.save_for_backward(*saved_tensors) @@ -1953,7 +2022,8 @@ def helion_layer_norm_mul_dropout_fwd( N, D = x.shape if seed is None: - seed = torch.randint(low=0, high=2**62, size=(1,), dtype=torch.int64).item() + seed = torch.randint(low=0, high=2**62, size=(1,), + dtype=torch.int64).item() if concat_ux: y = torch.empty([N, 3 * D], dtype=x.dtype, device=x.device) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_preprocess_and_attention.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_preprocess_and_attention.py index 85e60db3c7..c1514b4ffc 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_preprocess_and_attention.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_hstu_preprocess_and_attention.py @@ -189,7 +189,8 @@ def backward( idx += 1 if ctx.recompute_uvqk_in_backward: uvqk_bias = ctx.saved_tensors[idx] - uvqk = maybe_triton_addmm_fwd(x=normed_x, w=uvqk_weight, y=uvqk_bias) + uvqk = maybe_triton_addmm_fwd( + x=normed_x, w=uvqk_weight, y=uvqk_bias) idx += 1 else: uvqk = ctx.saved_tensors[idx] diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged.py index 46884a63d0..877da3193b 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged.py @@ -222,7 +222,8 @@ def jagged_dense_bmm_broadcast_add_kernel( jg = tl.load( jg_ptrs, # pyre-fixme[16]: `int` has no attribute `__getitem__`. - mask=(offs_m[:, None] < (seq_len - start_m)) & ((k + offs_k)[None, :] < K), + mask=(offs_m[:, None] < (seq_len - start_m) + ) & ((k + offs_k)[None, :] < K), other=0.0, ) dn = tl.load( @@ -237,10 +238,12 @@ def jagged_dense_bmm_broadcast_add_kernel( if HAS_BIAS: if ELEMENTWISE: Bias += (seq_start + start_m) * stride_bias_b - bias_ptrs = Bias + offs_m[:, None] * stride_bias_b + offs_n[None, :] + bias_ptrs = Bias + offs_m[:, None] * \ + stride_bias_b + offs_n[None, :] bias = tl.load( bias_ptrs, - mask=(offs_m[:, None] < (seq_len - start_m)) & (offs_n[None, :] < N), + mask=(offs_m[:, None] < (seq_len - start_m) + ) & (offs_n[None, :] < N), other=0.0, ) accumulator += bias.to(tl.float32) @@ -357,14 +360,16 @@ def _jagged_jagged_bmm_reduce_sum( JaggedA += seq_start * stride_ak JaggedB += seq_start * stride_bk offs_k = tl.arange(0, BLOCK_K) - jg_a_ptrs = JaggedA + offs_k[None, :] * stride_ak + (start_m + offs_m)[:, None] + jg_a_ptrs = JaggedA + offs_k[None, :] * \ + stride_ak + (start_m + offs_m)[:, None] jg_b_ptrs = JaggedB + offs_k[:, None] * stride_bk + offs_n[None, :] for k in range(0, seq_len, BLOCK_K): jg_a = tl.load( jg_a_ptrs, # pyre-fixme[16]: `int` has no attribute `__getitem__`. - mask=(offs_m[:, None] < (M - start_m)) & ((k + offs_k)[None, :] < seq_len), + mask=(offs_m[:, None] < (M - start_m) + ) & ((k + offs_k)[None, :] < seq_len), other=0.0, ) jg_b = tl.load( @@ -411,7 +416,7 @@ def forward( B, _, K = dense.shape bmm_out = torch.empty((L, K), dtype=jagged.dtype, device=jagged.device) - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(K, meta["BLOCK_N"]), triton.cdiv(max_seq_len, meta["BLOCK_M"]), B, @@ -453,7 +458,7 @@ def backward( d_jagged = torch.empty_like(jagged) d_dense = torch.empty_like(dense) - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(ctx.D, meta["BLOCK_N"]), triton.cdiv(ctx.max_seq_len, meta["BLOCK_M"]), ctx.B, @@ -478,7 +483,7 @@ def backward( ELEMENTWISE=False, ) - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(ctx.D, meta["BLOCK_M"]), triton.cdiv(ctx.K, meta["BLOCK_N"]), ctx.B, @@ -636,7 +641,7 @@ def forward( B, _ = dense.shape out = torch.empty_like(jagged) - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 B, triton.cdiv(max_seq_len, meta["BLOCK_N"]), ) @@ -666,7 +671,8 @@ def backward( ctx, d_out: torch.Tensor ) -> Tuple[None, None, torch.Tensor, torch.Tensor]: seq_offsets = ctx.saved_tensors[0] - d_dense = torch.empty((ctx.B, ctx.D), device=d_out.device, dtype=d_out.dtype) + d_dense = torch.empty( + (ctx.B, ctx.D), device=d_out.device, dtype=d_out.dtype) BLOCK_D = triton.next_power_of_2(ctx.D) if ctx.D < 64 else 64 jagged_reduce_sum[(ctx.B, triton.cdiv(ctx.D, BLOCK_D))]( seq_offsets=seq_offsets, @@ -694,7 +700,7 @@ def triton_jagged_dense_bmm_add_fwd( B, _, N = dense.shape out = torch.empty((L, N), dtype=jagged.dtype, device=jagged.device) - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(N, meta["BLOCK_N"]), triton.cdiv(max_seq_len, meta["BLOCK_M"]), B, @@ -733,7 +739,7 @@ def triton_jagged_dense_bmm_add_bwd_jagged( B: int, N: int, ) -> torch.Tensor: - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(K, meta["BLOCK_N"]), triton.cdiv(max_seq_len, meta["BLOCK_M"]), B, @@ -774,7 +780,7 @@ def triton_jagged_dense_bmm_add_bwd_dense_bias( ) -> Tuple[torch.Tensor, torch.Tensor]: d_bias = torch.empty((B, N), device=d_out.device, dtype=d_out.dtype) - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(K, meta["BLOCK_M"]), triton.cdiv(N, meta["BLOCK_N"]), B, @@ -937,7 +943,8 @@ def concat_2D_jagged_w_prefix( + offs_d ) else: - in_ptrs = ValuesA + (off_a + seq_start_a).to(tl.int64) * stride_ad + offs_d + in_ptrs = ValuesA + \ + (off_a + seq_start_a).to(tl.int64) * stride_ad + offs_d else: off_b = off_n - out_seq_b_start + n_prefix_from_B if off_n < n_prefix_from_B: @@ -950,7 +957,8 @@ def concat_2D_jagged_w_prefix( + offs_d ) else: - in_ptrs = ValuesB + (off_b + seq_start_b).to(tl.int64) * stride_bd + offs_d + in_ptrs = ValuesB + \ + (off_b + seq_start_b).to(tl.int64) * stride_bd + offs_d v = tl.load(in_ptrs, mask=offs_d < D) tl.store(out_ptrs, v, mask=offs_d < D) @@ -1084,12 +1092,14 @@ def split_2D_jagged_w_prefix( in_ptrs = JaggedIn + (seq_start + off_n).to(tl.int64) * stride_id + offs_d if off_n < out_seq_b_start and off_n >= n_prefix_to_B: off_a = off_n - n_prefix_to_B - out_ptrs = OutA + (off_a + seq_start_a).to(tl.int64) * stride_ad + offs_d + out_ptrs = OutA + (off_a + seq_start_a).to(tl.int64) * \ + stride_ad + offs_d else: off_b = off_n - out_seq_b_start + n_prefix_to_B if off_n < n_prefix_to_B: off_b += out_seq_b_start - n_prefix_to_B - out_ptrs = OutB + (off_b + seq_start_b).to(tl.int64) * stride_bd + offs_d + out_ptrs = OutB + (off_b + seq_start_b).to(tl.int64) * \ + stride_bd + offs_d v = tl.load(in_ptrs, mask=offs_d < D) tl.store(out_ptrs, v, mask=offs_d < D) @@ -1425,9 +1435,11 @@ def forward( offsets_b.device, non_blocking=True ) if seq_len_a is None: - seq_len_a = offsets_a.index_select(dim=0, index=offsets_a_last_idx) + seq_len_a = offsets_a.index_select( + dim=0, index=offsets_a_last_idx) if seq_len_b is None: - seq_len_b = offsets_b.index_select(dim=0, index=offsets_b_last_idx) + seq_len_b = offsets_b.index_select( + dim=0, index=offsets_b_last_idx) else: if seq_len_a is None: seq_len_a = int(offsets_a[-1].item()) @@ -1436,9 +1448,11 @@ def forward( _, D = values.shape BLOCK_D = triton.next_power_of_2(D) # pyre-ignore[6] Incompatible parameter type - values_a = torch.empty((seq_len_a, D), device=values.device, dtype=values.dtype) + values_a = torch.empty( + (seq_len_a, D), device=values.device, dtype=values.dtype) # pyre-ignore[6] Incompatible parameter type - values_b = torch.empty((seq_len_b, D), device=values.device, dtype=values.dtype) + values_b = torch.empty( + (seq_len_b, D), device=values.device, dtype=values.dtype) _triton_split_2D_jagged_internal( jagged_in=values, max_seq_len=max_seq_len, @@ -1599,7 +1613,8 @@ def triton_jagged_dense_bmm( jagged: torch.Tensor, dense: torch.Tensor, ) -> torch.Tensor: - return _JaggedDenseBmmFunction.apply(max_seq_len, seq_offsets, jagged, dense) + return _JaggedDenseBmmFunction.apply( + max_seq_len, seq_offsets, jagged, dense) @torch.jit.unused @@ -1690,7 +1705,8 @@ def concat_2D_jagged_w_prefix_multirow( + offs_d[None, :] ) - to_a_mask = (offs_n < out_seq_b_start) & (offs_n >= n_prefix_from_B) & valid_mask + to_a_mask = (offs_n < out_seq_b_start) & ( + offs_n >= n_prefix_from_B) & valid_mask to_b_mask = ~to_a_mask & valid_mask off_a = offs_n - n_prefix_from_B @@ -1708,12 +1724,18 @@ def concat_2D_jagged_w_prefix_multirow( + offs_d[None, :] ) - v_a = tl.load(in_a_ptrs, mask=to_a_mask[:, None] & (offs_d[None, :] < D), other=0.0) + v_a = tl.load(in_a_ptrs, mask=to_a_mask[:, None] & ( + offs_d[None, :] < D), other=0.0) tl.store(out_ptrs, v_a, mask=to_a_mask[:, None] & (offs_d[None, :] < D)) prefix_mask = offs_n < n_prefix_from_B - off_b = tl.where(prefix_mask, offs_n, offs_n - out_seq_b_start + n_prefix_from_B) + off_b = tl.where( + prefix_mask, + offs_n, + offs_n - + out_seq_b_start + + n_prefix_from_B) if IS_DENSE_B: in_b_ptrs = ( ValuesB @@ -1728,7 +1750,8 @@ def concat_2D_jagged_w_prefix_multirow( + offs_d[None, :] ) - v_b = tl.load(in_b_ptrs, mask=to_b_mask[:, None] & (offs_d[None, :] < D), other=0.0) + v_b = tl.load(in_b_ptrs, mask=to_b_mask[:, None] & ( + offs_d[None, :] < D), other=0.0) tl.store(out_ptrs, v_b, mask=to_b_mask[:, None] & (offs_d[None, :] < D)) @@ -1883,22 +1906,31 @@ def split_2D_jagged_w_prefix_multirow( + offs_d[None, :] ) - v = tl.load(in_ptrs, mask=valid_mask[:, None] & (offs_d[None, :] < D), other=0.0) + v = tl.load(in_ptrs, mask=valid_mask[:, None] & ( + offs_d[None, :] < D), other=0.0) - to_a_mask = (offs_n < out_seq_b_start) & (offs_n >= n_prefix_to_B) & valid_mask + to_a_mask = (offs_n < out_seq_b_start) & ( + offs_n >= n_prefix_to_B) & valid_mask to_b_mask = ~to_a_mask & valid_mask off_a = offs_n - n_prefix_to_B out_a_ptrs = ( - OutA + (off_a[:, None] + seq_start_a).to(tl.int64) * stride_ad + offs_d[None, :] + OutA + (off_a[:, None] + seq_start_a).to(tl.int64) * + stride_ad + offs_d[None, :] ) tl.store(out_a_ptrs, v, mask=to_a_mask[:, None] & (offs_d[None, :] < D)) prefix_mask = offs_n < n_prefix_to_B - off_b = tl.where(prefix_mask, offs_n, offs_n - out_seq_b_start + n_prefix_to_B) + off_b = tl.where( + prefix_mask, + offs_n, + offs_n - + out_seq_b_start + + n_prefix_to_B) out_b_ptrs = ( - OutB + (off_b[:, None] + seq_start_b).to(tl.int64) * stride_bd + offs_d[None, :] + OutB + (off_b[:, None] + seq_start_b).to(tl.int64) * + stride_bd + offs_d[None, :] ) tl.store(out_b_ptrs, v, mask=to_b_mask[:, None] & (offs_d[None, :] < D)) @@ -2023,7 +2055,10 @@ def _helion_split_2d_jagged_kernel( ), ) # Load output boundaries for part A - out_a_start = tl.load(offsets_a + batch_id * 1, None, eviction_policy="evict_last") + out_a_start = tl.load( + offsets_a + batch_id * 1, + None, + eviction_policy="evict_last") batch_id_plus_1 = 1 + triton_helpers.div_floor_integer( flat_program_id, triton_helpers.div_floor_integer( @@ -2079,7 +2114,8 @@ def _helion_split_2d_jagged_kernel( disallow_acc_multi_buffer=True, flatten=True, ): - feature_indices = feature_offset + tl.arange(0, _BLOCK_SIZE_1).to(tl.int32) + feature_indices = feature_offset + \ + tl.arange(0, _BLOCK_SIZE_1).to(tl.int32) # Compute D constant and feature mask once per feature iteration D_const = tl.full([], tl.cast(D, tl.int32), tl.int32) @@ -2090,15 +2126,18 @@ def _helion_split_2d_jagged_kernel( row_subscript = row_indices[:, None] input_row_a = input_start_i32 + row_subscript input_idx_a = ( - tl.cast(input_row_a * D_const, tl.int32) + feature_indices[None, :] + tl.cast(input_row_a * D_const, tl.int32) + + feature_indices[None, :] ) out_a_row = out_a_start_i32 + row_subscript out_a_idx = ( - tl.cast(out_a_row * D_const, tl.int32) + feature_indices[None, :] + tl.cast(out_a_row * D_const, tl.int32) + + feature_indices[None, :] ) - mask_a = is_part_a[:, None] & valid_mask[:, None] & feature_mask[None, :] + mask_a = is_part_a[:, None] & valid_mask[:, + None] & feature_mask[None, :] # Load and store part A data slice_a = tl.load( @@ -2118,7 +2157,8 @@ def _helion_split_2d_jagged_kernel( row_minus_len_a = row_subscript - len_a_i32 out_b_row = out_b_start_i32 + row_minus_len_a out_b_idx = ( - tl.cast(out_b_row * D_const, tl.int32) + feature_indices[None, :] + tl.cast(out_b_row * D_const, tl.int32) + + feature_indices[None, :] ) mask_b = is_part_b[:, None] & feature_mask[None, :] @@ -2185,8 +2225,10 @@ def _helion_split_2d_jagged( num_seq_blocks = (max_seq_len + block_size_0 - 1) // block_size_0 total_len_a = int(offsets_a[-1].item()) total_len_b = int(offsets_b[-1].item()) - out_a = torch.empty([total_len_a, D], dtype=values.dtype, device=values.device) - out_b = torch.empty([total_len_b, D], dtype=values.dtype, device=values.device) + out_a = torch.empty( + [total_len_a, D], dtype=values.dtype, device=values.device) + out_b = torch.empty( + [total_len_b, D], dtype=values.dtype, device=values.device) values_flat = values.view(-1) out_a_flat = out_a.view(-1) out_b_flat = out_b.view(-1) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged_tensors.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged_tensors.py index 7fd79ad99d..f117e7ceba 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged_tensors.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_jagged_tensors.py @@ -206,7 +206,8 @@ def _concat_2D_jagged_multirow( valid_mask = offs_n < seq_len out_seq_start = seq_start_a + seq_start_b + offs_n - out_ptrs = Out + out_seq_start[:, None].to(tl.int64) * stride_od + offs_d[None, :] + out_ptrs = Out + \ + out_seq_start[:, None].to(tl.int64) * stride_od + offs_d[None, :] from_prefix_b_mask = (offs_n < n_prefix_from_B) & valid_mask from_a_mask = ( @@ -224,7 +225,8 @@ def _concat_2D_jagged_multirow( v_b1 = tl.load( in_b1_ptrs, mask=from_prefix_b_mask[:, None] & (offs_d[None, :] < D), other=0.0 ) - tl.store(out_ptrs, v_b1, mask=from_prefix_b_mask[:, None] & (offs_d[None, :] < D)) + tl.store(out_ptrs, v_b1, mask=from_prefix_b_mask[:, None] & ( + offs_d[None, :] < D)) off_a = offs_n - n_prefix_from_B in_a_ptrs = ( @@ -246,7 +248,8 @@ def _concat_2D_jagged_multirow( v_b2 = tl.load( in_b2_ptrs, mask=from_suffix_b_mask[:, None] & (offs_d[None, :] < D), other=0.0 ) - tl.store(out_ptrs, v_b2, mask=from_suffix_b_mask[:, None] & (offs_d[None, :] < D)) + tl.store(out_ptrs, v_b2, mask=from_suffix_b_mask[:, None] & ( + offs_d[None, :] < D)) @triton_autotune( @@ -343,11 +346,13 @@ def _split_2D_jagged_multirow( + offs_d[None, :] ) - v = tl.load(in_ptrs, mask=valid_mask[:, None] & (offs_d[None, :] < D), other=0.0) + v = tl.load(in_ptrs, mask=valid_mask[:, None] & ( + offs_d[None, :] < D), other=0.0) to_prefix_b_mask = (offs_n < n_prefix_to_B) & valid_mask to_a_mask = ( - (offs_n >= n_prefix_to_B) & (offs_n < seq_len_a + n_prefix_to_B) & valid_mask + (offs_n >= n_prefix_to_B) & ( + offs_n < seq_len_a + n_prefix_to_B) & valid_mask ) to_suffix_b_mask = (offs_n >= seq_len_a + n_prefix_to_B) & valid_mask @@ -356,19 +361,23 @@ def _split_2D_jagged_multirow( + (offs_n[:, None] + seq_start_b).to(tl.int64) * stride_bd + offs_d[None, :] ) - tl.store(out_b1_ptrs, v, mask=to_prefix_b_mask[:, None] & (offs_d[None, :] < D)) + tl.store(out_b1_ptrs, v, mask=to_prefix_b_mask[:, None] & ( + offs_d[None, :] < D)) off_a = offs_n - n_prefix_to_B out_a_ptrs = ( - OutA + (off_a[:, None] + seq_start_a).to(tl.int64) * stride_ad + offs_d[None, :] + OutA + (off_a[:, None] + seq_start_a).to(tl.int64) * + stride_ad + offs_d[None, :] ) tl.store(out_a_ptrs, v, mask=to_a_mask[:, None] & (offs_d[None, :] < D)) off_b = offs_n - seq_len_a out_b2_ptrs = ( - OutB + (off_b[:, None] + seq_start_b).to(tl.int64) * stride_bd + offs_d[None, :] + OutB + (off_b[:, None] + seq_start_b).to(tl.int64) * + stride_bd + offs_d[None, :] ) - tl.store(out_b2_ptrs, v, mask=to_suffix_b_mask[:, None] & (offs_d[None, :] < D)) + tl.store(out_b2_ptrs, v, mask=to_suffix_b_mask[:, None] & ( + offs_d[None, :] < D)) @triton_autotune( @@ -455,7 +464,8 @@ def _concat_2D_jagged( out_seq_start = seq_start_a + seq_start_b + off_n out_ptrs = Out + out_seq_start.to(tl.int64) * stride_od + offs_d if off_n < n_prefix_from_B: - in_ptrs = ValuesB + (off_n + seq_start_b).to(tl.int64) * stride_bd + offs_d + in_ptrs = ValuesB + \ + (off_n + seq_start_b).to(tl.int64) * stride_bd + offs_d elif off_n < seq_len_a + n_prefix_from_B: in_ptrs = ( ValuesA @@ -513,7 +523,8 @@ def _split_2D_jagged( offs_d = tl.arange(0, BLOCK_D) in_ptrs = JaggedIn + (seq_start + off_n).to(tl.int64) * stride_id + offs_d if off_n < n_prefix_to_B: - out_ptrs = OutB + (off_n + seq_start_b).to(tl.int64) * stride_bd + offs_d + out_ptrs = OutB + (off_n + seq_start_b).to(tl.int64) * \ + stride_bd + offs_d elif off_n < seq_len_a + n_prefix_to_B: out_ptrs = ( OutA @@ -522,7 +533,8 @@ def _split_2D_jagged( ) else: out_ptrs = ( - OutB + (off_n - seq_len_a + seq_start_b).to(tl.int64) * stride_bd + offs_d + OutB + (off_n - seq_len_a + seq_start_b).to(tl.int64) * + stride_bd + offs_d ) v = tl.load(in_ptrs, mask=offs_d < D) tl.store(out_ptrs, v, mask=offs_d < D) diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_layer_norm.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_layer_norm.py index 2327ab14c6..736bffcb79 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_layer_norm.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_layer_norm.py @@ -400,8 +400,16 @@ def _weighted_layer_norm_bwd_dx( dx = dy_block * sigmoid_layer_norm + dx # Write dx - tl.store(DX_block_ptr, dx.to(DX.dtype.element_ty), boundary_check=(0, 1)) - partial_dw = tl.sum(dy_block * x_block * xhat * sigmoid_deriv, axis=0) + tl.store( + DX_block_ptr, dx.to( + DX.dtype.element_ty), boundary_check=( + 0, 1)) + partial_dw = tl.sum( + dy_block * + x_block * + xhat * + sigmoid_deriv, + axis=0) partial_db = tl.sum(dy_block * x_block * sigmoid_deriv, axis=0) else: c1 = tl.sum(xhat * wdy, axis=1) / D @@ -410,7 +418,10 @@ def _weighted_layer_norm_bwd_dx( c2 = tl.expand_dims(c2, 1) dx = (wdy - (xhat * c1 + c2)) * rstd # Write dx - tl.store(DX_block_ptr, dx.to(DX.dtype.element_ty), boundary_check=(0, 1)) + tl.store( + DX_block_ptr, dx.to( + DX.dtype.element_ty), boundary_check=( + 0, 1)) partial_dw = tl.sum(dy_block * xhat, axis=0) partial_db = tl.sum(dy_block, axis=0) @@ -471,8 +482,16 @@ def _layer_norm_bwd_dwdb( sum_dw = tl.sum(dw, axis=0) sum_db = tl.sum(db, axis=0) - tl.store(FINAL_DW + cols, sum_dw.to(FINAL_DW.dtype.element_ty), mask=cols < D) - tl.store(FINAL_DB + cols, sum_db.to(FINAL_DB.dtype.element_ty), mask=cols < D) + tl.store( + FINAL_DW + cols, + sum_dw.to( + FINAL_DW.dtype.element_ty), + mask=cols < D) + tl.store( + FINAL_DB + cols, + sum_db.to( + FINAL_DB.dtype.element_ty), + mask=cols < D) def triton_weighted_layer_norm_fwd( @@ -505,13 +524,14 @@ def triton_weighted_layer_norm_fwd( MAX_FUSED_SIZE = 65536 // x.element_size() BLOCK_D: int = min(MAX_FUSED_SIZE, triton.next_power_of_2(D)) if D > BLOCK_D: - raise RuntimeError("This layer norm doesn't support feature dim >= 64KB.") + raise RuntimeError( + "This layer norm doesn't support feature dim >= 64KB.") if N == 0: return y, mean, rstd, BLOCK_D # pyre-ignore[28] - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(N, meta["BLOCK_N"]), ) if learnable: @@ -569,8 +589,10 @@ def triton_weighted_layer_norm_bwd( dx = torch.empty_like(x) sms = torch.cuda.get_device_properties(x.device).multi_processor_count tile_num = max(1, min(sms * 8, N // 4)) - _dweight = torch.empty((tile_num, D), dtype=torch.float32, device=x.device) - _dbias = torch.empty((tile_num, D), dtype=torch.float32, device=x.device) + _dweight = torch.empty( + (tile_num, D), dtype=torch.float32, device=x.device) + _dbias = torch.empty( + (tile_num, D), dtype=torch.float32, device=x.device) dweight = torch.empty((D,), dtype=weight.dtype, device=x.device) dbias = torch.empty((D,), dtype=weight.dtype, device=x.device) if N == 0: @@ -768,7 +790,8 @@ def _weighted_rms_norm_fwd( y = y * w[None, :] if SILU: - # pyre-ignore[16]: Module `triton.language.math` has no attribute `fast_dividef` + # pyre-ignore[16]: Module `triton.language.math` has no attribute + # `fast_dividef` y = fast_dividef(y, 1.0 + tl.exp(-y)) tl.store(Y_block_ptr, y.to(Y.dtype.element_ty), boundary_check=(0, 1)) @@ -928,7 +951,8 @@ def _weighted_rms_norm_bwd( # pyre-fixme[16] sig_y = fast_dividef(1.0, 1.0 + tl.exp(-y_before_silu)) # SILU derivative: sigmoid(y) + y * sigmoid(y) * (1 - sigmoid(y)) - dy_block = dy_block * (sig_y + y_before_silu * sig_y * (1.0 - sig_y)) + dy_block = dy_block * \ + (sig_y + y_before_silu * sig_y * (1.0 - sig_y)) wdy = w[None, :] * dy_block @@ -937,7 +961,10 @@ def _weighted_rms_norm_bwd( dx = (wdy - (xhat * c1)) * rstd # Write dx - tl.store(DX_block_ptr, dx.to(DX.dtype.element_ty), boundary_check=(0, 1)) + tl.store( + DX_block_ptr, dx.to( + DX.dtype.element_ty), boundary_check=( + 0, 1)) # Accumulate partial sums for dw # Compute dw for all rows, then sum locally before atomic operation @@ -975,7 +1002,11 @@ def _rms_norm_bwd_dwdb( dw += tl.load(DW + offs, mask=mask, other=0.0) sum_dw = tl.sum(dw, axis=0) - tl.store(FINAL_DW + cols, sum_dw.to(FINAL_DW.dtype.element_ty), mask=cols < D) + tl.store( + FINAL_DW + cols, + sum_dw.to( + FINAL_DW.dtype.element_ty), + mask=cols < D) class RMSNormFunction(torch.autograd.Function): @@ -1001,7 +1032,8 @@ def forward( MAX_FUSED_SIZE = 65536 // x.element_size() BLOCK_D = min(MAX_FUSED_SIZE, triton.next_power_of_2(D)) if D > BLOCK_D: - raise RuntimeError("This layer norm doesn't support feature dim >= 64KB.") + raise RuntimeError( + "This layer norm doesn't support feature dim >= 64KB.") ctx.save_for_backward(x, weight, rstd) ctx.silu = silu @@ -1009,7 +1041,7 @@ def forward( return y # pyre-ignore[28] - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(N, meta["BLOCK_N"]), ) _weighted_rms_norm_fwd[grid]( @@ -1110,7 +1142,7 @@ def forward( return y # pyre-ignore[28] - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 triton.cdiv(N, meta["BLOCK_N"]), ) _weighted_layer_norm_fwd[grid]( @@ -1143,8 +1175,10 @@ def backward( dx = torch.empty_like(x) sms = torch.cuda.get_device_properties(x.device).multi_processor_count tile_num = max(1, min(sms * 8, N // 4)) - _dweight = torch.empty((tile_num, D), dtype=torch.float32, device=x.device) - _dbias = torch.empty((tile_num, D), dtype=torch.float32, device=x.device) + _dweight = torch.empty( + (tile_num, D), dtype=torch.float32, device=x.device) + _dbias = torch.empty( + (tile_num, D), dtype=torch.float32, device=x.device) dweight = torch.empty((D,), dtype=weight.dtype, device=x.device) dbias = torch.empty((D,), dtype=weight.dtype, device=x.device) if N == 0: diff --git a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_position.py b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_position.py index 793b61f5e0..63aa54c732 100644 --- a/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_position.py +++ b/recommendation/dlrm_v3/generative_recommenders/ops/triton/triton_position.py @@ -150,7 +150,8 @@ def _add_timestamp_position_embeddings_kernel( seq_emb = tl.load(SeqEmb + seq_emb_offsets, mask=mask) pos_emb = tl.load(PosEmb + pos_emb_offsets, mask=mask) ts_emb = tl.load(TsEmb + ts_emb_offsets, mask=mask) - tl.store(out_offsets, seq_emb + (pos_emb + ts_emb).to(seq_emb.dtype), mask=mask) + tl.store(out_offsets, seq_emb + (pos_emb + + ts_emb).to(seq_emb.dtype), mask=mask) seq_emb_offsets += BLOCK_D pos_emb_offsets += BLOCK_D ts_emb_offsets += BLOCK_D @@ -274,7 +275,7 @@ def forward( pos_inds = torch.empty_like(seq_embeddings[:, 0], dtype=torch.int32) ts_emb_size = ts_embeddings.shape[0] - grid = lambda meta: ( # noqa E731 + def grid(meta): return ( # noqa E731 B, triton.cdiv(max_seq_len, meta["BLOCK_N"]), ) @@ -309,7 +310,8 @@ def forward( BLOCK_D=BLOCK_D, ) try: - values = torch.arange(0, N, dtype=torch.int32, device=timestamps.device) + values = torch.arange( + 0, N, dtype=torch.int32, device=timestamps.device) sorted_ts_key_inds, sorted_ts_value_inds = torch.ops.hammer.sort_kv_pairs( ts_inds, values ) @@ -363,7 +365,7 @@ def backward( d_ts_embeddings = torch.empty( (ctx.ts_emb_size, ctx.D), device=d_out.device, dtype=torch.float32 ) - grid = lambda meta: (triton.cdiv(d_out.shape[0], meta["BLOCK"]),) # noqa E731 + def grid(meta): return (triton.cdiv(d_out.shape[0], meta["BLOCK"]),) # noqa E731 AUTOTUNE_B = prev_power_of_2(ctx.B) _add_embeddings_bwd_kernel[grid]( In=d_out, diff --git a/recommendation/dlrm_v3/inference_modules.py b/recommendation/dlrm_v3/inference_modules.py index 6bc78694f2..2a0cae495b 100644 --- a/recommendation/dlrm_v3/inference_modules.py +++ b/recommendation/dlrm_v3/inference_modules.py @@ -195,7 +195,8 @@ def move_sparse_output_to_device( seq_embeddings = { k: SequenceEmbedding( lengths=seq_embeddings[k].lengths.to(device), - embedding=seq_embeddings[k].embedding.to(device).to(torch.bfloat16), + embedding=seq_embeddings[k].embedding.to( + device).to(torch.bfloat16), ) for k in seq_embeddings.keys() } diff --git a/recommendation/dlrm_v3/main.py b/recommendation/dlrm_v3/main.py index b5dbbe3169..b04dfea7ee 100644 --- a/recommendation/dlrm_v3/main.py +++ b/recommendation/dlrm_v3/main.py @@ -17,6 +17,31 @@ mlperf dlrm_v3 inference benchmarking tool. """ +from utils import ( + get_dataset, + profiler_or_nullcontext, + SUPPORTED_DATASETS, +) +from model_family import HSTUModelFamily +from inference_modules import set_is_inference +from data_producer import ( + MultiThreadDataProducer, + QueryItem, + SingleThreadDataProducer, +) +from datasets.synthetic_streaming import ( + DLRMv3SyntheticStreamingDataset, +) +from datasets.dataset import Dataset, Samples +from configs import get_embedding_table_config, get_hstu_configs +from generative_recommenders.common import set_dev_mode, set_verbose_level +import torch +import numpy as np +import mlperf_loadgen as lg # @manual +from typing import Any, Dict, List, Optional, Union +import time +import sys +import os import argparse import array import logging @@ -24,33 +49,8 @@ import threading logging.basicConfig(level=logging.INFO) -import os -import sys -import time -from typing import Any, Dict, List, Optional, Union # pyre-ignore [21] -import mlperf_loadgen as lg # @manual -import numpy as np -import torch -from generative_recommenders.common import set_dev_mode, set_verbose_level -from configs import get_embedding_table_config, get_hstu_configs -from datasets.dataset import Dataset, Samples -from datasets.synthetic_streaming import ( - DLRMv3SyntheticStreamingDataset, -) -from data_producer import ( - MultiThreadDataProducer, - QueryItem, - SingleThreadDataProducer, -) -from inference_modules import set_is_inference -from model_family import HSTUModelFamily -from utils import ( - get_dataset, - profiler_or_nullcontext, - SUPPORTED_DATASETS, -) logger: logging.Logger = logging.getLogger("main") @@ -221,15 +221,17 @@ def run_one_item(self, qitem: QueryItem) -> None: query_mt_target_preds = ( mt_target_preds[ # pyre-ignore [61] 0, - candidate_size * i : candidate_size * (i + 1), + candidate_size * i: candidate_size * (i + 1), ] .view(-1) .float() .numpy() ) - response_array = array.array("B", query_mt_target_preds.tobytes()) + response_array = array.array( + "B", query_mt_target_preds.tobytes()) bi = response_array.buffer_info() - # since we send buffer to loadgen, needs `response_array` in memory during send + # since we send buffer to loadgen, needs `response_array` + # in memory during send lg.QuerySamplesComplete( [lg.QuerySampleResponse(query_id, bi[0], bi[1])] ) @@ -237,7 +239,7 @@ def run_one_item(self, qitem: QueryItem) -> None: for i, query_id in enumerate(qitem.query_ids): query_mt_target_preds = ( mt_target_preds[ # pyre-ignore [61] - 0, candidate_size * i : candidate_size * (i + 1) + 0, candidate_size * i: candidate_size * (i + 1) ] .view(-1) .float() @@ -245,7 +247,7 @@ def run_one_item(self, qitem: QueryItem) -> None: ) query_mt_target_labels = ( mt_target_labels[ # pyre-ignore [16,61] - 0, candidate_size * i : candidate_size * (i + 1) + 0, candidate_size * i: candidate_size * (i + 1) ] .view(-1) .float() @@ -253,7 +255,7 @@ def run_one_item(self, qitem: QueryItem) -> None: ) query_mt_target_weights = ( mt_target_weights[ # pyre-ignore [61] - 0, candidate_size * i : candidate_size * (i + 1) + 0, candidate_size * i: candidate_size * (i + 1) ] .view(-1) .float() @@ -269,7 +271,8 @@ def run_one_item(self, qitem: QueryItem) -> None: ) response_array = array.array("B", np_array.tobytes()) bi = response_array.buffer_info() - # since we send buffer to loadgen, needs `response_array` in memory during send + # since we send buffer to loadgen, needs `response_array` + # in memory during send lg.QuerySamplesComplete( [lg.QuerySampleResponse(query_id, bi[0], bi[1])] ) @@ -297,10 +300,10 @@ def enqueue(self, query_samples, t0: float) -> None: # pyre-ignore [2] for i in range(len(self.current_query_ids) // self.batchsize): self.data_producer.enqueue( query_ids=self.current_query_ids[ - i * self.batchsize : (i + 1) * self.batchsize + i * self.batchsize: (i + 1) * self.batchsize ], content_ids=self.current_content_ids[ - i * self.batchsize : (i + 1) * self.batchsize + i * self.batchsize: (i + 1) * self.batchsize ], t0=t0, dt_queue=dt_queue, @@ -345,7 +348,8 @@ def add_results( timing: list[float] = [result[key] for result in result_timing] buckets: List[float] = np.percentile(timing, percentiles).tolist() buckets_str: str = ",".join( - ["| {}:{:.4f}| ".format(p, b) for p, b in zip(percentiles, buckets)] + ["| {}:{:.4f}| ".format(p, b) + for p, b in zip(percentiles, buckets)] ) buckets_dict[key] = buckets buckets_str_dict[key] = buckets_str @@ -397,7 +401,8 @@ def get_num_queries( Number of queries to execute in the benchmark run. """ if scenario_name == "Offline": - # consistent with https://github.com/mlcommons/inference/blob/8999c4d686f6e4a180da14597c97063fce7c9f33/loadgen/test_settings_internal.cc#L147 + # consistent with + # https://github.com/mlcommons/inference/blob/8999c4d686f6e4a180da14597c97063fce7c9f33/loadgen/test_settings_internal.cc#L147 return int(1.1 * target_duration / 1000 * offline_target_qps) else: if input_size is None: @@ -547,7 +552,8 @@ def get_samples(self, id_list: List[int]) -> List[Samples]: if curr_ts_idx == self.inference_ts - 1: curr_ts_queries += self.remaining_queries begin_query_idx: int = self.ts_processed_cnt - end_query_idx: int = min(begin_query_idx + batch_size, curr_ts_queries) + end_query_idx: int = min( + begin_query_idx + batch_size, curr_ts_queries) begin_request_idx: int = begin_query_idx % curr_ts_unique_requests end_request_idx: int = end_query_idx % curr_ts_unique_requests if begin_query_idx + batch_size >= curr_ts_queries: @@ -639,7 +645,8 @@ def run( """ set_dev_mode(False) if scenario_name not in SCENARIO_MAP: - raise NotImplementedError("valid scanarios:" + str(list(SCENARIO_MAP.keys()))) + raise NotImplementedError( + "valid scanarios:" + str(list(SCENARIO_MAP.keys()))) scenario = SCENARIO_MAP[scenario_name] np.random.seed(numpy_rand_seed) random.seed(numpy_rand_seed) @@ -773,7 +780,8 @@ def flush_queries() -> None: ds.unload_query_samples, ) with profiler_or_nullcontext(enabled=output_trace, with_stack=False): - logger.info(f"starting warmup {scenario} with {warmup_count} queries") + logger.info( + f"starting warmup {scenario} with {warmup_count} queries") lg.StartTest(sut, qsl, settings) lg.DestroyQSL(qsl) lg.DestroySUT(sut) diff --git a/recommendation/dlrm_v3/model_family.py b/recommendation/dlrm_v3/model_family.py index e40b8d4c02..8d4b81da6a 100644 --- a/recommendation/dlrm_v3/model_family.py +++ b/recommendation/dlrm_v3/model_family.py @@ -258,7 +258,8 @@ def load(self, model_path: str) -> None: activation=quant.PlaceholderObserver.with_args( dtype=torch.float ), - weight=quant.PlaceholderObserver.with_args(dtype=torch.int8), + weight=quant.PlaceholderObserver.with_args( + dtype=torch.int8), ), }, mapping={ @@ -361,8 +362,10 @@ def __init__( self.dist_backend = "nccl" ctx = mp.get_context("spawn") - self.samples_q: List[mp.Queue] = [ctx.Queue() for _ in range(self.world_size)] - self.result_q: List[mp.Queue] = [ctx.Queue() for _ in range(self.world_size)] + self.samples_q: List[mp.Queue] = [ctx.Queue() + for _ in range(self.world_size)] + self.result_q: List[mp.Queue] = [ctx.Queue() + for _ in range(self.world_size)] def load(self, model_path: str) -> None: """ @@ -387,7 +390,8 @@ def load(self, model_path: str) -> None: p.start() processes.append(p) - def distributed_setup(self, rank: int, world_size: int, model_path: str) -> None: + def distributed_setup(self, rank: int, world_size: int, + model_path: str) -> None: """ Initialize and run a dense worker process. @@ -431,7 +435,8 @@ def distributed_setup(self, rank: int, world_size: int, model_path: str) -> None assert profiler is not None profiler.step() with torch.profiler.record_function("get_item_from_queue"): - # Copy here to release data in the producer to avoid invalid cuda caching allocator release. + # Copy here to release data in the producer to avoid + # invalid cuda caching allocator release. item = copy.deepcopy(item) ( id, @@ -510,7 +515,8 @@ def predict( max_num_candidates: int, num_candidates: Optional[torch.Tensor], ) -> Optional[ - Tuple[torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], float] + Tuple[torch.Tensor, Optional[torch.Tensor], + Optional[torch.Tensor], float] ]: """ Run distributed dense forward pass. diff --git a/recommendation/dlrm_v3/streaming_synthetic_data.py b/recommendation/dlrm_v3/streaming_synthetic_data.py index 8046909e00..d0f96e56aa 100644 --- a/recommendation/dlrm_v3/streaming_synthetic_data.py +++ b/recommendation/dlrm_v3/streaming_synthetic_data.py @@ -133,7 +133,8 @@ def generate_one_timestamp( total_cnt = sum(category_to_cnt.values()) p = np.array( [ - (alpha / len(categories) + category_to_cnt[c]) / (alpha + total_cnt) + (alpha / len(categories) + + category_to_cnt[c]) / (alpha + total_cnt) for c in categories ] ) @@ -146,7 +147,8 @@ def generate_one_timestamp( ) sample_inds = np.random.randint(0, sample_end_idx, size=seq_len) offsets = np.array( - [self.category_to_start_end_item_idx[cat][0] for cat in item_categories] + [self.category_to_start_end_item_idx[cat][0] + for cat in item_categories] ) sample_inds = sample_inds + offsets num_categories = len(categories) @@ -205,7 +207,8 @@ def generate_one_timestamp( for i in range(seq_len) ] if not inference: - sub_indices = random.sample(range(seq_len), self.num_eval_candidates) + sub_indices = random.sample( + range(seq_len), self.num_eval_candidates) sample_candidate_inds = [sample_inds[i] for i in sub_indices] sample_candidate_ratings = [sample_ratings[i] for i in sub_indices] sample_uih_inds = sample_inds @@ -281,7 +284,10 @@ def generate_one_user( Returns: List of CSV row values for this user's data. """ - categories = random.sample(range(self.num_categories), self.categories_per_user) + categories = random.sample( + range( + self.num_categories), + self.categories_per_user) category_to_cnt = {c: 0 for c in categories} out_list: List[str] = [] # t = -1 as base UIH @@ -304,7 +310,8 @@ def generate_one_user( ts_buffers=ts_buffers, ) out_list.append(",".join([str(ind) for ind in sample_candidate_inds])) - out_list.append(",".join([str(rat) for rat in sample_candidate_ratings])) + out_list.append(",".join([str(rat) + for rat in sample_candidate_ratings])) out_list.append(",".join([str(ind) for ind in sample_inds])) out_list.append(",".join([str(rat) for rat in sample_ratings])) # train @@ -328,7 +335,8 @@ def generate_one_user( file_idx=file_idx, ts_buffers=ts_buffers, ) - out_list.append(",".join([str(ind) for ind in sample_candidate_inds])) + out_list.append(",".join([str(ind) + for ind in sample_candidate_inds])) out_list.append( ",".join([str(rat) for rat in sample_candidate_ratings]) ) @@ -356,7 +364,8 @@ def generate_one_user( ts_buffers=ts_buffers, ) out_list.append(",".join([str(ind) for ind in sample_candidate_inds])) - out_list.append(",".join([str(rat) for rat in sample_candidate_ratings])) + out_list.append(",".join([str(rat) + for rat in sample_candidate_ratings])) out_list.append(",".join([str(ind) for ind in sample_inds])) out_list.append(",".join([str(rat) for rat in sample_ratings])) # inference @@ -382,7 +391,8 @@ def generate_one_user( file_idx=file_idx, ts_buffers=ts_buffers, ) - out_list.append(",".join([str(ind) for ind in sample_candidate_inds])) + out_list.append(",".join([str(ind) + for ind in sample_candidate_inds])) out_list.append( ",".join([str(rat) for rat in sample_candidate_ratings]) ) @@ -491,7 +501,10 @@ def worker( rank=rank, ) num_files_per_rank = num_files // world_size - file_indices = [i + rank * num_files_per_rank for i in range(num_files_per_rank)] + file_indices = [ + i + + rank * + num_files_per_rank for i in range(num_files_per_rank)] for file_idx in file_indices: logger.warning(f"rank {rank}: start generating file {file_idx}") generator.write_dataset( @@ -534,7 +547,8 @@ def write_offset(output_folder: str, num_files: int, num_users: int) -> None: writer.writerow([",".join([str(offset) for offset in offsets])]) -def write_ts_metadata(output_folder: str, total_ts: int, num_files: int) -> None: +def write_ts_metadata(output_folder: str, total_ts: int, + num_files: int) -> None: """ Write timestamp metadata for streaming simulation. @@ -563,7 +577,8 @@ def write_ts_metadata(output_folder: str, total_ts: int, num_files: int) -> None num_users_per_file.append(size) cumsum = np.cumsum(num_users_per_file).tolist() assert cumsum[-1] == len(requests) - requests_writer.writerow([",".join([str(r) for r in requests])]) + requests_writer.writerow( + [",".join([str(r) for r in requests])]) cumsum_writer.writerow([",".join([str(s) for s in cumsum])]) logger.warning(f"ts {ts} finished") with open( @@ -579,7 +594,8 @@ def write_ts_metadata(output_folder: str, total_ts: int, num_files: int) -> None if not line: break offsets.append(offset) - assert len(offsets) == total_ts, f"total_ts {total_ts} != {len(offsets)}" + assert len( + offsets) == total_ts, f"total_ts {total_ts} != {len(offsets)}" logger.warning("offsets for file requests_per_ts.csv finished") writer.writerow([",".join([str(offset) for offset in offsets])]) diff --git a/recommendation/dlrm_v3/utils.py b/recommendation/dlrm_v3/utils.py index 4d18d360d1..11743d219d 100644 --- a/recommendation/dlrm_v3/utils.py +++ b/recommendation/dlrm_v3/utils.py @@ -34,7 +34,8 @@ MultitaskTaskType, TaskConfig, ) -from torch.profiler import profile, profiler, ProfilerActivity # pyre-ignore [21] +# pyre-ignore [21] +from torch.profiler import profile, profiler, ProfilerActivity from torch.utils.tensorboard import SummaryWriter from torchrec.metrics.accuracy import AccuracyMetricComputation from torchrec.metrics.gauc import GAUCMetricComputation @@ -94,7 +95,8 @@ def profiler_or_nullcontext(enabled: bool, with_stack: bool): """ return ( profile( - # pyre-fixme[16]: Module `profiler` has no attribute `ProfilerActivity`. + # pyre-fixme[16]: Module `profiler` has no attribute + # `ProfilerActivity`. activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], on_trace_ready=_on_trace_ready_fn(), with_stack=with_stack, @@ -126,7 +128,8 @@ def __init__(self, rank, active: int = 50) -> None: repeat=1, ), on_trace_ready=_on_trace_ready_fn(self.rank), - # pyre-fixme[16]: Module `profiler` has no attribute `ProfilerActivity`. + # pyre-fixme[16]: Module `profiler` has no attribute + # `ProfilerActivity`. activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True, profile_memory=False, @@ -180,7 +183,8 @@ def __init__( assert all_classification_tasks + all_regression_tasks == [ task.task_name for task in multitask_configs ] - self.task_names: List[str] = all_classification_tasks + all_regression_tasks + self.task_names: List[str] = all_classification_tasks + \ + all_regression_tasks self.class_metrics: Dict[str, List[RecMetricComputation]] = { "train": [], @@ -239,7 +243,8 @@ def __init__( self.global_step: Dict[str, int] = {"train": 0, "eval": 0} self.tb_logger: Optional[SummaryWriter] = None if tensorboard_log_path != "": - self.tb_logger = SummaryWriter(log_dir=tensorboard_log_path, purge_step=0) + self.tb_logger = SummaryWriter( + log_dir=tensorboard_log_path, purge_step=0) self.tb_logger.flush() @property From a6726fe974dbc7af098a430d32125a28873fd720 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Jan 2026 20:01:00 +0000 Subject: [PATCH 16/59] [Automated Commit] Format Codebase --- multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/evaluation.py | 2 +- tools/submission/submission_checker.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/evaluation.py b/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/evaluation.py index 2c629b7d26..d1701a19ff 100644 --- a/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/evaluation.py +++ b/multimodal/qwen3-vl/src/mlperf_inf_mm_q3vl/evaluation.py @@ -408,4 +408,4 @@ def run_evaluation(random_seed: int, filename: FilePath, with open("accuracy.txt", "w") as f: f.write("Results\n\n") f.write(f"{data_string}\n\n") - f.write(f"hash={file_hash}") \ No newline at end of file + f.write(f"hash={file_hash}") diff --git a/tools/submission/submission_checker.py b/tools/submission/submission_checker.py index f5b8aa8753..62bd6a87bb 100755 --- a/tools/submission/submission_checker.py +++ b/tools/submission/submission_checker.py @@ -1514,7 +1514,9 @@ def check_accuracy_dir(config, model, path, verbose): is_valid = False else: if os.stat(fname).st_size > MAX_ACCURACY_LOG_SIZE: - log.error("Max expected file size is: %s bytes", MAX_ACCURACY_LOG_SIZE) + log.error( + "Max expected file size is: %s bytes", + MAX_ACCURACY_LOG_SIZE) log.error("%s is not truncated", fname) is_valid = False From 7c756c82ad25e095fd20996dc17360f4ea3fe576 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Jan 2026 16:09:13 +0000 Subject: [PATCH 17/59] [Automated Commit] Format Codebase --- compliance/TEST07/run_verification.py | 42 +++++++++++++++------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/compliance/TEST07/run_verification.py b/compliance/TEST07/run_verification.py index 44353aa31a..906cbd56a3 100755 --- a/compliance/TEST07/run_verification.py +++ b/compliance/TEST07/run_verification.py @@ -49,15 +49,15 @@ def parse_audit_config(config_path): """ Parse audit.config file and extract TEST07-specific settings. - + Returns: dict: Parsed configuration values """ config = {} - + if not os.path.isfile(config_path): return config - + try: with open(config_path, 'r') as f: for line in f: @@ -65,27 +65,29 @@ def parse_audit_config(config_path): # Skip comments and empty lines if not line or line.startswith('#'): continue - + # Parse key = value if '=' in line: key, value = line.split('=', 1) key = key.strip() value = value.strip() - - # Extract the setting name (last part of key like *.*.setting_name) + + # Extract the setting name (last part of key like + # *.*.setting_name) parts = key.split('.') if len(parts) >= 3: setting_name = parts[-1] - + # Parse test07_accuracy_threshold if setting_name == 'test07_accuracy_threshold': try: config['accuracy_threshold'] = float(value) except ValueError: - print(f"Warning: Invalid threshold value in audit.config: {value}") + print( + f"Warning: Invalid threshold value in audit.config: {value}") except Exception as e: print(f"Warning: Error parsing audit.config: {e}") - + return config @@ -149,28 +151,29 @@ def main(): # Determine accuracy threshold accuracy_threshold = args.accuracy_threshold audit_config_path = args.audit_config - + # Try to read threshold from audit.config if provided if audit_config_path: print(f"Reading audit.config from: {audit_config_path}") audit_config = parse_audit_config(audit_config_path) - + if 'accuracy_threshold' in audit_config: config_threshold = audit_config['accuracy_threshold'] print(f"Found threshold in audit.config: {config_threshold}") - + # CLI argument overrides config file if accuracy_threshold is None: accuracy_threshold = config_threshold else: - print(f"CLI threshold ({accuracy_threshold}) overrides audit.config ({config_threshold})") - + print( + f"CLI threshold ({accuracy_threshold}) overrides audit.config ({config_threshold})") + # Validate we have a threshold if accuracy_threshold is None: print("Error: No accuracy threshold specified.") print("Provide --accuracy-threshold or --audit-config with test07_accuracy_threshold field.") sys.exit(1) - + print(f"Using accuracy threshold: {accuracy_threshold}") # Build accuracy script command with placeholder substitution @@ -264,19 +267,22 @@ def main(): try: shutil.copy2(accuracy_file, output_accuracy_dir) except Exception: - print(f"Exception occurred trying to copy {accuracy_file} to {output_accuracy_dir}") + print( + f"Exception occurred trying to copy {accuracy_file} to {output_accuracy_dir}") try: if os.path.exists(summary_file): shutil.copy2(summary_file, output_performance_dir) except Exception: - print(f"Exception occurred trying to copy {summary_file} to {output_performance_dir}") + print( + f"Exception occurred trying to copy {summary_file} to {output_performance_dir}") try: if os.path.exists(detail_file): shutil.copy2(detail_file, output_performance_dir) except Exception: - print(f"Exception occurred trying to copy {detail_file} to {output_performance_dir}") + print( + f"Exception occurred trying to copy {detail_file} to {output_performance_dir}") print(f"\nAccuracy check pass: {accuracy_pass}") print("TEST07 verification complete") From 6a24415be9baaca1533bddfbded5f7580eee19bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 17:47:09 +0000 Subject: [PATCH 18/59] [Automated Commit] Format Codebase --- recommendation/dlrm_v3/accuracy.py | 3 ++- recommendation/dlrm_v3/main.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/recommendation/dlrm_v3/accuracy.py b/recommendation/dlrm_v3/accuracy.py index 244fe11000..94087e12dc 100644 --- a/recommendation/dlrm_v3/accuracy.py +++ b/recommendation/dlrm_v3/accuracy.py @@ -64,7 +64,8 @@ def main() -> None: logger.warning(f"results have {len(results)} entries") for result in results: data = np.frombuffer(bytes.fromhex(result["data"]), np.float32) - # Format: [ts_idx, query_idx, predictions..., labels..., weights..., candidate_size] + # Format: [ts_idx, query_idx, predictions..., labels..., weights..., + # candidate_size] num_candidates = data[-1].astype(int) assert len(data) == 3 + num_candidates * 3 mt_target_preds = torch.from_numpy(data[2:2 + num_candidates]) diff --git a/recommendation/dlrm_v3/main.py b/recommendation/dlrm_v3/main.py index 12e429ec03..a0dbd45991 100644 --- a/recommendation/dlrm_v3/main.py +++ b/recommendation/dlrm_v3/main.py @@ -227,8 +227,10 @@ def run_one_item(self, qitem: QueryItem) -> None: .float() .numpy() ) - ts_idx_val = float(qitem.ts_idx) if qitem.ts_idx is not None else -1.0 - query_idx_val = float(qitem.query_idx[i]) if qitem.query_idx is not None else -1.0 + ts_idx_val = float( + qitem.ts_idx) if qitem.ts_idx is not None else -1.0 + query_idx_val = float( + qitem.query_idx[i]) if qitem.query_idx is not None else -1.0 np_array = np.concatenate( [ np.array([ts_idx_val]).astype(np.float32), @@ -269,8 +271,10 @@ def run_one_item(self, qitem: QueryItem) -> None: .float() .numpy() ) - ts_idx_val = float(qitem.ts_idx) if qitem.ts_idx is not None else -1.0 - query_idx_val = float(qitem.query_idx[i]) if qitem.query_idx is not None else -1.0 + ts_idx_val = float( + qitem.ts_idx) if qitem.ts_idx is not None else -1.0 + query_idx_val = float( + qitem.query_idx[i]) if qitem.query_idx is not None else -1.0 np_array = np.concatenate( [ np.array([ts_idx_val]).astype(np.float32), @@ -731,7 +735,8 @@ def run( if is_streaming: ds.init_sut() # pyre-ignore [16] result = ds.get_samples(warmup_ids) - if isinstance(result, list) and len(result) > 0 and isinstance(result[0], tuple): + if isinstance(result, list) and len( + result) > 0 and isinstance(result[0], tuple): for sample, _, _ in result: model_family.predict(sample) elif isinstance(result, Samples): From f5ce39d557617e3818a1f4d69539ae239ef4822f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 18:08:57 +0000 Subject: [PATCH 19/59] [Automated Commit] Format Codebase --- main.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index e0f9a8787b..17d01fe11f 100755 --- a/main.py +++ b/main.py @@ -654,7 +654,13 @@ def get_readme_suffix(spaces, model, implementation, extra_variation_tags): if implementation == "reference" and not extra_variation_tags: if not model.endswith("-99"): - model_base_name = model.replace("-99.9", "").replace("-99", "").replace("-95", "") + model_base_name = model.replace( + "-99.9", + "").replace( + "-99", + "").replace( + "-95", + "") readme_suffix += f"{pre_space}* If you want to download the official MLPerf model and dataset for {model} you can follow [this README](get-{model_base_name}-data.md).\n" if model == "resnet50": readme_suffix += f"{pre_space}* Please see [mobilenets.md](mobilenets.md) for running mobilenet models for Image Classification." From 20b47731517ab9e2723c99b9af94d4fb46d87885 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 28 Jan 2026 07:17:02 +0000 Subject: [PATCH 20/59] [Automated Commit] Format Codebase --- text_to_video/wan2.2-t2v-14b/run_mlperf.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/text_to_video/wan2.2-t2v-14b/run_mlperf.py b/text_to_video/wan2.2-t2v-14b/run_mlperf.py index 7b6d7a8604..170c8db176 100644 --- a/text_to_video/wan2.2-t2v-14b/run_mlperf.py +++ b/text_to_video/wan2.2-t2v-14b/run_mlperf.py @@ -101,10 +101,12 @@ def issue_queries(self, query_samples): output = self.pipe(**pipeline_kwargs).frames[0] # Save to video to reduce mlperf_log_accuracy.json size - output_path = Path(self.video_output_path, f"{self.prompts[i]}-0.mp4") + output_path = Path( + self.video_output_path, + f"{self.prompts[i]}-0.mp4") logging.info(f"Saving {q} to {output_path}") export_to_video(output[0], str(output_path), fps=self.fps) - + with open(output_path, "rb") as f: resp = f.read() @@ -276,7 +278,14 @@ def run_mlperf(args, config): logging.info("No fixed latent provided - using random initial latents") # Loading model - model = Model(args.model_path, args.video_output_path, device, config, dataset, fixed_latent, rank) + model = Model( + args.model_path, + args.video_output_path, + device, + config, + dataset, + fixed_latent, + rank) # model = DebugModel(args.model_path, device, config, dataset, fixed_latent, rank) logging.info("Model loaded successfully!") From 16bb47d431772acf17385fe0aadd56412a07c6a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 29 Jan 2026 07:21:38 +0000 Subject: [PATCH 21/59] [Automated Commit] Format Codebase --- compliance/TEST09/run_verification.py | 42 ++++++++++++------- .../checks/compliance_check.py | 3 +- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/compliance/TEST09/run_verification.py b/compliance/TEST09/run_verification.py index 224b044684..33d8c598e3 100644 --- a/compliance/TEST09/run_verification.py +++ b/compliance/TEST09/run_verification.py @@ -122,14 +122,16 @@ def parse_mlperf_log(log_path: str) -> List[Dict[str, Any]]: parsed_count += 1 except json.JSONDecodeError: if parsed_count == 0: - print(f"Warning: Line {line_num}: Could not parse JSON") + print( + f"Warning: Line {line_num}: Could not parse JSON") break print(f"Loaded {len(entries)} entries from MLPerf log") return entries -def compute_output_token_lengths(entries: List[Dict[str, Any]]) -> Tuple[List[int], float, int, int]: +def compute_output_token_lengths( + entries: List[Dict[str, Any]]) -> Tuple[List[int], float, int, int]: """Compute output token lengths from MLPerf log entries. Args: @@ -147,7 +149,8 @@ def compute_output_token_lengths(entries: List[Dict[str, Any]]) -> Tuple[List[in token_ids = decode_hex_to_tokens(hex_data) token_lengths.append(len(token_ids)) except Exception as e: - print(f"Warning: Error decoding entry {entry.get('qsl_idx')}: {e}") + print( + f"Warning: Error decoding entry {entry.get('qsl_idx')}: {e}") token_lengths.append(0) else: token_lengths.append(0) @@ -194,13 +197,15 @@ def parse_audit_config(config_path: str) -> Dict[str, Any]: try: config['min_output_tokens'] = float(value) except ValueError: - print(f"Warning: Invalid min_output_tokens value: {value}") + print( + f"Warning: Invalid min_output_tokens value: {value}") elif setting_name == 'test09_max_output_tokens': try: config['max_output_tokens'] = float(value) except ValueError: - print(f"Warning: Invalid max_output_tokens value: {value}") + print( + f"Warning: Invalid max_output_tokens value: {value}") except Exception as e: print(f"Warning: Error parsing audit.config: {e}") @@ -275,7 +280,8 @@ def main(): if min_output_tokens is None: min_output_tokens = config_min else: - print(f"CLI min ({min_output_tokens}) overrides audit.config ({config_min})") + print( + f"CLI min ({min_output_tokens}) overrides audit.config ({config_min})") if 'max_output_tokens' in audit_config: config_max = audit_config['max_output_tokens'] @@ -283,7 +289,8 @@ def main(): if max_output_tokens is None: max_output_tokens = config_max else: - print(f"CLI max ({max_output_tokens}) overrides audit.config ({config_max})") + print( + f"CLI max ({max_output_tokens}) overrides audit.config ({config_max})") # Validate we have thresholds if min_output_tokens is None or max_output_tokens is None: @@ -309,7 +316,8 @@ def main(): sys.exit(1) print(f"\nComputing output token lengths for {len(entries)} samples...") - token_lengths, mean_length, min_length, max_length = compute_output_token_lengths(entries) + token_lengths, mean_length, min_length, max_length = compute_output_token_lengths( + entries) # Print statistics print("\n" + "=" * 80) @@ -322,7 +330,8 @@ def main(): # Compute standard deviation if token_lengths: - variance = sum((x - mean_length) ** 2 for x in token_lengths) / len(token_lengths) + variance = sum((x - mean_length) ** + 2 for x in token_lengths) / len(token_lengths) std_dev = variance ** 0.5 print(f"Std deviation: {std_dev:.2f}") @@ -336,8 +345,10 @@ def main(): overall_pass = min_check_pass and max_check_pass print(f"Mean output tokens: {mean_length:.2f}") - print(f"Min threshold: {min_output_tokens} -> {'PASS' if min_check_pass else 'FAIL'}") - print(f"Max threshold: {max_output_tokens} -> {'PASS' if max_check_pass else 'FAIL'}") + print( + f"Min threshold: {min_output_tokens} -> {'PASS' if min_check_pass else 'FAIL'}") + print( + f"Max threshold: {max_output_tokens} -> {'PASS' if max_check_pass else 'FAIL'}") print(f"\nOverall: {'TEST PASS' if overall_pass else 'TEST FAIL'}") # Write verification results @@ -391,19 +402,22 @@ def main(): try: shutil.copy2(accuracy_file, output_accuracy_dir) except Exception: - print(f"Exception occurred trying to copy {accuracy_file} to {output_accuracy_dir}") + print( + f"Exception occurred trying to copy {accuracy_file} to {output_accuracy_dir}") try: if os.path.exists(summary_file): shutil.copy2(summary_file, output_performance_dir) except Exception: - print(f"Exception occurred trying to copy {summary_file} to {output_performance_dir}") + print( + f"Exception occurred trying to copy {summary_file} to {output_performance_dir}") try: if os.path.exists(detail_file): shutil.copy2(detail_file, output_performance_dir) except Exception: - print(f"Exception occurred trying to copy {detail_file} to {output_performance_dir}") + print( + f"Exception occurred trying to copy {detail_file} to {output_performance_dir}") print("\n" + "=" * 80) print("TEST09 verification complete") diff --git a/tools/submission/submission_checker/checks/compliance_check.py b/tools/submission/submission_checker/checks/compliance_check.py index 7e09ac1644..477c17a856 100644 --- a/tools/submission/submission_checker/checks/compliance_check.py +++ b/tools/submission/submission_checker/checks/compliance_check.py @@ -335,7 +335,8 @@ def accuracy_check(self): elif test == "TEST09": # TEST09: Verify output token length in performance mode # Check verify_output_len.txt for TEST PASS - output_len_path = os.path.join(test_dir, "verify_output_len.txt") + output_len_path = os.path.join( + test_dir, "verify_output_len.txt") if os.path.exists(output_len_path): with open(output_len_path, "r", encoding="utf-8") as f: content = f.read() From e7f30b7abeae09257c0c416d5d84541e2148a10c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 29 Jan 2026 18:07:37 +0000 Subject: [PATCH 22/59] [Automated Commit] Format Codebase --- .../submission_checker/checks/compliance_check.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/submission/submission_checker/checks/compliance_check.py b/tools/submission/submission_checker/checks/compliance_check.py index fb0212daa7..a152383cbe 100644 --- a/tools/submission/submission_checker/checks/compliance_check.py +++ b/tools/submission/submission_checker/checks/compliance_check.py @@ -360,8 +360,10 @@ def accuracy_check(self): is_valid = False elif test == "TEST08": # TEST08 is used for dlrm-v3 streaming dataset compliance - # It verifies that NE values match between accuracy and performance runs - lines = self.submission_logs.loader_data.get(f"{test}_acc_result") + # It verifies that NE values match between accuracy and + # performance runs + lines = self.submission_logs.loader_data.get( + f"{test}_acc_result") if lines is None: self.log.error( "TEST08 accuracy result file not found for %s", test_dir) @@ -401,7 +403,8 @@ def accuracy_check(self): elif test == "TEST09": # TEST09: Verify output token length in performance mode # Check verify_output_len.txt for TEST PASS - output_len_path = os.path.join(test_dir, "verify_output_len.txt") + output_len_path = os.path.join( + test_dir, "verify_output_len.txt") if os.path.exists(output_len_path): with open(output_len_path, "r", encoding="utf-8") as f: content = f.read() From c1f698ba6fe7d753641a21bff001ab0197f85a8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 6 Feb 2026 19:56:54 +0000 Subject: [PATCH 23/59] [Automated Commit] Format Codebase --- text_to_video/wan-2.2-t2v-a14b/run_mlperf.py | 28 ++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/text_to_video/wan-2.2-t2v-a14b/run_mlperf.py b/text_to_video/wan-2.2-t2v-a14b/run_mlperf.py index 147624b340..ab73c25966 100644 --- a/text_to_video/wan-2.2-t2v-a14b/run_mlperf.py +++ b/text_to_video/wan-2.2-t2v-a14b/run_mlperf.py @@ -46,7 +46,8 @@ def load_prompts(dataset_path): class Model: - def __init__(self, model_path, device, config, prompts, fixed_latent=None, rank=0): + def __init__(self, model_path, device, config, + prompts, fixed_latent=None, rank=0): self.device = device self.rank = rank self.height = config["height"] @@ -106,7 +107,8 @@ def flush_queries(self): class DebugModel: - def __init__(self, model_path, device, config, prompts, fixed_latent=None, rank=0): + def __init__(self, model_path, device, config, + prompts, fixed_latent=None, rank=0): self.prompts = prompts def issue_queries(self, query_samples): @@ -186,7 +188,8 @@ def get_args(): parser.add_argument( "--scenario", default="SingleStream", - help="mlperf benchmark scenario, one of " + str(list(SCENARIO_MAP.keys())), + help="mlperf benchmark scenario, one of " + + str(list(SCENARIO_MAP.keys())), ) parser.add_argument( "--user_conf", @@ -202,7 +205,10 @@ def get_args(): help="performance sample count", default=5000, ) - parser.add_argument("--accuracy", action="store_true", help="enable accuracy pass") + parser.add_argument( + "--accuracy", + action="store_true", + help="enable accuracy pass") # Dont overwrite these for official submission parser.add_argument("--count", type=int, help="dataset items to use") parser.add_argument("--time", type=int, help="time to scan in seconds") @@ -271,7 +277,10 @@ def run_mlperf(args, config): audit_config = os.path.abspath(args.audit_conf) if os.path.exists(audit_config): - settings.FromConfig(audit_config, "wan-2.2-t2v-a14b", args.scenario) + settings.FromConfig( + audit_config, + "wan-2.2-t2v-a14b", + args.scenario) settings.scenario = SCENARIO_MAP[args.scenario] settings.mode = lg.TestMode.PerformanceOnly @@ -297,8 +306,10 @@ def run_mlperf(args, config): if args.samples_per_query: settings.multi_stream_samples_per_query = args.samples_per_query if args.max_latency: - settings.server_target_latency_ns = int(args.max_latency * NANO_SEC) - settings.multi_stream_expected_latency_ns = int(args.max_latency * NANO_SEC) + settings.server_target_latency_ns = int( + args.max_latency * NANO_SEC) + settings.multi_stream_expected_latency_ns = int( + args.max_latency * NANO_SEC) performance_sample_count = ( args.performance_sample_count @@ -311,7 +322,8 @@ def run_mlperf(args, config): count, performance_sample_count, load_query_samples, unload_query_samples ) - lg.StartTestWithLogSettings(sut, qsl, settings, log_settings, audit_config) + lg.StartTestWithLogSettings( + sut, qsl, settings, log_settings, audit_config) lg.DestroyQSL(qsl) lg.DestroySUT(sut) From 06b4c8dfa17a391728e98435dc26c20eb67cb3a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Feb 2026 23:03:26 +0000 Subject: [PATCH 24/59] [Automated Commit] Format Codebase --- tools/submission/generate_final_report.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/submission/generate_final_report.py b/tools/submission/generate_final_report.py index 4b0e055290..f191bdbf5f 100644 --- a/tools/submission/generate_final_report.py +++ b/tools/submission/generate_final_report.py @@ -101,11 +101,11 @@ def main(): "singlestream": "SingleStream", "multistream": "MultiStream", "server": "Server", - "interactive":"Interactive", + "interactive": "Interactive", "offline": "Offline", } - df["Scenario"] = df["Scenario"].apply(lambda x: scenario_map.get(str(x).lower(), x)) - + df["Scenario"] = df["Scenario"].apply( + lambda x: scenario_map.get(str(x).lower(), x)) output = args.input[:-4] writer = pd.ExcelWriter(output + ".xlsx", engine="xlsxwriter") From 5eb743500e808f0278e70d8f5f034f247e2a9c9f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Feb 2026 16:48:14 +0000 Subject: [PATCH 25/59] [Automated Commit] Format Codebase --- .../checks/performance_check.py | 16 +++++++++------- tools/submission/submission_checker/loader.py | 12 +++++++----- tools/submission/submission_checker/utils.py | 8 +++++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tools/submission/submission_checker/checks/performance_check.py b/tools/submission/submission_checker/checks/performance_check.py index 915abd04cf..a2c34394c4 100644 --- a/tools/submission/submission_checker/checks/performance_check.py +++ b/tools/submission/submission_checker/checks/performance_check.py @@ -85,13 +85,15 @@ def missing_check(self): self.log.error("Performance log missing at %s", self.path) return False return True - + def scenarios_check(self): if self.submission_logs.loader_data.get("check_scenarios", False): return True else: - missing_scenarios = self.submission_logs.loader_data.get("missing_scenarios", []) - unknown_scenarios = self.submission_logs.loader_data.get("unknown_scenarios", []) + missing_scenarios = self.submission_logs.loader_data.get( + "missing_scenarios", []) + unknown_scenarios = self.submission_logs.loader_data.get( + "unknown_scenarios", []) if len(missing_scenarios) > 0: self.log.error( "%s does not have all required scenarios, missing %s", @@ -445,7 +447,7 @@ def inferred_check(self): ("singlestream", "offline") ] if (self.scenario.lower(), self.scenario_fixed.lower() - ) not in list_inferred: + ) not in list_inferred: self.log.error( "Result for scenario %s can not be inferred from %s for: %s", self.scenario_fixed, @@ -529,12 +531,12 @@ def get_inferred_result(self, res): res = qps_wo_loadgen_overhead if (self.scenario_fixed in ["Offline"] - ) and self.scenario in ["MultiStream"]: + ) and self.scenario in ["MultiStream"]: inferred = True res = samples_per_query * S_TO_MS / (latency_mean / MS_TO_NS) if (self.scenario_fixed in ["MultiStream"] - ) and self.scenario in ["SingleStream"]: + ) and self.scenario in ["SingleStream"]: inferred = True # samples_per_query does not match with the one reported in the logs # when inferring MultiStream from SingleStream @@ -551,6 +553,6 @@ def get_inferred_result(self, res): else: res = (latency_99_percentile * samples_per_query) / MS_TO_NS if (self.scenario_fixed in ["Interactive"] - ) and self.scenario not in ["Server"]: + ) and self.scenario not in ["Server"]: is_valid = False return res, is_valid diff --git a/tools/submission/submission_checker/loader.py b/tools/submission/submission_checker/loader.py index 323ed2a078..89c8bf08ce 100644 --- a/tools/submission/submission_checker/loader.py +++ b/tools/submission/submission_checker/loader.py @@ -212,8 +212,9 @@ def load_single_log(self, path, log_type: Literal["Performance", "Accuracy", log_type, path) return log - - def check_scenarios(self, benchmark, model_mapping, system_type, scenarios): + + def check_scenarios(self, benchmark, model_mapping, + system_type, scenarios): self.config.set_type(system_type) mlperf_model = self.config.get_mlperf_model(benchmark, model_mapping) required_scenarios = lower_list(self.config.get_required(mlperf_model)) @@ -230,13 +231,13 @@ def check_scenarios(self, benchmark, model_mapping, system_type, scenarios): unknown, passed = contains_list(set(all_senarios), scenarios) if not passed: check = False - if contains_list(set(optional_scenarios), ["interactive", "server"])[1]: + if contains_list(set(optional_scenarios), [ + "interactive", "server"])[1]: if "interactive" not in scenarios and "server" not in scenarios: check = False missing.append("(one of) Interactive or Server") return missing, unknown, check - def load(self) -> Generator[SubmissionLogs, None, None]: """Traverse submissions directory and yield parsed log containers. @@ -270,7 +271,8 @@ def load(self) -> Generator[SubmissionLogs, None, None]: for benchmark in list_dir(system_path): benchmark_path = os.path.join(system_path, benchmark) if division.lower() in ["closed", "network"]: - missing_scenarios, unknown_scenarios, check_scenarios = self.check_scenarios(benchmark, model_mapping, system_type, list_dir(benchmark_path)) + missing_scenarios, unknown_scenarios, check_scenarios = self.check_scenarios( + benchmark, model_mapping, system_type, list_dir(benchmark_path)) else: missing_scenarios, unknown_scenarios, check_scenarios = [], [], True for scenario in list_dir(benchmark_path): diff --git a/tools/submission/submission_checker/utils.py b/tools/submission/submission_checker/utils.py index 7d1daf0e72..7ff4fd020a 100644 --- a/tools/submission/submission_checker/utils.py +++ b/tools/submission/submission_checker/utils.py @@ -107,17 +107,19 @@ def is_number(s): return True except ValueError: return False - + + def lower_list(l): return [str(e).lower() for e in l] + def contains_list(l1, l2): # Check if l1 contains all elements of l2 missing = [] for e in l2: if e not in l1: missing.append(e) - return missing, len(missing) == 0 + return missing, len(missing) == 0 def get_performance_metric( @@ -317,7 +319,7 @@ def get_power_metric(config, scenario_fixed, log_path, is_valid, res): samples_per_query = 8 if (scenario_fixed in ["MultiStream"] - ) and scenario in ["SingleStream"]: + ) and scenario in ["SingleStream"]: power_metric = ( avg_power * power_duration * samples_per_query * 1000 / num_queries ) From 3b8e0e569d7ff67c0e4e5fdc25d92de8b9069d1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Feb 2026 22:40:28 +0000 Subject: [PATCH 26/59] [Automated Commit] Format Codebase --- .../submission_checker/checks/performance_check.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tools/submission/submission_checker/checks/performance_check.py b/tools/submission/submission_checker/checks/performance_check.py index 21d0f1c718..29203d6de1 100644 --- a/tools/submission/submission_checker/checks/performance_check.py +++ b/tools/submission/submission_checker/checks/performance_check.py @@ -118,7 +118,8 @@ def loadgen_errors_check(self): bool: True if no blocking Loadgen errors are present, False otherwise. """ - compliance_skip = self.submission_logs.loader_data.get("compliance_skip", False) + compliance_skip = self.submission_logs.loader_data.get( + "compliance_skip", False) if self.mlperf_log.has_error(): has_critical_errors = False if self.config.ignore_uncommited: @@ -129,7 +130,7 @@ def loadgen_errors_check(self): ): has_critical_errors = True if ( - not compliance_skip + not compliance_skip and "Multiple conf files are used" in error["value"] ): has_critical_errors = True @@ -456,7 +457,7 @@ def inferred_check(self): ("singlestream", "offline") ] if (self.scenario.lower(), self.scenario_fixed.lower() - ) not in list_inferred: + ) not in list_inferred: self.log.error( "Result for scenario %s can not be inferred from %s for: %s", self.scenario_fixed, @@ -540,12 +541,12 @@ def get_inferred_result(self, res): res = qps_wo_loadgen_overhead if (self.scenario_fixed in ["Offline"] - ) and self.scenario in ["MultiStream"]: + ) and self.scenario in ["MultiStream"]: inferred = True res = samples_per_query * S_TO_MS / (latency_mean / MS_TO_NS) if (self.scenario_fixed in ["MultiStream"] - ) and self.scenario in ["SingleStream"]: + ) and self.scenario in ["SingleStream"]: inferred = True # samples_per_query does not match with the one reported in the logs # when inferring MultiStream from SingleStream @@ -562,6 +563,6 @@ def get_inferred_result(self, res): else: res = (latency_99_percentile * samples_per_query) / MS_TO_NS if (self.scenario_fixed in ["Interactive"] - ) and self.scenario not in ["Server"]: + ) and self.scenario not in ["Server"]: is_valid = False return res, is_valid From 707587b14490e2e37c4e6e8d1da81ae707512270 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 20:50:35 +0000 Subject: [PATCH 27/59] [Automated Commit] Format Codebase --- .../submission/submission_checker/checks/performance_check.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/submission/submission_checker/checks/performance_check.py b/tools/submission/submission_checker/checks/performance_check.py index f65d2e4173..a895aa20ca 100644 --- a/tools/submission/submission_checker/checks/performance_check.py +++ b/tools/submission/submission_checker/checks/performance_check.py @@ -526,7 +526,8 @@ def get_inferred_result(self, res): # Check if current scenario (and version) uses early stopping uses_early_stopping = self.config.uses_early_stopping(self.scenario) scenario = SCENARIO_MAPPING.get(self.scenario, self.scenario) - scenario_fixed = SCENARIO_MAPPING.get(self.scenario_fixed, self.scenario_fixed) + scenario_fixed = SCENARIO_MAPPING.get( + self.scenario_fixed, self.scenario_fixed) latency_mean = self.mlperf_log["result_mean_latency_ns"] if scenario in ["MultiStream"]: From a6615ea5cf68a243b54f2e1eedab038672f0fa8c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 21:29:47 +0000 Subject: [PATCH 28/59] [Automated Commit] Format Codebase --- tools/submission/submission_checker/results.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/submission/submission_checker/results.py b/tools/submission/submission_checker/results.py index 9f39519f45..990172b989 100644 --- a/tools/submission/submission_checker/results.py +++ b/tools/submission/submission_checker/results.py @@ -99,7 +99,8 @@ def add_result(self, submission_logs: SubmissionLogs): row["host_processor_core_count"] = submission_logs.system_json["host_processor_core_count"] row["accelerator_model_name"] = submission_logs.system_json["accelerator_model_name"] row["accelerators_per_node"] = submission_logs.system_json["accelerators_per_node"] - row["total_accelerators"] = int(row["number_of_nodes"]) * int(row["accelerators_per_node"]) + row["total_accelerators"] = int( + row["number_of_nodes"]) * int(row["accelerators_per_node"]) row["Location"] = os.path.dirname( submission_logs.loader_data["perf_path"]) row["framework"] = submission_logs.system_json["framework"] From 364da84ecb8f7e4f0587f0eab3da037c32cb8c98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 15:37:47 +0000 Subject: [PATCH 29/59] [Automated Commit] Format Codebase --- vision/medical_imaging/3d-unet-kits19/global_vars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vision/medical_imaging/3d-unet-kits19/global_vars.py b/vision/medical_imaging/3d-unet-kits19/global_vars.py index 1ac539e6c5..b5fa192d9d 100644 --- a/vision/medical_imaging/3d-unet-kits19/global_vars.py +++ b/vision/medical_imaging/3d-unet-kits19/global_vars.py @@ -65,7 +65,7 @@ TARGET_CASES = json.load(f) with open(CALIBRATION_CASE_FILE, "r") as f: CALIB_CASES = json.load(f) - + # constants used preprocessing images as well as sliding window inference MEAN_VAL = 101.0 STDDEV_VAL = 76.9 From 1b8e1150afc0bd3529b540890a7db63bfbe959ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 16:28:09 +0000 Subject: [PATCH 30/59] [Automated Commit] Format Codebase --- loadgen/version_generator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/loadgen/version_generator.py b/loadgen/version_generator.py index e0de87c281..b88ae8e1e0 100644 --- a/loadgen/version_generator.py +++ b/loadgen/version_generator.py @@ -101,7 +101,8 @@ def generate_loadgen_version_definitions(cc_filename, loadgen_root): raise with open(cc_filename, "w") as ofile: - ofile.write("// DO NOT EDIT: Autogenerated by version_generator.py.\n\n") + ofile.write( + "// DO NOT EDIT: Autogenerated by version_generator.py.\n\n") ofile.write("#include \n\n") ofile.write("namespace mlperf {\n\n") # Open and read the VERSION.txt file @@ -114,7 +115,12 @@ def generate_loadgen_version_definitions(cc_filename, loadgen_root): date_time_now_local = datetime.datetime.now().isoformat() date_time_now_utc = datetime.datetime.utcnow().isoformat() - ofile.write(func_def("BuildDateLocal", '"' + date_time_now_local + '"')) + ofile.write( + func_def( + "BuildDateLocal", + '"' + + date_time_now_local + + '"')) ofile.write(func_def("BuildDateUtc", '"' + date_time_now_utc + '"')) git_dir = '--git-dir="' + loadgen_root + '/../.git" ' From 156482478cf5975e4079b09893693ad2c4e3ab7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 17:09:12 +0000 Subject: [PATCH 31/59] [Automated Commit] Format Codebase --- .../never_adopted/language/gpt3/megatron/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/retired_benchmarks/never_adopted/language/gpt3/megatron/utils.py b/retired_benchmarks/never_adopted/language/gpt3/megatron/utils.py index d7f64bc261..9b9bfbe776 100644 --- a/retired_benchmarks/never_adopted/language/gpt3/megatron/utils.py +++ b/retired_benchmarks/never_adopted/language/gpt3/megatron/utils.py @@ -1,10 +1,11 @@ import json import io + def jload(f, mode="r"): """Load a .json file into a dictionary.""" if not isinstance(f, io.IOBase): with open(f, mode=mode) as f: return json.load(f) else: - return json.load(f) \ No newline at end of file + return json.load(f) From 52e78b37a323415b500aa48fa0a7d819041b192a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 15:51:26 +0000 Subject: [PATCH 32/59] [Automated Commit] Format Codebase --- compliance/TEST04/verify_performance.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/compliance/TEST04/verify_performance.py b/compliance/TEST04/verify_performance.py index 3a4edd3268..15ea64abc1 100644 --- a/compliance/TEST04/verify_performance.py +++ b/compliance/TEST04/verify_performance.py @@ -40,8 +40,7 @@ def main(): args = parser.parse_args() print("Verifying performance.") - - + ref_score = 0 test_score = 0 ref_mode = "" @@ -87,7 +86,10 @@ def main(): if re.match("\\d+ ERROR", line): error = line.split(" ", 1)[0].strip() - print("WARNING: " + error + " ERROR reported in reference results") + print( + "WARNING: " + + error + + " ERROR reported in reference results") with open(args.test_summary, "r") as test_file: for line in test_file: From 859c5860fdedcce9589c73fc8c38d9364c244e5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 16:07:01 +0000 Subject: [PATCH 33/59] [Automated Commit] Format Codebase --- language/gpt-j/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/language/gpt-j/utils.py b/language/gpt-j/utils.py index d7f64bc261..9b9bfbe776 100644 --- a/language/gpt-j/utils.py +++ b/language/gpt-j/utils.py @@ -1,10 +1,11 @@ import json import io + def jload(f, mode="r"): """Load a .json file into a dictionary.""" if not isinstance(f, io.IOBase): with open(f, mode=mode) as f: return json.load(f) else: - return json.load(f) \ No newline at end of file + return json.load(f) From 2724679f2dfec34086db68cfd9503c6cd070b273 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 16:17:04 +0000 Subject: [PATCH 34/59] [Automated Commit] Format Codebase --- recommendation/dlrm_v2/pytorch/python/main.py | 2 -- recommendation/dlrm_v2/pytorch/tools/accuracy-dlrm.py | 2 -- retired_benchmarks/recommendation/dlrm/pytorch/python/main.py | 2 -- .../recommendation/dlrm/pytorch/tools/accuracy-dlrm.py | 2 -- retired_benchmarks/recommendation/dlrm/tf/mlp_log.py | 2 -- .../recommendation/dlrm/tf/train_and_eval_runner.py | 1 - .../translation/gnmt/tensorflow/nmt/inference_test.py | 1 - .../translation/gnmt/tensorflow/nmt/model_test.py | 1 - retired_benchmarks/translation/gnmt/tensorflow/nmt/nmt_test.py | 1 - .../translation/gnmt/tensorflow/nmt/scripts/rouge.py | 2 -- .../translation/gnmt/tensorflow/nmt/utils/common_test_utils.py | 1 - .../gnmt/tensorflow/nmt/utils/evaluation_utils_test.py | 1 - .../gnmt/tensorflow/nmt/utils/iterator_utils_test.py | 1 - .../translation/gnmt/tensorflow/nmt/utils/misc_utils_test.py | 1 - .../gnmt/tensorflow/nmt/utils/standard_hparams_utils.py | 1 - .../translation/gnmt/tensorflow/nmt/utils/vocab_utils.py | 1 - .../translation/gnmt/tensorflow/nmt/utils/vocab_utils_test.py | 1 - .../vision/classification_and_detection/python/main.py | 2 -- .../vision/classification_and_detection/tools/accuracy-coco.py | 2 -- .../classification_and_detection/tools/accuracy-imagenet.py | 2 -- .../vision/classification_and_detection/tools/coco-analyze.py | 2 -- .../vision/classification_and_detection/tools/lglog2csv.py | 2 -- .../vision/classification_and_detection/tools/resnet_save.py | 1 - text_to_image/main.py | 2 -- tools/submission/filter_errors.py | 2 -- vision/classification_and_detection/python/main.py | 2 -- vision/classification_and_detection/tools/accuracy-coco.py | 2 -- vision/classification_and_detection/tools/accuracy-imagenet.py | 2 -- .../classification_and_detection/tools/accuracy-openimages.py | 2 -- vision/classification_and_detection/tools/coco-analyze.py | 2 -- vision/classification_and_detection/tools/lglog2csv.py | 2 -- 31 files changed, 50 deletions(-) diff --git a/recommendation/dlrm_v2/pytorch/python/main.py b/recommendation/dlrm_v2/pytorch/python/main.py index e97de7f9dc..859a2f5b7c 100755 --- a/recommendation/dlrm_v2/pytorch/python/main.py +++ b/recommendation/dlrm_v2/pytorch/python/main.py @@ -3,8 +3,6 @@ """ - - import argparse import array import collections diff --git a/recommendation/dlrm_v2/pytorch/tools/accuracy-dlrm.py b/recommendation/dlrm_v2/pytorch/tools/accuracy-dlrm.py index b62104cc5e..0a64710313 100644 --- a/recommendation/dlrm_v2/pytorch/tools/accuracy-dlrm.py +++ b/recommendation/dlrm_v2/pytorch/tools/accuracy-dlrm.py @@ -5,8 +5,6 @@ """ - - import argparse import json diff --git a/retired_benchmarks/recommendation/dlrm/pytorch/python/main.py b/retired_benchmarks/recommendation/dlrm/pytorch/python/main.py index 8d3f8c0d9f..d3547d4058 100755 --- a/retired_benchmarks/recommendation/dlrm/pytorch/python/main.py +++ b/retired_benchmarks/recommendation/dlrm/pytorch/python/main.py @@ -3,8 +3,6 @@ """ - - import argparse import array import collections diff --git a/retired_benchmarks/recommendation/dlrm/pytorch/tools/accuracy-dlrm.py b/retired_benchmarks/recommendation/dlrm/pytorch/tools/accuracy-dlrm.py index bf7837575f..4125e7fd64 100644 --- a/retired_benchmarks/recommendation/dlrm/pytorch/tools/accuracy-dlrm.py +++ b/retired_benchmarks/recommendation/dlrm/pytorch/tools/accuracy-dlrm.py @@ -5,8 +5,6 @@ """ - - import argparse import json diff --git a/retired_benchmarks/recommendation/dlrm/tf/mlp_log.py b/retired_benchmarks/recommendation/dlrm/tf/mlp_log.py index 361b3bea1c..409416619e 100644 --- a/retired_benchmarks/recommendation/dlrm/tf/mlp_log.py +++ b/retired_benchmarks/recommendation/dlrm/tf/mlp_log.py @@ -16,8 +16,6 @@ """ - - import inspect import json import logging diff --git a/retired_benchmarks/recommendation/dlrm/tf/train_and_eval_runner.py b/retired_benchmarks/recommendation/dlrm/tf/train_and_eval_runner.py index dd7783a35c..4d55e2bcf6 100644 --- a/retired_benchmarks/recommendation/dlrm/tf/train_and_eval_runner.py +++ b/retired_benchmarks/recommendation/dlrm/tf/train_and_eval_runner.py @@ -15,7 +15,6 @@ """Bypass TPUEstimator for ResNet-50 Train.""" - import functools import math import operator diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/inference_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/inference_test.py index bc6041295a..71ee1217af 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/inference_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/inference_test.py @@ -16,7 +16,6 @@ """Tests for model inference.""" - import os import numpy as np import tensorflow as tf diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/model_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/model_test.py index d4a9af6bae..6c8804435f 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/model_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/model_test.py @@ -15,7 +15,6 @@ """Tests for model.py.""" - import pprint import sys import numpy as np diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/nmt_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/nmt_test.py index 149b4c90b2..ea587ed40c 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/nmt_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/nmt_test.py @@ -15,7 +15,6 @@ """Tests for nmt.py, train.py and inference.py.""" - import argparse import os diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/scripts/rouge.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/scripts/rouge.py index 2afd3fc825..18096aa0da 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/scripts/rouge.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/scripts/rouge.py @@ -6,8 +6,6 @@ """ - - import itertools import numpy as np diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/common_test_utils.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/common_test_utils.py index 5d68e4ede2..960be680f0 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/common_test_utils.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/common_test_utils.py @@ -16,7 +16,6 @@ """Common utility functions for tests.""" - import tensorflow as tf from tensorflow.python.ops import lookup_ops diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/evaluation_utils_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/evaluation_utils_test.py index 5012b69646..1e0ca62eaf 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/evaluation_utils_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/evaluation_utils_test.py @@ -16,7 +16,6 @@ """Tests for evaluation_utils.py.""" - import tensorflow as tf from ..utils import evaluation_utils diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/iterator_utils_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/iterator_utils_test.py index b39233051f..7a10d256c1 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/iterator_utils_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/iterator_utils_test.py @@ -16,7 +16,6 @@ """Tests for iterator_utils.py""" - import tensorflow as tf from tensorflow.python.ops import lookup_ops diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/misc_utils_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/misc_utils_test.py index a63b531345..29649dd807 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/misc_utils_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/misc_utils_test.py @@ -16,7 +16,6 @@ """Tests for vocab_utils.""" - import tensorflow as tf from ..utils import misc_utils diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/standard_hparams_utils.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/standard_hparams_utils.py index 077122208c..84f1760006 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/standard_hparams_utils.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/standard_hparams_utils.py @@ -16,7 +16,6 @@ """standard hparams utils.""" - import tensorflow as tf diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils.py index 0e2b6ee339..32425d2664 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils.py @@ -16,7 +16,6 @@ """Utility to handle vocabularies.""" - import codecs import os import tensorflow as tf diff --git a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils_test.py b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils_test.py index c2af64628a..c6588c8e37 100644 --- a/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils_test.py +++ b/retired_benchmarks/translation/gnmt/tensorflow/nmt/utils/vocab_utils_test.py @@ -16,7 +16,6 @@ """Tests for vocab_utils.""" - import codecs import os import tensorflow as tf diff --git a/retired_benchmarks/vision/classification_and_detection/python/main.py b/retired_benchmarks/vision/classification_and_detection/python/main.py index ff29e3887e..de1a547647 100755 --- a/retired_benchmarks/vision/classification_and_detection/python/main.py +++ b/retired_benchmarks/vision/classification_and_detection/python/main.py @@ -3,8 +3,6 @@ """ - - import argparse import array import collections diff --git a/retired_benchmarks/vision/classification_and_detection/tools/accuracy-coco.py b/retired_benchmarks/vision/classification_and_detection/tools/accuracy-coco.py index c50853631c..88709706c6 100644 --- a/retired_benchmarks/vision/classification_and_detection/tools/accuracy-coco.py +++ b/retired_benchmarks/vision/classification_and_detection/tools/accuracy-coco.py @@ -5,8 +5,6 @@ """ - - import argparse import json import os diff --git a/retired_benchmarks/vision/classification_and_detection/tools/accuracy-imagenet.py b/retired_benchmarks/vision/classification_and_detection/tools/accuracy-imagenet.py index 45a5e025f0..71ef9d5636 100644 --- a/retired_benchmarks/vision/classification_and_detection/tools/accuracy-imagenet.py +++ b/retired_benchmarks/vision/classification_and_detection/tools/accuracy-imagenet.py @@ -4,8 +4,6 @@ """ - - import argparse import json diff --git a/retired_benchmarks/vision/classification_and_detection/tools/coco-analyze.py b/retired_benchmarks/vision/classification_and_detection/tools/coco-analyze.py index 8761b00786..49bfc19a4d 100755 --- a/retired_benchmarks/vision/classification_and_detection/tools/coco-analyze.py +++ b/retired_benchmarks/vision/classification_and_detection/tools/coco-analyze.py @@ -3,8 +3,6 @@ """ - - import argparse import collections import json diff --git a/retired_benchmarks/vision/classification_and_detection/tools/lglog2csv.py b/retired_benchmarks/vision/classification_and_detection/tools/lglog2csv.py index 73e023e53d..1b968a251c 100644 --- a/retired_benchmarks/vision/classification_and_detection/tools/lglog2csv.py +++ b/retired_benchmarks/vision/classification_and_detection/tools/lglog2csv.py @@ -3,8 +3,6 @@ """ - - import argparse import re import time diff --git a/retired_benchmarks/vision/classification_and_detection/tools/resnet_save.py b/retired_benchmarks/vision/classification_and_detection/tools/resnet_save.py index 67374d7c22..ed6a9a5a69 100755 --- a/retired_benchmarks/vision/classification_and_detection/tools/resnet_save.py +++ b/retired_benchmarks/vision/classification_and_detection/tools/resnet_save.py @@ -20,7 +20,6 @@ """ - import functools import math import multiprocessing diff --git a/text_to_image/main.py b/text_to_image/main.py index 3b4aecf97f..1935c2d1e0 100644 --- a/text_to_image/main.py +++ b/text_to_image/main.py @@ -3,8 +3,6 @@ """ - - import argparse import array import collections diff --git a/tools/submission/filter_errors.py b/tools/submission/filter_errors.py index 874dfc05f3..ce037184d8 100644 --- a/tools/submission/filter_errors.py +++ b/tools/submission/filter_errors.py @@ -3,8 +3,6 @@ """ - - import argparse import sys diff --git a/vision/classification_and_detection/python/main.py b/vision/classification_and_detection/python/main.py index ba2fbb5f85..5f1ef39429 100755 --- a/vision/classification_and_detection/python/main.py +++ b/vision/classification_and_detection/python/main.py @@ -3,8 +3,6 @@ """ - - import argparse import array import collections diff --git a/vision/classification_and_detection/tools/accuracy-coco.py b/vision/classification_and_detection/tools/accuracy-coco.py index c50853631c..88709706c6 100644 --- a/vision/classification_and_detection/tools/accuracy-coco.py +++ b/vision/classification_and_detection/tools/accuracy-coco.py @@ -5,8 +5,6 @@ """ - - import argparse import json import os diff --git a/vision/classification_and_detection/tools/accuracy-imagenet.py b/vision/classification_and_detection/tools/accuracy-imagenet.py index 45a5e025f0..71ef9d5636 100644 --- a/vision/classification_and_detection/tools/accuracy-imagenet.py +++ b/vision/classification_and_detection/tools/accuracy-imagenet.py @@ -4,8 +4,6 @@ """ - - import argparse import json diff --git a/vision/classification_and_detection/tools/accuracy-openimages.py b/vision/classification_and_detection/tools/accuracy-openimages.py index fc20ecb648..2e631623fe 100644 --- a/vision/classification_and_detection/tools/accuracy-openimages.py +++ b/vision/classification_and_detection/tools/accuracy-openimages.py @@ -5,8 +5,6 @@ """ - - import argparse import json import os diff --git a/vision/classification_and_detection/tools/coco-analyze.py b/vision/classification_and_detection/tools/coco-analyze.py index 8761b00786..49bfc19a4d 100755 --- a/vision/classification_and_detection/tools/coco-analyze.py +++ b/vision/classification_and_detection/tools/coco-analyze.py @@ -3,8 +3,6 @@ """ - - import argparse import collections import json diff --git a/vision/classification_and_detection/tools/lglog2csv.py b/vision/classification_and_detection/tools/lglog2csv.py index 73e023e53d..1b968a251c 100644 --- a/vision/classification_and_detection/tools/lglog2csv.py +++ b/vision/classification_and_detection/tools/lglog2csv.py @@ -3,8 +3,6 @@ """ - - import argparse import re import time From 744faed7c38bf9f96722d4b34e025879296b01ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 16:29:24 +0000 Subject: [PATCH 35/59] [Automated Commit] Format Codebase --- .../tools/calibrate_torchvision_model.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/vision/classification_and_detection/tools/calibrate_torchvision_model.py b/vision/classification_and_detection/tools/calibrate_torchvision_model.py index 875d26c388..87c090e2ee 100644 --- a/vision/classification_and_detection/tools/calibrate_torchvision_model.py +++ b/vision/classification_and_detection/tools/calibrate_torchvision_model.py @@ -74,10 +74,15 @@ def main(): dataloader = DataLoader(dataset, batch_size=1) if not hasattr(torchvision_quantization_models, args.model): - raise ValueError(f"Model {args.model} not found in torchvision quantization models") - - - model = getattr(torchvision_quantization_models, args.model)(pretrained=True, progress=True, quantize=False) + raise ValueError( + f"Model {args.model} not found in torchvision quantization models") + + model = getattr( + torchvision_quantization_models, + args.model)( + pretrained=True, + progress=True, + quantize=False) quantize_model(model, dataloader) print(model) From 271b3eec2ae946204ce3d218cba73a4bad34597d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 16:35:24 +0000 Subject: [PATCH 36/59] [Automated Commit] Format Codebase --- language/mixtral-8x7b/evaluate_mbxp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language/mixtral-8x7b/evaluate_mbxp.py b/language/mixtral-8x7b/evaluate_mbxp.py index 48c39a37bd..57a13d63e0 100644 --- a/language/mixtral-8x7b/evaluate_mbxp.py +++ b/language/mixtral-8x7b/evaluate_mbxp.py @@ -76,7 +76,7 @@ def worker(inp_queue, out_queue): } checker = lang_to_checker_map[problem["lang"]] - + problem["task_id"] = key problem["test"] = problem["test_code"] From d98f4eb3caa262475fe815c0ad75ea0d8aa61bc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 16:38:44 +0000 Subject: [PATCH 37/59] [Automated Commit] Format Codebase --- .../tools/coco_generate_calibration.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/text_to_image/tools/coco_generate_calibration.py b/text_to_image/tools/coco_generate_calibration.py index 3a89c63683..096f5e4079 100644 --- a/text_to_image/tools/coco_generate_calibration.py +++ b/text_to_image/tools/coco_generate_calibration.py @@ -15,6 +15,7 @@ logging.basicConfig(level=logging.INFO) log = logging.getLogger("coco") + def get_args(): """Parse commandline.""" parser = argparse.ArgumentParser() @@ -41,6 +42,7 @@ def get_args(): args = parser.parse_args() return args + def download_file(url: str, output_dir: Path, filename: str | None = None): os.makedirs(str(output_dir), exist_ok=True) @@ -65,6 +67,7 @@ def download_file(url: str, output_dir: Path, filename: str | None = None): return output_path + if __name__ == "__main__": args = get_args() dataset_dir = os.path.abspath(args.dataset_dir) @@ -80,21 +83,22 @@ def download_file(url: str, output_dir: Path, filename: str | None = None): calibration_dir = Path(calibration_dir) # Check if raw annotations file already exist - if not (dataset_dir / "raw" / "annotations" / "captions_train2014.json").exists(): - # Download annotations - os.makedirs(str(dataset_dir / "raw"), exist_ok=True) - os.makedirs(str(dataset_dir / "download_aux"), exist_ok=True) - download_file( - url="http://images.cocodataset.org/annotations/annotations_trainval2014.zip", - output_dir=dataset_dir / "download_aux", - ) - # Unzip file - zipfile_path = dataset_dir / "download_aux" / "annotations_trainval2014.zip" - # Unzip file - with zipfile.ZipFile( - str(zipfile_path), "r" - ) as zip_ref: - zip_ref.extractall(str(dataset_dir / "raw/")) + if not (dataset_dir / "raw" / "annotations" / + "captions_train2014.json").exists(): + # Download annotations + os.makedirs(str(dataset_dir / "raw"), exist_ok=True) + os.makedirs(str(dataset_dir / "download_aux"), exist_ok=True) + download_file( + url="http://images.cocodataset.org/annotations/annotations_trainval2014.zip", + output_dir=dataset_dir / "download_aux", + ) + # Unzip file + zipfile_path = dataset_dir / "download_aux" / "annotations_trainval2014.zip" + # Unzip file + with zipfile.ZipFile( + str(zipfile_path), "r" + ) as zip_ref: + zip_ref.extractall(str(dataset_dir / "raw/")) # Convert to dataframe format and extract the relevant fields with open(dataset_dir / "raw" / "annotations" / "captions_train2014.json") as f: @@ -133,4 +137,4 @@ def download_file(url: str, output_dir: Path, filename: str | None = None): s = "\n".join([str(_) for _ in df_annotations["id"].values]) f.write(s) # Remove Folder - shutil.rmtree(dataset_dir) \ No newline at end of file + shutil.rmtree(dataset_dir) From 8b1a40edafa1050b03211b6318d0c530207e1375 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 16:58:09 +0000 Subject: [PATCH 38/59] [Automated Commit] Format Codebase --- vision/classification_and_detection/python/backend_ncnn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vision/classification_and_detection/python/backend_ncnn.py b/vision/classification_and_detection/python/backend_ncnn.py index 59f6accde1..a5d2fd2adb 100644 --- a/vision/classification_and_detection/python/backend_ncnn.py +++ b/vision/classification_and_detection/python/backend_ncnn.py @@ -24,7 +24,7 @@ def load(self, model_path, inputs=None, outputs=None): if param_file.endswith("resnet50_v1.param"): # download model files if doesn't self.net = Resnet50(param_file, bin_file) - else: + else: print( "please add your ncnn model .param and .bin files to dir named 'resnet'" From 5b3f1da81144e2a22ea868a8b179ac993da29574 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 17:25:29 +0000 Subject: [PATCH 39/59] [Automated Commit] Format Codebase --- tools/submission/submission_checker/constants.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/submission/submission_checker/constants.py b/tools/submission/submission_checker/constants.py index dc45cd83d2..2f4abd87f8 100644 --- a/tools/submission/submission_checker/constants.py +++ b/tools/submission/submission_checker/constants.py @@ -1132,12 +1132,12 @@ "84", "59", "12", - "31", + "31", "86", - "122", - "233", + "122", + "233", "96", - ] + ] }, } } From e015604cf9c90f510a4de793467b9c29c71d04d7 Mon Sep 17 00:00:00 2001 From: mlc-automations <3246381+mlc-automations@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:52:28 +0000 Subject: [PATCH 40/59] [Automated Commit] Format Codebase --- tools/submission/submission_checker/loader.py | 4 ++-- tools/submission/submission_checker/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/submission/submission_checker/loader.py b/tools/submission/submission_checker/loader.py index 61c176efa7..bfa9dba319 100644 --- a/tools/submission/submission_checker/loader.py +++ b/tools/submission/submission_checker/loader.py @@ -283,7 +283,7 @@ def load(self) -> Generator[SubmissionLogs, None, None]: private_id_json = self.load_single_log( private_id_json_path, "System") private_id = private_id_json[system] - except: + except BaseException: self.logger.warning( "%s Private id not cached for system %s", system_path, @@ -297,7 +297,7 @@ def load(self) -> Generator[SubmissionLogs, None, None]: system_path, private_id_json_path ) - + for benchmark in list_dir(system_path): benchmark_path = os.path.join(system_path, benchmark) if division.lower() in ["closed", "network"]: diff --git a/tools/submission/submission_checker/utils.py b/tools/submission/submission_checker/utils.py index 12fe13a770..e9e6d0d443 100644 --- a/tools/submission/submission_checker/utils.py +++ b/tools/submission/submission_checker/utils.py @@ -325,7 +325,7 @@ def get_power_metric(config, scenario_fixed, log_path, is_valid, res): samples_per_query = 8 if (scenario_fixed in ["MultiStream"] - ) and scenario in ["SingleStream"]: + ) and scenario in ["SingleStream"]: power_metric = ( avg_power * power_duration * samples_per_query * 1000 / num_queries ) @@ -334,6 +334,7 @@ def get_power_metric(config, scenario_fixed, log_path, is_valid, res): return is_valid, power_metric, scenario, avg_power_efficiency + _ADJECTIVES = [ "amber", "azure", "bold", "brave", "calm", "clear", "cool", "crisp", "dark", "deep", "deft", "epic", "fair", "fast", "firm", "flat", @@ -362,4 +363,3 @@ def generate_private_id(system_id: str) -> str: noun = _NOUNS[h[1] % len(_NOUNS)] suffix = h[2:4].hex() return f"{adj}-{noun}-{suffix}" - From eaf62c12d8557e079feccdf88d9c8a080787a1f8 Mon Sep 17 00:00:00 2001 From: mlc-automations <3246381+mlc-automations@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:25:35 +0000 Subject: [PATCH 41/59] [Automated Commit] Format Codebase --- e2e-rag/QSL.py | 11 +- e2e-rag/accuracy_eval.py | 76 ++- e2e-rag/datasetup_accuracy_eval.py | 100 ++-- e2e-rag/db_manifest.py | 26 +- e2e-rag/download_docs.py | 308 +++++----- e2e-rag/evaluate.py | 72 ++- e2e-rag/evaluation.py | 509 +++++++++------- e2e-rag/ingestion_monitor.py | 236 ++++---- e2e-rag/llm_logger.py | 46 +- e2e-rag/measure_indexing_with_chunking.py | 20 +- e2e-rag/multi_shot_retrieval.py | 669 +++++++++++++--------- e2e-rag/oracle_single_shot.py | 176 +++--- e2e-rag/params.py | 127 ++-- e2e-rag/perf_test_cache.py | 6 +- e2e-rag/read_docs.py | 297 +++++----- e2e-rag/reference_SUT.py | 15 +- e2e-rag/reference_SUT_datasetup.py | 69 ++- e2e-rag/reference_mlperf.py | 27 +- e2e-rag/reference_mlperf_datasetup.py | 20 +- e2e-rag/reranker_worker.py | 15 +- e2e-rag/retrieve/__init__.py | 2 +- e2e-rag/retrieve/filter.py | 149 ++--- e2e-rag/retrieve/ragdb.py | 91 +-- e2e-rag/retrieve/vectordb.py | 300 ++++++---- e2e-rag/single_shot_retrieval.py | 109 ++-- e2e-rag/text_splitter.py | 59 +- e2e-rag/utils.py | 88 +-- loadgen/issue_query_controller.cc | 12 +- loadgen/logging.cc | 3 +- 29 files changed, 2129 insertions(+), 1509 deletions(-) diff --git a/e2e-rag/QSL.py b/e2e-rag/QSL.py index 93e5f7933c..164c25c693 100644 --- a/e2e-rag/QSL.py +++ b/e2e-rag/QSL.py @@ -85,7 +85,8 @@ def __init__(self, dataset_path, perf_count=None, skip_qsl=False): print(f"Dataset loaded: {self.count} queries") if perf_count is not None: - print(f" (limited to first {perf_count} queries for performance testing)") + print( + f" (limited to first {perf_count} queries for performance testing)") def load_query_samples(self, sample_list): """ @@ -161,15 +162,12 @@ def __init__(self, dataset_path, perf_count=None): # limitations under the License. # ============================================================================= + """ Query Sample Library for RAG-QnA workload. Loads queries from frames_dataset.tsv and provides them to MLPerf Loadgen. """ -import os -import pandas as pd -import mlperf_loadgen as lg - class E2EQSL: """Query Sample Library for RAG-QnA multi-hop RAG benchmark.""" @@ -233,7 +231,8 @@ def __init__(self, dataset_path, perf_count=None, skip_qsl=False): print(f"Dataset loaded: {self.count} queries") if perf_count is not None: - print(f" (limited to first {perf_count} queries for performance testing)") + print( + f" (limited to first {perf_count} queries for performance testing)") def load_query_samples(self, sample_list): """ diff --git a/e2e-rag/accuracy_eval.py b/e2e-rag/accuracy_eval.py index 29ce199482..a4561c7449 100644 --- a/e2e-rag/accuracy_eval.py +++ b/e2e-rag/accuracy_eval.py @@ -34,9 +34,10 @@ # OpenRouter configuration DEFAULT_JUDGE_URL = "http://127.0.0.1:8123/v1/chat/completions" DEFAULT_JUDGE_MODEL = "gpt-oss-20b" -# Masked API key (set OPENROUTER_API_KEY environment variable to use OpenRouter) +# Masked API key (set OPENROUTER_API_KEY environment variable to use +# OpenRouter) OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', - 'sk-or-v1-****') + 'sk-or-v1-****') JUDGE_PROMPT = """You are an expert evaluator comparing LLM-generated answers to ground truth answers. @@ -83,7 +84,11 @@ def call_judge(question: str, ground_truth: str, llm_answer: str, } try: - response = requests.post(service_url, json=payload, headers=headers, timeout=60) + response = requests.post( + service_url, + json=payload, + headers=headers, + timeout=60) response.raise_for_status() result = response.json() @@ -105,7 +110,8 @@ def call_judge(question: str, ground_truth: str, llm_answer: str, return {"correct": False, "reasoning": f"Judge error: {e}"} -def calculate_retrieval_metrics(retrieved_urls: List[str], expected_urls: List[str]) -> Dict: +def calculate_retrieval_metrics( + retrieved_urls: List[str], expected_urls: List[str]) -> Dict: """Calculate precision, recall, F1 for retrieval.""" retrieved_set = set(retrieved_urls) @@ -118,7 +124,8 @@ def calculate_retrieval_metrics(retrieved_urls: List[str], expected_urls: List[s precision = len(correct) / len(retrieved_set) if retrieved_set else 0.0 recall = len(correct) / len(expected_set) if expected_set else 0.0 - f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0 + f1 = 2 * precision * recall / \ + (precision + recall) if (precision + recall) > 0 else 0.0 return { "precision": precision, @@ -128,8 +135,8 @@ def calculate_retrieval_metrics(retrieved_urls: List[str], expected_urls: List[s def evaluate_results(results: Dict, dataset_path: str, num_workers: int = 4, - judge_service_url: str = DEFAULT_JUDGE_URL, - judge_model: str = DEFAULT_JUDGE_MODEL) -> Dict: + judge_service_url: str = DEFAULT_JUDGE_URL, + judge_model: str = DEFAULT_JUDGE_MODEL) -> Dict: """ Evaluate loadgen results. @@ -190,12 +197,13 @@ def evaluate_single_query(query_id, result): expected_urls = gt_data['expected_urls'] # Calculate retrieval metrics - retrieval_metrics = calculate_retrieval_metrics(retrieved_urls, expected_urls) + retrieval_metrics = calculate_retrieval_metrics( + retrieved_urls, expected_urls) # Judge answer correctness judge_result = call_judge(query, ground_truth, llm_answer, - service_url=judge_service_url, - model_name=judge_model) + service_url=judge_service_url, + model_name=judge_model) answer_correct = judge_result.get('correct', False) return { @@ -229,7 +237,8 @@ def evaluate_single_query(query_id, result): total_queries += 1 if total_queries % 10 == 0: - print(f" Evaluated {total_queries}/{len(results)} queries...") + print( + f" Evaluated {total_queries}/{len(results)} queries...") except Exception as e: print(f"Error evaluating query: {e}") @@ -257,14 +266,37 @@ def evaluate_single_query(query_id, result): def main(): - parser = argparse.ArgumentParser(description="Evaluate RAG-QnA loadgen accuracy") - parser.add_argument('--log_dir', required=True, help='Loadgen log directory') - parser.add_argument('--results_file', required=True, help='SUT results JSON file') - parser.add_argument('--dataset_path', required=True, help='Path to frames_dataset.tsv') - parser.add_argument('--num_workers', type=int, default=4, help='Number of parallel judge workers') - parser.add_argument('--output', default='accuracy_results.json', help='Output file for detailed results') - parser.add_argument('--judge_service_url', default=DEFAULT_JUDGE_URL, help='Judge LLM service URL') - parser.add_argument('--judge_model', default=DEFAULT_JUDGE_MODEL, help='Judge LLM model name') + parser = argparse.ArgumentParser( + description="Evaluate RAG-QnA loadgen accuracy") + parser.add_argument( + '--log_dir', + required=True, + help='Loadgen log directory') + parser.add_argument( + '--results_file', + required=True, + help='SUT results JSON file') + parser.add_argument( + '--dataset_path', + required=True, + help='Path to frames_dataset.tsv') + parser.add_argument( + '--num_workers', + type=int, + default=4, + help='Number of parallel judge workers') + parser.add_argument( + '--output', + default='accuracy_results.json', + help='Output file for detailed results') + parser.add_argument( + '--judge_service_url', + default=DEFAULT_JUDGE_URL, + help='Judge LLM service URL') + parser.add_argument( + '--judge_model', + default=DEFAULT_JUDGE_MODEL, + help='Judge LLM model name') args = parser.parse_args() # Load results @@ -280,9 +312,9 @@ def main(): judge_model=args.judge_model) # Print summary - print("\n" + "="*80) + print("\n" + "=" * 80) print("ACCURACY EVALUATION RESULTS") - print("="*80) + print("=" * 80) print(f"Total Queries: {metrics['total_queries']}") print(f"\nRetrieval Metrics:") print(f" Precision@N: {metrics['retrieval_precision']:.3f}") @@ -290,7 +322,7 @@ def main(): print(f" F1@N: {metrics['retrieval_f1']:.3f}") print(f"\nAnswer Quality:") print(f" LLM Judge Accuracy: {metrics['answer_accuracy']:.3f}") - print("="*80 + "\n") + print("=" * 80 + "\n") # Save detailed results with open(args.output, 'w') as f: diff --git a/e2e-rag/datasetup_accuracy_eval.py b/e2e-rag/datasetup_accuracy_eval.py index 7df71d03f0..3f89041b46 100755 --- a/e2e-rag/datasetup_accuracy_eval.py +++ b/e2e-rag/datasetup_accuracy_eval.py @@ -68,7 +68,8 @@ def parse_accuracy_log(log_path): try: qsl_idx = entry['qsl_idx'] - # Handle data field - can be hex string or list of bytes + # Handle data field - can be hex string or list of + # bytes data_field = entry['data'] if isinstance(data_field, str): # Hex string - convert to bytes @@ -180,7 +181,8 @@ def validate_database(database_path, retriever_model): print(f" ✗ Vector count: Cannot access vector store") # Check 2: Docstore consistency - if hasattr(db, '_vector_store') and hasattr(db._vector_store, 'index_to_docstore_id'): + if hasattr(db, '_vector_store') and hasattr( + db._vector_store, 'index_to_docstore_id'): docstore_count = len(db._vector_store.index_to_docstore_id) check_passed = (docstore_count == vector_count) validation_results["checks"].append({ @@ -194,7 +196,8 @@ def validate_database(database_path, retriever_model): if check_passed: print(f" ✓ Docstore consistency: {docstore_count} documents") else: - print(f" ✗ Docstore consistency: {vector_count} vectors but {docstore_count} documents") + print( + f" ✗ Docstore consistency: {vector_count} vectors but {docstore_count} documents") else: validation_results["checks"].append({ "name": "docstore_consistency", @@ -206,7 +209,8 @@ def validate_database(database_path, retriever_model): print(f" ✗ Docstore consistency: Cannot access docstore") # Check 3: Index dimension - if hasattr(db, '_vector_store') and hasattr(db._vector_store.index, 'd'): + if hasattr(db, '_vector_store') and hasattr( + db._vector_store.index, 'd'): dimension = db._vector_store.index.d expected_dim = 768 # e5-base-v2 check_passed = (dimension == expected_dim) @@ -218,7 +222,8 @@ def validate_database(database_path, retriever_model): "expected": expected_dim }) # Don't fail on dimension mismatch, just warn - print(f" {'✓' if check_passed else '⚠'} Index dimension: {dimension} (expected {expected_dim})") + print( + f" {'✓' if check_passed else '⚠'} Index dimension: {dimension} (expected {expected_dim})") else: validation_results["checks"].append({ "name": "index_dimension", @@ -241,7 +246,8 @@ def validate_database(database_path, retriever_model): }) validation_results["passed"] &= check_passed if check_passed: - print(f" ✓ Sample retrieval: Retrieved {len(results)} results") + print( + f" ✓ Sample retrieval: Retrieved {len(results)} results") else: print(f" ✗ Sample retrieval: No results returned") except Exception as e: @@ -268,7 +274,8 @@ def validate_database(database_path, retriever_model): return validation_results -def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): +def evaluate_accuracy(log_dir, output_dir, database_path, + retriever_model=None): """ Evaluate accuracy of datasetup workload. @@ -281,9 +288,9 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): Returns: dict: Accuracy results """ - print("="*80) + print("=" * 80) print("RAG-DB Accuracy Evaluation") - print("="*80) + print("=" * 80) print(f"Started: {datetime.now().isoformat()}") print() @@ -320,18 +327,21 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): # MD5 hash (32 hex characters = 32 bytes when encoded) try: md5_str = data.decode('utf-8') - if len(md5_str) == 32 and all(c in '0123456789abcdef' for c in md5_str): + if len(md5_str) == 32 and all( + c in '0123456789abcdef' for c in md5_str): md5_response = md5_str md5_qsl_idx = qsl_idx success_count += 1 # MD5 response counts as success else: - print(f"Warning: Invalid MD5 format at qsl_idx {qsl_idx}: {md5_str}") + print( + f"Warning: Invalid MD5 format at qsl_idx {qsl_idx}: {md5_str}") failure_count += 1 except UnicodeDecodeError: print(f"Warning: Cannot decode response at qsl_idx {qsl_idx}") failure_count += 1 else: - print(f"Warning: Unexpected response length {len(data)} at qsl_idx {qsl_idx}") + print( + f"Warning: Unexpected response length {len(data)} at qsl_idx {qsl_idx}") failure_count += 1 print(f"Response Summary:") @@ -372,13 +382,14 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): # Compare MD5s md5_match = (md5_response == actual_md5) - print("="*80) + print("=" * 80) print("Accuracy Results:") - print("="*80) + print("=" * 80) print(f" Total files processed: {success_count + failure_count}") print(f" Successful: {success_count}") print(f" Failed: {failure_count}") - print(f" Success rate: {100.0 * success_count / (success_count + failure_count):.2f}%") + print( + f" Success rate: {100.0 * success_count / (success_count + failure_count):.2f}%") print() print(f" MD5 returned: {md5_response}") print(f" MD5 actual: {actual_md5}") @@ -390,12 +401,13 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): # 1. At least 99% of files succeeded # 2. MD5 matches min_success_rate = 0.99 - actual_success_rate = success_count / (success_count + failure_count) if (success_count + failure_count) > 0 else 0 + actual_success_rate = success_count / \ + (success_count + failure_count) if (success_count + failure_count) > 0 else 0 passed = (actual_success_rate >= min_success_rate) and md5_match print(f"Overall: {'✅ PASSED' if passed else '❌ FAILED'}") - print("="*80) + print("=" * 80) print() # Save results @@ -423,9 +435,9 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): # Run database validation if retriever model provided validation_results = None if retriever_model and os.path.exists(database_path): - print("="*80) + print("=" * 80) print("Database Validation") - print("="*80) + print("=" * 80) print() validation_results = validate_database(database_path, retriever_model) @@ -436,48 +448,54 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): overall_passed = passed and validation_results["passed"] accuracy_results["passed"] = overall_passed - print("="*80) + print("=" * 80) print("Validation Summary:") - print("="*80) + print("=" * 80) for check in validation_results["checks"]: - status_symbol = "✓" if check["result"] == "PASS" else ("⚠" if check["result"] == "WARN" else "✗") - print(f" {status_symbol} {check['description']}: {check['result']}") + status_symbol = "✓" if check["result"] == "PASS" else ( + "⚠" if check["result"] == "WARN" else "✗") + print( + f" {status_symbol} {check['description']}: {check['result']}") print() - print(f"Validation: {'✅ PASSED' if validation_results['passed'] else '❌ FAILED'}") + print( + f"Validation: {'✅ PASSED' if validation_results['passed'] else '❌ FAILED'}") print(f"Overall: {'✅ PASSED' if overall_passed else '❌ FAILED'}") - print("="*80) + print("=" * 80) print() # Write accuracy.txt in MLPerf format accuracy_txt_path = os.path.join(log_dir, "accuracy.txt") with open(accuracy_txt_path, 'w') as f: - f.write("="*80 + "\n") + f.write("=" * 80 + "\n") f.write("RAG-DB Accuracy Report\n") - f.write("="*80 + "\n") + f.write("=" * 80 + "\n") f.write(f"Timestamp: {datetime.now().isoformat()}\n") f.write(f"Database: {database_path}\n") f.write("\n") f.write("File Processing Results:\n") - f.write("-"*80 + "\n") + f.write("-" * 80 + "\n") f.write(f"Total files: {accuracy_results['total_files']}\n") f.write(f"Successful: {accuracy_results['successful_files']}\n") f.write(f"Failed: {accuracy_results['failed_files']}\n") f.write(f"Success rate: {accuracy_results['success_rate']*100:.2f}%\n") - f.write(f"Required rate: {accuracy_results['min_success_rate_required']*100:.2f}%\n") - f.write(f"Status: {'PASS' if actual_success_rate >= min_success_rate else 'FAIL'}\n") + f.write( + f"Required rate: {accuracy_results['min_success_rate_required']*100:.2f}%\n") + f.write( + f"Status: {'PASS' if actual_success_rate >= min_success_rate else 'FAIL'}\n") f.write("\n") f.write("MD5 Verification:\n") - f.write("-"*80 + "\n") + f.write("-" * 80 + "\n") f.write(f"MD5 returned by SUT: {accuracy_results['md5_response']}\n") f.write(f"MD5 actual (computed): {accuracy_results['md5_actual']}\n") - f.write(f"MD5 match: {'PASS' if accuracy_results['md5_match'] else 'FAIL'}\n") + f.write( + f"MD5 match: {'PASS' if accuracy_results['md5_match'] else 'FAIL'}\n") f.write("\n") if validation_results: f.write("Database Validation:\n") - f.write("-"*80 + "\n") + f.write("-" * 80 + "\n") for check in validation_results["checks"]: f.write(f" {check['name']}: {check['result']}\n") f.write(f" - {check['description']}\n") @@ -485,12 +503,14 @@ def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None): f.write(f" - Value: {check['value']}\n") if 'error' in check: f.write(f" - Error: {check['error']}\n") - f.write(f"Validation status: {'PASS' if validation_results['passed'] else 'FAIL'}\n") + f.write( + f"Validation status: {'PASS' if validation_results['passed'] else 'FAIL'}\n") f.write("\n") - f.write("="*80 + "\n") - f.write(f"Overall Result: {'PASS' if accuracy_results['passed'] else 'FAIL'}\n") - f.write("="*80 + "\n") + f.write("=" * 80 + "\n") + f.write( + f"Overall Result: {'PASS' if accuracy_results['passed'] else 'FAIL'}\n") + f.write("=" * 80 + "\n") print(f"Accuracy report saved to: {accuracy_txt_path}") print() @@ -526,7 +546,11 @@ def main(): args = parser.parse_args() - results = evaluate_accuracy(args.log_dir, args.output_dir, args.database, args.retriever_model) + results = evaluate_accuracy( + args.log_dir, + args.output_dir, + args.database, + args.retriever_model) # Exit with appropriate code if results.get("passed", False): diff --git a/e2e-rag/db_manifest.py b/e2e-rag/db_manifest.py index 1cfe20f85f..39f8cbbc0a 100644 --- a/e2e-rag/db_manifest.py +++ b/e2e-rag/db_manifest.py @@ -112,7 +112,8 @@ def _gather_top_k(db: VectorDB, queries: List[Dict], k: int) -> List[Dict]: urls = [] for doc in results: md = getattr(doc, "metadata", None) or {} - url = md.get("original_url") or md.get("source") or md.get("base_filename") or "" + url = md.get("original_url") or md.get( + "source") or md.get("base_filename") or "" urls.append(url) out.append({"index": q["index"], "top_k_urls": urls}) return out @@ -139,10 +140,12 @@ def cmd_write(args): db = _load_db(args.db, args.retriever_model) total_passages = len(db._vector_store.index_to_docstore_id) - print(f"[manifest] DB has {total_passages} passages, dim={db._embedding_dimension}") + print( + f"[manifest] DB has {total_passages} passages, dim={db._embedding_dimension}") corpus_sha = _sha256_docstore(db) - sample_block = _gather_sample_embeddings(db, total_passages, NUM_SAMPLE_EMBEDDINGS) + sample_block = _gather_sample_embeddings( + db, total_passages, NUM_SAMPLE_EMBEDDINGS) probe_queries = _load_probe_queries(args.dataset, NUM_PROBE_QUERIES) probe_block = _gather_top_k(db, probe_queries, PROBE_TOP_K) @@ -237,7 +240,9 @@ def cmd_verify(args): f"top-{args.top_k_depth} {len(probe_queries) - len(rank_failures)}/" f"{len(probe_queries)} match") if rank_failures: - failures.append("probe-query top-K rank mismatch:\n" + "\n".join(rank_failures)) + failures.append( + "probe-query top-K rank mismatch:\n" + + "\n".join(rank_failures)) if failures: print("\n[verify] FAILED:") @@ -252,17 +257,24 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="cmd", required=True) - pw = sub.add_parser("write", help="Generate a reference manifest from a DB.") + pw = sub.add_parser( + "write", + help="Generate a reference manifest from a DB.") pw.add_argument("--db", required=True) pw.add_argument("--retriever_model", default="intfloat/e5-base-v2") pw.add_argument("--dataset", default="data/frames_dataset.tsv") pw.add_argument("--output", required=True) pw.set_defaults(func=cmd_write) - pv = sub.add_parser("verify", help="Verify a DB against a reference manifest.") + pv = sub.add_parser( + "verify", + help="Verify a DB against a reference manifest.") pv.add_argument("--db", required=True) pv.add_argument("--manifest", required=True) - pv.add_argument("--cosine-threshold", type=float, default=DEFAULT_COSINE_THRESHOLD) + pv.add_argument( + "--cosine-threshold", + type=float, + default=DEFAULT_COSINE_THRESHOLD) pv.add_argument("--top-k-depth", type=int, default=DEFAULT_TOP_K_DEPTH) pv.set_defaults(func=cmd_verify) diff --git a/e2e-rag/download_docs.py b/e2e-rag/download_docs.py index 1778e97e21..f5d1ad46e8 100644 --- a/e2e-rag/download_docs.py +++ b/e2e-rag/download_docs.py @@ -78,56 +78,61 @@ def fix_malformed_url(url: str) -> str: return url - - - class BaseDownloader(ABC): """Base class for downloading web pages in different formats.""" - + def __init__(self, output_dir: str, processes: int = 10): self.output_dir = Path(output_dir) self.output_dir.mkdir(exist_ok=True) self.processes = processes self.url_mapping = {} - + @abstractmethod def get_file_extension(self) -> str: """Return the file extension for this downloader.""" pass - + def create_filename(self, url: str) -> str: """Generate a filename from URL.""" - filename = url.replace("https://", "").replace("/", "_").replace(":", "_") - + filename = url.replace( + "https://", + "").replace( + "/", + "_").replace( + ":", + "_") + # Truncate if too long max_length = 200 if len(filename) > max_length: filename = filename[:max_length] - + return filename + self.get_file_extension() - + @abstractmethod - def download_single_url(self, url: str, output_path: Path) -> Tuple[bool, str]: + def download_single_url( + self, url: str, output_path: Path) -> Tuple[bool, str]: """ Download a single URL to the specified path. - + Returns: Tuple of (success: bool, error_message: str) """ pass - - def process_url(self, args_tuple: Tuple[str, Path, int, int]) -> Tuple[bool, str, str, str]: + + def process_url( + self, args_tuple: Tuple[str, Path, int, int]) -> Tuple[bool, str, str, str]: """Process a single URL - designed for multiprocessing.""" url, output_dir, index, total = args_tuple - + # Create safe filename filename = self.create_filename(url) output_path = output_dir / filename - + # Skip if file already exists if output_path.exists(): return True, filename, "Skipping", url - + # Download the URL try: success, error_msg = self.download_single_url(url, output_path) @@ -141,76 +146,82 @@ def process_url(self, args_tuple: Tuple[str, Path, int, int]) -> Tuple[bool, str return False, filename, error_msg, url else: return False, filename, error_msg, url - + except Exception as e: return False, filename, f"Exception: {str(e)[:100]}", url - - def download_urls(self, urls: List[str], retry_failures: bool = True) -> Dict[str, Any]: + + def download_urls( + self, urls: List[str], retry_failures: bool = True) -> Dict[str, Any]: """Download multiple URLs with parallel processing and progress tracking.""" - + if not urls: print("No URLs found to process") return {"successful": 0, "failed": 0, "failed_urls": []} - - print(f"Processing {len(urls)} URLs with {self.processes} parallel processes...") - + + print( + f"Processing {len(urls)} URLs with {self.processes} parallel processes...") + # Create progress bar progress_bar = tqdm( total=len(urls), desc="Starting downloads...", unit="URL" ) - + # Process URLs in parallel with progress bar start_time = time.time() - + # Prepare arguments for multiprocessing process_args = [(url, self.output_dir, i + 1, len(urls)) - for i, url in enumerate(urls)] - + for i, url in enumerate(urls)] + # Process with progress bar updates results = [] failed_urls = [] # Track failed URLs for detailed reporting - + with Pool(processes=self.processes) as pool: for result in pool.imap(self.process_url, process_args): success, filename, status, url = result results.append((success, filename)) - + base_filename = get_base_filename(filename) self.url_mapping[base_filename] = url - + # Update progress bar with status if status == "Skipping": - progress_bar.set_description(f"Skipping: {filename[:30]}...") + progress_bar.set_description( + f"Skipping: {filename[:30]}...") elif status == "Success": - progress_bar.set_description(f"✓ Success: {filename[:30]}...") + progress_bar.set_description( + f"✓ Success: {filename[:30]}...") else: # This is a failure case - progress_bar.set_description(f"✗ {status}: {filename[:30]}...") + progress_bar.set_description( + f"✗ {status}: {filename[:30]}...") failed_urls.append((filename, status, url)) print(f"\n❌ FAILED: {filename}") print(f" URL: {url}") print(f" Error: {status}") - + progress_bar.update(1) - + progress_bar.close() - + # Save URL mapping to JSON file self.save_url_mapping() - + # Count results successful = sum(1 for success, _ in results if success) failed = len(results) - successful - + end_time = time.time() duration = end_time - start_time - - print(f"\nDownload complete! Successful: {successful}, Failed: {failed}") + + print( + f"\nDownload complete! Successful: {successful}, Failed: {failed}") print(f"Total time: {duration:.2f} seconds") print(f"Average time per URL: {duration/len(urls):.2f} seconds") - + # Print detailed failure report if there were failures if failed_urls: print(f"\n=== FAILED DOWNLOADS DETAILS ===") @@ -219,7 +230,7 @@ def download_urls(self, urls: List[str], retry_failures: bool = True) -> Dict[st print(f" URL: {url}") print(f" Error: {status}") print() - + # Retry failed URLs if requested if retry_failures: retry_result = self.retry_failed_urls(failed_urls) @@ -227,23 +238,24 @@ def download_urls(self, urls: List[str], retry_failures: bool = True) -> Dict[st failed = retry_result["still_failed"] else: print(f"\n✅ All downloads completed successfully!") - + return { "successful": successful, "failed": failed, "failed_urls": failed_urls, "duration": duration } - + def save_url_mapping(self): """Save URL mapping to JSON file.""" save_url_mapping(str(self.output_dir), self.url_mapping) - - def retry_failed_urls(self, failed_urls: List[Tuple[str, str, str]]) -> Dict[str, int]: + + def retry_failed_urls( + self, failed_urls: List[Tuple[str, str, str]]) -> Dict[str, int]: """Retry downloading failed URLs, but skip certain types of permanent failures.""" if not failed_urls: return {"successful": 0, "still_failed": 0} - + # Filter out failures that shouldn't be retried (permanent failures). # Note: We intentionally do NOT include "HTTP error 4" here because that # would also match retryable 429 (Too Many Requests) responses. @@ -252,62 +264,72 @@ def retry_failed_urls(self, failed_urls: List[Tuple[str, str, str]]) -> Dict[str "HTTP error 410", "HTTP error 451", "Invalid or empty content", ] - + retryable_urls = [] permanent_failures = [] - + for filename, status, url in failed_urls: - is_permanent = any(keyword in status for keyword in permanent_failure_keywords) + is_permanent = any( + keyword in status for keyword in permanent_failure_keywords) if is_permanent: permanent_failures.append((filename, status, url)) else: retryable_urls.append((filename, status, url)) - + if permanent_failures: - print(f"\nSkipping {len(permanent_failures)} permanent failures (404s, etc.)") - + print( + f"\nSkipping {len(permanent_failures)} permanent failures (404s, etc.)") + if not retryable_urls: print("No retryable URLs found.") return {"successful": 0, "still_failed": len(permanent_failures)} - + print(f"\n=== RETRYING FAILED DOWNLOADS ===") - print(f"Retrying {len(retryable_urls)} failed URLs (skipping {len(permanent_failures)} permanent failures)...") - + print( + f"Retrying {len(retryable_urls)} failed URLs (skipping {len(permanent_failures)} permanent failures)...") + # Prepare arguments for retry retry_args = [(url, self.output_dir, i + 1, len(retryable_urls)) - for i, (_, _, url) in enumerate(retryable_urls)] - + for i, (_, _, url) in enumerate(retryable_urls)] + # Create progress bar for retry - progress_bar = tqdm(total=len(retryable_urls), desc="Retrying...", unit="URL") - + progress_bar = tqdm( + total=len(retryable_urls), + desc="Retrying...", + unit="URL") + successful_retries = 0 still_failed = [] - + with Pool(processes=self.processes) as pool: for result in pool.imap(self.process_url, retry_args): success, filename, status, url = result - + if success: - progress_bar.set_description(f"✓ Retry Success: {filename[:30]}...") + progress_bar.set_description( + f"✓ Retry Success: {filename[:30]}...") successful_retries += 1 else: - progress_bar.set_description(f"✗ Retry Failed: {filename[:30]}...") + progress_bar.set_description( + f"✗ Retry Failed: {filename[:30]}...") still_failed.append((filename, status, url)) print(f"\n❌ RETRY FAILED: {filename}") print(f" URL: {url}") print(f" Error: {status}") - + progress_bar.update(1) - + progress_bar.close() - + # Combine still failed with permanent failures all_failed = still_failed + permanent_failures - - print(f"\nRetry complete! Successfully retried: {successful_retries}, Still failed: {len(all_failed)}") - print(f" - Retryable failures: {len(still_failed)}") - print(f" - Permanent failures (404s, etc.): {len(permanent_failures)}") - + + print( + f"\nRetry complete! Successfully retried: {successful_retries}, Still failed: {len(all_failed)}") + print(f" - Retryable failures: {len(still_failed)}") + print( + f" - Permanent failures (404s, etc.): {len(permanent_failures)}") + if all_failed: print(f"\n=== STILL FAILED AFTER RETRY ===") for i, (filename, status, url) in enumerate(all_failed, 1): @@ -315,18 +337,19 @@ def retry_failed_urls(self, failed_urls: List[Tuple[str, str, str]]) -> Dict[str print(f" URL: {url}") print(f" Error: {status}") print() - - return {"successful": successful_retries, "still_failed": len(all_failed)} + + return {"successful": successful_retries, + "still_failed": len(all_failed)} class PDFDownloader(BaseDownloader): """Download web pages as PDFs using wkhtmltopdf.""" - + def get_file_extension(self) -> str: return ".pdf" - - - def download_single_url(self, url: str, output_path: Path) -> Tuple[bool, str]: + + def download_single_url( + self, url: str, output_path: Path) -> Tuple[bool, str]: """Download a single URL as PDF using wkhtmltopdf with try-fix-retry approach.""" def attempt_pdf_download(target_url: str) -> Tuple[bool, str, bool]: """ @@ -339,7 +362,7 @@ def attempt_pdf_download(target_url: str) -> Tuple[bool, str, bool]: f'--load-error-handling ignore --load-media-error-handling ignore ' f'--javascript-delay 2000 "{target_url}" "{output_path}"' ) - + try: result = subprocess.run( command, @@ -348,7 +371,7 @@ def attempt_pdf_download(target_url: str) -> Tuple[bool, str, bool]: text=True, timeout=120 ) - + if result.returncode == 0: return True, "Success", False else: @@ -357,20 +380,20 @@ def attempt_pdf_download(target_url: str) -> Tuple[bool, str, bool]: stderr_text = result.stderr[:200] error_msg += f" - {stderr_text}" # Check for 404-like errors in stderr - is_404 = any(phrase in stderr_text.lower() for phrase in - ['404', 'not found', 'page not found', 'http error']) + is_404 = any(phrase in stderr_text.lower() for phrase in + ['404', 'not found', 'page not found', 'http error']) return False, error_msg, is_404 return False, error_msg, False - + except subprocess.TimeoutExpired: return False, "Timeout (120s)", False - + # Try original URL first success, error_msg, is_404 = attempt_pdf_download(url) - + if success: return True, "Success" - + # If it was a 404-like error, try to fix the URL and retry if is_404: fixed_url = fix_malformed_url(url) @@ -381,30 +404,32 @@ def attempt_pdf_download(target_url: str) -> Tuple[bool, str, bool]: return True, f"Success (fixed URL)" else: return False, f"Original: {error_msg}; Fixed attempt: {retry_error_msg}" - + # Return original error if no fix was attempted or fix failed return False, error_msg class HTMLDownloader(BaseDownloader): """Download web pages as HTML files using requests.""" - + # Per-process retry policy for transient errors (429, 5xx) MAX_RETRIES = 5 BASE_BACKOFF = 2.0 # seconds; exponential: BASE_BACKOFF * 2**attempt MAX_BACKOFF = 60.0 # cap on a single sleep - def __init__(self, output_dir: str, processes: int = 4, delay: float = 1.0, timeout: int = 30): + def __init__(self, output_dir: str, processes: int = 4, + delay: float = 1.0, timeout: int = 30): # HTML downloading can use parallel processes with rate limiting super().__init__(output_dir, processes=processes) self.delay = delay self.timeout = timeout - + if requests is None: - raise ImportError("requests package is required for HTML downloads. Install with: pip install requests") - + raise ImportError( + "requests package is required for HTML downloads. Install with: pip install requests") + self.session = requests.Session() - + # Wikipedia's User-Agent policy asks for a descriptive UA that identifies # the tool/operator and includes a contact URL or email. Generic browser # UAs are aggressively rate-limited. Operators may override via the @@ -420,12 +445,12 @@ def __init__(self, output_dir: str, processes: int = 4, delay: float = 1.0, time 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', }) - + def get_file_extension(self) -> str: return ".html" - - - def _compute_backoff(self, attempt: int, retry_after_header: Optional[str]) -> float: + + def _compute_backoff(self, attempt: int, + retry_after_header: Optional[str]) -> float: """Compute backoff delay honoring Retry-After if present, with jitter.""" if retry_after_header: try: @@ -437,7 +462,8 @@ def _compute_backoff(self, attempt: int, retry_after_header: Optional[str]) -> f cap = min(self.BASE_BACKOFF * (2 ** attempt), self.MAX_BACKOFF) return random.uniform(self.BASE_BACKOFF, cap) - def download_single_url(self, url: str, output_path: Path) -> Tuple[bool, str]: + def download_single_url( + self, url: str, output_path: Path) -> Tuple[bool, str]: """Download a single URL as HTML using requests with try-fix-retry approach.""" def attempt_download(target_url: str) -> Tuple[bool, str, bool]: """ @@ -447,7 +473,8 @@ def attempt_download(target_url: str) -> Tuple[bool, str, bool]: last_error = "Unknown error" for attempt in range(self.MAX_RETRIES): try: - response = self.session.get(target_url, timeout=self.timeout) + response = self.session.get( + target_url, timeout=self.timeout) # Retry on 429 (rate limit) and 5xx (server errors) if response.status_code == 429 or 500 <= response.status_code < 600: @@ -472,14 +499,16 @@ def attempt_download(target_url: str) -> Tuple[bool, str, bool]: # Check if we got redirected to a different article if response.url != target_url: - print(f"Redirected from {target_url} to {response.url}") + print( + f"Redirected from {target_url} to {response.url}") if 'charset' in response.headers.get('content-type', ''): encoding = response.encoding else: encoding = 'utf-8' - html_content = response.content.decode(encoding, errors='ignore') + html_content = response.content.decode( + encoding, errors='ignore') if 'wikipedia' not in html_content.lower() or len(html_content) < 1000: return False, f"Invalid or empty content (length: {len(html_content)})", False @@ -514,13 +543,13 @@ def attempt_download(target_url: str) -> Tuple[bool, str, bool]: return False, f"Exception: {str(e)[:100]}", False return False, last_error, False - + # Try original URL first success, error_msg, is_404 = attempt_download(url) - + if success: return True, "Success" - + # If it was a 404-like error, try to fix the URL and retry if is_404: fixed_url = fix_malformed_url(url) @@ -531,21 +560,15 @@ def attempt_download(target_url: str) -> Tuple[bool, str, bool]: return True, f"Success (fixed URL)" else: return False, f"Original: {error_msg}; Fixed attempt: {retry_error_msg}" - + # Return original error if no fix was attempted or fix failed return False, error_msg - + def download_urls(self, urls: List[str], retry_failures: bool = True): """Download URLs with parallel processing and rate limiting.""" return super().download_urls(urls, retry_failures) - - - - - - def download_frames_dataset(output_dir): """Download the FRAMES dataset from Hugging Face and save as TSV.""" print("Downloading FRAMES dataset from Hugging Face...") @@ -623,7 +646,8 @@ def extract_wikipedia_links(item): return links -def extract_urls_from_frames_dataset(tsv_path: str, max_urls: Optional[int] = None) -> List[str]: +def extract_urls_from_frames_dataset( + tsv_path: str, max_urls: Optional[int] = None) -> List[str]: """Extract unique Wikipedia URLs from FRAMES dataset TSV file.""" # Load dataset df = pd.read_csv(tsv_path, sep='\t') @@ -635,15 +659,12 @@ def extract_urls_from_frames_dataset(tsv_path: str, max_urls: Optional[int] = No urls.add(link) urls = list(urls) - + # Limit number if specified if max_urls and max_urls > 0: urls = urls[:max_urls] - - return urls - - + return urls def main(): @@ -651,15 +672,15 @@ def main(): description='Download Wikipedia pages as PDFs or HTML files from FRAMES dataset or other sources.\nBy default, URLs are validated before downloading to avoid 404 errors.', formatter_class=argparse.RawTextHelpFormatter ) - + # Format selection parser.add_argument( - '--format', - choices=['pdf', 'html'], + '--format', + choices=['pdf', 'html'], default='pdf', help='Output format: pdf or html (default: pdf)' ) - + # URL sources (mutually exclusive) url_group = parser.add_mutually_exclusive_group() url_group.add_argument( @@ -668,15 +689,15 @@ def main(): ) url_group.add_argument( - '--urls', - nargs='+', + '--urls', + nargs='+', help='List of URLs to download' ) url_group.add_argument( - '--url-file', + '--url-file', help='File containing URLs (one per line)' ) - + # Output options parser.add_argument( '--output-dir', @@ -687,7 +708,7 @@ def main(): default='frames-benchmark-dataset', help='Directory for dataset files (default: frames-benchmark-dataset)' ) - + # Processing options parser.add_argument( '--max-files', @@ -702,7 +723,7 @@ def main(): 'Wikipedia rate-limits aggressive HTML scrapers; ' 'consider --processes 4 for HTML.' ) - + # HTML-specific options parser.add_argument( '--delay', @@ -717,7 +738,7 @@ def main(): default=30, help='Timeout for HTML requests in seconds (default: 30)' ) - + # Dataset options parser.add_argument( '--download-dataset', @@ -733,7 +754,7 @@ def main(): # Get URLs from various sources urls = None - + if args.url_file: try: with open(args.url_file, 'r', encoding='utf-8') as f: @@ -742,14 +763,14 @@ def main(): except Exception as e: print(f"Error loading URLs from file: {e}") return - + elif args.urls: urls = args.urls - + elif args.tsv_path or args.download_dataset: # Handle FRAMES dataset tsv_path = args.tsv_path - + if tsv_path is None and args.download_dataset: print("=== DOWNLOADING FRAMES DATASET ===") tsv_path = download_frames_dataset(args.data_dir) @@ -759,10 +780,10 @@ def main(): elif tsv_path is None: print("❌ No TSV path provided. Use --tsv-path or --download-dataset") return - + urls = extract_urls_from_frames_dataset(tsv_path, args.max_files) print(f"Extracted {len(urls)} URLs from FRAMES dataset: {tsv_path}") - + else: # Default: download dataset print("=== DOWNLOADING FRAMES DATASET ===") @@ -770,10 +791,10 @@ def main(): if tsv_path is None: print("❌ Failed to download FRAMES dataset. Exiting.") return - + urls = extract_urls_from_frames_dataset(tsv_path, args.max_files) print(f"Extracted {len(urls)} URLs from FRAMES dataset: {tsv_path}") - + if not urls: print("No URLs found to download") return @@ -784,21 +805,22 @@ def main(): user = getpass.getuser() xdg_runtime_dir = f"/tmp/runtime-{user}" os.environ["XDG_RUNTIME_DIR"] = xdg_runtime_dir - + downloader = PDFDownloader(args.output_dir, args.processes) - + elif args.format == 'html': if requests is None: - print("❌ HTML format requires 'requests' package. Install with: pip install requests") + print( + "❌ HTML format requires 'requests' package. Install with: pip install requests") return - + downloader = HTMLDownloader( output_dir=args.output_dir, processes=args.processes, delay=args.delay, timeout=args.timeout ) - + else: print(f"❌ Unsupported format: {args.format}") return diff --git a/e2e-rag/evaluate.py b/e2e-rag/evaluate.py index 9c94ce8890..b963b98124 100644 --- a/e2e-rag/evaluate.py +++ b/e2e-rag/evaluate.py @@ -51,16 +51,18 @@ def load_results(path: Path): # Load pandas DataFrame checkpoint with open(path, 'rb') as f: df = pickle.load(f) - + # Convert DataFrame to dict: query -> llm_answer # Only include successfully completed queries successful = df[df['success'] == True] - return {row['query']: row['llm_answer'] for _, row in successful.iterrows()} + return {row['query']: row['llm_answer'] + for _, row in successful.iterrows()} else: # Legacy JSON format data = json.loads(path.read_text(encoding="utf-8")) results = data.get("results", []) - return {entry.get("prompt"): entry.get("llm_answer", "") for entry in results if entry.get("prompt")} + return {entry.get("prompt"): entry.get("llm_answer", "") + for entry in results if entry.get("prompt")} def _parse_score_value(value) -> int: @@ -122,7 +124,8 @@ def _extract_json_dict(content: str) -> Optional[dict]: return None -def call_judge(session: requests.Session, service_url: str, model: str, question: str, gold: str, pred: str): +def call_judge(session: requests.Session, service_url: str, + model: str, question: str, gold: str, pred: str): prompt = ( "You judge whether the model answer correctly answers the question based on semantic equivalence to the gold answer.\n\n" "GRADING RULES:\n" @@ -168,12 +171,17 @@ def call_judge(session: requests.Session, service_url: str, model: str, question "X-Title": "RAG-QnA Evaluation" } - response = session.post(service_url, json=payload, headers=headers, timeout=120) + response = session.post( + service_url, + json=payload, + headers=headers, + timeout=120) response.raise_for_status() data = response.json() # Defensive: handle missing or malformed 'choices' in response choices = data.get("choices") - if not choices or not isinstance(choices, list) or not choices[0] or "message" not in choices[0] or "content" not in choices[0]["message"]: + if not choices or not isinstance( + choices, list) or not choices[0] or "message" not in choices[0] or "content" not in choices[0]["message"]: print("[ERROR] Judge response missing 'choices' or 'content':", data) # Return score 0, explanation with raw response, and raw data return 0, f"Malformed judge response: {data}", str(data) @@ -207,11 +215,13 @@ def call_judge(session: requests.Session, service_url: str, model: str, question def _judge_row(idx, prompt, gold, pred, service_url, model): """Call judge for a single row, returning (idx, prompt, gold, pred, score, explanation, raw).""" session = requests.Session() - score, explanation, raw = call_judge(session, service_url, model, prompt, gold, pred) + score, explanation, raw = call_judge( + session, service_url, model, prompt, gold, pred) return idx, prompt, gold, pred, score, explanation, raw -def evaluate(results_path: Path, dataset_path: Path, service_url: str, model: str, batch_size: int = 16): +def evaluate(results_path: Path, dataset_path: Path, + service_url: str, model: str, batch_size: int = 16): # Check for OpenRouter API key if using OpenRouter if "openrouter.ai" in service_url and not OPENROUTER_API_KEY: print("ERROR: OPENROUTER_API_KEY environment variable not set") @@ -227,13 +237,15 @@ def evaluate(results_path: Path, dataset_path: Path, service_url: str, model: st print(f"CHECKPOINT STATISTICS") print("=" * 80) print(f"Total queries in checkpoint: {len(checkpoint_df)}") - print(f"Successful queries: {(checkpoint_df['success'] == True).sum()}") + print( + f"Successful queries: {(checkpoint_df['success'] == True).sum()}") print(f"Failed queries: {(checkpoint_df['success'] == False).sum()}") if 'num_docs' in checkpoint_df.columns: total_docs = checkpoint_df['num_docs'].sum() total_missing = checkpoint_df['num_missing_docs'].sum() print(f"Total documents referenced: {total_docs}") - print(f"Missing documents: {total_missing} ({100*total_missing/total_docs:.2f}%)") + print( + f"Missing documents: {total_missing} ({100*total_missing/total_docs:.2f}%)") print("=" * 80) print() @@ -256,7 +268,8 @@ def evaluate(results_path: Path, dataset_path: Path, service_url: str, model: st total = len(items) unknown = sum(1 for _, _, _, pred in items if pred.lower() == "unknown") - # Submit all judge calls in parallel (batch_size workers), print as they complete + # Submit all judge calls in parallel (batch_size workers), print as they + # complete score_sum = 0 with ThreadPoolExecutor(max_workers=batch_size) as executor: futures = { @@ -284,15 +297,38 @@ def evaluate(results_path: Path, dataset_path: Path, service_url: str, model: st def parse_args(): - parser = argparse.ArgumentParser(description="Evaluate single-shot results using an LLM judge.") - parser.add_argument("results", type=Path, help="Path to results (result_single_shot.json or oracle_checkpoint.pkl)") - parser.add_argument("--dataset", type=Path, default=Path("data/frames_dataset.tsv"), help="Evaluation dataset TSV") - parser.add_argument("--judge-url", default=DEFAULT_JUDGE_URL, help="Judge service endpoint") - parser.add_argument("--judge-model", default=DEFAULT_JUDGE_MODEL, help="Judge model identifier") - parser.add_argument("--batch-size", type=int, default=16, help="Number of concurrent judge requests (default: 16)") + parser = argparse.ArgumentParser( + description="Evaluate single-shot results using an LLM judge.") + parser.add_argument( + "results", + type=Path, + help="Path to results (result_single_shot.json or oracle_checkpoint.pkl)") + parser.add_argument( + "--dataset", + type=Path, + default=Path("data/frames_dataset.tsv"), + help="Evaluation dataset TSV") + parser.add_argument( + "--judge-url", + default=DEFAULT_JUDGE_URL, + help="Judge service endpoint") + parser.add_argument( + "--judge-model", + default=DEFAULT_JUDGE_MODEL, + help="Judge model identifier") + parser.add_argument( + "--batch-size", + type=int, + default=16, + help="Number of concurrent judge requests (default: 16)") return parser.parse_args() if __name__ == "__main__": args = parse_args() - evaluate(args.results, args.dataset, args.judge_url, args.judge_model, args.batch_size) + evaluate( + args.results, + args.dataset, + args.judge_url, + args.judge_model, + args.batch_size) diff --git a/e2e-rag/evaluation.py b/e2e-rag/evaluation.py index a6c59a889a..8ed81824fc 100644 --- a/e2e-rag/evaluation.py +++ b/e2e-rag/evaluation.py @@ -32,82 +32,85 @@ from utils import filter_dataset_by_difficulty -def calculate_retrieval_metrics(expected_urls: List[str], retrieved_urls: List[str], k_values: List[int] = [1, 3, 5, 10]) -> Dict[str, float]: +def calculate_retrieval_metrics(expected_urls: List[str], retrieved_urls: List[str], k_values: List[int] = [ + 1, 3, 5, 10]) -> Dict[str, float]: """ Calculate comprehensive retrieval metrics. - + Args: expected_urls: List of expected/ground truth URLs retrieved_urls: List of retrieved URLs in ranking order k_values: List of k values for Precision@k, Recall@k, F1@k - + Returns: Dictionary containing all calculated metrics """ expected_set = set(url for url in expected_urls if url and url.strip()) - + # Handle edge cases if not expected_set: return {f'precision@{k}': 1.0 if len(retrieved_urls) == 0 else 0.0 for k in k_values} | \ {f'recall@{k}': 1.0 for k in k_values} | \ {f'f1@{k}': 1.0 if len(retrieved_urls) == 0 else 0.0 for k in k_values} | \ {'average_precision': 1.0 if len(retrieved_urls) == 0 else 0.0} - + metrics = {} - - # Calculate metrics for different k values, including @N (actual retrieved count) + + # Calculate metrics for different k values, including @N (actual retrieved + # count) num_retrieved = len(retrieved_urls) num_expected = len(expected_set) k_values_with_n = k_values + [num_retrieved] # Add N to k_values - + for k in k_values_with_n: # Determine the label (use 'N' for the actual retrieved count) k_label = 'N' if k == num_retrieved else str(k) - + # Get top k documents top_k = retrieved_urls[:k] top_k_set = set(top_k) relevant_retrieved = len(expected_set.intersection(top_k_set)) - + # Precision@k: fraction of retrieved documents that are relevant precision_k = relevant_retrieved / k if k > 0 else 0.0 metrics[f'precision@{k_label}'] = precision_k - + # Recall@k: fraction of relevant documents that are retrieved recall_k = relevant_retrieved / num_expected if num_expected > 0 else 0.0 metrics[f'recall@{k_label}'] = recall_k - + # F1@k: harmonic mean of precision and recall if precision_k + recall_k > 0: f1_k = 2 * (precision_k * recall_k) / (precision_k + recall_k) else: f1_k = 0.0 metrics[f'f1@{k_label}'] = f1_k - + # Mean Average Precision (MAP) - considers ranking order ap_sum = 0.0 relevant_found = 0 - + for i, url in enumerate(retrieved_urls): if url in expected_set: relevant_found += 1 precision_at_i = relevant_found / (i + 1) ap_sum += precision_at_i - - average_precision = ap_sum / len(expected_set) if len(expected_set) > 0 else 0.0 + + average_precision = ap_sum / \ + len(expected_set) if len(expected_set) > 0 else 0.0 metrics['average_precision'] = average_precision - + return metrics -def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], - top_k_retriever: int = 50, top_k_reranking: int = 10, - verbose: bool = True, no_rerank: bool = False, - retrieval_strategy: str = "fixed_k", print_results: bool = False, - return_results: bool = False, **strategy_params) -> Union[Dict[str, Any], Tuple[Dict[str, Any], List[Any]]]: +def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], + top_k_retriever: int = 50, top_k_reranking: int = 10, + verbose: bool = True, no_rerank: bool = False, + retrieval_strategy: str = "fixed_k", print_results: bool = False, + return_results: bool = False, **strategy_params) -> Union[Dict[str, Any], Tuple[Dict[str, Any], List[Any]]]: """ Evaluate a single retrieval query and return comprehensive retrieval metrics. - + Args: rag_db: RAG database instance query: Query string @@ -118,12 +121,12 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], no_rerank: Skip reranking step for fair comparison between retrieval methods retrieval_strategy: Strategy for retrieval ("fixed_k", "top_p", "relative") **strategy_params: Parameters for adaptive retrieval strategies - + Returns: Dictionary containing all metrics. When return_results=True, returns a tuple of (metrics_dict, retrieved_results). """ import time - + # Step 1: Time the initial retrieval retrieval_start = time.perf_counter() if retrieval_strategy == "fixed_k": @@ -131,23 +134,25 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], else: from retrieve.filter import filter max_results = strategy_params.pop("max_results", 20) - results = filter(rag_db, query, method=retrieval_strategy, - max_results=max_results, **strategy_params) + results = filter(rag_db, query, method=retrieval_strategy, + max_results=max_results, **strategy_params) retrieval_time = time.perf_counter() - retrieval_start - - # Step 2: Apply reranking if enabled and reranker is available + + # Step 2: Apply reranking if enabled and reranker is available reranking_time = 0.0 - if not no_rerank and hasattr(rag_db, '_reranker_model') and rag_db._reranker_model is not None: + if not no_rerank and hasattr( + rag_db, '_reranker_model') and rag_db._reranker_model is not None: # Safety check: If no results retrieved, skip reranking if not results: if verbose: - print(f"Warning: No documents retrieved for query: {query[:50]}") + print( + f"Warning: No documents retrieved for query: {query[:50]}") else: reranking_start = time.perf_counter() # Extract text content for reranking (rerank expects strings) passages = [result.page_content for result in results] scored_passages = rag_db.rerank(query, passages) - + # Reconstruct document objects with reranked order # scored_passages is [(text, score), ...] ordered by score reranked_results = [] @@ -157,39 +162,45 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], if doc.page_content == text: reranked_results.append(doc) break - + # Apply top_k_reranking limit AFTER reranking # For adaptive strategies (top_p, relative, etc.), respect the number of documents # selected by the strategy, only limit for fixed_k if retrieval_strategy == "fixed_k": results = reranked_results[:top_k_reranking] else: - # For adaptive strategies, keep all documents selected by the strategy + # For adaptive strategies, keep all documents selected by the + # strategy results = reranked_results reranking_time = time.perf_counter() - reranking_start - + # Extract URLs from results in order (maintaining ranking) retrieved_urls = [] for result in results: if 'original_url' in result.metadata and result.metadata['original_url']: retrieved_urls.append(result.metadata['original_url']) - # Deduplicate URLs preserving first appearance order (for accurate MAP calculation) - deduplicated_urls = list(dict.fromkeys(retrieved_urls)) # Preserves order, removes duplicates - + # Deduplicate URLs preserving first appearance order (for accurate MAP + # calculation) + # Preserves order, removes duplicates + deduplicated_urls = list(dict.fromkeys(retrieved_urls)) + # Calculate comprehensive metrics using deduplicated URLs (accurate MAP) expected_set = set(url for url in expected_urls if url and url.strip()) - metrics = calculate_retrieval_metrics(list(expected_set), deduplicated_urls) - + metrics = calculate_retrieval_metrics( + list(expected_set), deduplicated_urls) + # Track both passages and unique documents num_passages = len(results) num_unique_docs = len(deduplicated_urls) - + if verbose: print(f"Query: {query:50}") matches = len(expected_set.intersection(set(deduplicated_urls))) - print(f"Expected ({len(expected_set)}): {sorted(list(expected_set)[:3])}{'...' if len(expected_set) > 3 else ''}") - print(f"Retrieved ({num_passages} passages, {num_unique_docs} unique docs): {deduplicated_urls[:3]}{'...' if num_unique_docs > 3 else ''}") + print( + f"Expected ({len(expected_set)}): {sorted(list(expected_set)[:3])}{'...' if len(expected_set) > 3 else ''}") + print( + f"Retrieved ({num_passages} passages, {num_unique_docs} unique docs): {deduplicated_urls[:3]}{'...' if num_unique_docs > 3 else ''}") print(f"Matches: {matches}") metric_categories = [ @@ -208,11 +219,12 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], print(f"MAP: {metrics['average_precision']:.3f}") print("-" * 80) - + # Print detailed results for single query mode if print_results: - print(f"\n{retrieval_strategy.upper()} lookup took time. {len(results)} results found:") - + print( + f"\n{retrieval_strategy.upper()} lookup took time. {len(results)} results found:") + # Display which PDFs the passages are from for i, result in enumerate(results, 1): print(f"{i}. {result.metadata}") @@ -232,22 +244,23 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], print("No reranker used (--no-rerank specified)") else: print("No reranker available - showing retrieval results only") - + # Calculate retrieval performance metrics total_time = retrieval_time + reranking_time docs_per_second = len(results) / total_time if total_time > 0 else 0 - + # Add retrieval performance to metrics retrieval_metrics = { 'retrieval_time': retrieval_time, - 'reranking_time': reranking_time, + 'reranking_time': reranking_time, 'total_retrieval_time': total_time, 'retrieved_passages_count': len(results), 'retrieved_docs_count': num_unique_docs, 'docs_per_second': docs_per_second } - - # Print retrieval performance if in benchmark mode and single query mode (not evaluation) + + # Print retrieval performance if in benchmark mode and single query mode + # (not evaluation) if hasattr(rag_db, '_benchmark') and rag_db._benchmark and print_results: print(f"\n🔍 RETRIEVAL PERFORMANCE METRICS") print("=" * 50) @@ -260,7 +273,7 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], print(f"🚀 Retrieval speed: {docs_per_second:.1f} docs/sec") print(f"💾 Time per query: {total_time:.4f}s") print() - + # Return metrics dict with retrieval performance merged_metrics = {**metrics, **retrieval_metrics} if return_results: @@ -268,21 +281,22 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], return merged_metrics -def run_evaluation(rag_db, dataset_path: str, - top_k_retriever: int = 50, top_k_reranking: int = 10, - max_queries: Optional[int] = None, no_rerank: bool = False, - retrieval_strategy: str = "fixed_k", detailed_analysis: bool = False, - difficulty: int = 0, collect_results: bool = False, - result_handler: Optional[Callable[[str, List[Any], Dict[str, Any]], Optional[Any]]] = None, - **strategy_params) -> Union[Dict[str, float], Tuple[Dict[str, float], List[Dict[str, Any]]]]: +def run_evaluation(rag_db, dataset_path: str, + top_k_retriever: int = 50, top_k_reranking: int = 10, + max_queries: Optional[int] = None, no_rerank: bool = False, + retrieval_strategy: str = "fixed_k", detailed_analysis: bool = False, + difficulty: int = 0, collect_results: bool = False, + result_handler: Optional[Callable[[ + str, List[Any], Dict[str, Any]], Optional[Any]]] = None, + **strategy_params) -> Union[Dict[str, float], Tuple[Dict[str, float], List[Dict[str, Any]]]]: """ Run comprehensive evaluation on a dataset with detailed metrics reporting. - + Args: rag_db: RAG database instance dataset_path: Path to the dataset TSV file top_k_retriever: Number of documents to retrieve initially - top_k_reranking: Number of documents after reranking + top_k_reranking: Number of documents after reranking max_queries: Maximum number of queries to evaluate (None = all) no_rerank: Skip reranking step for fair comparison between retrieval methods retrieval_strategy: Strategy for retrieval ("fixed_k", "top_p", "relative") @@ -291,15 +305,15 @@ def run_evaluation(rag_db, dataset_path: str, collect_results: If True, also collect retrieval outputs for each query result_handler: Optional callback invoked per query with (prompt, retrieved_docs, metrics) **strategy_params: Parameters for adaptive retrieval strategies - + Returns: Dictionary of averaged metrics across all queries. When collect_results=True, returns a tuple of (metrics_dict, collected_results). """ df = pd.read_csv(dataset_path, sep='\t') - + # Filter by difficulty if specified df = filter_dataset_by_difficulty(df, difficulty) - + # Limit number of queries if specified if isinstance(max_queries, int) and max_queries > 0: df = df.head(max_queries) @@ -307,7 +321,7 @@ def run_evaluation(rag_db, dataset_path: str, max_queries = len(df) print(f"\nRunning evaluation on {max_queries} queries from dataset") - + # Aggregate metrics collection total_metrics = {} all_query_metrics = [] # Store individual query metrics for detailed analysis @@ -317,19 +331,19 @@ def run_evaluation(rag_db, dataset_path: str, docs_per_sec_list = [] collected_queries = [] if collect_results else None valid_queries = 0 - + for idx, row in df.iterrows(): # Extract expected Wikipedia links expected_urls = [] for col in df.columns: if col.startswith('wikipedia_link_') and pd.notna(row[col]): expected_urls.append(row[col].strip()) - + if expected_urls: # Get comprehensive metrics for this query need_results = collect_results or (result_handler is not None) metrics_output = evaluate_retrieval_query( - rag_db, row['Prompt'], expected_urls, + rag_db, row['Prompt'], expected_urls, top_k_retriever, top_k_reranking, verbose=True, no_rerank=no_rerank, retrieval_strategy=retrieval_strategy, return_results=need_results, **strategy_params @@ -346,7 +360,8 @@ def run_evaluation(rag_db, dataset_path: str, for doc in retrieved_docs: url = None if hasattr(doc, 'metadata'): - url = doc.metadata.get('original_url') or doc.metadata.get('source') + url = doc.metadata.get( + 'original_url') or doc.metadata.get('source') content = doc.page_content elif isinstance(doc, dict): url = doc.get('url') @@ -369,59 +384,73 @@ def run_evaluation(rag_db, dataset_path: str, if result_handler: result_handler(row['Prompt'], retrieved_docs, metrics) - + # Store metrics for detailed analysis if requested if detailed_analysis: all_query_metrics.append(metrics) - + # Collect retrieval performance metrics for statistics if 'retrieval_time' in metrics: retrieval_times.append(metrics['retrieval_time']) reranking_times.append(metrics['reranking_time']) total_times.append(metrics['total_retrieval_time']) docs_per_sec_list.append(metrics['docs_per_second']) - + # Accumulate metrics for metric_name, value in metrics.items(): if metric_name not in total_metrics: total_metrics[metric_name] = 0.0 total_metrics[metric_name] += value - + valid_queries += 1 - + if valid_queries > 0: # Calculate average metrics - avg_metrics = {name: total / valid_queries for name, total in total_metrics.items()} - + avg_metrics = { + name: total / + valid_queries for name, + total in total_metrics.items()} + # Display results results_title = "OVERALL EVALUATION RESULTS" if detailed_analysis else "EVALUATION RESULTS" - print(f"\n" + "="*60) + print(f"\n" + "=" * 60) print(f"{results_title} ({valid_queries} queries)") - print(f"="*60) + print(f"=" * 60) print(f"PRECISION METRICS:") - print(f" Precision@N: {avg_metrics.get('precision@N', 0.0):.3f}") + print( + f" Precision@N: {avg_metrics.get('precision@N', 0.0):.3f}") if 'precision@1' in avg_metrics: - print(f" Precision@1: {avg_metrics['precision@1']:.3f}") + print( + f" Precision@1: {avg_metrics['precision@1']:.3f}") if 'precision@3' in avg_metrics: - print(f" Precision@3: {avg_metrics['precision@3']:.3f}") + print( + f" Precision@3: {avg_metrics['precision@3']:.3f}") if 'precision@5' in avg_metrics: - print(f" Precision@5: {avg_metrics['precision@5']:.3f}") + print( + f" Precision@5: {avg_metrics['precision@5']:.3f}") if 'precision@10' in avg_metrics: - print(f" Precision@10: {avg_metrics['precision@10']:.3f}") + print( + f" Precision@10: {avg_metrics['precision@10']:.3f}") print(f"") print(f"RECALL METRICS:") - print(f" Recall@N: {avg_metrics.get('recall@N', 0.0):.3f}") + print( + f" Recall@N: {avg_metrics.get('recall@N', 0.0):.3f}") if 'recall@1' in avg_metrics: - print(f" Recall@1: {avg_metrics['recall@1']:.3f}") + print( + f" Recall@1: {avg_metrics['recall@1']:.3f}") if 'recall@3' in avg_metrics: - print(f" Recall@3: {avg_metrics['recall@3']:.3f}") + print( + f" Recall@3: {avg_metrics['recall@3']:.3f}") if 'recall@5' in avg_metrics: - print(f" Recall@5: {avg_metrics['recall@5']:.3f}") + print( + f" Recall@5: {avg_metrics['recall@5']:.3f}") if 'recall@10' in avg_metrics: - print(f" Recall@10: {avg_metrics['recall@10']:.3f}") + print( + f" Recall@10: {avg_metrics['recall@10']:.3f}") print(f"") print(f"F1 METRICS:") - print(f" F1@N: {avg_metrics.get('f1@N', 0.0):.3f}") + print( + f" F1@N: {avg_metrics.get('f1@N', 0.0):.3f}") if 'f1@1' in avg_metrics: print(f" F1@1: {avg_metrics['f1@1']:.3f}") if 'f1@3' in avg_metrics: @@ -432,45 +461,61 @@ def run_evaluation(rag_db, dataset_path: str, print(f" F1@10: {avg_metrics['f1@10']:.3f}") print(f"") print(f"RANKING METRICS:") - print(f" Mean Average Precision: {avg_metrics['average_precision']:.3f}") + print( + f" Mean Average Precision: {avg_metrics['average_precision']:.3f}") print(f"") print(f"RETRIEVAL STATISTICS:") - print(f" Avg Passages Retrieved: {avg_metrics.get('retrieved_passages_count', 0.0):.1f}") - print(f" Avg Unique Docs (N): {avg_metrics.get('retrieved_docs_count', 0.0):.1f}") - + print( + f" Avg Passages Retrieved: {avg_metrics.get('retrieved_passages_count', 0.0):.1f}") + print( + f" Avg Unique Docs (N): {avg_metrics.get('retrieved_docs_count', 0.0):.1f}") + # Add retrieval performance statistics if we have retrieval data - if retrieval_times and hasattr(rag_db, '_benchmark') and rag_db._benchmark: + if retrieval_times and hasattr( + rag_db, '_benchmark') and rag_db._benchmark: import numpy as np - + print(f"") print(f"🔍 RETRIEVAL PERFORMANCE STATISTICS:") print(f" Retrieval Time (ms):") - print(f" Average: {np.mean(retrieval_times)*1000:.2f}ms") - print(f" P50 (Median): {np.percentile(retrieval_times, 50)*1000:.2f}ms") - print(f" P99: {np.percentile(retrieval_times, 99)*1000:.2f}ms") - + print( + f" Average: {np.mean(retrieval_times)*1000:.2f}ms") + print( + f" P50 (Median): {np.percentile(retrieval_times, 50)*1000:.2f}ms") + print( + f" P99: {np.percentile(retrieval_times, 99)*1000:.2f}ms") + if any(t > 0 for t in reranking_times): print(f" Reranking Time (ms):") - print(f" Average: {np.mean(reranking_times)*1000:.2f}ms") - print(f" P50 (Median): {np.percentile(reranking_times, 50)*1000:.2f}ms") - print(f" P99: {np.percentile(reranking_times, 99)*1000:.2f}ms") - + print( + f" Average: {np.mean(reranking_times)*1000:.2f}ms") + print( + f" P50 (Median): {np.percentile(reranking_times, 50)*1000:.2f}ms") + print( + f" P99: {np.percentile(reranking_times, 99)*1000:.2f}ms") + print(f" Total Query Time (ms):") - print(f" Average: {np.mean(total_times)*1000:.2f}ms") - print(f" P50 (Median): {np.percentile(total_times, 50)*1000:.2f}ms") - print(f" P99: {np.percentile(total_times, 99)*1000:.2f}ms") - + print( + f" Average: {np.mean(total_times)*1000:.2f}ms") + print( + f" P50 (Median): {np.percentile(total_times, 50)*1000:.2f}ms") + print( + f" P99: {np.percentile(total_times, 99)*1000:.2f}ms") + print(f" Retrieval Throughput (docs/sec):") - print(f" Average: {np.mean(docs_per_sec_list):.1f} docs/sec") - print(f" P50 (Median): {np.percentile(docs_per_sec_list, 50):.1f} docs/sec") - print(f" P99: {np.percentile(docs_per_sec_list, 99):.1f} docs/sec") - - print(f"="*60) - + print( + f" Average: {np.mean(docs_per_sec_list):.1f} docs/sec") + print( + f" P50 (Median): {np.percentile(docs_per_sec_list, 50):.1f} docs/sec") + print( + f" P99: {np.percentile(docs_per_sec_list, 99):.1f} docs/sec") + + print(f"=" * 60) + # Print detailed analysis if requested if detailed_analysis: _print_detailed_analysis(df, all_query_metrics, valid_queries) - + if collect_results: return avg_metrics, collected_queries or [] return avg_metrics @@ -481,12 +526,12 @@ def run_evaluation(rag_db, dataset_path: str, return {} -def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, Any]], - valid_queries: int) -> None: +def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, Any]], + valid_queries: int) -> None: """ Print detailed dataset analysis broken down by reasoning types and answer link counts. (Internal helper function for run_evaluation) - + Args: df: DataFrame with dataset (must have 'reasoning_types' column) all_query_metrics: List of metrics dictionaries for each query @@ -494,131 +539,132 @@ def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, """ if valid_queries == 0: return - - print("\n" + "="*80) + + print("\n" + "=" * 80) print("DETAILED DATASET ANALYSIS") - print("="*80) - + print("=" * 80) + # Prepare data - match metrics with reasoning types and link counts analysis_data = [] for idx, metrics in enumerate(all_query_metrics): if idx < len(df): row = df.iloc[idx] reasoning_types = row.get('reasoning_types', 'Unknown') - + # Count Wikipedia links - num_links = sum(1 for col in df.columns - if col.startswith('wikipedia_link_') and pd.notna(row[col])) - + num_links = sum(1 for col in df.columns + if col.startswith('wikipedia_link_') and pd.notna(row[col])) + analysis_data.append({ 'reasoning_types': reasoning_types, 'num_links': num_links, 'metrics': metrics }) - + # === ANALYSIS 1: By Reasoning Classification === - print("\n" + "-"*80) + print("\n" + "-" * 80) print("ANALYSIS BY REASONING CLASSIFICATION") - print("-"*80) - + print("-" * 80) + # Group by reasoning types reasoning_groups = defaultdict(list) for data in analysis_data: reasoning_groups[data['reasoning_types']].append(data['metrics']) - + # Calculate averages for each reasoning type reasoning_results = [] for reasoning_type, metrics_list in reasoning_groups.items(): if not metrics_list: continue - + avg_metrics = {} for key in ['precision@N', 'recall@N', 'f1@N', 'average_precision']: values = [m.get(key, 0.0) for m in metrics_list] avg_metrics[key] = sum(values) / len(values) - + reasoning_results.append({ 'type': reasoning_type, 'count': len(metrics_list), **avg_metrics }) - + # Sort by count (most common first) reasoning_results.sort(key=lambda x: x['count'], reverse=True) - + # Print top reasoning types print(f"\nTop reasoning type combinations:") print(f"{'Reasoning Type':<50} {'Count':>6} {'P@N':>6} {'R@N':>6} {'F1@N':>6} {'MAP':>6}") - print("-"*80) - + print("-" * 80) + for i, result in enumerate(reasoning_results): - rt = result['type'][:48] if len(result['type']) > 48 else result['type'] + rt = result['type'][:48] if len( + result['type']) > 48 else result['type'] print(f"{rt:<50} {result['count']:6d} " f"{result['precision@N']:6.3f} {result['recall@N']:6.3f} " f"{result['f1@N']:6.3f} {result['average_precision']:6.3f}") - + # === ANALYSIS 2: By Individual Reasoning Tags === - print(f"\n" + "-"*80) + print(f"\n" + "-" * 80) print("ANALYSIS BY INDIVIDUAL REASONING TAGS") - print("-"*80) - + print("-" * 80) + # Parse reasoning tags (split by |) tag_groups = defaultdict(list) for data in analysis_data: tags = [tag.strip() for tag in data['reasoning_types'].split('|')] for tag in tags: tag_groups[tag].append(data['metrics']) - + tag_results = [] for tag, metrics_list in tag_groups.items(): if not metrics_list: continue - + avg_metrics = {} for key in ['precision@N', 'recall@N', 'f1@N', 'average_precision']: values = [m.get(key, 0.0) for m in metrics_list] avg_metrics[key] = sum(values) / len(values) - + tag_results.append({ 'tag': tag, 'count': len(metrics_list), 'percentage': len(metrics_list) / valid_queries * 100, **avg_metrics }) - + # Sort by count tag_results.sort(key=lambda x: x['count'], reverse=True) - + print(f"\nPerformance by reasoning tag:") print(f"{'Tag':<30} {'Count':>6} {'%':>6} {'P@N':>6} {'R@N':>6} {'F1@N':>6} {'MAP':>6}") - print("-"*80) - + print("-" * 80) + for result in tag_results: tag = result['tag'][:28] if len(result['tag']) > 28 else result['tag'] print(f"{tag:<30} {result['count']:6d} {result['percentage']:5.1f}% " f"{result['precision@N']:6.3f} {result['recall@N']:6.3f} " f"{result['f1@N']:6.3f} {result['average_precision']:6.3f}") - + # === ANALYSIS 3: By Number of Answer Links === - print(f"\n" + "-"*80) + print(f"\n" + "-" * 80) print("ANALYSIS BY NUMBER OF ANSWER LINKS (Multi-hop Analysis)") - print("-"*80) - + print("-" * 80) + # Group by number of links link_groups = defaultdict(list) for data in analysis_data: link_groups[data['num_links']].append(data['metrics']) - + link_results = [] for num_links, metrics_list in link_groups.items(): if not metrics_list: continue - + avg_metrics = {} for key in ['precision@N', 'recall@N', 'f1@N', 'average_precision']: values = [m.get(key, 0.0) for m in metrics_list] avg_metrics[key] = sum(values) / len(values) - + # Classify complexity if num_links <= 2: complexity = "Simple" @@ -626,7 +672,7 @@ def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, complexity = "Multi-hop" else: complexity = "Complex" - + link_results.append({ 'num_links': num_links, 'complexity': complexity, @@ -634,25 +680,25 @@ def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, 'percentage': len(metrics_list) / valid_queries * 100, **avg_metrics }) - + # Sort by number of links link_results.sort(key=lambda x: x['num_links']) - + print(f"\nPerformance by number of Wikipedia links (reasoning hops):") print(f"{'Links':>5} {'Complexity':<12} {'Count':>6} {'%':>6} {'P@N':>6} {'R@N':>6} {'F1@N':>6} {'MAP':>6}") - print("-"*80) - + print("-" * 80) + for result in link_results: print(f"{result['num_links']:5d} {result['complexity']:<12} " f"{result['count']:6d} {result['percentage']:5.1f}% " f"{result['precision@N']:6.3f} {result['recall@N']:6.3f} " f"{result['f1@N']:6.3f} {result['average_precision']:6.3f}") - + # Summary by complexity category - print(f"\n" + "-"*80) + print(f"\n" + "-" * 80) print("SUMMARY BY COMPLEXITY LEVEL") - print("-"*80) - + print("-" * 80) + complexity_groups = defaultdict(list) for result in link_results: for _ in range(result['count']): @@ -663,49 +709,51 @@ def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, 'f1@N': result['f1@N'], 'average_precision': result['average_precision'] }) - + # Calculate totals complexity_summary = [] for complexity in ["Simple", "Multi-hop", "Complex"]: if complexity not in complexity_groups: continue - + metrics_list = complexity_groups[complexity] count = len(metrics_list) - + # Recalculate from link_results - matching_results = [r for r in link_results if r['complexity'] == complexity] + matching_results = [ + r for r in link_results if r['complexity'] == complexity] total_count = sum(r['count'] for r in matching_results) - + # Weighted average weighted_metrics = {} for key in ['precision@N', 'recall@N', 'f1@N', 'average_precision']: weighted_sum = sum(r[key] * r['count'] for r in matching_results) - weighted_metrics[key] = weighted_sum / total_count if total_count > 0 else 0.0 - + weighted_metrics[key] = weighted_sum / \ + total_count if total_count > 0 else 0.0 + complexity_summary.append({ 'complexity': complexity, 'count': total_count, 'percentage': total_count / valid_queries * 100, **weighted_metrics }) - + print(f"\n{'Complexity':<12} {'Count':>6} {'%':>6} {'P@N':>6} {'R@N':>6} {'F1@N':>6} {'MAP':>6}") - print("-"*80) - + print("-" * 80) + for result in complexity_summary: print(f"{result['complexity']:<12} {result['count']:6d} {result['percentage']:5.1f}% " f"{result['precision@N']:6.3f} {result['recall@N']:6.3f} " f"{result['f1@N']:6.3f} {result['average_precision']:6.3f}") - + # === ANALYSIS 4: Correlation Between Complexity and Reasoning Types === - print(f"\n" + "-"*80) + print(f"\n" + "-" * 80) print("CORRELATION: COMPLEXITY vs REASONING TYPES") - print("-"*80) - + print("-" * 80) + # Build correlation matrix: complexity level x reasoning tags complexity_reasoning_data = defaultdict(lambda: defaultdict(list)) - + for data in analysis_data: # Determine complexity num_links = data['num_links'] @@ -715,44 +763,60 @@ def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, complexity = "Multi-hop" else: complexity = "Complex" - + # Extract individual reasoning tags tags = [tag.strip() for tag in data['reasoning_types'].split('|')] for tag in tags: complexity_reasoning_data[complexity][tag].append(data['metrics']) - + # Calculate statistics for each complexity-reasoning combination print(f"\n1. REASONING TAG DISTRIBUTION BY COMPLEXITY:") print(f"{'Reasoning Tag':<30} {'Simple':>10} {'Multi-hop':>10} {'Complex':>10} {'Total':>10}") - print("-"*80) - + print("-" * 80) + # Get all unique tags all_tags = set() for complexity_data in complexity_reasoning_data.values(): all_tags.update(complexity_data.keys()) - + tag_distribution = {} for tag in sorted(all_tags): - simple_count = len(complexity_reasoning_data.get('Simple', {}).get(tag, [])) - multihop_count = len(complexity_reasoning_data.get('Multi-hop', {}).get(tag, [])) - complex_count = len(complexity_reasoning_data.get('Complex', {}).get(tag, [])) + simple_count = len( + complexity_reasoning_data.get( + 'Simple', + {}).get( + tag, + [])) + multihop_count = len( + complexity_reasoning_data.get( + 'Multi-hop', + {}).get( + tag, + [])) + complex_count = len( + complexity_reasoning_data.get( + 'Complex', + {}).get( + tag, + [])) total = simple_count + multihop_count + complex_count - + tag_distribution[tag] = { 'simple': simple_count, 'multihop': multihop_count, 'complex': complex_count, 'total': total } - + tag_display = tag[:28] if len(tag) > 28 else tag - print(f"{tag_display:<30} {simple_count:10d} {multihop_count:10d} {complex_count:10d} {total:10d}") - + print( + f"{tag_display:<30} {simple_count:10d} {multihop_count:10d} {complex_count:10d} {total:10d}") + # Calculate percentage distribution print(f"\n2. REASONING TAG PERCENTAGE BY COMPLEXITY:") print(f"{'Reasoning Tag':<30} {'Simple %':>10} {'Multi %':>10} {'Complex %':>10}") - print("-"*80) - + print("-" * 80) + for tag in sorted(all_tags): dist = tag_distribution[tag] total = dist['total'] @@ -760,56 +824,67 @@ def _print_detailed_analysis(df: pd.DataFrame, all_query_metrics: List[Dict[str, simple_pct = (dist['simple'] / total) * 100 multihop_pct = (dist['multihop'] / total) * 100 complex_pct = (dist['complex'] / total) * 100 - + tag_display = tag[:28] if len(tag) > 28 else tag - print(f"{tag_display:<30} {simple_pct:9.1f}% {multihop_pct:9.1f}% {complex_pct:9.1f}%") - + print( + f"{tag_display:<30} {simple_pct:9.1f}% {multihop_pct:9.1f}% {complex_pct:9.1f}%") + # Calculate average number of links per reasoning tag print(f"\n3. AVERAGE COMPLEXITY (# LINKS) BY REASONING TAG:") print(f"{'Reasoning Tag':<30} {'Avg Links':>10} {'Count':>10}") - print("-"*80) - + print("-" * 80) + tag_link_stats = defaultdict(list) for data in analysis_data: tags = [tag.strip() for tag in data['reasoning_types'].split('|')] for tag in tags: tag_link_stats[tag].append(data['num_links']) - + tag_avg_links = [] for tag in sorted(all_tags): links = tag_link_stats[tag] if links: avg_links = sum(links) / len(links) tag_avg_links.append((tag, avg_links, len(links))) - + # Sort by average links (descending) tag_avg_links.sort(key=lambda x: x[1], reverse=True) - + for tag, avg_links, count in tag_avg_links: tag_display = tag[:28] if len(tag) > 28 else tag print(f"{tag_display:<30} {avg_links:10.2f} {count:10d}") - + # Performance by complexity x reasoning tag (for top tags only) print(f"\n4. PERFORMANCE BY COMPLEXITY x TOP REASONING TAGS:") - print("-"*80) - + print("-" * 80) + # Get top 5 most common tags - top_tags = sorted(tag_distribution.items(), key=lambda x: x[1]['total'], reverse=True)[:5] - + top_tags = sorted( + tag_distribution.items(), + key=lambda x: x[1]['total'], + reverse=True)[ + :5] + for tag, _ in top_tags: print(f"\n{tag}:") - print(f"{'Complexity':<12} {'Count':>6} {'P@N':>6} {'R@N':>6} {'F1@N':>6} {'MAP':>6}") - print("-"*70) - + print( + f"{'Complexity':<12} {'Count':>6} {'P@N':>6} {'R@N':>6} {'F1@N':>6} {'MAP':>6}") + print("-" * 70) + for complexity in ["Simple", "Multi-hop", "Complex"]: - metrics_list = complexity_reasoning_data.get(complexity, {}).get(tag, []) + metrics_list = complexity_reasoning_data.get( + complexity, {}).get(tag, []) if metrics_list: count = len(metrics_list) - avg_p = sum(m.get('precision@N', 0.0) for m in metrics_list) / count - avg_r = sum(m.get('recall@N', 0.0) for m in metrics_list) / count + avg_p = sum(m.get('precision@N', 0.0) + for m in metrics_list) / count + avg_r = sum(m.get('recall@N', 0.0) + for m in metrics_list) / count avg_f1 = sum(m.get('f1@N', 0.0) for m in metrics_list) / count - avg_map = sum(m.get('average_precision', 0.0) for m in metrics_list) / count - - print(f"{complexity:<12} {count:6d} {avg_p:6.3f} {avg_r:6.3f} {avg_f1:6.3f} {avg_map:6.3f}") - - print("="*80) \ No newline at end of file + avg_map = sum(m.get('average_precision', 0.0) + for m in metrics_list) / count + + print( + f"{complexity:<12} {count:6d} {avg_p:6.3f} {avg_r:6.3f} {avg_f1:6.3f} {avg_map:6.3f}") + + print("=" * 80) diff --git a/e2e-rag/ingestion_monitor.py b/e2e-rag/ingestion_monitor.py index b3f1e69179..17aa004322 100644 --- a/e2e-rag/ingestion_monitor.py +++ b/e2e-rag/ingestion_monitor.py @@ -20,17 +20,17 @@ Usage: from ingestion_monitor import IngestionMonitor - + monitor = IngestionMonitor() - + # Track document processing with monitor.track_component("html_parsing"): process_html_files(files) - - # Track embedding generation + + # Track embedding generation with monitor.track_component("embedding_generation"): embeddings = generate_embeddings(texts) - + # Get performance report report = monitor.get_performance_report() """ @@ -43,6 +43,7 @@ from dataclasses import dataclass, asdict from pathlib import Path + @dataclass class ComponentMetrics: """Metrics for a single pipeline component.""" @@ -56,6 +57,7 @@ class ComponentMetrics: is_pipeline_input: bool = False # Mark if this is a pipeline input component is_pipeline_output: bool = False # Mark if this is a pipeline output component + @dataclass class IndexingTrendPoint: """Single data point for indexing performance trend.""" @@ -64,7 +66,8 @@ class IndexingTrendPoint: indexing_time: float # Time to add this batch (seconds) throughput_items_per_sec: float cumulative_time: float # Total time so far - + + @dataclass class IngestionReport: """Complete ingestion performance report.""" @@ -75,12 +78,14 @@ class IngestionReport: overall_throughput_mb_per_sec: float components: List[ComponentMetrics] bottleneck_component: str - indexing_trend: List[IndexingTrendPoint] = None # For scaling analysis = "none" + # For scaling analysis = "none" + indexing_trend: List[IndexingTrendPoint] = None bottleneck_component: str + class IngestionMonitor: """Real-time ingestion performance monitoring.""" - + def __init__(self): self.components: Dict[str, ComponentMetrics] = {} self.start_time = None # Will be set when ingestion starts @@ -88,17 +93,17 @@ def __init__(self): self.component_start_time = None self.indexing_trend: List[IndexingTrendPoint] = [] self.cumulative_indexing_time = 0.0 - + def start_ingestion(self): """Mark the start of ingestion. Should be called at the beginning of ingest().""" self.start_time = time.time() - + @contextmanager - def track_component(self, component_name: str, input_size_bytes: int = 0, - items_count: int = 0, text_only: bool = False, - is_pipeline_input: bool = False, is_pipeline_output: bool = False): + def track_component(self, component_name: str, input_size_bytes: int = 0, + items_count: int = 0, text_only: bool = False, + is_pipeline_input: bool = False, is_pipeline_output: bool = False): """Context manager to track performance of a pipeline component. - + Args: component_name: Name of the component being tracked input_size_bytes: Input data size in bytes @@ -108,53 +113,54 @@ def track_component(self, component_name: str, input_size_bytes: int = 0, is_pipeline_output: If True, mark as pipeline output component for aggregation """ start_time = time.time() - + class ComponentContext: def __init__(self): self.input_size_bytes = input_size_bytes self.items_count = items_count self.text_only = text_only - + def set_input_size(self, size_bytes: int): self.input_size_bytes = size_bytes - + def set_item_count(self, count: int): self.items_count = count - + def add_text_bytes(self, text_bytes: int): """Add text-only bytes for passage tracking.""" self.input_size_bytes += text_bytes - + context = ComponentContext() - + try: self.current_component = component_name self.component_start_time = start_time yield context - + finally: end_time = time.time() duration = end_time - start_time - - # Calculate throughput + + # Calculate throughput total_input = context.input_size_bytes - total_output = 0 # Output will be set separately + total_output = 0 # Output will be set separately total_items = context.items_count total_duration = duration - + # Check if component already exists (accumulate metrics) if component_name in self.components: existing = self.components[component_name] # Accumulate metrics total_duration = existing.duration + duration total_input = existing.input_size_bytes + context.input_size_bytes - total_output = existing.output_size_bytes + 0 + total_output = existing.output_size_bytes + 0 total_items = existing.items_processed + context.items_count - + is_pipeline_input = is_pipeline_input or existing.is_pipeline_input is_pipeline_output = is_pipeline_output or existing.is_pipeline_output - throughput_mb = (total_input / (1024 * 1024)) / total_duration if total_duration > 0 else 0 + throughput_mb = (total_input / (1024 * 1024)) / \ + total_duration if total_duration > 0 else 0 throughput_items = total_items / total_duration if total_duration > 0 else 0 self.components[component_name] = ComponentMetrics( @@ -168,18 +174,18 @@ def add_text_bytes(self, text_bytes: int): is_pipeline_input=is_pipeline_input, is_pipeline_output=is_pipeline_output ) - + def set_output_size(self, component_name: str, output_size_bytes: int): """Set the output size for a component after processing.""" if component_name in self.components: self.components[component_name].output_size_bytes = output_size_bytes - + def set_output_size_callback(self, component_name: str, callback_fn): """Set the output size for a component using a callback function. - + This is useful when the output size calculation is complex or requires accessing class-specific data (e.g., BM25 index files). - + Args: component_name: Name of the component callback_fn: Function that returns the output size in bytes @@ -189,40 +195,41 @@ def set_output_size_callback(self, component_name: str, callback_fn): output_size = callback_fn() self.components[component_name].output_size_bytes = output_size except Exception as e: - print(f"Warning: Failed to calculate output size for {component_name}: {e}") - + print( + f"Warning: Failed to calculate output size for {component_name}: {e}") + @contextmanager def track_ingestion(self): """Track overall ingestion performance.""" self.start_time = time.time() # Set start_time for get_performance_report() - + class IngestionContext: def __init__(self): self.item_count = 0 - + def set_item_count(self, count: int): self.item_count = count - + context = IngestionContext() - + try: yield context finally: pass # start_time is checked by get_performance_report() - - def track_incremental_indexing(self, db_size_before: int, batch_size: int, - indexing_time: float): + + def track_incremental_indexing(self, db_size_before: int, batch_size: int, + indexing_time: float): """Track indexing performance for incremental batches to analyze scaling trends. - + Args: db_size_before: Number of items in DB before adding this batch - batch_size: Number of items added in this batch + batch_size: Number of items added in this batch indexing_time: Time taken to index this batch (seconds) """ db_size_after = db_size_before + batch_size throughput = batch_size / indexing_time if indexing_time > 0 else 0 self.cumulative_indexing_time += indexing_time - + trend_point = IndexingTrendPoint( db_size=db_size_after, batch_size=batch_size, @@ -230,36 +237,44 @@ def track_incremental_indexing(self, db_size_before: int, batch_size: int, throughput_items_per_sec=throughput, cumulative_time=self.cumulative_indexing_time ) - + self.indexing_trend.append(trend_point) - + def get_performance_report(self) -> IngestionReport: """Generate comprehensive performance report.""" # Calculate duration from when start_ingestion() was called if self.start_time is None: - raise ValueError("start_ingestion() must be called before getting performance report") - + raise ValueError( + "start_ingestion() must be called before getting performance report") + total_duration = time.time() - self.start_time - + # Aggregate metrics based on pipeline input/output flags # If no flags set, fall back to first component for input - input_components = [c for c in self.components.values() if c.is_pipeline_input] - output_components = [c for c in self.components.values() if c.is_pipeline_output] - + input_components = [ + c for c in self.components.values() if c.is_pipeline_input] + output_components = [ + c for c in self.components.values() if c.is_pipeline_output] + total_input = sum(c.input_size_bytes for c in input_components) total_items = sum(c.items_processed for c in input_components) total_output = sum(c.output_size_bytes for c in output_components) - - overall_throughput = (total_input / (1024 * 1024)) / total_duration if total_duration > 0 else 0 - + + overall_throughput = (total_input / (1024 * 1024)) / \ + total_duration if total_duration > 0 else 0 + # Find bottleneck and calculate efficiency ratio bottleneck_name = "none" - + if self.components: - bottleneck = min(self.components.values(), key=lambda x: x.throughput_mb_per_sec) - fastest = max(self.components.values(), key=lambda x: x.throughput_mb_per_sec) + bottleneck = min( + self.components.values(), + key=lambda x: x.throughput_mb_per_sec) + fastest = max( + self.components.values(), + key=lambda x: x.throughput_mb_per_sec) bottleneck_name = bottleneck.name - + return IngestionReport( total_duration=total_duration, total_input_bytes=total_input, @@ -270,60 +285,72 @@ def get_performance_report(self) -> IngestionReport: bottleneck_component=bottleneck_name, indexing_trend=self.indexing_trend if self.indexing_trend else None ) - + def save_report(self, filename: str = "ingestion_performance.json"): """Save performance report to JSON file.""" report = self.get_performance_report() - + # Convert to serializable format report_dict = asdict(report) - + with open(filename, 'w') as f: json.dump(report_dict, f, indent=2) - + return report_dict - + def print_summary(self): """Print detailed performance summary with individual components.""" report = self.get_performance_report() - + print("🚀 INGESTION PERFORMANCE SUMMARY") print("=" * 60) print(f"📊 Overall Metrics:") print(f" Total duration: {report.total_duration:.2f}s") - print(f" Overall throughput: {report.overall_throughput_mb_per_sec:.2f} MB/s") + print( + f" Overall throughput: {report.overall_throughput_mb_per_sec:.2f} MB/s") print(f" Items processed: {report.total_items:,}") - + # DEBUG: Show detailed breakdown of input data aggregation - input_components = [c for c in report.components if c.is_pipeline_input] + input_components = [ + c for c in report.components if c.is_pipeline_input] print(f"\n🔍 DEBUG: Input Data Breakdown (is_pipeline_input=True):") print(f" {'Component':<30} {'Input Size (MB)':<20} {'Items':<15}") print(f" {'-'*65}") total_input_debug = 0 for comp in input_components: - input_mb = comp.input_size_bytes / (1024*1024) + input_mb = comp.input_size_bytes / (1024 * 1024) total_input_debug += comp.input_size_bytes - print(f" {comp.name:<30} {input_mb:>18.2f} MB {comp.items_processed:>12,}") + print( + f" {comp.name:<30} {input_mb:>18.2f} MB {comp.items_processed:>12,}") print(f" {'-'*65}") - print(f" {'TOTAL AGGREGATED INPUT':<30} {total_input_debug/(1024*1024):>18.2f} MB") - - # Show input data size from report (aggregated from marked input components or first component) - print(f"\n Input data size (from report): {report.total_input_bytes / (1024*1024):.2f} MB") - + print( + f" {'TOTAL AGGREGATED INPUT':<30} {total_input_debug/(1024*1024):>18.2f} MB") + + # Show input data size from report (aggregated from marked input + # components or first component) + print( + f"\n Input data size (from report): {report.total_input_bytes / (1024*1024):.2f} MB") + # Show output size and expansion ratio if output data exists if report.total_output_bytes > 0: - output_size_mb = report.total_output_bytes / (1024*1024) - expansion_ratio = report.total_output_bytes / report.total_input_bytes if report.total_input_bytes > 0 else 0 + output_size_mb = report.total_output_bytes / (1024 * 1024) + expansion_ratio = report.total_output_bytes / \ + report.total_input_bytes if report.total_input_bytes > 0 else 0 print(f" Output data size: {output_size_mb:.2f} MB") print(f" Output/Input ratio: {expansion_ratio:.1f}x") - + print(f" Bottleneck component: {report.bottleneck_component}") - + print(f"\n🔧 Component Performance Details:") - for component in sorted(report.components, key=lambda x: x.duration, reverse=True): - percentage = (component.duration / report.total_duration) * 100 if report.total_duration > 0 else 0 - mb_processed = component.input_size_bytes / (1024*1024) - avg_latency_ms = (component.duration * 1000 / component.items_processed) if component.items_processed > 0 else 0 + for component in sorted( + report.components, key=lambda x: x.duration, reverse=True): + percentage = (component.duration / report.total_duration) * \ + 100 if report.total_duration > 0 else 0 + mb_processed = component.input_size_bytes / (1024 * 1024) + avg_latency_ms = ( + component.duration * + 1000 / + component.items_processed) if component.items_processed > 0 else 0 pipeline_flags = [] if component.is_pipeline_input: pipeline_flags.append("INPUT") @@ -331,33 +358,38 @@ def print_summary(self): pipeline_flags.append("OUTPUT") flag_str = f" [{', '.join(pipeline_flags)}]" if pipeline_flags else "" print(f" 📈 {component.name}{flag_str}:") - print(f" ⏱️ Duration: {component.duration:.3f}s ({percentage:.1f}% of total)") - print(f" 🚀 Throughput: {component.throughput_mb_per_sec:.2f} MB/s") + print( + f" ⏱️ Duration: {component.duration:.3f}s ({percentage:.1f}% of total)") + print( + f" 🚀 Throughput: {component.throughput_mb_per_sec:.2f} MB/s") print(f" 📦 Items: {component.items_processed:,}") print(f" 💾 Data: {mb_processed:.2f} MB") print(f" ⚡ Avg latency: {avg_latency_ms:.2f}ms per item") print() - + # Print indexing trend analysis if available if report.indexing_trend and len(report.indexing_trend) > 1: print("📈 VECTOR DB INDEXING SCALING ANALYSIS") print("=" * 60) print("DB Size → Batch Time (Throughput)") - + for i, point in enumerate(report.indexing_trend): db_size_k = point.db_size // 1000 if point.db_size >= 1000 else point.db_size size_unit = "K" if point.db_size >= 1000 else "" - - print(f" {db_size_k:>4}{size_unit} docs → {point.indexing_time:>6.3f}s ({point.throughput_items_per_sec:>6.1f} docs/sec)") - + + print( + f" {db_size_k:>4}{size_unit} docs → {point.indexing_time:>6.3f}s ({point.throughput_items_per_sec:>6.1f} docs/sec)") + # Calculate scaling trend if len(report.indexing_trend) >= 3: first_point = report.indexing_trend[0] last_point = report.indexing_trend[-1] - - size_ratio = last_point.db_size / first_point.db_size if first_point.db_size > 0 else 0 - time_ratio = last_point.indexing_time / first_point.indexing_time if first_point.indexing_time > 0 else 0 - + + size_ratio = last_point.db_size / \ + first_point.db_size if first_point.db_size > 0 else 0 + time_ratio = last_point.indexing_time / \ + first_point.indexing_time if first_point.indexing_time > 0 else 0 + if size_ratio > 1: scaling_factor = time_ratio / size_ratio if scaling_factor > 1.5: @@ -366,9 +398,10 @@ def print_summary(self): trend_desc = "📊 Linear scaling (time proportional to size)" else: trend_desc = "📉 Sub-linear scaling (indexing gets more efficient)" - + print(f"\n💡 Trend Analysis:") - print(f" Size increased {size_ratio:.1f}x, time increased {time_ratio:.1f}x") + print( + f" Size increased {size_ratio:.1f}x, time increased {time_ratio:.1f}x") print(f" {trend_desc}") print() @@ -376,13 +409,14 @@ def print_summary(self): if __name__ == "__main__": # Example usage monitor = IngestionMonitor() - + # Simulate components - with monitor.track_component("html_parsing", 1024*1024, 100): # 1MB, 100 files + with monitor.track_component("html_parsing", 1024 * 1024, 100): # 1MB, 100 files time.sleep(0.1) - - with monitor.track_component("embedding_generation", 512*1024, 500): # 512KB, 500 chunks + + # 512KB, 500 chunks + with monitor.track_component("embedding_generation", 512 * 1024, 500): time.sleep(0.5) - + monitor.print_summary() monitor.save_report("example_performance.json") diff --git a/e2e-rag/llm_logger.py b/e2e-rag/llm_logger.py index 7207edf1e8..99f1d7e0b3 100644 --- a/e2e-rag/llm_logger.py +++ b/e2e-rag/llm_logger.py @@ -31,7 +31,8 @@ class LLMLogger: """Logger for tracking all LLM calls with full input/output and metrics.""" - def __init__(self, output_file: str = None, experiment_metadata: Dict[str, Any] = None): + def __init__(self, output_file: str = None, + experiment_metadata: Dict[str, Any] = None): self.session_id = str(uuid.uuid4()) self.queries = [] self.output_file = output_file @@ -159,7 +160,8 @@ def _append_query_to_file(self, query_data: Dict): data['queries'].append(query_data) # Update experiment summary - data['experiment_summary'] = self._calculate_experiment_summary(data['queries']) + data['experiment_summary'] = self._calculate_experiment_summary( + data['queries']) # Write back with open(self.output_file, 'w', encoding='utf-8') as f: @@ -187,7 +189,8 @@ def _calculate_experiment_summary(self, queries: List[Dict]) -> Dict: } # Add retrieval/answer metrics if available - queries_with_retrieval = [q for q in queries if "retrieval_results" in q] + queries_with_retrieval = [ + q for q in queries if "retrieval_results" in q] if queries_with_retrieval: experiment_summary["retrieval_metrics"] = { "average_precision": round(sum(q["retrieval_results"].get("precision", 0) for q in queries_with_retrieval) / len(queries_with_retrieval), 4), @@ -197,7 +200,9 @@ def _calculate_experiment_summary(self, queries: List[Dict]) -> Dict: queries_with_answers = [q for q in queries if "answer_results" in q] if queries_with_answers: - correct_count = sum(1 for q in queries_with_answers if q["answer_results"].get("judge_score", 0) >= 4) + correct_count = sum( + 1 for q in queries_with_answers if q["answer_results"].get( + "judge_score", 0) >= 4) experiment_summary["answer_metrics"] = { "average_judge_score": round(sum(q["answer_results"].get("judge_score", 0) for q in queries_with_answers) / len(queries_with_answers), 2), "queries_correct": correct_count, @@ -207,14 +212,17 @@ def _calculate_experiment_summary(self, queries: List[Dict]) -> Dict: return experiment_summary - def end_query(self, retrieval_results: Dict = None, answer_results: Dict = None, wall_time_s: float = None): + def end_query(self, retrieval_results: Dict = None, + answer_results: Dict = None, wall_time_s: float = None): """Finish logging current query, compute summary, and write to file""" if self.current_query: - self.current_query["timestamp_end"] = datetime.utcnow().isoformat() + "Z" + self.current_query["timestamp_end"] = datetime.utcnow( + ).isoformat() + "Z" # Calculate summary llm_calls = self.current_query["llm_calls"] - hop_counts = [c["hop_count"] for c in llm_calls if c["hop_count"] is not None] + hop_counts = [c["hop_count"] + for c in llm_calls if c["hop_count"] is not None] summary = { "total_llm_calls": len(llm_calls), @@ -244,7 +252,8 @@ def end_query(self, retrieval_results: Dict = None, answer_results: Dict = None, self.current_query = None - def save(self, output_file: str = None, experiment_metadata: Dict[str, Any] = None): + def save(self, output_file: str = None, + experiment_metadata: Dict[str, Any] = None): """Save all logs to JSON file (legacy method for backward compatibility). Note: If logger was initialized with output_file, logs are already written @@ -280,21 +289,28 @@ def save(self, output_file: str = None, experiment_metadata: Dict[str, Any] = No print(f"LLM logs saved to: {target_file}") print(f"Total queries: {len(self.queries)}") if experiment_summary: - print(f"Total LLM calls: {experiment_summary.get('total_llm_calls', 0)}") + print( + f"Total LLM calls: {experiment_summary.get('total_llm_calls', 0)}") print(f"Total tokens: {experiment_summary.get('total_tokens', 0):,} (input: {experiment_summary.get('total_input_tokens', 0):,}, output: {experiment_summary.get('total_output_tokens', 0):,})") # Per-query latency distribution (wall time: query to answer) per_query_latencies = sorted( - (q["summary"].get("total_wall_time_ms") or q["summary"].get("total_latency_ms", 0)) / 1000 + (q["summary"].get("total_wall_time_ms") + or q["summary"].get("total_latency_ms", 0)) / 1000 for q in self.queries if "summary" in q ) n = len(per_query_latencies) if n > 0: mean_lat = sum(per_query_latencies) / n - median_lat = per_query_latencies[n // 2] if n % 2 == 1 else (per_query_latencies[n // 2 - 1] + per_query_latencies[n // 2]) / 2 - p90_lat = per_query_latencies[int(n * 0.90)] if n >= 10 else per_query_latencies[-1] - p99_lat = per_query_latencies[int(n * 0.99)] if n >= 100 else per_query_latencies[-1] + median_lat = per_query_latencies[n // 2] if n % 2 == 1 else ( + per_query_latencies[n // 2 - 1] + per_query_latencies[n // 2]) / 2 + p90_lat = per_query_latencies[int( + n * 0.90)] if n >= 10 else per_query_latencies[-1] + p99_lat = per_query_latencies[int( + n * 0.99)] if n >= 100 else per_query_latencies[-1] total_latency_s = sum(per_query_latencies) - print(f"Per-query latency (query-to-answer): mean={mean_lat:.2f}s median={median_lat:.2f}s p90={p90_lat:.2f}s p99={p99_lat:.2f}s") - print(f"Throughput: {n / total_latency_s:.4f} queries/sec ({total_latency_s / n:.2f}s per query)") + print( + f"Per-query latency (query-to-answer): mean={mean_lat:.2f}s median={median_lat:.2f}s p90={p90_lat:.2f}s p99={p99_lat:.2f}s") + print( + f"Throughput: {n / total_latency_s:.4f} queries/sec ({total_latency_s / n:.2f}s per query)") print(f"{'='*80}\n") diff --git a/e2e-rag/measure_indexing_with_chunking.py b/e2e-rag/measure_indexing_with_chunking.py index b8069c24f0..1ef82c2a1a 100644 --- a/e2e-rag/measure_indexing_with_chunking.py +++ b/e2e-rag/measure_indexing_with_chunking.py @@ -188,14 +188,17 @@ def main(): # Validate required arguments if not args.documents and not args.ingest: - parser.error("Either --documents (for raw docs) or --ingest (for pre-chunked passages) is required") + parser.error( + "Either --documents (for raw docs) or --ingest (for pre-chunked passages) is required") # Set default database name if not provided if args.database is None: args.database = VectorDB.get_default_db_name() - db_file_path = args.database if args.database.endswith('.db') else f"{args.database}.db" - db_base_name = args.database.replace('.db', '') if args.database.endswith('.db') else args.database + db_file_path = args.database if args.database.endswith( + '.db') else f"{args.database}.db" + db_base_name = args.database.replace( + '.db', '') if args.database.endswith('.db') else args.database # Check if database already exists db_exists = Path(db_file_path).exists() @@ -249,8 +252,10 @@ def main(): if args.save_passages: passages_file = args.save_passages else: - # Generate filename based on source directory and chunking parameters - source_dir_name = os.path.basename(os.path.normpath(args.documents)) + # Generate filename based on source directory and chunking + # parameters + source_dir_name = os.path.basename( + os.path.normpath(args.documents)) passages_file = f"passages_{source_dir_name}_len{args.chunk_size}_ov{args.chunk_overlap}_{args.text_boundary}.json" print(f" Auto-generated passages filename: {passages_file}") @@ -358,7 +363,8 @@ def main(): # STEP 5: VALIDATE DATABASE (after save, not part of perf) # ============================================================ print("[5/5] Validating database...") - validation_results = validate_database(rag_db, expected_passages=num_passages if args.documents else None) + validation_results = validate_database( + rag_db, expected_passages=num_passages if args.documents else None) vector_count = validation_results["vector_count"] if validation_results["validation_passed"]: @@ -460,7 +466,7 @@ def main(): import shutil try: shutil.rmtree(temp_text_dir) - except: + except BaseException: pass return 0 diff --git a/e2e-rag/multi_shot_retrieval.py b/e2e-rag/multi_shot_retrieval.py index 3ee84c731d..905b83bfbb 100644 --- a/e2e-rag/multi_shot_retrieval.py +++ b/e2e-rag/multi_shot_retrieval.py @@ -28,6 +28,13 @@ Prompt → Query Rewriter (LLM) → k Sub-queries → Retrieval → Reranking → Evaluation """ +import requests +from llm_logger import LLMLogger +from params import add_all_args +from utils import (set_deterministic_seeds, filter_dataset_by_difficulty, + setup_llm_config, get_device_config) +from evaluation import evaluate_retrieval_query, run_evaluation +from retrieve import VectorDB import argparse import json import re @@ -46,13 +53,6 @@ # Get OpenRouter API key from environment OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '') -from retrieve import VectorDB -from evaluation import evaluate_retrieval_query, run_evaluation -from utils import (set_deterministic_seeds, filter_dataset_by_difficulty, - setup_llm_config, get_device_config) -from params import add_all_args -from llm_logger import LLMLogger -import requests # Prompts @@ -171,15 +171,15 @@ **If there are NO NEW documents to evaluate, skip this task and go to TASK 3** -SUMMARY REQUIREMENTS: +SUMMARY REQUIREMENTS: - Extract and preserve specific details - only facts from the document TASK 2: CHECK IF SUFFICIENT AND CONNECT INFORMATION Review ALL KEPT documents and summaries. Actively connect facts across documents: -- Identify entities by matching names across summaries +- Identify entities by matching names across summaries - Chain relationships (A → B → C) -- Cross-reference dates/events +- Cross-reference dates/events - Build complete chains: Person → Family member → Attribute, or Event → Year → Cross-reference If you can construct a complete answer chain with specific names/facts from kept documents, provide final answer. @@ -204,7 +204,7 @@ **For People/Biography:** - Full article: Just the person's name "Harriet Lane" -- Family: "Person X family", "Person X parents" +- Family: "Person X family", "Person X parents" - Specific relative: "Person X" then extract family, don't search "Person X mother" repeatedly **For Events/Dates:** @@ -240,7 +240,7 @@ {{"relevance": [], "summaries": [], "queries": ["Nth position holder name", "specific event list"], "feedback": "Starting with direct entity/list searches."}} CRITICAL REQUIREMENTS: -- If NO NEW documents: return empty arrays: "relevance": [], "summaries": [] +- If NO NEW documents: return empty arrays: "relevance": [], "summaries": [] - If {len_new_docs} NEW documents: return exactly {len_new_docs} relevance scores and {len_new_docs} summaries - For relevant docs (relevance=1): summary MUST extract specific facts (names, dates, relationships, family details from infobox/text) - For irrelevant docs (relevance=0): summary MUST be empty string "" @@ -254,9 +254,6 @@ Respond only in JSON format""" - - - def get_chat_completions_headers(service_url: str): """Headers for an OpenAI-compatible /v1/chat/completions request. @@ -272,21 +269,22 @@ def get_chat_completions_headers(service_url: str): } return {} + def call_chat_completions(service_url: str, model_name: str, messages: List[Dict], - temperature: float = 1.0, max_tokens: int = 4096, - top_p: float = 1.0, - top_k: int = -1, - reasoning_effort: str = "medium", - frequency_penalty: float = 0.0, - presence_penalty: float = 0.0, - repetition_penalty: float = 1.0, - max_retries: int = 5, - logger: Optional[LLMLogger] = None, - component: str = "unknown", - hop_count: Optional[int] = None, - context: Dict[str, Any] = None, - perf_test_cache: Optional[Any] = None, - query_id: Optional[str] = None) -> str: + temperature: float = 1.0, max_tokens: int = 4096, + top_p: float = 1.0, + top_k: int = -1, + reasoning_effort: str = "medium", + frequency_penalty: float = 0.0, + presence_penalty: float = 0.0, + repetition_penalty: float = 1.0, + max_retries: int = 5, + logger: Optional[LLMLogger] = None, + component: str = "unknown", + hop_count: Optional[int] = None, + context: Dict[str, Any] = None, + perf_test_cache: Optional[Any] = None, + query_id: Optional[str] = None) -> str: """Call OpenRouter API with proper authentication and logging. Default sampling parameters: @@ -307,7 +305,8 @@ def call_chat_completions(service_url: str, model_name: str, messages: List[Dict if reasoning_effort != "medium": payload["reasoning_effort"] = reasoning_effort else: - payload["reasoning_effort"] = reasoning_effort # Always include for logging + # Always include for logging + payload["reasoning_effort"] = reasoning_effort # Add optional sampling parameters if frequency_penalty != 0.0: @@ -322,22 +321,32 @@ def call_chat_completions(service_url: str, model_name: str, messages: List[Dict # Performance test mode: check if we have cached response cached_response = None if perf_test_cache and query_id and component and hop_count is not None: - cached_response = perf_test_cache.get_response(query_id, component, hop_count) + cached_response = perf_test_cache.get_response( + query_id, component, hop_count) if cached_response: print(f" [PERF TEST MODE] Will attempt real LLM call for performance measurement, but return cached response for deterministic pipeline") - print(f" [PERF TEST MODE] CRITICAL: LLM call MUST succeed - test will STOP if LLM service is unavailable") + print( + f" [PERF TEST MODE] CRITICAL: LLM call MUST succeed - test will STOP if LLM service is unavailable") else: - print(f" [WARNING] No cached response for {component} hop {hop_count}, will use real LLM response") + print( + f" [WARNING] No cached response for {component} hop {hop_count}, will use real LLM response") for attempt in range(max_retries): start_time = time.time() try: - response = requests.post(service_url, json=payload, headers=headers, timeout=120) + response = requests.post( + service_url, + json=payload, + headers=headers, + timeout=120) # Retry on rate limit if response.status_code == 429: - retry_after = int(response.headers.get('Retry-After', 2 ** attempt)) - print(f" Rate limited (429). Retrying in {retry_after}s (attempt {attempt+1}/{max_retries})") + retry_after = int( + response.headers.get( + 'Retry-After', 2 ** attempt)) + print( + f" Rate limited (429). Retrying in {retry_after}s (attempt {attempt+1}/{max_retries})") time.sleep(retry_after) continue @@ -352,12 +361,14 @@ def call_chat_completions(service_url: str, model_name: str, messages: List[Dict if not llm_output: reasoning_content = message.get('reasoning_content') or '' if reasoning_content: - json_match = re.search(r'\{.*\}', reasoning_content, re.DOTALL) + json_match = re.search( + r'\{.*\}', reasoning_content, re.DOTALL) if json_match: llm_output = json_match.group(0) if not llm_output: - print(f" WARNING [{component}]: LLM returned empty content. Raw response: {json.dumps(result)[:500]}") + print( + f" WARNING [{component}]: LLM returned empty content. Raw response: {json.dumps(result)[:500]}") # Log this call if logger: @@ -368,14 +379,19 @@ def call_chat_completions(service_url: str, model_name: str, messages: List[Dict response=result, latency_ms=latency_ms, context=context or {}, - simulated_response=cached_response # None in normal mode, cached value in perf test mode + # None in normal mode, cached value in perf test mode + simulated_response=cached_response ) - # In perf test mode, return cached response instead of real LLM output + # In perf test mode, return cached response instead of real LLM + # output if cached_response: - print(f" [PERF TEST MODE] Returning simulated response (LLM generated: {len(llm_output)} chars, Simulated: {len(cached_response)} chars)") - print(f" [PERF TEST MODE] Real LLM output: {llm_output[:200]}{'...' if len(llm_output) > 200 else ''}") - print(f" [PERF TEST MODE] Cached output (used in pipeline): {cached_response[:200]}{'...' if len(cached_response) > 200 else ''}") + print( + f" [PERF TEST MODE] Returning simulated response (LLM generated: {len(llm_output)} chars, Simulated: {len(cached_response)} chars)") + print( + f" [PERF TEST MODE] Real LLM output: {llm_output[:200]}{'...' if len(llm_output) > 200 else ''}") + print( + f" [PERF TEST MODE] Cached output (used in pipeline): {cached_response[:200]}{'...' if len(cached_response) > 200 else ''}") return cached_response return llm_output @@ -384,54 +400,71 @@ def call_chat_completions(service_url: str, model_name: str, messages: List[Dict status = e.response.status_code if e.response is not None else None if status in (502, 503, 504) and attempt < max_retries - 1: wait = 2 ** attempt - print(f" Server error ({status}). Retrying in {wait}s (attempt {attempt+1}/{max_retries})") + print( + f" Server error ({status}). Retrying in {wait}s (attempt {attempt+1}/{max_retries})") time.sleep(wait) continue # In perf test mode, LLM calls MUST succeed for valid benchmarking if cached_response: print(f" ERROR [{component}]: HTTP {status}: {e}") - print(f" [PERF TEST MODE FATAL] LLM call failed - cannot proceed with cached response") - print(f" [PERF TEST MODE FATAL] Performance benchmarking requires all LLM calls to succeed for run-to-run equivalency") - print(f" [PERF TEST MODE FATAL] Please ensure LLM service is running on {service_url}") - raise RuntimeError(f"Perf test mode requires LLM service to be available. LLM call failed for {component}") from e + print( + f" [PERF TEST MODE FATAL] LLM call failed - cannot proceed with cached response") + print( + f" [PERF TEST MODE FATAL] Performance benchmarking requires all LLM calls to succeed for run-to-run equivalency") + print( + f" [PERF TEST MODE FATAL] Please ensure LLM service is running on {service_url}") + raise RuntimeError( + f"Perf test mode requires LLM service to be available. LLM call failed for {component}") from e print(f" ERROR [{component}]: HTTP {status}: {e}") raise except requests.exceptions.Timeout: if attempt < max_retries - 1: wait = 2 ** attempt - print(f" Timeout. Retrying in {wait}s (attempt {attempt+1}/{max_retries})") + print( + f" Timeout. Retrying in {wait}s (attempt {attempt+1}/{max_retries})") time.sleep(wait) continue # In perf test mode, LLM calls MUST succeed for valid benchmarking if cached_response: - print(f" ERROR [{component}]: Request timed out after {max_retries} attempts") - print(f" [PERF TEST MODE FATAL] LLM call timed out - cannot proceed with cached response") - print(f" [PERF TEST MODE FATAL] Performance benchmarking requires all LLM calls to succeed for run-to-run equivalency") - raise RuntimeError(f"Perf test mode requires LLM service to respond. LLM call timed out for {component}") - - print(f" ERROR [{component}]: Request timed out after {max_retries} attempts") + print( + f" ERROR [{component}]: Request timed out after {max_retries} attempts") + print( + f" [PERF TEST MODE FATAL] LLM call timed out - cannot proceed with cached response") + print( + f" [PERF TEST MODE FATAL] Performance benchmarking requires all LLM calls to succeed for run-to-run equivalency") + raise RuntimeError( + f"Perf test mode requires LLM service to respond. LLM call timed out for {component}") + + print( + f" ERROR [{component}]: Request timed out after {max_retries} attempts") raise except Exception as e: # In perf test mode, LLM calls MUST succeed for valid benchmarking if cached_response: print(f" ERROR [{component}]: {e}") - print(f" [PERF TEST MODE FATAL] LLM call failed with exception - cannot proceed with cached response") - print(f" [PERF TEST MODE FATAL] Performance benchmarking requires all LLM calls to succeed for run-to-run equivalency") - print(f" [PERF TEST MODE FATAL] Please ensure LLM service is running and accessible") - raise RuntimeError(f"Perf test mode requires LLM service to be available. LLM call failed for {component}") from e + print( + f" [PERF TEST MODE FATAL] LLM call failed with exception - cannot proceed with cached response") + print( + f" [PERF TEST MODE FATAL] Performance benchmarking requires all LLM calls to succeed for run-to-run equivalency") + print( + f" [PERF TEST MODE FATAL] Please ensure LLM service is running and accessible") + raise RuntimeError( + f"Perf test mode requires LLM service to be available. LLM call failed for {component}") from e print(f" ERROR [{component}]: {e}") raise print(f" ERROR [{component}]: Max retries ({max_retries}) exceeded") if cached_response: - print(f" [PERF TEST MODE] Real LLM call failed, returning cached response") + print( + f" [PERF TEST MODE] Real LLM call failed, returning cached response") return cached_response raise RuntimeError(f"LLM call failed after {max_retries} retries") + def evaluate_document_relevance(question: str, new_documents: List[tuple], kept_documents: List[tuple], @@ -512,15 +545,18 @@ def evaluate_document_relevance(question: str, relevance = relevance_result.get("relevance", []) if len(relevance) != len(new_documents): - print(f" Warning: Relevance mismatch. Expected {len(new_documents)}, got {len(relevance)}") + print( + f" Warning: Relevance mismatch. Expected {len(new_documents)}, got {len(relevance)}") return {"relevance": [1] * len(new_documents)} return {"relevance": relevance} except Exception as e: # In perf test mode, propagate fatal errors (don't fallback) - perf_test_cache = llm_config.get('perf_test_cache') if llm_config else None - if perf_test_cache and isinstance(e, RuntimeError) and "Perf test mode requires" in str(e): + perf_test_cache = llm_config.get( + 'perf_test_cache') if llm_config else None + if perf_test_cache and isinstance( + e, RuntimeError) and "Perf test mode requires" in str(e): # This is a perf test mode fatal error - must propagate it raise @@ -529,13 +565,13 @@ def evaluate_document_relevance(question: str, def check_sufficiency(question: str, - kept_documents: List[tuple], - iteration: int, - max_iterations: int, - llm_config: Optional[Dict[str, Any]] = None, - logger: Optional[LLMLogger] = None, - hop_count: int = 1, - query_id: Optional[str] = None) -> Dict[str, Any]: + kept_documents: List[tuple], + iteration: int, + max_iterations: int, + llm_config: Optional[Dict[str, Any]] = None, + logger: Optional[LLMLogger] = None, + hop_count: int = 1, + query_id: Optional[str] = None) -> Dict[str, Any]: """ Check if kept documents are sufficient to answer the question. Uses gpt-oss-120b model via OpenRouter. @@ -601,13 +637,15 @@ def check_sufficiency(question: str, query_id=query_id ) - print(f" [DEBUG] Sufficiency check raw output: {llm_output[:200]}...") + print( + f" [DEBUG] Sufficiency check raw output: {llm_output[:200]}...") if not llm_output: print(f" Warning: Sufficiency check returned empty") # On final iteration, force sufficient if iteration >= max_iterations: - return {"sufficient": True, "reasoning": "Max iterations reached"} + return {"sufficient": True, + "reasoning": "Max iterations reached"} return {"sufficient": False, "reasoning": "LLM returned empty"} if llm_output.startswith("```"): @@ -635,24 +673,27 @@ def check_sufficiency(question: str, except Exception as e: # In perf test mode, propagate fatal errors (don't fallback) - perf_test_cache = llm_config.get('perf_test_cache') if llm_config else None - if perf_test_cache and isinstance(e, RuntimeError) and "Perf test mode requires" in str(e): + perf_test_cache = llm_config.get( + 'perf_test_cache') if llm_config else None + if perf_test_cache and isinstance( + e, RuntimeError) and "Perf test mode requires" in str(e): # This is a perf test mode fatal error - must propagate it raise print(f" Error in sufficiency check: {e}") # On final iteration, force sufficient if iteration >= max_iterations: - return {"sufficient": True, "reasoning": f"Max iterations reached (error: {str(e)})"} + return {"sufficient": True, + "reasoning": f"Max iterations reached (error: {str(e)})"} return {"sufficient": False, "reasoning": f"Error: {str(e)}"} def generate_answer(question: str, - kept_documents: List[tuple], - llm_config: Optional[Dict[str, Any]] = None, - logger: Optional[LLMLogger] = None, - hop_count: Optional[int] = None, - query_id: Optional[str] = None) -> str: + kept_documents: List[tuple], + llm_config: Optional[Dict[str, Any]] = None, + logger: Optional[LLMLogger] = None, + hop_count: Optional[int] = None, + query_id: Optional[str] = None) -> str: """ Generate final answer from kept documents using gpt-oss-120b. @@ -726,9 +767,12 @@ def generate_answer(question: str, return llm_output.strip() except Exception as e: - # In perf test mode, propagate fatal errors (don't fallback to "Unknown") - perf_test_cache = llm_config.get('perf_test_cache') if llm_config else None - if perf_test_cache and isinstance(e, RuntimeError) and "Perf test mode requires" in str(e): + # In perf test mode, propagate fatal errors (don't fallback to + # "Unknown") + perf_test_cache = llm_config.get( + 'perf_test_cache') if llm_config else None + if perf_test_cache and isinstance( + e, RuntimeError) and "Perf test mode requires" in str(e): # This is a perf test mode fatal error - must propagate it raise @@ -737,15 +781,15 @@ def generate_answer(question: str, def generate_search_queries(question: str, - kept_documents: List[tuple], - max_queries: int = 3, - query_history: Optional[List[str]] = None, - query_results: Optional[List[int]] = None, - feedback_history: Optional[List[str]] = None, - llm_config: Optional[Dict[str, Any]] = None, - logger: Optional[LLMLogger] = None, - hop_count: int = 1, - query_id: Optional[str] = None) -> Dict[str, Any]: + kept_documents: List[tuple], + max_queries: int = 3, + query_history: Optional[List[str]] = None, + query_results: Optional[List[int]] = None, + feedback_history: Optional[List[str]] = None, + llm_config: Optional[Dict[str, Any]] = None, + logger: Optional[LLMLogger] = None, + hop_count: int = 1, + query_id: Optional[str] = None) -> Dict[str, Any]: """ Generate search queries using gpt-oss-120b via OpenRouter. """ @@ -771,7 +815,8 @@ def generate_search_queries(question: str, history_text = "No queries yet" # Format feedback - feedback_text = "\n".join(feedback_history) if feedback_history else "Iteration 1 - Initial search" + feedback_text = "\n".join( + feedback_history) if feedback_history else "Iteration 1 - Initial search" prompt = QUERY_GENERATION_PROMPT.format( question=question, @@ -833,9 +878,12 @@ def generate_search_queries(question: str, } except Exception as e: - # In perf test mode, propagate fatal errors (don't fallback to original question) - perf_test_cache = llm_config.get('perf_test_cache') if llm_config else None - if perf_test_cache and isinstance(e, RuntimeError) and "Perf test mode requires" in str(e): + # In perf test mode, propagate fatal errors (don't fallback to original + # question) + perf_test_cache = llm_config.get( + 'perf_test_cache') if llm_config else None + if perf_test_cache and isinstance( + e, RuntimeError) and "Perf test mode requires" in str(e): # This is a perf test mode fatal error - must propagate it raise @@ -854,7 +902,7 @@ def query_rewriter(question: str, new_documents: List[tuple], llm_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ Evaluates documents AND generates new queries in one LLM call. - + Args: question: The user's original question new_documents: List of NEW document texts to evaluate @@ -864,7 +912,7 @@ def query_rewriter(question: str, new_documents: List[tuple], query_history: List of previous search queries query_results: List of number of documents found for each query (parallel to query_history) previous_feedback: Feedback from previous iteration about what's missing - + Returns: Dict with: - 'relevance' (list of 0/1 for ONLY new_documents) @@ -879,18 +927,18 @@ def query_rewriter(question: str, new_documents: List[tuple], kept_context += f"\n[KEPT {i}] {doc[2]}\n" else: kept_context = "None" - - # Format NEW documents + + # Format NEW documents new_context = "" if new_documents: for i, doc in enumerate(new_documents, 1): new_context += f"\n[NEW {i}] {doc[1]}\n" else: new_context = "None" - + # Combine for context context = f"KEPT DOCUMENTS (already relevant):\n{kept_context}\n\nNEW DOCUMENTS (evaluate these):\n{new_context}" - + # Format query history with results - focus on failures for learning if query_history: failed_queries = [] @@ -901,31 +949,35 @@ def query_rewriter(question: str, new_documents: List[tuple], failed_queries.append(q) else: successful_queries.append(f"{q} ({num_docs} docs)") - + history_parts = [] if failed_queries: - history_parts.append(f"FAILED: {', '.join(failed_queries)}") # Last 3 failures + history_parts.append( + f"FAILED: {', '.join(failed_queries)}") # Last 3 failures if successful_queries: - history_parts.append(f"SUCCESS: {', '.join(successful_queries)}") # Last 2 successes - - history_text = "; ".join(history_parts) if history_parts else "No queries yet" + # Last 2 successes + history_parts.append(f"SUCCESS: {', '.join(successful_queries)}") + + history_text = "; ".join( + history_parts) if history_parts else "No queries yet" else: history_text = "No queries yet" - + # Build feedback history - show progression of what was tried and learned if feedback_history and len(feedback_history) > 0: unique_feedback = [] for fb in reversed(feedback_history): if fb and fb not in unique_feedback: unique_feedback.append(fb) - + if unique_feedback: - feedback_text = "PREVIOUS ATTEMPTS: " + " → ".join(reversed(unique_feedback)) + feedback_text = "PREVIOUS ATTEMPTS: " + \ + " → ".join(reversed(unique_feedback)) else: feedback_text = f"Iteration {len(query_history) + 1 if query_history else 1}" else: feedback_text = f"Iteration {len(query_history) + 1 if query_history else 1} - Initial search" - + print(f"Context: {context}") print(f"History: {history_text}") print(f"Feedback: {feedback_text}") @@ -938,12 +990,12 @@ def query_rewriter(question: str, new_documents: List[tuple], k=max_queries, len_new_docs=len(new_documents) ) - - system_message = f"""You are an expert at multi-hop reasoning and strategic search. - CRITICAL: Never repeat failed queries. - Always try completely different approaches when queries return 0 docs. + + system_message = f"""You are an expert at multi-hop reasoning and strategic search. + CRITICAL: Never repeat failed queries. + Always try completely different approaches when queries return 0 docs. Focus on atomic facts and progressive strategies.""" - + # Use LLM config if provided, otherwise use defaults if llm_config: model_name = llm_config["model_name"] @@ -973,10 +1025,10 @@ def query_rewriter(question: str, new_documents: List[tuple], response = requests.post(service_url, json=payload, timeout=300) response.raise_for_status() result = response.json() - + message = result['choices'][0]['message'] llm_output = message.get('content') - + # Fallback: use reasoning_content if content is empty (thinking models) reasoning_content = message.get('reasoning_content', '') if reasoning_content and not llm_output: @@ -985,12 +1037,15 @@ def query_rewriter(question: str, new_documents: List[tuple], json_match = re.search(r'\{.*\}', reasoning_content, re.DOTALL) if json_match: llm_output = json_match.group(0) - print(f" DEBUG: Extracted JSON from reasoning_content ({len(llm_output)} chars)") + print( + f" DEBUG: Extracted JSON from reasoning_content ({len(llm_output)} chars)") else: - print(f" DEBUG: No JSON found in reasoning_content snippet: {reasoning_content[:200]}") + print( + f" DEBUG: No JSON found in reasoning_content snippet: {reasoning_content[:200]}") if llm_output is None or not llm_output.strip(): - print(f" Warning: LLM returned empty content, using original query as fallback") + print( + f" Warning: LLM returned empty content, using original query as fallback") # Always fall back to original query - never return empty queries return { "relevance": [0] * len(new_documents), @@ -999,25 +1054,25 @@ def query_rewriter(question: str, new_documents: List[tuple], "feedback": "LLM returned empty response", "answer": "" } - + llm_output = llm_output.strip() - + # Parse JSON output - handle markdown code blocks if llm_output.startswith("```"): llm_output = llm_output.split("```")[1] if llm_output.startswith("json"): llm_output = llm_output[4:] llm_output = llm_output.strip() - + result_data = json.loads(llm_output) - - # Validate format + + # Validate format required_fields = ["relevance"] for field in required_fields: if field not in result_data: print(f"Warning: Missing required field '{field}' in response") result_data[field] = [0] * len(new_documents) - + # Ensure we have either "answer" OR "queries"+"feedback" if "answer" not in result_data: result_data["answer"] = "" @@ -1027,37 +1082,47 @@ def query_rewriter(question: str, new_documents: List[tuple], result_data["feedback"] = "" if "summaries" not in result_data: result_data["summaries"] = [""] * len(new_documents) - - # Ensure relevance array matches NEW document count - fix mismatches by padding/truncating + + # Ensure relevance array matches NEW document count - fix mismatches by + # padding/truncating if len(result_data["relevance"]) != len(new_documents): - print(f"Warning: Relevance array length mismatch. Expected {len(new_documents)}, got {len(result_data['relevance'])}. Auto-fixing.") - relevance = result_data["relevance"][:len(new_documents)] # Truncate if too long - while len(relevance) < len(new_documents): # Pad with 0s if too short + print( + f"Warning: Relevance array length mismatch. Expected {len(new_documents)}, got {len(result_data['relevance'])}. Auto-fixing.") + relevance = result_data["relevance"][:len( + new_documents)] # Truncate if too long + while len(relevance) < len( + new_documents): # Pad with 0s if too short relevance.append(0) result_data["relevance"] = relevance print(f"Fixed relevance array: {relevance}") - - # Ensure summaries array matches NEW document count - fix mismatches by padding/truncating + + # Ensure summaries array matches NEW document count - fix mismatches by + # padding/truncating if len(result_data["summaries"]) != len(new_documents): - print(f"Warning: Summaries array length mismatch. Expected {len(new_documents)}, got {len(result_data['summaries'])}. Auto-fixing.") - summaries = result_data["summaries"][:len(new_documents)] # Truncate if too long - while len(summaries) < len(new_documents): # Pad with empty strings if too short + print( + f"Warning: Summaries array length mismatch. Expected {len(new_documents)}, got {len(result_data['summaries'])}. Auto-fixing.") + summaries = result_data["summaries"][:len( + new_documents)] # Truncate if too long + while len(summaries) < len( + new_documents): # Pad with empty strings if too short summaries.append("") result_data["summaries"] = summaries print(f"Fixed summaries array length: {len(summaries)}") - + # Validate that relevant documents have non-empty summaries - for i, (rel, summary) in enumerate(zip(result_data["relevance"], result_data["summaries"])): + for i, (rel, summary) in enumerate( + zip(result_data["relevance"], result_data["summaries"])): if rel == 1 and not summary.strip(): - print(f"Warning: Document {i+1} marked relevant but has empty summary. This defeats the summarization purpose.") + print( + f"Warning: Document {i+1} marked relevant but has empty summary. This defeats the summarization purpose.") # Don't auto-fix here - let it be empty to debug the issue - + # Ensure queries is a list if not isinstance(result_data["queries"], list): result_data["queries"] = [] - + return result_data - + except requests.exceptions.RequestException as e: print(f"Error calling combined LLM: {e}") return { @@ -1106,14 +1171,14 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], **strategy_params) -> Dict[str, Any]: """ Multi-shot retrieval with iterative query refinement and document evaluation. - + Algorithm: 1. Generate initial search queries based on the original question 2. Retrieve documents for each query 3. Evaluate documents and check if sufficient to answer 4. If not sufficient: generate new queries based on what's missing, go to step 2 5. Repeat until sufficient or max_iterations reached - + Args: rag_db: RAG database instance original_query: Original user question @@ -1127,29 +1192,31 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], verbose: Print detailed information reasoning_effort: LLM reasoning level **strategy_params: Additional parameters for retrieval strategy - + Returns: Dictionary containing evaluation metrics and iteration statistics """ - + start_time = time.perf_counter() llm_start_time = None # set just before first generate_search_queries call llm_end_time = None # set just after generate_answer returns - + # Track iteration history query_history = [] query_results = [] # Track how many docs each query found - kept_docs = [] # List of (url, content, summary) tuples that were marked relevant - new_docs = [] # List of (url, content) tuples just retrieved this iteration + # List of (url, content, summary) tuples that were marked relevant + kept_docs = [] + # List of (url, content) tuples just retrieved this iteration + new_docs = [] all_retrieved_urls = set() iteration_times = [] previous_feedback = "" # Feedback from previous iteration feedback_history = [] # Track all feedback to show progression - + sufficient = False iteration = 0 final_answer = "" - + if verbose: print(f"\n{'='*80}") print(f"MULTI-SHOT RETRIEVAL") @@ -1158,29 +1225,33 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], print(f"Max iterations: {max_iterations}") print(f"Max sub-queries per iteration: {max_sub_queries}") print(f"{'='*80}\n") - + while not sufficient and iteration < max_iterations: iteration += 1 iteration_start = time.perf_counter() - + if verbose: print(f"\n{'─'*80}") print(f"ITERATION {iteration}/{max_iterations}") print(f"{'─'*80}") - - # Step 1: Use combined function to grade NEW docs AND generate new queries + + # Step 1: Use combined function to grade NEW docs AND generate new + # queries if verbose: print(f"\n Evaluating documents and generating queries...") - - # Aggressive summarization: use summaries after iteration 2 to improve information connection + + # Aggressive summarization: use summaries after iteration 2 to improve + # information connection total_content_length = sum(len(doc[1]) for doc in kept_docs) # Special handling for iteration 1: decompose original query first if iteration == 1 and not new_docs and not kept_docs: if verbose: - print(f" [ITERATION 1] Decomposing original query into sub-queries via generate_search_queries...") + print( + f" [ITERATION 1] Decomposing original query into sub-queries via generate_search_queries...") - # Use generate_search_queries for initial decomposition (uses query_model_name / gpt-oss-120b) + # Use generate_search_queries for initial decomposition (uses + # query_model_name / gpt-oss-120b) if llm_start_time is None: llm_start_time = time.perf_counter() query_result = generate_search_queries( @@ -1207,7 +1278,8 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], sufficient = False final_answer = "" - current_feedback = query_result.get("feedback", "Initial query decomposition") + current_feedback = query_result.get( + "feedback", "Initial query decomposition") relevance = [] summaries = [] reasoning_steps = "" @@ -1217,7 +1289,8 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], relevance = [] if new_docs: if verbose: - print(f" [CALL1] Evaluating {len(new_docs)} new documents with gpt-oss-20b...") + print( + f" [CALL1] Evaluating {len(new_docs)} new documents with gpt-oss-20b...") relevance_result = evaluate_document_relevance( question=original_query, @@ -1228,7 +1301,8 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], hop_count=iteration, query_id=query_id ) - relevance = relevance_result.get("relevance", [1] * len(new_docs)) + relevance = relevance_result.get( + "relevance", [1] * len(new_docs)) # Add relevant docs to kept_docs IMMEDIATELY for i, (url, content) in enumerate(new_docs): @@ -1236,14 +1310,16 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], kept_docs.append((url, content, content[:1000])) if verbose: - print(f" Marked {sum(relevance)} of {len(new_docs)} docs as relevant") + print( + f" Marked {sum(relevance)} of {len(new_docs)} docs as relevant") print(f" Relevance array: {relevance}") print(f" Total kept docs now: {len(kept_docs)}") # CALL 2: Check sufficiency - uses gpt-oss-120b if kept_docs: if verbose: - print(f" [CALL2] Checking sufficiency with gpt-oss-120b (iteration {iteration}/{max_iterations})...") + print( + f" [CALL2] Checking sufficiency with gpt-oss-120b (iteration {iteration}/{max_iterations})...") sufficiency_result = check_sufficiency( question=original_query, @@ -1267,11 +1343,13 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], sufficient = False sufficiency_reasoning = "No relevant documents kept yet" - # CALL 3a or 3b: Either generate answer (if sufficient) or generate queries (if not) + # CALL 3a or 3b: Either generate answer (if sufficient) or generate + # queries (if not) if sufficient: # CALL 3a: Generate final answer - uses gpt-oss-120b if verbose: - print(f" [CALL3a] Generating final answer with gpt-oss-120b...") + print( + f" [CALL3a] Generating final answer with gpt-oss-120b...") final_answer = generate_answer( question=original_query, @@ -1291,11 +1369,13 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], else: # CALL 3b: Generate search queries - uses gpt-oss-120b if verbose: - print(f" [CALL3b] Generating search queries with gpt-oss-120b...") + print( + f" [CALL3b] Generating search queries with gpt-oss-120b...") # Cap kept_docs sent to avoid context overflow MAX_DOCS_FOR_QUERY_GEN = 12 - docs_for_query_gen = kept_docs[-MAX_DOCS_FOR_QUERY_GEN:] if len(kept_docs) > MAX_DOCS_FOR_QUERY_GEN else kept_docs + docs_for_query_gen = kept_docs[-MAX_DOCS_FOR_QUERY_GEN:] if len( + kept_docs) > MAX_DOCS_FOR_QUERY_GEN else kept_docs query_result = generate_search_queries( question=original_query, @@ -1320,27 +1400,30 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], summaries = [] reasoning_steps = "" - # Add to feedback history if it's new and meaningful - if current_feedback and current_feedback.strip() and current_feedback != previous_feedback: + if current_feedback and current_feedback.strip( + ) and current_feedback != previous_feedback: feedback_history.append(current_feedback.strip()) previous_feedback = current_feedback # Only print status for iteration 1 after we've printed the queries # For iteration 2+, print status after CALL1/CALL2/CALL3 - # Skip printing here for iteration 1 (will print later after retrieval/grading) + # Skip printing here for iteration 1 (will print later after + # retrieval/grading) if verbose and iteration > 1: print(f" Sufficient: {'yes' if sufficient else 'no'}") print(f" Kept docs: {len(kept_docs)}") if new_docs: print(f" New docs evaluated: {len(new_docs)}") - print(f" Relevant new docs: {sum(relevance)}/{len(relevance)}") + print( + f" Relevant new docs: {sum(relevance)}/{len(relevance)}") print(f" Relevance array: {relevance}") # Show summary quality if summaries: non_empty_summaries = [s for s in summaries if s.strip()] - print(f" Generated summaries: {len(non_empty_summaries)}/{len(summaries)} non-empty") + print( + f" Generated summaries: {len(non_empty_summaries)}/{len(summaries)} non-empty") for i, summary in enumerate(summaries): if summary.strip() and relevance[i] == 1: print(f" Summary {i+1}: {summary}...") @@ -1349,10 +1432,11 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], if not sufficient: print(f" Feedback: {previous_feedback}") print(f" Generated {len(sub_queries)} new queries") - - # Clear new_docs for next iteration (already added to kept_docs in CALL1 block above) + + # Clear new_docs for next iteration (already added to kept_docs in + # CALL1 block above) new_docs = [] - + # If sufficient, we're done if sufficient: if verbose: @@ -1361,21 +1445,22 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], print(f" Answer: {final_answer[:200]}...") iteration_times.append(time.perf_counter() - iteration_start) break - - # If no queries generated, fall back to original query rather than stopping + + # If no queries generated, fall back to original query rather than + # stopping if not sub_queries: if verbose: print(f"\n ⚠ No new queries generated, falling back to original query") sub_queries = [original_query] - + if verbose: print(f"\n New queries:") for i, q in enumerate(sub_queries, 1): print(f" {i}. {q}") - + # Step 2: Retrieve for each sub-query and track results num_sub_queries = len(sub_queries) - #docs_per_subquery = max(1, top_k_retriever // num_sub_queries) + # docs_per_subquery = max(1, top_k_retriever // num_sub_queries) docs_per_subquery = max(1, top_k_retriever) iteration_results = [] @@ -1383,45 +1468,52 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], # Calculate target docs per subquery after reranking target_docs_per_subquery = max(3, top_k_retriever // num_sub_queries) - + for i, sub_query in enumerate(sub_queries, 1): if verbose: print(f"\n Retrieving for query {i}: {sub_query[:60]}...") - + query_start_count = len(new_docs) # Track docs before this query - + # Retrieve if retrieval_strategy == "fixed_k": results = rag_db.lookup(sub_query, k=docs_per_subquery) else: from retrieve.filter import filter original_max_results = strategy_params.get("max_results", 20) - #adjusted_max_results = max(1, original_max_results // num_sub_queries) + # adjusted_max_results = max(1, original_max_results // num_sub_queries) adjusted_max_results = max(1, original_max_results) strategy_params_copy = strategy_params.copy() strategy_params_copy["max_results"] = adjusted_max_results - results = filter(rag_db, sub_query, method=retrieval_strategy, **strategy_params_copy) - + results = filter( + rag_db, + sub_query, + method=retrieval_strategy, + **strategy_params_copy) + # Apply per-subquery reranking if enabled if not no_rerank and len(results) > target_docs_per_subquery: if verbose: - print(f" Reranking {len(results)} docs for this subquery to top {target_docs_per_subquery}...") - + print( + f" Reranking {len(results)} docs for this subquery to top {target_docs_per_subquery}...") + # Extract contents for reranking contents = [r.page_content for r in results] scored_passages = rag_db.rerank(sub_query, contents) - + # Reorder results by reranking scores and take top-k - reranked_indices = [i for i, _ in sorted(enumerate(scored_passages), + reranked_indices = [i for i, _ in sorted(enumerate(scored_passages), key=lambda x: x[1][1], reverse=True)] - results = [results[idx] for idx in reranked_indices[:target_docs_per_subquery]] - + results = [results[idx] + for idx in reranked_indices[:target_docs_per_subquery]] + if verbose: - print(f" After reranking: keeping top {len(results)} docs") + print( + f" After reranking: keeping top {len(results)} docs") elif len(results) > target_docs_per_subquery: # No reranking, just limit to target results = results[:target_docs_per_subquery] - + # Add to new_docs for evaluation (avoid duplicates) for result in results: if 'original_url' in result.metadata and result.metadata['original_url']: @@ -1430,27 +1522,30 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], all_retrieved_urls.add(url) new_docs.append((url, result.page_content)) iteration_results.append(result) - + # Track how many NEW docs this query found docs_found_by_query = len(new_docs) - query_start_count per_query_counts.append(docs_found_by_query) - + if verbose: - print(f" Retrieved {len(results)} docs, {docs_found_by_query} new unique docs from this query") + print( + f" Retrieved {len(results)} docs, {docs_found_by_query} new unique docs from this query") for j, result in enumerate(results, 1): url = result.metadata.get('original_url', 'N/A') passage = result.page_content[:300].replace('\n', ' ') print(f" [{j}] {url}\n {passage}...") - + # Add queries and their results to history for sub_query, count in zip(sub_queries, per_query_counts): query_history.append(sub_query) query_results.append(count) - + if verbose: - print(f" Total kept docs: {len(kept_docs)}, new docs to evaluate: {len(new_docs)}") + print( + f" Total kept docs: {len(kept_docs)}, new docs to evaluate: {len(new_docs)}") - # For iteration 1, print status summary now (after retrieval, before next iteration's grading) + # For iteration 1, print status summary now (after retrieval, before + # next iteration's grading) if verbose and iteration == 1: print(f"\n Iteration 1 Summary:") print(f" Generated {len(sub_queries)} new queries") @@ -1458,15 +1553,15 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], iteration_time = time.perf_counter() - iteration_start iteration_times.append(iteration_time) - + if iteration >= max_iterations: if verbose: print(f"\n ⚠ Maximum iterations reached") break - + # Final processing total_time = time.perf_counter() - start_time - + # Extract URLs from kept_docs retrieved_urls = [] for doc in kept_docs: @@ -1474,17 +1569,20 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], retrieved_urls.append(doc[0]) # url is first element elif len(doc) == 2: # Handle old format for backward compatibility retrieved_urls.append(doc[0]) # url is first element - + # Limit to top_k_reranking (reranking already done per-subquery) retrieved_urls = retrieved_urls[:top_k_reranking] - + # Calculate metrics from evaluation import calculate_retrieval_metrics expected_set = set(url for url in expected_urls if url and url.strip()) metrics = calculate_retrieval_metrics(list(expected_set), retrieved_urls) - + # Add iteration statistics - query_llm_time = (llm_end_time - llm_start_time) if (llm_start_time is not None and llm_end_time is not None) else total_time + query_llm_time = ( + llm_end_time - + llm_start_time) if ( + llm_start_time is not None and llm_end_time is not None) else total_time metrics.update({ 'total_time': total_time, 'query_llm_time': query_llm_time, @@ -1495,7 +1593,7 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], 'avg_iteration_time': sum(iteration_times) / len(iteration_times) if iteration_times else 0, 'llm_answer': final_answer, }) - + # Print final results if verbose: print(f"\n{'='*80}") @@ -1509,8 +1607,10 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], print(f"LLM Answer: {final_answer}") if expected_answer: print(f"Expected Answer: {expected_answer}") - print(f"Expected ({len(expected_set)}): {sorted(list(expected_set)[:3])}{'...' if len(expected_set) > 3 else ''}") - print(f"Retrieved ({len(retrieved_urls)} unique docs): {retrieved_urls[:3]}{'...' if len(retrieved_urls) > 3 else ''}") + print( + f"Expected ({len(expected_set)}): {sorted(list(expected_set)[:3])}{'...' if len(expected_set) > 3 else ''}") + print( + f"Retrieved ({len(retrieved_urls)} unique docs): {retrieved_urls[:3]}{'...' if len(retrieved_urls) > 3 else ''}") matches = len(expected_set.intersection(set(retrieved_urls))) print(f"Matches: {matches}") print(f"\nMetrics:") @@ -1519,10 +1619,11 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], print(f" F1@N: {metrics.get('f1@N', 0.0):.3f}") print(f" MAP: {metrics.get('average_precision', 0.0):.3f}") print(f"\nTiming:") - print(f" Avg per iteration: {metrics['avg_iteration_time']*1000:.1f}ms") + print( + f" Avg per iteration: {metrics['avg_iteration_time']*1000:.1f}ms") print(f" Total: {total_time*1000:.1f}ms") print(f"{'='*80}\n") - + return metrics @@ -1543,7 +1644,7 @@ def run_multi_shot_evaluation(rag_db, dataset_path: str, **strategy_params) -> Dict[str, float]: """ Run multi-shot evaluation on a dataset. - + Args: rag_db: RAG database instance dataset_path: Path to dataset TSV file @@ -1558,21 +1659,21 @@ def run_multi_shot_evaluation(rag_db, dataset_path: str, difficulty: Minimum number of answer links required (0 = no filtering) max_iterations: Maximum iterations for iterative retrieval (default: 10) **strategy_params: Additional parameters for retrieval strategy - + Returns: Dictionary of averaged metrics """ - + df = pd.read_csv(dataset_path, sep='\t') - + # Filter by difficulty if specified df = filter_dataset_by_difficulty(df, difficulty) - + if isinstance(max_queries, int) and max_queries > 0: df = df.head(max_queries) else: max_queries = len(df) - + print(f"\n{'='*80}") print(f"MULTI-SHOT EVALUATION") print(f"{'='*80}") @@ -1586,7 +1687,7 @@ def run_multi_shot_evaluation(rag_db, dataset_path: str, if difficulty > 0: print(f"Difficulty filter: >= {difficulty} answer links") print(f"{'='*80}\n") - + eval_wall_start = time.perf_counter() total_metrics = {} valid_queries = 0 @@ -1600,9 +1701,12 @@ def run_multi_shot_evaluation(rag_db, dataset_path: str, for col in df.columns: if col.startswith('wikipedia_link_') and pd.notna(row[col]): expected_urls.append(row[col].strip()) - expected_answer = row.get('Answer', '').strip() if 'Answer' in row and pd.notna(row.get('Answer')) else "" + expected_answer = row.get( + 'Answer', '').strip() if 'Answer' in row and pd.notna( + row.get('Answer')) else "" if expected_urls: - work_items.append((idx, row['Prompt'], expected_urls, expected_answer)) + work_items.append( + (idx, row['Prompt'], expected_urls, expected_answer)) def process_single_query(item): idx, prompt, expected_urls, expected_answer = item @@ -1630,7 +1734,8 @@ def process_single_query(item): ) query_wall_time = metrics.get('total_time', 0.0) - print(f" [QUERY TIMING] Query {idx+1}/{max_queries}: {query_wall_time:.2f}s") + print( + f" [QUERY TIMING] Query {idx+1}/{max_queries}: {query_wall_time:.2f}s") if logger: logger.end_query( @@ -1672,7 +1777,10 @@ def process_single_query(item): # Parallel execution with thread pool print(f"\n Using {num_workers} parallel workers") with ThreadPoolExecutor(max_workers=num_workers) as executor: - futures = {executor.submit(process_single_query, item): item for item in work_items} + futures = { + executor.submit( + process_single_query, + item): item for item in work_items} for future in as_completed(futures): try: idx, metrics, result = future.result() @@ -1689,45 +1797,60 @@ def process_single_query(item): print(f" Error processing query: {e}") import traceback traceback.print_exc() - + if valid_queries > 0: # Calculate averages - avg_metrics = {name: total / valid_queries for name, total in total_metrics.items()} - + avg_metrics = { + name: total / + valid_queries for name, + total in total_metrics.items()} + # Print summary print(f"\n{'='*80}") print(f"MULTI-SHOT EVALUATION SUMMARY ({valid_queries} queries)") print(f"{'='*80}") print(f"\nPRECISION METRICS:") - print(f" Precision@N: {avg_metrics.get('precision@N', 0.0):.3f}") + print( + f" Precision@N: {avg_metrics.get('precision@N', 0.0):.3f}") print(f"\nRECALL METRICS:") - print(f" Recall@N: {avg_metrics.get('recall@N', 0.0):.3f}") + print( + f" Recall@N: {avg_metrics.get('recall@N', 0.0):.3f}") print(f"\nF1 METRICS:") - print(f" F1@N: {avg_metrics.get('f1@N', 0.0):.3f}") + print( + f" F1@N: {avg_metrics.get('f1@N', 0.0):.3f}") print(f"\nRANKING METRICS:") - print(f" Mean Average Precision: {avg_metrics.get('average_precision', 0.0):.3f}") + print( + f" Mean Average Precision: {avg_metrics.get('average_precision', 0.0):.3f}") print(f"\nRETRIEVAL STATISTICS:") - print(f" Avg Sub-queries: {avg_metrics.get('num_sub_queries', 0.0):.1f}") - print(f" Avg Passages Retrieved: {avg_metrics.get('retrieved_passages_count', 0.0):.1f}") - print(f" Avg Unique Docs (N): {avg_metrics.get('retrieved_docs_count', 0.0):.1f}") + print( + f" Avg Sub-queries: {avg_metrics.get('num_sub_queries', 0.0):.1f}") + print( + f" Avg Passages Retrieved: {avg_metrics.get('retrieved_passages_count', 0.0):.1f}") + print( + f" Avg Unique Docs (N): {avg_metrics.get('retrieved_docs_count', 0.0):.1f}") print(f"\nTIMING:") - print(f" Avg Decomposition Time: {avg_metrics.get('decomposition_time', 0.0)*1000:.1f}ms") - print(f" Avg Retrieval Time: {avg_metrics.get('retrieval_time', 0.0)*1000:.1f}ms") + print( + f" Avg Decomposition Time: {avg_metrics.get('decomposition_time', 0.0)*1000:.1f}ms") + print( + f" Avg Retrieval Time: {avg_metrics.get('retrieval_time', 0.0)*1000:.1f}ms") if avg_metrics.get('reranking_time', 0.0) > 0: - print(f" Avg Reranking Time: {avg_metrics.get('reranking_time', 0.0)*1000:.1f}ms") - print(f" Avg Total Time: {avg_metrics.get('total_time', 0.0)*1000:.1f}ms") - print(f" Avg Query LLM Time: {avg_metrics.get('query_llm_time', 0.0)*1000:.1f}ms") + print( + f" Avg Reranking Time: {avg_metrics.get('reranking_time', 0.0)*1000:.1f}ms") + print( + f" Avg Total Time: {avg_metrics.get('total_time', 0.0)*1000:.1f}ms") + print( + f" Avg Query LLM Time: {avg_metrics.get('query_llm_time', 0.0)*1000:.1f}ms") eval_wall_time = time.perf_counter() - eval_wall_start qps = valid_queries / eval_wall_time if eval_wall_time > 0 else 0.0 print(f" Total Wall Time: {eval_wall_time:.1f}s") print(f" Throughput: {qps:.3f} queries/sec") print(f"{'='*80}\n") - + # Print detailed analysis if requested if detailed_analysis and all_query_metrics: from evaluation import _print_detailed_analysis _print_detailed_analysis(df, all_query_metrics, valid_queries) - + avg_metrics['_per_query_results'] = all_results return avg_metrics else: @@ -1738,26 +1861,26 @@ def process_single_query(item): if __name__ == "__main__": args = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="Multi-shot retrieval with query decomposition") - + # Add all standard parameters add_all_args(args) - + # Add multi-shot specific parameters args.add_argument('--max-sub-queries', type=int, default=3, - help='Maximum number of sub-queries to generate (default: 3)') + help='Maximum number of sub-queries to generate (default: 3)') args.add_argument('--reasoning', type=str, default='medium', - choices=['low', 'medium', 'high'], - help='LLM reasoning level for query decomposition (default: medium)') + choices=['low', 'medium', 'high'], + help='LLM reasoning level for query decomposition (default: medium)') args.add_argument('--max-iterations', type=int, default=10, - help='Maximum number of retrieval iterations (default: 10)') + help='Maximum number of retrieval iterations (default: 10)') args.add_argument('--num-workers', type=int, default=1, - help='Number of parallel query workers (default: 1, sequential)') + help='Number of parallel query workers (default: 1, sequential)') args.add_argument('--temperature', type=float, default=1.0, - help='LLM sampling temperature (default: 1.0)') + help='LLM sampling temperature (default: 1.0)') args.add_argument('--max-retries', type=int, default=5, - help='Max retries for LLM calls on rate limit/server errors (default: 5)') + help='Max retries for LLM calls on rate limit/server errors (default: 5)') args.add_argument('--output-dir', type=str, default='.', - help='Directory for output files (default: current directory)') + help='Directory for output files (default: current directory)') # Special handling for --eval argument for action in args._actions: @@ -1765,12 +1888,12 @@ def process_single_query(item): action.type = lambda x: int(x) if x.isdigit() else True action.const = True break - + args = args.parse_args() - + # Set deterministic seeds set_deterministic_seeds(args.seed) - + # Setup LLM configuration with auto-detection llm_config = setup_llm_config(args) llm_config['temperature'] = args.temperature @@ -1797,13 +1920,15 @@ def process_single_query(item): # Setup device-specific environment device_config = get_device_config() print(f"Device Config: {device_config}") - + # Initialize database if args.database is None: args.database = VectorDB.get_default_db_name() - db_file_path = args.database if args.database.endswith('.db') else f"{args.database}.db" - db_base_name = args.database.replace('.db', '') if args.database.endswith('.db') else args.database + db_file_path = args.database if args.database.endswith( + '.db') else f"{args.database}.db" + db_base_name = args.database.replace( + '.db', '') if args.database.endswith('.db') else args.database rag_db = VectorDB( retriever_model=args.retriever_model, @@ -1816,14 +1941,15 @@ def process_single_query(item): reranker_device=args.reranker_device, benchmark=args.benchmark ) - + # Load database if os.path.exists(db_file_path): print(f"Loading existing database from {db_file_path}") rag_db.from_serialized(db_file_path) else: - raise ValueError(f"Database not found: {db_file_path}. Please create it first using single_shot_retrieval.py") - + raise ValueError( + f"Database not found: {db_file_path}. Please create it first using single_shot_retrieval.py") + # Build strategy parameters strategy_params = {"max_results": args.max_results} if args.retrieval_strategy == "top_p": @@ -1834,7 +1960,9 @@ def process_single_query(item): # Initialize LLM logger with incremental writing experiment_start_time = datetime.now() os.makedirs(args.output_dir, exist_ok=True) - log_filename = os.path.join(args.output_dir, f"llm_logs_multi_shot_{experiment_start_time.strftime('%Y%m%d_%H%M%S')}.json") + log_filename = os.path.join( + args.output_dir, + f"llm_logs_multi_shot_{experiment_start_time.strftime('%Y%m%d_%H%M%S')}.json") # Determine chunk size from database name chunk_size = 768 # default @@ -1848,7 +1976,8 @@ def process_single_query(item): llm_logger = LLMLogger( output_file=log_filename, experiment_metadata={ - "experiment_name": f"multi_shot_{db_base_name}_n{{queries}}", # Will be updated + # Will be updated + "experiment_name": f"multi_shot_{db_base_name}_n{{queries}}", "timestamp_start": experiment_start_time.isoformat(), "timestamp_end": "in_progress", "retrieval_mode": "multi_shot", @@ -1867,7 +1996,6 @@ def process_single_query(item): ) print(f"LLM logs will be written incrementally to: {log_filename}") - # Setup threading infrastructure if parallel workers requested if args.num_workers > 1: print(f"Enabling parallel execution with {args.num_workers} workers") @@ -1875,7 +2003,9 @@ def process_single_query(item): # Run evaluation or single query if args.eval: - max_queries = args.eval if isinstance(args.eval, int) and not isinstance(args.eval, bool) and args.eval > 0 else None + max_queries = args.eval if isinstance( + args.eval, int) and not isinstance( + args.eval, bool) and args.eval > 0 else None metrics = run_multi_shot_evaluation( rag_db, args.dataset, @@ -1894,7 +2024,7 @@ def process_single_query(item): num_workers=args.num_workers, **strategy_params ) - + # Save results per_query_results = metrics.pop('_per_query_results', []) results_data = { @@ -1904,7 +2034,7 @@ def process_single_query(item): "metrics": metrics, "results": per_query_results, } - + result_path = os.path.join(args.output_dir, "result_multi_shot.json") with open(result_path, "w") as f: json.dump(results_data, f, indent=2) @@ -1981,5 +2111,6 @@ def process_single_query(item): rq = rag_db._reranker_queue if rq is not None: avg_ms = rq.total_latency_ms / rq.total_requests if rq.total_requests else 0 - print(f"Reranker stats: {rq.total_requests} requests, {rq.total_documents} docs, {rq.total_latency_ms:.0f}ms total, {avg_ms:.1f}ms/request avg") + print( + f"Reranker stats: {rq.total_requests} requests, {rq.total_documents} docs, {rq.total_latency_ms:.0f}ms total, {avg_ms:.1f}ms/request avg") rag_db.shutdown_reranker() diff --git a/e2e-rag/oracle_single_shot.py b/e2e-rag/oracle_single_shot.py index 1a39f7fd65..1496daf1c5 100644 --- a/e2e-rag/oracle_single_shot.py +++ b/e2e-rag/oracle_single_shot.py @@ -46,14 +46,16 @@ DEFAULT_CHECKPOINT_FILE = "oracle_checkpoint.pkl" DEFAULT_SERVICE_URL = "http://localhost:8123/v1/chat/completions" -#DEFAULT_MODEL_NAME = "/mnt/weka/data/pytorch/llama3.3/Meta-Llama-3.3-70B-Instruct" -#DEFAULT_MODEL_NAME = "/mnt/weka/data/pytorch/llama3.1/Meta-Llama-3.1-405B-Instruct-v2" +# DEFAULT_MODEL_NAME = "/mnt/weka/data/pytorch/llama3.3/Meta-Llama-3.3-70B-Instruct" +# DEFAULT_MODEL_NAME = "/mnt/weka/data/pytorch/llama3.1/Meta-Llama-3.1-405B-Instruct-v2" DEFAULT_MODEL_NAME = "/model/gpt-oss-120b-mxfp4" DEFAULT_BATCH_SIZE = 1 DEFAULT_TIMEOUT = 2400 # For reasoning model, it should be large enough -DEFAULT_MAX_TOKENS = 10*1024 -MAX_TOTAL_CHARS = 400000 # total char budget split across all docs per query (~100K tokens @ 4 chars/token) +DEFAULT_MAX_TOKENS = 10 * 1024 +# total char budget split across all docs per query (~100K tokens @ 4 +# chars/token) +MAX_TOTAL_CHARS = 400000 # Global cache for URL to filename mapping _url_to_file_cache: Optional[Dict[str, Path]] = None @@ -145,27 +147,28 @@ def build_url_to_file_cache(wiki_dir: Path) -> Dict[str, Path]: Uses JSON metadata files to get accurate URL mapping. """ cache = {} - + print(f"Building URL cache from {wiki_dir}...") json_files = list(wiki_dir.glob("*.json")) - + for json_file in json_files: try: with open(json_file, 'r') as f: data = json.load(f) - + # Get URL without fragment url = data.get('url', '') source_url = data.get('source_url', '') - + # Remove fragment from source_url if present if '#' in source_url: source_url = source_url.split('#')[0] - + # Get corresponding .txt file txt_file = json_file.with_suffix('.txt') if txt_file.exists(): - # Map both url and source_url (without fragment) to the file + # Map both url and source_url (without fragment) to the + # file if url: cache[url] = txt_file if source_url and source_url != url: @@ -173,7 +176,7 @@ def build_url_to_file_cache(wiki_dir: Path) -> Dict[str, Path]: except Exception as e: # Skip files with errors continue - + print(f"Cached {len(cache)} URL mappings from {len(json_files)} JSON files") return cache @@ -184,27 +187,28 @@ def find_wiki_article(url: str, wiki_dir: Path) -> Optional[Path]: Uses JSON metadata for accurate matching. """ global _url_to_file_cache - + if not url or "wikipedia.org/wiki/" not in url: return None - + # Build cache on first call if _url_to_file_cache is None: _url_to_file_cache = build_url_to_file_cache(wiki_dir) - + # Remove fragment from URL if present url_no_fragment = url.split('#')[0] - + # Look up in cache if url in _url_to_file_cache: return _url_to_file_cache[url] elif url_no_fragment in _url_to_file_cache: return _url_to_file_cache[url_no_fragment] - + return None -def load_wiki_articles(wiki_urls: List[str], wiki_dir: Path, max_chars: int = MAX_TOTAL_CHARS) -> Tuple[List[str], List[str], List[int]]: +def load_wiki_articles(wiki_urls: List[str], wiki_dir: Path, + max_chars: int = MAX_TOTAL_CHARS) -> Tuple[List[str], List[str], List[int]]: """ Load Wikipedia articles from wiki_articles folder. max_chars is the TOTAL character budget shared across all docs for this query. @@ -225,7 +229,8 @@ def load_wiki_articles(wiki_urls: List[str], wiki_dir: Path, max_chars: int = MA if file_path and file_path.exists(): try: - content = file_path.read_text(encoding="utf-8", errors="ignore") + content = file_path.read_text( + encoding="utf-8", errors="ignore") truncated = content[:per_doc_limit] documents.append(truncated) file_paths.append(str(file_path)) @@ -239,14 +244,15 @@ def load_wiki_articles(wiki_urls: List[str], wiki_dir: Path, max_chars: int = MA documents.append("") file_paths.append("") doc_lengths.append(0) - + return documents, file_paths, doc_lengths -def generate_llm_answer(query: str, documents: List[str], urls: List[str], llm_config: Dict) -> str: +def generate_llm_answer( + query: str, documents: List[str], urls: List[str], llm_config: Dict) -> str: """ Generate LLM answer using the provided documents as context. - + This function is adapted from single_shot_retrieval.py _generate_llm_answer """ context_parts = [] @@ -255,15 +261,16 @@ def generate_llm_answer(query: str, documents: List[str], urls: List[str], llm_c source = url or "Unknown source" snippet = doc.strip() context_parts.append(f"[{idx}] Source: {source}\n{snippet}") - - evidence_block = "\n\n".join(context_parts) if context_parts else "No supporting documents were retrieved." - + + evidence_block = "\n\n".join( + context_parts) if context_parts else "No supporting documents were retrieved." + user_prompt = ( "Answer the question using only the provided evidence." " Respond with a single word or short phrase, or 'Unknown' if the evidence is insufficient.\n\n" f"Question:\n{query}\n\nEvidence:\n{evidence_block}" ) - + payload = { "model": llm_config["model_name"], "messages": [ @@ -283,11 +290,14 @@ def generate_llm_answer(query: str, documents: List[str], urls: List[str], llm_c if llm_config.get("reasoning_effort"): payload["reasoning_effort"] = llm_config["reasoning_effort"] - response = requests.post(llm_config["service_url"], json=payload, timeout=llm_config["timeout"]) + response = requests.post( + llm_config["service_url"], + json=payload, + timeout=llm_config["timeout"]) response.raise_for_status() data = response.json() - #from pprint import pprint - #pprint(data, indent=4) + # from pprint import pprint + # pprint(data, indent=4) return data["choices"][0]["message"]["content"].strip() @@ -315,7 +325,7 @@ def parse_wiki_links(wiki_links_str: str) -> List[str]: try: # Use ast.literal_eval to safely parse the string as a Python literal return ast.literal_eval(wiki_links_str) - except: + except BaseException: return [] @@ -344,7 +354,8 @@ def process_single( } try: - documents, file_paths, doc_lengths = load_wiki_articles(wiki_urls, wiki_dir) + documents, file_paths, doc_lengths = load_wiki_articles( + wiki_urls, wiki_dir) result["wiki_file_paths"] = str(file_paths) result["doc_lengths"] = str(doc_lengths) result["total_doc_length"] = sum(doc_lengths) @@ -354,9 +365,11 @@ def process_single( result["num_missing_docs"] = missing_count if missing_count > 0: - print(f" Query {idx}: {missing_count}/{len(wiki_urls)} documents missing") + print( + f" Query {idx}: {missing_count}/{len(wiki_urls)} documents missing") - llm_answer = generate_llm_answer(query, documents, wiki_urls, llm_config) + llm_answer = generate_llm_answer( + query, documents, wiki_urls, llm_config) result["llm_answer"] = llm_answer result["success"] = True @@ -386,7 +399,14 @@ def process_batch( futures_map = {} with ThreadPoolExecutor(max_workers=len(batch_data)) as executor: for idx, query, ground_truth, wiki_urls in batch_data: - future = executor.submit(process_single, idx, query, ground_truth, wiki_urls, wiki_dir, llm_config) + future = executor.submit( + process_single, + idx, + query, + ground_truth, + wiki_urls, + wiki_dir, + llm_config) futures_map[future] = idx results_map = {} @@ -400,38 +420,40 @@ def process_batch( def main(): args = parse_args() - + # Setup paths dataset_path = Path(args.dataset) wiki_dir = Path(args.wiki_articles_dir) checkpoint_file = Path(args.checkpoint_file) - + # Validate paths if not dataset_path.exists(): raise FileNotFoundError(f"Dataset not found: {dataset_path}") if not wiki_dir.exists(): - raise FileNotFoundError(f"Wiki articles directory not found: {wiki_dir}") + raise FileNotFoundError( + f"Wiki articles directory not found: {wiki_dir}") # Pre-build URL cache once before threads start global _url_to_file_cache _url_to_file_cache = build_url_to_file_cache(wiki_dir) - + # Load dataset print(f"Loading dataset from {dataset_path}...") df = pd.read_csv(dataset_path, sep="\t") - + # Apply max_queries limit if specified if args.max_queries: df = df.head(args.max_queries) - + print(f"Total queries in dataset: {len(df)}") - + # Load checkpoint checkpoint_df = load_checkpoint(checkpoint_file) - processed_indices = set(checkpoint_df["index"].tolist()) if not checkpoint_df.empty else set() - + processed_indices = set( + checkpoint_df["index"].tolist()) if not checkpoint_df.empty else set() + print(f"Already processed: {len(processed_indices)} queries") - + # LLM configuration llm_config = { "service_url": args.service_url, @@ -441,16 +463,16 @@ def main(): "enable_thinking": args.enable_thinking, "reasoning_effort": args.reasoning_effort, } - + # Determine which queries to process if args.retry_failed: # Retry only failed queries if not checkpoint_df.empty: failed_df = checkpoint_df[checkpoint_df["success"] == False] - queries_to_process = [(int(row["index"]), df.iloc[int(row["index"])]["Prompt"], + queries_to_process = [(int(row["index"]), df.iloc[int(row["index"])]["Prompt"], df.iloc[int(row["index"])]["Answer"], parse_wiki_links(df.iloc[int(row["index"])]["wiki_links"])) - for _, row in failed_df.iterrows()] + for _, row in failed_df.iterrows()] print(f"Retrying {len(queries_to_process)} failed queries...") else: queries_to_process = [] @@ -460,85 +482,93 @@ def main(): for idx, row in df.iterrows(): if idx not in processed_indices: wiki_urls = parse_wiki_links(row["wiki_links"]) - queries_to_process.append((idx, row["Prompt"], row["Answer"], wiki_urls)) - + queries_to_process.append( + (idx, row["Prompt"], row["Answer"], wiki_urls)) + print(f"Processing {len(queries_to_process)} new queries...") - + if not queries_to_process: # If no new queries and all done, check for failed ones if not args.retry_failed: - failed_count = len(checkpoint_df[checkpoint_df["success"] == False]) if not checkpoint_df.empty else 0 + failed_count = len( + checkpoint_df[checkpoint_df["success"] == False]) if not checkpoint_df.empty else 0 if failed_count > 0: - print(f"\nAll new queries processed. {failed_count} queries failed.") + print( + f"\nAll new queries processed. {failed_count} queries failed.") print("Run with --retry-failed to retry failed queries.") else: print("\nAll queries successfully processed!") else: print("No failed queries to retry!") return - + # Process in batches batch_size = args.batch_size total_batches = (len(queries_to_process) + batch_size - 1) // batch_size - + print(f"Batch size: {batch_size}") print(f"Total batches: {total_batches}") print(f"Service URL: {llm_config['service_url']}") print(f"Model: {llm_config['model_name']}\n") - + for batch_idx in range(total_batches): start_idx = batch_idx * batch_size end_idx = min(start_idx + batch_size, len(queries_to_process)) batch = queries_to_process[start_idx:end_idx] - + print(f"Processing batch {batch_idx + 1}/{total_batches} " f"(queries {start_idx + 1}-{end_idx})...") - + # Process batch batch_results = process_batch(batch, wiki_dir, llm_config) - + # Convert batch results to DataFrame batch_df = pd.DataFrame(batch_results) - + # Update checkpoint if args.retry_failed: # For retry, update existing results # Remove old entries for these indices indices_to_update = batch_df["index"].tolist() - checkpoint_df = checkpoint_df[~checkpoint_df["index"].isin(indices_to_update)] + checkpoint_df = checkpoint_df[~checkpoint_df["index"].isin( + indices_to_update)] # Append new results - checkpoint_df = pd.concat([checkpoint_df, batch_df], ignore_index=True) + checkpoint_df = pd.concat( + [checkpoint_df, batch_df], ignore_index=True) else: # For new queries, append results - checkpoint_df = pd.concat([checkpoint_df, batch_df], ignore_index=True) - + checkpoint_df = pd.concat( + [checkpoint_df, batch_df], ignore_index=True) + # Sort by index for consistency - checkpoint_df = checkpoint_df.sort_values("index").reset_index(drop=True) - + checkpoint_df = checkpoint_df.sort_values( + "index").reset_index(drop=True) + # Save checkpoint after each batch save_checkpoint(checkpoint_file, checkpoint_df) print(f" Checkpoint saved to {checkpoint_file}") - + # Show batch statistics success_count = batch_df["success"].sum() print(f" Batch success rate: {success_count}/{len(batch_results)}\n") - + # Final statistics - print("\n" + "="*60) + print("\n" + "=" * 60) print("Processing complete!") - print("="*60) - + print("=" * 60) + total_processed = len(checkpoint_df) total_success = checkpoint_df["success"].sum() total_failed = total_processed - total_success - + print(f"Total queries processed: {total_processed}") print(f"Successful: {total_success}") print(f"Failed: {total_failed}") - + if total_failed > 0: - print(f"\nRun with --retry-failed to retry {total_failed} failed queries.") - + print( + f"\nRun with --retry-failed to retry {total_failed} failed queries.") + print(f"\nResults saved to: {checkpoint_file}") diff --git a/e2e-rag/params.py b/e2e-rag/params.py index b59e573937..7333feac5e 100644 --- a/e2e-rag/params.py +++ b/e2e-rag/params.py @@ -22,7 +22,7 @@ Usage: from params import add_all_args, add_common_args, add_retrieval_args - + parser = argparse.ArgumentParser() add_all_args(parser) # Add all parameters # OR @@ -37,9 +37,12 @@ # ============================================================================ # Parameter Definitions # ============================================================================ + + class ParamDef: """Parameter definition with metadata.""" - def __init__(self, + + def __init__(self, name: str, arg_names: List[str], type: type, @@ -76,35 +79,36 @@ def __init__(self, self.category = category self.applies_to = applies_to or ["both"] self.optuna_suggest = optuna_suggest - + def add_to_parser(self, parser: argparse.ArgumentParser): """Add this parameter to an argument parser.""" kwargs = { 'help': self.help, 'default': self.default, } - + if self.action: kwargs['action'] = self.action else: kwargs['type'] = self.type - + if self.choices: kwargs['choices'] = self.choices - + if self.nargs: kwargs['nargs'] = self.nargs - + parser.add_argument(*self.arg_names, **kwargs) - + def suggest_value(self, trial): """Suggest a value for Optuna trial.""" if not self.optuna_suggest: - raise ValueError(f"No optuna_suggest config for parameter {self.name}") - + raise ValueError( + f"No optuna_suggest config for parameter {self.name}") + config = self.optuna_suggest suggest_type = config['type'] - + if suggest_type == 'float': return trial.suggest_float( self.name, @@ -477,7 +481,12 @@ def suggest_value(self, trial): choices=["fixed_k", "top_p", "relative"], category="strategy", applies_to=["both"], - optuna_suggest={'type': 'categorical', 'choices': ["fixed_k", "top_p", "relative"]} + optuna_suggest={ + 'type': 'categorical', + 'choices': [ + "fixed_k", + "top_p", + "relative"]} ), ParamDef( name="top_k_retriever", @@ -557,8 +566,10 @@ def suggest_value(self, trial): } # Method-specific parameters -BM25_METHOD_PARAMS = [p for p in ALL_PARAMS if "bm25" in p.applies_to or "both" in p.applies_to] -VECTOR_METHOD_PARAMS = [p for p in ALL_PARAMS if "vector" in p.applies_to or "both" in p.applies_to] +BM25_METHOD_PARAMS = [ + p for p in ALL_PARAMS if "bm25" in p.applies_to or "both" in p.applies_to] +VECTOR_METHOD_PARAMS = [ + p for p in ALL_PARAMS if "vector" in p.applies_to or "both" in p.applies_to] # Optimizable parameters (those with optuna_suggest defined) OPTIMIZABLE_PARAMS = [p for p in ALL_PARAMS if p.optuna_suggest is not None] @@ -568,24 +579,26 @@ def suggest_value(self, trial): # Helper Functions # ============================================================================ + def add_common_args(parser: argparse.ArgumentParser): """ Add common script parameters (ingest, database, query, etc.) - + Args: parser: ArgumentParser to add arguments to """ for param in COMMON_PARAMS: param.add_to_parser(parser) -def add_retrieval_args(parser: argparse.ArgumentParser, + +def add_retrieval_args(parser: argparse.ArgumentParser, method: Optional[str] = None, categories: Optional[List[str]] = None): """ Add retrieval parameters to an argument parser. Includes: General, BM25, Vector, Strategy, and Reranking parameters. Does NOT include common script parameters (use add_common_args for those). - + Args: parser: ArgumentParser to add arguments to method: Filter by method ('bm25', 'vector', or None for all) @@ -593,24 +606,26 @@ def add_retrieval_args(parser: argparse.ArgumentParser, """ # Get non-common params params_to_add = [p for p in ALL_PARAMS if p.category != "common"] - + # Filter by method if method: - params_to_add = [p for p in params_to_add - if method in p.applies_to or "both" in p.applies_to] - + params_to_add = [p for p in params_to_add + if method in p.applies_to or "both" in p.applies_to] + # Filter by category if categories: params_to_add = [p for p in params_to_add if p.category in categories] - + # Add to parser for param in params_to_add: param.add_to_parser(parser) -def add_all_args(parser: argparse.ArgumentParser, method: Optional[str] = None): + +def add_all_args(parser: argparse.ArgumentParser, + method: Optional[str] = None): """ Add all parameters (common + retrieval) to an argument parser. - + Args: parser: ArgumentParser to add arguments to method: Filter by method ('bm25', 'vector', or None for all) @@ -618,48 +633,51 @@ def add_all_args(parser: argparse.ArgumentParser, method: Optional[str] = None): add_common_args(parser) add_retrieval_args(parser, method=method) + def get_optimizable_params(method: Optional[str] = None) -> List[ParamDef]: """ Get list of parameters that can be optimized with Optuna. - + Args: method: Filter by method ('bm25', 'vector', or None for all) - + Returns: List of ParamDef objects """ params = OPTIMIZABLE_PARAMS - + if method: - params = [p for p in params - if method in p.applies_to or "both" in p.applies_to] - + params = [p for p in params + if method in p.applies_to or "both" in p.applies_to] + return params + def suggest_param(trial, param_name: str) -> Any: """ Suggest a parameter value for Optuna trial. - + Args: trial: Optuna trial object param_name: Parameter name - + Returns: Suggested value """ if param_name not in PARAM_BY_NAME: raise ValueError(f"Unknown parameter: {param_name}") - + param = PARAM_BY_NAME[param_name] return param.suggest_value(trial) + def get_default_params(method: str) -> Dict[str, Any]: """ Get default parameter values for a method. - + Args: method: 'bm25' or 'vector' - + Returns: Dictionary of parameter name -> default value """ @@ -669,34 +687,36 @@ def get_default_params(method: str) -> Dict[str, Any]: params = VECTOR_METHOD_PARAMS else: params = ALL_PARAMS - + return {p.name: p.default for p in params} -def format_params_for_cli(params: Dict[str, Any], skip_defaults: bool = True) -> List[str]: + +def format_params_for_cli( + params: Dict[str, Any], skip_defaults: bool = True) -> List[str]: """ Format parameter dictionary as CLI arguments. - + Args: params: Dictionary of parameter name -> value skip_defaults: If True, skip parameters that match their default values - + Returns: List of CLI argument strings """ args = [] - + for name, value in params.items(): if name not in PARAM_BY_NAME: continue - + param_def = PARAM_BY_NAME[name] - + # Skip if value matches default (when skip_defaults=True) if skip_defaults and value == param_def.default: continue - + cli_arg = param_def.arg_names[0] - + if param_def.action == "store_true": if value: args.append(cli_arg) @@ -705,40 +725,42 @@ def format_params_for_cli(params: Dict[str, Any], skip_defaults: bool = True) -> args.append(cli_arg) elif value is not None: args.extend([cli_arg, str(value)]) - + return args + def print_param_info(method: Optional[str] = None): """Print parameter information grouped by category.""" - + if method == "bm25": params = BM25_METHOD_PARAMS elif method == "vector": params = VECTOR_METHOD_PARAMS else: params = ALL_PARAMS - + # Group by category by_category = {} for param in params: if param.category not in by_category: by_category[param.category] = [] by_category[param.category].append(param) - + # Print for category in ["common", "general", "vector", "strategy", "reranking"]: if category not in by_category: continue - + print(f"\n{category.upper()} Parameters:") print("=" * 60) - + for param in by_category[category]: opt_marker = " [optimizable]" if param.optuna_suggest else "" print(f" {param.name}{opt_marker}") print(f" CLI: {', '.join(param.arg_names)}") print(f" Default: {param.default}") - print(f" Help: {param.help[:80]}..." if len(param.help) > 80 else f" Help: {param.help}") + print(f" Help: {param.help[:80]}..." if len( + param.help) > 80 else f" Help: {param.help}") if param.choices: print(f" Choices: {param.choices}") @@ -746,9 +768,10 @@ def print_param_info(method: Optional[str] = None): # Main (for testing/documentation) # ============================================================================ + if __name__ == "__main__": import sys - + if len(sys.argv) > 1 and sys.argv[1] == "list": method = sys.argv[2] if len(sys.argv) > 2 else None print_param_info(method) diff --git a/e2e-rag/perf_test_cache.py b/e2e-rag/perf_test_cache.py index 09be338e8f..0a29451071 100644 --- a/e2e-rag/perf_test_cache.py +++ b/e2e-rag/perf_test_cache.py @@ -89,7 +89,8 @@ def _build_index(self): print(f" Built cache index with {len(self.cache)} LLM responses") - def get_response(self, query_id: str, component: str, hop_count: int) -> Optional[str]: + def get_response(self, query_id: str, component: str, + hop_count: int) -> Optional[str]: """ Retrieve cached LLM response. @@ -104,7 +105,8 @@ def get_response(self, query_id: str, component: str, hop_count: int) -> Optiona key = (str(query_id), component, hop_count) return self.cache.get(key) - def has_response(self, query_id: str, component: str, hop_count: int) -> bool: + def has_response(self, query_id: str, component: str, + hop_count: int) -> bool: """ Check if cached response exists. diff --git a/e2e-rag/read_docs.py b/e2e-rag/read_docs.py index 12ba80272f..9859e6dd1a 100644 --- a/e2e-rag/read_docs.py +++ b/e2e-rag/read_docs.py @@ -47,12 +47,12 @@ class BaseDocumentExtractor(ABC): """Base class for document text extractors.""" - + @abstractmethod def extract_text(self, file_path: str) -> Optional[str]: """Extract text from a document file.""" pass - + @abstractmethod def get_supported_extensions(self) -> List[str]: """Return list of supported file extensions.""" @@ -61,7 +61,7 @@ def get_supported_extensions(self) -> List[str]: class PDFExtractor(BaseDocumentExtractor): """Extract text from PDF files using PyMuPDF (fitz).""" - + def extract_text(self, file_path: str) -> Optional[str]: """Extract text from a single PDF file.""" try: @@ -74,81 +74,83 @@ def extract_text(self, file_path: str) -> Optional[str]: doc.close() return "\n".join(extracted_text) - + except Exception as e: print(f"Error processing PDF {file_path}: {e}") return None - + def get_supported_extensions(self) -> List[str]: return ['.pdf'] class HTMLExtractor(BaseDocumentExtractor): """Extract text from HTML files using BeautifulSoup with focus on retrieval quality.""" - - def __init__(self, preserve_tables: bool = True, preserve_lists: bool = True, + + def __init__(self, preserve_tables: bool = True, preserve_lists: bool = True, text_boundary: str = "sentence"): """ Initialize HTML extractor with configurable options. - + Args: preserve_tables: Whether to preserve table structure - preserve_lists: Whether to preserve list structure + preserve_lists: Whether to preserve list structure text_boundary: Text boundary optimization - "sentence" (default), "word", or "none" """ if BeautifulSoup is None: - raise ImportError("BeautifulSoup is required for HTML processing. Install with: pip install beautifulsoup4") - + raise ImportError( + "BeautifulSoup is required for HTML processing. Install with: pip install beautifulsoup4") + self.preserve_tables = preserve_tables self.preserve_lists = preserve_lists self.text_boundary = text_boundary - + if text_boundary not in ["sentence", "word", "none"]: - raise ValueError("text_boundary must be 'sentence', 'word', or 'none'") - + raise ValueError( + "text_boundary must be 'sentence', 'word', or 'none'") + def extract_text(self, file_path: str) -> Optional[str]: """Extract text from a single HTML file.""" try: with open(file_path, 'r', encoding='utf-8') as f: html_content = f.read() - + return self.extract_text_from_html(html_content) - + except Exception as e: print(f"Error processing HTML {file_path}: {e}") return None - + def extract_text_from_html(self, html_content: str) -> str: """Extract clean text optimized for retrieval systems.""" # Use lxml parser for speed (fallback to html.parser if not available) try: soup = BeautifulSoup(html_content, 'lxml') - except: + except BaseException: soup = BeautifulSoup(html_content, 'html.parser') - + # Remove noise elements completely for element in soup(['script', 'style', 'nav', 'header', 'footer']): element.decompose() - + # Remove Wikipedia-specific metadata and navigation self._remove_wikipedia_metadata(soup) - + # Extract main content using priority order main_content = self._find_main_content(soup) - + # Get plain text with sentence separation if main_content: text = main_content.get_text(separator=' ', strip=True) else: text = soup.get_text(separator=' ', strip=True) - + # Clean and normalize the text return self._clean_text(text) - + def _extract_from_element(self, element) -> List[str]: """Extract text from an HTML element, preserving structure.""" text_parts = [] - + if isinstance(element, NavigableString): text = str(element).strip() if text: @@ -186,9 +188,9 @@ def _extract_from_element(self, element) -> List[str]: text = element.get_text().strip() if text: text_parts.append(text) - + return text_parts - + def _extract_table_text(self, table) -> str: """Extract text from a table element.""" rows = [] @@ -200,7 +202,7 @@ def _extract_table_text(self, table) -> str: if cells: rows.append(' | '.join(cells)) return '\n'.join(rows) - + def _extract_list_text(self, list_elem) -> str: """Extract text from a list element.""" items = [] @@ -210,75 +212,79 @@ def _extract_list_text(self, list_elem) -> str: prefix = '- ' if list_elem.name == 'ul' else f"{len(items) + 1}. " items.append(f"{prefix}{item_text}") return '\n'.join(items) - + def _clean_text(self, text: str) -> str: """Clean and normalize extracted text with configurable boundary optimization.""" # Basic normalization import unicodedata text = unicodedata.normalize('NFKC', text) - + # Replace various whitespace characters with standard space text = re.sub(r'[\u00A0\u2000-\u200B\u2028\u2029]', ' ', text) - + # Clean up whitespace text = text.strip().replace('\r', '\n') text = re.sub(r' +', ' ', text) # Multiple spaces -> single space - text = re.sub(r'\n+', '\n', text) # Multiple newlines -> single newline - + # Multiple newlines -> single newline + text = re.sub(r'\n+', '\n', text) + # Remove empty lines and extra spacing lines = [line.strip() for line in text.split('\n') if line.strip()] text = '\n'.join(lines) - + # Apply boundary optimization based on setting if self.text_boundary == "sentence": text = self._optimize_sentence_boundaries(text) elif self.text_boundary == "word": text = self._optimize_word_boundaries(text) # "none" - no boundary optimization - + return text - + def _optimize_sentence_boundaries(self, text: str) -> str: """Optimize text for sentence-level splitting and retrieval.""" # Add space after sentence endings if missing text = re.sub(r'([.!?])([A-Z])', r'\1 \2', text) - + # Handle common abbreviations that shouldn't split sentences # (e.g., "Mr.", "Dr.", "etc.", "U.S.") abbrev_pattern = r'\b(Mr|Mrs|Dr|Prof|etc|vs|Inc|Ltd|Corp|U\.S|U\.K|E\.g|I\.e)\.(\s+)([a-z])' text = re.sub(abbrev_pattern, r'\1.\2\3', text, flags=re.IGNORECASE) - + return text - + def _optimize_word_boundaries(self, text: str) -> str: """Optimize text for word-level processing and retrieval.""" # Ensure proper spacing around punctuation for better tokenization text = re.sub(r'([.!?,:;])([A-Za-z])', r'\1 \2', text) - + # Handle hyphenated words - keep them as single tokens text = re.sub(r'(\w+)-\s+(\w+)', r'\1-\2', text) - + # Normalize quotation marks and other punctuation - text = text.replace('"', '"').replace('"', '"') # Smart quotes to regular quotes - text = text.replace(''', "'").replace(''', "'") # Smart apostrophes to regular apostrophes - + text = text.replace( + '"', '"').replace( + '"', '"') # Smart quotes to regular quotes + text = text.replace(''', "'").replace(''', + "'") # Smart apostrophes to regular apostrophes + # Ensure consistent spacing text = re.sub(r'\s+', ' ', text) - + return text - + def _remove_wikipedia_metadata(self, soup): """Remove Wikipedia-specific metadata and navigation elements.""" # Wikipedia-specific noise removal selectors_to_remove = [ # Navigation and interface elements - '#mw-navigation', '.navbox', '.navigation-box', + '#mw-navigation', '.navbox', '.navigation-box', '.ambox', '.tmbox', # Edit links and metadata '.mw-editsection', '.edit-section', '.editlink', # References and citations (keep text but remove citation numbers) 'sup.reference', '.reference', '.citation', - # Disambiguation and hatnotes + # Disambiguation and hatnotes '.hatnote', '.dablink', '.rellink', # Categories and external links boxes '#catlinks', '.catlinks', '.external-links', @@ -287,11 +293,11 @@ def _remove_wikipedia_metadata(self, soup): # Image captions and metadata (keep main text) '.thumbcaption .metadata', '.image-metadata' ] - + for selector in selectors_to_remove: for element in soup.select(selector): element.decompose() - + def _find_main_content(self, soup): """Find the main content area with fallback strategy.""" # Priority order for content detection @@ -304,31 +310,31 @@ def _find_main_content(self, soup): '#content', # Generic content ID 'body' # Last resort ] - + for selector in content_selectors: content = soup.select_one(selector) if content: return content - + # Final fallback return soup - + def get_supported_extensions(self) -> List[str]: return ['.html', '.htm'] class DocumentProcessor: """Unified document processor that handles both PDF and HTML files.""" - - def __init__(self, preserve_tables: bool = True, preserve_lists: bool = True, + + def __init__(self, preserve_tables: bool = True, preserve_lists: bool = True, text_boundary: str = "sentence", benchmark: bool = False, processes: int = 4): """ Initialize document processor. - + Args: preserve_tables: Whether to preserve table structure (HTML only) - preserve_lists: Whether to preserve list structure (HTML only) + preserve_lists: Whether to preserve list structure (HTML only) text_boundary: Text boundary optimization - "sentence" (default), "word", or "none" benchmark: Enable performance monitoring processes: Number of parallel processes for document processing @@ -337,62 +343,64 @@ def __init__(self, preserve_tables: bool = True, preserve_lists: bool = True, self.extractors = { '.pdf': PDFExtractor(), } - + # Only add HTML extractor if BeautifulSoup is available if BeautifulSoup is not None: self.extractors.update({ '.html': HTMLExtractor(preserve_tables, preserve_lists, text_boundary), '.htm': HTMLExtractor(preserve_tables, preserve_lists, text_boundary), }) - + self.url_mapping = {} self.benchmark = benchmark self.monitor = None - + # Store config for worker processes self.preserve_tables = preserve_tables self.preserve_lists = preserve_lists self.text_boundary = text_boundary - + # Initialize monitoring if benchmark mode enabled if self.benchmark: from ingestion_monitor import IngestionMonitor self.monitor = IngestionMonitor() - + def get_supported_extensions(self) -> List[str]: """Get all supported file extensions.""" extensions = [] for extractor in self.extractors.values(): extensions.extend(extractor.get_supported_extensions()) return list(set(extensions)) - + @staticmethod - def process_single_file(args_tuple: Tuple) -> Optional[Tuple[str, str, List[str], str]]: + def process_single_file( + args_tuple: Tuple) -> Optional[Tuple[str, str, List[str], str]]: """ Process a single document file (worker function for multiprocessing). - + Args: - args_tuple: (doc_file_path, output_dir, url_mapping, preserve_tables, + args_tuple: (doc_file_path, output_dir, url_mapping, preserve_tables, preserve_lists, text_boundary, fixed_length, fixed_overlap, max_passage_length, passage_overlap) - + Returns: Tuple of (output_filename, text, passages, original_url) or None if processing failed """ - (doc_file_path, output_dir, url_mapping, preserve_tables, preserve_lists, + (doc_file_path, output_dir, url_mapping, preserve_tables, preserve_lists, text_boundary, fixed_length, fixed_overlap, max_passage_length, passage_overlap) = args_tuple - + doc_file = Path(doc_file_path) file_extension = doc_file.suffix.lower() - + # Create appropriate extractor if file_extension == '.pdf': extractor = PDFExtractor() elif file_extension in ['.html', '.htm'] and BeautifulSoup is not None: - extractor = HTMLExtractor(preserve_tables, preserve_lists, text_boundary) + extractor = HTMLExtractor( + preserve_tables, preserve_lists, text_boundary) else: return None - + # Extract text try: text = extractor.extract_text(str(doc_file)) @@ -401,33 +409,35 @@ def process_single_file(args_tuple: Tuple) -> Optional[Tuple[str, str, List[str] except Exception as e: print(f"Error extracting text from {doc_file}: {e}") return None - + # Split text into passages try: if fixed_length: - passages = split_into_fixed_passages(text, fixed_length, fixed_overlap or 32) + passages = split_into_fixed_passages( + text, fixed_length, fixed_overlap or 32) else: - passages = split_into_passages(text, max_passage_length, passage_overlap) + passages = split_into_passages( + text, max_passage_length, passage_overlap) except Exception as e: print(f"Error splitting text for {doc_file}: {e}") return None - + # Get original URL base_filename = get_base_filename(doc_file.name) original_url = url_mapping.get(base_filename, "") - + # Generate output filename output_filename = doc_file.stem + ".txt" - + return (output_filename, text, passages, original_url, doc_file.name) - + def process_documents(self, input_dir: str, output_dir: str, json_file: Optional[str] = None, - max_passage_length: int = 512, passage_overlap: int = 50, - fixed_length: Optional[int] = None, fixed_overlap: Optional[int] = None, - max_files: Optional[int] = None): + max_passage_length: int = 512, passage_overlap: int = 50, + fixed_length: Optional[int] = None, fixed_overlap: Optional[int] = None, + max_files: Optional[int] = None): """ Process documents in a directory, extracting text and splitting into passages. - + Args: input_dir: Directory containing document files output_dir: Directory to save extracted text files @@ -447,22 +457,23 @@ def process_documents(self, input_dir: str, output_dir: str, json_file: Optional # Find all supported document files supported_extensions = self.get_supported_extensions() document_files = [] - + for ext in supported_extensions: pattern = f"*{ext}" document_files.extend(input_path.glob(pattern)) - + # Sort for consistent processing order document_files = sorted(document_files) - + if not document_files: return - + if max_files: document_files = document_files[:max_files] - - print(f"Processing {len(document_files)} documents with {self.processes} parallel processes...") - + + print( + f"Processing {len(document_files)} documents with {self.processes} parallel processes...") + all_passages = [] passage_id = 0 @@ -472,22 +483,23 @@ def process_documents(self, input_dir: str, output_dir: str, json_file: Optional # Prepare arguments for multiprocessing process_args = [ - (str(doc_file), str(output_path), self.url_mapping, + (str(doc_file), str(output_path), self.url_mapping, self.preserve_tables, self.preserve_lists, self.text_boundary, fixed_length, fixed_overlap, max_passage_length, passage_overlap) for doc_file in document_files ] - + # Process documents in parallel with progress bar with Pool(processes=self.processes) as pool: with tqdm(total=len(document_files), desc="Processing documents") as pbar: - for result in pool.imap(self.process_single_file, process_args): + for result in pool.imap( + self.process_single_file, process_args): if result is None: pbar.update(1) continue - + output_filename, text, passages, original_url, doc_filename = result - + # Save text file output_file_path = output_path / output_filename try: @@ -497,19 +509,19 @@ def process_documents(self, input_dir: str, output_dir: str, json_file: Optional print(f"Error writing {output_file_path}: {e}") pbar.update(1) continue - + # Add passages to collection if JSON output requested if json_file: for passage in passages: passage_metadata = create_passage_metadata( doc_filename, passage_id, original_url=original_url) - + all_passages.append({ **passage_metadata, 'passage': passage, }) passage_id += 1 - + pbar.update(1) # Finalize monitoring and report @@ -538,65 +550,72 @@ def process_documents(self, input_dir: str, output_dir: str, json_file: Optional # Add component-level timing if monitoring was enabled if self.benchmark and self.monitor: for component_name, metrics in self.monitor.components.items(): - result[f'{component_name}_time_seconds'] = round(metrics.duration, 2) - result[f'{component_name}_throughput_mb_per_sec'] = round(metrics.throughput_mb_per_sec, 2) + result[f'{component_name}_time_seconds'] = round( + metrics.duration, 2) + result[f'{component_name}_throughput_mb_per_sec'] = round( + metrics.throughput_mb_per_sec, 2) return result - - def _process_document(self, doc_file: Path, file_extension: str) -> Optional[str]: + + def _process_document(self, doc_file: Path, + file_extension: str) -> Optional[str]: """Process a single document with optional monitoring.""" extractor = self.extractors[file_extension] - + if self.benchmark and self.monitor: - component_name = "html_parsing" if file_extension in ['.html', '.htm'] else "pdf_parsing" + component_name = "html_parsing" if file_extension in [ + '.html', '.htm'] else "pdf_parsing" file_size = doc_file.stat().st_size - with self.monitor.track_component(component_name, input_size_bytes=file_size, - items_count=1, is_pipeline_input=True): + with self.monitor.track_component(component_name, input_size_bytes=file_size, + items_count=1, is_pipeline_input=True): return extractor.extract_text(str(doc_file)) else: return extractor.extract_text(str(doc_file)) - + def _process_text_chunking(self, text: str, fixed_length: Optional[int], fixed_overlap: Optional[int], - max_passage_length: int, passage_overlap: int) -> List[str]: + max_passage_length: int, passage_overlap: int) -> List[str]: """Process text chunking with optional monitoring.""" def chunk_func(): if fixed_length: - return split_into_fixed_passages(text, fixed_length, fixed_overlap or 32) + return split_into_fixed_passages( + text, fixed_length, fixed_overlap or 32) else: - return split_into_passages(text, max_passage_length, passage_overlap) - + return split_into_passages( + text, max_passage_length, passage_overlap) + if self.benchmark and self.monitor: text_size = len(text.encode('utf-8')) - with self.monitor.track_component("text_chunking", input_size_bytes=text_size, - items_count=1, text_only=True) as ctx: + with self.monitor.track_component("text_chunking", input_size_bytes=text_size, + items_count=1, text_only=True) as ctx: passages = chunk_func() ctx.add_text_bytes(text_size) return passages else: return chunk_func() - - def _add_passages_to_collection(self, doc_file: Path, passages: List[str], - all_passages: List[Dict], passage_id: int) -> int: + + def _add_passages_to_collection(self, doc_file: Path, passages: List[str], + all_passages: List[Dict], passage_id: int) -> int: """Add passages to collection and return updated passage_id.""" base_filename = get_base_filename(doc_file.name) original_url = self.url_mapping.get(base_filename, "") - + for passage in passages: passage_metadata = create_passage_metadata( doc_file.name, passage_id, original_url=original_url) - + all_passages.append({ **passage_metadata, 'passage': passage, }) passage_id += 1 - + return passage_id - + def _report_processing_performance(self): """Report processing performance if monitoring is enabled.""" # Suppress the detailed benchmark summary - metrics are tracked internally - # and will be reported by the calling script (measure_indexing_with_chunking.py) + # and will be reported by the calling script + # (measure_indexing_with_chunking.py) pass @@ -605,31 +624,33 @@ def main(): description="Extract text from PDF and HTML files and split into passages", formatter_class=argparse.RawTextHelpFormatter ) - - parser.add_argument("input_dir", help="Directory containing document files (PDF/HTML)") + + parser.add_argument( + "input_dir", + help="Directory containing document files (PDF/HTML)") parser.add_argument("output_dir", help="Directory to save text files") parser.add_argument("--json", help="JSON file to save passage data") - parser.add_argument("--max-length", type=int, default=512, - help="Maximum passage length in characters (default: 512)") + parser.add_argument("--max-length", type=int, default=512, + help="Maximum passage length in characters (default: 512)") parser.add_argument("--overlap", type=int, default=50, - help="Overlap between passages in characters (default: 50)") + help="Overlap between passages in characters (default: 50)") parser.add_argument("--fixed-length", type=int, - help="Use fixed-length passages instead of variable-length") + help="Use fixed-length passages instead of variable-length") parser.add_argument("--fixed-overlap", type=int, default=32, - help="Overlap for fixed-length passages (default: 32)") + help="Overlap for fixed-length passages (default: 32)") parser.add_argument("--max-files", type=int, - help="Maximum number of files to process (for testing)") + help="Maximum number of files to process (for testing)") parser.add_argument("--no-tables", action="store_true", - help="Don't preserve table structure (HTML only)") - parser.add_argument("--no-lists", action="store_true", - help="Don't preserve list structure (HTML only)") - parser.add_argument("--text-boundary", choices=["sentence", "word", "none"], - default="sentence", - help="Text boundary optimization: 'sentence' (default), 'word', or 'none'") + help="Don't preserve table structure (HTML only)") + parser.add_argument("--no-lists", action="store_true", + help="Don't preserve list structure (HTML only)") + parser.add_argument("--text-boundary", choices=["sentence", "word", "none"], + default="sentence", + help="Text boundary optimization: 'sentence' (default), 'word', or 'none'") parser.add_argument("--processes", type=int, default=4, - help="Number of parallel processes for document processing (default: 4)") + help="Number of parallel processes for document processing (default: 4)") parser.add_argument("--benchmark", action="store_true", - help="Enable performance monitoring and detailed component analysis") + help="Enable performance monitoring and detailed component analysis") args = parser.parse_args() @@ -640,7 +661,7 @@ def main(): benchmark=args.benchmark, processes=args.processes ) - + processor.process_documents( input_dir=args.input_dir, output_dir=args.output_dir, diff --git a/e2e-rag/reference_SUT.py b/e2e-rag/reference_SUT.py index d1e3c5c723..9bccb2e6bd 100644 --- a/e2e-rag/reference_SUT.py +++ b/e2e-rag/reference_SUT.py @@ -112,8 +112,10 @@ def __init__( # Performance test mode if hasattr(args, 'perf_test_mode') and args.perf_test_mode: from perf_test_cache import PerfTestCache - log.info(f"Loading performance test cache from {args.perf_test_mode}") - self.llm_config['perf_test_cache'] = PerfTestCache(args.perf_test_mode) + log.info( + f"Loading performance test cache from {args.perf_test_mode}") + self.llm_config['perf_test_cache'] = PerfTestCache( + args.perf_test_mode) else: self.llm_config['perf_test_cache'] = None @@ -130,11 +132,14 @@ def __init__( # Initialize database log.info("Initializing RAG database...") self.rag_db = VectorDB( - retriever_model=args.retriever_model if hasattr(args, 'retriever_model') else 'BAAI/bge-base-en-v1.5', - reranker_model=args.reranker_model if hasattr(args, 'reranker_model') else 'BAAI/bge-reranker-base', + retriever_model=args.retriever_model if hasattr( + args, 'retriever_model') else 'BAAI/bge-base-en-v1.5', + reranker_model=args.reranker_model if hasattr( + args, 'reranker_model') else 'BAAI/bge-reranker-base', device=device, database=db_path.replace('.db', ''), - num_embedding_devices=args.num_embedding_devices if hasattr(args, 'num_embedding_devices') else 1, + num_embedding_devices=args.num_embedding_devices if hasattr( + args, 'num_embedding_devices') else 1, benchmark=args.benchmark if hasattr(args, 'benchmark') else False ) diff --git a/e2e-rag/reference_SUT_datasetup.py b/e2e-rag/reference_SUT_datasetup.py index d23d1d7802..62e81f0f8d 100644 --- a/e2e-rag/reference_SUT_datasetup.py +++ b/e2e-rag/reference_SUT_datasetup.py @@ -117,7 +117,8 @@ def __init__( # Initialize HTML extractor if not HAVE_HTML: - raise RuntimeError("BeautifulSoup required for HTML processing. Install with: pip install beautifulsoup4") + raise RuntimeError( + "BeautifulSoup required for HTML processing. Install with: pip install beautifulsoup4") log.info("Initializing HTML extractor...") self.html_extractor = HTMLExtractor( @@ -206,7 +207,8 @@ def _process_document(self, query_sample): file_path = document_info['file_path'] file_name = document_info['file_name'] - log.info(f"Processing document {sample_id} (QID: {query_id}): {file_name}") + log.info( + f"Processing document {sample_id} (QID: {query_id}): {file_name}") start_time = time.time() success = 0 # 0 = failure, 1 = success @@ -218,7 +220,8 @@ def _process_document(self, query_sample): if not text or len(text.strip()) == 0: # Empty file - create minimal passage with file name - log.warning(f"No text extracted from {file_name}, creating minimal passage") + log.warning( + f"No text extracted from {file_name}, creating minimal passage") text = f"Document: {file_name}" # Continue with text (even if minimal) @@ -232,22 +235,28 @@ def _process_document(self, query_sample): if not passages: # Even after splitting, no passages - this shouldn't happen now # but create one minimal passage as fallback - log.warning(f"No passages created from {file_name}, using text as-is") + log.warning( + f"No passages created from {file_name}, using text as-is") passages = [text] # Step 3: Generate embeddings (parallel, outside lock) - passage_metadata = [{'source': file_name, 'passage_id': i} for i in range(len(passages))] + passage_metadata = [{'source': file_name, 'passage_id': i} + for i in range(len(passages))] - # Generate embeddings WITHOUT holding db_lock (allows parallel embedding generation) - log.info(f"Generating embeddings for {len(passages)} passages from {file_name}") + # Generate embeddings WITHOUT holding db_lock (allows parallel + # embedding generation) + log.info( + f"Generating embeddings for {len(passages)} passages from {file_name}") if self.rag_db._num_embedding_devices > 1: embeddings = self.rag_db._embed_documents_parallel(passages) else: - embeddings = self.rag_db._embedding_model.embed_documents(passages) + embeddings = self.rag_db._embedding_model.embed_documents( + passages) log.info(f"Embeddings generated for {file_name}, adding to index") - # Step 4: Add to index (thread-safe, holds lock only for index update) + # Step 4: Add to index (thread-safe, holds lock only for index + # update) with self.db_lock: # Add embeddings and documents to vector store ids = self.rag_db._vector_store.add_embeddings( @@ -260,7 +269,8 @@ def _process_document(self, query_sample): self.total_passages_indexed += len(passages) success = 1 - log.info(f"Successfully indexed {len(passages)} passages from {file_name}") + log.info( + f"Successfully indexed {len(passages)} passages from {file_name}") # Check if this is the last file to complete with self.completion_lock: @@ -268,7 +278,8 @@ def _process_document(self, query_sample): is_last_file = (self.completed_count == len(self.qsl)) if is_last_file: - log.info(f"Last file completed! Saving database and computing MD5...") + log.info( + f"Last file completed! Saving database and computing MD5...") # Save database db_path = f"{self.database}.db" save_start = time.time() @@ -287,7 +298,8 @@ def _process_document(self, query_sample): self.db_md5 = md5_hash.hexdigest() md5_end = time.time() md5_duration = md5_end - md5_start - log.info(f"Database MD5: {self.db_md5} (computed in {md5_duration:.2f}s)") + log.info( + f"Database MD5: {self.db_md5} (computed in {md5_duration:.2f}s)") self.db_saved = True @@ -317,7 +329,8 @@ def _process_document(self, query_sample): } # Create response for loadgen - # For the last file, include MD5 hash; otherwise just success/failure byte + # For the last file, include MD5 hash; otherwise just success/failure + # byte if success and self.db_saved: # Last file: return MD5 hash as response response_bytes = self.db_md5.encode('utf-8') @@ -339,7 +352,8 @@ def _process_document(self, query_sample): ) lg.QuerySamplesComplete([response]) - log.info(f"Completed document {sample_id} (QID: {query_id}): {file_name}") + log.info( + f"Completed document {sample_id} (QID: {query_id}): {file_name}") def flush_queries(self): """ @@ -349,10 +363,12 @@ def flush_queries(self): log.info("Flushing queries...") if self.db_saved: - log.info(f"Database already saved by last file. MD5: {self.db_md5}") + log.info( + f"Database already saved by last file. MD5: {self.db_md5}") else: # Fallback: save database if somehow not done yet - log.warning("Database not saved by last file - saving now as fallback") + log.warning( + "Database not saved by last file - saving now as fallback") db_path = f"{self.database}.db" save_start = time.time() self.rag_db.serialize(db_path) @@ -379,13 +395,15 @@ def finalize(self): failed_count = len(self.failed_documents) success_count = total_docs - failed_count - avg_time_per_doc = sum(self.processing_times) / total_docs if total_docs > 0 else 0 - throughput_passages = self.total_passages_indexed / total_time if total_time > 0 else 0 + avg_time_per_doc = sum( + self.processing_times) / total_docs if total_docs > 0 else 0 + throughput_passages = self.total_passages_indexed / \ + total_time if total_time > 0 else 0 throughput_docs = total_docs / total_time if total_time > 0 else 0 - log.info("="*80) + log.info("=" * 80) log.info("Datasetup Complete") - log.info("="*80) + log.info("=" * 80) log.info(f"Total documents processed: {total_docs}") log.info(f"Successful: {success_count}") log.info(f"Failed: {failed_count}") @@ -394,10 +412,11 @@ def finalize(self): log.info(f"Throughput: {throughput_passages:.2f} passages/sec") log.info(f"Throughput: {throughput_docs:.2f} docs/sec") log.info(f"Average time per document: {avg_time_per_doc:.2f}s") - log.info("="*80) + log.info("=" * 80) # Cleanup reranker queue if it exists - if hasattr(self.rag_db, '_reranker_queue') and self.rag_db._reranker_queue is not None: + if hasattr( + self.rag_db, '_reranker_queue') and self.rag_db._reranker_queue is not None: log.info("Shutting down reranker queue...") self.rag_db._reranker_queue.stop() @@ -419,12 +438,14 @@ def save_results(self, output_path): failed_count = len(self.failed_documents) success_count = total_docs - failed_count - throughput_passages = self.total_passages_indexed / total_time if total_time > 0 else 0 + throughput_passages = self.total_passages_indexed / \ + total_time if total_time > 0 else 0 throughput_docs = total_docs / total_time if total_time > 0 else 0 # Get vector count from database vector_count = 0 - if hasattr(self.rag_db, '_vector_store') and hasattr(self.rag_db._vector_store, 'index'): + if hasattr(self.rag_db, '_vector_store') and hasattr( + self.rag_db._vector_store, 'index'): vector_count = self.rag_db._vector_store.index.ntotal output = { diff --git a/e2e-rag/reference_mlperf.py b/e2e-rag/reference_mlperf.py index 83ebc0fcd0..2b306c5742 100644 --- a/e2e-rag/reference_mlperf.py +++ b/e2e-rag/reference_mlperf.py @@ -90,7 +90,8 @@ def get_args(): help="Number of queries for performance testing (None = all)" ) - # Multi-shot specific parameters (these are unique to multi_shot_retrieval.py) + # Multi-shot specific parameters (these are unique to + # multi_shot_retrieval.py) parser.add_argument( '--max-sub-queries', type=int, @@ -171,9 +172,9 @@ def main(): os.makedirs(args.output_dir, exist_ok=True) # Initialize SUT - print("\n" + "="*80) + print("\n" + "=" * 80) print("Initializing RAG-QnA SUT...") - print("="*80) + print("=" * 80) sut = E2ESUT( dataset_path=args.dataset_path, @@ -194,9 +195,9 @@ def main(): args=args, # Pass full args for additional params ) - print("\n" + "="*80) + print("\n" + "=" * 80) print("SUT initialization complete") - print("="*80 + "\n") + print("=" * 80 + "\n") # Configure loadgen settings settings = lg.TestSettings() @@ -227,9 +228,9 @@ def main(): log_settings.log_output = log_output_settings # Run loadgen test - print("\n" + "="*80) + print("\n" + "=" * 80) print("Running MLPerf Loadgen test...") - print("="*80 + "\n") + print("=" * 80 + "\n") lg.StartTestWithLogSettings( sut.sut, @@ -239,9 +240,9 @@ def main(): args.audit_conf ) - print("\n" + "="*80) + print("\n" + "=" * 80) print("Loadgen test complete") - print("="*80 + "\n") + print("=" * 80 + "\n") # Finalize SUT (save logs, cleanup) sut.finalize() @@ -253,9 +254,9 @@ def main(): # Run accuracy evaluation if in accuracy mode if args.accuracy: - print("\n" + "="*80) + print("\n" + "=" * 80) print("Running accuracy evaluation...") - print("="*80 + "\n") + print("=" * 80 + "\n") cmd = [ "python3", @@ -269,9 +270,9 @@ def main(): print(f"Command: {' '.join(cmd)}") subprocess.check_call(cmd) - print("\n" + "="*80) + print("\n" + "=" * 80) print("Done!") - print("="*80) + print("=" * 80) if __name__ == "__main__": diff --git a/e2e-rag/reference_mlperf_datasetup.py b/e2e-rag/reference_mlperf_datasetup.py index 5ec27d1232..647cd8c97b 100644 --- a/e2e-rag/reference_mlperf_datasetup.py +++ b/e2e-rag/reference_mlperf_datasetup.py @@ -171,9 +171,9 @@ def main(): os.makedirs(args.output_dir, exist_ok=True) # Initialize SUT - print("\n" + "="*80) + print("\n" + "=" * 80) print("Initializing RAG-DB SUT...") - print("="*80) + print("=" * 80) sut = DatasetupSUT( documents_dir=args.documents_dir, @@ -192,9 +192,9 @@ def main(): args=args, ) - print("\n" + "="*80) + print("\n" + "=" * 80) print("SUT initialization complete") - print("="*80 + "\n") + print("=" * 80 + "\n") # Configure loadgen settings settings = lg.TestSettings() @@ -225,9 +225,9 @@ def main(): log_settings.log_output = log_output_settings # Run loadgen test - print("\n" + "="*80) + print("\n" + "=" * 80) print("Running MLPerf Loadgen test...") - print("="*80 + "\n") + print("=" * 80 + "\n") lg.StartTestWithLogSettings( sut.sut, @@ -237,9 +237,9 @@ def main(): args.audit_conf ) - print("\n" + "="*80) + print("\n" + "=" * 80) print("Loadgen test complete") - print("="*80 + "\n") + print("=" * 80 + "\n") # Finalize SUT (batch index, save database, cleanup) sut.finalize() @@ -249,9 +249,9 @@ def main(): sut.save_results(results_path) print(f"Results saved to {results_path}") - print("\n" + "="*80) + print("\n" + "=" * 80) print("Done!") - print("="*80) + print("=" * 80) if __name__ == "__main__": diff --git a/e2e-rag/reranker_worker.py b/e2e-rag/reranker_worker.py index e3acfea045..fc0b568a82 100644 --- a/e2e-rag/reranker_worker.py +++ b/e2e-rag/reranker_worker.py @@ -100,7 +100,8 @@ def _reranker_worker_main( response_q.put((request_id, None, repr(e))) -def _do_rerank(model, tokenizer, device: str, query: str, passages: List[str]) -> List[Tuple[str, float]]: +def _do_rerank(model, tokenizer, device: str, query: str, + passages: List[str]) -> List[Tuple[str, float]]: """ColBERT late-interaction reranking with MaxSim scoring.""" import torch @@ -180,10 +181,12 @@ def start(self): self._process.start() if not self._ready_event.wait(timeout=300): - raise RuntimeError("reranker child failed to become ready within 300s") + raise RuntimeError( + "reranker child failed to become ready within 300s") self._dispatcher_running = True - self._dispatcher_thread = threading.Thread(target=self._dispatcher_loop, daemon=True) + self._dispatcher_thread = threading.Thread( + target=self._dispatcher_loop, daemon=True) self._dispatcher_thread.start() def stop(self): @@ -215,12 +218,14 @@ def _dispatcher_loop(self): continue event, container = slot if err is not None: - container["error"] = RuntimeError(f"reranker child error: {err}") + container["error"] = RuntimeError( + f"reranker child error: {err}") else: container["result"] = result event.set() - def submit(self, query: str, passages: List[str]) -> List[Tuple[str, float]]: + def submit(self, query: str, + passages: List[str]) -> List[Tuple[str, float]]: if self._process is None or not self._process.is_alive(): raise RuntimeError("reranker process is not running") diff --git a/e2e-rag/retrieve/__init__.py b/e2e-rag/retrieve/__init__.py index b14c776219..2e5187bee9 100644 --- a/e2e-rag/retrieve/__init__.py +++ b/e2e-rag/retrieve/__init__.py @@ -22,4 +22,4 @@ from .vectordb import VectorDB from .filter import filter, get_score_statistics -__all__ = ['RagDB', 'VectorDB', 'filter', 'get_score_statistics'] \ No newline at end of file +__all__ = ['RagDB', 'VectorDB', 'filter', 'get_score_statistics'] diff --git a/e2e-rag/retrieve/filter.py b/e2e-rag/retrieve/filter.py index 710977dac5..aa7e07b4d9 100644 --- a/e2e-rag/retrieve/filter.py +++ b/e2e-rag/retrieve/filter.py @@ -24,7 +24,7 @@ Implements various thresholding approaches: - Top-p (nucleus sampling) - popular in NLP - Score threshold - absolute quality bar -- Relative threshold - adaptive to query difficulty +- Relative threshold - adaptive to query difficulty - Elbow method - natural breakpoints - Percentile-based - statistical cutoffs @@ -40,14 +40,14 @@ def softmax(scores: List[float], temperature: float = 1.0) -> List[float]: """Convert scores to probabilities using softmax with temperature scaling. - + Args: scores: List of scores to convert temperature: Temperature parameter (lower = sharper distribution) - temperature = 1.0: standard softmax - temperature < 1.0: sharper (more weight on top scores) - temperature > 1.0: smoother (more uniform) - + Returns: List of probabilities that sum to 1.0 """ @@ -58,95 +58,98 @@ def softmax(scores: List[float], temperature: float = 1.0) -> List[float]: return [exp_s / sum_exp for exp_s in exp_scores] -def top_p_filter(results_with_scores: List[Tuple[Any, float]], p: float = 0.9) -> List[Any]: +def top_p_filter( + results_with_scores: List[Tuple[Any, float]], p: float = 0.9) -> List[Any]: """ Top-p (nucleus) sampling: Take results until cumulative probability >= p - + Args: results_with_scores: List of (result, score) tuples, sorted by score DESC p: Cumulative probability threshold (0.8-0.95 typical) - + Returns: Filtered results list """ if not results_with_scores: return [] - + scores = [score for _, score in results_with_scores] - - #print(f"\n[DEBUG top_p_filter] p={p}, num_candidates={len(scores)}") - #print(f"[DEBUG] Score range: [{min(scores):.4f}, {max(scores):.4f}]") - #print(f"[DEBUG] Score mean: {sum(scores)/len(scores):.4f}") - #print(f"[DEBUG] First 10 scores: {[f'{s:.4f}' for s in scores[:10]]}") - + + # print(f"\n[DEBUG top_p_filter] p={p}, num_candidates={len(scores)}") + # print(f"[DEBUG] Score range: [{min(scores):.4f}, {max(scores):.4f}]") + # print(f"[DEBUG] Score mean: {sum(scores)/len(scores):.4f}") + # print(f"[DEBUG] First 10 scores: {[f'{s:.4f}' for s in scores[:10]]}") + # Use temperature scaling to sharpen the distribution # Lower temperature = more discriminative (top docs get higher probability) # For vector embeddings with compressed L2 distances, use very low temperature # Temperature = 0.01 to 0.05 for L2 distances in range [0.27-0.42] temperature = 1 - #temperature = 0.02 + # temperature = 0.02 probs = softmax(scores, temperature=temperature) - - #print(f"[DEBUG] Temperature: {temperature}") - #print(f"[DEBUG] Probability range: [{min(probs):.6f}, {max(probs):.6f}]") - #print(f"[DEBUG] First 10 probs: {[f'{p:.6f}' for p in probs[:10]]}") - #print(f"[DEBUG] Prob sum: {sum(probs):.6f}") - + + # print(f"[DEBUG] Temperature: {temperature}") + # print(f"[DEBUG] Probability range: [{min(probs):.6f}, {max(probs):.6f}]") + # print(f"[DEBUG] First 10 probs: {[f'{p:.6f}' for p in probs[:10]]}") + # print(f"[DEBUG] Prob sum: {sum(probs):.6f}") + cumulative_prob = 0.0 selected_results = [] - - for i, ((result, score), prob) in enumerate(zip(results_with_scores, probs)): + + for i, ((result, score), prob) in enumerate( + zip(results_with_scores, probs)): cumulative_prob += prob selected_results.append(result) - + if cumulative_prob >= p: - print(f"[DEBUG] Selected {i+1} documents (cumulative_prob={cumulative_prob:.4f} >= p={p})") + print( + f"[DEBUG] Selected {i+1} documents (cumulative_prob={cumulative_prob:.4f} >= p={p})") break - + return selected_results -def score_threshold_filter(results_with_scores: List[Tuple[Any, float]], - threshold: float, higher_better: bool = True) -> List[Any]: +def score_threshold_filter(results_with_scores: List[Tuple[Any, float]], + threshold: float, higher_better: bool = True) -> List[Any]: """ Absolute score threshold filtering. - + Args: results_with_scores: List of (result, score) tuples threshold: Absolute score cutoff higher_better: If True, keep scores >= threshold, else <= threshold - + Returns: Filtered results list """ selected_results = [] - + for result, score in results_with_scores: if higher_better and score >= threshold: selected_results.append(result) elif not higher_better and score <= threshold: selected_results.append(result) - + return selected_results -def relative_threshold_filter(results_with_scores: List[Tuple[Any, float]], - ratio: float = 0.8) -> List[Any]: +def relative_threshold_filter(results_with_scores: List[Tuple[Any, float]], + ratio: float = 0.8) -> List[Any]: """ Relative threshold: Keep top ratio fraction of results based on score range. - + For both positive and negative scores: - Calculates score range between best and worst - Keeps only results within top ratio% of that range - + Args: results_with_scores: List of (result, score) tuples (sorted desc, best first) ratio: Fraction of score range to keep (0.7-0.9 typical) e.g., 0.9 means keep top 90% of score range - + Returns: Filtered results list - + Example with negative scores: Scores: [-0.42, -0.43, -0.44, ..., -0.49] best=-0.42, worst=-0.49, range=0.07 @@ -156,85 +159,88 @@ def relative_threshold_filter(results_with_scores: List[Tuple[Any, float]], """ if not results_with_scores: return [] - + # Get best and worst scores best_score = results_with_scores[0][1] worst_score = results_with_scores[-1][1] - + # Calculate the score range score_range = best_score - worst_score # Always positive since sorted desc - + # Calculate threshold: start from best, move down by (1-ratio) of range cutoff_distance = score_range * (1 - ratio) threshold = best_score - cutoff_distance - - return score_threshold_filter(results_with_scores, threshold, higher_better=True) + return score_threshold_filter( + results_with_scores, threshold, higher_better=True) -def elbow_method_filter(results_with_scores: List[Tuple[Any, float]]) -> List[Any]: + +def elbow_method_filter( + results_with_scores: List[Tuple[Any, float]]) -> List[Any]: """ Elbow method: Find largest score gap and cut there. - + Args: results_with_scores: List of (result, score) tuples, sorted by score DESC - + Returns: Filtered results list """ if len(results_with_scores) <= 1: return [result for result, _ in results_with_scores] - + scores = [score for _, score in results_with_scores] - + # Calculate gaps between consecutive scores gaps = [] for i in range(len(scores) - 1): gap = scores[i] - scores[i + 1] # Assuming DESC order gaps.append(gap) - + # Find largest gap if not gaps: return [result for result, _ in results_with_scores] - + max_gap_idx = gaps.index(max(gaps)) cutoff_point = max_gap_idx + 1 # Include the score before the gap - + return [result for result, _ in results_with_scores[:cutoff_point]] -def percentile_filter(results_with_scores: List[Tuple[Any, float]], - percentile: float = 90.0) -> List[Any]: +def percentile_filter(results_with_scores: List[Tuple[Any, float]], + percentile: float = 90.0) -> List[Any]: """ Percentile-based filtering: Keep top X percentile of scores. - + Args: results_with_scores: List of (result, score) tuples percentile: Percentile threshold (80-95 typical) - + Returns: Filtered results list """ if not results_with_scores: return [] - + scores = [score for _, score in results_with_scores] threshold = np.percentile(scores, percentile) - - return score_threshold_filter(results_with_scores, threshold, higher_better=True) + + return score_threshold_filter( + results_with_scores, threshold, higher_better=True) -def filter(rag_db, query: str, method: str = "top_p", +def filter(rag_db, query: str, method: str = "top_p", max_results: int = 100, **kwargs) -> List[Any]: """ Perform adaptive retrieval using score-based filtering. - + Args: rag_db: RAG database instance (VectorDB) query: Search query method: Filtering method ("top_p", "score_threshold", "relative", "elbow", "percentile") max_results: Maximum results to retrieve initially **kwargs: Method-specific parameters - + Returns: Filtered results list """ @@ -242,32 +248,33 @@ def filter(rag_db, query: str, method: str = "top_p", if hasattr(rag_db, 'lookup_with_scores'): results_with_scores = rag_db.lookup_with_scores(query, k=max_results) else: - raise ValueError(f"Database {type(rag_db)} doesn't support score-based retrieval") - + raise ValueError( + f"Database {type(rag_db)} doesn't support score-based retrieval") + # Results already have proper similarity scores (higher is better) from lookup_with_scores # Sort by score (descending - higher is better) results_with_scores.sort(key=lambda x: x[1], reverse=True) - + # Apply filtering method if method == "top_p": p = kwargs.get("p", 0.9) return top_p_filter(results_with_scores, p) - + elif method == "score_threshold": threshold = kwargs.get("threshold", 5.0) return score_threshold_filter(results_with_scores, threshold) - + elif method == "relative": ratio = kwargs.get("ratio", 0.8) return relative_threshold_filter(results_with_scores, ratio) - + elif method == "elbow": return elbow_method_filter(results_with_scores) - + elif method == "percentile": percentile = kwargs.get("percentile", 90.0) return percentile_filter(results_with_scores, percentile) - + else: raise ValueError(f"Unknown filtering method: {method}") @@ -276,10 +283,10 @@ def get_score_statistics(rag_db, query: str, k: int = 100) -> Dict[str, float]: """Get score distribution statistics for threshold calibration.""" results_with_scores = rag_db.lookup_with_scores(query, k=k) scores = [score for _, score in results_with_scores] - + if not scores: return {} - + return { "min": min(scores), "max": max(scores), @@ -294,4 +301,4 @@ def get_score_statistics(rag_db, query: str, k: int = 100) -> Dict[str, float]: # Backward compatibility alias -adaptive_retrieval = filter \ No newline at end of file +adaptive_retrieval = filter diff --git a/e2e-rag/retrieve/ragdb.py b/e2e-rag/retrieve/ragdb.py index 19f65c4416..e4c7e1ba79 100644 --- a/e2e-rag/retrieve/ragdb.py +++ b/e2e-rag/retrieve/ragdb.py @@ -18,15 +18,17 @@ import os from typing import List, Dict, Any + class RagDB(abc.ABC): """Base class for retrieval-augmented generation databases.""" - + def __init__(self, reranker_model: str = None, device: str = "auto", benchmark: bool = False, reranker_device: str = None): self._reranker_model_name = reranker_model self._device = self._determine_device(device) # Reranker device defaults to inheriting from --device. - self._reranker_device = self._determine_device(reranker_device) if reranker_device else self._device + self._reranker_device = self._determine_device( + reranker_device) if reranker_device else self._device self._reranker_queue = None self._benchmark = benchmark self._monitor = None @@ -39,12 +41,12 @@ def __init__(self, reranker_model: str = None, device: str = "auto", # Initialize out-of-process reranker if specified if self._reranker_model_name: self._init_reranker() - + def _determine_device(self, device: str) -> str: """Determine the best device to use. Delegates to utils.detect_device() for auto detection so device-selection - logic lives in one place. ROCm maps to "cuda" + logic lives in one place. ROCm maps to "cuda" """ if device == "rocm": return "cuda" @@ -52,14 +54,14 @@ def _determine_device(self, device: str) -> str: from utils import detect_device return detect_device() return device - + @staticmethod def get_data_dir(db_name: str) -> str: """Get data directory based on database name.""" from pathlib import Path base_name = Path(db_name).stem # Remove .db extension if present return f"{base_name}_data" - + @staticmethod def get_db_path(db_name: str) -> str: """Get database file path based on database name.""" @@ -90,11 +92,11 @@ def _init_reranker(self): omp_threads=int(omp_threads) if omp_threads else None, ) self._reranker_queue.start() - - def _track_component(self, name: str, total_chars: int, item_count: int, func, - is_pipeline_input: bool = False, is_pipeline_output: bool = False): + + def _track_component(self, name: str, total_chars: int, item_count: int, func, + is_pipeline_input: bool = False, is_pipeline_output: bool = False): """Execute function with optional component tracking. - + Args: name: Component name total_chars: Input size in bytes @@ -104,26 +106,27 @@ def _track_component(self, name: str, total_chars: int, item_count: int, func, is_pipeline_output: Mark as pipeline output for aggregation """ if self._benchmark and self._monitor: - with self._monitor.track_component(name, input_size_bytes=total_chars, - items_count=item_count, text_only=True, - is_pipeline_input=is_pipeline_input, - is_pipeline_output=is_pipeline_output) as ctx: + with self._monitor.track_component(name, input_size_bytes=total_chars, + items_count=item_count, text_only=True, + is_pipeline_input=is_pipeline_input, + is_pipeline_output=is_pipeline_output) as ctx: result = func() ctx.add_text_bytes(total_chars) return result else: return func() - + def _start_ingestion_timer(self): """Start the ingestion timer. Works for both benchmark and non-benchmark modes.""" import time if self._benchmark and self._monitor: self._monitor.start_ingestion() return time.perf_counter() - - def _report_performance(self, ingestion_start_time: float, item_count: int, total_chars: int, db_type: str): + + def _report_performance(self, ingestion_start_time: float, + item_count: int, total_chars: int, db_type: str): """Report performance metrics with optional detailed breakdown. - + Args: ingestion_start_time: Start time from _start_ingestion_timer() (used only in non-benchmark mode) item_count: Number of items processed @@ -131,7 +134,7 @@ def _report_performance(self, ingestion_start_time: float, item_count: int, tota db_type: Database type string for display """ import time - + if self._benchmark and self._monitor: with self._monitor.track_ingestion() as ingestion_ctx: ingestion_ctx.set_item_count(item_count) @@ -142,9 +145,11 @@ def _report_performance(self, ingestion_start_time: float, item_count: int, tota duration = end_time - ingestion_start_time docs_per_sec = item_count / duration if duration > 0 else 0 chars_per_sec = total_chars / duration if duration > 0 else 0 - print(f"{db_type} ingestion: {item_count} docs, {total_chars:,} chars in {duration:.2f}s") - print(f" Performance: {docs_per_sec:.1f} docs/sec, {chars_per_sec/1024:.1f} KB/sec") - + print( + f"{db_type} ingestion: {item_count} docs, {total_chars:,} chars in {duration:.2f}s") + print( + f" Performance: {docs_per_sec:.1f} docs/sec, {chars_per_sec/1024:.1f} KB/sec") + def enable_threading(self): """Enable thread-safe access. Override in subclasses that need locks.""" pass @@ -153,26 +158,27 @@ def enable_threading(self): def ingest(self, passages: List[str], metadatas: List[Dict[str, Any]]): """Ingest passages and their metadata into the database.""" pass - + @abc.abstractmethod def lookup(self, query: str, k: int) -> List[Any]: """Retrieve top-k relevant passages for a query.""" pass - + @abc.abstractmethod def serialize(self, path: str): """Serialize the database to disk.""" pass - + @abc.abstractmethod def from_serialized(self, path: str): """Load the database from disk.""" pass - + def ingest_from_folder(self, folder_path: str, **kwargs): """Ingest data from a folder. Default implementation raises NotImplementedError.""" - raise NotImplementedError(f"Folder ingestion not supported for {self.__class__.__name__}") - + raise NotImplementedError( + f"Folder ingestion not supported for {self.__class__.__name__}") + def ingest_from_file(self, file_path: str, **kwargs): """Ingest data from a JSON file. Default implementation for JSON files. @@ -204,35 +210,39 @@ def ingest_from_file(self, file_path: str, **kwargs): # Use child for embedding doc_list.append(entry['child_passage']) # Store all metadata including parent - metadata = {k: v for k, v in entry.items() if k != 'child_passage'} + metadata = {k: v for k, v in entry.items() if k != + 'child_passage'} passage_metadata.append(metadata) else: # Flat format for entry in passage_data: doc_list.append(entry['passage']) - passage_metadata.append({k: v for k, v in entry.items() if k != 'passage'}) + passage_metadata.append( + {k: v for k, v in entry.items() if k != 'passage'}) print(f"Ingesting {len(doc_list)} passages from JSON file {file_path}") - return self.ingest(doc_list, passage_metadata, passages_path=file_path, **kwargs) + return self.ingest(doc_list, passage_metadata, + passages_path=file_path, **kwargs) def ingest_from_path(self, source_path: str, **kwargs): """Handle both file and folder ingestion. - + Default implementation that delegates to appropriate methods: - Folders: calls ingest_from_folder() (may raise NotImplementedError if not overridden) - Files: calls ingest_from_file() (default JSON implementation) """ from pathlib import Path - + source_path = Path(source_path) - + if source_path.is_dir(): print(f"Ingesting documents from folder {source_path}") return self.ingest_from_folder(source_path, **kwargs) elif source_path.is_file(): return self.ingest_from_file(source_path, **kwargs) else: - raise ValueError(f"Source path {source_path} is neither a file nor a directory") + raise ValueError( + f"Source path {source_path} is neither a file nor a directory") def shutdown_reranker(self): """Tear down the reranker child process. Safe to call multiple times.""" @@ -246,7 +256,8 @@ def rerank(self, query: str, passages: List[str]): return self._reranker_queue.submit(query, passages) return [(p, 0.0) for p in passages] - def lookup_with_rerank(self, query: str, k: int, rerank_k: int = None) -> List[Any]: + def lookup_with_rerank(self, query: str, k: int, + rerank_k: int = None) -> List[Any]: """Retrieve and rerank passages.""" if rerank_k is None: rerank_k = k @@ -257,13 +268,13 @@ def lookup_with_rerank(self, query: str, k: int, rerank_k: int = None) -> List[A # If no reranker or fewer results than requested, return as-is if self._reranker_queue is None or len(results) <= k: return results[:k] - + # Extract passages for reranking passages = [result.page_content for result in results] - + # Rerank reranked_passages = self.rerank(query, passages) - + # Map back to original results and return top-k reranked_results = [] for passage, score in reranked_passages[:k]: @@ -271,7 +282,7 @@ def lookup_with_rerank(self, query: str, k: int, rerank_k: int = None) -> List[A if result.page_content == passage: reranked_results.append(result) break - + return reranked_results @property diff --git a/e2e-rag/retrieve/vectordb.py b/e2e-rag/retrieve/vectordb.py index 73499e93d5..d0c392568a 100644 --- a/e2e-rag/retrieve/vectordb.py +++ b/e2e-rag/retrieve/vectordb.py @@ -44,7 +44,11 @@ def _alias_missing_faiss_swigfaiss_modules() -> None: serialized databases loadable across heterogeneous installs without rebuilding them. """ - candidates = ("swigfaiss_avx512_spr", "swigfaiss_avx512", "swigfaiss_avx2", "swigfaiss") + candidates = ( + "swigfaiss_avx512_spr", + "swigfaiss_avx512", + "swigfaiss_avx2", + "swigfaiss") available = None for name in candidates: full = f"faiss.{name}" @@ -66,7 +70,10 @@ def _alias_missing_faiss_swigfaiss_modules() -> None: except ImportError: sys.modules[full] = available -# Worker function for parallel embedding generation (must be at module level for multiprocessing) +# Worker function for parallel embedding generation (must be at module +# level for multiprocessing) + + def _parallel_embed_worker(device_id, input_chunk_indices, input_chunks, result_queue, model_name, encode_kwargs, base_device, numa_plan=None): """Worker function to generate embeddings on a specific device. @@ -120,9 +127,11 @@ def _parallel_embed_worker(device_id, input_chunk_indices, input_chunks, result_ encode_kwargs=encode_kwargs ) - print(f"✓ Device {device}: Loaded model, processing {len(input_chunks)} input chunk(s)") + print( + f"✓ Device {device}: Loaded model, processing {len(input_chunks)} input chunk(s)") - for input_chunk_idx, input_chunk in zip(input_chunk_indices, input_chunks): + for input_chunk_idx, input_chunk in zip( + input_chunk_indices, input_chunks): embeddings = embedder.embed_documents(input_chunk) result_queue.put((input_chunk_idx, embeddings)) @@ -133,24 +142,25 @@ def _parallel_embed_worker(device_id, input_chunk_indices, input_chunks, result_ for input_chunk_idx in input_chunk_indices: result_queue.put((input_chunk_idx, None)) + class VectorDB(RagDB): @classmethod def get_default_db_name(cls) -> str: """Get the default database filename for VectorDB.""" return "vector.db" - + def __init__(self, - retriever_model: str = None, - reranker_model: str = None, - device: str = "auto", - load_embeddings: bool = True, - num_embedding_devices: int = 1, - benchmark: bool = False, - hierarchical: bool = False, - embedding_device: str = None, - reranker_device: str = None, - **kwargs - ): + retriever_model: str = None, + reranker_model: str = None, + device: str = "auto", + load_embeddings: bool = True, + num_embedding_devices: int = 1, + benchmark: bool = False, + hierarchical: bool = False, + embedding_device: str = None, + reranker_device: str = None, + **kwargs + ): super().__init__(reranker_model, device, benchmark, reranker_device=reranker_device) self._retriever_model_name = retriever_model self._reranker_model_name = reranker_model @@ -159,7 +169,8 @@ def __init__(self, self._hierarchical = hierarchical self._embedding_lock = None # Embedding device defaults to inheriting from --device. - self._embedding_device = self._determine_device(embedding_device) if embedding_device else self._device + self._embedding_device = self._determine_device( + embedding_device) if embedding_device else self._device # For hierarchical mode: map child_index -> parent_passage self._parent_map = {} @@ -175,7 +186,8 @@ def __init__(self, # For single-device embedding, allocate one GPU now so the reranker # (allocated later) can't pick the same one. Multi-device path # allocates inside _embed_documents_parallel. - if num_embedding_devices == 1 and self._embedding_device in ("cuda", "xpu"): + if num_embedding_devices == 1 and self._embedding_device in ( + "cuda", "xpu"): from utils import resolve_gpu_device self._embedding_device = resolve_gpu_device( self._embedding_device, name="embedding", @@ -183,27 +195,32 @@ def __init__(self, ) # Initialize embedding model with device configuration - model_kwargs = {'device': self._embedding_device, 'local_files_only': True} + model_kwargs = { + 'device': self._embedding_device, + 'local_files_only': True} encode_kwargs = {'normalize_embeddings': True} - + self._embedding_model = HuggingFaceEmbeddings( model_name=self._retriever_model_name, model_kwargs=model_kwargs, encode_kwargs=encode_kwargs ) - self._embedding_dimension = len(self._embedding_model.embed_query("hello world")) - + self._embedding_dimension = len( + self._embedding_model.embed_query("hello world")) + # Check the dtype of the embedding without using numpy test_embedding_raw = self._embedding_model.embed_query("test") - + # Calculate dtype and itemsize from Python native list - if isinstance(test_embedding_raw, list) and len(test_embedding_raw) > 0: + if isinstance(test_embedding_raw, list) and len( + test_embedding_raw) > 0: test_element = test_embedding_raw[0] embedding_dtype = type(test_element) embedding_itemsize = test_element.__sizeof__() # Size in bytes of one element self._embedding_bytes_per_element = embedding_itemsize else: - raise ValueError("Embedding query did not return a valid list of floats.") + raise ValueError( + "Embedding query did not return a valid list of floats.") if self._benchmark: print(f" Embedding element type: {embedding_dtype}") @@ -220,12 +237,12 @@ def __init__(self, embedding_function=self._embedding_model, index=self._index, docstore=self._docstore, - index_to_docstore_id={}, # This will be populated as documents are added + index_to_docstore_id={}, # This will be populated as documents are added ) - + # Keep track of ingested documents self._doc_list = [] - + def _create_vector_index(self, dimension: int): """Create a FAISS HNSW vector index. @@ -241,12 +258,14 @@ def _create_vector_index(self, dimension: int): FAISS HNSW index """ # M: number of connections per layer (higher = better recall, more memory) - # efConstruction: quality of index construction (higher = better quality, slower build) + # efConstruction: quality of index construction (higher = better + # quality, slower build) M = 32 # Default: 32, good balance index = faiss.IndexHNSWFlat(dimension, M) index.hnsw.efConstruction = 200 # Default: 40 index.hnsw.efSearch = 100 # Search-time parameter, can be adjusted later return index + def _get_embeddings_cache_path(self, passages_path: str) -> str: """Get the cache path for embeddings based on passages file path.""" from pathlib import Path @@ -254,15 +273,16 @@ def _get_embeddings_cache_path(self, passages_path: str) -> str: # Replace extension with .emb.pkl cache_path = passages_path.with_suffix('.emb.pkl') return str(cache_path) - + def _save_embeddings_cache(self, embeddings: list, passages_path: str): """Save embeddings to a pickle file for reuse.""" - import os, pickle + import os + import pickle from pathlib import Path - + cache_path = self._get_embeddings_cache_path(passages_path) Path(cache_path).parent.mkdir(parents=True, exist_ok=True) - + if os.path.exists(cache_path): print(f"Embeddings cache exists: {cache_path}") return @@ -270,16 +290,16 @@ def _save_embeddings_cache(self, embeddings: list, passages_path: str): with open(cache_path, 'wb') as f: pickle.dump(embeddings, f) print(f"💾 Saved embeddings cache to {cache_path}") - + def _load_embeddings_cache(self, passages_path: str) -> list: """Load embeddings from cache if available.""" import pickle from pathlib import Path - + cache_path = self._get_embeddings_cache_path(passages_path) if not Path(cache_path).exists(): return None - + try: with open(cache_path, 'rb') as f: embeddings = pickle.load(f) @@ -288,7 +308,7 @@ def _load_embeddings_cache(self, passages_path: str) -> list: except Exception as e: print(f"⚠️ Failed to load embeddings cache: {e}") return None - + def _build_numa_plans(self, num_workers: int): """Parse INFERENCE_EMBEDDING_NUMA_NODES into per-worker (node, cpu_set). @@ -319,22 +339,23 @@ def _build_numa_plans(self, num_workers: int): def _embed_documents_parallel(self, passages: List[str]) -> list: """Generate embeddings using multiple devices in parallel. - + Uses the device type from --device option and spawns multiple workers. - + Args: passages: List of text passages to embed - + Returns: List of embeddings (one per passage) """ import torch import multiprocessing as mp - + # Use the embedding device (may differ from the global --device). # Strip any GPU index already allocated for the single-device path so # the parallel allocator gets a clean device-type string. - base_device = self._embedding_device.split(":")[0] # e.g., 'xpu', 'cuda', 'cpu', 'hpu' + base_device = self._embedding_device.split( + ":")[0] # e.g., 'xpu', 'cuda', 'cpu', 'hpu' num_workers = min(self._num_embedding_devices, len(passages)) @@ -350,7 +371,8 @@ def _embed_documents_parallel(self, passages: List[str]) -> list: override_env="INFERENCE_EMBEDDING_GPU_DEVICES", ) else: - # CPU / HPU: workers use the same device string; index field is ignored. + # CPU / HPU: workers use the same device string; index field is + # ignored. device_indices = list(range(num_workers)) # Set spawn method for device compatibility (required for XPU/CUDA) @@ -360,16 +382,19 @@ def _embed_documents_parallel(self, passages: List[str]) -> list: # Already set, ignore pass - print(f"🚀 Parallel embedding on {num_workers} {base_device.upper()} device(s)...") + print( + f"🚀 Parallel embedding on {num_workers} {base_device.upper()} device(s)...") # Optional per-worker NUMA pinning (CPU + memory + OMP). numa_plans = self._build_numa_plans(num_workers) # Split passages into per-worker input chunks. input_chunk_size = (len(passages) + num_workers - 1) // num_workers - input_chunks = [passages[i:i + input_chunk_size] for i in range(0, len(passages), input_chunk_size)] + input_chunks = [passages[i:i + input_chunk_size] + for i in range(0, len(passages), input_chunk_size)] - print(f" Split {len(passages)} passages into {len(input_chunks)} input chunk(s) (~{input_chunk_size} passages/device)") + print( + f" Split {len(passages)} passages into {len(input_chunks)} input chunk(s) (~{input_chunk_size} passages/device)") result_queue = mp.Queue() processes = [] @@ -379,11 +404,12 @@ def _embed_documents_parallel(self, passages: List[str]) -> list: # input_chunk_idx is the position in `input_chunks`; device_id is the GPU # index from the allocator. They differ when the allocator returns # non-contiguous indices. - for input_chunk_idx, device_id in enumerate(device_indices[:len(input_chunks)]): + for input_chunk_idx, device_id in enumerate( + device_indices[:len(input_chunks)]): numa_plan = numa_plans[input_chunk_idx] if numa_plans else None p = mp.Process(target=_parallel_embed_worker, - args=(device_id, [input_chunk_idx], [input_chunks[input_chunk_idx]], result_queue, - self._retriever_model_name, encode_kwargs, base_device, numa_plan)) + args=(device_id, [input_chunk_idx], [input_chunks[input_chunk_idx]], result_queue, + self._retriever_model_name, encode_kwargs, base_device, numa_plan)) p.start() processes.append(p) @@ -401,36 +427,37 @@ def _embed_documents_parallel(self, passages: List[str]) -> list: for i in range(len(input_chunks)): if i in results: all_embeddings.extend(results[i]) - - print(f"✓ Generated {len(all_embeddings)} embeddings across {num_workers} devices") - + + print( + f"✓ Generated {len(all_embeddings)} embeddings across {num_workers} devices") + return all_embeddings - + def _calculate_index_output_size(self): """Calculate the size of VectorDB output data (db file - metadata). - + Returns the total size in bytes of the serialized database file, excluding configuration metadata overhead. - + The .db file contains: - FAISS index (vectors) - Passages (docstore) - Metadata (small overhead) - + We estimate metadata size and subtract it from total file size. """ from pathlib import Path - + if not hasattr(self, '_serialize_path') or not self._serialize_path: return 0 - + db_path = Path(self._serialize_path) if not db_path.exists(): return 0 - + total_file_size = db_path.stat().st_size return total_file_size - + def ingest(self, passages: List[str], metadatas: List[dict], **kwargs): """Ingest passages with performance monitoring. @@ -457,7 +484,7 @@ def ingest(self, passages: List[str], metadatas: List[dict], **kwargs): print(f" Stored {len(self._parent_map)} parent mappings") total_chars = sum(len(passage) for passage in passages) - + # Handle embeddings: try to load from cache or generate new ones embeddings = None @@ -468,44 +495,58 @@ def ingest(self, passages: List[str], metadatas: List[dict], **kwargs): if embeddings is None: if self._num_embedding_devices > 1: # Use parallel embedding generation across multiple devices - embeddings = self._track_component("embedding_generation", total_chars, len(passages), - lambda: self._embed_documents_parallel(passages), - is_pipeline_input=True) + embeddings = self._track_component("embedding_generation", total_chars, len(passages), + lambda: self._embed_documents_parallel( + passages), + is_pipeline_input=True) else: # Single device embedding generation - embeddings = self._track_component("embedding_generation", total_chars, len(passages), - lambda: self._embedding_model.embed_documents(passages), - is_pipeline_input=True) - - - # Determine batch size: single batch for small datasets, multiple batches for scaling analysis - track_incremental = self._benchmark and self._monitor and len(passages) >= 500 + embeddings = self._track_component("embedding_generation", total_chars, len(passages), + lambda: self._embedding_model.embed_documents( + passages), + is_pipeline_input=True) + + # Determine batch size: single batch for small datasets, multiple + # batches for scaling analysis + track_incremental = self._benchmark and self._monitor and len( + passages) >= 500 if track_incremental: - batch_size = max(1000, len(passages) // 10) # 10 batches, minimum 1000 docs per batch - print(f"🔬 Incremental indexing analysis: {len(passages)} docs in batches of {batch_size}") + # 10 batches, minimum 1000 docs per batch + batch_size = max(1000, len(passages) // 10) + print( + f"🔬 Incremental indexing analysis: {len(passages)} docs in batches of {batch_size}") else: batch_size = len(passages) # Single batch - + # Track total indexing time for component metrics import time indexing_component_start = time.perf_counter() - + # Process in batches for i in range(0, len(passages), batch_size): batch_end = min(i + batch_size, len(passages)) - self._ingest_single_batch(passages, metadatas, embeddings, i, batch_end, track_incremental) - + self._ingest_single_batch( + passages, + metadatas, + embeddings, + i, + batch_end, + track_incremental) + indexing_component_end = time.perf_counter() indexing_component_duration = indexing_component_end - indexing_component_start - + # Create component metrics for the entire indexing operation if not track_incremental: - # For single batch, component was tracked inside _ingest_single_batch + # For single batch, component was tracked inside + # _ingest_single_batch pass elif self._monitor: - # For incremental, create component metrics here for the entire operation - embedding_bytes = len(passages) * self._embedding_dimension * self._embedding_bytes_per_element - + # For incremental, create component metrics here for the entire + # operation + embedding_bytes = len( + passages) * self._embedding_dimension * self._embedding_bytes_per_element + from ingestion_monitor import ComponentMetrics self._monitor.components["faiss_indexing"] = ComponentMetrics( name="faiss_indexing", @@ -513,25 +554,27 @@ def ingest(self, passages: List[str], metadatas: List[dict], **kwargs): input_size_bytes=embedding_bytes, output_size_bytes=embedding_bytes, # Vectors stored in FAISS index items_processed=len(passages), - throughput_mb_per_sec=(embedding_bytes / (1024 * 1024)) / indexing_component_duration if indexing_component_duration > 0 else 0, - throughput_items_per_sec=len(passages) / indexing_component_duration if indexing_component_duration > 0 else 0, + throughput_mb_per_sec=(embedding_bytes / (1024 * 1024)) / + indexing_component_duration if indexing_component_duration > 0 else 0, + throughput_items_per_sec=len( + passages) / indexing_component_duration if indexing_component_duration > 0 else 0, is_pipeline_input=False, is_pipeline_output=True ) - + # Store ingestion metrics for later reporting self._ingestion_start = ingestion_start self._ingestion_item_count = len(passages) self._ingestion_total_chars = total_chars - - # Save embeddings to cache + + # Save embeddings to cache if self._load_embeddings and passages_path: self._save_embeddings_cache(embeddings, passages_path) def _ingest_single_batch(self, passages: List[str], metadatas: List[dict], embeddings: list, - batch_start: int, batch_end: int, track_incremental: bool): + batch_start: int, batch_end: int, track_incremental: bool): """Ingest a batch of passages. Can be used for single or incremental indexing. - + Args: passages: All passages metadatas: All metadata @@ -541,40 +584,42 @@ def _ingest_single_batch(self, passages: List[str], metadatas: List[dict], embed track_incremental: Whether to track this batch for incremental analysis """ import time - + # Extract batch data batch_passages = passages[batch_start:batch_end] - batch_metadatas = metadatas[batch_start:batch_end] if metadatas else [{}] * (batch_end - batch_start) + batch_metadatas = metadatas[batch_start:batch_end] if metadatas else [ + {}] * (batch_end - batch_start) batch_embeddings = embeddings[batch_start:batch_end] - + # Track DB size before adding (for incremental tracking) db_size_before = len(self._doc_list) if track_incremental else 0 - + # Calculate embedding size for this batch - batch_embedding_bytes = len(batch_passages) * self._embedding_dimension * self._embedding_bytes_per_element - + batch_embedding_bytes = len( + batch_passages) * self._embedding_dimension * self._embedding_bytes_per_element + # Time and execute indexing operation indexing_start = time.perf_counter() - + if track_incremental: # For incremental: just add embeddings without component tracking self._vector_store.add_embeddings( - list(zip(batch_passages, batch_embeddings)), + list(zip(batch_passages, batch_embeddings)), batch_metadatas ) else: # For single batch: use component tracking self._track_component("faiss_indexing", batch_embedding_bytes, len(batch_passages), - lambda: self._vector_store.add_embeddings( - list(zip(batch_passages, batch_embeddings)), batch_metadatas), - is_pipeline_output=True) - + lambda: self._vector_store.add_embeddings( + list(zip(batch_passages, batch_embeddings)), batch_metadatas), + is_pipeline_output=True) + indexing_end = time.perf_counter() indexing_time = indexing_end - indexing_start - + # Update document list self._doc_list.extend(batch_passages) - + # Track for incremental analysis if requested if track_incremental and self._monitor: self._monitor.track_incremental_indexing( @@ -582,7 +627,7 @@ def _ingest_single_batch(self, passages: List[str], metadatas: List[dict], embed batch_size=len(batch_passages), indexing_time=indexing_time ) - + def enable_threading(self): """Enable thread-safe access to the embedding model.""" import threading @@ -598,9 +643,11 @@ def lookup(self, query: str, k: int): embedding = self.embed_query(query) else: embedding = self.embed_query(query) - results = self._vector_store.similarity_search_by_vector(embedding, k=k) + results = self._vector_store.similarity_search_by_vector( + embedding, k=k) - # In hierarchical mode: replace child passages with parents, deduplicate + # In hierarchical mode: replace child passages with parents, + # deduplicate if self._hierarchical and self._parent_map: parent_docs = [] seen_parents = set() @@ -631,7 +678,7 @@ def lookup(self, query: str, k: int): return parent_docs return results - + def lookup_with_scores(self, query: str, k: int): """ Lookup documents with similarity scores. @@ -645,13 +692,16 @@ def lookup_with_scores(self, query: str, k: int): embedding = self.embed_query(query) else: embedding = self.embed_query(query) - results_with_scores = self._vector_store.similarity_search_with_score_by_vector(embedding, k=k) + results_with_scores = self._vector_store.similarity_search_with_score_by_vector( + embedding, k=k) # FAISS returns (document, distance) where distance is L2 distance (lower is better) # Convert to similarity score (higher is better) by negating - results_with_similarity = [(doc, -distance) for doc, distance in results_with_scores] + results_with_similarity = [(doc, -distance) + for doc, distance in results_with_scores] - # In hierarchical mode: replace child passages with parents, deduplicate + # In hierarchical mode: replace child passages with parents, + # deduplicate if self._hierarchical and self._parent_map: parent_results = [] seen_parents = set() @@ -683,8 +733,6 @@ def lookup_with_scores(self, query: str, k: int): return results_with_similarity - - def serialize(self, path: str): # Store path for output size calculation self._serialize_path = path @@ -700,21 +748,27 @@ def serialize(self, path: str): parent_map_path = Path(path).with_suffix('.parent_map.pkl') with open(parent_map_path, 'wb') as f: pickle.dump(self._parent_map, f) - print(f"💾 Saved parent map ({len(self._parent_map)} entries) to {parent_map_path}") + print( + f"💾 Saved parent map ({len(self._parent_map)} entries) to {parent_map_path}") # Update output size after serialization (now file exists) if self._benchmark and self._monitor: - self._monitor.set_output_size_callback("faiss_indexing", self._calculate_index_output_size) + self._monitor.set_output_size_callback( + "faiss_indexing", self._calculate_index_output_size) # Report performance after serialization if benchmarking - if self._benchmark and self._monitor and hasattr(self, '_ingestion_start'): + if self._benchmark and self._monitor and hasattr( + self, '_ingestion_start'): # Determine db_type based on whether incremental was used - db_type = "VectorDB (Incremental)" if hasattr(self._monitor, 'indexing_trend') and len(self._monitor.indexing_trend) > 0 else "VectorDB" + db_type = "VectorDB (Incremental)" if hasattr( + self._monitor, 'indexing_trend') and len( + self._monitor.indexing_trend) > 0 else "VectorDB" self._report_performance(self._ingestion_start, self._ingestion_item_count, - self._ingestion_total_chars, db_type) + self._ingestion_total_chars, db_type) def from_serialized(self, path: str): - assert len(self._vector_store.index_to_docstore_id) == 0, "Vector store already has documents" + assert len( + self._vector_store.index_to_docstore_id) == 0, "Vector store already has documents" # Pickled FAISS indexes reference a specific SWIG submodule (e.g. # `faiss.swigfaiss_avx512`). Alias any missing submodules so DBs built # on one host load on hosts with a different faiss-cpu build. @@ -722,8 +776,8 @@ def from_serialized(self, path: str): with open(path, "rb") as f: data = f.read() self._vector_store = FAISS.deserialize_from_bytes(embeddings=self._embedding_model, - serialized=data, - allow_dangerous_deserialization=True) # <--- USE WITH CAUTION - Only deserialize files you trust + serialized=data, + allow_dangerous_deserialization=True) # <--- USE WITH CAUTION - Only deserialize files you trust # Load parent map if hierarchical mode if self._hierarchical: @@ -733,6 +787,8 @@ def from_serialized(self, path: str): if parent_map_path.exists(): with open(parent_map_path, 'rb') as f: self._parent_map = pickle.load(f) - print(f"✓ Loaded parent map ({len(self._parent_map)} entries) from {parent_map_path}") + print( + f"✓ Loaded parent map ({len(self._parent_map)} entries) from {parent_map_path}") else: - print(f"⚠️ Warning: Hierarchical mode enabled but no parent map found at {parent_map_path}") + print( + f"⚠️ Warning: Hierarchical mode enabled but no parent map found at {parent_map_path}") diff --git a/e2e-rag/single_shot_retrieval.py b/e2e-rag/single_shot_retrieval.py index 81e0d69c4c..aa1afe54ad 100644 --- a/e2e-rag/single_shot_retrieval.py +++ b/e2e-rag/single_shot_retrieval.py @@ -26,7 +26,8 @@ from utils import set_deterministic_seeds, setup_llm_config from params import add_all_args -# Taken below from frames: https://huggingface.co/datasets/google/frames-benchmark +# Taken below from frames: +# https://huggingface.co/datasets/google/frames-benchmark DEFAULT_QUERY = "Who won the French Open Mens Singles tournament the year that New York City FC won their first MLS Cup title?" MAX_PASSAGE_PREVIEW = 4096 FULL_DOC_MAX_CHARS = 39000 @@ -58,7 +59,8 @@ def _read_text(path: str) -> str: return path_obj.read_text(encoding="utf-8", errors="ignore") -def _load_document_text(metadata, base_dir=None, default_base_dir="doc_html", max_chars=FULL_DOC_MAX_CHARS): +def _load_document_text(metadata, base_dir=None, + default_base_dir="doc_html", max_chars=FULL_DOC_MAX_CHARS): target_dir = base_dir or default_base_dir base_filename = metadata.get("base_filename") if not base_filename: @@ -79,7 +81,8 @@ def _load_document_text(metadata, base_dir=None, default_base_dir="doc_html", ma return "", None -def _convert_results_to_entries(results, limit=5, full_doc=False, base_dir=None, default_base_dir="doc_html"): +def _convert_results_to_entries( + results, limit=5, full_doc=False, base_dir=None, default_base_dir="doc_html"): entries = [] seen_ids = set() count = 0 @@ -96,9 +99,15 @@ def _convert_results_to_entries(results, limit=5, full_doc=False, base_dir=None, default_base_dir=default_base_dir ) if not content: - content = getattr(doc, "page_content", metadata.get("content", ""))[:MAX_PASSAGE_PREVIEW] + content = getattr( + doc, "page_content", metadata.get( + "content", ""))[ + :MAX_PASSAGE_PREVIEW] else: - content = getattr(doc, "page_content", metadata.get("content", ""))[:MAX_PASSAGE_PREVIEW] + content = getattr( + doc, "page_content", metadata.get( + "content", ""))[ + :MAX_PASSAGE_PREVIEW] source_path = None entry = {"url": url, "content": content} if source_path: @@ -130,7 +139,8 @@ def _generate_llm_answer(query, doc_entries, llm_config): source = doc.get("url") or "Unknown source" snippet = doc.get("content", "").strip() context_parts.append(f"[{idx}] Source: {source}\n{snippet}") - evidence_block = "\n\n".join(context_parts) if context_parts else "No supporting documents were retrieved." + evidence_block = "\n\n".join( + context_parts) if context_parts else "No supporting documents were retrieved." user_prompt = ( "Answer the question using only the provided evidence." " Respond with a single word or short phrase, or 'Unknown' if the evidence is insufficient.\n\n" @@ -153,7 +163,10 @@ def _generate_llm_answer(query, doc_entries, llm_config): "temperature": 0.0, "max_tokens": max_tokens } - response = requests.post(llm_config["service_url"], json=payload, timeout=60) + response = requests.post( + llm_config["service_url"], + json=payload, + timeout=60) response.raise_for_status() data = response.json() message = data["choices"][0]["message"] @@ -164,14 +177,15 @@ def _generate_llm_answer(query, doc_entries, llm_config): return content.strip() if content.strip() else "Unknown" - if __name__ == "__main__": - args = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - + args = argparse.ArgumentParser( + formatter_class=argparse.RawTextHelpFormatter) + # Add all parameters from centralized definitions - # This includes: Common, General, BM25, Vector, Strategy, and Reranking parameters + # This includes: Common, General, BM25, Vector, Strategy, and Reranking + # parameters add_all_args(args) - + # Special handling for --eval argument (needs custom type) # Override the default eval argument with custom type for action in args._actions: @@ -190,17 +204,19 @@ def _generate_llm_answer(query, doc_entries, llm_config): args.database = VectorDB.get_default_db_name() # Normalize database path: ensure .db extension for file operations - db_file_path = args.database if args.database.endswith('.db') else f"{args.database}.db" - db_base_name = args.database.replace('.db', '') if args.database.endswith('.db') else args.database + db_file_path = args.database if args.database.endswith( + '.db') else f"{args.database}.db" + db_base_name = args.database.replace( + '.db', '') if args.database.endswith('.db') else args.database # Create VectorDB instance (pass base name without .db) rag_db = VectorDB(retriever_model=args.retriever_model, reranker_model=args.reranker_model, device=args.device, - database=db_base_name, - load_embeddings=args.load_embeddings, num_embedding_devices=args.num_embedding_devices, - hierarchical=args.hierarchical, - embedding_device=args.embedding_device, - reranker_device=args.reranker_device, - benchmark=args.benchmark) + database=db_base_name, + load_embeddings=args.load_embeddings, num_embedding_devices=args.num_embedding_devices, + hierarchical=args.hierarchical, + embedding_device=args.embedding_device, + reranker_device=args.reranker_device, + benchmark=args.benchmark) if os.path.exists(db_file_path): # Load existing database @@ -208,18 +224,21 @@ def _generate_llm_answer(query, doc_entries, llm_config): rag_db.from_serialized(db_file_path) else: if not args.ingest: - raise ValueError("Either --database (existing) or --ingest (to create new) must be provided") - + raise ValueError( + "Either --database (existing) or --ingest (to create new) must be provided") + # Ingest from file or folder tic = time.time() rag_db.ingest_from_path(args.ingest) - + # Get number of passages for timing calculation - num_passages = len(rag_db._doc_list) # This should be available after ingestion + # This should be available after ingestion + num_passages = len(rag_db._doc_list) toc = time.time() - ingestion_speed = num_passages/(toc-tic) - print(f"Ingestion of {num_passages} passages took {toc - tic:.2f} seconds. {ingestion_speed:.2f} docs/sec") - + ingestion_speed = num_passages / (toc - tic) + print( + f"Ingestion of {num_passages} passages took {toc - tic:.2f} seconds. {ingestion_speed:.2f} docs/sec") + # Save the database (unless --no-save is specified) if not args.no_save: print(f"Saving database to {db_file_path}") @@ -229,16 +248,20 @@ def _generate_llm_answer(query, doc_entries, llm_config): # Run evaluation or single query lookup if args.eval: - max_queries = args.eval if isinstance(args.eval, int) and not isinstance(args.eval, bool) and args.eval > 0 else None - - # Build strategy_params with correct parameter names for filter function + max_queries = args.eval if isinstance( + args.eval, int) and not isinstance( + args.eval, bool) and args.eval > 0 else None + + # Build strategy_params with correct parameter names for filter + # function strategy_params = {"max_results": args.max_results} if args.retrieval_strategy == "top_p": strategy_params["p"] = args.top_p elif args.retrieval_strategy == "relative": strategy_params["ratio"] = args.relative_ratio - + answer_records = [] + def handle_result(prompt, retrieved_docs, metrics): urls = _extract_unique_urls(retrieved_docs) answer_text = None @@ -249,7 +272,8 @@ def handle_result(prompt, retrieved_docs, metrics): full_doc=args.full_doc_context, base_dir=doc_base_dir ) - answer_text = _generate_llm_answer(prompt, doc_entries, llm_config) + answer_text = _generate_llm_answer( + prompt, doc_entries, llm_config) print(f"LLM Answer: {answer_text}") if args.save_results: record = { @@ -270,19 +294,21 @@ def handle_result(prompt, retrieved_docs, metrics): retrieval_strategy=args.retrieval_strategy, detailed_analysis=True, difficulty=args.difficulty, - result_handler=handle_result if (args.generate_answer or args.save_results) else None, + result_handler=handle_result if ( + args.generate_answer or args.save_results) else None, **strategy_params ) - + # Save results for optimization results_data = { - "accuracy": metrics.get('legacy_score', 0.0), # Backward compatibility + # Backward compatibility + "accuracy": metrics.get('legacy_score', 0.0), "metrics": metrics } - + with open("results.json", "w") as f: json.dump(results_data, f, indent=2) - + if args.save_results: with open("result_single_shot.json", "w") as f: json.dump({ @@ -292,13 +318,13 @@ def handle_result(prompt, retrieved_docs, metrics): exit(0) # Exit after evaluation else: # Single query lookup - reuse evaluation code for consistency - + strategy_params = {} if args.retrieval_strategy == "top_p": strategy_params["p"] = args.top_p elif args.retrieval_strategy == "relative": strategy_params["ratio"] = args.relative_ratio - + # Time the retrieval tic = time.time() need_results = args.generate_answer or args.save_results @@ -329,7 +355,8 @@ def handle_result(prompt, retrieved_docs, metrics): full_doc=args.full_doc_context, base_dir=doc_base_dir ) - answer_value = _generate_llm_answer(args.query, doc_entries, llm_config) + answer_value = _generate_llm_answer( + args.query, doc_entries, llm_config) print(f"LLM Answer: {answer_value}") if args.save_results: @@ -345,5 +372,5 @@ def handle_result(prompt, retrieved_docs, metrics): "results": [record] }, f, indent=2) toc = time.time() - + print(f"\nLookup took {toc - tic:.3f} seconds") diff --git a/e2e-rag/text_splitter.py b/e2e-rag/text_splitter.py index 14c09a6752..a83f89718d 100644 --- a/e2e-rag/text_splitter.py +++ b/e2e-rag/text_splitter.py @@ -30,37 +30,39 @@ def clean_text(text: str) -> str: """Clean and normalize text.""" # Normalize whitespace text = re.sub(r'\s+', ' ', text.strip()) - + # Remove excessive newlines but preserve paragraph structure text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) - + return text -def find_sentence_boundary(text: str, start: int, end: int, search_window: int = 100) -> int: +def find_sentence_boundary(text: str, start: int, end: int, + search_window: int = 100) -> int: """ Find the best sentence boundary within the search window. - + Args: text: The text to search in start: Start position of the passage end: Desired end position search_window: Number of characters to look back for sentence boundary - + Returns: Best boundary position """ if end >= len(text): return len(text) - + # Look for sentence endings within the search window search_start = max(start, end - search_window) sentence_endings = ['.', '!', '?', '\n'] - + best_break = end for i in range(end - 1, search_start - 1, -1): if text[i] in sentence_endings: - # Check if it's followed by whitespace and uppercase letter (proper sentence end) + # Check if it's followed by whitespace and uppercase letter (proper + # sentence end) if i + 1 < len(text) and text[i + 1].isspace(): # Look for the next non-whitespace character j = i + 1 @@ -69,11 +71,12 @@ def find_sentence_boundary(text: str, start: int, end: int, search_window: int = if j < len(text) and (text[j].isupper() or text[j].isdigit()): best_break = i + 1 break - + return best_break -def split_into_passages(text: str, max_length: int = 512, overlap: int = 50) -> List[str]: +def split_into_passages(text: str, max_length: int = 512, + overlap: int = 50) -> List[str]: """ Split text into passages suitable for retrieval systems like ColBERT. @@ -87,7 +90,7 @@ def split_into_passages(text: str, max_length: int = 512, overlap: int = 50) -> """ # Clean up the text text = clean_text(text) - + if len(text) <= max_length: return [text] if text else [] @@ -97,7 +100,8 @@ def split_into_passages(text: str, max_length: int = 512, overlap: int = 50) -> while start < len(text): end = start + max_length - # If we're not at the end of the text, try to break at a sentence boundary + # If we're not at the end of the text, try to break at a sentence + # boundary if end < len(text): end = find_sentence_boundary(text, start, end) @@ -113,7 +117,8 @@ def split_into_passages(text: str, max_length: int = 512, overlap: int = 50) -> return passages -def split_into_fixed_passages(text: str, fixed_length: int = 256, overlap: int = 32) -> List[str]: +def split_into_fixed_passages( + text: str, fixed_length: int = 256, overlap: int = 32) -> List[str]: """ Split text into fixed-length passages with exact character counts. Useful for consistent passage lengths across datasets. @@ -128,7 +133,7 @@ def split_into_fixed_passages(text: str, fixed_length: int = 256, overlap: int = """ # Clean up the text text = clean_text(text) - + if len(text) <= fixed_length: return [text] if text else [] @@ -138,7 +143,7 @@ def split_into_fixed_passages(text: str, fixed_length: int = 256, overlap: int = while start < len(text): end = min(start + fixed_length, len(text)) passage = text[start:end].strip() - + if passage: passages.append(passage) @@ -150,15 +155,16 @@ def split_into_fixed_passages(text: str, fixed_length: int = 256, overlap: int = return passages -def create_passage_metadata(filename: str, passage_index: int, original_url: Optional[str] = None) -> dict: +def create_passage_metadata( + filename: str, passage_index: int, original_url: Optional[str] = None) -> dict: """ Create standardized metadata for a passage. - + Args: filename: Source filename (PDF or HTML) passage_index: Index of this passage within the document original_url: Original URL if available - + Returns: Dictionary containing passage metadata """ @@ -166,19 +172,20 @@ def create_passage_metadata(filename: str, passage_index: int, original_url: Opt base_filename = filename if '.' in filename: base_filename = '.'.join(filename.split('.')[:-1]) - + metadata = { 'index': passage_index, 'base_filename': base_filename } - + if original_url: metadata['original_url'] = original_url - + return metadata -def estimate_passage_count(text: str, max_length: int = 512, overlap: int = 50) -> int: +def estimate_passage_count( + text: str, max_length: int = 512, overlap: int = 50) -> int: """ Estimate the number of passages that will be created from text. Useful for progress tracking without actually splitting. @@ -227,7 +234,8 @@ def split_into_hierarchical_passages( if len(text) <= parent_length: # Single parent case - still create child chunks parent_text = text - children = split_into_passages(parent_text, child_length, child_overlap) + children = split_into_passages( + parent_text, child_length, child_overlap) results = [] for child_idx, child_text in enumerate(children): @@ -246,7 +254,8 @@ def split_into_hierarchical_passages( all_results = [] for parent_id, parent_text in enumerate(parent_chunks): # Split parent into children - children = split_into_passages(parent_text, child_length, child_overlap) + children = split_into_passages( + parent_text, child_length, child_overlap) # Create hierarchical entries for child_idx, child_text in enumerate(children): @@ -257,4 +266,4 @@ def split_into_hierarchical_passages( 'child_index': child_idx }) - return all_results \ No newline at end of file + return all_results diff --git a/e2e-rag/utils.py b/e2e-rag/utils.py index 6867c2aad3..50b9bddb05 100644 --- a/e2e-rag/utils.py +++ b/e2e-rag/utils.py @@ -27,7 +27,6 @@ from typing import Dict, Optional, Union, Any - def load_url_mapping(directory: str) -> Dict[str, str]: """Load URL mapping from url_mapping.json in specified directory.""" mapping_path = Path(directory) / "url_mapping.json" @@ -70,27 +69,28 @@ def set_deterministic_seeds(seed: int = 42) -> None: def filter_dataset_by_difficulty(df, difficulty: int = 0): """ Filter dataset by minimum number of answer links (difficulty level). - + Args: df: pandas DataFrame with dataset difficulty: Minimum number of answer links required (0 = no filtering) - + Returns: Filtered DataFrame with queries having >= difficulty answer links """ if difficulty <= 0: return df - + # Count answer links for each row link_counts = df.apply( - lambda row: sum(1 for col in df.columns - if col.startswith('wikipedia_link_') and row.notna()[col]), + lambda row: sum(1 for col in df.columns + if col.startswith('wikipedia_link_') and row.notna()[col]), axis=1 ) - + filtered_df = df[link_counts >= difficulty].reset_index(drop=True) - print(f"Filtered dataset by difficulty >= {difficulty}: {len(filtered_df)} queries remaining (from {len(df)} total)") - + print( + f"Filtered dataset by difficulty >= {difficulty}: {len(filtered_df)} queries remaining (from {len(df)} total)") + return filtered_df @@ -156,7 +156,8 @@ def set_mempolicy_membind(node: int) -> None: libnuma = ctypes.CDLL("libnuma.so.1", use_errno=True) rc = libnuma.set_mempolicy(MPOL_BIND, ctypes.byref(nodemask), maxnode) except (OSError, AttributeError): - # set_mempolicy is syscall 238 on x86_64; 237 on aarch64 (rare in this codebase). + # set_mempolicy is syscall 238 on x86_64; 237 on aarch64 (rare in this + # codebase). SYS_SET_MEMPOLICY_X86_64 = 238 libc = ctypes.CDLL("libc.so.6", use_errno=True) rc = libc.syscall(SYS_SET_MEMPOLICY_X86_64, MPOL_BIND, @@ -237,7 +238,8 @@ def pin_worker_to_node(node: int, cpu_set: list) -> None: if "OMP_NUM_THREADS" not in os.environ: os.environ["OMP_NUM_THREADS"] = str(len(cpu_set)) - print(f" [worker] node={node} cores={cpu_set[0]}..{cpu_set[-1]} ({len(cpu_set)}) OMP_NUM_THREADS={os.environ['OMP_NUM_THREADS']}") + print( + f" [worker] node={node} cores={cpu_set[0]}..{cpu_set[-1]} ({len(cpu_set)}) OMP_NUM_THREADS={os.environ['OMP_NUM_THREADS']}") def apply_numa_pinning() -> None: @@ -275,7 +277,8 @@ def apply_numa_pinning() -> None: print(f" sched_setaffinity failed: {e}; skipping pinning") return - print(f" Pinned CPU affinity to {len(cores)} cores: {cores[0]}..{cores[-1]}") + print( + f" Pinned CPU affinity to {len(cores)} cores: {cores[0]}..{cores[-1]}") def apply_cpu_threading_env() -> None: @@ -394,17 +397,20 @@ def _parse_override(self, override_env: str) -> list: ) return indices - def allocate(self, count: int = 1, name: str = "", override_env: str = "") -> list: + def allocate(self, count: int = 1, name: str = "", + override_env: str = "") -> list: if override_env: requested = self._parse_override(override_env) if requested: avail = [i for i in requested if i not in self._taken] source = f"{override_env}={','.join(map(str, requested))}" else: - avail = [i for i in self._all_indices if i not in self._taken and self._is_empty(i)] + avail = [ + i for i in self._all_indices if i not in self._taken and self._is_empty(i)] source = "auto" else: - avail = [i for i in self._all_indices if i not in self._taken and self._is_empty(i)] + avail = [ + i for i in self._all_indices if i not in self._taken and self._is_empty(i)] source = "auto" if len(avail) < count: @@ -417,7 +423,8 @@ def allocate(self, count: int = 1, name: str = "", override_env: str = "") -> li chosen = avail[:count] self._taken.update(chosen) label = name or self.device_type - print(f" Allocated {self.device_type}:{chosen} for {label} (via {source})") + print( + f" Allocated {self.device_type}:{chosen} for {label} (via {source})") return chosen @@ -428,14 +435,16 @@ def get_device_allocator(device_type: str) -> DeviceAllocator: return _DEVICE_ALLOCATORS[device_type] -def resolve_gpu_device(device: str, name: str = "", override_env: str = "") -> str: +def resolve_gpu_device(device: str, name: str = "", + override_env: str = "") -> str: """Map a bare device type ('cuda' / 'xpu') to a specific 'cuda:N' string. Returns `device` unchanged for cpu/hpu/auto/already-indexed strings. Errors if no empty GPU is available (use override_env to override). """ if device in ("cuda", "xpu"): - idx = get_device_allocator(device).allocate(count=1, name=name, override_env=override_env)[0] + idx = get_device_allocator(device).allocate( + count=1, name=name, override_env=override_env)[0] return f"{device}:{idx}" return device @@ -447,7 +456,8 @@ def detect_device() -> str: if torch.cuda.is_available(): if getattr(torch.version, "hip", None): - print(f"Using AMD ROCm GPU (torch.version.hip={torch.version.hip})") + print( + f"Using AMD ROCm GPU (torch.version.hip={torch.version.hip})") else: print("Using NVIDIA CUDA GPU") return "cuda" @@ -473,12 +483,14 @@ def get_model_info_from_service(service_url: str) -> Optional[Dict]: """Get model information from LLM service.""" try: # Try OpenAI-compatible API first - models_response = requests.get(f"{service_url.rstrip('/v1/chat/completions').rstrip('/v1')}/v1/models", timeout=10) + models_response = requests.get( + f"{service_url.rstrip('/v1/chat/completions').rstrip('/v1')}/v1/models", + timeout=10) if models_response.status_code == 200: models_data = models_response.json() if "data" in models_data and len(models_data["data"]) > 0: return models_data["data"][0] - + # Try alternative endpoints base_url = service_url.rstrip('/v1/chat/completions').rstrip('/v1') for endpoint in ["/models", "/info", "/v1/model"]: @@ -486,19 +498,19 @@ def get_model_info_from_service(service_url: str) -> Optional[Dict]: response = requests.get(f"{base_url}{endpoint}", timeout=5) if response.status_code == 200: return response.json() - except: + except BaseException: continue - + except Exception as e: print(f"Warning: Could not auto-detect model from {service_url}: {e}") - + return None def get_model_name_from_service(service_url: str) -> str: """Auto-detect model name from LLM service.""" model_info = get_model_info_from_service(service_url) - + if model_info: # Try different possible fields for model name for field in ["id", "model", "name", "model_name"]: @@ -512,18 +524,20 @@ def get_model_name_from_service(service_url: str) -> str: def get_max_tokens_from_service(service_url: str) -> int: """Auto-detect max tokens from LLM service.""" model_info = get_model_info_from_service(service_url) - + if model_info: # Try different possible fields for max tokens - for field in ["max_tokens", "max_length", "context_length", "max_context_length"]: + for field in ["max_tokens", "max_length", + "context_length", "max_context_length"]: if field in model_info and isinstance(model_info[field], int): return model_info[field] - + # Default fallback based on common models return 10240 -def resolve_config_value(value: Union[str, int], auto_func, *args) -> Union[str, int]: +def resolve_config_value( + value: Union[str, int], auto_func, *args) -> Union[str, int]: """Resolve configuration value that might be 'auto'.""" if value == "auto": return auto_func(*args) @@ -537,21 +551,22 @@ def get_device_config(): "device_count": 1, "device_memory": None } - + if torch is None: return config - + if config["device_type"] == "hpu": config["device_count"] = torch.hpu.device_count() - + elif config["device_type"] == "cuda": config["device_count"] = torch.cuda.device_count() if torch.cuda.is_available(): - config["device_memory"] = torch.cuda.get_device_properties(0).total_memory - + config["device_memory"] = torch.cuda.get_device_properties( + 0).total_memory + elif config["device_type"] == "xpu": config["device_count"] = torch.xpu.device_count() - + return config @@ -589,7 +604,8 @@ def setup_llm_config(args): grader_service_url = getattr(args, 'grader_service_url', None) or base_url grader_model_name = getattr(args, 'grader_model', None) or model_name query_service_url = getattr(args, 'query_service_url', None) or base_url - sufficiency_service_url = getattr(args, 'sufficiency_service_url', None) or base_url + sufficiency_service_url = getattr( + args, 'sufficiency_service_url', None) or base_url return { "service_url": base_url, diff --git a/loadgen/issue_query_controller.cc b/loadgen/issue_query_controller.cc index 4c5ca66f0c..c1abea9d14 100644 --- a/loadgen/issue_query_controller.cc +++ b/loadgen/issue_query_controller.cc @@ -459,8 +459,8 @@ void IssueQueryController::IssueQueriesInternal(size_t query_stride, #if USE_NEW_LOGGING_FORMAT std::stringstream ss; ss << "IssueQueryThread " << thread_idx - << " Ending early: Too many outstanding queries." - << " issued " << queries_issued_total << " outstanding " + << " Ending early: Too many outstanding queries." << " issued " + << queries_issued_total << " outstanding " << queries_outstanding; MLPERF_LOG_ERROR(detail, "error_runtime", ss.str()); #else @@ -499,8 +499,8 @@ void IssueQueryController::IssueQueriesInternal(size_t query_stride, #if USE_NEW_LOGGING_FORMAT std::stringstream ss; ss << "IssueQueryThread " << thread_idx - << " Ending early: Max query count reached." - << " query_count " << queries_issued; + << " Ending early: Max query count reached." << " query_count " + << queries_issued; MLPERF_LOG_ERROR(detail, "error_runtime", ss.str()); #else detail.Error("IssueQueryThread ", std::to_string(thread_idx), @@ -519,8 +519,8 @@ void IssueQueryController::IssueQueriesInternal(size_t query_stride, #if USE_NEW_LOGGING_FORMAT std::stringstream ss; ss << "IssueQueryThread " << thread_idx - << " Ending early: Max test duration reached." - << " duration_ns " << duration.count(); + << " Ending early: Max test duration reached." << " duration_ns " + << duration.count(); MLPERF_LOG_ERROR(detail, "error_runtime", ss.str()); #else detail.Error("IssueQueryThread ", std::to_string(thread_idx), diff --git a/loadgen/logging.cc b/loadgen/logging.cc index d7e83e54b9..807c1954a8 100644 --- a/loadgen/logging.cc +++ b/loadgen/logging.cc @@ -812,8 +812,7 @@ void Logger::CollectTlsLoggerStats(TlsLogger* tls_logger) { if (max_entry_vector_size > kTlsLogReservedEntryCount) { #if USE_NEW_LOGGING_FORMAT std::stringstream msg; - msg << "Logging allocation detected:" - << " tid: " << tls_logger->Tid() + msg << "Logging allocation detected:" << " tid: " << tls_logger->Tid() << " reserved_entries: " << kTlsLogReservedEntryCount << " max_entries: " << max_entry_vector_size; MLPERF_LOG_WARNING((*this), "warning_generic_message", msg.str()); From e708a735050f3aead70a767215c9d8006a2673c6 Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Tue, 7 Jul 2026 19:27:05 +0530 Subject: [PATCH 42/59] Add system info collection documentation for MLPerf Inference Adds docs/system-info/index.md covering the get-mlperf-multi-node-system-info script for MLPerf Inference submissions: installation, quick start, config file usage, serving framework detection, sample output, and a verified field reference table aligned with SYSTEM_DESC_REQUIRED_FIELDS. Co-Authored-By: Claude Sonnet 4.6 --- docs/system-info/index.md | 246 ++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 2 files changed, 248 insertions(+) create mode 100644 docs/system-info/index.md diff --git a/docs/system-info/index.md b/docs/system-info/index.md new file mode 100644 index 0000000000..c059e76cfe --- /dev/null +++ b/docs/system-info/index.md @@ -0,0 +1,246 @@ +# MLPerf Inference System Info Collection + +This guide covers how to automatically collect hardware and software information from one or more nodes for MLPerf Inference submissions using the MLC `get-mlperf-multi-node-system-info` script. + +## Prerequisites + +**Install MLC** — Follow the [MLC installation guide](../install/index.md) to set up `mlc-scripts`. + +**SSH access** — For multi-node setups the script SSHes into each remote node to run the hardware probe. Password-based SSH works, but passwordless (key-based) access is strongly recommended — it is seamless and avoids repeated prompts when probing a large number of nodes. + +To set up passwordless SSH: + +```bash +# Generate a key if you don't have one +ssh-keygen -t rsa -b 4096 + +# Copy it to every target node +ssh-copy-id user@node1 +ssh-copy-id user@node2 + +# Verify +ssh user@node1 "hostname && nvidia-smi -L" +``` + +## Quick Start + +The same `get-mlperf-multi-node-system-info` script handles everything from a single node to a large cluster. Internally it always calls `get-mlperf-single-node-system-info` on each target node (including the local machine as node 0). When no `--ssh_ids` are given it probes only the local machine. + +**Single node (local machine)** + +```bash +mlcr get-mlperf-multi-node-system-info,_cuda,_inference \ + --system_name="My-1xH100-System" +``` + +**Multi-node cluster** + +```bash +mlcr get-mlperf-multi-node-system-info,_cuda,_inference \ + --ssh_ids="user@node1:22,user@node2:22,user@node3:22" \ + --system_name="24xH100-Cluster" +``` + +The local machine is included as node 0; each SSH target becomes node 1, 2, 3, and so on. To collect from SSH targets only and exclude the local machine: + +```bash +mlcr get-mlperf-multi-node-system-info,_cuda,_inference,_exclude_current_node \ + --ssh_ids="user@node1:22,user@node2:22" \ + --system_name="Remote-Only-Cluster" +``` + +## Using a Config File + +For repeated runs or shared team configs, store submission metadata in a YAML or JSON file instead of passing many CLI flags. CLI arguments always take precedence over config file values. + +```yaml +# system_config.yaml +submitter_org_names: "Your Organization" +system_name: "8xH100-vLLM-Server" +division: "open" +system_category: "datacenter" +system_availability_status: "available" +cooling: "air" +hw_notes: "DGX H100 node, 8x NVLink-connected H100 SXM5" +system_type_detail: "on-premise" +``` + +```bash +mlcr get-mlperf-multi-node-system-info,_cuda,_inference \ + --config_file=system_config.yaml \ + --ssh_ids="user@node1:22,user@node2:22" +``` + + + +## Serving Framework Detection + +`framework` is a required field for the MLPerf Inference submission. The script can detect it automatically in two ways. + +**Auto-detect via HTTP probe** — provide `--endpoint_url` and the script probes the running inference server: + +```bash +mlcr get-mlperf-multi-node-system-info,_cuda,_inference \ + --ssh_ids="user@node1:22" \ + --endpoint_url="http://node1:8000" \ + --system_name="vLLM-System" +``` + +| Framework | Endpoint probed | +|-----------|----------------| +| TRT-LLM | `/perf_metrics` | +| vLLM | `/version` | +| SGLang | `/get_server_info` | + + +## Output + +The script writes `system-info-multi-node.json` in the current directory. The full path is printed at the end of the run and exported as `MLC_MULTI_NODE_SYSTEM_INFO_FILE_PATH`. + +With `_inference`, the output is a flat JSON as required for the MLPerf Inference submission. Submitters must verify the generated `system-info-multi-node.json` and manually fill in any fields that are empty. Fields expected to be auto-detected (see [Hardware and Software Fields](#hardware-and-software-fields) below) should not be empty — if any of those come out as an empty string, please [raise an issue](https://github.com/mlcommons/mlperf-automations/issues) with the field name and details of your machine. + +When the `_network` variation is also active, all network mode fields are appended to the output as empty strings and must be filled in manually — see [Network Mode Fields](#network-mode-fields-with-_network-variation) for the full list. + +```json +{ + "submitter": "Your Organization", + "system_name": "2xDGX-H100-vLLM", + "status": "available", + "system_type": "datacenter", + "division": "open", + "system_size": "16x NVIDIA H100 80GB HBM3", + "number_of_nodes": 2, + "host_processor_model_name": "Intel(R) Xeon(R) Platinum 8480+", + "host_processors_per_node": 2, + "host_processor_core_count": 112, + "host_processor_vcpu_count": 224, + "host_processor_frequency": "3.80 GHz", + "host_processor_caches": "L1d: 4.4 MiB; L1i: 2.2 MiB; L2: 224 MiB; L3: 210 MiB", + "host_processor_interconnect": "", + "host_memory_capacity": "2.2T", + "host_storage_type": "NVMe SSD", + "host_storage_capacity": "1.8 TB SSD", + "host_memory_configuration": "DDR5", + "host_networking": "mlx5_0: native InfiniBand", + "host_networking_topology": "", + "host_network_card_count": "3x mlx5_0: native InfiniBand", + "accelerator_model_name": "NVIDIA H100 80GB HBM3", + "accelerators_per_node": 8, + "accelerator_memory_capacity": "80GiB", + "accelerator_memory_configuration": "80 GiB HBM3", + "accelerator_host_interconnect": "PCIe Gen5 x16", + "accelerator_interconnect": "NVLink", + "accelerator_interconnect_topology": "", + "accelerator_frequency": "", + "accelerator_on-chip_memories": "Shared Memory: 228 KB/block", + "framework": "vLLM 0.4.3", + "operating_system": "ubuntu 24.04", + "other_software_stack": "CUDA 12.9, Driver 575.57.08", + "hw_notes": "", + "sw_notes": "", + "other_hardware": "", + "cooling": "air", + "system_type_detail": "" +} +``` + +## Information Captured + +### Hardware and Software Fields + +These are collected automatically on each node. If a value cannot be detected (driver missing, command unavailable), the field is set to `""` or `"N/A"` in the output for manual completion. + +| Field | Description | Auto-Detected | +|-------|-------------|:---:| +| `host_processor_model_name` | CPU model name | ✅ | +| `host_processors_per_node` | Number of CPU sockets | ✅ | +| `host_processor_core_count` | Physical CPU cores per socket | ✅ | +| `host_processor_frequency` | CPU maximum frequency | ✅ | +| `host_processor_caches` | L1d / L1i / L2 / L3 cache sizes | ✅ | +| `host_processor_interconnect` | CPU-to-CPU interconnect inferred from NUMA topology | ✅ | +| `host_memory_capacity` | Total system RAM | ✅ | +| `host_memory_configuration` | Memory type and speed | ✅ | +| `host_storage_type` | Primary storage type (NVMe, SSD, HDD) | ✅ | +| `host_storage_capacity` | Total disk capacity | ✅ | +| `host_networking` | Primary NIC description | ✅ | +| `host_network_card_count` | NIC count and model | ✅ | +| `accelerator_model_name` | GPU model name | ✅ | +| `accelerators_per_node` | Number of GPUs per node | ✅ | +| `accelerator_memory_capacity` | GPU memory per device | ✅ | +| `accelerator_memory_configuration` | GPU memory size and type | ✅ | +| `accelerator_host_interconnect` | Host-to-GPU link (PCIe Gen, NVLink) | ✅ | +| `accelerator_interconnect` | GPU-to-GPU link (NVLink, xGMI) | ✅ | +| `accelerator_interconnect_topology` | Interconnect topology description | ⚠️ CUDA only; may be empty | +| `accelerator_frequency` | GPU clock frequency | ✅ | +| `accelerator_on-chip_memories` | Shared memory per SM block | ✅ | +| `operating_system` | OS distribution and version | ✅ | +| `other_software_stack` | CUDA/ROCm version + driver version | ✅ | +| `number_of_nodes` | Total node count | ✅ Computed from SSH targets | +| `framework` | Inference framework and version | ⚠️ Requires `--endpoint_url` | + +### Submission Identity Fields + +These fields are not detectable from hardware and must be supplied via CLI flags, a config file, or by directly editing the output JSON before submission. + +| Field | CLI Flag | Config Key | Notes | +|-------|----------|-----------|-------| +| `system_name` | `--system_name` | `system_name` | Required | +| `submitter` | `--submitter_org_names` | `submitter_org_names` | Required | +| `division` | `--division` | `division` | `open` or `closed` | +| `status` | `--system_availability_status` | `system_availability_status` | System availability — `available` (publicly available), `preview` (available soon), or `rdi` (Research, Development, and Internal use only) | +| `system_type` | `--category` | `system_category` | `datacenter` or `edge` | +| `cooling` | `--cooling` | `cooling` | e.g. `air`, `liquid` | +| `hw_notes` | `--hw_notes` | `hw_notes` | Hardware notes | +| `sw_notes` | *(manual edit)* | — | Software notes; fill in the output JSON | +| `host_networking_topology` | *(manual edit)* | — | Network topology description (not auto-detected; different from `host_networking` which is captured automatically) | +| `system_type_detail` | `--system_type_detail` | `system_type_detail` | More specific system type — `cloud`, `on-premise`, `edge-server`, or `edge-device` (optional) | + +### Network Mode Fields (with `_network` variation) + +When the `_network` variation is active alongside `_inference`, the script adds the fields required for network mode submissions as per MLPerf Inference submission rules. All fields are initialised to `""` and must be filled in manually. + +`is_network`, `network_type`, `network_media`, `network_rate`, `nic_loadgen`, `number_nic_loadgen`, `net_software_stack_loadgen`, `network_protocol`, `number_connections`, `nic_sut`, `number_nic_sut`, `net_software_stack_sut`, `network_topology` + +### Power Measurement Fields (with `_power` variation) + +When the `_power` variation is active, the following additional fields are required. All are initialised to `""` and must be filled in manually. + +`power_management`, `filesystem`, `boot_firmware_version`, `management_firmware_version`, `other_hardware`, `number_of_type_nics_installed`, `nics_enabled_firmware`, `nics_enabled_os`, `nics_enabled_connected`, `network_speed_mbit`, `power_supply_quantity_and_rating_watts`, `power_supply_details`, `disk_drives`, `disk_controllers`, `system_power_only` + +## Available Variations + +| Variation | Description | +|-----------|-------------| +| `_cuda` | Probe NVIDIA GPUs via CUDA | +| `_rocm` | Probe AMD GPUs via ROCm | +| `_xpu` | Probe Intel GPUs via XPU | +| `_inference` | Flat JSON output as required for MLPerf Inference submission | +| `_exclude_current_node` | Skip the local machine; collect only from SSH targets | +| `_network` | Add network mode fields to the output | +| `_power` | Add power measurement fields to the output | + +Variations can be stacked: + +```bash +mlcr get-mlperf-multi-node-system-info,_cuda,_inference,_power \ + --ssh_ids="user@node1:22" \ + --system_name="My-System" +``` + +## Key Parameters Reference + +| CLI Flag | Environment Variable | Description | +|----------|---------------------|-------------| +| `--ssh_ids` | `MLC_MULTINODE_SYSTEM_SSH_IDS` | Comma-separated SSH targets (`user@host:port`) | +| `--system_name` | `MLC_MLPERF_SYSTEM_NAME` | System identifier (required) | +| `--config_file` | `MLC_MLPERF_CONFIG_FILE` | Path to a JSON / YAML config file | +| `--endpoint_url` | `MLC_MLPERF_ENDPOINT_URL` | Endpoint URL for serving framework auto-detection | +| `--submitter_org_names` | `MLC_MLPERF_SUBMITTER` | Submitting organization name | +| `--division` | `MLC_MLPERF_SUBMISSION_DIVISION` | `open` or `closed` | +| `--category` | `MLC_MLPERF_SUBMISSION_SYSTEM_TYPE` | `datacenter` or `edge` | +| `--system_availability_status` | `MLC_MLPERF_SUBMISSION_SYSTEM_STATUS` | `available`, `preview`, or `rdi` | +| `--cooling` | `MLC_MLPERF_COOLING` | Cooling method | +| `--hw_notes` | `MLC_MLPERF_HARDWARE_NOTES` | Hardware notes | +| `--system_type_detail` | `MLC_MLPERF_SYSTEM_TYPE_DETAIL` | `cloud`, `on-premise`, `edge-server`, or `edge-device` (optional) | + +If you hit any issues while using this script, please feel free to raise an issue at [https://github.com/mlcommons/mlperf-automations](https://github.com/mlcommons/mlperf-automations). diff --git a/mkdocs.yml b/mkdocs.yml index 634b4fd21f..c6b66b9c10 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -65,6 +65,8 @@ nav: - Automated Submission CLI: submission/submission-cli.md - Power: - power/index.md + - System Info: + - system-info/index.md - Release Notes: - What's New: changelog/index.md - Changelog: changelog/changelog.md From a5efd6999136990254bfb0d4c3140ee66a4e0015 Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Tue, 7 Jul 2026 20:06:33 +0530 Subject: [PATCH 43/59] docs: add v6.1 scope note to system-info page Clarifies that power and network mode fields are scoped for future rounds and must be filled in manually before submission. Co-Authored-By: Claude Sonnet 4.6 --- docs/system-info/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/system-info/index.md b/docs/system-info/index.md index c059e76cfe..71326f8056 100644 --- a/docs/system-info/index.md +++ b/docs/system-info/index.md @@ -2,6 +2,9 @@ This guide covers how to automatically collect hardware and software information from one or more nodes for MLPerf Inference submissions using the MLC `get-mlperf-multi-node-system-info` script. +!!! note "MLPerf Inference v6.1 scope" + For inference round v6.1, the sysinfo tool aims to automate system inventory collection (CPU, GPU, memory, storage, OS and software stack). Power and Network mode fields are scoped for future rounds and scaffolded as empty strings in the output. Submitters have to fill them in manually before submission. + ## Prerequisites **Install MLC** — Follow the [MLC installation guide](../install/index.md) to set up `mlc-scripts`. From 1a085311e9058a2e4c14e907949e9f61dddab230 Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Tue, 7 Jul 2026 21:04:27 +0530 Subject: [PATCH 44/59] update benchmark run docs for v6.1 --- docs/benchmarks/image_classification/resnet50.md | 7 ++++--- docs/benchmarks/language/bert.md | 10 ++++++---- docs/benchmarks/language/llama2-70b.md | 3 +++ docs/benchmarks/text_to_image/sdxl.md | 8 +++++--- mkdocs.yml | 9 --------- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/benchmarks/image_classification/resnet50.md b/docs/benchmarks/image_classification/resnet50.md index 3840e6be6b..e2619d95cc 100644 --- a/docs/benchmarks/image_classification/resnet50.md +++ b/docs/benchmarks/image_classification/resnet50.md @@ -10,14 +10,15 @@ hide: === "MLCommons-Python" ## MLPerf Reference Implementation in Python -{{ mlperf_inference_implementation_readme (4, "resnet50", "reference") }} +{{ mlperf_inference_implementation_readme (4, "resnet50", "reference", categories=["Edge"]) }} === "Nvidia" ## Nvidia MLPerf Implementation {{ mlperf_inference_implementation_readme (4, "resnet50", "nvidia") }} - + +{# === "Intel" ## Intel MLPerf Implementation @@ -34,4 +35,4 @@ hide: {{ mlperf_inference_implementation_readme (4, "resnet50", "cpp") }} ---> +#} diff --git a/docs/benchmarks/language/bert.md b/docs/benchmarks/language/bert.md index 51ac91c86e..9d0ae09b25 100644 --- a/docs/benchmarks/language/bert.md +++ b/docs/benchmarks/language/bert.md @@ -8,9 +8,9 @@ hide: === "MLCommons-Python" ## MLPerf Reference Implementation in Python -{{ mlperf_inference_implementation_readme (4, "bert-99", "reference") }} +{{ mlperf_inference_implementation_readme (4, "bert-99", "reference", categories=["Edge"]) }} -{{ mlperf_inference_implementation_readme (4, "bert-99.9", "reference") }} +{{ mlperf_inference_implementation_readme (4, "bert-99.9", "reference", categories=["Edge"]) }} === "Nvidia" ## Nvidia MLPerf Implementation @@ -19,7 +19,8 @@ hide: {{ mlperf_inference_implementation_readme (4, "bert-99.9", "nvidia") }} -s \ No newline at end of file + +#} \ No newline at end of file diff --git a/docs/benchmarks/language/llama2-70b.md b/docs/benchmarks/language/llama2-70b.md index effc7b18a6..7e4b243aa6 100644 --- a/docs/benchmarks/language/llama2-70b.md +++ b/docs/benchmarks/language/llama2-70b.md @@ -12,6 +12,8 @@ hide: {{ mlperf_inference_implementation_readme (4, "llama2-70b-99.9", "reference") }} +{# + === "Nvidia" ## Nvidia MLPerf Implementation @@ -19,6 +21,7 @@ hide: {{ mlperf_inference_implementation_readme (4, "llama2-70b-99.9", "nvidia") }} +#} + +#} diff --git a/mkdocs.yml b/mkdocs.yml index c6b66b9c10..175690dccb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,26 +34,17 @@ nav: - External Use: - SCC24 Guide: benchmarks/text_to_image/reproducibility/scc24.md - 2D Object Detection: - - RetinaNet: benchmarks/object_detection/retinanet.md - Yolo: benchmarks/object_detection/yolo.md - - Automotive: - - 3D Object Detection: - - PointPainting: benchmarks/automotive/3d_object_detection/pointpainting.md - Medical Imaging: - 3d-unet: benchmarks/medical_imaging/3d-unet.md - Language Processing: - Bert-Large: benchmarks/language/bert.md - - GPT-J: benchmarks/language/gpt-j.md - LLAMA2-70B: - Run Commands: benchmarks/language/llama2-70b.md - External Use: - SCC25 Guide: benchmarks/language/scc25_guide/scc25.md - - LLAMA3-405B: benchmarks/language/llama3_1-405b.md - LLAMA3-8B: benchmarks/language/llama3_1-8b.md - - MIXTRAL-8x7B: benchmarks/language/mixtral-8x7b.md - DeepSeek-R1: benchmarks/language/deepseek-r1.md - - Recommendation: - - DLRM-v2: benchmarks/recommendation/dlrm-v2.md - Graph Neural Networks: - R-GAT: benchmarks/graph/rgat.md - Speech to Text: From 1d88318fef4060c1cbc1dd2b1455a37f2b3e4dc4 Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:55:47 +0530 Subject: [PATCH 45/59] Update README.md --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 496a93718a..8f888f148c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # Documentation Website for MLPerf Inference using the MLC interface ## Commands to get the website running locally + ``` git clone https://github.com/mlcommons/inference cd inference From 9d99a0c72decfc2b3293ebe5d18724455de9ddab Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:08:09 +0530 Subject: [PATCH 46/59] Update main.py --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index f76e510bd3..9acecb29fd 100755 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ + def define_env(env): @env.macro From 60463564e5f60918aaf32f17fc3e875a64b3c49d Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:31:49 +0530 Subject: [PATCH 47/59] Update main.py --- main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main.py b/main.py index 9acecb29fd..f76e510bd3 100755 --- a/main.py +++ b/main.py @@ -1,4 +1,3 @@ - def define_env(env): @env.macro From 5db1668a15be7d6f2665434d6e4c954fae398dbd Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Tue, 28 Jul 2026 11:06:14 +0530 Subject: [PATCH 48/59] fix directory structure info --- docs/submission/submission-cli.md | 77 +++++++++++++++++++------------ 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/docs/submission/submission-cli.md b/docs/submission/submission-cli.md index f920bbfa11..d573a029f1 100644 --- a/docs/submission/submission-cli.md +++ b/docs/submission/submission-cli.md @@ -9,35 +9,54 @@ Please refer to the [installation page](site:inference/install/) to install MLCF === "Custom automation based MLPerf results" If you have not followed the `mlcr` commands under the individual model pages in the [benchmarks](../index.md) directory, please make sure that the result directory is structured in the following way. You can see the real examples for the expected folder structure [here](https://github.com/mlcommons/inference/tree/submission-generation-examples). - ``` - └── System description ID(SUT Name) - ├── system_meta.json - └── Benchmark - └── Scenario - ├── Performance - | └── run_1 run for all scenarios - | ├── mlperf_log_summary.txt - | └── mlperf_log_detail.txt - ├── Accuracy - | ├── mlperf_log_summary.txt - | ├── mlperf_log_detail.txt - | ├── mlperf_log_accuracy.json - | └── accuracy.txt - |── Compliance_Test_ID - | ├── Performance - | | └── run_x/#1 run for all scenarios - | | ├── mlperf_log_summary.txt - | | └── mlperf_log_detail.txt - | ├── Accuracy # for TEST01 only - | | ├── baseline_accuracy.txt (if test fails in deterministic mode) - | | ├── compliance_accuracy.txt (if test fails in deterministic mode) - | | ├── mlperf_log_accuracy.json - | | └── accuracy.txt - | ├── verify_performance.txt - | └── verify_accuracy.txt # for TEST01 only - |── user.conf - └── measurements.json - ``` + + The submission generator supports two kinds of results, shown in the tabs below. **LoadGen based results** are produced by the reference/optimized implementations and contain the usual `mlperf_log_*` files. **Inference endpoint based results** are produced by the LLM/API endpoint harness; a scenario is treated as an endpoint run when a `config.yaml` (or `config.yml`) is present at the scenario root, in which case a single JSON summary replaces the LoadGen logs and there is no `run_1` subfolder. The mode folder names (`performance`, `accuracy`) must be lowercase. + + === "LoadGen based results" + ``` + └── System description ID(SUT Name) + ├── system_meta.json + └── Benchmark + └── Scenario + ├── performance + | └── run_1 run for all scenarios + | ├── mlperf_log_summary.txt + | └── mlperf_log_detail.txt + ├── accuracy + | ├── mlperf_log_summary.txt + | ├── mlperf_log_detail.txt + | ├── mlperf_log_accuracy.json + | └── accuracy.txt + |── Compliance_Test_ID + | ├── performance + | | └── run_x/#1 run for all scenarios + | | ├── mlperf_log_summary.txt + | | └── mlperf_log_detail.txt + | ├── accuracy # for TEST01 only + | | ├── baseline_accuracy.txt (if test fails in deterministic mode) + | | ├── compliance_accuracy.txt (if test fails in deterministic mode) + | | ├── mlperf_log_accuracy.json + | | └── accuracy.txt + | ├── verify_performance.txt + | └── verify_accuracy.txt # for TEST01 only + |── user.conf + └── measurements.json + ``` + + === "Inference endpoint based results" + ``` + └── System description ID(SUT Name) + ├── system_meta.json + └── Benchmark + └── Scenario + ├── config.yaml # endpoint run marker (config.yml also accepted); copied into the submission + ├── measurements.json + ├── performance + | └── result_summary.json + └── accuracy + └── accuracy_results.json + ``` + Note: endpoint runs do not require a `user.conf` and do not use the `run_1` subfolder or any `mlperf_log_*` files.
Click here if you are submitting in open division From d86076b4fc78a99069fb583f4c56090afbc4fa3a Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Tue, 28 Jul 2026 21:05:27 +0530 Subject: [PATCH 49/59] Document optional nameplate power YAML in system-info guide Adds a section covering the new _redfish and _inference_optional_nameplate variations of get-mlperf-multi-node-system-info: the skeleton template written when used alone, and real PSU data populated from a live Redfish BMC when stacked with _redfish, including the Redfish-to-nameplate field mapping and required CLI parameters. Co-Authored-By: Claude Sonnet 5 --- docs/system-info/index.md | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/system-info/index.md b/docs/system-info/index.md index 71326f8056..02764c366c 100644 --- a/docs/system-info/index.md +++ b/docs/system-info/index.md @@ -210,6 +210,60 @@ When the `_power` variation is active, the following additional fields are requi `power_management`, `filesystem`, `boot_firmware_version`, `management_firmware_version`, `other_hardware`, `number_of_type_nics_installed`, `nics_enabled_firmware`, `nics_enabled_os`, `nics_enabled_connected`, `network_speed_mbit`, `power_supply_quantity_and_rating_watts`, `power_supply_details`, `disk_drives`, `disk_controllers`, `system_power_only` +### Optional Nameplate Power YAML (`_inference_optional_nameplate`, `_redfish`) + +Separate from the fields above, the script can also generate the **optional +nameplate / design-power YAML** described in the inference submission rules +(`tools/submission/submission_structure.md`). This is not part of +`system-info-multi-node.json` — it's a standalone file consumed by the +MLPerf Inference `submission_checker`'s `nameplate_power_check`, which sums +`PowerCapacityWatts` across the `Min PSUs Needed` largest PSUs per leaf node +of PSU declarations. The checker expects it at +`systems/_power.yaml` (required starting `v6.1`). + +Two modes, selected by whether `_redfish` is also active: + +| Tags | What gets written | +|------|--------------------| +| `_inference_optional_nameplate` alone | A generic **skeleton template** — placeholder `My Rack 1` / `My Server 1` / `My Switch 1` labels, two PSUs at 1200W each, `Description: 'Optional Description'` — for you to fill in by hand. No BMC is contacted. | +| `_inference_optional_nameplate,_redfish` | The **real** PSU nameplate/capacity data, queried live from a Redfish-enabled BMC (or a [DMTF Redfish mockup server](https://github.com/DMTF/Redfish-Mockup-Server) for local testing) | + +**Skeleton template (no BMC):** + +```bash +mlcr get-mlperf-multi-node-system-info,_inference,_inference_optional_nameplate \ + --system_name="My-System" +``` + +**Populated from a live Redfish BMC:** + +```bash +mlcr get-mlperf-multi-node-system-info,_inference,_redfish,_inference_optional_nameplate \ + --system_name="My-System" \ + --redfish_endpoint="https://bmc.example.com" \ + --redfish_username="admin" \ + --redfish_password="secret" +``` + +When populated from Redfish, PSU data is sourced as follows: + +| Nameplate field | Redfish source | +|---|---| +| `PSUs[].Name` / `PowerCapacityWatts` | `Chassis//PowerSubsystem/PowerSupplies/` (preferred), falling back to the legacy `Chassis//Power` → `PowerSupplies[]` on BMCs that only implement the older schema | +| `Min PSUs Needed` | `Chassis//PowerSubsystem` → `PowerSupplyRedundancy[].MinNeededInGroup`, when reported; otherwise conservatively defaults to the number of installed PSUs (no redundancy credit) | + +Redfish has no concept of rack/system grouping above a chassis — each +chassis becomes one flat leaf under ``, even with real BMC +data. If you want an explicit rack layer in between (as the skeleton +template shows), add it to the generated YAML by hand. + +The output is written to `_power.yaml` and its path is exported as +`MLC_NAMEPLATE_POWER_YAML_FILE_PATH`. As with the main system-info JSON, +you still need to copy/rename this file into your submission's `systems/` +directory to match whatever `` that submission actually +uses. + ## Available Variations | Variation | Description | @@ -221,6 +275,8 @@ When the `_power` variation is active, the following additional fields are requi | `_exclude_current_node` | Skip the local machine; collect only from SSH targets | | `_network` | Add network mode fields to the output | | `_power` | Add power measurement fields to the output | +| `_redfish` | Capture live PSU/power data from a Redfish BMC (used with `_inference_optional_nameplate`, or alone for a raw reference capture) | +| `_inference_optional_nameplate` | Generate the optional nameplate power YAML — a skeleton template alone, or populated from Redfish when stacked with `_redfish` (see [Optional Nameplate Power YAML](#optional-nameplate-power-yaml-_inference_optional_nameplate-_redfish)) | Variations can be stacked: @@ -245,5 +301,8 @@ mlcr get-mlperf-multi-node-system-info,_cuda,_inference,_power \ | `--cooling` | `MLC_MLPERF_COOLING` | Cooling method | | `--hw_notes` | `MLC_MLPERF_HARDWARE_NOTES` | Hardware notes | | `--system_type_detail` | `MLC_MLPERF_SYSTEM_TYPE_DETAIL` | `cloud`, `on-premise`, `edge-server`, or `edge-device` (optional) | +| `--redfish_endpoint` | `MLC_REDFISH_ENDPOINT` | Redfish BMC base URL (only relevant with `_redfish`) | +| `--redfish_username` | `MLC_REDFISH_USERNAME` | BMC username; leave unset for an unauthenticated mockup | +| `--redfish_password` | `MLC_REDFISH_PASSWORD` | BMC password | If you hit any issues while using this script, please feel free to raise an issue at [https://github.com/mlcommons/mlperf-automations](https://github.com/mlcommons/mlperf-automations). From e95816fbcd1e7c9bdd3e8249740e7c9c618c10bc Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:44:25 +0530 Subject: [PATCH 50/59] Update main.py --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index f76e510bd3..9acecb29fd 100755 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ + def define_env(env): @env.macro From e6238bd1e9a7c1eb2a9cc651c12759de38aad3bb Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:04:51 +0530 Subject: [PATCH 51/59] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bdf3c3a44c..5314c3ace8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # MLPerf® Inference Benchmark Suite MLPerf Inference is a benchmark suite for measuring how fast systems can run models in a variety of deployment scenarios. From 765c47682884aabab7c7b8634ad4940f44174d2a Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Wed, 26 Aug 2026 20:04:47 +0530 Subject: [PATCH 52/59] Resolve docs/master conflicts in e2e-rag Take master's versions of the three files that conflict when merging docs into master: - e2e-rag/db_manifest.py - e2e-rag/datasetup_accuracy_eval.py - e2e-rag/reference_SUT_datasetup.py The conflicts came from master's functional rewrites (#2645, #2625) landing on top of the automated "Format Codebase" reflow of the older code on docs. Docs never made a manual edit to these files, so master's content is authoritative and nothing is lost. The files are left byte-identical to master rather than reformatted: autopep8 on the docs side rewrites the same line ranges master did, which reintroduces the conflicts. Formatting can follow once both branches agree on content. Co-Authored-By: Claude Opus 5 --- e2e-rag/datasetup_accuracy_eval.py | 105 ++++++- e2e-rag/db_manifest.py | 488 ++++++++++++++++++++++------- e2e-rag/reference_SUT_datasetup.py | 13 +- 3 files changed, 476 insertions(+), 130 deletions(-) diff --git a/e2e-rag/datasetup_accuracy_eval.py b/e2e-rag/datasetup_accuracy_eval.py index 3f89041b46..f21dd8094c 100755 --- a/e2e-rag/datasetup_accuracy_eval.py +++ b/e2e-rag/datasetup_accuracy_eval.py @@ -274,8 +274,9 @@ def validate_database(database_path, retriever_model): return validation_results -def evaluate_accuracy(log_dir, output_dir, database_path, - retriever_model=None): +def evaluate_accuracy(log_dir, output_dir, database_path, retriever_model=None, + manifest_path=None, cosine_threshold=0.9999, + top_k_depth=10, retrieval_threshold=0.95): """ Evaluate accuracy of datasetup workload. @@ -284,6 +285,13 @@ def evaluate_accuracy(log_dir, output_dir, database_path, output_dir: Directory containing SUT output files database_path: Path to the saved database file retriever_model: Path to retriever model (for validation) + manifest_path: Path to reference DB manifest for cross-system + verification. If None, the manifest check is skipped. + cosine_threshold: Informational sample-embedding cosine threshold for + the manifest check; does not affect pass/fail. + top_k_depth: Probe-query top-K depth for the manifest overlap check. + retrieval_threshold: Minimum mean probe-query top-K document overlap + required for the manifest check to pass. Returns: dict: Accuracy results @@ -463,6 +471,61 @@ def evaluate_accuracy(log_dir, output_dir, database_path, print("=" * 80) print() + # Cross-system manifest verification (corpus fingerprint, sample-embedding + # cosine, probe-query top-K ranks) against a reference manifest. + manifest_results = None + if manifest_path: + print("=" * 80) + print("DB Manifest Verification") + print("=" * 80) + print(f"Manifest: {manifest_path}") + print() + + if not os.path.exists(manifest_path): + manifest_results = { + "passed": False, + "error": "manifest_not_found", + "manifest_path": manifest_path, + } + print(f" ✗ Manifest not found: {manifest_path}") + elif not os.path.exists(database_path): + manifest_results = { + "passed": False, + "error": "database_not_found", + "database_path": database_path, + } + print(f" ✗ Database not found: {database_path}") + else: + try: + from db_manifest import verify_manifest + manifest_results = verify_manifest( + database_path, + manifest_path, + retriever_model=retriever_model, + cosine_threshold=cosine_threshold, + top_k_depth=top_k_depth, + retrieval_threshold=retrieval_threshold, + ) + if manifest_results["passed"]: + print(" ✓ Manifest verification PASSED") + else: + print(" ✗ Manifest verification FAILED:") + for failure in manifest_results["failures"]: + print(f" - {failure}") + except Exception as e: + manifest_results = {"passed": False, "error": str(e)} + print(f" ✗ Manifest verification error: {e}") + + accuracy_results["manifest"] = manifest_results + # Overall pass now also requires the manifest check to pass. + accuracy_results["passed"] = accuracy_results["passed"] and manifest_results["passed"] + + print() + print( + f"Manifest: {'✅ PASSED' if manifest_results['passed'] else '❌ FAILED'}") + print("=" * 80) + print() + # Write accuracy.txt in MLPerf format accuracy_txt_path = os.path.join(log_dir, "accuracy.txt") with open(accuracy_txt_path, 'w') as f: @@ -540,17 +603,45 @@ def main(): ) parser.add_argument( "--retriever_model", - default="/data/model/e5-base-v2", + default="intfloat_e5-base-v2/e5-base-v2", help="Path to retriever model (for validation)" ) + parser.add_argument( + "--manifest", + default=None, + help="Path to reference DB manifest (.json/.json.gz) for cross-system " + "verification. If omitted, the manifest check is skipped." + ) + parser.add_argument( + "--cosine_threshold", + type=float, + default=0.9999, + help="Informational sample-embedding cosine threshold for the manifest " + "check; does not affect pass/fail." + ) + parser.add_argument( + "--top_k_depth", + type=int, + default=10, + help="Probe-query top-K depth for the manifest overlap check" + ) + parser.add_argument( + "--retrieval_threshold", + type=float, + default=0.95, + help="Minimum mean probe-query top-K document overlap required for the " + "manifest check to pass (default: 0.95)." + ) args = parser.parse_args() results = evaluate_accuracy( - args.log_dir, - args.output_dir, - args.database, - args.retriever_model) + args.log_dir, args.output_dir, args.database, args.retriever_model, + manifest_path=args.manifest, + cosine_threshold=args.cosine_threshold, + top_k_depth=args.top_k_depth, + retrieval_threshold=args.retrieval_threshold, + ) # Exit with appropriate code if results.get("passed", False): diff --git a/e2e-rag/db_manifest.py b/e2e-rag/db_manifest.py index 39f8cbbc0a..9476ae4d2e 100644 --- a/e2e-rag/db_manifest.py +++ b/e2e-rag/db_manifest.py @@ -15,18 +15,36 @@ #!/usr/bin/env python3 -"""Cross-system vector DB sanity check. +"""Behavioral-equivalence vector-DB check — "is this the SAME DB", not byte-identical. + +Different implementations may legitimately rebuild the vector DB: HTML files and +passages in a different order, and numerically different embeddings (as long as +the SAME embedding MODEL is used). Those DBs are considered equivalent. What must +match: + + * embedding-model dimension + FAISS index params/algorithm (same index config) + * the CORPUS SET: same HTML + chunking + parsing => same set of passage texts, + regardless of order (order-independent set hash). Catches parser/chunking + drift (e.g. shifted chunk boundaries, injected markup) but NOT reordering. + * TOP-K RETRIEVAL behaviour against reference queries, within a tolerance + (mean top-K URL set-overlap), since different embeddings shuffle exact ranks. + +This tool does NOT check stored-vector cosine or an order-dependent corpus hash: +a regenerated DB is not byte-identical, and that is allowed by design. Workflow: - # System A (after building DB): + # System A (after building DB) writes a manifest from the reference DB: python3 db_manifest.py write \\ --db vector_html_hnsw_len768_ov32_word.db \\ - --output manifest_intel_xpu.json + --output manifest_intel_xpu.json.gz - # System B (after building DB independently): + # System B verifies its independently-built DB against it: python3 db_manifest.py verify \\ --db vector_html_hnsw_len768_ov32_word.db \\ - --manifest manifest_intel_xpu.json + --manifest manifest_intel_xpu.json.gz + + # Or compare two DBs directly on disk (no manifest): + python3 db_manifest.py compare --ref reference.db --db vendor.db The passage corpus is fingerprinted from the DB's docstore directly — no external passages file needed. @@ -53,34 +71,100 @@ def _open_manifest(path: str, mode: str): return open(path, mode) -SAMPLE_SEED = 0xC0FFEE -NUM_SAMPLE_EMBEDDINGS = 50 -NUM_PROBE_QUERIES = 10 -PROBE_TOP_K = 5 -DEFAULT_COSINE_THRESHOLD = 0.9999 -DEFAULT_TOP_K_DEPTH = 3 +def _resolve_model(args, manifest=None): + """Accept either --retriever_model or --embedding_model, falling back to the + manifest's retriever_model / embedding_model key. The two names are + interchangeable (same underlying model).""" + model = getattr(args, "retriever_model", None) or getattr(args, "embedding_model", None) + if model: + return model + if manifest is not None: + return manifest.get("retriever_model") or manifest.get("embedding_model") + return None -def _sha256_docstore(db: "VectorDB") -> str: - """SHA256 of all passages in index order; identifies the source corpus.""" - h = hashlib.sha256() +def _add_model_args(parser, **kwargs): + """--retriever_model / --embedding_model as interchangeable aliases.""" + parser.add_argument("--retriever_model", "--embedding_model", + dest="retriever_model", **kwargs) + + +# Manifest schema version. Written as int 2; older manifests used the string +# "v2-behavioral", still accepted on verify. +MANIFEST_VERSION = 2 +ACCEPTED_VERSIONS = (2, "v2-behavioral") +SAMPLE_SEED = 0xC0FFEE +NUM_PROBE_QUERIES = 50 +PROBE_TOP_K = 10 +# Minimum mean top-K document-URL set-overlap across the probe queries. +DEFAULT_RETRIEVAL_THRESHOLD = 0.95 + + +# --------------------------------------------------------------------------- +# Corpus-set fingerprint (order-independent) +# --------------------------------------------------------------------------- +def _corpus_set_sha256(db: "VectorDB") -> str: + """SHA256 over the SORTED set of per-passage text hashes. + + Order-independent: reordering HTML files/passages yields the same value. + Sensitive to parsing/chunking: any changed passage text (whitespace, + boundary shift, injected markup) changes exactly one member hash and thus + the overall fingerprint. Text is hashed RAW (no normalization) so that + parser whitespace differences are treated as real differences. + """ n = len(db._vector_store.index_to_docstore_id) + per_passage = [] for i in range(n): doc_id = db._vector_store.index_to_docstore_id[i] doc = db._vector_store.docstore.search(doc_id) - h.update(doc.page_content.encode("utf-8", errors="replace")) + per_passage.append( + hashlib.sha256(doc.page_content.encode("utf-8", errors="replace")).hexdigest() + ) + h = hashlib.sha256() + for ph in sorted(per_passage): + h.update(ph.encode("ascii")) h.update(b"\x00") return h.hexdigest() -def _cosine(a: List[float], b: List[float]) -> float: - import math - dot = sum(x * y for x, y in zip(a, b)) - na = math.sqrt(sum(x * x for x in a)) - nb = math.sqrt(sum(y * y for y in b)) - if na == 0 or nb == 0: - return 0.0 - return dot / (na * nb) +def _passage_hash_set(db: "VectorDB") -> set: + """Set of per-passage raw-text SHA256 hashes (for overlap diagnostics).""" + n = len(db._vector_store.index_to_docstore_id) + out = set() + for i in range(n): + doc_id = db._vector_store.index_to_docstore_id[i] + doc = db._vector_store.docstore.search(doc_id) + out.add(hashlib.sha256(doc.page_content.encode("utf-8", errors="replace")).hexdigest()) + return out + + +# --------------------------------------------------------------------------- +# FAISS index params / algorithm +# --------------------------------------------------------------------------- +def _index_params(db: "VectorDB") -> Dict: + """Extract index type / metric / HNSW build params for equivalence check.""" + index = db._vector_store.index + try: + import faiss + base = faiss.downcast_index(index) if hasattr(faiss, "downcast_index") else index + except Exception: + base = index + + params = { + "class": type(base).__name__, + "dim": int(getattr(base, "d", 0)), + "metric_type": int(getattr(base, "metric_type", -1)), + } + hnsw = getattr(base, "hnsw", None) + if hnsw is not None: + params["efConstruction"] = int(hnsw.efConstruction) + params["efSearch"] = int(hnsw.efSearch) + try: + # HNSW stores up to 2*M neighbors at level 0. + params["M"] = int(hnsw.nb_neighbors(0)) // 2 + except Exception: + pass + return params def _load_db(db_path: str, retriever_model: str) -> VectorDB: @@ -119,137 +203,278 @@ def _gather_top_k(db: VectorDB, queries: List[Dict], k: int) -> List[Dict]: return out -def _gather_sample_embeddings(db: VectorDB, total: int, n: int) -> Dict: - rng = random.Random(SAMPLE_SEED) - indices = sorted(rng.sample(range(total), min(n, total))) - - docstore = db._vector_store.docstore - embeddings = [] - for idx in indices: - # docstore is keyed by string ids; FAISS internally maps int->id->doc. - doc_id = db._vector_store.index_to_docstore_id.get(idx) - if doc_id is None: - raise RuntimeError(f"docstore has no entry for index {idx}") - doc = docstore.search(doc_id) - emb = db.embed_query(doc.page_content) - embeddings.append(list(emb)) - return {"indices": indices, "embeddings": embeddings} +# --------------------------------------------------------------------------- +# Retrieval overlap (behavioral equivalence) +# --------------------------------------------------------------------------- +def _norm_url(u: str) -> str: + """Normalize a doc URL so equivalent DBs match despite metadata-format + differences, e.g. 'https://en.wikipedia.org/wiki/James_Cameron#Filmography' + and 'en.wikipedia.org_wiki_James_Cameron#Filmography.html' -> the same key. + Compares the underlying article (anchors dropped), not the storage format.""" + u = u.lower() + for pre in ("https://", "http://"): + if u.startswith(pre): + u = u[len(pre):] + if u.endswith(".html"): + u = u[:-5] + u = u.replace("en.wikipedia.org/wiki/", "").replace("en.wikipedia.org_wiki_", "") + u = u.split("#")[0] + return u.replace("/", "_").strip("_") + + +def _overlap_vs_reference(cand_top: List[Dict], ref_top_map: Dict[int, List[str]], + top_k: int): + """Return (mean_overlap, top1_rate, n). Overlap = fraction of reference + top-K URLs also present in the candidate top-K (order-independent). URLs are + normalized so differing metadata formats don't cause false mismatches.""" + overlaps, top1 = [], 0 + n = 0 + per_query = [] + for entry in cand_top: + ref_urls = ref_top_map.get(entry["index"]) + if ref_urls is None: + continue + n += 1 + cand_urls = [_norm_url(u) for u in entry["top_k_urls"][:top_k]] + ref_urls = [_norm_url(u) for u in ref_urls[:top_k]] + sr, sc = set(ref_urls), set(cand_urls) + ov = len(sr & sc) / (len(sr) or 1) + overlaps.append(ov) + per_query.append((entry["index"], ov, sorted(sc), sorted(sr))) + if ref_urls and cand_urls and ref_urls[0] == cand_urls[0]: + top1 += 1 + mean_ov = sum(overlaps) / (len(overlaps) or 1) + return mean_ov, (top1 / n if n else 0.0), n, per_query def cmd_write(args): - db = _load_db(args.db, args.retriever_model) + model = _resolve_model(args) + db = _load_db(args.db, model) total_passages = len(db._vector_store.index_to_docstore_id) print( f"[manifest] DB has {total_passages} passages, dim={db._embedding_dimension}") - corpus_sha = _sha256_docstore(db) - sample_block = _gather_sample_embeddings( - db, total_passages, NUM_SAMPLE_EMBEDDINGS) - probe_queries = _load_probe_queries(args.dataset, NUM_PROBE_QUERIES) - probe_block = _gather_top_k(db, probe_queries, PROBE_TOP_K) + ref_queries = _load_probe_queries(args.dataset, args.num_queries) + ref_top = _gather_top_k(db, ref_queries, PROBE_TOP_K) manifest = { - "version": 1, - "corpus_sha256": corpus_sha, - "retriever_model": args.retriever_model, - "vector_index_method": "hnsw", + "version": MANIFEST_VERSION, + # Write both names so the manifest is portable across repos that use + # either "retriever_model" or "embedding_model". + "retriever_model": model, + "embedding_model": model, "total_passages": total_passages, "embedding_dim": db._embedding_dimension, - "sample_seed": SAMPLE_SEED, - "sample_embeddings": sample_block, - "probe_queries": probe_queries, - "probe_top_k": probe_block, + "index_params": _index_params(db), + "corpus_set_sha256": _corpus_set_sha256(db), + "probe_top_k": PROBE_TOP_K, + "reference_queries": ref_queries, + "reference_top_k": ref_top, } with _open_manifest(args.output, "wt") as f: json.dump(manifest, f, indent=2) - print(f"[manifest] wrote {args.output}") - - -def cmd_verify(args): - with _open_manifest(args.manifest, "rt") as f: + print(f"[manifest] wrote {args.output} " + f"({len(ref_queries)} reference queries, top-{PROBE_TOP_K})") + + +def verify_manifest(db_path: str, manifest_path: str, + retriever_model: str = None, + cosine_threshold: float = None, + top_k_depth: int = None, + retrieval_threshold: float = DEFAULT_RETRIEVAL_THRESHOLD) -> Dict: + """Verify a vector DB against a reference (behavioral-equivalence) manifest. + + The gate is *behavioral equivalence*, not byte-for-byte identity. Two correct + implementations chunk and index the corpus at different times and in + different order, and produce numerically different embeddings (same model), + so per-index sample-embedding cosine and an order-dependent corpus hash all + legitimately differ. What must hold for a valid submission is: + + * same embedding dimension and FAISS index configuration, + * the same order-independent CORPUS SET (same HTML + chunking + parsing), + * TOP-K retrieval that returns the same document SET for a fixed set of + reference queries, within ``retrieval_threshold`` (mean set-overlap). + + Args: + db_path: Path to the local vector DB to check. + manifest_path: Path to the reference manifest (.json or .json.gz). + retriever_model: Retriever model to load the DB with. If None, falls + back to the manifest's stored ``embedding_model``. The manifest value + is often a system-specific absolute path, so callers on other systems + should pass their own local model path here. + cosine_threshold: Accepted for backward-compatibility with the previous + manifest API; ignored (per-index cosine is no longer checked). + top_k_depth: Accepted for backward-compatibility; ignored (the top-K + depth is fixed by the manifest's ``probe_top_k``). + retrieval_threshold: Minimum mean top-K document-URL set-overlap across + the reference queries required to pass. + + Returns: + dict with keys ``passed`` (bool), ``failures`` (list[str]), and + ``metrics`` (dict of observed values). Never raises on mismatch; the CLI + wrapper is responsible for translating a failure into an exit code. + """ + with _open_manifest(manifest_path, "rt") as f: manifest = json.load(f) - db = _load_db(args.db, manifest["retriever_model"]) + if manifest.get("version") not in ACCEPTED_VERSIONS: + return { + "passed": False, + "failures": [ + f"not a v2 manifest (got version=" + f"{manifest.get('version')!r}); regenerate it with " + f"`db_manifest.py write`" + ], + "metrics": {"manifest_version": manifest.get("version")}, + } + + # Prefer an explicit retriever model; the manifest's value may be an + # absolute path that only exists on the system that wrote it. Accept either + # "retriever_model" or "embedding_model" from the manifest. + model = (retriever_model or manifest.get("retriever_model") + or manifest.get("embedding_model")) + db = _load_db(db_path, model) total_passages = len(db._vector_store.index_to_docstore_id) failures = [] + metrics = { + "total_passages": total_passages, + "manifest_total_passages": manifest["total_passages"], + "embedding_dim": db._embedding_dimension, + "retriever_model": model, + } - # Exact-match fields. + # 1. Structural: passage count + embedding dim. if total_passages != manifest["total_passages"]: failures.append( - f"total_passages mismatch: local={total_passages} manifest={manifest['total_passages']}" + f"total_passages mismatch: local={total_passages} " + f"manifest={manifest['total_passages']}" ) if db._embedding_dimension != manifest["embedding_dim"]: failures.append( f"embedding_dim mismatch: local={db._embedding_dimension} " - f"manifest={manifest['embedding_dim']}" + f"manifest={manifest['embedding_dim']} " + f"(different retriever model — comparison is not meaningful)" ) - # Corpus fingerprint (sha256 of all passage texts in index order). - local_corpus_sha = _sha256_docstore(db) - if local_corpus_sha != manifest["corpus_sha256"]: + # 2. Index params / algorithm. + local_params = _index_params(db) + metrics["index_params"] = local_params + if local_params != manifest["index_params"]: failures.append( - f"corpus sha256 mismatch:\n" - f" local = {local_corpus_sha}\n" - f" manifest = {manifest['corpus_sha256']}" + f"index_params differ:\n" + f" local = {local_params}\n" + f" manifest = {manifest['index_params']}" + ) + print(f"[verify] index params: {local_params}") + + # 3. Corpus set (order-independent) — informational only, never gated. + # Report the manifest's recorded hash; do not compare it against the DB. + metrics["manifest_corpus_set_sha256"] = manifest["corpus_set_sha256"] + print(f"[verify] corpus set sha256 (manifest, reported): " + f"{manifest['corpus_set_sha256']}") + + # 4. Top-K retrieval overlap vs reference queries (the tolerant gate). + probe_top_k = manifest["probe_top_k"] + cand_top = _gather_top_k(db, manifest["reference_queries"], probe_top_k) + ref_map = {r["index"]: r["top_k_urls"] for r in manifest["reference_top_k"]} + mean_ov, top1, nq, per_query = _overlap_vs_reference(cand_top, ref_map, probe_top_k) + + metrics["probe_queries_total"] = nq + metrics["probe_queries_full_match"] = sum(1 for _, ov, _, _ in per_query if ov >= 1.0) + metrics["retrieval_accuracy"] = mean_ov + metrics["retrieval_top1_rate"] = top1 + metrics["retrieval_threshold"] = retrieval_threshold + print(f"[verify] retrieval vs reference ({nq} queries, top-{probe_top_k}): " + f"mean overlap={mean_ov:.4f} (threshold {retrieval_threshold}), " + f"top-1 match={top1:.3f} [reported]") + + if mean_ov < retrieval_threshold: + low = [(idx, ov, sc, sr) for idx, ov, sc, sr in per_query if ov < 1.0] + detail = "\n".join( + f" query idx {idx}: overlap={ov:.2f}\n" + f" local : {sc}\n" + f" ref : {sr}" + for idx, ov, sc, sr in low[:10] ) - - # Sample-embedding cosine similarity. - cosines = [] - for idx, ref_emb in zip(manifest["sample_embeddings"]["indices"], - manifest["sample_embeddings"]["embeddings"]): - doc_id = db._vector_store.index_to_docstore_id.get(idx) - if doc_id is None: - failures.append(f"sample idx {idx}: not present in local DB") - continue - doc = db._vector_store.docstore.search(doc_id) - local_emb = db.embed_query(doc.page_content) - cosines.append((idx, _cosine(local_emb, ref_emb))) - - if cosines: - worst_idx, worst_cos = min(cosines, key=lambda x: x[1]) - mean_cos = sum(c for _, c in cosines) / len(cosines) - print(f"[verify] sample embeddings: mean cosine={mean_cos:.6f} " - f"min={worst_cos:.6f} (idx={worst_idx}) threshold={args.cosine_threshold}") - if worst_cos < args.cosine_threshold: - failures.append( - f"sample embedding cosine below threshold: " - f"min={worst_cos:.6f} (idx={worst_idx}) < threshold={args.cosine_threshold}\n" - f" mean={mean_cos:.6f}" - ) - - # Probe-query top-K rank check. - probe_queries = manifest["probe_queries"] - local_top = _gather_top_k(db, probe_queries, PROBE_TOP_K) - ref_top = {r["index"]: r["top_k_urls"] for r in manifest["probe_top_k"]} - - rank_failures = [] - for entry in local_top: - local_urls = entry["top_k_urls"][:args.top_k_depth] - ref_urls = ref_top.get(entry["index"], [])[:args.top_k_depth] - if local_urls != ref_urls: - rank_failures.append( - f" query idx {entry['index']}: top-{args.top_k_depth} differs\n" - f" local : {local_urls}\n" - f" ref : {ref_urls}" - ) - - print(f"[verify] probe queries: {len(probe_queries)} queries, " - f"top-{args.top_k_depth} {len(probe_queries) - len(rank_failures)}/" - f"{len(probe_queries)} match") - if rank_failures: failures.append( - "probe-query top-K rank mismatch:\n" + - "\n".join(rank_failures)) + f"retrieval overlap below threshold: {mean_ov:.4f} < " + f"{retrieval_threshold} — retrieval behaviour diverges from " + f"reference\n" + f" {metrics['probe_queries_full_match']}/{nq} queries fully " + f"matched; sample of divergent queries:\n{detail}" + ) - if failures: + return {"passed": not failures, "failures": failures, "metrics": metrics} + + +def cmd_verify(args): + result = verify_manifest( + args.db, + args.manifest, + retriever_model=args.retriever_model, + retrieval_threshold=args.retrieval_threshold, + ) + if not result["passed"]: print("\n[verify] FAILED:") + for f in result["failures"]: + print(f" - {f}") + sys.exit(1) + print("\n[verify] OK — DB is behaviourally equivalent to the reference") + + +def cmd_compare(args): + """Direct DB-vs-DB behavioral comparison, no manifest.""" + model = _resolve_model(args) + ref = _load_db(args.ref, model) + cand = _load_db(args.db, model) + n_ref = len(ref._vector_store.index_to_docstore_id) + n_cand = len(cand._vector_store.index_to_docstore_id) + print(f"[compare] REF {Path(args.ref).name}: {n_ref} passages") + print(f"[compare] CAND {Path(args.db).name}: {n_cand} passages") + + failures = [] + + # Structural + index params. + if n_cand != n_ref: + failures.append(f"passage count: REF={n_ref} CAND={n_cand}") + rp, cp = _index_params(ref), _index_params(cand) + if rp != cp: + failures.append(f"index params differ:\n REF ={rp}\n CAND={cp}") + print(f"[compare] index params REF ={rp}") + print(f"[compare] index params CAND={cp}") + + # Corpus set overlap (order-independent). + rh, ch = _passage_hash_set(ref), _passage_hash_set(cand) + common = rh & ch + ov_ref = len(common) / (len(rh) or 1) + print(f"\n[compare] corpus set: {len(common)} common passages; " + f"{100 * ov_ref:.2f}% of REF also in CAND " + f"({len(rh - ch)} only-REF, {len(ch - rh)} only-CAND)") + if rh != ch: + failures.append(f"corpus set differs: only {100 * ov_ref:.2f}% of REF " + f"passages present in CAND (parsing/chunking/HTML changed)") + + # Retrieval overlap. + queries = _load_probe_queries(args.dataset, args.num_queries) + ref_top = _gather_top_k(ref, queries, args.probe_k) + cand_top = _gather_top_k(cand, queries, args.probe_k) + ref_map = {r["index"]: r["top_k_urls"] for r in ref_top} + mean_ov, top1, nq, _ = _overlap_vs_reference(cand_top, ref_map, args.probe_k) + print(f"\n[compare] retrieval vs REF ({nq} queries, top-{args.probe_k}): " + f"mean overlap={mean_ov:.3f} (threshold {args.retrieval_threshold}), " + f"top-1 match={top1:.3f} [reported]") + if mean_ov < args.retrieval_threshold: + failures.append(f"retrieval overlap {mean_ov:.3f} < {args.retrieval_threshold}") + + if failures: + print("\n[compare] NOT EQUIVALENT:") for f in failures: print(f" - {f}") sys.exit(1) - print("\n[verify] OK") + print("\n[compare] OK — CAND is behaviourally equivalent to REF") def main(): @@ -261,8 +486,9 @@ def main(): "write", help="Generate a reference manifest from a DB.") pw.add_argument("--db", required=True) - pw.add_argument("--retriever_model", default="intfloat/e5-base-v2") + _add_model_args(pw, default="intfloat_e5-base-v2/e5-base-v2") pw.add_argument("--dataset", default="data/frames_dataset.tsv") + pw.add_argument("--num-queries", type=int, default=NUM_PROBE_QUERIES) pw.add_argument("--output", required=True) pw.set_defaults(func=cmd_write) @@ -271,13 +497,33 @@ def main(): help="Verify a DB against a reference manifest.") pv.add_argument("--db", required=True) pv.add_argument("--manifest", required=True) + _add_model_args( + pv, + default=None, + help="Retriever model to load the DB with. Defaults to the manifest's " + "stored value, which may be a system-specific absolute path; pass " + "your local model path to verify on a different system.", + ) pv.add_argument( - "--cosine-threshold", - type=float, - default=DEFAULT_COSINE_THRESHOLD) - pv.add_argument("--top-k-depth", type=int, default=DEFAULT_TOP_K_DEPTH) + "--retrieval-threshold", type=float, default=DEFAULT_RETRIEVAL_THRESHOLD, + help="Minimum mean reference-query top-K document set-overlap required " + f"to pass (default: {DEFAULT_RETRIEVAL_THRESHOLD}).", + ) pv.set_defaults(func=cmd_verify) + pc = sub.add_parser( + "compare", + help="Directly compare two DBs (no manifest).") + pc.add_argument("--ref", required=True) + pc.add_argument("--db", required=True) + _add_model_args(pc, default="intfloat_e5-base-v2/e5-base-v2") + pc.add_argument("--dataset", default="data/frames_dataset.tsv") + pc.add_argument("--num-queries", type=int, default=NUM_PROBE_QUERIES) + pc.add_argument("--probe-k", type=int, default=PROBE_TOP_K) + pc.add_argument( + "--retrieval-threshold", type=float, default=DEFAULT_RETRIEVAL_THRESHOLD) + pc.set_defaults(func=cmd_compare) + args = parser.parse_args() args.func(args) diff --git a/e2e-rag/reference_SUT_datasetup.py b/e2e-rag/reference_SUT_datasetup.py index 62e81f0f8d..7190e7a683 100644 --- a/e2e-rag/reference_SUT_datasetup.py +++ b/e2e-rag/reference_SUT_datasetup.py @@ -35,7 +35,7 @@ from QSL_datasetup import DatasetupQSLInMemory from retrieve import VectorDB from text_splitter import split_into_fixed_passages -from utils import get_device_config +from utils import get_device_config, load_url_mapping, get_base_filename # Import HTML extractor try: @@ -111,6 +111,12 @@ def __init__( device_config = get_device_config() log.info(f"Device Config: {device_config}") + # Load the frozen corpus URL mapping (filename -> canonical Wikipedia + # URL) so each passage records its original_url. The DB manifest gate + # compares canonical URLs; without this, passages only carry the + # filename-form source and cross-system verification fails. + self.url_mapping = load_url_mapping(documents_dir) + # Initialize QSL log.info("Initializing Datasetup Query Sample Library...") self.qsl = DatasetupQSLInMemory(documents_dir) @@ -240,7 +246,10 @@ def _process_document(self, query_sample): passages = [text] # Step 3: Generate embeddings (parallel, outside lock) - passage_metadata = [{'source': file_name, 'passage_id': i} + original_url = self.url_mapping.get( + get_base_filename(file_name), "") + passage_metadata = [{'source': file_name, 'passage_id': i, + 'original_url': original_url} for i in range(len(passages))] # Generate embeddings WITHOUT holding db_lock (allows parallel From 7c17261380b884d2e8261e2efbcbb4f4a06390b1 Mon Sep 17 00:00:00 2001 From: mlc-automations <3246381+mlc-automations@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:37:23 +0000 Subject: [PATCH 53/59] [Automated Commit] Format Codebase --- e2e-rag/db_manifest.py | 46 ++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/e2e-rag/db_manifest.py b/e2e-rag/db_manifest.py index 9476ae4d2e..eaec2247f1 100644 --- a/e2e-rag/db_manifest.py +++ b/e2e-rag/db_manifest.py @@ -75,11 +75,18 @@ def _resolve_model(args, manifest=None): """Accept either --retriever_model or --embedding_model, falling back to the manifest's retriever_model / embedding_model key. The two names are interchangeable (same underlying model).""" - model = getattr(args, "retriever_model", None) or getattr(args, "embedding_model", None) + model = getattr( + args, + "retriever_model", + None) or getattr( + args, + "embedding_model", + None) if model: return model if manifest is not None: - return manifest.get("retriever_model") or manifest.get("embedding_model") + return manifest.get("retriever_model") or manifest.get( + "embedding_model") return None @@ -118,7 +125,10 @@ def _corpus_set_sha256(db: "VectorDB") -> str: doc_id = db._vector_store.index_to_docstore_id[i] doc = db._vector_store.docstore.search(doc_id) per_passage.append( - hashlib.sha256(doc.page_content.encode("utf-8", errors="replace")).hexdigest() + hashlib.sha256( + doc.page_content.encode( + "utf-8", + errors="replace")).hexdigest() ) h = hashlib.sha256() for ph in sorted(per_passage): @@ -134,7 +144,11 @@ def _passage_hash_set(db: "VectorDB") -> set: for i in range(n): doc_id = db._vector_store.index_to_docstore_id[i] doc = db._vector_store.docstore.search(doc_id) - out.add(hashlib.sha256(doc.page_content.encode("utf-8", errors="replace")).hexdigest()) + out.add( + hashlib.sha256( + doc.page_content.encode( + "utf-8", + errors="replace")).hexdigest()) return out @@ -146,7 +160,8 @@ def _index_params(db: "VectorDB") -> Dict: index = db._vector_store.index try: import faiss - base = faiss.downcast_index(index) if hasattr(faiss, "downcast_index") else index + base = faiss.downcast_index(index) if hasattr( + faiss, "downcast_index") else index except Exception: base = index @@ -217,7 +232,11 @@ def _norm_url(u: str) -> str: u = u[len(pre):] if u.endswith(".html"): u = u[:-5] - u = u.replace("en.wikipedia.org/wiki/", "").replace("en.wikipedia.org_wiki_", "") + u = u.replace( + "en.wikipedia.org/wiki/", + "").replace( + "en.wikipedia.org_wiki_", + "") u = u.split("#")[0] return u.replace("/", "_").strip("_") @@ -379,11 +398,14 @@ def verify_manifest(db_path: str, manifest_path: str, # 4. Top-K retrieval overlap vs reference queries (the tolerant gate). probe_top_k = manifest["probe_top_k"] cand_top = _gather_top_k(db, manifest["reference_queries"], probe_top_k) - ref_map = {r["index"]: r["top_k_urls"] for r in manifest["reference_top_k"]} - mean_ov, top1, nq, per_query = _overlap_vs_reference(cand_top, ref_map, probe_top_k) + ref_map = {r["index"]: r["top_k_urls"] + for r in manifest["reference_top_k"]} + mean_ov, top1, nq, per_query = _overlap_vs_reference( + cand_top, ref_map, probe_top_k) metrics["probe_queries_total"] = nq - metrics["probe_queries_full_match"] = sum(1 for _, ov, _, _ in per_query if ov >= 1.0) + metrics["probe_queries_full_match"] = sum( + 1 for _, ov, _, _ in per_query if ov >= 1.0) metrics["retrieval_accuracy"] = mean_ov metrics["retrieval_top1_rate"] = top1 metrics["retrieval_threshold"] = retrieval_threshold @@ -462,12 +484,14 @@ def cmd_compare(args): ref_top = _gather_top_k(ref, queries, args.probe_k) cand_top = _gather_top_k(cand, queries, args.probe_k) ref_map = {r["index"]: r["top_k_urls"] for r in ref_top} - mean_ov, top1, nq, _ = _overlap_vs_reference(cand_top, ref_map, args.probe_k) + mean_ov, top1, nq, _ = _overlap_vs_reference( + cand_top, ref_map, args.probe_k) print(f"\n[compare] retrieval vs REF ({nq} queries, top-{args.probe_k}): " f"mean overlap={mean_ov:.3f} (threshold {args.retrieval_threshold}), " f"top-1 match={top1:.3f} [reported]") if mean_ov < args.retrieval_threshold: - failures.append(f"retrieval overlap {mean_ov:.3f} < {args.retrieval_threshold}") + failures.append( + f"retrieval overlap {mean_ov:.3f} < {args.retrieval_threshold}") if failures: print("\n[compare] NOT EQUIVALENT:") From 297a81da3bd65411419fcf17031493280c935931 Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:22:20 +0530 Subject: [PATCH 54/59] Delete .github/workflows/format.yml --- .github/workflows/format.yml | 73 ------------------------------------ 1 file changed, 73 deletions(-) delete mode 100644 .github/workflows/format.yml diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml deleted file mode 100644 index fc4109acdc..0000000000 --- a/.github/workflows/format.yml +++ /dev/null @@ -1,73 +0,0 @@ -# Automatic code formatting -name: "Code formatting" -on: - push: - branches: - - "**" - -env: - python_version: "3.9" - -jobs: - format-code: - if: github.actor != 'mlc-automations' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.MLC_AUTOMATIONS_APP_ID }} - private-key: ${{ secrets.MLC_AUTOMATIONS_PRIVATE_KEY }} - - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - name: Set up Python ${{ env.python_version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ env.python_version }} - - - name: Format modified Python files - env: - filter: ${{ github.event.before }} - run: | - python3 -m pip install autopep8 - for FILE in $(git diff --name-only $filter | grep -E '.*\.py$') - do - # Check if the file still exists in the working tree - if [ -f "$FILE" ] && [ "$FILE" != "tools/submission/power/power_checker.py" ]; then - autopep8 --in-place -a --max-line-length 79 "$FILE" - git add "$FILE" - fi - done - - - name: Format modified C++ files - env: - filter: ${{ github.event.before }} - run: | - for FILE in $(git diff --name-only $filter | grep -E '.*\.(cc|cpp|h|hpp)$') - do - # Check if the file still exists in the working tree - if [ -f "$FILE" ]; then - clang-format -i -style=file $FILE - git add $FILE - fi - done - - - name: Commit and push changes - run: | - HAS_CHANGES=$(git diff --staged --name-only) - if [ ${#HAS_CHANGES} -gt 0 ]; then - # Use the GitHub actor's name and email - git config --global user.name mlc-automations - git config --global user.email "3246381+mlc-automations@users.noreply.github.com" - # Commit changes - git commit -m '[Automated Commit] Format Codebase' - git push - fi From 88f91002d4d4e02dc5ee93d4a868fe35cd1c9185 Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Wed, 26 Aug 2026 20:27:23 +0530 Subject: [PATCH 55/59] Revert "[Automated Commit] Format Codebase" This reverts commit 7c172613 for e2e-rag/db_manifest.py. The format.yml workflow reformatted db_manifest.py right after 765c4768 made it byte-identical to master, which reintroduced the merge conflict against master: autopep8 on the docs side rewrites the same line ranges master's functional changes did. Now that format.yml is removed from this branch (297a81da), the file can stay byte-identical to master and docs merges into master cleanly. Co-Authored-By: Claude Opus 5 --- e2e-rag/db_manifest.py | 46 ++++++++++-------------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/e2e-rag/db_manifest.py b/e2e-rag/db_manifest.py index eaec2247f1..9476ae4d2e 100644 --- a/e2e-rag/db_manifest.py +++ b/e2e-rag/db_manifest.py @@ -75,18 +75,11 @@ def _resolve_model(args, manifest=None): """Accept either --retriever_model or --embedding_model, falling back to the manifest's retriever_model / embedding_model key. The two names are interchangeable (same underlying model).""" - model = getattr( - args, - "retriever_model", - None) or getattr( - args, - "embedding_model", - None) + model = getattr(args, "retriever_model", None) or getattr(args, "embedding_model", None) if model: return model if manifest is not None: - return manifest.get("retriever_model") or manifest.get( - "embedding_model") + return manifest.get("retriever_model") or manifest.get("embedding_model") return None @@ -125,10 +118,7 @@ def _corpus_set_sha256(db: "VectorDB") -> str: doc_id = db._vector_store.index_to_docstore_id[i] doc = db._vector_store.docstore.search(doc_id) per_passage.append( - hashlib.sha256( - doc.page_content.encode( - "utf-8", - errors="replace")).hexdigest() + hashlib.sha256(doc.page_content.encode("utf-8", errors="replace")).hexdigest() ) h = hashlib.sha256() for ph in sorted(per_passage): @@ -144,11 +134,7 @@ def _passage_hash_set(db: "VectorDB") -> set: for i in range(n): doc_id = db._vector_store.index_to_docstore_id[i] doc = db._vector_store.docstore.search(doc_id) - out.add( - hashlib.sha256( - doc.page_content.encode( - "utf-8", - errors="replace")).hexdigest()) + out.add(hashlib.sha256(doc.page_content.encode("utf-8", errors="replace")).hexdigest()) return out @@ -160,8 +146,7 @@ def _index_params(db: "VectorDB") -> Dict: index = db._vector_store.index try: import faiss - base = faiss.downcast_index(index) if hasattr( - faiss, "downcast_index") else index + base = faiss.downcast_index(index) if hasattr(faiss, "downcast_index") else index except Exception: base = index @@ -232,11 +217,7 @@ def _norm_url(u: str) -> str: u = u[len(pre):] if u.endswith(".html"): u = u[:-5] - u = u.replace( - "en.wikipedia.org/wiki/", - "").replace( - "en.wikipedia.org_wiki_", - "") + u = u.replace("en.wikipedia.org/wiki/", "").replace("en.wikipedia.org_wiki_", "") u = u.split("#")[0] return u.replace("/", "_").strip("_") @@ -398,14 +379,11 @@ def verify_manifest(db_path: str, manifest_path: str, # 4. Top-K retrieval overlap vs reference queries (the tolerant gate). probe_top_k = manifest["probe_top_k"] cand_top = _gather_top_k(db, manifest["reference_queries"], probe_top_k) - ref_map = {r["index"]: r["top_k_urls"] - for r in manifest["reference_top_k"]} - mean_ov, top1, nq, per_query = _overlap_vs_reference( - cand_top, ref_map, probe_top_k) + ref_map = {r["index"]: r["top_k_urls"] for r in manifest["reference_top_k"]} + mean_ov, top1, nq, per_query = _overlap_vs_reference(cand_top, ref_map, probe_top_k) metrics["probe_queries_total"] = nq - metrics["probe_queries_full_match"] = sum( - 1 for _, ov, _, _ in per_query if ov >= 1.0) + metrics["probe_queries_full_match"] = sum(1 for _, ov, _, _ in per_query if ov >= 1.0) metrics["retrieval_accuracy"] = mean_ov metrics["retrieval_top1_rate"] = top1 metrics["retrieval_threshold"] = retrieval_threshold @@ -484,14 +462,12 @@ def cmd_compare(args): ref_top = _gather_top_k(ref, queries, args.probe_k) cand_top = _gather_top_k(cand, queries, args.probe_k) ref_map = {r["index"]: r["top_k_urls"] for r in ref_top} - mean_ov, top1, nq, _ = _overlap_vs_reference( - cand_top, ref_map, args.probe_k) + mean_ov, top1, nq, _ = _overlap_vs_reference(cand_top, ref_map, args.probe_k) print(f"\n[compare] retrieval vs REF ({nq} queries, top-{args.probe_k}): " f"mean overlap={mean_ov:.3f} (threshold {args.retrieval_threshold}), " f"top-1 match={top1:.3f} [reported]") if mean_ov < args.retrieval_threshold: - failures.append( - f"retrieval overlap {mean_ov:.3f} < {args.retrieval_threshold}") + failures.append(f"retrieval overlap {mean_ov:.3f} < {args.retrieval_threshold}") if failures: print("\n[compare] NOT EQUIVALENT:") From eb552c9a4bd63684675004a02ca77b6b3e46ef35 Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Wed, 26 Aug 2026 21:48:02 +0530 Subject: [PATCH 56/59] Drop autopep8 churn in e2e-rag: mirror master exactly The docs branch carried autopep8 reformatting of e2e-rag Python files that master never had, which showed up as pure formatting noise in the docs -> master PR diff. Reset these files to master's exact bytes. Verified at the token level (ignoring whitespace and comments) that none of them contains any docs-authored code: every difference was either autopep8 churn from the now-removed format.yml workflow, or docs lagging master. The autopep8 artifacts included reflowed call sites, trailing whitespace stripped inside docstrings, reordered imports, and one long comment split in a way that injected a stray '#'. main.py is deliberately left alone: it carries docs-only commits. Co-Authored-By: Claude Opus 5 --- e2e-rag/accuracy_eval.py | 42 ++++++++++++++---- e2e-rag/evaluate.py | 4 +- e2e-rag/evaluation.py | 3 +- e2e-rag/llm_logger.py | 15 +++++++ e2e-rag/multi_shot_retrieval.py | 64 ++++++++++++++++++++------- e2e-rag/params.py | 18 ++++---- e2e-rag/reference_SUT.py | 29 +++++++++--- e2e-rag/reference_mlperf.py | 2 +- e2e-rag/reference_mlperf_datasetup.py | 2 +- e2e-rag/reranker_worker.py | 7 ++- e2e-rag/retrieve/ragdb.py | 2 +- 11 files changed, 140 insertions(+), 48 deletions(-) diff --git a/e2e-rag/accuracy_eval.py b/e2e-rag/accuracy_eval.py index a4561c7449..435c2e670a 100644 --- a/e2e-rag/accuracy_eval.py +++ b/e2e-rag/accuracy_eval.py @@ -40,7 +40,7 @@ 'sk-or-v1-****') -JUDGE_PROMPT = """You are an expert evaluator comparing LLM-generated answers to ground truth answers. +JUDGE_PROMPT = """You are grading whether an LLM answer is correct against a ground truth answer. QUESTION: {question} @@ -48,8 +48,14 @@ LLM ANSWER: {llm_answer} -Evaluate if the LLM answer is factually correct compared to the ground truth. -Consider semantic equivalence, not just exact string matching. +Grade in two steps. + +STEP 1 - If the LLM answer is empty, "Unknown", "I don't know", "cannot be determined", or otherwise does not commit to an answer, then it is WRONG: output correct=false immediately and do not go to step 2. + +STEP 2 - Otherwise compare it to the ground truth by meaning, not wording. correct=true only if it supplies every fact the ground truth requires and each clearly matches; if you are unsure or the match is only partial, output correct=false. Rules: +- If the ground truth is a list or has multiple parts, an answer missing any of them is correct=false. +- Every number, date, and name must match the ground truth; a different or differently-rounded value is correct=false, a different name is correct=false. +- Do NOT penalize harmless extras or omissions when the required facts match: a missing suffix like "Inc.", an added state/country, a full middle name, missing units when the number is right, or a briefer/longer phrasing. Return your evaluation in JSON format: {{ @@ -96,11 +102,21 @@ def call_judge(question: str, ground_truth: str, llm_answer: str, # Parse JSON response content = content.strip() - if content.startswith("```"): - content = content.split("```")[1] - if content.startswith("json"): - content = content[4:] - content = content.strip() + + # Extract JSON from markdown code blocks + if "```" in content: + json_block_match = re.search( + r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL) + if json_block_match: + content = json_block_match.group(1).strip() + + # Try to extract JSON object + json_match = re.search(r'\{.*\}', content, re.DOTALL) + if json_match: + content = json_match.group(0) + else: + return {"correct": False, + "reasoning": "No JSON found in judge response"} judge_result = json.loads(content) return judge_result @@ -329,6 +345,16 @@ def main(): json.dump(metrics, f, indent=2) print(f"Detailed results saved to {args.output}") + # Write accuracy.txt into the loadgen log dir in MLPerf format. The + # submission checker parses the LLM judge answer accuracy (as a percentage) + # from the "Accuracy:" line. The hash= line and log truncation are added + # later by tools/submission/truncate_accuracy_log.py during submission + # prep. + accuracy_txt_path = os.path.join(args.log_dir, "accuracy.txt") + with open(accuracy_txt_path, 'w') as f: + f.write(f"Accuracy: {metrics['answer_accuracy'] * 100:.4f}\n") + print(f"Accuracy report saved to {accuracy_txt_path}") + if __name__ == "__main__": main() diff --git a/e2e-rag/evaluate.py b/e2e-rag/evaluate.py index b963b98124..e6b9c1e6ab 100644 --- a/e2e-rag/evaluate.py +++ b/e2e-rag/evaluate.py @@ -40,8 +40,8 @@ import requests # LLM judge configuration (defaults to local vLLM) -DEFAULT_JUDGE_URL = "http://127.0.0.1:8123/v1/chat/completions" -DEFAULT_JUDGE_MODEL = "gpt-oss-20b" +DEFAULT_JUDGE_URL = "http://127.0.0.1:8192/v1/chat/completions" +DEFAULT_JUDGE_MODEL = "gpt-oss-20b-mxfp4" OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '') diff --git a/e2e-rag/evaluation.py b/e2e-rag/evaluation.py index 8ed81824fc..226f7d3d29 100644 --- a/e2e-rag/evaluation.py +++ b/e2e-rag/evaluation.py @@ -1,4 +1,5 @@ # Copyright 2025 The MLPerf Authors. All Rights Reserved. +# Copyright 2026 Arm Ltd. and affiliates # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -231,7 +232,7 @@ def evaluate_retrieval_query(rag_db, query: str, expected_urls: List[str], print("-" * 50) # Show reranked results if reranker is available and reranking was used - if not no_rerank and rag_db._reranker_model is not None: + if not no_rerank and has_reranker: print(f"\nReranking to top-{top_k_reranking}") print(f"Reranking results:") diff --git a/e2e-rag/llm_logger.py b/e2e-rag/llm_logger.py index 99f1d7e0b3..892d5b0046 100644 --- a/e2e-rag/llm_logger.py +++ b/e2e-rag/llm_logger.py @@ -117,6 +117,21 @@ def log_llm_call(self, if self.current_query: self.current_query["llm_calls"].append(call_record) + def get_component_output_tokens(self, component: str) -> Optional[int]: + """Return the real output-token count (osl) of the most recent call to + ``component`` in the current query, or None if there was no such call. + + Used by the SUT to report the true answer-generation token length to + LoadGen (for TEST09), rather than a proxy derived from the answer text. + """ + cq = self.current_query + if not cq: + return None + for call in reversed(cq.get("llm_calls", [])): + if call.get("component") == component: + return call.get("metrics", {}).get("osl") + return None + def _extract_response_text(self, response: Dict) -> str: """Extract response text from API response""" if not response or 'choices' not in response: diff --git a/e2e-rag/multi_shot_retrieval.py b/e2e-rag/multi_shot_retrieval.py index 905b83bfbb..f65c22696a 100644 --- a/e2e-rag/multi_shot_retrieval.py +++ b/e2e-rag/multi_shot_retrieval.py @@ -535,11 +535,20 @@ def evaluate_document_relevance(question: str, print(f" Warning: Relevance check returned empty, marking all as relevant") return {"relevance": [1] * len(new_documents)} - if llm_output.startswith("```"): - llm_output = llm_output.split("```")[1] - if llm_output.startswith("json"): - llm_output = llm_output[4:] - llm_output = llm_output.strip() + # Extract JSON from markdown code blocks + if "```" in llm_output: + json_block_match = re.search( + r'```(?:json)?\s*\n?(.*?)\n?```', llm_output, re.DOTALL) + if json_block_match: + llm_output = json_block_match.group(1).strip() + + # Try to extract JSON object + json_match = re.search(r'\{.*\}', llm_output, re.DOTALL) + if json_match: + llm_output = json_match.group(0) + else: + print(f" Warning: No JSON in relevance response, marking all as relevant") + return {"relevance": [1] * len(new_documents)} relevance_result = json.loads(llm_output) relevance = relevance_result.get("relevance", []) @@ -648,16 +657,23 @@ def check_sufficiency(question: str, "reasoning": "Max iterations reached"} return {"sufficient": False, "reasoning": "LLM returned empty"} - if llm_output.startswith("```"): - llm_output = llm_output.split("```")[1] - if llm_output.startswith("json"): - llm_output = llm_output[4:] - llm_output = llm_output.strip() + # Extract JSON from markdown code blocks + if "```" in llm_output: + json_block_match = re.search( + r'```(?:json)?\s*\n?(.*?)\n?```', llm_output, re.DOTALL) + if json_block_match: + llm_output = json_block_match.group(1).strip() # Try to extract JSON json_match = re.search(r'\{.*\}', llm_output, re.DOTALL) if json_match: llm_output = json_match.group(0) + else: + print(f" Warning: No JSON in sufficiency response") + if iteration >= max_iterations: + return {"sufficient": True, + "reasoning": "Max iterations reached, no JSON"} + return {"sufficient": False, "reasoning": "No JSON in response"} result = json.loads(llm_output) sufficient = result.get("sufficient", False) @@ -860,16 +876,23 @@ def generate_search_queries(question: str, print(f" Warning: Query generation returned empty") return {"queries": [question], "feedback": "LLM returned empty"} - if llm_output.startswith("```"): - llm_output = llm_output.split("```")[1] - if llm_output.startswith("json"): - llm_output = llm_output[4:] - llm_output = llm_output.strip() + # Extract JSON from markdown code blocks + if "```" in llm_output: + # Find content between ```json and ``` or just between ``` markers + json_block_match = re.search( + r'```(?:json)?\s*\n?(.*?)\n?```', llm_output, re.DOTALL) + if json_block_match: + llm_output = json_block_match.group(1).strip() # Try to extract JSON object even from mixed text/markdown responses json_match = re.search(r'\{.*\}', llm_output, re.DOTALL) if json_match: llm_output = json_match.group(0) + else: + # No JSON found in response + print(f" Warning: No JSON found in LLM response") + return {"queries": [question], + "feedback": "No JSON in LLM response"} query_result = json.loads(llm_output) return { @@ -1516,6 +1539,16 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], # Add to new_docs for evaluation (avoid duplicates) for result in results: + # DEBUG: Log metadata structure for first result + if not all_retrieved_urls: # Log only once + print( + f" [DEBUG] Sample result metadata keys: {list(result.metadata.keys())}") + print( + f" [DEBUG] Sample result metadata: {result.metadata}") + + # Try to get URL from metadata - support both 'original_url' + # and 'source' fields + url = None if 'original_url' in result.metadata and result.metadata['original_url']: url = result.metadata['original_url'] if url not in all_retrieved_urls: @@ -1592,6 +1625,7 @@ def multi_shot_retrieval(rag_db, original_query: str, expected_urls: List[str], 'sufficient': sufficient, 'avg_iteration_time': sum(iteration_times) / len(iteration_times) if iteration_times else 0, 'llm_answer': final_answer, + 'retrieved_urls': retrieved_urls, }) # Print final results diff --git a/e2e-rag/params.py b/e2e-rag/params.py index 7333feac5e..47e3457065 100644 --- a/e2e-rag/params.py +++ b/e2e-rag/params.py @@ -193,7 +193,7 @@ def suggest_value(self, trial): name="retriever_model", arg_names=["--retriever_model"], type=str, - default="intfloat/e5-base-v2", + default="intfloat_e5-base-v2/e5-base-v2", help="Model to use for embedding-based retrieval", category="common", applies_to=["vector"] @@ -202,7 +202,7 @@ def suggest_value(self, trial): name="reranker_model", arg_names=["--reranker_model"], type=str, - default="colbert-ir/colbertv2.0", + default="colbert-ir_colbertv2.0/colbertv2.0", help="Model to use for reranking (currently unused)", category="common", applies_to=["both"] @@ -324,8 +324,8 @@ def suggest_value(self, trial): name="llm_service_url", arg_names=["--llm_service_url"], type=str, - default="http://127.0.0.1:8123/v1/chat/completions", - help="URL for the LLM service endpoint", + default="http://127.0.0.1:8192/v1/chat/completions", + help="URL for the LLM service endpoint (20B model on port 8192)", category="general", applies_to=["both"] ), @@ -342,14 +342,14 @@ def suggest_value(self, trial): name="query_model", arg_names=["--query_model"], type=str, - default=None, - help="LLM model name/path for query generation (generate_search_queries). Defaults to --llm_model if not set. Example: /model/gpt-oss-120b", + default="gpt-oss-120b-mxfp4", + help="LLM model name/path for query generation (generate_search_queries). Defaults to --llm_model if not set. Example: gpt-oss-120b-mxfp4", category="general", applies_to=["both"] ), # Per-component endpoint overrides. Each defaults to --llm_service_url when # not set; same for the model. Lets you split components across separate - # vLLM servers (e.g. 20B grader on :8124, 120B query/sufficiency on :8123). + # vLLM servers (e.g. 20B grader on :8192, 120B query/sufficiency on :8123). ParamDef( name="grader_service_url", arg_names=["--grader-service-url"], @@ -372,8 +372,8 @@ def suggest_value(self, trial): name="query_service_url", arg_names=["--query-service-url"], type=str, - default=None, - help="LLM service URL for query generation (default: --llm_service_url).", + default="http://127.0.0.1:8123/v1/chat/completions", + help="LLM service URL for query generation (120B model on port 8123, default: --llm_service_url).", category="general", applies_to=["both"] ), diff --git a/e2e-rag/reference_SUT.py b/e2e-rag/reference_SUT.py index 9bccb2e6bd..ef3c493bde 100644 --- a/e2e-rag/reference_SUT.py +++ b/e2e-rag/reference_SUT.py @@ -254,6 +254,13 @@ def _process_single_query(self, query_sample): # Extract answer answer = result.get('llm_answer', 'Unknown') + # Capture the real output-token count of the final answer generation + # (from the LLM API's usage.completion_tokens) BEFORE end_query() + # clears the per-thread query state. This is what LoadGen reports for + # TEST09; deriving it from the answer text would undercount badly. + output_token_count = self.llm_logger.get_component_output_tokens( + "answer_generator") + # End logging for this query self.llm_logger.end_query( retrieval_results={ @@ -289,6 +296,7 @@ def _process_single_query(self, query_sample): import traceback traceback.print_exc() answer = "Error" + output_token_count = None # Store error result with self.results_lock: @@ -298,17 +306,26 @@ def _process_single_query(self, query_sample): 'error': str(e) } - # Convert answer to byte array for loadgen - answer_bytes = answer.encode('utf-8') - response_array = array.array('B', answer_bytes) - bi = response_array.buffer_info() + # LoadGen's accuracy log records the response `data` blob and reports + # `n_tokens` for the compliance token-length check (TEST09), which + # decodes `data` as an array of 4-byte little-endian int32 token IDs and + # counts them. Reference LLM SUTs therefore emit int32 token IDs, not the + # answer's UTF-8 bytes. The authoritative token count is the LLM API's + # usage.completion_tokens for the final answer generation, captured above + # as output_token_count. The human-readable answer is preserved + # separately in results.json, so we only need a blob of the right length + # here; fall back to a whitespace-token estimate if the count is missing. + if output_token_count is None or output_token_count <= 0: + output_token_count = max(1, len(answer.split())) + token_ids = array.array('i', [0] * output_token_count) + bi = token_ids.buffer_info() # Send response to loadgen response = lg.QuerySampleResponse( query_id, bi[0], - bi[1] * response_array.itemsize, - len(answer_bytes) + bi[1] * token_ids.itemsize, + output_token_count ) lg.QuerySamplesComplete([response]) diff --git a/e2e-rag/reference_mlperf.py b/e2e-rag/reference_mlperf.py index 2b306c5742..6ec3830380 100644 --- a/e2e-rag/reference_mlperf.py +++ b/e2e-rag/reference_mlperf.py @@ -205,7 +205,7 @@ def main(): # Load config files if os.path.exists(args.user_conf): - settings.FromConfig(args.user_conf, "rag-qna", args.scenario) + settings.FromConfig(args.user_conf, "e2e-rag-qna", args.scenario) print(f"Loaded user config from {args.user_conf}") else: print(f"Warning: User config not found: {args.user_conf}") diff --git a/e2e-rag/reference_mlperf_datasetup.py b/e2e-rag/reference_mlperf_datasetup.py index 647cd8c97b..abf2a60b09 100644 --- a/e2e-rag/reference_mlperf_datasetup.py +++ b/e2e-rag/reference_mlperf_datasetup.py @@ -210,7 +210,7 @@ def main(): # Load config files if os.path.exists(args.user_conf): - settings.FromConfig(args.user_conf, "rag-db", args.scenario) + settings.FromConfig(args.user_conf, "e2e-rag-db", args.scenario) print(f"Loaded user config from {args.user_conf}") else: print(f"Warning: User config not found: {args.user_conf}") diff --git a/e2e-rag/reranker_worker.py b/e2e-rag/reranker_worker.py index fc0b568a82..2b42c7f36b 100644 --- a/e2e-rag/reranker_worker.py +++ b/e2e-rag/reranker_worker.py @@ -21,7 +21,7 @@ - Its own CPU affinity + memory binding (per-NUMA-node placement). - Isolation from the main process's GIL / thread pool. -Public API kept: RerankerQueue.submit(query, passages) -> [(passage, score), ...] +Public API kept: RerankerQueue.submit(query, passages) -> [(passage, score), ...] in input order. """ import ctypes @@ -127,9 +127,8 @@ def _do_rerank(model, tokenizer, device: str, query: str, sim = sim.masked_fill(~d_mask.unsqueeze(1).bool(), float('-inf')) scores = sim.max(dim=-1).values.sum(dim=-1) - scored_passages = list(zip(passages, scores.float().tolist())) - scored_passages.sort(key=lambda x: x[1], reverse=True) - return scored_passages + # Return (passage, score) in input order; caller sorts/selects. + return list(zip(passages, scores.float().tolist())) class RerankerQueue: diff --git a/e2e-rag/retrieve/ragdb.py b/e2e-rag/retrieve/ragdb.py index e4c7e1ba79..ee7470e041 100644 --- a/e2e-rag/retrieve/ragdb.py +++ b/e2e-rag/retrieve/ragdb.py @@ -251,7 +251,7 @@ def shutdown_reranker(self): self._reranker_queue = None def rerank(self, query: str, passages: List[str]): - """Rerank passages via the reranker queue (ColBERT MaxSim).""" + """Score passages via the reranker; returns (passage, score) in input order.""" if self._reranker_queue: return self._reranker_queue.submit(query, passages) return [(p, 0.0) for p in passages] From 7e64968d17502b5741fc0cc208cb038874b20dee Mon Sep 17 00:00:00 2001 From: anandhu-eng Date: Wed, 26 Aug 2026 22:04:22 +0530 Subject: [PATCH 57/59] Sync e2e-rag with master Copy the entire e2e-rag directory from master so the docs branch carries no divergence there at all. The remaining files were docs lagging master: shell scripts, user.conf, .gitignore, README.md and the scripts/ helpers. e2e-rag on docs is now byte-identical to master. Co-Authored-By: Claude Opus 5 --- e2e-rag/.gitignore | 10 ++++ e2e-rag/README.md | 31 ++++++++---- e2e-rag/config.template.sh | 14 +++--- e2e-rag/reference_mlperf_accuracy.sh | 27 +++++++---- e2e-rag/reference_mlperf_datasetup.sh | 16 +++---- .../reference_mlperf_datasetup_accuracy.sh | 34 +++++++++---- e2e-rag/reference_mlperf_perf.sh | 26 ++++++---- e2e-rag/run_compliance_test09.sh | 45 ++++++++++++------ e2e-rag/scripts/db_manifest_intel_xpu.json.gz | Bin 369706 -> 8563 bytes .../scripts/download_dataset_and_models.sh | 38 ++++++++++++--- e2e-rag/scripts/run_ingestion.sh | 2 +- e2e-rag/scripts/run_multi_shot.sh | 16 +++---- e2e-rag/scripts/run_oracle.sh | 2 +- e2e-rag/scripts/run_single_shot.sh | 6 +-- e2e-rag/scripts/start_vllm_server.sh | 4 +- e2e-rag/scripts/verify_db_manifest.sh | 26 +++++----- e2e-rag/scripts/write_db_manifest.sh | 2 +- e2e-rag/user.conf | 16 +++---- 18 files changed, 207 insertions(+), 108 deletions(-) diff --git a/e2e-rag/.gitignore b/e2e-rag/.gitignore index 817a93c1f3..b52c4e4a5e 100644 --- a/e2e-rag/.gitignore +++ b/e2e-rag/.gitignore @@ -37,6 +37,16 @@ result_*.json temp_complete_kpi_*.json run_output_datasetup_accuracy/ run_output_datasetup/ +run_output_*/ +run_output_test09/ +accuracy_results.json +/smoke_manifest.json +/db_manifest_*.json +/db_manifest_*.json.gz +doc_html_smoke/ +/submission/ +/audit.config +/verify_output_len.txt colbert-ir_colbertv2.0/ frames-benchmark-dataset/ intfloat_e5-base-v2/ diff --git a/e2e-rag/README.md b/e2e-rag/README.md index 9e83f1945d..3e1dfff471 100644 --- a/e2e-rag/README.md +++ b/e2e-rag/README.md @@ -44,8 +44,8 @@ The `setup.sh` script installs all required Python packages and system dependenc ### Typical Workflow ``` -1. Download Wikipedia documents → 2. Build vector database → 3. Run QA workload - (download_docs.py) (reference_mlperf_datasetup.sh) (reference_mlperf_accuracy.sh) +1. Download models + frozen corpus → 2. Build vector database → 3. Run QA workload + (download_dataset_and_models.sh) (reference_mlperf_datasetup.sh) (reference_mlperf_accuracy.sh) ``` ### Step 1: Download models and data (one-time) @@ -54,12 +54,16 @@ The `setup.sh` script installs all required Python packages and system dependenc bash scripts/download_dataset_and_models.sh ``` -This downloads all required models and datasets from MLCommons storage (~283GB). +This downloads all required models and datasets from MLCommons storage (~283GB) +and extracts the frozen document corpus (`docs.tar.gz`) into `doc_html/`. -**Then download Wikipedia documents:** -```bash -python3 download_docs.py --output_dir doc_html --format html --processes 30 -``` +> **Important — use the frozen corpus.** The benchmark ships a fixed Wikipedia +> snapshot as `docs.tar.gz`; the download script extracts it to `doc_html/`. +> Build your vector database from this corpus. Do **not** re-scrape Wikipedia +> with `download_docs.py` — that fetches whatever revision is live today, which +> differs from the reference corpus in bytes, passage counts, and retrieval +> results, and will fail cross-system DB-manifest verification. +> (`download_docs.py` remains only for regenerating the snapshot itself.) ### Step 2: Build vector database (one-time measured operation) @@ -165,14 +169,21 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8125 ### Wikipedia Documents -After downloading the FRAMES dataset, download Wikipedia pages referenced in it: +The document corpus is **frozen** and shipped with the FRAMES dataset as +`docs.tar.gz`. `scripts/download_dataset_and_models.sh` extracts it to +`doc_html/` (2515 HTML pages) automatically — this is the corpus every +submission must build its vector DB from. + +`download_docs.py` re-scrapes the live Wikipedia pages and is intended **only** +for regenerating the snapshot from scratch (benchmark maintenance). Do not use +it to prepare a submission: the live pages have since changed revision and will +not reproduce the reference corpus. ```bash +# Snapshot regeneration only — NOT for submissions: python3 download_docs.py --output_dir doc_html --format html --processes 30 ``` -This downloads ~2,000 Wikipedia pages in HTML format. - ### System Requirements - **Disk**: ~50GB for documents + vector DB diff --git a/e2e-rag/config.template.sh b/e2e-rag/config.template.sh index 33b80080c1..6be88f0c34 100644 --- a/e2e-rag/config.template.sh +++ b/e2e-rag/config.template.sh @@ -36,22 +36,22 @@ INFERENCE_N_QUERIES=5 INFERENCE_NUM_WORKERS=1 # LLM endpoints (vLLM, OpenRouter, etc.) -INFERENCE_LLM_URL="http://127.0.0.1:8123/v1/chat/completions" -INFERENCE_MODEL="/model/gpt-oss-20b-mxfp4" -INFERENCE_QUERY_MODEL="/model/gpt-oss-120b-mxfp4" +INFERENCE_LLM_URL="http://127.0.0.1:8192/v1/chat/completions" +INFERENCE_MODEL="gpt-oss-20b-mxfp4" +INFERENCE_QUERY_MODEL="gpt-oss-120b-mxfp4" # Per-component endpoint splits. Each defaults to INFERENCE_LLM_URL / # INFERENCE_MODEL when empty. Set when components live on different servers # (e.g. small grader on one vLLM, large query/sufficiency on another). -# INFERENCE_GRADER_URL="http://127.0.0.1:8124/v1/chat/completions" -# INFERENCE_GRADER_MODEL="/model/gpt-oss-20b" +# INFERENCE_GRADER_URL="http://127.0.0.1:8192/v1/chat/completions" +# INFERENCE_GRADER_MODEL="gpt-oss-20b-mxfp4" # INFERENCE_QUERY_URL="http://127.0.0.1:8123/v1/chat/completions" # INFERENCE_SUFFICIENCY_URL="http://127.0.0.1:8123/v1/chat/completions" -# INFERENCE_SUFFICIENCY_MODEL="/model/gpt-oss-120b" +# INFERENCE_SUFFICIENCY_MODEL="gpt-oss-120b-mxfp4" # Judge (used by evaluate.py at the end of the run scripts) INFERENCE_JUDGE_URL="https://openrouter.ai/api/v1/chat/completions" -INFERENCE_JUDGE_MODEL="openai/gpt-oss-20b" +INFERENCE_JUDGE_MODEL="Llama3.1-8B-v1" # ── Oracle evaluation (run_oracle.sh) ───────────────────────────────────────── INFERENCE_ORACLE_BATCH_SIZE=4 diff --git a/e2e-rag/reference_mlperf_accuracy.sh b/e2e-rag/reference_mlperf_accuracy.sh index 8e1a50f19d..542a222cfb 100644 --- a/e2e-rag/reference_mlperf_accuracy.sh +++ b/e2e-rag/reference_mlperf_accuracy.sh @@ -14,7 +14,7 @@ # limitations under the License. # ============================================================================ -# Accuracy test script for E2E DocGrader workload with MLPerf Loadgen +# Accuracy test script for E2E-RAG-QnA workload with MLPerf Loadgen echo "Time Start: $(date +%s)" @@ -23,8 +23,8 @@ export WORKSPACE_DIR=${WORKSPACE_DIR:-"/workspace"} export DATA_DIR=${DATA_DIR:-"frames-benchmark-dataset"} export DATASET_PATH="${DATA_DIR}/frames_dataset.tsv" export DATABASE="${DATABASE:-vector_html_hnsw_len768_ov32_word.db}" -export RUN_LOGS=${WORKSPACE_DIR}/run_output -export OUTPUT_DIR=${WORKSPACE_DIR}/output +export RUN_LOGS=${WORKSPACE_DIR}/run_output_e2e-rag-qna/accuracy +export OUTPUT_DIR=${WORKSPACE_DIR}/output_e2e-rag-qna/accuracy export SCENARIO="${SCENARIO:-Offline}" # Threading configuration @@ -48,12 +48,17 @@ export RERANKER_MODEL=${RERANKER_MODEL:-colbert-ir_colbertv2.0/colbertv2.0} export OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-sk-or-v1-****} # Default to local vLLM server -export LLM_SERVICE_URL=${LLM_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} -export LLM_MODEL=${LLM_MODEL:-gpt-oss-20b} -export QUERY_MODEL=${QUERY_MODEL:-gpt-oss-120b} +export LLM_SERVICE_URL=${LLM_SERVICE_URL:-http://127.0.0.1:8192/v1/chat/completions} +export LLM_MODEL=${LLM_MODEL:-gpt-oss-20b-mxfp4} +export QUERY_MODEL=${QUERY_MODEL:-gpt-oss-120b-mxfp4} + +# Query and sufficiency use 120B model on port 8123 +export QUERY_SERVICE_URL=${QUERY_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} +export SUFFICIENCY_SERVICE_URL=${SUFFICIENCY_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} +export SUFFICIENCY_MODEL=${SUFFICIENCY_MODEL:-gpt-oss-120b-mxfp4} # Judge LLM configuration (for accuracy evaluation) -export JUDGE_SERVICE_URL=${JUDGE_SERVICE_URL:-http://127.0.0.1:8125/v1/chat/completions} +export JUDGE_SERVICE_URL=${JUDGE_SERVICE_URL:-http://127.0.0.1:8193/v1/chat/completions} export JUDGE_MODEL=${JUDGE_MODEL:-meta-llama/Llama-3.1-8B-Instruct} echo " LLM Service URL: ${LLM_SERVICE_URL}" @@ -79,7 +84,7 @@ if [ -n "${PERF_COUNT}" ]; then fi # Update user.conf with threading configuration -sed -i "s/^e2e.Offline.max_async_queries = .*/e2e.Offline.max_async_queries = ${MAX_ASYNC_QUERIES}/" user.conf +sed -i "s/^e2e-rag-qna.Offline.max_async_queries = .*/e2e-rag-qna.Offline.max_async_queries = ${MAX_ASYNC_QUERIES}/" user.conf # Run loadgen accuracy test python3 reference_mlperf.py \ @@ -98,8 +103,14 @@ python3 reference_mlperf.py \ --llm_service_url ${LLM_SERVICE_URL} \ --llm_model ${LLM_MODEL} \ --query_model ${QUERY_MODEL} \ + --query-service-url ${QUERY_SERVICE_URL} \ + --sufficiency-service-url ${SUFFICIENCY_SERVICE_URL} \ + --sufficiency-model ${SUFFICIENCY_MODEL} \ --judge_service_url ${JUDGE_SERVICE_URL} \ --judge_model ${JUDGE_MODEL} \ --accuracy +EXIT_CODE=$? + echo "Time Stop: $(date +%s)" +exit ${EXIT_CODE} diff --git a/e2e-rag/reference_mlperf_datasetup.sh b/e2e-rag/reference_mlperf_datasetup.sh index fba93c0a24..df82def111 100755 --- a/e2e-rag/reference_mlperf_datasetup.sh +++ b/e2e-rag/reference_mlperf_datasetup.sh @@ -22,8 +22,8 @@ echo "Time Start: $(date +%s)" export WORKSPACE_DIR=${WORKSPACE_DIR:-"/workspace"} export DOCUMENTS_DIR=${DOCUMENTS_DIR:-"doc_html"} export DATABASE="${DATABASE:-vector_html_hnsw_len768_ov32_word}" -export RUN_LOGS=${WORKSPACE_DIR}/run_output_datasetup -export OUTPUT_DIR=${WORKSPACE_DIR}/output_datasetup +export RUN_LOGS=${WORKSPACE_DIR}/run_output_e2e-rag-db/performance +export OUTPUT_DIR=${WORKSPACE_DIR}/output_e2e-rag-db/performance export SCENARIO="${SCENARIO:-Offline}" # Chunking configuration @@ -81,16 +81,16 @@ fi if [ -f "user.conf" ]; then # Update max_async_queries to match HTML count (send all at once) # Update min_query_count to match HTML count - if grep -q "e2e-datasetup.Offline.max_async_queries" user.conf; then - sed -i "s/^e2e-datasetup.Offline.max_async_queries = .*/e2e-datasetup.Offline.max_async_queries = ${HTML_COUNT}/" user.conf + if grep -q "e2e-rag-db.Offline.max_async_queries" user.conf; then + sed -i "s/^e2e-rag-db.Offline.max_async_queries = .*/e2e-rag-db.Offline.max_async_queries = ${HTML_COUNT}/" user.conf else - echo "e2e-datasetup.Offline.max_async_queries = ${HTML_COUNT}" >> user.conf + echo "e2e-rag-db.Offline.max_async_queries = ${HTML_COUNT}" >> user.conf fi - if grep -q "e2e-datasetup.Offline.min_query_count" user.conf; then - sed -i "s/^e2e-datasetup.Offline.min_query_count = .*/e2e-datasetup.Offline.min_query_count = ${HTML_COUNT}/" user.conf + if grep -q "e2e-rag-db.Offline.min_query_count" user.conf; then + sed -i "s/^e2e-rag-db.Offline.min_query_count = .*/e2e-rag-db.Offline.min_query_count = ${HTML_COUNT}/" user.conf else - echo "e2e-datasetup.Offline.min_query_count = ${HTML_COUNT}" >> user.conf + echo "e2e-rag-db.Offline.min_query_count = ${HTML_COUNT}" >> user.conf fi echo " Loadgen configured to dispatch all ${HTML_COUNT} files at once" diff --git a/e2e-rag/reference_mlperf_datasetup_accuracy.sh b/e2e-rag/reference_mlperf_datasetup_accuracy.sh index 1a6faaa598..85e262c88b 100755 --- a/e2e-rag/reference_mlperf_datasetup_accuracy.sh +++ b/e2e-rag/reference_mlperf_datasetup_accuracy.sh @@ -22,8 +22,8 @@ echo "Time Start: $(date +%s)" export WORKSPACE_DIR=${WORKSPACE_DIR:-"/workspace"} export DOCUMENTS_DIR=${DOCUMENTS_DIR:-"doc_html"} export DATABASE="${DATABASE:-vector_html_hnsw_len768_ov32_word}" -export RUN_LOGS=${WORKSPACE_DIR}/run_output_datasetup_accuracy -export OUTPUT_DIR=${WORKSPACE_DIR}/output_datasetup_accuracy +export RUN_LOGS=${WORKSPACE_DIR}/run_output_e2e-rag-db/accuracy +export OUTPUT_DIR=${WORKSPACE_DIR}/output_e2e-rag-db/accuracy export SCENARIO="${SCENARIO:-Offline}" # Chunking configuration @@ -42,6 +42,15 @@ export NUM_EMBEDDING_DEVICES=${NUM_EMBEDDING_DEVICES:-1} # Vector database configuration export VECTOR_INDEX_METHOD=${VECTOR_INDEX_METHOD:-"hnsw"} +# Reference DB manifest for cross-system verification (corpus fingerprint, +# sample-embedding cosine, probe-query top-K ranks). +# Set to "" or "none" to skip the manifest check. Note: ${VAR:-default} treats +# an empty value the same as unset, so an explicit "none" sentinel is the +# reliable way to skip from a parent script that exports MANIFEST="". +export MANIFEST=${MANIFEST-scripts/db_manifest_intel_xpu.json.gz} +export COSINE_THRESHOLD=${COSINE_THRESHOLD:-0.9999} +export TOP_K_DEPTH=${TOP_K_DEPTH:-3} + # Performance options export BENCHMARK=${BENCHMARK:-false} export MAX_WORKERS=${MAX_WORKERS:-4} @@ -81,16 +90,16 @@ fi if [ -f "user.conf" ]; then # Update max_async_queries to match HTML count (send all at once) # Update min_query_count to match HTML count - if grep -q "e2e-datasetup.Offline.max_async_queries" user.conf; then - sed -i "s/^e2e-datasetup.Offline.max_async_queries = .*/e2e-datasetup.Offline.max_async_queries = ${HTML_COUNT}/" user.conf + if grep -q "e2e-rag-db.Offline.max_async_queries" user.conf; then + sed -i "s/^e2e-rag-db.Offline.max_async_queries = .*/e2e-rag-db.Offline.max_async_queries = ${HTML_COUNT}/" user.conf else - echo "e2e-datasetup.Offline.max_async_queries = ${HTML_COUNT}" >> user.conf + echo "e2e-rag-db.Offline.max_async_queries = ${HTML_COUNT}" >> user.conf fi - if grep -q "e2e-datasetup.Offline.min_query_count" user.conf; then - sed -i "s/^e2e-datasetup.Offline.min_query_count = .*/e2e-datasetup.Offline.min_query_count = ${HTML_COUNT}/" user.conf + if grep -q "e2e-rag-db.Offline.min_query_count" user.conf; then + sed -i "s/^e2e-rag-db.Offline.min_query_count = .*/e2e-rag-db.Offline.min_query_count = ${HTML_COUNT}/" user.conf else - echo "e2e-datasetup.Offline.min_query_count = ${HTML_COUNT}" >> user.conf + echo "e2e-rag-db.Offline.min_query_count = ${HTML_COUNT}" >> user.conf fi echo " Loadgen configured to dispatch all ${HTML_COUNT} files at once (accuracy mode)" @@ -133,11 +142,18 @@ if [ ${EXIT_CODE} -eq 0 ]; then echo "Running Accuracy Evaluation" echo "============================================================" + # Build optional manifest argument (skip check if MANIFEST is empty or "none") + MANIFEST_ARG="" + if [ -n "${MANIFEST}" ] && [ "${MANIFEST,,}" != "none" ]; then + MANIFEST_ARG="--manifest ${MANIFEST} --cosine_threshold ${COSINE_THRESHOLD} --top_k_depth ${TOP_K_DEPTH}" + fi + python3 datasetup_accuracy_eval.py \ --log_dir ${RUN_LOGS} \ --output_dir ${OUTPUT_DIR} \ --database ${DATABASE}.db \ - --retriever_model ${RETRIEVER_MODEL} + --retriever_model ${RETRIEVER_MODEL} \ + ${MANIFEST_ARG} EVAL_EXIT_CODE=$? diff --git a/e2e-rag/reference_mlperf_perf.sh b/e2e-rag/reference_mlperf_perf.sh index eb38215279..7e784f502d 100644 --- a/e2e-rag/reference_mlperf_perf.sh +++ b/e2e-rag/reference_mlperf_perf.sh @@ -14,7 +14,7 @@ # limitations under the License. # ============================================================================ -# Performance test script for E2E DocGrader workload with MLPerf Loadgen +# Performance test script for E2E-RAG-QnA workload with MLPerf Loadgen echo "Time Start: $(date +%s)" @@ -23,8 +23,8 @@ export WORKSPACE_DIR=${WORKSPACE_DIR:-"/workspace"} export DATA_DIR=${DATA_DIR:-"frames-benchmark-dataset"} export DATASET_PATH="${DATA_DIR}/frames_dataset.tsv" export DATABASE="${DATABASE:-vector_html_hnsw_len768_ov32_word.db}" -export RUN_LOGS=${WORKSPACE_DIR}/run_output -export OUTPUT_DIR=${WORKSPACE_DIR}/output +export RUN_LOGS=${WORKSPACE_DIR}/run_output_e2e-rag-qna/performance +export OUTPUT_DIR=${WORKSPACE_DIR}/output_e2e-rag-qna/performance export SCENARIO="${SCENARIO:-Offline}" # Threading configuration @@ -51,13 +51,16 @@ export RERANKER_MODEL=${RERANKER_MODEL:-colbert-ir_colbertv2.0/colbertv2.0} export OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-sk-or-v1-****} # Separate service endpoints for each LLM component -export LLM_SERVICE_URL=${LLM_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} -export LLM_MODEL=${LLM_MODEL:-gpt-oss-20b} +export LLM_SERVICE_URL=${LLM_SERVICE_URL:-http://127.0.0.1:8192/v1/chat/completions} +export LLM_MODEL=${LLM_MODEL:-gpt-oss-20b-mxfp4} -export QUERY_SERVICE_URL=${QUERY_SERVICE_URL:-http://127.0.0.1:8124/v1/chat/completions} -export QUERY_MODEL=${QUERY_MODEL:-gpt-oss-120b} +export QUERY_SERVICE_URL=${QUERY_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} +export QUERY_MODEL=${QUERY_MODEL:-gpt-oss-120b-mxfp4} -export JUDGE_SERVICE_URL=${JUDGE_SERVICE_URL:-http://127.0.0.1:8125/v1/chat/completions} +export SUFFICIENCY_SERVICE_URL=${SUFFICIENCY_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} +export SUFFICIENCY_MODEL=${SUFFICIENCY_MODEL:-gpt-oss-120b-mxfp4} + +export JUDGE_SERVICE_URL=${JUDGE_SERVICE_URL:-http://127.0.0.1:8193/v1/chat/completions} export JUDGE_MODEL=${JUDGE_MODEL:-meta-llama/Llama-3.1-8B-Instruct} echo "Configuration:" @@ -80,7 +83,7 @@ echo " JUDGE_SERVICE_URL: ${JUDGE_SERVICE_URL}" echo " JUDGE_MODEL: ${JUDGE_MODEL}" # Update user.conf with threading configuration -sed -i "s/^e2e.Offline.max_async_queries = .*/e2e.Offline.max_async_queries = ${MAX_ASYNC_QUERIES}/" user.conf +sed -i "s/^e2e-rag-qna.Offline.max_async_queries = .*/e2e-rag-qna.Offline.max_async_queries = ${MAX_ASYNC_QUERIES}/" user.conf # Build perf cache argument if file exists PERF_CACHE_ARG="" @@ -109,8 +112,13 @@ python3 reference_mlperf.py \ --llm_model ${LLM_MODEL} \ --query_service_url ${QUERY_SERVICE_URL} \ --query_model ${QUERY_MODEL} \ + --sufficiency-service-url ${SUFFICIENCY_SERVICE_URL} \ + --sufficiency-model ${SUFFICIENCY_MODEL} \ --judge_service_url ${JUDGE_SERVICE_URL} \ --judge_model ${JUDGE_MODEL} \ ${PERF_CACHE_ARG} +EXIT_CODE=$? + echo "Time Stop: $(date +%s)" +exit ${EXIT_CODE} diff --git a/e2e-rag/run_compliance_test09.sh b/e2e-rag/run_compliance_test09.sh index 483d6590bc..4ee05e7209 100755 --- a/e2e-rag/run_compliance_test09.sh +++ b/e2e-rag/run_compliance_test09.sh @@ -14,7 +14,7 @@ # limitations under the License. # ============================================================================ -# TEST09 Compliance Test Runner for E2E DocGrader Workload +# TEST09 Compliance Test Runner for E2E-RAG-QnA Workload # Automates: setup -> run -> verify -> cleanup workflow set -e # Exit on error @@ -27,7 +27,11 @@ echo "" # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COMPLIANCE_DIR="${SCRIPT_DIR}/../compliance/TEST09/e2e-rag" +# The TEST09 config comes from the main inference repo's compliance tree. +# Copy compliance/TEST09/e2e-rag-qna/ into this directory before running +# (e.g. when e2e-rag is mounted standalone into a container), or override +# COMPLIANCE_DIR to point at it. +COMPLIANCE_DIR="${COMPLIANCE_DIR:-${SCRIPT_DIR}/../compliance/TEST09/e2e-rag-qna}" AUDIT_CONFIG="${COMPLIANCE_DIR}/audit.config" WORKING_AUDIT_CONFIG="${SCRIPT_DIR}/audit.config" TEST09_VERIFICATION="${SCRIPT_DIR}/third_party/mlperf-inference/compliance/TEST09/run_verification.py" @@ -39,11 +43,12 @@ export DATASET_PATH="${DATA_DIR}/frames_dataset.tsv" export DATABASE="${DATABASE:-vector_html_hnsw_len768_ov32_word.db}" export RUN_LOGS=${WORKSPACE_DIR}/run_output_test09 export OUTPUT_DIR=${WORKSPACE_DIR}/output_test09 -export SUBMISSION_DIR=${WORKSPACE_DIR}/submission/compliance/e2e-rag/Offline +export SUBMISSION_DIR=${WORKSPACE_DIR}/submission/compliance/e2e-rag-qna/Offline export SCENARIO="${SCENARIO:-Offline}" -# Performance testing - full dataset for compliance -export PERF_COUNT=824 +# Performance testing - full dataset for compliance. +# Overridable (e.g. for a smoke run); a valid TEST09 submission needs 824. +export PERF_COUNT=${PERF_COUNT:-824} # Threading configuration export MAX_ASYNC_QUERIES=${MAX_ASYNC_QUERIES:-10} @@ -55,15 +60,17 @@ export MAX_SUB_QUERIES=${MAX_SUB_QUERIES:-3} export TOP_K_RETRIEVER=${TOP_K_RETRIEVER:-10} # Model paths -export RETRIEVER_MODEL=${RETRIEVER_MODEL:-/data/model/e5-base-v2} -export RERANKER_MODEL=${RERANKER_MODEL:-/data/model/colbertv2.0} +export RETRIEVER_MODEL=${RETRIEVER_MODEL:-intfloat_e5-base-v2/e5-base-v2} +export RERANKER_MODEL=${RERANKER_MODEL:-colbert-ir_colbertv2.0/colbertv2.0} # LLM service configuration -export LLM_SERVICE_URL=${LLM_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} -export LLM_MODEL=${LLM_MODEL:-gpt-oss-20b} -export QUERY_SERVICE_URL=${QUERY_SERVICE_URL:-http://127.0.0.1:8124/v1/chat/completions} -export QUERY_MODEL=${QUERY_MODEL:-gpt-oss-120b} -export JUDGE_SERVICE_URL=${JUDGE_SERVICE_URL:-http://127.0.0.1:8125/v1/chat/completions} +export LLM_SERVICE_URL=${LLM_SERVICE_URL:-http://127.0.0.1:8192/v1/chat/completions} +export LLM_MODEL=${LLM_MODEL:-gpt-oss-20b-mxfp4} +export QUERY_SERVICE_URL=${QUERY_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} +export QUERY_MODEL=${QUERY_MODEL:-gpt-oss-120b-mxfp4} +export SUFFICIENCY_SERVICE_URL=${SUFFICIENCY_SERVICE_URL:-http://127.0.0.1:8123/v1/chat/completions} +export SUFFICIENCY_MODEL=${SUFFICIENCY_MODEL:-gpt-oss-120b-mxfp4} +export JUDGE_SERVICE_URL=${JUDGE_SERVICE_URL:-http://127.0.0.1:8193/v1/chat/completions} export JUDGE_MODEL=${JUDGE_MODEL:-meta-llama/Llama-3.1-8B-Instruct} # Performance cache file (optional - for faster testing) @@ -109,7 +116,11 @@ mkdir -p "${SUBMISSION_DIR}" # Copy audit.config to working directory echo "Copying audit.config to working directory..." cp "${AUDIT_CONFIG}" "${WORKING_AUDIT_CONFIG}" -echo "✓ audit.config copied to ${WORKING_AUDIT_CONFIG}" +# Keep the audit.config's min_query_count in sync with PERF_COUNT. Otherwise +# loadgen honors the config's min_query_count (824) and loops back up to it even +# when PERF_COUNT is smaller (e.g. a smoke run). A real submission uses 824. +sed -i "s/^\*\.\*\.min_query_count = .*/*.*.min_query_count = ${PERF_COUNT}/" "${WORKING_AUDIT_CONFIG}" +echo "✓ audit.config copied to ${WORKING_AUDIT_CONFIG} (min_query_count=${PERF_COUNT})" echo "" # ============================================================================ @@ -129,11 +140,15 @@ if [ -n "${PERF_CACHE_FILE}" ] && [ -f "${PERF_CACHE_FILE}" ]; then fi # Run loadgen performance test -# Note: LoadGen automatically detects audit.config in the current directory +# reference_mlperf.py passes --audit_conf explicitly to StartTestWithLogSettings, +# so we must point it at the copied audit.config (named audit.config, whereas +# the default arg is audit.conf). Without this, loadgen never applies the TEST09 +# accuracy_log_sampling_target and mlperf_log_accuracy.json comes out empty. python3 reference_mlperf.py \ --dataset_path ${DATASET_PATH} \ --database ${DATABASE} \ --scenario ${SCENARIO} \ + --audit_conf ${WORKING_AUDIT_CONFIG} \ --log_dir ${RUN_LOGS} \ --output_dir ${OUTPUT_DIR} \ --perf_count ${PERF_COUNT} \ @@ -147,6 +162,8 @@ python3 reference_mlperf.py \ --llm_model ${LLM_MODEL} \ --query_service_url ${QUERY_SERVICE_URL} \ --query_model ${QUERY_MODEL} \ + --sufficiency-service-url ${SUFFICIENCY_SERVICE_URL} \ + --sufficiency-model ${SUFFICIENCY_MODEL} \ --judge_service_url ${JUDGE_SERVICE_URL} \ --judge_model ${JUDGE_MODEL} \ ${PERF_CACHE_ARG} diff --git a/e2e-rag/scripts/db_manifest_intel_xpu.json.gz b/e2e-rag/scripts/db_manifest_intel_xpu.json.gz index 3a46be5dc0c94ad9f8e72bfb9239be185cc2b515..a8271034b5c31aa8ed5407cab9130fb13347fb81 100644 GIT binary patch literal 8563 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 z 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 z z?0d)+3M$kWg~{ULl}6T3%WJ!pZa=Sy=m(`7Ghlu3w?!z0cEs=45LGX-ztEf0%d>Iy zBplIF8>cJSg{TF#CUsMYtmW7R;tlDEaOohc2*~>-x@lYz1aEIOz?&?9VFe><_EV*5 zFfB7MpUerP_eJ@ORty5Q<875mdDYV0NCAI;mPQbU^j~?mpw?-S(Bs#z%r10*4OcD$ z64!}f|EaP|69S(&Al%vvk>Bx?G+1WLh;?N>vc9;Yk!HQ{YQ^Hg6bqF$NVushG#1xV z!%=+!g1_GF5jPEbg$=Tqq6f7iJrOxD!*j`-QlFbRl2+~Ut3o|x7~k?-uOCS1lHAj8 z)O%XN2)e5U!Hkm*JkCYieSbSkKTymRy83`ZJFbVmDnzS}+Ik#&z(vF3R2b&FBobW# z_~0Tacdc>Hk&!{j_WPOQbl!*&m>*PSG`gOO;Vz1|TmJB~iE2$hzG!lN*Vd%dT~g~% zSck1$zwhqf3t>x5yp`=L-Yr$UB+8U8LyPIW2zLjbml1d1G6YWE*_5H2ng85CP<`Hi3d$wR= zIAaPYn&RXLl{YCY%)~A)Tf1HkpbDEB&janMVShuN4Ss=JcZL?Oqb34cA+4_Mx|R^p zJaX@o^idgnM}X8<9ZuvQzTZTcVA^Oq)F(zQDP%R)4CA$eGbB1;tQGK3tnT6q9+D{I zY{fp$H#8M!G~ydlz&#Pf5j9S*g1C5%x73kY=kqwLww^agC|FnCnaoDxUA@!uo-ekz zB2S>Rv=KKg1x6735i#>iVSFfh$QAP67DWL1`HBP}coTJf0rJ=k-Q}SCXW8+Tg%H9n z6x;7izT6ivB(GFG)FRB3tG~4{*!CvHlI$)I8K$aL#k{XapEFq93Z~TqXZHw3AU4V9 zYI+FwxS(L8uu=qP6bOR`qnQtquhD7ecwca$NL@EPH9*rQB?^Fd_t781m?m|lhPdvU z#5RXOn`*Ge?45ZMaoI@ouNwjBg4s7SY02QrsT5I)NMsiNelkfU=0@wp<#yd6bsP2A zIe~Manp=2USILCBA>|cEr%;>WxaYt@S_iCfhuNDW3OyEmX=^UMhJ%SGkK_IZJcH?I z;WZGSsYkmtxeXZ85TBTD{ySjtnL~#Q@H4QYJGKxN*J&cGN8f8iq#Ozek5C{*r~*VB z;<`lc4nW*ZP0j4dcA!@YzX#wQfNxI=nG!(DKJ&|w|H+AQ@lG;bP}n365%D5?ZukYw zJudA_&Fe6&U)9m$z#@5tjV_kq8xAN0+sFyzX0adpUA=rqNR2lmrqF$HixuHF`}Tmt zAEH=C!PD1_+>los^Dolpod`f;+xWh}imU(Ra9>I9hmi5e4ImzNmj5K{M0)ws?F?n; zJ)Z6pCYM12#LKKSIOy!6WF+k(+*;SA$C9|gyNb!@Q8ylm_T?!GC23PQbmwO8fz)V( zRv+0em6;n0LwbD>NmO3jZ&B_WwZ(K-{$ZhUnL3x4$HX|gi0RH)=>vSZ#*JymQb2<< z_Hp3iPG>XF`t^=md;sJL09gX{02TDgYEYKX^wFMUKgK?{118UdO3g5^XB8j*!G0#c zj;^|9zlDZ`DId35bP=p<3n{%4$Q#w|eJfSP<}0yCuyfD{^Et>_M?R$pOa~@Q6T?(& zJII{GnD;cZFCMW3YiJ9VMayaz2#_X`hBIc2568f#+7RQR%_l#X8 zY>YhLU*jPMTLe1DTV(&(41hig77B&1mq1O)u!k`pSok;;4v10;c_7@$M5+qPR)Sf- zj^f}XE7ZqTQbga&!RSV5M%=N}%F*I8++)0Zg29Xn`^rg*Z+6Dik;OL%9{zW+>}UMO zydQtdbgmxLD(I|C9=M}tpzpv{lT8MzCxAFOF-YxD9H0;O2g(S5l876MwY^a6@tLr2 zZXt~6{If*O00($y=KqHrO=bjoXmE-(bOZ2WK=LA5`U75z>H32$pBkGsl2AB~!ER6Y zxx$-6Hu5fOHY#p#e9*d5MX>{mw7P^ftTNmdM<+CJF#@%c;tlA=pvqQBVuRM%SKTMRd@@Fj^ECI%0}R zavO4g{_!=pGdSTPV}8bZv#bVWjmEN+Id=K&-T4IPObUf-c^6C!{NUzOv@*Hl>k4%q z@nB87iJ(A>Zo~qfpU$NclW(23b$!xO7jWR(fsw}=_V}9ucwP_^NV?b8mLQ|jeE~TtbBA1d@gsv@ zRZ>GZ22?2{Jb)eFT4-!QH6T`nX*%-nrtw^&MeLRXUHVEfnGpSFXF_}*AC*iG9{Ncd zJo`&jKx?}C5)|&c+_(=Ayadu%%cmn*+V0Mo0WO4uk}?3Vc1&7$>&4n9uekZWi<*?Y z=XzF2~-d9LeWg_{*#T~Jf+RXDgy{6s@C*i_bs;vyI^nPU!b(6(`FY@6jA+DFW zp%lk+b}4+|sJ?S2l8$>*I1N}@S5k<7M_Os!0EE*Be;7l81y&d9Bht&!4Q+-b?CX-` zgWmw}Px(kR0R_Ps2GO)ZsWH#Vz<iWH{!w_X{5DGR2mLvgq>##MSTZp9<-9X>V=HWT)V% zmM>9h9u-Lq=GbSRP2t9DqTkDQZ@}{93&MrRB*4ly1R|tELL$vT1ERv^u4S|oIjZR9 zsogfMnlseVs^Pt1+2_73Tv=1)J}c5day4d|TzTl2OVHaF5=>|m8t`Xg0pUUi^DSPL z@FK9V!^?oyIV?8B>T>H`yi&bITYPU(Xa(0=3+f&P<@*8<@%Gh1bV@+SAwL3EQVQK! zUkw~M#K$zh#0h3+?6rvxxYOf&80HkdNgmwHvYxo0w`j;W4kevg6PU?}v1XS*;VRbZ z2IKOJsivl+gP6xF6FG3cFg6NF4^!M?I60KLvMWxw>S&1DRxp4aTef_Pu z#{M*%HksOJmxl7yasN7to(xUMm!DX`Zb{yhA=2BN5f+aW@v;QRkQ_EP*n^v`uFyU1 zV*%&SI1Mi<$9JT0X`#h#c9Z)4ufMwgqq!}U{7IVOSP7G+NJ5rI*hLFlz3Elr+HDH>so1tsCT_o*6Z4bmx0w=owTQ zu6~An8>3Y7My)E6V*cP=mzO8Z;Oob9NJPAyAeCLv zRk9*qRQS%A=OMxATbO=dGg7Qi(T(J{cAH*N)`o~jwR^!-l`e~Bq*z_r+0c>^1kEz+ z-Pq@t7t8c~iYJTc#;o6P&->P^jU}4Evun(Hl`e?3Vp1150Gqk$p$vmQ5NJiI!n^j< zJz~elnnNkSdev%}Wtb4ZLg2Sf0u--tAyj#Fdo*eFBp_%j6(w#$B8!_ zog4QoT{BC}kf;(&1*DpneLGWb(Y8WS-pAXW4f9*+Q_yt0p~26o54pm@@HunGn%9Ms z#q%220N`D0)83&CjmxKpbh3&vQM&wa{S7%g2y<`qNvfn4Q%qOZK%VzUQ;9qhLI;Qr zt9W1;9CV0MPX}RzfjMX%tSRpN8$2A6d=VczVzbS!JJx?$g|=P$zb@N_q(YiuQwEV3&5M8*x%F~WT7uMm195XT)P z)&aevhvsuB$+k-($@(%pnB>z@kiTbaTla^URP8@I%-S3mKADjZh|y>ajA0AokfHYb zpyeS6>Lbox;))-|5O3u`r7ojveqVJ;8qSS|AKM%AR8KDQM+|WQOMZM(VMye zy{mv%GdBOYqL~bAom+owa!F0??|D}eGlEcuz5}{yHK`%9AmovICI77M;S6Pb|vtBbVCyxLAL>*c2SI^lp@w zFgkJ>2$_wvqg`Svg(k6}74MpQMg>4Mi1ZSj$XYGD&PZ+ONV!>}iqhbre>CdWhHGlx z^@?j0Bj*gDWfROD{Ushi2Tp2`M^mEu^JRSYwsX{S38m z#$FMRJF6I;p!Xhz=!Iw zv4d?NaVO1GTCiCbGtCChUm}h3)3JAQ&DnnVQ1WQwg?$ErQOLFetf+kBj1I>f}YX?t0+m~tP*>6i?zN>RB-xj(CyU|@6d7)LH@qs?t^-QkpMVx zKsXBJ{Ve2A;Ci~<@l4RwnDSAWE|)hzO@yt1|>;&W#WA`Q@IiT z$F8IY){4)^`{tZ0WGNBo3~& zq&C3MC~d*;NFNr~s!AoyaA^Q`8ikPp?;5owsji^Zz?oPhDde3<6Oav^@@jtSkcfhw z9F|dZ!AQSQHLY*15!lbpB2@#^_mMb19XX6LF<6XdG}1c)nME7~7%|{^Ejfr>l$3M+ z;b9`mi-8C0(m4Zyrz;)}>cw7{x&mS%ctqIX#cUBzE3VlOEnD2vBQ78`#4Wd>V*w9I zs*N?vD1mQVTysT%E=pW=V$rL4NCXfjBA~*RNeY2dDBog9!(x)iZH!Eb(PZTGlV1Pn zy6m{?M=7o_R8SOIU&5)v6ko1fAL9~~&PE-~)?Lf>1B`W=a_rG2_=JX~`Q=^2mTqTo zL&UTYHILt;v~T)2p~j)#*mK01O=#dQxI`8QMIRtww+0$=L`2q4dPJvOSfvN5phuDv4EZB zc8W+aZbQb20*VHnzoA3}7{{Vu`VNR+qB4-OF)0EG?-Tei?v)A?}{YT!u8h~N_=T>9jyT;lD; zh7n|@&vqrupxu_`IHIK9oA9p5%XGHf5WhBt=>vp2Q|++&(@nQ4Y$%f!-{W+^5sZ#D zYDjMbu?uVT(^zs&vlj^}qgBr(ozDwmzC_4D@2{PnRxv6;GyvYXCu*+$$p8kVJ2H%U9w`o3blS;at&LrT2L2W zr`J4aj4T`BNZ*~9g!RpcRgP4I69$b2s?Zg{K{C`IuxhFHi+K00?imCbEL3aAuOn1| z8+ywl)N9m2hr)tOO@9eXmt5N5D+dcc|!@e#BXBHd1G>nY>=FC9c(M`BpdPp zqyG_{Yl`-AI z8%VQAXaL9BH^B|hwjwlGIG`%Gd*PB#0IQx$tA^}I>|-Lb2iNEb*u!KKfy3_|nSP84 zZi}`b_}{Q9is@aEXQE-7T2UnnuE^hw5pQKQDy0NY+?|7R+?^e}75|@YU=%*buL^X- zorPC;i$NkA8yq{>2M5?@c&os$Y@wjYzDUA<|Xs6K`=FW(>Bp=X2J_L`t~l~#}3R zY=4K)zkb~T%neB0h?E_PlF#rKIrD6#NgE0EHkeqJFc`h94{B7hq9T#$=OB9cn`FL` zLKJLuLBOY+X(}m`j|!)^Ka zc?aNs)<8(XH`%fef(CHF8_S)+^)ZjhWchdr%!F~x`{V{*MD&-BlvQm2atC;*_ccsj zHa~@xFx^!+@a#ow3sJ2|rMbeV4}Vc@a-y*%jnkpvVz~=-1x0)`jZm>Jqy4i54)rma z+;n)7w#>NkTfJxJeO&1GkJBBH*DpZi7&UaPM*bcv4FI`yd$ZGqtN|2LUDV>>=QG#i z`2Q+_uv}7k5)LGV_y5?zkwfmvx%B&$i06Dq`iMX}ojnV!HTC;$kx>ryfbUke zcE2Q8>qE=~qUBE>69ag&jW^=NiWh@1_Mav!O$!_~_cw1VSx6olSDJjspnV{dIAb48 zU{jNP6+YnhwjsRzmX0)@1W+Qs%YkWgtx{*9g(Evj>d` zumSkABTkb$S`MtYG1*ubH+QhG0%E1uS!*nrhQ2xt-1UO2xBf^@2lELFYy|`f<;Ukh z27^BxbQov)^oFZi*>!h;gzy3FOjXMX2)`F)RJ{rOX=y$0U zM+%j%+U? z@a?`6sY{~8%sV#mAm918!2rATSdoE1M&9SKJ;14pD&Lph%KGOM0B)EPLk86vR0`CZKdP!U>9@__)hRvFYG7~I$)Gvhe`N^on2;|;dx ztI{Oa5wkFA+X{c5H9sz{VC2-Gm zPm13a(21C|Kf>MEYye7RVVsNcOD-UrZH3FmpaB2R-Kv4lyonhCttiero=y9-A&n?r z7-92_*%f08!b8#9`_si3{C&6h;l+mZX%1`^t~}PjI}%=h$dQW5Yq4J-->(RYw#AhA zeeh`u$gY;W1e1|bt6u@G4!I{Dq78gp%2t3a(USqF;U#QrfFfvzDGeJ>-qX1w((OJDe0N}o(jF0*p@jq*vJW@QOLO3%b#aZ3rEdmgDbFxhME+SIk zd;H6>OJrt!vW&-9pCAD}lMZ4IUe zOffb`R~R_~F|nb?XnP!3siZngM)zRma6ilbB1eZqt{RKhbLx zrkpyzZ!HD`z=V}Z`O1$KHthjV7}R%g(vs{^IvIl8@S96ZQzR;+|44AUAXM%o$1(Jk(7uqtGu!?gaue$q)@q8XORzU>YglT~gBueG`hr}N& zFP9SL!XDWa-vXK3>Vhq8x%RNou`oEtCE)1MuBQ=r*|56uKM|bo*jH{fqt}rNV3b3X zHs3kslAO`k_M$;$cJ>Uu&;FO4qMbLT^%!|Q2%xtk$vpJ zF99GP!ULZV7MwQ3UN^wmXDm4cM74?%CJneR-K2B6GMvxJ(pmA};I7JTst0>^)JC4LU7Gt-iwL%7%2Jz(VJ8cX5un7BH>_u$X1rB|%Bm zox=g3xkFN75W^1G>7gX+GFu!>={$G_uM1}-H>ei{Ooh;Q;G|^D=%0q>dQ>8eMZ1rc z-tWRgMX5aRh#`U}JXhlukFzUU#UiuA&bYd}nncu^=<9a4@aD(uKk%m*t4=>54oS3#(&1&teMhht zAIpG5=z_Z??qc|n=>nf%_9KsQ_6An7z7P?)~wuvB+qyUUddSH!z7{B5ByEXeIEIC&BWIwZhqI_%-Dax!A zN1h}q@sdL%=<@J%n%*9QSbR%tsOKM;M)CXLcJe;tpp4=1RQ?6D6$`?~8M1|>yg`@4 zMMrH2IQPBI)9(ld){GUaY5`Y=vR~EqT%W+)AsLK&{6Ru#L%?%^qD=qoOp#<ddpF=NeijOdkXed?GL@8ies5^L^$qY1zB|AJ z0W8BoBjX1xQB+IhNe~(;p1MDDIF>t6!v_`SiON#GU&g`<44_RqNJ_1uAcE^=ZF8**A2A4>01(?~HK~vwc|1kwK-5}u#l1aTnHd$qa*s>HMv>S+) z*9ELUU`j&!LHy=yPM*f29E?};+!cTow$Uv9Yitx@;gfAnULt|DL217NZ-|f-K3IBg8qAwPsvx|Y8yIQj_GLBAQMO7+!M za(cjO;khIpy1z>k2&H{*EG7_ z#=x9;`bE^uaqf0HirdfjzzWMQ>vh!Y3+Q5lJje&{Kb;R=ympS_CxaNeIie0sJ2w@Y zI>aXkejy%8_9>M%GHyO_rf?eg^4cFEG-3-}Epy4|w4w;wrgG&E5_1P}eb8F%Cuh)S zin5Fm7|Fb7E3<}DW|P`c$yg0f6j-n=|0j%q9V2tdgZNk9kskpF3*s$pFOeTRl47e2 zX7~sqgNvFMk5EHk7Hjw^U&=t8=37|OK~P0cTr)$xOtQz@xfeA*c*~fwSnH!nl8p`*HZ)g&{F~B zsYHT&|9JaSO!P!99>J?QZU0L0qOQF_q8S1NtB>~W%4^38V%4As3TsO?dM*j#?ckAI|2WYZsL&M|+!MV9-9zNyz5#&#^WxYjfj z349H94)()SaN;nB7>U^RN5V2{{Dc4S&~$mPxIf(QMB;jUki=0aSQpc}6WJEBk)zeC zG0}UB8I((Xc>_a<6{pTq7WIh2NIlquk9y;S45XudrGX z$93p2K2#k`$-dN#ztPA)EH5T!kfhNn&$vJsmmF1mBHc6`KpekVjz*BpRXq0O zx-F5oz-hE)0^YdPBD&y6fexgc@su6$tWZ(COd zx=pO})hHi*SvWm`n*l^bfaIeqT1hNlokE?wWLpE^rYe~6nW@GI0y_m!FtkZ`I2D1j z2HpX#%o?(mcjv<$5k$5lybJ>M>s2W7in2;#n0pl-BVV*b0w)Ckir9BoUWYX+OS)}k z`M6gb=@-If=APpZHo?IigV=FGbC2w_&a7`TEFi0}dm;CVw@%MZG%fTsE0=Hrf2g_c zYyMH>x+|hIi|baFB7@&|voJT=fJ5XE z1g&igKWr{-)Dm21u>Oj(CzcWrRiwcfj-tEpO~wu{$bUV*U{W`rax+WA6J8 zkUH(rAh-ia8>PYZbr`Cx#vJu?iR@U@D2KJGm@##sGd1YDL`iF&2JD)S9Si@D_Om zOf?}Kn&u%jZJa|1pNwQPObgO6@F!{H{iK5!(N)a|mC{1&hFt1>Q4Cy{Cmx_DN0b5= z$d)j;355O!9MFALqUuM!!OA1*vapSwldMe0UH)juTA?%w-ZlqlVazAzh5~8%Vh9{8 z?GyvuUhLwXU&JTDqSrzZzxQ_`!l-2TU6+%xxaZ74H~^o(W_E(}~jH*nWjHwh9 zyly@^WpAJYniV@q+dezhwyL6pUAdiQhyf5mI?9rd##S;HySwX(QtGjH1_Bh(?kEC} zapB^`e$9E`j@M*y0c!)rsWYSH!A6Lv^uEv&ixw|A3IovXqoyNY1`llwH454|u>P7- z&ydhEa__>4mv3k0hYlKu0~M@=kd7{M%x|SawHh=G;z&A6a-!`(_~1DJ8kV48B>aR}BOLjuj_=)PsWSo>Lmh=yc86L@@Pg(OENPN>Ob0FMSQf{zkW z{jwM9#Ijb86XVd}CF;sG_NMF#>#~6n%_!Mva1nnl4JzCtNtbcA^vvjBY)z-sE%`#{ z@U@Bp>1pyc!3qy731KMc1f%cPImt-Kje!<|L6eu>@{yoNzS>=ezfv0MBe|jrLrGr0 zTo{Etg@0Oj=SWzG>yHy5w!hG*rpzu}cA>IF*TMNdm^#NuciaSdW*D2iw8F*q3*5jjM`d%CBK0;VtR$x~254q;`- z{vH~JU`~{pWDMewZH!K027ze=p43Fz@Anz&^~MMsxykPU1s=CU5QsQv&TfbDO4>My z5qv6k5EA!7hl|7YI@cE;$r3-CRhEyhMGV+Q5Dy+pr-CSs4@mxiX%w&ECU}Y1>3l&O zS8Vw(D|cUC1(PrYk{e+`{Uf&l6bYCUt$SCEWgv!LBnj*IJXWUpg`SwP$_R8n(@TQz z-p%9ksiw4X+G9E$8zWM^2wg`6>G8T8*$%IW;7PqXS<2EZTwB z6$ZyfNeWJ!h$Y`KP0?C8f?ti@f(f?2EP(^DC_LR@$S)f~Fl#$MWirh*Ld@AFd#kS& zsYniQFx@C}8BVquvk!0)3<-&X8Y|Pt+7SnG>y`0)5(cp_M&OWk2D)q#w=o3;>YWH6 zW>;G2o$EvK0WG*vuQW`IHu>VlIV3vKTO^=XlT-%11TWh+#6n~%{SLlf450a!Iav4- zXyE*UMtRTb9EW@UXE?u{DMQ)Vp;BcsEh*FVU zl#xvT?b$a?TNc!bbR1Yh3O6tZHOgV(-Z6FSnw2flTs>dSs|m~j24-DVxC7O zb^j5_rPCKJH?l5&8n%rH!!o5tUwj|OdO;1^%^CYzQu44=wPBfhZRcNq*v!uy;yI7@ zE_m}(oXvVo5*39)62sPgv{_S%!AVqm>3N$!Sgo zOq2g!Ek2!hb%&wgZymLc#shUfgP%GNTK1Y#{z#E&mo&a zOCns~gRO7d$n_%Og z>!X<5)$?mh%gP_t!O{6`Lzc+pF%VWU`(lP@)ora%EVZYt$%DT7au!w128FX`O7FRK z(wTW6hvfQ}_A!Fwx@5MD_M-Lvs2*EUdzkJM)&=yu4)@R z*qgyHJtXijjh?~BFd2L4vosc2RRfMKnrMwnVdw#0q+7x2_dg$0| zc9C-}MZsTdB}LUWe}j~NHZ3ERe-OyuYJ?v}{4kxDiYE`JHRKX2*cbJIPyMl9{SE!y zPI%M?@z?k=FAJncXRcZ~VphM{juE`&cL^{J{WXgD)q)cG@~<8u@W z7hdu!JM0T&Z)wTqHL6i&_;I(Wv@@Hh7`g5$3sH9g;wlK&xjZ|U-i*)$b413lc4~mQvKh;!VP;&tegIAkD8EQeZ51(4s~NkGlq?^$*m`~f!j?EBJyOkbxVe|1W?I5YVN)8s}7Om`_T?VSQksFBy_dS6w@bC zo;Z`|D$!9Wmj5j^8ZEKlp-~3-M67umaW0e+9(zw+1(N6nUm{8>oAPALuL4X*e^!h} zw)$HF!o~D9^0aBGq89CNZyyv3)vAubC@iKmr+LM~72qjK_@I$dJce~7KrC9Qes#?)X8;&B2T?xjVp`9t< zx3ne+@uF=s$f`*D4NqoUX&iJeuh3WU&gL0MtCq5bNx!uPo_=|qu=D)#L&`er65b6% z=M^ngy4dXduxr~fGzyJ$X2W{R4n6Oh%o&0)K=KeBaJBrjT~94)Rtr*La_gsB6^a7M zr_!_;Vy=l_anBoK<1p#t+rS2S%Vl+eHz3dvl)lf=Vm&b(KCI$deMCo4k>ahML}k|{ z{AEe6+14jkzM*ZieZ$mLwORU0#H&b&9<0(x*1d~Bkn_2MEKbR|bJdBarn8w*aXC#M zB=p>UUW$$aqF1EgLq>>0)$-VxzIp3GJv_3>Jv}dC>aE)^2 zT*)D-id^0m?foQ2tIH7E=p&#aDJo4h{UmBW8@PsE*bU@fUGTL>M%r#dIGEJrN5iw@ znrcPDC*{9r^2Z|zR{XpsDBHf93fbQ(ttszZI`^f4sq)6COtw{41Iky({g8r?)1B6! zj>7JljIyb{TDN55$@}aHO_PsV1MCN{-j(UlUPY$ny4GC$sKPE=_binuOfM#cnjvOn zY=^!l^|MX)4xv5P70=tFNCYa;>?Km78u?ramm-a)2Bt{BHfKk(z3tIS+>ges zjsgr_Ma{CcK%@4BR^EOC{ikt)5orw?-QR7=bL09jJ zbkxLJQTn0IRN7J|@0JKY5(j#YEQg!P5G*Ek-@D`Uhw;`C+dpDebw8dV{`r*&!^-!# z6S6##Iq?pzx9O@S(SX>+s9%bAz17Q^Bt&Dj28l;cg|teK8mpR!n%CCf5TkiDJY7TA zeZ3#RBZa^-RTKGNp7OuDc>=bpe zRYT5|2#c4A(FppjrS6tQ0O7d0`WBG@=*x&TL(my5i(>tUhX3Z`+uP8L>kr;R;D>lu zpqvRdwc@qNYReV|Lh`+H&(N?oaL!R>|C=+yRSt;ucu65z-9Leo{uAW7+ejd5&4(#n{M7U5N9{}>RdVzfL_#1Chn%ezv=k#aQ zC3GK^yz{0VS_Sij&?^6wvdF=~P{Ll*?8|DvTeynVcbl&{Pqt`#SK-{FBcet8n0L0y~lGc_-#UqO@roB>(&FrYm z*XVbf1wyxEw0;B_F&pL36q*>;rPc80_ny-KVWDcNoHrpe#TuFO))HScFm$0B-}I=n z-6`rkizTV7An+!N5@~951za$DW>~Vr8=N{ai(8rR$JM3JDXJqgyRN$)w{8q z!Cmni*32-kFCQjk*E~vxz%cKn=aErxwKB@xtYn!-qmu;a2Qqwy2HiqvgXVp?eP$qC zwKCA*JCSx-Lq}_C(BLI#f`x3Yy;zkY-3l?r{f>^ectu1PRJ*=AVkC3sE;6udi#As@R}bFj%3I5{*XTkWM!|DVPLTD?+PRZsgs zIyEb=UbN%mPJJcs(I5fgvCepJk*c4Xa!b%Q&BCv0jOOeL8FeIVVs!!a)JntR7UyNF zN`gC!!f2t01{zpXCIXq=6Hs~$xc;2YPtv}}*@7hAZ+J9;imGnx5bgNTcv=iqwTb%9 zKup`XkEeQXuj3!AlMI-wBzyE47m>YUG;7)I0eLNyPG(=r5%yZRx zXOA1mZ$koE@yHS1y;L$pmv{z0eV{1;w(AhS4K; z(yNR7S#?vlSHB55zAj$YvsKeS={1Swt8j!XiGGf}CqZnrr2QaKY=g!hZ8D7$wd&IbgR(qgcts5f*O?Hd*RI48#vR9i(xHFG7zLZbHvTSkP!YHE7m|T^G6S-&P9^*W@JID0~^JTeTd>KNzj_WN4(9W z@-OhsS0(J2KKm7a@S?pV*?Z^c8HvXg3ox+H_VoCi^`}=$Wt(nAS=%_}fe5?XlV9C( z*28<^UkOJ>b8iPlm%O=OUW^}&IewKagEON1s!->70(E(Fb-z`7k4) zG_+~40VgFwcod2Eos||sq_Sm-`_8A44l<~&KL`y-#ELo!k^ye`N`QZrhUT&t&TIVBbi zuayI_(op=m$h*8a#}eB_*Y}75Bj#D&z%+w<)?a-z*m^< z;5JntMSpsHBd@!99Wn18oyyFI+JKwSk>1bXxVZ)lKrTF7o1s~cIa5@FG>)75D%$=T zNx@Hu)U2tgB(Y{QQJWV%0)32Kw0YHa*RU7sjWZxF^4Qj0Ur*XA?u>UfDg$PfrU0sK zv=BIeAzQn{F63>=Inv7$jzQ-%k6E$JJjJ+Sv3S&@>K$RR0{JyC4j!~U7$JN}LiMvc z$l_^*w#I`FK~Io?QnH*j@iCI^ouuuvO*xM*{pdg+9PL<-do?M)6a{w;zZtangRpvC z+IMNrr$TQgdrmEM)-+Au-|64G?yCKNXGwnsqa#pxF8%Fo(p@tvoMF_07ANu1&f&Dh zb2Q^QX&Ei-o(0BR@tCjqt%X*sH+2mR$w|Q-T24(#$>#YG%QZ*^-a5z&!6lJXlt?X? zH8e&^_u9pl2{@hz!?{WEEF1cNQoJH@Fy+)S0!xhj!jKG8mC$#V+sHgmEH@RxFN`R7 za5LsQfiM~h+K=z1y61$iTbEqnM^qlP4Gq#8FUH+z!}k?VC>c?ctz~_E=bLOT>ipYovpZZrMmoX?Np2IIKMkpHrLfcjutk@6yxMT9_oJF-qR@z}ump;fyEMWVBri1?Ca$(`c@D%^PuPxZaJ83igRVv88}2 z&*hmwZka=rTs1*SXb(o=G?A<38;2;*M*MJ9*G7bWo5L^>|3=|_3D&sk>!Z9?f^Rhd z#-wC42KHXp2j~j#=X85ooxWZlJ=T>|Y2EoP7w-5Ec`6E_emFS1$qGoh-bScpuY$++ zMOA8n8SjZ4B?Pffyg*chddckFYhswm0kpoo!?;HEh@ZA87#{)wDr0^G%lIbPVr%|m z@M)&8jiGF>t%tPLI~12ADe9%RA%;aGVVP~D4e;1Ai596|>h{;Gzj6QZhSZhT`cJ+n zDkzS-k%30S(bloU5#G|Z*7u86wVt2h&a1{#^y)NB9bEgbO!|TmPfEBm5X{zizqj^yJ>8dORydcYTGA~G8L0XRe)tR&;H%h zl==|xlELn3Lu3eMy?7=#`K_(8(a#1tvQFr&D$BNz1-Q$BJL`j!FlL?OR?L7O!Qmlj zRN)zWH|m!GuHxw_Tjg(J!S`|4(CNj>Gbdi;g?6yqtpx=rsbiOM{BDWb9}X&V1+ffo zV>SB%zFEk&x7qG{CkN10TdG&h8KXH+Gq}l*0z)x8b0si?xn?VQA{UaEeEpm6t7o?1 z)&f^+oXa80`)FyA@baf+5t+}e8+s-)wiJvvc=QfbwA!vY+6Z;Mn-Q35owkPNJd^Yt z2=QykUY9h-2z3DLYCoEhDZau!)OaE*nZ!Djd`GJ*M#9L5Q_dExep`{>kci&YyCRu`LBj@!yRW7$n0c|B z4Ci6(P{l2zr!eB*o2)eqJfDWi*wc}*;8QUdymg`B4U2Rsv2c~`>h=W(6F50)n7E=yp@hS%Z6!dLEdmRp>~2~4+fH|E!T;wmHoKunQXEmg%@Bbq1Ra~Wu&rRG6wlo zvK+17B$1%uwex6$4Qe%UOHU548tH6sXXo@|RVO(WmIz1Ph3 z)4MhFSW|=0n5-$X5l-)U_HfiFwAGABVyMsKkMDk3gf-y^QSq1Fwa>Q&!QgV{*r9`H zj4jls^_uBLkKb;!pIno5ULHP5NdujuA=*xejn}wsN$9Av!{ci^T6G>D;?%9aqv_Sq zS6InHb0ZRI)T+{W(JYtEQbd3~D2RU&y9Aj2Qt>mX{qT>H#=6OlB*usQdO)QI;IIiM;5jNQ{K6Q|9uzC|Zdj?~`cMGqd`Fqw5U)>*2hk>@<^O2u zOtJ$CD6Mt)#g8!o-B@jZ?aj{Ud#=v01E|Dvjd$f{qklZPFa6PuemzuIp_HZ|i$^ zxN1wE*+^*=^uQ#q?Bb*2Vj(IgxeBFAeH~U!K6z-jq-njCR;SP0U21!^;Hi%8S9-a& zeO5yuZJy;es%P^I@NH=d6OY}lc6NV`FdN_Pw<+{X0d^lHZWVl|8Ysje>{5L8d|KuA z{S-rof^TcC{DYqul#vW&RvEijDub>tah>S9=9x zxJHfbsCHV%NS-X?gT10fVbTMzPrOlGRDxAZHU9Ut?LFn4=Y3~6q$hpqTQuw@%zC%G zE$zX>9+yrsb6cp~DGzc+Ds)y*-1+5j>#k-)dC1lgX0AQ4F>DWw(@gt)#EjBINvwLk zy8B&cpA@(H4D|c6^DjZAE{!H%b%Rr!2$k07d(s2XZpd2QEl18Dr#GJK3&)` z(=QJe`Q+PlQQ8U8hr?o~{J!gYlT*qR;lZlHw>?=y8OiWgRmu!j|z@&!5OI}AVuwMW{5V&vC z#^}{ny`xy1bm|%y1~4miZfqv(ow)3F~f;oqj#k zLyys0^b=1Miy=@v-S#w--$JUJETK9o5_>S6E%$zoddhD0M{G3&J#{G#Q?`nym-x|Q zr7ePasRQLn=gWo{yTocbILRW&+vh zR@dYMESnm7uiD07+G4EFMcwk{^&+elQKrnA`t4xXigKCJ4K}p}7`LsvHE2tIL(=2A zVVkZrm-PlYoRT7O-F44o*T%yAepgGq{11k?6CQHbTFBU$Uvj%^`3?d+Z= z>)KJvfXya~Q4Q4C1xEpNGVL5aN6kz?iGDFUA1%g**x#Ci)nu<`pUqMvNblonjG65V zY@`6HVvU(s)NQsCg>@52^hIjq{cK6qyr);dS?|-h#`@3~1{@CP9#!R^?2nu3&ls!q z$XVzqm(G}xYiXd}oB=if2GpkxwD`ynIatb4?$90)<0dQhXh|Lh84v8;(Qd3&u9_v? znfE>&2p&v_OLVAb>MDXcI6{Vo3oy`&yu1 zW@``g7?pW@`PYv+gwqfRbXVn!t3eV6JJ@WnOV9cf^UJ7k6Gj^;Kp#xLx?XnvQJT&t zp}_TAk*J{T7>PpMnyx{R2S+~pNE>d;?^y5n{5Vxrc7_jb=LzD^6=Oo_cT-3q=X2|z1s-+SazFEVa5qm_;<+%hD=9-eyc+MUOu>NlDW_9u1 z%{FXtSjI;zwbgUy&WcFg5UdhwC6hczV;NdB4cH@Z0jc}87<`+ndIqSZ7oX#-7~bnm zUfzt_ztx>uRcet7W4^xPSpFvRH~4xL^CZvoT=oR6Sm3PiIpvU59AWfqqy--=7P7x*rl)ytmM-LFH+o5FcyZyr>>jHCp_9jj}QM1;g_dH`S)dNejMWbkhw{gM(fZ*KlT%DsC5eCVr z)%*GUE-k)SAqpU|J_)#Mncqs98~Et04>Z-`fyZc-}y5#vky62VVQM};m#7W#+ zf^3eetXMCxwIab#s{k!3s4mt4@dxi|pcjdPn?jnaa(yb7DM1YgTWV=Kd)I81U03^~ z<_haJH*Phq%QlDGwmSwzFxeFx=cz zGWpv0)%|NX=liJ}9c^f=XHs$Y7LR^ow0u&I7S8Q%*7Ko5G)KGBTvf`{z|~xPnN{rG zE_Nqng%+V5&s^@u`xeo}@s&S`yt}F&nTCc3llS*&?xkEKIk8{ls)j3Pb6nrP!NIoo z1qh}cb7zRW9fZEq7(B2hjpM2HerfF88I5?h9)Ib~r|Zhvp|TSEwESF-h1LTnFFHGv zvyMQeOK^{Zj=g&+aD4WIsnEamt_$@jcpqAj;ILYP(-E8%Q9m0_8`&Qru>w5aXOl!^ zP9+nXF3J_a@QZ`opt=C zPuAx)neNM7UDrdC!Fv*LgTexIZmwAo1ze8H!W zYDyhfa^J$|c}MFsMNyGkT)`GMKbVRCXHuhtZbpX$(*QWAgz6-bBtp`A{G(^vw_hPU zk_CsF(qa~^dfk2Znk?nW_L>70Q1;>B%j8(%aFa<}Q;^S=@=|25a zaACx&cXJfYNN9J%yEfs*6#Fm~XHxBo_=Kcfr;?7p&KhW5?mgB%M7?_Ka7dA~9f``A zoepi@1A`ESL;aj@Fc9H8Y$fGOA{^1^z3IPOxm^g;R6hex^%>JNW-@Xu)b6 zk@yWsp`9pY9bt+g5#{0aN08zhj?tAcVgtU~S=&96HoX+OM+-t+U8JR%_y1@et`c~a zbq6)qEV7Cd$-*-!oqIN!7vt7Yg|5@AQpcqX7=i40pX!-&)89I8D9p;tZm6M>IqMi5 z4X+yLlNe8-;$`D@n717X$^O(p9iL?M9W=|{az)Y6VYD3#((X%BjuRzWj7GFt5#nXi zj8PWOy7~q;nJ%7#GNuaQUoN-@Px3y_2BKmVH{SA=z1LYEW{|pwXw@xSyEL#LP%QZ} zT5Pu?l;QJ^M86I{*AWTHBO^yMQLKp2+9zU|Pjf39Dp~FK9?aCX#O|zInEK7iAI3W!wg< z&J$h^Dg&e~hzvuh)(z02N+ps6*>2BipaubD`8Zov^n=vCe>>!-{;sPt33tF=<*gf0 zXL;ayhopr1>p&;7Fs|lB;?=RAonR9d9JgTYw8KK$85b75^Ci4XeK3kwbh~+Zq7RA7h!EjG~nyDx+BhP zitYVC{@LoU<#c3LNxo8zs*ob!FZO8J1DAX5h>68(vR6Bhul}^WPJ-YwTb~kySe9Gc zJ&J&2MAFHYp*e3$Z^|W%YtrJQrr&-OQyw$hud8Vq#XDXZs6Q|URb8|yly^_!)@Y!p z`1x6%HCRW*2p3tTrFh&Cm<^idb`XL~Qal<~oIABQR-T7BKifkxrdJ5v##_##kO_Y(S zhI^073#GQnUAL@DXgSF4%IPhZ#|qI*VXHlTX7Ox4w$N?(Xg>o!hXR<8m9of7ODN-f z)n0dvr^~Q>wH$A z-ssh@7N=)68GYDWpNv zZNob6+L-S%dfmQ$G6V7BcNo;qsx=H5$j)X@r5afWB%vp5dQDDQM*{yjna|l=Uwe#^ zgi?0QSA!;_mat~8_q#=q$+rm@(RH_Vy)$49n=WKay;MY*CS5#R?Y(RVV3kN$R@tZ5 ztgH@cYC!Hr8M8%2(RDKvm{1)@o#rZIsfXT3CaaM!qe2rfDX(`+fmG5AO~!5Q*2-&+ zlW@-sovK@xXUHUdg1S64{K`NNS!BH~1@sUU*edQavhCRx2*iX-XXxakRSm>-yM3p< zaup8J8`6duf8G1up*U2*x9aS!7a@feJ8oEO7>%nKB#}n2e}J0lnpC`?5t~vGqyVo3Wd;7$x$2|M(Q+`(SRz*OLIhC;%i|{!WX}NjewrR^3+qsG z=^|;NQ&ijG>zYkAxiGjcx&J%j>#Cy%nW}I`oBdHkP92#pbK>deDwA?JQ^B8f)^9f- z>O6;_!}&DKnJse2lXQKR;h`Ofe~@S6zS;X7w&_3aErK+I3|IGqt!tun)8?rDG^ z%T^W|k%!U;uBPmn;id+uT6LqTUaUmVcU*s9XFe&kHfC@1ATpILnC1I98sP&x<`!D_ zmV|qLi#M5>?8SG1KM_sv7NZi}Z)^AN^P_0R-`-qz-e6ksgR`zlKEMoYrJQ(sW&dxv z16jn~)K7)Co#+q*TYMp1nXOlRGh=$QrEe?^V~Fu-hDVzbdq4dJnu*fkSPa|$oOf3^ zZqNc!{C z9Dly`EKJ;hX&zlr57Aa@QBhCYy~rF0NoNK{CTsE)I;&wF(N1wAhppDpw29i0!$5v7LPW|b^E{0^?r~-0o7JyF>E!2b zV->4ArY;bXw|WI>o7G6F6&XrZ8G?8q9RaoIX`5qwwlBF;`PukB0)MjAHWaMl+2dQ6 zyeJOa`o(J25%0~DGX6PwYUtxOp{ia-X)~!RSZC;s_h{+B+&Hmc97rtSGcSSyX)%4@&cjwc~0)h)O_2evIUc_EIaW6Vm-%9^Ni-j#L8B!0y}1Jhiu z9fbAyv4(Ld&i@?}{U4PA&dmg0>ds>N67QDuoU^{shdx{w<9aWTI;Ek*Cp^sbLF{|E zl`#)N_fQS$`ukHUJZan||lRcP%$}k^GolF6v&l@(E+mF%b!$O_r*8jTp(dT|K-B zJf2^N{M37dMAPkwjP-6!!i09mM(zI`qxyTf?D&V?NTb>a{=L9+4o@q_VXMgZFOgwS zfjpW@C1y8y(onKjouqH}90@sovJ0(g;vTQ0LD*f@=@L>W7Z>@{-EcHPyNGl zzB3XOK_pUhj;h8dW8q%pFM__PN{1_|LSCKm{srgqxEpi45V&V`pW?eS?8>((x5D}- z$hK}P7f1Een`fJ)F>+pQ%mHz<$GUus=6YW5_L~e!;+-nogTN!saI^Tis+RONPN1Cv z_iX=ObH@`B6z#ERps(Ip*ug!MHC)yB82bufK}3}`$C4@x=85~Xt>Hjw2ohhf)T}2Y zatPn5+vsk+wPS~E^67c;k26vi3!OAR*4M|Y&tG(Zf(x2_l{Pg}dukZ1`SUsf2r0 z6oWO5)0Ee5)C%C1BXly_N10!_MCc;0%HypVTH!Y)AFJ_W)viSkG1E!K1E0S)h7Wb) zE3sV){nh6(&z4j?RLZ6`hC?yj7H<30<9|;?(Gz(AX+)KOP;TKAo;YbOH31VWNyPjH z&DR$22PrCA>|0@m&_E|e1yz3gdb4Sl1qf_3+BP7JLvF3%`znVt$$?oSF^+1wx|g8) zDAQ=@T6t+>#vYhh)~!qgRw?9OAN}|J8LeNh-DIZK)kFj_i(CC>>)B}6TtR{9nS9VtA1sb{Z?Y10Dy)e%?MRu|K6)@rJ( zRrC}s#r7n;h_ZL9Dp#^TCjU!kU9+=HCj3HD6C#7GVNzmZj@YX1R;@-ceG`0(WqX=@ z_CQBgTGE2ous$o3J$M)H@g^!f)8t)uLi<_-C{)d)-?&dF7u>F@3H#4ZFEA^5p`XFM|YJ`nQwmtaV(aW%wfTB$|v(Gth;-j^+4i?gakjHEfaE5FAe|2v6CYCXOLA z$UUD}y(Y)}iJv+GMyu!d=8oiR$CIw$fbnEC3DdyYd{tTS{UIsu1QnKhWBo#;zloA* zQFGi@8b4TOOOQVv=@G+ZEEe@*P}L;^xHDa^pWUZ+$Y{6HUqn-BHz`!AOXx^I%q`XG z-5POf-5D~r0Y0eM}yaIyeGm|UUq@%~R#21cOy)KTxbIV8o_R@W=s7?DR3c@PWBv0)Q~pSS*hG{#FLSGimK5w1f6_^xy4zIHS<>$DUR0_CNv7$`^$mZ@PYmsNWJjk!n2quK zgDR<|ktbD{i0;~|Ri3$9#eQ9`yPEJ3&hUV_IeT&eP6?=HqTR==xL0^14>`Ka)c`1t zuOzN&ed@oK7w7nfEAwmv8)98kS^u!%^zE|(3IjrD_M+&%)J{?y>OhWec76L30dZFT zzvJQzQB&}5n;~x=Zv??ad~KtCjwl{m0_XO(8Vjukk8fUo3ux@drTdP~jyU104t}=K z-3S}7f3{iw>wEpe7q{xUxl-~Y)u9ZvxXC>oF?;uww9G1Ugx*KMgO@%xskk!b72^&(I3^E1DG9QKF_0Cp06mmV?5tU02;I)q4Gm1 zjLuk(r8pk7ZLYjgUx_Id2I1z|_nPczg>bD=Ud4*_f*ZFT}4asU?IFQ#q8!PK#6^n8E9t}YGfwW zIiAoq_c}RQ_wn=Y!IelC|Gl%N`m+h&BW=BE5lpa6_4MtN>JLl`v!@na?^?BgQ%HEX zsp0m~c3Y}j7alTi{i8HtMvuJ3DubrN<=ESumbT_cqxN_)}? zauhDnuf4BoiRc2k`9@>Ko+IlpgWL_a;Hnqp=^D!sx?5biGO132aCL#Q3CO_C(QHal{s4K%bS&-=e1i_^o0trXukaabFSm^M44F5BrpNUs-Urk~o|RRsiW`8O_EQm*T*F@PK|%6;O^ zmPs*Ef%?pdUdpV;6z&v9g1g>^aZzmCKV;LI=$dcNZMCy!KOKSsPwTqDA8dj%_5%s1 ziS=%R!V&Qu<#s};Y9^>g(Rsc{mfrjeajnjX%9`Bp-O$6<3%0Jh(VmT91qTMX1hxvd za<0QhY+tl;i$s_~7qWrXbVZNV9r4x(JFn>H2`dd;On9Fz%>gFQb#b0Wm%XPy;h$dR z=KJ;)uDX`f^3!jfRq@L#_g)F4t6uj(LpIZCu1B|F4w%{KMYZI|RXtDZ=WCoPhki1a^n`zAf;_Nev0VVysyDqg?)`XL_FaK`oaThUk|R0;}M z_JPSO9l7c&B?7G<^`W7EdQrD0$sra}q47mq0z(pa;ht@6ov42axdgaDpf=9?^JO^M z{~z@{lwlwD1{qPEK>2puM$@Tz&bPU1PQnH_BD%oxRLWqZIq`k15ALWk*uZCpTl}mo zC2AAUm_Vbqs?9T+f*cWy@iq-~aEPIReyvDT7lJVk7Xtxd^XwS&h>TSNdc-&J7WX`pBF2(2xv|Aq4xKb(E4Ef+T{3(^G# zz}9qU>zE@-6rp|fjma6SVFWd2GwPmZF6&%SuCCIirwe$L2MNA^T!X4XIL#b53TwPA zEV>OL{?)u%J`~FLPy3Xpt$N-`Udhs``DjQ(ipOERlVyiZrG>viR zITUL*84yy!p?q4Ez4k9GY2uX*px?2<~e9Bs;jEw1A`!Qu6T29u;aAR@)Fs?{Ge zcIY185nh&n5(8Nr5}eTle$NPp0}o-pDZ5R zZ*0o~{wfKBUm_WVuqFFb=W?va9P<_wzYUvT?nrXejg!unFnx(q;4>wq+uxSh3m$CA zv!jsJ7h$R^Y*Rz6X$j>R*s;i5(c+fk?WG5_!PPi4D~M69(@4?Mbdok9=l;bw0W4N= zPl#K%)j)~$6?i{h{tQlRT3J^|&uxU<3)2gG^{!t&QaT?pH~l_ac=H9F?h$~TZ>(H*ZYTzWs+gn;AvtSEx{!aU$-n5=edi z1^M)zzmvUw{D+S^6l`WzRZxsRuZ_yPZAEVMv)IbCf#w$zyD1?k)p$J_GPlvZoFn(t zmXOT)V<(yqp_t!${U!DKy`p`6ta8U3t-YeZU9SoINHv+Qi)7~7!6dn_8e#e(FCpxZ zrJa@6s8J2j=4lKhtcnXuMif;GI*@>1dW^|+whTl{@)YE6GND>lSu|>2$gohedaBG= z%#(w0*3(%H3$Okph`JnRt5T5L=YDN57Rj;j@*Cq<_zrQbcfo#QtGnz|?Q`<89f7dd zRNVFPNc&`#yQ(lJClp>^$vU%XKmH2834&^2FVRA$SIF2I=xXXI1pd z+J)qoCH^+3rD~p~P2P5mWz^dqo(iQ(+bcMq20``ZMsMly9#!z94_w`fD@`*pQzl&V zXwRzY6qMuH)|QFIUZn{3{?$~so*Aq4Vf^$qD@dF+xIOQ0bW(nz$(oyhg!KlsL@$vX z#8m+{_8skf103DIjY`WISdWwg5-hU~0^jV8MhdgN^y-|WiWF`ijOU6DeN1U+`Bp{H zN(DF)QBSU`QB^%eInl0qs`QDJH_<3mA0Z#8VK%6Ln_;p4y5BCOEy zk0?diCJL5kiwNVVmL26lAb!Jf7XYQ4c`xrRi*cop#DO7-ZT$U${TY+Oh4dFc0= zK+59EL`}$rw5p~A=gC3oNjy%489i=Z0-E2*nQyy?M=}fpujaQBKz5S*2kKYt`BsTy zf`~Y*8}6P!%l4~9A`7m|TV{*Zof7;vRXnoXk)4ZwTF(XmJ8uwc+gX*OJ4mEY`Gyi} zCx^GtI#Mxd{#0u|wluH(h1xF)nTs|-?R!WWL1DG?Y5oG9d%*JOp%=!1=XJ_34 zm|JFW@UrcYHAbN z<(n|N0K%`fl6!!OsH2suZPp;cXk?DDi5ze9>u>?CEUM1(P3`L=yfy7C%1)5Rhwyw6 z7T$kI9)My6Yzm>it32#rwu^4%q-U$gyydh9n-9@Cu>4qug@~?#HoKN#7L7)3?8mnv zr$rIN-x`7LUol>XU@t$P5oM^MRYCtq1I^cEI@?0BgxI50<0Dl4?rBpSzn-}8wr4z2 zNAPr?jR6-d$a*v(^l0vC;VNqK%O#-YtELcCR^r1$;!zzI52)wQax(8`VoFV=Jmj-z>gU&4PlpjX)W*-*vTOe)5z1m3%ToEC+da^{VzZu4C@joJ9)x_WBN z>Z47*zi7$lt+dkBvaEA+55cor_qK4=^O5fj+#J6F0jU10vs9W4>b-#>wp_1VG#*^@eWkOkDj zX$0{zpwZqI-f4!(FAV!TDj^QKM(8l!>2G=Aw;mTzfKL^JcGKOMg%YY@H$N>_;iS3R zlEb-GpW-^BQNC=q6WLKa5Mcr9OUMSGYCb-}g>8juU>r@a1yAV0AM#xr#(I5NNrV)Q zRYyZM(SL55KBy2m<|EHvH``mR=c;L=l^Sv#$SU;m%{&ebB zoxhnmDd9C;N|LjfUC2^} z2d&rpX~AP8CQj|hrAO29#4)e;m`a_l)Srn(p%xL_+p>3)=R2QNk3|xjW&aa%_1kiY zGiGtqm`a^bUE1crU?%dWo|WMS?iFz4Yj!-VZ$7qZkQwx{`i61@t^+)IhqFgg1-%Oy zzfPkzejkZypb^^|k3n>pZanhsI+1?kZZkSI&#a}pa9iYx&4{56ht_q%`+P<3_tdh^ zTKY_hZa3Q=RWt=_%7Fq<9@WUUNxWXbubMx-jr=fSj5JO)!3#Xvy2^fshx|klVtia% zMy6Hc@vR?_9Z#vA)w|62gX;IFaO<{5j)SIA@(Jupm7^;)O@GA@9Nk5p?DMDul+mrr zxrCS{CRn&dtp_(5A<19T_SK+(DI+Ah6ey^2XUy%xz)&n7Es14a1nUUPX23h%;^?S! zApX|(bTsTQ@I7apJNE9qJqL}v-w(*JwN^wmH#sQrA?vc=By+uCJZ@-XzaNjtrSdI@ zDBmkwt@CLJ*WH5G`*E(<^F>-+C+SzRZ)b=eM+F0ZUZ@dBnL>3o?Y~B4Qa_$ovlC{K zY+`zcB2%MH2=(l1QEOC>)mOh8|4Pd`j`AK#YbRO-M|eA}T7b)$_QgW6W{8+$@gg#N zkGcVWu=t(q6xYXHDK`_R$wy}_z?Xz0%+bTG! zf1QdXQo5U%(`Nle*li+TB+lhV1TbO|Qt?gliUW}}D9rNA) zN0_sPXMI%Lke7cw>38<_p6ja}A%WkJ`v$I3?IrjWzU;#x)1tM_|CObj%#EzL{!>i9 ztd!5Iu3*ULS>6AEGu8Od5!Z0ypvr@NWqG?r^F_m6 zE0!br@A+oVf41?BKG;S%X#1LQqT(|XjB^Ny=HH+7`H!#f&XO-NkH06i9|YmHtc~Hk zQ3ef$JzOUX^+)1A-`^Eb}#XA7^TL5;X(`*jgc#*3|I0-K>>U@`&_j1XNn{PJL1Wc^sJy+PZoJ3=>8C{hLai6_KQqMDEbe*7I`gncKW* zFn6I&^OH&e+mlxQ)RV)0@R7`-)xDZN?hg0R=kd;2`R0lc^ zCC0yhuARvTj@-;k5y$XZZbY477{-+>nGM>`@Oeu6cElunOh6hA@k$}O(93DZd{p--7O#%ZV20|-4SdyZ5efAi{T@2#KumK1Od4wb$}n&^HnuJYLspk( zIc?~V_FGAxV4v2bRpACeya9+_X0HXXt>($-x_XIwRJ+O^4cmHlLoce=?jDESH1b<# z*;{4?rbO~|I33UXY`?F%IN`8dSUo&j@5YBA+Ph3#N3d_QRi5C0TIAUmaFvYLyD7dB z@{X*6=Fq^fczev-G&OL z*44>4W}ScJMK@fjT%yU!@-kXC=nfG*%I+!9-|tHFy3c2m z7}4ur6q2I~{RbXf(r(KL`{xe50-Hb;VfAmAR&TNBHJ4c1k6;g4j2bSqd?V&ML*t!` z_bsRq2^c1S2Ds~MeCvktu?psu3%9g^=an($U9fvc8yq4MJYZBrQCCfbTJ`snS?KLd zt<%sDf0*ng!M+XN<4fWK45Q(#GV>!&5o#v^)&0f=;2n3&5l@q|9ojBim&?DpCuSci zD0lt(vu87EM#J!m@HpP9{!`f4^_y}|nJeGTr$-!tpmYA-2Y+lIKh(^Bi}=$T}ysSzRx!E(!>BXZRu>ye(Ij>^xRFvyr3 z?y4uf*f7<+cbyUMw~8h}zH(rQUb$=Ixh0kO#gM$>dYuix z;+mu%vvsUz8Iq#S3~fZcPbB=Aa6VC9HhRnPe6wPYWELH@EL~Ss&h^zmAp2F)$sK^~ z8TM!sl3Mqgia(@aRrG(bMI`^qZ{24}Tp;1Lf%oh^8Rq$Mb})%wqZ^Z_k)w1jWuofT^Er76V|G-#;H zw2QXsJ`<^x&2^hcqHReSIp-f+$>D}p*Cr(Ss+9r0RG)K8yRh2rwfz{!@k8FNC{$(RY`N~m8_A|yPke{pX&*7+mwv>xX$J(SpUwTnC=t|?Q|mO=e!o0GPbU1 zAZJL3@;A3>%t<5%BltK#%hoXv`Ec7I6RTETRL)^U5KK(D>4osHwEC^irbE6H-K-|t z+hm0M4&`ZefG*{_-AS788D*x~TYVA9lnoQd7hGLEE4zI~Mg;p_=Vm@BK~OLUcaN@G z4@Bx-pVMf|opqqnO6{RgmC@L`oIE#&oS5@$3XX5s5pSZ$tPt! zK;o%?~|gm(ID$A*(s;Yn>+@d%3mT2pc-%DuM73-ieFu{YXx$grfH^e>pggze0CjR#}QJs zn2&Ztb5+c^eNJM(YUuD1@LrmFR8g-?ig$(uvd+QMh${?9q{0AflQgz$@933~?Ml#g z>6)TkYOVS%zz!Z+TgA)VKFdfm-=uh&h_tYllgyps|9tNDVcApPu`?f>H}#S|@l0vX zG$MEN&L|t;E4BTeaAt6*JyrO<2kXJ#|yzX)1AluF3 zt=TAv!?NWSExA|g6sL(An5Bd@qry*;5h*Zyj2?Wnej*eG)92RWS{u=X_Yxwex z9*MiJsxji2betV7D{O1`8?z$Lj#`cf^@*40sb{z*>Pv`KOT7)_`20wup5!ss0#Tui zUCKFhG$E`3uXQ>V0E}?UAKZd*Pe|3?>Psf3GiwI6iKa;O8Xjyxhfu_p<_^+G;~z2P z*<+Vxj6qL|@d~b9UjkW1lwNy`5?gUWzld^i2|UjRH!*I&smW@Tlk7)N>D-{us5=UNyW<%dQCh zxVoFYLaI9qu13Njy*^Q14L567O8y=Whqm%)nOvdI?dfOMaeg!$aEz{;mOvM^cu)eP84A@=XC5miugWC{13BYzC0A_aJ?Ry2A zVBe0tuy;tJBZ<_SDz9n76J}$}D@n8oi4UVt`kri@pE}^belqRXomuz%_A2i>dO#Sd z!0LMOQRn38k==N<(FA;Pk}ph)@1~l4=@))52Zi0_WC6V5C}`Xs;DhxJg=>DaoA`>$ zTyXten4dqNmDKC?6~SlJ$cxJp`$EOAGt}}vamjKVGIP7zg!j(TamyzyV|Hb|JukZB zXbqh29cMC_qOG}(cj*t$CzvWTiB%jWOltjTZ_^$VtQ;hd*BAZQ(HSSq;5PwkK^Syl z7#Qi*&z_a;y0BewrB&uNd&3hz=5K?eK`nA=`q)RkZVA6ZT8)TVf*_wb>HnXx`>jFt4Rko z31_Dw$?$5_T=KxJ9qNz$Lk)q|Ad@SS7r!(GLFq z0$=?J^QCS%pSC2Q_MCK>SVK+!r$~B}K7Z}cZ2ntoQXT_s>9KeH*x{GSr)9rU`Bs_# zo-j9e12!?QS?a%MRkeI#hN%cMxZW#HV$XUL6C2Eb+goIbTlaJUKb4?)evDE5pbdM~ za33_w=ZH^a)E{`zJZ+d*TciiA3kFVsrrI(Mtr@gNhWa`}@LHVld<8?2jmT)46LSU+ z&l$3B+h&2IdTO)}En<38FwirLEUA?ae1%}VH*>9~OFDwke4~n-H4@$6^$E$1l7Fj} zZu}oy;i=-b#`}>%c->hU(-jxPFwP6{_xqHDp6@l~to)JTy)}puV=xAJIJYuTc)xit zbA9N}HA~b)S_2pTcEw=sfi{_FKHDg?QBYXSv==>b=k4$6Kw<#`-i9hoF58?095sdIpzDB&wmv@ExFb0$dVdK-M>SJ` zfG6dJd;4rINTck?8Gv7(x@Z*+&U!6F6Pl^UZ`zeP0X#!jjR#w`-D-kvInzWBwN)m< zT2B`v_0wUdUswlWNIcE|`p3sVaXP%75}u4XI#YB{S{|v>pcXwU>}CePWQz>oR_m#h z2=?X1?SF*1WXKA?bJx2Gp4=Zp)dw?cN1HkADiS_TeL;e$?oHyuSJU`b^9sG32F&uZ z_kXun?%fg$d)BxmXx7973bU=_;XC0p@fPfFmBX#ejvn4cYuwhhw6vTzM3z-cTM(1l zi)@+XM-{ZedhZq9_#vJ@kmMdqqbUz*9S>iy0PuJF(*wff{~d~a=I^ToFRWH~RTf9G z91Sj-p3=3*^EJ+zLjRM0{6UX{La6Z&k zh(U+)kGzA!q-ya=gX83kQmRxf55HT?8Z07L<6LnTC6{!Y^XTOMn{Q2GlPUdPQ556O zC}YStt{WOy$XfYjL%)f|Q_;40g_Y>4cu_sqMo>^i(dRI)$lMcuLYp&S+X&C-A|)9CCNqu>%C|bG03+a^-Y1yAlY1V@`vv;)oslwI%5|lyoW5T zmO2)&h&ib0W{Yb*g%zDNU2b`N>$8{u&L|LEEPH#ShPAt? z$9C6kO@@C*(!1T5O%y?%c;xK->VcX%kB5(iL80)Bsv)r~u{D7%I7eG;X#+EQwuHPI zt!;j;E!E_9^w*g|EmjuB@+Q_c#@mv5L6A{;1@4(Wp}OmcDx1yQr)|1;A=FnB+MyfE zy6#CFS^cTVt;RyAY#FEMR8#-06jp$zjrrj9qXyb+r+MU2{Dpyri_b|p(0#QHkXHA zZ$9m9GkUSri1ELznY8MJCciEblLB(c&IA*H5^?E%!(*XQwdvSSfj|VdYn)7I|9U?DoDXz1tt;I1b z8HxlSwK-a)L!Q{$|8pqi6`3$xyBEN9Y4&x7c!o4j%E=XL%&udmUFKF-iCgi+v zzp+TyUkYhX;qvbX=l*98XM8ulVioHB$(CpGOD#|jl<)r(E}(}2pJ_bQH?|q_HdBYu zvT0r(5|j74ZJ2->^#_(Yf|bibjo^k_r)he+j8HX2C>kjKQKM9LhepdQ6^ zHNtc^)WL;l6v$-VxP-T>?NccW_qg`%yJDe*&ks?c0a(^kR4?x;ThHzKYpVo=_{bE% zf=9GT{SFM6+F7X)qPy=aN9o_#I#WYPAa?9GZRR&?5Y;(Xv~QHlZUF{ z3B1ybzPNI4^?@UhpXtG`=QkVUn<6stP!O-HvJI$rZUw%`)sCO@kMKN4o#3`(YZ)%L znTDVX==Qn}Lp%UY0Z6YK6S!gPU5Tyxxi_YYeMKg~PO2ugrwyG*xqMHv;)a1G6273B zKU?;Hy=&wIro+&QOa%q;A@mDvwW5ZSzLzuxK2NM3khC{?9-y~!?JFF%1% zV^)krtX;_tuNo^?iMnv-A}XC4`+1 z?ChxL5Dd$Vm~HyKt1gf?@-yLX6`UU#`-pv4Hs$P};X3El3%GlgthXLw<}JVJd^>6k z*$!@tNVi%jw*15A3+<@EvU)Y4Y{8ivRhVbMb%X|s3R)?1BUnW+C9C`T7aQxEcXQg; z7gJGfhjkMrMa^Ep9sb8i4~o1|QjD1x-PX&EI#JD4`q!xBuCvy1f;}mBXjMi%r6{sa zb)xC=T_d|>2(f$U_?{NbPJ#Z7`{*<7nn`=b1Vy5>?`@xt_wWQ&Ro&_+n@^~BZT|6X zG5tEmm%hWjXR5L3?ML{uTl;bW65yDRE1InemExQueJ8V?<@h_3UPg}g1VvmYcI!;G zbg`GaYKSM<{iU!Vp>6^c%UY!28PZ8Pq8!Kq8idJR)zWijf3h*muG^_;uGN-TYUy_` zqrRDdRI^X8u}apUW}OM8dgt2$NhR@tAnbAMvA0&RceGaHO9#IE!-4yM@wl(<@-y3sd#usk46)Q(OEgtIf zXnF1yC}S_Z>f9}vGBpf*mwsXpsBD3ZsRrQNdz0YEbGD%tmE8W-t%UV$y}kM?52Nx_ z8fz14Pf<0g@@9M3Np!xy+~Z!EmI|?Tz)6kq_q4IqdS!hb+6zeb%2YyIP(Y&0oISTa zN6IS2tnVujm-XSwz=Aq;9(7wBs)7+S*H0UzY=}B>)T2-L0#mgsG)!sT;8}B6Eg;yR zcwtY2Tqx8uhzy!l@8N$|0cu2bm4^28her5hh`UvgZ@~A0R0YLbZ^-e1TxTFTCD!zS zm&5Uf@g(o5dVF3$`&vxn@?6pZjKr%yzth|DDTCu_Fy=SoZsxW#>mO&4E*0PL`Bp-^N|#s*XZnt}3X%}T8h!dYlx3#f*?Z^@sn zVH&GYG=e=+>0cGX%QKZ74 zyP8*Lvo~zivOSI=g|n}&HpzaQv6LoeXOv zt{O2%h6qb|O7l8excwbs2Y(vz9)t*ko)>nl3iRUo&j7@pilaa~e{!zF{3Ch3vpGivDi9#2Rz?~j@%u^%6> zpJ@30-M)PvRJLcb<=EXj4PG*#K`@%l+r2q=MCx$!XNye=3ub*%nx(B$HZ2G)t9wD$ zZ#nIg)AnZj_l$FI8B@ZGk5Kaox7)W1aCD2H+-cTZs%vuiaL#YC)f%iWR5v-q>a)Gi z^M-~CVXi+`3AJKYx}ASL4W7VjB%{vFTwaq4^BZ;Gdi%|2XLF02T_5tb|KBv}Q{@iF zY{hugeFY~ad4wSL9DCM^YA%zSobnc4=ls0cIup|e`1 zbvbgdSGjEmP!_EZ?+|9Hq?Pk(Ox1O`duQT{YT_n`^q$$S<tP&OjN9LU^60;Z1 zT&956wH8r|%>b8`9M9A0-B(5jl^}m%-8^GqozxmylQQLb&iKb(-@}+yhZF9Z&&mhZ zQdr~_KT&yHZ_AS#^dvk0hhVDueERmdFyo^*I=`5b`}pwxJx5E;rn8$`_S)O+jKS1J z6TGr|DK9_M@ujTX&v|rxA&{zyMd#3wr#iP06f7ZWd!EtWh)dbO%LZ*dId^2<(NlFo z^?H-U;96Ua-VHe*@}L`OpH(AsU#k@=(b zQ4eB^pn#@{TBy+{i~=PkC5LnWLoszh2pRSsjbM+83eiA9KnB=mh1SMTs=U=rxA+H3 zwj+{!p896Q)!Mzg(EZTdhY zYDf$SiSwwH4xp-;Qc#RNU(>bD9~^o5-q1qty1kQg0)4z!KNN@8b;WT#@3ue3^spUBHp2Eyr_^q|hx`MfJ-e|*G0)dbuJ7XSz_>Y4`5}lQqV*}d87vvO2NF35B?s{83 zBKEd7e|25&MEs8{xkG(6*W4v$mv$)fz)}nc05~C!sI<4%9tv(19JhYA)|PU!-RR}( zmZo8-UYyB{dj%oDAJS@;D_fK=Md)nz^piNbAf)bQKhuUG$gPSM<+Z`3OeI~&A(Ykq zcQ#(@Jv`0^m)a7>VnV2Y?P~keixbS)E>tj*b9v&oqkq0RmYB52v;v1%*~V&m6dKW_ZG zyag4QQ4y@S;%R2g@kM2r!{tqDEo+ddhk_aDxA71nWgs0Br+D_9F%eS+K&0Qz1|sO; zG+>TzvVun}z+pBxn<`x_MWMF^P-VZbR=J+p&-tlN?z_swlN$LR-~C^`-0`Em4z>Mg zhI_pgU(%^JXzoS+2<{Hn_4jH_q$8IKLR-DkluoiWTcPP_P^8ygBWcz{ z4LC$2_%MrN?Sq&NZ}DD?FkkcbP9WSXqt@BbZv>V^K#>y_p~(?n^8Dh8-(Jp|+gMQ0 zji38bfo15B7FvQIq|An2BXx<<4<5Njaau3^OuO$G__{ena4jX?ru8@!NoOhsvIj-`CK9 zRXl~!y`G>dnhX{o35{`W)K5!*@vTh0esc%%6W<&$r1uijS_&g2;1)Gdw%5%@OR8>8 zom}ra+0mukSI{5tbH5$WdJzS#=gXe$57QOnTs$uIG_;te{k9VLW5A|t=e{7IoTWsf z=CsJT8Qs>uq`XUl)i8F04IdQ>fXa9gRgUh7a5O#S5mtVtjg~SKW|6t)(4QmpG*U>W4Mc0g_ zb%;VQ3^TgM-hNno-WN*ol{A?(l3T1GTJ!ea!TDRLBNTzl2 zRvszgmRAUvW*pTM;K+ZnV90E2Z#J)6bR=uUF0ejvR9E^$-DU?E@{gKfim6zdQbh_Ge=JB!GstD87$>juF_1n>g%m%^XkGkr9 z?_|D*?#ZwOp=7Ty=a9q%xTg9~OCns)@_l}`86viF&e8SqkG=UY$}f9<&KxmqG=vj44a3{7KZ0fQttgrp4?SVI0&qiJ59EpIk_qr0W+-x~Q z;K0kxy-FK`jfHt0&*RmSQH+lt;B<@p8p!(QdE0GzN)~W>Z4lN81vMIjjGI+f+Bz*L zu&QvFJztW;BN4n!dcNQbM4JTbwLu+D_LI@a-Ws-unxc3z)s+k1!l!bO@rd>L;udPA z)-_K3*^E6(A|a&{!>STlFTd5xNqatl0#-r&ac0zK&(rX=uKUc?SLwd$irVB%A|U9~ z`+l3{HE`td8TL@hkDieZ_CtMVyj2g`1L)oHP2kkl;$G1$h%~olQ!Za-gyj61v@$t! z;!A4rX;@RS|=ac$dzI+`4>;;6`?bW#C z9JQ3+LqI)Srjw8Hh<}e3y5IlwK9uY6nl}46fisth*LZKglWdLk+9jxa_3ktuXC-+g>E{mBS7uC&k}(@ufBeOpWFlzq#A@9ocOrR=ruDZk$11#q zceYNKHiFPl&?;zTOV-14k@)j&mFvp)Fm0jaDjaR`rZYQ&l)JCQr`M67FCmOXCvIIe ztWtU1P+y#MEdckVa$Zuf_j7M9WXn39(p%~JbaM6;KfRo(_pKy4+yW6u?{I9+ri~&a z@yPF~BkOT0=#x?%gtzoyUIk5dwC=QGO*;Ai`MMWeId&yU*CRTGAmhcD|HLjoOVvfm zU8hlKbfb1lS}QYx^oQAR?!5_&b3XNOA$q!4@z9axwfdDCVZ&{FSGC==QP;0Z8_jfK zH8l)qWEukr6jt<`Ow*cfjeV;GFs_r?i+zN3MA^YU|y^Rbk&XV-C- z2=p8obzXJP?DHUu6*gMO^2Q1HR~hS%#=x}v_A|!?J@-hUYz?U5% zt5>$s(%YXz#Jq+r&~+b7td_VmcY|wz{LEyzGPfNK8u=Q6b0!Ops$%be%-pB9ID^blY@5xI@V8uyaE!Jo*sZe{ZdJ%(>?ebs=$r*(Y^ zj9o-n<+DDm_TJ^2+CN|w;XlYRDb;JdN#b}AT~m}-`pAb;7d3$Cdy{MC%tAauwy42N zTp=itCNb4;WkzOA2${uU(FN+4z8*C_&vjoYfV#f<=T}qJ1Fet}ABk2fpqBBmxBRAL zM}_&nPjE`0omXeFIe*=xqVwKjb)Q&%WK&KL3*a3{ z#BJa|nxhdH@%6=LN>+4!i><%6!Ur#0IV8*o_4sKoqrHr0QN2Cm*;+;9gmqr zHSSy?TcVgBQd4W>Mq2EZoaVdluMY!3!bTZ&cWdAUB1WI`VnlskQy`fp?5Ae8YJdi% zvh+aiN5Ze|q--Vcr$R(8l)!Se0?_8tKQ4&4h54$$=$ls3pz6o@(5^wAfowNVwa^Mu zPKUzP#H|CP$K)05bJZMjpEPpfOq)5|)NkLv2#SuE)tnJFRH`&X_|d=*e9cr3w?eEM zSM4xydsp^vZ|q zqzPu!{!vlnwYWFP1r4xg2=Fx|rH3t3&+t!)`k+Hr=4_^a4jwHu6CIzsT z1YpW^&qi{zrb2aHi2^Et7%d#~% zGQcnY5o>pOPf)TOkJ%USTX3TET^08;jN%d}eChqZmbsmI^PT3u0ZnKV%na`3S88Bx zJ2F9Rer6u3Hdr1?&wYg)KWc9alf2O(6)pRPsP}0geK&_I3Dj3nU%UA>m}F`^8WJ&* zf~DR8ia2`Qu#QA=Fi8`>v*k8YUx=yHnp8%`!60Y>yq^NXv+?&XOy_CHw2y!_n-oP5 zwB3Y%_^FLa2En>$em{Jgwz5jrB+L$Pey!vp1>7CaKI+3UtCgo1Q@zqX{(%ysF%y>Y zOO&mbqTW(ds$PEXE+>1F-E1!`tEh=IK6pXzeDa2?*u^Z@Am~*q)WNy;{Fe{lkUUy{ zDgDTux^I!ul-qAqUzBoxA5cH$6oKo7G+<@c)Oqhd3%-CLtY~X%q9XGgB)H%oH=j}u zY!EN>D6b@8T~bt})*VMJ*E21pzq_&(Xk^Sg3b`9qriI;5b$%oqXH=_AHcWtd;DN}b zI+{gBGN@tR2pg`#_4bnsk|%YbHtYLQ_Of_>fxt#9U(CuN!JQ&y-!_jw309CU>+X97 zXXf%~n^stz?l&n& z@7scOuT1i6#mu&51=2~J1nv7dsmc(6RbWC|vshJK5;)38>a48dw=7lv`||P^(r|?t zjmbKDTs^Gc`q9v;mWFhWLgR@^rVb}X0TUE_yl8K`>Y<6*TcB0Df5vKX|0YClR|^#p z*bLDYAkvxM(7Bqr% z_W_BDza_rTyNX#viJ^|&qO+go2cC)jID?C{G;1-nZ@@%T#L8W$g~G_A0-UF`k^P**xEeq-a)_gr&ELL-Qq>uH7zjN3rl+aRuMC z`NRo$gfrxLFIUbP-C)L@p4}>wR8Hp|T~{+lNA3+4WN}jUmskzL8SXcLv{DY|SD9gl zui4IS5oDwX7nE})`p7P_QF=oVu5U^9&4E3=EjA^F`*gLWU2p$&uFwbG=w6QY4^N3g z65&kS?uuvx%V%+U3U2R@m_MO!c>n&6#>BGAaPy)?v^oYPeU}5ck>}fCqq6*78PdCY zd@3?umLJL5lYwc~`jba5iRxF=lf4Naxbd3oCY!M22*kte|7scynQAYI!PmB6uWN)j zk=3Nxnxzuyy>&tr|8MwWs!wR+;b};WB>X%~z*ub+0LSBay;Vm@)L?N6mLPH{(KYM1DC{zJI+p#y{Z0RNPF;L7b~_W#sj zlqvN!=TGhZHog(Z(~~g9{aLPthdz%Oi+Aa>`;}!RiKqzf#Zs>yYVv5xG;A?rW~o%EdkFD zvt2UGYm(DGQ}o7>5)%;A@DM-Ppb8_0tSWWEc{k37Ro6G`7vPgbukaj6!BJ+}tr%(A0A%RMikpcQ7CcPOn*ieE%cf#CIPb zs{*h@;v`D+{mFd(uCBGz0S4c+`GsL@#f9j+k{CPMGie7&@;0uTKL3+c$G1*&(4uQk ziFI*f>tUOhUSVW>ayGieu3WF5GmvAp>6>YvcG)af^Mu8z2iEt?Lh+hcT)L#YkKj)i zc{KU5?5{M=7XV&Oy}Oe(!r1HqX;a~$N|od|5wWVm>eC0rIzu2|W7ut|$m{T+tCH~+ zv$eaI{YuRCA9GR!sVDBQ7CA9mS#kM{|F#(Y3M6r5Z(!a}6&#X#c@y}x?iSIE$I8f+ z!9+7fnR@_!PQ!_}B@WmV`vTvuq{;lM!9;ip^NhTr`k5{IFXk7~(iLWT{E15A@-79A zSx6$G_6O}(D(%Td{jz^7J-{5@6gtITo)WEgq14Epcd#%_rDxM@B@WFx@yTAdz9z_y zFI@&F$f{SfsiJ}mjQyH*grB;2aEKhxf+QSPyXHzi;HH=8LpXj>7a-zK8w@+ZF zPZL)rSW6<6CbH(*2F6Zwc_eGJMmHh8T4ONsRpGp58d?TmW|m>>Wgt$W7Dh*d9Vm{( zmNT<6#Fv9TlvEm*`J<`Sf7f{xB*8vlO1@2I6CO(R}Dby&QeN4oxr7 zNz~W(SxwqMwH^z^@~hQFk)0eR(3(dF)q%}alYCQp>KI{Imk?w1@h+>44~-f!6_3eTFf$rSgg;doB~z4YYLbEy3j zF39XvkF_sq5IVN05UZvCm4Z|C_B}Oc@xC*(Ns=#uO?w88EJXZpqq(&Y8H}f1W`u6m zdo(B{@WI`pjke9%-@7q%Vu%|VT(}~0&SQJlNclQHY0dzT)fDi;Ny+&9VTWdGSnog5 zth0gx1dEuZ=2-^-^T0so4$j;-K)`rUF%&KfHy=&Cit!qRK( zmnG$N88Y+mUTy(9ldBPn95=kJJV8~alK4zpub7{U+S?60M8FVbj@IPrL^Dfb>nSY~ z-Yh|-c(WEo860TtT18RE%F(?aSY*@}cZ15>SDnfV+7Z`(T6$&j7BQD)tw4Mztex1?dWgBx|EyssO zMGwnOINoLyz!i5o0`1OvXqc#6Kl3>(cbz}q+Gjl>8h+^vu!ut>bmF{a=5s2$eAI-% zyR!K2_~`tM6WK_Ecqc==X@~Aacp${t+gG&{yc}}UfgfKPAV_yS(ZxekN;AE$(JJ+p!0?pLd?Pv+OYU`NNg_Vjov9 zwPA*o=;$1;zVA;;)Us7au&zr^h!%vJ)2y_mXCb4=GQB-$PgBaJV3yz{7HV{82d9WF zH5<;TT9P+Kg|Uh!{H*p|NqHoB=0Vw3n?69|G9`^Nkxb@_e%c1)YGxwq6SjGL zSR<&SY@nPnl41aCJ&{Y2-fRC-_ju}f?+AX`*NgRiRQ~G_8*IYwJML^rSm2C7iN;$B z_*9Co>vudI4FS%|s4lg6ed;|xC5r$}5oUI>+Oc81B4_1?)-U2c+(L^IrjRz!&O^xu zcLyi)w6*NZSPXA)J*@=X_hNo)1=-mmc=k0zH0+56l&QWhrrt*sD%@W~px7}&7Tbddw1y8 z5*Hjo0vSfVXL+Y^K20d+TjhjS?g1~waNpsBi2?XRFRvP1#UL8v9ff8OH8LOGMW0@8 zwhgUm+rDUbWj)TnGo1c}t&gN)DpWfd`ASOX$uk5u_0^!J>?I%8fW{vLE+ng1Gj98< zYFX@z6jvO0)iLyD)1?L_4Y}(uhw;i&B7Eq2>T=k^>xx#5BjIYd zHIv2uitHYKn})9Jfy2C$@a@l5)bkG)@)w)InPiPzk0wEf z20A$&p7xq2GL=&TM7xHKnuxP3I=xzgj$`Z8lAppKtBEJ+(HYKL71rAtWQIcZ#3GtC z6l+A_>WYvRSOt>kzVNYO>+P=nZHb8{lM4jxx$mmrhGoM8NB#N0*V|8K9Jj3G=znWK zMdCyi`I%w-{ZIeUM@Ld!_y4DQbVcy#e|;K`z7?DrV7?+fQvC8OXVv}#4D1)DIc<0$ z!GRn;wL=O?MG&TG(AN{y6pr*31od|#5}hgqVtmk)vsFgKBRC62iH#&pU;trwQY&9Q zWFV%(@$YQ%k-#ow#tWFDsQDP?Z0<8DlaAMTOGVF{hPz`4p_Tk-(IR2<+X|atOG2Z z&ymngeL{T?dCMN|aC)|O>+NrLe+v9|PqvPA4`HfvnvPM$H06Uu!2M*}m>pevUldkP zj@;Ez9k>zud0+URUswO)1M&Bf4An&iy9D5zACPUnKDaiHbkcW-cins{1j)6wvYD#u zH6nNUr-`yE4=yU4VL%YMP2EBSa>q7Ius7@Uo5#;-iTQC|4Gih=6!%~-T*15OZ0U=w zPQ1>wW4|0cC=wdU_JiHxWV9k|lQrO;pf)nVU+|n2P}?|CU)V>6Us0*tNun z_z(B{$iY@|cgr`#J9544(W4>+0K|BELCoq>NHAs~oMcdC2tEWHwBMi~aYu48?u`^gS{=@NWk}pz(BfHA=F%gyR z$}1Y403vj{w)iy{LHNvZT=Q{sDZKo<<-FVKH%p9($3>b`6n~G64%geIsw%JP%(R~i zW*;o80~53@7i&QPCq+!J*(S#wg#2Z_zL1UL;!;Ug#6tqNz6hVL`rBB^?e;Mtbw^We zoB=!V*T4Zak;4lB!wD?EszS|PJE|Gx#d%jFq#vD?#?JvIJ=3!H>jqC2 zS4YWdchwd_w!dtrSEw?jrhqhk^t_zZZAW;6(Il&_eiXq)a5e?J0ovGjM077F0qD~P znxT)HRmbyD(TfImGLn@gCL?tZ3qS4eH9;;iTd2nHk(3AHXy!L5jl9`J+6@T=G?6F< zQCc;(27r{^c)!nA;{{&%Mj7wz3TkCeM;_W=p!jt@%WJuRmYH4NQCAGm-Er@N*Oo)o zkTg!AA<=w%|G;lugoEi0zw&}G5Xn1)(U57;bJQ<_D!2OmubBcRD>*XnTMgTYCD(|Z zFXFkXAcA(}`?-m=m48RhuONi>t!g9IgrVOT<15a>{;lfq23`?NjS~ErOn>NiJ|hXCLOi}|$V&is&>(Q)UqjU(e-S-~kORe3^=l{f zuolFkhI(K*k<~f zON#~qRk5N7jDgK3Bcn=cU*1|3;ru%i_&O!@b&aM7F@?u6|B1lQF`G`lepI7SocK6gGgLX0ZW0%$XI!5^!X+J z3BLTyDDG=rv^(*H54n4v>q$@A7m~)=N19g{%D!4$oGMbVi8E%Uo&85XS@2jR(un3{ z)THubxFM-ki;v1eo4g@5p|axL6~YysmB4Qfo4`~y3mE3A% zt%zVB8i3Py&7%8=dAbrMFU7M8+i0@c?gM5V*&rToZ9^pOa_%Rwv!&?~2Rmt0eqYO_ zF}MNyhdSu|9Xnsd(u2-)oGqEsGR?Bfm-y5eSMnhO7g|m5n0hR%U<#0QXJ=?Qf#oJI z8o#%4QgY3sJrG_up~RcnzhSB5=`sNkIw=E2g3z}bqg;S1Q|H}Me*f?_jNtJ4qcHdy z`qfw{wdMELXd{^!zfLpi`MU}@jQ5wCcmAWS@$S0PGP!pn|oIOYM4mOg;r1p75m)_JedCNdYanOmbbkEu2medrdw^h+KI|=dNj)ggYXELJdqVD0SE0`44>dk+N~*MXmE{{E8v2>85`9D*b%| zi9B3yZ(~wNvubijV=z1C#!F&4w-}3CaLXG2kDQAAwpAs#;vnb-C3XFn-NBJlWCdOH zBc`!JHVKrHf7&ZUkH?T<@;35+_^WqYHOMJ;?UNfI-S1>Db&%1)s=FSp26bI5m8M9QE1p5eq?}6;23QsTgKBuabcH! zGHi&(7BH9#NBQus7?k_$PDO)rd#!GlMD^SMwqUl)M^eVv6dJ*q0sJ*f2K@Up89Xus z`e)eC60N%*y|eyS-+oEg5lr{fGSlt$lB;KeUwbsFu~U|}m8I9YlJJ}k9Kc$KTK&@< zIVW$i6-QYom}2i5fT|H*pkkZ+p>X3vQ*4Il`x!rk*ID66%A}da-kaG~)y5j6Fyj68 z+0Ai-LV7u&#(w(*aD*`Yk`-gy{#&kU8j z2b_B?1)e$WTMu}aNx4g6mG4&6iupB6wbemdZ22qP2aky!b7Zwu{RWe9m-;$qi(I~f zxl9^sZH0ZmnP_~JEtUEaWQUjdOv6@wB|`_!xFD(ThLlFR5u5lnFFi>~Qe>G6s_j^L zeZN`zkCu2p(_LZf8n!pJ+igpZpm7on)I-#7k(mImP{aN@i0<&HIbOOuM8L@cL)~9Q z8{*lkkmhju*1^ta#!5R(Y;{-U7McXf2)O2PH=^K*jBUjdQFrf=1TsEYfbB}RO~Q5^ z1le<>cB@mNlUSI*+qLw)?egZbM1uz0YJ&7DNmG`E#QD|fX(K}>M=Ud2dIR6EM2P)W zg_K=COU{BgF85ph7A5W_3JycbR=**Mn)t9wl zffN%v^EX_W3afSIA@(L~E90@I07F1+CnznI(TP+yH0Z{(B2Zv;A4Lqb zFJDjAv1-VX#D#=XpDvEZ<%Pa_vjI&iOp!9KUwlKlhlpIZV^d++b5jibX4>%#l${zE}OLKXqCnE0YsfbIoLxpCk>lQ(AD1>#=%s(kT zGK!p*x;E(geJC(j&9!_+2!?~rvRW!Ev)((&TJ)?fgPmvaP9WZ7fchJKy5Gx#%SAF> z)6m5tmGx3S!hC%g`1SiuGTw6T@Yqx<3J0IU8}q#? z@4Y9ApIh+YRTjqkdy#UE6b(FCX8|-c?$5#gS)l?W=hwb34A+mViEYduqK$sfebx4~ zurq-_MyucRR0TBCzkDO13Qcy)QA)W)karwkQ?M%8eg3w*jT6j8EjBuQmhWg7z>}>* z?gi8NUHPEX0D}tJRIW*DC(nmkl5VNV*zI_7+i;~kEenV<>jU+12e&}vgnMc!j^}7) z-+X@Ve}aTyChG&8Fe<~j8BkMO!J?vBlHRe)mxt@hM3>s{ZohIAzYNFH6K7>Ip6uM> zeg?gJi-bF(>^`3do-rVsG>3rZ(EH+%Y;MNz!j#uhIHlqEaF%ro+k)5;#vC^2NK}+~;Y9?Mmb2 zs>*QkjQt3W{3T=6JPXM3i;tAUrW>s7Fs&vz-FLu9M|u(uO6!?SM3h}PVW0LUaW@jVXPD@a&D;)L=&GGb+`Gu z0D1XEG;*s$O}hFbWj7CwH7wXKdD3&6Vv+|M@X#uDycFUPs|BR zz%{!^B3=~O$76hrD#og|zX*`>q8(z#&?`eZ-~`tp8LOL!!+kI$g1i(&h97Z5hQiQX zba#-Q4<$p-sXIqNd`ArCt228Eu=brMxwQ;P+E+)t>vd5p zbch%iTewz2Qm5SxA56nw?z-oFlH3%xdPNBAI>H9=7pvW}#&)ayLs@j_f43liS!+9a`7eC~S%r8hNz_bh7B znVr+}a4%h1T`4^g=^P>Rwg6pIGJ?+q>fZ7fa)8>F+&CmIEm_2HAcu(q@~{$_{F)OXX|OOd-@#RkP{ zC5T+f68e&_`5bq%DZN@EjXtm0+sXpdEZ@>Dm?~40cyk}}_!am0Ha;~}flt(dGpoFl z_1fHDCU~o*PpEZE>X~s`eLva7CD`vP?`rfg`2oB8OTMB@s|XzoqsbxTahR1^I8$`& zS~|g@wJ7}Pm$lQBG(DSw{g$*&N7tHR3a0t}y!&_E%sAnsaadQ4_aF3QK~lGA^jF^O zC+dGQKisF*8tk`Y2q^OX+|}V|ehRZKPevA1xvUb_%YlY*e|MhLn=|_ay{lv|bYt2V znt)Cr`DuRXs?4-oeC@~&1~fqo(R|sMPZ@RfoEF+z>-u^ABg-oo;E}`gZR3bI)8XVu z->)`nX2iBMkS6>$d=#$}GF_-p&G&*c8HWX2*WrJGHI9LR5dW7drg!7q?aq=w~CAd7vZDLFUFg)VCu zMPz2iUIfruzCnK~lBpuXNjLYk5C^l(%iF~~`(Tg6AeC}Cr_7PO+eyf(v)72&6Imh6 zq#mF53|3X%jb@^TLx`I-;Nkgir;T;H-*qIJrcSVrcwnCJRqwh7&Y)?Ya$SyZHpMmq z@EIzsaO(O%$|)kz%`w-UiN4r7zIX85SC$mf4~T1S5XPLx@at$(Z%#;=^9*QF$=WA0=Q%0%!8)W9_#@|ov(U$AXV2J3p{ zx1xM$(k2SC4|DZ|S16B2Bux-RZ2Ur@p_N?nf$OjTnCATxeD_0>?(Zv6vxmu~th zf7oDmM$q+oFIr8n;oB$5@=aM8R)s>IAZ~-%pkhX-DhZJyjuy1RhEfOTpN~fSRFAOzO9HT}i zin`1f2n&02LEK|IRuJXwNJ-vG1NH|G^n`#aQ>u}IOjE*FRuj5aZwy^)}{LaRKj8-vV zWFzc4(Kkoi#pY|Oa@3m607)6o8qUAe#v1&8bgfRg!tA56I(I4JNO@|r7N*Vlq<(7O z0p;W)EB8xi)YU$WBc!`0FIjbGo_xZ;;Mi>P+XzW6-wkhhR=lX4H}06XGMwWU;xSBe zR#Jekvvj9q-TNv5i)8j1m09C;|6%^$rc2rXo8JP0aIq$=Xr*`#?x@o1B%WAoUykh8 zqYku90J16nsHxU0g4BtG7RtLR?$em*h>COl6pd>&NvceO&vCRHl%wq2$U+iSwaZY! zCpAzuNe2QB(R?fC6GRE&HH`nO^0W;?&46}8)~?Q9!abnCNuSqHM`x1hm?G!u;vj|9 zV_S1|p9QIj7mfJmT+K;glRFY{c|xkrcE{0R3l>&ny^ShlSf!NpBGpx`A?t9rKG;yr zVWon(i1FF18ozH)*cd;IRkI*Zhu}1sj)Ax~A^N7Q+yR^4r~XF5=GVa;ZR=4>x-WKG z#PxmJo;7?OIoc#wcE!~)I4%L~pMLMH0|K@=5s{dt&r=CF3`*!LCmHShYPs(?S0h-L zuco6(2(4Y2Xuh-INv-c>zgR0F0NyQcnp(~H84qXFpp-MFew+NK`qaA1y1cBMUdAg#Gx)#y9+A6n4ZJJsxH@SUohyp?4Sl{j@AnHJfalW; z^qizP8$piW*ECjvtI)^2EO{`8&tu0JjuJ6I2v;^6c{x*Zeei!oHxmMmLwITgs8;h&C zLjwVAiJjzL)CHXB-Hc0yKd30y?AR|oA^~4xHgQz$uQl7{PK`fS&IH}MQBWP#@LfV# zhWK?fwr~u6A-?GLN@I){`-v`<$@;W26jbL30B1aPmUfVt;sjlupt69`{#!L6Skd9O zs?L%HTiD5QS7<2BCcuy_=vT9O_Q^298C=$X)kY6Sp!{Veyq>mWXF<*>Xf9R>F_0G? z2_%r_CPLFWBf_xOthhk4WY_wN?E9#i#w@idO&j@p&8s-W+sI+VSJ8M7 ziLE|K`rle@u5q@#I+X*J>9* z7LK2s!ym$_tn%T49>qVGPR-4cJftI{ChDX`%8pC1m*1#nEbjN4W)5vT-j{ljXU%Hd z{Dom$YHfo8(z(g8(IS6;ny2qax&N9gk@3U6%y8iCkvuxAnf|1%3S* zoDE|{m9qcpaIEfj(`(_yX&^*2W%3tG8j;QC^*yUN!NEoZ8l!*LymJ@Zt@I#ir~i{o zbyKXC|Hl6-GQ*hcEIJ#0G1fnThx3@t{2`@9U>TRydG;N>DGGq+(R$^{32%QGg?N)w zoA=X*e=U0TN0+(mOW9Gz*SIxjq?RjL$4?g+6E%1o&yj^w?T3VtB{_-qr71aXK^|T= zfg7vK`IK_`rsyMUm?A@)iK5o@t2RrjC8r>Jx*WiEBcN<2ZE+VA?NpcWDX~3Dp?CFn z7%rJz1~a|>%ib0f+cU9;4fGAKcrmX~)we*0^GLjb#6Ikm2EDdyBGVw-D;E_NUnrWV z3H;SF$nYW@O|2Y2#(O2mz>>aQbSV!ulgem}Y8yu-LyTkC)&d@p?$^&-_%&t8Sea2u zKjLn00qCTZfpA}UXMEd1y$>1Z#T!;Rm|?I`@>3aJqlbK2sXs}1dAhjJn;_I}AGNyf z&Pq`LWqKj2wWY6v&)ahF>6apO|DF;A9k+JarM%t$7R^Hj-J|B{o=S=JM%YW&Sf4Ck zl5z$7^*%er8ye!wer~ozTwFtBU=HrpNe%D*{8z}0>pf=6^ZEt0_#5^SG@5SiLC^bs z>Y~;T2o~kQjAr;dzNS9P`F*L9N9U}<83lfhn|4;y+xR7~y$SyEoRr>)6Gt$yLT!Ha zy1mui$-q7v+{)^8CYh4l|9pCV5q5nr-=l^^tXLC09dhU@L-XFB(@jzN1fTzE2Fw$ zO{>&Sv3WumxM{~fwB8n@nZ)Hrtus%y!WT>L(|T1qET{W#F^|C5rq=W{f)5~kSA(FA zgoA(P6wTx1Gv03>RK`7On#Kuc0KDm=e$L8CA^_b#l_>eha+>)NE^oxh*GjlVy%V=x z;z7RpQA#U2WB-!T(m?C<$_aU~06waUd!_5TIl({;>iqz(gcS=T)}{>f;aKRZfF@5% zP|%ga^4WiLS1$pCz&Py_t5(a;nd@W!<2l3cMh1U#XP^W0^eSOB34MBw-JMaPx;48ndOX>U=+ud3Uk4AVS!Hk-C0QLrS4N zc=zc%pQo8qOwlmzF+<);j4Mw4ep6l5=!$(gt z!+ke?8po>0DUnU_+&5Lae@aiv*|RQv3Ykia?xwq(nE((=QM6m9vWtIm-!p{57JV*66I?u>L3_$n) zuUZ|%hH>wFY34R8W6g~bTaabNN74V8f^-2ARNr1 z$`rCdAK@{W9yIz3SpxMHSWXM`20GXuvlE#VvH~$K0Y8-XtW8H%&aQoGC=a!FJ?V3R zQAG@vMN{Quc^++o$KNqZslljvY}=N$rX@rhvEIBAX-rEY#06>jfpt`sA<`6P*qLxN zO-N{9u}o0l{7bh;GNg;5F}`b}he*scR+jB`hki+hy~EhOnmLq7#zJz42pIMCGwy{N z-u2t;#kEz-Z;-_Mch<=macY@09EZD#KHru${Ipk0B$$uGArK2fjSfA_H zBOzuYdt~5UekCm^hm^75-RP*gAE-eWzi*;u%RBEa6Mj&^s=0gbj2kpavt?+TG+D-+ zJbV>&DOsE4{StTFYRWr^QqXF2)tUhyjsvbd{{7CeWDKB}g)pmtP3&WbI}OIyu=%(S z^p;#93G}Ga)|GG?NiXb__3~woW3|gL>N=Y+TI4Lb8U9Ya1!o8+h>;H4BIUf3=eWOQ zI^U*kUPr{KHb48vs+rO&A?c*Lc%-H9ABZllSpK2bJ5xXm8;WPqowaVmtO!+Uw#R)x zu$FsANLVaXEqE^jCEs4*-@X*-D@p_f(^r zWJKXgNxy6k*;z>f^+R=Z7VgUjvh~*X>k!BUVQeEIC8rp}ULFthH<+q$KPyY41{7#Al9*k>{aGH!3KnmW-(L=4^X5O4L70rYUn0u zIcTkZHhzN;Fiki_5JKLqqBKL z{VB~tUV7?ZZKx7GFYQm)<&hQG)&9z_GSK6buzS_1*nw73VAyntL#kD`B!@R4mAKDk zVVgCe2c5tEYy6}4FdRlAifOFO`pogh5i!%L6fM)7;Fhhgo`hL9P0+0?hO1u*%-`8jhg7g_0#|3lN77({;U4tCKc6~i%*|^4+C|JZQ@~}&C zhP>_E6ZZ5;v{8q5R{+-Je%0Jk_uruVCwGwCW{$mm&in(=gJLiMnD2xH&7Q!VNhX!` ze)#Kt#%9f8$7u39>uQ$7$({t)O2;)Tt<>p4;Qb}kq6SqWwUD%&nU&ULPO7~9xlH+K zSK}<2qf@Pl8?DcMQ1e&JwRf}ybp}g-#fWJ-uFGgEag)>uWUWQ$%nj@#GCxOC* zpC&OnEnzn{)RU+4QqEVg^~0C-{HS~Nry_@!q2Ltznyq>S8Wbz>6BJM_xvx;^Y;nxL z+wyNstAE_I`)Or$cI0LZQW9rfS-LT)wb}A1XWOcjbqEDF#N`UMfOTT5RD9LQq8OPj zombp{_2v})VKxLK*|KUuyr^B&8DJmvomzQ|&p=w6W!T7iQBVlZYO&){Ze07{1%h?< z*4Y%bub=Jy>Q{_SmzdG;gPP2SEI`X;*LJq_cd4t)2P-`N7A2X8V~7_I!t5bTXjNyT z8J~8l8f+L(B=GwFnj*oZEuo4KdM)uJk!dGc`)oWMYFA+zleuV&22BjR2b+jnuPS~K zRzf=kJ!!M8{m(|#V~lkk4P2G0dRL;e$qJ81nP9=Rf^RDaGv*SJ|L*FWQDEeYG~sKF2UMNy}}7%^L}cgDOd(kg-$`>ju$k5N7ucbSH3 z@mErctq`he%w6^dGW*_V_xLV!28w>kR7VN|Nk43&gn zeTfdOHzm%BD-fMjDq)4*gzQDDnB8AnXX=?4?#cVwCf)*##{Ng>Zy+CAP%2x|GT&R# zfV-dxduCrwXSHF*fOGxCbNgHPYE?|6@^i_4HOMZbIE|0L-ZZ|Y{YrK6Kg3LRU3o7| z1xXU%&FqP_)*p?}$H3;qd-VX#XQ-_J@BnsPAjJAE{M*{Zv*p8WdPGWBmO& zM`~M|#C_TMdEh9o`e0G!L&Us8y>wh{-s8HFCL%6zRs#k+0?p}>xaf1$C@f`|N}Z0i z`2+QEyW?VcP61GnsD^weq4eshy{N?*2r`^E|CsEr{Wxm$8j^!}ipuR57FbQi`-_QJ z@l;XQmzx`a|CtMOTy-LTMMi%U@G3j1;PcAP%JgYA)cL$3&7N_wyiHbQv%e@tL|-3o zvn#WNTCWl$*JC5h1j<(cq!MBhkF({f2(A5p*G(Mygw27#p;0kTIJ&28SyBO>jt2k8 z@_u=zO;4x>p2!yF67|kd>Cps8beZ-1&9P6XWS-Y<-)T6QW=QB$l{i0v9P$NlY?Wu( zQFB0uOp?B{Tt;Ygzo1Ct>nbRqoUl-w=FH!y7QLSlAvf3FoUezg7*pjTJKk7th7z9>Cx!W7ve$^(t?36CyEt-m8dm(@eI$AV#2^9hV`an zNRAIPP-yD48I8wWoxGB9UjE%(#hPP;gIb4lwy27Ih`gs|h%-@w%zOb4R*M4>p&s?i z5jYxxWI`D#-`^@Qe>3sP1i1P>Cz}_Hi(298fYgz!kwlz4&*(KQhXX~`{@Ko}rdaHw z(`d#O_}p(>41N2tfHwKmKXm|tKtegAAA8Qe>6Rk;Fc>u7oZc2W^$m_|RlJ3{iBOF< z=KiWQyrKo(AO849zeZ|HyZHSqDx=vkPFPF{v`i{>#;Q*z_JMw!r635B6_Q0JkL5)v z+JJoLpeHr0q6K%Kj_E0o7j{Att$bz9t+f`~=R^Js4<#Phjvgdr?$~O5@ zD$-Po?r1HQO``>iDyzP1eQ6k$d59FJK*kGV9X@U*Pto$V8TS@-z+jaPn-D=Pi zRwUV-L_JoledM=0=tBR5A@C;RmoGBQKG;T%sMiY{h+J^mSPzCOQ zn|@jdkydOaA8VDdzde~mcMEifeBFX8=>7ju*Nr}clE-sH>G6@ZxGpB$tZ<+i&LYc| zCUQ~TnU35w>65(bS3YI%dRNP>^+sZVmpZr9Dls+&miJN)Rdw4>6E~B*({Mi4zss#* zUDaZj>~6W@gz^~LRI3i)Zy+T5@MwyX56sytn6a_(w%(v`uE~*ov^?!UP%Fn8{YW!p z@bi8^#?My`%2*$Er2+Sh_A;&bxbQ$GWOSY8PT8|eeumklb%s9AKgxR6mO@wT(hmNq znypE%v^{4)jJn3U1T#-323O3^=39DYW28H}YEYx+Mrw~aMr-FFBiVz=g2q?BJ>yK? zC7D07cBpxfaf>Accg%3qewOr+nzal_I^NjV?}QK5xd{IP0tRqv38~TgoH62o37yra z^MqOclD*q`w@Za=UL|4{_%@%L+Hq;A2Qr0Nd1Unn3UNG@hF*{C-iDa#EW3}*D6Ii=O?t`PsdQcVR_uaUY zjdHQS`0vR6+si*&POpKr)rEBUzm+ck`CmcG3sAKWyr*c9))j^02pITkhB$Xc95w6r ze@dZCLx7qvsY*&;eXqDuP0O?QsDRv;5D>#eCR?kKeF5h9XRsJ;S3GTNLam(@*~|sG zzh^UkBY~PFvL9^LY@8aWPN*<+0wU8RDj`t6kP_A__Nnp@u z6srB}v!(Ux1d>v%15mY-fh!P?Dw`#4d^F@IDe>7=TQm9YLY-`I*Z7Kw} zlfsjy zHyx8;0*h;-T(grJ0vQMfNGBeAWw4y*AIp@0i|TY9*$Od`Q+C&Bt6>}6)|)xSs5y=F z5TtDZQ8!?4l4l{{N%Kb8c^FJNLvy{3rzVJ8)Yfth!KT`B5wXC@Tf11~{=*0u418~|i?zdP!fIPno4Y)?=NFGy!31XPB}cVBfn zeTbvp&)pFwR2}wt?Jv5HeFygtUtwKhvK^Rpo1T?(DCLlAtxaO_aPRvxoA;ZI!iRKn z3Sa%x_&)7sqF>jjnHR`oXK;A=sT+9o^jw<+Mp0S=`F{9T|JU1YrF?fMg`=%ltz^Zw zOihsVlj|sJKBf`bX#25K!Phl_ykqb(=_N-CTsY}DiNeh8N`er#i&kO8?_TS^@Pt?5 z`f&5k7M6VLzatge|42n-|FDMp6Mk4LE3%d}?)g_r zaTP?vK_Pt2Kzy!v!}i4?_qe)0H4^$5)AQzNq-T;(x};cq*KE7TpeI$}Ua6|c96``O znwpBmyMx9Tlw*DP)h!R;tlz|IR4o^{lZ?R}{ywO3UM0T`y#a^0LClZf5X{Yuc&gHK zkq}-v{w%)$clqplA{1FfyU7DF)Yw9CyE) z`v|y4tw;l5LOl$^<*Uh4yx&>09od5P@EL{~P0QUZeA8;mRm;fRqJ+T;`^wa1*WtsLAx@MDvZO;mXGE%-gy`yDsW;%MXBo`hS<}hu6th)5 z*nStHCJ?2n{$@I zinl8VBJU1zk+ikqA7BCRU=*QZpEhdzOk<_YUXJ*?+gr5dlNh*F9K=RcFi%1|n!cFa z39RUmkkXhIRE%^9-rlQGr;U;3FT9+EuqJpfGDrLUaeZyIxi2A4-R`q+e(Q+SzyLGV z)RsHr?RRU2waUlg*@qNGcv+Kt0v63cIlY<+ZGstQlOV- z)|0g_oSPT_D(&nW`5ophnMTv-VKe8Ohtg=~webTIIG%AcR%314injx6&w1U!wC!8>7v9E3^6iLdYCxnrAoNetcBR%^sCm;CyqsGiTJv!Oa^XZh5?o zDn&T5WE+#1eY44yGjF`l-(cwJX;2|uDlZAW%3wdzK@4`$cdh-`OTV!Te-=ceQBy3i zP}(0kR`aM2$s5`|9q@6jhG{(Qg4y)HtB+AFuGRLe6GsO?9YV%|^jTES7T!aGO&gD( zJg(-ae={^Wq&C&?h;$7m<#d|Q(=xlRgbnQv$?fUG;-iYL5mr1xL=+0&#!kV(TwH)1F zK0?&0(ih_yjDO1Cd-cdlyLW{YoV14PmA8;RH+-BD{#_-KqNjK=I zh#;}H;4VojILfHDy!7nFdh_`Ht%V zWf$+(TKoj4Rmb)JMo5ngd*d@9j%_BWh=nwB!9G-R&daj%rPz;#pterCe!wF~i@C0b zX5U>V)CKq6+1s047Qd+|U9Pp=3%S@2gmG0_VU&6L9-5-BX=Cm7)QjxbGUC9>W(m^*)KZ6!Ff-81(6C;=JRJEnK9P=dSHxVMl`~erE01 z_-E;1_&P={tf(g6G#e`RhIU8BQWotS75K88$h+1eH+mzFKhK`Qm`DgS?9_aXK(6$8 z!!Ul2X2OmsDr)1W1T+71yDkt`G}^A}zoRw^16Cr|XjK_E;PgrJ_?E)ydp!07>ZyAH z_TghBs`2>%2dY41+zAcH)NQ>@|D3299!~SzrodGvET{GO0J!DhAMXt->R`bCCN#mMu4Aup)eaHT7kHFd+*c=)PAb7uLq?uO} z%Mf6?&+qo!=xxqORKFzt{3A=2TroFhZ(zB>t(ZQ7mw8oi=?qI3P$a4EEoK^?PQ8`` z<}F&rGh`$K-z$2UvNq9z7*dX487o9S=-3ReR^u1(eLq&co~jCKrs(uwa=6aFlM;V# zN;GtD?us>=W5Whdc!zOLb9>SDeVK`S?yedZ%|#r4?fGLC)z%5b|BWMP)KBAxFzhkP z2fu5h5fU)TMIguT)rw}QXCGrQhif!5NnqeohCZXjarVg^iHFMSQUSC+*Qn2U`>`Os z>YL2ytm1Q|zLn{ze%kd5nKd7EO<2YW&MXW0?V2@j9(IjG+giN|efB};^3P*5+R$%E zCq&5vbv>%hRf>^olZx!En>TlWunVX3_qORb@+SVvK3=Zc#Xbq%Niwj+jCzj~@H{cZ zGr~2MJZ`lVs{0>7x?xkCM-uFS3LET-N`#|eV4?WbnkDoJFr0RA)r|BbB_BG#$sF2{ z@8p37j^9j_U47^HSY^BXBUDkBX;Ek!wVu)u@Kl_sA#6y``YU@I75xv}LAw(PVHF`l zu8x+)%A;^bjQ1Kzm9H4@-RF&ttC=n9PtiR2nca$YuCssF{Xvtf`zPKOPOFAh@g_K> zu4VHHd2~az8dZg_r8hD{2fFLVSD1tUt(xj;v0p+Sz8s9~vrWRH3S+Xr_mSPctF;3Md08QlE#?lZ2EFS+D(3*|(R!7X!fzwKuSUX0kjb@uzUt6M7dDKv>) zWs8j>1BkF{e_`CeRX@RJD5YY={aNlG#R=t&`PzHV=FxzNfjQOEe2o?UJC2tVbF|dW z#4PWVI;D3Vkx$S2z5-53Wz9roRYbD|8hyM?@{vT&cz4Ai){2TpX3;7jkMzK*E21wo zpwP2MjXgDWPav5p@+!t3v3G#?-Cqke4@caJCfLaGfCyN+mS;~68_NCLz(<;CBhbmwee|ktB zp!=>tcV$!yD(>h>#AJbTUkxj0F)Jp|va)3unR$MRHik^B;Tq;A9Lf=kK0T&rCSCeU zu5lZ#8s`!o$M|qo{nKt|uvM*-?|-Ci@5ka~XgmSp+5-H_qbgsI)vjtu1S0SH(jwED z+%gJ${2}H*DKwH3QL|HJhHQAzh6s8m=KAi2>a3hTB;Y1Y+UN9_~&bLv5{0zd-A)9lo9&O zOQ1!oItpgV=m;eLy(^Jcm*oAn0;L*gM5{YEqFsF4F%h)cg(3Hw?&W;jniSQQ!#WXb zU-e_D{*yFCd^f%}7lw?9`G9$)rVYz_@ZUdh|El;&fCVGa!0_%Xb|s5NoI(~2L1 zFnRbYKJt$y%UP+h33K^u8>Nc{o6=04Uro*<<()Z^bnIs}FDQc(&5@vyDxZPoV2rwd zil62IjFmRdwI0kd@~09hCTMC_`ytgw(&r^FwRn_~K8+9wLTc7Mlr_4Cz=)%K&ii2{ z>0YtooDF$rnh!JYU2DKH^aLTCHoFK-RsXafWc)r#hgz?^`3zTbq;@GbswVUHutA3O zO9!FGz|&|xE{1yc3)As1U+ROZL9;oxk>#ln+H< zK792#9|Yrmq9rO_PXle5vy+|J`-|(j<~^LvwO8mV89q;#*;eRvTMik<6~3n`SfQWDV$?<(_zshMYD| z;FiCGJgy%m@&^3x>0@atAl)ipo84fIv1b3#Qtoq5n_L(6x@?q`&`Fzw%{MR%f$<`~ zYKUCP9}5v~G*&8FB?0)ARHmx+)req?YbyIH|3R;TlOra5E63uTKm4`^d^D1jrEA0@ zGn(9Hsxh_}CLg=OI!6mv;kjZx$Bt|(`5PyUOu4CYGJ>-b6bXMkrG&X$@R6dyl6Ak1wbv2W<-&Wy(Juw%m$R9~UAk}e7LP=p zy8aVgTgQG$wHoljUu82IWCepr(RtJf%EGz66MMMN=h?ERahC+SKV`l1%XuR1@Pt^H zv;^f48AT(EWR+jtx&jFqWDiSZc~RKI?~#PC)n4Cy(Xk2LyncS=aw8cDiRS)=+;?0X zod75O{ddm%f3E-WJTdoYrTRDU@t2DGV_ayI&T-a(BWq|h^n5=c}e81}k^CP5<}bUH`NAf=pa^HSd2=Do>eturC(Kv$5OBWpz604P}4b zXyWWvP~NLuDf?2*7}WW_U;buHJ!#j+48WyX;@p02IT!rX9K3gu z)%kmWL)a=J{_);_HD_gt=LnX-z3_(S+lE|9Y^LOkprpOR;NBj00XgNy0Xx<}5?n5&T5K z2ydxIyW3~=NO6Jz6$aJf0$d7~=*%Afpd6DwG6y?7s&M`n1C#6fVauua^ZpjCoq`Vk z-bNLbAL{9*!6|_K`=u8|L^hp$zMv)5|XSceu$rdvK(l$uE z8doJHWDzA=Lvhge>@M}+YnBa9&7sj`?54vg`}!I^8P*>vc4U#?gc4NIoOoiqc0tvg zdK5#rGxXqXGUtwqwrAW~s&|oKBGdL;JidxKLf+t+6;l3HH~ql*Juk?LlRc(hr28Oz z(LVxpZGi)Z!OZI?d3xN$FjMTqQKo;XNN89_X zh9FMLdrVVe%J=D1F)YR^cf|K=RlWXs2g}XZNMh3}QtKB7LzKjg&mSu;TDC97%@k#&v+PhriB{x<`w`e!v2r!{xCr-erQm&1ua&kx`BZn@9SW<&meluAyTWqeM4Wz z?>&5ba%qB6v0Cw{j#^kc-Km-rzSxpV22rtDa?&WTM$3`8;x&49N&H>SP={6JupQ>r zEv^g6RYkIn0J7WIfSQ)k^D7{wpHj|0z2J$V6`J90B-i-JuC?#6E0e1pc*5JS3- zuK$zOs*#m1fA&Eb+^aWPm9Tn*&NA!fa ztp4baN0%hh5>S?~UoG)a(hk=u#H5KaHvfXS+--yV#>sNd7NFE1y>AKn~ zTWc`i5FL7|Ylgow{5WA{cMXP0`o;`@@vci_Sf=))z~2lR8CPLl0^c;T6R1Iu8yOH(iz*WUw4C?5k!unFt00OWv7D9*P-XHw?Ef>L zmIJKgHD~S-5t-2a#qiqSD;&^UOjvx3KnoJzpc3fv+wc4vNJ`Qan=T z>Uod9@?Dt8Qxp=0S@5(o30^H1RiYFG82{7#wOgDT+4u9s*;$_EVc-$=6J25DJcO#N zcRa%3D~lZt-=89ARRNWVqgx!!LfY!wK*~9E0Go<371XKvTagE!#%hOl8W4I%;s&eL zexI!?zy)-YI=RP6&)Y2?ey%#c85ByPS|RGR*Aw_r>h%cI?rD4(gj?}W-oWa}d{DF? zKcBEuLj)mXKP`KBe}y5T0fAgKFxh9A3<>kN zTCbRLD*PeuifS(zF)tcF{zy|ZPX*Q)136qvz4@<5%!pA|H)G49%1A%vuxgX=zo#^% zwtk;V=VTK}CWS_-Y1Lu4sC~TtR|AUTVFA3@QgnL@{+;_GALComS(LCx8-z`!;!$^h#wtA`Wh+NkLrZ$CUHNFw)EZy&1F4(Wkx4?Q!5nxUi->kwUbp=!^+8P+(&=S z|6O6WfijYk)WFU8!_VW#G=UNzEBUHL;nP4kN9xnhC3oK~7nA1@kauv6KFusUKqmrS z=G1Ryqcium=J_NTX!L4y&5*-vKvyl>bLmp$(*o9qj=gUJFi7-!Yf&E&s<#EaXjID9 zilE9!Us-on6d{bT2527+Er#dHgw-v1YaXj#K#?@Ji97;dSqt?`~LZ zM9_`>Fc_S=v9E)96u}-*s|t3=Q7#vw&B4ra<^lwW{>4 zOLr2{Pba}nbaz-Rn1*P_{fgJQuCMj|Rcz8=k2-uOXJ!8%C9-roa;}bQ$RJ)e03$VP(iXeikoN6` z8xH~W!%Puiph1)noA$F4kx2Vz!ymo_6E=>VeNS~7CGaT1UNkPVhV2aev&FS4Bw14- ztUcN7OcMS~4=sYJZe2NEFLu}SZPSme(r1PsPoB-@PEdBolDE3NK}wWb zmM51D`*l>GQPisoA`flr|NPPOf8uJ%g+$}>wpAky!u}+PP^SQxa(E!k5Fr6W*1^2i z=R4w3HEn3BCs?N&YATYCT5OIVHd6VN5q|^G?sJ_Ez!0^%LD70I+-^kCt49~UAey`G zgU7A}Rp;g<400KjTc9{B6`I9D4iKj!UKZ$EiALP)R&ScG>YA3J^kJP zHB*&}?#W{0x!TJG1*TPg9o36FLy7+_-*$GD`4F0)#l z6@Lk@U_B&nAoY-?uB1p;GcD;hoHLP1LoO&&zsyJo(Cb~dzd$r%eB-|}ZGPQKq~ScR z=hszlhs7vo3T{wsDfZ#Lk27qRwVKS+I6G+^0k5>p$9V)I^dzdKJQ@;a1Hj!E`?#vg zI#&8;Mb1^%^^=Nge@G|y@ZAEMXB?>3=&+0v+iRP3H~$xN&Y?lCD(9d zTM5tRIyCOv5Bo3P@2N7)UD{aKfx12w+2f>>-iBmYg#S!?bT&>SFIo9}r?=fJsyb`o z@mR^sSFFzWwOS?mqA@%t{mXUL#QWQl?u}~cZea?(IS2Alz!>o)OuHQ=Rv`HDt19KdbBR3))CXv`?XBJ*P=8VX074b@;nXUt-U@^ zWhn?=H#un5tM{%CP6Frk-l6_2reXXOKY@PdLCq5O*^YTeL zx@n@)pO;?niN}ezN3cR`VwfO5Y#s=mHaHsq<|lLs8&4@i5Q21km-4+4JK2TwDCX(& zUmdJ8oI~lR9jS3hCmD}cAH}-4g7k8xxOC&QVi-PE6|I)pO!*E{b=@FicQRS)8qL;- z$m=sSZ-^EEc&`|tR=-f7^P-kIY;$^u?Co>B7qr0HFe^a5F7AC6j>?$hjC`cff*|gT zwvI5A#-7>`$Sbfj8d)@yS0HoqqEYtUPr_%~NY2XnVbL(nZ?Q9K-(ULA*JL>0rM?Kt zvR(0g?ZmMzL~fBx@b@>gVq(8CDxdT2-kKxgP)gS>{6Bw2FeG>8kLsUu=Z^rQ9qv`K z_J#0=4#Hi1wG3gBfIBD7HCL5z%!EWsemg@aDu1;NTk3V4Z+Twn5!U74*X&oNoYvJG zpMke^)%&|MK_KH6={p;i0VWi)%fV5nQy{*<(_3hj)zR-^O<)Ps%q-q)tR)K7!dWt5L@HAdTN)a zESk660xYZY&!%UMVD*u{*pf^AsQc6UshZ!beAHB#U)Vi}$Yq><{RmVarQ152s_Y5} zaeseT0$FOAx%(X$Bw#7KxGH1d6trdmVEL~0CIBJ|_c>aLO5WyLHdi55sAY`hDv6z} z=H}|b@+2mQ^fqZOQ{p6xM+*~P6vL6u&-N$Wequ751XXA>&j!TZ)^nFWpa&Y7dlK`ib}#+8Or*w zTdaZY(f%wS^Q}r>;I5pJQm*@|NgTBBCxy=IAg&aHn;93WTcPvTEiY9msxe$mBs$eo z6iFdbIkiM{SXo{qN$vZa4EX0Trrg{AF!R>&t$z$g`B9ZiPn{F)pBhj#*?#uwcQYHW z5hF6=SHmWMuEc~@CO@viujM&orTsF}YeRx2Lf7D3KJK=jp3!${dCpbMP^sWZaAK4@ z>OnBF?;byg*4Ze+lW%%==XM4<* z7YnsrWEELPUE)QIKDBCA%?KUWm*v>o@?{)`QXGTjUS&XS+u{#{IuFK~vcYhDM7i)3 z{((#cv1Y#7_iS0S#z3rWa3FuL>;6>W`qU9|;L!kCeDAwh<^HhzuIpMpV!4$Lz2hI| zz0-bcpS58omPsN@%aMutbGFH;t!3``p}?y$%Va!QAe#tXPv47S%NyxDEe~2YVqBrz z>Q9{^Gm@_`T?R77Y#KWmTjH`tZ(~vIW3qrIt-sDgmh!=x>#}NCyNx5lcQg#eO&Wfc zI{bC5mJS%TQT$RK)VGcE-dqT0Y&5emZ?7*-Lb`34v@Jk$1va=}FsEM2z#TLmkGKsA zygDYf`xkyWbemTz0B(t|!mW+QOD{tZq@dj&)G^z}TmzJB9zxfqV;(<`BVn<8r@O_R zel`U6ka{vJ?K<)*DoOtM`e`te*&*?;RzWdvS!g@U8`|>8iBe|P9s6#Xb%tTuMg(QN zS^YJLXFq-jQb74_)(>LoihQ3IRI6-!qYFG!^sELEe6og9@s78duNbpvV!WX3V0P;| zb=*6M93_w{mOJOcE*+|QHoXYXt^50_Gr43GFH}eD;?dYs*;;u%Mhz4kPttSWxT^2j zpvJq|V49y1NOt@!H?DkZn?>{Z)_mv7)%2Y$q|^!*mO>VS*=XgXo{_PkD?Gprl{0&q zT)(e<+tpM@5*}o~(Ee2&95*Lza&Sv+oS+1@vZ9HBxvBvkqLmDpNz-a5FW4E`t3mSd zG<(#(C-^HYCf{?k5IxcnlV;^+In4ZqGHYhiyDBpanCK-FSKU4TZ*(+M%KpA79IgOM z8raFYpP*;|6tgcB3?IB&HbzI?pV@2?vg;J&e4mZr0I^Ej?7udi3^6*E1>nu9c+Mln zGvIVHkp^sMm`Qeq@#WiNIf5m>fD7U7p@Aa5u29$dRh!^+X)z2>z$;#?3qEUO3|J z+H$?%MpA}I@T+#5qyx+73v8zQ4&*d}PEOOKv)aQSZ4DXrr_!rYPZ}NzBV*C#Y81mu z6v<3?TvfofAQBKksh^sF8^rv@!?2-#|Di!3mmP%Rw%PSwRBmNQ>Kv?F5_WT|F>wsd z@FWN74RSnojAr4;D2}@rHMxi1{M0k+XI-pkvU-w~AWV{{zS3an=y>_TcIWQ6F1hqAVDQQrY4~^i zn|#`wvklRm$YP^B(LXH}bDs?eJ2R2?Exd4r3x{C8R_oI^B|pnf)8qSSIqcK9AOcGu zM8^v2q7IFqrn$xP3;?~4qe`dR#M;(Uq>NTu1vn+r^4FVMd##4j*HnHp$6kw9Ib=+!Z)M#UzO|?47#9>Ak$kov2m9n({5;SNzcZ}+fU_!ayDCTl$~8E9D&uu zYFiDH`(U_I<&a0!w)W1Bb-&;T`BOXKhx^`pC|kFs@+>PDi_**3z3SeRHjdr*SOUF0 zuJac{t2t2%)tNYePI{X6`Wo(-*+olEBtsX_+-F}l@YWhv<#!zk>-)KGFJz>8Gk;Nh z3*Td1Y#~SVMEW8pNZsCF3zHlf@`DwAT7n_Bd;-p#%GKCP7Zp7yLho$nxHAbDqcXnF z#Gc45eLM+$sOlp!&Gih<>RcITg3~;jc0IL@Ih^r#;%P~@Mni|iV4lzd{)r|RXKGTl zXEA;?WU;o}h?1hAfnF%W&-ig=e>S0uX>3FX5@Wsw@%lus?kmima@J|^m9fV>sRzpT z2?F&^&Y3#7YEEV}0ScSv_`7cTGt9HrNL2XjwTF6f+Nn9ZMoppJp8`DKl`zQjY4f_* zBA&$3=Jcy@S2p~_^8GE#{oPwHco!!X40vl(>~UrS4Q3}2zcF)v2}nEt0WXl9d16-u z0afZ)^SwKvk?PQTx~!R8JS4W`X__KBW%Bj?MUS?@I7ebnfUUSx8|0sekh+aJvMn%{ ze5m~f2XHgm=8IQ@;C!D!zQ@{53@3_s13C6hTB)1lZ6LlYM~8#*Wdz4fY0gx71t@B} zz%iQF=LviH`Gq6bgE&R_3?b^aJ82vrYi(#U_|^{p)_ENf0XWP%NO4+gGwq)OtK0aB zJ^W6buamKi`|(Z&%f=nzZQHzAiP49NRo|aldkB7P@7Oz5?-^~0Tx|}yuUW{0+X7Ca zRjrZEJ}AluX_a*sY|AU3M>$V-)H8D|l!gbMWxG;(tq5roVkfGH8Uu=%5qm(@CYbZ? zH|okWbT(mkvP~5NF60&v5Oxep&zHmLswNofFAAUi_>G!y@A9z!dc!9jWl?n-fH)YB zl&^J#3B$C+qb1bxu8e-7VRxd9V;e*b>yIS6{+z0%8{;v`>m-DAR=a^tO+E~v-dSU# zY-#R0KBha0<|Ob=*y}wm&1{=>X54I$^oRd|x8FG{qGGc!!{z`>?Luc|L@Q zE8?j5ni*efiAmw85|WGno=MByIRa|1)$(q5tTRu&UU#0yd?$q$)dI5JUx!C{CV$X)Euov;d%f; zK)%20lK;Nef7+rOFML->B)T>2X#UtRM*_m`7S}v!4GnL+N3E3+Ul^gDz4ue@q7k1! zhM`WZazkdX$j`}&1Wj2}U-r)gJ*gf#+Wt1M|0g$SkD{6RSx2&SuPQnW<}|4tgehN- zYkYtM*e4@UV>Po27XeinnrsVo1UDOXssQ8z3YM#wjJUw|<4PuqowKI?2Ne>#MPZ9Sq z7V$KrDOEtq4bOEZEI@5v)h-Um-7G}0SS%zHpYjURWw80x-ILh_P9f3nF+1^jg}!ka zme(Oh41VH4L&$7G2RiS4w_V|W)@93nOu?5vGn;>DRnhZC2pkYOm1TI6X(j7LCPdj? z5C!Z{;d@-0jgW-(1X5>YX`)>JUEe=W8GX(*8ua&k=8uJVc>qS|g5{H7$G+aSJhldk zTkG$fq2H-bpEr_D&OTmh(8L8(vCo7hR`spH-3uUKEnOOZ#j8LB4#W2@Es7B4C3DAjXz4ir!mx+PWL0rCpuR<^a=26%aW#C>%E8ZPvdV<*oVT(a&CbuITTf~ zh;#KusJtq=4GDyml6>=S;qne;SqzSDyV@G4;lByGHaDy_7xA?r&=V%ivs#;ZTPXQ{ zoX}6#caE{Xh_e}|dm7vlhz(l0i|6Bhd$agH+4@+Sw-p=FnlSb&ex;|Ha`;empzG?h z{qc)Q+$9APL>+=seHi||!e&<07CgVW!Vw(q>P&z1TYP2irnl+J=%k32z}ILs%K(k? z>dmujGT4Ie6N7r0a>&Q4X+kz+v@DoovtM_(U(YgO>l>+kupm`Jv79Y_J{bb8_>r94 z`a|v4N!$<$o{G3mA{k@!K91|U>AdMmi8v;@fBUnFt6eeLbBNcfd4RDHC+zg9Swd%2 zDQAMJ81ie8JPBebE|BW(s^B(FezU_v5N*TzmwbM|-n!+N)X)3;PRdM6r+P#pdho8lMI680C{fp0Nj@z- zHJyN`RNvfK8ZNObod)ar-K2SP;yBYEs?nx}!7jhkAhYF+mOmu3ocl($X9cU-7t`2W z{NIgIpZ$4AwHJy+4R|-=`7k_CYi9W<@5Cbczck!c4|AXKQVQie$6b}I;*yTgd5wL` zmH~W8nR*AIR{m?0^=x2?%vud3e`HT-q&Cl{Vf2)f(VEKCtIjec2}1^gSA~+8^S=S~ z7`pVZPI*TfZYaa|uH6JgMsgnPoO;Xh|#JpNI+2ahZzDi`hpl0939(Cxx zvZ5rEiqfEbSm{p|Y$FUvdnE0=gcpC;-%z1H3*TKCGM`mL<*(+a{on!Y;a?%f`6%k@yNjNSF2M(M(s&;XHe)*7vFp z<$?emM)IPS%1QOpY{$rZS8m>bua`WP?RHi7z`;n%Qua%uj>hj{_BvYrh|ReGnos}Rxu~S%)Du?UZ&T(2(8aNQt>bUB z+JF97fIqZ8hJ^2VyDCtaic*YZsOr_i>bx_#Znn8c86b4hh`%Bwf@8ei^uG$7x5l{c z+fS2wiE(VN_ZPq9#qV%eYFonQKEX9{$b5~txZ45O`9e@@yQ_<>H8xU@K&u#%i=BSZ z2VQQ$sG7`*Ryvlmp{~q%rRm&p)mA(@x%PfN^OsyTdkjoi81qO4e%8jF=yq95>;s8z zxTX(s0+6xZQYtFKa>Y)6#4Y-?=VuV*6L!yk>HAn~K%A~0jJ8}lpE&hgRaZ|hN+2O~ zF{{$ikgfsIkF+CN=H`!!e40b&$r`c_-1i+SUSWMH&&!QQV);_`0{Y%H+(srkp>pzg z>NZz7Xqj@}N!x2+IQ$)muTwHsnX|*(RVi1_&zc%M#$V+Dz5y< zP+3Pf4mFbL0sJ(B*>__1D9d^C4jqO^t5b{#6!>`+kA)mYB7JD!rgrFVMsX^Uyoblt zn%QA1_HzA+bi3+|%q&o0PlH=8ygRMRov4^bA(v(=ALmByyNhuzhX#NWvi@w;k)#07 zsbA!@?^*t|55vaA!SLXkH7w)`4g>M?tmU7`!6YIR;hxRkas#MDGkMyOwe*aA*^#B6 zvbCPJy2A4WfwI&;?h4x&aWlTGDo;HPm8*p1m$l9 zzWK+e+)%^Qe+S^EGr1cLk>VmW6+`ykLz~&cmY9?L{m3gzvzGbph@jr4s`2iE<;a|x zb)2bFBIlYEsg1}1SeZZU&#$(V6$$l3h(y}cM6hT^^rYXDx2j^zoLBl%>h!kzX3N?Y z(S!MZH@32~Hos8bsIJ@IUF-UCe-)cFL5~?8f0pdy7GM8HMkcS%p7U##LaUb}fD_K} z9tEte$?<0|QP+hUt_Q@C+_+I%Nfb5PM9}{bK{6Y@NlUTFDUTj@r3>E>sf09N*Up2Go;6Q8%l6J6{K0dHH+-!RrAa} zh-Re^-(QS00jV8h)||jEMy2e1{w6j3UL!!3D^;iD#JU38?Y|@I4I#O`!S@xgtKW6m zd;IN$BKH+PXCYXjDy}(UFfQ=BstevHYdc#iCaU^GEd4=lM?(O?LF0Eh^u8+dq?kpAA|9Fj zB{-!>EZ&}35nUs`L_Lpb$9dE>RwkT&?q5;b9=X?K$KT!E^Az=;nV?s`IRUO+5eD7` zH9u;YpQ(bchp0}(RkIoU9BtV67phe6NW43#&KsgxHK5GsKRr9}QjK>ev7D8CAK$H; zNq{pYPnz29_sqBpoIY7%<%(UYVxlvD@8_)$%@D6Xpv0@JRjHDs9`Q=EDihC zKJQU|irUTZC*-i>Z2Ml)6Os!$;WnlAu$>Z1$rHcTG{7l1e!G)nHCmzFO`P*@=6n_9 zbSG%lA^|n6hIFGs8l(AK+UN1OIcJg+kL#%{5oAqbrDr|oTLCl*^zr}kbvMXz^U89r zN3>6nAPABJ^8BY@JlWb2R+W5g-|!9Ve9~IoBv3z@Z$1GpG=6Ss2`~ux?ssG0S_j$} zPgA-Ek~aaRAaWi>j8!Vm&z)j-Hb#-WPOvSyW=#IOYNZE*;k05fi~jB!iU1X&R7ow< z_DE{bm+J-xyIuISn=?HB<7nE`u#L6P(7VrCZIx)zOQCO1-9C(GJsEW(1vHWWl_1zN z0g$^tY-*f!rehpeHcoZ+{sgRy%<;DJDe^v2knsbZG#a(5~jT^gOn z#_Xp+gY}_R?%}TM7%NP8A61~h`$LHix0zBRZg4W)J(tL9&w7isrXwhGo&D>6p7mYW zd=;CJ2qt>x+Zgyc_p3{L%+q|N?KyZTmJRtvAEs6?B+`8)xLaR8y%=|(AV-Jghkwe)7kp2 z&Mka&zu#fZU84!1L#~)HIiqXGh~zJ=b0jLXMze=JuKSe?|ONM2yU!fG|C<3lrm!({}$r^pehUeAJxIE+gCp&`% zZnX@*$xpqS#ECk?{7GAPd1(LpwRyn_Jj9eV{$V=&e(8?hV{^G0n)ODFi;-=^Q{4=U zA|^!f(ZM$v)Vb-_Osr<5KzV4We5DFh;RT=DK9u&;vA?imwOH~*8}3(49CH(NQkgKy zM69m|Xv}vD$A@j`?1(7;fg2z{{!uC#jzOYjUtw$R4&=Gt%ot#-1q_1&cCHZ$ovxGwo)%>le$aY}mwGXAo&rYe&PB#M)oe$`-}{nNZ7trW6touWW| zK_^BhQFpfi#$$mx0BWY;3Vl{Tv~->=_{<1$H;r5kcRjQS^oc#=^_B?GiofXu7g7B@ z)r?OWJHteq;TG5Bjr>0>Wt`7)ve?=Lw52kct>AIm!3BQmMy%j+&GJz`czfU9{?DJn z=}Jr-r$=j|&m*PVa(q;a=KhdGZQ3{6lFi0EC8$R2DPh~cANUCji!v4;q%tiYvj&gG z3kvnNF|uUA2u9zdeFfLghO?*&su~aC{yLsurQ0ydXzZ06$%|ICczS1$kNqq%yVM^z zX`}nmpkQj%_x>2gv}M|U)r_Ho0PRWy92G-dK&aPez-E*{;4*=uYF&0Hx=fQs0uU-TSH`XcAzUS^^0{7sY3+>(neXETvAyY?fgsrm`{mbt+k97O&zScpoP&A?X6}a z`uJK;;h~QXY0^Z$bp@T;ici3LygV8C=h>4N9 z%Toaq!PCd+@ef(^NgDQL;S&beY+`p9Hr_oAR{4=xyTXiDx3#E63o%is)D~5; zeg}VnSL(~DTN9_u?Y+c6xvRGLNXUTRn7Cm!&6AZeu|pIQReLhHl!o%^Uw4WJPF22} z5ItjUyG2NwJw|8Ri1Ep|AJ=cj)lqupnxV2}uic_0ZD?)MAx=7pHf9uZ*^HyA%IDT3 zJl3`EU0W1QuV@wYaR`i}9}>I`8Gb+Ay>kQlPNd;1dIkNX%M^7;Zn(TIShO#jwLzNTpsSO{9A0fZ#}>fDhV-oDzkY zQTBM^NrFEyO0MQMnF2srm*1=! zn*NicXT<;o&QjN28TA1)3NNfp29>XwrC8BtHUchFfGX-|-)a0+t9u-P{X@O8&rI|5 zXfe;x^h^LkBX&0zVM9j-?dtFjFXnCnKR{rEUip4^4UToK5&=hTQK$9#JbS+LNL$x1 z_>_<{L4KXeC5FeSUal|@Vm<99iycD}57jtZTGN}l>`KvJsVU)Y0YMNv*Df=1GB;g< z$L}s_!?VZx-~g(h2l5S`BYo%YSW2%?aK?S@wtVZBz%}P8T*-be&68XNLvu8yI_Y0; zv`!>C#t*gpG0y%{p@KR$R!3Y8va*h5?SXn$t`l~}&RC`6&MT1s;pf+gbKTsN(Uk0> zuED0!5%$!geG_DARw{t?yz2z%A+ZV1-O{=Q6|f;2APJ)I7c^FzaVT~6>?!mS6UM_N zvY-_}G@p8uWr%;2-&S@El9N1^x5Yf25}vL&uSn0U$o`iGDRL|e?wT_~YrYRHs+bz< zJDy)WVj>D^f4U18N>m&73HMaj&KEiX4=W<@(H4)y5WCa1&Y4$ps;;cM!l0w>t~w01 zERJBi&GFTx_DeJ_2-U~&wV5eZx?DzjI&z^IxZ$L)fKtRoz~yVnSz5!{!SB2!z9W6tT7GK4WnWV)px5mq478+Dfrn z*+08ZS9N{=n5fvo$YXZ2! zi>;2Jy#V~ktzoLH`EP)^CiwL~k#FcB;H-H{`|drH=so`acYnX~6@HVwr14>O*POhF z+Ix1jW+zcZj1j)e*OUPd`slqs3^9(+RgEh93|!0x;5TLK@H&j-OFX~m!M4v*CN7YH z{XPEgyl6OG*auUgK73ookXaXs@-ZJ~LBr*Uzdf4};dg&@j78 zeWn>EXK_o{;w$9w4S8vO6_5M{i}?}VU48sU)0#9m?(Sx3RL|@e(hqhvU5tmzv6mpS zqxwz-GVZU2$QuRiQAEgg`2K+Jc^-Z!KehOap;erdJg?pEmb~d z#?Iuc05g6KUuYTbZb^{VQ_+{UQ=F-Ect;D}3;vJ%+J||xJK~MuQ6UzXX#0mphMAH% zciYse_~XY+3W2u*1#p$}KXE&Sj<&Exa*H%saLd=0fkEBKV&5lGV_!;SA21# zysTZje#g;;tuIubePQandKRcvO;#fWYc}i{#@ThhV2&GO!vbVi(Pm+FfWDG@`6QXk z9H1pE#bP((S$W<%apHu=um;R{j>_n88Nif1K;viaw7-PRz&a34*03v?jf`KrkQ{xl zZq4{a#2E~7DdQPhr^)+1lUUOmWkqYjYP&dzo4#RD67enN-e{X<13xVhtj$&7>aMl} z&5Pvo>!FTe98oB|E&iJ-b!gUZsk}wb6Kxv>I77twR+R}c2yf|3`f77ZFb26ZFIP^3 z(E4_N4G-*eRf<`~o+pTw^}uXDHCD8RVS?pL{bOIcqCrpS`qvOWmib)#C-iFVYGV$u zrCiitb=xE-^kOX&h18l}=)e)WyS>8O3a|Y#(AHhzmA-0xvj}(`#m3P+e~}Gmc#Zc{ zqG>J_!DCrFqEv&yZJz3hU>8ML3m6A|hbfC<^R1*%f8DOu&xBX3rZaxbQ8CU=Yp1p& z{cK&;F}q&4-pUJ1C{(l^GI(p-H51HqXE*qcmT8j_!8p?{URRav0z3p%sBAy#H^t8a zv|%GH$+00^#C4>Bx>~~DOmMH*WGD7j4=1-n2rr)8RzUfGhd-U-Kfd-#8=#YL0OJAxfIwzj%K7<;|TWc{|l^@AXqP$8J)|M|MacTB^-RX z80zDvE<$YWgR41J?=@X{f45}B3>zAnY6elIiEQACIj>Z zug3K=EcFInmYq!r{76?h%^pGHXOr`*;y-(1O62+y;Uoafr$DLQkp<);7##F1wJ9S3 zZ&kL{(H5&KT*KNdvX0^H1deRm`8`&1RXO*;CqqhmxG|b(8(?W~=?MV!?&Isz zRs@L)X6vi}vR2*Ms#%oAsO1O{k9cJIlwZZ^Q67L56g zh01u${0kBO$+EjDoKH$Int#<%Tefg{$IM4MQtfN|R6wnPeWVm`Udtnp+k*c`VE1Gmge6=mY$KS* z>LYqm0~vnC1>SVour$Y?@XxO(2M~{pv2v``TP;=`8*YFh+f;$k;qF(kwp>>q7og#D-gQ?)L2g~`I9sE&QgAmqPlBTT zx;M`1R35xO(Rnm%okgrSDTM6V(#}s0 zIqSdb*8Yhdkp5I9&76F~N)pu)1h?_wf%2no5J}oR@d@`_#^AK#&4Pw!MsXtiy>VqP zGBZGAk<)dDbUtCppJ7T?CF1o{u~~c_UetOhn8{xlTG$1%4+V1rih&dNUDq1q8vy8p za0QS;+_a2z=anhg?D!=MGgHYm+B`|_)PD0OO~Ny(4uX1dsj9K^_61+s2}Zq_;YncV z*-~2MbKMELe?+>sUHO~x=w&i^n<(tQV-3@Nwe@jCm6>%3aXK4R=u1f4O4Lh@)MOmI zv&LV~Y-!zcc$nGtcPp@~Ooh*+bb>=J8^79maRI2>1>c-)JeJ&wlih;Pvo@pI9J}5c zH#afhQ#Vh1Ro{|3)q*kxwykb;fC0=nI2Nv!FEE0^M2Q#HKJIqNy=VuQ z(}d2U*T*Vjo$8W<+a%<^mHn_A+ zKAzgQX9~UV8?42qf`qElLnZ#;ZvAuQd%u(skzTXsF%BP?v#*!+>;rihql>5-a;$Hb zJPbI;;#-nWrUskIVK`K)=AJtrD#icQyUG-kjxkN8)PcrtHTukiQb@>C_N2FQk#hD3 zeWb6wSL}Y7qE{uHm(+o{ex&@C>TMNjaN^Q@t%E8o2c+M{`z^Tb3fA+58X;4umA z-%2)0W1yWbcLpN;KtF}ek3IL=GwOM7ESmM7q;IPZx0hWTc~X{&!gK-L#nk#2D`#97{N7ZQQd zY>VGup*XGH&{*rZe@GG0;a;=aTCIW^Q{UW$ck?!#^y|xl!&0GMJo}8*UwcMGw>MFe zQ^B-x7v{;k3B?`pSnuQ59%^9xms3~3x-#VYlnOMY{ zr?dnRJa1P2Wy@r8c1!u0uBNxKFc2kn+h^ogCLA^{+7m^U#ml0z59YY=WTVS zYuB;$Bo(!bMIZyC_RF48+cz`|3w@3rF=KTqpMY-)TWIGBQt|Pw5G)O5nhxGj%|v#8 zCgO(PmNOwGgvH44t|DCCqz*5!4WsdnUe3dZRbijdZN?S%#XhYPp-`jiEjU9uy##*D z)&AQ@U>4V`6RZ|2Z%RPq0a?*cJJKe%=NTnM^)ftvc%WHTRDaV)IJ=96rw^`Ry~E$sH7Vb4etoQGg-|)GLe$wvLC&3 zUR`kh?4z9opD3)__1-X@F}<(9vI2e^=7*Nz)<1jV8*eV2Us?`2`l@|{?Ga$}q}Tp> z{(6wFYeogx#u;CRcarJ)zTOm;w)>20E)rqz*Dk|-PNwWWn)+8Gjec}BR(5ym>4kM8w4C<%FI-FL|iNKiO zQ3@%5G6FHvXja53{ui7!QF-?T--z!Y)AGI*{8-f)keu@D`d$Lv>a`ZIzAs^Jphs0y z>o;0;P;OZg^#s5dc*PJg$?YqvZokR&2>}&>Et7O6v1f7KJ&oK-A)G|Z?_X;~uM>~O zSo61xF6$mfBB$-2AnZm~Mt`!Q#8RwrZ;ryl@q!Tb7Cncv$G+gqM&K#Mz0%o6g{|7$ ztM;Sn)#Vrkfn04nuCf1C;^dyX4aDBM??iE3-o;84bVnL5-p^Sb3ztfgOe|LXI-DIu zt=|P#r-7)69(P_76NR&mgiUhtpC!nm9@54XPxiSh(}6Nv;Tw?IvwlZRgVm5ks7b{+ zw!_kXk-7X|8}};-5lO2?2v`+mc48;)6T%5ohdK)h<;+t(JMDyfV$crhV>%FcpHc{! zuEaWtj86>LiGuJf6M>m7B@VRc#aqh5 zFVJHPTff_VH|GmtL1=X z?qsoCVVIm}o7!lrrjrBe=xxFTIB*MxeT%(TRjq3?@ORnGjt%jvUYWh@kENB3D8ud< z&dp+9rQtY@Hm<-%Fs0ztK@;BE>2WlvYtsESWi~Z zL_u3tjYh0jZn49jHmS7>UZ<|hQKa_i=x!DzU(*srN166(ud8C6R6;j=efJq(Uz4*B zSO7v;r|JE*L89EaB;oRpH9n%B*p4E(l<%m+K%Ib9t$K2`svieh6udQPkHm9c8QKuF zjFzPF1$?SN2P~`Zk#Jc6p)KFafI=`Fb;$eHA%g>h0Px_kKUx@y$7a!KAm+D8FB8oE zWodlemTg_@(#}j)KUzG(i#*mt|IFifTJf9MRt^cYzprJ2GxEOlSCD#Tly5(LW|(KV zUWK7zvNf4V+=3Zt zzv=b8<62rR!^z=_$!`bPcJ+!>k6a9zO@6;iBmMko-a0 z*}_>x!%-uWhrN~frk*=j5G;2U#2f+KebgmUo>f}5<*&D_;m{<7g{XTTwM`|Lpyd4i51E%eujz`OI8wk^1(je}ZO3QEf z2D(4j)zFK4Owo=I7`;!UfQ$QvXT!mI>O9`q`^jiCQNMK^t2a24;d~uelbp^Z@DSRv z$~B5S^0~Hazy9N{OfkuhpA-Vy&pImNVST<}Ea_hAW{VhELYsWp(Hv#;VQ@sw(z9YY z6VdY7K8~MHyyDou0RJ3%-#u0fpAFua55jh&9ZvgqSt2j3ZJuVaOM|=HhL!)mCNVup zY~Sss+IHMEsF&jZhq5zbEsym+WX1@kF~j*6z9%_p=i4 zXdIs27O1o`8|NRc>eKnA54IFi z9@cB}R8S?M<>OLek9{lK{PsvHDt<1U`pn5O7pkI2zndb;T}a+C0e^}gF#}O_`A~d{ zYALa7S2PRCk1`dt+z3(WNn~c(Ajadkrjq8HPtU8i)jA%;tG^Qg|4bQY7Wv7euMz8! zDtr%$t>tq2@*M^tmW>?!eM>N{0oR|yto+!!+1UT!b0kc=zMqm8S8Shr35|@4QerZ< zzvSnt<9fsq8j<_UCZVpiKYGbGuI??%HbyAJ>E3~L?eb2B5^3X); zQzydW>^}bNLB&y_4f{jJ`+ULYuQu%W>Ko|JW-E{vvhp6}C_G!x0{W@E?->5eOn`ps zg(*O=ZAZ>N&DbmX_sM>mGPF@ErCi1wt-$;UM**#GnfFvaXtnSoJFfx5d zkGsma5a$=*IM3`~Nu@b(v>f|r_JmYb@L>}l%ecgYa`!4@&%nPI2F}++c5IQPBTAU( zsKefu`UB<}qOnQz==QWCSz+I;em3jhZ%l9>UtM1y$OHS3w3OOf%qy{?XRk3cM5nRc z??9zW8L5Rdn0 z{xzdFsxJ7j8aR|lKKuK`YjqL;5nbw=v+?jvaLxa%4_ZET_IJh{Z=S3s+%>dwr4Z(= znuD|0hb=c?I?AS$xy+jL{msa#YT)Nhdqx}!qs35u*}Z~xo_EVeH%k90e{T}7vaDt+ zWVAoR<7ymWW|BkObT@?McxAoW(!AkKHYVH^ytwc-*}ZSeZ}@rm4_#X=_YQR$l`B2j zGaHvvxRHeMM6?{uh=7_(_0r~H>FndgD<17x3O_W zZlYovDM=H3D>Kx}o6vE<1-FD_?b?V>(Z03`Fo6cLeQ<+66|8%jQc}eP4q3^OazChT zzCe4bRE8Ij<4jz5Thq%r+BKok&3^7H-Qpdoy)M(SvXP!+-?m7p>C^KwOJuBOA%er3g9_L`Iu$NI7a%euDU2d7NjhyB9I% zlmE3ue%H5Q|2M|9CfSJ0?U{`#&2z)aRt@*3cl`S$;j$z5IE0t>G600PeG9C(Nw5Fj zcvb6h2_6*?IcU#p$Rs8wt<{^uzP{v1P(Bk|R(k?tS@xx5tRC&z5y9hlCuw1iFYg1N zNfK}o-|Dzv{Ai1`uCYrtR?ic_pxSO-J%l5kF1yt+>U=b$zyvd+P@Rn<_lwDdM;X4$ zB0W(Gftp%G)-%R%)}# zZ7K)rX=>PT*GDoS_7hU7w=2@>$J}n7ZN#zmYf5u&;Wu@LO$Y$?4iZ2*R^8u(zpcT}Do zPMSY=Kk^x4yve;PJ#y>>ORCA;Az->F=@ehSucIwj@)dAO?5Ctwd4t}8PYwv^X`*t$ z>x4BDTWVvczYQMWG^5UC{F1IaN4QgMRSB=f*rode5`3!%Vm~~~Q2e4lZMHsgiv8qH zq>n1oI!vW$EhIAOI?F+Ng^W(Ru)fiYDedv`hfrI&b>L;BrTaCc(ws%4i>Rq>`NErc zpN7Zxnt|8JOB(fnPsQ*5qp;eS7Bc_fc_(I|&npY6D6$MPl%y|2_p&REu z+QQRov4k!)z!;O7`oXIFsu?qcaiXXp;h+0|2S<;L${6Qq3%X;P@q8CB7tUcbOy60r zd=eGBC2b0PyDM~?2=(>W{!k_#QSiI-o1=xb@0aT&9b>q?O6BFM7>*RTZY3LJC8#pH z1LJB_-uN*_iu$kdN-bwTp}HS2UIoRUUdpku?F8_uJi-WAEuST3yzM%n3A=4um2~w$ z$dGVFP0UK(B>#RUSsCEx)xxp@q@+cDty`+BOy=zgi2Wvby=GNOO|Yq(RMIuwRyi+5 z1HZMdf8hGp`)45AfEiHg$&!ls>8WAu=>^$svx~1nI18oS8^b}Cx;nzhmEfT7zTWCg zRtfHZ_cJf3)bL=L1tY$TsHmxiIP4OvB>=Vl#qO31PFzGsEsQ>aQ6`>O&|+;}ef*-@ zZrI`S)R5Kkah-1xmy0$U8cE;*0quFeeU5O(_4N7z+u^kmr}!|;zh{eAdVu;9V%ahT z{X~q3!xKAO%HIgk(Gqpv15b;tgD9H-EcSz4IsHi*Q_N={_SWW=^W3Xh zuU)7%C-!ZKe#^5bf4)W|LQXgNCmHjuBDqfm8ci>wSB+ZMVfur8Ak1pho1~X#td#R_ z$Ks!;BIX61jfqR0cZ=svl_f2@POs*L|dL zu^&3W@;$Hxql>t>^lLTK)~Mw+_~uWnn~C|Wmo7s>u*tg6nj)OTp|&+{X=9Waka84Y%T z=M~mtUA_d*z!A26sd2s`NWhVAtM{W<9fFIV?`fzerW#@hSwN$0^3G6XJ_d}V0SN&b zv%hz_)Yz0WbB6P$`!yV_wY4XQ^G!B(-7hlDK~ueaV)pv2$GrMn!&T$NNGM`3-6n zmw@?QMgIwz>-H*ugCAM&d%hYwIRZXA0*9|_?>ih_Se&ngPI{y{WEH-^vp1SIMY%xZ zv(~;`thYyZy`5k6d{#&2f004`kdaNZk9?^1mh3*0J-TaE>(08^y>dfo=%PWa{xJKV zv|^mvUvrF-D1HA`To&yiGt^wUmD{Z1y|W?N@8Y%FsroG#$;w8uA#Vl(F$>hC(qVWp zYElbeYBF=a@gDyuoMZ=>4iU)Jg3nigC6<)$QRgeHnUJb6Nmi$$^z)^yCSUv-vWcxe zjkvYKRhR21G8R$o7H{ESrZsZd6z^gdW0vdXJT(0xd|Jb_E^c)ZH3iydZyP!t{>yy=WnbDIJ5mBU z-xy~YjO&|Bc9k^p>s_@r(vW>ny@|P+_59%R(oCHDUvEvWuxg@Pd(G$R*z}ZRw>B%9 zwU&`MGk+gy#u~U1Pru)bu9m5&T=+s$=#Bhre=XzxbN$bZW1Yat<=mqRz5QRvwV8Zf z)rk^evnVyxva<>q6U6{>vx}tWr2k@8ARri3!hRzj@7h_z`tZS&mpS%sSXWN}`uGG| zZ8@_{4VnY(aQc|7U3qI-aqN+OqwFVsu4ecPsYL3V%%-#USBSmnD~gataF9%MamJoW z-ne_sZXXg|jG&Nq+w1t{Kue3M9X3m~Tnv=s)s_e5@Ta!y&U~Q0PaIW7D~45HU8Nfb z1o*1QpLGuM+TJC!X-4^99Z=)}M#OhQtlN zw1GE*b#T(NV)<#tPbw@DgzEYmhC&Ps(~9A%5=@~@hPT7DP*;;tm%#su!)?(jcZ|Z1 zYQT$LDY_A9OpX(2`$%RrUI1D(i+eubl3&O=kCuN<<%q;Xb z&Mq)K%C#E1b>%NMyNegCOr%T{*9wBm{nq+ap=lmH|M6M~w6m%OSv7xUs58P0W35kUII z2@vwxY0SFfF$BK^8i4$~sD`5;Bt{cbX@ldT#cs zx&OD(T;c-CX!gyB@>cDiUwwzzq4#m3Ek2J*&pvHe4fXeXeN?Q$1ZMOd@$>besauWYJ@tosN`!)Eer`t~b%>QuMMJS%6>em9b%btg!&+W1}XiJA0p zRJ>ZO4f$q?idqm4NyhpSv_zo4ZSt-|yAs*-cS__Rmym!O_PI9Mu&ar_@~p{XKhHFD zK$;gI^lHVI;>mBr5b!h+9gr}~<#$D8TWwxQR|D&nOQLC2ZvnY>_xZfu<^;a1{e@*z z7Xdf?ep;`D^?Y4PjzCQ9V8~n(AdVo)VYyXNR|`Cu$>yUux?4t{O2!m;ANO4K=c>i3 zE*9$IP6Bh^KJxh@&(wX5!zuW+M$ALhlP=uczEpi&=qs^%w zYe}$wW7Z;z_Ayo?dPbiUT`I13O*Nt^j$v=z%~g|QH>TZNwQyBm^!X!h54}>!Cm+Dc z{6M^mHHMQ27I@%aB07XNi0==K$@|PmC$|HdKKe|xv=N7RTkulhF95+4x z+5&igj0!1)K@g zz-Y5d;Ow-q)q)=?Jnq4HENXu}1H zWdrB91a*w|{nB#^4*CJ7&I;_M5V-OsSfzIWIhY{1=qW~`n@4vocg^$?ZNrp7<)O(@ zn0+mPQ#5oSO<~g%6N6bL{p164-Y^{o-6w<5<4kIw)`(nWlIX8`o@E;n(L@aSRWWWm zMADc3y-IpU4nQXBv*!km0NlwMcwBnL&LSeFVE*tWHA6tf@EdksZwvpm68ly9@@MM3 zArCnX{g@a?axA~`15v3so8dXPyz^UR5okKO`l@=sH?1pj zP1TYM^PudR_=vNQsU$FvBbo8tcazWVA1b1%SP-Sdwj|B#{w09(?46zD!PazIJB&)o zl9(Zxf{EyB?W! zP>7jpHnqiWns?bx%1h!?iRol7NghH&SPke&v+d&$M{9i7B0v&zN65vndDX7qFxSWh(xE{=@Ads-07;1t)w&|utzN*BfYg{zuwZ>A z&5jYRc7I&6VFX7#$)hKWF0C`*o(MfbsKsjhTlL3WvgcF~aB=>>Kk)NE zI?yj)5k2+)`A-x0*tenK8y2y_06jp$zco?y1!F ztoF5uauR;@gMVHd?+-iJNb4@J?%#!?zg;q#j(5}z`I$_ap%4j{u_1O&AJfHXkg%>Q zW;t@=gA-lzZoP{50FxOBfVB|bM@_;?czm^e#&Tt< z(e3UsT4~EX`!%Uouv3vqw$dhTc2vhbTi^?8v^&y)V4|e}58^-S!{qp$ro$ zXcP-{KeK47*lyKAKmqeH#rwBgu&OAudBoRDOWVqnVCBzT8E+~2R0&DC3Fx<&;YQEv-hRx4O+YOOK+xCVvfBn)SFcoWHnfO&`qY z>udyai~5#B&HT7M&g++~&ibkuuHZLM02$Rjy6SO7lI@}SyiYv<{h=AOA>i0+_$~hh zvAQbcYZ^^Y(~6kndG991_fv6G{{on6zIDip`&NG=9IWcQX>dY@GJ``X#WntppNe3u zB{XSbUNb0_+ZbKVh%)NxSVoW)`RTzFv?CzsX)pIFtJ9Rh5B{BHWH%!M2ys8dy9V1f zZyQl78}|N#k7>wcM0vg~hukAe`Qy&|JLdi;IkP{`SNYb}5{a{$gWHGkzHdco49v!k zuOVElUkx)jN|iZnY^~3_KuB?s+ReY)p#gqqnd6LNS45k_pwYVV$DvV z(Fzv0rfEbgC9+p>*8DNVU+PUa#MeGcFTK7fQ?B!^6k=)bn4V;An){ZMI2}m(1$XLG z#s#jykyy#|tV=vLKD-4Sa(1o;XMAFq1spr?=3CANl?csBeD?oXkDCOb9_~1UNyJrF zMLi7%XJ4s}#BZ!TBD+%mY~Ghq{b3{NpVFPi_tnf3w0nL=>zJ0$BsRuTWKS-dxlPCR z`R9WWe~@h4&Pv(pcobfpRot$idRjXs-f8l( z#&I^~G&a){&46CBBL2BEGy2qvwyEpBupZ(;74mmA?CxVT^>8!ax~MuiIX$d=KkKPj z`;$YONSK}OsZic%WFAa6iASTRaWp^ec%asms(6Yd@pP=}jChj!JA? zs6zm5?Vh}T_sx;9Yy;IIBcr!qyi}P)PT$WO|2wMOUxX2ctv=>#8A@6sE(i3@)rRg= z4A*5+NX$Fh`^Srdwy#>L=^Y&73iC6jy2>e&68%y$2rc5i6VRX<1h3bCwb)I(6?Ovk6+eK%KH?&vtrjowN~s=C9+cy* zGr32#A=b~ppwa=fsSo#rW3PpRUrA#?xYX^Ip2Xx_pdT59Q@*ia)92W zAti!6>H#g>hwP0ix?)#x`ng5;2(QXzh9=#`!5v9O1>RWHxhIY0-nUvO^z#aW3I5W^ z_xp?A&vTOIsJZVJ`F%;XKqpo6RPy}UI8U=)X%j3tyv8fMwt)k?&z!9qRG<|x%6O8Z(N!?9V2lOD8|#t-BQIsFyW-D9rcUjJ+|Tf?9aBr z=KfQ+YFyjA5oq>`Xi6Av`@r@?_lfAUa+E(-zm9Vy9P*8{KNF%OC1PdK`nk+X%{rS;Kpo^@ z3MQ&fog$;dXe2c{o>sXCla0q38lXJV8l*2`65E1m&8EZA37Z);6O#B%athw-b=9Ri zfgYu#2;HxdloYfMcUu{$WurzMkcLKy`#$xNBlCDH#zt=2I&o5J(IXR}x23yN8dA%f zzwSyZ#f0-4%g5~olt4-!^%74`%Tly-BQLrmVjEZQ!q>0DJEb#pVyqw$&xhjYt22}{ z7T7bt^VJYwXU6x|B$dvoam*~))`!odRJ@!A28uIdfxN_6c7L_uEee%@oTVAXU%&w(*$=^hZM; zLX6HM2>X~z+BD;(%taJ>&X(4t0_I=Z2NpKKtYcrr-y6x*C?hmC0zSC(3et!cG-0&% zyU46E-m0kysqtquoz<7-s>ARx({4*wSs!*cDdzs}ASGY2KF(u=X5vu=9@J+%MUSO>=ynxxYh3mu1oz zRI)$1K0;9f}g4JKZP=1^LOoEs}@n0i=FJ?;{jYx z+s;JT!ufbs#MTt3J(470vw!#KKx|8YslRUYIJyW1+g4rekC;OnLwEr{=l z*dLK+oK%t?NOZe<;sRx1+!;V*1YsKYge08azP|{ZZ^sTcK9o!i+HHDz?TQt>rS~V0`)w9K2)Ezp?#UlCQ$Ir7~Jf&{NZD# zwt|$qn+10Nu~=mJtB&_Nw~2F`=6pX??^y@yBpQZXNzAF6B)p^DM-1iuaW|Oo7rE5_ zMNNik+kHfI6uBLMS3Q5vGjc~IkbH}}L;Rs2Ga^#n;(pI-c^iAD)%lsuT=Q-hrU; z?{)(+U47Bt?)QE?#c7mI@pKRF$B|3ER=|`xSBRhY(*PbW^J%BO|5RQ7_wV#3KqRY^ zBudwN8{31UPKDQSz>vuTiT1Nq*J9G0qP*UX&pEE6y7g%2pGe`Hva?4jd{Gv^e@ChP zDK*V&O}7}nii8V`{h79I%q$gAyxTO**7GJdqibl@5ziAnRGZfhbEio#APYT6|G+poew( zAIUS1xsithAa1re!+k7Uippzn$JY}WvqQUJdRhs5U?Ds^FZ0{FzE+K}^9^g>n|<}I zLfG{xB%H$I>+)Xay5*Vv&OW5@=gF4izs9fnTKEj8(4DN-IXbAAVs3X}Y|@y+4aH-V zAM3%@NCs2-CtliGiSsKG-HXgq4-^h*O;3;-{5bqIJ=K;Eq?b*kNRO&+Np$+H0avRW zPm@*czm~vo|K#tekq(;0D{v}Y(8|CURA2(aM|u^3_nl7i%t*iP8959m>s1t<-&=DR z80V#7LCjTar8$GymN1W}bF&|W;4i5st7(~mD2P(-U&d6;QB}o8oo(`7mvYc=N8r}K zjF3k3pTzU%<*N#KHNdoZ*|{|=5LB7%)jGdiwdKsh)yZU&FQs}YFt#G3G3%e*Ch=>L z>^M^$wqTo2fqlW|pH)CcZ_Eeh6Fn+oU>C3A3`rZLUe>2ILfK@;gz8^Zi^i#^XDx<^ z@~>u;`a%6aHJ%Ilz-YIgyn3GKMd9u0LJ^95>stGpns~V0;0oIGCxL|95gU(S#*IPy#)Z zCFTIzF)Yp&90~4f=O*)I&U)+_b9~VCStT`G<{4OBZ~?Tn4I8K2*w_9HvKlEbEehyEkOKrXQsAKV7lu0G+( zLbo@++M)TyWlr8aMhlK7dP>UCd?U@Q-h4_dzmn*)76ZqpY76{iS@1O1(HVyb14FY> z#(O{Pr%u?WI*nvUSZ~OYR0Z{iCYdAkWKL1I)Kn*yqq(mqMu5g+K{P)ji$5w3Q()8l zy?i9hEzjeO%Smi3gMV#3%-t;u3<9n?tlIq|R;D9fMtnI`eeteSy`FI5ip5cJ{`}JH z)>Xp52fLpvrtxEy^qey)!!rs}IK-=I$jLv$iTQix|Hq3#0xIV0Zdlqgnw#u_#R1fd z&l5Ss2+=;TD#s6wjR-js$i5$m^ZQpuw;a`1&XC2N^Wkn&Ztrx|^hxHZ4X^K5*wSZ8 z)~r+$OX*HduV$`x=_AhEnYrWJ8q<9)(jedldhlzbWXz!rx0h{(bqtC(_yo6O2Y!hK}BeXYbN z+;wSB)~7dY`6WKeX9P0$eDC~6bBAn)p$chQPnJ;R%slKHtMGZq`dV4{@}9S2)?k(~ zaYAhVV>3tUMTXwP4RcUM>$hKX0cf>})AHCCKZ00G(`h^Y&S?27#PBphQG+8)J=I1@ z;0qniu+ck1R&c|JXyd+GQ!Ld!VY%-gx(y`0SWm8B%*V6pY-s%6e24dH zM?ZtdoenRdm*r_CxF=kK0H1A}OS1|tw__8yyY9&p zZvPvcX{(wrb-EbuiJiYz?MzfZD-8aChU&1Ic_iWn=&VYG*3i5`_RPk}xbxKLY%C<| zh~!J82?!)-N}Faa@_iyNewbSJN;w`;nMa)PHrruRB*6h9MJY{FZOQ@JI-i%(P<7*0#%r>(2cv9^@)3hebL$4W>_9 z)Ygjyt#gMUc-HHEzd)v_;^MIguKIcCJw=ftzq}31I+DYO!fv^GSGS$vyrwp~x2B4l zBR2=*+EoN*e>RA$M{pHaCvrW6#v`MN7W88j`P{y(ao4tpbnrF0vA=1XH^@}r1_XHZ zaKA`bFr!o#$61x)q5^LOV?LpI)jze$O%95UbXC=0+)93l1ZmapP!IjfNq2;wwGTXl z$hJV`j;dhPA7e5U>+^0LawsTg8|Bxo*?{>x4*jDQ1@RweQ zwY$w@;$JmRQP{TC{=P&>5-8zEi>-&Ti?4?JU8PY&H84$bDDD^fsX(feC`7MwCF-n> za}Wga6ItW$I^ms!%Eg;Gpt?wZcHWOd&1kGVeU$o^ z-EQJq!>#w1wSu9$zLrC2ojC)Xi)OS8ADunPi~Y=N%cqQDq$OQf{)IL!P$YAzn6x$& z9Z%c6tDQxn+#~^v{~RhG+FeUFxg;!%5jd0_^S0m(wGm^P^0t+p`=K^V-3@xs2D7fy zoFR|6s=t+Yf07js`bq*?((S)#!L0T&cijg2|MzcP@K4)e*!I53dq1nkluSI(SB&j{ z96zX6Nv~fO9&$jXc<8}Gxo_)ROeSL={zy)!o!SiqDKWv+_x?KbSs?U&cuo4d1x92N zz>9#Kr<4ApGa9BCPF_&y*Q56?Kc>;=J~)n}RDqY9%{ji$zLV>>S&U;$cKv*- zQHqZ!_AO1MCat2LL__%4B&?RsOg2-%}STaG@$beez+48<+ zfS=>%j4VxSqKzHF|Aywqdwe3?&x|jM;DkNFH}F|6*Bvj9E)g3(^U-`Lg6po#Tb`C1 zMnNV@eMEx!l_|eB#1kO_>Ke_GBMZd=3>RKU2M8^UQ3Oe(j1}%hhz4)_LdI9g&nI$5 zOk~W6cMm-x$#TE1DnDld0>a7?*$Liw80tn3A$h`Ycvt#RYDK8-t)>Ugf zEzxV#s~I~>AtrJ}NyJ~WFQzu#GoRy!ry3otn0gaOkf`1kykNI>L3IZNU)2m(B-~5^jH)+v(0fwT1O?$QnU=UBR$k|XwdXEv3_PSw7dXZvvTDe{ zyK!;x(VnEqoUL-a13hM@x`nJXRc%bVBi>e=>H27#SnA03A97p$EKFrpXW!rKm*3UB zA33cXUD#Rm)FTEBwBPTXb)o=In%VnB>egZrOvWLG)!+A5+u6+R;#eX@uHzTC^7F7_ z?;pA))lgr2rmV1FEk)0hah-p_Ud@{9`-v-k7lwb&n}2!%MX3Kap%AOlO*KKn>5UF* zW2jakVRG*u%?|^gu@9A9qko|r)K$0jn>P45`e2tvkY>ZKZMBd!)!bv8w0=O8DjMP) z=SmI0xE>5l<4!8Z>#GcBfdv5mExy7stS<_?!=I>`r(Wx?P-1Y#mFW3qrx`Tu_9t?n z%hJ*oJPa;SxUUX|q#)*RCUU9eGgraQPqkmRr#1^zD1oB^j?j_TZG%*r`SEdsZ;h5r z9Ywt{&AX~>&+48N9>o9r8dRC%a_MrQpex@69w`u}2oCC9r8Th+)A={K(XiB)M`6zW z)g6clwYZDHBRZRHNqZefyH(X{?4~=8C?`PTXDext3naR*>;9Vj0xKOs;59`XttODG z^d^C1tkx#K(m+jSjZ2==fQK?A4a|_+cCCsXI3c~-26+`MwEnz);Z2?OZhjLKbe&-v zRQ<87J>exA-E~!OY03@J6DELH3w$*w<{{o!nw+BA;qLB9#V=!K&Nc!6u_m}gLQHA3 zCiC4XTf#Q6{^c_RDSlj-%?hG4V%=}A!_J7O3UD%R!sL^T{&qKR3?8~KWALN)st>09 z{>L5uZ|~e#1gM7m?du;G+N%>;`t#A*^fB=;Aqu#s7=M@f z9rJ1PSsHf3Gjf3TGul!WR)|R8t~|jdTr4=(m1N%bXwm36z>NS`S%WON^{8n=?yd3B z!G*>y=K`r8CjTcPv$Ll;3~aBWkp^CF%zQg6$X(;T+I91@`JtIbRtxt0!3v%qNU2*v zuI$ynY_m}LD*)s@MC>@>pQ??&wGc$|hky5=M>-p5teq2w(@y&Tv zqw7X+7q-=L^ZfmvTDU%OaBEhNn)Xcn2y$pIP!IQ;FN`S;3hya<4o2sMON8oK4PdHv z`~_9GePLVcP_BBt2-@l;OG|OYNGVRZa#b-bUTi&=WFt@uaN4I|Y%{wGsC0T;Kt3S{ z>G{?W9HjotW5!jZ*k&>&uWXxXamqV{68c0pyE+#82v2H6&D0qT>{1AcHr1-|G72=4 z8w%tQzK(hj((~;iiW~B)+d?%`lp<`ut==FYOlHVqA5fQPjLQ2p3aoWk6)o$8_;42# z(#IMximofhU}F8JuCHOw(@8SO&$o4&4=FgqO_XDe?f_p43Frem-U@oACQBmJYu-~Q z1%nx5|Gk$Vypr#MKH2CcWRK=wIK;;3j)leFcQt$DbD*Akqo`_DODANK9qX>Y@IrH0DnE3wT4Bq1M;QJocPh+6DPv|N%5~hU=|utq454k`LDe^d_6lN( zjej(H8_~4-DGF|8mD(8WPg~$*JkNEjig8WJsmUH!cXUZKI1|@$wew8&llSD+gViYs z9NC#jbrR|I54h@KNNqBAwj#{-TTMt`+xVzwSU~9*C1+f1+vi3ht1_f%iXZ|u6pZ!f zwQIQp>`OrdB@w)|mZQ<4)QZbU|IHtt?c0bB?nbPJ!%q z2p4&vLyi`w0@`dwLL4zGF8aHE(X3zaJGECatS9VNbo?y@{iEjFk4FL+?XQ}>|NU(~ z_7~YWm5iD*d(D^EtNcphk~~ztc_mk|mDBJVx$Xo;_L0M9(`yx#4IT zl~1W1%YGTH`m`Fde7<`GwI$1Nc zNsjri*{N3qC^WNceGQ^^hv&1a@9$@EYSJo$0WV@K8mno{*#uvV?0r$R=6vR7+E4l} zv{#L3LvsG&6Ff8V8Z|c1pU9oRGT^PLpMm}m_ggi!%JE|gqQo%lcp4^YW$0%Zz1CAz zN-+;3Qcxl^|LXKaU!>le(rRQm4U!p-elXPff#qK$MW9FhuGJzc%-OV~Xy`}BO1U2Z zEgzR^kqWp9m(NksS1n99kL6~~NKvZ43fp5v_+TX662@d%lE8vtR>0qB=S`x(9zV~z z#mWQVmBHz(bZy>JUFlF$-aWbG3kPq;72IB9<3RG_myoVD9&9&n$WQ1~?QQd;#!pOK zCyTcZ!Z`?%$#kq$mr?s@kCB^dyLu{(S3nqUJ@55*VAqfA22&0ZdYz$O)+OhDferZX zmd^U;x0*+s=9bQNpNLm-pTF1+>60z$Ryg!vzUG2`8p2hiy#N+1%u|oe+wg_lAaI0P zHA+7~7Z+p?v)$E}(Q%{^a=2bsKP4&oBNyq{GO#2s}OufzRqgP@e38^a+zzduq!)mZCLkV!9D;v+6 zbU`r8v}-8(%{G7l@iJ;V>_tYK;=ghy6TYN*ZIgCO%Ux+7@tZ!JjaeO2FC*7(ogg}9-DiI608cK+iU_;1L}L$pIOyB82y;7bFzMauRRFXz%INH#$Ay-nnGk|sk**Y9}Fv}}D23x2)BDV0_H-H+9wX1r5MOR~!CegER$`jHaAg{mBMI3%TDfTeu`W2;T3~RrkozCE zOYCYJ3sJi~q}Qg284hT{2ENVF*3TnODaR3hav2IE(nS|*(Jm*N%(bDbUGY`@fz zNtKBXs!Y{doib+mFTM_#XM`CEyt_xWZI}^at0{|G;&1%^ZwS!FYBds)yn{TO8@|R= z-^q4zq?RDr9Mj3^U<~6cuINH0$PWOEU#^b%gY|kX>J7ac9gQx_@_PPq=1bK{eSy>} z!nV@j4AP}fy9e7Rr-o^EOJ%W?XR)jUx>K~>G(;XCc$HA zFfo5Y8~zci9Yky%rt@rGRG9;s$zQN)IYpCx%C14Xa5gCu%`Myjn$Xu|GImyPSde3V zEjKxaO}jrFkk&R>;|Nld&Ql%CkRyCCUjkBRD=CR6Bxy+-JX*NZ@_AY1ofcnhag`i2 zE$Mf)$gmMIu`;NI!eJZPb>B%Jm{s_38FJiaWlFGG+8G)A1bBfjwXYwFORM~X2DEHr zWJ>?S8!TMo>wv}YTIbWsyzl#u*f40F3BXiq&gGS^J&mP%#(I(Hk1AN%KZt=B zsqA_E{dpiZ5u!!%LT*_XV*d2_qAKfzSDi7F2$f@>4_o}xXYi@kxMdRb*FyTq2EWTb zr??`Kx$|tAA%?p@K?dSQ%4pDQl&H#^E~!q@1axzHM9@@auHJ4^#h{|jntDxrOTI}y zWv)<~y_{a8zvHSK&6w>^Y|P{Dc)TC1KC1-*({dZiA<4pOWqn0M zS6#>&WYzuY2d=%h)|H1JIb$BRabKvhH#rj-b=AL}yB=eFzf$EiTB_t|q64wUWdd@V zt%90)WX~331ujN81zjkfrRtG>7$U(RXG?rLs0`4T$d!Ca_0aI>pb%C&{}1yS?%}B% zc`9$i_lMf#9K#T4+;6FwCq2wJd(faA8Cx@TR)Z`3DAw5g2>Rn6Jg@!D$woE^Z2(GN zzyZ?26Mi(aU6KkZEKch1Q8et~?hg`5g7xyM+}tlNXRmKfF%iW2#!q8QSpVGvy5`KR z;U8OnjvWdkCwp0A2jiqRJH{6ngps;Zm6E-Atlhm-HwT>4rFK=2`BFonK z!x-BgP{Up*Lfo%{$!$?8%X7ciA`*6I|FrY>j-K1jQr1Rju^yfscJAiQWzNS zt}Ct@`eYDf_9<|7RRFt0(||q+tNSjP zzeD^y?;1%h&xXQ#M1x$3-n99|e$Nr!~Uahe82Fx>_fA?;l*Z+2K>G_?R%@zLNv^R48P`dq~o2t*bq#Qj{nv zWK6cnQS3nZh163OD3WQh(ad~$z?YGL0nCZ{)L2rd(%tTdM4U&jQ3!mmPtvGOteX#g zWr@QIb(C)xn(_XQEc2gH#nSf^HO3?BLE?NfckLqzc~w9``yLK1G@umjzEOQe422q9 z9C5S!w-G6Yg9rLVP*#uKHIuSxTea$%O+l+mpjdY~*HJi=x@C)b=V(!y=5`55-;ZUz zWMELp`!8~?qro?TBU$eqf|abMUAe#D-0qr`V=jNGoSt0Ue;=K^t(o4^vNx6G!li<1 z=8msr9--0)k+ScARf<-dDz2aLuQJWIPV7!w{I}5NkJi-~<9QTsl%oH{spps%vie%p&c%)+^zwc@DRY;G}t~ zU@S|UaMWFk$p}(4TOv5t>o20q)S=eI%j0yIOS8f@Tm2o64YY-{vqq>R{U%Mak~$mU z{K`1|i~O%~5#R$aXx=H;sLkjz>_&aDIle2{^`N8L$DHWTG3sR$aglXZ(j4xus(+mw z_AmP@tC?}%&ksxh0_jlNw8UWqjwR)8G+ojWt`k+;+vM=!ar=VsmCbHzuv-#PX)K=l z@ZiM;eWfb0bf}g3NWzxhLLpc4;)hz!Ip~e6;vdf3-9+r>-}Jp=6K6)r$o6NA@Nplj zNJ(^$#>LJ+Li=yf zq{}&&zY`zC2(CU;$h$v0EUZgw(bvxl3{h3*U_WgzFOeLyrW@LyallAdhE<~(nXIan zgeTE*a~la&2S5;E@mDP9&#)3N`Qdv1YCA;APND#6`c~(>%mFaGIQ?s;%o}RjO2GA1 z^AtS)(nxO*x}#o?;|OnAb$bgITD2Lrh@AAK#12&~fWFH$WN1(}ejbQR3LTxob#`dl zlZVm3{C;N3#(d?Ry%Y*pg^S9pFfuZ#zyE=7<(jt*wyGXSv%hhAd|u(nja~}ApeG=; zhiy`&L)r5xzreUwl}ToSkQX<;j;jW|P@;*^dHQ%JZfyG3bLFb76JJ6cjj$H+Yl&~7 z2lpX1|IzvpWvD+Q{H4vPGc;u)ciZ!$m$q2erKC&leeI$l6OTwrap_wOmI!I7Ux&=9 z=O|}0D2>ivt#}>{mm{Ye4(jHewLdIgyQ3CTULR>>&xJka*vU*rGsdvd4%-p~}(N9Pf6yV6l` zW&Zzp(RoTuuEi8hMPO3^N`j$E0)_7^OQ}?-t7pvIHG1QTHQ^1Dy!y*70Gy1?)SvCv z6IDy`KnW+ZtvX9RqZ-cB1hl*zHU|4~VDkA|WHFN#@PLyqnT++gmba$rnnc1aJ2bh` zvb^lsYr4hv#iK`$dz_8>x#*7#pGl)@WYYuf5qcKyWBrfKG3Yu3eSIc|FdVjSa!I2oyat+uITOp{#Ye_vIxfY#;SIiuxmFTeuS&Lr?UX5CqF zhZ+ZE2=z1@&v!*>Z34DhnpzTQ&95!WH(-CE`HZiufq0K$H==9ViDSCQm! zEB>04P5FajOH;o;^3jdbNgY?mjhZJwQ8d1!4PQsia^YL>$Vcbfsv7^+OG!fvRlR`; zo{H`9nJuF8RzZ9YMj;D@-H!ksRNS%a%2rG{hfQlq=8d1Lj`d28-;HDOG^6DH(xzp~ z6V1ZeE_3rQw8mg7-sXlAMSn=}6b;-sQ!;brlXdBRH%u8<*)pmwwPXl0h=gzSXQlnntz|??yz{I&o;zn`HM-85I%N-qPgeDjJim6aGxl8>2|q#^8{}wz zwd`!?m38R-kEw*yjD}OHTNQ;_r=_Ta5v>l6j}v|$?4Jg{Uf;p%pfllh?;3Tq@%hQa zF9~P=eOZk(@lDGP;9JqeqkEP|8oZCw8N6|A$Rx6j-)FDV_Rx zbm8``rnbx5M?lr(S^n~>hH+i>U?|K+aG`czZ$E=yGb^Db#`fL6xjS=7rn`TK@TvMa zy#X#7kHc4oEc3TX+n;g79gV0;Py7NlUb8C>63%RRki40SAU4JD9bWH0Qlx%Vy(8j) ztJ-_b4bhq*jAQYrQ(ul%5GBP<-FN348)v*5^J@xNj<2ic0@^X7*xfRSqi}}+KlejaDT}@j+^aE5Csd~Au47o@zeMR3t zZY1rE33W-8**Il|AAU5@=kj2^Jfpqn zC$~olBqk;KYpO2YfNJqFY8y46>`n6PO0ect`?maz`>%w<^>;MU{=PyfKjf(IAHKbZ z-%9v)rgUwsC)xlSZNEzFN|h=QasHXKHdfbWzSA^*xT!C|YF!%)>orqN0+vShI1zkB zfIEWDwtXGDAbHR%uwtJE>zy-kiW<1z-^H@A^@H9wH(w1JKcBOR^ZrDUd-*dEgPq z4&+v1wI=I_#NOWqvCn?|+Sl~qnD}#Qg$nhg(YQSJw1A)254uN8XcUD@|{y zc07wg_=`3w0QY*TBoWKkS$P=-S4kq*#`S}-YtowIBTUB!qVMS@ znY}mI9;rkpw(Sl4YQLj;L>pDn9G9bdB$ZiANev9D4v8Js#L;Aj&xXU+@9 z&uU!1ePYzx)JAgfJ?hA58s-Ys#Fc4@t(Z<`hExgi3F`}fi%Wy?ptS5Glw}hzW1+gP z7M!$83)Za}dH&yxVKQQ^z`wNEb+dcN>N68DQFXyANB|r-{TdE6Z^jab$M1eXl+9w%}G(O_@ zb!)xx%NgOZL*o6{q$*w!RI{#V6@veH7SEzW(92Wts0v*&*9TKM`jorl2N}T;bNHAQY&M=Yrd(ecKoOw z*L|&j9JA|9nr6G7&);DsM);|Z|cP)>W`%lE%u zhd0&Tt}8&2>Opw`3c@*jVI7r?*-Rqco6Nns;$t65ZWd8V56!}-M#vcfFjbnjyZZ}P zU&==ne96ee3C%34)0ebQTp`B~TUH8fddfSr8okh4JrabTq`&)M{F?R6mi)5eRTQ=8 zqT9sYs+FX=p(6akzYxdd+SNeWBT20T7&e7%-MkH<3S<86ytASrL=Q*UdN-}v?in7F zOt0uyiOzy9QNQ#QXN?z+)tZh45a8+4M#J?Kw0{p~V+36`&k!iP6sx^ZgaMGOM|gDg z?DngYHrhqXG^`^~RQl7YwkwXtUd#GEsaebnt>E~WkU#m-cVC7tq+cRK?&fhc*X+p8 z{n_0(x7t6AP<@Aa}8qEa=iyitw#bYp}Q#X>SP z-?Jqa3}H~Cbw)xV9xIF`Q_Y-`MLK?Hd<9mu^O>MZmFLcBku#jGq(T*2{zLg#Xaio@ zC-e<7QMjw*HRh<2g3Dr!a8iALHK8vHHpQz`xXiHbP`>xyoT#(9g7n2(BH0nYO+@IT z2M{7pYoJJBZg5^ep5NCuJ?mExjvqp}Ts9U27EQJ`Euqmm{0{BWo6>>UR1(@(Q;=^} zqB2SO#Q~unX=P|qs0|bWCR#iQvTZyg>&b&zb#?o|Sjnt(KfgWo>3)15p`$k$8ZnE& zHgU#xWpNxy$Guk;)J2&^cesy98z$%7(_%-y-*6PjsoEr7grzSh@cwI8+ivh(Q4xGy z*-Caor-r(EomG3otvZ>=PA^HRoUf2le}agkVQI4|n9SEZb%_)~{KJ2YC02FOA~4QG7=KB^tQls(Iz0qO`l#y28;RYVM(bx|8}6$wBz~LR zdzSO&)Izclj?kpfu0Ot$2*+DP+4mWLgIIp}hkaB{e(oU&rd;b(6(4pz z&_avfh8Gkx`6Ov))hN1=Nr1Ea)$H`J9MzTay5jIU{y`wUSSdzSBVWUVEFP-cu50v! zwJD84g?C&H4k0FYCW$(pq=u$}Rpm3Ia-kfp-Ns~`UVvvrIpwOvmnH2?uGmwvfggW! zhU5FZZ(zgUZDs~$U{KG}JJ1WSn>3du{d+Q-Q`j**_4o*r^{5VKT9K%H5|c=DkFs80bUR2y653mS&PmY8?!k`A~M^5Y|>y=(@9wSzuQB z#O}GL83^y#IxGAB7Q`VR<_(ptL0km>2{Jd2szb;xoISp(ed5)}-4}b&*6)Y@dd}94 zM3{);m=?XATSn$j)V=lwEtp7Z!al_PgyjyOKXi4boY!3r2mLmEwZ87IsJxNRTgNB; z>upjF)!@jW;i+k`uI+tz#}{#ruNnAa?%;+S-qR+S%*NKz%{x{BwUDjubehFELjVe_Z0Rp-X z{ZJ)zT~0>UA!3m4ZG&EIF>rzdy1YTk2ID`vLy+*N?g-sG!dmQW)`z}Gd&s`S}7#OM~@n>>~JJe~a z*!^D<#I5RCm1g_AIHIY!>b#Y`+_=w5wxLp{Vnh87JFl1jLaj7?WRyBo2 z{ZV)!fUGtJ=A}GwxQ&yVpN%X$`5l&Wv*DX}`MQ!9HRwZn;%O(v!~T`qo=sNhK62oW-lxSG zSaySk&ZwHNs>AxpIJDIooys{*EECDM+x4qh8TTe52m@vx2YkADHpfgSEAb#kW~(-l z=gmU$@z3(nzN0#=FlPi(OsbiqDfqOY@`KGe_WwjP@&p8%YoGO~KFxZ0V<-}jyVsxz zj*up&(x?MOPct|oLetUKaOf{4Jyv2h^9zZ_09Wlr9@=O`gkgk`Twqm%LEC53I;o}J z)~1HQW0Lx}Y5PZRN^~@@+#L0xl0o|N&s((|4JmJ=ZL#N|#rCe`RIn2&2<^l=`@jYk z_a2cJce8l>uR(DL9J?A_-T#52I{v1O{lV_~U8o@q$0`{)*qN0&@~g9gmEGZ;u(&#A z;HVMDhq>L=Y9n&S^tgCe?K{{-#FP2%trn`)E4^^iIra(pwLl*Q8GZV^W^MKlHTM&en#KC0uivEnVUe$w;Iyg629aD_)tP}8Kg2a;BlB)K zpd=D8DRFL=zj){nXrcgu@0-zh6`QZJeu>X}YM^=X!gCF{9>x z_o4CDRkn1S{f;qgSs-+6HB!-|;C{^3p2{9E{Sp=yom{O#-Jopl2k2paB9TtGV~7VU zGDr+mfST6~J+}p%Sg(3B?MO#G%YbgIq%E{Yb^V5%weKi=uKCts>`GEd$QJYO9`MhW zg&FcqTDMhPeYYd9cADkR{VeeTuIx4{ky1ltEqH8(S_R`9F2jHr1B#+ z1N!;$9(fX}g$ysArv|-J3jINVV=%31Ha5PzGKUhI*^fyL6BqrbxUXZvsFYW7hOgD= z<@awMMxzD?6_7{SWK(gh{SWpfYn{y$< z(EqD>wdjKAs^J0i^_;Ci9z zYA8(DT!AE-&Q_kw{i8j9baItEv=j`?_rupw&aB(Hozx_B#Y(UMhZku)5_?~vDKiI$ zBgNLUo>#_q<{_}?JwE(%XGnkrn_hKXHUnilgA*MUwJKXayzbomebHv$0o7_++)kZ0 zb(4@8+km!Kyz{)f*wttC@;(BXsBm_e%$%kr@qOL2~(BKb(FO$iDJpT>Muc zTt(Osh3oELzJFD2;3Uut!^!eC<9Cb`f(TE!c~yS#CHd|_DR_IVwd~k*!A#hFfeQ_E zS>IcEwHlbz_fr!JH?FxwHG9=Ns4v??<=#YecGG3qW?{tZuyF%oErgzXqinhS@@^? zxBp}Eq+_ZwlzDjob*@9=!!#?xv2U=e$@d=0pFG`n%Pfw;$ZAyK5F2cOinHGcB5Dwq261W>*&wc3_7q3o zkG3Vb5Qior3>0C!-nPF+sNfLZontQ;V;r(uYUn!`M6X0ZeFngm(+KK| z;x^)M#@2vR5%Fd3EmwcvyZx!3eK`bwpb@1qILyp5EwSO&BZ9a(n93V?#L{B0c-#@= z^eeR=gYWKY!g`k+Im}Q}96i|`5W=+4B+tN)UyMQkLz!Uv@_|r8Vx<>+|DNfi6gcEW zj_`YuDp$n4;;uRNX-LGxE6DKrcqh`pJWLT0B>=J;lnhvWo`gv65q0pyWB7_?deRs& zqRe?T*)`W z+DJmrzAF^y>M?nwriY7?MuJQbr9|;T?)yF1lqv1<)IVT9?@PzK3{e4uOAQwYELs=> zypGc|p5bz05S6woGHW$ZZCiQwGe#J^vqKOU!NC0sBnO4CPAIP3UDKSOjer$al4bd{ z9_Tpn#LUTJtHoXO9z{Owdb}O1m()TIS)L9*=y;x(_F!)-+2l+m?Q-L<8ip%^H5Mw! zB6*;Fi8Pt`e>#yB0W9iYS536bMV)4?^Pxyt+9b$gVkg5TgW=UH%+9w9Hvp`>H-$ip z5>_q5{1Cbc7+_v}yl6v^t#!$m9`UPLUZ+mHg6G-I-f^agHpCdsn%wvB&?E6+kcSaP z9gG!%D=)8MOjZx$2iR74m&0v+FufKL-3WMc$Zl26$raP9#E5IPZzD}myZihIo5F?J zDEYTfbU?Gd>X+fmjSHtK*KNoWi&JYQHCA1r?taI84HD9jcFK}vbCu5^iro<>Vdl5; zIfETl=%riYfV98U8C)^0Y}XqW^-l<^aDw~i+_l|dON6x3Mq|{}6i~uH%rv!PO&O|3 zB6O79e3$JU){Y0iObSJEjZxWMf9#Y~j-HtPLLASVGMDTZ!ap-rT#!^?-Si^9nNkJA zu!oH4X=C-KKSS=syUiTpkF$hBZVr4EK~QGdvT06=RM1I6?HJm@Wnj1T(tE>o#an=C zcmDxC2YSm>xx9g5P34H_u+Q9`~(A?lmzt&pDC*NJX*H)g`tLaP3s2iWZMj5?D!Uwoj`gNQPAV?in$P5S}``y34 zddfbErI?>ND2O8HVs`6HH z9Wj>M9^b;_UqlmQ!W_^D_HSr`l_u>aq4H=zev8O@{mVxH`Ylzkj{5MKHl2NrR42iF zQ&Kd|I>NEi)4s%HjiCzg&p4CNOuw?>@=5=0W%MRo?L6?tA*}W3C0?Hc3hy=8;d`r! zCWriU>kfF=Vl~-_7=1r_$d@@_p$>uZ@uaaWSUW|Xa<@a<^SP`S{<9pqR2JqPciVda zL-wv+9jQ?;aq^+$D}Y7fMZr&q-K*=wDY?Tp?PxcohmbSe3)L%NJ5pW*J|~CEg`o^% zM*Jz6P!9qX*W&aOXETLo^ay%=I%!@v0t0$T6*pr*l>mw371gNQ8hBQSj8bLqN>kxP zh^Rgi`ZROdzBZ9jLq%}rM!k1-zEhIIiW#NP5h~liE$wWJ-4Q08MjDYkIWxxEs71(k zp$a|$qTy#E_K^EO$_ZI{aF7fP2HVMrOgvDH%ulZG7HP^!Vx-lsx~z}H1k#|60M7o@ zROFEaBM=rG&&46*m&6+CR4)a|_C!D~*JNA@gaW;P1JJ&NW^ye6l|bbYu9SvG0^0}E z(tBNxP4*pzUnxF4qK7yqw96p-UWNU?BcXpX2NszY*spGEsChpXeKf_Ej7zUed4?o% z_%Fz_M~ftx2cxYZ8j)Yy-WSd0CD=hoDyC9hNHfRO~?=wRRf!{i#0GWb$+g~Jv6H$E)@SM*geRym5sHOtN-d+dHYbQC+`*dA>9)0b;%k$d)Vru-rUSuS z(;9*D?&yv3>FWSC_ui&OM;a~MmZn(u+Kr@IWt20MznCQPblZ;&vJindMJmX(^OGmL((Ve2=3ERFpXpWaW1(n%&DAq_a>a!W56IDAuGN%6KD zWmHg=8N0BP_vCvH*ddg*8Ca-}qifAM$F)6FXKI9YmCV5!4ZvmyIfOGKZ`frZqMSBX zBz@(Z*fi96RWvH#8dDUx?ix#$v%>GqIBB-0!={o+Q?I~ORox)gOnH=t(esDNEi;5Y| zKya9<60~K_CKPA>;prAkhaxxB0#tzqXcpE$cLz-D_&oKzFA+M#u(N$GR`i%HcxMaw zve?b5{qba0`K&O%USGlCN+AaRy9&y*ed0M_9;jOW0L*mah8y#v1CrT3=&rm+Arz(^ zEUbDN3?qF0-7ozp{c5cecosD`0j$M3LL3F7`eS;_s(=J6^0?$8kob^C)r2Kw67EJ) zzE*g}6@6WR?5ij~Ob^jk>^P>&5&8SV94N6rr=V68)~&zC8sEG24sguKSW}vp-BX>M z2u>XB5{O(4tr{_z|weX>pUV^fx8tp&U-X# zLUu8nv2G+Wrp{;+siEEz`Tkpi;mstPk=%emc6)T2FcKuqP>p+*Syhk#e>0O~4>IIf zK{HEVv4d@cR*HXmDJsJ83xsD+sh(Ny1dbG?ynG48DNZ_7WxVznDQOThFeecM1O$I|$0%EA7%8ps)Cfo+p@ZzA2CyuR( zzB^)Oy)1wY!dXR1;rh?c?)+0(d;@JiDu!}<_Fh_IMk<8M31K-0A}BbCgxSA^sXzGT z`mGBoOX`U0nSOPGGi0CT0`euExGo%VYGPuEdmT>ayd^{~L>Ms{9`c0ybEjnn{I8`B z!tVt#-C1V?x_2j5*IW{kws1|&`!MJM3_TR1=I8TD(m1rg zauqFsgFTRdJu!KCY_qHHUZE)B#llTfT{4d*UHCV^OD+VXhe7EK5e508>##{=mUMl! z=71RRn&JRKuNr}@J6lr0%7V^!`WQBu#sPJ912kb>>=XJYfe34$=*4*8%kOl)A2Typh-J8#m+HRI$IWdjmegL_#9e9T%nOFz(SJQUf%3VYcax`)>bO|yCOktdMJ#jhW z?jxE*rxd9UxG(FM^!57x5SVghciELuT>fqsaBiNhELt#D_IfUIYmr92zjD|kiD{3d zO7sMrAF(p}a@66CG8Kn>u*2{8qyP7>C=X>FeaG;` zwvLFjC~WHDx$^T2)hu&Iwg4*}oQ%I%HY!$PTAmCesznH)#C>7^#RT)(JHiX6A7`u$ zuQ0i<{$dm7p-oIHc{X9pcwaZ9B2({o#)$x2MO$5^h|dzQAx$H56m(8IC;x~1-^pgbA9fquglwQE7zvYVqAE+QS_RG>S*45=O*gEEAay#@ z^LL?kpZjoe09Mu(B?C!_-WkeY?lRl@Uf>EoCL7N9gWZdqkKn%U#WH&e){PJgOz-)x zV2sn0I~?U-_>L(@fvJL=FELW=5;-0?aVM)C*bW5Cf<`Ph2tg-G;5VD)-@)7J9L5*e zH{fhIJOynHfHxl5Wp)Za9R@6{l*uuVRTD`N`0tfiGyj+hvIoVkk<@-Ab|DIadFD`v ziK~nF1oYmeCTgB9mj;~}rN}A5=2Ml**1VI&am281Fm|ra{jJmsGRq**ic6M3C95&A zLT{GAwC=DrL5+J|vCyY8kOI+mU3S^nH7$7?I0WpYg5<<@T)7{mhWWFis)S_QO1DQH ziLi>DML>p|ER}4|&=0C@?LATRtXbcKHz`Yw>pH6Xr1uUXzY9n!ML7%E`cuns;5mSqtN&BR?CpjRy2Ox|);YU%`q)M?r ztHp3%Ur=l0>I|TlRTtLlYkl`mYbEK_nW5!fs#Z19zunq4ava#dL#Qe-YM@%drG7qd z#F*amEhY7kyw3R4XIL27U#Y|?ds=2blYoZmk~S*TM5Gz>l1!2YcLsJb99#IpHsSXE zxHH=#(;4ZH;UWoe07=KpiU)n>*o{e>AbeW_YAAVKm+ZCLkQ)k#8>MFa(CJ ztay)#ht4hi#jAKczJ$+W(+$*5eB&JlZN2ERv`IluItpStdIxt{|NB1j=E+~Qi)S0* zDAVA+wjiOhs_hE$#kzEB?u!wpG71y2+}^@L9uPv|SBgY$2mI^RXLzCc7E@~mI`E1X zB|b}7{NBq+70lQ^mR#T)`MPM+&+nkQpRqisn|HvIKWZ%)h?PnTIK~%bqst?tIl-y% zXozQqjZj>%sAljUOJ6)F!2Y0|EuWDh?12hPm}R6ZEG>(ef)!W@*U>(AXpH&JA?J=y ztou3ubp*x3BHP+k)o|*&R;yO2Bf3Q5-DH_THBMUAxuIDC!Z1QqM#A#9iG1)(VUCg zqDYkJn*E_bC@MH|IRk3&T44dL;i24L6|tVoLn7N$zU^Nr8jD#8-3MD+*BTJ@(*?7p zXbL6Ud7t0?SSzd*T&~Q7hD#O0$=boYA*@K*UWMIYkbbd&0|pKtwWh>WSX*z}-V~=BpxVK#8tIG*tnI!MdaTK=~mq{!QK2BrmWk`_V)FD(=#w z&C@PcxPUTe10!k9_12?m?W1s)wO6=BFo2@1n541=a~It^F?50?*7!J-G$^+qk#0Sm z?c#!nX2|?1T`nY{tRf}F@v`P)$FNocNmW8IrVkr`4O@xelpfWB^y}Lr%$Z^`24~Pa zoxvxNCHM6uv#*a#Uz_xL&}i3#>V8ZDv@pEp4tQOrsdze zQr~ko9oSI0K)^Fn8~5Y?W|X%gjy49{J+?fR#Ja9v;^Sau5ocx5;Yo=ch_2*aFb8eU zXe42D9dv;V+;$JiA()VgXl!#Hc%8u71G74VOQ$R2z-z)&u+XQGwUs822Zl@Q*57(1 zJ-Cd8xt~zfU+0J@fmwz_@Y{Fz$+iZTr$&c8!URyD|3R&MeMksakaiEg4nFX@yhTL} z0e5>GNvT{$N%?q=>M5*-kMdEMAB99C{l|Nvm{o13Ufm$WP#_^P{rim7+1)=j} zY6hYL$Q&Z^h~TD&T*@Lu*x-Ab*J-~`kZST1IUo}vm7@~2$}c(^wJCE2LzRgP+jj= zUfr^)p%xK^#?m`=MFa5wj}9+lU|NYaV-+UEtfEbPu1ztG1CWfch-+X*-?cGe@#1rr zi7K=z{0i-Z8ycrD%bIW8>z5OvA@bkewK#t-^1jeXwy9*6O4EFNHw7LT@;5+KA2D#J zP3`{?A>8QnU&Vs)prR}eZ8)7XlWz`GtU@t6A@K$Y5fYNU$!G+mr{G* z|CC&8poc)1q*dX2K!ZMeQ8T|Ah}D*fr6-BCU@OrI&>;D_g=`F;%e@(f%98DJNafz|1E@mh zlb3QG?SkSpSo8bBRB)Zk@`L+2de=qcL)oE&Cw^+mDejh&6ldJq_@s1gr4wGh?jijD zq2In~-~eOMxosVNccexq%tz5!9u$T=e-i;Y`ftkEPszODK3#m;^Z2zy_9TUN$247H z+dc(5z(2qxZz3*ke}Y-w_43Myxnk(XoET{^im8!$<%L&9d0G)>e-%&&X;o2yLGE}r z%aLulOxLr^&?`*pGAF+x3IN#S6TYyl5{3zaqZ(cMQ#0+(AAi5B>j zsXS_Cxv?vN^~=9@#4I1pxah<D^y%3OTK2Zu-~arPJe%mWaC zsqf_mxzyPR>Di8+M=l&n51!o{XvEhrDS-vltKEiZDU0lb5apT&o36&i@d^hecO73s z&Z^D;uA8sMlzGY80$7z(1lhUZAO_^vA~XIV(L9O@L&oi!puK<2w4Mt{{H?e)0OVZX z5+FH;?LgYYPLKbfy*gqWF$^&>Q5%nZRtu z7BCp3}yyW)dC7%eVrI zR+}uKW4gq2uPgNF)l&BEqwN7mOb6)nBw>=pDXN7=`5sp!I43>Vm^#p%u4jsCN0b??6s z(qNci=$VTDz6E$GfNbfwzK;Ng62u)pJcc@wgXZw&Dh`qxBc;Xcc?HjpC9UKmDFR}7 z(&QW3g11C=;g!3U_?7o_`>=xj9usFY&)cnr*wGOM5=J%X{Q`j}fd{uNYWvXePe_vu zXYss|4~jrTbubuGUzwt0uSYW!R%+D|JEIHw#tbl|7Ce^RDfOGC`NHe{kxLftMpJL1 zWBiF*0Y80}Jd)lQAt``@QSB837sW#HiqjnU;HCuK&vRZPNEeYjgLq;q+!KV0qPb&?31vV` zc@})#?Q_@~7l|!QPJY{rzus#JR>bMwW2uP90hs5|jKd=ZzyJ_fT>cmc104n+!tH<} z%b!(r0R)6MA;&()<+?L7BoBv*mj4#}#LllZl;Gp3i*y4&KT8pTw=g`n@ry7Qa$4e|09WD zLklby!YFF<9XJgIbq<=?bQ8LV{h7wYy%|$sO<2{&s?Bo*83W;t6O%D)U$Fib?HeHO zkQJhaqz~EBXZSuqzzs2{z^lyk<}f5+cQ#ULo*`iT zzC6~*o-qj_NKD#EbbvEpshSAyo@6MNAkBerA^?4(7;;7oA3p`7T4;Z2&>zvjGL?1H ztU3_%X7AIE=4UYc-P-bP#`!@eKrE`A+jgiPt9~P^M{2;41U~b;zQq;nSAsQ_faL`T+a+%3Z!hap8RZL7O3IP#aCe+5Og4$` zK6Ye`E=MK%AJ!uw=h-;kNJ1GBU;oEHZvXHYUX7Qy`5k!ui5#c=`Y@_iOmtXj3GvKE z$Q|fBI11t=%pQ=aO~(+&Ks5r!QKmnbh$@(iv(uf9YGRqnoPzhQM_Q-Mr|;?Iu_^zkiceb@s* zaR8p);W5Td(g~UR1Mdz-Uty|fXI;(3P6QMG`egeNsf9ASur*`pZLd+Mx_*ZZkM;6p zJO5lvG9|DOeI45PF49K-?kgW({rE)Da@_C&D`y#TvvR2)-Qzu@dR~#=H==?sAkoE*9MJkfrD0aFl=ZzfCl%$}MWSfbV}vl`2k=WFXgB_V z5^9)4AS85>I-kH$5Tx`te(h-c1lpjXC3J`vVHSLvjJV^_YO(;opN%CEfzRV~FAE{7~HZWU5vu5}gjdqo*zMsDj1&_;@KTovZ}88Fp8`a$oS!bxD0i_ zGS_XK!&VvWAUufOJ*JA9a`IebMGW}R`$O0m%4cqOKLCSg%njm>hQ;BsIysjK*bHOr zB=C*yn+u}bm;#B0*sdIFC45`uHDBXnOQOCv_kTgyS~RagV9 zDL6vH($!_0B+OCrR+nOQ#kWksh@*kx^ylCpTIPlypEYhr^+J-F`ga4dA9r@DuNJurLHRxW8)DGdPAH;Ch>2;B^OE}J2wmUmjBOk1lvnk4UIrg* zqE6qEx8E84-qO2W$R7j{k2Ks568Y+(CY@4N`Hq zo#z+j1dHJAMCkA!4HYnZrOlE70<%uy>8uany<^!a2~XUV;@& zk5LMT<5tYqALlhRhz}Nqw8zEMY$K(wC&n*ymx$*Fd+zsbAD73z5`uq8>$@;w!$ znFuBIH)mg?5ZK;qXQ&*4<odWseLe@?WuBIYG2k%=R|Ms=>?j_%M*DF&FNMDs8hccSECP2xerh!(k*RA3lk;g+?{pO9-)sUtVgc`B;;MTZw zdAC{PIWKWbRxCL2^ypP0cD`(rMGNUY+WuG{OS!Pa2Bi#lIs+vL7u9^zxJ#*{;mCj( zTG660a)R~9Qn}~JA&BFV1|leE=T21bB0oP3kL*%GXKCoGTjrju?}+@77n^XQjUkQaE0CyR}LwtTIk}& z%gi|hb*#x`_6;;R*E0++eETQroUQE|2+zxG$=3$RyQ$5X;>`$RgOt1&5c5$*!0P;b z?PQ(-*YS;mf;cvq!7;nTItfK$I0GX#s!iB~#IA1!54D~MmjdmNwjDK3kkT=4FxSJy z!AK(``j}+{swpK_M>ysOY%JU@%k7k}uBcmjPbnu|Vo2;OQwbDM{v}d8qXuH+yyKQG zLNQ>NV>VbaGI}YgjTyl15o$+)dzn37V@t(P%E%@m>IL?&FBzL|C!R$&vBT;>T zeDIF#15z?|9&~JFMHDy%H!vqcOMJO1>csnw`SS0M1si+oqo5t)6k_ee70Ks#|0XRH zrNbuy8qd|1Ac(jCpgF!8FhYsQ)AY{=-^Eg-zz* z78k)SA|0bWvy!Fle`KP8D9?14+}WOA7j4ZS$sUV#L+XW4|^C^2cgYSDSI{4 zWSL@7d#GxhO^nHUuvi#M`*Kw+O6-Cax7~nV%NI|u@Fuja7^jM?lrAk8M`m~)FVkr4 zVbGJg&cESy!k)s@HDWhJI4j+qGDl-%M!+BU)%zCeieUtUsjFs037*^4PDkO;WNjfu zb8h;n(oT>Mh{xIGCP05ShX(|e8^fnpQzAHlQ;0=wXOJ%Wu0UJNgC_GK=_5riR4Lap zKDV?IgVEUF4cf9c?Vj}%G7;E0F{!_CMM>y#Y!}NB~Oi8rbLKA)*k;ss~HN|a3 zrVYC;?aKNViX_I7vr63o#AF9Zns^b;dH(cpLLMOwU^@rBalq3$Ouz~bNKsOb8Xc0) zE;$LE)qujHz=EE_;|Do)vx)%*ax#{CkMC)6@5c_^kCr&C$| zK9ks7K+-@?Q?g(>wEl>lq=9y41XR6(NnF6!=JQo-BJzrz?Q^oL5kN<$(~pO^!g=I^ zfOudH9$QehnM^@n0chUv$#_m1HpI%2$m-N7>v5%OJ$T#C(b&ADXK?@eZeFYE)Y;6M(w?H;02hW2K!V= z2n;hJRGc3D602k3iza9RPw0Y;w<#b=y-dCgdNF()5D09x&h1HA0nsOdA-~WdumMP| zmd}eZEuA7I%{UR241ZEOa%BV!rwqQ{3NZ{{eQVCzZd!I}=6qUm(fE1`qIyz?!#$|I z2#*!7kxNRrY4C?Gx%G$7Nic^I5b^$ZrWK2{I5di+h7Ftk(PMLPl19$0WGX0jx`bpD zV7;+AJiMRiH$a=L;Df(v0^%+_?&dufOQfm^8?n|AblRFU5Hp7c*s!WOr? zK)Nap7jhYx!AP8%Qb<7aduMoH&FExlH>VULF3f(1;71l?-w-010YigCAxu6YlX~Ag zi(Rr(&~4jOd5-d|uIS0;SZA|Kq=pElxLcjkf(ChkwucjWo#WluivhxzzbB64AAloG z%N$S{C!~2?`vc&KV9lYwsQf@Q*A3uOOEwh?L@jEYt3qn$^n|=gI*>bE$lDE~Po zqE=x#bo3?eNG2AeiJkJl^uoBi6@QCwa$J`|7Wzrz?i7g<#BHkNa#2DpMP_W*jgcEj z@H@d7*T<5inF?z$;LFEuuClPZidE_DdI^Tkf%Rus;Dd)#1iayIT0ANTnk7|c_ zd6(7`S;hzuOEqb(HGqqn1F&C!RO~LKSIBUlf#m!R68*eSONgbbs;}Cxd>XlBCSeO+8kc5RM>$gz`?C5j_rYGfe)n; z>p=TsM*}gl%GdLLyMXSaO&~%9*JMu~C-*7hpDq8E+chzlGvx)0eEb!EzvM-?yCt{qGcrBGyHP^V7^#`_DG;23;`sQn15V8ShOTX^v}(TBK9rDzCygEHq#_VZG67Caq!Qw}4$MoP!uEgs{?>QJ zzo#03N#xsMRK_Y^xs`7$H9#srbQB1``bvx>0GxPJX#zWB&-Anu)EpsLr30zpX;;NLXvq9-$Q2^-ivXa?fG;+% z&C8YCqeihgC9oDPXU#2lB{o>f=*DoJa$u<%lzy*k}uSzctl@q!vVTO@`xLN8=SoiJ*C%QacK_df>j^snL*xWhCqeE zOlUzNje?zJTUR*RjI9`fO^DN&1Vv4S;u`i1`ehfVAef)p%PxW;gC_E2eK6|N+vxd+ z#5rTB1UquV1O~qj?F`4F{X$e0Oo6cg01OI^<~oXXQq=cK-9rTiMw0P+X>Mp280L09 zGHyeQQ4_6uHAtVR%fie_gN>T zoJ0JbBa(QiW%(4wr)j?SM;B?_@6$)!@Q!jXs9qg4K3N1kSM2dwD=LDRHBV*<5t9K%yzPM`#mOHbE6U_A4lYgv3lzHD1#lW_WDY;w8f{IwlZCcO*|C zOZXB*0(K7LySmZ)xN2s}vdZ47_^`GsSnZM$uxG>!wywA9b+%09oB&D@ztAKCK8_d> zH23&>7WtCXW4jZK&z>_c&&x{ygS@w^}+80Flfuz5MtIia`2n4<{#cbhJfL zL4u)|Kw>uNMQ4fza%;q5DvgEDgSZZmHm>l(jPyl~H7i%&vqUSp@v{8B*?$r>-gU)( zh%rI5e?m^G^$-uvR}-k@+1szKvE~f6p(vB|rEEAdNr>zt!e&UKaD%>boVja8lZ|8h zGjQcxtdt%-p{cC2sw^$w+KXEvVHXms@rwL=L$}PwvA5(HL32~UxXcb5c@CptVah5F zJvAAs-X9{-1--`Z#lA%5H|#aAh~XM?McpgH7XGgq$@I*Ia7!;Dw!$t~6pRB;4XQa? z??6M~-JC!q_Xxf;J>^kp!BQl2+}u>$&|FKm`#H-!3Ds6>Qo)H z7A6pw9A+)_! z(>p`}eTTV3h>oh$nA$WhH3YSM7u;KYrDhh+4dqmKEZ;_`1bvH&Hzb-B|1ZAO@jK(= zbG-7rB8RDDCG^N-h4F|sgfU6YVd;9Et_ldcRy##Cqi;H9hsU&NII{rNU5Ogqpg%~; zE)pg=Y_4+)OE-l2z+|HpIOzLX(ZemgCE?G<6-kN$Zi?9Ou~Fjd!#!Vyz z4HNjo^MS7bHGp`Vpb9L>uQ#Ha=63flr+t2U#nOU%2|?w(_TNU!rx8t2yp)Ux&u6>} zXs6`Tfm(Mx4g-?<6 z#-SFl?jxrp#Y~59>jGZ{miTyZkcBajwK03oDHx9rGy$#Z4Hi^zC2zG0`G>ouThpj( zp($0XqKFOdOk?Z_r-f+q9ogoRO|U}X4)FAA$+|B!6Gew-Qvor#2n3OW_^XCnZb*S} ziWi3zydV%^<0|~wPgy2tTk*JaM_-6BXcI&&G|dQQiUQg8M(tXB-VMeGt%u0l&2Q)h z$z2XDq=hX)ZH5?U%t^NAjKb5TlYxN7yfr#Bgbr-YX_e;8$Zmj08|6`n#zU5B?uDa7 zVAFWL?^j&hZ@&<)_#}kYi9TcfoYdTUDJPw)= z2O0;&i7gy!GBYmbY{>&;*+`W=HNG8ztN@vKxK}WqIE#|`7zrO6w`?HeXn_$HlHUGP3h}z6RGkA$5j(J&e}l_E?O-3u>7kJISCru<>9wTd&Pc?o(xq*cC1{3& zU&~j(kQKYq_WE7^_RaJMafvBbnO`nYbqYOD{C8I@%sE{_ z5MwwK^0h#p0KadWC-DOfT6ZNJTXBx46N${^Bwl65Z=^V91m|;S5*BE9%nIzXfhQ-G zK?`M|_BBEc^tcj!ytePb-!Z~a0)al_wHZ$Kb4h4}0EG6@b)^)9M{y}|a9y!_1@2pt z-&+B_=x*O%2*fP5v&;fGlqWcpJ7O+>*}H<0S|ZlQT9}0~x3>svp1A!!hN>F)%zjfe zd^vBZzv-krx4binVm{RIJ6;z;zgJHUHpu-5yp&6oA6ylwX^NHpflqpaqKLseVFb)x z%4gk++xs3dS&3X{^&J0d1^4MK52k$+aCdhlMAr0$1F)@d5Gtsgm5bk9l+$}bwL66t zc}L@Vz?DL=Z~s)5MhqCIUwcWr%-u&L#XkS&{5wOgN#-$XiRDrm&762?PH}(+=M5z~ z5&@DxeH6woxDo zuzzorpDdiwpm#Y%sYZR}iN*f#-Ftk=kN0-m5u+WgAa&yXUpPaywx=?`L?tlt3uh?%d}OKE)gs-o;r=90kT5;yf&LycmlsPI@lYIE z0LO%ViE@QrhNQDDYTxxxT2TAE#Y8~AqcJjX5F=yv0Sdj}>EY!cvp0w^Qfb%V2C%3` z(&~rwq*A3~B|O5LLbXApr2dhD#{QZg^P=8}f^z6uM~L#yP|vw7M7wBMiNyc7g@R2H zA`}H(jb1&}aR~fI^oIk|-V;uJ%O%Ie*WrhZClESAbJ@OM?1W0cqQX`EpV5RF#XG5x zX8ROzV2g%}K)7aD4Jz8lMc&v|@#N_%tW)1>N9NxMg)49S5WJG9YLFxb_!#)6?2_Ds zPTCW$57dpP-M=D~F6pWZ%uRPv)5U|NAksDdh{t^z&D zrSp({a(ndhWS_ad2#3Chx4zIEVP~L6Ke3ciel^iKi)PZpQQyUgorg{T`&3*ziB_*J zi5up&_i_uh$9steIbc}_YcsP`D0ta7H{naM{T!xuSx#VC8yW+QYooJv+9$$cgwcFX z_^yE^oE5@{8nh4LW0W(>NA8)c?9w*yJi;1QLUkk%AhbQ?dPS2wKBx~E5XsvxBwr!G z-W2p|4J{~X@yJ#hEMPA-i)Kuh7|LVyF%J{f6ERh{?u0g&ah2m09AGS3I5u4dh zX+3GlSWoRt4Z%^tP8_9huxS}yan}p8pWBD^P5T%zfRMAhQ}NaeF^7_n{1tqi@y7gk z1e*%E3_%GS6rOWynrP6kqt|wPKmIP92k&s5%Z<9cL-i;Xa6W=s5LJ1^b^#0vG7N^NBZiZx8I--$k%6Z0RiI{~Hn>T1 zLcIy{tcrXGj^sWe?lN?d&|X6<$M}1Tx{YYfDrc}J?=vLcVF6WH4%7$LDSu*JF|UWU z(tyVkjTja+^5tmFu(wXv@~S^U7DVqoM)7)r;}sWB(Ks(8AOr0)FvV9v$4KUVP_Od2 zdNzANB{5Wkav$v6oS59NDDKE9tKEZgARva~Em9>D$siL$jP!qu#hqSsn8tyv11#xP z5QNzG$g&&XERln!eTtd|Ay#rxKteR7J`*-O0G1N+UbVtaIlbLumBTWpNrvo(ET&qb zCULb}VZBk8u#dkGDuO(L9czA}%AB(+*d*=q;u3~YLTJUQUl}sYz-a)G-3;x}CWIIc z8|b1;BAW4cTI8p*(u_yEUdYJ~5CIH9VgcPvyeV?}A$1%A6r`S-l8>sK);iLgh9(ef z7wIT{OmV42;ka$hihXmbRSa{!iE(Q>ybK32OR+tIYyQ4o<@V8)F=KP99~*oY=IjU@ z^ZXTdifnBD#B%9O;aAx;_F!JmD{+fhZ(d&GsGI-2iH*Dq9B=HGkm^)x)`7D?_*=Xx zHFdOC#wN;{;^yRcoqarXhPi>NMKp#wkQj}_)0HM4$Q*pZhO+d20K2c#cIRv~0~>?)(-u zfVl0|w!4}g%@NTKtjt4L#2LvDm~~lXG{E+zK;|9OQr5p+{1q7SYLU~}5e*+^13%V@ zp|-S)AdQrS6gc}OZ~&j-2}Mm9Tq(kH3S+V^a%dL)MYvzQlrqHT{+>mO~xqG zxcF&jM!bteHecS=PF>?$@de4(~Z zI>TV0%kR*=sHjS;)6ubGeF3+g)$>91(jw!bC=ufj_WVvi@BC)_Zk7c~`}dsqyAG@H zz_bE2F2nsL?Vy+%rNqJ2uX6Ah>shf&>&+o?aqPa!Aeah`yb<=fyk{hgz8! zcAt5Zl1`{+L)l$Y#DQ>({(J${>tS0y#QEahP$rGFqkuZ_!XuN$bm{PsgB!9lsk(Vy zGS%6bQ~7UrXDC;B6-7-B^8hq`zI7h#8(LiGZx#Qa^1tG=C6h+0J{Ib!XQxEsQUp7VP0tj$Mr~JAf*a$trM@?F13QjONv98vJDFa8P2i2;irR z=UuC1UR{DqO7m2Msa+kJ&Wz8!&|>p*k0cd1lL9(EDKHp!Of({)Yi^WT7G+$%b@2yR55-fBe212y90+7J-zp#8q0$>K%$c|wLB45oJEwlu3P?<5EZ z(EjWz>+s!-Fsf=)#N)8|#*kcanT@bi^`Tr? zkpC-|XdRXV62`@Q?h&9>gikbrHOWVC{NC3{+r9%L$Qd{VMHn)^MZmz7K$VTiwBQ)P z6i0_VWxNMaF+|lRi@{{s)gxaxqMA!+lBv`1BNL!At01Pc*&gAnU)l9!Z6^& zB;$x~jISbGK!bh72FFl}-8q%vf%6z!zjq>Fz3kkW@K1ubCV~s?8RIyAF7V${<83Hr zVUxdv4dVhrLFJi+O@YBcG09RG(GCq^xRD6&OtfSyMM8F+uPuI`sOj-0c2ruHHt&YJ?O zS~TcYwos-V65GeHyEC^W#2g@;>E)IF%h#s_c>kQgWha)l6Xt#J8H(QlsI`KU0Y;aq zvanckAXZ^u%+~jW^WbZi7n1XXYJJ--^P8g`h($e=XPZSK?Wi{Q4l;Nr_It(;X!;#r zJ@yTnsCV$ZmZv#O|J^2p*W+ZWL3nF|G7yK%_+Z7*i18V~LZalmFa7xR3r{4A;dx_K zHBptJH{jvzNtdi_1dj5dB4>rFX$TGj@Oos6_R2+MVoO$zD8Lm%r(&kZ{uTQM;sEiV3jFj9nrMKgOP`M$rQ6!W$ zm-zy10m05?6|qBrIp{-~mtks|E{TQ=P?gUczQ-DUgmbMTtR{!4F~lE zfhR=xNIPkT4mNB8#}Oz8M+uSryD!kiwa{8>mt4OYKMEP2M$*(Vh~`WRY&2!}5CWwi z-&C^&#nDC&+?-a9&}+!djPq}V5}R!l&m*-^MuU=|fzpVa))Lv?LZu>MD)V^NFsA~8 zrE=;ck(C%bhU(OHw^zup3`ys`U|Vj6WzduN^Dn!_k3&ZHpJtH}uaADv^#Z(OZWVEl z1j;}TTzpS%g<^)ap!1+R54RHw>dC&5WB5iOt<26ynCN$_4R$a`)75c+Bb17U0aY^b zdqi}%>PxoDdD1KUp%F9mjued644p7kW*aCVNWV%v4LJ!>ahT`4J9`)`x#6f zB98(|Gnf%TY@wcDuCn;30aHf`C1LZpuVIJdc!(aCnGkFA#g~0NQyw220vtv_d0<_V zBERKbgzT2@g@DVCQrw+1L#m%4#b1l5F(D?GRp@5DUR_SW<-0aXl_--vlyfVTj6yu_ zjiw|l`b>$PVy@SwmS5<(6zpkppCj*Mo z(W2jiI~H6iUJMjvL&RRsvtaOxnqrgI5Dxz$)w;$R@B4B;9hNA(?rhB3@ZI21R5HT@ zDr4s)(MBUuy@KJm?TJVi(jk>A;uEyFl!8IBq{q7p=f>zxO#AwhTmc2jo)VKp9ogg$(^}y4ODBR0psKXnbX}6+u6rox+(T) z#v~!do`3PtSb0xRwXQ4&VP47)5&A^s4_gEzDzIRuI(7oOKM7rOML7lw(mtpK)Z43$wA$;DJD1O@ z$2H#Tmsfre!(@UO?h{t%%xPM6DGd`4pc1@>nM!%Y0|POtOpQyN^+%zw;?LqfdDQQUo{{eF>3HwDM5M4HtQ z7j7!oVgzuK!)Cvlikg-+jaA$10yvo%4c!>gphTmFsF+n8!V2RK4aR$#krvcw#Zfe| z)nWj7V`RnqO-u|I&-D2Yv4m&pZDE@2kd{{+Ncu%p?E?>3V;l~3Yeei)Dq0+E!pJM7 z!$Gn@LBHejc=gWfKw|O35vaf<@MMM?^^jtz5fV9@_P&O3{)aH?~AYO_qP0GdL^Xl&!9t^_JMR!~?VE<^wDK1CTLEr`*Ry#xS zHYI`ELdb}4gW4+W9q;KmS8A%7`a@i?*qEtXj(aq)sR7GkC01K?e-EoeMq6Az0>!&n zx*r_^OK~1vBOLln#VsVDy}X+^n+ZEb{-13~-{NcH&l)Y-IiaW&+e={n-fI)cDZ z0)|eTcM*vhPwt!!F!-nl@81ho#WpG=VKX)XMZcOmbQ;|p>r6l}p~UJ}S=;K2dZ_=A zZX>hMv~2_u`(1iXNxzqwPLj*`7*QZVfr_zd!IX{tx%{CJfoH4{65^={&1&*90H7pv z%T1Su`jSL;Op?W|G?>cJ;n$kOfmJbOSB4Vzy%Vit_V_BdUiyUOtffsu}(dA{UEX%DhCC}o0 z=}IPX^`HP3KBw1TbeCIl0l*4JpdRi_H&st@YFTuLxtu7p)MnZW0 zkvD>S#4P3@?kQN3heP;b-(cpzp}orvpEJws1Bhn(ea6u)bmT=nHiN_~ME)+|x<=Ae zCADNBvS)B_UJHP}hPf&QS!8_kUQ;a-6cy3b#Lscm5nkuKa;```6NroNig2NCemI~2 z?l4nenCQ(hJU274HaM7P33BW!vz)@2U#cGk|pHf^IiMtr_z0^xd$-JgssaB05uj~-Yt#YI# z&JCP4v`Z~C*x&2P#05d>3Ia?O(F&H3b=j}%no1l&ukwdmZ7{`Toxwrso)A{fomZRr zP|9RC)z_r?Lm3*sF&iERN3ZFl@Lc&jt*LGqoq@+FFVxJmjnrX2lsJNE6Y+vr#_n&P zxQW$W!h{&$5|YRN;OuVUVH;X#SlnD z8%hyqH#8q&c+_1vF8Mu6%?l|q;2Q+tKv{j)fZq)mWBUj8|Etyoh6tS>@dz>?b2E`{ zLni#^wKanu`+4!hnu3Du7sB*ASjUP08{z?KMkZbjwY1B0z@*}wC!uioODa&Nw(x_D z&orb8RSKm)QJGXTNcR?QjJ})^Mn6C-5W59Q8(lh5b$0;hub%txiWCL66M1}GNHwk? zT6I@#0Mbp)qHCxlWJMSQ?Xx5(tsHB9%i@nGntKAs39%cC=i9&ho!@`5vF7RtNnX zWOm5ZBXV-^-%0ty5cXULmmw-ch=&oo{ko+;n8hT-lL3EU5gdyGC0*v*HP~CwfSM85v3u|^kLK`Lx~CSA81j! zH&XcMzJ~h^8Ga4vWM5rq6Zl4Kn+z797{_tS5V?=6Pqyq`cItzSCkz8gt52!Mpx{uM zLy53L7sO6r>c*=P7IcZGQ7Wjo3|3*`P~|*%ACxN+Dxw1J0%CcZGqjNlP!A~4nDM{Y z=5%Y=L?|``lLtCsA&|q6lrI738BB^i$L7eW`g-V{8x>{j6W^bf(7%c#6iaTQ^O+(G z7h!@FvUMlxB$BgyQp|$;v(WMXrAc}Uu>4-$>C{(Z1ZiJcM+8G{pkX87ie|K)kn~n+ zXJ$o}JxkQ({5S*><3MrL#}Z-nJ^Tc{?Xc|;_()HM0~>>sUx5&N4{|X&9Z&XU$OrYU zN=+Pn2ocwO0ig1nnFV$qphm?roEWdkfJ%ujC{A*WNUui_AS2@*0y;=qD8V`9Qa>Tv z^?~Pe6|{1LDhs#90JLAQ2U-dqz1NWRg80_i6M3P&~qST|~NNGeEt(%~~jLRv(@bBR7pJ^hM!60cHCO_=Fn0?Uc-%OIqG0N1EW zT*tyBgIoRmcV-DW8k%1;U2wEx>cb)#mqRb20)!!y&cW3C>K8> z`<9`MfUZfQ?c>W9@!xmxBH>6^u(@-2sM{{uF2p5PTCl_;`L5yXot~UYwD!R}4T|W5 z5SCjBw8|Fc&M)z;vW>r@RkL)ls9S2v(ck(%8c72oSraNhY)j89eF^dJda)bljSOCxz9;$Eg8~zS=QtMUgpY0P}cLpumW&IA9}mdxPy|zpgR_ zw<1vNez&A4el2kA8A((mD{3T<1$bbpOFpIh4a9M1iV2!jlb#0{J6x`YnoP8Lu zX&1T2;8d+Rn`w1`P%w?K0XLfJ`61PKdgBJYdMJSz?_#rlAuA|jKo^a3V~@u?sQrYk zIvJR#<%w+qiL954iw5YuLaH|$a81x3)(MH9Y&|&G9(44UQ)?HiQFYKG)VgF$`zJg( zaA+!Ie~aZ|kwcMM3g20A zhdVR$e$IQkNVrFycK42fD(-WiF28z}3$nh~;!c4n?H@9^_^8kLJZ%3eusuaq* z2fp%vcGCPa1V@VUJG}8T_JY1*MYqYMvxlZK)M9`{!@j&IA`^`IR0XfSKD7CV&k0*` zJ7G8A#i6WG2HaUuU@-PT5O8m3cPk_-_KsTIgj@{0@)i5(jN#Z$1t*IX-9%=!z3vp{ ztZ*leNJF7(+46p)-QFQ}t!hcobKT$Q#;*fx!eb+>NTM&aO%#I^3$=Goq(GfJ0dfG* zYkp`@H4c&taTa?xfN-6SX-Q+#c|7|GzzF!ozU#di|57!XWjcYA*CGGVl_;n#Q2^dO zG$rkkb20sX`jqjwpnwVcat$!X`XW@P?^gul|6HN3I=Z3x#azh@M0@n&7CPOg%KPdq ziVq@zh)qX5wMU4$a6wCk+VP$GfTvc8x=6Zbb2!s9I@uOf>uBcIofT88*KXvO|An{W!#H-z?+1I2h49dJF)96V%f2}xqLZ$dSUTW;^|}NaxdCZPTVh7H zU{T4V?T6l81CRwP>YazoOqke32eWuo5$5h8vX~!>9Oa~vGGHu$Vd!0}mIF?Dx_=KN zBAW5`=K{D(?}Al-0JQFo6jfV%5Qv2;82tpC^R<+v?lUn&CxZwn#_BO_>+nVx60;rl z5ir_H)d*lmgTCFj@@F*NY)<#!&%iii>{&xkj%S#ZCbqC_@keAJ5G$`Fl4A!0EFlHk z@D9Ic&u@rI%+iL#yJo0WCbwem+Ni;2wu$$9?6^WO6c(3@cd0=bAaZ(##zsz4e$iLt z9ar`-Y@@8J$>nax0> zp=$FD&bva7#x?K7Kf#M-zDJPO)(F8%i%Da}{Fe*`L2OaeK-C2cz#Fkn zjF7}-dynnAe?>#~DK7I$0!-ML%;79C%&%D4nxH1ONUwA&qU*j-?EHVg(HA2PXUJ4n z5vLbEeTi2{mWj`BHaAP7>`7{ZunhL0PZ zT?H~85iLi6Cr&liCp!Xlsq&lZgcg?@W{0KN%|U$fOfmwmEjDdLw-$9ne|n^y=)o3# zr=|XGlJ^jxIbjI;D`JB^6L(3OL{2N#)8p1-sAC)H12%1sy5Sv0cCx1i%Hc_QuOsXM z-phKz52u+hvqotZ`UzDe{7(MuaG6fL&&t9^PP?+|E_*6<6Ncx^dDh(sZRu1Nzf|zS zm>5y;a74N(a*8^M4P*mz36oCYZ3o5ACL|Cf2MRC69`S_EfTJ=2Oo|JSuf&xK(gJKTz8y{1iTIk8lvHi3p8_oZb&*q6{$AzoHtbidzRK5w1um8fC}|HSYTx=Xa!! z=0~=ij*M`WuQ@%t_^6`dL;wrNwyLsQukfYtpGwHj+%a(cpyfKsiPmr-g=DHT zFF@y2@>D{-#Mp~)gcj$=ujD*8YAV^Kl9xpePD#l z7s}5el!RQ(?VgvV@hp)HJnyzXYoxrMsytDi&KX?*TO{Cfy|kFZvJ7IGj8eu;&lS}( z_Hm#rbSbgsP+T{{$w=9$jA!5H7wv@?-^m1 z(7gR5acq8G{k+iu-7*y=fM+<>0{b6N-#C?wyc+4oKQ7uKkWsv9%Au?{6W5GTB5Y$t zfsBatBRPomGfg>H%=Yy+tvdZ>`R3yP@c_(!xL4TdG9I4ex0UYSUB*5vf01%3C3#ZuU5tI#uvdq9NfWm{*Lq5b=gsvJ?5l)9f0F>$?9YNfe3ZXXpdef4a*xnK78H#XJa!=0V*xjUdF`~j9F;bl zWJgEC4w9v0ghR*1fzN{-cL)Npi!!SbqoCjz z7-BUH%b+m$UEfC@2~8EZ&18MW=GiBGpx-ZYBG!8I7i9k@36V3DQu&=Qj)7FBHYv#* z52vTX`J!@Q(qFoH3`oSmyRD7fyT1GdXRIZ9uoOcv=N(6-xWWUxlbRraH3Y0oWI`}B zz9@EL6h?DV*Zq=1FjFe-((^l&{rFyP7)C)`rblXwzR75 zm129uFAZsd{tcfsSs=DJ$Mr&q_l?MZFA-j$TJ_I+d>o+VE+H_Jm3%H3s5w~nS3peu z+`$eMApm)#?H5ol0uN{M)Q>CC03F?~y6}}k*Khs{x{cg>&KMhuVv-gchPOW!>Wyhb z{bUhAS*UnG7-ZmjTr<+972HKE8({gopkXNAAuH&N`}>{GorO5cq2qA>Du)kRuqJVY zOOUSy^#}XsYr=e)hD?`k7qFRY6cI6X4Tac5&*9x0WiZ7qQn+%0v4H-(Dlq!h9>=kR z+;ilRD7Mh1?fCmPX;uP3JEBXWugd58Ew}$1%@aTlryF9r%Uf?h@F#s$6mY5=|+_kTc!5P0+G|b-++wP9oBjH9QsW`x}M- z!5Mz+iXBo>;rx|C`4EQsB`RbBIrvXqa zN3;lp^NrYq17oAoV7C&KQ!Mg0yz^!rbsTqe3>f`0!7g{%$d??d08z*m?@`q*ZGYeV8UcFJII z*IbP-3BikStarf`1+&4K3lm^j0@-fz<|Z|ZABL3}aDe1WTJ$_9O;$T|AKqqv!abPp^5$kSE1KJl&+%*+|Skr(V(3xdHqv`XQb{PI(nm2cTh{f|j{@9ZF==I&7?Li&N0zG}-rO!cMf+$pBv+5Avx_ij z2mRJE&BOe{%>haQQg;_UoRuk20M0;249!r z9W`CD=-3yd|KuJ4X&uP}^@9~bZ&_igQT!G=Y~s#Wi^0AuVDEEM1>wj*a$DHoB8ixu z>;7IKhE=bdl&Zp`dj}$4+%YWaU8XCk#ZTU)wO&i!WFTm1JcYl`(2i0GgG+V167++G zV*mn_&E^mjR;98s_IDW#KPTgpqcmiz>%k1oE?(%$>FE1+vJ+yn5}^XZvH=O4GET%I z66}(iktt)<47D>(TGCNlcb5Xc=R}>&R<4HP9H$)?JBea~l0Z#>qWKWCQ=;1xF6`CV zMWWkC8ddjP^+4{JaASG_93WC@jnizN%WR&R&qUklPxFUlJP56jLi*jn5e$!zGcN$l zNKeEYL_ES*8Nxy8hNcYFT+MPLZRO)ZBP@T6=Goc+Y^b@KrCDWqTF8T3vFTJ`5eKmx zcj0ze(hk!o;}JiZqJ_j3N&5{ZELa!@od|?V^?!)J0;a!$M_f>V;2q@i(elk5mEbA} z1}Mn)IN^L3QGUYA0AG|pR;awg3>IMX4j)+1uJF46tatf^Hclj0QKUF?M4FE+>-db- ze8uUfL+xE0J)|1~O*Y&RG=XcnN%TsqqK`V})Z)HVK)$y*kVC6|kvEhF-4SYA%THAF zv?n#FPYbjU!4AzX=#QuXeTCTeo2<%C+~5(4Qe9~>cxb*!x{&+WS7aL6qVE-w-l_5k zXprc#Ki8f;s*-o$N(&8>C^(4_3s87UIOJ~=OQEkL2IaJ(hiB}&$U2f+BkA9ShO<$E z1Cgn6QnRBdpz`uR{1GJh3mD@^PP#(U#lkC|?f35RBgZ9W*o+QQnsQKvv=MRMVwfc6 zLL0Au_6ssrG++d`QZB!1W2w76?kXb> zx?*y?q#8y?%&Gw%XfL_|yxCGOjGz0I>#qwP#U?SFrS81$d7*n#oXtun4q_q=U%9%v zjs6N;<%Ud}BhiO&$i}OLkE|u~kpw=%GMP+^QvQG-YoLkwhVy7pX-Id zX77J7YVM)C_oJ2RI`&$a%^2|>`i?^3bkxt1$ULV%beN!4qZX*H zTAfOx(0s^5$E%4?i_IG`Vx)H=*W?x@rO7F1=tHRwkx!iY2TGPUn9&3@wXjzfp^Wf; z5Op)dQ_!_&zHEhuYp0HO38ZMa$B~|rHGuTSXw3o&sZ>R}UHr$HbH`2hnxKV+(iQeg zG34yVS<0VK5~Aa};NV2Djnr+N$NbjZSg6^Y&k0!w4e`KYI0BYYzQiXD<6;C0@xN(* zS4+Yw_tz?Gw-fXTlDC!;gIN1__s~%45X^|Eq()NxP;S>$d`H_3B4*tPONZOG*=0uT z$6!<@cUt7Ly)RZ_5&V~eN5?%P`3p--n3!G)k*z~k^ps87haq%Af(U(gV!UdTBaIuJ zQY|uLw?1x$mV!Qe&&n7S+6g294;m+BPmp=4^cn;gDN`=GW*?i^(Ud+MoVWtv(m!%meUUz*eE z9UvIA!@RP6#v>m|y(3Mz0wl*6C z*Wfco6SN!&jX)9<8&B3ug%zF1OKQ%b5;P#xqRWB(XhqfioJ$ z8jSkxeaBvNFvwziHz+9sf&Je64xt=YF)XlnM>pc1@U(%mlPfw^n5g!%=A7-F~Jx>hF4>?CyumaZ3 zGdLhXuo+yR9=Hd3J5X12BWz5(C?PHk8)Q>LQ0$Yn8kff^Th0!yU$RhG_SLPM0~}S6 z-7J;^w-z@wnvlyDjxdQDX+%nWmv7DHhV?3257{hE9)Af* z7^kFRssDby5N#1sMH)co!t=Kfm3rleB{`#b^Tw$2eT55^@(D2+kO~%quS!NuS=>2sX*!+dRVU&4fGH^MIYGdgU?yS&_~1QPKM z2t^G|<4N-6kuaF0tB>dj-607Z+kH?AwXZ;RhOw(h)Sdb)#L7p>0uHin6c&Q@>?DM~ z6HA7~QBZG&7XjftKnpxLE2kmSI4{B*UV-b!I0k64Na$=zOph3ZC^Qc0ar4Q+`&QY6 zbQTYhEL)UA&l3$bu?H&`c?`rX#;z&Bd9NS3D>xBrtQ)^_0ih1%ub{wkOZt1T_)~0x zX>RT3y0{!DUPv_@y@fCkeBffGo}tB@43g-)=7BYI7?XLU@ONxIqj72CYDCk9GRO}v zMkriRTSQNjrYLC)NfO~dC|p9J1w&%$fS7GZSM{#S_dkuiOO_NqAwnA}d}`a?+8{6( zk~%9A*L8!+;04G^nQ4_Ao^?q?&_DF(Z7IQ+X*7_Eb@^2e&qn(=1P-p5pwXvAP!#jn zUT{?+bl9`Dzx?t5$}TdlC9#}~PBG>1{R`IV$Q!b;px53yg5`&L0B09N)A2hMMsu3_ z!L*?$lpZi&^s30+UUiB`+^H2+S`N%Tqx(#23yOm$UI4T}2)~0v$_3>l*^LQxmR_C2 zq7Y2r`e-`1Zfq>stg^^Q@KSBON2=_`yN94W_Nch3Dl={V-BAw1!NiQjQsex^*lt?h zvlc$x!btI0VmBJ3VxPq3P8=y2z$^@?fFhHUzVmP?&4%qIu>>&K{H`M0m z0$4I~%dXu6A&tejD1B!wo0pKA1qO)5b%vsJ$eYO}LBZC-^9$X1@vp`25NPoMI=a$2 zuHo_SST>9DcVDgKU=KPL7=tX+s!Q-B!k`d4EVP`20B!cdNynP&u^+5M3rgif73R8o zh9pI4K&ywpnWgcR_kq9J1Q_o>7&5k8Vl0+_PFUt}?MZi#RR!VI7n}vd{om)@cUd7R z+zN0;0aQaKLGmhGy|Y}J%Z>29dWa=F(b<)9rlZSOhO6ImOi|uriyILSR?H5nf~sNC zruT*+86P%9F+YQK;p`H*4y5kPG};1F4k`uHk{JoBomL5qgC>D)kLR#!QU$Xp8Ll~e zpBD3fLUyZ5A<5Nv?t@y`x%;7CqW#^p1XjMk`F+So(qg z;*p)js%&{dx-w2O3>`JM6u#iAA+;@o$L9M{-F@3xmY^LJ$eRbNwk3W23tb^nR zf~V*7EPhL3q=YWLuCL<95{5{#pBh^hRNMVX&gK(%)O8gHD)A{ZT;?ojIH-V033y_C zp-K*dBX|V;$l++G89t15?A`uG(eI;1f90tXD$2o39aJmfP)4_bi5my}m^m^Ke+4QX zu4^~*!%aj|4)wiv$Z)+wTK_NR&a}so>pH{V{VNCwMr>d#SJkb%l=HzD$+0EJvW-ZA zz8D}l-Ry4KY_dbLJ?aGh?>Wy~Y|ZpJZOjtH31G-G94=M2?sArQ>7t*fy^1H>@LVuU zObv65Ab`?>mve1Wq7fT(3YN>3ClIGd^M<3xO=+Y}@HK@|T)dPeg)H&R===^|Tus?I zJQ6G{dzWegM4hqs=;(N2hH8{}8`Ej=b#D8RJkT3{qqPQW2l~uVa!{40MeokKSr<}b z=cCtbwGkSSa;PwohXDE!3rJWacrMQ43bZGsB77vf6f`oa6jAV-2;M>~l=`V{gd}`) ze(UHh7O3D-aN^J#d`-D$vyy=p{F#uKQ!K!(tqwpO50hB1)NQQtNZL9&wGHEHGplXjSduIrX zkU-&+T6)s;0Hiy~6B0c17%dp+NhZ1n!1w}LG^4r!C_yvj>6~U-kyB2+5RV~AqWVcy z1_3{ZWdNqe>`~y@ybd>HTI3@vziZ%Od@H=k?gZCAyiojdDNtWgPO``4Nn2w#Cswp7 zvqNuM0&Apwxm35}W`+KHF+Z{F>hx zCKUC0WZ^UJE~-$>*d*UR2!3=Jp>yVTQIy=aF-x+@CqILp@ZxssDXp(msYaC;4r zGpJ|;0#vE@c`+pjV89I&fIowZED*4w5Z%Y)lc>-EB1Aj3c!^(}ND+iXkOWAjcZY=n zTH%OVz>qwq837$mqHJH$*AQ6KJ+#R|mEHHtXn2B$?*z&0Vu_Fxg-W1Ere3h%?8+eJ zY7f<&22m1+MAJ}Ho(x6#%=U39UKgM%&qy<&&abZF?06J#e3sV~bpN3MXMbLx zV@i4?T1jLs$jFxa1~nOUSbI)ZN;OC?l{SpegfH?d25%F*?K;T8c|PlWYJ^EKkk?Tq z#33Np5I>}O!z%22u}&2P8c%o&_rCh1ZbQYfmZunLAb`@LRLeH65B?2GPr(+&`~;;b zE=`aO57Qw(#Ult`PcJ-=>5&jv5~Oyf(l`(!C8Z9nw-2Ixz}W`;)^5j}?R3)cM_Zd@ z%zM>>hiqs%O;J}9$dA-cX#sb>vV^QoQyz}OVURNiA5k@RrZF1FNsvY6GcukExDtE= z0brrb<-lKdP~b@bJD7aUhdziCf@Pq?+l9=e%B?bKFAOKbIP*b(=g3jS#V-ml3Xyys zqZrtLr$CcRC{7N+W-ZH$slv1BviFjoi0tWBAUEf&*Xc%adInidh{&A9z>yI54LlVv ze+|^JU@$0G0~c!mMIv5;~EK!1>U&#Id#wPl{lstp)t1T12D)tQ9Qvtepz7ER$EdnXQISQ^{K{_3U z=3wmCGnFk<2A5F+lo?_c`u5aVJT#L!NC!EO7U?uTgX@M3UDgq(Oq#tB9F{S4}B|C_v6@kVFI1XECiMN*OX+1hHbsJC@lhJlv0T z^TFgH%Q%Eec9Xaa*tp$1Mj8Qg%x$OJyx?R03a_i_WC(1Z!l zZCQmt2$<;bgmM-D{93VRP2S}xBRobYlXN=t8`xsuv4ifLm< zL@ii>p`ygO=x;(^2dMYau8XKhOdtg=^wj{uGYdMXw*mJKy<^fs*bt2aqj-%DM5YnA z1%N)eai{bT095v`KFG#Vwh)<4p&vqC)cWB1}mhwV|8B#h?uNui- zr$Z1?wml4a$N*fFPGO12wP*FT(Au!$0mWD8(zvslqBp`3Jyhrxgc`utV?S3V_AvU` z1YH6d+$p#BRGY%|f9C0*NmxWBfSzTSpS(H7CrvbPpzw)Tpmnam-4 ztP^qt2#Zj`3IB}Jg*-J;efE}bgJ5H@F@!4&#t{{RLe!A@$$KRLOpu99CwaBNlY=OQ zv^MaQ=tl$giUSj&m2rp^O~lJ(iCl_L{N)%7WCenWr?e-U4oSw!JJ zD_HNt3pxXp6=a+OEe$}46u}TBsZ&(KpmtMb<~v0LXx48pA{h9;pfpQ`jn)=OY2VXE ztG{m&aiR?3sRPvSn;^DGse3jQ%$|vk3K9<`S_%Qn%M1#nSoaw0Yf0iVy+@A@f&&c= zo_Yjg)q&tc!xdAQP7+eRHvp663#H6wG~K(oS&JYEG95Ka*-h|h5P7OmV+C|DFlS(p zla~%kb1BnuIZE%r4Pu&27CT#S#nUYCWDwKSoYG;^w9`<;DdlPwewK} zsLEc|{4Db?PvB%U2m)aU`F1(M*dWIla7ocsd=fi9TS#ZKqlG2Nb_Lm7-U`o<;fjK* zPCi+#T?T-w%#uYlkewcdC@WC65TDL^Z`2tP!R>?U#E2XDG{aMRpK9at3BV@{a_Ebo zy^J|=&jdwKBQRWg@_3lSvh|fr>@^e3$1&#Gj8&0B3!Jw=90HPtW(YZkx9>x80q;fd zY-9C|W0C;UWOhlfDaI0`>K}M#eH?4sY9o z>$xC~!mPvQQD+RDpv0FqK$5(G*vtlsR!xHmkSTK^49K}?H@&PZ7P!yDt`ut-MUZ ze9;SEmX6v9zJ4KsOS9kzE@=U=N*B4zbP{?BejkRI%{!WitCKgGMmVTgkUv6Atx>OT zq6s;1{6~Wm6wo9Y3Fh6y%8LXVZM?t-m*D>ioIKlQ57_F2mBLGGEow=Y6hXSk@6xxc zva57OdaSE((j+D6`XG>9XF@8;SoOUjNgV-9sX=KRGg_RHL9dM-3@NH3P+5?t;eyfn zX`{{~%!5NiirNRNUh=h_!tqX5h)s<~wKubcYdT>FAv8?1d@kLY&5-lc2Fr}UFETj6 zMVy|OH)kH@OafG|;7jJOS_)k_AoQ3L2RA|@(h;y^YMmQPBm?KHf!M}@S~yQ+t~f~6 zB8Wf23IU-WUg1`@L`ED9TyaLAY=-#K;8RlD$iS=!jNpMDa!u@|kB6qPi-dK66Xd~x z$zHja?4BSiGu7QNa#Y9~jrMnqc-2YgK`&$IA)tG61Cmi@SZg2X$b}TCDbMxbAY6Db zKw#1#VLp18v?e4?yIi~w8g-XpMsNvl9};lv(o{P@+c7?(LJ3&;2CjF_qpZ;jn`od7 zfnzfWC672y3zBkD#izuJJ#_3t(-+4AUFNv~l?05HdO0Ekt7)R1VJ58i9pFmsDyGAOs3-NeW+TY$nfY-?O;NUg7AnY zR1G_9rjiF|L&ig{=!&_Mtscu!o~j%D?5vE0_X`a|nRWOWRYa~b!>_$I;iTq*eKES1 z=}AF<_{=_pX_Yovdrr?t3=gXIV@idRP3UYwGb3SVlTTrT=77VDJ<;Zv0wjcrD&WN| zq@j!JC~k7{(jiSP_=|AbSE@E*$JZ$K34ads9C{@+P|-$Jm248gi19{V9UBXdc0B}Z zN4-K18BI=l6fWW~#0B8*v2t}au1?rsc(YUmfIB(R2Uey?rxDKKur}6$6ybXwisu5mL6|OD2PHC~g1fM4(w`*rNvOeA>0H_}MdZEkV z^=FhQpqEA=v#aqC1UX7^g#6oAAP2eDG_*-u_BLB1pmD$zfbv4~QWUpQ=5O#=gFO%! zK)^TPDt%>Ky0Luk2&5}`>_T3ku0oj*v5$bDA@PxPdFey7A7zM2@Fnp>k1ek9r^i{6 zN_9vkAeCk~)dxQNk1 zIn=4A5&<-QoPs?d1#l|5YOqDo6VL(UBuH=&8KyQ(nMwo$AFPlwQuC=GLZX8gg<_kM zBU+;o#g80n6U~Ur^NL-v1;^!$B-@K_k%e~k2c))7W!s?pWUTBd`LZhESEm{^bnb9s z4QPWjR@+p3lFU&xgG{gq?}HYc4035B6>yaSK@M`ZiW1m`(Ms}W<%}-0!cq_ru_rLe z8_CO0a8(H}S_KC=Xs(MUVcDzEgDnYVu8D17Nk zpo|VhX>O+)SVIj+RVT~Z}deyxw9EO0@%~}0;z;G-`B#_ zFOPCU79zhy)k_{c;bCT41w;+l2Kk2LF&6c~0d`XUwiMlKNRIHiheu_#+5WpNN zD?|+@tfHQ8#SjV9T!a$ahF_%cD>zp6i4X->BH&p;D!Z;2nsM^mz!a-^DDkAWg&nDEw3 z&Jw9~o;MBjtO!asMFVzBtU12ZC>)Uw!sQv9fJg8=hOiSNr99}U9D+oYxFn?N^aVje%Dz|BnUe4F7FFIkNCQbg_fk-W!a8K~$vM#63cCjZsfhCsv4?yS zal_;VqAd_*X7!YyyD%2E{@Mr?+qQi#TWhI|qz@A8lfhRC?VPg27(5OxxxNKKQIi{* zxmaGZ5Ij5ro>c7NHwPnf+_wtdw(?Xta&B9aw`YmGOuZMxp^`w7BhNdJh6Uy*;}?8A zeafh4fW|X~_%WKLp`J;&hkAd=uBm)+^s@W*URw$cyFvs_Yq8`Oeiq7MMz%H&zJ`IJ zq{ZEsVKnG1ajFB)9A&VAib3#`CFp`_KXgw$MdyPOMFfJ_TDuVSOHl#T^w(`e~RbdJ|2uP;{&+ z*>}ypK}<;4sLhOMz4+)EaP=U_I+dh=+(+P&bPjzB-?oM)Op^fLL3Ho@`HRd7JZKQ4 zkwA?jJj`lTtNRF~!Fw02BNcV}Kv;yMiYv5u6W!qR+$NbMc!l*T^lUB}*d60XIiP#s z8oA^*c2oE;=D~}9(Y2y1lO>T@0-hDlD4<7Z`yK+O(3UpdH0u{cxoeg+C}U6rog^p;A7y z(RZPtCd!WpRK*l(H&}6^w2bYASKc78=Fm%$)pEfwbr}?361uNmV5ac^30nz3!n_5V zV0-mk-S?C{RO1@}Q%qhUM!KpG{*0XNLXvtAw*`0P6Dk{*(MOdP)(I3)gav}6X__Q* z3o=}kUEdC(Cu6f2qG#}xBc)-oSIQM(LnM)%d$H(|_=uBelZsLWtLqLXR2wWY>zSED zkZu6HpJZh=3`}^xnIf@j!6B_&3=ECX zN?<603|v%7C638p=7@S)6pf~$E1~m-+nvYwl_W39Cja){8$(ejs_-LG%l7#wB48C6 zc6mVtiU%c7TTWR&(1(sZu=P$00!yD74Cet4(jyzhxbmqoNs#&{I224{kj+Xw3Oqa^ zdbqS?oQS2#v`QaTiB(yVav8^0@t(&CyewE0s-aVG|0=?efI)||@9~F7v~q5tIo&zA zYM_NsKn4`>JVOtkYJok##y@10$kliBtVK%zM}Y}4p>5dX$y%YzSJ1rC3>*DPM7HUY^nv?KW1imq zQ^T`)q*x~?$2YGJdzK%dKc`8MO3*w;-3U|@J#32Q&zM%iJ77obeE$^~3fU08yi0FGc9>tYS`fEHz8ZZN$g#Ag%z|jd_>v9!@z;o2${^D=TlGI z5ehClG!7zDkJfkURKSyDuLq~&09S#!===!kwMl9@JpIvuP?0N2+U2Pb1oLQkVU*Em z@c^#&*jPv{+JqZGLa(u=lZplm<*+`e4$R^Z5JAYr%5yXiX0=KIeJERsQv(-|Eh1pD z>D)9*GM!`;L;cB45HRSF@^ZlEu-lfIz?NdJC5^mU&?%}SA$i)h*yHn(M*A{!{>k1#9*aHP8<@9}*p+J~YxQ8$9He{NsP%8-oa@Dt(f2@mY`cMh#aVPOG%;C zO*xp0;i1iZZOBYSqa^^WV(VahnuF3aC`EbHM*=az;oxVo z{Kegzl5o+#kkqe@S$GS1{9B3b#;K6BNoIHCRnG&E?0Q@dq`c#3;p$@|Q)k%5lGHCDJh*A@ns29V#DiHD(# zm_FbNj+%*gXs|5?;4&#EhLl~<${V24Rpl$*voY5ZrF)r?mFj9Xs72UJqp}A6)zx0X zrGt9`OrHi<)GZU!8@E{)458HoSZDTp=9UARvIiEIrGOP|mwO~iuw$DWexAyNc>Dki@`Q!rj4-SCa35%E8?01(M6Lkb<69<{kD>-btBnG$_Z`>rGWA~$=e;dem zl@UYHg$7ZUND2_+A#mBy>!Y}d(6Faz;2@f-TGet(@tGp<{|aoL>RBILXc<9HGLDPX zIU+l7^&(4nh_>PJ4pIzd=hpe@uq2`!p_gqtmKR4FXZ(03o<#zO;%B^jv{WaL;#y?jKEs4mDx3Yjfn%nBWDnOCMD2S6JJj7S#q zsQ3LipfmE@ZDE_F|3fXP#|y)0J!J{z0suxx%BQt3GmFlBs-M9H5aU7sSLikLk;ezegR)FL!L&YdwXAR6|(~*oy>lQF%TNiTE`|X$p^&=_@((Hf3Ll@~aOfYB#H0%8Mj$k!eA;G(Qe30Yg;;Wn+vxESK#423*d5rczl= zLHle#DguBBb8GONL`)cJmU$N#@{sO{@k=TKx5d{9@g%mV1W>n>$)f}ygbuhwz>D(g zCg3os6`;sj`yAMtf=-d9&|vWuH*!|} zPom7Qa}mbD7o!A$LOU8|%p|qp>i2*Ic@ToV2s|FXgrtseDe_jE0%x&E(Lo7Ae#V!A zV1-y$exFa{TykdQ=U|2R?$EH949OfG1LNXJIJCl$D8b`n^uf%~{}4kn*jJGtc!6#^}RaPIw!nXN7S2xJSCt5(!FUwWYRx<5cr+Ix|Cjm<_>D(bciq` z3OfhoG#e{<8;qf8ioy^87VZlksAJjAmh@7X?34G+q)l&Q0e^@Tw>}C+|LF0*f8+h1 z+#U9>*3196UmrH><0rp3>q!6C`Q4vvc9-k#?d3ON2cNjTJK1ZW{9(8FdUd$CS=7_b ziv?}67mL%)dU3fqtS?Uc!{WEA+x2nrh5h%i-!0bP-);AY_2pu<`0M)cu(({W?-uv= z1Bc^!bzGbd>)pl8XN$$l<6?icczJ()e>^P`Uuz4O)mW_!8WT`xAf#W$}QU8 zR(j!Ky*ezuT;1J0EPl0mI4)XxR#`u@-_^CHr*jqpKWZ?V9f!|y$9`^T()g=RX zy|$k{=(ktb8j+W~)#4A^?SAp){c3y3kX*j~&;0SihU8=q-2VF2@vvHNZD3ZX^SCT7 z_xgVOW_x+P(_fDFuTF>6g+2Z}KQ4Rma31Y<-P)Ti_V3-=4|7VFv6+YR)#mz|X>?d` zH`f~*@XhYY#oM!7+^o*;<&Br?+x>-IJPSp7FV{8ZCV{Sm+Rf>@rjpk2BpFTgM{r2JZZgcUjD|yR| z(~s}x-+r^WUC%#$f7sc*W&NB@oAWkv-0yc>{e1uOpN{9tT5Vt1m2x>YcWftn z=jtnO0{yqH!=Be+&1_sPu2#4E`{Ux<_4oFr7n>8akAGY-;u;q=yA!t$nA`ToBah>_ z`F?S&EtiMJMMFP0?;2m+-@UMp*(2O-AI^92nuq#0Bd#`UdmGEw=9lfX_PDpq+ant+ zZ`!SFXja?B^v8?&`rkEvZ#oG6Ii45uUc1!JzRT`;eZAS~F1a`RRZX7F=|=C|u1}1_pFX+# z(dp=9UI*J zYJ1#k@DBI4Hj*}M8@}bQ>+Swd*TQ+XI`MD%)s<~Dc4?=L#?|iRa({h%Ty96Zk;SXS znzQq0XYPZ2;Oo`*i?6=2YgpSEdVjRd*PfP*#?DT}{W}J*w)*S!(aS*wK+W4 zWpkQeF8J2-wLBi~y0~u-d%N$&U-uh(knGpo`QqYc#pZ3pYWHljnS=OdV-NW2{a-ih z$7}o1*}dW%f}Hku%fGx=0HSv;?+@EI@rqu*Ii2o~zxesj*SpVNZ~n5mTVHNgpY0FV zKj&{hS1htT$0DEpdb7RV+eE&*dHCeTM}5Ude(eA5mp^$D0lrTV@^J%gabQ_$&slym zN0l%Be}DAb!{%^2#>wSGJgb`XRsF4Nrdi(=TjI?T>awE?@1xf6sw` zDVSQ zanHmK_2s|V&c45841V<(4lcjaFTD5%f6W}WE$6W9{XaGb?H};|-6gToX7zrf{C3#; zaijeC{;)YNf4jfg9Y17y{LW7A&FXgf<$8B`Sbn)W*tdMp_PV*feX#BIU`O5if9mu7 zZgp7xZuRD4x$J`$`q`$XCy4qPtXv=21wusHoG5xk$(JgIxc_5A3jfa>;L?Rf6Z@Kf7!oUesghi&)2=*u)m#P zeC$kq`RT7HT6}1P@X;Uq>R11~{Nvg}_vPpRfM2sb>(loe%eRxHj~mM`DDUi+pYLC9 zmp?hqiPHz1>i@%!|8S1p9}X7e+uHr}-j@A(b$xF`eGW2~=kGoK!!GOjkH6m&c`NPu zxF@2_#YW=$*n{}23zKx0S1VDXD(pr3xZUTse||BJu)B|GHxW`A5A zF7~&pPrrT4?T+s^v~MS59~o#U6I=e|%F>IUepq1f-JTlk)&152gS{PuHfiS*@tgZ= zJE=bOE5ErptpD=q%ZrQsc6Iq-arnpG!jq3Z9d2^WAN}pW0RRC1{{sMjPwprfyAc4M C_hwrF diff --git a/e2e-rag/scripts/download_dataset_and_models.sh b/e2e-rag/scripts/download_dataset_and_models.sh index 2626985b1b..3eea7bc636 100755 --- a/e2e-rag/scripts/download_dataset_and_models.sh +++ b/e2e-rag/scripts/download_dataset_and_models.sh @@ -48,6 +48,29 @@ echo "============================================================" bash <(curl -s https://raw.githubusercontent.com/mlcommons/r2-downloader/refs/heads/main/mlc-r2-downloader.sh) \ https://inference.mlcommons-storage.org/metadata/frames-benchmark-dataset.uri +echo "" +echo "============================================================" +echo "Extracting frozen document corpus (docs.tar.gz)" +echo "============================================================" +# The benchmark ships a FROZEN Wikipedia corpus as docs.tar.gz. Every submission +# MUST build its vector DB from this exact corpus -- do NOT re-scrape Wikipedia +# with download_docs.py, which fetches whatever revision is live today and +# produces a different corpus (different bytes, passage counts, and retrieval +# results), causing DB-manifest verification to fail across systems. +CORPUS_ARCHIVE="frames-benchmark-dataset/doc_html/docs.tar.gz" +CORPUS_DIR="doc_html" +if [ -f "${CORPUS_ARCHIVE}" ]; then + mkdir -p "${CORPUS_DIR}" + tar -xzf "${CORPUS_ARCHIVE}" -C "${CORPUS_DIR}" + # Ship the fixed URL mapping alongside the HTML so ingestion records the + # canonical original_url for each document. + cp "frames-benchmark-dataset/doc_html/url_mapping.json" "${CORPUS_DIR}/" 2>/dev/null || true + HTML_COUNT=$(find "${CORPUS_DIR}" -maxdepth 1 -name '*.html' | wc -l) + echo "Extracted ${HTML_COUNT} HTML documents to ${CORPUS_DIR}/" +else + echo "WARNING: ${CORPUS_ARCHIVE} not found; corpus was not extracted." +fi + echo "" echo "============================================================" echo "Downloading Embedding Model (e5-base-v2)" @@ -83,23 +106,26 @@ echo "============================================================" echo "" echo "Downloaded files:" echo " - Dataset: data/frames_dataset.tsv" +echo " - Document corpus: doc_html/ (extracted from frozen docs.tar.gz)" echo " - Embedding model: intfloat_e5-base-v2/e5-base-v2/" echo " - Reranker model: colbert-ir_colbertv2.0/colbertv2.0/" echo " - GPT-OSS-120B: gpt-oss-model/" echo " - GPT-OSS-20B: gpt-oss-20B/" echo "" -echo "Next steps:" -echo " 1. Download Wikipedia documents:" -echo " python3 download_docs.py --output_dir doc_html --format html --processes 30" +echo "NOTE: The document corpus is FROZEN. Build your vector DB from the" +echo " extracted doc_html/ above. Do NOT re-scrape Wikipedia with" +echo " download_docs.py -- doing so fetches a different (live) revision and" +echo " will fail cross-system DB-manifest verification." echo "" -echo " 2. Build vector database:" +echo "Next steps:" +echo " 1. Build vector database (uses doc_html/ extracted above):" echo " bash reference_mlperf_datasetup.sh" echo "" -echo " 3. Start LLM servers (adjust paths to your downloaded models):" +echo " 2. Start LLM servers (adjust paths to your downloaded models):" echo " vllm serve gpt-oss-20B/ --port 8123" echo " vllm serve gpt-oss-model/ --port 8124" echo " vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8125" echo "" -echo " 4. Run QA workload:" +echo " 3. Run QA workload:" echo " bash reference_mlperf_accuracy.sh" echo "" diff --git a/e2e-rag/scripts/run_ingestion.sh b/e2e-rag/scripts/run_ingestion.sh index 7b3348bbcd..f4c21845fc 100644 --- a/e2e-rag/scripts/run_ingestion.sh +++ b/e2e-rag/scripts/run_ingestion.sh @@ -27,7 +27,7 @@ INGESTION_EMBEDDING_DEVICE="${INGESTION_EMBEDDING_DEVICE:-${INGESTION_DEVICE}}" INGESTION_NUM_EMBEDDING_DEVICES="${INGESTION_NUM_EMBEDDING_DEVICES:-4}" INGESTION_CHUNK_LEN="${INGESTION_CHUNK_LEN:-768}" INGESTION_CHUNK_OVERLAP="${INGESTION_CHUNK_OVERLAP:-32}" -INGESTION_RETRIEVER_MODEL="${INGESTION_RETRIEVER_MODEL:-/data/model/e5-base-v2}" +INGESTION_RETRIEVER_MODEL="${INGESTION_RETRIEVER_MODEL:-intfloat_e5-base-v2/e5-base-v2}" INGESTION_DOC_DIR="${INGESTION_DOC_DIR:-doc_html}" INGESTION_PASSAGES_JSON="${INGESTION_PASSAGES_JSON:-passages/doc_html_len${INGESTION_CHUNK_LEN}_ov${INGESTION_CHUNK_OVERLAP}_word.json}" INGESTION_DB="${INGESTION_DB:-vector_html_hnsw_len${INGESTION_CHUNK_LEN}_ov${INGESTION_CHUNK_OVERLAP}_word}" diff --git a/e2e-rag/scripts/run_multi_shot.sh b/e2e-rag/scripts/run_multi_shot.sh index 445bfd3bf9..105315e1db 100644 --- a/e2e-rag/scripts/run_multi_shot.sh +++ b/e2e-rag/scripts/run_multi_shot.sh @@ -14,7 +14,7 @@ # e.g.: INFERENCE_DEVICE=cpu bash scripts/run_multi_shot.sh 50 # # Prerequisites: -# - Local vLLM server running on port 8123 (default) +# - Local vLLM servers running: 20B on port 8192, 120B on port 8123 (default) # - OR set OPENROUTER_API_KEY environment variable to use OpenRouter # - scripts/run_ingestion.sh has been run (vector DB exists) # @@ -38,7 +38,7 @@ INFERENCE_DEVICE="${INFERENCE_DEVICE:-cpu}" INFERENCE_EMBEDDING_DEVICE="${INFERENCE_EMBEDDING_DEVICE:-${INFERENCE_DEVICE}}" INFERENCE_RERANKER_DEVICE="${INFERENCE_RERANKER_DEVICE:-${INFERENCE_DEVICE}}" INFERENCE_DB="${INFERENCE_DB:-vector_html_hnsw_len768_ov32_word}" -INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-/data/model/e5-base-v2}" +INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-intfloat_e5-base-v2/e5-base-v2}" INFERENCE_TOP_K_RETRIEVER="${INFERENCE_TOP_K_RETRIEVER:-15}" INFERENCE_MAX_ITERATIONS="${INFERENCE_MAX_ITERATIONS:-5}" INFERENCE_MAX_SUB_QUERIES="${INFERENCE_MAX_SUB_QUERIES:-3}" @@ -47,18 +47,18 @@ INFERENCE_REASONING="${INFERENCE_REASONING:-medium}" INFERENCE_MAX_RETRIES="${INFERENCE_MAX_RETRIES:-5}" INFERENCE_N_QUERIES="${INFERENCE_N_QUERIES:-5}" INFERENCE_NUM_WORKERS="${INFERENCE_NUM_WORKERS:-1}" -INFERENCE_LLM_URL="${INFERENCE_LLM_URL:-http://127.0.0.1:8123/v1/chat/completions}" -INFERENCE_MODEL="${INFERENCE_MODEL:-/model/gpt-oss-20b-mxfp4}" -INFERENCE_QUERY_MODEL="${INFERENCE_QUERY_MODEL:-/model/gpt-oss-120b-mxfp4}" +INFERENCE_LLM_URL="${INFERENCE_LLM_URL:-http://127.0.0.1:8192/v1/chat/completions}" +INFERENCE_MODEL="${INFERENCE_MODEL:-gpt-oss-20b-mxfp4}" +INFERENCE_QUERY_MODEL="${INFERENCE_QUERY_MODEL:-gpt-oss-120b-mxfp4}" # Per-component endpoint splits. Empty -> inherit INFERENCE_LLM_URL / INFERENCE_MODEL. INFERENCE_GRADER_URL="${INFERENCE_GRADER_URL:-}" INFERENCE_GRADER_MODEL="${INFERENCE_GRADER_MODEL:-}" INFERENCE_QUERY_URL="${INFERENCE_QUERY_URL:-}" INFERENCE_SUFFICIENCY_URL="${INFERENCE_SUFFICIENCY_URL:-}" -INFERENCE_SUFFICIENCY_MODEL="${INFERENCE_SUFFICIENCY_MODEL:-}" -INFERENCE_JUDGE_URL="${INFERENCE_JUDGE_URL:-http://127.0.0.1:8123/v1/chat/completions}" -INFERENCE_JUDGE_MODEL="${INFERENCE_JUDGE_MODEL:-gpt-oss-20b}" +INFERENCE_SUFFICIENCY_MODEL="${INFERENCE_SUFFICIENCY_MODEL:-gpt-oss-120b-mxfp4}" +INFERENCE_JUDGE_URL="${INFERENCE_JUDGE_URL:-http://127.0.0.1:8192/v1/chat/completions}" +INFERENCE_JUDGE_MODEL="${INFERENCE_JUDGE_MODEL:-gpt-oss-20b-mxfp4}" INFERENCE_PERF_TEST_MODE="${INFERENCE_PERF_TEST_MODE:-}" # Positional args override config. diff --git a/e2e-rag/scripts/run_oracle.sh b/e2e-rag/scripts/run_oracle.sh index 7539a6ac15..8e57ab687a 100644 --- a/e2e-rag/scripts/run_oracle.sh +++ b/e2e-rag/scripts/run_oracle.sh @@ -26,7 +26,7 @@ else fi INFERENCE_LLM_URL="${INFERENCE_LLM_URL:-http://127.0.0.1:8123/v1/chat/completions}" -INFERENCE_MODEL="${INFERENCE_MODEL:-/model/gpt-oss-20b-mxfp4}" +INFERENCE_MODEL="${INFERENCE_MODEL:-gpt-oss-120b-mxfp4}" INFERENCE_N_QUERIES="${INFERENCE_N_QUERIES:-5}" INFERENCE_ORACLE_BATCH_SIZE="${INFERENCE_ORACLE_BATCH_SIZE:-4}" INFERENCE_ORACLE_TIMEOUT="${INFERENCE_ORACLE_TIMEOUT:-2400}" diff --git a/e2e-rag/scripts/run_single_shot.sh b/e2e-rag/scripts/run_single_shot.sh index 148ce38b57..f0c4503f64 100644 --- a/e2e-rag/scripts/run_single_shot.sh +++ b/e2e-rag/scripts/run_single_shot.sh @@ -28,11 +28,11 @@ INFERENCE_DEVICE="${INFERENCE_DEVICE:-cpu}" INFERENCE_EMBEDDING_DEVICE="${INFERENCE_EMBEDDING_DEVICE:-${INFERENCE_DEVICE}}" INFERENCE_RERANKER_DEVICE="${INFERENCE_RERANKER_DEVICE:-${INFERENCE_DEVICE}}" INFERENCE_DB="${INFERENCE_DB:-vector_html_hnsw_len768_ov32_word}" -INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-/data/model/e5-base-v2}" +INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-intfloat_e5-base-v2/e5-base-v2}" INFERENCE_TOP_K_RETRIEVER="${INFERENCE_TOP_K_RETRIEVER:-15}" INFERENCE_N_QUERIES="${INFERENCE_N_QUERIES:-5}" -INFERENCE_LLM_URL="${INFERENCE_LLM_URL:-http://127.0.0.1:8123/v1/chat/completions}" -INFERENCE_MODEL="${INFERENCE_MODEL:-/model/gpt-oss-20b-mxfp4}" +INFERENCE_LLM_URL="${INFERENCE_LLM_URL:-http://127.0.0.1:8192/v1/chat/completions}" +INFERENCE_MODEL="${INFERENCE_MODEL:-gpt-oss-20b-mxfp4}" INFERENCE_JUDGE_URL="${INFERENCE_JUDGE_URL:-https://openrouter.ai/api/v1/chat/completions}" INFERENCE_JUDGE_MODEL="${INFERENCE_JUDGE_MODEL:-openai/gpt-oss-20b}" diff --git a/e2e-rag/scripts/start_vllm_server.sh b/e2e-rag/scripts/start_vllm_server.sh index 6a7871a555..1227129e53 100644 --- a/e2e-rag/scripts/start_vllm_server.sh +++ b/e2e-rag/scripts/start_vllm_server.sh @@ -1,5 +1,5 @@ python3 -m vllm.entrypoints.openai.api_server \ - --model /model/gpt-oss-20b-mxfp4 \ + --model gpt-oss-20b-mxfp4 \ --dtype bfloat16 \ --enforce-eager \ --host 0.0.0.0 \ @@ -10,6 +10,6 @@ python3 -m vllm.entrypoints.openai.api_server \ --disable-log-requests \ --max-model-len=131072 \ --block-size 64 \ - --port 8123 \ + --port 8192 \ -tp 4 \ --async_scheduling \ No newline at end of file diff --git a/e2e-rag/scripts/verify_db_manifest.sh b/e2e-rag/scripts/verify_db_manifest.sh index 495f61478a..5db6bb044b 100644 --- a/e2e-rag/scripts/verify_db_manifest.sh +++ b/e2e-rag/scripts/verify_db_manifest.sh @@ -3,11 +3,11 @@ # Verify this system's vector DB against a reference manifest. # # Usage (from repo root): -# bash scripts/verify_db_manifest.sh MANIFEST [COSINE_THRESHOLD] [TOP_K_DEPTH] +# bash scripts/verify_db_manifest.sh MANIFEST [RETRIEVAL_THRESHOLD] # -# MANIFEST: path to the reference manifest JSON (required). -# COSINE_THRESHOLD: minimum sample-embedding cosine similarity (default: 0.9999). -# TOP_K_DEPTH: probe-query top-K rank match depth (default: 3). +# MANIFEST: path to the reference manifest JSON (required). +# RETRIEVAL_THRESHOLD: minimum mean reference-query top-K document set-overlap +# required to pass (default: 0.95). # # Configuration: see config.template.sh (uses INFERENCE_DB and # INFERENCE_RETRIEVER_MODEL). @@ -17,7 +17,7 @@ set -e if [[ -z "$1" ]]; then echo "ERROR: manifest path required" >&2 - echo "Usage: $0 MANIFEST [COSINE_THRESHOLD] [TOP_K_DEPTH]" >&2 + echo "Usage: $0 MANIFEST [RETRIEVAL_THRESHOLD]" >&2 exit 1 fi @@ -29,20 +29,20 @@ else fi INFERENCE_DB="${INFERENCE_DB:-vector_html_hnsw_len768_ov32_word}" +INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-intfloat_e5-base-v2/e5-base-v2}" MANIFEST="$1" -COSINE_THRESHOLD="${2:-0.9999}" -TOP_K_DEPTH="${3:-3}" +RETRIEVAL_THRESHOLD="${2:-0.95}" echo "=== Verifying DB against manifest ===" -echo " DB: ${INFERENCE_DB}" -echo " Manifest: ${MANIFEST}" -echo " Cosine threshold: ${COSINE_THRESHOLD}" -echo " Top-K depth: ${TOP_K_DEPTH}" +echo " DB: ${INFERENCE_DB}" +echo " Retriever: ${INFERENCE_RETRIEVER_MODEL}" +echo " Manifest: ${MANIFEST}" +echo " Retrieval threshold: ${RETRIEVAL_THRESHOLD}" echo "" python3 -u db_manifest.py verify \ --db "${INFERENCE_DB}" \ --manifest "${MANIFEST}" \ - --cosine-threshold "${COSINE_THRESHOLD}" \ - --top-k-depth "${TOP_K_DEPTH}" + --retriever_model "${INFERENCE_RETRIEVER_MODEL}" \ + --retrieval-threshold "${RETRIEVAL_THRESHOLD}" diff --git a/e2e-rag/scripts/write_db_manifest.sh b/e2e-rag/scripts/write_db_manifest.sh index 01b6580f8d..2b29767fa8 100644 --- a/e2e-rag/scripts/write_db_manifest.sh +++ b/e2e-rag/scripts/write_db_manifest.sh @@ -22,7 +22,7 @@ else fi INFERENCE_DB="${INFERENCE_DB:-vector_html_hnsw_len768_ov32_word}" -INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-/data/model/e5-base-v2}" +INFERENCE_RETRIEVER_MODEL="${INFERENCE_RETRIEVER_MODEL:-intfloat_e5-base-v2/e5-base-v2}" OUTPUT="${1:-db_manifest_$(hostname -s).json.gz}" diff --git a/e2e-rag/user.conf b/e2e-rag/user.conf index e920ef67ad..c023795e57 100644 --- a/e2e-rag/user.conf +++ b/e2e-rag/user.conf @@ -11,14 +11,14 @@ # min_query_count takes priority - loadgen will run AT LEAST this many unique queries # min_duration is secondary - only matters if fewer queries would complete faster # max_async_queries controls concurrent query processing (default: 1) -rag-qna.Offline.target_qps = 0.11 -rag-qna.Offline.min_duration = 0 -rag-qna.Offline.min_query_count = 824 -rag-qna.Offline.max_async_queries = 10 +e2e-rag-qna.Offline.target_qps = 0.11 +e2e-rag-qna.Offline.min_duration = 0 +e2e-rag-qna.Offline.min_query_count = 824 +e2e-rag-qna.Offline.max_async_queries = 10 # RAG DB workload settings # Send all documents at once for parallel processing -rag-db.Offline.target_qps = 0 -rag-db.Offline.min_duration = 0 -rag-db.Offline.min_query_count = 2503 -rag-db.Offline.max_async_queries = 2503 +e2e-rag-db.Offline.target_qps = 0 +e2e-rag-db.Offline.min_duration = 0 +e2e-rag-db.Offline.min_query_count = 2515 +e2e-rag-db.Offline.max_async_queries = 2515 From b31180d95e1357cfab7dc676078c5e6017a1c0b0 Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:54:32 +0530 Subject: [PATCH 58/59] Update main.py --- main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main.py b/main.py index 9acecb29fd..f76e510bd3 100755 --- a/main.py +++ b/main.py @@ -1,4 +1,3 @@ - def define_env(env): @env.macro From 909662820111fec392685adde422cab8ac71437b Mon Sep 17 00:00:00 2001 From: ANANDHU S <71482562+anandhu-eng@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:46:59 +0530 Subject: [PATCH 59/59] Update main.py --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index f76e510bd3..9acecb29fd 100755 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ + def define_env(env): @env.macro