Files
2026-08-12 04:47:48 -04:00

291 lines
10 KiB
Python

# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import datetime
from collections.abc import Callable
from .environment import TestEnvironment
from .test import Test
def date_str(ts: int) -> str:
"""Format a Unix timestamp as 'YYYY-MM-DD HH:MM:SS' in UTC."""
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
def passes_threshold(value: float, success: str, threshold: float) -> bool:
"""Check if a performance value passes the given threshold.
Returns True when the value is on the good side of the threshold
based on the success direction ('greater_than' or 'less_than').
"""
if success == 'greater_than':
return value > threshold
elif success == 'less_than':
return value < threshold
return False
class BisectProgress:
"""Tracks the current search window during bisecting."""
def __init__(self) -> None:
self.min_index = 0
self.max_index = 0
@property
def remaining(self) -> int:
return self.max_index - self.min_index
class Bisect:
"""
Bisect over a commit range.
"""
def __init__(
self,
env: TestEnvironment,
test_commit_cb: Callable[..., tuple[float | None, str]],
start_ts: int,
end_ts: int,
) -> None:
self.env = env
self.test_commit_cb = test_commit_cb
self.start_ts = start_ts
self.end_ts = end_ts
self.commit_status: dict[str, str] = {}
self.last_good: str | None = None
self.first_bad: str | None = None
def run(
self,
progress: BisectProgress | None,
) -> None:
self._run_per_day(progress)
if self.first_bad is not None:
self._run_single_day(progress)
def _run_per_day(
self,
progress: BisectProgress | None,
) -> None:
"""
Walks day-by-day trying up to three commits per day to quickly locate the good/bad commit.
"""
SECONDS_PER_DAY = 86400
day_windows: list[list[tuple[str, int]]] = []
day_ts = self.start_ts
while day_ts < self.end_ts:
next_day_ts = day_ts + SECONDS_PER_DAY
day_windows.append(self.env.commits_in_window(day_ts, next_day_ts))
day_ts = next_day_ts
total_commits = sum(len(w) for w in day_windows)
self._update_progress(progress, 0, total_commits)
day_index = 0
consumed = 0
last_tested = None
while day_index < len(day_windows):
day_commits = day_windows[day_index]
self._update_progress(progress, consumed, total_commits)
consumed += len(day_commits)
attempts = 0
for commit_hash, commit_ts in day_commits:
if commit_hash == last_tested:
continue
if attempts >= 3:
break
if commit_hash in self.commit_status:
break
attempts += 1
_, status = self.test_commit_cb(commit_hash, commit_ts)
if status in {'build_error', 'no_output', 'run_error', 'skip'}:
continue
if status == 'pass':
self.last_good = commit_hash
self.commit_status[commit_hash] = 'pass'
else:
self.first_bad = commit_hash
self.commit_status[commit_hash] = 'fail'
last_tested = commit_hash
break
if self.first_bad:
break
day_index += 1
def _run_single_day(
self,
progress: BisectProgress | None,
) -> None:
"""
Narrows down to the exact commit with a binary search.
"""
all_commits = self.env.commits_in_window(self.start_ts, self.end_ts)
commit_index = {commit_hash: index for index, (commit_hash, _) in enumerate(all_commits)}
max_index = commit_index[self.first_bad]
min_index = commit_index[self.last_good] + 1 if self.last_good else 0
self._update_progress(progress, min_index, max_index)
while min_index < max_index:
mid_index = (min_index + max_index) // 2
commit_hash, commit_ts = all_commits[mid_index]
if commit_hash in self.commit_status:
if self.commit_status[commit_hash] == 'pass':
min_index = mid_index + 1
else:
max_index = mid_index
self._update_progress(progress, min_index, max_index)
continue
_, status = self.test_commit_cb(commit_hash, commit_ts)
if status == 'pass':
self.commit_status[commit_hash] = 'pass'
self.last_good = commit_hash
min_index = mid_index + 1
elif status == 'fail':
self.commit_status[commit_hash] = 'fail'
self.first_bad = commit_hash
max_index = mid_index
else:
_, new_max = self._forward_scan(mid_index + 1, max_index, all_commits, progress)
if new_max is None:
break
max_index = new_max
self._update_progress(progress, min_index, max_index)
continue
self._update_progress(progress, min_index, max_index)
def _forward_scan(
self,
start_index: int,
max_index: int,
commits: list[tuple[str, int]],
progress: BisectProgress | None,
) -> tuple[int | None, int | None]:
for scan_index in range(start_index, max_index):
scan_hash, scan_ts = commits[scan_index]
if scan_hash in self.commit_status:
if self.commit_status[scan_hash] == 'fail':
self.first_bad = scan_hash
self._update_progress(progress, start_index, scan_index)
return start_index, scan_index
continue
_, status = self.test_commit_cb(scan_hash, scan_ts)
if status == 'pass':
self.commit_status[scan_hash] = 'pass'
self.last_good = scan_hash
self._update_progress(progress, scan_index + 1, max_index)
return scan_index + 1, max_index
elif status == 'fail':
self.commit_status[scan_hash] = 'fail'
self.first_bad = scan_hash
self._update_progress(progress, start_index, scan_index)
return start_index, scan_index
return None, None
@staticmethod
def run_commit(
env: TestEnvironment,
test: Test,
device_id: str,
gpu_backend: str,
count: int,
attribute: str,
success: str,
threshold: float,
tested: set[str],
on_progress: Callable[..., None],
commit_hash: str,
commit_ts: int,
) -> tuple[float | None, str]:
"""Build, benchmark, and evaluate a single commit.
Builds the given git hash, runs the test ``count`` times, averages the
results, and checks whether the value passes the threshold.
Args:
env: TestEnvironment for git/build operations.
test: Test object to run.
device_id: Device identifier string.
gpu_backend: GPU backend string.
count: Number of benchmark runs per commit.
attribute: Name of the performance attribute to extract from output.
success: Comparison direction ('greater_than' or 'less_than').
threshold: Pass/fail threshold value.
tested: Mutable set tracking already-tested commit hashes.
on_progress: Callable ``(row_values, end)`` for printing table rows.
commit_hash: Commit hash to test.
commit_ts: Unix timestamp of the commit.
Returns:
Tuple ``(value, status)`` where status is one of
``'skip'``, ``'build_error'``, ``'no_output'``, ``'run_error``, ``'pass'`` or ``'fail'``.
value can be None when status is ``'skip'``, ``'build_error'``, ``'no_output'``, ``'run_error'``.
"""
# During the weekends it can happen that a day doesn't have any commit. In that case a commit
# can be selected that has already been performed.
if commit_hash in tested:
return None, 'skip'
tested.add(commit_hash)
title = env.commit_title(commit_hash)[:70].replace('`', '\'')
title = f'`{title}`'
commit_hash_str = f'`{commit_hash}`'
on_progress([commit_hash_str, date_str(commit_ts), title, '', 'building'], end='\r')
install_dir = env.install_dir
ok = env.build(commit_hash, install_dir)
if not ok:
on_progress([commit_hash_str, date_str(commit_ts), title, 'error', 'FAIL (build)'])
return None, 'build_error'
env.set_blender_executable(install_dir, {})
values: list[float] = []
try:
for run_idx in range(count):
run_status = 'running' if count == 1 else f'run [{run_idx + 1}/{count}]'
on_progress([commit_hash_str, date_str(commit_ts), title, '', run_status], end='\r')
output = test.run(env, device_id, gpu_backend)
if not output or attribute not in output:
env.set_default_blender_executable()
on_progress([commit_hash_str, date_str(commit_ts), title, 'error', 'run'])
return None, 'no_output'
values.append(output[attribute])
except Exception as e:
env.set_default_blender_executable()
on_progress([commit_hash_str, date_str(commit_ts), title, 'error', str(e)[:30]])
return None, 'run_error'
env.set_default_blender_executable()
avg = sum(values) / len(values)
good = passes_threshold(avg, success, threshold)
status = 'PASS' if good else 'FAIL'
on_progress([commit_hash_str, date_str(commit_ts), title, f'{avg:.4f}', status])
return avg, 'pass' if good else 'fail'
@staticmethod
def _update_progress(
progress: BisectProgress | None,
min_index: int,
max_index: int,
) -> None:
if progress:
progress.min_index = min_index
progress.max_index = max_index