-
Notifications
You must be signed in to change notification settings - Fork 1.7k
ci: add import profiler check across monorepo #17657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6d60f59
da118aa
5eab435
8ccfb28
a0c075f
a7d2363
68a0bdf
42dd64a
a6491b5
9472ab6
705b188
885de7f
a2bd6ec
a77dc18
8a2d75b
9732c4e
df592fe
b87f403
483e44b
943133d
8aff62c
ff77903
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I worry that the
Recommendation:Instead of guessing the module name with a heuristic, consider: Reading the module name directly from the package metadata (e.g., using
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. addressed |
||
| 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) | ||
| run_master(args.iterations, target_module, args.cpu, args.csv, not args.keep_pycache, args.fail_threshold, args.diff_baseline, args.diff_threshold) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR doesn't touch any packages, so it's hard to tell what the action will look like. Can you temporarily touch some files to test it out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should also see how long the new check would take if all packages were updated. In #17438, I added a new
unit_test:all_packagestag, that will run against all packages when added. Maybe we should support that here tooIt's possible we would only want to profile a few key packages instead of all of them. Most generated libraries should be very similar
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addressed!
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with Daniel on a couple of performance issues:
google-cloud-compute, etc).Note
In case you've never done it... clicking on the
xORcheckmarknext to the commit number grants access to the test results for previous commits:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added a temporary commit (8a2d75b) that adds a blank line to
google-cloud-computeandgoogle-api-coreso you can see the profiler run on a couple of real packages and review the output.Regarding the benchmark on all packages, we actually already support running the profiler against all packages in this PR! Similar to how the
unit_test:all_packageslabel works, there's logic in.github/workflows/import-profiler.ymlthat checks for animport_profile:all_packageslabel.If we want to see how long it takes across all ~240 packages to get an order of magnitude, we can simply apply the
import_profile:all_packageslabel to this PR and let it run.