diff --git a/.github/workflows/import-profiler.yml b/.github/workflows/import-profiler.yml new file mode 100644 index 000000000000..29707cb482f2 --- /dev/null +++ b/.github/workflows/import-profiler.yml @@ -0,0 +1,38 @@ +name: import-profiler + +on: + pull_request: + branches: + - main + - preview + # Trigger workflow on GitHub merge queue events + merge_group: + types: [checks_requested] + +permissions: + contents: read + +jobs: + import-profile: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.15" + allow-prereleases: true + - name: Upgrade pip, setuptools, and wheel + run: | + python -m pip install --upgrade setuptools pip wheel + - name: Run import profiler + env: + BUILD_TYPE: ${{ contains(github.event.pull_request.labels.*.name, 'import_profile:all_packages') && 'all' || 'presubmit' }} + TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} + TEST_TYPE: import_profile + PY_VERSION: "3.15" + run: | + ci/run_conditional_tests.sh diff --git a/ci/run_single_test.sh b/ci/run_single_test.sh index aa158c4d342c..0ed818d90fc5 100755 --- a/ci/run_single_test.sh +++ b/ci/run_single_test.sh @@ -105,6 +105,47 @@ case ${TEST_TYPE} in ;; esac ;; + import_profile) + if [ -f setup.py ]; then + echo "Creating temporary virtualenv for import profile..." + python3 -m venv .venv-profiler + source .venv-profiler/bin/activate + pip install -e . + + PACKAGE_NAME=$(basename $(pwd)) + # Save the new profiler script to /tmp so we don't lose it during HEAD^1 checkout + cp ../../scripts/import_profiler/profiler.py /tmp/profiler.py + PROFILER_SCRIPT="/tmp/profiler.py" + + rm -f /tmp/baseline.csv + if [ -n "${TARGET_BRANCH}" ]; then + if git rev-parse HEAD^1 >/dev/null 2>&1; then + echo "Checking out HEAD^1 for baseline..." + git checkout HEAD^1 + if [ -f setup.py ]; then + python ${PROFILER_SCRIPT} --package ${PACKAGE_NAME} --iterations 11 --csv /tmp/baseline.csv + else + echo "setup.py not found on baseline. Skipping baseline generation." + fi + git checkout - + else + echo "Could not find HEAD^1. Skipping baseline generation." + fi + fi + + if [ -f /tmp/baseline.csv ]; then + python ${PROFILER_SCRIPT} --package ${PACKAGE_NAME} --iterations 11 --fail-threshold 5000 --diff-baseline /tmp/baseline.csv --diff-threshold 100 + else + python ${PROFILER_SCRIPT} --package ${PACKAGE_NAME} --iterations 11 --fail-threshold 5000 + fi + retval=$? + deactivate + rm -rf .venv-profiler + else + echo "Skipping import_profile as this does not appear to be a Python package (no setup.py)." + retval=0 + fi + ;; *) nox -s ${TEST_TYPE} retval=$? diff --git a/scripts/import_profiler/profiler.py b/scripts/import_profiler/profiler.py index 8412aaf527b7..432b17840864 100644 --- a/scripts/import_profiler/profiler.py +++ b/scripts/import_profiler/profiler.py @@ -19,7 +19,7 @@ def clean_bytecode(): count = 0 # Walk the directory avoiding hidden directories (e.g. .git, .venv, .nox) for root, dirs, files in os.walk('.'): - dirs[:] = [d for d in dirs if not d.startswith('.')] + dirs[:] = [d for d in dirs if not d.startswith('.') or d == '.venv-profiler'] if '__pycache__' in dirs: shutil.rmtree(os.path.join(root, '__pycache__')) dirs.remove('__pycache__') @@ -154,7 +154,7 @@ def _format_stats(title, data, p50, p90, p99, fmt): {_format_stats("Physical RSS RAM (MB)", rss_memories, p50_rss, p90_rss, p99_rss, ".4f")}""" print(final_output.strip()) -def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True): +def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True, fail_threshold=None, diff_baseline=None, diff_threshold=None): """Orchestrates the benchmark.""" if iterations < 1: raise ValueError("Number of iterations must be at least 1.") @@ -207,6 +207,13 @@ def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True print(f"Error in worker process:\n{e.stderr}", file=sys.stderr) raise e + if iterations > 1: + times = times[1:] + memories = memories[1:] + rss_memories = rss_memories[1:] + iterations -= 1 + print("Discarded the first iteration as a cache burn-in run.") + # Write CSV if requested if csv_path: with open(csv_path, "w", newline="", encoding="utf-8") as f: @@ -229,6 +236,54 @@ def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True rss_memories, p50_rss, p90_rss, p99_rss ) + exit_code = 0 + final_messages = [] + + if fail_threshold is not None: + if p99_time > fail_threshold: + final_messages.append(f"FAILURE: P99 import time ({p99_time:.2f} ms) exceeds the failure threshold ({fail_threshold} ms).") + exit_code = 1 + else: + final_messages.append(f"SUCCESS: P99 import time ({p99_time:.2f} ms) is within the failure threshold ({fail_threshold} ms).") + + if diff_baseline: + if os.path.exists(diff_baseline): + baseline_times = [] + with open(diff_baseline, "r", encoding="utf-8") as f: + reader = csv.reader(f) + next(reader) # skip header + for row in reader: + baseline_times.append(float(row[1])) + _, _, baseline_p99 = _calculate_percentiles(baseline_times) + diff = p99_time - baseline_p99 + + diff_msg = ( + f"--- Diff vs Baseline ---\n" + f"Baseline P99: {baseline_p99:.2f} ms\n" + f"Current P99: {p99_time:.2f} ms\n" + f"Difference: {diff:+.2f} ms" + ) + final_messages.append(diff_msg) + + if diff > diff_threshold: + final_messages.append(f"FAILURE: Import time regression of {diff:.2f} ms exceeds the allowed threshold of {diff_threshold} ms.") + exit_code = 1 + else: + final_messages.append("SUCCESS: Import time diff is within acceptable thresholds.") + else: + final_messages.append(f"WARNING: Baseline CSV {diff_baseline} not found. Skipping diff check.") + + if final_messages: + print("\n" + "\n".join(final_messages)) + + if exit_code == 0: + print("\nSession import_profiler was successful.") + sys.exit(0) + else: + print("\nSession import_profiler was failed.") + sys.exit(1) + + def run_trace(target_module): """Generates importtime trace log and writes it to a file.""" trace_file = f"import_trace_{target_module.replace('.', '_')}.log" @@ -307,8 +362,57 @@ def validate_module_name(module_name): raise argparse.ArgumentTypeError(f"'{module_name}' is not a valid Python module identifier.") return module_name + def find_module_from_package(pkg): + import importlib.metadata + + # 1. Try to use importlib.metadata.files (works for standard installations from PyPI/wheels) + try: + files = importlib.metadata.files(pkg) + if files: + init_files = [str(f) for f in files if str(f).endswith('__init__.py') and '__pycache__' not in str(f) and not str(f).startswith('tests/')] + if init_files: + shortest_init = min(init_files, key=lambda p: len(p.split('/'))) + parts = shortest_init.split('/')[:-1] + mod = '.'.join(parts) + if importlib.util.find_spec(mod): + return mod + except Exception: + pass + + # 2. Try setuptools.find_namespace_packages() in current directory (works for editable installs in source trees) + try: + import setuptools + import os + if os.path.exists('setup.py') or os.path.exists('pyproject.toml'): + pkgs = setuptools.find_namespace_packages(where='.') + for p in sorted(pkgs, key=len): + if p.startswith('tests'): + continue + path = p.replace('.', os.sep) + if os.path.isfile(os.path.join(path, '__init__.py')): + if importlib.util.find_spec(p): + return p + except Exception: + pass + + # 3. Fallback to basic string manipulation heuristics + candidates = [ + pkg.replace('-', '.'), + '.'.join(pkg.split('-')[:-1]) + '_' + pkg.split('-')[-1] if '-' in pkg else pkg, + pkg.replace('-', '_') + ] + for mod in candidates: + try: + if importlib.util.find_spec(mod): + return mod + except Exception: + pass + return candidates[0] + parser = argparse.ArgumentParser(description="Python SDK Import Profiler") - parser.add_argument("--module", type=validate_module_name, default="google.cloud.compute_v1", help="Target module to profile") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--module", type=validate_module_name, help="Target module to profile") + group.add_argument("--package", help="Target package name to profile (auto-detects module)") parser.add_argument("--iterations", type=int, default=50, help="Number of iterations") default_cpu = 0 if sys.platform.startswith("linux") else NO_CPU_PINNING parser.add_argument("--cpu", type=int, default=default_cpu, help="CPU core to pin to (or -1 for no pinning)") @@ -317,20 +421,27 @@ def validate_module_name(module_name): parser.add_argument("--cprofile", action="store_true", help="Run cProfile") parser.add_argument("--mprofile", action="store_true", help="Run tracemalloc memory snapshot") parser.add_argument("--keep-pycache", action="store_true", help="Preserve __pycache__ and allow bytecode execution (Default: False, script automatically sweeps __pycache__ for true cold-starts)") + parser.add_argument("--fail-threshold", type=float, help="Fail the profiling if the P99 time exceeds this threshold (in ms).") + parser.add_argument("--diff-baseline", help="Path to a baseline CSV file to compare against.") + parser.add_argument("--diff-threshold", type=float, default=100.0, help="Fail if P99 time exceeds baseline P99 by this many ms.") parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() + target_module = args.module + if args.package: + target_module = find_module_from_package(args.package) + if args.worker: - run_worker(args.module) + run_worker(target_module) elif args.trace: if not args.keep_pycache: clean_bytecode() - run_trace(args.module) + run_trace(target_module) elif args.cprofile: if not args.keep_pycache: clean_bytecode() - run_cprofile(args.module) + run_cprofile(target_module) elif args.mprofile: if not args.keep_pycache: clean_bytecode() - run_mprofile(args.module) + run_mprofile(target_module) else: - run_master(args.iterations, args.module, args.cpu, args.csv, not args.keep_pycache) \ No newline at end of file + run_master(args.iterations, target_module, args.cpu, args.csv, not args.keep_pycache, args.fail_threshold, args.diff_baseline, args.diff_threshold) \ No newline at end of file