Add Chromium-only Blender WebEngine parity work
This commit is contained in:
12
blender-5.2.0/tests/performance/api/__init__.py
Normal file
12
blender-5.2.0/tests/performance/api/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .common import normalize_device_id
|
||||
from .environment import TestFailure, TestEnvironment
|
||||
from .device import TestDevice, TestMachine
|
||||
from .config import TestEntry, TestQueue, TestConfig
|
||||
from .test import Test, TestCollection
|
||||
from .graph import TestGraph
|
||||
from .table import MarkdownTable
|
||||
from .bisect import Bisect, BisectProgress
|
||||
290
blender-5.2.0/tests/performance/api/bisect.py
Normal file
290
blender-5.2.0/tests/performance/api/bisect.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# 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
|
||||
11
blender-5.2.0/tests/performance/api/common.py
Normal file
11
blender-5.2.0/tests/performance/api/common.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
def normalize_device_id(device_id: str) -> str:
|
||||
"""Normalize a device ID by adding _0 suffix when there is no index."""
|
||||
parts = device_id.rsplit('_', 1)
|
||||
if len(parts) == 1 or not parts[1].isdigit():
|
||||
return device_id + '_0'
|
||||
return device_id
|
||||
323
blender-5.2.0/tests/performance/api/config.py
Normal file
323
blender-5.2.0/tests/performance/api/config.py
Normal file
@@ -0,0 +1,323 @@
|
||||
# SPDX-FileCopyrightText: 2021-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .common import normalize_device_id
|
||||
from .test import TestCollection
|
||||
|
||||
|
||||
def get_build_hash(args: None) -> str:
|
||||
import bpy
|
||||
build_hash = bpy.app.build_hash.decode('utf-8')
|
||||
return '' if build_hash == 'Unknown' else build_hash
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestEntry:
|
||||
"""Test to run, a combination of revision, test and device."""
|
||||
test: str = ''
|
||||
category: str = ''
|
||||
revision: str = ''
|
||||
git_hash: str = ''
|
||||
environment: dict = field(default_factory=dict)
|
||||
executable: str = ''
|
||||
date: int = 0
|
||||
device_type: str = 'CPU'
|
||||
device_id: str = 'CPU'
|
||||
device_name: str = 'Unknown CPU'
|
||||
device_cpu: str = ''
|
||||
status: str = 'queued'
|
||||
# Short, single-line error.
|
||||
error_msg: str = ''
|
||||
# More detailed error info, potentially multi-lines.
|
||||
exception_msg: str = ''
|
||||
output: dict = field(default_factory=dict)
|
||||
output_all_runs: dict = field(default_factory=dict)
|
||||
benchmark_type: str = 'comparison'
|
||||
|
||||
def to_json(self) -> dict:
|
||||
json_dict = {}
|
||||
for field in self.__dataclass_fields__:
|
||||
json_dict[field] = getattr(self, field)
|
||||
return json_dict
|
||||
|
||||
def from_json(self, json_dict):
|
||||
for field in self.__dataclass_fields__:
|
||||
if field in json_dict:
|
||||
setattr(self, field, json_dict[field])
|
||||
|
||||
def migrate(self):
|
||||
if self.output:
|
||||
missing_keys = self.output.keys() - self.output_all_runs.keys()
|
||||
for key in missing_keys:
|
||||
self.output_all_runs[key] = [self.output[key]]
|
||||
|
||||
|
||||
class TestQueue:
|
||||
"""Queue of tests to be run or inspected. Matches JSON file on disk."""
|
||||
|
||||
def __init__(self, filepath: pathlib.Path):
|
||||
self.filepath = filepath
|
||||
self.has_multiple_categories = False
|
||||
self.has_multiple_devices = False
|
||||
self.entries = []
|
||||
|
||||
if self.filepath.is_file():
|
||||
with open(self.filepath, 'r') as f:
|
||||
json_entries = json.load(f)
|
||||
|
||||
for json_entry in json_entries:
|
||||
entry = TestEntry()
|
||||
entry.from_json(json_entry)
|
||||
entry.migrate()
|
||||
self.entries.append(entry)
|
||||
|
||||
def rows(self, use_revision_columns: bool) -> list:
|
||||
# Generate rows of entries for printing and running.
|
||||
entries = sorted(
|
||||
self.entries,
|
||||
key=lambda entry: (
|
||||
entry.revision,
|
||||
entry.device_id,
|
||||
entry.category,
|
||||
entry.test,
|
||||
))
|
||||
|
||||
if not use_revision_columns:
|
||||
# One entry per row.
|
||||
return [[entry] for entry in entries]
|
||||
else:
|
||||
# Multiple revisions per row.
|
||||
rows = {}
|
||||
|
||||
for entry in entries:
|
||||
key = (normalize_device_id(entry.device_id), entry.category, entry.test)
|
||||
if key in rows:
|
||||
rows[key].append(entry)
|
||||
else:
|
||||
rows[key] = [entry]
|
||||
|
||||
return [value for _, value in sorted(rows.items())]
|
||||
|
||||
def find(self, revision: str, test: str, category: str, device_id: str) -> dict:
|
||||
sanitized = normalize_device_id(device_id)
|
||||
for entry in self.entries:
|
||||
if (
|
||||
entry.revision == revision and
|
||||
entry.test == test and
|
||||
entry.category == category and
|
||||
normalize_device_id(entry.device_id) == sanitized
|
||||
):
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
def write(self) -> None:
|
||||
json_entries = [entry.to_json() for entry in self.entries]
|
||||
with open(self.filepath, 'w') as f:
|
||||
json.dump(json_entries, f, indent=2)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Test configuration, containing a subset of revisions, tests and devices."""
|
||||
|
||||
def __init__(self, env, name: str):
|
||||
# Init configuration from config.py file.
|
||||
self.name = name
|
||||
self.base_dir = env.base_dir / name
|
||||
self.logs_dir = self.base_dir / 'logs'
|
||||
self.builds_dir = self.base_dir / 'builds'
|
||||
|
||||
config = TestConfig._read_config_module(self.base_dir)
|
||||
self.tests = TestCollection(env,
|
||||
getattr(config, 'tests', ['*']),
|
||||
getattr(config, 'categories', ['*']),
|
||||
getattr(config, 'background', False))
|
||||
self.revisions = getattr(config, 'revisions', {})
|
||||
self.builds = getattr(config, 'builds', {})
|
||||
self.queue = TestQueue(self.base_dir / 'results.json')
|
||||
self.benchmark_type = getattr(config, 'benchmark_type', 'comparison')
|
||||
|
||||
self.devices = []
|
||||
self._update_devices(env, getattr(config, 'devices', ['CPU']))
|
||||
|
||||
self._update_queue(env)
|
||||
|
||||
def revision_names(self) -> list:
|
||||
return sorted(list(self.revisions.keys()) + list(self.builds.keys()))
|
||||
|
||||
def device_name(self, device_id: str) -> str:
|
||||
for device in self.devices:
|
||||
if device.id == device_id:
|
||||
return device.name
|
||||
|
||||
return "Unknown"
|
||||
|
||||
@staticmethod
|
||||
def write_default_config(env, config_dir: pathlib.Path, build_dir: str) -> None:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
default_config = """devices = ['CPU']\n"""
|
||||
default_config += """tests = ['*']\n"""
|
||||
default_config += """categories = ['*']\n"""
|
||||
default_config += """builds = {\n"""
|
||||
if build_dir:
|
||||
default_config += """ 'main': '{}',""".format(build_dir)
|
||||
else:
|
||||
default_config += """ 'main': '/home/user/blender-git/build/bin/blender',"""
|
||||
default_config += """ '2.93': '/home/user/blender-2.93/blender',"""
|
||||
default_config += """}\n"""
|
||||
default_config += """revisions = {\n"""
|
||||
default_config += """}\n"""
|
||||
|
||||
config_file = config_dir / 'config.py'
|
||||
with open(config_file, 'w') as f:
|
||||
f.write(default_config)
|
||||
|
||||
@staticmethod
|
||||
def read_blender_executables(env, name) -> list:
|
||||
config = TestConfig._read_config_module(env.base_dir / name)
|
||||
builds = getattr(config, 'builds', {})
|
||||
executables = []
|
||||
|
||||
for executable in builds.values():
|
||||
executable, _ = TestConfig._split_environment_variables(executable)
|
||||
executables.append(pathlib.Path(executable))
|
||||
|
||||
return executables
|
||||
|
||||
@staticmethod
|
||||
def _read_config_module(base_dir: pathlib.Path) -> None:
|
||||
# Import config.py as a module.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("testconfig", base_dir / 'config.py')
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
def _update_devices(self, env, device_filters: list) -> None:
|
||||
# Find devices matching the filters.
|
||||
need_gpus = device_filters != ['CPU']
|
||||
machine = env.get_machine(need_gpus)
|
||||
|
||||
self.devices = []
|
||||
for device in machine.devices:
|
||||
for device_filter in device_filters:
|
||||
if fnmatch.fnmatch(device.id, device_filter) or \
|
||||
fnmatch.fnmatch(normalize_device_id(device.id), normalize_device_id(device_filter)):
|
||||
self.devices.append(device)
|
||||
break
|
||||
|
||||
def _update_queue(self, env) -> None:
|
||||
# Update queue to match configuration, adding and removing entries
|
||||
# so that there is one entry for each revision, device and test
|
||||
# combination.
|
||||
entries = []
|
||||
|
||||
# Get entries for specified commits, tags and branches.
|
||||
for revision_name, revision_commit in self.revisions.items():
|
||||
revision_commit, environment = self._split_environment_variables(revision_commit)
|
||||
git_hash = env.resolve_git_hash(revision_commit)
|
||||
date = env.git_hash_date(git_hash)
|
||||
entries += self._get_entries(revision_name, git_hash, '', environment, date)
|
||||
|
||||
# Get entries for revisions based on existing builds.
|
||||
for revision_name, executable in self.builds.items():
|
||||
executable, environment = self._split_environment_variables(executable)
|
||||
executable_path = env._blender_executable_from_path(pathlib.Path(executable))
|
||||
if not executable_path:
|
||||
import sys
|
||||
sys.stderr.write(f'Error: no valid build found at {executable}\n')
|
||||
sys.exit(1)
|
||||
|
||||
env.set_blender_executable(executable_path)
|
||||
git_hash, _ = env.run_in_blender(get_build_hash, {})
|
||||
env.set_default_blender_executable()
|
||||
|
||||
mtime = executable_path.stat().st_mtime
|
||||
entries += self._get_entries(revision_name, git_hash, executable, environment, mtime)
|
||||
|
||||
# Detect number of categories for more compact printing.
|
||||
categories = set()
|
||||
devices = set()
|
||||
for entry in entries:
|
||||
categories.add(entry.category)
|
||||
devices.add(entry.device_id)
|
||||
self.queue.has_multiple_categories = len(categories) > 1
|
||||
self.queue.has_multiple_devices = len(devices) > 1
|
||||
|
||||
# Replace actual entries.
|
||||
self.queue.entries = entries
|
||||
|
||||
def _get_entries(self,
|
||||
revision_name: str,
|
||||
git_hash: str,
|
||||
executable: pathlib.Path,
|
||||
environment: str,
|
||||
date: int) -> None:
|
||||
entries = []
|
||||
for test in self.tests.tests:
|
||||
test_name = test.name()
|
||||
test_category = test.category()
|
||||
|
||||
# Filter devices that are supported by this test. Add a default CPU when
|
||||
# no devices are supported for backwards compatibility
|
||||
supported_device_types = ['CPU']
|
||||
if test.use_device():
|
||||
supported_device_types = test.supported_device_types()
|
||||
|
||||
devices = filter(lambda device: device.type in test.supported_device_types(), self.devices)
|
||||
if not devices:
|
||||
devices = filter(lambda device: device.type == 'CPU', self.devices)
|
||||
|
||||
for device in devices:
|
||||
entry = self.queue.find(revision_name, test_name, test_category, device.id)
|
||||
if entry:
|
||||
# Test if revision hash or executable changed.
|
||||
if entry.git_hash != git_hash or \
|
||||
entry.executable != executable or \
|
||||
entry.environment != environment or \
|
||||
entry.benchmark_type != self.benchmark_type or \
|
||||
entry.date != date:
|
||||
# Update existing entry.
|
||||
entry.git_hash = git_hash
|
||||
entry.environment = environment
|
||||
entry.executable = executable
|
||||
entry.benchmark_type = self.benchmark_type
|
||||
entry.date = date
|
||||
entry.device_name = device.name
|
||||
if device.cpu:
|
||||
entry.device_cpu = device.cpu
|
||||
if entry.status in {'done', 'failed'}:
|
||||
entry.status = 'outdated'
|
||||
else:
|
||||
# Add new entry if it did not exist yet.
|
||||
entry = TestEntry(
|
||||
revision=revision_name,
|
||||
git_hash=git_hash,
|
||||
executable=executable,
|
||||
environment=environment,
|
||||
date=date,
|
||||
test=test_name,
|
||||
category=test_category,
|
||||
device_type=device.type,
|
||||
device_id=device.id,
|
||||
device_name=device.name,
|
||||
device_cpu=device.cpu,
|
||||
benchmark_type=self.benchmark_type)
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def _split_environment_variables(revision):
|
||||
if isinstance(revision, str):
|
||||
return revision, {}
|
||||
else:
|
||||
return revision[0], revision[1]
|
||||
152
blender-5.2.0/tests/performance/api/device.py
Normal file
152
blender-5.2.0/tests/performance/api/device.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# SPDX-FileCopyrightText: 2021-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_cpu_name() -> str:
|
||||
# Get full CPU name.
|
||||
if platform.system() == "Windows":
|
||||
return platform.processor()
|
||||
elif platform.system() == "Darwin":
|
||||
cmd = ['/usr/sbin/sysctl', "-n", "machdep.cpu.brand_string"]
|
||||
return subprocess.check_output(cmd).strip().decode('utf-8', 'ignore')
|
||||
else:
|
||||
with open('/proc/cpuinfo') as f:
|
||||
for line in f:
|
||||
if line.startswith('model name'):
|
||||
return line.split(':')[1].strip()
|
||||
|
||||
return "Unknown CPU"
|
||||
|
||||
|
||||
def get_gpu_device_cycles(args: None) -> dict:
|
||||
# Get the list of available Cycles GPU devices.
|
||||
import bpy
|
||||
|
||||
prefs = bpy.context.preferences
|
||||
if 'cycles' not in prefs.addons.keys():
|
||||
return {'devices': []}
|
||||
cprefs = prefs.addons['cycles'].preferences
|
||||
|
||||
result = []
|
||||
|
||||
for device_type, _, _, _ in cprefs.get_device_types(bpy.context):
|
||||
cprefs.compute_device_type = device_type
|
||||
devices = cprefs.get_devices_for_type(device_type)
|
||||
index = 0
|
||||
for device in devices:
|
||||
if device.type == device_type:
|
||||
result.append({'type': device.type, 'name': device.name, 'index': index})
|
||||
if device.type in {"HIP", "METAL", "ONEAPI"}:
|
||||
result.append({'type': f"{device.type}-RT", 'name': device.name, 'index': index})
|
||||
if device.type in {"OPTIX"}:
|
||||
result.append({'type': f"{device.type}-OSL", 'name': device.name, 'index': index})
|
||||
index += 1
|
||||
return {'devices': result}
|
||||
|
||||
|
||||
def get_gpu_device_backend(args: dict) -> dict:
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
|
||||
result = []
|
||||
|
||||
prefs = bpy.context.preferences
|
||||
gpu_backend = args['gpu_backend'].upper()
|
||||
|
||||
try:
|
||||
gpu.init()
|
||||
except AttributeError:
|
||||
# `gpu.init` has been introduced in March 2026, previous versions are not able to access gpu module and require a fallback.
|
||||
original_gpu_backend = prefs.system.gpu_backend
|
||||
try:
|
||||
prefs.system.gpu_backend = gpu_backend
|
||||
except TypeError:
|
||||
# GPU backend isn't available.
|
||||
pass
|
||||
else:
|
||||
result.append({'type': gpu_backend, 'name': gpu_backend})
|
||||
prefs.system.gpu_backend = original_gpu_backend
|
||||
else:
|
||||
backend_type = gpu.platform.backend_type_get()
|
||||
if backend_type != args['gpu_backend'].upper():
|
||||
return {'devices': []}
|
||||
|
||||
try:
|
||||
devices = gpu.platform.devices_get()
|
||||
except (AttributeError, RuntimeError):
|
||||
devices = []
|
||||
|
||||
if devices:
|
||||
for device in devices:
|
||||
result.append({'type': backend_type, 'name': device.name, 'index': device.index})
|
||||
else:
|
||||
result.append({'type': backend_type, 'name': gpu.platform.renderer_get()})
|
||||
|
||||
return {'devices': result}
|
||||
|
||||
|
||||
def get_gpu_devices(env) -> list:
|
||||
"""
|
||||
Return a list of devices available in default blender executable.
|
||||
"""
|
||||
from api.environment import TestFailure
|
||||
|
||||
result = []
|
||||
|
||||
try:
|
||||
cycles_devices, _ = env.run_in_blender(get_gpu_device_cycles, {})
|
||||
except TestFailure as failure:
|
||||
logger.error("Unable to receive cycles device list", exc_info=failure)
|
||||
else:
|
||||
result += cycles_devices.get('devices', [])
|
||||
|
||||
for backend in ['vulkan', 'opengl', 'metal']:
|
||||
try:
|
||||
backend_devices, _ = env.run_in_blender(
|
||||
get_gpu_device_backend, {'gpu_backend': backend}, ['--gpu-backend', backend])
|
||||
except TestFailure as failure:
|
||||
logger.error(f"Unable to receive device list for '{backend}'", exc_info=failure)
|
||||
else:
|
||||
result += backend_devices.get('devices', [])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestDevice:
|
||||
def __init__(self, device_type: str, device_id: str, name: str, cpu: str, operating_system: str):
|
||||
self.type = device_type
|
||||
self.id = device_id
|
||||
self.name = name
|
||||
self.cpu = cpu
|
||||
self.operating_system = operating_system
|
||||
|
||||
|
||||
class TestMachine:
|
||||
def __init__(self, env, need_gpus: bool):
|
||||
operating_system = platform.system()
|
||||
|
||||
device_cpu = get_cpu_name()
|
||||
self.devices = [TestDevice('CPU', 'CPU', device_cpu, device_cpu, operating_system),
|
||||
TestDevice('CPU-OSL', 'CPU-OSL', device_cpu, device_cpu, operating_system)]
|
||||
self.has_gpus = need_gpus
|
||||
|
||||
if need_gpus and env.blender_executable:
|
||||
gpu_devices = get_gpu_devices(env)
|
||||
for gpu_device in gpu_devices:
|
||||
device_type = gpu_device['type']
|
||||
device_name = gpu_device['name']
|
||||
device_id = device_type
|
||||
if 'index' in gpu_device:
|
||||
device_id += "_" + str(gpu_device['index'])
|
||||
self.devices.append(TestDevice(device_type, device_id, device_name, device_cpu, operating_system))
|
||||
|
||||
def cpu_device(self) -> TestDevice:
|
||||
return self.devices[0]
|
||||
437
blender-5.2.0/tests/performance/api/environment.py
Normal file
437
blender-5.2.0/tests/performance/api/environment.py
Normal file
@@ -0,0 +1,437 @@
|
||||
# SPDX-FileCopyrightText: 2021-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import inspect
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import pickle
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
)
|
||||
|
||||
from .common import normalize_device_id
|
||||
from .config import TestConfig
|
||||
from .device import TestMachine
|
||||
|
||||
|
||||
class TestFailure(Exception):
|
||||
def __init__(self, *args, message, output_lines=[], **kwargs):
|
||||
super().__init__(message, *args)
|
||||
self.message = message
|
||||
self.output_lines = output_lines
|
||||
|
||||
def __str__(self):
|
||||
msg = self.message
|
||||
if self.output_lines:
|
||||
msg += f":\n{'': <10} | "
|
||||
msg += f"\n{'': <10} | ".join(l.rstrip(' \r\n\t') for l in self.output_lines)
|
||||
return msg
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
def __init__(self, blender_git_dir: pathlib.Path, base_dir: pathlib.Path):
|
||||
self.blender_git_dir = blender_git_dir
|
||||
self.base_dir = base_dir
|
||||
self.blender_dir = base_dir / 'blender'
|
||||
self.build_dir = base_dir / 'build'
|
||||
self.install_dir = self.build_dir / "bin"
|
||||
self.benchmarks_dir = self.blender_git_dir / 'tests' / 'benchmarks'
|
||||
self.git_executable = 'git'
|
||||
self.cmake_executable = 'cmake'
|
||||
self.cmake_options = ['-DWITH_INTERNATIONAL=OFF', '-DWITH_BUILDINFO=OFF']
|
||||
self.log_file = None
|
||||
self.machine = None
|
||||
self._title_cache = {}
|
||||
self._init_default_blender_executable()
|
||||
self.set_default_blender_executable()
|
||||
|
||||
def get_machine(self, need_gpus: bool = True) -> None:
|
||||
if not self.machine or (need_gpus and not self.machine.has_gpus):
|
||||
self.machine = TestMachine(self, need_gpus)
|
||||
|
||||
return self.machine
|
||||
|
||||
def init(self, build: bool, blender_bin: str) -> None:
|
||||
if not self.benchmarks_dir.exists():
|
||||
sys.stderr.write(f'Error: benchmark files directory not found at {self.benchmarks_dir}')
|
||||
sys.exit(1)
|
||||
|
||||
# Create benchmarks folder contents.
|
||||
print(f'Init {self.base_dir}')
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if len(self.get_config_names()) == 0:
|
||||
config_dir = self.base_dir / 'default'
|
||||
print(f'Creating default configuration in {config_dir}')
|
||||
TestConfig.write_default_config(self, config_dir, blender_bin)
|
||||
|
||||
if build:
|
||||
if not self.blender_dir.exists():
|
||||
print(f'Init git worktree in {self.blender_dir}')
|
||||
self.call([self.git_executable, 'worktree', 'add', '--detach',
|
||||
self.blender_dir, 'HEAD'], self.blender_git_dir)
|
||||
else:
|
||||
print(f'Exists {self.blender_dir}')
|
||||
|
||||
if not self.build_dir.exists():
|
||||
print(f'Init build in {self.build_dir}')
|
||||
self.build_dir.mkdir()
|
||||
# No translation to avoid dealing with submodules
|
||||
self.call([self.cmake_executable, self.blender_dir, '.'] + self.cmake_options, self.build_dir)
|
||||
else:
|
||||
print(f'Exists {self.build_dir}')
|
||||
|
||||
print("Building")
|
||||
git_hash = self.resolve_git_hash('HEAD')
|
||||
self.build(git_hash, self.install_dir)
|
||||
|
||||
print('Done')
|
||||
|
||||
def checkout(self, git_hash: str, update_submodules: bool = True) -> None:
|
||||
# Checkout Blender revision
|
||||
if not self.blender_dir.exists():
|
||||
sys.stderr.write('\n\nError: no build set up, run `./benchmark init --build` first\n')
|
||||
sys.exit(1)
|
||||
|
||||
self.call([self.git_executable, 'clean', '-f', '-d'], self.blender_dir)
|
||||
self.call([self.git_executable, 'reset', '--hard', 'HEAD'], self.blender_dir)
|
||||
self.call([self.git_executable, 'checkout', '--detach', git_hash], self.blender_dir)
|
||||
|
||||
if update_submodules:
|
||||
self.call([self.git_executable, 'submodule', 'update', '--recursive', '--force'], self.blender_dir)
|
||||
|
||||
def _submodule_key(self) -> str:
|
||||
"""
|
||||
Return a key to identify the current submodule checkout. The key only includes checked out
|
||||
submodules.
|
||||
|
||||
The key is used to determine if submodules have changed between builds. In that case the
|
||||
CMakeCache should be reevaluated as it can point to different libraries/versions.
|
||||
"""
|
||||
log_lines = self.call([self.git_executable, 'submodule', 'status'], self.blender_dir, silent=True)
|
||||
# Only add submodules that are checked out
|
||||
log_lines = [line for line in log_lines if not line.startswith("-")]
|
||||
submodule_key = ",".join([l.split()[0] for l in log_lines])
|
||||
return submodule_key
|
||||
|
||||
def build(self, git_hash: str, install_dir: pathlib.Path, update_submodules: bool = True) -> bool:
|
||||
# Build Blender revision
|
||||
if not self.build_dir.exists():
|
||||
sys.stderr.write('\n\nError: no build set up, run `./benchmark init --build` first\n')
|
||||
sys.exit(1)
|
||||
|
||||
# Skip if build with same hash is already done.
|
||||
if install_dir.resolve() != self.install_dir.resolve():
|
||||
complete_txt = pathlib.Path(install_dir) / "complete.txt"
|
||||
if complete_txt.is_file():
|
||||
if complete_txt.read_text().strip() == git_hash:
|
||||
self._init_default_blender_executable()
|
||||
return True
|
||||
# Different hash, build again.
|
||||
complete_txt.unlink()
|
||||
else:
|
||||
complete_txt = None
|
||||
|
||||
old_submodule_key = self._submodule_key()
|
||||
self.checkout(git_hash, update_submodules)
|
||||
new_submodule_key = self._submodule_key()
|
||||
if old_submodule_key != new_submodule_key:
|
||||
cmake_cache = self.build_dir / "CMakeCache.txt"
|
||||
if cmake_cache.exists():
|
||||
cmake_cache.unlink()
|
||||
|
||||
jobs = str(multiprocessing.cpu_count())
|
||||
cmake_options = list(self.cmake_options)
|
||||
cmake_options += [f"-DCMAKE_INSTALL_PREFIX={install_dir}"]
|
||||
try:
|
||||
self.call([self.cmake_executable, self.blender_dir, '.'] + cmake_options, self.build_dir)
|
||||
self.call([self.cmake_executable, '--build', '.', '-j', jobs, '--target', 'install'], self.build_dir)
|
||||
if complete_txt:
|
||||
complete_txt.write_text(git_hash)
|
||||
except KeyboardInterrupt as e:
|
||||
raise e
|
||||
except:
|
||||
return False
|
||||
|
||||
self._init_default_blender_executable()
|
||||
return True
|
||||
|
||||
def set_blender_executable(self, executable_path: pathlib.Path, environment: dict = {}) -> None:
|
||||
if executable_path.is_dir():
|
||||
executable_path = self._blender_executable_from_path(executable_path)
|
||||
|
||||
# Run all Blender commands with this executable.
|
||||
self.blender_executable = executable_path
|
||||
self.blender_executable_environment = environment
|
||||
|
||||
def _blender_executable_name(self) -> pathlib.Path:
|
||||
if platform.system() == "Windows":
|
||||
return pathlib.Path('blender.exe')
|
||||
elif platform.system() == "Darwin":
|
||||
return pathlib.Path('Blender.app') / 'Contents' / 'MacOS' / 'Blender'
|
||||
else:
|
||||
return pathlib.Path('blender')
|
||||
|
||||
def _has_install_files(self, executable_path: pathlib.Path) -> bool:
|
||||
"""
|
||||
Check if the given executable is a full installation of blender by checking
|
||||
the availability of the 'license' directory.
|
||||
"""
|
||||
# Follow symlink to actual install location.
|
||||
executable_path = executable_path.resolve()
|
||||
if platform.system() == "Darwin":
|
||||
license_path = executable_path.parent.parent / 'Resources' / 'text' / 'license'
|
||||
else:
|
||||
license_path = executable_path.parent / 'license'
|
||||
return license_path.is_dir()
|
||||
|
||||
def _blender_executable_from_path(self, executable: pathlib.Path) -> pathlib.Path:
|
||||
if executable.is_dir():
|
||||
# Directory
|
||||
executable = executable / self._blender_executable_name()
|
||||
elif not executable.is_file() and executable.name == 'blender':
|
||||
# Executable path without proper path on Windows or macOS.
|
||||
executable = executable.parent / self._blender_executable_name()
|
||||
|
||||
if executable.is_file() and self._has_install_files(executable):
|
||||
return executable
|
||||
|
||||
return None
|
||||
|
||||
def _init_default_blender_executable(self) -> None:
|
||||
# Find a default executable to run commands independent of testing a specific build.
|
||||
# Try own built executable.
|
||||
built_executable = self._blender_executable_from_path(self.install_dir)
|
||||
if built_executable:
|
||||
self.default_blender_executable = built_executable
|
||||
return
|
||||
|
||||
# Try find an executable in the configs.
|
||||
for config_name in self.get_config_names():
|
||||
for executable in TestConfig.read_blender_executables(self, config_name):
|
||||
executable = self._blender_executable_from_path(executable)
|
||||
if executable:
|
||||
self.default_blender_executable = executable
|
||||
return
|
||||
|
||||
# Fallback to a "blender" command in the hope it's available.
|
||||
self.default_blender_executable = pathlib.Path("blender")
|
||||
|
||||
def set_default_blender_executable(self) -> None:
|
||||
self.blender_executable = self.default_blender_executable
|
||||
self.blender_executable_environment = {}
|
||||
|
||||
def set_log_file(self, filepath: pathlib.Path, clear=True) -> None:
|
||||
# Log all commands and output to this file.
|
||||
self.log_file = filepath
|
||||
|
||||
if clear:
|
||||
self.log_file.unlink(missing_ok=True)
|
||||
|
||||
def unset_log_file(self) -> None:
|
||||
self.log_file = None
|
||||
|
||||
def call(self, args: list[str], cwd: pathlib.Path, silent: bool = False, environment: dict = {}) -> list[str]:
|
||||
# Execute command with arguments in specified directory,
|
||||
# and return combined stdout and stderr output.
|
||||
|
||||
# Open log file for writing
|
||||
f = None
|
||||
if self.log_file:
|
||||
if not self.log_file.exists():
|
||||
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(self.log_file, 'a', encoding='utf-8', errors='ignore')
|
||||
f.write('\n' + ' '.join([str(arg) for arg in args]) + '\n\n')
|
||||
|
||||
env = os.environ
|
||||
if len(environment):
|
||||
env = env.copy()
|
||||
for key, value in environment.items():
|
||||
env[key] = value
|
||||
|
||||
proc = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
|
||||
|
||||
# Read line by line
|
||||
lines = []
|
||||
try:
|
||||
while proc.poll() is None:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
line_str = line.decode('utf-8', 'ignore')
|
||||
lines.append(line_str)
|
||||
if f:
|
||||
f.write(line_str)
|
||||
except KeyboardInterrupt as e:
|
||||
# Avoid processes that keep running when interrupting.
|
||||
proc.terminate()
|
||||
raise e
|
||||
|
||||
# Raise error on failure
|
||||
if proc.returncode != 0 and not silent:
|
||||
raise TestFailure(message="Error executing command", output_lines=lines)
|
||||
|
||||
return lines
|
||||
|
||||
def call_blender(self, args: list[str], foreground=False) -> list[str]:
|
||||
# Execute Blender command with arguments.
|
||||
common_args = ['--factory-startup', '-noaudio', '--enable-autoexec', '--python-exit-code', '1']
|
||||
if sys.platform == 'win32':
|
||||
# Set HighQoS level on Windows to avoid reduced performance when the window is out of focus.
|
||||
# See: https://learn.microsoft.com/en-us/windows/win32/procthread/quality-of-service
|
||||
common_args += ['--qos', 'high']
|
||||
if foreground:
|
||||
common_args += ['--no-window-focus', '--window-geometry', '0', '0', '1024', '768', '--gpu-vsync', 'off']
|
||||
else:
|
||||
common_args += ['--background']
|
||||
|
||||
return self.call([self.blender_executable] + common_args + args, cwd=self.base_dir,
|
||||
environment=self.blender_executable_environment)
|
||||
|
||||
def run_in_blender(self,
|
||||
function: Callable[[dict], dict],
|
||||
args: dict,
|
||||
blender_args: list = [],
|
||||
foreground=False) -> dict:
|
||||
# Run function in a Blender instance. Arguments and return values are
|
||||
# passed as a Python object that must be serializable with pickle.
|
||||
|
||||
# Get information to call this function from Blender.
|
||||
package_path = pathlib.Path(__file__).parent.parent
|
||||
functionname = function.__name__
|
||||
modulename = inspect.getmodule(function).__name__
|
||||
|
||||
# Serialize arguments in base64, to avoid having to escape it.
|
||||
args = base64.b64encode(pickle.dumps(args))
|
||||
output_prefix = 'TEST_OUTPUT: '
|
||||
|
||||
expression = (f'import sys, pickle, base64;'
|
||||
f'sys.path.append(r"{package_path}");'
|
||||
f'import {modulename};'
|
||||
f'args = pickle.loads(base64.b64decode({args}));'
|
||||
f'result = {modulename}.{functionname}(args);'
|
||||
f'result = base64.b64encode(pickle.dumps(result));'
|
||||
f'print("\\n{output_prefix}" + result.decode() + "\\n")')
|
||||
|
||||
expr_args = blender_args + ['--python-expr', expression]
|
||||
lines = self.call_blender(expr_args, foreground=foreground)
|
||||
|
||||
# Parse output.
|
||||
for line in lines:
|
||||
if line.startswith(output_prefix):
|
||||
output = line[len(output_prefix):].strip()
|
||||
result = pickle.loads(base64.b64decode(output))
|
||||
return result, lines
|
||||
|
||||
return {}, lines
|
||||
|
||||
def find_blend_files(self, dirpath: pathlib.Path) -> list:
|
||||
# Find .blend files in subdirectories of the given directory in the
|
||||
# lib/benchmarks directory.
|
||||
dirpath = self.benchmarks_dir / dirpath
|
||||
filepaths = []
|
||||
for filename in glob.iglob(str(dirpath / '*.blend'), recursive=True):
|
||||
filepaths.append(pathlib.Path(filename))
|
||||
return filepaths
|
||||
|
||||
def get_config_names(self) -> list:
|
||||
names = []
|
||||
|
||||
if self.base_dir.exists():
|
||||
for dirname in os.listdir(self.base_dir):
|
||||
dirpath = self.base_dir / dirname / 'config.py'
|
||||
if dirpath.exists():
|
||||
names.append(dirname)
|
||||
|
||||
return names
|
||||
|
||||
def get_configs(self, name: str = None, names_only: bool = False) -> list:
|
||||
# Get list of configurations in the benchmarks directory.
|
||||
configs = []
|
||||
|
||||
for config_name in self.get_config_names():
|
||||
if not name or config_name == name:
|
||||
if names_only:
|
||||
configs.append(config_name)
|
||||
else:
|
||||
configs.append(TestConfig(self, config_name))
|
||||
|
||||
return configs
|
||||
|
||||
def resolve_git_hash(self, revision):
|
||||
# Get git hash for a tag or branch.
|
||||
lines = self.call([self.git_executable, 'rev-parse', revision], self.blender_git_dir)
|
||||
return lines[0].strip() if len(lines) else revision
|
||||
|
||||
def git_hash_date(self, git_hash):
|
||||
# Get commit data for a git hash.
|
||||
lines = self.call([self.git_executable, 'log', '-n1', git_hash, '--format=%at'], self.blender_git_dir)
|
||||
return int(lines[0].strip()) if len(lines) else 0
|
||||
|
||||
def commits_in_window(self, after_ts: int, before_ts: int) -> list[tuple[str, int]]:
|
||||
"""List commits in a time window, oldest first.
|
||||
|
||||
Returns a list of ``(commit_hash, unix_timestamp)`` tuples
|
||||
for commits reachable from ``HEAD`` whose commit date falls
|
||||
between ``after_ts`` and ``before_ts``.
|
||||
"""
|
||||
try:
|
||||
lines = self.call(
|
||||
[self.git_executable, 'log', '--first-parent', '--reverse',
|
||||
'--after=' + str(after_ts - 1), '--before=' + str(before_ts),
|
||||
'--format=%H %ct', 'HEAD'],
|
||||
self.blender_git_dir, silent=True)
|
||||
except:
|
||||
return []
|
||||
result: list[tuple[str, int]] = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
result.append((parts[0][:12], int(parts[1])))
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
|
||||
def commit_title(self, git_hash: str) -> str:
|
||||
"""Return the one-line subject of a commit, with caching."""
|
||||
if git_hash in self._title_cache:
|
||||
return self._title_cache[git_hash]
|
||||
try:
|
||||
lines = self.call(
|
||||
[self.git_executable, 'log', '-n1', '--format=%s', git_hash],
|
||||
self.blender_git_dir, silent=True)
|
||||
title = lines[0].strip() if lines else ''
|
||||
except:
|
||||
title = ''
|
||||
self._title_cache[git_hash] = title
|
||||
return title
|
||||
|
||||
def resolve_device(self, device_str: str) -> tuple[str, str]:
|
||||
"""Resolve a device string to a device_id and gpu_backend pair."""
|
||||
machine = self.get_machine(need_gpus=True)
|
||||
device_id = device_str
|
||||
gpu_backend = 'default'
|
||||
|
||||
sanitized_str = normalize_device_id(device_str)
|
||||
for device in machine.devices:
|
||||
if normalize_device_id(device.id) == sanitized_str or device.type == device_str:
|
||||
device_id = device.id
|
||||
gpu_backend = {
|
||||
'VULKAN': 'vulkan',
|
||||
'METAL': 'metal',
|
||||
'OPENGL': 'opengl'
|
||||
}.get(device.type, 'default')
|
||||
break
|
||||
|
||||
return device_id, gpu_backend
|
||||
174
blender-5.2.0/tests/performance/api/graph.py
Normal file
174
blender-5.2.0/tests/performance/api/graph.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from . import TestQueue
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
|
||||
class TestGraph:
|
||||
def __init__(self, json_filepaths: list[pathlib.Path]):
|
||||
# Initialize graph from JSON file. Note that this is implemented without
|
||||
# accessing any benchmark environment or configuration. This ways benchmarks
|
||||
# run on various machines can be aggregated and the graph generated on another
|
||||
# machine.
|
||||
|
||||
# Gather entries for each device.
|
||||
devices = {}
|
||||
|
||||
for json_filepath in json_filepaths:
|
||||
queue = TestQueue(json_filepath)
|
||||
|
||||
for entry in queue.entries:
|
||||
if entry.status in {'done', 'outdated'}:
|
||||
device_name = f"{entry.device_name} ({entry.device_type})"
|
||||
if device_name in devices.keys():
|
||||
devices[device_name].append(entry)
|
||||
else:
|
||||
devices[device_name] = [entry]
|
||||
|
||||
data = []
|
||||
# Sort devices alphabetically.
|
||||
sorted_devices = sorted(devices.items(), key=lambda item: item[0])
|
||||
for device_name, device_entries in sorted_devices:
|
||||
|
||||
# Gather used categories.
|
||||
categories = {}
|
||||
device_cpu = ''
|
||||
for entry in device_entries:
|
||||
category = entry.category
|
||||
if category in categories.keys():
|
||||
categories[category].append(entry)
|
||||
else:
|
||||
categories[category] = [entry]
|
||||
|
||||
if device_cpu == '':
|
||||
device_cpu = entry.device_cpu
|
||||
|
||||
# Sort categories alphabetically.
|
||||
sorted_categories = sorted(categories.items(), key=lambda item: item[0])
|
||||
# Generate one graph for every device x category x result key combination.
|
||||
for category, category_entries in sorted_categories:
|
||||
entries = sorted(category_entries, key=lambda entry: (entry.date, entry.revision, entry.test))
|
||||
|
||||
outputs = set()
|
||||
for entry in entries:
|
||||
for output in entry.output.keys():
|
||||
outputs.add(output)
|
||||
|
||||
chart_type = 'line' if entries[0].benchmark_type == 'time_series' else 'comparison'
|
||||
if chart_type == 'comparison':
|
||||
entries = sorted(entries, key=lambda entry: (entry.revision, entry.test))
|
||||
|
||||
for output in sorted(outputs, reverse=True):
|
||||
chart_name = f"{category} ({output})"
|
||||
data.append(self.chart(device_name, device_cpu, chart_name, entries, chart_type, output))
|
||||
|
||||
self.json = json.dumps(data, indent=2)
|
||||
|
||||
def chart(self, device_name: str, device_cpu, chart_name: str, entries: list, chart_type: str, output: str) -> dict:
|
||||
# Gather used tests.
|
||||
tests = {}
|
||||
for entry in entries:
|
||||
test = entry.test
|
||||
if test not in tests.keys():
|
||||
tests[test] = len(tests)
|
||||
|
||||
# Gather used revisions.
|
||||
revisions = {}
|
||||
revision_dates = {}
|
||||
use_error_bars = False
|
||||
for entry in entries:
|
||||
revision = entry.revision
|
||||
if revision not in revisions.keys():
|
||||
revisions[revision] = len(revisions)
|
||||
revision_dates[revision] = int(entry.date)
|
||||
|
||||
output_values = entry.output_all_runs.get(output)
|
||||
if output_values and len(output_values) > 1:
|
||||
use_error_bars = True
|
||||
|
||||
default_entry = {
|
||||
'x': None,
|
||||
'y': None,
|
||||
'yMin': None,
|
||||
'yMax': None,
|
||||
}
|
||||
|
||||
# Convert to chart.js data layout.
|
||||
if chart_type == 'comparison':
|
||||
# For comparison, tests on the X axis and revisions as datasets.
|
||||
|
||||
# Sort tests by index to ensure stable order for labels.
|
||||
sorted_tests = sorted(tests.items(), key=lambda item: item[1])
|
||||
labels = [test for test, _ in sorted_tests]
|
||||
|
||||
datasets = []
|
||||
# Sort revisions by index.
|
||||
sorted_revisions = sorted(revisions.items(), key=lambda item: item[1])
|
||||
for revision, index in sorted_revisions:
|
||||
datasets.append({
|
||||
'label': revision,
|
||||
'data': [default_entry] * len(tests),
|
||||
})
|
||||
|
||||
for entry in entries:
|
||||
test_index = tests[entry.test]
|
||||
revision_index = revisions[entry.revision]
|
||||
output_values = entry.output_all_runs.get(output)
|
||||
if output_values:
|
||||
datasets[revision_index]['data'][test_index] = {
|
||||
'x': test_index,
|
||||
'y': sum(output_values) / len(output_values),
|
||||
'yMin': min(output_values),
|
||||
'yMax': max(output_values),
|
||||
}
|
||||
|
||||
else:
|
||||
# For time series, dates on the X axis and tests as datasets.
|
||||
labels = [None] * len(revisions)
|
||||
for revision, index in revisions.items():
|
||||
labels[index] = revision_dates[revision] * 1000
|
||||
|
||||
datasets = []
|
||||
# Sort tests by index to ensure stable order.
|
||||
sorted_tests = sorted(tests.items(), key=lambda item: item[1])
|
||||
for test, index in sorted_tests:
|
||||
datasets.append({
|
||||
'label': test,
|
||||
'data': [default_entry] * len(revisions),
|
||||
'tension': 0.1,
|
||||
})
|
||||
|
||||
for entry in entries:
|
||||
test_index = tests[entry.test]
|
||||
revision_index = revisions[entry.revision]
|
||||
output_values = entry.output_all_runs.get(output)
|
||||
if output_values:
|
||||
datasets[test_index]['data'][revision_index] = {
|
||||
'x': revision_index,
|
||||
'y': sum(output_values) / len(output_values),
|
||||
'yMin': min(output_values),
|
||||
'yMax': max(output_values),
|
||||
}
|
||||
|
||||
data = {'labels': labels, 'datasets': datasets}
|
||||
return {
|
||||
'device': device_name,
|
||||
'device_cpu': device_cpu,
|
||||
'name': chart_name,
|
||||
'data': data,
|
||||
'chart_type': chart_type,
|
||||
'use_error_bars': use_error_bars}
|
||||
|
||||
def write(self, filepath: pathlib.Path) -> None:
|
||||
# Write HTML page with JSON graph data embedded.
|
||||
template_dir = pathlib.Path(__file__).parent
|
||||
with open(template_dir / 'graph.template.html', 'r') as f:
|
||||
template = f.read()
|
||||
|
||||
contents = template.replace('%JSON_DATA%', self.json)
|
||||
with open(filepath, "w") as f:
|
||||
f.write(contents)
|
||||
583
blender-5.2.0/tests/performance/api/graph.template.html
Normal file
583
blender-5.2.0/tests/performance/api/graph.template.html
Normal file
@@ -0,0 +1,583 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Benchmarks</title>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<style type="text/css">
|
||||
body { margin: 20px; font-size: 16px; color: #333; }
|
||||
a { text-decoration: none; color: #06b; }
|
||||
h2 { color: #222; font-size: 1.4em; }
|
||||
h3 { color: #555; font-size: 1.2em; }
|
||||
h4 { color: #888; font-size: 1.0em; }
|
||||
.nav-tabs { font-size: 1.2em; }
|
||||
.nav-tabs .nav-link { color: #999; }
|
||||
.nav-tabs .nav-link.active { color: #555; font-weight: 500; }
|
||||
.tab-content { margin: 20px 20px; }
|
||||
|
||||
/* Fixed width for header buttons to ensure groups match in size. */
|
||||
.btn-time-range { width: 100px; }
|
||||
.btn-scale-mode { width: 133.33px; /* (100 * 4) / 3 */ }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-error-bars@4.4.5/build/index.umd.min.js" integrity="sha384-pmEzimiQMD67BbR0zE+ss0ugx98UAGIaiNlaKtGz7BBQ3UaT69z1CN323+oCogy0" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
let chart_instances = [];
|
||||
let current_scale_mode = 'linear-clipped';
|
||||
let current_time_range = '4m';
|
||||
let latest_timestamp = 0;
|
||||
|
||||
/* Constants for time calculations. */
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/* Find the latest timestamp across all time-series data to use as "now". */
|
||||
function calculate_latest_timestamp(json_data) {
|
||||
let max_ts = 0;
|
||||
for (const benchmark of json_data) {
|
||||
if (benchmark.chart_type === 'line' && benchmark.data.labels) {
|
||||
for (const ts of benchmark.data.labels) {
|
||||
if (ts && ts > max_ts) {
|
||||
max_ts = ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return max_ts;
|
||||
}
|
||||
|
||||
/* Update the global time filter and refresh all time-series charts. */
|
||||
function set_time_range(range, button) {
|
||||
current_time_range = range;
|
||||
|
||||
/* Update button states. */
|
||||
document.querySelectorAll('.btn-time-range').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.classList.replace('btn-secondary', 'btn-outline-secondary');
|
||||
});
|
||||
if (button) {
|
||||
button.classList.add('active');
|
||||
button.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
} else {
|
||||
const target_btn = document.querySelector(`.btn-time-range[data-range='${range}']`);
|
||||
if (target_btn) {
|
||||
target_btn.classList.add('active');
|
||||
target_btn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
const min_timestamp = get_min_timestamp(range);
|
||||
|
||||
for (const chart of chart_instances) {
|
||||
if (chart.config._is_time_series) {
|
||||
apply_time_range_to_chart(chart, min_timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Calculate the start timestamp for a given time range string. */
|
||||
function get_min_timestamp(range) {
|
||||
if (range === 'all') return 0;
|
||||
if (range === '1m') return latest_timestamp - 30 * DAY_MS;
|
||||
if (range === '4m') return latest_timestamp - 120 * DAY_MS;
|
||||
if (range === '1y') return latest_timestamp - 365 * DAY_MS;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Filter a chart's data points based on the selected time range. */
|
||||
function apply_time_range_to_chart(chart, min_timestamp) {
|
||||
const full_data = chart.config._full_data;
|
||||
|
||||
/* Find indices that match the time range. */
|
||||
const valid_indices = [];
|
||||
for (let i = 0; i < full_data.labels_raw.length; i++) {
|
||||
const timestamp = full_data.labels_raw[i];
|
||||
if (min_timestamp === 0 || timestamp >= min_timestamp) {
|
||||
valid_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reconstruct labels and datasets based on valid indices. */
|
||||
chart.data.labels = valid_indices.map(i => full_data.labels[i]);
|
||||
chart.data.datasets.forEach((ds, ds_index) => {
|
||||
const full_ds_data = full_data.datasets[ds_index].data;
|
||||
ds.data = valid_indices.map(i => full_ds_data[i]);
|
||||
});
|
||||
|
||||
/* Hide the entire section if there's no data in this range. */
|
||||
const section = chart.canvas.closest('section');
|
||||
if (section) {
|
||||
section.style.display = valid_indices.length === 0 ? 'none' : 'block';
|
||||
}
|
||||
|
||||
update_chart_bounds(chart);
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
/* Change the Y-axis scale (linear, log, etc.) for all visible charts. */
|
||||
function set_scale_mode(mode, button) {
|
||||
current_scale_mode = mode;
|
||||
const scale_type = mode === 'logarithmic' ? 'logarithmic' : 'linear';
|
||||
const clip_outliers = mode === 'linear-clipped';
|
||||
|
||||
/* Update button states. */
|
||||
document.querySelectorAll('.btn-scale-mode').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.classList.replace('btn-secondary', 'btn-outline-secondary');
|
||||
});
|
||||
if (button) {
|
||||
button.classList.add('active');
|
||||
button.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
} else {
|
||||
const target_btn = document.querySelector(`.btn-scale-mode[data-mode='${mode}']`);
|
||||
if (target_btn) {
|
||||
target_btn.classList.add('active');
|
||||
target_btn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
/* Only update visible charts immediately for performance. */
|
||||
for (const chart of chart_instances) {
|
||||
const canvas = chart.canvas;
|
||||
const is_visible = canvas.offsetParent !== null;
|
||||
|
||||
if (is_visible) {
|
||||
apply_scale_to_chart(chart, scale_type, clip_outliers, true);
|
||||
} else {
|
||||
/* Mark for deferred update. */
|
||||
chart._needs_scale_update = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Configure a specific chart instance with the selected scale settings. */
|
||||
function apply_scale_to_chart(chart, scale_type, clip_outliers, animate) {
|
||||
chart.options.scales.y.type = scale_type;
|
||||
update_chart_bounds(chart);
|
||||
chart.update(animate ? {
|
||||
duration: scale_type === 'logarithmic' ? 800 : 0,
|
||||
easing: 'easeOutQuart'
|
||||
} : 'none');
|
||||
chart._needs_scale_update = false;
|
||||
}
|
||||
|
||||
/* Calculate robust and absolute maximums for a set of values.
|
||||
* The robust maximum is used to clip outliers, calculated as the 75th percentile
|
||||
* plus 5 times the Interquartile Range (IQR). */
|
||||
function calculate_robust_max(values) {
|
||||
if (values.length === 0) {
|
||||
return { absolute_max: 0, robust_max: 0 };
|
||||
}
|
||||
|
||||
const absolute_max = Math.max(...values);
|
||||
let robust_max = absolute_max;
|
||||
|
||||
if (values.length > 4) {
|
||||
/* Create a copy to sort. */
|
||||
const sorted = Float64Array.from(values).sort();
|
||||
const q1 = sorted[Math.floor(sorted.length * 0.25)];
|
||||
const q3 = sorted[Math.floor(sorted.length * 0.75)];
|
||||
const iqr = q3 - q1;
|
||||
robust_max = q3 + 5 * iqr;
|
||||
}
|
||||
|
||||
return { absolute_max, robust_max };
|
||||
}
|
||||
|
||||
/* Recalculate robust and absolute maximums based on currently visible datasets. */
|
||||
function update_chart_bounds(chart) {
|
||||
let absolute_max = 0;
|
||||
let robust_max = 0;
|
||||
|
||||
chart.data.datasets.forEach((ds, i) => {
|
||||
if (!chart.isDatasetVisible(i)) {
|
||||
return;
|
||||
}
|
||||
/* Filter out null/undefined values. */
|
||||
const ds_values = ds.data.filter(v => v !== null && v !== undefined);
|
||||
const ds_stats = calculate_robust_max(ds_values);
|
||||
|
||||
absolute_max = Math.max(absolute_max, ds_stats.absolute_max);
|
||||
robust_max = Math.max(robust_max, ds_stats.robust_max);
|
||||
});
|
||||
|
||||
chart.config._absolute_max = absolute_max;
|
||||
chart.config._robust_max = robust_max;
|
||||
|
||||
const is_clipped = current_scale_mode === 'linear-clipped';
|
||||
update_chart_max(chart, is_clipped);
|
||||
}
|
||||
|
||||
/* Set a fixed maximum for the Y-axis if outlier clipping is active. */
|
||||
function update_chart_max(chart, clip) {
|
||||
const robust_max = chart.config._robust_max;
|
||||
const absolute_max = chart.config._absolute_max;
|
||||
if (clip && chart.options.scales.y.type === 'linear' && robust_max < absolute_max) {
|
||||
chart.options.scales.y.max = robust_max;
|
||||
} else {
|
||||
chart.options.scales.y.max = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/* Show/hide UI controls like time ranges or scale modes based on the active tab's content. */
|
||||
function update_ui_for_active_tab() {
|
||||
let has_time_series = false;
|
||||
for (const chart of chart_instances) {
|
||||
if (chart.canvas.offsetParent !== null && chart.config._is_time_series) {
|
||||
has_time_series = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const clipped_btn = document.getElementById('linear-clipped');
|
||||
if (has_time_series) {
|
||||
clipped_btn.classList.remove('d-none');
|
||||
} else {
|
||||
clipped_btn.classList.add('d-none');
|
||||
if (current_scale_mode === 'linear-clipped') {
|
||||
set_scale_mode('linear');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Format Y-axis tick labels for memory or large numeric values. */
|
||||
function chart_y_axis_tick_callback(value, index, values, is_memory) {
|
||||
if (is_memory) {
|
||||
return format_memory(value);
|
||||
}
|
||||
if (Math.abs(value) >= 10000) {
|
||||
return value.toExponential(2);
|
||||
}
|
||||
return +(value.toFixed(4));
|
||||
}
|
||||
|
||||
/* Isolate a single dataset when its legend item is clicked, or show all if already isolated. */
|
||||
function chart_legend_click_callback(e, legendItem, legend) {
|
||||
const index = legendItem.datasetIndex;
|
||||
const ci = legend.chart;
|
||||
const is_visible = ci.isDatasetVisible(index);
|
||||
|
||||
/* Check if any other dataset is currently visible. */
|
||||
let other_visible = false;
|
||||
for (let i = 0; i < ci.data.datasets.length; i++) {
|
||||
if (i !== index && ci.isDatasetVisible(i)) {
|
||||
other_visible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!other_visible && is_visible) {
|
||||
/* If this was the only one visible and we clicked it, show all. */
|
||||
for (let i = 0; i < ci.data.datasets.length; i++) {
|
||||
ci.setDatasetVisibility(i, true);
|
||||
}
|
||||
} else {
|
||||
/* Otherwise, isolate this one. */
|
||||
for (let i = 0; i < ci.data.datasets.length; i++) {
|
||||
ci.setDatasetVisibility(i, i === index);
|
||||
}
|
||||
}
|
||||
update_chart_bounds(ci);
|
||||
ci.update();
|
||||
}
|
||||
|
||||
/* Format tooltip labels to include dataset names and formatted values. */
|
||||
function chart_tooltip_label_callback(context, is_memory) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
if (is_memory) {
|
||||
label += format_memory(context.parsed.y);
|
||||
} else {
|
||||
label += context.parsed.y.toFixed(4);
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/* Convert byte values into human-readable strings (MB, GB, etc.). */
|
||||
function format_memory(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/* Main entry point to initialize all benchmarks, create tabs, and render Chart.js instances. */
|
||||
function draw_charts() {
|
||||
/* Global Chart Configuration. */
|
||||
Chart.defaults.plugins.colors.enabled = false;
|
||||
Chart.defaults.animation.duration = 0;
|
||||
Chart.defaults.transitions.active.animation.duration = 400;
|
||||
|
||||
/* Material Design Base Colors (HSL). */
|
||||
const base_colors = [
|
||||
[217, 89, 61], /* Blue */
|
||||
[5, 69, 54], /* Red */
|
||||
[44, 100, 48], /* Yellow */
|
||||
[151, 83, 34], /* Green */
|
||||
[291, 47, 51], /* Purple */
|
||||
[187, 100, 38], /* Cyan */
|
||||
[14, 100, 63], /* Deep Orange */
|
||||
[60, 61, 38], /* Lime */
|
||||
[231, 48, 56], /* Indigo */
|
||||
[340, 82, 66], /* Pink */
|
||||
[174, 42, 51], /* Teal */
|
||||
[336, 78, 43] /* Dark Pink */
|
||||
];
|
||||
|
||||
/* Generate a distinct color for each dataset using a Material Design base palette. */
|
||||
function get_chart_color(index) {
|
||||
const base = base_colors[index % base_colors.length];
|
||||
const iteration = Math.floor(index / base_colors.length);
|
||||
let l = base[2];
|
||||
|
||||
if (iteration > 0) {
|
||||
/* Vary lightness for more datasets: alternates between darkening and lightening. */
|
||||
const offset = (iteration % 2 === 1 ? -15 : 15) * Math.ceil(iteration / 2);
|
||||
l = Math.max(10, Math.min(90, l + offset));
|
||||
}
|
||||
return `hsl(${base[0]}, ${base[1]}%, ${l}%)`;
|
||||
}
|
||||
|
||||
/* This placeholder is replaced by the actual JSON data during generation. */
|
||||
const json_data = %JSON_DATA%;
|
||||
|
||||
const max_ts = calculate_latest_timestamp(json_data);
|
||||
if (max_ts > 0) {
|
||||
latest_timestamp = max_ts;
|
||||
}
|
||||
|
||||
const charts_nav_elem = document.getElementById("charts-nav");
|
||||
const charts_content_elem = document.getElementById("charts-content");
|
||||
|
||||
/* Clear contents. */
|
||||
charts_nav_elem.replaceChildren();
|
||||
charts_content_elem.replaceChildren();
|
||||
|
||||
chart_instances = [];
|
||||
|
||||
/* Prepare UI and charts queue for each device. */
|
||||
for (let i = 0; i < json_data.length; i++) {
|
||||
const benchmark = json_data[i];
|
||||
|
||||
const tab_name = benchmark['name'].split(" ")[0];
|
||||
const tab_id = "benchmark-" + tab_name;
|
||||
let tab_div = document.getElementById(tab_id);
|
||||
|
||||
if (!tab_div) {
|
||||
/* Create tab button. */
|
||||
const li_nav = document.createElement('li');
|
||||
li_nav.className = "nav-item";
|
||||
charts_nav_elem.appendChild(li_nav);
|
||||
|
||||
const button_nav = document.createElement('button');
|
||||
button_nav.id = tab_id + "-tab";
|
||||
button_nav.classList.add("nav-link");
|
||||
button_nav.setAttribute("data-bs-toggle", "tab");
|
||||
button_nav.setAttribute("data-bs-target", "#" + tab_id);
|
||||
button_nav.setAttribute("type", "button");
|
||||
button_nav.setAttribute("role", "tab");
|
||||
button_nav.setAttribute("aria-controls", tab_id);
|
||||
button_nav.textContent = tab_name;
|
||||
li_nav.appendChild(button_nav);
|
||||
|
||||
/* Create chart container div. */
|
||||
tab_div = document.createElement('div');
|
||||
tab_div.id = tab_id;
|
||||
tab_div.classList.add("tab-pane");
|
||||
tab_div.setAttribute("aria-labelledby", button_nav.id);
|
||||
charts_content_elem.appendChild(tab_div);
|
||||
|
||||
if (i === 0) {
|
||||
button_nav.classList.add("active");
|
||||
tab_div.classList.add('show', 'active');
|
||||
}
|
||||
}
|
||||
|
||||
/* Wrap everything in a section for easy hiding. */
|
||||
const section = document.createElement('section');
|
||||
section.className = "benchmark-section mb-5";
|
||||
tab_div.appendChild(section);
|
||||
|
||||
/* Create titles. */
|
||||
const subtitle_h3 = document.createElement('h3');
|
||||
subtitle_h3.textContent = benchmark['name'];
|
||||
section.appendChild(subtitle_h3);
|
||||
|
||||
const subtitle_h4 = document.createElement('h4');
|
||||
subtitle_h4.textContent = benchmark['device'];
|
||||
section.appendChild(subtitle_h4);
|
||||
|
||||
if (benchmark['device_cpu'] && benchmark['device_cpu'] != benchmark['device']) {
|
||||
const subtitle_h4 = document.createElement('h4');
|
||||
subtitle_h4.textContent = benchmark['device_cpu'];
|
||||
section.appendChild(subtitle_h4);
|
||||
}
|
||||
|
||||
/* Create chart container. */
|
||||
const chart_container = document.createElement('div');
|
||||
chart_container.style.position = 'relative';
|
||||
chart_container.style.height = '500px';
|
||||
chart_container.style.width = '100%';
|
||||
section.appendChild(chart_container);
|
||||
|
||||
/* Create chart canvas. */
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = "chart-" + i;
|
||||
chart_container.appendChild(canvas);
|
||||
|
||||
/* Prepare Data. */
|
||||
const chart_data = benchmark['data'];
|
||||
const is_time_series = benchmark['chart_type'] === 'line';
|
||||
const labels_raw = is_time_series ? [...chart_data.labels] : [];
|
||||
|
||||
if (is_time_series) {
|
||||
chart_data.labels = chart_data.labels.map(ts => {
|
||||
if (ts === null) return "";
|
||||
return new Date(ts).toISOString().split('T')[0];
|
||||
});
|
||||
document.getElementById("timeRangeGroup").classList.remove("d-none");
|
||||
}
|
||||
|
||||
/* Assign Colors Manually. */
|
||||
for (let j = 0; j < chart_data.datasets.length; j++) {
|
||||
const color = get_chart_color(j);
|
||||
const ds = chart_data.datasets[j];
|
||||
ds.backgroundColor = color;
|
||||
ds.borderColor = color;
|
||||
ds.borderWidth = 2;
|
||||
ds.pointBackgroundColor = color;
|
||||
ds.pointBorderColor = '#fff';
|
||||
ds.pointRadius = 0;
|
||||
ds.pointHoverRadius = 5;
|
||||
ds.pointHitRadius = 10;
|
||||
ds.hoverBackgroundColor = color;
|
||||
ds.hoverBorderColor = '#fff';
|
||||
ds.hoverBorderWidth = 2;
|
||||
}
|
||||
|
||||
const is_memory = benchmark['name'].toLowerCase().indexOf("memory") !== -1;
|
||||
const use_error_bars = benchmark['use_error_bars']
|
||||
|
||||
/* Draw Chart. */
|
||||
const chart_type = (benchmark['chart_type'] === 'line'? 'line': 'bar') + (use_error_bars ? 'WithErrorBars': '');
|
||||
const chart = new Chart(canvas, {
|
||||
type: chart_type,
|
||||
data: chart_data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
parsing: {
|
||||
xAxisKey: 'x',
|
||||
yAxisKey: 'y'
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 20,
|
||||
callback: (value, index, values) => chart_y_axis_tick_callback(value, index, values, is_memory)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: benchmark['chart_type'] === 'line' ? 'nearest' : 'index',
|
||||
intersect: benchmark['chart_type'] === 'line',
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
onClick: chart_legend_click_callback
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
callbacks: {
|
||||
label: (context) => chart_tooltip_label_callback(context, is_memory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chart.config._is_time_series = is_time_series;
|
||||
/* Deep copy data for filtering. */
|
||||
chart.config._full_data = {
|
||||
labels: [...chart_data.labels],
|
||||
labels_raw: labels_raw,
|
||||
datasets: chart_data.datasets.map(ds => ({ ...ds, data: [...ds.data] }))
|
||||
};
|
||||
|
||||
if (is_time_series) {
|
||||
apply_time_range_to_chart(chart, get_min_timestamp(current_time_range));
|
||||
} else {
|
||||
update_chart_bounds(chart);
|
||||
}
|
||||
|
||||
chart_instances.push(chart);
|
||||
}
|
||||
|
||||
/* Add event listeners to resize and update charts when tabs are shown. */
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tab_el => {
|
||||
tab_el.addEventListener('shown.bs.tab', (event) => {
|
||||
const scale_type = current_scale_mode === 'logarithmic' ? 'logarithmic' : 'linear';
|
||||
const clip_outliers = current_scale_mode === 'linear-clipped';
|
||||
|
||||
for (const chart of chart_instances) {
|
||||
if (chart.canvas.offsetParent !== null) {
|
||||
if (chart._needs_scale_update) {
|
||||
apply_scale_to_chart(chart, scale_type, clip_outliers, false);
|
||||
}
|
||||
chart.resize('none');
|
||||
}
|
||||
}
|
||||
update_ui_for_active_tab();
|
||||
});
|
||||
});
|
||||
|
||||
update_ui_for_active_tab();
|
||||
}
|
||||
|
||||
/* Initialize when DOM is ready. */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
/* Attach UI event listeners. */
|
||||
document.querySelectorAll('.btn-time-range').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => set_time_range(e.target.dataset.range, e.target));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-scale-mode').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => set_scale_mode(e.target.dataset.mode, e.target));
|
||||
});
|
||||
|
||||
draw_charts();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Benchmarks</h1>
|
||||
<div class="d-flex flex-column align-items-end">
|
||||
<div class="btn-group mb-2 d-none" id="timeRangeGroup" role="group" aria-label="Time Range">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-time-range" data-range="1m">1 Month</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm shadow-sm btn-time-range active" data-range="4m">4 Months</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-time-range" data-range="1y">12 Months</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-time-range" data-range="all">All Time</button>
|
||||
</div>
|
||||
<div class="btn-group" role="group" aria-label="Scale Type">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-scale-mode" data-mode="linear">Linear</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm shadow-sm btn-scale-mode active" id="linear-clipped" data-mode="linear-clipped">Linear (Clipped)</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-scale-mode" data-mode="logarithmic">Logarithmic</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs" id="charts-nav" role="tablist">
|
||||
</ul>
|
||||
<div class="tab-content" id="charts-content">
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
blender-5.2.0/tests/performance/api/table.py
Normal file
51
blender-5.2.0/tests/performance/api/table.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
class MarkdownColumn:
|
||||
def __init__(self, name, width=10, is_visible=True, alignment='LEFT'):
|
||||
self.name = name
|
||||
self.width = width
|
||||
self.is_visible = is_visible
|
||||
self.alignment = alignment
|
||||
if len(self.name) > self.width:
|
||||
self.width = len(self.name)
|
||||
|
||||
|
||||
class MarkdownTable:
|
||||
def __init__(self):
|
||||
self.columns = []
|
||||
self.show_header = True
|
||||
|
||||
def add_column(self, *args, **kwargs):
|
||||
self.columns.append(MarkdownColumn(*args, **kwargs))
|
||||
|
||||
def print_header(self):
|
||||
if not self.show_header:
|
||||
return
|
||||
|
||||
values = []
|
||||
lines = []
|
||||
for column in self.columns:
|
||||
if not column.is_visible:
|
||||
continue
|
||||
values.append(f"{column.name:{column.width}}")
|
||||
alignment_char = ':' if column.alignment == 'RIGHT' else '-'
|
||||
lines.append('-' * (column.width - 1) + alignment_char)
|
||||
|
||||
print('| ' + (' | '.join(values)) + ' |')
|
||||
print('| ' + (' | '.join(lines)) + ' |')
|
||||
|
||||
def print_row(self, row_values, end='\n'):
|
||||
values = []
|
||||
for column, value in zip(self.columns, row_values):
|
||||
if not column.is_visible:
|
||||
continue
|
||||
if len(value) > column.width:
|
||||
column.width = len(value)
|
||||
if column.alignment == 'LEFT':
|
||||
values.append(f"{value:<{column.width}}")
|
||||
else:
|
||||
values.append(f"{value:>{column.width}}")
|
||||
|
||||
print("| " + (" | ".join(values)) + " |", end=end, flush=True)
|
||||
118
blender-5.2.0/tests/performance/api/test.py
Normal file
118
blender-5.2.0/tests/performance/api/test.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import abc
|
||||
import fnmatch
|
||||
import typing
|
||||
|
||||
|
||||
class Test:
|
||||
@abc.abstractmethod
|
||||
def name(self) -> str:
|
||||
"""
|
||||
Name of the test.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def category(self) -> str:
|
||||
"""
|
||||
Category of the test.
|
||||
"""
|
||||
|
||||
def use_device(self) -> bool:
|
||||
"""
|
||||
Test uses a specific CPU or GPU device.
|
||||
"""
|
||||
return False
|
||||
|
||||
def supported_device_types(self) -> typing.List[str]:
|
||||
"""
|
||||
Supported device types when using multiple devices.
|
||||
"""
|
||||
return ['CPU']
|
||||
|
||||
def use_background(self) -> bool:
|
||||
"""
|
||||
Test runs in background mode and requires no display.
|
||||
"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def blender_gpu_arguments(device_id: str, gpu_backend: str) -> list:
|
||||
"""
|
||||
Return GPU arguments for blender.
|
||||
|
||||
Always includes --gpu-backend and optional include --gpu-device when device_id isn't
|
||||
default (0 or missing).
|
||||
"""
|
||||
args = ['--gpu-backend', gpu_backend]
|
||||
if '_' in device_id:
|
||||
parts = device_id.rsplit('_', 1)
|
||||
device_index = int(parts[1])
|
||||
# Only specify --gpu-device for non-zero indices. Older builds could not support it.
|
||||
if device_index > 0:
|
||||
args += ['--gpu-device', str(device_index)]
|
||||
return args
|
||||
|
||||
@abc.abstractmethod
|
||||
def run(self, env, device_id: str, gpu_backend: str) -> dict:
|
||||
"""
|
||||
Execute the test and report results.
|
||||
"""
|
||||
|
||||
|
||||
class TestCollection:
|
||||
def __init__(self, env, names_filter: list = ['*'], categories_filter: list = ['*'], background: bool = False):
|
||||
import importlib
|
||||
import pkgutil
|
||||
import tests
|
||||
|
||||
self.tests = []
|
||||
|
||||
# Find and import all Python files in the tests folder, and generate
|
||||
# the list of tests for each.
|
||||
for _, modname, _ in pkgutil.iter_modules(tests.__path__, 'tests.'):
|
||||
module = importlib.import_module(modname)
|
||||
tests = module.generate(env)
|
||||
|
||||
for test in tests:
|
||||
if background and not test.use_background():
|
||||
continue
|
||||
|
||||
test_category = test.category()
|
||||
found = False
|
||||
for category_filter in categories_filter:
|
||||
if fnmatch.fnmatch(test_category, category_filter):
|
||||
found = True
|
||||
if not found:
|
||||
continue
|
||||
|
||||
test_name = test.name()
|
||||
|
||||
included = False
|
||||
excluded = False
|
||||
|
||||
for name_filter in names_filter:
|
||||
is_exclusion = name_filter.startswith('!')
|
||||
pattern = name_filter[1:] if is_exclusion else name_filter
|
||||
|
||||
if fnmatch.fnmatch(test_name, pattern):
|
||||
if is_exclusion:
|
||||
excluded = True
|
||||
break
|
||||
else:
|
||||
included = True
|
||||
|
||||
if not included or excluded:
|
||||
continue
|
||||
|
||||
self.tests.append(test)
|
||||
|
||||
def find(self, test_name: str, test_category: str):
|
||||
# Find a test based on name and category.
|
||||
for test in self.tests:
|
||||
if test.name() == test_name and test.category() == test_category:
|
||||
return test
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user