Skip to content

Commit cff43d7

Browse files
hebaalazzehchalmerlowegemini-code-assist[bot]
authored
ci: add import profiler check across monorepo (#17657)
## Overview This PR integrates the import profiler script (introduced in #17467) into our automated CI pipeline. Why this matters: Import times significantly impact CLI responsiveness and cold starts for Serverless products like Cloud Run and Cloud Functions. Ideally, library imports should stay under 500ms, and anything taking over 1 second is a target for optimization. This CI check helps us proactively track metrics like import latency, memory footprint, and code volume to prevent performance regressions on critical libraries. The primary goal of this check is to track and enforce performance standards for package import times across the repository, especially following our recent work on lazy loading and cold-start optimizations. By running this benchmark as a CI check with a defined dynamic differential failure threshold, we can programmatically prevent latency regressions in module initialization times before they are merged, ensuring downstream consumers aren't impacted by unexpectedly slow startup times. ## Changes Included * **GitHub Actions Workflow**: Created a new workflow (`.github/workflows/import-profiler.yml`) that triggers on PRs and merge groups. The workflow is pinned specifically to Python 3.15. * **Native CI Integration (No template bloat)**: Rather than polluting the central gapic-generator template (`noxfile.py.j2`) and forcing updates across 150+ packages, the CI script (`ci/run_single_test.sh`) natively handles the profiler. It automatically spins up a lightweight virtual environment, installs the target package, and runs the profiler. * **Dynamic Differential Checks**: Enhanced `ci/run_single_test.sh` to checkout `HEAD^1` (the main branch), generate a baseline CSV profile, and then diff it against the PR branch. If the Median (P50) import time of the PR degrades by >100ms compared to the baseline, the CI check will fail. *(Note: Using Median instead of P99 ensures stability against intermittent CPU spikes on GitHub Action runners).* * **Safety Backstop**: The script still enforces an absolute hard-failure backstop of 5000ms for extreme regressions. * **Conditional Execution & Graceful Skips**: The pipeline verifies if a valid `setup.py` exists before attempting to profile, gracefully skipping directories that are not valid Python packages. ## Developer Interactions If a developer fails this CI check due to an import latency regression, they can reproduce and debug it locally by running the profiler script with the `--cprofile` flag (`python scripts/import_profiler/profiler.py --package <their-package> --cprofile`). This will generate a cProfile stack trace breakdown of the import time, allowing them to pinpoint exactly which new dependency or module initialization is causing the latency spike. Related PRs * Builds upon the import profiler tool added in #17467 * Regression test proving the CI catches regressions: #17690 --------- Co-authored-by: Chalmer Lowe <chalmerlowe@google.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 4b049c4 commit cff43d7

5 files changed

Lines changed: 231 additions & 7 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: import-profiler
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
- preview
8+
# Trigger workflow on GitHub merge queue events
9+
merge_group:
10+
types: [checks_requested]
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
import-profile:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 60
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v6
22+
with:
23+
fetch-depth: 2
24+
- name: Setup Python
25+
uses: actions/setup-python@v6
26+
with:
27+
python-version: "3.15"
28+
allow-prereleases: true
29+
- name: Upgrade pip, setuptools, and wheel
30+
run: |
31+
python -m pip install --upgrade setuptools pip wheel
32+
- name: Run import profiler
33+
env:
34+
BUILD_TYPE: presubmit
35+
TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }}
36+
TEST_TYPE: import_profile
37+
PY_VERSION: "3.15"
38+
run: |
39+
ci/run_conditional_tests.sh

ci/run_single_test.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,61 @@ case ${TEST_TYPE} in
105105
;;
106106
esac
107107
;;
108+
import_profile)
109+
if [ -f setup.py ] || [ -f pyproject.toml ]; then
110+
echo "Creating temporary virtualenv for import profile..."
111+
python3 -m venv .venv-profiler
112+
source .venv-profiler/bin/activate
113+
114+
PACKAGE_NAME=$(basename $(pwd))
115+
PROFILER_TEMP_DIR=$(mktemp -d)
116+
cp ../../scripts/import_profiler/profiler.py "${PROFILER_TEMP_DIR}/profiler.py"
117+
PROFILER_SCRIPT="${PROFILER_TEMP_DIR}/profiler.py"
118+
BASELINE_CSV="${PROFILER_TEMP_DIR}/baseline_${PACKAGE_NAME}.csv"
119+
120+
if [ -n "${TARGET_BRANCH}" ]; then
121+
# Try upstream first (for forks), then origin
122+
BASELINE_COMMIT=$(git merge-base HEAD "upstream/${TARGET_BRANCH}" 2>/dev/null || \
123+
git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || \
124+
git merge-base HEAD "${TARGET_BRANCH}" 2>/dev/null || true)
125+
if [ -n "${BASELINE_COMMIT}" ]; then
126+
echo "Checking out baseline commit ${BASELINE_COMMIT} in a temporary worktree..."
127+
REPO_PREFIX=$(git rev-parse --show-prefix)
128+
WORKTREE_DIR=$(mktemp -d)
129+
rmdir "${WORKTREE_DIR}"
130+
if git worktree add "${WORKTREE_DIR}" "${BASELINE_COMMIT}" 2>/dev/null; then
131+
(
132+
cd "${WORKTREE_DIR}/${REPO_PREFIX}"
133+
if [ -f setup.py ] || [ -f pyproject.toml ]; then
134+
pip install -e .
135+
python "${PROFILER_SCRIPT}" --package "${PACKAGE_NAME}" --iterations 11 --csv "${BASELINE_CSV}"
136+
fi
137+
)
138+
git worktree remove -f "${WORKTREE_DIR}"
139+
else
140+
echo "Failed to create git worktree for baseline. Skipping baseline generation."
141+
fi
142+
else
143+
echo "Could not find baseline commit for ${TARGET_BRANCH:-main}. Skipping baseline generation."
144+
fi
145+
fi
146+
147+
pip install -e .
148+
149+
if [ -f "${BASELINE_CSV}" ]; then
150+
python ${PROFILER_SCRIPT} --package ${PACKAGE_NAME} --iterations 11 --fail-threshold 5000 --diff-baseline "${BASELINE_CSV}" --diff-threshold 100
151+
else
152+
python ${PROFILER_SCRIPT} --package ${PACKAGE_NAME} --iterations 11 --fail-threshold 5000
153+
fi
154+
retval=$?
155+
deactivate
156+
rm -rf .venv-profiler
157+
rm -rf "${PROFILER_TEMP_DIR}"
158+
else
159+
echo "Skipping import_profile as this does not appear to be a Python package (no setup.py or pyproject.toml)."
160+
retval=0
161+
fi
162+
;;
108163
*)
109164
nox -s ${TEST_TYPE}
110165
retval=$?

packages/google-cloud-compute/google/cloud/compute_v1/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4365,3 +4365,4 @@ def _get_version(dependency_name):
43654365
"ZoneVmExtensionPoliciesClient",
43664366
"ZonesClient",
43674367
)
4368+
# Trigger CI check

packages/google-cloud-kms/google/cloud/kms_v1/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,4 @@ def _get_version(dependency_name):
355355
"VerifyConnectivityRequest",
356356
"VerifyConnectivityResponse",
357357
)
358+
# Trigger CI check

scripts/import_profiler/profiler.py

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def _format_stats(title, data, p50, p90, p99, fmt):
154154
{_format_stats("Physical RSS RAM (MB)", rss_memories, p50_rss, p90_rss, p99_rss, ".4f")}"""
155155
print(final_output.strip())
156156

157-
def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True):
157+
def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True, fail_threshold=None, diff_baseline=None, diff_threshold=None):
158158
"""Orchestrates the benchmark."""
159159
if iterations < 1:
160160
raise ValueError("Number of iterations must be at least 1.")
@@ -191,6 +191,7 @@ def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True
191191
times.append(data["time_ms"])
192192
memories.append(data["peak_ram_mb"])
193193
rss_memories.append(data["rss_ram_mb"])
194+
print(f"Iteration {i+1}/{iterations} completed in {data['time_ms']:.2f} ms")
194195
if i > 0 and loaded_modules_val != data["loaded_modules"]:
195196
print(f"WARNING: Non-deterministic import behavior! Iteration {i+1} loaded {data['loaded_modules']} modules (expected {loaded_modules_val}).", file=sys.stderr)
196197
if i > 0 and loaded_lines_val != data["loaded_lines"]:
@@ -207,6 +208,13 @@ def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True
207208
print(f"Error in worker process:\n{e.stderr}", file=sys.stderr)
208209
raise e
209210

211+
if iterations > 1:
212+
times = times[1:]
213+
memories = memories[1:]
214+
rss_memories = rss_memories[1:]
215+
iterations -= 1
216+
print("Discarded the first iteration as a cache burn-in run.")
217+
210218
# Write CSV if requested
211219
if csv_path:
212220
with open(csv_path, "w", newline="", encoding="utf-8") as f:
@@ -229,6 +237,68 @@ def run_master(iterations, target_module, cpu=0, csv_path=None, clear_cache=True
229237
rss_memories, p50_rss, p90_rss, p99_rss
230238
)
231239

240+
exit_code = 0
241+
final_messages = []
242+
243+
baseline_p50 = None
244+
if diff_baseline:
245+
if os.path.exists(diff_baseline):
246+
baseline_times = []
247+
with open(diff_baseline, "r", encoding="utf-8") as f:
248+
reader = csv.reader(f)
249+
next(reader) # skip header
250+
for row in reader:
251+
baseline_times.append(float(row[1]))
252+
if baseline_times:
253+
baseline_p50, _, _ = _calculate_percentiles(baseline_times)
254+
255+
if baseline_p50 is not None:
256+
diff = p50_time - baseline_p50
257+
258+
diff_msg = (
259+
f"--- Diff vs Baseline ---\n"
260+
f"Baseline Median: {baseline_p50:.2f} ms\n"
261+
f"Current Median: {p50_time:.2f} ms\n"
262+
f"Difference: {diff:+.2f} ms"
263+
)
264+
final_messages.append(diff_msg)
265+
266+
relative_diff_threshold = 0.15 * baseline_p50
267+
if diff > diff_threshold and diff > relative_diff_threshold:
268+
final_messages.append(
269+
f"FAILURE: Import time regression of {diff:.2f} ms exceeds both the absolute threshold ({diff_threshold} ms) "
270+
f"and the relative threshold ({relative_diff_threshold:.2f} ms, 15% of baseline Median)."
271+
)
272+
exit_code = 1
273+
else:
274+
if diff > diff_threshold:
275+
final_messages.append(f"SUCCESS: Import time regression of {diff:.2f} ms exceeds absolute threshold ({diff_threshold} ms) but is within relative threshold ({relative_diff_threshold:.2f} ms, 15%).")
276+
else:
277+
final_messages.append("SUCCESS: Import time diff is within acceptable thresholds.")
278+
else:
279+
final_messages.append(f"WARNING: Baseline CSV {diff_baseline} not found. Skipping diff check.")
280+
281+
if fail_threshold is not None:
282+
if p50_time > fail_threshold:
283+
if baseline_p50 is not None and baseline_p50 > fail_threshold:
284+
final_messages.append(f"WARNING: Median import time ({p50_time:.2f} ms) exceeds the absolute failure threshold ({fail_threshold} ms), but the baseline ({baseline_p50:.2f} ms) also exceeded it. Bypassing absolute backstop failure.")
285+
else:
286+
final_messages.append(f"FAILURE: Median import time ({p50_time:.2f} ms) exceeds the failure threshold ({fail_threshold} ms).")
287+
exit_code = 1
288+
else:
289+
final_messages.append(f"SUCCESS: Median import time ({p50_time:.2f} ms) is within the failure threshold ({fail_threshold} ms).")
290+
291+
if final_messages:
292+
print("\n" + "\n".join(final_messages))
293+
294+
if exit_code == 0:
295+
print("\nSession import_profiler was successful.")
296+
sys.exit(0)
297+
else:
298+
print("\nSession import_profiler failed.")
299+
sys.exit(1)
300+
301+
232302
def run_trace(target_module):
233303
"""Generates importtime trace log and writes it to a file."""
234304
trace_file = f"import_trace_{target_module.replace('.', '_')}.log"
@@ -307,8 +377,59 @@ def validate_module_name(module_name):
307377
raise argparse.ArgumentTypeError(f"'{module_name}' is not a valid Python module identifier.")
308378
return module_name
309379

380+
def find_module_from_package(pkg):
381+
import importlib.metadata
382+
import importlib.util
383+
384+
# 1. Try to use importlib.metadata.files (works for standard installations from PyPI/wheels)
385+
try:
386+
files = importlib.metadata.files(pkg)
387+
if files:
388+
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/')]
389+
if init_files:
390+
from pathlib import Path
391+
shortest_init = min(init_files, key=lambda p: len(Path(p).parts))
392+
parts = Path(shortest_init).parent.parts
393+
mod = '.'.join(parts)
394+
if importlib.util.find_spec(mod):
395+
return mod
396+
except Exception:
397+
pass
398+
399+
# 2. Try setuptools.find_namespace_packages() in current directory (works for editable installs in source trees)
400+
try:
401+
import setuptools
402+
import os
403+
if os.path.exists('setup.py') or os.path.exists('pyproject.toml'):
404+
pkgs = setuptools.find_namespace_packages(where='.')
405+
for p in sorted(pkgs, key=len):
406+
if p in ("google", "google.cloud") or p.startswith("tests"):
407+
continue
408+
path = p.replace('.', os.sep)
409+
if os.path.isfile(os.path.join(path, '__init__.py')):
410+
if importlib.util.find_spec(p):
411+
return p
412+
except Exception:
413+
pass
414+
415+
# 3. Fallback to basic string manipulation heuristics
416+
candidates = [
417+
pkg.replace('-', '.'),
418+
'.'.join(pkg.split('-')[:-1]) + '_' + pkg.split('-')[-1] if '-' in pkg else pkg,
419+
pkg.replace('-', '_')
420+
]
421+
for mod in candidates:
422+
try:
423+
if importlib.util.find_spec(mod):
424+
return mod
425+
except Exception:
426+
pass
427+
return candidates[0]
428+
310429
parser = argparse.ArgumentParser(description="Python SDK Import Profiler")
311-
parser.add_argument("--module", type=validate_module_name, default="google.cloud.compute_v1", help="Target module to profile")
430+
group = parser.add_mutually_exclusive_group(required=True)
431+
group.add_argument("--module", type=validate_module_name, help="Target module to profile")
432+
group.add_argument("--package", help="Target package name to profile (auto-detects module)")
312433
parser.add_argument("--iterations", type=int, default=50, help="Number of iterations")
313434
default_cpu = 0 if sys.platform.startswith("linux") else NO_CPU_PINNING
314435
parser.add_argument("--cpu", type=int, default=default_cpu, help="CPU core to pin to (or -1 for no pinning)")
@@ -317,20 +438,27 @@ def validate_module_name(module_name):
317438
parser.add_argument("--cprofile", action="store_true", help="Run cProfile")
318439
parser.add_argument("--mprofile", action="store_true", help="Run tracemalloc memory snapshot")
319440
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)")
441+
parser.add_argument("--fail-threshold", type=float, help="Fail the profiling if the Median time exceeds this threshold (in ms).")
442+
parser.add_argument("--diff-baseline", help="Path to a baseline CSV file to compare against.")
443+
parser.add_argument("--diff-threshold", type=float, default=100.0, help="Fail if Median time exceeds baseline Median by this many ms.")
320444
parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
321445

322446
args = parser.parse_args()
323447

448+
target_module = args.module
449+
if args.package:
450+
target_module = find_module_from_package(args.package)
451+
324452
if args.worker:
325-
run_worker(args.module)
453+
run_worker(target_module)
326454
elif args.trace:
327455
if not args.keep_pycache: clean_bytecode()
328-
run_trace(args.module)
456+
run_trace(target_module)
329457
elif args.cprofile:
330458
if not args.keep_pycache: clean_bytecode()
331-
run_cprofile(args.module)
459+
run_cprofile(target_module)
332460
elif args.mprofile:
333461
if not args.keep_pycache: clean_bytecode()
334-
run_mprofile(args.module)
462+
run_mprofile(target_module)
335463
else:
336-
run_master(args.iterations, args.module, args.cpu, args.csv, not args.keep_pycache)
464+
run_master(args.iterations, target_module, args.cpu, args.csv, not args.keep_pycache, args.fail_threshold, args.diff_baseline, args.diff_threshold)

0 commit comments

Comments
 (0)