Skip to content

Commit e1f00d4

Browse files
committed
Merge branch 'main' into shuowei-gbq-delegation-telemetry-mvp
2 parents 09f0490 + b2f7cb0 commit e1f00d4

6 files changed

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

packages/bigframes/testing/constraints-3.11.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ google-pasta==0.2.0
172172
google-resumable-media==2.7.2
173173
googleapis-common-protos==1.70.0
174174
googledrivedownloader==1.1.0
175-
gradio==6.15.0
175+
gradio==6.15.1
176176
gradio_client==1.11.0
177177
graphviz==0.21
178178
greenlet==3.2.3

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)