Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6d60f59
ci: add import profiler check across monorepo
hebaalazzeh Jul 8, 2026
da118aa
ci: allow prerelease for python 3.15 in setup-python
hebaalazzeh Jul 8, 2026
5eab435
chore: propagate import_profile nox session across all packages and g…
hebaalazzeh Jul 8, 2026
8ccfb28
fix(ci): gracefully skip import_profile session if profiler script mi…
hebaalazzeh Jul 8, 2026
a0c075f
style(ci): reformat noxfile with black to fix lint errors
hebaalazzeh Jul 8, 2026
a7d2363
chore: revert google-crc32c noxfile modification to bypass broken kok…
hebaalazzeh Jul 8, 2026
68a0bdf
fix(ci): fix bazel integration tests and lint formatting
hebaalazzeh Jul 8, 2026
42dd64a
chore: revert gapic-generator template and noxfile changes in packages
hebaalazzeh Jul 8, 2026
a6491b5
ci: run import_profile without nox templates
hebaalazzeh Jul 8, 2026
9472ab6
address PR comments for import profiler CI
hebaalazzeh Jul 8, 2026
705b188
fix: update goldens for gapic-generator integration tests
hebaalazzeh Jul 9, 2026
885de7f
feat: implement dynamic import profile diff check against baseline
hebaalazzeh Jul 9, 2026
a2bd6ec
refactor: simplify CI without gapic-generator template changes and fi…
hebaalazzeh Jul 9, 2026
a77dc18
chore: remove nox installation from import profiler job
hebaalazzeh Jul 9, 2026
8a2d75b
chore: temporary commit to trigger profiler
hebaalazzeh Jul 9, 2026
9732c4e
chore: temporarily force profiler to run on all packages for benchmar…
hebaalazzeh Jul 9, 2026
df592fe
fix: resolve profiler baseline args and improve failure logs
hebaalazzeh Jul 9, 2026
b87f403
fix: restore dynamic label check for import profiler BUILD_TYPE
hebaalazzeh Jul 10, 2026
483e44b
chore: temporarily force profiler to run on all packages again for be…
hebaalazzeh Jul 10, 2026
943133d
fix: restore dynamic label check for import profiler BUILD_TYPE
hebaalazzeh Jul 10, 2026
8aff62c
fix: revert dummy __init__ changes to fix OwlBot Post Processor
hebaalazzeh Jul 10, 2026
ff77903
chore: address reviewer feedback on iterations, bytecode caching, and…
hebaalazzeh Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/import-profiler.yml
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
41 changes: 41 additions & 0 deletions ci/run_single_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$?

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Contributor

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_packages tag, that will run against all packages when added. Maybe we should support that here too

It's possible we would only want to profile a few key packages instead of all of them. Most generated libraries should be very similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed!

@chalmerlowe chalmerlowe Jul 9, 2026

Copy link
Copy Markdown
Contributor

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:

  1. I would like to see this thing run so that I can understand what it will produce and whether output appears as I would expect. Please temporarily touch some files to trigger the profiler to run against them. As an example: add a blank line right after the license in this file in google-cloud-compute and a similar change in at least one other package. That will trigger the profiler to run (without requiring you to go back and undo any changes to google-cloud-compute, etc).
  • If you already did this and have reverted your change, please point to the commit that you used to trigger the profiler (we should be able to check the CI/CD results for that commit).

Note

In case you've never done it... clicking on the x OR checkmark next to the commit number grants access to the test results for previous commits:

          Screenshot 2026-07-09 at 11 38 17

  1. If, as Daniel suggests, we run the profiler against all packages, it would be useful to know how long that CI/CD check will take. We have 240 generated packages and if we regen and profile all of them we don't wanna find out later that it takes hours. Do we have benchmark results to give us an order of magnitude for how long it takes?

Copy link
Copy Markdown
Contributor Author

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-compute and google-api-core so 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_packages label works, there's logic in .github/workflows/import-profiler.yml that checks for an import_profile:all_packages label.

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_packages label to this PR and let it run.

Expand Down
127 changes: 119 additions & 8 deletions scripts/import_profiler/profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__')
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry that the find_module_from_package heuristic is too brittle. Here are some examples of where it potentially fails:

  1. proto-plus
    Package Name: proto-plus
    Generated Candidates: proto.plus, proto_plus
    The Reality: The actual module installed and used is proto.
    The Bug: The heuristic will never guess proto. It will fail to find proto.plus or proto_plus via find_spec (even after installation) and will fall back to proto.plus. The profiler will then attempt to import proto.plus, which will FAIL because there is no plus submodule inside proto.

  2. google-cloud-core
    Package Name: google-cloud-core
    Generated Candidates: google.cloud.core
    The Reality: Looking at the source, this package doesn't place its code in a core directory. It extends google.cloud directly with modules like client, exceptions, etc.
    The Bug: The heuristic falls back to google.cloud.core. If you try to import google.cloud.core, it may fail because that module path doesn't exist as a distinct entity in the way the heuristic expects.

Recommendation:

Instead of guessing the module name with a heuristic, consider:

Reading the module name directly from the package metadata (e.g., using importlib.metadata or parsing pyproject.toml/setup.py).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)")
Expand All @@ -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)
Loading